LastNoob commited on
Commit
0ff89da
·
unverified ·
1 Parent(s): 9ae5701

Add searchable model selection to the Admin UI (#1121)

Browse files

## Problem

Admin model routing fields required users to construct provider-prefixed
model slugs. Optional tier overrides represented inheritance as an
unexplained blank value.

## Changes

| Before | After |
| --- | --- |
| Model inputs only gained suggestions after an individual provider
refresh. | Model inputs load configured and discovered canonical slugs
from one Admin catalog. |
| Model routing looked like unrestricted text entry. | Model routing
uses the browser's searchable model dropdown while retaining manual
entry. |
| Tier overrides displayed an empty value for fallback routing. | Tier
overrides display **None** and persist it as an unset override. |
| Model refresh returned provider-shaped cache internals. | Model
refresh returns the same canonical catalog consumed by the Admin UI. |
| Users inferred the provider/model slug format from examples. | The
Admin UI and README define and present complete provider/model slugs. |

<!-- greptile_comment -->

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

This PR adds searchable model selection to the Admin UI. The main
changes are:

- Adds a canonical catalog of configured and discovered model slugs.
- Adds searchable model inputs while preserving manual entry.
- Represents unset tier overrides as **None**.
- Reports provider-specific model refresh failures.
- Reconciles cached models when provider settings change.
- Updates documentation, package metadata, and tests.
</details>

<h3>Confidence Score: 5/5</h3>

This looks safe to merge.

Catalog failures no longer stop the rest of the Admin UI from loading.
Partial provider refreshes now produce a visible warning. Removed
credential-backed providers are pruned from the shared cache, and later
stale writes are rejected. No blocking issues remain in the changed
code.

<details><summary><h3><a href="https://www.greptile.com/trex"><img
alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="20" align="absmiddle"></a> T-Rex Logs</h3></summary>

**What T-Rex did**
- T-Rex ran the requested verification for the pull request checks.
- The verification completed, but local artifact references were not
uploaded.

<sub><a href="https://www.greptile.com/trex"><img alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="14" align="absmiddle"></a> Ran code and verified through
T-Rex</sub>
</details>

<details open><summary><h3>Important Files Changed</h3></summary>

| Filename | Overview |
|----------|----------|
| src/free_claude_code/api/admin_static/admin.js | Adds searchable model
fields, optional catalog hydration, refresh warnings, and None-to-unset
conversion. |
| src/free_claude_code/api/admin_routes.py | Adds canonical model
catalog endpoints and provider refresh failure metadata. |
| src/free_claude_code/providers/runtime/discovery.py | Tracks provider
refresh outcomes and separates cache eligibility from discovery
eligibility. |
| src/free_claude_code/providers/runtime/model_cache.py | Scopes cached
model metadata to currently available providers and removes stale remote
entries. |
| src/free_claude_code/runtime/provider_manager.py | Reconciles cache
scope during runtime replacement and returns explicit refresh results. |

</details>

<sub>Reviews (2): Last reviewed commit: ["Fix model catalog refresh
lifecycle"](https://github.com/alishahryar1/free-claude-code/commit/d16e170055f5389e538e18dece269a5f7a8c599d)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=44373795)</sub>

<!-- /greptile_comment -->

README.md CHANGED
@@ -108,7 +108,7 @@ Use the port shown in your terminal if it differs from `8082`.
108
  1. Create an API key at [build.nvidia.com/settings/api-keys](https://build.nvidia.com/settings/api-keys).
109
  2. Open the Admin UI URL from the server log.
110
  3. Paste the key into `NVIDIA_NIM_API_KEY`.
111
- 4. Leave `MODEL` on the default `nvidia_nim/nvidia/nemotron-3-super-120b-a12b`, or select another model.
112
  5. Click **Validate**, then **Apply**.
113
 
114
  <div align="center">
@@ -145,7 +145,7 @@ fcc-codex exec "hello"
145
 
146
  ## Choose A Provider
147
 
148
- Enter the listed setting in the Admin UI, set `MODEL` to a provider-prefixed model ID, then click **Validate** and **Apply**. Provider names link to their key, model, or setup pages.
149
 
150
  | Provider | Admin UI setting | Example `MODEL` |
151
  | --- | --- | --- |
@@ -209,7 +209,7 @@ Use the tag shown by `ollama list` with the `ollama/` prefix. `OLLAMA_BASE_URL`
209
 
210
  ### Optional Model-Tier Routing
211
 
212
- `MODEL` is the fallback for every request. Set `MODEL_FABLE`, `MODEL_OPUS`, `MODEL_SONNET`, or `MODEL_HAIKU` to override individual Claude Code tiers; leave a tier blank to inherit `MODEL`.
213
 
214
  For example, route Opus to `nvidia_nim/moonshotai/kimi-k2.6`, Sonnet to `open_router/openrouter/free`, Haiku to `lmstudio/qwen3.5-coder`, and keep `MODEL` on `zai/glm-5.2`.
215
 
 
108
  1. Create an API key at [build.nvidia.com/settings/api-keys](https://build.nvidia.com/settings/api-keys).
109
  2. Open the Admin UI URL from the server log.
110
  3. Paste the key into `NVIDIA_NIM_API_KEY`.
111
+ 4. Leave `MODEL` on the default `nvidia_nim/nvidia/nemotron-3-super-120b-a12b`, or search the model dropdown and select another model.
112
  5. Click **Validate**, then **Apply**.
113
 
114
  <div align="center">
 
145
 
146
  ## Choose A Provider
147
 
148
+ Enter the listed setting in the Admin UI, open **Model Config**, then search the `MODEL` dropdown and select a model. FCC constructs each slug as `<provider-id>/<exact-provider-model-id>`; free-text entry remains available when a provider cannot list a model. Click **Validate** and **Apply**. Provider names link to their key, model, or setup pages.
149
 
150
  | Provider | Admin UI setting | Example `MODEL` |
151
  | --- | --- | --- |
 
209
 
210
  ### Optional Model-Tier Routing
211
 
212
+ `MODEL` is the fallback for every request. Select a model for `MODEL_FABLE`, `MODEL_OPUS`, `MODEL_SONNET`, or `MODEL_HAIKU` to override an individual Claude Code tier; select **None** to use `MODEL`.
213
 
214
  For example, route Opus to `nvidia_nim/moonshotai/kimi-k2.6`, Sonnet to `open_router/openrouter/free`, Haiku to `lmstudio/qwen3.5-coder`, and keep `MODEL` on `zai/glm-5.2`.
215
 
pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
 
5
  [project]
6
  name = "free-claude-code"
7
- version = "4.5.2"
8
  description = "Local proxy connecting coding agents to OpenAI-compatible AI providers"
9
  readme = "README.md"
10
  requires-python = ">=3.14.0"
 
4
 
5
  [project]
6
  name = "free-claude-code"
7
+ version = "4.6.1"
8
  description = "Local proxy connecting coding agents to OpenAI-compatible AI providers"
9
  readme = "README.md"
10
  requires-python = ">=3.14.0"
src/free_claude_code/api/admin_routes.py CHANGED
@@ -10,9 +10,11 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
10
  from fastapi.responses import FileResponse
11
  from pydantic import BaseModel, Field
12
 
 
13
  from free_claude_code.config.admin.manifest import FIELD_BY_KEY
14
  from free_claude_code.config.admin.persistence import validate_updates
15
  from free_claude_code.config.admin.values import load_config_response
 
16
 
17
  from .dependencies import get_services
18
  from .ports import ApiServices
@@ -143,13 +145,44 @@ async def test_provider(
143
  return await services.admin.test_provider(provider_id)
144
 
145
 
 
 
 
 
 
 
 
 
 
146
  @router.post("/admin/api/models/refresh")
147
  async def refresh_models(
148
  request: Request,
149
  services: ApiServices = Depends(get_services),
150
  ):
151
  require_loopback_admin(request)
152
- return await services.admin.refresh_models()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
 
155
  def _filtered_values(values: dict[str, Any]) -> dict[str, Any]:
 
10
  from fastapi.responses import FileResponse
11
  from pydantic import BaseModel, Field
12
 
13
+ from free_claude_code.application.model_metadata import ProviderModelRefreshResult
14
  from free_claude_code.config.admin.manifest import FIELD_BY_KEY
15
  from free_claude_code.config.admin.persistence import validate_updates
16
  from free_claude_code.config.admin.values import load_config_response
17
+ from free_claude_code.config.model_refs import configured_chat_model_refs
18
 
19
  from .dependencies import get_services
20
  from .ports import ApiServices
 
145
  return await services.admin.test_provider(provider_id)
146
 
147
 
148
+ @router.get("/admin/api/models")
149
+ async def models(
150
+ request: Request,
151
+ services: ApiServices = Depends(get_services),
152
+ ):
153
+ require_loopback_admin(request)
154
+ return _model_options(services)
155
+
156
+
157
  @router.post("/admin/api/models/refresh")
158
  async def refresh_models(
159
  request: Request,
160
  services: ApiServices = Depends(get_services),
161
  ):
162
  require_loopback_admin(request)
163
+ result = await services.admin.refresh_models()
164
+ return _model_options(services, refresh_result=result)
165
+
166
+
167
+ def _model_options(
168
+ services: ApiServices,
169
+ *,
170
+ refresh_result: ProviderModelRefreshResult | None = None,
171
+ ) -> dict[str, list[str]]:
172
+ configured = {
173
+ ref.model_ref
174
+ for ref in configured_chat_model_refs(services.requests.current_settings())
175
+ }
176
+ discovered = {
177
+ info.model_id for info in services.requests.cached_prefixed_model_infos()
178
+ }
179
+ failed_provider_ids = (
180
+ refresh_result.failed_provider_ids if refresh_result is not None else ()
181
+ )
182
+ return {
183
+ "models": sorted(configured | discovered, key=str.casefold),
184
+ "failed_providers": list(failed_provider_ids),
185
+ }
186
 
187
 
188
  def _filtered_values(values: dict[str, Any]) -> dict[str, Any]:
src/free_claude_code/api/admin_static/admin.css CHANGED
@@ -494,21 +494,6 @@ textarea:focus-visible,
494
  padding-right: 36px;
495
  }
496
 
497
- /* Custom Dropdown Styling for Datalist Input Elements (Model Routing) */
498
- .field input[list] {
499
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%239ca3af' stroke-width='2.5'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M19.5 8.25l-7.5 7.5-7.5-7.5'/%3E%3C/svg%3E");
500
- background-repeat: no-repeat;
501
- background-position: right 14px center;
502
- background-size: 14px;
503
- padding-right: 36px;
504
- }
505
-
506
- /* Hide WebKit's native picker indicator to avoid double arrow icons */
507
- .field input[list]::-webkit-calendar-picker-indicator {
508
- display: none !important;
509
- }
510
-
511
-
512
  .field input:focus,
513
  .field select:focus,
514
  .field textarea:focus {
@@ -628,6 +613,11 @@ textarea:focus-visible,
628
  font-weight: 600;
629
  }
630
 
 
 
 
 
 
631
  .action-buttons {
632
  display: flex;
633
  gap: 12px;
 
494
  padding-right: 36px;
495
  }
496
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
497
  .field input:focus,
498
  .field select:focus,
499
  .field textarea:focus {
 
613
  font-weight: 600;
614
  }
615
 
616
+ .message-area.warn {
617
+ color: var(--warn);
618
+ font-weight: 600;
619
+ }
620
+
621
  .action-buttons {
622
  display: flex;
623
  gap: 12px;
src/free_claude_code/api/admin_static/admin.js CHANGED
@@ -84,6 +84,7 @@ async function load() {
84
  renderProviders(config.provider_status);
85
  renderSections(config.sections, config.fields);
86
  byId("configPath").textContent = config.paths.managed;
 
87
  await validate(false);
88
  await refreshLocalStatus();
89
  updateDirtyState();
@@ -210,6 +211,14 @@ function renderSections(sections, fields) {
210
  const heading = document.createElement("div");
211
  heading.className = "section-heading";
212
  heading.innerHTML = `<div><h3>${section.label}</h3><p>${section.description}</p></div>`;
 
 
 
 
 
 
 
 
213
  sectionEl.appendChild(heading);
214
 
215
  const grid = document.createElement("div");
@@ -261,9 +270,18 @@ function renderField(field) {
261
  input.dataset.original = field.value || "";
262
  input.dataset.secret = field.secret ? "true" : "false";
263
  input.dataset.configured = field.configured ? "true" : "false";
 
264
  input.disabled = field.locked;
265
  input.addEventListener("input", updateDirtyState);
266
  input.addEventListener("change", updateDirtyState);
 
 
 
 
 
 
 
 
267
 
268
  wrapper.append(label, input);
269
  if (field.description) {
@@ -308,6 +326,18 @@ function inputForField(field) {
308
  return textarea;
309
  }
310
 
 
 
 
 
 
 
 
 
 
 
 
 
311
  const input = document.createElement("input");
312
  input.type = field.type === "number" ? "number" : "text";
313
  if (field.type === "secret") {
@@ -320,9 +350,6 @@ function inputForField(field) {
320
  } else {
321
  input.value = field.value || "";
322
  }
323
- if (field.key.startsWith("MODEL")) {
324
- input.setAttribute("list", "model-options");
325
- }
326
  return input;
327
  }
328
 
@@ -335,6 +362,12 @@ function option(value, label) {
335
 
336
  function readFieldValue(input) {
337
  if (input.type === "checkbox") return input.checked ? "true" : "false";
 
 
 
 
 
 
338
  if (input.dataset.secret === "true" && input.dataset.configured === "true") {
339
  return input.value ? input.value : MASKED_SECRET;
340
  }
@@ -434,13 +467,10 @@ async function testProvider(providerId, button) {
434
  `${result.models.length} models`,
435
  result.models.slice(0, 3).join(", ") || "No models returned",
436
  );
437
- state.modelOptions = Array.from(
438
- new Set([
439
- ...state.modelOptions,
440
- ...result.models.map((model) => `${providerId}/${model}`),
441
- ]),
442
- ).sort();
443
- syncModelDatalist();
444
  } else {
445
  updateProviderCard(providerId, "offline", result.error_type, result.error_type);
446
  }
@@ -450,15 +480,74 @@ async function testProvider(providerId, button) {
450
  }
451
  }
452
 
453
- function syncModelDatalist() {
454
- let datalist = byId("model-options");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
455
  if (!datalist) {
456
  datalist = document.createElement("datalist");
457
- datalist.id = "model-options";
458
  document.body.appendChild(datalist);
459
  }
460
  datalist.innerHTML = "";
461
- state.modelOptions.forEach((model) => datalist.appendChild(option(model, model)));
 
 
 
 
 
462
  }
463
 
464
  function showMessage(message, kind = "") {
 
84
  renderProviders(config.provider_status);
85
  renderSections(config.sections, config.fields);
86
  byId("configPath").textContent = config.paths.managed;
87
+ await hydrateModelOptions();
88
  await validate(false);
89
  await refreshLocalStatus();
90
  updateDirtyState();
 
211
  const heading = document.createElement("div");
212
  heading.className = "section-heading";
213
  heading.innerHTML = `<div><h3>${section.label}</h3><p>${section.description}</p></div>`;
214
+ if (section.id === "models") {
215
+ const refreshButton = document.createElement("button");
216
+ refreshButton.type = "button";
217
+ refreshButton.className = "secondary-button";
218
+ refreshButton.textContent = "Refresh models";
219
+ refreshButton.addEventListener("click", () => refreshModelOptions(refreshButton));
220
+ heading.appendChild(refreshButton);
221
+ }
222
  sectionEl.appendChild(heading);
223
 
224
  const grid = document.createElement("div");
 
270
  input.dataset.original = field.value || "";
271
  input.dataset.secret = field.secret ? "true" : "false";
272
  input.dataset.configured = field.configured ? "true" : "false";
273
+ input.dataset.fieldType = field.type;
274
  input.disabled = field.locked;
275
  input.addEventListener("input", updateDirtyState);
276
  input.addEventListener("change", updateDirtyState);
277
+ if (field.type === "optional_model") {
278
+ input.addEventListener("blur", () => {
279
+ if (!input.value.trim() || input.value.trim().toLowerCase() === "none") {
280
+ input.value = "None";
281
+ updateDirtyState();
282
+ }
283
+ });
284
+ }
285
 
286
  wrapper.append(label, input);
287
  if (field.description) {
 
326
  return textarea;
327
  }
328
 
329
+ if (field.type === "model" || field.type === "optional_model") {
330
+ const input = document.createElement("input");
331
+ input.type = "text";
332
+ input.value = field.value || (field.type === "optional_model" ? "None" : "");
333
+ input.setAttribute(
334
+ "list",
335
+ field.type === "optional_model" ? "optional-model-options" : "model-options",
336
+ );
337
+ input.autocomplete = "off";
338
+ return input;
339
+ }
340
+
341
  const input = document.createElement("input");
342
  input.type = field.type === "number" ? "number" : "text";
343
  if (field.type === "secret") {
 
350
  } else {
351
  input.value = field.value || "";
352
  }
 
 
 
353
  return input;
354
  }
355
 
 
362
 
363
  function readFieldValue(input) {
364
  if (input.type === "checkbox") return input.checked ? "true" : "false";
365
+ if (
366
+ input.dataset.fieldType === "optional_model" &&
367
+ input.value.trim().toLowerCase() === "none"
368
+ ) {
369
+ return "";
370
+ }
371
  if (input.dataset.secret === "true" && input.dataset.configured === "true") {
372
  return input.value ? input.value : MASKED_SECRET;
373
  }
 
467
  `${result.models.length} models`,
468
  result.models.slice(0, 3).join(", ") || "No models returned",
469
  );
470
+ setModelOptions([
471
+ ...state.modelOptions,
472
+ ...result.models.map((model) => `${providerId}/${model}`),
473
+ ]);
 
 
 
474
  } else {
475
  updateProviderCard(providerId, "offline", result.error_type, result.error_type);
476
  }
 
480
  }
481
  }
482
 
483
+ async function hydrateModelOptions() {
484
+ try {
485
+ await loadModelOptions();
486
+ } catch {
487
+ // Model fields remain editable when optional catalog hydration is unavailable.
488
+ }
489
+ }
490
+
491
+ async function loadModelOptions(refresh = false) {
492
+ const result = await api("/admin/api/models" + (refresh ? "/refresh" : ""), {
493
+ method: refresh ? "POST" : "GET",
494
+ });
495
+ setModelOptions(result.models);
496
+ return result;
497
+ }
498
+
499
+ async function refreshModelOptions(button) {
500
+ const original = button.textContent;
501
+ button.disabled = true;
502
+ button.textContent = "Refreshing";
503
+ try {
504
+ const result = await loadModelOptions(true);
505
+ const failedProviders = result.failed_providers || [];
506
+ if (failedProviders.length) {
507
+ const labels = failedProviders.map(providerDisplayName).join(", ");
508
+ showMessage(
509
+ `${state.modelOptions.length} models available; could not refresh ${labels}`,
510
+ "warn",
511
+ );
512
+ } else {
513
+ showMessage(`${state.modelOptions.length} models available`, "ok");
514
+ }
515
+ } catch (error) {
516
+ showMessage(`Could not refresh models: ${error.message}`, "error");
517
+ } finally {
518
+ button.disabled = false;
519
+ button.textContent = original;
520
+ }
521
+ }
522
+
523
+ function providerDisplayName(providerId) {
524
+ const provider = state.config?.provider_status?.find(
525
+ (candidate) => candidate.provider_id === providerId,
526
+ );
527
+ return provider?.display_name || providerId;
528
+ }
529
+
530
+ function setModelOptions(models) {
531
+ state.modelOptions = Array.from(
532
+ new Set(models.filter((model) => typeof model === "string" && model.trim())),
533
+ ).sort((left, right) => left.localeCompare(right));
534
+ syncModelDatalists();
535
+ }
536
+
537
+ function syncModelDatalist(id, values) {
538
+ let datalist = byId(id);
539
  if (!datalist) {
540
  datalist = document.createElement("datalist");
541
+ datalist.id = id;
542
  document.body.appendChild(datalist);
543
  }
544
  datalist.innerHTML = "";
545
+ values.forEach((model) => datalist.appendChild(option(model, model)));
546
+ }
547
+
548
+ function syncModelDatalists() {
549
+ syncModelDatalist("model-options", state.modelOptions);
550
+ syncModelDatalist("optional-model-options", ["None", ...state.modelOptions]);
551
  }
552
 
553
  function showMessage(message, kind = "") {
src/free_claude_code/api/ports.py CHANGED
@@ -4,6 +4,7 @@ from collections.abc import Mapping
4
  from dataclasses import dataclass
5
  from typing import Any, Protocol
6
 
 
7
  from free_claude_code.application.ports import RequestRuntimePort, TaskController
8
 
9
 
@@ -18,7 +19,7 @@ class AdminRuntimePort(Protocol):
18
 
19
  async def test_provider(self, provider_id: str) -> dict[str, Any]: ...
20
 
21
- async def refresh_models(self) -> dict[str, Any]: ...
22
 
23
  async def request_restart(self) -> None: ...
24
 
 
4
  from dataclasses import dataclass
5
  from typing import Any, Protocol
6
 
7
+ from free_claude_code.application.model_metadata import ProviderModelRefreshResult
8
  from free_claude_code.application.ports import RequestRuntimePort, TaskController
9
 
10
 
 
19
 
20
  async def test_provider(self, provider_id: str) -> dict[str, Any]: ...
21
 
22
+ async def refresh_models(self) -> ProviderModelRefreshResult: ...
23
 
24
  async def request_restart(self) -> None: ...
25
 
src/free_claude_code/application/model_metadata.py CHANGED
@@ -9,3 +9,11 @@ class ProviderModelInfo:
9
 
10
  model_id: str
11
  supports_thinking: bool | None = None
 
 
 
 
 
 
 
 
 
9
 
10
  model_id: str
11
  supports_thinking: bool | None = None
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class ProviderModelRefreshResult:
16
+ """Per-provider outcome of one model-catalog refresh."""
17
+
18
+ refreshed_provider_ids: tuple[str, ...] = ()
19
+ failed_provider_ids: tuple[str, ...] = ()
src/free_claude_code/config/admin/manifest.py CHANGED
@@ -14,6 +14,8 @@ FieldType = Literal[
14
  "number",
15
  "boolean",
16
  "tri_boolean",
 
 
17
  "select",
18
  "textarea",
19
  ]
@@ -56,7 +58,7 @@ SECTIONS: tuple[ConfigSectionSpec, ...] = (
56
  ConfigSectionSpec(
57
  "models",
58
  "Model Routing",
59
- "Provider-prefixed models used for Claude model tiers.",
60
  ),
61
  ConfigSectionSpec(
62
  "thinking",
@@ -103,6 +105,7 @@ _NON_PROVIDER_FIELDS: tuple[ConfigFieldSpec, ...] = (
103
  "MODEL",
104
  "Default Model",
105
  "models",
 
106
  settings_attr="model",
107
  default="nvidia_nim/nvidia/nemotron-3-super-120b-a12b",
108
  description="Fallback provider/model route for all Claude model names.",
@@ -111,29 +114,33 @@ _NON_PROVIDER_FIELDS: tuple[ConfigFieldSpec, ...] = (
111
  "MODEL_FABLE",
112
  "Fable Override",
113
  "models",
 
114
  settings_attr="model_fable",
115
- description="Optional provider/model route for Fable requests.",
116
  ),
117
  ConfigFieldSpec(
118
  "MODEL_OPUS",
119
  "Opus Override",
120
  "models",
 
121
  settings_attr="model_opus",
122
- description="Optional provider/model route for Opus requests.",
123
  ),
124
  ConfigFieldSpec(
125
  "MODEL_SONNET",
126
  "Sonnet Override",
127
  "models",
 
128
  settings_attr="model_sonnet",
129
- description="Optional provider/model route for Sonnet requests.",
130
  ),
131
  ConfigFieldSpec(
132
  "MODEL_HAIKU",
133
  "Haiku Override",
134
  "models",
 
135
  settings_attr="model_haiku",
136
- description="Optional provider/model route for Haiku requests.",
137
  ),
138
  ConfigFieldSpec(
139
  "ENABLE_MODEL_THINKING",
 
14
  "number",
15
  "boolean",
16
  "tri_boolean",
17
+ "model",
18
+ "optional_model",
19
  "select",
20
  "textarea",
21
  ]
 
58
  ConfigSectionSpec(
59
  "models",
60
  "Model Routing",
61
+ "Search discovered provider models or enter a provider/model slug.",
62
  ),
63
  ConfigSectionSpec(
64
  "thinking",
 
105
  "MODEL",
106
  "Default Model",
107
  "models",
108
+ "model",
109
  settings_attr="model",
110
  default="nvidia_nim/nvidia/nemotron-3-super-120b-a12b",
111
  description="Fallback provider/model route for all Claude model names.",
 
114
  "MODEL_FABLE",
115
  "Fable Override",
116
  "models",
117
+ "optional_model",
118
  settings_attr="model_fable",
119
+ description="Select None to use the Default Model for Fable requests.",
120
  ),
121
  ConfigFieldSpec(
122
  "MODEL_OPUS",
123
  "Opus Override",
124
  "models",
125
+ "optional_model",
126
  settings_attr="model_opus",
127
+ description="Select None to use the Default Model for Opus requests.",
128
  ),
129
  ConfigFieldSpec(
130
  "MODEL_SONNET",
131
  "Sonnet Override",
132
  "models",
133
+ "optional_model",
134
  settings_attr="model_sonnet",
135
+ description="Select None to use the Default Model for Sonnet requests.",
136
  ),
137
  ConfigFieldSpec(
138
  "MODEL_HAIKU",
139
  "Haiku Override",
140
  "models",
141
+ "optional_model",
142
  settings_attr="model_haiku",
143
+ description="Select None to use the Default Model for Haiku requests.",
144
  ),
145
  ConfigFieldSpec(
146
  "ENABLE_MODEL_THINKING",
src/free_claude_code/providers/runtime/discovery.py CHANGED
@@ -5,7 +5,10 @@ from collections.abc import Callable
5
 
6
  from loguru import logger
7
 
8
- from free_claude_code.application.model_metadata import ProviderModelInfo
 
 
 
9
  from free_claude_code.config.model_refs import configured_chat_model_refs
10
  from free_claude_code.config.provider_catalog import PROVIDER_CATALOG
11
  from free_claude_code.config.settings import Settings
@@ -23,21 +26,27 @@ def referenced_provider_ids(settings: Settings) -> frozenset[str]:
23
  return frozenset(ref.provider_id for ref in configured_chat_model_refs(settings))
24
 
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  def model_list_provider_ids_for_settings(settings: Settings) -> tuple[str, ...]:
27
  """Return providers worth discovering for this process configuration."""
28
  referenced_ids = referenced_provider_ids(settings)
29
- provider_ids: list[str] = []
30
- for provider_id, descriptor in PROVIDER_CATALOG.items():
31
- if descriptor.local:
32
- if provider_id in referenced_ids:
33
- provider_ids.append(provider_id)
34
- continue
35
- if (
36
- descriptor.credential_env is not None
37
- and provider_credential(descriptor, settings).strip()
38
- ):
39
- provider_ids.append(provider_id)
40
- return tuple(provider_ids)
41
 
42
 
43
  class ProviderModelDiscovery:
@@ -53,7 +62,9 @@ class ProviderModelDiscovery:
53
  self._provider_resolver = provider_resolver
54
  self._model_cache = model_cache
55
 
56
- async def refresh_model_list_cache(self, *, only_missing: bool = False) -> None:
 
 
57
  """Best-effort refresh of model lists for usable providers."""
58
  provider_ids = model_list_provider_ids_for_settings(self._settings)
59
  if only_missing:
@@ -62,34 +73,46 @@ class ProviderModelDiscovery:
62
  for provider_id in provider_ids
63
  if not self._model_cache.has_provider(provider_id)
64
  )
65
- await self._refresh_model_infos(provider_ids)
66
 
67
- async def _refresh_model_infos(self, provider_ids: tuple[str, ...]) -> None:
 
 
 
68
  tasks: dict[str, asyncio.Task[frozenset[ProviderModelInfo]]] = {}
69
  for provider_id in provider_ids:
70
  try:
71
  provider = self._provider_resolver(provider_id)
72
  except Exception as exc:
73
  self._log_discovery_failure(provider_id, exc)
 
74
  continue
75
  tasks[provider_id] = asyncio.create_task(provider.list_model_infos())
76
 
77
- if not tasks:
78
- return
79
-
80
- results = await asyncio.gather(*tasks.values(), return_exceptions=True)
81
- for (provider_id, _task), result in zip(tasks.items(), results, strict=True):
82
- if isinstance(result, BaseException):
83
- if isinstance(result, asyncio.CancelledError):
84
- raise result
85
- self._log_discovery_failure(provider_id, result)
86
- continue
87
- self._model_cache.cache_model_infos(provider_id, result)
88
- logger.info(
89
- "Provider model discovery cached: provider={} models={}",
90
- provider_id,
91
- len(result),
92
- )
 
 
 
 
 
 
 
 
93
 
94
  def _log_discovery_failure(self, provider_id: str, exc: BaseException) -> None:
95
  logger.warning(
 
5
 
6
  from loguru import logger
7
 
8
+ from free_claude_code.application.model_metadata import (
9
+ ProviderModelInfo,
10
+ ProviderModelRefreshResult,
11
+ )
12
  from free_claude_code.config.model_refs import configured_chat_model_refs
13
  from free_claude_code.config.provider_catalog import PROVIDER_CATALOG
14
  from free_claude_code.config.settings import Settings
 
26
  return frozenset(ref.provider_id for ref in configured_chat_model_refs(settings))
27
 
28
 
29
+ def model_cache_provider_ids_for_settings(settings: Settings) -> tuple[str, ...]:
30
+ """Return providers whose model metadata is valid for these settings."""
31
+ return tuple(
32
+ provider_id
33
+ for provider_id, descriptor in PROVIDER_CATALOG.items()
34
+ if descriptor.local
35
+ or (
36
+ descriptor.credential_env is not None
37
+ and provider_credential(descriptor, settings).strip()
38
+ )
39
+ )
40
+
41
+
42
  def model_list_provider_ids_for_settings(settings: Settings) -> tuple[str, ...]:
43
  """Return providers worth discovering for this process configuration."""
44
  referenced_ids = referenced_provider_ids(settings)
45
+ return tuple(
46
+ provider_id
47
+ for provider_id in model_cache_provider_ids_for_settings(settings)
48
+ if not PROVIDER_CATALOG[provider_id].local or provider_id in referenced_ids
49
+ )
 
 
 
 
 
 
 
50
 
51
 
52
  class ProviderModelDiscovery:
 
62
  self._provider_resolver = provider_resolver
63
  self._model_cache = model_cache
64
 
65
+ async def refresh_model_list_cache(
66
+ self, *, only_missing: bool = False
67
+ ) -> ProviderModelRefreshResult:
68
  """Best-effort refresh of model lists for usable providers."""
69
  provider_ids = model_list_provider_ids_for_settings(self._settings)
70
  if only_missing:
 
73
  for provider_id in provider_ids
74
  if not self._model_cache.has_provider(provider_id)
75
  )
76
+ return await self._refresh_model_infos(provider_ids)
77
 
78
+ async def _refresh_model_infos(
79
+ self, provider_ids: tuple[str, ...]
80
+ ) -> ProviderModelRefreshResult:
81
+ failed_provider_ids: list[str] = []
82
  tasks: dict[str, asyncio.Task[frozenset[ProviderModelInfo]]] = {}
83
  for provider_id in provider_ids:
84
  try:
85
  provider = self._provider_resolver(provider_id)
86
  except Exception as exc:
87
  self._log_discovery_failure(provider_id, exc)
88
+ failed_provider_ids.append(provider_id)
89
  continue
90
  tasks[provider_id] = asyncio.create_task(provider.list_model_infos())
91
 
92
+ refreshed_provider_ids: list[str] = []
93
+ if tasks:
94
+ results = await asyncio.gather(*tasks.values(), return_exceptions=True)
95
+ for (provider_id, _task), result in zip(
96
+ tasks.items(), results, strict=True
97
+ ):
98
+ if isinstance(result, BaseException):
99
+ if isinstance(result, asyncio.CancelledError):
100
+ raise result
101
+ self._log_discovery_failure(provider_id, result)
102
+ failed_provider_ids.append(provider_id)
103
+ continue
104
+ self._model_cache.cache_model_infos(provider_id, result)
105
+ refreshed_provider_ids.append(provider_id)
106
+ logger.info(
107
+ "Provider model discovery cached: provider={} models={}",
108
+ provider_id,
109
+ len(result),
110
+ )
111
+
112
+ return ProviderModelRefreshResult(
113
+ refreshed_provider_ids=tuple(refreshed_provider_ids),
114
+ failed_provider_ids=tuple(failed_provider_ids),
115
+ )
116
 
117
  def _log_discovery_failure(self, provider_id: str, exc: BaseException) -> None:
118
  logger.warning(
src/free_claude_code/providers/runtime/model_cache.py CHANGED
@@ -10,7 +10,11 @@ from free_claude_code.providers.model_listing import model_infos_from_ids
10
  class ProviderModelCache:
11
  """Store provider model metadata for instant model-list responses."""
12
 
13
- def __init__(self) -> None:
 
 
 
 
14
  self._model_infos_by_provider: dict[str, dict[str, ProviderModelInfo]] = {}
15
 
16
  def cache_model_ids(self, provider_id: str, model_ids: Iterable[str]) -> None:
@@ -21,11 +25,22 @@ class ProviderModelCache:
21
  self, provider_id: str, model_infos: Iterable[ProviderModelInfo]
22
  ) -> None:
23
  """Store provider model metadata by raw provider model id."""
 
 
24
  clean_infos = {
25
  info.model_id: info for info in model_infos if info.model_id.strip()
26
  }
27
  self._model_infos_by_provider[provider_id] = clean_infos
28
 
 
 
 
 
 
 
 
 
 
29
  def cached_model_ids(self) -> dict[str, frozenset[str]]:
30
  """Return cached raw provider model ids by provider."""
31
  return {
 
10
  class ProviderModelCache:
11
  """Store provider model metadata for instant model-list responses."""
12
 
13
+ def __init__(
14
+ self,
15
+ available_provider_ids: Iterable[str] = SUPPORTED_PROVIDER_IDS,
16
+ ) -> None:
17
+ self._available_provider_ids = frozenset(available_provider_ids)
18
  self._model_infos_by_provider: dict[str, dict[str, ProviderModelInfo]] = {}
19
 
20
  def cache_model_ids(self, provider_id: str, model_ids: Iterable[str]) -> None:
 
25
  self, provider_id: str, model_infos: Iterable[ProviderModelInfo]
26
  ) -> None:
27
  """Store provider model metadata by raw provider model id."""
28
+ if provider_id not in self._available_provider_ids:
29
+ return
30
  clean_infos = {
31
  info.model_id: info for info in model_infos if info.model_id.strip()
32
  }
33
  self._model_infos_by_provider[provider_id] = clean_infos
34
 
35
+ def set_available_providers(self, provider_ids: Iterable[str]) -> None:
36
+ """Replace the provider scope and discard entries outside it."""
37
+ self._available_provider_ids = frozenset(provider_ids)
38
+ self._model_infos_by_provider = {
39
+ provider_id: infos
40
+ for provider_id, infos in self._model_infos_by_provider.items()
41
+ if provider_id in self._available_provider_ids
42
+ }
43
+
44
  def cached_model_ids(self) -> dict[str, frozenset[str]]:
45
  """Return cached raw provider model ids by provider."""
46
  return {
src/free_claude_code/providers/runtime/validation.py CHANGED
@@ -81,8 +81,8 @@ class ConfiguredModelValidator:
81
  self._format_provider_query_failures(provider_refs, result)
82
  )
83
  continue
 
84
  self._model_cache.cache_model_infos(provider_id, result)
85
- model_ids = self._model_cache.cached_model_ids()[provider_id]
86
  failures.extend(
87
  self._format_missing_model_failure(ref)
88
  for ref in provider_refs
 
81
  self._format_provider_query_failures(provider_refs, result)
82
  )
83
  continue
84
+ model_ids = frozenset(info.model_id for info in result)
85
  self._model_cache.cache_model_infos(provider_id, result)
 
86
  failures.extend(
87
  self._format_missing_model_failure(ref)
88
  for ref in provider_refs
src/free_claude_code/runtime/application.py CHANGED
@@ -14,6 +14,7 @@ import free_claude_code.cli.managed as cli_managed
14
  import free_claude_code.messaging.session as messaging_session
15
  import free_claude_code.messaging.workflow as messaging_workflow_module
16
  from free_claude_code.application.errors import ApplicationUnavailableError
 
17
  from free_claude_code.application.ports import StopResult
18
  from free_claude_code.config.admin.persistence import (
19
  PreparedAdminUpdate,
@@ -244,14 +245,8 @@ class ApplicationRuntime:
244
  "models": sorted(info.model_id for info in infos),
245
  }
246
 
247
- async def refresh_models(self) -> dict[str, Any]:
248
- await self.provider_manager.refresh_model_list_cache()
249
- return {
250
- "cached_models": {
251
- provider_id: sorted(model_ids)
252
- for provider_id, model_ids in self.provider_manager.cached_model_ids().items()
253
- }
254
- }
255
 
256
  async def request_restart(self) -> None:
257
  callback = self._restart_callback
 
14
  import free_claude_code.messaging.session as messaging_session
15
  import free_claude_code.messaging.workflow as messaging_workflow_module
16
  from free_claude_code.application.errors import ApplicationUnavailableError
17
+ from free_claude_code.application.model_metadata import ProviderModelRefreshResult
18
  from free_claude_code.application.ports import StopResult
19
  from free_claude_code.config.admin.persistence import (
20
  PreparedAdminUpdate,
 
245
  "models": sorted(info.model_id for info in infos),
246
  }
247
 
248
+ async def refresh_models(self) -> ProviderModelRefreshResult:
249
+ return await self.provider_manager.refresh_model_list_cache()
 
 
 
 
 
 
250
 
251
  async def request_restart(self) -> None:
252
  callback = self._restart_callback
src/free_claude_code/runtime/provider_manager.py CHANGED
@@ -7,12 +7,18 @@ from dataclasses import dataclass, field
7
  from loguru import logger
8
 
9
  from free_claude_code.application.errors import ApplicationUnavailableError
10
- from free_claude_code.application.model_metadata import ProviderModelInfo
 
 
 
11
  from free_claude_code.config.settings import Settings
12
  from free_claude_code.core.trace import trace_event
13
  from free_claude_code.providers.base import BaseProvider
14
  from free_claude_code.providers.runtime import ProviderRuntime
15
- from free_claude_code.providers.runtime.discovery import ProviderModelDiscovery
 
 
 
16
  from free_claude_code.providers.runtime.model_cache import ProviderModelCache
17
  from free_claude_code.providers.runtime.validation import ConfiguredModelValidator
18
 
@@ -86,7 +92,9 @@ class ProviderRuntimeManager:
86
  self._runtime_factory = runtime_factory
87
  self._replace_lock = asyncio.Lock()
88
  self._close_lock = asyncio.Lock()
89
- self._model_cache = ProviderModelCache()
 
 
90
  self._refresh_task: asyncio.Task[None] | None = None
91
  self._next_generation_id = 2
92
  self._retired: dict[int, _ProviderGeneration] = {}
@@ -153,16 +161,16 @@ class ProviderRuntimeManager:
153
  return
154
  generation = self._current
155
  self._refresh_task = asyncio.create_task(
156
- self._refresh_generation(generation, only_missing=True)
157
  )
158
 
159
- async def refresh_model_list_cache(self) -> None:
160
  """Run an explicit full refresh without racing replacement."""
161
  async with self._replace_lock:
162
  if self._closing or self._closed:
163
  raise ApplicationUnavailableError("Provider runtime is shutting down.")
164
  await self._cancel_refresh()
165
- await self._refresh_generation(self._current, only_missing=False)
166
 
167
  async def replace(
168
  self,
@@ -205,13 +213,16 @@ class ProviderRuntimeManager:
205
  runtime=candidate_runtime,
206
  )
207
  self._current = candidate
 
 
 
208
  previous.retired = True
209
  self._retired[previous.generation_id] = previous
210
  self._trace_published(candidate, previous=previous, reason=reason)
211
  self._trace_retired(previous, reason=reason)
212
 
213
  self._refresh_task = asyncio.create_task(
214
- self._refresh_generation(candidate, only_missing=False)
215
  )
216
  if previous.active_leases == 0:
217
  await self._close_generation(previous, forced=False)
@@ -262,9 +273,9 @@ class ProviderRuntimeManager:
262
  generation: _ProviderGeneration,
263
  *,
264
  only_missing: bool,
265
- ) -> None:
266
  if generation.closed:
267
- return
268
  generation.active_leases += 1
269
  generation.drained.clear()
270
  try:
@@ -273,7 +284,18 @@ class ProviderRuntimeManager:
273
  generation.runtime.resolve_provider,
274
  self._model_cache,
275
  )
276
- await discovery.refresh_model_list_cache(only_missing=only_missing)
 
 
 
 
 
 
 
 
 
 
 
277
  except asyncio.CancelledError:
278
  raise
279
  except Exception as exc:
@@ -281,8 +303,6 @@ class ProviderRuntimeManager:
281
  "Provider model discovery task failed: exc_type={}",
282
  type(exc).__name__,
283
  )
284
- finally:
285
- await self._release(generation)
286
 
287
  async def _cancel_refresh(self) -> None:
288
  task = self._refresh_task
 
7
  from loguru import logger
8
 
9
  from free_claude_code.application.errors import ApplicationUnavailableError
10
+ from free_claude_code.application.model_metadata import (
11
+ ProviderModelInfo,
12
+ ProviderModelRefreshResult,
13
+ )
14
  from free_claude_code.config.settings import Settings
15
  from free_claude_code.core.trace import trace_event
16
  from free_claude_code.providers.base import BaseProvider
17
  from free_claude_code.providers.runtime import ProviderRuntime
18
+ from free_claude_code.providers.runtime.discovery import (
19
+ ProviderModelDiscovery,
20
+ model_cache_provider_ids_for_settings,
21
+ )
22
  from free_claude_code.providers.runtime.model_cache import ProviderModelCache
23
  from free_claude_code.providers.runtime.validation import ConfiguredModelValidator
24
 
 
92
  self._runtime_factory = runtime_factory
93
  self._replace_lock = asyncio.Lock()
94
  self._close_lock = asyncio.Lock()
95
+ self._model_cache = ProviderModelCache(
96
+ model_cache_provider_ids_for_settings(settings)
97
+ )
98
  self._refresh_task: asyncio.Task[None] | None = None
99
  self._next_generation_id = 2
100
  self._retired: dict[int, _ProviderGeneration] = {}
 
161
  return
162
  generation = self._current
163
  self._refresh_task = asyncio.create_task(
164
+ self._refresh_generation_in_background(generation, only_missing=True)
165
  )
166
 
167
+ async def refresh_model_list_cache(self) -> ProviderModelRefreshResult:
168
  """Run an explicit full refresh without racing replacement."""
169
  async with self._replace_lock:
170
  if self._closing or self._closed:
171
  raise ApplicationUnavailableError("Provider runtime is shutting down.")
172
  await self._cancel_refresh()
173
+ return await self._refresh_generation(self._current, only_missing=False)
174
 
175
  async def replace(
176
  self,
 
213
  runtime=candidate_runtime,
214
  )
215
  self._current = candidate
216
+ self._model_cache.set_available_providers(
217
+ model_cache_provider_ids_for_settings(settings)
218
+ )
219
  previous.retired = True
220
  self._retired[previous.generation_id] = previous
221
  self._trace_published(candidate, previous=previous, reason=reason)
222
  self._trace_retired(previous, reason=reason)
223
 
224
  self._refresh_task = asyncio.create_task(
225
+ self._refresh_generation_in_background(candidate, only_missing=False)
226
  )
227
  if previous.active_leases == 0:
228
  await self._close_generation(previous, forced=False)
 
273
  generation: _ProviderGeneration,
274
  *,
275
  only_missing: bool,
276
+ ) -> ProviderModelRefreshResult:
277
  if generation.closed:
278
+ return ProviderModelRefreshResult()
279
  generation.active_leases += 1
280
  generation.drained.clear()
281
  try:
 
284
  generation.runtime.resolve_provider,
285
  self._model_cache,
286
  )
287
+ return await discovery.refresh_model_list_cache(only_missing=only_missing)
288
+ finally:
289
+ await self._release(generation)
290
+
291
+ async def _refresh_generation_in_background(
292
+ self,
293
+ generation: _ProviderGeneration,
294
+ *,
295
+ only_missing: bool,
296
+ ) -> None:
297
+ try:
298
+ await self._refresh_generation(generation, only_missing=only_missing)
299
  except asyncio.CancelledError:
300
  raise
301
  except Exception as exc:
 
303
  "Provider model discovery task failed: exc_type={}",
304
  type(exc).__name__,
305
  )
 
 
306
 
307
  async def _cancel_refresh(self) -> None:
308
  task = self._refresh_task
tests/api/test_admin.py CHANGED
@@ -1,14 +1,18 @@
1
  from pathlib import Path
2
- from unittest.mock import patch
3
 
4
  import httpx
5
  import pytest
6
  from fastapi.testclient import TestClient
7
 
 
 
 
 
8
  from free_claude_code.config.admin.values import MASKED_SECRET
9
  from free_claude_code.config.server_urls import local_admin_url
10
  from free_claude_code.config.settings import Settings
11
- from tests.api.support import create_test_app
12
 
13
 
14
  def _local_client(app):
@@ -103,6 +107,22 @@ def test_admin_static_hides_managed_source_label():
103
  assert "sourceEl.textContent = source" in script
104
 
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  def test_admin_config_masks_secrets_and_exposes_manifest(monkeypatch, tmp_path):
107
  _set_home(monkeypatch, tmp_path)
108
  _clear_process_config(monkeypatch)
@@ -148,6 +168,19 @@ def test_admin_config_masks_secrets_and_exposes_manifest(monkeypatch, tmp_path):
148
  assert open_browser_field["type"] == "boolean"
149
  assert open_browser_field["value"] == "true"
150
  assert open_browser_field["restart_required"] is False
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  restart_required = {
152
  field["key"] for field in body["fields"] if field["restart_required"] is True
153
  }
@@ -163,6 +196,80 @@ def test_admin_config_masks_secrets_and_exposes_manifest(monkeypatch, tmp_path):
163
  } <= restart_required
164
 
165
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  def test_admin_config_preserves_managed_env_source_contract(monkeypatch, tmp_path):
167
  _set_home(monkeypatch, tmp_path)
168
  _clear_process_config(monkeypatch)
 
1
  from pathlib import Path
2
+ from unittest.mock import AsyncMock, patch
3
 
4
  import httpx
5
  import pytest
6
  from fastapi.testclient import TestClient
7
 
8
+ from free_claude_code.application.model_metadata import (
9
+ ProviderModelInfo,
10
+ ProviderModelRefreshResult,
11
+ )
12
  from free_claude_code.config.admin.values import MASKED_SECRET
13
  from free_claude_code.config.server_urls import local_admin_url
14
  from free_claude_code.config.settings import Settings
15
+ from tests.api.support import create_test_app, provider_manager_for_app
16
 
17
 
18
  def _local_client(app):
 
107
  assert "sourceEl.textContent = source" in script
108
 
109
 
110
+ def test_admin_static_loads_searchable_model_options_and_maps_none_to_unset():
111
+ script = Path("src/free_claude_code/api/admin_static/admin.js").read_text(
112
+ encoding="utf-8"
113
+ )
114
+
115
+ assert 'api("/admin/api/models" + (refresh ? "/refresh" : "")' in script
116
+ assert 'field.type === "model" || field.type === "optional_model"' in script
117
+ assert '"optional-model-options", ["None", ...state.modelOptions]' in script
118
+ assert 'input.dataset.fieldType === "optional_model"' in script
119
+ assert 'return "";' in script
120
+ assert "await hydrateModelOptions();" in script
121
+ assert "Model fields remain editable" in script
122
+ assert "result.failed_providers || []" in script
123
+ assert '"warn"' in script
124
+
125
+
126
  def test_admin_config_masks_secrets_and_exposes_manifest(monkeypatch, tmp_path):
127
  _set_home(monkeypatch, tmp_path)
128
  _clear_process_config(monkeypatch)
 
168
  assert open_browser_field["type"] == "boolean"
169
  assert open_browser_field["value"] == "true"
170
  assert open_browser_field["restart_required"] is False
171
+ model_field_types = {
172
+ field["key"]: field["type"]
173
+ for field in body["fields"]
174
+ if field["key"]
175
+ in {"MODEL", "MODEL_FABLE", "MODEL_OPUS", "MODEL_SONNET", "MODEL_HAIKU"}
176
+ }
177
+ assert model_field_types == {
178
+ "MODEL": "model",
179
+ "MODEL_FABLE": "optional_model",
180
+ "MODEL_OPUS": "optional_model",
181
+ "MODEL_SONNET": "optional_model",
182
+ "MODEL_HAIKU": "optional_model",
183
+ }
184
  restart_required = {
185
  field["key"] for field in body["fields"] if field["restart_required"] is True
186
  }
 
196
  } <= restart_required
197
 
198
 
199
+ def test_admin_models_include_configured_and_cached_canonical_slugs():
200
+ settings = Settings()
201
+ settings.model = "nvidia_nim/configured-model"
202
+ settings.model_opus = "open_router/anthropic/configured-opus"
203
+ settings.open_router_api_key = "open-router-key"
204
+ app = create_test_app(settings)
205
+ provider_manager_for_app(app).cache_model_infos(
206
+ "open_router",
207
+ {
208
+ ProviderModelInfo("anthropic/configured-opus"),
209
+ ProviderModelInfo("meta/llama-3.3"),
210
+ },
211
+ )
212
+
213
+ response = _local_client(app).get("/admin/api/models")
214
+
215
+ assert response.status_code == 200
216
+ assert response.json() == {
217
+ "models": [
218
+ "nvidia_nim/configured-model",
219
+ "open_router/anthropic/configured-opus",
220
+ "open_router/meta/llama-3.3",
221
+ ],
222
+ "failed_providers": [],
223
+ }
224
+
225
+
226
+ def test_admin_model_refresh_returns_the_updated_canonical_catalog():
227
+ settings = Settings()
228
+ settings.model = "deepseek/deepseek-chat"
229
+ settings.deepseek_api_key = "deepseek-key"
230
+ app = create_test_app(settings)
231
+ runtime = app.state.services.admin
232
+
233
+ async def refresh_models() -> ProviderModelRefreshResult:
234
+ provider_manager_for_app(app).cache_model_infos(
235
+ "deepseek",
236
+ {ProviderModelInfo("deepseek-reasoner")},
237
+ )
238
+ return ProviderModelRefreshResult(refreshed_provider_ids=("deepseek",))
239
+
240
+ runtime.refresh_models = AsyncMock(side_effect=refresh_models)
241
+
242
+ response = _local_client(app).post("/admin/api/models/refresh")
243
+
244
+ assert response.status_code == 200
245
+ assert response.json() == {
246
+ "models": ["deepseek/deepseek-chat", "deepseek/deepseek-reasoner"],
247
+ "failed_providers": [],
248
+ }
249
+ runtime.refresh_models.assert_awaited_once_with()
250
+
251
+
252
+ def test_admin_model_refresh_reports_partial_provider_failures():
253
+ settings = Settings()
254
+ settings.model = "deepseek/deepseek-chat"
255
+ app = create_test_app(settings)
256
+ runtime = app.state.services.admin
257
+ runtime.refresh_models = AsyncMock(
258
+ return_value=ProviderModelRefreshResult(
259
+ refreshed_provider_ids=("deepseek",),
260
+ failed_provider_ids=("open_router",),
261
+ )
262
+ )
263
+
264
+ response = _local_client(app).post("/admin/api/models/refresh")
265
+
266
+ assert response.status_code == 200
267
+ assert response.json() == {
268
+ "models": ["deepseek/deepseek-chat"],
269
+ "failed_providers": ["open_router"],
270
+ }
271
+
272
+
273
  def test_admin_config_preserves_managed_env_source_contract(monkeypatch, tmp_path):
274
  _set_home(monkeypatch, tmp_path)
275
  _clear_process_config(monkeypatch)
tests/api/test_model_listing.py CHANGED
@@ -19,6 +19,9 @@ def _settings(
19
  model_sonnet=None,
20
  model_haiku=model_haiku,
21
  anthropic_auth_token="",
 
 
 
22
  )
23
 
24
 
 
19
  model_sonnet=None,
20
  model_haiku=model_haiku,
21
  anthropic_auth_token="",
22
+ deepseek_api_key="deepseek-key",
23
+ open_router_api_key="open-router-key",
24
+ wafer_api_key="wafer-key",
25
  )
26
 
27
 
tests/providers/test_model_validation.py CHANGED
@@ -367,7 +367,11 @@ class FakeProvider(BaseProvider):
367
 
368
  @pytest.mark.asyncio
369
  async def test_runtime_validation_succeeds_for_all_configured_models() -> None:
370
- settings = _settings(model_opus="open_router/anthropic/claude-opus")
 
 
 
 
371
  runtime = _manager(
372
  settings,
373
  {
@@ -468,12 +472,14 @@ async def test_runtime_refresh_model_list_cache_uses_configured_remote_keys_and_
468
  },
469
  )
470
 
471
- await runtime.refresh_model_list_cache()
472
 
473
  assert runtime.cached_model_ids() == {
474
  "open_router": frozenset({"anthropic/claude-sonnet"}),
475
  "lmstudio": frozenset({"local-qwen"}),
476
  }
 
 
477
 
478
 
479
  @pytest.mark.asyncio
@@ -491,9 +497,11 @@ async def test_runtime_refresh_model_list_cache_keeps_prior_cache_on_failure() -
491
  {ProviderModelInfo("cached-model")},
492
  )
493
 
494
- await runtime.refresh_model_list_cache()
495
 
496
  assert runtime.cached_model_ids() == {"nvidia_nim": frozenset({"cached-model"})}
 
 
497
 
498
 
499
  def test_runtime_metadata_cache_exposes_ids_and_prefixed_infos() -> None:
@@ -519,6 +527,21 @@ def test_runtime_metadata_cache_exposes_ids_and_prefixed_infos() -> None:
519
  )
