Thanks to visit codestin.com
Credit goes to github.com

Skip to content

fix(resilience): circuit breaker correctness, stale provider inventory, fast health rechecks#480

Merged
SantiagoDePolonia merged 6 commits into
mainfrom
fix/review-bugs
Jul 5, 2026
Merged

fix(resilience): circuit breaker correctness, stale provider inventory, fast health rechecks#480
SantiagoDePolonia merged 6 commits into
mainfrom
fix/review-bugs

Conversation

@SantiagoDePolonia

@SantiagoDePolonia SantiagoDePolonia commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Review of the circuit breaker feature and the provider-health machinery around it, prompted by a user report of a down provider showing "Last checked 20m ago" with no way to control the cadence. Two confirmed breaker bugs fixed, the registry's failure handling reworked, and the previously invisible/undocumented pieces surfaced.

Circuit breaker fixes (internal/llmclient)

  • Permanently wedged breaker: a local request-build error (or caller cancellation) during the half-open state consumed the single probe slot without ever returning it — every subsequent request got 503 circuit breaker is open until process restart. The probe slot is now released when a request ends without a provider verdict.
  • Client aborts counted as provider failures: canceled contexts during stream establishment charged RecordFailure, so a handful of impatient clients could open the circuit for a healthy provider. Caller cancellations are now neutral; client deadlines still count.
  • Dead code removed (Allow(), duplicate getBaseURL()), NewWithHTTPClient delegates to New, extractStatusCode uses errors.As.

Provider health rework (internal/providers, internal/virtualmodels)

  • Stale inventory instead of the wipe: a failed refresh keeps the provider's previous models, marked stale. Direct requests still resolve and fail at the provider with an honest 502/503 (manual failover rules can fire) instead of a misleading "model not found". New Catalog.ModelAvailable() keeps virtual-model load balancing skipping stale targets, and bare model IDs served by several providers prefer a healthy one — the same routing the wipe produced, without the data loss. The dashboard's previously unreachable "Degraded — previous inventory is still available" state is now what a down provider shows.
  • Parallel refresh sweep: provider fetches ran sequentially under one shared 30s budget, so one slow upstream starved every provider after it (starved providers were recorded failed and lost their models).
  • Fast recheck of failed providers: PROVIDER_RECHECK_INTERVAL (default 60s, 0 disables) re-probes only the providers whose last refresh failed, so outage recovery and the dashboard "Last checked" no longer wait for the next full refresh (CACHE_REFRESH_INTERVAL, default hourly).

Observability & dashboard

  • New gomodel_circuit_breaker_state{provider} gauge (0=closed, 1=half-open, 2=open) — previously breaker state was invisible everywhere.
  • Dashboard "Last checked" shows the most recent of model-fetch and availability-check timestamps.

Docs

resilience.mdx now documents what counts as a retryable vs breaker failure (429/4xx/cancellation exemptions), per-path retry behavior (streaming never retries; passthrough only when replay-safe), how to disable each layer, and a "circuit breaker vs dashboard provider health" section. CACHE_REFRESH_INTERVAL enters the user-facing configuration reference (it was only an .env.template comment).

User-visible impact

  • A provider outage now shows as Degraded with its models intact, is re-probed every minute by default, and recovers into rotation within ~a minute of coming back.
  • Load-balanced redirects keep routing around down providers exactly as before.
  • Streaming-heavy workloads can no longer trip a healthy provider's breaker through client disconnects.
  • New env vars: PROVIDER_RECHECK_INTERVAL (60); new metric: gomodel_circuit_breaker_state.

Testing

  • 12 new regression tests (breaker wedge + cancellation, carry-forward/recovery, stale LB skip, bare-ID duplicate preference, sweep starvation, recheck loop, gauge, hooks plumbing, dashboard timestamp). The two breaker bugs were reproduced as failing tests before the fix; the starvation test was verified to fail against the old sequential sweep.
  • Full go test ./... green, -race clean on all touched packages, all 400 dashboard JS tests pass.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added PROVIDER_RECHECK_INTERVAL cache setting (default 60s) to re-probe only failed providers.
    • Dashboard health now distinguishes degraded providers and updates “Last checked” using the most recent check.
    • Added Prometheus gomodel_circuit_breaker_state gauge for circuit-breaker visibility.
  • Bug Fixes
    • Failed provider refreshes now keep serving last-known models while marking inventory stale, preventing redirects/load balancing from selecting degraded targets.
    • Circuit breaker now correctly releases half-open probe slots after cancellations and build errors.
  • Documentation
    • Updated configuration, resilience, and Prometheus metrics docs to reflect the new behavior and settings.

