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.
- {eggpool-0.4.1 → eggpool-0.4.2}/AGENTS.md +3 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/CHANGELOG.md +8 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/PKG-INFO +6 -2
- {eggpool-0.4.1 → eggpool-0.4.2}/README.md +5 -1
- {eggpool-0.4.1 → eggpool-0.4.2}/architecture/README.md +94 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/config.example.toml +12 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/pyproject.toml +1 -1
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/_share/config.example.toml +12 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/app.py +18 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/catalog/cache.py +152 -12
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/catalog/service.py +217 -11
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/cli_full.py +86 -1
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/routes.py +13 -1
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/static/dashboard.css +23 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/integrations/cline.py +3 -1
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/integrations/codex.py +6 -5
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/integrations/common.py +32 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/integrations/continue_dev.py +4 -8
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/integrations/goose.py +9 -4
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/integrations/openhands.py +10 -5
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/integrations/roo_code.py +3 -1
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/repository.py +38 -2
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/service.py +112 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/models/config.py +5 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/routing/router.py +116 -8
- {eggpool-0.4.1 → eggpool-0.4.2}/uv.lock +1 -1
- {eggpool-0.4.1 → eggpool-0.4.2}/.env.example +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/.github/workflows/ci.yml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/.github/workflows/release.yml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/.gitignore +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/LICENSE +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/config-examples/claude-code.env +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/config-examples/opencode.jsonc +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/deploy/eggpool-logrotate.conf +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/deploy/eggpool.service +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/deploy/env.example +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/docs/backup-restore.md +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/docs/deployment.md +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/docs/filesystem-layout.md +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/docs/firewall.md +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/docs/model-limits.md +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/docs/network-diagnostics.md +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/docs/providers.md +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/docs/proxy.md +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/docs/raspberry-pi.md +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/docs/transcoding.md +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/scripts/__init__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/scripts/check_database.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/scripts/install.sh +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/scripts/install_prompt.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/scripts/smoke_test.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/scripts/validate_routing.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/scripts/verify_upstream_auth.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/__init__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/__main__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/_share/.env.example +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/accounts/__init__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/accounts/registry.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/accounts/state.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/api/__init__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/api/backoff.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/api/chat_completions.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/api/errors.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/api/messages.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/api/model_info.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/api/models.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/api/network.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/api/proxy_request.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/api/runtime.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/api/stats.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/api/update.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/auth.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/background/__init__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/background/backup.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/background/cleanup.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/catalog/__init__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/catalog/catalog_resolvers.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/catalog/fetcher.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/catalog/limits.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/catalog/normalizer.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/catalog/pricing.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/catalog/pricing_aliases.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/catalog/pricing_resolver.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/catalog/protocols.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/cli.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/config.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/config_utils.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/constants.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/cost_recompute.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/__init__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/_resources.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/escape.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/render.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/static/chart.umd.min.js +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/static/dashboard.js +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/static/favicon.svg +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/theme.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Booberry.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Catppuccin Latte.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Catppuccin Macchiato.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Catppuccin Mocha.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Cyber Red.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Cyberpunk.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Dark Green.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Discord (80_ Saturation).toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Discord.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Dracula.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Ferra Light.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Flexor Dark.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Gruvbox.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Halcyon Dark.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/IntelliJ Light.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Kanagawa.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Macaw Dark.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Macaw Light.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Matrix.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Noctis Lilac.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Nord.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Nostromo Terminal.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/One Dark.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Oxocarbon.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Rose Pine Dawn.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Rose Pine Moon.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Rose Pine.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Solarized Dark.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Sonokai.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Tokyo Night Storm.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/VESPER.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/Zenburn.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/acton.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/bam.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/base16-atelier-forest-light.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/berlin.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/black but with important highlights.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/broc.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/cork.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/ferra.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/forest.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/lisbon.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/midnight.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/oslo.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/plum.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/portland.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/sunset.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/tofino.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/vanimo.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/dashboard/themes/vik.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/__init__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/connection.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/migrations.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/repositories.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/rollup_repository.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0001_initial.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0002_indexes.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0003_request_attempts.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0004_integration_hardening.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0005_price_microdollars.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0006_correct_price_microdollars.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0007_price_cache_rates.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0008_proxy_request_identity.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0009_model_protocol_source.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0010_health_probe.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0011_model_resolution_status.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0012_drop_reservations_estimated_microdollars.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0013_request_attempts_account_id_index.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0014_bandwidth_tracking.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0015_multi_provider.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0016_requests_provider_id.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0017_price_snapshots_provider_id.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0018_provider_pings.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0019_client_ip.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0020_performance_indexes.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0021_provider_model_metadata.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0022_dashboard_indexes.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0023_deprecated_model_placeholder.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0024_account_backoffs.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0025_stale_request_index.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0026_attempt_observability.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0027_routing_decisions.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0028_operational_events.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0029_latency_phases.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0030_model_pricing_aliases.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0031_price_snapshot_provenance.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0032_usage_rollups.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0033_request_provider_local_cost.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0034_transcoding_daily.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0035_routing_decision_score_components.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0036_model_info.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0037_model_info_failure_count.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/0038_model_info_phase5.sql +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/db/schema/checksums.json +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/deploy/__init__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/deploy_user.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/errors.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/fastcli.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/health/__init__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/health/backoff.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/health/circuit_breaker.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/health/health_manager.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/integrations/__init__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/integrations/aider.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/integrations/kilo.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/integrations/opencode.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/integrations/qwen_code.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/lifecycle/__init__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/lifecycle/backup.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/lifecycle/uninstall.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/logging.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/metrics/__init__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/metrics/buffer.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/__init__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/dedup.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/identity.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/scheduler.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/sources/__init__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/sources/artificial_analysis.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/sources/base.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/sources/huggingface.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/sources/openrouter.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/sources/provider_catalog.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/model_info/types.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/models/__init__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/models/api.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/models/database.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/models/domain.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/onboard.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/providers/__init__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/providers/_templates.toml +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/providers/auth.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/providers/client_pool.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/providers/connect.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/providers/contract.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/providers/dns_cache.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/providers/outbound.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/providers/pproxy_transport.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/proxy/__init__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/proxy/client.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/proxy/cost_reporting.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/proxy/sse_observer.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/proxy/usage.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/py.typed +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/quota/__init__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/quota/audit.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/quota/estimation.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/quota/reservation.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/quota/scorer.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/request/__init__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/request/attempt_finalizer.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/request/body.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/request/coordinator.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/request/finalizer.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/request/limits.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/retry/__init__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/retry/classification.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/routing/__init__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/routing/eligibility.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/routing/fairness.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/routing/provider.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/runtime.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/runtime_dispatch.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/runtime_metrics.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/runtime_paths.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/security/__init__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/security/redaction.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/stats/__init__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/stats/grouped_timeseries.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/stats/queries.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/stats/service.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/toml_edit.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/transcoder/__init__.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/transcoder/anthropic_to_openai.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/transcoder/context.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/transcoder/errors.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/transcoder/ids.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/transcoder/json_helpers.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/transcoder/openai_to_anthropic.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/transcoder/policy.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/transcoder/protocol.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/transcoder/static_headers.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/transcoder/streaming.py +0 -0
- {eggpool-0.4.1 → eggpool-0.4.2}/src/eggpool/transcoder/usage.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
251
|
-
bring its own resolution. The merge mutates *model_info*
|
|
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],
|