g1t/services/billing/src/lib.rs

647 lines23,448 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents as a team: lifecycle, merge queue, billing and a new shell1//! The billing service: what agents cost, charged to the workspace they
2//! worked for.
3//!
4//! A workspace buys credit with a card. Before the runner starts an agent
5//! it asks here, and is refused if the workspace has none. When the
6//! agent's sandbox finishes it reports what the model cost, and that plus
7//! g1t's margin comes off the balance. Every change is a ledger entry, and
8//! a balance is always the sum of its ledger.
9//!
10//! Without a card processor configured the service says so and charges
11//! nothing, so that g1t still runs where billing has not been set up.
12//!
13//! Reached only through service bindings; see `g1t_contracts::billing` for
14//! the methods and their arguments.
15
16mod stripe;
17
18use g1t_contracts::billing::*;
19use g1t_contracts::time::rfc3339;
20use g1t_contracts::{FailureCode, Outcome, Role, new_id};
21use g1t_kit::{args, now_ms, reply, rpc_method};
22use serde::Deserialize;
23use sha2::{Digest, Sha256};
24use worker::wasm_bindgen::JsValue;
25use worker::{Context, D1Database, Env, Request, Response, Result, event};
26
27use stripe::Stripe;
28
29const MIN_TOP_UP_CENTS: u32 = 500;
30const MAX_TOP_UP_CENTS: u32 = 50_000;
31const LEDGER_PAGE: u32 = 100;
32/// A run's reported cost is believed up to this much. A sandbox cannot
33/// spend more in the time it has, so anything above is a fault.
34const MAX_RUN_COST_USD: f64 = 100.0;
35
36/// What a run is charged: its cost plus the margin, rounded up to a whole
37/// millionth of a dollar.
38pub fn charge_micros(cost_usd: f64, margin_percent: u32) -> i64 {
39 let cost_micros = (cost_usd.clamp(0.0, MAX_RUN_COST_USD) * MICROS_PER_DOLLAR as f64).ceil();
40 (cost_micros * f64::from(100 + margin_percent) / 100.0).ceil() as i64
41}
42
43fn hash(token: &str) -> String {
44 hex::encode(Sha256::digest(token.as_bytes()))
45}
46
47fn optional(value: Option<&str>) -> JsValue {
48 value.map_or(JsValue::NULL, JsValue::from)
49}
50
51#[derive(Deserialize)]
52struct AccountRow {
53 balance_micros: i64,
54 customer_id: Option<String>,
55}
56
57#[derive(Deserialize)]
58struct LedgerRow {
59 id: String,
60 kind: EntryKind,
61 amount_micros: i64,
62 description: String,
63 repo: Option<String>,
64 number: Option<u32>,
65 task: Option<String>,
66 model: Option<String>,
67 created_by: Option<String>,
68 created_at: String,
Integrations: your own model provider, alerts that open issues, tickets agents read69 billed_to: Option<String>,
Agents as a team: lifecycle, merge queue, billing and a new shell70}
71
72impl From<LedgerRow> for LedgerEntry {
73 fn from(row: LedgerRow) -> Self {
74 LedgerEntry {
75 id: row.id,
76 kind: row.kind,
77 amount_micros: row.amount_micros,
78 description: row.description,
79 repo: row.repo,
80 number: row.number,
81 task: row.task,
82 model: row.model,
Integrations: your own model provider, alerts that open issues, tickets agents read83 billed_to: row.billed_to.unwrap_or_else(|| "g1t".to_owned()),
Agents as a team: lifecycle, merge queue, billing and a new shell84 created_by: row.created_by,
85 created_at: row.created_at,
86 }
87 }
88}
89
90#[derive(Deserialize)]
91struct RunRow {
92 workspace: String,
93 repo: String,
94 number: u32,
95 task: String,
96 model: String,
97 token_hash: String,
Integrations: your own model provider, alerts that open issues, tickets agents read98 billed_to: Option<String>,
99}
100
101impl RunRow {
102 fn own_provider(&self) -> bool {
103 self.billed_to.as_deref() == Some("workspace")
104 }
Agents as a team: lifecycle, merge queue, billing and a new shell105}
106
107#[derive(Deserialize)]
108struct CheckoutRow {
109 workspace: String,
110 created_by: String,
111}
112
113/// A row an `UPDATE … RETURNING` touched.
114#[derive(Deserialize)]
115struct Touched {
116 #[allow(dead_code)]
117 id: String,
118}
119
120struct Billing {
121 db: D1Database,
122 /// Absent when no card processor is configured.
123 stripe: Option<Stripe>,
124 margin_percent: u32,
Integrations: your own model provider, alerts that open issues, tickets agents read125 /// Charged for a run on the workspace's own model provider.
126 orchestration_fee_micros: i64,
Free while g1t is being built out; agents can check out their own forks127 /// While g1t is being built out, nothing is charged (`FREE_WHILE_BUILDING`).
128 free: bool,
Agents as a team: lifecycle, merge queue, billing and a new shell129}
130
131impl Billing {
132 fn status(&self) -> Status {
133 Status {
134 enabled: self.stripe.is_some(),
135 live: self.stripe.as_ref().is_some_and(Stripe::live),
Free while g1t is being built out; agents can check out their own forks136 free: self.free,
Agents as a team: lifecycle, merge queue, billing and a new shell137 }
138 }
139
140 async fn row(&self, workspace: &str) -> Result<Option<AccountRow>> {
141 self.db
142 .prepare("SELECT balance_micros, customer_id FROM accounts WHERE workspace = ?")
143 .bind(&[workspace.into()])?
144 .first::<AccountRow>(None)
145 .await
146 }
147
148 async fn standing(&self, workspace: &str) -> Result<Account> {
149 Ok(Account {
150 workspace: workspace.to_owned(),
151 balance_micros: self
152 .row(workspace)
153 .await?
154 .map_or(0, |row| row.balance_micros),
155 status: self.status(),
156 margin_percent: self.margin_percent,
Integrations: your own model provider, alerts that open issues, tickets agents read157 orchestration_fee_micros: self.orchestration_fee_micros,
Agents as a team: lifecycle, merge queue, billing and a new shell158 })
159 }
160
161 /// Adds a ledger entry and moves the balance by the same amount, as
162 /// one write.
163 #[allow(clippy::too_many_arguments)]
164 async fn enter(
165 &self,
166 workspace: &str,
167 kind: EntryKind,
168 amount_micros: i64,
169 description: &str,
170 reference: &str,
171 run: Option<&RunRow>,
172 cost_micros: Option<i64>,
173 created_by: Option<&str>,
174 customer: Option<&str>,
175 ) -> Result<()> {
176 let now = now_ms();
177 let timestamp = rfc3339(now);
178 let kind = match kind {
179 EntryKind::TopUp => "top_up",
180 EntryKind::Usage => "usage",
181 };
182 self.db
183 .batch(vec![
184 self.db
185 .prepare(
186 "INSERT INTO ledger
187 (id, workspace, kind, amount_micros, description, repo, number, task,
Integrations: your own model provider, alerts that open issues, tickets agents read188 model, cost_micros, reference, created_by, created_at, billed_to)
189 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
Agents as a team: lifecycle, merge queue, billing and a new shell190 )
191 .bind(&[
192 new_id("led", now).into(),
193 workspace.into(),
194 kind.into(),
195 // D1 takes numbers as doubles, which hold every
196 // amount this service will see exactly.
197 (amount_micros as f64).into(),
198 description.into(),
199 optional(run.map(|run| run.repo.as_str())),
200 run.map_or(JsValue::NULL, |run| run.number.into()),
201 optional(run.map(|run| run.task.as_str())),
202 optional(run.map(|run| run.model.as_str())),
203 cost_micros.map_or(JsValue::NULL, |cost| (cost as f64).into()),
204 reference.into(),
205 optional(created_by),
206 timestamp.as_str().into(),
Integrations: your own model provider, alerts that open issues, tickets agents read207 run.map_or("g1t", |run| if run.own_provider() { "workspace" } else { "g1t" }).into(),
Agents as a team: lifecycle, merge queue, billing and a new shell208 ])?,
209 self.db
210 .prepare(
211 "INSERT INTO accounts (workspace, balance_micros, customer_id, created_at)
212 VALUES (?1, ?2, ?3, ?4)
213 ON CONFLICT (workspace) DO UPDATE SET
214 balance_micros = balance_micros + ?2,
215 customer_id = COALESCE(?3, customer_id)",
216 )
217 .bind(&[
218 workspace.into(),
219 (amount_micros as f64).into(),
220 optional(customer),
221 timestamp.as_str().into(),
222 ])?,
223 ])
224 .await?;
225 Ok(())
226 }
227
228 async fn account(&self, a: AccountArgs) -> Result<Outcome<Account>> {
229 let workspace = a.workspace.to_lowercase();
230 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
231 return Ok(members_only());
232 }
233 Ok(Outcome::Ok(self.standing(&workspace).await?))
234 }
235
236 async fn ledger(&self, a: AccountArgs) -> Result<Outcome<Vec<LedgerEntry>>> {
237 let workspace = a.workspace.to_lowercase();
238 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
239 return Ok(members_only());
240 }
241 let rows = self
242 .db
243 .prepare("SELECT * FROM ledger WHERE workspace = ? ORDER BY id DESC LIMIT ?")
244 .bind(&[workspace.into(), LEDGER_PAGE.into()])?
245 .all()
246 .await?
247 .results::<LedgerRow>()?;
248 Ok(Outcome::Ok(
249 rows.into_iter().map(LedgerEntry::from).collect(),
250 ))
251 }
252
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request253 async fn usage(&self, a: UsageArgs) -> Result<Outcome<Usage>> {
254 let workspace = a.workspace.to_lowercase();
255 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
256 return Ok(members_only());
257 }
258 #[derive(serde::Deserialize)]
259 struct SliceRow {
260 key: Option<String>,
261 micros: Option<i64>,
262 runs: Option<u32>,
263 }
264 let slices = |key: &str, limit: u32| {
265 format!(
266 "SELECT {key} AS key, -SUM(amount_micros) AS micros, COUNT(*) AS runs FROM ledger
267 WHERE workspace = ?1 AND kind = 'usage' AND created_at >= ?2
268 GROUP BY 1 ORDER BY micros DESC LIMIT {limit}"
269 )
270 };
271 let query = |sql: String| {
272 let db = &self.db;
273 let workspace = workspace.clone();
274 let since = a.since.clone();
275 async move {
276 let rows = db
277 .prepare(sql)
278 .bind(&[workspace.into(), since.into()])?
279 .all()
280 .await?
281 .results::<SliceRow>()?;
282 Ok::<Vec<UsageSlice>, worker::Error>(
283 rows.into_iter()
284 .map(|row| UsageSlice {
285 key: row.key.unwrap_or_else(|| "other".to_owned()),
286 micros: row.micros.unwrap_or_default(),
287 runs: row.runs.unwrap_or_default(),
288 })
289 .collect(),
290 )
291 }
292 };
293 #[derive(serde::Deserialize)]
294 struct Totals {
295 spent: Option<i64>,
296 cost: Option<i64>,
Integrations: your own model provider, alerts that open issues, tickets agents read297 provider: Option<i64>,
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request298 runs: Option<u32>,
299 added: Option<i64>,
300 }
301 let totals = self
302 .db
303 .prepare(
304 "SELECT
305 -SUM(CASE WHEN kind = 'usage' THEN amount_micros END) AS spent,
Integrations: your own model provider, alerts that open issues, tickets agents read306 SUM(CASE WHEN kind = 'usage' AND COALESCE(billed_to, 'g1t') = 'g1t' THEN cost_micros END) AS cost,
307 SUM(CASE WHEN kind = 'usage' AND billed_to = 'workspace' THEN cost_micros END) AS provider,
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request308 SUM(CASE WHEN kind = 'usage' THEN 1 ELSE 0 END) AS runs,
309 SUM(CASE WHEN kind = 'top_up' THEN amount_micros END) AS added
310 FROM ledger WHERE workspace = ?1 AND created_at >= ?2",
311 )
312 .bind(&[workspace.as_str().into(), a.since.as_str().into()])?
313 .first::<Totals>(None)
314 .await?;
315 let totals = totals.unwrap_or(Totals {
316 spent: None,
317 cost: None,
Integrations: your own model provider, alerts that open issues, tickets agents read318 provider: None,
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request319 runs: None,
320 added: None,
321 });
322 Ok(Outcome::Ok(Usage {
323 spent_micros: totals.spent.unwrap_or_default(),
324 cost_micros: totals.cost.unwrap_or_default(),
Integrations: your own model provider, alerts that open issues, tickets agents read325 provider_micros: totals.provider.unwrap_or_default(),
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request326 runs: totals.runs.unwrap_or_default(),
327 added_micros: totals.added.unwrap_or_default(),
328 by_day: query(slices("substr(created_at, 1, 10) || '/' || COALESCE(task, 'other')", 400)).await?,
329 by_task: query(slices("task", 20)).await?,
330 by_repo: query(slices("repo", 20)).await?,
331 by_pull: query(slices("repo || '#' || number", 10)).await?,
332 by_model: query(slices("model", 10)).await?,
333 since: a.since,
334 }))
335 }
336
Agents as a team: lifecycle, merge queue, billing and a new shell337 async fn checkout(&self, a: CheckoutArgs) -> Result<Outcome<Checkout>> {
338 let workspace = a.workspace.to_lowercase();
339 if a.actor.role_in(&workspace) != Some(Role::Owner) {
340 return Ok(Outcome::fail(
341 FailureCode::Forbidden,
342 "Only an owner can add credit to a workspace.",
343 ));
344 }
345 let Some(stripe) = &self.stripe else {
346 return Ok(Outcome::fail(
347 FailureCode::Conflict,
348 "Payments are not set up on this g1t yet.",
349 ));
350 };
351 if !(MIN_TOP_UP_CENTS..=MAX_TOP_UP_CENTS).contains(&a.amount_cents) {
352 return Ok(Outcome::fail(
353 FailureCode::Invalid,
354 format!(
355 "Add between ${} and ${} at a time.",
356 MIN_TOP_UP_CENTS / 100,
357 MAX_TOP_UP_CENTS / 100
358 ),
359 ));
360 }
361 let customer = self.row(&workspace).await?.and_then(|row| row.customer_id);
362 let session = stripe
363 .start_checkout(
364 &workspace,
365 a.amount_cents,
366 customer.as_deref(),
367 &a.return_url,
368 )
369 .await?;
370 let Some(url) = session.url else {
371 return Err(worker::Error::RustError(
372 "the card processor returned no payment page".into(),
373 ));
374 };
375 self.db
376 .prepare(
377 "INSERT INTO checkouts (id, workspace, amount_cents, created_by, created_at)
378 VALUES (?, ?, ?, ?, ?)",
379 )
380 .bind(&[
381 session.id.into(),
382 workspace.into(),
383 a.amount_cents.into(),
384 a.actor.username.into(),
385 rfc3339(now_ms()).into(),
386 ])?
387 .run()
388 .await?;
389 Ok(Outcome::Ok(Checkout { url }))
390 }
391
392 /// Credits a payment if the processor says it was made and it has not
393 /// been credited before. The amount credited is what the processor
394 /// says was paid, not what anyone here remembers asking for.
395 async fn confirm(&self, a: ConfirmArgs) -> Result<Outcome<Account>> {
396 let workspace = a.workspace.to_lowercase();
397 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
398 return Ok(members_only());
399 }
400 let (Some(stripe), Some(checkout)) = (
401 &self.stripe,
402 self.db
403 .prepare(
404 "SELECT workspace, created_by FROM checkouts
405 WHERE id = ? AND workspace = ? AND status = 'open'",
406 )
407 .bind(&[a.session.as_str().into(), workspace.as_str().into()])?
408 .first::<CheckoutRow>(None)
409 .await?,
410 ) else {
411 // Unknown, someone else's, or already credited: nothing to do.
412 return Ok(Outcome::Ok(self.standing(&workspace).await?));
413 };
414 let session = stripe.session(&a.session).await?;
415 let paid = session
416 .amount_total
417 .filter(|_| session.payment_status == "paid");
418 if let Some(cents) = paid {
419 // Only whoever flips it from open to paid enters the credit.
420 let claimed = self
421 .db
422 .prepare(
423 "UPDATE checkouts SET status = 'paid' WHERE id = ? AND status = 'open'
424 RETURNING id",
425 )
426 .bind(&[a.session.as_str().into()])?
427 .first::<Touched>(None)
428 .await?;
429 if claimed.is_some() {
430 self.enter(
431 &checkout.workspace,
432 EntryKind::TopUp,
433 i64::from(cents) * MICROS_PER_DOLLAR / 100,
434 "Credit added by card",
435 &session.id,
436 None,
437 None,
438 Some(&checkout.created_by),
439 session.customer.as_deref(),
440 )
441 .await?;
442 }
443 }
444 Ok(Outcome::Ok(self.standing(&workspace).await?))
445 }
446
447 /// A refusal if the workspace has no credit to start an agent with.
448 async fn out_of_credit<T>(&self, workspace: &str) -> Result<Option<Outcome<T>>> {
Free while g1t is being built out; agents can check out their own forks449 // While g1t is being built out, no one needs credit.
450 if self.free {
451 return Ok(None);
452 }
Agents as a team: lifecycle, merge queue, billing and a new shell453 let balance = self
454 .row(workspace)
455 .await?
456 .map_or(0, |row| row.balance_micros);
457 Ok((balance <= 0).then(|| {
458 Outcome::fail(
459 FailureCode::PaymentRequired,
460 format!(
461 "The {workspace} workspace has no agent credit. An owner can add some under Billing on the workspace's page."
462 ),
463 )
464 }))
465 }
466
467 async fn can_start(&self, a: CanStartArgs) -> Result<Outcome<bool>> {
468 if self.stripe.is_none() {
469 return Ok(Outcome::Ok(true));
470 }
471 Ok(self
472 .out_of_credit(&a.workspace.to_lowercase())
473 .await?
474 .unwrap_or(Outcome::Ok(true)))
475 }
476
477 async fn start_run(&self, a: StartRunArgs) -> Result<Outcome<Option<RunTicket>>> {
478 if self.stripe.is_none() {
479 return Ok(Outcome::Ok(None));
480 }
481 let workspace = a.workspace.to_lowercase();
482 if let Some(refused) = self.out_of_credit(&workspace).await? {
483 return Ok(refused);
484 }
485 let now = now_ms();
486 let run_id = new_id("run", now);
487 let mut bytes = [0u8; 32];
488 getrandom::getrandom(&mut bytes).expect("no source of randomness");
489 let token = hex::encode(bytes);
490 self.db
491 .prepare(
Integrations: your own model provider, alerts that open issues, tickets agents read492 "INSERT INTO runs (id, workspace, repo, number, task, model, token_hash, created_at, billed_to)
493 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
Agents as a team: lifecycle, merge queue, billing and a new shell494 )
495 .bind(&[
496 run_id.as_str().into(),
497 workspace.into(),
498 format!("{}/{}", a.repo.namespace, a.repo.name).into(),
499 a.number.into(),
500 a.task.into(),
501 a.model.into(),
502 hash(&token).into(),
503 rfc3339(now).into(),
Integrations: your own model provider, alerts that open issues, tickets agents read504 if a.billed_to == "workspace" { "workspace" } else { "g1t" }.into(),
Agents as a team: lifecycle, merge queue, billing and a new shell505 ])?
506 .run()
507 .await?;
508 Ok(Outcome::Ok(Some(RunTicket { run_id, token })))
509 }
510
511 async fn finish_run(&self, a: FinishRunArgs) -> Result<Outcome<bool>> {
512 let run = self
513 .db
514 .prepare(
Integrations: your own model provider, alerts that open issues, tickets agents read515 "SELECT workspace, repo, number, task, model, token_hash, billed_to FROM runs
Agents as a team: lifecycle, merge queue, billing and a new shell516 WHERE id = ? AND finished_at IS NULL",
517 )
518 .bind(&[a.run_id.as_str().into()])?
519 .first::<RunRow>(None)
520 .await?;
521 let Some(run) = run.filter(|run| run.token_hash == hash(&a.token)) else {
522 return Ok(Outcome::fail(FailureCode::NotFound, "Run not found."));
523 };
524 if !a.cost_usd.is_finite() || a.cost_usd < 0.0 {
525 return Ok(Outcome::fail(FailureCode::Invalid, "That is not a cost."));
526 }
527 // Only whoever closes the run charges for it.
528 let claimed = self
529 .db
530 .prepare(
531 "UPDATE runs SET finished_at = ? WHERE id = ? AND finished_at IS NULL RETURNING id",
532 )
533 .bind(&[rfc3339(now_ms()).into(), a.run_id.as_str().into()])?
534 .first::<Touched>(None)
535 .await?;
536 if claimed.is_none() {
537 return Ok(Outcome::Ok(false));
538 }
Integrations: your own model provider, alerts that open issues, tickets agents read539 // On the workspace's own provider, the model was paid for there:
540 // g1t charges its fee, and keeps the provider's cost to show.
Free while g1t is being built out; agents can check out their own forks541 let charge = if self.free {
542 // Recorded, with what it cost, but not charged.
543 0
544 } else if run.own_provider() {
Integrations: your own model provider, alerts that open issues, tickets agents read545 self.orchestration_fee_micros
546 } else {
547 charge_micros(a.cost_usd, self.margin_percent)
548 };
549 let mut description = match run.task.as_str() {
Agents as a team: lifecycle, merge queue, billing and a new shell550 "plan" => format!("Planning for {}", run.repo),
551 "review" => format!("Review of {}#{}", run.repo, run.number),
552 "update" => format!("Catching up {}#{}", run.repo, run.number),
553 _ => format!("Work on {}#{}", run.repo, run.number),
554 };
Integrations: your own model provider, alerts that open issues, tickets agents read555 if run.own_provider() {
556 description.push_str(", on your own model provider");
557 }
Free while g1t is being built out; agents can check out their own forks558 if self.free {
559 description.push_str(" (free while g1t is being built out)");
560 }
Agents as a team: lifecycle, merge queue, billing and a new shell561 self.enter(
562 &run.workspace,
563 EntryKind::Usage,
564 -charge,
565 &description,
566 &a.run_id,
567 Some(&run),
568 Some(charge_micros(a.cost_usd, 0)),
569 None,
570 None,
571 )
572 .await?;
573 Ok(Outcome::Ok(true))
574 }
575}
576
577fn members_only<T>() -> Outcome<T> {
578 Outcome::fail(
579 FailureCode::Forbidden,
580 "Only members can see a workspace's billing.",
581 )
582}
583
584#[event(fetch)]
585async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> {
586 let Some(method) = rpc_method(&request) else {
587 return Response::error("Not found", 404);
588 };
589 let body: serde_json::Value = request.json().await?;
590 let billing = Billing {
591 db: env.d1("DB")?,
592 stripe: env
593 .secret("STRIPE_SECRET_KEY")
594 .ok()
595 .map(|key| key.to_string())
596 .filter(|key| !key.is_empty())
597 .map(Stripe::new),
598 margin_percent: env
599 .var("MARGIN_PERCENT")
600 .ok()
601 .and_then(|percent| percent.to_string().parse().ok())
602 .unwrap_or(20),
Integrations: your own model provider, alerts that open issues, tickets agents read603 orchestration_fee_micros: env
604 .var("ORCHESTRATION_FEE_MICROS")
605 .ok()
606 .and_then(|fee| fee.to_string().parse().ok())
607 .unwrap_or(100_000),
Free while g1t is being built out; agents can check out their own forks608 free: env.var("FREE_WHILE_BUILDING").is_ok_and(|v| v.to_string() == "true"),
Agents as a team: lifecycle, merge queue, billing and a new shell609 };
610 match method.as_str() {
611 "status" => reply(&billing.status()),
612 "account" => reply(&billing.account(args(body)?).await?),
613 "ledger" => reply(&billing.ledger(args(body)?).await?),
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request614 "usage" => reply(&billing.usage(args(body)?).await?),
Agents as a team: lifecycle, merge queue, billing and a new shell615 "checkout" => reply(&billing.checkout(args(body)?).await?),
616 "confirm" => reply(&billing.confirm(args(body)?).await?),
617 "can_start" => reply(&billing.can_start(args(body)?).await?),
618 "start_run" => reply(&billing.start_run(args(body)?).await?),
619 "finish_run" => reply(&billing.finish_run(args(body)?).await?),
620 _ => Response::error("Unknown method", 404),
621 }
622}
623
624#[cfg(test)]
625mod tests {
626 use super::*;
627
628 #[test]
629 fn a_run_is_charged_its_cost_plus_the_margin() {
630 // $0.05 at 20% is six cents.
631 assert_eq!(charge_micros(0.05, 20), 60_000);
632 assert_eq!(charge_micros(1.0, 20), 1_200_000);
633 assert_eq!(charge_micros(0.05, 0), 50_000);
634 }
635
636 #[test]
637 fn fractions_of_a_millionth_round_up_and_nothing_costs_less_than_nothing() {
638 assert_eq!(charge_micros(0.000_000_4, 20), 2);
639 assert_eq!(charge_micros(0.0, 20), 0);
640 assert_eq!(charge_micros(-3.0, 20), 0);
641 }
642
643 #[test]
644 fn an_absurd_cost_is_capped() {
645 assert_eq!(charge_micros(1e9, 20), 120 * MICROS_PER_DOLLAR);
646 }
647}