eggpool 0.3.2__tar.gz → 0.3.4__tar.gz

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 (237) hide show
  1. {eggpool-0.3.2 → eggpool-0.3.4}/AGENTS.md +2 -0
  2. {eggpool-0.3.2 → eggpool-0.3.4}/CHANGELOG.md +7 -0
  3. {eggpool-0.3.2 → eggpool-0.3.4}/PKG-INFO +17 -6
  4. {eggpool-0.3.2 → eggpool-0.3.4}/README.md +16 -5
  5. {eggpool-0.3.2 → eggpool-0.3.4}/docs/providers.md +19 -2
  6. {eggpool-0.3.2 → eggpool-0.3.4}/pyproject.toml +1 -1
  7. {eggpool-0.3.2 → eggpool-0.3.4}/scripts/check_database.py +1 -1
  8. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/api/stats.py +21 -4
  9. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/catalog/catalog_resolvers.py +7 -3
  10. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/catalog/pricing.py +1 -1
  11. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/render.py +182 -29
  12. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/routes.py +28 -3
  13. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/static/dashboard.css +108 -50
  14. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/static/dashboard.js +96 -0
  15. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/repositories.py +33 -3
  16. eggpool-0.3.4/src/eggpool/db/schema/0033_request_provider_local_cost.sql +39 -0
  17. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/checksums.json +2 -1
  18. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/models/config.py +25 -1
  19. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/providers/_templates.toml +28 -0
  20. eggpool-0.3.4/src/eggpool/proxy/cost_reporting.py +245 -0
  21. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/proxy/sse_observer.py +26 -4
  22. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/proxy/usage.py +65 -0
  23. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/request/coordinator.py +60 -4
  24. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/request/finalizer.py +102 -59
  25. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/stats/__init__.py +2 -0
  26. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/stats/queries.py +68 -2
  27. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/stats/service.py +18 -4
  28. {eggpool-0.3.2 → eggpool-0.3.4}/uv.lock +1 -1
  29. {eggpool-0.3.2 → eggpool-0.3.4}/.env.example +0 -0
  30. {eggpool-0.3.2 → eggpool-0.3.4}/.github/workflows/ci.yml +0 -0
  31. {eggpool-0.3.2 → eggpool-0.3.4}/.github/workflows/release.yml +0 -0
  32. {eggpool-0.3.2 → eggpool-0.3.4}/.gitignore +0 -0
  33. {eggpool-0.3.2 → eggpool-0.3.4}/LICENSE +0 -0
  34. {eggpool-0.3.2 → eggpool-0.3.4}/architecture/README.md +0 -0
  35. {eggpool-0.3.2 → eggpool-0.3.4}/config-examples/claude-code.env +0 -0
  36. {eggpool-0.3.2 → eggpool-0.3.4}/config-examples/opencode.jsonc +0 -0
  37. {eggpool-0.3.2 → eggpool-0.3.4}/config.example.toml +0 -0
  38. {eggpool-0.3.2 → eggpool-0.3.4}/deploy/eggpool-logrotate.conf +0 -0
  39. {eggpool-0.3.2 → eggpool-0.3.4}/deploy/eggpool.service +0 -0
  40. {eggpool-0.3.2 → eggpool-0.3.4}/deploy/env.example +0 -0
  41. {eggpool-0.3.2 → eggpool-0.3.4}/docs/backup-restore.md +0 -0
  42. {eggpool-0.3.2 → eggpool-0.3.4}/docs/deployment.md +0 -0
  43. {eggpool-0.3.2 → eggpool-0.3.4}/docs/filesystem-layout.md +0 -0
  44. {eggpool-0.3.2 → eggpool-0.3.4}/docs/firewall.md +0 -0
  45. {eggpool-0.3.2 → eggpool-0.3.4}/docs/model-limits.md +0 -0
  46. {eggpool-0.3.2 → eggpool-0.3.4}/docs/network-diagnostics.md +0 -0
  47. {eggpool-0.3.2 → eggpool-0.3.4}/docs/proxy.md +0 -0
  48. {eggpool-0.3.2 → eggpool-0.3.4}/docs/raspberry-pi.md +0 -0
  49. {eggpool-0.3.2 → eggpool-0.3.4}/scripts/__init__.py +0 -0
  50. {eggpool-0.3.2 → eggpool-0.3.4}/scripts/install.sh +0 -0
  51. {eggpool-0.3.2 → eggpool-0.3.4}/scripts/install_prompt.py +0 -0
  52. {eggpool-0.3.2 → eggpool-0.3.4}/scripts/smoke_test.py +0 -0
  53. {eggpool-0.3.2 → eggpool-0.3.4}/scripts/verify_upstream_auth.py +0 -0
  54. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/__init__.py +0 -0
  55. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/__main__.py +0 -0
  56. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/_share/.env.example +0 -0
  57. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/_share/config.example.toml +0 -0
  58. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/accounts/__init__.py +0 -0
  59. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/accounts/registry.py +0 -0
  60. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/accounts/state.py +0 -0
  61. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/api/__init__.py +0 -0
  62. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/api/backoff.py +0 -0
  63. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/api/chat_completions.py +0 -0
  64. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/api/errors.py +0 -0
  65. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/api/messages.py +0 -0
  66. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/api/models.py +0 -0
  67. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/api/network.py +0 -0
  68. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/api/proxy_request.py +0 -0
  69. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/api/runtime.py +0 -0
  70. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/api/update.py +0 -0
  71. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/app.py +0 -0
  72. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/auth.py +0 -0
  73. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/background/__init__.py +0 -0
  74. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/background/backup.py +0 -0
  75. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/background/cleanup.py +0 -0
  76. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/catalog/__init__.py +0 -0
  77. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/catalog/cache.py +0 -0
  78. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/catalog/fetcher.py +0 -0
  79. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/catalog/limits.py +0 -0
  80. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/catalog/normalizer.py +0 -0
  81. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/catalog/pricing_aliases.py +0 -0
  82. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/catalog/pricing_resolver.py +0 -0
  83. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/catalog/protocols.py +0 -0
  84. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/catalog/service.py +0 -0
  85. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/cli.py +0 -0
  86. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/cli_full.py +0 -0
  87. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/config.py +0 -0
  88. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/constants.py +0 -0
  89. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/cost_recompute.py +0 -0
  90. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/__init__.py +0 -0
  91. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/_resources.py +0 -0
  92. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/escape.py +0 -0
  93. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/static/chart.umd.min.js +0 -0
  94. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/static/favicon.svg +0 -0
  95. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/theme.py +0 -0
  96. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Booberry.toml +0 -0
  97. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Catppuccin Latte.toml +0 -0
  98. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Catppuccin Macchiato.toml +0 -0
  99. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Catppuccin Mocha.toml +0 -0
  100. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Cyber Red.toml +0 -0
  101. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Cyberpunk.toml +0 -0
  102. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Dark Green.toml +0 -0
  103. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Discord (80_ Saturation).toml +0 -0
  104. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Discord.toml +0 -0
  105. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Dracula.toml +0 -0
  106. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Ferra Light.toml +0 -0
  107. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Flexor Dark.toml +0 -0
  108. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Gruvbox.toml +0 -0
  109. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Halcyon Dark.toml +0 -0
  110. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/IntelliJ Light.toml +0 -0
  111. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Kanagawa.toml +0 -0
  112. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Macaw Dark.toml +0 -0
  113. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Macaw Light.toml +0 -0
  114. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Matrix.toml +0 -0
  115. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Noctis Lilac.toml +0 -0
  116. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Nord.toml +0 -0
  117. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Nostromo Terminal.toml +0 -0
  118. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/One Dark.toml +0 -0
  119. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Oxocarbon.toml +0 -0
  120. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Rose Pine Dawn.toml +0 -0
  121. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Rose Pine Moon.toml +0 -0
  122. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Rose Pine.toml +0 -0
  123. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Solarized Dark.toml +0 -0
  124. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Sonokai.toml +0 -0
  125. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Tokyo Night Storm.toml +0 -0
  126. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/VESPER.toml +0 -0
  127. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/Zenburn.toml +0 -0
  128. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/acton.toml +0 -0
  129. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/bam.toml +0 -0
  130. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/base16-atelier-forest-light.toml +0 -0
  131. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/berlin.toml +0 -0
  132. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/black but with important highlights.toml +0 -0
  133. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/broc.toml +0 -0
  134. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/cork.toml +0 -0
  135. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/ferra.toml +0 -0
  136. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/forest.toml +0 -0
  137. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/lisbon.toml +0 -0
  138. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/midnight.toml +0 -0
  139. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/oslo.toml +0 -0
  140. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/plum.toml +0 -0
  141. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/portland.toml +0 -0
  142. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/sunset.toml +0 -0
  143. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/tofino.toml +0 -0
  144. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/vanimo.toml +0 -0
  145. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/dashboard/themes/vik.toml +0 -0
  146. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/__init__.py +0 -0
  147. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/connection.py +0 -0
  148. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/migrations.py +0 -0
  149. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/rollup_repository.py +0 -0
  150. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0001_initial.sql +0 -0
  151. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0002_indexes.sql +0 -0
  152. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0003_request_attempts.sql +0 -0
  153. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0004_integration_hardening.sql +0 -0
  154. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0005_price_microdollars.sql +0 -0
  155. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0006_correct_price_microdollars.sql +0 -0
  156. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0007_price_cache_rates.sql +0 -0
  157. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0008_proxy_request_identity.sql +0 -0
  158. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0009_model_protocol_source.sql +0 -0
  159. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0010_health_probe.sql +0 -0
  160. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0011_model_resolution_status.sql +0 -0
  161. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0012_drop_reservations_estimated_microdollars.sql +0 -0
  162. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0013_request_attempts_account_id_index.sql +0 -0
  163. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0014_bandwidth_tracking.sql +0 -0
  164. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0015_multi_provider.sql +0 -0
  165. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0016_requests_provider_id.sql +0 -0
  166. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0017_price_snapshots_provider_id.sql +0 -0
  167. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0018_provider_pings.sql +0 -0
  168. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0019_client_ip.sql +0 -0
  169. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0020_performance_indexes.sql +0 -0
  170. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0021_provider_model_metadata.sql +0 -0
  171. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0022_dashboard_indexes.sql +0 -0
  172. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0023_deprecated_model_placeholder.sql +0 -0
  173. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0024_account_backoffs.sql +0 -0
  174. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0025_stale_request_index.sql +0 -0
  175. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0026_attempt_observability.sql +0 -0
  176. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0027_routing_decisions.sql +0 -0
  177. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0028_operational_events.sql +0 -0
  178. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0029_latency_phases.sql +0 -0
  179. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0030_model_pricing_aliases.sql +0 -0
  180. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0031_price_snapshot_provenance.sql +0 -0
  181. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/db/schema/0032_usage_rollups.sql +0 -0
  182. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/deploy/__init__.py +0 -0
  183. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/deploy_user.py +0 -0
  184. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/errors.py +0 -0
  185. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/fastcli.py +0 -0
  186. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/health/__init__.py +0 -0
  187. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/health/backoff.py +0 -0
  188. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/health/circuit_breaker.py +0 -0
  189. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/health/health_manager.py +0 -0
  190. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/integrations/__init__.py +0 -0
  191. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/integrations/opencode.py +0 -0
  192. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/lifecycle/__init__.py +0 -0
  193. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/lifecycle/backup.py +0 -0
  194. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/lifecycle/uninstall.py +0 -0
  195. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/logging.py +0 -0
  196. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/metrics/__init__.py +0 -0
  197. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/metrics/buffer.py +0 -0
  198. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/models/__init__.py +0 -0
  199. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/models/api.py +0 -0
  200. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/models/database.py +0 -0
  201. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/models/domain.py +0 -0
  202. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/onboard.py +0 -0
  203. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/providers/__init__.py +0 -0
  204. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/providers/auth.py +0 -0
  205. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/providers/client_pool.py +0 -0
  206. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/providers/connect.py +0 -0
  207. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/providers/contract.py +0 -0
  208. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/providers/dns_cache.py +0 -0
  209. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/providers/outbound.py +0 -0
  210. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/providers/pproxy_transport.py +0 -0
  211. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/proxy/__init__.py +0 -0
  212. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/proxy/client.py +0 -0
  213. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/py.typed +0 -0
  214. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/quota/__init__.py +0 -0
  215. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/quota/audit.py +0 -0
  216. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/quota/estimation.py +0 -0
  217. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/quota/reservation.py +0 -0
  218. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/quota/scorer.py +0 -0
  219. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/request/__init__.py +0 -0
  220. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/request/attempt_finalizer.py +0 -0
  221. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/request/body.py +0 -0
  222. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/request/limits.py +0 -0
  223. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/retry/__init__.py +0 -0
  224. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/retry/classification.py +0 -0
  225. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/routing/__init__.py +0 -0
  226. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/routing/eligibility.py +0 -0
  227. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/routing/provider.py +0 -0
  228. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/routing/router.py +0 -0
  229. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/runtime.py +0 -0
  230. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/runtime_dispatch.py +0 -0
  231. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/runtime_metrics.py +0 -0
  232. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/runtime_paths.py +0 -0
  233. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/security/__init__.py +0 -0
  234. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/security/redaction.py +0 -0
  235. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/stats/grouped_timeseries.py +0 -0
  236. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/toml_edit.py +0 -0
  237. {eggpool-0.3.2 → eggpool-0.3.4}/src/eggpool/update_checker.py +0 -0
