diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 4d23315487d6..2b14e64f6d7d 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -918,7 +918,71 @@ def _try_custom_endpoint() -> Tuple[Optional[OpenAI], Optional[str]]: return OpenAI(api_key=custom_key, base_url=custom_base), model +def _try_codex_from_config() -> Tuple[Optional[Any], Optional[str]]: + """Build a Codex auxiliary client from config.yaml ``model.api_key``. + + This is the auxiliary-side mirror of the Direct config path that + ``runtime_provider.py`` added in PR #2 — it lets reverse-proxied + deployments (e.g. a sub2api-compatible endpoint with a static API + key but no local OAuth state) use auxiliary tasks without going + through the Codex device-code flow. + + Activation is **strictly opt-in**: only triggers when the operator + has explicitly set ``model.provider == "openai-codex"`` AND + ``model.api_key`` to a non-empty value. Single-user CLI workflows + (where ``api_key`` is empty and OAuth tokens live in the auth store) + are never touched and continue to follow the unchanged code path + below. + + The auxiliary model defaults to ``model.default`` (the operator's + main model) rather than ``_CODEX_AUX_MODEL``, because in + direct-config mode the only models guaranteed to exist are the ones + the operator's reverse proxy actually serves — which is what + ``model.default`` describes. Per-task ``auxiliary.{task}.model`` + overrides still win because the caller propagates them via + ``model or default``. + """ + try: + from hermes_cli.config import load_config + cfg = load_config() + except Exception as exc: + logger.warning( + "Codex direct config: load_config() failed, falling back to OAuth: %s", + exc, + ) + return None, None + model_cfg = cfg.get("model") if isinstance(cfg, dict) else None + if not isinstance(model_cfg, dict): + return None, None + if str(model_cfg.get("provider") or "").strip().lower() != "openai-codex": + return None, None + api_key = str(model_cfg.get("api_key") or "").strip() + if not api_key: + return None, None + base_url = ( + str(model_cfg.get("base_url") or "").strip().rstrip("/") + or _CODEX_AUX_BASE_URL + ) + model_name = ( + str(model_cfg.get("default") or "").strip() or _CODEX_AUX_MODEL + ) + logger.debug( + "Auxiliary client: Codex direct config (%s at %s)", + model_name, base_url[:60], + ) + real_client = OpenAI(api_key=api_key, base_url=base_url) + return CodexAuxiliaryClient(real_client, model_name), model_name + + def _try_codex() -> Tuple[Optional[Any], Optional[str]]: + # Direct config path: when the operator pinned an explicit api_key + # in config.yaml, use it instead of OAuth. This lets reverse-proxied + # Hermes deployments use auxiliary tasks without the device-code + # flow. Falls through to the OAuth path below when api_key is empty. + direct_client, direct_model = _try_codex_from_config() + if direct_client is not None: + return direct_client, direct_model + pool_present, entry = _select_pool_entry("openai-codex") if pool_present: codex_token = _pool_runtime_api_key(entry) @@ -1378,6 +1442,33 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""): # ── OpenAI Codex (OAuth → Responses API) ───────────────────────── if provider == "openai-codex": + # Direct-credentials path: when the caller passed an explicit + # api_key (e.g., from a fallback_providers entry, a CLI override, + # or the runtime provider resolver after PR #2), use those values + # straight away instead of going through OAuth. Otherwise + # `raw_codex=True` and other explicit-credential code paths would + # silently fail in reverse-proxied deployments that have no + # local Codex auth state. + if explicit_api_key: + codex_base = ( + (explicit_base_url or "").strip().rstrip("/") + or _CODEX_AUX_BASE_URL + ) + final_model = _normalize_resolved_model( + model or _CODEX_AUX_MODEL, provider, + ) + raw_client = OpenAI( + api_key=explicit_api_key.strip(), base_url=codex_base, + ) + if raw_codex: + return (raw_client, final_model) + wrapped = CodexAuxiliaryClient(raw_client, final_model) + return ( + _to_async_client(wrapped, final_model) + if async_mode + else (wrapped, final_model) + ) + if raw_codex: # Return the raw OpenAI client for callers that need direct # access to responses.stream() (e.g., the main agent loop). diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 09416f10f8ff..c9ce9a03ca37 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -738,6 +738,12 @@ async def get_env_vars(): result[var_name] = { "is_set": bool(value), "redacted_value": redact_key(value) if value else None, + # Plain value for non-secret fields (allow-lists, mode flags, + # account IDs, etc.) so dashboards can round-trip them in + # form inputs without forcing the user to retype on every + # edit. Password-flagged fields stay None and must still go + # through the rate-limited /api/env/reveal endpoint. + "value": value if (value and not info.get("password", False)) else None, "description": info.get("description", ""), "url": info.get("url"), "category": info.get("category", ""),