Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions gateway/platforms/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,7 @@ def _create_agent(
self,
ephemeral_system_prompt: Optional[str] = None,
session_id: Optional[str] = None,
requested_model: Optional[str] = None,
stream_delta_callback=None,
tool_progress_callback=None,
tool_start_callback=None,
Expand Down Expand Up @@ -826,7 +827,8 @@ def _create_agent(

runtime_kwargs = _resolve_runtime_agent_kwargs()
reasoning_config = GatewayRunner._load_reasoning_config()
model = _resolve_gateway_model()
runtime_model = runtime_kwargs.pop("model", None)
model = requested_model or runtime_model or _resolve_gateway_model()

user_config = _load_gateway_config()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main's model_routes path resolves provider-specific runtime credentials before changing the model (gateway/platforms/api_server.py:1299-1324). Selecting a raw request model here leaves the global provider/API key/base URL intact, so cross-provider model strings need a provider-resolution contract rather than only this precedence change.

enabled_toolsets = sorted(_get_platform_tools(user_config, "api_server"))
Expand Down Expand Up @@ -858,6 +860,17 @@ def _create_agent(
)
return agent

def _resolve_request_model(self, body: Dict[str, Any]):
raw_model = body.get("model")
if not isinstance(raw_model, str):
return self._model_name, None
model = raw_model.strip()
if not model:
return self._model_name, None
if model == self._model_name:
return model, None
return model, model

# ------------------------------------------------------------------
# HTTP Handlers
# ------------------------------------------------------------------
Expand Down Expand Up @@ -1082,7 +1095,7 @@ async def _handle_chat_completions(self, request: "web.Request") -> "web.Respons
# history already set from request body above

completion_id = f"chatcmpl-{uuid.uuid4().hex[:29]}"
model_name = body.get("model", self._model_name)
model_name, requested_model = self._resolve_request_model(body)
created = int(time.time())

if stream:
Expand Down Expand Up @@ -1167,6 +1180,7 @@ def _on_tool_complete(tool_call_id, function_name, function_args, function_resul
tool_complete_callback=_on_tool_complete,
agent_ref=agent_ref,
gateway_session_key=gateway_session_key,
requested_model=requested_model,
))
# Ensure SSE drain loops can terminate without relying on polling
# agent_task.done(), which can race with queue timeout checks.
Expand All @@ -1186,6 +1200,7 @@ async def _compute_completion():
ephemeral_system_prompt=system_prompt,
session_id=session_id,
gateway_session_key=gateway_session_key,
requested_model=requested_model,
)