520
 
521
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
522
  def test_runtime_model_id_cache_keeps_unknown_thinking_support() -> None:
523
  cache = ProviderModelCache()
524
  cache.cache_model_ids("open_router", {"plain-model"})
 
367
 
368
  @pytest.mark.asyncio
369
  async def test_runtime_validation_succeeds_for_all_configured_models() -> None:
370
+ settings = _settings(
371
+ model_opus="open_router/anthropic/claude-opus",
372
+ nvidia_nim_api_key="nim-key",
373
+ open_router_api_key="open-router-key",
374
+ )
375
  runtime = _manager(
376
  settings,
377
  {
 
472
  },
473
  )
474
 
475
+ result = await runtime.refresh_model_list_cache()
476
 
477
  assert runtime.cached_model_ids() == {
478
  "open_router": frozenset({"anthropic/claude-sonnet"}),
479
  "lmstudio": frozenset({"local-qwen"}),
480
  }
481
+ assert result.refreshed_provider_ids == ("open_router", "lmstudio")
482
+ assert result.failed_provider_ids == ()
483
 
484
 
485
  @pytest.mark.asyncio
 
497
  {ProviderModelInfo("cached-model")},
498
  )
499
 
500
+ result = await runtime.refresh_model_list_cache()
501
 
502
  assert runtime.cached_model_ids() == {"nvidia_nim": frozenset({"cached-model"})}
