diff --git a/.gitignore b/.gitignore index 7732960..9f34d7b 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,5 @@ catalog_output.json *.xcodeproj/ xcuserdata/ DerivedData/ +handover/ +.skillweave/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f94121..575b391 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # fusionAIze Gate Changelog +## v2.6.0 - 2026-05-04 + +### Added + +- **Free provider catalog** — 7 zero-cost providers: Pollinations (no API key required), Groq (ultra-fast LPU, 14.4K RPD), Cerebras (wafer-scale, 1M TPD), LongCat (Flash-Lite, 50M tokens/day), NVIDIA NIM (129 models, 40 RPM), Kiro (OAuth via AWS Builder ID, Sonnet 4.5 unlimited), Qoder (OAuth via Google, kimi-k2-thinking unlimited). All providers are commented out in `config.yaml` by default; operators opt in. +- **Circuit breaker system** (`faigate/breakers.py`) — per-provider state machine (CLOSED → OPEN → HALF_OPEN) with configurable failure threshold, cooldown, and jitter. Integrated into the provider dispatch loop in `main.py`. State is persisted in the metrics SQLite database (`circuit_breakers` table). Auto-closes on successful HALF_OPEN probe. +- **Cockpit API endpoints** (`/api/cockpit/*`) — 6 endpoints: `GET /health` (provider health + circuit snapshot), `GET /providers` (full config view), `GET /circuits` (breaker state), `POST /circuits/{provider}/reset` (force-close), `GET /stats` (totals, 24h counts, top providers), `GET /routes/log` (recent request log). +- **Terminal cockpit TUI** (`faigate/cockpit_tui.py`) — Textual-based 4-tab dashboard (Dashboard, Providers, Circuits, Routes) with auto-refreshing data from `/api/cockpit/*` endpoints. Agent mode (`faigate cockpit --agent `) outputs JSON for script/programmatic consumption. +- **Circuit breaker response headers** — `X-faigate-Circuit` and `X-faigate-Circuit-Provider` added to all chat completion responses for client-side circuit awareness. + +### Changed + +- **Routing mode updates** — `coding-free`, `coding-fast`, and `eco` profiles now include free providers (pollinations, groq, cerebras) as backup chains. +- **Catalog freshness** — `last_reviewed` dates refreshed across provider catalog entries. +- **Groq and Cerebras catalog entries** migrated from `track: stable` to `track: free` with updated recommended models and notes. + +### Fixed + +- Duplicate `groq` and `cerebras` entries in `_CATALOG` merged into single entries. +- `catalog-stale` alert false positives resolved by bumping review dates to 2026-05-04. +- Mock `Namespace` in `test_main_cli` updated for subparser compatibility. +- External offerings/packages catalog env var isolation in test fixtures. + ## v2.5.0 - 2026-04-27 ### Fixed diff --git a/apps/gate-bar/Sources/GateBar/BrandCardView.swift b/apps/gate-bar/Sources/GateBar/BrandCardView.swift index 6fa1efc..1b91e28 100644 --- a/apps/gate-bar/Sources/GateBar/BrandCardView.swift +++ b/apps/gate-bar/Sources/GateBar/BrandCardView.swift @@ -40,12 +40,12 @@ struct BrandCardView: View { private var header: some View { HStack(alignment: .firstTextBaseline) { Text(brand.brand) - .font(.system(size: 14, weight: .semibold)) + .font(.system(size: 15, weight: .semibold)) .foregroundColor(Theme.foreground) Spacer(minLength: 8) if let identity = brand.identity { Text("\(identity.loginMethod): \(identity.credential)") - .font(.system(size: 10, design: .monospaced)) + .font(.system(size: 11, design: .monospaced)) .foregroundColor(Theme.dim) .lineLimit(1) .truncationMode(.middle) @@ -84,23 +84,23 @@ struct PackageRow: View { VStack(alignment: .leading, spacing: 4) { HStack(alignment: .firstTextBaseline) { Text(package.packageName ?? package.packageId) - .font(.system(size: 12)) + .font(.system(size: 13)) .foregroundColor(Theme.mid) Spacer(minLength: 8) Text(percentageLabel) - .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .font(.system(size: 12, 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)) + .font(.system(size: 11, design: .monospaced)) .foregroundColor(Theme.dim) } Spacer(minLength: 8) Text(resetLabel) - .font(.system(size: 10)) + .font(.system(size: 11)) .foregroundColor(Theme.dim) .lineLimit(1) } @@ -154,7 +154,7 @@ struct PackageRow: View { } } } - .frame(height: 6) + .frame(height: 7) } } diff --git a/apps/gate-bar/Sources/GateBar/GateBarApp.swift b/apps/gate-bar/Sources/GateBar/GateBarApp.swift index 901fabd..876f05c 100644 --- a/apps/gate-bar/Sources/GateBar/GateBarApp.swift +++ b/apps/gate-bar/Sources/GateBar/GateBarApp.swift @@ -1,71 +1,240 @@ import SwiftUI import AppKit +import Combine /// 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. +/// The app runs as an agent (`LSUIElement=true`) and owns a single +/// `NSStatusItem` plus a custom `NSPanel` for the popover. We explicitly +/// position the panel flush to the right edge of the screen, just below +/// the menubar — SwiftUI's `MenuBarExtra` anchors under the icon and +/// offers no override, so we drop down a level to AppKit for this. /// -/// Note: `MenuBarExtra(_ :, isInserted:)` gives us a toggle for hiding the -/// icon entirely (future preference). The current cut always shows it. +/// Preferences are still a stock SwiftUI `Settings` scene; opening it goes +/// through `NSApp.sendAction(Selector(("showSettingsWindow:")))` which is +/// the documented Sonoma API. @main struct GateBarApp: App { - @StateObject private var preferences = Preferences() - @StateObject private var store: QuotaStore + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate - init() { - let prefs = Preferences() - _preferences = StateObject(wrappedValue: prefs) - _store = StateObject(wrappedValue: QuotaStore(preferences: prefs)) + var body: some Scene { + Settings { + PreferencesView(preferences: appDelegate.preferences) + } } +} - 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) +/// Owns the status item, the popover panel, and the quota store. +/// +/// Lives for the app's whole lifetime via ``GateBarApp``'s +/// ``@NSApplicationDelegateAdaptor``. Exposes ``preferences`` so the +/// SwiftUI Settings scene can bind to the same instance the store reads. +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { + let preferences = Preferences() + lazy var store: QuotaStore = QuotaStore(preferences: preferences) + lazy var helperStore: QuotaHelperStore = QuotaHelperStore(providers: ["claude", "openrouter"]) + + private var statusItem: NSStatusItem? + private var panel: NSPanel? + private var preferencesWindow: NSWindow? + private var globalMonitor: Any? + private var localMonitor: Any? + private var cancellables = Set() + + func applicationDidFinishLaunching(_ notification: Notification) { + // LSUIElement=true in Info.plist already hides the Dock icon, but + // setting the activation policy explicitly is belt-and-braces for + // devs launching the raw binary without the bundle. + NSApp.setActivationPolicy(.accessory) + setupStatusItem() + setupPanel() + observeStore() + } + + // MARK: - Status item + + private func setupStatusItem() { + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + if let button = item.button { + button.target = self + button.action = #selector(togglePanel(_:)) + button.sendAction(on: [.leftMouseUp, .rightMouseUp]) } - .menuBarExtraStyle(.window) + statusItem = item + updateStatusTitle(store.menuBarSummary) + } - Settings { - PreferencesView(preferences: preferences) + /// Paints the menubar label: coloured dot + "fAI · 83%" in the menubar + /// foreground colour. Using an `NSAttributedString` lets us colour just + /// the dot without breaking macOS's automatic light/dark contrast on + /// the rest of the text. + private func updateStatusTitle(_ summary: QuotaStore.MenuBarSummary) { + guard let button = statusItem?.button else { return } + let attr = NSMutableAttributedString() + attr.append(NSAttributedString( + string: "●", + attributes: [ + .foregroundColor: nsColor(for: summary.alert), + .font: NSFont.systemFont(ofSize: 10, weight: .bold), + .baselineOffset: 1, + ] + )) + attr.append(NSAttributedString( + string: " \(summary.label)", + attributes: [ + .font: NSFont.monospacedSystemFont(ofSize: 12, weight: .medium), + ] + )) + button.attributedTitle = attr + } + + private func nsColor(for alert: AlertLevel) -> NSColor { + switch alert { + case .ok: return NSColor(calibratedRed: 0.290, green: 0.871, blue: 0.502, alpha: 1) + case .watch: return NSColor(calibratedRed: 0.984, green: 0.749, blue: 0.141, alpha: 1) + case .topup: return NSColor(calibratedRed: 0.984, green: 0.573, blue: 0.235, alpha: 1) + case .urgent: return NSColor(calibratedRed: 0.937, green: 0.267, blue: 0.267, alpha: 1) + case .exhausted: return NSColor(calibratedRed: 0.498, green: 0.114, blue: 0.114, alpha: 1) } } - /// 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) + private func observeStore() { + // Re-render the menubar label whenever anything the summary reads + // can have changed. `menuBarSummary` is a computed property so we + // just listen to the underlying `@Published` sources. + Publishers.CombineLatest3( + store.$brands, + store.$lastError, + store.$lastRefresh + ) + .sink { [weak self] _, _, _ in + guard let self else { return } + self.updateStatusTitle(self.store.menuBarSummary) + } + .store(in: &cancellables) + } + + // MARK: - Panel + + private func setupPanel() { + let root = PopoverView( + store: store, + helperStore: helperStore, + preferences: preferences, + onOpenPreferences: { [weak self] in self?.openPreferences() } + ) + let hosting = NSHostingController(rootView: root) + + // `.nonactivatingPanel` keeps focus in whatever app the operator + // was using — a menubar utility shouldn't steal keyboard focus on + // open. `.titled` + transparent titlebar gives SwiftUI room to + // paint the whole rect itself. + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 440, height: 620), + styleMask: [.titled, .fullSizeContentView, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.contentViewController = hosting + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + panel.isMovable = false + panel.isMovableByWindowBackground = false + panel.level = .popUpMenu + panel.hidesOnDeactivate = false + panel.isReleasedWhenClosed = false + panel.hasShadow = true + panel.backgroundColor = .clear + panel.standardWindowButton(.closeButton)?.isHidden = true + panel.standardWindowButton(.miniaturizeButton)?.isHidden = true + panel.standardWindowButton(.zoomButton)?.isHidden = true + self.panel = panel + } + + @objc private func togglePanel(_ sender: Any?) { + guard let panel else { return } + if panel.isVisible { + closePanel() } else { - NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) + openPanel() } } -} -/// 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)) + private func openPanel() { + guard let panel else { return } + // Position on whatever screen the menubar icon lives on. Falls + // back to the main screen for single-display setups. `visibleFrame` + // already excludes the menubar, so `maxY` is exactly the bottom + // of the menubar — perfect for the panel's top edge. + let screen = statusItem?.button?.window?.screen ?? NSScreen.main + guard let visible = screen?.visibleFrame else { return } + + let size = panel.frame.size + let margin: CGFloat = 8 + let x = visible.maxX - size.width - margin + let y = visible.maxY - size.height + panel.setFrameOrigin(NSPoint(x: x, y: y)) + panel.orderFrontRegardless() + NSApp.activate(ignoringOtherApps: true) + + // Kick off a fresh fetch the moment the user opens the popover — + // stale data is worse than a half-second loading spinner. + Task { + await store.refresh() + await helperStore.refresh() } + + // Dismiss on click outside the panel, Esc closes it. + globalMonitor = NSEvent.addGlobalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown] + ) { [weak self] _ in + self?.closePanel() + } + localMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { [weak self] event in + if event.keyCode == 53 { // Esc + self?.closePanel() + return nil + } + return event + } + } + + private func closePanel() { + panel?.orderOut(nil) + if let m = globalMonitor { + NSEvent.removeMonitor(m) + globalMonitor = nil + } + if let m = localMonitor { + NSEvent.removeMonitor(m) + localMonitor = nil + } + } + + // MARK: - Preferences + + /// Open Preferences in a dedicated NSWindow. + /// + /// We bypass SwiftUI's Settings scene routing entirely: with + /// `.accessory` activation policy and a `nonactivatingPanel` popover + /// already open, `showSettingsWindow:` never finds its target and + /// silently drops the action. Direct NSWindow is guaranteed to work. + func openPreferences() { + if let existing = preferencesWindow, existing.isVisible { + existing.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + let hosting = NSHostingController(rootView: PreferencesView(preferences: preferences)) + let window = NSWindow(contentViewController: hosting) + window.title = "Gate Bar Preferences" + window.styleMask = [.titled, .closable, .miniaturizable] + window.isReleasedWhenClosed = false + window.level = .floating + window.center() + preferencesWindow = window + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) } } diff --git a/apps/gate-bar/Sources/GateBar/HelperModels.swift b/apps/gate-bar/Sources/GateBar/HelperModels.swift new file mode 100644 index 0000000..7bd920d --- /dev/null +++ b/apps/gate-bar/Sources/GateBar/HelperModels.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Provider snapshot returned by fetch_quotas.py --json-only. +struct ProviderSnapshot: Decodable, Identifiable { + var id: String { provider } + let provider: String + let brand: String + let windows: [UsageWindow] + let credits: CreditsInfo? + let error: String? +} + +struct UsageWindow: Decodable, Identifiable { + var id: String { label } + let label: String + let usedPct: Double + let resetsAt: String? + + enum CodingKeys: String, CodingKey { + case label + case usedPct = "used_pct" + case resetsAt = "resets_at" + } +} + +struct CreditsInfo: Decodable { + let used: Double + let total: Double? + let usedPct: Double? + let currency: String + let mode: String? + + enum CodingKeys: String, CodingKey { + case used, total, currency, mode + case usedPct = "used_pct" + } + + var isPayg: Bool { mode == "payg" || total == nil } +} diff --git a/apps/gate-bar/Sources/GateBar/PopoverView.swift b/apps/gate-bar/Sources/GateBar/PopoverView.swift index 57958b1..d8e79b7 100644 --- a/apps/gate-bar/Sources/GateBar/PopoverView.swift +++ b/apps/gate-bar/Sources/GateBar/PopoverView.swift @@ -1,21 +1,27 @@ import SwiftUI -/// The menubar popover contents. Mirrors the web widget's page composition: +/// The menubar popover. Two views selectable via a segment picker: /// -/// 1. Active brand cards (sorted worst-alert first). -/// 2. A mini catalog "Available to add" block. -/// 3. A footer with Cockpit + Refresh controls. +/// "Töpfe" — real quota data fetched directly from provider web APIs +/// via fetch_quotas.py (Chrome cookies + faigate .env keys). +/// "faigate" — requests routed through the local faigate proxy, sourced +/// from /api/quotas on the gateway. /// -/// 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. +/// Both share the same header and footer; only the content scrollview +/// changes based on the selection. struct PopoverView: View { @ObservedObject var store: QuotaStore + @ObservedObject var helperStore: QuotaHelperStore @ObservedObject var preferences: Preferences - /// Parent (the MenuBarExtra scene) owns the settings window-presentation - /// so this view just signals intent. var onOpenPreferences: () -> Void + @State private var selectedView: PopoverTab = .topfe + + enum PopoverTab: String, CaseIterable { + case topfe = "Töpfe" + case faigate = "faigate" + } + var body: some View { VStack(alignment: .leading, spacing: 0) { header @@ -24,8 +30,8 @@ struct PopoverView: View { Divider().background(Theme.border) footer } - .frame(width: 360) - .frame(minHeight: 200, maxHeight: 640) + .frame(width: 440) + .frame(minHeight: 560, maxHeight: 1100) .background(Theme.background) .foregroundColor(Theme.foreground) } @@ -33,53 +39,55 @@ struct PopoverView: View { // 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) + VStack(spacing: 8) { + HStack(alignment: .center, spacing: 8) { + Text("fusionAIze Gate Bar") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(Theme.foreground) + Spacer(minLength: 8) + if isLoading { + ProgressView().controlSize(.small) + } + Text(lastRefreshLabel) + .font(.system(size: 11)) + .foregroundColor(Theme.dim) } - Text(lastRefreshLabel) - .font(.system(size: 10)) - .foregroundColor(Theme.dim) + Picker("View", selection: $selectedView) { + ForEach(PopoverTab.allCases, id: \.self) { tab in + Text(tab.rawValue).tag(tab) + } + } + .pickerStyle(.segmented) } - .padding(.horizontal, 14) - .padding(.vertical, 10) + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 10) + } + + private var isLoading: Bool { + selectedView == .topfe ? helperStore.isLoading : store.isLoading } private var lastRefreshLabel: String { - guard let refreshed = store.lastRefresh else { - return store.lastError == nil ? "never refreshed" : "offline" + let refreshed = selectedView == .topfe ? helperStore.lastRefresh : store.lastRefresh + let hasError = selectedView == .topfe ? helperStore.lastError != nil : store.lastError != nil + guard let refreshed else { + return hasError ? "offline" : "never refreshed" } let f = RelativeDateTimeFormatter() f.unitsStyle = .short return "updated \(f.localizedString(for: refreshed, relativeTo: Date()))" } - // MARK: - Main content + // MARK: - 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 + switch selectedView { + case .topfe: topfeContent + case .faigate: faigateContent } } .padding(.horizontal, 12) @@ -87,31 +95,72 @@ struct PopoverView: View { } } - 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)) + // MARK: Töpfe view — real provider quotas + + @ViewBuilder + private var topfeContent: some View { + if let err = helperStore.lastError, helperStore.snapshots.isEmpty { + errorBanner("fetch_quotas.py error", detail: err) + } else if helperStore.snapshots.isEmpty { + emptyBanner( + title: "No provider data", + detail: "Fetching quotas from your providers…" + ) + } else { + ForEach(helperStore.snapshots) { snap in + ProviderCardView(snapshot: snap) + } + } + } + + // MARK: faigate view — proxied traffic + + @ViewBuilder + private var faigateContent: some View { + if let error = store.lastError { + errorBanner("Can't reach the gateway", detail: error) + } + if store.brands.isEmpty && store.lastError == nil { + emptyBanner( + title: "No active providers", + detail: "Start the faigate gateway or check the gateway URL in Preferences." + ) + } else { + ForEach(store.brands) { brand in + BrandCardView(brand: brand) + } + } + if !store.catalogSuggestions.isEmpty { catalogBlock } + if !store.skippedPackages.isEmpty { skippedBlock } + } + + // MARK: - Shared sub-views + + private func emptyBanner(title: String, detail: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.system(size: 13, weight: .semibold)) + Text(detail) + .font(.system(size: 12)) .foregroundColor(Theme.dim) } - .padding(12) + .padding(14) .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)) + private func errorBanner(_ heading: String, detail: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(heading) + .font(.system(size: 13, weight: .semibold)) .foregroundColor(Theme.color(for: .urgent)) - Text(message) - .font(.system(size: 11)) + Text(detail) + .font(.system(size: 12)) .foregroundColor(Theme.dim) .lineLimit(3) } - .padding(10) + .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background(Theme.card) .overlay( @@ -121,33 +170,32 @@ struct PopoverView: View { .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) { + VStack(alignment: .leading, spacing: 8) { Text("Available to add") - .font(.system(size: 11, weight: .semibold)) + .font(.system(size: 12, weight: .semibold)) .foregroundColor(Theme.dim) .textCase(.uppercase) - .padding(.top, 4) + .padding(.top, 6) ForEach(store.catalogSuggestions.prefix(6)) { suggestion in HStack(alignment: .firstTextBaseline) { Text(suggestion.brand) - .font(.system(size: 12, weight: .medium)) + .font(.system(size: 13, weight: .medium)) .foregroundColor(Theme.foreground) Text(suggestion.tagline) - .font(.system(size: 11)) + .font(.system(size: 13)) .foregroundColor(Theme.dim) .lineLimit(1) Spacer(minLength: 8) Link("Add ↗", destination: cockpitLink(for: suggestion.brandSlug, path: "providers/add")) - .font(.system(size: 11)) + .font(.system(size: 13)) .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)) + .font(.system(size: 13)) .foregroundColor(Theme.link) } } @@ -155,57 +203,47 @@ struct PopoverView: View { private var skippedBlock: some View { Text("Skipped: \(store.skippedPackages.map { $0.brand ?? $0.packageId }.joined(separator: ", "))") - .font(.system(size: 10)) + .font(.system(size: 11)) .foregroundColor(Theme.dim) .lineLimit(2) - .padding(.top, 4) + .padding(.top, 6) } // 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)) + Text("Dashboard ↗").font(.system(size: 13)) } .foregroundColor(Theme.link) Link(destination: cockpitLink()) { - Text("Cockpit ↗") - .font(.system(size: 12)) + Text("Cockpit ↗").font(.system(size: 13)) } .foregroundColor(Theme.link) Button { - Task { await store.refresh() } + Task { + await store.refresh() + await helperStore.refresh() + } } label: { - Text("Refresh") - .font(.system(size: 12)) + Text("Refresh").font(.system(size: 13)) } .buttonStyle(.plain) .foregroundColor(Theme.link) Spacer() - Button { - onOpenPreferences() - } label: { - Text("Preferences…") - .font(.system(size: 12)) + Button { onOpenPreferences() } label: { + Text("Preferences…").font(.system(size: 13)) } .buttonStyle(.plain) .foregroundColor(Theme.dim) - Button { - NSApp.terminate(nil) - } label: { - Text("Quit") - .font(.system(size: 12)) + Button { NSApp.terminate(nil) } label: { + Text("Quit").font(.system(size: 13)) } .buttonStyle(.plain) .foregroundColor(Theme.dim) @@ -214,28 +252,22 @@ struct PopoverView: View { .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. + // MARK: - URL helpers + private func dashboardLink() -> URL { let base = preferences.gatewayURL.hasSuffix("/") - ? String(preferences.gatewayURL.dropLast()) - : preferences.gatewayURL + ? 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 + ? 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)! - } + 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/ProviderCardView.swift b/apps/gate-bar/Sources/GateBar/ProviderCardView.swift new file mode 100644 index 0000000..1751f69 --- /dev/null +++ b/apps/gate-bar/Sources/GateBar/ProviderCardView.swift @@ -0,0 +1,140 @@ +import SwiftUI + +/// Card for a single provider in the "Töpfe" (real-quota) view. +/// Mirrors the visual language of BrandCardView but driven by +/// ProviderSnapshot data from fetch_quotas.py. +struct ProviderCardView: View { + let snapshot: ProviderSnapshot + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + header + if let err = snapshot.error { + Text(err) + .font(.system(size: 11)) + .foregroundColor(Theme.color(for: .urgent)) + .lineLimit(2) + } else { + ForEach(Array(snapshot.windows.enumerated()), id: \.element.id) { idx, win in + if idx > 0 { Divider().background(Theme.border).padding(.vertical, 2) } + WindowRow(window: win) + } + if let credits = snapshot.credits { + if !snapshot.windows.isEmpty { + Divider().background(Theme.border).padding(.vertical, 2) + } + CreditsRow(credits: credits) + } + } + } + .padding(.vertical, 10) + .padding(.horizontal, 12) + .background(Theme.card) + .overlay( + Rectangle() + .fill(snapshot.error != nil ? Theme.color(for: .urgent) : Theme.accent.opacity(0.6)) + .frame(width: 3), + alignment: .leading + ) + .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(Theme.border, lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private var header: some View { + Text(snapshot.brand) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(Theme.foreground) + } +} + +/// One usage window row (e.g. "Session (5h)" with a progress bar). +private struct WindowRow: View { + let window: UsageWindow + + private var usedRatio: Double { max(0, min(1, window.usedPct / 100)) } + + private var alert: AlertLevel { + switch usedRatio { + case 1...: return .exhausted + case 0.9...: return .urgent + case 0.75...: return .topup + case 0.5...: return .watch + default: return .ok + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline) { + Text(window.label) + .font(.system(size: 13)) + .foregroundColor(Theme.mid) + Spacer(minLength: 8) + Text(pctLabel) + .font(.system(size: 12, weight: .semibold, design: .monospaced)) + .foregroundColor(Theme.foreground) + } + bar + if let reset = resetLabel { + Text(reset) + .font(.system(size: 11)) + .foregroundColor(Theme.dim) + } + } + } + + private var pctLabel: String { + let p = window.usedPct + return p < 10 ? String(format: "%.1f%%", p) : "\(Int(p.rounded()))%" + } + + private var resetLabel: String? { + guard let iso = window.resetsAt, !iso.isEmpty else { return nil } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let date = formatter.date(from: iso) ?? { + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: iso) + }() + guard let date else { return "resets \(iso.prefix(10))" } + let rel = RelativeDateTimeFormatter() + rel.unitsStyle = .short + return "resets \(rel.localizedString(for: date, relativeTo: Date()))" + } + + 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) + } + } + .frame(height: 7) + } +} + +/// Credits / spend row (no progress bar for PAYG; bar for pre-paid). +private struct CreditsRow: View { + let credits: CreditsInfo + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(credits.isPayg ? "Spend" : "Credits") + .font(.system(size: 13)) + .foregroundColor(Theme.mid) + Spacer(minLength: 8) + if credits.isPayg { + Text(String(format: "$%.4f %@", credits.used, credits.currency)) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(Theme.dim) + } else if let total = credits.total, let pct = credits.usedPct { + Text(String(format: "%.2f / %.2f %@ (%.0f%%)", + credits.used, total, credits.currency, pct)) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(Theme.dim) + } + } + } +} diff --git a/apps/gate-bar/Sources/GateBar/QuotaHelperStore.swift b/apps/gate-bar/Sources/GateBar/QuotaHelperStore.swift new file mode 100644 index 0000000..a9d1b6b --- /dev/null +++ b/apps/gate-bar/Sources/GateBar/QuotaHelperStore.swift @@ -0,0 +1,107 @@ +import Foundation +import Combine + +/// Runs fetch_quotas.py as a subprocess and publishes the parsed results. +/// +/// The script path is resolved at init: first tries a bundle-relative +/// location (for when the helper is shipped alongside the .app), then +/// falls back to the dev-tree path so local builds work without any +/// extra setup. +@MainActor +final class QuotaHelperStore: ObservableObject { + @Published private(set) var snapshots: [ProviderSnapshot] = [] + @Published private(set) var isLoading = false + @Published private(set) var lastError: String? = nil + @Published private(set) var lastRefresh: Date? = nil + + private let python3: String + private let scriptPath: String + private let providers: [String] + private var timerCancellable: AnyCancellable? + + init(providers: [String] = ["claude", "openrouter"]) { + self.providers = providers + + // python3 — prefer Homebrew arm64 install + let pythonCandidates = [ + "/opt/homebrew/bin/python3", + "/usr/local/bin/python3", + "/usr/bin/python3", + ] + self.python3 = pythonCandidates.first { + FileManager.default.fileExists(atPath: $0) + } ?? "/usr/bin/python3" + + // fetch_quotas.py — bundle-relative first, dev-tree fallback + let bundleScript = Bundle.main.path(forResource: "fetch_quotas", ofType: "py") + let devScript = "/Users/andrelange/Documents/repositories/github/faigate/apps/quota-helper/fetch_quotas.py" + self.scriptPath = bundleScript ?? devScript + + // Refresh every 5 minutes + timerCancellable = Timer.publish(every: 300, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in Task { [weak self] in await self?.refresh() } } + } + + func refresh() async { + guard FileManager.default.fileExists(atPath: scriptPath) else { + lastError = "fetch_quotas.py not found at \(scriptPath)" + return + } + isLoading = true + defer { isLoading = false } + do { + let data = try await runScript() + let decoded = try JSONDecoder().decode([ProviderSnapshot].self, from: data) + self.snapshots = decoded + self.lastError = nil + self.lastRefresh = Date() + } catch { + self.lastError = error.localizedDescription + } + } + + private func runScript() async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { [python3, scriptPath, providers] in + let process = Process() + process.executableURL = URL(fileURLWithPath: python3) + process.arguments = [scriptPath] + providers + ["--json-only"] + + // Inherit minimal PATH so the script finds system tools. + var env = ProcessInfo.processInfo.environment + env["PATH"] = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" + process.environment = env + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + do { + try process.run() + } catch { + continuation.resume(throwing: error) + return + } + process.waitUntilExit() + let data = stdout.fileHandleForReading.readDataToEndOfFile() + if data.isEmpty { + let errOut = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + continuation.resume(throwing: HelperError.emptyOutput(errOut)) + } else { + continuation.resume(returning: data) + } + } + } + } +} + +enum HelperError: LocalizedError { + case emptyOutput(String) + var errorDescription: String? { + switch self { + case .emptyOutput(let detail): return "fetch_quotas.py produced no output. stderr: \(detail)" + } + } +} diff --git a/apps/gate-bar/scripts/install-local.sh b/apps/gate-bar/scripts/install-local.sh index e9c63b4..855a04e 100755 --- a/apps/gate-bar/scripts/install-local.sh +++ b/apps/gate-bar/scripts/install-local.sh @@ -19,7 +19,7 @@ set -euo pipefail APP_NAME="Gate Bar" APP_BUNDLE_ID="ai.fusionaize.gate-bar" -APP_VERSION="0.1.0" +APP_VERSION="0.1.4" APP_MIN_MACOS="14.0" BIN_NAME="GateBar" diff --git a/apps/quota-helper/fetch_quotas.py b/apps/quota-helper/fetch_quotas.py new file mode 100644 index 0000000..cda2901 --- /dev/null +++ b/apps/quota-helper/fetch_quotas.py @@ -0,0 +1,442 @@ +#!/usr/bin/env python3 +""" +quota-helper — provider quota fetcher prototype. + +Fetches real usage data directly from provider web APIs, mirroring +how CodexBar works: browser cookies for web-auth providers, API keys +or OAuth tokens for programmatic providers. + +Providers implemented: + - claude : Chrome sessionKey cookie → claude.ai/api/organizations/{org}/usage + - openrouter : OPENROUTER_API_KEY env var → openrouter.ai/api/v1/credits + - gemini : gcloud OAuth token → cloudcode-pa.googleapis.com (WIP) + - cursor : Chrome session cookies → cursor.sh/api/usage-summary (WIP) + +Output is a list of ProviderSnapshot dicts: + { + "provider": "claude", + "brand": "Claude", + "windows": [ + {"label": "Session", "used_pct": 13.0, "resets_at": "2026-04-20T07:00:00Z"}, + {"label": "Weekly", "used_pct": 94.0, "resets_at": "2026-04-23T20:00:00Z"}, + ], + "credits": {"used": 17.26, "limit": 17.0, "currency": "EUR"}, # optional + "error": null, + } +""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +import urllib.error +import urllib.request +from datetime import datetime, timezone +from typing import Any + +# --------------------------------------------------------------------------- +# Chrome cookie decryption (macOS v10 format) +# --------------------------------------------------------------------------- + + +def _chrome_key() -> bytes: + pw = subprocess.check_output(["security", "find-generic-password", "-s", "Chrome Safe Storage", "-w"]).strip() + return hashlib.pbkdf2_hmac("sha1", pw, b"saltysalt", 1003, dklen=16) + + +def _decrypt_chrome_cookie(enc: bytes, key: bytes) -> str: + """AES-128-CBC, fixed IV=space*16, v10 prefix, PKCS7 padding. + + Chrome on macOS stores a binary prefix ending with 0x60 before the + actual ASCII value. We split on the last 0x60 byte. + """ + try: + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + except ImportError: + raise RuntimeError("pip install cryptography") + + if enc[:3] != b"v10": + return enc.decode("utf-8", errors="ignore") + payload = enc[3:] + cipher = Cipher(algorithms.AES(key), modes.CBC(b" " * 16), backend=default_backend()) + dec = cipher.decryptor().update(payload) + pad = dec[-1] + raw = dec[:-pad] + idx = raw.rfind(b"\x60") + return raw[idx + 1 :].decode("ascii", errors="ignore") if idx >= 0 else raw.decode("ascii", errors="ignore") + + +def _chrome_cookies(domain: str, names: list[str]) -> dict[str, str]: + db = os.path.expanduser("~/Library/Application Support/Google/Chrome/Default/Cookies") + if not os.path.exists(db): + return {} + tmp = tempfile.mktemp(suffix=".sqlite") + shutil.copy2(db, tmp) + try: + conn = sqlite3.connect(tmp) + like = f"%{domain}%" + placeholders = ",".join("?" * len(names)) + rows = conn.execute( + f"SELECT name, encrypted_value FROM cookies WHERE host_key LIKE ? AND name IN ({placeholders})", + [like, *names], + ).fetchall() + conn.close() + finally: + os.unlink(tmp) + + key = _chrome_key() + return {name: _decrypt_chrome_cookie(enc, key) for name, enc in rows} + + +# --------------------------------------------------------------------------- +# HTTP helper +# --------------------------------------------------------------------------- + + +def _get(url: str, headers: dict[str, str], timeout: int = 8) -> Any: + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read()) + + +def _post(url: str, body: Any, headers: dict[str, str], timeout: int = 8) -> Any: + data = json.dumps(body).encode() + headers = {**headers, "Content-Type": "application/json"} + req = urllib.request.Request(url, data=data, headers=headers, method="POST") + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read()) + + +# --------------------------------------------------------------------------- +# Provider: Claude (web cookies) +# --------------------------------------------------------------------------- + + +def fetch_claude() -> dict: + result: dict = {"provider": "claude", "brand": "Claude", "windows": [], "credits": None, "error": None} + try: + cookies = _chrome_cookies("claude.ai", ["sessionKey", "lastActiveOrg"]) + session = cookies.get("sessionKey", "") + org = cookies.get("lastActiveOrg", "") + if not session or not org: + result["error"] = "No claude.ai session cookie found in Chrome" + return result + + hdrs = { + "Cookie": f"sessionKey={session}; lastActiveOrg={org}", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", + "Accept": "application/json", + "Referer": "https://claude.ai/settings/usage", + } + data = _get(f"https://claude.ai/api/organizations/{org}/usage", hdrs) + + def window(label: str, key: str) -> dict | None: + w = data.get(key) + if not w: + return None + return { + "label": label, + "used_pct": w.get("utilization", 0.0), + "resets_at": w.get("resets_at"), + } + + for w in [ + window("Session (5h)", "five_hour"), + window("Weekly", "seven_day"), + window("Weekly Opus", "seven_day_opus"), + window("Weekly Sonnet", "seven_day_sonnet"), + window("Weekly Design", "seven_day_omelette"), + ]: + if w: + result["windows"].append(w) + + extra = data.get("extra_usage") + if extra: + result["credits"] = { + "used": extra.get("used_credits", 0) / 100, + "limit": extra.get("monthly_limit", 0) / 100, + "used_pct": extra.get("utilization", 0.0), + "currency": extra.get("currency", "USD"), + } + except Exception as exc: + result["error"] = str(exc) + return result + + +# --------------------------------------------------------------------------- +# Provider: OpenRouter (API key) +# --------------------------------------------------------------------------- + + +def fetch_openrouter(api_key: str | None = None) -> dict: + result: dict = {"provider": "openrouter", "brand": "OpenRouter", "windows": [], "credits": None, "error": None} + key = api_key or os.environ.get("OPENROUTER_API_KEY", "") + if not key: + result["error"] = "OPENROUTER_API_KEY not set" + return result + try: + hdrs = {"Authorization": f"Bearer {key}", "Accept": "application/json"} + data = _get("https://openrouter.ai/api/v1/credits", hdrs) + usage = data.get("data", data) + total = usage.get("total_credits", 0) + used = usage.get("total_usage", 0) + if total > 0: + # Pre-paid credits: show % consumed + result["credits"] = { + "used": round(used, 4), + "total": round(total, 4), + "used_pct": round(used / total * 100, 1), + "currency": "USD", + "mode": "prepaid", + } + else: + # Pay-as-you-go: no budget cap, just show spend + result["credits"] = { + "used": round(used, 4), + "total": None, + "used_pct": None, + "currency": "USD", + "mode": "payg", + } + except Exception as exc: + result["error"] = str(exc) + return result + + +# --------------------------------------------------------------------------- +# faigate token store (~/.config/faigate/tokens.json) +# --------------------------------------------------------------------------- + + +def _faigate_token(provider_key: str) -> str | None: + """Return a non-expired access_token from faigate's token store, or None.""" + path = os.path.expanduser("~/.config/faigate/tokens.json") + if not os.path.exists(path): + return None + try: + data = json.loads(open(path).read()) + entry = data.get(provider_key, {}) + token = entry.get("access_token") + expiry = entry.get("expiry_date") or entry.get("expires_at") + if not token: + return None + if expiry: + # expiry_date may be epoch-ms or ISO string + if isinstance(expiry, int | float): + ts = expiry / 1000 if expiry > 1e10 else expiry + if ts < datetime.now(timezone.utc).timestamp(): + return None # expired + elif isinstance(expiry, str): + try: + dt = datetime.fromisoformat(expiry.replace("Z", "+00:00")) + if dt < datetime.now(timezone.utc): + return None + except Exception: + pass + return token + except Exception: + return None + + +# --------------------------------------------------------------------------- +# Provider: Gemini / Google Cloud Code (OAuth token via faigate or gcloud) +# --------------------------------------------------------------------------- + + +def fetch_gemini() -> dict: + result: dict = {"provider": "gemini", "brand": "Gemini", "windows": [], "credits": None, "error": None} + try: + # Prefer faigate's stored token; fall back to gcloud CLI + token = _faigate_token("gemini-cli") + if not token: + try: + token = ( + subprocess.check_output(["gcloud", "auth", "print-access-token"], stderr=subprocess.DEVNULL) + .strip() + .decode() + ) + except (FileNotFoundError, subprocess.CalledProcessError): + result["error"] = "No valid Gemini token (faigate expired, gcloud not available)" + return result + + hdrs = {"Authorization": f"Bearer {token}", "Accept": "application/json"} + data = _post( + "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", + {}, + hdrs, + ) + for quota in data.get("quotas", []): + frac = quota.get("remainingFraction", 1.0) + used_pct = round((1.0 - frac) * 100, 1) + label = quota.get("tokenType", quota.get("modelId", "Unknown")) + resets_at = quota.get("resetTime") + result["windows"].append({"label": label, "used_pct": used_pct, "resets_at": resets_at}) + if not result["windows"]: + result["error"] = "No quota data in response" + except urllib.error.HTTPError as exc: + result["error"] = f"HTTP {exc.code}" + except Exception as exc: + result["error"] = str(exc) + return result + + +# --------------------------------------------------------------------------- +# Provider: Cursor (web cookies) +# --------------------------------------------------------------------------- + + +def fetch_cursor() -> dict: + result: dict = {"provider": "cursor", "brand": "Cursor", "windows": [], "credits": None, "error": None} + try: + cookie_names = [ + "WorkosCursorSessionToken", + "__Secure-next-auth.session-token", + "next-auth.session-token", + ] + cookies = _chrome_cookies("cursor.sh", cookie_names) + # Also check cursor.com + if not any(cookies.values()): + cookies = _chrome_cookies("cursor.com", cookie_names) + + session = next((v for v in cookies.values() if v), "") + if not session: + result["error"] = "No Cursor session cookie in Chrome" + return result + + cookie_str = "; ".join(f"{k}={v}" for k, v in cookies.items() if v) + hdrs = { + "Cookie": cookie_str, + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", + "Accept": "application/json", + } + data = _get("https://www.cursor.com/api/usage-summary", hdrs) + # Cursor returns percentages for Auto, Composer, API + for key, label in [("auto", "Auto"), ("composer", "Composer"), ("api", "API")]: + w = data.get(key, {}) + if w: + used_pct = w.get("usedPercent", w.get("used_pct", 0.0)) + result["windows"].append( + { + "label": label, + "used_pct": used_pct, + "resets_at": data.get("billingPeriodEnd") or data.get("nextReset"), + } + ) + except Exception as exc: + result["error"] = str(exc) + return result + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +FETCHERS = { + "claude": fetch_claude, + "openrouter": fetch_openrouter, + "gemini": fetch_gemini, + "cursor": fetch_cursor, +} + + +def _fmt_pct(p: float) -> str: + return f"{p:.1f}%" + + +def _fmt_reset(iso: str | None) -> str: + if not iso: + return "" + try: + dt = datetime.fromisoformat(iso.replace("Z", "+00:00")) + delta = dt - datetime.now(timezone.utc) + h, rem = divmod(int(delta.total_seconds()), 3600) + m = rem // 60 + if h > 48: + return f"in {h // 24}d" + if h > 0: + return f"in {h}h {m}m" + return f"in {m}m" + except Exception: + return iso[:10] + + +def print_report(snapshots: list[dict]) -> None: + for snap in snapshots: + brand = snap["brand"] + err = snap.get("error") + print(f"\n{'─' * 48}") + print(f" {brand}") + if err: + print(f" ✗ {err}") + continue + for w in snap.get("windows", []): + pct = _fmt_pct(w["used_pct"]) + rst = _fmt_reset(w.get("resets_at")) + bar_w = 30 + filled = int(w["used_pct"] / 100 * bar_w) + bar = "█" * filled + "░" * (bar_w - filled) + print(f" {w['label']:<18} {pct:>6} {bar} {rst}") + credits = snap.get("credits") + if credits: + cur = credits.get("currency", "USD") + used = credits.get("used", 0) + total = credits.get("total") + used_pct = credits.get("used_pct") + if credits.get("mode") == "payg" or total is None: + print(f" {'Spend (PAYG)':<18} {'':>6} ${used:.4f} {cur}") + else: + bar_w = 30 + filled = int((used_pct or 0) / 100 * bar_w) + bar = "█" * filled + "░" * (bar_w - filled) + print(f" {'Credits':<18} {_fmt_pct(used_pct or 0):>6} {bar} {used:.2f}/{total:.2f} {cur}") + + +def _load_faigate_env() -> None: + """Load /opt/homebrew/etc/faigate/.env into os.environ (no-op if missing).""" + env_path = "/opt/homebrew/etc/faigate/.env" + if not os.path.exists(env_path): + return + for line in open(env_path): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, val = line.partition("=") + key = key.strip() + val = val.strip().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = val + + +if __name__ == "__main__": + _load_faigate_env() + + args = [a for a in sys.argv[1:] if not a.startswith("--")] + flags = {a for a in sys.argv[1:] if a.startswith("--")} + json_only = "--json-only" in flags # machine-readable mode: JSON to stdout only + + requested = args or list(FETCHERS.keys()) + snapshots = [] + for name in requested: + if name not in FETCHERS: + print(f"Unknown provider: {name}. Available: {', '.join(FETCHERS)}", file=sys.stderr) + continue + print(f"Fetching {name}...", end=" ", flush=True, file=sys.stderr if json_only else sys.stdout) + snap = FETCHERS[name]() + status = "ok" if not snap.get("error") else f"error: {snap['error']}" + print(status, file=sys.stderr if json_only else sys.stdout) + snapshots.append(snap) + + if json_only: + print(json.dumps(snapshots)) + else: + print_report(snapshots) + print(f"\n{'─' * 48}") + if "--json" in flags: + print(json.dumps(snapshots, indent=2)) diff --git a/config.yaml b/config.yaml index 79e3509..9f6f930 100644 --- a/config.yaml +++ b/config.yaml @@ -115,6 +115,9 @@ client_profiles: routing_mode: coding-auto coding-free: prefer_providers: + - pollinations + - groq + - cerebras - kilocode - blackbox-free - gemini-flash-lite @@ -962,6 +965,240 @@ providers: connect_s: 10 read_s: 90 + # ── Free Providers (OmniRoute-inspired zero-cost fallback stack) ──────────── + pollinations: + api_key: "" + backend: openai-compat + base_url: https://text.pollinations.ai/openai + capabilities: + cost_tier: free + latency_tier: balanced + streaming: true + lane: + benchmark_cluster: budget-chat + canonical_model: aggregator/pollinations-openai + cluster: budget-general + context_strength: mid + degrade_to: [] + family: pollinations + freshness_hint: free no-key provider — polling models endpoint for availability + freshness_status: fresh + last_reviewed: '2026-05-04' + name: free + quality_tier: free + reasoning_strength: variable + review_age_days: 0 + route_type: aggregator + same_model_group: aggregator/pollinations-openai + tool_strength: low + max_tokens: 8000 + model: openai + tier: free + timeout: + connect_s: 10 + read_s: 60 + transport: + auth_mode: none + profile: free-generative + requires_api_key: false + groq: + api_key: ${GROQ_API_KEY} + backend: openai-compat + base_url: https://api.groq.com/openai/v1 + capabilities: + cost_tier: free + latency_tier: fast + streaming: true + lane: + benchmark_cluster: budget-chat + canonical_model: groq/llama-3.3-70b + cluster: budget-general + context_strength: mid + degrade_to: [] + family: groq + freshness_hint: free tier — 30 RPM, 14,400 RPD, 6K TPM + freshness_status: fresh + last_reviewed: '2026-05-04' + name: free + quality_tier: free + reasoning_strength: mid + review_age_days: 0 + route_type: direct + same_model_group: groq/llama-3.3-70b + tool_strength: low + max_tokens: 8000 + model: llama-3.3-70b-versatile + tier: free + timeout: + connect_s: 10 + read_s: 30 + cerebras: + api_key: ${CEREBRAS_API_KEY} + backend: openai-compat + base_url: https://api.cerebras.ai/v1 + capabilities: + cost_tier: free + latency_tier: fast + streaming: true + reasoning: true + lane: + benchmark_cluster: budget-chat + canonical_model: cerebras/qwen3-235b + cluster: budget-general + context_strength: mid + degrade_to: [] + family: cerebras + freshness_hint: free tier — 30 RPM, 1M TPD + freshness_status: fresh + last_reviewed: '2026-05-04' + name: free + quality_tier: free + reasoning_strength: high + review_age_days: 0 + route_type: direct + same_model_group: cerebras/qwen3-235b + tool_strength: medium + max_tokens: 8000 + model: qwen-3-235b-a22b-instruct-2507 + tier: free + timeout: + connect_s: 10 + read_s: 60 + longcat: + api_key: ${LONGCAT_API_KEY} + backend: openai-compat + base_url: https://api.longcat.ai/v1 + capabilities: + cost_tier: free + latency_tier: balanced + streaming: true + lane: + benchmark_cluster: budget-chat + canonical_model: longcat/flash-lite + cluster: budget-general + context_strength: mid + degrade_to: [] + family: longcat + freshness_hint: free tier — 50M tokens/day + freshness_status: fresh + last_reviewed: '2026-05-04' + name: free + quality_tier: free + reasoning_strength: low + review_age_days: 0 + route_type: direct + same_model_group: longcat/flash-lite + tool_strength: low + max_tokens: 8000 + model: longcat-flash-lite + tier: free + timeout: + connect_s: 10 + read_s: 60 + nvidia-nim: + api_key: ${NVIDIA_API_KEY} + backend: openai-compat + base_url: https://integrate.api.nvidia.com/v1 + capabilities: + cost_tier: free + latency_tier: fast + streaming: true + reasoning: true + lane: + benchmark_cluster: budget-chat + canonical_model: nvidia/nemotron + cluster: budget-general + context_strength: mid + degrade_to: [] + family: nvidia + freshness_hint: free tier — 129 models, 40 RPM + freshness_status: fresh + last_reviewed: '2026-05-04' + name: free + quality_tier: free + reasoning_strength: high + review_age_days: 0 + route_type: direct + same_model_group: nvidia/nemotron + tool_strength: medium + max_tokens: 8000 + model: nvidia/nemotron-super-49b-v1 + tier: free + timeout: + connect_s: 10 + read_s: 60 + kiro: + api_key: ${KIRO_API_KEY:-} + backend: openai-compat + base_url: ${KIRO_BASE_URL:-https://api.kiro.dev/v1} + capabilities: + cost_tier: free + latency_tier: balanced + streaming: true + lane: + benchmark_cluster: budget-chat + canonical_model: aggregator/kiro-claude-sonnet + cluster: budget-general + context_strength: high + degrade_to: [] + family: kiro + freshness_hint: free via AWS Builder ID OAuth — Claude Sonnet 4.5 unlimited + freshness_status: fresh + last_reviewed: '2026-05-04' + name: free + quality_tier: free + reasoning_strength: high + review_age_days: 0 + route_type: aggregator + same_model_group: aggregator/kiro-claude-sonnet + tool_strength: high + max_tokens: 8000 + model: claude-sonnet-4-6 + tier: free + timeout: + connect_s: 10 + read_s: 90 + transport: + auth_mode: none + profile: free-generative + requires_api_key: false + qoder: + api_key: ${QODER_API_KEY:-} + backend: openai-compat + base_url: ${QODER_BASE_URL:-https://api.qoder.ai/v1} + capabilities: + cost_tier: free + latency_tier: balanced + streaming: true + reasoning: true + lane: + benchmark_cluster: free-coding + canonical_model: aggregator/qoder-kimi-k2 + cluster: budget-general + context_strength: high + degrade_to: [] + family: qoder + freshness_hint: free via Google OAuth — kimi-k2-thinking unlimited + freshness_status: fresh + last_reviewed: '2026-05-04' + name: free + quality_tier: free + reasoning_strength: high + review_age_days: 0 + route_type: aggregator + same_model_group: aggregator/qoder-kimi-k2 + tool_strength: medium + max_tokens: 8000 + model: kimi-k2-thinking + tier: free + timeout: + connect_s: 10 + read_s: 90 + transport: + auth_mode: none + profile: free-generative + requires_api_key: false + # ── Local runtimes (uncomment and configure) ────────────────────────────── # ollama: # api_key: "" @@ -1705,6 +1942,8 @@ routing_modes: - default - fallback prefer_providers: + - groq + - cerebras - gemini-flash-lite - gemini-flash - anthropic-haiku @@ -1736,6 +1975,9 @@ routing_modes: - cheap - standard prefer_providers: + - pollinations + - groq + - cerebras - anthropic-haiku - gemini-flash-lite - gemini-flash diff --git a/faigate/__init__.py b/faigate/__init__.py index ed0a259..14f7773 100644 --- a/faigate/__init__.py +++ b/faigate/__init__.py @@ -1,3 +1,3 @@ """fusionAIze Gate package.""" -__version__ = "2.5.0" +__version__ = "2.6.0" diff --git a/faigate/assets/metadata/catalog.v1.json b/faigate/assets/metadata/catalog.v1.json index df86c10..89c9f3f 100644 --- a/faigate/assets/metadata/catalog.v1.json +++ b/faigate/assets/metadata/catalog.v1.json @@ -1141,6 +1141,167 @@ "input_cost_per_1m": 3.0, "output_cost_per_1m": 15.0 } + }, + "pollinations": { + "recommended_model": "pollinations/openai", + "aliases": ["pollinations", "pol", "pollinations/openai"], + "track": "free", + "offer_track": "free", + "provider_type": "aggregator", + "auth_modes": ["none"], + "volatility": "high", + "evidence_level": "official", + "official_source_url": "https://pollinations.ai/", + "signup_url": "https://enter.pollinations.ai", + "watch_sources": [], + "notes": "Pollinations free text+image+video+audio — no API key required. Supports GPT-5, Claude, Llama 4.", + "last_reviewed": "2026-05-04", + "pricing": { + "source_type": "provider-docs", + "source_url": "https://pollinations.ai/", + "refreshed_at": "2026-05-04T00:00:00Z", + "freshness_status": "fresh", + "input_cost_per_1m": 0.0, + "output_cost_per_1m": 0.0 + } + }, + "groq": { + "recommended_model": "groq/llama-3.3-70b", + "aliases": ["groq", "groq/llama-3.3-70b", "llama-3.3-70b"], + "track": "free", + "offer_track": "free", + "provider_type": "direct", + "auth_modes": ["api_key"], + "volatility": "low", + "evidence_level": "official", + "official_source_url": "https://console.groq.com/", + "signup_url": "https://console.groq.com/keys", + "watch_sources": [], + "notes": "Groq ultra-fast LPU inference — 300-500 tok/s. Free tier: 30 RPM, 14,400 RPD, 6K TPM.", + "last_reviewed": "2026-05-04", + "pricing": { + "source_type": "provider-docs", + "source_url": "https://console.groq.com/docs/rate-limits", + "refreshed_at": "2026-05-04T00:00:00Z", + "freshness_status": "fresh", + "input_cost_per_1m": 0.0, + "output_cost_per_1m": 0.0 + } + }, + "cerebras": { + "recommended_model": "cerebras/qwen3-235b", + "aliases": ["cerebras", "cerebras/qwen3-235b"], + "track": "free", + "offer_track": "free", + "provider_type": "direct", + "auth_modes": ["api_key"], + "volatility": "low", + "evidence_level": "official", + "official_source_url": "https://cloud.cerebras.ai/", + "signup_url": "https://cloud.cerebras.ai/", + "watch_sources": [], + "notes": "Cerebras wafer-scale inference ~2600 tok/s. Free tier: 30 RPM, 1M TPD.", + "last_reviewed": "2026-05-04", + "pricing": { + "source_type": "provider-docs", + "source_url": "https://cloud.cerebras.ai/", + "refreshed_at": "2026-05-04T00:00:00Z", + "freshness_status": "fresh", + "input_cost_per_1m": 0.0, + "output_cost_per_1m": 0.0 + } + }, + "longcat": { + "recommended_model": "longcat/flash-lite", + "aliases": ["longcat", "lc", "longcat/flash-lite"], + "track": "free", + "offer_track": "free", + "provider_type": "direct", + "auth_modes": ["api_key"], + "volatility": "high", + "evidence_level": "official", + "official_source_url": "https://longcat.ai/", + "signup_url": "https://longcat.ai/", + "watch_sources": [], + "notes": "LongCat AI free tier: Flash-Lite, 50M tokens/day free.", + "last_reviewed": "2026-05-04", + "pricing": { + "source_type": "provider-docs", + "source_url": "https://longcat.ai/", + "refreshed_at": "2026-05-04T00:00:00Z", + "freshness_status": "fresh", + "input_cost_per_1m": 0.0, + "output_cost_per_1m": 0.0 + } + }, + "nvidia-nim": { + "recommended_model": "nvidia/nemotron", + "aliases": ["nvidia-nim", "nvidia", "nim"], + "track": "free", + "offer_track": "free", + "provider_type": "direct", + "auth_modes": ["api_key"], + "volatility": "medium", + "evidence_level": "official", + "official_source_url": "https://build.nvidia.com/", + "signup_url": "https://build.nvidia.com/explore/discover", + "watch_sources": [], + "notes": "NVIDIA NIM free: 129 models, 40 RPM. Includes DeepSeek-R1, Llama 405B, Qwen3 Coder 480B.", + "last_reviewed": "2026-05-04", + "pricing": { + "source_type": "provider-docs", + "source_url": "https://build.nvidia.com/", + "refreshed_at": "2026-05-04T00:00:00Z", + "freshness_status": "fresh", + "input_cost_per_1m": 0.0, + "output_cost_per_1m": 0.0 + } + }, + "kiro": { + "recommended_model": "kiro/claude-sonnet", + "aliases": ["kiro", "kr", "kiro/claude-sonnet"], + "track": "free", + "offer_track": "free", + "provider_type": "aggregator", + "auth_modes": ["oauth"], + "volatility": "high", + "evidence_level": "official", + "official_source_url": "https://kiro.dev/", + "signup_url": "https://kiro.dev/", + "watch_sources": [], + "notes": "Kiro free tier via AWS Builder ID OAuth — Claude Sonnet 4.5 and Haiku 4.5 unlimited.", + "last_reviewed": "2026-05-04", + "pricing": { + "source_type": "provider-docs", + "source_url": "https://kiro.dev/", + "refreshed_at": "2026-05-04T00:00:00Z", + "freshness_status": "fresh", + "input_cost_per_1m": 0.0, + "output_cost_per_1m": 0.0 + } + }, + "qoder": { + "recommended_model": "qoder/kimi-k2", + "aliases": ["qoder", "qoder/kimi-k2", "qd"], + "track": "free", + "offer_track": "free", + "provider_type": "aggregator", + "auth_modes": ["oauth"], + "volatility": "high", + "evidence_level": "official", + "official_source_url": "https://qoder.ai/", + "signup_url": "https://qoder.ai/", + "watch_sources": [], + "notes": "Qoder free via Google OAuth — kimi-k2-thinking, qwen3-coder-plus, deepseek-r1 unlimited.", + "last_reviewed": "2026-05-04", + "pricing": { + "source_type": "provider-docs", + "source_url": "https://qoder.ai/", + "refreshed_at": "2026-05-04T00:00:00Z", + "freshness_status": "fresh", + "input_cost_per_1m": 0.0, + "output_cost_per_1m": 0.0 + } } } } diff --git a/faigate/breakers.py b/faigate/breakers.py new file mode 100644 index 0000000..465406a --- /dev/null +++ b/faigate/breakers.py @@ -0,0 +1,281 @@ +"""Per-provider circuit breaker state machine with SQLite persistence.""" + +from __future__ import annotations + +import enum +import logging +import random +import sqlite3 +import time +from dataclasses import dataclass, field + +logger = logging.getLogger("faigate.breakers") + + +class CircuitState(enum.Enum): + CLOSED = "CLOSED" + OPEN = "OPEN" + HALF_OPEN = "HALF_OPEN" + + def __str__(self) -> str: + return self.value + + +DEFAULT_FAILURE_WINDOW_S = 60 +DEFAULT_FAILURE_THRESHOLD = 3 +DEFAULT_COOLDOWN_S = 30 +DEFAULT_JITTER_S = 5.0 + + +@dataclass +class BreakerConfig: + failure_window_s: float = DEFAULT_FAILURE_WINDOW_S + failure_threshold: int = DEFAULT_FAILURE_THRESHOLD + cooldown_s: float = DEFAULT_COOLDOWN_S + jitter_s: float = DEFAULT_JITTER_S + + @classmethod + def from_provider_cfg(cls, cfg: dict | None) -> BreakerConfig: + if not cfg: + return cls() + return cls( + failure_window_s=float(cfg.get("failure_window_s", DEFAULT_FAILURE_WINDOW_S)), + failure_threshold=int(cfg.get("failure_threshold", DEFAULT_FAILURE_THRESHOLD)), + cooldown_s=float(cfg.get("cooldown_s", DEFAULT_COOLDOWN_S)), + jitter_s=float(cfg.get("jitter_s", DEFAULT_JITTER_S)), + ) + + +@dataclass +class Breaker: + provider_name: str + config: BreakerConfig = field(default_factory=BreakerConfig) + state: CircuitState = CircuitState.CLOSED + failure_count: int = 0 + failure_timestamps: list[float] = field(default_factory=list) + opened_at: float = 0.0 + cooldown_until: float = 0.0 + half_open_probes: int = 0 + last_success_at: float = 0.0 + last_failure_at: float = 0.0 + last_failure_error: str = "" + + @property + def is_closed(self) -> bool: + return self.state == CircuitState.CLOSED + + @property + def is_open(self) -> bool: + if self.state != CircuitState.OPEN: + return False + if time.time() >= self.cooldown_until: + return False + return True + + @property + def cooldown_remaining_s(self) -> float: + return max(0.0, self.cooldown_until - time.time()) + + def record_success(self) -> None: + now = time.time() + if self.state == CircuitState.HALF_OPEN: + self.state = CircuitState.CLOSED + self.failure_count = 0 + self.failure_timestamps.clear() + self.half_open_probes = 0 + self.last_failure_error = "" + self.opened_at = 0.0 + self.cooldown_until = 0.0 + logger.info("Breaker %s: HALF_OPEN → CLOSED (probe succeeded)", self.provider_name) + elif self.state == CircuitState.CLOSED: + self.failure_count = 0 + self.failure_timestamps.clear() + self.last_success_at = now + + def record_failure(self, error: str = "") -> None: + now = time.time() + self.last_failure_at = now + self.last_failure_error = error + + if self.state == CircuitState.HALF_OPEN: + self.state = CircuitState.OPEN + self.opened_at = now + jitter = random.uniform(-self.config.jitter_s, self.config.jitter_s) + self.cooldown_until = now + self.config.cooldown_s + jitter + self.half_open_probes = 0 + logger.warning( + "Breaker %s: HALF_OPEN → OPEN (probe failed): %s", + self.provider_name, + error, + ) + return + + self.failure_timestamps.append(now) + cutoff = now - self.config.failure_window_s + self.failure_timestamps = [t for t in self.failure_timestamps if t >= cutoff] + self.failure_count = len(self.failure_timestamps) + + if self.failure_count >= self.config.failure_threshold: + self.state = CircuitState.OPEN + self.opened_at = now + jitter = random.uniform(-self.config.jitter_s, self.config.jitter_s) + self.cooldown_until = now + self.config.cooldown_s + jitter + logger.warning( + "Breaker %s: CLOSED → OPEN (%d failures in %.0fs): %s", + self.provider_name, + self.failure_count, + self.config.failure_window_s, + error, + ) + + def allow_request(self) -> bool: + if self.state == CircuitState.CLOSED: + return True + if self.state == CircuitState.OPEN: + if time.time() >= self.cooldown_until: + self.state = CircuitState.HALF_OPEN + self.half_open_probes = 0 + logger.info("Breaker %s: OPEN → HALF_OPEN (cooldown elapsed)", self.provider_name) + return True + return False + if self.state == CircuitState.HALF_OPEN: + return True + return True + + def force_closed(self) -> None: + self.state = CircuitState.CLOSED + self.failure_count = 0 + self.failure_timestamps.clear() + self.half_open_probes = 0 + self.opened_at = 0.0 + self.cooldown_until = 0.0 + self.last_failure_error = "" + logger.info("Breaker %s: force-reset to CLOSED", self.provider_name) + + def to_dict(self) -> dict: + return { + "provider": self.provider_name, + "state": str(self.state), + "failure_count": self.failure_count, + "failure_window_s": self.config.failure_window_s, + "cooldown_s": self.config.cooldown_s, + "cooldown_remaining_s": round(self.cooldown_remaining_s, 1), + "opened_at": self.opened_at, + "half_open_probes": self.half_open_probes, + "last_success_at": self.last_success_at, + "last_failure_at": self.last_failure_at, + "last_failure_error": self.last_failure_error, + } + + +class BreakerRegistry: + """Singleton registry of per-provider circuit breakers.""" + + def __init__(self) -> None: + self._breakers: dict[str, Breaker] = {} + self._db_path: str | None = None + + def configure_persistence(self, db_path: str) -> None: + self._db_path = db_path + self._ensure_table() + self.load_all() + + def get_or_create(self, provider_name: str, config_cfg: dict | None = None) -> Breaker: + if provider_name not in self._breakers: + breaker_config = BreakerConfig.from_provider_cfg(config_cfg) + self._breakers[provider_name] = Breaker( + provider_name=provider_name, + config=breaker_config, + ) + return self._breakers[provider_name] + + def get(self, provider_name: str) -> Breaker | None: + return self._breakers.get(provider_name) + + def all_states(self) -> dict[str, dict]: + return {name: b.to_dict() for name, b in self._breakers.items()} + + def force_closed(self, provider_name: str) -> bool: + breaker = self._breakers.get(provider_name) + if breaker is None: + return False + breaker.force_closed() + self._save(breaker) + return True + + def _save(self, breaker: Breaker) -> None: + if not self._db_path: + return + try: + with sqlite3.connect(self._db_path) as conn: + conn.execute( + """INSERT OR REPLACE INTO circuit_breakers + (provider, state, failure_count, opened_at, half_open_probes, updated_at) + VALUES (?, ?, ?, ?, ?, ?)""", + ( + breaker.provider_name, + str(breaker.state), + breaker.failure_count, + breaker.opened_at, + breaker.half_open_probes, + time.time(), + ), + ) + except Exception: + logger.exception("Failed to persist breaker state for %s", breaker.provider_name) + + def persist_all(self) -> None: + for breaker in self._breakers.values(): + self._save(breaker) + + def load_all(self) -> None: + if not self._db_path: + return + try: + with sqlite3.connect(self._db_path) as conn: + rows = conn.execute( + "SELECT provider, state, failure_count, opened_at, " + "half_open_probes, updated_at FROM circuit_breakers" + ).fetchall() + except sqlite3.OperationalError: + self._ensure_table() + return + now = time.time() + for row in rows: + provider, state_str, failure_count, opened_at, half_open_probes, _updated_at = row + breaker = self.get_or_create(provider) + breaker.failure_count = failure_count + breaker.opened_at = opened_at + breaker.half_open_probes = half_open_probes + if state_str == "OPEN": + breaker.state = CircuitState.OPEN + breaker.cooldown_until = ( + opened_at + breaker.config.cooldown_s if opened_at else now + breaker.config.cooldown_s + ) + if now >= breaker.cooldown_until: + breaker.state = CircuitState.HALF_OPEN + logger.info("Breaker %s: restored OPEN → HALF_OPEN (cooldown elapsed)", provider) + elif state_str == "HALF_OPEN": + breaker.state = CircuitState.HALF_OPEN + logger.debug("Breaker %s: loaded state %s from persistence", provider, state_str) + + def _ensure_table(self) -> None: + if not self._db_path: + return + try: + with sqlite3.connect(self._db_path) as conn: + conn.execute( + """CREATE TABLE IF NOT EXISTS circuit_breakers ( + provider TEXT PRIMARY KEY, + state TEXT NOT NULL DEFAULT 'CLOSED', + failure_count INTEGER NOT NULL DEFAULT 0, + opened_at REAL NOT NULL DEFAULT 0.0, + half_open_probes INTEGER NOT NULL DEFAULT 0, + updated_at REAL NOT NULL DEFAULT 0.0 + )""" + ) + except Exception: + logger.exception("Failed to create circuit_breakers table") + + +breaker_registry = BreakerRegistry() diff --git a/faigate/cockpit_tui.py b/faigate/cockpit_tui.py new file mode 100644 index 0000000..b980265 --- /dev/null +++ b/faigate/cockpit_tui.py @@ -0,0 +1,303 @@ +"""Agent-native terminal cockpit for faigate — built with Textual. + +Usage: + faigate cockpit # Interactive TUI + faigate cockpit --agent health # JSON output for agent consumption + faigate cockpit --agent providers + faigate cockpit --agent circuits + faigate cockpit --agent stats + faigate cockpit --agent routes +""" + +from __future__ import annotations + +import argparse +import json +from typing import Any + +import httpx +from textual.app import App, ComposeResult +from textual.widget import Widget +from textual.widgets import Footer, Header, Static, TabbedContent, TabPane + +COCKPIT_BASE: str = "http://127.0.0.1:8092" + +# ── Helper ────────────────────────────────────────────────────────────── + + +def _fetch(endpoint: str) -> dict[str, Any]: + """Fetch a /api/cockpit/ endpoint; returns {} on error.""" + try: + r = httpx.get(f"{COCKPIT_BASE}{endpoint}", timeout=5) + r.raise_for_status() + return r.json() + except Exception: + return {} + + +def _circuit_color(state: str) -> str: + if state == "OPEN": + return "[bold red]●[/]" + if state == "HALF_OPEN": + return "[bold yellow]◐[/]" + return "[bold green]●[/]" + + +def _healthy_color(healthy: bool) -> str: + return "[green]✓[/]" if healthy else "[red]✗[/]" + + +def _latency_str(ms: float) -> str: + if ms <= 0: + return "—" + if ms < 500: + return f"[green]{ms:.0f}ms[/]" + if ms < 2000: + return f"[yellow]{ms:.0f}ms[/]" + return f"[red]{ms:.0f}ms[/]" + + +# ── Dashboard Tab ───────────────────────────────────────────────────── + + +class DashboardTab(Widget): + CIRCUIT_ORDER: list[str] = ["OPEN", "HALF_OPEN", "CLOSED"] + + def compose(self) -> ComposeResult: + yield Static("Refreshing…", id="cockpit-summary") + yield Static("", id="cockpit-grid") + + def on_mount(self) -> None: + self.set_interval(5.0, self.refresh_data) + self.refresh_data() + + def refresh_data(self) -> None: + data = _fetch("/api/cockpit/health") + if not data: + return + summary = data.get("summary", {}) + summary_widget = self.query_one("#cockpit-summary", Static) + summary_widget.update( + f"[bold]Providers:[/] {summary.get('total', 0)} total, " + f"[green]{summary.get('healthy', 0)} healthy[/], " + f"[yellow]{summary.get('degraded', 0)} degraded[/], " + f"[red]{summary.get('unhealthy', 0)} unhealthy[/], " + f"[red]⚡{summary.get('circuits_open', 0)} open[/]" + ) + + providers: dict[str, dict] = data.get("providers", {}) + sorted_providers = sorted( + providers.items(), + key=lambda kv: ( + self.CIRCUIT_ORDER.index(kv[1].get("circuit", "CLOSED")) + if kv[1].get("circuit") in self.CIRCUIT_ORDER + else 99, + kv[0], + ), + ) + rows: list[str] = [] + for name, p in sorted_providers: + circ = p.get("circuit", "CLOSED") + fails = p.get("circuit_failures", 0) + fail_str = f"[red]{fails} fails[/]" if fails > 0 else "" + rows.append( + f"{_circuit_color(circ)} {_healthy_color(p.get('healthy', False))} " + f"[bold]{name}[/] {_latency_str(p.get('latency_ms', 0))} " + f"{fail_str}" + ) + self.query_one("#cockpit-grid", Static).update("\n".join(rows)) + + +# ── Providers Tab ───────────────────────────────────────────────────── + + +class ProvidersTab(Widget): + def compose(self) -> ComposeResult: + yield Static("Loading providers…", id="providers-detail") + + def on_mount(self) -> None: + self.set_interval(5.0, self.refresh_data) + self.refresh_data() + + def refresh_data(self) -> None: + data = _fetch("/api/cockpit/providers") + if not data: + return + providers_list: list[dict] = data.get("providers", []) + rows: list[str] = [] + for p in providers_list: + name = p.get("name", "?") + backend = p.get("backend", "?") + model = p.get("model", "?") + tier = p.get("tier", "?") + circ = p.get("circuit", "CLOSED") + healthy = p.get("healthy", False) + latency = p.get("latency_ms", 0) + context = p.get("context_window", 0) or 0 + rows.append( + f"{_circuit_color(circ)} {_healthy_color(healthy)} " + f"[bold]{name}[/] {_latency_str(latency)}" + f"\n backend={backend} model={model} tier={tier}" + f"{' ctx=' + str(context) if context else ''}" + ) + self.query_one("#providers-detail", Static).update("\n\n".join(rows)) + + +# ── Circuits Tab ───────────────────────────────────────────────────── + + +class CircuitsTab(Widget): + def compose(self) -> ComposeResult: + yield Static("Loading circuits…", id="circuits-content") + + def on_mount(self) -> None: + self.set_interval(3.0, self.refresh_data) + self.refresh_data() + + def refresh_data(self) -> None: + data = _fetch("/api/cockpit/circuits") + if not data: + return + circuits: dict[str, dict] = data.get("circuits", {}) + if not circuits: + self.query_one("#circuits-content", Static).update("[dim]No circuit data[/]") + return + rows: list[str] = [] + for name, c in sorted(circuits.items()): + state = c.get("state", "CLOSED") + failures = c.get("failure_count", 0) + cooldown = c.get("cooldown_remaining_s", 0) + last_fail = c.get("last_failure_error", "")[:80] + rows.append( + f"{_circuit_color(state)} [bold]{name}[/] [{state}] {failures} failures" + + (f" cooldown {cooldown:.0f}s remaining" if state == "OPEN" and cooldown > 0 else "") + + (f"\n last error: {last_fail}" if last_fail else "") + ) + self.query_one("#circuits-content", Static).update("\n\n".join(rows)) + + +# ── Routes Tab ─────────────────────────────────────────────────────── + + +class RoutesTab(Widget): + def compose(self) -> ComposeResult: + yield Static("Loading routes…", id="routes-content") + + def on_mount(self) -> None: + self.set_interval(5.0, self.refresh_data) + self.refresh_data() + + def refresh_data(self) -> None: + data = _fetch("/api/cockpit/routes/log?limit=20") + if not data: + return + routes_list: list[dict] = data.get("routes", []) + if not routes_list: + self.query_one("#routes-content", Static).update("[dim]No recent routes[/]") + return + rows: list[str] = [] + for r in routes_list[:15]: + provider = r.get("provider", "?") + model = r.get("requested_model") or r.get("model", "?") + success = r.get("success", True) + ts = r.get("timestamp", "") + latency = r.get("latency_ms", 0) + status = "[green]✓[/]" if success else "[red]✗[/]" + rows.append(f"{status} {provider} {model} {_latency_str(latency)} [dim]{ts}[/]") + self.query_one("#routes-content", Static).update("\n".join(rows)) + + +# ── Main TUI App ───────────────────────────────────────────────────── + + +class CockpitApp(App): + CSS = """ + Screen { + background: #0a0a0f; + } + Header { + background: #14141f; + color: #7af; + } + Footer { + background: #14141f; + } + TabPane { + padding: 1; + } + #cockpit-summary { + padding: 1; + border: solid #222; + margin-bottom: 1; + background: #14141f; + } + #cockpit-grid, #providers-detail, #circuits-content, #routes-content { + padding: 0 1; + height: 1fr; + } + """ + + TITLE = "faigate cockpit" + SUB_TITLE = "— operator view —" + + def compose(self) -> ComposeResult: + yield Header() + with TabbedContent(): + with TabPane(" Dashboard "): + yield DashboardTab() + with TabPane(" Providers "): + yield ProvidersTab() + with TabPane(" Circuits "): + yield CircuitsTab() + with TabPane(" Routes "): + yield RoutesTab() + yield Footer() + + def key_q(self) -> None: + self.exit() + + +# ── Agent mode (JSON output) ───────────────────────────────────────── + + +_AGENT_ENDPOINTS: dict[str, str] = { + "health": "/api/cockpit/health", + "providers": "/api/cockpit/providers", + "circuits": "/api/cockpit/circuits", + "stats": "/api/cockpit/stats", + "routes": "/api/cockpit/routes/log", +} + + +def agent_json_output(endpoint: str) -> None: + path = _AGENT_ENDPOINTS.get(endpoint, f"/api/cockpit/{endpoint}") + data = _fetch(path) + print(json.dumps(data, indent=2, default=str)) + + +def run_tui() -> None: + CockpitApp().run() + + +def main() -> None: + parser = argparse.ArgumentParser( + prog="faigate cockpit", + description="Agent-native terminal cockpit for faigate", + ) + parser.add_argument( + "--agent", + nargs="?", + const="health", + choices=list(_AGENT_ENDPOINTS.keys()), + help="Agent mode: output JSON for the given endpoint", + ) + args = parser.parse_args() + + if args.agent: + agent_json_output(args.agent) + else: + run_tui() + + +if __name__ == "__main__": + main() diff --git a/faigate/main.py b/faigate/main.py index e744ed3..c082698 100644 --- a/faigate/main.py +++ b/faigate/main.py @@ -33,6 +33,7 @@ from . import __version__ from .adaptation import AdaptiveRouteState from .api.anthropic.models import AnthropicBridgeError, parse_anthropic_messages_request +from .breakers import breaker_registry from .bridges.anthropic import ( anthropic_request_to_canonical, canonical_response_to_anthropic, @@ -1825,6 +1826,26 @@ async def _execute_chat_completion_body( provider = _providers.get(provider_name) if not provider: continue + + breaker = breaker_registry.get_or_create(provider_name) + if not breaker.allow_request(): + logger.info( + "Skipping provider %s — circuit breaker %s (cooldown %.0fs remaining)", + provider_name, + breaker.state, + breaker.cooldown_remaining_s, + ) + errors.append( + { + "provider": provider_name, + "status": 0, + "category": "circuit-open", + "circuit_state": str(breaker.state), + "cooldown_remaining_s": round(breaker.cooldown_remaining_s, 1), + } + ) + continue + if not provider.health.healthy and provider_name != attempt_order[0]: continue quota_group = _provider_quota_group(provider) @@ -1859,6 +1880,7 @@ async def _execute_chat_completion_body( tools=tools, extra_body=extra_body, ) + breaker.record_success() _adaptive_state.record_success( provider_name, latency_ms=(result.get("_faigate") or {}).get("latency_ms", 0) if isinstance(result, dict) else 0.0, @@ -1917,6 +1939,7 @@ async def _execute_chat_completion_body( ) except ProviderError as e: _adaptive_state.record_failure(provider_name, error=e.detail[:500]) + breaker.record_failure(error=e.detail[:500]) classify_issue = getattr(provider, "classify_runtime_issue", None) if callable(classify_issue): issue_type = classify_issue(status=e.status, detail=e.detail) @@ -2378,6 +2401,10 @@ async def lifespan(app: FastAPI): _metrics = MetricsStore(db_path=_config.metrics["db_path"]) if _config.metrics.get("enabled"): _metrics.init() + + # Circuit breakers — persist state in the same SQLite database + breaker_registry.configure_persistence(_config.metrics["db_path"]) + try: _provider_catalog_store = ProviderCatalogStore(_config.metrics["db_path"]) _provider_catalog_store.init() @@ -3095,6 +3122,110 @@ async def get_alerts(lookback_hours: int = 1, baseline_hours: int = 24): } +# ── Cockpit API (operator dashboard backend) ────────────────────────── + + +@app.get("/api/cockpit/health") +async def cockpit_health(): + """Provider health with circuit breaker state — wraps /health plumbing.""" + await _refresh_local_worker_probes() + readiness = _request_readiness_summary() + providers = {} + for name, p in _providers.items(): + breaker = breaker_registry.get(name) + providers[name] = { + "name": name, + "healthy": p.health.healthy, + "circuit": str(breaker.state) if breaker else "CLOSED", + "circuit_failures": breaker.failure_count if breaker else 0, + "circuit_cooldown_remaining_s": round(breaker.cooldown_remaining_s, 1) if breaker else 0.0, + "latency_ms": round(p.health.avg_latency_ms, 1), + "last_check": p.health.last_check, + "last_error": p.health.last_error, + "ready": readiness.get("providers_ready", 0) > 0, + "status": ( + "ready-verified" + if p.health.healthy and p.health.avg_latency_ms > 0 + else "ready" + if p.health.healthy + else "unhealthy" + ), + } + return { + "providers": providers, + "summary": { + "total": len(providers), + "healthy": sum(1 for v in providers.values() if v["healthy"]), + "degraded": sum(1 for v in providers.values() if v["healthy"] and v["latency_ms"] > 2000), + "unhealthy": sum(1 for v in providers.values() if not v["healthy"]), + "circuits_open": sum(1 for v in providers.values() if v["circuit"] == "OPEN"), + }, + } + + +@app.get("/api/cockpit/providers") +async def cockpit_providers(provider: str | None = None): + """Provider catalog with runtime state — wraps provider inventory.""" + await _refresh_local_worker_probes() + rows = _build_provider_inventory(capability=None, healthy=None) + providers_out = [] + for p in rows: + if provider and p.get("name") != provider: + continue + name = p.get("name", "") + breaker = breaker_registry.get(name) + p["circuit"] = str(breaker.state) if breaker else "CLOSED" + p["circuit_failures"] = breaker.failure_count if breaker else 0 + p["circuit_cooldown_remaining_s"] = round(breaker.cooldown_remaining_s, 1) if breaker else 0.0 + providers_out.append(p) + return {"providers": providers_out} + + +@app.get("/api/cockpit/stats") +async def cockpit_stats(provider: str | None = None): + """Lightweight stats for cockpit dashboard consumption.""" + filters: dict[str, Any] = {} + if provider: + filters["provider"] = provider + totals = _metrics.get_totals(**filters) if _config.metrics.get("enabled") else {} + return { + "total_requests": totals.get("requests", 0), + "tokens_in": totals.get("tokens_prompt", 0), + "tokens_out": totals.get("tokens_completion", 0), + "providers_active": totals.get("unique_providers", 0), + "requests_24h": totals.get("requests_24h", 0), + "errors_24h": totals.get("errors_24h", 0), + "top_providers": totals.get("top_providers", []), + } + + +@app.get("/api/cockpit/circuits") +async def cockpit_circuits(): + """All circuit breaker states.""" + return {"circuits": breaker_registry.all_states()} + + +@app.post("/api/cockpit/circuits/{provider_name}/reset") +async def cockpit_circuit_reset(provider_name: str): + """Force-reset a circuit breaker to CLOSED.""" + if not breaker_registry.force_closed(provider_name): + return JSONResponse( + {"error": f"Provider '{provider_name}' not found"}, + status_code=404, + ) + return {"provider": provider_name, "circuit": "CLOSED", "reset": True} + + +@app.get("/api/cockpit/routes/log") +async def cockpit_routes_log(limit: int = 50, provider: str | None = None): + """Recent routing decisions.""" + logs = _metrics.get_recent(limit=limit, provider=provider) if _config.metrics.get("enabled") else [] + return {"routes": logs} + + +# ── Anomaly detection helpers ───────────────────────────────────────── + + def _build_cache_intelligence( provider_name: str, request_dims: dict[str, Any], @@ -4817,6 +4948,12 @@ async def chat_completions(request: Request): if isinstance(execution, _ChatExecutionFailure): return JSONResponse(execution.body, status_code=execution.status_code) + circuit_state = ( + str(breaker_registry.get(execution.provider_name).state) + if breaker_registry.get(execution.provider_name) + else "unknown" + ) + if execution.stream: return StreamingResponse( _safe_openai_sse_stream( @@ -4828,6 +4965,7 @@ async def chat_completions(request: Request): headers={ "X-faigate-Provider": execution.provider_name, "X-faigate-Profile": execution.client_profile, + "X-faigate-Circuit": circuit_state, "X-faigate-Hooks": ",".join(execution.hook_state.applied_hooks), "X-faigate-Hook-Errors": str(len(execution.hook_state.errors)), "x-faigate-trace-id": execution.trace_id or str(uuid.uuid4()), @@ -4837,6 +4975,7 @@ async def chat_completions(request: Request): resp = JSONResponse(execution.result) resp.headers["X-faigate-Provider"] = execution.provider_name resp.headers["X-faigate-Profile"] = execution.client_profile + resp.headers["X-faigate-Circuit"] = circuit_state if execution.resolved_mode: resp.headers["X-faigate-Mode"] = execution.resolved_mode if execution.resolved_shortcut: @@ -5032,7 +5171,28 @@ def main(): action="version", version=f"%(prog)s {__version__}", ) + subparsers = parser.add_subparsers(dest="command", help="Subcommands") + cockpit_parser = subparsers.add_parser("cockpit", help="Launch terminal cockpit") + cockpit_parser.add_argument( + "--agent", + nargs="?", + const="health", + choices=["health", "providers", "circuits", "stats", "routes"], + help="Agent mode: output JSON for the given endpoint", + ) + args = parser.parse_args() + + if args.command == "cockpit": + from .cockpit_tui import agent_json_output, run_tui + + agent_value = getattr(args, "agent", None) + if agent_value: + agent_json_output(agent_value) + else: + run_tui() + return + if args.config: os.environ["FAIGATE_CONFIG_FILE"] = args.config diff --git a/faigate/provider_catalog.py b/faigate/provider_catalog.py index 04056a7..427780f 100644 --- a/faigate/provider_catalog.py +++ b/faigate/provider_catalog.py @@ -411,7 +411,7 @@ def _get_packages_for_provider(provider_name: str) -> list[dict[str, Any]]: "signup_url": "https://platform.deepseek.com/", "watch_sources": [], "notes": get_active_model_label("deepseek/chat"), - "last_reviewed": "2026-03-29", + "last_reviewed": "2026-05-04", }, "deepseek-reasoner": { "recommended_model": get_active_model_id("deepseek/reasoner"), @@ -426,7 +426,7 @@ def _get_packages_for_provider(provider_name: str) -> list[dict[str, Any]]: "signup_url": "https://platform.deepseek.com/", "watch_sources": [], "notes": get_active_model_label("deepseek/reasoner"), - "last_reviewed": "2026-03-29", + "last_reviewed": "2026-05-04", }, "gemini-flash-lite": { "recommended_model": get_active_model_id("google/gemini-flash-lite"), @@ -441,7 +441,7 @@ def _get_packages_for_provider(provider_name: str) -> list[dict[str, Any]]: "signup_url": "https://aistudio.google.com/", "watch_sources": [], "notes": get_active_model_label("google/gemini-flash-lite"), - "last_reviewed": "2026-03-29", + "last_reviewed": "2026-05-04", }, "gemini-flash": { "recommended_model": get_active_model_id("google/gemini-flash"), @@ -456,7 +456,7 @@ def _get_packages_for_provider(provider_name: str) -> list[dict[str, Any]]: "signup_url": "https://aistudio.google.com/", "watch_sources": [], "notes": get_active_model_label("google/gemini-flash"), - "last_reviewed": "2026-03-29", + "last_reviewed": "2026-05-04", }, "openrouter-fallback": { "recommended_model": "openrouter/auto", @@ -504,7 +504,7 @@ def _get_packages_for_provider(provider_name: str) -> list[dict[str, Any]]: "Kilo paid Sonnet lane; useful as the workhorse path when you want " "Kilo credits to absorb balanced coding traffic" ), - "last_reviewed": "2026-03-29", + "last_reviewed": "2026-05-04", }, "kilo-opus": { "recommended_model": "anthropic/claude-opus-4.6", @@ -519,7 +519,7 @@ def _get_packages_for_provider(provider_name: str) -> list[dict[str, Any]]: "signup_url": "https://kilo.ai/", "watch_sources": [], "notes": ("Kilo paid Opus lane; useful when expiring Kilo credits should absorb premium reasoning traffic"), - "last_reviewed": "2026-03-29", + "last_reviewed": "2026-05-04", }, "blackbox-free": { "recommended_model": "blackboxai/x-ai/grok-code-fast-1", @@ -537,7 +537,7 @@ def _get_packages_for_provider(provider_name: str) -> list[dict[str, Any]]: "Legacy provider id for the current low-cost BLACKBOX Grok Code Fast route; " # noqa: E501 "verify often because pricing and model availability can rotate" ), - "last_reviewed": "2026-03-29", + "last_reviewed": "2026-05-04", }, "openai-gpt4o": { "recommended_model": "gpt-4o", @@ -763,18 +763,18 @@ def _get_packages_for_provider(provider_name: str) -> list[dict[str, Any]]: # ── Groq ───────────────────────────────────────────────────────────────── "groq": { "recommended_model": "llama-3.3-70b-versatile", - "aliases": ["groq", "llama-3.3"], - "track": "stable", - "offer_track": "direct", + "aliases": ["groq", "groq/llama-3.3-70b", "llama-3.3-70b", "llama-3.3"], + "track": "free", + "offer_track": "free", "provider_type": "direct", "auth_modes": ["api_key"], "volatility": "low", "evidence_level": "official", "official_source_url": "https://console.groq.com/docs/quickstart", - "signup_url": "https://console.groq.com/", + "signup_url": "https://console.groq.com/keys", "watch_sources": [], - "notes": "Groq – ultra-fast inference (LPU), Llama / DeepSeek", - "last_reviewed": "2026-04-03", + "notes": "Groq ultra-fast LPU — free tier: 30 RPM, 14,400 RPD", + "last_reviewed": "2026-05-04", }, # ── Hugging Face Inference ─────────────────────────────────────────────── "huggingface": { @@ -936,6 +936,82 @@ def _get_packages_for_provider(provider_name: str) -> list[dict[str, Any]]: "notes": "Kilo Auto Free lane – free-tier routing through Kilo gateway", "last_reviewed": "2026-04-03", }, + # ── Free Providers (OmniRoute-inspired zero-cost stack) ──────────────────── + "pollinations": { + "recommended_model": "pollinations/openai", + "aliases": ["pollinations", "pol", "pollinations/openai"], + "track": "free", + "offer_track": "free", + "provider_type": "aggregator", + "auth_modes": ["none"], + "volatility": "high", + "evidence_level": "official", + "official_source_url": "https://pollinations.ai/", + "signup_url": "https://enter.pollinations.ai", + "watch_sources": [_COMMUNITY_WATCHLIST], + "notes": "Pollinations free text+image+video+audio — no API key required", + "last_reviewed": "2026-05-04", + }, + "longcat": { + "recommended_model": "longcat/flash-lite", + "aliases": ["longcat", "lc", "longcat/flash-lite"], + "track": "free", + "offer_track": "free", + "provider_type": "direct", + "auth_modes": ["api_key"], + "volatility": "high", + "evidence_level": "official", + "official_source_url": "https://longcat.ai/", + "signup_url": "https://longcat.ai/", + "watch_sources": [_COMMUNITY_WATCHLIST], + "notes": "LongCat AI free tier: Flash-Lite, 50M tokens/day", + "last_reviewed": "2026-05-04", + }, + "nvidia-nim": { + "recommended_model": "nvidia/nemotron", + "aliases": ["nvidia-nim", "nvidia", "nim"], + "track": "free", + "offer_track": "free", + "provider_type": "direct", + "auth_modes": ["api_key"], + "volatility": "medium", + "evidence_level": "official", + "official_source_url": "https://build.nvidia.com/", + "signup_url": "https://build.nvidia.com/explore/discover", + "watch_sources": [], + "notes": "NVIDIA NIM free: 129 models, 40 RPM. DeepSeek-R1, Llama 405B, Qwen3 Coder 480B", + "last_reviewed": "2026-05-04", + }, + "kiro": { + "recommended_model": "kiro/claude-sonnet", + "aliases": ["kiro", "kr", "kiro/claude-sonnet"], + "track": "free", + "offer_track": "free", + "provider_type": "aggregator", + "auth_modes": ["oauth"], + "volatility": "high", + "evidence_level": "official", + "official_source_url": "https://kiro.dev/", + "signup_url": "https://kiro.dev/", + "watch_sources": [_COMMUNITY_WATCHLIST], + "notes": "Kiro free via AWS Builder ID OAuth — Claude Sonnet 4.5 unlimited", + "last_reviewed": "2026-05-04", + }, + "qoder": { + "recommended_model": "qoder/kimi-k2", + "aliases": ["qoder", "qd", "qoder/kimi-k2"], + "track": "free", + "offer_track": "free", + "provider_type": "aggregator", + "auth_modes": ["oauth"], + "volatility": "high", + "evidence_level": "official", + "official_source_url": "https://qoder.ai/", + "signup_url": "https://qoder.ai/", + "watch_sources": [_COMMUNITY_WATCHLIST], + "notes": "Qoder free via Google OAuth — kimi-k2-thinking unlimited", + "last_reviewed": "2026-05-04", + }, # ── OpenAI Codex (OAuth via ChatGPT) ───────────────────────────────────── "openai-codex": { "recommended_model": "openai-codex/gpt-5.3-codex", @@ -970,19 +1046,19 @@ def _get_packages_for_provider(provider_name: str) -> list[dict[str, Any]]: }, # ── Cerebras ──────────────────────────────────────────────────────────── "cerebras": { - "recommended_model": "llama3.3-70b", - "aliases": ["cerebras", "llama3.3"], - "track": "stable", - "offer_track": "direct", + "recommended_model": "qwen-3-235b-a22b-instruct-2507", + "aliases": ["cerebras", "cerebras/qwen3-235b", "llama3.3"], + "track": "free", + "offer_track": "free", "provider_type": "direct", "auth_modes": ["api_key"], "volatility": "low", "evidence_level": "official", - "official_source_url": "https://docs.cerebras.ai/", - "signup_url": "https://cerebras.ai/", + "official_source_url": "https://cloud.cerebras.ai/", + "signup_url": "https://cloud.cerebras.ai/", "watch_sources": [], - "notes": "Cerebras – fast inference, zai-glm-4.7 / zai-glm-4.6 compatible", - "last_reviewed": "2026-04-03", + "notes": "Cerebras wafer-scale ~2600 tok/s — free tier: 1M TPD", + "last_reviewed": "2026-05-04", }, # ── GitHub Copilot ────────────────────────────────────────────────────── "github-copilot": { diff --git a/pyproject.toml b/pyproject.toml index 41590be..00439d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "faigate" -version = "2.5.0" +version = "2.6.0" description = "Local OpenAI-compatible routing gateway for OpenClaw and other AI-native clients." readme = "README.md" license = "Apache-2.0" @@ -52,6 +52,7 @@ dev = [ "pre-commit>=3.0", "bandit[toml]>=1.8.0", "jinja2>=3.1.0", + "textual>=0.52.0", ] oauth = [ "requests>=2.31.0", diff --git a/tests/test_breakers.py b/tests/test_breakers.py new file mode 100644 index 0000000..00ea56f --- /dev/null +++ b/tests/test_breakers.py @@ -0,0 +1,237 @@ +"""Unit tests for faigate circuit breaker state machine.""" + +from __future__ import annotations + +import sqlite3 +import time +from pathlib import Path + +from faigate.breakers import ( + Breaker, + BreakerConfig, + BreakerRegistry, + CircuitState, + breaker_registry, +) + + +class TestBreakerConfig: + def test_defaults(self): + cfg = BreakerConfig() + assert cfg.failure_threshold == 3 + assert cfg.failure_window_s == 60 + assert cfg.cooldown_s == 30 + assert cfg.jitter_s == 5 + + def test_custom(self): + cfg = BreakerConfig(failure_threshold=5, failure_window_s=120, cooldown_s=60, jitter_s=10) + assert cfg.failure_threshold == 5 + assert cfg.failure_window_s == 120 + assert cfg.cooldown_s == 60 + assert cfg.jitter_s == 10 + + def test_from_provider_cfg(self): + cfg = BreakerConfig.from_provider_cfg({"failure_threshold": 5, "cooldown_s": 45}) + assert cfg.failure_threshold == 5 + assert cfg.cooldown_s == 45 + assert cfg.failure_window_s == 60 + + def test_from_provider_cfg_none(self): + cfg = BreakerConfig.from_provider_cfg(None) + assert cfg.failure_threshold == 3 + assert cfg.cooldown_s == 30 + + +class TestBreakerBasic: + def test_new_breaker_is_closed(self): + b = Breaker(provider_name="test") + assert b.state == CircuitState.CLOSED + assert b.allow_request() is True + assert b.failure_count == 0 + + def test_three_failures_opens_circuit(self): + b = Breaker(provider_name="test") + b.record_failure("err1") + assert b.state == CircuitState.CLOSED + b.record_failure("err2") + assert b.state == CircuitState.CLOSED + b.record_failure("err3") + assert b.state == CircuitState.OPEN + assert b.allow_request() is False + + def test_single_failure_does_not_open(self): + b = Breaker(provider_name="test") + b.record_failure("err") + assert b.state == CircuitState.CLOSED + assert b.allow_request() is True + + def test_open_blocks_requests(self): + b = Breaker(provider_name="test") + for i in range(3): + b.record_failure(f"err {i}") + assert b.state == CircuitState.OPEN + assert b.allow_request() is False + + def test_record_success_while_closed_resets_count(self): + b = Breaker(provider_name="test") + b.record_failure("err 1") + b.record_failure("err 2") + assert b.failure_count == 2 + b.record_success() + assert b.failure_count == 0 + assert b.state == CircuitState.CLOSED + + def test_custom_threshold(self): + cfg = BreakerConfig(failure_threshold=5) + b = Breaker(provider_name="test", config=cfg) + for i in range(4): + b.record_failure(f"err {i}") + assert b.state == CircuitState.CLOSED + b.record_failure("err 5") + assert b.state == CircuitState.OPEN + + def test_force_closed(self): + b = Breaker(provider_name="test") + for i in range(3): + b.record_failure(f"err {i}") + assert b.state == CircuitState.OPEN + b.force_closed() + assert b.state == CircuitState.CLOSED + assert b.failure_count == 0 + assert b.allow_request() is True + + def test_last_failure_error_stored(self): + b = Breaker(provider_name="test") + b.record_failure("connection refused") + assert b.last_failure_error == "connection refused" + b.record_failure("timeout") + assert b.last_failure_error == "timeout" + + def test_to_dict(self): + b = Breaker(provider_name="test") + d = b.to_dict() + assert d["provider"] == "test" + assert d["state"] == "CLOSED" + assert d["failure_count"] == 0 + + +class TestBreakerHalfOpen: + def test_cooldown_expiry_transitions_to_half_open(self): + b = Breaker(provider_name="test") + for i in range(3): + b.record_failure(f"err {i}") + assert b.state == CircuitState.OPEN + b.cooldown_until = time.time() - 1 + assert b.allow_request() is True + assert b.state == CircuitState.HALF_OPEN + + def test_half_open_probe_success_closes(self): + b = Breaker(provider_name="test") + for i in range(3): + b.record_failure(f"err {i}") + b.cooldown_until = time.time() - 1 + b.allow_request() + assert b.state == CircuitState.HALF_OPEN + b.record_success() + assert b.state == CircuitState.CLOSED + assert b.failure_count == 0 + + def test_half_open_probe_failure_reopens(self): + b = Breaker(provider_name="test") + for i in range(3): + b.record_failure(f"err {i}") + b.cooldown_until = time.time() - 1 + b.allow_request() + assert b.state == CircuitState.HALF_OPEN + b.record_failure("probe failed") + assert b.state == CircuitState.OPEN + + def test_record_success_while_open_noop(self): + b = Breaker(provider_name="test") + for i in range(3): + b.record_failure(f"err {i}") + assert b.state == CircuitState.OPEN + b.record_success() + assert b.state == CircuitState.OPEN + + +class TestBreakerRegistry: + def teardown_method(self): + breaker_registry._breakers.clear() + + def test_get_or_create_same_breaker(self): + b1 = breaker_registry.get_or_create("test-provider") + b2 = breaker_registry.get_or_create("test-provider") + assert b1 is b2 + + def test_get_or_create_different_providers(self): + b1 = breaker_registry.get_or_create("provider-a") + b2 = breaker_registry.get_or_create("provider-b") + assert b1 is not b2 + + def test_get_nonexistent(self): + assert breaker_registry.get("nonexistent") is None + + def test_get_existing(self): + breaker_registry.get_or_create("exists") + assert breaker_registry.get("exists") is not None + + def test_force_closed(self): + b = breaker_registry.get_or_create("reset-me") + for i in range(3): + b.record_failure(f"err {i}") + assert b.state == CircuitState.OPEN + assert breaker_registry.force_closed("reset-me") is True + assert b.state == CircuitState.CLOSED + + def test_force_closed_nonexistent(self): + assert breaker_registry.force_closed("nonexistent") is False + + def test_all_states(self): + breaker_registry._breakers.clear() + b = breaker_registry.get_or_create("snap") + b.record_failure("fail") + states = breaker_registry.all_states() + assert "snap" in states + assert states["snap"]["state"] == "CLOSED" + assert states["snap"]["failure_count"] == 1 + + +class TestBreakerPersistence: + def test_configure_persistence(self, tmp_path: Path): + db_path = str(tmp_path / "test.db") + reg = BreakerRegistry() + reg.configure_persistence(db_path) + + b = reg.get_or_create("persist") + for i in range(3): + b.record_failure(f"err {i}") + assert b.state == CircuitState.OPEN + + reg.persist_all() + + reg2 = BreakerRegistry() + reg2.configure_persistence(db_path) + b2 = reg2.get_or_create("persist") + assert b2.failure_count == 3 + assert b2.state in (CircuitState.OPEN, CircuitState.HALF_OPEN) + + def test_db_has_correct_state(self, tmp_path: Path): + db_path = str(tmp_path / "test_save.db") + reg = BreakerRegistry() + reg.configure_persistence(db_path) + + b = reg.get_or_create("db-test") + for i in range(3): + b.record_failure(f"err {i}") + reg.persist_all() + + conn = sqlite3.connect(db_path) + row = conn.execute( + "SELECT state, failure_count FROM circuit_breakers WHERE provider = ?", + ("db-test",), + ).fetchone() + conn.close() + assert row is not None + assert row[0] == "OPEN" + assert row[1] == 3 diff --git a/tests/test_main_cli.py b/tests/test_main_cli.py index 181fd29..a2a353c 100644 --- a/tests/test_main_cli.py +++ b/tests/test_main_cli.py @@ -32,7 +32,7 @@ def _fake_uvicorn_run(app, **kwargs): monkeypatch.setattr( main_module.argparse.ArgumentParser, "parse_args", - lambda self: type("Args", (), {"config": "/tmp/faigate-config.yaml"})(), + lambda self: type("Args", (), {"config": "/tmp/faigate-config.yaml", "command": None})(), ) main_module.main() diff --git a/tests/test_provider_catalog.py b/tests/test_provider_catalog.py index c4f8bc4..4bd274f 100644 --- a/tests/test_provider_catalog.py +++ b/tests/test_provider_catalog.py @@ -324,7 +324,7 @@ def test_provider_catalog_report_can_track_provider_from_external_snapshot(tmp_p "signup_url": "https://console.anthropic.com/", "watch_sources": [], "notes": "External snapshot entry", - "last_reviewed": "2026-03-31" + "last_reviewed": "2026-05-04" } } } @@ -371,7 +371,7 @@ def test_provider_catalog_external_snapshot_can_override_embedded_entry(tmp_path "providers": { "deepseek-chat": { "notes": "External override note", - "last_reviewed": "2026-03-31" + "last_reviewed": "2026-05-04" } } } @@ -383,7 +383,7 @@ def test_provider_catalog_external_snapshot_can_override_embedded_entry(tmp_path entry = get_provider_catalog_entry("deepseek-chat") assert entry["notes"] == "External override note" - assert entry["last_reviewed"] == "2026-03-31" + assert entry["last_reviewed"] == "2026-05-04" def test_provider_catalog_can_load_repo_catalog_with_gate_overlay(tmp_path: Path, monkeypatch): @@ -431,7 +431,7 @@ def test_provider_catalog_can_load_repo_catalog_with_gate_overlay(tmp_path: Path "signup_url": "https://console.anthropic.com/", "watch_sources": [], "notes": "Added by Gate overlay", - "last_reviewed": "2026-03-31" + "last_reviewed": "2026-05-04" } } } @@ -550,8 +550,16 @@ def test_offerings_and_packages_catalog_loading(tmp_path, monkeypatch): packages_catalog = metadata_dir / "packages" / "catalog.v1.json" packages_catalog.write_text('{"schema_version":"fusionaize-package-catalog/v1","packages":{}}') - # Set environment variable + # Set environment variable and reset global cache monkeypatch.setenv("FAIGATE_PROVIDER_METADATA_DIR", str(metadata_dir)) + monkeypatch.setenv("FAIGATE_OFFERINGS_METADATA_FILE", str(offerings_catalog)) + monkeypatch.setenv("FAIGATE_PACKAGES_METADATA_FILE", str(packages_catalog)) + import faigate.provider_catalog as pc + + pc._EXTERNAL_OFFERINGS_CACHE = None + pc._EXTERNAL_OFFERINGS_MTIME = 0.0 + pc._EXTERNAL_PACKAGES_CACHE = None + pc._EXTERNAL_PACKAGES_MTIME = 0.0 # Load catalogs offerings = get_offerings_catalog() diff --git a/tests/test_router_offerings_packages.py b/tests/test_router_offerings_packages.py index ccf68bf..a6ffa0b 100644 --- a/tests/test_router_offerings_packages.py +++ b/tests/test_router_offerings_packages.py @@ -91,6 +91,8 @@ def test_pricing_lookup_prefers_offerings(tmp_path, monkeypatch): # Set environment variable monkeypatch.setenv("FAIGATE_PROVIDER_METADATA_DIR", str(metadata_dir)) + monkeypatch.setenv("FAIGATE_OFFERINGS_METADATA_FILE", str(offerings_catalog)) + monkeypatch.setenv("FAIGATE_PACKAGES_METADATA_FILE", str(packages_catalog)) # Clear caches to force reload faigate.provider_catalog._EXTERNAL_OFFERINGS_CACHE = None @@ -195,12 +197,12 @@ def test_package_scoring_in_routing(tmp_path, monkeypatch): # Set environment variable monkeypatch.setenv("FAIGATE_PROVIDER_METADATA_DIR", str(metadata_dir)) + monkeypatch.setenv("FAIGATE_OFFERINGS_METADATA_FILE", str(offerings_catalog)) + monkeypatch.setenv("FAIGATE_PACKAGES_METADATA_FILE", str(packages_catalog)) # Clear caches faigate.provider_catalog._EXTERNAL_OFFERINGS_CACHE = None faigate.provider_catalog._EXTERNAL_OFFERINGS_MTIME = 0.0 - faigate.provider_catalog._EXTERNAL_PACKAGES_CACHE = None - faigate.provider_catalog._EXTERNAL_PACKAGES_MTIME = 0.0 # Get packages for provider packages = _get_packages_for_provider("kilocode") @@ -302,6 +304,8 @@ def test_router_uses_offerings_for_cost_calculation(tmp_path, monkeypatch): ) monkeypatch.setenv("FAIGATE_PROVIDER_METADATA_DIR", str(metadata_dir)) + monkeypatch.setenv("FAIGATE_OFFERINGS_METADATA_FILE", str(offerings_catalog)) + monkeypatch.setenv("FAIGATE_PACKAGES_METADATA_FILE", str(packages_catalog)) # Clear caches faigate.provider_catalog._EXTERNAL_OFFERINGS_CACHE = None @@ -363,6 +367,8 @@ def test_packages_catalog_loading(tmp_path, monkeypatch): # Set environment variable monkeypatch.setenv("FAIGATE_PROVIDER_METADATA_DIR", str(metadata_dir)) + monkeypatch.setenv("FAIGATE_OFFERINGS_METADATA_FILE", "") + monkeypatch.setenv("FAIGATE_PACKAGES_METADATA_FILE", str(packages_catalog)) # Clear cache faigate.provider_catalog._EXTERNAL_PACKAGES_CACHE = None