fix(api_server): close divergence gaps from gateway/run.py

Three parity fixes between the API server and the native gateway's
agent-runtime resolution, integrated with the provider-aware request
routing that landed in #70853:

- Session-persisted model is honored: POST /api/sessions {"model": ...}
  stores a model that the chat handlers previously fetched and threw
  away. A stored value that matches a model_routes alias goes through
  the route path (route provider/credentials apply); a raw model string
  threads through as session_model, pinning the session's turns ahead
  of per-request body values but below an explicit session /model
  override.
- Empty-model recovery: provider-catalog default when config has no
  model.default but a provider resolved, plus last-known-good model
  recovery (#35314) keyed on gateway_session_key only (never ephemeral
  session_id — no unbounded growth from one-off requests).
- Provider auth failures surface as controlled responses: RuntimeError
  from _resolve_runtime_agent_kwargs() is re-raised as a dedicated
  _ProviderAuthResolutionError at the call site, caught narrowly in
  _run_agent() and the /v1/runs executor to return run.py's response
  shape instead of an undifferentiated 500 (session-chat endpoints
  previously returned a raw aiohttp 500 with no JSON body).

Salvaged from PR #57947 by @FvanW; session-model route-alias resolution
from PR #59941 by @kaishi00.

Co-authored-by: kaishi00 <kaishi00@users.noreply.github.com>
This commit is contained in:
Frederick 2026-07-24 11:37:18 -07:00 committed by Teknium
parent 0f732cb3d6
commit 7dd00bb47d
5 changed files with 552 additions and 11 deletions

View file

