odysseus/tests/test_llm_core_fallback.py
Alexandre Teixeira c4369305f0
refactor(model-routing): centralize explicit foreground fallback policy (#6020)
* refactor(model-routing): centralize explicit foreground fallback policy

Make foreground fallback an explicit per-user, availability-only policy shared by streaming Chat, non-stream Chat, and Agent runs.

Preserve strict defaults, owner/model and credential boundaries, pinned Agent routes, and truthful per-round provenance/accounting. Carry provider-reported model identifiers through native streaming adapters, non-stream responses, and caches, and keep legacy default_model_fallbacks as tombstoned raw storage that generic settings APIs and agent tools cannot expose or mutate.

* fix(agent-loop): restore rebase-dropped qwen routing, workspace prompt, and temperature clamp

* fix(model-routing): thread selected endpoint identity, fix cost classification and fallback eligibility

* fix(chat): restore stream helpers and harden run stop lifecycle

* fix(model-routing): let numeric provider codes win over symbolic rate-limit statuses

* fix(agent-loop): apply qwen temperature and notes-tool clamps per fallback candidate

* fix(chat): honor queued stop across resend and reload canonical terminal on EOF

* fix(chat): track stop queue and cleanup ownership by per-send generation

* fix(agent-loop): preserve requested temperature for non-qwen fallback candidates

* fix(chat): reserve send ownership before any await and scope stop to the current send

* fix(chat): clear the previous run identity at send reservation

---------

Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com>
Co-authored-by: StressTestor <212606152+StressTestor@users.noreply.github.com>
2026-08-14 08:10:30 +01:00

1493 lines
50 KiB
Python

"""Tests for the fallback indicator in stream_llm_with_fallback.
When the selected model fails *before output* and another candidate answers,
a `fallback` event must be emitted so the switch is never masked under the
selected model's name (which is how a misconfigured provider can look like it
works while a different model silently answers).
"""
import json
import asyncio
import httpx
import pytest
from fastapi import HTTPException
from src import llm_core
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):
for ln in per_model(model):
yield ln
monkeypatch.setattr(llm_core, "stream_llm", fake_stream)
async def run():
out = []
async for c in llm_core.stream_llm_with_fallback(
[("u1", "primary", {}), ("u2", "backup", {})],
[{"role": "user", "content": "hi"}],
**fallback_kwargs,
):
out.append(c)
return out
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":
return ['event: error\ndata: {"status": 400, "text": "Provider X returned HTTP 400"}\n\n']
return ['data: {"delta": "hello"}\n\n', "data: [DONE]\n\n"]
chunks = _run_fallback(monkeypatch, per_model)
fb = [json.loads(c[6:]) for c in chunks if c.startswith("data: ") and '"fallback"' in c]
assert fb, f"no fallback event in {chunks}"
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]
assert order == sorted(order)
assert any('"delta": "hello"' in c for c in chunks)
def test_no_fallback_event_when_primary_succeeds(monkeypatch):
def per_model(model):
return ['data: {"delta": "ok"}\n\n', "data: [DONE]\n\n"]
chunks = _run_fallback(monkeypatch, per_model)
assert not any('"fallback"' in c for c in chunks)
def test_done_only_primary_invokes_fallback(monkeypatch):
calls = []
def per_model(model):
calls.append(model)
if model == "primary":
return ["data: [DONE]\n\n"]
return [
'data: {"type": "model_actual", "requested_model": "backup", "model": "backup-v2"}\n\n',
'data: {"delta": "backup answer"}\n\n',
"data: [DONE]\n\n",
]
chunks = _run_fallback(monkeypatch, per_model)
assert calls == ["primary", "backup"]
assert any('"delta": "backup answer"' in c for c in chunks)
model_idx = next(i for i, c in enumerate(chunks) if '"model_actual"' in c)
fallback_idx = next(i for i, c in enumerate(chunks) if '"fallback"' in c)
answer_idx = next(i for i, c in enumerate(chunks) if '"delta": "backup answer"' in c)
assert fallback_idx < model_idx < answer_idx
def test_usage_then_done_primary_invokes_fallback_and_discards_usage(monkeypatch):
calls = []
def per_model(model):
calls.append(model)
if model == "primary":
return [
'data: {"type": "usage", "data": {"input_tokens": 4, "output_tokens": 0}}\n\n',
"data: [DONE]\n\n",
]
return ['data: {"delta": "backup answer"}\n\n', "data: [DONE]\n\n"]
chunks = _run_fallback(monkeypatch, per_model)
assert calls == ["primary", "backup"]
assert not any('"type": "usage"' in c for c in chunks)
@pytest.mark.parametrize(
"output_chunk",
[
'data: {"delta": "visible text"}\n\n',
'data: {"delta": "reasoning", "thinking": true}\n\n',
],
)
def test_text_or_reasoning_output_prevents_fallback(monkeypatch, output_chunk):
calls = []
def per_model(model):
calls.append(model)
return [output_chunk, "data: [DONE]\n\n"]
chunks = _run_fallback(monkeypatch, per_model)
assert calls == ["primary"]
assert output_chunk in chunks
assert not any('"fallback"' in c for c in chunks)
def test_foreground_whitespace_only_delta_surfaces_empty_response_without_fallback(monkeypatch):
calls = []
whitespace = 'data: {"delta": " "}\n\n'
def per_model(model):
calls.append(model)
return [whitespace, "data: [DONE]\n\n"]
chunks = _run_fallback(monkeypatch, per_model, fallback_on_empty=False)
assert calls == ["primary"]
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):
calls = []
tool_calls = 'data: {"type": "tool_calls", "calls": [{"id": "c1", "name": "bash", "arguments": "{}"}]}\n\n'
def per_model(model):
calls.append(model)
return [tool_calls, "data: [DONE]\n\n"]
chunks = _run_fallback(monkeypatch, per_model)
assert calls == ["primary"]
assert tool_calls in chunks
assert not any('"fallback"' in c for c in chunks)
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'
tool_calls = 'data: {"type": "tool_calls", "calls": [{"id": "c1", "name": "write_file", "arguments": "{\\"path\\":\\"x\\"}"}]}\n\n'
async def fake_stream(url, model, messages, **kw):
nonlocal advanced_past_delta
calls.append(model)
yield tool_delta
advanced_past_delta = True
yield tool_calls
yield "data: [DONE]\n\n"
monkeypatch.setattr(llm_core, "stream_llm", fake_stream)
async def run():
stream = llm_core.stream_llm_with_fallback(
[("u1", "primary", {}), ("u2", "backup", {})],
[{"role": "user", "content": "hi"}],
)
first = await anext(stream)
assert first == tool_delta
assert advanced_past_delta
chunks = [first]
async for chunk in stream:
chunks.append(chunk)
return chunks
chunks = asyncio.run(run())
assert calls == ["primary"]
assert tool_calls in chunks
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 = []
def per_model(model):
calls.append(model)
if model == "primary":
return [] # clean EOF without substantive output
return ["data: [DONE]\n\n"]
chunks = _run_fallback(monkeypatch, per_model)
assert calls == ["primary", "backup"]
errors = [c for c in chunks if c.startswith("event: error")]
assert len(errors) == 1
assert "All model candidates returned no substantive output" in errors[0]
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(
"symbolic_status",
["RATE_LIMITED", "RATE_LIMIT_EXCEEDED", "RESOURCE_EXHAUSTED"],
)
def test_provider_symbolic_rate_limited_status_is_availability_evidence(symbolic_status):
assert llm_core._provider_stream_error_status({
"status": symbolic_status,
"message": "Request quota reached",
}) == 429
def test_provider_unknown_symbolic_status_still_fails_closed():
assert llm_core._provider_stream_error_status({
"status": "PERMISSION_DENIED",
"message": "denied",
}) == 400
def test_provider_numeric_code_wins_over_symbolic_rate_limit_status():
# Google-style payloads pair a symbolic status with a numeric code; the
# numeric truth must surface instead of advancing fallback on the symbol.
assert llm_core._provider_stream_error_status({
"status": "RATE_LIMITED",
"code": 401,
"message": "bad key",
}) == 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.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.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.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_models"),
[
(httpx.PoolTimeout("pool timed out"), ["selected", "backup"]),
(httpx.WriteTimeout("write timed out"), ["selected"]),
],
)
def test_nonstream_foreground_advances_on_pool_timeout_but_not_write_timeout(
monkeypatch,
error,
expected_models,
):
calls = []
async def fake_post(client, url, headers, **kwargs):
model = kwargs["json"]["model"]
calls.append(model)
if model == "selected":
raise error
return httpx.Response(
200,
request=httpx.Request("POST", url),
json={"choices": [{"message": {"content": "backup answer"}}]},
)
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)
if isinstance(error, httpx.PoolTimeout):
response, route, actual_model = 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={504},
)
)
assert response == "backup answer"
assert route[1] == "backup"
assert actual_model == "backup"
else:
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={504},
))
assert getattr(exc.value, "fallback_eligible", None) is False
assert calls == expected_models
@pytest.mark.parametrize(
("error", "expected_models"),
[
(httpx.PoolTimeout("pool timed out"), ["selected", "backup"]),
(httpx.WriteTimeout("write timed out"), ["selected"]),
],
)
def test_stream_foreground_advances_on_pool_timeout_but_not_write_timeout(
monkeypatch,
error,
expected_models,
):
calls = []
class _RouteClient:
def stream(self, method, url, **kwargs):
model = kwargs["json"]["model"]
calls.append(model)
if model == "selected":
class _FailureContext:
async def __aenter__(self):
raise error
async def __aexit__(self, *args):
return False
return _FailureContext()
return _ProviderStreamContext([
'data: {"choices":[{"delta":{"content":"backup answer"}}]}',
"data: [DONE]",
])
monkeypatch.setattr(llm_core, "_get_http_client", lambda: _RouteClient())
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_with_fallback(
[
("https://selected.example/v1", "selected", {}),
("https://backup.example/v1", "backup", {}),
],
[{"role": "user", "content": "hi"}],
fallback_statuses={504},
fallback_on_empty=False,
)
]
chunks = asyncio.run(run())
assert calls == expected_models
if isinstance(error, httpx.PoolTimeout):
assert any('"delta": "backup answer"' in chunk for chunk in chunks)
assert any('"type": "fallback"' in chunk for chunk in chunks)
else:
assert not any('"delta": "backup answer"' in chunk for chunk in chunks)
assert chunks[0].startswith("event: error")
@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():
"""Exact route repeats are dropped while credential-distinct routes remain."""
cands = [
("u1", "m1", {"h": 1}), # first u1/m1 — kept
("u1", "m1", {"h": 2}), # same provider/model, different credential — kept
("u2", "m2", {}), # distinct — kept
("u1", "m1", {"h": 1}), # exact repeat — dropped
(None, "x", {}), # malformed (no url) — dropped
("u3", "", {}), # malformed (no model) — dropped
]
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):
"""A fallback that repeats the primary's (url, model) must NOT make the chain
sail back into the same dead route — each distinct route is tried once."""
calls = []
async def fake_stream(url, model, messages, **kw):
calls.append((url, model))
yield 'event: error\ndata: {"status": 503, "text": "down"}\n\n'
monkeypatch.setattr(llm_core, "stream_llm", fake_stream)
async def run():
out = []
cands = [("u1", "m1", {}), ("u1", "m1", {}), ("u2", "m2", {})]
async for c in llm_core.stream_llm_with_fallback(cands, [{"role": "user", "content": "hi"}]):
out.append(c)
return out
asyncio.run(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"
assert llm_core._summarize_stream_error("garbage") == "primary model failed"