503
+ assert result.refreshed_provider_ids == ()
504
+ assert result.failed_provider_ids == ("nvidia_nim",)
505
 
506
 
507
  def test_runtime_metadata_cache_exposes_ids_and_prefixed_infos() -> None:
 
527
  )
528
 
529
 
530
+ def test_runtime_metadata_cache_enforces_replaced_provider_scope() -> None:
531
+ cache = ProviderModelCache({"open_router", "lmstudio"})
532
+ cache.cache_model_ids("open_router", {"old-model"})
533
+ cache.cache_model_ids("lmstudio", {"local-model"})
534
+
535
+ cache.set_available_providers({"deepseek", "lmstudio"})
536
+ cache.cache_model_ids("open_router", {"late-old-model"})
537
+ cache.cache_model_ids("deepseek", {"new-model"})
538
+
539
+ assert cache.cached_model_ids() == {
540
+ "deepseek": frozenset({"new-model"}),
541
+ "lmstudio": frozenset({"local-model"}),
542
+ }
543
+
544
+
545
  def test_runtime_model_id_cache_keeps_unknown_thinking_support() -> None:
546
  cache = ProviderModelCache()
547
  cache.cache_model_ids("open_router", {"plain-model"})
tests/runtime/test_provider_manager.py CHANGED
@@ -325,7 +325,7 @@ async def test_failed_candidate_cleanup_is_retried_at_shutdown() -> None:
325
  assert factory.runtimes[1].cleanup_calls == 1
