Skip to content

Qt-removal R7.4a: API-provider settings page (backend+frontend)#142

Merged
dovvnloading merged 1 commit into
mainfrom
qt-removal/r7-4a-api-provider-settings
Jul 26, 2026
Merged

Qt-removal R7.4a: API-provider settings page (backend+frontend)#142
dovvnloading merged 1 commit into
mainfrom
qt-removal/r7-4a-api-provider-settings

Conversation

@dovvnloading

Copy link
Copy Markdown
Owner

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 existing app-settings topic: setViewingApiProvider, loadApiModels, saveApiConfiguration, resetApiSettings. Reuses the established asyncio.to_thread + _manager_lock pattern already used for General/Integrations.
  • graphlink_app/graphlink_app_settings_payload.py — extended AppSettingsStatePayload with 10 new fields (incl. a nested ApiModelDescriptorPayload); codegen regenerated the TS contract.
  • web_ui/src/app/chrome/SettingsDialog.tsx — new ApiProviderPage (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.
  • Tests: 30 new/updated backend tests in 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.pytest_backend_* naming avoids a basename collision with the still-present legacy files, matching the test_backend_composer.py precedent). 11 new frontend tests in SettingsDialog.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: apiKeyConfigured is 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:

  • Data-loss race in save_api_configuration: the other 2 providers' "keep as-is" keys were read outside the lock, before an await — a concurrent save for a different provider could get silently reverted. Fixed by moving the read inside the locked _persist().
  • Matching race in load_api_models against api_provider.py's process-global state — fixed with a post-hoc consistency check.
  • 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 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.

Validation

  • python -m compileall -q . passes
  • tests/test_no_qt_anywhere.py — burn-down pin unchanged at 152/84/68
  • Full backend suite: 2430 passed
  • Frontend: 1064 vitest passed, tsc/eslint/build clean
  • Live drive against a real running FastAPI/uvicorn backend over a genuine WebSocket (isolated scratch settings file — the real ~/.graphlink/session.dat was 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-trip

Additional 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_thread idiom).

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>
@dovvnloading
dovvnloading merged commit ba2acb9 into main Jul 26, 2026
2 checks passed
@dovvnloading
dovvnloading deleted the qt-removal/r7-4a-api-provider-settings branch July 26, 2026 12:59
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant