The Spend page says what a workspace spent, where it went and the budgets that hold it, in Workspace under Money: spent at price, agents' spend and the spend limit; by day; by agent, person, channel, model, kind of work and product; budgets from the workspace down to each person, agent and task, with what happens at 100%; the costliest tasks, each with a receipt of every session at the provider's price and what was charged; and how it's priced. Everyone sees what agents spent for them, and owners and billing managers the whole workspace; each person can have a monthly budget agents keep to, and the spend pill in the top bar shows your month or the workspace's against its limit. The spend guide says how.
28 files+2238−500/28 viewed
| 51 | 51 | | `@g1t`, the orchestrator every workspace has, which hands work to the right agent with `hand_off` | Live | | |
| 52 | 52 | | Your own model providers' keys (14 providers), and the AI Gateway | Live | | |
| 53 | 53 | | g1t's hosted models (open to invited workspaces during the alpha) | Preview | | |
| 54 | − | | Budgets per workspace, agent and task; guardrails; agents act with the asker's access | Live | | |
| 54 | + | | Budgets per workspace, person, agent and task; guardrails; agents act with the asker's access | Live | | |
| 55 | + | | Spend: where the money went by agent, person, channel, model and product, every budget, a receipt for each task, and your spend in the top bar | Preview | | |
| 56 | + | | Spend by extension | Coming | | |
| 55 | 57 | | Memory, sessions with live steps and cost, routines on a schedule | Live | | |
| 56 | 58 | | An agent catalog of specialists, and foundational skills (documents, research, data, code, communication, files) | Coming | | |
| 57 | 59 | | Agents on runners anywhere (g1t's, yours, your desktop), with sessions that persist between tasks | Coming | |
| 92 | 92 | { label: 'Sessions', slug: 'guides/agent-sessions' }, | |
| 93 | 93 | { label: 'Agent memory', slug: 'guides/agent-memory' }, | |
| 94 | 94 | { label: 'Routines', slug: 'guides/agent-routines' }, | |
| 95 | + | { label: 'Spend', slug: 'guides/spend' }, | |
| 95 | 96 | { label: 'Agent budgets and spend', slug: 'guides/agent-budgets' }, | |
| 96 | 97 | { label: 'What agents can do for whom', slug: 'guides/agent-access' }, | |
| 97 | 98 | { label: 'Artifacts', slug: 'guides/artifacts' }, |
| 17 | 17 | | --- | --- | --- | | |
| 18 | 18 | | **The workspace's spend limit and AI credit** | Owners and billing managers, under [Billing](/guides/usage-and-billing/#your-spend-limit). | A hard stop for everything, agents included. | | |
| 19 | 19 | | **The agent budget** | Owners, under **Agents → Budget**. | Every agent's work together, each month. At 100% no agent takes new work until the 1st, or until an owner raises it. | | |
| 20 | + | | **A person's budget** | Owners, under [Workspace → Spend](/guides/spend/#budgets): a default for everyone, and a budget of their own for anyone. | What agents spend on the work one person asks for, each month. At 100% agents take no new work for that person until the 1st, and say so where they were asked. | | |
| 20 | 21 | | **An agent's own budget** | Owners, on the agent's **Profile**. | Monthly, and optionally daily. At 100% that agent takes no new work. | | |
| 21 | 22 | | **A session's cap** | The workspace's session cap, or the agent's lower per-session cap. | The session stops at **Needs approval** until an owner approves more. | | |
| 22 | 23 | ||
| ⋯ | |||
| 52 | 53 | ||
| 53 | 54 | ## Where the month went | |
| 54 | 55 | ||
| 56 | + | [Spend](/guides/spend/) has the same breakdown over any period, by | |
| 57 | + | channel too, with each task's receipt; everyone can see their own there. | |
| 58 | + | ||
| 55 | 59 | The **Agents** page breaks down every agent's spend this month: | |
| 56 | 60 | ||
| 57 | 61 | | View | Shows | | |
| ⋯ | |||
| 78 | 82 | ||
| 79 | 83 | ## Next | |
| 80 | 84 | ||
| 85 | + | - [Spend](/guides/spend/): every budget in one place, and receipts. | |
| 81 | 86 | - [Sessions](/guides/agent-sessions/): caps and approving more spend. | |
| 82 | 87 | - [Agents](/guides/agents/#budgets): an agent's own budget. | |
| 83 | 88 | - [Usage and billing](/guides/usage-and-billing/): spend limits, AI credit and rates. | |
| 1 | + | --- | |
| 2 | + | title: Spend | |
| 3 | + | description: What the workspace spent and where it went, by agent, person, channel, model and product; the budgets from the workspace down to one task, and what happens at each; a receipt for every task; and how it is priced. | |
| 4 | + | --- | |
| 5 | + | ||
| 6 | + | **Spend** is the front of the workspace's money: | |
| 7 | + | `g1t.sh/<workspace>/-/spend`, first under **Money** in the Workspace | |
| 8 | + | sidebar. It says what was spent over a period, where it went, the budgets | |
| 9 | + | that hold it, and what each task cost. Every figure on it comes from the | |
| 10 | + | workspace's usage and agent records; nothing is estimated. | |
| 11 | + | ||
| 12 | + | **Usage** has every meter in detail, and **Billing and plans** has the | |
| 13 | + | plan, the spend limit, payments and the statement. Spend links to both. | |
| 14 | + | ||
| 15 | + | ## Whose spend | |
| 16 | + | ||
| 17 | + | | View | Who sees it | What it counts | | |
| 18 | + | | --- | --- | --- | | |
| 19 | + | | **Workspace** | Owners and [billing managers](/guides/workspaces/#roles-that-add-to-a-member) | Everything the workspace used, every product, and every agent's work. | | |
| 20 | + | | **You** | Everyone | What agents spent working for you: your chats with agents and the sessions you started. | | |
| 21 | + | ||
| 22 | + | Owners and billing managers switch between them with **Workspace** and | |
| 23 | + | **You** at the top of the page. Everyone else sees **You**. | |
| 24 | + | ||
| 25 | + | ## The period | |
| 26 | + | ||
| 27 | + | Choose **This month**, **Last month**, **30 days** or **7 days**. Days are | |
| 28 | + | UTC. Budgets are always this month's, whatever the period. | |
| 29 | + | ||
| 30 | + | ## The figures at the top | |
| 31 | + | ||
| 32 | + | | View | Figures | | |
| 33 | + | | --- | --- | | |
| 34 | + | | **Workspace** | **Spent**: usage at price over the period, every product, the same figure as [Usage](/guides/usage-and-billing/#the-usage-page). **Agents**: every agent's replies and sessions. **Spend limit**: what was charged this month against the workspace's spend limit. | | |
| 35 | + | | **You** | **Agents for you** over the period. **Your budget** this month, and what is spent against it. **Your chats with agents**: the replies agents wrote you. | | |
| 36 | + | ||
| 37 | + | **By day** shows the period one day at a time. Hover or focus a day for | |
| 38 | + | its amount. | |
| 39 | + | ||
| 40 | + | ## Where it went | |
| 41 | + | ||
| 42 | + | Slice the period's spend with the tabs: | |
| 43 | + | ||
| 44 | + | | Slice | Shows | | |
| 45 | + | | --- | --- | | |
| 46 | + | | **Agents** | Each agent's share. A session's helpers and subagents count on the agent that started it. Open one for its own Spend tab. | | |
| 47 | + | | **People** | Who asked. Routines and agent-to-agent work have no asker. Workspace view only. | | |
| 48 | + | | **Channels** | Where it was asked: each channel, direct messages together, and channels you aren't in together, without their names. | | |
| 49 | + | | **Models** | Each model's share. | | |
| 50 | + | | **Kind of work** | Chat replies, sessions, routines, helping colleagues and subagents. | | |
| 51 | + | | **Products** | Every product at price, from Usage: Agent, Sandboxes, AI Gateway, Deployments, Git & storage, Packages, Security & quality and Search. Workspace view only. | | |
| 52 | + | | **Extensions** | Coming: what each installed extension runs, once extensions can be installed. | | |
| 53 | + | ||
| 54 | + | A slice is part of the page's address (`?by=channel`), so you can share it. | |
| 55 | + | ||
| 56 | + | ## Budgets | |
| 57 | + | ||
| 58 | + | Budgets nest, from the widest to the narrowest. Before every agent reply | |
| 59 | + | and session step, g1t checks each level, and the first one that is used | |
| 60 | + | up stops the work. Each row on Spend shows the budget, what is spent | |
| 61 | + | against it this month, and what happens at 100%. | |
| 62 | + | ||
| 63 | + | | Level | Set by | At 100% | | |
| 64 | + | | --- | --- | --- | | |
| 65 | + | | **The workspace** | Owners and billing managers, under [Billing → Your spend limit](/guides/usage-and-billing/#your-spend-limit). | New work stops until the month turns or an owner raises it: agents, workflows, builds and deploys. With **Pause usage at 100%** off, owners are alerted and work goes on. | | |
| 66 | + | | **All agents together** | Owners, here or under **Agents → Budget**. | No agent takes new work until the 1st, or until an owner raises it. | | |
| 67 | + | | **Each person** | Owners, here. A default for everyone, and a budget of their own for anyone. | Agents take no new work for that person until the 1st, and say so where they were asked. | | |
| 68 | + | | **Each agent** | Owners, on the agent's **Profile**. A new agent starts with the default set here. | That agent takes no new work until the 1st, or the next day for a daily cap. | | |
| 69 | + | | **Each task** | Owners, here: the cap a session starts with. The plan's caps on one run and one issue are under [Billing → Caps](/guides/usage-and-billing/#caps). | The session stops at **Needs approval**, and an owner decides whether it goes on. | | |
| 70 | + | ||
| 71 | + | Work already running finishes, so spend can go slightly past a budget. | |
| 72 | + | ||
| 73 | + | ### A person's budget | |
| 74 | + | ||
| 75 | + | A person's budget counts what agents spend on work that person asks for: | |
| 76 | + | the replies agents write them and the sessions they start, with | |
| 77 | + | everything those sessions bring in. Each person can see their own on | |
| 78 | + | **You**, and in the top bar. | |
| 79 | + | ||
| 80 | + | To set budgets, as an owner: | |
| 81 | + | ||
| 82 | + | 1. Open **Spend** and find **Budgets**. | |
| 83 | + | 2. Choose **Change** on **All agents together**, **Each person** or | |
| 84 | + | **Each task**, fill in the amounts in dollars, and choose **Save | |
| 85 | + | budgets**. An empty budget means none. | |
| 86 | + | 3. To give one person their own budget, choose **Set** on their row, or | |
| 87 | + | **Give someone their own budget**, and enter it. `0` means no budget at | |
| 88 | + | all for them; empty puts them back on the default. | |
| 89 | + | ||
| 90 | + | ## Receipts | |
| 91 | + | ||
| 92 | + | **Costliest tasks** lists the period's most expensive sessions. Open one | |
| 93 | + | for its receipt, at `g1t.sh/<workspace>/-/spend/receipts/<session>`: | |
| 94 | + | ||
| 95 | + | - Each session of its tree: the agent's own, and every helper and subagent | |
| 96 | + | it brought in, with their tokens, the model at the provider's price, and | |
| 97 | + | what each was charged. | |
| 98 | + | - **Models, at the provider's price**: every session's model answers | |
| 99 | + | together. | |
| 100 | + | - **g1t's agent rate**: the rest, at the rate in the price book. | |
| 101 | + | - **Total, as budgets count it**. | |
| 102 | + | ||
| 103 | + | On your own model key, your provider bills the model and only the agent | |
| 104 | + | rate is charged; the receipt says so. A session from a conversation you | |
| 105 | + | aren't in shows what it cost, not what it was about. | |
| 106 | + | ||
| 107 | + | ## How it's priced | |
| 108 | + | ||
| 109 | + | The page's last section reads the [price book](/guides/usage-and-billing/#how-prices-are-set): | |
| 110 | + | ||
| 111 | + | | | Price | | |
| 112 | + | | --- | --- | | |
| 113 | + | | **Models** | What the model provider charges, with no markup. | | |
| 114 | + | | **Agent rate** | Per million tokens an agent's run uses, for context, memory, routing and orchestration. | | |
| 115 | + | | **Everything g1t runs** | Sandboxes, builds, hosting, storage and search, at cost plus 20%. | | |
| 116 | + | | **Your own model keys** | Your provider bills the model; only the agent rate is charged. Local models the same. | | |
| 117 | + | | **Your own runners** | $0. | | |
| 118 | + | | **People** | No seats. | | |
| 119 | + | ||
| 120 | + | ## In the top bar | |
| 121 | + | ||
| 122 | + | Your spend this month sits in the page's header, beside **Ask g1t**: what | |
| 123 | + | agents did for you against your budget. Choose it for where it went, by | |
| 124 | + | kind of work and by agent, and a link to Spend. Owners and billing | |
| 125 | + | managers can switch it to **Workspace**: what the workspace was charged | |
| 126 | + | this month against its spend limit, and its agents' spend. The choice is | |
| 127 | + | remembered on this browser. On a phone, open **Spend** from the Workspace | |
| 128 | + | sidebar instead. | |
| 129 | + | ||
| 130 | + | ## Next | |
| 131 | + | ||
| 132 | + | - [Agent budgets and spend](/guides/agent-budgets/): who pays for a session tree. | |
| 133 | + | - [Usage and billing](/guides/usage-and-billing/): the spend limit, caps, AI credit and rates. | |
| 134 | + | - [Sessions](/guides/agent-sessions/): caps and approving more spend. |
| 537 | 537 | | --- | --- | --- | | |
| 538 | 538 | | **The dock** | A rounded bar down the left | g1t's mark, which goes to [Today](/guides/today/); the built-in apps; the apps you pinned; **Apps**; and at its foot **People**, **Workspace** and your account. | | |
| 539 | 539 | | **The sidebar** | Beside the dock | The workspace and its switcher at the top, then the lists of the app you are in. Today and Apps have none. | | |
| 540 | − | | **The page** | A rounded panel, the rest of the window | Its header: the button that shows or hides the sidebar, where you are, **Search or jump to** (<kbd>Ctrl</kbd> <kbd>K</kbd>, or <kbd>⌘</kbd> <kbd>K</kbd> on a Mac), **Ask g1t**, the [notifications](/guides/notifications/) bell and **Create new**. Then the page itself. | | |
| 540 | + | | **The page** | A rounded panel, the rest of the window | Its header: the button that shows or hides the sidebar, where you are, **Search or jump to** (<kbd>Ctrl</kbd> <kbd>K</kbd>, or <kbd>⌘</kbd> <kbd>K</kbd> on a Mac), [your spend this month](/guides/spend/#in-the-top-bar), **Ask g1t**, the [notifications](/guides/notifications/) bell and **Create new**. Then the page itself. | | |
| 541 | 541 | ||
| 542 | 542 | ### The dock | |
| 543 | 543 | ||
| ⋯ | |||
| 633 | 633 | for the roles that can use it. A link straight to any of these pages opens | |
| 634 | 634 | the sidebar already there. | |
| 635 | 635 | ||
| 636 | − | Workspace's sidebar lists the workspace's **Overview**, **Usage**, | |
| 636 | + | Workspace's sidebar lists the workspace's **Overview**; under **Money**, | |
| 637 | + | [**Spend**](/guides/spend/), **Usage**, | |
| 637 | 638 | **Billing and plans**, **Integrations**, its security policies, the | |
| 638 | 639 | **Audit log** and **Settings**, which slides over to how the workspace is | |
| 639 | 640 | set up: | |
| 79 | 79 | | [g1t, the orchestrator](/guides/agents/) | Every workspace's own agent. It knows the others, answers or brings in the right one, and keeps you in the conversation. | <Status is="live" /> | | |
| 80 | 80 | | [Model providers](/guides/models/) | Agents use your own provider's key, or g1t's hosted models through the [AI Gateway](/guides/ai-gateway/). | <Status is="live" /> | | |
| 81 | 81 | | g1t's hosted models | Models g1t runs for you, billed on your workspace. Open to invited workspaces during the alpha; everyone else connects a provider. | <Status is="preview" /> | | |
| 82 | − | | [Budgets](/guides/agent-budgets/) and [guardrails](/guides/guardrails/) | Spend limits per workspace, agent and task, checked before work starts, and what an agent may do alone. | <Status is="live" /> | | |
| 82 | + | | [Budgets](/guides/agent-budgets/) and [guardrails](/guides/guardrails/) | Spend limits per workspace, person, agent and task, checked before work starts, and what an agent may do alone. | <Status is="live" /> | | |
| 83 | + | | [Spend](/guides/spend/) | Where the money went, by agent, person, channel, model and product; every budget with what happens at 100%; a receipt for each task; your own spend in the top bar. | <Status is="preview" /> | | |
| 83 | 84 | | [Acting for the asker](/guides/agent-access/) | Agents act with the access of the person who asked, and answer with only what their audience may see. | <Status is="live" /> | | |
| 84 | 85 | | [Memory](/guides/agent-memory/), [sessions](/guides/agent-sessions/) and [routines](/guides/agent-routines/) | What an agent keeps, every run with its steps and cost, and work on a schedule. | <Status is="live" /> | | |
| 85 | 86 | | Agent catalog | Specialists to hire from a catalog, each with a role, skills, tools and the runner it prefers. | <Status is="coming" /> | |
| 1 | − | import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Bot, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, KanbanSquare, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogOut, Mail, Network, Package, PanelLeft, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, Shapes, ShieldCheck, Scale, Smile, Sparkles, Sun, Ticket, TrendingUp, UserRoundKey, Users, UsersRound, Webhook, X, ArrowLeftRight } from "lucide-react"; | |
| 1 | + | import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Bot, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Coins, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, KanbanSquare, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogOut, Mail, Network, Package, PanelLeft, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, Shapes, ShieldCheck, Scale, Smile, Sparkles, Sun, Ticket, TrendingUp, UserRoundKey, Users, UsersRound, Webhook, X, ArrowLeftRight } from "lucide-react"; | |
| 2 | 2 | import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 7 | 7 | import { InMain } from "./landmark"; | |
| 8 | 8 | import { CommandPalette, type PaletteCommand, PaletteKey, usePaletteShortcut } from "./command-palette"; | |
| 9 | 9 | import { NotificationsBell } from "./inbox"; | |
| 10 | + | import { SpendPill } from "./spend"; | |
| 10 | 11 | import { PinButton } from "./pin-button"; | |
| 11 | 12 | import { Hint } from "./ui/hint"; | |
| 12 | 13 | import { Sheet, SheetContent, SheetTitle } from "./ui/sheet"; | |
| ⋯ | |||
| 1166 | 1167 | Overview | |
| 1167 | 1168 | </SidebarLink> | |
| 1168 | 1169 | </div> | |
| 1169 | − | <SidebarGroup title="Usage and billing" className="mt-3"> | |
| 1170 | + | <SidebarGroup title="Money" className="mt-3"> | |
| 1171 | + | <SidebarLink to={`/${slug}/-/spend`} icon={<Coins size={15} />} current={at("spend")}> | |
| 1172 | + | Spend | |
| 1173 | + | </SidebarLink> | |
| 1170 | 1174 | <SidebarLink to={`/${slug}/-/usage`} icon={<BarChart3 size={15} />} current={at("usage", "gateway")}> | |
| 1171 | 1175 | Usage | |
| 1172 | 1176 | </SidebarLink> | |
| ⋯ | |||
| 1376 | 1380 | teams: "Teams", | |
| 1377 | 1381 | tokens: "Access tokens", | |
| 1378 | 1382 | "personal-access-tokens": "Personal access tokens", | |
| 1383 | + | spend: "Spend", | |
| 1384 | + | receipts: "Receipts", | |
| 1379 | 1385 | usage: "Usage", | |
| 1380 | 1386 | gateway: "AI Gateway", | |
| 1381 | 1387 | billing: "Billing and plans", | |
| ⋯ | |||
| 1607 | 1613 | { label: displayName(membership), hint: `Workspace · ${membership.slug}`, to: `/${membership.slug}`, icon: <Avatar name={membership.slug} image={membership.avatar} size={15} square /> }, | |
| 1608 | 1614 | { label: "All projects", hint: membership.slug, to: `/${membership.slug}/-/projects`, icon: <LayoutGrid size={15} /> }, | |
| 1609 | 1615 | { label: "People", hint: membership.slug, to: `/${membership.slug}/-/people`, icon: <Users size={15} /> }, | |
| 1616 | + | { label: "Spend", hint: `${membership.slug} · Budgets and receipts`, to: `/${membership.slug}/-/spend`, icon: <Coins size={15} /> }, | |
| 1610 | 1617 | { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> }, | |
| 1611 | 1618 | { label: "AI Gateway", hint: `${membership.slug} · Usage`, to: `/${membership.slug}/-/gateway`, icon: <Network size={15} /> }, | |
| 1612 | 1619 | { label: "Billing and plans", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> }, | |
| ⋯ | |||
| 1960 | 1967 | <span className="hidden grow truncate text-left text-[0.8125rem] md:inline">Search or jump to</span> | |
| 1961 | 1968 | <PaletteKey className="hidden rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line md:inline" /> | |
| 1962 | 1969 | </button> | |
| 1970 | + | {/* Your spend this month, or the workspace's for owners and billing managers; on a phone it is on Spend. */} | |
| 1971 | + | {ws && ( | |
| 1972 | + | <span className="max-md:hidden"> | |
| 1973 | + | <SpendPill slug={ws.slug} mayWorkspace={ws.role === "owner" || !!ws.org_roles?.includes("billing_manager")} /> | |
| 1974 | + | </span> | |
| 1975 | + | )} | |
| 1963 | 1976 | {ws && <AskG1tButton slug={ws.slug} />} | |
| 1964 | 1977 | {/* On a phone, Notifications is in the bottom bar. */} | |
| 1965 | 1978 | <span className="max-md:hidden"> | |
| 1 | + | /** | |
| 2 | + | * Spend's parts: the tiles at the top, spend by day, where it went, the | |
| 3 | + | * budgets from the workspace down to a task with what happens at each, | |
| 4 | + | * the costliest tasks with their receipts, pricing, and the top bar's | |
| 5 | + | * spend pill. Every figure is one a service returned (lib/spend.server.ts). | |
| 6 | + | */ | |
| 7 | + | import { ArrowRight, Building2, Coins, Cpu, KeyRound, MessagesSquare, Puzzle, ReceiptText, Sparkles, UserRound, Users } from "lucide-react"; | |
| 8 | + | import { type ReactNode, useEffect, useId, useState } from "react"; | |
| 9 | + | import { Link, useFetcher } from "react-router"; | |
| 10 | + | ||
| 11 | + | import type { AgentPolicy, AgentSession, PersonBudget } from "@g1t/contracts"; | |
| 12 | + | ||
| 13 | + | import { AgentAvatar } from "./agent-avatar"; | |
| 14 | + | import { BUTTONS, DollarsInput, useDialogFetcher } from "./agents/dialogs"; | |
| 15 | + | import { Meter, PrivateTitle, SliceList, stepsLine } from "./agents/parts"; | |
| 16 | + | import { monthName, shortDay } from "./agents/format"; | |
| 17 | + | import { Badge } from "./ui/badge"; | |
| 18 | + | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; | |
| 19 | + | import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field"; | |
| 20 | + | import { Hint } from "./ui/hint"; | |
| 21 | + | import { Input } from "./ui/input"; | |
| 22 | + | import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; | |
| 23 | + | import { Skeleton } from "./ui/skeleton"; | |
| 24 | + | import { TimeAgo } from "./ui"; | |
| 25 | + | import { dollarsField } from "../lib/agent-form"; | |
| 26 | + | import { cn } from "../lib/cn"; | |
| 27 | + | import type { AgentBudgetRow, Budgets, PillData } from "../lib/spend.server"; | |
| 28 | + | import { AT_LIMIT, type Pricing, type SpendScope, markupLabel, percentLabel, shareOfBudget } from "../lib/spend"; | |
| 29 | + | import { money } from "../lib/usage"; | |
| 30 | + | ||
| 31 | + | ||
| 32 | + | /** A figure at the top of the page: what it is, the amount, and a line under it. */ | |
| 33 | + | export function Tile({ label, value, sub, children }: { label: ReactNode; value: ReactNode; sub?: ReactNode; children?: ReactNode }) { | |
| 34 | + | return ( | |
| 35 | + | <div className="min-w-0 rounded-xl border border-line bg-surface p-4"> | |
| 36 | + | <p className="flex items-center gap-1.5 text-xs text-muted">{label}</p> | |
| 37 | + | <p className="mt-1.5 truncate text-2xl font-semibold tracking-tight tabular-nums">{value}</p> | |
| 38 | + | {sub && <p className="mt-0.5 text-xs text-faint">{sub}</p>} | |
| 39 | + | {children} | |
| 40 | + | </div> | |
| 41 | + | ); | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | export function TileSkeleton() { | |
| 45 | + | return ( | |
| 46 | + | <div className="rounded-xl border border-line bg-surface p-4"> | |
| 47 | + | <Skeleton className="h-3 w-24" /> | |
| 48 | + | <Skeleton className="mt-3 h-7 w-28" /> | |
| 49 | + | <Skeleton className="mt-2 h-3 w-36" /> | |
| 50 | + | </div> | |
| 51 | + | ); | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /** | |
| 55 | + | * Spend by day over any span, one bar a day with its amount on hover or | |
| 56 | + | * focus, and a table for screen readers. | |
| 57 | + | */ | |
| 58 | + | export function SpendBars({ days, className }: { days: { day: string; micros: number }[]; className?: string }) { | |
| 59 | + | const max = Math.max(0, ...days.map((d) => d.micros)); | |
| 60 | + | return ( | |
| 61 | + | <div className={className}> | |
| 62 | + | <div className="flex h-28 items-end gap-0.5" aria-hidden="true"> | |
| 63 | + | {days.map((d) => ( | |
| 64 | + | <Hint key={d.day} label={`${shortDay(d.day)} · ${money(d.micros)}`}> | |
| 65 | + | <span className="flex h-full min-w-0 flex-1 items-end rounded-t-[3px] hover:bg-raised/60"> | |
| 66 | + | <span | |
| 67 | + | className={cn("block w-full rounded-t-[3px]", d.micros > 0 ? "bg-accent/80" : "bg-line/70")} | |
| 68 | + | style={{ height: max > 0 && d.micros > 0 ? `${Math.max(4, (d.micros / max) * 100)}%` : "2px" }} | |
| 69 | + | /> | |
| 70 | + | </span> | |
| 71 | + | </Hint> | |
| 72 | + | ))} | |
| 73 | + | </div> | |
| 74 | + | <div className="mt-1.5 flex justify-between text-[0.6875rem] text-faint" aria-hidden="true"> | |
| 75 | + | <span>{days[0] ? shortDay(days[0].day) : ""}</span> | |
| 76 | + | <span>{days.length > 1 ? shortDay(days[days.length - 1]!.day) : ""}</span> | |
| 77 | + | </div> | |
| 78 | + | <table className="sr-only"> | |
| 79 | + | <caption>Spend by day</caption> | |
| 80 | + | <tbody> | |
| 81 | + | {days.map((d) => ( | |
| 82 | + | <tr key={d.day}> | |
| 83 | + | <th scope="row">{shortDay(d.day)}</th> | |
| 84 | + | <td>{money(d.micros)}</td> | |
| 85 | + | </tr> | |
| 86 | + | ))} | |
| 87 | + | </tbody> | |
| 88 | + | </table> | |
| 89 | + | </div> | |
| 90 | + | ); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /** A section with a heading, an aside, and what it holds. */ | |
| 94 | + | export function Section({ id, title, aside, children, className }: { id: string; title: ReactNode; aside?: ReactNode; children: ReactNode; className?: string }) { | |
| 95 | + | return ( | |
| 96 | + | <section aria-labelledby={id} className={cn("space-y-3", className)}> | |
| 97 | + | <div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1"> | |
| 98 | + | <h2 id={id} className="text-base font-semibold tracking-tight"> | |
| 99 | + | {title} | |
| 100 | + | </h2> | |
| 101 | + | {aside && <div className="text-xs text-faint">{aside}</div>} | |
| 102 | + | </div> | |
| 103 | + | {children} | |
| 104 | + | </section> | |
| 105 | + | ); | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /** The ways to slice spend, as the page's tabs. */ | |
| 109 | + | export type SliceKey = "agent" | "person" | "channel" | "model" | "kind" | "product" | "extension"; | |
| 110 | + | ||
| 111 | + | export const SLICES: { key: SliceKey; label: string; icon: ReactNode; workspaceOnly?: boolean }[] = [ | |
| 112 | + | { key: "agent", label: "Agents", icon: <Sparkles size={13} /> }, | |
| 113 | + | { key: "person", label: "People", icon: <Users size={13} />, workspaceOnly: true }, | |
| 114 | + | { key: "channel", label: "Channels", icon: <MessagesSquare size={13} /> }, | |
| 115 | + | { key: "model", label: "Models", icon: <Cpu size={13} /> }, | |
| 116 | + | { key: "kind", label: "Kind of work", icon: <ReceiptText size={13} /> }, | |
| 117 | + | { key: "product", label: "Products", icon: <Building2 size={13} />, workspaceOnly: true }, | |
| 118 | + | { key: "extension", label: "Extensions", icon: <Puzzle size={13} />, workspaceOnly: true }, | |
| 119 | + | ]; | |
| 120 | + | ||
| 121 | + | /** The tabs over the breakdown: each a link, so a slice is an address. */ | |
| 122 | + | export function SliceTabs({ current, scope, href }: { current: SliceKey; scope: SpendScope; href: (key: SliceKey) => string }) { | |
| 123 | + | const shown = SLICES.filter((s) => scope === "workspace" || !s.workspaceOnly); | |
| 124 | + | return ( | |
| 125 | + | <nav aria-label="Slice spend by" className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1 [scrollbar-width:none]"> | |
| 126 | + | {shown.map((s) => ( | |
| 127 | + | <Link | |
| 128 | + | key={s.key} | |
| 129 | + | to={href(s.key)} | |
| 130 | + | preventScrollReset | |
| 131 | + | replace | |
| 132 | + | aria-current={s.key === current ? "page" : undefined} | |
| 133 | + | className={cn( | |
| 134 | + | "inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-[0.8125rem] transition-colors", | |
| 135 | + | s.key === current ? "bg-raised text-fg ring-1 ring-line-strong" : "text-muted hover:bg-raised/60 hover:text-fg", | |
| 136 | + | )} | |
| 137 | + | > | |
| 138 | + | <span className="text-faint">{s.icon}</span> | |
| 139 | + | {s.label} | |
| 140 | + | {s.key === "extension" && <span className="ml-0.5 text-[0.625rem] tracking-wide text-faint uppercase">Coming</span>} | |
| 141 | + | </Link> | |
| 142 | + | ))} | |
| 143 | + | </nav> | |
| 144 | + | ); | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /** Spend in a panel, the slices ranked. */ | |
| 148 | + | export function SlicePanel({ children, foot }: { children: ReactNode; foot?: ReactNode }) { | |
| 149 | + | return ( | |
| 150 | + | <div className="overflow-hidden rounded-xl border border-line bg-surface"> | |
| 151 | + | {children} | |
| 152 | + | {foot && <p className="border-t border-line px-4 py-2.5 text-xs text-faint">{foot}</p>} | |
| 153 | + | </div> | |
| 154 | + | ); | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | export { SliceList }; | |
| 158 | + | ||
| 159 | + | /** Something not built yet, said plainly where it will be. */ | |
| 160 | + | export function ComingNote({ title, children }: { title: string; children: ReactNode }) { | |
| 161 | + | return ( | |
| 162 | + | <div className="px-4 py-8 text-center"> | |
| 163 | + | <p className="flex items-center justify-center gap-2 text-sm font-medium"> | |
| 164 | + | {title} | |
| 165 | + | <Badge>Coming</Badge> | |
| 166 | + | </p> | |
| 167 | + | <p className="mx-auto mt-1.5 max-w-md text-sm text-muted">{children}</p> | |
| 168 | + | </div> | |
| 169 | + | ); | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // ── Budgets ────────────────────────────────────────────────────────────── | |
| 173 | + | ||
| 174 | + | /** One level of budget: its name, its amount, what is spent against it, and what happens at 100%. */ | |
| 175 | + | function Level({ | |
| 176 | + | icon, | |
| 177 | + | title, | |
| 178 | + | amount, | |
| 179 | + | spent, | |
| 180 | + | budget, | |
| 181 | + | atLimit, | |
| 182 | + | action, | |
| 183 | + | children, | |
| 184 | + | }: { | |
| 185 | + | icon: ReactNode; | |
| 186 | + | title: string; | |
| 187 | + | amount: ReactNode; | |
| 188 | + | spent?: number | null; | |
| 189 | + | budget?: number | null; | |
| 190 | + | atLimit: string; | |
| 191 | + | action?: ReactNode; | |
| 192 | + | children?: ReactNode; | |
| 193 | + | }) { | |
| 194 | + | const share = spent != null ? shareOfBudget(spent, budget) : null; | |
| 195 | + | return ( | |
| 196 | + | <li className="px-4 py-4"> | |
| 197 | + | <div className="flex flex-wrap items-start gap-x-3 gap-y-2"> | |
| 198 | + | <span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg bg-raised text-muted ring-1 ring-line">{icon}</span> | |
| 199 | + | <div className="min-w-0 grow basis-56"> | |
| 200 | + | <p className="text-sm font-medium">{title}</p> | |
| 201 | + | <p className="mt-0.5 text-xs text-muted"> | |
| 202 | + | <span className="font-medium text-fg-soft">At 100%:</span> {atLimit} | |
| 203 | + | </p> | |
| 204 | + | </div> | |
| 205 | + | <div className="flex shrink-0 items-center gap-3 max-sm:w-full max-sm:justify-between max-sm:pl-10"> | |
| 206 | + | <div className="sm:text-right"> | |
| 207 | + | <p className="text-sm tabular-nums">{amount}</p> | |
| 208 | + | {spent != null && ( | |
| 209 | + | <p className="text-xs text-faint tabular-nums"> | |
| 210 | + | {money(spent)} spent{share != null ? ` · ${percentLabel(spent, budget)}` : ""} | |
| 211 | + | </p> | |
| 212 | + | )} | |
| 213 | + | </div> | |
| 214 | + | {action} | |
| 215 | + | </div> | |
| 216 | + | </div> | |
| 217 | + | {share != null && <Meter spent={spent!} cap={budget} label={`${title}: spent of its budget`} size="sm" className="mt-3 ml-10" />} | |
| 218 | + | {children} | |
| 219 | + | </li> | |
| 220 | + | ); | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | const EDIT = "inline-flex h-8 items-center rounded-md border border-line px-2.5 text-xs font-medium text-fg/85 transition-colors hover:border-line-strong hover:bg-raised hover:text-fg"; | |
| 224 | + | ||
| 225 | + | /** | |
| 226 | + | * The budgets, widest first: the workspace's spend limit, every agent | |
| 227 | + | * together, each person, each agent, and each task. Owners change the | |
| 228 | + | * agent levels here; the spend limit is on Billing, an agent's own on its | |
| 229 | + | * profile. | |
| 230 | + | */ | |
| 231 | + | export function BudgetLadder({ slug, budgets, owner, me, action, workspaceFigures }: { slug: string; budgets: Budgets; owner: boolean; me: string; action: string; workspaceFigures: boolean }) { | |
| 232 | + | const { limit, caps, policy, people, agents } = budgets; | |
| 233 | + | const limitMicros = limit ? (limit.spendLimitMicros ?? limit.ceilingMicros) : null; | |
| 234 | + | const mine = people?.people.find((p) => p.username === me) ?? null; | |
| 235 | + | return ( | |
| 236 | + | <ol className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 237 | + | <Level | |
| 238 | + | icon={<Building2 size={14} />} | |
| 239 | + | title="The workspace" | |
| 240 | + | amount={limit ? (limitMicros != null ? `${money(limitMicros)} a month` : "No limit") : <span className="text-faint">Unavailable</span>} | |
| 241 | + | spent={limit && workspaceFigures ? (limit.spentMicros ?? null) : null} | |
| 242 | + | budget={limitMicros} | |
| 243 | + | atLimit={AT_LIMIT.workspace(limit?.pauseAtLimit ?? true)} | |
| 244 | + | action={ | |
| 245 | + | <Link to={`/${slug}/-/billing`} className={EDIT}> | |
| 246 | + | {owner ? "Change" : "Billing"} | |
| 247 | + | </Link> | |
| 248 | + | } | |
| 249 | + | > | |
| 250 | + | {limit?.defaultSpendLimit && <p className="mt-2 ml-10 text-xs text-faint">Automatic until owners set one: $200, or twice last month's spend, whichever is more.</p>} | |
| 251 | + | </Level> | |
| 252 | + | <Level | |
| 253 | + | icon={<Sparkles size={14} />} | |
| 254 | + | title="All agents together" | |
| 255 | + | amount={policy ? (policy.monthly_micros != null ? `${money(policy.monthly_micros)} a month` : "No budget of its own") : <span className="text-faint">Unavailable</span>} | |
| 256 | + | spent={workspaceFigures ? budgets.agentsMonthMicros : null} | |
| 257 | + | budget={policy?.monthly_micros ?? null} | |
| 258 | + | atLimit={AT_LIMIT.agents} | |
| 259 | + | action={owner && policy ? <PolicyDialog policy={policy} action={action} trigger={<button type="button" className={EDIT}>Change</button>} /> : null} | |
| 260 | + | /> | |
| 261 | + | <Level | |
| 262 | + | icon={<UserRound size={14} />} | |
| 263 | + | title="Each person" | |
| 264 | + | amount={policy ? (policy.person_monthly_micros != null ? `${money(policy.person_monthly_micros)} a month each` : "No budget per person") : <span className="text-faint">Unavailable</span>} | |
| 265 | + | spent={!owner && mine ? mine.spent_micros : null} | |
| 266 | + | budget={!owner && mine ? mine.monthly_micros : null} | |
| 267 | + | atLimit={AT_LIMIT.person} | |
| 268 | + | action={owner && policy ? <PolicyDialog policy={policy} action={action} trigger={<button type="button" className={EDIT}>Change</button>} /> : null} | |
| 269 | + | > | |
| 270 | + | <p className="mt-2 ml-10 text-xs text-faint">What agents spend on work a person asks for: their chats with agents and the sessions they start.</p> | |
| 271 | + | {owner && people && <PeopleBudgets people={people.people} defaultMicros={people.default_micros} action={action} />} | |
| 272 | + | </Level> | |
| 273 | + | <Level | |
| 274 | + | icon={<Sparkles size={14} />} | |
| 275 | + | title="Each agent" | |
| 276 | + | amount={policy ? (policy.default_agent_monthly_micros != null ? `${money(policy.default_agent_monthly_micros)} a month for a new agent` : "Set on each agent") : <span className="text-faint">Unavailable</span>} | |
| 277 | + | atLimit={AT_LIMIT.agent} | |
| 278 | + | > | |
| 279 | + | {agents && agents.length > 0 && <AgentBudgets slug={slug} agents={agents} />} | |
| 280 | + | </Level> | |
| 281 | + | <Level | |
| 282 | + | icon={<ReceiptText size={14} />} | |
| 283 | + | title="Each task" | |
| 284 | + | amount={policy ? `${money(policy.default_session_micros)} a session` : <span className="text-faint">Unavailable</span>} | |
| 285 | + | atLimit={AT_LIMIT.session} | |
| 286 | + | action={owner && policy ? <PolicyDialog policy={policy} action={action} trigger={<button type="button" className={EDIT}>Change</button>} /> : null} | |
| 287 | + | > | |
| 288 | + | <dl className="mt-3 ml-10 grid grid-cols-1 gap-x-6 gap-y-2 text-xs sm:grid-cols-3"> | |
| 289 | + | <Fact label="A session" value={policy ? money(policy.default_session_micros) : "—"} hint="Unless its agent's own per-task cap is lower." /> | |
| 290 | + | <Fact label="One run of the plan" value={caps ? money(caps.runMicros) : "—"} hint="Set under Caps on agents, on Billing." /> | |
| 291 | + | <Fact label="Agents on one issue" value={caps ? money(caps.issueMicros) : "—"} hint="Every run on the issue together." /> | |
| 292 | + | </dl> | |
| 293 | + | </Level> | |
| 294 | + | </ol> | |
| 295 | + | ); | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | function Fact({ label, value, hint }: { label: string; value: string; hint: string }) { | |
| 299 | + | return ( | |
| 300 | + | <div> | |
| 301 | + | <dt className="text-faint">{label}</dt> | |
| 302 | + | <dd className="mt-0.5 text-sm tabular-nums">{value}</dd> | |
| 303 | + | <dd className="text-faint">{hint}</dd> | |
| 304 | + | </div> | |
| 305 | + | ); | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | /** Each agent against its own monthly budget, a link to its profile. */ | |
| 309 | + | function AgentBudgets({ slug, agents }: { slug: string; agents: AgentBudgetRow[] }) { | |
| 310 | + | const [all, setAll] = useState(false); | |
| 311 | + | const shown = all ? agents : agents.slice(0, 5); | |
| 312 | + | return ( | |
| 313 | + | <ul className="mt-3 ml-10 space-y-2.5"> | |
| 314 | + | {shown.map((agent) => ( | |
| 315 | + | <li key={agent.id} className="flex items-center gap-2.5 text-sm"> | |
| 316 | + | <AgentAvatar agent={{ handle: agent.handle, avatar_seed: agent.avatar_seed }} size={20} /> | |
| 317 | + | <Link to={`/${slug}/-/agents/${agent.handle}/spend`} className="min-w-0 grow truncate hover:underline"> | |
| 318 | + | {agent.display_name} | |
| 319 | + | </Link> | |
| 320 | + | <span className="shrink-0 text-xs text-faint tabular-nums"> | |
| 321 | + | {money(agent.spent_month_micros)} | |
| 322 | + | {agent.budget.monthly_micros != null ? ` of ${money(agent.budget.monthly_micros)}` : " · no cap"} | |
| 323 | + | </span> | |
| 324 | + | <span className="hidden w-24 shrink-0 sm:block"> | |
| 325 | + | {agent.budget.monthly_micros != null && <Meter spent={agent.spent_month_micros} cap={agent.budget.monthly_micros} label={`${agent.display_name}: spent of its budget`} size="sm" />} | |
| 326 | + | </span> | |
| 327 | + | </li> | |
| 328 | + | ))} | |
| 329 | + | {agents.length > 5 && ( | |
| 330 | + | <li> | |
| 331 | + | <button type="button" onClick={() => setAll((v) => !v)} className="text-xs text-accent hover:underline"> | |
| 332 | + | {all ? "Show fewer" : `Show all ${agents.length}`} | |
| 333 | + | </button> | |
| 334 | + | </li> | |
| 335 | + | )} | |
| 336 | + | </ul> | |
| 337 | + | ); | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | /** Each person who has a budget of their own or spent this month, for owners, with the way to set one. */ | |
| 341 | + | function PeopleBudgets({ people, defaultMicros, action }: { people: PersonBudget[]; defaultMicros: number | null; action: string }) { | |
| 342 | + | return ( | |
| 343 | + | <div className="mt-3 ml-10"> | |
| 344 | + | {people.length > 0 && ( | |
| 345 | + | <ul className="space-y-2.5"> | |
| 346 | + | {people.slice(0, 12).map((person) => ( | |
| 347 | + | <li key={person.username} className="flex items-center gap-2.5 text-sm"> | |
| 348 | + | <span className="min-w-0 grow truncate"> | |
| 349 | + | @{person.username} | |
| 350 | + | {person.own && <span className="ml-1.5 text-xs text-faint">own budget</span>} | |
| 351 | + | </span> | |
| 352 | + | <span className="shrink-0 text-xs text-faint tabular-nums"> | |
| 353 | + | {money(person.spent_micros)} | |
| 354 | + | {person.monthly_micros != null ? ` of ${money(person.monthly_micros)}` : " · no budget"} | |
| 355 | + | </span> | |
| 356 | + | <span className="hidden w-24 shrink-0 sm:block"> | |
| 357 | + | {person.monthly_micros != null && <Meter spent={person.spent_micros} cap={person.monthly_micros} label={`@${person.username}: spent of their budget`} size="sm" />} | |
| 358 | + | </span> | |
| 359 | + | <PersonDialog action={action} person={person} defaultMicros={defaultMicros} trigger={<button type="button" className="shrink-0 text-xs text-accent hover:underline">Set</button>} /> | |
| 360 | + | </li> | |
| 361 | + | ))} | |
| 362 | + | </ul> | |
| 363 | + | )} | |
| 364 | + | <div className="mt-2.5"> | |
| 365 | + | <PersonDialog action={action} person={null} defaultMicros={defaultMicros} trigger={<button type="button" className="text-xs text-accent hover:underline">Give someone their own budget</button>} /> | |
| 366 | + | </div> | |
| 367 | + | </div> | |
| 368 | + | ); | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | /** The workspace's agent policy: every agent together, each person, a new agent, a session. Owners only. */ | |
| 372 | + | function PolicyDialog({ policy, action, trigger }: { policy: AgentPolicy; action: string; trigger: ReactNode }) { | |
| 373 | + | const { fetcher, open, setOpen, error, busy } = useDialogFetcher("spend-policy"); | |
| 374 | + | const id = useId(); | |
| 375 | + | return ( | |
| 376 | + | <Dialog open={open} onOpenChange={setOpen}> | |
| 377 | + | <DialogTrigger asChild>{trigger}</DialogTrigger> | |
| 378 | + | <DialogContent> | |
| 379 | + | <DialogHeader> | |
| 380 | + | <DialogTitle>Agent budgets</DialogTitle> | |
| 381 | + | <DialogDescription>Monthly, reset on the 1st (UTC). The workspace's spend limit sits above all of them. Leave a budget empty for none.</DialogDescription> | |
| 382 | + | </DialogHeader> | |
| 383 | + | <fetcher.Form method="post" action={action} className="grid gap-5"> | |
| 384 | + | <input type="hidden" name="intent" value="policy" /> | |
| 385 | + | <Field> | |
| 386 | + | <FieldLabel htmlFor={`${id}-monthly`}>All agents together</FieldLabel> | |
| 387 | + | <DollarsInput id={`${id}-monthly`} name="monthly" defaultValue={dollarsField(policy.monthly_micros)} placeholder="No budget of its own" /> | |
| 388 | + | </Field> | |
| 389 | + | <Field> | |
| 390 | + | <FieldLabel htmlFor={`${id}-person`}>Each person</FieldLabel> | |
| 391 | + | <DollarsInput id={`${id}-person`} name="person" defaultValue={dollarsField(policy.person_monthly_micros)} placeholder="No budget per person" /> | |
| 392 | + | <FieldDescription>What agents may spend on the work one person asks for. Give someone more or less from their row.</FieldDescription> | |
| 393 | + | </Field> | |
| 394 | + | <Field> | |
| 395 | + | <FieldLabel htmlFor={`${id}-agent`}>A new agent</FieldLabel> | |
| 396 | + | <DollarsInput id={`${id}-agent`} name="default_agent" defaultValue={dollarsField(policy.default_agent_monthly_micros)} placeholder="None" /> | |
| 397 | + | <FieldDescription>Filled in when an agent is hired; change each one on its profile.</FieldDescription> | |
| 398 | + | </Field> | |
| 399 | + | <Field> | |
| 400 | + | <FieldLabel htmlFor={`${id}-session`}>A session</FieldLabel> | |
| 401 | + | <DollarsInput id={`${id}-session`} name="default_session" defaultValue={dollarsField(policy.default_session_micros)} required /> | |
| 402 | + | <FieldDescription>Between $0.10 and $500. A session stops here and asks before spending more.</FieldDescription> | |
| 403 | + | </Field> | |
| 404 | + | <FieldError>{error}</FieldError> | |
| 405 | + | <DialogFooter> | |
| 406 | + | <button type="button" className={BUTTONS.QUIET} onClick={() => setOpen(false)}> | |
| 407 | + | Cancel | |
| 408 | + | </button> | |
| 409 | + | <button type="submit" className={BUTTONS.PRIMARY} disabled={busy}> | |
| 410 | + | {busy ? "Saving…" : "Save budgets"} | |
| 411 | + | </button> | |
| 412 | + | </DialogFooter> | |
| 413 | + | </fetcher.Form> | |
| 414 | + | </DialogContent> | |
| 415 | + | </Dialog> | |
| 416 | + | ); | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | /** One person's own budget: an amount, none at all, or back to the default. Owners only. */ | |
| 420 | + | function PersonDialog({ action, person, defaultMicros, trigger }: { action: string; person: PersonBudget | null; defaultMicros: number | null; trigger: ReactNode }) { | |
| 421 | + | const { fetcher, open, setOpen, error, busy } = useDialogFetcher(`spend-person-${person?.username ?? "new"}`); | |
| 422 | + | const id = useId(); | |
| 423 | + | return ( | |
| 424 | + | <Dialog open={open} onOpenChange={setOpen}> | |
| 425 | + | <DialogTrigger asChild>{trigger}</DialogTrigger> | |
| 426 | + | <DialogContent> | |
| 427 | + | <DialogHeader> | |
| 428 | + | <DialogTitle>{person ? `@${person.username}'s budget` : "A person's own budget"}</DialogTitle> | |
| 429 | + | <DialogDescription> | |
| 430 | + | What agents may spend on work this person asks for each month, in place of the default | |
| 431 | + | {defaultMicros != null ? ` of ${money(defaultMicros)}` : " (none now)"}. 0 means no budget at all. | |
| 432 | + | </DialogDescription> | |
| 433 | + | </DialogHeader> | |
| 434 | + | <fetcher.Form method="post" action={action} className="grid gap-5"> | |
| 435 | + | <input type="hidden" name="intent" value="person" /> | |
| 436 | + | {person ? ( | |
| 437 | + | <input type="hidden" name="username" value={person.username} /> | |
| 438 | + | ) : ( | |
| 439 | + | <Field> | |
| 440 | + | <FieldLabel htmlFor={`${id}-username`}>Username</FieldLabel> | |
| 441 | + | <Input id={`${id}-username`} name="username" required autoComplete="off" placeholder="ana" /> | |
| 442 | + | </Field> | |
| 443 | + | )} | |
| 444 | + | <Field> | |
| 445 | + | <FieldLabel htmlFor={`${id}-amount`}>Monthly budget</FieldLabel> | |
| 446 | + | <DollarsInput id={`${id}-amount`} name="amount" defaultValue={person?.own ? dollarsField(person.monthly_micros ?? 0) : ""} placeholder="The default" /> | |
| 447 | + | <FieldDescription>Empty puts them back on the default.</FieldDescription> | |
| 448 | + | </Field> | |
| 449 | + | <FieldError>{error}</FieldError> | |
| 450 | + | <DialogFooter> | |
| 451 | + | <button type="button" className={BUTTONS.QUIET} onClick={() => setOpen(false)}> | |
| 452 | + | Cancel | |
| 453 | + | </button> | |
| 454 | + | <button type="submit" className={BUTTONS.PRIMARY} disabled={busy}> | |
| 455 | + | {busy ? "Saving…" : "Save"} | |
| 456 | + | </button> | |
| 457 | + | </DialogFooter> | |
| 458 | + | </fetcher.Form> | |
| 459 | + | </DialogContent> | |
| 460 | + | </Dialog> | |
| 461 | + | ); | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | // ── Receipts ───────────────────────────────────────────────────────────── | |
| 465 | + | ||
| 466 | + | /** Where a task's receipt is. */ | |
| 467 | + | export function receiptHref(slug: string, session: Pick<AgentSession, "id">): string { | |
| 468 | + | return `/${slug}/-/spend/receipts/${session.id}`; | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | /** The costliest tasks, each a link to its receipt. */ | |
| 472 | + | export function TaskList({ slug, sessions }: { slug: string; sessions: AgentSession[] }) { | |
| 473 | + | if (sessions.length === 0) return <p className="px-4 py-6 text-center text-sm text-faint">No tasks in this period.</p>; | |
| 474 | + | return ( | |
| 475 | + | <ul className="divide-y divide-line/60"> | |
| 476 | + | {sessions.map((session) => ( | |
| 477 | + | <li key={session.id} className="group relative flex items-center gap-3 px-4 py-3 transition-colors hover:bg-raised/40"> | |
| 478 | + | <AgentAvatar agent={{ handle: session.agent_handle, avatar_seed: session.agent_avatar_seed }} size={24} /> | |
| 479 | + | <div className="min-w-0 grow"> | |
| 480 | + | <Link to={receiptHref(slug, session)} className="block min-w-0 truncate text-sm font-medium after:absolute after:inset-0 hover:underline"> | |
| 481 | + | {session.visible ? session.title || "Untitled session" : <PrivateTitle />} | |
| 482 | + | </Link> | |
| 483 | + | <p className="mt-0.5 truncate text-xs text-faint"> | |
| 484 | + | {session.agent_name} | |
| 485 | + | {session.visible && session.asked_by_username ? ` · for @${session.asked_by_username}` : ""} · {stepsLine(session)} · <TimeAgo at={session.created_at} /> | |
| 486 | + | </p> | |
| 487 | + | </div> | |
| 488 | + | <span className="shrink-0 text-sm tabular-nums">{money(session.charged_micros)}</span> | |
| 489 | + | <ArrowRight size={14} className="shrink-0 text-faint group-hover:text-muted" aria-hidden="true" /> | |
| 490 | + | </li> | |
| 491 | + | ))} | |
| 492 | + | </ul> | |
| 493 | + | ); | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | // ── Pricing ────────────────────────────────────────────────────────────── | |
| 497 | + | ||
| 498 | + | /** How g1t prices what it runs, from the price book. */ | |
| 499 | + | export function PricingCard({ pricing }: { pricing: Pricing | null }) { | |
| 500 | + | if (!pricing) return <p className="rounded-xl border border-line bg-surface px-4 py-6 text-center text-sm text-muted">Prices couldn't be read right now.</p>; | |
| 501 | + | const rows: { icon: ReactNode; title: string; value: string; about: string }[] = [ | |
| 502 | + | { | |
| 503 | + | icon: <Cpu size={15} />, | |
| 504 | + | title: "Models", | |
| 505 | + | value: pricing.modelMarkupPercent > 0 ? `Provider price + ${pricing.modelMarkupPercent}%` : "Provider price", | |
| 506 | + | about: pricing.modelMarkupPercent > 0 ? "What the model provider charges, with the markup shown." : "What the model provider charges, with no markup.", | |
| 507 | + | }, | |
| 508 | + | { | |
| 509 | + | icon: <Sparkles size={15} />, | |
| 510 | + | title: "Agent rate", | |
| 511 | + | value: pricing.agentRateMicros != null ? `${money(pricing.agentRateMicros)} per million tokens` : "None", | |
| 512 | + | about: "For context, memory, routing and orchestration around each model call, on every token an agent's run uses.", | |
| 513 | + | }, | |
| 514 | + | { | |
| 515 | + | icon: <Coins size={15} />, | |
| 516 | + | title: "Everything g1t runs", | |
| 517 | + | value: pricing.markup ? `At cost + ${markupLabel(pricing.markup)}` : "At cost", | |
| 518 | + | about: "Sandboxes, builds, hosting, storage and search, at what they cost g1t.", | |
| 519 | + | }, | |
| 520 | + | { | |
| 521 | + | icon: <KeyRound size={15} />, | |
| 522 | + | title: "Your own model keys", | |
| 523 | + | value: pricing.agentRateOwnMicros != null ? `${money(pricing.agentRateOwnMicros)} per million tokens` : "Agent rate only", | |
| 524 | + | about: "Your provider bills the model; only the agent rate is charged here. Local models the same.", | |
| 525 | + | }, | |
| 526 | + | { icon: <Building2 size={15} />, title: "Your own runners", value: "$0", about: "Time on your own hardware is never charged." }, | |
| 527 | + | { icon: <Users size={15} />, title: "People", value: "No seats", about: "Invite everyone; people chatting costs nothing." }, | |
| 528 | + | ]; | |
| 529 | + | return ( | |
| 530 | + | <ul className="grid gap-px overflow-hidden rounded-xl border border-line bg-line sm:grid-cols-2 lg:grid-cols-3"> | |
| 531 | + | {rows.map((row) => ( | |
| 532 | + | <li key={row.title} className="bg-surface p-4"> | |
| 533 | + | <p className="flex items-center gap-2 text-xs text-muted"> | |
| 534 | + | <span className="text-faint">{row.icon}</span> | |
| 535 | + | {row.title} | |
| 536 | + | </p> | |
| 537 | + | <p className="mt-1 text-sm font-medium">{row.value}</p> | |
| 538 | + | <p className="mt-0.5 text-xs text-faint">{row.about}</p> | |
| 539 | + | </li> | |
| 540 | + | ))} | |
| 541 | + | </ul> | |
| 542 | + | ); | |
| 543 | + | } | |
| 544 | + | ||
| 545 | + | // ── The top bar's pill ─────────────────────────────────────────────────── | |
| 546 | + | ||
| 547 | + | const SCOPE_KEY = "g1t_spend_pill"; | |
| 548 | + | ||
| 549 | + | function storedScope(): SpendScope { | |
| 550 | + | try { | |
| 551 | + | return window.localStorage.getItem(SCOPE_KEY) === "workspace" ? "workspace" : "me"; | |
| 552 | + | } catch { | |
| 553 | + | return "me"; | |
| 554 | + | } | |
| 555 | + | } | |
| 556 | + | ||
| 557 | + | /** | |
| 558 | + | * Your spend this month in the top bar: what agents did for you against | |
| 559 | + | * your budget, or, for owners and billing managers who switch it, what the | |
| 560 | + | * workspace was charged against its spend limit. A click opens where it | |
| 561 | + | * went. Read after the page draws, so it adds nothing to the page's time. | |
| 562 | + | */ | |
| 563 | + | export function SpendPill({ slug, mayWorkspace }: { slug: string; mayWorkspace: boolean }) { | |
| 564 | + | const pill = useFetcher<PillData>({ key: `spend-pill:${slug}` }); | |
| 565 | + | const [scope, setScope] = useState<SpendScope>("me"); | |
| 566 | + | const [open, setOpen] = useState(false); | |
| 567 | + | useEffect(() => { | |
| 568 | + | setScope(mayWorkspace ? storedScope() : "me"); | |
| 569 | + | pill.load(`/${slug}/-/spend/pill`); | |
| 570 | + | // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 571 | + | }, [slug, mayWorkspace]); | |
| 572 | + | useEffect(() => { | |
| 573 | + | if (open) pill.load(`/${slug}/-/spend/pill`); | |
| 574 | + | // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 575 | + | }, [open]); | |
| 576 | + | const choose = (next: SpendScope) => { | |
| 577 | + | setScope(next); | |
| 578 | + | try { | |
| 579 | + | window.localStorage.setItem(SCOPE_KEY, next); | |
| 580 | + | } catch { | |
| 581 | + | // Kept for this page only. | |
| 582 | + | } | |
| 583 | + | }; | |
| 584 | + | const data = pill.data; | |
| 585 | + | const ws = scope === "workspace" ? data?.workspace : null; | |
| 586 | + | const spent = ws ? ws.spentMicros : (data?.me?.spentMicros ?? null); | |
| 587 | + | const budget = ws ? ws.limitMicros : (data?.me?.budgetMicros ?? null); | |
| 588 | + | const share = spent != null ? shareOfBudget(spent, budget) : null; | |
| 589 | + | const label = spent == null ? (data ? "Spend" : "") : money(spent); | |
| 590 | + | return ( | |
| 591 | + | <Popover open={open} onOpenChange={setOpen}> | |
| 592 | + | <PopoverTrigger | |
| 593 | + | aria-label={spent == null ? "Spend this month" : `${ws ? "The workspace's" : "Your"} spend this month: ${money(spent)}${budget != null ? ` of ${money(budget)}` : ""}`} | |
| 594 | + | className="flex h-8 items-center gap-2 rounded-md border border-line px-2.5 text-[0.8125rem] text-fg/90 tabular-nums transition-colors outline-none hover:border-line-strong hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:bg-raised" | |
| 595 | + | > | |
| 596 | + | {ws ? <Building2 size={14} className="shrink-0 text-faint" /> : <Coins size={14} className="shrink-0 text-faint" />} | |
| 597 | + | {data ? <span>{label}</span> : <Skeleton className="h-3 w-10" />} | |
| 598 | + | {share != null && ( | |
| 599 | + | <span className="hidden w-9 lg:block"> | |
| 600 | + | <Meter spent={spent!} cap={budget} label="Spent of the budget" size="sm" /> | |
| 601 | + | </span> | |
| 602 | + | )} | |
| 603 | + | </PopoverTrigger> | |
| 604 | + | <PopoverContent align="end" className="w-80 p-0"> | |
| 605 | + | <SpendPopover slug={slug} data={data ?? null} scope={scope} mayWorkspace={mayWorkspace} onScope={choose} onClose={() => setOpen(false)} /> | |
| 606 | + | </PopoverContent> | |
| 607 | + | </Popover> | |
| 608 | + | ); | |
| 609 | + | } | |
| 610 | + | ||
| 611 | + | function SpendPopover({ | |
| 612 | + | slug, | |
| 613 | + | data, | |
| 614 | + | scope, | |
| 615 | + | mayWorkspace, | |
| 616 | + | onScope, | |
| 617 | + | onClose, | |
| 618 | + | }: { | |
| 619 | + | slug: string; | |
| 620 | + | data: PillData | null; | |
| 621 | + | scope: SpendScope; | |
| 622 | + | mayWorkspace: boolean; | |
| 623 | + | onScope: (scope: SpendScope) => void; | |
| 624 | + | onClose: () => void; | |
| 625 | + | }) { | |
| 626 | + | const month = data ? monthName(data.month).split(" ")[0] : ""; | |
| 627 | + | const ws = scope === "workspace" ? data?.workspace : null; | |
| 628 | + | return ( | |
| 629 | + | <div> | |
| 630 | + | <div className="flex items-center gap-1 border-b border-line p-1.5"> | |
| 631 | + | {(["me", "workspace"] as const).map((key) => | |
| 632 | + | key === "workspace" && !mayWorkspace ? ( | |
| 633 | + | <Hint key={key} label="The workspace's spend is for owners and billing managers."> | |
| 634 | + | <span tabIndex={0} className="flex h-7 grow cursor-default items-center justify-center rounded-md text-xs text-faint"> | |
| 635 | + | Workspace | |
| 636 | + | </span> | |
| 637 | + | </Hint> | |
| 638 | + | ) : ( | |
| 639 | + | <button | |
| 640 | + | key={key} | |
| 641 | + | type="button" | |
| 642 | + | aria-pressed={scope === key} | |
| 643 | + | onClick={() => onScope(key)} | |
| 644 | + | className={cn("h-7 grow rounded-md text-xs transition-colors", scope === key ? "bg-surface text-fg ring-1 ring-line-strong" : "text-muted hover:text-fg")} | |
| 645 | + | > | |
| 646 | + | {key === "me" ? "You" : "Workspace"} | |
| 647 | + | </button> | |
| 648 | + | ), | |
| 649 | + | )} | |
| 650 | + | </div> | |
| 651 | + | {!data ? ( | |
| 652 | + | <div className="space-y-2 p-4"> | |
| 653 | + | <Skeleton className="h-3 w-32" /> | |
| 654 | + | <Skeleton className="h-6 w-24" /> | |
| 655 | + | <Skeleton className="h-3 w-full" /> | |
| 656 | + | </div> | |
| 657 | + | ) : ws ? ( | |
| 658 | + | <div className="p-4"> | |
| 659 | + | <p className="text-xs text-muted">The workspace · {month}, charged</p> | |
| 660 | + | <p className="mt-1 text-xl font-semibold tabular-nums"> | |
| 661 | + | {ws.spentMicros != null ? money(ws.spentMicros) : "—"} | |
| 662 | + | {ws.limitMicros != null && <span className="ml-1.5 text-sm font-normal text-muted">of {money(ws.limitMicros)}</span>} | |
| 663 | + | </p> | |
| 664 | + | {ws.spentMicros != null && ws.limitMicros != null && <Meter spent={ws.spentMicros} cap={ws.limitMicros} label="Charged of the spend limit" size="sm" className="mt-2" />} | |
| 665 | + | <p className="mt-3 text-xs text-muted">Agents this month{ws.agentsMicros != null ? `: ${money(ws.agentsMicros)}` : ""}</p> | |
| 666 | + | <PopoverSlices slices={ws.byAgent} /> | |
| 667 | + | </div> | |
| 668 | + | ) : data.me ? ( | |
| 669 | + | <div className="p-4"> | |
| 670 | + | <p className="text-xs text-muted">You · {month}, agents working for you included</p> | |
| 671 | + | <p className="mt-1 text-xl font-semibold tabular-nums"> | |
| 672 | + | {money(data.me.spentMicros)} | |
| 673 | + | {data.me.budgetMicros != null && <span className="ml-1.5 text-sm font-normal text-muted">of {money(data.me.budgetMicros)}</span>} | |
| 674 | + | </p> | |
| 675 | + | {data.me.budgetMicros != null ? ( | |
| 676 | + | <Meter spent={data.me.spentMicros} cap={data.me.budgetMicros} label="Spent of your budget" size="sm" className="mt-2" /> | |
| 677 | + | ) : ( | |
| 678 | + | <p className="mt-0.5 text-xs text-faint">No budget of your own; the workspace's limits apply.</p> | |
| 679 | + | )} | |
| 680 | + | <PopoverSlices slices={data.me.byKind} /> | |
| 681 | + | {data.me.byAgent.length > 0 && ( | |
| 682 | + | <p className="mt-2 truncate text-xs text-faint"> | |
| 683 | + | {data.me.byAgent | |
| 684 | + | .slice(0, 3) | |
| 685 | + | .map((s) => `${s.label.replace(/ \(@[^)]+\)$/, "")} ${money(s.micros)}`) | |
| 686 | + | .join(" · ")} | |
| 687 | + | </p> | |
| 688 | + | )} | |
| 689 | + | </div> | |
| 690 | + | ) : ( | |
| 691 | + | <p className="p-4 text-sm text-muted">Your spend couldn't be read right now.</p> | |
| 692 | + | )} | |
| 693 | + | <div className="border-t border-line p-1.5"> | |
| 694 | + | <Link | |
| 695 | + | to={`/${slug}/-/spend${scope === "workspace" ? "" : "?scope=me"}`} | |
| 696 | + | onClick={onClose} | |
| 697 | + | className="flex h-8 items-center justify-between rounded-md px-2.5 text-sm text-fg/90 hover:bg-surface hover:text-fg" | |
| 698 | + | > | |
| 699 | + | {scope === "workspace" ? "Open workspace spend" : "Your spend and budget"} | |
| 700 | + | <ArrowRight size={14} className="text-faint" /> | |
| 701 | + | </Link> | |
| 702 | + | </div> | |
| 703 | + | </div> | |
| 704 | + | ); | |
| 705 | + | } | |
| 706 | + | ||
| 707 | + | function PopoverSlices({ slices }: { slices: { key: string; label: string; micros: number }[] }) { | |
| 708 | + | const shown = slices.filter((s) => s.micros > 0).slice(0, 4); | |
| 709 | + | if (shown.length === 0) return <p className="mt-2 text-xs text-faint">Nothing spent yet this month.</p>; | |
| 710 | + | return ( | |
| 711 | + | <ul className="mt-2 space-y-1"> | |
| 712 | + | {shown.map((s) => ( | |
| 713 | + | <li key={s.key || "none"} className="flex items-center justify-between gap-3 text-sm"> | |
| 714 | + | <span className="min-w-0 truncate text-fg-soft">{s.label}</span> | |
| 715 | + | <span className="shrink-0 tabular-nums">{money(s.micros)}</span> | |
| 716 | + | </li> | |
| 717 | + | ))} | |
| 718 | + | </ul> | |
| 719 | + | ); | |
| 720 | + | } |
| 118 | 118 | "settings statement statement_entries status status_by_id suggest tree usage usage_meters user_by_username " + | |
| 119 | 119 | "user_for_session user_for_access_token usernames waiting_workspaces workflows workspace workspace_invites github_enabled " + | |
| 120 | 120 | "stars about public_links branch_drift tags last_commits languages contributors license releases release " + | |
| 121 | − | "stargazers starred commit_checks shortcuts" | |
| 121 | + | "stargazers starred commit_checks shortcuts spend person_budgets usage_report" | |
| 122 | 122 | ).split(" "), | |
| 123 | 123 | ); | |
| 124 | 124 |
| 1 | + | /** | |
| 2 | + | * Spend's reads: each part from the service that owns it, each on its own | |
| 3 | + | * so one that fails leaves only its part saying so. Billing has the | |
| 4 | + | * workspace's money (usage at price, the spend limit, caps, the price | |
| 5 | + | * book); the agents service has where agent work went (by agent, person, | |
| 6 | + | * channel, model, kind) and the budgets below the workspace's (its agents | |
| 7 | + | * together, each person, each agent, each session). The shaping is in | |
| 8 | + | * ./spend.ts. | |
| 9 | + | */ | |
| 10 | + | import type { AgentPolicy, AgentSpendBreakdown, Limit, PersonBudgets, SpendPeriod, UsageReport, User, WorkspaceAgent } from "@g1t/contracts"; | |
| 11 | + | ||
| 12 | + | import { type Pricing, type SpendScope, pricingOf, spanFor } from "./spend"; | |
| 13 | + | import { billing, workspaceAgents } from "./services.server"; | |
| 14 | + | ||
| 15 | + | const warn = (what: string) => (error: unknown) => { | |
| 16 | + | console.warn(`spend: ${what} failed`, error); | |
| 17 | + | return null; | |
| 18 | + | }; | |
| 19 | + | ||
| 20 | + | const value = <T>(result: { ok: true; value: T } | { ok: false } | null): T | null => (result && result.ok ? result.value : null); | |
| 21 | + | ||
| 22 | + | /** Where agent work went for the scope and period: everyone's, or only what the viewer asked for. */ | |
| 23 | + | export function loadBreakdown(viewer: User, slug: string, scope: SpendScope, period: SpendPeriod): Promise<AgentSpendBreakdown | null> { | |
| 24 | + | return workspaceAgents | |
| 25 | + | .spend(slug, viewer, null, { period, person: scope === "me" ? viewer.username.toLowerCase() : null }) | |
| 26 | + | .then(value) | |
| 27 | + | .catch(warn("agents spend")); | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /** Everything the workspace used over the period, at price, by product and day. */ | |
| 31 | + | export function loadUsage(viewer: User, slug: string, period: SpendPeriod, now: Date): Promise<UsageReport | null> { | |
| 32 | + | const { from, until } = spanFor(period, now); | |
| 33 | + | return billing.usageReport(slug, viewer, { from, until }).then(value).catch(warn("usage report")); | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /** One agent's budget and month, as the budgets list shows it. */ | |
| 37 | + | export type AgentBudgetRow = Pick<WorkspaceAgent, "id" | "handle" | "display_name" | "avatar_seed" | "budget" | "spent_month_micros" | "builtin">; | |
| 38 | + | ||
| 39 | + | /** Every level of budget, widest first, with what is spent against each this month. */ | |
| 40 | + | export type Budgets = { | |
| 41 | + | /** The workspace's spend limit (billing); null when billing did not answer or is off. */ | |
| 42 | + | limit: Limit | null; | |
| 43 | + | /** The plan's caps on one run and one issue (billing). */ | |
| 44 | + | caps: { runMicros: number; issueMicros: number } | null; | |
| 45 | + | /** The workspace's agent policy: every agent together, the per-person default, a new agent's, a session's. */ | |
| 46 | + | policy: AgentPolicy | null; | |
| 47 | + | /** Every agent's spend this month, against the policy's budget. */ | |
| 48 | + | agentsMonthMicros: number | null; | |
| 49 | + | people: PersonBudgets | null; | |
| 50 | + | agents: AgentBudgetRow[] | null; | |
| 51 | + | }; | |
| 52 | + | ||
| 53 | + | export async function loadBudgets(viewer: User, slug: string, monthBreakdown: Promise<AgentSpendBreakdown | null>): Promise<Budgets> { | |
| 54 | + | const [limit, entitlements, policy, people, agents, month] = await Promise.all([ | |
| 55 | + | billing.limit(slug, viewer).then(value).catch(warn("limit")), | |
| 56 | + | billing.entitlements(slug).catch(warn("entitlements")), | |
| 57 | + | workspaceAgents.policy(slug, viewer).then(value).catch(warn("agent policy")), | |
| 58 | + | workspaceAgents.personBudgets(slug, viewer).then(value).catch(warn("person budgets")), | |
| 59 | + | workspaceAgents.list(slug, viewer).then(value).catch(warn("agents")), | |
| 60 | + | monthBreakdown, | |
| 61 | + | ]); | |
| 62 | + | return { | |
| 63 | + | limit, | |
| 64 | + | caps: entitlements ? { runMicros: entitlements.runCapMicros, issueMicros: entitlements.issueCapMicros } : null, | |
| 65 | + | policy, | |
| 66 | + | agentsMonthMicros: month?.total_micros ?? null, | |
| 67 | + | people, | |
| 68 | + | agents: agents | |
| 69 | + | ? agents | |
| 70 | + | .filter((a) => !a.archived_at) | |
| 71 | + | .map((a) => ({ id: a.id, handle: a.handle, display_name: a.display_name, avatar_seed: a.avatar_seed, budget: a.budget, spent_month_micros: a.spent_month_micros, builtin: a.builtin })) | |
| 72 | + | .sort((a, b) => Number(b.builtin) - Number(a.builtin) || b.spent_month_micros - a.spent_month_micros) | |
| 73 | + | : null, | |
| 74 | + | }; | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /** What the price book says about pricing. */ | |
| 78 | + | export function loadPricing(): Promise<Pricing | null> { | |
| 79 | + | return billing | |
| 80 | + | .prices() | |
| 81 | + | .then(pricingOf) | |
| 82 | + | .catch(warn("prices")); | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /** The top bar's pill: the viewer's month, and the workspace's for someone who may see it. */ | |
| 86 | + | export type PillData = { | |
| 87 | + | month: string; | |
| 88 | + | me: { spentMicros: number; budgetMicros: number | null; byKind: AgentSpendBreakdown["by_kind"]; byAgent: AgentSpendBreakdown["by_agent"] } | null; | |
| 89 | + | workspace: { spentMicros: number | null; limitMicros: number | null; agentsMicros: number | null; byAgent: AgentSpendBreakdown["by_agent"] } | null; | |
| 90 | + | }; | |
| 91 | + | ||
| 92 | + | export async function loadPill(viewer: User, slug: string, mayWorkspace: boolean): Promise<PillData> { | |
| 93 | + | const me = viewer.username.toLowerCase(); | |
| 94 | + | const [mine, people, everyone, limit] = await Promise.all([ | |
| 95 | + | loadBreakdown(viewer, slug, "me", "month"), | |
| 96 | + | workspaceAgents.personBudgets(slug, viewer).then(value).catch(warn("person budgets")), | |
| 97 | + | mayWorkspace ? loadBreakdown(viewer, slug, "workspace", "month") : Promise.resolve(null), | |
| 98 | + | mayWorkspace ? billing.limit(slug, viewer).then(value).catch(warn("limit")) : Promise.resolve(null), | |
| 99 | + | ]); | |
| 100 | + | const own = people?.people.find((p) => p.username === me); | |
| 101 | + | const budget = own ? own.monthly_micros : (people?.default_micros ?? null); | |
| 102 | + | return { | |
| 103 | + | month: (mine ?? everyone)?.period ?? new Date().toISOString().slice(0, 7), | |
| 104 | + | me: mine ? { spentMicros: mine.total_micros, budgetMicros: budget, byKind: mine.by_kind, byAgent: mine.by_agent } : null, | |
| 105 | + | workspace: mayWorkspace | |
| 106 | + | ? { | |
| 107 | + | spentMicros: limit?.spentMicros ?? null, | |
| 108 | + | limitMicros: limit ? (limit.spendLimitMicros ?? limit.ceilingMicros) : null, | |
| 109 | + | agentsMicros: everyone?.total_micros ?? null, | |
| 110 | + | byAgent: everyone?.by_agent ?? [], | |
| 111 | + | } | |
| 112 | + | : null, | |
| 113 | + | }; | |
| 114 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import type { AgentSession, PriceBook } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import { daySeries, markupLabel, percentLabel, pricingOf, readPeriod, readScope, receiptOf, spanFor, tokenCount } from "./spend.ts"; | |
| 7 | + | ||
| 8 | + | const OCT_8 = new Date("2026-10-08T15:00:00Z"); | |
| 9 | + | ||
| 10 | + | test("a period's days match the agents service's, both ends included", () => { | |
| 11 | + | assert.deepEqual(spanFor("month", OCT_8), { from: "2026-10-01", until: "2026-10-08" }); | |
| 12 | + | assert.deepEqual(spanFor("last_month", OCT_8), { from: "2026-09-01", until: "2026-09-30" }); | |
| 13 | + | assert.deepEqual(spanFor("last_month", new Date("2026-01-15T00:00:00Z")), { from: "2025-12-01", until: "2025-12-31" }); | |
| 14 | + | assert.deepEqual(spanFor("7d", OCT_8), { from: "2026-10-02", until: "2026-10-08" }); | |
| 15 | + | assert.deepEqual(spanFor("30d", OCT_8), { from: "2026-09-09", until: "2026-10-08" }); | |
| 16 | + | }); | |
| 17 | + | ||
| 18 | + | test("only someone who may see the workspace's spend gets it; anything else is their own", () => { | |
| 19 | + | assert.equal(readScope(null, true), "workspace"); | |
| 20 | + | assert.equal(readScope("me", true), "me"); | |
| 21 | + | assert.equal(readScope("workspace", false), "me"); | |
| 22 | + | assert.equal(readPeriod("7d"), "7d"); | |
| 23 | + | assert.equal(readPeriod("year"), "month"); | |
| 24 | + | }); | |
| 25 | + | ||
| 26 | + | test("a day series has every day, products added together, zeros where nothing was spent", () => { | |
| 27 | + | const days = daySeries("2026-10-01", "2026-10-03", [ | |
| 28 | + | { day: "2026-10-01", micros: 5 }, | |
| 29 | + | { day: "2026-10-01", micros: 7 }, | |
| 30 | + | { day: "2026-10-03", micros: 1 }, | |
| 31 | + | ]); | |
| 32 | + | assert.deepEqual(days, [ | |
| 33 | + | { day: "2026-10-01", micros: 12 }, | |
| 34 | + | { day: "2026-10-02", micros: 0 }, | |
| 35 | + | { day: "2026-10-03", micros: 1 }, | |
| 36 | + | ]); | |
| 37 | + | }); | |
| 38 | + | ||
| 39 | + | test("pricing comes from the price book: the model markup, the run markup, the agent rate", () => { | |
| 40 | + | const book = { | |
| 41 | + | modelMarginPercent: 0, | |
| 42 | + | changes: [], | |
| 43 | + | prices: [ | |
| 44 | + | { meter: "sandbox_second", costMicros: 10, markupPercent: 20, priceMicros: 12 }, | |
| 45 | + | { meter: "build_second", costMicros: 10, markupPercent: 20, priceMicros: 12 }, | |
| 46 | + | { meter: "agent_tokens", costMicros: 0, markupPercent: 0, priceMicros: 250_000 }, | |
| 47 | + | { meter: "agent_tokens_own", costMicros: 0, markupPercent: 0, priceMicros: 250_000 }, | |
| 48 | + | ], | |
| 49 | + | } as unknown as PriceBook; | |
| 50 | + | const pricing = pricingOf(book); | |
| 51 | + | assert.equal(pricing.modelMarkupPercent, 0); | |
| 52 | + | assert.deepEqual(pricing.markup, { min: 20, max: 20 }); | |
| 53 | + | assert.equal(markupLabel(pricing.markup!), "20%"); | |
| 54 | + | assert.equal(markupLabel({ min: 15, max: 20 }), "15–20%"); | |
| 55 | + | assert.equal(pricing.agentRateMicros, 250_000); | |
| 56 | + | }); | |
| 57 | + | ||
| 58 | + | const session = (over: Partial<AgentSession>): AgentSession => | |
| 59 | + | ({ id: "s", parent_id: null, root_id: "r", charged_micros: 0, cost_micros: 0, input_tokens: 0, output_tokens: 0, steps: 0, tool_calls: 0, created_at: "2026-10-08T00:00:00Z", ...over }) as AgentSession; | |
| 60 | + | ||
| 61 | + | test("a receipt gives the root its own share, children under their parents, and the tree's totals", () => { | |
| 62 | + | const tree = [ | |
| 63 | + | session({ id: "r", charged_micros: 1_840_000, cost_micros: 1_000_000, input_tokens: 400_000, output_tokens: 30_000, steps: 6, tool_calls: 20 }), | |
| 64 | + | session({ id: "c1", parent_id: "r", charged_micros: 300_000, cost_micros: 200_000, input_tokens: 10_000, steps: 2, created_at: "2026-10-08T00:01:00Z" }), | |
| 65 | + | session({ id: "g1", parent_id: "c1", charged_micros: 40_000, cost_micros: 30_000, steps: 1, created_at: "2026-10-08T00:02:00Z" }), | |
| 66 | + | ]; | |
| 67 | + | const receipt = receiptOf(tree, "r")!; | |
| 68 | + | assert.deepEqual( | |
| 69 | + | receipt.lines.map((l) => [l.session.id, l.depth, l.ownMicros]), | |
| 70 | + | [ | |
| 71 | + | ["r", 0, 1_500_000], | |
| 72 | + | ["c1", 1, 300_000], | |
| 73 | + | ["g1", 2, 40_000], | |
| 74 | + | ], | |
| 75 | + | ); | |
| 76 | + | assert.equal(receipt.chargedMicros, 1_840_000); | |
| 77 | + | assert.equal(receipt.providerMicros, 1_230_000); | |
| 78 | + | assert.equal(receipt.steps, 9); | |
| 79 | + | assert.equal(receiptOf([], "r"), null); | |
| 80 | + | }); | |
| 81 | + | ||
| 82 | + | test("token counts and percents read short", () => { | |
| 83 | + | assert.equal(tokenCount(830), "830"); | |
| 84 | + | assert.equal(tokenCount(1_200), "1.2K"); | |
| 85 | + | assert.equal(tokenCount(412_000), "412K"); | |
| 86 | + | assert.equal(tokenCount(1_250_000), "1.3M"); | |
| 87 | + | assert.equal(percentLabel(62, 100), "62%"); | |
| 88 | + | assert.equal(percentLabel(62, null), ""); | |
| 89 | + | }); |
| 1 | + | /** | |
| 2 | + | * Spend's arithmetic and wording (routes/workspace/spend.tsx and the top | |
| 3 | + | * bar's spend pill): which days a period covers, what the price book says | |
| 4 | + | * about pricing, a day-by-day series, a task's receipt from its session | |
| 5 | + | * tree, and what each budget does when it is reached. Pure, with type-only | |
| 6 | + | * imports, so it is tested on its own. Every number comes from a service; | |
| 7 | + | * nothing here invents one. | |
| 8 | + | */ | |
| 9 | + | import type { AgentSession, PriceBook, SpendPeriod, SpendSlice, UsageDay } from "@g1t/contracts"; | |
| 10 | + | ||
| 11 | + | /** Whose spend: your own (what agents did for you), or the whole workspace's. */ | |
| 12 | + | export type SpendScope = "me" | "workspace"; | |
| 13 | + | ||
| 14 | + | export const SPEND_PERIODS: { key: SpendPeriod; label: string; short: string }[] = [ | |
| 15 | + | { key: "month", label: "This month", short: "This month" }, | |
| 16 | + | { key: "last_month", label: "Last month", short: "Last month" }, | |
| 17 | + | { key: "30d", label: "Last 30 days", short: "30 days" }, | |
| 18 | + | { key: "7d", label: "Last 7 days", short: "7 days" }, | |
| 19 | + | ]; | |
| 20 | + | ||
| 21 | + | export function readPeriod(raw: string | null | undefined): SpendPeriod { | |
| 22 | + | return SPEND_PERIODS.some((p) => p.key === raw) ? (raw as SpendPeriod) : "month"; | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | export function periodLabel(period: SpendPeriod): string { | |
| 26 | + | return SPEND_PERIODS.find((p) => p.key === period)?.label ?? "This month"; | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /** Whose view a request asks for: the workspace's only for someone who may see it. */ | |
| 30 | + | export function readScope(raw: string | null | undefined, mayWorkspace: boolean): SpendScope { | |
| 31 | + | if (!mayWorkspace) return "me"; | |
| 32 | + | return raw === "me" ? "me" : "workspace"; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | const iso = (at: Date) => at.toISOString().slice(0, 10); | |
| 36 | + | ||
| 37 | + | /** | |
| 38 | + | * The days a period covers in UTC, both ends included, as the agents | |
| 39 | + | * service counts them (services/agents/src/budget.ts `spendSpan`). | |
| 40 | + | */ | |
| 41 | + | export function spanFor(period: SpendPeriod, now: Date): { from: string; until: string } { | |
| 42 | + | const y = now.getUTCFullYear(); | |
| 43 | + | const m = now.getUTCMonth(); | |
| 44 | + | const d = now.getUTCDate(); | |
| 45 | + | switch (period) { | |
| 46 | + | case "last_month": | |
| 47 | + | return { from: iso(new Date(Date.UTC(y, m - 1, 1))), until: iso(new Date(Date.UTC(y, m, 0))) }; | |
| 48 | + | case "7d": | |
| 49 | + | return { from: iso(new Date(Date.UTC(y, m, d - 6))), until: iso(now) }; | |
| 50 | + | case "30d": | |
| 51 | + | return { from: iso(new Date(Date.UTC(y, m, d - 29))), until: iso(now) }; | |
| 52 | + | default: | |
| 53 | + | return { from: iso(new Date(Date.UTC(y, m, 1))), until: iso(now) }; | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /** Every day from `from` to `until`, with what was spent on it; days with none are 0. */ | |
| 58 | + | export function daySeries(from: string, until: string, spent: { day: string; micros: number }[]): { day: string; micros: number }[] { | |
| 59 | + | const by = new Map<string, number>(); | |
| 60 | + | for (const s of spent) by.set(s.day, (by.get(s.day) ?? 0) + s.micros); | |
| 61 | + | const out: { day: string; micros: number }[] = []; | |
| 62 | + | const at = new Date(`${from}T00:00:00Z`); | |
| 63 | + | const end = new Date(`${until}T00:00:00Z`); | |
| 64 | + | if (Number.isNaN(at.getTime()) || Number.isNaN(end.getTime())) return out; | |
| 65 | + | for (let n = 0; at <= end && n < 400; n++) { | |
| 66 | + | const key = iso(at); | |
| 67 | + | out.push({ day: key, micros: by.get(key) ?? 0 }); | |
| 68 | + | at.setUTCDate(at.getUTCDate() + 1); | |
| 69 | + | } | |
| 70 | + | return out; | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /** Usage's days (one row per product a day) as one total a day. */ | |
| 74 | + | export function usageByDay(days: UsageDay[]): { day: string; micros: number }[] { | |
| 75 | + | return days.map((d) => ({ day: d.day, micros: d.micros })); | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /** What the price book says about pricing, for the page's pricing card. */ | |
| 79 | + | export type Pricing = { | |
| 80 | + | /** What models are marked up, in percent: 0 is the provider's price. */ | |
| 81 | + | modelMarkupPercent: number; | |
| 82 | + | /** What g1t's own metered work is marked up: one figure, or the range across meters. */ | |
| 83 | + | markup: { min: number; max: number } | null; | |
| 84 | + | /** g1t's agent rate per million tokens, on g1t's models and on your own key. Null when the book has none. */ | |
| 85 | + | agentRateMicros: number | null; | |
| 86 | + | agentRateOwnMicros: number | null; | |
| 87 | + | }; | |
| 88 | + | ||
| 89 | + | const AGENT_METERS = new Set(["agent_models", "agent_tokens", "agent_tokens_own"]); | |
| 90 | + | ||
| 91 | + | export function pricingOf(book: PriceBook): Pricing { | |
| 92 | + | const run = book.prices.filter((p) => !AGENT_METERS.has(p.meter) && p.costMicros > 0); | |
| 93 | + | const markups = run.map((p) => p.markupPercent); | |
| 94 | + | const rate = (meter: string) => { | |
| 95 | + | const price = book.prices.find((p) => p.meter === meter); | |
| 96 | + | return price ? price.priceMicros : null; | |
| 97 | + | }; | |
| 98 | + | return { | |
| 99 | + | modelMarkupPercent: book.modelMarginPercent, | |
| 100 | + | markup: markups.length ? { min: Math.min(...markups), max: Math.max(...markups) } : null, | |
| 101 | + | agentRateMicros: rate("agent_tokens"), | |
| 102 | + | agentRateOwnMicros: rate("agent_tokens_own"), | |
| 103 | + | }; | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /** "20%", or "15–20%" when meters differ. */ | |
| 107 | + | export function markupLabel(markup: { min: number; max: number }): string { | |
| 108 | + | return markup.min === markup.max ? `${markup.min}%` : `${markup.min}–${markup.max}%`; | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /** One line of a task's receipt: one session of its tree. */ | |
| 112 | + | export type ReceiptLine = { | |
| 113 | + | session: AgentSession; | |
| 114 | + | depth: number; | |
| 115 | + | /** What this session's own steps were charged (a root's charge includes its tree's). */ | |
| 116 | + | ownMicros: number; | |
| 117 | + | }; | |
| 118 | + | ||
| 119 | + | export type Receipt = { | |
| 120 | + | root: AgentSession; | |
| 121 | + | lines: ReceiptLine[]; | |
| 122 | + | /** The whole tree, as the root's charge counts it. */ | |
| 123 | + | chargedMicros: number; | |
| 124 | + | /** The model answers at the provider's price, every session's together. */ | |
| 125 | + | providerMicros: number; | |
| 126 | + | inputTokens: number; | |
| 127 | + | outputTokens: number; | |
| 128 | + | steps: number; | |
| 129 | + | toolCalls: number; | |
| 130 | + | }; | |
| 131 | + | ||
| 132 | + | /** | |
| 133 | + | * A task's receipt from its session tree (root first, as the agents | |
| 134 | + | * service gives it): each session's own charge, children under their | |
| 135 | + | * parents, and the totals. A child's charge is added to the root as it is | |
| 136 | + | * spent, so the root's own share is its charge less its children's. | |
| 137 | + | */ | |
| 138 | + | export function receiptOf(tree: AgentSession[], rootId: string): Receipt | null { | |
| 139 | + | const root = tree.find((s) => s.id === rootId) ?? tree.find((s) => s.parent_id == null); | |
| 140 | + | if (!root) return null; | |
| 141 | + | const children = new Map<string, AgentSession[]>(); | |
| 142 | + | for (const s of tree) { | |
| 143 | + | if (s.id === root.id || !s.parent_id) continue; | |
| 144 | + | children.set(s.parent_id, [...(children.get(s.parent_id) ?? []), s]); | |
| 145 | + | } | |
| 146 | + | const lines: ReceiptLine[] = []; | |
| 147 | + | const seen = new Set<string>(); | |
| 148 | + | const walk = (s: AgentSession, depth: number) => { | |
| 149 | + | if (seen.has(s.id)) return; | |
| 150 | + | seen.add(s.id); | |
| 151 | + | lines.push({ session: s, depth, ownMicros: s.charged_micros }); | |
| 152 | + | for (const kid of [...(children.get(s.id) ?? [])].sort((a, b) => a.created_at.localeCompare(b.created_at))) walk(kid, depth + 1); | |
| 153 | + | }; | |
| 154 | + | walk(root, 0); | |
| 155 | + | const others = lines.slice(1).reduce((n, l) => n + l.ownMicros, 0); | |
| 156 | + | lines[0]!.ownMicros = Math.max(0, root.charged_micros - others); | |
| 157 | + | const sum = (pick: (s: AgentSession) => number) => lines.reduce((n, l) => n + (pick(l.session) || 0), 0); | |
| 158 | + | return { | |
| 159 | + | root, | |
| 160 | + | lines, | |
| 161 | + | chargedMicros: root.charged_micros, | |
| 162 | + | providerMicros: sum((s) => s.cost_micros ?? 0), | |
| 163 | + | inputTokens: sum((s) => s.input_tokens), | |
| 164 | + | outputTokens: sum((s) => s.output_tokens), | |
| 165 | + | steps: sum((s) => s.steps), | |
| 166 | + | toolCalls: sum((s) => s.tool_calls), | |
| 167 | + | }; | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | /** A count of tokens for a receipt: "412K", "1.2M", "830". */ | |
| 171 | + | export function tokenCount(n: number): string { | |
| 172 | + | if (n >= 1_000_000) return `${(n / 1_000_000).toLocaleString("en-US", { maximumFractionDigits: 1 })}M`; | |
| 173 | + | if (n >= 10_000) return `${Math.round(n / 1000).toLocaleString("en-US")}K`; | |
| 174 | + | if (n >= 1000) return `${(n / 1000).toLocaleString("en-US", { maximumFractionDigits: 1 })}K`; | |
| 175 | + | return n.toLocaleString("en-US"); | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | /** How far through a budget: 0 to 1 (more when over), null with none. */ | |
| 179 | + | export function shareOfBudget(spent: number, budget: number | null | undefined): number | null { | |
| 180 | + | return budget != null && budget > 0 ? Math.max(0, spent) / budget : null; | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | /** "62%" of a budget, for a pill or a row; empty with none. */ | |
| 184 | + | export function percentLabel(spent: number, budget: number | null | undefined): string { | |
| 185 | + | const share = shareOfBudget(spent, budget); | |
| 186 | + | return share == null ? "" : `${Math.round(share * 100)}%`; | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | /** The slices a person's own view leaves out: the "who asked" one is only ever them. */ | |
| 190 | + | export function withoutSelf(slices: SpendSlice[], username: string): SpendSlice[] { | |
| 191 | + | return slices.filter((s) => s.key !== username); | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | /** What a level of budget does when it is reached, as the page says it. */ | |
| 195 | + | export const AT_LIMIT = { | |
| 196 | + | workspace: (pauses: boolean) => | |
| 197 | + | pauses | |
| 198 | + | ? "New work stops until the month turns or an owner raises it: agents, workflows, builds and deploys. Work already running finishes." | |
| 199 | + | : "Owners are alerted, and work goes on; g1t's own ceiling still applies.", | |
| 200 | + | agents: "No agent takes new work until the 1st, or until an owner raises it.", | |
| 201 | + | person: "Agents take no new work for that person until the 1st; they say so where they were asked.", | |
| 202 | + | agent: "That agent takes no new work until the 1st, or the next day for a daily cap.", | |
| 203 | + | session: "The session stops at Needs approval, and an owner decides whether it goes on.", | |
| 204 | + | } as const; |
| 150 | 150 | members: "people", | |
| 151 | 151 | teams: "people", | |
| 152 | 152 | workspace: "workspace", | |
| 153 | + | spend: "workspace", | |
| 153 | 154 | usage: "workspace", | |
| 154 | 155 | gateway: "workspace", | |
| 155 | 156 | billing: "workspace", |
| 119 | 119 | route("-/tokens/:id", "routes/workspace/token.tsx"), | |
| 120 | 120 | // Its rules for members' personal access tokens, and approving them. | |
| 121 | 121 | route("-/personal-access-tokens", "routes/workspace/personal-access-tokens.tsx"), | |
| 122 | + | // Spend, the front of its money: where it went, budgets, receipts; and the top bar's pill. | |
| 123 | + | route("-/spend", "routes/workspace/spend.tsx"), | |
| 124 | + | route("-/spend/pill", "routes/workspace/spend-pill.ts"), | |
| 125 | + | route("-/spend/receipts/:id", "routes/workspace/spend-receipt.tsx"), | |
| 122 | 126 | route("-/usage", "routes/workspace/usage.tsx"), | |
| 123 | 127 | route("-/gateway", "routes/workspace/gateway.tsx"), | |
| 124 | 128 | route("-/billing", "routes/workspace/billing.tsx"), |
| 1 | + | import { data } from "react-router"; | |
| 2 | + | ||
| 3 | + | import type { Route } from "./+types/spend-pill"; | |
| 4 | + | import { managesBilling, requireUser, roleIn } from "../../lib/session.server"; | |
| 5 | + | import { loadPill } from "../../lib/spend.server"; | |
| 6 | + | ||
| 7 | + | /** | |
| 8 | + | * The top bar's spend pill, read after the page draws (components/spend.tsx | |
| 9 | + | * `SpendPill`): the viewer's month, and the workspace's for owners and | |
| 10 | + | * billing managers. | |
| 11 | + | */ | |
| 12 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 13 | + | const viewer = requireUser(context, request); | |
| 14 | + | const slug = params.owner.toLowerCase(); | |
| 15 | + | if (!roleIn(viewer, slug)) throw data(null, { status: 404 }); | |
| 16 | + | return data(await loadPill(viewer, slug, managesBilling(viewer, slug)), { headers: { "Cache-Control": "private, no-store" } }); | |
| 17 | + | } |
| 1 | + | import { ArrowLeft, CornerDownRight } from "lucide-react"; | |
| 2 | + | import type { CSSProperties } from "react"; | |
| 3 | + | import { Link, data } from "react-router"; | |
| 4 | + | ||
| 5 | + | import type { Route } from "./+types/spend-receipt"; | |
| 6 | + | import { AgentAvatar } from "../../components/agent-avatar"; | |
| 7 | + | import { KindBadge, PrivateTitle, StatusChip, sessionHref } from "../../components/agents/parts"; | |
| 8 | + | import { whereLabel } from "../../components/agents/format"; | |
| 9 | + | import { TimeAgo } from "../../components/ui"; | |
| 10 | + | import { page } from "../../lib/meta"; | |
| 11 | + | import { requireUser, roleIn } from "../../lib/session.server"; | |
| 12 | + | import { loadPricing } from "../../lib/spend.server"; | |
| 13 | + | import { receiptOf, tokenCount } from "../../lib/spend"; | |
| 14 | + | import { workspaceAgents } from "../../lib/services.server"; | |
| 15 | + | import { money } from "../../lib/usage"; | |
| 16 | + | ||
| 17 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 18 | + | return page(args, { title: `Receipt · ${params.owner} · g1t` }); | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | /** A task's receipt: its session tree from the agents service, and the price book's terms. */ | |
| 22 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 23 | + | const viewer = requireUser(context, request); | |
| 24 | + | const slug = params.owner.toLowerCase(); | |
| 25 | + | if (!roleIn(viewer, slug)) throw data(null, { status: 404 }); | |
| 26 | + | const [detail, pricing] = await Promise.all([workspaceAgents.session(slug, params.id, viewer).catch(() => null), loadPricing()]); | |
| 27 | + | if (detail && !detail.ok && detail.error.code === "not_found") throw data(null, { status: 404 }); | |
| 28 | + | const receipt = detail?.ok ? receiptOf(detail.value.tree, detail.value.session.root_id) : null; | |
| 29 | + | return { slug, receipt, pricing }; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /** | |
| 33 | + | * One task, itemized: each session of its tree (the agent's own, and every | |
| 34 | + | * helper and subagent it brought in) with its tokens, the model at the | |
| 35 | + | * provider's price and what it was charged; then the totals. A session in | |
| 36 | + | * a conversation you aren't in shows what it cost, not what it was about. | |
| 37 | + | */ | |
| 38 | + | export default function Receipt({ loaderData }: Route.ComponentProps) { | |
| 39 | + | const { slug, receipt, pricing } = loaderData; | |
| 40 | + | const back = ( | |
| 41 | + | <Link to={`/${slug}/-/spend`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> | |
| 42 | + | <ArrowLeft size={14} /> Spend | |
| 43 | + | </Link> | |
| 44 | + | ); | |
| 45 | + | if (!receipt) { | |
| 46 | + | return ( | |
| 47 | + | <div className="mx-auto w-full max-w-215 space-y-6 px-4 py-5 md:px-10 md:py-8"> | |
| 48 | + | {back} | |
| 49 | + | <p className="rounded-xl border border-line bg-surface px-4 py-8 text-center text-sm text-muted">This receipt couldn't be read right now. Reload in a moment.</p> | |
| 50 | + | </div> | |
| 51 | + | ); | |
| 52 | + | } | |
| 53 | + | const { root } = receipt; | |
| 54 | + | const g1tPart = Math.max(0, receipt.chargedMicros - receipt.providerMicros); | |
| 55 | + | return ( | |
| 56 | + | <div className="mx-auto w-full max-w-215 space-y-6 px-4 py-5 md:px-10 md:py-8"> | |
| 57 | + | {back} | |
| 58 | + | <header className="flex items-start gap-3"> | |
| 59 | + | <AgentAvatar agent={{ handle: root.agent_handle, avatar_seed: root.agent_avatar_seed }} size={36} /> | |
| 60 | + | <div className="min-w-0 grow"> | |
| 61 | + | <p className="text-xs text-muted">Receipt</p> | |
| 62 | + | <h1 className="mt-0.5 text-xl leading-snug font-semibold tracking-tight">{root.visible ? root.title || "Untitled session" : <PrivateTitle />}</h1> | |
| 63 | + | <p className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-faint"> | |
| 64 | + | <span className="text-muted">{root.agent_name}</span> | |
| 65 | + | {root.visible && root.asked_by_username && <span>· for @{root.asked_by_username}</span>} | |
| 66 | + | {root.visible && <span>· {whereLabel(root)}</span>} | |
| 67 | + | <span>·</span> | |
| 68 | + | <TimeAgo at={root.created_at} /> | |
| 69 | + | <StatusChip status={root.status} className="ml-1" /> | |
| 70 | + | </p> | |
| 71 | + | </div> | |
| 72 | + | <div className="shrink-0 text-right"> | |
| 73 | + | <p className="text-xs text-muted">Total</p> | |
| 74 | + | <p className="text-2xl font-semibold tracking-tight tabular-nums">{money(receipt.chargedMicros)}</p> | |
| 75 | + | </div> | |
| 76 | + | </header> | |
| 77 | + | ||
| 78 | + | <section aria-label="Sessions" className="overflow-hidden rounded-xl border border-line bg-surface"> | |
| 79 | + | <div className="hidden grid-cols-[minmax(0,1fr)_9rem_6.5rem_5.5rem] gap-3 border-b border-line px-4 py-2 text-[0.6875rem] font-medium tracking-wide text-faint uppercase sm:grid"> | |
| 80 | + | <span>Session</span> | |
| 81 | + | <span className="text-right">Tokens</span> | |
| 82 | + | <span className="text-right">Provider price</span> | |
| 83 | + | <span className="text-right">Charged</span> | |
| 84 | + | </div> | |
| 85 | + | <ul className="divide-y divide-line/60"> | |
| 86 | + | {receipt.lines.map(({ session, depth, ownMicros }) => ( | |
| 87 | + | <li key={session.id} className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-1 px-4 py-3 sm:grid-cols-[minmax(0,1fr)_9rem_6.5rem_5.5rem] sm:items-center"> | |
| 88 | + | <div className="flex min-w-0 items-start gap-2" style={{ paddingLeft: `${depth * 1.25}rem` }}> | |
| 89 | + | {depth > 0 && <CornerDownRight size={13} className="mt-0.5 shrink-0 text-faint" aria-hidden="true" />} | |
| 90 | + | <div className="min-w-0"> | |
| 91 | + | <p className="flex min-w-0 flex-wrap items-center gap-1.5 text-sm"> | |
| 92 | + | {session.visible ? ( | |
| 93 | + | <Link to={sessionHref(slug, session)} className="min-w-0 truncate hover:underline"> | |
| 94 | + | {session.title || "Untitled session"} | |
| 95 | + | </Link> | |
| 96 | + | ) : ( | |
| 97 | + | <PrivateTitle /> | |
| 98 | + | )} | |
| 99 | + | <KindBadge kind={session.kind} subagent={session.subagent} /> | |
| 100 | + | </p> | |
| 101 | + | <p className="mt-0.5 truncate text-xs text-faint"> | |
| 102 | + | {session.agent_name} | |
| 103 | + | {session.model ? ` · ${session.model}` : ""} · {session.steps} {session.steps === 1 ? "step" : "steps"} · {session.tool_calls} {session.tool_calls === 1 ? "tool" : "tools"} | |
| 104 | + | </p> | |
| 105 | + | </div> | |
| 106 | + | </div> | |
| 107 | + | <span className="text-right text-sm tabular-nums sm:hidden">{money(ownMicros)}</span> | |
| 108 | + | <span className="col-span-2 text-xs text-faint tabular-nums sm:col-span-1 sm:text-right sm:text-sm sm:text-muted max-sm:pl-(--indent)" style={{ "--indent": `${depth * 1.25 + (depth ? 1.3 : 0)}rem` } as CSSProperties}> | |
| 109 | + | <span className="sm:hidden">Tokens </span> | |
| 110 | + | {tokenCount(session.input_tokens)} in · {tokenCount(session.output_tokens)} out | |
| 111 | + | </span> | |
| 112 | + | <span className="hidden text-right text-sm text-muted tabular-nums sm:block">{money(session.cost_micros ?? 0)}</span> | |
| 113 | + | <span className="hidden text-right text-sm tabular-nums sm:block">{money(ownMicros)}</span> | |
| 114 | + | </li> | |
| 115 | + | ))} | |
| 116 | + | </ul> | |
| 117 | + | </section> | |
| 118 | + | ||
| 119 | + | <section aria-label="Totals" className="rounded-xl border border-line bg-surface p-4"> | |
| 120 | + | <dl className="space-y-2 text-sm"> | |
| 121 | + | <Row | |
| 122 | + | label={receipt.chargedMicros < receipt.providerMicros ? "Models, billed by your own provider, not here" : "Models, at the provider's price"} | |
| 123 | + | value={money(receipt.providerMicros)} | |
| 124 | + | /> | |
| 125 | + | <Row | |
| 126 | + | label={ | |
| 127 | + | pricing?.agentRateMicros != null | |
| 128 | + | ? `g1t's agent rate, ${money(pricing.agentRateMicros)} per million tokens${pricing.modelMarkupPercent > 0 ? `, and the ${pricing.modelMarkupPercent}% model markup` : ""}` | |
| 129 | + | : "g1t's part" | |
| 130 | + | } | |
| 131 | + | value={money(g1tPart)} | |
| 132 | + | /> | |
| 133 | + | <div className="border-t border-line pt-2"> | |
| 134 | + | <Row label="Total, as budgets count it" value={money(receipt.chargedMicros)} strong /> | |
| 135 | + | </div> | |
| 136 | + | </dl> | |
| 137 | + | <p className="mt-3 text-xs text-faint"> | |
| 138 | + | {tokenCount(receipt.inputTokens)} tokens in and {tokenCount(receipt.outputTokens)} out over {receipt.steps} steps and {receipt.toolCalls} tool calls. On your | |
| 139 | + | own model key, your provider bills the model and only the agent rate is charged here; time on your own runners is $0. What the workspace is charged after | |
| 140 | + | included usage and credit is on Billing. | |
| 141 | + | </p> | |
| 142 | + | </section> | |
| 143 | + | </div> | |
| 144 | + | ); | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | function Row({ label, value, strong = false }: { label: string; value: string; strong?: boolean }) { | |
| 148 | + | return ( | |
| 149 | + | <div className="flex items-baseline justify-between gap-4"> | |
| 150 | + | <dt className={strong ? "font-medium" : "text-muted"}>{label}</dt> | |
| 151 | + | <dd className={`shrink-0 tabular-nums ${strong ? "font-semibold" : ""}`}>{value}</dd> | |
| 152 | + | </div> | |
| 153 | + | ); | |
| 154 | + | } |
| 1 | + | import { ArrowRight } from "lucide-react"; | |
| 2 | + | import { type ReactNode, Suspense } from "react"; | |
| 3 | + | import { Await, Link, data } from "react-router"; | |
| 4 | + | ||
| 5 | + | import type { AgentPolicy, AgentSpendBreakdown, SpendSlice, UsageReport } from "@g1t/contracts"; | |
| 6 | + | ||
| 7 | + | import type { Route } from "./+types/spend"; | |
| 8 | + | import { answer, agentsAction } from "../../components/agents/actions.server"; | |
| 9 | + | import { RowsSkeleton } from "../../components/today"; | |
| 10 | + | import { | |
| 11 | + | BudgetLadder, | |
| 12 | + | ComingNote, | |
| 13 | + | PricingCard, | |
| 14 | + | Section, | |
| 15 | + | SliceList, | |
| 16 | + | SlicePanel, | |
| 17 | + | type SliceKey, | |
| 18 | + | SLICES, | |
| 19 | + | SliceTabs, | |
| 20 | + | SpendBars, | |
| 21 | + | TaskList, | |
| 22 | + | Tile, | |
| 23 | + | TileSkeleton, | |
| 24 | + | } from "../../components/spend"; | |
| 25 | + | import { Meter } from "../../components/agents/parts"; | |
| 26 | + | import { Skeleton } from "../../components/ui/skeleton"; | |
| 27 | + | import { microsFromDollars } from "../../lib/agent-form"; | |
| 28 | + | import { cn } from "../../lib/cn"; | |
| 29 | + | import { page } from "../../lib/meta"; | |
| 30 | + | import { workspaceAgents } from "../../lib/services.server"; | |
| 31 | + | import { managesBilling, requireUser, roleIn } from "../../lib/session.server"; | |
| 32 | + | import { SPEND_PERIODS, type SpendScope, daySeries, periodLabel, readPeriod, readScope, spanFor, usageByDay, withoutSelf } from "../../lib/spend"; | |
| 33 | + | import { loadBreakdown, loadBudgets, loadPricing, loadUsage } from "../../lib/spend.server"; | |
| 34 | + | import { money } from "../../lib/usage"; | |
| 35 | + | ||
| 36 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 37 | + | return page(args, { title: `Spend · ${params.owner} · g1t` }); | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /** | |
| 41 | + | * Spend: the front of the workspace's money. What was spent over a | |
| 42 | + | * period, where it went (by agent, person, channel, model, kind of work and | |
| 43 | + | * product), the budgets from the workspace down to one task with what | |
| 44 | + | * happens at each, the costliest tasks with their receipts, and how it is | |
| 45 | + | * priced. Owners and billing managers see the workspace's, or their own; | |
| 46 | + | * everyone else sees their own: what agents did for them. Each part | |
| 47 | + | * streams in from its own service. Definitions: lib/spend.ts. | |
| 48 | + | */ | |
| 49 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 50 | + | const viewer = requireUser(context, request); | |
| 51 | + | const slug = params.owner.toLowerCase(); | |
| 52 | + | const role = roleIn(viewer, slug); | |
| 53 | + | if (!role) throw data(null, { status: 404 }); | |
| 54 | + | const url = new URL(request.url); | |
| 55 | + | const mayWorkspace = managesBilling(viewer, slug); | |
| 56 | + | const scope = readScope(url.searchParams.get("scope"), mayWorkspace); | |
| 57 | + | const period = readPeriod(url.searchParams.get("period")); | |
| 58 | + | const by = readSlice(url.searchParams.get("by"), scope); | |
| 59 | + | const now = new Date(); | |
| 60 | + | ||
| 61 | + | const breakdown = loadBreakdown(viewer, slug, scope, period); | |
| 62 | + | // The budgets are monthly: every agent's month, read once when the page is on it already. | |
| 63 | + | const month = scope === "workspace" && period === "month" ? breakdown : loadBreakdown(viewer, slug, "workspace", "month"); | |
| 64 | + | const usage = scope === "workspace" ? loadUsage(viewer, slug, period, now) : Promise.resolve(null); | |
| 65 | + | return { | |
| 66 | + | slug, | |
| 67 | + | me: viewer.username.toLowerCase(), | |
| 68 | + | owner: role === "owner", | |
| 69 | + | mayWorkspace, | |
| 70 | + | scope, | |
| 71 | + | period, | |
| 72 | + | by, | |
| 73 | + | span: spanFor(period, now), | |
| 74 | + | breakdown, | |
| 75 | + | usage, | |
| 76 | + | budgets: loadBudgets(viewer, slug, month), | |
| 77 | + | pricing: loadPricing(), | |
| 78 | + | }; | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | function readSlice(raw: string | null, scope: SpendScope): SliceKey { | |
| 82 | + | const found = SLICES.find((s) => s.key === raw && (scope === "workspace" || !s.workspaceOnly)); | |
| 83 | + | return found?.key ?? "agent"; | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /** Owners set the agent budgets: every agent together, each person, a new agent, a session; and one person's own. */ | |
| 87 | + | export async function action({ params, context, request }: Route.ActionArgs) { | |
| 88 | + | const { viewer, slug, isOwner, form } = await agentsAction(request, context, params.owner); | |
| 89 | + | if (!isOwner) return { ok: false as const, error: "Only the workspace's owners set budgets." }; | |
| 90 | + | const intent = form.get("intent"); | |
| 91 | + | if (intent === "policy") { | |
| 92 | + | const monthly = microsFromDollars(form.get("monthly")); | |
| 93 | + | const person = microsFromDollars(form.get("person")); | |
| 94 | + | const agent = microsFromDollars(form.get("default_agent")); | |
| 95 | + | const session = microsFromDollars(form.get("default_session")); | |
| 96 | + | if ([monthly, person, agent, session].some((v) => v != null && Number.isNaN(v))) return { ok: false as const, intent, error: "Write each amount in dollars, such as 250 or 2.50." }; | |
| 97 | + | const changes: Partial<AgentPolicy> = { monthly_micros: monthly, person_monthly_micros: person, default_agent_monthly_micros: agent }; | |
| 98 | + | if (session != null) changes.default_session_micros = session; | |
| 99 | + | return answer("policy", workspaceAgents.setPolicy(slug, viewer, changes)); | |
| 100 | + | } | |
| 101 | + | if (intent === "person") { | |
| 102 | + | const username = String(form.get("username") ?? "").trim(); | |
| 103 | + | const amount = microsFromDollars(form.get("amount")); | |
| 104 | + | if (amount != null && Number.isNaN(amount)) return { ok: false as const, intent, error: "Write the amount in dollars, such as 50, or 0 for no budget." }; | |
| 105 | + | return answer("person", workspaceAgents.setPersonBudget(slug, viewer, username, amount)); | |
| 106 | + | } | |
| 107 | + | return { ok: false as const, error: "Unknown request." }; | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | /** The page's address with some of its query changed. */ | |
| 111 | + | function hrefFor(slug: string, q: { scope: SpendScope; period: string; by: SliceKey }, change: Partial<{ scope: SpendScope; period: string; by: SliceKey }>): string { | |
| 112 | + | const next = { ...q, ...change }; | |
| 113 | + | const params = new URLSearchParams(); | |
| 114 | + | if (next.scope === "me") params.set("scope", "me"); | |
| 115 | + | if (next.period !== "month") params.set("period", next.period); | |
| 116 | + | if (next.by !== "agent") params.set("by", next.by); | |
| 117 | + | const query = params.toString(); | |
| 118 | + | return `/${slug}/-/spend${query ? `?${query}` : ""}`; | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | export default function SpendPage({ loaderData }: Route.ComponentProps) { | |
| 122 | + | const { slug, me, owner, mayWorkspace, scope, period, by, span, breakdown, usage, budgets, pricing } = loaderData; | |
| 123 | + | const q = { scope, period, by }; | |
| 124 | + | const href = (change: Partial<typeof q>) => hrefFor(slug, q, change); | |
| 125 | + | const label = periodLabel(period).toLowerCase(); | |
| 126 | + | return ( | |
| 127 | + | <div className="mx-auto w-full max-w-[1040px] space-y-8 px-4 py-5 md:px-10 md:py-8"> | |
| 128 | + | <header className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"> | |
| 129 | + | <div className="min-w-0"> | |
| 130 | + | <h1 className="text-[1.75rem] leading-tight font-semibold tracking-tight">Spend</h1> | |
| 131 | + | <p className="mt-1.5 max-w-2xl text-sm leading-relaxed text-muted"> | |
| 132 | + | {scope === "workspace" | |
| 133 | + | ? "What the workspace spent, where it went, and the budgets that hold it, from the workspace down to one task." | |
| 134 | + | : "What agents spent working for you: your chats with them and the sessions you started, against your budget."} | |
| 135 | + | </p> | |
| 136 | + | </div> | |
| 137 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 138 | + | {mayWorkspace && ( | |
| 139 | + | <div role="group" aria-label="Whose spend" className="inline-flex rounded-lg border border-line bg-surface p-0.5"> | |
| 140 | + | {(["workspace", "me"] as const).map((key) => ( | |
| 141 | + | <Link | |
| 142 | + | key={key} | |
| 143 | + | to={href({ scope: key, by: "agent" })} | |
| 144 | + | aria-current={scope === key ? "page" : undefined} | |
| 145 | + | className={cn("inline-flex h-7 items-center rounded-md px-3 text-[0.8125rem] transition-colors", scope === key ? "bg-raised text-fg ring-1 ring-line-strong" : "text-muted hover:text-fg")} | |
| 146 | + | > | |
| 147 | + | {key === "workspace" ? "Workspace" : "You"} | |
| 148 | + | </Link> | |
| 149 | + | ))} | |
| 150 | + | </div> | |
| 151 | + | )} | |
| 152 | + | <nav aria-label="Period" className="inline-flex rounded-lg border border-line bg-surface p-0.5"> | |
| 153 | + | {SPEND_PERIODS.map((p) => ( | |
| 154 | + | <Link | |
| 155 | + | key={p.key} | |
| 156 | + | to={href({ period: p.key })} | |
| 157 | + | preventScrollReset | |
| 158 | + | aria-current={period === p.key ? "page" : undefined} | |
| 159 | + | className={cn( | |
| 160 | + | "inline-flex h-7 items-center rounded-md px-2.5 text-[0.8125rem] whitespace-nowrap transition-colors", | |
| 161 | + | period === p.key ? "bg-raised text-fg ring-1 ring-line-strong" : "text-muted hover:text-fg", | |
| 162 | + | )} | |
| 163 | + | > | |
| 164 | + | {p.short} | |
| 165 | + | </Link> | |
| 166 | + | ))} | |
| 167 | + | </nav> | |
| 168 | + | </div> | |
| 169 | + | </header> | |
| 170 | + | ||
| 171 | + | {/* The figures at the top. */} | |
| 172 | + | <Suspense | |
| 173 | + | fallback={ | |
| 174 | + | <div className="grid gap-3 sm:grid-cols-3"> | |
| 175 | + | <TileSkeleton /> | |
| 176 | + | <TileSkeleton /> | |
| 177 | + | <TileSkeleton /> | |
| 178 | + | </div> | |
| 179 | + | } | |
| 180 | + | > | |
| 181 | + | <Await resolve={Promise.all([breakdown, usage, budgets])}> | |
| 182 | + | {([spent, used, levels]) => ( | |
| 183 | + | <div className="grid gap-3 sm:grid-cols-3"> | |
| 184 | + | {scope === "workspace" ? ( | |
| 185 | + | <> | |
| 186 | + | <Tile label={`Spent, ${label}`} value={used ? money(used.free ? used.totals.costMicros : used.totals.priceMicros) : "—"} sub={used ? (used.free ? "At cost: g1t charges nothing for now" : "Usage at price, every product") : "Usage couldn't be read"} /> | |
| 187 | + | <Tile label={`Agents, ${label}`} value={spent ? money(spent.total_micros) : "—"} sub={spent ? `${countOf(spent.by_kind)} replies and sessions` : "The agents service didn't answer"} /> | |
| 188 | + | <Tile | |
| 189 | + | label="Spend limit, this month" | |
| 190 | + | value={levels.limit?.spentMicros != null ? money(levels.limit.spentMicros) : "—"} | |
| 191 | + | sub={levels.limit ? limitLine(levels.limit.spentMicros ?? 0, levels.limit.spendLimitMicros ?? levels.limit.ceilingMicros) : "Billing couldn't be read"} | |
| 192 | + | > | |
| 193 | + | {levels.limit && (levels.limit.spendLimitMicros ?? levels.limit.ceilingMicros) != null && ( | |
| 194 | + | <Meter spent={levels.limit.spentMicros ?? 0} cap={levels.limit.spendLimitMicros ?? levels.limit.ceilingMicros} label="Charged of the spend limit" size="sm" className="mt-2.5" /> | |
| 195 | + | )} | |
| 196 | + | </Tile> | |
| 197 | + | </> | |
| 198 | + | ) : ( | |
| 199 | + | <MyTiles spent={spent} levels={levels} me={me} label={label} /> | |
| 200 | + | )} | |
| 201 | + | </div> | |
| 202 | + | )} | |
| 203 | + | </Await> | |
| 204 | + | </Suspense> | |
| 205 | + | ||
| 206 | + | {/* Spend by day. */} | |
| 207 | + | <Section id="by-day" title="By day" aside={`${periodLabel(period)}, UTC`}> | |
| 208 | + | <div className="rounded-xl border border-line bg-surface px-4 pt-4 pb-3"> | |
| 209 | + | <Suspense fallback={<Skeleton className="h-32 w-full" />}> | |
| 210 | + | <Await resolve={Promise.all([breakdown, usage])}> | |
| 211 | + | {([spent, used]) => { | |
| 212 | + | const days = scope === "workspace" && used ? usageByDay(used.days) : (spent?.days ?? null); | |
| 213 | + | return days ? <SpendBars days={daySeries(span.from, span.until, days)} /> : <p className="py-8 text-center text-sm text-muted">Spend by day couldn't be read right now.</p>; | |
| 214 | + | }} | |
| 215 | + | </Await> | |
| 216 | + | </Suspense> | |
| 217 | + | </div> | |
| 218 | + | </Section> | |
| 219 | + | ||
| 220 | + | {/* Where it went. */} | |
| 221 | + | <Section id="where" title="Where it went" aside={scope === "workspace" ? "Agent work by who and where; every product from usage" : "Agent work you asked for"}> | |
| 222 | + | <SliceTabs current={by} scope={scope} href={(key) => href({ by: key })} /> | |
| 223 | + | <Suspense | |
| 224 | + | fallback={ | |
| 225 | + | <SlicePanel> | |
| 226 | + | <RowsSkeleton /> | |
| 227 | + | </SlicePanel> | |
| 228 | + | } | |
| 229 | + | > | |
| 230 | + | <Await resolve={Promise.all([breakdown, usage])}>{([spent, used]) => <Slices by={by} spent={spent} used={used} slug={slug} me={me} scope={scope} />}</Await> | |
| 231 | + | </Suspense> | |
| 232 | + | </Section> | |
| 233 | + | ||
| 234 | + | {/* Budgets. */} | |
| 235 | + | <Section id="budgets" title="Budgets" aside="Monthly, UTC. Checked before work starts, widest first"> | |
| 236 | + | <Suspense | |
| 237 | + | fallback={ | |
| 238 | + | <SlicePanel> | |
| 239 | + | <RowsSkeleton rows={5} /> | |
| 240 | + | </SlicePanel> | |
| 241 | + | } | |
| 242 | + | > | |
| 243 | + | <Await resolve={budgets}>{(levels) => <BudgetLadder slug={slug} budgets={levels} owner={owner} me={me} action={`/${slug}/-/spend`} workspaceFigures={mayWorkspace} />}</Await> | |
| 244 | + | </Suspense> | |
| 245 | + | </Section> | |
| 246 | + | ||
| 247 | + | {/* Receipts. */} | |
| 248 | + | <Section id="tasks" title="Costliest tasks" aside="Open one for its receipt"> | |
| 249 | + | <SlicePanel> | |
| 250 | + | <Suspense fallback={<RowsSkeleton />}> | |
| 251 | + | <Await resolve={breakdown}>{(spent) => (spent ? <TaskList slug={slug} sessions={spent.top_sessions} /> : <p className="px-4 py-6 text-center text-sm text-muted">Tasks couldn't be read right now.</p>)}</Await> | |
| 252 | + | </Suspense> | |
| 253 | + | </SlicePanel> | |
| 254 | + | </Section> | |
| 255 | + | ||
| 256 | + | {/* Pricing. */} | |
| 257 | + | <Section | |
| 258 | + | id="pricing" | |
| 259 | + | title="How it's priced" | |
| 260 | + | aside={ | |
| 261 | + | <Link to={`/${slug}/-/usage`} className="inline-flex items-center gap-1 hover:text-fg"> | |
| 262 | + | Every meter on Usage <ArrowRight size={11} /> | |
| 263 | + | </Link> | |
| 264 | + | } | |
| 265 | + | > | |
| 266 | + | <Suspense fallback={<Skeleton className="h-36 w-full rounded-xl" />}> | |
| 267 | + | <Await resolve={pricing}>{(value) => <PricingCard pricing={value} />}</Await> | |
| 268 | + | </Suspense> | |
| 269 | + | </Section> | |
| 270 | + | ||
| 271 | + | <p className="text-xs text-faint"> | |
| 272 | + | Agent figures count every reply and session at list price, as budgets do. What the workspace is charged, after included usage, credit and any | |
| 273 | + | discount, is on{" "} | |
| 274 | + | <Link to={`/${slug}/-/billing`} className="hover:text-fg hover:underline"> | |
| 275 | + | Billing | |
| 276 | + | </Link> | |
| 277 | + | . | |
| 278 | + | </p> | |
| 279 | + | </div> | |
| 280 | + | ); | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | function countOf(slices: SpendSlice[]): string { | |
| 284 | + | return slices.reduce((n, s) => n + s.count, 0).toLocaleString("en-US"); | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | function limitLine(spent: number, limit: number | null): string { | |
| 288 | + | if (limit == null) return "No spend limit"; | |
| 289 | + | return `Charged, of ${money(limit)} · ${Math.round((spent / Math.max(1, limit)) * 100)}%`; | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | /** Your own figures: what agents spent for you, and your budget. */ | |
| 293 | + | function MyTiles({ spent, levels, me, label }: { spent: AgentSpendBreakdown | null; levels: Awaited<Route.ComponentProps["loaderData"]["budgets"]>; me: string; label: string }) { | |
| 294 | + | const people = levels.people; | |
| 295 | + | const mine = people?.people.find((p) => p.username === me) ?? null; | |
| 296 | + | const budget = mine ? mine.monthly_micros : (people?.default_micros ?? null); | |
| 297 | + | const month = mine?.spent_micros ?? null; | |
| 298 | + | const replies = spent?.by_kind.find((s) => s.key === "reply"); | |
| 299 | + | return ( | |
| 300 | + | <> | |
| 301 | + | <Tile label={`Agents for you, ${label}`} value={spent ? money(spent.total_micros) : "—"} sub={spent ? `${countOf(spent.by_kind)} replies and sessions you asked for` : "The agents service didn't answer"} /> | |
| 302 | + | <Tile label="Your budget, this month" value={budget != null ? money(budget) : "None"} sub={budget != null ? `${month != null ? money(month) : "—"} spent · ${mine?.own ? "set for you" : "the workspace's default"}` : "The workspace's limits apply"}> | |
| 303 | + | {budget != null && month != null && <Meter spent={month} cap={budget} label="Spent of your budget" size="sm" className="mt-2.5" />} | |
| 304 | + | </Tile> | |
| 305 | + | <Tile label={`Your chats with agents, ${label}`} value={replies ? money(replies.micros) : spent ? money(0) : "—"} sub={replies ? `${replies.count.toLocaleString("en-US")} replies` : "Talking costs only what the model answers"} /> | |
| 306 | + | </> | |
| 307 | + | ); | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | /** The breakdown for the slice chosen. */ | |
| 311 | + | function Slices({ by, spent, used, slug, me, scope }: { by: SliceKey; spent: AgentSpendBreakdown | null; used: UsageReport | null; slug: string; me: string; scope: SpendScope }) { | |
| 312 | + | if (by === "extension") { | |
| 313 | + | return ( | |
| 314 | + | <SlicePanel> | |
| 315 | + | <ComingNote title="Spend by extension">Once extensions can be installed, what each one runs and what agents do in it will show here, priced like everything else.</ComingNote> | |
| 316 | + | </SlicePanel> | |
| 317 | + | ); | |
| 318 | + | } | |
| 319 | + | if (by === "product") { | |
| 320 | + | if (!used) return <Unavailable what="Usage" />; | |
| 321 | + | const slices: SpendSlice[] = used.products.map((p) => ({ key: p.key, label: p.label, micros: p.micros, count: 0 })); | |
| 322 | + | return ( | |
| 323 | + | <SlicePanel foot="Every product, at price, from Usage. Code and Deployments are built in."> | |
| 324 | + | <SliceList slices={slices} empty="Nothing used in this period." href={() => `/${slug}/-/usage`} /> | |
| 325 | + | </SlicePanel> | |
| 326 | + | ); | |
| 327 | + | } | |
| 328 | + | if (!spent) return <Unavailable what="The agents service" />; | |
| 329 | + | const pick: Record<Exclude<SliceKey, "extension" | "product">, { slices: SpendSlice[]; unit: string; foot?: ReactNode; link?: (s: SpendSlice) => string | null }> = { | |
| 330 | + | agent: { | |
| 331 | + | slices: spent.by_agent, | |
| 332 | + | unit: "runs", | |
| 333 | + | foot: "A session's helpers and subagents count on the agent that started it.", | |
| 334 | + | link: (s) => { | |
| 335 | + | const handle = /\(@([^)]+)\)$/.exec(s.label)?.[1]; | |
| 336 | + | return handle ? `/${slug}/-/agents/${handle}/spend` : null; | |
| 337 | + | }, | |
| 338 | + | }, | |
| 339 | + | person: { slices: spent.by_person, unit: "runs", foot: "Who asked. Routines and agent-to-agent work have no asker." }, | |
| 340 | + | channel: { slices: spent.by_channel, unit: "runs", foot: "Where it was asked. Channels you aren't in show only what they cost." }, | |
| 341 | + | model: { slices: spent.by_model, unit: "runs" }, | |
| 342 | + | kind: { slices: spent.by_kind, unit: "" }, | |
| 343 | + | }; | |
| 344 | + | const chosen = pick[by]; | |
| 345 | + | const slices = scope === "me" && by === "person" ? withoutSelf(chosen.slices, me) : chosen.slices; | |
| 346 | + | return ( | |
| 347 | + | <SlicePanel foot={chosen.foot}> | |
| 348 | + | <SliceList slices={slices} unit={chosen.unit} empty="Nothing spent in this period." href={chosen.link} /> | |
| 349 | + | </SlicePanel> | |
| 350 | + | ); | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | function Unavailable({ what }: { what: string }) { | |
| 354 | + | return ( | |
| 355 | + | <SlicePanel> | |
| 356 | + | <p className="px-4 py-6 text-center text-sm text-muted">{what} didn't answer. Reload in a moment.</p> | |
| 357 | + | </SlicePanel> | |
| 358 | + | ); | |
| 359 | + | } | |
| 360 | + |
| 347 | 347 | tool_calls: number; | |
| 348 | 348 | input_tokens: number; | |
| 349 | 349 | output_tokens: number; | |
| 350 | − | /** At list price, what it counts against budgets. */ | |
| 350 | + | /** | |
| 351 | + | * At list price, what it counts against budgets: the model at the | |
| 352 | + | * provider's price with billing's model margin, plus g1t's agent rate on | |
| 353 | + | * every token. A root session's includes everything its tree spent. | |
| 354 | + | */ | |
| 351 | 355 | charged_micros: number; | |
| 356 | + | /** What its own steps' model answers cost at the provider's price, its tree's not included. */ | |
| 357 | + | cost_micros: number; | |
| 352 | 358 | /** The most it may spend before someone approves more. */ | |
| 353 | 359 | cap_micros: number | null; | |
| 354 | 360 | model: string | null; | |
| ⋯ | |||
| 514 | 520 | default_agent_monthly_micros: number | null; | |
| 515 | 521 | /** The cap one session starts with, unless its agent's per-task cap is lower. */ | |
| 516 | 522 | default_session_micros: number; | |
| 523 | + | /** | |
| 524 | + | * What the agents working for one person (their replies and sessions, | |
| 525 | + | * asked for by that person) may spend together in a month, unless the | |
| 526 | + | * person has a budget of their own. Null: no budget per person. | |
| 527 | + | */ | |
| 528 | + | person_monthly_micros: number | null; | |
| 529 | + | }; | |
| 530 | + | ||
| 531 | + | /** One person's budget: what agents working for them may spend in a month, and what they have. */ | |
| 532 | + | export type PersonBudget = { | |
| 533 | + | username: string; | |
| 534 | + | /** The budget that applies: their own, or the workspace's per-person default. Null: none. */ | |
| 535 | + | monthly_micros: number | null; | |
| 536 | + | /** Whether it is their own, set by an owner, rather than the default. */ | |
| 537 | + | own: boolean; | |
| 538 | + | /** What agents spent for them this month (UTC). */ | |
| 539 | + | spent_micros: number; | |
| 517 | 540 | }; | |
| 518 | 541 | ||
| 542 | + | /** Budgets per person: the default, and each person who has one of their own or has spent this month. */ | |
| 543 | + | export type PersonBudgets = { | |
| 544 | + | period: string; | |
| 545 | + | default_micros: number | null; | |
| 546 | + | /** Owners see everyone; anyone else sees only themselves. Most spent first. */ | |
| 547 | + | people: PersonBudget[]; | |
| 548 | + | }; | |
| 549 | + | ||
| 519 | 550 | export type SpendSlice = { key: string; label: string; micros: number; count: number }; | |
| 520 | 551 | ||
| 521 | − | /** Where an agent's (or every agent's) month went. */ | |
| 552 | + | /** Which days a breakdown covers: this month (the default), last month, or the last 7 or 30 days, in UTC. */ | |
| 553 | + | export type SpendPeriod = "month" | "last_month" | "7d" | "30d"; | |
| 554 | + | ||
| 555 | + | /** Where an agent's (or every agent's) spend went over a period. */ | |
| 522 | 556 | export type AgentSpendBreakdown = { | |
| 557 | + | /** `YYYY-MM` for a month; for a span of days, the month it ends in. */ | |
| 523 | 558 | period: string; | |
| 559 | + | /** The span asked for, and its first and last day (`YYYY-MM-DD`, both included). */ | |
| 560 | + | span: SpendPeriod; | |
| 561 | + | from: string; | |
| 562 | + | until: string; | |
| 563 | + | /** The one person it is about (work asked for by them), or null for everyone's. */ | |
| 564 | + | person: string | null; | |
| 524 | 565 | total_micros: number; | |
| 525 | 566 | /** Chat replies, sessions, routines, helping colleagues. */ | |
| 526 | 567 | by_kind: SpendSlice[]; | |
| ⋯ | |||
| 529 | 570 | by_person: SpendSlice[]; | |
| 530 | 571 | by_agent: SpendSlice[]; | |
| 531 | 572 | by_team: SpendSlice[]; | |
| 532 | − | /** The costliest sessions this month. */ | |
| 573 | + | /** | |
| 574 | + | * Where it was asked: a channel by id (labelled `#name`), direct | |
| 575 | + | * messages together (`dm`), and channels the viewer can't read together | |
| 576 | + | * (`private`). | |
| 577 | + | */ | |
| 578 | + | by_channel: SpendSlice[]; | |
| 579 | + | /** The costliest sessions in the period. */ | |
| 533 | 580 | top_sessions: AgentSession[]; | |
| 534 | − | /** Spend by day this month. */ | |
| 581 | + | /** Spend by day in the period. */ | |
| 535 | 582 | days: { day: string; micros: number }[]; | |
| 536 | 583 | }; | |
| 537 | 584 | ||
| ⋯ | |||
| 644 | 691 | deleteRoutine(workspace: string, handle: string, viewer: User, id: string): Promise<Result<null>>; | |
| 645 | 692 | /** Runs a routine now, as a session. */ | |
| 646 | 693 | runRoutine(workspace: string, handle: string, viewer: User, id: string): Promise<Result<AgentSession>>; | |
| 647 | − | /** Where the month went: one agent's, or every agent's. */ | |
| 648 | − | spend(workspace: string, viewer: User, handle?: string | null): Promise<Result<AgentSpendBreakdown>>; | |
| 694 | + | /** | |
| 695 | + | * Where the spend went: one agent's, or every agent's; this month unless | |
| 696 | + | * `period` says otherwise; for everyone, or only the work one `person` | |
| 697 | + | * (by username) asked for. | |
| 698 | + | */ | |
| 699 | + | spend(workspace: string, viewer: User, handle?: string | null, options?: { period?: SpendPeriod | null; person?: string | null }): Promise<Result<AgentSpendBreakdown>>; | |
| 700 | + | /** Budgets per person this month: owners see everyone's, anyone else their own. */ | |
| 701 | + | personBudgets(workspace: string, viewer: User): Promise<Result<PersonBudgets>>; | |
| 702 | + | /** | |
| 703 | + | * Gives one person a monthly budget of their own (`monthly_micros`; 0 for | |
| 704 | + | * no budget at all), or with null puts them back on the default. Owners only. | |
| 705 | + | */ | |
| 706 | + | setPersonBudget(workspace: string, viewer: User, username: string, monthlyMicros: number | null): Promise<Result<PersonBudgets>>; | |
| 649 | 707 | activity(workspace: string, handle: string, viewer: User): Promise<Result<AgentActivity[]>>; | |
| 650 | 708 | versions(workspace: string, handle: string, viewer: User): Promise<Result<AgentVersion[]>>; | |
| 651 | 709 | /** | |
| ⋯ | |||
| 695 | 753 | saveRoutine: (workspace, handle, viewer, input, id) => call("save_routine", { workspace, handle, viewer, input, id: id ?? null }), | |
| 696 | 754 | deleteRoutine: (workspace, handle, viewer, id) => call("delete_routine", { workspace, handle, viewer, id }), | |
| 697 | 755 | runRoutine: (workspace, handle, viewer, id) => call("run_routine", { workspace, handle, viewer, id }), | |
| 698 | − | spend: (workspace, viewer, handle) => call("spend", { workspace, viewer, handle: handle ?? null }), | |
| 756 | + | spend: (workspace, viewer, handle, options) => | |
| 757 | + | call("spend", { workspace, viewer, handle: handle ?? null, period: options?.period ?? null, person: options?.person ?? null }), | |
| 758 | + | personBudgets: (workspace, viewer) => call("person_budgets", { workspace, viewer }), | |
| 759 | + | setPersonBudget: (workspace, viewer, username, monthlyMicros) => | |
| 760 | + | call("set_person_budget", { workspace, viewer, username, monthly_micros: monthlyMicros }), | |
| 699 | 761 | activity: (workspace, handle, viewer) => call("activity", { workspace, handle, viewer }), | |
| 700 | 762 | versions: (workspace, handle, viewer) => call("versions", { workspace, handle, viewer }), | |
| 701 | 763 | cardAction: (input) => call("card_action", input), | |
| 1 | + | -- Budgets per person (docs.g1t.sh/guides/agent-budgets/): what the agents | |
| 2 | + | -- working for one person, the replies and sessions that person asked for, | |
| 3 | + | -- may spend together in a month. Budgets nest: the workspace's spend limit | |
| 4 | + | -- (billing), the workspace's agent budget, a person's, an agent's, a task's. | |
| 5 | + | ||
| 6 | + | -- The budget every person has unless they have their own. Null: none. | |
| 7 | + | ALTER TABLE agent_policies ADD COLUMN person_monthly_micros INTEGER; | |
| 8 | + | ||
| 9 | + | -- A person's own budget, set by an owner. 0: no budget at all, whatever | |
| 10 | + | -- the default. No row: the default. | |
| 11 | + | CREATE TABLE person_budgets ( | |
| 12 | + | workspace_id TEXT NOT NULL, | |
| 13 | + | username TEXT NOT NULL, | |
| 14 | + | monthly_micros INTEGER NOT NULL, | |
| 15 | + | updated_by TEXT NOT NULL, | |
| 16 | + | updated_at TEXT NOT NULL, | |
| 17 | + | PRIMARY KEY (workspace_id, username) | |
| 18 | + | ); | |
| 19 | + | ||
| 20 | + | -- What a person's agents spent is summed from the replies and sessions they | |
| 21 | + | -- asked for, so it counts from the first one, before any budget was set. | |
| 22 | + | CREATE INDEX agent_replies_asker ON agent_replies (workspace_id, asked_by_username, created_at); | |
| 23 | + | CREATE INDEX agent_sessions_asker ON agent_sessions (workspace_id, asked_by_username, created_at); |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { agentStatus, budgetBlock, chargedMicros, costMicros, dayKey, monthKey, replyCapMicros, totalTokens } from "./budget.ts"; | |
| 4 | + | import { agentStatus, budgetBlock, chargedMicros, costMicros, dayKey, monthKey, personBlock, personLimit, replyCapMicros, spendSpan, totalTokens } from "./budget.ts"; | |
| 5 | 5 | ||
| 6 | 6 | const OCT_8 = new Date("2026-10-08T15:00:00Z"); | |
| 7 | 7 | const none = { monthly_micros: null, daily_micros: null, task_micros: null }; | |
| ⋯ | |||
| 12 | 12 | assert.equal(dayKey(new Date("2026-10-31T23:59:59Z")), "2026-10-31"); | |
| 13 | 13 | }); | |
| 14 | 14 | ||
| 15 | + | test("a breakdown's span: this month by default, last month whole, or the last 7 or 30 days", () => { | |
| 16 | + | assert.deepEqual(spendSpan(null, OCT_8), { span: "month", from: "2026-10-01", until: "2026-10-08", period: "2026-10" }); | |
| 17 | + | assert.deepEqual(spendSpan("nonsense", OCT_8), spendSpan("month", OCT_8)); | |
| 18 | + | assert.deepEqual(spendSpan("last_month", OCT_8), { span: "last_month", from: "2026-09-01", until: "2026-09-30", period: "2026-09" }); | |
| 19 | + | assert.deepEqual(spendSpan("last_month", new Date("2026-01-15T00:00:00Z")), { span: "last_month", from: "2025-12-01", until: "2025-12-31", period: "2025-12" }); | |
| 20 | + | assert.deepEqual(spendSpan("7d", OCT_8), { span: "7d", from: "2026-10-02", until: "2026-10-08", period: "2026-10" }); | |
| 21 | + | assert.deepEqual(spendSpan("30d", OCT_8), { span: "30d", from: "2026-09-09", until: "2026-10-08", period: "2026-10" }); | |
| 22 | + | }); | |
| 23 | + | ||
| 24 | + | test("a person's own budget wins over the default; 0 of their own is no budget at all", () => { | |
| 25 | + | assert.equal(personLimit(null, null), null); | |
| 26 | + | assert.equal(personLimit(20_000_000, null), 20_000_000); | |
| 27 | + | assert.equal(personLimit(20_000_000, 50_000_000), 50_000_000); | |
| 28 | + | assert.equal(personLimit(20_000_000, 0), null); | |
| 29 | + | assert.equal(personLimit(0, null), null); | |
| 30 | + | }); | |
| 31 | + | ||
| 32 | + | test("a person's budget blocks at 100%, naming them and where it is raised", () => { | |
| 33 | + | assert.equal(personBlock("ana", null, 9e9, OCT_8), null); | |
| 34 | + | assert.equal(personBlock("ana", 10_000_000, 9_999_999, OCT_8), null); | |
| 35 | + | assert.equal( | |
| 36 | + | personBlock("ana", 10_000_000, 10_000_000, OCT_8), | |
| 37 | + | "@ana has used their agent budget for October. An owner can raise it under Workspace → Spend.", | |
| 38 | + | ); | |
| 39 | + | }); | |
| 40 | + | ||
| 15 | 41 | test("no caps, no block; a cap of zero is no cap", () => { | |
| 16 | 42 | assert.equal(budgetBlock(none, { month: 9e9, day: 9e9 }, OCT_8), null); | |
| 17 | 43 | assert.equal(budgetBlock({ ...none, monthly_micros: 0 }, { month: 9e9, day: 0 }, OCT_8), null); | |
| 4 | 4 | * | |
| 5 | 5 | * Spend is limited at several levels (docs.g1t.sh/guides/agent-budgets/). | |
| 6 | 6 | * The workspace's own limit and AI credit are billing's, checked by the | |
| 7 | − | * compute gate. The agent's monthly and daily caps are checked here, from | |
| 8 | − | * the spend this service rolls up per agent, before anything is reserved. | |
| 7 | + | * compute gate. The agent's monthly and daily caps, and the budget of the | |
| 8 | + | * person who asked, are checked here, before anything is reserved. | |
| 9 | 9 | * Spend counts what a reply costs at price: the model at the provider's | |
| 10 | 10 | * price with billing's model margin, plus g1t's agent rate on every token. | |
| 11 | 11 | * The ledger (with comped terms and discounts) is billing's; the agent's | |
| ⋯ | |||
| 68 | 68 | return caps.length ? Math.max(1, Math.floor(Math.min(...caps))) : null; | |
| 69 | 69 | } | |
| 70 | 70 | ||
| 71 | + | export type Span = { span: "month" | "last_month" | "7d" | "30d"; from: string; until: string; period: string }; | |
| 72 | + | ||
| 73 | + | /** | |
| 74 | + | * The days a spend breakdown covers, in UTC, both ends included: this | |
| 75 | + | * month to today (anything not asked for), last month whole, or the last | |
| 76 | + | * 7 or 30 days to today. `period` is the month it ends in. | |
| 77 | + | */ | |
| 78 | + | export function spendSpan(asked: unknown, now: Date): Span { | |
| 79 | + | const today = dayKey(now); | |
| 80 | + | const day = (offset: number) => dayKey(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + offset))); | |
| 81 | + | switch (asked) { | |
| 82 | + | case "last_month": { | |
| 83 | + | const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 0)); | |
| 84 | + | const until = dayKey(end); | |
| 85 | + | return { span: "last_month", from: `${until.slice(0, 7)}-01`, until, period: until.slice(0, 7) }; | |
| 86 | + | } | |
| 87 | + | case "7d": | |
| 88 | + | return { span: "7d", from: day(-6), until: today, period: monthKey(now) }; | |
| 89 | + | case "30d": | |
| 90 | + | return { span: "30d", from: day(-29), until: today, period: monthKey(now) }; | |
| 91 | + | default: | |
| 92 | + | return { span: "month", from: `${monthKey(now)}-01`, until: today, period: monthKey(now) }; | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | /** | |
| 97 | + | * The budget that applies to one person: their own when an owner set one | |
| 98 | + | * (0 there means none at all), else the workspace's per-person default. | |
| 99 | + | * Null: no budget. | |
| 100 | + | */ | |
| 101 | + | export function personLimit(defaultMicros: number | null | undefined, own: number | null | undefined): number | null { | |
| 102 | + | if (typeof own === "number" && Number.isFinite(own)) return own > 0 ? Math.floor(own) : null; | |
| 103 | + | return capSet(defaultMicros) ? Math.floor(defaultMicros) : null; | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /** | |
| 107 | + | * Why work asked for by `username` may not start: what agents spent for | |
| 108 | + | * them this month has reached their budget. Null when it may. | |
| 109 | + | */ | |
| 110 | + | export function personBlock(username: string, limit: number | null, spent: number, now: Date): string | null { | |
| 111 | + | if (!capSet(limit) || spent < limit) return null; | |
| 112 | + | return `@${username} has used their agent budget for ${MONTHS[now.getUTCMonth()]}. An owner can raise it under Workspace → Spend.`; | |
| 113 | + | } | |
| 114 | + | ||
| 71 | 115 | /** The tokens one answer used, by kind. */ | |
| 72 | 116 | export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }; | |
| 73 | 117 | ||
| 442 | 442 | case "run_routine": | |
| 443 | 443 | return Response.json(await service.view(args, (ctx) => views.runRoutineNow(ctx, args.handle, args.id))); | |
| 444 | 444 | case "spend": | |
| 445 | − | return Response.json(await service.view(args, (ctx) => views.spend(ctx, args.handle ?? null))); | |
| 445 | + | return Response.json(await service.view(args, (ctx) => views.spend(ctx, args.handle ?? null, { period: args.period, person: args.person }))); | |
| 446 | + | case "person_budgets": | |
| 447 | + | return Response.json(await service.view(args, (ctx) => views.personBudgetsView(ctx))); | |
| 448 | + | case "set_person_budget": | |
| 449 | + | return Response.json(await service.view(args, (ctx) => views.setPersonBudget(ctx, args.username, args.monthly_micros))); | |
| 446 | 450 | case "activity": | |
| 447 | 451 | return Response.json(await service.view(args, (ctx) => views.activity(ctx, args.handle))); | |
| 448 | 452 | case "versions": |
| 3 | 3 | * through, so g1t meters and bills an agent's model work one way | |
| 4 | 4 | * (docs.g1t.sh/guides/agent-budgets/). | |
| 5 | 5 | * | |
| 6 | − | * 1. The paying agent's own monthly and daily caps (`budget.ts`), and the | |
| 7 | − | * workspace's budget for all its agents together (`policy.ts`). | |
| 6 | + | * 1. The paying agent's own monthly and daily caps (`budget.ts`), the | |
| 7 | + | * workspace's budget for all its agents together (`policy.ts`), and the | |
| 8 | + | * budget of the person who asked (`person-budget.ts`). | |
| 8 | 9 | * 2. Whether the agent may use a model at all: g1t's hosted models are open | |
| 9 | 10 | * as the runner decides (`hostedOpen`, `HOSTED_AGENT_WORKSPACES` and | |
| 10 | 11 | * billing's status), or the workspace's own provider; the agent's | |
| ⋯ | |||
| 28 | 29 | ||
| 29 | 30 | import { hostedOpen } from "../../runner/src/hosted.ts"; | |
| 30 | 31 | import { type AgentRouting as Policy, routingReader } from "../../runner/src/model-env.ts"; | |
| 31 | − | import { type Spent, type Tokens, budgetBlock, chargedMicros, replyCapMicros, totalTokens } from "./budget.ts"; | |
| 32 | + | import { type Spent, type Tokens, budgetBlock, chargedMicros, personBlock, personLimit, replyCapMicros, totalTokens } from "./budget.ts"; | |
| 33 | + | import { monthStart, ownBudget, personSpent } from "./person-budget.ts"; | |
| 32 | 34 | import { BUILTIN_NO_MODEL } from "./orchestrator.ts"; | |
| 33 | 35 | import { type PolicyRow, alertDue, markAlerted, policyBlock, readPolicy, workspaceSpendStatements } from "./policy.ts"; | |
| 34 | 36 | import { dollars } from "./money.ts"; | |
| ⋯ | |||
| 154 | 156 | start: ModelTier; | |
| 155 | 157 | /** Who asked, by username, for billing's record. */ | |
| 156 | 158 | askerName: string | null; | |
| 159 | + | /** The person the work is for, by username: their budget counts it. Null for work no person asked for. */ | |
| 160 | + | person?: string | null; | |
| 157 | 161 | /** What is left of a session's cap, so one step never overruns it. */ | |
| 158 | 162 | leftMicros?: number | null; | |
| 159 | 163 | /** Agent tier limits narrower than the agent's own (a subagent's). */ | |
| ⋯ | |||
| 181 | 185 | // 1. The paying agent's caps, and the workspace's budget for every agent. | |
| 182 | 186 | const payerDefinition = definitionOf(payer); | |
| 183 | 187 | const [month, day] = periods(now); | |
| 184 | − | const [spentRows, policy] = await Promise.all([ | |
| 188 | + | const asker = input.person ? input.person.toLowerCase() : null; | |
| 189 | + | const [spentRows, policy, ownCap] = await Promise.all([ | |
| 185 | 190 | db | |
| 186 | 191 | .prepare("SELECT period, micros FROM agent_spend WHERE agent_id = ? AND period IN (?, ?)") | |
| 187 | 192 | .bind(payer.id, month, day) | |
| 188 | 193 | .all<{ period: string; micros: number }>(), | |
| 189 | 194 | readPolicy(db, row.workspace_id, month), | |
| 195 | + | asker ? ownBudget(db, row.workspace_id, asker) : Promise.resolve(null), | |
| 190 | 196 | ]); | |
| 191 | 197 | const spent: Spent = { | |
| 192 | 198 | month: spentRows.results.find((r) => r.period === month)?.micros ?? 0, | |
| ⋯ | |||
| 199 | 205 | } | |
| 200 | 206 | const pool = policyBlock(policy); | |
| 201 | 207 | if (pool) return { ok: false, reason: "workspace_agent_budget", message: pool }; | |
| 208 | + | // The budget of the person the work is for, summed only when one applies. | |
| 209 | + | const personCap = asker ? personLimit(policy.person_monthly_micros, ownCap) : null; | |
| 210 | + | const personUsed = asker && personCap != null ? await personSpent(db, row.workspace_id, asker, monthStart(now)) : 0; | |
| 211 | + | const personStop = asker ? personBlock(asker, personCap, personUsed, now) : null; | |
| 212 | + | if (personStop) return { ok: false, reason: "person_budget", message: personStop }; | |
| 202 | 213 | ||
| 203 | 214 | // 2. Whether it may use a model at all. | |
| 204 | 215 | const definition = definitionOf(row); | |
| ⋯ | |||
| 279 | 290 | if (input.leftMicros != null) caps.push(Math.max(1, Math.floor(input.leftMicros))); | |
| 280 | 291 | const left = policy.monthly_micros ? policy.monthly_micros - policy.spent : null; | |
| 281 | 292 | if (left != null) caps.push(Math.max(1, left)); | |
| 293 | + | if (personCap != null) caps.push(Math.max(1, personCap - personUsed)); | |
| 282 | 294 | const cap = caps.filter((c): c is number => c != null); | |
| 283 | 295 | if (cap.length) await integrations.capModelSessions([await sha256Hex(session.token)], Math.min(...cap)).catch(() => 0); | |
| 284 | 296 | ||
| 1 | + | /** | |
| 2 | + | * Budgets per person (docs.g1t.sh/guides/agent-budgets/): what the agents | |
| 3 | + | * working for one person, the replies and sessions that person asked for, | |
| 4 | + | * may spend together in a month. The default is the workspace policy's | |
| 5 | + | * `person_monthly_micros`; an owner can give anyone their own | |
| 6 | + | * (`person_budgets`, 0 for none at all). What a person's agents spent is | |
| 7 | + | * summed from the replies and the root sessions they asked for, the same | |
| 8 | + | * rows the spend breakdown reads, so the two always agree. | |
| 9 | + | */ | |
| 10 | + | import type { PersonBudget, PersonBudgets } from "@g1t/contracts"; | |
| 11 | + | ||
| 12 | + | import { monthKey, personLimit } from "./budget.ts"; | |
| 13 | + | ||
| 14 | + | /** The first instant of `now`'s month (UTC), as rows' `created_at` compare. */ | |
| 15 | + | export function monthStart(now: Date): string { | |
| 16 | + | return `${monthKey(now)}-01T00:00:00.000Z`; | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | /** What agents spent for `username` since `from`: their replies, and their sessions with everything each brought in. */ | |
| 20 | + | export async function personSpent(db: D1Database, workspaceId: string, username: string, from: string): Promise<number> { | |
| 21 | + | const row = await db | |
| 22 | + | .prepare( | |
| 23 | + | `SELECT | |
| 24 | + | (SELECT COALESCE(SUM(charged_micros), 0) FROM agent_replies WHERE workspace_id = ?1 AND asked_by_username = ?2 AND created_at >= ?3) | |
| 25 | + | + (SELECT COALESCE(SUM(charged_micros), 0) FROM agent_sessions WHERE workspace_id = ?1 AND asked_by_username = ?2 AND created_at >= ?3 AND parent_id IS NULL) | |
| 26 | + | AS micros`, | |
| 27 | + | ) | |
| 28 | + | .bind(workspaceId, username, from) | |
| 29 | + | .first<{ micros: number | null }>(); | |
| 30 | + | return row?.micros ?? 0; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /** A person's own budget, if an owner set one (0: none at all). */ | |
| 34 | + | export async function ownBudget(db: D1Database, workspaceId: string, username: string): Promise<number | null> { | |
| 35 | + | const row = await db | |
| 36 | + | .prepare("SELECT monthly_micros FROM person_budgets WHERE workspace_id = ? AND username = ?") | |
| 37 | + | .bind(workspaceId, username) | |
| 38 | + | .first<{ monthly_micros: number }>(); | |
| 39 | + | return row ? row.monthly_micros : null; | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /** | |
| 43 | + | * Everyone's budget this month: each person with one of their own, and | |
| 44 | + | * each person agents spent for. `only` narrows it to one person. | |
| 45 | + | */ | |
| 46 | + | export async function personBudgets(db: D1Database, workspaceId: string, defaultMicros: number | null, now: Date, only: string | null): Promise<PersonBudgets> { | |
| 47 | + | const from = monthStart(now); | |
| 48 | + | const narrow = only ? " AND asked_by_username = ?3" : ""; | |
| 49 | + | const binds = only ? [workspaceId, from, only] : [workspaceId, from]; | |
| 50 | + | const [own, spent] = await Promise.all([ | |
| 51 | + | db | |
| 52 | + | .prepare(`SELECT username, monthly_micros FROM person_budgets WHERE workspace_id = ?1${only ? " AND username = ?2" : ""}`) | |
| 53 | + | .bind(...(only ? [workspaceId, only] : [workspaceId])) | |
| 54 | + | .all<{ username: string; monthly_micros: number }>(), | |
| 55 | + | db | |
| 56 | + | .prepare( | |
| 57 | + | `SELECT asked_by_username AS username, SUM(micros) AS micros FROM ( | |
| 58 | + | SELECT asked_by_username, charged_micros AS micros FROM agent_replies WHERE workspace_id = ?1 AND created_at >= ?2 AND asked_by_username IS NOT NULL${narrow} | |
| 59 | + | UNION ALL SELECT asked_by_username, charged_micros AS micros FROM agent_sessions WHERE workspace_id = ?1 AND created_at >= ?2 AND parent_id IS NULL AND asked_by_username IS NOT NULL${narrow} | |
| 60 | + | ) GROUP BY asked_by_username`, | |
| 61 | + | ) | |
| 62 | + | .bind(...binds) | |
| 63 | + | .all<{ username: string; micros: number }>(), | |
| 64 | + | ]); | |
| 65 | + | const people = new Map<string, PersonBudget>(); | |
| 66 | + | const entry = (username: string) => { | |
| 67 | + | const found = people.get(username); | |
| 68 | + | if (found) return found; | |
| 69 | + | const fresh: PersonBudget = { username, monthly_micros: personLimit(defaultMicros, null), own: false, spent_micros: 0 }; | |
| 70 | + | people.set(username, fresh); | |
| 71 | + | return fresh; | |
| 72 | + | }; | |
| 73 | + | for (const row of own.results) { | |
| 74 | + | const person = entry(row.username); | |
| 75 | + | person.own = true; | |
| 76 | + | person.monthly_micros = personLimit(defaultMicros, row.monthly_micros); | |
| 77 | + | } | |
| 78 | + | for (const row of spent.results) entry(row.username).spent_micros = row.micros ?? 0; | |
| 79 | + | if (only) entry(only); | |
| 80 | + | return { | |
| 81 | + | period: monthKey(now), | |
| 82 | + | default_micros: personLimit(defaultMicros, null), | |
| 83 | + | people: [...people.values()].sort((a, b) => b.spent_micros - a.spent_micros || a.username.localeCompare(b.username)), | |
| 84 | + | }; | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | /** A username as people type it: `@Ana` is `ana`. Null when it can't be one. */ | |
| 88 | + | export function cleanUsername(raw: unknown): string | null { | |
| 89 | + | const name = String(raw ?? "").trim().replace(/^@/, "").toLowerCase(); | |
| 90 | + | return /^[a-z0-9][a-z0-9_-]{0,38}$/.test(name) ? name : null; | |
| 91 | + | } |
| 2 | 2 | * The workspace's say over all its agents together | |
| 3 | 3 | * (docs.g1t.sh/guides/agent-budgets/): one monthly budget across every | |
| 4 | 4 | * agent, the budget a new agent starts with, the cap a session starts with, | |
| 5 | − | * and alerts at 75, 90 and 100% of the monthly budget. Owners set it. Pure | |
| 5 | + | * the budget each person's agents share, and alerts at 75, 90 and 100% of | |
| 6 | + | * the monthly budget. Owners set it. Pure | |
| 6 | 7 | * apart from the statements it builds, so the rules are tested on their | |
| 7 | 8 | * own. | |
| 8 | 9 | */ | |
| ⋯ | |||
| 22 | 23 | alerted: number; | |
| 23 | 24 | }; | |
| 24 | 25 | ||
| 25 | − | export const DEFAULT_POLICY: AgentPolicy = { monthly_micros: null, default_agent_monthly_micros: null, default_session_micros: DEFAULT_SESSION_MICROS }; | |
| 26 | + | export const DEFAULT_POLICY: AgentPolicy = { | |
| 27 | + | monthly_micros: null, | |
| 28 | + | default_agent_monthly_micros: null, | |
| 29 | + | default_session_micros: DEFAULT_SESSION_MICROS, | |
| 30 | + | person_monthly_micros: null, | |
| 31 | + | }; | |
| 26 | 32 | ||
| 27 | 33 | /** The workspace's policy and this month's spend across its agents. */ | |
| 28 | 34 | export async function readPolicy(db: D1Database, workspaceId: string, month: string): Promise<PolicyRow> { | |
| 29 | 35 | const [policy, spend] = await Promise.all([ | |
| 30 | 36 | db | |
| 31 | − | .prepare("SELECT monthly_micros, default_agent_monthly_micros, default_session_micros FROM agent_policies WHERE workspace_id = ?") | |
| 37 | + | .prepare("SELECT monthly_micros, default_agent_monthly_micros, default_session_micros, person_monthly_micros FROM agent_policies WHERE workspace_id = ?") | |
| 32 | 38 | .bind(workspaceId) | |
| 33 | 39 | .first<AgentPolicy>(), | |
| 34 | 40 | db | |
| ⋯ | |||
| 40 | 46 | monthly_micros: positive(policy?.monthly_micros), | |
| 41 | 47 | default_agent_monthly_micros: positive(policy?.default_agent_monthly_micros), | |
| 42 | 48 | default_session_micros: positive(policy?.default_session_micros) ?? DEFAULT_SESSION_MICROS, | |
| 49 | + | person_monthly_micros: positive(policy?.person_monthly_micros), | |
| 43 | 50 | spent: spend?.micros ?? 0, | |
| 44 | 51 | alerted: spend?.alerted ?? 0, | |
| 45 | 52 | }; | |
| ⋯ | |||
| 92 | 99 | */ | |
| 93 | 100 | export function checkPolicy(current: AgentPolicy, changes: Partial<AgentPolicy>): { ok: true; value: AgentPolicy } | { ok: false; message: string } { | |
| 94 | 101 | const next = { ...current }; | |
| 95 | − | for (const key of ["monthly_micros", "default_agent_monthly_micros"] as const) { | |
| 102 | + | for (const key of ["monthly_micros", "default_agent_monthly_micros", "person_monthly_micros"] as const) { | |
| 96 | 103 | if (!(key in changes)) continue; | |
| 97 | 104 | const value = changes[key]; | |
| 98 | 105 | if (value === null || value === 0) next[key] = null; | |
| ⋯ | |||
| 108 | 115 | } | |
| 109 | 116 | return { ok: true, value: next }; | |
| 110 | 117 | } | |
| 118 | + | ||
| 354 | 354 | let posted: string | null = null; | |
| 355 | 355 | let spinOffs = 0; | |
| 356 | 356 | ||
| 357 | − | const done = await metered(env, { row, payer: row, slug, task: "reply", start, askerName }, async (model) => { | |
| 357 | + | const done = await metered(env, { row, payer: row, slug, task: "reply", start, askerName, person: delivery.asker?.username ?? null }, async (model) => { | |
| 358 | 358 | // What colleagues consulted along the way used: billed to this reply. | |
| 359 | 359 | const consulted = { tokens: NO_TOKENS, cost: 0 }; | |
| 360 | 360 | let toolbox: ToolBox | null = null; |
| 176 | 176 | input_tokens: row.input_tokens, | |
| 177 | 177 | output_tokens: row.output_tokens, | |
| 178 | 178 | charged_micros: row.charged_micros, | |
| 179 | + | cost_micros: row.cost_micros, | |
| 179 | 180 | cap_micros: row.cap_micros, | |
| 180 | 181 | model: row.model, | |
| 181 | 182 | outputs: visible ? json<SessionOutput[]>(row.outputs, []) : [], | |
| ⋯ | |||
| 740 | 741 | task: "session", | |
| 741 | 742 | start: startTier, | |
| 742 | 743 | askerName: row.asked_by_username, | |
| 744 | + | person: row.asked_by_username, | |
| 743 | 745 | leftMicros: row.cap_micros != null ? row.cap_micros - row.charged_micros : null, | |
| 744 | 746 | limits: subagent ? subagent.routing : null, | |
| 745 | 747 | }, | |
| 21 | 21 | type AgentsOverview, | |
| 22 | 22 | type AgentMemoryScope, | |
| 23 | 23 | type NewRoutine, | |
| 24 | + | type PersonBudgets, | |
| 24 | 25 | type Result, | |
| 25 | 26 | type RoutineSuggestion, | |
| 26 | 27 | type SessionEvent, | |
| ⋯ | |||
| 33 | 34 | ok, | |
| 34 | 35 | } from "@g1t/contracts"; | |
| 35 | 36 | ||
| 36 | − | import { monthKey } from "./budget.ts"; | |
| 37 | + | import { monthKey, spendSpan } from "./budget.ts"; | |
| 38 | + | import { cleanUsername, personBudgets } from "./person-budget.ts"; | |
| 37 | 39 | import { type MemoryRow, type MemoryViewer, changeableBy, cleanFact, toMemory, visibleTo } from "./memory.ts"; | |
| 38 | 40 | import { DEFAULT_POLICY, checkPolicy, readPolicy } from "./policy.ts"; | |
| 39 | 41 | import { type RoutineRow, MAX_ROUTINES, checkRoutine, newRoutineId, nextRun, runRoutine, toRoutine } from "./routines.ts"; | |
| ⋯ | |||
| 371 | 373 | .sort((a, b) => b.micros - a.micros); | |
| 372 | 374 | } | |
| 373 | 375 | ||
| 376 | + | /** The most channels named in a breakdown; the rest of the channels fold into one slice. */ | |
| 377 | + | const CHANNEL_SLICES = 12; | |
| 378 | + | ||
| 379 | + | /** | |
| 380 | + | * Spend by where it was asked: the costliest channels by name, if the | |
| 381 | + | * viewer can read them; direct messages together; channels they can't | |
| 382 | + | * read, and channels past the first few, folded together without names. | |
| 383 | + | */ | |
| 384 | + | async function channelSlices(ctx: ViewContext, rows: { key: string | null; label: string | null; micros: number; n: number }[]): Promise<SpendSlice[]> { | |
| 385 | + | const ranked = rows.filter((r) => r.micros > 0 || r.n > 0).sort((a, b) => b.micros - a.micros); | |
| 386 | + | const named = ranked.filter((r) => r.key && r.key !== "dm").slice(0, CHANNEL_SLICES); | |
| 387 | + | const can = await readable(ctx, named.map((r) => r.key!)); | |
| 388 | + | const out: SpendSlice[] = []; | |
| 389 | + | const hidden: SpendSlice = { key: "private", label: "Channels you're not in", micros: 0, count: 0 }; | |
| 390 | + | const rest: SpendSlice = { key: "other", label: "Other channels", micros: 0, count: 0 }; | |
| 391 | + | for (const row of ranked) { | |
| 392 | + | if (row.key === "dm") out.push({ key: "dm", label: "Direct messages", micros: row.micros, count: row.n }); | |
| 393 | + | else if (!named.includes(row)) (rest.micros += row.micros), (rest.count += row.n); | |
| 394 | + | else if (can.has(row.key!)) out.push({ key: row.key!, label: row.label ? `#${row.label}` : "A channel", micros: row.micros, count: row.n }); | |
| 395 | + | else (hidden.micros += row.micros), (hidden.count += row.n); | |
| 396 | + | } | |
| 397 | + | for (const folded of [hidden, rest]) if (folded.count > 0) out.push(folded); | |
| 398 | + | return out.sort((a, b) => b.micros - a.micros); | |
| 399 | + | } | |
| 400 | + | ||
| 374 | 401 | /** | |
| 375 | − | * Where the month went, for one agent (what it was paid for: its replies and | |
| 376 | − | * every session it paid for, colleagues' help included) or for every agent. | |
| 402 | + | * Budgets per person this month (person-budget.ts): owners see everyone's, | |
| 403 | + | * anyone else only their own. | |
| 377 | 404 | */ | |
| 378 | − | export async function spend(ctx: ViewContext, handle: string | null): Promise<Result<AgentSpendBreakdown>> { | |
| 405 | + | export async function personBudgetsView(ctx: ViewContext): Promise<Result<PersonBudgets>> { | |
| 379 | 406 | const now = new Date(); | |
| 380 | − | const month = monthKey(now); | |
| 381 | − | const from = `${month}-01T00:00:00.000Z`; | |
| 407 | + | const policyRow = await readPolicy(ctx.db, ctx.workspaceId, monthKey(now)); | |
| 408 | + | const only = ctx.owner ? null : ctx.viewer.kind === "workspace" ? null : ctx.viewer.username.toLowerCase(); | |
| 409 | + | return ok(await personBudgets(ctx.db, ctx.workspaceId, policyRow.person_monthly_micros, now, only)); | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | /** Gives a person a budget of their own (0: none at all), or with null puts them back on the default. Owners only. */ | |
| 413 | + | export async function setPersonBudget(ctx: ViewContext, username: unknown, monthly: unknown): Promise<Result<PersonBudgets>> { | |
| 414 | + | if (!ctx.owner) return fail("forbidden", "Only the workspace's owners set budgets for people."); | |
| 415 | + | const name = cleanUsername(username); | |
| 416 | + | if (!name) return fail("invalid", "That isn't a username."); | |
| 417 | + | if (monthly === null || monthly === undefined) { | |
| 418 | + | await ctx.db.prepare("DELETE FROM person_budgets WHERE workspace_id = ? AND username = ?").bind(ctx.workspaceId, name).run(); | |
| 419 | + | } else { | |
| 420 | + | const value = Number(monthly); | |
| 421 | + | if (!Number.isFinite(value) || value < 0 || value > 1_000_000_000_000) return fail("invalid", "A budget is a positive amount, or none."); | |
| 422 | + | await ctx.db | |
| 423 | + | .prepare( | |
| 424 | + | `INSERT INTO person_budgets (workspace_id, username, monthly_micros, updated_by, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) | |
| 425 | + | ON CONFLICT (workspace_id, username) DO UPDATE SET monthly_micros = ?3, updated_by = ?4, updated_at = ?5`, | |
| 426 | + | ) | |
| 427 | + | .bind(ctx.workspaceId, name, Math.floor(value), ctx.viewer.username, new Date().toISOString()) | |
| 428 | + | .run(); | |
| 429 | + | } | |
| 430 | + | return personBudgetsView(ctx); | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | /** | |
| 434 | + | * Where the spend went over a span of days (this month unless asked), for | |
| 435 | + | * one agent (what it was paid for: its replies and every session it paid | |
| 436 | + | * for, colleagues' help included) or for every agent; for everyone, or only | |
| 437 | + | * the work one person asked for. | |
| 438 | + | */ | |
| 439 | + | export async function spend(ctx: ViewContext, handle: string | null, options: { period?: unknown; person?: unknown } = {}): Promise<Result<AgentSpendBreakdown>> { | |
| 440 | + | const now = new Date(); | |
| 441 | + | const span = spendSpan(options.period, now); | |
| 442 | + | const from = `${span.from}T00:00:00.000Z`; | |
| 443 | + | const end = new Date(`${span.until}T00:00:00.000Z`); | |
| 444 | + | end.setUTCDate(end.getUTCDate() + 1); | |
| 445 | + | const to = end.toISOString(); | |
| 446 | + | const person = options.person == null || options.person === "" ? null : cleanUsername(options.person); | |
| 447 | + | if (options.person != null && options.person !== "" && !person) return fail("invalid", "That isn't a username."); | |
| 382 | 448 | let agentId: string | null = null; | |
| 383 | 449 | if (handle) { | |
| 384 | 450 | const agent = await agentByHandle(ctx, handle); | |
| ⋯ | |||
| 386 | 452 | agentId = agent.id; | |
| 387 | 453 | } | |
| 388 | 454 | const db = ctx.db; | |
| 389 | − | const rFilter = agentId ? "agent_id = ?2" : "workspace_id = ?2"; | |
| 390 | − | const sFilter = agentId ? "payer_agent_id = ?2" : "workspace_id = ?2"; | |
| 391 | − | const scope = agentId ?? ctx.workspaceId; | |
| 392 | − | const union = `SELECT 'reply' AS kind, agent_id AS agent, asked_by_username AS person, model, charged_micros AS micros, substr(created_at, 1, 10) AS day FROM agent_replies WHERE ${rFilter} AND created_at >= ?1 | |
| 393 | − | UNION ALL SELECT kind, payer_agent_id AS agent, asked_by_username AS person, model, charged_micros AS micros, substr(created_at, 1, 10) AS day FROM agent_sessions WHERE ${sFilter} AND created_at >= ?1 AND parent_id IS NULL | |
| 394 | − | UNION ALL SELECT kind, payer_agent_id AS agent, asked_by_username AS person, model, 0 AS micros, substr(created_at, 1, 10) AS day FROM agent_sessions WHERE ${sFilter} AND created_at >= ?1 AND parent_id IS NOT NULL`; | |
| 455 | + | const who = person ? " AND asked_by_username = ?4" : ""; | |
| 456 | + | const rFilter = `${agentId ? "agent_id = ?2" : "workspace_id = ?2"} AND created_at >= ?1 AND created_at < ?3${who}`; | |
| 457 | + | const sFilter = `${agentId ? "payer_agent_id = ?2" : "workspace_id = ?2"} AND created_at >= ?1 AND created_at < ?3${who}`; | |
| 458 | + | const binds: string[] = [from, agentId ?? ctx.workspaceId, to, ...(person ? [person] : [])]; | |
| 459 | + | // Where it was asked: a channel by id; direct messages (a reply's channel has no name) together. | |
| 460 | + | const union = `SELECT 'reply' AS kind, agent_id AS agent, asked_by_username AS person, model, charged_micros AS micros, substr(created_at, 1, 10) AS day, | |
| 461 | + | CASE WHEN channel_name IS NULL THEN 'dm' ELSE channel_id END AS channel, channel_name AS channel_label FROM agent_replies WHERE ${rFilter} | |
| 462 | + | UNION ALL SELECT kind, payer_agent_id AS agent, asked_by_username AS person, model, charged_micros AS micros, substr(created_at, 1, 10) AS day, | |
| 463 | + | CASE WHEN channel_kind = 'dm' THEN 'dm' ELSE channel_id END AS channel, channel_name AS channel_label FROM agent_sessions WHERE ${sFilter} AND parent_id IS NULL | |
| 464 | + | UNION ALL SELECT kind, payer_agent_id AS agent, asked_by_username AS person, model, 0 AS micros, substr(created_at, 1, 10) AS day, | |
| 465 | + | CASE WHEN channel_kind = 'dm' THEN 'dm' ELSE channel_id END AS channel, channel_name AS channel_label FROM agent_sessions WHERE ${sFilter} AND parent_id IS NOT NULL`; | |
| 395 | 466 | // A child's spend is already counted on its root (sessions.ts), so children add counts, not money. | |
| 396 | 467 | const group = (column: string) => | |
| 397 | − | db.prepare(`SELECT ${column} AS key, COALESCE(SUM(micros), 0) AS micros, COUNT(*) AS n FROM (${union}) GROUP BY ${column}`).bind(from, scope).all<{ key: string | null; micros: number; n: number }>(); | |
| 398 | − | const [byKind, byModel, byPerson, byAgent, byDay, top, agents] = await Promise.all([ | |
| 468 | + | db.prepare(`SELECT ${column} AS key, COALESCE(SUM(micros), 0) AS micros, COUNT(*) AS n FROM (${union}) GROUP BY ${column}`).bind(...binds).all<{ key: string | null; micros: number; n: number }>(); | |
| 469 | + | const [byKind, byModel, byPerson, byAgent, byDay, byChannel, top, agents] = await Promise.all([ | |
| 399 | 470 | group("kind"), | |
| 400 | 471 | group("model"), | |
| 401 | 472 | group("person"), | |
| 402 | 473 | group("agent"), | |
| 403 | 474 | group("day"), | |
| 404 | − | db.prepare(`SELECT * FROM agent_sessions WHERE ${sFilter} AND created_at >= ?1 AND parent_id IS NULL ORDER BY charged_micros DESC LIMIT 8`).bind(from, scope).all<SessionRow>(), | |
| 475 | + | db | |
| 476 | + | .prepare(`SELECT channel AS key, MAX(channel_label) AS label, COALESCE(SUM(micros), 0) AS micros, COUNT(*) AS n FROM (${union}) GROUP BY channel`) | |
| 477 | + | .bind(...binds) | |
| 478 | + | .all<{ key: string | null; label: string | null; micros: number; n: number }>(), | |
| 479 | + | db.prepare(`SELECT * FROM agent_sessions WHERE ${sFilter} AND parent_id IS NULL ORDER BY charged_micros DESC LIMIT 8`).bind(...binds).all<SessionRow>(), | |
| 405 | 480 | faces(ctx), | |
| 406 | 481 | ]); | |
| 482 | + | const channels = await channelSlices(ctx, byChannel.results); | |
| 407 | 483 | const byTeamMap = new Map<string, SpendSlice>(); | |
| 408 | 484 | for (const row of byAgent.results) { | |
| 409 | 485 | const face = agents.get(row.key ?? ""); | |
| ⋯ | |||
| 415 | 491 | } | |
| 416 | 492 | const total = byKind.results.reduce((n, r) => n + r.micros, 0); | |
| 417 | 493 | return ok({ | |
| 418 | − | period: month, | |
| 494 | + | period: span.period, | |
| 495 | + | span: span.span, | |
| 496 | + | from: span.from, | |
| 497 | + | until: span.until, | |
| 498 | + | person, | |
| 419 | 499 | total_micros: total, | |
| 420 | 500 | by_kind: slices(byKind.results, (k) => KIND_LABELS[k] ?? k), | |
| 421 | 501 | by_model: slices(byModel.results, (k) => k || "No model"), | |
| ⋯ | |||
| 425 | 505 | return face ? `${face.display_name} (@${face.handle})` : "An archived agent"; | |
| 426 | 506 | }), | |
| 427 | 507 | by_team: [...byTeamMap.values()].sort((a, b) => b.micros - a.micros), | |
| 508 | + | by_channel: channels, | |
| 428 | 509 | top_sessions: await sessionsOut(ctx, top.results, agents), | |
| 429 | 510 | days: byDay.results.map((r) => ({ day: r.key ?? "", micros: r.micros })).sort((a, b) => a.day.localeCompare(b.day)), | |
| 430 | 511 | }); | |
| ⋯ | |||
| 510 | 591 | ||
| 511 | 592 | export async function policy(ctx: ViewContext): Promise<Result<AgentPolicy>> { | |
| 512 | 593 | const row = await readPolicy(ctx.db, ctx.workspaceId, monthKey(new Date())); | |
| 513 | − | return ok({ monthly_micros: row.monthly_micros, default_agent_monthly_micros: row.default_agent_monthly_micros, default_session_micros: row.default_session_micros }); | |
| 594 | + | return ok(policyOf(row)); | |
| 595 | + | } | |
| 596 | + | ||
| 597 | + | function policyOf(row: AgentPolicy): AgentPolicy { | |
| 598 | + | return { | |
| 599 | + | monthly_micros: row.monthly_micros, | |
| 600 | + | default_agent_monthly_micros: row.default_agent_monthly_micros, | |
| 601 | + | default_session_micros: row.default_session_micros, | |
| 602 | + | person_monthly_micros: row.person_monthly_micros, | |
| 603 | + | }; | |
| 514 | 604 | } | |
| 515 | 605 | ||
| 516 | 606 | export async function setPolicy(ctx: ViewContext, changes: Partial<AgentPolicy>): Promise<Result<AgentPolicy>> { | |
| ⋯ | |||
| 521 | 611 | const p = checked.value; | |
| 522 | 612 | await ctx.db | |
| 523 | 613 | .prepare( | |
| 524 | − | `INSERT INTO agent_policies (workspace_id, monthly_micros, default_agent_monthly_micros, default_session_micros, updated_by, updated_at) | |
| 525 | − | VALUES (?1, ?2, ?3, ?4, ?5, ?6) | |
| 526 | − | ON CONFLICT (workspace_id) DO UPDATE SET monthly_micros = ?2, default_agent_monthly_micros = ?3, default_session_micros = ?4, updated_by = ?5, updated_at = ?6`, | |
| 614 | + | `INSERT INTO agent_policies (workspace_id, monthly_micros, default_agent_monthly_micros, default_session_micros, person_monthly_micros, updated_by, updated_at) | |
| 615 | + | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) | |
| 616 | + | ON CONFLICT (workspace_id) DO UPDATE SET monthly_micros = ?2, default_agent_monthly_micros = ?3, default_session_micros = ?4, person_monthly_micros = ?5, updated_by = ?6, updated_at = ?7`, | |
| 527 | 617 | ) | |
| 528 | − | .bind(ctx.workspaceId, p.monthly_micros, p.default_agent_monthly_micros, p.default_session_micros, ctx.viewer.username, new Date().toISOString()) | |
| 618 | + | .bind(ctx.workspaceId, p.monthly_micros, p.default_agent_monthly_micros, p.default_session_micros, p.person_monthly_micros, ctx.viewer.username, new Date().toISOString()) | |
| 529 | 619 | .run(); | |
| 530 | 620 | return ok(p); | |
| 531 | 621 | } | |
| ⋯ | |||
| 554 | 644 | for (const s of live.results) liveByAgent[s.agent_id] = (liveByAgent[s.agent_id] ?? 0) + 1; | |
| 555 | 645 | const level = policyRow.monthly_micros ? [100, 90, 75].find((l) => (policyRow.spent * 100) / policyRow.monthly_micros! >= l) ?? null : null; | |
| 556 | 646 | return ok({ | |
| 557 | − | policy: { monthly_micros: policyRow.monthly_micros, default_agent_monthly_micros: policyRow.default_agent_monthly_micros, default_session_micros: policyRow.default_session_micros }, | |
| 647 | + | policy: policyOf(policyRow), | |
| 558 | 648 | spent_month_micros: policyRow.spent, | |
| 559 | 649 | alert: level, | |
| 560 | 650 | agents, | |
| ⋯ | |||
| 566 | 656 | const face = agentFaces.get(r.agent_id); | |
| 567 | 657 | return { ...toRoutine(r), agent_handle: face?.handle ?? "agent", agent_name: face?.display_name ?? "An agent" }; | |
| 568 | 658 | }), | |
| 569 | − | spend: breakdown.ok ? breakdown.value : { period: monthKey(now), total_micros: 0, by_kind: [], by_model: [], by_person: [], by_agent: [], by_team: [], top_sessions: [], days: [] }, | |
| 659 | + | spend: breakdown.ok ? breakdown.value : emptySpend(now), | |
| 570 | 660 | can_manage: ctx.owner, | |
| 571 | 661 | }); | |
| 572 | 662 | } | |
| 663 | + | ||
| 664 | + | /** A breakdown with nothing in it, for this month. */ | |
| 665 | + | function emptySpend(now: Date): AgentSpendBreakdown { | |
| 666 | + | const span = spendSpan("month", now); | |
| 667 | + | return { ...span, person: null, total_micros: 0, by_kind: [], by_model: [], by_person: [], by_agent: [], by_team: [], by_channel: [], top_sessions: [], days: [] }; | |
| 668 | + | } | |