Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

Billing reads what Anthropic and OpenAI say the organization spent

g1t meters only the runs it makes itself, so Home and the Workspace overview miss what a team's other agents spend: CI scripts and other tools on the same provider account. The provider's own cost report is the ground truth for that, so a workspace owner can now connect the organization's admin key and billing reads the report day by day. The key is checked with one small report request when it is connected, and a wrong one is named plainly (an API key where an admin key is needed, a key without permission). It is sealed with BILLING_KEY bound to the connection's id, never returned and never logged; only its last four characters are kept as a hint. The daily cron reads the last seven days again, since providers revise recent days, and 90 days the first time; a read replaces the days it covers, so it is idempotent, and one provider failing records why on its connection and leaves the others. Anthropic is read from /v1/organizations/cost_report grouped by workspace_id and description, OpenAI from /v1/organization/costs grouped by line_item and project_id, both as their docs describe. Part of #157

G1t-Session:
ses_01m573jm1je4tv3wf6e9e2f7q4
syntaqxcommitted Parent0ba9169Browse files
10 files+1479−00/10 viewed
+2−0
14661466 "futures-util",
14671467 "g1t-contracts",
14681468 "g1t-kit",
1469+ "g1t-secrets",
14691470 "getrandom 0.2.17",
14701471 "hex",
14711472 "hmac 0.12.1",
1473+ "rusqlite",
14721474 "serde",
14731475 "serde_json",
14741476 "sha2 0.10.9",
+151−0
39963996 pub language: Option<String>,
39973997 }
39983998
3999+// --- Provider cost reports ----------------------------------------------------
4000+//
4001+// A workspace connects an AI provider's admin key, and billing reads what
4002+// the provider says the organization spent, day by day, from the
4003+// provider's own cost report: every use of that organization, inside g1t
4004+// or not. The key is sealed when it is saved and never returned; only its
4005+// last four characters are.
4006+
4007+/// An AI provider whose cost report g1t reads.
4008+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
4009+#[serde(rename_all = "snake_case")]
4010+pub enum CostProvider {
4011+ /// Anthropic's Usage and Cost Admin API, with an Admin API key.
4012+ Anthropic,
4013+ /// OpenAI's Costs API, with an admin key.
4014+ Openai,
4015+}
4016+
4017+impl CostProvider {
4018+ pub const ALL: [CostProvider; 2] = [CostProvider::Anthropic, CostProvider::Openai];
4019+
4020+ pub fn name(self) -> &'static str {
4021+ match self {
4022+ CostProvider::Anthropic => "anthropic",
4023+ CostProvider::Openai => "openai",
4024+ }
4025+ }
4026+
4027+ pub fn label(self) -> &'static str {
4028+ match self {
4029+ CostProvider::Anthropic => "Anthropic",
4030+ CostProvider::Openai => "OpenAI",
4031+ }
4032+ }
4033+
4034+ pub fn parse(name: &str) -> Option<CostProvider> {
4035+ CostProvider::ALL.into_iter().find(|provider| provider.name() == name)
4036+ }
4037+}
4038+
4039+/// A provider a workspace has connected, as anyone may see it: never the
4040+/// key, only its hint.
4041+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
4042+#[serde(rename_all = "camelCase")]
4043+pub struct ProviderConnection {
4044+ pub provider: CostProvider,
4045+ /// The key's last four characters, such as `…a1B2`.
4046+ pub hint: String,
4047+ /// The username of the owner who connected it.
4048+ pub connected_by: String,
4049+ /// RFC 3339.
4050+ pub connected_at: String,
4051+ /// RFC 3339: when its report was last read in full. None until then.
4052+ pub last_synced_at: Option<String>,
4053+ /// What went wrong at the last read, in a sentence that says what to
4054+ /// do; None when it worked.
4055+ pub last_error: Option<String>,
4056+ /// `YYYY-MM-DD`: the first day g1t has asked the provider for. None
4057+ /// until the first read.
4058+ #[serde(default)]
4059+ pub synced_from: Option<String>,
4060+}
4061+
4062+/// `provider_connections`: the providers a workspace has connected.
4063+/// Members only. Returns `Outcome<Vec<ProviderConnection>>`.
4064+#[derive(Debug, Serialize, Deserialize)]
4065+#[serde(rename_all = "camelCase")]
4066+pub struct ProviderConnectionsArgs {
4067+ pub workspace: String,
4068+ pub viewer: Viewer,
4069+}
4070+
4071+/// `connect_provider`: saves a provider's admin key for the workspace,
4072+/// after one small report request shows that it works, and reads up to 90
4073+/// days of its report. A workspace has one connection per provider:
4074+/// connecting again replaces the key and the costs read with the old one.
4075+/// Owners only, as people. Returns `Outcome<ProviderConnection>`.
4076+#[derive(Serialize, Deserialize)]
4077+#[serde(rename_all = "camelCase")]
4078+pub struct ConnectProviderArgs {
4079+ pub actor: User,
4080+ pub workspace: String,
4081+ pub provider: CostProvider,
4082+ /// The admin key. Never logged, never returned.
4083+ pub credential: String,
4084+}
4085+
4086+/// Written out without the key, so that logging the arguments cannot leak it.
4087+impl std::fmt::Debug for ConnectProviderArgs {
4088+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4089+ f.debug_struct("ConnectProviderArgs")
4090+ .field("actor", &self.actor.username)
4091+ .field("workspace", &self.workspace)
4092+ .field("provider", &self.provider)
4093+ .field("credential", &"<redacted>")
4094+ .finish()
4095+ }
4096+}
4097+
4098+/// `disconnect_provider`: deletes the provider's key and every cost read
4099+/// with it. Owners only, as people. Returns `Outcome<bool>`: whether there
4100+/// was a connection to delete.
4101+#[derive(Debug, Serialize, Deserialize)]
4102+#[serde(rename_all = "camelCase")]
4103+pub struct DisconnectProviderArgs {
4104+ pub actor: User,
4105+ pub workspace: String,
4106+ pub provider: CostProvider,
4107+}
4108+
4109+/// `provider_costs`: what the connected providers say the workspace's
4110+/// organizations spent, by day. Members only. Returns
4111+/// `Outcome<ProviderCosts>`.
4112+#[derive(Debug, Serialize, Deserialize)]
4113+#[serde(rename_all = "camelCase")]
4114+pub struct ProviderCostsArgs {
4115+ pub workspace: String,
4116+ pub viewer: Viewer,
4117+ /// `YYYY-MM-DD` (or an RFC 3339 time, whose day is used): the first
4118+ /// day, UTC.
4119+ pub since: String,
4120+}
4121+
4122+/// One day's cost of one line item at one provider, summed over the
4123+/// provider's own workspaces or projects.
4124+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
4125+#[serde(rename_all = "camelCase")]
4126+pub struct ProviderCostDay {
4127+ /// `YYYY-MM-DD`, UTC.
4128+ pub day: String,
4129+ pub provider: CostProvider,
4130+ /// The provider's own words for it: Anthropic's description (such as
4131+ /// "Claude Opus 5.5 Usage - Input Tokens"), OpenAI's line item. Empty
4132+ /// when the provider gave none.
4133+ pub item: String,
4134+ /// Millionths of a US dollar.
4135+ pub micros: i64,
4136+}
4137+
4138+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
4139+#[serde(rename_all = "camelCase")]
4140+pub struct ProviderCosts {
4141+ pub connections: Vec<ProviderConnection>,
4142+ /// Oldest first.
4143+ pub days: Vec<ProviderCostDay>,
4144+ /// True when at least one provider is connected and every one was last
4145+ /// read without an error and has been read back to `since`. The day in
4146+ /// progress is never complete at the provider either.
4147+ pub complete: bool,
4148+}
4149+
39994150 #[cfg(test)]
40004151 mod tests {
40014152 use super::*;
+61−0
12081208 billingDetails(workspace: string, viewer: Viewer): Promise<Result<BillingDetails>>;
12091209 /** Saves invoice details on the Stripe customer; absent fields stay, empty clears. Owners only. */
12101210 setBillingDetails(actor: User, workspace: string, details: BillingDetailsInput): Promise<Result<BillingDetails>>;
1211+ /** The AI providers whose cost reports the workspace reads: never their keys. Members only. */
1212+ providerConnections(workspace: string, viewer: Viewer): Promise<Result<ProviderConnection[]>>;
1213+ /**
1214+ * Saves a provider's admin key after one small report request shows it works, then reads up to 90 days of its
1215+ * report. Connecting again replaces the key and the costs read with the old one. Owners only.
1216+ */
1217+ connectProvider(actor: User, workspace: string, provider: CostProvider, credential: string): Promise<Result<ProviderConnection>>;
1218+ /** Deletes the provider's key and every cost read with it. Owners only. True when there was one. */
1219+ disconnectProvider(actor: User, workspace: string, provider: CostProvider): Promise<Result<boolean>>;
1220+ /** What the connected providers say the organization spent, by day, from `since` (`YYYY-MM-DD`, UTC). Members only. */
1221+ providerCosts(workspace: string, viewer: Viewer, since: string): Promise<Result<ProviderCosts>>;
12111222 }
12121223
12131224
18141825 poNumber?: string;
18151826 language?: string;
18161827 };
1828+
1829+/** An AI provider whose own cost report g1t reads, with the organization's admin key. */
1830+export type CostProvider = "anthropic" | "openai";
1831+
1832+export const COST_PROVIDERS: Record<CostProvider, { label: string }> = {
1833+ anthropic: { label: "Anthropic" },
1834+ openai: { label: "OpenAI" },
1835+};
1836+
1837+export function isCostProvider(value: unknown): value is CostProvider {
1838+ return value === "anthropic" || value === "openai";
1839+}
1840+
1841+/** A provider a workspace has connected: never its key, only the key's last four characters. */
1842+export type ProviderConnection = {
1843+ provider: CostProvider;
1844+ /** Such as `…a1B2`. */
1845+ hint: string;
1846+ /** The owner who connected it. */
1847+ connectedBy: string;
1848+ connectedAt: string;
1849+ /** When its report was last read in full; null until then. */
1850+ lastSyncedAt: string | null;
1851+ /** What went wrong at the last read, in a sentence that says what to do; null when it worked. */
1852+ lastError: string | null;
1853+ /** `YYYY-MM-DD`: the first day g1t has asked the provider for; null until the first read. */
1854+ syncedFrom?: string | null;
1855+};
1856+
1857+/** One day's cost of one line item at one provider, summed over the provider's own workspaces or projects. */
1858+export type ProviderCostDay = {
1859+ /** `YYYY-MM-DD`, UTC. */
1860+ day: string;
1861+ provider: CostProvider;
1862+ /** The provider's own words: Anthropic's description, OpenAI's line item. Empty when it gave none. */
1863+ item: string;
1864+ /** Millionths of a US dollar. */
1865+ micros: number;
1866+};
1867+
1868+export type ProviderCosts = {
1869+ connections: ProviderConnection[];
1870+ /** Oldest first. */
1871+ days: ProviderCostDay[];
1872+ /**
1873+ * True when at least one provider is connected and every one was last read without an error and back to `since`.
1874+ * The day in progress is never complete at the provider either.
1875+ */
1876+ complete: boolean;
1877+};
+4−0
704704 setBudget: (actor, workspace, budget) => call("set_budget", { actor, workspace, ...budget }),
705705 billingDetails: (workspace, viewer) => call("billing_details", { workspace, viewer }),
706706 setBillingDetails: (actor, workspace, details) => call("set_billing_details", { actor, workspace, ...details }),
707+ providerConnections: (workspace, viewer) => call("provider_connections", { workspace, viewer }),
708+ connectProvider: (actor, workspace, provider, credential) => call("connect_provider", { actor, workspace, provider, credential }),
709+ disconnectProvider: (actor, workspace, provider) => call("disconnect_provider", { actor, workspace, provider }),
710+ providerCosts: (workspace, viewer, since) => call("provider_costs", { workspace, viewer, since }),
707711 };
708712 }
709713
+4−0
1111 [dependencies]
1212 g1t-contracts.workspace = true
1313 g1t-kit.workspace = true
14+g1t-secrets.workspace = true
1415 serde.workspace = true
1516 serde_json.workspace = true
1617 worker.workspace = true
2021 sha2 = "0.10"
2122 hmac = "0.12"
2223
24+[dev-dependencies]
25+rusqlite = { version = "0.32", features = ["bundled"] }
26+
2327 # wasm-opt at -O1: about the same gzipped size as -O in a tenth of the
2428 # time.
2529 [package.metadata.wasm-pack.profile.release]
+40−0
1+-- Provider cost reports (src/provider_costs.rs): a workspace's AI
2+-- provider admin key, and what that provider's own cost report says the
3+-- organization spent, by day.
4+
5+-- One per workspace and provider. The key is sealed with BILLING_KEY,
6+-- bound to the row's id; `hint` is its last four characters, the only
7+-- part ever shown.
8+CREATE TABLE provider_connections (
9+ id TEXT PRIMARY KEY,
10+ workspace TEXT NOT NULL,
11+ provider TEXT NOT NULL,
12+ credential TEXT NOT NULL,
13+ hint TEXT NOT NULL,
14+ connected_by TEXT NOT NULL,
15+ connected_at TEXT NOT NULL,
16+ last_synced_at TEXT,
17+ last_error TEXT,
18+ -- YYYY-MM-DD: the first day asked for. Unset until the first read,
19+ -- which goes back 90 days.
20+ synced_from TEXT,
21+ UNIQUE (workspace, provider)
22+);
23+
24+-- What the provider reported, per UTC day, line item (Anthropic's
25+-- description, OpenAI's line item) and the provider's own workspace or
26+-- project (`scope`, empty for its default). Each read replaces the days
27+-- it covers, since providers revise recent days.
28+CREATE TABLE provider_costs (
29+ workspace TEXT NOT NULL,
30+ provider TEXT NOT NULL,
31+ day TEXT NOT NULL,
32+ item TEXT NOT NULL DEFAULT '',
33+ scope TEXT NOT NULL DEFAULT '',
34+ micros INTEGER NOT NULL,
35+ currency TEXT NOT NULL DEFAULT 'USD',
36+ synced_at TEXT NOT NULL,
37+ PRIMARY KEY (workspace, provider, day, item, scope)
38+);
39+
40+CREATE INDEX provider_costs_by_day ON provider_costs (workspace, day);
+17−0
3232 mod margin;
3333 mod platform;
3434 mod pricing;
35+mod provider_costs;
3536 mod report;
3637 mod details;
3738 mod credits;
252253 ceilings: limits::Ceilings,
253254 /// The caps on what g1t pays for itself; see `budget`.
254255 caps: budget::Caps,
256+ /// What seals the AI providers' admin keys a workspace connects for
257+ /// their cost reports (`BILLING_KEY`); without it none can be
258+ /// connected. See `provider_costs`.
259+ sealer: Option<g1t_secrets::Sealer>,
255260 /// The worker's bindings, for emailing staff (`EMAIL`).
256261 env: Env,
257262 }
10591064 packages: env.service("PACKAGES").ok(),
10601065 identity: env.service("IDENTITY").ok(),
10611066 caps: budget::Caps::from_env(env),
1067+ sealer: env.secret("BILLING_KEY").ok().and_then(|key| g1t_secrets::Sealer::new(&key.to_string())),
10621068 env: env.clone(),
10631069 })
10641070 }
12031209 if let Err(error) = billing.sweep_reservations().await {
12041210 worker::console_error!("clearing reservations failed: {error}");
12051211 }
1212+ // What each connected AI provider says its organization spent
1213+ // (provider_costs.rs): the last days again, each provider on its own.
1214+ match billing.sync_provider_costs().await {
1215+ Ok((read, failed)) => worker::console_log!("provider costs: {read} read, {failed} failed"),
1216+ Err(error) => worker::console_error!("reading provider costs failed: {error}"),
1217+ }
12061218 // The AI Gateway's request log, from when there was one, still
12071219 // keeps only its last 30 days until it is empty.
12081220 let cutoff = rfc3339(now_ms().saturating_sub(30 * 24 * 60 * 60 * 1000));
12571269 "set_budget" => reply(&billing.set_budget(args(body)?).await?),
12581270 "billing_details" => reply(&billing.billing_details(args(body)?).await?),
12591271 "set_billing_details" => reply(&billing.set_billing_details(args(body)?).await?),
1272+ // Provider cost reports (provider_costs.rs).
1273+ "provider_connections" => reply(&billing.provider_connections(args(body)?).await?),
1274+ "connect_provider" => reply(&billing.connect_provider(args(body)?).await?),
1275+ "disconnect_provider" => reply(&billing.disconnect_provider(args(body)?).await?),
1276+ "provider_costs" => reply(&billing.provider_costs(args(body)?).await?),
12601277 "checkout" => reply(&billing.checkout(args(body)?).await?),
12611278 "confirm" => reply(&billing.confirm(args(body)?).await?),
12621279 "trial" => reply(&billing.trial(args(body)?).await?),
+1187−0
A large diff (1,187 lines) is not shown until you ask for it.
+9−0
121121 "DELETE FROM price_notices WHERE workspace = ?2",
122122 "UPDATE OR IGNORE closed_workspaces SET workspace = ?1 WHERE workspace = ?2",
123123 "DELETE FROM closed_workspaces WHERE workspace = ?2",
124+ // A provider connected under both slugs: the current slug's stays, with
125+ // its own costs (costs first, while the connections say whose they
126+ // are). Keys are sealed to the row's id, not the slug.
127+ "UPDATE OR IGNORE provider_costs SET workspace = ?1
128+ WHERE workspace = ?2 AND provider NOT IN (SELECT provider FROM provider_connections WHERE workspace = ?1)",
129+ "DELETE FROM provider_costs WHERE workspace = ?2",
130+ "UPDATE OR IGNORE provider_connections SET workspace = ?1 WHERE workspace = ?2",
131+ "DELETE FROM provider_connections WHERE workspace = ?2",
124132 // The margin figures and counts are redone each day from the ledger and
125133 // the meters; moved so the days between keep adding up.
126134 "UPDATE OR IGNORE workspace_costs SET workspace = ?1 WHERE workspace = ?2",
337345 "reservations", "spikes", "limit_requests", "plan_payments", "card_checks", "alerts_sent",
338346 "package_storage_days", "pending_days", "token_usage", "price_notices", "closed_workspaces",
339347 "workspace_costs", "own_counts", "ai_reload", "ai_reloads", "tax_and_fees", "reset_costs",
348+ "provider_connections", "provider_costs",
340349 ] {
341350 assert!(all.contains(&format!("FROM {table} WHERE workspace = ?2"))
342351 || all.contains(&format!("UPDATE {table} SET"))
+4−0
182182 // STRIPE_WEBHOOK_SECRET: the signing secret (whsec_…) of the destination
183183 // made in Stripe's dashboard for https://api.g1t.sh/stripe/webhook.
184184 // Without it every event is refused (the replay still reads them).
185+ // BILLING_KEY: 64 hex characters (openssl rand -hex 32), which seals the
186+ // AI providers' admin keys workspaces connect for their cost reports
187+ // (src/provider_costs.rs). Without it none can be connected; changing it
188+ // means every provider has to be connected again.
185189 // Every log kept: money moves here, and every failure matters.
186190 "observability": { "enabled": true, "head_sampling_rate": 1 }
187191 }

Comments

Sign in to comment.