326
  assert manager._unpublished == {factory.runtimes[1]}
327
 
328
- manager.cache_model_infos("nvidia_nim", {ProviderModelInfo("cached")})
329
  with pytest.raises(
330
  RuntimeError,
331
  match="One or more provider runtimes failed to close",
@@ -335,7 +335,7 @@ async def test_failed_candidate_cleanup_is_retried_at_shutdown() -> None:
335
  assert "private cleanup detail" not in str(exc_info.value)
336
  assert manager._closed is False
337
  assert manager._unpublished == {factory.runtimes[1]}
338
- assert manager.cached_model_ids() == {"nvidia_nim": frozenset({"cached"})}
339
 
340
  factory.runtimes[1].cleanup_error = None
341
  await manager.close()
@@ -554,7 +554,7 @@ async def test_failed_shutdown_cleanup_is_retryable() -> None:
554
  _settings("nvidia_nim/one"),
555
  runtime_factory=factory,
556
  )
557
- manager.cache_model_infos("nvidia_nim", {ProviderModelInfo("cached")})
558
  factory.runtimes[0].cleanup_error = RuntimeError("private provider detail")
559
 
560
  with pytest.raises(
@@ -566,7 +566,7 @@ async def test_failed_shutdown_cleanup_is_retryable() -> None:
566
  assert "private provider detail" not in str(exc_info.value)
567
  assert manager._closed is False
568
  assert 1 in manager._retired
569
- assert manager.cached_model_ids() == {"nvidia_nim": frozenset({"cached"})}
570
  assert factory.runtimes[0].cleanup_calls == 1
571
 
572
  factory.runtimes[0].cleanup_error = None
@@ -582,21 +582,40 @@ async def test_failed_shutdown_cleanup_is_retryable() -> None:
582
  async def test_application_catalog_survives_generation_replacement() -> None:
583
  factory = RuntimeFactory()
584
  manager = ProviderRuntimeManager(
585
- _settings("open_router/one"),
586
  runtime_factory=factory,
587
  )
588
  manager.cache_model_infos(
589
- "open_router",
590
  {ProviderModelInfo("persisted", supports_thinking=True)},
591
  )
592
 
593
  await manager.replace(
594
- _settings("open_router/two"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
595
  commit=lambda: None,
596
  )
 
597
 
598
- assert manager.cached_model_ids() == {"open_router": frozenset({"persisted"})}
599
- assert manager.cached_model_supports_thinking("open_router", "persisted") is True
600
  await manager.close()
601
 
602
 
 
325
  assert factory.runtimes[1].cleanup_calls == 1
326
  assert manager._unpublished == {factory.runtimes[1]}
327
 
328
+ manager.cache_model_infos("lmstudio", {ProviderModelInfo("cached")})
329
  with pytest.raises(
330
  RuntimeError,
331
  match="One or more provider runtimes failed to close",
 
335
  assert "private cleanup detail" not in str(exc_info.value)
336
  assert manager._closed is False
337
  assert manager._unpublished == {factory.runtimes[1]}
338
+ assert manager.cached_model_ids() == {"lmstudio": frozenset({"cached"})}
339
 
340
  factory.runtimes[1].cleanup_error = None
341
  await manager.close()
 
554
  _settings("nvidia_nim/one"),
555
  runtime_factory=factory,
556
  )
557
+ manager.cache_model_infos("lmstudio", {ProviderModelInfo("cached")})
558
  factory.runtimes[0].cleanup_error = RuntimeError("private provider detail")
559
 
560
  with pytest.raises(
 
566
  assert "private provider detail" not in str(exc_info.value)
567
  assert manager._closed is False
568
  assert 1 in manager._retired
569
+ assert manager.cached_model_ids() == {"lmstudio": frozenset({"cached"})}
570
  assert factory.runtimes[0].cleanup_calls == 1
571
 
572
  factory.runtimes[0].cleanup_error = None
 
582
  async def test_application_catalog_survives_generation_replacement() -> None:
583
  factory = RuntimeFactory()
584
  manager = ProviderRuntimeManager(
585
+ _settings("nvidia_nim/one"),
586
  runtime_factory=factory,
587
  )
588
  manager.cache_model_infos(
589
+ "lmstudio",
590
  {ProviderModelInfo("persisted", supports_thinking=True)},
591
  )
592
 
593
  await manager.replace(
594
+ _settings("nvidia_nim/two"),
595
+ commit=lambda: None,
596
+ )
597
+
598
+ assert manager.cached_model_ids() == {"lmstudio": frozenset({"persisted"})}
599
+ assert manager.cached_model_supports_thinking("lmstudio", "persisted") is True
600
+ await manager.close()
601
+
602
+
603
+ @pytest.mark.asyncio
604
+ async def test_replacement_prunes_and_rejects_removed_remote_provider_cache() -> None:
605
+ factory = RuntimeFactory()
606
+ first_settings = _settings("open_router/one").model_copy(
607
+ update={"open_router_api_key": "open-router-key"}
608
+ )
609
+ manager = ProviderRuntimeManager(first_settings, runtime_factory=factory)
610
+ manager.cache_model_infos("open_router", {ProviderModelInfo("old-model")})
611
+
612
+ await manager.replace(
613
+ _settings("nvidia_nim/two"),
614
  commit=lambda: None,
615
  )
616
+ manager.cache_model_infos("open_router", {ProviderModelInfo("late-old-model")})
617
 
618
+ assert "open_router" not in manager.cached_model_ids()
 
619
  await manager.close()
620
 
621
 
uv.lock CHANGED
@@ -561,7 +561,7 @@ wheels = [
561
 
562
  [[package]]
563
  name = "free-claude-code"
564
- version = "4.5.2"
565
  source = { editable = "." }
566
  dependencies = [
567
  { name = "aiohttp" },
 
561
 
562
  [[package]]
563
  name = "free-claude-code"
564
+ version = "4.6.1"
565
  source = { editable = "." }
566
  dependencies = [
567
  { name = "aiohttp" },