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
| 1466 | 1466 | "futures-util", | |
| 1467 | 1467 | "g1t-contracts", | |
| 1468 | 1468 | "g1t-kit", | |
| 1469 | + | "g1t-secrets", | |
| 1469 | 1470 | "getrandom 0.2.17", | |
| 1470 | 1471 | "hex", | |
| 1471 | 1472 | "hmac 0.12.1", | |
| 1473 | + | "rusqlite", | |
| 1472 | 1474 | "serde", | |
| 1473 | 1475 | "serde_json", | |
| 1474 | 1476 | "sha2 0.10.9", |
| 3996 | 3996 | pub language: Option<String>, | |
| 3997 | 3997 | } | |
| 3998 | 3998 | ||
| 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 | + | ||
| 3999 | 4150 | #[cfg(test)] | |
| 4000 | 4151 | mod tests { | |
| 4001 | 4152 | use super::*; |
| 1208 | 1208 | billingDetails(workspace: string, viewer: Viewer): Promise<Result<BillingDetails>>; | |
| 1209 | 1209 | /** Saves invoice details on the Stripe customer; absent fields stay, empty clears. Owners only. */ | |
| 1210 | 1210 | 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>>; | |
| 1211 | 1222 | } | |
| 1212 | 1223 | ||
| 1213 | 1224 | ||
| ⋯ | |||
| 1814 | 1825 | poNumber?: string; | |
| 1815 | 1826 | language?: string; | |
| 1816 | 1827 | }; | |
| 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 | + | }; | |
| 704 | 704 | setBudget: (actor, workspace, budget) => call("set_budget", { actor, workspace, ...budget }), | |
| 705 | 705 | billingDetails: (workspace, viewer) => call("billing_details", { workspace, viewer }), | |
| 706 | 706 | 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 }), | |
| 707 | 711 | }; | |
| 708 | 712 | } | |
| 709 | 713 |
| 11 | 11 | [dependencies] | |
| 12 | 12 | g1t-contracts.workspace = true | |
| 13 | 13 | g1t-kit.workspace = true | |
| 14 | + | g1t-secrets.workspace = true | |
| 14 | 15 | serde.workspace = true | |
| 15 | 16 | serde_json.workspace = true | |
| 16 | 17 | worker.workspace = true | |
| ⋯ | |||
| 20 | 21 | sha2 = "0.10" | |
| 21 | 22 | hmac = "0.12" | |
| 22 | 23 | ||
| 24 | + | [dev-dependencies] | |
| 25 | + | rusqlite = { version = "0.32", features = ["bundled"] } | |
| 26 | + | ||
| 23 | 27 | # wasm-opt at -O1: about the same gzipped size as -O in a tenth of the | |
| 24 | 28 | # time. | |
| 25 | 29 | [package.metadata.wasm-pack.profile.release] | |
| 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); |
| 32 | 32 | mod margin; | |
| 33 | 33 | mod platform; | |
| 34 | 34 | mod pricing; | |
| 35 | + | mod provider_costs; | |
| 35 | 36 | mod report; | |
| 36 | 37 | mod details; | |
| 37 | 38 | mod credits; | |
| ⋯ | |||
| 252 | 253 | ceilings: limits::Ceilings, | |
| 253 | 254 | /// The caps on what g1t pays for itself; see `budget`. | |
| 254 | 255 | 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>, | |
| 255 | 260 | /// The worker's bindings, for emailing staff (`EMAIL`). | |
| 256 | 261 | env: Env, | |
| 257 | 262 | } | |
| ⋯ | |||
| 1059 | 1064 | packages: env.service("PACKAGES").ok(), | |
| 1060 | 1065 | identity: env.service("IDENTITY").ok(), | |
| 1061 | 1066 | caps: budget::Caps::from_env(env), | |
| 1067 | + | sealer: env.secret("BILLING_KEY").ok().and_then(|key| g1t_secrets::Sealer::new(&key.to_string())), | |
| 1062 | 1068 | env: env.clone(), | |
| 1063 | 1069 | }) | |
| 1064 | 1070 | } | |
| ⋯ | |||
| 1203 | 1209 | if let Err(error) = billing.sweep_reservations().await { | |
| 1204 | 1210 | worker::console_error!("clearing reservations failed: {error}"); | |
| 1205 | 1211 | } | |
| 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 | + | } | |
| 1206 | 1218 | // The AI Gateway's request log, from when there was one, still | |
| 1207 | 1219 | // keeps only its last 30 days until it is empty. | |
| 1208 | 1220 | let cutoff = rfc3339(now_ms().saturating_sub(30 * 24 * 60 * 60 * 1000)); | |
| ⋯ | |||
| 1257 | 1269 | "set_budget" => reply(&billing.set_budget(args(body)?).await?), | |
| 1258 | 1270 | "billing_details" => reply(&billing.billing_details(args(body)?).await?), | |
| 1259 | 1271 | "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?), | |
| 1260 | 1277 | "checkout" => reply(&billing.checkout(args(body)?).await?), | |
| 1261 | 1278 | "confirm" => reply(&billing.confirm(args(body)?).await?), | |
| 1262 | 1279 | "trial" => reply(&billing.trial(args(body)?).await?), | |
| 121 | 121 | "DELETE FROM price_notices WHERE workspace = ?2", | |
| 122 | 122 | "UPDATE OR IGNORE closed_workspaces SET workspace = ?1 WHERE workspace = ?2", | |
| 123 | 123 | "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", | |
| 124 | 132 | // The margin figures and counts are redone each day from the ledger and | |
| 125 | 133 | // the meters; moved so the days between keep adding up. | |
| 126 | 134 | "UPDATE OR IGNORE workspace_costs SET workspace = ?1 WHERE workspace = ?2", | |
| ⋯ | |||
| 337 | 345 | "reservations", "spikes", "limit_requests", "plan_payments", "card_checks", "alerts_sent", | |
| 338 | 346 | "package_storage_days", "pending_days", "token_usage", "price_notices", "closed_workspaces", | |
| 339 | 347 | "workspace_costs", "own_counts", "ai_reload", "ai_reloads", "tax_and_fees", "reset_costs", | |
| 348 | + | "provider_connections", "provider_costs", | |
| 340 | 349 | ] { | |
| 341 | 350 | assert!(all.contains(&format!("FROM {table} WHERE workspace = ?2")) | |
| 342 | 351 | || all.contains(&format!("UPDATE {table} SET")) | |
| 182 | 182 | // STRIPE_WEBHOOK_SECRET: the signing secret (whsec_…) of the destination | |
| 183 | 183 | // made in Stripe's dashboard for https://api.g1t.sh/stripe/webhook. | |
| 184 | 184 | // 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. | |
| 185 | 189 | // Every log kept: money moves here, and every failure matters. | |
| 186 | 190 | "observability": { "enabled": true, "head_sampling_rate": 1 } | |
| 187 | 191 | } |
Comments
Sign in to comment.