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/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: diff --git a/tests/unit/router_strategy/test_complexity_router.py b/tests/unit/router_strategy/test_complexity_router.py index ba829512d8b..392afaddd75 100644 --- a/tests/unit/router_strategy/test_complexity_router.py +++ b/tests/unit/router_strategy/test_complexity_router.py @@ -10052,6 +10052,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]