idempotency_key = request.headers.get("Idempotency-Key")
Expand Down Expand Up @@ -1252,11 +1267,18 @@ async def _compute_completion():
# Soft-partial path: we have *some* text but the run did not complete
# (e.g. truncation with partial buffered output). Still 200 but signal
# truncation via finish_reason="length" + Hermes-specific extras.
#
# Prefer the upstream-reported model id when present so clients see
# the concrete backend a routing gateway actually served (e.g. an
# alias "high-accuracy" → "high-accuracy-m3"). Fall back to the
# client-supplied name when the upstream did not advertise one.
upstream_model = result.get("last_response_model")
response_model = upstream_model if isinstance(upstream_model, str) and upstream_model else model_name
response_data = {
"id": completion_id,
"object": "chat.completion",
"created": created,
"model": model_name,
"model": response_model,
"choices": [
{
"index": 0,
Expand Down Expand Up @@ -2690,6 +2712,7 @@ async def _run_agent(
tool_complete_callback=None,
agent_ref: Optional[list] = None,
gateway_session_key: Optional[str] = None,
requested_model: Optional[str] = None,
) -> tuple:
"""
Create an agent and run a conversation in a thread executor.
Expand All @@ -2713,6 +2736,7 @@ def _run():
tool_start_callback=tool_start_callback,
tool_complete_callback=tool_complete_callback,
gateway_session_key=gateway_session_key,
requested_model=requested_model,
)
if agent_ref is not None:
agent_ref[0] = agent
Expand Down
19 changes: 19 additions & 0 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1396,6 +1396,14 @@ def __init__(
self._interrupt_message = None # Optional message that triggered interrupt
self._execution_thread_id: int | None = None # Set at run_conversation() start
self._interrupt_thread_signal_pending = False

# Tracks the upstream-reported model id from the last API response.
# Routing gateways (e.g. an alias like "high-accuracy" that fans out
# to "high-accuracy-m1/m2/m3") populate ``response.model`` with the
# concrete backend they actually served. We surface this through
# ``run_conversation()`` so callers can report it to the end user
# instead of echoing the alias the client originally sent.
self._last_response_model: Optional[str] = None
self._client_lock = threading.RLock()

# /steer mechanism — inject a user note into the next tool result
Expand Down Expand Up @@ -14495,6 +14503,16 @@ def _stop_spinner():
self._persist_session(messages, conversation_history)
break

# Capture the upstream-reported model id (e.g. routing gateways
# that turn "high-accuracy" into "high-accuracy-m3"). We stamp
# this on every successful response so the last assignment
# reflects the final turn; falsy values are ignored so a
# provider that omits the field doesn't blank out an earlier
# known value.
_resp_model = getattr(response, "model", None)
if isinstance(_resp_model, str) and _resp_model:
self._last_response_model = _resp_model

try:
_transport = self._get_transport()
_normalize_kwargs = {}
Expand Down Expand Up @@ -15576,6 +15594,7 @@ def _stop_spinner():
"interrupted": interrupted,
"response_previewed": getattr(self, "_response_was_previewed", False),
"model": self.model,
"last_response_model": self._last_response_model,
"provider": self.provider,
"base_url": self.base_url,
"input_tokens": self.session_input_tokens,
Expand Down
83 changes: 83 additions & 0 deletions tests/gateway/test_api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,36 @@ def __init__(self, **kwargs):
assert isinstance(agent, FakeAgent)
assert captured["reasoning_config"] == {"enabled": True, "effort": "xhigh"}

def test_create_agent_uses_requested_model(self, monkeypatch):
captured = {}

class FakeAgent:
def __init__(self, **kwargs):
captured.update(kwargs)

monkeypatch.setattr("run_agent.AIAgent", FakeAgent)
monkeypatch.setattr(
"gateway.run._resolve_runtime_agent_kwargs",
lambda: {
"provider": "openai",
"base_url": "https://example.test/v1",
"api_mode": "chat_completions",
},
)
monkeypatch.setattr("gateway.run._resolve_gateway_model", lambda: "configured-model")
monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {})
monkeypatch.setattr("gateway.run.GatewayRunner._load_reasoning_config", staticmethod(lambda: None))
monkeypatch.setattr("gateway.run.GatewayRunner._load_fallback_model", staticmethod(lambda: None))
monkeypatch.setattr("hermes_cli.tools_config._get_platform_tools", lambda *_: set())

adapter = APIServerAdapter(PlatformConfig(enabled=True))
monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None)

agent = adapter._create_agent(requested_model="request-model")

assert isinstance(agent, FakeAgent)
assert captured["model"] == "request-model"


# ---------------------------------------------------------------------------
# Auth checking
Expand Down Expand Up @@ -1092,6 +1122,59 @@ async def test_successful_completion(self, adapter):
assert data["choices"][0]["finish_reason"] == "stop"
assert "usage" in data

@pytest.mark.asyncio
async def test_chat_completion_model_is_passed_to_agent(self, adapter):
mock_result = {"final_response": "ok", "messages": [], "api_calls": 1}

app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
resp = await cli.post(
"/v1/chat/completions",
json={
"model": "gpt-requested",
"messages": [{"role": "user", "content": "Hello"}],
},
)

assert resp.status == 200
data = await resp.json()
assert data["model"] == "gpt-requested"
assert mock_run.call_args.kwargs["requested_model"] == "gpt-requested"

@pytest.mark.asyncio
async def test_chat_completion_reports_upstream_model(self, adapter):
"""When the upstream surfaces a concrete model (routing-gateway alias
like ``high-accuracy`` → ``high-accuracy-m3``), the response should
advertise the concrete backend instead of echoing the alias the
client originally sent.
"""
mock_result = {
"final_response": "ok",
"messages": [],
"api_calls": 1,
"last_response_model": "high-accuracy-m3",
}

app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
resp = await cli.post(
"/v1/chat/completions",
json={
"model": "high-accuracy",
"messages": [{"role": "user", "content": "Hello"}],
},
)

assert resp.status == 200
data = await resp.json()
assert data["model"] == "high-accuracy-m3"
# The alias still drives provider selection / agent kwargs.
assert mock_run.call_args.kwargs["requested_model"] == "high-accuracy"

@pytest.mark.asyncio
async def test_system_prompt_extracted(self, adapter):
"""System messages from the client are passed as ephemeral_system_prompt."""
Expand Down