SantiagoDePolonia and others added 4 commits July 5, 2026 00:47
- Release the half-open probe slot when a request ends without a breaker
  verdict (local build error or caller cancellation); previously the
  breaker stayed half-open with the slot consumed and rejected every
  request until process restart.
- Stop counting caller-side context cancellations as provider failures:
  client aborts during stream establishment could open the circuit for a
  healthy provider. Client deadlines still count.
- Export per-provider breaker state as the gomodel_circuit_breaker_state
  gauge (0=closed, 1=half-open, 2=open) via ResponseInfo.CircuitState.
- Simplifications: remove dead Allow() and duplicate getBaseURL(),
  delegate NewWithHTTPClient to New, use errors.As in extractStatusCode.

Co-Authored-By: Claude Fable 5 <[email protected]>
…echeck

- A provider whose model refresh fails keeps its previous inventory,
  marked stale, instead of losing it: direct requests reach the provider
  and fail with an honest 502/503 (failover rules can fire) instead of
  "model not found". New Catalog.ModelAvailable() lets virtual-model load
  balancing keep skipping stale targets, and bare model IDs served by
  several providers prefer a healthy one - the same routing the old
  inventory wipe produced, without the data loss.
- Fetch the refresh sweep concurrently: one slow upstream no longer
  starves later providers out of the shared context budget.
- Re-probe only the providers whose latest refresh failed on
  PROVIDER_RECHECK_INTERVAL (default 60s, 0 disables), so outage recovery
  and the dashboard "Last checked" no longer wait for the next full
  refresh (CACHE_REFRESH_INTERVAL, default hourly).

Co-Authored-By: Claude Fable 5 <[email protected]>
The provider status card preferred last_model_fetch_at even when an
availability probe ran more recently.

Co-Authored-By: Claude Fable 5 <[email protected]>
…r gauge

- What counts as a retryable failure vs a breaker failure (429/4xx/
  cancellation exemptions), per-path retry behavior, and how to disable
  each layer.
- New section separating the circuit breaker from dashboard provider
  health, including what happens while a provider is down and the
  CACHE_REFRESH_INTERVAL / PROVIDER_RECHECK_INTERVAL knobs (the former
  was previously undocumented outside .env.template).
- Document the gomodel_circuit_breaker_state gauge.

Co-Authored-By: Claude Fable 5 <[email protected]>
@mintlify

mintlify Bot commented Jul 4, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
gomodel 🟢 Ready View Preview Jul 4, 2026, 10:50 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2f874c2d-6c4c-48f3-8e58-144fdb17ba59

📥 Commits

Reviewing files that changed from the base of the PR and between 9b663e7 and 3695fbb.

📒 Files selected for processing (5)
  • docs/dev/prometheus-metrics.md
  • internal/admin/handler_providers.go
  • internal/admin/handler_providers_test.go
  • internal/providers/registry.go
  • internal/providers/registry_test.go

📝 Walkthrough

Walkthrough

Adds a provider recheck interval, stale-inventory tracking with availability-aware routing, circuit-breaker state reporting and probe release handling, a dashboard timestamp fix, and matching documentation updates.

Changes

Recheck interval, stale inventory, and observability

