@sriinnu/runic 2.0.0 → 2.11.2

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