stagent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +191 -0
- package/README.md +399 -0
- package/components.json +21 -0
- package/dist/cli.js +171 -0
- package/drizzle.config.ts +12 -0
- package/next.config.mjs +15 -0
- package/package.json +114 -0
- package/postcss.config.mjs +8 -0
- package/public/icon-512.png +0 -0
- package/public/icon.svg +13 -0
- package/public/readme/home-workspace.png +0 -0
- package/public/readme/inbox-approvals.png +0 -0
- package/public/readme/workflow-blueprints.png +0 -0
- package/public/stagent-s-128.png +0 -0
- package/public/stagent-s-64.png +0 -0
- package/src/app/api/blueprints/[id]/instantiate/route.ts +27 -0
- package/src/app/api/blueprints/[id]/route.ts +39 -0
- package/src/app/api/blueprints/import/route.ts +68 -0
- package/src/app/api/blueprints/route.ts +29 -0
- package/src/app/api/command-palette/recent/route.ts +31 -0
- package/src/app/api/data/clear/route.ts +22 -0
- package/src/app/api/data/seed/route.ts +22 -0
- package/src/app/api/documents/[id]/file/route.ts +44 -0
- package/src/app/api/documents/[id]/route.ts +123 -0
- package/src/app/api/documents/route.ts +59 -0
- package/src/app/api/logs/stream/route.ts +101 -0
- package/src/app/api/notifications/[id]/route.ts +36 -0
- package/src/app/api/notifications/mark-all-read/route.ts +13 -0
- package/src/app/api/notifications/pending-approvals/route.ts +10 -0
- package/src/app/api/notifications/pending-approvals/stream/route.ts +101 -0
- package/src/app/api/notifications/route.ts +34 -0
- package/src/app/api/permissions/route.ts +46 -0
- package/src/app/api/profiles/[id]/route.ts +79 -0
- package/src/app/api/profiles/[id]/test/route.ts +42 -0
- package/src/app/api/profiles/import/route.ts +108 -0
- package/src/app/api/profiles/route.ts +50 -0
- package/src/app/api/projects/[id]/route.ts +72 -0
- package/src/app/api/projects/route.ts +53 -0
- package/src/app/api/schedules/[id]/route.ts +185 -0
- package/src/app/api/schedules/route.ts +117 -0
- package/src/app/api/settings/budgets/route.ts +24 -0
- package/src/app/api/settings/openai/route.ts +24 -0
- package/src/app/api/settings/route.ts +21 -0
- package/src/app/api/settings/test/route.ts +26 -0
- package/src/app/api/tasks/[id]/cancel/route.ts +21 -0
- package/src/app/api/tasks/[id]/execute/route.ts +90 -0
- package/src/app/api/tasks/[id]/logs/route.ts +95 -0
- package/src/app/api/tasks/[id]/output/route.ts +47 -0
- package/src/app/api/tasks/[id]/respond/route.ts +64 -0
- package/src/app/api/tasks/[id]/resume/route.ts +76 -0
- package/src/app/api/tasks/[id]/route.ts +77 -0
- package/src/app/api/tasks/assist/route.ts +35 -0
- package/src/app/api/tasks/route.ts +82 -0
- package/src/app/api/uploads/[id]/route.ts +81 -0
- package/src/app/api/uploads/cleanup/route.ts +7 -0
- package/src/app/api/uploads/route.ts +66 -0
- package/src/app/api/workflows/[id]/execute/route.ts +82 -0
- package/src/app/api/workflows/[id]/route.ts +133 -0
- package/src/app/api/workflows/[id]/status/route.ts +54 -0
- package/src/app/api/workflows/[id]/steps/[stepId]/retry/route.ts +22 -0
- package/src/app/api/workflows/route.ts +61 -0
- package/src/app/apple-icon.tsx +31 -0
- package/src/app/costs/page.tsx +256 -0
- package/src/app/dashboard/page.tsx +44 -0
- package/src/app/documents/[id]/page.tsx +46 -0
- package/src/app/documents/page.tsx +45 -0
- package/src/app/error.tsx +26 -0
- package/src/app/global-error.tsx +23 -0
- package/src/app/globals.css +733 -0
- package/src/app/icon.tsx +30 -0
- package/src/app/inbox/loading.tsx +15 -0
- package/src/app/inbox/page.tsx +35 -0
- package/src/app/layout.tsx +78 -0
- package/src/app/manifest.ts +32 -0
- package/src/app/monitor/page.tsx +37 -0
- package/src/app/page.tsx +162 -0
- package/src/app/profiles/[id]/edit/page.tsx +39 -0
- package/src/app/profiles/[id]/page.tsx +33 -0
- package/src/app/profiles/new/page.tsx +22 -0
- package/src/app/profiles/page.tsx +19 -0
- package/src/app/projects/[id]/page.tsx +134 -0
- package/src/app/projects/loading.tsx +17 -0
- package/src/app/projects/page.tsx +32 -0
- package/src/app/schedules/[id]/page.tsx +47 -0
- package/src/app/schedules/page.tsx +18 -0
- package/src/app/settings/loading.tsx +24 -0
- package/src/app/settings/page.tsx +27 -0
- package/src/app/tasks/[id]/page.tsx +45 -0
- package/src/app/tasks/new/page.tsx +27 -0
- package/src/app/workflows/[id]/edit/page.tsx +66 -0
- package/src/app/workflows/[id]/page.tsx +37 -0
- package/src/app/workflows/blueprints/[id]/page.tsx +40 -0
- package/src/app/workflows/blueprints/new/page.tsx +20 -0
- package/src/app/workflows/blueprints/page.tsx +11 -0
- package/src/app/workflows/new/page.tsx +36 -0
- package/src/app/workflows/page.tsx +18 -0
- package/src/components/charts/donut-ring.tsx +64 -0
- package/src/components/charts/mini-bar.tsx +75 -0
- package/src/components/charts/sparkline.tsx +107 -0
- package/src/components/costs/cost-dashboard.tsx +877 -0
- package/src/components/costs/cost-filters.tsx +179 -0
- package/src/components/dashboard/activity-feed.tsx +95 -0
- package/src/components/dashboard/greeting.tsx +30 -0
- package/src/components/dashboard/priority-queue.tsx +79 -0
- package/src/components/dashboard/quick-actions.tsx +62 -0
- package/src/components/dashboard/recent-projects.tsx +79 -0
- package/src/components/dashboard/stats-cards.tsx +114 -0
- package/src/components/documents/document-browser.tsx +235 -0
- package/src/components/documents/document-detail-view.tsx +367 -0
- package/src/components/documents/document-grid.tsx +78 -0
- package/src/components/documents/document-preview.tsx +68 -0
- package/src/components/documents/document-table.tsx +119 -0
- package/src/components/documents/document-upload-dialog.tsx +153 -0
- package/src/components/documents/types.ts +6 -0
- package/src/components/documents/utils.ts +57 -0
- package/src/components/monitoring/connection-indicator.tsx +14 -0
- package/src/components/monitoring/log-entry.tsx +79 -0
- package/src/components/monitoring/log-filters.tsx +57 -0
- package/src/components/monitoring/log-stream.tsx +144 -0
- package/src/components/monitoring/monitor-overview-wrapper.tsx +64 -0
- package/src/components/monitoring/monitor-overview.tsx +119 -0
- package/src/components/notifications/failure-action.tsx +38 -0
- package/src/components/notifications/inbox-list.tsx +165 -0
- package/src/components/notifications/message-response.tsx +196 -0
- package/src/components/notifications/notification-item.tsx +250 -0
- package/src/components/notifications/pending-approval-host.tsx +478 -0
- package/src/components/notifications/permission-action.tsx +37 -0
- package/src/components/notifications/permission-response-actions.tsx +126 -0
- package/src/components/notifications/unread-badge.tsx +35 -0
- package/src/components/profiles/profile-browser.tsx +117 -0
- package/src/components/profiles/profile-card.tsx +78 -0
- package/src/components/profiles/profile-detail-view.tsx +564 -0
- package/src/components/profiles/profile-form-view.tsx +480 -0
- package/src/components/profiles/profile-import-dialog.tsx +113 -0
- package/src/components/projects/project-card.tsx +58 -0
- package/src/components/projects/project-create-dialog.tsx +140 -0
- package/src/components/projects/project-detail.tsx +68 -0
- package/src/components/projects/project-edit-dialog.tsx +219 -0
- package/src/components/projects/project-list.tsx +108 -0
- package/src/components/schedules/schedule-create-dialog.tsx +403 -0
- package/src/components/schedules/schedule-detail-view.tsx +274 -0
- package/src/components/schedules/schedule-list.tsx +242 -0
- package/src/components/schedules/schedule-status-badge.tsx +16 -0
- package/src/components/settings/api-key-form.tsx +141 -0
- package/src/components/settings/auth-config-section.tsx +141 -0
- package/src/components/settings/auth-method-selector.tsx +67 -0
- package/src/components/settings/auth-status-badge.tsx +40 -0
- package/src/components/settings/auth-status-dot.tsx +59 -0
- package/src/components/settings/budget-guardrails-section.tsx +842 -0
- package/src/components/settings/data-management-section.tsx +141 -0
- package/src/components/settings/openai-runtime-section.tsx +104 -0
- package/src/components/settings/permissions-section.tsx +91 -0
- package/src/components/shared/app-sidebar.tsx +123 -0
- package/src/components/shared/card-skeleton.tsx +42 -0
- package/src/components/shared/command-palette.tsx +250 -0
- package/src/components/shared/confirm-dialog.tsx +52 -0
- package/src/components/shared/empty-state.tsx +24 -0
- package/src/components/shared/error-state.tsx +32 -0
- package/src/components/shared/form-section-card.tsx +33 -0
- package/src/components/shared/section-heading.tsx +14 -0
- package/src/components/shared/stagent-logo.tsx +21 -0
- package/src/components/shared/theme-toggle.tsx +46 -0
- package/src/components/tasks/ai-assist-panel.tsx +210 -0
- package/src/components/tasks/content-preview.tsx +89 -0
- package/src/components/tasks/empty-board.tsx +12 -0
- package/src/components/tasks/file-upload.tsx +120 -0
- package/src/components/tasks/kanban-board.tsx +275 -0
- package/src/components/tasks/kanban-column.tsx +75 -0
- package/src/components/tasks/skeleton-board.tsx +21 -0
- package/src/components/tasks/task-attachments.tsx +114 -0
- package/src/components/tasks/task-card.tsx +101 -0
- package/src/components/tasks/task-create-panel.tsx +360 -0
- package/src/components/tasks/task-detail-view.tsx +356 -0
- package/src/components/ui/alert-dialog.tsx +196 -0
- package/src/components/ui/badge.tsx +50 -0
- package/src/components/ui/button.tsx +71 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/checkbox.tsx +32 -0
- package/src/components/ui/command.tsx +184 -0
- package/src/components/ui/dialog.tsx +158 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/form.tsx +167 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +24 -0
- package/src/components/ui/popover.tsx +89 -0
- package/src/components/ui/progress.tsx +31 -0
- package/src/components/ui/radio-group.tsx +45 -0
- package/src/components/ui/scroll-area.tsx +58 -0
- package/src/components/ui/select.tsx +190 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +143 -0
- package/src/components/ui/sidebar.tsx +726 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/slider.tsx +63 -0
- package/src/components/ui/sonner.tsx +36 -0
- package/src/components/ui/switch.tsx +35 -0
- package/src/components/ui/table.tsx +116 -0
- package/src/components/ui/tabs.tsx +91 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +57 -0
- package/src/components/workflows/blueprint-editor.tsx +109 -0
- package/src/components/workflows/blueprint-gallery.tsx +155 -0
- package/src/components/workflows/blueprint-preview.tsx +240 -0
- package/src/components/workflows/loop-status-view.tsx +272 -0
- package/src/components/workflows/swarm-dashboard.tsx +185 -0
- package/src/components/workflows/workflow-form-view.tsx +1376 -0
- package/src/components/workflows/workflow-list.tsx +230 -0
- package/src/components/workflows/workflow-status-view.tsx +477 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/instrumentation.ts +7 -0
- package/src/lib/agents/claude-agent.ts +737 -0
- package/src/lib/agents/execution-manager.ts +27 -0
- package/src/lib/agents/profiles/assignment-validation.ts +75 -0
- package/src/lib/agents/profiles/builtins/code-reviewer/SKILL.md +21 -0
- package/src/lib/agents/profiles/builtins/code-reviewer/profile.yaml +28 -0
- package/src/lib/agents/profiles/builtins/data-analyst/SKILL.md +25 -0
- package/src/lib/agents/profiles/builtins/data-analyst/profile.yaml +27 -0
- package/src/lib/agents/profiles/builtins/devops-engineer/SKILL.md +34 -0
- package/src/lib/agents/profiles/builtins/devops-engineer/profile.yaml +27 -0
- package/src/lib/agents/profiles/builtins/document-writer/SKILL.md +16 -0
- package/src/lib/agents/profiles/builtins/document-writer/profile.yaml +27 -0
- package/src/lib/agents/profiles/builtins/general/SKILL.md +13 -0
- package/src/lib/agents/profiles/builtins/general/profile.yaml +18 -0
- package/src/lib/agents/profiles/builtins/health-fitness-coach/SKILL.md +34 -0
- package/src/lib/agents/profiles/builtins/health-fitness-coach/profile.yaml +26 -0
- package/src/lib/agents/profiles/builtins/learning-coach/SKILL.md +35 -0
- package/src/lib/agents/profiles/builtins/learning-coach/profile.yaml +26 -0
- package/src/lib/agents/profiles/builtins/project-manager/SKILL.md +26 -0
- package/src/lib/agents/profiles/builtins/project-manager/profile.yaml +26 -0
- package/src/lib/agents/profiles/builtins/researcher/SKILL.md +15 -0
- package/src/lib/agents/profiles/builtins/researcher/profile.yaml +27 -0
- package/src/lib/agents/profiles/builtins/shopping-assistant/SKILL.md +34 -0
- package/src/lib/agents/profiles/builtins/shopping-assistant/profile.yaml +26 -0
- package/src/lib/agents/profiles/builtins/technical-writer/SKILL.md +31 -0
- package/src/lib/agents/profiles/builtins/technical-writer/profile.yaml +29 -0
- package/src/lib/agents/profiles/builtins/travel-planner/SKILL.md +23 -0
- package/src/lib/agents/profiles/builtins/travel-planner/profile.yaml +26 -0
- package/src/lib/agents/profiles/builtins/wealth-manager/SKILL.md +24 -0
- package/src/lib/agents/profiles/builtins/wealth-manager/profile.yaml +26 -0
- package/src/lib/agents/profiles/compatibility.ts +109 -0
- package/src/lib/agents/profiles/registry.ts +293 -0
- package/src/lib/agents/profiles/test-runner.ts +18 -0
- package/src/lib/agents/profiles/test-types.ts +20 -0
- package/src/lib/agents/profiles/types.ts +43 -0
- package/src/lib/agents/router.ts +56 -0
- package/src/lib/agents/runtime/catalog.ts +85 -0
- package/src/lib/agents/runtime/claude-sdk.ts +12 -0
- package/src/lib/agents/runtime/claude.ts +370 -0
- package/src/lib/agents/runtime/codex-app-server-client.ts +289 -0
- package/src/lib/agents/runtime/index.ts +167 -0
- package/src/lib/agents/runtime/openai-codex.ts +1089 -0
- package/src/lib/agents/runtime/task-assist-types.ts +8 -0
- package/src/lib/agents/runtime/types.ts +30 -0
- package/src/lib/constants/settings.ts +13 -0
- package/src/lib/constants/status-colors.ts +44 -0
- package/src/lib/constants/task-status.ts +49 -0
- package/src/lib/data/clear.ts +63 -0
- package/src/lib/data/seed-data/documents.ts +715 -0
- package/src/lib/data/seed-data/logs.ts +195 -0
- package/src/lib/data/seed-data/notifications.ts +141 -0
- package/src/lib/data/seed-data/profiles.ts +175 -0
- package/src/lib/data/seed-data/projects.ts +61 -0
- package/src/lib/data/seed-data/schedules.ts +108 -0
- package/src/lib/data/seed-data/tasks.ts +341 -0
- package/src/lib/data/seed-data/usage-ledger.ts +130 -0
- package/src/lib/data/seed-data/workflows.ts +213 -0
- package/src/lib/data/seed.ts +129 -0
- package/src/lib/db/index.ts +221 -0
- package/src/lib/db/migrations/0000_aromatic_gargoyle.sql +59 -0
- package/src/lib/db/migrations/0001_first_iron_patriot.sql +6 -0
- package/src/lib/db/migrations/0002_add_resume_count.sql +1 -0
- package/src/lib/db/migrations/0003_add_settings.sql +5 -0
- package/src/lib/db/migrations/0004_add_documents.sql +20 -0
- package/src/lib/db/migrations/0005_add_document_preprocessing.sql +4 -0
- package/src/lib/db/migrations/0006_add_agent_profile.sql +2 -0
- package/src/lib/db/migrations/0007_add_usage_metering_ledger.sql +30 -0
- package/src/lib/db/migrations/0008_add_document_version.sql +1 -0
- package/src/lib/db/migrations/meta/0000_snapshot.json +416 -0
- package/src/lib/db/migrations/meta/0001_snapshot.json +461 -0
- package/src/lib/db/migrations/meta/0002_snapshot.json +469 -0
- package/src/lib/db/migrations/meta/_journal.json +27 -0
- package/src/lib/db/schema.ts +227 -0
- package/src/lib/documents/cleanup.ts +50 -0
- package/src/lib/documents/context-builder.ts +75 -0
- package/src/lib/documents/output-scanner.ts +166 -0
- package/src/lib/documents/processor.ts +120 -0
- package/src/lib/documents/processors/image.ts +21 -0
- package/src/lib/documents/processors/office.ts +36 -0
- package/src/lib/documents/processors/pdf.ts +12 -0
- package/src/lib/documents/processors/spreadsheet.ts +18 -0
- package/src/lib/documents/processors/text.ts +8 -0
- package/src/lib/documents/registry.ts +25 -0
- package/src/lib/notifications/actionable.ts +108 -0
- package/src/lib/notifications/permissions.ts +169 -0
- package/src/lib/queries/chart-data.ts +184 -0
- package/src/lib/schedules/interval-parser.ts +110 -0
- package/src/lib/schedules/scheduler.ts +220 -0
- package/src/lib/settings/auth.ts +98 -0
- package/src/lib/settings/budget-guardrails.ts +590 -0
- package/src/lib/settings/helpers.ts +23 -0
- package/src/lib/settings/openai-auth.ts +80 -0
- package/src/lib/settings/permissions.ts +102 -0
- package/src/lib/usage/ledger.ts +489 -0
- package/src/lib/usage/pricing.ts +68 -0
- package/src/lib/utils/crypto.ts +90 -0
- package/src/lib/utils/format-timestamp.ts +46 -0
- package/src/lib/utils/session-cleanup.ts +26 -0
- package/src/lib/utils/stagent-paths.ts +18 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/validators/blueprint.ts +43 -0
- package/src/lib/validators/profile.ts +64 -0
- package/src/lib/validators/project.ts +17 -0
- package/src/lib/validators/settings.ts +57 -0
- package/src/lib/validators/task.ts +30 -0
- package/src/lib/workflows/blueprints/builtins/code-review-pipeline.yaml +72 -0
- package/src/lib/workflows/blueprints/builtins/documentation-generation.yaml +62 -0
- package/src/lib/workflows/blueprints/builtins/investment-research.yaml +81 -0
- package/src/lib/workflows/blueprints/builtins/meal-planning.yaml +73 -0
- package/src/lib/workflows/blueprints/builtins/product-research.yaml +72 -0
- package/src/lib/workflows/blueprints/builtins/research-report.yaml +77 -0
- package/src/lib/workflows/blueprints/builtins/sprint-planning.yaml +77 -0
- package/src/lib/workflows/blueprints/builtins/travel-planning.yaml +80 -0
- package/src/lib/workflows/blueprints/instantiator.ts +131 -0
- package/src/lib/workflows/blueprints/registry.ts +128 -0
- package/src/lib/workflows/blueprints/template.ts +58 -0
- package/src/lib/workflows/blueprints/types.ts +38 -0
- package/src/lib/workflows/definition-validation.ts +121 -0
- package/src/lib/workflows/engine.ts +1113 -0
- package/src/lib/workflows/loop-executor.ts +270 -0
- package/src/lib/workflows/parallel.ts +55 -0
- package/src/lib/workflows/swarm.ts +97 -0
- package/src/lib/workflows/types.ts +112 -0
- package/tsconfig.json +41 -0
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, type ComponentType } from "react";
|
|
4
|
+
import { Badge } from "@/components/ui/badge";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import { Input } from "@/components/ui/input";
|
|
8
|
+
import { Separator } from "@/components/ui/separator";
|
|
9
|
+
import { listRuntimeCatalog } from "@/lib/agents/runtime/catalog";
|
|
10
|
+
import type { BudgetPolicy } from "@/lib/validators/settings";
|
|
11
|
+
import {
|
|
12
|
+
AlertTriangle,
|
|
13
|
+
ArrowRight,
|
|
14
|
+
ChevronDown,
|
|
15
|
+
ChevronUp,
|
|
16
|
+
Coins,
|
|
17
|
+
Landmark,
|
|
18
|
+
RotateCcw,
|
|
19
|
+
ShieldAlert,
|
|
20
|
+
ShieldCheck,
|
|
21
|
+
Wallet,
|
|
22
|
+
} from "lucide-react";
|
|
23
|
+
import { toast } from "sonner";
|
|
24
|
+
|
|
25
|
+
type BudgetHealth = "unlimited" | "ok" | "warning" | "blocked";
|
|
26
|
+
type BudgetMetric = "spend" | "tokens";
|
|
27
|
+
type BudgetWindow = "daily" | "monthly";
|
|
28
|
+
|
|
29
|
+
interface BudgetStatus {
|
|
30
|
+
id: string;
|
|
31
|
+
scopeId: string;
|
|
32
|
+
scopeLabel: string;
|
|
33
|
+
runtimeId: string | null;
|
|
34
|
+
metric: BudgetMetric;
|
|
35
|
+
window: BudgetWindow;
|
|
36
|
+
currentValue: number;
|
|
37
|
+
limitValue: number | null;
|
|
38
|
+
ratio: number | null;
|
|
39
|
+
health: BudgetHealth;
|
|
40
|
+
resetAtIso: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface BudgetSnapshot {
|
|
44
|
+
policy: BudgetPolicy;
|
|
45
|
+
statuses: BudgetStatus[];
|
|
46
|
+
dailyResetAtIso: string;
|
|
47
|
+
monthlyResetAtIso: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface BudgetFormState {
|
|
51
|
+
overallDailySpendCapUsd: string;
|
|
52
|
+
overallMonthlySpendCapUsd: string;
|
|
53
|
+
runtimes: Record<
|
|
54
|
+
string,
|
|
55
|
+
{
|
|
56
|
+
dailySpendCapUsd: string;
|
|
57
|
+
monthlySpendCapUsd: string;
|
|
58
|
+
dailyTokenCap: string;
|
|
59
|
+
monthlyTokenCap: string;
|
|
60
|
+
}
|
|
61
|
+
>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const runtimes = listRuntimeCatalog();
|
|
65
|
+
|
|
66
|
+
interface DerivedTokenEstimate {
|
|
67
|
+
estimatedBudgetTokens: number | null;
|
|
68
|
+
estimatedRemainingTokens: number | null;
|
|
69
|
+
sourceLabel: string | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function toInputValue(value: number | null) {
|
|
73
|
+
return value == null ? "" : String(value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function toNullableNumber(value: string) {
|
|
77
|
+
const trimmed = value.trim();
|
|
78
|
+
return trimmed === "" ? null : Number(trimmed);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function buildFormState(policy: BudgetPolicy): BudgetFormState {
|
|
82
|
+
return {
|
|
83
|
+
overallDailySpendCapUsd: toInputValue(policy.overall.dailySpendCapUsd),
|
|
84
|
+
overallMonthlySpendCapUsd: toInputValue(policy.overall.monthlySpendCapUsd),
|
|
85
|
+
runtimes: Object.fromEntries(
|
|
86
|
+
runtimes.map((runtime) => [
|
|
87
|
+
runtime.id,
|
|
88
|
+
{
|
|
89
|
+
dailySpendCapUsd: toInputValue(
|
|
90
|
+
policy.runtimes[runtime.id].dailySpendCapUsd
|
|
91
|
+
),
|
|
92
|
+
monthlySpendCapUsd: toInputValue(
|
|
93
|
+
policy.runtimes[runtime.id].monthlySpendCapUsd
|
|
94
|
+
),
|
|
95
|
+
dailyTokenCap: toInputValue(policy.runtimes[runtime.id].dailyTokenCap),
|
|
96
|
+
monthlyTokenCap: toInputValue(
|
|
97
|
+
policy.runtimes[runtime.id].monthlyTokenCap
|
|
98
|
+
),
|
|
99
|
+
},
|
|
100
|
+
])
|
|
101
|
+
),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function buildPayload(form: BudgetFormState): BudgetPolicy {
|
|
106
|
+
return {
|
|
107
|
+
overall: {
|
|
108
|
+
dailySpendCapUsd: toNullableNumber(form.overallDailySpendCapUsd),
|
|
109
|
+
monthlySpendCapUsd: toNullableNumber(form.overallMonthlySpendCapUsd),
|
|
110
|
+
},
|
|
111
|
+
runtimes: Object.fromEntries(
|
|
112
|
+
runtimes.map((runtime) => [
|
|
113
|
+
runtime.id,
|
|
114
|
+
{
|
|
115
|
+
dailySpendCapUsd: toNullableNumber(
|
|
116
|
+
form.runtimes[runtime.id].dailySpendCapUsd
|
|
117
|
+
),
|
|
118
|
+
monthlySpendCapUsd: toNullableNumber(
|
|
119
|
+
form.runtimes[runtime.id].monthlySpendCapUsd
|
|
120
|
+
),
|
|
121
|
+
dailyTokenCap: toNullableNumber(form.runtimes[runtime.id].dailyTokenCap),
|
|
122
|
+
monthlyTokenCap: toNullableNumber(
|
|
123
|
+
form.runtimes[runtime.id].monthlyTokenCap
|
|
124
|
+
),
|
|
125
|
+
},
|
|
126
|
+
])
|
|
127
|
+
) as BudgetPolicy["runtimes"],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function formatResetAt(value: string) {
|
|
132
|
+
return new Date(value).toLocaleString(undefined, {
|
|
133
|
+
dateStyle: "medium",
|
|
134
|
+
timeStyle: "short",
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function formatStatusValue(status: BudgetStatus) {
|
|
139
|
+
if (status.metric === "tokens") {
|
|
140
|
+
return new Intl.NumberFormat("en-US").format(status.currentValue);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return new Intl.NumberFormat("en-US", {
|
|
144
|
+
style: "currency",
|
|
145
|
+
currency: "USD",
|
|
146
|
+
minimumFractionDigits: 2,
|
|
147
|
+
maximumFractionDigits: 4,
|
|
148
|
+
}).format(status.currentValue / 1_000_000);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function formatStatusLimit(status: BudgetStatus) {
|
|
152
|
+
if (status.limitValue == null) {
|
|
153
|
+
return "Unlimited";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (status.metric === "tokens") {
|
|
157
|
+
return new Intl.NumberFormat("en-US").format(status.limitValue);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return new Intl.NumberFormat("en-US", {
|
|
161
|
+
style: "currency",
|
|
162
|
+
currency: "USD",
|
|
163
|
+
minimumFractionDigits: 2,
|
|
164
|
+
maximumFractionDigits: 4,
|
|
165
|
+
}).format(status.limitValue / 1_000_000);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function formatEstimatedTokens(value: number | null) {
|
|
169
|
+
if (value == null) {
|
|
170
|
+
return "Unavailable";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const rounded = Math.max(0, Math.round(value));
|
|
174
|
+
return new Intl.NumberFormat("en-US", {
|
|
175
|
+
notation: rounded >= 100_000 ? "compact" : "standard",
|
|
176
|
+
maximumFractionDigits: rounded >= 100_000 ? 1 : 0,
|
|
177
|
+
}).format(rounded);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function SectionEyebrow({
|
|
181
|
+
icon: Icon,
|
|
182
|
+
label,
|
|
183
|
+
}: {
|
|
184
|
+
icon: ComponentType<{ className?: string }>;
|
|
185
|
+
label: string;
|
|
186
|
+
}) {
|
|
187
|
+
return (
|
|
188
|
+
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
189
|
+
<Icon className="h-3.5 w-3.5" />
|
|
190
|
+
<span>{label}</span>
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function healthBadge(status: BudgetStatus) {
|
|
196
|
+
if (status.health === "blocked") {
|
|
197
|
+
return <Badge variant="destructive">Blocked</Badge>;
|
|
198
|
+
}
|
|
199
|
+
if (status.health === "warning") {
|
|
200
|
+
return (
|
|
201
|
+
<Badge
|
|
202
|
+
variant="outline"
|
|
203
|
+
className="border-status-warning/30 bg-status-warning/10 text-status-warning"
|
|
204
|
+
>
|
|
205
|
+
Warning
|
|
206
|
+
</Badge>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (status.health === "ok") {
|
|
210
|
+
return <Badge variant="success">Healthy</Badge>;
|
|
211
|
+
}
|
|
212
|
+
return <Badge variant="secondary">Unlimited</Badge>;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function getStatus(
|
|
216
|
+
statuses: BudgetStatus[],
|
|
217
|
+
scopeId: string,
|
|
218
|
+
window: BudgetWindow,
|
|
219
|
+
metric: BudgetMetric
|
|
220
|
+
) {
|
|
221
|
+
return statuses.find(
|
|
222
|
+
(status) =>
|
|
223
|
+
status.scopeId === scopeId &&
|
|
224
|
+
status.window === window &&
|
|
225
|
+
status.metric === metric
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function deriveTokenEstimate(input: {
|
|
230
|
+
spendCapUsd: string;
|
|
231
|
+
primarySpendStatus?: BudgetStatus;
|
|
232
|
+
primaryTokenStatus?: BudgetStatus;
|
|
233
|
+
fallbackSpendStatus?: BudgetStatus;
|
|
234
|
+
fallbackTokenStatus?: BudgetStatus;
|
|
235
|
+
}): DerivedTokenEstimate {
|
|
236
|
+
const spendCapUsd = toNullableNumber(input.spendCapUsd);
|
|
237
|
+
if (spendCapUsd == null) {
|
|
238
|
+
return {
|
|
239
|
+
estimatedBudgetTokens: null,
|
|
240
|
+
estimatedRemainingTokens: null,
|
|
241
|
+
sourceLabel: null,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const spendCapMicros = spendCapUsd * 1_000_000;
|
|
246
|
+
const primaryHasRate =
|
|
247
|
+
(input.primarySpendStatus?.currentValue ?? 0) > 0 &&
|
|
248
|
+
(input.primaryTokenStatus?.currentValue ?? 0) > 0;
|
|
249
|
+
const fallbackHasRate =
|
|
250
|
+
(input.fallbackSpendStatus?.currentValue ?? 0) > 0 &&
|
|
251
|
+
(input.fallbackTokenStatus?.currentValue ?? 0) > 0;
|
|
252
|
+
|
|
253
|
+
const spendStatus = primaryHasRate
|
|
254
|
+
? input.primarySpendStatus
|
|
255
|
+
: fallbackHasRate
|
|
256
|
+
? input.fallbackSpendStatus
|
|
257
|
+
: undefined;
|
|
258
|
+
const tokenStatus = primaryHasRate
|
|
259
|
+
? input.primaryTokenStatus
|
|
260
|
+
: fallbackHasRate
|
|
261
|
+
? input.fallbackTokenStatus
|
|
262
|
+
: undefined;
|
|
263
|
+
|
|
264
|
+
if (!spendStatus || !tokenStatus) {
|
|
265
|
+
return {
|
|
266
|
+
estimatedBudgetTokens: null,
|
|
267
|
+
estimatedRemainingTokens: null,
|
|
268
|
+
sourceLabel: null,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const tokensPerMicro = tokenStatus.currentValue / spendStatus.currentValue;
|
|
273
|
+
const estimatedBudgetTokens = spendCapMicros * tokensPerMicro;
|
|
274
|
+
const estimatedRemainingTokens = Math.max(
|
|
275
|
+
0,
|
|
276
|
+
(spendCapMicros - spendStatus.currentValue) * tokensPerMicro
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
estimatedBudgetTokens,
|
|
281
|
+
estimatedRemainingTokens,
|
|
282
|
+
sourceLabel:
|
|
283
|
+
spendStatus.window === input.primarySpendStatus?.window
|
|
284
|
+
? `${spendStatus.window} blended pricing`
|
|
285
|
+
: `${spendStatus.window} blended pricing fallback`,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function BudgetGuardrailsSection() {
|
|
290
|
+
const [snapshot, setSnapshot] = useState<BudgetSnapshot | null>(null);
|
|
291
|
+
const [form, setForm] = useState<BudgetFormState | null>(null);
|
|
292
|
+
const [advancedOpen, setAdvancedOpen] = useState<Record<string, boolean>>({});
|
|
293
|
+
const [loading, setLoading] = useState(true);
|
|
294
|
+
const [saving, setSaving] = useState(false);
|
|
295
|
+
const [error, setError] = useState<string | null>(null);
|
|
296
|
+
|
|
297
|
+
async function fetchSnapshot() {
|
|
298
|
+
setLoading(true);
|
|
299
|
+
setError(null);
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const res = await fetch("/api/settings/budgets");
|
|
303
|
+
const data = await res.json();
|
|
304
|
+
|
|
305
|
+
if (!res.ok) {
|
|
306
|
+
throw new Error(data?.error?.formErrors?.[0] ?? "Failed to load budget settings");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const parsed = data as BudgetSnapshot;
|
|
310
|
+
setSnapshot(parsed);
|
|
311
|
+
setForm(buildFormState(parsed.policy));
|
|
312
|
+
setAdvancedOpen(
|
|
313
|
+
Object.fromEntries(
|
|
314
|
+
runtimes.map((runtime) => [
|
|
315
|
+
runtime.id,
|
|
316
|
+
Boolean(
|
|
317
|
+
parsed.policy.runtimes[runtime.id].dailyTokenCap ||
|
|
318
|
+
parsed.policy.runtimes[runtime.id].monthlyTokenCap
|
|
319
|
+
),
|
|
320
|
+
])
|
|
321
|
+
)
|
|
322
|
+
);
|
|
323
|
+
} catch (fetchError) {
|
|
324
|
+
setError(
|
|
325
|
+
fetchError instanceof Error
|
|
326
|
+
? fetchError.message
|
|
327
|
+
: "Failed to load budget settings"
|
|
328
|
+
);
|
|
329
|
+
} finally {
|
|
330
|
+
setLoading(false);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
useEffect(() => {
|
|
335
|
+
fetchSnapshot();
|
|
336
|
+
}, []);
|
|
337
|
+
|
|
338
|
+
async function handleSave() {
|
|
339
|
+
if (!form) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
setSaving(true);
|
|
344
|
+
setError(null);
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
const res = await fetch("/api/settings/budgets", {
|
|
348
|
+
method: "POST",
|
|
349
|
+
headers: { "Content-Type": "application/json" },
|
|
350
|
+
body: JSON.stringify(buildPayload(form)),
|
|
351
|
+
});
|
|
352
|
+
const data = await res.json();
|
|
353
|
+
|
|
354
|
+
if (!res.ok) {
|
|
355
|
+
throw new Error(data?.error?.formErrors?.[0] ?? "Failed to save budget settings");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const parsed = data as BudgetSnapshot;
|
|
359
|
+
setSnapshot(parsed);
|
|
360
|
+
setForm(buildFormState(parsed.policy));
|
|
361
|
+
toast.success("Budget guardrails updated");
|
|
362
|
+
} catch (saveError) {
|
|
363
|
+
const message =
|
|
364
|
+
saveError instanceof Error
|
|
365
|
+
? saveError.message
|
|
366
|
+
: "Failed to save budget settings";
|
|
367
|
+
setError(message);
|
|
368
|
+
toast.error(message);
|
|
369
|
+
} finally {
|
|
370
|
+
setSaving(false);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (loading || !snapshot || !form) {
|
|
375
|
+
return (
|
|
376
|
+
<Card className="surface-card">
|
|
377
|
+
<CardHeader>
|
|
378
|
+
<CardTitle>Cost & Usage Guardrails</CardTitle>
|
|
379
|
+
<CardDescription>Loading budget policy and current usage windows.</CardDescription>
|
|
380
|
+
</CardHeader>
|
|
381
|
+
</Card>
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const blockedStatuses = snapshot.statuses.filter((status) => status.health === "blocked");
|
|
386
|
+
const warningStatuses = snapshot.statuses.filter((status) => status.health === "warning");
|
|
387
|
+
const groupedStatuses = snapshot.statuses.reduce<Record<string, BudgetStatus[]>>(
|
|
388
|
+
(acc, status) => {
|
|
389
|
+
const key = status.scopeId;
|
|
390
|
+
acc[key] ??= [];
|
|
391
|
+
acc[key].push(status);
|
|
392
|
+
return acc;
|
|
393
|
+
},
|
|
394
|
+
{}
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
return (
|
|
398
|
+
<Card className="surface-card">
|
|
399
|
+
<CardHeader>
|
|
400
|
+
<div className="space-y-4">
|
|
401
|
+
<div className="space-y-2">
|
|
402
|
+
<CardTitle className="flex items-center gap-2">
|
|
403
|
+
<Wallet className="h-5 w-5" />
|
|
404
|
+
Cost & Usage Guardrails
|
|
405
|
+
</CardTitle>
|
|
406
|
+
<CardDescription>
|
|
407
|
+
Set optional daily and monthly spend caps for all runtime activity.
|
|
408
|
+
Runtime sections keep spend as the primary control, show derived
|
|
409
|
+
token guidance from recent blended pricing, and tuck hard token
|
|
410
|
+
ceilings into an advanced section.
|
|
411
|
+
</CardDescription>
|
|
412
|
+
</div>
|
|
413
|
+
<div className="grid gap-3 md:grid-cols-3">
|
|
414
|
+
<div className="surface-card-muted flex items-start gap-3 rounded-xl px-4 py-3">
|
|
415
|
+
<div className="rounded-lg bg-destructive/10 p-2 text-destructive">
|
|
416
|
+
<ShieldAlert className="h-4 w-4" />
|
|
417
|
+
</div>
|
|
418
|
+
<div className="min-w-0">
|
|
419
|
+
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
420
|
+
Blocked now
|
|
421
|
+
</p>
|
|
422
|
+
<p className="mt-1 text-lg font-semibold">{blockedStatuses.length}</p>
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
<div className="surface-card-muted flex items-start gap-3 rounded-xl px-4 py-3">
|
|
426
|
+
<div className="rounded-lg bg-status-warning/10 p-2 text-status-warning">
|
|
427
|
+
<AlertTriangle className="h-4 w-4" />
|
|
428
|
+
</div>
|
|
429
|
+
<div className="min-w-0">
|
|
430
|
+
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
431
|
+
Near cap
|
|
432
|
+
</p>
|
|
433
|
+
<p className="mt-1 text-lg font-semibold">{warningStatuses.length}</p>
|
|
434
|
+
</div>
|
|
435
|
+
</div>
|
|
436
|
+
<div className="surface-card-muted flex items-start gap-3 rounded-xl px-4 py-3">
|
|
437
|
+
<div className="rounded-lg bg-info/10 p-2 text-info">
|
|
438
|
+
<RotateCcw className="h-4 w-4" />
|
|
439
|
+
</div>
|
|
440
|
+
<div className="min-w-0">
|
|
441
|
+
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
442
|
+
Reset windows
|
|
443
|
+
</p>
|
|
444
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
445
|
+
Day: {formatResetAt(snapshot.dailyResetAtIso)}
|
|
446
|
+
</p>
|
|
447
|
+
<p className="text-xs text-muted-foreground">
|
|
448
|
+
Month: {formatResetAt(snapshot.monthlyResetAtIso)}
|
|
449
|
+
</p>
|
|
450
|
+
</div>
|
|
451
|
+
</div>
|
|
452
|
+
</div>
|
|
453
|
+
</div>
|
|
454
|
+
</CardHeader>
|
|
455
|
+
<CardContent className="space-y-6">
|
|
456
|
+
<div className="surface-panel rounded-xl p-4">
|
|
457
|
+
<div className="flex items-start gap-3">
|
|
458
|
+
{blockedStatuses.length > 0 ? (
|
|
459
|
+
<ShieldAlert className="mt-0.5 h-4 w-4 text-destructive" />
|
|
460
|
+
) : (
|
|
461
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 text-status-warning" />
|
|
462
|
+
)}
|
|
463
|
+
<div className="space-y-1 text-sm text-muted-foreground">
|
|
464
|
+
<p>
|
|
465
|
+
Warning notifications are emitted once per window when usage
|
|
466
|
+
reaches 80% of a configured cap.
|
|
467
|
+
</p>
|
|
468
|
+
<p>
|
|
469
|
+
Blocked attempts are recorded in the usage ledger with zero cost
|
|
470
|
+
so later audit views can explain why work did not start.
|
|
471
|
+
</p>
|
|
472
|
+
</div>
|
|
473
|
+
</div>
|
|
474
|
+
</div>
|
|
475
|
+
|
|
476
|
+
<div className="space-y-4">
|
|
477
|
+
<div className="space-y-3">
|
|
478
|
+
<div>
|
|
479
|
+
<SectionEyebrow icon={Landmark} label="Global Guardrails" />
|
|
480
|
+
<h3 className="text-sm font-semibold">Overall spend caps</h3>
|
|
481
|
+
<p className="text-xs text-muted-foreground">
|
|
482
|
+
Leave an input blank to keep that window unlimited.
|
|
483
|
+
</p>
|
|
484
|
+
</div>
|
|
485
|
+
<div className="grid gap-3 md:grid-cols-2">
|
|
486
|
+
<label className="space-y-2">
|
|
487
|
+
<span className="text-sm font-medium">Daily spend cap (USD)</span>
|
|
488
|
+
<Input
|
|
489
|
+
className="surface-control"
|
|
490
|
+
inputMode="decimal"
|
|
491
|
+
placeholder="Unlimited"
|
|
492
|
+
value={form.overallDailySpendCapUsd}
|
|
493
|
+
onChange={(event) =>
|
|
494
|
+
setForm((current) =>
|
|
495
|
+
current
|
|
496
|
+
? {
|
|
497
|
+
...current,
|
|
498
|
+
overallDailySpendCapUsd: event.target.value,
|
|
499
|
+
}
|
|
500
|
+
: current
|
|
501
|
+
)
|
|
502
|
+
}
|
|
503
|
+
/>
|
|
504
|
+
</label>
|
|
505
|
+
<label className="space-y-2">
|
|
506
|
+
<span className="text-sm font-medium">Monthly spend cap (USD)</span>
|
|
507
|
+
<Input
|
|
508
|
+
className="surface-control"
|
|
509
|
+
inputMode="decimal"
|
|
510
|
+
placeholder="Unlimited"
|
|
511
|
+
value={form.overallMonthlySpendCapUsd}
|
|
512
|
+
onChange={(event) =>
|
|
513
|
+
setForm((current) =>
|
|
514
|
+
current
|
|
515
|
+
? {
|
|
516
|
+
...current,
|
|
517
|
+
overallMonthlySpendCapUsd: event.target.value,
|
|
518
|
+
}
|
|
519
|
+
: current
|
|
520
|
+
)
|
|
521
|
+
}
|
|
522
|
+
/>
|
|
523
|
+
</label>
|
|
524
|
+
</div>
|
|
525
|
+
</div>
|
|
526
|
+
|
|
527
|
+
<Separator />
|
|
528
|
+
|
|
529
|
+
<div className="space-y-4">
|
|
530
|
+
{runtimes.map((runtime) => (
|
|
531
|
+
<div key={runtime.id} className="surface-card-muted rounded-xl p-4">
|
|
532
|
+
<div className="mb-3">
|
|
533
|
+
<SectionEyebrow icon={Wallet} label="Runtime Budget" />
|
|
534
|
+
<h3 className="mt-1 text-sm font-semibold">{runtime.label}</h3>
|
|
535
|
+
<p className="text-xs text-muted-foreground">
|
|
536
|
+
{runtime.description}
|
|
537
|
+
</p>
|
|
538
|
+
</div>
|
|
539
|
+
<div className="grid gap-3 md:grid-cols-2">
|
|
540
|
+
<label className="space-y-2">
|
|
541
|
+
<span className="text-sm font-medium">Daily spend cap (USD)</span>
|
|
542
|
+
<Input
|
|
543
|
+
className="surface-control"
|
|
544
|
+
inputMode="decimal"
|
|
545
|
+
placeholder="Unlimited"
|
|
546
|
+
value={form.runtimes[runtime.id].dailySpendCapUsd}
|
|
547
|
+
onChange={(event) =>
|
|
548
|
+
setForm((current) =>
|
|
549
|
+
current
|
|
550
|
+
? {
|
|
551
|
+
...current,
|
|
552
|
+
runtimes: {
|
|
553
|
+
...current.runtimes,
|
|
554
|
+
[runtime.id]: {
|
|
555
|
+
...current.runtimes[runtime.id],
|
|
556
|
+
dailySpendCapUsd: event.target.value,
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
}
|
|
560
|
+
: current
|
|
561
|
+
)
|
|
562
|
+
}
|
|
563
|
+
/>
|
|
564
|
+
</label>
|
|
565
|
+
<label className="space-y-2">
|
|
566
|
+
<span className="text-sm font-medium">Monthly spend cap (USD)</span>
|
|
567
|
+
<Input
|
|
568
|
+
className="surface-control"
|
|
569
|
+
inputMode="decimal"
|
|
570
|
+
placeholder="Unlimited"
|
|
571
|
+
value={form.runtimes[runtime.id].monthlySpendCapUsd}
|
|
572
|
+
onChange={(event) =>
|
|
573
|
+
setForm((current) =>
|
|
574
|
+
current
|
|
575
|
+
? {
|
|
576
|
+
...current,
|
|
577
|
+
runtimes: {
|
|
578
|
+
...current.runtimes,
|
|
579
|
+
[runtime.id]: {
|
|
580
|
+
...current.runtimes[runtime.id],
|
|
581
|
+
monthlySpendCapUsd: event.target.value,
|
|
582
|
+
},
|
|
583
|
+
},
|
|
584
|
+
}
|
|
585
|
+
: current
|
|
586
|
+
)
|
|
587
|
+
}
|
|
588
|
+
/>
|
|
589
|
+
</label>
|
|
590
|
+
</div>
|
|
591
|
+
{(() => {
|
|
592
|
+
const dailySpendStatus = getStatus(
|
|
593
|
+
snapshot.statuses,
|
|
594
|
+
runtime.id,
|
|
595
|
+
"daily",
|
|
596
|
+
"spend"
|
|
597
|
+
);
|
|
598
|
+
const dailyTokenStatus = getStatus(
|
|
599
|
+
snapshot.statuses,
|
|
600
|
+
runtime.id,
|
|
601
|
+
"daily",
|
|
602
|
+
"tokens"
|
|
603
|
+
);
|
|
604
|
+
const monthlySpendStatus = getStatus(
|
|
605
|
+
snapshot.statuses,
|
|
606
|
+
runtime.id,
|
|
607
|
+
"monthly",
|
|
608
|
+
"spend"
|
|
609
|
+
);
|
|
610
|
+
const monthlyTokenStatus = getStatus(
|
|
611
|
+
snapshot.statuses,
|
|
612
|
+
runtime.id,
|
|
613
|
+
"monthly",
|
|
614
|
+
"tokens"
|
|
615
|
+
);
|
|
616
|
+
const dailyEstimate = deriveTokenEstimate({
|
|
617
|
+
spendCapUsd: form.runtimes[runtime.id].dailySpendCapUsd,
|
|
618
|
+
primarySpendStatus: dailySpendStatus,
|
|
619
|
+
primaryTokenStatus: dailyTokenStatus,
|
|
620
|
+
fallbackSpendStatus: monthlySpendStatus,
|
|
621
|
+
fallbackTokenStatus: monthlyTokenStatus,
|
|
622
|
+
});
|
|
623
|
+
const monthlyEstimate = deriveTokenEstimate({
|
|
624
|
+
spendCapUsd: form.runtimes[runtime.id].monthlySpendCapUsd,
|
|
625
|
+
primarySpendStatus: monthlySpendStatus,
|
|
626
|
+
primaryTokenStatus: monthlyTokenStatus,
|
|
627
|
+
fallbackSpendStatus: dailySpendStatus,
|
|
628
|
+
fallbackTokenStatus: dailyTokenStatus,
|
|
629
|
+
});
|
|
630
|
+
const hasAdvancedTokenCaps =
|
|
631
|
+
Boolean(form.runtimes[runtime.id].dailyTokenCap) ||
|
|
632
|
+
Boolean(form.runtimes[runtime.id].monthlyTokenCap);
|
|
633
|
+
|
|
634
|
+
return (
|
|
635
|
+
<>
|
|
636
|
+
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
|
637
|
+
<div className="surface-panel rounded-lg px-3 py-3">
|
|
638
|
+
<SectionEyebrow icon={Coins} label="Derived Guidance" />
|
|
639
|
+
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
640
|
+
Estimated Daily Token Budget
|
|
641
|
+
</p>
|
|
642
|
+
{dailyEstimate.sourceLabel ? (
|
|
643
|
+
<>
|
|
644
|
+
<p className="mt-1 text-sm font-semibold">
|
|
645
|
+
~{formatEstimatedTokens(dailyEstimate.estimatedBudgetTokens)}
|
|
646
|
+
</p>
|
|
647
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
648
|
+
~{formatEstimatedTokens(dailyEstimate.estimatedRemainingTokens)} remaining headroom based on {dailyEstimate.sourceLabel}.
|
|
649
|
+
</p>
|
|
650
|
+
</>
|
|
651
|
+
) : (
|
|
652
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
653
|
+
Set a spend cap and accumulate priced usage to see a token estimate.
|
|
654
|
+
</p>
|
|
655
|
+
)}
|
|
656
|
+
</div>
|
|
657
|
+
<div className="surface-panel rounded-lg px-3 py-3">
|
|
658
|
+
<SectionEyebrow icon={Coins} label="Derived Guidance" />
|
|
659
|
+
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
660
|
+
Estimated Monthly Token Budget
|
|
661
|
+
</p>
|
|
662
|
+
{monthlyEstimate.sourceLabel ? (
|
|
663
|
+
<>
|
|
664
|
+
<p className="mt-1 text-sm font-semibold">
|
|
665
|
+
~{formatEstimatedTokens(monthlyEstimate.estimatedBudgetTokens)}
|
|
666
|
+
</p>
|
|
667
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
668
|
+
~{formatEstimatedTokens(monthlyEstimate.estimatedRemainingTokens)} remaining headroom based on {monthlyEstimate.sourceLabel}.
|
|
669
|
+
</p>
|
|
670
|
+
</>
|
|
671
|
+
) : (
|
|
672
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
673
|
+
Set a spend cap and accumulate priced usage to see a token estimate.
|
|
674
|
+
</p>
|
|
675
|
+
)}
|
|
676
|
+
</div>
|
|
677
|
+
</div>
|
|
678
|
+
|
|
679
|
+
<div className="mt-4 rounded-lg border border-border/60 bg-background/40">
|
|
680
|
+
<button
|
|
681
|
+
type="button"
|
|
682
|
+
className="flex w-full items-center justify-between gap-3 cursor-pointer px-3 py-2 text-left text-sm font-medium"
|
|
683
|
+
onClick={() =>
|
|
684
|
+
setAdvancedOpen((current) => ({
|
|
685
|
+
...current,
|
|
686
|
+
[runtime.id]: !(current[runtime.id] ?? hasAdvancedTokenCaps),
|
|
687
|
+
}))
|
|
688
|
+
}
|
|
689
|
+
>
|
|
690
|
+
<span className="flex items-center gap-2">
|
|
691
|
+
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
|
|
692
|
+
{advancedOpen[runtime.id] ?? hasAdvancedTokenCaps
|
|
693
|
+
? "Hide advanced token guardrails"
|
|
694
|
+
: "Show advanced token guardrails"}
|
|
695
|
+
</span>
|
|
696
|
+
{advancedOpen[runtime.id] ?? hasAdvancedTokenCaps ? (
|
|
697
|
+
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
|
698
|
+
) : (
|
|
699
|
+
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
700
|
+
)}
|
|
701
|
+
</button>
|
|
702
|
+
{(advancedOpen[runtime.id] ?? hasAdvancedTokenCaps) && (
|
|
703
|
+
<div className="border-t border-border/60 px-3 py-3">
|
|
704
|
+
<SectionEyebrow icon={ShieldCheck} label="Advanced Override" />
|
|
705
|
+
<p className="mb-3 text-xs text-muted-foreground">
|
|
706
|
+
Use hard token caps only when you need a strict technical ceiling. Spend caps remain the primary operator control.
|
|
707
|
+
</p>
|
|
708
|
+
<div className="grid gap-3 md:grid-cols-2">
|
|
709
|
+
<label className="space-y-2">
|
|
710
|
+
<span className="text-sm font-medium">Daily token cap</span>
|
|
711
|
+
<Input
|
|
712
|
+
className="surface-control"
|
|
713
|
+
inputMode="numeric"
|
|
714
|
+
placeholder="Unlimited"
|
|
715
|
+
value={form.runtimes[runtime.id].dailyTokenCap}
|
|
716
|
+
onChange={(event) =>
|
|
717
|
+
setForm((current) =>
|
|
718
|
+
current
|
|
719
|
+
? {
|
|
720
|
+
...current,
|
|
721
|
+
runtimes: {
|
|
722
|
+
...current.runtimes,
|
|
723
|
+
[runtime.id]: {
|
|
724
|
+
...current.runtimes[runtime.id],
|
|
725
|
+
dailyTokenCap: event.target.value,
|
|
726
|
+
},
|
|
727
|
+
},
|
|
728
|
+
}
|
|
729
|
+
: current
|
|
730
|
+
)
|
|
731
|
+
}
|
|
732
|
+
/>
|
|
733
|
+
</label>
|
|
734
|
+
<label className="space-y-2">
|
|
735
|
+
<span className="text-sm font-medium">Monthly token cap</span>
|
|
736
|
+
<Input
|
|
737
|
+
className="surface-control"
|
|
738
|
+
inputMode="numeric"
|
|
739
|
+
placeholder="Unlimited"
|
|
740
|
+
value={form.runtimes[runtime.id].monthlyTokenCap}
|
|
741
|
+
onChange={(event) =>
|
|
742
|
+
setForm((current) =>
|
|
743
|
+
current
|
|
744
|
+
? {
|
|
745
|
+
...current,
|
|
746
|
+
runtimes: {
|
|
747
|
+
...current.runtimes,
|
|
748
|
+
[runtime.id]: {
|
|
749
|
+
...current.runtimes[runtime.id],
|
|
750
|
+
monthlyTokenCap: event.target.value,
|
|
751
|
+
},
|
|
752
|
+
},
|
|
753
|
+
}
|
|
754
|
+
: current
|
|
755
|
+
)
|
|
756
|
+
}
|
|
757
|
+
/>
|
|
758
|
+
</label>
|
|
759
|
+
</div>
|
|
760
|
+
</div>
|
|
761
|
+
)}
|
|
762
|
+
</div>
|
|
763
|
+
</>
|
|
764
|
+
);
|
|
765
|
+
})()}
|
|
766
|
+
</div>
|
|
767
|
+
))}
|
|
768
|
+
</div>
|
|
769
|
+
</div>
|
|
770
|
+
|
|
771
|
+
<div className="flex items-center justify-between gap-3">
|
|
772
|
+
<div className="text-xs text-muted-foreground">
|
|
773
|
+
Blank fields are treated as unlimited. Changes reset warning dedupe for the current windows.
|
|
774
|
+
</div>
|
|
775
|
+
<Button onClick={handleSave} disabled={saving}>
|
|
776
|
+
{saving ? "Saving..." : "Save guardrails"}
|
|
777
|
+
</Button>
|
|
778
|
+
</div>
|
|
779
|
+
|
|
780
|
+
{error && (
|
|
781
|
+
<div className="rounded-xl border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
782
|
+
{error}
|
|
783
|
+
</div>
|
|
784
|
+
)}
|
|
785
|
+
|
|
786
|
+
<Separator />
|
|
787
|
+
|
|
788
|
+
<div className="space-y-3">
|
|
789
|
+
<div>
|
|
790
|
+
<SectionEyebrow icon={ArrowRight} label="Live Status" />
|
|
791
|
+
<h3 className="text-sm font-semibold">Current window status</h3>
|
|
792
|
+
<p className="text-xs text-muted-foreground">
|
|
793
|
+
Live usage is derived from the normalized usage ledger in the
|
|
794
|
+
machine's local timezone.
|
|
795
|
+
</p>
|
|
796
|
+
</div>
|
|
797
|
+
<div className="grid gap-4 xl:grid-cols-3">
|
|
798
|
+
{Object.entries(groupedStatuses).map(([scopeId, statuses]) => (
|
|
799
|
+
<div key={scopeId} className="surface-card-muted rounded-xl p-4">
|
|
800
|
+
<div className="mb-3 flex items-center justify-between gap-2">
|
|
801
|
+
<h4 className="text-sm font-semibold">{statuses[0]?.scopeLabel}</h4>
|
|
802
|
+
{statuses.some((status) => status.health === "blocked")
|
|
803
|
+
? <Badge variant="destructive">Blocked</Badge>
|
|
804
|
+
: statuses.some((status) => status.health === "warning")
|
|
805
|
+
? (
|
|
806
|
+
<Badge
|
|
807
|
+
variant="outline"
|
|
808
|
+
className="border-status-warning/30 bg-status-warning/10 text-status-warning"
|
|
809
|
+
>
|
|
810
|
+
Warning
|
|
811
|
+
</Badge>
|
|
812
|
+
)
|
|
813
|
+
: <Badge variant="success">Healthy</Badge>}
|
|
814
|
+
</div>
|
|
815
|
+
<div className="space-y-2">
|
|
816
|
+
{statuses.map((status) => (
|
|
817
|
+
<div key={status.id} className="surface-panel rounded-lg px-3 py-2">
|
|
818
|
+
<div className="flex items-center justify-between gap-2">
|
|
819
|
+
<div>
|
|
820
|
+
<p className="text-sm font-medium capitalize">
|
|
821
|
+
{status.window} {status.metric}
|
|
822
|
+
</p>
|
|
823
|
+
<p className="text-xs text-muted-foreground">
|
|
824
|
+
{formatStatusValue(status)} / {formatStatusLimit(status)}
|
|
825
|
+
</p>
|
|
826
|
+
</div>
|
|
827
|
+
{healthBadge(status)}
|
|
828
|
+
</div>
|
|
829
|
+
<p className="mt-2 text-xs text-muted-foreground">
|
|
830
|
+
Resets {formatResetAt(status.resetAtIso)}
|
|
831
|
+
</p>
|
|
832
|
+
</div>
|
|
833
|
+
))}
|
|
834
|
+
</div>
|
|
835
|
+
</div>
|
|
836
|
+
))}
|
|
837
|
+
</div>
|
|
838
|
+
</div>
|
|
839
|
+
</CardContent>
|
|
840
|
+
</Card>
|
|
841
|
+
);
|
|
842
|
+
}
|