@sriinnu/runic 1.2.0 → 2.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (586) hide show
  1. package/CHANGELOG.md +267 -0
  2. package/LICENSE +373 -21
  3. package/README.md +98 -84
  4. package/THIRD-PARTY-NOTICES.md +651 -0
  5. package/package.json +14 -4
  6. package/.swiftformat +0 -50
  7. package/.swiftlint.yml +0 -160
  8. package/CONTRIBUTING.md +0 -456
  9. package/Icon.icns +0 -0
  10. package/Icon.icon/Assets/runic.png +0 -0
  11. package/Icon.icon/icon.json +0 -36
  12. package/Package.resolved +0 -51
  13. package/Package.swift +0 -136
  14. package/Packages/Helix/LICENSE +0 -21
  15. package/Packages/Helix/Package.swift +0 -29
  16. package/Packages/Helix/README.md +0 -976
  17. package/Packages/Helix/Sources/Helix/Binding.swift +0 -111
  18. package/Packages/Helix/Sources/Helix/CommandParser.swift +0 -160
  19. package/Packages/Helix/Sources/Helix/CommandSignature.swift +0 -117
  20. package/Packages/Helix/Sources/Helix/ExpressibleFromArgument.swift +0 -72
  21. package/Packages/Helix/Sources/Helix/HelpGenerator.swift +0 -175
  22. package/Packages/Helix/Sources/Helix/NameSpecification.swift +0 -98
  23. package/Packages/Helix/Sources/Helix/ParsableCommand.swift +0 -204
  24. package/Packages/Helix/Sources/Helix/Platform/DarwinPlatformContext.swift +0 -44
  25. package/Packages/Helix/Sources/Helix/Platform/LinuxPlatformContext.swift +0 -34
  26. package/Packages/Helix/Sources/Helix/Platform/PlatformContext+Default.swift +0 -91
  27. package/Packages/Helix/Sources/Helix/Platform/PlatformContext.swift +0 -94
  28. package/Packages/Helix/Sources/Helix/Platform/PlatformPath.swift +0 -167
  29. package/Packages/Helix/Sources/Helix/Platform/Stdio.swift +0 -99
  30. package/Packages/Helix/Sources/Helix/Platform/WebPlatformContext.swift +0 -146
  31. package/Packages/Helix/Sources/Helix/Platform/WindowsPlatformContext.swift +0 -97
  32. package/Packages/Helix/Sources/Helix/Program.swift +0 -204
  33. package/Packages/Helix/Sources/Helix/PropertyWrappers.swift +0 -367
  34. package/Packages/Silo/LICENSE +0 -21
  35. package/Packages/Silo/Package.swift +0 -34
  36. package/Packages/Silo/README.md +0 -466
  37. package/Packages/Silo/Sources/Silo/BinaryCookiesReader.swift +0 -169
  38. package/Packages/Silo/Sources/Silo/BrowserCookieAnalytics.swift +0 -365
  39. package/Packages/Silo/Sources/Silo/BrowserCookieBackends.swift +0 -594
  40. package/Packages/Silo/Sources/Silo/BrowserCookieClient.swift +0 -326
  41. package/Packages/Silo/Sources/Silo/BrowserCookieExport.swift +0 -154
  42. package/Packages/Silo/Sources/Silo/BrowserCookieImport.swift +0 -119
  43. package/Packages/Silo/Sources/Silo/BrowserCookieMockStore.swift +0 -121
  44. package/Packages/Silo/Sources/Silo/BrowserCookieModels.swift +0 -391
  45. package/Packages/Silo/Sources/Silo/ChromiumCookieReader.swift +0 -240
  46. package/Packages/Silo/Sources/Silo/CryptoSupport.swift +0 -86
  47. package/Packages/Silo/Sources/Silo/FirefoxCookieReader.swift +0 -172
  48. package/Packages/Silo/Sources/Silo/LinuxChromiumCookieReader.swift +0 -172
  49. package/Packages/Silo/Sources/Silo/LinuxSecretService.swift +0 -105
  50. package/Packages/Silo/Sources/Silo/MacOSChromiumCookieReader.swift +0 -191
  51. package/Packages/Silo/Sources/Silo/SQLiteSnapshot.swift +0 -41
  52. package/Packages/Silo/Sources/Silo/WindowsChromiumCookieReader.swift +0 -110
  53. package/RuniciOS/App/RuniciOSApp.swift +0 -105
  54. package/RuniciOS/Core/iOSUsageStore.swift +0 -192
  55. package/RuniciOS/Views/ProviderDetailView.swift +0 -312
  56. package/RuniciOS/Views/ProviderListView.swift +0 -228
  57. package/RuniciOS/Widgets/LargeWidget.swift +0 -367
  58. package/RuniciOS/Widgets/LiveActivity.swift +0 -342
  59. package/RuniciOS/Widgets/LockScreenWidget.swift +0 -273
  60. package/RuniciOS/Widgets/MediumWidget.swift +0 -370
  61. package/RuniciOS/Widgets/RunicWidget.swift +0 -274
  62. package/RuniciOS/Widgets/SmallWidget.swift +0 -281
  63. package/RuniciOS/Widgets/WidgetModels.swift +0 -285
  64. package/RuniciOS/Widgets/WidgetTimeline.swift +0 -270
  65. package/Scripts/CREATION-SUMMARY.md +0 -367
  66. package/Scripts/DIRECTORY-STRUCTURE.md +0 -358
  67. package/Scripts/GETTING-STARTED-CHECKLIST.md +0 -364
  68. package/Scripts/INDEX.md +0 -365
  69. package/Scripts/QUICK-REFERENCE.md +0 -164
  70. package/Scripts/README-SCRIPTS.md +0 -926
  71. package/Scripts/build-android.sh +0 -526
  72. package/Scripts/build-ios.sh +0 -529
  73. package/Scripts/build-macos.sh +0 -603
  74. package/Scripts/build-windows.sh +0 -504
  75. package/Scripts/build_icon.sh +0 -49
  76. package/Scripts/changelog-to-html.sh +0 -89
  77. package/Scripts/check-release-assets.sh +0 -10
  78. package/Scripts/ci-config.yml +0 -474
  79. package/Scripts/compile_and_run.sh +0 -219
  80. package/Scripts/compile_and_run_adhoc.sh +0 -4
  81. package/Scripts/docs-list.mjs +0 -119
  82. package/Scripts/make_appcast.sh +0 -58
  83. package/Scripts/package_app.sh +0 -409
  84. package/Scripts/release-lib.sh +0 -214
  85. package/Scripts/release.sh +0 -76
  86. package/Scripts/setup-all.sh +0 -387
  87. package/Scripts/setup-ios.sh +0 -404
  88. package/Scripts/setup-react-native.sh +0 -523
  89. package/Scripts/sign-and-notarize.sh +0 -122
  90. package/Scripts/test_live_update.sh +0 -38
  91. package/Scripts/validate_changelog.sh +0 -23
  92. package/Scripts/verify_appcast.sh +0 -92
  93. package/Sources/Runic/About.swift +0 -93
  94. package/Sources/Runic/AppNotifications.swift +0 -115
  95. package/Sources/Runic/Controllers/StatusItemController+Actions.swift +0 -666
  96. package/Sources/Runic/Controllers/StatusItemController+Animation.swift +0 -518
  97. package/Sources/Runic/Controllers/StatusItemController+ChartSubmenus.swift +0 -810
  98. package/Sources/Runic/Controllers/StatusItemController+CustomProviders.swift +0 -87
  99. package/Sources/Runic/Controllers/StatusItemController+Menu.swift +0 -1407
  100. package/Sources/Runic/Controllers/StatusItemController+MenuCards.swift +0 -337
  101. package/Sources/Runic/Controllers/StatusItemController+MenuHelpers.swift +0 -395
  102. package/Sources/Runic/Controllers/StatusItemController.swift +0 -360
  103. package/Sources/Runic/Core/BudgetNotificationManager.swift +0 -80
  104. package/Sources/Runic/Core/Login/ClaudeLoginRunner.swift +0 -103
  105. package/Sources/Runic/Core/Login/CodexLoginRunner.swift +0 -154
  106. package/Sources/Runic/Core/Login/CursorLoginRunner.swift +0 -206
  107. package/Sources/Runic/Core/Login/GeminiLoginRunner.swift +0 -92
  108. package/Sources/Runic/Core/Rendering/IconRenderer.swift +0 -1216
  109. package/Sources/Runic/Core/Rendering/IconView.swift +0 -120
  110. package/Sources/Runic/Core/Rendering/ProviderBrandIcon.swift +0 -71
  111. package/Sources/Runic/Core/RunicAnimation.swift +0 -53
  112. package/Sources/Runic/Core/RunicColors.swift +0 -73
  113. package/Sources/Runic/Core/RunicCornerRadius.swift +0 -15
  114. package/Sources/Runic/Core/RunicSpacing.swift +0 -68
  115. package/Sources/Runic/Core/Stores/AuggieTokenStore.swift +0 -119
  116. package/Sources/Runic/Core/Stores/AzureOpenAITokenStore.swift +0 -119
  117. package/Sources/Runic/Core/Stores/CerebrasTokenStore.swift +0 -119
  118. package/Sources/Runic/Core/Stores/CohereTokenStore.swift +0 -119
  119. package/Sources/Runic/Core/Stores/CopilotTokenStore.swift +0 -119
  120. package/Sources/Runic/Core/Stores/CustomProviderStore.swift +0 -158
  121. package/Sources/Runic/Core/Stores/DeepSeekTokenStore.swift +0 -119
  122. package/Sources/Runic/Core/Stores/FireworksTokenStore.swift +0 -119
  123. package/Sources/Runic/Core/Stores/GroqTokenStore.swift +0 -119
  124. package/Sources/Runic/Core/Stores/KimiTokenStore.swift +0 -119
  125. package/Sources/Runic/Core/Stores/MiniMaxCookieHeaderStore.swift +0 -119
  126. package/Sources/Runic/Core/Stores/MiniMaxGroupIDStore.swift +0 -119
  127. package/Sources/Runic/Core/Stores/MiniMaxTokenStore.swift +0 -119
  128. package/Sources/Runic/Core/Stores/MistralTokenStore.swift +0 -119
  129. package/Sources/Runic/Core/Stores/OpenRouterTokenStore.swift +0 -119
  130. package/Sources/Runic/Core/Stores/PerplexityTokenStore.swift +0 -119
  131. package/Sources/Runic/Core/Stores/ProviderToggleStore.swift +0 -30
  132. package/Sources/Runic/Core/Stores/QwenTokenStore.swift +0 -119
  133. package/Sources/Runic/Core/Stores/SambaNovaTokenStore.swift +0 -119
  134. package/Sources/Runic/Core/Stores/SettingsStore.swift +0 -1711
  135. package/Sources/Runic/Core/Stores/TogetherTokenStore.swift +0 -119
  136. package/Sources/Runic/Core/Stores/UsageStore+Accessors.swift +0 -20
  137. package/Sources/Runic/Core/Stores/UsageStore+Status.swift +0 -189
  138. package/Sources/Runic/Core/Stores/UsageStore+TokenCost.swift +0 -33
  139. package/Sources/Runic/Core/Stores/UsageStore+WidgetSnapshot.swift +0 -67
  140. package/Sources/Runic/Core/Stores/UsageStore.swift +0 -3605
  141. package/Sources/Runic/Core/Stores/XAITokenStore.swift +0 -119
  142. package/Sources/Runic/Core/Stores/ZaiTokenStore.swift +0 -118
  143. package/Sources/Runic/Core/UsageExporter.swift +0 -193
  144. package/Sources/Runic/Date+RelativeDescription.swift +0 -21
  145. package/Sources/Runic/HiddenWindowView.swift +0 -32
  146. package/Sources/Runic/InstallOrigin.swift +0 -9
  147. package/Sources/Runic/KeyboardShortcuts+Names.swift +0 -6
  148. package/Sources/Runic/MouseLocationReader.swift +0 -65
  149. package/Sources/Runic/Notifications+Runic.swift +0 -6
  150. package/Sources/Runic/OpenAICreditsPurchaseWindowController.swift +0 -503
  151. package/Sources/Runic/ProviderRegistry.swift +0 -100
  152. package/Sources/Runic/ProviderSwitcherButtons.swift +0 -267
  153. package/Sources/Runic/Providers/Antigravity/AntigravityLoginFlow.swift +0 -11
  154. package/Sources/Runic/Providers/Antigravity/AntigravityProviderImplementation.swift +0 -14
  155. package/Sources/Runic/Providers/Auggie/AuggieProviderImplementation.swift +0 -24
  156. package/Sources/Runic/Providers/Azure/AzureProviderImplementation.swift +0 -52
  157. package/Sources/Runic/Providers/Bedrock/BedrockProviderImplementation.swift +0 -42
  158. package/Sources/Runic/Providers/Cerebras/CerebrasProviderImplementation.swift +0 -24
  159. package/Sources/Runic/Providers/Claude/ClaudeLoginFlow.swift +0 -26
  160. package/Sources/Runic/Providers/Claude/ClaudeProviderImplementation.swift +0 -14
  161. package/Sources/Runic/Providers/Codex/CodexLoginFlow.swift +0 -18
  162. package/Sources/Runic/Providers/Codex/CodexProviderImplementation.swift +0 -15
  163. package/Sources/Runic/Providers/Cohere/CohereProviderImplementation.swift +0 -24
  164. package/Sources/Runic/Providers/Copilot/CopilotLoginFlow.swift +0 -193
  165. package/Sources/Runic/Providers/Copilot/CopilotProviderImplementation.swift +0 -110
  166. package/Sources/Runic/Providers/Cursor/CursorLoginFlow.swift +0 -28
  167. package/Sources/Runic/Providers/Cursor/CursorProviderImplementation.swift +0 -15
  168. package/Sources/Runic/Providers/DeepSeek/DeepSeekProviderImplementation.swift +0 -24
  169. package/Sources/Runic/Providers/Factory/FactoryLoginFlow.swift +0 -12
  170. package/Sources/Runic/Providers/Factory/FactoryProviderImplementation.swift +0 -15
  171. package/Sources/Runic/Providers/Fireworks/FireworksProviderImplementation.swift +0 -24
  172. package/Sources/Runic/Providers/Gemini/GeminiLoginFlow.swift +0 -20
  173. package/Sources/Runic/Providers/Gemini/GeminiProviderImplementation.swift +0 -15
  174. package/Sources/Runic/Providers/Groq/GroqProviderImplementation.swift +0 -24
  175. package/Sources/Runic/Providers/Kimi/KimiProviderImplementation.swift +0 -24
  176. package/Sources/Runic/Providers/MiniMax/MiniMaxProviderImplementation.swift +0 -32
  177. package/Sources/Runic/Providers/Mistral/MistralProviderImplementation.swift +0 -24
  178. package/Sources/Runic/Providers/OpenRouter/OpenRouterProviderImplementation.swift +0 -24
  179. package/Sources/Runic/Providers/Perplexity/PerplexityProviderImplementation.swift +0 -24
  180. package/Sources/Runic/Providers/SambaNova/SambaNovaProviderImplementation.swift +0 -24
  181. package/Sources/Runic/Providers/Shared/ProviderCatalog.swift +0 -15
  182. package/Sources/Runic/Providers/Shared/ProviderImplementation.swift +0 -48
  183. package/Sources/Runic/Providers/Shared/ProviderImplementationRegistry.swift +0 -66
  184. package/Sources/Runic/Providers/Shared/ProviderLoginFlow.swift +0 -12
  185. package/Sources/Runic/Providers/Shared/ProviderSettingsDescriptors.swift +0 -99
  186. package/Sources/Runic/Providers/Shared/SystemSettingsLinks.swift +0 -18
  187. package/Sources/Runic/Providers/Together/TogetherProviderImplementation.swift +0 -24
  188. package/Sources/Runic/Providers/XAI/XAIProviderImplementation.swift +0 -24
  189. package/Sources/Runic/Providers/Zai/ZaiProviderImplementation.swift +0 -24
  190. package/Sources/Runic/Resources/ProviderIcon-antigravity.svg +0 -2
  191. package/Sources/Runic/Resources/ProviderIcon-auggie.svg +0 -3
  192. package/Sources/Runic/Resources/ProviderIcon-azure.svg +0 -5
  193. package/Sources/Runic/Resources/ProviderIcon-bedrock.svg +0 -7
  194. package/Sources/Runic/Resources/ProviderIcon-cerebras.svg +0 -3
  195. package/Sources/Runic/Resources/ProviderIcon-claude.svg +0 -1
  196. package/Sources/Runic/Resources/ProviderIcon-codex.svg +0 -1
  197. package/Sources/Runic/Resources/ProviderIcon-cohere.svg +0 -3
  198. package/Sources/Runic/Resources/ProviderIcon-copilot.svg +0 -1
  199. package/Sources/Runic/Resources/ProviderIcon-cursor.svg +0 -3
  200. package/Sources/Runic/Resources/ProviderIcon-deepseek.svg +0 -4
  201. package/Sources/Runic/Resources/ProviderIcon-factory.svg +0 -3
  202. package/Sources/Runic/Resources/ProviderIcon-fireworks.svg +0 -3
  203. package/Sources/Runic/Resources/ProviderIcon-gemini.svg +0 -1
  204. package/Sources/Runic/Resources/ProviderIcon-groq.svg +0 -1
  205. package/Sources/Runic/Resources/ProviderIcon-kimi.svg +0 -4
  206. package/Sources/Runic/Resources/ProviderIcon-minimax.svg +0 -1
  207. package/Sources/Runic/Resources/ProviderIcon-mistral.svg +0 -5
  208. package/Sources/Runic/Resources/ProviderIcon-openrouter.svg +0 -7
  209. package/Sources/Runic/Resources/ProviderIcon-perplexity.svg +0 -4
  210. package/Sources/Runic/Resources/ProviderIcon-qwen.svg +0 -9
  211. package/Sources/Runic/Resources/ProviderIcon-sambanova.svg +0 -4
  212. package/Sources/Runic/Resources/ProviderIcon-together.svg +0 -5
  213. package/Sources/Runic/Resources/ProviderIcon-vertexai.svg +0 -17
  214. package/Sources/Runic/Resources/ProviderIcon-xai.svg +0 -3
  215. package/Sources/Runic/Resources/ProviderIcon-zai.svg +0 -5
  216. package/Sources/Runic/Resources/RunicMenubarIcon.svg +0 -7
  217. package/Sources/Runic/Resources/provider-context-windows.json +0 -20
  218. package/Sources/Runic/RunicApp.swift +0 -357
  219. package/Sources/Runic/SessionQuotaNotifications.swift +0 -58
  220. package/Sources/Runic/UsagePaceText.swift +0 -52
  221. package/Sources/Runic/UsageProgressBar.swift +0 -224
  222. package/Sources/Runic/Utilities/DisplayLink.swift +0 -96
  223. package/Sources/Runic/Utilities/LoadingPattern.swift +0 -60
  224. package/Sources/Runic/Utilities/PerformanceConstants.swift +0 -85
  225. package/Sources/Runic/Views/Menu/AlertsMenuView.swift +0 -256
  226. package/Sources/Runic/Views/Menu/ChartPanelStyle.swift +0 -22
  227. package/Sources/Runic/Views/Menu/CostComparativeChartMenuView.swift +0 -275
  228. package/Sources/Runic/Views/Menu/CostHistoryChartMenuView.swift +0 -377
  229. package/Sources/Runic/Views/Menu/CreditsHistoryChartMenuView.swift +0 -312
  230. package/Sources/Runic/Views/Menu/EfficiencyMetricsMenuView.swift +0 -312
  231. package/Sources/Runic/Views/Menu/HeroStatCardsMenuView.swift +0 -228
  232. package/Sources/Runic/Views/Menu/HourlyActivityChartMenuView.swift +0 -228
  233. package/Sources/Runic/Views/Menu/InlineUsageChartView.swift +0 -155
  234. package/Sources/Runic/Views/Menu/MenuCardEntranceModifier.swift +0 -64
  235. package/Sources/Runic/Views/Menu/MenuCardSidebarView.swift +0 -268
  236. package/Sources/Runic/Views/Menu/MenuCardSkeletonView.swift +0 -104
  237. package/Sources/Runic/Views/Menu/MenuCardView.swift +0 -1900
  238. package/Sources/Runic/Views/Menu/MenuContent.swift +0 -150
  239. package/Sources/Runic/Views/Menu/MenuDescriptor.swift +0 -369
  240. package/Sources/Runic/Views/Menu/MenuHighlightStyle.swift +0 -42
  241. package/Sources/Runic/Views/Menu/ModelBreakdownMenuView.swift +0 -235
  242. package/Sources/Runic/Views/Menu/OverviewMenuView.swift +0 -278
  243. package/Sources/Runic/Views/Menu/ProjectBreakdownMenuView.swift +0 -209
  244. package/Sources/Runic/Views/Menu/ProjectBudgetMenuView.swift +0 -220
  245. package/Sources/Runic/Views/Menu/ProviderTabBarView.swift +0 -88
  246. package/Sources/Runic/Views/Menu/SubscriptionUtilizationChartMenuView.swift +0 -201
  247. package/Sources/Runic/Views/Menu/UsageBreakdownChartMenuView.swift +0 -398
  248. package/Sources/Runic/Views/Menu/UsageHeatmapMenuView.swift +0 -232
  249. package/Sources/Runic/Views/Menu/UsageTimelineChartMenuView.swift +0 -364
  250. package/Sources/Runic/Views/Menu/UsageWindowComparisonChartMenuView.swift +0 -190
  251. package/Sources/Runic/Views/Menu/WeeklyActivityChartMenuView.swift +0 -132
  252. package/Sources/Runic/Views/Performance/PerformanceDashboardView.swift +0 -618
  253. package/Sources/Runic/Views/Preferences/LiquidComponents.swift +0 -305
  254. package/Sources/Runic/Views/Preferences/PreferencesAboutPane.swift +0 -271
  255. package/Sources/Runic/Views/Preferences/PreferencesAdvancedPane.swift +0 -268
  256. package/Sources/Runic/Views/Preferences/PreferencesAlertsPane.swift +0 -422
  257. package/Sources/Runic/Views/Preferences/PreferencesAnalyticsPane.swift +0 -641
  258. package/Sources/Runic/Views/Preferences/PreferencesBudgetsPane.swift +0 -344
  259. package/Sources/Runic/Views/Preferences/PreferencesComponents.swift +0 -230
  260. package/Sources/Runic/Views/Preferences/PreferencesCustomProvidersPane.swift +0 -322
  261. package/Sources/Runic/Views/Preferences/PreferencesDebugPane.swift +0 -469
  262. package/Sources/Runic/Views/Preferences/PreferencesGeneralPane.swift +0 -231
  263. package/Sources/Runic/Views/Preferences/PreferencesHelpPane.swift +0 -50
  264. package/Sources/Runic/Views/Preferences/PreferencesIntegrationsPane.swift +0 -567
  265. package/Sources/Runic/Views/Preferences/PreferencesPerformancePane.swift +0 -288
  266. package/Sources/Runic/Views/Preferences/PreferencesProvidersPane.swift +0 -3435
  267. package/Sources/Runic/Views/Preferences/PreferencesSelection.swift +0 -25
  268. package/Sources/Runic/Views/Preferences/PreferencesSyncPane.swift +0 -59
  269. package/Sources/Runic/Views/Preferences/PreferencesView.swift +0 -206
  270. package/Sources/Runic/Views/Rating/QualityRatingView.swift +0 -238
  271. package/Sources/Runic/Views/Team/TeamDashboardView.swift +0 -371
  272. package/Sources/Runic/Views/Team/TeamInviteSheet.swift +0 -175
  273. package/Sources/Runic/Views/Team/TeamManagementView.swift +0 -761
  274. package/Sources/Runic/Views/Team/TeamMemberRow.swift +0 -181
  275. package/Sources/RunicCLI/CLIEntry.swift +0 -942
  276. package/Sources/RunicCLI/CLIRenderer.swift +0 -87
  277. package/Sources/RunicCLI/Commands/EnhancedUsageCommand.swift +0 -588
  278. package/Sources/RunicCLI/Commands/ModelsCommand.swift +0 -362
  279. package/Sources/RunicCLI/Commands/ProjectsCommand.swift +0 -439
  280. package/Sources/RunicCLI/Commands/ResetCommand.swift +0 -429
  281. package/Sources/RunicCLI/Commands/UsageLedgerSourceFactory.swift +0 -168
  282. package/Sources/RunicClaudeWatchdog/main.swift +0 -122
  283. package/Sources/RunicClaudeWebProbe/main.swift +0 -62
  284. package/Sources/RunicCore/AlertRuleStore.swift +0 -187
  285. package/Sources/RunicCore/BrowserCookieImportOrder.swift +0 -25
  286. package/Sources/RunicCore/CopilotUsageModels.swift +0 -84
  287. package/Sources/RunicCore/CostUsageFetcher.swift +0 -119
  288. package/Sources/RunicCore/CostUsageModels.swift +0 -479
  289. package/Sources/RunicCore/CreditsModels.swift +0 -50
  290. package/Sources/RunicCore/CustomProviders/CustomProviderModels.swift +0 -269
  291. package/Sources/RunicCore/CustomProviders/GenericProviderFetcher.swift +0 -633
  292. package/Sources/RunicCore/CustomProviders/ProviderTemplates.swift +0 -500
  293. package/Sources/RunicCore/Host/PTY/TTYCommandRunner.swift +0 -679
  294. package/Sources/RunicCore/Host/Process/SubprocessRunner.swift +0 -135
  295. package/Sources/RunicCore/Integrations/GitHubIntegration.swift +0 -173
  296. package/Sources/RunicCore/Logging/JSONStderrLogHandler.swift +0 -79
  297. package/Sources/RunicCore/Logging/OSLogLogHandler.swift +0 -86
  298. package/Sources/RunicCore/Logging/RunicLog.swift +0 -123
  299. package/Sources/RunicCore/Models/EnhancedUsageModels.swift +0 -371
  300. package/Sources/RunicCore/OpenAIDashboardModels.swift +0 -210
  301. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift +0 -676
  302. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardFetcher.swift +0 -492
  303. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift +0 -51
  304. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardParser.swift +0 -483
  305. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift +0 -558
  306. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift +0 -204
  307. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardWebsiteDataStore.swift +0 -78
  308. package/Sources/RunicCore/PathEnvironment.swift +0 -482
  309. package/Sources/RunicCore/Performance/PerformanceModels.swift +0 -371
  310. package/Sources/RunicCore/Performance/PerformanceStorageImpl.swift +0 -409
  311. package/Sources/RunicCore/Policy/UsagePolicyEngine.swift +0 -178
  312. package/Sources/RunicCore/ProjectBudgetStore.swift +0 -99
  313. package/Sources/RunicCore/ProviderCostSnapshot.swift +0 -29
  314. package/Sources/RunicCore/Providers/Antigravity/AntigravityProviderDescriptor.swift +0 -66
  315. package/Sources/RunicCore/Providers/Antigravity/AntigravityStatusProbe.swift +0 -685
  316. package/Sources/RunicCore/Providers/Auggie/AuggieProviderDescriptor.swift +0 -83
  317. package/Sources/RunicCore/Providers/Auggie/AuggieUsageFetcher.swift +0 -142
  318. package/Sources/RunicCore/Providers/Azure/AzureProviderDescriptor.swift +0 -141
  319. package/Sources/RunicCore/Providers/Azure/AzureUsageFetcher.swift +0 -188
  320. package/Sources/RunicCore/Providers/Bedrock/BedrockProviderDescriptor.swift +0 -126
  321. package/Sources/RunicCore/Providers/Bedrock/BedrockUsageFetcher.swift +0 -168
  322. package/Sources/RunicCore/Providers/Cerebras/CerebrasProviderDescriptor.swift +0 -84
  323. package/Sources/RunicCore/Providers/Cerebras/CerebrasUsageFetcher.swift +0 -75
  324. package/Sources/RunicCore/Providers/Claude/ClaudeCLISession.swift +0 -332
  325. package/Sources/RunicCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +0 -180
  326. package/Sources/RunicCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift +0 -137
  327. package/Sources/RunicCore/Providers/Claude/ClaudeProviderDescriptor.swift +0 -207
  328. package/Sources/RunicCore/Providers/Claude/ClaudeStatusProbe.swift +0 -654
  329. package/Sources/RunicCore/Providers/Claude/ClaudeUsageDataSource.swift +0 -28
  330. package/Sources/RunicCore/Providers/Claude/ClaudeUsageFetcher.swift +0 -480
  331. package/Sources/RunicCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +0 -780
  332. package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +0 -147
  333. package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift +0 -280
  334. package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift +0 -100
  335. package/Sources/RunicCore/Providers/Codex/CodexProviderDescriptor.swift +0 -243
  336. package/Sources/RunicCore/Providers/Codex/CodexStatusProbe.swift +0 -125
  337. package/Sources/RunicCore/Providers/Codex/CodexUsageDataSource.swift +0 -24
  338. package/Sources/RunicCore/Providers/Codex/CodexWebDashboardStrategy.swift +0 -181
  339. package/Sources/RunicCore/Providers/Cohere/CohereProviderDescriptor.swift +0 -83
  340. package/Sources/RunicCore/Providers/Cohere/CohereUsageFetcher.swift +0 -85
  341. package/Sources/RunicCore/Providers/Copilot/CopilotDeviceFlow.swift +0 -123
  342. package/Sources/RunicCore/Providers/Copilot/CopilotGitHubCLITokenReader.swift +0 -114
  343. package/Sources/RunicCore/Providers/Copilot/CopilotProviderDescriptor.swift +0 -100
  344. package/Sources/RunicCore/Providers/Copilot/CopilotUsageFetcher.swift +0 -291
  345. package/Sources/RunicCore/Providers/Copilot/CopilotVSCodeTokenReader.swift +0 -386
  346. package/Sources/RunicCore/Providers/Cursor/CursorProviderDescriptor.swift +0 -65
  347. package/Sources/RunicCore/Providers/Cursor/CursorStatusProbe.swift +0 -595
  348. package/Sources/RunicCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift +0 -85
  349. package/Sources/RunicCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift +0 -108
  350. package/Sources/RunicCore/Providers/Factory/FactoryLocalStorageImporter.swift +0 -398
  351. package/Sources/RunicCore/Providers/Factory/FactoryProviderDescriptor.swift +0 -65
  352. package/Sources/RunicCore/Providers/Factory/FactoryStatusProbe.swift +0 -1276
  353. package/Sources/RunicCore/Providers/Fireworks/FireworksProviderDescriptor.swift +0 -84
  354. package/Sources/RunicCore/Providers/Fireworks/FireworksUsageFetcher.swift +0 -75
  355. package/Sources/RunicCore/Providers/Gemini/GeminiProviderDescriptor.swift +0 -65
  356. package/Sources/RunicCore/Providers/Gemini/GeminiStatusProbe.swift +0 -836
  357. package/Sources/RunicCore/Providers/Groq/GroqProviderDescriptor.swift +0 -83
  358. package/Sources/RunicCore/Providers/Groq/GroqUsageFetcher.swift +0 -71
  359. package/Sources/RunicCore/Providers/Kimi/KimiProviderDescriptor.swift +0 -83
  360. package/Sources/RunicCore/Providers/Kimi/KimiUsageFetcher.swift +0 -77
  361. package/Sources/RunicCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +0 -92
  362. package/Sources/RunicCore/Providers/MiniMax/MiniMaxSettingsReader.swift +0 -54
  363. package/Sources/RunicCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +0 -168
  364. package/Sources/RunicCore/Providers/MiniMax/MiniMaxWebUsageFetcher.swift +0 -911
  365. package/Sources/RunicCore/Providers/Mistral/MistralProviderDescriptor.swift +0 -84
  366. package/Sources/RunicCore/Providers/Mistral/MistralUsageFetcher.swift +0 -75
  367. package/Sources/RunicCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift +0 -87
  368. package/Sources/RunicCore/Providers/OpenRouter/OpenRouterUsageFetcher.swift +0 -203
  369. package/Sources/RunicCore/Providers/Perplexity/PerplexityProviderDescriptor.swift +0 -84
  370. package/Sources/RunicCore/Providers/Perplexity/PerplexityUsageFetcher.swift +0 -75
  371. package/Sources/RunicCore/Providers/ProviderBranding.swift +0 -25
  372. package/Sources/RunicCore/Providers/ProviderCLIConfig.swift +0 -17
  373. package/Sources/RunicCore/Providers/ProviderDescriptor.swift +0 -129
  374. package/Sources/RunicCore/Providers/ProviderFetchPlan.swift +0 -263
  375. package/Sources/RunicCore/Providers/ProviderSettingsSnapshot.swift +0 -98
  376. package/Sources/RunicCore/Providers/ProviderTokenResolver.swift +0 -501
  377. package/Sources/RunicCore/Providers/ProviderVersionDetector.swift +0 -68
  378. package/Sources/RunicCore/Providers/Providers.swift +0 -184
  379. package/Sources/RunicCore/Providers/Qwen/QwenProviderDescriptor.swift +0 -84
  380. package/Sources/RunicCore/Providers/Qwen/QwenSettingsReader.swift +0 -30
  381. package/Sources/RunicCore/Providers/Qwen/QwenUsageStats.swift +0 -315
  382. package/Sources/RunicCore/Providers/SambaNova/SambaNovaProviderDescriptor.swift +0 -84
  383. package/Sources/RunicCore/Providers/SambaNova/SambaNovaUsageFetcher.swift +0 -75
  384. package/Sources/RunicCore/Providers/Together/TogetherProviderDescriptor.swift +0 -83
  385. package/Sources/RunicCore/Providers/Together/TogetherUsageFetcher.swift +0 -75
  386. package/Sources/RunicCore/Providers/VertexAI/VertexAIProviderDescriptor.swift +0 -123
  387. package/Sources/RunicCore/Providers/VertexAI/VertexAISettingsReader.swift +0 -48
  388. package/Sources/RunicCore/Providers/VertexAI/VertexAIUsageFetcher.swift +0 -144
  389. package/Sources/RunicCore/Providers/XAI/XAIProviderDescriptor.swift +0 -83
  390. package/Sources/RunicCore/Providers/XAI/XAIUsageFetcher.swift +0 -75
  391. package/Sources/RunicCore/Providers/Zai/ZaiProviderDescriptor.swift +0 -72
  392. package/Sources/RunicCore/Providers/Zai/ZaiSettingsReader.swift +0 -41
  393. package/Sources/RunicCore/Providers/Zai/ZaiUsageStats.swift +0 -718
  394. package/Sources/RunicCore/Sync/BackgroundSyncManager.swift +0 -385
  395. package/Sources/RunicCore/Sync/SyncConflictResolver.swift +0 -310
  396. package/Sources/RunicCore/Sync/SyncProtocol.swift +0 -288
  397. package/Sources/RunicCore/Sync/SyncRecord.swift +0 -405
  398. package/Sources/RunicCore/Sync/SyncUsageExample.swift +0 -294
  399. package/Sources/RunicCore/Sync/TeamSyncRecords.swift +0 -775
  400. package/Sources/RunicCore/Sync/iCloudSyncEngine.swift +0 -333
  401. package/Sources/RunicCore/Team/TeamModels.swift +0 -583
  402. package/Sources/RunicCore/Team/TeamShowbackExporter.swift +0 -495
  403. package/Sources/RunicCore/Team/TeamStore.swift +0 -792
  404. package/Sources/RunicCore/TextParsing.swift +0 -55
  405. package/Sources/RunicCore/UsageFetcher.swift +0 -694
  406. package/Sources/RunicCore/UsageFormatter.swift +0 -410
  407. package/Sources/RunicCore/UsageLedger/ClaudeUsageLogSource.swift +0 -427
  408. package/Sources/RunicCore/UsageLedger/CodexUsageLogSource.swift +0 -494
  409. package/Sources/RunicCore/UsageLedger/LedgerCache.swift +0 -158
  410. package/Sources/RunicCore/UsageLedger/OTelGenAILedgerAdapter.swift +0 -550
  411. package/Sources/RunicCore/UsageLedger/UsageLedger.swift +0 -29
  412. package/Sources/RunicCore/UsageLedger/UsageLedgerAggregator.swift +0 -660
  413. package/Sources/RunicCore/UsageLedger/UsageLedgerInsightsAdvisor.swift +0 -209
  414. package/Sources/RunicCore/UsageLedger/UsageLedgerModels.swift +0 -417
  415. package/Sources/RunicCore/UsageLedger/UsageLedgerProjectIdentity.swift +0 -245
  416. package/Sources/RunicCore/UsagePace.swift +0 -81
  417. package/Sources/RunicCore/Vendored/CostUsage/CostUsageCache.swift +0 -73
  418. package/Sources/RunicCore/Vendored/CostUsage/CostUsageJsonl.swift +0 -74
  419. package/Sources/RunicCore/Vendored/CostUsage/CostUsagePricing.swift +0 -208
  420. package/Sources/RunicCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +0 -455
  421. package/Sources/RunicCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift +0 -132
  422. package/Sources/RunicCore/Vendored/CostUsage/CostUsageScanner.swift +0 -589
  423. package/Sources/RunicCore/WidgetSnapshot.swift +0 -183
  424. package/Sources/RunicMacroSupport/ProviderRegistrationMacros.swift +0 -14
  425. package/Sources/RunicMacros/ProviderRegistrationMacros.swift +0 -216
  426. package/Sources/RunicWidget/RunicWidgetBundle.swift +0 -79
  427. package/Sources/RunicWidget/RunicWidgetProvider.swift +0 -283
  428. package/Sources/RunicWidget/RunicWidgetViews.swift +0 -680
  429. package/Tests/RunicTests/AntigravityStatusProbeTests.swift +0 -50
  430. package/Tests/RunicTests/AppDelegateTests.swift +0 -43
  431. package/Tests/RunicTests/BrowserCookieOrderLabelTests.swift +0 -22
  432. package/Tests/RunicTests/CLIArgumentParsingTests.swift +0 -70
  433. package/Tests/RunicTests/CLICostTests.swift +0 -65
  434. package/Tests/RunicTests/CLIProviderSelectionTests.swift +0 -22
  435. package/Tests/RunicTests/CLISnapshotTests.swift +0 -72
  436. package/Tests/RunicTests/CLIWebFallbackTests.swift +0 -58
  437. package/Tests/RunicTests/ClaudeOAuthTests.swift +0 -164
  438. package/Tests/RunicTests/ClaudeResilienceTests.swift +0 -30
  439. package/Tests/RunicTests/ClaudeUsageTests.swift +0 -370
  440. package/Tests/RunicTests/CodexOAuthTests.swift +0 -122
  441. package/Tests/RunicTests/CodexUsageLogSourceTests.swift +0 -82
  442. package/Tests/RunicTests/CopilotTokenResolverTests.swift +0 -75
  443. package/Tests/RunicTests/CopilotVSCodeTokenReaderTests.swift +0 -75
  444. package/Tests/RunicTests/CostUsageDecodingTests.swift +0 -394
  445. package/Tests/RunicTests/CostUsagePricingTests.swift +0 -50
  446. package/Tests/RunicTests/CostUsageScannerTests.swift +0 -700
  447. package/Tests/RunicTests/CursorStatusProbeTests.swift +0 -323
  448. package/Tests/RunicTests/FactoryStatusProbeTests.swift +0 -69
  449. package/Tests/RunicTests/GeminiAPITestHelpers.swift +0 -94
  450. package/Tests/RunicTests/GeminiLoginAlertTests.swift +0 -28
  451. package/Tests/RunicTests/GeminiStatusProbeAPITests.swift +0 -283
  452. package/Tests/RunicTests/GeminiStatusProbePlanTests.swift +0 -279
  453. package/Tests/RunicTests/GeminiStatusProbeTests.swift +0 -233
  454. package/Tests/RunicTests/GeminiTestEnvironment.swift +0 -87
  455. package/Tests/RunicTests/GoogleWorkspaceStatusTests.swift +0 -73
  456. package/Tests/RunicTests/InstallOriginTests.swift +0 -18
  457. package/Tests/RunicTests/KeyboardShortcutsBundleTests.swift +0 -9
  458. package/Tests/RunicTests/LiveAccountTests.swift +0 -24
  459. package/Tests/RunicTests/LoadingPatternTests.swift +0 -35
  460. package/Tests/RunicTests/MenuCardModelTests.swift +0 -545
  461. package/Tests/RunicTests/MiniMaxCookieHeaderStoreTestSupport.swift +0 -8
  462. package/Tests/RunicTests/MiniMaxGroupIDStoreTestSupport.swift +0 -8
  463. package/Tests/RunicTests/MiniMaxParsingTests.swift +0 -68
  464. package/Tests/RunicTests/MiniMaxTokenStoreTestSupport.swift +0 -8
  465. package/Tests/RunicTests/OTelGenAILedgerAdapterTests.swift +0 -112
  466. package/Tests/RunicTests/OpenAIDashboardBrowserCookieImporterTests.swift +0 -17
  467. package/Tests/RunicTests/OpenAIDashboardFetcherCreditsWaitTests.swift +0 -75
  468. package/Tests/RunicTests/OpenAIDashboardOffscreenHostTests.swift +0 -27
  469. package/Tests/RunicTests/OpenAIDashboardParserTests.swift +0 -134
  470. package/Tests/RunicTests/OpenAIWebAccountSwitchTests.swift +0 -53
  471. package/Tests/RunicTests/OpenRouterTokenStoreTestSupport.swift +0 -92
  472. package/Tests/RunicTests/PathBuilderTests.swift +0 -237
  473. package/Tests/RunicTests/ProviderFetchPipelineTests.swift +0 -160
  474. package/Tests/RunicTests/ProviderIconResourcesTests.swift +0 -38
  475. package/Tests/RunicTests/ProviderMetadataStatusLinkTests.swift +0 -16
  476. package/Tests/RunicTests/ProviderRegistryTests.swift +0 -29
  477. package/Tests/RunicTests/ProviderSettingsDescriptorTests.swift +0 -202
  478. package/Tests/RunicTests/ProviderToggleStoreTests.swift +0 -51
  479. package/Tests/RunicTests/RunicTests.swift +0 -120
  480. package/Tests/RunicTests/SessionQuotaNotificationLogicTests.swift +0 -36
  481. package/Tests/RunicTests/SettingsStoreTests.swift +0 -161
  482. package/Tests/RunicTests/StatusItemAnimationTests.swift +0 -49
  483. package/Tests/RunicTests/StatusMenuTests.swift +0 -434
  484. package/Tests/RunicTests/StatusProbeTests.swift +0 -377
  485. package/Tests/RunicTests/SubprocessRunnerTests.swift +0 -20
  486. package/Tests/RunicTests/SubscriptionDetectionTests.swift +0 -65
  487. package/Tests/RunicTests/TTYCommandRunnerTests.swift +0 -148
  488. package/Tests/RunicTests/TTYIntegrationTests.swift +0 -41
  489. package/Tests/RunicTests/TeamShowbackExporterTests.swift +0 -159
  490. package/Tests/RunicTests/TextParsingTests.swift +0 -12
  491. package/Tests/RunicTests/UsageFormatterTests.swift +0 -112
  492. package/Tests/RunicTests/UsageLedgerAnomalyDetectionTests.swift +0 -172
  493. package/Tests/RunicTests/UsageLedgerForecastTests.swift +0 -213
  494. package/Tests/RunicTests/UsageLedgerInsightsAdvisorTests.swift +0 -83
  495. package/Tests/RunicTests/UsageLedgerProjectNameTests.swift +0 -170
  496. package/Tests/RunicTests/UsagePaceTests.swift +0 -77
  497. package/Tests/RunicTests/UsagePaceTextTests.swift +0 -109
  498. package/Tests/RunicTests/UsagePolicyEngineTests.swift +0 -91
  499. package/Tests/RunicTests/WidgetSnapshotTests.swift +0 -45
  500. package/Tests/RunicTests/ZaiProviderTests.swift +0 -61
  501. package/Tests/RunicTests/ZaiTokenStoreTestSupport.swift +0 -6
  502. package/TestsLinux/PlatformGatingTests.swift +0 -46
  503. package/appcast.xml +0 -144
  504. package/assets/logo-concept-stack.svg +0 -24
  505. package/assets/logo-minimal-bars.svg +0 -15
  506. package/assets/logo.svg +0 -20
  507. package/assets/logo2.svg +0 -43
  508. package/bin/docs-list +0 -5
  509. package/build-and-run.sh +0 -369
  510. package/docs/.nojekyll +0 -1
  511. package/docs/CNAME +0 -1
  512. package/docs/antigravity.md +0 -75
  513. package/docs/architecture.md +0 -32
  514. package/docs/claude.md +0 -93
  515. package/docs/cli-commands/integration_guide.md +0 -371
  516. package/docs/cli-commands/quick_reference.md +0 -362
  517. package/docs/cli-commands/readme.md +0 -361
  518. package/docs/cli.md +0 -156
  519. package/docs/cloudkit_sync_guide.md +0 -495
  520. package/docs/codebase_review.md +0 -288
  521. package/docs/codex-oauth.md +0 -635
  522. package/docs/codex.md +0 -104
  523. package/docs/codexbar.png +0 -0
  524. package/docs/copilot.md +0 -44
  525. package/docs/cursor.md +0 -46
  526. package/docs/design/designcomparisonchart.md +0 -510
  527. package/docs/design/implementationguide.md +0 -792
  528. package/docs/design/menubaricondesignconcepts.md +0 -796
  529. package/docs/design/mockups/hybridiconconcept.svg +0 -62
  530. package/docs/design/mockups/infinityiconconcept.svg +0 -41
  531. package/docs/design/mockups/infinityiconstates.svg +0 -129
  532. package/docs/design/mockups/wavelogorefined.svg +0 -48
  533. package/docs/design/quick_summary.md +0 -426
  534. package/docs/design/readme.md +0 -478
  535. package/docs/design/sizespecifications.md +0 -575
  536. package/docs/design-system.md +0 -836
  537. package/docs/factory.md +0 -103
  538. package/docs/gemini.md +0 -73
  539. package/docs/icon.md +0 -37
  540. package/docs/icon.png +0 -0
  541. package/docs/index.html +0 -175
  542. package/docs/keychain_fix.md +0 -274
  543. package/docs/minimax.md +0 -71
  544. package/docs/packaging.md +0 -25
  545. package/docs/progress-bar-improvements.md +0 -240
  546. package/docs/progress-bar-quick-reference.md +0 -283
  547. package/docs/progress-bar-technical-details.md +0 -432
  548. package/docs/progress-bar-visual-test.swift +0 -375
  549. package/docs/provider.md +0 -202
  550. package/docs/providers.md +0 -86
  551. package/docs/refactor/macros.md +0 -35
  552. package/docs/refresh-loop.md +0 -22
  553. package/docs/releasing-homebrew.md +0 -35
  554. package/docs/releasing.md +0 -121
  555. package/docs/research/concrete_todo_2026-02-22.md +0 -47
  556. package/docs/research/core-feature-research.md +0 -248
  557. package/docs/research/feature_uiux_roadmap_2026-02-22.md +0 -202
  558. package/docs/research/parity_matrix.md +0 -49
  559. package/docs/research/provider_expansion_candidates_2026-02-22.md +0 -80
  560. package/docs/research/provider_model_token_project_matrix_2026-02-23.md +0 -46
  561. package/docs/research/release_gate_checklist.md +0 -57
  562. package/docs/site.css +0 -505
  563. package/docs/sparkle.md +0 -28
  564. package/docs/status.md +0 -28
  565. package/docs/sync/implementation.md +0 -393
  566. package/docs/sync/readme.md +0 -317
  567. package/docs/sync_files_summary.md +0 -391
  568. package/docs/sync_quick_reference.md +0 -225
  569. package/docs/todo.md +0 -11
  570. package/docs/ui.md +0 -29
  571. package/docs/web-integration.md +0 -9
  572. package/docs/widgets.md +0 -85
  573. package/docs/workflows/error-handling-improvements.md +0 -434
  574. package/docs/workflows/folder_structure.md +0 -299
  575. package/docs/workflows/loading_states.md +0 -446
  576. package/docs/workflows/loading_states_examples.md +0 -565
  577. package/docs/workflows/loading_states_flow.md +0 -430
  578. package/docs/workflows/performance_optimizations.md +0 -457
  579. package/docs/workflows/phase1-cli-testing-report.md +0 -313
  580. package/docs/workflows/quick_reference.md +0 -228
  581. package/docs/workflows/readme.md +0 -59
  582. package/docs/workflows/recent_improvements.md +0 -275
  583. package/docs/workflows/sync_enablement_summary.md +0 -400
  584. package/docs/zai.md +0 -43
  585. package/runic.png +0 -0
  586. package/version.env +0 -2
