From edb8c565ce645125d314bb5b8561abadca18c683 Mon Sep 17 00:00:00 2001 From: Greg Fuener Date: Tue, 3 Nov 2026 14:57:07 -0500 Subject: [PATCH 1/2] feat(complexity_router): add classifier_caller_system_prompt to control system prompt forwarding The LLM and JEV classifiers quote the caller's system prompt as task context, except for Claude Code requests, which are detected by user agent and always omitted. Other coding agents and harnesses send a per-session system prompt that says nothing about the request being routed, and there was no way to keep it out of classification classifier_caller_system_prompt takes auto (the existing behavior and the default), omit (never quote it), or include (always quote it, Claude Code included). The routed completion receives the original system prompt in every mode --- .../complexity_router/README.md | 21 ++++++--- .../complexity_router/complexity_router.py | 29 ++++++++----- .../complexity_router/config.py | 20 +++++++-- .../router_strategy/test_complexity_router.py | 43 +++++++++++++++++++ 4 files changed, 93 insertions(+), 20 deletions(-) diff --git a/litellm/router_strategy/complexity_router/README.md b/litellm/router_strategy/complexity_router/README.md index d06248a555f..35021efc7ff 100644 --- a/litellm/router_strategy/complexity_router/README.md +++ b/litellm/router_strategy/complexity_router/README.md @@ -679,12 +679,23 @@ Omit `reasoning_keywords` to use the built-in list, or supply a nonempty list to Reasoning markers in the system prompt do **not** trigger the reasoning override. This prevents system prompts like "Think step by step before answering" from forcing all requests to the reasoning tier. -For requests identified by a `claude-cli/` or `claude-code/` user agent, the LLM classifier omits caller system +`classifier_caller_system_prompt` controls whether the LLM and JEV classifiers see the caller's system text. +The routed completion receives the original system text in every mode + +With the default `auto`, requests identified by a `claude-cli/` or `claude-code/` user agent omit caller system text to avoid classifying environment, agent, and skill catalogs. The current ask, configured prior-turn context, -and trajectory signal remain unchanged. The routed completion still receives the original system text. This -also excludes genuine task constraints supplied only in Claude Code system messages. Other clients keep the -existing system-context behavior. The browser routing preview has no client-identity field and retains that -generic behavior; use the real client when checking Claude Code routing. +and trajectory signal remain unchanged. This also excludes genuine task constraints supplied only in Claude Code +system messages. Other clients keep the existing system-context behavior. The browser routing preview has no +client-identity field and retains that generic behavior, so use the real client when checking Claude Code routing + +Set `omit` when the router fronts other coding agents or harnesses whose system prompt is a per-session constant, +since quoting the same instructions on every request tells the classifier nothing about the request itself. Set +`include` to quote system text for every client, Claude Code included + +```yaml +classifier_type: llm +classifier_caller_system_prompt: omit +``` ### Harness Reminder Blocks diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index 05dd4706b62..4239806999e 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -2666,16 +2666,23 @@ class ComplexityRouter(CustomLogger): def _classifier_caller_constraints( self, system_prompt: str | None, request_kwargs: Mapping[str, object] | None ) -> str | None: - """Exclude Claude Code's environment and skill catalogs from task forecasts.""" - return ( - None - if any( - is_claude_code_user_agent(user_agent) - for metadata in (self._iter_metadata_dicts(request_kwargs) if request_kwargs is not None else ()) - if isinstance(user_agent := metadata.get("user_agent"), str) - ) - else system_prompt - ) + match self.config.classifier_caller_system_prompt: + case "include": + return system_prompt + case "omit": + return None + case "auto": + return ( + None + if any( + is_claude_code_user_agent(user_agent) + for metadata in ( + self._iter_metadata_dicts(request_kwargs) if request_kwargs is not None else () + ) + if isinstance(user_agent := metadata.get("user_agent"), str) + ) + else system_prompt + ) def _classifier_context_payload( self, @@ -2733,7 +2740,7 @@ class ComplexityRouter(CustomLogger): Args: prompt: The current user ask text (already extracted as the real human ask, not tool results) - system_prompt: Caller task constraints, omitted from classification for Claude Code requests + system_prompt: Caller task constraints, filtered by classifier_caller_system_prompt request_kwargs: Request metadata for spend attribution messages: Full message history for extracting prior turns and the trajectory signal """ diff --git a/litellm/router_strategy/complexity_router/config.py b/litellm/router_strategy/complexity_router/config.py index d5870bb6510..8bdc179ea71 100644 --- a/litellm/router_strategy/complexity_router/config.py +++ b/litellm/router_strategy/complexity_router/config.py @@ -1316,8 +1316,8 @@ class ComplexityRouterConfig(LiteLLMBaseModel): "classifier_context_include_assistant_turns is enabled. These turns are sent to the classifier " "model (the configured TypeSafe endpoint for JEV), which may " "be a different deployment or provider than the routed completion model; that call carries " - "the current user ask and, except for Claude Code requests, the extracted system-role text in full. " - "Claude Code system text is omitted to avoid classifying harness instructions; the routed " + "the current user ask and the extracted system-role text in full, unless " + "classifier_caller_system_prompt omits it; the routed " "completion still receives it. Set to 0 to omit prior turns and the conversation-depth summary; " "the current ask and selected system text are still sent. Applies to LLM and JEV classification." ), @@ -1330,8 +1330,8 @@ class ComplexityRouterConfig(LiteLLMBaseModel): "context window, per classification call. Turns are taken newest first and quoted whole " "while they fit, so a conversation small enough to quote entirely is never cut; once the " "budget runs out the older turns are dropped whole and only the turn straddling the " - "boundary is truncated, into whatever space is left. The current ask and, except for Claude " - "Code requests, the extracted system-role text sit outside this budget and are sent in full, as does " + "boundary is truncated, into whatever space is left. The current ask and the extracted " + "system-role text, when classifier_caller_system_prompt sends it, sit outside this budget and are sent in full, as does " "the numbering each quoted turn carries. A budget under 120 leaves no room to quote a turn and " "suppresses the block; set classifier_context_window_size to 0 to turn context off " "deliberately. Applies to LLM and JEV classification." @@ -1363,6 +1363,18 @@ class ComplexityRouterConfig(LiteLLMBaseModel): "spend, for an already-deployed router. Applies to LLM and JEV classification." ), ) + classifier_caller_system_prompt: Literal["auto", "include", "omit"] = Field( + default="auto", + description=( + "Whether the caller's system prompt is quoted to the LLM or JEV classifier as task context. " + "'auto' omits it for Claude Code requests, whose system text is harness instructions and " + "tool catalogs rather than the task, and includes it for every other client. 'omit' never " + "sends it, which suits any coding agent or harness whose system prompt is a per-session " + "constant that says nothing about the request being routed. 'include' always sends it, " + "Claude Code included. The routed completion receives the original system prompt in every " + "mode. Applies to LLM and JEV classification." + ), + ) adaptive: bool = Field( default=False, diff --git a/tests/unit/router_strategy/test_complexity_router.py b/tests/unit/router_strategy/test_complexity_router.py index d0e2b381253..1c5fa78b79a 100644 --- a/tests/unit/router_strategy/test_complexity_router.py +++ b/tests/unit/router_strategy/test_complexity_router.py @@ -10322,6 +10322,49 @@ class TestContextAwareClassifier: assert "TOP_LEVEL_HARNESS_SYSTEM" not in payload assert "Conversation so far: ~35 tokens across the request" in payload + @pytest.mark.asyncio + @pytest.mark.parametrize( + "mode,user_agent,forwards_system", + [ + ("auto", "claude-cli/2.1.233", False), + ("auto", "codex_cli_rs/0.60.1", True), + ("omit", "codex_cli_rs/0.60.1", False), + ("omit", None, False), + ("include", "claude-cli/2.1.233", True), + ("include", None, True), + ], + ) + async def test_classifier_caller_system_prompt_mode( + self, + llm_classifier_config: dict[str, object], + mode: str, + user_agent: str | None, + forwards_system: bool, + ) -> None: + dependency: Final = MagicMock(acompletion=AsyncMock(return_value=_llm_response('{"tier": "COMPLEX"}'))) + router: Final = ComplexityRouter( + "test-complexity-router", + RoutingContext(dependency), + {**llm_classifier_config, "classifier_caller_system_prompt": mode}, + ) + messages: Final = [ + {"role": "system", "content": "HARNESS_SYSTEM_PROMPT"}, + {"role": "user", "content": "Now prove it cannot livelock"}, + ] + request_kwargs: Final = {"metadata": {} if user_agent is None else {"user_agent": user_agent}} + + result: Final = await router.async_pre_routing_hook("test-complexity-router", request_kwargs, messages) + + assert result is not None and result.routing_decision["cause"] == "llm_classifier" + assert result.messages == messages + payload: Final = dependency.acompletion.call_args.kwargs["messages"][1]["content"] + assert payload.endswith("Classify this message:\nNow prove it cannot livelock") + assert ("HARNESS_SYSTEM_PROMPT" in payload) is forwards_system + + def test_classifier_caller_system_prompt_rejects_unknown_mode(self, llm_classifier_config: dict[str, object]): + with pytest.raises(ValidationError): + ComplexityRouterConfig(**{**llm_classifier_config, "classifier_caller_system_prompt": "never"}) + @pytest.mark.asyncio async def test_claude_code_first_turn_without_context_omits_harness_system_prompt( self, llm_classifier_config: dict[str, object] From a50fc3c7a06b82bfbb4e7b2833676d672e8ca410 Mon Sep 17 00:00:00 2001 From: Greg Fuener Date: Tue, 3 Nov 2026 15:19:29 -0500 Subject: [PATCH 2/2] test(complexity_router): cover classifier_caller_system_prompt on the jev and laya classifiers --- .../complexity_router/test_jev_classifier.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/unit/router_strategy/complexity_router/test_jev_classifier.py b/tests/unit/router_strategy/complexity_router/test_jev_classifier.py index bcbf69e14a6..93574269454 100644 --- a/tests/unit/router_strategy/complexity_router/test_jev_classifier.py +++ b/tests/unit/router_strategy/complexity_router/test_jev_classifier.py @@ -271,6 +271,63 @@ async def test_jev_accounts_once_with_parent_identity_even_when_the_verdict_fail assert (standard["prompt_tokens"], standard["completion_tokens"], standard["total_tokens"]) == (3, 2, 5) +@pytest.mark.asyncio +@pytest.mark.parametrize("classifier", [{"provider": "jev"}, {"provider": "laya", "model": "english"}]) +@pytest.mark.parametrize( + ("mode", "user_agent", "forwards_system"), + [ + ("auto", "opencode/1.18.20", True), + ("auto", "claude-cli/2.1.233", False), + ("omit", "opencode/1.18.20", False), + ("include", "claude-cli/2.1.233", True), + ], +) +async def test_oss_classifier_honors_classifier_caller_system_prompt( + classifier: dict[str, str], mode: str, user_agent: str, forwards_system: bool +) -> None: + captured: list[Mapping[str, object]] = [] + + def respond(request: httpx.Request) -> httpx.Response: + captured.append(json.loads(request.content)) + return httpx.Response(200, json={"answers": {"tier": _answer().model_dump()}}) + + handler: Final = AsyncHTTPHandler() + handler.client = httpx.AsyncClient(transport=httpx.MockTransport(respond)) + router: Final = ComplexityRouter( + "oss-system-prompt", + RoutingContext(litellm.Router(model_list=[])), + { + "classifier_type": "oss_classifier", + "opensource_classifier_config": classifier, + "classifier_caller_system_prompt": mode, + "tiers": {"SIMPLE": "cheap"}, + "session_affinity": False, + "deployment_affinity": False, + }, + jev_client=HttpJevClassifierClient("test", "https://typesafe.test", handler), + derive_savings_baseline=False, + ) + messages: Final = [ + {"role": "system", "content": "HARNESS_SYSTEM_PROMPT"}, + {"role": "user", "content": "ok, go ahead"}, + ] + + result: Final = await router.async_pre_routing_hook( + model="oss-system-prompt", + messages=messages, + request_kwargs={"metadata": {"user_agent": user_agent}}, + ) + await GLOBAL_LOGGING_WORKER.flush() + await handler.client.aclose() + + assert result is not None and result.routing_decision is not None + assert result.routing_decision["cause"] == "jev_classifier" + assert len(captured) == 1 + state: Final = str(captured[0]["state"]) + assert "ok, go ahead" in state + assert ("HARNESS_SYSTEM_PROMPT" in state) is forwards_system + + @pytest.mark.asyncio @pytest.mark.parametrize("include_assistant", [False, True]) async def test_jev_uses_bounded_history_and_separates_operator_instructions(include_assistant: bool) -> None: