Skip to content

fix: make global model listing best effort - #4165

Open
lugnicca wants to merge 3 commits into
maximhq:mainfrom
lugnicca:fix-list-models-best-effort
Open

fix: make global model listing best effort#4165
lugnicca wants to merge 3 commits into
maximhq:mainfrom
lugnicca:fix-list-models-best-effort

Conversation

@lugnicca

@lugnicca lugnicca commented Jun 8, 2026

Copy link
Copy Markdown

Summary

Make global ListAllModels() best-effort and bounded by each provider's existing network_config.default_request_timeout_in_seconds instead of waiting for every provider goroutine to finish before returning any models.

This prevents one slow or broken OpenAI-compatible custom provider from making GET /v1/models unusable for clients that rely on the global model list, such as OpenAI-compatible model pickers.

What changed

  • Collect global list-model results as providers finish and return partial successful results when another provider exceeds its configured request timeout.
  • Preserve provider/key failure status where available, and synthesize provider-level failure status for provider timeouts or nil responses.
  • Return an error only when no provider yields any models and at least one provider produced a real error.
  • Add OpenAI-compatible fallback behavior: if upstream /v1/models fails but the key has explicit configured models[] / aliases, return those configured models for filtered requests.
  • Keep Unfiltered semantics strict: unfiltered requests still require upstream inventory and do not synthesize configured allowlist fallback.
  • Fix alias backfill for wildcard model allowlists when upstream listing is unavailable or omits configured aliases.

Reproduction

Before this change, ListAllModels() launched provider list calls concurrently but then waited on wg.Wait(). A slow/down custom OpenAI-compatible provider could block the entire global /v1/models response, even when other providers had already returned models.

The new regression test configures two custom OpenAI-compatible providers backed by local test servers: one responds immediately, while the other blocks beyond its configured one-second timeout. ListAllModels() now returns the fast provider model without waiting for the slow provider to complete.

Tests

cd core && go test . -count=1
cd core && go test ./providers/openai -count=1
cd core && go test ./providers/utils -count=1
git diff --check HEAD

All commands pass locally.

Notes

No production config or secrets were used. The live-style validation is covered with local httptest custom OpenAI-compatible providers, including a simulated slow /v1/models endpoint.

Summary by CodeRabbit

  • Bug Fixes & Improvements
    • Model listing now completes faster and doesn't wait for slow providers to respond
    • Added fallback model support when providers are unavailable
    • Enhanced error resilience and status reporting when collecting models from multiple sources

@coderabbitai

coderabbitai Bot commented Jun 8, 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5755d5b2-ad01-4f06-b0df-8c91e1a68236

📥 Commits

Reviewing files that changed from the base of the PR and between 2409c05 and f444419.

📒 Files selected for processing (4)
  • core/providers/openai/list_models_fallback_test.go
  • core/providers/openai/openai.go
  • core/providers/utils/models.go
  • core/providers/utils/utils.go

📝 Walkthrough

Walkthrough

Refactors ListAllModels to use per-provider timeout contexts and watchdog timers, preserves provider KeyStatuses, adds OpenAI configured-model fallback on list-models failures, and updates backfill gating for empty restricted allowlists. Tests exercise timeout and fallback scenarios.

Changes

Timeout Resilience and Provider Fallback