@@ -1,3605 +0,0 @@
1
- import AppKit
2
- import RunicCore
3
- import Foundation
4
- import Observation
5
- import Silo
6
- import CoreGraphics
7
-
8
- /// **UsageStore** - Main state management for AI provider usage tracking
9
- ///
10
- /// **Purpose:**
11
- /// Central store managing all provider usage data, snapshots, errors, and refresh logic.
12
- /// This is the single source of truth for the app's state.
13
- ///
14
- /// **Responsibilities:**
15
- /// - Fetch usage snapshots from all providers (Claude, Codex, Cursor, Gemini, etc.)
16
- /// - Manage authentication state and cookies via Silo
17
- /// - Track token usage across all providers
18
- /// - Coordinate "ping" operations (manual and automatic)
19
- /// - Handle errors and stale data detection
20
- /// - Provide Observable state for SwiftUI views
21
- ///
22
- /// **Performance Philosophy:**
23
- /// - **Zero Token Leakage** - Always use cookies/cached data first before API calls
24
- /// - **Single Ping Strategy** - One ping per provider per session, cache rest
25
- /// - **Stale Detection** - Mark data stale after 5 minutes (PerformanceConstants.staleDuration)
26
- ///
27
- /// **Known Issues:**
28
- /// - **God Class** - 2089 lines, needs refactoring (see PLAN.md)
29
- /// - Planned split: `UsageStateStore`, `UsageFetchingActor`, `TokenUsageService`
30
- ///
31
- /// **Dependencies:**
32
- /// - `RunicCore` - Provider descriptors and status probes
33
- /// - `Silo` - Browser cookie access
34
- /// - `Observation` - SwiftUI state updates
35
- ///
36
- /// **Usage:**
37
- /// ```swift
38
- /// @Observable
39
- /// final class UsageStore {
40
- /// var snapshots: [UsageProvider: ProviderSnapshot] = [:]
41
- /// var errors: [UsageProvider: Error] = [:]
42
- ///
43
- /// func ping(provider: UsageProvider) async { ... }
44
- /// }
45
- /// ```
46
-
47
- // MARK: - Observation helpers
48
-
49
- @MainActor
50
- extension UsageStore {
51
- var menuObservationToken: Int {
52
- _ = self.snapshots
53
- _ = self.errors
54
- _ = self.lastSourceLabels
55
- _ = self.lastFetchAttempts
56
- _ = self.tokenSnapshots
57
- _ = self.tokenErrors
58
- _ = self.tokenRefreshInFlight
59
- _ = self.customProviderSnapshots
60
- _ = self.customProviderErrors
61
- _ = self.ledgerDailySummaries
62
- _ = self.ledgerAllDailySummaries
63
- _ = self.ledgerHourlySummaries
64
- _ = self.ledgerActiveBlocks
65
- _ = self.ledgerTopModels
66
- _ = self.ledgerTopProjects
67
- _ = self.ledgerModelBreakdowns
68
- _ = self.ledgerProjectBreakdowns
69
- _ = self.ledgerSpendForecasts
70
- _ = self.ledgerProjectSpendForecasts
71
- _ = self.ledgerTopProjectSpendForecasts
72
- _ = self.ledgerAnomalies
73
- _ = self.ledgerErrors
74
- _ = self.ledgerUpdatedAt
75
- _ = self.credits
76
- _ = self.lastCreditsError
77
- _ = self.openAIDashboard
78
- _ = self.lastOpenAIDashboardError
79
- _ = self.openAIDashboardRequiresLogin
80
- _ = self.openAIDashboardCookieImportStatus
81
- _ = self.openAIDashboardCookieImportDebugLog
82
- _ = self.codexVersion
83
- _ = self.claudeVersion
84
- _ = self.geminiVersion
85
- _ = self.zaiVersion
86
- _ = self.antigravityVersion
87
- _ = self.isRefreshing
88
- _ = self.refreshingProviders
89
- _ = self.pathDebugInfo
90
- _ = self.statuses
91
- _ = self.probeLogs
92
- _ = self.autoRefreshSuspensionReason
93
- _ = self.lastRefreshTrigger
94
- _ = self.lastRefreshAt
95
- _ = self.lastAutoRefreshDisableReason
96
- _ = self.lastAutoRefreshDisableAt
97
- return 0
98
- }
99
-
100
- func observeSettingsChanges() {
101
- withObservationTracking {
102
- _ = self.settings.refreshFrequency
103
- _ = self.settings.autoDisableRefreshWhenIdleEnabled
104
- _ = self.settings.autoDisableRefreshWhenIdleMinutes
105
- _ = self.settings.autoDisableRefreshOnSleepEnabled
106
- _ = self.settings.autoRefreshWarningEnabled
107
- _ = self.settings.autoRefreshWarningThreshold
108
- _ = self.settings.autoSuspendInactiveProvidersEnabled
109
- _ = self.settings.autoSuspendInactiveProvidersMinutes
110
- _ = self.settings.statusChecksEnabled
111
- _ = self.settings.sessionQuotaNotificationsEnabled
112
- _ = self.settings.usageBarsShowUsed
113
- _ = self.settings.costUsageEnabled
114
- _ = self.settings.randomBlinkEnabled
115
- _ = self.settings.claudeWebExtrasEnabled
116
- _ = self.settings.claudeUsageDataSource
117
- _ = self.settings.mergeIcons
118
- _ = self.settings.debugLoadingPattern
119
- } onChange: { [weak self] in
120
- Task { @MainActor [weak self] in
121
- guard let self else { return }
122
- self.observeSettingsChanges()
123
- await self.handleSettingsChange()
124
- }
125
- }
126
- }
127
- }
128
-
129
- // MARK: - OpenAI web error messaging
130
-
131
- extension UsageStore {
132
- private func openAIDashboardFriendlyError(
133
- body: String,
134
- targetEmail: String?,
135
- cookieImportStatus: String?) -> String?
136
- {
137
- let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines)
138
- let status = cookieImportStatus?.trimmingCharacters(in: .whitespacesAndNewlines)
139
- if trimmed.isEmpty {
140
- return [
141
- "OpenAI web dashboard returned an empty page.",
142
- "Sign in to chatgpt.com and re-enable “Access OpenAI via web”.",
143
- ].joined(separator: " ")
144
- }
145
-
146
- let lower = trimmed.lowercased()
147
- let looksLikePublicLanding = lower.contains("skip to content")
148
- && (lower.contains("about") || lower.contains("openai") || lower.contains("chatgpt"))
149
- let looksLoggedOut = lower.contains("sign in")
150
- || lower.contains("log in")
151
- || lower.contains("create account")
152
- || lower.contains("continue with google")
153
- || lower.contains("continue with apple")
154
- || lower.contains("continue with microsoft")
155
-
156
- guard looksLikePublicLanding || looksLoggedOut else { return nil }
157
- let emailLabel = targetEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
158
- let targetLabel = (emailLabel?.isEmpty == false) ? emailLabel! : "your OpenAI account"
159
- if let status, !status.isEmpty {
160
- if status.contains("Browser cookies do not match Codex account")
161
- || status.contains("Browser cookie import failed")
162
- {
163
- return [
164
- status,
165
- "Sign in to chatgpt.com as \(targetLabel), then re-enable “Access OpenAI via web”.",
166
- ].joined(separator: " ")
167
- }
168
- }
169
- return [
170
- "OpenAI web dashboard returned a public page (not signed in).",
171
- "Sign in to chatgpt.com as \(targetLabel), then re-enable “Access OpenAI via web”.",
172
- ].joined(separator: " ")
173
- }
174
- }
175
-
176
- enum ProviderStatusIndicator: String {
177
- case none
178
- case minor
179
- case major
180
- case critical
181
- case maintenance
182
- case unknown
183
-
184
- var hasIssue: Bool {
185
- switch self {
186
- case .none: false
187
- default: true
188
- }
189
- }
190
-
191
- var label: String {
192
- switch self {
193
- case .none: "Operational"
194
- case .minor: "Partial outage"
195
- case .major: "Major outage"
196
- case .critical: "Critical issue"
197
- case .maintenance: "Maintenance"
198
- case .unknown: "Status unknown"
199
- }
200
- }
201
- }
202
-
203
- enum RefreshTrigger: String {
204
- case manual
205
- case menuOpen
206
- case autoTimer
207
- case settingsChange
208
- case login
209
- case resume
210
- case startup
211
-
212
- var isAuto: Bool {
213
- switch self {
214
- case .manual, .login: false
215
- case .menuOpen, .autoTimer, .settingsChange, .resume, .startup: true
216
- }
217
- }
218
-
219
- var menuLabel: String {
220
- switch self {
221
- case .manual: "Manual"
222
- case .menuOpen: "Menu open"
223
- case .autoTimer: "Auto"
224
- case .settingsChange: "Settings"
225
- case .login: "Login"
226
- case .resume: "Resume"
227
- case .startup: "Startup"
228
- }
229
- }
230
- }
231
-
232
- enum AutoRefreshSuspensionReason: String {
233
- case systemSleep
234
- case screenSleep
235
- case sessionInactive
236
-
237
- var label: String {
238
- switch self {
239
- case .systemSleep: "Sleeping"
240
- case .screenSleep: "Display asleep"
241
- case .sessionInactive: "Session inactive"
242
- }
243
- }
244
- }
245
-
246
- enum AutoRefreshDisableReason: String {
247
- case idle
248
- case systemSleep
249
- case screenSleep
250
- case sessionInactive
251
-
252
- var label: String {
253
- switch self {
254
- case .idle: "idle"
255
- case .systemSleep: "sleep"
256
- case .screenSleep: "display sleep"
257
- case .sessionInactive: "lock"
258
- }
259
- }
260
- }
261
-
262
- #if DEBUG
263
- extension UsageStore {
264
- func _setSnapshotForTesting(_ snapshot: UsageSnapshot?, provider: UsageProvider) {
265
- self.snapshots[provider] = snapshot?.scoped(to: provider)
266
- }
267
-
268
- func _setTokenSnapshotForTesting(_ snapshot: CostUsageTokenSnapshot?, provider: UsageProvider) {
269
- self.tokenSnapshots[provider] = snapshot
270
- }
271
-
272
- func _setTokenErrorForTesting(_ error: String?, provider: UsageProvider) {
273
- self.tokenErrors[provider] = error
274
- }
275
-
276
- func _setErrorForTesting(_ error: String?, provider: UsageProvider) {
277
- self.errors[provider] = error
278
- }
279
- }
280
- #endif
281
-
282
- struct ProviderStatus {
283
- let indicator: ProviderStatusIndicator
284
- let description: String?
285
- let updatedAt: Date?
286
- }
287
-
288
- /// Tracks consecutive failures so we can ignore a single flake when we previously had fresh data.
289
- struct ConsecutiveFailureGate {
290
- private(set) var streak: Int = 0
291
-
292
- mutating func recordSuccess() {
293
- self.streak = 0
294
- }
295
-
296
- mutating func reset() {
297
- self.streak = 0
298
- }
299
-
300
- /// Returns true when the caller should surface the error to the UI.
301
- mutating func shouldSurfaceError(onFailureWithPriorData hadPriorData: Bool) -> Bool {
302
- self.streak += 1
303
- if hadPriorData, self.streak == 1 { return false }
304
- return true
305
- }
306
- }
307
-
308
- struct UsageLedgerAnomalySummary: Sendable, Hashable {
309
- enum Severity: Int, Sendable, Hashable {
310
- case elevated = 1
311
- case high = 2
312
- case critical = 3
313
-
314
- var label: String {
315
- switch self {
316
- case .elevated: "Elevated"
317
- case .high: "High"
318
- case .critical: "Critical"
319
- }
320
- }
321
- }
322
-
323
- struct MetricAnomaly: Sendable, Hashable {
324
- enum Metric: String, Sendable, Hashable {
325
- case tokens
326
- case spend
327
-
328
- var label: String {
329
- switch self {
330
- case .tokens: "tokens"
331
- case .spend: "spend"
332
- }
333
- }
334
- }
335
-
336
- let metric: Metric
337
- let severity: Severity
338
- let todayValue: Double
339
- let baselineAverage: Double
340
- let percentIncrease: Double
341
- }
342
-
343
- struct Explanation: Sendable, Hashable {
344
- struct Factor: Sendable, Hashable {
345
- let metric: MetricAnomaly.Metric
346
- let severity: Severity
347
- let percentIncrease: Double
348
- let detail: String
349
- }
350
-
351
- let headline: String
352
- let details: [String]
353
- let primaryFactor: Factor
354
- let contributingFactors: [Factor]
355
- }
356
- let provider: UsageProvider
357
- let baselineDays: Int
358
- let tokenAnomaly: MetricAnomaly?
359
- let spendAnomaly: MetricAnomaly?
360
-
361
- var primaryAnomaly: MetricAnomaly? {
362
- [self.tokenAnomaly, self.spendAnomaly]
363
- .compactMap { $0 }
364
- .max { lhs, rhs in
365
- if lhs.severity != rhs.severity {
366
- return lhs.severity.rawValue < rhs.severity.rawValue
367
- }
368
- return lhs.percentIncrease < rhs.percentIncrease
369
- }
370
- }
371
-
372
- func secondaryAnomaly(excluding metric: MetricAnomaly.Metric) -> MetricAnomaly? {
373
- let candidates = [self.tokenAnomaly, self.spendAnomaly].compactMap { $0 }
374
- guard !candidates.isEmpty else { return nil }
375
- return candidates.first { $0.metric != metric }
376
- }
377
-
378
- var explanation: Explanation? {
379
- guard let primary = self.primaryAnomaly else { return nil }
380
- let primaryFactor = self.factor(from: primary)
381
- var contributingFactors: [Explanation.Factor] = []
382
- if let secondary = self.secondaryAnomaly(excluding: primary.metric) {
383
- contributingFactors.append(self.factor(from: secondary))
384
- }
385
- return Explanation(
386
- headline: "Anomaly: \(primary.severity.label) \(primary.metric.label) spike",
387
- details: [primaryFactor.detail] + contributingFactors.map(\.detail),
388
- primaryFactor: primaryFactor,
389
- contributingFactors: contributingFactors)
390
- }
391
-
392
- private func factor(from anomaly: MetricAnomaly) -> Explanation.Factor {
393
- Explanation.Factor(
394
- metric: anomaly.metric,
395
- severity: anomaly.severity,
396
- percentIncrease: anomaly.percentIncrease,
397
- detail: self.metricDetail(for: anomaly))
398
- }
399
-
400
- private func metricDetail(for anomaly: MetricAnomaly) -> String {
401
- let percentText = "\(Int((anomaly.percentIncrease * 100).rounded()))%"
402
- let baselineLabel = "\(self.baselineDays)d avg"
403
- switch anomaly.metric {
404
- case .tokens:
405
- let todayTokens = UsageFormatter.tokenCountString(Int(anomaly.todayValue.rounded()))
406
- let baselineTokens = UsageFormatter.tokenCountString(Int(anomaly.baselineAverage.rounded()))
407
- return "Tokens \(todayTokens) today · +\(percentText) vs \(baselineLabel) \(baselineTokens)"
408
- case .spend:
409
- let todaySpend = UsageFormatter.usdString(anomaly.todayValue)
410
- let baselineSpend = UsageFormatter.usdString(anomaly.baselineAverage)
411
- return "Spend \(todaySpend) today · +\(percentText) vs \(baselineLabel) \(baselineSpend)"
412
- }
413
- }
414
- }
415
-
416
- enum UsageLedgerAnomalyDetector {
417
- private static let baselineDayCount = 7
418
- private static let minimumIncreaseThreshold = 0.60
419
-
420
- private struct DayUsageBucket {
421
- var tokens: Int = 0
422
- var costSum: Double = 0
423
- var hasCost: Bool = false
424
-
425
- mutating func consume(_ summary: UsageLedgerDailySummary) {
426
- self.tokens += summary.totals.totalTokens
427
- if let cost = summary.totals.costUSD {
428
- self.costSum += cost
429
- self.hasCost = true
430
- }
431
- }
432
-
433
- var costUSD: Double? {
434
- self.hasCost ? self.costSum : nil
435
- }
436
- }
437
-
438
- static func summaries(
439
- dailySummaries: [UsageLedgerDailySummary],
440
- now: Date,
441
- calendar: Calendar? = nil) -> [UsageProvider: UsageLedgerAnomalySummary]
442
- {
443
- var calendar = calendar ?? Calendar(identifier: .gregorian)
444
- calendar.timeZone = TimeZone.current
445
- let todayStart = calendar.startOfDay(for: now)
446
-
447
- var bucketsByProvider: [UsageProvider: [Date: DayUsageBucket]] = [:]
448
- for summary in dailySummaries {
449
- let dayStart = calendar.startOfDay(for: summary.dayStart)
450
- bucketsByProvider[summary.provider, default: [:]][dayStart, default: DayUsageBucket()].consume(summary)
451
- }
452
-
453
- var summariesByProvider: [UsageProvider: UsageLedgerAnomalySummary] = [:]
454
- for (provider, dayBuckets) in bucketsByProvider {
455
- guard let todayBucket = dayBuckets[todayStart] else { continue }
456
-
457
- var baselineBuckets: [DayUsageBucket] = []
458
- baselineBuckets.reserveCapacity(self.baselineDayCount)
459
- for dayOffset in 1...self.baselineDayCount {
460
- guard let baselineDay = calendar.date(byAdding: .day, value: -dayOffset, to: todayStart),
461
- let bucket = dayBuckets[baselineDay]
462
- else {
463
- baselineBuckets.removeAll(keepingCapacity: true)
464
- break
465
- }
466
- baselineBuckets.append(bucket)
467
- }
468
- guard baselineBuckets.count == self.baselineDayCount else { continue }
469
-
470
- let tokenAnomaly = self.metricAnomaly(
471
- metric: .tokens,
472
- todayValue: Double(todayBucket.tokens),
473
- baselineValues: baselineBuckets.map { Double($0.tokens) })
474
-
475
- var spendAnomaly: UsageLedgerAnomalySummary.MetricAnomaly?
476
- if let todayCost = todayBucket.costUSD {
477
- spendAnomaly = self.metricAnomaly(
478
- metric: .spend,
479
- todayValue: todayCost,
480
- baselineValues: baselineBuckets.compactMap(\.costUSD))
481
- }
482
-
483
- if tokenAnomaly != nil || spendAnomaly != nil {
484
- summariesByProvider[provider] = UsageLedgerAnomalySummary(
485
- provider: provider,
486
- baselineDays: self.baselineDayCount,
487
- tokenAnomaly: tokenAnomaly,
488
- spendAnomaly: spendAnomaly)
489
- }
490
- }
491
-
492
- return summariesByProvider
493
- }
494
-
495
- private static func metricAnomaly(
496
- metric: UsageLedgerAnomalySummary.MetricAnomaly.Metric,
497
- todayValue: Double,
498
- baselineValues: [Double]) -> UsageLedgerAnomalySummary.MetricAnomaly?
499
- {
500
- guard baselineValues.count == self.baselineDayCount else { return nil }
501
- guard baselineValues.allSatisfy(\.isFinite) else { return nil }
502
- guard todayValue.isFinite else { return nil }
503
-
504
- let baselineAverage = baselineValues.reduce(0, +) / Double(baselineValues.count)
505
- guard baselineAverage > 0 else { return nil }
506
-
507
- let increase = (todayValue - baselineAverage) / baselineAverage
508
- guard let severity = self.severity(for: increase) else { return nil }
509
- return UsageLedgerAnomalySummary.MetricAnomaly(
510
- metric: metric,
511
- severity: severity,
512
- todayValue: todayValue,
513
- baselineAverage: baselineAverage,
514
- percentIncrease: increase)
515
- }
516
-
517
- private static func severity(for increase: Double) -> UsageLedgerAnomalySummary.Severity? {
518
- guard increase.isFinite, increase >= self.minimumIncreaseThreshold else { return nil }
519
- if increase >= 2.0 { return .critical }
520
- if increase >= 1.0 { return .high }
521
- return .elevated
522
- }
523
- }
524
-
525
- struct ProviderHistoryDaySnapshot: Sendable, Hashable, Identifiable {
526
- let dayStart: Date
527
- let totals: UsageLedgerTotals
528
- let requestCount: Int
529
- let modelsUsed: [String]
530
- let topModel: UsageLedgerModelSummary?
531
- let topProject: UsageLedgerProjectSummary?
532
- let modelSummaries: [UsageLedgerModelSummary]
533
- let projectSummaries: [UsageLedgerProjectSummary]
534
-
535
- var id: Date { self.dayStart }
536
- }
537
-
538
- struct ProviderHistoryMonthSnapshot: Sendable, Hashable {
539
- let provider: UsageProvider
540
- let monthStart: Date
541
- let generatedAt: Date
542
- let days: [ProviderHistoryDaySnapshot]
543
- let isSupported: Bool
544
- let note: String?
545
- let error: String?
546
- }
547
- @MainActor
548
- @Observable
549
- final class UsageStore {
550
- private(set) var snapshots: [UsageProvider: UsageSnapshot] = [:]
551
- private(set) var errors: [UsageProvider: String] = [:]
552
- private(set) var lastSourceLabels: [UsageProvider: String] = [:]
553
- private(set) var lastFetchAttempts: [UsageProvider: [ProviderFetchAttempt]] = [:]
554
- private(set) var tokenSnapshots: [UsageProvider: CostUsageTokenSnapshot] = [:]
555
- // Custom provider snapshots
556
- private(set) var customProviderSnapshots: [String: CustomProviderSnapshot] = [:]
557
- private(set) var customProviderErrors: [String: String] = [:]
558
- private(set) var tokenErrors: [UsageProvider: String] = [:]
559
- private(set) var tokenRefreshInFlight: Set<UsageProvider> = []
560
- private(set) var ledgerDailySummaries: [UsageProvider: UsageLedgerDailySummary] = [:]
561
- private(set) var ledgerAllDailySummaries: [UsageProvider: [UsageLedgerDailySummary]] = [:]
562
- private(set) var ledgerHourlySummaries: [UsageProvider: [UsageLedgerHourlySummary]] = [:]
563
- private(set) var ledgerActiveBlocks: [UsageProvider: UsageLedgerBlockSummary] = [:]
564
- private(set) var ledgerTopModels: [UsageProvider: UsageLedgerModelSummary] = [:]
565
- private(set) var ledgerTopProjects: [UsageProvider: UsageLedgerProjectSummary] = [:]
566
- private(set) var ledgerModelBreakdowns: [UsageProvider: [UsageLedgerModelSummary]] = [:]
567
- private(set) var ledgerProjectBreakdowns: [UsageProvider: [UsageLedgerProjectSummary]] = [:]
568
- private(set) var ledgerSpendForecasts: [UsageProvider: UsageLedgerSpendForecast] = [:]
569
- private(set) var ledgerProjectSpendForecasts: [UsageProvider: [UsageLedgerSpendForecast]] = [:]
570
- private(set) var ledgerTopProjectSpendForecasts: [UsageProvider: UsageLedgerSpendForecast] = [:]
571
- private(set) var ledgerAnomalies: [UsageProvider: UsageLedgerAnomalySummary] = [:]
572
- private(set) var ledgerErrors: [UsageProvider: String] = [:]
573
- private(set) var ledgerUpdatedAt: [UsageProvider: Date] = [:]
574
- var credits: CreditsSnapshot?
575
- var lastCreditsError: String?
576
- var openAIDashboard: OpenAIDashboardSnapshot?
577
- var lastOpenAIDashboardError: String?
578
- private(set) var openAIDashboardRequiresLogin: Bool = false
579
- var openAIDashboardCookieImportStatus: String?
580
- var openAIDashboardCookieImportDebugLog: String?
581
- var codexVersion: String?
582
- var claudeVersion: String?
583
- var geminiVersion: String?
584
- var zaiVersion: String?
585
- var antigravityVersion: String?
586
- var cursorVersion: String?
587
- var isRefreshing = false
588
- private(set) var refreshingProviders: Set<UsageProvider> = []
589
- var debugForceAnimation = false
590
- var pathDebugInfo: PathDebugSnapshot = .empty
591
- private(set) var statuses: [UsageProvider: ProviderStatus] = [:]
592
- private(set) var probeLogs: [UsageProvider: String] = [:]
593
- @ObservationIgnored private var lastCreditsSnapshot: CreditsSnapshot?
594
- @ObservationIgnored private var creditsFailureStreak: Int = 0
595
- @ObservationIgnored private var lastOpenAIDashboardSnapshot: OpenAIDashboardSnapshot?
596
- @ObservationIgnored private var lastOpenAIDashboardTargetEmail: String?
597
- @ObservationIgnored private var lastOpenAIDashboardCookieImportAttemptAt: Date?
598
- @ObservationIgnored private var lastOpenAIDashboardCookieImportEmail: String?
599
- @ObservationIgnored private var openAIWebAccountDidChange: Bool = false
600
- private var autoRefreshSuspensionReason: AutoRefreshSuspensionReason?
601
- private(set) var lastRefreshTrigger: RefreshTrigger?
602
- private(set) var lastRefreshAt: Date?
603
- private(set) var lastAutoRefreshDisableReason: AutoRefreshDisableReason?
604
- private(set) var lastAutoRefreshDisableAt: Date?
605
- @ObservationIgnored private var autoRefreshRunCount: Int = 0
606
- @ObservationIgnored private var autoRefreshWarningSent: Bool = false
607
- @ObservationIgnored private var suppressNextSettingsRefresh: Bool = false
608
- @ObservationIgnored private var lastUsageDeltaAt: [UsageProvider: Date] = [:]
609
- @ObservationIgnored private var lastLedgerActivityAt: [UsageProvider: Date] = [:]
610
- @ObservationIgnored private var lastRefreshFrequency: RefreshFrequency = .manual
611
- @ObservationIgnored private var lastAutoRefreshWarningEnabled: Bool = true
612
- @ObservationIgnored private var lastAutoRefreshWarningThreshold: Int = 10
613
-
614
- @ObservationIgnored private let codexFetcher: UsageFetcher
615
- @ObservationIgnored private let claudeFetcher: any ClaudeUsageFetching
616
- @ObservationIgnored private let costUsageFetcher: CostUsageFetcher
617
- @ObservationIgnored private let registry: ProviderRegistry
618
- @ObservationIgnored private let settings: SettingsStore
619
- @ObservationIgnored private let sessionQuotaNotifier: SessionQuotaNotifier
620
- @ObservationIgnored private let sessionQuotaLogger = RunicLog.logger("sessionQuota")
621
- @ObservationIgnored private let openAIWebLogger = RunicLog.logger("openai-web")
622
- @ObservationIgnored private let tokenCostLogger = RunicLog.logger("token-cost")
623
- @ObservationIgnored private var openAIWebDebugLines: [String] = []
624
- @ObservationIgnored private var failureGates: [UsageProvider: ConsecutiveFailureGate] = [:]
625
- @ObservationIgnored private var tokenFailureGates: [UsageProvider: ConsecutiveFailureGate] = [:]
626
- @ObservationIgnored private var providerSpecs: [UsageProvider: ProviderSpec] = [:]
627
- @ObservationIgnored private let providerMetadata: [UsageProvider: ProviderMetadata]
628
- @ObservationIgnored private var timerTask: Task<Void, Never>?
629
- @ObservationIgnored private var tokenTimerTask: Task<Void, Never>?
630
- @ObservationIgnored private var tokenRefreshSequenceTask: Task<Void, Never>?
631
- @ObservationIgnored private var ledgerRefreshTask: Task<Void, Never>?
632
- @ObservationIgnored private var providerHistoryMonthCache: [UsageProvider: [String: ProviderHistoryMonthCacheEntry]] = [:]
633
- @ObservationIgnored private var lastKnownSessionRemaining: [UsageProvider: Double] = [:]
634
- @ObservationIgnored private(set) var lastTokenFetchAt: [UsageProvider: Date] = [:]
635
- @ObservationIgnored private let tokenFetchTTL: TimeInterval = 60 * 60
636
- @ObservationIgnored private let tokenFetchTimeout: TimeInterval = 10 * 60
637
- @ObservationIgnored private let ledgerRefreshTTL: TimeInterval = 90
638
- @ObservationIgnored private var ledgerMaxAgeDays: Int { self.settings.ledgerMaxAgeDays }
639
- @ObservationIgnored private let providerHistoryCacheTTL: TimeInterval = 90
640
- @ObservationIgnored private let providerHistoryMaxScanDays: Int = 180
641
- @ObservationIgnored nonisolated(unsafe) private let performanceStorage: PerformanceStorageImpl?
642
-
643
- init(
644
- fetcher: UsageFetcher,
645
- claudeFetcher: any ClaudeUsageFetching = ClaudeUsageFetcher(),
646
- costUsageFetcher: CostUsageFetcher = CostUsageFetcher(),
647
- settings: SettingsStore,
648
- registry: ProviderRegistry = .shared,
649
- sessionQuotaNotifier: SessionQuotaNotifier = SessionQuotaNotifier())
650
- {
651
- self.codexFetcher = fetcher
652
- self.claudeFetcher = claudeFetcher
653
- self.costUsageFetcher = costUsageFetcher
654
- self.settings = settings
655
- self.registry = registry
656
- self.sessionQuotaNotifier = sessionQuotaNotifier
657
- self.providerMetadata = registry.metadata
658
- self.lastRefreshFrequency = settings.refreshFrequency
659
-
660
- // Initialize performance tracking (optional, gracefully fails if unavailable)
661
- self.performanceStorage = try? PerformanceStorageImpl()
662
- self.lastAutoRefreshWarningEnabled = settings.autoRefreshWarningEnabled
663
- self.lastAutoRefreshWarningThreshold = settings.autoRefreshWarningThreshold
664
- self
665
- .failureGates = Dictionary(uniqueKeysWithValues: UsageProvider.allCases
666
- .map { ($0, ConsecutiveFailureGate()) })
667
- self.tokenFailureGates = Dictionary(uniqueKeysWithValues: UsageProvider.allCases
668
- .map { ($0, ConsecutiveFailureGate()) })
669
- self.providerSpecs = registry.specs(
670
- settings: settings,
671
- metadata: self.providerMetadata,
672
- codexFetcher: fetcher,
673
- claudeFetcher: claudeFetcher)
674
- self.bindSettings()
675
- self.detectVersions()
676
- self.refreshPathDebugInfo()
677
- LoginShellPathCache.shared.captureOnce { [weak self] _ in
678
- Task { @MainActor in self?.refreshPathDebugInfo() }
679
- }
680
- if self.settings.refreshFrequency != .manual {
681
- Task { await self.refresh(trigger: .startup) }
682
- }
683
- self.startTimer()
684
- self.startTokenTimer()
685
- }
686
-
687
- /// Returns the login method (plan type) for the specified provider, if available.
688
- private func loginMethod(for provider: UsageProvider) -> String? {
689
- self.snapshots[provider]?.loginMethod(for: provider)
690
- }
691
-
692
- /// Returns true if the Claude account appears to be a subscription (Max, Pro, Ultra, Team).
693
- /// Returns false for API users or when plan cannot be determined.
694
- func isClaudeSubscription() -> Bool {
695
- Self.isSubscriptionPlan(self.loginMethod(for: .claude))
696
- }
697
-
698
- /// Determines if a login method string indicates a Claude subscription plan.
699
- /// Known subscription indicators: Max, Pro, Ultra, Team (case-insensitive).
700
- nonisolated static func isSubscriptionPlan(_ loginMethod: String?) -> Bool {
701
- guard let method = loginMethod?.lowercased(), !method.isEmpty else {
702
- return false
703
- }
704
- let subscriptionIndicators = ["max", "pro", "ultra", "team"]
705
- return subscriptionIndicators.contains { method.contains($0) }
706
- }
707
-
708
- func version(for provider: UsageProvider) -> String? {
709
- switch provider {
710
- case .codex: self.codexVersion
711
- case .claude: self.claudeVersion
712
- case .zai: self.zaiVersion
713
- case .gemini: self.geminiVersion
714
- case .antigravity: self.antigravityVersion
715
- case .cursor: self.cursorVersion
716
- case .factory: nil
717
- case .copilot: nil
718
- case .minimax: nil
719
- case .openrouter: nil
720
- case .groq: nil
721
- case .deepseek: nil
722
- case .fireworks: nil
723
- case .mistral: nil
724
- case .perplexity: nil
725
- case .kimi: nil
726
- case .auggie: nil
727
- case .together: nil
728
- case .cohere: nil
729
- case .xai: nil
730
- case .cerebras: nil
731
- case .sambanova: nil
732
- case .azure: nil
733
- case .bedrock: nil
734
- case .vertexai: nil
735
- case .qwen: nil
736
- }
737
- }
738
-
739
- var preferredSnapshot: UsageSnapshot? {
740
- for provider in self.enabledProviders() {
741
- if let snap = self.snapshots[provider] { return snap }
742
- }
743
- return nil
744
- }
745
-
746
- var iconStyle: IconStyle {
747
- let enabled = self.enabledProviders()
748
- if enabled.count > 1 { return .combined }
749
- if let provider = enabled.first {
750
- return self.style(for: provider)
751
- }
752
- return .codex
753
- }
754
-
755
- var isStale: Bool {
756
- self.enabledProviders().contains { provider in
757
- switch provider {
758
- case .codex:
759
- self.lastCodexError != nil
760
- case .claude:
761
- self.lastClaudeError != nil
762
- default:
763
- self.errors[provider] != nil
764
- }
765
- }
766
- }
767
-
768
- func autoRefreshStatusLine() -> String? {
769
- if self.settings.refreshFrequency == .manual {
770
- if let reason = self.lastAutoRefreshDisableReason {
771
- return "Auto-refresh: Manual (switched after \(reason.label))"
772
- }
773
- return "Auto-refresh: Manual"
774
- }
775
- if let reason = self.autoRefreshSuspensionReason {
776
- return "Auto-refresh: Paused (\(reason.label))"
777
- }
778
- return "Auto-refresh: \(self.settings.refreshFrequency.label)"
779
- }
780
-
781
- func setAutoRefreshSuspended(_ reason: AutoRefreshSuspensionReason?) {
782
- self.autoRefreshSuspensionReason = reason
783
- }
784
-
785
- func disableAutoRefreshForSystemPause(_ reason: AutoRefreshSuspensionReason) {
786
- let detail = self.systemPauseDetailText(reason)
787
- self.disableAutoRefresh(
788
- reason: self.disableReason(for: reason),
789
- idPrefix: "auto-refresh-system",
790
- body: "Runic switched auto-refresh to Manual because \(detail).")
791
- }
792
-
793
- func handleAutoRefreshSystemPause(_ reason: AutoRefreshSuspensionReason) {
794
- if self.settings.autoDisableRefreshOnSleepEnabled {
795
- self.disableAutoRefreshForSystemPause(reason)
796
- } else {
797
- self.setAutoRefreshSuspended(reason)
798
- }
799
- }
800
-
801
- func lastRefreshStatusLine(now: Date = .now) -> String? {
802
- guard let trigger = self.lastRefreshTrigger,
803
- let refreshedAt = self.lastRefreshAt else { return nil }
804
- let relative = refreshedAt.relativeDescription(now: now)
805
- return "Last refresh: \(trigger.menuLabel) • \(relative)"
806
- }
807
-
808
- func autoRefreshSwitchLine(now: Date = .now) -> String? {
809
- guard self.settings.refreshFrequency == .manual,
810
- let reason = self.lastAutoRefreshDisableReason,
811
- let disabledAt = self.lastAutoRefreshDisableAt else { return nil }
812
- let relative = disabledAt.relativeDescription(now: now)
813
- return "Auto-refresh switched to Manual after \(reason.label) • \(relative)"
814
- }
815
-
816
- func autoRefreshDisableBadgeText() -> String? {
817
- guard self.settings.refreshFrequency == .manual,
818
- let reason = self.lastAutoRefreshDisableReason else { return nil }
819
- return "Manual because of \(reason.label)"
820
- }
821
-
822
- func enabledProviders() -> [UsageProvider] {
823
- // Use cached enablement to avoid repeated UserDefaults lookups in animation ticks.
824
- let enabled = self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata)
825
- return enabled.filter { self.isProviderAvailable($0) }
826
- }
827
-
828
- var statusChecksEnabled: Bool {
829
- self.settings.statusChecksEnabled
830
- }
831
-
832
- func metadata(for provider: UsageProvider) -> ProviderMetadata {
833
- self.providerMetadata[provider]!
834
- }
835
-
836
- private var codexBrowserCookieOrder: BrowserCookieImportOrder {
837
- self.metadata(for: .codex).browserCookieOrder ?? Browser.defaultImportOrder
838
- }
839
-
840
- func snapshot(for provider: UsageProvider) -> UsageSnapshot? {
841
- self.snapshots[provider]
842
- }
843
-
844
- func sourceLabel(for provider: UsageProvider) -> String {
845
- var label = self.lastSourceLabels[provider] ?? ""
846
- if label.isEmpty {
847
- let descriptor = ProviderDescriptorRegistry.descriptor(for: provider)
848
- let modes = descriptor.fetchPlan.sourceModes
849
- if modes.count == 1, let mode = modes.first {
850
- label = mode.rawValue
851
- } else if provider == .claude {
852
- label = self.settings.claudeUsageDataSource.rawValue
853
- } else {
854
- label = "auto"
855
- }
856
- }
857
-
858
- // When OpenAI web extras are active, show a blended label like `oauth + openai-web`.
859
- if provider == .codex,
860
- self.settings.openAIWebAccessEnabled,
861
- self.openAIDashboard != nil,
862
- !self.openAIDashboardRequiresLogin,
863
- !label.contains("openai-web")
864
- {
865
- return "\(label) + openai-web"
866
- }
867
- return label
868
- }
869
-
870
- func fetchAttempts(for provider: UsageProvider) -> [ProviderFetchAttempt] {
871
- self.lastFetchAttempts[provider] ?? []
872
- }
873
-
874
- func style(for provider: UsageProvider) -> IconStyle {
875
- self.providerSpecs[provider]?.style ?? .codex
876
- }
877
-
878
- func isStale(provider: UsageProvider) -> Bool {
879
- self.errors[provider] != nil
880
- }
881
-
882
- func isEnabled(_ provider: UsageProvider) -> Bool {
883
- let enabled = self.settings.isProviderEnabledCached(
884
- provider: provider,
885
- metadataByProvider: self.providerMetadata)
886
- guard enabled else { return false }
887
- return self.isProviderAvailable(provider)
888
- }
889
-
890
- private func isProviderAvailable(_ provider: UsageProvider) -> Bool {
891
- if provider == .zai {
892
- if ZaiSettingsReader.apiToken(environment: ProcessInfo.processInfo.environment) != nil {
893
- return true
894
- }
895
- return !self.settings.zaiAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
896
- }
897
- return true
898
- }
899
-
900
- func ledgerDailySummary(for provider: UsageProvider) -> UsageLedgerDailySummary? {
901
- self.ledgerDailySummaries[provider]
902
- }
903
-
904
- func ledgerAllDailySummary(for provider: UsageProvider) -> [UsageLedgerDailySummary] {
905
- self.ledgerAllDailySummaries[provider] ?? []
906
- }
907
-
908
- func ledgerHourlySummary(for provider: UsageProvider) -> [UsageLedgerHourlySummary] {
909
- self.ledgerHourlySummaries[provider] ?? []
910
- }
911
-
912
- func ledgerActiveBlock(for provider: UsageProvider) -> UsageLedgerBlockSummary? {
913
- self.ledgerActiveBlocks[provider]
914
- }
915
-
916
- func ledgerTopModel(for provider: UsageProvider) -> UsageLedgerModelSummary? {
917
- self.ledgerTopModels[provider]
918
- }
919
-
920
- func ledgerTopProject(for provider: UsageProvider) -> UsageLedgerProjectSummary? {
921
- self.ledgerTopProjects[provider]
922
- }
923
-
924
- func ledgerModelBreakdown(for provider: UsageProvider) -> [UsageLedgerModelSummary] {
925
- self.ledgerModelBreakdowns[provider] ?? []
926
- }
927
-
928
- func ledgerProjectBreakdown(for provider: UsageProvider) -> [UsageLedgerProjectSummary] {
929
- self.ledgerProjectBreakdowns[provider] ?? []
930
- }
931
-
932
- func ledgerSpendForecast(for provider: UsageProvider) -> UsageLedgerSpendForecast? {
933
- self.ledgerSpendForecasts[provider]
934
- }
935
-
936
- func ledgerProjectSpendForecasts(for provider: UsageProvider) -> [UsageLedgerSpendForecast] {
937
- self.ledgerProjectSpendForecasts[provider] ?? []
938
- }
939
-
940
- func ledgerTopProjectSpendForecast(for provider: UsageProvider) -> UsageLedgerSpendForecast? {
941
- self.ledgerTopProjectSpendForecasts[provider]
942
- }
943
-
944
- func ledgerAnomalySummary(for provider: UsageProvider) -> UsageLedgerAnomalySummary? {
945
- self.ledgerAnomalies[provider]
946
- }
947
-
948
-
949
- func ledgerError(for provider: UsageProvider) -> String? {
950
- self.ledgerErrors[provider]
951
- }
952
-
953
- func ledgerUpdatedAt(for provider: UsageProvider) -> Date? {
954
- self.ledgerUpdatedAt[provider]
955
- }
956
-
957
- func ledgerReliabilityScore(for provider: UsageProvider) -> UsageLedgerReliabilityScore? {
958
- UsageLedgerInsightsAdvisor.reliabilityScore(
959
- provider: provider,
960
- daily: self.ledgerDailySummary(for: provider),
961
- activeBlock: self.ledgerActiveBlock(for: provider),
962
- modelBreakdown: self.ledgerModelBreakdown(for: provider),
963
- projectBreakdown: self.ledgerProjectBreakdown(for: provider),
964
- providerError: self.error(for: provider),
965
- ledgerError: self.ledgerError(for: provider))
966
- }
967
-
968
- func ledgerRoutingRecommendation(for provider: UsageProvider) -> UsageLedgerRoutingRecommendation? {
969
- UsageLedgerInsightsAdvisor.routingRecommendation(
970
- modelBreakdown: self.ledgerModelBreakdown(for: provider))
971
- }
972
-
973
- func providerHistoryMonth(
974
- provider: UsageProvider,
975
- monthStart: Date,
976
- forceRefresh: Bool = false) async -> ProviderHistoryMonthSnapshot
977
- {
978
- var calendar = Calendar(identifier: .gregorian)
979
- calendar.timeZone = TimeZone.current
980
- let normalizedMonthStart = self.normalizedMonthStart(monthStart, calendar: calendar)
981
- let now = Date()
982
- let cacheKey = self.historyMonthCacheKey(for: normalizedMonthStart)
983
-
984
- if !forceRefresh,
985
- let cached = self.providerHistoryMonthCache[provider]?[cacheKey],
986
- now.timeIntervalSince(cached.fetchedAt) <= self.providerHistoryCacheTTL
987
- {
988
- return cached.snapshot
989
- }
990
-
991
- let scanDays = self.providerHistoryScanDays(
992
- monthStart: normalizedMonthStart,
993
- now: now,
994
- calendar: calendar)
995
-
996
- guard let source = self.providerHistorySource(
997
- provider: provider,
998
- now: now,
999
- maxAgeDays: scanDays)
1000
- else {
1001
- let unsupported = Self.unsupportedHistorySnapshot(
1002
- provider: provider,
1003
- monthStart: normalizedMonthStart,
1004
- generatedAt: now)
1005
- self.providerHistoryMonthCache[provider, default: [:]][cacheKey] = ProviderHistoryMonthCacheEntry(
1006
- fetchedAt: now,
1007
- snapshot: unsupported)
1008
- return unsupported
1009
- }
1010
-
1011
- let note = self.providerHistoryNote(scanDays: scanDays)
1012
-
1013
- let snapshot = await Task.detached(priority: .utility) {
1014
- let timeZone = TimeZone.current
1015
- var calendar = Calendar(identifier: .gregorian)
1016
- calendar.timeZone = timeZone
1017
-
1018
- do {
1019
- let loaded = try await source.loadEntries()
1020
- let providerEntries = loaded.filter { $0.provider == provider }
1021
- let monthEntries = providerEntries.filter {
1022
- calendar.isDate($0.timestamp, equalTo: normalizedMonthStart, toGranularity: .month)
1023
- }
1024
- let days = Self.providerHistoryDays(entries: monthEntries, timeZone: timeZone)
1025
- return ProviderHistoryMonthSnapshot(
1026
- provider: provider,
1027
- monthStart: normalizedMonthStart,
1028
- generatedAt: now,
1029
- days: days,
1030
- isSupported: true,
1031
- note: note,
1032
- error: nil)
1033
- } catch {
1034
- return ProviderHistoryMonthSnapshot(
1035
- provider: provider,
1036
- monthStart: normalizedMonthStart,
1037
- generatedAt: now,
1038
- days: [],
1039
- isSupported: true,
1040
- note: note,
1041
- error: error.localizedDescription)
1042
- }
1043
- }.value
1044
-
1045
- self.providerHistoryMonthCache[provider, default: [:]][cacheKey] = ProviderHistoryMonthCacheEntry(
1046
- fetchedAt: now,
1047
- snapshot: snapshot)
1048
- return snapshot
1049
- }
1050
-
1051
- private func normalizedMonthStart(_ date: Date, calendar: Calendar) -> Date {
1052
- let components = calendar.dateComponents([.year, .month], from: date)
1053
- return calendar.date(from: components) ?? calendar.startOfDay(for: date)
1054
- }
1055
-
1056
- private func historyMonthCacheKey(for monthStart: Date) -> String {
1057
- var calendar = Calendar(identifier: .gregorian)
1058
- calendar.timeZone = TimeZone.current
1059
- let components = calendar.dateComponents([.year, .month], from: monthStart)
1060
- return String(format: "%04d-%02d", components.year ?? 1970, components.month ?? 1)
1061
- }
1062
-
1063
- private func providerHistoryScanDays(monthStart: Date, now: Date, calendar: Calendar) -> Int {
1064
- let start = calendar.startOfDay(for: monthStart)
1065
- let end = calendar.startOfDay(for: now)
1066
- let daysBetween = max(0, calendar.dateComponents([.day], from: start, to: end).day ?? 0)
1067
- let target = max(35, daysBetween + 7)
1068
- return min(self.providerHistoryMaxScanDays, target)
1069
- }
1070
-
1071
- private func providerHistoryNote(scanDays: Int) -> String? {
1072
- guard scanDays >= self.providerHistoryMaxScanDays else { return nil }
1073
- return "History scans up to the most recent \(scanDays) days for performance."
1074
- }
1075
-
1076
- private func providerHistorySource(
1077
- provider: UsageProvider,
1078
- now: Date,
1079
- maxAgeDays: Int) -> (any UsageLedgerSource)?
1080
- {
1081
- switch provider {
1082
- case .claude:
1083
- return ClaudeUsageLogSource(maxAgeDays: maxAgeDays, now: now)
1084
- case .codex:
1085
- return CodexUsageLogSource(maxAgeDays: maxAgeDays, now: now)
1086
- case .copilot,
1087
- .gemini,
1088
- .antigravity,
1089
- .cursor,
1090
- .factory,
1091
- .zai,
1092
- .minimax,
1093
- .openrouter,
1094
- .groq,
1095
- .deepseek,
1096
- .fireworks,
1097
- .mistral,
1098
- .perplexity,
1099
- .kimi,
1100
- .auggie,
1101
- .together,
1102
- .cohere,
1103
- .xai,
1104
- .cerebras,
1105
- .sambanova,
1106
- .azure,
1107
- .bedrock:
1108
- return self.providerOTelHistorySource(provider: provider, now: now, maxAgeDays: maxAgeDays)
1109
- @unknown default:
1110
- return nil
1111
- }
1112
- }
1113
-
1114
- private func providerOTelHistorySource(
1115
- provider: UsageProvider,
1116
- now: Date,
1117
- maxAgeDays: Int) -> (any UsageLedgerSource)?
1118
- {
1119
- let files = self.otelLedgerFiles(for: provider)
1120
- guard !files.isEmpty else { return nil }
1121
-
1122
- let cutoff = Calendar.current.date(byAdding: .day, value: -max(1, maxAgeDays), to: now)
1123
- let options = OTelGenAIIngestionOptions(
1124
- enabled: true,
1125
- allowExperimentalSemanticConventions: true,
1126
- defaultProvider: provider,
1127
- source: .api)
1128
- return UsageLedgerProviderFilterSource(
1129
- source: OTelGenAIFileLedgerSource(files: files, options: options),
1130
- provider: provider,
1131
- minTimestamp: cutoff)
1132
- }
1133
-
1134
- private struct UsageLedgerProviderFilterSource: UsageLedgerSource, Sendable {
1135
- private let source: any UsageLedgerSource
1136
- private let provider: UsageProvider
1137
- private let minTimestamp: Date?
1138
-
1139
- init(source: any UsageLedgerSource, provider: UsageProvider, minTimestamp: Date?) {
1140
- self.source = source
1141
- self.provider = provider
1142
- self.minTimestamp = minTimestamp
1143
- }
1144
-
1145
- func loadEntries() async throws -> [UsageLedgerEntry] {
1146
- let entries = try await self.source.loadEntries()
1147
- return entries.filter { entry in
1148
- if entry.provider != self.provider { return false }
1149
- if let minTimestamp, entry.timestamp < minTimestamp { return false }
1150
- return true
1151
- }
1152
- }
1153
- }
1154
-
1155
- private func otelLedgerFiles(for provider: UsageProvider) -> [URL] {
1156
- let env = ProcessInfo.processInfo.environment
1157
- let providerKey = provider.rawValue
1158
- .replacingOccurrences(of: "-", with: "_")
1159
- .uppercased()
1160
-
1161
- let candidatePaths = [
1162
- Self.splitPathList(env["RUNIC_OTEL_GENAI_LOG_PATHS"]),
1163
- Self.splitPathList(env["RUNIC_OTEL_GENAI_LOG_PATH"]),
1164
- Self.splitPathList(env["RUNIC_\(providerKey)_OTEL_GENAI_LOG_PATHS"]),
1165
- Self.splitPathList(env["RUNIC_\(providerKey)_OTEL_GENAI_LOG_PATH"]),
1166
- Self.splitPathList(env["RUNIC_\(providerKey)_OTEL_LOG_PATHS"]),
1167
- Self.splitPathList(env["RUNIC_\(providerKey)_OTEL_LOG_PATH"]),
1168
- ].flatMap { $0 }
1169
-
1170
- let urls = candidatePaths.compactMap(Self.expandTildePath)
1171
- return Self.discoverOTelLedgerFiles(from: urls)
1172
- }
1173
-
1174
- private static func splitPathList(_ raw: String?) -> [String] {
1175
- guard let raw else { return [] }
1176
- return raw
1177
- .split(whereSeparator: { $0 == "," || $0 == ";" || $0 == "\n" })
1178
- .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
1179
- .filter { !$0.isEmpty }
1180
- }
1181
-
1182
- private static func expandTildePath(_ rawPath: String) -> URL? {
1183
- let fileManager = FileManager.default
1184
- if rawPath.hasPrefix("~/") {
1185
- return fileManager.homeDirectoryForCurrentUser
1186
- .appendingPathComponent(String(rawPath.dropFirst(2)), isDirectory: true)
1187
- }
1188
- return URL(fileURLWithPath: rawPath, isDirectory: true)
1189
- }
1190
-
1191
- private static func discoverOTelLedgerFiles(from paths: [URL]) -> [URL] {
1192
- var found: [URL] = []
1193
- var seen: Set<String> = []
1194
-
1195
- for path in paths {
1196
- if Self.isSupportedOTelFile(path) {
1197
- if seen.insert(path.standardizedFileURL.path).inserted {
1198
- found.append(path.standardizedFileURL)
1199
- }
1200
- continue
1201
- }
1202
-
1203
- for file in Self.scanOTelDirectory(path) where seen.insert(file.standardizedFileURL.path).inserted {
1204
- found.append(file.standardizedFileURL)
1205
- }
1206
- }
1207
-
1208
- return found
1209
- }
1210
-
1211
- private static func isSupportedOTelFile(_ url: URL) -> Bool {
1212
- var isDirectory: ObjCBool = false
1213
- let exists = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory)
1214
- guard exists && !isDirectory.boolValue else { return false }
1215
-
1216
- let ext = url.pathExtension.lowercased()
1217
- return ext == "json" || ext == "jsonl"
1218
- }
1219
-
1220
- private static func scanOTelDirectory(_ directory: URL) -> [URL] {
1221
- var isDirectory: ObjCBool = false
1222
- guard FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory), isDirectory.boolValue
1223
- else { return [] }
1224
-
1225
- guard let enumerator = FileManager.default.enumerator(
1226
- at: directory,
1227
- includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey],
1228
- options: [.skipsHiddenFiles])
1229
- else { return [] }
1230
-
1231
- var files: [URL] = []
1232
- for case let file as URL in enumerator where Self.isSupportedOTelFile(file) {
1233
- files.append(file)
1234
- }
1235
- return files
1236
- }
1237
-
1238
- private static func unsupportedHistorySnapshot(
1239
- provider: UsageProvider,
1240
- monthStart: Date,
1241
- generatedAt: Date) -> ProviderHistoryMonthSnapshot
1242
- {
1243
- ProviderHistoryMonthSnapshot(
1244
- provider: provider,
1245
- monthStart: monthStart,
1246
- generatedAt: generatedAt,
1247
- days: [],
1248
- isSupported: false,
1249
- note: "History is currently available for Claude/Codex local ledgers and configured OTel sources.",
1250
- error: nil)
1251
- }
1252
-
1253
- nonisolated private static func providerHistoryDays(
1254
- entries: [UsageLedgerEntry],
1255
- timeZone: TimeZone) -> [ProviderHistoryDaySnapshot]
1256
- {
1257
- guard !entries.isEmpty else { return [] }
1258
-
1259
- var calendar = Calendar(identifier: .gregorian)
1260
- calendar.timeZone = timeZone
1261
-
1262
- let dailySummaries = UsageLedgerAggregator.dailySummaries(
1263
- entries: entries,
1264
- timeZone: timeZone,
1265
- groupByProject: false)
1266
- .sorted { $0.dayStart < $1.dayStart }
1267
-
1268
- var entriesByDay: [Date: [UsageLedgerEntry]] = [:]
1269
- for entry in entries {
1270
- let dayStart = calendar.startOfDay(for: entry.timestamp)
1271
- entriesByDay[dayStart, default: []].append(entry)
1272
- }
1273
-
1274
- return dailySummaries.map { summary in
1275
- let dayEntries = entriesByDay[summary.dayStart] ?? []
1276
- let modelSummaries = UsageLedgerAggregator.modelSummaries(entries: dayEntries)
1277
- let projectSummaries = UsageLedgerAggregator.projectSummaries(entries: dayEntries)
1278
- let topModel = modelSummaries.first
1279
- let topProject = projectSummaries.first
1280
- return ProviderHistoryDaySnapshot(
1281
- dayStart: summary.dayStart,
1282
- totals: summary.totals,
1283
- requestCount: dayEntries.count,
1284
- modelsUsed: summary.modelsUsed,
1285
- topModel: topModel,
1286
- topProject: topProject,
1287
- modelSummaries: modelSummaries,
1288
- projectSummaries: projectSummaries)
1289
- }
1290
- }
1291
-
1292
- /// Refresh a single provider (used when user clicks Ping on a specific provider tab).
1293
- func refreshSingleProvider(_ provider: UsageProvider) async {
1294
- self.isRefreshing = true
1295
- defer { self.isRefreshing = false }
1296
- await self.refreshProvider(provider, trigger: .manual)
1297
- await self.refreshStatus(provider, trigger: .manual)
1298
- self.scheduleLedgerRefresh(force: true, inactiveProviders: [])
1299
- }
1300
-
1301
- func refresh(trigger: RefreshTrigger = .manual, forceTokenUsage: Bool = false) async {
1302
- guard !self.isRefreshing else { return }
1303
- let now = Date()
1304
- if trigger.isAuto, !self.shouldRunAutoRefresh(trigger: trigger, now: now) {
1305
- return
1306
- }
1307
- if trigger.isAuto {
1308
- self.recordAutoRefreshRunIfNeeded(trigger: trigger)
1309
- }
1310
-
1311
- self.lastRefreshTrigger = trigger
1312
- self.lastRefreshAt = now
1313
- self.isRefreshing = true
1314
- defer { self.isRefreshing = false }
1315
-
1316
- let inactiveProviders = trigger.isAuto ? self.inactiveProviders(now: now) : []
1317
- let skipCodexExtras = trigger.isAuto && inactiveProviders.contains(.codex)
1318
-
1319
- await withTaskGroup(of: Void.self) { group in
1320
- for provider in UsageProvider.allCases {
1321
- if inactiveProviders.contains(provider) { continue }
1322
- group.addTask { await self.refreshProvider(provider, trigger: trigger) }
1323
- group.addTask { await self.refreshStatus(provider, trigger: trigger) }
1324
- }
1325
- if !skipCodexExtras {
1326
- group.addTask { await self.refreshCreditsIfNeeded() }
1327
- }
1328
- }
1329
-
1330
- // Token-cost usage can be slow; run it outside the refresh group so we don't block menu updates.
1331
- self.scheduleTokenRefresh(force: forceTokenUsage, trigger: trigger, inactiveProviders: inactiveProviders)
1332
-
1333
- // OpenAI web scrape depends on the current Codex account email (which can change after login/account switch).
1334
- // Run this after Codex usage refresh so we don't accidentally scrape with stale credentials.
1335
- if !skipCodexExtras {
1336
- await self.refreshOpenAIDashboardIfNeeded(force: forceTokenUsage)
1337
- }
1338
-
1339
- if self.openAIDashboardRequiresLogin, !skipCodexExtras {
1340
- await self.refreshProvider(.codex, trigger: trigger)
1341
- await self.refreshCreditsIfNeeded()
1342
- }
1343
-
1344
- self.scheduleLedgerRefresh(force: true, inactiveProviders: inactiveProviders)
1345
-
1346
- // Refresh custom providers
1347
- await self.refreshCustomProviders()
1348
-
1349
- self.persistWidgetSnapshot(reason: "refresh")
1350
- }
1351
-
1352
- /// Refresh all enabled custom providers
1353
- func refreshCustomProviders() async {
1354
- let providers = CustomProviderStore.getEnabledProviders()
1355
- await withTaskGroup(of: Void.self) { group in
1356
- for provider in providers {
1357
- group.addTask {
1358
- await self.refreshCustomProvider(id: provider.id)
1359
- }
1360
- }
1361
- }
1362
- }
1363
-
1364
- /// Refresh a single custom provider
1365
- func refreshCustomProvider(id: String) async {
1366
- guard let config = CustomProviderStore.getProvider(id: id), config.enabled else {
1367
- return
1368
- }
1369
-
1370
- let fetcher = GenericProviderFetcher(config: config)
1371
- let startTime = Date()
1372
- let requestID = UUID().uuidString
1373
-
1374
- do {
1375
- let usageData = try await fetcher.fetchUsage()
1376
- let endTime = Date()
1377
-
1378
- // Track successful latency
1379
- await self.trackLatency(
1380
- provider: .codex, // TODO: Add custom provider tracking
1381
- requestID: requestID,
1382
- startTime: startTime,
1383
- endTime: endTime,
1384
- success: true
1385
- )
1386
-
1387
- let snapshot = CustomProviderSnapshot.from(usageData: usageData.toCustomUsageData(), config: config)
1388
- await MainActor.run {
1389
- self.customProviderSnapshots[id] = snapshot
1390
- self.customProviderErrors.removeValue(forKey: id)
1391
- }
1392
- } catch {
1393
- let endTime = Date()
1394
-
1395
- // Track failed latency and error
1396
- await self.trackLatency(
1397
- provider: .codex, // TODO: Add custom provider tracking
1398
- requestID: requestID,
1399
- startTime: startTime,
1400
- endTime: endTime,
1401
- success: false
1402
- )
1403
- await self.trackError(provider: .codex, error: error) // TODO: Add custom provider tracking
1404
-
1405
- await MainActor.run {
1406
- self.customProviderErrors[id] = error.localizedDescription
1407
- }
1408
- }
1409
- }
1410
-
1411
- /// Clear a custom provider snapshot
1412
- func clearCustomProviderSnapshot(id: String) {
1413
- self.customProviderSnapshots.removeValue(forKey: id)
1414
- self.customProviderErrors.removeValue(forKey: id)
1415
- }
1416
-
1417
- /// For demo/testing: drop the snapshot so the loading animation plays, then restore the last snapshot.
1418
- func replayLoadingAnimation(duration: TimeInterval = 3) {
1419
- let current = self.preferredSnapshot
1420
- self.snapshots.removeAll()
1421
- self.debugForceAnimation = true
1422
- Task { @MainActor in
1423
- try? await Task.sleep(for: .seconds(duration))
1424
- if let current, let provider = self.enabledProviders().first {
1425
- self.snapshots[provider] = current
1426
- }
1427
- self.debugForceAnimation = false
1428
- }
1429
- }
1430
-
1431
- // MARK: - Private
1432
-
1433
- private func bindSettings() {
1434
- self.observeSettingsChanges()
1435
- }
1436
-
1437
- private func handleSettingsChange() async {
1438
- let refreshChanged = self.settings.refreshFrequency != self.lastRefreshFrequency
1439
- let warningEnabledChanged = self.settings.autoRefreshWarningEnabled != self.lastAutoRefreshWarningEnabled
1440
- let warningThresholdChanged = self.settings.autoRefreshWarningThreshold
1441
- != self.lastAutoRefreshWarningThreshold
1442
- if refreshChanged || warningEnabledChanged || warningThresholdChanged {
1443
- self.resetAutoRefreshWarningState()
1444
- self.lastRefreshFrequency = self.settings.refreshFrequency
1445
- self.lastAutoRefreshWarningEnabled = self.settings.autoRefreshWarningEnabled
1446
- self.lastAutoRefreshWarningThreshold = self.settings.autoRefreshWarningThreshold
1447
- }
1448
- if refreshChanged, !self.suppressNextSettingsRefresh {
1449
- self.clearAutoRefreshDisableReason()
1450
- }
1451
- self.startTimer()
1452
- self.startTokenTimer()
1453
- guard !self.suppressNextSettingsRefresh else {
1454
- self.suppressNextSettingsRefresh = false
1455
- return
1456
- }
1457
- await self.refresh(trigger: .settingsChange)
1458
- }
1459
-
1460
- private func startTimer() {
1461
- self.timerTask?.cancel()
1462
- guard let wait = self.settings.refreshFrequency.seconds else { return }
1463
-
1464
- // Background poller so the menu stays responsive; canceled when settings change or store deallocates.
1465
- self.timerTask = Task.detached(priority: .utility) { [weak self] in
1466
- while !Task.isCancelled {
1467
- try? await Task.sleep(for: .seconds(wait))
1468
- await self?.refresh(trigger: .autoTimer)
1469
- }
1470
- }
1471
- }
1472
-
1473
- private func startTokenTimer() {
1474
- self.tokenTimerTask?.cancel()
1475
- guard self.settings.refreshFrequency != .manual else { return }
1476
- guard self.settings.costUsageEnabled else { return }
1477
- let wait = self.tokenFetchTTL
1478
- self.tokenTimerTask = Task.detached(priority: .utility) { [weak self] in
1479
- while !Task.isCancelled {
1480
- try? await Task.sleep(for: .seconds(wait))
1481
- let inactive = await self?.inactiveProviders(now: Date()) ?? []
1482
- await self?.scheduleTokenRefresh(
1483
- force: false,
1484
- trigger: .autoTimer,
1485
- inactiveProviders: inactive)
1486
- }
1487
- }
1488
- }
1489
-
1490
- private func shouldRunAutoRefresh(trigger: RefreshTrigger, now: Date) -> Bool {
1491
- guard trigger.isAuto else { return true }
1492
- guard self.settings.refreshFrequency != .manual else { return false }
1493
- if self.autoRefreshSuspensionReason != nil { return false }
1494
-
1495
- if self.settings.autoDisableRefreshWhenIdleEnabled,
1496
- let idleSeconds = self.currentIdleSeconds()
1497
- {
1498
- let thresholdMinutes = max(1, self.settings.autoDisableRefreshWhenIdleMinutes)
1499
- let thresholdSeconds = TimeInterval(thresholdMinutes) * 60
1500
- if idleSeconds >= thresholdSeconds {
1501
- self.disableAutoRefreshForIdle(thresholdMinutes: thresholdMinutes)
1502
- return false
1503
- }
1504
- }
1505
- return true
1506
- }
1507
-
1508
- private func disableAutoRefreshForIdle(thresholdMinutes: Int) {
1509
- self.disableAutoRefresh(
1510
- reason: .idle,
1511
- idPrefix: "auto-refresh-idle",
1512
- body: "Runic switched auto-refresh to Manual after \(thresholdMinutes) minutes of inactivity. "
1513
- + "Use Refresh in the menu when you want new data.")
1514
- }
1515
-
1516
- private func disableAutoRefresh(reason: AutoRefreshDisableReason, idPrefix: String, body: String) {
1517
- guard self.settings.refreshFrequency != .manual else { return }
1518
- self.suppressNextSettingsRefresh = true
1519
- self.settings.refreshFrequency = .manual
1520
- self.autoRefreshSuspensionReason = nil
1521
- self.lastAutoRefreshDisableReason = reason
1522
- self.lastAutoRefreshDisableAt = Date()
1523
- AppNotifications.shared.post(
1524
- idPrefix: idPrefix,
1525
- title: "Auto-refresh switched to Manual",
1526
- body: body)
1527
- }
1528
-
1529
- private func clearAutoRefreshDisableReason() {
1530
- self.lastAutoRefreshDisableReason = nil
1531
- self.lastAutoRefreshDisableAt = nil
1532
- }
1533
-
1534
- private func systemPauseDetailText(_ reason: AutoRefreshSuspensionReason) -> String {
1535
- switch reason {
1536
- case .systemSleep:
1537
- return "your Mac went to sleep"
1538
- case .screenSleep:
1539
- return "the display went to sleep"
1540
- case .sessionInactive:
1541
- return "your session became inactive"
1542
- }
1543
- }
1544
-
1545
- private func disableReason(for reason: AutoRefreshSuspensionReason) -> AutoRefreshDisableReason {
1546
- switch reason {
1547
- case .systemSleep: .systemSleep
1548
- case .screenSleep: .screenSleep
1549
- case .sessionInactive: .sessionInactive
1550
- }
1551
- }
1552
-
1553
- private func currentIdleSeconds() -> TimeInterval? {
1554
- #if os(macOS)
1555
- guard let anyInputType = CGEventType(rawValue: UInt32.max) else { return nil }
1556
- let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyInputType)
1557
- return seconds >= 0 ? seconds : nil
1558
- #else
1559
- return nil
1560
- #endif
1561
- }
1562
-
1563
- private func recordAutoRefreshRunIfNeeded(trigger: RefreshTrigger) {
1564
- guard trigger == .autoTimer else { return }
1565
- guard self.settings.autoRefreshWarningEnabled else { return }
1566
- guard !self.autoRefreshWarningSent else { return }
1567
- let threshold = max(1, self.settings.autoRefreshWarningThreshold)
1568
- self.autoRefreshRunCount += 1
1569
- guard self.autoRefreshRunCount >= threshold else { return }
1570
- self.autoRefreshWarningSent = true
1571
- AppNotifications.shared.post(
1572
- idPrefix: "auto-refresh-warning",
1573
- title: "Auto-refresh is enabled",
1574
- body: "Runic has auto-refreshed \(threshold) times. Auto-refresh can touch CLIs, "
1575
- + "logs, or browser cookies depending on provider settings. Switch to Manual if you prefer "
1576
- + "click-only refresh.")
1577
- }
1578
-
1579
- private func resetAutoRefreshWarningState() {
1580
- self.autoRefreshRunCount = 0
1581
- self.autoRefreshWarningSent = false
1582
- }
1583
-
1584
- private func inactiveProviders(now: Date) -> Set<UsageProvider> {
1585
- guard self.settings.autoSuspendInactiveProvidersEnabled else { return [] }
1586
- let thresholdMinutes = max(1, self.settings.autoSuspendInactiveProvidersMinutes)
1587
- let thresholdSeconds = TimeInterval(thresholdMinutes) * 60
1588
-
1589
- var inactive: Set<UsageProvider> = []
1590
- for provider in UsageProvider.allCases {
1591
- guard self.isEnabled(provider) else { continue }
1592
- guard let lastActivity = self.lastActivityAt(for: provider) else { continue }
1593
- if now.timeIntervalSince(lastActivity) >= thresholdSeconds {
1594
- inactive.insert(provider)
1595
- }
1596
- }
1597
- return inactive
1598
- }
1599
-
1600
- private func lastActivityAt(for provider: UsageProvider) -> Date? {
1601
- let ledger = self.lastLedgerActivityAt[provider]
1602
- let delta = self.lastUsageDeltaAt[provider]
1603
- switch (ledger, delta) {
1604
- case let (l?, d?):
1605
- return max(l, d)
1606
- case let (l?, nil):
1607
- return l
1608
- case let (nil, d?):
1609
- return d
1610
- case (nil, nil):
1611
- return nil
1612
- }
1613
- }
1614
-
1615
- private func recordUsageActivity(
1616
- provider: UsageProvider,
1617
- previous: UsageSnapshot?,
1618
- current: UsageSnapshot)
1619
- {
1620
- if previous == nil || self.usageChanged(previous: previous, current: current) {
1621
- self.lastUsageDeltaAt[provider] = current.updatedAt
1622
- }
1623
- }
1624
-
1625
- private func usageChanged(previous: UsageSnapshot?, current: UsageSnapshot) -> Bool {
1626
- guard let previous else { return true }
1627
-
1628
- func windowChanged(_ lhs: RateWindow?, _ rhs: RateWindow?) -> Bool {
1629
- switch (lhs, rhs) {
1630
- case (nil, nil):
1631
- return false
1632
- case let (l?, r?):
1633
- return abs(l.usedPercent - r.usedPercent) >= 0.1
1634
- case (nil, _), (_, nil):
1635
- return true
1636
- }
1637
- }
1638
-
1639
- if windowChanged(previous.primary, current.primary) { return true }
1640
- if windowChanged(previous.secondary, current.secondary) { return true }
1641
- if windowChanged(previous.tertiary, current.tertiary) { return true }
1642
-
1643
- switch (previous.providerCost, current.providerCost) {
1644
- case (nil, nil):
1645
- return false
1646
- case let (lhs?, rhs?):
1647
- if lhs.used != rhs.used { return true }
1648
- if lhs.limit != rhs.limit { return true }
1649
- return false
1650
- case (nil, _), (_, nil):
1651
- return true
1652
- }
1653
- }
1654
-
1655
- private func scheduleLedgerRefresh(
1656
- force: Bool,
1657
- inactiveProviders: Set<UsageProvider>)
1658
- {
1659
- let now = Date()
1660
- let sources = self.ledgerSources(now: now, inactiveProviders: inactiveProviders)
1661
- let providers = sources.map(\.0)
1662
- if providers.isEmpty { return }
1663
- if !force {
1664
- let shouldRefresh = providers.contains { provider in
1665
- guard let last = self.ledgerUpdatedAt[provider] else { return true }
1666
- return now.timeIntervalSince(last) >= self.ledgerRefreshTTL
1667
- }
1668
- if !shouldRefresh { return }
1669
- }
1670
- if self.ledgerRefreshTask != nil { return }
1671
-
1672
- // Immediately populate from cache so the UI has data before the background scan.
1673
- let providersToCache = providers.filter { self.ledgerAllDailySummaries[$0] == nil || self.ledgerAllDailySummaries[$0]?.isEmpty == true }
1674
- if !providersToCache.isEmpty {
1675
- Task {
1676
- let cache = LedgerCache.shared
1677
- for provider in providersToCache {
1678
- let providerKey = provider.rawValue
1679
- guard let cached = await cache.loadCachedDailies(provider: providerKey) else { continue }
1680
- let summaries = cached.dailies.compactMap { $0.toLedgerDailySummary(provider: provider) }
1681
- if !summaries.isEmpty {
1682
- await MainActor.run { [weak self] in
1683
- guard let self else { return }
1684
- if self.ledgerAllDailySummaries[provider] == nil || self.ledgerAllDailySummaries[provider]?.isEmpty == true {
1685
- self.ledgerAllDailySummaries[provider] = summaries
1686
- // Also populate today's summary from cache.
1687
- let todayStart = Calendar.current.startOfDay(for: now)
1688
- if self.ledgerDailySummaries[provider] == nil,
1689
- let todaySummary = summaries.first(where: { $0.dayStart == todayStart })
1690
- {
1691
- self.ledgerDailySummaries[provider] = todaySummary
1692
- }
1693
- }
1694
- }
1695
- }
1696
- }
1697
- }
1698
- }
1699
-
1700
- self.ledgerRefreshTask = Task.detached(priority: .utility) { [weak self] in
1701
- guard let self else { return }
1702
- let result = await self.loadLedgerInsights(sources: sources, now: now)
1703
- await MainActor.run { [weak self] in
1704
- guard let self else { return }
1705
- self.ledgerRefreshTask = nil
1706
- for provider in result.providers {
1707
- if let error = result.errorsByProvider[provider] {
1708
- self.ledgerErrors[provider] = error
1709
- } else {
1710
- self.ledgerErrors.removeValue(forKey: provider)
1711
- }
1712
-
1713
- if let daily = result.dailyByProvider[provider] {
1714
- self.ledgerDailySummaries[provider] = daily
1715
- } else {
1716
- self.ledgerDailySummaries.removeValue(forKey: provider)
1717
- }
1718
-
1719
- if let allDaily = result.allDailySummariesByProvider[provider], !allDaily.isEmpty {
1720
- self.ledgerAllDailySummaries[provider] = allDaily
1721
- } else {
1722
- self.ledgerAllDailySummaries.removeValue(forKey: provider)
1723
- }
1724
-
1725
- if let hourly = result.hourlySummariesByProvider[provider], !hourly.isEmpty {
1726
- self.ledgerHourlySummaries[provider] = hourly
1727
- } else {
1728
- self.ledgerHourlySummaries.removeValue(forKey: provider)
1729
- }
1730
-
1731
- if let block = result.activeBlocksByProvider[provider] {
1732
- self.ledgerActiveBlocks[provider] = block
1733
- } else {
1734
- self.ledgerActiveBlocks.removeValue(forKey: provider)
1735
- }
1736
-
1737
- if let topModel = result.topModelsByProvider[provider] {
1738
- self.ledgerTopModels[provider] = topModel
1739
- } else {
1740
- self.ledgerTopModels.removeValue(forKey: provider)
1741
- }
1742
-
1743
- if let topProject = result.topProjectsByProvider[provider] {
1744
- self.ledgerTopProjects[provider] = topProject
1745
- } else {
1746
- self.ledgerTopProjects.removeValue(forKey: provider)
1747
- }
1748
-
1749
- if let breakdown = result.modelBreakdownsByProvider[provider] {
1750
- self.ledgerModelBreakdowns[provider] = breakdown
1751
- } else {
1752
- self.ledgerModelBreakdowns.removeValue(forKey: provider)
1753
- }
1754
-
1755
- if let breakdown = result.projectBreakdownsByProvider[provider] {
1756
- self.ledgerProjectBreakdowns[provider] = breakdown
1757
- } else {
1758
- self.ledgerProjectBreakdowns.removeValue(forKey: provider)
1759
- }
1760
-
1761
- if let forecast = result.spendForecastsByProvider[provider] {
1762
- self.ledgerSpendForecasts[provider] = forecast
1763
- } else {
1764
- self.ledgerSpendForecasts.removeValue(forKey: provider)
1765
- }
1766
-
1767
- if let forecasts = result.projectSpendForecastsByProvider[provider] {
1768
- self.ledgerProjectSpendForecasts[provider] = forecasts
1769
- } else {
1770
- self.ledgerProjectSpendForecasts.removeValue(forKey: provider)
1771
- }
1772
-
1773
- if let forecast = result.topProjectSpendForecastsByProvider[provider] {
1774
- self.ledgerTopProjectSpendForecasts[provider] = forecast
1775
- } else {
1776
- self.ledgerTopProjectSpendForecasts.removeValue(forKey: provider)
1777
- }
1778
- if let anomaly = result.anomaliesByProvider[provider] {
1779
- self.ledgerAnomalies[provider] = anomaly
1780
- } else {
1781
- self.ledgerAnomalies.removeValue(forKey: provider)
1782
- }
1783
-
1784
- if let lastActivity = result.lastActivityByProvider[provider] {
1785
- self.lastLedgerActivityAt[provider] = lastActivity
1786
- }
1787
-
1788
- self.ledgerUpdatedAt[provider] = result.updatedAt
1789
- }
1790
-
1791
- // Budget breach notifications
1792
- if self.settings.budgetNotificationsEnabled {
1793
- BudgetNotificationManager.shared.checkAndNotify(
1794
- forecasts: self.ledgerProjectSpendForecasts,
1795
- settings: self.settings)
1796
- }
1797
- }
1798
- }
1799
- }
1800
-
1801
- private func scheduleTokenRefresh(
1802
- force: Bool,
1803
- trigger: RefreshTrigger,
1804
- inactiveProviders: Set<UsageProvider>)
1805
- {
1806
- if trigger.isAuto, !self.shouldRunAutoRefresh(trigger: trigger, now: Date()) {
1807
- return
1808
- }
1809
- if force {
1810
- self.tokenRefreshSequenceTask?.cancel()
1811
- self.tokenRefreshSequenceTask = nil
1812
- } else if self.tokenRefreshSequenceTask != nil {
1813
- return
1814
- }
1815
-
1816
- self.tokenRefreshSequenceTask = Task(priority: .utility) { [weak self] in
1817
- guard let self else { return }
1818
- defer {
1819
- Task { @MainActor [weak self] in
1820
- self?.tokenRefreshSequenceTask = nil
1821
- }
1822
- }
1823
- await withTaskGroup(of: Void.self) { group in
1824
- for provider in UsageProvider.allCases {
1825
- group.addTask {
1826
- guard !Task.isCancelled else { return }
1827
- await self.refreshTokenUsage(
1828
- provider,
1829
- force: force,
1830
- trigger: trigger,
1831
- inactiveProviders: inactiveProviders)
1832
- }
1833
- }
1834
- }
1835
- }
1836
- }
1837
-
1838
- deinit {
1839
- self.timerTask?.cancel()
1840
- self.tokenTimerTask?.cancel()
1841
- self.tokenRefreshSequenceTask?.cancel()
1842
- self.ledgerRefreshTask?.cancel()
1843
- }
1844
-
1845
- private struct LedgerRefreshResult: Sendable {
1846
- let dailyByProvider: [UsageProvider: UsageLedgerDailySummary]
1847
- let allDailySummariesByProvider: [UsageProvider: [UsageLedgerDailySummary]]
1848
- let hourlySummariesByProvider: [UsageProvider: [UsageLedgerHourlySummary]]
1849
- let activeBlocksByProvider: [UsageProvider: UsageLedgerBlockSummary]
1850
- let topModelsByProvider: [UsageProvider: UsageLedgerModelSummary]
1851
- let topProjectsByProvider: [UsageProvider: UsageLedgerProjectSummary]
1852
- let modelBreakdownsByProvider: [UsageProvider: [UsageLedgerModelSummary]]
1853
- let projectBreakdownsByProvider: [UsageProvider: [UsageLedgerProjectSummary]]
1854
- let spendForecastsByProvider: [UsageProvider: UsageLedgerSpendForecast]
1855
- let projectSpendForecastsByProvider: [UsageProvider: [UsageLedgerSpendForecast]]
1856
- let topProjectSpendForecastsByProvider: [UsageProvider: UsageLedgerSpendForecast]
1857
- let anomaliesByProvider: [UsageProvider: UsageLedgerAnomalySummary]
1858
- let errorsByProvider: [UsageProvider: String]
1859
- let lastActivityByProvider: [UsageProvider: Date]
1860
- let updatedAt: Date
1861
- let providers: [UsageProvider]
1862
- }
1863
-
1864
- private struct ProviderHistoryMonthCacheEntry {
1865
- let fetchedAt: Date
1866
- let snapshot: ProviderHistoryMonthSnapshot
1867
- }
1868
-
1869
- private func ledgerSources(
1870
- now: Date,
1871
- inactiveProviders: Set<UsageProvider>) -> [(UsageProvider, any UsageLedgerSource)]
1872
- {
1873
- return UsageProvider.allCases.compactMap { provider in
1874
- guard self.isEnabled(provider), !inactiveProviders.contains(provider) else { return nil }
1875
- guard let source = self.providerHistorySource(
1876
- provider: provider,
1877
- now: now,
1878
- maxAgeDays: self.ledgerMaxAgeDays)
1879
- else { return nil }
1880
- return (provider, source)
1881
- }
1882
- }
1883
-
1884
- private func loadLedgerInsights(
1885
- sources: [(UsageProvider, any UsageLedgerSource)],
1886
- now: Date) async -> LedgerRefreshResult
1887
- {
1888
- guard !sources.isEmpty else {
1889
- return LedgerRefreshResult(
1890
- dailyByProvider: [:],
1891
- allDailySummariesByProvider: [:],
1892
- hourlySummariesByProvider: [:],
1893
- activeBlocksByProvider: [:],
1894
- topModelsByProvider: [:],
1895
- topProjectsByProvider: [:],
1896
- modelBreakdownsByProvider: [:],
1897
- projectBreakdownsByProvider: [:],
1898
- spendForecastsByProvider: [:],
1899
- projectSpendForecastsByProvider: [:],
1900
- topProjectSpendForecastsByProvider: [:],
1901
- anomaliesByProvider: [:],
1902
- errorsByProvider: [:],
1903
- lastActivityByProvider: [:],
1904
- updatedAt: now,
1905
- providers: [])
1906
- }
1907
-
1908
- let providers = sources.map(\.0)
1909
- var entries: [UsageLedgerEntry] = []
1910
- var errors: [UsageProvider: String] = [:]
1911
-
1912
- await withTaskGroup(of: (UsageProvider, Result<[UsageLedgerEntry], Error>).self) { group in
1913
- for (provider, source) in sources {
1914
- group.addTask {
1915
- do {
1916
- let loaded = try await source.loadEntries()
1917
- return (provider, .success(loaded))
1918
- } catch {
1919
- return (provider, .failure(error))
1920
- }
1921
- }
1922
- }
1923
-
1924
- for await (provider, result) in group {
1925
- switch result {
1926
- case let .success(loaded):
1927
- entries.append(contentsOf: loaded)
1928
- case let .failure(error):
1929
- errors[provider] = error.localizedDescription
1930
- }
1931
- }
1932
- }
1933
-
1934
- var lastActivityByProvider: [UsageProvider: Date] = [:]
1935
- for entry in entries {
1936
- if let current = lastActivityByProvider[entry.provider] {
1937
- if entry.timestamp > current { lastActivityByProvider[entry.provider] = entry.timestamp }
1938
- } else {
1939
- lastActivityByProvider[entry.provider] = entry.timestamp
1940
- }
1941
- }
1942
-
1943
- let timeZone = TimeZone.current
1944
- var calendar = Calendar(identifier: .gregorian)
1945
- calendar.timeZone = timeZone
1946
- let todayStart = calendar.startOfDay(for: now)
1947
-
1948
- let dailySummaries = UsageLedgerAggregator.dailySummaries(
1949
- entries: entries,
1950
- timeZone: timeZone,
1951
- groupByProject: false)
1952
- var dailyByProvider: [UsageProvider: UsageLedgerDailySummary] = [:]
1953
- var allDailySummariesByProvider: [UsageProvider: [UsageLedgerDailySummary]] = [:]
1954
- for summary in dailySummaries {
1955
- allDailySummariesByProvider[summary.provider, default: []].append(summary)
1956
- if summary.dayStart == todayStart {
1957
- dailyByProvider[summary.provider] = summary
1958
- }
1959
- }
1960
-
1961
- let hourlySummaries = UsageLedgerAggregator.hourlySummaries(
1962
- entries: entries,
1963
- timeZone: timeZone,
1964
- groupByProject: false)
1965
- var hourlySummariesByProvider: [UsageProvider: [UsageLedgerHourlySummary]] = [:]
1966
- for summary in hourlySummaries {
1967
- hourlySummariesByProvider[summary.provider, default: []].append(summary)
1968
- }
1969
-
1970
- let blocks = UsageLedgerAggregator.blockSummaries(entries: entries, blockHours: 5, now: now)
1971
- var activeByProvider: [UsageProvider: UsageLedgerBlockSummary] = [:]
1972
- for block in blocks where block.isActive {
1973
- if activeByProvider[block.provider] == nil {
1974
- activeByProvider[block.provider] = block
1975
- }
1976
- }
1977
-
1978
- let todayEntries = entries.filter { calendar.isDate($0.timestamp, inSameDayAs: now) }
1979
- let budgetProjectNames = self.projectNameOverridesFromBudgets()
1980
- let modelSummaries = UsageLedgerAggregator.modelSummaries(entries: todayEntries)
1981
- var topModelsByProvider: [UsageProvider: UsageLedgerModelSummary] = [:]
1982
- for summary in modelSummaries {
1983
- if topModelsByProvider[summary.provider] == nil {
1984
- topModelsByProvider[summary.provider] = summary
1985
- }
1986
- }
1987
-
1988
- let projectSummaries = UsageLedgerAggregator.projectSummaries(entries: todayEntries)
1989
- .map { self.resolvedProjectSummary($0, budgetProjectNames: budgetProjectNames) }
1990
- var topProjectsByProvider: [UsageProvider: UsageLedgerProjectSummary] = [:]
1991
- for summary in projectSummaries {
1992
- if topProjectsByProvider[summary.provider] == nil {
1993
- topProjectsByProvider[summary.provider] = summary
1994
- }
1995
- }
1996
-
1997
- let modelBreakdowns = UsageLedgerAggregator.modelSummaries(entries: todayEntries, groupByProject: true)
1998
- .map { self.resolvedModelSummary($0, budgetProjectNames: budgetProjectNames) }
1999
- var modelBreakdownsByProvider: [UsageProvider: [UsageLedgerModelSummary]] = [:]
2000
- for summary in modelBreakdowns {
2001
- modelBreakdownsByProvider[summary.provider, default: []].append(summary)
2002
- }
2003
-
2004
- var projectBreakdownsByProvider: [UsageProvider: [UsageLedgerProjectSummary]] = [:]
2005
- for summary in projectSummaries {
2006
- projectBreakdownsByProvider[summary.provider, default: []].append(summary)
2007
- }
2008
-
2009
- let providerForecasts = UsageLedgerAggregator.providerSpendForecasts(
2010
- entries: entries,
2011
- now: now,
2012
- timeZone: timeZone)
2013
- var spendForecastsByProvider: [UsageProvider: UsageLedgerSpendForecast] = [:]
2014
- for forecast in providerForecasts where spendForecastsByProvider[forecast.provider] == nil {
2015
- spendForecastsByProvider[forecast.provider] = forecast
2016
- }
2017
-
2018
- let budgetLimitsByProjectID = self.activeBudgetLimitsByProjectID()
2019
- let projectForecasts = UsageLedgerAggregator.projectSpendForecasts(
2020
- entries: entries,
2021
- now: now,
2022
- timeZone: timeZone)
2023
- .map {
2024
- self.resolvedSpendForecast(
2025
- $0,
2026
- budgetProjectNames: budgetProjectNames,
2027
- budgetLimitsByProjectID: budgetLimitsByProjectID)
2028
- }
2029
- var projectSpendForecastsByProvider: [UsageProvider: [UsageLedgerSpendForecast]] = [:]
2030
- for forecast in projectForecasts {
2031
- projectSpendForecastsByProvider[forecast.provider, default: []].append(forecast)
2032
- }
2033
-
2034
- var topProjectSpendForecastsByProvider: [UsageProvider: UsageLedgerSpendForecast] = [:]
2035
- for (provider, summary) in topProjectsByProvider {
2036
- guard let forecasts = projectSpendForecastsByProvider[provider] else { continue }
2037
- if let matched = self.matchingProjectForecast(for: summary, forecasts: forecasts) {
2038
- topProjectSpendForecastsByProvider[provider] = matched
2039
- }
2040
- }
2041
- let anomaliesByProvider = UsageLedgerAnomalyDetector.summaries(
2042
- dailySummaries: dailySummaries,
2043
- now: now,
2044
- calendar: calendar)
2045
-
2046
- return LedgerRefreshResult(
2047
- dailyByProvider: dailyByProvider,
2048
- allDailySummariesByProvider: allDailySummariesByProvider,
2049
- hourlySummariesByProvider: hourlySummariesByProvider,
2050
- activeBlocksByProvider: activeByProvider,
2051
- topModelsByProvider: topModelsByProvider,
2052
- topProjectsByProvider: topProjectsByProvider,
2053
- modelBreakdownsByProvider: modelBreakdownsByProvider,
2054
- projectBreakdownsByProvider: projectBreakdownsByProvider,
2055
- spendForecastsByProvider: spendForecastsByProvider,
2056
- projectSpendForecastsByProvider: projectSpendForecastsByProvider,
2057
- topProjectSpendForecastsByProvider: topProjectSpendForecastsByProvider,
2058
- anomaliesByProvider: anomaliesByProvider,
2059
- errorsByProvider: errors,
2060
- lastActivityByProvider: lastActivityByProvider,
2061
- updatedAt: now,
2062
- providers: providers)
2063
- }
2064
-
2065
- private func projectNameOverridesFromBudgets() -> [String: String] {
2066
- var namesByProjectID: [String: String] = [:]
2067
- for budget in ProjectBudgetStore.getAllBudgets() {
2068
- let trimmed = budget.projectName?.trimmingCharacters(in: .whitespacesAndNewlines)
2069
- if let trimmed, !trimmed.isEmpty {
2070
- namesByProjectID[budget.projectID] = trimmed
2071
- }
2072
- }
2073
- return namesByProjectID
2074
- }
2075
-
2076
- private func activeBudgetLimitsByProjectID() -> [String: Double] {
2077
- var limitsByProjectID: [String: Double] = [:]
2078
- for budget in ProjectBudgetStore.getAllBudgets() where budget.enabled && budget.monthlyLimit > 0 {
2079
- limitsByProjectID[budget.projectID] = budget.monthlyLimit
2080
- }
2081
- return limitsByProjectID
2082
- }
2083
-
2084
- private func resolvedSpendForecast(
2085
- _ forecast: UsageLedgerSpendForecast,
2086
- budgetProjectNames: [String: String],
2087
- budgetLimitsByProjectID: [String: Double]) -> UsageLedgerSpendForecast
2088
- {
2089
- let budgetName = forecast.projectID.flatMap { budgetProjectNames[$0] }
2090
- let identity = UsageLedgerProjectIdentityResolver.resolve(
2091
- provider: forecast.provider,
2092
- projectID: forecast.projectID,
2093
- projectName: forecast.projectName,
2094
- budgetNameOverride: budgetName)
2095
- let resolved = UsageLedgerSpendForecast(
2096
- provider: forecast.provider,
2097
- projectKey: forecast.projectKey ?? identity.key,
2098
- projectID: identity.projectID ?? forecast.projectID,
2099
- projectName: identity.displayName ?? forecast.projectName,
2100
- observedDays: forecast.observedDays,
2101
- observedCostUSD: forecast.observedCostUSD,
2102
- averageDailyCostUSD: forecast.averageDailyCostUSD,
2103
- projected30DayCostUSD: forecast.projected30DayCostUSD,
2104
- projectedCostP50USD: forecast.projectedCostP50USD,
2105
- projectedCostP80USD: forecast.projectedCostP80USD,
2106
- projectedCostP95USD: forecast.projectedCostP95USD,
2107
- projectionDays: forecast.projectionDays,
2108
- budgetLimitUSD: forecast.budgetLimitUSD,
2109
- budgetETAInDays: forecast.budgetETAInDays,
2110
- budgetWillBreach: forecast.budgetWillBreach)
2111
- let budgetLimit = resolved.projectID.flatMap { budgetLimitsByProjectID[$0] }
2112
- return resolved.applyingBudget(monthlyLimitUSD: budgetLimit)
2113
- }
2114
-
2115
- private func matchingProjectForecast(
2116
- for summary: UsageLedgerProjectSummary,
2117
- forecasts: [UsageLedgerSpendForecast]) -> UsageLedgerSpendForecast?
2118
- {
2119
- if let key = summary.projectKey,
2120
- let byKey = forecasts.first(where: { $0.projectKey == key })
2121
- {
2122
- return byKey
2123
- }
2124
- if let projectID = summary.projectID,
2125
- let byID = forecasts.first(where: { $0.projectID == projectID })
2126
- {
2127
- return byID
2128
- }
2129
- let summaryName = summary.projectName?
2130
- .trimmingCharacters(in: .whitespacesAndNewlines)
2131
- .lowercased()
2132
- if let summaryName, !summaryName.isEmpty {
2133
- return forecasts.first {
2134
- $0.projectName?
2135
- .trimmingCharacters(in: .whitespacesAndNewlines)
2136
- .lowercased() == summaryName
2137
- }
2138
- }
2139
- return nil
2140
- }
2141
-
2142
- private func resolvedProjectSummary(
2143
- _ summary: UsageLedgerProjectSummary,
2144
- budgetProjectNames: [String: String]) -> UsageLedgerProjectSummary
2145
- {
2146
- let budgetName = summary.projectID.flatMap { budgetProjectNames[$0] }
2147
- let identity = UsageLedgerProjectIdentityResolver.resolve(
2148
- provider: summary.provider,
2149
- projectID: summary.projectID,
2150
- projectName: summary.projectName,
2151
- budgetNameOverride: budgetName)
2152
- return UsageLedgerProjectSummary(
2153
- provider: summary.provider,
2154
- projectKey: summary.projectKey ?? identity.key,
2155
- projectID: identity.projectID ?? summary.projectID,
2156
- projectName: identity.displayName ?? summary.projectName,
2157
- projectNameConfidence: identity.confidence,
2158
- projectNameSource: identity.source,
2159
- projectNameProvenance: identity.provenance,
2160
- entryCount: summary.entryCount,
2161
- totals: summary.totals,
2162
- modelsUsed: summary.modelsUsed)
2163
- }
2164
-
2165
- private func resolvedModelSummary(
2166
- _ summary: UsageLedgerModelSummary,
2167
- budgetProjectNames: [String: String]) -> UsageLedgerModelSummary
2168
- {
2169
- let budgetName = summary.projectID.flatMap { budgetProjectNames[$0] }
2170
- let identity = UsageLedgerProjectIdentityResolver.resolve(
2171
- provider: summary.provider,
2172
- projectID: summary.projectID,
2173
- projectName: summary.projectName,
2174
- budgetNameOverride: budgetName)
2175
- return UsageLedgerModelSummary(
2176
- provider: summary.provider,
2177
- projectKey: summary.projectKey ?? identity.key,
2178
- projectID: identity.projectID ?? summary.projectID,
2179
- projectName: identity.displayName ?? summary.projectName,
2180
- projectNameConfidence: identity.confidence,
2181
- projectNameSource: identity.source,
2182
- projectNameProvenance: identity.provenance,
2183
- model: summary.model,
2184
- entryCount: summary.entryCount,
2185
- totals: summary.totals)
2186
- }
2187
-
2188
- private func refreshProvider(_ provider: UsageProvider, trigger _: RefreshTrigger) async {
2189
- guard let spec = self.providerSpecs[provider] else { return }
2190
-
2191
- if !spec.isEnabled() {
2192
- self.refreshingProviders.remove(provider)
2193
- await MainActor.run {
2194
- self.snapshots.removeValue(forKey: provider)
2195
- self.errors[provider] = nil
2196
- self.lastSourceLabels.removeValue(forKey: provider)
2197
- self.lastFetchAttempts.removeValue(forKey: provider)
2198
- self.tokenSnapshots.removeValue(forKey: provider)
2199
- self.tokenErrors[provider] = nil
2200
- self.failureGates[provider]?.reset()
2201
- self.tokenFailureGates[provider]?.reset()
2202
- self.statuses.removeValue(forKey: provider)
2203
- self.lastKnownSessionRemaining.removeValue(forKey: provider)
2204
- self.lastTokenFetchAt.removeValue(forKey: provider)
2205
- self.lastUsageDeltaAt.removeValue(forKey: provider)
2206
- self.lastLedgerActivityAt.removeValue(forKey: provider)
2207
- }
2208
- return
2209
- }
2210
-
2211
- self.refreshingProviders.insert(provider)
2212
- defer { self.refreshingProviders.remove(provider) }
2213
-
2214
- let previousSnapshot = self.snapshots[provider]
2215
- let outcome = await spec.fetch()
2216
- await MainActor.run {
2217
- self.lastFetchAttempts[provider] = outcome.attempts
2218
- }
2219
-
2220
- switch outcome.result {
2221
- case let .success(result):
2222
- let scoped = result.usage.scoped(to: provider)
2223
- await MainActor.run {
2224
- self.handleSessionQuotaTransition(provider: provider, snapshot: scoped)
2225
- self.snapshots[provider] = scoped
2226
- self.lastSourceLabels[provider] = result.sourceLabel
2227
- self.errors[provider] = nil
2228
- self.failureGates[provider]?.recordSuccess()
2229
- self.recordUsageActivity(
2230
- provider: provider,
2231
- previous: previousSnapshot,
2232
- current: scoped)
2233
- }
2234
- case let .failure(error):
2235
- await MainActor.run {
2236
- let hadPriorData = self.snapshots[provider] != nil
2237
- let shouldSurface = self.failureGates[provider]?
2238
- .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true
2239
- if shouldSurface {
2240
- self.errors[provider] = error.localizedDescription
2241
- self.snapshots.removeValue(forKey: provider)
2242
- } else {
2243
- self.errors[provider] = nil
2244
- }
2245
- }
2246
- }
2247
- }
2248
-
2249
- private func handleSessionQuotaTransition(provider: UsageProvider, snapshot: UsageSnapshot) {
2250
- let currentRemaining = snapshot.primary.remainingPercent
2251
- let previousRemaining = self.lastKnownSessionRemaining[provider]
2252
-
2253
- defer { self.lastKnownSessionRemaining[provider] = currentRemaining }
2254
-
2255
- guard self.settings.sessionQuotaNotificationsEnabled else {
2256
- if SessionQuotaNotificationLogic.isDepleted(currentRemaining) ||
2257
- SessionQuotaNotificationLogic.isDepleted(previousRemaining)
2258
- {
2259
- let providerText = provider.rawValue
2260
- let message =
2261
- "notifications disabled: provider=\(providerText) " +
2262
- "prev=\(previousRemaining ?? -1) curr=\(currentRemaining)"
2263
- self.sessionQuotaLogger.debug(message)
2264
- }
2265
- return
2266
- }
2267
-
2268
- guard previousRemaining != nil else {
2269
- if SessionQuotaNotificationLogic.isDepleted(currentRemaining) {
2270
- let providerText = provider.rawValue
2271
- let message = "startup depleted: provider=\(providerText) curr=\(currentRemaining)"
2272
- self.sessionQuotaLogger.info(message)
2273
- self.sessionQuotaNotifier.post(transition: .depleted, provider: provider)
2274
- }
2275
- return
2276
- }
2277
-
2278
- let transition = SessionQuotaNotificationLogic.transition(
2279
- previousRemaining: previousRemaining,
2280
- currentRemaining: currentRemaining)
2281
- guard transition != .none else {
2282
- if SessionQuotaNotificationLogic.isDepleted(currentRemaining) ||
2283
- SessionQuotaNotificationLogic.isDepleted(previousRemaining)
2284
- {
2285
- let providerText = provider.rawValue
2286
- let message =
2287
- "no transition: provider=\(providerText) " +
2288
- "prev=\(previousRemaining ?? -1) curr=\(currentRemaining)"
2289
- self.sessionQuotaLogger.debug(message)
2290
- }
2291
- return
2292
- }
2293
-
2294
- let providerText = provider.rawValue
2295
- let transitionText = String(describing: transition)
2296
- let message =
2297
- "transition \(transitionText): provider=\(providerText) " +
2298
- "prev=\(previousRemaining ?? -1) curr=\(currentRemaining)"
2299
- self.sessionQuotaLogger.info(message)
2300
-
2301
- self.sessionQuotaNotifier.post(transition: transition, provider: provider)
2302
- }
2303
-
2304
- private func refreshStatus(_ provider: UsageProvider, trigger _: RefreshTrigger) async {
2305
- guard self.isEnabled(provider) else { return }
2306
- guard self.settings.statusChecksEnabled else { return }
2307
- guard self.settings.refreshFrequency != .manual else { return }
2308
- guard let meta = self.providerMetadata[provider] else { return }
2309
-
2310
- do {
2311
- let status: ProviderStatus
2312
- if let urlString = meta.statusPageURL, let baseURL = URL(string: urlString) {
2313
- status = try await Self.fetchStatus(from: baseURL)
2314
- } else if let productID = meta.statusWorkspaceProductID {
2315
- status = try await Self.fetchWorkspaceStatus(productID: productID)
2316
- } else {
2317
- return
2318
- }
2319
- await MainActor.run { self.statuses[provider] = status }
2320
- } catch {
2321
- // Keep the previous status to avoid flapping when the API hiccups.
2322
- await MainActor.run {
2323
- if self.statuses[provider] == nil {
2324
- self.statuses[provider] = ProviderStatus(
2325
- indicator: .unknown,
2326
- description: error.localizedDescription,
2327
- updatedAt: nil)
2328
- }
2329
- }
2330
- }
2331
- }
2332
-
2333
- private func refreshCreditsIfNeeded() async {
2334
- guard self.isEnabled(.codex) else { return }
2335
- do {
2336
- let credits = try await self.codexFetcher.loadLatestCredits()
2337
- await MainActor.run {
2338
- self.credits = credits
2339
- self.lastCreditsError = nil
2340
- self.lastCreditsSnapshot = credits
2341
- self.creditsFailureStreak = 0
2342
- }
2343
- } catch {
2344
- let message = error.localizedDescription
2345
- if message.localizedCaseInsensitiveContains("data not available yet") {
2346
- await MainActor.run {
2347
- if let cached = self.lastCreditsSnapshot {
2348
- self.credits = cached
2349
- self.lastCreditsError = nil
2350
- } else {
2351
- self.credits = nil
2352
- self.lastCreditsError = "Codex credits are still loading; will retry shortly."
2353
- }
2354
- }
2355
- return
2356
- }
2357
-
2358
- await MainActor.run {
2359
- self.creditsFailureStreak += 1
2360
- if let cached = self.lastCreditsSnapshot {
2361
- self.credits = cached
2362
- let stamp = cached.updatedAt.formatted(date: .abbreviated, time: .shortened)
2363
- self.lastCreditsError =
2364
- "Last Codex credits refresh failed: \(message). Cached values from \(stamp)."
2365
- } else {
2366
- self.lastCreditsError = message
2367
- self.credits = nil
2368
- }
2369
- }
2370
- }
2371
- }
2372
-
2373
- private func applyOpenAIDashboard(_ dash: OpenAIDashboardSnapshot, targetEmail: String?) async {
2374
- await MainActor.run {
2375
- self.openAIDashboard = dash
2376
- self.lastOpenAIDashboardError = nil
2377
- self.lastOpenAIDashboardSnapshot = dash
2378
- self.openAIDashboardRequiresLogin = false
2379
- // Only fill gaps; OAuth/CLI remain the primary sources for usage + credits.
2380
- if self.snapshots[.codex] == nil,
2381
- let usage = dash.toUsageSnapshot(provider: .codex, accountEmail: targetEmail)
2382
- {
2383
- self.snapshots[.codex] = usage
2384
- self.errors[.codex] = nil
2385
- self.failureGates[.codex]?.recordSuccess()
2386
- self.lastSourceLabels[.codex] = "openai-web"
2387
- }
2388
- if self.credits == nil, let credits = dash.toCreditsSnapshot() {
2389
- self.credits = credits
2390
- self.lastCreditsSnapshot = credits
2391
- self.lastCreditsError = nil
2392
- self.creditsFailureStreak = 0
2393
- }
2394
- }
2395
-
2396
- if let email = targetEmail, !email.isEmpty {
2397
- OpenAIDashboardCacheStore.save(OpenAIDashboardCache(accountEmail: email, snapshot: dash))
2398
- }
2399
- }
2400
-
2401
- private func applyOpenAIDashboardFailure(message: String) async {
2402
- await MainActor.run {
2403
- if let cached = self.lastOpenAIDashboardSnapshot {
2404
- self.openAIDashboard = cached
2405
- let stamp = cached.updatedAt.formatted(date: .abbreviated, time: .shortened)
2406
- self.lastOpenAIDashboardError =
2407
- "Last OpenAI dashboard refresh failed: \(message). Cached values from \(stamp)."
2408
- } else {
2409
- self.lastOpenAIDashboardError = message
2410
- self.openAIDashboard = nil
2411
- }
2412
- }
2413
- }
2414
-
2415
- private func refreshOpenAIDashboardIfNeeded(force: Bool = false) async {
2416
- guard self.isEnabled(.codex), self.settings.openAIWebAccessEnabled else {
2417
- await MainActor.run {
2418
- self.openAIDashboard = nil
2419
- self.lastOpenAIDashboardError = nil
2420
- self.lastOpenAIDashboardSnapshot = nil
2421
- self.lastOpenAIDashboardTargetEmail = nil
2422
- self.openAIDashboardRequiresLogin = false
2423
- self.openAIDashboardCookieImportStatus = nil
2424
- self.openAIDashboardCookieImportDebugLog = nil
2425
- self.lastOpenAIDashboardCookieImportAttemptAt = nil
2426
- self.lastOpenAIDashboardCookieImportEmail = nil
2427
- }
2428
- return
2429
- }
2430
-
2431
- let targetEmail = self.codexAccountEmailForOpenAIDashboard()
2432
- self.handleOpenAIWebTargetEmailChangeIfNeeded(targetEmail: targetEmail)
2433
-
2434
- let now = Date()
2435
- let minInterval = max(self.settings.refreshFrequency.seconds ?? 0, 120)
2436
- if !force,
2437
- !self.openAIWebAccountDidChange,
2438
- self.lastOpenAIDashboardError == nil,
2439
- let snapshot = self.lastOpenAIDashboardSnapshot,
2440
- now.timeIntervalSince(snapshot.updatedAt) < minInterval
2441
- {
2442
- return
2443
- }
2444
-
2445
- if self.openAIWebDebugLines.isEmpty {
2446
- self.resetOpenAIWebDebugLog(context: "refresh")
2447
- } else {
2448
- let stamp = Date().formatted(date: .abbreviated, time: .shortened)
2449
- self.logOpenAIWeb("[\(stamp)] OpenAI web refresh start")
2450
- }
2451
- let log: (String) -> Void = { [weak self] line in
2452
- guard let self else { return }
2453
- self.logOpenAIWeb(line)
2454
- }
2455
-
2456
- do {
2457
- let normalized = targetEmail?
2458
- .trimmingCharacters(in: .whitespacesAndNewlines)
2459
- .lowercased()
2460
- var effectiveEmail = targetEmail
2461
-
2462
- // Use a per-email persistent `WKWebsiteDataStore` so multiple dashboard sessions can coexist.
2463
- // Strategy:
2464
- // - Try the existing per-email WebKit cookie store first (fast; avoids Keychain prompts).
2465
- // - On login-required or account mismatch, import cookies from the configured browser order and retry once.
2466
- if self.openAIWebAccountDidChange, let targetEmail, !targetEmail.isEmpty {
2467
- // On account switches, proactively re-import cookies so we don't show stale data from the previous
2468
- // user.
2469
- if let imported = await self.importOpenAIDashboardCookiesIfNeeded(
2470
- targetEmail: targetEmail,
2471
- force: true)
2472
- {
2473
- effectiveEmail = imported
2474
- }
2475
- self.openAIWebAccountDidChange = false
2476
- }
2477
-
2478
- var dash = try await OpenAIDashboardFetcher().loadLatestDashboard(
2479
- accountEmail: effectiveEmail,
2480
- logger: log,
2481
- debugDumpHTML: false)
2482
-
2483
- if self.dashboardEmailMismatch(expected: normalized, actual: dash.signedInEmail) {
2484
- if let imported = await self.importOpenAIDashboardCookiesIfNeeded(
2485
- targetEmail: targetEmail,
2486
- force: true)
2487
- {
2488
- effectiveEmail = imported
2489
- }
2490
- dash = try await OpenAIDashboardFetcher().loadLatestDashboard(
2491
- accountEmail: effectiveEmail,
2492
- logger: log,
2493
- debugDumpHTML: false)
2494
- }
2495
-
2496
- if self.dashboardEmailMismatch(expected: normalized, actual: dash.signedInEmail) {
2497
- let signedIn = dash.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "unknown"
2498
- await MainActor.run {
2499
- self.openAIDashboard = nil
2500
- self.lastOpenAIDashboardError = [
2501
- "OpenAI dashboard signed in as \(signedIn), but Codex uses \(normalized ?? "unknown").",
2502
- "Switch accounts in your browser and re-enable “Access OpenAI via web”.",
2503
- ].joined(separator: " ")
2504
- self.openAIDashboardRequiresLogin = true
2505
- }
2506
- return
2507
- }
2508
-
2509
- await self.applyOpenAIDashboard(dash, targetEmail: effectiveEmail)
2510
- } catch let OpenAIDashboardFetcher.FetchError.noDashboardData(body) {
2511
- // Often indicates a missing/stale session without an obvious login prompt. Retry once after
2512
- // importing cookies from the user's browser.
2513
- let targetEmail = self.codexAccountEmailForOpenAIDashboard()
2514
- var effectiveEmail = targetEmail
2515
- if let imported = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true) {
2516
- effectiveEmail = imported
2517
- }
2518
- do {
2519
- let dash = try await OpenAIDashboardFetcher().loadLatestDashboard(
2520
- accountEmail: effectiveEmail,
2521
- logger: log,
2522
- debugDumpHTML: true)
2523
- await self.applyOpenAIDashboard(dash, targetEmail: effectiveEmail)
2524
- } catch let OpenAIDashboardFetcher.FetchError.noDashboardData(retryBody) {
2525
- let finalBody = retryBody.isEmpty ? body : retryBody
2526
- let message = self.openAIDashboardFriendlyError(
2527
- body: finalBody,
2528
- targetEmail: targetEmail,
2529
- cookieImportStatus: self.openAIDashboardCookieImportStatus)
2530
- ?? OpenAIDashboardFetcher.FetchError.noDashboardData(body: finalBody).localizedDescription
2531
- await self.applyOpenAIDashboardFailure(message: message)
2532
- } catch {
2533
- await self.applyOpenAIDashboardFailure(message: error.localizedDescription)
2534
- }
2535
- } catch OpenAIDashboardFetcher.FetchError.loginRequired {
2536
- let targetEmail = self.codexAccountEmailForOpenAIDashboard()
2537
- var effectiveEmail = targetEmail
2538
- if let imported = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true) {
2539
- effectiveEmail = imported
2540
- }
2541
- do {
2542
- let dash = try await OpenAIDashboardFetcher().loadLatestDashboard(
2543
- accountEmail: effectiveEmail,
2544
- logger: log,
2545
- debugDumpHTML: true)
2546
- await self.applyOpenAIDashboard(dash, targetEmail: effectiveEmail)
2547
- } catch OpenAIDashboardFetcher.FetchError.loginRequired {
2548
- await MainActor.run {
2549
- self.lastOpenAIDashboardError = [
2550
- "OpenAI web access requires a signed-in chatgpt.com session.",
2551
- "Sign in using \(self.codexBrowserCookieOrder.loginHint), " +
2552
- "then re-enable “Access OpenAI via web”.",
2553
- ].joined(separator: " ")
2554
- self.openAIDashboard = self.lastOpenAIDashboardSnapshot
2555
- self.openAIDashboardRequiresLogin = true
2556
- }
2557
- } catch {
2558
- await self.applyOpenAIDashboardFailure(message: error.localizedDescription)
2559
- }
2560
- } catch {
2561
- await self.applyOpenAIDashboardFailure(message: error.localizedDescription)
2562
- }
2563
- }
2564
-
2565
- // MARK: - OpenAI web account switching
2566
-
2567
- /// Detect Codex account email changes and clear stale OpenAI web state so the UI can't show the wrong user.
2568
- /// This does not delete other per-email WebKit cookie stores (we keep multiple accounts around).
2569
- func handleOpenAIWebTargetEmailChangeIfNeeded(targetEmail: String?) {
2570
- let normalized = targetEmail?
2571
- .trimmingCharacters(in: .whitespacesAndNewlines)
2572
- .lowercased()
2573
-
2574
- guard let normalized, !normalized.isEmpty else { return }
2575
-
2576
- let previous = self.lastOpenAIDashboardTargetEmail
2577
- self.lastOpenAIDashboardTargetEmail = normalized
2578
-
2579
- if let previous,
2580
- !previous.isEmpty,
2581
- previous != normalized
2582
- {
2583
- let stamp = Date().formatted(date: .abbreviated, time: .shortened)
2584
- self.logOpenAIWeb(
2585
- "[\(stamp)] Codex account changed: \(previous) → \(normalized); " +
2586
- "clearing OpenAI web snapshot")
2587
- self.openAIWebAccountDidChange = true
2588
- self.openAIDashboard = nil
2589
- self.lastOpenAIDashboardSnapshot = nil
2590
- self.lastOpenAIDashboardError = nil
2591
- self.openAIDashboardRequiresLogin = true
2592
- self.openAIDashboardCookieImportStatus = "Codex account changed; importing browser cookies…"
2593
- self.lastOpenAIDashboardCookieImportAttemptAt = nil
2594
- self.lastOpenAIDashboardCookieImportEmail = nil
2595
- }
2596
- }
2597
-
2598
- func importOpenAIDashboardBrowserCookiesNow() async {
2599
- self.resetOpenAIWebDebugLog(context: "manual import")
2600
- let targetEmail = self.codexAccountEmailForOpenAIDashboard()
2601
- _ = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true)
2602
- await self.refreshOpenAIDashboardIfNeeded(force: true)
2603
- }
2604
-
2605
- private func importOpenAIDashboardCookiesIfNeeded(targetEmail: String?, force: Bool) async -> String? {
2606
- let normalizedTarget = targetEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
2607
- let allowAnyAccount = normalizedTarget == nil || normalizedTarget?.isEmpty == true
2608
-
2609
- let now = Date()
2610
- let lastEmail = self.lastOpenAIDashboardCookieImportEmail
2611
- let lastAttempt = self.lastOpenAIDashboardCookieImportAttemptAt ?? .distantPast
2612
-
2613
- let shouldAttempt: Bool = if force {
2614
- true
2615
- } else {
2616
- if allowAnyAccount {
2617
- now.timeIntervalSince(lastAttempt) > 300
2618
- } else {
2619
- self.openAIDashboardRequiresLogin &&
2620
- (lastEmail?.lowercased() != normalizedTarget?.lowercased() || now
2621
- .timeIntervalSince(lastAttempt) > 300)
2622
- }
2623
- }
2624
-
2625
- guard shouldAttempt else { return normalizedTarget }
2626
- self.lastOpenAIDashboardCookieImportEmail = normalizedTarget
2627
- self.lastOpenAIDashboardCookieImportAttemptAt = now
2628
-
2629
- let stamp = now.formatted(date: .abbreviated, time: .shortened)
2630
- let targetLabel = normalizedTarget ?? "unknown"
2631
- self.logOpenAIWeb("[\(stamp)] import start (target=\(targetLabel))")
2632
-
2633
- do {
2634
- let log: (String) -> Void = { [weak self] message in
2635
- guard let self else { return }
2636
- self.logOpenAIWeb(message)
2637
- }
2638
-
2639
- let result = try await OpenAIDashboardBrowserCookieImporter()
2640
- .importBestCookies(
2641
- intoAccountEmail: normalizedTarget,
2642
- allowAnyAccount: allowAnyAccount,
2643
- logger: log)
2644
- let effectiveEmail = result.signedInEmail?
2645
- .trimmingCharacters(in: .whitespacesAndNewlines)
2646
- .isEmpty == false
2647
- ? result.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
2648
- : normalizedTarget
2649
- self.lastOpenAIDashboardCookieImportEmail = effectiveEmail ?? normalizedTarget
2650
- await MainActor.run {
2651
- let signed = result.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
2652
- let matchText = result.matchesCodexEmail ? "matches Codex" : "does not match Codex"
2653
- if let signed, !signed.isEmpty {
2654
- self.openAIDashboardCookieImportStatus =
2655
- allowAnyAccount
2656
- ? [
2657
- "Using \(result.sourceLabel) cookies (\(result.cookieCount)).",
2658
- "Signed in as \(signed).",
2659
- ].joined(separator: " ")
2660
- : [
2661
- "Using \(result.sourceLabel) cookies (\(result.cookieCount)).",
2662
- "Signed in as \(signed) (\(matchText)).",
2663
- ].joined(separator: " ")
2664
- } else {
2665
- self.openAIDashboardCookieImportStatus =
2666
- "Using \(result.sourceLabel) cookies (\(result.cookieCount))."
2667
- }
2668
- }
2669
- return effectiveEmail
2670
- } catch let err as OpenAIDashboardBrowserCookieImporter.ImportError {
2671
- switch err {
2672
- case let .noMatchingAccount(found):
2673
- let foundText: String = if found.isEmpty {
2674
- "no signed-in session detected in \(self.codexBrowserCookieOrder.loginHint)"
2675
- } else {
2676
- found
2677
- .sorted { lhs, rhs in
2678
- if lhs.sourceLabel == rhs.sourceLabel { return lhs.email < rhs.email }
2679
- return lhs.sourceLabel < rhs.sourceLabel
2680
- }
2681
- .map { "\($0.sourceLabel): \($0.email)" }
2682
- .joined(separator: " • ")
2683
- }
2684
- self.logOpenAIWeb("[\(stamp)] import mismatch: \(foundText)")
2685
- await MainActor.run {
2686
- self.openAIDashboardCookieImportStatus = allowAnyAccount
2687
- ? [
2688
- "No signed-in OpenAI web session found.",
2689
- "Found \(foundText).",
2690
- ].joined(separator: " ")
2691
- : [
2692
- "Browser cookies do not match Codex account (\(normalizedTarget ?? "unknown")).",
2693
- "Found \(foundText).",
2694
- ].joined(separator: " ")
2695
- // Treat mismatch like "not logged in" for the current Codex account.
2696
- self.openAIDashboardRequiresLogin = true
2697
- self.openAIDashboard = nil
2698
- }
2699
- case .noCookiesFound,
2700
- .browserAccessDenied,
2701
- .dashboardStillRequiresLogin:
2702
- self.logOpenAIWeb("[\(stamp)] import failed: \(err.localizedDescription)")
2703
- await MainActor.run {
2704
- self.openAIDashboardCookieImportStatus =
2705
- "Browser cookie import failed: \(err.localizedDescription)"
2706
- self.openAIDashboardRequiresLogin = true
2707
- }
2708
- }
2709
- } catch {
2710
- self.logOpenAIWeb("[\(stamp)] import failed: \(error.localizedDescription)")
2711
- await MainActor.run {
2712
- self.openAIDashboardCookieImportStatus =
2713
- "Browser cookie import failed: \(error.localizedDescription)"
2714
- }
2715
- }
2716
- return nil
2717
- }
2718
-
2719
- private func resetOpenAIWebDebugLog(context: String) {
2720
- let stamp = Date().formatted(date: .abbreviated, time: .shortened)
2721
- self.openAIWebDebugLines.removeAll(keepingCapacity: true)
2722
- self.openAIDashboardCookieImportDebugLog = nil
2723
- self.logOpenAIWeb("[\(stamp)] OpenAI web \(context) start")
2724
- }
2725
-
2726
- private func logOpenAIWeb(_ message: String) {
2727
- self.openAIWebLogger.debug(message)
2728
- self.openAIWebDebugLines.append(message)
2729
- if self.openAIWebDebugLines.count > 240 {
2730
- self.openAIWebDebugLines.removeFirst(self.openAIWebDebugLines.count - 240)
2731
- }
2732
- self.openAIDashboardCookieImportDebugLog = self.openAIWebDebugLines.joined(separator: "\n")
2733
- }
2734
-
2735
- private func dashboardEmailMismatch(expected: String?, actual: String?) -> Bool {
2736
- guard let expected, !expected.isEmpty else { return false }
2737
- guard let raw = actual?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return false }
2738
- return raw.lowercased() != expected.lowercased()
2739
- }
2740
-
2741
- func codexAccountEmailForOpenAIDashboard() -> String? {
2742
- let direct = self.snapshots[.codex]?.accountEmail(for: .codex)?
2743
- .trimmingCharacters(in: .whitespacesAndNewlines)
2744
- if let direct, !direct.isEmpty { return direct }
2745
- let fallback = self.codexFetcher.loadAccountInfo().email?.trimmingCharacters(in: .whitespacesAndNewlines)
2746
- if let fallback, !fallback.isEmpty { return fallback }
2747
- let cached = self.openAIDashboard?.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
2748
- if let cached, !cached.isEmpty { return cached }
2749
- let imported = self.lastOpenAIDashboardCookieImportEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
2750
- if let imported, !imported.isEmpty { return imported }
2751
- return nil
2752
- }
2753
- }
2754
-
2755
- extension UsageStore {
2756
- func debugDumpClaude() async {
2757
- let output = await self.claudeFetcher.debugRawProbe(model: "sonnet")
2758
- let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("runic-claude-probe.txt")
2759
- try? output.write(to: url, atomically: true, encoding: .utf8)
2760
- await MainActor.run {
2761
- let snippet = String(output.prefix(180)).replacingOccurrences(of: "\n", with: " ")
2762
- self.errors[.claude] = "[Claude] \(snippet) (saved: \(url.path))"
2763
- NSWorkspace.shared.open(url)
2764
- }
2765
- }
2766
-
2767
- func dumpLog(toFileFor provider: UsageProvider) async -> URL? {
2768
- let text = await self.debugLog(for: provider)
2769
- let filename = "runic-\(provider.rawValue)-probe.txt"
2770
- let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(filename)
2771
- do {
2772
- try text.write(to: url, atomically: true, encoding: .utf8)
2773
- _ = await MainActor.run { NSWorkspace.shared.open(url) }
2774
- return url
2775
- } catch {
2776
- await MainActor.run {
2777
- self.errors[provider] = "Failed to save log: \(error.localizedDescription)"
2778
- }
2779
- return nil
2780
- }
2781
- }
2782
-
2783
- func debugClaudeDump() async -> String {
2784
- await ClaudeStatusProbe.latestDumps()
2785
- }
2786
-
2787
- func debugLog(for provider: UsageProvider) async -> String {
2788
- if let cached = self.probeLogs[provider], !cached.isEmpty {
2789
- return cached
2790
- }
2791
-
2792
- let settingsSnapshot = await MainActor.run {
2793
- ProviderSettingsSnapshot(
2794
- debugMenuEnabled: self.settings.debugMenuEnabled,
2795
- codex: ProviderSettingsSnapshot.CodexProviderSettings(
2796
- usageDataSource: self.settings.codexUsageDataSource),
2797
- claude: ProviderSettingsSnapshot.ClaudeProviderSettings(
2798
- usageDataSource: self.settings.claudeUsageDataSource,
2799
- webExtrasEnabled: self.settings.claudeWebExtrasEnabled),
2800
- zai: ProviderSettingsSnapshot.ZaiProviderSettings(),
2801
- copilot: ProviderSettingsSnapshot.CopilotProviderSettings(
2802
- apiToken: self.settings.copilotAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
2803
- ? nil : self.settings.copilotAPIToken),
2804
- azure: ProviderSettingsSnapshot.AzureProviderSettings(
2805
- apiToken: self.settings.azureOpenAIAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
2806
- ? nil : self.settings.azureOpenAIAPIToken,
2807
- endpoint: self.settings.azureOpenAIEndpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
2808
- ? nil : self.settings.azureOpenAIEndpoint,
2809
- deployment: self.settings.azureOpenAIDeployment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
2810
- ? nil : self.settings.azureOpenAIDeployment,
2811
- apiVersion: self.settings.azureOpenAIAPIVersion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
2812
- ? nil : self.settings.azureOpenAIAPIVersion),
2813
- bedrock: ProviderSettingsSnapshot.BedrockProviderSettings(
2814
- region: self.settings.bedrockRegion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
2815
- ? nil : self.settings.bedrockRegion,
2816
- profile: self.settings.bedrockAWSProfile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
2817
- ? nil : self.settings.bedrockAWSProfile,
2818
- modelID: self.settings.bedrockModelID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
2819
- ? nil : self.settings.bedrockModelID),
2820
- vertexai: ProviderSettingsSnapshot.VertexAIProviderSettings(
2821
- project: self.settings.vertexaiProject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
2822
- ? nil : self.settings.vertexaiProject,
2823
- location: self.settings.vertexaiLocation.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
2824
- ? nil : self.settings.vertexaiLocation))
2825
- }
2826
-
2827
- let debugContext = ProviderFetchContext(
2828
- runtime: .app,
2829
- sourceMode: .auto,
2830
- includeCredits: true,
2831
- webTimeout: 15,
2832
- webDebugDumpHTML: false,
2833
- verbose: false,
2834
- env: ProcessInfo.processInfo.environment,
2835
- settings: settingsSnapshot,
2836
- fetcher: self.codexFetcher,
2837
- claudeFetcher: self.claudeFetcher)
2838
-
2839
- let claudeWebExtrasEnabled = self.settings.claudeWebExtrasEnabled
2840
- let claudeUsageDataSource = self.settings.claudeUsageDataSource
2841
- let claudeDebugMenuEnabled = self.settings.debugMenuEnabled
2842
- return await Task.detached(priority: .utility) { () -> String in
2843
- switch provider {
2844
- case .codex:
2845
- let raw = await self.codexFetcher.debugRawRateLimits()
2846
- await MainActor.run { self.probeLogs[.codex] = raw }
2847
- return raw
2848
- case .claude:
2849
- let text = await self.runWithTimeout(seconds: 15) {
2850
- var lines: [String] = []
2851
- let hasKey = ClaudeWebAPIFetcher.hasSessionKey { msg in lines.append(msg) }
2852
- let hasOAuthCredentials = (try? ClaudeOAuthCredentialsStore.load()) != nil
2853
-
2854
- let strategy = ClaudeProviderDescriptor.resolveUsageStrategy(
2855
- debugMenuEnabled: claudeDebugMenuEnabled,
2856
- selectedDataSource: claudeUsageDataSource,
2857
- webExtrasEnabled: claudeWebExtrasEnabled,
2858
- hasWebSession: hasKey,
2859
- hasOAuthCredentials: hasOAuthCredentials)
2860
-
2861
- await MainActor.run {
2862
- if self.settings.claudeUsageDataSource != strategy.dataSource {
2863
- self.settings.claudeUsageDataSource = strategy.dataSource
2864
- }
2865
- }
2866
-
2867
- lines.append("strategy=\(strategy.dataSource.rawValue)")
2868
- lines.append("hasSessionKey=\(hasKey)")
2869
- lines.append("hasOAuthCredentials=\(hasOAuthCredentials)")
2870
- if strategy.useWebExtras {
2871
- lines.append("web_extras=enabled")
2872
- }
2873
- lines.append("")
2874
-
2875
- switch strategy.dataSource {
2876
- case .web:
2877
- do {
2878
- let web = try await ClaudeWebAPIFetcher.fetchUsage { msg in lines.append(msg) }
2879
- lines.append("")
2880
- lines.append("Web API summary:")
2881
-
2882
- let sessionReset = web.sessionResetsAt?.description ?? "nil"
2883
- lines.append("session_used=\(web.sessionPercentUsed)% resetsAt=\(sessionReset)")
2884
-
2885
- if let weekly = web.weeklyPercentUsed {
2886
- let weeklyReset = web.weeklyResetsAt?.description ?? "nil"
2887
- lines.append("weekly_used=\(weekly)% resetsAt=\(weeklyReset)")
2888
- } else {
2889
- lines.append("weekly_used=nil")
2890
- }
2891
-
2892
- lines.append("opus_used=\(web.opusPercentUsed?.description ?? "nil")")
2893
-
2894
- if let extra = web.extraUsageCost {
2895
- let resetsAt = extra.resetsAt?.description ?? "nil"
2896
- let period = extra.period ?? "nil"
2897
- let line =
2898
- "extra_usage used=\(extra.used) limit=\(extra.limit) " +
2899
- "currency=\(extra.currencyCode) period=\(period) resetsAt=\(resetsAt)"
2900
- lines.append(line)
2901
- } else {
2902
- lines.append("extra_usage=nil")
2903
- }
2904
-
2905
- return lines.joined(separator: "\n")
2906
- } catch {
2907
- lines.append("Web API failed: \(error.localizedDescription)")
2908
- return lines.joined(separator: "\n")
2909
- }
2910
- case .cli:
2911
- let cli = await self.claudeFetcher.debugRawProbe(model: "sonnet")
2912
- lines.append(cli)
2913
- return lines.joined(separator: "\n")
2914
- case .oauth:
2915
- lines.append("OAuth source selected.")
2916
- return lines.joined(separator: "\n")
2917
- }
2918
- }
2919
- await MainActor.run { self.probeLogs[.claude] = text }
2920
- return text
2921
- case .zai:
2922
- let text = self.debugTokenSummary(
2923
- provider: "zai",
2924
- resolution: ProviderTokenResolver.zaiResolution())
2925
- await MainActor.run { self.probeLogs[.zai] = text }
2926
- return text
2927
- case .gemini:
2928
- let text = await self.runWithTimeout(seconds: 15) {
2929
- await self.debugProviderProbe(
2930
- provider: .gemini,
2931
- context: debugContext,
2932
- settings: settingsSnapshot)
2933
- }
2934
- await MainActor.run { self.probeLogs[.gemini] = text }
2935
- return text
2936
- case .antigravity:
2937
- let text = await self.runWithTimeout(seconds: 15) {
2938
- await self.debugProviderProbe(
2939
- provider: .antigravity,
2940
- context: debugContext,
2941
- settings: settingsSnapshot)
2942
- }
2943
- await MainActor.run { self.probeLogs[.antigravity] = text }
2944
- return text
2945
- case .cursor:
2946
- let text = await self.runWithTimeout(seconds: 15) {
2947
- await self.debugProviderProbe(
2948
- provider: .cursor,
2949
- context: debugContext,
2950
- settings: settingsSnapshot)
2951
- }
2952
- await MainActor.run { self.probeLogs[.cursor] = text }
2953
- return text
2954
- case .factory:
2955
- let text = await self.runWithTimeout(seconds: 15) {
2956
- await self.debugProviderProbe(
2957
- provider: .factory,
2958
- context: debugContext,
2959
- settings: settingsSnapshot)
2960
- }
2961
- await MainActor.run { self.probeLogs[.factory] = text }
2962
- return text
2963
- case .copilot:
2964
- let text = await self.runWithTimeout(seconds: 15) {
2965
- await self.debugProviderProbe(
2966
- provider: .copilot,
2967
- context: debugContext,
2968
- settings: settingsSnapshot)
2969
- }
2970
- await MainActor.run { self.probeLogs[.copilot] = text }
2971
- return text
2972
- case .minimax:
2973
- let text = await self.runWithTimeout(seconds: 15) {
2974
- await self.debugProviderProbe(
2975
- provider: .minimax,
2976
- context: debugContext,
2977
- settings: settingsSnapshot)
2978
- }
2979
- await MainActor.run { self.probeLogs[.minimax] = text }
2980
- return text
2981
- case .openrouter:
2982
- let text = await self.runWithTimeout(seconds: 15) {
2983
- await self.debugProviderProbe(
2984
- provider: .openrouter,
2985
- context: debugContext,
2986
- settings: settingsSnapshot)
2987
- }
2988
- await MainActor.run { self.probeLogs[.openrouter] = text }
2989
- return text
2990
- case .groq:
2991
- let text = await self.runWithTimeout(seconds: 15) {
2992
- await self.debugProviderProbe(
2993
- provider: .groq,
2994
- context: debugContext,
2995
- settings: settingsSnapshot)
2996
- }
2997
- await MainActor.run { self.probeLogs[.groq] = text }
2998
- return text
2999
- case .deepseek:
3000
- let text = await self.runWithTimeout(seconds: 15) {
3001
- await self.debugProviderProbe(
3002
- provider: .deepseek,
3003
- context: debugContext,
3004
- settings: settingsSnapshot)
3005
- }
3006
- await MainActor.run { self.probeLogs[.deepseek] = text }
3007
- return text
3008
- case .fireworks:
3009
- let text = await self.runWithTimeout(seconds: 15) {
3010
- await self.debugProviderProbe(
3011
- provider: .fireworks,
3012
- context: debugContext,
3013
- settings: settingsSnapshot)
3014
- }
3015
- await MainActor.run { self.probeLogs[.fireworks] = text }
3016
- return text
3017
- case .mistral:
3018
- let text = await self.runWithTimeout(seconds: 15) {
3019
- await self.debugProviderProbe(
3020
- provider: .mistral,
3021
- context: debugContext,
3022
- settings: settingsSnapshot)
3023
- }
3024
- await MainActor.run { self.probeLogs[.mistral] = text }
3025
- return text
3026
- case .perplexity:
3027
- let text = await self.runWithTimeout(seconds: 15) {
3028
- await self.debugProviderProbe(
3029
- provider: .perplexity,
3030
- context: debugContext,
3031
- settings: settingsSnapshot)
3032
- }
3033
- await MainActor.run { self.probeLogs[.perplexity] = text }
3034
- return text
3035
- case .kimi:
3036
- let text = await self.runWithTimeout(seconds: 15) {
3037
- await self.debugProviderProbe(
3038
- provider: .kimi,
3039
- context: debugContext,
3040
- settings: settingsSnapshot)
3041
- }
3042
- await MainActor.run { self.probeLogs[.kimi] = text }
3043
- return text
3044
- case .auggie:
3045
- let text = await self.runWithTimeout(seconds: 15) {
3046
- await self.debugProviderProbe(
3047
- provider: .auggie,
3048
- context: debugContext,
3049
- settings: settingsSnapshot)
3050
- }
3051
- await MainActor.run { self.probeLogs[.auggie] = text }
3052
- return text
3053
- case .together:
3054
- let text = await self.runWithTimeout(seconds: 15) {
3055
- await self.debugProviderProbe(
3056
- provider: .together,
3057
- context: debugContext,
3058
- settings: settingsSnapshot)
3059
- }
3060
- await MainActor.run { self.probeLogs[.together] = text }
3061
- return text
3062
- case .cohere:
3063
- let text = await self.runWithTimeout(seconds: 15) {
3064
- await self.debugProviderProbe(
3065
- provider: .cohere,
3066
- context: debugContext,
3067
- settings: settingsSnapshot)
3068
- }
3069
- await MainActor.run { self.probeLogs[.cohere] = text }
3070
- return text
3071
- case .xai:
3072
- let text = await self.runWithTimeout(seconds: 15) {
3073
- await self.debugProviderProbe(
3074
- provider: .xai,
3075
- context: debugContext,
3076
- settings: settingsSnapshot)
3077
- }
3078
- await MainActor.run { self.probeLogs[.xai] = text }
3079
- return text
3080
- case .cerebras:
3081
- let text = await self.runWithTimeout(seconds: 15) {
3082
- await self.debugProviderProbe(
3083
- provider: .cerebras,
3084
- context: debugContext,
3085
- settings: settingsSnapshot)
3086
- }
3087
- await MainActor.run { self.probeLogs[.cerebras] = text }
3088
- return text
3089
- case .sambanova:
3090
- let text = await self.runWithTimeout(seconds: 15) {
3091
- await self.debugProviderProbe(
3092
- provider: .sambanova,
3093
- context: debugContext,
3094
- settings: settingsSnapshot)
3095
- }
3096
- await MainActor.run { self.probeLogs[.sambanova] = text }
3097
- return text
3098
- case .azure:
3099
- let text = await self.runWithTimeout(seconds: 15) {
3100
- await self.debugProviderProbe(
3101
- provider: .azure,
3102
- context: debugContext,
3103
- settings: settingsSnapshot)
3104
- }
3105
- await MainActor.run { self.probeLogs[.azure] = text }
3106
- return text
3107
- case .bedrock:
3108
- let text = await self.runWithTimeout(seconds: 15) {
3109
- await self.debugProviderProbe(
3110
- provider: .bedrock,
3111
- context: debugContext,
3112
- settings: settingsSnapshot)
3113
- }
3114
- await MainActor.run { self.probeLogs[.bedrock] = text }
3115
- return text
3116
- case .vertexai:
3117
- let text = await self.runWithTimeout(seconds: 15) {
3118
- await self.debugProviderProbe(
3119
- provider: .vertexai,
3120
- context: debugContext,
3121
- settings: settingsSnapshot)
3122
- }
3123
- await MainActor.run { self.probeLogs[.vertexai] = text }
3124
- return text
3125
- case .qwen:
3126
- let text = await self.runWithTimeout(seconds: 15) {
3127
- await self.debugProviderProbe(
3128
- provider: .qwen,
3129
- context: debugContext,
3130
- settings: settingsSnapshot)
3131
- }
3132
- await MainActor.run { self.probeLogs[.qwen] = text }
3133
- return text
3134
- }
3135
- }.value
3136
- }
3137
-
3138
- private nonisolated func debugProviderProbe(
3139
- provider: UsageProvider,
3140
- context: ProviderFetchContext,
3141
- settings: ProviderSettingsSnapshot) async -> String
3142
- {
3143
- var lines: [String] = [
3144
- "provider=\(provider.rawValue)"
3145
- ]
3146
-
3147
- lines.append(contentsOf: self.debugCredentialLines(for: provider, settings: settings))
3148
- let descriptor = ProviderDescriptorRegistry.descriptor(for: provider)
3149
- let outcome = await descriptor.fetchOutcome(context: context)
3150
- lines.append(contentsOf: self.debugAttemptLines(from: outcome.attempts))
3151
-
3152
- switch outcome.result {
3153
- case let .success(result):
3154
- lines.append("result=success")
3155
- lines.append("strategy_id=\(result.strategyID)")
3156
- lines.append("strategy_kind=\(self.debugStrategyKindLabel(result.strategyKind))")
3157
- lines.append("source_label=\(result.sourceLabel)")
3158
- lines.append(contentsOf: self.debugUsageSummary(result.usage))
3159
- case let .failure(error):
3160
- lines.append("result=failure")
3161
- lines.append("error=\(error.localizedDescription)")
3162
- }
3163
-
3164
- return lines.joined(separator: "\n")
3165
- }
3166
-
3167
- private nonisolated func debugCredentialLines(
3168
- for provider: UsageProvider,
3169
- settings: ProviderSettingsSnapshot) -> [String]
3170
- {
3171
- switch provider {
3172
- case .zai:
3173
- return [self.debugTokenSummary(provider: "zai", resolution: ProviderTokenResolver.zaiResolution())]
3174
- case .copilot:
3175
- return [self.debugTokenSummary(provider: "copilot", resolution: ProviderTokenResolver.copilotResolution())]
3176
- case .minimax:
3177
- return [
3178
- self.debugTokenSummary(
3179
- provider: "minimax.api",
3180
- resolution: ProviderTokenResolver.minimaxApiKeyResolution()),
3181
- self.debugTokenSummary(
3182
- provider: "minimax.cookie",
3183
- resolution: ProviderTokenResolver.minimaxCookieHeaderResolution()),
3184
- self.debugTokenSummary(
3185
- provider: "minimax.group",
3186
- resolution: ProviderTokenResolver.minimaxGroupResolution()),
3187
- ]
3188
- case .openrouter:
3189
- return [self.debugTokenSummary(provider: "openrouter", resolution: ProviderTokenResolver.openRouterResolution())]
3190
- case .groq:
3191
- return [self.debugTokenSummary(provider: "groq", resolution: ProviderTokenResolver.groqResolution())]
3192
- case .deepseek:
3193
- return [self.debugTokenSummary(provider: "deepseek", resolution: ProviderTokenResolver.deepSeekResolution())]
3194
- case .fireworks:
3195
- return [self.debugTokenSummary(provider: "fireworks", resolution: ProviderTokenResolver.fireworksResolution())]
3196
- case .mistral:
3197
- return [self.debugTokenSummary(provider: "mistral", resolution: ProviderTokenResolver.mistralResolution())]
3198
- case .perplexity:
3199
- return [self.debugTokenSummary(provider: "perplexity", resolution: ProviderTokenResolver.perplexityResolution())]
3200
- case .kimi:
3201
- return [self.debugTokenSummary(provider: "kimi", resolution: ProviderTokenResolver.kimiResolution())]
3202
- case .auggie:
3203
- return [self.debugTokenSummary(provider: "auggie", resolution: ProviderTokenResolver.auggieResolution())]
3204
- case .together:
3205
- return [self.debugTokenSummary(provider: "together", resolution: ProviderTokenResolver.togetherResolution())]
3206
- case .cohere:
3207
- return [self.debugTokenSummary(provider: "cohere", resolution: ProviderTokenResolver.cohereResolution())]
3208
- case .xai:
3209
- return [self.debugTokenSummary(provider: "xai", resolution: ProviderTokenResolver.xaiResolution())]
3210
- case .cerebras:
3211
- return [self.debugTokenSummary(provider: "cerebras", resolution: ProviderTokenResolver.cerebrasResolution())]
3212
- case .sambanova:
3213
- return [self.debugTokenSummary(provider: "sambanova", resolution: ProviderTokenResolver.sambaNovaResolution())]
3214
- case .azure:
3215
- var lines: [String] = [
3216
- "azure.endpoint=\(self.debugFieldValue(settings.azure?.endpoint))",
3217
- "azure.deployment=\(self.debugFieldValue(settings.azure?.deployment))",
3218
- "azure.apiVersion=\(self.debugFieldValue(settings.azure?.apiVersion))"
3219
- ]
3220
- lines.append(self.debugTokenSummary(provider: "azure.token", resolution: ProviderTokenResolver.azureOpenAIResolution()))
3221
- return lines
3222
- case .bedrock:
3223
- return [
3224
- "bedrock.region=\(self.debugFieldValue(settings.bedrock?.region))",
3225
- "bedrock.profile=\(self.debugFieldValue(settings.bedrock?.profile))",
3226
- "bedrock.model_filter=\(self.debugFieldValue(settings.bedrock?.modelID))",
3227
- ]
3228
- case .vertexai:
3229
- return [
3230
- "vertexai.project=\(self.debugFieldValue(settings.vertexai?.project))",
3231
- "vertexai.location=\(self.debugFieldValue(settings.vertexai?.location))",
3232
- ]
3233
- case .qwen:
3234
- return [self.debugTokenSummary(provider: "qwen", resolution: ProviderTokenResolver.qwenResolution())]
3235
- case .gemini:
3236
- return [
3237
- "gemini.authType=\(GeminiStatusProbe.currentAuthType().rawValue)",
3238
- ]
3239
- case .antigravity, .cursor, .factory:
3240
- return ["credentials=provider_internal"]
3241
- case .codex, .claude:
3242
- return []
3243
- }
3244
- }
3245
-
3246
- private nonisolated func debugAttemptLines(from attempts: [ProviderFetchAttempt]) -> [String] {
3247
- var lines: [String] = []
3248
- if attempts.isEmpty {
3249
- lines.append("attempts=none")
3250
- return lines
3251
- }
3252
-
3253
- let attemptSummary = attempts.enumerated().map { index, attempt in
3254
- let error = attempt.errorDescription ?? "nil"
3255
- return "attempt\(index + 1): id=\(attempt.strategyID) kind=\(attempt.kind) available=\(attempt.wasAvailable) error=\(error)"
3256
- }
3257
- lines.append("attempts=\(attempts.count)")
3258
- lines.append(contentsOf: attemptSummary)
3259
- return lines
3260
- }
3261
-
3262
- private nonisolated func debugUsageSummary(_ snapshot: UsageSnapshot) -> [String] {
3263
- var lines: [String] = []
3264
- lines.append(self.debugRateWindowSummary(label: "primary", window: snapshot.primary))
3265
- if let secondary = snapshot.secondary {
3266
- lines.append(self.debugRateWindowSummary(label: "secondary", window: secondary))
3267
- }
3268
- if let tertiary = snapshot.tertiary {
3269
- lines.append(self.debugRateWindowSummary(label: "tertiary", window: tertiary))
3270
- }
3271
- if let identity = snapshot.identity {
3272
- lines.append("identity.email=\(identity.accountEmail ?? "nil")")
3273
- lines.append("identity.organization=\(identity.accountOrganization ?? "nil")")
3274
- lines.append("identity.loginMethod=\(identity.loginMethod ?? "nil")")
3275
- }
3276
- if let providerCost = snapshot.providerCost {
3277
- let resetsAt = providerCost.resetsAt?.description ?? "nil"
3278
- lines.append(
3279
- "provider_cost.used=\(providerCost.used) limit=\(providerCost.limit) " +
3280
- "currency=\(providerCost.currencyCode) period=\(providerCost.period ?? "nil") resetsAt=\(resetsAt)")
3281
- }
3282
- return lines
3283
- }
3284
-
3285
- private nonisolated func debugRateWindowSummary(label: String, window: RateWindow) -> String {
3286
- let resetsAt = window.resetsAt?.description ?? "nil"
3287
- let windowMinutes = window.windowMinutes.map { "\($0)m" } ?? "nil"
3288
- let resetDescription = window.resetDescription ?? "nil"
3289
- let windowLabel = window.label ?? "nil"
3290
- let used = String(format: "%.2f", window.usedPercent)
3291
- let remaining = String(format: "%.2f", window.remainingPercent)
3292
- return "\(label).usedPercent=\(used) remainingPercent=\(remaining) window=\(windowMinutes) resetsAt=\(resetsAt) label=\(windowLabel) desc=\(resetDescription)"
3293
- }
3294
-
3295
- private nonisolated func debugTokenSummary(provider: String, resolution: ProviderTokenResolution?) -> String {
3296
- let tokenState = resolution == nil ? "missing" : "present"
3297
- let source = resolution.flatMap { r in
3298
- let key = r.sourceKey.map { "(\($0))" } ?? ""
3299
- return "\(r.source.rawValue)\(key)"
3300
- } ?? "nil"
3301
- let length = resolution?.token.count ?? 0
3302
- return "\(provider)=\(tokenState) source=\(source) length=\(length)"
3303
- }
3304
-
3305
- private nonisolated func debugFieldValue(_ raw: String?) -> String {
3306
- let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
3307
- return trimmed.isEmpty ? "missing" : trimmed
3308
- }
3309
-
3310
- private nonisolated func debugStrategyKindLabel(_ kind: ProviderFetchKind) -> String {
3311
- switch kind {
3312
- case .cli: "cli"
3313
- case .api: "api"
3314
- case .web: "web"
3315
- case .oauth: "oauth"
3316
- case .apiToken: "apiToken"
3317
- case .localProbe: "localProbe"
3318
- case .webDashboard: "webDashboard"
3319
- }
3320
- }
3321
-
3322
- private func runWithTimeout(seconds: Double, operation: @escaping @Sendable () async -> String) async -> String {
3323
- await withTaskGroup(of: String?.self) { group -> String in
3324
- group.addTask { await operation() }
3325
- group.addTask {
3326
- try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
3327
- return nil
3328
- }
3329
- let result = await group.next()?.flatMap(\.self)
3330
- group.cancelAll()
3331
- return result ?? "Probe timed out after \(Int(seconds))s"
3332
- }
3333
- }
3334
-
3335
- private func detectVersions() {
3336
- Task.detached { [claudeFetcher] in
3337
- let codexVer = Self.readCLI("codex", args: ["-s", "read-only", "-a", "untrusted", "--version"])
3338
- let claudeVer = claudeFetcher.detectVersion()
3339
- let geminiVer = Self.readCLI("gemini", args: ["--version"])
3340
- let antigravityVer = await AntigravityStatusProbe.detectVersion()
3341
- await MainActor.run {
3342
- self.codexVersion = codexVer
3343
- self.claudeVersion = claudeVer
3344
- self.geminiVersion = geminiVer
3345
- self.zaiVersion = nil
3346
- self.antigravityVersion = antigravityVer
3347
- }
3348
- }
3349
- }
3350
-
3351
- private nonisolated static func readCLI(_ cmd: String, args: [String]) -> String? {
3352
- let env = ProcessInfo.processInfo.environment
3353
- var pathEnv = env
3354
- pathEnv["PATH"] = PathBuilder.effectivePATH(purposes: [.rpc, .tty, .nodeTooling], env: env)
3355
- let loginPATH = LoginShellPathCache.shared.current
3356
-
3357
- let resolved: String = switch cmd {
3358
- case "codex":
3359
- BinaryLocator.resolveCodexBinary(env: env, loginPATH: loginPATH) ?? cmd
3360
- case "gemini":
3361
- BinaryLocator.resolveGeminiBinary(env: env, loginPATH: loginPATH) ?? cmd
3362
- default:
3363
- cmd
3364
- }
3365
-
3366
- let process = Process()
3367
- process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
3368
- process.arguments = [resolved] + args
3369
- process.environment = pathEnv
3370
- let pipe = Pipe()
3371
- process.standardOutput = pipe
3372
- try? process.run()
3373
- process.waitUntilExit()
3374
- guard process.terminationStatus == 0 else { return nil }
3375
- let data = pipe.fileHandleForReading.readDataToEndOfFile()
3376
- guard let text = String(data: data, encoding: .utf8)?
3377
- .trimmingCharacters(in: .whitespacesAndNewlines),
3378
- !text.isEmpty else { return nil }
3379
- return text
3380
- }
3381
-
3382
- private func refreshPathDebugInfo() {
3383
- self.pathDebugInfo = PathBuilder.debugSnapshot(purposes: [.rpc, .tty, .nodeTooling])
3384
- }
3385
-
3386
- func clearCostUsageCache() async -> String? {
3387
- let errorMessage: String? = await Task.detached(priority: .utility) {
3388
- let fm = FileManager.default
3389
- let cacheDirs = [
3390
- Self.costUsageCacheDirectory(fileManager: fm),
3391
- ]
3392
-
3393
- for cacheDir in cacheDirs {
3394
- do {
3395
- try fm.removeItem(at: cacheDir)
3396
- } catch let error as NSError {
3397
- if error.domain == NSCocoaErrorDomain, error.code == NSFileNoSuchFileError { continue }
3398
- return error.localizedDescription
3399
- }
3400
- }
3401
- return nil
3402
- }.value
3403
-
3404
- guard errorMessage == nil else { return errorMessage }
3405
-
3406
- self.tokenSnapshots.removeAll()
3407
- self.tokenErrors.removeAll()
3408
- self.lastTokenFetchAt.removeAll()
3409
- self.tokenFailureGates[.codex]?.reset()
3410
- self.tokenFailureGates[.claude]?.reset()
3411
- return nil
3412
- }
3413
-
3414
- private func refreshTokenUsage(
3415
- _ provider: UsageProvider,
3416
- force: Bool,
3417
- trigger: RefreshTrigger,
3418
- inactiveProviders: Set<UsageProvider>) async
3419
- {
3420
- guard provider == .codex || provider == .claude else {
3421
- self.tokenSnapshots.removeValue(forKey: provider)
3422
- self.tokenErrors[provider] = nil
3423
- self.tokenFailureGates[provider]?.reset()
3424
- self.lastTokenFetchAt.removeValue(forKey: provider)
3425
- return
3426
- }
3427
-
3428
- guard self.settings.costUsageEnabled else {
3429
- self.tokenSnapshots.removeValue(forKey: provider)
3430
- self.tokenErrors[provider] = nil
3431
- self.tokenFailureGates[provider]?.reset()
3432
- self.lastTokenFetchAt.removeValue(forKey: provider)
3433
- return
3434
- }
3435
-
3436
- guard self.isEnabled(provider) else {
3437
- self.tokenSnapshots.removeValue(forKey: provider)
3438
- self.tokenErrors[provider] = nil
3439
- self.tokenFailureGates[provider]?.reset()
3440
- self.lastTokenFetchAt.removeValue(forKey: provider)
3441
- return
3442
- }
3443
-
3444
- if trigger.isAuto, inactiveProviders.contains(provider), !force {
3445
- return
3446
- }
3447
-
3448
- guard !self.tokenRefreshInFlight.contains(provider) else { return }
3449
-
3450
- let now = Date()
3451
- if !force,
3452
- let last = self.lastTokenFetchAt[provider],
3453
- now.timeIntervalSince(last) < self.tokenFetchTTL
3454
- {
3455
- return
3456
- }
3457
- self.lastTokenFetchAt[provider] = now
3458
- self.tokenRefreshInFlight.insert(provider)
3459
- defer { self.tokenRefreshInFlight.remove(provider) }
3460
-
3461
- let startedAt = Date()
3462
- let providerText = provider.rawValue
3463
- self.tokenCostLogger
3464
- .info("cost usage start provider=\(providerText) force=\(force)")
3465
-
3466
- do {
3467
- let fetcher = self.costUsageFetcher
3468
- let timeoutSeconds = self.tokenFetchTimeout
3469
- let snapshot = try await withThrowingTaskGroup(of: CostUsageTokenSnapshot.self) { group in
3470
- group.addTask(priority: .utility) {
3471
- try await fetcher.loadTokenSnapshot(provider: provider, now: now, forceRefresh: force)
3472
- }
3473
- group.addTask {
3474
- try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
3475
- throw CostUsageError.timedOut(seconds: Int(timeoutSeconds))
3476
- }
3477
- defer { group.cancelAll() }
3478
- guard let snapshot = try await group.next() else { throw CancellationError() }
3479
- return snapshot
3480
- }
3481
-
3482
- guard !snapshot.daily.isEmpty else {
3483
- self.tokenSnapshots.removeValue(forKey: provider)
3484
- self.tokenErrors[provider] = Self.tokenCostNoDataMessage(for: provider)
3485
- self.tokenFailureGates[provider]?.recordSuccess()
3486
- return
3487
- }
3488
- let duration = Date().timeIntervalSince(startedAt)
3489
- let sessionCost = snapshot.sessionCostUSD.map(UsageFormatter.usdString) ?? "—"
3490
- let monthCost = snapshot.last30DaysCostUSD.map(UsageFormatter.usdString) ?? "—"
3491
- let durationText = String(format: "%.2f", duration)
3492
- let message =
3493
- "cost usage success provider=\(providerText) " +
3494
- "duration=\(durationText)s " +
3495
- "today=\(sessionCost) " +
3496
- "30d=\(monthCost)"
3497
- self.tokenCostLogger.info(message)
3498
- self.tokenSnapshots[provider] = snapshot
3499
- self.tokenErrors[provider] = nil
3500
- self.tokenFailureGates[provider]?.recordSuccess()
3501
- self.persistWidgetSnapshot(reason: "token-usage")
3502
- } catch {
3503
- if error is CancellationError { return }
3504
- let duration = Date().timeIntervalSince(startedAt)
3505
- let msg = error.localizedDescription
3506
- let durationText = String(format: "%.2f", duration)
3507
- let message = "cost usage failed provider=\(providerText) duration=\(durationText)s error=\(msg)"
3508
- self.tokenCostLogger.error(message)
3509
- let hadPriorData = self.tokenSnapshots[provider] != nil
3510
- let shouldSurface = self.tokenFailureGates[provider]?
3511
- .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true
3512
- if shouldSurface {
3513
- self.tokenErrors[provider] = error.localizedDescription
3514
- self.tokenSnapshots.removeValue(forKey: provider)
3515
- } else {
3516
- self.tokenErrors[provider] = nil
3517
- }
3518
- }
3519
- }
3520
-
3521
- // MARK: - Performance Tracking
3522
-
3523
- /// Track API call latency for performance monitoring
3524
- private nonisolated func trackLatency(
3525
- provider: UsageProvider,
3526
- requestID: String,
3527
- startTime: Date,
3528
- endTime: Date,
3529
- success: Bool
3530
- ) async {
3531
- guard let storage = self.performanceStorage else { return }
3532
-
3533
- let metric = LatencyMetric(
3534
- id: UUID().uuidString,
3535
- requestID: requestID,
3536
- provider: provider,
3537
- model: nil,
3538
- startTime: startTime,
3539
- endTime: endTime,
3540
- durationMs: Int(endTime.timeIntervalSince(startTime) * 1000),
3541
- success: success,
3542
- createdAt: Date()
3543
- )
3544
-
3545
- try? await storage.save(latency: metric)
3546
- }
3547
-
3548
- /// Track API errors for performance monitoring
3549
- private nonisolated func trackError(provider: UsageProvider, error: Error) async {
3550
- guard let storage = self.performanceStorage else { return }
3551
-
3552
- let errorType = self.classifyError(error)
3553
- let errorEvent = ErrorEvent(
3554
- id: UUID().uuidString,
3555
- provider: provider,
3556
- errorType: errorType,
3557
- errorMessage: error.localizedDescription,
3558
- retryCount: 0,
3559
- timestamp: Date()
3560
- )
3561
-
3562
- try? await storage.save(error: errorEvent)
3563
- }
3564
-
3565
- /// Classify errors for performance tracking
3566
- private nonisolated func classifyError(_ error: Error) -> ErrorType {
3567
- let message = error.localizedDescription.lowercased()
3568
-
3569
- // Timeout errors
3570
- if message.contains("timed out") || message.contains("timeout") {
3571
- return .timeout
3572
- }
3573
-
3574
- // Quota/rate limit errors
3575
- if message.contains("quota") || message.contains("rate limit") || message.contains("429") {
3576
- return .quota
3577
- }
3578
-
3579
- // Authentication errors
3580
- if message.contains("auth") || message.contains("unauthorized") ||
3581
- message.contains("401") || message.contains("403")
3582
- {
3583
- return .auth
3584
- }
3585
-
3586
- // Network errors
3587
- if message.contains("network") || message.contains("connection") ||
3588
- message.contains("offline") || message.contains("no internet")
3589
- {
3590
- return .network
3591
- }
3592
-
3593
- // Parsing errors
3594
- if message.contains("json") || message.contains("decode") || message.contains("parse") {
3595
- return .parsing
3596
- }
3597
-
3598
- // API errors
3599
- if message.contains("api") || message.contains("server") {
3600
- return .apiError
3601
- }
3602
-
3603
- return .unknown
3604
- }
3605
- }