with the models for a given endpoint id.
function fillModels(selectEl, epId, selected) {
@@ -469,64 +462,6 @@ async function initDefaultChat() {
function refreshEndpointOptions(selectedEndpoint, selectedModel) {
_fillEndpointSelect(epSel, _endpoints, selectedEndpoint !== undefined ? selectedEndpoint : epSel.value, false);
refreshModels(selectedModel !== undefined ? selectedModel : modelSel.value);
- renderFallbacks();
- }
-
- // Render the fallback chain. Each row is endpoint + model + remove.
- function renderFallbacks() {
- fbContainer.innerHTML = '';
- _fallbacks.forEach(function(fb, idx) {
- var row = document.createElement('div');
- row.className = 'settings-fallback-row';
-
- var num = document.createElement('span');
- num.className = 'settings-fallback-num';
- num.textContent = (idx + 1) + '.';
-
- var epS = document.createElement('select');
- epS.className = 'settings-select';
- enabledEndpoints().forEach(function(ep) {
- var o = document.createElement('option');
- o.value = ep.id;
- o.textContent = ep.name + (ep.online ? '' : ' (offline)');
- epS.appendChild(o);
- });
- var first = enabledEndpoints()[0];
- epS.value = fb.endpoint_id || (first ? first.id : '');
-
- var mS = document.createElement('select');
- mS.className = 'settings-select';
- fillModels(mS, epS.value, fb.model);
-
- // Keep the model in sync with the values actually shown.
- fb.endpoint_id = epS.value;
- fb.model = mS.value;
-
- epS.addEventListener('change', function() {
- fb.endpoint_id = epS.value;
- fillModels(mS, epS.value, '');
- fb.model = mS.value;
- saveDefault();
- });
- mS.addEventListener('change', function() { fb.model = mS.value; saveDefault(); });
-
- var rm = document.createElement('button');
- rm.type = 'button';
- rm.className = 'settings-fallback-remove';
- rm.title = 'Remove fallback';
- rm.innerHTML = ' ';
- rm.addEventListener('click', function() {
- _fallbacks.splice(idx, 1);
- renderFallbacks();
- saveDefault();
- });
-
- row.appendChild(num);
- row.appendChild(epS);
- row.appendChild(mS);
- row.appendChild(rm);
- fbContainer.appendChild(row);
- });
}
try {
@@ -534,7 +469,6 @@ async function initDefaultChat() {
var settings = await res.json();
if (settings.default_endpoint_id) epSel.value = settings.default_endpoint_id;
refreshModels(settings.default_model || '');
- renderFallbacks();
} catch (e) { console.warn('Failed to load default chat settings', e); }
epSel.addEventListener('change', function() { refreshModels(''); saveDefault(); });
@@ -554,13 +488,6 @@ async function initDefaultChat() {
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
}
- if (addFbBtn) addFbBtn.addEventListener('click', function() {
- var first = enabledEndpoints()[0];
- _fallbacks.push({ endpoint_id: first ? first.id : '', model: '' });
- renderFallbacks();
- saveDefault();
- });
-
_registerAiEndpointRefresh(function(endpoints) {
_endpoints = endpoints;
refreshEndpointOptions(epSel.value, modelSel.value);
diff --git a/static/js/slashCommands.js b/static/js/slashCommands.js
index e85146af9..99529d54f 100644
--- a/static/js/slashCommands.js
+++ b/static/js/slashCommands.js
@@ -2027,12 +2027,12 @@ async function _cmdUsage(args, ctx) {
const messageCount = Number(session?.message_count || 0);
const totalTokens = Number(session?.total_tokens || 0);
const costTracked = chatRenderer.isCostTrackedEndpoint ? chatRenderer.isCostTrackedEndpoint(endpointUrl) : true;
- const cost = costTracked && chatRenderer.getSessionCost ? Number(chatRenderer.getSessionCost(sid) || 0) : 0;
- const costLine = costTracked
- ? (cost > 0
- ? `Estimated local cost: $${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}`
- : 'Estimated local cost: unavailable or zero')
- : 'Estimated local cost: not tracked for this endpoint';
+ const cost = chatRenderer.getSessionCost ? Number(chatRenderer.getSessionCost(sid) || 0) : 0;
+ const costLine = cost > 0
+ ? `Estimated local cost: $${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}`
+ : costTracked
+ ? 'Estimated local cost: unavailable or zero'
+ : 'Estimated local cost: no billable usage recorded';
slashReply(`${[
`Session: ${ctx.esc(session?.name || 'Current chat')}`,
diff --git a/tests/test_agent_loop.py b/tests/test_agent_loop.py
index 107636b4a..6f149fc5e 100644
--- a/tests/test_agent_loop.py
+++ b/tests/test_agent_loop.py
@@ -314,17 +314,21 @@ def test_prep_timings_included(self):
def test_tool_events_included(self):
events = [{"tool": "bash", "duration": 1.0}]
texts = ["round 1 text"]
+ models = ["round-1-model"]
m = _compute_final_metrics(**self._base_args(
tool_events=events,
round_texts=texts,
+ round_models=models,
))
assert m["tool_events"] == events
assert m["round_texts"] == texts
+ assert m["round_models"] == models
def test_no_tool_events_excluded(self):
m = _compute_final_metrics(**self._base_args(tool_events=[], round_texts=[]))
assert "tool_events" not in m
assert "round_texts" not in m
+ assert "round_models" not in m
# ---------------------------------------------------------------------------
diff --git a/tests/test_agent_round_model_provenance_ui.py b/tests/test_agent_round_model_provenance_ui.py
new file mode 100644
index 000000000..dbdda3936
--- /dev/null
+++ b/tests/test_agent_round_model_provenance_ui.py
@@ -0,0 +1,237 @@
+"""Saved Agent rounds must render and bill with actual per-round provenance."""
+
+import json
+from pathlib import Path
+import re
+import shutil
+import subprocess
+
+import pytest
+
+
+_SOURCE = (
+ Path(__file__).resolve().parents[1] / "static" / "js" / "chatRenderer.js"
+).read_text(encoding="utf-8")
+_CHAT_SOURCE = (
+ Path(__file__).resolve().parents[1] / "static" / "js" / "chat.js"
+).read_text(encoding="utf-8")
+_SLASH_SOURCE = (
+ Path(__file__).resolve().parents[1] / "static" / "js" / "slashCommands.js"
+).read_text(encoding="utf-8")
+_HAS_NODE = shutil.which("node") is not None
+
+
+def _function_source(name):
+ match = re.search(
+ rf"^(?:export )?function {name}\(.*?^\}}",
+ _SOURCE,
+ re.MULTILINE | re.DOTALL,
+ )
+ assert match, f"{name} not found"
+ return match.group(0).replace("export function", "function", 1)
+
+
+def _run_node(source):
+ proc = subprocess.run(
+ ["node", "--input-type=module"],
+ input=source,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout.strip())
+
+
+def test_saved_agent_rounds_prefer_round_model_provenance():
+ assert "const roundModels = metadata.round_models || [];" in _SOURCE
+ assert "const contModel = roundModels[r] || pair.actualModel || pair.requestedModel;" in _SOURCE
+ assert "Array.isArray(metadata.round_texts) && metadata.round_texts.length > 1" in _SOURCE
+ assert "const roundEndpointIds = metadata.round_endpoint_ids || [];" in _SOURCE
+ assert "const roundEndpointLabels = metadata.round_endpoint_labels || [];" in _SOURCE
+ assert "r < roundEndpointIds.length" in _SOURCE
+ assert "r < roundEndpointLabels.length" in _SOURCE
+ assert "roundEndpointIds[r] || pair.actualEndpointId" not in _SOURCE
+
+
+def test_metrics_cost_uses_actual_fallback_endpoint_classification():
+ assert "metrics.endpoint_cost_tracked" in _SOURCE
+ assert "endpointCostTracked === false" in _SOURCE
+ assert "endpointCostTracked !== true && !isCostTrackedEndpoint(selectedUrl)" in _SOURCE
+ assert "Array.isArray(metrics.usage_buckets)" in _SOURCE
+
+
+@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
+def test_agent_usage_buckets_sum_only_billable_answering_routes():
+ source = "\n".join([
+ "let currentUrl = '';",
+ "function _currentEndpointUrl() { return currentUrl; }",
+ "function isCostTrackedEndpoint(url) { return url === 'paid'; }",
+ "function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
+ _function_source("_billableCost"),
+ _function_source("_metricsBillableCost"),
+ "const paidSelected = {usage_buckets: [",
+ " {model: 'selected', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true},",
+ " {model: 'local-fallback', input_tokens: 200, output_tokens: 20, endpoint_cost_tracked: false},",
+ "]};",
+ "const localSelected = {usage_buckets: [",
+ " {model: 'selected', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: false},",
+ " {model: 'paid-fallback', input_tokens: 200, output_tokens: 20, endpoint_cost_tracked: true},",
+ "]};",
+ "currentUrl = 'local';",
+ "const paidToLocal = _metricsBillableCost(paidSelected, 'final', 300, 30);",
+ "currentUrl = 'paid';",
+ "const localToPaid = _metricsBillableCost(localSelected, 'final', 300, 30);",
+ "console.log(JSON.stringify({paidToLocal, localToPaid}));",
+ ])
+
+ assert _run_node(source) == {"paidToLocal": 0.11, "localToPaid": 0.22}
+
+
+@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
+def test_force_answer_synthesis_segment_is_included_in_fallback_cost():
+ source = "\n".join([
+ "function _currentEndpointUrl() { return 'local-selected'; }",
+ "function isCostTrackedEndpoint() { return false; }",
+ "function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
+ _function_source("_billableCost"),
+ _function_source("_metricsBillableCost"),
+ "const metrics = {usage_buckets: [",
+ " {round: 6, model: 'paid-fallback', input_tokens: 100, output_tokens: 0, endpoint_cost_tracked: true},",
+ " {round: 6, model: 'paid-fallback', input_tokens: 80, output_tokens: 20, endpoint_cost_tracked: true},",
+ "]};",
+ "console.log(JSON.stringify({cost: _metricsBillableCost(metrics, 'paid-fallback', 180, 20)}));",
+ ])
+
+ assert _run_node(source) == {"cost": 0.2}
+
+
+@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
+def test_repeated_live_metrics_render_records_session_cost_once():
+ source = "\n".join([
+ "const _COST_KEY = 'ody-session-cost';",
+ "const state = {};",
+ "const localStorage = {",
+ " getItem(key) { return state[key] || null; },",
+ " setItem(key, value) { state[key] = value; },",
+ "};",
+ "const window = {sessionModule: {getCurrentSessionId() { return 'session'; }}};",
+ "function updateSessionCostUI() {}",
+ "function _currentEndpointUrl() { return 'local'; }",
+ "function isCostTrackedEndpoint(url) { return url === 'paid'; }",
+ "function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
+ _function_source("_billableCost"),
+ _function_source("_metricsBillableCost"),
+ _function_source("recordSessionMetricsCost"),
+ "const metrics = {model: 'paid-model', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true};",
+ "recordSessionMetricsCost(metrics);",
+ "recordSessionMetricsCost(metrics);",
+ "console.log(JSON.stringify({cost: JSON.parse(state[_COST_KEY]).session, recorded: metrics._costRecorded}));",
+ ])
+
+ assert _run_node(source) == {"cost": 0.11, "recorded": True}
+
+
+@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
+def test_replayed_metrics_use_run_identity_for_durable_cost_deduplication():
+ source = "\n".join([
+ "const _COST_KEY = 'ody-session-cost';",
+ "const _COST_RUNS_KEY = 'ody-session-cost-runs';",
+ "const _MAX_COST_RUNS_PER_SESSION = 256;",
+ "const state = {};",
+ "const localStorage = {",
+ " getItem(key) { return state[key] || null; },",
+ " setItem(key, value) { state[key] = value; },",
+ "};",
+ "const window = {sessionModule: {getCurrentSessionId() { return 'session'; }}};",
+ "function updateSessionCostUI() {}",
+ "function _currentEndpointUrl() { return 'local'; }",
+ "function isCostTrackedEndpoint(url) { return url === 'paid'; }",
+ "function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
+ _function_source("_billableCost"),
+ _function_source("_metricsBillableCost"),
+ _function_source("recordSessionMetricsCost"),
+ _function_source("getSessionCost"),
+ "const firstObject = {model: 'paid-model', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true, _costRecordId: 'run-1'};",
+ "const replayedObject = {...firstObject};",
+ "recordSessionMetricsCost(firstObject);",
+ "recordSessionMetricsCost(replayedObject);",
+ "console.log(JSON.stringify({cost: getSessionCost('session'), runs: JSON.parse(state[_COST_RUNS_KEY]).session}));",
+ ])
+
+ assert _run_node(source) == {"cost": 0.11, "runs": {"run-1": 0.11}}
+
+
+@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
+def test_run_cost_ledger_sums_segments_and_updates_repeated_segment_metrics():
+ source = "\n".join([
+ "const _COST_KEY = 'ody-session-cost';",
+ "const _COST_RUNS_KEY = 'ody-session-cost-runs';",
+ "const _MAX_COST_RUNS_PER_SESSION = 256;",
+ "const state = {};",
+ "const localStorage = {",
+ " getItem(key) { return state[key] || null; },",
+ " setItem(key, value) { state[key] = value; },",
+ "};",
+ "const window = {sessionModule: {getCurrentSessionId() { return 'session'; }}};",
+ "function updateSessionCostUI() {}",
+ "function _currentEndpointUrl() { return 'paid'; }",
+ "function isCostTrackedEndpoint() { return true; }",
+ "function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
+ _function_source("_billableCost"),
+ _function_source("_metricsBillableCost"),
+ _function_source("recordSessionMetricsCost"),
+ _function_source("getSessionCost"),
+ "recordSessionMetricsCost({model: 'student', input_tokens: 100, output_tokens: 10, _costRecordId: 'run:primary'});",
+ "recordSessionMetricsCost({model: 'student', input_tokens: 120, output_tokens: 20, _costRecordId: 'run:primary'});",
+ "recordSessionMetricsCost({model: 'teacher', input_tokens: 200, output_tokens: 30, _costRecordId: 'run:teacher'});",
+ "console.log(JSON.stringify({cost: getSessionCost('session'), runs: JSON.parse(state[_COST_RUNS_KEY]).session}));",
+ ])
+
+ assert _run_node(source) == {
+ "cost": 0.37,
+ "runs": {"run:primary": 0.14, "run:teacher": 0.23},
+ }
+
+
+@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
+def test_local_selected_endpoint_does_not_erase_paid_fallback_ledger():
+ source = "\n".join([
+ "const _COST_KEY = 'ody-session-cost';",
+ "const _COST_RUNS_KEY = 'ody-session-cost-runs';",
+ "const state = {'ody-session-cost': JSON.stringify({session: 0.125})};",
+ "const localStorage = {",
+ " getItem(key) { return state[key] || null; },",
+ " setItem(key, value) { state[key] = value; },",
+ "};",
+ "const badge = {style: {}, textContent: ''};",
+ "const document = {getElementById() { return badge; }};",
+ "const window = {sessionModule: {getCurrentSessionId() { return 'session'; }, getCurrentEndpointUrl() { return 'local'; }}};",
+ _function_source("getSessionCost"),
+ _function_source("updateSessionCostUI"),
+ "updateSessionCostUI();",
+ "console.log(JSON.stringify({stored: JSON.parse(state[_COST_KEY]).session, display: badge.style.display, text: badge.textContent}));",
+ ])
+
+ assert _run_node(source) == {
+ "stored": 0.125,
+ "display": "",
+ "text": "$0.125",
+ }
+
+
+def test_live_and_resumed_terminal_events_apply_usage_metrics_before_reload():
+ assert "metrics = json.data || metrics;" in _CHAT_SOURCE
+ assert "displayMetrics(terminalMetricsTarget, metrics);" in _CHAT_SOURCE
+ assert "metricsData = json.data || metricsData;" in _CHAT_SOURCE
+ assert "displayMetrics(holder, metricsData);" in _CHAT_SOURCE
+ assert "json.type === 'agent_terminal' || json.type === 'chat_terminal'" in _CHAT_SOURCE
+ assert "chatRenderer.recordSessionMetricsCost(metrics, streamSessionId);" in _CHAT_SOURCE
+ assert "chatRenderer.recordSessionMetricsCost(metricsData, sessionId);" in _CHAT_SOURCE
+ assert "metricsData._costRecordId = _metricsCostRecordId(resumeRunId, json);" in _CHAT_SOURCE
+ assert "bgTerminal.status = 'completed';" in _CHAT_SOURCE
+
+
+def test_usage_command_does_not_hide_existing_fallback_cost_for_local_selection():
+ assert "const cost = chatRenderer.getSessionCost" in _SLASH_SOURCE
+ assert "const cost = costTracked && chatRenderer.getSessionCost" not in _SLASH_SOURCE
diff --git a/tests/test_chat_model_provenance_js.py b/tests/test_chat_model_provenance_js.py
new file mode 100644
index 000000000..18d03a0cf
--- /dev/null
+++ b/tests/test_chat_model_provenance_js.py
@@ -0,0 +1,203 @@
+"""Execute the round-aware live model-provenance state helper under Node."""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+
+_REPO = Path(__file__).resolve().parents[1]
+_MODULE = (_REPO / "static" / "js" / "chatModelProvenance.js").as_uri()
+
+
+def test_round_two_fallback_then_provider_alias_does_not_relabel_round_one():
+ if not shutil.which("node"):
+ pytest.skip("node is not installed")
+
+ script = f"""
+ import {{ applyModelRouteEventState }} from {json.dumps(_MODULE)};
+ const round1 = {{ _requestedModel: 'selected-model', _actualModel: 'selected-model' }};
+ const round2 = {{ _requestedModel: 'selected-model', _actualModel: 'selected-model' }};
+
+ const fallbackTarget = applyModelRouteEventState({{
+ type: 'fallback', round: 2,
+ selected_model: 'selected-model', answered_by: 'backup-model'
+ }}, round1, round2, 'selected-model');
+ const aliasTarget = applyModelRouteEventState({{
+ type: 'model_actual', round: 2,
+ requested_model: 'selected-model', model: 'provider-backup-alias'
+ }}, round1, round2, 'selected-model');
+
+ console.log(JSON.stringify({{
+ fallbackIsRound2: fallbackTarget === round2,
+ aliasIsRound2: aliasTarget === round2,
+ round1,
+ round2,
+ }}));
+ """
+ result = subprocess.run(
+ ["node", "--input-type=module"],
+ input=script,
+ capture_output=True,
+ text=True,
+ cwd=_REPO,
+ timeout=30,
+ )
+ assert result.returncode == 0, result.stderr
+ state = json.loads(result.stdout)
+ assert state == {
+ "fallbackIsRound2": True,
+ "aliasIsRound2": True,
+ "round1": {
+ "_requestedModel": "selected-model",
+ "_actualModel": "selected-model",
+ },
+ "round2": {
+ "_requestedModel": "selected-model",
+ "_actualModel": "provider-backup-alias",
+ },
+ }
+
+
+def test_next_round_and_final_metrics_preserve_each_agent_round_route():
+ if not shutil.which("node"):
+ pytest.skip("node is not installed")
+
+ script = f"""
+ import {{
+ applyModelMetricsState,
+ applyModelRouteEventState,
+ inheritModelRouteState,
+ }} from {json.dumps(_MODULE)};
+ const round1 = {{ _requestedModel: 'selected-model', _actualModel: 'selected-model' }};
+ const round2 = {{}};
+ inheritModelRouteState(round1, round1, round2, 'selected-model');
+ applyModelRouteEventState({{
+ type: 'fallback', round: 2,
+ selected_model: 'selected-model', answered_by: 'backup-model'
+ }}, round1, round2, 'selected-model');
+ applyModelRouteEventState({{
+ type: 'model_actual', round: 2,
+ requested_model: 'selected-model', model: 'provider-backup-alias'
+ }}, round1, round2, 'selected-model');
+
+ const round3 = {{}};
+ inheritModelRouteState(round1, round2, round3, 'selected-model');
+ const metricsTarget = applyModelMetricsState({{
+ requested_model: 'selected-model',
+ model: 'provider-backup-alias',
+ round_models: ['selected-model', 'provider-backup-alias', 'backup-model'],
+ }}, round1, round3, 'selected-model');
+
+ console.log(JSON.stringify({{
+ metricsIsRound3: metricsTarget === round3,
+ round1,
+ round2,
+ round3,
+ }}));
+ """
+ result = subprocess.run(
+ ["node", "--input-type=module"],
+ input=script,
+ capture_output=True,
+ text=True,
+ cwd=_REPO,
+ timeout=30,
+ )
+ assert result.returncode == 0, result.stderr
+ assert json.loads(result.stdout) == {
+ "metricsIsRound3": True,
+ "round1": {
+ "_requestedModel": "selected-model",
+ "_actualModel": "selected-model",
+ },
+ "round2": {
+ "_requestedModel": "selected-model",
+ "_actualModel": "provider-backup-alias",
+ },
+ "round3": {
+ "_requestedModel": "selected-model",
+ "_actualModel": "backup-model",
+ },
+ }
+
+
+def test_same_model_fallback_preserves_distinct_endpoint_route_state():
+ if not shutil.which("node"):
+ pytest.skip("node is not installed")
+
+ script = f"""
+ import {{ applyModelMetricsState, applyModelRouteEventState }} from {json.dumps(_MODULE)};
+ const holder = {{ _requestedModel: 'same-model', _actualModel: 'same-model' }};
+ applyModelRouteEventState({{
+ type: 'fallback',
+ selected_model: 'same-model', answered_by: 'same-model',
+ selected_endpoint_id: 'account-one', selected_endpoint_label: 'Account one',
+ answered_by_endpoint_id: 'account-two', answered_by_endpoint_label: 'Account two',
+ }}, holder, null, 'same-model');
+ applyModelMetricsState({{
+ requested_model: 'same-model', model: 'same-model',
+ requested_endpoint_id: 'account-one', requested_endpoint_label: 'Account one',
+ endpoint_id: 'account-two', endpoint_label: 'Account two',
+ }}, holder, null, 'same-model');
+ console.log(JSON.stringify(holder));
+ """
+ result = subprocess.run(
+ ["node", "--input-type=module"],
+ input=script,
+ capture_output=True,
+ text=True,
+ cwd=_REPO,
+ timeout=30,
+ )
+ assert result.returncode == 0, result.stderr
+ assert json.loads(result.stdout) == {
+ "_requestedModel": "same-model",
+ "_actualModel": "same-model",
+ "_requestedEndpointId": "account-one",
+ "_requestedEndpointLabel": "Account one",
+ "_actualEndpointId": "account-two",
+ "_actualEndpointLabel": "Account two",
+ }
+
+
+def test_metrics_preserve_explicitly_unknown_round_endpoint():
+ if not shutil.which("node"):
+ pytest.skip("node is not installed")
+
+ script = f"""
+ import {{ applyModelMetricsState }} from {json.dumps(_MODULE)};
+ const holder = {{
+ _requestedModel: 'same-model',
+ _actualModel: 'same-model',
+ _requestedEndpointId: 'account-one',
+ _requestedEndpointLabel: 'Account one',
+ }};
+ const roundHolder = {{}};
+ applyModelMetricsState({{
+ requested_model: 'same-model', model: 'same-model',
+ requested_endpoint_id: 'account-one', requested_endpoint_label: 'Account one',
+ endpoint_id: 'account-two', endpoint_label: 'Account two',
+ round_endpoint_ids: [null], round_endpoint_labels: [null],
+ }}, holder, roundHolder, 'same-model');
+ console.log(JSON.stringify(roundHolder));
+ """
+ result = subprocess.run(
+ ["node", "--input-type=module"],
+ input=script,
+ capture_output=True,
+ text=True,
+ cwd=_REPO,
+ timeout=30,
+ )
+ assert result.returncode == 0, result.stderr
+ assert json.loads(result.stdout) == {
+ "_requestedModel": "same-model",
+ "_actualModel": "same-model",
+ "_requestedEndpointId": "account-one",
+ "_requestedEndpointLabel": "Account one",
+ "_actualEndpointId": None,
+ "_actualEndpointLabel": None,
+ }
diff --git a/tests/test_chat_stream_errors_js.py b/tests/test_chat_stream_errors_js.py
new file mode 100644
index 000000000..bb698eb62
--- /dev/null
+++ b/tests/test_chat_stream_errors_js.py
@@ -0,0 +1,47 @@
+"""Execute terminal stream-error classification under Node."""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+
+_REPO = Path(__file__).resolve().parents[1]
+_MODULE = (_REPO / "static" / "js" / "chatStreamErrors.js").as_uri()
+
+
+def test_terminal_provider_errors_preserve_text_and_never_auto_retry():
+ if not shutil.which("node"):
+ pytest.skip("node is not installed")
+
+ script = f"""
+ import {{ createTerminalStreamError, isRecoverableStreamError }} from {json.dumps(_MODULE)};
+ const stringError = createTerminalStreamError({{ status: 401, error: 'invalid key' }});
+ const objectError = createTerminalStreamError({{ status: 404, error: {{ message: 'model missing' }} }});
+ console.log(JSON.stringify({{
+ stringMessage: stringError.message,
+ objectMessage: objectError.message,
+ terminalRecoverable: isRecoverableStreamError(stringError),
+ eofRecoverable: isRecoverableStreamError(new Error('Stream closed before completion')),
+ networkRecoverable: isRecoverableStreamError(new TypeError('fetch failed')),
+ }}));
+ """
+ result = subprocess.run(
+ ["node", "--input-type=module"],
+ input=script,
+ capture_output=True,
+ text=True,
+ cwd=_REPO,
+ timeout=30,
+ )
+
+ assert result.returncode == 0, result.stderr
+ assert json.loads(result.stdout) == {
+ "stringMessage": "invalid key",
+ "objectMessage": "model missing",
+ "terminalRecoverable": False,
+ "eofRecoverable": True,
+ "networkRecoverable": True,
+ }
diff --git a/tests/test_compare_stop_disconnect_poll.py b/tests/test_compare_stop_disconnect_poll.py
index 8c0238784..de837cc74 100644
--- a/tests/test_compare_stop_disconnect_poll.py
+++ b/tests/test_compare_stop_disconnect_poll.py
@@ -81,6 +81,13 @@ async def gen():
return gen()
+async def _collect_subscription(session_id, expected_run=None):
+ return [
+ event
+ async for event in agent_runs.subscribe(session_id, expected_run)
+ ]
+
+
# --------------------------------------------------------------------------- #
# agent_runs: detached-run semantics (what NORMAL chat/agent streams use)
# --------------------------------------------------------------------------- #
@@ -136,7 +143,7 @@ async def test_stop_cancels_detached_run_and_saves_partial_exactly_once():
break
await sub.aclose()
- stopped = agent_runs.stop(session_id)
+ stopped = agent_runs.stop(session_id, run.run_id)
assert stopped is True
await run.task # propagates promptly — not stuck on the hung await
@@ -165,6 +172,172 @@ async def test_normal_completion_saves_exactly_once_not_partial():
assert sink.saves == []
+@pytest.mark.asyncio
+async def test_detached_run_identity_is_stable_for_replay_and_unique_per_run():
+ session_id = "sess-detached-run-identity"
+ agent_runs._RUNS.pop(session_id, None)
+
+ first = agent_runs.start(session_id, _make_stream_with_save(_FakeSaveSink(), ["one"]))
+ first_id = first.run_id
+ assert agent_runs.get_run_id(session_id) == first_id
+ await first.task
+ assert agent_runs.get_run_id(session_id) == first_id
+
+ second = agent_runs.start(session_id, _make_stream_with_save(_FakeSaveSink(), ["two"]))
+ assert second.run_id != first_id
+ assert agent_runs.get_run_id(session_id) == second.run_id
+ await second.task
+
+
+@pytest.mark.asyncio
+async def test_lazy_subscription_stays_bound_to_header_run_after_replacement():
+ session_id = "sess-detached-lazy-subscription"
+ agent_runs._RUNS.pop(session_id, None)
+
+ async def stream(label):
+ yield f'data: {{"delta":"{label}"}}\n\n'
+
+ first = agent_runs.start(session_id, stream("first"))
+ await first.task
+ # StreamingResponse does not iterate its body until after construction.
+ # Capture the same exact run object used for its identity header.
+ lazy_body = agent_runs.subscribe(session_id, first)
+
+ second = agent_runs.start(session_id, stream("second"))
+ await second.task
+
+ replayed = [event async for event in lazy_body]
+ assert replayed == ['data: {"delta":"first"}\n\n']
+ assert agent_runs.get_run_id(session_id) == second.run_id
+
+
+@pytest.mark.asyncio
+async def test_stale_run_identity_cannot_stop_replacement_run():
+ session_id = "sess-detached-stale-stop"
+ agent_runs._RUNS.pop(session_id, None)
+ release = asyncio.Event()
+
+ async def finished():
+ yield 'data: {"delta":"old"}\n\n'
+
+ async def replacement():
+ yield 'data: {"delta":"new"}\n\n'
+ await release.wait()
+
+ first = agent_runs.start(session_id, finished())
+ await first.task
+ second = agent_runs.start(session_id, replacement())
+ await asyncio.sleep(0)
+
+ assert agent_runs.stop(session_id) is False
+ assert agent_runs.stop(session_id, first.run_id) is False
+ assert second.task is not None and not second.task.done()
+ assert agent_runs.stop(session_id, second.run_id) is True
+ await second.task
+
+
+@pytest.mark.asyncio
+async def test_triple_replacement_closes_middle_subscriber_and_preserves_save_order():
+ session_id = "sess-detached-triple-replacement"
+ agent_runs._RUNS.pop(session_id, None)
+ first_closing = asyncio.Event()
+ release_first = asyncio.Event()
+ third_started = asyncio.Event()
+
+ async def first_stream():
+ try:
+ yield 'data: {"delta":"first"}\n\n'
+ await asyncio.Event().wait()
+ finally:
+ first_closing.set()
+ await release_first.wait()
+
+ async def middle_stream():
+ yield 'data: {"delta":"middle"}\n\n'
+
+ async def third_stream():
+ third_started.set()
+ yield 'data: {"delta":"third"}\n\n'
+
+ first = agent_runs.start(session_id, first_stream())
+ while not first.buffer:
+ await asyncio.sleep(0)
+
+ middle = agent_runs.start(session_id, middle_stream())
+ await first_closing.wait()
+ assert middle.task is not None and not middle.task.done()
+
+ middle_events_task = asyncio.create_task(
+ _collect_subscription(session_id, middle)
+ )
+ while not middle.subscribers:
+ await asyncio.sleep(0)
+
+ third = agent_runs.start(session_id, third_stream())
+
+ # The superseded middle response closes immediately even though its task
+ # remains as the transitive barrier for the first run's partial save.
+ assert await asyncio.wait_for(middle_events_task, timeout=1) == []
+ assert middle.status == "stopped"
+ assert middle.task is not None and not middle.task.done()
+ assert not third_started.is_set()
+
+ release_first.set()
+ await asyncio.wait_for(first.task, timeout=1)
+ await asyncio.wait_for(middle.task, timeout=1)
+ await asyncio.wait_for(third.task, timeout=1)
+
+ assert first.status == "stopped"
+ assert middle.status == "stopped"
+ assert third.status == "done"
+ assert third_started.is_set()
+
+
+@pytest.mark.asyncio
+async def test_reconnect_replays_pinned_fallback_run_without_restarting_tools():
+ session_id = "sess-detached-fallback-resume"
+ agent_runs._RUNS.pop(session_id, None)
+ release = asyncio.Event()
+ tool_executions = 0
+ fallback = 'data: {"type":"fallback","answered_by":"backup","candidate_index":1}\n\n'
+ tool = 'data: {"type":"tool_output","tool":"bash","output":"ok"}\n\n'
+
+ async def pinned_run():
+ nonlocal tool_executions
+ yield fallback
+ tool_executions += 1
+ yield tool
+ await release.wait()
+ yield 'data: {"delta":"backup finished"}\n\n'
+ yield "data: [DONE]\n\n"
+
+ run = agent_runs.start(session_id, pinned_run())
+ first = agent_runs.subscribe(session_id)
+ first_events = []
+ async for event in first:
+ first_events.append(event)
+ if len(first_events) == 2:
+ break
+ await first.aclose()
+
+ assert run.status == "running"
+ assert tool_executions == 1
+ assert agent_runs._RUNS[session_id] is run
+
+ resumed_events = []
+ resumed = agent_runs.subscribe(session_id)
+ async for event in resumed:
+ resumed_events.append(event)
+ if len(resumed_events) == 2:
+ release.set()
+ await run.task
+
+ assert resumed_events[:2] == [fallback, tool]
+ assert resumed_events[-1] == "data: [DONE]\n\n"
+ assert tool_executions == 1
+ assert agent_runs._RUNS[session_id] is run
+
+
# --------------------------------------------------------------------------- #
# chat_stream: Compare panes must NOT be detached, so the Stop button (closing
# the SSE) cancels the upstream generator promptly — exercising the same
diff --git a/tests/test_context_compactor.py b/tests/test_context_compactor.py
index 3ccd3fb59..2f5ab7888 100644
--- a/tests/test_context_compactor.py
+++ b/tests/test_context_compactor.py
@@ -63,6 +63,23 @@ def test_mentions_compactions(self):
class TestTrimForContext:
+ def test_system_truncation_preserves_internal_route_metadata(self):
+ messages = [
+ {
+ "role": "system",
+ "content": "persona\n\n" + ("agent prompt " * 2000),
+ "_agent_injected": "merged_prompt",
+ "_agent_base_message": {"role": "system", "content": "persona"},
+ },
+ {"role": "user", "content": "latest"},
+ ]
+
+ trimmed = trim_for_context(messages, context_length=1024, reserve_tokens=256)
+
+ system = next(message for message in trimmed if message.get("role") == "system")
+ assert system["_agent_injected"] == "merged_prompt"
+ assert system["_agent_base_message"] == {"role": "system", "content": "persona"}
+
def test_keeps_current_large_user_message_by_truncating(self):
huge = "A" * 20000
messages = [
@@ -194,6 +211,50 @@ def test_handles_multimodal_list_content(self):
assert len(result) == 3 and result[2] is True
+@pytest.mark.asyncio
+async def test_deferred_compaction_persists_only_after_route_commit(monkeypatch):
+ updates = []
+ state = {}
+ messages = [
+ {"role": "system", "content": "system " * 100},
+ {"role": "user", "content": "one"},
+ {"role": "assistant", "content": "two"},
+ {"role": "user", "content": "three"},
+ {"role": "assistant", "content": "four"},
+ {"role": "user", "content": "five"},
+ ]
+
+ monkeypatch.setattr(cc, "get_context_length", lambda *args: 100)
+ monkeypatch.setattr(cc, "resolve_endpoint", lambda *args, **kwargs: (None, None, None))
+
+ async def fake_summary(*args, **kwargs):
+ return "route-specific summary"
+
+ monkeypatch.setattr(cc, "llm_call_async", fake_summary)
+ monkeypatch.setattr(
+ cc,
+ "_update_session_history",
+ lambda *args, **kwargs: updates.append((args, kwargs)),
+ )
+
+ _compacted, _context, was_compacted = await cc.maybe_compact(
+ object(),
+ "https://candidate.example/v1",
+ "candidate-model",
+ messages,
+ persist=False,
+ compaction_state=state,
+ )
+
+ assert was_compacted is True
+ assert updates == []
+ assert state["summary"] == "route-specific summary"
+ assert cc.apply_compaction_state(object(), state) is True
+ assert len(updates) == 1
+ assert cc.apply_compaction_state(object(), state) is False
+ assert len(updates) == 1
+
+
class TestResearchPrimerPreserved:
"""A research-spinoff primer (metadata research_spinoff_from) must never be
trimmed away — it is the Discuss chat's sole knowledge base (drift fix)."""
diff --git a/tests/test_foreground_model_routing.py b/tests/test_foreground_model_routing.py
index 14d2aeb7f..6cc8792eb 100644
--- a/tests/test_foreground_model_routing.py
+++ b/tests/test_foreground_model_routing.py
@@ -4,14 +4,24 @@
import json
from types import SimpleNamespace
+import httpx
import pytest
+from fastapi import HTTPException
+import core.database as database
import src.agent_loop as agent_loop
import src.endpoint_resolver as endpoint_resolver
import src.foreground_model_routing as foreground_model_routing
+import src.llm_core as llm_core
import routes.chat_routes as chat_routes
+import routes.chat_helpers as chat_helpers
+import routes.prefs_routes as prefs_routes
+from src.request_models import ChatRequest
from src.foreground_model_routing import (
+ FOREGROUND_AVAILABILITY_STATUSES,
+ MAX_FOREGROUND_FALLBACKS,
+ ForegroundModelPolicy,
build_foreground_model_candidates,
- resolve_foreground_fallback_candidates,
+ resolve_foreground_model_policy,
)
@@ -42,9 +52,13 @@ def close(self):
class _RouteRequest:
- def __init__(self, mode):
+ def __init__(self, mode, privileges=None):
self.headers = {}
- self.app = SimpleNamespace(state=SimpleNamespace(auth_manager=None))
+ auth_manager = None
+ if privileges is not None:
+ auth_manager = SimpleNamespace(get_privileges=lambda user: privileges)
+ self.app = SimpleNamespace(state=SimpleNamespace(auth_manager=auth_manager))
+ self.state = SimpleNamespace(current_user="alice")
self._form = {
"message": "hello",
"session": "session-1",
@@ -56,14 +70,41 @@ async def form(self):
return self._form
-def _chat_stream_endpoint(monkeypatch, mode, captured):
+@pytest.mark.parametrize(
+ ("status", "expected"),
+ [
+ (429, 429),
+ ("503", 503),
+ (429.9, None),
+ (True, None),
+ ("429.9", None),
+ ],
+)
+def test_stream_failure_status_uses_exact_http_statuses(status, expected):
+ chunk = f'event: error\ndata: {json.dumps({"status": status})}\n\n'
+ assert chat_routes._stream_failure_status(chunk) == expected
+
+
+def _chat_stream_endpoint(
+ monkeypatch,
+ mode,
+ captured,
+ *,
+ agent_chunks=None,
+ chat_chunks=None,
+ capture_completion=False,
+ endpoint_url="https://selected.example/v1",
+):
+ def add_message(message):
+ captured.setdefault("added_messages", []).append(message)
+
session = SimpleNamespace(
- endpoint_url="https://selected.example/v1",
+ endpoint_url=endpoint_url,
model="selected-model",
headers={"Authorization": "Bearer selected"},
name="test",
history=[],
- add_message=lambda message: None,
+ add_message=add_message,
)
session_manager = SimpleNamespace(
get_session=lambda session_id: session,
@@ -72,6 +113,11 @@ def _chat_stream_endpoint(monkeypatch, mode, captured):
context = SimpleNamespace(
user="alice",
messages=[{"role": "user", "content": "hello"}],
+ route_messages=[
+ {"role": "user", "content": "old one"},
+ {"role": "assistant", "content": "old answer"},
+ {"role": "user", "content": "hello"},
+ ],
preprocessed=SimpleNamespace(attachment_meta=[]),
auto_opened_docs=[],
rag_sources=[],
@@ -94,6 +140,12 @@ async def fake_build_context(*args, **kwargs):
async def fake_chat_stream(candidates, messages, **kwargs):
captured["chat"] = candidates
+ if chat_chunks is not None:
+ for chunk in chat_chunks:
+ if isinstance(chunk, BaseException):
+ raise chunk
+ yield chunk
+ return
yield f'data: {json.dumps({"delta": "done"})}\n\n'
yield "data: [DONE]\n\n"
@@ -102,6 +154,12 @@ async def fake_agent_stream(endpoint_url, model, messages, **kwargs):
"primary": (endpoint_url, model, kwargs.get("headers")),
"fallbacks": kwargs.get("fallbacks"),
}
+ if agent_chunks is not None:
+ for chunk in agent_chunks:
+ if isinstance(chunk, BaseException):
+ raise chunk
+ yield chunk
+ return
yield f'data: {json.dumps({"delta": "done"})}\n\n'
yield "data: [DONE]\n\n"
@@ -119,14 +177,31 @@ async def fake_agent_stream(endpoint_url, model, messages, **kwargs):
monkeypatch.setattr(chat_routes, "_is_image_generation_session", lambda *args, **kwargs: False)
monkeypatch.setattr(chat_routes, "stream_llm_with_fallback", fake_chat_stream)
monkeypatch.setattr(chat_routes, "stream_agent_loop", fake_agent_stream)
- monkeypatch.setattr(chat_routes, "save_assistant_response", lambda *args, **kwargs: None)
- monkeypatch.setattr(chat_routes, "run_post_response_tasks", lambda *args, **kwargs: None)
+ if capture_completion:
+ monkeypatch.setattr(
+ chat_routes,
+ "save_assistant_response",
+ lambda *args, **kwargs: captured.setdefault("saved", []).append((args, kwargs)),
+ )
+ monkeypatch.setattr(
+ chat_routes,
+ "run_post_response_tasks",
+ lambda *args, **kwargs: captured.setdefault("post_processed", []).append((args, kwargs)),
+ )
+ else:
+ monkeypatch.setattr(chat_routes, "save_assistant_response", lambda *args, **kwargs: None)
+ monkeypatch.setattr(chat_routes, "run_post_response_tasks", lambda *args, **kwargs: None)
monkeypatch.setattr(chat_routes, "estimate_tokens", lambda messages: 10)
monkeypatch.setattr(
- endpoint_resolver,
- "resolve_chat_fallback_candidates",
- lambda owner=None: [("https://legacy.example/v1", "legacy-model", {})],
+ chat_routes,
+ "accumulate_token_usage",
+ lambda *args, **kwargs: captured.setdefault("accumulated_usage", []).append((args, kwargs)),
)
+ monkeypatch.setattr(foreground_model_routing, "_load_policy_preferences", lambda owner=None: {
+ "default_model_fallbacks": [
+ {"endpoint_id": "legacy", "model": "legacy-model"},
+ ],
+ })
import src.settings as settings
@@ -177,101 +252,2998 @@ async def test_chat_stream_route_keeps_selected_model_strict_with_legacy_data(mo
assert captured == {"agent": {"primary": selected, "fallbacks": []}}
-def test_candidate_builder_appends_only_policy_authorized_fallbacks(monkeypatch):
- """Chat and Agent share the same candidate-building policy boundary."""
+@pytest.mark.asyncio
+@pytest.mark.parametrize("mode", ["chat", "agent"])
+async def test_chat_stream_rejects_empty_selected_endpoint_before_fallback(monkeypatch, mode):
+ captured = {}
+ endpoint = _chat_stream_endpoint(
+ monkeypatch,
+ mode,
+ captured,
+ endpoint_url="",
+ )
- authorized = [("https://opt-in.example/v1", "opt-in-model", {})]
+ with pytest.raises(HTTPException) as exc:
+ await endpoint(_RouteRequest(mode))
+
+ assert exc.value.status_code == 400
+ assert "not configured" in str(exc.value.detail)
+ assert captured == {}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("mode", ["chat", "agent"])
+async def test_chat_stream_route_uses_only_new_explicit_fallback_policy(monkeypatch, mode):
+ captured = {}
+ endpoint = _chat_stream_endpoint(monkeypatch, mode, captured)
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "_load_policy_preferences",
+ lambda owner=None: {
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "backup", "model": "backup-model"},
+ ],
+ "default_model_fallbacks": [
+ {"endpoint_id": "legacy", "model": "legacy-model"},
+ ],
+ },
+ )
monkeypatch.setattr(
foreground_model_routing,
- "resolve_foreground_fallback_candidates",
- lambda owner=None: authorized,
+ "resolve_fallback_entries",
+ lambda entries, owner=None, require_exact_model=False: [
+ ("https://backup.example/v1", "backup-model", {"Authorization": "Bearer backup"}),
+ ],
)
- assert build_foreground_model_candidates(
+ response = await endpoint(_RouteRequest(mode))
+ async for _ in response.body_iterator:
+ pass
+
+ selected = (
"https://selected.example/v1",
"selected-model",
{"Authorization": "Bearer selected"},
- owner="alice",
- ) == [
- ("https://selected.example/v1", "selected-model", {"Authorization": "Bearer selected"}),
- *authorized,
- ]
+ )
+ backup = (
+ "https://backup.example/v1",
+ "backup-model",
+ {"Authorization": "Bearer backup"},
+ )
+ if mode == "chat":
+ assert captured == {"chat": [selected, backup]}
+ else:
+ assert captured == {"agent": {"primary": selected, "fallbacks": [backup]}}
-def test_strict_policy_builds_only_the_selected_chat_candidate():
- candidates = build_foreground_model_candidates(
- "https://selected.example/v1",
- "selected-model",
- {"Authorization": "Bearer selected"},
- owner="alice",
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("primary_context", "backup_context", "expected_counts"),
+ [
+ (100, 1000, (1, 3)),
+ (1000, 100, (3, 1)),
+ ],
+)
+async def test_streaming_chat_shapes_each_candidate_from_route_neutral_history(
+ monkeypatch,
+ primary_context,
+ backup_context,
+ expected_counts,
+):
+ captured = {}
+ endpoint = _chat_stream_endpoint(monkeypatch, "chat", captured)
+ monkeypatch.setattr(foreground_model_routing, "_load_policy_preferences", lambda owner=None: {
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "backup", "model": "backup-model"},
+ ],
+ })
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_fallback_entries",
+ lambda *args, **kwargs: [("https://backup.example/v1", "backup-model", {})],
)
+ async def fake_compact(
+ session, url, model, messages, headers=None, owner=None, **kwargs
+ ):
+ return (
+ list(messages),
+ backup_context if model == "backup-model" else primary_context,
+ False,
+ )
- assert candidates == [
- ("https://selected.example/v1", "selected-model", {"Authorization": "Bearer selected"})
+ monkeypatch.setattr(chat_routes, "maybe_compact", fake_compact)
+ monkeypatch.setattr(
+ chat_routes,
+ "trim_for_context",
+ lambda messages, budget: list(messages) if budget >= 1000 else list(messages[-1:]),
+ )
+
+ async def fake_stream(candidates, messages, **kwargs):
+ factory = kwargs["candidate_request_factory"]
+ requests = [
+ await factory(index, *candidate)
+ for index, candidate in enumerate(candidates)
+ ]
+ captured["request_counts"] = tuple(
+ len(request["messages"]) for request in requests
+ )
+ yield 'data: {"type": "fallback", "candidate_index": 1, "selected_model": "selected-model", "answered_by": "backup-model"}\n\n'
+ yield 'data: {"delta": "backup"}\n\n'
+ yield "data: [DONE]\n\n"
+
+ monkeypatch.setattr(chat_routes, "stream_llm_with_fallback", fake_stream)
+
+ response = await endpoint(_RouteRequest("chat"))
+ async for _chunk in response.body_iterator:
+ pass
+
+ assert captured["request_counts"] == expected_counts
+
+
+@pytest.mark.asyncio
+async def test_streaming_chat_persists_only_answering_route_compaction(monkeypatch):
+ captured = {}
+ applied = []
+ endpoint = _chat_stream_endpoint(monkeypatch, "chat", captured)
+ monkeypatch.setattr(foreground_model_routing, "_load_policy_preferences", lambda owner=None: {
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "backup", "model": "backup-model"},
+ ],
+ })
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_fallback_entries",
+ lambda *args, **kwargs: [("https://backup.example/v1", "backup-model", {})],
+ )
+
+ async def fake_compact(
+ session, url, model, messages, headers=None, owner=None,
+ *, persist=True, compaction_state=None,
+ ):
+ assert persist is False
+ compaction_state.update({"route": model, "applied": False})
+ return ([{"role": "system", "content": f"summary for {model}"}, *messages], 1000, True)
+
+ def fake_apply(session, state):
+ if not state or state.get("applied"):
+ return False
+ state["applied"] = True
+ applied.append(state["route"])
+ return True
+
+ async def fake_stream(candidates, messages, **kwargs):
+ factory = kwargs["candidate_request_factory"]
+ for index, candidate in enumerate(candidates):
+ await factory(index, *candidate)
+ yield 'data: {"type": "fallback", "candidate_index": 1, "selected_model": "selected-model", "answered_by": "backup-model"}\n\n'
+ yield 'data: {"delta": "backup"}\n\n'
+ yield "data: [DONE]\n\n"
+
+ monkeypatch.setattr(chat_routes, "maybe_compact", fake_compact)
+ monkeypatch.setattr(chat_routes, "apply_compaction_state", fake_apply)
+ monkeypatch.setattr(chat_routes, "stream_llm_with_fallback", fake_stream)
+
+ response = await endpoint(_RouteRequest("chat"))
+ chunks = [chunk async for chunk in response.body_iterator]
+
+ assert applied == ["backup-model"]
+ assert any('"type": "compacted"' in chunk for chunk in chunks)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("selected_url", "selected_cost_tracked", "backup_url", "expected_cost_tracked"),
+ [
+ ("http://localhost:11434/v1", False, "https://backup.example/v1", True),
+ ("https://selected.example/v1", True, "http://localhost:11434/v1", False),
+ ],
+)
+async def test_streaming_chat_cost_uses_answering_route_classification(
+ monkeypatch,
+ selected_url,
+ selected_cost_tracked,
+ backup_url,
+ expected_cost_tracked,
+):
+ captured = {}
+ chunks = [
+ 'data: {"type": "fallback", "candidate_index": 1, "selected_model": "selected-model", "answered_by": "backup-model"}\n\n',
+ 'data: {"type": "usage", "data": {"model": "backup-model", "input_tokens": 20, "output_tokens": 5}}\n\n',
+ 'data: {"delta": "backup answer"}\n\n',
+ "data: [DONE]\n\n",
]
+ endpoint = _chat_stream_endpoint(
+ monkeypatch,
+ "chat",
+ captured,
+ chat_chunks=chunks,
+ capture_completion=True,
+ endpoint_url=selected_url,
+ )
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "_load_policy_preferences",
+ lambda owner=None: {
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "backup", "model": "backup-model"},
+ ],
+ },
+ )
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_fallback_entries",
+ lambda *args, **kwargs: [
+ (backup_url, "backup-model", {}),
+ ],
+ )
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_route_descriptor",
+ lambda *args, **kwargs: {
+ "endpoint_id": "selected",
+ "endpoint_label": "Selected local endpoint",
+ "endpoint_cost_tracked": selected_cost_tracked,
+ },
+ )
+ response = await endpoint(_RouteRequest("chat"))
+ emitted = [chunk async for chunk in response.body_iterator]
-def test_legacy_chat_resolver_is_disconnected():
- assert endpoint_resolver.resolve_chat_fallback_candidates(owner="alice") == []
+ metrics = json.loads(next(
+ chunk for chunk in emitted if '"type": "metrics"' in chunk
+ )[6:])["data"]
+ assert metrics["endpoint_id"] == "backup"
+ assert metrics["endpoint_cost_tracked"] is expected_cost_tracked
+ saved_args, _saved_kwargs = captured["saved"][0]
+ assert saved_args[4]["endpoint_cost_tracked"] is expected_cost_tracked
-def test_utility_resolver_does_not_inherit_legacy_chat_fallbacks(monkeypatch):
- seen_keys = []
+@pytest.mark.asyncio
+@pytest.mark.parametrize("selected_cost_tracked", [False, True])
+async def test_streaming_chat_persists_selected_route_cost_classification(
+ monkeypatch,
+ selected_cost_tracked,
+):
+ captured = {}
+ endpoint = _chat_stream_endpoint(
+ monkeypatch,
+ "chat",
+ captured,
+ chat_chunks=[
+ 'data: {"type": "usage", "data": {"model": "selected-model", "input_tokens": 20, "output_tokens": 5}}\n\n',
+ 'data: {"delta": "selected answer"}\n\n',
+ "data: [DONE]\n\n",
+ ],
+ capture_completion=True,
+ )
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_route_descriptor",
+ lambda *args, **kwargs: {
+ "endpoint_id": "selected",
+ "endpoint_label": "Selected endpoint",
+ "endpoint_cost_tracked": selected_cost_tracked,
+ },
+ )
- def fake_resolve(setting_key, owner=None):
- seen_keys.append((setting_key, owner))
- return [("https://utility.example/v1", "utility-model", {})]
+ response = await endpoint(_RouteRequest("chat"))
+ emitted = [chunk async for chunk in response.body_iterator]
- monkeypatch.setattr(endpoint_resolver, "_resolve_fallback_candidates", fake_resolve)
+ metrics = json.loads(next(
+ chunk for chunk in emitted if '"type": "metrics"' in chunk
+ )[6:])["data"]
+ assert metrics["endpoint_cost_tracked"] is selected_cost_tracked
+ saved_args, _saved_kwargs = captured["saved"][0]
+ assert saved_args[4]["endpoint_cost_tracked"] is selected_cost_tracked
- assert endpoint_resolver.resolve_utility_fallback_candidates(owner="alice") == [
- ("https://utility.example/v1", "utility-model", {})
+
+@pytest.mark.asyncio
+async def test_chat_stream_route_does_not_save_or_postprocess_terminal_agent_error(monkeypatch):
+ captured = {}
+ error_chunk = 'event: error\ndata: {"status": 401, "error": "invalid key"}\n\n'
+ endpoint = _chat_stream_endpoint(
+ monkeypatch,
+ "agent",
+ captured,
+ agent_chunks=[error_chunk],
+ capture_completion=True,
+ )
+
+ response = await endpoint(_RouteRequest("agent"))
+ chunks = [chunk async for chunk in response.body_iterator]
+
+ assert error_chunk in chunks
+ assert "saved" not in captured
+ assert "post_processed" not in captured
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("provider_status", "expected_status", "expected_message"),
+ [
+ (401, 401, "Model request failed (HTTP 401)"),
+ (429.9, None, "Model request failed"),
+ ],
+)
+async def test_chat_stream_persists_completed_tools_before_later_terminal_error(
+ monkeypatch,
+ provider_status,
+ expected_status,
+ expected_message,
+):
+ captured = {}
+ terminal_metadata = {
+ "failed": True,
+ "failure": {
+ "status": provider_status,
+ "message": "credential-shaped provider detail",
+ },
+ "model": "backup-model",
+ "requested_model": "selected-model",
+ "endpoint_id": "backup-endpoint",
+ "endpoint_label": "Backup endpoint",
+ "tool_events": [
+ {"round": 1, "tool": "bash", "output": "created", "exit_code": 0},
+ ],
+ "round_texts": ["partial answer"],
+ "round_models": ["backup-model"],
+ "round_endpoint_ids": ["backup-endpoint"],
+ "round_endpoint_labels": ["Backup endpoint"],
+ "input_tokens": 75,
+ "output_tokens": 15,
+ "usage_source": "real",
+ "endpoint_cost_tracked": True,
+ }
+ chunks = [
+ 'data: {"delta": "partial answer"}\n\n',
+ f'data: {json.dumps({"type": "agent_terminal", "data": terminal_metadata})}\n\n',
+ f'event: error\ndata: {json.dumps({"status": provider_status, "error": "invalid key"})}\n\n',
]
- assert seen_keys == [("utility_model_fallbacks", "alice")]
+ endpoint = _chat_stream_endpoint(
+ monkeypatch,
+ "agent",
+ captured,
+ agent_chunks=chunks,
+ capture_completion=True,
+ )
+ response = await endpoint(_RouteRequest("agent"))
+ emitted = [chunk async for chunk in response.body_iterator]
-def test_multi_round_agent_uses_only_selected_model(monkeypatch):
- """Every Agent round receives only the selected foreground candidate."""
+ assert any(chunk.startswith("event: error") for chunk in emitted)
+ assert not any(chunk == "data: [DONE]\n\n" for chunk in emitted)
+ assert len(captured["saved"]) == 1
+ saved_args, _saved_kwargs = captured["saved"][0]
+ assert "partial answer" in saved_args[3]
+ assert f"Agent stopped: {expected_message}" in saved_args[3]
+ assert "credential-shaped provider detail" not in saved_args[3]
+ assert saved_args[4]["failure"] == {
+ "status": expected_status,
+ "message": expected_message,
+ }
+ assert saved_args[4]["failed"] is True
+ assert saved_args[4]["tool_events"][0]["output"] == "created"
+ assert captured["accumulated_usage"][0][0][1] == saved_args[4]
+ assert saved_args[4]["input_tokens"] == 75
+ assert saved_args[4]["output_tokens"] == 15
+ assert "post_processed" not in captured
+ assert all(chunk != "data: [DONE]\n\n" for chunk in chunks)
- seen_candidates = []
- round_number = 0
- monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
- monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
- monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
+@pytest.mark.asyncio
+async def test_chat_stream_persists_partial_terminal_error_with_route_provenance(monkeypatch):
+ captured = {}
+ chunks = [
+ 'data: {"type": "fallback", "candidate_index": 1, "selected_model": "selected-model", "answered_by": "backup-model", "answered_by_endpoint_id": "backup", "answered_by_endpoint_label": "Backup endpoint"}\n\n',
+ 'data: {"delta": "visible partial"}\n\n',
+ 'event: error\ndata: {"status": 503, "error": "credential-shaped provider detail"}\n\n',
+ "data: [DONE]\n\n",
+ ]
+ endpoint = _chat_stream_endpoint(
+ monkeypatch,
+ "chat",
+ captured,
+ chat_chunks=chunks,
+ capture_completion=True,
+ )
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "_load_policy_preferences",
+ lambda owner=None: {
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "backup", "model": "backup-model"},
+ ],
+ },
+ )
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_fallback_entries",
+ lambda *args, **kwargs: [
+ ("https://backup.example/v1", "backup-model", {"Authorization": "Bearer backup"}),
+ ],
+ )
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_route_descriptor",
+ lambda *args, **kwargs: {
+ "endpoint_id": "selected",
+ "endpoint_label": "Selected endpoint",
+ },
+ )
- async def fake_stream(candidates, messages, **kwargs):
- nonlocal round_number
- round_number += 1
- seen_candidates.append([(url, model) for url, model, _headers in candidates])
- if round_number == 1:
- call = {"name": "bash", "arguments": json.dumps({"command": "printf ok"})}
- yield f'data: {json.dumps({"type": "tool_calls", "calls": [call]})}\n\n'
- else:
- yield f'data: {json.dumps({"delta": "done"})}\n\n'
- yield "data: [DONE]\n\n"
+ response = await endpoint(_RouteRequest("chat"))
+ emitted = [chunk async for chunk in response.body_iterator]
- async def fake_execute(block, *args, **kwargs):
- return "bash", {"output": "ok", "exit_code": 0}
+ assert any(chunk.startswith("event: error") for chunk in emitted)
+ assert not any(chunk == "data: [DONE]\n\n" for chunk in emitted)
+ assert len(captured["saved"]) == 1
+ saved_args, _saved_kwargs = captured["saved"][0]
+ assert saved_args[3] == (
+ "visible partial\n\n"
+ "[Response stopped: Model request failed (HTTP 503)]"
+ )
+ assert "credential-shaped provider detail" not in str(saved_args)
+ assert saved_args[4]["failure"] == {
+ "status": 503,
+ "message": "Model request failed (HTTP 503)",
+ }
+ assert saved_args[4]["model"] == "backup-model"
+ assert saved_args[4]["requested_model"] == "selected-model"
+ assert saved_args[4]["endpoint_id"] == "backup"
+ assert saved_args[4]["endpoint_label"] == "backup"
+ assert saved_args[4]["requested_endpoint_id"] == "selected"
+ assert saved_args[4]["requested_endpoint_label"] == "Selected endpoint"
+ assert saved_args[4]["endpoint_cost_tracked"] is True
+ assert saved_args[4]["input_tokens"] == 10
+ assert saved_args[4]["output_tokens"] == len("visible partial") // 4
+ assert saved_args[4]["usage_source"] == "estimated"
+ assert captured["accumulated_usage"][0][0][1] == saved_args[4]
+ chat_terminal = json.loads(next(
+ chunk for chunk in emitted if '"type": "chat_terminal"' in chunk
+ )[6:])["data"]
+ assert chat_terminal == saved_args[4]
+ assert "post_processed" not in captured
- monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
- monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute)
- fallbacks = resolve_foreground_fallback_candidates(owner="alice")
- chunks = _collect(
- agent_loop.stream_agent_loop(
- "https://selected.example/v1",
- "selected-model",
- [{"role": "user", "content": "Run one tool and report back."}],
- max_rounds=3,
- relevant_tools={"bash"},
- fallbacks=fallbacks,
- _is_teacher_run=True,
- )
+@pytest.mark.asyncio
+async def test_chat_terminal_preserves_real_usage_and_accumulates_once(monkeypatch):
+ captured = {}
+ endpoint = _chat_stream_endpoint(
+ monkeypatch,
+ "chat",
+ captured,
+ chat_chunks=[
+ 'data: {"type": "usage", "data": {"model": "selected-model", "input_tokens": 123, "output_tokens": 17, "usage_source": "real"}}\n\n',
+ 'data: {"delta": "visible partial"}\n\n',
+ 'event: error\ndata: {"status": 503, "error": "provider detail"}\n\n',
+ ],
+ capture_completion=True,
+ )
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_route_descriptor",
+ lambda *args, **kwargs: {
+ "endpoint_id": "selected",
+ "endpoint_label": "Selected paid endpoint",
+ "endpoint_cost_tracked": True,
+ },
)
- assert seen_candidates == [
- [("https://selected.example/v1", "selected-model")],
- [("https://selected.example/v1", "selected-model")],
+ response = await endpoint(_RouteRequest("chat"))
+ emitted = [chunk async for chunk in response.body_iterator]
+
+ saved_metrics = captured["saved"][0][0][4]
+ assert saved_metrics["input_tokens"] == 123
+ assert saved_metrics["output_tokens"] == 17
+ assert saved_metrics["usage_source"] == "real"
+ assert saved_metrics["endpoint_cost_tracked"] is True
+ assert saved_metrics["failed"] is True
+ assert len(captured["accumulated_usage"]) == 1
+ assert captured["accumulated_usage"][0][0][1] == saved_metrics
+ assert len([
+ chunk for chunk in emitted if '"type": "metrics"' in chunk
+ ]) == 1
+ assert len([
+ chunk for chunk in emitted if '"type": "chat_terminal"' in chunk
+ ]) == 1
+ assert "post_processed" not in captured
+
+
+@pytest.mark.asyncio
+async def test_cancelled_agent_fallback_saves_endpoint_and_round_provenance(monkeypatch):
+ captured = {}
+ chunks = [
+ 'data: {"type": "model_actual", "round": 1, "model": "selected-provider-alias"}\n\n',
+ 'data: {"type": "agent_step", "round": 2}\n\n',
+ 'data: {"type": "fallback", "round": 2, "selected_model": "selected-model", "answered_by": "backup-model", "answered_by_endpoint_id": "account-two", "answered_by_endpoint_label": "Account two"}\n\n',
+ 'data: {"delta": "partial answer"}\n\n',
+ asyncio.CancelledError(),
]
- assert any('"delta": "done"' in chunk for chunk in chunks)
+ endpoint = _chat_stream_endpoint(
+ monkeypatch,
+ "agent",
+ captured,
+ agent_chunks=chunks,
+ )
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "_load_policy_preferences",
+ lambda owner=None: {
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "account-two", "model": "selected-model"},
+ ],
+ },
+ )
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_fallback_entries",
+ lambda *args, **kwargs: [
+ ("https://backup.example/v1", "selected-model", {"Authorization": "Bearer two"}),
+ ],
+ )
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_route_descriptor",
+ lambda *args, **kwargs: {
+ "endpoint_id": "account-one",
+ "endpoint_label": "Account one",
+ },
+ )
+
+ response = await endpoint(_RouteRequest("agent"))
+ with pytest.raises(asyncio.CancelledError):
+ async for _chunk in response.body_iterator:
+ pass
+
+ saved = captured["added_messages"][-1]
+ assert saved.metadata["requested_endpoint_id"] == "account-one"
+ assert saved.metadata["endpoint_id"] == "account-two"
+ assert saved.metadata["model"] == "backup-model"
+ assert saved.metadata["round_models"] == ["selected-provider-alias", "backup-model"]
+ assert saved.metadata["round_endpoint_ids"] == ["account-one", "account-two"]
+ assert saved.metadata["round_endpoint_labels"] == ["Account one", "Account two"]
+
+
+@pytest.mark.asyncio
+async def test_cancelled_chat_fallback_saves_same_model_endpoint_provenance(monkeypatch):
+ captured = {}
+ chunks = [
+ 'data: {"type": "fallback", "candidate_index": 1, "selected_model": "selected-model", "answered_by": "selected-model", "answered_by_endpoint_id": "account-two", "answered_by_endpoint_label": "Account two"}\n\n',
+ 'data: {"delta": "partial answer"}\n\n',
+ asyncio.CancelledError(),
+ ]
+ endpoint = _chat_stream_endpoint(
+ monkeypatch,
+ "chat",
+ captured,
+ chat_chunks=chunks,
+ )
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "_load_policy_preferences",
+ lambda owner=None: {
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "account-two", "model": "selected-model"},
+ ],
+ },
+ )
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_fallback_entries",
+ lambda *args, **kwargs: [
+ ("https://backup.example/v1", "selected-model", {"Authorization": "Bearer two"}),
+ ],
+ )
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_route_descriptor",
+ lambda *args, **kwargs: {
+ "endpoint_id": "account-one",
+ "endpoint_label": "Account one",
+ },
+ )
+
+ response = await endpoint(_RouteRequest("chat"))
+ with pytest.raises(asyncio.CancelledError):
+ async for _chunk in response.body_iterator:
+ pass
+
+ saved = captured["added_messages"][-1]
+ assert saved.metadata["requested_endpoint_id"] == "account-one"
+ assert saved.metadata["endpoint_id"] == "account-two"
+ assert saved.metadata["requested_endpoint_label"] == "Account one"
+ assert saved.metadata["endpoint_label"] == "account-two"
+
+
+@pytest.mark.asyncio
+async def test_chat_stream_route_excludes_fallback_outside_non_admin_allowlist(monkeypatch):
+ captured = {}
+ endpoint = _chat_stream_endpoint(monkeypatch, "chat", captured)
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "_load_policy_preferences",
+ lambda owner=None: {
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "backup", "model": "blocked-model"},
+ ],
+ },
+ )
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_fallback_entries",
+ lambda *args, **kwargs: pytest.fail("unauthorized fallback reached endpoint resolution"),
+ )
+
+ response = await endpoint(_RouteRequest("chat", privileges={
+ "allowed_models": ["selected-model"],
+ "allowed_models_restricted": True,
+ "max_messages_per_day": 0,
+ }))
+ async for _ in response.body_iterator:
+ pass
+
+ assert captured == {"chat": [(
+ "https://selected.example/v1",
+ "selected-model",
+ {"Authorization": "Bearer selected"},
+ )]}
+
+
+class _NonStreamChatHandler:
+ async def handle_memory_command(self, sess, message):
+ return None
+
+
+def _chat_endpoint(
+ monkeypatch,
+ *,
+ owner="alice",
+ endpoint_url="https://selected.example/v1",
+):
+ saved = []
+ session = SimpleNamespace(
+ endpoint_url=endpoint_url,
+ model="selected-model",
+ headers={"Authorization": "Bearer selected"},
+ history=[],
+ add_message=saved.append,
+ )
+ session_manager = SimpleNamespace(
+ get_session=lambda session_id: session,
+ save_sessions=lambda: None,
+ )
+ context = SimpleNamespace(
+ user=owner,
+ messages=[{"role": "user", "content": "hello"}],
+ route_messages=[
+ {"role": "user", "content": "old one"},
+ {"role": "assistant", "content": "old answer"},
+ {"role": "user", "content": "hello"},
+ ],
+ context_length=100,
+ uprefs={},
+ preset=SimpleNamespace(
+ temperature=0.2,
+ max_tokens=128,
+ character_name=None,
+ ),
+ )
+
+ async def fake_build_context(*args, **kwargs):
+ return context
+
+ monkeypatch.setattr(chat_routes, "_verify_session_owner", lambda *args, **kwargs: None)
+ monkeypatch.setattr(chat_routes, "effective_user", lambda request: owner)
+ monkeypatch.setattr(chat_routes, "_clear_orphaned_session_endpoint", lambda *args, **kwargs: False)
+ monkeypatch.setattr(chat_routes, "_recover_empty_session_model", lambda *args, **kwargs: False)
+ monkeypatch.setattr(chat_routes, "_enforce_chat_privileges", lambda *args, **kwargs: None)
+ monkeypatch.setattr(chat_routes, "build_chat_context", fake_build_context)
+ monkeypatch.setattr(chat_routes, "clean_thinking_for_save", lambda reply, metadata: (reply, metadata))
+ monkeypatch.setattr(chat_routes, "run_post_response_tasks", lambda *args, **kwargs: None)
+
+ import core.database as database
+
+ monkeypatch.setattr(database, "update_session_last_accessed", lambda session_id: None)
+
+ router = chat_routes.setup_chat_routes(
+ session_manager,
+ _NonStreamChatHandler(),
+ SimpleNamespace(),
+ SimpleNamespace(),
+ SimpleNamespace(),
+ SimpleNamespace(),
+ )
+ endpoint = next(route.endpoint for route in router.routes if route.path == "/api/chat")
+ return endpoint, saved
+
+
+@pytest.mark.asyncio
+async def test_nonstream_chat_is_strict_by_default_and_reports_selected_route(monkeypatch):
+ calls = []
+ monkeypatch.setattr(foreground_model_routing, "_load_policy_preferences", lambda owner: {})
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_fallback_entries",
+ lambda *args, **kwargs: pytest.fail("strict non-stream Chat resolved fallback entries"),
+ )
+
+ async def fake_call(url, model, messages, **kwargs):
+ calls.append((url, model, kwargs.get("headers")))
+ return "selected answer"
+
+ monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
+ endpoint, saved = _chat_endpoint(monkeypatch)
+
+ response = await endpoint(
+ _RouteRequest("chat"),
+ ChatRequest(message="hello", session="session-1"),
+ )
+
+ assert calls == [(
+ "https://selected.example/v1",
+ "selected-model",
+ {"Authorization": "Bearer selected"},
+ )]
+ assert response == {
+ "response": "selected answer",
+ "requested_model": "selected-model",
+ "model": "selected-model",
+ "requested_endpoint_id": None,
+ "requested_endpoint_label": "Selected route",
+ "endpoint_id": None,
+ "endpoint_label": "Selected route",
+ }
+ assert saved[-1].metadata == {
+ "model": "selected-model",
+ "requested_model": "selected-model",
+ "endpoint_id": None,
+ "endpoint_label": "Selected route",
+ "requested_endpoint_id": None,
+ "requested_endpoint_label": "Selected route",
+ "context_length": 100,
+ "context_trimmed": False,
+ }
+
+
+@pytest.mark.asyncio
+async def test_nonstream_chat_rejects_empty_selected_endpoint_before_fallback(monkeypatch):
+ endpoint, saved = _chat_endpoint(monkeypatch, endpoint_url="")
+
+ with pytest.raises(HTTPException) as exc:
+ await endpoint(
+ _RouteRequest("chat"),
+ ChatRequest(message="hello", session="session-1"),
+ )
+
+ assert exc.value.status_code == 400
+ assert "not configured" in str(exc.value.detail)
+ assert saved == []
+
+
+@pytest.mark.asyncio
+async def test_nonstream_chat_opt_in_advances_only_on_eligible_failure(monkeypatch):
+ calls = []
+ monkeypatch.setattr(foreground_model_routing, "_load_policy_preferences", lambda owner: {
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "backup", "model": "backup-model"},
+ ],
+ })
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_fallback_entries",
+ lambda entries, owner=None, require_exact_model=False: [
+ ("https://backup.example/v1", "backup-model", {"Authorization": "Bearer backup"}),
+ ],
+ )
+
+ async def fake_call(url, model, messages, **kwargs):
+ calls.append((url, model, kwargs.get("headers")))
+ if model == "selected-model":
+ raise HTTPException(503, "selected unavailable")
+ return "backup answer"
+
+ monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
+ endpoint, saved = _chat_endpoint(monkeypatch)
+
+ response = await endpoint(
+ _RouteRequest("chat"),
+ ChatRequest(message="hello", session="session-1"),
+ )
+
+ assert [call[1] for call in calls] == ["selected-model", "backup-model"]
+ assert response == {
+ "response": "backup answer",
+ "requested_model": "selected-model",
+ "model": "backup-model",
+ "requested_endpoint_id": None,
+ "requested_endpoint_label": "Selected route",
+ "endpoint_id": "backup",
+ "endpoint_label": "backup",
+ }
+ assert saved[-1].metadata == {
+ "model": "backup-model",
+ "requested_model": "selected-model",
+ "endpoint_id": "backup",
+ "endpoint_label": "backup",
+ "requested_endpoint_id": None,
+ "requested_endpoint_label": "Selected route",
+ "context_length": 128000,
+ "context_trimmed": False,
+ }
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("primary_context", "backup_context", "expected_counts"),
+ [
+ (100, 1000, (1, 3)),
+ (1000, 100, (3, 1)),
+ ],
+)
+async def test_nonstream_chat_shapes_each_candidate_from_route_neutral_history(
+ monkeypatch,
+ primary_context,
+ backup_context,
+ expected_counts,
+):
+ calls = []
+ monkeypatch.setattr(foreground_model_routing, "_load_policy_preferences", lambda owner: {
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "backup", "model": "backup-model"},
+ ],
+ })
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_fallback_entries",
+ lambda *args, **kwargs: [("https://backup.example/v1", "backup-model", {})],
+ )
+ async def fake_compact(
+ session, url, model, messages, headers=None, owner=None, **kwargs
+ ):
+ return (
+ list(messages),
+ backup_context if model == "backup-model" else primary_context,
+ False,
+ )
+
+ monkeypatch.setattr(chat_routes, "maybe_compact", fake_compact)
+ monkeypatch.setattr(
+ chat_routes,
+ "trim_for_context",
+ lambda messages, budget: list(messages) if budget >= 1000 else list(messages[-1:]),
+ )
+
+ async def fake_call(url, model, messages, **kwargs):
+ calls.append((model, list(messages)))
+ if model == "selected-model":
+ raise HTTPException(503, "selected unavailable")
+ return "backup answer"
+
+ monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
+ endpoint, _saved = _chat_endpoint(monkeypatch)
+
+ response = await endpoint(
+ _RouteRequest("chat"),
+ ChatRequest(message="hello", session="session-1"),
+ )
+
+ assert tuple(len(messages) for _model, messages in calls) == expected_counts
+ assert response["model"] == "backup-model"
+
+
+@pytest.mark.asyncio
+async def test_nonstream_same_model_fallback_persists_endpoint_identity(monkeypatch):
+ monkeypatch.setattr(foreground_model_routing, "_load_policy_preferences", lambda owner: {
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "account-two", "model": "selected-model"},
+ ],
+ })
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_fallback_entries",
+ lambda *args, **kwargs: [(
+ "https://selected.example/v1",
+ "selected-model",
+ {"Authorization": "Bearer account-two"},
+ )],
+ )
+
+ async def fake_call(url, model, messages, **kwargs):
+ if kwargs.get("headers", {}).get("Authorization") == "Bearer selected":
+ raise HTTPException(429, "rate limited")
+ return "second account answer"
+
+ monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
+ endpoint, saved = _chat_endpoint(monkeypatch)
+
+ response = await endpoint(
+ _RouteRequest("chat"),
+ ChatRequest(message="hello", session="session-1"),
+ )
+
+ assert response["requested_model"] == response["model"] == "selected-model"
+ assert response["endpoint_id"] == "account-two"
+ assert saved[-1].metadata["endpoint_id"] == "account-two"
+
+
+@pytest.mark.asyncio
+async def test_nonstream_chat_does_not_fallback_on_ineligible_failure(monkeypatch):
+ calls = []
+ monkeypatch.setattr(foreground_model_routing, "_load_policy_preferences", lambda owner: {
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "backup", "model": "backup-model"},
+ ],
+ })
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_fallback_entries",
+ lambda entries, owner=None, require_exact_model=False: [
+ ("https://backup.example/v1", "backup-model", {}),
+ ],
+ )
+
+ async def fake_call(url, model, messages, **kwargs):
+ calls.append(model)
+ raise HTTPException(401, "invalid key")
+
+ monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
+ endpoint, _saved = _chat_endpoint(monkeypatch)
+
+ with pytest.raises(HTTPException) as exc:
+ await endpoint(
+ _RouteRequest("chat"),
+ ChatRequest(message="hello", session="session-1"),
+ )
+
+ assert exc.value.status_code == 401
+ assert calls == ["selected-model"]
+
+
+@pytest.mark.asyncio
+async def test_nonstream_chat_does_not_fallback_on_endpoint_configuration_error(monkeypatch):
+ calls = []
+ monkeypatch.setattr(foreground_model_routing, "_load_policy_preferences", lambda owner: {
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "backup", "model": "backup-model"},
+ ],
+ })
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_fallback_entries",
+ lambda *args, **kwargs: [("https://backup.example/v1", "backup-model", {})],
+ )
+
+ async def fake_post(client, url, headers, **kwargs):
+ calls.append(url)
+ raise httpx.UnsupportedProtocol("unsupported protocol")
+
+ monkeypatch.setattr(llm_core, "httpx_post_kimi_aware_async", fake_post)
+ monkeypatch.setattr(llm_core, "_is_host_dead", lambda url: False)
+ monkeypatch.setattr(llm_core, "note_model_activity", lambda *args, **kwargs: None)
+ monkeypatch.setattr(llm_core, "_get_cached_response", lambda key: None)
+ endpoint, saved = _chat_endpoint(monkeypatch, endpoint_url="ftp://selected.example")
+
+ with pytest.raises(HTTPException) as exc:
+ await endpoint(
+ _RouteRequest("chat"),
+ ChatRequest(message="hello", session="session-1"),
+ )
+
+ assert exc.value.status_code == 502
+ assert len(calls) == 1
+ assert saved == []
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("primary_body", "expected_status"),
+ [
+ ({"error": {"type": "invalid_request_error", "message": "unsupported model"}}, 400),
+ ({"unexpected": "successful but malformed provider body"}, 502),
+ ],
+)
+async def test_nonstream_chat_real_parser_never_falls_back_on_provider_or_schema_error(
+ monkeypatch,
+ primary_body,
+ expected_status,
+):
+ calls = []
+ monkeypatch.setattr(foreground_model_routing, "_load_policy_preferences", lambda owner: {
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "backup", "model": "backup-model"},
+ ],
+ })
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_fallback_entries",
+ lambda entries, owner=None, require_exact_model=False: [
+ ("https://backup.example/v1", "backup-model", {}),
+ ],
+ )
+
+ class _Response:
+ is_success = True
+ status_code = 200
+ text = ""
+
+ def __init__(self, body):
+ self._body = body
+
+ def json(self):
+ return self._body
+
+ async def fake_post(_client, target_url, _headers, **kwargs):
+ calls.append(target_url)
+ if "selected.example" in target_url:
+ return _Response(primary_body)
+ return _Response({"choices": [{"message": {"content": "backup answer"}}]})
+
+ monkeypatch.setattr(llm_core, "_get_http_client", lambda: object())
+ monkeypatch.setattr(llm_core, "httpx_post_kimi_aware_async", fake_post)
+ monkeypatch.setattr(llm_core, "_is_host_dead", lambda url: False)
+ monkeypatch.setattr(llm_core, "note_model_activity", lambda *args, **kwargs: None)
+ monkeypatch.setattr(llm_core, "_get_cached_response", lambda key: None)
+ monkeypatch.setattr(llm_core, "_set_cached_response", lambda *args, **kwargs: None)
+ endpoint, saved = _chat_endpoint(monkeypatch)
+
+ with pytest.raises(HTTPException) as exc:
+ await endpoint(
+ _RouteRequest("chat"),
+ ChatRequest(message="hello", session="session-1"),
+ )
+
+ assert exc.value.status_code == expected_status
+ assert len(calls) == 1
+ assert "selected.example" in calls[0]
+ assert saved == []
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("use_fallback", [False, True])
+async def test_nonstream_chat_real_parser_persists_provider_model_alias(
+ monkeypatch,
+ use_fallback,
+):
+ calls = []
+ if use_fallback:
+ monkeypatch.setattr(foreground_model_routing, "_load_policy_preferences", lambda owner: {
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "backup", "model": "backup-model"},
+ ],
+ })
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_fallback_entries",
+ lambda *args, **kwargs: [
+ ("https://backup.example/v1", "backup-model", {}),
+ ],
+ )
+ else:
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "_load_policy_preferences",
+ lambda owner: {},
+ )
+
+ class _Response:
+ is_success = True
+ status_code = 200
+ text = ""
+
+ def __init__(self, body):
+ self._body = body
+
+ def json(self):
+ return self._body
+
+ async def fake_post(_client, target_url, _headers, **kwargs):
+ calls.append(target_url)
+ if use_fallback and "selected.example" in target_url:
+ return _Response({
+ "error": {
+ "status": 503,
+ "message": "selected unavailable",
+ },
+ })
+ return _Response({
+ "model": "provider-backup-alias" if use_fallback else "provider-selected-alias",
+ "choices": [{"message": {"content": "provider answer"}}],
+ })
+
+ monkeypatch.setattr(llm_core, "_get_http_client", lambda: object())
+ monkeypatch.setattr(llm_core, "httpx_post_kimi_aware_async", fake_post)
+ monkeypatch.setattr(llm_core, "_is_host_dead", lambda url: False)
+ monkeypatch.setattr(llm_core, "note_model_activity", lambda *args, **kwargs: None)
+ monkeypatch.setattr(llm_core, "_get_cached_response", lambda key: None)
+ monkeypatch.setattr(llm_core, "_set_cached_response", lambda *args, **kwargs: None)
+ endpoint, saved = _chat_endpoint(monkeypatch)
+
+ response = await endpoint(
+ _RouteRequest("chat"),
+ ChatRequest(message="hello", session="session-1"),
+ )
+
+ expected_model = (
+ "provider-backup-alias" if use_fallback else "provider-selected-alias"
+ )
+ assert response["response"] == "provider answer"
+ assert response["model"] == expected_model
+ assert saved[-1].metadata["model"] == expected_model
+ assert response["endpoint_id"] == ("backup" if use_fallback else None)
+ assert len(calls) == (2 if use_fallback else 1)
+
+
+@pytest.mark.asyncio
+async def test_nonstream_chat_does_not_treat_empty_response_as_unavailability(monkeypatch):
+ calls = []
+ monkeypatch.setattr(foreground_model_routing, "_load_policy_preferences", lambda owner: {
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "backup", "model": "backup-model"},
+ ],
+ })
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_fallback_entries",
+ lambda entries, owner=None, require_exact_model=False: [
+ ("https://backup.example/v1", "backup-model", {}),
+ ],
+ )
+
+ async def fake_call(url, model, messages, **kwargs):
+ calls.append(model)
+ return ""
+
+ monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
+ endpoint, _saved = _chat_endpoint(monkeypatch)
+
+ response = await endpoint(
+ _RouteRequest("chat"),
+ ChatRequest(message="hello", session="session-1"),
+ )
+
+ assert calls == ["selected-model"]
+ assert response["model"] == "selected-model"
+
+
+@pytest.mark.asyncio
+async def test_nonstream_named_owner_does_not_inherit_flat_opt_in(monkeypatch):
+ calls = []
+ monkeypatch.setattr(prefs_routes, "_load", lambda: {
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "shared", "model": "shared-model"},
+ ],
+ })
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_fallback_entries",
+ lambda *args, **kwargs: pytest.fail("flat opt-in resolved a candidate for bob"),
+ )
+
+ async def fake_call(url, model, messages, **kwargs):
+ calls.append(model)
+ raise HTTPException(503, "selected unavailable")
+
+ monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
+ endpoint, _saved = _chat_endpoint(monkeypatch, owner="bob")
+
+ with pytest.raises(HTTPException) as exc:
+ await endpoint(
+ _RouteRequest("chat"),
+ ChatRequest(message="hello", session="session-1"),
+ )
+
+ assert exc.value.status_code == 503
+ assert calls == ["selected-model"]
+
+
+def test_candidate_builder_appends_only_policy_authorized_fallbacks():
+ """Chat and Agent share the same candidate-building policy boundary."""
+
+ authorized = [("https://opt-in.example/v1", "opt-in-model", {})]
+ policy = ForegroundModelPolicy(enabled=True, fallback_candidates=tuple(authorized))
+
+ assert build_foreground_model_candidates(
+ "https://selected.example/v1",
+ "selected-model",
+ {"Authorization": "Bearer selected"},
+ owner="alice",
+ policy=policy,
+ ) == [
+ ("https://selected.example/v1", "selected-model", {"Authorization": "Bearer selected"}),
+ *authorized,
+ ]
+
+
+def test_strict_policy_builds_only_the_selected_chat_candidate():
+ candidates = build_foreground_model_candidates(
+ "https://selected.example/v1",
+ "selected-model",
+ {"Authorization": "Bearer selected"},
+ owner="alice",
+ policy=ForegroundModelPolicy(),
+ )
+
+ assert candidates == [
+ ("https://selected.example/v1", "selected-model", {"Authorization": "Bearer selected"})
+ ]
+
+
+def test_legacy_chat_resolver_is_disconnected():
+ assert not hasattr(endpoint_resolver, "resolve_chat_fallback_candidates")
+
+
+def test_retired_silent_endpoint_switcher_is_not_callable():
+ assert not hasattr(chat_helpers, "try_fallback_endpoint")
+
+
+@pytest.mark.parametrize(
+ "prefs",
+ [
+ {},
+ {"foreground_fallback_enabled": False, "foreground_model_fallbacks": [{"endpoint_id": "b", "model": "m"}]},
+ {"foreground_fallback_enabled": "true", "foreground_model_fallbacks": [{"endpoint_id": "b", "model": "m"}]},
+ {"foreground_fallback_enabled": True, "foreground_model_fallbacks": []},
+ {"default_model_fallbacks": [{"endpoint_id": "legacy", "model": "legacy"}]},
+ ],
+)
+def test_foreground_policy_fails_closed_without_explicit_complete_opt_in(monkeypatch, prefs):
+ monkeypatch.setattr(foreground_model_routing, "_load_policy_preferences", lambda owner=None: prefs)
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_fallback_entries",
+ lambda entries, owner=None: pytest.fail("disabled policy resolved fallback entries"),
+ )
+
+ assert resolve_foreground_model_policy("alice") == ForegroundModelPolicy()
+
+
+def test_foreground_policy_resolves_ordered_owner_scoped_entries(monkeypatch):
+ entries = [
+ {"endpoint_id": f"ep-{i}", "model": f"model-{i}"}
+ for i in range(MAX_FOREGROUND_FALLBACKS + 2)
+ ]
+ seen = {}
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "_load_policy_preferences",
+ lambda owner=None: {
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": entries,
+ },
+ )
+
+ def fake_resolve(resolved_entries, owner=None, *, require_exact_model=False):
+ seen["entries"] = resolved_entries
+ seen["owner"] = owner
+ seen["require_exact_model"] = require_exact_model
+ return [("https://backup.example/v1", "backup", {"Authorization": "secret"})]
+
+ monkeypatch.setattr(foreground_model_routing, "resolve_fallback_entries", fake_resolve)
+
+ policy = resolve_foreground_model_policy("alice")
+
+ assert policy.enabled is True
+ assert policy.fallback_candidates == (
+ ("https://backup.example/v1", "backup", {"Authorization": "secret"}),
+ )
+ assert policy.eligible_statuses == FOREGROUND_AVAILABILITY_STATUSES
+ assert policy.fallback_on_empty is False
+ assert seen == {
+ "entries": entries[:MAX_FOREGROUND_FALLBACKS],
+ "owner": "alice",
+ "require_exact_model": True,
+ }
+
+
+def test_foreground_policy_loads_only_the_requested_users_preferences(monkeypatch):
+ by_owner = {
+ "alice": {
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": [{"endpoint_id": "alice-backup", "model": "alice-model"}],
+ },
+ "bob": {
+ "foreground_fallback_enabled": False,
+ "foreground_model_fallbacks": [{"endpoint_id": "bob-backup", "model": "bob-model"}],
+ },
+ }
+ seen = []
+ monkeypatch.setattr(foreground_model_routing, "_load_policy_preferences", lambda owner=None: by_owner[owner])
+
+ def fake_resolve(entries, owner=None, *, require_exact_model=False):
+ seen.append((entries, owner))
+ assert require_exact_model is True
+ return [(f"https://{owner}.example/v1", f"{owner}-model", {})]
+
+ monkeypatch.setattr(foreground_model_routing, "resolve_fallback_entries", fake_resolve)
+
+ assert resolve_foreground_model_policy("alice").enabled is True
+ assert resolve_foreground_model_policy("bob") == ForegroundModelPolicy()
+ assert seen == [([{"endpoint_id": "alice-backup", "model": "alice-model"}], "alice")]
+
+
+def test_named_owner_does_not_inherit_flat_single_user_fallback_consent(monkeypatch):
+ monkeypatch.setattr(prefs_routes, "_load", lambda: {
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "shared-backup", "model": "shared-model"},
+ ],
+ })
+ monkeypatch.setattr(
+ prefs_routes,
+ "_load_for_user",
+ lambda owner=None: pytest.fail("named foreground policy used flat compatibility loader"),
+ )
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_fallback_entries",
+ lambda *args, **kwargs: pytest.fail("flat consent resolved candidate endpoints for bob"),
+ )
+
+ assert resolve_foreground_model_policy("bob") == ForegroundModelPolicy()
+
+
+def test_named_owner_unrelated_save_does_not_import_flat_fallback_consent(
+ monkeypatch,
+ tmp_path,
+):
+ prefs_file = tmp_path / "user_prefs.json"
+ prefs_file.write_text(json.dumps({
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "single-user", "model": "single-model"},
+ ],
+ "default_model_fallbacks": [
+ {"endpoint_id": "legacy", "model": "legacy-model"},
+ ],
+ }), encoding="utf-8")
+ monkeypatch.setattr(prefs_routes, "PREFS_FILE", str(prefs_file))
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_fallback_entries",
+ lambda *args, **kwargs: pytest.fail("Bob inherited flat fallback consent"),
+ )
+
+ assert resolve_foreground_model_policy("bob") == ForegroundModelPolicy()
+ bob = prefs_routes._load_for_user("bob")
+ bob["theme"] = "dark"
+ prefs_routes._save_for_user("bob", bob)
+
+ assert resolve_foreground_model_policy("bob") == ForegroundModelPolicy()
+ raw = prefs_routes._load()
+ assert raw["_users"]["bob"] == {"theme": "dark"}
+ assert raw["default_model_fallbacks"][0]["endpoint_id"] == "legacy"
+
+
+def test_startup_pref_migration_does_not_transfer_flat_fallback_consent(
+ monkeypatch,
+ tmp_path,
+):
+ database_file = tmp_path / "app.db"
+ database_file.touch()
+ (tmp_path / "auth.json").write_text(json.dumps({
+ "users": {
+ "alice": {"is_admin": True},
+ },
+ }), encoding="utf-8")
+ prefs_file = tmp_path / "user_prefs.json"
+ flat_fallbacks = [
+ {"endpoint_id": "single-user", "model": "single-model"},
+ ]
+ legacy_fallbacks = [
+ {"endpoint_id": "legacy", "model": "legacy-model"},
+ ]
+ prefs_file.write_text(json.dumps({
+ "theme": "dark",
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": flat_fallbacks,
+ "default_model_fallbacks": legacy_fallbacks,
+ }), encoding="utf-8")
+ monkeypatch.setattr(database, "DATABASE_URL", f"sqlite:///{database_file}")
+ monkeypatch.setattr(database, "AUTH_FILE", str(tmp_path / "auth.json"))
+ monkeypatch.setattr(database, "MEMORY_FILE", str(tmp_path / "memory.json"))
+ monkeypatch.setattr(database, "USER_PREFS_FILE", str(prefs_file))
+ monkeypatch.setattr(prefs_routes, "PREFS_FILE", str(prefs_file))
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_fallback_entries",
+ lambda *args, **kwargs: pytest.fail(
+ "startup migration transferred flat fallback consent"
+ ),
+ )
+
+ database._migrate_assign_legacy_owner()
+
+ raw = json.loads(prefs_file.read_text(encoding="utf-8"))
+ assert raw["foreground_fallback_enabled"] is True
+ assert raw["foreground_model_fallbacks"] == flat_fallbacks
+ assert raw["_users"]["alice"] == {
+ "theme": "dark",
+ "default_model_fallbacks": legacy_fallbacks,
+ }
+ assert resolve_foreground_model_policy("alice") == ForegroundModelPolicy()
+
+
+def test_auth_disabled_write_in_multiuser_store_does_not_grant_named_consent(
+ monkeypatch,
+ tmp_path,
+):
+ prefs_file = tmp_path / "user_prefs.json"
+ prefs_file.write_text(json.dumps({
+ "_users": {
+ "alice": {"theme": "dark"},
+ "bob": {"theme": "light"},
+ },
+ }), encoding="utf-8")
+ monkeypatch.setattr(prefs_routes, "PREFS_FILE", str(prefs_file))
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_fallback_entries",
+ lambda *args, **kwargs: pytest.fail(
+ "auth-disabled consent was written into a named owner"
+ ),
+ )
+
+ ownerless = prefs_routes._load_for_user(None)
+ ownerless["foreground_fallback_enabled"] = True
+ ownerless["foreground_model_fallbacks"] = [
+ {"endpoint_id": "single-user", "model": "single-model"},
+ ]
+ prefs_routes._save_for_user(None, ownerless)
+
+ raw = prefs_routes._load()
+ assert raw["foreground_fallback_enabled"] is True
+ assert raw["foreground_model_fallbacks"][0]["endpoint_id"] == "single-user"
+ assert raw["_users"]["alice"] == {"theme": "dark"}
+ assert raw["_users"]["bob"] == {"theme": "light"}
+ assert resolve_foreground_model_policy("alice") == ForegroundModelPolicy()
+ assert resolve_foreground_model_policy("bob") == ForegroundModelPolicy()
+
+
+def test_named_owner_resolves_only_an_actual_scoped_preferences_dict(monkeypatch):
+ entry = {"endpoint_id": "alice-backup", "model": "alice-model"}
+ monkeypatch.setattr(prefs_routes, "_load", lambda: {
+ "_users": {
+ "alice": {
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": [entry],
+ },
+ "bob": ["not", "a", "preferences", "dict"],
+ },
+ })
+ seen = []
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_fallback_entries",
+ lambda entries, owner=None, require_exact_model=False: (
+ seen.append((entries, owner, require_exact_model))
+ or [("https://alice.example/v1", "alice-model", {})]
+ ),
+ )
+
+ assert resolve_foreground_model_policy("alice").enabled is True
+ assert resolve_foreground_model_policy("bob") == ForegroundModelPolicy()
+ assert seen == [([entry], "alice", True)]
+
+
+def test_auth_disabled_owner_none_preserves_flat_single_user_policy(monkeypatch):
+ prefs = {
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "single-user-backup", "model": "backup-model"},
+ ],
+ }
+ seen = []
+ monkeypatch.setattr(prefs_routes, "_load_for_user", lambda owner=None: prefs)
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_fallback_entries",
+ lambda entries, owner=None, require_exact_model=False: (
+ seen.append((entries, owner, require_exact_model))
+ or [("https://backup.example/v1", "backup-model", {})]
+ ),
+ )
+
+ policy = resolve_foreground_model_policy(None)
+
+ assert policy.enabled is True
+ assert policy.fallback_candidates == (("https://backup.example/v1", "backup-model", {}),)
+ assert seen == [(
+ [{"endpoint_id": "single-user-backup", "model": "backup-model"}],
+ None,
+ True,
+ )]
+
+
+def test_foreground_policy_filters_models_outside_the_callers_allowlist(monkeypatch):
+ seen = []
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "_load_policy_preferences",
+ lambda owner=None: {
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "allowed", "model": "allowed-model"},
+ {"endpoint_id": "blocked", "model": "blocked-model"},
+ ],
+ },
+ )
+
+ def fake_resolve(entries, owner=None, *, require_exact_model=False):
+ seen.extend(entries)
+ return [("https://allowed.example/v1", "allowed-model", {})]
+
+ monkeypatch.setattr(foreground_model_routing, "resolve_fallback_entries", fake_resolve)
+
+ policy = resolve_foreground_model_policy("alice", allowed_models={"allowed-model"})
+
+ assert policy.enabled is True
+ assert seen == [{"endpoint_id": "allowed", "model": "allowed-model"}]
+
+
+def test_foreground_policy_is_strict_when_all_fallbacks_are_disallowed(monkeypatch):
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "_load_policy_preferences",
+ lambda owner=None: {
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "blocked", "model": "blocked-model"},
+ ],
+ },
+ )
+ monkeypatch.setattr(
+ foreground_model_routing,
+ "resolve_fallback_entries",
+ lambda *args, **kwargs: pytest.fail("disallowed entry reached credential resolution"),
+ )
+
+ assert resolve_foreground_model_policy(
+ "alice",
+ allowed_models={"selected-model"},
+ ) == ForegroundModelPolicy()
+
+
+def test_utility_resolver_does_not_inherit_legacy_chat_fallbacks(monkeypatch):
+ seen_keys = []
+
+ def fake_resolve(setting_key, owner=None):
+ seen_keys.append((setting_key, owner))
+ return [("https://utility.example/v1", "utility-model", {})]
+
+ monkeypatch.setattr(endpoint_resolver, "_resolve_fallback_candidates", fake_resolve)
+
+ assert endpoint_resolver.resolve_utility_fallback_candidates(owner="alice") == [
+ ("https://utility.example/v1", "utility-model", {})
+ ]
+ assert seen_keys == [("utility_model_fallbacks", "alice")]
+
+
+def test_multi_round_agent_uses_only_selected_model(monkeypatch):
+ """Every Agent round receives only the selected foreground candidate."""
+
+ seen_candidates = []
+ round_number = 0
+
+ monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
+ monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
+ monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
+ async def fake_stream(candidates, messages, **kwargs):
+ nonlocal round_number
+ round_number += 1
+ seen_candidates.append([(url, model) for url, model, _headers in candidates])
+ if round_number == 1:
+ call = {"name": "bash", "arguments": json.dumps({"command": "printf ok"})}
+ yield f'data: {json.dumps({"type": "tool_calls", "calls": [call]})}\n\n'
+ else:
+ yield f'data: {json.dumps({"delta": "done"})}\n\n'
+ yield "data: [DONE]\n\n"
+
+ async def fake_execute(block, *args, **kwargs):
+ return "bash", {"output": "ok", "exit_code": 0}
+
+ monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
+ monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute)
+
+ chunks = _collect(
+ agent_loop.stream_agent_loop(
+ "https://selected.example/v1",
+ "selected-model",
+ [{"role": "user", "content": "Run one tool and report back."}],
+ max_rounds=3,
+ relevant_tools={"bash"},
+ fallbacks=[],
+ _is_teacher_run=True,
+ )
+ )
+
+ assert seen_candidates == [
+ [("https://selected.example/v1", "selected-model")],
+ [("https://selected.example/v1", "selected-model")],
+ ]
+ assert any('"delta": "done"' in chunk for chunk in chunks)
+
+
+def test_multi_round_agent_pins_answering_fallback_for_the_run(monkeypatch):
+ """A tool round must not silently switch back to the selected model."""
+
+ seen_candidates = []
+ round_number = 0
+ primary = ("https://selected.example/v1", "selected-model", {})
+ backup = ("https://backup.example/v1", "backup-model", {"Authorization": "backup"})
+ route_modes = []
+
+ monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
+ monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
+ monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
+ monkeypatch.setattr(
+ agent_loop,
+ "_agent_route_tool_mode",
+ lambda url, model, owner=None, headers=None: route_modes.append((url, model, owner, headers)) or (model == "selected-model", False, False),
+ )
+
+ async def fake_stream(candidates, messages, **kwargs):
+ nonlocal round_number
+ round_number += 1
+ seen_candidates.append(candidates)
+ assert kwargs["fallback_statuses"] == FOREGROUND_AVAILABILITY_STATUSES
+ assert kwargs["fallback_on_empty"] is False
+ if round_number == 1:
+ yield f'data: {json.dumps({"type": "fallback", "selected_model": "selected-model", "answered_by": "backup-model", "candidate_index": 1})}\n\n'
+ call = {"name": "bash", "arguments": json.dumps({"command": "printf ok"})}
+ yield f'data: {json.dumps({"type": "tool_calls", "calls": [call]})}\n\n'
+ else:
+ yield f'data: {json.dumps({"delta": "done"})}\n\n'
+ yield "data: [DONE]\n\n"
+
+ async def fake_execute(block, *args, **kwargs):
+ return "bash", {"output": "ok", "exit_code": 0}
+
+ monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
+ monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute)
+
+ chunks = _collect(
+ agent_loop.stream_agent_loop(
+ primary[0],
+ primary[1],
+ [{"role": "user", "content": "Run one tool and report back."}],
+ max_rounds=3,
+ relevant_tools={"bash"},
+ headers={},
+ fallbacks=[backup],
+ fallback_statuses=FOREGROUND_AVAILABILITY_STATUSES,
+ fallback_on_empty=False,
+ _is_teacher_run=True,
+ )
+ )
+
+ assert seen_candidates == [[primary, backup], [backup]]
+ fallback_event = next(chunk for chunk in chunks if '"type": "fallback"' in chunk)
+ fallback_data = json.loads(fallback_event[6:])
+ assert fallback_data["pinned_for_run"] is True
+ assert fallback_data["round"] == 1
+ metrics_event = next(chunk for chunk in chunks if '"type": "metrics"' in chunk)
+ metrics = json.loads(metrics_event[6:])["data"]
+ assert metrics["requested_model"] == "selected-model"
+ assert metrics["model"] == "backup-model"
+ assert metrics["round_models"] == ["backup-model", "backup-model"]
+ assert metrics["tool_events"][0]["model"] == "backup-model"
+ assert route_modes == [
+ ("https://selected.example/v1", "selected-model", None, {}),
+ ("https://backup.example/v1", "backup-model", None, {"Authorization": "backup"}),
+ ]
+
+
+def test_late_agent_fallback_records_each_round_and_stays_pinned(monkeypatch):
+ seen_candidates = []
+ round_number = 0
+ primary = ("https://selected.example/v1", "selected-model", {})
+ backup = ("https://backup.example/v1", "backup-model", {})
+
+ monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
+ monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
+ monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
+ monkeypatch.setattr(agent_loop, "_agent_route_tool_mode", lambda url, model, owner=None, headers=None: (True, False, False))
+
+ async def fake_stream(candidates, messages, **kwargs):
+ nonlocal round_number
+ round_number += 1
+ seen_candidates.append(candidates)
+ if round_number == 1:
+ yield 'data: {"delta": "primary round"}\n\n'
+ call = {"name": "bash", "arguments": json.dumps({"command": "printf one"})}
+ yield f'data: {json.dumps({"type": "tool_calls", "calls": [call]})}\n\n'
+ elif round_number == 2:
+ yield f'data: {json.dumps({"type": "fallback", "selected_model": "selected-model", "answered_by": "backup-model", "candidate_index": 1})}\n\n'
+ yield f'data: {json.dumps({"type": "model_actual", "requested_model": "backup-model", "model": "provider-backup-alias"})}\n\n'
+ yield 'data: {"delta": "backup round"}\n\n'
+ call = {"name": "bash", "arguments": json.dumps({"command": "printf two"})}
+ yield f'data: {json.dumps({"type": "tool_calls", "calls": [call]})}\n\n'
+ else:
+ yield 'data: {"delta": "backup final"}\n\n'
+ yield "data: [DONE]\n\n"
+
+ async def fake_execute(block, *args, **kwargs):
+ return "bash", {"output": "ok", "exit_code": 0}
+
+ monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
+ monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute)
+
+ chunks = _collect(
+ agent_loop.stream_agent_loop(
+ primary[0],
+ primary[1],
+ [{"role": "user", "content": "Run two tools and report back."}],
+ headers=primary[2],
+ max_rounds=4,
+ relevant_tools={"bash"},
+ fallbacks=[backup],
+ fallback_statuses=FOREGROUND_AVAILABILITY_STATUSES,
+ fallback_on_empty=False,
+ _is_teacher_run=True,
+ )
+ )
+
+ assert seen_candidates == [[primary, backup], [primary, backup], [backup]]
+ fallback_data = json.loads(next(chunk for chunk in chunks if '"type": "fallback"' in chunk)[6:])
+ assert fallback_data["round"] == 2
+ model_actual = json.loads(next(chunk for chunk in chunks if '"type": "model_actual"' in chunk)[6:])
+ assert model_actual["round"] == 2
+ assert model_actual["requested_model"] == "selected-model"
+ assert model_actual["model"] == "provider-backup-alias"
+ metrics = json.loads(next(chunk for chunk in chunks if '"type": "metrics"' in chunk)[6:])["data"]
+ assert metrics["round_models"] == ["selected-model", "provider-backup-alias", "backup-model"]
+ assert [event["model"] for event in metrics["tool_events"]] == [
+ "selected-model",
+ "provider-backup-alias",
+ ]
+
+
+@pytest.mark.parametrize("status", [400, 401, 404])
+def test_agent_terminal_first_round_error_has_no_success_completion(monkeypatch, status):
+ calls = 0
+ monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
+ monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
+ monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
+
+ async def fake_stream(candidates, messages, **kwargs):
+ nonlocal calls
+ calls += 1
+ yield f'event: error\ndata: {json.dumps({"status": status, "error": "provider rejected request"})}\n\n'
+
+ monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
+
+ chunks = _collect(
+ agent_loop.stream_agent_loop(
+ "https://selected.example/v1",
+ "selected-model",
+ [{"role": "user", "content": "hello"}],
+ max_rounds=3,
+ relevant_tools=set(),
+ fallback_statuses=FOREGROUND_AVAILABILITY_STATUSES,
+ fallback_on_empty=False,
+ _is_teacher_run=True,
+ )
+ )
+
+ assert calls == 1
+ assert any(chunk.startswith("event: error") for chunk in chunks)
+ assert not any('"type": "metrics"' in chunk for chunk in chunks), chunks
+ assert "data: [DONE]\n\n" not in chunks
+ assert not any("empty response" in chunk.lower() for chunk in chunks)
+
+
+@pytest.mark.parametrize(
+ ("provider_status", "expected_status", "expected_message"),
+ [
+ (400, 400, "Model request failed (HTTP 400)"),
+ (429.9, None, "Model request failed"),
+ ],
+)
+def test_agent_terminal_later_round_error_stops_after_completed_tool(
+ monkeypatch,
+ provider_status,
+ expected_status,
+ expected_message,
+):
+ calls = 0
+ monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
+ monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
+ monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
+ monkeypatch.setattr(
+ agent_loop,
+ "_agent_route_tool_mode",
+ lambda *args, **kwargs: (True, False, False),
+ )
+
+ async def fake_stream(candidates, messages, **kwargs):
+ nonlocal calls
+ calls += 1
+ if calls == 1:
+ tool_call = {
+ "name": "bash",
+ "arguments": json.dumps({"command": "printf one"}),
+ }
+ yield f'data: {json.dumps({"type": "tool_calls", "calls": [tool_call]})}\n\n'
+ yield "data: [DONE]\n\n"
+ return
+ yield 'data: {"delta": "partial second-round prose"}\n\n'
+ yield f'event: error\ndata: {json.dumps({"status": provider_status, "error": "unsupported model"})}\n\n'
+
+ async def fake_execute(block, *args, **kwargs):
+ return "bash", {"output": "ok", "exit_code": 0}
+
+ monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
+ monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute)
+
+ chunks = _collect(
+ agent_loop.stream_agent_loop(
+ "https://selected.example/v1",
+ "selected-model",
+ [{"role": "user", "content": "Run one tool."}],
+ max_rounds=3,
+ relevant_tools={"bash"},
+ fallback_statuses=FOREGROUND_AVAILABILITY_STATUSES,
+ fallback_on_empty=False,
+ _is_teacher_run=True,
+ )
+ )
+
+ assert calls == 2
+ assert sum('"type": "agent_step"' in chunk for chunk in chunks) == 1
+ terminal = json.loads(next(
+ chunk for chunk in chunks if '"type": "agent_terminal"' in chunk
+ )[6:])["data"]
+ assert terminal["failed"] is True
+ assert terminal["failure"]["status"] == expected_status
+ assert terminal["tool_events"][0]["output"] == "ok"
+ assert terminal["failure"] == {
+ "status": expected_status,
+ "message": expected_message,
+ }
+ assert terminal["round_models"] == ["selected-model", "selected-model"]
+ assert terminal["round_texts"][-1] == (
+ "partial second-round prose\n\n"
+ f"[Agent stopped: {expected_message}]"
+ )
+ assert any(chunk.startswith("event: error") for chunk in chunks)
+ assert not any('"type": "metrics"' in chunk for chunk in chunks)
+ assert "data: [DONE]\n\n" not in chunks
+ assert not any("empty response" in chunk.lower() for chunk in chunks)
+
+
+@pytest.mark.parametrize(
+ ("provider_status", "expected_status", "expected_message"),
+ [
+ (503, 503, "Model request failed (HTTP 503)"),
+ (429.9, None, "Model request failed"),
+ ],
+)
+def test_direct_low_signal_partial_error_emits_terminal_history(
+ monkeypatch,
+ provider_status,
+ expected_status,
+ expected_message,
+):
+ monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
+ monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
+ monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
+ monkeypatch.setattr(
+ agent_loop,
+ "_classify_agent_request",
+ lambda messages, latest: {
+ "low_signal": True,
+ "continuation": False,
+ "domains": [],
+ "retrieval_query": latest,
+ },
+ )
+ monkeypatch.setattr(agent_loop, "_is_casual_low_signal", lambda latest: True)
+
+ async def fake_stream(candidates, messages, **kwargs):
+ yield 'data: {"delta": "visible direct partial"}\n\n'
+ yield f'event: error\ndata: {json.dumps({"status": provider_status, "error": "provider detail"})}\n\n'
+
+ monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
+
+ chunks = _collect(agent_loop.stream_agent_loop(
+ "https://selected.example/v1",
+ "selected-model",
+ [{"role": "user", "content": "hello"}],
+ relevant_tools=set(),
+ route_descriptors=[{
+ "endpoint_id": "selected",
+ "endpoint_label": "Selected",
+ "endpoint_cost_tracked": True,
+ }],
+ fallback_statuses=FOREGROUND_AVAILABILITY_STATUSES,
+ fallback_on_empty=False,
+ _is_teacher_run=True,
+ ))
+
+ terminal = json.loads(next(
+ chunk for chunk in chunks if '"type": "agent_terminal"' in chunk
+ )[6:])["data"]
+ assert terminal["round_texts"] == [
+ "visible direct partial\n\n"
+ f"[Agent stopped: {expected_message}]"
+ ]
+ assert terminal["failure"] == {
+ "status": expected_status,
+ "message": expected_message,
+ }
+ assert terminal["endpoint_cost_tracked"] is True
+ assert terminal["usage_buckets"][0]["endpoint_cost_tracked"] is True
+ assert any(chunk.startswith("event: error") for chunk in chunks)
+ assert "data: [DONE]\n\n" not in chunks
+
+
+@pytest.mark.parametrize("terminal_error", [False, True])
+def test_direct_low_signal_fallback_estimates_winning_route_prompt(
+ monkeypatch,
+ terminal_error,
+):
+ primary = ("https://selected.example/v1", "generic-model", {})
+ backup = ("https://backup.example/v1", "odysseus-qwen-backup", {})
+ candidate_requests = []
+
+ monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
+ monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
+ monkeypatch.setattr(
+ agent_loop,
+ "_classify_agent_request",
+ lambda messages, latest: {
+ "low_signal": True,
+ "continuation": False,
+ "domains": [],
+ "retrieval_query": latest,
+ },
+ )
+ monkeypatch.setattr(agent_loop, "_is_casual_low_signal", lambda latest: True)
+ monkeypatch.setattr(
+ agent_loop,
+ "_is_odysseus_qwen_model",
+ lambda candidate_model: candidate_model == backup[1],
+ )
+ monkeypatch.setattr(
+ agent_loop,
+ "_minimal_odysseus_general_messages",
+ lambda messages, include_memory=True: [
+ {"role": "system", "content": "larger backup route prompt"},
+ *list(messages),
+ ],
+ )
+ monkeypatch.setattr(
+ agent_loop,
+ "estimate_tokens",
+ lambda request_messages: (
+ 99
+ if any(
+ message.get("content") == "larger backup route prompt"
+ for message in request_messages
+ )
+ else 7
+ ),
+ )
+
+ async def fake_stream(candidates, messages, **kwargs):
+ assert messages == [{"role": "user", "content": "hello"}]
+ request = kwargs["candidate_request_factory"](1, *backup)
+ candidate_requests.append(request["messages"])
+ fallback_event = {
+ "type": "fallback",
+ "selected_model": primary[1],
+ "answered_by": backup[1],
+ "candidate_index": 1,
+ "selected_endpoint_id": "selected",
+ "selected_endpoint_label": "Selected",
+ "selected_endpoint_cost_tracked": False,
+ "answered_by_endpoint_id": "backup",
+ "answered_by_endpoint_label": "Backup",
+ "answered_by_endpoint_cost_tracked": True,
+ }
+ yield "data: " + json.dumps(fallback_event) + "\n\n"
+ yield 'data: {"type": "usage", "data": {"input_tokens": null, "output_tokens": 1}}\n\n'
+ yield 'data: {"delta": "backup response"}\n\n'
+ if terminal_error:
+ yield 'event: error\ndata: {"status": 503, "error": "unavailable"}\n\n'
+ else:
+ yield "data: [DONE]\n\n"
+
+ monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
+
+ chunks = _collect(agent_loop.stream_agent_loop(
+ primary[0],
+ primary[1],
+ [{"role": "user", "content": "hello"}],
+ relevant_tools=set(),
+ fallbacks=[backup],
+ route_descriptors=[
+ {
+ "endpoint_id": "selected",
+ "endpoint_label": "Selected",
+ "endpoint_cost_tracked": False,
+ },
+ {
+ "endpoint_id": "backup",
+ "endpoint_label": "Backup",
+ "endpoint_cost_tracked": True,
+ },
+ ],
+ fallback_statuses=FOREGROUND_AVAILABILITY_STATUSES,
+ fallback_on_empty=False,
+ _is_teacher_run=True,
+ ))
+
+ assert candidate_requests[0][0] == {
+ "role": "system",
+ "content": "larger backup route prompt",
+ }
+ event_type = "agent_terminal" if terminal_error else "metrics"
+ accounted = json.loads(next(
+ chunk for chunk in chunks if f'"type": "{event_type}"' in chunk
+ )[6:])["data"]
+ assert accounted["model"] == backup[1]
+ assert accounted["endpoint_id"] == "backup"
+ assert accounted["input_tokens"] == 99
+ assert accounted["usage_buckets"] == [{
+ "round": 1,
+ "model": backup[1],
+ "endpoint_id": "backup",
+ "endpoint_label": "Backup",
+ "input_tokens": 99,
+ "output_tokens": len("backup response") // 4,
+ "usage_source": "estimated",
+ "endpoint_cost_tracked": True,
+ }]
+
+
+def test_direct_low_signal_configuration_error_surfaces_without_fake_success(monkeypatch):
+ monkeypatch.setattr(
+ agent_loop,
+ "get_setting",
+ lambda key, default=None: (
+ "not-an-int" if key == "agent_stream_timeout_seconds" else default
+ ),
+ )
+ monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
+ monkeypatch.setattr(
+ agent_loop,
+ "_classify_agent_request",
+ lambda messages, latest: {
+ "low_signal": True,
+ "continuation": False,
+ "domains": [],
+ "retrieval_query": latest,
+ },
+ )
+ monkeypatch.setattr(agent_loop, "_is_casual_low_signal", lambda latest: True)
+
+ chunks = _collect(agent_loop.stream_agent_loop(
+ "https://selected.example/v1",
+ "selected-model",
+ [{"role": "user", "content": "hello"}],
+ relevant_tools=set(),
+ fallback_statuses=FOREGROUND_AVAILABILITY_STATUSES,
+ fallback_on_empty=False,
+ _is_teacher_run=True,
+ ))
+
+ assert len(chunks) == 1
+ assert chunks[0].startswith("event: error")
+ payload = json.loads(chunks[0].split("data: ", 1)[1])
+ assert payload == {
+ "error": "Model request failed",
+ "status": 500,
+ "fallback_eligible": False,
+ }
+ assert not any('"delta": "Hey."' in chunk for chunk in chunks)
+ assert not any('"type": "metrics"' in chunk for chunk in chunks)
+ assert "data: [DONE]\n\n" not in chunks
+
+
+def test_direct_low_signal_empty_completion_surfaces_without_fake_success(monkeypatch):
+ monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
+ monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
+ monkeypatch.setattr(
+ agent_loop,
+ "_classify_agent_request",
+ lambda messages, latest: {
+ "low_signal": True,
+ "continuation": False,
+ "domains": [],
+ "retrieval_query": latest,
+ },
+ )
+ monkeypatch.setattr(agent_loop, "_is_casual_low_signal", lambda latest: True)
+
+ async def empty_stream(*args, **kwargs):
+ yield "data: [DONE]\n\n"
+
+ monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", empty_stream)
+ chunks = _collect(agent_loop.stream_agent_loop(
+ "https://selected.example/v1",
+ "selected-model",
+ [{"role": "user", "content": "hello"}],
+ relevant_tools=set(),
+ fallback_statuses=FOREGROUND_AVAILABILITY_STATUSES,
+ fallback_on_empty=False,
+ _is_teacher_run=True,
+ ))
+
+ assert len(chunks) == 1
+ payload = json.loads(chunks[0].split("data: ", 1)[1])
+ assert payload["error"] == "Model returned an empty response"
+ assert payload["fallback_eligible"] is False
+ assert not any('"delta": "Hey."' in chunk for chunk in chunks)
+ assert not any('"type": "metrics"' in chunk for chunk in chunks)
+
+
+def test_reasoning_only_agent_error_emits_terminal_history(monkeypatch):
+ monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
+ monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
+ monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
+
+ async def fake_stream(candidates, messages, **kwargs):
+ yield 'data: {"delta": "private reasoning partial", "thinking": true}\n\n'
+ yield 'event: error\ndata: {"status": 504, "error": "provider detail"}\n\n'
+
+ monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
+
+ chunks = _collect(agent_loop.stream_agent_loop(
+ "https://selected.example/v1",
+ "selected-model",
+ [{"role": "user", "content": "Investigate this failure."}],
+ relevant_tools={"bash"},
+ fallback_statuses=FOREGROUND_AVAILABILITY_STATUSES,
+ fallback_on_empty=False,
+ _is_teacher_run=True,
+ ))
+
+ terminal = json.loads(next(
+ chunk for chunk in chunks if '"type": "agent_terminal"' in chunk
+ )[6:])["data"]
+ assert terminal["thinking"] == "private reasoning partial"
+ assert terminal["round_texts"] == [
+ "[Agent stopped: Model request failed (HTTP 504)]"
+ ]
+ assert any(chunk.startswith("event: error") for chunk in chunks)
+ assert "data: [DONE]\n\n" not in chunks
+
+
+def test_toolless_multi_round_agent_persists_round_route_provenance(monkeypatch):
+ calls = 0
+ primary = ("https://selected.example/v1", "selected-model", {})
+ backup = ("https://backup.example/v1", "backup-model", {})
+ monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
+ monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
+ monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
+
+ async def fake_stream(candidates, messages, **kwargs):
+ nonlocal calls
+ calls += 1
+ if calls == 1:
+ yield 'data: {"delta": "Let me check that now"}\n\n'
+ else:
+ yield 'data: {"type": "fallback", "selected_model": "selected-model", "answered_by": "backup-model", "candidate_index": 1, "selected_endpoint_id": "selected-ep", "selected_endpoint_label": "Selected endpoint", "selected_endpoint_cost_tracked": false, "answered_by_endpoint_id": "backup-ep", "answered_by_endpoint_label": "Backup endpoint", "answered_by_endpoint_cost_tracked": true}\n\n'
+ yield 'data: {"delta": "final answer"}\n\n'
+ yield "data: [DONE]\n\n"
+
+ monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
+
+ chunks = _collect(agent_loop.stream_agent_loop(
+ primary[0],
+ primary[1],
+ [{"role": "user", "content": "Please investigate."}],
+ headers=primary[2],
+ max_rounds=3,
+ relevant_tools=set(),
+ fallbacks=[backup],
+ route_descriptors=[
+ {"endpoint_id": "selected-ep", "endpoint_label": "Selected endpoint", "endpoint_cost_tracked": False},
+ {"endpoint_id": "backup-ep", "endpoint_label": "Backup endpoint", "endpoint_cost_tracked": True},
+ ],
+ fallback_statuses=FOREGROUND_AVAILABILITY_STATUSES,
+ fallback_on_empty=False,
+ _is_teacher_run=True,
+ ))
+
+ metrics = json.loads(next(
+ chunk for chunk in chunks if '"type": "metrics"' in chunk
+ )[6:])["data"]
+ assert metrics["round_texts"] == ["Let me check that now", "final answer"]
+ assert metrics["round_models"] == ["selected-model", "backup-model"]
+ assert metrics["round_endpoint_ids"] == ["selected-ep", "backup-ep"]
+ assert metrics["endpoint_id"] == "backup-ep"
+ assert metrics["requested_endpoint_id"] == "selected-ep"
+ assert metrics["endpoint_cost_tracked"] is True
+ assert "tool_events" not in metrics
+
+
+def test_agent_metrics_attribute_usage_to_each_answering_route(monkeypatch):
+ calls = 0
+ primary = ("https://paid.example/v1", "selected-model", {})
+ backup = ("http://localhost:11434/v1", "backup-model", {})
+ monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
+ monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
+ monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
+ monkeypatch.setattr(
+ agent_loop,
+ "_agent_route_tool_mode",
+ lambda *args, **kwargs: (True, False, False),
+ )
+
+ async def fake_stream(candidates, messages, **kwargs):
+ nonlocal calls
+ calls += 1
+ if calls == 1:
+ yield 'data: {"type": "model_actual", "model": "selected-alias"}\n\n'
+ yield 'data: {"type": "usage", "data": {"model": "selected-alias", "input_tokens": 100, "output_tokens": 10}}\n\n'
+ tool_call = {
+ "name": "bash",
+ "arguments": json.dumps({"command": "printf one"}),
+ }
+ yield f'data: {json.dumps({"type": "tool_calls", "calls": [tool_call]})}\n\n'
+ else:
+ yield 'data: {"type": "fallback", "selected_model": "selected-model", "answered_by": "backup-model", "candidate_index": 1, "selected_endpoint_id": "paid", "selected_endpoint_label": "Paid", "selected_endpoint_cost_tracked": true, "answered_by_endpoint_id": "local", "answered_by_endpoint_label": "Local", "answered_by_endpoint_cost_tracked": false}\n\n'
+ yield 'data: {"type": "model_actual", "model": "backup-alias"}\n\n'
+ yield 'data: {"type": "usage", "data": {"model": "backup-alias", "input_tokens": 200, "output_tokens": 20}}\n\n'
+ yield 'data: {"delta": "done"}\n\n'
+ yield "data: [DONE]\n\n"
+
+ async def fake_execute(block, *args, **kwargs):
+ return "bash", {"output": "ok", "exit_code": 0}
+
+ monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
+ monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute)
+
+ chunks = _collect(agent_loop.stream_agent_loop(
+ primary[0],
+ primary[1],
+ [{"role": "user", "content": "Run one tool."}],
+ headers=primary[2],
+ max_rounds=3,
+ relevant_tools={"bash"},
+ fallbacks=[backup],
+ route_descriptors=[
+ {"endpoint_id": "paid", "endpoint_label": "Paid", "endpoint_cost_tracked": True},
+ {"endpoint_id": "local", "endpoint_label": "Local", "endpoint_cost_tracked": False},
+ ],
+ fallback_statuses=FOREGROUND_AVAILABILITY_STATUSES,
+ fallback_on_empty=False,
+ _is_teacher_run=True,
+ ))
+
+ metrics = json.loads(next(
+ chunk for chunk in chunks if '"type": "metrics"' in chunk
+ )[6:])["data"]
+ assert metrics["input_tokens"] == 300
+ assert metrics["output_tokens"] == 30
+ assert metrics["usage_source"] == "real"
+ assert metrics["usage_buckets"] == [
+ {
+ "round": 1,
+ "model": "selected-alias",
+ "endpoint_id": "paid",
+ "endpoint_label": "Paid",
+ "input_tokens": 100,
+ "output_tokens": 10,
+ "usage_source": "real",
+ "endpoint_cost_tracked": True,
+ },
+ {
+ "round": 2,
+ "model": "backup-alias",
+ "endpoint_id": "local",
+ "endpoint_label": "Local",
+ "input_tokens": 200,
+ "output_tokens": 20,
+ "usage_source": "real",
+ "endpoint_cost_tracked": False,
+ },
+ ]
+
+
+@pytest.mark.parametrize("malformed_input", [None, "bad", 10**1000])
+def test_agent_round_ignores_malformed_usage_and_uses_estimate(
+ monkeypatch,
+ malformed_input,
+):
+ monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
+ monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
+ monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
+ monkeypatch.setattr(
+ agent_loop,
+ "_agent_route_tool_mode",
+ lambda *args, **kwargs: (True, False, False),
+ )
+
+ async def fake_stream(candidates, messages, **kwargs):
+ usage_event = {
+ "type": "usage",
+ "data": {
+ "input_tokens": malformed_input,
+ "output_tokens": 1,
+ },
+ }
+ yield "data: " + json.dumps(usage_event) + "\n\n"
+ yield 'data: {"delta": "valid answer"}\n\n'
+ yield "data: [DONE]\n\n"
+
+ monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
+
+ chunks = _collect(agent_loop.stream_agent_loop(
+ "https://selected.example/v1",
+ "selected-model",
+ [{"role": "user", "content": "Run a detailed investigation."}],
+ max_rounds=1,
+ relevant_tools={"bash"},
+ route_descriptors=[{
+ "endpoint_id": "selected",
+ "endpoint_label": "Selected",
+ "endpoint_cost_tracked": True,
+ }],
+ fallback_statuses=FOREGROUND_AVAILABILITY_STATUSES,
+ fallback_on_empty=False,
+ _is_teacher_run=True,
+ ))
+
+ metrics = json.loads(next(
+ chunk for chunk in chunks if '"type": "metrics"' in chunk
+ )[6:])["data"]
+ assert metrics["usage_source"] == "estimated"
+ assert metrics["input_tokens"] == 10
+ assert metrics["usage_buckets"][0]["input_tokens"] == 10
+ assert metrics["usage_buckets"][0]["output_tokens"] == len("valid answer") // 4
+
+
+@pytest.mark.parametrize(
+ ("synthesis_result", "expected_answer"),
+ [
+ ("Recovered final answer.", "Recovered final answer."),
+ (
+ "",
+ "I gathered some search results but couldn't pull a clean answer together. "
+ "Want me to try a more specific question, or summarize what I did find?",
+ ),
+ ],
+)
+def test_force_answer_recovery_persists_and_bills_pinned_fallback_route(
+ monkeypatch,
+ synthesis_result,
+ expected_answer,
+):
+ primary = ("https://selected.example/v1", "selected-model", {})
+ backup = (
+ "https://backup.example/v1",
+ "backup-model",
+ {"Authorization": "Bearer backup"},
+ )
+ requests_by_round = []
+ synthesis_calls = []
+
+ monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
+ monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
+ monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
+ monkeypatch.setattr(agent_loop, "blocked_tools_for_owner", lambda owner: set())
+ monkeypatch.setattr(
+ agent_loop,
+ "_agent_route_tool_mode",
+ lambda *args, **kwargs: (True, False, False),
+ )
+
+ async def fake_compact(
+ session, url, model, messages, headers=None, owner=None,
+ *, persist=True, compaction_state=None,
+ ):
+ return (list(messages), 4096, False)
+
+ async def fake_stream(candidates, messages, **kwargs):
+ round_index = len(requests_by_round)
+ requests_by_round.append([(url, model) for url, model, _ in candidates])
+ factory = kwargs["candidate_request_factory"]
+ for index, candidate in enumerate(candidates):
+ await factory(index, *candidate)
+ if round_index == 0:
+ fallback_event = {
+ "type": "fallback",
+ "selected_model": primary[1],
+ "answered_by": backup[1],
+ "candidate_index": 1,
+ "selected_endpoint_id": "selected-ep",
+ "selected_endpoint_label": "Selected",
+ "selected_endpoint_cost_tracked": False,
+ "answered_by_endpoint_id": "backup-ep",
+ "answered_by_endpoint_label": "Backup",
+ "answered_by_endpoint_cost_tracked": True,
+ }
+ yield "data: " + json.dumps(fallback_event) + "\n\n"
+ tool_call = {
+ "name": "bash",
+ "arguments": json.dumps({"command": "printf repeated"}),
+ }
+ yield f'data: {json.dumps({"type": "tool_calls", "calls": [tool_call]})}\n\n'
+ yield "data: [DONE]\n\n"
+
+ async def fake_execute(block, *args, **kwargs):
+ return "bash", {"output": "same result", "exit_code": 0}
+
+ async def fake_synthesis(**kwargs):
+ synthesis_calls.append(kwargs)
+ return synthesis_result
+
+ monkeypatch.setattr(agent_loop, "maybe_compact", fake_compact)
+ monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
+ monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute)
+ monkeypatch.setattr(llm_core, "llm_call_async", fake_synthesis)
+
+ chunks = _collect(agent_loop.stream_agent_loop(
+ primary[0],
+ primary[1],
+ [{"role": "user", "content": "Keep checking until you can answer."}],
+ headers=primary[2],
+ max_rounds=6,
+ relevant_tools={"bash"},
+ fallbacks=[backup],
+ route_descriptors=[
+ {
+ "endpoint_id": "selected-ep",
+ "endpoint_label": "Selected",
+ "endpoint_cost_tracked": False,
+ },
+ {
+ "endpoint_id": "backup-ep",
+ "endpoint_label": "Backup",
+ "endpoint_cost_tracked": True,
+ },
+ ],
+ fallback_statuses=FOREGROUND_AVAILABILITY_STATUSES,
+ fallback_on_empty=False,
+ _is_teacher_run=True,
+ ))
+
+ assert requests_by_round[0] == [
+ (primary[0], primary[1]),
+ (backup[0], backup[1]),
+ ]
+ assert requests_by_round[1:] == [[(backup[0], backup[1])]] * 5
+ assert len(synthesis_calls) == 1
+ assert synthesis_calls[0]["url"] == backup[0]
+ assert synthesis_calls[0]["model"] == backup[1]
+ assert synthesis_calls[0]["headers"] == backup[2]
+
+ metrics = json.loads(next(
+ chunk for chunk in chunks if '"type": "metrics"' in chunk
+ )[6:])["data"]
+ assert metrics["round_texts"][-1] == expected_answer
+ assert metrics["round_models"][-1] == backup[1]
+ assert metrics["round_endpoint_ids"][-1] == "backup-ep"
+ assert metrics["usage_buckets"][-1] == {
+ "round": 6,
+ "model": backup[1],
+ "endpoint_id": "backup-ep",
+ "endpoint_label": "Backup",
+ "input_tokens": 10,
+ "output_tokens": len(synthesis_result) // 4,
+ "usage_source": "estimated",
+ "endpoint_cost_tracked": True,
+ }
+ assert len(metrics["usage_buckets"]) == 7
+
+
+def test_agent_terminal_retains_completed_paid_fallback_usage(monkeypatch):
+ calls = 0
+ primary = ("http://localhost:11434/v1", "selected-model", {})
+ backup = ("https://paid.example/v1", "backup-model", {})
+ monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
+ monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
+ monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
+ monkeypatch.setattr(
+ agent_loop,
+ "_agent_route_tool_mode",
+ lambda *args, **kwargs: (True, False, False),
+ )
+
+ async def fake_stream(candidates, messages, **kwargs):
+ nonlocal calls
+ calls += 1
+ if calls == 1:
+ yield 'data: {"type": "fallback", "selected_model": "selected-model", "answered_by": "backup-model", "candidate_index": 1, "selected_endpoint_id": "local", "selected_endpoint_label": "Local", "selected_endpoint_cost_tracked": false, "answered_by_endpoint_id": "paid", "answered_by_endpoint_label": "Paid", "answered_by_endpoint_cost_tracked": true}\n\n'
+ yield 'data: {"type": "usage", "data": {"model": "backup-model", "input_tokens": 125, "output_tokens": 25}}\n\n'
+ tool_call = {
+ "name": "bash",
+ "arguments": json.dumps({"command": "printf one"}),
+ }
+ yield f'data: {json.dumps({"type": "tool_calls", "calls": [tool_call]})}\n\n'
+ yield "data: [DONE]\n\n"
+ return
+ yield 'event: error\ndata: {"status": 400, "error": "unsupported model"}\n\n'
+
+ async def fake_execute(block, *args, **kwargs):
+ return "bash", {"output": "ok", "exit_code": 0}
+
+ monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
+ monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute)
+
+ chunks = _collect(agent_loop.stream_agent_loop(
+ primary[0],
+ primary[1],
+ [{"role": "user", "content": "Run one tool."}],
+ headers=primary[2],
+ max_rounds=3,
+ relevant_tools={"bash"},
+ fallbacks=[backup],
+ route_descriptors=[
+ {"endpoint_id": "local", "endpoint_label": "Local", "endpoint_cost_tracked": False},
+ {"endpoint_id": "paid", "endpoint_label": "Paid", "endpoint_cost_tracked": True},
+ ],
+ fallback_statuses=FOREGROUND_AVAILABILITY_STATUSES,
+ fallback_on_empty=False,
+ _is_teacher_run=True,
+ ))
+
+ terminal = json.loads(next(
+ chunk for chunk in chunks if '"type": "agent_terminal"' in chunk
+ )[6:])["data"]
+ assert terminal["input_tokens"] == 125
+ assert terminal["output_tokens"] == 25
+ assert terminal["usage_source"] == "real"
+ assert terminal["usage_buckets"] == [{
+ "round": 1,
+ "model": "backup-model",
+ "endpoint_id": "paid",
+ "endpoint_label": "Paid",
+ "input_tokens": 125,
+ "output_tokens": 25,
+ "usage_source": "real",
+ "endpoint_cost_tracked": True,
+ }]
+ assert not any('"type": "metrics"' in chunk for chunk in chunks)
+
+
+def test_agent_builds_backup_prompt_and_tool_transport_before_attempt(monkeypatch):
+ requests = []
+ primary = ("https://selected.example/v1", "selected-model", {})
+ backup = ("http://localhost:11434/api/chat", "backup-model", {})
+
+ monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
+ monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
+ monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
+ monkeypatch.setattr(agent_loop, "blocked_tools_for_owner", lambda owner: set())
+ monkeypatch.setattr(
+ agent_loop,
+ "_agent_route_tool_mode",
+ lambda url, model, owner=None, headers=None: (model == "selected-model", model == "backup-model", False),
+ )
+
+ def fake_build(messages, model, *args, **kwargs):
+ return (
+ list(messages) + [{
+ "role": "system",
+ "content": f"route prompt for {model}",
+ "_agent_injected": "prompt",
+ }],
+ [],
+ )
+
+ monkeypatch.setattr(agent_loop, "_build_system_prompt", fake_build)
+
+ async def fake_stream(candidates, messages, **kwargs):
+ factory = kwargs["candidate_request_factory"]
+ requests.extend([
+ await factory(index, *candidate)
+ for index, candidate in enumerate(candidates)
+ ])
+ yield f'data: {json.dumps({"type": "fallback", "selected_model": primary[1], "answered_by": backup[1], "candidate_index": 1})}\n\n'
+ yield 'data: {"delta": "backup answer"}\n\n'
+ yield "data: [DONE]\n\n"
+
+ monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
+
+ chunks = _collect(
+ agent_loop.stream_agent_loop(
+ primary[0],
+ primary[1],
+ [{"role": "user", "content": "Use bash if needed."}],
+ headers=primary[2],
+ max_rounds=1,
+ relevant_tools={"bash"},
+ fallbacks=[backup],
+ fallback_statuses=FOREGROUND_AVAILABILITY_STATUSES,
+ fallback_on_empty=False,
+ _is_teacher_run=True,
+ )
+ )
+
+ assert requests[0]["kwargs"]["tools"]
+ assert requests[1]["kwargs"]["tools"] is None
+ backup_contents = [message.get("content") for message in requests[1]["messages"]]
+ assert "route prompt for backup-model" in backup_contents
+ assert "route prompt for selected-model" not in backup_contents
+ assert any('"delta": "backup answer"' in chunk for chunk in chunks)
+
+
+@pytest.mark.parametrize(
+ ("primary_context", "backup_context", "expected_fallback_message_count"),
+ [
+ (1000, 100, 2),
+ (100, 1000, 22),
+ ],
+)
+def test_agent_fallback_request_uses_candidate_context_budget(
+ monkeypatch,
+ primary_context,
+ backup_context,
+ expected_fallback_message_count,
+):
+ requests_by_round = []
+ context_lookups = []
+ trim_budgets = []
+ round_number = 0
+ primary = ("https://selected.example/v1", "selected-model", {})
+ backup = ("https://backup.example/v1", "backup-model", {})
+ latest_user = "LATEST USER TURN MUST SURVIVE"
+ history = [
+ {"role": "user" if index % 2 == 0 else "assistant", "content": f"history-{index}"}
+ for index in range(20)
+ ] + [{"role": "user", "content": latest_user}]
+
+ monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
+ monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
+ monkeypatch.setattr(agent_loop, "estimate_tokens", lambda messages: len(messages) * 10)
+ monkeypatch.setattr(agent_loop, "blocked_tools_for_owner", lambda owner: set())
+ monkeypatch.setattr(
+ agent_loop,
+ "_agent_route_tool_mode",
+ lambda *args, **kwargs: (True, False, False),
+ )
+
+ def fake_build(messages, model, *args, **kwargs):
+ return ([{
+ "role": "system",
+ "content": f"route prompt for {model}",
+ "_agent_injected": "prompt",
+ }] + list(messages), [])
+
+ monkeypatch.setattr(agent_loop, "_build_system_prompt", fake_build)
+
+ import src.context_budget as context_budget
+ import src.context_compactor as context_compactor
+ import src.model_context as model_context
+
+ def fake_context(candidate_url, candidate_model, fallback=0):
+ context_lookups.append((candidate_url, candidate_model, fallback))
+ return backup_context if candidate_model == "backup-model" else primary_context
+
+ def fake_compute(soft_budget, candidate_context, explicit, hard_max=None):
+ return candidate_context
+
+ def fake_trim(messages, effective_budget, reserve_tokens=0):
+ trim_budgets.append(effective_budget)
+ if effective_budget != 100:
+ return list(messages)
+ route_prompt = next(
+ message for message in messages
+ if message.get("_agent_injected") == "prompt"
+ )
+ current_user = next(
+ message for message in reversed(messages)
+ if message.get("role") == "user"
+ )
+ return [route_prompt, current_user]
+
+ monkeypatch.setattr(model_context, "budget_context_for_model", fake_context)
+ monkeypatch.setattr(context_budget, "compute_input_token_budget", fake_compute)
+ monkeypatch.setattr(context_budget, "budget_is_explicit", lambda value: False)
+ monkeypatch.setattr(context_compactor, "trim_for_context", fake_trim)
+
+ async def fake_stream(candidates, messages, **kwargs):
+ nonlocal round_number
+ round_number += 1
+ factory = kwargs["candidate_request_factory"]
+ requests = [
+ await factory(index, *candidate)
+ for index, candidate in enumerate(candidates)
+ ]
+ requests_by_round.append(requests)
+ if round_number == 1:
+ yield f'data: {json.dumps({"type": "fallback", "selected_model": primary[1], "answered_by": backup[1], "candidate_index": 1})}\n\n'
+ tool_call = {
+ "name": "bash",
+ "arguments": json.dumps({"command": "printf one"}),
+ }
+ yield f'data: {json.dumps({"type": "tool_calls", "calls": [tool_call]})}\n\n'
+ else:
+ yield 'data: {"delta": "pinned backup answer"}\n\n'
+ yield "data: [DONE]\n\n"
+
+ async def fake_execute(block, *args, **kwargs):
+ return "bash", {"output": "ok", "exit_code": 0}
+
+ monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
+ monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute)
+
+ chunks = _collect(
+ agent_loop.stream_agent_loop(
+ primary[0],
+ primary[1],
+ history,
+ headers=primary[2],
+ max_rounds=2,
+ relevant_tools={"bash"},
+ fallbacks=[backup],
+ fallback_statuses=FOREGROUND_AVAILABILITY_STATUSES,
+ fallback_on_empty=False,
+ context_length=4096,
+ _is_teacher_run=True,
+ )
+ )
+
+ assert [(url, model) for url, model, _fallback in context_lookups] == [
+ (primary[0], primary[1]),
+ (backup[0], backup[1]),
+ (backup[0], backup[1]),
+ ]
+ assert trim_budgets == [primary_context, backup_context, backup_context]
+ fallback_messages = requests_by_round[0][1]["messages"]
+ assert len(fallback_messages) == expected_fallback_message_count
+ assert fallback_messages[0]["content"] == "route prompt for backup-model"
+ assert any(
+ message == {"role": "user", "content": latest_user}
+ for message in fallback_messages
+ )
+ assert all("selected-model" not in str(message) for message in fallback_messages)
+ pinned_messages = requests_by_round[1][0]["messages"]
+ assert any(
+ message == {"role": "user", "content": latest_user}
+ for message in pinned_messages
+ )
+ assert pinned_messages[0]["content"] == "route prompt for backup-model"
+ metrics = json.loads(next(
+ chunk for chunk in chunks if '"type": "metrics"' in chunk
+ )[6:])["data"]
+ assert metrics["context_length"] == backup_context
+
+
+def test_agent_persists_only_answering_route_compaction(monkeypatch):
+ primary = ("https://selected.example/v1", "selected-model", {})
+ backup = ("https://backup.example/v1", "backup-model", {})
+ applied = []
+
+ monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
+ monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
+ monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
+ monkeypatch.setattr(agent_loop, "blocked_tools_for_owner", lambda owner: set())
+
+ async def fake_compact(
+ session, url, model, messages, headers=None, owner=None,
+ *, persist=True, compaction_state=None,
+ ):
+ assert persist is False
+ compaction_state.update({"route": model, "applied": False})
+ return (list(messages), 1000, True)
+
+ def fake_apply(session, state):
+ if not state or state.get("applied"):
+ return False
+ state["applied"] = True
+ applied.append(state["route"])
+ return True
+
+ async def fake_stream(candidates, messages, **kwargs):
+ factory = kwargs["candidate_request_factory"]
+ for index, candidate in enumerate(candidates):
+ await factory(index, *candidate)
+ yield f'data: {json.dumps({"type": "fallback", "selected_model": primary[1], "answered_by": backup[1], "candidate_index": 1})}\n\n'
+ yield 'data: {"delta": "backup answer"}\n\n'
+ yield "data: [DONE]\n\n"
+
+ monkeypatch.setattr(agent_loop, "maybe_compact", fake_compact)
+ monkeypatch.setattr(agent_loop, "apply_compaction_state", fake_apply)
+ monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
+
+ _collect(
+ agent_loop.stream_agent_loop(
+ primary[0],
+ primary[1],
+ [{"role": "user", "content": "Run a command after checking the route."}],
+ headers=primary[2],
+ history_session=object(),
+ max_rounds=1,
+ relevant_tools={"bash"},
+ fallbacks=[backup],
+ fallback_statuses=FOREGROUND_AVAILABILITY_STATUSES,
+ fallback_on_empty=False,
+ _is_teacher_run=True,
+ )
+ )
+
+ assert applied == ["backup-model"]
+
+
+def test_agent_deferred_compaction_survives_duplicate_primary_fallback(monkeypatch):
+ primary = ("https://selected.example/v1", "selected-model", {})
+ compacted_routes = []
+
+ monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
+ monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
+ monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
+ monkeypatch.setattr(agent_loop, "blocked_tools_for_owner", lambda owner: set())
+
+ async def fake_compact(
+ session, url, model, messages, headers=None, owner=None,
+ *, persist=True, compaction_state=None,
+ ):
+ assert persist is False
+ compacted_routes.append((url, model))
+ return (list(messages), 1000, False)
+
+ async def fake_stream(candidates, messages, **kwargs):
+ assert candidates == [primary]
+ request = await kwargs["candidate_request_factory"](0, *primary)
+ assert request["messages"]
+ yield 'data: {"delta": "answer"}\n\n'
+ yield "data: [DONE]\n\n"
+
+ monkeypatch.setattr(agent_loop, "maybe_compact", fake_compact)
+ monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
+
+ chunks = _collect(agent_loop.stream_agent_loop(
+ primary[0],
+ primary[1],
+ [{"role": "user", "content": "Investigate this."}],
+ headers=primary[2],
+ relevant_tools={"bash"},
+ fallbacks=[primary],
+ defer_context_shaping=True,
+ max_rounds=1,
+ fallback_statuses=FOREGROUND_AVAILABILITY_STATUSES,
+ fallback_on_empty=False,
+ _is_teacher_run=True,
+ ))
+
+ assert compacted_routes == [(primary[0], primary[1])]
+ assert any('"delta": "answer"' in chunk for chunk in chunks)
+
+
+def test_skill_activation_reaches_later_fallback_request_and_pinned_round(monkeypatch):
+ requests_by_round = []
+ round_number = 0
+ primary = ("https://selected.example/v1", "selected-model", {})
+ backup = ("https://backup.example/v1", "odysseus-qwen-backup", {})
+
+ monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
+ monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
+ monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
+ monkeypatch.setattr(agent_loop, "blocked_tools_for_owner", lambda owner: set())
+ monkeypatch.setattr(
+ agent_loop,
+ "_is_odysseus_qwen_model",
+ lambda model: model == backup[1],
+ )
+ monkeypatch.setattr(
+ agent_loop,
+ "_agent_route_tool_mode",
+ lambda url, model, owner=None, headers=None: (
+ model == "selected-model",
+ False,
+ False,
+ ),
+ )
+
+ def fake_build(messages, model, *args, **kwargs):
+ route_tools = sorted(kwargs.get("relevant_tools") or [])
+ return (
+ list(messages) + [{
+ "role": "system",
+ "content": f"route={model}; tools={','.join(route_tools)}",
+ "_agent_injected": "prompt",
+ }],
+ [],
+ )
+
+ monkeypatch.setattr(agent_loop, "_build_system_prompt", fake_build)
+
+ import services.memory.skills as skills_module
+ import src.tool_policy as tool_policy
+
+ class FakeSkillsManager:
+ def __init__(self, data_dir):
+ pass
+
+ def load(self, owner=None):
+ return [{
+ "name": "runtime-skill",
+ "requires_toolsets": ["grep"],
+ }]
+
+ def get_relevant_skills(self, *args, **kwargs):
+ return []
+
+ monkeypatch.setattr(skills_module, "SkillsManager", FakeSkillsManager)
+ monkeypatch.setattr(tool_policy, "known_tool_names", lambda: {"manage_skills", "grep"})
+
+ async def fake_stream(candidates, messages, **kwargs):
+ nonlocal round_number
+ round_number += 1
+ factory = kwargs["candidate_request_factory"]
+ requests = [
+ await factory(index, *candidate)
+ for index, candidate in enumerate(candidates)
+ ]
+ requests_by_round.append((list(candidates), requests))
+
+ if round_number == 1:
+ call = {
+ "name": "manage_skills",
+ "arguments": json.dumps({"action": "view", "name": "runtime-skill"}),
+ }
+ yield f'data: {json.dumps({"type": "tool_calls", "calls": [call]})}\n\n'
+ elif round_number == 2:
+ yield f'data: {json.dumps({"type": "fallback", "selected_model": primary[1], "answered_by": backup[1], "candidate_index": 1})}\n\n'
+ call = {
+ "name": "grep",
+ "arguments": json.dumps({"pattern": "needle", "path": "."}),
+ }
+ yield f'data: {json.dumps({"type": "tool_calls", "calls": [call]})}\n\n'
+ else:
+ yield 'data: {"delta": "pinned backup answer"}\n\n'
+ yield "data: [DONE]\n\n"
+
+ async def fake_execute(block, *args, **kwargs):
+ return block.tool_type, {"output": "ok", "exit_code": 0}
+
+ monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
+ monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute)
+
+ chunks = _collect(
+ agent_loop.stream_agent_loop(
+ primary[0],
+ primary[1],
+ [{"role": "user", "content": "Load runtime-skill, then use it."}],
+ headers=primary[2],
+ max_rounds=3,
+ relevant_tools={"manage_skills"},
+ fallbacks=[backup],
+ fallback_statuses=FOREGROUND_AVAILABILITY_STATUSES,
+ fallback_on_empty=False,
+ _is_teacher_run=True,
+ )
+ )
+
+ round_two_candidates, round_two_requests = requests_by_round[1]
+ assert round_two_candidates == [primary, backup]
+ primary_schema_names = {
+ schema["function"]["name"]
+ for schema in round_two_requests[0]["kwargs"]["tools"]
+ }
+ assert "grep" in primary_schema_names
+ assert round_two_requests[1]["kwargs"]["tools"] is None
+ assert any(
+ "route=odysseus-qwen-backup; tools=grep,manage_skills" in (message.get("content") or "")
+ for message in round_two_requests[1]["messages"]
+ )
+
+ round_three_candidates, round_three_requests = requests_by_round[2]
+ assert round_three_candidates == [backup]
+ assert any(
+ "route=odysseus-qwen-backup; tools=grep,manage_skills" in (message.get("content") or "")
+ for message in round_three_requests[0]["messages"]
+ )
+ assert any('"delta": "pinned backup answer"' in chunk for chunk in chunks)
diff --git a/tests/test_legacy_default_fallback_ui.py b/tests/test_legacy_default_fallback_ui.py
index 8e5fe5c9b..a34bf2aa6 100644
--- a/tests/test_legacy_default_fallback_ui.py
+++ b/tests/test_legacy_default_fallback_ui.py
@@ -8,15 +8,15 @@
_REPO = Path(__file__).resolve().parents[1]
-def test_legacy_default_fallback_editor_is_hidden():
+def test_legacy_default_fallback_editor_is_absent():
soup = BeautifulSoup(
(_REPO / "static" / "index.html").read_text(encoding="utf-8"),
"html.parser",
)
editor = soup.find(id="set-defaultFallbacks")
- assert editor is not None
- assert editor.find_parent(class_="settings-row").has_attr("hidden")
+ assert editor is None
+ assert soup.find(id="set-defaultAddFallback") is None
def test_default_model_save_does_not_rewrite_legacy_fallbacks():
@@ -27,3 +27,5 @@ def test_default_model_save_does_not_rewrite_legacy_fallbacks():
assert "settings.default_model_fallbacks" not in default_chat_source
assert "default_model_fallbacks:" not in default_chat_source
+ assert "set-defaultFallbacks" not in default_chat_source
+ assert "set-defaultAddFallback" not in default_chat_source
diff --git a/tests/test_live_fallback_round_attribution.py b/tests/test_live_fallback_round_attribution.py
new file mode 100644
index 000000000..13e8d81c8
--- /dev/null
+++ b/tests/test_live_fallback_round_attribution.py
@@ -0,0 +1,284 @@
+"""Source contract for live multi-round fallback attribution."""
+
+import json
+from pathlib import Path
+import shutil
+import subprocess
+
+import pytest
+
+
+CHAT_JS = Path("static/js/chat.js").read_text(encoding="utf-8")
+_HAS_NODE = shutil.which("node") is not None
+
+
+def _resume_function_source():
+ body = CHAT_JS.split("export async function resumeStream", 1)[1].split(
+ "export function checkBackgroundStream", 1
+ )[0]
+ return "async function resumeStream" + body.rstrip()
+
+
+def _run_node(source):
+ proc = subprocess.run(
+ ["node", "--input-type=module"],
+ input=source,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout.strip())
+
+
+def test_live_fallback_targets_the_active_round_and_replaces_actual_model():
+ fallback_block = CHAT_JS.split("json.type === 'fallback'", 1)[1].split(
+ "json.type === 'doc_stream_open'", 1
+ )[0]
+
+ assert "applyModelRouteEventState(json, holder, roundHolder, modelName)" in fallback_block
+ assert "_fallbackHolder.querySelector('.role')" in fallback_block
+ assert "_hasResolvedActual" not in fallback_block
+
+
+def test_provider_alias_uses_the_same_round_aware_holder_selection():
+ actual_block = CHAT_JS.split("json.type === 'model_actual'", 1)[1].split(
+ "json.type === 'attachments'", 1
+ )[0]
+
+ assert "applyModelRouteEventState(json, holder, roundHolder, modelName)" in actual_block
+ assert "_modelHolder.querySelector('.role')" in actual_block
+
+
+def test_new_round_and_final_metrics_target_the_active_round():
+ agent_step_block = CHAT_JS.split("} else if (json.type === 'agent_step')", 1)[1].split(
+ "json.type === 'budget_exceeded'", 1
+ )[0]
+ metrics_block = CHAT_JS.split("json.type === 'metrics'", 1)[1].split(
+ "json.type === 'message_saved'", 1
+ )[0]
+ final_block = CHAT_JS.split("const _isBgFinal", 1)[1].split(
+ "holder.dataset.raw", 1
+ )[0]
+
+ assert "inheritModelRouteState(holder, roundHolder, newWrap" in agent_step_block
+ assert "applyModelMetricsState(metrics, holder, roundHolder, modelName)" in metrics_block
+ assert "_finalModelHolder.querySelector('.role')" in final_block
+ assert "holder.querySelector('.role')" not in final_block
+
+
+def test_terminal_sse_error_bypasses_eof_auto_recovery():
+ parser_block = CHAT_JS.split("if (_nextIsError || json.status >= 400)", 1)[1].split(
+ "if (json.delta", 1
+ )[0]
+ completion_gate = CHAT_JS.split("if (_streamTerminalError)", 1)[1].split(
+ "if (!_streamSawDone)", 1
+ )[0]
+ recovery_block = CHAT_JS.split("isRecoverableStreamError(err)", 1)[1].split(
+ "const errorHolder", 1
+ )[0]
+
+ assert "createTerminalStreamError(json)" in parser_block
+ assert "throw _streamTerminalError" in completion_gate
+ assert "if (err.terminalStreamError)" in recovery_block
+ assert "await sessionModule.selectSession(streamSessionId, { showLoading: false })" in recovery_block
+
+
+def test_connection_recovery_resumes_detached_run_without_resubmitting_selected_model():
+ recovery = CHAT_JS.split("function _tryAutoRecover", 1)[1].split(
+ "function _removeStallBanner", 1
+ )[0]
+
+ assert "await resumeStream(sessionId, holder || null)" in recovery
+ assert "/api/chat_stream" not in recovery
+ assert ".click()" not in recovery
+ assert "_pendingContinue" not in recovery
+ assert "if (_streamSessionId === streamSessionId) _streamSessionId = null" in CHAT_JS
+
+
+def test_detached_resume_reloads_canonical_terminal_failures():
+ resume = CHAT_JS.split("export async function resumeStream", 1)[1].split(
+ "export function checkBackgroundStream", 1
+ )[0]
+
+ assert "l.trim() === 'event: error'" in resume
+ assert "json.type === 'agent_terminal'" in resume
+ assert "rich = true" in resume
+ assert "Network drop or parse failure: fall through to the canonical reload" in resume
+ assert "if (onThisSession && !rich && roundText.trim())" in resume
+ assert "res.headers.get('X-Odysseus-Run-Id')" in resume
+ assert "chatRenderer.recordSessionMetricsCost(metricsData, sessionId)" in resume
+
+
+@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
+def test_detached_resume_surfaces_fallback_then_provider_alias_before_reload():
+ source = "\n".join([
+ "import { applyModelRouteEventState } from './static/js/chatModelProvenance.js';",
+ "class Element {",
+ " constructor(tag = 'div') { this.tag = tag; this.children = []; this.parentNode = null; this.style = {}; this.textContent = ''; this._html = ''; }",
+ " appendChild(child) { child.parentNode = this; this.children.push(child); return child; }",
+ " remove() { if (!this.parentNode) return; this.parentNode.children = this.parentNode.children.filter(c => c !== this); this.parentNode = null; }",
+ " set innerHTML(value) {",
+ " this._html = value;",
+ " if (value.includes('stream-content')) {",
+ " this._role = new Element('div'); this._role.parentNode = this;",
+ " this._body = new Element('div'); this._body.parentNode = this;",
+ " this._content = new Element('div'); this._body.appendChild(this._content);",
+ " }",
+ " }",
+ " get innerHTML() { return this._html; }",
+ " querySelector(selector) { if (selector === '.role') return this._role || null; if (selector === '.body') return this._body || null; if (selector === '.stream-content') return this._content || null; return null; }",
+ "}",
+ "const box = new Element('main');",
+ "const document = { getElementById(id) { return id === 'chat-history' ? box : null; }, createElement(tag) { return new Element(tag); } };",
+ "const window = {};",
+ "let selectCalls = 0; const labels = []; const toasts = [];",
+ "const sessionModule = { getSessions() { return [{id: 's1', model: 'selected-model'}]; }, getCurrentSessionId() { return 's1'; }, selectSession() { selectCalls += 1; }, loadSessions() {} };",
+ "const uiModule = { esc(value) { return String(value); }, scrollHistory() {}, showToast(value) { toasts.push(value); } };",
+ "const spinnerModule = { create() { return { element: null, createElement() { this.element = new Element('spinner'); return this.element; }, start() {}, destroy() { if (this.element) this.element.remove(); } }; } };",
+ "const markdownModule = { normalizeThinkingMarkup(v) { return v; }, mdToHtml(v) { return v; }, squashOutsideCode(v) { return v; } };",
+ "const documentModule = null; const chatRenderer = { recordSessionMetricsCost() {}, addMessage() {} };",
+ "const _resumingStreams = new Set(); const _streamRunIds = new Map(); const API_BASE = '';",
+ "function hasActiveStream() { return false; } function _shortModel(v) { return v; } function _applyModelColor() {}",
+ "function _setRoleModelLabel(role, requested, actual) { labels.push({requested, actual}); role.textContent = requested + ' -> ' + actual; }",
+ "function _streamDisplayText(v) { return v; } function _showDocumentWritingStatus() {} function _finishDocumentWritingStatus() {} function _metricsCostRecordId() { return 'run'; }",
+ "const events = [",
+ " 'data: {\"type\":\"fallback\",\"selected_model\":\"selected-model\",\"answered_by\":\"fallback-model\",\"reason\":\"429\"}\\n\\n',",
+ " 'data: {\"type\":\"model_actual\",\"model\":\"provider/fallback-alias\"}\\n\\n',",
+ " 'data: {\"delta\":\"hello\"}\\n\\n',",
+ " 'data: [DONE]\\n\\n',",
+ "].join('');",
+ "const encoded = new TextEncoder().encode(events); let reads = 0;",
+ "const reader = { async read() { return reads++ === 0 ? {done:false, value:encoded} : {done:true}; }, async cancel() {} };",
+ "async function fetch() { return { ok:true, body:{getReader(){return reader;}}, headers:{get(){return 'run-1';}} }; }",
+ _resume_function_source(),
+ "await resumeStream('s1');",
+ "console.log(JSON.stringify({labels, toasts, selectCalls, holderCount: box.children.length}));",
+ ])
+
+ assert _run_node(source) == {
+ "labels": [
+ {"requested": "selected-model", "actual": "fallback-model"},
+ {"requested": "selected-model", "actual": "provider/fallback-alias"},
+ ],
+ "toasts": ["Fallback: selected-model failed — answered by fallback-model"],
+ "selectCalls": 1,
+ "holderCount": 0,
+ }
+
+
+@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
+def test_detached_resume_renders_preoutput_error_without_empty_reload():
+ source = "\n".join([
+ "import { createTerminalStreamError } from './static/js/chatStreamErrors.js';",
+ "class Element {",
+ " constructor(tag = 'div') { this.tag = tag; this.children = []; this.parentNode = null; this.style = {}; this.textContent = ''; this._html = ''; }",
+ " appendChild(child) { child.parentNode = this; this.children.push(child); return child; }",
+ " remove() { if (!this.parentNode) return; this.parentNode.children = this.parentNode.children.filter(c => c !== this); this.parentNode = null; }",
+ " set innerHTML(value) {",
+ " this._html = value;",
+ " if (value.includes('stream-content')) {",
+ " this._role = new Element('div'); this._role.parentNode = this;",
+ " this._body = new Element('div'); this._body.parentNode = this;",
+ " this._content = new Element('div'); this._body.appendChild(this._content);",
+ " }",
+ " }",
+ " get innerHTML() { return this._html; }",
+ " querySelector(selector) { if (selector === '.role') return this._role || null; if (selector === '.body') return this._body || null; if (selector === '.stream-content') return this._content || null; return null; }",
+ "}",
+ "const box = new Element('main');",
+ "const document = { getElementById(id) { return id === 'chat-history' ? box : null; }, createElement(tag) { return new Element(tag); } };",
+ "const window = {};",
+ "let selectCalls = 0;",
+ "const sessionModule = { getSessions() { return [{id: 's1', model: 'selected'}]; }, getCurrentSessionId() { return 's1'; }, selectSession() { selectCalls += 1; }, loadSessions() {} };",
+ "const uiModule = { esc(value) { return String(value); }, scrollHistory() {} };",
+ "const spinnerModule = { create() { return { element: null, createElement() { this.element = new Element('spinner'); return this.element; }, start() {}, destroy() { if (this.element) this.element.remove(); } }; } };",
+ "const markdownModule = { normalizeThinkingMarkup(v) { return v; }, mdToHtml(v) { return v; }, squashOutsideCode(v) { return v; } };",
+ "const documentModule = null;",
+ "const chatRenderer = { recordSessionMetricsCost() {}, addMessage() {} };",
+ "const _resumingStreams = new Set(); const _streamRunIds = new Map(); const API_BASE = '';",
+ "function hasActiveStream() { return false; } function _shortModel(v) { return v; } function _applyModelColor() {}",
+ "function _streamDisplayText(v) { return v; } function _showDocumentWritingStatus() {} function _finishDocumentWritingStatus() {} function _metricsCostRecordId() { return 'run'; }",
+ "const encoded = new TextEncoder().encode('event: error\\ndata: {\"status\":401,\"error\":\"invalid key \"}\\n\\n');",
+ "let reads = 0; const reader = { async read() { return reads++ === 0 ? {done:false, value:encoded} : {done:true}; }, async cancel() {} };",
+ "async function fetch() { return { ok:true, body:{getReader(){return reader;}}, headers:{get(){return 'run-1';}} }; }",
+ _resume_function_source(),
+ "const result = await resumeStream('s1');",
+ "const holder = box.children[0]; const errorNode = holder && holder._content.children.find(node => node.textContent.startsWith('[Error:'));",
+ "console.log(JSON.stringify({result, selectCalls, holderCount: box.children.length, errorText: errorNode && errorNode.textContent}));",
+ ])
+
+ assert _run_node(source) == {
+ "result": True,
+ "selectCalls": 0,
+ "holderCount": 1,
+ "errorText": "[Error: invalid key ]",
+ }
+
+
+def test_terminal_then_session_switch_preserves_completed_background_state():
+ terminal = CHAT_JS.split(
+ "json.type === 'agent_terminal' || json.type === 'chat_terminal'", 1
+ )[1].split("json.type === 'metrics'", 1)[0]
+ detach = CHAT_JS.split("export function detachCurrentStream", 1)[1].split(
+ "export async function resumeStream", 1
+ )[0]
+ background_catch = CHAT_JS.split("if (_isBgCatch)", 1)[1].split(
+ "} else {", 1
+ )[0]
+
+ assert "_terminalSavedStreams.add(streamSessionId)" in terminal
+ assert "terminalSaved ? 'completed' : 'running'" in detach
+ assert "!terminalSaved && sessionModule && sessionModule.markStreaming" in detach
+ assert "_terminalSavedStreams.has(streamSessionId)" in background_catch
+
+
+def test_detached_run_identity_is_attached_to_live_metrics():
+ routes = Path("routes/chat_routes.py").read_text(encoding="utf-8")
+ assert "headers={\"X-Odysseus-Run-Id\": _detached_run.run_id}" in routes
+ assert "agent_runs.subscribe(session, _detached_run)" in routes
+ assert "agent_runs.subscribe(session_id, _active_run)" in routes
+ assert "const streamRunId = res.headers.get('X-Odysseus-Run-Id')" in CHAT_JS
+ assert "metrics._costRecordId = _metricsCostRecordId(streamRunId, json)" in CHAT_JS
+ assert "'X-Odysseus-Run-Id': runId" in CHAT_JS
+ assert "agent_runs.stop(session_id, _expected_run_id)" in routes
+ assert "_stopExactRun(streamSessionId)" in CHAT_JS
+ timeout_block = CHAT_JS.split("timeoutId = setTimeout", 1)[1].split(
+ "clearResponseTimeout", 1
+ )[0]
+ assert "/api/chat/stop/" not in timeout_block
+
+
+def test_replay_cost_identity_distinguishes_primary_and_teacher_segments():
+ identity = CHAT_JS.split("function _metricsCostRecordId", 1)[1].split("\n }", 1)[0]
+ resume = CHAT_JS.split("export async function resumeStream", 1)[1].split(
+ "export function checkBackgroundStream", 1
+ )[0]
+
+ assert "event.teacher ? 'teacher' : 'primary'" in identity
+ assert "_metricsCostRecordId(resumeRunId, json)" in resume
+ metrics_block = resume.split("json.type === 'metrics'", 1)[1].split(
+ "json.type === 'agent_terminal'", 1
+ )[0]
+ assert "chatRenderer.recordSessionMetricsCost(metricsData, sessionId)" in metrics_block
+
+ routes = Path("routes/chat_routes.py").read_text(encoding="utf-8")
+ route_metrics = routes.split('elif data.get("type") == "metrics"', 1)[1].split(
+ "except json.JSONDecodeError", 1
+ )[0]
+ assert 'if data.get("teacher") is True' in route_metrics
+ assert '_metrics_event["teacher"] = True' in route_metrics
+
+
+def test_foreground_terminal_error_reloads_saved_partial_without_typewriter_race():
+ parser = CHAT_JS.split("if (_nextIsError || json.status >= 400)", 1)[1].split(
+ "if (json.delta", 1
+ )[0]
+ terminal_catch = CHAT_JS.split("if (err.terminalStreamError)", 1)[1].split(
+ "const errorHolder", 1
+ )[0]
+
+ assert "typewriterInto" not in parser
+ assert "json.type === 'agent_terminal'" in CHAT_JS
+ assert "_canonicalTerminalSaved = true" in CHAT_JS
+ assert "await sessionModule.selectSession(streamSessionId, { showLoading: false })" in terminal_catch
diff --git a/tests/test_llm_core_fallback.py b/tests/test_llm_core_fallback.py
index af99b73b7..5b820853b 100644
--- a/tests/test_llm_core_fallback.py
+++ b/tests/test_llm_core_fallback.py
@@ -8,12 +8,46 @@
import json
import asyncio
+import httpx
import pytest
+from fastapi import HTTPException
from src import llm_core
-def _run_fallback(monkeypatch, per_model):
+class _ProviderResponse:
+ def __init__(self, lines):
+ self._lines = lines
+ self.status_code = 200
+
+ async def aiter_lines(self):
+ for line in self._lines:
+ yield line
+
+ async def aread(self):
+ return b""
+
+
+class _ProviderStreamContext:
+ def __init__(self, lines):
+ self._lines = lines
+
+ async def __aenter__(self):
+ return _ProviderResponse(self._lines)
+
+ async def __aexit__(self, *args):
+ return False
+
+
+class _ProviderClient:
+ def __init__(self, lines):
+ self._lines = lines
+
+ def stream(self, method, url, **kwargs):
+ return _ProviderStreamContext(self._lines)
+
+
+def _run_fallback(monkeypatch, per_model, **fallback_kwargs):
"""Drive stream_llm_with_fallback with a stubbed stream_llm that returns a
canned SSE line list per candidate model. Returns the emitted chunks."""
async def fake_stream(url, model, messages, **kw):
@@ -24,7 +58,9 @@ async def fake_stream(url, model, messages, **kw):
async def run():
out = []
async for c in llm_core.stream_llm_with_fallback(
- [("u1", "primary", {}), ("u2", "backup", {})], [{"role": "user", "content": "hi"}]
+ [("u1", "primary", {}), ("u2", "backup", {})],
+ [{"role": "user", "content": "hi"}],
+ **fallback_kwargs,
):
out.append(c)
return out
@@ -32,6 +68,26 @@ async def run():
return asyncio.run(run())
+def _run_provider_stream(monkeypatch, url, lines):
+ monkeypatch.setattr(llm_core, "_get_http_client", lambda: _ProviderClient(lines))
+ monkeypatch.setattr(llm_core, "_is_host_dead", lambda url: False)
+ monkeypatch.setattr(llm_core, "_clear_host_dead", lambda *args, **kwargs: None)
+ monkeypatch.setattr(llm_core, "note_model_activity", lambda *args, **kwargs: None)
+
+ async def run():
+ return [
+ chunk
+ async for chunk in llm_core._stream_llm_inner(
+ url,
+ "configured-model",
+ [{"role": "user", "content": "hi"}],
+ headers={"Authorization": "Bearer test"},
+ )
+ ]
+
+ return asyncio.run(run())
+
+
def test_fallback_emits_indicator_when_primary_fails(monkeypatch):
def per_model(model):
if model == "primary":
@@ -43,6 +99,7 @@ def per_model(model):
assert fb[0]["type"] == "fallback"
assert fb[0]["selected_model"] == "primary"
assert fb[0]["answered_by"] == "backup"
+ assert fb[0]["candidate_index"] == 1
assert "400" in fb[0]["reason"]
# the fallback notice must precede the answer content
order = [i for i, c in enumerate(chunks) if '"fallback"' in c or '"delta": "hello"' in c]
@@ -116,7 +173,7 @@ def per_model(model):
assert not any('"fallback"' in c for c in chunks)
-def test_whitespace_only_delta_prevents_fallback(monkeypatch):
+def test_foreground_whitespace_only_delta_surfaces_empty_response_without_fallback(monkeypatch):
calls = []
whitespace = 'data: {"delta": " "}\n\n'
@@ -124,10 +181,13 @@ def per_model(model):
calls.append(model)
return [whitespace, "data: [DONE]\n\n"]
- chunks = _run_fallback(monkeypatch, per_model)
+ chunks = _run_fallback(monkeypatch, per_model, fallback_on_empty=False)
assert calls == ["primary"]
- assert whitespace in chunks
+ assert whitespace not in chunks
assert not any('"fallback"' in c for c in chunks)
+ assert len(chunks) == 1
+ assert chunks[0].startswith("event: error")
+ assert "returned no substantive output" in chunks[0]
def test_completed_tool_call_output_prevents_fallback(monkeypatch):
@@ -144,7 +204,7 @@ def per_model(model):
assert not any('"fallback"' in c for c in chunks)
-def test_tool_call_delta_is_forwarded_immediately_and_prevents_fallback(monkeypatch):
+def test_tool_call_delta_is_released_only_after_completed_call_commits_route(monkeypatch):
calls = []
advanced_past_delta = False
tool_delta = 'data: {"type": "tool_call_delta", "index": 0, "arg_delta": "{\\"path\\":"}\n\n'
@@ -167,7 +227,7 @@ async def run():
)
first = await anext(stream)
assert first == tool_delta
- assert not advanced_past_delta
+ assert advanced_past_delta
chunks = [first]
async for chunk in stream:
chunks.append(chunk)
@@ -179,6 +239,39 @@ async def run():
assert not any('"type": "fallback"' in c for c in chunks)
+def test_incomplete_tool_call_delta_is_discarded_before_eligible_fallback(monkeypatch):
+ calls = []
+ tool_delta = 'data: {"type": "tool_call_delta", "index": 0, "arg_delta": "{\\"path\\":"}\n\n'
+ terminal = 'event: error\ndata: {"status": 503, "error": "unavailable"}\n\n'
+
+ async def fake_stream(url, model, messages, **kw):
+ calls.append(model)
+ if model == "primary":
+ yield tool_delta
+ yield terminal
+ return
+ yield 'data: {"delta": "backup answer"}\n\n'
+ yield "data: [DONE]\n\n"
+
+ monkeypatch.setattr(llm_core, "stream_llm", fake_stream)
+
+ async def run():
+ return [
+ chunk
+ async for chunk in llm_core.stream_llm_with_fallback(
+ [("u1", "primary", {}), ("u2", "backup", {})],
+ [{"role": "user", "content": "hi"}],
+ fallback_statuses={503},
+ )
+ ]
+
+ chunks = asyncio.run(run())
+ assert calls == ["primary", "backup"]
+ assert tool_delta not in chunks
+ assert any('"type": "fallback"' in chunk for chunk in chunks)
+ assert any("backup answer" in chunk for chunk in chunks)
+
+
def test_empty_final_candidate_surfaces_terminal_error(monkeypatch):
calls = []
@@ -196,20 +289,847 @@ def per_model(model):
assert '"status": 502' in errors[0]
+def test_explicit_foreground_policy_falls_back_on_availability_error(monkeypatch):
+ calls = []
+
+ def per_model(model):
+ calls.append(model)
+ if model == "primary":
+ return ['event: error\ndata: {"status": 503, "text": "unavailable"}\n\n']
+ return ['data: {"delta": "backup answer"}\n\n', "data: [DONE]\n\n"]
+
+ chunks = _run_fallback(
+ monkeypatch,
+ per_model,
+ fallback_statuses={408, 425, 429, 500, 502, 503, 504, 507, 508, 529},
+ fallback_on_empty=False,
+ )
+
+ assert calls == ["primary", "backup"]
+ assert any('"type": "fallback"' in chunk for chunk in chunks)
+ assert any('"delta": "backup answer"' in chunk for chunk in chunks)
+
+
+@pytest.mark.parametrize("status", [400, 401, 403, 404])
+def test_explicit_foreground_policy_does_not_fallback_on_request_errors(monkeypatch, status):
+ calls = []
+
+ def per_model(model):
+ calls.append(model)
+ if model == "primary":
+ return [f'event: error\ndata: {{"status": {status}, "text": "request rejected"}}\n\n']
+ return ['data: {"delta": "must not run"}\n\n', "data: [DONE]\n\n"]
+
+ chunks = _run_fallback(
+ monkeypatch,
+ per_model,
+ fallback_statuses={408, 425, 429, 500, 502, 503, 504, 507, 508, 529},
+ fallback_on_empty=False,
+ )
+
+ assert calls == ["primary"]
+ assert chunks == [f'event: error\ndata: {{"status": {status}, "text": "request rejected"}}\n\n']
+
+
+def test_explicit_foreground_policy_does_not_fallback_on_empty_completion(monkeypatch):
+ calls = []
+
+ def per_model(model):
+ calls.append(model)
+ return ["data: [DONE]\n\n"]
+
+ chunks = _run_fallback(
+ monkeypatch,
+ per_model,
+ fallback_statuses={408, 425, 429, 500, 502, 503, 504, 507, 508, 529},
+ fallback_on_empty=False,
+ )
+
+ assert calls == ["primary"]
+ assert len(chunks) == 1
+ assert chunks[0].startswith("event: error")
+ assert "returned no substantive output" in chunks[0]
+
+
+def test_explicit_foreground_policy_respects_adapter_ineligible_override(monkeypatch):
+ calls = []
+ terminal = 'event: error\ndata: {"status": 502, "error": "local adapter failure", "fallback_eligible": false}\n\n'
+
+ def per_model(model):
+ calls.append(model)
+ return [terminal] if model == "primary" else ['data: {"delta": "must not run"}\n\n']
+
+ chunks = _run_fallback(
+ monkeypatch,
+ per_model,
+ fallback_statuses={502},
+ fallback_on_empty=False,
+ )
+
+ assert calls == ["primary"]
+ assert chunks == [terminal]
+
+
+def test_generic_policy_preserves_legacy_fallback_for_adapter_errors(monkeypatch):
+ calls = []
+
+ def per_model(model):
+ calls.append(model)
+ if model == "primary":
+ return ['event: error\ndata: {"status": 502, "fallback_eligible": false}\n\n']
+ return ['data: {"delta": "legacy backup"}\n\n', "data: [DONE]\n\n"]
+
+ chunks = _run_fallback(monkeypatch, per_model)
+
+ assert calls == ["primary", "backup"]
+ assert any('"delta": "legacy backup"' in chunk for chunk in chunks)
+
+
+def test_candidate_request_factory_builds_each_attempt_before_streaming(monkeypatch):
+ calls = []
+
+ async def fake_stream(url, model, messages, **kwargs):
+ calls.append((model, messages, kwargs.get("tools")))
+ if model == "primary":
+ yield 'event: error\ndata: {"status": 503, "text": "down"}\n\n'
+ else:
+ yield 'data: {"delta": "backup"}\n\n'
+ yield "data: [DONE]\n\n"
+
+ def request_factory(index, url, model, headers):
+ return {
+ "messages": [{"role": "user", "content": f"prompt for {model}"}],
+ "kwargs": {"tools": [model] if index == 0 else None},
+ }
+
+ monkeypatch.setattr(llm_core, "stream_llm", fake_stream)
+
+ async def run():
+ return [
+ chunk
+ async for chunk in llm_core.stream_llm_with_fallback(
+ [("u1", "primary", {}), ("u2", "backup", {})],
+ [{"role": "user", "content": "shared"}],
+ fallback_statuses={503},
+ fallback_on_empty=False,
+ candidate_request_factory=request_factory,
+ )
+ ]
+
+ chunks = asyncio.run(run())
+
+ assert calls == [
+ ("primary", [{"role": "user", "content": "prompt for primary"}], ["primary"]),
+ ("backup", [{"role": "user", "content": "prompt for backup"}], None),
+ ]
+ assert any('"delta": "backup"' in chunk for chunk in chunks)
+
+
+def test_candidate_request_factory_eligible_failure_advances_streaming_route(monkeypatch):
+ calls = []
+
+ async def fake_stream(url, model, messages, **kwargs):
+ calls.append(("stream", model))
+ yield 'data: {"delta": "backup"}\n\n'
+ yield "data: [DONE]\n\n"
+
+ async def request_factory(index, url, model, headers):
+ calls.append(("factory", model))
+ if model == "primary":
+ raise HTTPException(503, "primary unavailable during compaction")
+ return {"messages": [{"role": "user", "content": "backup prompt"}]}
+
+ monkeypatch.setattr(llm_core, "stream_llm", fake_stream)
+
+ async def run():
+ return [
+ chunk
+ async for chunk in llm_core.stream_llm_with_fallback(
+ [("u1", "primary", {}), ("u2", "backup", {})],
+ [{"role": "user", "content": "shared"}],
+ fallback_statuses={503},
+ fallback_on_empty=False,
+ candidate_request_factory=request_factory,
+ )
+ ]
+
+ chunks = asyncio.run(run())
+
+ assert calls == [
+ ("factory", "primary"),
+ ("factory", "backup"),
+ ("stream", "backup"),
+ ]
+ assert any('"type": "fallback"' in chunk for chunk in chunks)
+ assert any('"delta": "backup"' in chunk for chunk in chunks)
+
+
+def test_candidate_request_factory_ineligible_failure_stops_streaming_route(monkeypatch):
+ calls = []
+
+ async def fake_stream(url, model, messages, **kwargs):
+ calls.append(("stream", model))
+ yield 'data: {"delta": "must not run"}\n\n'
+
+ async def request_factory(index, url, model, headers):
+ calls.append(("factory", model))
+ raise HTTPException(401, "invalid credentials")
+
+ monkeypatch.setattr(llm_core, "stream_llm", fake_stream)
+
+ async def run():
+ return [
+ chunk
+ async for chunk in llm_core.stream_llm_with_fallback(
+ [("u1", "primary", {}), ("u2", "backup", {})],
+ [{"role": "user", "content": "shared"}],
+ fallback_statuses={503},
+ fallback_on_empty=False,
+ candidate_request_factory=request_factory,
+ )
+ ]
+
+ chunks = asyncio.run(run())
+
+ assert calls == [("factory", "primary")]
+ assert len(chunks) == 1
+ assert chunks[0].startswith("event: error")
+ assert '"status": 401' in chunks[0]
+ assert "invalid credentials" not in chunks[0]
+
+
+def test_response_cache_is_partitioned_by_non_secret_header_identity(monkeypatch):
+ calls = []
+ llm_core._response_cache.clear()
+
+ def fake_post(url, headers=None, json=None, timeout=None):
+ credential = headers.get("Authorization")
+ calls.append(credential)
+ request = httpx.Request("POST", url)
+ return httpx.Response(
+ 200,
+ request=request,
+ json={"choices": [{"message": {"content": f"answer from {credential}"}}]},
+ )
+
+ monkeypatch.setattr(llm_core.httpx, "post", fake_post)
+ messages = [{"role": "user", "content": "same prompt"}]
+ try:
+ first = llm_core.llm_call(
+ "https://same.example/v1",
+ "same-model",
+ messages,
+ headers={"Authorization": "Bearer one"},
+ )
+ second = llm_core.llm_call(
+ "https://same.example/v1",
+ "same-model",
+ messages,
+ headers={"Authorization": "Bearer two"},
+ )
+ first_again = llm_core.llm_call(
+ "https://same.example/v1",
+ "same-model",
+ messages,
+ headers={"Authorization": "Bearer one"},
+ )
+ finally:
+ llm_core._response_cache.clear()
+
+ assert first == "answer from Bearer one"
+ assert second == "answer from Bearer two"
+ assert first_again == first
+ assert calls == ["Bearer one", "Bearer two"]
+ key = llm_core._get_cache_key(
+ "https://same.example/v1",
+ "same-model",
+ messages,
+ 0.7,
+ 4096,
+ headers={"Authorization": "Bearer one"},
+ )
+ assert "Bearer one" not in key
+
+
+@pytest.mark.parametrize(
+ ("error", "expected_status"),
+ [
+ ({"type": "invalid_request_error", "code": "model_not_found", "message": "Unsupported model"}, 404),
+ ({"type": "rate_limit_error", "message": "Too many requests"}, 429),
+ ({"type": "server_error", "message": "Temporarily unavailable"}, 500),
+ ({"status": True, "message": "rate limit"}, 400),
+ ],
+)
+def test_chatgpt_subscription_stream_preserves_error_semantics(monkeypatch, error, expected_status):
+ lines = [
+ "event: response.failed",
+ "data: " + json.dumps({"type": "response.failed", "response": {"error": error}}),
+ ]
+
+ chunks = _run_provider_stream(
+ monkeypatch,
+ "https://chatgpt.com/backend-api/codex/responses",
+ lines,
+ )
+
+ assert len(chunks) == 1
+ assert json.loads(chunks[0].split("data: ", 1)[1])["status"] == expected_status
+
+
+def test_chatgpt_subscription_top_level_error_preserves_semantics(monkeypatch):
+ chunks = _run_provider_stream(
+ monkeypatch,
+ "https://chatgpt.com/backend-api/codex/responses",
+ ["data: " + json.dumps({
+ "type": "error",
+ "code": "server_error",
+ "message": "Temporarily unavailable",
+ })],
+ )
+
+ payload = json.loads(chunks[0].split("data: ", 1)[1])
+ assert payload["status"] == 500
+ assert payload["text"] == "Temporarily unavailable"
+
+
+def test_provider_explicit_status_wins_over_transient_text():
+ assert llm_core._provider_stream_error_status({
+ "status": 401,
+ "message": "Temporarily unavailable",
+ }) == 401
+
+
+@pytest.mark.parametrize("status", [True, 429.9, "429.0", float("inf")])
+def test_provider_malformed_status_fails_closed(status):
+ assert llm_core._provider_stream_error_status({
+ "status": status,
+ "message": "rate limit",
+ }) == 400
+
+
+def test_stream_fractional_status_does_not_advance_fallback(monkeypatch):
+ calls = []
+
+ def per_model(model):
+ calls.append(model)
+ if model == "primary":
+ return ['event: error\ndata: {"status": 429.9, "error": "malformed status"}\n\n']
+ return ['data: {"delta": "backup"}\n\n', "data: [DONE]\n\n"]
+
+ chunks = _run_fallback(
+ monkeypatch,
+ per_model,
+ fallback_statuses={429},
+ fallback_on_empty=False,
+ )
+
+ assert calls == ["primary"]
+ assert not any('"fallback"' in chunk for chunk in chunks)
+ assert any(chunk.startswith("event: error") for chunk in chunks)
+
+
+def test_failed_candidate_closes_before_next_route_starts(monkeypatch):
+ state = {"primary_closed": False, "backup_started": False}
+
+ async def fake_stream(url, model, messages, **kwargs):
+ try:
+ if model == "primary":
+ yield 'event: error\ndata: {"status": 503, "error": "down"}\n\n'
+ yield 'data: {"delta": "must not continue"}\n\n'
+ else:
+ state["backup_started"] = True
+ assert state["primary_closed"] is True
+ yield 'data: {"delta": "backup"}\n\n'
+ yield "data: [DONE]\n\n"
+ finally:
+ if model == "primary":
+ state["primary_closed"] = True
+
+ monkeypatch.setattr(llm_core, "stream_llm", fake_stream)
+
+ async def run():
+ return [
+ chunk
+ async for chunk in llm_core.stream_llm_with_fallback(
+ [("u1", "primary", {}), ("u2", "backup", {})],
+ [{"role": "user", "content": "hi"}],
+ fallback_statuses={503},
+ fallback_on_empty=False,
+ )
+ ]
+
+ chunks = asyncio.run(run())
+
+ assert state == {"primary_closed": True, "backup_started": True}
+ assert any('"delta": "backup"' in chunk for chunk in chunks)
+
+
+def test_consumer_close_closes_active_candidate_stream(monkeypatch):
+ state = {"closed": False}
+
+ async def fake_stream(url, model, messages, **kwargs):
+ try:
+ yield 'data: {"delta": "partial"}\n\n'
+ yield 'data: {"delta": "more"}\n\n'
+ finally:
+ state["closed"] = True
+
+ monkeypatch.setattr(llm_core, "stream_llm", fake_stream)
+
+ async def run():
+ stream = llm_core.stream_llm_with_fallback(
+ [("u1", "primary", {})],
+ [{"role": "user", "content": "hi"}],
+ fallback_statuses={503},
+ fallback_on_empty=False,
+ )
+ assert '"delta": "partial"' in await anext(stream)
+ await stream.aclose()
+
+ asyncio.run(run())
+
+ assert state["closed"] is True
+
+
+def test_nonstream_fractional_status_does_not_advance_fallback(monkeypatch):
+ calls = []
+
+ class FractionalStatusError(Exception):
+ status_code = 429.9
+
+ async def fake_call(url, model, messages, **kwargs):
+ calls.append(model)
+ raise FractionalStatusError("malformed status")
+
+ monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
+
+ with pytest.raises(FractionalStatusError):
+ asyncio.run(llm_core.llm_call_async_with_route_fallback(
+ [
+ ("https://selected.example/v1", "selected", {}),
+ ("https://backup.example/v1", "backup", {}),
+ ],
+ [{"role": "user", "content": "hi"}],
+ fallback_statuses={429},
+ ))
+
+ assert calls == ["selected"]
+
+
+def test_nonstream_provider_boolean_status_does_not_advance_fallback(monkeypatch):
+ calls = []
+
+ async def fake_post(client, url, headers, **kwargs):
+ calls.append(url)
+ return httpx.Response(
+ 200,
+ request=httpx.Request("POST", url),
+ json={"error": {"status": True, "message": "rate limit"}},
+ )
+
+ monkeypatch.setattr(llm_core, "httpx_post_kimi_aware_async", fake_post)
+ monkeypatch.setattr(llm_core, "_is_host_dead", lambda url: False)
+ monkeypatch.setattr(llm_core, "note_model_activity", lambda *args, **kwargs: None)
+ monkeypatch.setattr(llm_core, "_get_cached_response", lambda key: None)
+
+ with pytest.raises(HTTPException) as exc:
+ asyncio.run(llm_core.llm_call_async_with_route_fallback(
+ [
+ ("https://selected.example/v1", "selected", {}),
+ ("https://backup.example/v1", "backup", {}),
+ ],
+ [{"role": "user", "content": "hi"}],
+ fallback_statuses={429},
+ ))
+
+ assert exc.value.status_code == 400
+ assert calls == ["https://selected.example/v1/chat/completions"]
+
+
+def test_google_style_numeric_error_code_is_classified_as_http_status():
+ assert llm_core._provider_stream_error_status({
+ "code": 429,
+ "status": "RESOURCE_EXHAUSTED",
+ "message": "Quota temporarily exhausted",
+ }) == 429
+
+
+def test_nonstream_model_metadata_round_trips_through_response_cache(monkeypatch):
+ calls = []
+ llm_core._response_cache.clear()
+ llm_core._response_model_cache.clear()
+
+ class _Response:
+ is_success = True
+ status_code = 200
+ text = ""
+
+ def json(self):
+ return {
+ "model": "provider-model-alias",
+ "choices": [{"message": {"content": "answer"}}],
+ }
+
+ async def fake_post(_client, target_url, _headers, **kwargs):
+ calls.append(target_url)
+ return _Response()
+
+ monkeypatch.setattr(llm_core, "_get_http_client", lambda: object())
+ monkeypatch.setattr(llm_core, "httpx_post_kimi_aware_async", fake_post)
+ monkeypatch.setattr(llm_core, "_is_host_dead", lambda url: False)
+ monkeypatch.setattr(llm_core, "note_model_activity", lambda *args, **kwargs: None)
+
+ async def run():
+ kwargs = {
+ "url": "https://selected.example/v1",
+ "model": "selected-model",
+ "messages": [{"role": "user", "content": "hello"}],
+ "return_model_metadata": True,
+ }
+ first = await llm_core.llm_call_async(**kwargs)
+ second = await llm_core.llm_call_async(**kwargs)
+ return first, second
+
+ try:
+ first, second = asyncio.run(run())
+ finally:
+ llm_core._response_cache.clear()
+ llm_core._response_model_cache.clear()
+
+ assert first == second == ("answer", "provider-model-alias")
+ assert calls == ["https://selected.example/v1/chat/completions"]
+
+
+@pytest.mark.parametrize(
+ ("url", "line"),
+ [
+ (
+ "https://openai-compatible.example/v1",
+ "data: " + json.dumps({"error": {"type": "rate_limit_error", "message": "Too many requests"}}),
+ ),
+ (
+ "http://localhost:11434/api/chat",
+ json.dumps({"error": {"type": "server_error", "message": "Temporarily unavailable"}}),
+ ),
+ ],
+)
+def test_stream_adapters_surface_top_level_provider_errors(monkeypatch, url, line):
+ chunks = _run_provider_stream(monkeypatch, url, [line])
+
+ assert len(chunks) == 1
+ payload = json.loads(chunks[0].split("data: ", 1)[1])
+ assert payload["status"] in {429, 500}
+
+
+_ABSURD_USAGE_COUNT = 10**1000
+
+
+@pytest.mark.parametrize(
+ ("url", "lines"),
+ [
+ (
+ "https://openai-compatible.example/v1",
+ [
+ 'data: ' + json.dumps({"choices": [{"delta": {"content": "ok"}}]}),
+ 'data: ' + json.dumps({
+ "choices": [],
+ "usage": {
+ "prompt_tokens": _ABSURD_USAGE_COUNT,
+ "completion_tokens": 1,
+ },
+ }),
+ "data: [DONE]",
+ ],
+ ),
+ (
+ "https://chatgpt.com/backend-api/codex/responses",
+ [
+ "event: response.output_text.delta",
+ 'data: ' + json.dumps({
+ "type": "response.output_text.delta",
+ "delta": "ok",
+ }),
+ "event: response.completed",
+ 'data: ' + json.dumps({
+ "type": "response.completed",
+ "response": {"usage": {
+ "input_tokens": _ABSURD_USAGE_COUNT,
+ "output_tokens": 1,
+ }},
+ }),
+ ],
+ ),
+ (
+ "http://localhost:11434/api/chat",
+ [json.dumps({
+ "message": {"content": "ok"},
+ "done": True,
+ "prompt_eval_count": _ABSURD_USAGE_COUNT,
+ "eval_count": 1,
+ })],
+ ),
+ (
+ "https://api.anthropic.com/v1/messages",
+ [
+ 'data: ' + json.dumps({
+ "type": "message_start",
+ "message": {"usage": {"input_tokens": _ABSURD_USAGE_COUNT}},
+ }),
+ 'data: ' + json.dumps({
+ "type": "content_block_delta",
+ "delta": {"type": "text_delta", "text": "ok"},
+ }),
+ 'data: ' + json.dumps({
+ "type": "message_delta",
+ "usage": {"output_tokens": 1},
+ }),
+ 'data: ' + json.dumps({"type": "message_stop"}),
+ ],
+ ),
+ ],
+)
+def test_provider_adapters_ignore_absurd_usage_without_losing_output(
+ monkeypatch,
+ url,
+ lines,
+):
+ chunks = _run_provider_stream(monkeypatch, url, lines)
+
+ assert any('"delta": "ok"' in chunk for chunk in chunks)
+ assert "data: [DONE]\n\n" in chunks
+ assert not any('"type": "usage"' in chunk for chunk in chunks)
+ assert not any(chunk.startswith("event: error") for chunk in chunks)
+
+
+@pytest.mark.parametrize(
+ ("url", "reported_model", "lines"),
+ [
+ (
+ "https://chatgpt.com/backend-api/codex/responses",
+ "responses-provider-model",
+ [
+ "data: " + json.dumps({
+ "type": "response.created",
+ "response": {"model": "responses-provider-model"},
+ }),
+ "data: " + json.dumps({
+ "type": "response.output_text.delta",
+ "delta": "ok",
+ }),
+ "data: " + json.dumps({
+ "type": "response.completed",
+ "response": {
+ "model": "responses-provider-model",
+ "usage": {"input_tokens": 4, "output_tokens": 1},
+ },
+ }),
+ ],
+ ),
+ (
+ "http://localhost:11434/api/chat",
+ "ollama-provider-model",
+ [json.dumps({
+ "model": "ollama-provider-model",
+ "message": {"content": "ok"},
+ "done": True,
+ "prompt_eval_count": 4,
+ "eval_count": 1,
+ })],
+ ),
+ (
+ "https://api.anthropic.com/v1/messages",
+ "anthropic-provider-model",
+ [
+ "data: " + json.dumps({
+ "type": "message_start",
+ "message": {
+ "model": "anthropic-provider-model",
+ "usage": {"input_tokens": 4},
+ },
+ }),
+ "data: " + json.dumps({
+ "type": "content_block_delta",
+ "delta": {"type": "text_delta", "text": "ok"},
+ }),
+ "data: " + json.dumps({
+ "type": "message_delta",
+ "usage": {"output_tokens": 1},
+ }),
+ "data: " + json.dumps({"type": "message_stop"}),
+ ],
+ ),
+ ],
+)
+def test_native_stream_adapters_report_actual_model_and_usage(
+ monkeypatch,
+ url,
+ reported_model,
+ lines,
+):
+ chunks = _run_provider_stream(monkeypatch, url, lines)
+ model_events = [
+ json.loads(chunk[6:])
+ for chunk in chunks
+ if chunk.startswith("data: ") and '"type": "model_actual"' in chunk
+ ]
+ usage_events = [
+ json.loads(chunk[6:])["data"]
+ for chunk in chunks
+ if chunk.startswith("data: ") and '"type": "usage"' in chunk
+ ]
+
+ assert model_events == [{
+ "type": "model_actual",
+ "requested_model": "configured-model",
+ "model": reported_model,
+ }]
+ assert usage_events == [{
+ "input_tokens": 4,
+ "output_tokens": 1,
+ "model": reported_model,
+ "requested_model": "configured-model",
+ }]
+ assert any('"delta": "ok"' in chunk for chunk in chunks)
+ assert "data: [DONE]\n\n" in chunks
+
+
+def test_degenerate_stream_error_is_not_availability_evidence():
+ guard = llm_core._DegenerateStreamGuard("looping-model")
+ chunk = guard.check("repeat " * 100)
+
+ assert chunk is not None
+ assert json.loads(chunk.split("data: ", 1)[1])["fallback_eligible"] is False
+
+
+@pytest.mark.parametrize(
+ ("error", "expected_status"),
+ [
+ (httpx.WriteTimeout("write timed out"), 504),
+ (httpx.PoolTimeout("pool timed out"), 504),
+ (httpx.RemoteProtocolError("peer disconnected"), 502),
+ ],
+)
+def test_ambiguous_transport_failures_are_not_availability_evidence(monkeypatch, error, expected_status):
+ class _RaisingClient:
+ def stream(self, *args, **kwargs):
+ raise error
+
+ monkeypatch.setattr(llm_core, "_get_http_client", lambda: _RaisingClient())
+ monkeypatch.setattr(llm_core, "_is_host_dead", lambda url: False)
+ monkeypatch.setattr(llm_core, "note_model_activity", lambda *args, **kwargs: None)
+
+ async def run():
+ return [
+ chunk
+ async for chunk in llm_core._stream_llm_inner(
+ "https://openai-compatible.example/v1",
+ "configured-model",
+ [{"role": "user", "content": "hi"}],
+ )
+ ]
+
+ chunks = asyncio.run(run())
+ payload = json.loads(chunks[0].split("data: ", 1)[1])
+ assert payload["status"] == expected_status
+ assert payload["fallback_eligible"] is False
+
+
+@pytest.mark.parametrize("error", [
+ httpx.WriteTimeout("write timed out"),
+ httpx.PoolTimeout("pool timed out"),
+ httpx.RemoteProtocolError("peer disconnected"),
+])
+def test_nonstream_foreground_does_not_advance_on_ambiguous_transport(monkeypatch, error):
+ calls = []
+
+ async def fake_call(url, model, messages, **kwargs):
+ calls.append(model)
+ raise error
+
+ monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
+
+ with pytest.raises(type(error)):
+ asyncio.run(llm_core.llm_call_async_with_route_fallback(
+ [
+ ("https://selected.example/v1", "selected", {}),
+ ("https://backup.example/v1", "backup", {}),
+ ],
+ [{"role": "user", "content": "hi"}],
+ fallback_statuses={502, 504},
+ ))
+
+ assert calls == ["selected"]
+
+
+@pytest.mark.parametrize("error", [
+ httpx.WriteTimeout("write timed out"),
+ httpx.PoolTimeout("pool timed out"),
+ httpx.RemoteProtocolError("peer disconnected"),
+])
+def test_nonstream_foreground_marks_adapter_transport_ineligible(monkeypatch, error):
+ calls = []
+
+ async def fake_post(client, url, headers, **kwargs):
+ calls.append(url)
+ raise error
+
+ monkeypatch.setattr(llm_core, "httpx_post_kimi_aware_async", fake_post)
+ monkeypatch.setattr(llm_core, "_is_host_dead", lambda url: False)
+ monkeypatch.setattr(llm_core, "note_model_activity", lambda *args, **kwargs: None)
+ monkeypatch.setattr(llm_core, "_get_cached_response", lambda key: None)
+
+ with pytest.raises(Exception) as exc:
+ asyncio.run(llm_core.llm_call_async_with_route_fallback(
+ [
+ ("https://selected.example/v1", "selected", {}),
+ ("https://backup.example/v1", "backup", {}),
+ ],
+ [{"role": "user", "content": "hi"}],
+ fallback_statuses={502, 504},
+ ))
+
+ assert len(calls) == 1
+ assert getattr(exc.value, "fallback_eligible", None) is False
+
+
+@pytest.mark.parametrize(
+ ("error", "expected_status"),
+ [
+ ({"type": "invalid_request_error", "message": "Unsupported model"}, 400),
+ ({"type": "overloaded_error", "message": "Overloaded"}, 529),
+ ({"type": "authentication_error", "message": "Invalid API key"}, 401),
+ ],
+)
+def test_anthropic_stream_preserves_error_semantics(monkeypatch, error, expected_status):
+ lines = ["data: " + json.dumps({"type": "error", "error": error})]
+
+ chunks = _run_provider_stream(
+ monkeypatch,
+ "https://api.anthropic.com/v1/messages",
+ lines,
+ )
+
+ assert len(chunks) == 1
+ assert json.loads(chunks[0].split("data: ", 1)[1])["status"] == expected_status
+
+
def test_dedupe_candidates_keeps_first_of_each_route():
- """(url, model) is the route key; later repeats are dropped, order preserved,
- the first tuple (with its headers) kept, malformed entries filtered."""
+ """Exact route repeats are dropped while credential-distinct routes remain."""
cands = [
("u1", "m1", {"h": 1}), # first u1/m1 — kept
- ("u1", "m1", {"h": 2}), # repeat route — dropped (first headers win)
+ ("u1", "m1", {"h": 2}), # same provider/model, different credential — kept
("u2", "m2", {}), # distinct — kept
- ("u1", "m1", {}), # repeat again — dropped
+ ("u1", "m1", {"h": 1}), # exact repeat — dropped
(None, "x", {}), # malformed (no url) — dropped
("u3", "", {}), # malformed (no model) — dropped
]
- assert llm_core._dedupe_candidates(cands) == [("u1", "m1", {"h": 1}), ("u2", "m2", {})]
- assert llm_core._dedupe_candidates([]) == []
- assert llm_core._dedupe_candidates(None) == []
+ assert llm_core.dedupe_model_candidates(cands) == [
+ ("u1", "m1", {"h": 1}),
+ ("u1", "m1", {"h": 2}),
+ ("u2", "m2", {}),
+ ]
+ assert llm_core.dedupe_model_candidates([]) == []
+ assert llm_core.dedupe_model_candidates(None) == []
def test_duplicate_route_is_attempted_only_once(monkeypatch):
@@ -234,6 +1154,193 @@ async def run():
assert calls == [("u1", "m1"), ("u2", "m2")], f"duplicate route re-attempted: {calls}"
+def test_same_provider_model_with_different_credentials_remains_ordered(monkeypatch):
+ calls = []
+
+ async def fake_stream(url, model, messages, **kwargs):
+ key = kwargs["headers"]["Authorization"]
+ calls.append(key)
+ if key == "Bearer key-one":
+ yield 'event: error\ndata: {"status": 429, "text": "rate limited"}\n\n'
+ else:
+ yield 'data: {"delta": "second account"}\n\n'
+ yield "data: [DONE]\n\n"
+
+ monkeypatch.setattr(llm_core, "stream_llm", fake_stream)
+
+ async def run():
+ return [
+ chunk
+ async for chunk in llm_core.stream_llm_with_fallback(
+ [
+ ("https://provider.example/v1", "same-model", {"Authorization": "Bearer key-one"}),
+ ("https://provider.example/v1", "same-model", {"Authorization": "Bearer key-two"}),
+ ],
+ [{"role": "user", "content": "hi"}],
+ fallback_statuses={429},
+ fallback_on_empty=False,
+ candidate_route_descriptors=[
+ {"endpoint_id": "account-one", "endpoint_label": "Account one"},
+ {"endpoint_id": "account-two", "endpoint_label": "Account two"},
+ ],
+ )
+ ]
+
+ chunks = asyncio.run(run())
+
+ assert calls == ["Bearer key-one", "Bearer key-two"]
+ assert any('"delta": "second account"' in chunk for chunk in chunks)
+ event = json.loads(next(chunk for chunk in chunks if '"type": "fallback"' in chunk)[6:])
+ assert event["selected_endpoint_id"] == "account-one"
+ assert event["answered_by_endpoint_id"] == "account-two"
+ assert event["answered_by_endpoint_label"] == "Account two"
+
+
+def test_invalid_primary_route_fails_closed_before_deduplication(monkeypatch):
+ calls = []
+
+ async def fake_stream(url, model, messages, **kwargs):
+ calls.append(model)
+ yield 'data: {"delta": "must not run"}\n\n'
+
+ monkeypatch.setattr(llm_core, "stream_llm", fake_stream)
+
+ async def run():
+ return [
+ chunk
+ async for chunk in llm_core.stream_llm_with_fallback(
+ [("", "selected", {}), ("https://backup.example/v1", "backup", {})],
+ [{"role": "user", "content": "hi"}],
+ fallback_statuses={503},
+ )
+ ]
+
+ chunks = asyncio.run(run())
+ assert calls == []
+ assert len(chunks) == 1
+ assert '"status": 400' in chunks[0]
+ assert '"fallback_eligible": false' in chunks[0]
+
+
+def test_nonstream_invalid_primary_route_fails_closed(monkeypatch):
+ monkeypatch.setattr(
+ llm_core,
+ "llm_call_async",
+ lambda *args, **kwargs: pytest.fail("invalid primary dispatched a request"),
+ )
+
+ with pytest.raises(Exception) as exc:
+ asyncio.run(llm_core.llm_call_async_with_route_fallback(
+ [("", "selected", {}), ("https://backup.example/v1", "backup", {})],
+ [{"role": "user", "content": "hi"}],
+ fallback_statuses={503},
+ ))
+
+ assert getattr(exc.value, "status_code", None) == 400
+ assert getattr(exc.value, "fallback_eligible", None) is False
+
+
+def test_subscription_collector_preserves_explicit_ineligible_marker(monkeypatch):
+ calls = []
+
+ async def fake_stream(url, model, messages, **kwargs):
+ calls.append(model)
+ if model == "selected":
+ yield 'event: error\ndata: {"status": 502, "error": "malformed frame", "fallback_eligible": false}\n\n'
+ else:
+ yield 'data: {"delta": "backup"}\n\n'
+ yield "data: [DONE]\n\n"
+
+ monkeypatch.setattr(llm_core, "stream_llm", fake_stream)
+ monkeypatch.setattr(llm_core, "_get_cached_response", lambda key: None)
+
+ with pytest.raises(Exception) as exc:
+ asyncio.run(llm_core.llm_call_async_with_route_fallback(
+ [
+ ("https://chatgpt.com/backend-api/codex/responses", "selected", {}),
+ ("https://chatgpt.com/backend-api/codex/responses", "backup", {}),
+ ],
+ [{"role": "user", "content": "hi"}],
+ fallback_statuses={502},
+ ))
+
+ assert calls == ["selected"]
+ assert getattr(exc.value, "fallback_eligible", None) is False
+
+
+def test_nonstream_request_configuration_error_is_ineligible(monkeypatch):
+ calls = []
+
+ async def fake_post(client, url, headers, **kwargs):
+ calls.append(url)
+ raise httpx.UnsupportedProtocol("unsupported protocol")
+
+ monkeypatch.setattr(llm_core, "httpx_post_kimi_aware_async", fake_post)
+ monkeypatch.setattr(llm_core, "_is_host_dead", lambda url: False)
+ monkeypatch.setattr(llm_core, "note_model_activity", lambda *args, **kwargs: None)
+ monkeypatch.setattr(llm_core, "_get_cached_response", lambda key: None)
+
+ with pytest.raises(Exception) as exc:
+ asyncio.run(llm_core.llm_call_async_with_route_fallback(
+ [
+ ("ftp://selected.example", "selected", {}),
+ ("https://backup.example/v1", "backup", {}),
+ ],
+ [{"role": "user", "content": "hi"}],
+ fallback_statuses={502},
+ ))
+
+ assert len(calls) == 1
+ assert getattr(exc.value, "fallback_eligible", None) is False
+
+
+def test_multi_candidate_fallback_preserves_primary_reason_and_failure_chain(monkeypatch):
+ async def fake_stream(url, model, messages, **kwargs):
+ if model == "primary":
+ yield 'event: error\ndata: {"status": 503, "text": "primary unavailable"}\n\n'
+ elif model == "backup-a":
+ yield 'event: error\ndata: {"status": 429, "text": "backup quota"}\n\n'
+ else:
+ yield 'data: {"delta": "answered"}\n\n'
+ yield "data: [DONE]\n\n"
+
+ monkeypatch.setattr(llm_core, "stream_llm", fake_stream)
+
+ async def run():
+ return [
+ chunk
+ async for chunk in llm_core.stream_llm_with_fallback(
+ [
+ ("u1", "primary", {}),
+ ("u2", "backup-a", {}),
+ ("u3", "backup-b", {}),
+ ],
+ [{"role": "user", "content": "hi"}],
+ fallback_statuses={429, 503},
+ fallback_on_empty=False,
+ )
+ ]
+
+ chunks = asyncio.run(run())
+ event = json.loads(next(chunk for chunk in chunks if '"type": "fallback"' in chunk)[6:])
+
+ assert event["selected_model"] == "primary"
+ assert event["answered_by"] == "backup-b"
+ assert "primary unavailable" in event["reason"]
+ assert event["failures"] == [
+ {
+ "candidate_index": 0,
+ "model": "primary",
+ "status": 503,
+ },
+ {
+ "candidate_index": 1,
+ "model": "backup-a",
+ "status": 429,
+ },
+ ]
+
+
def test_summarize_stream_error():
assert "400" in llm_core._summarize_stream_error('event: error\ndata: {"status": 400, "text": "nope"}\n\n')
assert llm_core._summarize_stream_error(None) == "primary model failed"
diff --git a/tests/test_llm_core_usage_finish_delta.py b/tests/test_llm_core_usage_finish_delta.py
index 507939d59..fa25674f6 100644
--- a/tests/test_llm_core_usage_finish_delta.py
+++ b/tests/test_llm_core_usage_finish_delta.py
@@ -9,6 +9,8 @@
import asyncio
import json
+import pytest
+
from src import llm_core
@@ -116,7 +118,8 @@ def test_null_choice_chunk_does_not_crash(monkeypatch):
def test_null_choice_with_null_usage_does_not_crash(monkeypatch):
- # Chunk with both choices:[null] and usage:null — neither field should panic.
+ # Chunk with both choices:[null] and usage:null is a keepalive, not a real
+ # zero-token accounting record.
lines = [
'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
'data: ' + json.dumps({"choices": [None], "usage": None}),
@@ -124,6 +127,66 @@ def test_null_choice_with_null_usage_does_not_crash(monkeypatch):
]
result = _drive(monkeypatch, lines)
assert "Hi" in result
+ assert _usage_events(result) == []
+
+
+def test_empty_usage_object_is_not_reported_as_real_zero_usage(monkeypatch):
+ lines = [
+ 'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
+ 'data: ' + json.dumps({"choices": [], "usage": {}}),
+ 'data: [DONE]',
+ ]
+ result = _drive(monkeypatch, lines)
+ assert "Hi" in result
+ assert _usage_events(result) == []
+
+
+def test_explicit_zero_token_usage_is_preserved(monkeypatch):
+ lines = [
+ 'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
+ 'data: ' + json.dumps({
+ "choices": [],
+ "usage": {"prompt_tokens": 0, "completion_tokens": 0},
+ }),
+ 'data: [DONE]',
+ ]
+ usage = _usage_events(_drive(monkeypatch, lines))
+ assert usage == [{"input_tokens": 0, "output_tokens": 0}]
+
+
+@pytest.mark.parametrize(
+ "usage_payload",
+ [
+ {"prompt_tokens": None, "completion_tokens": 1},
+ {"prompt_tokens": "bad", "completion_tokens": 1},
+ {"prompt_tokens": -1, "completion_tokens": 1},
+ {"prompt_tokens": True, "completion_tokens": 1},
+ {"prompt_tokens": 1.5, "completion_tokens": 1},
+ {"prompt_tokens": float("inf"), "completion_tokens": 1},
+ ],
+)
+def test_malformed_token_values_do_not_emit_usage(monkeypatch, usage_payload):
+ lines = [
+ 'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
+ 'data: ' + json.dumps({"choices": [], "usage": usage_payload}),
+ 'data: [DONE]',
+ ]
+ result = _drive(monkeypatch, lines)
+ assert "Hi" in result
+ assert _usage_events(result) == []
+
+
+def test_missing_usage_counterpart_defaults_to_zero(monkeypatch):
+ lines = [
+ 'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
+ 'data: ' + json.dumps({
+ "choices": [],
+ "usage": {"completion_tokens": 2},
+ }),
+ 'data: [DONE]',
+ ]
+ usage = _usage_events(_drive(monkeypatch, lines))
+ assert usage == [{"input_tokens": 0, "output_tokens": 2}]
def test_null_tool_call_in_delta_is_skipped(monkeypatch):
diff --git a/tests/test_model_routes.py b/tests/test_model_routes.py
index 85b0146ce..e91dba0d9 100644
--- a/tests/test_model_routes.py
+++ b/tests/test_model_routes.py
@@ -98,6 +98,9 @@ def test_endpoint_cleanup_preserves_legacy_default_fallback_data():
{"endpoint_id": "dead", "model": "fallback-a"},
{"endpoint_id": "keep", "model": "fallback-b"},
],
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "dead", "model": "foreground"},
+ ],
"utility_model_fallbacks": [{"endpoint_id": "dead", "model": "utility"}],
"vision_model_fallbacks": [{"endpoint_id": "dead", "model": "vision"}],
"stt_provider": "endpoint:dead",
@@ -106,12 +109,14 @@ def test_endpoint_cleanup_preserves_legacy_default_fallback_data():
assert _endpoint_settings_using_endpoint(settings, "dead", include_speech=True) == [
"Default Model",
+ "Foreground Model Fallbacks",
"Utility Model Fallbacks",
"Vision Model Fallbacks",
"Speech to Text",
]
assert _clear_endpoint_settings_for_endpoint(settings, "dead", include_speech=True) == [
"Default Model",
+ "Foreground Model Fallbacks",
"Utility Model Fallbacks",
"Vision Model Fallbacks",
"Speech to Text",
@@ -122,6 +127,7 @@ def test_endpoint_cleanup_preserves_legacy_default_fallback_data():
{"endpoint_id": "dead", "model": "fallback-a"},
{"endpoint_id": "keep", "model": "fallback-b"},
]
+ assert settings["foreground_model_fallbacks"] == []
assert settings["utility_model_fallbacks"] == []
assert settings["vision_model_fallbacks"] == []
assert settings["stt_provider"] == "disabled"
@@ -130,10 +136,19 @@ def test_endpoint_cleanup_preserves_legacy_default_fallback_data():
def test_endpoint_cleanup_updates_active_scoped_prefs_but_preserves_legacy_data():
scoped = {
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "dead", "model": "ownerless"},
+ ],
+ "default_model_fallbacks": [
+ {"endpoint_id": "dead", "model": "legacy-ownerless"},
+ ],
"_users": {
"alice": {
"utility_endpoint_id": "dead",
"utility_model": "utility",
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "dead", "model": "foreground"},
+ ],
"vision_model_fallbacks": [{"endpoint_id": "dead", "model": "vision"}],
},
"bob": {
@@ -142,10 +157,15 @@ def test_endpoint_cleanup_updates_active_scoped_prefs_but_preserves_legacy_data(
},
},
}
- assert _clear_user_pref_endpoint_refs(scoped, "dead") == 1
+ assert _clear_user_pref_endpoint_refs(scoped, "dead") == 2
+ assert scoped["foreground_model_fallbacks"] == []
+ assert scoped["default_model_fallbacks"] == [
+ {"endpoint_id": "dead", "model": "legacy-ownerless"},
+ ]
assert scoped["_users"]["alice"] == {
"utility_endpoint_id": "",
"utility_model": "",
+ "foreground_model_fallbacks": [],
"vision_model_fallbacks": [],
}
assert scoped["_users"]["bob"]["default_endpoint_id"] == "keep"
diff --git a/tests/test_prefs_routes.py b/tests/test_prefs_routes.py
index 575f12c9a..8b0f50c5f 100644
--- a/tests/test_prefs_routes.py
+++ b/tests/test_prefs_routes.py
@@ -17,4 +17,26 @@ def test_load_keeps_object_prefs_file(tmp_path, monkeypatch):
prefs_file.write_text(json.dumps({"theme": "dark"}), encoding="utf-8")
monkeypatch.setattr(prefs_routes, "PREFS_FILE", str(prefs_file))
- assert prefs_routes._load_for_user("alice") == {"theme": "dark"}
+ assert prefs_routes._load_for_user(None) == {"theme": "dark"}
+ assert prefs_routes._load_for_user("alice") == {}
+
+
+def test_named_preference_write_does_not_copy_flat_fallback_consent(tmp_path, monkeypatch):
+ prefs_file = tmp_path / "user_prefs.json"
+ prefs_file.write_text(json.dumps({
+ "theme": "light",
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": [
+ {"endpoint_id": "legacy-single-user", "model": "legacy-model"},
+ ],
+ }), encoding="utf-8")
+ monkeypatch.setattr(prefs_routes, "PREFS_FILE", str(prefs_file))
+
+ bob = prefs_routes._load_for_user("bob")
+ bob["theme"] = "dark"
+ prefs_routes._save_for_user("bob", bob)
+
+ raw = prefs_routes._load()
+ assert raw["_users"] == {"bob": {"theme": "dark"}}
+ assert raw["foreground_fallback_enabled"] is True
+ assert raw["foreground_model_fallbacks"][0]["endpoint_id"] == "legacy-single-user"
diff --git a/tests/test_prefs_single_user_no_clobber.py b/tests/test_prefs_single_user_no_clobber.py
index 7bd2c6153..120b8413d 100644
--- a/tests/test_prefs_single_user_no_clobber.py
+++ b/tests/test_prefs_single_user_no_clobber.py
@@ -6,6 +6,9 @@
on a deployment that previously ran multi-user). It must preserve the other
users and round-trip the change into the same (first) slot _load_for_user
reads from.
+
+Foreground fallback keys are the exception: auth-disabled consent is stored
+at the flat root so it can never become consent for the first named owner.
"""
import json
@@ -51,3 +54,58 @@ def test_named_user_save_unaffected(tmp_path, monkeypatch):
data = json.loads(f.read_text())
assert data["_users"]["alice"] == {"theme": "light"}
assert data["_users"]["bob"] == {"theme": "dark"}
+
+
+def test_auth_disabled_fallback_consent_does_not_mutate_first_named_user(
+ tmp_path,
+ monkeypatch,
+):
+ f = tmp_path / "user_prefs.json"
+ f.write_text(json.dumps({"_users": {
+ "alice": {"theme": "light"},
+ "bob": {"theme": "paper"},
+ }}), encoding="utf-8")
+ monkeypatch.setattr(pr, "PREFS_FILE", str(f))
+
+ current = pr._load_for_user(None)
+ current["foreground_fallback_enabled"] = True
+ current["foreground_model_fallbacks"] = [
+ {"endpoint_id": "single-user", "model": "single-model"},
+ ]
+ pr._save_for_user(None, current)
+
+ data = json.loads(f.read_text(encoding="utf-8"))
+ assert data["foreground_fallback_enabled"] is True
+ assert data["foreground_model_fallbacks"][0]["endpoint_id"] == "single-user"
+ assert data["_users"]["alice"] == {"theme": "light"}
+ assert data["_users"]["bob"] == {"theme": "paper"}
+
+
+def test_auth_disabled_save_preserves_named_fallback_consent(tmp_path, monkeypatch):
+ f = tmp_path / "user_prefs.json"
+ alice_fallbacks = [{"endpoint_id": "alice", "model": "alice-model"}]
+ f.write_text(json.dumps({"_users": {
+ "alice": {
+ "theme": "light",
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": alice_fallbacks,
+ },
+ }}), encoding="utf-8")
+ monkeypatch.setattr(pr, "PREFS_FILE", str(f))
+
+ current = pr._load_for_user(None)
+ assert "foreground_fallback_enabled" not in current
+ assert "foreground_model_fallbacks" not in current
+ current["theme"] = "dark"
+ current["foreground_fallback_enabled"] = False
+ current["foreground_model_fallbacks"] = []
+ pr._save_for_user(None, current)
+
+ data = json.loads(f.read_text(encoding="utf-8"))
+ assert data["foreground_fallback_enabled"] is False
+ assert data["foreground_model_fallbacks"] == []
+ assert data["_users"]["alice"] == {
+ "theme": "dark",
+ "foreground_fallback_enabled": True,
+ "foreground_model_fallbacks": alice_fallbacks,
+ }
diff --git a/tests/test_resolve_endpoint_fallbacks.py b/tests/test_resolve_endpoint_fallbacks.py
index c210ecf19..0abdd9d61 100644
--- a/tests/test_resolve_endpoint_fallbacks.py
+++ b/tests/test_resolve_endpoint_fallbacks.py
@@ -4,7 +4,13 @@
from types import SimpleNamespace
import src.endpoint_resolver as endpoint_resolver
-from src.endpoint_resolver import resolve_endpoint
+from src.endpoint_resolver import (
+ endpoint_cost_tracked,
+ resolve_endpoint,
+ resolve_endpoint_by_id,
+ resolve_fallback_entries,
+ resolve_fallback_entries_with_descriptors,
+)
class _FakeColumn:
@@ -34,6 +40,9 @@ def filter(self, *conditions):
def first(self):
return self.rows[0] if self.rows else None
+ def all(self):
+ return list(self.rows)
+
class _FakeDb:
def __init__(self, rows):
@@ -49,6 +58,7 @@ def close(self):
def _endpoint(ep_id, model, *, hidden=None):
return SimpleNamespace(
id=ep_id,
+ name=f"Endpoint {ep_id}",
base_url=f"https://{ep_id}.example/v1",
api_key=f"key-{ep_id}",
cached_models=json.dumps([model]),
@@ -191,3 +201,95 @@ def test_hidden_configured_model_selects_first_enabled_chat_model(monkeypatch):
assert url == "https://default.example/v1/chat/completions"
assert model == "enabled-chat"
assert headers == {"Authorization": "Bearer key-default"}
+
+
+def test_exact_fallback_drops_hidden_model_instead_of_substituting(monkeypatch):
+ endpoint = SimpleNamespace(
+ id="fallback",
+ base_url="https://fallback.example/v1",
+ api_key="key-fallback",
+ cached_models=json.dumps(["chosen-hidden", "different-live"]),
+ hidden_models=json.dumps(["chosen-hidden"]),
+ is_enabled=True,
+ )
+ _install_resolver_fakes(monkeypatch, {}, [endpoint])
+
+ assert resolve_endpoint_by_id(
+ "fallback",
+ "chosen-hidden",
+ require_exact_model=True,
+ ) is None
+ assert resolve_endpoint_by_id("fallback", "chosen-hidden")[1] == "different-live"
+
+
+def test_exact_fallback_drops_known_missing_model(monkeypatch):
+ _install_resolver_fakes(monkeypatch, {}, [_endpoint("fallback", "known-live")])
+
+ assert resolve_endpoint_by_id(
+ "fallback",
+ "unlisted-model",
+ require_exact_model=True,
+ ) is None
+
+
+def test_fallback_entry_resolution_preserves_credential_distinct_endpoints(monkeypatch):
+ seen = []
+
+ def fake_resolve(ep_id, model, owner=None, *, require_exact_model=False):
+ seen.append((ep_id, model, owner, require_exact_model))
+ return (
+ "https://provider.example/v1/chat/completions",
+ model,
+ {"Authorization": f"Bearer {ep_id}"},
+ )
+
+ monkeypatch.setattr(endpoint_resolver, "resolve_endpoint_by_id", fake_resolve)
+ entries = [
+ {"endpoint_id": "key-one", "model": "same-model"},
+ {"endpoint_id": "key-two", "model": "same-model"},
+ ]
+
+ assert resolve_fallback_entries(
+ entries,
+ owner="alice",
+ require_exact_model=True,
+ ) == [
+ ("https://provider.example/v1/chat/completions", "same-model", {"Authorization": "Bearer key-one"}),
+ ("https://provider.example/v1/chat/completions", "same-model", {"Authorization": "Bearer key-two"}),
+ ]
+ assert seen == [
+ ("key-one", "same-model", "alice", True),
+ ("key-two", "same-model", "alice", True),
+ ]
+
+
+def test_descriptor_resolution_preserves_safe_endpoint_identity(monkeypatch):
+ _install_resolver_fakes(monkeypatch, {}, [_endpoint("backup", "backup-model")])
+
+ routes = resolve_fallback_entries_with_descriptors(
+ [{"endpoint_id": "backup", "model": "backup-model"}],
+ require_exact_model=True,
+ )
+
+ assert routes == [(
+ (
+ "https://backup.example/v1/chat/completions",
+ "backup-model",
+ {"Authorization": "Bearer key-backup"},
+ ),
+ {
+ "endpoint_id": "backup",
+ "endpoint_label": "Endpoint backup",
+ "endpoint_cost_tracked": True,
+ },
+ )]
+
+
+def test_endpoint_cost_tracking_is_non_secret_route_classification():
+ assert endpoint_cost_tracked("http://localhost:11434/v1") is False
+ assert endpoint_cost_tracked("http://model-service:8000/v1") is False
+ assert endpoint_cost_tracked("http://192.168.1.20:8000/v1") is False
+ assert endpoint_cost_tracked("https://chatgpt.com/backend-api/codex") is False
+ assert endpoint_cost_tracked("https://api.example.com/v1") is True
+ assert endpoint_cost_tracked("http://192.168.1.20:8000/v1", "api") is True
+ assert endpoint_cost_tracked("https://api.example.com/v1", "local") is False
diff --git a/tests/test_resolve_session_auth_chatgpt.py b/tests/test_resolve_session_auth_chatgpt.py
index ebba8298d..87ef845f7 100644
--- a/tests/test_resolve_session_auth_chatgpt.py
+++ b/tests/test_resolve_session_auth_chatgpt.py
@@ -163,53 +163,3 @@ def test_chatgpt_subscription_clears_previously_persisted_bearer(monkeypatch):
)
finally:
db.close()
-
-
-def test_chatgpt_subscription_fallback_auth_is_not_written_to_sessions_table(monkeypatch):
- """Fallback endpoint selection must keep the resolved bearer request-local."""
- TestSessionLocal = _mem_db(monkeypatch)
- db = TestSessionLocal()
- try:
- db.add(ModelEndpoint(
- id="ep1", name="ChatGPT Subscription", base_url=_CODEX_BASE,
- provider_auth_id="auth1", owner="alice", is_enabled=True, api_key=None,
- cached_models='["gpt-5.1-codex"]',
- ))
- db.add(DbSession(
- id="sess1", name="chat", endpoint_url="https://old.example/v1",
- model="old-model", owner="alice", headers={},
- ))
- db.commit()
- finally:
- db.close()
-
- monkeypatch.setattr(
- endpoint_resolver,
- "resolve_endpoint_runtime",
- lambda ep, owner=None: (_CODEX_BASE, "live-access-token"),
- )
-
- sess = types.SimpleNamespace(
- id="sess1", endpoint_url="https://old.example/v1", model="old-model",
- owner="alice", headers={},
- )
- result = chat_helpers.try_fallback_endpoint(sess, "sess1")
-
- assert result == {
- "model": "gpt-5.1-codex",
- "endpoint_url": _CODEX_BASE + "/responses",
- "endpoint_name": "ChatGPT Subscription",
- }
- assert sess.headers["Authorization"] == "Bearer live-access-token"
-
- db = TestSessionLocal()
- try:
- row = db.query(DbSession).filter(DbSession.id == "sess1").first()
- assert row.model == "gpt-5.1-codex"
- assert row.endpoint_url == _CODEX_BASE + "/responses"
- stored = row.headers or {}
- assert not any(k.lower() == "authorization" for k in stored), (
- f"ChatGPT fallback bearer leaked into sessions table: {stored}"
- )
- finally:
- db.close()
diff --git a/tests/test_retired_settings_interfaces.py b/tests/test_retired_settings_interfaces.py
new file mode 100644
index 000000000..d11e3afb8
--- /dev/null
+++ b/tests/test_retired_settings_interfaces.py
@@ -0,0 +1,119 @@
+"""Retired settings stay stored but cannot leak through generic interfaces."""
+
+import asyncio
+import json
+from types import SimpleNamespace
+
+import pytest
+
+import core.database as database
+import routes.auth_routes as auth_routes
+import src.settings as settings_mod
+from src.agent_tools.admin_tools import do_manage_settings
+
+
+LEGACY_VALUE = [
+ {"endpoint_id": "private-endpoint-id", "model": "private-model-name"},
+]
+
+
+class _AuthManager:
+ def get_username_for_token(self, token):
+ return "admin" if token == "admin-session" else None
+
+ def is_admin(self, username):
+ return username == "admin"
+
+
+class _Request(SimpleNamespace):
+ def __init__(self, body=None, *, admin=False):
+ super().__init__(
+ cookies={
+ auth_routes.SESSION_COOKIE: "admin-session"
+ } if admin else {},
+ _body=body,
+ )
+
+ async def json(self):
+ return self._body
+
+
+def _route(router, path, method):
+ return next(
+ route.endpoint
+ for route in router.routes
+ if route.path == path and method in route.methods
+ )
+
+
+@pytest.mark.asyncio
+async def test_generic_settings_hide_and_preserve_retired_fallbacks(monkeypatch):
+ store = {
+ **settings_mod.DEFAULT_SETTINGS,
+ "default_model_fallbacks": list(LEGACY_VALUE),
+ "tts_enabled": True,
+ }
+
+ monkeypatch.setattr(auth_routes, "migrate_from_settings", lambda: None)
+ monkeypatch.setattr(auth_routes, "_load_settings", lambda: dict(store))
+
+ def save_settings(updated):
+ store.clear()
+ store.update(updated)
+
+ monkeypatch.setattr(auth_routes, "_save_settings", save_settings)
+ router = auth_routes.setup_auth_routes(_AuthManager())
+ get_settings = _route(router, "/api/auth/settings", "GET")
+ set_settings = _route(router, "/api/auth/settings", "POST")
+
+ anonymous = await get_settings(_Request())
+ admin = await get_settings(_Request(admin=True))
+
+ assert "default_model_fallbacks" not in anonymous
+ assert "default_model_fallbacks" not in admin
+ assert store["default_model_fallbacks"] == LEGACY_VALUE
+
+ response = await set_settings(_Request({
+ "default_model_fallbacks": [],
+ "tts_enabled": False,
+ }, admin=True))
+
+ assert "default_model_fallbacks" not in response
+ assert store["default_model_fallbacks"] == LEGACY_VALUE
+ assert store["tts_enabled"] is False
+
+
+def test_manage_settings_tombstones_legacy_fallback_key(monkeypatch):
+ store = {
+ **settings_mod.DEFAULT_SETTINGS,
+ "default_model_fallbacks": list(LEGACY_VALUE),
+ }
+ save_calls = []
+
+ class _Db:
+ def close(self):
+ return None
+
+ monkeypatch.setattr(database, "SessionLocal", lambda: _Db())
+ monkeypatch.setattr(settings_mod, "load_settings", lambda: dict(store))
+
+ def save_settings(updated):
+ save_calls.append(dict(updated))
+ store.clear()
+ store.update(updated)
+
+ monkeypatch.setattr(settings_mod, "save_settings", save_settings)
+
+ listed = asyncio.run(do_manage_settings(json.dumps({"action": "list"})))
+ assert "default_model_fallbacks" not in listed["settings"]
+
+ for action in ("get", "set", "reset", "delete"):
+ payload = {"action": action, "key": "default_model_fallbacks"}
+ if action == "set":
+ payload["value"] = []
+ result = asyncio.run(do_manage_settings(json.dumps(payload)))
+ assert result["exit_code"] == 1
+ assert "Unknown setting" in result["error"]
+
+ assert save_calls == []
+ assert store["default_model_fallbacks"] == LEGACY_VALUE
diff --git a/tests/test_tool_support_heuristic.py b/tests/test_tool_support_heuristic.py
index 468a210b5..e9947139f 100644
--- a/tests/test_tool_support_heuristic.py
+++ b/tests/test_tool_support_heuristic.py
@@ -6,8 +6,15 @@
2. api.deepseek.com must still be treated as tool-capable via the host
allow-list (_API_HOSTS), so cloud deepseek users keep working.
"""
+from types import SimpleNamespace
+
import pytest
-from src.agent_loop import _API_HOSTS, _endpoint_lookup_keys, _is_ollama_openai_compat_url
+from src.agent_loop import (
+ _API_HOSTS,
+ _agent_route_tool_mode,
+ _endpoint_lookup_keys,
+ _is_ollama_openai_compat_url,
+)
from src.llm_core import _is_ollama_native_url
@@ -164,3 +171,57 @@ def test_native_ollama_chat_url_matches_api_base(self):
keys = _endpoint_lookup_keys("http://host.docker.internal:11434/api/chat")
assert "http://host.docker.internal:11434/api" in keys
+
+
+def test_route_tool_mode_matches_credential_distinct_endpoint(monkeypatch):
+ from core import database
+ from src import endpoint_resolver
+
+ rows = [
+ SimpleNamespace(
+ id="one",
+ base_url="https://same.example/v1",
+ api_key="key-one",
+ provider_auth_id=None,
+ supports_tools=True,
+ ),
+ SimpleNamespace(
+ id="two",
+ base_url="https://same.example/v1",
+ api_key="key-two",
+ provider_auth_id=None,
+ supports_tools=False,
+ ),
+ ]
+
+ class Query:
+ def filter(self, *args, **kwargs):
+ return self
+
+ def all(self):
+ return rows
+
+ class Db:
+ def query(self, *args, **kwargs):
+ return Query()
+
+ def close(self):
+ return None
+
+ monkeypatch.setattr(database, "SessionLocal", lambda: Db())
+ monkeypatch.setattr(
+ endpoint_resolver,
+ "resolve_endpoint_runtime",
+ lambda endpoint, owner=None: (endpoint.base_url, endpoint.api_key),
+ )
+
+ assert _agent_route_tool_mode(
+ "https://same.example/v1",
+ "custom-model",
+ headers={"Authorization": "Bearer key-one"},
+ )[0] is True
+ assert _agent_route_tool_mode(
+ "https://same.example/v1",
+ "custom-model",
+ headers={"Authorization": "Bearer key-two"},
+ )[0] is False
diff --git a/tests/test_user_time.py b/tests/test_user_time.py
index f93017702..525ea3287 100644
--- a/tests/test_user_time.py
+++ b/tests/test_user_time.py
@@ -117,6 +117,31 @@ def test_agent_system_prompt_includes_shared_current_time(monkeypatch):
assert "Australia/Brisbane, UTC+10:00" in datetime_messages[0]["content"]
+def test_route_prompt_rebuild_restores_leading_user_system_message(monkeypatch):
+ import src.agent_loop as agent_loop
+
+ monkeypatch.setattr(agent_loop, "_build_base_prompt", lambda *args, **kwargs: ("AGENT PROMPT", ""))
+ monkeypatch.setattr(agent_loop, "set_active_model", lambda model: None)
+ monkeypatch.setattr(agent_loop, "get_builtin_overrides", lambda: {})
+ monkeypatch.setattr(agent_loop, "_cached_base_prompt", None)
+ monkeypatch.setattr(agent_loop, "_cached_base_prompt_key", None)
+
+ original = [
+ {"role": "system", "content": "USER PERSONA"},
+ {"role": "user", "content": "hello"},
+ ]
+ built, _ = agent_loop._build_system_prompt(
+ original,
+ model="selected-model",
+ active_document=None,
+ mcp_mgr=None,
+ )
+
+ assert built[0]["content"] == "USER PERSONA\n\nAGENT PROMPT"
+ assert built[0]["_agent_injected"] == "merged_prompt"
+ assert agent_loop._strip_agent_injected_messages(built) == original
+
+
def test_calendar_relative_time_parser_handles_dotted_pm(monkeypatch):
import routes.calendar_routes as calendar_routes