@sriinnu/runic 1.0.0

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 (584) hide show
  1. package/.swiftformat +50 -0
  2. package/.swiftlint.yml +160 -0
  3. package/CHANGELOG.md +307 -0
  4. package/CONTRIBUTING.md +456 -0
  5. package/Icon.icns +0 -0
  6. package/Icon.icon/Assets/runic.png +0 -0
  7. package/Icon.icon/icon.json +36 -0
  8. package/LICENSE +21 -0
  9. package/Package.resolved +51 -0
  10. package/Package.swift +136 -0
  11. package/Packages/Helix/LICENSE +21 -0
  12. package/Packages/Helix/Package.swift +29 -0
  13. package/Packages/Helix/README.md +976 -0
  14. package/Packages/Helix/Sources/Helix/Binding.swift +111 -0
  15. package/Packages/Helix/Sources/Helix/CommandParser.swift +160 -0
  16. package/Packages/Helix/Sources/Helix/CommandSignature.swift +117 -0
  17. package/Packages/Helix/Sources/Helix/ExpressibleFromArgument.swift +72 -0
  18. package/Packages/Helix/Sources/Helix/HelpGenerator.swift +175 -0
  19. package/Packages/Helix/Sources/Helix/NameSpecification.swift +98 -0
  20. package/Packages/Helix/Sources/Helix/ParsableCommand.swift +204 -0
  21. package/Packages/Helix/Sources/Helix/Platform/DarwinPlatformContext.swift +44 -0
  22. package/Packages/Helix/Sources/Helix/Platform/LinuxPlatformContext.swift +34 -0
  23. package/Packages/Helix/Sources/Helix/Platform/PlatformContext+Default.swift +91 -0
  24. package/Packages/Helix/Sources/Helix/Platform/PlatformContext.swift +94 -0
  25. package/Packages/Helix/Sources/Helix/Platform/PlatformPath.swift +167 -0
  26. package/Packages/Helix/Sources/Helix/Platform/Stdio.swift +99 -0
  27. package/Packages/Helix/Sources/Helix/Platform/WebPlatformContext.swift +146 -0
  28. package/Packages/Helix/Sources/Helix/Platform/WindowsPlatformContext.swift +97 -0
  29. package/Packages/Helix/Sources/Helix/Program.swift +204 -0
  30. package/Packages/Helix/Sources/Helix/PropertyWrappers.swift +367 -0
  31. package/Packages/Silo/LICENSE +21 -0
  32. package/Packages/Silo/Package.swift +34 -0
  33. package/Packages/Silo/README.md +466 -0
  34. package/Packages/Silo/Sources/Silo/BinaryCookiesReader.swift +169 -0
  35. package/Packages/Silo/Sources/Silo/BrowserCookieAnalytics.swift +365 -0
  36. package/Packages/Silo/Sources/Silo/BrowserCookieBackends.swift +594 -0
  37. package/Packages/Silo/Sources/Silo/BrowserCookieClient.swift +326 -0
  38. package/Packages/Silo/Sources/Silo/BrowserCookieExport.swift +154 -0
  39. package/Packages/Silo/Sources/Silo/BrowserCookieImport.swift +119 -0
  40. package/Packages/Silo/Sources/Silo/BrowserCookieMockStore.swift +121 -0
  41. package/Packages/Silo/Sources/Silo/BrowserCookieModels.swift +391 -0
  42. package/Packages/Silo/Sources/Silo/ChromiumCookieReader.swift +240 -0
  43. package/Packages/Silo/Sources/Silo/CryptoSupport.swift +86 -0
  44. package/Packages/Silo/Sources/Silo/FirefoxCookieReader.swift +172 -0
  45. package/Packages/Silo/Sources/Silo/LinuxChromiumCookieReader.swift +172 -0
  46. package/Packages/Silo/Sources/Silo/LinuxSecretService.swift +105 -0
  47. package/Packages/Silo/Sources/Silo/MacOSChromiumCookieReader.swift +191 -0
  48. package/Packages/Silo/Sources/Silo/SQLiteSnapshot.swift +41 -0
  49. package/Packages/Silo/Sources/Silo/WindowsChromiumCookieReader.swift +110 -0
  50. package/README.md +109 -0
  51. package/RuniciOS/App/RuniciOSApp.swift +105 -0
  52. package/RuniciOS/Core/iOSUsageStore.swift +192 -0
  53. package/RuniciOS/Views/ProviderDetailView.swift +312 -0
  54. package/RuniciOS/Views/ProviderListView.swift +228 -0
  55. package/RuniciOS/Widgets/LargeWidget.swift +367 -0
  56. package/RuniciOS/Widgets/LiveActivity.swift +342 -0
  57. package/RuniciOS/Widgets/LockScreenWidget.swift +273 -0
  58. package/RuniciOS/Widgets/MediumWidget.swift +370 -0
  59. package/RuniciOS/Widgets/RunicWidget.swift +274 -0
  60. package/RuniciOS/Widgets/SmallWidget.swift +281 -0
  61. package/RuniciOS/Widgets/WidgetModels.swift +285 -0
  62. package/RuniciOS/Widgets/WidgetTimeline.swift +270 -0
  63. package/Scripts/CREATION-SUMMARY.md +367 -0
  64. package/Scripts/DIRECTORY-STRUCTURE.md +358 -0
  65. package/Scripts/GETTING-STARTED-CHECKLIST.md +364 -0
  66. package/Scripts/INDEX.md +365 -0
  67. package/Scripts/QUICK-REFERENCE.md +164 -0
  68. package/Scripts/README-SCRIPTS.md +926 -0
  69. package/Scripts/build-android.sh +526 -0
  70. package/Scripts/build-ios.sh +529 -0
  71. package/Scripts/build-macos.sh +603 -0
  72. package/Scripts/build-windows.sh +504 -0
  73. package/Scripts/build_icon.sh +49 -0
  74. package/Scripts/changelog-to-html.sh +89 -0
  75. package/Scripts/check-release-assets.sh +10 -0
  76. package/Scripts/ci-config.yml +474 -0
  77. package/Scripts/compile_and_run.sh +219 -0
  78. package/Scripts/compile_and_run_adhoc.sh +4 -0
  79. package/Scripts/docs-list.mjs +119 -0
  80. package/Scripts/make_appcast.sh +58 -0
  81. package/Scripts/package_app.sh +409 -0
  82. package/Scripts/release-lib.sh +214 -0
  83. package/Scripts/release.sh +76 -0
  84. package/Scripts/setup-all.sh +387 -0
  85. package/Scripts/setup-ios.sh +404 -0
  86. package/Scripts/setup-react-native.sh +523 -0
  87. package/Scripts/sign-and-notarize.sh +122 -0
  88. package/Scripts/test_live_update.sh +38 -0
  89. package/Scripts/validate_changelog.sh +23 -0
  90. package/Scripts/verify_appcast.sh +92 -0
  91. package/Sources/Runic/About.swift +93 -0
  92. package/Sources/Runic/AppNotifications.swift +115 -0
  93. package/Sources/Runic/Controllers/StatusItemController+Actions.swift +657 -0
  94. package/Sources/Runic/Controllers/StatusItemController+Animation.swift +518 -0
  95. package/Sources/Runic/Controllers/StatusItemController+ChartSubmenus.swift +810 -0
  96. package/Sources/Runic/Controllers/StatusItemController+CustomProviders.swift +87 -0
  97. package/Sources/Runic/Controllers/StatusItemController+Menu.swift +1379 -0
  98. package/Sources/Runic/Controllers/StatusItemController+MenuCards.swift +337 -0
  99. package/Sources/Runic/Controllers/StatusItemController+MenuHelpers.swift +395 -0
  100. package/Sources/Runic/Controllers/StatusItemController.swift +360 -0
  101. package/Sources/Runic/Core/BudgetNotificationManager.swift +80 -0
  102. package/Sources/Runic/Core/Login/ClaudeLoginRunner.swift +103 -0
  103. package/Sources/Runic/Core/Login/CodexLoginRunner.swift +154 -0
  104. package/Sources/Runic/Core/Login/CursorLoginRunner.swift +206 -0
  105. package/Sources/Runic/Core/Login/GeminiLoginRunner.swift +95 -0
  106. package/Sources/Runic/Core/Rendering/IconRenderer.swift +1216 -0
  107. package/Sources/Runic/Core/Rendering/IconView.swift +120 -0
  108. package/Sources/Runic/Core/Rendering/ProviderBrandIcon.swift +71 -0
  109. package/Sources/Runic/Core/RunicAnimation.swift +53 -0
  110. package/Sources/Runic/Core/RunicColors.swift +73 -0
  111. package/Sources/Runic/Core/RunicCornerRadius.swift +15 -0
  112. package/Sources/Runic/Core/RunicSpacing.swift +68 -0
  113. package/Sources/Runic/Core/Stores/AuggieTokenStore.swift +143 -0
  114. package/Sources/Runic/Core/Stores/AzureOpenAITokenStore.swift +143 -0
  115. package/Sources/Runic/Core/Stores/CerebrasTokenStore.swift +143 -0
  116. package/Sources/Runic/Core/Stores/CohereTokenStore.swift +143 -0
  117. package/Sources/Runic/Core/Stores/CopilotTokenStore.swift +143 -0
  118. package/Sources/Runic/Core/Stores/CustomProviderStore.swift +158 -0
  119. package/Sources/Runic/Core/Stores/DeepSeekTokenStore.swift +143 -0
  120. package/Sources/Runic/Core/Stores/FireworksTokenStore.swift +143 -0
  121. package/Sources/Runic/Core/Stores/GroqTokenStore.swift +143 -0
  122. package/Sources/Runic/Core/Stores/KimiTokenStore.swift +143 -0
  123. package/Sources/Runic/Core/Stores/MiniMaxCookieHeaderStore.swift +143 -0
  124. package/Sources/Runic/Core/Stores/MiniMaxGroupIDStore.swift +143 -0
  125. package/Sources/Runic/Core/Stores/MiniMaxTokenStore.swift +143 -0
  126. package/Sources/Runic/Core/Stores/MistralTokenStore.swift +143 -0
  127. package/Sources/Runic/Core/Stores/OpenRouterTokenStore.swift +143 -0
  128. package/Sources/Runic/Core/Stores/PerplexityTokenStore.swift +143 -0
  129. package/Sources/Runic/Core/Stores/ProviderToggleStore.swift +30 -0
  130. package/Sources/Runic/Core/Stores/QwenTokenStore.swift +143 -0
  131. package/Sources/Runic/Core/Stores/SambaNovaTokenStore.swift +143 -0
  132. package/Sources/Runic/Core/Stores/SettingsStore.swift +1702 -0
  133. package/Sources/Runic/Core/Stores/TogetherTokenStore.swift +143 -0
  134. package/Sources/Runic/Core/Stores/UsageStore+Accessors.swift +20 -0
  135. package/Sources/Runic/Core/Stores/UsageStore+Status.swift +189 -0
  136. package/Sources/Runic/Core/Stores/UsageStore+TokenCost.swift +33 -0
  137. package/Sources/Runic/Core/Stores/UsageStore+WidgetSnapshot.swift +67 -0
  138. package/Sources/Runic/Core/Stores/UsageStore.swift +3564 -0
  139. package/Sources/Runic/Core/Stores/XAITokenStore.swift +143 -0
  140. package/Sources/Runic/Core/Stores/ZaiTokenStore.swift +143 -0
  141. package/Sources/Runic/Core/UsageExporter.swift +193 -0
  142. package/Sources/Runic/Date+RelativeDescription.swift +21 -0
  143. package/Sources/Runic/HiddenWindowView.swift +32 -0
  144. package/Sources/Runic/InstallOrigin.swift +9 -0
  145. package/Sources/Runic/KeyboardShortcuts+Names.swift +6 -0
  146. package/Sources/Runic/MouseLocationReader.swift +65 -0
  147. package/Sources/Runic/Notifications+Runic.swift +6 -0
  148. package/Sources/Runic/OpenAICreditsPurchaseWindowController.swift +503 -0
  149. package/Sources/Runic/ProviderRegistry.swift +100 -0
  150. package/Sources/Runic/ProviderSwitcherButtons.swift +267 -0
  151. package/Sources/Runic/Providers/Antigravity/AntigravityLoginFlow.swift +11 -0
  152. package/Sources/Runic/Providers/Antigravity/AntigravityProviderImplementation.swift +14 -0
  153. package/Sources/Runic/Providers/Auggie/AuggieProviderImplementation.swift +24 -0
  154. package/Sources/Runic/Providers/Azure/AzureProviderImplementation.swift +52 -0
  155. package/Sources/Runic/Providers/Bedrock/BedrockProviderImplementation.swift +42 -0
  156. package/Sources/Runic/Providers/Cerebras/CerebrasProviderImplementation.swift +24 -0
  157. package/Sources/Runic/Providers/Claude/ClaudeLoginFlow.swift +26 -0
  158. package/Sources/Runic/Providers/Claude/ClaudeProviderImplementation.swift +14 -0
  159. package/Sources/Runic/Providers/Codex/CodexLoginFlow.swift +18 -0
  160. package/Sources/Runic/Providers/Codex/CodexProviderImplementation.swift +15 -0
  161. package/Sources/Runic/Providers/Cohere/CohereProviderImplementation.swift +24 -0
  162. package/Sources/Runic/Providers/Copilot/CopilotLoginFlow.swift +193 -0
  163. package/Sources/Runic/Providers/Copilot/CopilotProviderImplementation.swift +110 -0
  164. package/Sources/Runic/Providers/Cursor/CursorLoginFlow.swift +28 -0
  165. package/Sources/Runic/Providers/Cursor/CursorProviderImplementation.swift +15 -0
  166. package/Sources/Runic/Providers/DeepSeek/DeepSeekProviderImplementation.swift +24 -0
  167. package/Sources/Runic/Providers/Factory/FactoryLoginFlow.swift +12 -0
  168. package/Sources/Runic/Providers/Factory/FactoryProviderImplementation.swift +15 -0
  169. package/Sources/Runic/Providers/Fireworks/FireworksProviderImplementation.swift +24 -0
  170. package/Sources/Runic/Providers/Gemini/GeminiLoginFlow.swift +20 -0
  171. package/Sources/Runic/Providers/Gemini/GeminiProviderImplementation.swift +15 -0
  172. package/Sources/Runic/Providers/Groq/GroqProviderImplementation.swift +24 -0
  173. package/Sources/Runic/Providers/Kimi/KimiProviderImplementation.swift +24 -0
  174. package/Sources/Runic/Providers/MiniMax/MiniMaxProviderImplementation.swift +32 -0
  175. package/Sources/Runic/Providers/Mistral/MistralProviderImplementation.swift +24 -0
  176. package/Sources/Runic/Providers/OpenRouter/OpenRouterProviderImplementation.swift +24 -0
  177. package/Sources/Runic/Providers/Perplexity/PerplexityProviderImplementation.swift +24 -0
  178. package/Sources/Runic/Providers/SambaNova/SambaNovaProviderImplementation.swift +24 -0
  179. package/Sources/Runic/Providers/Shared/ProviderCatalog.swift +15 -0
  180. package/Sources/Runic/Providers/Shared/ProviderImplementation.swift +48 -0
  181. package/Sources/Runic/Providers/Shared/ProviderImplementationRegistry.swift +66 -0
  182. package/Sources/Runic/Providers/Shared/ProviderLoginFlow.swift +12 -0
  183. package/Sources/Runic/Providers/Shared/ProviderSettingsDescriptors.swift +99 -0
  184. package/Sources/Runic/Providers/Shared/SystemSettingsLinks.swift +18 -0
  185. package/Sources/Runic/Providers/Together/TogetherProviderImplementation.swift +24 -0
  186. package/Sources/Runic/Providers/XAI/XAIProviderImplementation.swift +24 -0
  187. package/Sources/Runic/Providers/Zai/ZaiProviderImplementation.swift +24 -0
  188. package/Sources/Runic/Resources/ProviderIcon-antigravity.svg +2 -0
  189. package/Sources/Runic/Resources/ProviderIcon-auggie.svg +3 -0
  190. package/Sources/Runic/Resources/ProviderIcon-azure.svg +5 -0
  191. package/Sources/Runic/Resources/ProviderIcon-bedrock.svg +7 -0
  192. package/Sources/Runic/Resources/ProviderIcon-cerebras.svg +3 -0
  193. package/Sources/Runic/Resources/ProviderIcon-claude.svg +1 -0
  194. package/Sources/Runic/Resources/ProviderIcon-codex.svg +1 -0
  195. package/Sources/Runic/Resources/ProviderIcon-cohere.svg +3 -0
  196. package/Sources/Runic/Resources/ProviderIcon-copilot.svg +1 -0
  197. package/Sources/Runic/Resources/ProviderIcon-cursor.svg +3 -0
  198. package/Sources/Runic/Resources/ProviderIcon-deepseek.svg +4 -0
  199. package/Sources/Runic/Resources/ProviderIcon-factory.svg +3 -0
  200. package/Sources/Runic/Resources/ProviderIcon-fireworks.svg +3 -0
  201. package/Sources/Runic/Resources/ProviderIcon-gemini.svg +1 -0
  202. package/Sources/Runic/Resources/ProviderIcon-groq.svg +1 -0
  203. package/Sources/Runic/Resources/ProviderIcon-kimi.svg +4 -0
  204. package/Sources/Runic/Resources/ProviderIcon-minimax.svg +1 -0
  205. package/Sources/Runic/Resources/ProviderIcon-mistral.svg +5 -0
  206. package/Sources/Runic/Resources/ProviderIcon-openrouter.svg +7 -0
  207. package/Sources/Runic/Resources/ProviderIcon-perplexity.svg +4 -0
  208. package/Sources/Runic/Resources/ProviderIcon-qwen.svg +9 -0
  209. package/Sources/Runic/Resources/ProviderIcon-sambanova.svg +4 -0
  210. package/Sources/Runic/Resources/ProviderIcon-together.svg +5 -0
  211. package/Sources/Runic/Resources/ProviderIcon-vertexai.svg +17 -0
  212. package/Sources/Runic/Resources/ProviderIcon-xai.svg +3 -0
  213. package/Sources/Runic/Resources/ProviderIcon-zai.svg +5 -0
  214. package/Sources/Runic/Resources/RunicMenubarIcon.svg +7 -0
  215. package/Sources/Runic/RunicApp.swift +357 -0
  216. package/Sources/Runic/SessionQuotaNotifications.swift +58 -0
  217. package/Sources/Runic/UsagePaceText.swift +52 -0
  218. package/Sources/Runic/UsageProgressBar.swift +224 -0
  219. package/Sources/Runic/Utilities/DisplayLink.swift +96 -0
  220. package/Sources/Runic/Utilities/LoadingPattern.swift +60 -0
  221. package/Sources/Runic/Utilities/PerformanceConstants.swift +85 -0
  222. package/Sources/Runic/Views/Menu/AlertsMenuView.swift +256 -0
  223. package/Sources/Runic/Views/Menu/ChartPanelStyle.swift +22 -0
  224. package/Sources/Runic/Views/Menu/CostComparativeChartMenuView.swift +275 -0
  225. package/Sources/Runic/Views/Menu/CostHistoryChartMenuView.swift +377 -0
  226. package/Sources/Runic/Views/Menu/CreditsHistoryChartMenuView.swift +312 -0
  227. package/Sources/Runic/Views/Menu/EfficiencyMetricsMenuView.swift +312 -0
  228. package/Sources/Runic/Views/Menu/HeroStatCardsMenuView.swift +228 -0
  229. package/Sources/Runic/Views/Menu/HourlyActivityChartMenuView.swift +228 -0
  230. package/Sources/Runic/Views/Menu/InlineUsageChartView.swift +162 -0
  231. package/Sources/Runic/Views/Menu/MenuCardEntranceModifier.swift +64 -0
  232. package/Sources/Runic/Views/Menu/MenuCardSidebarView.swift +268 -0
  233. package/Sources/Runic/Views/Menu/MenuCardSkeletonView.swift +104 -0
  234. package/Sources/Runic/Views/Menu/MenuCardView.swift +1900 -0
  235. package/Sources/Runic/Views/Menu/MenuContent.swift +150 -0
  236. package/Sources/Runic/Views/Menu/MenuDescriptor.swift +369 -0
  237. package/Sources/Runic/Views/Menu/MenuHighlightStyle.swift +42 -0
  238. package/Sources/Runic/Views/Menu/ModelBreakdownMenuView.swift +235 -0
  239. package/Sources/Runic/Views/Menu/OverviewMenuView.swift +141 -0
  240. package/Sources/Runic/Views/Menu/ProjectBreakdownMenuView.swift +209 -0
  241. package/Sources/Runic/Views/Menu/ProjectBudgetMenuView.swift +220 -0
  242. package/Sources/Runic/Views/Menu/ProviderTabBarView.swift +88 -0
  243. package/Sources/Runic/Views/Menu/SubscriptionUtilizationChartMenuView.swift +201 -0
  244. package/Sources/Runic/Views/Menu/UsageBreakdownChartMenuView.swift +398 -0
  245. package/Sources/Runic/Views/Menu/UsageHeatmapMenuView.swift +232 -0
  246. package/Sources/Runic/Views/Menu/UsageTimelineChartMenuView.swift +417 -0
  247. package/Sources/Runic/Views/Menu/UsageWindowComparisonChartMenuView.swift +190 -0
  248. package/Sources/Runic/Views/Menu/WeeklyActivityChartMenuView.swift +132 -0
  249. package/Sources/Runic/Views/Performance/PerformanceDashboardView.swift +618 -0
  250. package/Sources/Runic/Views/Preferences/LiquidComponents.swift +305 -0
  251. package/Sources/Runic/Views/Preferences/PreferencesAboutPane.swift +271 -0
  252. package/Sources/Runic/Views/Preferences/PreferencesAdvancedPane.swift +268 -0
  253. package/Sources/Runic/Views/Preferences/PreferencesAlertsPane.swift +422 -0
  254. package/Sources/Runic/Views/Preferences/PreferencesAnalyticsPane.swift +641 -0
  255. package/Sources/Runic/Views/Preferences/PreferencesBudgetsPane.swift +344 -0
  256. package/Sources/Runic/Views/Preferences/PreferencesComponents.swift +230 -0
  257. package/Sources/Runic/Views/Preferences/PreferencesCustomProvidersPane.swift +322 -0
  258. package/Sources/Runic/Views/Preferences/PreferencesDebugPane.swift +469 -0
  259. package/Sources/Runic/Views/Preferences/PreferencesGeneralPane.swift +231 -0
  260. package/Sources/Runic/Views/Preferences/PreferencesHelpPane.swift +50 -0
  261. package/Sources/Runic/Views/Preferences/PreferencesIntegrationsPane.swift +497 -0
  262. package/Sources/Runic/Views/Preferences/PreferencesPerformancePane.swift +288 -0
  263. package/Sources/Runic/Views/Preferences/PreferencesProvidersPane.swift +3435 -0
  264. package/Sources/Runic/Views/Preferences/PreferencesSelection.swift +25 -0
  265. package/Sources/Runic/Views/Preferences/PreferencesSyncPane.swift +59 -0
  266. package/Sources/Runic/Views/Preferences/PreferencesView.swift +206 -0
  267. package/Sources/Runic/Views/Rating/QualityRatingView.swift +238 -0
  268. package/Sources/Runic/Views/Team/TeamDashboardView.swift +371 -0
  269. package/Sources/Runic/Views/Team/TeamInviteSheet.swift +175 -0
  270. package/Sources/Runic/Views/Team/TeamManagementView.swift +761 -0
  271. package/Sources/Runic/Views/Team/TeamMemberRow.swift +181 -0
  272. package/Sources/RunicCLI/CLIEntry.swift +942 -0
  273. package/Sources/RunicCLI/CLIRenderer.swift +87 -0
  274. package/Sources/RunicCLI/Commands/EnhancedUsageCommand.swift +588 -0
  275. package/Sources/RunicCLI/Commands/ModelsCommand.swift +362 -0
  276. package/Sources/RunicCLI/Commands/ProjectsCommand.swift +439 -0
  277. package/Sources/RunicCLI/Commands/ResetCommand.swift +429 -0
  278. package/Sources/RunicCLI/Commands/UsageLedgerSourceFactory.swift +168 -0
  279. package/Sources/RunicClaudeWatchdog/main.swift +122 -0
  280. package/Sources/RunicClaudeWebProbe/main.swift +62 -0
  281. package/Sources/RunicCore/AlertRuleStore.swift +187 -0
  282. package/Sources/RunicCore/BrowserCookieImportOrder.swift +25 -0
  283. package/Sources/RunicCore/CopilotUsageModels.swift +84 -0
  284. package/Sources/RunicCore/CostUsageFetcher.swift +119 -0
  285. package/Sources/RunicCore/CostUsageModels.swift +479 -0
  286. package/Sources/RunicCore/CreditsModels.swift +50 -0
  287. package/Sources/RunicCore/CustomProviders/CustomProviderModels.swift +269 -0
  288. package/Sources/RunicCore/CustomProviders/GenericProviderFetcher.swift +622 -0
  289. package/Sources/RunicCore/CustomProviders/ProviderTemplates.swift +500 -0
  290. package/Sources/RunicCore/Host/PTY/TTYCommandRunner.swift +679 -0
  291. package/Sources/RunicCore/Host/Process/SubprocessRunner.swift +135 -0
  292. package/Sources/RunicCore/Integrations/GitHubIntegration.swift +173 -0
  293. package/Sources/RunicCore/Logging/JSONStderrLogHandler.swift +79 -0
  294. package/Sources/RunicCore/Logging/OSLogLogHandler.swift +86 -0
  295. package/Sources/RunicCore/Logging/RunicLog.swift +123 -0
  296. package/Sources/RunicCore/Models/EnhancedUsageModels.swift +371 -0
  297. package/Sources/RunicCore/OpenAIDashboardModels.swift +210 -0
  298. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift +676 -0
  299. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardFetcher.swift +492 -0
  300. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift +51 -0
  301. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardParser.swift +483 -0
  302. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift +558 -0
  303. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift +204 -0
  304. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardWebsiteDataStore.swift +78 -0
  305. package/Sources/RunicCore/PathEnvironment.swift +482 -0
  306. package/Sources/RunicCore/Performance/PerformanceModels.swift +371 -0
  307. package/Sources/RunicCore/Performance/PerformanceStorageImpl.swift +409 -0
  308. package/Sources/RunicCore/Policy/UsagePolicyEngine.swift +178 -0
  309. package/Sources/RunicCore/ProjectBudgetStore.swift +99 -0
  310. package/Sources/RunicCore/ProviderCostSnapshot.swift +29 -0
  311. package/Sources/RunicCore/Providers/Antigravity/AntigravityProviderDescriptor.swift +66 -0
  312. package/Sources/RunicCore/Providers/Antigravity/AntigravityStatusProbe.swift +685 -0
  313. package/Sources/RunicCore/Providers/Auggie/AuggieProviderDescriptor.swift +83 -0
  314. package/Sources/RunicCore/Providers/Auggie/AuggieUsageFetcher.swift +142 -0
  315. package/Sources/RunicCore/Providers/Azure/AzureProviderDescriptor.swift +141 -0
  316. package/Sources/RunicCore/Providers/Azure/AzureUsageFetcher.swift +186 -0
  317. package/Sources/RunicCore/Providers/Bedrock/BedrockProviderDescriptor.swift +126 -0
  318. package/Sources/RunicCore/Providers/Bedrock/BedrockUsageFetcher.swift +168 -0
  319. package/Sources/RunicCore/Providers/Cerebras/CerebrasProviderDescriptor.swift +84 -0
  320. package/Sources/RunicCore/Providers/Cerebras/CerebrasUsageFetcher.swift +75 -0
  321. package/Sources/RunicCore/Providers/Claude/ClaudeCLISession.swift +332 -0
  322. package/Sources/RunicCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +180 -0
  323. package/Sources/RunicCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift +137 -0
  324. package/Sources/RunicCore/Providers/Claude/ClaudeProviderDescriptor.swift +207 -0
  325. package/Sources/RunicCore/Providers/Claude/ClaudeStatusProbe.swift +654 -0
  326. package/Sources/RunicCore/Providers/Claude/ClaudeUsageDataSource.swift +28 -0
  327. package/Sources/RunicCore/Providers/Claude/ClaudeUsageFetcher.swift +480 -0
  328. package/Sources/RunicCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +780 -0
  329. package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +146 -0
  330. package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift +269 -0
  331. package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift +100 -0
  332. package/Sources/RunicCore/Providers/Codex/CodexProviderDescriptor.swift +243 -0
  333. package/Sources/RunicCore/Providers/Codex/CodexStatusProbe.swift +125 -0
  334. package/Sources/RunicCore/Providers/Codex/CodexUsageDataSource.swift +24 -0
  335. package/Sources/RunicCore/Providers/Codex/CodexWebDashboardStrategy.swift +181 -0
  336. package/Sources/RunicCore/Providers/Cohere/CohereProviderDescriptor.swift +83 -0
  337. package/Sources/RunicCore/Providers/Cohere/CohereUsageFetcher.swift +85 -0
  338. package/Sources/RunicCore/Providers/Copilot/CopilotDeviceFlow.swift +123 -0
  339. package/Sources/RunicCore/Providers/Copilot/CopilotGitHubCLITokenReader.swift +114 -0
  340. package/Sources/RunicCore/Providers/Copilot/CopilotProviderDescriptor.swift +100 -0
  341. package/Sources/RunicCore/Providers/Copilot/CopilotUsageFetcher.swift +291 -0
  342. package/Sources/RunicCore/Providers/Copilot/CopilotVSCodeTokenReader.swift +386 -0
  343. package/Sources/RunicCore/Providers/Cursor/CursorProviderDescriptor.swift +65 -0
  344. package/Sources/RunicCore/Providers/Cursor/CursorStatusProbe.swift +594 -0
  345. package/Sources/RunicCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift +85 -0
  346. package/Sources/RunicCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift +108 -0
  347. package/Sources/RunicCore/Providers/Factory/FactoryLocalStorageImporter.swift +398 -0
  348. package/Sources/RunicCore/Providers/Factory/FactoryProviderDescriptor.swift +65 -0
  349. package/Sources/RunicCore/Providers/Factory/FactoryStatusProbe.swift +1275 -0
  350. package/Sources/RunicCore/Providers/Fireworks/FireworksProviderDescriptor.swift +84 -0
  351. package/Sources/RunicCore/Providers/Fireworks/FireworksUsageFetcher.swift +75 -0
  352. package/Sources/RunicCore/Providers/Gemini/GeminiProviderDescriptor.swift +65 -0
  353. package/Sources/RunicCore/Providers/Gemini/GeminiStatusProbe.swift +835 -0
  354. package/Sources/RunicCore/Providers/Groq/GroqProviderDescriptor.swift +83 -0
  355. package/Sources/RunicCore/Providers/Groq/GroqUsageFetcher.swift +71 -0
  356. package/Sources/RunicCore/Providers/Kimi/KimiProviderDescriptor.swift +83 -0
  357. package/Sources/RunicCore/Providers/Kimi/KimiUsageFetcher.swift +77 -0
  358. package/Sources/RunicCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +92 -0
  359. package/Sources/RunicCore/Providers/MiniMax/MiniMaxSettingsReader.swift +54 -0
  360. package/Sources/RunicCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +171 -0
  361. package/Sources/RunicCore/Providers/MiniMax/MiniMaxWebUsageFetcher.swift +911 -0
  362. package/Sources/RunicCore/Providers/Mistral/MistralProviderDescriptor.swift +84 -0
  363. package/Sources/RunicCore/Providers/Mistral/MistralUsageFetcher.swift +75 -0
  364. package/Sources/RunicCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift +87 -0
  365. package/Sources/RunicCore/Providers/OpenRouter/OpenRouterUsageFetcher.swift +207 -0
  366. package/Sources/RunicCore/Providers/Perplexity/PerplexityProviderDescriptor.swift +84 -0
  367. package/Sources/RunicCore/Providers/Perplexity/PerplexityUsageFetcher.swift +75 -0
  368. package/Sources/RunicCore/Providers/ProviderBranding.swift +25 -0
  369. package/Sources/RunicCore/Providers/ProviderCLIConfig.swift +17 -0
  370. package/Sources/RunicCore/Providers/ProviderDescriptor.swift +129 -0
  371. package/Sources/RunicCore/Providers/ProviderFetchPlan.swift +263 -0
  372. package/Sources/RunicCore/Providers/ProviderSettingsSnapshot.swift +98 -0
  373. package/Sources/RunicCore/Providers/ProviderTokenResolver.swift +501 -0
  374. package/Sources/RunicCore/Providers/ProviderVersionDetector.swift +68 -0
  375. package/Sources/RunicCore/Providers/Providers.swift +184 -0
  376. package/Sources/RunicCore/Providers/Qwen/QwenProviderDescriptor.swift +84 -0
  377. package/Sources/RunicCore/Providers/Qwen/QwenSettingsReader.swift +30 -0
  378. package/Sources/RunicCore/Providers/Qwen/QwenUsageStats.swift +317 -0
  379. package/Sources/RunicCore/Providers/SambaNova/SambaNovaProviderDescriptor.swift +84 -0
  380. package/Sources/RunicCore/Providers/SambaNova/SambaNovaUsageFetcher.swift +75 -0
  381. package/Sources/RunicCore/Providers/Together/TogetherProviderDescriptor.swift +83 -0
  382. package/Sources/RunicCore/Providers/Together/TogetherUsageFetcher.swift +75 -0
  383. package/Sources/RunicCore/Providers/VertexAI/VertexAIProviderDescriptor.swift +123 -0
  384. package/Sources/RunicCore/Providers/VertexAI/VertexAISettingsReader.swift +48 -0
  385. package/Sources/RunicCore/Providers/VertexAI/VertexAIUsageFetcher.swift +144 -0
  386. package/Sources/RunicCore/Providers/XAI/XAIProviderDescriptor.swift +83 -0
  387. package/Sources/RunicCore/Providers/XAI/XAIUsageFetcher.swift +75 -0
  388. package/Sources/RunicCore/Providers/Zai/ZaiProviderDescriptor.swift +72 -0
  389. package/Sources/RunicCore/Providers/Zai/ZaiSettingsReader.swift +41 -0
  390. package/Sources/RunicCore/Providers/Zai/ZaiUsageStats.swift +724 -0
  391. package/Sources/RunicCore/Sync/BackgroundSyncManager.swift +385 -0
  392. package/Sources/RunicCore/Sync/SyncConflictResolver.swift +310 -0
  393. package/Sources/RunicCore/Sync/SyncProtocol.swift +288 -0
  394. package/Sources/RunicCore/Sync/SyncRecord.swift +405 -0
  395. package/Sources/RunicCore/Sync/SyncUsageExample.swift +294 -0
  396. package/Sources/RunicCore/Sync/TeamSyncRecords.swift +775 -0
  397. package/Sources/RunicCore/Sync/iCloudSyncEngine.swift +333 -0
  398. package/Sources/RunicCore/Team/TeamModels.swift +583 -0
  399. package/Sources/RunicCore/Team/TeamShowbackExporter.swift +495 -0
  400. package/Sources/RunicCore/Team/TeamStore.swift +792 -0
  401. package/Sources/RunicCore/TextParsing.swift +55 -0
  402. package/Sources/RunicCore/UsageFetcher.swift +694 -0
  403. package/Sources/RunicCore/UsageFormatter.swift +410 -0
  404. package/Sources/RunicCore/UsageLedger/ClaudeUsageLogSource.swift +329 -0
  405. package/Sources/RunicCore/UsageLedger/CodexUsageLogSource.swift +448 -0
  406. package/Sources/RunicCore/UsageLedger/OTelGenAILedgerAdapter.swift +550 -0
  407. package/Sources/RunicCore/UsageLedger/UsageLedger.swift +29 -0
  408. package/Sources/RunicCore/UsageLedger/UsageLedgerAggregator.swift +660 -0
  409. package/Sources/RunicCore/UsageLedger/UsageLedgerInsightsAdvisor.swift +209 -0
  410. package/Sources/RunicCore/UsageLedger/UsageLedgerModels.swift +417 -0
  411. package/Sources/RunicCore/UsageLedger/UsageLedgerProjectIdentity.swift +245 -0
  412. package/Sources/RunicCore/UsagePace.swift +81 -0
  413. package/Sources/RunicCore/Vendored/CostUsage/CostUsageCache.swift +73 -0
  414. package/Sources/RunicCore/Vendored/CostUsage/CostUsageJsonl.swift +74 -0
  415. package/Sources/RunicCore/Vendored/CostUsage/CostUsagePricing.swift +208 -0
  416. package/Sources/RunicCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +455 -0
  417. package/Sources/RunicCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift +132 -0
  418. package/Sources/RunicCore/Vendored/CostUsage/CostUsageScanner.swift +589 -0
  419. package/Sources/RunicCore/WidgetSnapshot.swift +183 -0
  420. package/Sources/RunicMacroSupport/ProviderRegistrationMacros.swift +14 -0
  421. package/Sources/RunicMacros/ProviderRegistrationMacros.swift +216 -0
  422. package/Sources/RunicWidget/RunicWidgetBundle.swift +79 -0
  423. package/Sources/RunicWidget/RunicWidgetProvider.swift +283 -0
  424. package/Sources/RunicWidget/RunicWidgetViews.swift +680 -0
  425. package/Tests/RunicTests/AntigravityStatusProbeTests.swift +50 -0
  426. package/Tests/RunicTests/AppDelegateTests.swift +43 -0
  427. package/Tests/RunicTests/BrowserCookieOrderLabelTests.swift +22 -0
  428. package/Tests/RunicTests/CLIArgumentParsingTests.swift +70 -0
  429. package/Tests/RunicTests/CLICostTests.swift +65 -0
  430. package/Tests/RunicTests/CLIProviderSelectionTests.swift +22 -0
  431. package/Tests/RunicTests/CLISnapshotTests.swift +72 -0
  432. package/Tests/RunicTests/CLIWebFallbackTests.swift +58 -0
  433. package/Tests/RunicTests/ClaudeOAuthTests.swift +164 -0
  434. package/Tests/RunicTests/ClaudeResilienceTests.swift +30 -0
  435. package/Tests/RunicTests/ClaudeUsageTests.swift +370 -0
  436. package/Tests/RunicTests/CodexOAuthTests.swift +122 -0
  437. package/Tests/RunicTests/CodexUsageLogSourceTests.swift +82 -0
  438. package/Tests/RunicTests/CopilotTokenResolverTests.swift +75 -0
  439. package/Tests/RunicTests/CopilotVSCodeTokenReaderTests.swift +75 -0
  440. package/Tests/RunicTests/CostUsageDecodingTests.swift +394 -0
  441. package/Tests/RunicTests/CostUsagePricingTests.swift +50 -0
  442. package/Tests/RunicTests/CostUsageScannerTests.swift +700 -0
  443. package/Tests/RunicTests/CursorStatusProbeTests.swift +323 -0
  444. package/Tests/RunicTests/FactoryStatusProbeTests.swift +69 -0
  445. package/Tests/RunicTests/GeminiAPITestHelpers.swift +94 -0
  446. package/Tests/RunicTests/GeminiLoginAlertTests.swift +28 -0
  447. package/Tests/RunicTests/GeminiStatusProbeAPITests.swift +283 -0
  448. package/Tests/RunicTests/GeminiStatusProbePlanTests.swift +279 -0
  449. package/Tests/RunicTests/GeminiStatusProbeTests.swift +233 -0
  450. package/Tests/RunicTests/GeminiTestEnvironment.swift +87 -0
  451. package/Tests/RunicTests/GoogleWorkspaceStatusTests.swift +73 -0
  452. package/Tests/RunicTests/InstallOriginTests.swift +18 -0
  453. package/Tests/RunicTests/KeyboardShortcutsBundleTests.swift +9 -0
  454. package/Tests/RunicTests/LiveAccountTests.swift +24 -0
  455. package/Tests/RunicTests/LoadingPatternTests.swift +35 -0
  456. package/Tests/RunicTests/MenuCardModelTests.swift +545 -0
  457. package/Tests/RunicTests/MiniMaxCookieHeaderStoreTestSupport.swift +8 -0
  458. package/Tests/RunicTests/MiniMaxGroupIDStoreTestSupport.swift +8 -0
  459. package/Tests/RunicTests/MiniMaxParsingTests.swift +68 -0
  460. package/Tests/RunicTests/MiniMaxTokenStoreTestSupport.swift +8 -0
  461. package/Tests/RunicTests/OTelGenAILedgerAdapterTests.swift +112 -0
  462. package/Tests/RunicTests/OpenAIDashboardBrowserCookieImporterTests.swift +17 -0
  463. package/Tests/RunicTests/OpenAIDashboardFetcherCreditsWaitTests.swift +75 -0
  464. package/Tests/RunicTests/OpenAIDashboardOffscreenHostTests.swift +27 -0
  465. package/Tests/RunicTests/OpenAIDashboardParserTests.swift +134 -0
  466. package/Tests/RunicTests/OpenAIWebAccountSwitchTests.swift +53 -0
  467. package/Tests/RunicTests/OpenRouterTokenStoreTestSupport.swift +92 -0
  468. package/Tests/RunicTests/PathBuilderTests.swift +237 -0
  469. package/Tests/RunicTests/ProviderFetchPipelineTests.swift +160 -0
  470. package/Tests/RunicTests/ProviderIconResourcesTests.swift +38 -0
  471. package/Tests/RunicTests/ProviderMetadataStatusLinkTests.swift +16 -0
  472. package/Tests/RunicTests/ProviderRegistryTests.swift +29 -0
  473. package/Tests/RunicTests/ProviderSettingsDescriptorTests.swift +202 -0
  474. package/Tests/RunicTests/ProviderToggleStoreTests.swift +51 -0
  475. package/Tests/RunicTests/RunicTests.swift +120 -0
  476. package/Tests/RunicTests/SessionQuotaNotificationLogicTests.swift +36 -0
  477. package/Tests/RunicTests/SettingsStoreTests.swift +161 -0
  478. package/Tests/RunicTests/StatusItemAnimationTests.swift +49 -0
  479. package/Tests/RunicTests/StatusMenuTests.swift +434 -0
  480. package/Tests/RunicTests/StatusProbeTests.swift +377 -0
  481. package/Tests/RunicTests/SubprocessRunnerTests.swift +20 -0
  482. package/Tests/RunicTests/SubscriptionDetectionTests.swift +65 -0
  483. package/Tests/RunicTests/TTYCommandRunnerTests.swift +148 -0
  484. package/Tests/RunicTests/TTYIntegrationTests.swift +41 -0
  485. package/Tests/RunicTests/TeamShowbackExporterTests.swift +159 -0
  486. package/Tests/RunicTests/TextParsingTests.swift +12 -0
  487. package/Tests/RunicTests/UsageFormatterTests.swift +112 -0
  488. package/Tests/RunicTests/UsageLedgerAnomalyDetectionTests.swift +172 -0
  489. package/Tests/RunicTests/UsageLedgerForecastTests.swift +213 -0
  490. package/Tests/RunicTests/UsageLedgerInsightsAdvisorTests.swift +83 -0
  491. package/Tests/RunicTests/UsageLedgerProjectNameTests.swift +170 -0
  492. package/Tests/RunicTests/UsagePaceTests.swift +77 -0
  493. package/Tests/RunicTests/UsagePaceTextTests.swift +109 -0
  494. package/Tests/RunicTests/UsagePolicyEngineTests.swift +91 -0
  495. package/Tests/RunicTests/WidgetSnapshotTests.swift +45 -0
  496. package/Tests/RunicTests/ZaiProviderTests.swift +61 -0
  497. package/Tests/RunicTests/ZaiTokenStoreTestSupport.swift +6 -0
  498. package/TestsLinux/PlatformGatingTests.swift +46 -0
  499. package/appcast.xml +105 -0
  500. package/assets/logo-concept-stack.svg +24 -0
  501. package/assets/logo-minimal-bars.svg +15 -0
  502. package/assets/logo.svg +20 -0
  503. package/assets/logo2.svg +43 -0
  504. package/bin/docs-list +5 -0
  505. package/bin/install-runic-cli.sh +32 -0
  506. package/build-and-run.sh +369 -0
  507. package/docs/.nojekyll +1 -0
  508. package/docs/CNAME +1 -0
  509. package/docs/antigravity.md +75 -0
  510. package/docs/architecture.md +32 -0
  511. package/docs/claude.md +93 -0
  512. package/docs/cli-commands/integration_guide.md +371 -0
  513. package/docs/cli-commands/quick_reference.md +362 -0
  514. package/docs/cli-commands/readme.md +361 -0
  515. package/docs/cli.md +156 -0
  516. package/docs/cloudkit_sync_guide.md +495 -0
  517. package/docs/codebase_review.md +288 -0
  518. package/docs/codex-oauth.md +635 -0
  519. package/docs/codex.md +104 -0
  520. package/docs/codexbar.png +0 -0
  521. package/docs/copilot.md +44 -0
  522. package/docs/cursor.md +46 -0
  523. package/docs/design/designcomparisonchart.md +510 -0
  524. package/docs/design/implementationguide.md +792 -0
  525. package/docs/design/menubaricondesignconcepts.md +796 -0
  526. package/docs/design/mockups/hybridiconconcept.svg +62 -0
  527. package/docs/design/mockups/infinityiconconcept.svg +41 -0
  528. package/docs/design/mockups/infinityiconstates.svg +129 -0
  529. package/docs/design/mockups/wavelogorefined.svg +48 -0
  530. package/docs/design/quick_summary.md +426 -0
  531. package/docs/design/readme.md +478 -0
  532. package/docs/design/sizespecifications.md +575 -0
  533. package/docs/design-system.md +836 -0
  534. package/docs/factory.md +103 -0
  535. package/docs/gemini.md +73 -0
  536. package/docs/icon.md +37 -0
  537. package/docs/icon.png +0 -0
  538. package/docs/index.html +175 -0
  539. package/docs/keychain_fix.md +274 -0
  540. package/docs/minimax.md +71 -0
  541. package/docs/packaging.md +25 -0
  542. package/docs/progress-bar-improvements.md +240 -0
  543. package/docs/progress-bar-quick-reference.md +283 -0
  544. package/docs/progress-bar-technical-details.md +432 -0
  545. package/docs/progress-bar-visual-test.swift +375 -0
  546. package/docs/provider.md +202 -0
  547. package/docs/providers.md +86 -0
  548. package/docs/refactor/macros.md +35 -0
  549. package/docs/refresh-loop.md +22 -0
  550. package/docs/releasing-homebrew.md +35 -0
  551. package/docs/releasing.md +121 -0
  552. package/docs/research/concrete_todo_2026-02-22.md +47 -0
  553. package/docs/research/core-feature-research.md +248 -0
  554. package/docs/research/feature_uiux_roadmap_2026-02-22.md +202 -0
  555. package/docs/research/parity_matrix.md +49 -0
  556. package/docs/research/provider_expansion_candidates_2026-02-22.md +80 -0
  557. package/docs/research/provider_model_token_project_matrix_2026-02-23.md +46 -0
  558. package/docs/research/release_gate_checklist.md +57 -0
  559. package/docs/site.css +505 -0
  560. package/docs/sparkle.md +28 -0
  561. package/docs/status.md +28 -0
  562. package/docs/sync/implementation.md +393 -0
  563. package/docs/sync/readme.md +317 -0
  564. package/docs/sync_files_summary.md +391 -0
  565. package/docs/sync_quick_reference.md +225 -0
  566. package/docs/todo.md +11 -0
  567. package/docs/ui.md +29 -0
  568. package/docs/web-integration.md +9 -0
  569. package/docs/widgets.md +85 -0
  570. package/docs/workflows/error-handling-improvements.md +434 -0
  571. package/docs/workflows/folder_structure.md +299 -0
  572. package/docs/workflows/loading_states.md +446 -0
  573. package/docs/workflows/loading_states_examples.md +565 -0
  574. package/docs/workflows/loading_states_flow.md +430 -0
  575. package/docs/workflows/performance_optimizations.md +457 -0
  576. package/docs/workflows/phase1-cli-testing-report.md +313 -0
  577. package/docs/workflows/quick_reference.md +228 -0
  578. package/docs/workflows/readme.md +59 -0
  579. package/docs/workflows/recent_improvements.md +275 -0
  580. package/docs/workflows/sync_enablement_summary.md +400 -0
  581. package/docs/zai.md +43 -0
  582. package/package.json +36 -0
  583. package/runic.png +0 -0
  584. package/version.env +2 -0
