diff --git a/.gitignore b/.gitignore index 73ecace..7732960 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,10 @@ venv/ .understand-anything/ .codenomad/ catalog_output.json + +# Swift Package Manager build artifacts (apps/gate-bar) +.build/ +.swiftpm/ +*.xcodeproj/ +xcuserdata/ +DerivedData/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4857bfc..1b173db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # fusionAIze Gate Changelog +## v2.3.0 - 2026-04-19 + +### Added + +- **Brand-first quota widget** (`/dashboard/quotas`): replaces the flat package list with one card per brand (Claude, DeepSeek, Kilo, …). Cards are sorted worst-alert first so the thing about to break is at the top. Each card stacks its sub-packages (session vs weekly, pay-as-you-go vs credits) with a pace marker on every window-based bar and an identity line (`OAuth Β· claude-code`, `API key Β· ${KEY}`) so you can tell which account feeds the meter. +- **Per-brand detail view** (`/dashboard/quotas/`): a focused page that shows the same quota panel plus a 24h totals strip, clients-by-profile table, routes/lanes breakdown, and an hourly sparkline. Shares CSS variables with the overview so scanning between them needs no retraining. +- **Read-only brand endpoints** (`/api/quotas//clients`, `/routes`, `/analytics`): the data feed behind the detail view. 404 on unknown brands (distinguishes "typo" from "no traffic yet"); analytics clamps `hours=1..168` and `days=1..90` to prevent URL-typo DB scans. Catalog surfaces `catalog_tagline` so the "Available to add" mini-block can show tier/price/quota shape at a glance. +- **Default landing view** (`dashboard.quotas.default_view` in `config.yaml`): three options β€” `overview` (default), `brand:`, `cockpit`. `GET /dashboard/quotas` honors the setting via 302; `?view=overview` is an always-available escape hatch. A `Pin as Home` / `πŸ“Œ Home` button sits on every brand card and the detail-page header β€” one-click promotion, no modal. Writes go through `ruamel.yaml` round-trip so the 220+ operator comments in a real `config.yaml` survive a pin toggle (`yaml.safe_dump` would flatten them). `GET/POST /api/dashboard/settings` drives the widget and is available to external consumers. +- **Gate Bar 0.1 (macOS menubar companion)** at `apps/gate-bar/`: SwiftUI `MenuBarExtra` app that reads the same `/api/quotas` feed and shows `fAI Β· 83%` in the menubar (tightest window across all brands, colour-coded). Popover renders brand cards with the web widget's visual vocabulary; footer links to `Dashboard β†—` (server-side redirect honours `default_view`) and `Cockpit β†—`. Preferences: gateway URL, Cockpit URL, refresh cadence (manual / 1 / 2 / 5 / 15 min). 13 Swift-Testing tests green on both Xcode.app and Command Line Tools via `scripts/swift-test.sh`. Read-only β€” every write path links out to the Operator Cockpit. Sparkle auto-update + notifications + code signing + Homebrew cask tracked for 0.2+ (`apps/gate-bar/README.md`). + +### Changed + +- Quota widget groups by `provider_id` first and gates on credential availability so brands without a resolvable API key / OAuth token land in the collapsed "Skipped" block instead of showing a perpetually-empty bar. +- `QuotaStatus` payload carries `brand`, `brand_slug`, `pace_delta`, and `identity` on every package (v1.3 catalog schema). Older v1.2 catalogs still decode through the brand-fallback table in `quota_tracker.py`. + +### Upgrade notes + +- New runtime dependency: `ruamel.yaml>=0.18.6` (already added to `requirements.txt` + `pyproject.toml`). Fresh `pip install faigate` or `brew upgrade faigate` picks it up automatically. +- The Gate Bar macOS app is a separate artifact. v0.1 is source-only β€” build it with `cd apps/gate-bar && ./scripts/install-local.sh` for local testing. A notarized Homebrew cask ships with Gate Bar 0.2. + ## v2.2.3 - 2026-04-18 ### Fixed diff --git a/apps/gate-bar/Package.swift b/apps/gate-bar/Package.swift new file mode 100644 index 0000000..0f50e78 --- /dev/null +++ b/apps/gate-bar/Package.swift @@ -0,0 +1,44 @@ +// swift-tools-version:5.9 +// +// fusionAIze Gate Bar β€” macOS menubar companion for the faigate local gateway. +// +// Design anchors (see ../../docs/GATE-BAR-DESIGN.md Β§5): +// +// - macOS 14 (Sonoma) minimum β€” keeps two-year-old Intel MacBooks alive. +// - Universal binary (x86_64 + arm64). SPM handles this at release time via +// swift build -c release --arch x86_64 --arch arm64 +// - SwiftUI surface is the Sonoma subset: ObservableObject, Combine, plain +// Color. No @Observable, no MeshGradient. +// - Pure HTTP consumer of the local gateway β€” no shared state, no socket, +// no filesystem coupling with the Python daemon. +// +// Why SPM executable instead of an .xcodeproj: +// The monorepo is CLI-first; a hand-crafted Package.swift keeps the app +// reviewable in a diff and builds with `swift build` on any machine with the +// Xcode command-line tools. Opening `apps/gate-bar/Package.swift` in Xcode +// still gives the full GUI editor for anyone who wants one. +import PackageDescription + +let package = Package( + name: "GateBar", + platforms: [ + .macOS(.v14), + ], + products: [ + // The app binary itself. Distribution (notarization, .app bundling, + // Sparkle, Homebrew cask) is release-engineering scaffolding tracked + // separately β€” not wired into the SPM manifest. + .executable(name: "GateBar", targets: ["GateBar"]), + ], + targets: [ + .executableTarget( + name: "GateBar", + path: "Sources/GateBar" + ), + .testTarget( + name: "GateBarTests", + dependencies: ["GateBar"], + path: "Tests/GateBarTests" + ), + ] +) diff --git a/apps/gate-bar/README.md b/apps/gate-bar/README.md new file mode 100644 index 0000000..653d8c1 --- /dev/null +++ b/apps/gate-bar/README.md @@ -0,0 +1,117 @@ +# fusionAIze Gate Bar + +A macOS menubar companion for the [faigate](../../README.md) local gateway. +Shows every active provider's quota at a glance, colour-coded by severity, +so you can answer "am I about to hit a session cap?" in under three seconds +without switching tabs. + +> **Status:** v0.1 β€” scaffold in place, read-only consumer of the local +> gateway's `/api/quotas` endpoint. Sparkle auto-update, notifications, +> code signing, and Homebrew cask distribution are tracked separately and +> not wired up yet. + +## What it does today + +- **Menubar label** β€” `fAI Β· 83%` plus a coloured dot for the tightest + window across all active brands. Click to open the popover. +- **Popover** β€” one card per brand (Claude, Codex, DeepSeek, …), sorted + worst-alert first. Each card shows package bars with a pace marker, + identity line, and reset time. +- **"Available to add" mini-catalog** β€” brands the operator hasn't wired + up yet, each with a deep link to the Operator Cockpit's onboarding flow. +- **Preferences** β€” gateway URL, Cockpit URL, refresh cadence (manual / + 1 / 2 / 5 / 15 min; default 5). +- **Privacy posture** β€” reads from `127.0.0.1` only. Gate Bar never talks + to a fusionAIze-hosted service; the "Cockpit β†—" button just opens a web + page in your default browser. + +## Design anchors + +The full design doc is at `../../docs/GATE-BAR-DESIGN.md`. Three rules +shape every file in this directory: + +1. **Pure HTTP client.** No shared state, no socket, no filesystem + coupling with the Python daemon. Every provider the Gate Bar renders + is discovered at runtime from `GET /api/quotas`. +2. **macOS 14+ Sonoma.** Two-year-old Intel MacBooks still run it. + SwiftUI surface is the Sonoma subset: `ObservableObject` + Combine, + plain `Color`, no `@Observable`, no `MeshGradient`. +3. **Read-only.** Nothing in the menubar writes config or wakes up an + onboarding wizard. Every action that mutates state deep-links to the + Operator Cockpit. + +## Build & run + +Requires the Xcode Command Line Tools (`xcode-select --install`) or +Xcode.app. Swift 5.9+ toolchain. + +```bash +cd apps/gate-bar +swift build # debug build of the executable target +swift run GateBar # launches the menubar app +``` + +The app launches as a `LSUIElement`-style menubar-only process β€” there's +no Dock icon or main window. Quit from the popover's "Quit" button or via +`⌘Q` while Gate Bar is the frontmost app. + +### Running against a local gateway + +By default Gate Bar talks to `http://127.0.0.1:4001` β€” the faigate +default. If you run the gateway on a different port, update it under +`Preferences β†’ Gateway`. + +### Tests + +```bash +./scripts/swift-test.sh +``` + +We use the **Swift Testing** framework (`import Testing`) rather than +XCTest because the Command Line Tools ship Testing but not XCTest. The +wrapper script adds the framework search paths dyld needs at runtime; on +a machine with Xcode.app, plain `swift test` also works. + +Current coverage (13 tests, 3 suites): + +- JSON-decode round-trip against a canned `/api/quotas` payload. +- Forward compatibility β€” unknown JSON fields don't break decode. +- `AlertLevel` classification (server-label precedence, ratio fallback, + unknown-string degradation, severity ordering). +- `QuotaStore` transforms (brand grouping, worst-alert sort, tie-break + rules, tightest-window menubar summary, identity propagation). + +## File map + +``` +Package.swift # SPM manifest, .macOS(.v14), executable target +Sources/GateBar/ + GateBarApp.swift # @main, MenuBarExtra + Settings scenes + Models.swift # Codable mirrors of /api/quotas (plus BrandGroup, AlertLevel) + QuotaClient.swift # URLSession-backed actor, the only network I/O + QuotaStore.swift # ObservableObject β€” grouping, sorting, menubar summary, timer + Preferences.swift # UserDefaults-backed @Published wrapper + Theme.swift # Colour palette mirroring the web widget's CSS variables + PopoverView.swift # The popover shell β€” active cards + catalog + footer + BrandCardView.swift # Per-brand card + per-package row with pace tick + PreferencesView.swift # Settings window β€” 4 controls, no wizards +Tests/GateBarTests/ + ModelsTests.swift # JSON decode + AlertLevel classification + QuotaStoreTests.swift # Grouping / sorting / menubar-summary +scripts/ + swift-test.sh # `swift test` with CLT-compatible rpaths +``` + +## Roadmap (not yet shipped) + +- [ ] Sparkle 2 auto-update (EdDSA-signed appcast, notarized .dmg from + GitHub releases). +- [ ] Notifications β€” threshold alerts (session 80 %, weekly 80 %, + pace +10 %) via `UserNotifications`. +- [ ] Launch at login via `SMAppService.mainApp.register()`. +- [ ] `.app` bundle + notarization pipeline in `/.github/workflows`. +- [ ] Homebrew cask: `brew install --cask fusionaize/tap/gate-bar`. +- [ ] Hide-menubar-icon toggle (keeps the app running but invisible). + +These are release-engineering passes, not app-code. Tracked against the +v2.3.x release milestone. diff --git a/apps/gate-bar/Sources/GateBar/BrandCardView.swift b/apps/gate-bar/Sources/GateBar/BrandCardView.swift new file mode 100644 index 0000000..6fa1efc --- /dev/null +++ b/apps/gate-bar/Sources/GateBar/BrandCardView.swift @@ -0,0 +1,169 @@ +import SwiftUI + +/// A single brand card in the popover. Visual parity with the web +/// widget's `.brand` block in `_QUOTAS_DASHBOARD_HTML`: +/// +/// - brand name left, identity right +/// - one `PackageRow` per package (bar + pace tick + % + under-bar meta) +/// - coloured left border carries the worst-alert signal +struct BrandCardView: View { + let brand: BrandGroup + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + header + ForEach(Array(brand.packages.enumerated()), id: \.element.packageId) { index, pkg in + if index > 0 { + Divider() + .background(Theme.border) + .padding(.vertical, 2) + } + PackageRow(package: pkg) + } + } + .padding(.vertical, 10) + .padding(.horizontal, 12) + .background(Theme.card) + .overlay( + Rectangle() + .fill(Theme.color(for: brand.worstAlert)) + .frame(width: 3), + alignment: .leading + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Theme.border, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private var header: some View { + HStack(alignment: .firstTextBaseline) { + Text(brand.brand) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Theme.foreground) + Spacer(minLength: 8) + if let identity = brand.identity { + Text("\(identity.loginMethod): \(identity.credential)") + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(Theme.dim) + .lineLimit(1) + .truncationMode(.middle) + } + } + } +} + +/// One package inside a brand card. +/// +/// Renders the same vocabulary as the web row: +/// - package title (left) + percentage (right) +/// - progress bar with an inline pace tick +/// - under-bar meta: `used / total` (left) Β· reset / days-left (right) +struct PackageRow: View { + let package: QuotaPackage + + private var usedRatio: Double { + max(0, min(1, package.usedRatio ?? 0)) + } + + private var alert: AlertLevel { + AlertLevel(rawAlert: package.alert, usedRatio: package.usedRatio) + } + + private var paceFraction: Double? { + // Pace marker only makes sense when both sides of the computation + // are present (rolling_window + daily). Credits packages return nil. + guard package.paceDelta != nil, let elapsed = package.elapsedRatio else { + return nil + } + return max(0, min(1, elapsed)) + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline) { + Text(package.packageName ?? package.packageId) + .font(.system(size: 12)) + .foregroundColor(Theme.mid) + Spacer(minLength: 8) + Text(percentageLabel) + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundColor(Theme.foreground) + } + bar + HStack { + if let used = package.usedDisplay, let total = package.totalDisplay { + Text("\(used) / \(total)") + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(Theme.dim) + } + Spacer(minLength: 8) + Text(resetLabel) + .font(.system(size: 10)) + .foregroundColor(Theme.dim) + .lineLimit(1) + } + } + } + + private var percentageLabel: String { + let pct = usedRatio * 100 + if pct < 10 { + return String(format: "%.1f%%", pct) + } + return "\(Int(pct.rounded()))%" + } + + private var resetLabel: String { + if let reset = package.resetAt, !reset.isEmpty { + return "resets \(formatReset(reset))" + } + if let days = package.projectedDaysLeft { + return "~\(Int(days.rounded()))d left" + } + return "" + } + + private func formatReset(_ iso: String) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + if let date = formatter.date(from: iso) ?? ISO8601DateFormatter.withFractional.date(from: iso) { + let rel = RelativeDateTimeFormatter() + rel.unitsStyle = .short + return rel.localizedString(for: date, relativeTo: Date()) + } + return iso + } + + /// Progress bar with an inline pace tick. `GeometryReader` lets us + /// position the tick at `elapsedRatio * width` without measuring text. + private var bar: some View { + GeometryReader { proxy in + ZStack(alignment: .topLeading) { + Capsule() + .fill(Theme.track) + Capsule() + .fill(Theme.color(for: alert)) + .frame(width: proxy.size.width * usedRatio) + if let pace = paceFraction { + Rectangle() + .fill(Theme.accent) + .frame(width: 2, height: proxy.size.height + 4) + .offset(x: (proxy.size.width * pace) - 1, y: -2) + } + } + } + .frame(height: 6) + } +} + +private extension ISO8601DateFormatter { + /// `/api/quotas` sometimes emits timestamps with fractional seconds + /// depending on the backend; try both shapes. + static let withFractional: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() +} diff --git a/apps/gate-bar/Sources/GateBar/GateBarApp.swift b/apps/gate-bar/Sources/GateBar/GateBarApp.swift new file mode 100644 index 0000000..901fabd --- /dev/null +++ b/apps/gate-bar/Sources/GateBar/GateBarApp.swift @@ -0,0 +1,71 @@ +import SwiftUI +import AppKit + +/// fusionAIze Gate Bar β€” macOS menubar companion. +/// +/// Entry point. The app is a `MenuBarExtra` (Sonoma 14+) with a +/// window-style popover and a separate Settings scene. +/// +/// Note: `MenuBarExtra(_ :, isInserted:)` gives us a toggle for hiding the +/// icon entirely (future preference). The current cut always shows it. +@main +struct GateBarApp: App { + @StateObject private var preferences = Preferences() + @StateObject private var store: QuotaStore + + init() { + let prefs = Preferences() + _preferences = StateObject(wrappedValue: prefs) + _store = StateObject(wrappedValue: QuotaStore(preferences: prefs)) + } + + var body: some Scene { + MenuBarExtra { + PopoverView( + store: store, + preferences: preferences, + onOpenPreferences: { openPreferencesWindow() } + ) + .task { + // First fetch runs eagerly when the popover opens. A small + // price for fresh data versus waiting for the timer. + await store.refresh() + } + } label: { + MenuBarLabelView(summary: store.menuBarSummary) + } + .menuBarExtraStyle(.window) + + Settings { + PreferencesView(preferences: preferences) + } + } + + /// Programmatically open the Settings scene. The stock keyboard shortcut + /// (``⌘,``) also works, but the "Preferences…" button in the popover + /// footer gives a discoverable affordance. + private func openPreferencesWindow() { + NSApp.activate(ignoringOtherApps: true) + if #available(macOS 14, *) { + // Sonoma's standard Settings scene accepts this action. + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + } else { + NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) + } + } +} + +/// The menubar label: a coloured dot + "fAI Β· 83%" text. +/// +/// Rendered in the menubar's text colour so macOS keeps the contrast right +/// in both light and dark appearances. +struct MenuBarLabelView: View { + let summary: QuotaStore.MenuBarSummary + var body: some View { + HStack(spacing: 4) { + AlertDot(alert: summary.alert) + Text(summary.label) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + } + } +} diff --git a/apps/gate-bar/Sources/GateBar/Models.swift b/apps/gate-bar/Sources/GateBar/Models.swift new file mode 100644 index 0000000..1d39d27 --- /dev/null +++ b/apps/gate-bar/Sources/GateBar/Models.swift @@ -0,0 +1,196 @@ +import Foundation + +// MARK: - JSON models (mirror /api/quotas) +// +// These intentionally decode a *subset* of the gateway's response β€” only the +// fields the menubar actually renders. Extra fields in the JSON are ignored, +// so the Python side can add new fields without breaking Gate Bar. +// +// Contract source of truth: `faigate.quota_tracker.QuotaStatus.to_dict()` and +// the `/api/quotas` handler in `faigate/main.py`. Keep field names in sync. + +/// Top-level response from `GET /api/quotas`. +struct QuotaResponse: Decodable { + let packages: [QuotaPackage] + let byAlert: [String: Int]? + let catalogSuggestions: [CatalogSuggestion]? + let skippedPackages: [SkippedPackage]? + + enum CodingKeys: String, CodingKey { + case packages + case byAlert = "by_alert" + case catalogSuggestions = "catalog_suggestions" + case skippedPackages = "skipped_packages" + } +} + +/// One active package. The menubar groups these by `brandSlug` into cards. +struct QuotaPackage: Decodable, Identifiable { + let packageId: String + let providerId: String? + let providerGroup: String? + + // v1.3 brand pivot (see docs/GATE-BAR-DESIGN.md Β§1). + let brand: String + let brandSlug: String + let identity: Identity? + + let packageType: String? + let usedRatio: Double? + let elapsedRatio: Double? + let paceDelta: Double? + let alert: String? + let resetAt: String? + let projectedDaysLeft: Double? + + // Human-readable numerators/denominators for the under-bar line. + let usedDisplay: String? + let totalDisplay: String? + + // The dashboard labels each row by `package_name` (authored) β€” fall back + // to package_id when the catalog is pre-v1.3 or the field is missing. + let packageName: String? + + var id: String { packageId } + + enum CodingKeys: String, CodingKey { + case packageId = "package_id" + case providerId = "provider_id" + case providerGroup = "provider_group" + case brand + case brandSlug = "brand_slug" + case identity + case packageType = "package_type" + case usedRatio = "used_ratio" + case elapsedRatio = "elapsed_ratio" + case paceDelta = "pace_delta" + case alert + case resetAt = "reset_at" + case projectedDaysLeft = "projected_days_left" + case usedDisplay = "used_display" + case totalDisplay = "total_display" + case packageName = "package_name" + } +} + +/// Credential identity the operator sees under each brand header. +/// Always one of two shapes β€” env-var-style API key or OAuth subject. +struct Identity: Decodable, Equatable { + let loginMethod: String + let credential: String + + enum CodingKeys: String, CodingKey { + case loginMethod = "login_method" + case credential + } +} + +/// Catalog row the widget offers as "Available to add". Shape matches +/// `/api/quotas.catalog_suggestions`. +struct CatalogSuggestion: Decodable, Identifiable, Hashable { + let brand: String + let brandSlug: String + let tagline: String + + var id: String { brandSlug } + + enum CodingKeys: String, CodingKey { + case brand + case brandSlug = "brand_slug" + case tagline + } +} + +/// Inactive packages shown in the "Skipped" section (credential missing). +struct SkippedPackage: Decodable, Identifiable, Hashable { + let packageId: String + let brand: String? + let brandSlug: String? + let requires: String? + + var id: String { packageId } + + enum CodingKeys: String, CodingKey { + case packageId = "package_id" + case brand + case brandSlug = "brand_slug" + case requires + } +} + +// MARK: - Derived aggregates + +/// A brand card as rendered in the popover: every package for that brand, +/// plus the identity line (pulled from the first package β€” identity is +/// brand-wide by design). +struct BrandGroup: Identifiable, Hashable { + let brand: String + let brandSlug: String + let identity: Identity? + let packages: [QuotaPackage] + + var id: String { brandSlug } + + /// Worst `used_ratio` across this brand's packages. Drives card sort + /// order so the brand most likely to blow up is rendered first. + var maxUsedRatio: Double { + packages.compactMap { $0.usedRatio }.max() ?? 0 + } + + /// Highest alert severity across the brand. + var worstAlert: AlertLevel { + packages.map { AlertLevel(rawAlert: $0.alert, usedRatio: $0.usedRatio) } + .max(by: { $0.severity < $1.severity }) ?? .ok + } + + static func == (lhs: BrandGroup, rhs: BrandGroup) -> Bool { + lhs.brandSlug == rhs.brandSlug && lhs.packages.map { $0.packageId } == rhs.packages.map { $0.packageId } + } + + func hash(into hasher: inout Hasher) { + hasher.combine(brandSlug) + hasher.combine(packages.map { $0.packageId }) + } +} + +/// Severity levels that drive the progress-bar colour and the menubar dot. +/// Ordered so `max(by:)` picks "worst". +enum AlertLevel: String, Comparable { + case ok + case watch + case topup + case urgent + case exhausted + + var severity: Int { + switch self { + case .ok: return 0 + case .watch: return 1 + case .topup: return 2 + case .urgent: return 3 + case .exhausted: return 4 + } + } + + static func < (lhs: AlertLevel, rhs: AlertLevel) -> Bool { + lhs.severity < rhs.severity + } + + /// Classify a package. Prefers the server-labelled alert; falls back to + /// `used_ratio` thresholds (kept in sync with the widget's `classify()` + /// in `_QUOTAS_DASHBOARD_HTML`). + init(rawAlert: String?, usedRatio: Double?) { + if let raw = rawAlert, let level = AlertLevel(rawValue: raw) { + self = level + return + } + let pct = max(0, min(1, usedRatio ?? 0)) + switch pct { + case 1.0...: self = .exhausted + case 0.9...: self = .urgent + case 0.7...: self = .topup + case 0.5...: self = .watch + default: self = .ok + } + } +} diff --git a/apps/gate-bar/Sources/GateBar/PopoverView.swift b/apps/gate-bar/Sources/GateBar/PopoverView.swift new file mode 100644 index 0000000..57958b1 --- /dev/null +++ b/apps/gate-bar/Sources/GateBar/PopoverView.swift @@ -0,0 +1,241 @@ +import SwiftUI + +/// The menubar popover contents. Mirrors the web widget's page composition: +/// +/// 1. Active brand cards (sorted worst-alert first). +/// 2. A mini catalog "Available to add" block. +/// 3. A footer with Cockpit + Refresh controls. +/// +/// Skipped-package block is collapsed into a subtle footer line to keep the +/// popover short; the web widget is the place to inspect skipped entries in +/// detail. +struct PopoverView: View { + @ObservedObject var store: QuotaStore + @ObservedObject var preferences: Preferences + /// Parent (the MenuBarExtra scene) owns the settings window-presentation + /// so this view just signals intent. + var onOpenPreferences: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider().background(Theme.border) + content + Divider().background(Theme.border) + footer + } + .frame(width: 360) + .frame(minHeight: 200, maxHeight: 640) + .background(Theme.background) + .foregroundColor(Theme.foreground) + } + + // MARK: - Header + + private var header: some View { + HStack(alignment: .center, spacing: 8) { + Text("fusionAIze Gate Bar") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Theme.foreground) + Spacer(minLength: 8) + if store.isLoading { + ProgressView() + .controlSize(.small) + } + Text(lastRefreshLabel) + .font(.system(size: 10)) + .foregroundColor(Theme.dim) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + + private var lastRefreshLabel: String { + guard let refreshed = store.lastRefresh else { + return store.lastError == nil ? "never refreshed" : "offline" + } + let f = RelativeDateTimeFormatter() + f.unitsStyle = .short + return "updated \(f.localizedString(for: refreshed, relativeTo: Date()))" + } + + // MARK: - Main content + + @ViewBuilder + private var content: some View { + ScrollView { + VStack(alignment: .leading, spacing: 10) { + if let error = store.lastError { + errorBanner(error) + } + if store.brands.isEmpty && store.lastError == nil { + emptyBanner + } else { + ForEach(store.brands) { brand in + BrandCardView(brand: brand) + } + } + if !store.catalogSuggestions.isEmpty { + catalogBlock + } + if !store.skippedPackages.isEmpty { + skippedBlock + } + } + .padding(.horizontal, 12) + .padding(.vertical, 12) + } + } + + private var emptyBanner: some View { + VStack(alignment: .leading, spacing: 4) { + Text("No active providers") + .font(.system(size: 12, weight: .semibold)) + Text("Start the faigate gateway or check the gateway URL in Preferences.") + .font(.system(size: 11)) + .foregroundColor(Theme.dim) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.card) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + private func errorBanner(_ message: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text("Can't reach the gateway") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Theme.color(for: .urgent)) + Text(message) + .font(.system(size: 11)) + .foregroundColor(Theme.dim) + .lineLimit(3) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.card) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Theme.color(for: .urgent).opacity(0.5), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + // Design doc Β§3.3: max 6 rows, anything past collapses into "N more". + private var catalogBlock: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Available to add") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(Theme.dim) + .textCase(.uppercase) + .padding(.top, 4) + ForEach(store.catalogSuggestions.prefix(6)) { suggestion in + HStack(alignment: .firstTextBaseline) { + Text(suggestion.brand) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(Theme.foreground) + Text(suggestion.tagline) + .font(.system(size: 11)) + .foregroundColor(Theme.dim) + .lineLimit(1) + Spacer(minLength: 8) + Link("Add β†—", destination: cockpitLink(for: suggestion.brandSlug, path: "providers/add")) + .font(.system(size: 11)) + .foregroundColor(Theme.link) + } + } + if store.catalogSuggestions.count > 6 { + let extra = store.catalogSuggestions.count - 6 + Link("… \(extra) more in Cockpit β†—", destination: cockpitLink()) + .font(.system(size: 11)) + .foregroundColor(Theme.link) + } + } + } + + private var skippedBlock: some View { + Text("Skipped: \(store.skippedPackages.map { $0.brand ?? $0.packageId }.joined(separator: ", "))") + .font(.system(size: 10)) + .foregroundColor(Theme.dim) + .lineLimit(2) + .padding(.top, 4) + } + + // MARK: - Footer + + private var footer: some View { + HStack(spacing: 12) { + // Opens the gateway's /dashboard/quotas β€” the server-side + // redirect honors `dashboard.quotas.default_view`, so if the + // operator pinned a brand or Cockpit this button goes straight + // there. No client-side branching needed. + Link(destination: dashboardLink()) { + Text("Dashboard β†—") + .font(.system(size: 12)) + } + .foregroundColor(Theme.link) + + Link(destination: cockpitLink()) { + Text("Cockpit β†—") + .font(.system(size: 12)) + } + .foregroundColor(Theme.link) + + Button { + Task { await store.refresh() } + } label: { + Text("Refresh") + .font(.system(size: 12)) + } + .buttonStyle(.plain) + .foregroundColor(Theme.link) + + Spacer() + + Button { + onOpenPreferences() + } label: { + Text("Preferences…") + .font(.system(size: 12)) + } + .buttonStyle(.plain) + .foregroundColor(Theme.dim) + + Button { + NSApp.terminate(nil) + } label: { + Text("Quit") + .font(.system(size: 12)) + } + .buttonStyle(.plain) + .foregroundColor(Theme.dim) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + + /// Deep-link into the gateway's dashboard. The gateway's redirect + /// handler decides whether to land on the overview, a pinned brand, + /// or Cockpit, per the operator's ``dashboard.quotas.default_view`` + /// setting. + private func dashboardLink() -> URL { + let base = preferences.gatewayURL.hasSuffix("/") + ? String(preferences.gatewayURL.dropLast()) + : preferences.gatewayURL + return URL(string: "\(base)/dashboard/quotas") ?? URL(string: base)! + } + + private func cockpitLink(for brandSlug: String? = nil, path: String? = nil) -> URL { + let base = preferences.cockpitURL.hasSuffix("/") + ? String(preferences.cockpitURL.dropLast()) + : preferences.cockpitURL + if let brandSlug, let path { + let encoded = brandSlug.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? brandSlug + return URL(string: "\(base)/\(path)?brand=\(encoded)") ?? URL(string: base)! + } + if let path { + return URL(string: "\(base)/\(path)") ?? URL(string: base)! + } + return URL(string: base) ?? URL(string: "https://cockpit.fusionaize.ai")! + } +} diff --git a/apps/gate-bar/Sources/GateBar/Preferences.swift b/apps/gate-bar/Sources/GateBar/Preferences.swift new file mode 100644 index 0000000..72622d3 --- /dev/null +++ b/apps/gate-bar/Sources/GateBar/Preferences.swift @@ -0,0 +1,65 @@ +import Foundation + +/// User-visible preferences, persisted via `UserDefaults`. +/// +/// Uses `@Published` + `ObservableObject` per the design doc (Β§2, "no +/// `Observable`-only APIs β€” use `ObservableObject`") so the app builds on +/// macOS 14 Sonoma without needing the Observation framework. +final class Preferences: ObservableObject { + // Refresh cadence. CodexBar defaults to 5 min; we keep that (design doc + // Β§2.6). Manual = never auto-refresh. + enum RefreshInterval: Int, CaseIterable, Identifiable { + case manual = 0 + case oneMinute = 60 + case twoMinutes = 120 + case fiveMinutes = 300 + case fifteenMinutes = 900 + + var id: Int { rawValue } + + var displayName: String { + switch self { + case .manual: return "Manual" + case .oneMinute: return "Every 1 min" + case .twoMinutes: return "Every 2 min" + case .fiveMinutes: return "Every 5 min" + case .fifteenMinutes: return "Every 15 min" + } + } + } + + // Defaults suite β€” use standard so `defaults write com.fusionaize.gate-bar` + // can tweak settings without opening the preferences window. + private let defaults: UserDefaults + private enum Key { + static let gatewayURL = "gatewayURL" + static let refreshInterval = "refreshIntervalSeconds" + static let cockpitURL = "cockpitURL" + } + + @Published var gatewayURL: String { + didSet { defaults.set(gatewayURL, forKey: Key.gatewayURL) } + } + + @Published var cockpitURL: String { + didSet { defaults.set(cockpitURL, forKey: Key.cockpitURL) } + } + + @Published var refreshInterval: RefreshInterval { + didSet { defaults.set(refreshInterval.rawValue, forKey: Key.refreshInterval) } + } + + /// Safe defaults that Just Work on a fresh install. + /// Gateway default matches faigate's listen port (4001). + /// Cockpit default matches `_cockpit_base_url()` in the Python side. + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + self.gatewayURL = defaults.string(forKey: Key.gatewayURL) + ?? "http://127.0.0.1:4001" + self.cockpitURL = defaults.string(forKey: Key.cockpitURL) + ?? "https://cockpit.fusionaize.ai" + let rawInterval = defaults.object(forKey: Key.refreshInterval) as? Int + self.refreshInterval = RefreshInterval(rawValue: rawInterval ?? 300) + ?? .fiveMinutes + } +} diff --git a/apps/gate-bar/Sources/GateBar/PreferencesView.swift b/apps/gate-bar/Sources/GateBar/PreferencesView.swift new file mode 100644 index 0000000..d9fe96d --- /dev/null +++ b/apps/gate-bar/Sources/GateBar/PreferencesView.swift @@ -0,0 +1,57 @@ +import SwiftUI + +/// The Preferences window. Intentionally minimal β€” four controls, no +/// wizards. See docs/GATE-BAR-DESIGN.md Β§5 ("Preferences window"). +struct PreferencesView: View { + @ObservedObject var preferences: Preferences + + var body: some View { + Form { + Section("Gateway") { + TextField("Gateway URL", text: $preferences.gatewayURL) + .textFieldStyle(.roundedBorder) + .frame(minWidth: 260) + Text("The local faigate daemon, typically http://127.0.0.1:4001.") + .font(.footnote) + .foregroundColor(.secondary) + } + + Section("Operator Cockpit") { + TextField("Cockpit URL", text: $preferences.cockpitURL) + .textFieldStyle(.roundedBorder) + .frame(minWidth: 260) + Text("Opens in your default browser when you click Cockpit β†—.") + .font(.footnote) + .foregroundColor(.secondary) + } + + Section("Refresh") { + Picker("Refresh interval", selection: $preferences.refreshInterval) { + ForEach(Preferences.RefreshInterval.allCases) { interval in + Text(interval.displayName).tag(interval) + } + } + .pickerStyle(.menu) + } + + Section("Privacy") { + // Verbatim from the about-box copy in docs/GATE-BAR-DESIGN.md Β§5. + Text( + """ + Gate Bar reads usage data from your local fusionAIze Gate \ + daemon over 127.0.0.1. Account identifiers, plan names, \ + and login methods stay on this machine. Gate Bar never \ + connects to a fusionAIze-hosted service; the Cockpit link \ + just opens a web page in your default browser. + """ + ) + .font(.footnote) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .formStyle(.grouped) + .frame(width: 420) + .frame(minHeight: 380) + } +} diff --git a/apps/gate-bar/Sources/GateBar/QuotaClient.swift b/apps/gate-bar/Sources/GateBar/QuotaClient.swift new file mode 100644 index 0000000..77b646c --- /dev/null +++ b/apps/gate-bar/Sources/GateBar/QuotaClient.swift @@ -0,0 +1,90 @@ +import Foundation + +/// HTTP client for the local faigate gateway. +/// +/// Everything Gate Bar knows about its providers comes from this one +/// endpoint β€” the Python side is already the source of truth. That +/// discipline (see `docs/GATE-BAR-DESIGN.md` Β§5) keeps the menubar app free +/// of a hard-coded provider enum and lets the catalog evolve without a Gate +/// Bar release. +actor QuotaClient { + enum ClientError: LocalizedError { + case invalidURL(String) + case transport(Error) + case httpStatus(Int) + case decoding(Error) + + var errorDescription: String? { + switch self { + case .invalidURL(let raw): + return "Gateway URL is not valid: \(raw)" + case .transport(let err): + // Network errors get the URLError localized description β€” + // friendlier than the raw NSError string. + return (err as? URLError)?.localizedDescription + ?? err.localizedDescription + case .httpStatus(let code): + return "Gateway returned HTTP \(code)" + case .decoding: + return "Gateway response did not match the expected shape" + } + } + } + + private let session: URLSession + private let decoder: JSONDecoder + + init(session: URLSession = .shared) { + self.session = session + // Ignoring unknown keys is the default for Swift Decodable β€” nothing + // to configure there. We *do* want to parse ISO-8601 timestamps + // (`reset_at`) to Date eventually, but the UI renders them as + // strings today so we keep the decoder simple. + self.decoder = JSONDecoder() + } + + /// Fetch the current quota snapshot. + /// + /// - Parameter baseURL: `http://127.0.0.1:` (no trailing slash + /// required β€” the `/api/quotas` path is appended via URLComponents so + /// trailing slashes, query strings, etc. are all tolerated). + func fetchQuotas(baseURL: String) async throws -> QuotaResponse { + guard var components = URLComponents(string: baseURL) else { + throw ClientError.invalidURL(baseURL) + } + // Normalize path: ``/api/quotas`` regardless of the user's trailing + // slash habits. ``URL(string:relativeTo:)`` would drop a non-empty + // base path, which we don't want. + components.path = (components.path.hasSuffix("/") + ? components.path + "api/quotas" + : components.path + "/api/quotas") + components.query = nil + + guard let url = components.url else { + throw ClientError.invalidURL(baseURL) + } + + var request = URLRequest(url: url) + request.timeoutInterval = 8 + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("fusionaize-gate-bar", forHTTPHeaderField: "User-Agent") + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw ClientError.transport(error) + } + + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + throw ClientError.httpStatus(http.statusCode) + } + + do { + return try decoder.decode(QuotaResponse.self, from: data) + } catch { + throw ClientError.decoding(error) + } + } +} diff --git a/apps/gate-bar/Sources/GateBar/QuotaStore.swift b/apps/gate-bar/Sources/GateBar/QuotaStore.swift new file mode 100644 index 0000000..7c89ca1 --- /dev/null +++ b/apps/gate-bar/Sources/GateBar/QuotaStore.swift @@ -0,0 +1,134 @@ +import Foundation +import Combine + +/// Source of truth for the Gate Bar menubar & popover. +/// +/// Fetches `/api/quotas` on a timer (cadence driven by `Preferences`), +/// exposes three observable shapes the UI reads: +/// +/// - ``brands`` β€” grouped, sorted brand cards for the popover. +/// - ``catalogSuggestions`` / ``skippedPackages`` β€” tail blocks. +/// - ``menuBarSummary`` β€” the label + colour the menubar displays. +/// +/// `ObservableObject` + `@Published` so the app compiles on Sonoma without +/// the Observation framework. +@MainActor +final class QuotaStore: ObservableObject { + @Published private(set) var brands: [BrandGroup] = [] + @Published private(set) var catalogSuggestions: [CatalogSuggestion] = [] + @Published private(set) var skippedPackages: [SkippedPackage] = [] + + /// Last refresh attempt's result. `nil` means "never fetched yet". + @Published private(set) var lastError: String? = nil + @Published private(set) var lastRefresh: Date? = nil + @Published private(set) var isLoading: Bool = false + + private let client: QuotaClient + private let preferences: Preferences + private var timerCancellable: AnyCancellable? + private var prefsCancellable: AnyCancellable? + + init(client: QuotaClient = QuotaClient(), preferences: Preferences) { + self.client = client + self.preferences = preferences + + // Re-arm the timer whenever the refresh preference changes. + self.prefsCancellable = preferences.$refreshInterval + .sink { [weak self] interval in + self?.rearmTimer(interval: interval) + } + } + + /// Fetch once now. Idempotent; safe to call from the "Refresh now" + /// menu item or on app launch. + func refresh() async { + isLoading = true + defer { isLoading = false } + do { + let response = try await client.fetchQuotas(baseURL: preferences.gatewayURL) + self.apply(response) + self.lastError = nil + self.lastRefresh = Date() + } catch { + self.lastError = (error as? LocalizedError)?.errorDescription + ?? error.localizedDescription + } + } + + /// Rebuild the published shapes from a freshly decoded response. + /// Kept non-private so tests can drive the store without real HTTP. + func apply(_ response: QuotaResponse) { + // Group by brand_slug, preserving the server's package order within + // each card. Sorting is done in a second pass so the "worst alert + // first" rule is explicit and testable. + var grouped: [String: (brand: String, identity: Identity?, pkgs: [QuotaPackage])] = [:] + var order: [String] = [] + for pkg in response.packages { + if grouped[pkg.brandSlug] == nil { + grouped[pkg.brandSlug] = (pkg.brand, pkg.identity, []) + order.append(pkg.brandSlug) + } + grouped[pkg.brandSlug]?.pkgs.append(pkg) + } + + let unsorted: [BrandGroup] = order.compactMap { slug in + guard let entry = grouped[slug] else { return nil } + return BrandGroup( + brand: entry.brand, + brandSlug: slug, + identity: entry.identity, + packages: entry.pkgs + ) + } + + // Worst severity β†’ highest usage β†’ alphabetical. Keeps the card the + // operator most likely needs to act on at the top of the popover. + self.brands = unsorted.sorted { a, b in + if a.worstAlert != b.worstAlert { + return a.worstAlert > b.worstAlert + } + if a.maxUsedRatio != b.maxUsedRatio { + return a.maxUsedRatio > b.maxUsedRatio + } + return a.brand.localizedCaseInsensitiveCompare(b.brand) == .orderedAscending + } + + self.catalogSuggestions = response.catalogSuggestions ?? [] + self.skippedPackages = response.skippedPackages ?? [] + } + + // MARK: - Menubar summary + + /// Tightest-window percentage across every active package, plus the + /// worst alert level (drives the menubar colour dot). + /// ``label`` is always short enough to fit the macOS menubar (≀ 12 chars). + struct MenuBarSummary: Equatable { + let label: String + let alert: AlertLevel + } + + var menuBarSummary: MenuBarSummary { + let ratios = brands.flatMap { $0.packages } + .compactMap { $0.usedRatio } + guard let tightest = ratios.max() else { + return MenuBarSummary(label: "fAI", alert: .ok) + } + let worst = brands.map { $0.worstAlert }.max() ?? .ok + let pct = Int((max(0, min(1, tightest)) * 100).rounded()) + return MenuBarSummary(label: "fAI Β· \(pct)%", alert: worst) + } + + // MARK: - Timer + + private func rearmTimer(interval: Preferences.RefreshInterval) { + timerCancellable?.cancel() + timerCancellable = nil + guard interval != .manual else { return } + let seconds = TimeInterval(interval.rawValue) + timerCancellable = Timer.publish(every: seconds, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + Task { [weak self] in await self?.refresh() } + } + } +} diff --git a/apps/gate-bar/Sources/GateBar/Theme.swift b/apps/gate-bar/Sources/GateBar/Theme.swift new file mode 100644 index 0000000..71fdd9f --- /dev/null +++ b/apps/gate-bar/Sources/GateBar/Theme.swift @@ -0,0 +1,40 @@ +import SwiftUI + +/// Colour palette mirroring the web widget's CSS variables in +/// `_QUOTAS_DASHBOARD_HTML`. Keeping them in one place means the menubar +/// popover visually reads as the same product as the browser dashboard. +enum Theme { + static let background = Color(red: 0.059, green: 0.067, blue: 0.090) // #0f1117 + static let card = Color(red: 0.102, green: 0.114, blue: 0.153) // #1a1d27 + static let border = Color(red: 0.165, green: 0.184, blue: 0.239) // #2a2f3d + static let track = Color(red: 0.149, green: 0.165, blue: 0.212) // #262a36 + + static let foreground = Color(red: 0.902, green: 0.914, blue: 0.937) // #e6e9ef + static let mid = Color(red: 0.725, green: 0.757, blue: 0.820) // #b9c1d1 + static let dim = Color(red: 0.541, green: 0.576, blue: 0.651) // #8a93a6 + + static let accent = Color(red: 0.545, green: 0.361, blue: 0.965) // #8b5cf6 + static let link = Color(red: 0.376, green: 0.647, blue: 0.980) // #60a5fa + + // Alert levels β€” keep in sync with AlertLevel. + static func color(for alert: AlertLevel) -> Color { + switch alert { + case .ok: return Color(red: 0.290, green: 0.871, blue: 0.502) // #4ade80 + case .watch: return Color(red: 0.984, green: 0.749, blue: 0.141) // #fbbf24 + case .topup: return Color(red: 0.984, green: 0.573, blue: 0.235) // #fb923c + case .urgent: return Color(red: 0.937, green: 0.267, blue: 0.267) // #ef4444 + case .exhausted: return Color(red: 0.498, green: 0.114, blue: 0.114) // #7f1d1d + } + } +} + +/// Convenience for the "N Β· 83%" label β€” adds a coloured dot before the +/// text so the menubar reads at a glance even at low contrast. +struct AlertDot: View { + let alert: AlertLevel + var body: some View { + Circle() + .fill(Theme.color(for: alert)) + .frame(width: 8, height: 8) + } +} diff --git a/apps/gate-bar/Tests/GateBarTests/ModelsTests.swift b/apps/gate-bar/Tests/GateBarTests/ModelsTests.swift new file mode 100644 index 0000000..f027b5f --- /dev/null +++ b/apps/gate-bar/Tests/GateBarTests/ModelsTests.swift @@ -0,0 +1,135 @@ +import Testing +import Foundation +@testable import GateBar + +// Uses the Swift Testing framework (`swift test` picks it up automatically +// on Sonoma+). XCTest is intentionally avoided because it only ships with +// a full Xcode install β€” the Swift Testing framework is bundled with the +// Command Line Tools too, so CI and fresh dev machines get green tests +// without needing Xcode.app. + +// MARK: - JSON decoding + +private let sample = """ +{ + "packages": [ + { + "package_id": "anthropic-pro-5h-session", + "package_name": "Pro Β· 5-h session", + "provider_id": "anthropic-claude", + "provider_group": "anthropic", + "brand": "Claude", + "brand_slug": "claude", + "package_type": "rolling_window", + "used_ratio": 0.83, + "elapsed_ratio": 0.62, + "pace_delta": 0.21, + "alert": "topup", + "reset_at": "2026-04-19T22:00:00Z", + "used_display": "83 / 100", + "total_display": "100", + "identity": {"login_method": "OAuth", "credential": "claude-code"} + }, + { + "package_id": "deepseek-pay-as-you-go", + "package_name": "Pay-as-you-go", + "provider_id": "deepseek-chat", + "provider_group": "deepseek", + "brand": "DeepSeek", + "brand_slug": "deepseek", + "package_type": "credits", + "used_ratio": 0.0, + "elapsed_ratio": null, + "pace_delta": null, + "alert": "ok", + "projected_days_left": 42, + "used_display": "$0.00", + "total_display": "$28.42", + "identity": {"login_method": "API key", "credential": "DEEPSEEK_API_KEY"} + } + ], + "by_alert": {"topup": 1, "ok": 1}, + "catalog_suggestions": [ + {"brand": "Cursor", "brand_slug": "cursor", "tagline": "Pro Β· $20/mo Β· 500 fast req/mo"} + ], + "skipped_packages": [ + {"package_id": "qwen-free-daily", "brand": "Qwen", "brand_slug": "qwen", "requires": "qwen-portal"} + ] +} +""".data(using: .utf8)! + +@Suite("QuotaResponse JSON decode") +struct QuotaResponseDecodeTests { + @Test func decodesFullResponse() throws { + let resp = try JSONDecoder().decode(QuotaResponse.self, from: sample) + #expect(resp.packages.count == 2) + #expect(resp.catalogSuggestions?.count == 1) + #expect(resp.skippedPackages?.count == 1) + + let claude = resp.packages[0] + #expect(claude.brand == "Claude") + #expect(claude.brandSlug == "claude") + #expect(abs((claude.paceDelta ?? 0) - 0.21) < 1e-9) + #expect(claude.identity?.loginMethod == "OAuth") + #expect(claude.packageName == "Pro Β· 5-h session") + } + + @Test func creditsPackageSurfacesNilPace() throws { + let resp = try JSONDecoder().decode(QuotaResponse.self, from: sample) + let ds = resp.packages[1] + #expect(ds.paceDelta == nil) + #expect(ds.elapsedRatio == nil) + #expect(ds.projectedDaysLeft == 42) + } + + @Test func unknownFieldsAreIgnored() throws { + // The Python side adds fields every few releases. Gate Bar must + // decode forward-compatible responses without failing so we can + // evolve the contract in one direction at a time. + let jsonWithExtras = """ + { + "packages": [{ + "package_id": "x", + "brand": "X", + "brand_slug": "x", + "used_ratio": 0.1, + "alert": "ok", + "invented_field_nobody_asked_for": 42 + }], + "header_snapshots": {"x": {"dialect": "openai"}}, + "has_exhausted": false + } + """.data(using: .utf8)! + _ = try JSONDecoder().decode(QuotaResponse.self, from: jsonWithExtras) + } +} + +// MARK: - AlertLevel + +@Suite("AlertLevel classification") +struct AlertLevelTests { + @Test func serverAlertWinsOverRatio() { + #expect(AlertLevel(rawAlert: "exhausted", usedRatio: 0.01) == .exhausted) + } + + @Test func fallsBackToRatioThresholds() { + #expect(AlertLevel(rawAlert: nil, usedRatio: 0.0) == .ok) + #expect(AlertLevel(rawAlert: nil, usedRatio: 0.55) == .watch) + #expect(AlertLevel(rawAlert: nil, usedRatio: 0.75) == .topup) + #expect(AlertLevel(rawAlert: nil, usedRatio: 0.95) == .urgent) + #expect(AlertLevel(rawAlert: nil, usedRatio: 1.2) == .exhausted) + } + + @Test func unknownAlertStringFallsBackToRatio() { + // Defensive: the server might ship a new level before Gate Bar + // knows about it (e.g. "frozen"). Degrade to the ratio fallback, + // don't crash. + #expect(AlertLevel(rawAlert: "frozen", usedRatio: 0.72) == .topup) + } + + @Test func severityOrdering() { + #expect(AlertLevel.urgent > .topup) + #expect(AlertLevel.exhausted > .urgent) + #expect(!(AlertLevel.ok > .watch)) + } +} diff --git a/apps/gate-bar/Tests/GateBarTests/QuotaStoreTests.swift b/apps/gate-bar/Tests/GateBarTests/QuotaStoreTests.swift new file mode 100644 index 0000000..f2c2539 --- /dev/null +++ b/apps/gate-bar/Tests/GateBarTests/QuotaStoreTests.swift @@ -0,0 +1,125 @@ +import Testing +import Foundation +@testable import GateBar + +// Pure data-transform tests for the store: grouping, sorting, menubar +// summary. HTTP + timer behaviour is deliberately out of scope β€” it gets +// exercised end-to-end once the app is running against a live gateway. + +@Suite("QuotaStore transforms", .serialized) +@MainActor +struct QuotaStoreTests { + private func makeStore() -> QuotaStore { + // Fresh, transient UserDefaults so tests never leak state into the + // developer's real preferences plist. + let suite = UserDefaults(suiteName: "gate-bar-tests-\(UUID().uuidString)")! + let prefs = Preferences(defaults: suite) + return QuotaStore(preferences: prefs) + } + + private func pkg( + _ id: String, + brand: String, + slug: String, + alert: String, + ratio: Double, + identity: Identity? = nil + ) -> QuotaPackage { + let identityFragment: String + if let identity { + identityFragment = ",\"identity\":{\"login_method\":\"\(identity.loginMethod)\",\"credential\":\"\(identity.credential)\"}" + } else { + identityFragment = "" + } + let json = """ + { + "package_id": "\(id)", + "brand": "\(brand)", + "brand_slug": "\(slug)", + "alert": "\(alert)", + "used_ratio": \(ratio)\(identityFragment) + } + """ + return try! JSONDecoder().decode(QuotaPackage.self, from: Data(json.utf8)) + } + + @Test func groupsPackagesByBrandSlug() { + let store = makeStore() + store.apply(QuotaResponse( + packages: [ + pkg("a1", brand: "Claude", slug: "claude", alert: "ok", ratio: 0.2), + pkg("a2", brand: "Claude", slug: "claude", alert: "watch", ratio: 0.6), + pkg("b1", brand: "DeepSeek", slug: "deepseek", alert: "ok", ratio: 0.05), + ], + byAlert: nil, catalogSuggestions: nil, skippedPackages: nil + )) + #expect(store.brands.count == 2) + let claude = store.brands.first { $0.brandSlug == "claude" } + #expect(claude?.packages.count == 2) + } + + @Test func sortsWorstAlertFirst() { + let store = makeStore() + // DeepSeek is urgent β†’ should beat Claude's `topup`. + store.apply(QuotaResponse( + packages: [ + pkg("a1", brand: "Claude", slug: "claude", alert: "topup", ratio: 0.72), + pkg("b1", brand: "DeepSeek", slug: "deepseek", alert: "urgent", ratio: 0.92), + pkg("c1", brand: "Gemini", slug: "gemini", alert: "ok", ratio: 0.05), + ], + byAlert: nil, catalogSuggestions: nil, skippedPackages: nil + )) + #expect(store.brands.map { $0.brandSlug } == ["deepseek", "claude", "gemini"]) + } + + @Test func tiesBreakByMaxUsedRatioThenName() { + let store = makeStore() + store.apply(QuotaResponse( + packages: [ + pkg("a1", brand: "Bravo", slug: "bravo", alert: "watch", ratio: 0.55), + pkg("a2", brand: "Alpha", slug: "alpha", alert: "watch", ratio: 0.65), + pkg("a3", brand: "Charlie", slug: "charlie", alert: "watch", ratio: 0.55), + ], + byAlert: nil, catalogSuggestions: nil, skippedPackages: nil + )) + #expect(store.brands.map { $0.brand } == ["Alpha", "Bravo", "Charlie"]) + } + + @Test func menuBarSummaryPicksTightestWindow() { + let store = makeStore() + store.apply(QuotaResponse( + packages: [ + pkg("a1", brand: "Claude", slug: "claude", alert: "topup", ratio: 0.72), + pkg("b1", brand: "DeepSeek", slug: "deepseek", alert: "urgent", ratio: 0.94), + pkg("c1", brand: "Gemini", slug: "gemini", alert: "ok", ratio: 0.05), + ], + byAlert: nil, catalogSuggestions: nil, skippedPackages: nil + )) + let summary = store.menuBarSummary + #expect(summary.label == "fAI Β· 94%") + #expect(summary.alert == .urgent) + } + + @Test func menuBarSummaryEmptyFallsBackToIdle() { + let store = makeStore() + store.apply(QuotaResponse( + packages: [], byAlert: nil, catalogSuggestions: nil, skippedPackages: nil + )) + let summary = store.menuBarSummary + #expect(summary.label == "fAI") + #expect(summary.alert == .ok) + } + + @Test func identityComesFromFirstPackage() { + let store = makeStore() + let identity = Identity(loginMethod: "OAuth", credential: "claude-code") + store.apply(QuotaResponse( + packages: [ + pkg("a1", brand: "Claude", slug: "claude", alert: "ok", ratio: 0.2, identity: identity), + pkg("a2", brand: "Claude", slug: "claude", alert: "watch", ratio: 0.6), + ], + byAlert: nil, catalogSuggestions: nil, skippedPackages: nil + )) + #expect(store.brands.first?.identity == identity) + } +} diff --git a/apps/gate-bar/scripts/install-local.sh b/apps/gate-bar/scripts/install-local.sh new file mode 100755 index 0000000..e9c63b4 --- /dev/null +++ b/apps/gate-bar/scripts/install-local.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# +# Local install for Gate Bar β€” build, wrap, copy, ad-hoc sign. +# +# Why this exists: +# Gate Bar 0.2+ will ship a notarized .app via Homebrew cask. Until +# then, developers who want to try it on their own machine can run +# this script: it builds a release binary, wraps it in a minimal .app +# bundle, and copies it to ~/Applications/. Because the binary is +# built and ad-hoc code-signed on the same machine, Gatekeeper trusts +# it without a full Developer ID notarization round-trip. +# +# Usage: +# ./scripts/install-local.sh # build + install + open +# ./scripts/install-local.sh --no-open # don't launch after install +# ./scripts/install-local.sh --uninstall # remove ~/Applications/Gate Bar.app + +set -euo pipefail + +APP_NAME="Gate Bar" +APP_BUNDLE_ID="ai.fusionaize.gate-bar" +APP_VERSION="0.1.0" +APP_MIN_MACOS="14.0" +BIN_NAME="GateBar" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PKG_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +INSTALL_DIR="$HOME/Applications" +APP_PATH="$INSTALL_DIR/$APP_NAME.app" + +OPEN_AFTER_INSTALL=1 +for arg in "$@"; do + case "$arg" in + --no-open) + OPEN_AFTER_INSTALL=0 + ;; + --uninstall) + if [[ -d "$APP_PATH" ]]; then + echo "β†’ Removing $APP_PATH" + rm -rf "$APP_PATH" + echo "βœ“ Gate Bar uninstalled." + else + echo "β†’ Nothing to uninstall at $APP_PATH" + fi + exit 0 + ;; + -h|--help) + grep -E '^#( |$)' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "Unknown argument: $arg" >&2 + exit 2 + ;; + esac +done + +cd "$PKG_DIR" + +# ── 1. Build the release binary ───────────────────────────────────────────── +echo "β†’ Building $BIN_NAME (release)…" +swift build -c release + +BINARY_PATH="$(swift build -c release --show-bin-path)/$BIN_NAME" +if [[ ! -x "$BINARY_PATH" ]]; then + echo "βœ— Release binary not found at $BINARY_PATH" >&2 + exit 1 +fi +echo " built: $BINARY_PATH" + +# ── 2. Assemble the .app bundle in a staging dir ──────────────────────────── +STAGING="$(mktemp -d -t gatebar-install)" +trap 'rm -rf "$STAGING"' EXIT +BUNDLE="$STAGING/$APP_NAME.app" +mkdir -p "$BUNDLE/Contents/MacOS" + +cp "$BINARY_PATH" "$BUNDLE/Contents/MacOS/$BIN_NAME" +chmod +x "$BUNDLE/Contents/MacOS/$BIN_NAME" + +cat > "$BUNDLE/Contents/Info.plist" < + + + + CFBundleName + $APP_NAME + CFBundleDisplayName + $APP_NAME + CFBundleIdentifier + $APP_BUNDLE_ID + CFBundleExecutable + $BIN_NAME + CFBundlePackageType + APPL + CFBundleShortVersionString + $APP_VERSION + CFBundleVersion + $APP_VERSION + LSMinimumSystemVersion + $APP_MIN_MACOS + LSUIElement + + NSHighResolutionCapable + + NSSupportsAutomaticTermination + + NSSupportsSuddenTermination + + + +PLIST + +# PkgInfo β€” legacy metadata that some macOS versions still sniff. Four +# chars "APPL" + four chars creator code. "????" is the conventional +# placeholder for a no-creator app. +printf 'APPL????' > "$BUNDLE/Contents/PkgInfo" + +# ── 3. Ad-hoc code-sign so Gatekeeper trusts the bundle ───────────────────── +# The `-` identity means ad-hoc: no Developer ID, but macOS still records a +# signature that ties the bundle to this machine. First-run Gatekeeper +# prompt may still appear once; after that the app runs freely. +echo "β†’ Ad-hoc code-signing the bundle…" +codesign --force --deep --sign - "$BUNDLE" 2>&1 | sed 's/^/ /' || { + echo "βœ— codesign failed" >&2 + exit 1 +} +# Verify the signature actually took +codesign --verify --verbose=2 "$BUNDLE" 2>&1 | sed 's/^/ /' + +# ── 4. Move into place ────────────────────────────────────────────────────── +mkdir -p "$INSTALL_DIR" +if [[ -d "$APP_PATH" ]]; then + echo "β†’ Replacing existing $APP_PATH" + # Kill any running instance so the replace doesn't fail on a busy Mach-O. + pkill -x "$BIN_NAME" 2>/dev/null || true + rm -rf "$APP_PATH" +fi +mv "$BUNDLE" "$APP_PATH" +echo "βœ“ Installed to $APP_PATH" + +# ── 5. Hint at launch-at-login (manual β€” SMAppService needs a code-signed .app +# with a proper Developer ID; ad-hoc builds can't register. So we print +# a one-liner the operator can paste to use launchctl instead, which +# works without Developer ID.) ────────────────────────────────────────── +cat </usr/lib/... + Frameworks under /../Frameworks +# CLT layout: /Library/Developer/Frameworks + .../usr/lib +if [[ -d "$DEVDIR/Library/Developer/Frameworks" ]]; then + FRAMEWORKS_DIR="$DEVDIR/Library/Developer/Frameworks" + INTEROP_DIR="$DEVDIR/Library/Developer/usr/lib" +elif [[ -d "$DEVDIR/../SharedFrameworks" ]]; then + # Xcode.app β€” Testing.framework ships in a different location; fall back + # to letting swift test find it on its own. + exec swift test "$@" +else + FRAMEWORKS_DIR="$DEVDIR/Library/Developer/Frameworks" + INTEROP_DIR="$DEVDIR/Library/Developer/usr/lib" +fi + +cd "$(dirname "$0")/.." + +exec swift test \ + -Xswiftc -F -Xswiftc "$FRAMEWORKS_DIR" \ + -Xlinker -rpath -Xlinker "$FRAMEWORKS_DIR" \ + -Xlinker -rpath -Xlinker "$INTEROP_DIR" \ + "$@" diff --git a/docs/GATE-BAR-DESIGN.md b/docs/GATE-BAR-DESIGN.md new file mode 100644 index 0000000..9d08f4f --- /dev/null +++ b/docs/GATE-BAR-DESIGN.md @@ -0,0 +1,574 @@ +# Gate Bar & Quota Widget Redesign + +**Status:** Draft β€” v2.3.0 roadmap input. +**Scope:** (a) CodexBar-inspired refresh of the `/dashboard/quotas` widget in +fusionAIze brand, (b) new `fusionAIze Gate Bar` macOS menubar companion app, +(c) rename "Usage Dashboard" β†’ "Cockpit" and point it at the Operator Cockpit +in the default browser. + +## 0. Design-Thinking Lens + +This doc is written under a design-thinking discipline, not as a +feature-port of CodexBar. Every decision is checked against the operator's +actual day, and every element that doesn't pull its weight gets cut. The +lens stays visible here so future edits stay honest. + +### Empathize β€” the operator's day + +The operator is running faigate locally. A typical moment: + +> "I'm halfway through a Claude Code session, about to hand off a long +> review task. Before I kick it off, am I going to hit the 5-h window and +> get bumped to weekly overflow? Is there a cheaper lane I'm not using? +> Wait β€” did my Kilo credits already expire?" + +That moment takes 3–5 seconds of glancing before real work resumes. The +widget either answers it fast, or it fails. + +### Define β€” the widget's single job + +> **Tell the operator, in one glance, whether they're in trouble on any +> active provider β€” and quietly hint at value they're leaving on the +> table.** + +Everything else (adding providers, tuning lanes, exploring trends, auditing +routes) is a longer-lived task and belongs in the browser Cockpit. The +widget is a **glance surface, not a workspace.** + +### Decision rules we apply to every element + +1. **Value β‰₯ noise.** If an element doesn't change a decision an operator + would make, it doesn't ship. No vanity counters, no decorative charts. +2. **Scan before read.** Position, colour, and size must carry the meaning + before the operator reads a single word. Text is backup, not the + channel. +3. **Active over available.** Active providers come first, always. + Catalog/available-to-add comes second and never pushes active below the + fold on a 13" laptop. +4. **Glance first, drill-down on demand.** Default views answer the + 3-second question. Per-brand quick-view answers the 30-second question. + Cockpit answers the 5-minute question. +5. **Widget β‰  workspace.** No wizards, no forms, no writes. Every setup + flow is a deep link to Cockpit. +6. **Decision-ready data.** Anything we surface has to be enough to act on + β€” quota numbers show pace, catalog entries show pricing, drill-downs + show which client is burning the budget. Half-data is worse than no + data because it invites a second tab. + +Each of the following sections points back to these rules where it matters. + +--- + +## 1. Naming Pivot β€” brand names, not company names + +Today the quota widget groups by `provider_group`, which uses company/engine +keys (`anthropic`, `openai`, `deepseek`, …). That mirrors the router's +internal provider IDs but is not how operators think about their +subscriptions. + +Going forward the operator-facing surface uses **product/brand names**, the +same way CodexBar does: + +| Current `provider_group` | New display `brand` | What it means to the operator | +|--------------------------|-------------------------|-------------------------------------------| +| `anthropic` | **Claude** | Claude Pro subscription + Anthropic API | +| `openai` | **Codex** | ChatGPT / Codex CLI subscription | +| `gemini` | **Gemini** | Google AI Studio free tier | +| `deepseek` | **DeepSeek** | DeepSeek API credits | +| `kilocode` | **Kilo Code** | Kilo starter credits | +| `openrouter` | **OpenRouter** | OpenRouter credits + free daily | +| `qwen` | **Qwen** | Qwen free daily via OAuth | +| `blackbox` | **Blackbox** | Blackbox subscription + API | +| *(future)* | **Cursor**, **Droid**, **Antigravity**, **Copilot** | CodexBar parity roster | + +### Catalog change + +Introduce a `brand` field at package level (not a new grouping key). The +existing `provider_group` keeps routing-side semantics; `brand` is purely the +display label. + +```jsonc +"anthropic-pro-5h-session": { + "provider_id": "claude-sonnet-4.5", + "provider_group": "anthropic", // unchanged β€” routing key + "brand": "Claude", // NEW β€” display label + "brand_slug": "claude", // NEW β€” CSS class / asset key + "name": "Pro Β· 5-h session", // package label within the brand card + ... +} +``` + +Rules: + +- When `brand` is absent, fall back to a lookup table keyed on + `provider_group` (same table as above). No catalog migration breakage. +- The widget groups by `brand` (brand-slug, really), not `provider_group`. + Two packages with the same `brand` render as subsections of one card, even + if their `provider_group` differs (e.g. Claude Pro + Anthropic API both + belong under the `Claude` card). +- Router code stays on `provider_group` / `provider_id`. Nothing in the + scoring path reads `brand`. + +--- + +## 2. What to adopt from CodexBar + +Source: `docs/provider.md` in `steipete/CodexBar` + the four UI screenshots +the operator attached as `~/Desktop/codex-bar.html`. + +> **Key divergence from CodexBar:** CodexBar is a tab-switcher β€” you click +> Codex/Claude/Cursor/Gemini to see one provider at a time. That works for a +> menubar popover where space is tight. The **fusionAIze quota widget stays +> an all-providers-at-a-glance overview**; every active brand is visible +> simultaneously in a scrollable stack of brand cards, clustered by brand. +> The Gate Bar menubar popover can afford either layout, but the default is +> still "show every active brand at once, scroll if needed" because our +> operator routinely checks "how am I doing on everything right now?". +> Tabs/filters are a future opt-in, not the default. + +### Keep (maps cleanly onto faigate) + +1. **Brand clustering (not tabs).** CodexBar clusters by brand via tabs; + fusionAIze clusters by brand visually β€” one card per brand, all cards + rendered together. A brand with multiple packages (e.g. Claude has five) + stacks them as sub-rows inside the same card so the operator sees the + whole brand situation in one glance. +2. **Per-brand detail panel with two stacked progress bars.** + - top bar: "Session" (5-h window) or "Credits" if no session applies + - bottom bar: "Weekly" (7-day window) or "Daily" for free tiers + - thin red tick marker at the "pace" threshold β€” see Β§4. +3. **"Pace" indicator.** Expected-vs-actual usage at this point in the + window. CodexBar prints "Pace: +2.3 %" in small type. We adopt it because + it's the single most useful at-a-glance cue for a quota that resets. +4. **Identity line per brand.** CodexBar shows email + plan + login method, + siloed per provider. We already have this via `identity` in the OAuth + token store β€” surface `plan Β· login method` (e.g. "Pro Β· OAuth", + "API Β· env var `ANTHROPIC_API_KEY`"). +5. **Menubar percentage summary.** For Gate Bar: the menubar shows the + tightest window across all brands as `fAI Β· 83 %` with a colour dot. + CodexBar uses two stacked micro-bars as an icon β€” we copy the concept but + draw it with the fusionAIze glyph rather than a generic bar. +6. **Refresh-interval picker.** Manual / 1 / 2 / 5 / 15 min. CodexBar default + is 5 min; we keep that. +7. **"Identity stays local" posture.** All detection happens on-device; no + data leaves the machine. This is already how faigate behaves β€” call it + out explicitly in the Gate Bar about-box. + +### Skip / adapt + +- **CodexBar scrapes OpenAI's web dashboard via Safari/Chrome cookies.** We + do not scrape. If a browser-cookie source is ever added, it goes behind an + explicit opt-in and a per-provider toggle, never on by default. +- **CodexBar's `UsageProvider` compile-time enum.** We already have a + data-driven catalog. Keep it data-driven; do not hard-code a provider enum + in the Swift app either β€” the Gate Bar fetches its brand roster from the + running gateway at `GET /api/quotas` (see Β§5). +- **macOS 15+ requirement.** We target **macOS 14+ (Sonoma)** so a two-year- + old Intel MacBook Pro still runs the menubar. SwiftUI surfaces used: the + subset that ships in the Sonoma SDK (no `MeshGradient`, no `Observable`-only + APIs β€” use `ObservableObject`). +- **CLI-only fallbacks.** CodexBar falls back to `codex /status` parsing. We + already have first-class quota capture in the gateway (`quota_poller.py` + + header capture + local count); the Gate Bar is a pure consumer of the + gateway's `/api/quotas`. No PTY, no CLI parsing in the Swift code. + +--- + +## 3. Quota widget refresh (in the existing dashboard) + +The widget lives at `/dashboard/quotas` and **remains the one-page overview +of every active faigate provider** β€” that's its entire reason for existing. +The refresh keeps everything inside the existing HTML shell and fusionAIze +brand CSS. No framework change, no new asset pipeline. No tabs, no filters +by default β€” just tighter clustering by brand. + +Layout is a responsive grid: 1 column ≀ 640 px, 2 columns 640–1100 px, 3 +columns β‰₯ 1100 px. Every active brand is rendered as one card and all cards +are visible on arrival. Inactive brands (credential missing) appear only in +the "Skipped" block at the bottom, never in the main grid. + +### Visual spec (brand-card layout) + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Claude Pro Β· OAuth β”‚ ← brand header +β”‚ β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–“β–“β–“β–‘β–‘β–‘β–‘ 83 % Β· Pace +2 %β”‚ ← session bar +β”‚ Session Β· resets in 2 h 14 min β”‚ +β”‚ β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–“β–‘ 102 % β”‚ ← weekly bar +β”‚ Weekly Β· rolled over to monthly overflow (17 € / 17.26 €) β”‚ +β”‚ β”‚ +β”‚ [ Cockpit β†— ] [ Refresh ] last updated 12 s ago β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +- **Colours:** existing fusionAIze palette. Progress fill uses the accent + gradient below 80 %, warn-orange 80–100 %, alert-red above 100 %. +- **Pace marker:** a 2-px vertical tick inside the bar at the expected + position (elapsed fraction of the window). If actual > expected the tick + is behind the fill; ahead of the fill means "under pace". +- **Brand header:** brand wordmark-style `