Layer / File(s) Summary
ListAllModels timeout and concurrency architecture
core/bifrost.go
Switches from sync.WaitGroup to channel-based coordination with per-provider timeout contexts and dedicated watchdog timers. Provider workers paginate under per-provider timeouts, conditionally append KeyStatuses, handle nil responses with context-aware errors, and results aggregation tracks seenProviders and post-processes missing providers as timeouts.
Testing timeout and concurrency behavior
core/bifrost_test.go
Adds net/http, net/http/httptest, and slices imports, a MockAccount.SetProviderConfig helper, and tests: TestListAllModels_ReturnsFastProviderWhenAnotherProviderIsSlow and TestListAllModels_AllowsEmptyProviderInventory to validate fast-path completion and empty inventories.
OpenAI provider fallback mechanism
core/providers/openai/openai.go, core/providers/openai/list_models_fallback_test.go
Adds configuredListModelsFallback (no-op for unfiltered) and invokes it on request errors, non-OK HTTP responses, and response-handling failures in ListModelsByKey. Fallback responses include measured latency and, on error paths, provider response headers. Tests cover configured-model fallbacks, wildcard allowlist, empty allowlist with aliases, and that unfiltered requests bypass fallback.
Backfill adjustment for empty restricted allowlists
core/providers/utils/models.go
Updates BackfillModels gating so IsRestricted() && !IsEmpty() is required for the explicit-allowlist backfill path; empty restricted allowlists now fall through to alias-based backfilling.
Preserve KeyStatuses in aggregation utils
core/providers/utils/utils.go
When provider responses include Response.KeyStatuses, those statuses are appended instead of being overwritten; keyless success no longer overwrites resp.KeyStatuses if it already exists.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Suggested reviewers

  • danpiths

Poem

🐰 A rabbit taps the code with glee,

timers hum and channels flee,
slow friends wait, fast ones play —
fallbacks keep the night at bay,
resilience stitched in lines of key.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix: make global model listing best-effort' accurately summarizes the main change: converting ListAllModels() to a best-effort approach that returns partial results rather than waiting for all providers.
Description check ✅ Passed The PR description is well-structured with Summary, Changes, Reproduction, and Tests sections. It clearly explains the problem, solution, and includes testing commands. However, the template's required sections like 'Type of change', 'Affected areas', and 'Checklist' are not explicitly addressed.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


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 and usage tips.

@CLAassistant

CLAassistant commented Jun 8, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@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

🤖 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 `@core/bifrost.go`:
- Around line 636-643: The current branch treats a provider that returns an
empty models slice and no keyStatuses/error as a synthetic failure by setting
result.err = listModelsNilResponseError, which incorrectly marks successful
empty inventories as errors; remove or change this logic so that when
result.models is empty and result.keyStatuses is empty but result.err == nil you
do not set result.err nor populate keyStatuses from listModelsNilResponseError
(so functions like ListAllModels won't treat empty-but-successful responses as
failures), i.e. only set listModelsNilResponseError when there is a real failure
reported by the provider or an explicit nil-response condition proven to be an
error. Ensure references to result.models, result.keyStatuses and
listModelsNilResponseError are adjusted accordingly.
- Around line 581-593: The timeout branch can race with the worker finishing and
enqueue a synthetic timeout that overwrites real provider results; inside the
timer.C case (where listModelsProviderTimeoutError, providerResult, providerKey
and results are used) guard the timeout send by doing a non-blocking select on
done first (if <-done then skip sending the timeout), and ensure you Stop and
drain the timer to avoid races; this preserves real worker-produced
providerResult when done and only sends the synthetic timeout if the worker
truly hasn't finished.

In `@core/providers/openai/list_models_fallback_test.go`:
- Around line 14-117: Add a new unit test that simulates the upstream returning
503 and verifies that an empty but restricted allowlist falls through to
alias-based backfilling: create a httptest server that returns
ServiceUnavailable, call ListModelsByKey with Models set to an empty
schemas.WhiteList{} (not wildcard), include a Key.Aliases map (e.g.
"desktop-picker-name":"actual-local-model"), use filtered=false and a local
provider (e.g. schemas.ModelProvider("local-openai")), and assert the response
contains one model with ID "local-openai/desktop-picker-name" and Alias
"actual-local-model"; name it e.g.
TestListModelsByKey_EmptyRestrictedAllowlistFallsBackToAliases and model it
after the existing fallback tests using ListModelsByKey to locate behavior in
models.go.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ed9ca7d3-f741-4ba0-ac7e-4329aebbe751

📥 Commits

Reviewing files that changed from the base of the PR and between c0d914c and c2ac56b.

