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

@ -5524,3 +5524,141 @@ class TestRouteWithoutModelKeepsDefault:
assert captured["model"] == "global/model"
assert captured["api_key"] == "sk-route"
# ---------------------------------------------------------------------------
# Empty-model recovery + provider-auth error typing in _create_agent
# (salvaged from PR #57947 by @FvanW)
# ---------------------------------------------------------------------------
class TestCreateAgentModelRecovery:
def test_create_agent_defaults_to_provider_catalog_model_when_empty(self, monkeypatch):
"""api_server.py had no equivalent of run.py's provider-catalog
default when model resolves empty but a provider did resolve (e.g.
`hermes auth add openai-codex` without `hermes model`)
AIAgent(model="") 400s every call."""
captured = {}
class FakeAgent:
def __init__(self, **kwargs):
captured.update(kwargs)
_patch_create_agent_runtime(monkeypatch, captured, FakeAgent)
monkeypatch.setattr(
"gateway.run._resolve_runtime_agent_kwargs",
lambda: {"provider": "openai-codex", "base_url": "https://example.test/v1",
"api_mode": "codex_responses"},
)
monkeypatch.setattr("gateway.run._resolve_gateway_model", lambda: "")
monkeypatch.setattr(
"hermes_cli.models.get_default_model_for_provider",
lambda provider: "gpt-5.5-codex" if provider == "openai-codex" else None,
)
adapter = APIServerAdapter(PlatformConfig(enabled=True))
monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None)
agent = adapter._create_agent(session_id="api-session")
assert isinstance(agent, FakeAgent)
assert captured["model"] == "gpt-5.5-codex"
def test_create_agent_recovers_last_known_good_model_when_empty(self, monkeypatch):
"""Last-known-good recovery (#35314): a transient config-cache miss
producing an empty model would build AIAgent(model="") and fail every
call until manual retry, instead of reusing the model that just
worked."""
captured = []
class FakeAgent:
def __init__(self, **kwargs):
captured.append(dict(kwargs))
_patch_create_agent_runtime(monkeypatch, {}, FakeAgent)
monkeypatch.setattr("run_agent.AIAgent", FakeAgent)
adapter = APIServerAdapter(PlatformConfig(enabled=True))
monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None)
# Turn 1: model resolves fine — populates the last-known-good cache
# (keyed on gateway_session_key).
monkeypatch.setattr("gateway.run._resolve_gateway_model", lambda: "minimax/minimax-m3")
adapter._create_agent(session_id="api-session", gateway_session_key="stable-chan-1")
assert captured[0]["model"] == "minimax/minimax-m3"
assert adapter._last_resolved_model["stable-chan-1"] == "minimax/minimax-m3"
# Turn 2: transient empty resolution, no provider catalog default —
# must recover the model from turn 1, not build model="".
monkeypatch.setattr("gateway.run._resolve_gateway_model", lambda: "")
monkeypatch.setattr(
"gateway.run._resolve_runtime_agent_kwargs",
lambda: {"provider": None, "base_url": None, "api_mode": None},
)
adapter._create_agent(session_id="another-session", gateway_session_key="stable-chan-1")
assert captured[1]["model"] == "minimax/minimax-m3"
def test_last_resolved_model_cache_does_not_grow_per_ephemeral_session_id(self, monkeypatch):
"""/v1/responses and /v1/runs hand out a fresh UUID session_id per
one-off request (no gateway_session_key). Keying the last-known-good
cache on session_id would leave one permanent dict entry per
stateless request, growing unbounded for the life of the process.
Only gateway_session_key may create an entry."""
class FakeAgent:
def __init__(self, **kwargs):
pass
_patch_create_agent_runtime(monkeypatch, {}, FakeAgent)
adapter = APIServerAdapter(PlatformConfig(enabled=True))
monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None)
import uuid as _uuid
for _ in range(50):
adapter._create_agent(session_id=str(_uuid.uuid4()))
# Only the "*" process-wide fallback may exist — never one entry per
# ephemeral session_id.
assert list(adapter._last_resolved_model.keys()) == ["*"]
def test_create_agent_wraps_runtime_credential_failure_as_provider_auth_error(self, monkeypatch):
"""_create_agent() must convert a RuntimeError from
gateway.run._resolve_runtime_agent_kwargs() the sole raiser of
RuntimeError(format_runtime_provider_error(...)) in this call graph
into _ProviderAuthResolutionError right at the call site, independent
of any caller's exception handling."""
from gateway.platforms.api_server import _ProviderAuthResolutionError
monkeypatch.setattr(
"gateway.run._resolve_runtime_agent_kwargs",
lambda: (_ for _ in ()).throw(RuntimeError("No credentials found for provider 'nous'")),
)
adapter = APIServerAdapter(PlatformConfig(enabled=True))
monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None)
with pytest.raises(_ProviderAuthResolutionError, match="No credentials found for provider 'nous'"):
adapter._create_agent(session_id="api-session")
def test_create_agent_session_model_pins_ahead_of_request(self, monkeypatch):
"""Session-persisted model beats per-request body values but yields
to an explicit session /model override."""
captured = {}
class FakeAgent:
def __init__(self, **kwargs):
captured.update(kwargs)
_patch_create_agent_runtime(monkeypatch, captured, FakeAgent)
adapter = APIServerAdapter(PlatformConfig(enabled=True))
monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None)
monkeypatch.setattr(adapter, "_session_model_override_for", lambda *_: None)
adapter._create_agent(
session_id="s1",
session_model="session-row/model",
requested_model="request/model",
)
assert captured["model"] == "session-row/model"