fix(router): scope Codex envelope defaults to Codex clients

This commit is contained in:
moe-berri 2026-09-10 12:01:04 -07:00
parent 5d6fec94b7
commit 1697684b68
7 changed files with 127 additions and 43 deletions

View file

@ -2,6 +2,7 @@
## Helper utilities
import copy
import logging
import re
from collections.abc import Iterable, Mapping
from typing import TYPE_CHECKING, Any, Final, Literal
@ -21,6 +22,13 @@ else:
Span = Any
_CODEX_CLIENT_PREFIX_RE: Final = re.compile(r"^codex[-_ /]", re.IGNORECASE)
def is_codex_user_agent(user_agent: str) -> bool:
return bool(_CODEX_CLIENT_PREFIX_RE.match(user_agent))
def safe_divide_seconds(seconds: float, denominator: float, default: float | None = None) -> float | None:
"""
Safely divide seconds by denominator, handling zero division.

View file

@ -31,6 +31,7 @@ from litellm.constants import (
SESSION_ID_OMITTED_METADATA_KEY,
X_LITELLM_DISABLE_CALLBACKS,
)
from litellm.litellm_core_utils.core_helpers import is_codex_user_agent
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
TRUSTED_CALLBACK_VARS_FIELD,
@ -83,10 +84,6 @@ _EXPLICIT_SESSION_HEADERS: Final = frozenset({"x-litellm-trace-id", "x-litellm-s
# ``session-id``/``thread-id``; builds before the codex-api split sent
# ``session_id``/``conversation_id``. Ordered session before thread.
_CODEX_SESSION_ID_HEADERS: Final = ("session-id", "session_id", "thread-id", "conversation_id")
# Matches every first-party Codex originator: codex-tui, codex_cli_rs, codex_exec,
# codex_vscode, "Codex ...". A separator is required so an unrelated "codexfoo" client
# does not read as Codex.
_CODEX_CLIENT_PREFIX_RE: Final = re.compile(r"^codex[-_ /]", re.IGNORECASE)
# Session-id values must be non-empty strings of alphanumerics, hyphens, or underscores
# (covers UUIDs and most common session-id formats).
_SESSION_ID_VALUE_RE: Final = re.compile(r"^[a-zA-Z0-9_\-]{8,}$")
@ -810,16 +807,6 @@ def apply_missing_session_id_policy(
)
def is_codex_user_agent(user_agent: str) -> bool:
"""Codex builds its user agent as ``<originator>/<version> ...`` and ships
several first-party originators: ``codex-tui``, ``codex_cli_rs``,
``codex_exec`` (exec mode), ``codex_vscode`` (IDE extension) and ``Codex ...``
(see ``is_first_party_originator`` in codex-rs). They agree only on the
``codex`` stem, and the TUI sends a bare ``codex-tui`` with no version at all,
so match the stem plus a separator rather than any one spelling."""
return bool(_CODEX_CLIENT_PREFIX_RE.match(user_agent))
def should_auto_drop_params_for_agentic_cli(user_agent: str, data: dict, proxy_config: ProxyConfig) -> bool:
"""drop_params defaults to on for agentic CLIs so their client-specific
params (e.g. Claude Code's thinking, Codex's service_tier) don't fail

View file

@ -446,7 +446,9 @@ Reasoning markers in the system prompt do **not** trigger the reasoning override
Agent harnesses inject their own context into the conversation as ordinary message text. That text is plumbing, not something a human asked for, so the router strips complete reminder blocks before classifying and picking a tier. A turn that is nothing but a reminder block strips to empty and is skipped, and the router falls back to the last real ask instead
By default the router strips complete `<system-reminder>`, `<environment_context>`, `<recommended_plugins>`, `<user_instructions>`, and `<environments_instructions>` blocks. It also strips Codex repository instructions from the fixed heading prefix `# AGENTS.md instructions for ` through `</INSTRUCTIONS>`, regardless of the repository path
By default the router strips complete `<system-reminder>` blocks. For requests with a Codex user agent, it also strips complete `<environment_context>`, `<recommended_plugins>`, `<user_instructions>`, and `<environments_instructions>` blocks, plus repository instructions from the fixed heading prefix `# AGENTS.md instructions for ` through `</INSTRUCTIONS>`, regardless of the repository path. Other clients keep those tags and their contents
The proxy records the incoming user agent in request metadata. SDK callers can supply `metadata.user_agent` (or `litellm_metadata.user_agent` on Responses requests), or configure `reminder_markers` explicitly when their client identity is unavailable
The Codex `Message Type: NEW_TASK` wrapper and its delegated-task payload remain available for classification. Cleanup applies to the current ask and quoted prior turns; the routed request retains its original content

View file