📒 Files selected for processing (5)
  • core/bifrost.go
  • core/bifrost_test.go
  • core/providers/openai/list_models_fallback_test.go
  • core/providers/openai/openai.go
  • core/providers/utils/models.go

Comment thread core/bifrost.go
Comment on lines +581 to +593
select {
case <-timer.C:
timeoutErr := listModelsProviderTimeoutError(providerKey, timeout)
results <- providerResult{
provider: providerKey,
keyStatuses: []schemas.KeyStatus{{
Provider: providerKey,
Status: schemas.KeyStatusListModelsFailed,
Error: timeoutErr,
}},
err: timeoutErr,
}
case <-done:

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Timer race can overwrite concrete provider failure metadata at timeout boundary.

At Line 581, if timer.C and done become ready together, the timer branch can win and enqueue a synthetic timeout even though the worker finished. Because results are deduped by first-seen provider, concrete key/provider failure details can be dropped nondeterministically.

Suggested fix
 		select {
 		case <-timer.C:
+			select {
+			case <-done:
+				return
+			default:
+			}
 			timeoutErr := listModelsProviderTimeoutError(providerKey, timeout)
 			results <- providerResult{
 				provider: providerKey,
🤖 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 `@core/bifrost.go` around lines 581 - 593, The timeout branch can race with the
worker finishing and enqueue a synthetic timeout that overwrites real provider
results; inside the timer.C case (where listModelsProviderTimeoutError,
providerResult, providerKey and results are used) guard the timeout send by
doing a non-blocking select on done first (if <-done then skip sending the
timeout), and ensure you Stop and drain the timer to avoid races; this preserves
real worker-produced providerResult when done and only sends the synthetic
timeout if the worker truly hasn't finished.

Comment thread core/bifrost.go Outdated
Comment on lines +636 to +643
if len(result.models) == 0 && len(result.keyStatuses) == 0 && result.err == nil {
result.err = listModelsNilResponseError(result.provider)
result.keyStatuses = []schemas.KeyStatus{{
Provider: result.provider,
Status: schemas.KeyStatusListModelsFailed,
Error: result.err,
}}
}

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not convert valid empty provider inventories into synthetic failures.

At Line 636, this branch turns (models == 0 && keyStatuses == 0 && err == nil) into listModelsNilResponseError. That path is reachable when a provider successfully returns an empty model list, which then incorrectly sets firstError and can make ListAllModels fail despite no real provider error.

Suggested fix
-		if len(result.models) == 0 && len(result.keyStatuses) == 0 && result.err == nil {
-			result.err = listModelsNilResponseError(result.provider)
-			result.keyStatuses = []schemas.KeyStatus{{
-				Provider: result.provider,
-				Status:   schemas.KeyStatusListModelsFailed,
-				Error:    result.err,
-			}}
-		}
📝 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
if len(result.models) == 0 && len(result.keyStatuses) == 0 && result.err == nil {
result.err = listModelsNilResponseError(result.provider)
result.keyStatuses = []schemas.KeyStatus{{
Provider: result.provider,
Status: schemas.KeyStatusListModelsFailed,
Error: result.err,
}}
}
🤖 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 `@core/bifrost.go` around lines 636 - 643, The current branch treats a provider
that returns an empty models slice and no keyStatuses/error as a synthetic
failure by setting result.err = listModelsNilResponseError, which incorrectly
marks successful empty inventories as errors; remove or change this logic so
that when result.models is empty and result.keyStatuses is empty but result.err
== nil you do not set result.err nor populate keyStatuses from
listModelsNilResponseError (so functions like ListAllModels won't treat
empty-but-successful responses as failures), i.e. only set
listModelsNilResponseError when there is a real failure reported by the provider
or an explicit nil-response condition proven to be an error. Ensure references
to result.models, result.keyStatuses and listModelsNilResponseError are adjusted
accordingly.

Comment on lines +14 to +117
func TestListModelsByKey_FallsBackToConfiguredModelsOnUpstreamError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "upstream unavailable", http.StatusServiceUnavailable)
}))
defer server.Close()

ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
resp, err := ListModelsByKey(
ctx,
&fasthttp.Client{ReadTimeout: time.Second, WriteTimeout: time.Second},
server.URL,
schemas.Key{
ID: "local-key",
Models: schemas.WhiteList{"hermes-qwen"},
Aliases: schemas.KeyAliases{
"hermes-qwen": "Qwen/Qwen3-32B",
},
},
false,
nil,
schemas.ModelProvider("bao-qwen"),
false,
false,
)