` + right-aligned + identity line (`plan Β· login method`). +- **Stacked sub-packages:** if a brand has more than one package (Claude has + five), render them as stacked rows within one card, each with its own bar. + CodexBar doesn't do this β€” we do because operators want the overflow + package visible under the same umbrella as the Pro session. +- **Skipped (no credential):** the "Skipped packages" block already + exists at the bottom of the widget; keep it, but label with the brand + (e.g. "Qwen β€” no OAuth token found"). + +### Cockpit link (rename from "Usage Dashboard") + +The button currently reads "Usage Dashboard" and links to the widget itself. +Replace with a **Cockpit** button in the brand-card footer that opens the +fusionAIze Operator Cockpit in the system's default browser: + +```html +Cockpit β†— +``` + +The cockpit URL is read from `FAIGATE_COCKPIT_URL` (env / config), defaulting +to the public operator cockpit URL. `target="_blank"` + `rel="noopener"` is +the correct way to hand off to the OS default browser from the built-in +dashboard β€” no custom URL handler required. + +### Page composition β€” active first, catalog second + +The widget page is **two blocks, in this fixed order**: + +1. **Active providers** (the clustered brand-card grid from the visual spec + above). This is the primary surface β€” "what am I working with right + now?". Every brand with a usable credential renders a card here. +2. **Catalog Β· available to add** (new, mini). A compact list derived from + the shared fusionaize-metadata catalog, filtered to brands the operator + does **not** yet have active. Intentionally small β€” one line per brand, + no bars, no numbers, no setup UI inside the widget. + +That ordering is deliberate: the overview always answers "how am I doing?" +first, and only then whispers "here's what else exists". The catalog block +never pushes the active grid below the fold on a typical laptop viewport. + +The absorbed "Skipped packages" block stays as a third tier at the bottom β€” +packages the operator has in their local catalog but the credential is +missing/placeholder. Distinct from "catalog available to add", because +skipped entries are one env-var away from becoming active, whereas catalog +entries need a full onboarding flow. + +### Catalog Β· available to add (mini block) + +**Why this block exists (design-thinking check):** the operator cannot make +a "should I wire this up?" decision from a brand name alone. A brand name +without pricing is noise (rule 1: *value β‰₯ noise*). A brand name **with** +pricing and the quota shape is decision-ready (rule 6: *decision-ready +data*) β€” the operator sees "Cursor Β· Pro $20/mo Β· 500 fast/mo" and knows +in under two seconds whether it's worth the 3-minute onboarding in +Cockpit. The block's job is one thing: turn discovery into a fast yes/no. + +Visual: + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Available to add from catalog β”‚ +β”‚ β”‚ +β”‚ Cursor Pro Β· $20/mo Β· 500 fast req/mo Add in β”‚ +β”‚ Cockpit β†— β”‚ +β”‚ Droid free Β· 50 req/day Add in β”‚ +β”‚ Cockpit β†— β”‚ +β”‚ Antigravity early access Β· 200 free credits Add in β”‚ +β”‚ Cockpit β†— β”‚ +β”‚ Copilot Individual Β· $10/mo Β· unlimited Add in β”‚ +β”‚ Cockpit β†— β”‚ +β”‚ Mistral API pay-as-you-go Β· from $0.25 / 1M tok Add in β”‚ +β”‚ Cockpit β†— β”‚ +β”‚ Groq free tier Β· 14 400 req/day Add in β”‚ +β”‚ Cockpit β†— β”‚ +β”‚ β”‚ +β”‚ … 3 more Β· see all in Cockpit β†— β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +Row grammar β€” three scannable columns, in this order: + +1. **Brand** (bold, brand colour accent at 60 % opacity so it reads softer + than an active card). +2. **Tagline** = `tier Β· price Β· quota shape`. Designed to parse + left-to-right in a single glance: + - *tier*: `free`, `Pro`, `Individual`, `early access`, `pay-as-you-go` + - *price*: `$X/mo`, `free`, `from $X / 1M tok`, `X€ signup credits` + - *quota shape*: `N req/day`, `N fast req/mo`, `unlimited`, + `5-h session + monthly`, `OAuth free daily` + Any component can be omitted when genuinely absent (e.g. early-access + with no public price β†’ `early access Β· 200 free credits`). Never pad. +3. **CTA**: the same `Add in Cockpit β†—` link on every row β€” consistent CTA + means the eye doesn't re-orient per row. + +Rules: + +- **Data source:** the shared catalog at + `fusionaize-metadata/packages/catalog.v1.json` (already synced locally). + We already read this file β€” the widget just filters it by + `brand_slug NOT IN active_brands`. +- **Max 6 rows** by default; anything beyond collapses behind + "… N more Β· see all in Cockpit β†—". The widget is not a catalog browser. +- **New field:** `catalog_tagline` on the first package of each brand. + Authored (not auto-generated) β€” we want the copy to be tight and + comparable across brands. Falls back to `name` only as an emergency; a + missing tagline is a catalog bug worth fixing, not a rendering + fallback path we're proud of. +- **No setup UI in the widget.** Every row's CTA is the same: "Add in + Cockpit β†—", which opens + `${FAIGATE_COCKPIT_URL}/providers/add?brand=` in the default + browser. The onboarding flow (keys, OAuth, model picks, lane config) + lives in Cockpit and only in Cockpit. +- **No write paths** in the dashboard. The widget does not touch the + catalog file, does not write env vars, does not invoke wizards. Anything + that mutates state belongs to Cockpit. + +**Test criterion (design-thinking closing loop):** when we prototype, +watch the operator look at a catalog row and ask "what can I do with this +and what does it cost?". If the answer takes more than 2 seconds of +reading, the tagline is wrong β€” shorten it, don't enlarge the row. + +This is the design contract for the dashboard going forward: + +> The built-in dashboard is a **glance surface** for active providers plus +> a mini pointer at the catalog. All deeper data views and every flow that +> adds, edits, or configures a provider run in the browser Cockpit. + +### Per-brand detail view (drill-down from a card) + +Clicking a brand card navigates to `/dashboard/quotas/` β€” a +"quick view" that stitches together the four pieces an operator actually +needs when they zoom in on a single brand. It is intentionally a *subset* of +the Operator Cockpit, not a competitor to it; Cockpit stays the deep +analysis surface. + +Layout (single column, brand-themed accent): + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ ← Overview Claude β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ QUOTA β”‚ +β”‚ (identical block to the brand card on the overview β€” β”‚ +β”‚ session / weekly / credits bars, pace, identity) β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ CLIENTS (last 24 h) β”‚ +β”‚ β–‡β–‡β–‡β–‡β–‡β–‡ Claude Code 64 % Β· 412 req β”‚ +β”‚ β–‡β–‡β–‡β–‡ Cursor 22 % Β· 141 req β”‚ +β”‚ β–‡β–‡ faigate-stats CLI 9 % Β· 58 req β”‚ +β”‚ β–‡ aider 5 % Β· 31 req β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ ROUTES β”‚ +β”‚ claude-sonnet-4.5 β†’ anthropic (direct) 412 req β”‚ +β”‚ claude-haiku β†’ anthropic (direct) 141 req β”‚ +β”‚ claude-opus β†’ openrouter (fallback) 18 req β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ ANALYTICS (sparkline, last 24 h) β”‚ +β”‚ requests β–β–‚β–‚β–ƒβ–…β–†β–‡β–ˆβ–‡β–†β–…β–ƒβ–‚β–‚β–β–‚β–ƒβ–…β–†β–‡β–ˆβ–‡β–†β–… 642 β”‚ +β”‚ tokens β–β–‚β–ƒβ–„β–…β–†β–‡β–ˆβ–ˆβ–ˆβ–‡β–†β–…β–„β–ƒβ–‚β–β–‚β–ƒβ–„β–…β–†β–‡β–ˆβ–ˆ 1.8 M β”‚ +β”‚ cost ▁▁▂▂▃▃▄▅▆▆▇▇▇▆▅▄▃▃▂▂▁▁▂▂ 2.14 € β”‚ +β”‚ β”‚ +β”‚ [ Open in Cockpit β†— ] β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +Blocks in order of operator priority: + +1. **Quota** β€” the same brand card from the overview, full-width. No new + data fetch; reuses `/api/quotas`. +2. **Clients** β€” the top-N client apps that hit this brand in the last + 24 h. Data source: `client_app` tag on the SQLite request log (already + captured via Anthropic-bridge + OpenAI-compat user-agent sniffing). + New endpoint `GET /api/quotas//clients?window=24h`. +3. **Routes** β€” routing lanes that resolved to this brand in the same + window, showing model ID, provider the lane selected, and whether it + was `direct` / `fallback` / `cost-optimized`. Data source: the route + decision log we already write. New endpoint + `GET /api/quotas//routes?window=24h`. +4. **Analytics (small charts)** β€” three sparklines: requests, tokens, cost. + uPlot is already on the dashboard bundle; reuse it at `height: 40px`. + Data source: the same SQLite log, bucketed into 60 Γ— 24-min bins. + New endpoint `GET /api/quotas//analytics?window=24h`. +5. **Open in Cockpit β†—** β€” a brand-scoped Cockpit link + (`${FAIGATE_COCKPIT_URL}/providers/`) for when the operator + needs the full analysis view. + +The quick view is a **read-only composite**; it writes nothing, it is safe +to reload, and it degrades gracefully if any of the three new endpoints +fails (the section just collapses with an "unavailable" hint). + +### Default landing view (user-configurable) β€” **shipped v2.3** + +Operators have different "home screens" β€” one person opens the dashboard to +check Claude quota first, another always wants the full overview. + +Persisted setting `dashboard.quotas.default_view` has three options: + +| Value | Behavior on `/dashboard/quotas` | +|----------------------|-------------------------------------------------------------| +| `overview` (default) | Clustered all-brands grid. | +| `brand:` | 302 to `/dashboard/quotas/` (per-brand detail). | +| `cockpit` | 302 to `FAIGATE_COCKPIT_URL` in the same tab. | + +**Persistence (`faigate/dashboard_settings.py`):** + +- Stored in `config.yaml` under `dashboard.quotas.default_view` alongside + a mirrored `pinned_brand_slug` key (so UI code can render "pinned" + state without string-splitting `brand:`). +- Writes go through `ruamel.yaml` round-trip β†’ **every operator comment + in the 48 kB config survives a pin toggle**. Regular `yaml.safe_dump` + would flatten 220+ comment lines; we didn't take that trade. +- Writes are atomic (`tempfile.mkstemp` in the same dir + `os.replace`) + so a crash mid-write can't leave a half-rewritten config. + +**HTTP surface:** + +- `GET /api/dashboard/settings` β€” `{default_view, pinned_brand_slug}`. +- `POST /api/dashboard/settings` β€” body `{default_view}`; validates + against `overview` | `cockpit` | `brand:` (slug = `[a-z0-9-]+`). + Bad input β†’ 400. +- `GET /dashboard/quotas` β€” honors the setting via 302. The escape + hatch `GET /dashboard/quotas?view=overview` always renders the grid, + so a pinned brand card's "Home" link back to the overview works even + when the operator's default is a brand detail. + +**UI:** + +- Overview header shows the current Home view in the top-right, plus a + "Reset to Overview" chip when anything other than overview is pinned. +- Every brand card has a `Pin as Home` / `πŸ“Œ Home` button next to its + Details / Cockpit actions. Clicking toggles between + `default_view=brand:` and `overview`. +- The detail page header has the same button. +- Gate Bar's popover footer has a `Dashboard β†—` link to + `/dashboard/quotas`. The server-side redirect lands the + operator on whichever view they pinned β€” no client-side branching. + +--- + +## 4. "Pace" computation (shared between widget and Gate Bar) + +Defined in `quota_tracker.py` so both consumers get the same number. + +``` +pace_delta = used_ratio - elapsed_ratio +``` + +- `used_ratio` = `used / total` (0.0–1.0, clamped) +- `elapsed_ratio`= fraction of the reset window that has elapsed +- Formatted as `+X %` / `βˆ’X %` with one decimal; `0 %` rendered as "on pace". + +Only defined for `rolling_window` and `daily` packages. For `credits` +packages we surface `burn_per_day` and `projected_days_left` instead, +which we already compute. + +--- + +## 5. Gate Bar β€” macOS menubar companion + +**Target:** macOS 14+ (Sonoma) Universal binary (x86_64 + arm64). Linux port +deferred. Windows: not planned. + +### Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” http://127.0.0.1:/api/quotas +β”‚ fusionAIze Gate Bar β”‚ ──────────────────────────────────▢ faigate daemon +β”‚ (SwiftUI menubar) β”‚ ◀────────────────────────────────── JSON response +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + macOS Notification Center + (alert thresholds) +``` + +No shared memory, no Unix socket, no filesystem coupling. The Gate Bar is a +pure HTTP client to the local gateway. That means: + +- Gate Bar works the moment the gateway is running β€” no separate auth setup. +- Brand roster is discovered at runtime; the app ships with no hard-coded + provider enum. +- Uninstalling Gate Bar cannot corrupt gateway state. + +### Surfaces + +1. **Menubar icon + label:** `fAI Β· 83 %` (tightest window across all + brands, colour-coded). Click opens the popover. +2. **Popover:** reuses the visual spec from Β§3, rendered in SwiftUI. One + card per brand, stacked vertically, scrollable if taller than screen. +3. **Preferences window:** + - Refresh interval (manual / 1 / 2 / 5 / 15 min; default 5) + - Notification thresholds (session 80 %, weekly 80 %, pace +10 %) + - Gateway URL (default `http://127.0.0.1:4001`) + - Launch at login (`SMAppService.mainApp.register()`) +4. **"Cockpit β†—" button** in the popover footer β€” opens the Operator Cockpit + in the default browser via `NSWorkspace.shared.open(_:)`. + +### Distribution + +- Homebrew cask: `brew install --cask fusionaize/tap/gate-bar` +- Direct download: notarized `.dmg` from GitHub releases +- Auto-update via Sparkle 2 (EdDSA-signed appcast) +- Code-signed with a Developer ID Application certificate +- Notarized + stapled + +### Privacy posture (about-box copy) + +> Gate Bar reads usage data from your local fusionAIze Gate daemon over +> `127.0.0.1`. Account identifiers, plan names, and login methods stay on +> this machine. Gate Bar never connects to a fusionAIze-hosted service; the +> Cockpit link just opens a web page in your default browser. + +--- + +## 6. Rollout plan + +**Phase A β€” data model & naming (v2.2.x point release):** +- Add `brand` / `brand_slug` to catalog schema (`fusionaize-package-catalog/v1.3`). +- Fallback table in `quota_tracker.py` for catalogs still on v1.2. +- Extend `QuotaStatus` with `brand`, `brand_slug`, `pace_delta`, `identity`. + +**Phase B β€” widget refresh (v2.3.0):** +- Brand-card layout at `/dashboard/quotas`. +- "Cockpit β†—" link replaces "Usage Dashboard" button. +- Pace marker + identity line. +- Per-brand detail route `/dashboard/quotas/` with quota / + clients / routes / analytics blocks. +- New read-only endpoints: `/api/quotas//clients`, `/routes`, + `/analytics` (all `?window=24h` by default). +- `dashboard.quotas.default_view` setting + one-click pin. *Shipped β€” + ruamel.yaml round-trip keeps operator comments intact; redirect + honored server-side so Gate Bar doesn't need to branch.* +- "Available to add" mini catalog block at the bottom of the overview, + reading from the existing fusionaize-metadata catalog. New authored + field `catalog_tagline` = `tier Β· price Β· quota shape` (e.g. "Pro Β· + $20/mo Β· 500 fast req/mo", "free Β· 50 req/day"). Each row's CTA is a + deep link to `${FAIGATE_COCKPIT_URL}/providers/add?brand=` + β€” no onboarding UI inside the dashboard. + +**Phase C β€” Gate Bar 0.1 (v2.3.0 companion release):** +- SwiftUI project, macOS 14+ Universal. *Scaffolded at `apps/gate-bar/` + β€” SPM executable, `MenuBarExtra` + `Settings` scenes, popover with + brand cards, preferences, 13 Swift-Testing tests green.* +- Popover with brand cards, menubar icon, preferences. *Shipped.* +- Sparkle 2 auto-update, Homebrew cask. *Release-engineering pass β€” not + yet shipped; tracked in `apps/gate-bar/README.md` under "Roadmap".* + +**Phase D β€” CodexBar parity roster (v2.4+):** +- Add Cursor, Droid, Antigravity, Copilot brands to the catalog (with + credential gating β€” they only appear if keys/OAuth are present). + +--- + +## 7. Deferred / unresolved + +- **Homebrew formula dylib ID error on `pydantic-core`.** Two attempts at a + source rebuild in the formula post-install step failed to actually execute + the rebuild (no `==>` line, build time too short for a Rust compile). The + `Failed changing dylib ID` message is cosmetic (Python uses `dlopen`) but + noisy. Diagnosis needed before next tap bump. Tracked separately β€” do not + block v2.3 on it. +- **Linux port of Gate Bar.** Blocked on evaluating GTK4 vs Qt6 vs a Tauri + wrapper around the existing widget. Revisit after the macOS version is in + operator hands. diff --git a/faigate/__init__.py b/faigate/__init__.py index ee3cd09..6ab741d 100644 --- a/faigate/__init__.py +++ b/faigate/__init__.py @@ -1,3 +1,3 @@ """fusionAIze Gate package.""" -__version__ = "2.2.3" +__version__ = "2.3.0" diff --git a/faigate/dashboard_settings.py b/faigate/dashboard_settings.py new file mode 100644 index 0000000..861225e --- /dev/null +++ b/faigate/dashboard_settings.py @@ -0,0 +1,200 @@ +"""Dashboard settings β€” persisted under ``dashboard.quotas`` in config.yaml. + +The operator's config is a human-authored file (48kb+ with ~220 comment +lines in the reference install). Writing through ``yaml.safe_dump`` would +flatten all of that, so we round-trip through ``ruamel.yaml`` which +preserves comments, key order, and block/flow style. + +Scope is intentionally narrow: one nested block (``dashboard.quotas``) +with two keys: + +- ``default_view``: ``"overview"`` (the grid) | ``"brand:"`` (a + specific detail page) | ``"cockpit"`` (deep-link out). +- ``pinned_brand_slug``: redundant echo of the ``brand:`` target + so UI can tell "which card is currently pinned" without re-parsing + the default_view string. + +Reads are cheap (safe_load-style) and happen on every request. Writes +go through a POSIX atomic rename so a crash mid-write can't leave the +operator with a half-written config. +""" + +from __future__ import annotations + +import io +import logging +import os +import tempfile +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +# Allowed shapes for ``default_view``. ``brand:`` is validated +# separately (any non-empty slug matching ``[a-z0-9-]+`` is accepted). +_ALLOWED_FIXED_VIEWS = {"overview", "cockpit"} + + +def _config_path() -> Path: + """Resolve the same config.yaml path used by the rest of faigate.""" + env_path = os.environ.get("FAIGATE_CONFIG_FILE") or os.environ.get("FAIGATE_CONFIG_PATH") + if env_path: + return Path(env_path) + candidates = [ + Path(__file__).resolve().parent.parent / "config.yaml", + Path.cwd() / "config.yaml", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + raise FileNotFoundError("config.yaml not found") + + +def _slug_is_valid(value: str) -> bool: + if not value: + return False + for ch in value: + if not (ch.isdigit() or ("a" <= ch <= "z") or ch == "-"): + return False + return True + + +def validate_default_view(value: str) -> str: + """Return the canonical form or raise ``ValueError``.""" + candidate = (value or "").strip().lower() + if candidate in _ALLOWED_FIXED_VIEWS: + return candidate + if candidate.startswith("brand:"): + slug = candidate[len("brand:") :] + if _slug_is_valid(slug): + return f"brand:{slug}" + raise ValueError(f"default_view must be 'overview', 'cockpit', or 'brand:' β€” got {value!r}") + + +def get_settings(path: str | Path | None = None) -> dict[str, Any]: + """Return the ``dashboard.quotas`` block (or the empty defaults).""" + resolved = Path(path) if path else _config_path() + if not resolved.exists(): + return _default_settings() + # Use ruamel.yaml for reads too so we stay consistent with the writer + # (the main app still reads via pyyaml in config.py β€” that's fine, + # these two paths never produce config objects that feed each other). + from ruamel.yaml import YAML + + yaml = YAML(typ="rt") + yaml.preserve_quotes = True + try: + with resolved.open("r", encoding="utf-8") as handle: + data = yaml.load(handle) or {} + except Exception as exc: # noqa: BLE001 β€” any parse failure β†’ defaults + logger.warning("dashboard_settings: failed to parse %s: %s", resolved, exc) + return _default_settings() + dashboard = data.get("dashboard") if isinstance(data, dict) else None + quotas = dashboard.get("quotas") if isinstance(dashboard, dict) else None + if not isinstance(quotas, dict): + return _default_settings() + default_view = str(quotas.get("default_view") or "overview") + try: + default_view = validate_default_view(default_view) + except ValueError: + default_view = "overview" + pinned = quotas.get("pinned_brand_slug") + pinned_slug = str(pinned).strip().lower() if pinned else "" + if not _slug_is_valid(pinned_slug): + pinned_slug = "" + return {"default_view": default_view, "pinned_brand_slug": pinned_slug} + + +def set_default_view( + value: str, + *, + path: str | Path | None = None, +) -> dict[str, Any]: + """Update ``dashboard.quotas.default_view`` with comment-preserving write. + + Returns the new settings dict. Raises ``ValueError`` on a bad value; + never swallows filesystem errors (caller surfaces them to the HTTP + layer as a 5xx). + """ + canonical = validate_default_view(value) + resolved = Path(path) if path else _config_path() + + from ruamel.yaml import YAML + from ruamel.yaml.comments import CommentedMap + + yaml = YAML(typ="rt") + yaml.preserve_quotes = True + # Match the existing 2-space indent faigate's wizard writes. + yaml.indent(mapping=2, sequence=4, offset=2) + + if resolved.exists(): + with resolved.open("r", encoding="utf-8") as handle: + data = yaml.load(handle) + if data is None: + data = CommentedMap() + else: + # Brand-new config; extremely rare in this path but we honor it. + data = CommentedMap() + + dashboard = data.get("dashboard") + if not isinstance(dashboard, CommentedMap): + dashboard = CommentedMap() + data["dashboard"] = dashboard + + quotas = dashboard.get("quotas") + if not isinstance(quotas, CommentedMap): + quotas = CommentedMap() + dashboard["quotas"] = quotas + + quotas["default_view"] = canonical + # Mirror the brand slug (if any) into a dedicated key so UI can render + # "Home ‴ pinned on this card" without parsing ``brand:``. + if canonical.startswith("brand:"): + quotas["pinned_brand_slug"] = canonical[len("brand:") :] + else: + # Drop the pinned_brand_slug key when we're not pinning a brand. + # Comments on neighboring keys survive because ruamel keeps its + # CommentedMap node graph intact around the drop. + if "pinned_brand_slug" in quotas: + del quotas["pinned_brand_slug"] + + # Write atomically: render to string, write to a temp file in the + # same directory, then rename. Prevents half-written config.yaml on + # power loss / SIGKILL. + buffer = io.StringIO() + yaml.dump(data, buffer) + rendered = buffer.getvalue() + + tmp_fd, tmp_path = tempfile.mkstemp( + prefix=".dashboard_settings.", + suffix=".yaml.tmp", + dir=str(resolved.parent), + ) + try: + with os.fdopen(tmp_fd, "w", encoding="utf-8") as handle: + handle.write(rendered) + os.replace(tmp_path, resolved) + except Exception: + # Best-effort cleanup; swallow the unlink error so the caller + # sees the original write failure. + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + return { + "default_view": canonical, + "pinned_brand_slug": canonical[len("brand:") :] if canonical.startswith("brand:") else "", + } + + +def _default_settings() -> dict[str, Any]: + return {"default_view": "overview", "pinned_brand_slug": ""} + + +__all__ = [ + "get_settings", + "set_default_view", + "validate_default_view", +] diff --git a/faigate/main.py b/faigate/main.py index 757a75a..05934a1 100644 --- a/faigate/main.py +++ b/faigate/main.py @@ -10,6 +10,7 @@ import argparse import asyncio +import html as html_lib import json import logging import mimetypes @@ -26,7 +27,7 @@ from typing import Any from fastapi import FastAPI, Request -from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse from starlette.datastructures import UploadFile from . import __version__ @@ -2864,7 +2865,14 @@ def _credential_available(hint: str | None) -> bool: def _filter_packages_by_credentials( packages: dict[str, dict[str, Any]], ) -> tuple[dict[str, dict[str, Any]], list[dict[str, str]]]: - """Return (kept, skipped) after applying ``_requires_credential`` gating.""" + """Return (kept, skipped) after applying ``_requires_credential`` gating. + + Skipped entries carry the brand label so the widget can render + "Qwen β€” no OAuth token" instead of "qwen-free-daily". Falls back to the + provider_group-derived brand when the catalog is pre-v1.3. + """ + from .quota_tracker import _derive_brand, _slugify_brand # local import to avoid cycles + kept: dict[str, dict[str, Any]] = {} skipped: list[dict[str, str]] = [] for pkg_id, pkg in packages.items(): @@ -2872,10 +2880,15 @@ def _filter_packages_by_credentials( if _credential_available(hint): kept[pkg_id] = pkg else: + provider_group = str(pkg.get("provider_group") or "") + brand = str(pkg.get("brand") or _derive_brand(provider_group)) + brand_slug = str(pkg.get("brand_slug") or _slugify_brand(brand)) skipped.append( { "package_id": pkg_id, - "provider_group": str(pkg.get("provider_group") or ""), + "provider_group": provider_group, + "brand": brand, + "brand_slug": brand_slug, "requires": str(hint or ""), } ) @@ -2942,6 +2955,27 @@ async def quotas(): key = str(s.get("alert") or "unknown") by_alert[key] = by_alert.get(key, 0) + 1 + # "Available to add" β€” brands that exist in the shared catalog but have + # no active package on this machine. Filters out brands the operator + # already uses so the widget's mini-block doesn't nudge them toward + # something already wired up. + active_brand_slugs = {str(s.get("brand_slug") or "") for s in statuses_json} + catalog_suggestions: list[dict[str, str]] = [] + seen_slugs: set[str] = set() + for pkg_id, pkg in raw_packages.items(): + brand_slug = str(pkg.get("brand_slug") or "") + if not brand_slug or brand_slug in active_brand_slugs or brand_slug in seen_slugs: + continue + tagline = pkg.get("catalog_tagline") or pkg.get("name") or pkg_id + catalog_suggestions.append( + { + "brand": str(pkg.get("brand") or ""), + "brand_slug": brand_slug, + "tagline": str(tagline), + } + ) + seen_slugs.add(brand_slug) + return { "packages": statuses_json, "count": len(statuses_json), @@ -2950,6 +2984,7 @@ async def quotas(): "has_exhausted": any(s.get("alert") == "exhausted" for s in statuses_json), "header_snapshots": snapshots_out, "skipped_packages": skipped_packages, + "catalog_suggestions": catalog_suggestions, } @@ -3424,60 +3459,81 @@ async def dashboard(): -

