fix(auxiliary): honor api_mode in auxiliary client (#6800)

The auxiliary client always calls client.chat.completions.create(),
ignoring the api_mode config flag. This breaks codex-family models
(e.g. gpt-5.3-codex) on direct OpenAI API keys, which need the
/v1/responses endpoint.

Changes:
- Expand _resolve_task_provider_model to return api_mode (5-tuple)
- Read api_mode from auxiliary.{task}.api_mode config and env vars
  (AUXILIARY_{TASK}_API_MODE)
- Pass api_mode through _get_cached_client to resolve_provider_client
- Add _needs_codex_wrap/_wrap_if_needed helpers that wrap plain OpenAI
  clients in CodexAuxiliaryClient when api_mode=codex_responses or
  when auto-detection finds api.openai.com + codex model pattern
- Apply wrapping at all custom endpoint, named custom provider, and
  API-key provider return paths
- Update test mocks for the new 5-tuple return format

Users can now set:
  auxiliary:
    compression:
      model: gpt-5.3-codex
      base_url: https://api.openai.com/v1
      api_mode: codex_responses

Closes #6800
This commit is contained in:
kshitijk4poor
2026-04-11 13:50:43 +05:30
committed by Teknium
parent 58b62e3e43
commit ffbd80f5fc
2 changed files with 80 additions and 21 deletions

View File

@@ -1217,6 +1217,7 @@ def resolve_provider_client(
raw_codex: bool = False, raw_codex: bool = False,
explicit_base_url: str = None, explicit_base_url: str = None,
explicit_api_key: str = None, explicit_api_key: str = None,
api_mode: str = None,
) -> Tuple[Optional[Any], Optional[str]]: ) -> Tuple[Optional[Any], Optional[str]]:
"""Central router: given a provider name and optional model, return a """Central router: given a provider name and optional model, return a
configured client with the correct auth, base URL, and API format. configured client with the correct auth, base URL, and API format.
@@ -1240,6 +1241,10 @@ def resolve_provider_client(
the main agent loop). the main agent loop).
explicit_base_url: Optional direct OpenAI-compatible endpoint. explicit_base_url: Optional direct OpenAI-compatible endpoint.
explicit_api_key: Optional API key paired with explicit_base_url. explicit_api_key: Optional API key paired with explicit_base_url.
api_mode: API mode override. One of "chat_completions",
"codex_responses", or None (auto-detect). When set to
"codex_responses", the client is wrapped in
CodexAuxiliaryClient to route through the Responses API.
Returns: Returns:
(client, resolved_model) or (None, None) if auth is unavailable. (client, resolved_model) or (None, None) if auth is unavailable.
@@ -1247,6 +1252,40 @@ def resolve_provider_client(
# Normalise aliases # Normalise aliases
provider = _normalize_aux_provider(provider) provider = _normalize_aux_provider(provider)
def _needs_codex_wrap(client_obj, base_url_str: str, model_str: str) -> bool:
"""Decide if a plain OpenAI client should be wrapped for Responses API.
Returns True when api_mode is explicitly "codex_responses", or when
auto-detection (api.openai.com + codex-family model) suggests it.
Already-wrapped clients (CodexAuxiliaryClient) are skipped.
"""
if isinstance(client_obj, CodexAuxiliaryClient):
return False
if raw_codex:
return False
if api_mode == "codex_responses":
return True
# Auto-detect: api.openai.com + codex model name pattern
if api_mode and api_mode != "codex_responses":
return False # explicit non-codex mode
normalized_base = (base_url_str or "").strip().lower()
if "api.openai.com" in normalized_base and "openrouter" not in normalized_base:
model_lower = (model_str or "").lower()
if "codex" in model_lower:
return True
return False
def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""):
"""Wrap a plain OpenAI client in CodexAuxiliaryClient if Responses API is needed."""
if _needs_codex_wrap(client_obj, base_url_str, final_model_str):
logger.debug(
"resolve_provider_client: wrapping client in CodexAuxiliaryClient "
"(api_mode=%s, model=%s, base_url=%s)",
api_mode or "auto-detected", final_model_str,
base_url_str[:60] if base_url_str else "")
return CodexAuxiliaryClient(client_obj, final_model_str)
return client_obj
# ── Auto: try all providers in priority order ──────────────────── # ── Auto: try all providers in priority order ────────────────────
if provider == "auto": if provider == "auto":
client, resolved = _resolve_auto() client, resolved = _resolve_auto()
@@ -1336,6 +1375,7 @@ def resolve_provider_client(
from hermes_cli.models import copilot_default_headers from hermes_cli.models import copilot_default_headers
extra["default_headers"] = copilot_default_headers() extra["default_headers"] = copilot_default_headers()
client = OpenAI(api_key=custom_key, base_url=custom_base, **extra) client = OpenAI(api_key=custom_key, base_url=custom_base, **extra)
client = _wrap_if_needed(client, final_model, custom_base)
return (_to_async_client(client, final_model) if async_mode return (_to_async_client(client, final_model) if async_mode
else (client, final_model)) else (client, final_model))
# Try custom first, then codex, then API-key providers # Try custom first, then codex, then API-key providers
@@ -1344,6 +1384,8 @@ def resolve_provider_client(
client, default = try_fn() client, default = try_fn()
if client is not None: if client is not None:
final_model = _normalize_resolved_model(model or default, provider) final_model = _normalize_resolved_model(model or default, provider)
_cbase = str(getattr(client, "base_url", "") or "")
client = _wrap_if_needed(client, final_model, _cbase)
return (_to_async_client(client, final_model) if async_mode return (_to_async_client(client, final_model) if async_mode
else (client, final_model)) else (client, final_model))
logger.warning("resolve_provider_client: custom/main requested " logger.warning("resolve_provider_client: custom/main requested "
@@ -1363,6 +1405,7 @@ def resolve_provider_client(
provider, provider,
) )
client = OpenAI(api_key=custom_key, base_url=custom_base) client = OpenAI(api_key=custom_key, base_url=custom_base)
client = _wrap_if_needed(client, final_model, custom_base)
logger.debug( logger.debug(
"resolve_provider_client: named custom provider %r (%s)", "resolve_provider_client: named custom provider %r (%s)",
provider, final_model) provider, final_model)
@@ -1442,6 +1485,11 @@ def resolve_provider_client(
except ImportError: except ImportError:
pass pass
# Honor api_mode for any API-key provider (e.g. direct OpenAI with
# codex-family models). The copilot-specific wrapping above handles
# copilot; this covers the general case (#6800).
client = _wrap_if_needed(client, final_model, base_url)
logger.debug("resolve_provider_client: %s (%s)", provider, final_model) logger.debug("resolve_provider_client: %s (%s)", provider, final_model)
return (_to_async_client(client, final_model) if async_mode return (_to_async_client(client, final_model) if async_mode
else (client, final_model)) else (client, final_model))
@@ -1474,12 +1522,13 @@ def get_text_auxiliary_client(task: str = "") -> Tuple[Optional[OpenAI], Optiona
Callers may override the returned model with a per-task env var Callers may override the returned model with a per-task env var
(e.g. CONTEXT_COMPRESSION_MODEL, AUXILIARY_WEB_EXTRACT_MODEL). (e.g. CONTEXT_COMPRESSION_MODEL, AUXILIARY_WEB_EXTRACT_MODEL).
""" """
provider, model, base_url, api_key = _resolve_task_provider_model(task or None) provider, model, base_url, api_key, api_mode = _resolve_task_provider_model(task or None)
return resolve_provider_client( return resolve_provider_client(
provider, provider,
model=model, model=model,
explicit_base_url=base_url, explicit_base_url=base_url,
explicit_api_key=api_key, explicit_api_key=api_key,
api_mode=api_mode,
) )
@@ -1490,13 +1539,14 @@ def get_async_text_auxiliary_client(task: str = ""):
(AsyncCodexAuxiliaryClient, model) which wraps the Responses API. (AsyncCodexAuxiliaryClient, model) which wraps the Responses API.
Returns (None, None) when no provider is available. Returns (None, None) when no provider is available.
""" """
provider, model, base_url, api_key = _resolve_task_provider_model(task or None) provider, model, base_url, api_key, api_mode = _resolve_task_provider_model(task or None)
return resolve_provider_client( return resolve_provider_client(
provider, provider,
model=model, model=model,
async_mode=True, async_mode=True,
explicit_base_url=base_url, explicit_base_url=base_url,
explicit_api_key=api_key, explicit_api_key=api_key,
api_mode=api_mode,
) )
@@ -1569,7 +1619,7 @@ def resolve_vision_provider_client(
backends, so users can intentionally force experimental providers. Auto mode backends, so users can intentionally force experimental providers. Auto mode
stays conservative and only tries vision backends known to work today. stays conservative and only tries vision backends known to work today.
""" """
requested, resolved_model, resolved_base_url, resolved_api_key = _resolve_task_provider_model( requested, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model(
"vision", provider, model, base_url, api_key "vision", provider, model, base_url, api_key
) )
requested = _normalize_vision_provider(requested) requested = _normalize_vision_provider(requested)
@@ -1791,6 +1841,7 @@ def _get_cached_client(
async_mode: bool = False, async_mode: bool = False,
base_url: str = None, base_url: str = None,
api_key: str = None, api_key: str = None,
api_mode: str = None,
) -> Tuple[Optional[Any], Optional[str]]: ) -> Tuple[Optional[Any], Optional[str]]:
"""Get or create a cached client for the given provider. """Get or create a cached client for the given provider.
@@ -1814,7 +1865,7 @@ def _get_cached_client(
loop_id = id(current_loop) loop_id = id(current_loop)
except RuntimeError: except RuntimeError:
pass pass
cache_key = (provider, async_mode, base_url or "", api_key or "", loop_id) cache_key = (provider, async_mode, base_url or "", api_key or "", api_mode or "", loop_id)
with _client_cache_lock: with _client_cache_lock:
if cache_key in _client_cache: if cache_key in _client_cache:
cached_client, cached_default, cached_loop = _client_cache[cache_key] cached_client, cached_default, cached_loop = _client_cache[cache_key]
@@ -1836,6 +1887,7 @@ def _get_cached_client(
async_mode, async_mode,
explicit_base_url=base_url, explicit_base_url=base_url,
explicit_api_key=api_key, explicit_api_key=api_key,
api_mode=api_mode,
) )
if client is not None: if client is not None:
# For async clients, remember which loop they were created on so we # For async clients, remember which loop they were created on so we
@@ -1855,7 +1907,7 @@ def _resolve_task_provider_model(
model: str = None, model: str = None,
base_url: str = None, base_url: str = None,
api_key: str = None, api_key: str = None,
) -> Tuple[str, Optional[str], Optional[str], Optional[str]]: ) -> Tuple[str, Optional[str], Optional[str], Optional[str], Optional[str]]:
"""Determine provider + model for a call. """Determine provider + model for a call.
Priority: Priority:
@@ -1864,15 +1916,17 @@ def _resolve_task_provider_model(
3. Config file (auxiliary.{task}.* or compression.*) 3. Config file (auxiliary.{task}.* or compression.*)
4. "auto" (full auto-detection chain) 4. "auto" (full auto-detection chain)
Returns (provider, model, base_url, api_key) where model may be None Returns (provider, model, base_url, api_key, api_mode) where model may
(use provider default). When base_url is set, provider is forced to be None (use provider default). When base_url is set, provider is forced
"custom" and the task uses that direct endpoint. to "custom" and the task uses that direct endpoint. api_mode is one of
"chat_completions", "codex_responses", or None (auto-detect).
""" """
config = {} config = {}
cfg_provider = None cfg_provider = None
cfg_model = None cfg_model = None
cfg_base_url = None cfg_base_url = None
cfg_api_key = None cfg_api_key = None
cfg_api_mode = None
if task: if task:
try: try:
@@ -1889,6 +1943,7 @@ def _resolve_task_provider_model(
cfg_model = str(task_config.get("model", "")).strip() or None cfg_model = str(task_config.get("model", "")).strip() or None
cfg_base_url = str(task_config.get("base_url", "")).strip() or None cfg_base_url = str(task_config.get("base_url", "")).strip() or None
cfg_api_key = str(task_config.get("api_key", "")).strip() or None cfg_api_key = str(task_config.get("api_key", "")).strip() or None
cfg_api_mode = str(task_config.get("api_mode", "")).strip() or None
# Backwards compat: compression section has its own keys. # Backwards compat: compression section has its own keys.
# The auxiliary.compression defaults to provider="auto", so treat # The auxiliary.compression defaults to provider="auto", so treat
@@ -1902,30 +1957,32 @@ def _resolve_task_provider_model(
cfg_base_url = cfg_base_url or _sbu.strip() or None cfg_base_url = cfg_base_url or _sbu.strip() or None
env_model = _get_auxiliary_env_override(task, "MODEL") if task else None env_model = _get_auxiliary_env_override(task, "MODEL") if task else None
env_api_mode = _get_auxiliary_env_override(task, "API_MODE") if task else None
resolved_model = model or env_model or cfg_model resolved_model = model or env_model or cfg_model
resolved_api_mode = env_api_mode or cfg_api_mode
if base_url: if base_url:
return "custom", resolved_model, base_url, api_key return "custom", resolved_model, base_url, api_key, resolved_api_mode
if provider: if provider:
return provider, resolved_model, base_url, api_key return provider, resolved_model, base_url, api_key, resolved_api_mode
if task: if task:
env_base_url = _get_auxiliary_env_override(task, "BASE_URL") env_base_url = _get_auxiliary_env_override(task, "BASE_URL")
env_api_key = _get_auxiliary_env_override(task, "API_KEY") env_api_key = _get_auxiliary_env_override(task, "API_KEY")
if env_base_url: if env_base_url:
return "custom", resolved_model, env_base_url, env_api_key or cfg_api_key return "custom", resolved_model, env_base_url, env_api_key or cfg_api_key, resolved_api_mode
env_provider = _get_auxiliary_provider(task) env_provider = _get_auxiliary_provider(task)
if env_provider != "auto": if env_provider != "auto":
return env_provider, resolved_model, None, None return env_provider, resolved_model, None, None, resolved_api_mode
if cfg_base_url: if cfg_base_url:
return "custom", resolved_model, cfg_base_url, cfg_api_key return "custom", resolved_model, cfg_base_url, cfg_api_key, resolved_api_mode
if cfg_provider and cfg_provider != "auto": if cfg_provider and cfg_provider != "auto":
return cfg_provider, resolved_model, None, None return cfg_provider, resolved_model, None, None, resolved_api_mode
return "auto", resolved_model, None, None return "auto", resolved_model, None, None, resolved_api_mode
return "auto", resolved_model, None, None return "auto", resolved_model, None, None, resolved_api_mode
_DEFAULT_AUX_TIMEOUT = 30.0 _DEFAULT_AUX_TIMEOUT = 30.0
@@ -2035,7 +2092,7 @@ def call_llm(
Raises: Raises:
RuntimeError: If no provider is configured. RuntimeError: If no provider is configured.
""" """
resolved_provider, resolved_model, resolved_base_url, resolved_api_key = _resolve_task_provider_model( resolved_provider, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model(
task, provider, model, base_url, api_key) task, provider, model, base_url, api_key)
if task == "vision": if task == "vision":
@@ -2068,6 +2125,7 @@ def call_llm(
resolved_model, resolved_model,
base_url=resolved_base_url, base_url=resolved_base_url,
api_key=resolved_api_key, api_key=resolved_api_key,
api_mode=resolved_api_mode,
) )
if client is None: if client is None:
# When the user explicitly chose a non-OpenRouter provider but no # When the user explicitly chose a non-OpenRouter provider but no
@@ -2229,7 +2287,7 @@ async def async_call_llm(
Same as call_llm() but async. See call_llm() for full documentation. Same as call_llm() but async. See call_llm() for full documentation.
""" """
resolved_provider, resolved_model, resolved_base_url, resolved_api_key = _resolve_task_provider_model( resolved_provider, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model(
task, provider, model, base_url, api_key) task, provider, model, base_url, api_key)
if task == "vision": if task == "vision":
@@ -2263,6 +2321,7 @@ async def async_call_llm(
async_mode=True, async_mode=True,
base_url=resolved_base_url, base_url=resolved_base_url,
api_key=resolved_api_key, api_key=resolved_api_key,
api_mode=resolved_api_mode,
) )
if client is None: if client is None:
_explicit = (resolved_provider or "").strip().lower() _explicit = (resolved_provider or "").strip().lower()

View File

@@ -1136,7 +1136,7 @@ class TestCallLlmPaymentFallback:
with patch("agent.auxiliary_client._get_cached_client", with patch("agent.auxiliary_client._get_cached_client",
return_value=(primary_client, "google/gemini-3-flash-preview")), \ return_value=(primary_client, "google/gemini-3-flash-preview")), \
patch("agent.auxiliary_client._resolve_task_provider_model", patch("agent.auxiliary_client._resolve_task_provider_model",
return_value=("openrouter", "google/gemini-3-flash-preview", None, None)), \ return_value=("openrouter", "google/gemini-3-flash-preview", None, None, None)), \
patch("agent.auxiliary_client._try_payment_fallback", patch("agent.auxiliary_client._try_payment_fallback",
return_value=(fallback_client, "gpt-5.2-codex", "openai-codex")) as mock_fb: return_value=(fallback_client, "gpt-5.2-codex", "openai-codex")) as mock_fb:
result = call_llm( result = call_llm(
@@ -1162,7 +1162,7 @@ class TestCallLlmPaymentFallback:
with patch("agent.auxiliary_client._get_cached_client", with patch("agent.auxiliary_client._get_cached_client",
return_value=(primary_client, "google/gemini-3-flash-preview")), \ return_value=(primary_client, "google/gemini-3-flash-preview")), \
patch("agent.auxiliary_client._resolve_task_provider_model", patch("agent.auxiliary_client._resolve_task_provider_model",
return_value=("openrouter", "google/gemini-3-flash-preview", None, None)): return_value=("openrouter", "google/gemini-3-flash-preview", None, None, None)):
with pytest.raises(Exception, match="Internal Server Error"): with pytest.raises(Exception, match="Internal Server Error"):
call_llm( call_llm(
task="compression", task="compression",
@@ -1179,7 +1179,7 @@ class TestCallLlmPaymentFallback:
with patch("agent.auxiliary_client._get_cached_client", with patch("agent.auxiliary_client._get_cached_client",
return_value=(primary_client, "google/gemini-3-flash-preview")), \ return_value=(primary_client, "google/gemini-3-flash-preview")), \
patch("agent.auxiliary_client._resolve_task_provider_model", patch("agent.auxiliary_client._resolve_task_provider_model",
return_value=("openrouter", "google/gemini-3-flash-preview", None, None)), \ return_value=("openrouter", "google/gemini-3-flash-preview", None, None, None)), \
patch("agent.auxiliary_client._try_payment_fallback", patch("agent.auxiliary_client._try_payment_fallback",
return_value=(None, None, "")): return_value=(None, None, "")):
with pytest.raises(Exception, match="insufficient credits"): with pytest.raises(Exception, match="insufficient credits"):