Merge nucleic/nimble-pebble-lynx-jfch into dev
This commit is contained in:
@@ -154,7 +154,8 @@ private struct UsageWindowRow: View {
|
||||
/// Shows, left to right: the session **context-window** usage (document icon) and then the
|
||||
/// account windows of **the provider this chat actually runs on** — each as a `%` colored
|
||||
/// calm → amber → red. A Claude chat shows the 5-hour (clock) and weekly (calendar) windows
|
||||
/// from `AppStore.subscriptionUsage` (real Pro/Max utilization via the OAuth usage endpoint),
|
||||
/// plus Fable's separate weekly cap when present, from `AppStore.subscriptionUsage`
|
||||
/// (real Pro/Max utilization via the OAuth usage endpoint),
|
||||
/// falling back to the coarse `rate_limit_event` status; a Codex chat shows the ChatGPT weekly
|
||||
/// window from `AppStore.codexUsage` instead, so the pill never reports Claude's quota next to
|
||||
/// a GPT model. Providers that report no quota at all (Grok, the ACP wrappers) show only the
|
||||
@@ -252,6 +253,9 @@ struct QuotaIndicator: View {
|
||||
if let w = usage.sevenDay {
|
||||
append("weekly", QuotaFormat.weeklyIcon, w.utilization(at: now))
|
||||
}
|
||||
if let fable = usage.sevenDayFable {
|
||||
append("fableWeekly", "sparkles", fable.utilization(at: now))
|
||||
}
|
||||
} else if let rl = store.latestRateLimit {
|
||||
// The coarse `rate_limit_event` fallback is Claude stream telemetry, so it only
|
||||
// stands in for Claude's windows.
|
||||
@@ -293,6 +297,7 @@ struct QuotaIndicator: View {
|
||||
}
|
||||
claudeLine("5-hour", usage.fiveHour)
|
||||
claudeLine("Weekly", usage.sevenDay)
|
||||
claudeLine("Weekly · Fable", usage.sevenDayFable)
|
||||
claudeLine("Weekly · Opus", usage.sevenDayOpus)
|
||||
claudeLine("Weekly · Sonnet", usage.sevenDaySonnet)
|
||||
} else if let rl = store.latestRateLimit {
|
||||
@@ -318,8 +323,8 @@ struct QuotaIndicator: View {
|
||||
// MARK: - Dashboard card
|
||||
|
||||
/// The home-dashboard form of the quota indicator: a card (matching the stat cards)
|
||||
/// with labeled progress bars for the 5-hour (clock) and weekly (calendar) windows and
|
||||
/// reset countdowns. `matchHeight` lets the home view size it to the neighboring stats grid so
|
||||
/// with labeled progress bars and reset countdowns for the 5-hour (clock), weekly (calendar),
|
||||
/// and optional Fable-weekly windows. `matchHeight` lets the home view size it to the neighboring stats grid so
|
||||
/// the two columns line up; it falls back to the `rate_limit_event` status, then a
|
||||
/// "checking…" placeholder, so it always shows something in the dashboard.
|
||||
struct QuotaCard: View {
|
||||
@@ -355,6 +360,7 @@ struct QuotaCard: View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
windowRow("5-hour limit", icon: QuotaFormat.fiveHourIcon, usage.fiveHour, now: now)
|
||||
windowRow("Weekly limit", icon: QuotaFormat.weeklyIcon, usage.sevenDay, now: now)
|
||||
windowRow("Weekly · Fable", icon: "sparkles", usage.sevenDayFable, now: now)
|
||||
}
|
||||
} else if let rl = store.latestRateLimit {
|
||||
HStack(spacing: 6) {
|
||||
|
||||
@@ -13588,6 +13588,7 @@ extension AppStore: SyncHostBridge {
|
||||
}
|
||||
return WireSubscriptionUsage(
|
||||
fiveHour: window(usage.fiveHour), sevenDay: window(usage.sevenDay),
|
||||
sevenDayFable: window(usage.sevenDayFable),
|
||||
sevenDayOpus: window(usage.sevenDayOpus), sevenDaySonnet: window(usage.sevenDaySonnet))
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ public enum IntelligenceRouter {
|
||||
var modelUtilization = [String: Double]()
|
||||
let claudeWindows = [
|
||||
claudeUsage?.fiveHour, claudeUsage?.sevenDay,
|
||||
claudeUsage?.sevenDayFable,
|
||||
claudeUsage?.sevenDayOpus, claudeUsage?.sevenDaySonnet,
|
||||
]
|
||||
let hasPreciseClaudeUsage = claudeWindows.contains { $0 != nil }
|
||||
@@ -170,6 +171,9 @@ public enum IntelligenceRouter {
|
||||
if reached(claudeUsage?.sevenDayOpus) {
|
||||
models.formUnion(IntelligenceRouter.routedModels.filter { $0.hasPrefix("claude-opus") })
|
||||
}
|
||||
if reached(claudeUsage?.sevenDayFable) {
|
||||
models.formUnion(IntelligenceRouter.routedModels.filter { $0.hasPrefix("claude-fable") })
|
||||
}
|
||||
if reached(claudeUsage?.sevenDaySonnet) {
|
||||
models.formUnion(IntelligenceRouter.routedModels.filter { $0.hasPrefix("claude-sonnet") })
|
||||
}
|
||||
@@ -178,6 +182,11 @@ public enum IntelligenceRouter {
|
||||
modelUtilization[sku] = opusUsage
|
||||
}
|
||||
}
|
||||
if let fableUsage = utilization(claudeUsage?.sevenDayFable) {
|
||||
for sku in IntelligenceRouter.routedModels where sku.hasPrefix("claude-fable") {
|
||||
modelUtilization[sku] = fableUsage
|
||||
}
|
||||
}
|
||||
if let sonnetUsage = utilization(claudeUsage?.sevenDaySonnet) {
|
||||
for sku in IntelligenceRouter.routedModels where sku.hasPrefix("claude-sonnet") {
|
||||
modelUtilization[sku] = sonnetUsage
|
||||
|
||||
@@ -43,6 +43,8 @@ public struct SubscriptionUsage: Sendable, Equatable {
|
||||
public let fiveHour: UsageWindow?
|
||||
/// The rolling 7-day ("weekly") limit, across all models.
|
||||
public let sevenDay: UsageWindow?
|
||||
/// The weekly Fable sub-limit (nil when the plan has no included Fable cap).
|
||||
public let sevenDayFable: UsageWindow?
|
||||
/// The weekly Opus sub-limit (nil when the plan has no separate Opus cap).
|
||||
public let sevenDayOpus: UsageWindow?
|
||||
/// The weekly Sonnet sub-limit.
|
||||
@@ -50,10 +52,12 @@ public struct SubscriptionUsage: Sendable, Equatable {
|
||||
|
||||
public init(
|
||||
fiveHour: UsageWindow?, sevenDay: UsageWindow?,
|
||||
sevenDayFable: UsageWindow? = nil,
|
||||
sevenDayOpus: UsageWindow?, sevenDaySonnet: UsageWindow?
|
||||
) {
|
||||
self.fiveHour = fiveHour
|
||||
self.sevenDay = sevenDay
|
||||
self.sevenDayFable = sevenDayFable
|
||||
self.sevenDayOpus = sevenDayOpus
|
||||
self.sevenDaySonnet = sevenDaySonnet
|
||||
}
|
||||
@@ -61,7 +65,7 @@ public struct SubscriptionUsage: Sendable, Equatable {
|
||||
/// The most-constrained window's utilization — what a single headline indicator
|
||||
/// should track, since hitting *any* window throttles you.
|
||||
public var peakUtilization: Double? {
|
||||
[fiveHour, sevenDay, sevenDayOpus, sevenDaySonnet]
|
||||
[fiveHour, sevenDay, sevenDayFable, sevenDayOpus, sevenDaySonnet]
|
||||
.compactMap { $0?.utilization }.max()
|
||||
}
|
||||
}
|
||||
@@ -121,17 +125,71 @@ public enum SubscriptionUsageFetcher {
|
||||
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw SubscriptionUsageError.badResponse
|
||||
}
|
||||
func window(_ key: String) -> UsageWindow? {
|
||||
guard let obj = json[key] as? [String: Any],
|
||||
let util = obj["utilization"] as? Double else { return nil }
|
||||
func window(_ obj: [String: Any]?, utilizationKeys: [String] = ["utilization"]) -> UsageWindow? {
|
||||
guard let obj,
|
||||
let util = utilizationKeys.lazy
|
||||
.compactMap({ (obj[$0] as? NSNumber)?.doubleValue }).first else { return nil }
|
||||
let resets = (obj["resets_at"] as? String).flatMap(parseDate)
|
||||
return UsageWindow(utilization: util, resetsAt: resets)
|
||||
}
|
||||
|
||||
// Anthropic's richer response shape (introduced in July 2026) carries the
|
||||
// authoritative buckets in a generic `limits` array. In particular, Fable has no
|
||||
// `seven_day_fable` field: it arrives as a `weekly_scoped` entry whose model id may
|
||||
// be null and whose display name is "Fable". Prefer these entries over the legacy
|
||||
// flat fields, which are null or can lag on migrated accounts.
|
||||
struct LimitCandidate {
|
||||
let window: UsageWindow
|
||||
let isActive: Bool
|
||||
}
|
||||
var session: LimitCandidate?
|
||||
var weekly: LimitCandidate?
|
||||
var scoped: [String: LimitCandidate] = [:]
|
||||
func prefer(_ candidate: LimitCandidate, over current: LimitCandidate?) -> Bool {
|
||||
guard let current else { return true }
|
||||
if candidate.isActive != current.isActive { return candidate.isActive }
|
||||
return candidate.window.utilization > current.window.utilization
|
||||
}
|
||||
if let limits = json["limits"] as? [[String: Any]] {
|
||||
for limit in limits {
|
||||
guard let parsed = window(limit, utilizationKeys: ["percent", "utilization"]),
|
||||
let kind = limit["kind"] as? String else { continue }
|
||||
let candidate = LimitCandidate(
|
||||
window: parsed, isActive: limit["is_active"] as? Bool ?? false)
|
||||
switch kind {
|
||||
case "session":
|
||||
if prefer(candidate, over: session) { session = candidate }
|
||||
case "weekly_all":
|
||||
if prefer(candidate, over: weekly) { weekly = candidate }
|
||||
case "weekly_scoped":
|
||||
guard let scope = limit["scope"] as? [String: Any],
|
||||
let model = scope["model"] as? [String: Any] else { continue }
|
||||
// `id` is currently null for Fable, so display_name is the primary key.
|
||||
let label = (model["display_name"] as? String)
|
||||
?? (model["id"] as? String)
|
||||
guard let label = label?.lowercased() else { continue }
|
||||
// Collapse versioned labels ("Fable 5", "Claude Fable 5") into one
|
||||
// family so duplicate migration-era entries can be ranked together.
|
||||
let family = ["fable", "opus", "sonnet"].first(where: label.contains)
|
||||
?? label
|
||||
if prefer(candidate, over: scoped[family]) { scoped[family] = candidate }
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
func scopedWindow(containing family: String) -> UsageWindow? {
|
||||
scoped.first { $0.key.contains(family) }?.value.window
|
||||
}
|
||||
func flatWindow(_ key: String) -> UsageWindow? {
|
||||
window(json[key] as? [String: Any])
|
||||
}
|
||||
return SubscriptionUsage(
|
||||
fiveHour: window("five_hour"),
|
||||
sevenDay: window("seven_day"),
|
||||
sevenDayOpus: window("seven_day_opus"),
|
||||
sevenDaySonnet: window("seven_day_sonnet"))
|
||||
fiveHour: session?.window ?? flatWindow("five_hour"),
|
||||
sevenDay: weekly?.window ?? flatWindow("seven_day"),
|
||||
sevenDayFable: scopedWindow(containing: "fable") ?? flatWindow("seven_day_fable"),
|
||||
sevenDayOpus: scopedWindow(containing: "opus") ?? flatWindow("seven_day_opus"),
|
||||
sevenDaySonnet: scopedWindow(containing: "sonnet") ?? flatWindow("seven_day_sonnet"))
|
||||
}
|
||||
|
||||
/// The endpoint stamps microsecond fractional seconds (`…851919+00:00`); try the
|
||||
|
||||
@@ -33,22 +33,26 @@ public struct WireUsageWindow: Sendable, Codable, Equatable {
|
||||
public struct WireSubscriptionUsage: Sendable, Codable, Equatable {
|
||||
public let fiveHour: WireUsageWindow?
|
||||
public let sevenDay: WireUsageWindow?
|
||||
public let sevenDayFable: WireUsageWindow?
|
||||
public let sevenDayOpus: WireUsageWindow?
|
||||
public let sevenDaySonnet: WireUsageWindow?
|
||||
|
||||
public init(
|
||||
fiveHour: WireUsageWindow?, sevenDay: WireUsageWindow?,
|
||||
sevenDayFable: WireUsageWindow? = nil,
|
||||
sevenDayOpus: WireUsageWindow? = nil, sevenDaySonnet: WireUsageWindow? = nil
|
||||
) {
|
||||
self.fiveHour = fiveHour
|
||||
self.sevenDay = sevenDay
|
||||
self.sevenDayFable = sevenDayFable
|
||||
self.sevenDayOpus = sevenDayOpus
|
||||
self.sevenDaySonnet = sevenDaySonnet
|
||||
}
|
||||
|
||||
/// The most-constrained window's utilization — the single headline number.
|
||||
public var peakUtilization: Double? {
|
||||
[fiveHour, sevenDay, sevenDayOpus, sevenDaySonnet].compactMap { $0?.utilization }.max()
|
||||
[fiveHour, sevenDay, sevenDayFable, sevenDayOpus, sevenDaySonnet]
|
||||
.compactMap { $0?.utilization }.max()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -230,6 +230,7 @@ import Testing
|
||||
let claude = SubscriptionUsage(
|
||||
fiveHour: UsageWindow(utilization: 100, resetsAt: future),
|
||||
sevenDay: nil,
|
||||
sevenDayFable: UsageWindow(utilization: 100, resetsAt: future),
|
||||
sevenDayOpus: UsageWindow(utilization: 100, resetsAt: future),
|
||||
sevenDaySonnet: nil)
|
||||
let codex = CodexUsage(weekly: CodexUsageWindow(
|
||||
@@ -240,6 +241,7 @@ import Testing
|
||||
|
||||
#expect(limits.providers == [.claudeCode, .codex])
|
||||
#expect(limits.models.contains("claude-opus-5"))
|
||||
#expect(limits.models.contains("claude-fable-5"))
|
||||
// The same stale snapshots stop excluding routes as soon as their reset passes.
|
||||
let reset = IntelligenceRouter.Limits(
|
||||
claudeUsage: claude, codexUsage: codex,
|
||||
@@ -268,6 +270,28 @@ import Testing
|
||||
#expect(resolution.reason.contains("usage limit"))
|
||||
}
|
||||
|
||||
@Test func reachedFableWindowFallsThroughWithoutBlockingClaude() {
|
||||
let now = Date(timeIntervalSince1970: 1_000_000)
|
||||
let future = now.addingTimeInterval(600)
|
||||
let limits = IntelligenceRouter.Limits(
|
||||
claudeUsage: SubscriptionUsage(
|
||||
fiveHour: UsageWindow(utilization: 10, resetsAt: future),
|
||||
sevenDay: UsageWindow(utilization: 20, resetsAt: future),
|
||||
sevenDayFable: UsageWindow(utilization: 100, resetsAt: future),
|
||||
sevenDayOpus: nil, sevenDaySonnet: nil),
|
||||
codexUsage: nil, claudeRateLimit: nil, now: now)
|
||||
|
||||
#expect(!limits.providers.contains(.claudeCode))
|
||||
#expect(limits.contains(model: "claude-fable-5"))
|
||||
#expect(!limits.contains(model: "claude-opus-5"))
|
||||
let resolution = IntelligenceRouter.route(
|
||||
purpose: .planning, level: .deep,
|
||||
connected: [.claudeCode, .codex], limits: limits,
|
||||
fallback: fallback)
|
||||
#expect(resolution.model == "gpt-5.6-sol")
|
||||
#expect(resolution.reason.contains("usage limit"))
|
||||
}
|
||||
|
||||
@Test func availableCapacityBreaksClosePerformanceCall() {
|
||||
// Sol and Opus are close choices for balanced backend work. With Codex's window
|
||||
// nearly consumed and ample Claude quota, preserve the scarce Codex capacity.
|
||||
@@ -288,6 +312,7 @@ import Testing
|
||||
let claude = SubscriptionUsage(
|
||||
fiveHour: UsageWindow(utilization: 10, resetsAt: future),
|
||||
sevenDay: UsageWindow(utilization: 5, resetsAt: future),
|
||||
sevenDayFable: UsageWindow(utilization: 80, resetsAt: future),
|
||||
sevenDayOpus: UsageWindow(utilization: 15, resetsAt: future),
|
||||
sevenDaySonnet: nil)
|
||||
let codex = CodexUsage(weekly: CodexUsageWindow(
|
||||
@@ -301,6 +326,7 @@ import Testing
|
||||
connected: [.claudeCode, .codex], limits: limits,
|
||||
fallback: fallback)
|
||||
#expect(resolution.model == "claude-opus-5")
|
||||
#expect(limits.modelUtilization["claude-fable-5"] == 80)
|
||||
}
|
||||
|
||||
@Test func performanceKeepsCloseCallUntilCapacityGapIsMaterial() {
|
||||
|
||||
@@ -4,7 +4,7 @@ import Testing
|
||||
@testable import NucleicCore
|
||||
|
||||
/// Locks the parse of Anthropic's `/api/oauth/usage` response (subscription quota).
|
||||
/// The body below is a real capture from the endpoint (2026-06-14, plan account);
|
||||
/// The body below mirrors captures from before and after the July 2026 `limits[]` migration;
|
||||
/// re-capture and diff if the indicator ever reads wrong after a CLI/API change.
|
||||
struct SubscriptionUsageTests {
|
||||
private static let sample = Data("""
|
||||
@@ -14,6 +14,14 @@ struct SubscriptionUsageTests {
|
||||
"seven_day_oauth_apps": null,
|
||||
"seven_day_opus": null,
|
||||
"seven_day_sonnet": { "utilization": 0.0, "resets_at": null },
|
||||
"limits": [
|
||||
{ "kind": "session", "percent": 38, "resets_at": "2026-06-15T04:29:59.851919+00:00", "is_active": false },
|
||||
{ "kind": "weekly_all", "percent": 15, "resets_at": "2026-06-21T01:59:59.851939+00:00", "is_active": false },
|
||||
{ "kind": "weekly_scoped", "percent": 68, "resets_at": "2026-06-21T01:59:59.851939+00:00",
|
||||
"scope": { "model": { "id": null, "display_name": "Fable" } }, "is_active": true },
|
||||
{ "kind": "weekly_scoped", "percent": 12, "resets_at": null,
|
||||
"scope": { "model": { "display_name": "Sonnet" } }, "is_active": false }
|
||||
],
|
||||
"extra_usage": { "is_enabled": false, "monthly_limit": null }
|
||||
}
|
||||
""".utf8)
|
||||
@@ -21,16 +29,18 @@ struct SubscriptionUsageTests {
|
||||
@Test func parsesWindowsAndPeak() throws {
|
||||
let usage = try SubscriptionUsageFetcher.parse(Self.sample)
|
||||
|
||||
#expect(usage.fiveHour?.utilization == 37.0)
|
||||
// Generic limits[] is authoritative over legacy flat buckets.
|
||||
#expect(usage.fiveHour?.utilization == 38.0)
|
||||
#expect(usage.fiveHour?.resetsAt != nil)
|
||||
#expect(usage.sevenDay?.utilization == 14.0)
|
||||
#expect(usage.sevenDay?.utilization == 15.0)
|
||||
#expect(usage.sevenDayFable?.utilization == 68.0)
|
||||
// A null per-model window is absent, not zero.
|
||||
#expect(usage.sevenDayOpus == nil)
|
||||
// A present window with a null reset keeps the utilization but no date.
|
||||
#expect(usage.sevenDaySonnet?.utilization == 0.0)
|
||||
#expect(usage.sevenDaySonnet?.utilization == 12.0)
|
||||
#expect(usage.sevenDaySonnet?.resetsAt == nil)
|
||||
// The headline tracks the most-constrained window.
|
||||
#expect(usage.peakUtilization == 37.0)
|
||||
#expect(usage.peakUtilization == 68.0)
|
||||
}
|
||||
|
||||
@Test func microsecondResetTimestampParses() throws {
|
||||
@@ -46,6 +56,37 @@ struct SubscriptionUsageTests {
|
||||
#expect(components.minute == 29)
|
||||
}
|
||||
|
||||
@Test func legacyFlatWindowsRemainSupported() throws {
|
||||
let usage = try SubscriptionUsageFetcher.parse(Data("""
|
||||
{
|
||||
"five_hour": { "utilization": 37.0, "resets_at": null },
|
||||
"seven_day": { "utilization": 14.0, "resets_at": null },
|
||||
"seven_day_opus": { "utilization": 9.0, "resets_at": null }
|
||||
}
|
||||
""".utf8))
|
||||
|
||||
#expect(usage.fiveHour?.utilization == 37)
|
||||
#expect(usage.sevenDay?.utilization == 14)
|
||||
#expect(usage.sevenDayFable == nil)
|
||||
#expect(usage.sevenDayOpus?.utilization == 9)
|
||||
}
|
||||
|
||||
@Test func malformedGenericEntriesAreIgnored() throws {
|
||||
let usage = try SubscriptionUsageFetcher.parse(Data("""
|
||||
{
|
||||
"five_hour": { "utilization": 8.0, "resets_at": null },
|
||||
"limits": [
|
||||
{ "kind": "weekly_scoped", "percent": null,
|
||||
"scope": { "model": { "display_name": "Fable" } } },
|
||||
{ "kind": "weekly_scoped", "percent": 45, "scope": null }
|
||||
]
|
||||
}
|
||||
""".utf8))
|
||||
|
||||
#expect(usage.fiveHour?.utilization == 8)
|
||||
#expect(usage.sevenDayFable == nil)
|
||||
}
|
||||
|
||||
@Test func lapsedWindowReadsAsEmptyUntilRefetched() {
|
||||
let reset = Date(timeIntervalSince1970: 1_000_000)
|
||||
let window = UsageWindow(utilization: 73.0, resetsAt: reset)
|
||||
|
||||
@@ -788,6 +788,7 @@ import Testing
|
||||
usage: WireSubscriptionUsage(
|
||||
fiveHour: WireUsageWindow(utilization: 42.5, resetsAt: Date(timeIntervalSince1970: 2)),
|
||||
sevenDay: WireUsageWindow(utilization: 78, resetsAt: nil),
|
||||
sevenDayFable: WireUsageWindow(utilization: 91, resetsAt: nil),
|
||||
sevenDayOpus: nil,
|
||||
sevenDaySonnet: WireUsageWindow(utilization: 12, resetsAt: nil)),
|
||||
statusFeeds: [WireStatusFeed(
|
||||
@@ -801,7 +802,8 @@ import Testing
|
||||
guard case .dashboard(let decoded) = back else { Issue.record("not a dashboard"); return }
|
||||
#expect(decoded.todos.first?.triage == "critical")
|
||||
#expect(decoded.usage?.fiveHour?.utilization == 42.5)
|
||||
#expect(decoded.usage?.peakUtilization == 78)
|
||||
#expect(decoded.usage?.sevenDayFable?.utilization == 91)
|
||||
#expect(decoded.usage?.peakUtilization == 91)
|
||||
#expect(decoded.statusFeeds.first?.hasActiveIncident == true)
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ struct QuotaCard: View {
|
||||
}
|
||||
windowRow("5-hour limit", icon: QuotaFormat.fiveHourIcon, usage.fiveHour, now: context.date)
|
||||
windowRow("Weekly limit", icon: QuotaFormat.weeklyIcon, usage.sevenDay, now: context.date)
|
||||
windowRow("Weekly · Fable", icon: "sparkles", usage.sevenDayFable, now: context.date)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user