Quotas

-
- Live view of all configured packages β€” updated every 60s. Source: - /api/quotas +
+
+

Quotas

+
+ Live view of every active provider β€” updated every 60s. Raw feed: + /api/quotas +
+
+
Loading…
-
+
+
+ + +""" + + +# ───────────────────────────────────────────────────────────────────────────── +# Per-brand detail view β€” served at ``/dashboard/quotas/``. Shares +# the visual vocabulary of the overview (same CSS variables, same bar/pace +# look) so operators don't have to retrain their eyes when drilling in. +# +# Design-Thinking note (see docs/GATE-BAR-DESIGN.md Β§3): +# - Quick-view: quota card, clients, routes, small analytics chart. +# - Read-only: every write path links out to the Cockpit. +# - Glance-before-read: totals row up top, tables below. +# ───────────────────────────────────────────────────────────────────────────── +_QUOTAS_BRAND_DETAIL_HTML = """ + + + + faigate Β· Brand detail + + + + + + + +
+
+

Loading…

+
Fetching brand context…
+
+ +
+ +
+
Requests (24h)
β€”
+
Failures
β€”
+
Tokens
β€”
+
Cost
β€”
+
+ +
+ +
+
+

Quota

+
Loading…
+
+ +
+

Activity (last 24h)

+ + + + + +
+ +
+