Layer / File(s) Summary
Recheck interval configuration
config/cache.go, config/config.go, .env.template, docs/advanced/configuration.mdx, CLAUDE.md
Adds RecheckInterval to ModelCacheConfig, defaults it to 60, and documents PROVIDER_RECHECK_INTERVAL alongside CACHE_REFRESH_INTERVAL.
Stale inventory tracking in model registry
internal/providers/provider_status.go, internal/providers/registry.go, internal/providers/registry_init.go, internal/providers/registry_provider_refresh.go
Adds stale-inventory state, ModelAvailable, failed-provider tracking, concurrent refresh fetching, stale carry-forward, and fresh-first ordering.
Background refresh recheck loop wiring
internal/providers/init.go, internal/providers/registry_init.go, internal/providers/registry_test.go
Passes the configured recheck interval into background refresh and rechecks failed providers on a separate ticker.
Catalog.ModelAvailable and virtual-model routing
internal/virtualmodels/types.go, internal/virtualmodels/service.go, internal/virtualmodels/snapshot.go, internal/virtualmodels/helpers_test.go, internal/virtualmodels/balancer_test.go, internal/admin/handler_virtualmodels_test.go, internal/server/handlers_test.go
Extends the catalog interface and switches redirect and balancing selection to skip stale targets.
Circuit breaker probe release and client refactor
internal/llmclient/circuit_breaker.go, internal/llmclient/client.go, internal/llmclient/client_test.go
Adds probe-slot release handling, includes circuit state in request-end hooks, refactors request completion paths, and updates tests.
Prometheus circuit breaker state gauge
internal/observability/metrics.go, internal/observability/metrics_test.go, docs/dev/prometheus-metrics.md
Adds CircuitBreakerState and exports it through request-end metrics and docs.
Dashboard last-checked timestamp fix
internal/admin/dashboard/static/js/modules/providers.js, internal/admin/dashboard/static/js/modules/providers.test.cjs
Compares both runtime timestamps and returns the newest for provider “Last checked”.
Resilience and breaker docs
docs/advanced/resilience.mdx, CLAUDE.md, docs/dev/prometheus-metrics.md
Expands the resilience, breaker, and metrics documentation for the new behavior.

Estimated code review effort: 4 (Complex) | ~75 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Registry
  participant Provider
  participant VirtualModelService
  participant Catalog

  Registry->>Provider: RefreshProviderModels()
  Provider-->>Registry: fetch error
  Registry->>Registry: markProviderInventoryStale(provider)
  Registry->>Registry: rebuild model map (fresh-first order)
  VirtualModelService->>Catalog: ModelAvailable(qualifiedTarget)
  Catalog-->>VirtualModelService: false (stale)
  VirtualModelService->>Catalog: ModelAvailable(nextTarget)
  Catalog-->>VirtualModelService: true
  VirtualModelService-->>VirtualModelService: route to healthy target
Loading
sequenceDiagram
  participant Client
  participant CircuitBreaker
  participant PrometheusHooks

  Client->>Client: buildRequest fails locally
  Client->>CircuitBreaker: finishRequestWithoutBreaker()
  CircuitBreaker->>CircuitBreaker: releaseProbe() sets halfOpenAllowed=true
  Client->>PrometheusHooks: OnRequestEnd(CircuitState)
  PrometheusHooks->>PrometheusHooks: set CircuitBreakerState gauge
Loading

Possibly related PRs

  • ENTERPILOT/GoModel#357: Touches provider refresh and availability routing in the same area of code as the stale-inventory and ModelAvailable changes.

Poem

A bunny hops through code so bright,
Rechecking providers through day and night.
Stale carrots skipped, fresh ones bloom,
Probe slots freed with gentle zoom.
Metrics glow and dashboards gleam —
Hop hop, it’s a tidy stream. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: circuit breaker fixes, stale provider inventory handling, and faster health rechecks.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/review-bugs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov-commenter

codecov-commenter commented Jul 4, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 96.05911% with 8 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/providers/registry.go 90.90% 3 Missing and 2 partials ⚠️
internal/providers/registry_init.go 95.52% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jul 4, 2026

Copy link
Copy Markdown

Confidence Score: 4/5

Mostly safe, with one contained provider-refresh bug to fix before merging.

The circuit breaker changes are localized and covered by regression tests. The remaining issue affects routing state during complete provider refresh outages.

internal/providers/registry_init.go

T-Rex T-Rex Logs

What T-Rex did

  • Static inspection of the target code and tests was performed, and a plan for an all-providers-fail repro was identified but not implemented or executed.
  • A reproducible resilience test run was executed using a foreground wrapper script, producing a transcript with exact commands, working directory, timestamps, and exit codes, and showing final package results OK under a cleared environment.

View all artifacts

T-Rex Ran code and verified through T-Rex

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant RefreshLoop as Background refresh/recheck
participant Registry as ModelRegistry
participant Provider as Provider ListModels
participant VM as Virtual model resolver
participant Metrics as Prometheus hooks

