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

Settings has a Provider costs page, for connecting a provider's cost report

Owners need somewhere to paste the organization's admin key and see whether the daily read is working, and members should be able to see where the provider figures come from. The page lists each connected provider with its key's last four characters, who connected it, when it was last read and how far back, the last 30 days' total, and the last error in place. Owners connect, replace and disconnect from it; members see it read only. The totals are a part of their own, so a failed read of them says so beside each provider and the list stays up. It sits under Money, after Billing and plans. Part of #157

G1t-Session:
ses_01m573jm1je4tv3wf6e9e2f7q4
syntaqxcommitted Parent881a893Browse files
6 files+305−20/6 viewed
+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+}

Comments

Sign in to comment.