From 62169ae8fee67148d2b66a34eb942b5f3de7aa56 Mon Sep 17 00:00:00 2001 From: witbrock Date: Sat, 12 Sep 2026 21:08:15 -0400 Subject: [PATCH 1/2] Repair allowed-model saves and metadata-backed transcription selection --- docs/engineering/speech_io.md | 45 +++++ src/backend/languagemodels/llm_interface.py | 8 +- src/backend/server/routes/settings_routes.py | 169 ++++++++++++++---- src/backend/server/routes/speech_routes.py | 5 +- src/backend/services/model_audio_metadata.py | 29 +++ .../services/speech_transcription_service.py | 38 +++- .../web/von_interface/static/js/dictation.js | 10 +- .../von_interface/static/js/modelInventory.js | 92 ++++++++++ .../von_interface/static/js/settingsPage.js | 144 +++++++++++++-- .../web/von_interface/static/styles.css | 7 + .../von_interface/templates/settings_tab.html | 9 + tests/backend/test_model_audio_metadata.py | 98 ++++++++++ tests/backend/test_speech_transcription.py | 60 +++++++ tests/frontend/modelInventory.test.js | 69 +++++++ .../settingsModelPoolControls.test.js | 53 +++++- 15 files changed, 762 insertions(+), 74 deletions(-) create mode 100644 src/backend/services/model_audio_metadata.py create mode 100644 src/frontend/web/von_interface/static/js/modelInventory.js create mode 100644 tests/backend/test_model_audio_metadata.py create mode 100644 tests/frontend/modelInventory.test.js diff --git a/docs/engineering/speech_io.md b/docs/engineering/speech_io.md index 66b6317ff..4730db535 100644 --- a/docs/engineering/speech_io.md +++ b/docs/engineering/speech_io.md @@ -231,3 +231,48 @@ handoff/reconciliation, actor denial and telemetry isolation/read-back. Streaming references: [OpenAI realtime transcription](https://developers.openai.com/api/docs/guides/realtime-transcription), [WebRTC transport](https://developers.openai.com/api/docs/guides/voice-webrtc?api=realtime). + +## Recorded-transcription metadata and model settings + +The Settings additional-model inventory projects each provider's existing +catalogue (OpenAI, OpenRouter, Gemini, Meta and all scanned Ollama hosts). It +labels catalogue availability separately from the selected target's persisted +allow list and from assigned browser, primary, server-default, RAG and recorded +transcription roles. An allowed alternative is not automatically assigned a +role. Provider availability means discovery succeeded with the configured key +or host; it does not establish quota or guarantee a later inference request. +Remote Ollama hosts remain subject to the existing remote-scan setting. Meta +retains its existing fixed catalogue. A disabled or unreachable provider gets +a provider-specific status and can be rechecked with Refresh provider inventory. + +**Allow and save** checks discovery, retains the selected primary and existing +alternatives, saves through the canonical scoped settings API, and checks the +returned pool before enabling use. Failed saves retain the edit and a retry +action. The 60-second browser observation bound releases a stuck Settings +interaction; it is not proof the server did not save. An unknown outcome asks +for **Reload saved model pool** before retrying. Reload discards local edits in +favour of canonical state. Saving and reloading update the selectors immediately. + +The represented model registry's `capabilities.audio_transcription` boolean +(on `#V#has_model_capabilities_json`) declares compatibility with recorded file +transcription. Exact provider/model identity or a registry model alias matches +the entry; model-name substrings are not evidence of support. Explicit false +can disable compatibility. The compatibility defaults for the existing OpenAI +file adapter are `gpt-transcribe`, `gpt-4o-transcribe` and +`gpt-4o-mini-transcribe`. Discovery still determines availability; defaults +absent from a provider catalogue are displayed as unavailable, not invented as +accessible models. Additional registry entries can extend the eligible list. +Only the implemented OpenAI file-transcription transport is currently supported; +OpenRouter audio input, general chat, realtime transcription and speech synthesis +do not establish compatibility with that transport. + +The recorded-transcription preference belongs to this browser and is distinct +from the scoped allow list. Its selector uses the actor's effective pool even +when an administrator is editing the organisation target. Disabled, unavailable +and stale selections remain visible but cannot be newly selected. The server +rechecks the requested model against metadata, the effective pool and the +configured OpenAI key, then applies the existing execution authority check before +sending audio. A stale explicit selection fails visibly rather than silently +switching to another model. Automatic selection retains the existing environment +preference and compatibility ordering, then considers metadata-backed models. +Live dictation continues to use its separate realtime transport. diff --git a/src/backend/languagemodels/llm_interface.py b/src/backend/languagemodels/llm_interface.py index a65ff4e41..8c379ebeb 100644 --- a/src/backend/languagemodels/llm_interface.py +++ b/src/backend/languagemodels/llm_interface.py @@ -2469,9 +2469,9 @@ def get_embedding(self, text: str, model: Optional[str] = None) -> List[float]: ) raise RuntimeError(f"OpenAI embedding error: {str(e)}") from e - def list_models(self) -> List[str]: - """List available models from OpenAI (focus on GPT models) with caching/backoff.""" - cache_key = "openai:models" + def list_models(self, *, include_all: bool = False) -> List[str]: + """Discover all model IDs for inventory, or the legacy GPT chat catalogue.""" + cache_key = "openai:models:all" if include_all else "openai:models" now = time.time() # Check cache @@ -2501,7 +2501,7 @@ def list_models(self) -> List[str]: models_response = self.client.models.list() for model in getattr(models_response, "data", []): # type: ignore[attr-defined] mid = getattr(model, "id", None) - if isinstance(mid, str) and "gpt" in mid: + if isinstance(mid, str) and (include_all or "gpt" in mid): models.append(mid) models = sorted(models) logger.debug(f"Found OpenAI models (filtered): {models}") diff --git a/src/backend/server/routes/settings_routes.py b/src/backend/server/routes/settings_routes.py index 697c58d02..916f19bbe 100644 --- a/src/backend/server/routes/settings_routes.py +++ b/src/backend/server/routes/settings_routes.py @@ -962,18 +962,24 @@ def get_llm_info(): def execution_cost_display(): """Read viewer-scoped thresholds or replace the authenticated user's pair.""" from ...services.execution_cost_display_service import ( - PREDICATE, resolve_preferences, validate_preferences, + PREDICATE, + resolve_preferences, + validate_preferences, ) actor = get_effective_user_concept_id() if not actor: return _jsonify_no_store({"error": "authenticated_actor_context_required"}, 401) effective = get_effective_context( - request.headers.get("X-Von-Window-Session"), dict(session), actor, + request.headers.get("X-Von-Window-Session"), + dict(session), + actor, ) if request.method == "PUT": import json - from ...services.ontology_mutation_command_service import execute_governed_ontology_method + from ...services.ontology_mutation_command_service import ( + execute_governed_ontology_method, + ) from ...services.text_value_service import upsert_singleton_text_relation try: @@ -981,19 +987,32 @@ def execution_cost_display(): except ValueError as exc: return _jsonify_no_store({"error": str(exc)}, 400) text = json.dumps(value, sort_keys=True) - provenance = {"source": "execution_cost_display_preferences", "actor_concept_id": actor} + provenance = { + "source": "execution_cost_display_preferences", + "actor_concept_id": actor, + } result = execute_governed_ontology_method( method_name="upsert_singleton_text_relation", - arguments={"concept_id": actor, "predicate": PREDICATE, "text": text, - "language": "en-NZ", "provenance": provenance}, + arguments={ + "concept_id": actor, + "predicate": PREDICATE, + "text": text, + "language": "en-NZ", + "provenance": provenance, + }, mutate=lambda: upsert_singleton_text_relation( - subject_concept_id=actor, predicate=PREDICATE, text=text, - lang="en-NZ", provenance=provenance, + subject_concept_id=actor, + predicate=PREDICATE, + text=text, + lang="en-NZ", + provenance=provenance, ), ) if result.get("success") is False: return _jsonify_no_store(result, 503) - return _jsonify_no_store(resolve_preferences(actor, effective.get("organisation_id"))) + return _jsonify_no_store( + resolve_preferences(actor, effective.get("organisation_id")) + ) @settings_bp.route("/llm/cost_summary", methods=["GET"]) @@ -1014,9 +1033,7 @@ def get_llm_cost_summary(): {"error": "An authenticated actor and namespace are required."}, 401 ) if not provider or not model: - return _jsonify_no_store( - {"error": "provider and model are required."}, 400 - ) + return _jsonify_no_store({"error": "provider and model are required."}, 400) try: limit = int(request.args.get("limit") or 200) window_days = int(request.args.get("window_days") or 30) @@ -2049,11 +2066,7 @@ def _settings_key_presence(env_var_name: str) -> tuple[bool, str | None]: if read_secret_file(os.getenv(f"{name}_FILE")): return True, "secret_file" - dotenv_keys = tuple( - key - for name in candidates - for key in (name, f"{name}_FILE") - ) + dotenv_keys = tuple(key for name in candidates for key in (name, f"{name}_FILE")) dotenv_values = read_repo_dotenv_values(dotenv_keys) for name in candidates: if dotenv_values.get(name): @@ -2101,7 +2114,7 @@ def get_ollama_models(): @settings_bp.route("/models/openai", methods=["GET"]) -def get_openai_models(): +def get_openai_models(*, include_all=False): """API endpoint to get available OpenAI models regardless of current selection.""" try: # Get the configured OpenAI API key environment variable @@ -2109,7 +2122,11 @@ def get_openai_models(): # Create a direct OpenAIClient instance to get models client = OpenAIClient(api_key_env_var=api_key_env_var) - models = client.list_models() + models = ( + client.list_models(include_all=True) + if include_all + else client.list_models() + ) if models: current_app.logger.info(f"Retrieved {len(models)} OpenAI models") @@ -2255,6 +2272,66 @@ def get_meta_models(): ) +@settings_bp.route("/models/inventory/", methods=["GET"]) +def get_model_inventory(provider): + """Project discovery and transport metadata without enabling any model.""" + from ...services.model_audio_metadata import recorded_transcription_supported + from ...services.model_registry_service import get_model_registry_snapshot + from ...languagemodels.model_defaults import TRANSCRIPTION_MODELS + + loaders = { + "openai": lambda: get_openai_models(include_all=True), + "openrouter": get_openrouter_models, + "gemini": get_gemini_models, + "meta": get_meta_models, + "ollama": get_ollama_models_from_all_hosts, + } + if provider not in loaders: + return _jsonify_no_store({"error": "Unsupported model provider."}, 400) + response = current_app.make_response(loaders[provider]()) + payload = response.get_json() + available = response.status_code == 200 + discovered = payload.get("models", []) if isinstance(payload, dict) else payload + discovered = discovered if available and isinstance(discovered, list) else [] + registry_models = get_model_registry_snapshot().get("models", []) + entries = [] + for value in discovered: + model = ( + value.get("model") or value.get("name") + if isinstance(value, dict) + else value + ) + if not isinstance(model, str) or not model: + continue + entry = {"provider": provider, "model": model, "available": True} + if isinstance(value, dict) and (value.get("host") or value.get("host_url")): + entry["host"] = value.get("host") or value.get("host_url") + entries.append(entry) + if provider == "openai": + for model in TRANSCRIPTION_MODELS: + if not any(e["model"] == model for e in entries): + entries.append( + {"provider": provider, "model": model, "available": False} + ) + for entry in entries: + entry["audio_transcription"] = recorded_transcription_supported( + provider, entry["model"], registry_models=registry_models + ) + return _jsonify_no_store( + { + "provider": provider, + "available": available, + "error": ( + None + if available + else f"{provider}: catalogue unavailable; check the provider key, host and access, then refresh." + ), + "models": entries, + }, + 200, + ) + + @settings_bp.route("/model_parameters/capabilities", methods=["GET"]) def get_model_parameter_capabilities(): provider = str(request.args.get("provider") or "").strip().lower() @@ -2355,10 +2432,14 @@ def first_val(v): preferred_language = language_rows[0].get("text") # Legacy member_of_organisation describes membership, not preference. # Never choose the first membership as a landing context. - from ...services.login_organisation_preference_service import initialise_login_context + from ...services.login_organisation_preference_service import ( + initialise_login_context, + ) + organisation_concept_id = ( initialise_login_context(session)["organisation_concept_id"] - if session.get("user_email") and session.get("user_concept_id") else None + if session.get("user_email") and session.get("user_concept_id") + else None ) return ( jsonify( @@ -2674,9 +2755,9 @@ def verify_gemini_api_key(): ) api_key = _resolve_settings_api_key( api_key_env_var, - fallback_env_vars=("GOOGLE_API_KEY",) - if api_key_env_var == "GEMINI_API_KEY" - else (), + fallback_env_vars=( + ("GOOGLE_API_KEY",) if api_key_env_var == "GEMINI_API_KEY" else () + ), ) if not api_key: return _jsonify_no_store( @@ -2780,7 +2861,10 @@ def verify_openrouter_api_key(): """Verify the fixed OpenRouter key source by listing its current catalogue.""" payload = request.get_json(silent=True) or {} - if str(payload.get("api_key_env_var") or "OPENROUTER_API_KEY").strip() != "OPENROUTER_API_KEY": + if ( + str(payload.get("api_key_env_var") or "OPENROUTER_API_KEY").strip() + != "OPENROUTER_API_KEY" + ): return _jsonify_no_store( { "success": False, @@ -4285,9 +4369,9 @@ def test_gemini_model(): api_key = _resolve_settings_api_key( api_key_env_var, - fallback_env_vars=("GOOGLE_API_KEY",) - if api_key_env_var == "GEMINI_API_KEY" - else (), + fallback_env_vars=( + ("GOOGLE_API_KEY",) if api_key_env_var == "GEMINI_API_KEY" else () + ), ) if not api_key: return _jsonify_no_store( @@ -4420,13 +4504,8 @@ def test_meta_model(): 400, ) - actor_user_concept_id, actor_source = ( - get_effective_user_concept_id_with_source() - ) - if ( - not actor_user_concept_id - or actor_source == LEGACY_IDENTITY_HEADER_ACTOR_SOURCE - ): + actor_user_concept_id, actor_source = get_effective_user_concept_id_with_source() + if not actor_user_concept_id or actor_source == LEGACY_IDENTITY_HEADER_ACTOR_SOURCE: return _jsonify_no_store( { "success": False, @@ -4657,13 +4736,25 @@ def test_openrouter_model(): failure_kind = "unexpected_error" reason = "Unexpected OpenRouter model probe failure." if status_code == 401 or "auth" in lowered or "api key" in lowered: - failure_kind, reason = "authentication_error", "OpenRouter authentication failed." + failure_kind, reason = ( + "authentication_error", + "OpenRouter authentication failed.", + ) elif status_code == 403 or "permission" in lowered: - failure_kind, reason = "permission_denied", "OpenRouter access was denied for this model." + failure_kind, reason = ( + "permission_denied", + "OpenRouter access was denied for this model.", + ) elif status_code == 404 or "not found" in lowered: - failure_kind, reason = "model_not_found", "The selected OpenRouter model was not found." + failure_kind, reason = ( + "model_not_found", + "The selected OpenRouter model was not found.", + ) elif status_code == 429 or "rate limit" in lowered or "quota" in lowered: - failure_kind, reason = "rate_limited", "OpenRouter rate or quota limit was reached." + failure_kind, reason = ( + "rate_limited", + "OpenRouter rate or quota limit was reached.", + ) elif "connect" in lowered or "network" in lowered: failure_kind, reason = "connection_error", "Could not reach OpenRouter." current_app.logger.warning( diff --git a/src/backend/server/routes/speech_routes.py b/src/backend/server/routes/speech_routes.py index 57a9167f2..df547d8a0 100644 --- a/src/backend/server/routes/speech_routes.py +++ b/src/backend/server/routes/speech_routes.py @@ -49,7 +49,7 @@ def speech_capabilities(): if not actor: return jsonify(error="authentication_required"), 401 try: - data = transcription_capability(actor, organisation) + data = transcription_capability(actor, organisation, request.args.get("model", "")) except SpeechUnavailable as exc: data = {"available": False, "reason": str(exc)} except Exception: @@ -95,6 +95,7 @@ def transcribe_speech(): context=request.form.get("context", ""), vocabulary=vocabulary, language=request.form.get("language", ""), + model=request.form.get("model", ""), ) except (ValueError, json.JSONDecodeError) as exc: return jsonify(error="invalid_audio_request", message=str(exc)), 400 @@ -108,7 +109,7 @@ def transcribe_speech(): return ( jsonify( error="transcription_failed", - message="Transcription failed. Your recording is available to retry.", + message="OpenAI transcription failed. Check model access and the configured provider key in Settings. Your recording is available to retry.", ), 502, ) diff --git a/src/backend/services/model_audio_metadata.py b/src/backend/services/model_audio_metadata.py new file mode 100644 index 000000000..370a9dd7a --- /dev/null +++ b/src/backend/services/model_audio_metadata.py @@ -0,0 +1,29 @@ +"""Recorded-audio transport metadata, supplemented by the represented registry. + +The compatibility entries describe existing transport support, not permission or +provider availability. Registry capabilities.audio_transcription may extend or +disable that support; only the implemented OpenAI file transport is eligible. +""" + +from ..languagemodels.model_defaults import TRANSCRIPTION_MODELS + + +def recorded_transcription_supported(provider, model, *, registry_models=None): + provider = str(provider or "").lower() + model = str(model or "").removeprefix(provider + ":") + supported = provider == "openai" and model in TRANSCRIPTION_MODELS + if registry_models is None: + from .model_registry_service import get_model_registry_snapshot + + registry_models = get_model_registry_snapshot().get("models", []) + for entry in registry_models: + if entry.get("provider") != provider: + continue + names = [entry.get("model_id"), *(entry.get("model_aliases") or [])] + if model not in names and provider + ":" + model not in names: + continue + capability = (entry.get("capabilities") or {}).get("audio_transcription") + if isinstance(capability, bool): + supported = capability + break + return provider == "openai" and supported diff --git a/src/backend/services/speech_transcription_service.py b/src/backend/services/speech_transcription_service.py index c1d86f589..ee8d56e4c 100644 --- a/src/backend/services/speech_transcription_service.py +++ b/src/backend/services/speech_transcription_service.py @@ -12,6 +12,7 @@ from ..languagemodels.llm_interface import assert_model_execution_allowed from ..languagemodels.model_defaults import TRANSCRIPTION_MODELS from .settings_service import get_openai_env_var, resolve_enabled_llm_settings +from .model_audio_metadata import recorded_transcription_supported MAX_AUDIO_BYTES = 24_000_000 AUDIO_TYPES = { @@ -28,7 +29,9 @@ class SpeechUnavailable(Exception): pass -def transcription_model(actor: str, organisation: str | None) -> str: +def transcription_model( + actor: str, organisation: str | None, requested_model="" +) -> str: enabled = resolve_enabled_llm_settings( user_concept_id=actor, org_concept_id=organisation ) @@ -37,20 +40,29 @@ def transcription_model(actor: str, organisation: str | None) -> str: for e in enabled if isinstance(e, dict) and e.get("provider") == "openai" } - preferred = os.environ.get("VON_TRANSCRIPTION_MODEL", "").strip() - candidates = (preferred,) if preferred else TRANSCRIPTION_MODELS + preferred = str( + requested_model or os.environ.get("VON_TRANSCRIPTION_MODEL", "") + ).strip() + candidates = (preferred,) if preferred else (*TRANSCRIPTION_MODELS, *sorted(models)) for model in candidates: - if model in TRANSCRIPTION_MODELS and model in models: + if model in models and recorded_transcription_supported("openai", model): return model raise SpeechUnavailable( - "Enable an OpenAI transcription model in Settings: " + ( + f"OpenAI {preferred} is not an enabled recorded-transcription model. " + if preferred + else "" + ) + + "Enable an OpenAI transcription model in Settings: " + ", ".join(TRANSCRIPTION_MODELS) + "." ) -def transcription_capability(actor: str, organisation: str | None) -> dict: - model = transcription_model(actor, organisation) +def transcription_capability( + actor: str, organisation: str | None, requested_model="" +) -> dict: + model = transcription_model(actor, organisation, requested_model) if not os.environ.get(get_openai_env_var() or "OPENAI_API_KEY"): raise SpeechUnavailable("The server's configured OpenAI key is unavailable.") return { @@ -84,7 +96,15 @@ def normalise_language(language): def transcribe_audio( - *, actor, organisation, audio, mime_type, context="", vocabulary=None, language="" + *, + actor, + organisation, + audio, + mime_type, + context="", + vocabulary=None, + language="", + model="", ): import openai @@ -93,7 +113,7 @@ def transcribe_audio( raise ValueError("Unsupported recording format. Use MP4, WebM, MP3 or WAV.") if not audio or len(audio) > MAX_AUDIO_BYTES: raise ValueError("Recording is empty or exceeds the 24 MB upload limit.") - capability = transcription_capability(actor, organisation) + capability = transcription_capability(actor, organisation, model) model = capability["model"] assert_model_execution_allowed( provider="openai", diff --git a/src/frontend/web/von_interface/static/js/dictation.js b/src/frontend/web/von_interface/static/js/dictation.js index 38864166d..7a30e7bc1 100644 --- a/src/frontend/web/von_interface/static/js/dictation.js +++ b/src/frontend/web/von_interface/static/js/dictation.js @@ -139,11 +139,13 @@ export function createDictationController({ input, button, status, cancelButton, async function transcribe(capture) { if (!valid(capture)) return; render('transcribing', 'Transcribing with conversation vocabulary…'); + capture.model ??= selectedTranscriptionModel(); capture.abort = new AbortController(); const form = new FormData(); const mime = capture.blob.type.split(';')[0]; form.append('audio', capture.blob, mime === 'audio/mp4' ? 'dictation.mp4' : 'dictation.webm'); form.append('language', capture.context.language || ''); + form.append('model', capture.model || ''); form.append('context', capture.context.text || ''); form.append('vocabulary', JSON.stringify(capture.context.vocabulary || [])); try { @@ -163,7 +165,7 @@ export function createDictationController({ input, button, status, cancelButton, statusDismissed = false; if (current) cancel(); const context = getContext(); - const capture = { key: context.key, context, base: input.value, + const capture = { key: context.key, context, base: input.value, model: selectedTranscriptionModel(), selection: { start: input.selectionStart, end: input.selectionEnd }, chunks: [], bytes: 0 }; capture.completion = new Promise(resolve => { capture.resolve = resolve; }); current = capture; @@ -261,7 +263,8 @@ export function createDictationController({ input, button, status, cancelButton, async function refreshCapabilities() { try { - const response = await fetchImpl('/api/speech/capabilities', { credentials: 'same-origin', cache: 'no-store' }); + const model = selectedTranscriptionModel(); + const response = await fetchImpl('/api/speech/capabilities' + (model ? `?model=${encodeURIComponent(model)}` : ''), { credentials: 'same-origin', cache: 'no-store' }); const data = await response.json(); capability = response.ok ? { ...data.transcription, streaming: data.streaming } : { available: false, reason: 'Sign in to use recorded transcription.' }; } catch (_) { @@ -302,6 +305,7 @@ export function createDictationController({ input, button, status, cancelButton, root.addEventListener?.('pagehide', cancel); root.addEventListener?.('focus', refreshCapabilities); root.addEventListener?.('von-preferences-changed', refreshCapabilities); + root.document?.addEventListener('von:settingsChanged', refreshCapabilities); root.document?.addEventListener('visibilitychange', visibility); render('idle'); void refreshCapabilities(); @@ -324,6 +328,8 @@ export function createDictationController({ input, button, status, cancelButton, retryButton.removeEventListener('click', retry); root.removeEventListener?.('pagehide', cancel); root.removeEventListener?.('focus', refreshCapabilities); root.removeEventListener?.('von-preferences-changed', refreshCapabilities); + root.document?.removeEventListener('von:settingsChanged', refreshCapabilities); root.document?.removeEventListener('visibilitychange', visibility); } }; } +import { selectedTranscriptionModel } from './modelInventory.js'; diff --git a/src/frontend/web/von_interface/static/js/modelInventory.js b/src/frontend/web/von_interface/static/js/modelInventory.js new file mode 100644 index 000000000..cc7b133df --- /dev/null +++ b/src/frontend/web/von_interface/static/js/modelInventory.js @@ -0,0 +1,92 @@ +// Discovery is evidence of availability, never model-pool authority. +export const TRANSCRIPTION_MODEL_KEY = 'chatRecordedTranscriptionModel'; + +export function selectedTranscriptionModel() { + try { return localStorage.getItem(TRANSCRIPTION_MODEL_KEY) || ''; } catch { return ''; } +} + +export async function modelSettingsRequest(url, options = {}, timeoutMs = 60000) { + const controller = new AbortController(); + let timer; + try { + return await Promise.race([ + (async () => { + const response = await fetch(url, { ...options, signal: controller.signal }); + const data = await response.json(); + if (!response.ok) throw new Error(data.message || data.error || `Request failed (${response.status}).`); + return data; + })(), + new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject(new Error('No response within 60 seconds. The save outcome is unknown; reload the model pool before retrying.')); + }, timeoutMs); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +export function renderModelInventory({ inventories, enabled, effectiveEnabled = enabled, roles, allow, busy = false, ready = true }) { + const list = document.getElementById('additionalModelInventory'); + const selector = document.getElementById('settingsTranscriptionModelSelect'); + if (!list && !selector) return; + const same = (a, b) => a.provider === b.provider && a.model === b.model + && (a.host || '') === (b.host || ''); + const models = inventories.flatMap((inventory) => inventory.models || []); + for (const entry of [...enabled, ...effectiveEnabled]) { + if (!models.some((candidate) => same(candidate, entry))) models.push({ ...entry, available: false }); + } + list?.replaceChildren(); + for (const inventory of inventories) { + if (inventory.error && list) { + const error = document.createElement('p'); + error.textContent = inventory.error; + list.append(error); + } + } + for (const entry of models) { + const allowed = ready && enabled.some((candidate) => same(candidate, entry)); + const assigned = roles.filter((role) => same(role.entry || {}, entry)).map((role) => role.label); + if (entry.provider === 'openai' && selectedTranscriptionModel() === entry.model) assigned.push('Recorded transcription preference'); + const row = document.createElement('div'); + row.className = 'settings-model-pool-entry'; + const label = document.createElement('span'); + label.className = 'settings-model-pool-entry-label'; + label.textContent = `${entry.provider}: ${entry.model}${entry.host ? ` (${entry.host})` : ''} · ${entry.available ? 'Provider available' : 'Provider/model unavailable'} · ${allowed ? 'Allowed' : 'Not allowed'} · ${assigned.join(', ') || 'No role assigned'}`; + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'btn-secondary'; + button.textContent = allowed ? 'Allowed' : 'Allow and save'; + button.disabled = busy || !ready || allowed || !entry.available; + button.addEventListener('click', () => allow(entry)); + row.append(label, button); + list?.append(row); + } + if (selector) { + selector.replaceChildren(new Option('Automatic from enabled transcription models', '')); + const eligible = models.filter((entry) => entry.audio_transcription === true); + for (const entry of eligible) { + const allowed = ready && effectiveEnabled.some((candidate) => same(candidate, entry)); + const option = new Option(`${entry.provider}: ${entry.model} · ${!entry.available ? 'unavailable' : allowed ? 'enabled' : 'enable in model pool'}`, entry.model); + option.disabled = !entry.available || !allowed; + selector.append(option); + } + const selected = selectedTranscriptionModel(); + if (selected && !eligible.some((entry) => entry.model === selected)) { + const stale = new Option(`${selected} · unavailable or not eligible; choose another model`, selected); + stale.disabled = true; + selector.append(stale); + } + selector.value = selected; + selector.disabled = !ready || busy; + selector.onchange = () => { + if (selector.selectedOptions[0]?.disabled) return; + localStorage.setItem(TRANSCRIPTION_MODEL_KEY, selector.value); + window.dispatchEvent(new CustomEvent('von-preferences-changed')); + window.parent?.dispatchEvent(new CustomEvent('von-preferences-changed')); + renderModelInventory({ inventories, enabled, effectiveEnabled, roles, allow, busy, ready }); + }; + } +} diff --git a/src/frontend/web/von_interface/static/js/settingsPage.js b/src/frontend/web/von_interface/static/js/settingsPage.js index 9aa366691..b149785e5 100644 --- a/src/frontend/web/von_interface/static/js/settingsPage.js +++ b/src/frontend/web/von_interface/static/js/settingsPage.js @@ -1,3 +1,4 @@ +import { modelSettingsRequest, renderModelInventory } from './modelInventory.js'; import { profileButton } from './components/participantProfile.js'; import { CONVERSATION_LAYOUT_KEY, CONVERSATION_LAYOUT_KEYS, CONVERSATION_TRAY_HOVER_KEY, loadConversationLayoutPreferences, normaliseConversationLayout, saveConversationLayoutPreference } from './utils/conversationLayoutPreferences.js'; import { supportsAudioRecording } from './dictation.js'; @@ -177,6 +178,7 @@ let latestCapabilityIndexStatus = null; let capabilityIndexStatusUnsubscribe = null; let latestRagRuntimeConfiguration = null; let currentEnabledLlmAlternatives = []; +let persistedTargetEnabledLlms = []; let currentServerDefaultLlm = null; let stagedScopedPrimaryLlm = null; let scopedModelStateReady = false; @@ -2037,6 +2039,7 @@ function applyScopedModelSettings(settings, generation = scopedModelLoadGenerati currentEffectiveEnabledLlms = normaliseEnabledLlmEntries( settings?.effective_enabled_llms || settings?.enabled_llms, ); + persistedTargetEnabledLlms = normaliseEnabledLlmEntries(settings?.enabled_llms); currentEnabledLlmAlternatives = normaliseEnabledLlmEntries(settings?.enabled_llms) .filter((entry) => !resolved || !sameLlmSlot(entry, resolved)); explicitlyRemovedWorkflowPoolKeys = new Set(); @@ -2401,6 +2404,10 @@ function renderModelPoolTargetScopeNote() { note.textContent = 'Saving changes only the current user primary and pool.'; } +function isPersistedTargetModelAllowed(entry) { + return scopedModelStateReady && persistedTargetEnabledLlms.some((candidate) => sameLlmSlot(candidate, entry)); +} + function updatePoolActionButton(buttonId, entry) { const button = document.getElementById(buttonId); if (!button) return; @@ -2424,10 +2431,10 @@ function updatePoolActionButton(buttonId, entry) { const existing = currentEnabledLlmAlternatives.find((candidate) => sameLlmSlot(candidate, entry)); if (existing && sameLlmEntry(existing, entry)) { - button.disabled = true; - button.textContent = isPersistedEffectiveModelAllowed(entry) + button.disabled = scopedModelSaveInFlight || isPersistedTargetModelAllowed(entry); + button.textContent = isPersistedTargetModelAllowed(entry) ? 'Already allowed for this scope' - : 'Pending allowed-model save'; + : 'Save allowed model now'; return; } @@ -2522,7 +2529,82 @@ function renderWorkflowModelPool() { updateSelectedPremiumEligibilityControls(); } +let additionalModelInventories = []; +let modelInventoryGeneration = 0; + +function renderAdditionalModelInventory() { + renderModelInventory({ + inventories: additionalModelInventories, + enabled: persistedTargetEnabledLlms, + effectiveEnabled: currentEffectiveEnabledLlms, + roles: [ + { entry: currentEffectiveLlm, label: 'Effective primary' }, + { entry: currentResolvedLlm, label: 'Save target primary' }, + { entry: browserChatPrimaryEntry(), label: 'Browser chat' }, + { entry: currentServerDefaultLlm, label: 'Server default' }, + { entry: latestRagRuntimeConfiguration?.llm_resolution?.effective, label: 'RAG language model' }, + { entry: latestRagRuntimeConfiguration?.embedder_resolution?.effective, label: 'RAG embedder' }, + ], + ready: scopedModelStateReady, + busy: scopedModelSaveInFlight, + allow: allowAndSaveWorkflowPoolEntry, + }); +} + +async function refreshAdditionalModelInventory() { + if (!document.getElementById('additionalModelInventory')) return; + const generation = ++modelInventoryGeneration; + additionalModelInventories = []; + renderAdditionalModelInventory(); + await Promise.all(['openai', 'openrouter', 'gemini', 'meta', 'ollama'].map(async (provider) => { + let inventory; + try { + inventory = await modelSettingsRequest(`/api/settings/models/inventory/${provider}`, { + cache: 'no-store', headers: await buildSettingsFetchHeaders(), + }); + } catch (_) { + inventory = { provider, models: [], error: `${provider}: model discovery unavailable. Check provider configuration and refresh the inventory.` }; + } + if (generation !== modelInventoryGeneration) return; + additionalModelInventories.push(inventory); + renderAdditionalModelInventory(); + })); +} + +async function allowAndSaveWorkflowPoolEntry(entry) { + if (scopedModelSaveInFlight || !entry) return false; + const generation = scopedModelLoadGeneration; + scopedModelSaveInFlight = true; + renderModelScopeOverview(); + setInlineStatusMessage(document.getElementById('modelPoolStatusMessage'), `Checking ${entry.provider}: ${entry.model} availability…`, null); + try { + const inventory = await modelSettingsRequest(`/api/settings/models/inventory/${entry.provider}`, { + cache: 'no-store', headers: await buildSettingsFetchHeaders(), + }); + if (!inventory.available || !inventory.models?.some((candidate) => candidate.available && sameLlmSlot(candidate, entry))) { + throw new Error(inventory.error || `${entry.provider}: ${entry.model} is unavailable. Check the provider key or host and refresh its model list.`); + } + if (generation !== scopedModelLoadGeneration) return false; + } catch (error) { + if (generation === scopedModelLoadGeneration) { + setInlineStatusMessage(document.getElementById('modelPoolStatusMessage'), `${entry.provider}: ${error.message}`, 'error'); + } + return false; + } finally { + scopedModelSaveInFlight = false; + renderModelScopeOverview(); + } + addOrUpdateWorkflowPoolEntry(entry); + const saved = await persistModelScopeSettings(); + if (!saved) { + const status = document.getElementById('modelPoolStatusMessage'); + setInlineStatusMessage(status, `${entry.provider}: ${entry.model}: ${status?.textContent || 'Save failed; retry.'}`, 'error'); + } + return saved; +} + function renderModelScopeOverview() { + renderAdditionalModelInventory(); const browserPrimary = browserChatPrimaryEntry(); setModelScopeSummaryText( 'browserChatModelSummary', @@ -2630,6 +2712,7 @@ function restoreScopedPrimary() { } async function persistModelScopeSettings() { + if (scopedModelSaveInFlight) return false; if (!scopedModelStateReady) { setInlineStatusMessage( document.getElementById('modelPoolStatusMessage'), @@ -2641,6 +2724,7 @@ async function persistModelScopeSettings() { } scopedModelSaveInFlight = true; updateScopedModelSaveAvailability(); + renderModelScopeOverview(); setInlineStatusMessage( document.getElementById('modelPoolStatusMessage'), 'Saving scoped primary and allowed models...', @@ -2655,15 +2739,18 @@ async function persistModelScopeSettings() { 'success', ); void refreshSelectedOpenAiCostSummary(); + return true; } catch (error) { setInlineStatusMessage( document.getElementById('modelPoolStatusMessage'), error?.message || 'Failed to save scoped primary and allowed models.', 'error', ); + return false; } finally { scopedModelSaveInFlight = false; updateScopedModelSaveAvailability(); + renderModelScopeOverview(); } } @@ -3970,7 +4057,7 @@ function setupSpeechSettingsSection() { const sttSupported = isSpeechRecognitionSupported(); if (supportNote) { - supportNote.textContent = `${supportsAudioRecording() ? 'Recorded dictation is supported; enable an OpenAI transcription model (gpt-transcribe, gpt-4o-transcribe or gpt-4o-mini-transcribe) in your model pool.' : 'Recorded dictation needs HTTPS and microphone recording support.'} Browser recognition: ${sttSupported ? 'available with limited context support' : 'unavailable'}. Spoken playback: ${ttsSupported ? 'available' : 'unavailable'}.`; + supportNote.textContent = `${supportsAudioRecording() ? 'Recorded dictation uses enabled models with file-transcription metadata and a supported provider transport. Allow a model in the inventory, then select it here. Live dictation uses a separate realtime transport.' : 'Recorded dictation needs HTTPS and microphone recording support.'} Browser recognition: ${sttSupported ? 'available with limited context support' : 'unavailable'}. Spoken playback: ${ttsSupported ? 'available' : 'unavailable'}.`; } if (!ttsSupported) { @@ -4389,6 +4476,7 @@ export function __testOnly_setModelScopeState({ ); currentEffectiveEnabledLlms = normaliseEnabledLlmEntries(effectiveEnabledLlms); currentServerDefaultLlm = buildCanonicalLlmEntry(serverDefaultLlm); + persistedTargetEnabledLlms = normaliseEnabledLlmEntries(enabledLlms); currentEnabledLlmAlternatives = normaliseEnabledLlmEntries(enabledLlms) .filter((entry) => !currentResolvedLlm || !sameLlmSlot(entry, currentResolvedLlm)); explicitlyRemovedWorkflowPoolKeys = new Set(); @@ -4410,6 +4498,14 @@ export function __testOnly_addOrUpdateWorkflowPoolEntry(entry) { return addOrUpdateWorkflowPoolEntry(entry); } +export function __testOnly_allowAndSaveWorkflowPoolEntry(entry) { + return allowAndSaveWorkflowPoolEntry(entry); +} + +export function __testOnly_refreshAdditionalModelInventory() { + return refreshAdditionalModelInventory(); +} + export function __testOnly_useBrowserModelAsScopedPrimary() { return useBrowserModelAsScopedPrimary(); } @@ -5028,6 +5124,8 @@ document.addEventListener('DOMContentLoaded', async () => { await loadAndDisplaySettings(); setupConversationHistorySettingsSection(); setupSpeechSettingsSection(); + void refreshAdditionalModelInventory(); + document.getElementById('refreshModelInventoryButton')?.addEventListener('click', refreshAdditionalModelInventory); setupConceptUiSettings(); // Load DB info try { await loadAndDisplayDbInfo(); } catch { } @@ -5244,19 +5342,19 @@ document.getElementById('testOpenRouterModelButton')?.addEventListener('click', document.getElementById('testMetaModelButton')?.addEventListener('click', testSelectedMetaModel); document.getElementById('testOllamaModelButton')?.addEventListener('click', testSelectedOllamaModel); document.getElementById('addOpenAiToWorkflowPoolButton')?.addEventListener('click', () => { - addOrUpdateWorkflowPoolEntry(selectedOpenAiPoolEntry()); + void allowAndSaveWorkflowPoolEntry(selectedOpenAiPoolEntry()); }); document.getElementById('addGeminiToWorkflowPoolButton')?.addEventListener('click', () => { - addOrUpdateWorkflowPoolEntry(selectedGeminiPoolEntry()); + void allowAndSaveWorkflowPoolEntry(selectedGeminiPoolEntry()); }); document.getElementById('addOpenRouterToWorkflowPoolButton')?.addEventListener('click', () => { - addOrUpdateWorkflowPoolEntry(selectedOpenRouterPoolEntry()); + void allowAndSaveWorkflowPoolEntry(selectedOpenRouterPoolEntry()); }); document.getElementById('addMetaToWorkflowPoolButton')?.addEventListener('click', () => { - addOrUpdateWorkflowPoolEntry(selectedMetaPoolEntry()); + void allowAndSaveWorkflowPoolEntry(selectedMetaPoolEntry()); }); document.getElementById('addOllamaToWorkflowPoolButton')?.addEventListener('click', () => { - addOrUpdateWorkflowPoolEntry(selectedOllamaPoolEntry()); + void allowAndSaveWorkflowPoolEntry(selectedOllamaPoolEntry()); }); document.getElementById('useBrowserModelAsScopedPrimaryButton')?.addEventListener( 'click', @@ -5267,6 +5365,9 @@ document.getElementById('restoreScopedPrimaryButton')?.addEventListener( restoreScopedPrimary, ); document.getElementById('saveModelPoolButton')?.addEventListener('click', persistModelScopeSettings); +document.getElementById('reloadModelPoolButton')?.addEventListener('click', () => { + if (!scopedModelSaveInFlight) void reloadScopedModelSettings(); +}); document.getElementById('saveRuntimeModelSettingsButton')?.addEventListener('click', persistRuntimeModelSettings); document.getElementById('modelPoolTargetScopeSelect')?.addEventListener('change', (event) => { if (!actorSessionReady) { @@ -6216,16 +6317,18 @@ async function saveAllSettings({ settings.rag_llm = ragLlmSetting; } - const response = await fetch('/api/settings/', { + const requestOptions = { method: 'POST', headers: await buildSettingsFetchHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify(settings), - }); - const payload = await response.json().catch(() => ({})); - - if (!response.ok) { - const message = payload?.message || `Failed to save settings (${response.status})`; - throw new Error(message); + }; + let payload; + if (includeChatModels) { + payload = await modelSettingsRequest('/api/settings/', requestOptions); + } else { + const response = await fetch('/api/settings/', requestOptions); + payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.message || `Failed to save settings (${response.status})`); } if (includeChatModels && modelScopeGenerationAtSave !== scopedModelLoadGeneration) { @@ -6248,10 +6351,16 @@ async function saveAllSettings({ } if (includeChatModels && payload?.resolved_llm) { + if (!Array.isArray(payload.enabled_llms) || settings.enabled_llms.some( + (entry) => !payload.enabled_llms.some((saved) => sameLlmEntry(entry, saved)), + )) throw new Error('Saved pool did not contain all requested models. Reload the pool and retry.'); currentResolvedLlm = payload.resolved_llm; + if (selectedModelPoolTargetScope === 'user' || effectiveModelScope(currentEffectiveLlm) === 'organisation') { + currentEffectiveLlm = payload.resolved_llm; + } stagedScopedPrimaryLlm = buildCanonicalLlmEntry(payload.resolved_llm); if (Array.isArray(payload?.enabled_llms)) { - const persistedTargetEnabledLlms = normaliseEnabledLlmEntries(payload.enabled_llms); + persistedTargetEnabledLlms = normaliseEnabledLlmEntries(payload.enabled_llms); currentEnabledLlmAlternatives = [...persistedTargetEnabledLlms] .filter((entry) => !currentResolvedLlm || !sameLlmSlot(entry, currentResolvedLlm)); if ( @@ -6325,6 +6434,7 @@ async function saveAllSettings({ true, ); } + if (includeChatModels) throw error; return false; } } diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 099173d0f..0d340130d 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -15524,6 +15524,13 @@ body.settings-body { gap: 8px; } +#additionalModelInventory { + display: grid; + gap: 8px; + max-height: 32rem; + overflow: auto; +} + .settings-model-pool-entry { display: flex; align-items: center; diff --git a/src/frontend/web/von_interface/templates/settings_tab.html b/src/frontend/web/von_interface/templates/settings_tab.html index 3f2012db4..ed4ae8854 100644 --- a/src/frontend/web/von_interface/templates/settings_tab.html +++ b/src/frontend/web/von_interface/templates/settings_tab.html @@ -233,6 +233,9 @@

Text-to-speech

Dictation

+ + +

Only enabled, available file-transcription models can be selected. A stale preference remains visible until you choose another model.

Recorded audio uses conversation vocabulary. The browser controls below apply only to browser recognition.

@@ -314,11 +317,17 @@

Scoped allowed models

The scoped primary is always allowed. Add or remove alternatives explicitly; configuring or testing a model does not allow it.

+
+ Additional models — all providers and assigned roles + +
+
+
diff --git a/tests/backend/test_model_audio_metadata.py b/tests/backend/test_model_audio_metadata.py new file mode 100644 index 000000000..115ee03a3 --- /dev/null +++ b/tests/backend/test_model_audio_metadata.py @@ -0,0 +1,98 @@ +from flask import Flask, jsonify +import pytest +from src.backend.services.model_audio_metadata import recorded_transcription_supported +from src.backend.services import model_registry_service +from src.backend.server.routes import settings_routes + + +def test_metadata_extends_and_disables_compatibility_without_guessing_from_names(): + registry = [ + { + "provider": "openai", + "model_id": "new-audio", + "capabilities": {"audio_transcription": True}, + }, + { + "provider": "openai", + "model_id": "gpt-transcribe", + "capabilities": {"audio_transcription": False}, + }, + { + "provider": "openrouter", + "model_id": "other-audio", + "capabilities": {"audio_transcription": True}, + }, + ] + assert recorded_transcription_supported( + "openai", "new-audio", registry_models=registry + ) + assert not recorded_transcription_supported( + "openai", "gpt-transcribe", registry_models=registry + ) + assert not recorded_transcription_supported( + "openai", "gpt-realtime-whisper", registry_models=registry + ) + assert not recorded_transcription_supported( + "openai", "general-chat", registry_models=registry + ) + assert not recorded_transcription_supported( + "openrouter", "other-audio", registry_models=registry + ) + for model in ("gpt-transcribe", "gpt-4o-transcribe", "gpt-4o-mini-transcribe"): + assert recorded_transcription_supported("openai", model, registry_models=[]) + + +@pytest.mark.parametrize( + "provider,loader,payload", + [ + ("openai", "get_openai_models", ["gpt-4o-transcribe", "chat"]), + ("openrouter", "get_openrouter_models", ["vendor/chat"]), + ( + "ollama", + "get_ollama_models_from_all_hosts", + {"models": [{"name": "local", "host_url": "http://localhost:11434"}]}, + ), + ("gemini", "get_gemini_models", ["gemini-model"]), + ("meta", "get_meta_models", ["meta-model"]), + ], +) +def test_inventory_preserves_provider_discovery_and_host_metadata( + monkeypatch, provider, loader, payload +): + monkeypatch.setattr( + model_registry_service, "get_model_registry_snapshot", lambda: {"models": []} + ) + monkeypatch.setattr(settings_routes, loader, lambda **kw: (jsonify(payload), 200)) + app = Flask(__name__) + with app.test_request_context(): + response = app.make_response(settings_routes.get_model_inventory(provider)) + data = response.get_json() + assert data["available"] + assert all(model["provider"] == provider for model in data["models"]) + if provider == "ollama": + assert data["models"][0]["host"] == "http://localhost:11434" + if provider == "openai": + models = {model["model"]: model for model in data["models"]} + assert models["gpt-4o-transcribe"]["audio_transcription"] + assert not models["gpt-transcribe"]["available"] + assert not models["chat"]["audio_transcription"] + + +def test_disabled_provider_has_actionable_failure_and_no_available_models(monkeypatch): + monkeypatch.setattr( + model_registry_service, "get_model_registry_snapshot", lambda: {"models": []} + ) + monkeypatch.setattr( + settings_routes, + "get_openai_models", + lambda **kw: (jsonify(error="private provider details"), 400), + ) + app = Flask(__name__) + with app.test_request_context(): + data = app.make_response( + settings_routes.get_model_inventory("openai") + ).get_json() + assert not data["available"] + assert "openai" in data["error"] and "check" in data["error"] + assert "private provider details" not in data["error"] + assert not any(model["available"] for model in data["models"]) diff --git a/tests/backend/test_speech_transcription.py b/tests/backend/test_speech_transcription.py index abfca3741..a010acae2 100644 --- a/tests/backend/test_speech_transcription.py +++ b/tests/backend/test_speech_transcription.py @@ -10,6 +10,11 @@ @pytest.fixture def provider(monkeypatch): + from src.backend.services import model_registry_service + + monkeypatch.setattr( + model_registry_service, "get_model_registry_snapshot", lambda: {"models": []} + ) monkeypatch.setattr( speech, "resolve_enabled_llm_settings", @@ -145,3 +150,58 @@ def test_bad_recording_never_reaches_provider(provider, data, mime): actor="#V#a", organisation=None, audio=data, mime_type=mime ) provider[0].audio.transcriptions.create.assert_not_called() + + +def test_explicit_metadata_model_selection_is_checked_against_effective_pool( + provider, monkeypatch +): + from src.backend.services import model_registry_service + + monkeypatch.setattr( + model_registry_service, + "get_model_registry_snapshot", + lambda: { + "models": [ + { + "provider": "openai", + "model_id": "future-transcription", + "capabilities": {"audio_transcription": True}, + } + ] + }, + ) + monkeypatch.setattr( + speech, + "resolve_enabled_llm_settings", + lambda **kw: [ + {"provider": "openai", "model": "future-transcription"}, + {"provider": "openai", "model": "general-chat"}, + ], + ) + assert ( + speech.transcription_model("#V#speaker", None, "future-transcription") + == "future-transcription" + ) + for model in ("general-chat", "removed-transcription", "gpt-4o-transcribe"): + with pytest.raises(speech.SpeechUnavailable, match="not an enabled"): + speech.transcription_model("#V#speaker", None, model) + client, _ = provider + speech.transcribe_audio( + actor="#V#speaker", + organisation=None, + audio=b"fixture", + mime_type="audio/webm", + model="future-transcription", + ) + assert ( + client.audio.transcriptions.create.call_args.kwargs["model"] + == "future-transcription" + ) + + +def test_disabled_provider_cannot_transcribe_even_with_enabled_model( + provider, monkeypatch +): + monkeypatch.delenv("TEST_SPEECH_KEY") + with pytest.raises(speech.SpeechUnavailable, match="OpenAI key"): + speech.transcription_capability("#V#speaker", None) diff --git a/tests/frontend/modelInventory.test.js b/tests/frontend/modelInventory.test.js new file mode 100644 index 000000000..5d65c1278 --- /dev/null +++ b/tests/frontend/modelInventory.test.js @@ -0,0 +1,69 @@ +/** @jest-environment jsdom */ +import { modelSettingsRequest, renderModelInventory, TRANSCRIPTION_MODEL_KEY } from '../../src/frontend/web/von_interface/static/js/modelInventory.js'; + +beforeEach(() => { + localStorage.clear(); + document.body.innerHTML = '
'; +}); + +const audio = { provider: 'openai', model: 'future-transcriber', available: true, audio_transcription: true }; +const chat = { provider: 'openrouter', model: 'vendor/chat', available: true }; +const local = { provider: 'ollama', model: 'local-model', host: 'http://localhost:11434', available: true }; +const inventories = [{ models: [audio, chat, local] }]; + +test('all providers, hosts, roles and enablement are visible; only transcription metadata enables selection', () => { + const allow = jest.fn(); + renderModelInventory({ inventories, enabled: [audio, local], roles: [{ entry: local, label: 'Browser chat' }], allow }); + const rows = [...document.querySelectorAll('.settings-model-pool-entry')]; + expect(rows).toHaveLength(3); + expect(rows[0].textContent).toContain('No role assigned'); + expect(rows[1].textContent).toContain('openrouter: vendor/chat'); + expect(rows[2].textContent).toContain('ollama: local-model (http://localhost:11434)'); + expect(rows[2].textContent).toContain('Browser chat'); + rows[1].querySelector('button').click(); + expect(allow).toHaveBeenCalledWith(chat); + const select = document.getElementById('settingsTranscriptionModelSelect'); + expect([...select.options].map(o => o.value)).toEqual(['', audio.model]); + select.value = audio.model; + select.dispatchEvent(new Event('change')); + expect(localStorage.getItem(TRANSCRIPTION_MODEL_KEY)).toBe(audio.model); + expect(document.getElementById('additionalModelInventory').textContent).toContain('Recorded transcription preference'); +}); + +test('stale, disabled and unallowed selections stay visible but cannot be selected', () => { + localStorage.setItem(TRANSCRIPTION_MODEL_KEY, 'stale-chat'); + renderModelInventory({ inventories, enabled: [], roles: [], allow: jest.fn() }); + const select = document.getElementById('settingsTranscriptionModelSelect'); + expect(select.value).toBe('stale-chat'); + expect(select.selectedOptions[0].disabled).toBe(true); + expect(select.options[1].disabled).toBe(true); + renderModelInventory({ inventories: [{ models: [{ ...audio, available: false }] }], enabled: [audio], roles: [], allow: jest.fn() }); + expect(select.options[1].disabled).toBe(true); + expect(document.querySelector('button').disabled).toBe(true); +}); + +test('organisation save target does not grant transcription to a user override', () => { + renderModelInventory({ inventories, enabled: [audio], effectiveEnabled: [local], roles: [], allow: jest.fn() }); + expect(document.querySelector('button').disabled).toBe(true); + expect(document.getElementById('settingsTranscriptionModelSelect').options[1].disabled).toBe(true); +}); + +test('unresponsive requests terminate visibly, ignore late results, and permit retry', async () => { + jest.useFakeTimers(); + let finish; + global.fetch = jest.fn(() => new Promise(resolve => { finish = resolve; })); + const request = modelSettingsRequest('/api/settings/', {}, 60000); + const check = expect(request).rejects.toThrow('outcome is unknown'); + await jest.advanceTimersByTimeAsync(60000); + await check; + expect(global.fetch.mock.calls[0][1].signal.aborted).toBe(true); + finish({ ok: true, json: async () => ({ stale: true }) }); + global.fetch.mockResolvedValue({ ok: true, json: async () => ({ saved: true }) }); + expect(await modelSettingsRequest('/api/settings/')).toEqual({ saved: true }); + jest.useRealTimers(); +}); + +test('provider-specific errors survive the transport', async () => { + global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 400, json: async () => ({ message: 'OpenRouter: check model access.' }) }); + await expect(modelSettingsRequest('/api/settings/')).rejects.toThrow('OpenRouter: check model access.'); +}); diff --git a/tests/frontend/settingsModelPoolControls.test.js b/tests/frontend/settingsModelPoolControls.test.js index 9e55b70b2..b4023ef72 100644 --- a/tests/frontend/settingsModelPoolControls.test.js +++ b/tests/frontend/settingsModelPoolControls.test.js @@ -5,6 +5,7 @@ const path = require('path'); import { __testOnly_addOrUpdateWorkflowPoolEntry, + __testOnly_allowAndSaveWorkflowPoolEntry, __testOnly_applyCapabilityIndexStatusCard, __testOnly_applySharedRuntimeModelWriteAccess, __testOnly_buildPersistedLlmSelections, @@ -146,7 +147,55 @@ describe('settings model scope and workflow pool controls', () => { model_parameters: { reasoning_effort: 'low' }, })).toBe(true); expect(document.getElementById('addOpenAiToWorkflowPoolButton').textContent) - .toBe('Pending allowed-model save'); + .toBe('Save allowed model now'); + expect(document.getElementById('addOpenAiToWorkflowPoolButton').disabled).toBe(false); + }); + + test('Allow checks the provider, persists once, retains existing entries and enables use after read-back', async () => { + sessionStorage.setItem('von_current_user', JSON.stringify({ concept_id: '#V#user-a' })); + const primary = { provider: 'ollama', model: 'gemma4:latest', scope: 'user' }; + const old = { provider: 'openrouter', model: 'existing' }; + const added = { provider: 'openai', model: 'gpt-5.6-luna', model_parameters: { reasoning_effort: 'low' } }; + __testOnly_setModelScopeState({ resolvedLlm: primary, enabledLlms: [primary, old] }); + let stored; + global.fetch.mockImplementation(async (url, options) => { + if (url.includes('/models/inventory/')) return { ok: true, json: async () => ({ available: true, models: [{ ...added, available: true }] }) }; + if (options?.method === 'POST') { + stored = JSON.parse(options.body); + return { ok: true, json: async () => ({ resolved_llm: primary, enabled_llms: stored.enabled_llms }) }; + } + return { ok: true, json: async () => ({ resolved_llm: primary, enabled_llms: stored?.enabled_llms || [] }) }; + }); + expect(await __testOnly_allowAndSaveWorkflowPoolEntry(added)).toBe(true); + expect(stored.enabled_llms).toEqual(expect.arrayContaining([old, added])); + expect(stored.active_llm.model).toBe(primary.model); + expect(document.getElementById('testOpenAiModelButton').disabled).toBe(false); + expect(document.getElementById('modelPoolStatusMessage').textContent).toContain('saved'); + expect((await __testOnly_reloadScopedModelSettings()).applied).toBe(true); + expect(document.getElementById('testOpenAiModelButton').disabled).toBe(false); + expect(stored.enabled_llms.filter(entry => entry.model === added.model)).toHaveLength(1); + }); + + test('unavailable providers do not mutate the staged pool and expose an actionable failure', async () => { + __testOnly_setModelScopeState({ resolvedLlm: { provider: 'ollama', model: 'primary', scope: 'user' } }); + global.fetch.mockResolvedValue({ ok: true, json: async () => ({ available: false, error: 'OpenRouter: configure provider key.', models: [] }) }); + expect(await __testOnly_allowAndSaveWorkflowPoolEntry({ provider: 'openrouter', model: 'missing' })).toBe(false); + expect(__testOnly_getScopedModelState().enabledAlternatives).toEqual([]); + expect(document.getElementById('modelPoolStatusMessage').textContent).toContain('configure provider key'); + expect(global.fetch.mock.calls.some(([, options]) => options?.method === 'POST')).toBe(false); + }); + + test('failed persistence retains the edit and exposes the real error with a retry button', async () => { + sessionStorage.setItem('von_current_user', JSON.stringify({ concept_id: '#V#user-a' })); + __testOnly_setModelScopeState({ resolvedLlm: { provider: 'ollama', model: 'primary', scope: 'user' } }); + const added = { provider: 'openai', model: 'gpt-5.6-luna', model_parameters: { reasoning_effort: 'low' } }; + global.fetch.mockImplementation(async (url) => url.includes('/models/inventory/') + ? { ok: true, json: async () => ({ available: true, models: [{ ...added, available: true }] }) } + : { ok: false, status: 500, json: async () => ({ message: 'Canonical persistence unavailable; retry.' }) }); + expect(await __testOnly_allowAndSaveWorkflowPoolEntry(added)).toBe(false); + expect(document.getElementById('modelPoolStatusMessage').textContent).toContain('Canonical persistence unavailable'); + expect(document.getElementById('addOpenAiToWorkflowPoolButton').disabled).toBe(false); + expect(document.getElementById('testOpenAiModelButton').disabled).toBe(true); }); test('fetches and renders bounded actor-scoped cost evidence without inventing zero', async () => { @@ -446,6 +495,8 @@ describe('settings model scope and workflow pool controls', () => { .rejects.toThrow('finish loading'); expect(global.fetch).not.toHaveBeenCalled(); + // Allow the asynchronous window-session header preparation to complete. + await new Promise(resolve => setTimeout(resolve, 0)); pending.get('/api/settings/?user_concept_id=%23V%23user-b&organisation_concept_id=%23V%23org&model_scope=user')({ ok: true, json: async () => ({ From 35b05527508077e005d20a8c6e6b66ba9475d713 Mon Sep 17 00:00:00 2001 From: witbrock Date: Sat, 12 Sep 2026 21:10:21 -0400 Subject: [PATCH 2/2] Verify inventory discovery and Settings browser recovery on current main --- src/backend/server/routes/settings_routes.py | 107 +++++++----------- .../von_interface/templates/settings_tab.html | 2 +- tests/backend/test_model_audio_metadata.py | 13 +++ tests/browser/modelEnablement.cjs | 96 ++++++++++++++++ 4 files changed, 148 insertions(+), 70 deletions(-) create mode 100644 tests/browser/modelEnablement.cjs diff --git a/src/backend/server/routes/settings_routes.py b/src/backend/server/routes/settings_routes.py index 916f19bbe..0c9f10646 100644 --- a/src/backend/server/routes/settings_routes.py +++ b/src/backend/server/routes/settings_routes.py @@ -962,24 +962,18 @@ def get_llm_info(): def execution_cost_display(): """Read viewer-scoped thresholds or replace the authenticated user's pair.""" from ...services.execution_cost_display_service import ( - PREDICATE, - resolve_preferences, - validate_preferences, + PREDICATE, resolve_preferences, validate_preferences, ) actor = get_effective_user_concept_id() if not actor: return _jsonify_no_store({"error": "authenticated_actor_context_required"}, 401) effective = get_effective_context( - request.headers.get("X-Von-Window-Session"), - dict(session), - actor, + request.headers.get("X-Von-Window-Session"), dict(session), actor, ) if request.method == "PUT": import json - from ...services.ontology_mutation_command_service import ( - execute_governed_ontology_method, - ) + from ...services.ontology_mutation_command_service import execute_governed_ontology_method from ...services.text_value_service import upsert_singleton_text_relation try: @@ -987,32 +981,19 @@ def execution_cost_display(): except ValueError as exc: return _jsonify_no_store({"error": str(exc)}, 400) text = json.dumps(value, sort_keys=True) - provenance = { - "source": "execution_cost_display_preferences", - "actor_concept_id": actor, - } + provenance = {"source": "execution_cost_display_preferences", "actor_concept_id": actor} result = execute_governed_ontology_method( method_name="upsert_singleton_text_relation", - arguments={ - "concept_id": actor, - "predicate": PREDICATE, - "text": text, - "language": "en-NZ", - "provenance": provenance, - }, + arguments={"concept_id": actor, "predicate": PREDICATE, "text": text, + "language": "en-NZ", "provenance": provenance}, mutate=lambda: upsert_singleton_text_relation( - subject_concept_id=actor, - predicate=PREDICATE, - text=text, - lang="en-NZ", - provenance=provenance, + subject_concept_id=actor, predicate=PREDICATE, text=text, + lang="en-NZ", provenance=provenance, ), ) if result.get("success") is False: return _jsonify_no_store(result, 503) - return _jsonify_no_store( - resolve_preferences(actor, effective.get("organisation_id")) - ) + return _jsonify_no_store(resolve_preferences(actor, effective.get("organisation_id"))) @settings_bp.route("/llm/cost_summary", methods=["GET"]) @@ -1033,7 +1014,9 @@ def get_llm_cost_summary(): {"error": "An authenticated actor and namespace are required."}, 401 ) if not provider or not model: - return _jsonify_no_store({"error": "provider and model are required."}, 400) + return _jsonify_no_store( + {"error": "provider and model are required."}, 400 + ) try: limit = int(request.args.get("limit") or 200) window_days = int(request.args.get("window_days") or 30) @@ -2066,7 +2049,11 @@ def _settings_key_presence(env_var_name: str) -> tuple[bool, str | None]: if read_secret_file(os.getenv(f"{name}_FILE")): return True, "secret_file" - dotenv_keys = tuple(key for name in candidates for key in (name, f"{name}_FILE")) + dotenv_keys = tuple( + key + for name in candidates + for key in (name, f"{name}_FILE") + ) dotenv_values = read_repo_dotenv_values(dotenv_keys) for name in candidates: if dotenv_values.get(name): @@ -2122,11 +2109,7 @@ def get_openai_models(*, include_all=False): # Create a direct OpenAIClient instance to get models client = OpenAIClient(api_key_env_var=api_key_env_var) - models = ( - client.list_models(include_all=True) - if include_all - else client.list_models() - ) + models = client.list_models(include_all=True) if include_all else client.list_models() if models: current_app.logger.info(f"Retrieved {len(models)} OpenAI models") @@ -2432,14 +2415,10 @@ def first_val(v): preferred_language = language_rows[0].get("text") # Legacy member_of_organisation describes membership, not preference. # Never choose the first membership as a landing context. - from ...services.login_organisation_preference_service import ( - initialise_login_context, - ) - + from ...services.login_organisation_preference_service import initialise_login_context organisation_concept_id = ( initialise_login_context(session)["organisation_concept_id"] - if session.get("user_email") and session.get("user_concept_id") - else None + if session.get("user_email") and session.get("user_concept_id") else None ) return ( jsonify( @@ -2755,9 +2734,9 @@ def verify_gemini_api_key(): ) api_key = _resolve_settings_api_key( api_key_env_var, - fallback_env_vars=( - ("GOOGLE_API_KEY",) if api_key_env_var == "GEMINI_API_KEY" else () - ), + fallback_env_vars=("GOOGLE_API_KEY",) + if api_key_env_var == "GEMINI_API_KEY" + else (), ) if not api_key: return _jsonify_no_store( @@ -2861,10 +2840,7 @@ def verify_openrouter_api_key(): """Verify the fixed OpenRouter key source by listing its current catalogue.""" payload = request.get_json(silent=True) or {} - if ( - str(payload.get("api_key_env_var") or "OPENROUTER_API_KEY").strip() - != "OPENROUTER_API_KEY" - ): + if str(payload.get("api_key_env_var") or "OPENROUTER_API_KEY").strip() != "OPENROUTER_API_KEY": return _jsonify_no_store( { "success": False, @@ -4369,9 +4345,9 @@ def test_gemini_model(): api_key = _resolve_settings_api_key( api_key_env_var, - fallback_env_vars=( - ("GOOGLE_API_KEY",) if api_key_env_var == "GEMINI_API_KEY" else () - ), + fallback_env_vars=("GOOGLE_API_KEY",) + if api_key_env_var == "GEMINI_API_KEY" + else (), ) if not api_key: return _jsonify_no_store( @@ -4504,8 +4480,13 @@ def test_meta_model(): 400, ) - actor_user_concept_id, actor_source = get_effective_user_concept_id_with_source() - if not actor_user_concept_id or actor_source == LEGACY_IDENTITY_HEADER_ACTOR_SOURCE: + actor_user_concept_id, actor_source = ( + get_effective_user_concept_id_with_source() + ) + if ( + not actor_user_concept_id + or actor_source == LEGACY_IDENTITY_HEADER_ACTOR_SOURCE + ): return _jsonify_no_store( { "success": False, @@ -4736,25 +4717,13 @@ def test_openrouter_model(): failure_kind = "unexpected_error" reason = "Unexpected OpenRouter model probe failure." if status_code == 401 or "auth" in lowered or "api key" in lowered: - failure_kind, reason = ( - "authentication_error", - "OpenRouter authentication failed.", - ) + failure_kind, reason = "authentication_error", "OpenRouter authentication failed." elif status_code == 403 or "permission" in lowered: - failure_kind, reason = ( - "permission_denied", - "OpenRouter access was denied for this model.", - ) + failure_kind, reason = "permission_denied", "OpenRouter access was denied for this model." elif status_code == 404 or "not found" in lowered: - failure_kind, reason = ( - "model_not_found", - "The selected OpenRouter model was not found.", - ) + failure_kind, reason = "model_not_found", "The selected OpenRouter model was not found." elif status_code == 429 or "rate limit" in lowered or "quota" in lowered: - failure_kind, reason = ( - "rate_limited", - "OpenRouter rate or quota limit was reached.", - ) + failure_kind, reason = "rate_limited", "OpenRouter rate or quota limit was reached." elif "connect" in lowered or "network" in lowered: failure_kind, reason = "connection_error", "Could not reach OpenRouter." current_app.logger.warning( diff --git a/src/frontend/web/von_interface/templates/settings_tab.html b/src/frontend/web/von_interface/templates/settings_tab.html index 28f75d76c..ae700ffa2 100644 --- a/src/frontend/web/von_interface/templates/settings_tab.html +++ b/src/frontend/web/von_interface/templates/settings_tab.html @@ -234,7 +234,7 @@

Text-to-speech

Dictation

- +

Only enabled, available file-transcription models can be selected. A stale preference remains visible until you choose another model.

Recorded audio uses conversation vocabulary. The browser controls below apply only to browser recognition.

diff --git a/tests/backend/test_model_audio_metadata.py b/tests/backend/test_model_audio_metadata.py index 115ee03a3..740120500 100644 --- a/tests/backend/test_model_audio_metadata.py +++ b/tests/backend/test_model_audio_metadata.py @@ -96,3 +96,16 @@ def test_disabled_provider_has_actionable_failure_and_no_available_models(monkey assert "openai" in data["error"] and "check" in data["error"] assert "private provider details" not in data["error"] assert not any(model["available"] for model in data["models"]) + + +def test_openai_inventory_includes_non_gpt_ids_without_changing_chat_catalogue(monkeypatch): + from types import SimpleNamespace + from src.backend.languagemodels import llm_interface + monkeypatch.setattr(llm_interface, '_MODEL_CACHE', {}) + monkeypatch.setattr(llm_interface, '_MODEL_FAIL_BACKOFF', {}) + client = object.__new__(llm_interface.OpenAIClient) + client.client = SimpleNamespace(models=SimpleNamespace(list=lambda: SimpleNamespace(data=[ + SimpleNamespace(id='gpt-chat'), SimpleNamespace(id='whisper-1'), SimpleNamespace(id='embedding-model') + ]))) + assert client.list_models(include_all=True) == ['embedding-model', 'gpt-chat', 'whisper-1'] + assert client.list_models() == ['gpt-chat'] diff --git a/tests/browser/modelEnablement.cjs b/tests/browser/modelEnablement.cjs new file mode 100644 index 000000000..a2dfec234 --- /dev/null +++ b/tests/browser/modelEnablement.cjs @@ -0,0 +1,96 @@ +const fs = require('fs'); +const http = require('http'); +const path = require('path'); +// Actual Settings markup and modules against isolated synthetic HTTP state. +// Run: PLAYWRIGHT_BROWSERS_PATH=/tmp/von-playwright node tests/browser/modelEnablement.cjs +const root = process.cwd(); +fs.mkdirSync('/tmp/von-model-browser', { recursive: true }); +const { chromium } = require(path.join(root, 'node_modules/playwright')); +let stored = { resolved_llm: { provider: 'ollama', model: 'local-model', host: 'http://localhost:11434', scope: 'user' }, enabled_llms: [{ provider: 'ollama', model: 'local-model', host: 'http://localhost:11434' }] }; +const audio = { provider: 'openai', model: 'gpt-4o-transcribe', available: true, audio_transcription: true }; +let saveCount = 0; +let failSave = false; +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + if (url.pathname === '/') { + let html = fs.readFileSync(path.join(root, 'src/frontend/web/von_interface/templates/settings_tab.html'), 'utf8'); + html = html.replace(/\{\{ url_for\('static', filename='([^']+)'\) \}\}/g, '/static/$1'); + html = html.replace(//g, ''); + res.setHeader('Content-Type', 'text/html'); return res.end(html); + } + if (url.pathname.startsWith('/static/')) { + const file = path.join(root, 'src/frontend/web/von_interface', url.pathname); + res.setHeader('Content-Type', file.endsWith('.js') ? 'text/javascript' : file.endsWith('.css') ? 'text/css' : 'text/plain'); + if (fs.existsSync(file)) return res.end(fs.readFileSync(file)); + res.statusCode = 404; return res.end(); + } + res.setHeader('Content-Type', 'application/json'); + if (url.pathname.includes('/models/inventory/')) { + const provider = url.pathname.split('/').pop(); + const models = provider === 'openai' ? [audio, { provider, model: 'general-chat', available: true }] : provider === 'ollama' ? [{ ...stored.resolved_llm, available: true }] : provider === 'openrouter' ? [{ provider, model: 'vendor/chat', available: true }] : []; + return res.end(JSON.stringify({ provider, available: true, models })); + } + if (url.pathname === '/api/settings/') { + if (req.method === 'POST') { + saveCount++; + let body = ''; for await (const chunk of req) body += chunk; + if (failSave) { res.statusCode = 500; return res.end(JSON.stringify({ message: 'Fixture persistence temporarily unavailable; retry.' })); } + const data = JSON.parse(body); + stored = { resolved_llm: data.active_llm, enabled_llms: data.enabled_llms }; + } + return res.end(JSON.stringify(stored)); + } + return res.end('{}'); +}); +(async () => { + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const url = `http://127.0.0.1:${server.address().port}`; + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1280, height: 1000 } }); + const errors = []; page.on('pageerror', error => errors.push(error.message)); + await page.goto(url); + await page.evaluate(async (stored) => { + sessionStorage.setItem('von_current_user', JSON.stringify({ concept_id: '#V#fixture-user' })); + const mod = await import('/static/js/settingsPage.js'); + window.settingsFixture = mod; + mod.__testOnly_setModelScopeState({ resolvedLlm: stored.resolved_llm, enabledLlms: stored.enabled_llms, actorReady: true }); + mod.__testOnly_initialiseSettingsConcernNavigation(); + mod.__testOnly_renderModelScopeOverview(); + await mod.__testOnly_refreshAdditionalModelInventory(); + }, stored); + await page.locator('[data-settings-concern-tab="models"]').click(); + await page.locator('summary').filter({ hasText: 'Additional models' }).click(); + const row = page.locator('#additionalModelInventory .settings-model-pool-entry').filter({ hasText: 'openai: gpt-4o-transcribe' }); + failSave = true; + await row.locator('button').click(); + await page.waitForFunction(() => document.getElementById('modelPoolStatusMessage').textContent.includes('temporarily unavailable')); + failSave = false; + await row.locator('button').click(); + await page.waitForFunction(() => document.getElementById('modelPoolStatusMessage').textContent.includes('saved for the current scope')); + if (saveCount !== 2 || stored.enabled_llms.length !== 2 || stored.resolved_llm.model !== 'local-model') throw Error('Save/recovery or preservation failed'); + await page.locator('#reloadModelPoolButton').click(); + await page.waitForFunction(() => document.getElementById('modelPoolStatusMessage').textContent.includes('Model settings loaded')); + const select = page.locator('#settingsTranscriptionModelSelect'); + await page.locator('[data-settings-concern-tab="conversations"]').click(); + await select.selectOption('gpt-4o-transcribe'); + await select.scrollIntoViewIfNeeded(); + await page.screenshot({ path: '/tmp/von-model-browser/transcription-desktop.png' }); + const result = await page.evaluate(() => ({ + selected: localStorage.getItem('chatRecordedTranscriptionModel'), + options: [...document.querySelector('#settingsTranscriptionModelSelect').options].map(o => ({ value: o.value, disabled: o.disabled })), + inventory: document.getElementById('additionalModelInventory').textContent, + overflow: document.documentElement.scrollWidth > innerWidth, + })); + if (result.options.some(o => o.value === 'general-chat') || result.selected !== 'gpt-4o-transcribe') throw Error('Filtering/selection failed'); + await page.locator('[data-settings-concern-tab="models"]').click(); + await page.screenshot({ path: '/tmp/von-model-browser/desktop.png', fullPage: true }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.screenshot({ path: '/tmp/von-model-browser/mobile.png', fullPage: true }); + await page.locator('[data-settings-concern-tab="conversations"]').click(); + await select.scrollIntoViewIfNeeded(); + await page.screenshot({ path: '/tmp/von-model-browser/transcription-mobile.png' }); + result.mobileOverflow = await page.evaluate(() => document.documentElement.scrollWidth > innerWidth); + fs.writeFileSync('/tmp/von-model-browser/evidence.json', JSON.stringify({ url, saveCount, result, errors, fixture: 'Actual Settings template/modules; synthetic discovery and canonical settings HTTP responses; no provider generation or live data.' }, null, 2)); + console.log(JSON.stringify({ saveCount, selected: result.selected, options: result.options, overflow: result.overflow, mobileOverflow: result.mobileOverflow, errors })); + await browser.close(); server.close(); +})().catch(error => { console.error(error); server.close(); process.exit(1); });