@@ -90,6 +90,7 @@ Use the hierarchy in `errors.py`. Chain exceptions with `raise ... from err` or
90
90
 
91
91
  - Configuration changes require a service restart; live reload is intentionally not supported
92
92
  - No pre-commit hooks are configured in this repo; CI runs ruff, pyright, and pytest via GitHub Actions
93
+ - **`static_models` is the source of truth for provider-specific protocol** — `FAMILY_PROTOCOLS` (`src/eggpool/catalog/protocols.py`) is a global fallback that applies when no explicit override exists. Providers like `minimax-cn` that serve MiniMax models on the OpenAI-compatible surface **must** ship `[[providers.<id>.static_models]]` rows with `protocol = "openai"`, otherwise the live `/v1/models` fetch resolves `MiniMax-M*` via the `minimax-` family prefix to `anthropic` and the provider protocol constraint check (`src/eggpool/catalog/service.py:533-545`) clears the protocol to `None`, producing `ModelUnavailableError("Model 'MiniMax-M3' has unresolved protocol")` instead of the more obvious 400 `ProtocolMismatchError`. Static seeds with `protocol_source = "static_config"` survive the live merge via `ModelCatalogCache._preserve_static_fields` (`src/eggpool/catalog/cache.py:146-187`). The same model ID may also be flipped on a per-provider basis via `[providers.<id>.model_overrides."<model-id>"].protocol = "openai"` for operator overrides that go beyond the bundled template.
93
94
  - `Database.vacuum()` is the only sanctioned path for `VACUUM` in production code