RefreshLoop->>Registry: Initialize() / RefreshProviderModels()
Registry->>Provider: ListModels() per provider (parallel sweep)
alt provider refresh succeeds
    Provider-->>Registry: model inventory
    Registry->>Registry: mark inventory fresh, rebuild global model map
else provider refresh fails with previous inventory
    Provider-->>Registry: error
    Registry->>Registry: carry previous models, mark inventory stale
end
VM->>Registry: ModelAvailable(target)
Registry-->>VM: false for stale providers, true for fresh providers
VM-->>VM: choose available redirect target
Metrics->>Metrics: observe gomodel_circuit_breaker_state on request completion
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant RefreshLoop as Background refresh/recheck
participant Registry as ModelRegistry
participant Provider as Provider ListModels
participant VM as Virtual model resolver
participant Metrics as Prometheus hooks

RefreshLoop->>Registry: Initialize() / RefreshProviderModels()
Registry->>Provider: ListModels() per provider (parallel sweep)
alt provider refresh succeeds
    Provider-->>Registry: model inventory
    Registry->>Registry: mark inventory fresh, rebuild global model map
else provider refresh fails with previous inventory
    Provider-->>Registry: error
    Registry->>Registry: carry previous models, mark inventory stale
end
VM->>Registry: ModelAvailable(target)
Registry-->>VM: false for stale providers, true for fresh providers
VM-->>VM: choose available redirect target
Metrics->>Metrics: observe gomodel_circuit_breaker_state on request completion
Loading

Comments Outside Diff (1)

  1. internal/providers/registry_init.go, line 46-49 (link)

    P1 Carry-forward is skipped
    When every provider fails during a refresh, this branch returns before applyFetchedInventory, so the stale carry-forward path never runs. With an existing inventory, old models stay marked fresh and ModelAvailable keeps load-balanced virtual models routing to down providers instead of skipping them.

    Context Used: CLAUDE.md (source)

Reviews (1): Last reviewed commit: "docs(resilience): document failure seman..." | Re-trigger Greptile

@SantiagoDePolonia

Copy link
Copy Markdown
Contributor Author

@coderabbitai continue

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

@SantiagoDePolonia Continuing the review now.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Review follow-up: a sweep in which every provider fails deliberately keeps
the previous inventory routable (marking everything stale would only turn
provider-level 502/503s into alias 404s, and would break aliased traffic
when only the control plane is down) - that branch is now documented and
pinned by a test.

The real gap it hid: after a total outage, a provider recovering via the
recheck loop left its still-down peers unmarked until the next full sweep,
so load-balanced redirects kept routing to them for up to an hour. Failed
per-provider probes (recheck loop, request-time refresh, availability
checks) now mark the provider's carried inventory stale as soon as at
least one healthy alternative exists, retiring it from load balancing
within one recheck interval.

Co-Authored-By: Claude Fable 5 <[email protected]>
@SantiagoDePolonia

Copy link
Copy Markdown
Contributor Author

