diff --git a/backend/app.py b/backend/app.py index bb48dc5..d94a9db 100644 --- a/backend/app.py +++ b/backend/app.py @@ -185,7 +185,12 @@ def ping(*args): # just above) so "Web Research" can create a real node - this ordering # (canvas_document exists before register_plugins runs) is load-bearing. register_plugins(bus, notifications_state, bus.canvas_document) - register_settings(bus, settings_manager) + # R7.4a: register_settings now takes notifications_state too, so the + # API-provider page's save-validation/init-failure paths can surface a + # real banner (same load-bearing ordering precedent as register_plugins/ + # register_chat_library above - notifications_state already exists by + # this point in every case). + register_settings(bus, settings_manager, notifications_state) # R6.4: register_chat_library needs the same session's canvas_document # (built above) so loadChat can actually restore a session into it, and # notifications_state so a failed/empty load can surface a real banner - diff --git a/backend/settings.py b/backend/settings.py index 58d9559..5ab0cd9 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -1,19 +1,36 @@ -"""Settings dialog: General + Integrations pages (Qt-removal plan R2.5d). +"""Settings dialog: General + Integrations + API-provider pages +(Qt-removal plan R2.5d, extended R7.4a). Unlike composer.py/plugins.py this is a genuine REUSE, not a reimplementation: SettingsManager (graphlink_licensing.py) and its own imports (graphlink_secrets, graphlink_model_catalog) carry zero PySide6 coupling, confirmed via a runtime sys.modules check in -test_settings_never_imports_qt below. +test_settings_never_imports_qt below. api_provider.py and +graphlink_task_config.py (this file's two new R7.4a imports) are +confirmed Qt-free repo-root survivor modules (Qt-removal plan R7.2). -Scope (doc/QT_REMOVAL_PLAN.md R2.5d): only the General/Appearance page +Scope (doc/QT_REMOVAL_PLAN.md R2.5d, R7.4a): the General/Appearance page (theme, token-counter visibility, system-prompt toggle, notification -preferences) and the Integrations page (GitHub token, write-only) are real -here. Ollama/Llama.cpp/API-provider pages, and the update-check pair -(checkForUpdates/openRepository), need a graphlink_config.py Qt/non-Qt -split and a native file-picker/browser-open capability neither of which -exist yet in graphlink_desktop.py - out of scope until R4. The SPA renders -those sections disabled with an explicit R4 label rather than faking them. +preferences), the Integrations page (GitHub token, write-only), and now +the API-provider page (OpenAI-Compatible/Anthropic/Gemini endpoint +config, per-task model assignment, live catalog refresh) are real here. +Ollama/Llama.cpp pages remain deferred - R7.4b/R7.4c - and the +update-check pair (checkForUpdates/openRepository) needs a native +browser-open capability that doesn't exist yet in graphlink_desktop.py. +The SPA renders those sections disabled with an explicit "lands in R7.4b/ +R7.4c" label rather than faking them. + +The API-provider page deliberately does NOT replicate one piece of legacy +behavior: the old QWidget pre-filled its API-key field with the saved +plaintext key on every provider switch (safe there only because the +widget lives in-process, with no serialization boundary). Serializing a +plaintext key into this topic's WS payload would be a real regression +from the write-only pattern already established for the GitHub token +below (githubTokenConfigured: bool) - so this page follows that same +precedent instead: apiKeyConfigured is a per-provider bool, the key input +always starts blank, and saving requires retyping the full key. This is a +deliberate security improvement over legacy parity, not an oversight - +called out explicitly per the "never redefine done" discipline. SettingsManager owns ONE shared ~/.graphlink/session.dat file for the whole app (the same file the legacy Qt app read/wrote). register_settings takes @@ -29,9 +46,25 @@ import threading from typing import Any, Callable +import api_provider +import graphlink_task_config as config from graphlink_licensing import SettingsManager from backend.events import SessionBus +from backend.notifications import NotificationState + +# The six per-task model-assignment slots the API-provider page exposes, +# in the same order the legacy widget built its combo boxes - not load- +# bearing (payload is a dict), but keeps save_api_configuration's required- +# task walk in a stable, readable order. +_API_TASK_KEYS = ( + config.TASK_TITLE, + config.TASK_CHAT, + config.TASK_CHART, + config.TASK_IMAGE_GEN, + config.TASK_WEB_VALIDATE, + config.TASK_WEB_SUMMARIZE, +) # Every persistence-touching mutation runs in a worker thread # (asyncio.to_thread) so SettingsManager._save_state's json-dump + fsync + @@ -49,6 +82,40 @@ def _apply(mutation: Callable[..., None], *args: Any) -> None: mutation(*args) +def _redact(text: str, secret: str) -> str: + # Post-review fix: some HTTP client libraries embed request parameters + # (including a rejected API key) directly in exception text - the + # legacy bridge this page replaces redacted for exactly this reason + # (graphlink_settings_bridge.py: str(exc).replace(api_key, '***')). + # Applied before any exception text reaches a WS-broadcast message, so + # the write-only-key guarantee holds on the failure path too, not just + # the happy path. + if not secret: + return text + return text.replace(secret, "***") + + +def _api_model_catalog_for_wire(manager: SettingsManager, provider: str) -> list[dict[str, Any]]: + # SettingsManager.get_api_model_catalog is a shared, general-purpose + # method (also used by graphlink_settings_bridge.py) and always returns + # snake_case model_id keys - its own internal convention, not this + # payload's. Every other field in this wire contract is camelCase (JS + # convention), so this maps at the boundary rather than either bending + # SettingsManager's shared shape or breaking the payload's own + # convention - the mismatch would otherwise be silently rejected by the + # generated validator's additionalProperties: false. + return [ + { + "modelId": str(entry.get("model_id", "")), + "provider": str(entry.get("provider", provider)), + "capabilities": list(entry.get("capabilities", [])), + "ready": bool(entry.get("ready", True)), + "available": bool(entry.get("available", True)), + } + for entry in manager.get_api_model_catalog(provider) + ] + + def settings_payload(manager: SettingsManager) -> dict[str, Any]: return { "theme": manager.get_theme(), @@ -59,16 +126,69 @@ def settings_payload(manager: SettingsManager) -> dict[str, Any]: } -def register_settings(bus: SessionBus, manager: SettingsManager) -> None: +def register_settings( + bus: SessionBus, manager: SettingsManager, notifications: NotificationState | None = None +) -> None: + # notifications is optional only so the ~11 pre-R7.4a tests in + # backend/tests/test_settings.py that call register_settings(bus, + # manager) keep working unchanged - the real (and only) production call + # site, backend/app.py's _configure_session, always passes a real one. # activeSection is session-local UI navigation, not SettingsManager # state - the legacy bridge didn't persist it either (the dialog always # opened on General). One mutable cell closed over by the builder and # its own intent, matching this field's single purpose. active_section = {"value": "general"} + # viewingApiProvider is likewise session-local: the legacy widget let a + # user free-browse a different provider's key/models via its dropdown + # without persisting anything until Save - starts on whichever provider + # is currently saved/active, matching the dialog's legacy open state. + viewing_api_provider = {"value": manager.get_api_provider()} + # Catalog-refresh feedback (mirrors the legacy discovery_status_label) + # is transient UI state, not a SettingsManager field - errors here are + # non-blocking background feedback, not a save failure (see + # load_api_models below; contrast with save_api_configuration's + # notification-banner errors, matching legacy's own QMessageBox split). + # + # Post-review fix: keyed by provider (not one flat pair of cells) - a + # load_api_models call for provider A resolving after the user has + # switched viewingApiProvider to B must not paint A's status/message + # onto B's page, and B's own Load button must not appear disabled just + # because A is still in flight. This also makes the legacy widget's + # explicit "discard if the user switched providers mid-flight" guard + # unnecessary here: each provider's outcome lands in its own slot, so a + # switch-back to A later still shows A's real, correct result rather + # than nothing. + _default_catalog_state = {"status": "idle", "message": "Model catalog has not been refreshed yet."} + api_catalog_state: dict[str, dict[str, str]] = {} + + def _catalog_state_for(provider: str) -> dict[str, str]: + return api_catalog_state.get(provider, _default_catalog_state) + def build_payload() -> dict[str, Any]: payload = settings_payload(manager) payload["activeSection"] = active_section["value"] + viewing_provider = viewing_api_provider["value"] + payload["activeApiProvider"] = manager.get_api_provider() + payload["viewingApiProvider"] = viewing_provider + payload["apiBaseUrl"] = manager.get_api_base_url() + payload["apiKeyConfigured"] = { + "openai": bool(manager.get_openai_key()), + "anthropic": bool(manager.get_anthropic_key()), + "gemini": bool(manager.get_gemini_key()), + } + payload["apiModels"] = manager.get_api_models(viewing_provider) + payload["apiModelCatalog"] = _api_model_catalog_for_wire(manager, viewing_provider) + catalog_state = _catalog_state_for(viewing_provider) + payload["apiCatalogStatus"] = catalog_state["status"] + payload["apiCatalogMessage"] = catalog_state["message"] + # Gemini has no live catalog-refresh endpoint wired here (matching + # legacy: load_btn was never shown for Gemini) - its model choices + # are this fixed, hand-maintained list. Sourced from api_provider.py + # rather than duplicated in TypeScript so there is one place to + # update when the list changes. + payload["geminiStaticModels"] = list(api_provider.GEMINI_MODELS_STATIC) + payload["geminiStaticImageModels"] = list(api_provider.GEMINI_IMAGE_MODELS_STATIC) return payload bus.register_topic("app-settings", build_payload) @@ -107,6 +227,169 @@ async def clear_github_token(): await asyncio.to_thread(_apply, manager.set_github_token, "") await bus.publish("app-settings") + async def set_viewing_api_provider(provider: str): + viewing_api_provider["value"] = str(provider) + await bus.publish("app-settings") + + async def load_api_models(provider: str, api_key: str, base_url: str = ""): + # Gemini has no live catalog endpoint wired (legacy never showed the + # Load button for it either) - a stale/misbehaving client asking + # anyway gets a clean error rather than a confusing initialize_api + # call with the wrong semantics. + if provider not in (config.API_PROVIDER_OPENAI, config.API_PROVIDER_ANTHROPIC): + api_catalog_state[provider] = {"status": "error", "message": f"{provider} does not support catalog refresh."} + await bus.publish("app-settings") + return + + api_catalog_state[provider] = { + "status": "loading", + "message": "Contacting the provider... you can keep editing other settings.", + } + await bus.publish("app-settings") + + try: + # Discovery deliberately exercises the same provider-init path + # Save uses, so a successful catalog load doubles as a + # connection check - matching legacy's ApiModelLoadWorker. + await asyncio.to_thread( + api_provider.initialize_api, + provider, + str(api_key), + str(base_url) if provider == config.API_PROVIDER_OPENAI else None, + ) + descriptors = await asyncio.to_thread(api_provider.get_available_model_descriptors) + # Post-review fix: api_provider's provider globals are process- + # wide (not per-request), and initialize_api/get_available_ + # model_descriptors are two separate asyncio.to_thread hops with + # an await gap between them - a concurrent loadApiModels/ + # saveApiConfiguration call for a DIFFERENT provider landing in + # that gap would repoint the globals before the descriptors read + # runs, so descriptors could describe the wrong provider. This + # is the same class of race api_provider.py's own + # _snapshot_provider_state() exists to prevent for chat()/ + # generate_image() - get_available_model_descriptors() has no + # snapshot-based equivalent, so the narrowest fix available at + # this call site is a post-hoc consistency check: if the global + # provider type no longer matches what THIS call just + # requested, treat the result as aborted rather than silently + # persisting/reporting it under the wrong provider's name. + if api_provider.API_PROVIDER_TYPE != provider: + api_catalog_state[provider] = { + "status": "error", + "message": "Catalog refresh aborted - another request changed the active provider. Try again.", + } + await bus.publish("app-settings") + return + normalized = [ + { + "model_id": descriptor.model_id, + "provider": descriptor.provider, + "capabilities": sorted(descriptor.capabilities), + "ready": descriptor.ready, + "available": descriptor.available, + } + for descriptor in descriptors + ] + except Exception as exc: # noqa: BLE001 - redacted, then surfaced, matching legacy's error label + message = _redact(str(exc), api_key) + api_catalog_state[provider] = { + "status": "error", + "message": f"Catalog refresh failed: {message}\nSaved/custom model IDs remain usable if the endpoint supports them.", + } + await bus.publish("app-settings") + return + + await asyncio.to_thread(_apply, manager.set_api_model_catalog, normalized, provider) + api_catalog_state[provider] = { + "status": "success", + "message": f"Catalog refreshed - {len(normalized)} model(s) available from {provider}.", + } + await bus.publish("app-settings") + + async def save_api_configuration(provider: str, base_url: str, api_key: str, models_by_task: dict[str, str]): + provider = str(provider) + base_url = str(base_url).strip() + api_key = str(api_key).strip() + # Post-review fix: a non-dict 4th argument (e.g. a WS client sending + # null) used to raise AttributeError out of the required-task loop + # below, uncaught, bypassing this intent's own validation-notice + # path entirely in favor of a generic "intent failed" WS error. + # Coercing to {} here routes it back through the normal "please + # select a model" warning instead. + if not isinstance(models_by_task, dict): + models_by_task = {} + + if not api_key: + if notifications is not None: + notifications.show("Please enter your API Key.", "warning") + await bus.publish("notification") + return + + required_tasks = [ + task for task in _API_TASK_KEYS if not (provider == config.API_PROVIDER_ANTHROPIC and task == config.TASK_IMAGE_GEN) + ] + for task in required_tasks: + if not str(models_by_task.get(task, "")).strip(): + if notifications is not None: + notifications.show(f"Please select a model for task: {task}", "warning") + await bus.publish("notification") + return + + try: + await asyncio.to_thread( + api_provider.initialize_api, + provider, + api_key, + base_url if provider == config.API_PROVIDER_OPENAI else None, + ) + except Exception as exc: # noqa: BLE001 - redacted, then surfaced, matching legacy's error dialog + if notifications is not None: + notifications.show(f"Failed to initialize the API provider: {_redact(str(exc), api_key)}", "error") + await bus.publish("notification") + return + + # Commit only after provider initialization succeeds - a rejected + # key or endpoint must not overwrite the last known-good profile + # (matching legacy save_settings's own ordering exactly). + normalized_models = { + str(task): str(model_id).strip() + for task, model_id in (models_by_task or {}).items() + if str(model_id).strip() + } + + def _persist() -> None: + # Post-review fix: the other two providers' "keep as-is" keys + # are now read HERE, inside _manager_lock (via _apply), not on + # the event loop before this thread was dispatched. The old + # read-before-await-then-write-after ordering was a lost-update + # race: two saveApiConfiguration calls for different providers + # (e.g. two browser tabs on the same default session) could + # interleave so that the second call's stale pre-read of the + # first call's key got written back over the first call's own, + # freshly-persisted value. Reading and writing inside the same + # locked critical section makes this atomic with respect to + # every other _apply()-guarded mutation. + openai_key = api_key if provider == config.API_PROVIDER_OPENAI else manager.get_openai_key() + anthropic_key = api_key if provider == config.API_PROVIDER_ANTHROPIC else manager.get_anthropic_key() + gemini_key = api_key if provider == config.API_PROVIDER_GEMINI else manager.get_gemini_key() + manager.set_api_settings(provider, base_url, openai_key, anthropic_key, gemini_key) + manager.set_api_models(normalized_models, provider) + for task, model_id in normalized_models.items(): + api_provider.set_task_model(task, model_id) + + await asyncio.to_thread(_apply, _persist) + if notifications is not None: + notifications.show(f"API settings for {provider} have been saved.", "success") + await bus.publish("notification") + await bus.publish("app-settings") + + async def reset_api_settings(): + await asyncio.to_thread(_apply, manager.reset_api_settings) + if notifications is not None: + notifications.show("All API settings have been cleared.", "success") + await bus.publish("notification") + await bus.publish("app-settings") + bus.register_intent("app-settings", "setActiveSection", set_active_section) bus.register_intent("app-settings", "setTheme", set_theme) bus.register_intent("app-settings", "setShowTokenCounter", set_show_token_counter) @@ -114,3 +397,7 @@ async def clear_github_token(): bus.register_intent("app-settings", "setNotificationPreference", set_notification_preference) bus.register_intent("app-settings", "setGithubToken", set_github_token) bus.register_intent("app-settings", "clearGithubToken", clear_github_token) + bus.register_intent("app-settings", "setViewingApiProvider", set_viewing_api_provider) + bus.register_intent("app-settings", "loadApiModels", load_api_models) + bus.register_intent("app-settings", "saveApiConfiguration", save_api_configuration) + bus.register_intent("app-settings", "resetApiSettings", reset_api_settings) diff --git a/backend/tests/test_backend_api_key_env_fallback.py b/backend/tests/test_backend_api_key_env_fallback.py new file mode 100644 index 0000000..4ee817a --- /dev/null +++ b/backend/tests/test_backend_api_key_env_fallback.py @@ -0,0 +1,131 @@ +"""Tests for environment-variable API key fallback in api_provider.initialize_api(). + +Ported from graphlink_app/tests/test_api_key_env_fallback.py (Qt-removal +plan R7.4a) - pure logic against api_provider.py/graphlink_task_config.py, +both confirmed Qt-free repo-root survivor modules; only the import (the +renamed graphlink_task_config, not the legacy graphlink_config shim) and +the removed graphlink_app-relative sys.path insert changed. Named +test_backend_* (not test_api_key_env_fallback.py) because the legacy file +of that exact name still exists in graphlink_app/tests/ until the R7.6 +cutover - two same-basename modules under different, __init__.py-less +test directories collide in pytest's default import mode, the same +collision test_backend_composer.py was already named to avoid. + +Regression coverage for the OpenAI env-var gap: Anthropic and Gemini both fall back to a +GRAPHLINK__API_KEY / vendor-standard env var when no key is passed in, but the +OpenAI-compatible branch didn't - a user with OPENAI_API_KEY set in their environment (a +very standard thing to have) but nothing saved in Graphlink's own Settings would get +"API key not configured" instead of it just working, unlike every other provider. +""" + +from unittest.mock import MagicMock, patch + +import api_provider +import graphlink_task_config as config + + +def _reset_api_provider_state(monkeypatch): + monkeypatch.setattr(api_provider, "USE_API_MODE", False) + monkeypatch.setattr(api_provider, "API_PROVIDER_TYPE", None) + monkeypatch.setattr(api_provider, "API_CLIENT", None) + monkeypatch.setattr(api_provider, "API_KEY", None) + monkeypatch.setattr(api_provider, "API_BASE_URL", None) + + +class TestOpenAiApiKeyEnvFallback: + def test_falls_back_to_graphlink_prefixed_env_var(self, monkeypatch): + _reset_api_provider_state(monkeypatch) + monkeypatch.setenv("GRAPHLINK_OPENAI_API_KEY", "from-graphlink-env") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + fake_openai_cls = MagicMock() + + with patch("openai.OpenAI", fake_openai_cls): + api_provider.initialize_api(config.API_PROVIDER_OPENAI, "", "https://api.example.com/v1") + + fake_openai_cls.assert_called_once_with(api_key="from-graphlink-env", base_url="https://api.example.com/v1") + + def test_falls_back_to_vendor_standard_env_var(self, monkeypatch): + _reset_api_provider_state(monkeypatch) + monkeypatch.delenv("GRAPHLINK_OPENAI_API_KEY", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "from-vendor-env") + fake_openai_cls = MagicMock() + + with patch("openai.OpenAI", fake_openai_cls): + api_provider.initialize_api(config.API_PROVIDER_OPENAI, "", "https://api.example.com/v1") + + fake_openai_cls.assert_called_once_with(api_key="from-vendor-env", base_url="https://api.example.com/v1") + + def test_explicitly_passed_key_wins_over_env_vars(self, monkeypatch): + _reset_api_provider_state(monkeypatch) + monkeypatch.setenv("GRAPHLINK_OPENAI_API_KEY", "from-env") + monkeypatch.setenv("OPENAI_API_KEY", "from-env-2") + fake_openai_cls = MagicMock() + + with patch("openai.OpenAI", fake_openai_cls): + api_provider.initialize_api(config.API_PROVIDER_OPENAI, "from-settings", "https://api.example.com/v1") + + fake_openai_cls.assert_called_once_with(api_key="from-settings", base_url="https://api.example.com/v1") + + def test_still_raises_when_no_key_anywhere_and_base_url_is_remote(self, monkeypatch): + import pytest + + _reset_api_provider_state(monkeypatch) + monkeypatch.delenv("GRAPHLINK_OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + with pytest.raises(RuntimeError, match="API key not configured"): + api_provider.initialize_api(config.API_PROVIDER_OPENAI, "", "https://api.example.com/v1") + + def test_local_base_url_still_uses_dummy_key_when_nothing_configured(self, monkeypatch): + _reset_api_provider_state(monkeypatch) + monkeypatch.delenv("GRAPHLINK_OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + fake_openai_cls = MagicMock() + + with patch("openai.OpenAI", fake_openai_cls): + api_provider.initialize_api(config.API_PROVIDER_OPENAI, "", "http://localhost:11434/v1") + + fake_openai_cls.assert_called_once_with(api_key="dummy-key-for-local", base_url="http://localhost:11434/v1") + + +class TestLegacyGraphiteEnvVarStillWorks: + """The app was renamed from Graphite to Graphlink; GRAPHITE_*_API_KEY env vars set + before the rename must keep working so existing power-user shell configs don't + silently break. GRAPHLINK_* always takes priority when both are set.""" + + def test_openai_falls_back_to_legacy_graphite_prefixed_env_var(self, monkeypatch): + _reset_api_provider_state(monkeypatch) + monkeypatch.delenv("GRAPHLINK_OPENAI_API_KEY", raising=False) + monkeypatch.setenv("GRAPHITE_OPENAI_API_KEY", "from-legacy-graphite-env") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + fake_openai_cls = MagicMock() + + with patch("openai.OpenAI", fake_openai_cls): + api_provider.initialize_api(config.API_PROVIDER_OPENAI, "", "https://api.example.com/v1") + + fake_openai_cls.assert_called_once_with(api_key="from-legacy-graphite-env", base_url="https://api.example.com/v1") + + def test_new_prefixed_env_var_wins_over_legacy_one(self, monkeypatch): + _reset_api_provider_state(monkeypatch) + monkeypatch.setenv("GRAPHLINK_OPENAI_API_KEY", "from-new-env") + monkeypatch.setenv("GRAPHITE_OPENAI_API_KEY", "from-legacy-env") + fake_openai_cls = MagicMock() + + with patch("openai.OpenAI", fake_openai_cls): + api_provider.initialize_api(config.API_PROVIDER_OPENAI, "", "https://api.example.com/v1") + + fake_openai_cls.assert_called_once_with(api_key="from-new-env", base_url="https://api.example.com/v1") + + def test_anthropic_falls_back_to_legacy_graphite_prefixed_env_var(self, monkeypatch): + monkeypatch.delenv("GRAPHLINK_ANTHROPIC_API_KEY", raising=False) + monkeypatch.setenv("GRAPHITE_ANTHROPIC_API_KEY", "legacy-ant-key") + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + + assert api_provider._get_anthropic_api_key() == "legacy-ant-key" + + def test_gemini_falls_back_to_legacy_graphite_prefixed_env_var(self, monkeypatch): + monkeypatch.delenv("GRAPHLINK_GEMINI_API_KEY", raising=False) + monkeypatch.setenv("GRAPHITE_GEMINI_API_KEY", "legacy-gem-key") + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + + assert api_provider._get_gemini_api_key() == "legacy-gem-key" diff --git a/backend/tests/test_backend_secrets_at_rest.py b/backend/tests/test_backend_secrets_at_rest.py new file mode 100644 index 0000000..67bb460 --- /dev/null +++ b/backend/tests/test_backend_secrets_at_rest.py @@ -0,0 +1,199 @@ +"""Tests for secrets-at-rest encryption (graphlink_secrets + SettingsManager wiring). + +Ported from graphlink_app/tests/test_secrets_at_rest.py (Qt-removal plan +R7.4a) - 100% Qt-free already (confirmed by import grep: only json, pathlib, +graphlink_secrets, graphlink_licensing.SettingsManager), it just needed a +new home now that the R7.4a API-provider settings page makes this the +active, exercised secrets path rather than a deferred one. Named +test_backend_* (not test_secrets_at_rest.py) because the legacy file of +that exact name still exists in graphlink_app/tests/ until the R7.6 +cutover - two same-basename modules under different, __init__.py-less +test directories collide in pytest's default import mode, the same +collision test_backend_composer.py was already named to avoid. + +Regression coverage for secrets stored in plaintext: API keys and the GitHub +token were stored as plaintext JSON in session.dat. They are now +DPAPI-protected ("dpapi:" + base64 blob, bound to the Windows user account) +with three hard requirements pinned down here: + +1. Roundtrip: what you set is what you get back, but the on-disk bytes never + contain the plaintext. +2. Legacy migration: a pre-existing plaintext session.dat is silently + upgraded on the first load - the plaintext leaves disk immediately, and + getters still return it. +3. Graceful degradation: when DPAPI is unavailable (non-Windows), everything + behaves exactly as before this change - plaintext in, plaintext out, no + crash, no rewrite loop. Simulated by stubbing the internal _dpapi_call to + fail. + +These tests run on real DPAPI (Windows dev machines and the windows-latest +CI runner). +""" + +import json + +import graphlink_secrets +from graphlink_licensing import SettingsManager + + +class TestProtectUnprotectPrimitives: + def test_roundtrip(self): + protected = graphlink_secrets.protect("sk-super-secret") + assert graphlink_secrets.unprotect(protected) == "sk-super-secret" + + def test_protected_form_is_prefixed_and_not_the_plaintext(self): + protected = graphlink_secrets.protect("sk-super-secret") + assert protected.startswith("dpapi:") + assert "sk-super-secret" not in protected + + def test_protect_is_idempotent(self): + once = graphlink_secrets.protect("sk-super-secret") + assert graphlink_secrets.protect(once) == once + + def test_legacy_plaintext_passes_through_unprotect_unchanged(self): + assert graphlink_secrets.unprotect("sk-legacy-plaintext") == "sk-legacy-plaintext" + + def test_empty_values_stay_empty(self): + assert graphlink_secrets.protect("") == "" + assert graphlink_secrets.unprotect("") == "" + + def test_undecryptable_blob_returns_empty_not_garbage(self): + # e.g. a session.dat copied from another user account/machine - the app must + # see "not configured", never hand corrupt bytes to a provider client. + assert graphlink_secrets.unprotect("dpapi:AAAA") == "" + assert graphlink_secrets.unprotect("dpapi:!!!not-base64!!!") == "" + + def test_unicode_secrets_roundtrip(self): + secret = "pässwörd-秘密-🔑" + assert graphlink_secrets.unprotect(graphlink_secrets.protect(secret)) == secret + + def test_plaintext_secret_that_starts_with_the_prefix_is_still_encrypted(self): + # Adversarial-review finding: the "dpapi:" prefix is in-band signaling. A + # plaintext secret that itself begins with "dpapi:" (e.g. a proxy master key a + # user types into the API settings dialog) must NOT be mistaken for an already- + # encrypted blob - otherwise it would be stored as plaintext and read back as "". + secret = "dpapi:my-actual-secret" + protected = graphlink_secrets.protect(secret) + + assert protected != secret # it was actually encrypted, not passed through + assert graphlink_secrets.unprotect(protected) == secret # ...and round-trips + + def test_prefixed_plaintext_with_base64_valid_suffix_is_still_encrypted(self): + # Harder variant: the suffix is valid base64 ("AAAA" -> 3 bytes) but not a real + # DPAPI blob, so it must not be treated as already-encrypted either. + secret = "dpapi:AAAA" + protected = graphlink_secrets.protect(secret) + + assert graphlink_secrets.unprotect(protected) == secret + + +class TestSettingsManagerStoresSecretsEncrypted: + def test_set_api_settings_leaves_no_plaintext_on_disk(self, tmp_path): + state_file = tmp_path / "session.dat" + manager = SettingsManager(state_file) + + manager.set_api_settings( + "OpenAI-Compatible", "https://api.openai.com/v1", + "sk-openai-secret", "sk-ant-secret", "AIza-gemini-secret", + ) + + raw = state_file.read_text(encoding="utf-8") + assert "sk-openai-secret" not in raw + assert "sk-ant-secret" not in raw + assert "AIza-gemini-secret" not in raw + assert manager.get_openai_key() == "sk-openai-secret" + assert manager.get_anthropic_key() == "sk-ant-secret" + assert manager.get_gemini_key() == "AIza-gemini-secret" + + def test_set_github_token_leaves_no_plaintext_on_disk(self, tmp_path): + state_file = tmp_path / "session.dat" + manager = SettingsManager(state_file) + + manager.set_github_token("ghp_super_secret_token") + + raw = state_file.read_text(encoding="utf-8") + assert "ghp_super_secret_token" not in raw + assert manager.get_github_token() == "ghp_super_secret_token" + + def test_secrets_survive_a_reload_from_disk(self, tmp_path): + state_file = tmp_path / "session.dat" + SettingsManager(state_file).set_github_token("ghp_reload_me") + + reloaded = SettingsManager(state_file) + + assert reloaded.get_github_token() == "ghp_reload_me" + + def test_reset_api_settings_still_clears_keys(self, tmp_path): + manager = SettingsManager(tmp_path / "session.dat") + manager.set_api_settings("OpenAI-Compatible", "url", "k1", "k2", "k3") + + manager.reset_api_settings() + + assert manager.get_openai_key() == "" + assert manager.get_anthropic_key() == "" + assert manager.get_gemini_key() == "" + + +class TestLegacyPlaintextMigration: + def _write_legacy_state(self, state_file, **secrets): + state = {"theme": "dark"} + state.update(secrets) + state_file.write_text(json.dumps(state), encoding="utf-8") + + def test_plaintext_secrets_are_encrypted_on_first_load(self, tmp_path): + state_file = tmp_path / "session.dat" + self._write_legacy_state( + state_file, + openai_api_key="sk-legacy-openai", + github_access_token="ghp_legacy", + ) + + manager = SettingsManager(state_file) + + # Getters return the plaintext, but the file no longer contains it. + assert manager.get_openai_key() == "sk-legacy-openai" + assert manager.get_github_token() == "ghp_legacy" + raw = state_file.read_text(encoding="utf-8") + assert "sk-legacy-openai" not in raw + assert "ghp_legacy" not in raw + assert json.loads(raw)["openai_api_key"].startswith("dpapi:") + + def test_migration_does_not_rewrite_when_nothing_needs_migrating(self, tmp_path): + state_file = tmp_path / "session.dat" + manager = SettingsManager(state_file) # fresh defaults, all secrets empty + mtime_after_first_load = state_file.stat().st_mtime_ns + + SettingsManager(state_file) # second load - nothing to migrate + + assert state_file.stat().st_mtime_ns == mtime_after_first_load + + def test_already_encrypted_secrets_are_not_double_wrapped(self, tmp_path): + state_file = tmp_path / "session.dat" + SettingsManager(state_file).set_github_token("ghp_once") + stored_once = json.loads(state_file.read_text(encoding="utf-8"))["github_access_token"] + + SettingsManager(state_file) # reload triggers the migration pass again + + stored_twice = json.loads(state_file.read_text(encoding="utf-8"))["github_access_token"] + assert stored_twice == stored_once + + +class TestGracefulDegradationWithoutDpapi: + def test_everything_behaves_like_before_when_dpapi_is_unavailable(self, tmp_path, monkeypatch): + # Simulate a non-Windows platform / DPAPI failure: protect() falls back to + # plaintext, unprotect() passes plaintext through, migration rewrites nothing. + monkeypatch.setattr(graphlink_secrets, "_dpapi_call", lambda data, encrypt: None) + + state_file = tmp_path / "session.dat" + manager = SettingsManager(state_file) + manager.set_github_token("ghp_plain_fallback") + + assert manager.get_github_token() == "ghp_plain_fallback" + raw = json.loads(state_file.read_text(encoding="utf-8")) + assert raw["github_access_token"] == "ghp_plain_fallback" # plaintext, as before + + # Reload with DPAPI still unavailable: no crash, no rewrite loop, same value. + mtime_before = state_file.stat().st_mtime_ns + reloaded = SettingsManager(state_file) + assert reloaded.get_github_token() == "ghp_plain_fallback" + assert state_file.stat().st_mtime_ns == mtime_before diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index 8d3b9af..09c4066 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -1,11 +1,14 @@ -"""Settings dialog topic tests (Qt-removal plan R2.5d).""" +"""Settings dialog topic tests (Qt-removal plan R2.5d, extended R7.4a).""" import asyncio import pytest from graphlink_licensing import SettingsManager +import api_provider +import graphlink_task_config as config from backend.events import SessionBus +from backend.notifications import NotificationState from backend.settings import register_settings, settings_payload @@ -175,3 +178,422 @@ def test_an_old_settings_file_without_schema_version_is_backfilled(tmp_path): old_manager.set_theme("mono") # any setter call persists the whole (now-backfilled) state assert json.loads(state_file.read_text(encoding="utf-8"))["schema_version"] == SettingsManager.CURRENT_SCHEMA_VERSION + + +# -- R7.4a: API-provider settings page. New intents on the same "app-settings" +# -- topic: setViewingApiProvider (session-local, mirrors setActiveSection), +# -- loadApiModels/saveApiConfiguration (async, wrap api_provider.py calls via +# -- asyncio.to_thread - monkeypatched below so no real network call happens), +# -- resetApiSettings. Ported/adapted from graphlink_app/tests/ +# -- test_settings_bridge_api_page.py's pure-logic assertions (required-task- +# -- per-provider validation, commit-only-on-init-success ordering, +# -- key-never-in-payload invariant) - its QThread-driving TestLoadAvailable +# -- Models tests are NOT ported as-is since there is no QThread here; the +# -- same contract is covered directly via loadApiModels instead. + + +def _six_task_models(**overrides): + models = { + config.TASK_TITLE: "gpt-4o-mini", + config.TASK_CHAT: "gpt-4o", + config.TASK_CHART: "gpt-4o", + config.TASK_IMAGE_GEN: "dall-e-3", + config.TASK_WEB_VALIDATE: "gpt-4o-mini", + config.TASK_WEB_SUMMARIZE: "gpt-4o-mini", + } + models.update(overrides) + return models + + +def test_set_viewing_api_provider_intent_updates_only_local_ui_state(manager): + bus = SessionBus("settings-viewing-provider-test") + register_settings(bus, manager) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run(bus.dispatch_intent("app-settings", "setViewingApiProvider", [config.API_PROVIDER_ANTHROPIC])) + + payload = recorder.messages[-1]["payload"] + assert payload["viewingApiProvider"] == config.API_PROVIDER_ANTHROPIC + # The persisted/active provider is untouched - only Save changes this. + assert payload["activeApiProvider"] == "OpenAI-Compatible" + + +def test_load_api_models_success_persists_catalog_and_publishes_status(manager, monkeypatch): + from graphlink_model_catalog import ModelDescriptor + + monkeypatch.setattr(api_provider, "initialize_api", lambda *a, **k: None) + # initialize_api is mocked to a no-op, so it never sets the real + # api_provider.API_PROVIDER_TYPE global the way load_api_models' own + # post-review stale-provider guard checks against - set it directly to + # simulate what the real call would have done. + monkeypatch.setattr(api_provider, "API_PROVIDER_TYPE", config.API_PROVIDER_OPENAI) + monkeypatch.setattr( + api_provider, + "get_available_model_descriptors", + lambda: [ModelDescriptor(model_id="gpt-4o", provider=config.API_PROVIDER_OPENAI, capabilities=frozenset({"text"}))], + ) + bus = SessionBus("settings-load-models-test") + register_settings(bus, manager) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run( + bus.dispatch_intent( + "app-settings", "loadApiModels", [config.API_PROVIDER_OPENAI, "sk-test", "https://api.openai.com/v1"] + ) + ) + + payload = recorder.messages[-1]["payload"] + assert payload["apiCatalogStatus"] == "success" + assert manager.get_api_model_catalog(config.API_PROVIDER_OPENAI)[0]["model_id"] == "gpt-4o" + + +def test_load_api_models_failure_sets_error_status_and_does_not_persist(manager, monkeypatch): + def _boom(*a, **k): + raise RuntimeError("connection refused") + + monkeypatch.setattr(api_provider, "initialize_api", _boom) + bus = SessionBus("settings-load-models-error-test") + register_settings(bus, manager) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run( + bus.dispatch_intent("app-settings", "loadApiModels", [config.API_PROVIDER_OPENAI, "sk-test", "https://x/v1"]) + ) + + payload = recorder.messages[-1]["payload"] + assert payload["apiCatalogStatus"] == "error" + assert "connection refused" in payload["apiCatalogMessage"] + assert manager.get_api_model_catalog(config.API_PROVIDER_OPENAI) == [] + + +def test_load_api_models_rejects_gemini_since_it_has_no_live_catalog(manager, monkeypatch): + calls = [] + monkeypatch.setattr(api_provider, "initialize_api", lambda *a, **k: calls.append(a)) + bus = SessionBus("settings-load-models-gemini-test") + register_settings(bus, manager) + recorder = Recorder() + bus.attach(recorder) + + # apiCatalogStatus is scoped per-provider (post-review fix) - it's only + # visible in the payload for whichever provider is currently viewed, so + # switch there first to observe Gemini's own rejection outcome. + asyncio.run(bus.dispatch_intent("app-settings", "setViewingApiProvider", [config.API_PROVIDER_GEMINI])) + asyncio.run( + bus.dispatch_intent("app-settings", "loadApiModels", [config.API_PROVIDER_GEMINI, "AIza-test", ""]) + ) + + assert calls == [] # never even attempted a connection, matching legacy's hidden Load button + payload = recorder.messages[-1]["payload"] + assert payload["apiCatalogStatus"] == "error" + + +def test_save_api_configuration_rejects_missing_api_key(manager): + notifications = NotificationState() + bus = SessionBus("settings-save-missing-key-test") + bus.register_topic("notification", notifications.payload) + register_settings(bus, manager, notifications) + + asyncio.run( + bus.dispatch_intent( + "app-settings", "saveApiConfiguration", [config.API_PROVIDER_OPENAI, "https://x/v1", "", _six_task_models()] + ) + ) + + assert manager.get_openai_key() == "" + assert notifications.msg_type == "warning" + assert "API Key" in notifications.message + + +def test_save_api_configuration_rejects_missing_required_model(manager): + notifications = NotificationState() + bus = SessionBus("settings-save-missing-model-test") + bus.register_topic("notification", notifications.payload) + register_settings(bus, manager, notifications) + + asyncio.run( + bus.dispatch_intent( + "app-settings", + "saveApiConfiguration", + [config.API_PROVIDER_OPENAI, "https://x/v1", "sk-test", _six_task_models(task_chat="")], + ) + ) + + assert manager.get_openai_key() == "" + assert notifications.msg_type == "warning" + assert config.TASK_CHAT in notifications.message + + +def test_save_api_configuration_anthropic_does_not_require_image_gen_task(manager, monkeypatch): + monkeypatch.setattr(api_provider, "initialize_api", lambda *a, **k: None) + bus = SessionBus("settings-save-anthropic-test") + register_settings(bus, manager) + + models = _six_task_models() + del models[config.TASK_IMAGE_GEN] # Anthropic doesn't support image generation + asyncio.run( + bus.dispatch_intent( + "app-settings", "saveApiConfiguration", [config.API_PROVIDER_ANTHROPIC, "", "sk-ant-test", models] + ) + ) + + assert manager.get_api_provider() == config.API_PROVIDER_ANTHROPIC + assert manager.get_anthropic_key() == "sk-ant-test" + + +def test_save_api_configuration_does_not_persist_when_provider_init_fails(manager): + def _boom(*a, **k): + raise RuntimeError("bad endpoint") + + notifications = NotificationState() + bus = SessionBus("settings-save-init-fails-test") + bus.register_topic("notification", notifications.payload) + + import backend.settings as settings_module + + # No monkeypatch fixture at module scope here - patch directly and undo, + # keeping this test symmetric with the others' monkeypatch(manager)-only + # signature while still exercising the real failure path. + original = settings_module.api_provider.initialize_api + settings_module.api_provider.initialize_api = _boom + try: + register_settings(bus, manager, notifications) + asyncio.run( + bus.dispatch_intent( + "app-settings", + "saveApiConfiguration", + [config.API_PROVIDER_OPENAI, "https://x/v1", "sk-test", _six_task_models()], + ) + ) + finally: + settings_module.api_provider.initialize_api = original + + assert manager.get_openai_key() == "" # rejected key must not overwrite the last known-good profile + assert notifications.msg_type == "error" + assert "bad endpoint" in notifications.message + + +def test_save_api_configuration_persists_on_success_and_routes_task_models(manager, monkeypatch): + routed = {} + monkeypatch.setattr(api_provider, "initialize_api", lambda *a, **k: None) + monkeypatch.setattr(api_provider, "set_task_model", lambda task, model: routed.__setitem__(task, model)) + notifications = NotificationState() + bus = SessionBus("settings-save-success-test") + bus.register_topic("notification", notifications.payload) + register_settings(bus, manager, notifications) + + models = _six_task_models() + asyncio.run( + bus.dispatch_intent( + "app-settings", "saveApiConfiguration", [config.API_PROVIDER_OPENAI, "https://x/v1", "sk-test", models] + ) + ) + + assert manager.get_api_provider() == config.API_PROVIDER_OPENAI + assert manager.get_openai_key() == "sk-test" + assert manager.get_api_models(config.API_PROVIDER_OPENAI) == models + assert routed == models + assert notifications.msg_type == "success" + + +def test_save_api_configuration_redacts_the_key_from_a_provider_error_message(manager): + # Regression test: some HTTP client libraries embed request parameters + # (including a rejected key) in their exception text. The legacy bridge + # this page replaces redacted for exactly this reason; the original + # R7.4a port surfaced str(exc) verbatim, re-leaking a "write-only" key + # through the failure path this page's own module docstring says never + # happens. + secret = "sk-should-never-leak" + + def _boom(*a, **k): + raise RuntimeError(f"401 unauthorized: rejected key '{secret}'") + + import backend.settings as settings_module + + original = settings_module.api_provider.initialize_api + settings_module.api_provider.initialize_api = _boom + notifications = NotificationState() + bus = SessionBus("settings-save-redaction-test") + bus.register_topic("notification", notifications.payload) + try: + register_settings(bus, manager, notifications) + asyncio.run( + bus.dispatch_intent( + "app-settings", + "saveApiConfiguration", + [config.API_PROVIDER_OPENAI, "https://x/v1", secret, _six_task_models()], + ) + ) + finally: + settings_module.api_provider.initialize_api = original + + assert secret not in notifications.message + assert "***" in notifications.message + + +def test_load_api_models_redacts_the_key_from_a_provider_error_message(manager, monkeypatch): + secret = "sk-should-never-leak-either" + + def _boom(*a, **k): + raise RuntimeError(f"connection refused for key {secret}") + + monkeypatch.setattr(api_provider, "initialize_api", _boom) + bus = SessionBus("settings-load-redaction-test") + register_settings(bus, manager) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run( + bus.dispatch_intent("app-settings", "loadApiModels", [config.API_PROVIDER_OPENAI, secret, "https://x/v1"]) + ) + + payload = recorder.messages[-1]["payload"] + assert secret not in payload["apiCatalogMessage"] + assert "***" in payload["apiCatalogMessage"] + + +def test_save_api_configuration_rejects_a_non_dict_models_argument_without_crashing(manager): + # Regression test: a non-dict 4th argument (e.g. a WS client sending + # null) used to raise an uncaught AttributeError from models_by_task.get + # inside the required-task loop, bypassing this intent's own + # validation-notice path in favor of a generic "intent failed" error. + notifications = NotificationState() + bus = SessionBus("settings-save-non-dict-models-test") + bus.register_topic("notification", notifications.payload) + register_settings(bus, manager, notifications) + + asyncio.run( + bus.dispatch_intent( + "app-settings", "saveApiConfiguration", [config.API_PROVIDER_OPENAI, "https://x/v1", "sk-test", None] + ) + ) + + assert manager.get_openai_key() == "" + assert notifications.msg_type == "warning" + assert "Please select a model" in notifications.message + + +def test_save_api_configuration_preserves_other_providers_keys(manager, monkeypatch): + monkeypatch.setattr(api_provider, "initialize_api", lambda *a, **k: None) + bus = SessionBus("settings-save-preserves-other-keys-test") + register_settings(bus, manager) + + asyncio.run( + bus.dispatch_intent( + "app-settings", "saveApiConfiguration", [config.API_PROVIDER_OPENAI, "https://x/v1", "sk-openai", _six_task_models()] + ) + ) + asyncio.run( + bus.dispatch_intent( + "app-settings", + "saveApiConfiguration", + [config.API_PROVIDER_ANTHROPIC, "", "sk-ant", {k: v for k, v in _six_task_models().items() if k != config.TASK_IMAGE_GEN}], + ) + ) + + # Switching to and saving Anthropic must not clobber the previously + # saved OpenAI key - each provider's key is independent. + assert manager.get_openai_key() == "sk-openai" + assert manager.get_anthropic_key() == "sk-ant" + + +def test_save_api_configuration_reads_other_providers_keys_atomically_with_its_own_write(manager, monkeypatch): + # Regression test for a post-review R7.4a race: the original code read + # manager.get_anthropic_key()/get_openai_key()/get_gemini_key() for the + # "keep as-is" providers on the event loop, BEFORE awaiting + # initialize_api and persisting - so a second, concurrent + # saveApiConfiguration for a different provider (e.g. two browser tabs + # on the same session) landing in between got its own freshly-committed + # key silently reverted by this call's stale pre-read. The fix moved + # that read inside _persist(), which only ever runs under _manager_lock + # - this test simulates the interleaving by having initialize_api's own + # mock perform the "concurrent" write, proving the later read (inside + # _persist) picks up the fresh value rather than a stale one captured + # earlier. + def _simulate_concurrent_anthropic_save_during_our_own_initialize_api(*_args, **_kwargs): + manager.set_api_settings(config.API_PROVIDER_ANTHROPIC, "", "", "sk-concurrent-ant", "") + + monkeypatch.setattr(api_provider, "initialize_api", _simulate_concurrent_anthropic_save_during_our_own_initialize_api) + bus = SessionBus("settings-save-race-regression-test") + register_settings(bus, manager) + + asyncio.run( + bus.dispatch_intent( + "app-settings", "saveApiConfiguration", [config.API_PROVIDER_OPENAI, "https://x/v1", "sk-openai", _six_task_models()] + ) + ) + + assert manager.get_openai_key() == "sk-openai" + # Must NOT be reverted to "" - our own save's "keep Anthropic as-is" + # logic must see the concurrently-committed value, not a stale read + # taken before the concurrent write happened. + assert manager.get_anthropic_key() == "sk-concurrent-ant" + + +def test_catalog_state_is_isolated_per_provider(manager, monkeypatch): + # Regression test: apiCatalogStatus/apiCatalogMessage used to be one + # flat pair of session-local cells shared by every provider, so a load + # result for provider A leaked onto provider B's page as soon as the + # user switched viewingApiProvider - and B's own Load button appeared + # disabled ("loading") whenever A's load was still in flight. Fixed by + # keying catalog state per-provider. + monkeypatch.setattr(api_provider, "initialize_api", lambda *a, **k: None) + + def _boom(*a, **k): + raise RuntimeError("openai endpoint unreachable") + + bus = SessionBus("settings-catalog-isolation-test") + register_settings(bus, manager) + recorder = Recorder() + bus.attach(recorder) + + monkeypatch.setattr(api_provider, "initialize_api", _boom) + asyncio.run( + bus.dispatch_intent("app-settings", "loadApiModels", [config.API_PROVIDER_OPENAI, "sk-test", "https://x/v1"]) + ) + payload = recorder.messages[-1]["payload"] + assert payload["apiCatalogStatus"] == "error" # viewing OpenAI (the default) - sees its own failure + + asyncio.run(bus.dispatch_intent("app-settings", "setViewingApiProvider", [config.API_PROVIDER_ANTHROPIC])) + payload = recorder.messages[-1]["payload"] + assert payload["apiCatalogStatus"] == "idle" # Anthropic was never touched - not OpenAI's error + assert "openai endpoint unreachable" not in payload["apiCatalogMessage"] + + +def test_api_key_never_appears_in_the_settings_payload(manager, monkeypatch): + monkeypatch.setattr(api_provider, "initialize_api", lambda *a, **k: None) + bus = SessionBus("settings-save-key-write-only-test") + register_settings(bus, manager) + recorder = Recorder() + bus.attach(recorder) + + asyncio.run( + bus.dispatch_intent( + "app-settings", + "saveApiConfiguration", + [config.API_PROVIDER_OPENAI, "https://x/v1", "sk-super-secret-key", _six_task_models()], + ) + ) + + payload = recorder.messages[-1]["payload"] + assert payload["apiKeyConfigured"] == {"openai": True, "anthropic": False, "gemini": False} + assert "sk-super-secret-key" not in str(payload) + + +def test_reset_api_settings_intent_clears_everything(manager, monkeypatch): + monkeypatch.setattr(api_provider, "initialize_api", lambda *a, **k: None) + bus = SessionBus("settings-reset-api-test") + register_settings(bus, manager) + asyncio.run( + bus.dispatch_intent( + "app-settings", "saveApiConfiguration", [config.API_PROVIDER_OPENAI, "https://x/v1", "sk-test", _six_task_models()] + ) + ) + + asyncio.run(bus.dispatch_intent("app-settings", "resetApiSettings", [])) + + assert manager.get_openai_key() == "" + assert manager.get_api_provider() == "OpenAI-Compatible" + assert manager.get_api_models("OpenAI-Compatible") == {} diff --git a/graphlink_app/graphlink_app_settings_payload.py b/graphlink_app/graphlink_app_settings_payload.py index d144b8f..351db85 100644 --- a/graphlink_app/graphlink_app_settings_payload.py +++ b/graphlink_app/graphlink_app_settings_payload.py @@ -1,13 +1,14 @@ -"""The SPA settings topic's wire contract (Qt-removal plan R2.5d). - -Deliberately a SUBSET of graphlink_settings_payload.py::SettingsStatePayload -(General + Integrations fields only) rather than the legacy island's full -~30-field surface: Ollama/Llama.cpp/API-provider pages aren't implemented -yet (see backend/settings.py's module docstring for why), so their fields -would be dead weight here - the same "only what the SPA actually needs" -rationale as every other R2.3-R2.5 app-* payload. Registered as its own -codegen artifact (topic "app-settings") so the generated validator doesn't -collide with the legacy island's own settings-state.ts. +"""The SPA settings topic's wire contract (Qt-removal plan R2.5d, extended R7.4a). + +Was deliberately a SUBSET of graphlink_settings_payload.py::SettingsStatePayload +(General + Integrations fields only) - Ollama/Llama.cpp/API-provider pages +weren't implemented yet (see backend/settings.py's module docstring for +why). R7.4a adds the API-provider fields for real; Ollama/Llama.cpp remain +deferred (R7.4b/R7.4c) so their fields would still be dead weight here - +the same "only what the SPA actually needs" rationale as every other +R2.3-R2.5 app-* payload. Registered as its own codegen artifact (topic +"app-settings") so the generated validator doesn't collide with the legacy +island's own settings-state.ts. """ from __future__ import annotations @@ -15,6 +16,21 @@ from dataclasses import dataclass +@dataclass +class ApiModelDescriptorPayload: + """One entry of apiModelCatalog - graphlink_island_schema.py has no bare + `dict` support by design (a future-drift guard), so this mirrors the + normalized descriptor shape backend/settings.py's load_api_models + already builds (model_id/provider/capabilities/ready/available) as a + real nested dataclass instead.""" + + modelId: str + provider: str + capabilities: list[str] + ready: bool + available: bool + + @dataclass class AppSettingsStatePayload: schemaVersion: int @@ -25,4 +41,15 @@ class AppSettingsStatePayload: enableSystemPrompt: bool notificationPreferences: dict[str, bool] githubTokenConfigured: bool + # R7.4a: API-provider page. + activeApiProvider: str + viewingApiProvider: str + apiBaseUrl: str + apiKeyConfigured: dict[str, bool] + apiModels: dict[str, str] + apiModelCatalog: list[ApiModelDescriptorPayload] + apiCatalogStatus: str + apiCatalogMessage: str + geminiStaticModels: list[str] + geminiStaticImageModels: list[str] minCompatibleSchemaVersion: int | None = None diff --git a/web_ui/src/app/chrome/SettingsDialog.test.tsx b/web_ui/src/app/chrome/SettingsDialog.test.tsx new file mode 100644 index 0000000..63bcedb --- /dev/null +++ b/web_ui/src/app/chrome/SettingsDialog.test.tsx @@ -0,0 +1,242 @@ +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { SettingsDialog } from "./SettingsDialog"; +import { OverlayProvider, useOverlays } from "../overlays/overlays"; +import type { WsTransport } from "../../lib/ws/transport"; + +// R7.4a: the first dedicated test file for SettingsDialog.tsx - recon for +// this increment confirmed every sibling chrome dialog built in the same +// R2.5/R2.6 batch had one except this file, a real gap rather than a +// documented decision. Focuses on the new API-provider page; General/ +// Integrations are exercised indirectly through the same render/subscribe +// path and are lower-risk (no async network-shaped intents, no secrets). + +const snapshot = { + schemaVersion: 1, + minCompatibleSchemaVersion: 1, + revision: 1, + activeSection: "general", + theme: "dark", + showTokenCounter: true, + enableSystemPrompt: true, + notificationPreferences: { info: true, success: true, warning: true, error: true }, + githubTokenConfigured: false, + activeApiProvider: "OpenAI-Compatible", + viewingApiProvider: "OpenAI-Compatible", + apiBaseUrl: "https://api.openai.com/v1", + apiKeyConfigured: { openai: false, anthropic: false, gemini: false }, + apiModels: {}, + apiModelCatalog: [], + apiCatalogStatus: "idle", + apiCatalogMessage: "Model catalog has not been refreshed yet.", + geminiStaticModels: ["gemini-2.5-flash", "gemini-2.5-pro"], + geminiStaticImageModels: ["gemini-2.5-flash-image"], +}; + +function makeTransport() { + const intents: unknown[][] = []; + let listener: ((payload: Record) => void) | null = null; + const transport = { + subscribe: (_topic: string, l: (payload: Record) => void) => { + listener = l; + return () => { + listener = null; + }; + }, + intent: (topic: string, intent: string, args: unknown[]) => { + intents.push([topic, intent, args]); + }, + } as unknown as WsTransport; + return { + transport, + intents, + push: (payload: Record) => listener?.(payload), + }; +} + +function OpenSettingsButton() { + const overlays = useOverlays(); + return ( + + ); +} + +function setup(initial: Record = snapshot) { + const user = userEvent.setup(); + const fake = makeTransport(); + render( + + + + , + ); + act(() => fake.push(initial)); + return { user, ...fake }; +} + +async function goToApiEndpoint( + user: ReturnType, + push: (payload: Record) => void, + overrides: Record = {}, +) { + await user.click(screen.getByText("open settings")); + await user.click(screen.getByRole("button", { name: "API Endpoint" })); + // setActiveSection is fire-and-forget over the fake transport - nothing + // echoes the new activeSection back automatically the way the real + // backend's republish-on-mutation does, so the round trip is simulated + // explicitly here (matching every other section's real activeSection + // being server, not client, state). + act(() => push({ ...snapshot, ...overrides, activeSection: "api endpoint" })); +} + +describe("SettingsDialog", () => { + it("navigating sections fires setActiveSection with the clicked section's key", async () => { + const { user, intents } = setup(); + await user.click(screen.getByText("open settings")); + + await user.click(screen.getByRole("button", { name: "Integrations" })); + + expect(intents).toContainEqual(["app-settings", "setActiveSection", ["integrations"]]); + }); + + it("API Endpoint page renders for the real (not deferred-placeholder) section", async () => { + const { user, push } = setup(); + await goToApiEndpoint(user, push); + + expect(screen.queryByText(/lands in R7\.4b\/R7\.4c/)).toBeNull(); + expect(screen.getByText("API Provider")).toBeInTheDocument(); + }); + + it("Ollama and Llama.cpp still render the deferred placeholder", async () => { + const { user, push } = setup(); + await user.click(screen.getByText("open settings")); + await user.click(screen.getByRole("button", { name: "Llama.cpp (Local)" })); + act(() => push({ ...snapshot, activeSection: "llama.cpp (local)" })); + + expect(screen.getByText("Llama.cpp (Local) configuration lands in R7.4b/R7.4c.")).toBeInTheDocument(); + }); + + it("the API key field always starts blank, even when a key is already configured", async () => { + const { user, push } = setup(); + await goToApiEndpoint(user, push, { apiKeyConfigured: { openai: true, anthropic: false, gemini: false } }); + + const keyField = screen.getByPlaceholderText("A key is configured - enter a new one to replace it"); + expect(keyField).toHaveValue(""); + }); + + it("switching the provider select fires setViewingApiProvider", async () => { + const { user, push, intents } = setup(); + await goToApiEndpoint(user, push); + + await user.selectOptions(screen.getByLabelText("API Provider"), "Anthropic Claude"); + + expect(intents).toContainEqual(["app-settings", "setViewingApiProvider", ["Anthropic Claude"]]); + }); + + it("Load Available Models is hidden for Gemini (no live catalog endpoint)", async () => { + const { user, push } = setup(); + await goToApiEndpoint(user, push, { viewingApiProvider: "Google Gemini" }); + + expect(screen.queryByText("Load Available Models")).toBeNull(); + }); + + it("Save Configuration stays disabled until the key and every required model are filled in", async () => { + const { user, push } = setup(); + await goToApiEndpoint(user, push); + + const saveButton = screen.getByText("Save Configuration"); + expect(saveButton).toBeDisabled(); + + await user.type(screen.getByPlaceholderText("Enter your API key..."), "sk-test"); + expect(saveButton).toBeDisabled(); // still missing every per-task model + + for (const label of [ + "Chat Naming / Session Title", + "Chat, Explain, Takeaways (main model)", + "Chart Generation (code-capable model)", + "Image Generation", + "Web Content Validation", + "Web Content Summarization", + ]) { + await user.type(screen.getByLabelText(label), "gpt-4o-mini"); + } + + expect(saveButton).toBeEnabled(); + }); + + it("Anthropic does not require an Image Generation model to enable Save", async () => { + const { user, push } = setup(); + await goToApiEndpoint(user, push, { viewingApiProvider: "Anthropic Claude", activeApiProvider: "Anthropic Claude" }); + + await user.type(screen.getByPlaceholderText("Enter your API key..."), "sk-ant-test"); + expect(screen.queryByLabelText("Image Generation")).toBeNull(); + + for (const label of [ + "Chat Naming / Session Title", + "Chat, Explain, Takeaways (main model)", + "Chart Generation (code-capable model)", + "Web Content Validation", + "Web Content Summarization", + ]) { + await user.type(screen.getByLabelText(label), "claude-sonnet"); + } + + expect(screen.getByText("Save Configuration")).toBeEnabled(); + }); + + it("Save fires saveApiConfiguration with the typed draft and clears the key field afterward", async () => { + const { user, push, intents } = setup(); + await goToApiEndpoint(user, push); + + await user.type(screen.getByPlaceholderText("Enter your API key..."), "sk-super-secret"); + for (const label of [ + "Chat Naming / Session Title", + "Chat, Explain, Takeaways (main model)", + "Chart Generation (code-capable model)", + "Image Generation", + "Web Content Validation", + "Web Content Summarization", + ]) { + await user.type(screen.getByLabelText(label), "gpt-4o-mini"); + } + await user.click(screen.getByText("Save Configuration")); + + const saveCall = intents.find(([, intent]) => intent === "saveApiConfiguration"); + expect(saveCall).toBeDefined(); + const [, , args] = saveCall as [string, string, unknown[]]; + expect(args[0]).toBe("OpenAI-Compatible"); + expect(args[2]).toBe("sk-super-secret"); + expect(screen.getByPlaceholderText("Enter your API key...")).toHaveValue(""); + }); + + it("Reset API Settings fires resetApiSettings", async () => { + const { user, push, intents } = setup(); + await goToApiEndpoint(user, push); + + await user.click(screen.getByText("Reset API Settings")); + + expect(intents).toContainEqual(["app-settings", "resetApiSettings", []]); + }); + + it("Reset API Settings clears the local Base URL and model drafts too, not just the key", async () => { + // Regression test: Reset originally only cleared draftApiKey, leaving + // draftBaseUrl/draftModels showing their pre-reset values (the resync + // effect only fires on a genuine provider switch, which Reset doesn't + // cause) - a subsequent Save could silently re-persist exactly what + // Reset was meant to clear. + const { user, push } = setup(); + await goToApiEndpoint(user, push); + + await user.clear(screen.getByPlaceholderText("https://api.openai.com/v1")); + await user.type(screen.getByPlaceholderText("https://api.openai.com/v1"), "https://custom-gateway.example/v1"); + await user.type(screen.getByLabelText("Chat, Explain, Takeaways (main model)"), "gpt-4o"); + + await user.click(screen.getByText("Reset API Settings")); + + expect(screen.getByPlaceholderText("https://api.openai.com/v1")).toHaveValue("https://api.openai.com/v1"); + expect(screen.getByLabelText("Chat, Explain, Takeaways (main model)")).toHaveValue(""); + }); +}); diff --git a/web_ui/src/app/chrome/SettingsDialog.tsx b/web_ui/src/app/chrome/SettingsDialog.tsx index 3443df9..44a7445 100644 --- a/web_ui/src/app/chrome/SettingsDialog.tsx +++ b/web_ui/src/app/chrome/SettingsDialog.tsx @@ -5,19 +5,19 @@ import type { AppSettingsState } from "../../lib/bridge-core/generated/app-setti import { Dialog } from "../overlays/overlays"; /** - * The settings dialog (Qt-removal plan R2.5d) - settings island's SPA - * successor, General + Integrations pages only. Ollama/Llama.cpp/API - * Endpoint need a graphlink_config.py Qt/non-Qt split and a native - * file-picker capability neither of which exist yet (backend/settings.py's - * module docstring) - rendered here as disabled placeholders with an - * explicit R4 label rather than faking them, the same explicit-defer - * discipline as the app bar's disabled Save/provider-select. + * The settings dialog (Qt-removal plan R2.5d, extended R7.4a) - settings + * island's SPA successor. General + Integrations + API-provider pages are + * real. Ollama/Llama.cpp remain deferred (R7.4b/R7.4c) - both need a native + * file-picker capability that doesn't exist yet in graphlink_desktop.py - + * rendered here as disabled placeholders with an explicit label rather than + * faking them, the same explicit-defer discipline as the app bar's disabled + * Save/provider-select. */ const SECTIONS = ["General", "Ollama (Local)", "Llama.cpp (Local)", "API Endpoint", "Integrations"] as const; type Section = (typeof SECTIONS)[number]; -const DEFERRED_SECTIONS = new Set
(["Ollama (Local)", "Llama.cpp (Local)", "API Endpoint"]); +const DEFERRED_SECTIONS = new Set
(["Ollama (Local)", "Llama.cpp (Local)"]); const THEME_OPTIONS = [ { value: "dark", label: "Dark" }, @@ -32,6 +32,34 @@ const NOTIFICATION_TYPE_LABELS: Record = { error: "Error", }; +// Mirrors graphlink_task_config.py's API_PROVIDER_* constants exactly (the +// wire values backend/settings.py's SettingsManager persists) - not an enum +// generated by codegen since these 3 strings are the whole of a small, +// stable, hand-maintained set, the same convention SECTIONS above follows. +const API_PROVIDER_OPENAI = "OpenAI-Compatible"; +const API_PROVIDER_ANTHROPIC = "Anthropic Claude"; +const API_PROVIDER_GEMINI = "Google Gemini"; +const API_PROVIDERS = [API_PROVIDER_OPENAI, API_PROVIDER_ANTHROPIC, API_PROVIDER_GEMINI] as const; +// Mirrors SettingsManager.get_api_base_url()'s own default (graphlink_licensing.py). +const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"; + +// Mirrors graphlink_task_config.py's TASK_* constants exactly. +const TASK_TITLE = "task_title"; +const TASK_CHAT = "task_chat"; +const TASK_CHART = "task_chart"; +const TASK_IMAGE_GEN = "task_image_gen"; +const TASK_WEB_VALIDATE = "task_web_validate"; +const TASK_WEB_SUMMARIZE = "task_web_summarize"; + +const API_TASK_FIELDS: { task: string; label: string }[] = [ + { task: TASK_TITLE, label: "Chat Naming / Session Title" }, + { task: TASK_CHAT, label: "Chat, Explain, Takeaways (main model)" }, + { task: TASK_CHART, label: "Chart Generation (code-capable model)" }, + { task: TASK_IMAGE_GEN, label: "Image Generation" }, + { task: TASK_WEB_VALIDATE, label: "Web Content Validation" }, + { task: TASK_WEB_SUMMARIZE, label: "Web Content Summarization" }, +]; + const initialState: AppSettingsState = { schemaVersion: 1, minCompatibleSchemaVersion: 1, @@ -42,6 +70,16 @@ const initialState: AppSettingsState = { enableSystemPrompt: true, notificationPreferences: {}, githubTokenConfigured: false, + activeApiProvider: API_PROVIDER_OPENAI, + viewingApiProvider: API_PROVIDER_OPENAI, + apiBaseUrl: DEFAULT_OPENAI_BASE_URL, + apiKeyConfigured: { openai: false, anthropic: false, gemini: false }, + apiModels: {}, + apiModelCatalog: [], + apiCatalogStatus: "idle", + apiCatalogMessage: "Model catalog has not been refreshed yet.", + geminiStaticModels: [], + geminiStaticImageModels: [], }; function sectionKey(section: Section): string { @@ -172,12 +210,195 @@ function IntegrationsPage({ ); } +function ApiProviderPage({ + state, + transport, +}: { + state: AppSettingsState; + transport: WsTransport; +}) { + const viewingProvider = state.viewingApiProvider; + const isOpenAi = viewingProvider === API_PROVIDER_OPENAI; + const isAnthropic = viewingProvider === API_PROVIDER_ANTHROPIC; + + // Draft fields, not server state - mirrors IntegrationsPage's draftToken: + // the key is write-only (never pre-filled from state, unlike the legacy + // QWidget - see this file's module docstring for why), and base + // URL/per-task models are a local edit buffer until Save commits them. + // Re-synced from the server snapshot whenever the VIEWED provider changes, + // matching the legacy dialog's own per-provider field reset. Uses React's + // "adjusting state when a prop changes" pattern (comparing against a + // tracked previous value INSIDE render, not a useEffect) so a genuine + // provider switch resets the drafts in the same render/commit rather than + // flashing the old provider's stale draft for one extra frame first. + const [draftApiKey, setDraftApiKey] = useState(""); + const [draftBaseUrl, setDraftBaseUrl] = useState(state.apiBaseUrl); + const [draftModels, setDraftModels] = useState>(state.apiModels); + const [lastViewingProvider, setLastViewingProvider] = useState(viewingProvider); + + if (viewingProvider !== lastViewingProvider) { + setLastViewingProvider(viewingProvider); + setDraftApiKey(""); + setDraftBaseUrl(state.apiBaseUrl); + setDraftModels(state.apiModels); + } + + const catalogModelIds = state.apiModelCatalog.map((entry) => entry.modelId); + const modelOptionsFor = (task: string): string[] => { + if (isOpenAi || isAnthropic) return catalogModelIds; + return task === TASK_IMAGE_GEN ? state.geminiStaticImageModels : state.geminiStaticModels; + }; + + const requiredTasks = API_TASK_FIELDS.filter(({ task }) => !(isAnthropic && task === TASK_IMAGE_GEN)); + + return ( +
+ + +

+ {viewingProvider === state.activeApiProvider + ? `${state.activeApiProvider} is the currently active provider.` + : `Currently active: ${state.activeApiProvider}. Save to switch to ${viewingProvider}.`} +

+ + {isOpenAi && ( + + )} + + + + {(isOpenAi || isAnthropic) && ( + <> +
+ +
+

+ {state.apiCatalogMessage} +

+ + )} + +
+ Model Selection (per task) + {API_TASK_FIELDS.filter(({ task }) => !(isAnthropic && task === TASK_IMAGE_GEN)).map(({ task, label }) => ( + + ))} +
+ +
+ + +
+
+ ); +} + function DeferredPage({ section }: { section: Section }) { return (
-

{section} configuration lands in R4.

+

{section} configuration lands in R7.4b/R7.4c.

- Local/API model providers need a Qt-free config split and a native file picker that don't exist yet. + {section === "Llama.cpp (Local)" + ? "Needs a native GGUF file picker that doesn't exist yet in graphlink_desktop.py." + : "Backend model scan/pull wiring for this provider hasn't landed yet."}

); @@ -217,6 +438,8 @@ export function SettingsDialog({ transport }: { transport: WsTransport }) { ) : activeSection === "Integrations" ? ( + ) : activeSection === "API Endpoint" ? ( + ) : DEFERRED_SECTIONS.has(activeSection) ? ( ) : ( diff --git a/web_ui/src/lib/bridge-core/generated/app-settings-state.schema.json b/web_ui/src/lib/bridge-core/generated/app-settings-state.schema.json index 451724f..595bc03 100644 --- a/web_ui/src/lib/bridge-core/generated/app-settings-state.schema.json +++ b/web_ui/src/lib/bridge-core/generated/app-settings-state.schema.json @@ -2,12 +2,82 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { + "activeApiProvider": { + "type": "string" + }, "activeSection": { "type": "string" }, + "apiBaseUrl": { + "type": "string" + }, + "apiCatalogMessage": { + "type": "string" + }, + "apiCatalogStatus": { + "type": "string" + }, + "apiKeyConfigured": { + "additionalProperties": { + "type": "boolean" + }, + "type": "object" + }, + "apiModelCatalog": { + "items": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "capabilities": { + "items": { + "type": "string" + }, + "type": "array" + }, + "modelId": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "ready": { + "type": "boolean" + } + }, + "required": [ + "modelId", + "provider", + "capabilities", + "ready", + "available" + ], + "type": "object" + }, + "type": "array" + }, + "apiModels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, "enableSystemPrompt": { "type": "boolean" }, + "geminiStaticImageModels": { + "items": { + "type": "string" + }, + "type": "array" + }, + "geminiStaticModels": { + "items": { + "type": "string" + }, + "type": "array" + }, "githubTokenConfigured": { "type": "boolean" }, @@ -31,6 +101,9 @@ }, "theme": { "type": "string" + }, + "viewingApiProvider": { + "type": "string" } }, "required": [ @@ -41,7 +114,17 @@ "showTokenCounter", "enableSystemPrompt", "notificationPreferences", - "githubTokenConfigured" + "githubTokenConfigured", + "activeApiProvider", + "viewingApiProvider", + "apiBaseUrl", + "apiKeyConfigured", + "apiModels", + "apiModelCatalog", + "apiCatalogStatus", + "apiCatalogMessage", + "geminiStaticModels", + "geminiStaticImageModels" ], "title": "AppSettingsState", "type": "object" diff --git a/web_ui/src/lib/bridge-core/generated/app-settings-state.ts b/web_ui/src/lib/bridge-core/generated/app-settings-state.ts index 88e5fcf..a50e9fe 100644 --- a/web_ui/src/lib/bridge-core/generated/app-settings-state.ts +++ b/web_ui/src/lib/bridge-core/generated/app-settings-state.ts @@ -2,6 +2,14 @@ * Regenerate with graphlink_island_codegen.py; a pytest fails if this file * drifts from what regenerating it now would produce. */ +export interface ApiModelDescriptor { + modelId: string; + provider: string; + capabilities: string[]; + ready: boolean; + available: boolean; +} + export interface AppSettingsState { schemaVersion: number; revision: number; @@ -11,6 +19,16 @@ export interface AppSettingsState { enableSystemPrompt: boolean; notificationPreferences: Record; githubTokenConfigured: boolean; + activeApiProvider: string; + viewingApiProvider: string; + apiBaseUrl: string; + apiKeyConfigured: Record; + apiModels: Record; + apiModelCatalog: ApiModelDescriptor[]; + apiCatalogStatus: string; + apiCatalogMessage: string; + geminiStaticModels: string[]; + geminiStaticImageModels: string[]; minCompatibleSchemaVersion?: number | null; } @@ -29,6 +47,36 @@ function isRecord(value: unknown): value is Record { // defeat the additive-forward-compatibility the version negotiation exists to // provide. Missing or wrongly-typed KNOWN fields are still hard errors. +function checkApiModelDescriptor(value: unknown, path: string, errors: string[]): void { + if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } + { + const fieldValue = value["modelId"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.modelId: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.modelId` + ": expected string"); } + } + { + const fieldValue = value["provider"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.provider: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.provider` + ": expected string"); } + } + { + const fieldValue = value["capabilities"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.capabilities: missing required field`); + else { if (!Array.isArray(fieldValue)) errors.push(`${path}.capabilities` + ": expected array"); + else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.capabilities` + `[${i}]` + ": expected string"); }); } + } + { + const fieldValue = value["ready"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.ready: missing required field`); + else { if (typeof fieldValue !== "boolean") errors.push(`${path}.ready` + ": expected boolean"); } + } + { + const fieldValue = value["available"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.available: missing required field`); + else { if (typeof fieldValue !== "boolean") errors.push(`${path}.available` + ": expected boolean"); } + } +} + function checkAppSettingsState(value: unknown, path: string, errors: string[]): void { if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } { @@ -72,6 +120,61 @@ function checkAppSettingsState(value: unknown, path: string, errors: string[]): if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.githubTokenConfigured: missing required field`); else { if (typeof fieldValue !== "boolean") errors.push(`${path}.githubTokenConfigured` + ": expected boolean"); } } + { + const fieldValue = value["activeApiProvider"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.activeApiProvider: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.activeApiProvider` + ": expected string"); } + } + { + const fieldValue = value["viewingApiProvider"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.viewingApiProvider: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.viewingApiProvider` + ": expected string"); } + } + { + const fieldValue = value["apiBaseUrl"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.apiBaseUrl: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.apiBaseUrl` + ": expected string"); } + } + { + const fieldValue = value["apiKeyConfigured"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.apiKeyConfigured: missing required field`); + else { if (!isRecord(fieldValue)) errors.push(`${path}.apiKeyConfigured` + ": expected object"); + else Object.entries(fieldValue as Record).forEach(([k, v]) => { if (typeof v !== "boolean") errors.push(`${path}.apiKeyConfigured` + `[${JSON.stringify(k)}]` + ": expected boolean"); }); } + } + { + const fieldValue = value["apiModels"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.apiModels: missing required field`); + else { if (!isRecord(fieldValue)) errors.push(`${path}.apiModels` + ": expected object"); + else Object.entries(fieldValue as Record).forEach(([k, v]) => { if (typeof v !== "string") errors.push(`${path}.apiModels` + `[${JSON.stringify(k)}]` + ": expected string"); }); } + } + { + const fieldValue = value["apiModelCatalog"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.apiModelCatalog: missing required field`); + else { if (!Array.isArray(fieldValue)) errors.push(`${path}.apiModelCatalog` + ": expected array"); + else (fieldValue as unknown[]).forEach((item, i) => { checkApiModelDescriptor(item, `${path}.apiModelCatalog` + `[${i}]`, errors); }); } + } + { + const fieldValue = value["apiCatalogStatus"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.apiCatalogStatus: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.apiCatalogStatus` + ": expected string"); } + } + { + const fieldValue = value["apiCatalogMessage"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.apiCatalogMessage: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.apiCatalogMessage` + ": expected string"); } + } + { + const fieldValue = value["geminiStaticModels"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.geminiStaticModels: missing required field`); + else { if (!Array.isArray(fieldValue)) errors.push(`${path}.geminiStaticModels` + ": expected array"); + else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.geminiStaticModels` + `[${i}]` + ": expected string"); }); } + } + { + const fieldValue = value["geminiStaticImageModels"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.geminiStaticImageModels: missing required field`); + else { if (!Array.isArray(fieldValue)) errors.push(`${path}.geminiStaticImageModels` + ": expected array"); + else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.geminiStaticImageModels` + `[${i}]` + ": expected string"); }); } + } { const fieldValue = value["minCompatibleSchemaVersion"]; if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.minCompatibleSchemaVersion` + ": expected number"); }