94
95
  - Every DML write must run inside `async with db.transaction():`
95
96
  - SQLite transactions are serialized across concurrent tasks via a single connection lock + ContextVar
@@ -100,6 +101,7 @@ Use the hierarchy in `errors.py`. Chain exceptions with `raise ... from err` or
100
101
  - **Streaming finalizer shielding**: streaming `_build_stream_generator` finalization runs under `asyncio.shield(asyncio.wait_for(..., timeout=10))` so ASGI task cancellation cannot kill the finalizer while it holds the DB lock. Leaks that escape this path are caught by the periodic `stale_request_finalizer` background task (`app._finalize_stale_requests`, runs every 60s) which force-finalizes any request that has been `pending` longer than `upstream.read_timeout_s`.
101
102
  - **Startup crash recovery**: `_crash_recovery` runs at every startup and recovers ALL pending requests and ALL active reservations with no time threshold. A process restart is a definitive boundary, so leaked state from the previous process is unconditionally cleaned up. If `Crash recovery: marked N stale requests` appears in logs, the safety net caught leaks from the previous run.
102
103
  - **Pricing resolution pipeline**: prices flow TOML override → upstream metadata → external catalog (OpenRouter / OpenCode Zen via the alias registry). `ResolvedPricing` records `source_detail` (`operator_override` / `provider_metadata` / `openrouter` / `opencode_zen`) and `source_confidence` (`exact_external_id` / `curated_alias` / `provider_metadata`). Cost rows are then labelled `derived` (every category trusted), `partial` (some categories filled by per-category fallback), `estimated` (no trusted rates), or `unknown` (no token usage). `partial_count` is a new exactness value exposed via `/api/stats/summary`, `/api/stats/accounts`, and `/api/stats/models`. The dashboard renders a per-row cost-exactness badge and a high-spend estimated warning banner (>$10 estimated) on the Accounts page.
104
+ - **Provider-reported cost precedence**: when an upstream includes a billing field (e.g. `usage.cost`, `usage.cost_usd`, `usage.cost_microdollars`, `usage.billing.cost_usd`), `eggpool.proxy.cost_reporting.extract_provider_reported_cost()` parses it and the `RequestFinalizer` records it on the `requests` row as `provider_cost_microdollars` (with `provider_cost_source` storing the field path). That value takes precedence over locally computed rates — the cost precedence ladder is `provider_reported > derived/partial/exact > estimated (reservation fallback) > unknown (zero)`. Locally calculated cost is still persisted to `local_cost_microdollars` / `local_cost_exactness` for diagnostics, never overrides provider-reported. The new `provider_reported_count` and `provider_reported_cost_microdollars` fields are exposed via `/api/stats/summary`, `/api/stats/accounts`, and `/api/stats/models` and surface in the dashboard's exactness badge and Total-cost card. Migration `0033_request_provider_local_cost.sql` adds the four nullable audit columns. The parser is allowlisted (no arbitrary nested keys) and is defensive — `TypeError`/`ValueError`/`AttributeError`/`RecursionError` yield `None`, so finalization never breaks on a malformed response body.
103
105
  - **`eggpool stats recompute-costs [--dry-run|--apply] [--limit N]`**: walks the requests table in started_at DESC order, recomputes cost from the current price snapshots, and reports / applies the change. Default is `--dry-run`. Use after upgrading the resolver to fix inflated totals on cached-token-heavy models (e.g. MiMo 2.5). Implemented in `src/eggpool/cost_recompute.py` and reuses the live `CostCalculator` so the new values match what the finalizer would write today.
104
106
  - **Migration 0030 (`model_pricing_aliases`) + 0031 (`price_snapshot_provenance`)**: 0030 introduces the alias registry that maps upstream model IDs (e.g. `mimo-v2.5`) onto external catalog IDs (e.g. `xiaomi/mimo-v2.5`) with an `exact`/`curated_alias`/`ambiguous_skip` confidence enum. 0031 adds `source_detail`, `source_confidence`, `catalog_source` columns to `model_price_snapshots` so the dashboard can attribute prices back to the resolver that produced them. Seed data lives in `seed_default_aliases()` (`src/eggpool/catalog/pricing_aliases.py`) and runs idempotently at startup via `CatalogService.attach_pricing_resolvers()`.
