eggpool 0.4.1__tar.gz → 0.4.2__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 (282) hide show
  1. {eggpool-0.4.1 → eggpool-0.4.2}/AGENTS.md +3 -0
  2. {eggpool-0.4.1 → eggpool-0.4.2}/CHANGELOG.md +8 -0
  3. {eggpool-0.4.1 → eggpool-0.4.2}/PKG-INFO +6 -2
  4. {eggpool-0.4.1 → eggpool-0.4.2}/README.md +5 -1
  5. {eggpool-0.4.1 → eggpool-0.4.2}/architecture/README.md +94 -0
  6. {eggpool-0.4.1 → eggpool-0.4.2}/config.example.toml +12 -0
  7. {eggpool-0.4.1 → eggpool-0.4.2}/pyproject.toml +1 -1
  8. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/_share/config.example.toml +12 -0
  9. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/app.py +18 -0
  10. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/catalog/cache.py +152 -12
  11. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/catalog/service.py +217 -11
  12. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/cli_full.py +86 -1
  13. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/routes.py +13 -1
  14. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/static/dashboard.css +23 -0
  15. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/integrations/cline.py +3 -1
  16. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/integrations/codex.py +6 -5
  17. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/integrations/common.py +32 -0
  18. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/integrations/continue_dev.py +4 -8
  19. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/integrations/goose.py +9 -4
  20. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/integrations/openhands.py +10 -5
  21. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/integrations/roo_code.py +3 -1
  22. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/repository.py +38 -2
  23. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/service.py +112 -0
  24. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/models/config.py +5 -0
  25. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/routing/router.py +116 -8
  26. {eggpool-0.4.1 → eggpool-0.4.2}/uv.lock +1 -1
  27. {eggpool-0.4.1 → eggpool-0.4.2}/.env.example +0 -0
  28. {eggpool-0.4.1 → eggpool-0.4.2}/.github/workflows/ci.yml +0 -0
  29. {eggpool-0.4.1 → eggpool-0.4.2}/.github/workflows/release.yml +0 -0
  30. {eggpool-0.4.1 → eggpool-0.4.2}/.gitignore +0 -0
  31. {eggpool-0.4.1 → eggpool-0.4.2}/LICENSE +0 -0
  32. {eggpool-0.4.1 → eggpool-0.4.2}/config-examples/claude-code.env +0 -0
  33. {eggpool-0.4.1 → eggpool-0.4.2}/config-examples/opencode.jsonc +0 -0
  34. {eggpool-0.4.1 → eggpool-0.4.2}/deploy/eggpool-logrotate.conf +0 -0
  35. {eggpool-0.4.1 → eggpool-0.4.2}/deploy/eggpool.service +0 -0
  36. {eggpool-0.4.1 → eggpool-0.4.2}/deploy/env.example +0 -0
  37. {eggpool-0.4.1 → eggpool-0.4.2}/docs/backup-restore.md +0 -0
  38. {eggpool-0.4.1 → eggpool-0.4.2}/docs/deployment.md +0 -0
  39. {eggpool-0.4.1 → eggpool-0.4.2}/docs/filesystem-layout.md +0 -0
  40. {eggpool-0.4.1 → eggpool-0.4.2}/docs/firewall.md +0 -0
  41. {eggpool-0.4.1 → eggpool-0.4.2}/docs/model-limits.md +0 -0
  42. {eggpool-0.4.1 → eggpool-0.4.2}/docs/network-diagnostics.md +0 -0
  43. {eggpool-0.4.1 → eggpool-0.4.2}/docs/providers.md +0 -0
  44. {eggpool-0.4.1 → eggpool-0.4.2}/docs/proxy.md +0 -0
  45. {eggpool-0.4.1 → eggpool-0.4.2}/docs/raspberry-pi.md +0 -0
  46. {eggpool-0.4.1 → eggpool-0.4.2}/docs/transcoding.md +0 -0
  47. {eggpool-0.4.1 → eggpool-0.4.2}/scripts/__init__.py +0 -0
  48. {eggpool-0.4.1 → eggpool-0.4.2}/scripts/check_database.py +0 -0
  49. {eggpool-0.4.1 → eggpool-0.4.2}/scripts/install.sh +0 -0
  50. {eggpool-0.4.1 → eggpool-0.4.2}/scripts/install_prompt.py +0 -0
  51. {eggpool-0.4.1 → eggpool-0.4.2}/scripts/smoke_test.py +0 -0
  52. {eggpool-0.4.1 → eggpool-0.4.2}/scripts/validate_routing.py +0 -0
  53. {eggpool-0.4.1 → eggpool-0.4.2}/scripts/verify_upstream_auth.py +0 -0
  54. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/__init__.py +0 -0
  55. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/__main__.py +0 -0
  56. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/_share/.env.example +0 -0
  57. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/accounts/__init__.py +0 -0
  58. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/accounts/registry.py +0 -0
  59. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/accounts/state.py +0 -0
  60. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/api/__init__.py +0 -0
  61. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/api/backoff.py +0 -0
  62. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/api/chat_completions.py +0 -0
  63. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/api/errors.py +0 -0
  64. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/api/messages.py +0 -0
  65. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/api/model_info.py +0 -0
  66. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/api/models.py +0 -0
  67. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/api/network.py +0 -0
  68. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/api/proxy_request.py +0 -0
  69. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/api/runtime.py +0 -0
  70. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/api/stats.py +0 -0
  71. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/api/update.py +0 -0
  72. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/auth.py +0 -0
  73. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/background/__init__.py +0 -0
  74. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/background/backup.py +0 -0
  75. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/background/cleanup.py +0 -0
  76. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/catalog/__init__.py +0 -0
  77. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/catalog/catalog_resolvers.py +0 -0
  78. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/catalog/fetcher.py +0 -0
  79. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/catalog/limits.py +0 -0
  80. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/catalog/normalizer.py +0 -0
  81. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/catalog/pricing.py +0 -0
  82. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/catalog/pricing_aliases.py +0 -0
  83. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/catalog/pricing_resolver.py +0 -0
  84. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/catalog/protocols.py +0 -0
  85. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/cli.py +0 -0
  86. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/config.py +0 -0
  87. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/config_utils.py +0 -0
  88. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/constants.py +0 -0
  89. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/cost_recompute.py +0 -0
  90. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/__init__.py +0 -0
  91. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/_resources.py +0 -0
  92. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/escape.py +0 -0
  93. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/render.py +0 -0
  94. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/static/chart.umd.min.js +0 -0
  95. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/static/dashboard.js +0 -0
  96. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/static/favicon.svg +0 -0
  97. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/theme.py +0 -0
  98. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Booberry.toml +0 -0
  99. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Catppuccin Latte.toml +0 -0
  100. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Catppuccin Macchiato.toml +0 -0
  101. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Catppuccin Mocha.toml +0 -0
  102. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Cyber Red.toml +0 -0
  103. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Cyberpunk.toml +0 -0
  104. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Dark Green.toml +0 -0
  105. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Discord (80_ Saturation).toml +0 -0
  106. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Discord.toml +0 -0
  107. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Dracula.toml +0 -0
  108. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Ferra Light.toml +0 -0
  109. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Flexor Dark.toml +0 -0
  110. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Gruvbox.toml +0 -0
  111. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Halcyon Dark.toml +0 -0
  112. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/IntelliJ Light.toml +0 -0
  113. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Kanagawa.toml +0 -0
  114. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Macaw Dark.toml +0 -0
  115. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Macaw Light.toml +0 -0
  116. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Matrix.toml +0 -0
  117. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Noctis Lilac.toml +0 -0
  118. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Nord.toml +0 -0
  119. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Nostromo Terminal.toml +0 -0
  120. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/One Dark.toml +0 -0
  121. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Oxocarbon.toml +0 -0
  122. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Rose Pine Dawn.toml +0 -0
  123. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Rose Pine Moon.toml +0 -0
  124. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Rose Pine.toml +0 -0
  125. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Solarized Dark.toml +0 -0
  126. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Sonokai.toml +0 -0
  127. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Tokyo Night Storm.toml +0 -0
  128. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/VESPER.toml +0 -0
  129. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Zenburn.toml +0 -0
  130. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/acton.toml +0 -0
  131. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/bam.toml +0 -0
  132. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/base16-atelier-forest-light.toml +0 -0
  133. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/berlin.toml +0 -0
  134. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/black but with important highlights.toml +0 -0
  135. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/broc.toml +0 -0
  136. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/cork.toml +0 -0
  137. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/ferra.toml +0 -0
  138. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/forest.toml +0 -0
  139. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/lisbon.toml +0 -0
  140. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/midnight.toml +0 -0
  141. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/oslo.toml +0 -0
  142. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/plum.toml +0 -0
  143. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/portland.toml +0 -0
  144. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/sunset.toml +0 -0
  145. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/tofino.toml +0 -0
  146. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/vanimo.toml +0 -0
  147. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/vik.toml +0 -0
  148. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/__init__.py +0 -0
  149. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/connection.py +0 -0
  150. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/migrations.py +0 -0
  151. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/repositories.py +0 -0
  152. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/rollup_repository.py +0 -0
  153. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0001_initial.sql +0 -0
  154. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0002_indexes.sql +0 -0
  155. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0003_request_attempts.sql +0 -0
  156. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0004_integration_hardening.sql +0 -0
  157. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0005_price_microdollars.sql +0 -0
  158. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0006_correct_price_microdollars.sql +0 -0
  159. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0007_price_cache_rates.sql +0 -0
  160. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0008_proxy_request_identity.sql +0 -0
  161. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0009_model_protocol_source.sql +0 -0
  162. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0010_health_probe.sql +0 -0
  163. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0011_model_resolution_status.sql +0 -0
  164. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0012_drop_reservations_estimated_microdollars.sql +0 -0
  165. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0013_request_attempts_account_id_index.sql +0 -0
  166. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0014_bandwidth_tracking.sql +0 -0
  167. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0015_multi_provider.sql +0 -0
  168. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0016_requests_provider_id.sql +0 -0
  169. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0017_price_snapshots_provider_id.sql +0 -0
  170. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0018_provider_pings.sql +0 -0
  171. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0019_client_ip.sql +0 -0
  172. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0020_performance_indexes.sql +0 -0
  173. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0021_provider_model_metadata.sql +0 -0
  174. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0022_dashboard_indexes.sql +0 -0
  175. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0023_deprecated_model_placeholder.sql +0 -0
  176. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0024_account_backoffs.sql +0 -0
  177. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0025_stale_request_index.sql +0 -0
  178. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0026_attempt_observability.sql +0 -0
  179. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0027_routing_decisions.sql +0 -0
  180. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0028_operational_events.sql +0 -0
  181. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0029_latency_phases.sql +0 -0
  182. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0030_model_pricing_aliases.sql +0 -0
  183. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0031_price_snapshot_provenance.sql +0 -0
  184. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0032_usage_rollups.sql +0 -0
  185. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0033_request_provider_local_cost.sql +0 -0
  186. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0034_transcoding_daily.sql +0 -0
  187. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0035_routing_decision_score_components.sql +0 -0
  188. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0036_model_info.sql +0 -0
  189. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0037_model_info_failure_count.sql +0 -0
  190. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0038_model_info_phase5.sql +0 -0
  191. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/checksums.json +0 -0
  192. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/deploy/__init__.py +0 -0
  193. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/deploy_user.py +0 -0
  194. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/errors.py +0 -0
  195. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/fastcli.py +0 -0
  196. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/health/__init__.py +0 -0
  197. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/health/backoff.py +0 -0
  198. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/health/circuit_breaker.py +0 -0
  199. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/health/health_manager.py +0 -0
  200. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/integrations/__init__.py +0 -0
  201. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/integrations/aider.py +0 -0
  202. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/integrations/kilo.py +0 -0
  203. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/integrations/opencode.py +0 -0
  204. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/integrations/qwen_code.py +0 -0
  205. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/lifecycle/__init__.py +0 -0
  206. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/lifecycle/backup.py +0 -0
  207. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/lifecycle/uninstall.py +0 -0
  208. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/logging.py +0 -0
  209. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/metrics/__init__.py +0 -0
  210. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/metrics/buffer.py +0 -0
  211. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/__init__.py +0 -0
  212. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/dedup.py +0 -0
  213. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/identity.py +0 -0
  214. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/scheduler.py +0 -0
  215. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/sources/__init__.py +0 -0
  216. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/sources/artificial_analysis.py +0 -0
  217. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/sources/base.py +0 -0
  218. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/sources/huggingface.py +0 -0
  219. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/sources/openrouter.py +0 -0
  220. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/sources/provider_catalog.py +0 -0
  221. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/types.py +0 -0
  222. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/models/__init__.py +0 -0
  223. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/models/api.py +0 -0
  224. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/models/database.py +0 -0
  225. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/models/domain.py +0 -0
  226. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/onboard.py +0 -0
  227. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/providers/__init__.py +0 -0
  228. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/providers/_templates.toml +0 -0
  229. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/providers/auth.py +0 -0
  230. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/providers/client_pool.py +0 -0
  231. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/providers/connect.py +0 -0
  232. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/providers/contract.py +0 -0
  233. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/providers/dns_cache.py +0 -0
  234. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/providers/outbound.py +0 -0
  235. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/providers/pproxy_transport.py +0 -0
  236. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/proxy/__init__.py +0 -0
  237. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/proxy/client.py +0 -0
  238. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/proxy/cost_reporting.py +0 -0
  239. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/proxy/sse_observer.py +0 -0
  240. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/proxy/usage.py +0 -0
  241. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/py.typed +0 -0
  242. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/quota/__init__.py +0 -0
  243. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/quota/audit.py +0 -0
  244. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/quota/estimation.py +0 -0
  245. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/quota/reservation.py +0 -0
  246. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/quota/scorer.py +0 -0
  247. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/request/__init__.py +0 -0
  248. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/request/attempt_finalizer.py +0 -0
  249. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/request/body.py +0 -0
  250. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/request/coordinator.py +0 -0
  251. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/request/finalizer.py +0 -0
  252. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/request/limits.py +0 -0
  253. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/retry/__init__.py +0 -0
  254. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/retry/classification.py +0 -0
  255. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/routing/__init__.py +0 -0
  256. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/routing/eligibility.py +0 -0
  257. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/routing/fairness.py +0 -0
  258. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/routing/provider.py +0 -0
  259. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/runtime.py +0 -0
  260. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/runtime_dispatch.py +0 -0
  261. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/runtime_metrics.py +0 -0
  262. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/runtime_paths.py +0 -0
  263. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/security/__init__.py +0 -0
  264. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/security/redaction.py +0 -0
  265. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/stats/__init__.py +0 -0
  266. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/stats/grouped_timeseries.py +0 -0
  267. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/stats/queries.py +0 -0
  268. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/stats/service.py +0 -0
  269. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/toml_edit.py +0 -0
  270. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/transcoder/__init__.py +0 -0
  271. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/transcoder/anthropic_to_openai.py +0 -0
  272. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/transcoder/context.py +0 -0
  273. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/transcoder/errors.py +0 -0
  274. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/transcoder/ids.py +0 -0
  275. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/transcoder/json_helpers.py +0 -0
  276. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/transcoder/openai_to_anthropic.py +0 -0
  277. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/transcoder/policy.py +0 -0
  278. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/transcoder/protocol.py +0 -0
  279. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/transcoder/static_headers.py +0 -0
  280. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/transcoder/streaming.py +0 -0
  281. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/transcoder/usage.py +0 -0
  282. {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/update_checker.py +0 -0
@@ -76,6 +76,7 @@ CI sets `PYTHONHASHSEED=0` and `TZ=UTC`; reproduce locally for deterministic res
76
76
  - **Process model**: supervisor + Granian worker, PID file lifecycle, daemon mode. See `architecture/README.md` § Daemon Mode.
77
77
  - **Dashboard**: server-rendered HTML, 13 pages (including `/models/{model_id:path}` model-info detail), Chart.js v4, grouped timeseries, CSS tooltips. See `architecture` skill § Dashboard.
78
78
  - **Observability**: attempt analytics, routing analytics, latency phases, pending health, runtime metrics. See `architecture` skill § Runtime Observability.
79
+ - **Catalog refresh semantics**: `CatalogService._fetch_and_process_account` returns an `AccountCatalogOutcome` enum (`SUCCESS_AUTHORITATIVE`, `SUCCESS_PARTIAL`, `SUCCESS_EMPTY`, `FAILED`, `SKIPPED`). Per-account cache updates run through `ModelCatalogCache.update_from_account(authoritative, allow_withdrawals)`, which is **non-destructive by default** — both flags default to `False`. The destructive `mark_account_models_unavailable` step is gated on `authoritative=True AND allow_withdrawals=True`, so the cache layer itself enforces the invariant. `ModelsConfig.catalog_withdrawal_policy` (`preserve_until_health` (default), `confirmed_once`, `confirmed_twice`) governs when the service flips `allow_withdrawals=True`. `SUCCESS_PARTIAL` always forces withdrawal off because a partial response is never a complete withdrawal confirmation. The per-cycle INFO summary at `_log_refresh_summary` reports per-outcome counts so operators can spot catalog uncertainty without enabling debug logging. See `architecture/README.md` § Catalog Refresh Semantics.
79
80
  - **Agent integrations**: `eggpool configsetup` generates configuration snippets for 11 coding agents (OpenCode, Claude Code, Aider, Codex, Qwen Code, Kilo, Continue, Cline, Roo Code, Goose, OpenHands). Target-specific generators live in `src/eggpool/integrations/`; shared utilities in `src/eggpool/config_utils.py` (`resolve_server_api_key`, `ServerKeyResolution`). Transcoder enablement is persisted to TOML when required. Secret output is controlled explicitly via `contains_secret`. See `architecture/README.md` § Package Structure.
80
81
  - **Model information**: `model_info/` sidecar subsystem with persistent metadata, provider-native observations (`ProviderCatalogSource`), OpenRouter metadata source (`OpenRouterModelInfoSource` — TTL-cached, exact alias matching, uses shared outbound client), identity resolution (`model_info/identity.py` — exact/curated alias matching only, no fuzzy matching), status classification, lifecycle wiring (`ModelInfoService`), background refresh scheduler (`ModelInfoRefreshScheduler`), catalog-refresh-driven reconciliation (`CatalogRefreshResult`), and CLI inspection. `ModelInfoSourceFetchError` for source failures. Phase 4 adds JSON API endpoints (`GET /api/model-info`, `GET /api/model-info/{model_id}`, `GET /api/model-info/sources`, `POST /api/model-info/refresh`), compact `/v1/models` enrichment under `eggpool.model_info`, and dashboard model-info status pills with tooltips. Phase 5 adds Artificial Analysis and Hugging Face source adapters, manual overrides (`[model_info.overrides]`), alias expansion (`[model_info.aliases]`), source health hardening (`rate_limited_until`, `last_status_code`, `last_payload_count`, `last_success_duration_ms`), richer summary generation (benchmarks, HF metadata, conflicts), and `model_info_overrides` table (migration `0037`). See `architecture/README.md` § Model Information.
81
82
 
@@ -85,6 +86,8 @@ CI sets `PYTHONHASHSEED=0` and `TZ=UTC`; reproduce locally for deterministic res
85
86
  - No pre-commit hooks are configured in this repo; CI runs ruff, pyright, and pytest via GitHub Actions
86
87
  - **`static_models` is the source of truth for provider-specific protocol** — `FAMILY_PROTOCOLS` (`src/eggpool/catalog/protocols.py`) is a global fallback. 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 protocol check clears it to `None`, producing `ModelUnavailableError` instead of `ProtocolMismatchError`. Static seeds survive via `ModelCatalogCache._preserve_static_fields` (`src/eggpool/catalog/cache.py:146-187`).
87
88
  - **Upstream-authoritative suppression**: local quota estimates are advisory by default (`local_quota_mode = "score_only"`). Only upstream-observed failures (429/402/5xx/auth) and explicit operator disablement suppress routing. Switch to `hard_cap` only as an opt-in escape hatch.
89
+ - **Catalog non-destructive refresh**: `ModelCatalogCache.update_from_account(authoritative=False, allow_withdrawals=False)` (the default) preserves prior account/model support on every cycle — failed, empty, and partial refreshes never de-pool a healthy account. The destructive `mark_account_models_unavailable` step is only reached when the service explicitly sets both flags, gated by `ModelsConfig.catalog_withdrawal_policy`. Under the default `preserve_until_health` policy the flags are never flipped, so `HealthManager` is the only de-pooling mechanism. Static seeds (`_seed_static_models`) inherit the non-destructive default and survive future refresh cycles via `_preserve_static_fields`.
90
+ - **Sibling-wins protocol guard**: `_provider_models` is keyed by `(model_id, provider_id)` and **shared** by every account that lists that provider (e.g. all `opencode-go-0001`/`-0002`/`-0003` accounts share one row per model on the `opencode-go` provider). `_preserve_resolved_protocol()` (`src/eggpool/catalog/cache.py`) is invoked on every non-destructive `update_from_account` call: when a per-provider row already has a resolved protocol and the new entry arrives with `protocol=None`, the prior protocol is preserved. This prevents a single sibling's partial refresh from silently de-pooling every account on that provider. The destructive path (`authoritative=True AND allow_withdrawals=True`) intentionally skips the guard.
88
91
  - **Same-tier fairness rotor**: EggPool is not purely lowest-score-wins for same-tier peer accounts. When accounts are effectively tied by priority, weight, health, protocol, and utilization score, same-tier fairness rotates candidates to avoid stable config-order bias and subscription starvation. When `fairness_mode = "round_robin"` (the default), effectively tied accounts within the same priority tier are rotated deterministically via `FairnessRotor` (`src/eggpool/routing/fairness.py`). Band membership requires same priority, same weight, same transcode status, and score within `fairness_epsilon` of the best. The server runtime honors all `[routing]` fairness config fields: `fairness_mode`, `fairness_epsilon`, and `fairness_scope`. The `fairness_scope` controls rotation group granularity: `provider_model_protocol` (default) includes the routed protocol in the key, `provider_model` intentionally collapses protocol groups, and `priority_model_protocol` intentionally co-balances same-priority providers serving the same model. The rotor's position map is capped at 4096 entries (`_ROTOR_HARD_CAP`); when reached the entire map is cleared and rotation restarts from 0. Restart also resets all rotor state — durable round-robin is explicitly out of scope.
89
92
  - **Backoff persistence**: upstream-derived backoffs survive restarts via the `account_backoffs` table (`src/eggpool/db/schema/0024_account_backoffs.sql`). Local cost overruns must never be persisted as backoff rows.
90
93
  - **Synthetic 503 vs 502**: `ModelUnavailableError` (503) is reserved for genuine pre-dispatch unavailability. `UpstreamExhaustedError` (502) is raised when every candidate account was attempted and exhausted mid-request.
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ### Added
11
11
 
12
+ - **Catalog non-destructive refresh contract.** `ModelCatalogCache.update_from_account()` now accepts `authoritative: bool = False, allow_withdrawals: bool = False`; both flags default to `False` so a failed, empty, or partial refresh cannot silently de-pool a healthy account. Operators opt into the old "refresh is the source of truth" behavior with `ModelsConfig.catalog_withdrawal_policy` (`preserve_until_health` default, `confirmed_once`, `confirmed_twice`). Under the default policy, health is the only de-pooling mechanism. `CatalogService._fetch_and_process_account()` returns an `AccountCatalogOutcome` enum (`SUCCESS_AUTHORITATIVE`, `SUCCESS_PARTIAL`, `SUCCESS_EMPTY`, `FAILED`, `SKIPPED`) plus an `AccountCatalogUpdateResult` summary row so the cache layer can stay non-destructive while operators still get full audit trail. `_log_refresh_summary` emits a single INFO line enumerating per-outcome counts after every cycle so catalog uncertainty shows up without enabling debug logging.
13
+ - **`eggpool accounts explain --gates`** (`src/eggpool/cli_full.py`) renders the per-account gate breakdown (config, credentials, health, circuit, provider id registry/catalog match, provider-supports-protocol, model support row/availability/freshness, provider-metadata-exists, protocol_match, local_quota_gate, final_eligible) as a compact text table. Backed by `Router.explain_account_eligibility(include_gates=True)` and `Router._collect_gate_status(...)`; the breakdown is informational — the canonical decision still comes from `_classify_eligibility`.
12
14
  - **Same-tier account fairness rotor**: deterministic round-robin rotation for effectively tied same-tier accounts prevents routing starvation. Configurable via `[routing] fairness_mode`, `fairness_epsilon`, and `fairness_scope`. Defaults to `round_robin` with `provider_model_protocol` scope. Fairness decisions are recorded in `routing_decisions.score_components_json` under the `fairness` key for operator diagnostics. The `fairness` payload now includes the `scope` field, and each `top_candidates` entry carries `rank_before_fairness`, `rank_after_fairness`, and `fairness_band_member` for full traceability. The `FairnessRotor` position map is capped at 4096 entries (`_ROTOR_HARD_CAP`); when reached the entire map is cleared and rotation restarts from 0.
13
15
  - **Bidirectional OpenAI ↔ Anthropic protocol transcoding.** When `[transcoder] enabled = true`, requests from clients using one protocol can be forwarded to upstream accounts that speak only the other. Initial scope is text-only requests and responses, plus streaming SSE. Tool calls, vision, and extended thinking land in a follow-up release. See `docs/transcoding.md` for the full translation table.
14
16
  - New `eggpool stats transcoding [--period 1d|7d|30d]` subcommand for transcoding observability.
@@ -22,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
22
24
  ### Changed
23
25
 
24
26
  - `RequestCoordinator` now carries `upstream_protocol` alongside `protocol` on `ProxyRequestContext`. Behaviour is identical when `[transcoder] enabled = false`.
27
+ - **`ModelCatalogCache.update_from_account()` semantic shift — now non-destructive by default.** Both `authoritative` and `allow_withdrawals` keyword arguments default to `False`, so a per-account catalog update that omits previously-known support rows no longer silently de-pools those rows. Updates that already pass `authoritative=True, allow_withdrawals=True` continue to behave as before (and are still the only way to remove support via a refresh). All in-tree callers that intentionally destroy support — the destructive `prune_unused()` cleanup step and the explicit `update_from_account(...)` calls in `tests/unit/test_catalog.py` / `tests/integration/test_catalog_unresolved_models.py` — now pass both flags explicitly so the test suite continues to assert legacy destructive behavior. See `architecture/README.md` § Catalog Refresh Semantics.
25
28
  - **`RequestCoordinator._select_and_persist_attempt()` lock scope.** The runtime publication step (`Router.increment_active_request_count` + `QuotaEstimator.add_reservation`) now runs INSIDE `_select_lock` AFTER the durable transaction commits but BEFORE the lock releases. The two contexts are written as explicit nested `async with` blocks (outer `_select_lock`, inner `_db.transaction()`). Note: collapsing them back into the previous compound `async with self._select_lock, self._db.transaction():` form would NOT by itself re-introduce the stale-score race on context-exit-order grounds — Python exits context managers right-to-left, so the transaction would still commit before the lock released. The actual bug was that the runtime publication block lived INSIDE the transaction body, so active-count and reserved-cost state were published before the durable transaction committed. The explicit nested form makes it hard to accidentally place publication inside the transaction while still keeping publication under `_select_lock`. The key invariant is block placement (publication must be outside the DB transaction body but still inside `_select_lock`), not context-exit order. The compensation chain (decrement → finalize-as-cancelled → release health slot → set `client_metadata["post_commit_interrupted"]` → re-raise) is preserved and still catches `BaseException` (including `CancelledError` / `SystemExit` / `KeyboardInterrupt`, all re-raised without being swallowed).
26
29
  - `RoutingScore` gains diagnostic fields (`reserved_microdollars`, `cost_5h_microdollars`, `cost_7d_microdollars`, `cost_30d_microdollars`, `capacity_5h_microdollars`, `capacity_7d_microdollars`, `capacity_30d_microdollars`, `active_request_count`) so the scorer can return enough state to populate `score_components_json` without a second pass over the quota tables.
27
30
  - `RoutingDecisionTrace` gains `score_components: Mapping[str, Any] | None` plus `to_score_components_json()`; `RoutingDecisionRepository.create()` accepts an optional `score_components_json` argument (defaults to `'{}'` for backward compatibility with rows inserted by code paths that have not yet been migrated).
@@ -31,6 +34,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
31
34
  - **`eggpool accounts status` now prints `routing_priority`.** The per-line output gained a `priority=N` field derived from the account's provider, alongside `provider`, `enabled`, `weight`, and the api-key-env set state.
32
35
  - **`eggpool accounts explain` runs migrations on fresh installs.** The inner `_run_explain` coroutine now calls `MigrationRunner(db).run()` before hydrating `ModelCatalogCache.hydrate_from_db(db)`, so a brand-new (unmigrated) database path no longer crashes with `sqlite3.OperationalError: no such table: models` / `provider_model_metadata` / `account_models`. With no catalog rows yet, accounts surface a `no_model` verdict instead of the SQL error. The command still performs no outbound provider refresh.
33
36
 
37
+ ### Fixed
38
+
39
+ - **Cross-account protocol poisoning from a partial sibling refresh.** `_provider_models` is keyed by `(model_id, provider_id)` and shared by every account that lists that provider (e.g. all `opencode-go-0001`/`-0002`/`-0003` accounts share one row per model on the `opencode-go` provider). Prior to this fix a single sibling's partial refresh — transient upstream parse error, unresolved family prefix, or a model whose protocol cannot be re-derived this cycle — produced a model entry with `protocol=None` and clobbered the previously-resolved protocol on the shared row, silently dropping every sibling account from routing even though `_account_support` still listed them. `ModelCatalogCache._preserve_resolved_protocol()` now applies a sibling-wins guard: a non-destructive `update_from_account` will keep the existing resolved protocol when the new entry's `protocol` is `None` and the prior row carried a resolved value. The destructive path (`authoritative=True AND allow_withdrawals=True`) intentionally skips the guard so operator-initiated withdrawals remain effective. `tests/unit/test_catalog_withdrawal_policy.py::test_partial_refresh_does_not_clobber_shared_provider_protocol{,multiple}` and `test_explicit_destructive_update_can_still_clear_protocol` pin the new contract.
40
+
34
41
  ## [0.3.5] - 2026-06-27
35
42
 
36
43
  ### Changed
@@ -43,6 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
43
50
 
44
51
  - **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.
45
52
  - **`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.
53
+ - **Catalog empty-data list is now classified `SUCCESS_EMPTY`, not `SUCCESS_AUTHORITATIVE`.** `CatalogService._fetch_and_process_account()` now distinguishes between "no model list in the response payload" (the existing `SUCCESS_EMPTY` branch on `result.response == {}`) and "model list returned but zero normalizable items after filter" (a new branch that fires when `normalize_models(...) == []` with `result.error is None`). Prior to this fix a fully empty but healthy upstream response was reported as authoritative and could mask a regression in the upstream `/v1/models` payload; the per-cycle summary line in operators' logs now correctly enumerates these as `empty=N`.
46
54
 
47
55
  ### Added
48
56
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eggpool
3
- Version: 0.4.1
3
+ Version: 0.4.2
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
@@ -130,7 +130,7 @@ Use `eggpool connect` for interactive provider setup. See [docs/providers.md](do
130
130
  | `[server]` | Bind address, port (default 11300), API key, logging, threads |
131
131
  | `[upstream]` | Upstream API base URL, timeouts, connection pool |
132
132
  | `[database]` | SQLite path, WAL mode |
133
- | `[models]` | Catalog refresh, exposure mode, model collapse |
133
+ | `[models]` | Catalog refresh, exposure mode, model collapse, withdrawal policy |
134
134
  | `[routing]` | Routing strategy, retry limits, quota mode, same-tier fairness |
135
135
  | `[dashboard]` | Dashboard toggle, theme, refresh interval |
136
136
  | `[providers.*]` | Provider configs with accounts and routing priority |
@@ -138,6 +138,8 @@ Use `eggpool connect` for interactive provider setup. See [docs/providers.md](do
138
138
  | `[model_info]` | Optional model metadata refresh, aliases, overrides, and external source settings |
139
139
  | `[transcoder]` | Protocol transcoding between OpenAI and Anthropic formats |
140
140
 
141
+ The catalog refresh is **non-destructive by default**: failed, empty, or partial upstream responses never silently de-pool a healthy account. Set `[models].catalog_withdrawal_policy` (`preserve_until_health` default, `confirmed_once`, `confirmed_twice`) to opt into destructive behavior on authoritative refreshes. See `architecture/README.md` § Catalog Refresh Semantics.
142
+
141
143
  Full config reference: [`config.example.toml`](config.example.toml) | [docs/providers.md](docs/providers.md)
142
144
 
143
145
  ## Protocol transcoding
@@ -226,6 +228,8 @@ uv run pytest
226
228
  | OpenHands | `eggpool configsetup openhands` | Shell env exports | N/A (printed) | recommended | stable fragment |
227
229
 
228
230
  Shared options: `--host`, `--base-url`, `--model`, `--write`, `--output`, `--force`, `--no-clipboard`, `--print-secret`.
231
+ Generated JSON, TOML, YAML, and shell snippets escape catalog/config values for
232
+ the target format, including provider-suffixed model IDs.
229
233
 
230
234
  Examples:
231
235
  ```sh
@@ -86,7 +86,7 @@ Use `eggpool connect` for interactive provider setup. See [docs/providers.md](do
86
86
  | `[server]` | Bind address, port (default 11300), API key, logging, threads |
87
87
  | `[upstream]` | Upstream API base URL, timeouts, connection pool |
88
88
  | `[database]` | SQLite path, WAL mode |
89
- | `[models]` | Catalog refresh, exposure mode, model collapse |
89
+ | `[models]` | Catalog refresh, exposure mode, model collapse, withdrawal policy |
90
90
  | `[routing]` | Routing strategy, retry limits, quota mode, same-tier fairness |
91
91
  | `[dashboard]` | Dashboard toggle, theme, refresh interval |
92
92
  | `[providers.*]` | Provider configs with accounts and routing priority |
@@ -94,6 +94,8 @@ Use `eggpool connect` for interactive provider setup. See [docs/providers.md](do
94
94
  | `[model_info]` | Optional model metadata refresh, aliases, overrides, and external source settings |
95
95
  | `[transcoder]` | Protocol transcoding between OpenAI and Anthropic formats |
96
96
 
97
+ The catalog refresh is **non-destructive by default**: failed, empty, or partial upstream responses never silently de-pool a healthy account. Set `[models].catalog_withdrawal_policy` (`preserve_until_health` default, `confirmed_once`, `confirmed_twice`) to opt into destructive behavior on authoritative refreshes. See `architecture/README.md` § Catalog Refresh Semantics.
98
+
97
99
  Full config reference: [`config.example.toml`](config.example.toml) | [docs/providers.md](docs/providers.md)
98
100
 
99
101
  ## Protocol transcoding
@@ -182,6 +184,8 @@ uv run pytest
182
184
  | OpenHands | `eggpool configsetup openhands` | Shell env exports | N/A (printed) | recommended | stable fragment |
183
185
 
184
186
  Shared options: `--host`, `--base-url`, `--model`, `--write`, `--output`, `--force`, `--no-clipboard`, `--print-secret`.
187
+ Generated JSON, TOML, YAML, and shell snippets escape catalog/config values for
188
+ the target format, including provider-suffixed model IDs.
185
189
 
186
190
  Examples:
187
191
  ```sh
@@ -43,6 +43,11 @@ src/eggpool/
43
43
  └── constants.py # Project-wide constants
44
44
  ```
45
45
 
46
+ `integrations/common.py` owns configsetup context construction, catalog-backed
47
+ default model resolution, and format-safe scalar/key rendering helpers. New
48
+ agent targets should reuse those helpers instead of hand-quoting JSON, TOML,
49
+ YAML, shell, or model ID values in target modules.
50
+
46
51
  ## Request Lifecycle
47
52
 
48
53
  All data-plane requests flow through `RequestCoordinator`:
@@ -507,6 +512,95 @@ suffixed (or unsuffixed) model ID.
507
512
  provider block and leaves existing blocks untouched, so operators can edit
508
513
  one number to rebalance.
509
514
 
515
+ ## Catalog Refresh Semantics
516
+
517
+ The catalog refresh path is **non-destructive by default**. Healthy
518
+ account/model support rows survive every refresh cycle so a transient
519
+ network blip, an empty upstream response, or a partially-normalized
520
+ response cannot silently de-pool a healthy account. The only de-pooling
521
+ mechanism in the catalog layer is `HealthManager`; configuration and
522
+ health state jointly own the eligibility decision.
523
+
524
+ ### Per-account outcome classification
525
+
526
+ `CatalogService._fetch_and_process_account` categorizes every refresh
527
+ attempt and returns an `AccountCatalogOutcome` plus, on success, an
528
+ `AccountCatalogUpdateResult`:
529
+
530
+ | Outcome | When | Cache touched? |
531
+ | ------- | ---- | -------------- |
532
+ | `SUCCESS_AUTHORITATIVE` | HTTP 2xx, fully protocol-resolved, non-empty | Add/update only unless `catalog_withdrawal_policy` permits withdrawal |
533
+ | `SUCCESS_PARTIAL` | HTTP 2xx, but at least one model lacks a resolved protocol | Add/update only; withdrawal forced off |
534
+ | `SUCCESS_EMPTY` | HTTP 2xx with zero normalizable models | No-op (prior support preserved) |
535
+ | `FAILED` | Network/5xx/auth/quota/JSON-shape failure | **No** (cache untouched) |
536
+ | `SKIPPED` | Fetcher returned without contacting upstream | **No** (cache untouched) |
537
+
538
+ ### Withdrawal policy
539
+
540
+ `ModelsConfig.catalog_withdrawal_policy` controls when withdrawal is
541
+ permitted:
542
+
543
+ - `preserve_until_health` (default) — withdrawal is **never**
544
+ triggered by a refresh. Health state is the sole de-pooling
545
+ mechanism.
546
+ - `confirmed_once` — a single authoritative refresh may withdraw
547
+ support for models no longer advertised.
548
+ - `confirmed_twice` — two consecutive authoritative refreshes are
549
+ required to withdraw support.
550
+
551
+ `SUCCESS_PARTIAL` overrides the policy for that cycle and forces
552
+ `allow_withdrawals = False` because a partial response is never a
553
+ complete withdrawal confirmation. The destructive
554
+ `mark_account_models_unavailable` step is also gated on
555
+ `authoritative=True, allow_withdrawals=True` so the cache layer
556
+ itself enforces the invariant; the service decides which flags to
557
+ flip based on outcome category and policy.
558
+
559
+ ### Per-cycle operational logging
560
+
561
+ `CatalogService.refresh()` calls `_log_refresh_summary` after every
562
+ cycle. The INFO log enumerates per-outcome counts on one line so
563
+ operators can spot catalog uncertainty without enabling debug
564
+ logging:
565
+
566
+ ```
567
+ Catalog refresh summary: policy=preserve_until_health total=3 authoritative=1 partial=0 empty=1 failed=1 skipped=0
568
+ ```
569
+
570
+ A run with many `FAILED` or `PARTIAL` rows is signal that an upstream
571
+ or DNS path is unhealthy; a sudden shift in `AUTHORITATIVE` count
572
+ indicates a real catalog change that may need rebalancing.
573
+
574
+ ### Gate diagnostics for `accounts explain`
575
+
576
+ `Router.explain_account_eligibility(include_gates=True)` returns a
577
+ per-account gate breakdown dict (config, credentials, health,
578
+ circuit, provider, protocol, model support, freshness, provider
579
+ metadata, protocol match, local quota, final eligible) so operators
580
+ can pinpoint exactly which gate is failing without running live
581
+ traffic. The dict is informational; the canonical decision still
582
+ comes from `_classify_eligibility`. The `eggpool accounts explain
583
+ --gates` CLI command renders the same breakdown as a compact
584
+ text table.
585
+
586
+ ### Shared per-provider metadata and sibling-wins protocol guard
587
+
588
+ `_provider_models` is keyed by `(model_id, provider_id)` and is
589
+ **shared** by every account that lists that provider — e.g. all
590
+ `opencode-go-0001`/`-0002`/`-0003` accounts share one row per
591
+ model on the `opencode-go` provider. The previous
592
+ `update_from_account()` clobbered this shared dict unconditionally
593
+ even on partial responses, which produced the upstream-reported
594
+ "all traffic on `opencode-go-0001`, none on `0002`/`0003`" regression
595
+ when a single account's refresh resolved the protocol as `None`.
596
+ `_preserve_resolved_protocol()` now applies a sibling-wins guard
597
+ in the non-destructive path: when a per-provider row already has a
598
+ resolved protocol and the new entry arrives with `protocol=None`,
599
+ the prior protocol is preserved and the resulting per-provider row
600
+ is shared across all sibling accounts. The destructive path
601
+ (`authoritative=True AND allow_withdrawals=True`) intentionally
602
+ skips the guard so operator-initiated withdrawals remain effective.
603
+
510
604
  ## Error Hierarchy
511
605
 
512
606
  ```
@@ -38,6 +38,18 @@ ping_retain_days = 7
38
38
  # # providers is collapsed to a single unsuffixed
39
39
  # # ID and routed across all of them. Default false
40
40
  # # emits one provider-suffixed entry per provider.
41
+ # Catalog withdrawal policy controls whether a live refresh can remove
42
+ # an account/model support row that the cache previously knew about.
43
+ # Default "preserve_until_health" never de-pools on catalog uncertainty:
44
+ # only explicit health/config state (auth failure, quota exhaustion,
45
+ # rate-limit, cooldown, circuit open, account disabled) suppresses
46
+ # routing. "confirmed_once" allows a successful authoritative non-empty
47
+ # refresh that omits a model to remove that account/model support.
48
+ # "confirmed_twice" requires two consecutive authoritative omissions.
49
+ # Default is correct for subscription aggregation: a flaky /models
50
+ # response or transient upstream parse error must not silently de-pool
51
+ # a healthy subscription account.
52
+ catalog_withdrawal_policy = "preserve_until_health"
41
53
 
42
54
  # ─── Protocol Transcoding ────────────────────────────────────────────
43
55
  # Transcoding is **on by default**. Requests from clients using one
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "eggpool"
3
- version = "0.4.1"
3
+ version = "0.4.2"
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"
@@ -40,6 +40,18 @@ stale_after_s = 7200
40
40
  allow_stale_catalog = true
41
41
  # How many days to retain provider ping data.
42
42
  ping_retain_days = 7
43
+ # Catalog withdrawal policy controls whether a live refresh can remove
44
+ # an account/model support row that the cache previously knew about.
45
+ # Default "preserve_until_health" never de-pools on catalog uncertainty:
46
+ # only explicit health/config state (auth failure, quota exhaustion,
47
+ # rate-limit, cooldown, circuit open, account disabled) suppresses
48
+ # routing. "confirmed_once" allows a successful authoritative non-empty
49
+ # refresh that omits a model to remove that account/model support.
50
+ # "confirmed_twice" requires two consecutive authoritative omissions.
51
+ # Default is correct for subscription aggregation: a flaky /models
52
+ # response or transient upstream parse error must not silently de-pool
53
+ # a healthy subscription account.
54
+ catalog_withdrawal_policy = "preserve_until_health"
43
55
 
44
56
  [routing]
45
57
  strategy = "quota_fair"
@@ -735,6 +735,15 @@ async def _lifespan_runtime(app: FastAPI) -> AsyncGenerator[None]:
735
735
  )
736
736
  except Exception:
737
737
  logger.exception("Model info startup reconciliation failed")
738
+ # Backfill canonical rows for any models table rows
739
+ # that reconcile_catalog_snapshot missed (e.g. models
740
+ # withdrawn and later reappeared).
741
+ try:
742
+ backfill = await model_info.backfill_missing_canonical()
743
+ if backfill["backfilled"] > 0:
744
+ logger.info("Model info startup backfill: %s", backfill)
745
+ except Exception:
746
+ logger.exception("Model info startup backfill failed")
738
747
  except Exception:
739
748
  logger.exception("Failed to initialize model info service")
740
749
  model_info = None
@@ -959,6 +968,15 @@ async def _lifespan_runtime(app: FastAPI) -> AsyncGenerator[None]:
959
968
  lambda: model_info.run_periodic_refresh(),
960
969
  )
961
970
 
971
+ # Register periodic backfill of canonical rows for models that lack
972
+ # one. Runs every 60s so withdrawn-and-reappeared models are
973
+ # covered within one cycle.
974
+ if config.model_info.enabled and model_info is not None:
975
+ supervisor.register(
976
+ "model_info_canonical_backfill",
977
+ lambda: model_info.run_backfill_missing_canonical(),
978
+ )
979
+
962
980
  # Register retention cleanup task (runs every hour)
963
981
  async def _retention_cleanup() -> None:
964
982
  while True:
@@ -6,6 +6,8 @@ import datetime as _dt
6
6
  import json
7
7
  import logging
8
8
  import time
9
+ from dataclasses import dataclass
10
+ from enum import Enum
9
11
  from typing import TYPE_CHECKING, Any, cast
10
12
 
11
13
  from eggpool.catalog.limits import EffectiveModelLimits, conservative_limits
@@ -21,6 +23,41 @@ if TYPE_CHECKING:
21
23
  logger = logging.getLogger(__name__)
22
24
 
23
25
 
26
+ class AccountCatalogOutcome(Enum):
27
+ """Result of a per-account catalog fetch.
28
+
29
+ The catalog service categorizes each refresh response before touching
30
+ the in-memory cache so that failed, empty, or partial refreshes do not
31
+ silently de-pool a healthy account. Only ``SUCCESS_AUTHORITATIVE``
32
+ responses are allowed to remove support from the cache.
33
+ """
34
+
35
+ SUCCESS_AUTHORITATIVE = "success_authoritative"
36
+ SUCCESS_EMPTY = "success_empty"
37
+ SUCCESS_PARTIAL = "success_partial"
38
+ FAILED = "failed"
39
+ SKIPPED = "skipped"
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class AccountCatalogUpdateResult:
44
+ """Summary of a single ``update_from_account`` call.
45
+
46
+ Surfaced to the catalog service so it can record an operational
47
+ event and so a destructive update can be audited (which models
48
+ were added, updated, withdrawn, or preserved).
49
+ """
50
+
51
+ account_name: str
52
+ provider_id: str
53
+ authoritative: bool
54
+ allow_withdrawals: bool
55
+ added_support: int
56
+ updated_support: int
57
+ preserved_support: int
58
+ withdrawn_support: int
59
+
60
+
24
61
  def parse_model_id(
25
62
  model_id: str, known_providers: set[str] | None = None
26
63
  ) -> tuple[str, str | None]:
@@ -137,26 +174,77 @@ class ModelCatalogCache:
137
174
  account_name: str,
138
175
  provider_id: str,
139
176
  models: list[dict[str, Any]],
140
- ) -> None:
141
- """Update cache with models from a specific account."""
177
+ *,
178
+ authoritative: bool = False,
179
+ allow_withdrawals: bool = False,
180
+ ) -> AccountCatalogUpdateResult:
181
+ """Update cache with models from a specific account.
182
+
183
+ ``authoritative`` and ``allow_withdrawals`` gate the destructive
184
+ path so a failed, empty, or partial refresh cannot silently
185
+ de-pool a healthy account. Live fetch callers should pass
186
+ ``authoritative=True`` only after a successful non-empty
187
+ response that has been protocol-resolved, and
188
+ ``allow_withdrawals=True`` only when the configured
189
+ ``catalog_withdrawal_policy`` permits it (default: never).
190
+
191
+ Semantics:
192
+
193
+ - The ``account_name -> provider_id`` mapping is always set.
194
+ - The supplied ``models`` are always added or updated; the
195
+ in-memory cache learns about new model/account support
196
+ regardless of the outcome category.
197
+ - When ``authoritative and allow_withdrawals`` are both true,
198
+ any account/model support the new response omits is removed
199
+ (the legacy "refresh is the source of truth" behavior).
200
+ - Otherwise prior support is preserved; the destructive
201
+ ``mark_account_models_unavailable`` step is skipped.
202
+
203
+ Returns an :class:`AccountCatalogUpdateResult` summarizing the
204
+ counts of added, updated, preserved, and withdrawn support
205
+ rows. The result is informational — the service layer uses it
206
+ to log/emit operational events.
207
+ """
142
208
  self._account_providers[account_name] = provider_id
143
209
  now = time.time()
144
- # A refresh response is authoritative for this account. Clear
145
- # prior support first so models withdrawn upstream stop being
146
- # exposed or routed for this account while other accounts'
147
- # support remains intact.
148
- self.mark_account_models_unavailable(account_name)
210
+ added = 0
211
+ updated = 0
212
+ preserved = 0
213
+ withdrawn = 0
214
+
215
+ # Destructive withdrawal path is only taken when both flags are
216
+ # set. Default behavior is non-destructive: catalog uncertainty
217
+ # must not silently de-pool a healthy account.
218
+ withdraw_destructive = authoritative and allow_withdrawals
219
+ if withdraw_destructive:
220
+ # Count the support rows the destructive step is about to
221
+ # remove so the result object carries the number of rows
222
+ # actually withdrawn, not just whether one was requested.
223
+ for accounts in self._account_support.values():
224
+ if account_name in accounts:
225
+ withdrawn += 1
226
+ self.mark_account_models_unavailable(account_name)
227
+ else:
228
+ # Tally preserved support for the result object before any
229
+ # additions below so the count reflects the snapshot of
230
+ # what the cache knew about this account.
231
+ for accounts in self._account_support.values():
232
+ if account_name in accounts:
233
+ preserved += 1
149
234
 
150
235
  # Drop any per-provider rows this account used to advertise
151
236
  # but the new response no longer includes. A row survives when
152
237
  # at least one other account on the same provider still
153
238
  # publishes it; otherwise it is removed so the in-memory cache
154
- # converges with the live catalog.
239
+ # converges with the live catalog. This step only runs in the
240
+ # destructive path — in the non-destructive path, missing
241
+ # (model_id, provider_id) rows survive because the prior
242
+ # account may simply have failed to refresh this cycle.
155
243
  new_keys: set[tuple[str, str]] = set()
156
244
  for model in models:
157
245
  new_keys.add((model["model_id"], provider_id))
158
246
  prior_keys = self._account_provider_keys.get(account_name, set())
159
- if prior_keys - new_keys:
247
+ if withdraw_destructive and prior_keys - new_keys:
160
248
  surviving: set[tuple[str, str]] = set()
161
249
  for other_acct, other_keys in self._account_provider_keys.items():
162
250
  if other_acct == account_name:
@@ -186,6 +274,8 @@ class ModelCatalogCache:
186
274
  "effective_limits": model.get("effective_limits", {}),
187
275
  }
188
276
  self._preserve_static_fields(provider_key, model_info)
277
+ if not withdraw_destructive:
278
+ self._preserve_resolved_protocol(provider_key, model_info)
189
279
 
190
280
  # Global entry: only materialize a global row if this
191
281
  # provider brings new information that the existing global
@@ -212,10 +302,24 @@ class ModelCatalogCache:
212
302
  global_info["last_seen_at"] = now
213
303
 
214
304
  existing_support = self._account_support.get(model_id, frozenset())
305
+ if account_name in existing_support:
306
+ updated += 1
307
+ else:
308
+ added += 1
215
309
  self._account_support[model_id] = existing_support | {account_name}
216
310
 
217
311
  self._last_refresh = now
218
312
  self._account_last_refresh[account_name] = now
313
+ return AccountCatalogUpdateResult(
314
+ account_name=account_name,
315
+ provider_id=provider_id,
316
+ authoritative=authoritative,
317
+ allow_withdrawals=allow_withdrawals,
318
+ added_support=added,
319
+ updated_support=updated,
320
+ preserved_support=preserved,
321
+ withdrawn_support=withdrawn,
322
+ )
219
323
 
220
324
  @staticmethod
221
325
  def _effective_limits_from_info(
@@ -247,9 +351,9 @@ class ModelCatalogCache:
247
351
 
248
352
  When a live row arrives for a model that already has a
249
353
  ``static_config``-sourced provider entry, explicit static
250
- protocol and capability fields win over a live row that did not
251
- bring its own resolution. The merge mutates *model_info* in
252
- place so callers can write the merged value into the cache.
354
+ protocol and capability fields win over a live row that did
355
+ not bring its own resolution. The merge mutates *model_info*
356
+ in place so callers can write the merged value into the cache.
253
357
  """
254
358
  existing = self._provider_models.get(provider_key)
255
359
  if existing is None:
@@ -281,6 +385,42 @@ class ModelCatalogCache:
281
385
  new_caps["supports_vision"] = existing_caps["supports_vision"]
282
386
  model_info["capabilities"] = new_caps
283
387
 
388
+ def _preserve_resolved_protocol(
389
+ self,
390
+ provider_key: tuple[str, str],
391
+ model_info: dict[str, Any],
392
+ ) -> None:
393
+ """Sibling-wins guard: never let a partial live update downgrade a
394
+ previously-resolved per-provider protocol to ``None``.
395
+
396
+ ``_provider_models`` is keyed by ``(model_id, provider_id)`` and
397
+ is shared by every account that lists that provider — e.g. all
398
+ three ``opencode-go-000X`` accounts share one row per model on
399
+ the ``opencode-go`` provider. When one account has a partial
400
+ refresh (transient upstream parse error, unresolved family
401
+ prefix, or a model whose protocol cannot be re-derived this
402
+ cycle), its ``model["protocol"]`` arrives as ``None``. Without
403
+ this guard, that single partial row would clobber the prior
404
+ resolved value for every sibling account, silently de-pooling
405
+ all of them from routing. The destructive ``update_from_account``
406
+ path (``authoritative=True and allow_withdrawals=True``)
407
+ intentionally skips this guard so an operator-initiated
408
+ withdrawal can still take effect.
409
+ """
410
+ existing = self._provider_models.get(provider_key)
411
+ if existing is None:
412
+ return
413
+ new_protocol = model_info.get("protocol")
414
+ new_protocol_source = model_info.get("protocol_source")
415
+ if new_protocol:
416
+ return
417
+ if not existing.get("protocol"):
418
+ return
419
+ if new_protocol_source in {"config", "static_config"}:
420
+ return
421
+ model_info["protocol"] = existing.get("protocol")
422
+ model_info["protocol_source"] = existing.get("protocol_source")
423
+
284
424
  def _visible_provider_ids(
285
425
  self,
286
426
  visible_accounts: AbstractSet[str],