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 src/backend/server/routes/settings_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2255,6 +2255,51 @@ def get_meta_models():
)


@settings_bp.route("/models/enabled/<provider>", methods=["GET"])
def get_enabled_models(provider):
"""Catalogue intersection for execution, scoped to the trusted request actor."""
from ...languagemodels.llm_interface import _normalise_model_execution_key

loaders = {
"openai": get_openai_models,
"openrouter": get_openrouter_models,
"gemini": get_gemini_models,
"meta": get_meta_models,
"ollama": get_ollama_models,
}
if provider not in loaders:
return _jsonify_no_store({"error": "Unsupported model provider."}, 400)
allowed = None
if provider != "ollama":
user = get_effective_user_concept_id()
org = get_effective_organisation_concept_id()
if not user and not org:
return _jsonify_no_store([], 200)
try:
entries = resolve_enabled_llm_settings(
user_concept_id=user, org_concept_id=org
)
allowed = {
_normalise_model_execution_key(entry.get("provider"), entry.get("model"))
for entry in entries if isinstance(entry, Mapping)
}
except Exception:
return _jsonify_no_store({"error": "Scoped model eligibility unavailable."}, 503)
if not any(key[0] == provider for key in allowed):
return _jsonify_no_store([], 200)
response = current_app.make_response(loaders[provider]())
if response.status_code != 200:
return response
models = response.get_json()
if not isinstance(models, list):
return _jsonify_no_store({"error": "Model catalogue unavailable."}, 503)
return _jsonify_no_store([
model for model in models
if isinstance(model, str) and model.strip()
and (allowed is None or _normalise_model_execution_key(provider, model) in allowed)
], 200)