105
107
  - **Migration 0032 (`usage_rollups`)**: introduces the rollup table for buffered analytics. Counter fields are designed for additive upserts (`INSERT ... ON CONFLICT DO UPDATE SET col = col + excluded.col`). Latency min/max use `CASE/WHEN` to converge monotonically within each bucket. The `UsageRollupRepository` provides `upsert_many()`, `query_timeseries()`, `query_summary()`, and `cleanup_old_rollups()`.
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.4] - 2026-06-27
11
+
12
+ ### Fixed
13
+
14
+ - **Exclusion taxonomy empty-state**: the Routing page's doughnut chart now shows `<p class="empty">No exclusion data in this period.</p>` instead of an invisible Chart.js ring when no exclusions have been recorded in the selected period. The previous behaviour rendered a zero-data doughnut whose legend was visible but the chart itself was not, producing a "key but no graph" artefact.
15
+ - **`circuit_breaker` classification**: `SUPPRESSIVE_EXCLUSION_REASONS` now includes `circuit_breaker`, the only exclusion reason the coordinator actually writes to `exclude_reasons_json`. Previously every real-world exclusion landed in the `unknown` bucket because the frozenset only contained the legacy `circuit_open` name.
16
+
10
17
  ### Added
11
18
 
12
19
  - **Sticky dashboard topbar**: `header.topbar` is `position: sticky; top: 0; z-index: 5` with a subtle backdrop blur, so the page navigation stays visible while scrolling on desktop. Mobile layout is unchanged (the topnav disclosure still wraps cleanly under 480px).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eggpool
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: A lightweight proxy that aggregates multiple LLM provider accounts behind one OpenAI-compatible endpoint
5
5
  Project-URL: Homepage, https://github.com/eggstack/eggpool
6
6
  Project-URL: Repository, https://github.com/eggstack/eggpool
@@ -375,11 +375,22 @@ dashboard's public/auth setting.
375
375
  no error_detail). Auth-gated.
376
376
  - **Cost/cache/reasoning exactness** (extended fields on `/api/stats/accounts`
377
377
  and `/api/stats/models`): per-account and per-model `exact_count`,
378
- `partial_count`, `derived_count`, `estimated_count`, `cache_read_ratio`,
379
- `cache_write_ratio`, `reasoning_output_ratio`, `estimated_cost_fraction`,
380
- `avg_cost_per_request`, `avg_cost_per_1k_tokens`. Lets you see which
381
- accounts/models/providers report exact usage versus partially-priced
382
- versus locally estimated cost.
378
+ `partial_count`, `derived_count`, `estimated_count`, `provider_reported_count`,
379
+ `cache_read_ratio`, `cache_write_ratio`, `reasoning_output_ratio`,
380
+ `estimated_cost_fraction`, `avg_cost_per_request`, `avg_cost_per_1k_tokens`.
381
+ Lets you see which accounts/models/providers report exact usage versus
382
+ partially-priced versus locally estimated cost versus upstream-billed cost.
383
+ - **Provider-reported cost precedence**: when an upstream provider includes a
384
+ billing field in its response (`usage.cost_microdollars`, `usage.cost_usd`,
385
+ `usage.billing.cost_usd`, OpenCode Go's `usage.cost`/`usage.total_cost`),
386
+ that value is recorded on the `requests` row in `provider_cost_microdollars`
387
+ (with `provider_cost_source` recording the field path) and the request
388
+ exactness is labelled `provider_reported`. Provider-reported cost is the
389
+ most-trusted tier in the cost precedence ladder — it precedes locally
390
+ derived rates (per-token pricing) and reservation-derived estimates
391
+ (`estimated`), and is never overridden by them. Locally calculated values
392
+ are still stored in `local_cost_microdollars` and `local_cost_exactness`
393
+ for diagnostics comparison.
383
394
  - **Pricing provenance** (`/api/stats/pricing-provenance`): per-`(model,
384
395
  provider)` snapshot breakdown of `source_detail` (operator override vs.
385
396
  upstream metadata vs. OpenRouter catalog vs. curated alias) and
@@ -331,11 +331,22 @@ dashboard's public/auth setting.
331
331
  no error_detail). Auth-gated.
332
332
  - **Cost/cache/reasoning exactness** (extended fields on `/api/stats/accounts`
333
333
  and `/api/stats/models`): per-account and per-model `exact_count`,
