Skip to content
Merged
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
3 changes: 2 additions & 1 deletion agent/models_dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,8 @@ def get_model_capabilities(provider: str, model: str) -> Optional[ModelCapabilit
else:
input_mods = None
if isinstance(input_mods, list):
supports_vision = "image" in input_mods
input_mod_values = {str(mod).strip().lower() for mod in input_mods}
supports_vision = "image" in input_mod_values
else:
supports_vision = bool(entry.get("attachment", False))
supports_reasoning = bool(entry.get("reasoning", False))
Expand Down
569 changes: 545 additions & 24 deletions gateway/platforms/api_server.py

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion hermes_cli/runtime_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -1431,6 +1431,7 @@ def resolve_runtime_provider(
explicit_api_key: Optional[str] = None,
explicit_base_url: Optional[str] = None,
target_model: Optional[str] = None,
allow_auto_codex_fallback: bool = True,
) -> Dict[str, Any]:
"""Resolve runtime provider credentials for agent execution.

Expand All @@ -1441,6 +1442,11 @@ def resolve_runtime_provider(
api_mode is derived from the model they are switching TO, not the stale
persisted default. Other callers can leave it None to preserve existing
behavior (api_mode derived from config).

allow_auto_codex_fallback: When False, an auto-detected Codex provider with
invalid credentials raises instead of falling through to another provider.
The normal chat path keeps the historical fallback behavior; utility
endpoints that must stay inside the Codex auth boundary can opt out.
"""
requested_provider = resolve_requested_provider(requested)

Expand Down Expand Up @@ -1662,7 +1668,7 @@ def resolve_runtime_provider(
"requested_provider": requested_provider,
}
except AuthError:
if requested_provider != "auto":
if requested_provider != "auto" or not allow_auto_codex_fallback:
raise
# Auto-detected Codex but credentials are stale/revoked —
# fall through to env-var providers (e.g. OpenRouter).
Expand Down
4 changes: 3 additions & 1 deletion run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1690,13 +1690,15 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo
if _is_multimodal_tool_result(content):
content = _multimodal_text_summary(content)
elif isinstance(content, list):
# List of OpenAI-style content parts: strip images, keep text.
# List of OpenAI-style content parts: strip media, keep text.
_txt = []
for p in content:
if isinstance(p, dict) and p.get("type") == "text":
_txt.append(str(p.get("text", "")))
elif isinstance(p, dict) and p.get("type") in {"image", "image_url", "input_image"}:
_txt.append("[screenshot]")
elif isinstance(p, dict) and p.get("type") in {"audio", "input_audio"}:
_txt.append("[audio]")
content = "\n".join(_txt) if _txt else None
tool_calls_data = None
if hasattr(msg, "tool_calls") and isinstance(msg.tool_calls, list) and msg.tool_calls:
Expand Down
5 changes: 5 additions & 0 deletions tests/agent/test_models_dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,3 +401,8 @@ def test_model_not_found_returns_none(self):
with patch("agent.models_dev.fetch_models_dev", return_value=CAPS_REGISTRY):
caps = get_model_capabilities("anthropic", "nonexistent-model")
assert caps is None

def test_provider_not_found_returns_none(self):
with patch("agent.models_dev.fetch_models_dev", return_value=CAPS_REGISTRY):
caps = get_model_capabilities("nonexistent-provider", "gemma-4-31b-it")
assert caps is None
89 changes: 89 additions & 0 deletions tests/gateway/test_api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,8 +534,10 @@ def _create_app(adapter: APIServerAdapter) -> web.Application:
app.router.add_get("/v1/capabilities", adapter._handle_capabilities)
app.router.add_get("/v1/skills", adapter._handle_skills)
app.router.add_get("/v1/toolsets", adapter._handle_toolsets)
app.router.add_post("/v1/audio/transcriptions", adapter._handle_audio_transcriptions)
app.router.add_post("/v1/chat/completions", adapter._handle_chat_completions)
app.router.add_post("/v1/responses", adapter._handle_responses)
app.router.add_post("/v1/runs", adapter._handle_runs)
app.router.add_get("/v1/responses/{response_id}", adapter._handle_get_response)
app.router.add_delete("/v1/responses/{response_id}", adapter._handle_delete_response)
return app
Expand Down Expand Up @@ -798,11 +800,35 @@ async def test_capabilities_advertises_plugin_safe_contract(self, adapter):
assert data["features"]["chat_completions"] is True
assert data["features"]["run_status"] is True
assert data["features"]["run_events_sse"] is True
assert data["features"]["audio_api"] is True
assert data["audio"]["transcription"] is True
assert data["audio"]["native_model"] is False
assert data["audio"]["max_bytes"] == 26214400
assert "ogg" in data["audio"]["formats"]
assert data["features"]["session_continuity_header"] == "X-Hermes-Session-Id"
assert data["endpoints"]["run_status"]["path"] == "/v1/runs/{run_id}"
assert data["endpoints"]["audio_transcriptions"] == {
"method": "POST",
"path": "/v1/audio/transcriptions",
}
assert data["endpoints"]["skills"] == {"method": "GET", "path": "/v1/skills"}
assert data["endpoints"]["toolsets"] == {"method": "GET", "path": "/v1/toolsets"}

@pytest.mark.asyncio
async def test_capabilities_never_reports_native_audio_from_runtime(self, adapter):
app = _create_app(adapter)
with patch("gateway.run._load_gateway_config", return_value={}), \
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"provider": "openai"}), \
patch("gateway.run._resolve_gateway_model", return_value="gpt-audio"):
async with TestClient(TestServer(app)) as cli:
resp = await cli.get("/v1/capabilities")
assert resp.status == 200
data = await resp.json()

assert data["features"]["audio_api"] is True
assert data["audio"]["transcription"] is True
assert data["audio"]["native_model"] is False

@pytest.mark.asyncio
async def test_capabilities_requires_auth_when_key_configured(self, auth_adapter):
app = _create_app(auth_adapter)
Expand All @@ -819,6 +845,69 @@ async def test_capabilities_requires_auth_when_key_configured(self, auth_adapter
assert data["auth"]["required"] is True


# ---------------------------------------------------------------------------
# /v1/runs audio preflight
# ---------------------------------------------------------------------------


class TestRunsAudioPreflight:
@pytest.mark.asyncio
async def test_runs_rejects_unsupported_audio_before_run_state(self, adapter):
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.post(
"/v1/runs",
json={
"input": [
{
"role": "user",
"content": [
{"type": "input_audio", "input_audio": {"data": "ZmFrZQ==", "format": "ogg"}},
],
}
],
},
)
data = await resp.json()

assert resp.status == 400
assert data["error"]["code"] == "unsupported_audio_input"
assert adapter._run_streams == {}
assert adapter._run_statuses == {}

@pytest.mark.asyncio
async def test_runs_rejects_previous_response_assistant_audio_before_run_state(self, adapter):
adapter._response_store.put(
"resp_prev",
{
"conversation_history": [
{
"role": "assistant",
"content": [
{"type": "input_audio", "input_audio": {"data": "ZmFrZQ==", "format": "ogg"}},
],
},
],
"session_id": "session_prev",
"instructions": None,
},
)

app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.post(
"/v1/runs",
json={"previous_response_id": "resp_prev", "input": "next"},
)
data = await resp.json()

assert resp.status == 400
assert data["error"]["code"] == "unsupported_audio_input"
assert data["error"]["param"] == "previous_response_id"
assert adapter._run_streams == {}
assert adapter._run_statuses == {}


# ---------------------------------------------------------------------------
# /v1/skills and /v1/toolsets endpoints
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading