Skip to content
Closed
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
57 changes: 49 additions & 8 deletions gateway/platforms/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -970,6 +970,17 @@ def __init__(self, config: PlatformConfig):
self._model_routes: Dict[str, Dict[str, Any]] = self._parse_model_routes(
extra.get("model_routes"),
)
# direct_model_requests: opt-in passthrough for the request body's
# ``model`` field. When enabled, a value that matches neither the
# advertised model name nor a configured model_routes alias is run
# as-is on the default provider (via an ephemeral route, so session
# ``/model`` overrides keep precedence). Off by default: generic
# OpenAI clients routinely hardcode model names ("gpt-4o", ...), and
# existing deployments rely on those falling back to the gateway
# default rather than erroring on an unknown upstream model.
self._direct_model_requests: bool = _coerce_request_bool(
extra.get("direct_model_requests"), default=False
)
self._app: Optional["web.Application"] = None
self._runner: Optional["web.AppRunner"] = None
self._site: Optional["web.TCPSite"] = None
Expand Down Expand Up @@ -1731,6 +1742,33 @@ def _resolve_route(self, model_alias: Any) -> Optional[Dict[str, Any]]:
return None
return self._model_routes.get(model_alias)

def _resolve_request_route(self, model_value: Any) -> Optional[Dict[str, Any]]:
"""Resolve the request body's ``model`` field to a route.

Configured ``model_routes`` aliases always win. When
``direct_model_requests`` is enabled, an unconfigured value that
differs from the advertised model name synthesizes an ephemeral
``{"model": <value>}`` route so API consumers can select a model
per request. Reusing the route-application path in
``_create_agent`` keeps the architecture intact: an explicit
session ``/model`` override still beats the request value, and
provider/credential resolution stays with the configured
``model_routes`` entries (a synthetic route never carries
provider/api_key/base_url — the requested model runs on the
default provider's runtime).
"""
route = self._resolve_route(model_value)
if route is not None:
return route
if not self._direct_model_requests:
return None
if not isinstance(model_value, str):
return None
requested = model_value.strip()
if not requested or requested == self._model_name:
return None
return {"model": requested}

def _session_model_override_for(self, session_key: Optional[str]) -> Optional[Dict[str, Any]]:
"""Return the gateway's session ``/model`` override for *session_key*, if any.

Expand Down Expand Up @@ -2790,10 +2828,11 @@ async def _handle_chat_completions(self, request: "web.Request") -> "web.Respons
model_name = body.get("model", self._model_name)
created = int(time.time())

# Per-client model routing: if the requested model matches a
# configured model_routes alias, this request's agent is created
# with that route's model/provider instead of the global default.
route = self._resolve_route(model_name)
# Per-client model routing: a configured model_routes alias — or,
# with direct_model_requests enabled, any unconfigured model value —
# creates this request's agent with that model instead of the
# global default.
route = self._resolve_request_route(model_name)

if stream:
import queue as _q
Expand Down Expand Up @@ -3905,8 +3944,9 @@ async def _handle_responses(self, request: "web.Request") -> "web.Response":
# groups the entire conversation under one session entry.
session_id = stored_session_id or str(uuid.uuid4())

# Per-client model routing for /v1/responses (see model_routes).
route = self._resolve_route(body.get("model"))
# Per-client model routing for /v1/responses (see model_routes /
# direct_model_requests).
route = self._resolve_request_route(body.get("model"))

stream = _coerce_request_bool(body.get("stream"), default=False)
if stream:
Expand Down Expand Up @@ -4906,8 +4946,9 @@ def _text_cb(delta: Optional[str]) -> None:
model=body.get("model", self._model_name),
)

# Per-client model routing for /v1/runs (see model_routes).
route = self._resolve_route(body.get("model"))
# Per-client model routing for /v1/runs (see model_routes /
# direct_model_requests).
route = self._resolve_request_route(body.get("model"))
# Background task outlives the HTTP response (and thus the middleware
# profile scope). Capture now and re-enter inside the task/executor.
request_profile = _api_request_profile.get()
Expand Down
169 changes: 169 additions & 0 deletions tests/gateway/test_api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4431,3 +4431,172 @@ def list_sessions_rich(self, **kwargs):
hermes_state.SessionDB = original_class
auth_adapter._session_db = None
auth_adapter._session_db_lock = None


class TestDirectModelRequests:
"""Opt-in passthrough of the request body's ``model`` field.

