Settings, AI — providers, keys, local runtimes, compute targets and downloads — Design Spec (#TBD)
Consolidating design:
ai/where-models-run.md(#5582) owns choosing a model together with where it runs and the one list of places; this spec keeps the Settings surface, keys and runtime status.Milestone: ai-settings One home (ruled 2026-10-04). Every behaviour of Settings’ AI pane lives here: the provider rows, the keys (folded from
ai/provider-keys.md, section K), the one model list and row used everywhere (folded fromui/model-selector-consistency.md, section M), each runtime’s status and the keys a runtime needs (moved fromai/local-runtimes.md), where work runs (moved fromcompute/targets-and-connection.md) and model downloads. How a runtime ships and loads stays inai/local-runtimes.md; how a target connects stays incompute/targets-and-connection.md; a download’s progress is a job row (ui/activity-and-automatic-work.md). Manual: TBD — the user manual’s Settings section needs “Configuring AI providers”: every provider (cloud and on-device) is a row you pick from one list; a row’s own detail carries whatever makes it work — a key, a download, a Start/Stop.Design-led (Testing Constitution). Creative director owns intent; tests enforce it; code makes them pass. Status: DRAFT — awaiting approval. The redesign this spec anchors was RATIFIED by the creative director (2026-08-24) but never got a spec; this is that spec, written against the code as it stands today, not the proposal as written. Tags: [OK] built and tested · [PARTIAL] built, unproven or partly wired · [GAP] intended, never built (needs an issue) · [BROKEN] regression, code contradicts the intent (needs an issue).
Provider API keys are OUT OF SCOPE here — persistence, verification, and security of a key once entered are section K’s (→ #4815, → #4816, → #4818, → #4819, → #4820, → #4821). This spec owns the SURFACE (rows, tabs, catalog) the key field lives inside.
Intent (the design)
Every AI provider — cloud or on-device — is a row in ONE list, with the same shape: a name, a status that means what it says, and a detail view. A local runtime’s detail carries what makes it local (provisioning, its own model catalog, start/stop) instead of living in a separate pane; a cloud provider’s detail carries a key and its models. There is one model catalog, not several disagreeing lists, and a provider’s status dot is never green unless the thing it names is actually ready. Settings surfaces preferences, not plumbing — no toggle a user can’t explain the consequence of.
Ruled 2026-10-04 (the maintainer, while testing the dev build)
Paraphrased. These narrow the 2026-08-24 target; where they differ, these win.
- Models live here. Downloaded or imported, every model (Kraken, YOLO, spaCy, vision and text models, LoRA adapters) is managed in Settings. A model a training node produces appears here the same way.
- Training does not live here. Training, fine-tuning, evaluating and publishing are a node in the sidebar with their own history (#5439). Settings only lists the result.
- Places to run work are configuration, so they live here: this Mac, Hugging Face, a cluster
such as Rorqual, an endpoint (Blackfish may be one). Adding a cluster is the guided flow in
compute/targets-and-connection.md; Fichero itself holds the connection. - Downloads and installs are jobs that belong to the Mac, shown in Activity’s Mac group
(
activity.global-work-is-the-macs), never a progress bar private to Settings. -
Open: AI settings in the sidebar. The maintainer wondered whether more of the AI settings should move to the sidebar instead of one window. Recommendation: configuration (providers, keys, runtimes, places to run) stays in Settings; work (training nodes) is in the sidebar.
-
settings.models.import— [GAP] (#5460) a model you already have (a Kraken or YOLO file, a spaCy pipeline, a vision model or LoRA from a Hugging Face repo id) is imported, validated, stored with its provenance and licence (and whether release is allowed), and becomes pickable wherever that kind of model is. Test: importing a fixture Kraken model through the route lists it in the catalogue with its licence and origin; a file that is not a model is refused with the reason. settings.models.training-is-not-here— [GAP] (#5439) Settings has no training controls; the catalogue row of a trained model links to the training node that made it. Test: the catalogue entry for a model with a training provenance carries the node id.settings.compute.places-to-run— [GAP] (#5454) the Compute section lists this Mac, Hugging Face, clusters and endpoints as one list with an honest status each, and adds a cluster through the guided flow. Test: the targets route returns all four kinds with a status that is the check’s result, never assumed.settings.downloads-are-jobs— [GAP] (#5415) a download or install started here is a job in the Mac’s group of the jobs table, pausable and cancellable there. Test: starting a model download through the route creates adownload-modeljob row with no library.
Why this spec exists
“Settings - Models & Providers” (GitHub milestone #20) accumulated 28 open issues over more than a year with no spec anchoring any of them — feature requests, regressions, and a whole redesign proposal, all landing on one bucket. The redesign itself was worked out and ratified by the creative director on 2026-08-24 (four phases, summarized below) but was never written as a testable spec, so nothing has been checking whether it actually landed. Reading the code against that ratified shape found it landed further than expected in some places (the provider-row unification) and not at all in others (the model-catalog unification, the runtime-status honesty). This spec draws the line.
The ratified target (paraphrased from the 2026-08-24 design review, not restated verbatim)
The redesign’s core finding: the on-device runtime (MLX, spaCy, Kraken, Whisper — download, provisioning, start/stop) was bolted onto its own parallel system with its own nouns, reached only through a pane wedged awkwardly under the provider list. The fix collapses that: every provider, cloud or local, is a row in the SAME list; a local row’s detail carries its runtime state alongside its models, not in a second surface. Concretely, in four phases:
- P1 — Schema + seams. The provider response gains real runtime fields (provisioned or not, service running or not, disk used) so the status dot can be HONEST instead of “local therefore green.” MLX’s detail renders those fields; nothing else changes yet.
- P2 — One catalog. Cloud and local models converge on one response shape (a cloud row’s local-only fields are simply absent); the on-device download row becomes one shared component every provider’s detail reuses; the standalone Downloads tab retires once everything it showed lives inside a provider row.
- P3 — Profiles become rows; routes fold. The local-inference runtime configuration
(today a singleton keyed by environment) becomes provider-linked state; the separate
/api/local-inference/*route family folds into/api/providers/{id}/…. - P4 — Rich defaults + Embeddings. The Defaults tab’s per-purpose pickers read the same rich model list the provider browser uses (cost/context/vision visible in both places), and Embeddings — a setting that already exists but has nowhere to be picked today — gets a section of its own.
Settings collapses to three tabs (Defaults · Providers · Advanced) once Downloads has nowhere left to point at.
What the code actually does (verified against the four phases)
- P1 is DONE for the layout, NOT done for the schema.
ProvidersViewis one list; every managed-local runtime (MLX/spaCy/Kraken/Whisper) is a row in it, and its detail (ProviderDetailView) renders runtime/model/service sections inline viaLocalRuntimeModelsView— the separate pane and its collapse bug (#4531) are gone. But the schema work never landed:ProviderResponsecarries noruntime_state/requires_runtime/service_statefield (grep confirms — onlylocal_inference.py’s OWN separate response model hasdisk_usage_bytes, not shared onto the provider). The status dot (ProviderDetailView.swift, theCircle().fill(...)) is stillisLocalProvider || provider.hasApiKey ? .green : .orange— an unprovisioned MLX shows the identical green dot as a ready one. This is the “green-dot lie” the ratified design named as the thing P1 exists to kill; it has not been killed. - P2 is PARTLY done. Whisper/spaCy/Kraken download rows moved into their provider
detail views (confirmed:
ProviderDetailView’sshowsRuntimeBlock/isNoPromptRuntimebranches renderLocalRuntimeModelsViewinline). Embeddings has NOT moved — its own comment says it stays in the separate Downloads tab “which are a search concept with no provider row of their own (yet)” (LocalModelsSettingsView.swift:9-12). No unified model-catalog schema was found (LocalModelCatalogEntry’s fields folding into the provider-scoped model response, as the design proposed) — cloud and local models still come from separate response shapes. The Downloads tab is very much alive, now scoped to embeddings alone. - P3 is NOT done.
/api/local-inference/*is still its ownAPIRouter(prefix= "/local-inference")(local_inference.py:29), entirely separate from/api/providers/{id}/.... No profile-as-provider-row work was found. - P4 is NOT done.
AISettingsView+Tabs.swift’sdefaultsTabhas sections for Text/Vision/Audio/Video plus the six$small/$medium/$large/$vision_*capability tiers — no “Embeddings” section anywhere in it.
Behaviors
What shipped (P1’s layout half)
settings.providers-list-is-one-home— [OK] every provider — cloud and on-device — is a row in the sameProvidersViewlist; there is no separate local-runtime pane. Pinned:ProvidersTabLayoutTests.testLocalRuntimesRenderAsProviderRows,.testProvidersTabHandsTheBrowserTheFlexibleHeight,.testProvidersTabDoesNotWrapItsPanesInAScrollView.settings.provider-detail-carries-its-own-controls— [OK] a provider’s detail view carries whatever it needs — API key + models for a cloud provider, runtime/download/service controls for a local one — never a second window or pane. Pinned:ProvidersTabLayoutTests.testAPIKeyEntryRemainsReachableFromTheProviderDetailPane(key entry survived the row unification — the surface itself, not whether saving it persists, which is section K’s).settings.shared-model-row— [OK] every model-picking surface (the island, the workflow bar, chat, Settings) renders the SAME row component; no surface hand-draws its own competing row shape. Pinned:ModelRowSourceGuardrailTests.noSecondModelRowStruct.settings.provider-selection-preserved— [OK] changing a Defaults picker’s provider never blanks the model selection or silently auto-pickslist.first— a ship-blocker fix that landed as part of the model-selector-consistency milestone’s own work (that milestone still has open follow-ups of its own, not cited here since they are its claim, not this one). Pinned:AISettingsSelectionTests(all 5 cases: fetch-failure leaves the prior model untouched, an absent-from-the-fresh-list model is not replaced, a present model is left as-is, an empty prior selection stays empty, and the load site routes only throughselectionAfterModelLoad).-
settings.errors-surface-not-swallowed— [OK] a load/save/reset failure on the AI defaults always surfaces aserrorMessage, never a silenttry?. Pinned:AISettingsStoreTests.testLoadSurfacesFetchFailureInsteadOfSwallowing,.testSaveSurfacesFailure,.testResetSurfacesFailure,.testSuccessfulLoadLeavesNoError,.testCallsAreNoOpBeforeAttach. -
settings.local-model-refused-before-it-loads— [OK] (#5221) a local MLX model is refused BEFORE its process starts when this Mac cannot hold it right now: it needs about the model’s weights x 1.2 + 1.5 GB free (an estimate from the catalog’s size, overridable withFICHERO_MLX_MEMORY_NEED_MB; not yet measured per size class as Kraken’s 2.5 GB was), and macOS memory pressure must not be critical (warn still loads, as ruled for Kraken on 2026-09-28). The refusal says what it needs, what is free, and up to two smaller catalog models with a shared capability that fit; it is the route’s existing 409 for hardware refusals. Pinned byfichero-server/tests/unit/llm/test_mlx_memory_guard.py(injected memory, never the machine), including that a refused load spawns no process. Not covered: user-configured models outside the catalog (no known size) and the Whisper models (under 1 GB).
What has not shipped (P1’s schema half, P2, P3, P4)
settings.mlx-runtime-honest-status— [BROKEN] (#4303) a local runtime’s status dot must reflect whether it is actually provisioned/ready, not just “is local.” Owned for every runtime byruntime.status-from-the-endpoint(ai/local-runtimes.md); this line is the MLX row’s symptom. Verified in code:ProviderDetailView’s status circle isisLocalProvider || provider.hasApiKey ? Color.green : Color.orange— an unprovisioned MLX renders the identical green dot as a ready one, because noruntime_statefield exists onProviderResponseto tell them apart. This is a verified sub-symptom of #4303’s broader “MLX appears non-functional” report — a user has no visual signal that MLX needs provisioning before it will work. Unchanged by the recent Test Connection three-state fix landed on the provider-keys milestone (now section K): that fix reworked the DETAIL view’s test-result icon (KeyTestOutcome), not this row.ProviderSettingsRow’s own status dot (the provider LIST, distinct from the detail view) uses the identical stateless boolean —isLocalProvider || provider.hasApiKey ? Color.green : Color.orange— and still never reads a Test Connection result (verified in code today).settings.embeddings-download-works— [GAP] (#4304) starting an embeddings download from Settings must actually deliver a usable model. Reported broken (field issue); this pass did not trace the failure inlocal_models.py’sdownload_modelroute to a root cause, so it stays GAP rather than a code-verified BROKEN — the download route exists (local_models.py:145) but whether/why it fails was not re-verified here.settings.one-catalog-unification— [GAP] (#4307, #1059, #1200, #1342, #1152 — moved onto this milestone this pass; previously cross-milestone pointers, now folded in since each genuinely backs this same claim) cloud and local models must resolve through ONE response shape (a cloud row’s local-only fields simply absent), with one shared download-row component and one disk-usage view. Not built: cloud (ProviderAPIService) and local (LocalInferenceStore/LocalModelsSettingsView) models still come from separate response shapes and separate stores. #4307 is the direct tracker; #1059 (consolidate ~6 picker UIs), #1200 (a richer searchable browser), #1342 (centralize download location), and #1152 (a deletable models folder) are the specific sub-asks this one claim now carries.settings.embeddings-in-defaults— [GAP] (#4302, → #4307) the Defaults tab needs an Embeddings section reading the same rich model list its picker uses elsewhere — the setting exists in the data model but has no picker anywhere. Verified absent: no “Embeddings” section exists inAISettingsView+Tabs.swift’sdefaultsTab.settings.downloads-tab-retirement— [GAP] (→ #4307) the standalone Downloads tab should retire once everything it shows lives inside a provider row; today it still exists, scoped to embeddings alone (LocalModelsSettingsView.swift:9-12’s own comment says embeddings has “no provider row of their own (yet)”). Pinned (the CURRENT, un-retired shape):AISettingsDefaultsSurfaceTests.testModelManagementTabsStayInsideSettingsAndRespectTierGateasserts the Downloads tab still exists — this test will need to invert once the tab actually retires.settings.profiles-as-rows— [GAP] (#2064) the local-inference runtime profile (today_configured_omlx_profile(), an environment-keyed singleton) should become provider-linked state, and/api/local-inference/*should fold into/api/providers/{id}/…. Verified not done:local_inference.py:29still declares its ownAPIRouter(prefix="/local-inference"), entirely separate.settings.health-observability— [GAP] (#4327, #4620, #4626, #4628, #4629 — the latter four moved onto this milestone 2026-09-19 while folding legacy milestone “Settings - Models & Providers - HPC”) a status surface across providers, tiers, and tools (plus per-call visibility into the underlying LangChain routing) does not exist today — every provider row shows its own status in isolation, with nothing cross-cutting. #4620 is the umbrella “declared == embedded == runnable == surfaced” ask this behavior already answers structurally; #4626 (loove), #4628 (Whisper), and #4629 (Apple Intelligence/Vision) are per-runtime instances of the exact same complaint — each wants its OWN availability shown truthfully, which is this behavior’s claim generalized to every provider, not four separate gaps.settings.mlx-runtime-honest- statusabove is the MLX-specific case of this same pattern, already tracked there.
Runtime status (moved from ai/local-runtimes.md §E, 2026-10-04)
runtime.status-from-the-endpoint— [BROKEN] (#5367, #4303) every local row’s status comes from/api/providers/local-runtimes, which is built (api/routes/ai/provider_models.py:1274) and called by no Swift code; synthetic rows hard-codeenabled=True, has_api_key=True(review). Widenssettings.mlx-runtime-honest-statusfrom MLX to every runtime.runtime.status-covers-every-runtime— [GAP] (#5367) the endpoint reports every runtime, including Apple’s four (with Apple Intelligence’s on/off state), embeddings, Tesseract and the local servers (by probing them), with the build state and a reason.runtime.unavailable-says-so— [BROKEN] (#5367, #4973) in a sandboxed build a runtime that needs code at run time is shown as “Unavailable in this build” with the reason, never offered as Download or Provision.runtime.defaults-name-working-runtimes— [BROKEN] (#5367) the factory defaults name only runtimes present in the build, and a tier that promises a prompt never defaults to a recognition-only card; today the audio default isapple-speech(not bundled) and the$vision_*tiers default toapple-vision, which ignores prompts (db/app.py:68-78).
Keys a runtime needs (moved from ai/local-runtimes.md §F, 2026-10-04)
keys.status-sees-supplied-keys— [BROKEN] (#5369) every key-status read (provider list, catalogue, chat availability) uses the same lookup as a call, including keys the app supplies; todaykeychain.has_api_key(security/keychain.py:303) reads only the engine keychain.keys.add-provider-uses-the-one-store— [BROKEN] (#5369) a key entered while adding a provider is written to the app’s Keychain and supplied in memory, never to the engine keychain (AddProviderSheet+Helpers.swift→api/routes/ai/providers.py(review)).
Where work runs (moved from compute/targets-and-connection.md, 2026-10-04)
compute.target.lives-in-ai-settings— [GAP] (#5238) targets appear in Settings, AI, in a section titled “Where work runs”, as rows that each carry their own controls, in the way provider rows do (settings.provider-detail-carries-its-own-controls). Connecting, checking and removing a target stay incompute/targets-and-connection.md. Test: availability leg: the section and its Add control are reachable.
Dead-simple-UX check (no needless toggles)
No new user-facing toggle was found in this surface beyond what a provider’s own nature
requires (a key field for a cloud provider, a Download/Start-Stop for a local one) — the
isSettingsModelsTabEnabled feature gate is a build-tier switch, not a user preference. The
one thing that reads as unexplained plumbing today is exactly settings.mlx-runtime-honest-
status’s gap: a green dot that doesn’t mean “ready” is worse than a toggle, because it
looks like a fact rather than a choice.
Test matrix
| Leg | This surface? | Pins | File |
|---|---|---|---|
| Pure rule (Swift) | y | provider-change never blanks/auto-picks the selection | fichero/Tests/Unit/general/Views/Settings/AISettingsSelectionTests.swift |
| Availability (Swift) | y | one provider list, one shared model row, key entry reachable | ProvidersTabLayoutTests.swift, ModelRowSourceGuardrailTests.swift |
| Availability (Swift) | y | Defaults tab tier sections + tab set (current, un-retired shape) | AISettingsDefaultsSurfaceTests.swift |
| Pure rule (Swift) | y | load/save/reset never swallow a failure | AISettingsStoreTests.swift |
| Backend (pytest) | n | no test found for the local-inference/provider route split, the runtime-status fields, or the embeddings download path | — (the three GAPs above) |
| Click-around (XCUITest) | n | no dedicated Settings AI flow test found | — |
Hard-gate: none yet — this is a DRAFT spec; the hard-gate set is chosen once the phases above have owners.
Issue map (all 28 open issues on “Settings - Models & Providers”, #20) — HISTORICAL, first pass
Superseded by “Legacy milestone fold, pass 2” below (2026-09-19), which re-read every remaining issue against today’s exact fits/redirect/waiting/verify-close/triage rubric, executed the moves this table only recommended, and reflects the CURRENT open set (22 on #20, not 28 — six already moved by this table). Kept for the reasoning trail.
Disposition key: cited = moved onto ai-settings (#306), backs a behavior above by plain
citation · related = arrow-cited above, stays on its own milestone (a broader,
pre-existing tracker, not this spec’s narrower claim) · recommend-close = looks
superseded by what’s already built; the maintainer’s call, not closed here · recommend
re-home = not really about this Settings surface at all; belongs on a different milestone.
| # | Title | Disposition |
|---|---|---|
| 284 | Re-enable Settings tabs (General/Backend/Models) after 0.0.2 | recommend-close — the tabs this refers to (General/Backend/Models) are long gone from the current tab set; looks stale |
| 484 | Wire: Providers + API Keys | recommend-close — providers + keys are wired (ProvidersView, provider_keys.py); superseded by what’s built + section K |
| 485 | Wire: Local Models | recommend-close — local models are wired (local_inference.py, LocalRuntimeModelsView) |
| 752 | Settings → Local Models tab: enable + download/manage local model weights | recommend-close — built (local runtime rows + download/manage controls) |
| 853 | Apple Intelligence: proactive token budgeting | recommend re-home — an Apple Intelligence capability, not a Settings-surface concern |
| 854 | Apple Intelligence: prewarm() + contentTagging | recommend re-home — same |
| 1059 | Consolidate model/provider selection — ~6 pickers | related → cited on settings.one-catalog-unification |
| 1146 | Embed MLX Swift for Qwen3-VL / Nanonets-OCR-s; investigate Chandra | recommend re-home — a model-support feature request, not the Settings surface |
| 1152 | Model management UI: deletable spaCy/embeddings models folder | related → cited on settings.one-catalog-unification |
| 1200 | Model browser: searchable OpenRouter catalogue with filters | related → cited on settings.one-catalog-unification |
| 1325 | Settings: clean up the Models window UX | recommend-close — superseded by this spec’s more specific behaviors |
| 1342 | Centralize model downloads to Application Support/Fichero/models | related → cited on settings.one-catalog-unification |
| 1435 | Wire 27 Providers & Models endpoints into SwiftUI | recommend-close — wired (ProviderAPIService) |
| 2063 | Privacy guarantee: nothing goes online without consent | recommend re-home — a cross-cutting privacy feature, not this surface |
| 2064 | Frontend: AI-infra surface — profile picker, local-only toggle, engine/model status | cited → settings.profiles-as-rows |
| 2116 | Model selection that EDUCATES + evaluates (loove) | recommend re-home — a bigger, separate feature (partial overlap: Language Coverage window already exists) |
| 2268 | Providers/Models belong in Settings window + configurable defaults + model location | recommend-close — largely done (they ARE in Settings, with a Defaults tab) |
| 2291 | Projects/Milestones/Tasks as agent-operable objects | recommend re-home — unrelated to AI Settings |
| 2314 | Three chat modes (Simple/RAG/Agent) | recommend re-home — a chat-surface feature, not Settings |
| 2444 | Expose Translate (DeepL) as a workflow tool/node | recommend re-home — DeepL is already a working provider (real Test Connection probe, per section K); this is about workflow-node exposure, not Settings |
| 2450 | Xcode-style activity status widget in toolbar | recommend re-home — unrelated to AI Settings |
| 3411 | Settings: Fonts & Colors controls | recommend re-home — not an AI-provider concern at all; looks mis-filed on this milestone |
| 4268 | Embeddings run automatically after import, visible as activity | recommend re-home — an embeddings-pipeline/background-processing behavior, not the Settings surface |
| 4302 | Default embeddings model: download on first launch, auto-embed backfill | cited → settings.embeddings-in-defaults |
| 4303 | MLX provider in Settings is untested and appears non-functional | cited → settings.mlx-runtime-honest-status |
| 4304 | Embeddings model download from Settings fails | cited → settings.embeddings-download-works |
| 4307 | Unify AI models and embeddings into one models list | cited → settings.one-catalog-unification, settings.embeddings-in-defaults, settings.downloads-tab-retirement |
| 4327 | AI health & observability: status surface + per-call LangChain visibility | cited → settings.health-observability |
Moved onto ai-settings (#306) by number: #2064, #4302, #4303, #4304, #4307, #4327 — the
six behaviors above cite them by plain #N. Everything else stays on #20 for the maintainer’s
own triage (recommend-close / recommend-re-home are recommendations, not actions — nothing
was closed or moved beyond this list).
Legacy milestone fold, pass 2 (#20, #126, #257 — 24 open issues, selected by NUMBER)
Every body read fresh at HEAD (2026-09-19), including areas that moved this week: provider
keys live in section K now; Kraken’s runtime is verified working with
the gap being import-time wiring (importer.md’s importer.segmentation-automatic-no-toggle,
4822); pytz is declared. No issue among these 24 mentions Kraken, HPC, or remote compute —
a negative result stated plainly, not assumed.
Fits (moved onto #306, backing settings.one-catalog-unification)
#1059, #1200, #1342, #1152 — see that behavior’s own updated citation above; each is a specific sub-ask of the one-catalog claim (consolidate ~6 pickers, a richer searchable browser, centralize the download location, a deletable models folder) and now shares this milestone instead of being an arrow-pointer to elsewhere.
Redirected to an existing spec
- #484 (“Wire: Providers + API Keys”) → section K. Its own acceptance
checklist (add a provider, enter a key, Test Connection, browse the catalog) is that spec’s
subject exactly, and largely already built there (
keys.test-connection-real-probe, the Keychain-and-engine sync fix 101a67cde). - #4268 (“Embeddings run automatically after import, visible as activity”) →
importer/importer.md, which already hasimporter.embeddings-auto-at-import[OK] — this is an importer-pipeline behavior, not a Settings-surface one. - #2450 (“Xcode-style activity status widget in toolbar”) →
ui/panes-workspaces.md, which already documents the status island’s separated engine/activity/message items (panes.status-island.separates-connection-and-activityand siblings) — largely the same ask, already substantially built there, not this spec’s territory.
Verify-close — evidence posted, left OPEN, not closed here
- #284 (“Re-enable Settings tabs General/Backend/Models”) — built:
SettingsTab(App/AppState/AppState.swift:10-29) has live.general,.backend, and.aiModelscases, each mounting a real view inSettingsView.swift. - #485 (“Wire: Local Models”) — built:
LocalModelsSettingsView.swiftexists and mounts. - #752 (“Settings → Local Models tab: enable + download/manage”) — built: same view, download/remove controls present.
- #1325 (“Settings: clean up the Models window UX”) — the whole Settings surface has been
rebuilt since this was filed: one
NavigationSplitView(macOS-sidebar source-list → detail, collapsing natively on iPhone/iPad) replaced the old top-tabTabView(#3679), with per-view sections (#3680). The specific “Models window” this issue names no longer exists in that shape. - #1435 (“Wire 27 Providers & Models endpoints into SwiftUI”) — re-ran
scripts/check_endpoint_coverage_matrix.pyfresh (the measurement wascheck_ui_wiring’s before it was retired into this one, #5105): ZERO of the 27 endpoints this issue lists appear in the current unwired/unallowlisted findings. All 27 are now either called or properly allowlisted. - #2268 (“Providers/Models belong in the Settings window + defaults + model location”) —
built: they ARE in Settings (
.aiModelstab), with a Defaults tab for default model selection. - #3366 (“Settings window should expose feature-gated app menu surfaces — MCP and
Integrations”) — built:
SettingsTab.mcpmountsMCPServersView(),.integrationsmountsIntegrationsSettingsView(), both live inSettingsView.swift’s own switch. - #3678 (#257’s own issue — see the full assessment below) — every concrete deliverable it asked for is built.
Maintainer triage — no home found among the specs read this pass
- #853, #854 (Apple Intelligence
prewarm()/contentTagging; proactive token budgeting) — an Apple Intelligence RUNTIME capability, not the Settings surface; #854 is additionally blocked on an external SDK version (26.4). - #1146 (embed MLX Swift for Qwen3-VL/Nanonets-OCR-s local models) — a specific model-integration feature request, not a Settings-surface behavior.
- #2063 (global local-only/no-cloud privacy guarantee) — a cross-cutting enforcement feature at the LLM/vision dispatch layer, not a Settings-UI question, though it would show a toggle there.
- #2116 (model selection that educates/evaluates per-language fit, cost, “test on your material”) — a large, separate feature; partial overlap with an existing Language Coverage window not independently re-verified this pass.
- #2291 (Projects/Milestones/Tasks as agent-operable objects) — an in-app-agent/chat feature, unrelated to AI Settings.
- #2314 (three chat modes with an on-device AI router) — a chat-surface feature;
ui/research.mdwas checked and does not cover this specific routing ask, so not redirected there without evidence. - #2444 (expose Translate/DeepL as a workflow tool/node) — a workflow-node-exposure question, not a Settings one; DeepL itself is already a working provider per section K.
- #3411 (Fonts & Colors settings controls for library/reader/labels/inspector/editor) — genuinely mis-filed on this milestone, per the historical map’s own read, confirmed again this pass: this is general app typography/appearance, not an AI-provider concern, and no general-settings/typography spec exists to redirect it to.
#257 assessment (“Settings IA v2 + Reader/Editor Typography”, one issue: #3678)
Read in full. #3678 asked for an audit-and-design pass covering seven concrete deliverables. Every one of them has landed, each under its own tracking issue, verified on disk, not assumed from the milestone’s age:
- A macOS-sidebar (source-list → detail) Settings architecture — built: one
NavigationSplitView(SettingsView.swift:5-33, its own doc comment names #3679 as the tracker) replaced the old top-tabTabView. - Per-view settings sections (Library/Reader/Preview/Inspector) — built:
SettingsTabhas.libraryView/.previewView/.readerView/.inspectorViewcases, each commented “#3680.” - iPhone/iPad mapping — built: the same
NavigationSplitViewcollapses to a list on compact widths natively, per the same file’s doc comment — no separate iOS implementation needed. - A typography model (semantic-default + user-override font sizes, stored in
ViewSettings/@AppStorage) — built:ViewSettings.FontScale.readerKey/.editorKeyvia@AppStorageinSettingsViewPanes.swift— the exact storage location the issue itself speculated. - A Reader theme/CSS-consistency approach (semantic colors/fonts into the WebKit templates)
— built:
document_view.html’s own CSS reads Swift-injected semantic variables (--bg,--reader-text-wrap), not a hardcoded “paper” look, per its own comment citing #1280. - Paragraph-wrapping with no orphaned last-lines — built:
document_view.html’stext-wrap: var(--reader-text-wrap, pretty), its own comment naming #3684 as the tracker and “pretty” (no-orphan) as the on-target default. - A short design doc — produced:
agent-work/superpowers/specs/2026-07-13-settings- typography-ia-design.mdexists (at a different path than the issue’s owndocs/superpowers/specs/suggestion — a location detail, not a substance gap).
Nothing in #3678 reads as a live blocker today. Stated as evidence, not as “superseded”: every deliverable the issue named has a corresponding, verifiable piece of code citing its own follow-up issue number (#3679, #3680, #3684, #1280) — the audit this issue asked for evidently happened and was acted on. Left OPEN per this fold’s own rule (never call an open issue superseded, never close it myself); posted as verify-close above.
Open questions
- Answered (design lead 2026-10-04, applying the spec’s lean): Before: the provider runtime-state work is small and the catalog unification builds on it. Does P1’s schema work (
runtime_state/requires_runtime/service_stateonProviderResponse) ship before or after the P2 catalog unification, given they touch overlapping response shapes? - Does Embeddings get its own provider row (matching every other capability) once
settings.one-catalog-unificationlands, or stay a Defaults-tab-only setting with no row of its own (LocalModelsSettingsView’s “yet” suggests a row was always the plan)? - Answered (design lead 2026-10-04, applying the spec’s lean): The per-call LangChain visibility belongs to a backend observability milestone.
settings.health-observability(#4327) is broad (providers + tiers + tools + per-call LangChain visibility) — does it belong entirely in this milestone, or does the per-call LangChain piece belong to a backend-observability milestone instead? - Answered (design lead 2026-10-04, applying the spec’s lean): Close them once the catalog unification supersedes them, carrying forward distinct scope such as #1200’s OpenRouter filters. Several “related” issues (#1059, #1200, #1152, #1342) predate the ratified redesign by
months — do they get closed once
settings.one-catalog-unificationsupersedes them, or do they carry distinct scope (e.g. #1200’s OpenRouter-specific filters) that survives the unification? - Recorded request, not decided here (from the source-model spec work, branch
spec/page-model,specs/source/models-chains-and-projects.md— not in this tree): every catalogue entry should take one “model card” shape — what a model takes in and gives out, the languages/scripts/periods it suits, local-or-cloud, and a licence class — INCLUDING embedding models, which today are chosen only by an environment variable. Verified:db/ embeddings.pydefinesEMBED_MODEL_ENV = "FICHERO_EMBED_MODEL"and reads it directly (:37,42,283) — there is no catalogue row, model card, or UI surface for choosing an embedding model today, exactly as the request describes. Whether/how this folds intosettings.one-catalog-unificationabove is not decided here. - Is the three-tab target (Defaults · Providers · Advanced) still right, or does Embeddings getting a provider row change what “Downloads retiring” even means?
K. Provider keys (folded from ai/provider-keys.md, 2026-10-04)
Intent (the design)
A researcher enters a provider’s API key once, in Settings, and it keeps working across every future launch — a restart never quietly reverts to an old or deleted key. Removing a key removes it, permanently, not until the next relaunch. There is exactly ONE place a local engine’s keys live; a remote engine’s keys are that host’s own business, never the app’s. And the app never claims a key works unless it actually checked: a “connection valid” green check means a real probe answered, never “some non-empty string was present.”
Why this spec exists
A field report surfaced two verified defects with no spec behind either of them:
- #4815 — every app launch re-pushes a stale, once-migrated copy of a provider’s key to
the engine, silently undoing a Settings save or a Settings remove. A whole afternoon of
OpenRouter
401s traced to exactly this: the key was fixed by hand in Settings, worked for 70 consecutive calls, then the next launch resurrected the old, broken key. - #4816 — Test Connection reports success for any provider with no real probe wired (OpenRouter among them) the moment the key field is non-empty, regardless of whether the key is valid. A user who followed the app’s own “Update API key in Settings” advice, then ran Test Connection and saw a green check, had no way to know the check meant nothing.
Both share one root cause worth naming once: a claim (“this key is saved”, “this connection works”) that nothing in the code actually verifies.
Two stores of truth (the shape of #4815)
- App-owned Keychain (
ProviderKeyStore.swift, serviceapp.fichero.fichero.provider-keys) — written exactly once, by a one-time migration off the engine’s legacy keychain item (migrateFromLegacyIfNeeded); after that,.alreadyOwnedforever. Pushed to the engine on EVERY connect (EngineLifecycleController+ProviderKeys.swift). - Settings’ own path (
ProvidersView+ProviderDetailView.swift’ssaveAPIKey/removeAPIKey) never touches the app-owned Keychain at all — it only calls the HTTPproviderService.setAPIKey/deleteAPIKey, which reach the engine’s OWN legacy keychain copy and its in-memory supplied-keys dict, neverProviderKeyStore.
So a Settings save/remove changes the engine’s copy for the rest of that session, and the next launch’s connect sequence re-pushes whatever the app-owned Keychain item still holds — the value from the original migration, unaffected by anything Settings has done since.
Behaviors
Persistence (#4815)
keys.one-store-of-truth— [OK] (101a67cde, #4815 closed) a local engine’s key lives in exactly one place the app treats as authoritative (the app Keychain). Before the fix there were two: the app-owned Keychain item (written once, at migration) and the engine’s own legacy Keychain item + in-memory supply (written by every Settings save/remove and re-supplied on every connect) —grep ProviderKeyStore fichero/ficherofound only the three launch-push call sites and the migration itself;store/removehad no production caller from Settings. Pinned:ProviderAPIServiceKeyPersistenceTests.testSetAPIKeySuccessStoresTheTrimmedKey,::testSetAndDeleteAPIKeySkipTheKeychainForARemoteEngine,::testSupplyAPIKeyToEngineBodyNeverReferencesTheKeychainClosures,::testSupplyAPIKeyToEngineHasExactlyOneCaller(all 4 read in full and confirmed to assert exactly this —fichero/Tests/Unit/general/Services/ProviderAPIServiceKeyPersistenceTests.swift).keys.settings-save-survives-relaunch— [OK] (101a67cde, #4815 closed) saving a new key in Settings stays in effect after the next launch. Pinned:ProviderAPIServiceKeyPersistenceTests.testSetAPIKeySuccessStoresTheTrimmedKey,::testStaleKeyRegression_settingsSaveIsReflectedByTheNextEngineSupply(the regression test named in the issue itself: a stale key seeded, a new one saved, the launch push’s own read-then-supply shape reads back the NEW value, never the stale one),::testSetAPIKeySuccessButKeychainFailureThrowsDistinctErrorWithNoKeyMaterial. Honest gap: the suite cannot re-read the WIRE body to independently confirm the engine received the trimmed value byte-for-byte — the generated client sends this POST as an upload task, whose body aURLProtocolstub cannot see (the same limitationBatchServiceTests.swiftalready documents). What IS proven:setAPIKeycomputes ONEtrimmedlocal and passes that SAME value to both the engine call and the Keychain closure (a source contract,testSetAPIKeyTrimsOnceForBothTheEngineAndTheKeychain) plus the closure receiving the expected trimmed string dynamically — the closest honest proof available without the wire-body seam.keys.remove-survives-relaunch— [OK] (101a67cde, #4815 closed) removing a key in Settings stays removed after the next launch. Pinned:ProviderAPIServiceKeyPersistenceTests.testDeleteAPIKeySuccessRemovesTheKeyAndLeavesNothingForTheNextLaunchPush.keys.launch-supplies-to-engine— [PARTIAL] (#4819, implemented, unpinned) the app supplies every candidate provider’s app-owned key to the engine on every connect (EngineLifecycleController+ProviderKeys.swift:27-66,supplyProviderKeysToEngine()) — this mechanism is real and IS what makes the launch-push problem above visible (it works correctly, from a stale source). No Swift test exercisessupplyProviderKeysToEngine()directly;ProviderKeyStoreTests.swiftonly covers the underlyingProviderKeyStoreprimitives it calls, not the connect-time supply loop itself.keys.remote-engine-not-app-business— [PARTIAL] (#4820, implemented, unpinned) a remote engine’s provider keys are that host’s own configuration; the app must never push its local Keychain keys to one.supplyProviderKeysToEngine()guards this explicitly (guard !EngineConfig.engineProvisioningStrategy().connectsToRemoteHost else { return },EngineLifecycleController+ProviderKeys.swift:28-31) — real code, but no test asserts the guard actually short-circuits for a remote-configured engine.
Verification (#4816)
keys.test-connection-real-probe— [OK] (bca344581 app half; 35c4b53f1 + 5a9676909 engine; #4816 closed) Test Connection only reports success when the app made a real network call to the provider and the provider confirmed the key, and a wrong/rate-limited/down endpoint never reads as a bad key. Provider breakdown (engine side): real probes —apple_vision,apple_intelligence(system checks),ollama,lmstudio(server reachability, no key involved),openai,huggingface,google,groq,deepl(the five original probes, now applying the same “only 401/403 means a bad key” rule), plus NINE added:openrouter,anthropic(a real authenticated request, no longer prefix-only),mistral,together,deepseek,xai,perplexity,fireworks,cohere. Still unverifiable from here:azure,bedrock,dashscope— these report a distinct “saved, could not verify” state rather than a false green check. The rule throughout: only a401/403(or a provider’s own documented bad-key status — Google’s400, kept as its real signal) means the key is bad; any OTHER non-2xx status means “could not verify,” never “invalid.” On the app side,KeyTestOutcome.from(success:verified:)is the pure derivation (readKeyTestOutcomeTests.swiftin full — exhaustive over all 6(success, verified)combinations, including anilverifiedfrom an engine with no opinion yet correctly landing on “saved, not verified,” never a positive claim) thatProvidersView+ProviderDetailView.swiftnow renders from instead ofresult.successalone. Pinned:test_routes_provider_keys.py::test_connection_test_real_probe_success_sets_verified,::test_connection_test_real_probe_401_fails_unverified,::test_connection_test_real_probe_network_failure_reports_connectivity,::test_connection_test_real_probe_non_auth_status_is_unverified_not_failed,::test_connection_test_key_never_appears_in_response_or_logs,KeyTestOutcomeTests.testSuccessAndVerifiedTrueIsVerified,.testSuccessAndVerifiedFalseIsSavedNotVerified,.testSuccessAndVerifiedNilIsSavedNotVerified,.testFailureAndVerifiedTrueIsStillFailed,.testFailureAndVerifiedFalseIsFailed,.testFailureAndVerifiedNilIsFailed.keys.untested-provider-reports-not-verified— [OK] (bca344581, #4816 closed) an untested provider reports a distinct “not verified” state and renders as neutral, never a green check — built on both sides now: the engine emitsConnectionTestResponse.verified: bool | Noneas a third state distinct fromsuccess(test_connection_test_untested_provider_reports_saved_not_verified, “Key saved — this provider cannot be verified from here”,azure/bedrock/dashscope); the app derivesKeyTestOutcomefrom the pair rather than keying its icon onresult.successalone. Pinned:test_routes_provider_keys.py::test_connection_test_untested_provider_reports_saved_not_verified,KeyTestOutcomeTests.testSavedNotVerifiedTintIsNeverGreen,.testVerifiedTintIsGreenAndFailedTintIsRed,.testEachOutcomeHasADistinctIcon,.testProviderDetailViewDoesNotKeyTheTestIconOnSuccessAlone(a source-scan guarding the regression directly: the old binaryresult.success ? "checkmark..." : "xmark..."ternary must never come back). What remains, honestly: the PROVIDER LIST row’s own status dot (ProviderSettingsRow) is unaffected by any of this — it is stateless and never sees a Test Connection result at all, stillisLocalProvider || provider.hasApiKey ? .green : .orange(verified in code today). That gap belongs toai/ai-settings.md’s local-runtime honest status behavior on the ai-settings milestone, a different behavior, unchanged by this fix.
Redirected from the legacy “Settings - Models & Providers” milestone
- #484 (“Wire: Providers + API Keys”) — redirected while folding
ai-settings.md’s pass - Its own acceptance checklist (add a provider, enter an API key, Test Connection, browse
the model catalog) is verify-close against
keys.test-connection-real-probeabove and the provider-management surface this spec already documents — not re-litigated as a fresh claim here since #484 itself names no gap beyond what’s already built or already tracked.
Legacy milestone fold — “Settings - Models & Providers - HPC” (#240), 2026-09-19
Two of the milestone’s seven issues in this spec’s scope are genuinely this spec’s own subject;
the rest belong to ai-settings.md or maintainer triage (folded there, not restated here).
- #4631 (“Google AI: availability + model-list parity”) and #4632 (“Hugging Face:
availability + model-list parity”) — each names two asks. The AVAILABILITY half (“shown iff
key configured + reachable”) is a verify-close against
keys.test-connection-real-probeabove, which already does exactly this for every registered provider, Google AI and Hugging Face included — evidence posted on both, left open. The MODEL-LIST-PARITY half (“== Settings across surfaces”) is NOT this spec’s claim — it’sai-settings.md’ssettings.one-catalog-unification(GAP), cross-referenced there, not duplicated here.
Apple Vision as an OCR/vision capability
-
keys.apple-vision-is-a-capability-not-only-a-key-check— [GAP] (#2060, redirected from the legacy “Importer” milestone while foldingimporter.md’s pass 2)apple_visionis already a recognized provider with a real connection probe (keys.test-connection-real-probeabove), but this issue’s actual ask is broader: using Apple’s Vision framework as an on-device OCR/vision ENGINE the importer or a workflow can choose, alongside cloud OCR providers — not only a settings-row key check. Whether Vision is wired as a selectable OCR/transcription engine anywhere in the import or workflow path was not verified this pass; the provider-key surface and the actual capability are two different questions, and this behavior is the capability one. -
keys.key-never-in-logs— [PARTIAL] (#4821) the app-supplied in-process key is never logged:supply_api_key(fichero-server/src/fichero_server/security/provider_keys.py:39-58) logs only the provider name and the fact of supply, explicitly documented (“Never log the key”) and pinned bytest_supplied_provider_keys.py::test_the_key_is_never_logged. The two other paths that touch a key value —set_provider_api_key_impl’s route-level logging (api/routes/ai/provider_keys.py:114,120, logs only the provider name) andkeychain.py’sset_api_key/delete_api_keydebug/warning lines (keychain.py:266-299, also provider-name-only) — were read and confirmed to never log the key value either, but neither has a dedicated test guarding it, hence PARTIAL rather than a blanket OK. A second pin lands with the Test Connection probe work:test_routes_provider_keys.py::test_connection_test_key_never_appears_in_response_or_logs(a sentinel key never appears in the response body, its JSON serialization, or the log capture, across a real probe path). keys.argv-exposure— [GAP] (#4818)keychain.py:253-265passes the plaintext key as-w <key>in argv to/usr/bin/security add-generic-password, visible to any other process on the machine (e.g.ps) for the subprocess’s brief lifetime. Needs a design decision (stdin-based write, or a non-shelling-out primitive) before it can be fixed.
Mid-run correctness
keys.per-call-key-resolution— [OK] a workflow already in progress must pick up a key change without a process restart.llm.get_api_key(provider)(fichero-server/src/fichero_server/llm/__init__.py:1222-1246) resolves per call through a process-level cache that both the Keychain write path (keychain.py’s_invalidate_llm_api_key_cache) and the app-supplied path (provider_keys.py’s_invalidate_llm_cache) bust on every write/supply/forget. Pinned:test_llm_api_key_cache.py::TestKeychainWriteInvalidatesCache::test_set_api_key_invalidates_cache,::test_delete_api_key_invalidates_cache,test_supplied_provider_keys.py::test_supplying_a_key_busts_the_resolution_cache.
Test matrix
| Leg | This surface? | Pins | File |
|---|---|---|---|
| Pure rule (Swift) | y | ProviderKeyStore primitives (store/read/remove/trim/migrate) |
fichero/Tests/Unit/general/Services/ProviderKeyStoreTests.swift |
| Availability (Swift) | y | supplyProviderKeysToEngine() pushes the CURRENT app-owned key, including after a Settings save/remove; remote-engine guard short-circuits |
proposed, no file yet — the #4815 regression test |
| Backend (pytest) | y | per-call key resolution + cache invalidation on write/supply/forget | fichero-server/tests/unit/security/test_llm_api_key_cache.py, test_supplied_provider_keys.py |
| Backend (pytest) | y | /test returns a real probe result per provider, not_verified for the rest |
fichero-server/tests/unit/api/test_routes_provider_keys.py (test_connection_test_real_probe_success_sets_verified, test_connection_test_untested_provider_reports_saved_not_verified) |
| Click-around (XCUITest) | n | this is a Settings + engine-connect contract, not a full-app flow worth a dedicated UI test yet | — |
Hard-gate: keys.settings-save-survives-relaunch, keys.remove-survives-relaunch,
keys.test-connection-real-probe — these three are exactly what the field report broke.
Open questions
- Does the app-side fix (
saveAPIKey/removeAPIKeyalso writing/removingProviderKeyStore) fully retire the engine’s own legacy-keychain write path (provider_keys.py:143-148’s own docstring already says the app-supplied POST should be memory-only), or does that engine-side cleanup wait for a separate pass? keys.untested-provider-reports-not-verified: does “not verified” ever get its own real probe over time (starting with OpenRouter, per #4816’s fix), or does the provider list grow faster than probes can be written, making “not verified” a permanent honest floor for most entries?- Should
anthropic’s format-only check be reclassified as “not verified” too, since it makes no network call, or does prefix-validity count as a legitimate lightweight probe distinct from theelsebranch’s true no-check? keys.argv-exposure(#4818): stdin-basedsecurityinvocation, or move off shelling out to/usr/bin/securityentirely in favor of a Swift-side write only (the app already ownsSecItemcalls directly inProviderKeyStore.swift) — does the engine need to write a keychain item at all once #4815 lands, or does #4815’s fix make the engine-side Keychain write dead code?keys.launch-supplies-to-engine/keys.remote-engine-not-app-business: worth a dedicated Swift test now, or fold into the same regression test #4815 already calls for?
Legacy milestone note
“Settings - Models & Providers” (#20) is the maintainer’s own 55-row triage queue and was not touched beyond moving #4815/#4816 off it. Of its other open issues, only #484 “Wire: Providers + API Keys” is really about provider KEYS specifically (title search across the milestone) — everything else on it is about models, embeddings, MLX, or the broader Settings UI, not key persistence/verification. Flagged for the maintainer’s fold-in decision, not moved.
M. One model list and row, everywhere (folded from ui/model-selector-consistency.md, 2026-10-04)
Folded from ui/model-selector-consistency.md on 2026-10-04 (its DRAFT of 2026-09-15). The brief: the model and key picker differed between the document island, the workflow bar and Settings; the island’s looked best, and the others should match it. [PROPOSED] tags were replaced with real ones on folding.
Intent (the design)
Choosing which model runs is ONE decision the user makes in several places. Wherever it appears — the window’s model chip, the workflow bar’s per-step picker, a chat toolbar, a comparison sheet — it must look and behave the SAME: same rows, same family glyph, same cost display, same grouping, same “configured tiers first,” same vision/selection awareness. Settings is the one different job (it MANAGES the catalog — add/remove/configure keys), but even it renders the same ROW so a model looks identical whether you’re picking it or configuring it. One picker component, one list policy, one row.
This is the same “one source, many surfaces” principle as [[menus-and-commands]] and the workspace consolidation.
Current architecture (grounded, 2026-09-15) — ~7 pickers, 3 list-builders
There is no shared picker. At least seven implementations render “choose a model”:
| Surface | File | Shape | List logic |
|---|---|---|---|
| Document island (REFERENCE) | Shell/Toolbar/ModelChipToolbarItem.swift (ModelChipToolbarItem + ModelFamilyMark + ModelPickerRow) |
compact chip → popover | selection-aware (vision vs text tier); loads its own provider cache on menu-open |
| Workflow bar (per step) | Shell/Toolbar/WorkflowBarModelPicker.swift (+ WorkflowBarModelTier) |
Menu | configured tiers first, then provider-grouped, deduped on provider+model, vision flag + cost |
| Settings | Settings/AI/AIProviders/AIModelSelectionView.swift (+ AIModelCatalog, ModelRowView) |
full filtered list | filter (capabilities/mode) + sort (cost/…) + search + add-model — a MANAGEMENT view |
| Chat toolbar | Chat/ChatViewToolbar.swift (ChatModelPicker) |
Menu | its own |
| Comparison | Chat/ModelComparison/ModelPickerSheet.swift |
sheet | its own |
| Workflow node | Workflow/Nodes/ModelPicker.swift, NodeProviderModelSelector.swift |
inline | its own |
Finding S1 — three different list-builders for the same list. The island loads a provider cache on open; the workflow bar has a pure tier-first/provider-grouped/deduped builder; Settings has a filter+sort pipeline. They each claim to use “the same provider cache the Run Workflow menu uses,” but assemble and order it differently — so the same account can show different models, in a different order, at different (or no) cost, depending on where you look.
Finding S2 — the ROW is drawn three ways. The island’s ModelPickerRow + ModelFamilyMark
(family glyph, tier, vision hint) is the nicest; the workflow bar draws its own menu rows; Settings’
ModelRowView draws its own with cost/capabilities. A model has three faces.
Finding S3 — vision/selection awareness is island-only. Only ModelChipToolbarItem narrows to
vision-capable models when a page is selected. The workflow bar carries a visionFlag but the chat
and node pickers don’t consistently. Awareness should be a property of the shared component.
Finding S4 — cost display is inconsistent. Settings and the workflow bar show per-million cost; the island shows tier/family; chat shows neither. Cost is a first-class decision input and should render the same everywhere it’s shown.
Proposed design — one picker, one list policy, one row (build on the island)
1. Extract the island’s row as the shared row — [PROPOSED]
ModelFamilyMark + ModelPickerRow (the reference’s row: family glyph · name · tier · vision hint ·
cost) become a shared component every surface renders — the island, workflow bar, chat, comparison
node, AND Settings’ management list (same row, plus its add/remove affordance). No surface hand-draws
a model row.
2. One pure list-builder — [PROPOSED]
Generalize WorkflowBarModelPicker’s already-pure builder (configured tiers first → provider-grouped
→ deduped on provider+model → vision flag → cost, with a tiers-only fallback when the cache is
empty) into the ONE model-list function. The island, workflow bar, chat and node pickers all call it;
Settings’ management view filters/sorts ON TOP of the same base list. Selection/vision-awareness is a
parameter (the island passes “vision” when a page is selected), not a fork.
3. One picker component, two presentations — [PROPOSED]
A single ModelPicker view (the island’s chip+popover as the canonical presentation) with a compact
mode (chip, for toolbar/workflow-bar/chat) and, where a sheet is warranted (comparison), the same
rows in a sheet. Settings keeps its management chrome but hosts the shared rows. One component, so
grouping, family marks, cost and vision awareness can’t drift.
4. Provider/API-key affordance is consistent — [PROPOSED]
Adding/choosing a provider key surfaces the same way from every picker (a “Manage providers…” route
into Settings), so a picker that finds no configured model always offers the same next step, never a
dead empty menu (mirrors SidebarContextMenuPolicyTests’ never-silently-empty rule).
Behaviors (each → one pinning test)
models.one-list-policy— [PARTIAL] (#4665, #4883; built:SharedModelListBuilderwithSharedModelListBuilderTests; not yet every picker) every picker’s base list comes from the ONE pure builder; same account → same models, same order, same dedupe (provider+model), everywhere. Test: pure unit tests over the builder (extend the existingWorkflowBarModelPickerlist tests): tier-first order, provider+model dedupe, tiers-only fallback, vision filter narrows correctly.models.one-row— [PARTIAL] (#4883; built:SharedModelRowwithModelRowSourceGuardrailTests; not yet every surface) the island, workflow bar, chat and Settings render the SAME row component (family mark + name + tier + cost). Test: a source guardrail that no surface defines its own model-row struct once the shared one exists.models.vision-awareness— [GAP] (#4883) a page/vision selection narrows every picker to vision-capable models identically. Test: the builder’s vision-filter unit test, plus the island’s existing selection→tier resolution test.models.cost-shown-consistently— [GAP] (#4883) where cost is shown it is the same value and format across surfaces. Test: a formatter unit test + a render check.models.never-empty-offers-providers— [GAP] (#4883) a picker with no configured model always offers the Manage-providers route, never a silent empty menu. Test: pure policy test (theSidebarContextMenuPolicyTestsfallback shape).
Maintainer test, 2026-09-19 morning
Evidence for the “~7 pickers, 3 list-builders” finding above, plus a fourth captured picker and a ruling.
models.four-pickers-today— [BROKEN] (#4883) what each of four surfaces shows today, as observed live (workflow bar, centre island, Settings > AI > Defaults, and — newly captured, screenshot 9.40.17 — the workflow node’s config popover):
| Picker | Shows today |
|---|---|
| Workflow bar | flat text list, “Use the default (model)” first, each row a name + a loose tag mixing capability/size/provider (“Vision”, “Text”, “Large”, “apple”, “openrouter”, “huggingface”, “spacy”, “kraken”, “whisper”); no icons, no prices; unavailable models greyed |
| Centre island | provider icon, name, price per million tokens in/out, an eye icon for vision-capable models, a tick on the current one, provider name at the right, “AI Settings…” link at the bottom |
| Settings > AI > Defaults | grouped under provider headings (Apple Intelligence, Hugging Face, …), full ids (“datalab-to/chandra-ocr-2”), descriptive names (“Apple Vision (OCR)”), a “None” row |
| Workflow node config popover | lists ONLY the role defaults, each with a generic “?” icon and no provider icon: Default, $small, $large, $vision_small, $vision_medium, $vision_large; lists NO concrete models at all |
Four different shapes, confirming Finding S1/S2 above with a fourth data point rather than
the three already catalogued. Whether a row shows price, capability, provider, or all three
remains an open DESIGN question (see “Open questions,” item three, below) — not decided by this finding.
- models.four-pickers-four-sources — [PARTIAL] (#4883) cause, verified at each file at the
time this behavior was written: four pickers, four different data sources, not just four
different rows/layouts, with the workflow bar’s modelPinMenu (hand-drawn Button rows, no
SharedModelRow/SharedModelChoice use) the one outlier surface. Updated 2026-09-19
(705e65cf1): per that commit’s own account, five of the six picker surfaces already shared
SharedModelListBuilder before this fix — the “zero adoption” framing above is stale, corrected
here. This commit closes the workflow bar’s own remaining gap: its model control is now the
same popover idiom the step inspector uses, rendering SharedModelRow, and its two hand-drawn
menus (modelMenu, modelPinMenu) are deleted outright. Which source is canonical is NOT an
open question — the node popover’s own code already cites the ruling:
docs/contributor_manual/specs/ui/workflow-node-config.md’s
nodeconfig.model.same-list-as-settings (ruled 2026-09-08, [OK], pinned
NodeModelListParityTests) states pickers offer the user-CONFIGURED models, because the live
catalog used to let a node pick a model its provider does not actually serve → a 404 at run.
PARTIAL, not OK: NOT SEEN ON SCREEN (the fixing commit’s own words — build passes, tests
pass, “how the popover and its rows look on screen” is unverified); the island’s own catalog
source and Settings’ own row are still open per the maintainer’s own outstanding questions
(below). Pinned: SharedModelListBuilderTests (17 cases), NodeModelListParityTests,
AISettingsSelectionTests, ModelRowSourceGuardrailTests — all executed through Xcode per
the commit, all passing.
- models.role-defaults-always-offered — [PARTIAL, RULED] (#4883) every model picker offers
the ROLE DEFAULTS (small, large, vision small, and so on) as choices ALONGSIDE concrete models
— in the workflow bar and the document island the maintainer must be able to choose “small”,
“large”, “vision small”, etc, not only a named model. The one shared picker (§”Proposed
design” above) should have two groups: role defaults (resolved to whatever Settings > AI >
Defaults currently names, and SAYING which model that is) and concrete models. At the time
this behavior was written, only the workflow node popover offered role defaults, and it
offered ONLY those, no concrete models — the opposite gap. Updated 2026-09-19 (705e65cf1):
role defaults now have ONE home, SharedModelListBuilder.roleDefaultAliases(from:
includeVision:) — it builds $small/$large/the three vision aliases from AIDefaults,
each naming the concrete model it resolves to today (or “not set”); no such resolver existed
before. The node popover’s own private aliasOptions is deleted and now takes the shared
rows; the workflow bar’s new popover offers every role default alongside the concrete models
(picking a role default stores the ALIAS, not the resolved model, so a run-level override
never silently freezes — a text alias chosen on a vision step raises, and the bar already
marks that choice unsuitable). Still open, per the maintainer, and NOT decided here: how the
ISLAND offers role defaults (it stores a CONCRETE (provider, model) pair only —
ModelChipToolbarItem.swift:259, select(_:) — it is where a role default GETS its value, so
it cannot hold a role alias today) and what SETTINGS’ own row should show. NOT SEEN ON
SCREEN — same disclaimer as models.four-pickers-four-sources above.
- models.node-popover-vision-check-diverges — [BROKEN] (#4694) the workflow node config
popover says “No vision-capable providers available” in orange on a vision node
(screenshot 9.40.17), while the other three pickers on the same machine show vision-capable
models as available (apple-vision, claude-opus-5, and others with the eye icon) at the same
time. Cause: UNVERIFIED — two candidates, both consistent with the code, neither confirmed
against what the maintainer’s machine actually returned. (a) Stale-capabilities path:
NodePopover+Comparison.swift:28-32 derives supportsVision from the SAVED rows’
capabilities; the id-based heuristic (idLooksVisionCapable) runs only when capabilities
is empty — so a saved row with a non-empty capabilities set that happens to lack “vision”
is marked not-vision-capable even if the island’s live catalog flags the same model as
vision-capable. This is a catalog-content divergence, not a load race. (b) Empty-providers
path: loadProviders() (:8-23) only appends a provider when provider.enabled is true and
listProviderModels succeeds — the screenshot shows the popover listing NO concrete models
at all, only role defaults, which equally fits zero enabled/configured providers returned, or
an unhandled throw into the function’s own catch. Both are named; neither is the confirmed
cause. #4694 already names the same CLASS of bug (“Node picker filters providers on the
provider-level vision flag”) — evidence added there rather than duplicated as a new issue.
- models.chat-picker-uses-the-shared-builder — [PARTIAL] (#4900) the chat toolbar’s model
picker (ChatViewToolbar.swift, ChatModelPicker) renders the shared
SharedModelListBuilder/SharedModelRow spine, not its own inline picker. Built: 21820e8d2
(82 insertions, 31 deletions). PARTIAL because nothing tests it — the commit’s own message
states outright: “No tests reference this surface.” A high-traffic surface (every chat
window’s model picker) with zero coverage of the rewrite. The test shape that would catch a
regression: drive the picker through the real shared builder and assert the rendered choice
set, PLUS an explicit companion asserting the chat toolbar’s production call site still
routes through it rather than a re-inlined picker — the same pure-function-plus-
call-site-routing pairing AISettingsSelectionTests uses for its own
“productionCallSiteRoutesOnlyThroughThePureFunction” check.
RATIFIED 2026-09-15 (evening, CD)
- Adopt the shared spine. One shared ROW (the island’s
ModelFamilyMark/ModelPickerRow) + one pure list-builder (generalizeWorkflowBarModelPicker’s tier-first / provider-grouped / provider+model-deduped / vision+cost builder). Every surface — island, workflow bar, chat, comparison, nodes — renders both. The ~7 divergent pickers collapse to one spine. Land subtractively (shared row → shared builder → per-surface adoption), each step pinned by a pure list-builder test. Keep the builder PURE (the island fetches its own cache outside theLibraryWorkspaceRootenv, #4448) — don’t bake an environment read into it. - Settings uses the SAME picker — direct, one-step selection. In the Settings window you choose a model the SAME way as the island: the shared picker (chip → popover of the shared rows), picked directly — NOT “open a menu, then a submenu” to drill to a model. Settings keeps its management affordances (add/remove providers, keys, filters) around that picker, but the act of CHOOSING the active/default model is the one shared component, so it looks and behaves identically to the island. (This supersedes “Settings reuses the ROW only” — it reuses the whole picker for selection.)
- Platform = iPad / Mac / iOS first-class (matches [[menus-and-commands]]): the shared row + builder must render correctly on all three; test each.
Open questions (still open)
- Chip vs menu vs sheet — ANSWERED 2026-09-19 (705e65cf1): the workflow bar’s model
control is now the same popover idiom the step inspector uses (not a
Menu), renderingSharedModelRow; itsChoose Modelright-click menu item opens the same popover. Not seen on screen yet, per that commit’s own disclaimer. - Settings’ scope — Settings manages keys/catalog (a superset job). Confirm it reuses the shared ROW only, keeping its filter/sort/add chrome — not that it collapses into the picker.
- Cost everywhere? — should the island show per-million cost too (it shows tier/family today), or
is cost a workflow-bar/settings concern where budget matters most? Candidate answer recorded,
not decided here (from the source-model spec work, branch
spec/page-model,specs/source/ models-chains-and-projects.md— not in this tree): a row could show what a “model card” knows — what it suits (languages/scripts/periods), whether it’s local or cloud, and a licence class — alongside or instead of price/capability/provider. Seeai-settings.md’s own Open Question 5 for the model-card shape this candidate answer depends on. - Node vs step pickers — the workflow NODE pickers (
ModelPicker,NodeProviderModelSelector) and the workflow BAR picker — one component for both, or do nodes need more (provider+model+params)? - How does the island offer role defaults? (
models.role-defaults-always-offered) The island is verified to store only a resolved concrete(provider, model)pair — it IS where $medium/ $visionMedium get their value, so it cannot itself hold a role-alias sentinel the way the node popover’saliasOptionsdo. Does picking “$medium” from the island mean “show me what $medium currently resolves to, then let me change that value” (the island stays the editor of the default), or does the shared picker need a genuinely separate role-alias affordance the island does not have today?
Ponytail review (2026-09-15)
The lazy, correct move is to promote what already works, not add a new abstraction: the island’s
ModelFamilyMark/ModelPickerRow is the nicest row — make it the shared one; the workflow bar
ALREADY has the pure list-builder with dedupe/tiers/vision/cost — make it THE one and delete the other
list logics. Net change is deletions plus a couple of extractions, not a new picker framework.
Watch-outs:
- Settings is a different job (management), not just a picker — reuse its ROW, don’t force it into
the chip. Collapsing them would lose the add/remove/key affordances.
- Data source boundary — the island fetches its own cache because a toolbar item lives OUTSIDE the
LibraryWorkspaceRoot env tree (ModelChipToolbarItem documents this #4448 boundary). The shared
list-builder must stay PURE (takes [LLMProvider] + tier defaults), so each host feeds it from
wherever it legitimately gets the cache — don’t bake an environment read into the shared component.
- Don’t over-unify presentation — chip, menu and sheet are legitimately different containers for
different contexts; share the rows and the list policy, not necessarily one container.
- Land it in increments (shared row first → shared list-builder → per-surface adoption), each pinned
by an extended existing test, exactly like the menu and workspace programs.
Verdict: adopt the island’s row + the workflow bar’s pure list-builder as the shared spine; Settings reuses the row; land subtractively. Ready for CD review, not yet code.
Triaged from the backlog (2026-10-04)
models.curated-catalog-clean-licences— [GAP] (#4611) Chandra-OCR and Nanonets-OCR are removed from MANAGED_MLX_MODELS (both still present at mlx_model_store.py:111,125).settings.local-only-mode— [GAP] (#2063) a global local-only switch in Settings and a visible indicator; the engine already refuses cloud calls under FICHERO_LOCAL_ONLY (llm/init.py:1166 enforce_local_only_provider) but no app toggle or indicator exists.ai.onboarding-leaves-a-working-setup— [GAP] (#5118) onboarding ends with a model that will run: RAM headroom is checked and HuggingFace discovery fills fields rather than chatting.settings.models.provider-status-says-what-is-true— [GAP] (#4964) a provider row’s dot is green only when a run would work now, and otherwise says not installed, installing with progress, install failed with the reason (Kraken today shows green while uninstallable).
Future (ideas, not scheduled)
- (#4177) Per-provider smoke tests across Apple Intelligence, MLX and online providers through the one provider interface.