g1t/services/billing/src/lib.rs

651 lines23,738 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 }
Usage while free is shown at cost; agents get rustfmt and clippy264 // While nothing is charged, what was used is what there is to show.
265 let measure = if self.free { "COALESCE(cost_micros, 0)" } else { "-amount_micros" };
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request266 let slices = |key: &str, limit: u32| {
267 format!(
Usage while free is shown at cost; agents get rustfmt and clippy268 "SELECT {key} AS key, SUM({measure}) AS micros, COUNT(*) AS runs FROM ledger
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request269 WHERE workspace = ?1 AND kind = 'usage' AND created_at >= ?2
270 GROUP BY 1 ORDER BY micros DESC LIMIT {limit}"
271 )
272 };
273 let query = |sql: String| {
274 let db = &self.db;
275 let workspace = workspace.clone();
276 let since = a.since.clone();
277 async move {
278 let rows = db
279 .prepare(sql)
280 .bind(&[workspace.into(), since.into()])?
281 .all()
282 .await?
283 .results::<SliceRow>()?;
284 Ok::<Vec<UsageSlice>, worker::Error>(
285 rows.into_iter()
286 .map(|row| UsageSlice {
287 key: row.key.unwrap_or_else(|| "other".to_owned()),
288 micros: row.micros.unwrap_or_default(),
289 runs: row.runs.unwrap_or_default(),
290 })
291 .collect(),
292 )
293 }
294 };
295 #[derive(serde::Deserialize)]
296 struct Totals {
297 spent: Option<i64>,
298 cost: Option<i64>,
Integrations: your own model provider, alerts that open issues, tickets agents read299 provider: Option<i64>,
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request300 runs: Option<u32>,
301 added: Option<i64>,
302 }
303 let totals = self
304 .db
305 .prepare(
306 "SELECT
307 -SUM(CASE WHEN kind = 'usage' THEN amount_micros END) AS spent,
Integrations: your own model provider, alerts that open issues, tickets agents read308 SUM(CASE WHEN kind = 'usage' AND COALESCE(billed_to, 'g1t') = 'g1t' THEN cost_micros END) AS cost,
309 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 request310 SUM(CASE WHEN kind = 'usage' THEN 1 ELSE 0 END) AS runs,
311 SUM(CASE WHEN kind = 'top_up' THEN amount_micros END) AS added
312 FROM ledger WHERE workspace = ?1 AND created_at >= ?2",
313 )
314 .bind(&[workspace.as_str().into(), a.since.as_str().into()])?
315 .first::<Totals>(None)
316 .await?;
317 let totals = totals.unwrap_or(Totals {
318 spent: None,
319 cost: None,
Integrations: your own model provider, alerts that open issues, tickets agents read320 provider: None,
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request321 runs: None,
322 added: None,
323 });
324 Ok(Outcome::Ok(Usage {
325 spent_micros: totals.spent.unwrap_or_default(),
326 cost_micros: totals.cost.unwrap_or_default(),
Integrations: your own model provider, alerts that open issues, tickets agents read327 provider_micros: totals.provider.unwrap_or_default(),
Usage while free is shown at cost; agents get rustfmt and clippy328 used_micros: totals.cost.unwrap_or_default() + totals.provider.unwrap_or_default(),
329 free: self.free,
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request330 runs: totals.runs.unwrap_or_default(),
331 added_micros: totals.added.unwrap_or_default(),
332 by_day: query(slices("substr(created_at, 1, 10) || '/' || COALESCE(task, 'other')", 400)).await?,
333 by_task: query(slices("task", 20)).await?,
334 by_repo: query(slices("repo", 20)).await?,
335 by_pull: query(slices("repo || '#' || number", 10)).await?,
336 by_model: query(slices("model", 10)).await?,
337 since: a.since,
338 }))
339 }
340
Agents as a team: lifecycle, merge queue, billing and a new shell341 async fn checkout(&self, a: CheckoutArgs) -> Result<Outcome<Checkout>> {
342 let workspace = a.workspace.to_lowercase();
343 if a.actor.role_in(&workspace) != Some(Role::Owner) {
344 return Ok(Outcome::fail(
345 FailureCode::Forbidden,
346 "Only an owner can add credit to a workspace.",
347 ));
348 }
349 let Some(stripe) = &self.stripe else {
350 return Ok(Outcome::fail(
351 FailureCode::Conflict,
352 "Payments are not set up on this g1t yet.",
353 ));
354 };
355 if !(MIN_TOP_UP_CENTS..=MAX_TOP_UP_CENTS).contains(&a.amount_cents) {
356 return Ok(Outcome::fail(
357 FailureCode::Invalid,
358 format!(
359 "Add between ${} and ${} at a time.",
360 MIN_TOP_UP_CENTS / 100,
361 MAX_TOP_UP_CENTS / 100
362 ),
363 ));
364 }
365 let customer = self.row(&workspace).await?.and_then(|row| row.customer_id);
366 let session = stripe
367 .start_checkout(
368 &workspace,
369 a.amount_cents,
370 customer.as_deref(),
371 &a.return_url,
372 )
373 .await?;
374 let Some(url) = session.url else {
375 return Err(worker::Error::RustError(
376 "the card processor returned no payment page".into(),
377 ));
378 };
379 self.db
380 .prepare(
381 "INSERT INTO checkouts (id, workspace, amount_cents, created_by, created_at)
382 VALUES (?, ?, ?, ?, ?)",
383 )
384 .bind(&[
385 session.id.into(),
386 workspace.into(),
387 a.amount_cents.into(),
388 a.actor.username.into(),
389 rfc3339(now_ms()).into(),
390 ])?
391 .run()
392 .await?;
393 Ok(Outcome::Ok(Checkout { url }))
394 }
395
396 /// Credits a payment if the processor says it was made and it has not
397 /// been credited before. The amount credited is what the processor
398 /// says was paid, not what anyone here remembers asking for.
399 async fn confirm(&self, a: ConfirmArgs) -> Result<Outcome<Account>> {
400 let workspace = a.workspace.to_lowercase();
401 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
402 return Ok(members_only());
403 }
404 let (Some(stripe), Some(checkout)) = (
405 &self.stripe,
406 self.db
407 .prepare(
408 "SELECT workspace, created_by FROM checkouts
409 WHERE id = ? AND workspace = ? AND status = 'open'",
410 )
411 .bind(&[a.session.as_str().into(), workspace.as_str().into()])?
412 .first::<CheckoutRow>(None)
413 .await?,
414 ) else {
415 // Unknown, someone else's, or already credited: nothing to do.
416 return Ok(Outcome::Ok(self.standing(&workspace).await?));
417 };
418 let session = stripe.session(&a.session).await?;
419 let paid = session
420 .amount_total
421 .filter(|_| session.payment_status == "paid");
422 if let Some(cents) = paid {
423 // Only whoever flips it from open to paid enters the credit.
424 let claimed = self
425 .db
426 .prepare(
427 "UPDATE checkouts SET status = 'paid' WHERE id = ? AND status = 'open'
428 RETURNING id",
429 )
430 .bind(&[a.session.as_str().into()])?
431 .first::<Touched>(None)
432 .await?;
433 if claimed.is_some() {
434 self.enter(
435 &checkout.workspace,
436 EntryKind::TopUp,
437 i64::from(cents) * MICROS_PER_DOLLAR / 100,
438 "Credit added by card",
439 &session.id,
440 None,
441 None,
442 Some(&checkout.created_by),
443 session.customer.as_deref(),
444 )
445 .await?;
446 }
447 }
448 Ok(Outcome::Ok(self.standing(&workspace).await?))
449 }
450
451 /// A refusal if the workspace has no credit to start an agent with.
452 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 forks453 // While g1t is being built out, no one needs credit.
454 if self.free {
455 return Ok(None);
456 }
Agents as a team: lifecycle, merge queue, billing and a new shell457 let balance = self
458 .row(workspace)
459 .await?
460 .map_or(0, |row| row.balance_micros);
461 Ok((balance <= 0).then(|| {
462 Outcome::fail(
463 FailureCode::PaymentRequired,
464 format!(
465 "The {workspace} workspace has no agent credit. An owner can add some under Billing on the workspace's page."
466 ),
467 )
468 }))
469 }
470
471 async fn can_start(&self, a: CanStartArgs) -> Result<Outcome<bool>> {
472 if self.stripe.is_none() {
473 return Ok(Outcome::Ok(true));
474 }
475 Ok(self
476 .out_of_credit(&a.workspace.to_lowercase())
477 .await?
478 .unwrap_or(Outcome::Ok(true)))
479 }
480
481 async fn start_run(&self, a: StartRunArgs) -> Result<Outcome<Option<RunTicket>>> {
482 if self.stripe.is_none() {
483 return Ok(Outcome::Ok(None));
484 }
485 let workspace = a.workspace.to_lowercase();
486 if let Some(refused) = self.out_of_credit(&workspace).await? {
487 return Ok(refused);
488 }
489 let now = now_ms();
490 let run_id = new_id("run", now);
491 let mut bytes = [0u8; 32];
492 getrandom::getrandom(&mut bytes).expect("no source of randomness");
493 let token = hex::encode(bytes);
494 self.db
495 .prepare(
Integrations: your own model provider, alerts that open issues, tickets agents read496 "INSERT INTO runs (id, workspace, repo, number, task, model, token_hash, created_at, billed_to)
497 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
Agents as a team: lifecycle, merge queue, billing and a new shell498 )
499 .bind(&[
500 run_id.as_str().into(),
501 workspace.into(),
502 format!("{}/{}", a.repo.namespace, a.repo.name).into(),
503 a.number.into(),
504 a.task.into(),
505 a.model.into(),
506 hash(&token).into(),
507 rfc3339(now).into(),
Integrations: your own model provider, alerts that open issues, tickets agents read508 if a.billed_to == "workspace" { "workspace" } else { "g1t" }.into(),
Agents as a team: lifecycle, merge queue, billing and a new shell509 ])?
510 .run()
511 .await?;
512 Ok(Outcome::Ok(Some(RunTicket { run_id, token })))
513 }
514
515 async fn finish_run(&self, a: FinishRunArgs) -> Result<Outcome<bool>> {
516 let run = self
517 .db
518 .prepare(
Integrations: your own model provider, alerts that open issues, tickets agents read519 "SELECT workspace, repo, number, task, model, token_hash, billed_to FROM runs
Agents as a team: lifecycle, merge queue, billing and a new shell520 WHERE id = ? AND finished_at IS NULL",
521 )
522 .bind(&[a.run_id.as_str().into()])?
523 .first::<RunRow>(None)
524 .await?;
525 let Some(run) = run.filter(|run| run.token_hash == hash(&a.token)) else {
526 return Ok(Outcome::fail(FailureCode::NotFound, "Run not found."));
527 };
528 if !a.cost_usd.is_finite() || a.cost_usd < 0.0 {
529 return Ok(Outcome::fail(FailureCode::Invalid, "That is not a cost."));
530 }
531 // Only whoever closes the run charges for it.
532 let claimed = self
533 .db
534 .prepare(
535 "UPDATE runs SET finished_at = ? WHERE id = ? AND finished_at IS NULL RETURNING id",
536 )
537 .bind(&[rfc3339(now_ms()).into(), a.run_id.as_str().into()])?
538 .first::<Touched>(None)
539 .await?;
540 if claimed.is_none() {
541 return Ok(Outcome::Ok(false));
542 }
Integrations: your own model provider, alerts that open issues, tickets agents read543 // On the workspace's own provider, the model was paid for there:
544 // 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 forks545 let charge = if self.free {
546 // Recorded, with what it cost, but not charged.
547 0
548 } else if run.own_provider() {
Integrations: your own model provider, alerts that open issues, tickets agents read549 self.orchestration_fee_micros
550 } else {
551 charge_micros(a.cost_usd, self.margin_percent)
552 };
553 let mut description = match run.task.as_str() {
Agents as a team: lifecycle, merge queue, billing and a new shell554 "plan" => format!("Planning for {}", run.repo),
555 "review" => format!("Review of {}#{}", run.repo, run.number),
556 "update" => format!("Catching up {}#{}", run.repo, run.number),
557 _ => format!("Work on {}#{}", run.repo, run.number),
558 };
Integrations: your own model provider, alerts that open issues, tickets agents read559 if run.own_provider() {
560 description.push_str(", on your own model provider");
561 }
Free while g1t is being built out; agents can check out their own forks562 if self.free {
563 description.push_str(" (free while g1t is being built out)");
564 }
Agents as a team: lifecycle, merge queue, billing and a new shell565 self.enter(
566 &run.workspace,
567 EntryKind::Usage,
568 -charge,
569 &description,
570 &a.run_id,
571 Some(&run),
572 Some(charge_micros(a.cost_usd, 0)),
573 None,
574 None,
575 )
576 .await?;
577 Ok(Outcome::Ok(true))
578 }
579}
580
581fn members_only<T>() -> Outcome<T> {
582 Outcome::fail(
583 FailureCode::Forbidden,
584 "Only members can see a workspace's billing.",
585 )
586}
587
588#[event(fetch)]
589async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> {
590 let Some(method) = rpc_method(&request) else {
591 return Response::error("Not found", 404);
592 };
593 let body: serde_json::Value = request.json().await?;
594 let billing = Billing {
595 db: env.d1("DB")?,
596 stripe: env
597 .secret("STRIPE_SECRET_KEY")
598 .ok()
599 .map(|key| key.to_string())
600 .filter(|key| !key.is_empty())
601 .map(Stripe::new),
602 margin_percent: env
603 .var("MARGIN_PERCENT")
604 .ok()
605 .and_then(|percent| percent.to_string().parse().ok())
606 .unwrap_or(20),
Integrations: your own model provider, alerts that open issues, tickets agents read607 orchestration_fee_micros: env
608 .var("ORCHESTRATION_FEE_MICROS")
609 .ok()
610 .and_then(|fee| fee.to_string().parse().ok())
611 .unwrap_or(100_000),
Free while g1t is being built out; agents can check out their own forks612 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 shell613 };
614 match method.as_str() {
615 "status" => reply(&billing.status()),
616 "account" => reply(&billing.account(args(body)?).await?),
617 "ledger" => reply(&billing.ledger(args(body)?).await?),
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request618 "usage" => reply(&billing.usage(args(body)?).await?),
Agents as a team: lifecycle, merge queue, billing and a new shell619 "checkout" => reply(&billing.checkout(args(body)?).await?),
620 "confirm" => reply(&billing.confirm(args(body)?).await?),
621 "can_start" => reply(&billing.can_start(args(body)?).await?),
622 "start_run" => reply(&billing.start_run(args(body)?).await?),
623 "finish_run" => reply(&billing.finish_run(args(body)?).await?),
624 _ => Response::error("Unknown method", 404),
625 }
626}
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631
632 #[test]
633 fn a_run_is_charged_its_cost_plus_the_margin() {
634 // $0.05 at 20% is six cents.
635 assert_eq!(charge_micros(0.05, 20), 60_000);
636 assert_eq!(charge_micros(1.0, 20), 1_200_000);
637 assert_eq!(charge_micros(0.05, 0), 50_000);
638 }
639
640 #[test]
641 fn fractions_of_a_millionth_round_up_and_nothing_costs_less_than_nothing() {
642 assert_eq!(charge_micros(0.000_000_4, 20), 2);
643 assert_eq!(charge_micros(0.0, 20), 0);
644 assert_eq!(charge_micros(-3.0, 20), 0);
645 }
646
647 #[test]
648 fn an_absurd_cost_is_capped() {
649 assert_eq!(charge_micros(1e9, 20), 120 * MICROS_PER_DOLLAR);
650 }
651}