patch: Let startup components become ready independently (#2107)
Some checks are pending
Post merge / Warm dependency cache (ubuntu-latest) (push) Waiting to run
Post merge / Warm dependency cache (windows-latest) (push) Waiting to run
Post merge / Warm dependency cache (macos-26) (push) Waiting to run
Post merge / Warm dependency cache (macos-26-intel) (push) Waiting to run
Post merge / Warm dependency cache (macos-latest) (push) Waiting to run
Post merge / Publish to PyPI (push) Waiting to run

## Why

Closes #2106. A slow provider held back model lists and integrations
even after other providers were ready. Shared waits also let Code
requests stall Admin progress, unrelated account checks prolong settings
saves, and one integration's file update block another.

## How

- Serve current configured and discovered models immediately. Code waits
only for the selected provider's metadata, including saved models
awaiting rediscovery, with the existing initialization budget and
generation checks.
- Publish catalog changes progressively. Notify integration consumers
independently from Codex file writes, coalesce publication, and track
the exact version written. File waiters follow newer publications
without reporting pending work as a failure. Explicit model refresh
retries an unpublished version even when provider metadata is unchanged.
- Let integrations update their own files concurrently. Preserve
exclusive settings publication and cancellation draining so active
writes cannot outlive their settings generation.
- Give Admin polling, account checks, model suggestions, provider
actions, and Code bootstrap independent completion. Coalesce Code
refreshes and preserve selections, drafts, open searches, and
stale-response protection as models arrive.
- Add regression coverage for held providers and requests, independent
integration writes, publication ordering, cancellation, partial
catalogs, and late model updates. Provider-ordering browser tests answer
subsequent status polls after releasing their initial held request.

Native launchers use the catalog available at launch. Apps that retain
that snapshot may need a reload or relaunch to show later discoveries.
Settings commits still wait for active integration writes to finish
before publishing new settings.


<!-- greptile_comment -->

<!-- greptile_summary -->

<h2><a
href="https://app.greptile.com/api/retrigger?id=82968429"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/RetriggerDark.svg?v=2"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=2"><img
alt="Retrigger"
src="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=2"
align="right"></picture></a><a
href="https://app.greptile.com/alishahryar1/github/Alishahryar1/free-claude-code/-/pull/2107"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewInGreptileDark.svg?v=2"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewInGreptile.svg?v=2"><img
alt="View in Greptile"
src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewInGreptile.svg?v=2"
align="right"></picture></a>Confidence Score: 5/5</h2>

<!-- greptile-impact-verdict --> No outstanding findings block merging
the reviewed changes.

<details open><summary><h3>Summary</h3></summary>

Startup components can become ready independently:
- Model lists grow as providers become ready, while Code waits only for
its selected provider.
- Integration files update independently, while Codex tracks the catalog
version it writes.
- Admin checks and Code startup can finish without waiting for one
another.

The latest change updates the provider-ordering browser test to answer
subsequent status polls after releasing the initial held request. No new
actionable issues were found.

Both earlier findings remain addressed in the current code: saved models
wait for their provider's pending discovery within the initialization
budget, and explicit model refresh requests publication again when the
current catalog version has not been written.
</details>

<!-- greptile_confidence_score:5 -->

<sub>Reviews (4) · Last reviewed commit: ["Complete subsequent status
polls in
prov..."](2780127722)
· [Reviewed by
Greptile](https://www.greptile.com/?utm_source=greptile_review&utm_medium=github&utm_campaign=review_public&utm_content=alishahryar1/free-claude-code)</sub>

<!-- /greptile_comment -->
This commit is contained in:
Ali Khokhar 2026-10-17 18:53:29 -07:00 • committed by GitHub
parent 261b12f964
commit 949ccfe6d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1141 additions and 290 deletions

View file

@ -225,7 +225,14 @@ def test_local_model_discovery_takes_precedence_over_reachability(
) -> None:
startup: list[Route] = []
availability: list[Route] = []
page.route("**/admin/api/status", lambda route: startup.append(route))
def status(route: Route) -> None:
if startup:
route.fulfill(json=snapshot)
else:
startup.append(route)
page.route("**/admin/api/status", status)
page.route(
"**/admin/api/providers/lmstudio/local-status",
lambda route: availability.append(route),
@ -259,7 +266,7 @@ def test_local_model_discovery_takes_precedence_over_reachability(
availability.pop().fulfill(json=probe)
expect(result).to_have_text(diagnostic)
expect(detail).to_be_hidden()
startup.pop(0).fulfill(json=snapshot)
startup[0].fulfill(json=snapshot)
expect(result).to_have_text(primary)
if not availability_first:
availability.pop().fulfill(json=probe)
@ -271,6 +278,9 @@ def test_local_model_discovery_takes_precedence_over_reachability(
)
expect(dialog.locator("#providerDialogCheck")).to_have_text(primary)
expect(dialog.locator("#providerDialogAvailability")).to_have_text(diagnostic)
if discovery == "starting":
with page.expect_response("**/admin/api/status"):
page.evaluate("void refreshStartup()")
def test_provider_check_failure_is_separate_and_never_exposes_exception_text(

View file

@ -1,6 +1,131 @@
import pytest
from playwright.sync_api import expect
from e2e.provider_support import api_key_input, open_provider
from e2e.test_code_sessions import create_session
def test_code_bootstrap_cannot_stall_other_pages_status(page, admin_base_url):
pending = []
starting = True
def status(route):
payload = route.fetch().json()
payload["startup"]["providers"]["open_router"] = (
"starting" if starting else "ready"
)
route.fulfill(json=payload)
def hold(route):
pending.append(route)
page.evaluate("window.heldBootstrap = true")
page.route("**/admin/api/status", status)
page.route("**/admin/api/code/bootstrap", hold)
page.goto(f"{admin_base_url}/admin")
page.wait_for_function("window.heldBootstrap === true")
expect(page.locator('[data-provider-check-result="open_router"]')).to_have_text(
"Checking models…"
)
starting = False
expect(page.locator('[data-provider-check-result="open_router"]')).to_have_text(
"3 models available"
)
assert len(pending) == 1
pending.pop().fulfill(
response=page.request.get(f"{admin_base_url}/admin/api/code/bootstrap")
)
page.unroute("**/admin/api/code/bootstrap", hold)
def test_partial_catalog_keeps_code_selection_and_send_usable(
page, admin_base_url, tmp_path
):
def bootstrap(route):
payload = route.fetch().json()
assert payload["models"]
payload["startup"]["catalog"] = "starting"
route.fulfill(json=payload)
page.route("**/admin/api/code/bootstrap", bootstrap)
create_session(page, admin_base_url, tmp_path)
page.locator("#codeComposer").fill("Use the available model")
expect(page.locator("#codeProvider")).to_be_enabled()
expect(page.locator("#codeSend")).to_be_enabled()
def test_saved_settings_finish_before_unrelated_account_check(page, admin_base_url):
page.goto(f"{admin_base_url}/admin")
expect(page.locator("#messageArea")).to_have_text("")
open_provider(page, "nvidia_nim")
api_key_input(page, "NVIDIA_NIM_API_KEY").fill("test-new-key")
pending = []
def hold(route):
pending.append(route)
page.evaluate("window.heldAccountCheck = true")
page.route("**/admin/api/providers/github_copilot/auth", hold)
page.locator("#saveProvider").click()
page.wait_for_function("window.heldAccountCheck === true")
expect(page.locator("#messageArea")).to_have_text("Applied")
assert not page.evaluate("state.applying")
for request in pending:
request.fulfill(
json={
"provider_id": "github_copilot",
"connected": False,
"state": "disconnected",
}
)
page.unroute("**/admin/api/providers/github_copilot/auth", hold)
expect(page.locator("#messageArea")).to_have_text("Applied")
def test_provider_refresh_finishes_before_optional_model_suggestions(
page, admin_base_url
):
page.goto(f"{admin_base_url}/admin")
dialog = open_provider(page, "open_router")
button = dialog.get_by_role("button", name="Refresh models", exact=True)
expect(button).to_be_enabled()
pending = []
def hold(route):
pending.append(route)
page.evaluate("window.heldModelSuggestions = true")
page.route("**/admin/api/models", hold)
button.click()
page.wait_for_function("window.heldModelSuggestions === true")
expect(button).to_be_enabled()
expect(dialog.locator("#providerDialogCheck")).to_contain_text("models available")
for request in tuple(pending):
request.fulfill(response=request.fetch())
page.unroute("**/admin/api/models", hold)
def test_refresh_all_finishes_without_waiting_for_code(page, admin_base_url):
page.goto(f"{admin_base_url}/admin")
page.get_by_role("button", name="Model Config", exact=True).click()
button = page.locator("[data-startup-catalog]")
expect(button).to_be_enabled()
pending = []
def hold(route):
pending.append(route)
page.evaluate("window.heldRefreshBootstrap = true")
page.route("**/admin/api/code/bootstrap", hold)
button.click()
page.wait_for_function("window.heldRefreshBootstrap === true")
expect(button).to_be_enabled()
expect(button).to_have_text("Refresh models")
expect(page.locator("#messageArea")).to_contain_text("models available")
for request in tuple(pending):
request.fulfill(response=request.fetch())
page.unroute("**/admin/api/code/bootstrap", hold)
@pytest.mark.parametrize(
"held_id,held_button",

View file

@ -44,9 +44,9 @@ def test_model_picker_uses_application_order_and_keeps_configured_selection(
create_session(page, admin_base_url, tmp_path)
expect(page.locator("#codeProvider")).to_have_value("open_router")
expect(page.locator("#codeModel")).to_have_value("Zulu")
assert page.locator("#codeProvider option").evaluate_all(
"options => options.map(option => option.value)"
) == ["deepseek", "open_router"]
expect(page.locator("#codeProvider option")).to_have_text(
["deepseek", "open_router"]
)
page.locator("#codeComposer").fill("Keep this draft")
page.locator("#codeModel").fill("")
expect(page.locator("#codeModel").locator("..").get_by_role("option")).to_have_text(
@ -58,6 +58,36 @@ def test_model_picker_uses_application_order_and_keeps_configured_selection(
expect(page.locator("#codeComposer")).to_have_value("Keep this draft")
def test_catalog_growth_preserves_open_search_selection_and_draft(
page, admin_base_url, tmp_path, code_control
):
code_control.harness.configurations.update({"provider/model-two": "two"})
create_session(page, admin_base_url, tmp_path)
expect(page.locator("#codeSend")).to_be_disabled()
assert page.evaluate("window.CodeSessions.refresh()") is True
page.locator("#codeComposer").fill("Keep the composer draft")
model = page.locator("#codeModel")
model.fill("model")
model.press("ArrowDown")
active = model.get_attribute("aria-activedescendant")
active_value = page.locator(f"#{active}").get_attribute("data-value")
effort = page.locator("#codeReasoning").input_value()
code_control.harness.configurations["provider/model-later"] = "later"
assert page.evaluate("window.CodeSessions.refresh()") is True
expect(model).to_be_focused()
expect(model).to_have_value("model")
expect(
model.locator("..").get_by_role("option", name="model-later", exact=True)
).to_be_visible()
active = model.get_attribute("aria-activedescendant")
assert page.locator(f"#{active}").get_attribute("data-value") == active_value
expect(page.locator("#codeComposer")).to_have_value("Keep the composer draft")
expect(page.locator("#codeReasoning")).to_have_value(effort)
model.press("Escape")
expect(model).to_have_value("model")
expect(page.locator("#codeSend")).to_be_enabled()
@pytest.mark.parametrize("width", [1440, 900, 390])
def test_five_header_controls_fit_without_hiding_composer(
page, admin_base_url, tmp_path, code_control, width

View file

@ -77,21 +77,36 @@ def test_setup_error_and_retry_remain_in_dialog(page, admin_base_url, tmp_path):
def test_superseded_connect_notice_matches_real_disconnect(
page, admin_base_url, tmp_path, monkeypatch
):
from free_claude_code.runtime.provider_manager import ProviderRuntimeManager
from contextlib import asynccontextmanager
from free_claude_code.runtime.integration_service import IntegrationService
home = install(tmp_path)
page.goto(admin_base_url + "/admin/integrations")
opener = page.locator("#openDshDesktopIntegration")
expect(opener).to_have_text("Connect")
entered, release = threading.Event(), threading.Event()
original = ProviderRuntimeManager.wait_for_catalog
original = IntegrationService._dsh_desktop
async def held(manager):
entered.set()
assert await asyncio.to_thread(release.wait, 10)
return await original(manager)
async def held(service, action):
if action == "connect":
write = service._dsh_update.access.write
first = True
monkeypatch.setattr(ProviderRuntimeManager, "wait_for_catalog", held)
@asynccontextmanager
async def held_write():
nonlocal first
if first:
first = False
entered.set()
assert await asyncio.to_thread(release.wait, 10)
async with write():
yield
monkeypatch.setattr(service._dsh_update.access, "write", held_write)
return await original(service, action)
monkeypatch.setattr(IntegrationService, "_dsh_desktop", held)
endpoint = admin_base_url + "/admin/api/integrations/dsh-desktop"
try:
opener.click()

View file

@ -146,6 +146,7 @@ def test_initial_code_catalog_wait_keeps_history_and_draft(
if starting:
data["models"] = []
data["startup"]["catalog"] = "starting"
data["startup"]["providers"]["provider"] = "starting"
route.fulfill(json=data)
def status(route):
@ -157,12 +158,14 @@ def test_initial_code_catalog_wait_keeps_history_and_draft(
page.route("**/admin/api/code/bootstrap", bootstrap)
page.route("**/admin/api/status", status)
create_session(page, admin_base_url, tmp_path)
assert page.evaluate("window.CodeSessions.refresh()") is True
composer = page.locator("#codeComposer")
composer.fill("Keep my draft")
send = page.locator("#codeSend")
expect(send).to_be_disabled()
expect(send).to_have_attribute("aria-busy", "true")
expect(page.locator("#codeProvider")).to_be_disabled()
expect(page.locator("#codeMode")).to_be_enabled()
starting = False
expect(send).to_be_enabled()
expect(composer).to_have_value("Keep my draft")
@ -221,3 +224,46 @@ def test_code_catalog_cannot_roll_back_after_a_newer_startup_status(
page.evaluate("status => window.CodeSessions.refresh(status)", status) is True
)
expect(page.locator("#codeSelectionError")).to_be_hidden()
def test_code_catalog_coalesces_notifications_and_catches_up_to_latest_version(
page, admin_base_url, tmp_path
):
create_session(page, admin_base_url, tmp_path)
page.wait_for_function("!state.startupRequest && !state.startupTimer")
assert page.evaluate("window.CodeSessions.refresh()") is True
data = page.request.get(f"{admin_base_url}/admin/api/code/bootstrap").json()
status = page.request.get(f"{admin_base_url}/admin/api/status").json()
generation = status["startup"]["generation_id"] + 1
status["startup"].update(generation_id=generation, catalog_revision=1)
first = data | {"startup": data["startup"] | status["startup"]}
latest = data | {
"startup": first["startup"] | {"catalog_revision": 5},
"models": [
*data["models"],
data["models"][0]
| {"id": "extra/later", "provider_id": "extra", "model_name": "later"},
],
}
pending = []
calls = 0
def bootstrap(route):
nonlocal calls
calls += 1
if calls == 1:
pending.append(route)
page.evaluate("window.heldCatalogVersion = true")
else:
route.fulfill(json=latest)
page.route("**/admin/api/code/bootstrap", bootstrap)
page.evaluate("status => { void window.CodeSessions.refresh(status); }", status)
page.wait_for_function("window.heldCatalogVersion === true")
for revision in range(2, 6):
status["startup"]["catalog_revision"] = revision
page.evaluate("status => { void window.CodeSessions.refresh(status); }", status)
assert calls == 1
pending.pop().fulfill(json=first)
expect(page.locator('#codeProvider option[value="extra"]')).to_have_count(1)
assert calls == 2

View file

@ -19,7 +19,7 @@ const state = {
startupRequest: null,
startupTimer: null,
startupAgain: false,
codeCatalogRetry: false,
configRequest: 0,
modelOptionsRequest: 0,
activeView: viewFromLocation(),
};
@ -177,8 +177,8 @@ async function refreshStartup() {
const changed = !previous || previous.instance_id !== result.instance_id ||
JSON.stringify(previous.startup) !== JSON.stringify(current);
if (changed) void hydrateModelOptions();
if (changed || state.codeCatalogRetry) state.codeCatalogRetry = await window.CodeSessions?.refresh(result) === false;
pending = state.codeCatalogRetry || current.catalog === "starting" || current.catalog_file === "starting" ||
if (changed) void window.CodeSessions?.refresh(result);
pending = current.catalog === "starting" || current.catalog_file === "starting" ||
current.code?.state === "starting" || current.messaging?.state === "starting" ||
Object.values(current.integrations || {}).some((update) => update.state === "starting") ||
Object.values(current.providers || {}).includes("starting");
@ -209,11 +209,13 @@ window.addEventListener("pagehide", () => {
});
async function load({ providersOnly = false } = {}) {
const request = ++state.configRequest;
state.localStatusRequest = null;
state.startupRequest?.controller.abort();
state.startupRequest = null;
showMessage("Loading admin config");
const config = await api("/admin/api/config");
if (request !== state.configRequest) return;
config.custom_providers ||= [];
config.provider_status.push(...config.custom_providers.map((provider) => ({ ...provider, kind: "custom", status: "configured", settings_keys: [], missing_configuration_keys: [] })));
state.config = config;
@ -229,11 +231,10 @@ async function load({ providersOnly = false } = {}) {
byId("configPath").textContent = config.paths.managed;
void refreshLocalStatus(config);
void refreshStartup();
await Promise.all([
refreshConnectedAccounts(),
hydrateModelOptions(),
providersOnly ? window.CodeSessions.refresh() : window.CodeSessions.initialize(api),
]);
void refreshConnectedAccounts();
void hydrateModelOptions();
if (providersOnly) void window.CodeSessions.refresh();
else window.CodeSessions.initialize(api);
if (state.config !== config) return;
updateDirtyState();
showMessage("");
@ -265,7 +266,7 @@ function setActiveView(viewId, { scroll = false } = {}) {
state.activeView = activeView.id;
byId("pageTitle").textContent = activeView.title;
renderStartup();
if (activeView.id === "code") state.codeCatalogRetry = true;
if (activeView.id === "code") void window.CodeSessions.refresh();
void refreshStartup();
const sessionActive = activeView.id === "code";
document.querySelector(".app-shell").classList.toggle("session-active", sessionActive);
@ -1715,7 +1716,7 @@ async function testProvider(providerId, button) {
"ok",
modelCountMessage(result.models.length),
);
await hydrateModelOptions();
void hydrateModelOptions();
} else {
updateProviderCheckResult(
providerId,
@ -1755,7 +1756,7 @@ async function loadModelOptions(refresh = false) {
method: refresh ? "POST" : "GET",
});
if (request === state.modelOptionsRequest && config === state.config) { state.modelLabels = new Map(Object.entries(result.model_labels || {})); setModelOptions(result.models); }
if (refresh && window.CodeSessions) await window.CodeSessions.refresh();
if (refresh && window.CodeSessions) void window.CodeSessions.refresh();
return result;
}

View file

@ -14,9 +14,15 @@
catalogLoaded = false,
catalogPhase = "starting",
catalogVersion = null,
catalogTarget = null,
catalogProviders = {},
catalogInstance = null,
catalogRequest = 0,
settingsPending = false;
let catalogFlight = null,
catalogAgain = false,
catalogRetryTimer = null,
catalogController = null;
let visibleIds = [],
query = "",
libraryToken = 0,
@ -364,32 +370,60 @@
record.nextBefore = data.next_before;
render(!before && outputChanged);
}
function olderCatalog(candidate) {
return candidate && catalogVersion && (candidate.generation_id < catalogVersion.generation_id ||
(candidate.generation_id === catalogVersion.generation_id && candidate.catalog_revision < catalogVersion.catalog_revision));
function olderCatalog(candidate, version = catalogTarget) {
return candidate && version && (candidate.generation_id < version.generation_id ||
(candidate.generation_id === version.generation_id && candidate.catalog_revision < version.catalog_revision));
}
async function bootstrap() {
async function readBootstrap() {
const token = syncToken;
const request = ++catalogRequest;
const controller = new AbortController();
catalogController = controller;
try {
const data = await api(`${base}/bootstrap`);
if (request !== catalogRequest || token !== syncToken || (epoch && data.epoch !== epoch) || olderCatalog(data.startup)) return false;
const data = await api(`${base}/bootstrap`, { signal: controller.signal });
if (request !== catalogRequest || token !== syncToken || (epoch && data.epoch !== epoch) || olderCatalog(data.startup) || olderCatalog(data.startup, catalogVersion)) return false;
if (data.startup) catalogVersion = data.startup;
available = data.available;
catalogPhase = data.startup?.catalog || "ready";
if (catalogPhase === "ready" || !catalogLoaded) {
catalog = data.models;
harnesses = data.harnesses;
}
if (catalogPhase === "ready") catalogLoaded = true;
catalogProviders = data.startup?.providers || {};
catalog = data.models;
harnesses = data.harnesses;
catalogLoaded = true;
availabilityNotice = data.message || "";
return true;
} catch (error) {
if (request === catalogRequest && token === syncToken) {
if (error.name !== "AbortError" && request === catalogRequest && token === syncToken) {
availabilityNotice = error.message;
if (!catalogLoaded) catalogPhase = "failed";
}
throw error;
return false;
} finally {
if (catalogController === controller) catalogController = null;
}
}
async function bootstrap() {
if (!api || document.hidden) return false;
catalogAgain = true;
clearTimeout(catalogRetryTimer);
catalogRetryTimer = null;
if (catalogFlight) return catalogFlight;
catalogFlight = (async () => {
while (catalogAgain && !document.hidden) {
catalogAgain = false;
const refreshed = await readBootstrap();
render();
if (!refreshed && !catalogAgain) {
if (!document.hidden) catalogRetryTimer = setTimeout(() => void bootstrap(), 500);
return false;
}
}
return true;
})();
try {
return await catalogFlight;
} finally {
catalogFlight = null;
}
}
@ -402,6 +436,7 @@
if (epoch !== readyData.epoch) {
if (epoch) {
catalogVersion = null;
catalogTarget = null;
catalogLoaded = false;
catalogPhase = "starting";
}
@ -429,7 +464,7 @@
for (const summary of readyData.sessions)
merge({ ...summary, cursor: readyData.cursor });
render();
void bootstrap().then(() => render()).catch(() => render());
void bootstrap();
try {
const results = await Promise.allSettled([
list(),
@ -479,17 +514,12 @@
source.addEventListener(type, (event) => {
if (feed === source) receive(type, event);
});
const disconnected = async () => {
const disconnected = () => {
if (feed !== source) return;
restart();
// A stopped store cannot open its event feed. Read its availability
// so that the retry screen can explain why it is unavailable.
try {
await bootstrap();
render();
} catch {
/* The scheduled connection retries the reads. */
}
void bootstrap();
};
source.addEventListener("feed.resync_required", disconnected);
source.onerror = disconnected;
@ -591,11 +621,7 @@
if (selected === id) notice = error.message;
}
if (error.status === 400 || error.status === 409) {
try {
await bootstrap();
} catch (failure) {
restart(failure.message);
}
await bootstrap();
}
} finally {
sends.delete(id);
@ -837,7 +863,9 @@
if (providerDraft) return "Choose a model for the selected provider.";
if (!catalogLoaded) return catalogPhase === "starting" ? "Models are loading…" : "Model list unavailable.";
const model = catalog.find((model) => model.id === record.session.model);
if (!model) return "Selected model is unavailable. Choose another model.";
if (!model) return catalogProviders[record.session.provider_id] === "starting"
? "Models for the selected provider are loading…"
: "Selected model is unavailable. Choose another model.";
if (
record.session.reasoning_effort &&
!model.reasoning_efforts.includes(record.session.reasoning_effort)
@ -846,6 +874,12 @@
return "";
}
function selectionLoading(record) {
if (!catalogLoaded) return catalogPhase === "starting";
return !!record?.session && !catalog.some((model) => model.id === record.session.model) &&
catalogProviders[record.session.provider_id] === "starting";
}
function remove() {
const record = records.get(selected),
id = selected,
@ -1241,8 +1275,8 @@
stop.hidden = !isBusy;
stop.disabled = !connected || record?.run?.stop_requested;
send.textContent = saved(selected).pending ? "Retry Send" : "Send";
send.classList.toggle("startup-busy", !catalogLoaded && catalogPhase === "starting");
send.setAttribute("aria-busy", String(!catalogLoaded && catalogPhase === "starting"));
send.classList.toggle("startup-busy", selectionLoading(record));
send.setAttribute("aria-busy", String(selectionLoading(record)));
send.disabled =
!ready(record) ||
!available ||
@ -1268,12 +1302,12 @@
record.session?.status === "deleting" ||
isBusy ||
pending(record);
const disabled =
const sessionDisabled =
!ready(record) ||
isBusy ||
pending(record) ||
settingsPending ||
!catalogLoaded;
settingsPending;
const disabled = sessionDisabled || !catalogLoaded || !catalog.length;
const provider =
providerDraft?.provider || record?.session?.provider_id || "";
if (modelControl.group.dataset.provider !== provider) {
@ -1298,7 +1332,7 @@
);
harnessControl.select.disabled = true;
modeControl.select.value = record?.session?.mode || "config";
modeControl.select.disabled = disabled;
modeControl.select.disabled = sessionDisabled;
const model = catalog.find((model) => model.id === record?.session?.model),
effort =
record?.session?.reasoning_effort ||
@ -1661,6 +1695,18 @@
dismissDialog();
}
window.addEventListener("pagehide", deactivate);
function pauseCatalog() {
clearTimeout(catalogRetryTimer);
catalogRetryTimer = null;
catalogAgain = false;
++catalogRequest;
catalogController?.abort();
}
window.addEventListener("pagehide", pauseCatalog);
document.addEventListener("visibilitychange", () => {
if (document.hidden) pauseCatalog();
else void bootstrap();
});
window.CodeSessions = {
setProviderNames(providers) {
providerNames = new Map(providers.map((provider) => [provider.provider_id, provider.display_name]));
@ -1675,18 +1721,15 @@
async refresh(status) {
if (!api) return false;
if (status?.startup) {
if (catalogInstance !== status.instance_id) catalogVersion = null;
if (catalogInstance !== status.instance_id) {
catalogTarget = null;
catalogVersion = null;
++catalogRequest;
}
catalogInstance = status.instance_id;
if (!olderCatalog(status.startup)) catalogVersion = status.startup;
}
try {
const refreshed = await bootstrap();
render();
return refreshed;
} catch {
render();
return false;
if (!olderCatalog(status.startup)) catalogTarget = status.startup;
}
return bootstrap();
},
};
document.addEventListener("pointerdown", (event) => {

View file

@ -87,7 +87,8 @@
input.autocomplete = "off";
input.value = value;
input.setAttribute("aria-label", "Selected model");
let committed = value;
let committed = value,
optionsSignature = JSON.stringify(values());
const combobox = new window.FccModelCombobox(input, {
listboxId: `${id}-options`,
label: "model",
@ -114,6 +115,14 @@
input.disabled = disabled;
combobox.toggle.disabled = disabled;
if (disabled) combobox.close();
const signature = JSON.stringify(values());
if (combobox.isOpen && signature !== optionsSignature) {
const active = combobox.visibleOptions[combobox.activeIndex]?.dataset.value;
combobox.render(combobox.query);
const index = combobox.visibleOptions.findIndex((option) => option.dataset.value === active);
if (index >= 0) combobox.setActive(index, false);
}
optionsSignature = signature;
},
};
}

View file

@ -302,7 +302,7 @@ async def list_models(
):
"""List the model ids this proxy advertises to compatible clients."""
trace_event(stage="ingress", event="free_claude_code.api.models.list", source="api")
snapshot = await services.requests.wait_for_catalog()
snapshot = services.requests.catalog_snapshot()
return build_models_list_response(
snapshot.settings,
snapshot,
@ -321,7 +321,7 @@ async def list_muse_models(
):
"""List the direct Responses models expected by Muse Code."""
trace_event(stage="ingress", event="free_claude_code.api.models.list", source="api")
snapshot = await services.requests.wait_for_catalog()
snapshot = services.requests.catalog_snapshot()
return build_muse_models_list_response(snapshot.settings, snapshot)

View file

@ -126,7 +126,9 @@ class RequestRuntimePort(ModelCatalogPort, Protocol):
async def acquire(self) -> RequestRuntimeLease: ...
async def wait_for_catalog(self) -> ModelCatalogSnapshot: ...
def catalog_snapshot(self) -> ModelCatalogSnapshot: ...
async def wait_for_model_catalog(self, model: str) -> ModelCatalogSnapshot: ...
def catalog_status(self) -> JsonObject: ...

View file

@ -30,6 +30,7 @@ from free_claude_code.config.loader import clear_settings_cache
from free_claude_code.config.model_refs import parse_provider_type
from free_claude_code.config.server_urls import local_admin_url
from free_claude_code.config.settings import Settings
from free_claude_code.core.async_rwlock import AsyncReadWriteLock
from free_claude_code.core.async_tasks import run_sync_owned
from free_claude_code.core.json_types import JsonObject
from free_claude_code.core.version import package_version
@ -125,6 +126,7 @@ class ApplicationRuntime:
for provider_id, manager in self._connected_accounts.items()
}
self._config_lock = asyncio.Lock()
self._config_publication = AsyncReadWriteLock()
self._pending_fields: list[str] = []
self._started = False
self._instance_id = uuid.uuid4().hex
@ -137,7 +139,7 @@ class ApplicationRuntime:
self._http_ready = asyncio.Event()
self._integrations = IntegrationService(
provider_manager,
config_lock=self._config_lock,
config_publication=self._config_publication,
is_draining=lambda: self._draining,
has_pending_restart=lambda: bool(self._pending_fields),
)
@ -253,7 +255,7 @@ class ApplicationRuntime:
type(result).__name__,
)
self._startup_tasks.clear()
async with self._config_lock:
async with self._config_lock, self._config_publication.write():
self._closed = await self._close_owned_resources()
if self._closed:
self._started = False
@ -345,30 +347,31 @@ class ApplicationRuntime:
commit: Callable[[], Awaitable[JsonObject]],
) -> JsonObject:
assert prepared.settings is not None
if prepared.pending_fields:
result = await commit()
else:
result: JsonObject = {}
async with self._config_publication.write():
if prepared.pending_fields:
result = await commit()
else:
result: JsonObject = {}
async def publish_commit() -> None:
result.update(await commit())
async def publish_commit() -> None:
result.update(await commit())
await self.provider_manager.replace(
await self.provider_manager.replace(
prepared.settings,
commit=publish_commit,
reason="admin_apply",
)
self._pending_fields = list(prepared.pending_fields)
automatic = bool(prepared.pending_fields and self._signal_restart())
if automatic:
self._pending_fields = []
result["restart"] = self._restart_metadata(
prepared.pending_fields,
prepared.settings,
commit=publish_commit,
reason="admin_apply",
automatic=automatic,
)
self._pending_fields = list(prepared.pending_fields)
automatic = bool(prepared.pending_fields and self._signal_restart())
if automatic:
self._pending_fields = []
result["restart"] = self._restart_metadata(
prepared.pending_fields,
prepared.settings,
automatic=automatic,
)
result["credential_checks"] = check_response
return result
result["credential_checks"] = check_response
return result
async def admin_config(self) -> JsonObject:
return await self._configuration.admin_config()

View file

@ -660,7 +660,7 @@ class CodexHarnessFactory:
binary = self._binary or shutil.which("codex")
if binary is None:
raise CodeUnavailableError("Codex is not installed. " + CODEX_INSTALL_HINT)
snapshot = await self._runtime.wait_for_catalog()
snapshot = await self._runtime.wait_for_model_catalog(model)
settings = snapshot.settings
models = read_model_catalog(snapshot, settings).models
projected = project_codex_models(models)

View file

@ -63,12 +63,12 @@ class IntegrationService:
self,
provider_manager: ProviderRuntimeManager,
*,
config_lock: asyncio.Lock,
config_publication: AsyncReadWriteLock,
is_draining: Callable[[], bool],
has_pending_restart: Callable[[], bool],
) -> None:
self.provider_manager = provider_manager
self._config_lock = config_lock
self._config_publication = config_publication
self._is_draining = is_draining
self._has_pending_restart = has_pending_restart
self._claude_update = _IntegrationUpdate()
@ -261,9 +261,12 @@ class IntegrationService:
if not current["connected"]:
return {"changed": False}
while True:
snapshot = await self.provider_manager.wait_for_catalog()
snapshot = self.provider_manager.catalog_snapshot()
revision = self.provider_manager.catalog_status()["catalog_revision"]
async with self._config_lock, self._dsh_update.access.write():
async with (
self._dsh_update.access.write(),
self._config_publication.read(),
):
self._check_integration_available()
if action_revision != self._dsh_action_revision:
return await run_sync_owned(
@ -278,7 +281,6 @@ class IntegrationService:
snapshot.current_settings() is not self.settings
or revision
!= self.provider_manager.catalog_status()["catalog_revision"]
or self.provider_manager.catalog_status()["catalog"] != "ready"
):
continue
catalog = read_model_catalog(snapshot)
@ -346,18 +348,20 @@ class IntegrationService:
return {"changed": False}
while True:
snapshot = (
await self.provider_manager.wait_for_catalog()
self.provider_manager.catalog_snapshot()
if action in {"connect", "refresh"}
else None
)
revision = self.provider_manager.catalog_status()["catalog_revision"]
async with self._config_lock, self._vscode_update.access.write():
async with (
self._vscode_update.access.write(),
self._config_publication.read(),
):
self._check_integration_available()
if snapshot is not None and (
snapshot.current_settings() is not self.settings
or revision
!= self.provider_manager.catalog_status()["catalog_revision"]
or self.provider_manager.catalog_status()["catalog"] != "ready"
):
continue
catalog = (
@ -424,7 +428,10 @@ class IntegrationService:
try:
if complete:
async with self._config_lock, self._claude_update.access.write():
async with (
self._claude_update.access.write(),
self._config_publication.read(),
):
self._check_integration_available()
return await run_sync_owned(operate)
async with self._claude_update.access.read():
@ -494,7 +501,7 @@ class IntegrationService:
return await self._read_status(
update, operation, mask_when_unready=mask_when_unready
)
async with self._config_lock, update.access.write():
async with update.access.write(), self._config_publication.read():
self._check_integration_available()
url = local_proxy_root_url(self.settings)
token = self.settings.proxy_auth_token
@ -733,16 +740,22 @@ class IntegrationService:
if not existing["connected"]:
return {"changed": False}
while True:
generation_id = (
catalog_version = (
await self.provider_manager.wait_for_catalog_file(wait)
if needs_catalog
else None
)
async with self._config_lock, self._codex_update.access.write():
async with (
self._codex_update.access.write(),
self._config_publication.read(),
):
self._check_integration_available()
if needs_catalog and (
generation_id != self.provider_manager.current_generation_id
or self.provider_manager.catalog_status()["catalog"] != "ready"
catalog_version
!= (
self.provider_manager.current_generation_id,
self.provider_manager.catalog_status()["catalog_revision"],
)
):
continue
url = local_proxy_root_url(self.settings)

View file

@ -15,6 +15,7 @@ from free_claude_code.application.model_metadata import (
)
from free_claude_code.application.ports import ModelCatalogPort, ModelCatalogSnapshot
from free_claude_code.application.readiness import InitializationWait
from free_claude_code.config.model_refs import split_provider_model_ref
from free_claude_code.config.provider_catalog import PROVIDER_CATALOG
from free_claude_code.config.settings import Settings
from free_claude_code.core.async_tasks import run_sync_owned
@ -182,7 +183,8 @@ class ProviderRuntimeManager:
self._next_generation_id = 2
self._retired: dict[int, _ProviderGeneration] = {}
self._unpublished: set[ProviderRuntime] = set()
self._publications: set[asyncio.Task[None]] = set()
self._publication_task: asyncio.Task[None] | None = None
self._published_version: tuple[int, int] | None = None
self._encoder_task: asyncio.Task[object] | None = None
self._catalog_revision = 0
self._closing = False
@ -249,6 +251,11 @@ class ProviderRuntimeManager:
self._synchronize_model_cache_scope()
return self._current.cache.cached_prefixed_model_infos()
def catalog_snapshot(self) -> ModelCatalogSnapshot:
self._ensure_open()
self._synchronize_model_cache_scope()
return self._current.snapshot()
def cache_model_infos(
self, provider_id: str, model_infos: Iterable[ProviderModelInfo]
) -> None:
@ -266,10 +273,7 @@ class ProviderRuntimeManager:
provider_id
):
self._catalog_revision += 1
if generation.initial_complete:
task = asyncio.create_task(self._publish_generation(generation))
self._publications.add(task)
task.add_done_callback(self._publications.discard)
self._catalog_updated()
def _catalog_task(
self,
@ -352,6 +356,15 @@ class ProviderRuntimeManager:
)
)
)
if generation is self._current:
self._catalog_updated()
elif (
refresh
and generation is self._current
and self._published_version
!= (generation.generation_id, self._catalog_revision)
):
self._request_publication()
if generation.refresh_task is not None and not generation.refresh_task.done():
return generation.refresh_task
for provider_id in generation.initial_ids:
@ -377,7 +390,6 @@ class ProviderRuntimeManager:
if isinstance(result, asyncio.CancelledError):
raise result
generation.initial_complete = True
await self._publish_generation(generation)
if generation.initial_complete and tasks == tuple(
generation.catalog_tasks[provider_id]
for provider_id in generation.initial_ids
@ -431,26 +443,39 @@ class ProviderRuntimeManager:
finally:
await self._release(generation)
async def wait_for_catalog(self) -> ModelCatalogSnapshot:
async def wait_for_model_catalog(self, model: str) -> ModelCatalogSnapshot:
"""Wait only for a selection's metadata, following settings replacement."""
wait = InitializationWait()
while True:
self._ensure_open()
snapshot = self.catalog_snapshot()
try:
provider_id, _ = split_provider_model_ref(model)
except ValueError:
return snapshot
generation = self._current
if not generation.initial_complete:
task = self._start_pass(generation)
if provider_id not in (
*referenced_provider_ids(generation.settings),
*model_list_provider_ids_for_settings(
generation.settings, self._connected_provider_ids()
),
):
return snapshot
if provider_id not in generation.initialized:
try:
await self._wait_for_catalog_work(generation, task, wait)
await self._wait_for_catalog_work(
generation, self._catalog_task(generation, provider_id), wait
)
except ApplicationUnavailableError:
if generation is self._current:
raise
continue
if generation is self._current and generation.initial_complete:
return generation.snapshot()
if generation is self._current:
return self.catalog_snapshot()
async def _wait_for_catalog_work(
async def _wait_for_catalog_work[T](
self,
generation: _ProviderGeneration,
task: asyncio.Task[ProviderModelRefreshResult],
task: asyncio.Task[T],
wait: InitializationWait,
) -> None:
if generation is not self._current:
@ -472,33 +497,31 @@ class ProviderRuntimeManager:
replaced.cancel()
await asyncio.gather(settled, replaced, return_exceptions=True)
async def wait_for_catalog_file(self, wait: InitializationWait) -> int:
async def wait_for_catalog_file(self, wait: InitializationWait) -> tuple[int, int]:
while True:
self._ensure_open()
generation = self._current
if not generation.initial_complete:
version = (generation.generation_id, self._catalog_revision)
if self._published_version != version:
try:
await self._wait_for_catalog_work(
generation, self._start_pass(generation), wait
generation, self._request_publication(), wait
)
except ApplicationUnavailableError:
if generation is self._current:
raise
continue
if generation is not self._current or not generation.initial_complete:
continue
if generation.file_state != "ready":
publication = asyncio.create_task(self._publish_generation(generation))
self._publications.add(publication)
publication.add_done_callback(self._publications.discard)
await wait.wait(publication)
if generation is not self._current:
continue
if generation.file_state != "ready":
if generation.file_state == "failed":
raise ApplicationUnavailableError(
"Could not publish the Codex model catalog. Check file permissions and retry."
)
return generation.generation_id
if self._published_version == (
generation.generation_id,
self._catalog_revision,
):
return self._published_version
def catalog_status(self) -> JsonObject:
generation = self._current
@ -554,33 +577,53 @@ class ProviderRuntimeManager:
generation.file_state = "starting"
self._catalog_revision += 1
self._start_pass(generation)
self._catalog_updated()
return ProviderModelRefreshResult()
async def _publish_generation(self, generation: _ProviderGeneration) -> None:
async with self._publication_lock:
if (
generation is not self._current
or self._closing
or not generation.initial_complete
):
return
publisher = self._model_catalog_publisher
try:
if publisher is not None:
snapshot = generation.snapshot()
await run_sync_owned(partial(publisher.publish, snapshot))
except asyncio.CancelledError:
raise
except Exception as exc:
generation.file_state = "failed"
logger.warning(
"Model catalog publication failed: exc_type={}", type(exc).__name__
)
else:
generation.file_state = "ready"
def _catalog_updated(self) -> None:
if self._closing or not self._current.catalog_started:
return
self._request_publication()
if self._catalog_changed is not None and not self._closing:
self._catalog_changed()
def _request_publication(self) -> asyncio.Task[None]:
self._current.file_state = "starting"
if self._publication_task is None or self._publication_task.done():
self._publication_task = asyncio.create_task(
self._publish_catalog(), name="fcc-model-catalog"
)
return self._publication_task
async def _publish_catalog(self) -> None:
while not self._closing:
async with self._publication_lock:
if self._closing:
return
generation = self._current
version = (generation.generation_id, self._catalog_revision)
try:
publisher = self._model_catalog_publisher
if publisher is not None and self._published_version != version:
snapshot = generation.snapshot()
await run_sync_owned(partial(publisher.publish, snapshot))
except asyncio.CancelledError:
raise
except Exception as exc:
generation.file_state = "failed"
logger.warning(
"Model catalog publication failed: exc_type={}",
type(exc).__name__,
)
if version[1] == self._catalog_revision:
return
else:
self._published_version = version
if version[1] == self._catalog_revision:
generation.file_state = "ready"
return
generation.file_state = "starting"
async def replace(
self, settings: Settings, *, commit: CommitConfig, reason: str = "admin_apply"
) -> int:
@ -690,7 +733,8 @@ class ProviderRuntimeManager:
await asyncio.gather(
*(self._cancel_generation_work(g) for g in generations)
)
await asyncio.gather(*tuple(self._publications), return_exceptions=True)
if self._publication_task is not None:
await asyncio.gather(self._publication_task, return_exceptions=True)
if self._encoder_task is not None:
self._encoder_task.cancel()
await asyncio.gather(self._encoder_task, return_exceptions=True)

View file

@ -22,7 +22,7 @@ def onboarding():
def test_passive_status_and_independent_completion(onboarding):
client, runtime, path = onboarding
runtime.provider_manager.wait_for_catalog = AsyncMock(
runtime.provider_manager.wait_for_model_catalog = AsyncMock(
side_effect=AssertionError("No model discovery needed")
)
runtime._integrations._claude_update.state = "failed"
@ -50,7 +50,7 @@ def test_passive_status_and_independent_completion(onboarding):
assert path.stat().st_mtime_ns == before
assert runtime._integrations._claude_update.state == "failed"
assert runtime._integrations._claude_update.message == "VS Code unavailable"
runtime.provider_manager.wait_for_catalog.assert_not_awaited()
runtime.provider_manager.wait_for_model_catalog.assert_not_awaited()
@pytest.mark.parametrize("action", ["", "/complete"])

View file

@ -4,6 +4,7 @@ from unittest.mock import patch
import pytest
from free_claude_code.cli.launchers.catalog_http import fetch_proxy_model_catalog
from tests.cli.test_launcher_workflow import launch
class _ModelsResponse:
@ -75,3 +76,30 @@ def test_fetch_proxy_models_rejects_non_object_json() -> None:
pytest.raises(ValueError, match="JSON object"),
):
fetch_proxy_model_catalog("http://127.0.0.1:9191", "proxy-token")
@pytest.mark.parametrize("harness", ["codex", "opencode", "aider"])
def test_native_launch_uses_current_catalog_without_waiting_for_more_models(
launch_capture, harness
):
launch_capture.catalog = {
"default_model_id": "nvidia_nim/configured",
"data": [
{
"id": "nvidia_nim/configured",
"provider_model_ref": "nvidia_nim/configured",
}
],
}
launch(harness, [])
assert len(launch_capture.commands) == 1
assert (
len(
[
request
for request in launch_capture.requests
if "/v1/models" in request.full_url
]
)
== 1
)

View file

@ -0,0 +1,37 @@
"""Wait for owned work in tests without imposing it on catalog consumers."""
import asyncio
from contextlib import asynccontextmanager
from free_claude_code.core.async_rwlock import AsyncReadWriteLock
from free_claude_code.runtime.provider_manager import ProviderRuntimeManager
async def finish_discovery(manager: ProviderRuntimeManager) -> None:
await manager._start_pass(manager._current)
async def finish_publication(manager: ProviderRuntimeManager) -> None:
task = manager._publication_task
if task is not None:
await task
def pause_next_write(monkeypatch, lock: AsyncReadWriteLock):
"""Pause one operation before ownership, allowing a later action to overtake it."""
entered, release = asyncio.Event(), asyncio.Event()
original = lock.write
first = True
@asynccontextmanager
async def held():
nonlocal first
if first:
first = False
entered.set()
await release.wait()
async with original():
yield
monkeypatch.setattr(lock, "write", held)
return entered, release

View file

@ -0,0 +1,420 @@
"""Catalog consumers depend on their own work, not the slowest provider."""
import asyncio
import threading
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
import pytest_asyncio
from free_claude_code.api.app import create_app
from free_claude_code.api.ports import ApiServices
from free_claude_code.application.code_sessions.models import CodeValidationError
from free_claude_code.application.errors import ApplicationUnavailableError
from free_claude_code.application.model_metadata import ProviderModelInfo
from free_claude_code.application.readiness import InitializationWait
from free_claude_code.config.loader import ManagedConfigStore
from free_claude_code.config.settings import Settings
from free_claude_code.core.gateway_model_ids import no_thinking_gateway_model_id
from free_claude_code.providers.base import BaseProvider
from free_claude_code.providers.runtime.runtime import ProviderRuntime
from free_claude_code.runtime.application import ApplicationRuntime
from free_claude_code.runtime.codex_app_server import CodexHarnessFactory
from free_claude_code.runtime.codex_catalog import CodexModelCatalogPublisher
from free_claude_code.runtime.configuration import ConfigurationService
from free_claude_code.runtime.provider_manager import ProviderRuntimeManager
from tests.runtime.catalog_support import finish_discovery, finish_publication
from tests.web_tools_support import StubWebToolsClient
@pytest_asyncio.fixture
async def catalog_runtime():
entered, release = asyncio.Event(), asyncio.Event()
settings = Settings.model_validate(
{
"MODEL": "nvidia_nim/configured",
"MODEL_SONNET": "groq/configured",
"NVIDIA_NIM_API_KEY": "test",
"GROQ_API_KEY": "test",
"MESSAGING_PLATFORM": "none",
"PROXY_AUTH_ENABLED": False,
}
)
async def construct(provider_id, *_):
provider = MagicMock(spec=BaseProvider)
async def models():
if provider_id == "groq":
entered.set()
await release.wait()
return frozenset(
{
ProviderModelInfo("discovered"),
ProviderModelInfo("configured", supports_thinking=False),
}
)
provider.list_model_infos = AsyncMock(side_effect=models)
return provider
manager = ProviderRuntimeManager(
settings,
runtime_factory=lambda settings, registry: ProviderRuntime(
settings, registry, provider_constructor=construct
),
)
runtime = ApplicationRuntime(
manager,
configuration=ConfigurationService(ManagedConfigStore()),
transcriber=None,
)
manager.start_model_list_refresh()
try:
await asyncio.wait_for(entered.wait(), 1)
async with await manager.acquire() as lease:
await lease.resolve_provider("nvidia_nim")
yield runtime, release
finally:
release.set()
await runtime.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("path", ["/v1/models?view=responses", "/muse-code/models"])
async def test_http_catalog_exposes_ready_models_while_another_provider_waits(
catalog_runtime, path
):
runtime, release = catalog_runtime
manager = runtime.provider_manager
app = create_app(
ApiServices(manager, runtime, runtime, web_tools=StubWebToolsClient())
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://127.0.0.1"
) as client:
response = await asyncio.wait_for(client.get(path), 1)
assert response.status_code == 200
ids = {entry["id"] for entry in response.json()["data"]}
assert {
no_thinking_gateway_model_id("nvidia_nim/configured"),
"nvidia_nim/discovered",
} <= ids
assert not release.is_set()
assert manager.catalog_status()["providers"]["groq"] == "starting"
@pytest.mark.asyncio
async def test_code_preparation_uses_only_its_selected_provider(catalog_runtime):
runtime, release = catalog_runtime
factory = CodexHarnessFactory(runtime.provider_manager, binary="codex")
selection = await asyncio.wait_for(
factory.prepare("nvidia_nim/discovered", None, "config"), 1
)
assert selection.model == "nvidia_nim/discovered"
assert not release.is_set()
@pytest.mark.asyncio
async def test_code_waits_for_its_provider_and_validates_settled_metadata(
catalog_runtime,
):
runtime, release = catalog_runtime
factory = CodexHarnessFactory(runtime.provider_manager, binary="codex")
waiting = asyncio.create_task(factory.prepare("groq/configured", None, "config"))
try:
ready = await asyncio.wait_for(
factory.prepare("nvidia_nim/configured", None, "config"), 1
)
assert ready.reasoning_effort == "off"
assert not waiting.done()
release.set()
selected = await asyncio.wait_for(waiting, 1)
assert selected.model == "groq/configured"
assert selected.reasoning_effort == "off"
finally:
release.set()
await asyncio.gather(waiting, return_exceptions=True)
@pytest.mark.asyncio
@pytest.mark.parametrize("replace_settings", [False, True])
async def test_code_waits_for_model_absent_from_partial_catalog(
catalog_runtime, replace_settings
):
runtime, release = catalog_runtime
manager = runtime.provider_manager
factory = CodexHarnessFactory(manager, binary="codex")
model = "groq/configured" if replace_settings else "groq/discovered"
waiting = asyncio.create_task(factory.prepare(model, None, "config"))
try:
await asyncio.sleep(0)
if replace_settings:
settings = manager.current_settings().model_copy(
update={"model_sonnet": "groq/other"}
)
await manager.replace(settings, commit=AsyncMock())
await asyncio.sleep(0)
assert not waiting.done()
release.set()
selected = await asyncio.wait_for(waiting, 1)
assert selected.model == model
finally:
release.set()
await asyncio.gather(waiting, return_exceptions=True)
@pytest.mark.asyncio
@pytest.mark.parametrize("model", ["unknown/model", "", "groq", "groq/"])
async def test_code_rejects_invalid_selection_without_waiting(catalog_runtime, model):
runtime, release = catalog_runtime
factory = CodexHarnessFactory(runtime.provider_manager, binary="codex")
with pytest.raises(CodeValidationError, match="model is unavailable"):
await asyncio.wait_for(factory.prepare(model, None, "config"), 1)
assert not release.is_set()
@pytest.mark.asyncio
async def test_code_rejects_selection_when_its_provider_is_removed(catalog_runtime):
runtime, release = catalog_runtime
manager = runtime.provider_manager
factory = CodexHarnessFactory(manager, binary="codex")
waiting = asyncio.create_task(factory.prepare("groq/configured", None, "config"))
try:
await asyncio.sleep(0)
settings = manager.current_settings().model_copy(
update={"model_sonnet": None, "groq_api_keys": ()}
)
await manager.replace(settings, commit=AsyncMock())
with pytest.raises(CodeValidationError, match="model is unavailable"):
await asyncio.wait_for(waiting, 1)
assert "groq" not in manager.catalog_status()["providers"]
assert not release.is_set()
finally:
release.set()
await asyncio.gather(waiting, return_exceptions=True)
@pytest.mark.asyncio
@pytest.mark.parametrize("repair", [False, True])
async def test_model_refresh_retries_failed_file_with_unchanged_metadata(
catalog_runtime, tmp_path, repair
):
runtime, release = catalog_runtime
manager = runtime.provider_manager
release.set()
await finish_discovery(manager)
await finish_publication(manager)
directory = tmp_path / "catalog"
directory.write_text("block directory creation", encoding="utf-8")
path = directory / "models.json"
publisher = CodexModelCatalogPublisher(path)
manager._model_catalog_publisher = publisher
manager.cache_model_infos("nvidia_nim", [ProviderModelInfo("new")])
await finish_publication(manager)
assert manager.catalog_status()["catalog_file"] == "failed"
# First refresh restores the fixture's normal provider metadata.
await manager.refresh_model_list_cache()
await finish_publication(manager)
revision = manager.catalog_status()["catalog_revision"]
if repair:
directory.unlink()
with pytest.MonkeyPatch.context() as patch:
publish = MagicMock(wraps=publisher.publish)
patch.setattr(publisher, "publish", publish)
result = await manager.refresh_model_list_cache()
await finish_publication(manager)
publish.assert_called_once()
assert "nvidia_nim" in result.refreshed_provider_ids
assert manager.catalog_status()["catalog_revision"] == revision
assert manager.catalog_status()["catalog_file"] == ("ready" if repair else "failed")
assert path.exists() is repair
@pytest.mark.asyncio
async def test_unchanged_refresh_does_not_republish_current_file(
catalog_runtime, tmp_path, monkeypatch
):
runtime, release = catalog_runtime
manager = runtime.provider_manager
release.set()
await finish_discovery(manager)
await finish_publication(manager)
publisher = CodexModelCatalogPublisher(tmp_path / "models.json")
manager._model_catalog_publisher = publisher
manager.cache_model_infos("nvidia_nim", [ProviderModelInfo("new")])
await manager.refresh_model_list_cache()
await finish_publication(manager)
assert manager.catalog_status()["catalog_file"] == "ready"
publish = MagicMock(wraps=publisher.publish)
monkeypatch.setattr(publisher, "publish", publish)
await manager.refresh_model_list_cache()
await finish_publication(manager)
publish.assert_not_called()
@pytest.mark.asyncio
async def test_catalog_file_is_available_before_all_discovery(catalog_runtime):
runtime, release = catalog_runtime
manager = runtime.provider_manager
await asyncio.wait_for(manager.wait_for_catalog_file(InitializationWait()), 1)
assert manager.catalog_status()["catalog_file"] == "ready"
assert not release.is_set()
@pytest.mark.asyncio
async def test_slow_file_publication_does_not_delay_catalog_notifications(
catalog_runtime,
):
entered, release = threading.Event(), threading.Event()
notified = asyncio.Event()
class Publisher:
def publish(self, snapshot):
entered.set()
assert release.wait(5)
runtime, _ = catalog_runtime
manager = runtime.provider_manager
manager._model_catalog_publisher = Publisher()
manager.set_catalog_changed_callback(notified.set)
try:
manager.cache_model_infos("nvidia_nim", [ProviderModelInfo("new")])
await asyncio.wait_for(notified.wait(), 1)
assert await asyncio.to_thread(entered.wait, 1)
assert not release.is_set()
finally:
release.set()
await manager.wait_for_catalog_file(InitializationWait())
@pytest.mark.asyncio
async def test_file_publication_coalesces_changes_and_tracks_written_version(
catalog_runtime,
):
runtime, _ = catalog_runtime
manager = runtime.provider_manager
entered, release = threading.Event(), threading.Event()
snapshots = []
class Publisher:
def publish(self, snapshot):
snapshots.append(snapshot)
if len(snapshots) == 1:
entered.set()
assert release.wait(5)
manager._model_catalog_publisher = Publisher()
try:
manager.cache_model_infos("nvidia_nim", [ProviderModelInfo("first")])
assert await asyncio.to_thread(entered.wait, 1)
for name in ("second", "third", "latest"):
manager.cache_model_infos("nvidia_nim", [ProviderModelInfo(name)])
assert manager.catalog_status()["catalog_file"] == "starting"
release.set()
version = await manager.wait_for_catalog_file(InitializationWait())
assert version == (
manager.current_generation_id,
manager.catalog_status()["catalog_revision"],
)
assert len(snapshots) == 2
assert snapshots[0].cached_model_info("nvidia_nim", "first") is not None
assert snapshots[-1].cached_model_info("nvidia_nim", "latest") is not None
finally:
release.set()
await manager.wait_for_catalog_file(InitializationWait())
@pytest.mark.asyncio
@pytest.mark.parametrize("fail_next", [False, True])
async def test_file_waiter_follows_publication_started_after_previous_write(
catalog_runtime, fail_next
):
runtime, _ = catalog_runtime
manager = runtime.provider_manager
await finish_publication(manager)
entered = [threading.Event(), threading.Event()]
release = [threading.Event(), threading.Event()]
snapshots = []
class Publisher:
def publish(self, snapshot):
index = len(snapshots)
snapshots.append(snapshot)
entered[index].set()
assert release[index].wait(5)
if index == 1 and fail_next:
raise OSError("catalog write failed")
manager._model_catalog_publisher = Publisher()
manager.cache_model_infos("nvidia_nim", [ProviderModelInfo("first")])
waiting = asyncio.create_task(manager.wait_for_catalog_file(InitializationWait()))
try:
assert await asyncio.to_thread(entered[0].wait, 1)
assert manager._publication_task is not None
manager._publication_task.add_done_callback(
lambda _: manager.cache_model_infos(
"nvidia_nim", [ProviderModelInfo("later")]
)
)
release[0].set()
assert await asyncio.to_thread(entered[1].wait, 1)
assert not waiting.done()
release[1].set()
if fail_next:
with pytest.raises(ApplicationUnavailableError, match="Could not publish"):
await waiting
assert manager.catalog_status()["catalog_file"] == "failed"
else:
version = await waiting
assert version == (
manager.current_generation_id,
manager.catalog_status()["catalog_revision"],
)
assert snapshots[-1].cached_model_info("nvidia_nim", "later") is not None
assert manager.catalog_status()["catalog_file"] == "ready"
assert len(snapshots) == 2
finally:
for event in release:
event.set()
await asyncio.gather(waiting, return_exceptions=True)
await finish_publication(manager)
@pytest.mark.asyncio
@pytest.mark.parametrize("cancel", [False, True])
async def test_file_waiter_exit_preserves_owned_publication(catalog_runtime, cancel):
runtime, _ = catalog_runtime
manager = runtime.provider_manager
entered, release = threading.Event(), threading.Event()
class Publisher:
def publish(self, snapshot):
entered.set()
assert release.wait(5)
manager._model_catalog_publisher = Publisher()
manager.cache_model_infos("nvidia_nim", [ProviderModelInfo("new")])
waiting = asyncio.create_task(
manager.wait_for_catalog_file(InitializationWait(1 if cancel else 0.01))
)
try:
assert await asyncio.to_thread(entered.wait, 1)
if cancel:
waiting.cancel()
with pytest.raises(asyncio.CancelledError):
await waiting
else:
from free_claude_code.application.errors import ApplicationUnavailableError
with pytest.raises(ApplicationUnavailableError, match="still starting"):
await waiting
assert (
manager._publication_task is not None
and not manager._publication_task.done()
)
finally:
release.set()
await asyncio.gather(waiting, return_exceptions=True)
await manager.wait_for_catalog_file(InitializationWait())

View file

@ -77,7 +77,7 @@ def factory_runtime():
"open_router/selected", supports_thinking=False, context_window_tokens=32000
),
)
runtime.wait_for_catalog.return_value = ModelCatalogSnapshot(
runtime.wait_for_model_catalog.return_value = ModelCatalogSnapshot(
runtime.current_settings(), runtime.cached_prefixed_model_infos()
)
runtime.acquire.side_effect = AssertionError("Setup must not acquire a provider")

View file

@ -40,9 +40,12 @@ class FakeRequestRuntime(RequestRuntimePort):
del include_model_infos
raise AssertionError("Catalog publication must not acquire a provider lease.")
async def wait_for_catalog(self) -> ModelCatalogSnapshot:
def catalog_snapshot(self) -> ModelCatalogSnapshot:
return ModelCatalogSnapshot(self._settings, self._cached_infos)
async def wait_for_model_catalog(self, model: str) -> ModelCatalogSnapshot:
return self.catalog_snapshot()
def catalog_status(self) -> JsonObject:
raise AssertionError("Catalog publication must not query startup status.")

View file

@ -53,7 +53,7 @@ async def test_integration_write_finishes_before_configuration_publication(
writer.cancel()
apply = asyncio.create_task(apply_config())
await asyncio.wait_for(apply_started.wait(), 5)
configuration.prepare.assert_not_awaited()
configuration.prepare.assert_awaited_once()
configuration.commit.assert_not_awaited()
assert manager.current_generation_id == 1
assert not writer.done()
@ -73,6 +73,34 @@ async def test_integration_write_finishes_before_configuration_publication(
await runtime.close()
@pytest.mark.asyncio
async def test_credential_validation_does_not_block_integrations(tmp_path, monkeypatch):
manager = ProviderRuntimeManager(_settings("nvidia_nim/old"))
configuration = AsyncMock(spec=ConfigurationService)
configuration.prepare.return_value = _prepared(
_settings("nvidia_nim/new"), tmp_path
)
runtime = ApplicationRuntime(manager, configuration=configuration, transcriber=None)
entered, release = asyncio.Event(), asyncio.Event()
async def check(*_args):
entered.set()
await release.wait()
return ()
monkeypatch.setattr("free_claude_code.runtime.application.check_credentials", check)
apply = asyncio.create_task(runtime.apply_admin_config({}))
try:
await asyncio.wait_for(entered.wait(), 1)
assert (await asyncio.wait_for(runtime.connect_claude_vscode(), 1))["connected"]
assert not apply.done()
configuration.commit.assert_not_awaited()
finally:
apply.cancel()
await asyncio.gather(apply, return_exceptions=True)
await runtime.close()
@pytest.mark.asyncio
async def test_awaitable_restart_callback_is_not_executed_under_apply_lock(tmp_path):
manager = ProviderRuntimeManager(_settings("nvidia_nim/old"))

View file

@ -11,6 +11,11 @@ from ruamel.yaml import YAML
from free_claude_code.application.model_metadata import ProviderModelInfo
from free_claude_code.harnesses import dsh_desktop_integration as desktop
from tests.runtime.catalog_support import (
finish_discovery,
finish_publication,
pause_next_write,
)
from tests.runtime.test_integration_startup import runtime as runtime
@ -42,7 +47,7 @@ def route():
async def settle(runtime):
await runtime.provider_manager.wait_for_catalog()
await finish_discovery(runtime.provider_manager)
task = runtime._integrations._dsh_update.task
if task:
await asyncio.wait_for(asyncio.shield(task), 5)
@ -66,7 +71,7 @@ async def test_late_catalog_updates_and_disconnect_remains_removed(connected):
runtime.provider_manager.cache_model_infos(
"nvidia_nim", (ProviderModelInfo("two"),)
)
await asyncio.gather(*runtime.provider_manager._publications)
await finish_publication(runtime.provider_manager)
await settle(runtime)
assert {model["id"] for model in route()["models"]} == {
"nvidia_nim/one",
@ -80,19 +85,13 @@ async def test_late_catalog_updates_and_disconnect_remains_removed(connected):
@pytest.mark.asyncio
async def test_disconnect_during_catalog_wait_cannot_be_reconnected(
async def test_disconnect_during_write_wait_cannot_be_reconnected(
connected, monkeypatch
):
runtime = connected
entered, release = asyncio.Event(), asyncio.Event()
original = runtime.provider_manager.wait_for_catalog
async def held():
entered.set()
await release.wait()
return await original()
monkeypatch.setattr(runtime.provider_manager, "wait_for_catalog", held)
entered, release = pause_next_write(
monkeypatch, runtime._integrations._dsh_update.access
)
try:
await runtime.refresh_dsh_desktop()
await asyncio.wait_for(entered.wait(), 5)
@ -111,7 +110,7 @@ async def test_status_and_disconnect_do_not_need_catalog(connected, monkeypatch)
runtime = connected
monkeypatch.setattr(
runtime.provider_manager,
"wait_for_catalog",
"catalog_snapshot",
AsyncMock(side_effect=AssertionError("must not wait")),
)
assert (await runtime.dsh_desktop_status())["connected"]
@ -121,20 +120,9 @@ async def test_status_and_disconnect_do_not_need_catalog(connected, monkeypatch)
@pytest.mark.asyncio
async def test_new_generation_replaces_old_url_and_credential(connected, monkeypatch):
runtime = connected
entered, release = asyncio.Event(), asyncio.Event()
original = runtime.provider_manager.wait_for_catalog
calls = 0
async def held():
nonlocal calls
snapshot = await original()
calls += 1
if calls == 1:
entered.set()
await release.wait()
return snapshot
monkeypatch.setattr(runtime.provider_manager, "wait_for_catalog", held)
entered, release = pause_next_write(
monkeypatch, runtime._integrations._dsh_update.access
)
try:
await runtime.refresh_dsh_desktop()
await asyncio.wait_for(entered.wait(), 5)
@ -179,7 +167,7 @@ async def test_catalog_change_during_write_is_coalesced_without_loss(
runtime.provider_manager.cache_model_infos(
"nvidia_nim", (ProviderModelInfo("late"),)
)
await asyncio.gather(*runtime.provider_manager._publications)
await finish_publication(runtime.provider_manager)
finally:
release.set()
await settle(runtime)
@ -199,21 +187,15 @@ async def test_startup_without_connection_does_not_create_native_profile(runtime
@pytest.mark.asyncio
async def test_first_configure_waiting_for_catalog_is_superseded_by_disconnect(
async def test_first_configure_waiting_for_ownership_is_superseded_by_disconnect(
runtime, monkeypatch
):
install()
await runtime.start()
await settle(runtime)
entered, release = asyncio.Event(), asyncio.Event()
original = runtime.provider_manager.wait_for_catalog
async def held():
entered.set()
await release.wait()
return await original()
monkeypatch.setattr(runtime.provider_manager, "wait_for_catalog", held)
entered, release = pause_next_write(
monkeypatch, runtime._integrations._dsh_update.access
)
task = asyncio.create_task(runtime.connect_dsh_desktop())
try:
await asyncio.wait_for(entered.wait(), 5)

View file

@ -276,7 +276,6 @@ async def test_shutdown_drains_integration_writer(runtime, monkeypatch):
close = asyncio.create_task(runtime.close())
await asyncio.sleep(0)
assert not close.done()
assert runtime._config_lock.locked()
release.set()
assert await asyncio.wait_for(close, 5)
finally:

View file

@ -9,9 +9,51 @@ from free_claude_code.application.errors import ApplicationUnavailableError
from free_claude_code.harnesses import claude_integration as claude
from free_claude_code.harnesses import claude_onboarding
from free_claude_code.harnesses import vscode_chat_integration as vscode
from tests.runtime.catalog_support import finish_discovery
from tests.runtime.test_integration_startup import runtime as runtime
@pytest.mark.asyncio
async def test_slow_integration_writer_does_not_block_another(runtime, monkeypatch):
entered, release = asyncio.Event(), threading.Event()
loop = asyncio.get_running_loop()
def held(*_args, **_kwargs):
loop.call_soon_threadsafe(entered.set)
assert release.wait(5)
return {"connected": True}
monkeypatch.setattr(vscode, "configure", held)
first = asyncio.create_task(runtime.connect_vscode_chat())
try:
await asyncio.wait_for(entered.wait(), 1)
result = await asyncio.wait_for(runtime.disconnect_claude_vscode(), 1)
assert result["connected"] is False
assert not first.done()
finally:
release.set()
await asyncio.gather(first, return_exceptions=True)
await runtime.close()
@pytest.mark.asyncio
async def test_queued_integration_writer_does_not_block_another(runtime):
update = runtime._integrations._vscode_update
first = None
try:
async with update.access.read():
first = asyncio.create_task(runtime.disconnect_vscode_chat())
while not update.access._waiting_writers:
await asyncio.sleep(0)
result = await asyncio.wait_for(runtime.disconnect_claude_vscode(), 1)
assert result["connected"] is False
assert not first.done()
finally:
if first is not None:
await asyncio.gather(first, return_exceptions=True)
await runtime.close()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"other",
@ -76,7 +118,7 @@ async def test_cancelled_reader_drains_before_writer_enters(runtime, monkeypatch
await asyncio.sleep(0)
reader.cancel()
writer = asyncio.create_task(runtime.connect_vscode_chat())
await runtime.provider_manager.wait_for_catalog()
await finish_discovery(runtime.provider_manager)
await asyncio.sleep(0)
assert not reader.done()
assert not writer_entered.is_set()
@ -208,7 +250,6 @@ async def test_cancelled_onboarding_write_is_drained_before_shutdown(
close = asyncio.create_task(runtime.close())
await asyncio.sleep(0)
assert not writer.done() and not close.done()
assert runtime._config_lock.locked()
release.set()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(writer, 5)

View file

@ -22,6 +22,7 @@ from free_claude_code.providers.model_listing import ModelListResponseError
from free_claude_code.providers.runtime import ProviderRuntime
from free_claude_code.runtime import provider_manager
from free_claude_code.runtime.provider_manager import ProviderRuntimeManager
from tests.runtime.catalog_support import finish_discovery, finish_publication
LOCAL_IDS = ("lmstudio", "llamacpp", "ollama")
@ -62,9 +63,10 @@ async def test_startup_discovers_unselected_locals_once_and_publishes_them():
manager = manager_for(providers=providers, model_catalog_publisher=publisher)
try:
manager.start_model_list_refresh()
await manager.wait_for_catalog()
await finish_discovery(manager)
manager.start_model_list_refresh()
await manager.wait_for_catalog()
await finish_discovery(manager)
await finish_publication(manager)
assert set(LOCAL_IDS) <= manager.cached_model_ids().keys()
assert all(
value.list_model_infos.await_count == 1 for value in providers.values()
@ -98,7 +100,7 @@ async def test_cancelled_local_waiter_preserves_probe_and_refresh_recovers_inven
with pytest.raises(asyncio.CancelledError):
await waiter
release.set()
await manager.wait_for_catalog()
await finish_discovery(manager)
assert local.list_model_infos.await_count == 1
assert manager.cached_model_ids()["ollama"] == {"installed"}
local.list_model_infos.side_effect = ConnectionError()
@ -317,7 +319,7 @@ async def test_local_endpoint_change_discards_old_inventory_on_failed_rediscover
update={attr: "http://localhost:9999"}
)
await manager.replace(settings, commit=AsyncMock())
await manager.wait_for_catalog()
await finish_discovery(manager)
assert local_id not in manager.cached_model_ids()
assert local_id + "/old-server-model" not in {
model.provider_model_ref for model in read_model_catalog(manager).models

View file

@ -22,6 +22,7 @@ from free_claude_code.runtime.application import ApplicationRuntime
from free_claude_code.runtime.codex_catalog import CodexModelCatalogPublisher
from free_claude_code.runtime.configuration import ConfigurationService
from free_claude_code.runtime.provider_manager import ProviderRuntimeManager
from tests.runtime.catalog_support import finish_discovery
from tests.web_tools_support import StubWebToolsClient
@ -326,7 +327,7 @@ async def test_late_retired_discovery_cannot_replace_current_metadata():
@pytest.mark.asyncio
async def test_complete_catalog_publication_preserves_prior_file_until_all_attempts(
async def test_catalog_publication_exposes_configured_models_before_discovery(
tmp_path,
):
entered, release = asyncio.Event(), asyncio.Event()
@ -353,27 +354,26 @@ async def test_complete_catalog_publication_preserves_prior_file_until_all_attem
),
model_catalog_publisher=CodexModelCatalogPublisher(path),
)
waiter = asyncio.create_task(manager.wait_for_catalog())
manager.start_model_list_refresh()
try:
await entered.wait()
assert not waiter.done()
assert path.read_text(encoding="utf-8") == "prior complete catalog"
await manager.wait_for_catalog_file(InitializationWait())
assert "nvidia_nim/one" in path.read_text(encoding="utf-8")
assert "new-model" not in path.read_text(encoding="utf-8")
release.set()
catalog = await waiter
await finish_discovery(manager)
await manager.wait_for_catalog_file(InitializationWait())
catalog = manager.catalog_snapshot()
assert catalog.cached_model_info("nvidia_nim", "new-model") is not None
assert "new-model" in path.read_text(encoding="utf-8")
finally:
release.set()
await asyncio.gather(waiter, return_exceptions=True)
await manager.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("retained_request", [False, True])
@pytest.mark.parametrize("catalog_file", [False, True])
async def test_catalog_wait_follows_replacement(
tmp_path, retained_request, catalog_file
):
async def test_catalog_wait_follows_replacement(tmp_path, retained_request):
entered, release = asyncio.Event(), asyncio.Event()
count = 0
@ -410,25 +410,14 @@ async def test_catalog_wait_follows_replacement(
request = (
asyncio.create_task(lease.resolve_provider("nvidia_nim")) if lease else None
)
waiting = asyncio.create_task(
manager.wait_for_catalog_file(InitializationWait())
if catalog_file
else manager.wait_for_catalog()
)
waiting = asyncio.create_task(manager.wait_for_model_catalog("nvidia_nim/one"))
try:
await entered.wait()
settings = _settings().model_copy(update={"model": "nvidia_nim/current"})
settings = _settings().model_copy(update={"proxy_auth_token": "rotated"})
await manager.replace(settings, commit=AsyncMock())
await manager.wait_for_catalog()
result = await asyncio.wait_for(asyncio.shield(waiting), 1)
if catalog_file:
assert isinstance(result, int)
assert result == manager.current_generation_id
assert "nvidia_nim/current" in path.read_text(encoding="utf-8")
else:
assert not isinstance(result, int)
assert result.settings is settings
assert result.cached_model_info("nvidia_nim", "current") is not None
assert result.settings is settings
assert result.cached_model_info("nvidia_nim", "current") is not None
if request is not None:
assert lease is not None
assert not request.done()
@ -447,11 +436,8 @@ async def test_catalog_wait_follows_replacement(
@pytest.mark.asyncio
@pytest.mark.parametrize("catalog_file", [False, True])
@pytest.mark.parametrize("cancel", [False, True])
async def test_catalog_waiter_exit_preserves_shared_discovery(
monkeypatch, catalog_file, cancel
):
async def test_catalog_waiter_exit_preserves_shared_discovery(monkeypatch, cancel):
existing_tasks = asyncio.all_tasks()
entered, release = asyncio.Event(), asyncio.Event()
provider = MagicMock(spec=BaseProvider)
@ -478,11 +464,7 @@ async def test_catalog_waiter_exit_preserves_shared_discovery(
monkeypatch.setattr(
"free_claude_code.runtime.provider_manager.InitializationWait", lambda: wait
)
waiting = asyncio.create_task(
manager.wait_for_catalog_file(wait)
if catalog_file
else manager.wait_for_catalog()
)
waiting = asyncio.create_task(manager.wait_for_model_catalog("nvidia_nim/one"))
try:
await entered.wait()
if cancel:
@ -497,7 +479,7 @@ async def test_catalog_waiter_exit_preserves_shared_discovery(
InitializationWait,
)
release.set()
snapshot = await manager.wait_for_catalog()
snapshot = await manager.wait_for_model_catalog("nvidia_nim/one")
assert snapshot.cached_model_info("nvidia_nim", "one") is not None
finally:
release.set()

View file

@ -14,6 +14,7 @@ from free_claude_code.providers.base import BaseProvider
from free_claude_code.providers.nvidia_nim import NvidiaNimProvider
from free_claude_code.providers.runtime import ProviderRuntime
from free_claude_code.runtime.provider_manager import ProviderRuntimeManager
from tests.runtime.catalog_support import finish_discovery, finish_publication
class FakeRuntime(ProviderRuntime):
@ -129,7 +130,7 @@ async def test_discovery_error_follows_attempt_and_generation_without_losing_cac
await manager.refresh_provider("nvidia_nim")
await manager.replace(settings, commit=AsyncMock())
assert manager.catalog_status()["provider_errors"] == {}
await manager.wait_for_catalog()
await finish_discovery(manager)
assert manager.catalog_status()["provider_errors"] == {}
finally:
await manager.close()
@ -223,7 +224,7 @@ async def test_connected_provider_refresh_and_eviction_are_targeted() -> None:
connected.add("openai")
await manager.connected_provider_changed("openai", connected=True)
await manager.wait_for_catalog()
await finish_discovery(manager)
assert manager.cached_model_ids()["openai"] == frozenset({"gpt-visible"})
connected.clear()
@ -249,6 +250,7 @@ async def test_catalog_publication_tracks_warm_refresh_and_direct_cache() -> Non
)
await manager.refresh_model_list_cache()
await finish_publication(manager)
manager.start_model_list_refresh()
refresh_task = manager._current.refresh_task
assert refresh_task is not None
@ -258,16 +260,11 @@ async def test_catalog_publication_tracks_warm_refresh_and_direct_cache() -> Non
{ProviderModelInfo("tested-model")},
)
await asyncio.gather(*manager._publications)
assert publisher.events == ["publish", "publish", "publish"]
assert publisher.snapshots == [
("nvidia_nim/one", ("nvidia_nim/warm-model",)),
("nvidia_nim/one", ("nvidia_nim/warm-model",)),
("nvidia_nim/one", ("nvidia_nim/tested-model",)),
]
await finish_publication(manager)
assert ("nvidia_nim/one", ("nvidia_nim/warm-model",)) in publisher.snapshots
assert publisher.snapshots[-1] == ("nvidia_nim/one", ("nvidia_nim/tested-model",))
await manager.close()
assert publisher.events == ["publish", "publish", "publish"]
@pytest.mark.asyncio
@ -286,6 +283,7 @@ async def test_failed_startup_discovery_still_ensures_a_fresh_catalog() -> None:
result = await manager.refresh_model_list_cache()
assert result.failed_provider_ids == ("nvidia_nim",)
await finish_publication(manager)
assert publisher.events == ["publish"]
assert publisher.snapshots == [("nvidia_nim/configured", ())]
await manager.close()
@ -302,7 +300,8 @@ async def test_catalog_publication_tracks_connected_account_changes() -> None:
connected_provider_ids=lambda: tuple(connected),
model_catalog_publisher=publisher,
)
await manager.wait_for_catalog()
await finish_discovery(manager)
await finish_publication(manager)
publisher.events.clear()
publisher.snapshots.clear()
factory.runtimes[0].provider.list_model_infos = AsyncMock(
@ -311,16 +310,15 @@ async def test_catalog_publication_tracks_connected_account_changes() -> None:
connected.add("openai")
await manager.connected_provider_changed("openai", connected=True)
await manager.wait_for_catalog()
await finish_discovery(manager)
await finish_publication(manager)
assert publisher.snapshots[-1] == ("nvidia_nim/one", ("openai/gpt-connected",))
connected.clear()
await manager.connected_provider_changed("openai", connected=False)
await manager.wait_for_catalog()
await finish_discovery(manager)
await finish_publication(manager)
assert publisher.events == ["publish", "publish"]
assert publisher.snapshots == [
("nvidia_nim/one", ("openai/gpt-connected",)),
("nvidia_nim/one", ()),
]
assert publisher.snapshots[-1] == ("nvidia_nim/one", ())
await manager.close()
@ -342,6 +340,7 @@ async def test_catalog_publication_tracks_replacement_and_its_refresh() -> None:
assert refresh_task is not None
await refresh_task
await finish_publication(manager)
assert publisher.events == ["publish"]
assert publisher.snapshots == [
("nvidia_nim/two", ()),
@ -363,11 +362,12 @@ async def test_catalog_publication_failure_is_warning_only_and_secret_safe() ->
with patch("free_claude_code.runtime.provider_manager.logger.warning") as warning:
await manager.refresh_model_list_cache()
await finish_publication(manager)
manager.cache_model_infos(
"nvidia_nim",
{ProviderModelInfo("tested-model")},
)
await asyncio.gather(*manager._publications)
await finish_publication(manager)
assert warning.call_count == 2
log_blob = " ".join(

View file

@ -15,6 +15,11 @@ from free_claude_code.providers.runtime.runtime import ProviderRuntime
from free_claude_code.runtime.application import ApplicationRuntime
from free_claude_code.runtime.configuration import ConfigurationService
from free_claude_code.runtime.provider_manager import ProviderRuntimeManager
from tests.runtime.catalog_support import (
finish_discovery,
finish_publication,
pause_next_write,
)
@pytest_asyncio.fixture
@ -58,7 +63,7 @@ async def runtime(request):
async def settle(runtime):
await runtime.provider_manager.wait_for_catalog()
await finish_discovery(runtime.provider_manager)
task = runtime._integrations._vscode_update.task
if task:
await asyncio.wait_for(asyncio.shield(task), 5)
@ -72,7 +77,7 @@ async def test_catalog_updates_connected_file_and_disconnect_stays_removed(runti
runtime.provider_manager.cache_model_infos(
"nvidia_nim", (ProviderModelInfo("two"),)
)
await asyncio.gather(*runtime.provider_manager._publications)
await finish_publication(runtime.provider_manager)
await settle(runtime)
models = json.loads(path.read_text())[0]["models"]
assert {m["id"] for m in models} == {"nvidia_nim/one", "nvidia_nim/two"}
@ -108,7 +113,7 @@ async def test_update_arriving_during_write_is_not_lost(runtime, monkeypatch):
runtime.provider_manager.cache_model_infos(
"nvidia_nim", (ProviderModelInfo("late"),)
)
await asyncio.gather(*runtime.provider_manager._publications)
await finish_publication(runtime.provider_manager)
finally:
release.set()
await settle(runtime)
@ -134,7 +139,7 @@ async def test_bad_file_does_not_fail_catalog_and_disconnect_needs_no_catalog(
await runtime.connect_vscode_chat()
monkeypatch.setattr(
runtime.provider_manager,
"wait_for_catalog",
"catalog_snapshot",
AsyncMock(side_effect=AssertionError("Must not wait")),
)
assert (await runtime.disconnect_vscode_chat())["connected"] is False
@ -164,20 +169,14 @@ async def test_startup_refreshes_owned_group_and_only_writes_once(runtime):
@pytest.mark.asyncio
async def test_disconnect_while_refresh_waits_for_catalog_never_reconnects(
async def test_disconnect_while_refresh_waits_for_ownership_never_reconnects(
runtime, monkeypatch
):
await runtime.connect_vscode_chat()
await settle(runtime)
entered, release = asyncio.Event(), asyncio.Event()
original = runtime.provider_manager.wait_for_catalog
async def held():
entered.set()
await release.wait()
return await original()
monkeypatch.setattr(runtime.provider_manager, "wait_for_catalog", held)
entered, release = pause_next_write(
monkeypatch, runtime._integrations._vscode_update.access
)
try:
await runtime.refresh_vscode_chat()
await asyncio.wait_for(entered.wait(), 5)
@ -194,20 +193,9 @@ async def test_replaced_generation_cannot_publish_old_url_or_token(
):
await runtime.connect_vscode_chat()
await settle(runtime)
entered, release = asyncio.Event(), asyncio.Event()
original = runtime.provider_manager.wait_for_catalog
calls = 0
async def held():
nonlocal calls
snapshot = await original()
calls += 1
if calls == 1:
entered.set()
await release.wait()
return snapshot
monkeypatch.setattr(runtime.provider_manager, "wait_for_catalog", held)
entered, release = pause_next_write(
monkeypatch, runtime._integrations._vscode_update.access
)
try:
await runtime.refresh_vscode_chat()
await asyncio.wait_for(entered.wait(), 5)
@ -238,7 +226,7 @@ async def test_codex_publication_failure_does_not_block_vscode(runtime):
runtime.provider_manager.cache_model_infos(
"nvidia_nim", (ProviderModelInfo("new"),)
)
await asyncio.gather(*runtime.provider_manager._publications)
await finish_publication(runtime.provider_manager)
await settle(runtime)
assert runtime.provider_manager.catalog_status()["catalog_file"] == "failed"
assert runtime._integrations._vscode_update.state == "ready"