diff options
| author | Claude <noreply@anthropic.com> | 2026-05-26 20:34:09 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-05-26 20:34:09 +0000 |
| commit | ab4b364954af08fa602388495ca425eaef0abf74 (patch) | |
| tree | c0806a57295270b4d86f097c3d0b6a0e1d631201 | |
| parent | 32715355fe2eed321df4f7083dfe580d35f8a62a (diff) | |
feat(api,web): budget headroom endpoint + UI chips (Phase 6)
Adds GET /api/budget returning per-provider rolling-window headroom (empty when
budget gating is unconfigured), wired from serve.go via SetBudget. The web UI
polls it and renders a chip per limited provider in the header, flagging any
under 20% remaining. New pure JS helpers formatBudgetHeadroom/
renderBudgetHeadroom are unit-tested; the endpoint is covered by Go handler
tests (empty/reports/500). UI render not browser-verified in this environment.
Completes Phase 6: spend accounting + dispatcher gating + observability surface.
https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39
| -rw-r--r-- | internal/api/budget.go | 36 | ||||
| -rw-r--r-- | internal/api/budget_test.go | 68 | ||||
| -rw-r--r-- | internal/api/server.go | 2 | ||||
| -rw-r--r-- | internal/cli/serve.go | 3 | ||||
| -rw-r--r-- | web/app.js | 48 | ||||
| -rw-r--r-- | web/index.html | 1 | ||||
| -rw-r--r-- | web/style.css | 21 | ||||
| -rw-r--r-- | web/test/budget.test.mjs | 58 |
8 files changed, 237 insertions, 0 deletions
diff --git a/internal/api/budget.go b/internal/api/budget.go new file mode 100644 index 0000000..eee5d09 --- /dev/null +++ b/internal/api/budget.go @@ -0,0 +1,36 @@ +package api + +import ( + "net/http" + + "github.com/thepeterstone/claudomator/internal/budget" +) + +// budgetReporter exposes per-provider spend headroom. Satisfied by +// *budget.Accountant; an interface keeps the handler testable. +type budgetReporter interface { + All() ([]budget.Headroom, error) +} + +// SetBudget wires the budget accountant so GET /api/budget can report headroom. +func (s *Server) SetBudget(b budgetReporter) { + s.budget = b +} + +// handleGetBudget returns per-provider rolling-window spend headroom. When no +// budget is configured it returns an empty list so the UI simply shows nothing. +func (s *Server) handleGetBudget(w http.ResponseWriter, _ *http.Request) { + if s.budget == nil { + writeJSON(w, http.StatusOK, []budget.Headroom{}) + return + } + hs, err := s.budget.All() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if hs == nil { + hs = []budget.Headroom{} + } + writeJSON(w, http.StatusOK, hs) +} diff --git a/internal/api/budget_test.go b/internal/api/budget_test.go new file mode 100644 index 0000000..d59836d --- /dev/null +++ b/internal/api/budget_test.go @@ -0,0 +1,68 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/thepeterstone/claudomator/internal/budget" +) + +type fakeBudget struct { + hs []budget.Headroom + err error +} + +func (f fakeBudget) All() ([]budget.Headroom, error) { return f.hs, f.err } + +func TestHandleGetBudget_NoBudgetConfigured_ReturnsEmpty(t *testing.T) { + srv, _ := testServer(t) + req := httptest.NewRequest("GET", "/api/budget", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d", w.Code) + } + var hs []budget.Headroom + if err := json.Unmarshal(w.Body.Bytes(), &hs); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(hs) != 0 { + t.Errorf("want empty list, got %+v", hs) + } +} + +func TestHandleGetBudget_ReportsHeadroom(t *testing.T) { + srv, _ := testServer(t) + srv.SetBudget(fakeBudget{hs: []budget.Headroom{ + {Provider: "claude", Limited: true, Limit: 10, Spent: 4, Remaining: 6, Fraction: 0.6}, + }}) + req := httptest.NewRequest("GET", "/api/budget", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d", w.Code) + } + var hs []budget.Headroom + if err := json.Unmarshal(w.Body.Bytes(), &hs); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(hs) != 1 || hs[0].Provider != "claude" || hs[0].Remaining != 6 { + t.Errorf("unexpected headroom: %+v", hs) + } +} + +func TestHandleGetBudget_ErrorReturns500(t *testing.T) { + srv, _ := testServer(t) + srv.SetBudget(fakeBudget{err: errors.New("db down")}) + req := httptest.NewRequest("GET", "/api/budget", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusInternalServerError { + t.Errorf("want 500, got %d", w.Code) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index a4b7ea1..ffa8cd4 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -61,6 +61,7 @@ type Server struct { pushStore pushSubscriptionStore dropsDir string llm *llm.Client + budget budgetReporter // optional; per-provider spend headroom for GET /api/budget } // SetAPIToken configures a bearer token that must be supplied to access the API. @@ -147,6 +148,7 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /api/tasks/{id}/events", s.handleListTaskEvents) s.mux.HandleFunc("GET /api/executions", s.handleListRecentExecutions) s.mux.HandleFunc("GET /api/stats", s.handleGetDashboardStats) + s.mux.HandleFunc("GET /api/budget", s.handleGetBudget) s.mux.HandleFunc("GET /api/agents/status", s.handleGetAgentStatus) s.mux.HandleFunc("GET /api/executions/{id}", s.handleGetExecution) s.mux.HandleFunc("GET /api/executions/{id}/log", s.handleGetExecutionLog) diff --git a/internal/cli/serve.go b/internal/cli/serve.go index 55fdaf5..7afa678 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -171,6 +171,9 @@ func serve(addr string) error { pool.RecoverStaleBlocked() srv := api.NewServer(store, pool, agentRegistry, logger, cfg.ClaudeBinaryPath, cfg.GeminiBinaryPath) + if accountant != nil { + srv.SetBudget(accountant) + } // Configure notifiers: combine webhook (if set) with web push. notifiers := []notify.Notifier{} @@ -198,6 +198,53 @@ export async function fetchTaskEvents(taskId, fetchImpl = (typeof fetch !== 'und } } +// ── Budget headroom ───────────────────────────────────────────────────────── +// Renders the per-provider rolling-window spend headroom from GET /api/budget. + +// formatBudgetHeadroom turns one provider's headroom into a short label. +// Returns '' for unlimited providers (nothing to show). +export function formatBudgetHeadroom(h) { + if (!h || !h.limited) return ''; + const pct = Math.round((h.fraction_remaining || 0) * 100); + const remaining = (h.remaining_usd || 0).toFixed(2); + const limit = (h.limit_usd || 0).toFixed(2); + const name = h.provider ? h.provider.charAt(0).toUpperCase() + h.provider.slice(1) : '?'; + return `${name}: ${pct}% left ($${remaining} of $${limit})`; +} + +// renderBudgetHeadroom builds a <span> chip per limited provider, flagging +// providers under 20% remaining with a --low modifier. Returns the container. +export function renderBudgetHeadroom(headrooms, doc = (typeof document !== 'undefined' ? document : null)) { + if (doc == null) return null; + const wrap = doc.createElement('div'); + wrap.className = 'budget-bar'; + for (const h of headrooms || []) { + if (!h || !h.limited) continue; + const chip = doc.createElement('span'); + chip.className = 'budget-chip' + ((h.fraction_remaining || 0) < 0.2 ? ' budget-chip--low' : ''); + chip.textContent = formatBudgetHeadroom(h); + wrap.appendChild(chip); + } + return wrap; +} + +// loadBudget fetches headroom and injects chips into #budget-bar. Best-effort. +async function loadBudget(fetchImpl = (typeof fetch !== 'undefined' ? fetch : null)) { + if (!fetchImpl || typeof document === 'undefined') return; + const slot = document.getElementById('budget-bar'); + if (!slot) return; + try { + const resp = await fetchImpl(`${BASE_PATH}/api/budget`); + if (!resp.ok) return; + const headrooms = await resp.json(); + const rendered = renderBudgetHeadroom(headrooms); + slot.innerHTML = ''; + if (rendered) for (const c of rendered.children) slot.appendChild(c); + } catch { + // budget display is non-critical; ignore. + } +} + function truncateToWordBoundary(text, maxLen = 120) { if (!text || text.length <= maxLen) return text; const cut = text.lastIndexOf(' ', maxLen); @@ -1297,6 +1344,7 @@ function renderActiveTab(allTasks) { async function poll() { try { + loadBudget(); // fire-and-forget; budget can change independently of tasks const health = await fetchHealth(); const serverUpdate = health.last_updated; diff --git a/web/index.html b/web/index.html index 8a705cc..5aa7b44 100644 --- a/web/index.html +++ b/web/index.html @@ -37,6 +37,7 @@ <button id="btn-notifications" class="btn-secondary" title="Enable push notifications">🔔</button> <button id="btn-start-next" class="btn-secondary">Start Next</button> </div> + <div id="budget-bar" class="budget-bar"></div> </header> <nav class="tab-bar"> <button class="tab active" data-tab="queue" title="Queue">⏳</button> diff --git a/web/style.css b/web/style.css index f4a9d91..b6f0484 100644 --- a/web/style.css +++ b/web/style.css @@ -2034,3 +2034,24 @@ dialog label select:focus { font-size: 0.85rem; padding: 6px 10px; } + +/* Budget headroom chips */ +.budget-bar { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.budget-chip { + font-size: 0.72rem; + padding: 2px 8px; + border-radius: 999px; + background: var(--bg); + border: 1px solid var(--border); + color: var(--text-muted); + white-space: nowrap; +} +.budget-chip--low { + border-color: var(--state-budget-exceeded); + color: var(--state-budget-exceeded); +} diff --git a/web/test/budget.test.mjs b/web/test/budget.test.mjs new file mode 100644 index 0000000..35d5256 --- /dev/null +++ b/web/test/budget.test.mjs @@ -0,0 +1,58 @@ +// budget.test.mjs — Unit tests for budget headroom display. +// +// Run with: node --test web/test/budget.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { formatBudgetHeadroom, renderBudgetHeadroom } from '../app.js'; + +function makeDoc() { + return { + createElement(tag) { + return { + tag, + className: '', + textContent: '', + children: [], + appendChild(child) { this.children.push(child); return child; }, + }; + }, + }; +} + +describe('formatBudgetHeadroom', () => { + it('formats a limited provider with percent and dollars', () => { + assert.equal( + formatBudgetHeadroom({ provider: 'claude', limited: true, limit_usd: 10, remaining_usd: 6, fraction_remaining: 0.6 }), + 'Claude: 60% left ($6.00 of $10.00)', + ); + }); + + it('returns empty string for unlimited providers', () => { + assert.equal(formatBudgetHeadroom({ provider: 'local', limited: false }), ''); + assert.equal(formatBudgetHeadroom(null), ''); + }); +}); + +describe('renderBudgetHeadroom', () => { + it('renders one chip per limited provider, skipping unlimited', () => { + const wrap = renderBudgetHeadroom([ + { provider: 'claude', limited: true, limit_usd: 10, remaining_usd: 6, fraction_remaining: 0.6 }, + { provider: 'local', limited: false }, + ], makeDoc()); + assert.equal(wrap.children.length, 1); + assert.equal(wrap.children[0].className, 'budget-chip'); + assert.match(wrap.children[0].textContent, /Claude: 60% left/); + }); + + it('flags providers under 20% remaining with --low', () => { + const wrap = renderBudgetHeadroom([ + { provider: 'gemini', limited: true, limit_usd: 5, remaining_usd: 0.5, fraction_remaining: 0.1 }, + ], makeDoc()); + assert.equal(wrap.children[0].className, 'budget-chip budget-chip--low'); + }); + + it('returns null when doc is null', () => { + assert.equal(renderBudgetHeadroom([], null), null); + }); +}); |
