Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,5 @@ catalog_output.json
*.xcodeproj/
xcuserdata/
DerivedData/
handover/
.skillweave/
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 <endpoint>`) 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
Expand Down
14 changes: 7 additions & 7 deletions apps/gate-bar/Sources/GateBar/BrandCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -154,7 +154,7 @@ struct PackageRow: View {
}
}
}
.frame(height: 6)
.frame(height: 7)
}
}

Expand Down
265 changes: 217 additions & 48 deletions apps/gate-bar/Sources/GateBar/GateBarApp.swift
Original file line number Diff line number Diff line change
@@ -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<AnyCancellable>()

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)
}
}
Loading
Loading