@ -438,3 +438,182 @@ async def test_session_header_rejected_without_api_key(adapter, session_db):
assert resp.status == 403
data = await resp.json()
assert "X-Hermes-Session-Key requires API key" in data["error"]["message"]
# ---------------------------------------------------------------------------
# Session-persisted model threading + provider-auth failure surfacing
# (salvaged from PR #57947 by @FvanW and PR #59941 by @kaishi00)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_session_chat_threads_session_model_to_run_agent(auth_adapter, session_db):
"""POST /api/sessions persists a per-session model, but the chat handler
previously fetched the session record and threw it away the session's
chosen model silently had no effect on any chat turn."""
session_id = session_db.create_session("model-pinned-session", "api_server", model="claude-sonnet-4-6")
mock_run = AsyncMock(return_value=({"final_response": "ok", "session_id": session_id}, {"total_tokens": 1}))
app = _create_session_app(auth_adapter)
with patch.object(auth_adapter, "_run_agent", mock_run):
async with TestClient(TestServer(app)) as cli:
resp = await cli.post(
f"/api/sessions/{session_id}/chat",
json={"message": "hi"},
headers={"Authorization": "Bearer sk-test"},
)
assert resp.status == 200
mock_run.assert_awaited_once()
_, kwargs = mock_run.call_args
assert kwargs["session_model"] == "claude-sonnet-4-6"
@pytest.mark.asyncio
async def test_session_chat_stream_threads_session_model_to_run_agent(adapter, session_db):
"""Streaming twin of the session-model threading test above."""
session_id = session_db.create_session("model-pinned-stream-session", "api_server", model="gpt-5.5")
mock_run = AsyncMock(return_value=({"final_response": "ok", "session_id": session_id}, {"total_tokens": 1}))
app = _create_session_app(adapter)
with patch.object(adapter, "_run_agent", mock_run):
async with TestClient(TestServer(app)) as cli:
resp = await cli.post(
f"/api/sessions/{session_id}/chat/stream",
json={"message": "hi"},
)
assert resp.status == 200
await resp.read()
mock_run.assert_awaited_once()
_, kwargs = mock_run.call_args
assert kwargs["session_model"] == "gpt-5.5"
@pytest.mark.asyncio
async def test_session_chat_resolves_stored_model_route_alias(session_db, monkeypatch):
"""A session-persisted model that matches a model_routes alias must go
through the route path (so route provider/credentials apply) and NOT be
passed as a raw session_model (idea from PR #59941 by @kaishi00)."""
adapter = APIServerAdapter(
PlatformConfig(
enabled=True,
extra={"model_routes": {"alias": {"model": "route/model", "provider": "openrouter"}}},
)
)
adapter._session_db = session_db
session_id = session_db.create_session("route-pinned-session", "api_server", model="alias")
mock_run = AsyncMock(return_value=({"final_response": "ok", "session_id": session_id}, {"total_tokens": 1}))
app = _create_session_app(adapter)
with patch.object(adapter, "_run_agent", mock_run):
async with TestClient(TestServer(app)) as cli:
resp = await cli.post(
f"/api/sessions/{session_id}/chat",
json={"message": "hi"},
)
assert resp.status == 200
_, kwargs = mock_run.call_args
assert kwargs["route"] == {"model": "route/model", "provider": "openrouter"}
assert kwargs["session_model"] is None
@pytest.mark.asyncio
async def test_run_agent_returns_controlled_response_on_provider_auth_failure(adapter, monkeypatch):
"""_resolve_runtime_agent_kwargs() (inside _create_agent()) raises
RuntimeError on provider auth/credential failure. Previously this
propagated unhandled out of _run_agent(): /v1/chat/completions caught it
as a generic 500, and /api/sessions/{id}/chat didn't catch it at all
(raw aiohttp 500, no JSON body). Must now return run.py's controlled
response shape instead of raising. Exercises the REAL boundary
(gateway.run._resolve_runtime_agent_kwargs, the sole raiser)."""
monkeypatch.setattr(
"gateway.run._resolve_runtime_agent_kwargs",
lambda: (_ for _ in ()).throw(
RuntimeError("No credentials found for provider 'nous' — run `hermes auth add nous`")
),
)
result, usage = await adapter._run_agent(
user_message="hello",
conversation_history=[],
session_id="request-session",
)
assert result == {
"final_response": "⚠️ Provider authentication failed: No credentials found for provider 'nous' — run `hermes auth add nous`",
"messages": [],
"api_calls": 0,
"tools": [],
}
assert usage == {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
@pytest.mark.asyncio
async def test_run_agent_does_not_swallow_unrelated_exceptions(adapter, monkeypatch):
"""The _ProviderAuthResolutionError catch must stay narrow — a TypeError
elsewhere in _create_agent()/run_conversation() must still propagate."""
def fake_create_agent(**kwargs):
raise TypeError("unrelated bug: unexpected keyword argument")
monkeypatch.setattr(adapter, "_create_agent", fake_create_agent)
with pytest.raises(TypeError, match="unrelated bug"):
await adapter._run_agent(
user_message="hello",
conversation_history=[],
session_id="request-session",
)
@pytest.mark.asyncio
async def test_run_agent_does_not_swallow_unrelated_runtime_error_from_run_conversation(adapter, monkeypatch):
"""agent.run_conversation() can legitimately raise a RuntimeError
unrelated to provider auth (e.g. run_agent.py's "Failed to recreate
closed OpenAI client"). A bare `except RuntimeError` around the whole
_create_agent()+run_conversation() span would mislabel it as
"Provider authentication failed". Only _ProviderAuthResolutionError
raised exclusively inside _create_agent() at the
_resolve_runtime_agent_kwargs() call site may trigger the controlled
response; this unrelated RuntimeError must propagate unhandled."""
class _FakeAgent:
def run_conversation(self, **kwargs):
raise RuntimeError("Failed to recreate closed OpenAI client")
monkeypatch.setattr(adapter, "_create_agent", lambda **kwargs: _FakeAgent())
with pytest.raises(RuntimeError, match="Failed to recreate closed OpenAI client"):
await adapter._run_agent(
user_message="hello",
conversation_history=[],
session_id="request-session",
)
@pytest.mark.asyncio
async def test_session_chat_surfaces_controlled_response_on_provider_auth_failure(auth_adapter, session_db, monkeypatch):
"""End-to-end: POST /api/sessions/{id}/chat previously had zero wrapping
around _run_agent() an unhandled RuntimeError produced a raw aiohttp
500 with no JSON body. Must now return 200 with the controlled error
message as the assistant content. Exercises the real
gateway.run._resolve_runtime_agent_kwargs() boundary, not a mocked
_create_agent()."""
session_id = session_db.create_session("auth-fail-session", "api_server")
monkeypatch.setattr(
"gateway.run._resolve_runtime_agent_kwargs",
lambda: (_ for _ in ()).throw(RuntimeError("Auth failed: token expired")),
)
app = _create_session_app(auth_adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.post(
f"/api/sessions/{session_id}/chat",
json={"message": "hi"},
headers={"Authorization": "Bearer sk-test"},
)
assert resp.status == 200
payload = await resp.json()
assert payload["message"]["content"] == "⚠️ Provider authentication failed: Auth failed: token expired"