@ -39,6 +39,7 @@ from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.core_helpers import (
_get_parent_otel_span_from_kwargs,
get_metadata_variable_name_from_kwargs,
is_codex_user_agent,
)
from litellm.litellm_core_utils.internal_call_metadata import forwarded_internal_call_metadata
from litellm.litellm_core_utils.prompt_templates.common_utils import (
@ -386,8 +387,8 @@ def _effective_turn_off_message_logging(request_kwargs: Mapping[str, object] | N
_REMINDER_OPEN: Final = "<system-reminder>"
_REMINDER_CLOSE: Final = "</system-reminder>"
_DEFAULT_REMINDER_MARKERS: Final = (
(_REMINDER_OPEN, _REMINDER_CLOSE),
_DEFAULT_REMINDER_MARKERS: Final = ((_REMINDER_OPEN, _REMINDER_CLOSE),)
_CODEX_REMINDER_MARKERS: Final = _DEFAULT_REMINDER_MARKERS + (
("<environment_context>", "</environment_context>"),
("<recommended_plugins>", "</recommended_plugins>"),
("<user_instructions>", "</user_instructions>"),
@ -1951,6 +1952,7 @@ class ComplexityRouter(CustomLogger):
raise ValueError("classifier_llm_config is not set")
include_assistant: Final = self.config.classifier_context_include_assistant_turns
marker_pairs: Final = self._reminder_markers_for_request(request_kwargs or {})
context_enabled: Final = bool(messages) and self.config.classifier_context_window_size > 0
prior_turns: Final = (
_extract_prior_turns(
@ -1960,20 +1962,14 @@ class ComplexityRouter(CustomLogger):
budget_chars=self.config.classifier_context_budget_chars,
per_turn_chars=self.config.classifier_context_per_turn_chars,
include_assistant=include_assistant,
marker_pairs=self._reminder_markers,
marker_pairs=marker_pairs,
)
if context_enabled
else ()
)
has_prior_conversation: Final = (
context_enabled
and len(
tuple(
islice(
_iter_context_turns_newest_first(messages or (), include_assistant, self._reminder_markers), 2
)
)
)
and len(tuple(islice(_iter_context_turns_newest_first(messages or (), include_assistant, marker_pairs), 2)))
> 1
)
@ -2434,7 +2430,7 @@ class ComplexityRouter(CustomLogger):
body if isinstance(body, Mapping) else None,
resolved_messages,
tuple(self.config.plan_mode_patterns or ()),
self._reminder_markers,
self._reminder_markers_for_request(request_kwargs),
)
def _matched_housekeeping_sentinel(self, newest_ask: str | None) -> str | None:
@ -3255,6 +3251,18 @@ class ComplexityRouter(CustomLogger):
"""
return _extract_current_ask_and_system_prompt(messages)
def _reminder_markers_for_request(self, request_kwargs: Mapping[str, object]) -> tuple[tuple[str, str], ...]:
if self.config.reminder_markers is not None:
return self._reminder_markers
if any(
is_codex_user_agent(user_agent)
for metadata_key in ("litellm_metadata", "metadata")
if isinstance(metadata := request_kwargs.get(metadata_key), Mapping)
if isinstance(user_agent := metadata.get("user_agent"), str)
):
return _CODEX_REMINDER_MARKERS
return _DEFAULT_REMINDER_MARKERS
@staticmethod
def _iter_metadata_dicts(request_kwargs: dict) -> list[dict]:
"""Metadata may land on `metadata` or `litellm_metadata` depending on the
@ -3356,6 +3364,7 @@ class ComplexityRouter(CustomLogger):
# chat-completions messages, so it is real work on every non-chat surface, and
# both the conversation shape and the classifier read the same list.
resolved_messages: Final = self._resolve_messages(messages, request_kwargs)
marker_pairs: Final = self._reminder_markers_for_request(request_kwargs)
conversation_continuing: Final = _conversation_is_continuing(resolved_messages)
use_session_affinity: Final = self._uses_tier_pin
@ -3365,7 +3374,7 @@ class ComplexityRouter(CustomLogger):
# In 'user_turn' mode a held pin is replayed only on continuation turns; a new human
# ask falls through and re-classifies. session_affinity restores pin-first for asks too.
pin_replay_allowed: Final = bool(self.config.session_affinity) or not _newest_turn_is_human_ask(
resolved_messages, self._reminder_markers
resolved_messages, marker_pairs
)
if cache_key is not None and pin_replay_allowed:
@ -3376,7 +3385,7 @@ class ComplexityRouter(CustomLogger):
pin_escalation_keyword: str | None = None
if self.escalation_keywords:
user_message: Final = (
_newest_turn_ask(resolved_messages, self._reminder_markers) if resolved_messages else None
_newest_turn_ask(resolved_messages, marker_pairs) if resolved_messages else None
)
if user_message is not None:
pin_escalation_keyword = self._matched_escalation_keyword(user_message)
@ -3564,7 +3573,8 @@ class ComplexityRouter(CustomLogger):
# Determine whether the original request used messages directly
has_original_messages: Final = messages is not None and len(messages) > 0
user_message, system_prompt = _extract_current_ask_and_system_prompt(resolved_messages, self._reminder_markers)
marker_pairs: Final = self._reminder_markers_for_request(request_kwargs)
user_message, system_prompt = _extract_current_ask_and_system_prompt(resolved_messages, marker_pairs)
classifier_images: Final = self._classifier_image_parts(resolved_messages)
if user_message is None and not classifier_images:
@ -3598,7 +3608,7 @@ class ComplexityRouter(CustomLogger):
)
ask: Final = user_message or ""
newest_ask: Final = _newest_turn_ask(resolved_messages, self._reminder_markers)
newest_ask: Final = _newest_turn_ask(resolved_messages, marker_pairs)
escalation_keyword: Final = self._matched_escalation_keyword(newest_ask) if newest_ask is not None else None
# Resolved here rather than beside the classifier because the keyword-override path below
# returns before any classification runs, and a forced tier gets stuck for the same reason

View file

@ -1292,8 +1292,9 @@ class ComplexityRouterConfig(BaseModel):
"Override the delimiter pairs used to recognize and strip harness-injected reminder "
"blocks before classification. A harness that wraps injected context differently per "
"agent type (main, subagent, cron) lists every pair it emits. Replaces, rather than "
"adds to, the built-in system-reminder and Codex envelope pairs, so list every "
"built-in pair your harness also emits. Matching is case-insensitive."
"adds to, the built-in system-reminder pair and the Codex envelope pairs enabled "
"for Codex user agents, so list every built-in pair your harness also emits. "
"Matching is case-insensitive."
),
)

