mirror of
https://github.com/Alishahryar1/free-claude-code.git
synced 2026-10-19 07:23:36 +00:00
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
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:
parent
261b12f964
commit
949ccfe6d5
28 changed files with 1141 additions and 290 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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: ...
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
37
tests/runtime/catalog_support.py
Normal file
37
tests/runtime/catalog_support.py
Normal 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
|
||||
420
tests/runtime/test_catalog_independence.py
Normal file
420
tests/runtime/test_catalog_independence.py
Normal 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())
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue