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
4 changes: 2 additions & 2 deletions hermes_cli/model_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -957,7 +957,7 @@ def switch_model(
elif target_provider == "custom" and current_base_url:
api_key = current_api_key
base_url = current_base_url
api_mode = determine_api_mode(target_provider, base_url)
api_mode = determine_api_mode(target_provider, base_url, new_model)
else:
try:
runtime = resolve_runtime_provider(
Expand Down Expand Up @@ -1089,7 +1089,7 @@ def switch_model(

# --- Determine api_mode if not already set ---
if not api_mode:
api_mode = determine_api_mode(target_provider, base_url)
api_mode = determine_api_mode(target_provider, base_url, new_model)

# OpenCode base URLs end with /v1 for OpenAI-compatible models, but the
# Anthropic SDK prepends its own /v1/messages to the base_url. Strip the
Expand Down
34 changes: 34 additions & 0 deletions hermes_cli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3264,6 +3264,40 @@ def azure_foundry_model_api_mode(model_name: Optional[str]) -> Optional[str]:
return None


def openai_model_api_mode(model_name: Optional[str]) -> Optional[str]:
"""Infer the api_mode for a direct ``api.openai.com`` model.

OpenAI serves two wire protocols on the same host and they are NOT
interchangeable per model:

* Reasoning families (GPT-5.x, o1/o3/o4, codex) require the Responses
API (``codex_responses``). ``/chat/completions`` 400s on these.
* Chat models (GPT-4.1, GPT-4o, GPT-4 Turbo, GPT-3.5, …) require
Chat Completions. The Responses API rejects them with HTTP 400
("Encrypted content is not supported with this model").

Hardcoding ``api.openai.com → codex_responses`` (the prior behaviour)
therefore broke every non-reasoning OpenAI model. This mirrors
``azure_foundry_model_api_mode`` — Azure Foundry hosts the same OpenAI
families, so the Responses-only prefix set is identical.

Returns ``None`` for an unknown/empty model so callers preserve their
existing default (api.openai.com historically defaulted to the
Responses API, which is correct for the GPT-5.x family Hermes ships as
the ``openai-api`` default).
"""
raw = str(model_name or "").strip().lower()
if not raw:
return None
# Strip any vendor/ prefix a user may have copied from OpenRouter / Copilot.
if "/" in raw:
raw = raw.rsplit("/", 1)[-1]
for prefix in _AZURE_FOUNDRY_RESPONSES_PREFIXES:
if raw.startswith(prefix):
return "codex_responses"
return "chat_completions"

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.

This fallback classifies every nonempty unknown ID as Chat Completions, despite the documented unknown-model behavior preserving the Responses default. Please return None unless this is a recognized GPT-3.5/GPT-4 chat family; otherwise a future reasoning model such as gpt-6-preview is silently downgraded.


def normalize_opencode_model_id(provider_id: Optional[str], model_id: Optional[str]) -> str:
"""Normalize OpenCode config IDs to the bare model slug used in API requests."""
provider = normalize_provider(provider_id)
Expand Down
13 changes: 10 additions & 3 deletions hermes_cli/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -499,14 +499,21 @@ def is_aggregator(provider: str) -> bool:
return pdef.is_aggregator if pdef else False


def determine_api_mode(provider: str, base_url: str = "") -> str:
def determine_api_mode(provider: str, base_url: str = "", model: str = "") -> str:
"""Determine the API mode (wire protocol) for a provider/endpoint.

Resolution order:
1. Known provider → transport → TRANSPORT_TO_API_MODE.
2. URL heuristics for unknown / custom providers.
3. Default: 'chat_completions'.

``model`` disambiguates api.openai.com, which serves reasoning families
(GPT-5.x, o-series, codex) on the Responses API and chat families
(GPT-4.1, GPT-4o, …) on Chat Completions. Omitting it preserves the
historical Responses-API default for that host.
"""
from hermes_cli.models import openai_model_api_mode

pdef = get_provider(provider)
if pdef is not None:
# Even for known providers, check URL heuristics for special endpoints
Expand All @@ -518,7 +525,7 @@ def determine_api_mode(provider: str, base_url: str = "") -> str:
if url_lower.endswith("/anthropic") or "api.anthropic.com" in url_lower:
return "anthropic_messages"
if "api.openai.com" in url_lower:
return "codex_responses"
return openai_model_api_mode(model) or "codex_responses"
return TRANSPORT_TO_API_MODE.get(pdef.transport, "chat_completions")

# Direct provider checks for providers not in HERMES_OVERLAYS
Expand All @@ -534,7 +541,7 @@ def determine_api_mode(provider: str, base_url: str = "") -> str:
if hostname == "api.kimi.com" and "/coding" in url_lower:
return "anthropic_messages"
if hostname == "api.openai.com":
return "codex_responses"
return openai_model_api_mode(model) or "codex_responses"
if hostname.startswith("bedrock-runtime.") and base_url_host_matches(base_url, "amazonaws.com"):
return "bedrock_converse"

Expand Down
39 changes: 27 additions & 12 deletions hermes_cli/runtime_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,14 @@ def _config_base_url_trustworthy_for_bare_custom(cfg_base_url: str, cfg_provider
return _loopback_hostname(base_url_hostname(bu))


def _detect_api_mode_for_url(base_url: str) -> Optional[str]:
def _detect_api_mode_for_url(base_url: str, model: Optional[str] = None) -> Optional[str]:
"""Auto-detect api_mode from the resolved base URL.

- Direct api.openai.com endpoints need the Responses API for GPT-5.x
tool calls with reasoning (chat/completions returns 400).
- Direct api.openai.com endpoints serve reasoning families (GPT-5.x,
o1/o3/o4, codex) on the Responses API and chat families (GPT-4.1,
GPT-4o, …) on Chat Completions — the two are NOT interchangeable per
model. ``model`` selects the right one via ``openai_model_api_mode``;
when ``model`` is unknown we keep the historical Responses default.
- Third-party Anthropic-compatible gateways (MiniMax, Zhipu GLM,
LiteLLM proxies, etc.) conventionally expose the native Anthropic
protocol under a ``/anthropic`` suffix — treat those as
Expand All @@ -93,7 +96,9 @@ def _detect_api_mode_for_url(base_url: str) -> Optional[str]:
if hostname == "api.x.ai":
return "codex_responses"
if hostname == "api.openai.com":
return "codex_responses"
from hermes_cli.models import openai_model_api_mode

return openai_model_api_mode(model) or "codex_responses"
path = urlparse(normalized).path.rstrip("/")
if path.endswith("/anthropic") or path.endswith("/anthropic/v1"):
return "anthropic_messages"
Expand Down Expand Up @@ -395,9 +400,9 @@ def _resolve_runtime_from_pool_entry(
api_mode = configured_mode
else:
# Auto-detect Anthropic-compatible endpoints (/anthropic suffix,
# Kimi /coding, api.openai.com → codex_responses, api.x.ai →
# codex_responses).
detected = _detect_api_mode_for_url(base_url)
# Kimi /coding, api.openai.com → model-aware Responses/Chat,
# api.x.ai → codex_responses).
detected = _detect_api_mode_for_url(base_url, effective_model)
if detected:
api_mode = detected

Expand Down Expand Up @@ -852,6 +857,7 @@ def _resolve_openrouter_runtime(
requested_provider: str,
explicit_api_key: Optional[str] = None,
explicit_base_url: Optional[str] = None,
target_model: Optional[str] = None,
) -> Dict[str, Any]:
model_cfg = _get_model_config()
cfg_base_url = model_cfg.get("base_url") if isinstance(model_cfg.get("base_url"), str) else ""
Expand Down Expand Up @@ -979,7 +985,7 @@ def _resolve_openrouter_runtime(
return {
"provider": effective_provider,
"api_mode": _parse_api_mode(model_cfg.get("api_mode"))
or _detect_api_mode_for_url(base_url)
or _detect_api_mode_for_url(base_url, target_model or model_cfg.get("default", ""))
or "chat_completions",
"base_url": base_url,
"api_key": api_key,
Expand Down Expand Up @@ -1163,6 +1169,7 @@ def _resolve_explicit_runtime(
model_cfg: Dict[str, Any],
explicit_api_key: Optional[str] = None,
explicit_base_url: Optional[str] = None,
target_model: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
explicit_api_key = str(explicit_api_key or "").strip()
explicit_base_url = str(explicit_base_url or "").strip().rstrip("/")
Expand Down Expand Up @@ -1291,8 +1298,11 @@ def _resolve_explicit_runtime(
api_mode = configured_mode
else:
# Auto-detect from URL (Anthropic /anthropic suffix,
# api.openai.com → Responses, Kimi /coding, etc.).
detected = _detect_api_mode_for_url(base_url)
# api.openai.com → model-aware Responses/Chat, Kimi /coding,
# etc.).
detected = _detect_api_mode_for_url(
base_url, target_model or model_cfg.get("default", "")
)
if detected:
api_mode = detected

Expand Down Expand Up @@ -1383,6 +1393,7 @@ def resolve_runtime_provider(
model_cfg=model_cfg,
explicit_api_key=explicit_api_key,
explicit_base_url=explicit_base_url,
target_model=target_model,
)
if explicit_runtime:
return explicit_runtime
Expand Down Expand Up @@ -1746,8 +1757,11 @@ def resolve_runtime_provider(
else:
# Auto-detect Anthropic-compatible endpoints by URL convention
# (e.g. https://api.minimax.io/anthropic, https://dashscope.../anthropic)
# plus api.openai.com → codex_responses and api.x.ai → codex_responses.
detected = _detect_api_mode_for_url(base_url)
# plus api.openai.com → model-aware Responses/Chat and
# api.x.ai → codex_responses.
detected = _detect_api_mode_for_url(
base_url, target_model or model_cfg.get("default", "")
)
if detected:
api_mode = detected
# Strip trailing /v1 for OpenCode Anthropic models (see comment above).
Expand All @@ -1766,6 +1780,7 @@ def resolve_runtime_provider(
requested_provider=requested_provider,
explicit_api_key=explicit_api_key,
explicit_base_url=explicit_base_url,
target_model=target_model,
)
runtime["requested_provider"] = requested_provider
return runtime
Expand Down
39 changes: 39 additions & 0 deletions tests/hermes_cli/test_api_key_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,45 @@ def test_runtime_copilot_uses_responses_for_gpt_5_4(self, monkeypatch):
assert result["provider"] == "copilot"
assert result["api_mode"] == "codex_responses"

def test_runtime_openai_api_chat_model_uses_chat_completions(self, monkeypatch):
"""Regression: --provider openai-api -m gpt-4.1 forced codex_responses,
and the Responses API 400s chat models ("Encrypted content is not
supported with this model"). It must resolve to chat_completions."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-test")
monkeypatch.setattr(
"hermes_cli.runtime_provider._get_model_config",
lambda: {"provider": "openai-api", "default": "gpt-5.5"},
)
from hermes_cli.runtime_provider import resolve_runtime_provider
result = resolve_runtime_provider(requested="openai-api", target_model="gpt-4.1")
assert result["provider"] == "openai-api"
assert result["api_mode"] == "chat_completions"
assert result["base_url"] == "https://api.openai.com/v1"
assert result["api_key"] == "sk-openai-test"

def test_runtime_openai_api_reasoning_model_uses_codex_responses(self, monkeypatch):
"""GPT-5.x / o-series remain on the Responses API — they require it."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-test")
monkeypatch.setattr(
"hermes_cli.runtime_provider._get_model_config",
lambda: {"provider": "openai-api", "default": "gpt-4.1"},
)
from hermes_cli.runtime_provider import resolve_runtime_provider
for model in ("gpt-5.5", "o4-mini"):
result = resolve_runtime_provider(requested="openai-api", target_model=model)
assert result["api_mode"] == "codex_responses", model

def test_runtime_openai_api_defaults_to_config_model(self, monkeypatch):
"""With no target_model, the config default model selects the api_mode."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-test")
monkeypatch.setattr(
"hermes_cli.runtime_provider._get_model_config",
lambda: {"provider": "openai-api", "default": "gpt-4o"},
)
from hermes_cli.runtime_provider import resolve_runtime_provider
result = resolve_runtime_provider(requested="openai-api")
assert result["api_mode"] == "chat_completions"

def test_runtime_copilot_acp_uses_process_runtime(self, monkeypatch):
monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: f"/usr/local/bin/{command}")
monkeypatch.setenv("HERMES_COPILOT_ACP_ARGS", "--acp --stdio --debug")
Expand Down
44 changes: 44 additions & 0 deletions tests/hermes_cli/test_detect_api_mode_for_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@

from __future__ import annotations

from hermes_cli.models import openai_model_api_mode
from hermes_cli.runtime_provider import _detect_api_mode_for_url


class TestCodexResponsesDetection:
def test_openai_api_returns_codex_responses(self):
# No model supplied → historical Responses-API default is preserved.
assert _detect_api_mode_for_url("https://api.openai.com/v1") == "codex_responses"

def test_xai_api_returns_codex_responses(self):
Expand Down Expand Up @@ -68,6 +70,48 @@ def test_anthropic_endpoint_subpath_does_not_match(self):
assert _detect_api_mode_for_url("https://api.example.com/anthropic/v1/models") is None


class TestOpenAIModelAwareDetection:
"""api.openai.com serves reasoning families on the Responses API and chat
families on Chat Completions — the helper must pick per-model when a model
is known (regression: GPT-4.1 / GPT-4o 400'd on the forced Responses API).
"""

def test_chat_model_uses_chat_completions(self):
assert _detect_api_mode_for_url("https://api.openai.com/v1", "gpt-4.1") == "chat_completions"

def test_gpt_4o_uses_chat_completions(self):
assert _detect_api_mode_for_url("https://api.openai.com/v1", "gpt-4o") == "chat_completions"

def test_reasoning_model_uses_codex_responses(self):
assert _detect_api_mode_for_url("https://api.openai.com/v1", "gpt-5.5") == "codex_responses"

def test_o_series_uses_codex_responses(self):
assert _detect_api_mode_for_url("https://api.openai.com/v1", "o4-mini") == "codex_responses"

def test_unknown_model_preserves_responses_default(self):
assert _detect_api_mode_for_url("https://api.openai.com/v1", "") == "codex_responses"


class TestOpenAIModelApiMode:
"""Unit coverage for the shared hermes_cli.models.openai_model_api_mode helper."""

def test_gpt_chat_families_are_chat_completions(self):
for model in ("gpt-4.1", "gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-3.5-turbo"):
assert openai_model_api_mode(model) == "chat_completions", model

def test_reasoning_families_are_codex_responses(self):
for model in ("gpt-5", "gpt-5.5", "gpt-5.5-pro", "o1", "o1-preview", "o3-mini", "o4-mini", "codex-mini"):
assert openai_model_api_mode(model) == "codex_responses", model

def test_vendor_prefix_is_stripped(self):
assert openai_model_api_mode("openai/gpt-4.1") == "chat_completions"
assert openai_model_api_mode("openai/gpt-5.5") == "codex_responses"

def test_unknown_or_empty_returns_none(self):
assert openai_model_api_mode("") is None
assert openai_model_api_mode(None) is None


class TestDefaultCase:
def test_generic_url_returns_none(self):
assert _detect_api_mode_for_url("https://api.together.xyz/v1") is None
Expand Down
19 changes: 19 additions & 0 deletions tests/hermes_cli/test_determine_api_mode_hostname.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,25 @@
from hermes_cli.providers import determine_api_mode


class TestOpenAIModelAware:
"""api.openai.com is model-dependent: reasoning families → Responses API,
chat families → Chat Completions. Threading the model fixes GPT-4.1/GPT-4o
which 400'd on the previously-forced Responses API.
"""

def test_chat_model_is_chat_completions(self):
assert determine_api_mode("openai-api", "https://api.openai.com/v1", "gpt-4.1") == "chat_completions"

def test_reasoning_model_is_codex_responses(self):
assert determine_api_mode("openai-api", "https://api.openai.com/v1", "gpt-5.5") == "codex_responses"

def test_o_series_is_codex_responses(self):
assert determine_api_mode("openai-api", "https://api.openai.com/v1", "o4-mini") == "codex_responses"

def test_no_model_preserves_responses_default(self):
assert determine_api_mode("openai-api", "https://api.openai.com/v1") == "codex_responses"


class TestOpenAIHostHardening:
def test_native_openai_url_is_codex_responses(self):
assert determine_api_mode("", "https://api.openai.com/v1") == "codex_responses"
Expand Down