if err != nil {
t.Fatalf("ListModelsByKey returned error: %v", err)
}
if resp == nil || len(resp.Data) != 1 {
t.Fatalf("expected one fallback model, got %#v", resp)
}
if resp.Data[0].ID != "bao-qwen/hermes-qwen" {
t.Fatalf("expected configured model id, got %q", resp.Data[0].ID)
}
if resp.Data[0].Alias == nil || *resp.Data[0].Alias != "Qwen/Qwen3-32B" {
t.Fatalf("expected alias to preserve upstream model id, got %#v", resp.Data[0].Alias)
}
}

func TestListModelsByKey_FallsBackToConfiguredAliasesWithWildcardModels(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "upstream unavailable", http.StatusServiceUnavailable)
}))
defer server.Close()

ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
resp, err := ListModelsByKey(
ctx,
&fasthttp.Client{ReadTimeout: time.Second, WriteTimeout: time.Second},
server.URL,
schemas.Key{
ID: "local-key",
Models: schemas.WhiteList{"*"},
Aliases: schemas.KeyAliases{
"desktop-picker-name": "actual-local-model",
},
},
false,
nil,
schemas.ModelProvider("local-openai"),
false,
false,
)

if err != nil {
t.Fatalf("ListModelsByKey returned error: %v", err)
}
if resp == nil || len(resp.Data) != 1 {
t.Fatalf("expected one alias fallback model, got %#v", resp)
}
if resp.Data[0].ID != "local-openai/desktop-picker-name" {
t.Fatalf("expected alias model id, got %q", resp.Data[0].ID)
}
if resp.Data[0].Alias == nil || *resp.Data[0].Alias != "actual-local-model" {
t.Fatalf("expected alias target, got %#v", resp.Data[0].Alias)
}
}

func TestListModelsByKey_UnfilteredDoesNotUseConfiguredFallback(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "upstream unavailable", http.StatusServiceUnavailable)
}))
defer server.Close()

ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
resp, err := ListModelsByKey(
ctx,
&fasthttp.Client{ReadTimeout: time.Second, WriteTimeout: time.Second},
server.URL,
schemas.Key{
ID: "local-key",
Models: schemas.WhiteList{"hermes-qwen"},
},
true,
nil,
schemas.ModelProvider("bao-qwen"),
false,
false,
)

if err == nil {
t.Fatalf("expected upstream error for unfiltered request, got response %#v", resp)
}
}

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.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Consider adding test coverage for empty restricted allowlist fallback.

The backfill logic change in models.go (line 350) specifically enables alias-based fallback when AllowedModels is restricted but empty by skipping Case A and falling through to Case B. However, none of the three tests exercises this scenario:

  • Test 1 uses Models: ["hermes-qwen"] (non-empty)
  • Test 2 uses Models: ["*"] (wildcard)
  • Test 3 uses Models: ["hermes-qwen"] (non-empty)

The PR stack description states: "Empty restricted allowlists now fall through to 'Case B' alias-based backfilling, enabling fallback via configured aliases when the allowlist is restrictive but empty."