Clients

+
Loading…
+
+ +
+

Routes & lanes

+
Loading…
+
+
+ + @@ -3635,10 +4390,300 @@ async def dashboard(): """ +def _cockpit_base_url() -> str: + """Resolve the Operator Cockpit base URL the widget links out to. + + Read order: ``FAIGATE_COCKPIT_URL`` env var β†’ public default. Trailing + slash is stripped so deep links compose cleanly (``${base}/providers/…``). + """ + url = os.environ.get("FAIGATE_COCKPIT_URL") or "https://cockpit.fusionaize.ai" + return url.rstrip("/") + + @app.get("/dashboard/quotas", response_class=HTMLResponse) -async def dashboard_quotas(): - """Self-contained quotas page. Polls /api/quotas every 60s.""" - return _QUOTAS_DASHBOARD_HTML +async def dashboard_quotas(request: Request): + """Self-contained quotas page. Polls /api/quotas every 60s. + + Honors ``dashboard.quotas.default_view`` from config.yaml: + + - ``"overview"`` β€” render the grid (default). + - ``"brand:"`` β€” 302 to ``/dashboard/quotas/``. + - ``"cockpit"`` β€” 302 to the Operator Cockpit. + + ``?view=overview`` always forces the grid, so a pinned brand card + can link back home without the redirect fighting the user. + """ + override = (request.query_params.get("view") or "").strip().lower() + if override == "overview": + return HTMLResponse(_QUOTAS_DASHBOARD_HTML.replace("__COCKPIT_URL__", _cockpit_base_url())) + + from .dashboard_settings import get_settings + + try: + settings = get_settings() + except Exception as exc: # noqa: BLE001 β€” never break the dashboard on a settings read + logger.warning("dashboard_quotas: settings read failed, falling back to overview: %s", exc) + settings = {"default_view": "overview", "pinned_brand_slug": ""} + + default_view = str(settings.get("default_view") or "overview") + if default_view == "cockpit": + return RedirectResponse(url=_cockpit_base_url(), status_code=302) + if default_view.startswith("brand:"): + slug = default_view[len("brand:") :] + if slug: + return RedirectResponse(url=f"/dashboard/quotas/{slug}", status_code=302) + + return HTMLResponse(_QUOTAS_DASHBOARD_HTML.replace("__COCKPIT_URL__", _cockpit_base_url())) + + +@app.get("/api/dashboard/settings") +async def api_dashboard_settings_get(): + """Read-only view of ``dashboard.quotas.*`` settings. + + Used by the overview HTML (to show "pinned" state on a card) and by + the Gate Bar menubar app (to decide which page "Open Dashboard" should + land on β€” but we let the server-side redirect do the work, so the + Gate Bar just opens ``/dashboard/quotas`` without branching logic). + """ + from .dashboard_settings import get_settings + + try: + return get_settings() + except Exception as exc: # noqa: BLE001 + logger.warning("api_dashboard_settings_get failed: %s", exc) + return {"default_view": "overview", "pinned_brand_slug": ""} + + +@app.post("/api/dashboard/settings") +async def api_dashboard_settings_post(request: Request): + """Update ``dashboard.quotas.default_view``. + + Body (JSON): ``{"default_view": "overview" | "cockpit" | "brand:"}``. + Returns the canonical settings dict after the write, or 400 on bad + input. Config file writes go through an atomic rename; comments and + key order in ``config.yaml`` are preserved via ruamel.yaml. + """ + from .dashboard_settings import set_default_view, validate_default_view + + try: + payload = await request.json() + except Exception: # noqa: BLE001 + return JSONResponse({"error": "invalid JSON body"}, status_code=400) + + if not isinstance(payload, dict): + return JSONResponse({"error": "body must be a JSON object"}, status_code=400) + + raw_view = payload.get("default_view") + if not isinstance(raw_view, str): + return JSONResponse({"error": "default_view must be a string"}, status_code=400) + + try: + validate_default_view(raw_view) + except ValueError: + # Don't echo the raw exception text β€” it may contain user-supplied + # fragments that a future refactor could forward to a templated + # error page. Keep the surface message static; the real cause is + # knowable from the client (it just sent the value). + return JSONResponse({"error": "default_view is not a valid view identifier"}, status_code=400) + + try: + return set_default_view(raw_view) + except FileNotFoundError: + logger.exception("api_dashboard_settings_post: config.yaml missing") + return JSONResponse({"error": "config.yaml not found"}, status_code=500) + except Exception: # noqa: BLE001 + logger.exception("api_dashboard_settings_post: write failed") + return JSONResponse({"error": "write failed"}, status_code=500) + + +def _brand_context(brand_slug: str) -> dict[str, Any] | None: + """Resolve a ``brand_slug`` to the runtime providers feeding it. + + Returns ``None`` when the brand is unknown or has no active packages on + this machine β€” the endpoints then 404 rather than silently returning + empty payloads, so the widget can distinguish "typo in URL" from + "brand exists but no traffic yet". + + The returned dict has: + - ``brand``: display name (e.g. ``"Claude"``) + - ``brand_slug``: echoed back for the client + - ``providers``: sorted list of ``provider_id`` strings feeding this + brand (the ``requests.provider`` column values to filter on) + - ``packages``: the list of active package dicts (for the detail + header β€” package names, tiers, pace markers) + """ + from .provider_catalog import get_packages_catalog + from .quota_tracker import _derive_brand, _slugify_brand + + slug = (brand_slug or "").strip().lower() + if not slug: + return None + + try: + raw_packages = get_packages_catalog() or {} + except Exception as exc: # noqa: BLE001 + logger.warning("get_packages_catalog failed in _brand_context: %s", exc) + raw_packages = {} + + filtered_packages, _ = _filter_packages_by_credentials(raw_packages) + + providers: set[str] = set() + packages: list[dict[str, Any]] = [] + brand_name = "" + for pkg in filtered_packages.values(): + pkg_brand = str(pkg.get("brand") or _derive_brand(pkg.get("provider_group") or "")) + pkg_slug = str(pkg.get("brand_slug") or _slugify_brand(pkg_brand)) + if pkg_slug != slug: + continue + if not brand_name: + brand_name = pkg_brand + pid = str(pkg.get("provider_id") or "").strip() + if pid: + providers.add(pid) + packages.append(pkg) + + if not packages: + return None + + return { + "brand": brand_name or slug.title(), + "brand_slug": slug, + "providers": sorted(providers), + "packages": packages, + } + + +@app.get("/api/quotas/{brand_slug}/clients") +async def api_quota_brand_clients(brand_slug: str): + """Clients (profile + tag) that hit this brand's providers. + + Read-only aggregation over the SQLite ``requests`` table, scoped to the + providers feeding the given brand. Used by the per-brand detail view + (``/dashboard/quotas/``). Returns ``404`` when the brand has no + active packages β€” consistent with the widget's "active-first" story. + """ + ctx = _brand_context(brand_slug) + if ctx is None: + return JSONResponse({"error": {"message": "Unknown brand"}}, status_code=404) + + if not ctx["providers"]: + return { + "brand": ctx["brand"], + "brand_slug": ctx["brand_slug"], + "providers": [], + "clients": [], + "client_totals": [], + } + + filters = {"providers": ctx["providers"]} + return { + "brand": ctx["brand"], + "brand_slug": ctx["brand_slug"], + "providers": ctx["providers"], + "clients": _metrics.get_client_breakdown(**filters), + "client_totals": _metrics.get_client_totals(**filters), + } + + +@app.get("/api/quotas/{brand_slug}/routes") +async def api_quota_brand_routes(brand_slug: str): + """Lane-family + routing breakdown for a brand's providers. + + Feeds the "Routes" panel in the per-brand detail view. Mirrors the shape + of ``/api/stats.routing`` / ``.lane_families`` but scoped to a single + brand so the widget can render a focused table without client-side + filtering. + """ + ctx = _brand_context(brand_slug) + if ctx is None: + return JSONResponse({"error": {"message": "Unknown brand"}}, status_code=404) + + if not ctx["providers"]: + return { + "brand": ctx["brand"], + "brand_slug": ctx["brand_slug"], + "providers": [], + "lane_families": [], + "routing": [], + "selection_paths": [], + } + + filters = {"providers": ctx["providers"]} + return { + "brand": ctx["brand"], + "brand_slug": ctx["brand_slug"], + "providers": ctx["providers"], + "lane_families": _metrics.get_lane_family_breakdown(**filters), + "routing": _metrics.get_routing_breakdown(**filters), + "selection_paths": _metrics.get_selection_path_breakdown(**filters), + } + + +@app.get("/api/quotas/{brand_slug}/analytics") +async def api_quota_brand_analytics(brand_slug: str, hours: int = 24, days: int = 14): + """Time-series + totals for a brand's providers. + + Returns ``totals``, ``providers`` (per-provider summary), ``hourly`` + (last N hours), and ``daily`` (last N days) β€” enough for the detail + view's sparkline + the single-number summary cards. Defaults cover a + 2-week window so most operators see a meaningful chart on first load. + """ + ctx = _brand_context(brand_slug) + if ctx is None: + return JSONResponse({"error": {"message": "Unknown brand"}}, status_code=404) + + # Clamp to sane ranges so a typo can't hammer the DB with a 100-year + # scan. These are pulled straight from the widget's controls, so the + # upper bounds match the UI options we expose. + hours = max(1, min(int(hours or 24), 24 * 7)) + days = max(1, min(int(days or 14), 90)) + + if not ctx["providers"]: + return { + "brand": ctx["brand"], + "brand_slug": ctx["brand_slug"], + "providers": [], + "totals": {}, + "provider_summary": [], + "hourly": [], + "daily": [], + } + + filters = {"providers": ctx["providers"]} + return { + "brand": ctx["brand"], + "brand_slug": ctx["brand_slug"], + "providers": ctx["providers"], + "totals": _metrics.get_totals(**filters), + "provider_summary": _metrics.get_provider_summary(**filters), + "hourly": _metrics.get_hourly_series(hours, **filters), + "daily": _metrics.get_daily_totals(days, **filters), + } + + +@app.get("/dashboard/quotas/{brand_slug}", response_class=HTMLResponse) +async def dashboard_quota_brand(brand_slug: str): + """Per-brand detail view β€” single-brand quota card + clients + routes + analytics. + + Renders the same shell for every brand; JS pulls the four data sources + (``/api/quotas``, ``/api/quotas//{clients,routes,analytics}``) on + load and every 60s. Unknown-brand 404s are handled client-side by + showing the "Back to overview" link. + """ + slug = (brand_slug or "").strip().lower() + # Strict whitelist: the slug is spliced into an HTML template below via + # str.replace, so anything outside this grammar would be a reflected-XSS + # vector. Valid catalog slugs (claude/codex/gemini/…) all fit. + if not slug or not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,63}", slug): + return JSONResponse({"error": {"message": "Unknown brand"}}, status_code=404) + # Belt-and-suspenders: even though the regex above already guarantees the + # slug is safe, run it through html.escape so CodeQL's taint tracker + # (which doesn't recognise arbitrary regex sanitisers) can prove it. + safe_slug = html_lib.escape(slug, quote=True) + html = _QUOTAS_BRAND_DETAIL_HTML + html = html.replace("__COCKPIT_URL__", _cockpit_base_url()) + html = html.replace("__BRAND_SLUG__", safe_slug) + return html @app.get("/dashboard/assets/{asset_kind}/{asset_name:path}") diff --git a/faigate/metrics.py b/faigate/metrics.py index f554264..7b05c3c 100644 --- a/faigate/metrics.py +++ b/faigate/metrics.py @@ -425,33 +425,37 @@ def get_modality_breakdown(self, **filters: Any) -> list[dict]: params, ) - def get_hourly_series(self, hours: int = 24) -> list[dict]: + def get_hourly_series(self, hours: int = 24, **filters: Any) -> list[dict]: cutoff = time.time() - hours * 3600 + filters = {**filters, "since": cutoff} + where_sql, params = self._build_where_clause(filters) return self._q( - """ + f""" SELECT CAST((timestamp-?)/3600 AS INTEGER) AS hour_offset, COUNT(*) AS requests, ROUND(SUM(cost_usd),6) AS cost_usd, SUM(prompt_tok+compl_tok) AS tokens - FROM requests WHERE timestamp>=? + FROM requests{where_sql} GROUP BY hour_offset ORDER BY hour_offset """, - (cutoff, cutoff), + (cutoff, *params), ) - def get_daily_totals(self, days: int = 30) -> list[dict]: + def get_daily_totals(self, days: int = 30, **filters: Any) -> list[dict]: cutoff = time.time() - days * 86400 + filters = {**filters, "since": cutoff} + where_sql, params = self._build_where_clause(filters) return self._q( - """ + f""" SELECT DATE(timestamp,'unixepoch','localtime') AS day, COUNT(*) AS requests, ROUND(SUM(cost_usd),6) AS cost_usd, SUM(prompt_tok+compl_tok) AS tokens, SUM(CASE WHEN success=0 THEN 1 ELSE 0 END) AS failures - FROM requests WHERE timestamp>=? + FROM requests{where_sql} GROUP BY day ORDER BY day """, - (cutoff,), + params, ) def get_operator_events(self, limit: int = 50, **filters: Any) -> list[dict]: @@ -552,6 +556,29 @@ def _build_where_clause(self, filters: dict[str, Any]) -> tuple[str, tuple[Any, clauses.append("success = ?") params.append(1 if bool(success) else 0) + # Multi-provider filter (``provider IN (...)``). Used by per-brand + # detail endpoints that aggregate across every runtime provider + # belonging to a brand. Deduped + order-preserved so query plans stay + # cache-friendly. + providers = filters.get("providers") + if providers: + unique: list[str] = [] + seen: set[str] = set() + for item in providers: + key = str(item) + if key and key not in seen: + seen.add(key) + unique.append(key) + if unique: + placeholders = ",".join("?" * len(unique)) + clauses.append(f"provider IN ({placeholders})") + params.extend(unique) + + since = filters.get("since") + if since not in (None, ""): + clauses.append("timestamp >= ?") + params.append(float(since)) + if not clauses: return "", () return f" WHERE {' AND '.join(clauses)}", tuple(params) diff --git a/faigate/quota_headers.py b/faigate/quota_headers.py index 288e8c1..6f63151 100644 --- a/faigate/quota_headers.py +++ b/faigate/quota_headers.py @@ -54,7 +54,7 @@ import threading from collections.abc import Mapping from dataclasses import dataclass, field -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Any from .quota_tracker import update_package_usage @@ -136,7 +136,7 @@ def _parse_reset(val: Any, *, now: datetime | None = None) -> datetime | None: secs = float(raw) if secs < 0: return None - base = now or datetime.now(UTC) + base = now or datetime.now(timezone.utc) # Sanity: seconds-delta should fit in ~24h for rate-limit resets. if secs < 86400 * 2: return base + timedelta(seconds=secs) @@ -147,8 +147,8 @@ def _parse_reset(val: Any, *, now: datetime | None = None) -> datetime | None: iso = raw.replace("Z", "+00:00") dt = datetime.fromisoformat(iso) if dt.tzinfo is None: - dt = dt.replace(tzinfo=UTC) - return dt.astimezone(UTC) + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) except ValueError: return None @@ -178,7 +178,7 @@ def parse_headers(provider_id: str, headers: Mapping[str, str]) -> HeaderSnapsho # Normalise to lowercase keys while keeping original case in raw payload. low = {k.lower(): v for k, v in headers.items()} dialect = _detect_dialect(headers) - now = datetime.now(UTC) + now = datetime.now(timezone.utc) if dialect == "anthropic": return HeaderSnapshot( diff --git a/faigate/quota_poller.py b/faigate/quota_poller.py index 5decbc8..5a1b538 100644 --- a/faigate/quota_poller.py +++ b/faigate/quota_poller.py @@ -36,7 +36,7 @@ import logging import os from dataclasses import dataclass -from datetime import UTC, date, datetime +from datetime import date, datetime, timezone from pathlib import Path from typing import Any @@ -320,7 +320,7 @@ def _select_due_packages( A package is "due" if it has ``source == "api_poll"`` and package_type in the credits family. Fast-lane cadence kicks in for expiring credits. """ - now = now or datetime.now(UTC) + now = now or datetime.now(timezone.utc) today = now.date() out: list[tuple[str, dict[str, Any], int]] = [] for pkg_id, entry in packages.items(): @@ -435,7 +435,7 @@ def _persist_cache_to_disk( envelope = {} envelope.setdefault("schema_version", "1.1") envelope["packages"] = packages_cache - envelope["generated_at"] = datetime.now(UTC).isoformat(timespec="seconds") + envelope["generated_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds") tmp_path = path.with_suffix(path.suffix + ".tmp") tmp_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/faigate/quota_tracker.py b/faigate/quota_tracker.py index 6a5520c..cf46e97 100644 --- a/faigate/quota_tracker.py +++ b/faigate/quota_tracker.py @@ -97,7 +97,7 @@ import logging import sqlite3 from dataclasses import asdict, dataclass, field -from datetime import UTC, date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone from pathlib import Path from typing import Any, Literal @@ -134,6 +134,10 @@ class QuotaStatus: source: SourceType confidence: ConfidenceLevel last_updated: str | None = None # ISO 8601 + # Brand naming (v1.3) β€” product/brand label for the operator-facing UI + # (Claude/Codex/Gemini/…). Routing keeps reading provider_group/_id. + brand: str = "" + brand_slug: str = "" # Window-specific window_hours: int | None = None reset_at: str | None = None # ISO 8601, when window resets @@ -142,6 +146,15 @@ class QuotaStatus: days_until_expiry: int | None = None burn_per_day: float | None = None projected_days_left: float | None = None + # Pace (how the operator is burning this quota vs. a linear schedule). + # Positive = ahead of pace (burning faster than linear), negative = under. + # None for credits packages (use projected_days_left instead). + pace_delta: float | None = None + elapsed_ratio: float | None = None + # Identity of the credential backing this package β€” used by the widget + # header line ("Pro Β· OAuth", "API Β· env ANTHROPIC_API_KEY"). None when + # the package has no credential requirement. + identity: dict[str, str] | None = None # Diagnostics (not part of stable UI contract) notes: str | None = None extras: dict[str, Any] = field(default_factory=dict) @@ -162,33 +175,43 @@ def compute_quota_status( * ``package``: a dict exactly as stored in the packages catalog (one value from ``packages_catalog.items()``). Must contain at least ``provider_id``. Everything else is defaulted or computed. - * ``now``: injected for test determinism; defaults to ``datetime.now(UTC)``. + * ``now``: injected for test determinism; defaults to ``datetime.now(timezone.utc)``. * ``sqlite_path``: faigate.db path for looking up ``used`` for window types via local request counts. If ``None`` and the package is window-based, ``used`` falls back to the catalog-stored value. """ - now = now or datetime.now(UTC) + now = now or datetime.now(timezone.utc) package_id = package.get("package_id") or _synthesize_package_id(package) provider_id = package.get("provider_id") or "unknown" provider_group = package.get("provider_group") or _derive_provider_group(provider_id) + brand = package.get("brand") or _derive_brand(provider_group) + brand_slug = package.get("brand_slug") or _slugify_brand(brand) + identity = _derive_identity(package.get("_requires_credential")) pkg_type: PackageType = package.get("package_type") or "credits" source: SourceType = package.get("source") or "manual" confidence: ConfidenceLevel = package.get("confidence") or "medium" last_updated = package.get("last_updated") notes = package.get("notes") + ctx = _StatusCtx( + package_id=package_id, + provider_id=provider_id, + provider_group=provider_group, + brand=brand, + brand_slug=brand_slug, + identity=identity, + source=source, + confidence=confidence, + last_updated=last_updated, + notes=notes, + ) + if pkg_type == "rolling_window": - return _status_rolling_window( - package, package_id, provider_id, provider_group, source, confidence, last_updated, notes, now, sqlite_path - ) + return _status_rolling_window(package, ctx, now, sqlite_path) if pkg_type == "daily": - return _status_daily( - package, package_id, provider_id, provider_group, source, confidence, last_updated, notes, now, sqlite_path - ) + return _status_daily(package, ctx, now, sqlite_path) # Default: credits - return _status_credits( - package, package_id, provider_id, provider_group, source, confidence, last_updated, notes, now, sqlite_path - ) + return _status_credits(package, ctx, now, sqlite_path) def _derive_provider_group(provider_id: str) -> str: @@ -206,6 +229,102 @@ def _derive_provider_group(provider_id: str) -> str: return provider_id.split("-", 1)[0] +# Operator-facing brand names keyed on the routing-side provider_group. +# Catalog v1.3 ships an explicit `brand` field per package; this table is the +# fallback for older catalogs and for packages the catalog forgot to label. +# See docs/GATE-BAR-DESIGN.md Β§1 for the full naming pivot. +_BRAND_BY_GROUP: dict[str, str] = { + "anthropic": "Claude", + "openai": "Codex", + "gemini": "Gemini", + "deepseek": "DeepSeek", + "kilocode": "Kilo Code", + "kilo": "Kilo Code", + "openrouter": "OpenRouter", + "qwen": "Qwen", + "blackbox": "Blackbox", +} + + +def _derive_brand(provider_group: str) -> str: + """Fallback brand label for catalogs still on pre-v1.3 schema.""" + if not provider_group or provider_group == "unknown": + return "Unknown" + return _BRAND_BY_GROUP.get(provider_group, provider_group.title()) + + +def _slugify_brand(brand: str) -> str: + """URL-safe kebab-case version of a brand name. + + ``"Kilo Code"`` -> ``"kilo-code"``, ``"Claude"`` -> ``"claude"``, + ``"OpenRouter"`` -> ``"openrouter"``. Used as the path segment in + /dashboard/quotas/. + """ + if not brand: + return "unknown" + out: list[str] = [] + for ch in brand.strip().lower(): + if ch.isalnum(): + out.append(ch) + elif ch in (" ", "_", "-", "."): + if out and out[-1] != "-": + out.append("-") + slug = "".join(out).strip("-") + return slug or "unknown" + + +def _derive_identity(requires: str | None) -> dict[str, str] | None: + """Describe the credential backing a package. + + Produces what the widget needs for the "plan Β· login method" line. Env + vars render as "API Β· env ", OAuth subjects as "OAuth Β· ". + Plan / email enrichment (e.g. Claude Pro vs Max) is deferred to a + follow-up that reads the OAuth token store β€” this is the on-disk MVP. + """ + if not requires: + return None + value = str(requires).strip() + if not value: + return None + if value.replace("_", "").isalnum() and value.isupper(): + return {"login_method": "API key", "credential": value} + return {"login_method": "OAuth", "credential": value} + + +def _compute_pace(used: float, total: float, elapsed_ratio: float) -> tuple[float | None, float | None]: + """Return ``(pace_delta, elapsed_ratio)`` for a window-based package. + + ``pace_delta = used_ratio - elapsed_ratio``. Positive means the operator + is burning faster than a linear schedule; negative means they're under + pace. ``None`` when the package has no meaningful total (limit=0). + """ + if total <= 0: + return None, None + er = max(0.0, min(1.0, elapsed_ratio)) + used_ratio = used / total + return used_ratio - er, er + + +@dataclass +class _StatusCtx: + """Immutable per-package context threaded into the _status_* helpers. + + Keeping this as a dataclass (not positional args) stops the signatures + from drifting every time we add a new field. + """ + + package_id: str + provider_id: str + provider_group: str + brand: str + brand_slug: str + identity: dict[str, str] | None + source: SourceType + confidence: ConfidenceLevel + last_updated: str | None + notes: str | None + + # ----------------------------------------------------------------------------- # Per-package-type computation # ----------------------------------------------------------------------------- @@ -213,13 +332,7 @@ def _derive_provider_group(provider_id: str) -> str: def _status_credits( package: dict[str, Any], - package_id: str, - provider_id: str, - provider_group: str, - source: SourceType, - confidence: ConfidenceLevel, - last_updated: str | None, - notes: str | None, + ctx: _StatusCtx, now: datetime, sqlite_path: Path | None, ) -> QuotaStatus: @@ -235,10 +348,10 @@ def _status_credits( expiry_date = date.fromisoformat(expiry_iso) days_until_expiry = (expiry_date - now.date()).days except ValueError: - logger.warning("Invalid expiry_date %r on package %s", expiry_iso, package_id) + logger.warning("Invalid expiry_date %r on package %s", expiry_iso, ctx.package_id) # Burn rate: look at the last 7d of requests for this provider - burn = _local_burn_per_day_usd(provider_id, now, sqlite_path, days=7) + burn = _local_burn_per_day_usd(ctx.provider_id, now, sqlite_path, days=7) projected_days_left: float | None = None if burn and burn > 0: projected_days_left = remaining / burn @@ -246,35 +359,36 @@ def _status_credits( alert = _classify_credits_alert(remaining, days_until_expiry, projected_days_left) return QuotaStatus( - provider_id=provider_id, - provider_group=provider_group, - package_id=package_id, + provider_id=ctx.provider_id, + provider_group=ctx.provider_group, + package_id=ctx.package_id, package_type="credits", total=total, used=used, remaining=remaining, remaining_ratio=ratio, alert=alert, - source=source, - confidence=confidence, - last_updated=last_updated, + source=ctx.source, + confidence=ctx.confidence, + last_updated=ctx.last_updated, + brand=ctx.brand, + brand_slug=ctx.brand_slug, + identity=ctx.identity, expiry_date=expiry_iso, days_until_expiry=days_until_expiry, burn_per_day=burn, projected_days_left=projected_days_left, - notes=notes, + # Pace is not meaningful for credits packages β€” projected_days_left + # already carries the "am I burning too fast?" signal. + pace_delta=None, + elapsed_ratio=None, + notes=ctx.notes, ) def _status_rolling_window( package: dict[str, Any], - package_id: str, - provider_id: str, - provider_group: str, - source: SourceType, - confidence: ConfidenceLevel, - last_updated: str | None, - notes: str | None, + ctx: _StatusCtx, now: datetime, sqlite_path: Path | None, ) -> QuotaStatus: @@ -282,7 +396,7 @@ def _status_rolling_window( limit = float(package.get("limit_per_window") or 0) model_weights: dict[str, float] = package.get("model_weights") or {} extra_ids = [str(p) for p in (package.get("extra_provider_ids") or [])] - counted_ids = [provider_id, *extra_ids] + counted_ids = [ctx.provider_id, *extra_ids] # Local count: weighted request count over the last window_hours, summed # across provider_id + any extra_provider_ids sharing the same quota. @@ -297,52 +411,58 @@ def _status_rolling_window( ratio = (remaining / limit) if limit > 0 else 0.0 if earliest: + window_start = earliest reset_at = (earliest + timedelta(hours=window_hours)).isoformat() else: + window_start = now # fresh window reset_at = (now + timedelta(hours=window_hours)).isoformat() + # Elapsed fraction of this rolling window β€” used for the pace marker. + elapsed_hours = max(0.0, (now - window_start).total_seconds() / 3600.0) + elapsed_ratio = elapsed_hours / window_hours if window_hours > 0 else 0.0 + pace_delta, elapsed_clamped = _compute_pace(used, limit, elapsed_ratio) + alert = _classify_window_alert(remaining, limit) return QuotaStatus( - provider_id=provider_id, - provider_group=provider_group, - package_id=package_id, + provider_id=ctx.provider_id, + provider_group=ctx.provider_group, + package_id=ctx.package_id, package_type="rolling_window", total=limit, used=used, remaining=remaining, remaining_ratio=ratio, alert=alert, - source=source, - confidence=confidence, - last_updated=last_updated, + source=ctx.source, + confidence=ctx.confidence, + last_updated=ctx.last_updated, + brand=ctx.brand, + brand_slug=ctx.brand_slug, + identity=ctx.identity, window_hours=window_hours, reset_at=reset_at, - notes=notes, + pace_delta=pace_delta, + elapsed_ratio=elapsed_clamped, + notes=ctx.notes, extras={"model_weights": model_weights} if model_weights else {}, ) def _status_daily( package: dict[str, Any], - package_id: str, - provider_id: str, - provider_group: str, - source: SourceType, - confidence: ConfidenceLevel, - last_updated: str | None, - notes: str | None, + ctx: _StatusCtx, now: datetime, sqlite_path: Path | None, ) -> QuotaStatus: limit = float(package.get("limit_per_day") or 0) extra_ids = [str(p) for p in (package.get("extra_provider_ids") or [])] - counted_ids = [provider_id, *extra_ids] + counted_ids = [ctx.provider_id, *extra_ids] # Count requests since UTC midnight β€” summed across counted_ids so a daily # quota shared by multiple router provider IDs (e.g. Gemini free tier # covers both gemini-flash and gemini-flash-lite) reads accurately. - midnight = datetime(now.year, now.month, now.day, tzinfo=UTC) + midnight = datetime(now.year, now.month, now.day, tzinfo=timezone.utc) hours_since_midnight = (now - midnight).total_seconds() / 3600.0 window = max(hours_since_midnight, 0.01) used = sum( @@ -354,23 +474,32 @@ def _status_daily( next_midnight = midnight + timedelta(days=1) reset_at = next_midnight.isoformat() + # Daily pace: how far into the 24h we are vs. how much we've burnt. + elapsed_ratio = hours_since_midnight / 24.0 + pace_delta, elapsed_clamped = _compute_pace(used, limit, elapsed_ratio) + alert = _classify_window_alert(remaining, limit) return QuotaStatus( - provider_id=provider_id, - provider_group=provider_group, - package_id=package_id, + provider_id=ctx.provider_id, + provider_group=ctx.provider_group, + package_id=ctx.package_id, package_type="daily", total=limit, used=used, remaining=remaining, remaining_ratio=ratio, alert=alert, - source=source, - confidence=confidence, - last_updated=last_updated, + source=ctx.source, + confidence=ctx.confidence, + last_updated=ctx.last_updated, + brand=ctx.brand, + brand_slug=ctx.brand_slug, + identity=ctx.identity, reset_at=reset_at, - notes=notes, + pace_delta=pace_delta, + elapsed_ratio=elapsed_clamped, + notes=ctx.notes, ) @@ -495,7 +624,7 @@ def _earliest_request_in_window( ) row = cur.fetchone() if row and row["t"] is not None: - return datetime.fromtimestamp(int(row["t"]), tz=UTC) + return datetime.fromtimestamp(int(row["t"]), tz=timezone.utc) return None except sqlite3.Error: return None @@ -575,7 +704,7 @@ def update_package_usage( entry["source"] = source if confidence is not None: entry["confidence"] = confidence - entry["last_updated"] = datetime.now(UTC).isoformat(timespec="seconds") + entry["last_updated"] = datetime.now(timezone.utc).isoformat(timespec="seconds") return True @@ -676,7 +805,7 @@ def format_status_line(status: QuotaStatus) -> str: "confidence": "estimated", }, } - now = datetime.now(UTC) + now = datetime.now(timezone.utc) for pid, pkg in demo_packages.items(): pkg["package_id"] = pid status = compute_quota_status(pkg, now=now) diff --git a/pyproject.toml b/pyproject.toml index b86dccd..d2a52e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "faigate" -version = "2.2.3" +version = "2.3.0" description = "Local OpenAI-compatible routing gateway for OpenClaw and other AI-native clients." readme = "README.md" license = "Apache-2.0" @@ -31,6 +31,10 @@ dependencies = [ "httpx>=0.27.0,<0.29", "pydantic>=2.9,<2.14", "pyyaml>=6.0.2", + # ruamel.yaml powers comment-preserving round-trip writes to config.yaml + # from the dashboard settings endpoint. Standard yaml.safe_dump would + # destroy operator-authored comments (section banners, OAuth docs, …). + "ruamel.yaml>=0.18.6", "python-dotenv>=1.0.1", "python-multipart>=0.0.9", ] @@ -79,7 +83,10 @@ faigate = [ [tool.ruff] line-length = 120 -target-version = "py312" +# Must match ``requires-python`` above: ruff/UP rules otherwise rewrite +# e.g. ``datetime.timezone.utc`` β†’ ``datetime.UTC`` (added in 3.11) and +# break CI on the 3.10 job. +target-version = "py310" [tool.ruff.lint] select = ["E", "F", "I", "W", "UP"] diff --git a/requirements.txt b/requirements.txt index c3edb0e..a542ccd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ fastapi>=0.115.0 uvicorn[standard]>=0.32.0 httpx>=0.27.0 pyyaml>=6.0.2 +ruamel.yaml>=0.18.6 python-dotenv>=1.0.1 aiosqlite>=0.20.0 python-multipart>=0.0.9 diff --git a/tests/test_brand_detail_endpoints.py b/tests/test_brand_detail_endpoints.py new file mode 100644 index 0000000..86b8b5c --- /dev/null +++ b/tests/test_brand_detail_endpoints.py @@ -0,0 +1,346 @@ +"""Coverage for the v2.3 per-brand detail view endpoints. + +Pins the contract the ``/dashboard/quotas/`` widget reads from: + +1. ``_brand_context(slug)`` resolves a brand_slug β†’ active providers, or + returns ``None`` for unknown/no-active-packages brands (so the HTTP + handlers 404 rather than returning empty JSON). +2. ``/api/quotas//{clients,routes,analytics}`` all 404 on unknown + brands and echo back ``brand`` / ``brand_slug`` / ``providers`` on + success so the widget can render a header without a second round trip. +3. The multi-provider ``providers=[...]`` filter reaches the metrics + layer (``_build_where_clause`` turns it into ``provider IN (...)``). +4. ``/dashboard/quotas/`` serves the detail-view shell with the + brand slug and cockpit URL substituted at render time. + +See ``docs/GATE-BAR-DESIGN.md`` Β§3.4 for the Design-Thinking rationale +("quick view most-relevant subset of the Operator Cockpit"). +""" + +from __future__ import annotations + +import importlib +import sys +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any + +import pytest + +sys.modules.pop("httpx", None) +import httpx # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +sys.modules["httpx"] = httpx + +sys.modules.pop("faigate.providers", None) +sys.modules.pop("faigate.updates", None) +sys.modules.pop("faigate.main", None) + +import faigate.main as main_module # noqa: E402 +from faigate.config import load_config # noqa: E402 +from faigate.router import Router # noqa: E402 + +importlib.reload(main_module) + + +# ── Canned catalog ──────────────────────────────────────────────────────────── +# Two active brands (Claude + DeepSeek), one unreachable brand (Qwen β€” the +# credential is "missing" in the stub). This mirrors the real fusionAIze +# catalog shape after the v1.3 brand pivot. + +_CATALOG: dict[str, dict[str, Any]] = { + "anthropic-pro-5h-session": { + "package_id": "anthropic-pro-5h-session", + "provider_id": "anthropic-claude", + "provider_group": "anthropic", + "brand": "Claude", + "brand_slug": "claude", + "package_type": "rolling_window", + "window_hours": 5, + "limit_per_window": 100, + "_requires_credential": "claude-code", + }, + "anthropic-pro-weekly": { + "package_id": "anthropic-pro-weekly", + "provider_id": "anthropic-claude-weekly", + "provider_group": "anthropic", + "brand": "Claude", + "brand_slug": "claude", + "package_type": "rolling_window", + "window_hours": 168, + "limit_per_window": 500, + "_requires_credential": "claude-code", + }, + "deepseek-pay-as-you-go": { + "package_id": "deepseek-pay-as-you-go", + "provider_id": "deepseek-chat", + "provider_group": "deepseek", + "brand": "DeepSeek", + "brand_slug": "deepseek", + "package_type": "credits", + "total_credits": 28.42, + "used_credits": 0.0, + "_requires_credential": "DEEPSEEK_API_KEY", + }, + "qwen-free-daily": { + "package_id": "qwen-free-daily", + "provider_id": "qwen-portal", + "provider_group": "qwen", + "brand": "Qwen", + "brand_slug": "qwen", + "package_type": "daily", + "limit_per_day": 2000, + "_requires_credential": "qwen-portal-missing", + }, +} + + +def _write_config(tmp_path: Path, body: str) -> Path: + path = tmp_path / "config.yaml" + path.write_text(body) + return path + + +class _MetricsRecorder: + """Metrics double that records the filters passed to each method. + + The endpoints' *only* job on top of the metrics layer is to inject + ``providers=[...]`` β€” so we check the recorded call shape rather than + the (boring) fake data coming back. + """ + + def __init__(self): + self.calls: list[tuple[str, dict]] = [] + + def _record(self, name, **kwargs): + self.calls.append((name, kwargs)) + + # Quota endpoints use these four: + def get_client_breakdown(self, **kw): + self._record("get_client_breakdown", **kw) + return [{"client_profile": "openclaw", "client_tag": "agent", "requests": 3}] + + def get_client_totals(self, **kw): + self._record("get_client_totals", **kw) + return [{"client_profile": "openclaw", "client_tag": "agent", "requests": 3}] + + def get_lane_family_breakdown(self, **kw): + self._record("get_lane_family_breakdown", **kw) + return [{"lane_family": "claude-coding", "requests": 3, "providers": 1, "cost_usd": 0.0}] + + def get_routing_breakdown(self, **kw): + self._record("get_routing_breakdown", **kw) + return [] + + def get_selection_path_breakdown(self, **kw): + self._record("get_selection_path_breakdown", **kw) + return [] + + def get_totals(self, **kw): + self._record("get_totals", **kw) + return {"total_requests": 3, "total_failures": 0, "total_cost_usd": 0.0} + + def get_provider_summary(self, **kw): + self._record("get_provider_summary", **kw) + return [] + + def get_hourly_series(self, *args, **kw): + self._record("get_hourly_series", hours=args[0] if args else None, **kw) + return [{"hour_offset": 0, "requests": 1, "cost_usd": 0.0, "tokens": 10}] + + def get_daily_totals(self, *args, **kw): + self._record("get_daily_totals", days=args[0] if args else None, **kw) + return [] + + # Unused by the brand endpoints but the quotas handler needs them: + def log_request(self, **_kw): + pass + + +@pytest.fixture +def api_client(tmp_path, monkeypatch): + cfg = load_config( + _write_config( + tmp_path, + """ +server: + host: "127.0.0.1" + port: 8090 +providers: {} +fallback_chain: [] +metrics: + enabled: false +""", + ) + ) + + @asynccontextmanager + async def _noop_lifespan(_app): + yield + + recorder = _MetricsRecorder() + monkeypatch.setattr(main_module, "_config", cfg, raising=False) + monkeypatch.setattr(main_module, "_router", Router(cfg), raising=False) + monkeypatch.setattr(main_module, "_providers", {}, raising=False) + monkeypatch.setattr(main_module, "_metrics", recorder, raising=False) + monkeypatch.setattr(main_module.app.router, "lifespan_context", _noop_lifespan, raising=False) + + # Catalog shim: both the detail endpoints and /api/quotas reach into + # ``provider_catalog.get_packages_catalog``. Patch the canonical module. + from faigate import provider_catalog + + monkeypatch.setattr(provider_catalog, "get_packages_catalog", lambda: _CATALOG, raising=False) + + # Credential gate: treat "claude-code" + "DEEPSEEK_API_KEY" as present, + # but reject the Qwen one so it appears in skipped/inactive. + def _cred_available(hint): + return bool(hint) and hint != "qwen-portal-missing" + + monkeypatch.setattr(main_module, "_credential_available", _cred_available, raising=False) + + with TestClient(main_module.app) as client: + client.metrics_recorder = recorder # type: ignore[attr-defined] + yield client + + +# ── _brand_context unit tests ──────────────────────────────────────────────── + + +class TestBrandContext: + def test_known_brand_returns_providers(self, api_client): + ctx = main_module._brand_context("claude") + assert ctx is not None + assert ctx["brand"] == "Claude" + assert ctx["brand_slug"] == "claude" + # Two packages under the same brand contribute two provider IDs. + assert ctx["providers"] == ["anthropic-claude", "anthropic-claude-weekly"] + assert len(ctx["packages"]) == 2 + + def test_unknown_brand_returns_none(self, api_client): + assert main_module._brand_context("nope") is None + assert main_module._brand_context("") is None + + def test_inactive_brand_returns_none(self, api_client): + # Qwen is in the catalog but credential missing β†’ filtered out β†’ + # _brand_context sees no packages β†’ None (so the endpoint 404s + # instead of silently returning empty data for "active" Qwen). + assert main_module._brand_context("qwen") is None + + def test_slug_is_case_insensitive(self, api_client): + ctx = main_module._brand_context("ClAuDe") + assert ctx is not None + assert ctx["brand_slug"] == "claude" + + +# ── HTTP-level tests ────────────────────────────────────────────────────────── + + +class TestClientsEndpoint: + def test_returns_404_for_unknown_brand(self, api_client): + r = api_client.get("/api/quotas/nope/clients") + assert r.status_code == 404 + + def test_returns_clients_scoped_to_brand_providers(self, api_client): + r = api_client.get("/api/quotas/claude/clients") + assert r.status_code == 200 + body = r.json() + assert body["brand"] == "Claude" + assert body["brand_slug"] == "claude" + assert body["providers"] == ["anthropic-claude", "anthropic-claude-weekly"] + assert body["clients"] and body["clients"][0]["client_profile"] == "openclaw" + + # Most important: the providers list reached the metrics layer. + recorder: _MetricsRecorder = api_client.metrics_recorder # type: ignore[attr-defined] + breakdown_calls = [c for c in recorder.calls if c[0] == "get_client_breakdown"] + assert breakdown_calls + assert breakdown_calls[-1][1]["providers"] == [ + "anthropic-claude", + "anthropic-claude-weekly", + ] + + +class TestRoutesEndpoint: + def test_returns_404_for_unknown_brand(self, api_client): + r = api_client.get("/api/quotas/nope/routes") + assert r.status_code == 404 + + def test_lane_families_filter_passes_through(self, api_client): + r = api_client.get("/api/quotas/deepseek/routes") + assert r.status_code == 200 + body = r.json() + assert body["brand"] == "DeepSeek" + assert body["providers"] == ["deepseek-chat"] + assert body["lane_families"][0]["lane_family"] == "claude-coding" + + recorder: _MetricsRecorder = api_client.metrics_recorder # type: ignore[attr-defined] + lane_calls = [c for c in recorder.calls if c[0] == "get_lane_family_breakdown"] + assert lane_calls + assert lane_calls[-1][1]["providers"] == ["deepseek-chat"] + + +class TestAnalyticsEndpoint: + def test_returns_404_for_unknown_brand(self, api_client): + r = api_client.get("/api/quotas/nope/analytics") + assert r.status_code == 404 + + def test_defaults_and_clamping(self, api_client): + # 99999-hour window should be clamped to the max (24 * 7 = 168h). + r = api_client.get("/api/quotas/claude/analytics?hours=99999&days=9999") + assert r.status_code == 200 + recorder: _MetricsRecorder = api_client.metrics_recorder # type: ignore[attr-defined] + hourly_calls = [c for c in recorder.calls if c[0] == "get_hourly_series"] + daily_calls = [c for c in recorder.calls if c[0] == "get_daily_totals"] + assert hourly_calls and hourly_calls[-1][1]["hours"] == 24 * 7 + assert daily_calls and daily_calls[-1][1]["days"] == 90 + + def test_totals_and_series_piped_through(self, api_client): + r = api_client.get("/api/quotas/claude/analytics") + body = r.json() + assert body["brand"] == "Claude" + assert body["totals"]["total_requests"] == 3 + assert body["hourly"][0]["requests"] == 1 + # Providers filter is propagated to every metrics call the endpoint + # makes β€” this is the core invariant the detail view relies on. + recorder: _MetricsRecorder = api_client.metrics_recorder # type: ignore[attr-defined] + for name in ( + "get_totals", + "get_provider_summary", + "get_hourly_series", + "get_daily_totals", + ): + matching = [c for c in recorder.calls if c[0] == name] + assert matching, f"{name} was not called" + assert matching[-1][1]["providers"] == [ + "anthropic-claude", + "anthropic-claude-weekly", + ], f"{name} missing providers filter" + + +class TestDetailHTML: + def test_brand_slug_is_substituted(self, api_client): + r = api_client.get("/dashboard/quotas/claude") + assert r.status_code == 200 + text = r.text + assert 'const BRAND_SLUG = "claude"' in text + # No stray placeholders should leak into the response. + assert "__BRAND_SLUG__" not in text + assert "__COCKPIT_URL__" not in text + # The overview page's "back" breadcrumb is wired up. + assert "/dashboard/quotas" in text + + def test_cockpit_url_respects_env(self, api_client, monkeypatch): + monkeypatch.setenv("FAIGATE_COCKPIT_URL", "https://cockpit.example.test/") + r = api_client.get("/dashboard/quotas/deepseek") + assert r.status_code == 200 + # Trailing slash stripped by _cockpit_base_url; the JS string is + # baked in at render time. + assert 'COCKPIT_URL = "https://cockpit.example.test"' in r.text + + def test_unknown_brand_slug_still_serves_shell(self, api_client): + # The HTML is intentionally brand-agnostic β€” it probes the API on + # load and shows "Brand not found" client-side when the API 404s. + # Serving a 404 for the page itself would break shareable links. + r = api_client.get("/dashboard/quotas/made-up-brand") + assert r.status_code == 200 + assert 'const BRAND_SLUG = "made-up-brand"' in r.text diff --git a/tests/test_dashboard_settings.py b/tests/test_dashboard_settings.py new file mode 100644 index 0000000..3638a86 --- /dev/null +++ b/tests/test_dashboard_settings.py @@ -0,0 +1,339 @@ +"""Coverage for ``dashboard.quotas.default_view`` persistence and the +``/api/dashboard/settings`` + ``/dashboard/quotas`` redirect contract. + +Pins (v2.3 Phase B.5): + +1. ``dashboard_settings.get_settings()`` defaults to ``overview`` when the + block is missing, and validates ``default_view`` on read (bad values + degrade to ``overview`` instead of crashing the endpoint). + +2. ``dashboard_settings.set_default_view()`` writes back to config.yaml + through ruamel.yaml round-trip β€” **comments and neighbor keys + survive**. This is the whole reason we took the ruamel.yaml + dependency; a regression here silently destroys 200+ operator + comments in the real config. + +3. Bad ``default_view`` values (empty, uppercase garbage, unknown + literal, bogus ``brand:`` suffix) raise ``ValueError`` and never + touch the file. + +4. ``POST /api/dashboard/settings`` surfaces validation errors as 400 and + otherwise returns the canonical settings dict. + +5. ``GET /dashboard/quotas`` redirects (302) for ``brand:`` and + ``cockpit`` defaults, **except** when the caller passes + ``?view=overview`` (the escape hatch a pinned-brand card uses to + link back home). +""" + +from __future__ import annotations + +import importlib +import sys +from contextlib import asynccontextmanager +from pathlib import Path + +import pytest + +sys.modules.pop("httpx", None) +import httpx # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +sys.modules["httpx"] = httpx + +sys.modules.pop("faigate.main", None) + +import faigate.dashboard_settings as ds # noqa: E402 +import faigate.main as main_module # noqa: E402 +from faigate.config import load_config # noqa: E402 +from faigate.router import Router # noqa: E402 + +importlib.reload(main_module) + + +# Heavily-commented fragment modeled after the real ``config.yaml`` so the +# round-trip test can verify every kind of comment survives: section +# banners, inline end-of-line notes, and blank-line separators. +_COMMENTED_CONFIG = """\ +# ── Server section ──────────────────────────────────────────────────── +server: + host: "127.0.0.1" + port: 8090 # dev default + +# providers left intentionally empty for this test +providers: {} + +fallback_chain: [] + +# ── Metrics ─────────────────────────────────────────────────────────── +metrics: + enabled: false + +# End-of-file trailing comment β€” should also survive. +""" + + +@pytest.fixture +def config_path(tmp_path: Path) -> Path: + path = tmp_path / "config.yaml" + path.write_text(_COMMENTED_CONFIG, encoding="utf-8") + return path + + +# ── Unit tests: dashboard_settings module ──────────────────────────────────── + + +class TestGetSettings: + def test_missing_block_returns_overview_defaults(self, config_path: Path): + got = ds.get_settings(config_path) + assert got == {"default_view": "overview", "pinned_brand_slug": ""} + + def test_reads_brand_view_after_write(self, config_path: Path): + ds.set_default_view("brand:claude", path=config_path) + got = ds.get_settings(config_path) + assert got == {"default_view": "brand:claude", "pinned_brand_slug": "claude"} + + def test_cockpit_view_has_no_pinned_slug(self, config_path: Path): + ds.set_default_view("cockpit", path=config_path) + got = ds.get_settings(config_path) + assert got == {"default_view": "cockpit", "pinned_brand_slug": ""} + + def test_bad_stored_value_degrades_to_overview(self, config_path: Path): + # Simulate a hand-edited config with a garbage value β€” the reader + # must not 500; it degrades to overview so the dashboard loads. + config_path.write_text( + _COMMENTED_CONFIG + "\ndashboard:\n quotas:\n default_view: lolwut\n", + encoding="utf-8", + ) + got = ds.get_settings(config_path) + assert got["default_view"] == "overview" + + def test_nonexistent_file_returns_defaults(self, tmp_path: Path): + got = ds.get_settings(tmp_path / "does-not-exist.yaml") + assert got == {"default_view": "overview", "pinned_brand_slug": ""} + + +class TestValidateDefaultView: + @pytest.mark.parametrize( + "value,expected", + [ + ("overview", "overview"), + (" OVERVIEW ", "overview"), # trimmed + lowered + ("cockpit", "cockpit"), + ("brand:claude", "brand:claude"), + ("brand:deepseek-chat", "brand:deepseek-chat"), + ("BRAND:CLAUDE", "brand:claude"), + ], + ) + def test_accepts_canonical_values(self, value: str, expected: str): + assert ds.validate_default_view(value) == expected + + @pytest.mark.parametrize( + "value", + ["", "home", "brand:", "brand:Has Spaces", "brand:", "random", "cockpit:claude", "brand:under_score"], + ) + def test_rejects_bad_values(self, value: str): + with pytest.raises(ValueError): + ds.validate_default_view(value) + + +class TestSetDefaultViewRoundTrip: + def test_preserves_comments_and_blank_lines(self, config_path: Path): + before = config_path.read_text(encoding="utf-8") + before_comment_count = before.count("#") + assert before_comment_count >= 4 # sanity: the fixture has 4+ comment lines + + ds.set_default_view("brand:claude", path=config_path) + after = config_path.read_text(encoding="utf-8") + + # Every comment survives the round-trip. + assert after.count("#") == before_comment_count + for sentinel in ( + "# ── Server section", + "port: 8090 # dev default", + "# providers left intentionally empty for this test", + "# ── Metrics", + "# End-of-file trailing comment β€” should also survive.", + ): + assert sentinel in after, f"Lost comment: {sentinel!r}" + + # The new block was appended, other keys stayed intact. + assert "dashboard:" in after + assert "default_view: brand:claude" in after + assert "pinned_brand_slug: claude" in after + assert "providers: {}" in after + assert "fallback_chain: []" in after + + def test_pinning_a_different_brand_updates_both_keys(self, config_path: Path): + ds.set_default_view("brand:claude", path=config_path) + ds.set_default_view("brand:deepseek", path=config_path) + got = ds.get_settings(config_path) + assert got == {"default_view": "brand:deepseek", "pinned_brand_slug": "deepseek"} + + def test_switching_away_from_brand_drops_pinned_slug_key(self, config_path: Path): + ds.set_default_view("brand:claude", path=config_path) + assert "pinned_brand_slug: claude" in config_path.read_text(encoding="utf-8") + + ds.set_default_view("overview", path=config_path) + text = config_path.read_text(encoding="utf-8") + assert "pinned_brand_slug" not in text + assert "default_view: overview" in text + + def test_rejects_bad_values_without_touching_file(self, config_path: Path): + before = config_path.read_text(encoding="utf-8") + with pytest.raises(ValueError): + ds.set_default_view("not-a-real-view", path=config_path) + assert config_path.read_text(encoding="utf-8") == before + + def test_atomic_rename_leaves_no_tmp_files(self, config_path: Path): + ds.set_default_view("brand:claude", path=config_path) + siblings = list(config_path.parent.iterdir()) + leftover = [s for s in siblings if s.name.startswith(".dashboard_settings.")] + assert leftover == [], f"leftover tmp files: {leftover}" + + +# ── HTTP-level tests: endpoints + redirect ────────────────────────────────── + + +def _write_minimal_config(tmp_path: Path) -> Path: + path = tmp_path / "config.yaml" + path.write_text( + """\ +server: + host: "127.0.0.1" + port: 8090 +providers: {} +fallback_chain: [] +metrics: + enabled: false +""", + encoding="utf-8", + ) + return path + + +@pytest.fixture +def api_client(tmp_path, monkeypatch): + cfg_path = _write_minimal_config(tmp_path) + cfg = load_config(cfg_path) + + # dashboard_settings always reads FAIGATE_CONFIG_FILE (same env var as + # the rest of faigate) β€” point it at our scratch file. + monkeypatch.setenv("FAIGATE_CONFIG_FILE", str(cfg_path)) + monkeypatch.setenv("FAIGATE_COCKPIT_URL", "https://cockpit.example") + + @asynccontextmanager + async def _noop_lifespan(_app): + yield + + monkeypatch.setattr(main_module, "_config", cfg, raising=False) + monkeypatch.setattr(main_module, "_router", Router(cfg), raising=False) + monkeypatch.setattr(main_module, "_providers", {}, raising=False) + monkeypatch.setattr(main_module.app.router, "lifespan_context", _noop_lifespan, raising=False) + + with TestClient(main_module.app, follow_redirects=False) as client: + client.config_path = cfg_path # type: ignore[attr-defined] + yield client + + +class TestSettingsApi: + def test_get_defaults_when_unset(self, api_client): + r = api_client.get("/api/dashboard/settings") + assert r.status_code == 200 + assert r.json() == {"default_view": "overview", "pinned_brand_slug": ""} + + def test_post_brand_persists_and_echoes(self, api_client): + r = api_client.post("/api/dashboard/settings", json={"default_view": "brand:claude"}) + assert r.status_code == 200 + assert r.json() == {"default_view": "brand:claude", "pinned_brand_slug": "claude"} + + # Value is visible on the next GET (persisted through config.yaml). + r = api_client.get("/api/dashboard/settings") + assert r.json()["default_view"] == "brand:claude" + + def test_post_cockpit(self, api_client): + r = api_client.post("/api/dashboard/settings", json={"default_view": "cockpit"}) + assert r.status_code == 200 + assert r.json() == {"default_view": "cockpit", "pinned_brand_slug": ""} + + def test_post_overview(self, api_client): + api_client.post("/api/dashboard/settings", json={"default_view": "brand:claude"}) + r = api_client.post("/api/dashboard/settings", json={"default_view": "overview"}) + assert r.status_code == 200 + assert r.json() == {"default_view": "overview", "pinned_brand_slug": ""} + + def test_post_bad_value_400s(self, api_client): + r = api_client.post("/api/dashboard/settings", json={"default_view": "bogus"}) + assert r.status_code == 400 + assert "default_view" in r.json()["error"] + + def test_post_missing_key_400s(self, api_client): + r = api_client.post("/api/dashboard/settings", json={}) + assert r.status_code == 400 + + def test_post_non_object_400s(self, api_client): + r = api_client.post("/api/dashboard/settings", json=["overview"]) + assert r.status_code == 400 + + def test_post_invalid_json_400s(self, api_client): + r = api_client.post( + "/api/dashboard/settings", + content=b"this is not json", + headers={"Content-Type": "application/json"}, + ) + assert r.status_code == 400 + + +class TestDashboardRedirect: + def test_overview_default_renders_html(self, api_client): + r = api_client.get("/dashboard/quotas") + assert r.status_code == 200 + assert "