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.

295 linesCodeBlameRaw
1import { AlertTriangle, CheckCircle2, ChevronRight, ReceiptText, X } from "lucide-react";
2import { Form, data } from "react-router";
3
4import { COST_PROVIDERS, type CostProvider, type ProviderConnection, type ProviderCosts, isCostProvider } from "@g1t/contracts";
5
6import type { Route } from "./+types/provider-costs";
7import { AiProviderMark } from "../../components/connectors";
8import { Unavailable } from "../../components/boundary";
9import { ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui";
10import { Card } from "../../components/ui/card";
11import { page } from "../../lib/meta";
12import { money } from "../../lib/money";
13import { type Failed, isFailed, part } from "../../lib/partial";
14import { billing } from "../../lib/services.server";
15import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
16
17export 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. */
22const RECENT_DAYS = 30;
23
24/** Which key each provider's report needs, and where it is made. */
25const 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 */
45export 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. */
65function 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
73export 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
95const SECTION = "border-t border-line pt-8 first:border-t-0 first:pt-0";
96
97export 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
186function 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
254function 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. */
278function 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}