Suggested test case
+func TestListModelsByKey_EmptyAllowlistFallsBackToAliases(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		http.Error(w, "upstream unavailable", http.StatusServiceUnavailable)
+	}))
+	defer server.Close()
+
+	ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
+	resp, err := ListModelsByKey(
+		ctx,
+		&fasthttp.Client{ReadTimeout: time.Second, WriteTimeout: time.Second},
+		server.URL,
+		schemas.Key{
+			ID:     "local-key",
+			Models: schemas.WhiteList{}, // empty restricted allowlist
+			Aliases: schemas.KeyAliases{
+				"my-alias": "actual-model",
+			},
+		},
+		false,
+		nil,
+		schemas.ModelProvider("test-provider"),
+		false,
+		false,
+	)
+
+	if err != nil {
+		t.Fatalf("ListModelsByKey returned error: %v", err)
+	}
+	if resp == nil || len(resp.Data) != 1 {
+		t.Fatalf("expected one alias fallback model, got %#v", resp)
+	}
+	if resp.Data[0].ID != "test-provider/my-alias" {
+		t.Fatalf("expected alias model id, got %q", resp.Data[0].ID)
+	}
+	if resp.Data[0].Alias == nil || *resp.Data[0].Alias != "actual-model" {
+		t.Fatalf("expected alias target, got %#v", resp.Data[0].Alias)
+	}
+}
🤖 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 `@core/providers/openai/list_models_fallback_test.go` around lines 14 - 117,
Add a new unit test that simulates the upstream returning 503 and verifies that
an empty but restricted allowlist falls through to alias-based backfilling:
create a httptest server that returns ServiceUnavailable, call ListModelsByKey
with Models set to an empty schemas.WhiteList{} (not wildcard), include a
Key.Aliases map (e.g. "desktop-picker-name":"actual-local-model"), use
filtered=false and a local provider (e.g.
schemas.ModelProvider("local-openai")), and assert the response contains one
model with ID "local-openai/desktop-picker-name" and Alias "actual-local-model";
name it e.g. TestListModelsByKey_EmptyRestrictedAllowlistFallsBackToAliases and
model it after the existing fallback tests using ListModelsByKey to locate
behavior in models.go.

@greptile-apps

greptile-apps Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Confidence Score: 5/5

Safe to merge; the best-effort timeout mechanism is correctly bounded per provider, goroutines cannot block indefinitely, and the channel buffer is sized to absorb both worker and watchdog sends per provider.

No new blocking defects were found beyond the two items already flagged in prior review threads. The BackfillModels condition change for empty allowlists is a deliberate fix that aligns implementation with the pre-existing ShouldEarlyExit comment. Integration tests with real httptest servers cover the core timing and fallback scenarios.

core/bifrost.go — the watchdog grace-period and post-cancellation error-synthesis concerns flagged in previous review threads are the only open items worth a follow-up read.

Important Files Changed

Filename Overview
core/bifrost.go Core best-effort ListAllModels rewrite; two concurrency concerns flagged in previous threads (watchdog/fallback race, cancellation error synthesis) remain open but are partially mitigated by the 100 ms grace period; no new blocking issues found.
core/providers/openai/openai.go Adds configuredListModelsFallback at all three error exit points in ListModelsByKey; mutation of upstreamErr.ExtraFields is safe because it only occurs when the fallback is returned; unfiltered guard is correct.
core/providers/utils/models.go BackfillModels Case A condition gains !IsEmpty() guard so empty allowlists with aliases fall through to Case B alias backfill, aligning implementation with the pre-existing ShouldEarlyExit comment.
core/providers/utils/utils.go extractSuccessfulListModelsResponses now passes through fallback KeyStatuses; HandleKeylessListModelsRequest adds a len==0 guard; both changes are correctly scoped and tested.
core/bifrost_test.go Adds two integration tests with real httptest servers covering the fast/slow timeout scenario and the empty-inventory success case.
core/providers/openai/list_models_fallback_test.go Five unit tests cover fallback with explicit allowlist, wildcard allowlist, empty allowlist, alias preservation, failed key-status propagation, and unfiltered guard.

Reviews (3): Last reviewed commit: "fix: surface list models fallback status" | Re-trigger Greptile

Comment thread core/bifrost.go
Comment thread core/bifrost.go
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