Merge branch 'worktree-agent-ab475706573b4fd42' into home-signal
17 files+1840−21 file not counted0/17 viewed
| 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", |
| 1004 | 1004 | and what covered it, for your own books. Invoices from Stripe remain the | |
| 1005 | 1005 | record for what was charged to your card. | |
| 1006 | 1006 | ||
| 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 | + | ||
| 1007 | 1067 | ## For services: entitlements, reserve, settle | |
| 1008 | 1068 | ||
| 1009 | 1069 | Every g1t service that starts something that costs money asks billing |
| 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"; | |
| 2 | 2 | import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { Link, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 1329 | 1329 | "personal-access-tokens": <UserRoundKey size={15} />, | |
| 1330 | 1330 | usage: <BarChart3 size={15} />, | |
| 1331 | 1331 | billing: <CreditCard size={15} />, | |
| 1332 | + | "provider-costs": <ReceiptText size={15} />, | |
| 1332 | 1333 | runners: <ServerCog size={15} />, | |
| 1333 | 1334 | repositories: <BookMarked size={15} />, | |
| 1334 | 1335 | actions: <PlayCircle size={15} />, | |
| ⋯ | |||
| 1609 | 1610 | "personal-access-tokens": "Personal access tokens", | |
| 1610 | 1611 | usage: "Usage", | |
| 1611 | 1612 | billing: "Billing and plans", | |
| 1613 | + | "provider-costs": "Provider costs", | |
| 1612 | 1614 | integrations: "Integrations", | |
| 1613 | 1615 | ai: "AI", | |
| 1614 | 1616 | webhooks: "Webhooks", | |
| 35 | 35 | "compare", "confirm-email", "context", "deployments", "dm", "docs", "emails", "emoji", "entries", "explore", "files", "forgot", "gateway", | |
| 36 | 36 | "github", "guardrails", "home", "inbox", "apps", "insights", "integrations", "invitations", "invite", "invites", "issues", | |
| 37 | 37 | "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", | |
| 39 | 39 | "pulls", "queue", "releases", "repositories", "reset", "rules", "runners", "runs", "search", "secrets", "security", | |
| 40 | 40 | "security-log", "settings", "soon", "spend", "tags", "teams", "today", "tokens", "tree", "two-factor", "u", "usage", | |
| 41 | 41 | "verify", "webhooks", "work", "workspace", "workspaces", |
| 134 | 134 | assert.deepEqual(workspaceSettingsPage("/acme/-/rules/rs_1", "acme"), { group: "security", page: "rules" }); | |
| 135 | 135 | assert.deepEqual(workspaceSettingsPage("/acme/-/security/settings", "acme"), { group: "security", page: "security-settings" }); | |
| 136 | 136 | assert.deepEqual(workspaceSettingsPage("/acme/-/usage", "acme"), { group: "money", page: "usage" }); | |
| 137 | + | assert.deepEqual(workspaceSettingsPage("/acme/-/provider-costs", "acme"), { group: "money", page: "provider-costs" }); | |
| 137 | 138 | // The overview, Code's security page and another workspace are no settings page. | |
| 138 | 139 | assert.equal(workspaceSettingsPage("/acme/-/workspace", "acme"), null); | |
| 139 | 140 | assert.equal(workspaceSettingsPage("/acme/-/security", "acme"), null); |
| 64 | 64 | pages: [ | |
| 65 | 65 | { key: "usage", label: "Usage", path: "usage" }, | |
| 66 | 66 | { key: "billing", label: "Billing and plans", path: "billing" }, | |
| 67 | + | { key: "provider-costs", label: "Provider costs", path: "provider-costs" }, | |
| 67 | 68 | ], | |
| 68 | 69 | }, | |
| 69 | 70 | { | |
| ⋯ | |||
| 260 | 261 | workspace: "workspace", | |
| 261 | 262 | usage: "workspace", | |
| 262 | 263 | billing: "workspace", | |
| 264 | + | "provider-costs": "workspace", | |
| 263 | 265 | integrations: "workspace", | |
| 264 | 266 | ai: "workspace", | |
| 265 | 267 | guardrails: "workspace", | |
| 158 | 158 | route("-/personal-access-tokens", "routes/workspace/personal-access-tokens.tsx"), | |
| 159 | 159 | route("-/usage", "routes/workspace/usage.tsx"), | |
| 160 | 160 | 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"), | |
| 161 | 164 | route("-/billing/entries", "routes/workspace/statement-entries.ts"), | |
| 162 | 165 | route("-/billing/statement.csv", "routes/workspace/statement-csv.ts"), | |
| 163 | 166 | // The workspace's AI: its AI providers, managed or self-hosted, and |
| 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}'s organization spent, day by day, read from each provider's own cost report. It counts every use of that | |
| 114 | + | organization, in g1t or not: CI scripts, other tools, people'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 | + | } |
| 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?), | |
A large diff (1,187 lines) is not shown until you ask for it.
| 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")) | |
Loading the diff…
Comments
Sign in to comment.