Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/engineering/speech_io.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 4 additions & 4 deletions src/backend/languagemodels/llm_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")
Expand Down
64 changes: 62 additions & 2 deletions src/backend/server/routes/settings_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2101,15 +2101,15 @@ 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
api_key_env_var = get_openai_env_var()

# 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")
Expand Down Expand Up @@ -2255,6 +2255,66 @@ def get_meta_models():
)


@settings_bp.route("/models/inventory/<provider>", 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()
Expand Down
5 changes: 3 additions & 2 deletions src/backend/server/routes/speech_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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,
)
Expand Down
29 changes: 29 additions & 0 deletions src/backend/services/model_audio_metadata.py
Original file line number Diff line number Diff line change
@@ -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
38 changes: 29 additions & 9 deletions src/backend/services/speech_transcription_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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
)
Expand All @@ -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 {
Expand Down Expand Up @@ -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

Expand All @@ -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",
Expand Down
10 changes: 8 additions & 2 deletions src/frontend/web/von_interface/static/js/dictation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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 (_) {
Expand Down Expand Up @@ -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();
Expand All @@ -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';
Loading
Loading