334
- `partial_count`, `derived_count`, `estimated_count`, `cache_read_ratio`,
335
- `cache_write_ratio`, `reasoning_output_ratio`, `estimated_cost_fraction`,
336
- `avg_cost_per_request`, `avg_cost_per_1k_tokens`. Lets you see which
337
- accounts/models/providers report exact usage versus partially-priced
338
- versus locally estimated cost.
334
+ `partial_count`, `derived_count`, `estimated_count`, `provider_reported_count`,
335
+ `cache_read_ratio`, `cache_write_ratio`, `reasoning_output_ratio`,
336
+ `estimated_cost_fraction`, `avg_cost_per_request`, `avg_cost_per_1k_tokens`.
337
+ Lets you see which accounts/models/providers report exact usage versus
338
+ partially-priced versus locally estimated cost versus upstream-billed cost.
339
+ - **Provider-reported cost precedence**: when an upstream provider includes a
340
+ billing field in its response (`usage.cost_microdollars`, `usage.cost_usd`,
341
+ `usage.billing.cost_usd`, OpenCode Go's `usage.cost`/`usage.total_cost`),
342
+ that value is recorded on the `requests` row in `provider_cost_microdollars`
343
+ (with `provider_cost_source` recording the field path) and the request
344
+ exactness is labelled `provider_reported`. Provider-reported cost is the
345
+ most-trusted tier in the cost precedence ladder — it precedes locally
346
+ derived rates (per-token pricing) and reservation-derived estimates
347
+ (`estimated`), and is never overridden by them. Locally calculated values
348
+ are still stored in `local_cost_microdollars` and `local_cost_exactness`
349
+ for diagnostics comparison.
339
350
  - **Pricing provenance** (`/api/stats/pricing-provenance`): per-`(model,
340
351
  provider)` snapshot breakdown of `source_detail` (operator override vs.
341
352
  upstream metadata vs. OpenRouter catalog vs. curated alias) and
@@ -170,11 +170,28 @@ Live model discovery fetches the catalog from the MiniMax `/v1/models`
170
170
  endpoint using the documented Anthropic-compatible listing. The static
171
171
  seeds serve only as a fallback when live discovery is unavailable.
172
172
 
173
- `minimax-cn` (China console) is intentionally still configured as plain
173
+ `minimax-cn` (China console) is intentionally configured as plain
174
174
  OpenAI-compatible in the bundled template because the China endpoint
175
175
  family and auth shape have not been confirmed against `api.minimaxi.com`.
176
176
  Do not assume parity with the international Anthropic-compatible
177
- template without live testing.
177
+ template without live testing. `minimax-cn` ships with
178
+ `[[providers.minimax-cn.static_models]]` rows pinning `MiniMax-M3`,
179
+ `MiniMax-M2.7`, and `MiniMax-M2.5` to `protocol = "openai"` so the
180
+ global `FAMILY_PROTOCOLS["minimax-"] = "anthropic"` mapping does not
181
+ clear the protocol at the provider constraint check. Route MiniMax
182
+ through `minimax-cn` on `/v1/chat/completions` (OpenAI), not
183
+ `/v1/messages`.
184
+
185
+ ### Endpoint routing by MiniMax provider
186
+
187
+ | Provider | Base URL | Endpoint | Auth style |
188
+ | ------------------- | --------------------------------------- | ----------------------- | ------------ |
189
+ | `minimax` | `https://api.minimax.io/anthropic` | `POST /v1/messages` | `x-api-key` |
190
+ | `minimax-cn` | `https://api.minimaxi.com/v1` | `POST /chat/completions`| `Bearer` |
191
+
192
+ Requests that hit the wrong endpoint receive a `400 ProtocolMismatchError`
193
+ ("Model 'MiniMax-M3' uses the X protocol. Use /v1/..."). Hit the row
194
+ that matches the provider you configured.
178
195
 
179
196
  ### GeneralCompute PAYG (Plain OpenAI-Compatible)
180
197
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "eggpool"
3
- version = "0.3.2"
3
+ version = "0.3.4"
4
4
  description = "A lightweight proxy that aggregates multiple LLM provider accounts behind one OpenAI-compatible endpoint"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -61,7 +61,7 @@ class InvariantQueryError(CheckerError):
61
61
  #: Highest migration number this checker knows how to inspect.
62
62
  #: If the database is older or newer than this, the checker
63
63
  #: returns exit code 2 with a clear message.
64
- EXPECTED_SCHEMA_VERSION = 32
64
+ EXPECTED_SCHEMA_VERSION = 33
65
65
 
66
66
  #: Required tables for the production EggPool schema.
67
67
  REQUIRED_TABLES: frozenset[str] = frozenset(
@@ -45,13 +45,30 @@ async def handle_summary(request: Request, period: str | None = "24h") -> Respon
45
45
 
46
46
 
47
47
  async def handle_account_stats(
48
- request: Request, period: str | None = "24h"
48
+ request: Request,
49
+ period: str | None = "24h",
50
+ include_disabled: bool = True,
49
51
  ) -> Response:
50
- """GET /api/stats/accounts."""
52
+ """GET /api/stats/accounts.
53
+
54
+ ``include_disabled`` defaults to True so existing API consumers
55
+ continue to see soft-deleted (``enabled = 0``) accounts — their
56
+ historical request/cost rows must still be attributable to the
57
+ account that produced them. Pass ``?include_disabled=0`` to hide
58
+ them, mirroring the dashboard's "Show disabled accounts" toggle.
59
+ """
51
60
  time_range = resolve_time_range(period)
52
61
  stats = request.app.state.stats
53
- accounts = await stats.get_account_stats(time_range)
54
- return JSONResponse(content={"period": time_range.label, "accounts": accounts})
62
+ accounts = await stats.get_account_stats(
63
+ time_range, include_disabled=include_disabled
64
+ )
65
+ return JSONResponse(
66
+ content={
67
+ "period": time_range.label,
68
+ "include_disabled": include_disabled,
69
+ "accounts": accounts,
70
+ }
71
+ )
55
72
 
56
73
 
57
74
  async def handle_model_stats(
@@ -231,9 +231,13 @@ class OpenRouterCatalogResolver:
231
231
  if not isinstance(model_id_obj, str) or not model_id_obj:
232
232
  continue
233
233
  pricing_obj: object = raw_dict.get("pricing") or {}
234
- entry = OpenRouterCatalogResolver._parse_entry(
235
- model_id_obj, pricing_obj, raw_dict
236
- )
234
+ try:
235
+ entry = OpenRouterCatalogResolver._parse_entry(
236
+ model_id_obj, pricing_obj, raw_dict
237
+ )
238
+ except (ValueError, TypeError) as exc:
239
+ logger.warning("Skipping catalog entry %r: %s", model_id_obj, exc)
240
+ continue
237
241
  entries[model_id_obj] = entry
238
242
  return entries
239
243
 
@@ -65,7 +65,7 @@ def _extract_decimal(value: object) -> Decimal | None:
65
65
  if not number.is_finite():
66
66
  raise ValueError("price must be finite")
67
67
  if number < 0:
68
- raise ValueError("price must be non-negative")
68
+ return None
69
69
  return number
70
70
 
71
71
 
@@ -94,6 +94,13 @@ _STATUS_BADGE_TOOLTIPS: dict[str, str] = {
94
94
  # signals). This split lets the dashboard verify the design rule that
95
95
  # upstream-observed failures control exclusion while local accounting
96
96
  # only influences priority.
97
+ #
98
+ # ``circuit_breaker`` is the only reason the coordinator actually writes
99
+ # to ``exclude_reasons_json`` (see
100
+ # ``eggpool.request.coordinator._score_and_select``). ``circuit_open``
101
+ # is kept for backwards compatibility with rows written before the
102
+ # coordinator rename and to align semantically with the
103
+ # ``_STATUS_BADGE_TOOLTIPS`` mapping above.
97
104
  SUPPRESSIVE_EXCLUSION_REASONS: frozenset[str] = frozenset(
98
105
  {
99
106
  "authentication_failed",
@@ -107,6 +114,7 @@ SUPPRESSIVE_EXCLUSION_REASONS: frozenset[str] = frozenset(
107
114
  "account_disabled",
108
115
  "protocol_mismatch",
109
116
  "circuit_open",
117
+ "circuit_breaker",
110
118
  }
111
119
  )
112
120
 
@@ -162,8 +170,12 @@ _CARD_TOOLTIPS: dict[str, str] = {
162
170
  "Fraction of requests in the selected period that ended in an error."
163
171
  ),
164
172
  "Total cost": (
165
- "Total recorded request cost in the selected period, using the current "
166
- "cost exactness pipeline."
173
+ "Total recorded request cost in the selected period. When the upstream "
174
+ "provider reports a cost (e.g. OpenCode Go's usage.cost field), that "
175
+ "value takes precedence over locally computed rates; otherwise eggpool "
176
+ "falls back to per-token rates from the catalog. Reservation-derived "
177
+ "estimates are advisory and never inflate the totals when more "
178
+ "trustworthy data is available."
167
179
  ),
168
180
  "Utilization imbalance": (
169
181
  "Coefficient of variation across active accounts. Higher values mean "
@@ -455,12 +467,17 @@ def _render_layout(
455
467
  script_block = (
456
468
  _render_auto_refresh_script(refresh_interval_s) if auto_refresh else ""
457
469
  )
470
+ # `dashboard.js` is intentionally always-on: it wires the mobile
471
+ # burger menu, the update-command copy affordance, and the
472
+ # timeseries controls. Its init functions guard themselves with
473
+ # empty-result DOM queries so they are no-ops on pages that do not
474
+ # use charts or timeseries. Only `chart.js` is gated behind
475
+ # `include_chart_js` because it is ~200 KB and we want to keep it
476
+ # off the critical path for non-chart pages.
458
477
  chart_script = (
459
- '<script defer src="/static/chart.js"></script>'
460
- '<script defer src="/static/dashboard.js"></script>'
461
- if include_chart_js
462
- else ""
478
+ '<script defer src="/static/chart.js"></script>' if include_chart_js else ""
463
479
  )
480
+ dashboard_script = '<script defer src="/static/dashboard.js"></script>'
464
481
  update_indicator = _render_update_indicator(update_info)
465
482
  return f"""<!DOCTYPE html>
466
483
  <html lang="en">
@@ -504,6 +521,7 @@ def _render_layout(
504
521
  </footer>
505
522
  {script_block}
506
523
  {chart_script}
524
+ {dashboard_script}
507
525
  </body>
508
526
  </html>"""
509
527
 
@@ -575,18 +593,27 @@ def _render_nav(
575
593
  ("runtime", "/runtime", "Runtime"),
576
594
  ]
577
595
  parts = ['<nav class="topnav">']
578
- # Wrap the page links in a hamburger <details> on phone viewports.
579
- # The CSS layer shows the <summary> as a Menu chip below 480px and
580
- # reveals `.topnav-links` when open; on wider viewports the summary
581
- # is hidden and the links are always visible inline. Theme selector
582
- # and refresh button stay outside the disclosure so they remain
583
- # reachable on every viewport size.
584
- parts.append('<details class="topnav-hamburger">')
596
+ # Mobile burger button + collapsible menu panel. The CSS layer hides
597
+ # the burger and reveals `.topnav-menu` inline on viewports ≥761px;
598
+ # on narrower viewports the burger is shown and the menu only opens
599
+ # when JS toggles `.topnav-open` on the nav (see
600
+ # `dashboard.js#initNavToggle`). Theme selector and refresh button
601
+ # stay outside the menu so they remain reachable on every viewport
602
+ # size.
585
603
  parts.append(
586
- '<summary data-tooltip="Open page menu" '
587
- 'aria-label="Open page menu">Menu</summary>'
604
+ '<button class="topnav-burger" type="button" '
605
+ 'data-tooltip="Open page menu" '
606
+ 'aria-label="Open page menu" '
607
+ 'aria-expanded="false" aria-controls="topnav-menu">'
608
+ '<svg class="topnav-burger-icon" viewBox="0 0 22 16" '
609
+ 'width="22" height="16" aria-hidden="true" focusable="false">'
610
+ '<rect class="bar bar-1" x="0" y="0" width="22" height="2" rx="1"/>'
611
+ '<rect class="bar bar-2" x="0" y="7" width="22" height="2" rx="1"/>'
612
+ '<rect class="bar bar-3" x="0" y="14" width="22" height="2" rx="1"/>'
613
+ "</svg>"
614
+ "</button>"
588
615
  )
589
- parts.append('<div class="topnav-links">')
616
+ parts.append('<div class="topnav-menu" id="topnav-menu">')
590
617
  for key, href, label in items:
591
618
  cls = "active" if key == active_nav else ""
592
619
  parts.append(
@@ -595,7 +622,6 @@ def _render_nav(
595
622
  f"{_html_escape(label)}</a>"
596
623
  )
597
624
  parts.append("</div>")
598
- parts.append("</details>")
599
625
 
600
626
  # Theme selector dropdown
601
627
  themes: list[str] = available_themes or []
@@ -664,6 +690,92 @@ def _render_period_selector(current: str, current_theme: str = "") -> str:
664
690
  )
665
691
 
666
692
 
693
+ def _render_account_filters(
694
+ period: str,
695
+ current_theme: str,
696
+ show_disabled: bool,
697
+ ) -> str:
698
+ """Render a single GET-form filter bar for the Accounts page.
699
+
700
+ Bundles the period selector and the ``show_disabled`` toggle into
701
+ one form so either change preserves the other via hidden inputs.
702
+ Disabled rows are hidden by default so the page matches the
703
+ operator's mental model after ``eggpool logout``; the toggle is
704
+ opt-in and lives server-side in the URL (``?show_disabled=1``) so
705
+ it survives refresh, is bookmarkable, and works without JS.
706
+ """
707
+ period_options = [
708
+ ("1h", "Last hour"),
709
+ ("24h", "Last 24 hours"),
710
+ ("7d", "Last 7 days"),
711
+ ("30d", "Last 30 days"),
712
+ ]
713
+ period_items: list[str] = []
714
+ for value, label in period_options:
715
+ selected = ' selected="selected"' if value == period else ""
716
+ period_items.append(
717
+ f'<option value="{escape(value)}"{selected}>{escape(label)}</option>'
718
+ )
719
+ period_selector = "".join(period_items)
720
+
721
+ show_options = [
722
+ ("0", "Hide disabled accounts"),
723
+ ("1", "Show disabled accounts"),
724
+ ]
725
+ show_items: list[str] = []
726
+ show_value = "1" if show_disabled else "0"
727
+ for value, label in show_options:
728
+ selected = ' selected="selected"' if value == show_value else ""
729
+ show_items.append(
730
+ f'<option value="{escape(value)}"{selected}>{escape(label)}</option>'
731
+ )
732
+ show_selector = "".join(show_items)
733
+
734
+ theme_hidden = (
735
+ f'<input type="hidden" name="theme" value="{escape_attr(current_theme)}">'
736
+ if current_theme
737
+ else ""
738
+ )
739
+ return (
740
+ f'<form method="get" class="period-selector account-filters" '
741
+ f'data-period-selector aria-label="Account filters">'
742
+ f'<label for="period">Period: </label>'
743
+ f'<select id="period" name="period" data-auto-submit="1">'
744
+ f"{period_selector}"
745
+ f"</select>"
746
+ f'<label for="show_disabled">Disabled: </label>'
747
+ f'<select id="show_disabled" name="show_disabled" '
748
+ f'data-auto-submit="1">'
749
+ f"{show_selector}"
750
+ f"</select>"
751
+ f"{theme_hidden}"
752
+ f"</form>"
753
+ )
754
+
755
+
756
+ def _render_accounts_empty_state(
757
+ show_disabled: bool,
758
+ disabled_count: int,
759
+ ) -> str:
760
+ """Render the empty-state hint for the Accounts page.
761
+
762
+ When the operator has filtered disabled rows out and there are no
763
+ enabled rows left, offer a one-click opt-in to the historical view
764
+ instead of the generic "No accounts configured." message. The link
765
+ is plain anchor navigation, so it works without JS.
766
+ """
767
+ if show_disabled or disabled_count <= 0:
768
+ return '<p class="empty">No accounts configured.</p>'
769
+ plural = "s" if disabled_count != 1 else ""
770
+ return (
771
+ '<p class="empty">'
772
+ "No enabled accounts. "
773
+ f"{disabled_count} disabled account{plural} hidden — "
774
+ '<a href="?show_disabled=1">show them</a>.'
775
+ "</p>"
776
+ )
777
+
778
+
667
779
  _HIGH_SPEND_ESTIMATED_DOLLARS = 10.0
668
780
 
669
781
 
@@ -736,16 +848,19 @@ def _render_pricing_exactness_badge(
736
848
  partial: int,
737
849
  estimated: int,
738
850
  unknown_exc: int,
851
+ provider_reported: int = 0,
739
852
  ) -> str:
740
853
  """Render a compact exactness badge.
741
854
 
742
855
  The badge is colored by the worst-case exactness observed: green when
743
- every row is ``exact`` or ``derived``, yellow when any ``partial``
744
- row exists, red when ``estimated`` or ``unknown`` dominates. Used
745
- on the Accounts / Models tables so operators can spot rows whose
746
- cost numbers are advisory at a glance.
856
+ every row is ``exact``, ``derived``, or ``provider_reported``, yellow
857
+ when any ``partial`` row exists, red when ``estimated`` or ``unknown``
858
+ dominates. Used on the Accounts / Models tables so operators can spot
859
+ rows whose cost numbers are advisory at a glance. ``provider_reported``
860
+ is the most-trusted category because it reflects the upstream's own
861
+ billing record.
747
862
  """
748
- total = exact + derived + partial + estimated + unknown_exc
863
+ total = exact + derived + partial + estimated + unknown_exc + provider_reported
749
864
  if total == 0:
750
865
  return '<span class="exactness-badge empty">—</span>'
751
866
  if estimated == total or unknown_exc == total:
@@ -754,7 +869,10 @@ def _render_pricing_exactness_badge(
754
869
  css_class = "exactness-badge partial-mix"
755
870
  else:
756
871
  css_class = "exactness-badge derived"
757
- summary = f"e:{exact},d:{derived},p:{partial},~:{estimated},?:{unknown_exc}"
872
+ summary = (
873
+ f"u:{provider_reported},e:{exact},d:{derived},"
874
+ f"p:{partial},~:{estimated},?:{unknown_exc}"
875
+ )
758
876
  return f'<span class="{css_class}" title="{escape(summary)}">{summary}</span>'
759
877
 
760
878
 
@@ -1333,6 +1451,7 @@ def render_overview(
1333
1451
  derived = int(summary.get("derived_count", 0))
1334
1452
  estimated = int(summary.get("estimated_count", 0))
1335
1453
  unknown_exc = int(summary.get("unknown_count", 0))
1454
+ provider_reported = int(summary.get("provider_reported_count", 0))
1336
1455
  bytes_in = format_bytes(summary.get("total_bytes_received", 0))
1337
1456
  bytes_out = format_bytes(summary.get("total_bytes_emitted", 0))
1338
1457
 
@@ -1367,7 +1486,14 @@ def render_overview(
1367
1486
  _render_metric_card(
1368
1487
  title="Total cost",
1369
1488
  metric=cost,
1370
- sub=f"in {in_tok} · out {out_tok} · total {total_tok}",
1489
+ sub=(
1490
+ f"in {in_tok} · out {out_tok} · total {total_tok}"
1491
+ + (
1492
+ f" · {provider_reported:,} provider-billed"
1493
+ if provider_reported > 0
1494
+ else ""
1495
+ )
1496
+ ),
1371
1497
  ),
1372
1498
  _render_metric_card(
1373
1499
  title="Utilization imbalance",
@@ -1412,8 +1538,9 @@ def render_overview(
1412
1538
  title="Exactness",
1413
1539
  metric=f"{exact:,}",
1414
1540
  sub=(
1415
- f"exact · {derived:,} derived · {estimated:,} est · "
1416
- f"{unknown_exc:,} unk"
1541
+ f"exact · {derived:,} derived · "
1542
+ f"{provider_reported:,} upstream · "
1543
+ f"{estimated:,} est · {unknown_exc:,} unk"
1417
1544
  ),
1418
1545
  ),
1419
1546
  ]
@@ -1581,12 +1708,14 @@ def _render_account_table(accounts: list[dict[str, Any]]) -> str:
1581
1708
  partial_count = int(row.get("partial_count", 0) or 0)
1582
1709
  estimated = int(row.get("estimated_count", 0) or 0)
1583
1710
  unknown_exc = int(row.get("unknown_count", 0) or 0)
1711
+ provider_reported = int(row.get("provider_reported_count", 0) or 0)
1584
1712
  exactness = _render_pricing_exactness_badge(
1585
1713
  exact=exact,
1586
1714
  derived=derived,
1587
1715
  partial=partial_count,
1588
1716
  estimated=estimated,
1589
1717
  unknown_exc=unknown_exc,
1718
+ provider_reported=provider_reported,
1590
1719
  )
1591
1720
  est_cost_fraction = row.get("estimated_cost_fraction")
1592
1721
  est_cost_pct = (
@@ -1688,14 +1817,33 @@ def render_accounts(
1688
1817
  available_themes: list[str] | None = None,
1689
1818
  current_theme: str = "",
1690
1819
  update_info: Any | None = None,
1820
+ *,
1821
+ show_disabled: bool = False,
1822
+ disabled_count: int = 0,
1691
1823
  ) -> str:
1692
- """Render the accounts page."""
1824
+ """Render the accounts page.
1825
+
1826
+ ``show_disabled`` controls whether soft-deleted accounts (those
1827
+ ``sync_from_config`` marked ``enabled = 0`` after ``eggpool logout``)
1828
+ are listed. Defaults to False so the page matches the operator's
1829
+ mental model after logout.
1830
+
1831
+ ``disabled_count`` is the total disabled-row count, used only when
1832
+ ``accounts`` is empty AND ``show_disabled`` is False: the empty
1833
+ state becomes a one-click "N disabled — show them?" hint instead
1834
+ of the generic "No accounts configured." message.
1835
+ """
1836
+ table_html = (
1837
+ _render_accounts_empty_state(show_disabled, disabled_count)
1838
+ if not accounts
1839
+ else _render_account_table(accounts)
1840
+ )
1693
1841
  body = f"""
1694
1842
  <h2>Accounts</h2>
1695
- {_render_period_selector(period, current_theme)}
1843
+ {_render_account_filters(period, current_theme, show_disabled)}
1696
1844
  {_render_pricing_warnings(accounts)}
1697
1845
  <section class="panel">
1698
- {_render_account_table(accounts)}
1846
+ {table_html}
1699
1847
  </section>
1700
1848
  """
1701
1849
  return _render_layout(
@@ -1763,12 +1911,14 @@ def render_models(
1763
1911
  partial_count = int(row.get("partial_count", 0) or 0)
1764
1912
  estimated = int(row.get("estimated_count", 0) or 0)
1765
1913
  unknown_exc = int(row.get("unknown_count", 0) or 0)
1914
+ provider_reported = int(row.get("provider_reported_count", 0) or 0)
1766
1915
  exactness = _render_pricing_exactness_badge(
1767
1916
  exact=exact,
1768
1917
  derived=derived,
1769
1918
  partial=partial_count,
1770
1919
  estimated=estimated,
1771
1920
  unknown_exc=unknown_exc,
1921
+ provider_reported=provider_reported,
1772
1922
  )
1773
1923
  est_cost_fraction = row.get("estimated_cost_fraction")
1774
1924
  est_cost_pct = (
@@ -3494,6 +3644,9 @@ def _render_exclusion_taxonomy_chart(
3494
3644
  category = _classify_exclusion(reason)
3495
3645
  category_totals[category] = category_totals.get(category, 0) + count
3496
3646
 
3647
+ if sum(category_totals.values()) == 0:
3648
+ return '<p class="empty">No exclusion data in this period.</p>'
3649
+
3497
3650
  labels = json.dumps(["Suppressive", "Advisory", "Unknown"])
3498
3651
  datasets = json.dumps(
3499
3652
  [
@@ -31,6 +31,7 @@ from eggpool.dashboard.render import (
31
31
  )
32
32
  from eggpool.errors import ConfigError
33
33
  from eggpool.stats import TimeRange, resolve_time_range
34
+ from eggpool.stats.queries import fetch_disabled_account_count
34
35
 
35
36
  if TYPE_CHECKING:
36
37
  from fastapi.responses import Response # noqa: TCH004
@@ -252,13 +253,35 @@ async def handle_overview(
252
253
 
253
254
 
254
255
  async def handle_accounts(
255
- request: Request, period: str | None = "24h", theme: str | None = None
256
+ request: Request,
257
+ period: str | None = "24h",
258
+ theme: str | None = None,
259
+ show_disabled: bool = False,
256
260
  ) -> Response:
257
- """Render the accounts page."""
261
+ """Render the accounts page.
262
+
263
+ ``show_disabled`` defaults to False so the page matches the
264
+ operator's mental model after ``eggpool logout``: disabled rows
265
+ are hidden by default. Pass ``?show_disabled=1`` to opt in to the
266
+ historical view (soft-deleted accounts still appear with
267
+ ``Enabled = no``). When the operator filters disabled rows out and
268
+ the empty result set hides disabled tombstones, the renderer shows
269
+ a one-click "N disabled — show them?" hint instead of the generic
270
+ "No accounts configured." empty state.
271
+ """
258
272
  _get_dashboard_config(request)
259
273
  time_range = resolve_time_range(period)
260
274
  stats = request.app.state.stats
261
- accounts = await stats.get_account_stats(time_range, use_cache=True)
275
+
276
+ # Always fetch the disabled count so the empty state can offer the
277
+ # one-click opt-in even when no rows are currently visible.
278
+ disabled_count = 0
279
+ if not show_disabled:
280
+ disabled_count = await fetch_disabled_account_count(request.app.state.stats_db)
281
+
282
+ accounts = await stats.get_account_stats(
283
+ time_range, include_disabled=show_disabled, use_cache=True
284
+ )
262
285
  theme_css, _, current_theme, available = _get_theme_data(request, theme)
263
286
  return HTMLResponse(
264
287
  content=render_accounts(
@@ -268,6 +291,8 @@ async def handle_accounts(
268
291
  available_themes=available,
269
292
  current_theme=current_theme,
270
293
  update_info=_get_update_info(request),
294
+ show_disabled=show_disabled,
295
+ disabled_count=disabled_count,
271
296
  )
272
297
  )
273
298