View file

@ -7625,9 +7625,38 @@ _CODEX_ENVELOPES: Final = (
class TestContextAwareClassifier:
"""Test the new classifier context window and trajectory signals."""
@pytest.mark.asyncio
@pytest.mark.parametrize("envelope", _CODEX_ENVELOPES)
@pytest.mark.parametrize("user_agent", (None, "curl/8.7.1", "codexify/1.0"))
async def test_non_codex_requests_preserve_tagged_asks(self, envelope: str, user_agent: str | None) -> None:
completion: Final = AsyncMock(return_value=_llm_response('{"tier":"COMPLEX"}'))
router: Final = ComplexityRouter(
model_name="router",
litellm_router_instance=MagicMock(acompletion=completion),
complexity_router_config={
"tiers": {"COMPLEX": "task-model"},
"default_model": "fallback-model",
"classifier_type": "llm",
"classifier_llm_config": {"model": "classifier-model"},
"escalation_keywords": [],
},
)
response: Final = await router.async_pre_routing_hook(
model="router",
request_kwargs={"metadata": {"user_agent": user_agent}} if user_agent is not None else {},
messages=[{"role": "user", "content": envelope}],
)
assert response is not None
assert response.model == "task-model"
completion.assert_awaited_once()
assert completion.call_args.kwargs["messages"][1]["content"].strip() == f"Classify this message:\n{envelope}"
@pytest.mark.parametrize("envelope", _CODEX_ENVELOPES)
def test_codex_envelopes_preserve_delegated_task_and_prior_context(self, envelope: str) -> None:
from litellm.router_strategy.complexity_router.complexity_router import (
_CODEX_REMINDER_MARKERS,
_extract_current_ask_and_system_prompt,
_extract_prior_turns,
_newest_turn_ask,
@ -7644,21 +7673,25 @@ class TestContextAwareClassifier:
{"role": "user", "content": envelope},
]
assert _extract_current_ask_and_system_prompt(messages)[0] == _CODEX_NEW_TASK
assert _extract_prior_turns(messages, _CODEX_NEW_TASK, 1, 100, None, False) == (
assert _extract_current_ask_and_system_prompt(messages, _CODEX_REMINDER_MARKERS)[0] == _CODEX_NEW_TASK
assert _extract_prior_turns(messages, _CODEX_NEW_TASK, 1, 100, None, False, _CODEX_REMINDER_MARKERS) == (
("user", "Design cache invalidation"),
)
assert _newest_turn_ask(messages) is None
assert _newest_turn_is_human_ask(messages) is False
assert _extract_current_ask_and_system_prompt([messages[-1]])[0] is None
assert _newest_turn_ask(messages, _CODEX_REMINDER_MARKERS) is None
assert _newest_turn_is_human_ask(messages, _CODEX_REMINDER_MARKERS) is False
assert _extract_current_ask_and_system_prompt([messages[-1]], _CODEX_REMINDER_MARKERS)[0] is None
@pytest.mark.parametrize("envelope", _CODEX_ENVELOPES)
def test_codex_marker_override_and_incomplete_blocks_preserve_text(self, envelope: str) -> None:
from litellm.router_strategy.complexity_router.complexity_router import _strip_reminder_blocks
from litellm.router_strategy.complexity_router.complexity_router import (
_CODEX_REMINDER_MARKERS,
_strip_reminder_blocks,
)
incomplete: Final = envelope.rsplit("</", 1)[0]
assert _strip_reminder_blocks(f"before {envelope.upper()} after") == "before after"
assert _strip_reminder_blocks(incomplete) == incomplete
assert _strip_reminder_blocks(f"before {envelope.upper()} after", _CODEX_REMINDER_MARKERS) == "before after"
assert _strip_reminder_blocks(incomplete, _CODEX_REMINDER_MARKERS) == incomplete
assert _strip_reminder_blocks(envelope) == envelope
assert _strip_reminder_blocks(f"<custom>noise</custom>{envelope}", (("<custom>", "</custom>"),)) == envelope
@pytest.mark.asyncio
@ -7681,9 +7714,12 @@ class TestContextAwareClassifier:
]
original: Final = deepcopy(messages)
request_kwargs: Final = (
{"input": messages, "litellm_metadata": {"user_api_key_request_route": "/v1/responses"}}
{
"input": messages,
"litellm_metadata": {"user_api_key_request_route": "/v1/responses", "user_agent": "codex-tui"},
}
if responses_api
else {}
else {"metadata": {"user_agent": "codex-tui"}}
)
response: Final = await router.async_pre_routing_hook(
@ -7706,6 +7742,46 @@ class TestContextAwareClassifier:
else:
assert response.messages == original
@pytest.mark.asyncio
@pytest.mark.parametrize("custom_markers", (False, True))
async def test_codex_markers_are_request_scoped_and_respect_overrides(self, custom_markers: bool) -> None:
completion: Final = AsyncMock(return_value=_llm_response('{"tier":"COMPLEX"}'))
router: Final = ComplexityRouter(
model_name="router",
litellm_router_instance=MagicMock(acompletion=completion),
complexity_router_config={
"tiers": {"COMPLEX": "task-model"},
"classifier_type": "llm",
"classifier_llm_config": {"model": "classifier-model"},
"classifier_context_window_size": 2,
"escalation_keywords": [],
**({"reminder_markers": [{"open": "<custom>", "close": "</custom>"}]} if custom_markers else {}),
},
)
envelope: Final = "\n".join(_CODEX_ENVELOPES)
prior: Final = f"{envelope}\nDesign cache invalidation"
messages: Final = [
{"role": "user", "content": prior},
{"role": "user", "content": _CODEX_NEW_TASK},
{"role": "user", "content": envelope},
]
for user_agent in ("codex-tui", "curl/8.7.1", "codex_cli_rs/0.62.0"):
response: Final = await router.async_pre_routing_hook(
model="router", request_kwargs={"metadata": {"user_agent": user_agent}}, messages=messages
)
assert response is not None
assert response.model == "task-model"
payload: Final = completion.call_args.kwargs["messages"][1]["content"]
if user_agent.startswith("codex") and not custom_markers:
assert payload.endswith(f"Classify this message:\n{_CODEX_NEW_TASK}")
assert "Design cache invalidation" in payload
assert "LITELLM ESCALATE" not in payload
else:
assert payload.endswith(f"Classify this message:\n{envelope}")
assert prior in payload
assert response.messages == messages
assert completion.await_count == 3
@pytest.mark.parametrize(
"messages,expected_ask",
[

View file

@ -35221,7 +35221,7 @@ export interface components {
reasoning_override_min_score?: number | null;
/**
* Reminder Markers
* @description Override the delimiter pairs used to recognize and strip harness-injected reminder blocks before classification. A harness that wraps injected context differently per agent type (main, subagent, cron) lists every pair it emits. Replaces, rather than adds to, the built-in default of ('<system-reminder>', '</system-reminder>'), so a harness that also emits that pair lists it too. Matching is case-insensitive.
* @description Override the delimiter pairs used to recognize and strip harness-injected reminder blocks before classification. A harness that wraps injected context differently per agent type (main, subagent, cron) lists every pair it emits. Replaces, rather than adds to, the built-in system-reminder pair and the Codex envelope pairs enabled for Codex user agents, so list every built-in pair your harness also emits. Matching is case-insensitive.
*/
reminder_markers?: components["schemas"]["ReminderMarkerPair"][] | null;
/**