From 4657d43fb999972bc9c77f65a4ee5a40bb68b44c Mon Sep 17 00:00:00 2001 From: kerry Date: Wed, 16 Sep 2026 00:27:09 +0000 Subject: [PATCH 1/9] fix(anthropic): tolerate message_delta chunks without usage in streams Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/anthropic/chat/handler.py | 4 +++- litellm/types/llms/anthropic.py | 2 +- .../anthropic/chat/test_anthropic_chat_handler.py | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/litellm/llms/anthropic/chat/handler.py b/litellm/llms/anthropic/chat/handler.py index 5d3ae444b42..4dd0deeb62b 100644 --- a/litellm/llms/anthropic/chat/handler.py +++ b/litellm/llms/anthropic/chat/handler.py @@ -1167,7 +1167,9 @@ class ModelResponseIterator: # (matches OpenAI behavior and non-streaming Anthropic implementation) if self.converted_response_format_tool: finish_reason = "stop" - usage: Final = self._handle_usage(anthropic_usage_chunk=message_delta["usage"]) + usage: Final = ( + self._handle_usage(anthropic_usage_chunk=message_delta["usage"]) if "usage" in message_delta else None + ) container: Final = message_delta["delta"].get("container") return finish_reason, usage, container diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index d56ada07ed5..ecb8dbb2502 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -586,7 +586,7 @@ class MessageBlockDelta(TypedDict): type: Literal["message_delta"] delta: MessageDelta - usage: UsageDelta + usage: NotRequired[UsageDelta] context_management: NotRequired[ContextManagementResponse] diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py index 83201aef143..ac05cdcf251 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py @@ -579,6 +579,21 @@ def test_text_only_streaming_has_index_zero(): ), f"Expected index=0, got {parsed.choices[0].index}" +def test_message_delta_without_usage_returns_chunk_with_no_usage(): + """A message_delta event may carry no usage field; it must not raise.""" + iterator = ModelResponseIterator(None, sync_stream=True) + + model_response = iterator.chunk_parser( + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + } + ) + + assert model_response.choices[0].finish_reason == "stop" + assert model_response.usage is None + + def test_streaming_thinking_deltas_count_reasoning_tokens_in_usage(): """Anthropic streaming usage should account for emitted thinking deltas.""" chunks = [ From f2145550680b85ed7f9b67826467125077cc8e8c Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 15 Sep 2026 18:21:43 -0700 Subject: [PATCH 2/9] fix(e2e): expect models filters to persist after reload --- .../tests/internal-user/modelsByTeam.spec.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/tests/e2e/ui/tests/internal-user/modelsByTeam.spec.ts b/tests/e2e/ui/tests/internal-user/modelsByTeam.spec.ts index 5e2c80b5845..736c352e3ee 100644 --- a/tests/e2e/ui/tests/internal-user/modelsByTeam.spec.ts +++ b/tests/e2e/ui/tests/internal-user/modelsByTeam.spec.ts @@ -7,6 +7,7 @@ import { import { E2E_TEAM_CRUD_ALIAS, E2E_TEAM_ORG_ALIAS, + E2E_TEAM_ORG_ID, INTERNAL_USER_STORAGE_PATH, } from "../../constants"; import { Page } from "../../fixtures/pages"; @@ -179,18 +180,28 @@ test.describe("Models and Endpoints for an internal user", () => { `switching to ${ALL_MODELS_VIEW} leaves the table populated rather than blanking it`, ).toHaveCount(1, { timeout: 15_000 }); + await expect(page).toHaveURL((url) => + url.searchParams.get("filter_team") === E2E_TEAM_ORG_ID && + url.searchParams.get("view_mode") === "all", + ); await page.reload(); await expect( teamSelector(page), - "the team selection is not persisted across a reload, so the table returns to the personal view", - ).toContainText(PERSONAL_TEAM, { timeout: 15_000 }); + "the selected team is restored from the URL after a reload", + ).toContainText(E2E_TEAM_ORG_ALIAS, { timeout: 15_000 }); await expect( viewSelector(page), - "the view selection is not persisted across a reload either", - ).toContainText(CURRENT_TEAM_VIEW, { timeout: 15_000 }); + "the selected view is restored from the URL after a reload", + ).toContainText(ALL_MODELS_VIEW, { timeout: 15_000 }); + await expect(modelRow(page, CHAT_MODEL_A)).toHaveCount(1, { timeout: 15_000 }); + await expect(page.getByTestId("pagination-range")).toHaveText("Showing 1-1 of 1"); + await expect(modelRow(page, CHAT_MODEL_B)).toHaveCount(0); + await expect(modelRow(page, ungrantedModelName)).toHaveCount(0); + + await chooseOption(page, teamSelector(page), PERSONAL_TEAM); await expect( modelRow(page, ungrantedModelName), - "the personal view still renders models after a reload rather than coming back empty", + "switching back to the personal team restores models outside the selected team", ).toHaveCount(1, { timeout: 30_000 }); }); }); From 10aef224e7acc6a5f47c77d63c7a98f81728f377 Mon Sep 17 00:00:00 2001 From: kerry Date: Wed, 16 Sep 2026 01:34:28 +0000 Subject: [PATCH 3/9] style(anthropic): apply repository conventions to the missing-usage change Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/types/llms/anthropic.py | 2 +- .../llms/anthropic/chat/test_anthropic_chat_handler.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index ecb8dbb2502..bcdee86360e 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -586,7 +586,7 @@ class MessageBlockDelta(TypedDict): type: Literal["message_delta"] delta: MessageDelta - usage: NotRequired[UsageDelta] + usage: NotRequired[ReadOnly[UsageDelta]] context_management: NotRequired[ContextManagementResponse] diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py index ac05cdcf251..32c98a83948 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py @@ -580,10 +580,9 @@ def test_text_only_streaming_has_index_zero(): def test_message_delta_without_usage_returns_chunk_with_no_usage(): - """A message_delta event may carry no usage field; it must not raise.""" - iterator = ModelResponseIterator(None, sync_stream=True) + iterator: Final = ModelResponseIterator(None, sync_stream=True) - model_response = iterator.chunk_parser( + model_response: Final = iterator.chunk_parser( { "type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None}, From 8d625d0400befd2043806d71caec103c68489be0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:43:01 +0000 Subject: [PATCH 4/9] fix(xai): keep 'instructions' on the xAI Responses API so system messages survive web_search bridging Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/xai/responses/transformation.py | 29 ++-------- .../test_xai_responses_transformation.py | 12 ++-- .../xai/xai_responses/test_transformation.py | 12 ++-- .../test_xai_responses_auto_routing.py | 56 ++++++++++++++++++- 4 files changed, 71 insertions(+), 38 deletions(-) diff --git a/litellm/llms/xai/responses/transformation.py b/litellm/llms/xai/responses/transformation.py index 1f977a66186..2f638da49c8 100644 --- a/litellm/llms/xai/responses/transformation.py +++ b/litellm/llms/xai/responses/transformation.py @@ -49,7 +49,6 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig): Inherits from OpenAIResponsesAPIConfig since XAI's Responses API is largely compatible with OpenAI's, with a few differences: - - Does not support the 'instructions' parameter - Requires code_interpreter tools to have 'container' field removed - Recommends store=false when sending images @@ -60,20 +59,6 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig): def custom_llm_provider(self) -> LlmProviders: return LlmProviders.XAI - def get_supported_openai_params(self, model: str) -> list: - """ - Get supported parameters for XAI Responses API. - - XAI supports most OpenAI Responses API params except 'instructions'. - """ - supported_params: Final = super().get_supported_openai_params(model) - - # Remove 'instructions' as it's not supported by XAI - if "instructions" in supported_params: - supported_params.remove("instructions") - - return supported_params - def _transform_web_search_tool(self, tool: Mapping[str, object]) -> Mapping[str, object]: """ Transform web_search tool to XAI format. @@ -158,19 +143,13 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig): Map parameters for XAI Responses API. Handles XAI-specific transformations: - 1. Drops 'instructions' parameter (not supported) - 2. Transforms code_interpreter tools to remove 'container' field - 3. Transforms web_search tools to XAI format (removes search_context_size, adds filters) - 4. Transforms x_search tools to XAI format - 5. Sets store=false when images are detected (recommended by XAI) + 1. Transforms code_interpreter tools to remove 'container' field + 2. Transforms web_search tools to XAI format (removes search_context_size, adds filters) + 3. Transforms x_search tools to XAI format + 4. Sets store=false when images are detected (recommended by XAI) """ params: Final = dict(response_api_optional_params) - # Drop instructions parameter (not supported by XAI) - if "instructions" in params: - verbose_logger.debug("XAI Responses API does not support 'instructions' parameter. Dropping it.") - params.pop("instructions") - if "metadata" in params: verbose_logger.debug("XAI Responses API does not support 'metadata' parameter. Dropping it.") params.pop("metadata") diff --git a/tests/test_litellm/llms/xai/responses/test_xai_responses_transformation.py b/tests/test_litellm/llms/xai/responses/test_xai_responses_transformation.py index e3feb7d5342..34ad4b9075d 100644 --- a/tests/test_litellm/llms/xai/responses/test_xai_responses_transformation.py +++ b/tests/test_litellm/llms/xai/responses/test_xai_responses_transformation.py @@ -51,23 +51,23 @@ class TestXAIResponsesAPITransformation: assert result["tools"][0]["type"] == "code_interpreter" assert "container" not in result["tools"][0], "Container field should be removed" - def test_instructions_parameter_dropped(self): - """Test that instructions parameter is dropped for XAI""" + def test_instructions_parameter_forwarded(self): + """xAI supports 'instructions' on /v1/responses, so it must survive param mapping""" config = XAIResponsesAPIConfig() params = ResponsesAPIOptionalRequestParams(instructions="You are a helpful assistant.", temperature=0.7) result = config.map_openai_params(response_api_optional_params=params, model="grok-4-fast", drop_params=False) - assert "instructions" not in result, "Instructions should be dropped" + assert result.get("instructions") == "You are a helpful assistant." assert result.get("temperature") == 0.7, "Other params should be preserved" - def test_supported_params_excludes_instructions(self): - """Test that get_supported_openai_params excludes instructions""" + def test_supported_params_includes_instructions(self): + """A system message bridged to 'instructions' must not be rejected for xAI""" config = XAIResponsesAPIConfig() supported = config.get_supported_openai_params("grok-4-fast") - assert "instructions" not in supported, "instructions should not be supported" + assert "instructions" in supported, "instructions should be supported" assert "tools" in supported, "tools should be supported" assert "temperature" in supported, "temperature should be supported" assert "model" in supported, "model should be supported" diff --git a/tests/test_litellm/llms/xai/xai_responses/test_transformation.py b/tests/test_litellm/llms/xai/xai_responses/test_transformation.py index c783918ca06..3ea3fe631bd 100644 --- a/tests/test_litellm/llms/xai/xai_responses/test_transformation.py +++ b/tests/test_litellm/llms/xai/xai_responses/test_transformation.py @@ -53,8 +53,8 @@ class TestXAIResponsesAPITransformation: "container" not in result["tools"][0] ), "Container field should be removed" - def test_instructions_parameter_dropped(self): - """Test that instructions parameter is dropped for XAI""" + def test_instructions_parameter_forwarded(self): + """xAI supports 'instructions' on /v1/responses, so it must survive param mapping""" config = XAIResponsesAPIConfig() params = ResponsesAPIOptionalRequestParams( @@ -65,15 +65,15 @@ class TestXAIResponsesAPITransformation: response_api_optional_params=params, model="grok-4-fast", drop_params=False ) - assert "instructions" not in result, "Instructions should be dropped" + assert result.get("instructions") == "You are a helpful assistant." assert result.get("temperature") == 0.7, "Other params should be preserved" - def test_supported_params_excludes_instructions(self): - """Test that get_supported_openai_params excludes instructions""" + def test_supported_params_includes_instructions(self): + """A system message bridged to 'instructions' must not be rejected for xAI""" config = XAIResponsesAPIConfig() supported = config.get_supported_openai_params("grok-4-fast") - assert "instructions" not in supported, "instructions should not be supported" + assert "instructions" in supported, "instructions should be supported" assert "tools" in supported, "tools should be supported" assert "temperature" in supported, "temperature should be supported" assert "model" in supported, "model should be supported" diff --git a/tests/test_litellm/test_xai_responses_auto_routing.py b/tests/test_litellm/test_xai_responses_auto_routing.py index fbf2453d7fb..d405ea1e6c6 100644 --- a/tests/test_litellm/test_xai_responses_auto_routing.py +++ b/tests/test_litellm/test_xai_responses_auto_routing.py @@ -2,14 +2,30 @@ Test automatic routing to xAI Responses API when tools are present """ +import json +from collections.abc import Mapping +from typing import Final from unittest.mock import MagicMock, patch - +import httpx import pytest import litellm +from litellm.llms.custom_httpx.http_handler import HTTPHandler from litellm.main import responses_api_bridge_check +class _RecordingResponsesHandler: + """MockTransport handler that serves a canned /responses reply and keeps the body xAI would have received""" + + def __init__(self, reply: Mapping[str, object]) -> None: + self.reply: Final = reply + self.request_body: Mapping[str, object] | None = None + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.request_body = json.loads(request.content) + return httpx.Response(200, json=dict(self.reply), request=request) + + class TestXAIResponsesAutoRouting: """Test that xAI requests with tools automatically route to Responses API""" @@ -254,6 +270,44 @@ class TestXAIResponsesAutoRouting: # Note: This test may need adjustment based on actual mock_response behavior # The key is that the responses_api_bridge_check logic routes correctly + def test_system_message_survives_web_search_bridge(self): + """A system message becomes 'instructions' on the bridged /responses call, and xAI accepts it""" + handler: Final = _RecordingResponsesHandler( + reply={ + "id": "resp_test", + "object": "response", + "created_at": 0, + "status": "completed", + "model": "grok-4.6", + "output": [ + { + "type": "message", + "id": "msg_test", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "1.0.0", "annotations": []}], + } + ], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + } + ) + + response: Final = litellm.completion( + model="xai/grok-4.6", + messages=[ + {"role": "system", "content": "Answer briefly."}, + {"role": "user", "content": "newest litellm version?"}, + ], + web_search_options={"search_context_size": "medium"}, + api_key="fake-key", + client=HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(handler))), + ) + + assert response.choices[0].message.content == "1.0.0" + assert handler.request_body is not None + assert handler.request_body["instructions"] == "Answer briefly." + assert handler.request_body["tools"] == [{"type": "web_search"}] + if __name__ == "__main__": pytest.main([__file__, "-v"]) From 091a38cce6b569439e116e8be5f493f06a0974de Mon Sep 17 00:00:00 2001 From: kerry Date: Wed, 16 Sep 2026 01:43:08 +0000 Subject: [PATCH 5/9] test(anthropic): import Final for the annotated locals Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../llms/anthropic/chat/test_anthropic_chat_handler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py index 32c98a83948..c3400dc40c3 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py @@ -1,6 +1,7 @@ import json import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Final from unittest.mock import AsyncMock, MagicMock, patch import httpx From a6228fab66f289b9e8014c4fc75646eae821d1aa Mon Sep 17 00:00:00 2001 From: kerry Date: Wed, 16 Sep 2026 02:03:21 +0000 Subject: [PATCH 6/9] fix(anthropic): write usage into copies of the read-only message_delta chunk Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../adapters/streaming_iterator.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py index e7179aad25b..4486eb0985a 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py @@ -376,10 +376,9 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): usage_dict: UsageDelta = LiteLLMAnthropicMessagesAdapter._translate_openai_usage_to_anthropic_usage_delta( chunk.usage ) - merged_chunk["usage"] = usage_dict if self.applied_edits and "context_management" not in merged_chunk: merged_chunk["context_management"] = ContextManagementResponse(applied_edits=list(self.applied_edits)) - return self._augment_message_delta_usage(merged_chunk) + return self._augment_message_delta_usage({**merged_chunk, "usage": usage_dict}) def _handle_choiceless_chunk(self, chunk: "ModelResponseStream") -> bool: """Consume an OpenAI-compatible chunk that carries no ``choices``. @@ -448,8 +447,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): } iterations.append(message_iteration) augmented_usage["iterations"] = iterations - augmented["usage"] = augmented_usage - return augmented + return {**augmented, "usage": augmented_usage} def _next_compaction_event(self) -> dict[str, object] | None: """Return the next compaction content-block SSE event, or ``None``. From 39b78128103b1c0a2f703e275ddef60e1c1a98e1 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Tue, 15 Sep 2026 18:28:21 -0700 Subject: [PATCH 7/9] feat(ui): configure capability and Fuse v2 classifiers --- .../AutoRouters/AutoRoutersPanel.tsx | 4 +- .../components/AutoRouters/autoRouterRows.ts | 2 + ...oRouterClassifierTabs.integration.test.tsx | 75 +++ .../add_model/AutoRouterClassifierTabs.tsx | 58 ++ .../add_model/ClassificationMethodConfig.tsx | 55 +- .../add_model/ComplexityRouterConfig.tsx | 636 +++++++++--------- ...plexityRouterFastMode.integration.test.tsx | 186 ++++- .../add_model/DefaultModelField.tsx | 56 ++ ...ecastClassifierConfig.integration.test.tsx | 224 ++++++ .../add_model/ForecastClassifierConfig.tsx | 433 ++++++++++++ .../add_model/PlanModeOverrideControls.tsx | 44 ++ .../components/add_model/RoutingOptions.tsx | 23 + .../add_model/TierModelEffortRows.tsx | 10 +- .../components/add_model/TierRestrictions.tsx | 12 +- .../add_model/add_auto_router_tab.test.tsx | 108 +++ .../add_model/add_auto_router_tab.tsx | 451 +++++++------ .../build_complexity_router_config.ts | 132 +++- .../classifier_type_transition.test.ts | 80 +++ .../add_model/classifier_type_transition.ts | 50 ++ .../forecast_classifier_config.test.ts | 284 ++++++++ .../add_model/forecast_classifier_config.ts | 188 ++++++ .../src/components/add_model/tier_rows.ts | 11 +- .../add_model/tier_set_actions.test.ts | 29 + .../components/add_model/tier_set_actions.ts | 30 +- ...d_updated_complexity_router_config.test.ts | 13 +- .../edit_auto_router_modal.tsx | 141 ++-- 26 files changed, 2593 insertions(+), 742 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.tsx create mode 100644 ui/litellm-dashboard/src/components/add_model/DefaultModelField.tsx create mode 100644 ui/litellm-dashboard/src/components/add_model/ForecastClassifierConfig.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/add_model/ForecastClassifierConfig.tsx create mode 100644 ui/litellm-dashboard/src/components/add_model/PlanModeOverrideControls.tsx create mode 100644 ui/litellm-dashboard/src/components/add_model/RoutingOptions.tsx create mode 100644 ui/litellm-dashboard/src/components/add_model/classifier_type_transition.test.ts create mode 100644 ui/litellm-dashboard/src/components/add_model/classifier_type_transition.ts create mode 100644 ui/litellm-dashboard/src/components/add_model/forecast_classifier_config.test.ts create mode 100644 ui/litellm-dashboard/src/components/add_model/forecast_classifier_config.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx index f11b74c939d..1625e0cbfb8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx @@ -104,8 +104,8 @@ export function AutoRoutersPanel({ Add Auto Router - Routes each request to a model by classifying its complexity. Called like any other model, so clients keep - using a single model name. + Choose a classifier to route each request to a model. Called like any other model, so clients keep using a + single model name. Array.from(new Set(models)); const COMPLEXITY_TYPE_LABELS: Record = { llm: "LLM Classifier", + capability: "Capability", + llm_v2: "Fuse v2", heuristic_first: "Heuristic first", hybrid: "Hybrid", custom: "Custom classifier", diff --git a/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.integration.test.tsx new file mode 100644 index 00000000000..ac6851349ea --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.integration.test.tsx @@ -0,0 +1,75 @@ +import React, { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, renderWithProviders, screen } from "../../../tests/test-utils"; +import AutoRouterClassifierTabs from "./AutoRouterClassifierTabs"; +import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig"; + +const initial: ComplexityRouterConfigValue = { + classifier_type: "llm", + tiers: { SIMPLE: ["efficient"], MEDIUM: [], COMPLEX: [], REASONING: ["capable"] }, +}; + +function Form({ initialValue = initial }: { initialValue?: ComplexityRouterConfigValue }) { + const [value, setValue] = useState(initialValue); + return ( + + {value.classifier_type} + + ); +} + +describe("AutoRouterClassifierTabs", () => { + it.each(["heuristic", "heuristic_v2", "llm", "heuristic_first", "hybrid"] as const)( + "groups %s under Complexity without resetting its configuration", + (classifier_type) => { + const onChange = vi.fn(); + renderWithProviders( + + Existing classifier settings + , + ); + expect(screen.getByRole("tab", { name: "Complexity" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("tabpanel", { name: "Complexity" })).toHaveTextContent("Existing classifier settings"); + fireEvent.click(screen.getByRole("tab", { name: "Complexity" })); + expect(onChange).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ["capability", "Capability"], + ["llm_v2", "Fuse v2"], + ] as const)("opens saved %s settings and switches back to local Complexity", (classifier_type, label) => { + renderWithProviders(
); + expect(screen.getByRole("tab", { name: label })).toHaveAttribute("aria-selected", "true"); + fireEvent.click(screen.getByRole("tab", { name: "Complexity" })); + expect(screen.getByRole("tab", { name: "Complexity" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("status", { name: "Classifier type" })).toHaveTextContent("heuristic"); + }); + + it("keeps custom tiers editable under Complexity and explains why forecast tabs are disabled", () => { + const onChange = vi.fn(); + renderWithProviders( + + Custom tiers + , + ); + expect(screen.getByRole("tabpanel", { name: "Complexity" })).toHaveTextContent("Custom tiers"); + for (const name of ["Capability", "Fuse v2"]) { + const tab = screen.getByRole("tab", { name }); + expect(tab).toHaveAttribute("aria-disabled", "true"); + expect(tab).toHaveAccessibleDescription("Restore standard tiers to use Capability or Fuse v2."); + fireEvent.click(tab); + } + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByText("Restore standard tiers to use Capability or Fuse v2.")).toBeVisible(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.tsx b/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.tsx new file mode 100644 index 00000000000..98c0d4aab2f --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/AutoRouterClassifierTabs.tsx @@ -0,0 +1,58 @@ +import React, { useId } from "react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { effectiveClassifierType, type ComplexityRouterConfigValue } from "./ComplexityRouterConfig"; +import { transitionClassifierType } from "./classifier_type_transition"; +import { isForecastClassifier } from "./forecast_classifier_config"; + +interface AutoRouterClassifierTabsProps { + value: ComplexityRouterConfigValue; + onChange: (value: ComplexityRouterConfigValue) => void; + children: React.ReactNode; +} + +const AutoRouterClassifierTabs: React.FC = ({ value, onChange, children }) => { + const restrictionId = useId(); + const classifierType = effectiveClassifierType(value); + const selected = isForecastClassifier(classifierType) ? classifierType : "complexity"; + const hasCustomTiers = Boolean(value.custom_tier_set); + + const handleChange = (tab: unknown) => { + if (tab === selected) return; + if (tab === "complexity") { + onChange(transitionClassifierType(value, isForecastClassifier(classifierType) ? "heuristic" : classifierType)); + } else if (!hasCustomTiers && (tab === "capability" || tab === "llm_v2")) { + onChange(transitionClassifierType(value, tab)); + } + }; + + return ( + +

Classifier type

+ + Complexity + + Capability + + + Fuse v2 + + + {hasCustomTiers && ( +

+ Restore standard tiers to use Capability or Fuse v2. +

+ )} + {children} +
+ ); +}; + +export default AutoRouterClassifierTabs; diff --git a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx index a6f2e65793a..64b08fc9ed1 100644 --- a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx @@ -1,3 +1,4 @@ +import { transitionClassifierType } from "./classifier_type_transition"; import { Info } from "lucide-react"; import { SimpleTooltip } from "@/components/ui/tooltip"; import { MultiSelect } from "@/components/shared/MultiSelect"; @@ -17,7 +18,6 @@ import ClassifierReasoningEffortSelect from "./ClassifierReasoningEffortSelect"; import ClassifierCircuitBreakerConfig from "./ClassifierCircuitBreakerConfig"; import ClassifierVisionConfig from "./ClassifierVisionConfig"; import type { ReasoningEffort } from "./complexity_router_tiers"; -import { nonReasoningTierFields } from "./nonReasoningTierFields"; import { useComplexityScorerDefaults } from "@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults"; import { ClassificationFrequency, @@ -33,12 +33,10 @@ import { DEFAULT_CLASSIFIER_FALLBACK, DEFAULT_CLASSIFIER_TIMEOUT_MS, DEFAULT_CLASSIFICATION_RUBRIC, - NEW_CLASSIFIER_CLASSIFICATION_RUBRIC, ClassificationRubric, effectiveTierLabel, heuristicScoringRole, usesLlmClassifier, - DEFAULT_HEURISTIC_FIRST_MAX_TIER, DEFAULT_HYBRID_BOUNDARY_MARGIN, HEURISTIC_FIRST_MAX_TIER_KEYS, effectiveClassifierType, @@ -263,35 +261,7 @@ const ClassificationMethodConfig: React.FC = ({ const explicitlySupportedClassifierEfforts = effortOptionsByModel[classifierModel]; const handleClassifierTypeChange = (classifierType: ClassifierType) => { - const nextValue: ComplexityRouterConfigValue = { - ...value, - classifier_type: classifierType, - classifier_llm_config: usesLlmClassifier(classifierType) - ? value.classifier_llm_config ?? { - model: "", - timeout_ms: DEFAULT_CLASSIFIER_TIMEOUT_MS, - classification_rubric: NEW_CLASSIFIER_CLASSIFICATION_RUBRIC, - } - : undefined, - classifier_context_window_size: usesLlmClassifier(classifierType) - ? value.classifier_context_window_size ?? DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE - : undefined, - classifier_context_budget_chars: usesLlmClassifier(classifierType) - ? value.classifier_context_budget_chars ?? DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS - : undefined, - classifier_context_include_assistant_turns: usesLlmClassifier(classifierType) - ? value.classifier_context_include_assistant_turns - : undefined, - classifier_fallback: usesLlmClassifier(classifierType) ? value.classifier_fallback : undefined, - heuristic_first_max_tier: - classifierType === "heuristic_first" - ? value.heuristic_first_max_tier ?? DEFAULT_HEURISTIC_FIRST_MAX_TIER - : undefined, - hybrid_boundary_margin: - classifierType === "hybrid" ? value.hybrid_boundary_margin ?? DEFAULT_HYBRID_BOUNDARY_MARGIN : undefined, - ...nonReasoningTierFields(classifierType, value), - }; - onChange(nextValue); + onChange(transitionClassifierType(value, classifierType)); }; const handleHeuristicFirstMaxTierChange = (tier: string) => { @@ -433,27 +403,6 @@ const ClassificationMethodConfig: React.FC = ({ }); }; - if (classifierType === "capability") { - return ( -

- This router uses capability forecasting. Configure its classifier, threshold, and calibration through YAML or - the API. Saving preserves those settings -

- ); - } - - if (classifierType === "llm_v2") { - return ( -
- LLM V2 classifier (experimental) -

- Combines task demands and model capability in one forecast. Its solver profiles and quality allowance are - configured through the API. Saving this router preserves those settings -

-
- ); - } - return ( <> diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx index c6b9a69e76a..4ccabff18b9 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx @@ -1,8 +1,11 @@ +import RoutingOptions from "./RoutingOptions"; +import PlanModeOverrideControls from "./PlanModeOverrideControls"; +import ForecastClassifierConfig, { ForecastSolverModels } from "./ForecastClassifierConfig"; +import { isForecastClassifier, type CapabilitySettings, type FuseSettings } from "./forecast_classifier_config"; import { SimpleTooltip } from "@/components/ui/tooltip"; import { MultiSelect } from "@/components/shared/MultiSelect"; -import { SearchSelect } from "@/components/shared/SearchSelect"; +import DefaultModelField from "./DefaultModelField"; import { ChevronRight, Info, Plus, Trash2, X } from "lucide-react"; -import { Switch } from "@/components/ui/switch"; import { AffinityControls } from "./AffinityControls"; import NonReasoningTierToggle from "./NonReasoningTierToggle"; @@ -204,11 +207,6 @@ const rowOrigin = (row: TierRow, editing: boolean): string => { return isBuiltInTierName(row.name) ? "built-in" : "custom"; }; -const defaultModelPlaceholderFor = (derivedDefaultModel: string | undefined, isCustomSet: boolean): string => { - if (derivedDefaultModel) return `Derived from tiers: ${derivedDefaultModel}`; - return isCustomSet ? "Add a model to your fallback tier" : "Add a model to the Simple or Medium tier"; -}; - const builtInTierInfo = (rowId: string): { label: string; description: string; examples: string } | undefined => { const builtIn = ALL_BUILT_IN_TIERS.find((tier) => tier === rowId); return builtIn ? TIER_DESCRIPTIONS[builtIn] : undefined; @@ -376,6 +374,8 @@ export interface ComplexityRouterConfigValue { /** An explicit pin. Unset means the default tracks the tiers - see resolveComplexityDefaultModel. */ default_model?: string; classifier_type: ClassifierType; + capability_classifier_config?: CapabilitySettings; + llm_v2_config?: FuseSettings; classifier_llm_config?: ClassifierLLMConfig; classifier_context_window_size?: number; classifier_context_budget_chars?: number; @@ -535,44 +535,6 @@ export const DEFAULT_HYBRID_BOUNDARY_MARGIN = 0.03; */ export const HEURISTIC_FIRST_MAX_TIER_KEYS = TIER_ORDER.slice(0, -1); -const PlanModeOverrideControls: React.FC<{ - value: ComplexityRouterConfigValue; - onChange: (value: ComplexityRouterConfigValue) => void; - planModeTierOptions: { value: string; label: string }[]; -}> = ({ value, onChange, planModeTierOptions }) => ( - <> -
- - onChange({ - ...value, - plan_mode_min_tier: enabled ? planModeTierOptions.at(-1)?.value : undefined, - }) - } - aria-label="Route plan-mode requests to a minimum tier" - /> - Route plan-mode requests to a minimum tier -
- - Requests from coding agents in plan mode (Claude Code, GitHub Copilot) route to at least this tier. The classifier - still wins when it picks higher, and the override only lasts while plan mode is active. - {planModeTierOptions.length === 0 && " Add models to a tier to enable this."} - - {value.plan_mode_min_tier !== undefined && ( -
- onChange({ ...value, plan_mode_min_tier: tier })} - /> -
- )} - -); - const ComplexityRouterConfig: React.FC = ({ modelInfo, value, @@ -596,6 +558,7 @@ const ComplexityRouterConfig: React.FC = ({ onAutoRouterCompressionChange, showValidationErrors = false, }) => { + const forecast = isForecastClassifier(value.classifier_type); const customTierSet = value.custom_tier_set; const tierRows = activeTierRows(value); const tierRowsError = customTierSet ? getCustomTierRowsError(customTierSet) : null; @@ -605,8 +568,6 @@ const ComplexityRouterConfig: React.FC = ({ value: row.id, label: tierRowLabel(row, value.tier_labels), })); - const derivedDefaultModel = resolveComplexityDefaultModel(value); - const defaultModelPlaceholder = defaultModelPlaceholderFor(derivedDefaultModel, Boolean(customTierSet)); const defaultModel = resolveComplexityDefaultModel(value, value.default_model); const dispatch = (action: TierSetAction) => { @@ -641,298 +602,321 @@ const ComplexityRouterConfig: React.FC = ({ tier_model_params: setTierModelParam(value.tier_model_params, tier, model, change), }); - // Clearing the select drops the key entirely rather than storing "", so an emptied pin reads as - // "track the tiers" everywhere downstream instead of as a blank model name. - const handleDefaultModelChange = (model: string | null | undefined) => { - onChange({ ...value, default_model: model || undefined }); - }; - - const handleTierLabelChange = (tier: keyof ComplexityTiers, label: string) => { - onChange({ - ...value, - tier_labels: { ...value.tier_labels, [tier]: label }, - }); - }; + const handleTierLabelChange = (tier: keyof ComplexityTiers, label: string) => + onChange({ ...value, tier_labels: { ...value.tier_labels, [tier]: label } }); return (
-

Complexity Tier Configuration

- - - +

+ {forecast ? "Solver models" : "Complexity Tier Configuration"} +

+ {!forecast && ( + + + + )}
- - - - - {!customTierSet && ( - - )} - - {tierRows.map((row, index) => { - const tierInfo = builtInTierInfo(row.id); - const label = tierRowLabel(row, value.tier_labels); - const tierMissing = showValidationErrors && row.models.length === 0; - const needsDefinition = Boolean(customTierSet) && !row.definition.trim() && !isBuiltInTierName(row.name); - const definitionMissing = showValidationErrors && needsDefinition; - const showsDisplayName = !customTierSet && !editingTiers; - return ( -
- {index > 0 && } -
- removeTierRow(row.id)} - /> - {tierInfo && !customTierSet && ( - Examples: {tierInfo.examples} - )} - {editingTiers && ( - updateTierRow(row.id, patch)} - /> - )} - {showsDisplayName && tierInfo && ( - - handleTierLabelChange(row.id as keyof ComplexityTiers, event.target.value)} - placeholder={`Display name (default: ${tierInfo.label})`} - aria-label={`Display name for the ${tierInfo.label} tier`} - /> - {value.tier_labels?.[row.id as keyof ComplexityTiers] && ( - - handleTierLabelChange(row.id as keyof ComplexityTiers, "")} - > - - - - )} - - )} - setRowModels(row, models)} - placeholder={`Select model(s) for ${label.toLowerCase()} queries`} - emptyText="No models found" - className={tierMissing ? "w-full border-destructive" : "w-full"} - /> - - handleTierModelParamChange(row.id, model, ["reasoning_effort", effort]) - } - onFastModeChange={(model, enabled) => - handleTierModelParamChange(row.id, model, ["speed", enabled ? "fast" : undefined]) - } - /> - {row.models.length > 1 && ( - - Multiple models selected: the router randomly picks among them per request (or Thompson-samples - within the pool when adaptive routing is on). - - )} - {tierMissing && The {label} tier is required} -
-
- ); - })} - - + + + + ) : ( + <> + - {customTierSet && ( - onChange(setFallbackTier(value, fallbackTierId))} - /> - )} + + + {!customTierSet && ( + + )} - + {tierRows.map((row, index) => { + const tierInfo = builtInTierInfo(row.id); + const label = tierRowLabel(row, value.tier_labels); + const tierMissing = showValidationErrors && row.models.length === 0; + const needsDefinition = + Boolean(customTierSet) && !row.definition.trim() && !isBuiltInTierName(row.name); + const definitionMissing = showValidationErrors && needsDefinition; + const showsDisplayName = !customTierSet && !editingTiers; + return ( +
+ {index > 0 && } +
+ removeTierRow(row.id)} + /> + {tierInfo && !customTierSet && ( + Examples: {tierInfo.examples} + )} + {editingTiers && ( + updateTierRow(row.id, patch)} + /> + )} + {showsDisplayName && tierInfo && ( + + + handleTierLabelChange(row.id as keyof ComplexityTiers, event.target.value) + } + placeholder={`Display name (default: ${tierInfo.label})`} + aria-label={`Display name for the ${tierInfo.label} tier`} + /> + {value.tier_labels?.[row.id as keyof ComplexityTiers] && ( + + handleTierLabelChange(row.id as keyof ComplexityTiers, "")} + > + + + + )} + + )} + setRowModels(row, models)} + placeholder={`Select model(s) for ${label.toLowerCase()} queries`} + emptyText="No models found" + className={tierMissing ? "w-full border-destructive" : "w-full"} + /> + + handleTierModelParamChange(row.id, model, ["reasoning_effort", effort]) + } + onFastModeChange={(model, enabled) => + handleTierModelParamChange(row.id, model, ["speed", enabled ? "fast" : undefined]) + } + /> + {row.models.length > 1 && ( + + Multiple models selected: the router randomly picks among them per request (or + Thompson-samples within the pool when adaptive routing is on). + + )} + {tierMissing && The {label} tier is required} +
+
+ ); + })} -
-
- Default Model - - - -
- - - Used when the tier the request lands in has no model, and when the classifier fails with "Route to - the default model" selected. - -
-
-
+ + {customTierSet && ( + onChange(setFallbackTier(value, fallbackTierId))} + /> + )} +
+
+ + )} + {!forecast && } -
- {[ - { - key: "classifier", - label: Advanced: Classification Method, - children: ( - - ), - }, - { - key: "adaptive", - label: Advanced: Adaptive Routing, - children: ( - - - - ), - }, - { - key: "affinity", - label: Advanced: Affinity, - children: , - }, - { - key: "modality", - label: Advanced: Modality Routing, - children: , - }, - { - key: "plan-mode", - label: Advanced: Plan-Mode Override, - children: ( - - ), - }, - { - key: "context-window", - label: Advanced: Context Window Escalation, - children: , - }, - { - key: "stall-escalation", - label: Advanced: Stalled Task Escalation, - children: ( - - - - ), - }, - { - key: "response", - label: Advanced: Response Format, - children: , - }, - ...(onEscalationKeywordsChange - ? [ - { - key: "escalation", - label: Advanced: Escalation Keywords, - children: ( - - - - ), - }, - ] - : []), - ...(onAutoRouterCompressionChange - ? [ - { - key: "compression", - label: Advanced: Compression, - children: ( - - ), - }, - ] - : []), - ...(onKeywordTierRulesChange || onSemanticMatchingEnabledChange - ? [ - { - key: "keyword-semantic", - label: Advanced: Keyword/Semantic Matching, - children: ( - <> - {onKeywordTierRulesChange && ( - - )} - {onKeywordTierRulesChange && onSemanticMatchingEnabledChange && } - {onSemanticMatchingEnabledChange && ( - - )} - - ), - }, - ] - : []), - ].map(({ key, label, children }) => ( - - - - {label} - - {children} - - ))} -
+ + {forecast && ( + <> + + + + )} +
+ {[ + ...(!forecast + ? [ + { + key: "classifier", + label: Advanced: Classification Method, + children: ( + + ), + }, + ] + : []), + ...(value.classifier_type !== "llm_v2" + ? [ + { + key: "adaptive", + label: Advanced: Adaptive Routing, + children: ( + + + + ), + }, + ] + : []), + { + key: "affinity", + label: Advanced: Affinity, + children: , + }, + { + key: "modality", + label: Advanced: Modality Routing, + children: , + }, + { + key: "plan-mode", + label: Advanced: Plan-Mode Override, + children: ( + + ), + }, + { + key: "context-window", + label: Advanced: Context Window Escalation, + children: , + }, + { + key: "stall-escalation", + label: Advanced: Stalled Task Escalation, + children: ( + + + + ), + }, + { + key: "response", + label: Advanced: Response Format, + children: , + }, + ...(onEscalationKeywordsChange + ? [ + { + key: "escalation", + label: Advanced: Escalation Keywords, + children: ( + + + + ), + }, + ] + : []), + ...(onAutoRouterCompressionChange + ? [ + { + key: "compression", + label: Advanced: Compression, + children: ( + + ), + }, + ] + : []), + ...(onKeywordTierRulesChange || onSemanticMatchingEnabledChange + ? [ + { + key: "keyword-semantic", + label: ( + Advanced: Keyword/Semantic Matching + ), + children: ( + <> + {onKeywordTierRulesChange && ( + + )} + {onKeywordTierRulesChange && onSemanticMatchingEnabledChange && } + {onSemanticMatchingEnabledChange && ( + + )} + + ), + }, + ] + : []), + ].map(({ key, label, children }) => ( + + + + {label} + + {children} + + ))} +
+
); }; diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterFastMode.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterFastMode.integration.test.tsx index 34d14091bde..810289da79f 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterFastMode.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterFastMode.integration.test.tsx @@ -1,11 +1,12 @@ import userEvent from "@testing-library/user-event"; import React from "react"; import { describe, expect, it, vi } from "vitest"; -import { renderWithProviders, screen } from "../../../tests/test-utils"; +import { renderWithProviders, screen, within } from "../../../tests/test-utils"; import { buildUpdatedComplexityRouterConfig, hydrateComplexityRouterConfig, } from "../edit_auto_router/edit_auto_router_modal"; +import type { KeywordTierRule } from "./KeywordTierRules"; import type { ModelGroup } from "../llm_calls/fetch_models"; import ComplexityRouterConfig, { type ComplexityRouterConfigValue } from "./ComplexityRouterConfig"; @@ -50,8 +51,8 @@ it.each([false, true])("edits and round-trips independent model settings with cu const view = renderWithProviders(editor(initial)); const fast = () => screen.getByRole("switch", { name: `Fast mode for primary in the ${label} tier` }); - expect(screen.getAllByRole("switch", { name: /^Fast mode for/ })).toHaveLength(3); - expect(screen.queryByRole("switch", { name: /^Fast mode for (blocked|missing)/ })).not.toBeInTheDocument(); + expect(screen.getAllByRole("switch", { name: /^Fast mode for/ })).toHaveLength(4); + expect(screen.queryByRole("switch", { name: /^Fast mode for missing/ })).not.toBeInTheDocument(); expect(screen.queryByRole("combobox", { name: /^Reasoning effort for secondary/ })).not.toBeInTheDocument(); expect(screen.getByRole("switch", { name: `Fast mode for secondary in the ${label} tier` })).toBeChecked(); expect(fast()).not.toBeChecked(); @@ -125,19 +126,170 @@ it.each([false, true])("edits and round-trips independent model settings with cu ); }); -describe("Fast mode metadata", () => { - it("offers nothing before model capabilities load and leaves stored speed untouched", () => { - const value: ComplexityRouterConfigValue = { - tiers: { SIMPLE: ["primary"], MEDIUM: [], COMPLEX: [], REASONING: [] }, - classifier_type: "heuristic", - tier_model_params: { SIMPLE: { primary: { speed: "fast" } } }, - }; - const onChange = vi.fn(); - renderWithProviders(); - expect(screen.queryByRole("switch", { name: /^Fast mode for/ })).not.toBeInTheDocument(); - expect(onChange).not.toHaveBeenCalled(); - expect(buildUpdatedComplexityRouterConfig({}, value).tier_model_configs).toEqual({ - SIMPLE: [{ model_name: "primary", litellm_params: { speed: "fast" } }], - }); +it.each(["capability", "llm_v2"] as const)("preserves Fast mode controls for %s solvers", async (classifierType) => { + const user = userEvent.setup(); + const initial: ComplexityRouterConfigValue = { + classifier_type: classifierType, + classifier_llm_config: { model: "primary", timeout_ms: 3000 }, + tiers: { SIMPLE: ["primary"], MEDIUM: [], COMPLEX: [], REASONING: ["blocked"] }, + capability_classifier_config: { efficient_tier: "SIMPLE", capable_tier: "REASONING", base_threshold: 0.7 }, + llm_v2_config: { + efficient_profile: "Small solver", + capable_profile: "Large solver", + harness: "One attempt", + max_quality_gap: 0.05, + }, + tier_model_params: { SIMPLE: { primary: { reasoning_effort: "high", max_tokens: 1024 } } }, + }; + const onChange = vi.fn<(value: ComplexityRouterConfigValue) => void>(); + const editor = (value: ComplexityRouterConfigValue) => ( + + ); + const view = renderWithProviders(editor(initial)); + const fast = () => screen.getByRole("switch", { name: "Fast mode for primary in the Efficient solver tier" }); + expect(screen.getAllByRole("switch", { name: /^Fast mode for/ })).toHaveLength(1); + expect(fast()).not.toBeChecked(); + await user.click(fast()); + const enabled = onChange.mock.lastCall![0]; + expect(enabled.tier_model_params?.SIMPLE.primary).toEqual({ + reasoning_effort: "high", + max_tokens: 1024, + speed: "fast", + }); + const saved = buildUpdatedComplexityRouterConfig({}, enabled); + expect(saved.tier_model_configs).toEqual({ + SIMPLE: [{ model_name: "primary", litellm_params: { reasoning_effort: "high", max_tokens: 1024, speed: "fast" } }], + }); + view.rerender(editor(hydrateComplexityRouterConfig(saved, undefined))); + expect(fast()).toBeChecked(); + await user.click(fast()); + expect(onChange.mock.lastCall![0].tier_model_params?.SIMPLE.primary).toEqual({ + reasoning_effort: "high", + max_tokens: 1024, }); }); + +describe("Fast mode metadata", () => { + it.each(["heuristic", "capability", "llm_v2"] as const)( + "can clear stored Fast mode without current capability metadata for %s", + async (classifier_type) => { + const user = userEvent.setup(); + const value: ComplexityRouterConfigValue = { + tiers: { SIMPLE: ["primary"], MEDIUM: [], COMPLEX: [], REASONING: ["secondary"] }, + classifier_type, + tier_model_params: { SIMPLE: { primary: { speed: "fast", max_tokens: 512 } } }, + }; + const onChange = vi.fn<(value: ComplexityRouterConfigValue) => void>(); + const editor = (current: ComplexityRouterConfigValue, info: ModelGroup[]) => ( + + ); + const view = renderWithProviders(editor(value, [])); + const fast = () => screen.getByRole("switch", { name: /^Fast mode for primary/ }); + expect(fast()).toBeChecked(); + expect(onChange).not.toHaveBeenCalled(); + view.rerender(editor(value, [{ model_group: "primary", supports_fast_mode: false }])); + expect(fast()).toBeChecked(); + await user.click(fast()); + const cleared = onChange.mock.lastCall![0]; + expect(cleared.tier_model_params?.SIMPLE.primary).toEqual({ max_tokens: 512 }); + const saved = buildUpdatedComplexityRouterConfig({}, cleared); + expect(saved.tier_model_configs).toEqual({ + SIMPLE: [{ model_name: "primary", litellm_params: { max_tokens: 512 } }], + }); + view.rerender(editor(hydrateComplexityRouterConfig(saved, undefined), [])); + expect(screen.queryByRole("switch", { name: /^Fast mode for primary/ })).not.toBeInTheDocument(); + view.rerender(editor(cleared, modelInfo)); + expect(fast()).not.toBeChecked(); + }, + ); +}); + +it.each(["MEDIUM", "REASONING"])("clears a legacy Capability pool while reconciling plan floor %s", async (floor) => { + const user = userEvent.setup(); + const stored = { + classifier_type: "capability" as const, + plan_mode_min_tier: floor, + tiers: { SIMPLE: ["primary"], MEDIUM: ["secondary"], COMPLEX: [], REASONING: ["blocked"] }, + tier_model_configs: { MEDIUM: [{ model_name: "secondary", litellm_params: { speed: "fast" } }] }, + }; + const value = hydrateComplexityRouterConfig(stored, undefined); + const onChange = vi.fn<(value: ComplexityRouterConfigValue) => void>(); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: "Advanced routing options" })); + expect(screen.getByRole("switch", { name: "Fast mode for secondary in the Medium routing pool tier" })).toBeChecked(); + expect(onChange).not.toHaveBeenCalled(); + await user.click(screen.getByRole("combobox", { name: "Select medium routing pool models" })); + await user.click(await screen.findByRole("option", { name: "secondary" })); + await user.keyboard("{Escape}"); + const cleared = onChange.mock.lastCall![0]; + expect(cleared.tiers.MEDIUM).toEqual([]); + expect(cleared.plan_mode_min_tier).toBe(floor === "MEDIUM" ? undefined : floor); + expect(cleared.tier_model_params).toBeUndefined(); + expect(buildUpdatedComplexityRouterConfig(stored, cleared).tiers).toEqual({ + SIMPLE: ["primary"], + REASONING: ["blocked"], + }); +}); + +it.each(["capability", "llm_v2"] as const)( + "shows and clears a persisted default model in %s", + async (classifier_type) => { + const user = userEvent.setup(); + const stored = { + classifier_type, + default_model: "legacy-default", + tiers: { SIMPLE: ["primary"], REASONING: ["secondary"] }, + }; + const onChange = vi.fn<(value: ComplexityRouterConfigValue) => void>(); + const editor = (value: ComplexityRouterConfigValue) => ( + + ); + const view = renderWithProviders(editor(hydrateComplexityRouterConfig(stored, undefined))); + await user.click(screen.getByRole("button", { name: "Advanced routing options" })); + const select = () => screen.getByRole("combobox", { name: "Default model" }); + expect(select()).toHaveValue("legacy-default"); + expect(onChange).not.toHaveBeenCalled(); + await user.click(select()); + await user.click(await screen.findByRole("option", { name: "blocked" })); + const changed = onChange.mock.lastCall![0]; + expect(buildUpdatedComplexityRouterConfig(stored, changed).default_model).toBe("blocked"); + view.rerender(editor(changed)); + await user.click( + within(screen.getByRole("group", { name: "Default model configuration" })).getByRole("button", { name: "Clear" }), + ); + const cleared = onChange.mock.lastCall![0]; + expect(cleared.default_model).toBeUndefined(); + const saved = buildUpdatedComplexityRouterConfig(stored, cleared); + expect(saved).not.toHaveProperty("default_model"); + view.rerender(editor(hydrateComplexityRouterConfig(saved, undefined))); + expect(select()).toHaveValue(""); + expect(select()).toHaveAttribute("placeholder", expect.stringContaining("primary")); + }, +); + +it.each(["capability", "llm_v2"] as const)("offers only populated keyword targets for %s", async (classifier_type) => { + const user = userEvent.setup(); + const value: ComplexityRouterConfigValue = { + classifier_type, + tiers: { SIMPLE: ["primary"], MEDIUM: [], COMPLEX: [], REASONING: ["secondary"] }, + }; + const onRulesChange = vi.fn<(rules: KeywordTierRule[]) => void>(); + const editor = (rules: KeywordTierRule[]) => ( + + ); + const view = renderWithProviders(editor([])); + await user.click(screen.getByRole("button", { name: "Advanced routing options" })); + await user.click(screen.getByText("Advanced: Keyword/Semantic Matching")); + await user.click(screen.getByRole("button", { name: "Add keyword rule" })); + const rules = onRulesChange.mock.lastCall![0]; + expect(rules[0].tier).toBe("SIMPLE"); + view.rerender(editor(rules)); + await user.click(screen.getByRole("combobox", { name: "Route keyword rule 1 to tier" })); + expect((await screen.findAllByRole("option")).map((option) => option.textContent)).toEqual(["Simple", "Reasoning"]); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/DefaultModelField.tsx b/ui/litellm-dashboard/src/components/add_model/DefaultModelField.tsx new file mode 100644 index 00000000000..a3ab85f7c13 --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/DefaultModelField.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { Info } from "lucide-react"; +import { SearchSelect } from "@/components/shared/SearchSelect"; +import { SimpleTooltip } from "@/components/ui/tooltip"; +import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig"; +import { isForecastClassifier } from "./forecast_classifier_config"; +import { resolveComplexityDefaultModel } from "./tier_rows"; + +interface DefaultModelFieldProps { + value: ComplexityRouterConfigValue; + onChange: (value: ComplexityRouterConfigValue) => void; + modelOptions: { value: string; label: string }[]; +} + +const defaultModelPlaceholderFor = (derivedDefaultModel: string | undefined, isCustomSet: boolean): string => { + if (derivedDefaultModel) return `Derived from tiers: ${derivedDefaultModel}`; + return isCustomSet ? "Add a model to your fallback tier" : "Add a model to the Simple or Medium tier"; +}; + +const DefaultModelField = ({ value, onChange, modelOptions }: DefaultModelFieldProps) => { + const defaultModelPlaceholder = defaultModelPlaceholderFor( + resolveComplexityDefaultModel(value), + Boolean(value.custom_tier_set), + ); + // Clearing the select drops the key entirely rather than storing "", so an emptied pin reads as + // "track the tiers" everywhere downstream instead of as a blank model name. + const handleDefaultModelChange = (model: string | null | undefined) => { + onChange({ ...value, default_model: model || undefined }); + }; + + return ( +
+
+ Default Model + + + +
+ + + {isForecastClassifier(value.classifier_type) + ? "Used when routing cannot find a suitable model. Classifier failures route to the capable solver." + : 'Used when the tier the request lands in has no model, and when the classifier fails with "Route to the default model" selected.'} + +
+ ); +}; + +export default DefaultModelField; diff --git a/ui/litellm-dashboard/src/components/add_model/ForecastClassifierConfig.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/ForecastClassifierConfig.integration.test.tsx new file mode 100644 index 00000000000..c249a63899a --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/ForecastClassifierConfig.integration.test.tsx @@ -0,0 +1,224 @@ +import React, { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { fireEvent, renderWithProviders, screen } from "../../../tests/test-utils"; +import ClassificationMethodConfig from "./ClassificationMethodConfig"; +import AutoRouterClassifierTabs from "./AutoRouterClassifierTabs"; +import ForecastClassifierConfig from "./ForecastClassifierConfig"; +import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig"; +import { getForecastConfigError, isForecastClassifier } from "./forecast_classifier_config"; +import { buildUpdatedComplexityRouterConfig } from "../edit_auto_router/edit_auto_router_modal"; + +vi.mock("@/components/networking", async (importOriginal) => ({ + ...(await importOriginal()), + getComplexityScorerDefaults: vi.fn(async () => ({ + tier_boundaries: {}, + token_thresholds: {}, + dimension_weights: {}, + })), +})); + +const initial: ComplexityRouterConfigValue = { + classifier_type: "capability", + classifier_llm_config: { model: "judge", timeout_ms: 20000 }, + tiers: { SIMPLE: ["efficient"], MEDIUM: [], COMPLEX: [], REASONING: ["capable"] }, + capability_classifier_config: { efficient_tier: "SIMPLE", capable_tier: "REASONING", base_threshold: 0.7 }, +}; +const fuseInitial: ComplexityRouterConfigValue = { + ...initial, + classifier_type: "llm_v2", + capability_classifier_config: undefined, + adaptive: false, + llm_v2_config: { + efficient_profile: "Small solver", + capable_profile: "Larger solver", + harness: "One attempt", + max_quality_gap: 0.05, + }, +}; +const options = ["judge", "efficient", "capable"].map((model) => ({ value: model, label: model })); + +function Form({ initialValue = initial }: { initialValue?: ComplexityRouterConfigValue }) { + const [value, setValue] = useState(initialValue); + const [saved, setSaved] = useState(""); + return ( + <> + + {isForecastClassifier(value.classifier_type) ? ( + + ) : ( + + )} + + + {saved} + + ); +} + +describe("forecast classifier form", () => { + it("switches a populated standard router to Capability without saving hidden pools or their overrides", () => { + renderWithProviders( + , + ); + fireEvent.click(screen.getByRole("tab", { name: "Capability" })); + fireEvent.change(screen.getByLabelText("Solve probability threshold"), { target: { value: "0.7" } }); + expect(screen.getByRole("button", { name: "Save configuration" })).toBeEnabled(); + fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + const output = screen.getByRole("status", { name: "Saved configuration" }); + expect(output).toHaveTextContent('"classifier_type":"capability"'); + expect(output).toHaveTextContent('"SIMPLE":["efficient","second-efficient"]'); + expect(output).toHaveTextContent('"REASONING":["capable"]'); + expect(output).toHaveTextContent('"reasoning_effort":"low","speed":"fast","max_tokens":1024'); + expect(output).toHaveTextContent('"reasoning_effort":"high"'); + expect(output).toHaveTextContent('"adaptive":true'); + expect(output).not.toHaveTextContent("leftover-medium"); + expect(output).not.toHaveTextContent("leftover-complex"); + expect(output).not.toHaveTextContent('"plan_mode_min_tier"'); + }); + + it.each(["capability", "llm_v2"] as const)( + "carries non-default solver assignments when switching away from %s", + (source) => { + const pair = { efficient_tier: "MEDIUM", capable_tier: "COMPLEX" }; + const previous: ComplexityRouterConfigValue = { + ...(source === "capability" ? initial : fuseInitial), + tiers: { SIMPLE: [], MEDIUM: ["efficient"], COMPLEX: ["capable"], REASONING: [] }, + capability_classifier_config: + source === "capability" ? { ...initial.capability_classifier_config!, ...pair } : undefined, + llm_v2_config: source === "llm_v2" ? { ...fuseInitial.llm_v2_config!, ...pair } : undefined, + plan_mode_min_tier: "COMPLEX", + tier_model_params: { MEDIUM: { efficient: { max_tokens: 128 } }, COMPLEX: { capable: { speed: "fast" } } }, + }; + renderWithProviders(); + fireEvent.click(screen.getByRole("tab", { name: source === "capability" ? "Fuse v2" : "Capability" })); + if (source === "capability") { + fireEvent.change(screen.getByLabelText("Efficient solver profile"), { target: { value: "Small solver" } }); + fireEvent.change(screen.getByLabelText("Capable solver profile"), { target: { value: "Large solver" } }); + fireEvent.change(screen.getByLabelText("Harness and budget"), { target: { value: "One attempt" } }); + fireEvent.change(screen.getByLabelText("Maximum quality gap"), { target: { value: "0.05" } }); + } else { + fireEvent.change(screen.getByLabelText("Solve probability threshold"), { target: { value: "0.7" } }); + } + expect(screen.getByRole("button", { name: "Save configuration" })).toBeEnabled(); + fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + const output = screen.getByRole("status", { name: "Saved configuration" }); + expect(output).toHaveTextContent('"efficient_tier":"MEDIUM","capable_tier":"COMPLEX"'); + expect(output).toHaveTextContent('"tiers":{"MEDIUM":["efficient"],"COMPLEX":["capable"]}'); + expect(output).toHaveTextContent('"plan_mode_min_tier":"COMPLEX"'); + expect(output).toHaveTextContent('"max_tokens":128'); + expect(output).toHaveTextContent('"speed":"fast"'); + }, + ); + + it("keeps decimal and negative numbers when entered one character at a time", async () => { + const user = userEvent.setup(); + renderWithProviders(); + const threshold = screen.getByLabelText("Solve probability threshold"); + await user.clear(threshold); + await user.type(threshold, "0.65"); + expect(threshold).toHaveValue(0.65); + await user.click(screen.getByRole("button", { name: "Classifier options" })); + await user.click(screen.getByRole("switch", { name: "Use fitted calibration" })); + await user.type(screen.getByLabelText("Efficient intercept"), "-0.3"); + expect(screen.getByLabelText("Efficient intercept")).toHaveValue(-0.3); + }); + + it.each([ + ["capability", "LLM Classifier"], + ["capability", "Heuristic first"], + ["capability", "Hybrid"], + ["llm_v2", "LLM Classifier"], + ["llm_v2", "Heuristic first"], + ["llm_v2", "Hybrid"], + ] as const)("restores the current rubric when switching %s through Complexity to %s", async (source, target) => { + const user = userEvent.setup(); + renderWithProviders(); + fireEvent.click(screen.getByRole("tab", { name: "Complexity" })); + fireEvent.click(screen.getByRole("radio", { name: new RegExp(`^${target}`) })); + await user.click(screen.getByRole("combobox", { name: "Classifier Model" })); + await user.click(screen.getByRole("option", { name: "judge", exact: true })); + fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + const output = screen.getByRole("status", { name: "Saved configuration" }); + expect(output).toHaveTextContent('"classification_rubric":"agentic"'); + expect(output).toHaveTextContent('"model":"judge"'); + expect(output).toHaveTextContent('"timeout_ms":3000'); + expect(output).not.toHaveTextContent('"capability_classifier_config"'); + expect(output).not.toHaveTextContent('"llm_v2_config"'); + }); + + it("saves capability threshold edits together with fitted calibration", () => { + renderWithProviders(); + fireEvent.change(screen.getByLabelText("Solve probability threshold"), { target: { value: "0.6" } }); + fireEvent.click(screen.getByRole("button", { name: "Classifier options" })); + fireEvent.click(screen.getByRole("switch", { name: "Use fitted calibration" })); + expect(screen.getByRole("button", { name: "Save configuration" })).toBeDisabled(); + fireEvent.change(screen.getByLabelText("Calibration version"), { target: { value: "eval-a" } }); + fireEvent.change(screen.getByLabelText("Efficient slope"), { target: { value: "1.2" } }); + fireEvent.change(screen.getByLabelText("Efficient intercept"), { target: { value: "-0.3" } }); + fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + const output = screen.getByRole("status", { name: "Saved configuration" }); + expect(output).toHaveTextContent('"base_threshold":0.6'); + expect(output).toHaveTextContent('"calibration":{"version":"eval-a","slope":1.2,"intercept":-0.3}'); + fireEvent.change(screen.getByLabelText("Solve probability threshold"), { target: { value: "" } }); + expect(screen.getByRole("button", { name: "Save configuration" })).toBeDisabled(); + }); + + it("switches to Fuse, requires solver context, and saves the filled fields", () => { + renderWithProviders(); + fireEvent.click(screen.getByRole("tab", { name: "Fuse v2" })); + expect(screen.queryByLabelText("Solve probability threshold")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save configuration" })).toBeDisabled(); + fireEvent.change(screen.getByLabelText("Efficient solver profile"), { + target: { value: "Short reasoning budget" }, + }); + fireEvent.change(screen.getByLabelText("Capable solver profile"), { target: { value: "Larger reasoning budget" } }); + fireEvent.change(screen.getByLabelText("Harness and budget"), { + target: { value: "Shell and test runner, one attempt" }, + }); + fireEvent.change(screen.getByLabelText("Maximum quality gap"), { target: { value: "0.05" } }); + fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + const output = screen.getByRole("status", { name: "Saved configuration" }); + expect(output).toHaveTextContent('"classifier_type":"llm_v2"'); + expect(output).toHaveTextContent('"efficient_profile":"Short reasoning budget"'); + expect(output).toHaveTextContent('"capable_profile":"Larger reasoning budget"'); + expect(output).toHaveTextContent('"harness":"Shell and test runner, one attempt"'); + expect(output).toHaveTextContent('"max_quality_gap":0.05'); + expect(output).toHaveTextContent('"adaptive":false'); + expect(output).not.toHaveTextContent('"capability_classifier_config"'); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/ForecastClassifierConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ForecastClassifierConfig.tsx new file mode 100644 index 00000000000..b901a509435 --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/ForecastClassifierConfig.tsx @@ -0,0 +1,433 @@ +import React from "react"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { ChevronRight } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { SearchSelect } from "@/components/shared/SearchSelect"; +import { MultiSelect } from "@/components/shared/MultiSelect"; +import { + type ComplexityRouterConfigValue, + type ClassificationFrequency, + classificationFrequency, + withClassificationFrequency, + DEFAULT_CLASSIFIER_TIMEOUT_MS, +} from "./ComplexityRouterConfig"; +import { + forecastTierNames, + forecastModels, + getForecastConfigError, + newCapabilitySettings, + newFuseSettings, + type CapabilitySettings, + type FuseSettings, +} from "./forecast_classifier_config"; +import ClassifierReasoningEffortSelect from "./ClassifierReasoningEffortSelect"; +import ClassifierCircuitBreakerConfig from "./ClassifierCircuitBreakerConfig"; +import ClassifierVisionConfig from "./ClassifierVisionConfig"; +import TierModelEffortRows from "./TierModelEffortRows"; +import { activeTierRows } from "./tier_rows"; +import { setTierModels } from "./tier_set_actions"; +import { tierRowLabel, setTierModelParam, setTierModelReasoningEffort } from "./complexity_router_tiers"; + +interface Props { + value: ComplexityRouterConfigValue; + onChange: (value: ComplexityRouterConfigValue) => void; + modelOptions: { value: string; label: string }[]; + effortOptionsByModel: Record; +} + +const NumberField = ({ + label, + value, + onChange, + min, + max, + step = "any", + help, +}: { + label: string; + value: number; + onChange: (value: number) => void; + min?: number; + max?: number; + step?: number | "any"; + help?: string; +}) => { + const id = React.useId(); + return ( +
+ + onChange(event.target.value === "" ? Number.NaN : Number(event.target.value))} + /> + {help &&

{help}

} +
+ ); +}; + +export const ForecastSolverModels = ({ + value, + onChange, + modelOptions, + effortOptionsByModel, + fastModeByModel, + additionalPoolsOnly = false, +}: Props & { fastModeByModel: Record; additionalPoolsOnly?: boolean }) => { + const id = React.useId(); + const names = forecastTierNames(value); + const additionalRows = + value.classifier_type === "capability" + ? activeTierRows(value) + .filter((row) => !names.includes(row.id) && row.models.length > 0) + .map((row) => ({ tier: row.id, label: `${tierRowLabel(row, value.tier_labels)} routing pool` })) + : []; + const rows = additionalPoolsOnly + ? additionalRows + : names.map((tier, index) => ({ tier, label: index === 0 ? "Efficient solver" : "Capable solver" })); + if (rows.length === 0) return null; + return ( +
+ {rows.map(({ tier, label }) => { + const models = forecastModels(value.tiers, tier); + const setModels = (next: string[]) => onChange(setTierModels(value, tier, next)); + return ( +
+ + {value.classifier_type === "llm_v2" ? ( + setModels(model ? [model] : [])} + /> + ) : ( + + )} + [model, efforts ?? []]), + )} + paramsByModel={value.tier_model_params?.[tier] ?? {}} + fastModeByModel={fastModeByModel} + onFastModeChange={(model, enabled) => + onChange({ + ...value, + tier_model_params: setTierModelParam(value.tier_model_params, tier, model, [ + "speed", + enabled ? "fast" : undefined, + ]), + }) + } + onEffortChange={(model, effort) => + onChange({ + ...value, + tier_model_params: setTierModelReasoningEffort(value.tier_model_params, tier, model, effort), + }) + } + /> +
+ ); + })} + {!additionalPoolsOnly && ( +

+ Invalid forecasts and classifier failures route to the capable solver +

+ )} +
+ ); +}; + +const CalibrationFields = ({ + label, + value, + onChange, + bounded = false, +}: { + label: string; + bounded?: boolean; + value: { slope: number; intercept: number }; + onChange: (value: { slope: number; intercept: number }) => void; +}) => ( +
+ onChange({ ...value, slope })} + /> + onChange({ ...value, intercept })} + /> +
+); + +const emptyCoefficients = () => ({ slope: Number.NaN, intercept: Number.NaN }); + +const ForecastClassifierConfig = ({ value, onChange, modelOptions, effortOptionsByModel }: Props) => { + const id = React.useId(); + const isCapability = value.classifier_type === "capability"; + const capability = value.capability_classifier_config ?? newCapabilitySettings(); + const fuse = value.llm_v2_config ?? newFuseSettings(); + const config = isCapability ? capability : fuse; + const llm = value.classifier_llm_config ?? { model: "", timeout_ms: DEFAULT_CLASSIFIER_TIMEOUT_MS }; + const updateCapability = (next: CapabilitySettings) => onChange({ ...value, capability_classifier_config: next }); + const updateFuse = (next: FuseSettings) => onChange({ ...value, llm_v2_config: next }); + const updateTransport = (patch: { max_output_tokens?: number; response_format?: "json_schema" | "json_object" }) => + isCapability ? updateCapability({ ...capability, ...patch }) : updateFuse({ ...fuse, ...patch }); + const setCalibrationVersion = (version: string) => { + if (isCapability && capability.calibration) + updateCapability({ ...capability, calibration: { ...capability.calibration, version } }); + if (!isCapability && fuse.calibration) updateFuse({ ...fuse, calibration: { ...fuse.calibration, version } }); + }; + const error = getForecastConfigError(value); + return ( +
+

+ {isCapability + ? "Forecasts whether the efficient solver can complete the task using the bundled capability card" + : "Forecasts success for both solvers and selects efficient when the estimated quality gap is within your allowance"} +

+
+ + { + if (model === llm.model) return; + onChange({ ...value, classifier_llm_config: { ...llm, model: model ?? "", reasoning_effort: undefined } }); + }} + /> +
+ {isCapability ? ( + <> + updateCapability({ ...capability, base_threshold })} + /> + + ) : ( + <> + {(["efficient_profile", "capable_profile", "harness"] as const).map((field) => { + const label = { + efficient_profile: "Efficient solver profile", + capable_profile: "Capable solver profile", + harness: "Harness and budget", + }[field]; + return ( +
+ +