Skip to content

Commit

The model proxy holds each run to its cost cap itself: a run's session carries its cap, a 402 at the cap, and a run token reaches only the message routes (integrations 0006)

syntaqxcommitted Parentd9ea97dBrowse files
16 files+616−290/16 viewed
+8−0
275275 spend the same way it reports it for billing and stops the agent once
276276 the spend reaches the cap. The step in flight when it does can take the
277277 run a little past it.
278+- g1t's model proxy holds the run to the same cap, whatever happens in the
279+ sandbox. It adds up what each of the run's model answers cost, and once
280+ the run has spent its cap it refuses the run's model requests with
281+ `402` and the error code `run_cap_reached`, which shows in the run's log.
282+ The proxy also takes at most 16 of a run's model requests at a time,
283+ and a run's model token reaches only `/v1/messages` (with
284+ `/v1/messages/count_tokens`) and `/v1/models`. The token stops working
285+ when the run ends.
278286 - The time cap is enforced twice: the harness stops the agent when it
279287 passes, and the sandbox itself is stopped three minutes after, whatever
280288 is running in it.
+19−0
464464 /// gateway's own token, sent as `cf-aig-authorization`.
465465 #[serde(default)]
466466 pub gateway_token: Option<String>,
467+ /// The most the run may spend on models, in millionths of a dollar: the
468+ /// lower of its project's cost cap and its plan's. The proxy refuses
469+ /// the run's requests once it has spent this. `None` until the sandbox
470+ /// sets it (`cap_model_sessions`), and for the AI Gateway.
471+ #[serde(default)]
472+ pub cap_micros: Option<i64>,
467473 }
468474
469475 // --- Methods -----------------------------------------------------------------
722728 pub token_hashes: Vec<String>,
723729 }
724730
731+/// `cap_model_sessions`: sets the most the runs whose model tokens hash to
732+/// these (SHA-256, lowercase hex) may spend on models, in millionths of a
733+/// dollar, which the model proxy holds them to. The sandbox sets it once
734+/// it knows the run's guardrails and plan; zero or less clears it. Returns
735+/// how many open sessions it set.
736+#[derive(Debug, Serialize, Deserialize)]
737+pub struct CapModelSessionsArgs {
738+ #[serde(alias = "tokenHashes")]
739+ pub token_hashes: Vec<String>,
740+ #[serde(alias = "capMicros")]
741+ pub cap_micros: i64,
742+}
743+
725744 /// `model_provider`: the workspace's own model connection, if it has one.
726745 /// Returns `Option<Connection>`.
727746 #[derive(Debug, Serialize, Deserialize)]
+1−0
755755 closeModelSessions: (tokenHashes) => call("close_model_sessions", { token_hashes: tokenHashes }),
756756 routes: (workspace, viewer) => call("routes", { workspace, viewer }),
757757 setRoutes: (actor, workspace, routes) => call("set_routes", { actor, workspace, routes }),
758+ capModelSessions: (tokenHashes, capMicros) => call("cap_model_sessions", { token_hashes: tokenHashes, cap_micros: capMicros }),
758759 };
759760 }
760761
+12−0
203203 authHeader: string | null;
204204 /** For an endpoint behind an authenticated Cloudflare AI Gateway: its token. */
205205 gatewayToken?: string | null;
206+ /**
207+ * The most the run may spend on models, in millionths of a dollar: the
208+ * lower of its project's cost cap and its plan's. The proxy refuses the
209+ * run's requests once it has spent this. Null until the sandbox sets it.
210+ */
211+ capMicros?: number | null;
206212 };
207213
208214 export type ConnectInput = {
265271 * how many were open.
266272 */
267273 closeModelSessions(tokenHashes: string[]): Promise<number>;
274+ /**
275+ * Sets the most the runs whose model tokens hash to these may spend on
276+ * models, in millionths of a dollar, which the model proxy holds them
277+ * to. Zero clears it. Returns how many open sessions it set.
278+ */
279+ capModelSessions(tokenHashes: string[], capMicros: number): Promise<number>;
268280 }
269281
270282 /** What each provider is for, as people choose between them. */
+5−0
1+-- The most a run may spend on models, in millionths of a dollar: the lower
2+-- of its project's cost cap and its plan's, set by the sandbox once it
3+-- knows them. The model proxy refuses the run's requests past it. Null
4+-- until set; the proxy then holds the run to the most any run may cost.
5+ALTER TABLE model_sessions ADD COLUMN cap_micros INTEGER;
+32−0
159159 tier: Option<String>,
160160 #[serde(default)]
161161 requested_by: Option<String>,
162+ #[serde(default)]
163+ cap_micros: Option<i64>,
164+}
165+
166+/// A run's model spend cap as a session keeps it: null for none (zero or
167+/// less), and never more than $1,000, which no run is allowed. D1 takes
168+/// numbers as doubles, which hold any such cap exactly.
169+fn session_cap(cap_micros: i64) -> JsValue {
170+ if cap_micros > 0 { JsValue::from_f64(cap_micros.min(1_000_000_000) as f64) } else { JsValue::NULL }
162171 }
163172
164173 #[derive(Deserialize)]
13241333 api_key: None,
13251334 auth_header: None,
13261335 gateway_token: None,
1336+ cap_micros: session.cap_micros.filter(|cap| *cap > 0),
13271337 };
13281338 let Some(connection_id) = session.connection_id else {
13291339 return Ok(Some(base));
13811391 api_key: secrets.secret.clone(),
13821392 auth_header: Some(models::auth_header(provider, &config)),
13831393 gateway_token: (provider == Provider::AnthropicEndpoint).then_some(secrets.signing_secret).flatten(),
1394+ cap_micros: None,
13841395 }))
13851396 }
13861397
14181429 Ok(result.meta()?.and_then(|meta| meta.changes).unwrap_or(0) as u32)
14191430 }
14201431
1432+ /// Sets the most a run may spend on models, which the model proxy holds
1433+ /// its token to. The sandbox calls it once it knows the run's caps.
1434+ async fn cap_model_sessions(&self, a: CapModelSessionsArgs) -> Result<u32> {
1435+ let hashes = closable_hashes(&a.token_hashes);
1436+ if hashes.is_empty() {
1437+ return Ok(0);
1438+ }
1439+ let marks = vec!["?"; hashes.len()].join(", ");
1440+ let mut values: Vec<JsValue> = vec![session_cap(a.cap_micros)];
1441+ values.extend(hashes.iter().map(|hash| JsValue::from(hash.as_str())));
1442+ values.push(rfc3339(now_ms()).into());
1443+ let result = self
1444+ .db
1445+ .prepare(format!("UPDATE model_sessions SET cap_micros = ? WHERE token_hash IN ({marks}) AND expires_at > ?"))
1446+ .bind(&values)?
1447+ .run()
1448+ .await?;
1449+ Ok(result.meta()?.and_then(|meta| meta.changes).unwrap_or(0) as u32)
1450+ }
1451+
14211452 // --- Writing back -----------------------------------------------------------
14221453
14231454 async fn on_event(&self, event: &Event) -> Result<()> {
15691600 "gateway_upstream" => reply(&service.gateway_upstream(args(body)?).await?),
15701601 "gateway_providers" => reply(&service.gateway_providers(args(body)?).await?),
15711602 "close_model_sessions" => reply(&service.close_model_sessions(args(body)?).await?),
1603+ "cap_model_sessions" => reply(&service.cap_model_sessions(args(body)?).await?),
15721604 "routes" => reply(&service.routes(args(body)?).await?),
15731605 "set_routes" => reply(&service.set_routes(args(body)?).await?),
15741606 _ => Response::error("Unknown method", 404),
+89−26
1515 * it passes and reported to billing afterwards, counted per run for usage
1616 * views.
1717 *
18+ * Each run is held to its cost cap here too, not only by the harness in the
19+ * sandbox: every answer's cost is added to the run's count (a Durable
20+ * Object per run, `run-spend.ts`), and once the run has spent its cap its
21+ * requests are refused with a 402 (`spend.ts`).
22+ *
1823 * The same address is the AI Gateway for a workspace's own code: a request
1924 * with one of the workspace's access tokens (`g1t_…`) instead of a run's,
2025 * at `/anthropic` in Anthropic's format or `/openai/v1` in OpenAI's, goes
3742
3843 import { openaiError } from "./chat";
3944 import { discover } from "./discover";
45+import { anthropicErrorType } from "./gateway";
4046 import { type AnthropicRequest, StreamTranslator, errorFromChat, estimateTokens, fromChat, toChat } from "./openai";
41−import { isAnswer, tokenReport } from "./report";
47+import { isAnswer, runMayCall, tokenReport } from "./report";
4248 import { type HostedRouting, presentedToken, upstreamRequest } from "./route";
49+import type { RunSpend } from "./run-spend";
4350 import { type GatewayDeps, isOpenAiPath, serveGateway } from "./serve";
51+import { capOf, capReached, ceilingMicros, chargeFor, pricesFor, tooBusy } from "./spend";
4452 import { measure } from "./usage";
4553
54+export { RunSpend } from "./run-spend";
55+
4656 interface Env extends HostedRouting {
4757 INTEGRATIONS: ServiceBinding;
4858 BILLING: ServiceBinding;
4959 IDENTITY: ServiceBinding;
60+ /** Each run's model spend, one object per model session. */
61+ RUN_SPEND: DurableObjectNamespace<RunSpend>;
5062 }
5163
5264 /**
6981
7082 /** An error in the shape Anthropic's API uses, which the harness understands. */
7183 function refuse(status: number, message: string): Response {
72− return Response.json(
73− { type: "error", error: { type: status === 401 ? "authentication_error" : "not_found_error", message } },
74− { status },
75− );
84+ return Response.json({ type: "error", error: { type: anthropicErrorType(status), message } }, { status });
85+}
86+
87+/** The run's spend count, by its session's id. */
88+function runSpend(env: Env, upstream: ModelUpstream): DurableObjectStub<RunSpend> {
89+ return env.RUN_SPEND.get(env.RUN_SPEND.idFromName(upstream.session || `${upstream.workspace}/${upstream.repo}#${upstream.number}`));
7690 }
7791
92+/** One answer's place in its run's count, settled once the answer has gone by. */
93+type Held = { spend: DurableObjectStub<RunSpend>; ticket: string; requested: Partial<AnthropicRequest> | null; bodyLength: number };
94+
7895 /**
79− * Passes an answer through and, once it has all gone by, tells billing what
80− * it used. Reporting happens after the answer, and a report that fails is
81− * dropped: the answer never waits on it or breaks for it.
96+ * Passes an answer through and, once it has all gone by, adds its cost to
97+ * the run's count and tells billing what it used. Both happen after the
98+ * answer, and a report that fails is dropped: the answer never waits on
99+ * it or breaks for it.
82100 */
83−function counted(answer: Response, upstream: ModelUpstream, env: Env, ctx: ExecutionContext): Response {
101+function counted(answer: Response, upstream: ModelUpstream, env: Env, ctx: ExecutionContext, held: Held): Response {
84102 const { response, tokens, model } = measure(answer);
85103 ctx.waitUntil(
86104 (async () => {
87− const report = tokenReport(upstream, await model, await tokens);
88− if (report) await billingClient(env.BILLING).recordTokens(report);
105+ const used = await tokens;
106+ const answeredBy = await model;
107+ const settle = (async () => {
108+ const prices = pricesFor(upstream.model ?? answeredBy ?? held.requested?.model, upstream.route, await offered(env).catch(() => []));
109+ const charge = chargeFor(prices, used, answer.ok, ceilingMicros(prices, held.bodyLength, held.requested?.max_tokens));
110+ await held.spend.settle(held.ticket, charge);
111+ })().catch((error: unknown) => console.error("models: a run's spend was not counted", upstream.session, String(error)));
112+ const report = tokenReport(upstream, answeredBy, used);
113+ const reported = report ? billingClient(env.BILLING).recordTokens(report).catch(() => undefined) : Promise.resolve();
114+ await Promise.all([settle, reported]);
89115 })().catch(() => undefined),
90116 );
91117 return response;
92118 }
93119
120+/** The fields of a request body the proxy reads, or null when it is not a JSON object. */
121+function parsedBody(text: string): Record<string, unknown> | null {
122+ try {
123+ const parsed = JSON.parse(text) as unknown;
124+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
125+ } catch {
126+ return null;
127+ }
128+}
129+
94130 /** Sends an Anthropic request to a provider that speaks OpenAI's API. */
95−async function viaChat(upstream: ModelUpstream, path: string, request: Request): Promise<Response> {
96− const body = (await request.json()) as AnthropicRequest;
131+async function viaChat(upstream: ModelUpstream, path: string, body: AnthropicRequest | null): Promise<Response> {
132+ if (!path.startsWith("/v1/messages")) return refuse(404, `${path} has no counterpart at this provider.`);
133+ if (!body) return refuse(400, "The request body is not a JSON object.");
97134 const model = upstream.model ?? body.model ?? "";
98135 if (path.startsWith("/v1/messages/count_tokens")) {
99136 return Response.json({ input_tokens: estimateTokens(body) });
100137 }
101− if (!path.startsWith("/v1/messages")) return refuse(404, `${path} has no counterpart at this provider.`);
102138
103139 const headers = new Headers({ "content-type": "application/json" });
104140 if (upstream.gatewayToken) headers.set("cf-aig-authorization", `Bearer ${upstream.gatewayToken}`);
237273 const upstream = await lookUp(env, token);
238274 if (!upstream) return refuse(401, "This run's model token has expired, or its model connection was removed.");
239275
240− const path = url.pathname.slice("/anthropic".length) + url.search;
276+ const route = url.pathname.slice("/anthropic".length);
277+ const path = route + url.search;
278+ if (!runMayCall(route, request.method)) return refuse(404, `A run's model token reaches only /anthropic/v1/messages and /anthropic/v1/models, not ${route}.`);
279+ const hasBody = request.method !== "GET" && request.method !== "HEAD";
280+ const text = hasBody ? await request.text() : null;
281+ const parsed = text === null ? null : parsedBody(text);
282+
283+ // A model's answer costs the run: it must be under its cap to start
284+ // one, and the answer's cost is added to its count once it has gone by.
285+ let held: Held | null = null;
286+ if (isAnswer(route) && hasBody) {
287+ const spend = runSpend(env, upstream);
288+ const cap = capOf(upstream);
289+ let admitted;
290+ try {
291+ admitted = await spend.admit(cap);
292+ } catch (error) {
293+ console.error("models: a run's spend could not be checked", upstream.session, String(error));
294+ return refuse(503, "g1t could not check this run's spending just now. Try again.");
295+ }
296+ if (!admitted.ok) return admitted.reason === "cap" ? capReached(cap, admitted.spent) : tooBusy();
297+ held = { spend, ticket: admitted.ticket, requested: parsed as Partial<AnthropicRequest> | null, bodyLength: text?.length ?? 0 };
298+ }
241299 // Both routes answer in Anthropic's shape, so one reading counts either.
242− const answer = (response: Response) => (isAnswer(url.pathname.slice("/anthropic".length)) ? counted(response, upstream, env, ctx) : response);
243− if (upstream.api === "openai") return answer(await viaChat(upstream, path, request));
300+ const answer = (response: Response) => (held ? counted(response, upstream, env, ctx, held) : response);
301+ try {
302+ if (upstream.api === "openai") return answer(await viaChat(upstream, path, parsed as AnthropicRequest | null));
244303
245− const { url: target, headers } = upstreamRequest(upstream, env, path, request.headers);
246− // A route that names a model gets it for every request of the run,
247− // including the harness's small background ones.
248− let body: BodyInit | null = request.method === "GET" || request.method === "HEAD" ? null : request.body;
249− if (upstream.model && body && path.startsWith("/v1/messages")) {
250− const parsed = (await request.json()) as Record<string, unknown>;
251− body = JSON.stringify({ ...parsed, model: upstream.model });
252− headers.delete("content-length");
304+ const { url: target, headers } = upstreamRequest(upstream, env, path, request.headers);
305+ // A route that names a model gets it for every request of the run,
306+ // including the harness's small background ones.
307+ let body: string | null = text;
308+ if (upstream.model && parsed && path.startsWith("/v1/messages")) {
309+ body = JSON.stringify({ ...parsed, model: upstream.model });
310+ headers.delete("content-length");
311+ }
312+ return answer(await fetch(target, { method: request.method, headers, body }));
313+ } catch (error) {
314+ // No answer: it cost nothing, and gives its place back.
315+ if (held) ctx.waitUntil(held.spend.settle(held.ticket, 0).catch(() => undefined));
316+ throw error;
253317 }
254− return answer(await fetch(target, { method: request.method, headers, body }));
255318 },
256319 } satisfies ExportedHandler<Env>;
+14−1
33
44 import type { ModelUpstream } from "@g1t/contracts";
55
6−import { isAnswer, tokenReport } from "./report.ts";
6+import { isAnswer, runMayCall, tokenReport } from "./report.ts";
77 import { NO_TOKENS, measure } from "./usage.ts";
88
99 const upstream: ModelUpstream = {
6262 await whole.response.text();
6363 assert.equal(await whole.model, "claude-y");
6464 });
65+
66+test("a run's token reaches answers, token counts and the model list, and nothing else", () => {
67+ assert.ok(runMayCall("/v1/messages", "POST"));
68+ assert.ok(runMayCall("/v1/messages?beta=true", "POST"));
69+ assert.ok(runMayCall("/v1/messages/count_tokens", "POST"));
70+ assert.ok(runMayCall("/v1/models", "GET"));
71+ assert.ok(runMayCall("/v1/models/claude-sonnet-5-5", "GET"));
72+ assert.equal(runMayCall("/v1/messages/batches", "POST"), false);
73+ assert.equal(runMayCall("/v1//messages", "POST"), false);
74+ assert.equal(runMayCall("/v1/complete", "POST"), false);
75+ assert.equal(runMayCall("/v1/models", "POST"), false);
76+ assert.equal(runMayCall("/v1/files", "GET"), false);
77+});
+12−0
2828 }
2929
3030 /**
31+ * Whether a run's token may make a request: a model's answer, counting a
32+ * request's tokens, or listing models. Nothing else, so no other route
33+ * (a batch of messages, say) spends past the run's cap uncounted.
34+ */
35+export function runMayCall(path: string, method: string): boolean {
36+ const route = path.split("?")[0];
37+ if (isAnswer(route)) return true;
38+ if (/^\/v1\/messages\/count_tokens\/?$/.test(route)) return true;
39+ return (method === "GET" || method === "HEAD") && /^\/v1\/models(\/[^/]+)?\/?$/.test(route);
40+}
41+
42+/**
3143 * The report for one answer, or null when it used nothing or its session
3244 * has no id to count it under. The model is the run's when its route names
3345 * one, else the one that answered.
+54−0
1+/**
2+ * One run's model spend, as a Durable Object named by its model session.
3+ *
4+ * Why an object per run: the count must agree across every isolate the
5+ * run's requests reach, at once, or an agent that sends many requests in
6+ * parallel spends the cap once per isolate. A per-isolate count written
7+ * back now and then has exactly that hole, and the proxy has no database
8+ * of its own to count in (billing's token count is written after each
9+ * answer, for usage views, and read nowhere near this fast). One object
10+ * per run is the natural unit: a run is one model session, its object sees
11+ * every one of its requests in order, and it costs two short requests per
12+ * model answer (admit and settle) plus one storage write, far below what
13+ * the answer itself costs. The object forgets the run a while after its
14+ * session has lapsed.
15+ */
16+import { DurableObject } from "cloudflare:workers";
17+
18+import { type Admission, SpendTally } from "./spend.ts";
19+
20+/** How long after its first answer a run's count is kept: longer than a model session lives (3 hours). */
21+const FORGET_MS = 4 * 60 * 60_000;
22+
23+export class RunSpend extends DurableObject<object> {
24+ private tally = new SpendTally();
25+
26+ constructor(ctx: DurableObjectState, env: object) {
27+ super(ctx, env);
28+ void ctx.blockConcurrencyWhile(async () => {
29+ this.tally = new SpendTally((await ctx.storage.get<number>("spent")) ?? 0);
30+ });
31+ }
32+
33+ /** Whether the run may start another answer under `cap`, in millionths of a dollar. */
34+ async admit(cap: number): Promise<Admission> {
35+ const admission = this.tally.admit(cap, Date.now());
36+ if (admission.ok && admission.spent === 0 && (await this.ctx.storage.getAlarm()) === null) {
37+ await this.ctx.storage.setAlarm(Date.now() + FORGET_MS);
38+ }
39+ return admission;
40+ }
41+
42+ /** An answer has ended, costing `micros`. Returns what the run has spent. */
43+ async settle(ticket: string, micros: number): Promise<number> {
44+ const before = this.tally.spent;
45+ const spent = this.tally.settle(ticket, micros);
46+ if (spent !== before) await this.ctx.storage.put("spent", spent);
47+ return spent;
48+ }
49+
50+ override async alarm(): Promise<void> {
51+ await this.ctx.storage.deleteAll();
52+ this.tally = new SpendTally();
53+ }
54+}
+128−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { GatewayModel } from "@g1t/contracts";
5+
6+import {
7+ BACKSTOP_CAP_MICROS,
8+ FRONTIER_PRICES,
9+ HOLD_MS,
10+ MAX_IN_FLIGHT,
11+ STANDARD_PRICES,
12+ SpendTally,
13+ capOf,
14+ capReached,
15+ ceilingMicros,
16+ chargeFor,
17+ costMicros,
18+ pricesFor,
19+ tooBusy,
20+} from "./spend.ts";
21+import { NO_TOKENS } from "./usage.ts";
22+
23+function model(fields: Partial<GatewayModel> & { model: string }): GatewayModel {
24+ return { name: fields.model, provider: "anthropic", inputMicros: 0, outputMicros: 0, cacheReadMicros: 0, cacheWriteMicros: 0, ...fields };
25+}
26+
27+const sonnet = model({ model: "claude-sonnet-5-5", inputMicros: 3_000_000, outputMicros: 15_000_000, cacheReadMicros: 300_000, cacheWriteMicros: 3_750_000, cacheWrite1hMicros: 6_000_000 });
28+const haiku = model({ model: "claude-haiku-5", inputMicros: 1_000_000, outputMicros: 5_000_000, cacheReadMicros: 100_000, cacheWriteMicros: 1_250_000 });
29+const embed = model({ model: "@cf/baai/bge-m3", provider: "workers-ai", kind: "embeddings", inputMicros: 12_000, outputMicros: 900_000_000 });
30+const offered = [haiku, sonnet, embed];
31+
32+test("a run's cap is the one its sandbox set, else the most any run may cost", () => {
33+ assert.equal(capOf({ capMicros: 2_000_000 }), 2_000_000);
34+ assert.equal(capOf({ capMicros: null }), BACKSTOP_CAP_MICROS);
35+ assert.equal(capOf({}), BACKSTOP_CAP_MICROS);
36+ assert.equal(capOf({ capMicros: 0 }), BACKSTOP_CAP_MICROS);
37+});
38+
39+test("an answer is priced by its model in the catalogue, dated ids and provider prefixes included", () => {
40+ assert.equal(pricesFor("claude-sonnet-5-5", "g1t", offered), sonnet);
41+ assert.equal(pricesFor("claude-sonnet-5-5-20260901", "g1t", offered), sonnet);
42+ assert.equal(pricesFor("anthropic/claude-haiku-5", "anthropic", offered), haiku);
43+});
44+
45+test("a model missing from the catalogue is priced high on g1t's models, at standard prices on the workspace's own", () => {
46+ assert.equal(pricesFor("mystery", "g1t", offered), FRONTIER_PRICES);
47+ assert.equal(pricesFor(null, "g1t", []), FRONTIER_PRICES);
48+ const dearer = model({ model: "claude-opus-9", inputMicros: 20_000_000, outputMicros: 100_000_000 });
49+ assert.equal(pricesFor("mystery", "g1t", [...offered, dearer]), dearer);
50+ assert.equal(pricesFor("llama-local", "endpoint", offered), STANDARD_PRICES);
51+});
52+
53+test("cost is billing's sum: each kind of token at its price, rounded up", () => {
54+ // 1,000 in at $3, 500 out at $15, 10,000 cache reads at $0.30, 2,000 writes (500 of them hour-long).
55+ const tokens = { input: 1_000, output: 500, cacheRead: 10_000, cacheWrite: 2_000, cacheWrite1h: 500 };
56+ assert.equal(costMicros(sonnet, tokens), 3_000 + 7_500 + 3_000 + 5_625 + 3_000);
57+ assert.equal(costMicros(sonnet, { ...NO_TOKENS, input: 1 }), 3);
58+ assert.equal(costMicros(haiku, { ...NO_TOKENS, input: 1 }), 1);
59+ // No hour-long price: those writes cost what five-minute ones do.
60+ assert.equal(costMicros(haiku, { ...NO_TOKENS, cacheWrite: 1_000, cacheWrite1h: 1_000 }), 1_250);
61+});
62+
63+test("a prompt past the model's threshold puts the whole request at the higher prices", () => {
64+ const long = model({ model: "long", inputMicros: 1_000_000, outputMicros: 2_000_000, threshold: 100, overInputMicros: 2_000_000, overOutputMicros: 4_000_000 });
65+ assert.equal(costMicros(long, { ...NO_TOKENS, input: 100, output: 1_000 }), 100 + 2_000);
66+ assert.equal(costMicros(long, { ...NO_TOKENS, input: 101, output: 1_000 }), 202 + 4_000);
67+});
68+
69+test("an answer is charged its cost, nothing when refused, and its ceiling when its usage could not be read", () => {
70+ const ceiling = ceilingMicros(sonnet, 4_000, 1_000);
71+ assert.equal(ceiling, 3_000 + 15_000);
72+ assert.equal(ceilingMicros(sonnet, 0, undefined), 32_000 * 15);
73+ assert.equal(chargeFor(sonnet, { ...NO_TOKENS, output: 100 }, true, ceiling), 1_500);
74+ assert.equal(chargeFor(sonnet, NO_TOKENS, true, ceiling), ceiling);
75+ assert.equal(chargeFor(sonnet, NO_TOKENS, false, ceiling), 0);
76+});
77+
78+test("a run under its cap is admitted, and refused once it has spent it", () => {
79+ const tally = new SpendTally();
80+ const first = tally.admit(10_000, 0);
81+ assert.ok(first.ok);
82+ assert.equal(tally.settle(first.ticket, 9_999), 9_999);
83+ const second = tally.admit(10_000, 1);
84+ assert.ok(second.ok);
85+ // The answer in flight takes it past; the next is refused.
86+ tally.settle(second.ticket, 5_000);
87+ assert.deepEqual(tally.admit(10_000, 2), { ok: false, reason: "cap", spent: 14_999 });
88+});
89+
90+test("many requests at once are bounded, and an answer never settled gives its place back in time", () => {
91+ const tally = new SpendTally();
92+ const tickets: string[] = [];
93+ for (let i = 0; i < MAX_IN_FLIGHT; i++) {
94+ const admitted = tally.admit(1_000_000, 0);
95+ assert.ok(admitted.ok);
96+ tickets.push(admitted.ticket);
97+ }
98+ assert.deepEqual(tally.admit(1_000_000, 0), { ok: false, reason: "busy", spent: 0 });
99+ tally.settle(tickets[0], 0);
100+ assert.ok(tally.admit(1_000_000, 1).ok);
101+ assert.equal(tally.admit(1_000_000, 1).ok, false);
102+ assert.ok(tally.admit(1_000_000, HOLD_MS + 1).ok);
103+ // Those from the start have lapsed; the one from a moment later has not.
104+ assert.equal(tally.inFlight, 2);
105+});
106+
107+test("a tally resumes from what was stored, and settling twice or with nonsense adds nothing", () => {
108+ const tally = new SpendTally(500);
109+ const admitted = tally.admit(1_000, 0);
110+ assert.ok(admitted.ok);
111+ tally.settle(admitted.ticket, Number.NaN);
112+ tally.settle(admitted.ticket, -5);
113+ assert.equal(tally.spent, 500);
114+ assert.equal(new SpendTally(-1).spent, 0);
115+});
116+
117+test("a run past its cap is told so in Anthropic's error shape, with a code", async () => {
118+ const response = capReached(2_000_000, 2_031_000);
119+ assert.equal(response.status, 402);
120+ const body = (await response.json()) as { type: string; error: { type: string; code: string; message: string } };
121+ assert.equal(body.type, "error");
122+ assert.equal(body.error.type, "billing_error");
123+ assert.equal(body.error.code, "run_cap_reached");
124+ assert.match(body.error.message, /cost cap of \$2\.00 \(it has spent \$2\.03/);
125+ const busy = tooBusy();
126+ assert.equal(busy.status, 429);
127+ assert.equal(busy.headers.get("retry-after"), "2");
128+});
+196−0
1+/**
2+ * A run's model spend, held to its cost cap by the proxy itself.
3+ *
4+ * The harness stops the agent at the run's cap (`--max-budget-usd`), but
5+ * that is the sandbox's own word: an agent talked into calling the proxy
6+ * directly would never be stopped by it. So the proxy counts what each of
7+ * the run's answers cost and refuses its requests once the run has spent
8+ * its cap, with a 402 the harness shows as the error it is.
9+ *
10+ * The count lives in one Durable Object per run (`run-spend.ts`): a run's
11+ * requests land on many isolates, and an agent can send many at once, so a
12+ * per-isolate count would let each isolate spend the cap again. This file
13+ * is the counting and pricing alone, without the worker, for tests.
14+ */
15+import type { GatewayModel, ModelUpstream } from "@g1t/contracts";
16+
17+import { anthropicError } from "./gateway.ts";
18+import { type Tokens, total } from "./usage.ts";
19+
20+/** A model's prices per million tokens, in millionths of a dollar. */
21+export type Prices = Omit<GatewayModel, "model" | "name" | "provider" | "kind">;
22+
23+/**
24+ * The cap of a run whose sandbox never set one (its guardrails and its plan
25+ * both said none, or setting it failed): the most any run may be allowed
26+ * to cost (`MAX_BUDGET_USD` in crates/contracts/src/guardrails.rs).
27+ */
28+export const BACKSTOP_CAP_MICROS = 100_000_000;
29+
30+/**
31+ * The most of a run's answers in flight at once. The cap is checked as a
32+ * request starts and an answer's cost is known only when it ends, so the
33+ * answers already in flight when the run reaches its cap can take it past
34+ * by their cost; this bounds how many there can be. The harness runs a
35+ * handful at a time, subagents included.
36+ */
37+export const MAX_IN_FLIGHT = 16;
38+
39+/**
40+ * How long an answer in flight is waited for before it no longer counts
41+ * against `MAX_IN_FLIGHT`: one whose cost was never settled (the proxy's
42+ * isolate went away mid-answer) must not hold a place for good.
43+ */
44+export const HOLD_MS = 15 * 60_000;
45+
46+/** The output an answer is assumed to reach when its request names no `max_tokens`. */
47+const DEFAULT_MAX_TOKENS = 32_000;
48+
49+/**
50+ * Prices for a model g1t has none for. On g1t's models, a model missing
51+ * from the catalogue is charged at a frontier model's list prices, so the
52+ * cap errs on the side of stopping early. On the workspace's own provider
53+ * (an endpoint whose model g1t does not list), at a standard model's, so
54+ * an inexpensive model is not stopped far short of its cap; the harness,
55+ * which knows no better, counts such a model much the same way.
56+ */
57+export const FRONTIER_PRICES: Prices = {
58+ inputMicros: 15_000_000,
59+ outputMicros: 75_000_000,
60+ cacheReadMicros: 1_500_000,
61+ cacheWriteMicros: 18_750_000,
62+ cacheWrite1hMicros: 30_000_000,
63+};
64+export const STANDARD_PRICES: Prices = {
65+ inputMicros: 3_000_000,
66+ outputMicros: 15_000_000,
67+ cacheReadMicros: 300_000,
68+ cacheWriteMicros: 3_750_000,
69+ cacheWrite1hMicros: 6_000_000,
70+};
71+
72+/** The run's cap, in millionths of a dollar. */
73+export function capOf(upstream: Pick<ModelUpstream, "capMicros">): number {
74+ const cap = upstream.capMicros;
75+ return typeof cap === "number" && Number.isFinite(cap) && cap > 0 ? cap : BACKSTOP_CAP_MICROS;
76+}
77+
78+/**
79+ * The prices an answer is charged at: its model's in g1t's catalogue (by
80+ * its id, or the catalogue id a dated id starts with), else the most
81+ * expensive chat model's on g1t's models, else the fallbacks above.
82+ */
83+export function pricesFor(model: unknown, route: ModelUpstream["route"], offered: GatewayModel[]): Prices {
84+ const id = (typeof model === "string" ? model : "").trim().toLowerCase().replace(/^[a-z0-9-]+\//, "");
85+ if (id) {
86+ const exact = offered.find((entry) => entry.model.toLowerCase() === id);
87+ if (exact) return exact;
88+ const prefixed = offered
89+ .filter((entry) => id.startsWith(`${entry.model.toLowerCase()}-`))
90+ .sort((a, b) => b.model.length - a.model.length)[0];
91+ if (prefixed) return prefixed;
92+ }
93+ if (route !== "g1t") return STANDARD_PRICES;
94+ const chat = offered.filter((entry) => (entry.kind ?? "chat") === "chat");
95+ const dearest = chat.sort((a, b) => b.outputMicros - a.outputMicros)[0];
96+ return dearest && dearest.outputMicros >= FRONTIER_PRICES.outputMicros ? dearest : FRONTIER_PRICES;
97+}
98+
99+/**
100+ * What tokens cost at a model's prices per million, rounded up to a whole
101+ * millionth of a dollar: billing's own sum (`cost_micros` in
102+ * services/billing/src/gateway.rs). A prompt longer than the model's
103+ * threshold puts the whole request at the over-threshold prices.
104+ */
105+export function costMicros(prices: Prices, tokens: Tokens): number {
106+ const prompt = tokens.input + tokens.cacheRead + tokens.cacheWrite;
107+ const over = (prices.threshold ?? 0) > 0 && prompt > (prices.threshold ?? 0);
108+ const pick = (base: number | undefined, above: number | undefined) => Math.max(0, (over ? above : base) ?? 0);
109+ const hour = Math.min(tokens.cacheWrite1h ?? 0, tokens.cacheWrite);
110+ const fiveMinutes = pick(prices.cacheWriteMicros, prices.overCacheWriteMicros);
111+ const hourPrice = pick(prices.cacheWrite1hMicros, prices.overCacheWrite1hMicros) || fiveMinutes;
112+ const millionths =
113+ tokens.input * pick(prices.inputMicros, prices.overInputMicros) +
114+ tokens.output * pick(prices.outputMicros, prices.overOutputMicros) +
115+ tokens.cacheRead * pick(prices.cacheReadMicros, prices.overCacheReadMicros) +
116+ (tokens.cacheWrite - hour) * fiveMinutes +
117+ hour * hourPrice;
118+ return Math.ceil(millionths / 1_000_000);
119+}
120+
121+/**
122+ * The most a request could cost: its whole body as input (about four
123+ * characters a token) and its `max_tokens` as output. What an answer that
124+ * could not be read is charged.
125+ */
126+export function ceilingMicros(prices: Prices, bodyLength: number, maxTokens: unknown): number {
127+ const output = typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0 ? maxTokens : DEFAULT_MAX_TOKENS;
128+ return costMicros(prices, { input: Math.ceil(bodyLength / 4), output, cacheRead: 0, cacheWrite: 0 });
129+}
130+
131+/**
132+ * What one answer is charged against the run's cap: nothing for a refused
133+ * request; its tokens' cost; or, for an answer whose usage could not be
134+ * read (cut off, or unreadable), the most it could have cost, so a reading
135+ * that fails never makes an answer free.
136+ */
137+export function chargeFor(prices: Prices, tokens: Tokens, ok: boolean, ceiling: number): number {
138+ if (!ok) return 0;
139+ return total(tokens) === 0 ? ceiling : costMicros(prices, tokens);
140+}
141+
142+export type Admission = { ok: true; ticket: string; spent: number } | { ok: false; reason: "cap" | "busy"; spent: number };
143+
144+/**
145+ * One run's count: what it has spent, and its answers in flight. Every
146+ * method runs to its end without waiting, so a Durable Object's requests
147+ * see each other's changes in order.
148+ */
149+export class SpendTally {
150+ spent: number;
151+ private readonly open = new Map<string, number>();
152+ private issued = 0;
153+
154+ constructor(spent = 0) {
155+ this.spent = Number.isFinite(spent) && spent > 0 ? spent : 0;
156+ }
157+
158+ /** Whether another answer may start: the run is under its cap and not too busy. */
159+ admit(cap: number, now: number): Admission {
160+ for (const [ticket, since] of this.open) if (now - since > HOLD_MS) this.open.delete(ticket);
161+ if (this.spent >= cap) return { ok: false, reason: "cap", spent: this.spent };
162+ if (this.open.size >= MAX_IN_FLIGHT) return { ok: false, reason: "busy", spent: this.spent };
163+ const ticket = `t${++this.issued}`;
164+ this.open.set(ticket, now);
165+ return { ok: true, ticket, spent: this.spent };
166+ }
167+
168+ /** An answer has ended, costing `micros`. Returns what the run has spent. */
169+ settle(ticket: string, micros: number): number {
170+ this.open.delete(ticket);
171+ if (Number.isFinite(micros) && micros > 0) this.spent += Math.ceil(micros);
172+ return this.spent;
173+ }
174+
175+ get inFlight(): number {
176+ return this.open.size;
177+ }
178+}
179+
180+const dollars = (micros: number) => `$${(micros / 1_000_000).toFixed(2)}`;
181+
182+/**
183+ * The refusal of a run past its cap: Anthropic's error shape, which the
184+ * harness shows as it is, with `code` for anything that reads it.
185+ */
186+export function capReached(cap: number, spent: number): Response {
187+ const message = `This run reached its cost cap of ${dollars(cap)} (it has spent ${dollars(spent)} on models), so g1t refuses its model requests from here. Raise the cap in the project's guardrails or the workspace's billing, then start the work again.`;
188+ return Response.json({ type: "error", error: { type: "billing_error", code: "run_cap_reached", message } }, { status: 402 });
189+}
190+
191+/** The refusal of a request while the run has too many answers in flight; the harness retries it. */
192+export function tooBusy(): Response {
193+ const response = anthropicError(429, "rate_limit_error", `This run has ${MAX_IN_FLIGHT} model requests in flight. Wait for one to finish.`);
194+ response.headers.set("retry-after", "2");
195+ return response;
196+}
+6−0
1616 // Who an AI Gateway request's access token belongs to, and its scopes.
1717 { "binding": "IDENTITY", "service": "g1t-identity" }
1818 ],
19+ // Each run's model spend, one object per model session, so its cost cap
20+ // holds across every isolate its requests reach (src/run-spend.ts).
21+ "durable_objects": {
22+ "bindings": [{ "name": "RUN_SPEND", "class_name": "RunSpend" }]
23+ },
24+ "migrations": [{ "tag": "v1", "new_sqlite_classes": ["RunSpend"] }],
1925 "vars": {
2026 // g1t's own route, for runs g1t pays for: a Cloudflare AI Gateway,
2127 // which holds g1t's key.
+21−1
22 import { createHash } from "node:crypto";
33 import { test } from "node:test";
44
5−import { credentialHashes, holdCredentials, modelTokenHashes, pushGrant, remotePath, revokeCredentials, sha256Hex } from "./credentials.ts";
5+import { capModelTokens, credentialHashes, holdCredentials, modelTokenHashes, pushGrant, remotePath, revokeCredentials, sha256Hex } from "./credentials.ts";
66
77 test("a remote names its repository", () => {
88 assert.deepEqual(remotePath("https://g1t.sh/acme/rocket.git"), { namespace: "acme", name: "rocket" });
9898 assert.equal(integrations.calls[0]?.method, "close_model_sessions");
9999 assert.equal(identity.calls.length, 0);
100100 });
101+
102+test("a run's model token is held to its cost cap, in millionths", async () => {
103+ const integrations = fakeIdentity();
104+ const storage = memoryStorage();
105+ const token = "g1tm_0123456789abcdef";
106+ await holdCredentials(fakeIdentity(), storage, { ANTHROPIC_API_KEY: token }, null);
107+ await capModelTokens(integrations, storage, 2.5);
108+ assert.equal(integrations.calls[0]?.method, "cap_model_sessions");
109+ assert.deepEqual(integrations.calls[0].body, { token_hashes: [await sha256Hex(token)], cap_micros: 2_500_000 });
110+});
111+
112+test("no cap, or no model token, asks integrations nothing", async () => {
113+ const integrations = fakeIdentity();
114+ const storage = memoryStorage();
115+ await capModelTokens(integrations, storage, 2);
116+ await holdCredentials(fakeIdentity(), storage, { ANTHROPIC_API_KEY: "g1tm_x" }, null);
117+ await capModelTokens(integrations, storage, null);
118+ await capModelTokens(integrations, storage, 0);
119+ assert.equal(integrations.calls.length, 0);
120+});
+16−0
100100 }
101101
102102 /**
103+ * Tells integrations the most the run may spend on models, for the model
104+ * tokens `holdCredentials` kept, so the model proxy holds them to it
105+ * whatever the sandbox does (the harness's own cap is only the sandbox's
106+ * word). No cap, or no model token, leaves the proxy's backstop. Never
107+ * stops the sandbox from starting.
108+ */
109+export async function capModelTokens(integrations: ServiceBinding, storage: Storage, budgetUsd: number | null | undefined): Promise<void> {
110+ if (typeof budgetUsd !== "number" || !Number.isFinite(budgetUsd) || budgetUsd <= 0) return;
111+ const models = await storage.get<string[]>(MODEL_STORAGE_KEY);
112+ if (!models?.length) return;
113+ await call(integrations, "cap_model_sessions", { token_hashes: models, cap_micros: Math.round(budgetUsd * 1_000_000) }).catch((error: unknown) =>
114+ console.log("model sessions not capped", String(error)),
115+ );
116+}
117+
118+/**
103119 * Ends a sandbox's credentials, once: its g1t tokens, and, when
104120 * `integrations` is given, its model tokens, which the model proxy then
105121 * refuses within seconds rather than when they would lapse.
+3−1
9090 import { BUMP_MINUTES, BUMP_TOKEN_TTL_SECONDS, bumpEnv, bumpProblem, bumpSandboxName, systemActor, registryHosts } from "./bump";
9191 import { BACKUP_MINUTES, backupEnv, backupPace, backupSandboxName } from "./backup";
9292 import { type ProjectSurroundings, readableSurroundings } from "./surroundings";
93−import { holdCredentials, pushGrant, remotePath, revokeCredentials, runCredential } from "./credentials";
93+import { capModelTokens, holdCredentials, pushGrant, remotePath, revokeCredentials, runCredential } from "./credentials";
9494 import { buildMentionPrompt, describeThread, handleMention, planMention } from "./mentions";
9595 import { instructionsFor, repoInstructions, withBlock } from "./repo-instructions";
9696 import { cancelTask, enqueueTask, handedOverStep, selfHostedRoute, taskEnv, taskRepo } from "./self-hosted";
450450 const tracked = track ? await this.openRun(track, envVars, guard) : null;
451451 // Its credentials are tied to the run, and revoked when it stops.
452452 await holdCredentials(this.env.IDENTITY, this.ctx.storage, envVars, tracked?.runId ?? null);
453+ // Its model token is held to its cost cap by the model proxy too.
454+ await capModelTokens(this.env.INTEGRATIONS, this.ctx.storage, guard?.policy.budgetUsd ?? limits?.budgetUsd);
453455 try {
454456 const vars = tracked ? { ...envVars, AGENT_RUN: tracked.runId, AGENT_RUN_TOKEN: tracked.token } : envVars;
455457 // The workspace's own runner, not a container: the same environment,