Re: P1 "Carry-forward is skipped" (registry_init.go early return) — addressed in 9b663e7, though not by marking stale on the total-failure sweep, which would make things worse:

  • If every provider fails, there is no healthy target left. Marking all inventory stale would empty supportedTargets for every redirect, and resolution falls through to the literal alias name → clients get 404 "model not found" instead of the provider-level 502/503 they get by routing through. When nothing is healthy, routing to a down provider is the more honest degradation (and lets breaker fail-fast + failover rules work).
  • A whole-sweep failure can also be control-plane-only (gateway can't reach any /models endpoint while inference works). Marking everything stale would break all aliased traffic on a false positive. This early return predates the stale mechanism for exactly this reason; it's now documented in-code and pinned by TestInitialize_TotalRefreshFailureKeepsRouting.

The real gap behind the finding was the recovery window: after a total outage, a provider recovering via the 60s recheck left its still-down peers unmarked (fresh) until the next full sweep — LB kept routing to them for up to an hour. Fixed: failed per-provider probes (recheck loop, request-time refresh, availability checks) now mark the provider's carried inventory stale as soon as at least one healthy alternative exists, retiring it from load balancing within ~one recheck interval. Covered by TestRefreshProviderModels_TotalOutageRecoveryRetiresStillDownPeer, ..._FailureMarksStaleWhenAlternativeHealthy, and ..._AvailabilityFailureMarksStale.

@SantiagoDePolonia

SantiagoDePolonia commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai continue

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

@SantiagoDePolonia Understood, thanks for the detailed rationale — that clarifies why the total-refresh-failure case intentionally routes through the down provider (honest 502/503) rather than marking everything stale (which would 404 via a false negative resolution, and could be a false positive on control-plane-only outages). Good to see the actual recovery-window gap addressed via the "mark stale once a healthy alternative exists" logic, with dedicated coverage in TestRefreshProviderModels_TotalOutageRecoveryRetiresStillDownPeer, TestRefreshProviderModels_FailureMarksStaleWhenAlternativeHealthy, and TestRefreshProviderModels_AvailabilityFailureMarksStale.

Continuing the review now.


✏️ Learnings added
Learnt from: SantiagoDePolonia
URL: https://github.com/ENTERPILOT/GoModel/pull/480

Timestamp: 2026-07-04T23:50:26.563Z
Learning: In ENTERPILOT/GoModel, `internal/providers/registry_init.go` intentionally does NOT mark all provider inventory stale when a total refresh sweep fails (all providers fail). Rationale: (1) if no healthy target remains, marking all inventory stale would empty `supportedTargets` for every redirect, causing resolution to fall through to the literal alias name and return a 404 "model not found" to clients instead of the more honest provider-level 502/503 obtained by routing through the down provider (this also lets circuit-breaker fail-fast and failover rules work); (2) a whole-sweep failure can be control-plane-only (e.g., gateway can't reach any `/models` endpoint while inference still works), so marking everything stale on this basis risks a false positive that breaks all aliased traffic. This behavior predates the stale-inventory mechanism and is now documented in-code, pinned by `TestInitialize_TotalRefreshFailureKeepsRouting`. Instead, the recovery-window gap (still-down peers staying "fresh" for up to an hour after a total outage due to a recovering provider's 60s recheck) is fixed by marking a provider's carried inventory stale as soon as at least one healthy alternative exists, across recheck-loop, request-time refresh, and availability-check failure paths (see `TestRefreshProviderModels_TotalOutageRecoveryRetiresStillDownPeer`, `TestRefreshProviderModels_FailureMarksStaleWhenAlternativeHealthy`, `TestRefreshProviderModels_AvailabilityFailureMarksStale`).

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/dev/prometheus-metrics.md (1)

44-67: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Hook API snippet is stale — missing the new CircuitState field.

This PR adds CircuitState string to ResponseInfo (used by the new gomodel_circuit_breaker_state gauge documented just below), but the ResponseInfo struct shown here still omits it. Readers following this doc to understand the hook payload will miss the field that powers the new metric.

📝 Proposed fix
 type ResponseInfo struct {
     Provider   string
     Model      string
     Endpoint   string
     StatusCode int           // 0 if network error
     Duration   time.Duration
     Stream     bool
     Error      error         // nil on success
+    // CircuitState is "closed"/"half-open"/"open"; empty when the breaker
+    // is disabled.
+    CircuitState string
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/dev/prometheus-metrics.md` around lines 44 - 67, Update the hook API
example so the ResponseInfo struct includes the new CircuitState string field
alongside StatusCode, Duration, Stream, and Error. Use the ResponseInfo and
Hooks symbols in the snippet to locate the stale doc block, and keep the example
aligned with the new gomodel_circuit_breaker_state metric by showing the full
current payload shape.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/providers/registry_init.go`:
- Around line 295-316: The empty-list refresh is being treated as authoritative
inventory because the presence check in the registry merge logic only tests for
a key, not whether the fetched provider model map is empty. Update this merge
path around fetched.modelsByProvider, r.modelsByProvider, and r.providerRuntime
so that providers with an empty fetched model list are carried forward from the
previous snapshot and marked inventoryStale instead of replacing the old models.
Keep rebuildGlobalModelMap using the carried-forward inventory so Supports() and
ModelAvailable() continue to reflect provider-level failure handling.

In `@internal/providers/registry_provider_refresh.go`:
- Around line 74-103: The refresh path in ModelRegistry is treating empty-list
results as successful inventory updates, which clears prior models and resets
staleness incorrectly. Update markFailedRefreshProvidersStale and
applyFetchedProviderInventory so providers with a non-empty lastModelFetchError
but no returned models are treated like failed refreshes: keep the existing
modelsByProvider entry, do not overwrite it with an empty map, and do not clear
inventoryStale for that provider. Use the existing symbols
fetched.modelsByProvider, fetched.runtimeUpdates, and
markProviderInventoryStale/applyFetchedProviderInventory to preserve stale
carry-forward behavior.

In `@internal/providers/registry.go`:
- Around line 936-1000: `markProviderInventoryStale` and `FailedProviderNames`
only look at `lastModelFetchError`, so providers with availability probe
failures can still be treated as healthy and skipped by the fast recheck loop.
Update the health checks in `ModelRegistry.markProviderInventoryStale` and
`ModelRegistry.FailedProviderNames` to also consider `lastAvailabilityError`
alongside `lastModelFetchError`, using the existing `providerRuntime` state for
each provider. Ensure the healthy-alternative gate and the returned
failed-provider list both include providers with either kind of failure.

---

Outside diff comments:
In `@docs/dev/prometheus-metrics.md`:
- Around line 44-67: Update the hook API example so the ResponseInfo struct
includes the new CircuitState string field alongside StatusCode, Duration,
Stream, and Error. Use the ResponseInfo and Hooks symbols in the snippet to
locate the stale doc block, and keep the example aligned with the new
gomodel_circuit_breaker_state metric by showing the full current payload shape.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: fec833b8-6ff2-44ce-a3ca-ff9d35a462bd

📥 Commits

Reviewing files that changed from the base of the PR and between e864064 and 9b663e7.

📒 Files selected for processing (27)
  • .env.template
  • CLAUDE.md
  • config/cache.go
  • config/config.go
  • docs/advanced/configuration.mdx
  • docs/advanced/resilience.mdx
  • docs/dev/prometheus-metrics.md
  • internal/admin/dashboard/static/js/modules/providers.js
  • internal/admin/dashboard/static/js/modules/providers.test.cjs
  • internal/admin/handler_virtualmodels_test.go
  • internal/llmclient/circuit_breaker.go
  • internal/llmclient/client.go
  • internal/llmclient/client_test.go
  • internal/observability/metrics.go
  • internal/observability/metrics_test.go
  • internal/providers/init.go
  • internal/providers/provider_status.go
  • internal/providers/registry.go
  • internal/providers/registry_init.go
  • internal/providers/registry_provider_refresh.go
  • internal/providers/registry_test.go
  • internal/server/handlers_test.go
  • internal/virtualmodels/balancer_test.go
  • internal/virtualmodels/helpers_test.go
  • internal/virtualmodels/service.go
  • internal/virtualmodels/snapshot.go
  • internal/virtualmodels/types.go

Comment on lines +295 to +316
stale := make(map[string]bool, len(fetched.runtimeUpdates))
carriedForward := 0
for name := range fetched.runtimeUpdates {
if _, ok := fetched.modelsByProvider[name]; ok {
continue // this sweep produced authoritative inventory
}
previous := r.modelsByProvider[name]
if len(previous) == 0 {
continue // nothing to carry forward
}
fetched.modelsByProvider[name] = previous
stale[name] = true
carriedForward++
}
r.modelsByProvider = fetched.modelsByProvider
r.applyProviderRuntimeUpdatesLocked(fetched.runtimeUpdates)
for name := range fetched.runtimeUpdates {
state := r.providerRuntime[name]
state.inventoryStale = stale[name]
r.providerRuntime[name] = state
}
r.models = rebuildGlobalModelMap(r.modelsByProvider, r.freshFirstProviderOrderLocked())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | 🏗️ Heavy lift

Empty-list refresh silently wipes inventory instead of carrying it forward stale.

fetchAllProviderModels's "provider returned empty model list" branch (Lines 206-220) still inserts an empty map into out.modelsByProvider[providerName], even though it records lastModelFetchError and is surfaced via FailedProviderNames(). The ok check here only tests presence, not content, so this empty-list "failure" is treated as authoritative fresh inventory: the provider's previous models are replaced by the empty map and inventoryStale is left false.

Net effect: the provider's models disappear from r.models/r.modelsByProvider entirely, so Supports()/ModelAvailable() both return false — direct requests now hit a "model not found" 404 instead of the provider-level 502/503 this PR set out to preserve.

The same bug also affects RefreshProviderModels's path in registry_provider_refresh.go (see comment there).

🐛 Proposed fix
 	stale := make(map[string]bool, len(fetched.runtimeUpdates))
 	carriedForward := 0
 	for name := range fetched.runtimeUpdates {
-		if _, ok := fetched.modelsByProvider[name]; ok {
+		if providerModels, ok := fetched.modelsByProvider[name]; ok && len(providerModels) > 0 {
 			continue // this sweep produced authoritative inventory
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
stale := make(map[string]bool, len(fetched.runtimeUpdates))
carriedForward := 0
for name := range fetched.runtimeUpdates {
if _, ok := fetched.modelsByProvider[name]; ok {
continue // this sweep produced authoritative inventory
}
previous := r.modelsByProvider[name]
if len(previous) == 0 {
continue // nothing to carry forward
}
fetched.modelsByProvider[name] = previous
stale[name] = true
carriedForward++
}
r.modelsByProvider = fetched.modelsByProvider
r.applyProviderRuntimeUpdatesLocked(fetched.runtimeUpdates)
for name := range fetched.runtimeUpdates {
state := r.providerRuntime[name]
state.inventoryStale = stale[name]
r.providerRuntime[name] = state
}
r.models = rebuildGlobalModelMap(r.modelsByProvider, r.freshFirstProviderOrderLocked())
stale := make(map[string]bool, len(fetched.runtimeUpdates))
carriedForward := 0
for name := range fetched.runtimeUpdates {
if providerModels, ok := fetched.modelsByProvider[name]; ok && len(providerModels) > 0 {
continue // this sweep produced authoritative inventory
}
previous := r.modelsByProvider[name]
if len(previous) == 0 {
continue // nothing to carry forward
}
fetched.modelsByProvider[name] = previous
stale[name] = true
carriedForward++
}
r.modelsByProvider = fetched.modelsByProvider
r.applyProviderRuntimeUpdatesLocked(fetched.runtimeUpdates)
for name := range fetched.runtimeUpdates {
state := r.providerRuntime[name]
state.inventoryStale = stale[name]
r.providerRuntime[name] = state
}
r.models = rebuildGlobalModelMap(r.modelsByProvider, r.freshFirstProviderOrderLocked())
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/providers/registry_init.go` around lines 295 - 316, The empty-list
refresh is being treated as authoritative inventory because the presence check
in the registry merge logic only tests for a key, not whether the fetched
provider model map is empty. Update this merge path around
fetched.modelsByProvider, r.modelsByProvider, and r.providerRuntime so that
providers with an empty fetched model list are carried forward from the previous
snapshot and marked inventoryStale instead of replacing the old models. Keep
rebuildGlobalModelMap using the carried-forward inventory so Supports() and
ModelAvailable() continue to reflect provider-level failure handling.

Comment on lines +74 to +103
r.markFailedRefreshProvidersStale(fetched)
return 0, core.NewProviderError(providerSelector, http.StatusServiceUnavailable, "failed to refresh provider models", fetchedProviderRefreshError(fetched))
}
r.applyFetchedProviderInventory(providerTypes, fetched)
r.markFailedRefreshProvidersStale(fetched)
return 0, core.NewProviderError(providerSelector, http.StatusServiceUnavailable, "provider returned no models", nil)
}

r.applyFetchedProviderInventory(providerTypes, fetched)
r.markFailedRefreshProvidersStale(fetched)
return fetched.totalModels, nil
}

// markFailedRefreshProvidersStale marks the carried inventory of providers
// whose live probe failed this refresh, so the recheck loop retires a down
// provider from load balancing as soon as a healthy alternative exists —
// instead of waiting for the next full sweep. Providers that produced (or
// fell back to) an authoritative inventory this refresh are left alone.
func (r *ModelRegistry) markFailedRefreshProvidersStale(fetched fetchedInventory) {
for providerName, state := range fetched.runtimeUpdates {
if strings.TrimSpace(state.lastModelFetchError) == "" {
continue
}
if _, ok := fetched.modelsByProvider[providerName]; ok {
continue
}
r.markProviderInventoryStale(providerName)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | 🏗️ Heavy lift

Same empty-list carry-forward bug as registry_init.go's applyFetchedInventory, but more aggressive here.

markFailedRefreshProvidersStale (Line 97) uses the same presence-only ok check, so a provider whose refresh returned an empty list (a failure per lastModelFetchError) is skipped and never marked stale.

Worse, applyFetchedProviderInventory (Lines 196-202) unconditionally overwrites r.modelsByProvider[providerName] with the (possibly empty) providerModels map and resets inventoryStale = false for every provider present in fetched.modelsByProvider — unlike genuine error/nil-response failures, which never populate that map key and thus implicitly preserve the prior inventory. An empty-list "failure" therefore actively wipes previously known models and marks the provider fresh, defeating the "carry forward stale inventory" contract this PR establishes.

🐛 Proposed fix
 func (r *ModelRegistry) applyFetchedProviderInventory(providerTypes map[core.Provider]string, fetched fetchedInventory) {
 	metadataStats := r.enrichFetchedProviderModelMaps(providerTypes, fetched.modelsByProvider)

 	r.mu.Lock()
 	for providerName, providerModels := range fetched.modelsByProvider {
+		if len(providerModels) == 0 {
+			continue // no authoritative inventory; leave prior data + let markFailedRefreshProvidersStale flag it
+		}
 		r.modelsByProvider[providerName] = providerModels
 		// A refresh that produced inventory is authoritative again.
 		state := r.providerRuntime[providerName]
 		state.inventoryStale = false
 		r.providerRuntime[providerName] = state
 	}
 	r.applyProviderRuntimeUpdatesLocked(fetched.runtimeUpdates)
 	r.models = rebuildGlobalModelMap(r.modelsByProvider, r.freshFirstProviderOrderLocked())
 func (r *ModelRegistry) markFailedRefreshProvidersStale(fetched fetchedInventory) {
 	for providerName, state := range fetched.runtimeUpdates {
 		if strings.TrimSpace(state.lastModelFetchError) == "" {
 			continue
 		}
-		if _, ok := fetched.modelsByProvider[providerName]; ok {
+		if providerModels, ok := fetched.modelsByProvider[providerName]; ok && len(providerModels) > 0 {
 			continue
 		}
 		r.markProviderInventoryStale(providerName)
 	}
 }

Also applies to: 192-206

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/providers/registry_provider_refresh.go` around lines 74 - 103, The
refresh path in ModelRegistry is treating empty-list results as successful
inventory updates, which clears prior models and resets staleness incorrectly.
Update markFailedRefreshProvidersStale and applyFetchedProviderInventory so
providers with a non-empty lastModelFetchError but no returned models are
treated like failed refreshes: keep the existing modelsByProvider entry, do not
overwrite it with an empty map, and do not clear inventoryStale for that
provider. Use the existing symbols fetched.modelsByProvider,
fetched.runtimeUpdates, and
markProviderInventoryStale/applyFetchedProviderInventory to preserve stale
carry-forward behavior.

Comment thread internal/providers/registry.go
…echecks

Review follow-up. A request-time refresh can fail the availability gate
(which marks the inventory stale) without ever attempting a model fetch,
leaving lastModelFetchError empty. FailedProviderNames skipped such
providers, so the recheck loop never re-probed them and they stayed
retired from load balancing until the next full sweep - even after
recovering. The healthy-alternative gate had the same blind spot and
could count an availability-failing provider as the alternative that
justifies retiring a peer.

Both checks now consider lastAvailabilityError alongside
lastModelFetchError, and the dashboard classifier reports Degraded
(instead of Healthy) for a provider whose inventory is stale from an
availability-only failure.

Also documents the ResponseInfo.CircuitState field in the hook API
snippet of the prometheus-metrics doc.

Co-Authored-By: Claude Fable 5 <[email protected]>
@SantiagoDePolonia SantiagoDePolonia merged commit 72246d5 into main Jul 5, 2026
20 checks passed
SantiagoDePolonia added a commit that referenced this pull request Jul 6, 2026
….yaml

PR #480 added the fast provider recheck loop and documented
PROVIDER_RECHECK_INTERVAL in .env.template and the docs, but the YAML
spelling was missing from config.example.yaml — the file users copy to
configure via YAML. Add it next to refresh_interval, and mention the
YAML key alongside the env var in the resilience docs.

Refs #462

Co-Authored-By: Claude Fable 5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants