diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 0765c72cecb4..6e9db9ad732b 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -203,6 +203,19 @@ def _parse_api_mode(raw: Any) -> Optional[str]: return None +def _parse_transport_api_mode(raw: Any) -> Optional[str]: + """Validate a provider transport value. + + ``codex_app_server`` is a runtime host, not an endpoint transport. Custom + providers must resolve to ``codex_responses`` first, then opt into the + app-server via ``model.openai_runtime``. + """ + mode = _parse_api_mode(raw) + if mode == "codex_app_server": + return None + return mode + + def _maybe_apply_codex_app_server_runtime( *, provider: str, @@ -210,18 +223,25 @@ def _maybe_apply_codex_app_server_runtime( model_cfg: Optional[Dict[str, Any]], ) -> str: """Optional opt-in: rewrite api_mode → "codex_app_server" for OpenAI/Codex - providers when the user has explicitly enabled that runtime via - `model.openai_runtime: codex_app_server` in config.yaml. + providers, or a custom Codex Responses proxy, when the user has explicitly + enabled that runtime via `model.openai_runtime: codex_app_server` in + config.yaml. Default behavior is preserved: when the key is unset, "auto", or empty, - this function is a no-op. Only providers in {"openai", "openai-codex"} - are eligible — other providers (anthropic, openrouter, etc.) cannot be - rerouted through codex. + this function is a no-op. Only providers in {"openai", "openai-codex"} and + custom providers that already resolved to ``codex_responses`` are eligible + — other providers (anthropic, openrouter, custom chat proxies, etc.) cannot + be rerouted through codex. Returns the (possibly-rewritten) api_mode.""" if not model_cfg: return api_mode - if provider not in {"openai", "openai-codex"}: + provider_norm = (provider or "").strip().lower() + api_mode_norm = (api_mode or "").strip().lower() + eligible_provider = provider_norm in {"openai", "openai-codex"} or ( + provider_norm == "custom" and api_mode_norm == "codex_responses" + ) + if not eligible_provider: return api_mode runtime = str(model_cfg.get("openai_runtime") or "").strip().lower() if runtime == "codex_app_server": @@ -478,7 +498,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An # already does, so without this lift every migrated config # silently downgrades codex_responses / anthropic_messages # providers to chat_completions in the resolved runtime. - api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport")) + api_mode = _parse_transport_api_mode(entry.get("api_mode") or entry.get("transport")) if api_mode: result["api_mode"] = api_mode return result @@ -496,7 +516,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An "api_key": resolved_api_key, "model": entry.get("default_model", ""), } - api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport")) + api_mode = _parse_transport_api_mode(entry.get("api_mode") or entry.get("transport")) if api_mode: result["api_mode"] = api_mode return result @@ -539,7 +559,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An result["key_env"] = key_env if provider_key: result["provider_key"] = provider_key - api_mode = _parse_api_mode(entry.get("api_mode")) + api_mode = _parse_transport_api_mode(entry.get("api_mode")) if api_mode: result["api_mode"] = api_mode model_name = str(entry.get("model", "") or "").strip() @@ -575,10 +595,12 @@ def _resolve_named_custom_runtime( pass if requested_norm == "custom" and explicit_base_url: base_url = explicit_base_url.strip().rstrip("/") + model_cfg = _get_model_config() + configured_mode = _parse_transport_api_mode(model_cfg.get("api_mode")) # Check credential pool first — mirrors the named-custom-provider path # so bare `provider: custom` with a configured custom_providers entry # also gets its api_key from the pool instead of env var fallbacks. - pool_result = _try_resolve_from_custom_pool(base_url, "custom", None) + pool_result = _try_resolve_from_custom_pool(base_url, "custom", configured_mode) if pool_result: pool_result["source"] = "direct-alias" return pool_result @@ -593,7 +615,9 @@ def _resolve_named_custom_runtime( ) or "no-key-required" return { "provider": "custom", - "api_mode": _detect_api_mode_for_url(base_url) or "chat_completions", + "api_mode": configured_mode + or _detect_api_mode_for_url(base_url) + or "chat_completions", "base_url": base_url, "api_key": api_key, "source": "direct-alias", @@ -746,8 +770,9 @@ def _resolve_openrouter_runtime( if effective_provider == "custom" and base_url: # Pass requested_provider so pool lookup prefers name match over base_url, # fixing credential mix-ups when multiple custom providers share a base_url. + configured_mode = _parse_transport_api_mode(model_cfg.get("api_mode")) pool_result = _try_resolve_from_custom_pool( - base_url, effective_provider, _parse_api_mode(model_cfg.get("api_mode")), + base_url, effective_provider, configured_mode, provider_name=requested_provider if requested_norm != "custom" else None, ) if pool_result: @@ -758,7 +783,11 @@ def _resolve_openrouter_runtime( return { "provider": effective_provider, - "api_mode": _parse_api_mode(model_cfg.get("api_mode")) + "api_mode": ( + _parse_transport_api_mode(model_cfg.get("api_mode")) + if effective_provider == "custom" + else _parse_api_mode(model_cfg.get("api_mode")) + ) or _detect_api_mode_for_url(base_url) or "chat_completions", "base_url": base_url, @@ -1142,6 +1171,11 @@ def resolve_runtime_provider( explicit_base_url=explicit_base_url, ) if custom_runtime: + custom_runtime["api_mode"] = _maybe_apply_codex_app_server_runtime( + provider=str(custom_runtime.get("provider") or ""), + api_mode=str(custom_runtime.get("api_mode") or "chat_completions"), + model_cfg=_get_model_config(), + ) custom_runtime["requested_provider"] = requested_provider return custom_runtime @@ -1543,6 +1577,11 @@ def resolve_runtime_provider( explicit_api_key=explicit_api_key, explicit_base_url=explicit_base_url, ) + runtime["api_mode"] = _maybe_apply_codex_app_server_runtime( + provider=str(runtime.get("provider") or ""), + api_mode=str(runtime.get("api_mode") or "chat_completions"), + model_cfg=model_cfg, + ) runtime["requested_provider"] = requested_provider return runtime diff --git a/skills/autonomous-ai-agents/codex/SKILL.md b/skills/autonomous-ai-agents/codex/SKILL.md index a796852b7547..93e1d9dad09f 100644 --- a/skills/autonomous-ai-agents/codex/SKILL.md +++ b/skills/autonomous-ai-agents/codex/SKILL.md @@ -33,8 +33,12 @@ Requires the codex CLI and a git repository. - Use `pty=true` in terminal calls — Codex is an interactive terminal app For Hermes itself, `model.provider: openai-codex` uses Hermes-managed Codex -OAuth from `~/.hermes/auth.json` after `hermes auth add openai-codex`. For the -standalone Codex CLI, a valid CLI OAuth session may live under +OAuth from `~/.hermes/auth.json` after `hermes auth add openai-codex`. Hermes' +Codex app-server runtime also supports named custom providers when they declare +`transport: codex_responses` or legacy `api_mode: codex_responses`; do not use +`transport: codex_app_server` in the provider entry. Plain custom +chat-completions providers are intentionally left on the default runtime. +For the standalone Codex CLI, a valid CLI OAuth session may live under `~/.codex/auth.json`; do not treat a missing `OPENAI_API_KEY` alone as proof that Codex auth is missing. diff --git a/tests/agent/transports/test_codex_app_server_runtime.py b/tests/agent/transports/test_codex_app_server_runtime.py index 55bbc8bc6d34..3ae1581ca233 100644 --- a/tests/agent/transports/test_codex_app_server_runtime.py +++ b/tests/agent/transports/test_codex_app_server_runtime.py @@ -70,6 +70,22 @@ def test_opt_in_rewrites_openai_codex(self) -> None: ) assert got == "codex_app_server" + def test_opt_in_rewrites_custom_codex_responses_proxy(self) -> None: + got = _maybe_apply_codex_app_server_runtime( + provider="custom", + api_mode="codex_responses", + model_cfg={"openai_runtime": "codex_app_server"}, + ) + assert got == "codex_app_server" + + def test_custom_chat_proxy_is_not_rerouted(self) -> None: + got = _maybe_apply_codex_app_server_runtime( + provider="custom", + api_mode="chat_completions", + model_cfg={"openai_runtime": "codex_app_server"}, + ) + assert got == "chat_completions" + def test_case_insensitive(self) -> None: got = _maybe_apply_codex_app_server_runtime( provider="openai", diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index db2b314f2f53..0cf13d7b1619 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -792,6 +792,262 @@ def test_named_custom_provider_uses_providers_dict_when_list_missing(monkeypatch assert resolved["model"] == "gpt-5-mini" +def test_named_custom_codex_responses_runtime_uses_codex_app_server(monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False) + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "model": { + "provider": "custom:codex-proxy", + "default": "gpt-5.5", + "openai_runtime": "codex_app_server", + }, + "providers": { + "codex-proxy": { + "api": "http://127.0.0.1:4141/v1", + "api_key": "proxy-key", + "default_model": "gpt-5.5", + "name": "codex-proxy", + "transport": "codex_responses", + } + }, + }, + ) + monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None) + monkeypatch.setattr( + rp, + "resolve_provider", + lambda *a, **k: (_ for _ in ()).throw( + AssertionError( + "resolve_provider should not be called for named custom providers" + ) + ), + ) + + resolved = rp.resolve_runtime_provider(requested=None) + + assert resolved["provider"] == "custom" + assert resolved["requested_provider"] == "custom:codex-proxy" + assert resolved["api_mode"] == "codex_app_server" + assert resolved["model"] == "gpt-5.5" + + +def test_legacy_custom_codex_responses_runtime_uses_codex_app_server(monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "model": { + "default": "gpt-5.5", + "openai_runtime": "codex_app_server", + }, + "custom_providers": [ + { + "name": "legacy-codex-proxy", + "base_url": "http://127.0.0.1:4242/v1", + "api_key": "legacy-key", + "api_mode": "codex_responses", + "model": "gpt-5.5", + } + ], + }, + ) + monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None) + + resolved = rp.resolve_runtime_provider(requested="custom:legacy-codex-proxy") + + assert resolved["provider"] == "custom" + assert resolved["requested_provider"] == "custom:legacy-codex-proxy" + assert resolved["api_mode"] == "codex_app_server" + assert resolved["model"] == "gpt-5.5" + + +def test_pooled_custom_codex_responses_runtime_uses_codex_app_server(monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "model": { + "default": "gpt-5.5", + "openai_runtime": "codex_app_server", + }, + "providers": { + "pooled-codex-proxy": { + "api": "http://127.0.0.1:4343/v1", + "api_key": "seed-key", + "default_model": "gpt-5.5", + "name": "pooled-codex-proxy", + "transport": "codex_responses", + } + }, + }, + ) + monkeypatch.setattr( + rp, + "_try_resolve_from_custom_pool", + lambda *a, **k: { + "provider": "custom", + "api_mode": "codex_responses", + "base_url": "http://127.0.0.1:4343/v1", + "api_key": "pool-key", + "source": "pool:pooled-codex-proxy", + }, + ) + + resolved = rp.resolve_runtime_provider(requested="custom:pooled-codex-proxy") + + assert resolved["provider"] == "custom" + assert resolved["api_mode"] == "codex_app_server" + assert resolved["api_key"] == "pool-key" + assert resolved["model"] == "gpt-5.5" + + +def test_named_custom_chat_runtime_does_not_use_codex_app_server(monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "model": { + "default": "local-model", + "openai_runtime": "codex_app_server", + }, + "providers": { + "chat-proxy": { + "api": "http://127.0.0.1:4444/v1", + "api_key": "chat-key", + "default_model": "local-model", + "name": "chat-proxy", + "transport": "chat_completions", + } + }, + }, + ) + monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None) + + resolved = rp.resolve_runtime_provider(requested="custom:chat-proxy") + + assert resolved["provider"] == "custom" + assert resolved["api_mode"] == "chat_completions" + + +def test_bare_custom_codex_responses_runtime_uses_codex_app_server(monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False) + monkeypatch.delenv("CUSTOM_BASE_URL", raising=False) + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "model": { + "provider": "custom", + "base_url": "http://127.0.0.1:4545/v1", + "api_key": "bare-key", + "api_mode": "codex_responses", + "openai_runtime": "codex_app_server", + }, + }, + ) + monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None) + + resolved = rp.resolve_runtime_provider(requested="custom") + + assert resolved["provider"] == "custom" + assert resolved["api_mode"] == "codex_app_server" + assert resolved["base_url"] == "http://127.0.0.1:4545/v1" + + +def test_explicit_base_url_custom_codex_responses_runtime_uses_codex_app_server(monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "model": { + "provider": "custom", + "api_mode": "codex_responses", + "openai_runtime": "codex_app_server", + }, + }, + ) + monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None) + + resolved = rp.resolve_runtime_provider( + requested="custom", + explicit_base_url="http://127.0.0.1:4646/v1", + explicit_api_key="explicit-key", + ) + + assert resolved["provider"] == "custom" + assert resolved["api_mode"] == "codex_app_server" + assert resolved["base_url"] == "http://127.0.0.1:4646/v1" + + +def test_custom_codex_app_server_transport_does_not_bypass_codex_responses_gate(monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "model": { + "openai_runtime": "codex_app_server", + }, + "providers": { + "runtime-not-transport": { + "api": "http://127.0.0.1:4747/v1", + "api_key": "proxy-key", + "default_model": "gpt-5.5", + "name": "runtime-not-transport", + "transport": "codex_app_server", + } + }, + }, + ) + monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None) + + resolved = rp.resolve_runtime_provider(requested="custom:runtime-not-transport") + + assert resolved["provider"] == "custom" + assert resolved["api_mode"] == "chat_completions" + + +def test_bare_custom_codex_app_server_api_mode_does_not_bypass_codex_responses_gate(monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False) + monkeypatch.delenv("CUSTOM_BASE_URL", raising=False) + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "model": { + "provider": "custom", + "base_url": "http://127.0.0.1:4848/v1", + "api_key": "bare-key", + "api_mode": "codex_app_server", + "openai_runtime": "codex_app_server", + }, + }, + ) + monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None) + + resolved = rp.resolve_runtime_provider(requested="custom") + + assert resolved["provider"] == "custom" + assert resolved["api_mode"] == "chat_completions" + + def test_named_custom_provider_uses_key_env_from_providers_dict(monkeypatch): """providers dict entries with key_env should resolve API key from env var.""" monkeypatch.delenv("OPENAI_API_KEY", raising=False) diff --git a/website/docs/user-guide/features/codex-app-server-runtime.md b/website/docs/user-guide/features/codex-app-server-runtime.md index 928b6d2d66b1..b51735ba3def 100644 --- a/website/docs/user-guide/features/codex-app-server-runtime.md +++ b/website/docs/user-guide/features/codex-app-server-runtime.md @@ -5,7 +5,7 @@ sidebar_label: Codex App-Server Runtime # Codex App-Server Runtime -Hermes can optionally hand `openai/*` and `openai-codex/*` turns to the [Codex CLI app-server](https://github.com/openai/codex) instead of running its own tool loop. When enabled, terminal commands, file edits, sandboxing, and MCP tool calls all execute inside Codex's runtime — Hermes becomes the shell around it (sessions DB, slash commands, gateway, memory and skill review). +Hermes can optionally hand `openai/*`, `openai-codex/*`, and custom providers whose transport is `codex_responses` to the [Codex CLI app-server](https://github.com/openai/codex) instead of running its own tool loop. When enabled, terminal commands, file edits, sandboxing, and MCP tool calls all execute inside Codex's runtime — Hermes becomes the shell around it (sessions DB, slash commands, gateway, memory and skill review). This is **opt-in only**. Default Hermes behavior is unchanged unless you flip the flag. Hermes never auto-routes you onto this runtime. @@ -126,7 +126,8 @@ The kanban tools are gated by `HERMES_KANBAN_TASK` env var the dispatcher sets | Kanban worker dispatch | yes | yes (via callback) | | Kanban orchestrator tools | yes | yes (via callback) | | All gateway platforms | yes | yes | -| Non-OpenAI providers | yes | n/a — OpenAI/Codex-scoped | +| Custom Codex Responses proxy | yes | yes | +| Other non-OpenAI providers | yes | n/a — OpenAI/Codex-scoped | ## Prerequisites @@ -141,6 +142,8 @@ The kanban tools are gated by `HERMES_KANBAN_TASK` env var the dispatcher sets ``` Hermes' own `hermes auth login codex` writes to `~/.hermes/auth.json` — that's a separate session. **Run `codex login` separately** if you haven't. + A named custom provider can use this runtime too, but only when it already declares the Codex Responses transport (`transport: codex_responses` or legacy `api_mode: codex_responses`). Do not set `transport: codex_app_server` on the provider entry — `codex_app_server` is selected through `model.openai_runtime`, not as an endpoint transport. Plain custom chat-completions endpoints stay on Hermes' default runtime. + 3. **(Optional) Install the Codex plugins you want.** When you enable the runtime, Hermes auto-migrates whichever curated plugins you've already installed via Codex CLI: ```bash codex plugin marketplace add openai-curated