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

Merge branch 'worktree-agent-ab475706573b4fd42' into home-signal

syntaqxcommitted Parents11d7d19e60c293Browse files
17 files+1840−21 file not counted0/17 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",
+60−0
10041004 and what covered it, for your own books. Invoices from Stripe remain the
10051005 record for what was charged to your card.
10061006
1007+## Provider cost reports
1008+
1009+g1t counts what its own runs cost. Your team's other agents (CI scripts,
1010+other tools, people's own sessions) are on your AI provider's bill too,
1011+and the provider's own cost report is the record of all of it. Connect
1012+the provider and g1t reads that report, day by day, at
1013+`g1t.sh/<workspace>/-/provider-costs` (**Provider costs**, under Money in
1014+the workspace's settings).
1015+
1016+**What it reads.** The provider's cost report for the whole organization
1017+the key belongs to, in US dollars, one line per day and line item:
1018+
1019+| Provider | Report | Line items |
1020+| --- | --- | --- |
1021+| Anthropic | The Usage and Cost Admin API's cost report (`/v1/organizations/cost_report`) | Each description, such as *Claude Opus 5.5 Usage - Input Tokens*, for each of the organization's Console workspaces |
1022+| OpenAI | The Costs API (`/v1/organization/costs`) | Each line item, for each of the organization's projects |
1023+
1024+**Which key.** An admin key, which only an organization's admins can make:
1025+
1026+- **Anthropic**: an Admin API key (it starts `sk-ant-admin`), made in the
1027+ Claude Console under **Settings**, **Admin keys**. An ordinary API key
1028+ or a workspace's key can't read the cost report. Individual accounts have
1029+ no Admin API: set up an organization first.
1030+- **OpenAI**: an admin key (it starts `sk-admin-`), made in the OpenAI
1031+ platform under **Organization settings**, **Admin keys**. A project or
1032+ user API key can't read the Costs API.
1033+
1034+Only an owner of the workspace can connect a provider, one key per
1035+provider. g1t checks the key with one small report request and says why
1036+when it doesn't work (the wrong kind of key, no permission), then reads
1037+the last 90 days.
1038+
1039+**What g1t keeps.** The key, sealed when it is saved; it is never shown
1040+again, never sent back to anyone and never written to a log. The page shows
1041+its last four characters, who connected it and when, when it was last read
1042+and what went wrong. And the costs: day, provider, line item, the provider's
1043+own workspace or project, and the amount. g1t uses the key for nothing but
1044+reading the report: never to run a model.
1045+
1046+**How often.** Once a day, around 04:17 UTC. Each read asks for the last
1047+seven days again, since providers revise recent days (and further back,
1048+up to 90 days, after reads have failed), and replaces what it had for
1049+those days. A provider that fails keeps its error on the page until a
1050+read works; the others are read as usual. The day in progress is
1051+incomplete at the provider too.
1052+
1053+**Not added to g1t's own figures.** Runs g1t made on the workspace's own
1054+key, such as Ask and sessions on [your AI providers](/guides/ai/), are on
1055+the provider's report as well. The provider's figure already includes
1056+them, so it is shown beside g1t's, never added to it.
1057+
1058+**Disconnecting.** **Disconnect** on the page deletes the key and every cost
1059+read with it. Replacing the key does the same and reads the last 90 days
1060+again, since a new key may belong to another organization. To stop the key
1061+working anywhere, also delete it at the provider.
1062+
1063+Members can see which providers are connected and what they reported;
1064+only owners connect, replace and disconnect them. Services read the figures
1065+with the billing service's `provider_costs` RPC.
1066+
10071067 ## For services: entitlements, reserve, settle
10081068
10091069 Every g1t service that starts something that costs money asks billing
+3−1
1−import { Database, Activity, BarChart3, Bot, Cpu, Columns3, Radio, ScrollText, BrickWall, Building2, ChartLine, Flag, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, Gauge, GitBranch, GitPullRequest, Globe, History, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTodo, Lock, LogOut, Mail, MonitorCog, MonitorDown, Package, PlayCircle, Plug, Plus, Repeat, Rocket, Search, ServerCog, Settings, Shapes, Shield, ShieldCheck, Scale, Smile, Sparkles, Star, House, Ticket, TrendingUp, UserPlus, UserRoundKey, Users, UsersRound, Webhook, X } from "lucide-react";
1+import { Database, Activity, BarChart3, Bot, Cpu, Columns3, Radio, ScrollText, BrickWall, Building2, ChartLine, Flag, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, Gauge, GitBranch, GitPullRequest, Globe, History, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTodo, Lock, LogOut, Mail, MonitorCog, MonitorDown, Package, PlayCircle, Plug, Plus, ReceiptText, Repeat, Rocket, Search, ServerCog, Settings, Shapes, Shield, ShieldCheck, Scale, Smile, Sparkles, Star, House, Ticket, TrendingUp, UserPlus, UserRoundKey, Users, UsersRound, Webhook, X } from "lucide-react";
22 import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
33 import { Link, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
44
13291329 "personal-access-tokens": <UserRoundKey size={15} />,
13301330 usage: <BarChart3 size={15} />,
13311331 billing: <CreditCard size={15} />,
1332+ "provider-costs": <ReceiptText size={15} />,
13321333 runners: <ServerCog size={15} />,
13331334 repositories: <BookMarked size={15} />,
13341335 actions: <PlayCircle size={15} />,
16091610 "personal-access-tokens": "Personal access tokens",
16101611 usage: "Usage",
16111612 billing: "Billing and plans",
1613+ "provider-costs": "Provider costs",
16121614 integrations: "Integrations",
16131615 ai: "AI",
16141616 webhooks: "Webhooks",
+1−1
3535 "compare", "confirm-email", "context", "deployments", "dm", "docs", "emails", "emoji", "entries", "explore", "files", "forgot", "gateway",
3636 "github", "guardrails", "home", "inbox", "apps", "insights", "integrations", "invitations", "invite", "invites", "issues",
3737 "jobs", "keys", "labels", "login", "members", "merge-queue", "milestones", "new", "notifications", "overview",
38− "packages", "patterns", "people", "personal-access-tokens", "pins", "policies", "profile", "projects", "pull",
38+ "packages", "patterns", "people", "personal-access-tokens", "pins", "policies", "profile", "projects", "provider-costs", "pull",
3939 "pulls", "queue", "releases", "repositories", "reset", "rules", "runners", "runs", "search", "secrets", "security",
4040 "security-log", "settings", "soon", "spend", "tags", "teams", "today", "tokens", "tree", "two-factor", "u", "usage",
4141 "verify", "webhooks", "work", "workspace", "workspaces",
+1−0
134134 assert.deepEqual(workspaceSettingsPage("/acme/-/rules/rs_1", "acme"), { group: "security", page: "rules" });
135135 assert.deepEqual(workspaceSettingsPage("/acme/-/security/settings", "acme"), { group: "security", page: "security-settings" });
136136 assert.deepEqual(workspaceSettingsPage("/acme/-/usage", "acme"), { group: "money", page: "usage" });
137+ assert.deepEqual(workspaceSettingsPage("/acme/-/provider-costs", "acme"), { group: "money", page: "provider-costs" });
137138 // The overview, Code's security page and another workspace are no settings page.
138139 assert.equal(workspaceSettingsPage("/acme/-/workspace", "acme"), null);
139140 assert.equal(workspaceSettingsPage("/acme/-/security", "acme"), null);
+2−0
6464 pages: [
6565 { key: "usage", label: "Usage", path: "usage" },
6666 { key: "billing", label: "Billing and plans", path: "billing" },
67+ { key: "provider-costs", label: "Provider costs", path: "provider-costs" },
6768 ],
6869 },
6970 {
260261 workspace: "workspace",
261262 usage: "workspace",
262263 billing: "workspace",
264+ "provider-costs": "workspace",
263265 integrations: "workspace",
264266 ai: "workspace",
265267 guardrails: "workspace",
+3−0
158158 route("-/personal-access-tokens", "routes/workspace/personal-access-tokens.tsx"),
159159 route("-/usage", "routes/workspace/usage.tsx"),
160160 route("-/billing", "routes/workspace/billing.tsx"),
161+ // What the workspace's AI providers say its organization spent, from
162+ // their own cost reports (services/billing/src/provider_costs.rs).
163+ route("-/provider-costs", "routes/workspace/provider-costs.tsx"),
161164 route("-/billing/entries", "routes/workspace/statement-entries.ts"),
162165 route("-/billing/statement.csv", "routes/workspace/statement-csv.ts"),
163166 // The workspace's AI: its AI providers, managed or self-hosted, and
+295−0
1+import { AlertTriangle, CheckCircle2, ChevronRight, ReceiptText, X } from "lucide-react";
2+import { Form, data } from "react-router";
3+
4+import { COST_PROVIDERS, type CostProvider, type ProviderConnection, type ProviderCosts, isCostProvider } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/provider-costs";
7+import { AiProviderMark } from "../../components/connectors";
8+import { Unavailable } from "../../components/boundary";
9+import { ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui";
10+import { Card } from "../../components/ui/card";
11+import { page } from "../../lib/meta";
12+import { money } from "../../lib/money";
13+import { type Failed, isFailed, part } from "../../lib/partial";
14+import { billing } from "../../lib/services.server";
15+import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
16+
17+export function meta({ params, ...args }: Route.MetaArgs) {
18+ return page(args, { title: `Provider costs · ${params.owner} · g1t` });
19+}
20+
21+/** How many days the page totals. */
22+const RECENT_DAYS = 30;
23+
24+/** Which key each provider's report needs, and where it is made. */
25+const KEYS: Record<CostProvider, { key: string; where: string; placeholder: string }> = {
26+ anthropic: {
27+ key: "an Admin API key",
28+ where: "Make one in the Claude Console under Settings, Admin keys. Only an organization admin can.",
29+ placeholder: "sk-ant-admin…",
30+ },
31+ openai: {
32+ key: "an admin key",
33+ where: "Make one in the OpenAI platform under Organization settings, Admin keys. Only an organization owner can.",
34+ placeholder: "sk-admin-…",
35+ },
36+};
37+
38+/**
39+ * Settings, Provider costs: the AI providers whose own cost reports g1t
40+ * reads for the workspace (services/billing/src/provider_costs.rs). Every
41+ * member can see which are connected, when they were last read and what
42+ * went wrong; only owners connect and disconnect them. Keys are never
43+ * shown again, only their last four characters.
44+ */
45+export async function loader({ params, context }: Route.LoaderArgs) {
46+ const viewer = getViewer(context);
47+ const role = roleIn(viewer, params.owner);
48+ if (!role) throw data(null, { status: 404 });
49+ const slug = params.owner.toLowerCase();
50+ const since = new Date(Date.now() - (RECENT_DAYS - 1) * 86_400_000).toISOString().slice(0, 10);
51+ // The connections are what the page is about; the totals beside them are a part.
52+ const [connections, costs] = await Promise.all([
53+ billing.providerConnections(slug, viewer),
54+ part(billing.providerCosts(slug, viewer, since), "What the providers reported"),
55+ ]);
56+ return {
57+ slug,
58+ owner: role === "owner",
59+ connections: unwrap(connections),
60+ totals: totalsOf(costs),
61+ };
62+}
63+
64+/** Each provider's total over the recent days, or the part's failure. */
65+function totalsOf(costs: Awaited<ReturnType<typeof billing.providerCosts>> | Failed): Partial<Record<CostProvider, number>> | Failed {
66+ if (isFailed(costs)) return costs;
67+ if (!costs.ok) return { failed: true, what: "What the providers reported" };
68+ const totals: Partial<Record<CostProvider, number>> = {};
69+ for (const day of (costs.value as ProviderCosts).days) totals[day.provider] = (totals[day.provider] ?? 0) + day.micros;
70+ return totals;
71+}
72+
73+export async function action({ params, context, request }: Route.ActionArgs) {
74+ assertSameOrigin(request);
75+ const user = requireUser(context, request);
76+ const slug = params.owner.toLowerCase();
77+ const form = await request.formData();
78+ const provider = String(form.get("provider") ?? "");
79+ if (!isCostProvider(provider)) return { ok: false as const, provider: null, error: "Choose a provider." };
80+ try {
81+ if (form.get("intent") === "disconnect") {
82+ const removed = await billing.disconnectProvider(user, slug, provider);
83+ return removed.ok ? { ok: true as const, provider, disconnected: true as const } : { ok: false as const, provider, error: removed.error.message };
84+ }
85+ const credential = String(form.get("credential") ?? "").trim();
86+ if (!credential) return { ok: false as const, provider, error: `Paste ${COST_PROVIDERS[provider].label}'s admin key.` };
87+ const connected = await billing.connectProvider(user, slug, provider, credential);
88+ return connected.ok ? { ok: true as const, provider, connected: connected.value } : { ok: false as const, provider, error: connected.error.message };
89+ } catch (error) {
90+ console.error("provider costs: billing did not answer", error);
91+ return { ok: false as const, provider, error: "Billing didn't answer. Try again in a moment." };
92+ }
93+}
94+
95+const SECTION = "border-t border-line pt-8 first:border-t-0 first:pt-0";
96+
97+export default function ProviderCostsPage({ loaderData, actionData }: Route.ComponentProps) {
98+ const { slug, owner, connections, totals } = loaderData;
99+ const connected = actionData?.ok && "connected" in actionData ? actionData.connected : null;
100+ const failure = actionData && !actionData.ok ? actionData : null;
101+ const byProvider = new Map(connections.map((c) => [c.provider, c]));
102+ const unconnected = (Object.keys(COST_PROVIDERS) as CostProvider[]).filter((p) => !byProvider.has(p));
103+ return (
104+ <div className="max-w-2xl">
105+ <header className="mb-8 border-b border-line pb-6">
106+ <h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
107+ <span className="text-muted">
108+ <ReceiptText size={18} />
109+ </span>
110+ Provider costs
111+ </h1>
112+ <p className="mt-1.5 text-sm text-muted">
113+ What your AI providers say {slug}&apos;s organization spent, day by day, read from each provider&apos;s own cost report. It counts every use of that
114+ organization, in g1t or not: CI scripts, other tools, people&apos;s own sessions. g1t reads it once a day.
115+ </p>
116+ </header>
117+
118+ {!owner && (
119+ <Card asChild radius="lg" className="mb-8 px-4 py-3 text-sm text-muted">
120+ <p>Only workspace owners can connect or disconnect a provider. They are shown so you know where these figures come from.</p>
121+ </Card>
122+ )}
123+
124+ {connected && <Connected connection={connected} />}
125+ {failure && !failure.provider && <ErrorText>{failure.error}</ErrorText>}
126+
127+ <div className="space-y-8">
128+ <section className={SECTION} aria-labelledby="connected-heading">
129+ <h2 id="connected-heading" className="font-medium">
130+ Connected providers
131+ </h2>
132+ {connections.length === 0 ? (
133+ <p className="mt-3 text-sm text-muted">{owner ? "None yet. Connect one below." : `None yet. An owner of ${slug} can connect one.`}</p>
134+ ) : (
135+ <Card asChild divided className="mt-4 overflow-hidden">
136+ <ul>
137+ {connections.map((connection) => (
138+ <li key={connection.provider} className="p-4">
139+ <ConnectionRow
140+ connection={connection}
141+ owner={owner}
142+ total={isFailed(totals) ? totals : (totals[connection.provider] ?? 0)}
143+ error={failure?.provider === connection.provider ? failure.error : null}
144+ />
145+ </li>
146+ ))}
147+ </ul>
148+ </Card>
149+ )}
150+ <p className="mt-3 text-xs text-faint">
151+ Keys are sealed when saved and never shown again. Disconnecting deletes the key and every cost read with it.
152+ </p>
153+ </section>
154+
155+ {owner && unconnected.length > 0 && (
156+ <section className={SECTION} aria-labelledby="connect-heading">
157+ <h2 id="connect-heading" className="font-medium">
158+ Connect a provider
159+ </h2>
160+ <p className="mt-1.5 text-xs text-faint">
161+ g1t checks the key with one small report request, then reads the last 90 days. It reads costs only: the key is never used to run models.
162+ </p>
163+ <div className="mt-4 space-y-4">
164+ {unconnected.map((provider) => (
165+ <Card key={provider} asChild className="p-4">
166+ <div>
167+ <div className="flex items-center gap-3">
168+ <AiProviderMark provider={provider} size={28} />
169+ <div className="min-w-0">
170+ <h3 className="text-sm font-medium">{COST_PROVIDERS[provider].label}</h3>
171+ <p className="text-xs text-muted">{KEYS[provider].where}</p>
172+ </div>
173+ </div>
174+ <KeyForm provider={provider} error={failure?.provider === provider ? failure.error : null} label="Connect" />
175+ </div>
176+ </Card>
177+ ))}
178+ </div>
179+ </section>
180+ )}
181+ </div>
182+ </div>
183+ );
184+}
185+
186+function ConnectionRow({ connection, owner, total, error }: { connection: ProviderConnection; owner: boolean; total: number | Failed; error: string | null }) {
187+ const { provider } = connection;
188+ return (
189+ <div>
190+ <div className="flex items-center gap-3">
191+ <AiProviderMark provider={provider} />
192+ <div className="min-w-0 grow">
193+ <p className="truncate text-sm font-medium">{COST_PROVIDERS[provider].label}</p>
194+ <p className="truncate text-xs text-muted">
195+ Key {connection.hint} · connected by {connection.connectedBy} <TimeAgo at={connection.connectedAt} />
196+ </p>
197+ </div>
198+ {owner && (
199+ <Form method="post" className="shrink-0">
200+ <input type="hidden" name="provider" value={provider} />
201+ <SubmitButton
202+ variant="outline"
203+ name="intent"
204+ value="disconnect"
205+ match={{ provider }}
206+ pending="Disconnecting…"
207+ aria-label={`Disconnect ${COST_PROVIDERS[provider].label}`}
208+ >
209+ <X size={14} />
210+ </SubmitButton>
211+ </Form>
212+ )}
213+ </div>
214+ <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted">
215+ <span>
216+ {connection.lastSyncedAt ? (
217+ <>
218+ Last read <TimeAgo at={connection.lastSyncedAt} />
219+ </>
220+ ) : (
221+ "Not read yet"
222+ )}
223+ {connection.syncedFrom && <>, back to {connection.syncedFrom}</>}
224+ </span>
225+ {isFailed(total) ? (
226+ <Unavailable what={total.what} compact />
227+ ) : (
228+ <span>
229+ {money(total)} in the last {RECENT_DAYS} days
230+ </span>
231+ )}
232+ </div>
233+ {connection.lastError && (
234+ <p className="mt-3 flex items-start gap-2 text-xs text-warn">
235+ <AlertTriangle size={13} className="mt-0.5 shrink-0" />
236+ {connection.lastError}
237+ </p>
238+ )}
239+ <ErrorText>{error}</ErrorText>
240+ {owner && (
241+ <details className="group mt-3">
242+ <summary className="inline-flex cursor-pointer list-none items-center gap-1 text-xs text-muted hover:text-fg">
243+ <ChevronRight size={12} className="transition-transform group-open:rotate-90" />
244+ Replace the key
245+ </summary>
246+ <p className="mt-2 text-xs text-faint">The costs read with the old key are deleted and the last 90 days are read again with the new one.</p>
247+ <KeyForm provider={provider} error={null} label="Replace" />
248+ </details>
249+ )}
250+ </div>
251+ );
252+}
253+
254+function KeyForm({ provider, error, label }: { provider: CostProvider; error: string | null; label: string }) {
255+ return (
256+ <Form method="post" className="mt-4 space-y-3">
257+ <input type="hidden" name="provider" value={provider} />
258+ <Field label={`${COST_PROVIDERS[provider].label} admin key`} hint={`Write-only: g1t never shows it again. Use ${KEYS[provider].key}.`}>
259+ <Input
260+ name="credential"
261+ type="password"
262+ required
263+ placeholder={KEYS[provider].placeholder}
264+ autoComplete="new-password"
265+ spellCheck={false}
266+ className="max-w-sm font-mono text-[0.8125rem]"
267+ />
268+ </Field>
269+ <ErrorText>{error}</ErrorText>
270+ <SubmitButton variant="outline" name="intent" value="connect" match={{ provider }} pending="Checking the key…">
271+ {label}
272+ </SubmitButton>
273+ </Form>
274+ );
275+}
276+
277+/** What happened, right after connecting: shown once. */
278+function Connected({ connection }: { connection: ProviderConnection }) {
279+ const label = COST_PROVIDERS[connection.provider].label;
280+ return (
281+ <div className="mb-8 rounded-xl border border-success-dim/60 bg-success/5 p-5">
282+ <p className="flex items-center gap-2 font-medium">
283+ <CheckCircle2 size={16} className="text-success" />
284+ {label} is connected.
285+ </p>
286+ <p className="mt-2 text-sm text-muted">
287+ {connection.lastError
288+ ? `The key works and is saved, but the first read failed: ${connection.lastError}`
289+ : connection.syncedFrom
290+ ? `Its report is read back to ${connection.syncedFrom}, and again every day.`
291+ : "The key works and is saved. Its report is read once a day."}
292+ </p>
293+ </div>
294+ );
295+}
+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"))
Loading the diff…

Comments

Sign in to comment.