@settings_bp.route("/models/inventory/<provider>", methods=["GET"])
def get_model_inventory(provider):
"""Project discovery and transport metadata without enabling any model."""
Expand Down
28 changes: 22 additions & 6 deletions src/backend/services/model_parameter_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,11 +289,21 @@ def build_model_parameter_capabilities(
fixed_value = _clean_text(reasoning_policy.get("fixed_value"))
source = str(reasoning_policy.get("source") or "model_registry")
supported = action != "omit"
merged_allowed_values = (
allowed_values
if allowed_values
merged_allowed_values = allowed_values or (
[fixed_value]
if fixed_value
else _normalise_string_list(reasoning_capability.get("allowed_values"))
)
if supported and not merged_allowed_values:
# Explicit registry support can introduce a model outside the
# static family list without losing the provider's value domain.
merged_allowed_values = list(
{
"openai": OPENAI_REASONING_EFFORT_VALUES,
"gemini": GEMINI_REASONING_EFFORT_VALUES,
"meta": META_MUSE_REASONING_EFFORT_VALUES,
}.get(provider_name, ())
)
reasoning_capability = _parameter_capability(
parameter_id=MODEL_PARAMETER_REASONING_EFFORT,
supported=supported,
Expand All @@ -305,12 +315,18 @@ def build_model_parameter_capabilities(
*[
item
for item in reasoning_capability.get("sources", [])
if isinstance(item, str) and item
if isinstance(item, str) and item and item != "unsupported"
],
source,
],
provider_api_mapping=_extract_mapping(
reasoning_capability.get("provider_api_mapping")
# Registry-only models still use the provider's transport.
provider_api_mapping={
"openai": _OPENAI_REASONING_EFFORT_API_MAPPINGS,
"gemini": _GEMINI_REASONING_EFFORT_API_MAPPINGS,
"meta": _META_MUSE_REASONING_EFFORT_API_MAPPINGS,
}.get(
provider_name,
_extract_mapping(reasoning_capability.get("provider_api_mapping")),
),
registry_policy=reasoning_policy,
)
Expand Down
13 changes: 12 additions & 1 deletion src/frontend/web/von_interface/static/js/chatTab.js
Original file line number Diff line number Diff line change
Expand Up @@ -33546,6 +33546,7 @@ function detachChatWorkForOrganisationSwitch() {
}

function startOrganisationSwitchForChatTab(detail = {}) {
turnModelPicker?.invalidate();
resetConversationCatalogue();
stopDictation();
const requestedSwitchId = normaliseOrganisationSwitchId(detail);
Expand Down Expand Up @@ -33742,6 +33743,7 @@ try {

function handleAuthStatusChangeForChatTab(detail) {
resetConversationCatalogue();
turnModelPicker?.invalidate();
synchroniseLlmExecutionContext({ force: true, reason: 'authenticated_actor_changed' });
loadHiddenChatSessionIds();
loadPinnedChatSessionIds();
Expand Down Expand Up @@ -33828,8 +33830,16 @@ export function initializeChatTab() {
select: turnModelSelect,
status: document.getElementById('turnModelStatus'),
clearButton: document.getElementById('clearTurnModelButton'),
reasoningSelect: document.getElementById('turnReasoningSelect'),
loadCapabilities: async (provider, model) => {
const api_surface = { gemini: 'interactions', openrouter: 'chat_completions' }[provider] || 'responses';
const query = new URLSearchParams({ provider, model, api_surface });
const response = await fetch(`/api/settings/model_parameters/capabilities?${query}`, { headers: buildChatFetchHeaders() });
if (!response.ok) throw new Error('Reasoning options unavailable');
return response.json();
},
loadModels: async provider => {
const response = await fetch(`/api/settings/models/${provider}`, { headers: buildChatFetchHeaders() });
const response = await fetch(`/api/settings/models/enabled/${provider}`, { headers: buildChatFetchHeaders() });
if (!response.ok) throw new Error('Model catalogue unavailable');
return response.json();
}
Expand Down Expand Up @@ -33885,6 +33895,7 @@ export function initializeChatTab() {
// Model and actor controls share this event. Invalidate the display
// snapshot immediately; retained debug data remains available for
// diagnostics and will be rebound only when current history loads.
turnModelPicker?.invalidate();
synchroniseLlmExecutionContext({ force: true, reason: 'settings_context_changed' });
const newHiddenKey = getHiddenSessionsStorageKey();
const newPinnedKey = getPinnedSessionsStorageKey();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,71 @@
// In-memory composer choice. Only a submitted turn receives these request fields.
export function createTurnModelPicker({ select, status, clearButton, loadModels }) {
export function createTurnModelPicker({ select, status, clearButton, loadModels, reasoningSelect, loadCapabilities }) {
let choice = null;
let parameters = {};
let loaded = false;
let loading = false;
let generation = 0;
let capabilityGeneration = 0;
const resetReasoning = (label = 'Choose a model first') => {
parameters = {};
if (!reasoningSelect) return;
reasoningSelect.replaceChildren(new Option(label, ''));
reasoningSelect.disabled = true;
};
const render = () => {
status.textContent = choice
? `Next turn only: ${choice.model} (${choice.model_provider}). Default resumes after sending.`
? `Next turn only: ${choice.model} (${choice.model_provider})${parameters.reasoning_effort ? ` · reasoning: ${parameters.reasoning_effort}` : ''}. Default resumes after sending.`
: '';
status.hidden = !choice;
clearButton.hidden = !choice;
select.value = choice ? JSON.stringify(choice) : '';
};
const clear = () => { choice = null; render(); };
const clear = () => { capabilityGeneration++; choice = null; resetReasoning(); render(); };
const updateReasoning = async () => {
const requestGeneration = ++capabilityGeneration;
resetReasoning(choice ? 'Loading reasoning options…' : undefined);
if (!choice || !reasoningSelect || !loadCapabilities) return;
try {
const result = await loadCapabilities(choice.model_provider, choice.model);
if (requestGeneration !== capabilityGeneration) return;
const capability = result?.parameters?.reasoning_effort;
if (!capability?.supported) {
resetReasoning('Reasoning level unavailable for this model');
return;
}
const values = [...new Set([...(capability.allowed_values || []), ...(capability.fixed_value ? [capability.fixed_value] : [])])];
resetReasoning('Provider default');
values.forEach(value => reasoningSelect.add(new Option(value, value)));
reasoningSelect.disabled = !!capability.read_only || !!capability.fixed_value || !values.length;
if (capability.fixed_value) {
reasoningSelect.value = capability.fixed_value;
parameters = { reasoning_effort: capability.fixed_value };
}
render();
} catch (_) {
if (requestGeneration === capabilityGeneration) resetReasoning('Reasoning options unavailable; reselect model to retry');
}
};
select.addEventListener('change', () => {
choice = select.value ? JSON.parse(select.value) : null;
void updateReasoning();
render();
});
reasoningSelect?.addEventListener('change', () => {
parameters = reasoningSelect.value ? { reasoning_effort: reasoningSelect.value } : {};
render();
});
clearButton.addEventListener('click', clear);
const load = async () => {
if (loaded || loading) return;
const requestGeneration = generation;
loading = true;
select.disabled = true;
const failures = [];
const providers = ['openai', 'gemini', 'openrouter', 'meta', 'ollama'];
const results = await Promise.allSettled(providers.map(provider => loadModels(provider)));
if (requestGeneration !== generation) return;
select.querySelectorAll('optgroup').forEach(group => group.remove());
results.forEach((result, index) => {
const provider = providers[index];
if (result.status !== 'fulfilled' || !Array.isArray(result.value)) {
Expand All @@ -45,16 +87,21 @@ export function createTurnModelPicker({ select, status, clearButton, loadModels
loaded = failures.length === 0;
select.disabled = false;
select.title = failures.length ? `Unavailable: ${failures.join(', ')}. Reopen to retry.` : '';
if (choice && ![...select.options].some(option => option.value === JSON.stringify(choice))) clear();
render();
};
return {
load: async () => {
// Retry unavailable catalogues without duplicating successful options.
if (!loaded && !loading) select.querySelectorAll('optgroup').forEach(group => group.remove());
await load();
load,
invalidate: () => {
generation++;
loading = false;
loaded = false;
select.disabled = false;
select.querySelectorAll('optgroup').forEach(group => group.remove());
clear();
},
peek: () => choice ? { ...choice } : null,
take: () => { const fields = choice ? { ...choice, model_parameters: {} } : null; clear(); return fields; },
peek: () => choice ? { ...choice, model_parameters: { ...parameters } } : null,
take: () => { const fields = choice ? { ...choice, model_parameters: { ...parameters } } : null; clear(); return fields; },
clear
};
}
4 changes: 4 additions & 0 deletions src/frontend/web/von_interface/templates/chat_tab.html
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,10 @@ <h3 id="composerModelHeading">Model for next turn</h3>
<select id="turnModelSelect" aria-describedby="turnModelHelp turnModelStatus">
<option value="">Use default model</option>
</select>
<label for="turnReasoningSelect">Reasoning level</label>
<select id="turnReasoningSelect" disabled>
<option value="">Choose a model first</option>
</select>
<p id="turnModelHelp">Choose a stronger or faster model for your next message only. Sending or queueing it restores the default for future turns.</p>
</section>
<section class="composer-options-section" aria-labelledby="composerSpeechHeading">
Expand Down
42 changes: 42 additions & 0 deletions tests/backend/test_model_parameter_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,45 @@ def test_gemini_37_reasoning_effort_maps_to_supported_thinking_levels(
)
== {}
)


def test_registry_only_astra_capability_reaches_both_openai_transports(monkeypatch):
import src.backend.services.model_parameter_service as mod

policy = {'action': 'allow', 'allowed_values': ['low', 'high'], 'source': 'configured_registry'}
monkeypatch.setattr(mod, '_registry_parameter_policy', lambda **kw: policy)
capability = mod.build_model_parameter_capabilities(
provider='openai', model='gpt-6-astra', include_registry=True,
)['parameters']['reasoning_effort']
assert capability['supported'] is True
assert capability['allowed_values'] == ['low', 'high']
assert mod.openai_responses_kwargs_from_model_parameters(
{'reasoning_effort': 'high'}, model='gpt-6-astra'
) == {'reasoning': {'effort': 'high'}}
assert mod.openai_chat_completions_kwargs_from_model_parameters(
{'reasoning_effort': 'high'}, model='gpt-6-astra'
) == {'reasoning_effort': 'high'}
policy['action'] = 'omit'
assert mod.openai_responses_kwargs_from_model_parameters(
{'reasoning_effort': 'high'}, model='gpt-6-astra'
) == {}
monkeypatch.setattr(mod, '_registry_parameter_policy', lambda **kw: None)
assert not mod.build_model_parameter_capabilities(
provider='openai', model='gpt-6-astra', include_registry=True,
)['parameters']['reasoning_effort']['supported']


def test_registry_only_model_without_value_constraint_gets_provider_domain(monkeypatch):
import src.backend.services.model_parameter_service as mod

policy = {'action': 'allow', 'source': 'configured_registry'}
monkeypatch.setattr(mod, '_registry_parameter_policy', lambda **kw: policy)
capability = mod.build_model_parameter_capabilities(
provider='openai', model='gpt-6-astra', include_registry=True,
)['parameters']['reasoning_effort']
assert capability['allowed_values'] == list(mod.OPENAI_REASONING_EFFORT_VALUES)
assert capability['sources'] == ['configured_registry']
policy['fixed_value'] = 'registry-defined-effort'
assert mod.openai_responses_kwargs_from_model_parameters(
{'reasoning_effort': 'registry-defined-effort'}, model='gpt-6-astra'
) == {'reasoning': {'effort': 'registry-defined-effort'}}
59 changes: 59 additions & 0 deletions tests/backend/test_settings_enabled_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from types import SimpleNamespace
from flask import Flask, jsonify
import pytest
from src.backend.server.routes import settings_routes as routes
from src.backend.languagemodels import llm_interface


@pytest.fixture
def client(monkeypatch):
app = Flask(__name__)
app.config['TESTING'] = True
app.register_blueprint(routes.settings_bp, url_prefix='/api/settings')
monkeypatch.setattr(routes, 'get_effective_user_concept_id', lambda: '#V#actor')
monkeypatch.setattr(routes, 'get_effective_organisation_concept_id', lambda: '#V#org')
monkeypatch.setattr(routes, 'OpenAIClient', lambda **kw: SimpleNamespace(
list_models=lambda: ['gpt-6-astra', 'gpt-6-astra-preview', 'other-model']
))
return app.test_client()


def test_picker_uses_execution_normalisation_and_trusted_scope(client, monkeypatch):
def pool(**scope):
assert scope == dict(user_concept_id='#V#actor', org_concept_id='#V#org')
return [dict(provider=' OpenAI ', model='openai:gpt-6-astra'),
dict(provider='gemini', model='other-model')]
monkeypatch.setattr(routes, 'resolve_enabled_llm_settings', pool)
monkeypatch.setattr(llm_interface, 'resolve_enabled_llm_settings', pool)
response = client.get('/api/settings/models/enabled/openai?user_concept_id=other')
assert response.status_code == 200
assert response.get_json() == ['gpt-6-astra']
assert 'no-store' in response.headers['Cache-Control']
assert llm_interface.assert_model_execution_allowed(
provider='openai', model=response.get_json()[0],
user_concept_id='#V#actor', org_concept_id='#V#org'
)['allowed']
# Settings discovery still exposes models which can be enabled later.
assert client.get('/api/settings/models/openai').get_json() == [
'gpt-6-astra', 'gpt-6-astra-preview', 'other-model'
]


def test_missing_scope_does_not_discover_or_read_pool(client, monkeypatch):
monkeypatch.setattr(routes, 'get_effective_user_concept_id', lambda: None)
monkeypatch.setattr(routes, 'get_effective_organisation_concept_id', lambda: None)
monkeypatch.setattr(routes, 'resolve_enabled_llm_settings', lambda **kw: pytest.fail('no actor'))
assert client.get('/api/settings/models/enabled/openai').get_json() == []


def test_unavailable_scope_is_not_reported_as_empty_pool(client, monkeypatch):
def unavailable(**kw):
raise RuntimeError('offline')
monkeypatch.setattr(routes, 'resolve_enabled_llm_settings', unavailable)
assert client.get('/api/settings/models/enabled/openai').status_code == 503


def test_ollama_retains_local_execution_exception(client, monkeypatch):
monkeypatch.setattr(routes, 'resolve_enabled_llm_settings', lambda **kw: pytest.fail('local'))
monkeypatch.setattr(routes, 'get_ollama_models', lambda: (jsonify(['local-model']), 200))
assert client.get('/api/settings/models/enabled/ollama').get_json() == ['local-model']
Loading
Loading