Compare commits

...

1 Commits

Author SHA1 Message Date
Tranquil-Flow
1059f68bca fix(gateway): preserve runtime provider model in agent handoff (#48061)
`_resolve_runtime_agent_kwargs()` did not surface the runtime provider's
explicit `model`, and the one caller that handled it did so inline. Other agent
construction sites (`api_server._create_agent`, the Feishu comment path) built
the agent from `**runtime_kwargs` without consuming a runtime `model`, so when
the runtime provider supplied an explicit model it either collided with the
separate `model=` constructor arg or was dropped entirely — the gateway sent an
empty/wrong runtime model (`MODEL:'' PROVIDER:None`).

Surface `model` in `_resolve_runtime_agent_kwargs()` and extract the
apply-and-pop logic into a shared `_consume_runtime_model(model, runtime_kwargs)`
helper. Apply it at every agent-construction site that forwards
`**runtime_kwargs`:
  - `GatewayRunner` (refactored from the existing inline consume)
  - `api_server._create_agent` (the #48061 root path)
  - `feishu_comment._resolve_model_and_runtime` (sibling call site)

This closes the whole bug class — `model` is consumed as the explicit `model=`
arg at each site instead of leaking through `**runtime_kwargs`.

Salvaged from #49899 by Tranquil-Flow (authorship preserved).

Tests: tests/gateway/test_runtime_provider_model_handoff.py (4) — the runtime
model is applied and popped so it can't collide; tests/gateway/test_api_server.py
(167) green.

Fixes #48061
2026-06-23 02:24:46 +05:30
5 changed files with 166 additions and 9 deletions

View File

@@ -1087,6 +1087,7 @@ class APIServerAdapter(BasePlatformAdapter):
"""
from run_agent import AIAgent
from gateway.run import (
_consume_runtime_model,
_current_max_iterations,
_resolve_runtime_agent_kwargs,
_resolve_gateway_model,
@@ -1098,6 +1099,7 @@ class APIServerAdapter(BasePlatformAdapter):
runtime_kwargs = _resolve_runtime_agent_kwargs()
reasoning_config = GatewayRunner._load_reasoning_config()
model = _resolve_gateway_model()
model, runtime_kwargs = _consume_runtime_model(model, runtime_kwargs)
user_config = _load_gateway_config()
enabled_toolsets = sorted(_get_platform_tools(user_config, "api_server"))

View File

@@ -1790,9 +1790,29 @@ def _resolve_runtime_agent_kwargs() -> dict:
"args": list(runtime.get("args") or []),
"credential_pool": runtime.get("credential_pool"),
"max_tokens": max_tokens,
"model": runtime.get("model"),
}
def _consume_runtime_model(model: str, runtime_kwargs: dict) -> tuple[str, dict]:
"""Apply and remove a runtime-provider model from agent kwargs.
Runtime provider resolution may supply an explicit model in addition to
credentials. AIAgent still receives ``model`` as a separate constructor
argument, so callers must consume this handoff key instead of forwarding it
through ``**runtime_kwargs`` where it would collide with ``model=...``.
"""
runtime_model = runtime_kwargs.pop("model", None)
if runtime_model:
logger.info(
"Runtime provider supplied explicit model override: %s -> %s",
model,
runtime_model,
)
model = runtime_model
return model, runtime_kwargs
def _try_resolve_fallback_provider() -> dict | None:
"""Attempt to resolve credentials from the fallback_model/fallback_providers config."""
from hermes_cli.runtime_provider import resolve_runtime_provider
@@ -3358,14 +3378,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
)
runtime_kwargs = _resolve_runtime_agent_kwargs()
runtime_model = runtime_kwargs.pop("model", None)
if runtime_model:
logger.info(
"Runtime provider supplied explicit model override: %s -> %s",
model,
runtime_model,
)
model = runtime_model
model, runtime_kwargs = _consume_runtime_model(model, runtime_kwargs)
if override and resolved_session_key:
model, runtime_kwargs = self._apply_session_model_override(
resolved_session_key, model, runtime_kwargs
@@ -8753,6 +8766,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
from agent.model_metadata import get_model_context_length
_msg_cwd = os.environ.get("TERMINAL_CWD", os.path.expanduser("~"))
# Probe-only use: we read base_url/provider/api_key fields for
# context-length/credential resolution and never splat this dict
# into AIAgent, so the "model" key it now carries is ignored here
# (no _consume_runtime_model needed). See _consume_runtime_model.
_msg_runtime = _resolve_runtime_agent_kwargs()
_msg_config_ctx = None
try:

View File

@@ -979,8 +979,9 @@ def _resolve_model_and_runtime() -> Tuple[str, dict]:
user_config = _load_gateway_config()
model = _resolve_gateway_model(user_config)
from gateway.run import _resolve_runtime_agent_kwargs
from gateway.run import _consume_runtime_model, _resolve_runtime_agent_kwargs
runtime_kwargs = _resolve_runtime_agent_kwargs()
model, runtime_kwargs = _consume_runtime_model(model, runtime_kwargs)
# Fall back to provider's default model if none configured
if not model and runtime_kwargs.get("provider"):

View File

@@ -337,6 +337,41 @@ class TestAdapterInit:
assert isinstance(agent, FakeAgent)
assert captured["reasoning_config"] == {"enabled": True, "effort": "xhigh"}
def test_create_agent_consumes_runtime_provider_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": "custom-runtime",
"base_url": "https://example.test/v1",
"api_mode": "openai",
"model": "runtime/model-from-provider",
},
)
monkeypatch.setattr("gateway.run._resolve_gateway_model", lambda: "")
monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {})
monkeypatch.setattr(
"gateway.run.GatewayRunner._load_reasoning_config",
staticmethod(lambda: {}),
)
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(session_id="api-session")
assert isinstance(agent, FakeAgent)
assert captured["model"] == "runtime/model-from-provider"
assert captured["provider"] == "custom-runtime"
def test_create_agent_refreshes_max_iterations_from_runtime_config(self, monkeypatch):
captured = {}

View File

@@ -0,0 +1,102 @@
from gateway import run as gateway_run
def _runner():
runner = gateway_run.GatewayRunner.__new__(gateway_run.GatewayRunner)
runner._session_model_overrides = {}
runner._last_resolved_model = {}
return runner
def test_runtime_agent_kwargs_preserves_explicit_provider_model(monkeypatch):
def fake_resolve_runtime_provider():
return {
"api_key": "sk-test",
"base_url": "https://example.test/v1",
"provider": "custom-runtime",
"api_mode": "openai",
"model": "runtime/model-from-provider",
}
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
fake_resolve_runtime_provider,
)
monkeypatch.setattr(
"hermes_cli.runtime_provider._get_model_config",
lambda: {"default": ""},
)
kwargs = gateway_run._resolve_runtime_agent_kwargs()
assert kwargs["provider"] == "custom-runtime"
assert kwargs["model"] == "runtime/model-from-provider"
def test_session_runtime_uses_provider_model_when_config_model_empty(monkeypatch):
def fake_resolve_runtime_provider():
return {
"api_key": "sk-test",
"base_url": "https://example.test/v1",
"provider": "custom-runtime",
"api_mode": "openai",
"model": "runtime/model-from-provider",
}
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
fake_resolve_runtime_provider,
)
monkeypatch.setattr(
"hermes_cli.runtime_provider._get_model_config",
lambda: {"default": ""},
)
monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda _cfg=None: "")
model, runtime_kwargs = _runner()._resolve_session_agent_runtime(session_key="sid")
assert model == "runtime/model-from-provider"
assert "model" not in runtime_kwargs
assert runtime_kwargs["provider"] == "custom-runtime"
def test_consume_runtime_model_leaves_kwargs_safe_for_agent_constructor():
model, runtime_kwargs = gateway_run._consume_runtime_model(
"",
{
"provider": "custom-runtime",
"model": "runtime/model-from-provider",
},
)
assert model == "runtime/model-from-provider"
assert runtime_kwargs == {"provider": "custom-runtime"}
def test_session_runtime_keeps_provider_default_fallback_without_runtime_model(monkeypatch):
def fake_resolve_runtime_provider():
return {
"api_key": "sk-test",
"base_url": "https://example.test/v1",
"provider": "custom-runtime",
"api_mode": "openai",
}
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
fake_resolve_runtime_provider,
)
monkeypatch.setattr(
"hermes_cli.runtime_provider._get_model_config",
lambda: {"default": ""},
)
monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda _cfg=None: "")
monkeypatch.setattr(
"hermes_cli.models.get_default_model_for_provider",
lambda provider: "catalog/default" if provider == "custom-runtime" else "",
)
model, runtime_kwargs = _runner()._resolve_session_agent_runtime(session_key="sid")
assert model == "catalog/default"
assert runtime_kwargs["provider"] == "custom-runtime"