pr_01m47d24b0e6n91zwymwxg0vpx/services/billing/src/lib.rs

746 lines27,236 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,
A free allowance on g1t's models, so anyone can try its agents129 /// The free allowance on g1t's hosted models, when there is one.
130 trial: Option<TrialConfig>,
131}
132
133/// `TRIAL_WORKSPACE_MICROS`, `TRIAL_TOTAL_MICROS` and `TRIAL_UNTIL`.
134struct TrialConfig {
135 per_workspace_micros: i64,
136 total_micros: i64,
137 /// RFC 3339, in UTC.
138 until: String,
139}
140
141#[derive(serde::Deserialize)]
142struct Sum {
143 micros: Option<i64>,
Agents as a team: lifecycle, merge queue, billing and a new shell144}
145
146impl Billing {
147 fn status(&self) -> Status {
148 Status {
149 enabled: self.stripe.is_some(),
150 live: self.stripe.as_ref().is_some_and(Stripe::live),
Free while g1t is being built out; agents can check out their own forks151 free: self.free,
Agents as a team: lifecycle, merge queue, billing and a new shell152 }
153 }
154
155 async fn row(&self, workspace: &str) -> Result<Option<AccountRow>> {
156 self.db
157 .prepare("SELECT balance_micros, customer_id FROM accounts WHERE workspace = ?")
158 .bind(&[workspace.into()])?
159 .first::<AccountRow>(None)
160 .await
161 }
162
163 async fn standing(&self, workspace: &str) -> Result<Account> {
164 Ok(Account {
165 workspace: workspace.to_owned(),
166 balance_micros: self
167 .row(workspace)
168 .await?
169 .map_or(0, |row| row.balance_micros),
170 status: self.status(),
171 margin_percent: self.margin_percent,
Integrations: your own model provider, alerts that open issues, tickets agents read172 orchestration_fee_micros: self.orchestration_fee_micros,
Agents as a team: lifecycle, merge queue, billing and a new shell173 })
174 }
175
176 /// Adds a ledger entry and moves the balance by the same amount, as
177 /// one write.
178 #[allow(clippy::too_many_arguments)]
179 async fn enter(
180 &self,
181 workspace: &str,
182 kind: EntryKind,
183 amount_micros: i64,
184 description: &str,
185 reference: &str,
186 run: Option<&RunRow>,
187 cost_micros: Option<i64>,
188 created_by: Option<&str>,
189 customer: Option<&str>,
190 ) -> Result<()> {
191 let now = now_ms();
192 let timestamp = rfc3339(now);
193 let kind = match kind {
194 EntryKind::TopUp => "top_up",
195 EntryKind::Usage => "usage",
196 };
197 self.db
198 .batch(vec![
199 self.db
200 .prepare(
201 "INSERT INTO ledger
202 (id, workspace, kind, amount_micros, description, repo, number, task,
Integrations: your own model provider, alerts that open issues, tickets agents read203 model, cost_micros, reference, created_by, created_at, billed_to)
204 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
Agents as a team: lifecycle, merge queue, billing and a new shell205 )
206 .bind(&[
207 new_id("led", now).into(),
208 workspace.into(),
209 kind.into(),
210 // D1 takes numbers as doubles, which hold every
211 // amount this service will see exactly.
212 (amount_micros as f64).into(),
213 description.into(),
214 optional(run.map(|run| run.repo.as_str())),
215 run.map_or(JsValue::NULL, |run| run.number.into()),
216 optional(run.map(|run| run.task.as_str())),
217 optional(run.map(|run| run.model.as_str())),
218 cost_micros.map_or(JsValue::NULL, |cost| (cost as f64).into()),
219 reference.into(),
220 optional(created_by),
221 timestamp.as_str().into(),
Integrations: your own model provider, alerts that open issues, tickets agents read222 run.map_or("g1t", |run| if run.own_provider() { "workspace" } else { "g1t" }).into(),
Agents as a team: lifecycle, merge queue, billing and a new shell223 ])?,
224 self.db
225 .prepare(
226 "INSERT INTO accounts (workspace, balance_micros, customer_id, created_at)
227 VALUES (?1, ?2, ?3, ?4)
228 ON CONFLICT (workspace) DO UPDATE SET
229 balance_micros = balance_micros + ?2,
230 customer_id = COALESCE(?3, customer_id)",
231 )
232 .bind(&[
233 workspace.into(),
234 (amount_micros as f64).into(),
235 optional(customer),
236 timestamp.as_str().into(),
237 ])?,
238 ])
239 .await?;
240 Ok(())
241 }
242
243 async fn account(&self, a: AccountArgs) -> Result<Outcome<Account>> {
244 let workspace = a.workspace.to_lowercase();
245 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
246 return Ok(members_only());
247 }
248 Ok(Outcome::Ok(self.standing(&workspace).await?))
249 }
250
251 async fn ledger(&self, a: AccountArgs) -> Result<Outcome<Vec<LedgerEntry>>> {
252 let workspace = a.workspace.to_lowercase();
253 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
254 return Ok(members_only());
255 }
256 let rows = self
257 .db
258 .prepare("SELECT * FROM ledger WHERE workspace = ? ORDER BY id DESC LIMIT ?")
259 .bind(&[workspace.into(), LEDGER_PAGE.into()])?
260 .all()
261 .await?
262 .results::<LedgerRow>()?;
263 Ok(Outcome::Ok(
264 rows.into_iter().map(LedgerEntry::from).collect(),
265 ))
266 }
267
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request268 async fn usage(&self, a: UsageArgs) -> Result<Outcome<Usage>> {
269 let workspace = a.workspace.to_lowercase();
270 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
271 return Ok(members_only());
272 }
273 #[derive(serde::Deserialize)]
274 struct SliceRow {
275 key: Option<String>,
276 micros: Option<i64>,
277 runs: Option<u32>,
278 }
Usage while free is shown at cost; agents get rustfmt and clippy279 // While nothing is charged, what was used is what there is to show.
280 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 request281 let slices = |key: &str, limit: u32| {
282 format!(
Usage while free is shown at cost; agents get rustfmt and clippy283 "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 request284 WHERE workspace = ?1 AND kind = 'usage' AND created_at >= ?2
285 GROUP BY 1 ORDER BY micros DESC LIMIT {limit}"
286 )
287 };
288 let query = |sql: String| {
289 let db = &self.db;
290 let workspace = workspace.clone();
291 let since = a.since.clone();
292 async move {
293 let rows = db
294 .prepare(sql)
295 .bind(&[workspace.into(), since.into()])?
296 .all()
297 .await?
298 .results::<SliceRow>()?;
299 Ok::<Vec<UsageSlice>, worker::Error>(
300 rows.into_iter()
301 .map(|row| UsageSlice {
302 key: row.key.unwrap_or_else(|| "other".to_owned()),
303 micros: row.micros.unwrap_or_default(),
304 runs: row.runs.unwrap_or_default(),
305 })
306 .collect(),
307 )
308 }
309 };
310 #[derive(serde::Deserialize)]
311 struct Totals {
312 spent: Option<i64>,
313 cost: Option<i64>,
Integrations: your own model provider, alerts that open issues, tickets agents read314 provider: Option<i64>,
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request315 runs: Option<u32>,
316 added: Option<i64>,
317 }
318 let totals = self
319 .db
320 .prepare(
321 "SELECT
322 -SUM(CASE WHEN kind = 'usage' THEN amount_micros END) AS spent,
Integrations: your own model provider, alerts that open issues, tickets agents read323 SUM(CASE WHEN kind = 'usage' AND COALESCE(billed_to, 'g1t') = 'g1t' THEN cost_micros END) AS cost,
324 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 request325 SUM(CASE WHEN kind = 'usage' THEN 1 ELSE 0 END) AS runs,
326 SUM(CASE WHEN kind = 'top_up' THEN amount_micros END) AS added
327 FROM ledger WHERE workspace = ?1 AND created_at >= ?2",
328 )
329 .bind(&[workspace.as_str().into(), a.since.as_str().into()])?
330 .first::<Totals>(None)
331 .await?;
332 let totals = totals.unwrap_or(Totals {
333 spent: None,
334 cost: None,
Integrations: your own model provider, alerts that open issues, tickets agents read335 provider: None,
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request336 runs: None,
337 added: None,
338 });
339 Ok(Outcome::Ok(Usage {
340 spent_micros: totals.spent.unwrap_or_default(),
341 cost_micros: totals.cost.unwrap_or_default(),
Integrations: your own model provider, alerts that open issues, tickets agents read342 provider_micros: totals.provider.unwrap_or_default(),
Usage while free is shown at cost; agents get rustfmt and clippy343 used_micros: totals.cost.unwrap_or_default() + totals.provider.unwrap_or_default(),
344 free: self.free,
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request345 runs: totals.runs.unwrap_or_default(),
346 added_micros: totals.added.unwrap_or_default(),
347 by_day: query(slices("substr(created_at, 1, 10) || '/' || COALESCE(task, 'other')", 400)).await?,
348 by_task: query(slices("task", 20)).await?,
349 by_repo: query(slices("repo", 20)).await?,
350 by_pull: query(slices("repo || '#' || number", 10)).await?,
351 by_model: query(slices("model", 10)).await?,
352 since: a.since,
353 }))
354 }
355
Agents as a team: lifecycle, merge queue, billing and a new shell356 async fn checkout(&self, a: CheckoutArgs) -> Result<Outcome<Checkout>> {
357 let workspace = a.workspace.to_lowercase();
358 if a.actor.role_in(&workspace) != Some(Role::Owner) {
359 return Ok(Outcome::fail(
360 FailureCode::Forbidden,
361 "Only an owner can add credit to a workspace.",
362 ));
363 }
364 let Some(stripe) = &self.stripe else {
365 return Ok(Outcome::fail(
366 FailureCode::Conflict,
367 "Payments are not set up on this g1t yet.",
368 ));
369 };
370 if !(MIN_TOP_UP_CENTS..=MAX_TOP_UP_CENTS).contains(&a.amount_cents) {
371 return Ok(Outcome::fail(
372 FailureCode::Invalid,
373 format!(
374 "Add between ${} and ${} at a time.",
375 MIN_TOP_UP_CENTS / 100,
376 MAX_TOP_UP_CENTS / 100
377 ),
378 ));
379 }
380 let customer = self.row(&workspace).await?.and_then(|row| row.customer_id);
381 let session = stripe
382 .start_checkout(
383 &workspace,
384 a.amount_cents,
385 customer.as_deref(),
386 &a.return_url,
387 )
388 .await?;
389 let Some(url) = session.url else {
390 return Err(worker::Error::RustError(
391 "the card processor returned no payment page".into(),
392 ));
393 };
394 self.db
395 .prepare(
396 "INSERT INTO checkouts (id, workspace, amount_cents, created_by, created_at)
397 VALUES (?, ?, ?, ?, ?)",
398 )
399 .bind(&[
400 session.id.into(),
401 workspace.into(),
402 a.amount_cents.into(),
403 a.actor.username.into(),
404 rfc3339(now_ms()).into(),
405 ])?
406 .run()
407 .await?;
408 Ok(Outcome::Ok(Checkout { url }))
409 }
410
411 /// Credits a payment if the processor says it was made and it has not
412 /// been credited before. The amount credited is what the processor
413 /// says was paid, not what anyone here remembers asking for.
414 async fn confirm(&self, a: ConfirmArgs) -> Result<Outcome<Account>> {
415 let workspace = a.workspace.to_lowercase();
416 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
417 return Ok(members_only());
418 }
419 let (Some(stripe), Some(checkout)) = (
420 &self.stripe,
421 self.db
422 .prepare(
423 "SELECT workspace, created_by FROM checkouts
424 WHERE id = ? AND workspace = ? AND status = 'open'",
425 )
426 .bind(&[a.session.as_str().into(), workspace.as_str().into()])?
427 .first::<CheckoutRow>(None)
428 .await?,
429 ) else {
430 // Unknown, someone else's, or already credited: nothing to do.
431 return Ok(Outcome::Ok(self.standing(&workspace).await?));
432 };
433 let session = stripe.session(&a.session).await?;
434 let paid = session
435 .amount_total
436 .filter(|_| session.payment_status == "paid");
437 if let Some(cents) = paid {
438 // Only whoever flips it from open to paid enters the credit.
439 let claimed = self
440 .db
441 .prepare(
442 "UPDATE checkouts SET status = 'paid' WHERE id = ? AND status = 'open'
443 RETURNING id",
444 )
445 .bind(&[a.session.as_str().into()])?
446 .first::<Touched>(None)
447 .await?;
448 if claimed.is_some() {
449 self.enter(
450 &checkout.workspace,
451 EntryKind::TopUp,
452 i64::from(cents) * MICROS_PER_DOLLAR / 100,
453 "Credit added by card",
454 &session.id,
455 None,
456 None,
457 Some(&checkout.created_by),
458 session.customer.as_deref(),
459 )
460 .await?;
461 }
462 }
463 Ok(Outcome::Ok(self.standing(&workspace).await?))
464 }
465
466 /// A refusal if the workspace has no credit to start an agent with.
467 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 forks468 // While g1t is being built out, no one needs credit.
469 if self.free {
470 return Ok(None);
471 }
Agents as a team: lifecycle, merge queue, billing and a new shell472 let balance = self
473 .row(workspace)
474 .await?
475 .map_or(0, |row| row.balance_micros);
476 Ok((balance <= 0).then(|| {
477 Outcome::fail(
478 FailureCode::PaymentRequired,
479 format!(
480 "The {workspace} workspace has no agent credit. An owner can add some under Billing on the workspace's page."
481 ),
482 )
483 }))
484 }
485
A free allowance on g1t's models, so anyone can try its agents486 /// A workspace's free allowance on g1t's hosted models: what its runs
487 /// there have cost against its share, and the pool everyone draws on.
488 async fn trial(&self, a: TrialArgs) -> Result<Trial> {
489 let workspace = a.workspace.to_lowercase();
490 let Some(config) = &self.trial else {
491 return Ok(Trial {
492 open: false,
493 used_micros: 0,
494 limit_micros: 0,
495 ends_at: None,
496 reason: Some("off".to_owned()),
497 });
498 };
499 let used = self
500 .db
501 .prepare(
502 "SELECT SUM(cost_micros) AS micros FROM ledger
503 WHERE kind = 'usage' AND COALESCE(billed_to, 'g1t') = 'g1t' AND workspace = ?",
504 )
505 .bind(&[workspace.as_str().into()])?
506 .first::<Sum>(None)
507 .await?
508 .and_then(|sum| sum.micros)
509 .unwrap_or_default();
510 // Everyone's, but for the workspaces open to hosted models anyway.
511 let exempt: Vec<String> = a.exempt.iter().map(|name| name.trim().to_lowercase()).collect();
512 let marks = vec!["?"; exempt.len().max(1)].join(", ");
513 let mut values: Vec<JsValue> = exempt.iter().map(|name| JsValue::from(name.as_str())).collect();
514 if values.is_empty() {
515 values.push(JsValue::from(""));
516 }
517 let pooled = self
518 .db
519 .prepare(format!(
520 "SELECT SUM(cost_micros) AS micros FROM ledger
521 WHERE kind = 'usage' AND COALESCE(billed_to, 'g1t') = 'g1t' AND workspace NOT IN ({marks})"
522 ))
523 .bind(&values)?
524 .first::<Sum>(None)
525 .await?
526 .and_then(|sum| sum.micros)
527 .unwrap_or_default();
528 let reason = if rfc3339(now_ms()) >= config.until {
529 Some("ended")
530 } else if used >= config.per_workspace_micros {
531 Some("used")
532 } else if pooled >= config.total_micros {
533 Some("pool")
534 } else {
535 None
536 };
537 Ok(Trial {
538 open: reason.is_none(),
539 used_micros: used,
540 limit_micros: config.per_workspace_micros,
541 ends_at: Some(config.until.clone()),
542 reason: reason.map(str::to_owned),
543 })
544 }
545
Agents as a team: lifecycle, merge queue, billing and a new shell546 async fn can_start(&self, a: CanStartArgs) -> Result<Outcome<bool>> {
547 if self.stripe.is_none() {
548 return Ok(Outcome::Ok(true));
549 }
550 Ok(self
551 .out_of_credit(&a.workspace.to_lowercase())
552 .await?
553 .unwrap_or(Outcome::Ok(true)))
554 }
555
556 async fn start_run(&self, a: StartRunArgs) -> Result<Outcome<Option<RunTicket>>> {
557 if self.stripe.is_none() {
558 return Ok(Outcome::Ok(None));
559 }
560 let workspace = a.workspace.to_lowercase();
561 if let Some(refused) = self.out_of_credit(&workspace).await? {
562 return Ok(refused);
563 }
564 let now = now_ms();
565 let run_id = new_id("run", now);
566 let mut bytes = [0u8; 32];
567 getrandom::getrandom(&mut bytes).expect("no source of randomness");
568 let token = hex::encode(bytes);
569 self.db
570 .prepare(
Integrations: your own model provider, alerts that open issues, tickets agents read571 "INSERT INTO runs (id, workspace, repo, number, task, model, token_hash, created_at, billed_to)
572 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
Agents as a team: lifecycle, merge queue, billing and a new shell573 )
574 .bind(&[
575 run_id.as_str().into(),
576 workspace.into(),
577 format!("{}/{}", a.repo.namespace, a.repo.name).into(),
578 a.number.into(),
579 a.task.into(),
580 a.model.into(),
581 hash(&token).into(),
582 rfc3339(now).into(),
Integrations: your own model provider, alerts that open issues, tickets agents read583 if a.billed_to == "workspace" { "workspace" } else { "g1t" }.into(),
Agents as a team: lifecycle, merge queue, billing and a new shell584 ])?
585 .run()
586 .await?;
587 Ok(Outcome::Ok(Some(RunTicket { run_id, token })))
588 }
589
590 async fn finish_run(&self, a: FinishRunArgs) -> Result<Outcome<bool>> {
591 let run = self
592 .db
593 .prepare(
Integrations: your own model provider, alerts that open issues, tickets agents read594 "SELECT workspace, repo, number, task, model, token_hash, billed_to FROM runs
Agents as a team: lifecycle, merge queue, billing and a new shell595 WHERE id = ? AND finished_at IS NULL",
596 )
597 .bind(&[a.run_id.as_str().into()])?
598 .first::<RunRow>(None)
599 .await?;
600 let Some(run) = run.filter(|run| run.token_hash == hash(&a.token)) else {
601 return Ok(Outcome::fail(FailureCode::NotFound, "Run not found."));
602 };
603 if !a.cost_usd.is_finite() || a.cost_usd < 0.0 {
604 return Ok(Outcome::fail(FailureCode::Invalid, "That is not a cost."));
605 }
606 // Only whoever closes the run charges for it.
607 let claimed = self
608 .db
609 .prepare(
610 "UPDATE runs SET finished_at = ? WHERE id = ? AND finished_at IS NULL RETURNING id",
611 )
612 .bind(&[rfc3339(now_ms()).into(), a.run_id.as_str().into()])?
613 .first::<Touched>(None)
614 .await?;
615 if claimed.is_none() {
616 return Ok(Outcome::Ok(false));
617 }
Integrations: your own model provider, alerts that open issues, tickets agents read618 // On the workspace's own provider, the model was paid for there:
619 // 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 forks620 let charge = if self.free {
621 // Recorded, with what it cost, but not charged.
622 0
623 } else if run.own_provider() {
Integrations: your own model provider, alerts that open issues, tickets agents read624 self.orchestration_fee_micros
625 } else {
626 charge_micros(a.cost_usd, self.margin_percent)
627 };
628 let mut description = match run.task.as_str() {
Agents as a team: lifecycle, merge queue, billing and a new shell629 "plan" => format!("Planning for {}", run.repo),
630 "review" => format!("Review of {}#{}", run.repo, run.number),
631 "update" => format!("Catching up {}#{}", run.repo, run.number),
632 _ => format!("Work on {}#{}", run.repo, run.number),
633 };
Integrations: your own model provider, alerts that open issues, tickets agents read634 if run.own_provider() {
635 description.push_str(", on your own model provider");
636 }
Free while g1t is being built out; agents can check out their own forks637 if self.free {
638 description.push_str(" (free while g1t is being built out)");
639 }
Agents as a team: lifecycle, merge queue, billing and a new shell640 self.enter(
641 &run.workspace,
642 EntryKind::Usage,
643 -charge,
644 &description,
645 &a.run_id,
646 Some(&run),
647 Some(charge_micros(a.cost_usd, 0)),
648 None,
649 None,
650 )
651 .await?;
652 Ok(Outcome::Ok(true))
653 }
654}
655
656fn members_only<T>() -> Outcome<T> {
657 Outcome::fail(
658 FailureCode::Forbidden,
659 "Only members can see a workspace's billing.",
660 )
661}
662
663#[event(fetch)]
664async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> {
665 let Some(method) = rpc_method(&request) else {
666 return Response::error("Not found", 404);
667 };
668 let body: serde_json::Value = request.json().await?;
669 let billing = Billing {
670 db: env.d1("DB")?,
671 stripe: env
672 .secret("STRIPE_SECRET_KEY")
673 .ok()
674 .map(|key| key.to_string())
675 .filter(|key| !key.is_empty())
676 .map(Stripe::new),
677 margin_percent: env
678 .var("MARGIN_PERCENT")
679 .ok()
680 .and_then(|percent| percent.to_string().parse().ok())
681 .unwrap_or(20),
Integrations: your own model provider, alerts that open issues, tickets agents read682 orchestration_fee_micros: env
683 .var("ORCHESTRATION_FEE_MICROS")
684 .ok()
685 .and_then(|fee| fee.to_string().parse().ok())
686 .unwrap_or(100_000),
Free while g1t is being built out; agents can check out their own forks687 free: env.var("FREE_WHILE_BUILDING").is_ok_and(|v| v.to_string() == "true"),
A free allowance on g1t's models, so anyone can try its agents688 trial: {
689 let number = |name: &str| env.var(name).ok().and_then(|v| v.to_string().parse::<i64>().ok());
690 match (
691 number("TRIAL_WORKSPACE_MICROS"),
692 number("TRIAL_TOTAL_MICROS"),
693 env.var("TRIAL_UNTIL").ok().map(|v| v.to_string()),
694 ) {
695 (Some(per_workspace_micros), Some(total_micros), Some(until))
696 if per_workspace_micros > 0 && !until.is_empty() =>
697 {
698 Some(TrialConfig {
699 per_workspace_micros,
700 total_micros,
701 until,
702 })
703 }
704 _ => None,
705 }
706 },
Agents as a team: lifecycle, merge queue, billing and a new shell707 };
708 match method.as_str() {
709 "status" => reply(&billing.status()),
710 "account" => reply(&billing.account(args(body)?).await?),
711 "ledger" => reply(&billing.ledger(args(body)?).await?),
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request712 "usage" => reply(&billing.usage(args(body)?).await?),
Agents as a team: lifecycle, merge queue, billing and a new shell713 "checkout" => reply(&billing.checkout(args(body)?).await?),
714 "confirm" => reply(&billing.confirm(args(body)?).await?),
715 "can_start" => reply(&billing.can_start(args(body)?).await?),
A free allowance on g1t's models, so anyone can try its agents716 "trial" => reply(&billing.trial(args(body)?).await?),
Agents as a team: lifecycle, merge queue, billing and a new shell717 "start_run" => reply(&billing.start_run(args(body)?).await?),
718 "finish_run" => reply(&billing.finish_run(args(body)?).await?),
719 _ => Response::error("Unknown method", 404),
720 }
721}
722
723#[cfg(test)]
724mod tests {
725 use super::*;
726
727 #[test]
728 fn a_run_is_charged_its_cost_plus_the_margin() {
729 // $0.05 at 20% is six cents.
730 assert_eq!(charge_micros(0.05, 20), 60_000);
731 assert_eq!(charge_micros(1.0, 20), 1_200_000);
732 assert_eq!(charge_micros(0.05, 0), 50_000);
733 }
734
735 #[test]
736 fn fractions_of_a_millionth_round_up_and_nothing_costs_less_than_nothing() {
737 assert_eq!(charge_micros(0.000_000_4, 20), 2);
738 assert_eq!(charge_micros(0.0, 20), 0);
739 assert_eq!(charge_micros(-3.0, 20), 0);
740 }
741
742 #[test]
743 fn an_absurd_cost_is_capped() {
744 assert_eq!(charge_micros(1e9, 20), 120 * MICROS_PER_DOLLAR);
745 }
746}