Qt-removal R7.4a: API-provider settings page (backend+frontend)#142
Merged
Conversation
Closes part of the R2.5d-flagged deferral: OpenAI-Compatible/Anthropic Claude/Google Gemini endpoint config, per-task model assignment, live catalog refresh, and write-only key persistence are now real in the new backend/frontend stack. Ollama and Llama.cpp remain deferred (R7.4b/c). Backend: 4 new intents on the existing "app-settings" topic (setViewingApiProvider, loadApiModels, saveApiConfiguration, resetApiSettings), wired onto the same asyncio.to_thread + _manager_lock pattern General/Integrations already use. Deliberately improves on legacy parity rather than matching it exactly: the API key field never pre-fills with the real key (the old Qt widget did, safe only in-process) - apiKeyConfigured is a per-provider bool, matching the GitHub-token write-only precedent. A 4-way adversarial review (backend/frontend/integration/security, 11 raw findings, 9 confirmed after independent verification) caught real bugs, all fixed with regression tests: - A data-loss race in save_api_configuration: the other 2 providers' "keep as-is" keys were read outside the lock, before an await, so a concurrent save for a different provider could get silently reverted. Fixed by moving the read inside the locked _persist(). - The same class of race in load_api_models against api_provider.py's process-global state - fixed with a post-hoc consistency check. - A security regression: a rejected API key could ride inside a raw provider-SDK exception straight into a WS-broadcast message, unredacted - fixed with a _redact() helper (the legacy bridge this replaces had this redaction; the port had dropped it). - Catalog-refresh status/message were flat cells that leaked one provider's outcome onto another provider's page after a switch - fixed by keying catalog state per-provider. - Reset API Settings only cleared the local key draft, leaving stale Base URL/model drafts a subsequent Save could silently re-persist. - An uncaught AttributeError on a non-dict intent argument. Verification: compileall clean, burn-down pin unchanged (152/84/68), 2430 backend pytest passed, 1064 frontend vitest passed, tsc/eslint/build clean, plus a live drive against a real running backend over a genuine WebSocket (isolated scratch settings file) confirming the real wire payload, validation notices, network-failure path with key redaction, and reset round-trip. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Jul 26, 2026
dovvnloading
added a commit
that referenced
this pull request
Jul 26, 2026
test_a_user_save_waits_out_a_real_in_flight_autosave_tick_instead_of_being_dropped failed once on main after #142 merged, then passed on a re-run of identical code. It is a genuine flake, and the fragility is mine - I wrote this test in #138. Cause: the test raced the real autosave loop with interval_seconds=0.02 while each tick held the guard for ~0.1s. Because _loop sleeps AFTER a tick, ticks ran effectively back-to-back, so the user's save had to win a scheduling race in the narrow window between one tick releasing the guard and the next re-claiming it. On top of that it asserted a 1.0s wall-clock bound against a 2.0s production timeout - a 2x margin on a contended 2-core CI runner. Fixed by removing the race rather than widening the bound: - register_autosave now exposes bus.autosave_guarded_tick, the same testability seam already used for bus.autosave_task and bus.chat_save_state (the file's own comments justify exactly this: a closure-local is unreachable to the tests that must prove the wiring). - The test drives exactly ONE real tick through that seam with interval_seconds=3600, so the periodic loop can never fire and no second tick can steal the guard back mid-handoff. - The tick's duration is controlled by a gate instead of a sleep, so the user's save provably arrives while the guard is genuinely held. - AUTOSAVE_YIELD_TIMEOUT_SECONDS is patched to 30s and the save asserted within 5s: a missing release signal still fails it, now with a 6x margin instead of 2x. Still drives the REAL _guarded_tick, so it keeps catching what it exists to catch - verified by mutation: dropping the AUTOSAVE owner tag fails it, and dropping the released.set() signal fails it. Ran 25/25 green locally. Full suite: 2438 passed, compileall clean. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes part of the R2.5d-flagged Settings deferral: API-provider (OpenAI-Compatible / Anthropic Claude / Google Gemini endpoint config, per-task model assignment, live catalog refresh, write-only key persistence) is now real in the new backend/frontend stack. Ollama and Llama.cpp remain deferred as R7.4b/R7.4c.
What Changed
backend/settings.py— 4 new intents on the existingapp-settingstopic:setViewingApiProvider,loadApiModels,saveApiConfiguration,resetApiSettings. Reuses the establishedasyncio.to_thread+_manager_lockpattern already used for General/Integrations.graphlink_app/graphlink_app_settings_payload.py— extendedAppSettingsStatePayloadwith 10 new fields (incl. a nestedApiModelDescriptorPayload); codegen regenerated the TS contract.web_ui/src/app/chrome/SettingsDialog.tsx— newApiProviderPage(provider select, base URL, write-only key field, per-task model inputs with datalist suggestions, Load/Save/Reset), wired into the existing rail-nav dialog.backend/tests/test_settings.py, plus 2 new fully-portable files ported from legacy (test_backend_secrets_at_rest.py,test_backend_api_key_env_fallback.py—test_backend_*naming avoids a basename collision with the still-present legacy files, matching thetest_backend_composer.pyprecedent). 11 new frontend tests inSettingsDialog.test.tsx(the first dedicated test file for this dialog).Design decision worth flagging
The API key field never pre-fills with the real key on a provider switch — the old Qt widget did, which was only safe because it lived in-process with no serialization boundary. This page follows the write-only pattern already established for the GitHub token instead:
apiKeyConfiguredis a per-provider bool, and saving requires retyping the full key. This is a deliberate security improvement over legacy parity, not an oversight.Adversarial review
A 4-way review (backend/frontend/integration/security) surfaced 11 findings; 9 confirmed after independent adversarial verification and all fixed with regression tests:
save_api_configuration: the other 2 providers' "keep as-is" keys were read outside the lock, before anawait— a concurrent save for a different provider could get silently reverted. Fixed by moving the read inside the locked_persist().load_api_modelsagainstapi_provider.py's process-global state — fixed with a post-hoc consistency check._redact()helper (the legacy bridge had this redaction; the port had dropped it).AttributeErroron a non-dict intent argument.Validation
python -m compileall -q .passestests/test_no_qt_anywhere.py— burn-down pin unchanged at 152/84/68~/.graphlink/session.datwas never touched), confirming the real wire payload shape, real validation notices, a real network-failure path with confirmed key redaction, and a real reset round-tripAdditional Context
Part of the Qt-removal parity/cutover phase (R7.4). Next: R7.4b (Ollama settings page — model scan/pull via the same
asyncio.to_threadidiom).