@@ -0,0 +1,3564 @@
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 let ledgerMaxAgeDays: Int = 3
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
+ func refresh(trigger: RefreshTrigger = .manual, forceTokenUsage: Bool = false) async {
1293
+ guard !self.isRefreshing else { return }
1294
+ let now = Date()
1295
+ if trigger.isAuto, !self.shouldRunAutoRefresh(trigger: trigger, now: now) {
1296
+ return
1297
+ }
1298
+ if trigger.isAuto {
1299
+ self.recordAutoRefreshRunIfNeeded(trigger: trigger)
1300
+ }
1301
+
1302
+ self.lastRefreshTrigger = trigger
1303
+ self.lastRefreshAt = now
1304
+ self.isRefreshing = true
1305
+ defer { self.isRefreshing = false }
1306
+
1307
+ let inactiveProviders = trigger.isAuto ? self.inactiveProviders(now: now) : []
1308
+ let skipCodexExtras = trigger.isAuto && inactiveProviders.contains(.codex)
1309
+
1310
+ await withTaskGroup(of: Void.self) { group in
1311
+ for provider in UsageProvider.allCases {
1312
+ if inactiveProviders.contains(provider) { continue }
1313
+ group.addTask { await self.refreshProvider(provider, trigger: trigger) }
1314
+ group.addTask { await self.refreshStatus(provider, trigger: trigger) }
1315
+ }
1316
+ if !skipCodexExtras {
1317
+ group.addTask { await self.refreshCreditsIfNeeded() }
1318
+ }
1319
+ }
1320
+
1321
+ // Token-cost usage can be slow; run it outside the refresh group so we don't block menu updates.
1322
+ self.scheduleTokenRefresh(force: forceTokenUsage, trigger: trigger, inactiveProviders: inactiveProviders)
1323
+
1324
+ // OpenAI web scrape depends on the current Codex account email (which can change after login/account switch).
1325
+ // Run this after Codex usage refresh so we don't accidentally scrape with stale credentials.
1326
+ if !skipCodexExtras {
1327
+ await self.refreshOpenAIDashboardIfNeeded(force: forceTokenUsage)
1328
+ }
1329
+
1330
+ if self.openAIDashboardRequiresLogin, !skipCodexExtras {
1331
+ await self.refreshProvider(.codex, trigger: trigger)
1332
+ await self.refreshCreditsIfNeeded()
1333
+ }
1334
+
1335
+ self.scheduleLedgerRefresh(force: forceTokenUsage, inactiveProviders: inactiveProviders)
1336
+
1337
+ // Refresh custom providers
1338
+ await self.refreshCustomProviders()
1339
+
1340
+ self.persistWidgetSnapshot(reason: "refresh")
1341
+ }
1342
+
1343
+ /// Refresh all enabled custom providers
1344
+ func refreshCustomProviders() async {
1345
+ let providers = CustomProviderStore.getEnabledProviders()
1346
+ await withTaskGroup(of: Void.self) { group in
1347
+ for provider in providers {
1348
+ group.addTask {
1349
+ await self.refreshCustomProvider(id: provider.id)
1350
+ }
1351
+ }
1352
+ }
1353
+ }
1354
+
1355
+ /// Refresh a single custom provider
1356
+ func refreshCustomProvider(id: String) async {
1357
+ guard let config = CustomProviderStore.getProvider(id: id), config.enabled else {
1358
+ return
1359
+ }
1360
+
1361
+ let fetcher = GenericProviderFetcher(config: config)
1362
+ let startTime = Date()
1363
+ let requestID = UUID().uuidString
1364
+
1365
+ do {
1366
+ let usageData = try await fetcher.fetchUsage()
1367
+ let endTime = Date()
1368
+
1369
+ // Track successful latency
1370
+ await self.trackLatency(
1371
+ provider: .codex, // TODO: Add custom provider tracking
1372
+ requestID: requestID,
1373
+ startTime: startTime,
1374
+ endTime: endTime,
1375
+ success: true
1376
+ )
1377
+
1378
+ let snapshot = CustomProviderSnapshot.from(usageData: usageData.toCustomUsageData(), config: config)
1379
+ await MainActor.run {
1380
+ self.customProviderSnapshots[id] = snapshot
1381
+ self.customProviderErrors.removeValue(forKey: id)
1382
+ }
1383
+ } catch {
1384
+ let endTime = Date()
1385
+
1386
+ // Track failed latency and error
1387
+ await self.trackLatency(
1388
+ provider: .codex, // TODO: Add custom provider tracking
1389
+ requestID: requestID,
1390
+ startTime: startTime,
1391
+ endTime: endTime,
1392
+ success: false
1393
+ )
1394
+ await self.trackError(provider: .codex, error: error) // TODO: Add custom provider tracking
1395
+
1396
+ await MainActor.run {
1397
+ self.customProviderErrors[id] = error.localizedDescription
1398
+ }
1399
+ }
1400
+ }
1401
+
1402
+ /// Clear a custom provider snapshot
1403
+ func clearCustomProviderSnapshot(id: String) {
1404
+ self.customProviderSnapshots.removeValue(forKey: id)
1405
+ self.customProviderErrors.removeValue(forKey: id)
1406
+ }
1407
+
1408
+ /// For demo/testing: drop the snapshot so the loading animation plays, then restore the last snapshot.
1409
+ func replayLoadingAnimation(duration: TimeInterval = 3) {
1410
+ let current = self.preferredSnapshot
1411
+ self.snapshots.removeAll()
1412
+ self.debugForceAnimation = true
1413
+ Task { @MainActor in
1414
+ try? await Task.sleep(for: .seconds(duration))
1415
+ if let current, let provider = self.enabledProviders().first {
1416
+ self.snapshots[provider] = current
1417
+ }
1418
+ self.debugForceAnimation = false
1419
+ }
1420
+ }
1421
+
1422
+ // MARK: - Private
1423
+
1424
+ private func bindSettings() {
1425
+ self.observeSettingsChanges()
1426
+ }
1427
+
1428
+ private func handleSettingsChange() async {
1429
+ let refreshChanged = self.settings.refreshFrequency != self.lastRefreshFrequency
1430
+ let warningEnabledChanged = self.settings.autoRefreshWarningEnabled != self.lastAutoRefreshWarningEnabled
1431
+ let warningThresholdChanged = self.settings.autoRefreshWarningThreshold
1432
+ != self.lastAutoRefreshWarningThreshold
1433
+ if refreshChanged || warningEnabledChanged || warningThresholdChanged {
1434
+ self.resetAutoRefreshWarningState()
1435
+ self.lastRefreshFrequency = self.settings.refreshFrequency
1436
+ self.lastAutoRefreshWarningEnabled = self.settings.autoRefreshWarningEnabled
1437
+ self.lastAutoRefreshWarningThreshold = self.settings.autoRefreshWarningThreshold
1438
+ }
1439
+ if refreshChanged, !self.suppressNextSettingsRefresh {
1440
+ self.clearAutoRefreshDisableReason()
1441
+ }
1442
+ self.startTimer()
1443
+ self.startTokenTimer()
1444
+ guard !self.suppressNextSettingsRefresh else {
1445
+ self.suppressNextSettingsRefresh = false
1446
+ return
1447
+ }
1448
+ await self.refresh(trigger: .settingsChange)
1449
+ }
1450
+
1451
+ private func startTimer() {
1452
+ self.timerTask?.cancel()
1453
+ guard let wait = self.settings.refreshFrequency.seconds else { return }
1454
+
1455
+ // Background poller so the menu stays responsive; canceled when settings change or store deallocates.
1456
+ self.timerTask = Task.detached(priority: .utility) { [weak self] in
1457
+ while !Task.isCancelled {
1458
+ try? await Task.sleep(for: .seconds(wait))
1459
+ await self?.refresh(trigger: .autoTimer)
1460
+ }
1461
+ }
1462
+ }
1463
+
1464
+ private func startTokenTimer() {
1465
+ self.tokenTimerTask?.cancel()
1466
+ guard self.settings.refreshFrequency != .manual else { return }
1467
+ guard self.settings.costUsageEnabled else { return }
1468
+ let wait = self.tokenFetchTTL
1469
+ self.tokenTimerTask = Task.detached(priority: .utility) { [weak self] in
1470
+ while !Task.isCancelled {
1471
+ try? await Task.sleep(for: .seconds(wait))
1472
+ let inactive = await self?.inactiveProviders(now: Date()) ?? []
1473
+ await self?.scheduleTokenRefresh(
1474
+ force: false,
1475
+ trigger: .autoTimer,
1476
+ inactiveProviders: inactive)
1477
+ }
1478
+ }
1479
+ }
1480
+
1481
+ private func shouldRunAutoRefresh(trigger: RefreshTrigger, now: Date) -> Bool {
1482
+ guard trigger.isAuto else { return true }
1483
+ guard self.settings.refreshFrequency != .manual else { return false }
1484
+ if self.autoRefreshSuspensionReason != nil { return false }
1485
+
1486
+ if self.settings.autoDisableRefreshWhenIdleEnabled,
1487
+ let idleSeconds = self.currentIdleSeconds()
1488
+ {
1489
+ let thresholdMinutes = max(1, self.settings.autoDisableRefreshWhenIdleMinutes)
1490
+ let thresholdSeconds = TimeInterval(thresholdMinutes) * 60
1491
+ if idleSeconds >= thresholdSeconds {
1492
+ self.disableAutoRefreshForIdle(thresholdMinutes: thresholdMinutes)
1493
+ return false
1494
+ }
1495
+ }
1496
+ return true
1497
+ }
1498
+
1499
+ private func disableAutoRefreshForIdle(thresholdMinutes: Int) {
1500
+ self.disableAutoRefresh(
1501
+ reason: .idle,
1502
+ idPrefix: "auto-refresh-idle",
1503
+ body: "Runic switched auto-refresh to Manual after \(thresholdMinutes) minutes of inactivity. "
1504
+ + "Use Refresh in the menu when you want new data.")
1505
+ }
1506
+
1507
+ private func disableAutoRefresh(reason: AutoRefreshDisableReason, idPrefix: String, body: String) {
1508
+ guard self.settings.refreshFrequency != .manual else { return }
1509
+ self.suppressNextSettingsRefresh = true
1510
+ self.settings.refreshFrequency = .manual
1511
+ self.autoRefreshSuspensionReason = nil
1512
+ self.lastAutoRefreshDisableReason = reason
1513
+ self.lastAutoRefreshDisableAt = Date()
1514
+ AppNotifications.shared.post(
1515
+ idPrefix: idPrefix,
1516
+ title: "Auto-refresh switched to Manual",
1517
+ body: body)
1518
+ }
1519
+
1520
+ private func clearAutoRefreshDisableReason() {
1521
+ self.lastAutoRefreshDisableReason = nil
1522
+ self.lastAutoRefreshDisableAt = nil
1523
+ }
1524
+
1525
+ private func systemPauseDetailText(_ reason: AutoRefreshSuspensionReason) -> String {
1526
+ switch reason {
1527
+ case .systemSleep:
1528
+ return "your Mac went to sleep"
1529
+ case .screenSleep:
1530
+ return "the display went to sleep"
1531
+ case .sessionInactive:
1532
+ return "your session became inactive"
1533
+ }
1534
+ }
1535
+
1536
+ private func disableReason(for reason: AutoRefreshSuspensionReason) -> AutoRefreshDisableReason {
1537
+ switch reason {
1538
+ case .systemSleep: .systemSleep
1539
+ case .screenSleep: .screenSleep
1540
+ case .sessionInactive: .sessionInactive
1541
+ }
1542
+ }
1543
+
1544
+ private func currentIdleSeconds() -> TimeInterval? {
1545
+ #if os(macOS)
1546
+ guard let anyInputType = CGEventType(rawValue: UInt32.max) else { return nil }
1547
+ let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyInputType)
1548
+ return seconds >= 0 ? seconds : nil
1549
+ #else
1550
+ return nil
1551
+ #endif
1552
+ }
1553
+
1554
+ private func recordAutoRefreshRunIfNeeded(trigger: RefreshTrigger) {
1555
+ guard trigger == .autoTimer else { return }
1556
+ guard self.settings.autoRefreshWarningEnabled else { return }
1557
+ guard !self.autoRefreshWarningSent else { return }
1558
+ let threshold = max(1, self.settings.autoRefreshWarningThreshold)
1559
+ self.autoRefreshRunCount += 1
1560
+ guard self.autoRefreshRunCount >= threshold else { return }
1561
+ self.autoRefreshWarningSent = true
1562
+ AppNotifications.shared.post(
1563
+ idPrefix: "auto-refresh-warning",
1564
+ title: "Auto-refresh is enabled",
1565
+ body: "Runic has auto-refreshed \(threshold) times. Auto-refresh can touch CLIs, "
1566
+ + "logs, or browser cookies depending on provider settings. Switch to Manual if you prefer "
1567
+ + "click-only refresh.")
1568
+ }
1569
+
1570
+ private func resetAutoRefreshWarningState() {
1571
+ self.autoRefreshRunCount = 0
1572
+ self.autoRefreshWarningSent = false
1573
+ }
1574
+
1575
+ private func inactiveProviders(now: Date) -> Set<UsageProvider> {
1576
+ guard self.settings.autoSuspendInactiveProvidersEnabled else { return [] }
1577
+ let thresholdMinutes = max(1, self.settings.autoSuspendInactiveProvidersMinutes)
1578
+ let thresholdSeconds = TimeInterval(thresholdMinutes) * 60
1579
+
1580
+ var inactive: Set<UsageProvider> = []
1581
+ for provider in UsageProvider.allCases {
1582
+ guard self.isEnabled(provider) else { continue }
1583
+ guard let lastActivity = self.lastActivityAt(for: provider) else { continue }
1584
+ if now.timeIntervalSince(lastActivity) >= thresholdSeconds {
1585
+ inactive.insert(provider)
1586
+ }
1587
+ }
1588
+ return inactive
1589
+ }
1590
+
1591
+ private func lastActivityAt(for provider: UsageProvider) -> Date? {
1592
+ let ledger = self.lastLedgerActivityAt[provider]
1593
+ let delta = self.lastUsageDeltaAt[provider]
1594
+ switch (ledger, delta) {
1595
+ case let (l?, d?):
1596
+ return max(l, d)
1597
+ case let (l?, nil):
1598
+ return l
1599
+ case let (nil, d?):
1600
+ return d
1601
+ case (nil, nil):
1602
+ return nil
1603
+ }
1604
+ }
1605
+
1606
+ private func recordUsageActivity(
1607
+ provider: UsageProvider,
1608
+ previous: UsageSnapshot?,
1609
+ current: UsageSnapshot)
1610
+ {
1611
+ if previous == nil || self.usageChanged(previous: previous, current: current) {
1612
+ self.lastUsageDeltaAt[provider] = current.updatedAt
1613
+ }
1614
+ }
1615
+
1616
+ private func usageChanged(previous: UsageSnapshot?, current: UsageSnapshot) -> Bool {
1617
+ guard let previous else { return true }
1618
+
1619
+ func windowChanged(_ lhs: RateWindow?, _ rhs: RateWindow?) -> Bool {
1620
+ switch (lhs, rhs) {
1621
+ case (nil, nil):
1622
+ return false
1623
+ case let (l?, r?):
1624
+ return abs(l.usedPercent - r.usedPercent) >= 0.1
1625
+ case (nil, _), (_, nil):
1626
+ return true
1627
+ }
1628
+ }
1629
+
1630
+ if windowChanged(previous.primary, current.primary) { return true }
1631
+ if windowChanged(previous.secondary, current.secondary) { return true }
1632
+ if windowChanged(previous.tertiary, current.tertiary) { return true }
1633
+
1634
+ switch (previous.providerCost, current.providerCost) {
1635
+ case (nil, nil):
1636
+ return false
1637
+ case let (lhs?, rhs?):
1638
+ if lhs.used != rhs.used { return true }
1639
+ if lhs.limit != rhs.limit { return true }
1640
+ return false
1641
+ case (nil, _), (_, nil):
1642
+ return true
1643
+ }
1644
+ }
1645
+
1646
+ private func scheduleLedgerRefresh(
1647
+ force: Bool,
1648
+ inactiveProviders: Set<UsageProvider>)
1649
+ {
1650
+ let now = Date()
1651
+ let sources = self.ledgerSources(now: now, inactiveProviders: inactiveProviders)
1652
+ let providers = sources.map(\.0)
1653
+ if providers.isEmpty { return }
1654
+ if !force {
1655
+ let shouldRefresh = providers.contains { provider in
1656
+ guard let last = self.ledgerUpdatedAt[provider] else { return true }
1657
+ return now.timeIntervalSince(last) >= self.ledgerRefreshTTL
1658
+ }
1659
+ if !shouldRefresh { return }
1660
+ }
1661
+ if self.ledgerRefreshTask != nil { return }
1662
+
1663
+ self.ledgerRefreshTask = Task.detached(priority: .utility) { [weak self] in
1664
+ guard let self else { return }
1665
+ let result = await self.loadLedgerInsights(sources: sources, now: now)
1666
+ await MainActor.run { [weak self] in
1667
+ guard let self else { return }
1668
+ self.ledgerRefreshTask = nil
1669
+ for provider in result.providers {
1670
+ if let error = result.errorsByProvider[provider] {
1671
+ self.ledgerErrors[provider] = error
1672
+ } else {
1673
+ self.ledgerErrors.removeValue(forKey: provider)
1674
+ }
1675
+
1676
+ if let daily = result.dailyByProvider[provider] {
1677
+ self.ledgerDailySummaries[provider] = daily
1678
+ } else {
1679
+ self.ledgerDailySummaries.removeValue(forKey: provider)
1680
+ }
1681
+
1682
+ if let allDaily = result.allDailySummariesByProvider[provider], !allDaily.isEmpty {
1683
+ self.ledgerAllDailySummaries[provider] = allDaily
1684
+ } else {
1685
+ self.ledgerAllDailySummaries.removeValue(forKey: provider)
1686
+ }
1687
+
1688
+ if let hourly = result.hourlySummariesByProvider[provider], !hourly.isEmpty {
1689
+ self.ledgerHourlySummaries[provider] = hourly
1690
+ } else {
1691
+ self.ledgerHourlySummaries.removeValue(forKey: provider)
1692
+ }
1693
+
1694
+ if let block = result.activeBlocksByProvider[provider] {
1695
+ self.ledgerActiveBlocks[provider] = block
1696
+ } else {
1697
+ self.ledgerActiveBlocks.removeValue(forKey: provider)
1698
+ }
1699
+
1700
+ if let topModel = result.topModelsByProvider[provider] {
1701
+ self.ledgerTopModels[provider] = topModel
1702
+ } else {
1703
+ self.ledgerTopModels.removeValue(forKey: provider)
1704
+ }
1705
+
1706
+ if let topProject = result.topProjectsByProvider[provider] {
1707
+ self.ledgerTopProjects[provider] = topProject
1708
+ } else {
1709
+ self.ledgerTopProjects.removeValue(forKey: provider)
1710
+ }
1711
+
1712
+ if let breakdown = result.modelBreakdownsByProvider[provider] {
1713
+ self.ledgerModelBreakdowns[provider] = breakdown
1714
+ } else {
1715
+ self.ledgerModelBreakdowns.removeValue(forKey: provider)
1716
+ }
1717
+
1718
+ if let breakdown = result.projectBreakdownsByProvider[provider] {
1719
+ self.ledgerProjectBreakdowns[provider] = breakdown
1720
+ } else {
1721
+ self.ledgerProjectBreakdowns.removeValue(forKey: provider)
1722
+ }
1723
+
1724
+ if let forecast = result.spendForecastsByProvider[provider] {
1725
+ self.ledgerSpendForecasts[provider] = forecast
1726
+ } else {
1727
+ self.ledgerSpendForecasts.removeValue(forKey: provider)
1728
+ }
1729
+
1730
+ if let forecasts = result.projectSpendForecastsByProvider[provider] {
1731
+ self.ledgerProjectSpendForecasts[provider] = forecasts
1732
+ } else {
1733
+ self.ledgerProjectSpendForecasts.removeValue(forKey: provider)
1734
+ }
1735
+
1736
+ if let forecast = result.topProjectSpendForecastsByProvider[provider] {
1737
+ self.ledgerTopProjectSpendForecasts[provider] = forecast
1738
+ } else {
1739
+ self.ledgerTopProjectSpendForecasts.removeValue(forKey: provider)
1740
+ }
1741
+ if let anomaly = result.anomaliesByProvider[provider] {
1742
+ self.ledgerAnomalies[provider] = anomaly
1743
+ } else {
1744
+ self.ledgerAnomalies.removeValue(forKey: provider)
1745
+ }
1746
+
1747
+ if let lastActivity = result.lastActivityByProvider[provider] {
1748
+ self.lastLedgerActivityAt[provider] = lastActivity
1749
+ }
1750
+
1751
+ self.ledgerUpdatedAt[provider] = result.updatedAt
1752
+ }
1753
+
1754
+ // Budget breach notifications
1755
+ if self.settings.budgetNotificationsEnabled {
1756
+ BudgetNotificationManager.shared.checkAndNotify(
1757
+ forecasts: self.ledgerProjectSpendForecasts,
1758
+ settings: self.settings)
1759
+ }
1760
+ }
1761
+ }
1762
+ }
1763
+
1764
+ private func scheduleTokenRefresh(
1765
+ force: Bool,
1766
+ trigger: RefreshTrigger,
1767
+ inactiveProviders: Set<UsageProvider>)
1768
+ {
1769
+ if trigger.isAuto, !self.shouldRunAutoRefresh(trigger: trigger, now: Date()) {
1770
+ return
1771
+ }
1772
+ if force {
1773
+ self.tokenRefreshSequenceTask?.cancel()
1774
+ self.tokenRefreshSequenceTask = nil
1775
+ } else if self.tokenRefreshSequenceTask != nil {
1776
+ return
1777
+ }
1778
+
1779
+ self.tokenRefreshSequenceTask = Task(priority: .utility) { [weak self] in
1780
+ guard let self else { return }
1781
+ defer {
1782
+ Task { @MainActor [weak self] in
1783
+ self?.tokenRefreshSequenceTask = nil
1784
+ }
1785
+ }
1786
+ for provider in UsageProvider.allCases {
1787
+ if Task.isCancelled { break }
1788
+ await self.refreshTokenUsage(
1789
+ provider,
1790
+ force: force,
1791
+ trigger: trigger,
1792
+ inactiveProviders: inactiveProviders)
1793
+ }
1794
+ }
1795
+ }
1796
+
1797
+ deinit {
1798
+ self.timerTask?.cancel()
1799
+ self.tokenTimerTask?.cancel()
1800
+ self.tokenRefreshSequenceTask?.cancel()
1801
+ self.ledgerRefreshTask?.cancel()
1802
+ }
1803
+
1804
+ private struct LedgerRefreshResult: Sendable {
1805
+ let dailyByProvider: [UsageProvider: UsageLedgerDailySummary]
1806
+ let allDailySummariesByProvider: [UsageProvider: [UsageLedgerDailySummary]]
1807
+ let hourlySummariesByProvider: [UsageProvider: [UsageLedgerHourlySummary]]
1808
+ let activeBlocksByProvider: [UsageProvider: UsageLedgerBlockSummary]
1809
+ let topModelsByProvider: [UsageProvider: UsageLedgerModelSummary]
1810
+ let topProjectsByProvider: [UsageProvider: UsageLedgerProjectSummary]
1811
+ let modelBreakdownsByProvider: [UsageProvider: [UsageLedgerModelSummary]]
1812
+ let projectBreakdownsByProvider: [UsageProvider: [UsageLedgerProjectSummary]]
1813
+ let spendForecastsByProvider: [UsageProvider: UsageLedgerSpendForecast]
1814
+ let projectSpendForecastsByProvider: [UsageProvider: [UsageLedgerSpendForecast]]
1815
+ let topProjectSpendForecastsByProvider: [UsageProvider: UsageLedgerSpendForecast]
1816
+ let anomaliesByProvider: [UsageProvider: UsageLedgerAnomalySummary]
1817
+ let errorsByProvider: [UsageProvider: String]
1818
+ let lastActivityByProvider: [UsageProvider: Date]
1819
+ let updatedAt: Date
1820
+ let providers: [UsageProvider]
1821
+ }
1822
+
1823
+ private struct ProviderHistoryMonthCacheEntry {
1824
+ let fetchedAt: Date
1825
+ let snapshot: ProviderHistoryMonthSnapshot
1826
+ }
1827
+
1828
+ private func ledgerSources(
1829
+ now: Date,
1830
+ inactiveProviders: Set<UsageProvider>) -> [(UsageProvider, any UsageLedgerSource)]
1831
+ {
1832
+ return UsageProvider.allCases.compactMap { provider in
1833
+ guard self.isEnabled(provider), !inactiveProviders.contains(provider) else { return nil }
1834
+ guard let source = self.providerHistorySource(
1835
+ provider: provider,
1836
+ now: now,
1837
+ maxAgeDays: self.ledgerMaxAgeDays)
1838
+ else { return nil }
1839
+ return (provider, source)
1840
+ }
1841
+ }
1842
+
1843
+ private func loadLedgerInsights(
1844
+ sources: [(UsageProvider, any UsageLedgerSource)],
1845
+ now: Date) async -> LedgerRefreshResult
1846
+ {
1847
+ guard !sources.isEmpty else {
1848
+ return LedgerRefreshResult(
1849
+ dailyByProvider: [:],
1850
+ allDailySummariesByProvider: [:],
1851
+ hourlySummariesByProvider: [:],
1852
+ activeBlocksByProvider: [:],
1853
+ topModelsByProvider: [:],
1854
+ topProjectsByProvider: [:],
1855
+ modelBreakdownsByProvider: [:],
1856
+ projectBreakdownsByProvider: [:],
1857
+ spendForecastsByProvider: [:],
1858
+ projectSpendForecastsByProvider: [:],
1859
+ topProjectSpendForecastsByProvider: [:],
1860
+ anomaliesByProvider: [:],
1861
+ errorsByProvider: [:],
1862
+ lastActivityByProvider: [:],
1863
+ updatedAt: now,
1864
+ providers: [])
1865
+ }
1866
+
1867
+ let providers = sources.map(\.0)
1868
+ var entries: [UsageLedgerEntry] = []
1869
+ var errors: [UsageProvider: String] = [:]
1870
+
1871
+ await withTaskGroup(of: (UsageProvider, Result<[UsageLedgerEntry], Error>).self) { group in
1872
+ for (provider, source) in sources {
1873
+ group.addTask {
1874
+ do {
1875
+ let loaded = try await source.loadEntries()
1876
+ return (provider, .success(loaded))
1877
+ } catch {
1878
+ return (provider, .failure(error))
1879
+ }
1880
+ }
1881
+ }
1882
+
1883
+ for await (provider, result) in group {
1884
+ switch result {
1885
+ case let .success(loaded):
1886
+ entries.append(contentsOf: loaded)
1887
+ case let .failure(error):
1888
+ errors[provider] = error.localizedDescription
1889
+ }
1890
+ }
1891
+ }
1892
+
1893
+ var lastActivityByProvider: [UsageProvider: Date] = [:]
1894
+ for entry in entries {
1895
+ if let current = lastActivityByProvider[entry.provider] {
1896
+ if entry.timestamp > current { lastActivityByProvider[entry.provider] = entry.timestamp }
1897
+ } else {
1898
+ lastActivityByProvider[entry.provider] = entry.timestamp
1899
+ }
1900
+ }
1901
+
1902
+ let timeZone = TimeZone.current
1903
+ var calendar = Calendar(identifier: .gregorian)
1904
+ calendar.timeZone = timeZone
1905
+ let todayStart = calendar.startOfDay(for: now)
1906
+
1907
+ let dailySummaries = UsageLedgerAggregator.dailySummaries(
1908
+ entries: entries,
1909
+ timeZone: timeZone,
1910
+ groupByProject: false)
1911
+ var dailyByProvider: [UsageProvider: UsageLedgerDailySummary] = [:]
1912
+ var allDailySummariesByProvider: [UsageProvider: [UsageLedgerDailySummary]] = [:]
1913
+ for summary in dailySummaries {
1914
+ allDailySummariesByProvider[summary.provider, default: []].append(summary)
1915
+ if summary.dayStart == todayStart {
1916
+ dailyByProvider[summary.provider] = summary
1917
+ }
1918
+ }
1919
+
1920
+ let hourlySummaries = UsageLedgerAggregator.hourlySummaries(
1921
+ entries: entries,
1922
+ timeZone: timeZone,
1923
+ groupByProject: false)
1924
+ var hourlySummariesByProvider: [UsageProvider: [UsageLedgerHourlySummary]] = [:]
1925
+ for summary in hourlySummaries {
1926
+ hourlySummariesByProvider[summary.provider, default: []].append(summary)
1927
+ }
1928
+
1929
+ let blocks = UsageLedgerAggregator.blockSummaries(entries: entries, blockHours: 5, now: now)
1930
+ var activeByProvider: [UsageProvider: UsageLedgerBlockSummary] = [:]
1931
+ for block in blocks where block.isActive {
1932
+ if activeByProvider[block.provider] == nil {
1933
+ activeByProvider[block.provider] = block
1934
+ }
1935
+ }
1936
+
1937
+ let todayEntries = entries.filter { calendar.isDate($0.timestamp, inSameDayAs: now) }
1938
+ let budgetProjectNames = self.projectNameOverridesFromBudgets()
1939
+ let modelSummaries = UsageLedgerAggregator.modelSummaries(entries: todayEntries)
1940
+ var topModelsByProvider: [UsageProvider: UsageLedgerModelSummary] = [:]
1941
+ for summary in modelSummaries {
1942
+ if topModelsByProvider[summary.provider] == nil {
1943
+ topModelsByProvider[summary.provider] = summary
1944
+ }
1945
+ }
1946
+
1947
+ let projectSummaries = UsageLedgerAggregator.projectSummaries(entries: todayEntries)
1948
+ .map { self.resolvedProjectSummary($0, budgetProjectNames: budgetProjectNames) }
1949
+ var topProjectsByProvider: [UsageProvider: UsageLedgerProjectSummary] = [:]
1950
+ for summary in projectSummaries {
1951
+ if topProjectsByProvider[summary.provider] == nil {
1952
+ topProjectsByProvider[summary.provider] = summary
1953
+ }
1954
+ }
1955
+
1956
+ let modelBreakdowns = UsageLedgerAggregator.modelSummaries(entries: todayEntries, groupByProject: true)
1957
+ .map { self.resolvedModelSummary($0, budgetProjectNames: budgetProjectNames) }
1958
+ var modelBreakdownsByProvider: [UsageProvider: [UsageLedgerModelSummary]] = [:]
1959
+ for summary in modelBreakdowns {
1960
+ modelBreakdownsByProvider[summary.provider, default: []].append(summary)
1961
+ }
1962
+
1963
+ var projectBreakdownsByProvider: [UsageProvider: [UsageLedgerProjectSummary]] = [:]
1964
+ for summary in projectSummaries {
1965
+ projectBreakdownsByProvider[summary.provider, default: []].append(summary)
1966
+ }
1967
+
1968
+ let providerForecasts = UsageLedgerAggregator.providerSpendForecasts(
1969
+ entries: entries,
1970
+ now: now,
1971
+ timeZone: timeZone)
1972
+ var spendForecastsByProvider: [UsageProvider: UsageLedgerSpendForecast] = [:]
1973
+ for forecast in providerForecasts where spendForecastsByProvider[forecast.provider] == nil {
1974
+ spendForecastsByProvider[forecast.provider] = forecast
1975
+ }
1976
+
1977
+ let budgetLimitsByProjectID = self.activeBudgetLimitsByProjectID()
1978
+ let projectForecasts = UsageLedgerAggregator.projectSpendForecasts(
1979
+ entries: entries,
1980
+ now: now,
1981
+ timeZone: timeZone)
1982
+ .map {
1983
+ self.resolvedSpendForecast(
1984
+ $0,
1985
+ budgetProjectNames: budgetProjectNames,
1986
+ budgetLimitsByProjectID: budgetLimitsByProjectID)
1987
+ }
1988
+ var projectSpendForecastsByProvider: [UsageProvider: [UsageLedgerSpendForecast]] = [:]
1989
+ for forecast in projectForecasts {
1990
+ projectSpendForecastsByProvider[forecast.provider, default: []].append(forecast)
1991
+ }
1992
+
1993
+ var topProjectSpendForecastsByProvider: [UsageProvider: UsageLedgerSpendForecast] = [:]
1994
+ for (provider, summary) in topProjectsByProvider {
1995
+ guard let forecasts = projectSpendForecastsByProvider[provider] else { continue }
1996
+ if let matched = self.matchingProjectForecast(for: summary, forecasts: forecasts) {
1997
+ topProjectSpendForecastsByProvider[provider] = matched
1998
+ }
1999
+ }
2000
+ let anomaliesByProvider = UsageLedgerAnomalyDetector.summaries(
2001
+ dailySummaries: dailySummaries,
2002
+ now: now,
2003
+ calendar: calendar)
2004
+
2005
+ return LedgerRefreshResult(
2006
+ dailyByProvider: dailyByProvider,
2007
+ allDailySummariesByProvider: allDailySummariesByProvider,
2008
+ hourlySummariesByProvider: hourlySummariesByProvider,
2009
+ activeBlocksByProvider: activeByProvider,
2010
+ topModelsByProvider: topModelsByProvider,
2011
+ topProjectsByProvider: topProjectsByProvider,
2012
+ modelBreakdownsByProvider: modelBreakdownsByProvider,
2013
+ projectBreakdownsByProvider: projectBreakdownsByProvider,
2014
+ spendForecastsByProvider: spendForecastsByProvider,
2015
+ projectSpendForecastsByProvider: projectSpendForecastsByProvider,
2016
+ topProjectSpendForecastsByProvider: topProjectSpendForecastsByProvider,
2017
+ anomaliesByProvider: anomaliesByProvider,
2018
+ errorsByProvider: errors,
2019
+ lastActivityByProvider: lastActivityByProvider,
2020
+ updatedAt: now,
2021
+ providers: providers)
2022
+ }
2023
+
2024
+ private func projectNameOverridesFromBudgets() -> [String: String] {
2025
+ var namesByProjectID: [String: String] = [:]
2026
+ for budget in ProjectBudgetStore.getAllBudgets() {
2027
+ let trimmed = budget.projectName?.trimmingCharacters(in: .whitespacesAndNewlines)
2028
+ if let trimmed, !trimmed.isEmpty {
2029
+ namesByProjectID[budget.projectID] = trimmed
2030
+ }
2031
+ }
2032
+ return namesByProjectID
2033
+ }
2034
+
2035
+ private func activeBudgetLimitsByProjectID() -> [String: Double] {
2036
+ var limitsByProjectID: [String: Double] = [:]
2037
+ for budget in ProjectBudgetStore.getAllBudgets() where budget.enabled && budget.monthlyLimit > 0 {
2038
+ limitsByProjectID[budget.projectID] = budget.monthlyLimit
2039
+ }
2040
+ return limitsByProjectID
2041
+ }
2042
+
2043
+ private func resolvedSpendForecast(
2044
+ _ forecast: UsageLedgerSpendForecast,
2045
+ budgetProjectNames: [String: String],
2046
+ budgetLimitsByProjectID: [String: Double]) -> UsageLedgerSpendForecast
2047
+ {
2048
+ let budgetName = forecast.projectID.flatMap { budgetProjectNames[$0] }
2049
+ let identity = UsageLedgerProjectIdentityResolver.resolve(
2050
+ provider: forecast.provider,
2051
+ projectID: forecast.projectID,
2052
+ projectName: forecast.projectName,
2053
+ budgetNameOverride: budgetName)
2054
+ let resolved = UsageLedgerSpendForecast(
2055
+ provider: forecast.provider,
2056
+ projectKey: forecast.projectKey ?? identity.key,
2057
+ projectID: identity.projectID ?? forecast.projectID,
2058
+ projectName: identity.displayName ?? forecast.projectName,
2059
+ observedDays: forecast.observedDays,
2060
+ observedCostUSD: forecast.observedCostUSD,
2061
+ averageDailyCostUSD: forecast.averageDailyCostUSD,
2062
+ projected30DayCostUSD: forecast.projected30DayCostUSD,
2063
+ projectedCostP50USD: forecast.projectedCostP50USD,
2064
+ projectedCostP80USD: forecast.projectedCostP80USD,
2065
+ projectedCostP95USD: forecast.projectedCostP95USD,
2066
+ projectionDays: forecast.projectionDays,
2067
+ budgetLimitUSD: forecast.budgetLimitUSD,
2068
+ budgetETAInDays: forecast.budgetETAInDays,
2069
+ budgetWillBreach: forecast.budgetWillBreach)
2070
+ let budgetLimit = resolved.projectID.flatMap { budgetLimitsByProjectID[$0] }
2071
+ return resolved.applyingBudget(monthlyLimitUSD: budgetLimit)
2072
+ }
2073
+
2074
+ private func matchingProjectForecast(
2075
+ for summary: UsageLedgerProjectSummary,
2076
+ forecasts: [UsageLedgerSpendForecast]) -> UsageLedgerSpendForecast?
2077
+ {
2078
+ if let key = summary.projectKey,
2079
+ let byKey = forecasts.first(where: { $0.projectKey == key })
2080
+ {
2081
+ return byKey
2082
+ }
2083
+ if let projectID = summary.projectID,
2084
+ let byID = forecasts.first(where: { $0.projectID == projectID })
2085
+ {
2086
+ return byID
2087
+ }
2088
+ let summaryName = summary.projectName?
2089
+ .trimmingCharacters(in: .whitespacesAndNewlines)
2090
+ .lowercased()
2091
+ if let summaryName, !summaryName.isEmpty {
2092
+ return forecasts.first {
2093
+ $0.projectName?
2094
+ .trimmingCharacters(in: .whitespacesAndNewlines)
2095
+ .lowercased() == summaryName
2096
+ }
2097
+ }
2098
+ return nil
2099
+ }
2100
+
2101
+ private func resolvedProjectSummary(
2102
+ _ summary: UsageLedgerProjectSummary,
2103
+ budgetProjectNames: [String: String]) -> UsageLedgerProjectSummary
2104
+ {
2105
+ let budgetName = summary.projectID.flatMap { budgetProjectNames[$0] }
2106
+ let identity = UsageLedgerProjectIdentityResolver.resolve(
2107
+ provider: summary.provider,
2108
+ projectID: summary.projectID,
2109
+ projectName: summary.projectName,
2110
+ budgetNameOverride: budgetName)
2111
+ return UsageLedgerProjectSummary(
2112
+ provider: summary.provider,
2113
+ projectKey: summary.projectKey ?? identity.key,
2114
+ projectID: identity.projectID ?? summary.projectID,
2115
+ projectName: identity.displayName ?? summary.projectName,
2116
+ projectNameConfidence: identity.confidence,
2117
+ projectNameSource: identity.source,
2118
+ projectNameProvenance: identity.provenance,
2119
+ entryCount: summary.entryCount,
2120
+ totals: summary.totals,
2121
+ modelsUsed: summary.modelsUsed)
2122
+ }
2123
+
2124
+ private func resolvedModelSummary(
2125
+ _ summary: UsageLedgerModelSummary,
2126
+ budgetProjectNames: [String: String]) -> UsageLedgerModelSummary
2127
+ {
2128
+ let budgetName = summary.projectID.flatMap { budgetProjectNames[$0] }
2129
+ let identity = UsageLedgerProjectIdentityResolver.resolve(
2130
+ provider: summary.provider,
2131
+ projectID: summary.projectID,
2132
+ projectName: summary.projectName,
2133
+ budgetNameOverride: budgetName)
2134
+ return UsageLedgerModelSummary(
2135
+ provider: summary.provider,
2136
+ projectKey: summary.projectKey ?? identity.key,
2137
+ projectID: identity.projectID ?? summary.projectID,
2138
+ projectName: identity.displayName ?? summary.projectName,
2139
+ projectNameConfidence: identity.confidence,
2140
+ projectNameSource: identity.source,
2141
+ projectNameProvenance: identity.provenance,
2142
+ model: summary.model,
2143
+ entryCount: summary.entryCount,
2144
+ totals: summary.totals)
2145
+ }
2146
+
2147
+ private func refreshProvider(_ provider: UsageProvider, trigger _: RefreshTrigger) async {
2148
+ guard let spec = self.providerSpecs[provider] else { return }
2149
+
2150
+ if !spec.isEnabled() {
2151
+ self.refreshingProviders.remove(provider)
2152
+ await MainActor.run {
2153
+ self.snapshots.removeValue(forKey: provider)
2154
+ self.errors[provider] = nil
2155
+ self.lastSourceLabels.removeValue(forKey: provider)
2156
+ self.lastFetchAttempts.removeValue(forKey: provider)
2157
+ self.tokenSnapshots.removeValue(forKey: provider)
2158
+ self.tokenErrors[provider] = nil
2159
+ self.failureGates[provider]?.reset()
2160
+ self.tokenFailureGates[provider]?.reset()
2161
+ self.statuses.removeValue(forKey: provider)
2162
+ self.lastKnownSessionRemaining.removeValue(forKey: provider)
2163
+ self.lastTokenFetchAt.removeValue(forKey: provider)
2164
+ self.lastUsageDeltaAt.removeValue(forKey: provider)
2165
+ self.lastLedgerActivityAt.removeValue(forKey: provider)
2166
+ }
2167
+ return
2168
+ }
2169
+
2170
+ self.refreshingProviders.insert(provider)
2171
+ defer { self.refreshingProviders.remove(provider) }
2172
+
2173
+ let previousSnapshot = self.snapshots[provider]
2174
+ let outcome = await spec.fetch()
2175
+ await MainActor.run {
2176
+ self.lastFetchAttempts[provider] = outcome.attempts
2177
+ }
2178
+
2179
+ switch outcome.result {
2180
+ case let .success(result):
2181
+ let scoped = result.usage.scoped(to: provider)
2182
+ await MainActor.run {
2183
+ self.handleSessionQuotaTransition(provider: provider, snapshot: scoped)
2184
+ self.snapshots[provider] = scoped
2185
+ self.lastSourceLabels[provider] = result.sourceLabel
2186
+ self.errors[provider] = nil
2187
+ self.failureGates[provider]?.recordSuccess()
2188
+ self.recordUsageActivity(
2189
+ provider: provider,
2190
+ previous: previousSnapshot,
2191
+ current: scoped)
2192
+ }
2193
+ case let .failure(error):
2194
+ await MainActor.run {
2195
+ let hadPriorData = self.snapshots[provider] != nil
2196
+ let shouldSurface = self.failureGates[provider]?
2197
+ .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true
2198
+ if shouldSurface {
2199
+ self.errors[provider] = error.localizedDescription
2200
+ self.snapshots.removeValue(forKey: provider)
2201
+ } else {
2202
+ self.errors[provider] = nil
2203
+ }
2204
+ }
2205
+ }
2206
+ }
2207
+
2208
+ private func handleSessionQuotaTransition(provider: UsageProvider, snapshot: UsageSnapshot) {
2209
+ let currentRemaining = snapshot.primary.remainingPercent
2210
+ let previousRemaining = self.lastKnownSessionRemaining[provider]
2211
+
2212
+ defer { self.lastKnownSessionRemaining[provider] = currentRemaining }
2213
+
2214
+ guard self.settings.sessionQuotaNotificationsEnabled else {
2215
+ if SessionQuotaNotificationLogic.isDepleted(currentRemaining) ||
2216
+ SessionQuotaNotificationLogic.isDepleted(previousRemaining)
2217
+ {
2218
+ let providerText = provider.rawValue
2219
+ let message =
2220
+ "notifications disabled: provider=\(providerText) " +
2221
+ "prev=\(previousRemaining ?? -1) curr=\(currentRemaining)"
2222
+ self.sessionQuotaLogger.debug(message)
2223
+ }
2224
+ return
2225
+ }
2226
+
2227
+ guard previousRemaining != nil else {
2228
+ if SessionQuotaNotificationLogic.isDepleted(currentRemaining) {
2229
+ let providerText = provider.rawValue
2230
+ let message = "startup depleted: provider=\(providerText) curr=\(currentRemaining)"
2231
+ self.sessionQuotaLogger.info(message)
2232
+ self.sessionQuotaNotifier.post(transition: .depleted, provider: provider)
2233
+ }
2234
+ return
2235
+ }
2236
+
2237
+ let transition = SessionQuotaNotificationLogic.transition(
2238
+ previousRemaining: previousRemaining,
2239
+ currentRemaining: currentRemaining)
2240
+ guard transition != .none else {
2241
+ if SessionQuotaNotificationLogic.isDepleted(currentRemaining) ||
2242
+ SessionQuotaNotificationLogic.isDepleted(previousRemaining)
2243
+ {
2244
+ let providerText = provider.rawValue
2245
+ let message =
2246
+ "no transition: provider=\(providerText) " +
2247
+ "prev=\(previousRemaining ?? -1) curr=\(currentRemaining)"
2248
+ self.sessionQuotaLogger.debug(message)
2249
+ }
2250
+ return
2251
+ }
2252
+
2253
+ let providerText = provider.rawValue
2254
+ let transitionText = String(describing: transition)
2255
+ let message =
2256
+ "transition \(transitionText): provider=\(providerText) " +
2257
+ "prev=\(previousRemaining ?? -1) curr=\(currentRemaining)"
2258
+ self.sessionQuotaLogger.info(message)
2259
+
2260
+ self.sessionQuotaNotifier.post(transition: transition, provider: provider)
2261
+ }
2262
+
2263
+ private func refreshStatus(_ provider: UsageProvider, trigger _: RefreshTrigger) async {
2264
+ guard self.isEnabled(provider) else { return }
2265
+ guard self.settings.statusChecksEnabled else { return }
2266
+ guard self.settings.refreshFrequency != .manual else { return }
2267
+ guard let meta = self.providerMetadata[provider] else { return }
2268
+
2269
+ do {
2270
+ let status: ProviderStatus
2271
+ if let urlString = meta.statusPageURL, let baseURL = URL(string: urlString) {
2272
+ status = try await Self.fetchStatus(from: baseURL)
2273
+ } else if let productID = meta.statusWorkspaceProductID {
2274
+ status = try await Self.fetchWorkspaceStatus(productID: productID)
2275
+ } else {
2276
+ return
2277
+ }
2278
+ await MainActor.run { self.statuses[provider] = status }
2279
+ } catch {
2280
+ // Keep the previous status to avoid flapping when the API hiccups.
2281
+ await MainActor.run {
2282
+ if self.statuses[provider] == nil {
2283
+ self.statuses[provider] = ProviderStatus(
2284
+ indicator: .unknown,
2285
+ description: error.localizedDescription,
2286
+ updatedAt: nil)
2287
+ }
2288
+ }
2289
+ }
2290
+ }
2291
+
2292
+ private func refreshCreditsIfNeeded() async {
2293
+ guard self.isEnabled(.codex) else { return }
2294
+ do {
2295
+ let credits = try await self.codexFetcher.loadLatestCredits()
2296
+ await MainActor.run {
2297
+ self.credits = credits
2298
+ self.lastCreditsError = nil
2299
+ self.lastCreditsSnapshot = credits
2300
+ self.creditsFailureStreak = 0
2301
+ }
2302
+ } catch {
2303
+ let message = error.localizedDescription
2304
+ if message.localizedCaseInsensitiveContains("data not available yet") {
2305
+ await MainActor.run {
2306
+ if let cached = self.lastCreditsSnapshot {
2307
+ self.credits = cached
2308
+ self.lastCreditsError = nil
2309
+ } else {
2310
+ self.credits = nil
2311
+ self.lastCreditsError = "Codex credits are still loading; will retry shortly."
2312
+ }
2313
+ }
2314
+ return
2315
+ }
2316
+
2317
+ await MainActor.run {
2318
+ self.creditsFailureStreak += 1
2319
+ if let cached = self.lastCreditsSnapshot {
2320
+ self.credits = cached
2321
+ let stamp = cached.updatedAt.formatted(date: .abbreviated, time: .shortened)
2322
+ self.lastCreditsError =
2323
+ "Last Codex credits refresh failed: \(message). Cached values from \(stamp)."
2324
+ } else {
2325
+ self.lastCreditsError = message
2326
+ self.credits = nil
2327
+ }
2328
+ }
2329
+ }
2330
+ }
2331
+
2332
+ private func applyOpenAIDashboard(_ dash: OpenAIDashboardSnapshot, targetEmail: String?) async {
2333
+ await MainActor.run {
2334
+ self.openAIDashboard = dash
2335
+ self.lastOpenAIDashboardError = nil
2336
+ self.lastOpenAIDashboardSnapshot = dash
2337
+ self.openAIDashboardRequiresLogin = false
2338
+ // Only fill gaps; OAuth/CLI remain the primary sources for usage + credits.
2339
+ if self.snapshots[.codex] == nil,
2340
+ let usage = dash.toUsageSnapshot(provider: .codex, accountEmail: targetEmail)
2341
+ {
2342
+ self.snapshots[.codex] = usage
2343
+ self.errors[.codex] = nil
2344
+ self.failureGates[.codex]?.recordSuccess()
2345
+ self.lastSourceLabels[.codex] = "openai-web"
2346
+ }
2347
+ if self.credits == nil, let credits = dash.toCreditsSnapshot() {
2348
+ self.credits = credits
2349
+ self.lastCreditsSnapshot = credits
2350
+ self.lastCreditsError = nil
2351
+ self.creditsFailureStreak = 0
2352
+ }
2353
+ }
2354
+
2355
+ if let email = targetEmail, !email.isEmpty {
2356
+ OpenAIDashboardCacheStore.save(OpenAIDashboardCache(accountEmail: email, snapshot: dash))
2357
+ }
2358
+ }
2359
+
2360
+ private func applyOpenAIDashboardFailure(message: String) async {
2361
+ await MainActor.run {
2362
+ if let cached = self.lastOpenAIDashboardSnapshot {
2363
+ self.openAIDashboard = cached
2364
+ let stamp = cached.updatedAt.formatted(date: .abbreviated, time: .shortened)
2365
+ self.lastOpenAIDashboardError =
2366
+ "Last OpenAI dashboard refresh failed: \(message). Cached values from \(stamp)."
2367
+ } else {
2368
+ self.lastOpenAIDashboardError = message
2369
+ self.openAIDashboard = nil
2370
+ }
2371
+ }
2372
+ }
2373
+
2374
+ private func refreshOpenAIDashboardIfNeeded(force: Bool = false) async {
2375
+ guard self.isEnabled(.codex), self.settings.openAIWebAccessEnabled else {
2376
+ await MainActor.run {
2377
+ self.openAIDashboard = nil
2378
+ self.lastOpenAIDashboardError = nil
2379
+ self.lastOpenAIDashboardSnapshot = nil
2380
+ self.lastOpenAIDashboardTargetEmail = nil
2381
+ self.openAIDashboardRequiresLogin = false
2382
+ self.openAIDashboardCookieImportStatus = nil
2383
+ self.openAIDashboardCookieImportDebugLog = nil
2384
+ self.lastOpenAIDashboardCookieImportAttemptAt = nil
2385
+ self.lastOpenAIDashboardCookieImportEmail = nil
2386
+ }
2387
+ return
2388
+ }
2389
+
2390
+ let targetEmail = self.codexAccountEmailForOpenAIDashboard()
2391
+ self.handleOpenAIWebTargetEmailChangeIfNeeded(targetEmail: targetEmail)
2392
+
2393
+ let now = Date()
2394
+ let minInterval = max(self.settings.refreshFrequency.seconds ?? 0, 120)
2395
+ if !force,
2396
+ !self.openAIWebAccountDidChange,
2397
+ self.lastOpenAIDashboardError == nil,
2398
+ let snapshot = self.lastOpenAIDashboardSnapshot,
2399
+ now.timeIntervalSince(snapshot.updatedAt) < minInterval
2400
+ {
2401
+ return
2402
+ }
2403
+
2404
+ if self.openAIWebDebugLines.isEmpty {
2405
+ self.resetOpenAIWebDebugLog(context: "refresh")
2406
+ } else {
2407
+ let stamp = Date().formatted(date: .abbreviated, time: .shortened)
2408
+ self.logOpenAIWeb("[\(stamp)] OpenAI web refresh start")
2409
+ }
2410
+ let log: (String) -> Void = { [weak self] line in
2411
+ guard let self else { return }
2412
+ self.logOpenAIWeb(line)
2413
+ }
2414
+
2415
+ do {
2416
+ let normalized = targetEmail?
2417
+ .trimmingCharacters(in: .whitespacesAndNewlines)
2418
+ .lowercased()
2419
+ var effectiveEmail = targetEmail
2420
+
2421
+ // Use a per-email persistent `WKWebsiteDataStore` so multiple dashboard sessions can coexist.
2422
+ // Strategy:
2423
+ // - Try the existing per-email WebKit cookie store first (fast; avoids Keychain prompts).
2424
+ // - On login-required or account mismatch, import cookies from the configured browser order and retry once.
2425
+ if self.openAIWebAccountDidChange, let targetEmail, !targetEmail.isEmpty {
2426
+ // On account switches, proactively re-import cookies so we don't show stale data from the previous
2427
+ // user.
2428
+ if let imported = await self.importOpenAIDashboardCookiesIfNeeded(
2429
+ targetEmail: targetEmail,
2430
+ force: true)
2431
+ {
2432
+ effectiveEmail = imported
2433
+ }
2434
+ self.openAIWebAccountDidChange = false
2435
+ }
2436
+
2437
+ var dash = try await OpenAIDashboardFetcher().loadLatestDashboard(
2438
+ accountEmail: effectiveEmail,
2439
+ logger: log,
2440
+ debugDumpHTML: false)
2441
+
2442
+ if self.dashboardEmailMismatch(expected: normalized, actual: dash.signedInEmail) {
2443
+ if let imported = await self.importOpenAIDashboardCookiesIfNeeded(
2444
+ targetEmail: targetEmail,
2445
+ force: true)
2446
+ {
2447
+ effectiveEmail = imported
2448
+ }
2449
+ dash = try await OpenAIDashboardFetcher().loadLatestDashboard(
2450
+ accountEmail: effectiveEmail,
2451
+ logger: log,
2452
+ debugDumpHTML: false)
2453
+ }
2454
+
2455
+ if self.dashboardEmailMismatch(expected: normalized, actual: dash.signedInEmail) {
2456
+ let signedIn = dash.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "unknown"
2457
+ await MainActor.run {
2458
+ self.openAIDashboard = nil
2459
+ self.lastOpenAIDashboardError = [
2460
+ "OpenAI dashboard signed in as \(signedIn), but Codex uses \(normalized ?? "unknown").",
2461
+ "Switch accounts in your browser and re-enable “Access OpenAI via web”.",
2462
+ ].joined(separator: " ")
2463
+ self.openAIDashboardRequiresLogin = true
2464
+ }
2465
+ return
2466
+ }
2467
+
2468
+ await self.applyOpenAIDashboard(dash, targetEmail: effectiveEmail)
2469
+ } catch let OpenAIDashboardFetcher.FetchError.noDashboardData(body) {
2470
+ // Often indicates a missing/stale session without an obvious login prompt. Retry once after
2471
+ // importing cookies from the user's browser.
2472
+ let targetEmail = self.codexAccountEmailForOpenAIDashboard()
2473
+ var effectiveEmail = targetEmail
2474
+ if let imported = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true) {
2475
+ effectiveEmail = imported
2476
+ }
2477
+ do {
2478
+ let dash = try await OpenAIDashboardFetcher().loadLatestDashboard(
2479
+ accountEmail: effectiveEmail,
2480
+ logger: log,
2481
+ debugDumpHTML: true)
2482
+ await self.applyOpenAIDashboard(dash, targetEmail: effectiveEmail)
2483
+ } catch let OpenAIDashboardFetcher.FetchError.noDashboardData(retryBody) {
2484
+ let finalBody = retryBody.isEmpty ? body : retryBody
2485
+ let message = self.openAIDashboardFriendlyError(
2486
+ body: finalBody,
2487
+ targetEmail: targetEmail,
2488
+ cookieImportStatus: self.openAIDashboardCookieImportStatus)
2489
+ ?? OpenAIDashboardFetcher.FetchError.noDashboardData(body: finalBody).localizedDescription
2490
+ await self.applyOpenAIDashboardFailure(message: message)
2491
+ } catch {
2492
+ await self.applyOpenAIDashboardFailure(message: error.localizedDescription)
2493
+ }
2494
+ } catch OpenAIDashboardFetcher.FetchError.loginRequired {
2495
+ let targetEmail = self.codexAccountEmailForOpenAIDashboard()
2496
+ var effectiveEmail = targetEmail
2497
+ if let imported = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true) {
2498
+ effectiveEmail = imported
2499
+ }
2500
+ do {
2501
+ let dash = try await OpenAIDashboardFetcher().loadLatestDashboard(
2502
+ accountEmail: effectiveEmail,
2503
+ logger: log,
2504
+ debugDumpHTML: true)
2505
+ await self.applyOpenAIDashboard(dash, targetEmail: effectiveEmail)
2506
+ } catch OpenAIDashboardFetcher.FetchError.loginRequired {
2507
+ await MainActor.run {
2508
+ self.lastOpenAIDashboardError = [
2509
+ "OpenAI web access requires a signed-in chatgpt.com session.",
2510
+ "Sign in using \(self.codexBrowserCookieOrder.loginHint), " +
2511
+ "then re-enable “Access OpenAI via web”.",
2512
+ ].joined(separator: " ")
2513
+ self.openAIDashboard = self.lastOpenAIDashboardSnapshot
2514
+ self.openAIDashboardRequiresLogin = true
2515
+ }
2516
+ } catch {
2517
+ await self.applyOpenAIDashboardFailure(message: error.localizedDescription)
2518
+ }
2519
+ } catch {
2520
+ await self.applyOpenAIDashboardFailure(message: error.localizedDescription)
2521
+ }
2522
+ }
2523
+
2524
+ // MARK: - OpenAI web account switching
2525
+
2526
+ /// Detect Codex account email changes and clear stale OpenAI web state so the UI can't show the wrong user.
2527
+ /// This does not delete other per-email WebKit cookie stores (we keep multiple accounts around).
2528
+ func handleOpenAIWebTargetEmailChangeIfNeeded(targetEmail: String?) {
2529
+ let normalized = targetEmail?
2530
+ .trimmingCharacters(in: .whitespacesAndNewlines)
2531
+ .lowercased()
2532
+
2533
+ guard let normalized, !normalized.isEmpty else { return }
2534
+
2535
+ let previous = self.lastOpenAIDashboardTargetEmail
2536
+ self.lastOpenAIDashboardTargetEmail = normalized
2537
+
2538
+ if let previous,
2539
+ !previous.isEmpty,
2540
+ previous != normalized
2541
+ {
2542
+ let stamp = Date().formatted(date: .abbreviated, time: .shortened)
2543
+ self.logOpenAIWeb(
2544
+ "[\(stamp)] Codex account changed: \(previous) → \(normalized); " +
2545
+ "clearing OpenAI web snapshot")
2546
+ self.openAIWebAccountDidChange = true
2547
+ self.openAIDashboard = nil
2548
+ self.lastOpenAIDashboardSnapshot = nil
2549
+ self.lastOpenAIDashboardError = nil
2550
+ self.openAIDashboardRequiresLogin = true
2551
+ self.openAIDashboardCookieImportStatus = "Codex account changed; importing browser cookies…"
2552
+ self.lastOpenAIDashboardCookieImportAttemptAt = nil
2553
+ self.lastOpenAIDashboardCookieImportEmail = nil
2554
+ }
2555
+ }
2556
+
2557
+ func importOpenAIDashboardBrowserCookiesNow() async {
2558
+ self.resetOpenAIWebDebugLog(context: "manual import")
2559
+ let targetEmail = self.codexAccountEmailForOpenAIDashboard()
2560
+ _ = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true)
2561
+ await self.refreshOpenAIDashboardIfNeeded(force: true)
2562
+ }
2563
+
2564
+ private func importOpenAIDashboardCookiesIfNeeded(targetEmail: String?, force: Bool) async -> String? {
2565
+ let normalizedTarget = targetEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
2566
+ let allowAnyAccount = normalizedTarget == nil || normalizedTarget?.isEmpty == true
2567
+
2568
+ let now = Date()
2569
+ let lastEmail = self.lastOpenAIDashboardCookieImportEmail
2570
+ let lastAttempt = self.lastOpenAIDashboardCookieImportAttemptAt ?? .distantPast
2571
+
2572
+ let shouldAttempt: Bool = if force {
2573
+ true
2574
+ } else {
2575
+ if allowAnyAccount {
2576
+ now.timeIntervalSince(lastAttempt) > 300
2577
+ } else {
2578
+ self.openAIDashboardRequiresLogin &&
2579
+ (lastEmail?.lowercased() != normalizedTarget?.lowercased() || now
2580
+ .timeIntervalSince(lastAttempt) > 300)
2581
+ }
2582
+ }
2583
+
2584
+ guard shouldAttempt else { return normalizedTarget }
2585
+ self.lastOpenAIDashboardCookieImportEmail = normalizedTarget
2586
+ self.lastOpenAIDashboardCookieImportAttemptAt = now
2587
+
2588
+ let stamp = now.formatted(date: .abbreviated, time: .shortened)
2589
+ let targetLabel = normalizedTarget ?? "unknown"
2590
+ self.logOpenAIWeb("[\(stamp)] import start (target=\(targetLabel))")
2591
+
2592
+ do {
2593
+ let log: (String) -> Void = { [weak self] message in
2594
+ guard let self else { return }
2595
+ self.logOpenAIWeb(message)
2596
+ }
2597
+
2598
+ let result = try await OpenAIDashboardBrowserCookieImporter()
2599
+ .importBestCookies(
2600
+ intoAccountEmail: normalizedTarget,
2601
+ allowAnyAccount: allowAnyAccount,
2602
+ logger: log)
2603
+ let effectiveEmail = result.signedInEmail?
2604
+ .trimmingCharacters(in: .whitespacesAndNewlines)
2605
+ .isEmpty == false
2606
+ ? result.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
2607
+ : normalizedTarget
2608
+ self.lastOpenAIDashboardCookieImportEmail = effectiveEmail ?? normalizedTarget
2609
+ await MainActor.run {
2610
+ let signed = result.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
2611
+ let matchText = result.matchesCodexEmail ? "matches Codex" : "does not match Codex"
2612
+ if let signed, !signed.isEmpty {
2613
+ self.openAIDashboardCookieImportStatus =
2614
+ allowAnyAccount
2615
+ ? [
2616
+ "Using \(result.sourceLabel) cookies (\(result.cookieCount)).",
2617
+ "Signed in as \(signed).",
2618
+ ].joined(separator: " ")
2619
+ : [
2620
+ "Using \(result.sourceLabel) cookies (\(result.cookieCount)).",
2621
+ "Signed in as \(signed) (\(matchText)).",
2622
+ ].joined(separator: " ")
2623
+ } else {
2624
+ self.openAIDashboardCookieImportStatus =
2625
+ "Using \(result.sourceLabel) cookies (\(result.cookieCount))."
2626
+ }
2627
+ }
2628
+ return effectiveEmail
2629
+ } catch let err as OpenAIDashboardBrowserCookieImporter.ImportError {
2630
+ switch err {
2631
+ case let .noMatchingAccount(found):
2632
+ let foundText: String = if found.isEmpty {
2633
+ "no signed-in session detected in \(self.codexBrowserCookieOrder.loginHint)"
2634
+ } else {
2635
+ found
2636
+ .sorted { lhs, rhs in
2637
+ if lhs.sourceLabel == rhs.sourceLabel { return lhs.email < rhs.email }
2638
+ return lhs.sourceLabel < rhs.sourceLabel
2639
+ }
2640
+ .map { "\($0.sourceLabel): \($0.email)" }
2641
+ .joined(separator: " • ")
2642
+ }
2643
+ self.logOpenAIWeb("[\(stamp)] import mismatch: \(foundText)")
2644
+ await MainActor.run {
2645
+ self.openAIDashboardCookieImportStatus = allowAnyAccount
2646
+ ? [
2647
+ "No signed-in OpenAI web session found.",
2648
+ "Found \(foundText).",
2649
+ ].joined(separator: " ")
2650
+ : [
2651
+ "Browser cookies do not match Codex account (\(normalizedTarget ?? "unknown")).",
2652
+ "Found \(foundText).",
2653
+ ].joined(separator: " ")
2654
+ // Treat mismatch like "not logged in" for the current Codex account.
2655
+ self.openAIDashboardRequiresLogin = true
2656
+ self.openAIDashboard = nil
2657
+ }
2658
+ case .noCookiesFound,
2659
+ .browserAccessDenied,
2660
+ .dashboardStillRequiresLogin:
2661
+ self.logOpenAIWeb("[\(stamp)] import failed: \(err.localizedDescription)")
2662
+ await MainActor.run {
2663
+ self.openAIDashboardCookieImportStatus =
2664
+ "Browser cookie import failed: \(err.localizedDescription)"
2665
+ self.openAIDashboardRequiresLogin = true
2666
+ }
2667
+ }
2668
+ } catch {
2669
+ self.logOpenAIWeb("[\(stamp)] import failed: \(error.localizedDescription)")
2670
+ await MainActor.run {
2671
+ self.openAIDashboardCookieImportStatus =
2672
+ "Browser cookie import failed: \(error.localizedDescription)"
2673
+ }
2674
+ }
2675
+ return nil
2676
+ }
2677
+
2678
+ private func resetOpenAIWebDebugLog(context: String) {
2679
+ let stamp = Date().formatted(date: .abbreviated, time: .shortened)
2680
+ self.openAIWebDebugLines.removeAll(keepingCapacity: true)
2681
+ self.openAIDashboardCookieImportDebugLog = nil
2682
+ self.logOpenAIWeb("[\(stamp)] OpenAI web \(context) start")
2683
+ }
2684
+
2685
+ private func logOpenAIWeb(_ message: String) {
2686
+ self.openAIWebLogger.debug(message)
2687
+ self.openAIWebDebugLines.append(message)
2688
+ if self.openAIWebDebugLines.count > 240 {
2689
+ self.openAIWebDebugLines.removeFirst(self.openAIWebDebugLines.count - 240)
2690
+ }
2691
+ self.openAIDashboardCookieImportDebugLog = self.openAIWebDebugLines.joined(separator: "\n")
2692
+ }
2693
+
2694
+ private func dashboardEmailMismatch(expected: String?, actual: String?) -> Bool {
2695
+ guard let expected, !expected.isEmpty else { return false }
2696
+ guard let raw = actual?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return false }
2697
+ return raw.lowercased() != expected.lowercased()
2698
+ }
2699
+
2700
+ func codexAccountEmailForOpenAIDashboard() -> String? {
2701
+ let direct = self.snapshots[.codex]?.accountEmail(for: .codex)?
2702
+ .trimmingCharacters(in: .whitespacesAndNewlines)
2703
+ if let direct, !direct.isEmpty { return direct }
2704
+ let fallback = self.codexFetcher.loadAccountInfo().email?.trimmingCharacters(in: .whitespacesAndNewlines)
2705
+ if let fallback, !fallback.isEmpty { return fallback }
2706
+ let cached = self.openAIDashboard?.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
2707
+ if let cached, !cached.isEmpty { return cached }
2708
+ let imported = self.lastOpenAIDashboardCookieImportEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
2709
+ if let imported, !imported.isEmpty { return imported }
2710
+ return nil
2711
+ }
2712
+ }
2713
+
2714
+ extension UsageStore {
2715
+ func debugDumpClaude() async {
2716
+ let output = await self.claudeFetcher.debugRawProbe(model: "sonnet")
2717
+ let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("runic-claude-probe.txt")
2718
+ try? output.write(to: url, atomically: true, encoding: .utf8)
2719
+ await MainActor.run {
2720
+ let snippet = String(output.prefix(180)).replacingOccurrences(of: "\n", with: " ")
2721
+ self.errors[.claude] = "[Claude] \(snippet) (saved: \(url.path))"
2722
+ NSWorkspace.shared.open(url)
2723
+ }
2724
+ }
2725
+
2726
+ func dumpLog(toFileFor provider: UsageProvider) async -> URL? {
2727
+ let text = await self.debugLog(for: provider)
2728
+ let filename = "runic-\(provider.rawValue)-probe.txt"
2729
+ let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(filename)
2730
+ do {
2731
+ try text.write(to: url, atomically: true, encoding: .utf8)
2732
+ _ = await MainActor.run { NSWorkspace.shared.open(url) }
2733
+ return url
2734
+ } catch {
2735
+ await MainActor.run {
2736
+ self.errors[provider] = "Failed to save log: \(error.localizedDescription)"
2737
+ }
2738
+ return nil
2739
+ }
2740
+ }
2741
+
2742
+ func debugClaudeDump() async -> String {
2743
+ await ClaudeStatusProbe.latestDumps()
2744
+ }
2745
+
2746
+ func debugLog(for provider: UsageProvider) async -> String {
2747
+ if let cached = self.probeLogs[provider], !cached.isEmpty {
2748
+ return cached
2749
+ }
2750
+
2751
+ let settingsSnapshot = await MainActor.run {
2752
+ ProviderSettingsSnapshot(
2753
+ debugMenuEnabled: self.settings.debugMenuEnabled,
2754
+ codex: ProviderSettingsSnapshot.CodexProviderSettings(
2755
+ usageDataSource: self.settings.codexUsageDataSource),
2756
+ claude: ProviderSettingsSnapshot.ClaudeProviderSettings(
2757
+ usageDataSource: self.settings.claudeUsageDataSource,
2758
+ webExtrasEnabled: self.settings.claudeWebExtrasEnabled),
2759
+ zai: ProviderSettingsSnapshot.ZaiProviderSettings(),
2760
+ copilot: ProviderSettingsSnapshot.CopilotProviderSettings(
2761
+ apiToken: self.settings.copilotAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
2762
+ ? nil : self.settings.copilotAPIToken),
2763
+ azure: ProviderSettingsSnapshot.AzureProviderSettings(
2764
+ apiToken: self.settings.azureOpenAIAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
2765
+ ? nil : self.settings.azureOpenAIAPIToken,
2766
+ endpoint: self.settings.azureOpenAIEndpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
2767
+ ? nil : self.settings.azureOpenAIEndpoint,
2768
+ deployment: self.settings.azureOpenAIDeployment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
2769
+ ? nil : self.settings.azureOpenAIDeployment,
2770
+ apiVersion: self.settings.azureOpenAIAPIVersion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
2771
+ ? nil : self.settings.azureOpenAIAPIVersion),
2772
+ bedrock: ProviderSettingsSnapshot.BedrockProviderSettings(
2773
+ region: self.settings.bedrockRegion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
2774
+ ? nil : self.settings.bedrockRegion,
2775
+ profile: self.settings.bedrockAWSProfile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
2776
+ ? nil : self.settings.bedrockAWSProfile,
2777
+ modelID: self.settings.bedrockModelID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
2778
+ ? nil : self.settings.bedrockModelID),
2779
+ vertexai: ProviderSettingsSnapshot.VertexAIProviderSettings(
2780
+ project: self.settings.vertexaiProject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
2781
+ ? nil : self.settings.vertexaiProject,
2782
+ location: self.settings.vertexaiLocation.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
2783
+ ? nil : self.settings.vertexaiLocation))
2784
+ }
2785
+
2786
+ let debugContext = ProviderFetchContext(
2787
+ runtime: .app,
2788
+ sourceMode: .auto,
2789
+ includeCredits: true,
2790
+ webTimeout: 15,
2791
+ webDebugDumpHTML: false,
2792
+ verbose: false,
2793
+ env: ProcessInfo.processInfo.environment,
2794
+ settings: settingsSnapshot,
2795
+ fetcher: self.codexFetcher,
2796
+ claudeFetcher: self.claudeFetcher)
2797
+
2798
+ let claudeWebExtrasEnabled = self.settings.claudeWebExtrasEnabled
2799
+ let claudeUsageDataSource = self.settings.claudeUsageDataSource
2800
+ let claudeDebugMenuEnabled = self.settings.debugMenuEnabled
2801
+ return await Task.detached(priority: .utility) { () -> String in
2802
+ switch provider {
2803
+ case .codex:
2804
+ let raw = await self.codexFetcher.debugRawRateLimits()
2805
+ await MainActor.run { self.probeLogs[.codex] = raw }
2806
+ return raw
2807
+ case .claude:
2808
+ let text = await self.runWithTimeout(seconds: 15) {
2809
+ var lines: [String] = []
2810
+ let hasKey = ClaudeWebAPIFetcher.hasSessionKey { msg in lines.append(msg) }
2811
+ let hasOAuthCredentials = (try? ClaudeOAuthCredentialsStore.load()) != nil
2812
+
2813
+ let strategy = ClaudeProviderDescriptor.resolveUsageStrategy(
2814
+ debugMenuEnabled: claudeDebugMenuEnabled,
2815
+ selectedDataSource: claudeUsageDataSource,
2816
+ webExtrasEnabled: claudeWebExtrasEnabled,
2817
+ hasWebSession: hasKey,
2818
+ hasOAuthCredentials: hasOAuthCredentials)
2819
+
2820
+ await MainActor.run {
2821
+ if self.settings.claudeUsageDataSource != strategy.dataSource {
2822
+ self.settings.claudeUsageDataSource = strategy.dataSource
2823
+ }
2824
+ }
2825
+
2826
+ lines.append("strategy=\(strategy.dataSource.rawValue)")
2827
+ lines.append("hasSessionKey=\(hasKey)")
2828
+ lines.append("hasOAuthCredentials=\(hasOAuthCredentials)")
2829
+ if strategy.useWebExtras {
2830
+ lines.append("web_extras=enabled")
2831
+ }
2832
+ lines.append("")
2833
+
2834
+ switch strategy.dataSource {
2835
+ case .web:
2836
+ do {
2837
+ let web = try await ClaudeWebAPIFetcher.fetchUsage { msg in lines.append(msg) }
2838
+ lines.append("")
2839
+ lines.append("Web API summary:")
2840
+
2841
+ let sessionReset = web.sessionResetsAt?.description ?? "nil"
2842
+ lines.append("session_used=\(web.sessionPercentUsed)% resetsAt=\(sessionReset)")
2843
+
2844
+ if let weekly = web.weeklyPercentUsed {
2845
+ let weeklyReset = web.weeklyResetsAt?.description ?? "nil"
2846
+ lines.append("weekly_used=\(weekly)% resetsAt=\(weeklyReset)")
2847
+ } else {
2848
+ lines.append("weekly_used=nil")
2849
+ }
2850
+
2851
+ lines.append("opus_used=\(web.opusPercentUsed?.description ?? "nil")")
2852
+
2853
+ if let extra = web.extraUsageCost {
2854
+ let resetsAt = extra.resetsAt?.description ?? "nil"
2855
+ let period = extra.period ?? "nil"
2856
+ let line =
2857
+ "extra_usage used=\(extra.used) limit=\(extra.limit) " +
2858
+ "currency=\(extra.currencyCode) period=\(period) resetsAt=\(resetsAt)"
2859
+ lines.append(line)
2860
+ } else {
2861
+ lines.append("extra_usage=nil")
2862
+ }
2863
+
2864
+ return lines.joined(separator: "\n")
2865
+ } catch {
2866
+ lines.append("Web API failed: \(error.localizedDescription)")
2867
+ return lines.joined(separator: "\n")
2868
+ }
2869
+ case .cli:
2870
+ let cli = await self.claudeFetcher.debugRawProbe(model: "sonnet")
2871
+ lines.append(cli)
2872
+ return lines.joined(separator: "\n")
2873
+ case .oauth:
2874
+ lines.append("OAuth source selected.")
2875
+ return lines.joined(separator: "\n")
2876
+ }
2877
+ }
2878
+ await MainActor.run { self.probeLogs[.claude] = text }
2879
+ return text
2880
+ case .zai:
2881
+ let text = self.debugTokenSummary(
2882
+ provider: "zai",
2883
+ resolution: ProviderTokenResolver.zaiResolution())
2884
+ await MainActor.run { self.probeLogs[.zai] = text }
2885
+ return text
2886
+ case .gemini:
2887
+ let text = await self.runWithTimeout(seconds: 15) {
2888
+ await self.debugProviderProbe(
2889
+ provider: .gemini,
2890
+ context: debugContext,
2891
+ settings: settingsSnapshot)
2892
+ }
2893
+ await MainActor.run { self.probeLogs[.gemini] = text }
2894
+ return text
2895
+ case .antigravity:
2896
+ let text = await self.runWithTimeout(seconds: 15) {
2897
+ await self.debugProviderProbe(
2898
+ provider: .antigravity,
2899
+ context: debugContext,
2900
+ settings: settingsSnapshot)
2901
+ }
2902
+ await MainActor.run { self.probeLogs[.antigravity] = text }
2903
+ return text
2904
+ case .cursor:
2905
+ let text = await self.runWithTimeout(seconds: 15) {
2906
+ await self.debugProviderProbe(
2907
+ provider: .cursor,
2908
+ context: debugContext,
2909
+ settings: settingsSnapshot)
2910
+ }
2911
+ await MainActor.run { self.probeLogs[.cursor] = text }
2912
+ return text
2913
+ case .factory:
2914
+ let text = await self.runWithTimeout(seconds: 15) {
2915
+ await self.debugProviderProbe(
2916
+ provider: .factory,
2917
+ context: debugContext,
2918
+ settings: settingsSnapshot)
2919
+ }
2920
+ await MainActor.run { self.probeLogs[.factory] = text }
2921
+ return text
2922
+ case .copilot:
2923
+ let text = await self.runWithTimeout(seconds: 15) {
2924
+ await self.debugProviderProbe(
2925
+ provider: .copilot,
2926
+ context: debugContext,
2927
+ settings: settingsSnapshot)
2928
+ }
2929
+ await MainActor.run { self.probeLogs[.copilot] = text }
2930
+ return text
2931
+ case .minimax:
2932
+ let text = await self.runWithTimeout(seconds: 15) {
2933
+ await self.debugProviderProbe(
2934
+ provider: .minimax,
2935
+ context: debugContext,
2936
+ settings: settingsSnapshot)
2937
+ }
2938
+ await MainActor.run { self.probeLogs[.minimax] = text }
2939
+ return text
2940
+ case .openrouter:
2941
+ let text = await self.runWithTimeout(seconds: 15) {
2942
+ await self.debugProviderProbe(
2943
+ provider: .openrouter,
2944
+ context: debugContext,
2945
+ settings: settingsSnapshot)
2946
+ }
2947
+ await MainActor.run { self.probeLogs[.openrouter] = text }
2948
+ return text
2949
+ case .groq:
2950
+ let text = await self.runWithTimeout(seconds: 15) {
2951
+ await self.debugProviderProbe(
2952
+ provider: .groq,
2953
+ context: debugContext,
2954
+ settings: settingsSnapshot)
2955
+ }
2956
+ await MainActor.run { self.probeLogs[.groq] = text }
2957
+ return text
2958
+ case .deepseek:
2959
+ let text = await self.runWithTimeout(seconds: 15) {
2960
+ await self.debugProviderProbe(
2961
+ provider: .deepseek,
2962
+ context: debugContext,
2963
+ settings: settingsSnapshot)
2964
+ }
2965
+ await MainActor.run { self.probeLogs[.deepseek] = text }
2966
+ return text
2967
+ case .fireworks:
2968
+ let text = await self.runWithTimeout(seconds: 15) {
2969
+ await self.debugProviderProbe(
2970
+ provider: .fireworks,
2971
+ context: debugContext,
2972
+ settings: settingsSnapshot)
2973
+ }
2974
+ await MainActor.run { self.probeLogs[.fireworks] = text }
2975
+ return text
2976
+ case .mistral:
2977
+ let text = await self.runWithTimeout(seconds: 15) {
2978
+ await self.debugProviderProbe(
2979
+ provider: .mistral,
2980
+ context: debugContext,
2981
+ settings: settingsSnapshot)
2982
+ }
2983
+ await MainActor.run { self.probeLogs[.mistral] = text }
2984
+ return text
2985
+ case .perplexity:
2986
+ let text = await self.runWithTimeout(seconds: 15) {
2987
+ await self.debugProviderProbe(
2988
+ provider: .perplexity,
2989
+ context: debugContext,
2990
+ settings: settingsSnapshot)
2991
+ }
2992
+ await MainActor.run { self.probeLogs[.perplexity] = text }
2993
+ return text
2994
+ case .kimi:
2995
+ let text = await self.runWithTimeout(seconds: 15) {
2996
+ await self.debugProviderProbe(
2997
+ provider: .kimi,
2998
+ context: debugContext,
2999
+ settings: settingsSnapshot)
3000
+ }
3001
+ await MainActor.run { self.probeLogs[.kimi] = text }
3002
+ return text
3003
+ case .auggie:
3004
+ let text = await self.runWithTimeout(seconds: 15) {
3005
+ await self.debugProviderProbe(
3006
+ provider: .auggie,
3007
+ context: debugContext,
3008
+ settings: settingsSnapshot)
3009
+ }
3010
+ await MainActor.run { self.probeLogs[.auggie] = text }
3011
+ return text
3012
+ case .together:
3013
+ let text = await self.runWithTimeout(seconds: 15) {
3014
+ await self.debugProviderProbe(
3015
+ provider: .together,
3016
+ context: debugContext,
3017
+ settings: settingsSnapshot)
3018
+ }
3019
+ await MainActor.run { self.probeLogs[.together] = text }
3020
+ return text
3021
+ case .cohere:
3022
+ let text = await self.runWithTimeout(seconds: 15) {
3023
+ await self.debugProviderProbe(
3024
+ provider: .cohere,
3025
+ context: debugContext,
3026
+ settings: settingsSnapshot)
3027
+ }
3028
+ await MainActor.run { self.probeLogs[.cohere] = text }
3029
+ return text
3030
+ case .xai:
3031
+ let text = await self.runWithTimeout(seconds: 15) {
3032
+ await self.debugProviderProbe(
3033
+ provider: .xai,
3034
+ context: debugContext,
3035
+ settings: settingsSnapshot)
3036
+ }
3037
+ await MainActor.run { self.probeLogs[.xai] = text }
3038
+ return text
3039
+ case .cerebras:
3040
+ let text = await self.runWithTimeout(seconds: 15) {
3041
+ await self.debugProviderProbe(
3042
+ provider: .cerebras,
3043
+ context: debugContext,
3044
+ settings: settingsSnapshot)
3045
+ }
3046
+ await MainActor.run { self.probeLogs[.cerebras] = text }
3047
+ return text
3048
+ case .sambanova:
3049
+ let text = await self.runWithTimeout(seconds: 15) {
3050
+ await self.debugProviderProbe(
3051
+ provider: .sambanova,
3052
+ context: debugContext,
3053
+ settings: settingsSnapshot)
3054
+ }
3055
+ await MainActor.run { self.probeLogs[.sambanova] = text }
3056
+ return text
3057
+ case .azure:
3058
+ let text = await self.runWithTimeout(seconds: 15) {
3059
+ await self.debugProviderProbe(
3060
+ provider: .azure,
3061
+ context: debugContext,
3062
+ settings: settingsSnapshot)
3063
+ }
3064
+ await MainActor.run { self.probeLogs[.azure] = text }
3065
+ return text
3066
+ case .bedrock:
3067
+ let text = await self.runWithTimeout(seconds: 15) {
3068
+ await self.debugProviderProbe(
3069
+ provider: .bedrock,
3070
+ context: debugContext,
3071
+ settings: settingsSnapshot)
3072
+ }
3073
+ await MainActor.run { self.probeLogs[.bedrock] = text }
3074
+ return text
3075
+ case .vertexai:
3076
+ let text = await self.runWithTimeout(seconds: 15) {
3077
+ await self.debugProviderProbe(
3078
+ provider: .vertexai,
3079
+ context: debugContext,
3080
+ settings: settingsSnapshot)
3081
+ }
3082
+ await MainActor.run { self.probeLogs[.vertexai] = text }
3083
+ return text
3084
+ case .qwen:
3085
+ let text = await self.runWithTimeout(seconds: 15) {
3086
+ await self.debugProviderProbe(
3087
+ provider: .qwen,
3088
+ context: debugContext,
3089
+ settings: settingsSnapshot)
3090
+ }
3091
+ await MainActor.run { self.probeLogs[.qwen] = text }
3092
+ return text
3093
+ }
3094
+ }.value
3095
+ }
3096
+
3097
+ private nonisolated func debugProviderProbe(
3098
+ provider: UsageProvider,
3099
+ context: ProviderFetchContext,
3100
+ settings: ProviderSettingsSnapshot) async -> String
3101
+ {
3102
+ var lines: [String] = [
3103
+ "provider=\(provider.rawValue)"
3104
+ ]
3105
+
3106
+ lines.append(contentsOf: self.debugCredentialLines(for: provider, settings: settings))
3107
+ let descriptor = ProviderDescriptorRegistry.descriptor(for: provider)
3108
+ let outcome = await descriptor.fetchOutcome(context: context)
3109
+ lines.append(contentsOf: self.debugAttemptLines(from: outcome.attempts))
3110
+
3111
+ switch outcome.result {
3112
+ case let .success(result):
3113
+ lines.append("result=success")
3114
+ lines.append("strategy_id=\(result.strategyID)")
3115
+ lines.append("strategy_kind=\(self.debugStrategyKindLabel(result.strategyKind))")
3116
+ lines.append("source_label=\(result.sourceLabel)")
3117
+ lines.append(contentsOf: self.debugUsageSummary(result.usage))
3118
+ case let .failure(error):
3119
+ lines.append("result=failure")
3120
+ lines.append("error=\(error.localizedDescription)")
3121
+ }
3122
+
3123
+ return lines.joined(separator: "\n")
3124
+ }
3125
+
3126
+ private nonisolated func debugCredentialLines(
3127
+ for provider: UsageProvider,
3128
+ settings: ProviderSettingsSnapshot) -> [String]
3129
+ {
3130
+ switch provider {
3131
+ case .zai:
3132
+ return [self.debugTokenSummary(provider: "zai", resolution: ProviderTokenResolver.zaiResolution())]
3133
+ case .copilot:
3134
+ return [self.debugTokenSummary(provider: "copilot", resolution: ProviderTokenResolver.copilotResolution())]
3135
+ case .minimax:
3136
+ return [
3137
+ self.debugTokenSummary(
3138
+ provider: "minimax.api",
3139
+ resolution: ProviderTokenResolver.minimaxApiKeyResolution()),
3140
+ self.debugTokenSummary(
3141
+ provider: "minimax.cookie",
3142
+ resolution: ProviderTokenResolver.minimaxCookieHeaderResolution()),
3143
+ self.debugTokenSummary(
3144
+ provider: "minimax.group",
3145
+ resolution: ProviderTokenResolver.minimaxGroupResolution()),
3146
+ ]
3147
+ case .openrouter:
3148
+ return [self.debugTokenSummary(provider: "openrouter", resolution: ProviderTokenResolver.openRouterResolution())]
3149
+ case .groq:
3150
+ return [self.debugTokenSummary(provider: "groq", resolution: ProviderTokenResolver.groqResolution())]
3151
+ case .deepseek:
3152
+ return [self.debugTokenSummary(provider: "deepseek", resolution: ProviderTokenResolver.deepSeekResolution())]
3153
+ case .fireworks:
3154
+ return [self.debugTokenSummary(provider: "fireworks", resolution: ProviderTokenResolver.fireworksResolution())]
3155
+ case .mistral:
3156
+ return [self.debugTokenSummary(provider: "mistral", resolution: ProviderTokenResolver.mistralResolution())]
3157
+ case .perplexity:
3158
+ return [self.debugTokenSummary(provider: "perplexity", resolution: ProviderTokenResolver.perplexityResolution())]
3159
+ case .kimi:
3160
+ return [self.debugTokenSummary(provider: "kimi", resolution: ProviderTokenResolver.kimiResolution())]
3161
+ case .auggie:
3162
+ return [self.debugTokenSummary(provider: "auggie", resolution: ProviderTokenResolver.auggieResolution())]
3163
+ case .together:
3164
+ return [self.debugTokenSummary(provider: "together", resolution: ProviderTokenResolver.togetherResolution())]
3165
+ case .cohere:
3166
+ return [self.debugTokenSummary(provider: "cohere", resolution: ProviderTokenResolver.cohereResolution())]
3167
+ case .xai:
3168
+ return [self.debugTokenSummary(provider: "xai", resolution: ProviderTokenResolver.xaiResolution())]
3169
+ case .cerebras:
3170
+ return [self.debugTokenSummary(provider: "cerebras", resolution: ProviderTokenResolver.cerebrasResolution())]
3171
+ case .sambanova:
3172
+ return [self.debugTokenSummary(provider: "sambanova", resolution: ProviderTokenResolver.sambaNovaResolution())]
3173
+ case .azure:
3174
+ var lines: [String] = [
3175
+ "azure.endpoint=\(self.debugFieldValue(settings.azure?.endpoint))",
3176
+ "azure.deployment=\(self.debugFieldValue(settings.azure?.deployment))",
3177
+ "azure.apiVersion=\(self.debugFieldValue(settings.azure?.apiVersion))"
3178
+ ]
3179
+ lines.append(self.debugTokenSummary(provider: "azure.token", resolution: ProviderTokenResolver.azureOpenAIResolution()))
3180
+ return lines
3181
+ case .bedrock:
3182
+ return [
3183
+ "bedrock.region=\(self.debugFieldValue(settings.bedrock?.region))",
3184
+ "bedrock.profile=\(self.debugFieldValue(settings.bedrock?.profile))",
3185
+ "bedrock.model_filter=\(self.debugFieldValue(settings.bedrock?.modelID))",
3186
+ ]
3187
+ case .vertexai:
3188
+ return [
3189
+ "vertexai.project=\(self.debugFieldValue(settings.vertexai?.project))",
3190
+ "vertexai.location=\(self.debugFieldValue(settings.vertexai?.location))",
3191
+ ]
3192
+ case .qwen:
3193
+ return [self.debugTokenSummary(provider: "qwen", resolution: ProviderTokenResolver.qwenResolution())]
3194
+ case .gemini:
3195
+ return [
3196
+ "gemini.authType=\(GeminiStatusProbe.currentAuthType().rawValue)",
3197
+ ]
3198
+ case .antigravity, .cursor, .factory:
3199
+ return ["credentials=provider_internal"]
3200
+ case .codex, .claude:
3201
+ return []
3202
+ }
3203
+ }
3204
+
3205
+ private nonisolated func debugAttemptLines(from attempts: [ProviderFetchAttempt]) -> [String] {
3206
+ var lines: [String] = []
3207
+ if attempts.isEmpty {
3208
+ lines.append("attempts=none")
3209
+ return lines
3210
+ }
3211
+
3212
+ let attemptSummary = attempts.enumerated().map { index, attempt in
3213
+ let error = attempt.errorDescription ?? "nil"
3214
+ return "attempt\(index + 1): id=\(attempt.strategyID) kind=\(attempt.kind) available=\(attempt.wasAvailable) error=\(error)"
3215
+ }
3216
+ lines.append("attempts=\(attempts.count)")
3217
+ lines.append(contentsOf: attemptSummary)
3218
+ return lines
3219
+ }
3220
+
3221
+ private nonisolated func debugUsageSummary(_ snapshot: UsageSnapshot) -> [String] {
3222
+ var lines: [String] = []
3223
+ lines.append(self.debugRateWindowSummary(label: "primary", window: snapshot.primary))
3224
+ if let secondary = snapshot.secondary {
3225
+ lines.append(self.debugRateWindowSummary(label: "secondary", window: secondary))
3226
+ }
3227
+ if let tertiary = snapshot.tertiary {
3228
+ lines.append(self.debugRateWindowSummary(label: "tertiary", window: tertiary))
3229
+ }
3230
+ if let identity = snapshot.identity {
3231
+ lines.append("identity.email=\(identity.accountEmail ?? "nil")")
3232
+ lines.append("identity.organization=\(identity.accountOrganization ?? "nil")")
3233
+ lines.append("identity.loginMethod=\(identity.loginMethod ?? "nil")")
3234
+ }
3235
+ if let providerCost = snapshot.providerCost {
3236
+ let resetsAt = providerCost.resetsAt?.description ?? "nil"
3237
+ lines.append(
3238
+ "provider_cost.used=\(providerCost.used) limit=\(providerCost.limit) " +
3239
+ "currency=\(providerCost.currencyCode) period=\(providerCost.period ?? "nil") resetsAt=\(resetsAt)")
3240
+ }
3241
+ return lines
3242
+ }
3243
+
3244
+ private nonisolated func debugRateWindowSummary(label: String, window: RateWindow) -> String {
3245
+ let resetsAt = window.resetsAt?.description ?? "nil"
3246
+ let windowMinutes = window.windowMinutes.map { "\($0)m" } ?? "nil"
3247
+ let resetDescription = window.resetDescription ?? "nil"
3248
+ let windowLabel = window.label ?? "nil"
3249
+ let used = String(format: "%.2f", window.usedPercent)
3250
+ let remaining = String(format: "%.2f", window.remainingPercent)
3251
+ return "\(label).usedPercent=\(used) remainingPercent=\(remaining) window=\(windowMinutes) resetsAt=\(resetsAt) label=\(windowLabel) desc=\(resetDescription)"
3252
+ }
3253
+
3254
+ private nonisolated func debugTokenSummary(provider: String, resolution: ProviderTokenResolution?) -> String {
3255
+ let tokenState = resolution == nil ? "missing" : "present"
3256
+ let source = resolution.flatMap { r in
3257
+ let key = r.sourceKey.map { "(\($0))" } ?? ""
3258
+ return "\(r.source.rawValue)\(key)"
3259
+ } ?? "nil"
3260
+ let length = resolution?.token.count ?? 0
3261
+ return "\(provider)=\(tokenState) source=\(source) length=\(length)"
3262
+ }
3263
+
3264
+ private nonisolated func debugFieldValue(_ raw: String?) -> String {
3265
+ let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
3266
+ return trimmed.isEmpty ? "missing" : trimmed
3267
+ }
3268
+
3269
+ private nonisolated func debugStrategyKindLabel(_ kind: ProviderFetchKind) -> String {
3270
+ switch kind {
3271
+ case .cli: "cli"
3272
+ case .api: "api"
3273
+ case .web: "web"
3274
+ case .oauth: "oauth"
3275
+ case .apiToken: "apiToken"
3276
+ case .localProbe: "localProbe"
3277
+ case .webDashboard: "webDashboard"
3278
+ }
3279
+ }
3280
+
3281
+ private func runWithTimeout(seconds: Double, operation: @escaping @Sendable () async -> String) async -> String {
3282
+ await withTaskGroup(of: String?.self) { group -> String in
3283
+ group.addTask { await operation() }
3284
+ group.addTask {
3285
+ try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
3286
+ return nil
3287
+ }
3288
+ let result = await group.next()?.flatMap(\.self)
3289
+ group.cancelAll()
3290
+ return result ?? "Probe timed out after \(Int(seconds))s"
3291
+ }
3292
+ }
3293
+
3294
+ private func detectVersions() {
3295
+ Task.detached { [claudeFetcher] in
3296
+ let codexVer = Self.readCLI("codex", args: ["-s", "read-only", "-a", "untrusted", "--version"])
3297
+ let claudeVer = claudeFetcher.detectVersion()
3298
+ let geminiVer = Self.readCLI("gemini", args: ["--version"])
3299
+ let antigravityVer = await AntigravityStatusProbe.detectVersion()
3300
+ await MainActor.run {
3301
+ self.codexVersion = codexVer
3302
+ self.claudeVersion = claudeVer
3303
+ self.geminiVersion = geminiVer
3304
+ self.zaiVersion = nil
3305
+ self.antigravityVersion = antigravityVer
3306
+ }
3307
+ }
3308
+ }
3309
+
3310
+ private nonisolated static func readCLI(_ cmd: String, args: [String]) -> String? {
3311
+ let env = ProcessInfo.processInfo.environment
3312
+ var pathEnv = env
3313
+ pathEnv["PATH"] = PathBuilder.effectivePATH(purposes: [.rpc, .tty, .nodeTooling], env: env)
3314
+ let loginPATH = LoginShellPathCache.shared.current
3315
+
3316
+ let resolved: String = switch cmd {
3317
+ case "codex":
3318
+ BinaryLocator.resolveCodexBinary(env: env, loginPATH: loginPATH) ?? cmd
3319
+ case "gemini":
3320
+ BinaryLocator.resolveGeminiBinary(env: env, loginPATH: loginPATH) ?? cmd
3321
+ default:
3322
+ cmd
3323
+ }
3324
+
3325
+ let process = Process()
3326
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
3327
+ process.arguments = [resolved] + args
3328
+ process.environment = pathEnv
3329
+ let pipe = Pipe()
3330
+ process.standardOutput = pipe
3331
+ try? process.run()
3332
+ process.waitUntilExit()
3333
+ guard process.terminationStatus == 0 else { return nil }
3334
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
3335
+ guard let text = String(data: data, encoding: .utf8)?
3336
+ .trimmingCharacters(in: .whitespacesAndNewlines),
3337
+ !text.isEmpty else { return nil }
3338
+ return text
3339
+ }
3340
+
3341
+ private func refreshPathDebugInfo() {
3342
+ self.pathDebugInfo = PathBuilder.debugSnapshot(purposes: [.rpc, .tty, .nodeTooling])
3343
+ }
3344
+
3345
+ func clearCostUsageCache() async -> String? {
3346
+ let errorMessage: String? = await Task.detached(priority: .utility) {
3347
+ let fm = FileManager.default
3348
+ let cacheDirs = [
3349
+ Self.costUsageCacheDirectory(fileManager: fm),
3350
+ ]
3351
+
3352
+ for cacheDir in cacheDirs {
3353
+ do {
3354
+ try fm.removeItem(at: cacheDir)
3355
+ } catch let error as NSError {
3356
+ if error.domain == NSCocoaErrorDomain, error.code == NSFileNoSuchFileError { continue }
3357
+ return error.localizedDescription
3358
+ }
3359
+ }
3360
+ return nil
3361
+ }.value
3362
+
3363
+ guard errorMessage == nil else { return errorMessage }
3364
+
3365
+ self.tokenSnapshots.removeAll()
3366
+ self.tokenErrors.removeAll()
3367
+ self.lastTokenFetchAt.removeAll()
3368
+ self.tokenFailureGates[.codex]?.reset()
3369
+ self.tokenFailureGates[.claude]?.reset()
3370
+ return nil
3371
+ }
3372
+
3373
+ private func refreshTokenUsage(
3374
+ _ provider: UsageProvider,
3375
+ force: Bool,
3376
+ trigger: RefreshTrigger,
3377
+ inactiveProviders: Set<UsageProvider>) async
3378
+ {
3379
+ guard provider == .codex || provider == .claude else {
3380
+ self.tokenSnapshots.removeValue(forKey: provider)
3381
+ self.tokenErrors[provider] = nil
3382
+ self.tokenFailureGates[provider]?.reset()
3383
+ self.lastTokenFetchAt.removeValue(forKey: provider)
3384
+ return
3385
+ }
3386
+
3387
+ guard self.settings.costUsageEnabled else {
3388
+ self.tokenSnapshots.removeValue(forKey: provider)
3389
+ self.tokenErrors[provider] = nil
3390
+ self.tokenFailureGates[provider]?.reset()
3391
+ self.lastTokenFetchAt.removeValue(forKey: provider)
3392
+ return
3393
+ }
3394
+
3395
+ guard self.isEnabled(provider) else {
3396
+ self.tokenSnapshots.removeValue(forKey: provider)
3397
+ self.tokenErrors[provider] = nil
3398
+ self.tokenFailureGates[provider]?.reset()
3399
+ self.lastTokenFetchAt.removeValue(forKey: provider)
3400
+ return
3401
+ }
3402
+
3403
+ if trigger.isAuto, inactiveProviders.contains(provider), !force {
3404
+ return
3405
+ }
3406
+
3407
+ guard !self.tokenRefreshInFlight.contains(provider) else { return }
3408
+
3409
+ let now = Date()
3410
+ if !force,
3411
+ let last = self.lastTokenFetchAt[provider],
3412
+ now.timeIntervalSince(last) < self.tokenFetchTTL
3413
+ {
3414
+ return
3415
+ }
3416
+ self.lastTokenFetchAt[provider] = now
3417
+ self.tokenRefreshInFlight.insert(provider)
3418
+ defer { self.tokenRefreshInFlight.remove(provider) }
3419
+
3420
+ let startedAt = Date()
3421
+ let providerText = provider.rawValue
3422
+ self.tokenCostLogger
3423
+ .info("cost usage start provider=\(providerText) force=\(force)")
3424
+
3425
+ do {
3426
+ let fetcher = self.costUsageFetcher
3427
+ let timeoutSeconds = self.tokenFetchTimeout
3428
+ let snapshot = try await withThrowingTaskGroup(of: CostUsageTokenSnapshot.self) { group in
3429
+ group.addTask(priority: .utility) {
3430
+ try await fetcher.loadTokenSnapshot(provider: provider, now: now, forceRefresh: force)
3431
+ }
3432
+ group.addTask {
3433
+ try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
3434
+ throw CostUsageError.timedOut(seconds: Int(timeoutSeconds))
3435
+ }
3436
+ defer { group.cancelAll() }
3437
+ guard let snapshot = try await group.next() else { throw CancellationError() }
3438
+ return snapshot
3439
+ }
3440
+
3441
+ guard !snapshot.daily.isEmpty else {
3442
+ self.tokenSnapshots.removeValue(forKey: provider)
3443
+ self.tokenErrors[provider] = Self.tokenCostNoDataMessage(for: provider)
3444
+ self.tokenFailureGates[provider]?.recordSuccess()
3445
+ return
3446
+ }
3447
+ let duration = Date().timeIntervalSince(startedAt)
3448
+ let sessionCost = snapshot.sessionCostUSD.map(UsageFormatter.usdString) ?? "—"
3449
+ let monthCost = snapshot.last30DaysCostUSD.map(UsageFormatter.usdString) ?? "—"
3450
+ let durationText = String(format: "%.2f", duration)
3451
+ let message =
3452
+ "cost usage success provider=\(providerText) " +
3453
+ "duration=\(durationText)s " +
3454
+ "today=\(sessionCost) " +
3455
+ "30d=\(monthCost)"
3456
+ self.tokenCostLogger.info(message)
3457
+ self.tokenSnapshots[provider] = snapshot
3458
+ self.tokenErrors[provider] = nil
3459
+ self.tokenFailureGates[provider]?.recordSuccess()
3460
+ self.persistWidgetSnapshot(reason: "token-usage")
3461
+ } catch {
3462
+ if error is CancellationError { return }
3463
+ let duration = Date().timeIntervalSince(startedAt)
3464
+ let msg = error.localizedDescription
3465
+ let durationText = String(format: "%.2f", duration)
3466
+ let message = "cost usage failed provider=\(providerText) duration=\(durationText)s error=\(msg)"
3467
+ self.tokenCostLogger.error(message)
3468
+ let hadPriorData = self.tokenSnapshots[provider] != nil
3469
+ let shouldSurface = self.tokenFailureGates[provider]?
3470
+ .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true
3471
+ if shouldSurface {
3472
+ self.tokenErrors[provider] = error.localizedDescription
3473
+ self.tokenSnapshots.removeValue(forKey: provider)
3474
+ } else {
3475
+ self.tokenErrors[provider] = nil
3476
+ }
3477
+ }
3478
+ }
3479
+
3480
+ // MARK: - Performance Tracking
3481
+
3482
+ /// Track API call latency for performance monitoring
3483
+ private nonisolated func trackLatency(
3484
+ provider: UsageProvider,
3485
+ requestID: String,
3486
+ startTime: Date,
3487
+ endTime: Date,
3488
+ success: Bool
3489
+ ) async {
3490
+ guard let storage = self.performanceStorage else { return }
3491
+
3492
+ let metric = LatencyMetric(
3493
+ id: UUID().uuidString,
3494
+ requestID: requestID,
3495
+ provider: provider,
3496
+ model: nil,
3497
+ startTime: startTime,
3498
+ endTime: endTime,
3499
+ durationMs: Int(endTime.timeIntervalSince(startTime) * 1000),
3500
+ success: success,
3501
+ createdAt: Date()
3502
+ )
3503
+
3504
+ try? await storage.save(latency: metric)
3505
+ }
3506
+
3507
+ /// Track API errors for performance monitoring
3508
+ private nonisolated func trackError(provider: UsageProvider, error: Error) async {
3509
+ guard let storage = self.performanceStorage else { return }
3510
+
3511
+ let errorType = self.classifyError(error)
3512
+ let errorEvent = ErrorEvent(
3513
+ id: UUID().uuidString,
3514
+ provider: provider,
3515
+ errorType: errorType,
3516
+ errorMessage: error.localizedDescription,
3517
+ retryCount: 0,
3518
+ timestamp: Date()
3519
+ )
3520
+
3521
+ try? await storage.save(error: errorEvent)
3522
+ }
3523
+
3524
+ /// Classify errors for performance tracking
3525
+ private nonisolated func classifyError(_ error: Error) -> ErrorType {
3526
+ let message = error.localizedDescription.lowercased()
3527
+
3528
+ // Timeout errors
3529
+ if message.contains("timed out") || message.contains("timeout") {
3530
+ return .timeout
3531
+ }
3532
+
3533
+ // Quota/rate limit errors
3534
+ if message.contains("quota") || message.contains("rate limit") || message.contains("429") {
3535
+ return .quota
3536
+ }
3537
+
3538
+ // Authentication errors
3539
+ if message.contains("auth") || message.contains("unauthorized") ||
3540
+ message.contains("401") || message.contains("403")
3541
+ {
3542
+ return .auth
3543
+ }
3544
+
3545
+ // Network errors
3546
+ if message.contains("network") || message.contains("connection") ||
3547
+ message.contains("offline") || message.contains("no internet")
3548
+ {
3549
+ return .network
3550
+ }
3551
+
3552
+ // Parsing errors
3553
+ if message.contains("json") || message.contains("decode") || message.contains("parse") {
3554
+ return .parsing
3555
+ }
3556
+
3557
+ // API errors
3558
+ if message.contains("api") || message.contains("server") {
3559
+ return .apiError
3560
+ }
3561
+
3562
+ return .unknown
3563
+ }
3564
+ }