With ``direct_model_requests: true``, an unconfigured model value is
salvaged through the existing route flow as an ephemeral
``{"model": <value>}`` route — session ``/model`` overrides keep
precedence and provider runtime resolution is untouched. Default-off
behavior (unknown model → gateway default) is asserted too.
"""

@staticmethod
def _make_adapter(direct: bool, routes=None) -> APIServerAdapter:
extra = {"model_routes": routes or {}}
if direct:
extra["direct_model_requests"] = True
return APIServerAdapter(PlatformConfig(enabled=True, extra=extra))

def test_resolver_disabled_by_default(self):
adapter = self._make_adapter(direct=False)
assert adapter._resolve_request_route("openai/gpt-5") is None

def test_resolver_synthesizes_route_when_enabled(self):
adapter = self._make_adapter(direct=True)
assert adapter._resolve_request_route("openai/gpt-5") == {"model": "openai/gpt-5"}

def test_resolver_ignores_advertised_model_name(self):
adapter = self._make_adapter(direct=True)
assert adapter._resolve_request_route(adapter._model_name) is None

def test_resolver_ignores_blank_and_non_string(self):
adapter = self._make_adapter(direct=True)
assert adapter._resolve_request_route(" ") is None
assert adapter._resolve_request_route(None) is None
assert adapter._resolve_request_route(123) is None

def test_configured_route_wins_over_direct_passthrough(self):
adapter = self._make_adapter(
direct=True,
routes={"alias": {"model": "minimax/minimax-m1", "provider": "openrouter"}},
)
assert adapter._resolve_request_route("alias") == {
"model": "minimax/minimax-m1", "provider": "openrouter",
}

@pytest.mark.asyncio
async def test_chat_completions_direct_model_reaches_run_agent(self):
adapter = self._make_adapter(direct=True)
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 = (
{"final_response": "hi", "messages": [], "api_calls": 1},
{"input_tokens": 5, "output_tokens": 5, "total_tokens": 10},
)
resp = await cli.post("/v1/chat/completions", json={
"model": "openai/gpt-5",
"messages": [{"role": "user", "content": "hello"}],
})
assert resp.status == 200
assert mock_run.call_args.kwargs.get("route") == {"model": "openai/gpt-5"}

@pytest.mark.asyncio
async def test_chat_completions_unknown_model_ignored_when_disabled(self):
adapter = self._make_adapter(direct=False)
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 = (
{"final_response": "hi", "messages": [], "api_calls": 1},
{"input_tokens": 5, "output_tokens": 5, "total_tokens": 10},
)
resp = await cli.post("/v1/chat/completions", json={
"model": "openai/gpt-5",
"messages": [{"role": "user", "content": "hello"}],
})
assert resp.status == 200
assert mock_run.call_args.kwargs.get("route") is None

@pytest.mark.asyncio
async def test_responses_api_direct_model_reaches_run_agent(self):
adapter = self._make_adapter(direct=True)
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 = (
{"final_response": "hi", "messages": [], "api_calls": 1},
{"input_tokens": 5, "output_tokens": 5, "total_tokens": 10},
)
resp = await cli.post("/v1/responses", json={
"model": "anthropic/claude-sonnet-4",
"input": "hello",
})
assert resp.status == 200
assert mock_run.call_args.kwargs.get("route") == {
"model": "anthropic/claude-sonnet-4",
}

@pytest.mark.asyncio
async def test_runs_direct_model_reaches_create_agent(self):
# /v1/runs passes route into _create_agent (not _run_agent).
adapter = self._make_adapter(direct=True)
app = _create_app(adapter)
app.router.add_post("/v1/runs", adapter._handle_runs)
app.router.add_get("/v1/runs/{run_id}", adapter._handle_get_run)
created = {}

class FakeAgent:
async def run_conversation(self, *a, **k):
return {"final_response": "hi", "messages": [], "api_calls": 1}

def get_token_usage(self):
return {"input_tokens": 5, "output_tokens": 5, "total_tokens": 10}

def _fake_create_agent(**kwargs):
created.update(kwargs)
return FakeAgent()

async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_create_agent", side_effect=_fake_create_agent), \
patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (
{"final_response": "hi", "messages": [], "api_calls": 1},
{"input_tokens": 5, "output_tokens": 5, "total_tokens": 10},
)
resp = await cli.post("/v1/runs", json={
"model": "openai/gpt-5",
"input": "hello",
})
assert resp.status == 202
run_id = (await resp.json())["run_id"]
# The run executes in a background task; poll until the
# route-carrying _create_agent call has happened.
for _ in range(100):
if "route" in created:
break
status_resp = await cli.get(f"/v1/runs/{run_id}")
status = (await status_resp.json()).get("status")
if status in ("completed", "failed", "cancelled"):
break
await asyncio.sleep(0.05)
assert created.get("route") == {"model": "openai/gpt-5"}

def test_session_model_override_beats_direct_request(self, monkeypatch):
"""An explicit session /model must beat the request body's model."""
captured = {}

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

_patch_create_agent_runtime(monkeypatch, captured, FakeAgent)
adapter = self._make_adapter(direct=True)
monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None)
monkeypatch.setattr(
adapter,
"_session_model_override_for",
lambda key: {"model": "session/override-model"},
)

adapter._create_agent(
session_id="s1",
route=adapter._resolve_request_route("openai/gpt-5"),
)

# Route must NOT be applied — session override wins (the gateway
# applies the actual /model override separately).
assert captured["model"] == "global/model"
Loading