mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-12 03:48:56 +00:00
Merge remote-tracking branch 'origin/main' into litellm_playground_lists_decision_models_by_supported_endpoints
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> # Conflicts: # ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneUI.integration.test.tsx
This commit is contained in:
commit
423ae5342e
85 changed files with 2497 additions and 1401 deletions
|
|
@ -9,6 +9,7 @@ pub enum Mode {
|
|||
AudioTranscription,
|
||||
Chat,
|
||||
Completion,
|
||||
Decisions,
|
||||
Embedding,
|
||||
Evaluation,
|
||||
Guardrail,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use litellm_model_catalog::{AliasIssue, Catalog, Error, IntegrityLimits, Provenance};
|
||||
use litellm_model_catalog::{AliasIssue, Catalog, Error, IntegrityLimits, Mode, Provenance};
|
||||
use rstest::{fixture, rstest};
|
||||
use serde_json::json;
|
||||
|
||||
|
|
@ -42,6 +42,20 @@ fn fixture_catalog() -> Catalog {
|
|||
.unwrap()
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::decisions("decisions", Mode::Decisions)]
|
||||
#[case::legacy_evaluation("evaluation", Mode::Evaluation)]
|
||||
fn parses_decision_modes(#[case] mode: &str, #[case] expected: Mode) {
|
||||
let source = json!({"Test":{"litellm_provider":"test","mode":mode}}).to_string();
|
||||
let catalog = Catalog::parse(source.as_bytes(), Provenance::default()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
catalog.lookup("Test").unwrap().entry.info().mode,
|
||||
Some(expected)
|
||||
);
|
||||
assert_eq!(serde_json::to_value(expected).unwrap(), json!(mode));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[ignore]
|
||||
fn preserves_fields_and_metadata(fixture_catalog: Catalog) {
|
||||
|
|
|
|||
|
|
@ -440,6 +440,10 @@ autorouter_presets_url: str = os.getenv(
|
|||
"LITELLM_AUTOROUTER_PRESETS_URL",
|
||||
"https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/proxy/public_endpoints/autorouter_presets.json",
|
||||
)
|
||||
whats_new_url: str = os.getenv(
|
||||
"LITELLM_WHATS_NEW_URL",
|
||||
"https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/proxy/public_endpoints/whats_new.json",
|
||||
)
|
||||
suppress_debug_info: bool = False
|
||||
dynamodb_table_name: Optional[str] = None
|
||||
s3_callback_params: Optional[Dict] = None
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ class HealthCheckHelpers:
|
|||
"responses",
|
||||
"anthropic_messages",
|
||||
"ocr",
|
||||
"evaluation",
|
||||
"decisions",
|
||||
],
|
||||
Callable,
|
||||
]:
|
||||
|
|
@ -311,7 +311,7 @@ class HealthCheckHelpers:
|
|||
**_filter_model_params(model_params=model_params),
|
||||
document=_ocr_health_check_document(model=model, custom_llm_provider=custom_llm_provider),
|
||||
),
|
||||
"evaluation": lambda: litellm.asystemone(
|
||||
"decisions": lambda: litellm.asystemone(
|
||||
**DECISIONS_CALL_PARAMS.validate_python(
|
||||
{
|
||||
"state": prompt or "health check",
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ from litellm.types.utils import (
|
|||
ModelResponseStream,
|
||||
RawRequestTypedDict,
|
||||
StreamingChoices,
|
||||
is_decisions_model_mode,
|
||||
)
|
||||
from litellm.types.workload_identity import ANTHROPIC_WIF_KWARGS_KEYS, OPENAI_WIF_KWARGS_KEYS
|
||||
from litellm.utils import (
|
||||
|
|
@ -8821,8 +8822,9 @@ async def ahealth_check(
|
|||
input=input,
|
||||
)
|
||||
|
||||
if mode in mode_handlers:
|
||||
_response: Final = await mode_handlers[mode]()
|
||||
handler_mode: Final = "decisions" if is_decisions_model_mode(mode) else mode
|
||||
if handler_mode in mode_handlers:
|
||||
_response: Final = await mode_handlers[handler_mode]()
|
||||
_response_headers: Final = cast( # cast-ok: provider headers are stored as a string-keyed mapping
|
||||
Mapping[str, object], (get_hidden_params(_response) or {}).get("headers", {}) or {}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1546,6 +1546,31 @@ class MCPRequestHandler:
|
|||
authorization: Final = next((value for key, value in headers.items() if key.lower() == "authorization"), "")
|
||||
return SecretStr(authorization[len("bearer ") :]) if authorization.lower().startswith("bearer ") else None
|
||||
|
||||
@staticmethod
|
||||
def get_guardrail_bearer_token(headers: Headers, auth: UserAPIKeyAuth) -> SecretStr | None:
|
||||
from litellm.proxy._experimental.mcp_server.utils import (
|
||||
_custom_litellm_key_header_name, # pyright: ignore[reportPrivateUsage] # reuse the shared credential-header setting
|
||||
)
|
||||
|
||||
bearer: Final = MCPRequestHandler.get_incoming_bearer_token(headers)
|
||||
if bearer is None:
|
||||
return None
|
||||
value: Final = bearer.get_secret_value()
|
||||
if MCPRequestHandler._is_gateway_admission_credential(value):
|
||||
return None
|
||||
if MCPRequestHandler._is_caller_admission_key(
|
||||
value, auth.api_key
|
||||
) or MCPRequestHandler._is_caller_admission_key(hash_token(value), auth.api_key):
|
||||
return None
|
||||
admitted_credential: Final = MCPRequestHandler.caller_admission_credential(
|
||||
headers,
|
||||
auth,
|
||||
custom_key_header_name=_custom_litellm_key_header_name(),
|
||||
)
|
||||
if not auth.jwt_claims and MCPRequestHandler._is_caller_admission_key(value, admitted_credential):
|
||||
return None
|
||||
return bearer
|
||||
|
||||
@staticmethod
|
||||
def get_oauth2_headers_from_headers(headers: Headers) -> dict[str, str]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -412,7 +412,7 @@ if MCP_AVAILABLE:
|
|||
raw_headers=virtual_raw_headers,
|
||||
litellm_logging_obj=virtual_logging_obj,
|
||||
guardrail_context=MCPRequestContext.resolve_guardrail_context(virtual_data),
|
||||
incoming_bearer_token=MCPRequestHandler.get_incoming_bearer_token(request.headers),
|
||||
incoming_bearer_token=MCPRequestHandler.get_guardrail_bearer_token(request.headers, user_api_key_dict),
|
||||
)
|
||||
except Exception as e:
|
||||
virtual_request_data: Final = virtual_processor.data
|
||||
|
|
@ -1346,7 +1346,9 @@ if MCP_AVAILABLE:
|
|||
litellm_logging_obj=data.get("litellm_logging_obj"),
|
||||
guardrail_context=MCPRequestContext.resolve_guardrail_context(data),
|
||||
requested_server_id=canonical_server_id,
|
||||
incoming_bearer_token=MCPRequestHandler.get_incoming_bearer_token(request.headers),
|
||||
incoming_bearer_token=MCPRequestHandler.get_guardrail_bearer_token(
|
||||
request.headers, user_api_key_dict
|
||||
),
|
||||
)
|
||||
result: Final = complete_call_tool_result(executed, WireCompat.LEGACY)
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -855,7 +855,7 @@ if MCP_AVAILABLE:
|
|||
wire_compat_for(ctx.protocol_version),
|
||||
ctx.protocol_version,
|
||||
incoming_bearer_token=(
|
||||
MCPRequestHandler.get_incoming_bearer_token(request.headers)
|
||||
MCPRequestHandler.get_guardrail_bearer_token(request.headers, auth)
|
||||
if isinstance(request, StarletteRequest)
|
||||
else None
|
||||
),
|
||||
|
|
|
|||
|
|
@ -2091,6 +2091,7 @@ async def test_model_connection(
|
|||
"anthropic_messages",
|
||||
"ocr",
|
||||
"evaluation",
|
||||
"decisions",
|
||||
]
|
||||
| None = fastapi.Body(
|
||||
None,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ class _MemberOpenSourceClassifierConfig(LiteLLMBaseModel):
|
|||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
deployment_name: str | None = None
|
||||
provider: Literal["jev", "laya", "bespoke", "databricks"] = "jev"
|
||||
model: str
|
||||
api_key: None = None
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|||
from importlib.resources import files
|
||||
from typing import TYPE_CHECKING, Final, Protocol
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import TypeAdapter
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
import litellm
|
||||
|
|
@ -37,6 +38,7 @@ from litellm.types.proxy.public_endpoints.public_endpoints import (
|
|||
ProviderCreateInfo,
|
||||
PublicModelHubInfo,
|
||||
SupportedEndpointsResponse,
|
||||
WhatsNewResponse,
|
||||
)
|
||||
from litellm.types.utils import LlmProviders
|
||||
|
||||
|
|
@ -505,14 +507,18 @@ def _load_bundled_autorouter_presets() -> Mapping[str, AutoRouterPresetRecord]:
|
|||
)
|
||||
|
||||
|
||||
async def _fetch_remote_autorouter_presets(url: str) -> Mapping[str, AutoRouterPresetRecord]:
|
||||
async def _fetch_remote_bytes(url: str) -> bytes:
|
||||
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
|
||||
from litellm.types.llms.custom_http import httpxSpecialProvider
|
||||
|
||||
client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.UI)
|
||||
response: Final = await client.get(url, timeout=5.0)
|
||||
response.raise_for_status()
|
||||
presets: Final = _AUTOROUTER_PRESETS_ADAPTER.validate_python(response.json())
|
||||
return response.content
|
||||
|
||||
|
||||
async def _fetch_remote_autorouter_presets(url: str) -> Mapping[str, AutoRouterPresetRecord]:
|
||||
presets: Final = _AUTOROUTER_PRESETS_ADAPTER.validate_json(await _fetch_remote_bytes(url))
|
||||
if not presets:
|
||||
raise ValueError("remote auto-router preset catalog is empty")
|
||||
return presets
|
||||
|
|
@ -575,6 +581,70 @@ async def get_public_autorouter_presets() -> Mapping[str, AutoRouterPresetRecord
|
|||
return await get_autorouter_presets(url=litellm.autorouter_presets_url)
|
||||
|
||||
|
||||
def _load_bundled_whats_new() -> WhatsNewResponse:
|
||||
return WhatsNewResponse.model_validate_json(
|
||||
files("litellm.proxy.public_endpoints").joinpath("whats_new.json").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_remote_whats_new(url: str) -> WhatsNewResponse:
|
||||
return WhatsNewResponse.model_validate_json(await _fetch_remote_bytes(url))
|
||||
|
||||
|
||||
async def _resolve_whats_new(url: str, fetch: Callable[[str], Awaitable[WhatsNewResponse]]) -> WhatsNewResponse:
|
||||
if os.getenv("LITELLM_LOCAL_WHATS_NEW", "").lower() == "true":
|
||||
return _load_bundled_whats_new()
|
||||
try:
|
||||
return await fetch(url)
|
||||
except (httpx.HTTPError, ValidationError, OSError) as e:
|
||||
verbose_logger.warning(
|
||||
"LiteLLM: failed to fetch What's new launches from %s: %s. Serving the bundled list for the life of this process.",
|
||||
url,
|
||||
str(e),
|
||||
)
|
||||
return _load_bundled_whats_new()
|
||||
|
||||
|
||||
class _WhatsNewCache:
|
||||
launches: WhatsNewResponse | None = None
|
||||
lock: asyncio.Lock | None = None
|
||||
|
||||
|
||||
async def get_whats_new(
|
||||
url: str,
|
||||
fetch: Callable[[str], Awaitable[WhatsNewResponse]] = _fetch_remote_whats_new,
|
||||
) -> WhatsNewResponse:
|
||||
cached: Final = _WhatsNewCache.launches
|
||||
if cached is not None:
|
||||
return cached
|
||||
if _WhatsNewCache.lock is None:
|
||||
_WhatsNewCache.lock = asyncio.Lock()
|
||||
async with _WhatsNewCache.lock:
|
||||
held: Final = _WhatsNewCache.launches
|
||||
if held is not None:
|
||||
return held
|
||||
resolved: Final = await _resolve_whats_new(url=url, fetch=fetch)
|
||||
_WhatsNewCache.launches = resolved
|
||||
return resolved
|
||||
|
||||
|
||||
@router.get(
|
||||
"/public/whats_new",
|
||||
tags=["public"],
|
||||
response_model=WhatsNewResponse,
|
||||
)
|
||||
async def get_public_whats_new() -> WhatsNewResponse:
|
||||
"""
|
||||
Return the launches the dashboard Home page shows under What's new.
|
||||
|
||||
Resolved once per process: fetched from ``litellm.whats_new_url`` (override with ``LITELLM_WHATS_NEW_URL``)
|
||||
on the first request, falling back to the list bundled with the package on any failure, so an airgapped
|
||||
proxy serves the bundled list without retrying. Set ``LITELLM_LOCAL_WHATS_NEW=True`` to skip the fetch.
|
||||
A restart picks up a newly published list.
|
||||
"""
|
||||
return await get_whats_new(url=litellm.whats_new_url)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/public/endpoints",
|
||||
tags=["public"],
|
||||
|
|
|
|||
25
litellm/proxy/public_endpoints/whats_new.json
Normal file
25
litellm/proxy/public_endpoints/whats_new.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"launches": [
|
||||
{
|
||||
"icon": "box",
|
||||
"title": "Claude Haiku 5.5",
|
||||
"description": "Anthropic's newest small model, with day 0 pricing on Anthropic, Bedrock and Vertex AI.",
|
||||
"href": "https://docs.litellm.ai/blog/claude-haiku-5-5",
|
||||
"published_on": "2026-10-07"
|
||||
},
|
||||
{
|
||||
"icon": "scale",
|
||||
"title": "Decision Models",
|
||||
"description": "Call decision models at /v1/decisions and try them in the Decisions playground.",
|
||||
"href": "https://docs.litellm.ai/docs/decisions",
|
||||
"published_on": "2026-10-07"
|
||||
},
|
||||
{
|
||||
"icon": "zap",
|
||||
"title": "OpenAI Ultrafast",
|
||||
"description": "service_tier: ultrafast on GPT-6 Astra and GPT-6.1 Sol, including /ultrafast in Codex.",
|
||||
"href": "https://docs.litellm.ai/docs/providers/openai/ultrafast",
|
||||
"published_on": "2026-10-06"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -123,6 +123,7 @@ from .jev_classifier import (
|
|||
HttpJevClassifierClient,
|
||||
JevClassifierClient,
|
||||
JevVerdict,
|
||||
RouterJevClassifierClient,
|
||||
build_jev_request,
|
||||
jev_classifier_cost,
|
||||
)
|
||||
|
|
@ -1331,8 +1332,9 @@ class ComplexityRouter(CustomLogger):
|
|||
- Question complexity (multiple questions)
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _build_jev_client(config: OpenSourceClassifierConfig) -> JevClassifierClient:
|
||||
def _build_jev_client(self, config: OpenSourceClassifierConfig) -> JevClassifierClient:
|
||||
if config.deployment_name is not None:
|
||||
return RouterJevClassifierClient(self.litellm_router_instance, config.deployment_name)
|
||||
if config.provider in ("laya", "bespoke"):
|
||||
from litellm.llms.oss_decision import oss_connection
|
||||
|
||||
|
|
@ -2318,13 +2320,23 @@ class ComplexityRouter(CustomLogger):
|
|||
if not self._tier_pools().get(tier_name):
|
||||
raise ValueError(f"Jev classifier returned tier {tier_name!r}, which has no models configured")
|
||||
model: Final = response.model or config.model
|
||||
accounting_provider: Final = "typesafe" if config.provider == "jev" else config.provider
|
||||
accounting_provider: Final = (
|
||||
response.classifier_provider
|
||||
if config.deployment_name and response.classifier_provider
|
||||
else "typesafe"
|
||||
if config.provider == "jev"
|
||||
else config.provider
|
||||
)
|
||||
verdict: Final = JevVerdict(
|
||||
label=answer.choice,
|
||||
probabilities=answer.probabilities,
|
||||
confidence=answer.confidence,
|
||||
model=model,
|
||||
cost=jev_classifier_cost(response, config.model, accounting_provider),
|
||||
cost=response.classifier_cost
|
||||
if config.deployment_name
|
||||
else jev_classifier_cost(
|
||||
response, config.model, "typesafe" if config.provider == "jev" else config.provider
|
||||
),
|
||||
provider=accounting_provider,
|
||||
)
|
||||
if breaker is not None and permit is not None:
|
||||
|
|
|
|||
|
|
@ -716,6 +716,7 @@ _ENVIRONMENT_KEY_SCOPE: Final = MappingProxyType(
|
|||
class OpenSourceClassifierConfig(LiteLLMBaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
deployment_name: str | None = Field(default=None, min_length=1)
|
||||
provider: Literal["jev", "laya", "bespoke", "databricks"] = "jev"
|
||||
model: str = "jev-latest"
|
||||
api_key: str | None = Field(default=None, description="Provider API key; optional for self-hosted providers")
|
||||
|
|
@ -754,6 +755,12 @@ class OpenSourceClassifierConfig(LiteLLMBaseModel):
|
|||
|
||||
@model_validator(mode="after")
|
||||
def _keep_the_environment_key_on_the_environment_base(self) -> "OpenSourceClassifierConfig":
|
||||
if self.deployment_name is not None:
|
||||
if not self.deployment_name.strip():
|
||||
raise ValueError("deployment_name must name a configured decision model")
|
||||
if self.api_key is not None or self.api_base is not None:
|
||||
raise ValueError("deployment_name uses the deployment connection; omit api_key and api_base")
|
||||
return self
|
||||
if self.provider in ("laya", "bespoke"):
|
||||
from litellm.llms.oss_decision import validate_oss_api_base, validate_oss_model
|
||||
|
||||
|
|
@ -833,7 +840,10 @@ def _resolve_normalized_complexity_router_config_write(
|
|||
{
|
||||
key: value
|
||||
for key, value in existing.items()
|
||||
if same_provider and key in ("api_key", "api_base") and (key != "api_key" or same_base)
|
||||
if same_provider
|
||||
and not classifier.get("deployment_name")
|
||||
and key in ("api_key", "api_base")
|
||||
and (key != "api_key" or same_base)
|
||||
}
|
||||
)
|
||||
return ComplexityRouterConfigWrite(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from collections.abc import Mapping
|
||||
from datetime import datetime, timezone
|
||||
from types import MappingProxyType
|
||||
from typing import Annotated, Final, Literal, NamedTuple, Protocol, TypeAlias
|
||||
from typing import TYPE_CHECKING, Annotated, Final, Literal, NamedTuple, Protocol, TypeAlias
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
|
|
@ -10,6 +10,7 @@ from pydantic import ConfigDict, Field, TypeAdapter, ValidationError
|
|||
import litellm
|
||||
from litellm._logging import verbose_router_logger
|
||||
from litellm.constants import INTERNAL_CALL_ORIGIN_METADATA_KEY
|
||||
from litellm.litellm_core_utils.classifier_logging import masked_originating_request
|
||||
from litellm.litellm_core_utils.internal_call_metadata import (
|
||||
effective_turn_off_message_logging,
|
||||
forwarded_internal_call_metadata,
|
||||
|
|
@ -22,9 +23,13 @@ from litellm.llms.databricks.decisions.transformation import DATABRICKS_DECISION
|
|||
from litellm.llms.laya.common_utils import laya_response_model
|
||||
from litellm.llms.pass_through.typesafe_logging_handler import TypeSafePassthroughLoggingHandler
|
||||
from litellm.router_strategy.complexity_router.config import DEFAULT_JEV_INSTRUCTIONS as _DEFAULT_JEV_INSTRUCTIONS
|
||||
from litellm.types.decisions import DecisionsResponse
|
||||
from litellm.types.llms.base import LiteLLMBaseModel
|
||||
from litellm.types.utils import AUTOROUTER_CLASSIFIER_CALL_ORIGIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.router import Router
|
||||
|
||||
JevProbability: TypeAlias = Annotated[float, Field(ge=0.0, le=1.0)]
|
||||
ClassifierProvider: TypeAlias = Literal["typesafe", "laya", "bespoke", "databricks"]
|
||||
DEFAULT_JEV_INSTRUCTIONS: Final = _DEFAULT_JEV_INSTRUCTIONS
|
||||
|
|
@ -67,6 +72,8 @@ class JevSystemOneResponse(LiteLLMBaseModel):
|
|||
|
||||
model: str | None = None
|
||||
answers: Mapping[str, JevChoiceAnswer]
|
||||
classifier_cost: float | None = None
|
||||
classifier_provider: str | None = None
|
||||
usage: JevUsage | None = None
|
||||
|
||||
|
||||
|
|
@ -79,6 +86,76 @@ class JevClassifierClient(Protocol):
|
|||
) -> JevSystemOneResponse: ...
|
||||
|
||||
|
||||
class _DecisionsRouter(Protocol):
|
||||
async def adecisions(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
state: str,
|
||||
questions: Mapping[str, object],
|
||||
timeout: float,
|
||||
num_retries: int,
|
||||
disable_fallbacks: bool,
|
||||
metadata: Mapping[str, object],
|
||||
proxy_server_request: Mapping[str, object],
|
||||
turn_off_message_logging: bool | None,
|
||||
litellm_session_id: str = "",
|
||||
litellm_trace_id: str = "",
|
||||
) -> object: ...
|
||||
|
||||
|
||||
_PARENT_METADATA: Final = TypeAdapter[Mapping[str, object] | None](Mapping[str, object] | None)
|
||||
|
||||
|
||||
class RouterJevClassifierClient:
|
||||
def __init__(self, router: "Router", deployment_name: str) -> None:
|
||||
self._router: _DecisionsRouter = router
|
||||
self._deployment_name = deployment_name
|
||||
|
||||
async def evaluate(
|
||||
self,
|
||||
request: JevSystemOneRequest,
|
||||
timeout_s: float,
|
||||
request_kwargs: Mapping[str, object] | None = None,
|
||||
) -> JevSystemOneResponse:
|
||||
parent: Final = request_kwargs or MappingProxyType({})
|
||||
metadata: Final = {
|
||||
**forwarded_internal_call_metadata(
|
||||
_PARENT_METADATA.validate_python(parent.get("litellm_metadata") or parent.get("metadata")),
|
||||
AUTOROUTER_CLASSIFIER_CALL_ORIGIN,
|
||||
),
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY: AUTOROUTER_CLASSIFIER_CALL_ORIGIN,
|
||||
}
|
||||
payload: Final = {
|
||||
"state": request.state,
|
||||
"questions": {key: value.model_dump() for key, value in request.questions.items()},
|
||||
}
|
||||
response: Final = DecisionsResponse.model_validate(
|
||||
await self._router.adecisions(
|
||||
model=self._deployment_name,
|
||||
state=request.state,
|
||||
questions={key: value.model_dump() for key, value in request.questions.items()},
|
||||
timeout=timeout_s,
|
||||
num_retries=0,
|
||||
disable_fallbacks=True,
|
||||
metadata=metadata,
|
||||
proxy_server_request={
|
||||
"originating_request_masked": masked_originating_request(request_kwargs),
|
||||
"body": {"model": self._deployment_name, **payload},
|
||||
},
|
||||
turn_off_message_logging=effective_turn_off_message_logging(request_kwargs),
|
||||
**parent_session_kwargs(request_kwargs),
|
||||
)
|
||||
)
|
||||
return JevSystemOneResponse.model_validate(
|
||||
{
|
||||
**response.model_dump(),
|
||||
"classifier_cost": response.hidden_params.get("response_cost"),
|
||||
"classifier_provider": response.hidden_params.get("custom_llm_provider"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class HttpJevClassifierClient:
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -212,7 +289,7 @@ class JevVerdict(NamedTuple):
|
|||
confidence: float
|
||||
model: str
|
||||
cost: float | None
|
||||
provider: ClassifierProvider = "typesafe"
|
||||
provider: str = "typesafe"
|
||||
|
||||
|
||||
class _RegistryPricing(LiteLLMBaseModel):
|
||||
|
|
|
|||
|
|
@ -169,9 +169,13 @@ def strategy_router_dependencies(
|
|||
else ()
|
||||
)
|
||||
+ (
|
||||
_named(
|
||||
f"{accounting_provider}/{decision_classifier.get('model', 'jev-latest')}",
|
||||
"evaluation",
|
||||
(
|
||||
_named(decision_classifier.get("deployment_name"), "classifier")
|
||||
if decision_classifier.get("deployment_name")
|
||||
else _named(
|
||||
f"{accounting_provider}/{decision_classifier.get('model', 'jev-latest')}",
|
||||
"evaluation",
|
||||
)
|
||||
)
|
||||
if complexity.get("classifier_type") == "oss_classifier"
|
||||
else ()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from collections.abc import Mapping, Sequence
|
||||
from datetime import date
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import ConfigDict
|
||||
|
|
@ -113,6 +114,24 @@ class AutoRouterPresetRecord(LiteLLMBaseModel):
|
|||
complexity_router_config: AutoRouterPresetConfig
|
||||
|
||||
|
||||
class WhatsNewLaunch(LiteLLMBaseModel):
|
||||
"""One launch card in the dashboard Home page's What's new section."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
icon: str
|
||||
title: str
|
||||
description: str
|
||||
href: str
|
||||
published_on: date
|
||||
|
||||
|
||||
class WhatsNewResponse(LiteLLMBaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
launches: tuple[WhatsNewLaunch, ...]
|
||||
|
||||
|
||||
class ComplexityScorerDefaults(LiteLLMBaseModel):
|
||||
"""The complexity router's shipped heuristic scorer defaults.
|
||||
|
||||
|
|
|
|||
|
|
@ -160,6 +160,10 @@ class LiteLLMCommonStrings(Enum):
|
|||
llm_provider_not_provided = "Unmapped LLM provider for this endpoint. You passed model={model}, custom_llm_provider={custom_llm_provider}. Check supported provider and route: https://docs.litellm.ai/docs/providers"
|
||||
|
||||
|
||||
def is_decisions_model_mode(mode: str | None) -> bool:
|
||||
return mode in ("decisions", "evaluation")
|
||||
|
||||
|
||||
SupportedCacheControls: Final = ["ttl", "s-maxage", "no-cache", "no-store"]
|
||||
|
||||
|
||||
|
|
@ -430,6 +434,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False):
|
|||
"audio_transcription",
|
||||
"audio_speech",
|
||||
"responses",
|
||||
"decisions",
|
||||
"evaluation",
|
||||
"ocr",
|
||||
"realtime",
|
||||
|
|
|
|||
|
|
@ -321,6 +321,7 @@ include = [
|
|||
"litellm/router_strategy/complexity_router/artifacts/*.json",
|
||||
"litellm/router_strategy/complexity_router/fuse_presets.json",
|
||||
"litellm/proxy/model_insights_tasks.json",
|
||||
"litellm/proxy/public_endpoints/whats_new.json",
|
||||
"litellm/proxy/common_utils/codex_base_instructions.md",
|
||||
"litellm/proxy/common_utils/codex_bundled_models_0.159.3.json",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import uuid
|
|||
from typing import Final
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from integration._support.client import Gateway, object_value, string_value
|
||||
from integration._support.upstream import ScenarioHandle, delete_scenario, register_scenario
|
||||
from integration.cost_calculation.cost_tracking_case import JsonResponse
|
||||
|
|
@ -93,7 +94,7 @@ def _probes_sent_to(gateway: Gateway, handle: ScenarioHandle) -> list[tuple[str,
|
|||
]
|
||||
|
||||
|
||||
def test_evaluation_mode_health_check_resolves_the_mode_from_the_cost_map_and_sends_the_default_probe(
|
||||
def test_decisions_mode_health_check_resolves_the_mode_from_the_cost_map_and_sends_the_default_probe(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
|
|
@ -114,7 +115,8 @@ def test_evaluation_mode_health_check_resolves_the_mode_from_the_cost_map_and_se
|
|||
]
|
||||
|
||||
|
||||
def test_evaluation_mode_health_check_sends_the_configured_state_and_questions(gateway: Gateway) -> None:
|
||||
@pytest.mark.parametrize("mode", ("decisions", "evaluation"), ids=("decisions", "legacy-evaluation"))
|
||||
def test_decisions_mode_health_check_sends_the_configured_state_and_questions(gateway: Gateway, mode: str) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = register_scenario(f"health-decisions-{uuid.uuid4().hex[:12]}", _CONFIGURED_PROBE_REPLY)
|
||||
scenario.cleanups.callback(delete_scenario, handle)
|
||||
|
|
@ -122,7 +124,7 @@ def test_evaluation_mode_health_check_sends_the_configured_state_and_questions(g
|
|||
model="typesafe/jev-custom",
|
||||
api_base=handle.api_base(),
|
||||
model_info={
|
||||
"mode": "evaluation",
|
||||
"mode": mode,
|
||||
"health_check_params": {"state": _CONFIGURED_STATE, "questions": _CONFIGURED_QUESTIONS},
|
||||
},
|
||||
)
|
||||
|
|
@ -136,7 +138,7 @@ def test_evaluation_mode_health_check_sends_the_configured_state_and_questions(g
|
|||
]
|
||||
|
||||
|
||||
def test_evaluation_mode_health_check_of_the_self_hosted_strands_model_resolves_the_mode_from_the_cost_map(
|
||||
def test_decisions_mode_health_check_of_the_self_hosted_strands_model_resolves_the_mode_from_the_cost_map(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ def test_an_openai_format_request_at_v1_decisions_reaches_the_serving_endpoint_a
|
|||
row["model_group"],
|
||||
row["api_base"],
|
||||
_number(row["spend"]),
|
||||
) == ("success", "asystemone", "databricks", deployment, f"{handle.api_base()}/{_ENDPOINT}/invocations", 0.0), (
|
||||
) == ("success", "adecisions", "databricks", deployment, f"{handle.api_base()}/{_ENDPOINT}/invocations", 0.0), (
|
||||
row
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -114,6 +114,12 @@ _OPENAI: Final = _Route(
|
|||
"/v1/decisions", {"input": _INPUT, "questions": _OPENAI_QUESTIONS}, _OPENAI_UPSTREAM_BODY, _OPENAI_ANSWER
|
||||
)
|
||||
_ROUTES: Final = (_SYSTEMONE, _OPENAI, replace(_SYSTEMONE, path="/systemone"), replace(_OPENAI, path="/decisions"))
|
||||
_ROUTE_CALL_TYPES: Final[Mapping[str, str]] = {
|
||||
"/v1/systemone": "asystemone",
|
||||
"/systemone": "asystemone",
|
||||
"/v1/decisions": "adecisions",
|
||||
"/decisions": "adecisions",
|
||||
}
|
||||
_SPEND_QUERY: Final = (
|
||||
"SELECT status, call_type, custom_llm_provider, model_group, api_base, prompt_tokens, completion_tokens "
|
||||
'FROM "LiteLLM_SpendLogs" WHERE request_id = %s'
|
||||
|
|
@ -411,11 +417,11 @@ def test_an_arn_deployment_answers_on_every_decisions_route_signed_for_bedrock_a
|
|||
_assert_runtime_call(call, "audit_signed", route=route)
|
||||
assert call.headers[_SESSION_HEADER] == _default_session("audit_signed"), dict(call.headers)
|
||||
_assert_signed(call)
|
||||
for response in responses:
|
||||
for response, route in zip(responses, _ROUTES, strict=True):
|
||||
row = _spend_row(response.headers["x-litellm-call-id"])
|
||||
assert row == {
|
||||
"status": "success",
|
||||
"call_type": "asystemone",
|
||||
"call_type": _ROUTE_CALL_TYPES[route.path],
|
||||
"custom_llm_provider": "strands_decider",
|
||||
"model_group": _SIGNED,
|
||||
"api_base": _invocations_url("audit_signed"),
|
||||
|
|
|
|||
|
|
@ -589,7 +589,7 @@ def test_ocr_health_check_document_raises_without_the_extension():
|
|||
("cloudflare/clef", "https://api.cloudflare.com/client/v4/accounts/acct-1/ai/run/@cf/cloudflare/clef"),
|
||||
),
|
||||
)
|
||||
async def test_ahealth_check_probes_evaluation_models_through_the_decisions_api(
|
||||
async def test_ahealth_check_probes_decisions_models_through_the_decisions_api(
|
||||
model: str,
|
||||
upstream_url: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
|
@ -617,7 +617,9 @@ async def test_ahealth_check_probes_evaluation_models_through_the_decisions_api(
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ahealth_check_evaluation_uses_configured_probe_state_and_questions(
|
||||
@pytest.mark.parametrize("mode", ("decisions", "evaluation"), ids=("decisions", "legacy-evaluation"))
|
||||
async def test_ahealth_check_decisions_uses_configured_probe_state_and_questions(
|
||||
mode: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
) -> None:
|
||||
|
|
@ -638,7 +640,7 @@ async def test_ahealth_check_evaluation_uses_configured_probe_state_and_question
|
|||
"state": "custom probe",
|
||||
"questions": {"ok": {"type": "noul", "instructions": "Is it ok?"}},
|
||||
},
|
||||
mode=None,
|
||||
mode=mode,
|
||||
)
|
||||
|
||||
assert "error" not in result, result
|
||||
|
|
|
|||
|
|
@ -6164,7 +6164,8 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
policy gates re-check. ``team_blocked=True`` patches the centralized gate's
|
||||
``get_team_object`` at the ``user_api_key_auth`` namespace it actually calls; ``owner``
|
||||
patches the SCIM gate's ``get_user_object`` (``auth_checks`` namespace); ``project_object``
|
||||
patches the centralized gate's ``get_project_object``. Unpatched lookups hit the MagicMock
|
||||
patches the centralized gate's ``get_project_object``. The team-membership lookup is
|
||||
patched to "no membership row". Unpatched lookups hit the MagicMock
|
||||
prisma and are swallowed (``_safe_fetch`` / the SCIM gate's fail-open), so their checks
|
||||
skip. Yields the ``get_key_object`` mock so callers can assert the sealed ``key_hash`` was
|
||||
the reload key."""
|
||||
|
|
@ -6179,6 +6180,10 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
),
|
||||
patch("litellm.proxy.proxy_server.prisma_client", MagicMock()),
|
||||
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()),
|
||||
patch(
|
||||
"litellm.proxy.auth.user_api_key_auth.get_team_membership",
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
]
|
||||
if team_blocked:
|
||||
patchers.append(
|
||||
|
|
@ -6206,7 +6211,8 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
def _patch_user_reload(*, return_value=None, side_effect=None):
|
||||
"""Patch the user-subject reload path an interactively-minted envelope takes: the
|
||||
``get_user_object`` lookup ``reload_admitted_user`` runs (which also drives the SCIM gate),
|
||||
plus the ``prisma_client`` / ``user_api_key_cache`` globals. The centralized gate's own
|
||||
plus the ``prisma_client`` / ``user_api_key_cache`` globals. The team-membership lookup is
|
||||
patched to "no membership row". The centralized gate's own
|
||||
fetches fail-safe to None under the MagicMock prisma, so an unblocked user admits. Yields the
|
||||
``get_user_object`` mock so a caller can assert the sealed user_id was the reload key."""
|
||||
get_user_object = AsyncMock(return_value=return_value, side_effect=side_effect)
|
||||
|
|
@ -6214,6 +6220,10 @@ class TestMCPDcrBridgeDelegateAdmission:
|
|||
patch("litellm.proxy.auth.auth_checks.get_user_object", get_user_object),
|
||||
patch("litellm.proxy.proxy_server.prisma_client", MagicMock()),
|
||||
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()),
|
||||
patch(
|
||||
"litellm.proxy.auth.user_api_key_auth.get_team_membership",
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
):
|
||||
yield get_user_object
|
||||
|
||||
|
|
@ -10814,3 +10824,39 @@ async def test_admission_request_body_serves_stashed_peek_callable():
|
|||
{"type": "http", "method": "POST", "path": "/mcp", "headers": []}
|
||||
)
|
||||
assert await without_peek.body() == b"{}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bearer,api_key,claims,explicit_key,expected", [
|
||||
(None, "sk-key", None, None, None),
|
||||
("sk-key", "sk-key", None, None, None),
|
||||
("sk-key", "sk-key", {"sub": "previous-jwt"}, "sk-key", None),
|
||||
("caller.jwt.assertion", "sk-key", {"sub": "caller"}, None, "caller.jwt.assertion"),
|
||||
("caller.jwt.assertion", None, {"sub": "caller"}, None, "caller.jwt.assertion"),
|
||||
("upstream", "sk-key", None, "sk-key", "upstream"),
|
||||
("llm_session_test", None, {"sub": "caller"}, None, None),
|
||||
("llm_srefresh_test", None, {"sub": "caller"}, None, None),
|
||||
("opaque-master", "litellm_proxy_master_key", None, None, None),
|
||||
])
|
||||
def test_guardrail_bearer_preserves_identity_without_exposing_gateway_credentials(
|
||||
bearer: str | None, api_key: str | None, claims: dict[str, str] | None,
|
||||
explicit_key: str | None, expected: str | None,
|
||||
) -> None:
|
||||
headers: Final = Headers({
|
||||
**({"Authorization": f"Bearer {bearer}"} if bearer is not None else {}),
|
||||
**({"x-litellm-api-key": explicit_key} if explicit_key else {}),
|
||||
})
|
||||
token: Final = MCPRequestHandler.get_guardrail_bearer_token(
|
||||
headers, UserAPIKeyAuth(api_key=api_key, jwt_claims=claims),
|
||||
)
|
||||
assert (token.get_secret_value() if token is not None else None) == expected
|
||||
|
||||
|
||||
def test_guardrail_bearer_respects_custom_admission_header(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from litellm.proxy.proxy_server import general_settings
|
||||
|
||||
monkeypatch.setitem(general_settings, "litellm_key_header_name", "x-gateway-key")
|
||||
token: Final = MCPRequestHandler.get_guardrail_bearer_token(
|
||||
Headers({"x-gateway-key": "opaque-master", "authorization": "Bearer opaque-master"}),
|
||||
UserAPIKeyAuth(api_key="litellm_proxy_master_key"),
|
||||
)
|
||||
assert token is None
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ async def test_mapped_caller_bearer_reaches_agent_evaluation(
|
|||
"headers": [] if verdict == "missing" else [(b"authorization", f"Bearer {bearer}".encode())],
|
||||
})
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
api_key="stored-key-hash", user_id="mapped-caller",
|
||||
api_key="stored-key-hash", user_id="mapped-caller", jwt_claims={"sub": "mapped-caller"},
|
||||
object_permission={"object_permission_id": "mapped", "mcp_servers": ["guarded"], "mcp_tool_search_enabled": mode == "virtual"},
|
||||
)
|
||||
oauth, raw, _, _ = MCPRequestHandler.scrub_gateway_admission_credentials(
|
||||
|
|
@ -1552,3 +1552,41 @@ def test_discovery_extra_headers_exclude_gateway_admission_key(header_name: str)
|
|||
assert auth_header is None
|
||||
assert extra_headers == {"X-Tenant": "tenant-control"}
|
||||
assert raw_headers == {"x-litellm-api-key": caller_key, "x-tenant": "tenant-control"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("request_kind", ["absent", "non_starlette", "without_bearer", "admission_key"])
|
||||
async def test_guardrail_context_never_recovers_a_previous_request_bearer(
|
||||
_mcp_request_ctx: Callable[..., ServerRequestContext], request_kind: str, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from mcp.server.auth.middleware.auth_context import auth_context_var
|
||||
from starlette.requests import Request
|
||||
from litellm.proxy._experimental.mcp_server import server as gateway
|
||||
|
||||
request: Final = (
|
||||
None if request_kind == "absent" else object() if request_kind == "non_starlette" else Request({
|
||||
"type": "http", "method": "POST", "path": "/mcp",
|
||||
"headers": [(b"authorization", b"Bearer sk-current-key")] if request_kind == "admission_key" else [],
|
||||
})
|
||||
)
|
||||
previous: Final = auth_context_var.set(None)
|
||||
try:
|
||||
gateway.set_auth_context(
|
||||
UserAPIKeyAuth(api_key="sk-current-key"),
|
||||
raw_headers={"authorization": "Bearer previous.caller.assertion"},
|
||||
)
|
||||
capture: Final = _CatalogHookCapture()
|
||||
monkeypatch.setattr(litellm, "callbacks", [capture])
|
||||
async with gateway._legacy_operation_context(_mcp_request_ctx(request=request), trace=False) as context:
|
||||
await operations.global_mcp_server_manager.pre_call_tool_check(
|
||||
name="echo", arguments={}, server_name="guarded",
|
||||
user_api_key_auth=context.user_api_key_auth,
|
||||
proxy_logging_obj=ProxyLogging(user_api_key_cache=UserApiKeyCache()),
|
||||
server=MCPServer(server_id="guarded", name="guarded", transport=MCPTransport.http, allow_all_keys=True),
|
||||
raw_headers=dict(context.raw_headers or {}),
|
||||
incoming_bearer_token=context.incoming_bearer_token,
|
||||
)
|
||||
assert capture.data is not None
|
||||
assert capture.data["incoming_bearer_token"] is None
|
||||
finally:
|
||||
auth_context_var.reset(previous)
|
||||
|
|
|
|||
|
|
@ -4862,3 +4862,70 @@ async def test_saved_preview_protocol_omission_and_explicit_edits(
|
|||
assert result == {"protocol_version": expected}
|
||||
assert saved.protocol_version == "2025-11-25"
|
||||
assert payload.mcp_info == metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("virtual", [False, True])
|
||||
@pytest.mark.parametrize("bearer,claims,expected", [
|
||||
("sk-guardrail-admission", None, None),
|
||||
("caller.jwt.assertion", {"sub": "caller"}, "caller.jwt.assertion"),
|
||||
("upstream-token", None, "upstream-token"),
|
||||
])
|
||||
async def test_rest_guardrail_bearer_excludes_admission_key(
|
||||
monkeypatch: pytest.MonkeyPatch, virtual: bool,
|
||||
bearer: str, claims: dict[str, str] | None, expected: str | None,
|
||||
) -> None:
|
||||
import litellm
|
||||
from litellm.caching.dual_cache import DualCache
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.proxy._experimental.mcp_server.tool_registry import global_mcp_tool_registry
|
||||
|
||||
observed: Final[asyncio.Queue[object]] = asyncio.Queue()
|
||||
|
||||
class CaptureBearer(CustomLogger):
|
||||
async def async_pre_call_hook(
|
||||
self, user_api_key_dict: UserAPIKeyAuth, cache: DualCache,
|
||||
data: dict[str, object], call_type: str,
|
||||
) -> None:
|
||||
if call_type == "call_mcp_tool" and "incoming_bearer_token" in data:
|
||||
observed.put_nowait(data["incoming_bearer_token"])
|
||||
|
||||
async def echo() -> str:
|
||||
return "guarded-result"
|
||||
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
api_key="sk-guardrail-admission", jwt_claims=claims,
|
||||
object_permission={"object_permission_id": "guarded", "mcp_servers": ["guarded"], "mcp_tool_search_enabled": virtual},
|
||||
)
|
||||
managed: Final = MCPServer(
|
||||
server_id="guarded", name="guarded", transport=MCPTransport.http,
|
||||
spec_path="/catalog.yaml", allow_all_keys=True,
|
||||
)
|
||||
monkeypatch.setitem(rest_endpoints.global_mcp_server_manager.registry, managed.server_id, managed)
|
||||
monkeypatch.setattr(litellm, "callbacks", [CaptureBearer()])
|
||||
global_mcp_tool_registry.register_tool(
|
||||
name="guarded-echo", description="Echo", input_schema={"type": "object"}, handler=echo,
|
||||
)
|
||||
headers: Final = {"authorization": f"Bearer {bearer}", **(
|
||||
{} if claims else {"x-litellm-api-key": "sk-guardrail-admission"}
|
||||
)}
|
||||
payload: Final = {
|
||||
"server_id": "guarded", "name": "mcp_tool_call" if virtual else "guarded-echo",
|
||||
"arguments": {"tool_name": "guarded-echo", "arguments": {}} if virtual else {},
|
||||
}
|
||||
try:
|
||||
result: Final = await rest_endpoints.call_tool_rest_api(
|
||||
_build_request(headers, path="/mcp-rest/tools/call", json_body=payload),
|
||||
user_api_key_dict=auth,
|
||||
)
|
||||
assert result.is_error is False
|
||||
assert observed.qsize() == 1
|
||||
assert observed.get_nowait() == expected
|
||||
if claims is None:
|
||||
list_headers: Final = rest_endpoints._extract_mcp_headers_from_request(
|
||||
_build_request(headers), auth,
|
||||
)[2]
|
||||
authorization: Final = list_headers.get("authorization", "")
|
||||
assert (authorization.removeprefix("Bearer ") or None) == expected
|
||||
finally:
|
||||
global_mcp_tool_registry.unregister_tools_with_prefix("guarded-")
|
||||
|
|
|
|||
|
|
@ -2008,6 +2008,45 @@ async def test_apply_guardrail_sends_textless_parts_rows_unflattened(
|
|||
assert wire_messages[1]["content"] == "D" * 5000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sdk_echoed_null_tool_calls_never_reach_compression_service(guardrail: HeadroomGuardrail):
|
||||
"""LIT-6921: OpenAI SDK assistant messages echoed back as history carry
|
||||
``"tool_calls": null``, which headroom-ai 0.27-0.30 answer with a 503. The
|
||||
chat handler sends no row carrying that null and forwards the echoed rows
|
||||
unchanged."""
|
||||
from openai.types.chat import ChatCompletionMessage
|
||||
|
||||
from litellm.llms.openai.chat.guardrail_translation.handler import OpenAIChatCompletionsHandler
|
||||
|
||||
def _sdk_assistant(**fields: object) -> dict:
|
||||
return ChatCompletionMessage.model_validate({"role": "assistant", **fields}).model_dump()
|
||||
|
||||
tool_call = {"id": "call_1", "type": "function", "function": {"name": "lookup", "arguments": "{}"}}
|
||||
history = [
|
||||
{"role": "user", "content": "E" * 5000},
|
||||
_sdk_assistant(content=None, tool_calls=[tool_call]),
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "R" * 5000},
|
||||
_sdk_assistant(content="summary"),
|
||||
{"role": "user", "content": "F" * 5000},
|
||||
_sdk_assistant(content="noted"),
|
||||
{"role": "user", "content": "and now?"},
|
||||
]
|
||||
assert history[3]["tool_calls"] is None
|
||||
sent: dict = {}
|
||||
|
||||
def _echo(**kwargs):
|
||||
sent["messages"] = kwargs["json"]["messages"]
|
||||
return _make_compress_response(json.loads(json.dumps(sent["messages"])))
|
||||
|
||||
data = {"model": "claude-sonnet-5", "messages": json.loads(json.dumps(history))}
|
||||
with patch.object(guardrail.async_handler, "post", new_callable=AsyncMock, side_effect=_echo):
|
||||
result = await OpenAIChatCompletionsHandler().process_input_messages(data=data, guardrail_to_apply=guardrail)
|
||||
|
||||
assert [row["role"] for row in sent["messages"]] == ["user", "tool", "user"]
|
||||
assert all(row.get("tool_calls", []) is not None for row in sent["messages"])
|
||||
assert [result["messages"][index] for index in (1, 3, 5)] == [history[index] for index in (1, 3, 5)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fail_open_returns_original_parts_shapes():
|
||||
guardrail = _make_guardrail(unreachable_fallback="fail_open")
|
||||
|
|
|
|||
|
|
@ -882,7 +882,8 @@ async def test_test_model_connection_request_mode_wins_over_resolved_mode():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_model_connection_evaluation_mode_uses_decisions_handler():
|
||||
@pytest.mark.parametrize("mode", ("decisions", "evaluation"), ids=("decisions", "legacy-evaluation"))
|
||||
async def test_test_model_connection_decision_modes_are_forwarded(mode: str):
|
||||
deployment: Final = MappingProxyType(
|
||||
{
|
||||
"model_name": "typesafe/jev-latest",
|
||||
|
|
@ -893,14 +894,14 @@ async def test_test_model_connection_evaluation_mode_uses_decisions_handler():
|
|||
with _test_connection_probe(deployment) as ahealth_check:
|
||||
result: Final = await health_test_model_connection(
|
||||
request=MagicMock(),
|
||||
mode="evaluation",
|
||||
mode=mode,
|
||||
litellm_params={"model": "typesafe/jev-latest"},
|
||||
model_info={"id": "typesafe-jev-id"},
|
||||
user_api_key_dict=UserAPIKeyAuth(user_id="test-user", token="test-token"),
|
||||
)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert ahealth_check.call_args.kwargs["mode"] == "evaluation"
|
||||
assert ahealth_check.call_args.kwargs["mode"] == mode
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -5011,7 +5012,8 @@ def test_test_model_connection_accepts_image_edit_mode(monkeypatch):
|
|||
assert response.json()["status"] == "success"
|
||||
|
||||
|
||||
def test_test_model_connection_accepts_evaluation_mode(monkeypatch):
|
||||
@pytest.mark.parametrize("mode", ("decisions", "evaluation"), ids=("decisions", "legacy-evaluation"))
|
||||
def test_test_model_connection_accepts_decision_modes(monkeypatch, mode: str):
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
|
||||
|
|
@ -5036,7 +5038,7 @@ def test_test_model_connection_accepts_evaluation_mode(monkeypatch):
|
|||
response = client.post(
|
||||
"/health/test_connection",
|
||||
json={
|
||||
"mode": "evaluation",
|
||||
"mode": mode,
|
||||
"litellm_params": {"model": "typesafe/jev-latest", "api_key": "sk-test"},
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -427,3 +427,26 @@ async def test_jev_evaluation_obeys_each_containing_scope(
|
|||
return
|
||||
await operation
|
||||
assert not catalog.get_model_list(f"{provider}/{model}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("restricted", ["key", "team", None])
|
||||
async def test_deployed_decision_classifier_requires_key_and_team_access(catalog: Router, restricted: str | None) -> None:
|
||||
permitted: Final = ["allowed", "other"]
|
||||
operation: Final = authorize_member_auto_router_dependencies(
|
||||
config=validate_member_auto_router_config({
|
||||
"tiers": {"SIMPLE": "allowed"},
|
||||
"classifier_type": "oss_classifier",
|
||||
"opensource_classifier_config": {"deployment_name": "other"},
|
||||
}),
|
||||
default_model=None,
|
||||
user_api_key_dict=_actor(models=["allowed"] if restricted == "key" else permitted),
|
||||
team=_team(models=["allowed"] if restricted == "team" else permitted),
|
||||
prisma_client=_Client(),
|
||||
llm_router=catalog,
|
||||
)
|
||||
if restricted is not None:
|
||||
with pytest.raises(ProxyException, match="other"):
|
||||
await operation
|
||||
return
|
||||
await operation
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ def test_success_handler_dispatches_to_typesafe_handler():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("mode", ("decisions", "evaluation"), ids=("decisions", "legacy-evaluation"))
|
||||
@pytest.mark.parametrize("guardrail_cost", [0.0, 0.25])
|
||||
@pytest.mark.parametrize("metadata_slot", ["metadata", "litellm_metadata"])
|
||||
@pytest.mark.parametrize("provider,requested,routing_model", [
|
||||
|
|
@ -149,7 +150,7 @@ def test_success_handler_dispatches_to_typesafe_handler():
|
|||
])
|
||||
async def test_oss_gateway_accounts_for_checkpoint_usage_and_registered_cost(
|
||||
monkeypatch: pytest.MonkeyPatch, routing_model: str | None, metadata_slot: str, guardrail_cost: float,
|
||||
provider: str, requested: str
|
||||
provider: str, requested: str, mode: str
|
||||
) -> None:
|
||||
checkpoint: Final = routing_model or requested
|
||||
model: Final = f"{provider}/{checkpoint}"
|
||||
|
|
@ -157,7 +158,7 @@ async def test_oss_gateway_accounts_for_checkpoint_usage_and_registered_cost(
|
|||
output_rate: Final = 0.005
|
||||
monkeypatch.setitem(litellm.model_cost, model, {
|
||||
"input_cost_per_token": input_rate, "output_cost_per_token": output_rate,
|
||||
"litellm_provider": provider, "mode": "evaluation",
|
||||
"litellm_provider": provider, "mode": mode,
|
||||
})
|
||||
start: Final = datetime.now()
|
||||
logging_obj: Final = Logging(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from importlib.resources import files
|
||||
from collections.abc import Iterator
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
|
@ -17,7 +19,7 @@ from litellm.router_strategy.complexity_router.fuse_presets import get_fuse_pres
|
|||
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
|
||||
ModelGroupInfoProxy,
|
||||
)
|
||||
from litellm.types.proxy.public_endpoints.public_endpoints import ProviderCreateInfo
|
||||
from litellm.types.proxy.public_endpoints.public_endpoints import ProviderCreateInfo, WhatsNewResponse
|
||||
from litellm.types.utils import LlmProviders
|
||||
|
||||
|
||||
|
|
@ -67,13 +69,10 @@ def test_get_provider_create_fields():
|
|||
assert isinstance(first_provider["credential_fields"], list)
|
||||
|
||||
has_detailed_fields = any(
|
||||
provider.get("credential_fields")
|
||||
and len(provider.get("credential_fields", [])) > 0
|
||||
provider.get("credential_fields") and len(provider.get("credential_fields", [])) > 0
|
||||
for provider in response_data
|
||||
)
|
||||
assert (
|
||||
has_detailed_fields
|
||||
), "Expected at least one provider to have detailed credential fields"
|
||||
assert has_detailed_fields, "Expected at least one provider to have detailed credential fields"
|
||||
|
||||
|
||||
def test_get_litellm_model_cost_map_catalog_only_excludes_runtime_registered_entries(
|
||||
|
|
@ -124,10 +123,7 @@ def test_get_litellm_model_cost_map_returns_cost_map():
|
|||
sample_model_data = payload[sample_model]
|
||||
assert isinstance(sample_model_data, dict)
|
||||
# Check for common cost fields that should be present
|
||||
assert (
|
||||
"input_cost_per_token" in sample_model_data
|
||||
or "output_cost_per_token" in sample_model_data
|
||||
)
|
||||
assert "input_cost_per_token" in sample_model_data or "output_cost_per_token" in sample_model_data
|
||||
|
||||
|
||||
def test_public_ai_hub_info_is_public_by_default(monkeypatch):
|
||||
|
|
@ -218,9 +214,9 @@ def test_anthropic_provider_fields_support_byok():
|
|||
"Anthropic api_key must be optional so admins can configure BYOK models "
|
||||
"without entering a key. See BYOK tutorial."
|
||||
)
|
||||
assert fields_by_key["api_key"].get(
|
||||
"tooltip"
|
||||
), "Anthropic api_key must have a tooltip explaining the BYOK use case."
|
||||
assert fields_by_key["api_key"].get("tooltip"), (
|
||||
"Anthropic api_key must have a tooltip explaining the BYOK use case."
|
||||
)
|
||||
assert "api_base" in fields_by_key, (
|
||||
"Anthropic provider form must expose api_base so cloud customers "
|
||||
"can override the upstream URL without env var access."
|
||||
|
|
@ -228,16 +224,14 @@ def test_anthropic_provider_fields_support_byok():
|
|||
api_base_field = fields_by_key["api_base"]
|
||||
assert api_base_field["required"] is False
|
||||
assert api_base_field["field_type"] == "text"
|
||||
assert api_base_field.get(
|
||||
"tooltip"
|
||||
), "api_base should have a tooltip explaining it is optional."
|
||||
assert api_base_field.get("tooltip"), "api_base should have a tooltip explaining it is optional."
|
||||
|
||||
# UI forms render fields in credential_fields order; api_base should come first
|
||||
# so an admin sees the URL override before the key field.
|
||||
field_order = [f["key"] for f in anthropic["credential_fields"]]
|
||||
assert field_order.index("api_base") < field_order.index(
|
||||
"api_key"
|
||||
), "api_base must appear before api_key in credential_fields (matches AI21 and ANTHROPIC_TEXT convention)."
|
||||
assert field_order.index("api_base") < field_order.index("api_key"), (
|
||||
"api_base must appear before api_key in credential_fields (matches AI21 and ANTHROPIC_TEXT convention)."
|
||||
)
|
||||
|
||||
|
||||
def test_bedrock_mantle_provider_fields():
|
||||
|
|
@ -554,9 +548,7 @@ def test_google_ai_studio_provider_fields_expose_api_base():
|
|||
assert response.status_code == 200
|
||||
providers = response.json()
|
||||
|
||||
google_ai = next(
|
||||
(p for p in providers if p["provider"] == "Google_AI_Studio"), None
|
||||
)
|
||||
google_ai = next((p for p in providers if p["provider"] == "Google_AI_Studio"), None)
|
||||
assert google_ai is not None, "Google_AI_Studio provider entry not found"
|
||||
assert google_ai["litellm_provider"] == "gemini"
|
||||
|
||||
|
|
@ -576,18 +568,15 @@ def test_google_ai_studio_provider_fields_expose_api_base():
|
|||
# placeholder shows the canonical URL so users still get the visual hint.
|
||||
# (See greptileai threads on PR #30419.)
|
||||
assert api_base_field["default_value"] is None
|
||||
assert (
|
||||
api_base_field["placeholder"]
|
||||
== "https://generativelanguage.googleapis.com/v1beta"
|
||||
)
|
||||
assert api_base_field["placeholder"] == "https://generativelanguage.googleapis.com/v1beta"
|
||||
|
||||
# UI forms render fields in credential_fields order; api_base should come
|
||||
# first so an admin sees the URL override before the key field (matches
|
||||
# OpenAI and Anthropic conventions).
|
||||
field_order = [f["key"] for f in google_ai["credential_fields"]]
|
||||
assert field_order.index("api_base") < field_order.index(
|
||||
"api_key"
|
||||
), "api_base must appear before api_key in credential_fields."
|
||||
assert field_order.index("api_base") < field_order.index("api_key"), (
|
||||
"api_base must appear before api_key in credential_fields."
|
||||
)
|
||||
|
||||
|
||||
def test_public_model_hub_with_healthy_model():
|
||||
|
|
@ -615,20 +604,15 @@ def test_public_model_hub_with_healthy_model():
|
|||
|
||||
mock_llm_router = MagicMock()
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.get_all_latest_health_checks = AsyncMock(
|
||||
return_value=[mock_health_check]
|
||||
)
|
||||
mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[mock_health_check])
|
||||
|
||||
with (
|
||||
patch("litellm.public_model_groups", ["gpt-3.5-turbo"]),
|
||||
patch("litellm.proxy.proxy_server.get_model_group_info") as mock_get_info,
|
||||
patch("litellm.proxy.proxy_server.llm_router", mock_llm_router),
|
||||
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
|
||||
patch(
|
||||
"litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict"
|
||||
) as mock_convert,
|
||||
patch("litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict") as mock_convert,
|
||||
):
|
||||
|
||||
mock_get_info.return_value = [mock_model_group]
|
||||
mock_convert.return_value = {
|
||||
"status": "healthy",
|
||||
|
|
@ -673,20 +657,15 @@ def test_public_model_hub_with_unhealthy_model():
|
|||
|
||||
mock_llm_router = MagicMock()
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.get_all_latest_health_checks = AsyncMock(
|
||||
return_value=[mock_health_check]
|
||||
)
|
||||
mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[mock_health_check])
|
||||
|
||||
with (
|
||||
patch("litellm.public_model_groups", ["gpt-4"]),
|
||||
patch("litellm.proxy.proxy_server.get_model_group_info") as mock_get_info,
|
||||
patch("litellm.proxy.proxy_server.llm_router", mock_llm_router),
|
||||
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
|
||||
patch(
|
||||
"litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict"
|
||||
) as mock_convert,
|
||||
patch("litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict") as mock_convert,
|
||||
):
|
||||
|
||||
mock_get_info.return_value = [mock_model_group]
|
||||
mock_convert.return_value = {
|
||||
"status": "unhealthy",
|
||||
|
|
@ -732,7 +711,6 @@ def test_public_model_hub_without_health_check():
|
|||
patch("litellm.proxy.proxy_server.llm_router", mock_llm_router),
|
||||
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
|
||||
):
|
||||
|
||||
mock_get_info.return_value = [mock_model_group]
|
||||
|
||||
response = client.get(
|
||||
|
|
@ -789,9 +767,7 @@ def test_public_model_hub_mixed_health_statuses():
|
|||
|
||||
mock_llm_router = MagicMock()
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.get_all_latest_health_checks = AsyncMock(
|
||||
return_value=[healthy_check, unhealthy_check]
|
||||
)
|
||||
mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[healthy_check, unhealthy_check])
|
||||
|
||||
def convert_side_effect(check):
|
||||
if check.model_name == "gpt-3.5-turbo":
|
||||
|
|
@ -813,11 +789,8 @@ def test_public_model_hub_mixed_health_statuses():
|
|||
patch("litellm.proxy.proxy_server.get_model_group_info") as mock_get_info,
|
||||
patch("litellm.proxy.proxy_server.llm_router", mock_llm_router),
|
||||
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
|
||||
patch(
|
||||
"litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict"
|
||||
) as mock_convert,
|
||||
patch("litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict") as mock_convert,
|
||||
):
|
||||
|
||||
mock_get_info.return_value = [
|
||||
healthy_model,
|
||||
unhealthy_model,
|
||||
|
|
@ -1066,9 +1039,7 @@ def test_get_supported_endpoints_provider_fields(reset_endpoints_cache):
|
|||
def test_get_supported_endpoints_paths_start_with_slash(reset_endpoints_cache):
|
||||
endpoints = _make_client().get("/public/endpoints").json()["endpoints"]
|
||||
for item in endpoints:
|
||||
assert item["endpoint"].startswith(
|
||||
"/"
|
||||
), f"Expected path starting with /, got: {item['endpoint']}"
|
||||
assert item["endpoint"].startswith("/"), f"Expected path starting with /, got: {item['endpoint']}"
|
||||
|
||||
|
||||
def test_get_supported_endpoints_chat_completions_present(reset_endpoints_cache):
|
||||
|
|
@ -1092,9 +1063,9 @@ def test_get_supported_endpoints_display_names_have_no_slug_suffix(
|
|||
endpoints = _make_client().get("/public/endpoints").json()["endpoints"]
|
||||
for item in endpoints:
|
||||
for provider in item["providers"]:
|
||||
assert not suffix_re.search(
|
||||
provider["display_name"]
|
||||
), f"display_name still contains slug suffix: {provider['display_name']!r}"
|
||||
assert not suffix_re.search(provider["display_name"]), (
|
||||
f"display_name still contains slug suffix: {provider['display_name']!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_get_supported_endpoints_is_cached(reset_endpoints_cache):
|
||||
|
|
@ -1320,7 +1291,6 @@ def test_public_mcp_hub_does_not_expose_upstream_url():
|
|||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_autorouter_presets_cache():
|
||||
from litellm.proxy.public_endpoints.public_endpoints import _AutoRouterPresetsCache
|
||||
|
|
@ -1332,9 +1302,7 @@ def reset_autorouter_presets_cache():
|
|||
_AutoRouterPresetsCache.lock = None
|
||||
|
||||
|
||||
def test_get_autorouter_presets_local_mode_serves_bundled_catalog(
|
||||
monkeypatch, reset_autorouter_presets_cache
|
||||
):
|
||||
def test_get_autorouter_presets_local_mode_serves_bundled_catalog(monkeypatch, reset_autorouter_presets_cache):
|
||||
monkeypatch.setenv("LITELLM_LOCAL_AUTOROUTER_PRESETS", "True")
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
|
@ -1362,9 +1330,7 @@ def test_get_autorouter_presets_local_mode_serves_bundled_catalog(
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_autorouter_presets_fetches_once_per_process(
|
||||
monkeypatch, reset_autorouter_presets_cache
|
||||
):
|
||||
async def test_get_autorouter_presets_fetches_once_per_process(monkeypatch, reset_autorouter_presets_cache):
|
||||
from litellm.proxy.public_endpoints.public_endpoints import (
|
||||
_AUTOROUTER_PRESETS_ADAPTER,
|
||||
get_autorouter_presets,
|
||||
|
|
@ -1376,7 +1342,9 @@ async def test_get_autorouter_presets_fetches_once_per_process(
|
|||
"remote_only": {
|
||||
"label": "Remote Only",
|
||||
"description": "from the remote catalog",
|
||||
"complexity_router_config": {"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}},
|
||||
"complexity_router_config": {
|
||||
"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -1411,7 +1379,9 @@ async def test_get_autorouter_presets_single_flight_on_concurrent_cold_start(
|
|||
"remote_only": {
|
||||
"label": "Remote Only",
|
||||
"description": "from the remote catalog",
|
||||
"complexity_router_config": {"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}},
|
||||
"complexity_router_config": {
|
||||
"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -1507,9 +1477,7 @@ async def test_autorouter_presets_adapter_rejects_wrong_shapes():
|
|||
)
|
||||
|
||||
|
||||
def test_get_autorouter_presets_passes_unknown_catalog_fields_through(
|
||||
monkeypatch, reset_autorouter_presets_cache
|
||||
):
|
||||
def test_get_autorouter_presets_passes_unknown_catalog_fields_through(monkeypatch, reset_autorouter_presets_cache):
|
||||
from litellm.proxy.public_endpoints.public_endpoints import (
|
||||
_AUTOROUTER_PRESETS_ADAPTER,
|
||||
_AutoRouterPresetsCache,
|
||||
|
|
@ -1551,12 +1519,14 @@ async def test_fetch_remote_autorouter_presets_parses_and_rejects_empty(monkeypa
|
|||
"remote_only": {
|
||||
"label": "Remote Only",
|
||||
"description": "from the remote catalog",
|
||||
"complexity_router_config": {"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}},
|
||||
"complexity_router_config": {
|
||||
"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}
|
||||
},
|
||||
}
|
||||
}
|
||||
response = MagicMock()
|
||||
response.raise_for_status = MagicMock()
|
||||
response.json = MagicMock(return_value=catalog)
|
||||
response.content = json.dumps(catalog).encode()
|
||||
client = MagicMock()
|
||||
client.get = AsyncMock(return_value=response)
|
||||
monkeypatch.setattr(http_handler_module, "get_async_httpx_client", lambda llm_provider: client)
|
||||
|
|
@ -1565,6 +1535,121 @@ async def test_fetch_remote_autorouter_presets_parses_and_rejects_empty(monkeypa
|
|||
assert presets["remote_only"].label == "Remote Only"
|
||||
response.raise_for_status.assert_called_once()
|
||||
|
||||
response.json = MagicMock(return_value={})
|
||||
response.content = b"{}"
|
||||
with pytest.raises(ValueError, match="empty"):
|
||||
await _fetch_remote_autorouter_presets("https://example.test/presets.json")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_whats_new_cache() -> Iterator[None]:
|
||||
from litellm.proxy.public_endpoints.public_endpoints import _WhatsNewCache
|
||||
|
||||
_WhatsNewCache.launches = None
|
||||
_WhatsNewCache.lock = None
|
||||
yield
|
||||
_WhatsNewCache.launches = None
|
||||
_WhatsNewCache.lock = None
|
||||
|
||||
|
||||
def _remote_whats_new() -> WhatsNewResponse:
|
||||
return WhatsNewResponse.model_validate(
|
||||
{
|
||||
"launches": [
|
||||
{
|
||||
"icon": "sparkles",
|
||||
"title": "Remote Launch",
|
||||
"description": "from the remote list",
|
||||
"href": "https://docs.litellm.ai/blog/remote",
|
||||
"published_on": "2026-10-11",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_get_whats_new_local_mode_serves_bundled_list(
|
||||
monkeypatch: pytest.MonkeyPatch, reset_whats_new_cache: None
|
||||
) -> None:
|
||||
monkeypatch.setenv("LITELLM_LOCAL_WHATS_NEW", "True")
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/public/whats_new")
|
||||
|
||||
assert response.status_code == 200
|
||||
served: Final = WhatsNewResponse.model_validate_json(response.content)
|
||||
bundled: Final = WhatsNewResponse.model_validate_json(
|
||||
files("litellm.proxy.public_endpoints").joinpath("whats_new.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert served == bundled
|
||||
assert len(served.launches) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_whats_new_fetches_once_per_process(
|
||||
monkeypatch: pytest.MonkeyPatch, reset_whats_new_cache: None
|
||||
) -> None:
|
||||
from litellm.proxy.public_endpoints.public_endpoints import get_whats_new
|
||||
|
||||
monkeypatch.delenv("LITELLM_LOCAL_WHATS_NEW", raising=False)
|
||||
remote = _remote_whats_new()
|
||||
calls: Final[list[str]] = []
|
||||
|
||||
async def fake_fetch(url: str) -> WhatsNewResponse:
|
||||
calls.append(url)
|
||||
return remote
|
||||
|
||||
first = await get_whats_new(url="https://example.test/whats_new.json", fetch=fake_fetch)
|
||||
second = await get_whats_new(url="https://example.test/whats_new.json", fetch=fake_fetch)
|
||||
|
||||
assert first == remote
|
||||
assert second == remote
|
||||
assert calls == ["https://example.test/whats_new.json"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_whats_new_caches_bundled_fallback_when_remote_is_unreachable(
|
||||
monkeypatch: pytest.MonkeyPatch, reset_whats_new_cache: None
|
||||
) -> None:
|
||||
from litellm.proxy.public_endpoints.public_endpoints import _load_bundled_whats_new, get_whats_new
|
||||
|
||||
monkeypatch.delenv("LITELLM_LOCAL_WHATS_NEW", raising=False)
|
||||
calls: Final[list[str]] = []
|
||||
|
||||
async def unreachable_fetch(url: str) -> WhatsNewResponse:
|
||||
calls.append(url)
|
||||
raise OSError("network is unreachable")
|
||||
|
||||
first = await get_whats_new(url="https://example.test/whats_new.json", fetch=unreachable_fetch)
|
||||
second = await get_whats_new(url="https://example.test/whats_new.json", fetch=unreachable_fetch)
|
||||
|
||||
assert first == _load_bundled_whats_new()
|
||||
assert second == first
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_remote_whats_new_rejects_a_malformed_list_so_the_bundled_one_is_served(
|
||||
monkeypatch: pytest.MonkeyPatch, reset_whats_new_cache: None
|
||||
) -> None:
|
||||
import httpx
|
||||
|
||||
from litellm.proxy.public_endpoints import public_endpoints
|
||||
|
||||
monkeypatch.delenv("LITELLM_LOCAL_WHATS_NEW", raising=False)
|
||||
request = httpx.Request("GET", "https://example.test/whats_new.json")
|
||||
client = MagicMock()
|
||||
client.get = AsyncMock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
request=request,
|
||||
content=b'{"launches": [{"icon": "box", "title": "No date", "description": "d", "href": "https://x"}]}',
|
||||
)
|
||||
)
|
||||
|
||||
with patch("litellm.llms.custom_httpx.http_handler.get_async_httpx_client", return_value=client):
|
||||
served = await public_endpoints.get_whats_new(url="https://example.test/whats_new.json")
|
||||
|
||||
assert served == public_endpoints._load_bundled_whats_new()
|
||||
client.get.assert_awaited_once_with("https://example.test/whats_new.json", timeout=5.0)
|
||||
|
|
|
|||
|
|
@ -573,7 +573,11 @@ async def test_databricks_routes_through_the_serving_endpoint_and_accounts_the_e
|
|||
"jev_classifier_config" if legacy else "opensource_classifier_config": {
|
||||
"provider": "databricks",
|
||||
"model": endpoint,
|
||||
**({"api_key": "dapi-configured", "api_base": "https://workspace.test/serving-endpoints"} if configured_key else {}),
|
||||
**(
|
||||
{"api_key": "dapi-configured", "api_base": "https://workspace.test/serving-endpoints"}
|
||||
if configured_key
|
||||
else {}
|
||||
),
|
||||
},
|
||||
"tiers": {"SIMPLE": "cheap"},
|
||||
},
|
||||
|
|
@ -596,7 +600,9 @@ async def test_databricks_routes_through_the_serving_endpoint_and_accounts_the_e
|
|||
assert (outcome.jev_verdict.provider, outcome.jev_verdict.model) == ("databricks", endpoint)
|
||||
assert outcome.classifier_cost == pytest.approx(0.31)
|
||||
sent: Final = route.calls.last.request
|
||||
assert sent.headers.get("authorization") == ("Bearer dapi-configured" if configured_key else "Bearer dapi-env-token")
|
||||
assert sent.headers.get("authorization") == (
|
||||
"Bearer dapi-configured" if configured_key else "Bearer dapi-env-token"
|
||||
)
|
||||
assert json.loads(sent.content)["model"] == endpoint
|
||||
assert len(recorder.calls) == 1
|
||||
assert recorder.calls[0]["response_cost"] == pytest.approx(0.31)
|
||||
|
|
@ -765,3 +771,143 @@ async def test_http_jev_classifier_client_posts_to_system_one() -> None:
|
|||
assert captured["content_type"] == "application/json"
|
||||
assert captured["body"] == request.model_dump(mode="json")
|
||||
assert response.model == "jev-1.13.0"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status_code", [200, 503])
|
||||
@pytest.mark.parametrize("provider,model", [("typesafe", "jev-latest"), ("databricks", "my-endpoint")])
|
||||
async def test_deployed_decision_classifier_uses_registered_connection(
|
||||
status_code: int, provider: str, model: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setitem(litellm.model_cost, f"{provider}/{model}", {"input_cost_per_token": 0.01})
|
||||
router: Final = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "decision-deployment",
|
||||
"litellm_params": {
|
||||
"model": f"{provider}/{model}",
|
||||
"api_key": "deployment-key",
|
||||
"api_base": "https://decision.test/serving-endpoints"
|
||||
if provider == "databricks"
|
||||
else "https://decision.test",
|
||||
},
|
||||
}
|
||||
],
|
||||
num_retries=0,
|
||||
)
|
||||
classifier: Final = ComplexityRouter(
|
||||
"deployed-classifier",
|
||||
router,
|
||||
{
|
||||
"classifier_type": "oss_classifier",
|
||||
"opensource_classifier_config": {"deployment_name": "decision-deployment"},
|
||||
"tiers": {"SIMPLE": "cheap", "MEDIUM": "mid", "COMPLEX": "strong", "REASONING": "top"},
|
||||
},
|
||||
derive_savings_baseline=False,
|
||||
)
|
||||
with respx.mock(assert_all_called=True) as requests:
|
||||
route: Final = requests.post(
|
||||
"https://decision.test/serving-endpoints/my-endpoint/invocations"
|
||||
if provider == "databricks"
|
||||
else "https://decision.test/v1/systemone"
|
||||
).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code,
|
||||
json={
|
||||
"model": model,
|
||||
"answers": {"tier": _answer().model_dump()},
|
||||
"usage": {"input_tokens": 31, "output_tokens": 0},
|
||||
},
|
||||
)
|
||||
)
|
||||
outcome: Final = await classifier.aclassify("What is two plus two?")
|
||||
assert route.call_count == 1
|
||||
assert route.calls[0].request.headers["authorization"] == "Bearer deployment-key"
|
||||
assert json.loads(route.calls[0].request.content)["model"] == model
|
||||
assert (outcome.cause == "jev_classifier") is (status_code == 200)
|
||||
if status_code == 200:
|
||||
assert outcome.jev_verdict is not None
|
||||
assert outcome.jev_verdict.label == _answer().choice
|
||||
|
||||
assert outcome.jev_verdict.provider == provider
|
||||
assert outcome.classifier_cost == pytest.approx(0.31)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("deployment_name", ["", " "])
|
||||
def test_deployed_decision_classifier_rejects_blank_deployment_name(deployment_name: str) -> None:
|
||||
with pytest.raises(ValueError, match="deployment_name"):
|
||||
JevClassifierConfig.model_validate({"deployment_name": deployment_name})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("connection", [{"api_key": "secret"}, {"api_base": "https://override.test"}])
|
||||
def test_deployed_decision_classifier_rejects_connection_overrides(connection: Mapping[str, str]) -> None:
|
||||
with pytest.raises(ValueError, match="deployment connection"):
|
||||
JevClassifierConfig.model_validate({"deployment_name": "deployed", **connection})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("failure", ["timeout", "cancelled", "invalid"])
|
||||
async def test_deployed_decision_classifier_preserves_failure_handling(
|
||||
failure: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
router: Final = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "decision-deployment",
|
||||
"litellm_params": {
|
||||
"model": "typesafe/jev-latest",
|
||||
"api_key": "test",
|
||||
"api_base": "https://decision.test",
|
||||
},
|
||||
}
|
||||
],
|
||||
num_retries=0,
|
||||
)
|
||||
classifier: Final = ComplexityRouter(
|
||||
"decision-router",
|
||||
router,
|
||||
{
|
||||
"classifier_type": "oss_classifier",
|
||||
"opensource_classifier_config": {"deployment_name": "decision-deployment"},
|
||||
"tiers": {"SIMPLE": "cheap", "MEDIUM": "mid", "COMPLEX": "strong", "REASONING": "top"},
|
||||
},
|
||||
derive_savings_baseline=False,
|
||||
)
|
||||
cancellation_reached: Final = asyncio.Event()
|
||||
with respx.mock(assert_all_called=failure != "cancelled") as upstream:
|
||||
route: Final = upstream.post("https://decision.test/v1/systemone")
|
||||
if failure == "timeout":
|
||||
route.mock(side_effect=httpx.ReadTimeout("classifier timeout"))
|
||||
elif failure == "cancelled":
|
||||
|
||||
async def cancel_request(request: httpx.Request) -> httpx.Response:
|
||||
cancellation_reached.set()
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
route.mock(side_effect=cancel_request)
|
||||
else:
|
||||
route.respond(json={"answers": {"tier": _answer("not-a-tier").model_dump()}})
|
||||
if failure == "cancelled":
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await classifier.aclassify("short question")
|
||||
assert cancellation_reached.is_set()
|
||||
return
|
||||
outcome: Final = await classifier.aclassify("short question")
|
||||
assert outcome.cause != "jev_classifier"
|
||||
assert outcome.jev_verdict is None
|
||||
|
||||
|
||||
def test_switching_to_deployed_classifier_does_not_copy_legacy_credentials() -> None:
|
||||
from litellm.router_strategy.complexity_router.config import resolve_complexity_router_config_write
|
||||
|
||||
write: Final = resolve_complexity_router_config_write(
|
||||
{"classifier_type": "oss_classifier", "opensource_classifier_config": {"deployment_name": "registered"}},
|
||||
{
|
||||
"classifier_type": "oss_classifier",
|
||||
"opensource_classifier_config": {"api_key": "legacy-secret", "api_base": "https://legacy.test"},
|
||||
},
|
||||
)
|
||||
assert write.effective is not None
|
||||
assert write.effective["opensource_classifier_config"] == {"deployment_name": "registered"}
|
||||
|
|
|
|||
|
|
@ -755,3 +755,20 @@ def test_chained_heuristic_and_judge_prompt_claim_each_active_capability(
|
|||
assert count_capability_routers(({"litellm_params": params},), capability=capability) == (
|
||||
1 if capability.key in expected else 0
|
||||
)
|
||||
|
||||
|
||||
def test_deployed_decision_classifier_is_a_model_access_dependency() -> None:
|
||||
dependencies: Final = strategy_router_dependencies(
|
||||
{
|
||||
"model": "auto_router/complexity_router",
|
||||
"complexity_router_config": {
|
||||
"classifier_type": "oss_classifier",
|
||||
"opensource_classifier_config": {"deployment_name": "decision-deployment"},
|
||||
"tiers": {"SIMPLE": "cheap"},
|
||||
},
|
||||
}
|
||||
)
|
||||
assert tuple((dependency.model_name, dependency.role) for dependency in dependencies) == (
|
||||
("cheap", "tier"),
|
||||
("decision-deployment", "classifier"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -637,7 +637,7 @@ def test_deepseek_rows_bill_half_rate_outside_weekday_peak_hours(path: Path):
|
|||
|
||||
|
||||
PROVIDER_LABELS_WITHOUT_A_MODEL_SET: Final = frozenset({"sagemaker", "bedrock_converse"})
|
||||
MODES_SERVED_OUTSIDE_THE_LLM_PROVIDER_REGISTRY: Final = frozenset({"search", "evaluation"})
|
||||
MODES_SERVED_OUTSIDE_THE_LLM_PROVIDER_REGISTRY: Final = frozenset({"search", "decisions", "evaluation"})
|
||||
VERTEX_FAMILIES_A_VERTEX_WILDCARD_GRANT_DOES_NOT_LIST: Final = frozenset(
|
||||
{
|
||||
"vertex_ai-ai21_models",
|
||||
|
|
|
|||
|
|
@ -8403,7 +8403,7 @@ def test_models_by_provider():
|
|||
or v["litellm_provider"] == "bedrock_converse"
|
||||
):
|
||||
continue
|
||||
elif v.get("mode") in ("search", "evaluation"):
|
||||
elif v.get("mode") in ("search", "decisions", "evaluation"):
|
||||
continue
|
||||
else:
|
||||
providers.add(v["litellm_provider"])
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from litellm.types.utils import (
|
|||
ModelResponseStream,
|
||||
TextCompletionResponse,
|
||||
all_litellm_params,
|
||||
is_decisions_model_mode,
|
||||
text_tokens_without_nested_reasoning,
|
||||
)
|
||||
|
||||
|
|
@ -41,6 +42,14 @@ class _DictHiddenParamsAccessor(Protocol):
|
|||
def hidden_params(self, hidden_params: dict[str, object]) -> None: ...
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "expected"),
|
||||
(("decisions", True), ("evaluation", True), ("chat", False), (None, False)),
|
||||
)
|
||||
def test_is_decisions_model_mode(mode: str | None, expected: bool) -> None:
|
||||
assert is_decisions_model_mode(mode) is expected
|
||||
|
||||
|
||||
def test_rust_is_a_known_litellm_param():
|
||||
assert "rust" in all_litellm_params
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import type { DailyActivityAggregatedResponse } from "@/components/UsagePage/dai
|
|||
import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi";
|
||||
import { all_admin_roles } from "@/utils/roles";
|
||||
import HomePage from "./HomePage";
|
||||
import { WHATS_NEW_ITEMS } from "./homeContent";
|
||||
|
||||
const auth = vi.hoisted(() => ({
|
||||
accessToken: "sk-test",
|
||||
|
|
@ -31,6 +30,28 @@ vi.mock("@/components/networking", async (importOriginal) => ({
|
|||
|
||||
const blogPosts = { posts: [{ title: "Post one", description: "d", date: "2026-10-09", url: "https://x/1" }] };
|
||||
|
||||
const launch = (title: string, icon: string, published_on: string) => ({
|
||||
icon,
|
||||
title,
|
||||
description: `${title} description`,
|
||||
href: `https://docs.litellm.ai/blog/${title}`,
|
||||
published_on,
|
||||
});
|
||||
|
||||
const whatsNew = vi.hoisted(() => ({ launches: [] as unknown[] }));
|
||||
|
||||
const requestUrl = (input: RequestInfo | URL): string => {
|
||||
if (typeof input === "string") return input;
|
||||
return input instanceof URL ? input.href : input.url;
|
||||
};
|
||||
|
||||
const fakeFetch = (input: RequestInfo | URL) => {
|
||||
const body = requestUrl(input).endsWith("/public/whats_new") ? whatsNew : blogPosts;
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }),
|
||||
);
|
||||
};
|
||||
|
||||
const dayResult = (date: string, model: string, tokens: number) => {
|
||||
const metrics = {
|
||||
spend: 1,
|
||||
|
|
@ -86,7 +107,8 @@ describe("HomePage", () => {
|
|||
network.aggregated.mockReset().mockResolvedValue(activity);
|
||||
network.gateway.mockReset().mockResolvedValue({ total_successful_requests: 0, total_failed_requests: 0 });
|
||||
network.userInfo.mockReset().mockResolvedValue({ user_info: { max_budget: null } });
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(blogPosts), { status: 200 })));
|
||||
whatsNew.launches = [launch("mid", "box", "2026-10-06"), launch("new", "zap", "2026-10-11")];
|
||||
vi.stubGlobal("fetch", vi.fn(fakeFetch));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
|
|
@ -121,13 +143,32 @@ describe("HomePage", () => {
|
|||
expect(network.aggregated).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("lists the curated launches newest first with their links", () => {
|
||||
it("renders the launches served by /public/whats_new newest first, whatever order the JSON lists them in", async () => {
|
||||
whatsNew.launches = [
|
||||
launch("mid", "box", "2026-10-06"),
|
||||
launch("new", "an-icon-from-a-newer-list", "2026-10-11"),
|
||||
launch("old", "scale", "2026-09-30"),
|
||||
];
|
||||
renderHome();
|
||||
const section = screen.getByTestId("home-whats-new");
|
||||
const links = within(section).getAllByRole("link");
|
||||
expect(links.map((link) => link.getAttribute("href"))).toEqual(WHATS_NEW_ITEMS.map((item) => item.href));
|
||||
const dates = WHATS_NEW_ITEMS.map((item) => item.publishedOn);
|
||||
expect(dates).toEqual([...dates].sort().reverse());
|
||||
const section = await screen.findByTestId("home-whats-new");
|
||||
expect(await within(section).findByText("new")).toBeInTheDocument();
|
||||
expect(
|
||||
within(section)
|
||||
.getAllByRole("link")
|
||||
.map((link) => link.getAttribute("href")),
|
||||
).toEqual([
|
||||
"https://docs.litellm.ai/blog/new",
|
||||
"https://docs.litellm.ai/blog/mid",
|
||||
"https://docs.litellm.ai/blog/old",
|
||||
]);
|
||||
expect(within(section).getByText("Oct 11")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides What's new when /public/whats_new has no launches", async () => {
|
||||
whatsNew.launches = [];
|
||||
renderHome();
|
||||
expect(await screen.findByRole("link", { name: /post one/i })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("home-whats-new")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ranks model groups by token share on the leaderboard", async () => {
|
||||
|
|
|
|||
|
|
@ -4,21 +4,24 @@ import { ArrowRight, Plus, Sparkles } from "lucide-react";
|
|||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Page } from "@/components/shared/Page";
|
||||
import { PageHeader, PageHeaderControls, PageHeaderTitle } from "@/components/shared/PageHeader";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
import { Leaderboard, useBreakdown } from "@/app/(dashboard)/usage/_components/components/overview/BreakdownChart";
|
||||
import { Panel } from "@/app/(dashboard)/usage/_components/components/overview/Primitives";
|
||||
import UsageStatStrip from "@/app/(dashboard)/usage/_components/components/overview/UsageStatStrip";
|
||||
import { formatPublishedOn, WHATS_NEW_ITEMS } from "./homeContent";
|
||||
import { formatPublishedOn, launchIcon } from "./homeContent";
|
||||
import UpdatesFeed from "./UpdatesFeed";
|
||||
import { HOME_USAGE_DAYS, useHomeUsage } from "./useHomeUsage";
|
||||
import { useWhatsNew } from "./useWhatsNew";
|
||||
|
||||
const LEADERBOARD = { metric: "tokens", dimension: "model_groups" } as const;
|
||||
|
||||
export default function HomePage() {
|
||||
const { isViewOnly } = useAuthorized();
|
||||
const usage = useHomeUsage();
|
||||
const whatsNew = useWhatsNew();
|
||||
const { series, ranking } = useBreakdown(usage.results, LEADERBOARD, 8);
|
||||
|
||||
return (
|
||||
|
|
@ -56,34 +59,40 @@ export default function HomePage() {
|
|||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,3fr)_minmax(16rem,1fr)]">
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<section aria-labelledby="home-whats-new" data-testid="home-whats-new" className="flex flex-col gap-3">
|
||||
<div>
|
||||
<h2 id="home-whats-new" className="flex items-center gap-2 text-lg font-semibold">
|
||||
<Sparkles className="size-4 text-muted-foreground" />
|
||||
What's new in LiteLLM?
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{WHATS_NEW_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<a key={item.title} href={item.href} target="_blank" rel="noopener noreferrer" className="group">
|
||||
<Card className="h-full gap-3 px-4 py-4 transition-colors hover:bg-muted/40">
|
||||
<div className="flex size-9 items-center justify-center rounded-md bg-muted">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="text-base font-semibold leading-tight">{item.title}</div>
|
||||
<p className="m-0 line-clamp-3 text-sm text-muted-foreground">{item.description}</p>
|
||||
<div className="mt-auto flex items-center justify-between pt-1 text-xs text-muted-foreground">
|
||||
<span>{formatPublishedOn(item.publishedOn)}</span>
|
||||
<ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
</Card>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
{(whatsNew.isLoading || (whatsNew.data?.length ?? 0) > 0) && (
|
||||
<section aria-labelledby="home-whats-new" data-testid="home-whats-new" className="flex flex-col gap-3">
|
||||
<div>
|
||||
<h2 id="home-whats-new" className="flex items-center gap-2 text-lg font-semibold">
|
||||
<Sparkles className="size-4 text-muted-foreground" />
|
||||
What's new in LiteLLM?
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{whatsNew.isLoading &&
|
||||
[0, 1, 2].map((i) => (
|
||||
<Skeleton key={i} className="h-40 rounded-xl" data-testid="home-whats-new-loading" />
|
||||
))}
|
||||
{whatsNew.data?.map((item) => {
|
||||
const Icon = launchIcon(item.icon);
|
||||
return (
|
||||
<a key={item.title} href={item.href} target="_blank" rel="noopener noreferrer" className="group">
|
||||
<Card className="h-full gap-3 px-4 py-4 transition-colors hover:bg-muted/40">
|
||||
<div className="flex size-9 items-center justify-center rounded-md bg-muted">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="text-base font-semibold leading-tight">{item.title}</div>
|
||||
<p className="m-0 line-clamp-3 text-sm text-muted-foreground">{item.description}</p>
|
||||
<div className="mt-auto flex items-center justify-between pt-1 text-xs text-muted-foreground">
|
||||
<span>{formatPublishedOn(item.published_on)}</span>
|
||||
<ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
</Card>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Panel
|
||||
title="Leaderboard"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import { Box, Sparkles } from "lucide-react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { latestLaunches, launchIcon, type WhatsNewLaunch } from "./homeContent";
|
||||
|
||||
const launch = (title: string, published_on: string): WhatsNewLaunch => ({
|
||||
icon: "box",
|
||||
title,
|
||||
description: "d",
|
||||
href: `https://docs.litellm.ai/${title}`,
|
||||
published_on,
|
||||
});
|
||||
|
||||
describe("latestLaunches", () => {
|
||||
it("orders launches by published_on, newest first, without mutating the input", () => {
|
||||
const launches = [launch("old", "2026-09-01"), launch("new", "2026-10-11"), launch("mid", "2026-10-01")];
|
||||
expect(latestLaunches(launches).map((l) => l.title)).toEqual(["new", "mid", "old"]);
|
||||
expect(launches.map((l) => l.title)).toEqual(["old", "new", "mid"]);
|
||||
});
|
||||
|
||||
it("keeps only the three newest so the cards fill one row", () => {
|
||||
const launches = [
|
||||
launch("oldest", "2026-08-01"),
|
||||
launch("a", "2026-10-03"),
|
||||
launch("b", "2026-10-02"),
|
||||
launch("c", "2026-10-01"),
|
||||
];
|
||||
expect(latestLaunches(launches).map((l) => l.title)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("launchIcon", () => {
|
||||
it("maps a known icon name to its lucide icon", () => {
|
||||
expect(launchIcon("box")).toBe(Box);
|
||||
});
|
||||
|
||||
it("falls back to Sparkles for an icon name this dashboard version does not know", () => {
|
||||
expect(launchIcon("hologram")).toBe(Sparkles);
|
||||
expect(launchIcon("constructor")).toBe(Sparkles);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,40 +1,26 @@
|
|||
import { Box, Scale, Zap, type LucideIcon } from "lucide-react";
|
||||
import { Bot, Box, Gauge, Plug, Rocket, Scale, Shield, Sparkles, Zap, type LucideIcon } from "lucide-react";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
|
||||
export interface Launch {
|
||||
readonly icon: LucideIcon;
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly href: string;
|
||||
readonly publishedOn: string;
|
||||
}
|
||||
export type WhatsNewLaunch = components["schemas"]["WhatsNewLaunch"];
|
||||
|
||||
const LAUNCHES: readonly Launch[] = [
|
||||
{
|
||||
icon: Box,
|
||||
title: "Claude Haiku 5.5",
|
||||
description: "Anthropic's newest small model, with day 0 pricing on Anthropic, Bedrock and Vertex AI.",
|
||||
href: "https://docs.litellm.ai/blog/claude-haiku-5-5",
|
||||
publishedOn: "2026-10-07",
|
||||
},
|
||||
{
|
||||
icon: Scale,
|
||||
title: "Decision Models",
|
||||
description: "Call decision models at /v1/decisions and try them in the Decisions playground.",
|
||||
href: "https://docs.litellm.ai/docs/decisions",
|
||||
publishedOn: "2026-10-07",
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: "OpenAI Ultrafast",
|
||||
description: "service_tier: ultrafast on GPT-6 Astra and GPT-6.1 Sol, including /ultrafast in Codex.",
|
||||
href: "https://docs.litellm.ai/docs/providers/openai/ultrafast",
|
||||
publishedOn: "2026-10-06",
|
||||
},
|
||||
];
|
||||
const LAUNCH_ICONS: ReadonlyMap<string, LucideIcon> = new Map([
|
||||
["bot", Bot],
|
||||
["box", Box],
|
||||
["gauge", Gauge],
|
||||
["plug", Plug],
|
||||
["rocket", Rocket],
|
||||
["scale", Scale],
|
||||
["shield", Shield],
|
||||
["sparkles", Sparkles],
|
||||
["zap", Zap],
|
||||
]);
|
||||
|
||||
export const WHATS_NEW_ITEMS: readonly Launch[] = [...LAUNCHES].sort((a, b) =>
|
||||
b.publishedOn.localeCompare(a.publishedOn),
|
||||
);
|
||||
export const launchIcon = (name: string): LucideIcon => LAUNCH_ICONS.get(name) ?? Sparkles;
|
||||
|
||||
export const WHATS_NEW_MAX = 3;
|
||||
|
||||
export const latestLaunches = (launches: readonly WhatsNewLaunch[]): readonly WhatsNewLaunch[] =>
|
||||
[...launches].sort((a, b) => b.published_on.localeCompare(a.published_on)).slice(0, WHATS_NEW_MAX);
|
||||
|
||||
export const formatPublishedOn = (isoDate: string): string =>
|
||||
new Date(`${isoDate}T00:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
import { $api } from "@/lib/http/api";
|
||||
import { latestLaunches } from "./homeContent";
|
||||
|
||||
export const useWhatsNew = () =>
|
||||
$api.useQuery(
|
||||
"get",
|
||||
"/public/whats_new",
|
||||
{},
|
||||
{ staleTime: 60 * 60 * 1000, select: (data) => latestLaunches(data.launches) },
|
||||
);
|
||||
|
|
@ -100,7 +100,10 @@ export function AutoRoutersPanel({
|
|||
<Dialog open={isCreating} onOpenChange={setIsCreating}>
|
||||
{/* The form is long, so the dialog caps its height and scrolls its body rather than
|
||||
growing past the viewport. */}
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogContent
|
||||
overlayClassName="bg-black/40 backdrop-blur-sm"
|
||||
className="max-h-[90vh] overflow-y-auto sm:max-w-4xl"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Auto Router</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import CurlPreview from "./CurlPreview";
|
||||
|
||||
const input = {
|
||||
baseUrl: "http://localhost:4000",
|
||||
endpoint: "/v1/systemone" as const,
|
||||
payload: JSON.stringify({ model: "jev-latest", state: "ok" }),
|
||||
authHeaderName: "Authorization",
|
||||
};
|
||||
|
||||
describe("CurlPreview", () => {
|
||||
it("shows the command and a copy button for a valid payload", () => {
|
||||
render(<CurlPreview {...input} />);
|
||||
expect(screen.getByLabelText("cURL request")).toHaveTextContent("http://localhost:4000/v1/systemone");
|
||||
expect(screen.getByRole("button", { name: /copy curl/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the copy button and points back to the form while the payload has errors", () => {
|
||||
render(<CurlPreview {...input} errorCount={2} />);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Fix 2 issues on the Form tab");
|
||||
expect(screen.getByLabelText("cURL request")).toHaveTextContent("jev-latest");
|
||||
expect(screen.queryByRole("button", { name: /copy curl/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { buildCurlCommand, type CurlCommandInput } from "./lib/curlCommand";
|
||||
|
||||
interface CurlPreviewProps extends CurlCommandInput {
|
||||
errorCount?: number;
|
||||
}
|
||||
|
||||
export default function CurlPreview({ errorCount = 0, ...input }: CurlPreviewProps) {
|
||||
const command = buildCurlCommand(input);
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col rounded-md border">
|
||||
<div className="flex items-center justify-between gap-2 border-b px-3 py-2 text-sm">
|
||||
<span className="font-medium">cURL request</span>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{errorCount > 0 ? (
|
||||
<span role="alert" className="text-destructive">
|
||||
Fix {errorCount} {errorCount === 1 ? "issue" : "issues"} on the Form tab before copying
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span>Replace $LITELLM_API_KEY with a virtual key</span>
|
||||
<CopyButton value={command} label="Copy cURL" variant="action" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<pre
|
||||
aria-label="cURL request"
|
||||
className="m-0 min-h-0 flex-1 overflow-auto whitespace-pre-wrap wrap-anywhere px-3 py-3 font-mono text-xs leading-5"
|
||||
>
|
||||
{command}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { cn } from "@/lib/cva.config";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useId, type ReactNode } from "react";
|
||||
import { IssueList, ValidationStatus } from "./JsonEditor";
|
||||
import { IssueList, ValidationStatus } from "./ValidationFeedback";
|
||||
import type { PayloadValidation } from "./lib/validatePayload";
|
||||
|
||||
export function RemoveButton({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import JsonEditor from "./JsonEditor";
|
||||
import { validateSystemOnePayload } from "./lib/validatePayload";
|
||||
|
||||
const validPayload = JSON.stringify(
|
||||
{ state: "Hi", questions: { escalate: { type: "noul", instructions: "Escalate?" } } },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
describe("JsonEditor", () => {
|
||||
it("marks a valid payload as ready to send and counts its lines", () => {
|
||||
render(<JsonEditor value={validPayload} onChange={vi.fn()} validation={validateSystemOnePayload(validPayload)} />);
|
||||
|
||||
expect(screen.getByText("Valid payload")).toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Ready to send");
|
||||
expect(screen.getByText(`${validPayload.split("\n").length} lines`)).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "System One JSON payload" })).toHaveAttribute("aria-invalid", "false");
|
||||
});
|
||||
|
||||
it("lists each issue with its path and counts only errors in the status badge", () => {
|
||||
const payload = JSON.stringify({
|
||||
state: "Hi",
|
||||
questions: {
|
||||
category: { type: "choice", instructions: 1, criteria: { support: "Help" } },
|
||||
urgency: { type: "score", instructions: "Rate", criteria: ["Low"] },
|
||||
},
|
||||
});
|
||||
render(<JsonEditor value={payload} onChange={vi.fn()} validation={validateSystemOnePayload(payload)} />);
|
||||
|
||||
expect(screen.getByText("2 issues")).toBeInTheDocument();
|
||||
const issues = screen.getByRole("list", { name: "Payload validation issues" });
|
||||
expect(issues).toHaveTextContent("questions.category.instructionsInstructions must be a string.");
|
||||
expect(issues).toHaveTextContent("questions.urgency.criteriaScore criteria must contain at least 2 levels.");
|
||||
expect(screen.getByRole("textbox", { name: "System One JSON payload" })).toHaveAttribute("aria-invalid", "true");
|
||||
});
|
||||
|
||||
it("shows warnings without counting them as issues", () => {
|
||||
const payload = JSON.stringify({
|
||||
state: "Hi",
|
||||
questions: { urgency: { type: "score", instructions: "Rate", criteria: Array.from({ length: 11 }, () => "L") } },
|
||||
});
|
||||
render(<JsonEditor value={payload} onChange={vi.fn()} validation={validateSystemOnePayload(payload)} />);
|
||||
|
||||
expect(screen.getByText("Valid payload")).toBeInTheDocument();
|
||||
expect(screen.getByRole("list", { name: "Payload validation issues" })).toHaveTextContent(
|
||||
"More than 10 score levels may reduce result quality.",
|
||||
);
|
||||
});
|
||||
|
||||
it("numbers every line, including a trailing empty one, so wrapped lines keep their number", () => {
|
||||
const value = `${validPayload}\n`;
|
||||
render(<JsonEditor value={value} onChange={vi.fn()} validation={validateSystemOnePayload(value)} />);
|
||||
|
||||
const lineCount = value.split("\n").length;
|
||||
expect(screen.getByText(`${lineCount} lines`)).toBeInTheDocument();
|
||||
expect(screen.getByText(String(lineCount))).toBeInTheDocument();
|
||||
expect(screen.queryByText(String(lineCount + 1))).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports edits to the caller", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<JsonEditor value={validPayload} onChange={onChange} validation={validateSystemOnePayload(validPayload)} />);
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "System One JSON payload" }), { target: { value: "{}" } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("{}");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { CircleAlert, CircleCheck, TriangleAlert } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useId, useRef } from "react";
|
||||
import { createElement, PrismLight as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import type { SyntaxHighlighterProps } from "react-syntax-highlighter";
|
||||
import json from "react-syntax-highlighter/dist/esm/languages/prism/json";
|
||||
import { vs, vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import type { PayloadValidation } from "./lib/validatePayload";
|
||||
|
||||
SyntaxHighlighter.registerLanguage("json", json);
|
||||
|
||||
const EDITOR_TEXT = "m-0 whitespace-pre-wrap wrap-anywhere py-3 font-mono text-xs leading-5 [scrollbar-gutter:stable]";
|
||||
const GUTTER_WIDTH = "w-11";
|
||||
type LineRendererProps = Parameters<NonNullable<SyntaxHighlighterProps["renderer"]>>[0];
|
||||
const CONTENT_INSET = "pl-14 pr-3";
|
||||
type SyntaxTheme = NonNullable<SyntaxHighlighterProps["style"]>;
|
||||
const THEME_BLOCKS = ['pre[class*="language-"]', 'code[class*="language-"]'] as const;
|
||||
|
||||
function withoutBlockBackground(theme: SyntaxTheme): SyntaxTheme {
|
||||
const stripped = THEME_BLOCKS.map((block) => {
|
||||
const { background: _background, backgroundColor: _backgroundColor, ...rest } = theme[block] ?? {};
|
||||
return [block, rest] as const;
|
||||
});
|
||||
return { ...theme, ...Object.fromEntries(stripped) };
|
||||
}
|
||||
|
||||
const VS_CODE_DARK_PLUS = withoutBlockBackground(vscDarkPlus);
|
||||
const VS_CODE_LIGHT_PLUS = {
|
||||
...withoutBlockBackground(vs),
|
||||
property: { color: "#0451a5" },
|
||||
string: { color: "#a31515" },
|
||||
number: { color: "#098658" },
|
||||
boolean: { color: "#0000ff" },
|
||||
punctuation: { color: "#000000" },
|
||||
operator: { color: "#000000" },
|
||||
} as const;
|
||||
const INHERIT_TEXT = {
|
||||
fontFamily: "inherit",
|
||||
fontSize: "inherit",
|
||||
lineHeight: "inherit",
|
||||
whiteSpace: "pre-wrap",
|
||||
overflowWrap: "anywhere",
|
||||
textShadow: "none",
|
||||
} as const;
|
||||
const CODE_TAG_PROPS = { className: "language-json", style: INHERIT_TEXT } as const;
|
||||
const TRANSPARENT_PRE = {
|
||||
...INHERIT_TEXT,
|
||||
background: "transparent",
|
||||
border: 0,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
overflow: "visible",
|
||||
} as const;
|
||||
|
||||
interface JsonEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
validation: PayloadValidation<unknown>;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function ValidationStatus({ validation }: { validation: PayloadValidation<unknown> }) {
|
||||
const errorCount = validation.issues.filter((issue) => issue.severity === "error").length;
|
||||
if (errorCount > 0) {
|
||||
return (
|
||||
<Badge variant="destructive">
|
||||
{errorCount} {errorCount === 1 ? "issue" : "issues"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return <Badge variant="secondary">Valid payload</Badge>;
|
||||
}
|
||||
|
||||
export function IssueList({ id, validation }: { id: string; validation: PayloadValidation<unknown> }) {
|
||||
if (validation.issues.length === 0) {
|
||||
return (
|
||||
<p id={id} role="status" className="flex items-center gap-1.5 border-t px-3 py-2 text-xs text-muted-foreground">
|
||||
<CircleCheck className="size-3.5 text-emerald-600 dark:text-emerald-400" />
|
||||
Ready to send
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul id={id} aria-label="Payload validation issues" className="grid max-h-36 gap-1 overflow-auto border-t px-3 py-2">
|
||||
{validation.issues.map((issue, index) => (
|
||||
<li key={`${issue.path}-${index}`} className="flex items-start gap-1.5 text-xs">
|
||||
{issue.severity === "error" ? (
|
||||
<CircleAlert className="mt-0.5 size-3.5 shrink-0 text-destructive" />
|
||||
) : (
|
||||
<TriangleAlert className="mt-0.5 size-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
)}
|
||||
<code className="shrink-0 rounded bg-muted px-1 font-mono">{issue.path}</code>
|
||||
<span>{issue.message}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function LineRows({ rows, stylesheet, useInlineStyles }: LineRendererProps) {
|
||||
return rows.map((row, line) => {
|
||||
const lineElement = { node: row, stylesheet, useInlineStyles, key: line };
|
||||
return (
|
||||
<div key={line} className="flex">
|
||||
<span className={cn(GUTTER_WIDTH, "shrink-0 select-none pr-3 text-right text-zinc-400 dark:text-zinc-500")}>
|
||||
{line + 1}
|
||||
</span>
|
||||
<span className="min-h-5 min-w-0 flex-1 px-3">{createElement(lineElement)}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default function JsonEditor({
|
||||
value,
|
||||
onChange,
|
||||
validation,
|
||||
label = "System One JSON payload",
|
||||
placeholder = "Paste or write a System One request",
|
||||
}: JsonEditorProps) {
|
||||
const issuesId = useId();
|
||||
const highlightRef = useRef<HTMLDivElement>(null);
|
||||
const syntaxTheme = useTheme().resolvedTheme === "dark" ? VS_CODE_DARK_PLUS : VS_CODE_LIGHT_PLUS;
|
||||
const lineCount = value.split("\n").length;
|
||||
const hasErrors = !validation.isValid;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-96 flex-1 flex-col overflow-hidden rounded-md border bg-background",
|
||||
hasErrors && "border-destructive/60",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Request JSON</span>
|
||||
<ValidationStatus validation={validation} />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{lineCount} {lineCount === 1 ? "line" : "lines"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative min-h-80 flex-1 bg-background dark:bg-[#1e1e1e]">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
GUTTER_WIDTH,
|
||||
"absolute inset-y-0 left-0 border-r bg-muted/50 dark:border-zinc-700 dark:bg-zinc-800/60",
|
||||
)}
|
||||
/>
|
||||
<div ref={highlightRef} aria-hidden="true" className={cn("absolute inset-0 overflow-hidden", EDITOR_TEXT)}>
|
||||
<SyntaxHighlighter
|
||||
language="json"
|
||||
style={syntaxTheme}
|
||||
customStyle={TRANSPARENT_PRE}
|
||||
PreTag="div"
|
||||
codeTagProps={CODE_TAG_PROPS}
|
||||
renderer={LineRows}
|
||||
>
|
||||
{value}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
<textarea
|
||||
aria-label={label}
|
||||
aria-invalid={hasErrors}
|
||||
aria-describedby={issuesId}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onScroll={(event) => {
|
||||
if (highlightRef.current) {
|
||||
highlightRef.current.scrollTop = event.currentTarget.scrollTop;
|
||||
}
|
||||
}}
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
EDITOR_TEXT,
|
||||
CONTENT_INSET,
|
||||
"absolute inset-0 size-full resize-none overflow-y-auto bg-transparent text-transparent caret-foreground outline-none selection:bg-primary/20 placeholder:text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<IssueList id={issuesId} validation={validation} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -177,10 +177,10 @@ interface OpenAIDecisionsFormProps {
|
|||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
validation: PayloadValidation<OpenAIDecisionsRequest>;
|
||||
onOpenJson: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export default function OpenAIDecisionsForm({ value, onChange, validation, onOpenJson }: OpenAIDecisionsFormProps) {
|
||||
export default function OpenAIDecisionsForm({ value, onChange, validation, onReset }: OpenAIDecisionsFormProps) {
|
||||
const inputId = useId();
|
||||
const safetyId = useId();
|
||||
const form = useMemo(() => readDecisionsForm(value), [value]);
|
||||
|
|
@ -188,11 +188,9 @@ export default function OpenAIDecisionsForm({ value, onChange, validation, onOpe
|
|||
if (form === undefined) {
|
||||
return (
|
||||
<FormFrame validation={validation}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This request has JSON the form can't show. Edit it in JSON to fix its shape.
|
||||
</p>
|
||||
<Button variant="outline" className="w-fit" onClick={onOpenJson}>
|
||||
Edit in JSON
|
||||
<p className="text-sm text-muted-foreground">This request has a shape the form can't show.</p>
|
||||
<Button variant="outline" className="w-fit" onClick={onReset}>
|
||||
Reset to example
|
||||
</Button>
|
||||
</FormFrame>
|
||||
);
|
||||
|
|
@ -207,12 +205,7 @@ export default function OpenAIDecisionsForm({ value, onChange, validation, onOpe
|
|||
<div className="grid gap-1.5">
|
||||
{Array.isArray(form.input) ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Input contains messages. Edit messages and image parts in JSON; form edits keep them unchanged.
|
||||
</p>
|
||||
<Button variant="outline" className="w-fit" onClick={onOpenJson}>
|
||||
Edit input in JSON
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">Input contains messages. Form edits keep them unchanged.</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import QuestionBreakdown from "./QuestionBreakdown";
|
||||
import { validateSystemOnePayload } from "./lib/validatePayload";
|
||||
|
||||
const previewOf = (questions: object) => validateSystemOnePayload(JSON.stringify({ state: "Hi", questions })).payload;
|
||||
|
||||
describe("QuestionBreakdown", () => {
|
||||
it("previews every noul criterion that will be sent, not only true and false", () => {
|
||||
render(
|
||||
<QuestionBreakdown
|
||||
payload={previewOf({
|
||||
escalate: {
|
||||
type: "noul",
|
||||
instructions: "Escalate?",
|
||||
criteria: { true: "Asks for a person", unsure: "Maybe" },
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Asks for a person")).toBeInTheDocument();
|
||||
expect(screen.getByText("unsure")).toBeInTheDocument();
|
||||
expect(screen.getByText("Maybe")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No criteria defined")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says when a noul question has no criteria", () => {
|
||||
render(<QuestionBreakdown payload={previewOf({ escalate: { type: "noul", instructions: "Escalate?" } })} />);
|
||||
|
||||
expect(screen.getByText("No criteria defined")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { PlaygroundQuestion, PlaygroundRequest } from "./lib/schemas";
|
||||
|
||||
function formatState(state: unknown): string {
|
||||
if (typeof state === "string") {
|
||||
return state;
|
||||
}
|
||||
return JSON.stringify(state, null, 2) ?? String(state);
|
||||
}
|
||||
|
||||
function QuestionCriteria({ question }: { question: PlaygroundQuestion }) {
|
||||
if (question.type === "choice") {
|
||||
return (
|
||||
<dl className="grid gap-2">
|
||||
{Object.entries(question.criteria).map(([label, description]) => (
|
||||
<div key={label} className="grid gap-0.5 sm:grid-cols-[minmax(7rem,auto)_1fr] sm:gap-3">
|
||||
<dt className="font-mono text-xs font-medium">{label}</dt>
|
||||
<dd className="text-xs text-muted-foreground">{formatState(description)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
if (question.type === "noul") {
|
||||
const criteria = Object.entries(question.criteria ?? {}).filter(([, description]) => description !== undefined);
|
||||
return criteria.length > 0 ? (
|
||||
<dl className="grid gap-2">
|
||||
{criteria.map(([label, description]) => (
|
||||
<div key={label} className="grid gap-0.5 sm:grid-cols-[minmax(7rem,auto)_1fr] sm:gap-3">
|
||||
<dt className="text-xs font-medium">{label}</dt>
|
||||
<dd className="text-xs text-muted-foreground">
|
||||
{typeof description === "string" ? description : JSON.stringify(description)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No criteria defined</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ol className="grid gap-2">
|
||||
{question.criteria.map((description, index) => (
|
||||
<li key={`${index}-${description}`} className="grid gap-0.5 sm:grid-cols-[minmax(7rem,auto)_1fr] sm:gap-3">
|
||||
<span className="font-mono text-xs font-medium">{index}</span>
|
||||
<span className="text-xs text-muted-foreground">{formatState(description)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
export default function QuestionBreakdown({ payload }: { payload?: PlaygroundRequest }) {
|
||||
if (!payload) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Question breakdown</CardTitle>
|
||||
<CardDescription>Enter a valid request to preview its state and questions.</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Question breakdown</CardTitle>
|
||||
<CardDescription>Review the state and criteria that will be sent.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 wrap-anywhere">
|
||||
<section aria-labelledby="system-one-state-heading" className="grid gap-2">
|
||||
<h3 id="system-one-state-heading" className="text-sm font-medium">
|
||||
State
|
||||
</h3>
|
||||
<pre className="max-h-48 overflow-y-auto whitespace-pre-wrap rounded-md bg-muted p-3 font-mono text-xs">
|
||||
{formatState(payload.state)}
|
||||
</pre>
|
||||
</section>
|
||||
<div className="grid gap-3">
|
||||
{Object.entries(payload.questions).map(([id, question]) => (
|
||||
<section key={id} className="grid gap-3 rounded-md border p-3" aria-labelledby={`question-${id}`}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 id={`question-${id}`} className="font-mono text-sm font-medium">
|
||||
{id}
|
||||
</h3>
|
||||
<Badge variant="secondary">{question.type}</Badge>
|
||||
</div>
|
||||
{question.instructions != null && <p className="text-sm">{formatState(question.instructions)}</p>}
|
||||
<div className="grid gap-2">
|
||||
<h4 className="text-xs font-medium text-muted-foreground">Criteria</h4>
|
||||
<QuestionCriteria question={question} />
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ const sampleResponse: SystemOneResponse = {
|
|||
};
|
||||
|
||||
describe("ResponseView integration", () => {
|
||||
it("shows OpenAI Decisions answer cards in Form view and the complete response in JSON view", async () => {
|
||||
it("shows OpenAI Decisions answer cards in Form view and the complete response in cURL view", async () => {
|
||||
const response: OpenAIDecisionsResponse = {
|
||||
model: "jev-latest",
|
||||
answers: [
|
||||
|
|
@ -71,7 +71,7 @@ describe("ResponseView integration", () => {
|
|||
expect(screen.queryByRole("region", { name: "Decisions response JSON" })).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Raw response" }));
|
||||
expect(screen.getByRole("region", { name: "Decisions response JSON" })).toHaveTextContent("keep-extra-fields");
|
||||
rerender(<ResponseView response={response} isLoading={false} view="json" />);
|
||||
rerender(<ResponseView response={response} isLoading={false} view="curl" />);
|
||||
expect(screen.queryByRole("meter")).not.toBeInTheDocument();
|
||||
expect(JSON.parse(screen.getByRole("region", { name: "Decisions response JSON" }).textContent ?? "")).toEqual(
|
||||
response,
|
||||
|
|
@ -80,8 +80,8 @@ describe("ResponseView integration", () => {
|
|||
expect(screen.getByRole("meter", { name: "false probability" })).toHaveAttribute("aria-valuenow", "80");
|
||||
});
|
||||
|
||||
it("shows System One raw response instead of cards in JSON view", () => {
|
||||
render(<ResponseView response={sampleResponse} isLoading={false} view="json" />);
|
||||
it("shows System One raw response instead of cards in cURL view", () => {
|
||||
render(<ResponseView response={sampleResponse} isLoading={false} view="curl" />);
|
||||
expect(screen.queryByRole("meter")).not.toBeInTheDocument();
|
||||
expect(JSON.parse(screen.getByRole("region", { name: "Decisions response JSON" }).textContent ?? "")).toEqual(
|
||||
sampleResponse,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const SCORE_FORMAT = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }
|
|||
|
||||
interface ResponseViewProps {
|
||||
response?: SystemOneResponse | OpenAIDecisionsResponse;
|
||||
view?: "form" | "json";
|
||||
view?: "form" | "curl";
|
||||
fallbackModel?: string;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
|
|
@ -236,7 +236,7 @@ export default function ResponseView({
|
|||
<CardHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="grid gap-1">
|
||||
<CardTitle>{view === "json" ? "Response JSON" : "Calibrated probabilities"}</CardTitle>
|
||||
<CardTitle>{view === "curl" ? "Response JSON" : "Calibrated probabilities"}</CardTitle>
|
||||
{model && <CardDescription>{model}</CardDescription>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
|
|
@ -250,7 +250,7 @@ export default function ResponseView({
|
|||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
{view === "json" ? <RawResponse response={response} /> : <AnswerCards answers={response.answers} />}
|
||||
{view === "curl" ? <RawResponse response={response} /> : <AnswerCards answers={response.answers} />}
|
||||
{view === "form" && (
|
||||
<Collapsible open={showRaw} onOpenChange={setShowRaw}>
|
||||
<CollapsibleTrigger
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ interface SystemOneFormProps {
|
|||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
validation: SystemOnePayloadValidation;
|
||||
onOpenJson: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
interface QuestionEditorProps<Q extends FormQuestion> {
|
||||
|
|
@ -247,18 +247,16 @@ function QuestionCard({ name, question, taken, onChange, onRename, onRemove }: Q
|
|||
);
|
||||
}
|
||||
|
||||
export default function SystemOneForm({ value, onChange, validation, onOpenJson }: SystemOneFormProps) {
|
||||
export default function SystemOneForm({ value, onChange, validation, onReset }: SystemOneFormProps) {
|
||||
const stateId = useId();
|
||||
const form = useMemo(() => readForm(value), [value]);
|
||||
|
||||
if (form === undefined) {
|
||||
return (
|
||||
<FormFrame validation={validation}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This request has JSON the form can't show, such as a syntax error or a value that isn't text.
|
||||
</p>
|
||||
<Button variant="outline" className="w-fit" onClick={onOpenJson}>
|
||||
Edit in JSON
|
||||
<p className="text-sm text-muted-foreground">This request has a shape the form can't show.</p>
|
||||
<Button variant="outline" className="w-fit" onClick={onReset}>
|
||||
Reset to example
|
||||
</Button>
|
||||
</FormFrame>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ import {
|
|||
import type { ReactElement } from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import SystemOneUI, { NOUL_HELP } from "./SystemOneUI";
|
||||
import SystemOneUI from "./SystemOneUI";
|
||||
import type { SystemOneResponse } from "./lib/schemas";
|
||||
import type { OpenAIDecisionsResponse } from "./lib/openAIDecisions";
|
||||
import { curlPayload } from "./lib/curlCommand";
|
||||
|
||||
interface Deployment {
|
||||
model_name: string;
|
||||
|
|
@ -71,9 +72,11 @@ const isModelLookup = (input: RequestInfo | URL) => String(input).endsWith("/mod
|
|||
const lookupAuthHeaders = (calls: Parameters<typeof fetch>[]) =>
|
||||
calls.map(([, init]) => Object.values((init?.headers ?? {}) as Record<string, string>));
|
||||
|
||||
const openJsonEditor = async () => {
|
||||
await userEvent.click(screen.getByRole("tab", { name: "JSON" }));
|
||||
return screen.getByRole("textbox", { name: "System One JSON payload" });
|
||||
const readCurlPayload = async () => {
|
||||
await userEvent.click(screen.getByRole("tab", { name: "cURL" }));
|
||||
const payload = curlPayload(screen.getByLabelText("cURL request").textContent ?? "");
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Form" }));
|
||||
return JSON.parse(payload ?? "") as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const selectEndpoint = async (endpoint: "/v1/decisions" | "/v1/systemone") => {
|
||||
|
|
@ -118,20 +121,20 @@ describe("SystemOneUI integration", () => {
|
|||
|
||||
it("switches between example presets and reads Custom once the request is edited", async () => {
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
const editor = await openJsonEditor();
|
||||
const example = (editor as HTMLTextAreaElement).value;
|
||||
const input = screen.getByRole("textbox", { name: "Input" }) as HTMLTextAreaElement;
|
||||
const example = input.value;
|
||||
const picker = screen.getByRole("combobox", { name: "Example" });
|
||||
expect(picker).toHaveTextContent("Triage a bug report");
|
||||
|
||||
fireEvent.change(editor, { target: { value: "{}" } });
|
||||
fireEvent.change(input, { target: { value: "Something else" } });
|
||||
expect(picker).toHaveTextContent("Custom");
|
||||
await selectPreset("Triage a bug report");
|
||||
expect(editor).toHaveValue(example);
|
||||
expect(screen.getByRole("textbox", { name: "Input" })).toHaveValue(example);
|
||||
expect(picker).toHaveTextContent("Triage a bug report");
|
||||
|
||||
await selectPreset("Score a sales lead");
|
||||
expect(picker).toHaveTextContent("Score a sales lead");
|
||||
expect(JSON.parse((editor as HTMLTextAreaElement).value)).toMatchObject({ questions: { fit: { type: "score" } } });
|
||||
expect(await readCurlPayload()).toMatchObject({ questions: { fit: { type: "score" } } });
|
||||
});
|
||||
|
||||
it("clears the whole request and lets a preset be picked from nothing", async () => {
|
||||
|
|
@ -153,26 +156,24 @@ describe("SystemOneUI integration", () => {
|
|||
expect(screen.getByRole("button", { name: "Send" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("keeps the preset label after picking a model and leaves an omitted model out when clearing", async () => {
|
||||
it("keeps the preset label after picking a model and keeps that model when clearing", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockModelLookup.mockResolvedValue(modelInfoResponse(DECISION_AND_CHAT_MODELS));
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
await waitFor(() => expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveValue("jev-latest"));
|
||||
const modelPicker = screen.getByRole("combobox", { name: "Decision model" });
|
||||
await waitFor(() => expect(modelPicker).toHaveValue("jev-latest"));
|
||||
const picker = screen.getByRole("combobox", { name: "Example" });
|
||||
const editor = await openJsonEditor();
|
||||
const example = JSON.parse((editor as HTMLTextAreaElement).value) as Record<string, unknown>;
|
||||
const { model: _model, ...withoutModel } = example;
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: { value: JSON.stringify({ ...withoutModel, model: "pplx-decider" }, null, 2) },
|
||||
});
|
||||
await user.clear(modelPicker);
|
||||
await user.type(modelPicker, "p");
|
||||
await user.click(await screen.findByRole("option", { name: "pplx-decider" }));
|
||||
expect(picker).toHaveTextContent("Triage a bug report");
|
||||
expect(await readCurlPayload()).toMatchObject({ model: "pplx-decider" });
|
||||
|
||||
fireEvent.change(editor, { target: { value: JSON.stringify(withoutModel, null, 2) } });
|
||||
expect(picker).toHaveTextContent("Triage a bug report");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Clear" }));
|
||||
expect(JSON.parse((editor as HTMLTextAreaElement).value)).toEqual({ state: "", questions: {} });
|
||||
await user.click(screen.getByRole("button", { name: "Clear" }));
|
||||
expect(await readCurlPayload()).toEqual({ model: "pplx-decider", state: "", questions: {} });
|
||||
await selectPreset("Score a sales lead");
|
||||
expect(JSON.parse((editor as HTMLTextAreaElement).value)).not.toHaveProperty("model");
|
||||
expect(await readCurlPayload()).toMatchObject({ model: "pplx-decider", questions: { fit: { type: "score" } } });
|
||||
});
|
||||
|
||||
it("offers the same presets on /v1/decisions in the OpenAI shape and clears to an empty input", async () => {
|
||||
|
|
@ -180,16 +181,14 @@ describe("SystemOneUI integration", () => {
|
|||
render(<SystemOneUI accessToken="session-key" />);
|
||||
await selectEndpoint("/v1/decisions");
|
||||
await selectPreset("Moderate a forum comment");
|
||||
await user.click(screen.getByRole("tab", { name: "JSON" }));
|
||||
const editor = screen.getByRole("textbox", { name: "Decisions JSON payload" }) as HTMLTextAreaElement;
|
||||
const payload = JSON.parse(editor.value);
|
||||
const payload = await readCurlPayload();
|
||||
expect(payload.input).toMatch(/stream=false/);
|
||||
expect(payload.questions).toEqual([
|
||||
{ type: "predicate", name: "breaks_rules", instructions: "Does this comment break the community guidelines?" },
|
||||
]);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Clear" }));
|
||||
expect(JSON.parse(editor.value)).toEqual({ input: "", questions: [] });
|
||||
expect(await readCurlPayload()).toEqual({ input: "", questions: [] });
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
});
|
||||
|
||||
|
|
@ -205,17 +204,11 @@ describe("SystemOneUI integration", () => {
|
|||
expect(screen.queryByText(/TypeSafe/)).not.toBeInTheDocument();
|
||||
await userEvent.keyboard("{Escape}");
|
||||
|
||||
expect(screen.getByRole("note", { name: "Decision endpoint notice" })).toHaveTextContent(
|
||||
"omit model to use the proxy's configured default.",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "How to call /v1/decisions and /v1/systemone" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://docs.litellm.ai/docs/decisions",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Give us feedback on what you want for decision models" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/BerriAI/litellm/discussions/44231",
|
||||
);
|
||||
expect(screen.queryByRole("note")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -242,91 +235,79 @@ describe("SystemOneUI integration", () => {
|
|||
mockModelLookup.mockResolvedValue(modelInfoResponse(DECISION_AND_CHAT_MODELS));
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
await waitFor(() => expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveValue("jev-latest"));
|
||||
const systemOneEditor = await openJsonEditor();
|
||||
const systemOneDraft = JSON.stringify({
|
||||
model: "jev-latest",
|
||||
state: "An outage",
|
||||
questions: { urgent: { type: "noul", instructions: "Is it urgent?" } },
|
||||
});
|
||||
fireEvent.change(systemOneEditor, { target: { value: systemOneDraft } });
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Input" }), { target: { value: "An outage" } });
|
||||
|
||||
await selectEndpoint("/v1/decisions");
|
||||
const editor = screen.getByRole("textbox", { name: "Decisions JSON payload" });
|
||||
const nativeExample = (editor as HTMLTextAreaElement).value;
|
||||
expect(JSON.parse(nativeExample)).toMatchObject({
|
||||
expect(screen.getByRole("textbox", { name: "Input" })).not.toHaveValue("An outage");
|
||||
expect(await readCurlPayload()).toMatchObject({
|
||||
input: expect.any(String),
|
||||
questions: expect.arrayContaining([expect.objectContaining({ type: "predicate" })]),
|
||||
});
|
||||
expect(screen.getByRole("tab", { name: "Form" })).toBeInTheDocument();
|
||||
const payload = {
|
||||
model: "jev-latest",
|
||||
input: "An outage",
|
||||
questions: [{ name: "urgent", type: "predicate", instructions: "Is it urgent?" }],
|
||||
safety_identifier: "qa-session",
|
||||
};
|
||||
fireEvent.change(editor, { target: { value: JSON.stringify(payload) } });
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Input" }), { target: { value: "An outage" } });
|
||||
const response: OpenAIDecisionsResponse = {
|
||||
model: "jev-latest",
|
||||
answers: [{ name: "urgent", type: "predicate", probability: 0.95 }],
|
||||
answers: [{ name: "has_repro_steps", type: "predicate", probability: 0.95 }],
|
||||
usage: { input_tokens: 10, output_tokens: 1, total_tokens: 11 },
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(createResponse(response));
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
const result = await screen.findByRole("region", { name: "Decisions response JSON" });
|
||||
expect(await screen.findByRole("meter", { name: "Yes probability" })).toHaveAttribute("aria-valuenow", "95");
|
||||
expect(screen.queryByRole("region", { name: "Decisions response JSON" })).not.toBeInTheDocument();
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toMatch(/\/v1\/decisions$/);
|
||||
expect(JSON.parse(mockFetch.mock.calls[0]?.[1]?.body as string)).toMatchObject({
|
||||
model: "jev-latest",
|
||||
input: "An outage",
|
||||
questions: expect.arrayContaining([expect.objectContaining({ type: "predicate" })]),
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "cURL" }));
|
||||
const result = screen.getByRole("region", { name: "Decisions response JSON" });
|
||||
expect(result).toHaveTextContent('"probability": 0.95');
|
||||
expect(result).toHaveTextContent('"answers": [');
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toMatch(/\/v1\/decisions$/);
|
||||
expect(JSON.parse(mockFetch.mock.calls[0]?.[1]?.body as string)).toEqual(payload);
|
||||
|
||||
expect(screen.queryByRole("meter")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("tab", { name: "Form" }));
|
||||
expect(screen.getByRole("meter", { name: "Yes probability" })).toHaveAttribute("aria-valuenow", "95");
|
||||
expect(screen.queryByRole("region", { name: "Decisions response JSON" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("tab", { name: "JSON" }));
|
||||
expect(screen.getByRole("region", { name: "Decisions response JSON" })).toHaveTextContent('"probability": 0.95');
|
||||
expect(screen.getByRole("meter", { name: "Yes probability" })).toBeInTheDocument();
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
await selectEndpoint("/v1/systemone");
|
||||
expect(screen.getByRole("textbox", { name: "System One JSON payload" })).toHaveValue(systemOneDraft);
|
||||
expect(screen.queryByRole("region", { name: "Decisions response JSON" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Input" })).toHaveValue("An outage");
|
||||
expect(screen.queryByRole("region", { name: "Decisions results" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
expect(await screen.findByRole("region", { name: "Decisions response JSON" })).toHaveTextContent(
|
||||
'"choice": "technical"',
|
||||
);
|
||||
expect(screen.queryByRole("meter")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("tab", { name: "Form" }));
|
||||
expect(screen.getByText("Selected choice")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Selected choice")).toBeInTheDocument();
|
||||
expect(screen.getByRole("meter", { name: "technical probability" })).toHaveAttribute("aria-valuenow", "90");
|
||||
await user.click(screen.getByRole("tab", { name: "JSON" }));
|
||||
await user.click(screen.getByRole("tab", { name: "cURL" }));
|
||||
expect(screen.getByRole("region", { name: "Decisions response JSON" })).toHaveTextContent('"choice": "technical"');
|
||||
expect(screen.queryByRole("meter")).not.toBeInTheDocument();
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetch.mock.calls[1]?.[0]).toMatch(/\/v1\/systemone$/);
|
||||
expect(JSON.parse(mockFetch.mock.calls[1]?.[1]?.body as string)).toEqual(JSON.parse(systemOneDraft));
|
||||
expect(JSON.parse(mockFetch.mock.calls[1]?.[1]?.body as string)).toMatchObject({
|
||||
model: "jev-latest",
|
||||
state: "An outage",
|
||||
});
|
||||
|
||||
await selectEndpoint("/v1/decisions");
|
||||
expect(screen.getByRole("textbox", { name: "Decisions JSON payload" })).toHaveValue(JSON.stringify(payload));
|
||||
expect(await readCurlPayload()).toMatchObject({ input: "An outage" });
|
||||
await selectPreset("Triage a bug report");
|
||||
expect(screen.getByRole("textbox", { name: "Decisions JSON payload" })).toHaveValue(nativeExample);
|
||||
expect(screen.getByRole("textbox", { name: "Input" })).not.toHaveValue("An outage");
|
||||
await selectEndpoint("/v1/systemone");
|
||||
expect(screen.getByRole("textbox", { name: "System One JSON payload" })).toHaveValue(systemOneDraft);
|
||||
expect(screen.getByRole("textbox", { name: "Input" })).toHaveValue("An outage");
|
||||
});
|
||||
|
||||
it("validates /v1/decisions independently and preserves an invalid draft across switches", async () => {
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
await selectEndpoint("/v1/decisions");
|
||||
await userEvent.click(screen.getByRole("tab", { name: "JSON" }));
|
||||
const editor = screen.getByRole("textbox", { name: "Decisions JSON payload" });
|
||||
fireEvent.change(editor, { target: { value: "{" } });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Clear" }));
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
await selectEndpoint("/v1/systemone");
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeEnabled();
|
||||
await selectEndpoint("/v1/decisions");
|
||||
expect(screen.getByRole("textbox", { name: "Decisions JSON payload" })).toHaveValue("{");
|
||||
expect(screen.getByRole("textbox", { name: "Input" })).toHaveValue("");
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers a Decisions form that sends input and typed questions and stays in sync with JSON", async () => {
|
||||
it("offers a Decisions form that sends input and typed questions and mirrors them in cURL", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
createResponse({
|
||||
|
|
@ -338,7 +319,6 @@ describe("SystemOneUI integration", () => {
|
|||
render(<SystemOneUI accessToken="session-key" />);
|
||||
await selectEndpoint("/v1/decisions");
|
||||
expect(screen.getByRole("tab", { name: "Form" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.queryByRole("button", { name: "Format JSON" })).not.toBeInTheDocument();
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Input" }), { target: { value: "Login is broken" } });
|
||||
const firstQuestion = within(screen.getByRole("group", { name: "Question 1" }));
|
||||
fireEvent.change(firstQuestion.getByRole("textbox", { name: "Question name" }), { target: { value: "team" } });
|
||||
|
|
@ -351,9 +331,7 @@ describe("SystemOneUI integration", () => {
|
|||
fireEvent.change(screen.getByRole("textbox", { name: "Safety identifier" }), {
|
||||
target: { value: "qa-session" },
|
||||
});
|
||||
await user.click(screen.getByRole("tab", { name: "JSON" }));
|
||||
const editor = screen.getByRole("textbox", { name: "Decisions JSON payload" });
|
||||
const payload = JSON.parse((editor as HTMLTextAreaElement).value);
|
||||
const payload = (await readCurlPayload()) as { questions: unknown[] };
|
||||
expect(payload).toMatchObject({ input: "Login is broken", safety_identifier: "qa-session" });
|
||||
const expectedQuestion = {
|
||||
name: "team",
|
||||
|
|
@ -363,15 +341,12 @@ describe("SystemOneUI integration", () => {
|
|||
};
|
||||
expect(payload.questions[0]).toMatchObject(expectedQuestion);
|
||||
expect(payload).not.toHaveProperty("state");
|
||||
fireEvent.change(editor, { target: { value: JSON.stringify({ ...payload, input: "Fixed in JSON" }) } });
|
||||
await user.click(screen.getByRole("tab", { name: "Form" }));
|
||||
expect(screen.getByRole("textbox", { name: "Input" })).toHaveValue("Fixed in JSON");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toMatch(/\/v1\/decisions$/);
|
||||
expect(JSON.parse(mockFetch.mock.calls[0]?.[1]?.body as string)).toEqual({ ...payload, input: "Fixed in JSON" });
|
||||
expect(JSON.parse(mockFetch.mock.calls[0]?.[1]?.body as string)).toEqual(payload);
|
||||
expect(await screen.findByRole("button", { name: "Raw response" })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("tab", { name: "JSON" }));
|
||||
await user.click(screen.getByRole("tab", { name: "cURL" }));
|
||||
expect(screen.getByRole("region", { name: "Decisions response JSON" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -399,10 +374,7 @@ describe("SystemOneUI integration", () => {
|
|||
target: { value: "Not urgent" },
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeEnabled();
|
||||
await user.click(screen.getByRole("tab", { name: "JSON" }));
|
||||
const choicePayload = JSON.parse(
|
||||
(screen.getByRole("textbox", { name: "Decisions JSON payload" }) as HTMLTextAreaElement).value,
|
||||
);
|
||||
const choicePayload = (await readCurlPayload()) as { questions: Record<string, unknown>[] };
|
||||
const expectedChoice = {
|
||||
type: "choice",
|
||||
name: "question_1",
|
||||
|
|
@ -421,10 +393,7 @@ describe("SystemOneUI integration", () => {
|
|||
expect(question(4).getByRole("textbox", { name: "Level 2 label" })).toHaveValue("2");
|
||||
await user.click(question(2).getByRole("button", { name: "Remove question 2" }));
|
||||
expect(question(3).getByRole("textbox", { name: "Instructions" })).toHaveValue("Should we escalate?");
|
||||
await user.click(screen.getByRole("tab", { name: "JSON" }));
|
||||
const scorePayload = JSON.parse(
|
||||
(screen.getByRole("textbox", { name: "Decisions JSON payload" }) as HTMLTextAreaElement).value,
|
||||
);
|
||||
const scorePayload = (await readCurlPayload()) as { questions: Record<string, unknown>[] };
|
||||
expect(scorePayload.questions).toHaveLength(3);
|
||||
const expectedScore = {
|
||||
type: "score",
|
||||
|
|
@ -435,10 +404,7 @@ describe("SystemOneUI integration", () => {
|
|||
expect(scorePayload.questions[2]).toEqual(expectedScore);
|
||||
await user.click(screen.getByRole("tab", { name: "Form" }));
|
||||
await pick(question(3).getByRole("combobox", { name: "Answer type" }), "Predicate");
|
||||
await user.click(screen.getByRole("tab", { name: "JSON" }));
|
||||
const predicatePayload = JSON.parse(
|
||||
(screen.getByRole("textbox", { name: "Decisions JSON payload" }) as HTMLTextAreaElement).value,
|
||||
);
|
||||
const predicatePayload = (await readCurlPayload()) as { questions: Record<string, unknown>[] };
|
||||
expect(predicatePayload.questions[2]).toEqual({
|
||||
type: "predicate",
|
||||
name: "question_1",
|
||||
|
|
@ -446,60 +412,6 @@ describe("SystemOneUI integration", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("preserves message input and extra fields in the Decisions form, including across routes and reset", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
await selectEndpoint("/v1/decisions");
|
||||
await user.click(screen.getByRole("tab", { name: "JSON" }));
|
||||
const payload = {
|
||||
model: "jev-latest",
|
||||
metadata: { source: "playground" },
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_text", text: "Is this urgent?" },
|
||||
{ type: "input_image", image_url: "https://example.com/image.png" },
|
||||
],
|
||||
},
|
||||
],
|
||||
questions: [{ type: "predicate", name: null, instructions: "Old instructions" }],
|
||||
};
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Decisions JSON payload" }), {
|
||||
target: { value: JSON.stringify(payload) },
|
||||
});
|
||||
await user.click(screen.getByRole("tab", { name: "Form" }));
|
||||
expect(screen.getByRole("button", { name: "Edit input in JSON" })).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Instructions" }), { target: { value: "Is it urgent?" } });
|
||||
await selectEndpoint("/v1/systemone");
|
||||
const nativeInput = (screen.getByRole("textbox", { name: "Input" }) as HTMLTextAreaElement).value;
|
||||
await selectEndpoint("/v1/decisions");
|
||||
expect(screen.getByRole("textbox", { name: "Instructions" })).toHaveValue("Is it urgent?");
|
||||
await user.click(screen.getByRole("button", { name: "Edit input in JSON" }));
|
||||
const next = JSON.parse(
|
||||
(screen.getByRole("textbox", { name: "Decisions JSON payload" }) as HTMLTextAreaElement).value,
|
||||
);
|
||||
expect(next).toEqual({ ...payload, questions: [{ ...payload.questions[0], instructions: "Is it urgent?" }] });
|
||||
await user.click(screen.getByRole("tab", { name: "Form" }));
|
||||
await selectPreset("Triage a bug report");
|
||||
expect(screen.getByRole("textbox", { name: "Input" })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("group", { name: /Question \d/ })).toHaveLength(3);
|
||||
await selectEndpoint("/v1/systemone");
|
||||
expect(screen.getByRole("textbox", { name: "Input" })).toHaveValue(nativeInput);
|
||||
});
|
||||
|
||||
it("keeps malformed Decisions JSON intact when switching to its form", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
await selectEndpoint("/v1/decisions");
|
||||
await user.click(screen.getByRole("tab", { name: "JSON" }));
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Decisions JSON payload" }), { target: { value: "{" } });
|
||||
await user.click(screen.getByRole("tab", { name: "Form" }));
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
await user.click(screen.getByRole("button", { name: "Edit in JSON" }));
|
||||
expect(screen.getByRole("textbox", { name: "Decisions JSON payload" })).toHaveValue("{");
|
||||
});
|
||||
|
||||
it.each(["success", "error"])("cancels the previous endpoint request and ignores a late %s", async (outcome) => {
|
||||
const pending = Promise.withResolvers<Response>();
|
||||
mockFetch.mockReturnValueOnce(pending.promise);
|
||||
|
|
@ -540,8 +452,7 @@ describe("SystemOneUI integration", () => {
|
|||
expect(screen.queryByRole("option", { name: "gpt-5.5" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("option", { name: "pplx-decider" }));
|
||||
|
||||
const editor = await openJsonEditor();
|
||||
expect(JSON.parse((editor as HTMLTextAreaElement).value)).toMatchObject({
|
||||
expect(await readCurlPayload()).toMatchObject({
|
||||
model: "pplx-decider",
|
||||
questions: { has_repro_steps: { type: "noul" } },
|
||||
});
|
||||
|
|
@ -629,16 +540,14 @@ describe("SystemOneUI integration", () => {
|
|||
const models = Promise.withResolvers<Response>();
|
||||
mockModelLookup.mockReturnValue(models.promise);
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
const editor = await openJsonEditor();
|
||||
const draft = JSON.stringify({ state: "edited", questions: { q: { type: "noul", instructions: "Yes?" } } });
|
||||
fireEvent.change(editor, { target: { value: draft } });
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Input" }), { target: { value: "edited" } });
|
||||
|
||||
await act(async () => {
|
||||
models.resolve(modelInfoResponse(DECISION_AND_CHAT_MODELS));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(editor).toHaveValue(draft);
|
||||
expect(screen.getByRole("textbox", { name: "Input" })).toHaveValue("edited");
|
||||
expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveValue("");
|
||||
});
|
||||
|
||||
|
|
@ -670,15 +579,46 @@ describe("SystemOneUI integration", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it("shows invalid JSON and disables Send", async () => {
|
||||
it("shows a read-only cURL command for the proxy with a key placeholder instead of the real key", async () => {
|
||||
const user = userEvent.setup();
|
||||
sessionStorage.setItem("customProxyBaseUrl", "https://proxy.example/");
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
await user.click(screen.getByRole("tab", { name: "cURL" }));
|
||||
|
||||
const command = screen.getByLabelText("cURL request");
|
||||
expect(command).toHaveTextContent("curl -X POST 'https://proxy.example/v1/systemone'");
|
||||
expect(command).toHaveTextContent("Authorization: Bearer $LITELLM_API_KEY");
|
||||
expect(command).not.toHaveTextContent("session-key");
|
||||
expect(JSON.parse(curlPayload(command.textContent ?? "") ?? "")).toMatchObject({
|
||||
questions: { has_repro_steps: { type: "noul" } },
|
||||
});
|
||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Copy cURL" })).toBeInTheDocument();
|
||||
|
||||
await selectEndpoint("/v1/decisions");
|
||||
expect(screen.getByLabelText("cURL request")).toHaveTextContent("https://proxy.example/v1/decisions");
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("keeps Send below the form and shows results only after a request", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
|
||||
fireEvent.change(await openJsonEditor(), {
|
||||
target: { value: "{" },
|
||||
});
|
||||
const send = screen.getByRole("button", { name: "Send" });
|
||||
const editor = screen.getByRole("region", { name: "Decisions request editor" });
|
||||
const form = within(editor).getByRole("textbox", { name: "Input" });
|
||||
expect(form.compareDocumentPosition(send) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(screen.queryByRole("region", { name: "Decisions results" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("note")).not.toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByText(/Invalid JSON syntax/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
await user.click(send);
|
||||
|
||||
const results = await screen.findByRole("region", { name: "Decisions results" });
|
||||
expect(within(results).getByText("Calibrated probabilities")).toBeInTheDocument();
|
||||
expect(editor.compareDocumentPosition(results) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
|
||||
fireEvent.change(within(editor).getByRole("textbox", { name: "Input" }), { target: { value: "Edited state" } });
|
||||
expect(screen.queryByRole("region", { name: "Decisions results" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends to /v1/systemone even without discovered models and renders calibrated answers", async () => {
|
||||
|
|
@ -720,64 +660,16 @@ describe("SystemOneUI integration", () => {
|
|||
expect(screen.getByText("Selected choice")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("validates native question values before sending", async () => {
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
|
||||
fireEvent.change(await openJsonEditor(), {
|
||||
target: {
|
||||
value: JSON.stringify({
|
||||
state: "A new support request",
|
||||
questions: {
|
||||
category: {
|
||||
type: "choice",
|
||||
instructions: 1,
|
||||
criteria: { support: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Form" }));
|
||||
expect(screen.getByText("Enter a valid request to preview its state and questions.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends structured native questions without changing their payload", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
const editor = await openJsonEditor();
|
||||
const draft = JSON.stringify({
|
||||
model: "my-decider",
|
||||
state: {},
|
||||
questions: {
|
||||
route: { type: "choice", criteria: { support: { text: "Help" }, other: null } },
|
||||
},
|
||||
});
|
||||
fireEvent.change(editor, { target: { value: draft } });
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeEnabled();
|
||||
await user.click(screen.getByRole("tab", { name: "Form" }));
|
||||
expect(screen.getByText(/"text": "Help"/)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
expect(await screen.findByText("Selected choice")).toBeInTheDocument();
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toMatch(/\/v1\/systemone$/);
|
||||
expect(JSON.parse(mockFetch.mock.calls[0]?.[1]?.body as string)).toEqual(JSON.parse(draft));
|
||||
});
|
||||
|
||||
it("sends a native request without model so the proxy can select its default", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
const payload = { state: "An outage", questions: { urgent: { type: "noul", instructions: "Is this urgent?" } } };
|
||||
fireEvent.change(await openJsonEditor(), {
|
||||
target: { value: JSON.stringify(payload) },
|
||||
});
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Input" }), { target: { value: "An outage" } });
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeEnabled();
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
expect(await screen.findByText("jev-1.13.0")).toBeInTheDocument();
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toMatch(/\/v1\/systemone$/);
|
||||
const body = JSON.parse(mockFetch.mock.calls[0]?.[1]?.body as string);
|
||||
expect(body).toEqual(payload);
|
||||
expect(body).toMatchObject({ state: "An outage" });
|
||||
expect(body).not.toHaveProperty("model");
|
||||
});
|
||||
|
||||
|
|
@ -872,19 +764,8 @@ describe("SystemOneUI integration", () => {
|
|||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
expect(await screen.findByText("Selected choice")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(await openJsonEditor(), {
|
||||
target: {
|
||||
value: JSON.stringify({
|
||||
state: "A different support request",
|
||||
questions: {
|
||||
department: {
|
||||
type: "choice",
|
||||
instructions: "Where should this go?",
|
||||
criteria: { billing: "Billing questions" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Input" }), {
|
||||
target: { value: "A different support request" },
|
||||
});
|
||||
|
||||
expect(screen.queryByText("Selected choice")).not.toBeInTheDocument();
|
||||
|
|
@ -948,13 +829,10 @@ describe("SystemOneUI integration", () => {
|
|||
fireEvent.blur(field);
|
||||
}
|
||||
|
||||
it("explains noul on hover in the banner and on the answer type option", async () => {
|
||||
it("explains noul on the answer type option", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
|
||||
await user.hover(screen.getByText("noul", { selector: "span.cursor-help" }));
|
||||
expect(await screen.findByText(NOUL_HELP)).toBeInTheDocument();
|
||||
|
||||
question("has_repro_steps").getByRole("combobox", { name: "Answer type" }).focus();
|
||||
await user.keyboard("{ArrowDown}");
|
||||
expect(await screen.findByRole("option", { name: "noul" })).toHaveAttribute(
|
||||
|
|
@ -993,7 +871,6 @@ describe("SystemOneUI integration", () => {
|
|||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
expect(await screen.findByText("Selected choice")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("textbox", { name: "System One JSON payload" })).not.toBeInTheDocument();
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toMatch(/\/v1\/systemone$/);
|
||||
expect(sentBody()).toEqual({
|
||||
state: "The login page is blank after the upgrade",
|
||||
|
|
@ -1066,39 +943,8 @@ describe("SystemOneUI integration", () => {
|
|||
expect(question("impact").getByRole("textbox", { name: "Question name" })).toHaveValue("impact");
|
||||
await user.click(screen.getByRole("button", { name: "Add question" }));
|
||||
await user.click(screen.getByRole("button", { name: "Add question" }));
|
||||
const json = JSON.parse(((await openJsonEditor()) as HTMLTextAreaElement).value);
|
||||
const json = (await readCurlPayload()) as { questions: Record<string, unknown> };
|
||||
expect(Object.keys(json.questions)).toEqual(["area", "has_repro_steps", "impact", "question_1", "question_2"]);
|
||||
});
|
||||
|
||||
it("round-trips unknown fields between JSON and the form and points to JSON when it cannot show one", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
expect(screen.queryByRole("button", { name: "Format JSON" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(await openJsonEditor(), {
|
||||
target: { value: JSON.stringify({ state: { ticket: 1 }, questions: {} }) },
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Format JSON" })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("tab", { name: "Form" }));
|
||||
expect(screen.getByText(/This request has JSON the form can.t show/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Edit in JSON" }));
|
||||
|
||||
const payload = {
|
||||
model: "m",
|
||||
state: "s",
|
||||
metadata: { k: 1 },
|
||||
questions: { q: { type: "noul", instructions: "i", note: "x" } },
|
||||
};
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "System One JSON payload" }), {
|
||||
target: { value: JSON.stringify(payload) },
|
||||
});
|
||||
await user.click(screen.getByRole("tab", { name: "Form" }));
|
||||
expect(screen.getByRole("textbox", { name: "Input" })).toHaveValue("s");
|
||||
fill(question("q").getByRole("textbox", { name: "Yes means" }), "y");
|
||||
|
||||
const json = JSON.parse(((await openJsonEditor()) as HTMLTextAreaElement).value);
|
||||
expect(json).toEqual({ ...payload, questions: { q: { ...payload.questions.q, criteria: { true: "y" } } } });
|
||||
expect(Object.keys(json)).toEqual(["model", "state", "metadata", "questions"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
"use client";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { DECISIONS_DOCS_URL } from "@/lib/decisionModels";
|
||||
import { getGlobalLitellmHeaderName, getProxyBaseUrl } from "@/components/networking";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Code, Eraser, Info, LoaderCircle, Send } from "lucide-react";
|
||||
import { BookOpen, Eraser, LoaderCircle, Send } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { makePlaygroundDecisionRequest, type PlaygroundDecisionRequest } from "../../llm_calls/system_one";
|
||||
import { DECISION_PRESETS, emptyPayload, matchPreset, presetPayload, type DecisionPreset } from "./lib/example";
|
||||
import { payloadModel, withPayloadModel } from "./lib/payloadModel";
|
||||
import JsonEditor from "./JsonEditor";
|
||||
import QuestionBreakdown from "./QuestionBreakdown";
|
||||
import CurlPreview from "./CurlPreview";
|
||||
import ResponseView from "./ResponseView";
|
||||
import SystemOneForm from "./SystemOneForm";
|
||||
import OpenAIDecisionsForm from "./OpenAIDecisionsForm";
|
||||
import { validateSystemOnePayload, validateOpenAIDecisionsPayload } from "./lib/validatePayload";
|
||||
import { validateSystemOnePayload, validateOpenAIDecisionsPayload, errorCount } from "./lib/validatePayload";
|
||||
import { useDecisionModels, type ApiKeySource } from "./useDecisionModels";
|
||||
|
||||
interface SystemOneUIProps {
|
||||
|
|
@ -28,7 +28,7 @@ interface SystemOneUIProps {
|
|||
disabledPersonalKeyCreation?: boolean;
|
||||
}
|
||||
|
||||
type EditorView = "form" | "json";
|
||||
type EditorView = "form" | "curl";
|
||||
|
||||
interface SystemOneSendVariables {
|
||||
request: PlaygroundDecisionRequest;
|
||||
|
|
@ -38,7 +38,6 @@ interface SystemOneSendVariables {
|
|||
|
||||
export const NOUL_HELP = "noul is a yes / no question. The model returns the probability that the answer is yes.";
|
||||
const CUSTOM_PRESET = "custom";
|
||||
const DECISION_MODELS_DISCUSSION_URL = "https://github.com/BerriAI/litellm/discussions/44231";
|
||||
|
||||
interface PresetPickerProps {
|
||||
activePreset: DecisionPreset | undefined;
|
||||
|
|
@ -96,13 +95,13 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
|
|||
[rawPayload, endpoint],
|
||||
);
|
||||
const validation = validated.validation;
|
||||
const hasSyntaxError = validation.issues.some((issue) => issue.path === "syntax");
|
||||
|
||||
const systemOne = useMutation({
|
||||
mutationFn: ({ request, apiKey, signal }: SystemOneSendVariables) =>
|
||||
makePlaygroundDecisionRequest(request, apiKey, getCustomProxyBaseUrl(), { signal }),
|
||||
});
|
||||
const isLoading = systemOne.isPending;
|
||||
const hasResult = isLoading || systemOne.data !== undefined || systemOne.error !== null;
|
||||
const { reset: resetSystemOne } = systemOne;
|
||||
|
||||
useEffect(() => () => activeController.current?.abort(), []);
|
||||
|
|
@ -119,6 +118,10 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
|
|||
systemOne.reset();
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
handlePayloadChange(presetPayload(DECISION_PRESETS[0], endpoint, draftModel ?? decisionModels[0]));
|
||||
}
|
||||
|
||||
function handlePayloadChange(value: string) {
|
||||
if (value !== rawPayload) {
|
||||
clearRequestState();
|
||||
|
|
@ -140,12 +143,6 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
|
|||
}
|
||||
}
|
||||
|
||||
function handleFormatJson() {
|
||||
if (rawPayload.trim() && !hasSyntaxError) {
|
||||
handlePayloadChange(JSON.stringify(JSON.parse(rawPayload), null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
function handleSend() {
|
||||
const request: PlaygroundDecisionRequest | undefined =
|
||||
validated.endpoint === "/v1/decisions"
|
||||
|
|
@ -163,49 +160,48 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-4 overflow-auto p-4 xl:overflow-hidden">
|
||||
<section className="grid gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Endpoint</span>
|
||||
<Select
|
||||
value={endpoint}
|
||||
onValueChange={(value) => {
|
||||
if (value === "/v1/decisions" || value === "/v1/systemone") {
|
||||
clearRequestState();
|
||||
setEndpoint(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-80" aria-label="Decision endpoint">
|
||||
<SelectValue>{endpoint}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="/v1/decisions">/v1/decisions</SelectItem>
|
||||
<SelectItem value="/v1/systemone">/v1/systemone</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="ml-2 text-sm font-medium text-muted-foreground">Model</span>
|
||||
<div className="w-80">
|
||||
<SearchSelect
|
||||
aria-label="Decision model"
|
||||
options={decisionModels.map((model) => ({ label: model, value: model }))}
|
||||
value={payloadModel(rawPayload) ?? null}
|
||||
onValueChange={handleModelPick}
|
||||
placeholder={decisionModels.length > 0 ? "Pick a decision model" : "No decision models yet"}
|
||||
emptyText="No decision models found"
|
||||
disabled={hasSyntaxError}
|
||||
allowClear={false}
|
||||
/>
|
||||
</div>
|
||||
{decisionModelsLoaded && decisionModels.length === 0 && (
|
||||
<a href={uiHref("models-and-endpoints")} className="text-sm text-primary underline">
|
||||
Add a decision model
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex h-full min-h-0 flex-col overflow-auto p-4 xl:overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto grid w-full gap-4 xl:min-h-0 xl:flex-1",
|
||||
hasResult ? "max-w-7xl xl:grid-cols-2" : "max-w-3xl",
|
||||
)}
|
||||
>
|
||||
<section className="flex min-h-96 flex-col gap-3 xl:min-h-0" aria-label="Decisions request editor">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Virtual Key Source</span>
|
||||
<Select
|
||||
value={endpoint}
|
||||
onValueChange={(value) => {
|
||||
if (value === "/v1/decisions" || value === "/v1/systemone") {
|
||||
clearRequestState();
|
||||
setEndpoint(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-44" aria-label="Decision endpoint">
|
||||
<SelectValue>{endpoint}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="/v1/decisions">/v1/decisions</SelectItem>
|
||||
<SelectItem value="/v1/systemone">/v1/systemone</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="w-56">
|
||||
<SearchSelect
|
||||
aria-label="Decision model"
|
||||
options={decisionModels.map((model) => ({ label: model, value: model }))}
|
||||
value={payloadModel(rawPayload) ?? null}
|
||||
onValueChange={handleModelPick}
|
||||
placeholder={decisionModels.length > 0 ? "Pick a decision model" : "No decision models yet"}
|
||||
emptyText="No decision models found"
|
||||
allowClear={false}
|
||||
/>
|
||||
</div>
|
||||
{decisionModelsLoaded && decisionModels.length === 0 && (
|
||||
<a href={uiHref("models-and-endpoints")} className="text-sm text-primary underline">
|
||||
Add a decision model
|
||||
</a>
|
||||
)}
|
||||
<Select
|
||||
value={apiKeySource}
|
||||
onValueChange={(value) => {
|
||||
|
|
@ -215,7 +211,7 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
|
|||
}}
|
||||
disabled={disabledPersonalKeyCreation}
|
||||
>
|
||||
<SelectTrigger className="w-48" aria-label="Virtual Key Source">
|
||||
<SelectTrigger className="w-44" aria-label="Virtual Key Source">
|
||||
<SelectValue>{apiKeySource === "custom" ? "Virtual Key" : "Current UI Session"}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -236,112 +232,100 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<PresetPicker activePreset={activePreset} onPick={handlePresetPick} />
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handlePayloadChange(clearedPayload)}
|
||||
disabled={rawPayload === clearedPayload}
|
||||
>
|
||||
<Eraser />
|
||||
Clear
|
||||
</Button>
|
||||
{view === "json" && (
|
||||
<Button variant="outline" onClick={handleFormatJson} disabled={!rawPayload.trim() || hasSyntaxError}>
|
||||
<Code />
|
||||
Format JSON
|
||||
</Button>
|
||||
)}
|
||||
{isLoading && (
|
||||
<Button variant="outline" onClick={clearRequestState}>
|
||||
Cancel request
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleSend} disabled={!validation.isValid || isLoading || !effectiveApiKey}>
|
||||
{isLoading ? <LoaderCircle className="animate-spin" /> : <Send />}
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Alert role="note" aria-label="Decision endpoint notice">
|
||||
<Info />
|
||||
<AlertTitle>{endpoint}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{endpoint === "/v1/decisions" ? (
|
||||
"Uses input and a questions array with predicate, choice, and score questions. Edit the form or JSON and inspect the response JSON in the OpenAI Decisions format."
|
||||
) : (
|
||||
<>
|
||||
Uses state and a questions object with{" "}
|
||||
<SimpleTooltip content={NOUL_HELP} className="underline decoration-dotted underline-offset-2">
|
||||
noul
|
||||
</SimpleTooltip>
|
||||
, choice, and score questions in the System One format.
|
||||
</>
|
||||
)}{" "}
|
||||
Pick a decision model under Model, or omit model to use the proxy's configured default.{" "}
|
||||
<a href={DECISIONS_DOCS_URL} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
How to call /v1/decisions and /v1/systemone
|
||||
</a>
|
||||
{" · "}
|
||||
<a href={DECISION_MODELS_DISCUSSION_URL} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
Give us feedback on what you want for decision models
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 xl:min-h-0 xl:flex-1 xl:grid-cols-2">
|
||||
<section className="flex min-h-96 flex-col xl:min-h-0" aria-label="Decisions request editor">
|
||||
<Tabs
|
||||
value={view}
|
||||
onValueChange={(value) => (value === "form" || value === "json") && setView(value)}
|
||||
onValueChange={(value) => (value === "form" || value === "curl") && setView(value)}
|
||||
className="min-h-0 flex-1"
|
||||
>
|
||||
<TabsList aria-label="Request editor view">
|
||||
<TabsTrigger value="form">Form</TabsTrigger>
|
||||
<TabsTrigger value="json">JSON</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<TabsList aria-label="Request editor view">
|
||||
<TabsTrigger value="form">Form</TabsTrigger>
|
||||
<TabsTrigger value="curl">cURL</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-1">
|
||||
<PresetPicker activePreset={activePreset} onPick={handlePresetPick} />
|
||||
<SimpleTooltip content="How to call /v1/decisions and /v1/systemone">
|
||||
<a
|
||||
href={DECISIONS_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="How to call /v1/decisions and /v1/systemone"
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "shrink-0")}
|
||||
>
|
||||
<BookOpen />
|
||||
</a>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent value="form" className="flex min-h-0 flex-col">
|
||||
{validated.endpoint === "/v1/decisions" ? (
|
||||
<OpenAIDecisionsForm
|
||||
value={rawPayload}
|
||||
onChange={handlePayloadChange}
|
||||
validation={validated.validation}
|
||||
onOpenJson={() => setView("json")}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
) : (
|
||||
<SystemOneForm
|
||||
value={rawPayload}
|
||||
onChange={handlePayloadChange}
|
||||
validation={validated.validation}
|
||||
onOpenJson={() => setView("json")}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="json" className="flex min-h-0 flex-col">
|
||||
<JsonEditor
|
||||
value={rawPayload}
|
||||
onChange={handlePayloadChange}
|
||||
validation={validation}
|
||||
label={endpoint === "/v1/decisions" ? "Decisions JSON payload" : "System One JSON payload"}
|
||||
placeholder={`Paste or write a ${endpoint} request`}
|
||||
<TabsContent value="curl" className="flex min-h-0 flex-col">
|
||||
<CurlPreview
|
||||
baseUrl={getCustomProxyBaseUrl() ?? getProxyBaseUrl()}
|
||||
endpoint={endpoint}
|
||||
payload={validation.payload === undefined ? rawPayload : JSON.stringify(validation.payload)}
|
||||
authHeaderName={getGlobalLitellmHeaderName()}
|
||||
errorCount={errorCount(validation)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => handlePayloadChange(clearedPayload)}
|
||||
disabled={rawPayload === clearedPayload}
|
||||
>
|
||||
<Eraser />
|
||||
Clear
|
||||
</Button>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{isLoading && (
|
||||
<Button variant="outline" onClick={clearRequestState}>
|
||||
Cancel request
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="lg"
|
||||
className="min-w-32"
|
||||
onClick={handleSend}
|
||||
disabled={!validation.isValid || isLoading || !effectiveApiKey}
|
||||
>
|
||||
{isLoading ? <LoaderCircle className="animate-spin" /> : <Send />}
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="grid content-start gap-4 xl:min-h-0 xl:overflow-auto" aria-label="Decisions results">
|
||||
<ResponseView
|
||||
response={systemOne.data?.response}
|
||||
view={view}
|
||||
fallbackModel={validation.payload?.model}
|
||||
latencyMs={systemOne.data?.latencyMs}
|
||||
error={systemOne.error?.message}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
{view === "form" && validated.endpoint === "/v1/systemone" && (
|
||||
<QuestionBreakdown payload={validated.validation.payload} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
{hasResult && (
|
||||
<section className="grid content-start gap-4 xl:min-h-0 xl:overflow-auto" aria-label="Decisions results">
|
||||
<ResponseView
|
||||
response={systemOne.data?.response}
|
||||
view={view}
|
||||
fallbackModel={validation.payload?.model}
|
||||
latencyMs={systemOne.data?.latencyMs}
|
||||
error={systemOne.error?.message}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { IssueList, ValidationStatus } from "./ValidationFeedback";
|
||||
import type { PayloadValidation } from "./lib/validatePayload";
|
||||
|
||||
const validation = (issues: PayloadValidation<unknown>["issues"]): PayloadValidation<unknown> => ({
|
||||
isValid: issues.every((issue) => issue.severity !== "error"),
|
||||
issues,
|
||||
});
|
||||
|
||||
describe("ValidationStatus", () => {
|
||||
it("counts only errors as issues", () => {
|
||||
render(
|
||||
<ValidationStatus
|
||||
validation={validation([
|
||||
{ path: "state", message: "Required", severity: "error" },
|
||||
{ path: "model", message: "Required", severity: "error" },
|
||||
{ path: "questions.a", message: "Long question", severity: "warning" },
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("2 issues")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports a valid payload when only warnings remain", () => {
|
||||
render(<ValidationStatus validation={validation([{ path: "q", message: "Long", severity: "warning" }])} />);
|
||||
expect(screen.getByText("Valid payload")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("IssueList", () => {
|
||||
it("lists every issue with its path and says ready when there are none", () => {
|
||||
const { rerender } = render(
|
||||
<IssueList id="issues" validation={validation([{ path: "state", message: "Required", severity: "error" }])} />,
|
||||
);
|
||||
expect(screen.getByRole("list", { name: "Payload validation issues" })).toHaveTextContent("stateRequired");
|
||||
rerender(<IssueList id="issues" validation={validation([])} />);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Ready to send");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { CircleAlert, CircleCheck, TriangleAlert } from "lucide-react";
|
||||
import { errorCount as countErrors, type PayloadValidation } from "./lib/validatePayload";
|
||||
|
||||
export function ValidationStatus({ validation }: { validation: PayloadValidation<unknown> }) {
|
||||
const errorCount = countErrors(validation);
|
||||
if (errorCount > 0) {
|
||||
return (
|
||||
<Badge variant="destructive">
|
||||
{errorCount} {errorCount === 1 ? "issue" : "issues"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return <Badge variant="secondary">Valid payload</Badge>;
|
||||
}
|
||||
|
||||
export function IssueList({ id, validation }: { id: string; validation: PayloadValidation<unknown> }) {
|
||||
if (validation.issues.length === 0) {
|
||||
return (
|
||||
<p id={id} role="status" className="flex items-center gap-1.5 border-t px-3 py-2 text-xs text-muted-foreground">
|
||||
<CircleCheck className="size-3.5 text-emerald-600 dark:text-emerald-400" />
|
||||
Ready to send
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul id={id} aria-label="Payload validation issues" className="grid max-h-36 gap-1 overflow-auto border-t px-3 py-2">
|
||||
{validation.issues.map((issue, index) => (
|
||||
<li key={`${issue.path}-${index}`} className="flex items-start gap-1.5 text-xs">
|
||||
{issue.severity === "error" ? (
|
||||
<CircleAlert className="mt-0.5 size-3.5 shrink-0 text-destructive" />
|
||||
) : (
|
||||
<TriangleAlert className="mt-0.5 size-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
)}
|
||||
<code className="shrink-0 rounded bg-muted px-1 font-mono">{issue.path}</code>
|
||||
<span>{issue.message}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildCurlCommand, curlPayload } from "./curlCommand";
|
||||
|
||||
describe("buildCurlCommand", () => {
|
||||
it("targets the proxy endpoint with a key placeholder and the pretty payload", () => {
|
||||
const input = {
|
||||
baseUrl: "http://localhost:4000/",
|
||||
endpoint: "/v1/systemone",
|
||||
payload: JSON.stringify({ model: "jev-latest", state: "it's broken" }),
|
||||
authHeaderName: "Authorization",
|
||||
};
|
||||
const command = buildCurlCommand(input);
|
||||
|
||||
expect(command).toContain("curl -X POST 'http://localhost:4000/v1/systemone'");
|
||||
expect(command).toContain('-H "Authorization: Bearer $LITELLM_API_KEY"');
|
||||
expect(command).not.toContain("sk-");
|
||||
expect(command).toContain(' "state": "it\'\\\'\'s broken"');
|
||||
expect(JSON.parse(curlPayload(command) ?? "")).toEqual({ model: "jev-latest", state: "it's broken" });
|
||||
});
|
||||
|
||||
it("quotes a proxy URL that carries shell metacharacters", () => {
|
||||
const input = {
|
||||
baseUrl: "http://proxy.example/$(touch pwned)'; echo x",
|
||||
endpoint: "/v1/decisions",
|
||||
payload: "{}",
|
||||
authHeaderName: "Authorization",
|
||||
};
|
||||
const command = buildCurlCommand(input);
|
||||
expect(command).toContain(`curl -X POST 'http://proxy.example/$(touch pwned)'\\''; echo x/v1/decisions'`);
|
||||
});
|
||||
|
||||
it("sends the key under the proxy's configured auth header name", () => {
|
||||
const input = {
|
||||
baseUrl: "https://proxy.example",
|
||||
endpoint: "/v1/systemone",
|
||||
payload: "{}",
|
||||
authHeaderName: "X-Litellm-Key",
|
||||
};
|
||||
const command = buildCurlCommand(input);
|
||||
expect(command).toContain('-H "X-Litellm-Key: Bearer $LITELLM_API_KEY"');
|
||||
expect(command).not.toContain("Authorization");
|
||||
});
|
||||
|
||||
it("lets the shell expand the key while a hostile header name stays literal", () => {
|
||||
const input = {
|
||||
baseUrl: "https://proxy.example",
|
||||
endpoint: "/v1/systemone" as const,
|
||||
payload: "{}",
|
||||
authHeaderName: 'X-"$(touch pwned)`id`\\Key',
|
||||
};
|
||||
const command = buildCurlCommand(input);
|
||||
const args = execFileSync("bash", ["-c", command.replace(/^curl /, "printf '%s\\n' ")], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, LITELLM_API_KEY: "sk-test-key" },
|
||||
});
|
||||
expect(args).toContain('X-"$(touch pwned)`id`\\Key: Bearer sk-test-key\n');
|
||||
expect(args).not.toContain("$LITELLM_API_KEY");
|
||||
});
|
||||
|
||||
it("passes a payload that is not JSON through untouched", () => {
|
||||
const input = {
|
||||
baseUrl: "https://proxy.example",
|
||||
endpoint: "/v1/decisions",
|
||||
payload: "{",
|
||||
authHeaderName: "Authorization",
|
||||
};
|
||||
const command = buildCurlCommand(input);
|
||||
expect(command).toContain("'https://proxy.example/v1/decisions'");
|
||||
expect(curlPayload(command)).toBe("{");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
export interface CurlCommandInput {
|
||||
baseUrl: string;
|
||||
endpoint: "/v1/decisions" | "/v1/systemone";
|
||||
payload: string;
|
||||
authHeaderName: string;
|
||||
}
|
||||
|
||||
export const CURL_KEY_PLACEHOLDER = "$LITELLM_API_KEY";
|
||||
|
||||
const shellSingleQuote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
|
||||
const shellDoubleQuoteLiteral = (value: string): string => value.replace(/["\\$`]/g, (char) => `\\${char}`);
|
||||
|
||||
const prettyPayload = (payload: string): string => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(payload), null, 2);
|
||||
} catch {
|
||||
return payload;
|
||||
}
|
||||
};
|
||||
|
||||
export const buildCurlCommand = ({ baseUrl, endpoint, payload, authHeaderName }: CurlCommandInput): string =>
|
||||
[
|
||||
`curl -X POST ${shellSingleQuote(`${baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl}${endpoint}`)}`,
|
||||
` -H "${shellDoubleQuoteLiteral(authHeaderName)}: Bearer ${CURL_KEY_PLACEHOLDER}"`,
|
||||
` -H "Content-Type: application/json"`,
|
||||
` -d ${shellSingleQuote(prettyPayload(payload))}`,
|
||||
].join(" \\\n");
|
||||
|
||||
export const curlPayload = (command: string): string | undefined => {
|
||||
const match = /-d '([\s\S]*)'$/.exec(command);
|
||||
return match === null ? undefined : match[1].replace(/'\\''/g, "'");
|
||||
};
|
||||
|
|
@ -23,6 +23,9 @@ export interface PayloadValidation<T> {
|
|||
|
||||
export type SystemOnePayloadValidation = PayloadValidation<PlaygroundRequest>;
|
||||
|
||||
export const errorCount = (validation: PayloadValidation<unknown>): number =>
|
||||
validation.issues.filter((issue) => issue.severity === "error").length;
|
||||
|
||||
const invalid = (path: string, message: string) => ({
|
||||
isValid: false,
|
||||
issues: [{ path, message, severity: "error" as const }],
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ describe("Auto-router classifier selection", () => {
|
|||
llm: "LLM",
|
||||
heuristic_first: "LLM",
|
||||
hybrid: "LLM",
|
||||
jev: "OSS Classifier",
|
||||
jev: "Decisions Model",
|
||||
}[classifier_type];
|
||||
expect(screen.getByRole("radio", { name: new RegExp(`^${family}$`) })).toBeChecked();
|
||||
fireEvent.click(screen.getByRole("radio", { name: new RegExp(`^${family}$`) }));
|
||||
|
|
@ -191,17 +191,14 @@ describe("Auto-router classifier selection", () => {
|
|||
expect(screen.getByRole("button", { name: field })).toHaveTextContent("Used by this router");
|
||||
});
|
||||
|
||||
it("shows Jev's single Complexity approach without changing saved configuration", () => {
|
||||
it("omits Jev's redundant routing approach without changing saved configuration", () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<AutoRouterClassifierTabs value={{ ...initial, classifier_type: "jev" }} onChange={onChange}>
|
||||
Existing settings
|
||||
</AutoRouterClassifierTabs>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Routing approach" })).toHaveTextContent("ComplexityUnlimited");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Routing approach" }));
|
||||
expect(screen.getAllByRole("menuitemradio")).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole("menuitemradio", { name: /^Complexity/ }));
|
||||
expect(screen.queryByRole("button", { name: "Routing approach" })).not.toBeInTheDocument();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import React, { useContext, useId } from "react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import { ChevronDownIcon, Info } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -124,9 +126,17 @@ interface AutoRouterClassifierTabsProps {
|
|||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
children: React.ReactNode;
|
||||
judgeModel?: React.ReactNode;
|
||||
decisionModel?: React.ReactNode;
|
||||
}
|
||||
|
||||
const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({ value, onChange, children }) => {
|
||||
const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
children,
|
||||
judgeModel,
|
||||
decisionModel,
|
||||
}) => {
|
||||
const id = useId();
|
||||
const availability = useContext(AutoRouterAvailabilityContext);
|
||||
const classifierType = effectiveClassifierType(value);
|
||||
|
|
@ -167,10 +177,17 @@ const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({ val
|
|||
llm_v2: "Use the efficient model when its predicted quality is close enough to the capable model",
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<fieldset>
|
||||
<legend className="mb-3 flex w-full items-center justify-between gap-3 text-sm font-medium">
|
||||
What classifies your requests?
|
||||
<legend className="mb-4 flex w-full items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="m-0 text-xl font-semibold text-foreground">Choose your classifier</h4>
|
||||
<SimpleTooltip content="Choose how requests are evaluated and routed to your tiers">
|
||||
<button type="button" aria-label="About classifiers" className="text-muted-foreground">
|
||||
<Info className="size-4" />
|
||||
</button>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
<AutoRouterLimits />
|
||||
</legend>
|
||||
<RadioGroup value={family} onValueChange={changeFamily} className="grid gap-3 sm:grid-cols-3">
|
||||
|
|
@ -179,8 +196,8 @@ const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({ val
|
|||
{ value: "llm", label: "LLM", description: "Use a judge model to choose a solver" },
|
||||
{
|
||||
value: "jev",
|
||||
label: "OSS Classifier",
|
||||
description: "Use Jev, Laya, Bespoke Nimble, or Databricks to choose a tier",
|
||||
label: "Decisions Model",
|
||||
description: "Use a decision model to choose a tier",
|
||||
},
|
||||
].map((option) => (
|
||||
<Label
|
||||
|
|
@ -206,33 +223,32 @@ const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({ val
|
|||
))}
|
||||
</RadioGroup>
|
||||
</fieldset>
|
||||
{family === "jev" && (
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">OSS provider</legend>
|
||||
<RadioGroup
|
||||
value={normalizeJevClassifierConfig(value.jev_classifier_config).provider}
|
||||
onValueChange={changeProvider}
|
||||
className="flex gap-6"
|
||||
>
|
||||
<Label>
|
||||
<RadioGroupItem value="jev" />
|
||||
Jev
|
||||
</Label>
|
||||
<Label>
|
||||
<RadioGroupItem value="laya" />
|
||||
Laya
|
||||
</Label>
|
||||
<Label>
|
||||
<RadioGroupItem value="bespoke" />
|
||||
Bespoke Nimble
|
||||
</Label>
|
||||
<Label>
|
||||
<RadioGroupItem value="databricks" />
|
||||
Databricks
|
||||
</Label>
|
||||
</RadioGroup>
|
||||
</fieldset>
|
||||
)}
|
||||
{family === "jev" &&
|
||||
(decisionModel ?? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-decision-model`}>Decision model</Label>
|
||||
<Select
|
||||
items={[
|
||||
{ value: "jev", label: "Jev" },
|
||||
{ value: "laya", label: "Laya" },
|
||||
{ value: "bespoke", label: "Bespoke Nimble" },
|
||||
{ value: "databricks", label: "Databricks" },
|
||||
]}
|
||||
value={normalizeJevClassifierConfig(value.jev_classifier_config).provider}
|
||||
onValueChange={changeProvider}
|
||||
>
|
||||
<SelectTrigger id={`${id}-decision-model`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="jev">Jev</SelectItem>
|
||||
<SelectItem value="laya">Laya</SelectItem>
|
||||
<SelectItem value="bespoke">Bespoke Nimble</SelectItem>
|
||||
<SelectItem value="databricks">Databricks</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
{family === "custom" && (
|
||||
<p className="text-sm text-muted-foreground">This router uses a custom classifier plugin</p>
|
||||
)}
|
||||
|
|
@ -268,7 +284,8 @@ const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({ val
|
|||
</p>
|
||||
</div>
|
||||
)}
|
||||
{(family === "llm" || family === "jev") && (
|
||||
{family === "llm" && judgeModel}
|
||||
{family === "llm" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-approach`}>Routing approach</Label>
|
||||
<ClassifierMenu
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ const HowClassificationWorks: React.FC<{ value: ComplexityRouterConfigValue }> =
|
|||
};
|
||||
|
||||
interface ClassificationMethodConfigProps {
|
||||
jevSection?: "all" | "advanced";
|
||||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
modelOptions: { value: string; label: string }[];
|
||||
|
|
@ -204,6 +205,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
showValidationErrors = false,
|
||||
defaultModel,
|
||||
advancedOnly = false,
|
||||
jevSection,
|
||||
section,
|
||||
}) => {
|
||||
const [draft, setDraft] = React.useState<{ id: string; raw: string } | null>(null);
|
||||
|
|
@ -522,7 +524,9 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{showClassifier && classifierType === "jev" && <JevClassifierConfig value={value} onChange={onChange} />}
|
||||
{showClassifier && classifierType === "jev" && (
|
||||
<JevClassifierConfig value={value} onChange={onChange} section={jevSection} />
|
||||
)}
|
||||
{showClassifier && usesLlmClassifier(classifierType) && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<ClassifierReasoningEffortSelect
|
||||
|
|
@ -671,8 +675,8 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Number of prior user turns sent to the classifier provider, excluding tool output and harness reminders.
|
||||
LLM and OSS classifiers default to 3 turns. Set to 0 to omit conversation history. The current message and
|
||||
selected system text are still sent.
|
||||
LLM and decision model classifiers default to 3 turns. Set to 0 to omit conversation history. The current
|
||||
message and selected system text are still sent.
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -18,11 +18,13 @@ export default function ClassifierPrimarySettings({
|
|||
onChange,
|
||||
modelOptions,
|
||||
showValidationErrors = false,
|
||||
section = "all",
|
||||
}: {
|
||||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
modelOptions: { value: string; label: string }[];
|
||||
showValidationErrors?: boolean;
|
||||
section?: "all" | "judge" | "frequency";
|
||||
}) {
|
||||
const id = React.useId();
|
||||
const restriction = restrictedBy(value, "sessionAffinity");
|
||||
|
|
@ -35,34 +37,44 @@ export default function ClassifierPrimarySettings({
|
|||
const usesJudge = usesLlmClassifier(effectiveClassifierType(value));
|
||||
const missingJudge = showValidationErrors && usesJudge && !value.classifier_llm_config?.model;
|
||||
return (
|
||||
<div className="mb-6 grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-frequency`}>How often to classify</Label>
|
||||
<Select
|
||||
items={[
|
||||
{ value: "every_request", label: "Every request" },
|
||||
{ value: "user_turn", label: "Every new user message" },
|
||||
{ value: "session", label: "Once per session" },
|
||||
]}
|
||||
value={frequency}
|
||||
onValueChange={(frequency) => {
|
||||
if (frequency) onChange(withClassificationFrequency(value, frequency as ClassificationFrequency));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={`${id}-frequency`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="every_request">Every request</SelectItem>
|
||||
<SelectItem value="user_turn">Every new user message</SelectItem>
|
||||
<SelectItem value="session" disabled={Boolean(restriction)}>
|
||||
Once per session
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{restriction?.reason ?? frequencyDescription}</p>
|
||||
</div>
|
||||
{usesJudge && (
|
||||
<div
|
||||
className={
|
||||
{
|
||||
all: "mb-6 grid gap-4 sm:grid-cols-2",
|
||||
frequency: "mb-6 grid gap-4",
|
||||
judge: "grid gap-4",
|
||||
}[section]
|
||||
}
|
||||
>
|
||||
{section !== "judge" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-frequency`}>How often to classify</Label>
|
||||
<Select
|
||||
items={[
|
||||
{ value: "every_request", label: "Every request" },
|
||||
{ value: "user_turn", label: "Every new user message" },
|
||||
{ value: "session", label: "Once per session" },
|
||||
]}
|
||||
value={frequency}
|
||||
onValueChange={(frequency) => {
|
||||
if (frequency) onChange(withClassificationFrequency(value, frequency as ClassificationFrequency));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={`${id}-frequency`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="every_request">Every request</SelectItem>
|
||||
<SelectItem value="user_turn">Every new user message</SelectItem>
|
||||
<SelectItem value="session" disabled={Boolean(restriction)}>
|
||||
Once per session
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{restriction?.reason ?? frequencyDescription}</p>
|
||||
</div>
|
||||
)}
|
||||
{section !== "frequency" && usesJudge && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-judge`}>Judge model</Label>
|
||||
<SearchSelect
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import type { AutoRouterCompressionState } from "./buildAutoRouterCompression";
|
|||
import { activeTierName, type TierRow } from "./tier_rows";
|
||||
|
||||
interface ComplexityRouterAdvancedSectionsProps {
|
||||
hideClassifier?: boolean;
|
||||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
forecast: boolean;
|
||||
|
|
@ -59,6 +60,7 @@ interface ComplexityRouterAdvancedSectionsProps {
|
|||
}
|
||||
|
||||
const ComplexityRouterAdvancedSections: React.FC<ComplexityRouterAdvancedSectionsProps> = ({
|
||||
hideClassifier,
|
||||
value,
|
||||
onChange,
|
||||
forecast,
|
||||
|
|
@ -268,7 +270,10 @@ const ComplexityRouterAdvancedSections: React.FC<ComplexityRouterAdvancedSection
|
|||
},
|
||||
{ label: "Sessions and efficiency", keys: ["affinity", "adaptive", "cache-aware", "compression"] },
|
||||
{ label: "Compatibility", keys: ["response"] },
|
||||
].filter((group) => sections.some(({ key }) => group.keys.includes(key)));
|
||||
].filter(
|
||||
(group) =>
|
||||
!(hideClassifier && group.keys.includes("classifier")) && sections.some(({ key }) => group.keys.includes(key)),
|
||||
);
|
||||
const [openGroups, setOpenGroups] = React.useState<string[]>(() =>
|
||||
showValidationErrors ? groups.map((group) => group.label) : [],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import {
|
|||
} from "./tier_rows";
|
||||
import React from "react";
|
||||
import { ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import { InactiveHeuristicV2Threshold } from "./ClassificationMethodConfig";
|
||||
import ClassificationMethodConfig, { InactiveHeuristicV2Threshold } from "./ClassificationMethodConfig";
|
||||
import ComplexityRouterAdvancedSections from "./ComplexityRouterAdvancedSections";
|
||||
import { type TierSetAction, applyTierSetAction, setFallbackTier } from "./tier_set_actions";
|
||||
import {
|
||||
|
|
@ -472,6 +472,8 @@ export const withClassificationFrequency = (
|
|||
});
|
||||
|
||||
interface ComplexityRouterConfigProps {
|
||||
creationFlow?: boolean;
|
||||
tierSetup?: React.ReactNode;
|
||||
modelInfo: ModelGroup[];
|
||||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
|
|
@ -548,7 +550,60 @@ export const DEFAULT_HYBRID_BOUNDARY_MARGIN = 0.03;
|
|||
*/
|
||||
export const HEURISTIC_FIRST_MAX_TIER_KEYS = TIER_ORDER.slice(0, -1);
|
||||
|
||||
const isJevCreation = (value: ComplexityRouterConfigValue, creationFlow: boolean | undefined) =>
|
||||
creationFlow && effectiveClassifierType(value) === "jev";
|
||||
|
||||
const CreationClassifierSettings = ({
|
||||
creationFlow,
|
||||
value,
|
||||
onChange,
|
||||
modelOptions,
|
||||
classifierEffortOptionsByModel,
|
||||
defaultModel,
|
||||
showValidationErrors,
|
||||
}: Pick<ComplexityRouterConfigProps, "value" | "onChange"> & {
|
||||
creationFlow: boolean | undefined;
|
||||
modelOptions: { value: string; label: string }[];
|
||||
classifierEffortOptionsByModel: Record<string, string[] | null | undefined>;
|
||||
defaultModel: string | undefined;
|
||||
showValidationErrors: boolean;
|
||||
}) => {
|
||||
const jevCreation = isJevCreation(value, creationFlow);
|
||||
return (
|
||||
<>
|
||||
<ClassifierPrimarySettings
|
||||
section={creationFlow ? "frequency" : "all"}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
modelOptions={modelOptions}
|
||||
showValidationErrors={showValidationErrors}
|
||||
/>
|
||||
{jevCreation && (
|
||||
<div className="mb-6">
|
||||
<RoutingOptions label="Advanced classifier settings" showValidationErrors={showValidationErrors}>
|
||||
<div className="px-4 pb-4">
|
||||
<ClassificationMethodConfig
|
||||
advancedOnly
|
||||
jevSection="advanced"
|
||||
section="classifier"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
modelOptions={modelOptions}
|
||||
effortOptionsByModel={classifierEffortOptionsByModel}
|
||||
defaultModel={defaultModel}
|
||||
showValidationErrors={showValidationErrors}
|
||||
/>
|
||||
</div>
|
||||
</RoutingOptions>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
||||
creationFlow,
|
||||
tierSetup,
|
||||
modelInfo,
|
||||
value,
|
||||
onChange,
|
||||
|
|
@ -572,6 +627,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
showValidationErrors = false,
|
||||
}) => {
|
||||
const forecast = isForecastClassifier(value.classifier_type);
|
||||
const tierHeading = creationFlow ? "Define your tiers" : "Models by tier";
|
||||
const customTierSet = value.custom_tier_set;
|
||||
const tierRows = activeTierRows(value);
|
||||
const tierRowsError = customTierSet ? getCustomTierRowsError(customTierSet) : null;
|
||||
|
|
@ -620,19 +676,25 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
|
||||
return (
|
||||
<div className="w-full max-w-none">
|
||||
<ClassifierPrimarySettings
|
||||
<CreationClassifierSettings
|
||||
creationFlow={creationFlow}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
modelOptions={modelOptions}
|
||||
classifierEffortOptionsByModel={classifierEffortOptionsByModel}
|
||||
defaultModel={defaultModel}
|
||||
showValidationErrors={showValidationErrors}
|
||||
/>
|
||||
<div className="inline-flex items-center gap-2 mb-4">
|
||||
<h4 className="m-0 text-xl font-semibold text-foreground">{forecast ? "Solver models" : "Models by tier"}</h4>
|
||||
{!forecast && (
|
||||
<SimpleTooltip content="Map each complexity tier to one or more models. Simple queries use cheaper/faster models, complex queries use more capable models.">
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
)}
|
||||
{creationFlow && <Separator className="my-6" />}
|
||||
<div className="mb-4">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<h4 className="m-0 text-xl font-semibold text-foreground">{forecast ? "Solver models" : tierHeading}</h4>
|
||||
{!forecast && (
|
||||
<SimpleTooltip content="Map each complexity tier to one or more models. Simple queries use cheaper/faster models, complex queries use more capable models.">
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InactiveHeuristicV2Threshold value={value} onChange={onChange} />
|
||||
|
|
@ -657,16 +719,9 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
) : (
|
||||
<>
|
||||
<TierConfigIntro value={value} />
|
||||
{tierSetup}
|
||||
<Card>
|
||||
<CardContent>
|
||||
{!customTierSet && (
|
||||
<NonReasoningTierToggle
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
available={value.classifier_type === "llm" || value.classifier_type === "jev"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tierRows.map((row, index) => {
|
||||
const tierInfo = builtInTierInfo(row.id);
|
||||
const label = tierRowLabel(row, value.tier_labels);
|
||||
|
|
@ -756,6 +811,14 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
);
|
||||
})}
|
||||
|
||||
{!customTierSet && (
|
||||
<NonReasoningTierToggle
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
available={value.classifier_type === "llm" || value.classifier_type === "jev"}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TierSetToolbar
|
||||
editing={editingTiers}
|
||||
isCustomSet={Boolean(customTierSet)}
|
||||
|
|
@ -779,7 +842,6 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
</>
|
||||
)}
|
||||
<DefaultModelField value={value} onChange={onChange} modelOptions={modelOptions} />
|
||||
<Separator className="my-6" />
|
||||
|
||||
<RoutingOptions
|
||||
showValidationErrors={showValidationErrors}
|
||||
|
|
@ -803,6 +865,7 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
|
|||
)}
|
||||
<div className="rounded-lg border border-border bg-muted">
|
||||
<ComplexityRouterAdvancedSections
|
||||
hideClassifier={isJevCreation(value, creationFlow)}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
forecast={forecast}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import React from "react";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
|
||||
import { buildDecisionCatalog, isDecisionMode, isDecisionSelection } from "@/lib/decisionModels";
|
||||
import { defaultJevClassifierConfig } from "./jev_classifier_config";
|
||||
import type { ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import type { AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
|
||||
export default function DecisionModelSelect({
|
||||
value,
|
||||
onChange,
|
||||
modelInfo,
|
||||
deployments,
|
||||
}: {
|
||||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
modelInfo: ModelGroup[];
|
||||
deployments: AutoRouterDeployment[] | undefined;
|
||||
}) {
|
||||
const id = React.useId();
|
||||
const { data: modelCostMapData } = useModelCostMap(true, true);
|
||||
const decisionCatalog = React.useMemo(() => buildDecisionCatalog(modelCostMapData), [modelCostMapData]);
|
||||
const decisionModels = modelInfo.filter(
|
||||
(model) =>
|
||||
isDecisionMode(model.mode) ||
|
||||
(deployments ?? []).some(
|
||||
(deployment) =>
|
||||
deployment.model_name === model.model_group &&
|
||||
isDecisionSelection(
|
||||
decisionCatalog,
|
||||
deployment.litellm_params?.custom_llm_provider ?? deployment.litellm_params?.model?.split("/")[0],
|
||||
[deployment.litellm_params?.model ?? "", deployment.litellm_params?.base_model ?? ""],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>Decision model</Label>
|
||||
<SearchSelect
|
||||
inputId={id}
|
||||
aria-label="Decision model"
|
||||
options={decisionModels.map((model) => ({ value: model.model_group, label: model.model_group }))}
|
||||
value={value.jev_classifier_config?.deployment_name ?? ""}
|
||||
placeholder="Select the decision model"
|
||||
allowClear={false}
|
||||
onValueChange={(deploymentName) => {
|
||||
if (!deploymentName) return;
|
||||
onChange({
|
||||
...value,
|
||||
jev_classifier_config: {
|
||||
...(value.jev_classifier_config ?? defaultJevClassifierConfig()),
|
||||
deployment_name: deploymentName,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -111,19 +111,19 @@ describe("JEV classifier editor", () => {
|
|||
expect(screen.getByText("Reasoning Effort")).toBeInTheDocument();
|
||||
expect(screen.getByText("Classifier Prompt")).toBeInTheDocument();
|
||||
expect(screen.getByRole("switch", { name: "Use images for classification" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("radio", { name: /^OSS Classifier$/ }));
|
||||
expect(screen.getByRole("radio", { name: /^OSS Classifier$/ })).toBeChecked();
|
||||
fireEvent.click(screen.getByRole("radio", { name: /^Decisions Model$/ }));
|
||||
expect(screen.getByRole("radio", { name: /^Decisions Model$/ })).toBeChecked();
|
||||
expect(screen.getByLabelText("Classifier Model")).toHaveValue("jev-latest");
|
||||
expect(screen.getByLabelText("Classifier Instructions")).toBeEnabled();
|
||||
expect(screen.queryByLabelText("Judge model")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Reasoning Effort")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Classifier Prompt")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("switch", { name: "Use images for classification" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Laya" }));
|
||||
await chooseSelectOption(userEvent, screen.getByRole("combobox", { name: "Decision model" }), "Laya");
|
||||
expect(screen.getByLabelText("Classifier Model")).toHaveTextContent("english");
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Jev" }));
|
||||
await chooseSelectOption(userEvent, screen.getByRole("combobox", { name: "Decision model" }), "Jev");
|
||||
expect(screen.getByLabelText("Classifier Model")).toHaveValue("jev-latest");
|
||||
fireEvent.click(screen.getByRole("radio", { name: label }));
|
||||
await chooseSelectOption(userEvent, screen.getByRole("combobox", { name: "Decision model" }), label);
|
||||
if (provider === "bespoke") expect(screen.getByLabelText("Classifier Model")).toHaveTextContent("nimble-latest");
|
||||
if (provider === "databricks") expect(screen.getByLabelText("Classifier Model")).toHaveValue("");
|
||||
if (typesTheModel) {
|
||||
|
|
@ -137,8 +137,8 @@ describe("JEV classifier editor", () => {
|
|||
fireEvent.click(screen.getByRole("switch", { name: "Classifier circuit breaker" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Customize tiers" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and reload" }));
|
||||
expect(screen.getByRole("radio", { name: /^OSS Classifier$/ })).toBeChecked();
|
||||
expect(screen.getByRole("radio", { name: label })).toBeChecked();
|
||||
expect(screen.getByRole("radio", { name: /^Decisions Model$/ })).toBeChecked();
|
||||
expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveTextContent(label);
|
||||
if (typesTheModel) expect(screen.getByLabelText("Classifier Model")).toHaveValue(model);
|
||||
else expect(screen.getByLabelText("Classifier Model")).toHaveTextContent(model);
|
||||
expect(screen.getByLabelText("Classifier Timeout (ms)")).toHaveValue(4200);
|
||||
|
|
|
|||
|
|
@ -21,9 +21,11 @@ const providerDescriptions = {
|
|||
export default function JevClassifierConfig({
|
||||
value,
|
||||
onChange,
|
||||
section = "all",
|
||||
}: {
|
||||
value: ComplexityRouterConfigValue;
|
||||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
section?: "all" | "model" | "advanced";
|
||||
}) {
|
||||
const id = useId();
|
||||
const config = value.jev_classifier_config ?? defaultJevClassifierConfig();
|
||||
|
|
@ -32,75 +34,85 @@ export default function JevClassifierConfig({
|
|||
onChange({ ...value, jev_classifier_config: { ...config, ...patch } });
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{providerDescriptions[config.provider ?? "jev"]}</p>
|
||||
<div>
|
||||
<Label htmlFor={`${id}-model`}>Classifier Model</Label>
|
||||
{models ? (
|
||||
<Select value={config.model} onValueChange={(model) => model && update({ model })}>
|
||||
<SelectTrigger id={`${id}-model`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
id={`${id}-model`}
|
||||
value={config.model}
|
||||
placeholder={
|
||||
config.provider === "databricks" ? "Serving endpoint name, e.g. databricks-openjev-qwen35-4b" : undefined
|
||||
<div className={section === "model" ? "space-y-3" : "mt-4 space-y-3"}>
|
||||
{section !== "advanced" && !config.deployment_name && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">{providerDescriptions[config.provider ?? "jev"]}</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-model`}>Classifier Model</Label>
|
||||
{models ? (
|
||||
<Select value={config.model} onValueChange={(model) => model && update({ model })}>
|
||||
<SelectTrigger id={`${id}-model`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
id={`${id}-model`}
|
||||
value={config.model}
|
||||
placeholder={
|
||||
config.provider === "databricks"
|
||||
? "Serving endpoint name, e.g. databricks-openjev-qwen35-4b"
|
||||
: undefined
|
||||
}
|
||||
onChange={(event) => update({ model: event.target.value })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{section !== "model" && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-timeout`}>Classifier Timeout (ms)</Label>
|
||||
<Input
|
||||
id={`${id}-timeout`}
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={config.timeout_ms}
|
||||
onChange={(event) => update({ timeout_ms: Number(event.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<ClassifierCircuitBreakerConfig
|
||||
value={config}
|
||||
onChange={(next) =>
|
||||
update({
|
||||
circuit_breaker_enabled: next.circuit_breaker_enabled,
|
||||
circuit_breaker_cooldown_seconds: next.circuit_breaker_cooldown_seconds,
|
||||
})
|
||||
}
|
||||
onChange={(event) => update({ model: event.target.value })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={`${id}-timeout`}>Classifier Timeout (ms)</Label>
|
||||
<Input
|
||||
id={`${id}-timeout`}
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={config.timeout_ms}
|
||||
onChange={(event) => update({ timeout_ms: Number(event.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<ClassifierCircuitBreakerConfig
|
||||
value={config}
|
||||
onChange={(next) =>
|
||||
update({
|
||||
circuit_breaker_enabled: next.circuit_breaker_enabled,
|
||||
circuit_breaker_cooldown_seconds: next.circuit_breaker_cooldown_seconds,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor={`${id}-instructions`}>Classifier Instructions</Label>
|
||||
<AutoRouterAllowanceNote
|
||||
feature="tier_or_classifier_prompt"
|
||||
label="Custom instructions share the custom-tier allowance"
|
||||
/>
|
||||
<Textarea
|
||||
id={`${id}-instructions`}
|
||||
value={config.instructions ?? ""}
|
||||
placeholder="Leave blank to use the built-in instructions"
|
||||
onChange={(event) => update({ instructions: event.target.value || undefined })}
|
||||
/>
|
||||
{config.instructions && (
|
||||
<Button variant="outline" type="button" onClick={() => update({ instructions: undefined })}>
|
||||
Restore built-in instructions
|
||||
</Button>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Built-in OSS classification is available without a license and uses the shipped tier criteria
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-instructions`}>Classifier Instructions</Label>
|
||||
<AutoRouterAllowanceNote
|
||||
feature="tier_or_classifier_prompt"
|
||||
label="Custom instructions share the custom-tier allowance"
|
||||
/>
|
||||
<Textarea
|
||||
id={`${id}-instructions`}
|
||||
value={config.instructions ?? ""}
|
||||
placeholder="Leave blank to use the built-in instructions"
|
||||
onChange={(event) => update({ instructions: event.target.value || undefined })}
|
||||
/>
|
||||
{config.instructions && (
|
||||
<Button variant="outline" type="button" onClick={() => update({ instructions: undefined })}>
|
||||
Restore built-in instructions
|
||||
</Button>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Built-in decision model classification is available without a license and uses the shipped tier criteria
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ const NonReasoningTierToggle: React.FC<{
|
|||
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-4" />
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Switch
|
||||
checked={value.enable_non_reasoning_tier === true}
|
||||
|
|
@ -41,7 +42,6 @@ const NonReasoningTierToggle: React.FC<{
|
|||
reasoning about it. Escalation still moves up out of it when a request needs more.
|
||||
{!available && " Requires the LLM or Jev classification method"}
|
||||
</span>
|
||||
<Separator className="my-4" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,12 +3,18 @@ import { ChevronRight } from "lucide-react";
|
|||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
|
||||
interface RoutingOptionsProps {
|
||||
label?: string;
|
||||
showValidationErrors?: boolean;
|
||||
summary?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const RoutingOptions = ({ showValidationErrors = false, summary, children }: RoutingOptionsProps) => {
|
||||
const RoutingOptions = ({
|
||||
label = "Advanced settings",
|
||||
showValidationErrors = false,
|
||||
summary,
|
||||
children,
|
||||
}: RoutingOptionsProps) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [previousValidation, setPreviousValidation] = React.useState(showValidationErrors);
|
||||
if (previousValidation !== showValidationErrors) {
|
||||
|
|
@ -16,10 +22,10 @@ const RoutingOptions = ({ showValidationErrors = false, summary, children }: Rou
|
|||
if (showValidationErrors) setOpen(true);
|
||||
}
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="rounded-lg border">
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="rounded-lg border bg-muted/40">
|
||||
<CollapsibleTrigger className="group flex w-full flex-wrap items-center gap-2 px-4 py-3 text-left font-medium">
|
||||
<ChevronRight className="size-4 transition-transform group-data-panel-open:rotate-90" />
|
||||
Advanced settings
|
||||
{label}
|
||||
{summary && <span className="text-xs font-normal text-muted-foreground">{summary}</span>}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4">{children}</CollapsibleContent>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import { handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit";
|
|||
import { getMissingTiersError } from "./build_complexity_router_config";
|
||||
import { getSubmitBlockedReason } from "./add_auto_router_tab";
|
||||
import { buildModelAvailability } from "@/lib/autorouter_presets";
|
||||
import { apiClient, modelCreateCall, testAutoRouterRouting } from "../networking";
|
||||
import { apiClient, modelCostMap, modelCreateCall, testAutoRouterRouting } from "../networking";
|
||||
import { ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import { AutoRouterPreset, getRequiredModelsInPreset } from "@/lib/autorouter_presets";
|
||||
import { BUNDLED_PRESETS, LOADED_PRESETS_QUERY, useAutoRouterPresets } from "../../../tests/mocks/autoRouterPresets";
|
||||
|
|
@ -55,7 +55,7 @@ const openTemplateDropdown = (): void => {
|
|||
};
|
||||
|
||||
const expandDetailedConfiguration = (): void => {
|
||||
expect(screen.getByText("Models by tier")).toBeVisible();
|
||||
expect(screen.getByText("Define your tiers")).toBeVisible();
|
||||
};
|
||||
|
||||
const visibleOptions = (): HTMLElement[] => screen.queryAllByRole("option");
|
||||
|
|
@ -79,6 +79,14 @@ const expectTierModel = (tier: string, model: string): void => {
|
|||
expect(chips.map((chip) => chip.getAttribute("aria-label"))).toEqual([model]);
|
||||
};
|
||||
|
||||
const waitForAutomaticTiers = async () => {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(tierChips("Simple")).getAllByLabelText(/.+/, { selector: '[data-slot="combobox-chip"]' }).length,
|
||||
).toBeGreaterThan(0),
|
||||
);
|
||||
};
|
||||
|
||||
const selectTemplate = async (label: string): Promise<void> => {
|
||||
await userEvent.click(optionByLabel(label)!);
|
||||
};
|
||||
|
|
@ -115,6 +123,7 @@ vi.mock("../networking", () => ({
|
|||
error: null,
|
||||
}),
|
||||
},
|
||||
modelCostMap: vi.fn().mockResolvedValue({}),
|
||||
modelCreateCall: vi.fn().mockResolvedValue({}),
|
||||
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
testAutoRouterRouting: vi.fn(),
|
||||
|
|
@ -180,6 +189,104 @@ describe("AddAutoRouterTab", () => {
|
|||
testQueryClient.clear();
|
||||
mockFetchAvailableModels.mockResolvedValue([]);
|
||||
mockFetchAllModelDeployments.mockResolvedValue([]);
|
||||
vi.mocked(modelCostMap).mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("shows Jev essentials before tiers and saves tuning from its own disclosure", async () => {
|
||||
mockFetchAvailableModels.mockResolvedValue([
|
||||
...ALL_FAMILY_MODELS,
|
||||
{ model_group: "my-decision", mode: "evaluation" },
|
||||
]);
|
||||
renderWithProviders(<Harness />);
|
||||
await waitForAutomaticTiers();
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Decisions Model" }));
|
||||
expect(screen.queryByLabelText("Classifier Model")).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("combobox", { name: "Decision model" }));
|
||||
expect(screen.queryByRole("option", { name: ALL_FAMILY_MODELS[0].model_group })).not.toBeInTheDocument();
|
||||
await userEvent.click(await screen.findByRole("option", { name: "my-decision" }));
|
||||
expect(screen.queryByRole("button", { name: "Routing approach" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Choose models for me" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Classifier Timeout (ms)")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Advanced settings" })).toHaveAttribute("aria-expanded", "false");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Advanced classifier settings" }));
|
||||
fireEvent.change(screen.getByLabelText("Classifier Timeout (ms)"), { target: { value: "4200" } });
|
||||
fireEvent.change(screen.getByLabelText("Context Window Size"), { target: { value: "5" } });
|
||||
await selectAutoRouterOption("How often to classify", "Every new user message");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Advanced classifier settings" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Advanced settings" }));
|
||||
expect(screen.queryByRole("button", { name: "Classifier tuning" })).not.toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("Auto Router Name"), { target: { value: "jev-router" } });
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeEnabled());
|
||||
await userEvent.click(screen.getByRole("button", { name: "Add Auto Router" }));
|
||||
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalledOnce());
|
||||
const expected = {
|
||||
classifier_type: "oss_classifier",
|
||||
opensource_classifier_config: { deployment_name: "my-decision", timeout_ms: 4200 },
|
||||
classifier_context_window_size: 5,
|
||||
classification_mode: "user_turn",
|
||||
};
|
||||
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls[0][0].complexity_router_config).toMatchObject(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ mode: "evaluation" },
|
||||
{ mode: "chat", supported_endpoints: ["/v1/decisions"] },
|
||||
{ supported_endpoints: ["/v1/systemone"] },
|
||||
])("selects and saves an onboarded alias using decision catalog metadata %j", async (metadata) => {
|
||||
const alias = "onboarded-decider";
|
||||
const underlyingModel = "mixed-provider/decision-model";
|
||||
vi.mocked(modelCostMap).mockResolvedValue({
|
||||
[underlyingModel]: { litellm_provider: "mixed-provider", ...metadata },
|
||||
"mixed-provider/chat-model": { litellm_provider: "mixed-provider", mode: "chat" },
|
||||
"catalog-only/decision-model": { litellm_provider: "catalog-only", mode: "evaluation" },
|
||||
});
|
||||
mockFetchAvailableModels.mockResolvedValue([
|
||||
...ALL_FAMILY_MODELS,
|
||||
{ model_group: alias },
|
||||
{ model_group: "ordinary-chat", mode: "chat" },
|
||||
]);
|
||||
mockFetchAllModelDeployments.mockResolvedValue([
|
||||
{ model_name: alias, litellm_params: { model: underlyingModel } },
|
||||
{ model_name: "ordinary-chat", litellm_params: { model: "mixed-provider/chat-model" } },
|
||||
{ model_name: "unavailable-decider", litellm_params: { model: underlyingModel } },
|
||||
]);
|
||||
renderWithProviders(<Harness />);
|
||||
await waitForAutomaticTiers();
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Decisions Model" }));
|
||||
await userEvent.click(screen.getByRole("combobox", { name: "Decision model" }));
|
||||
const option = await screen.findByRole("option", { name: alias });
|
||||
expect(screen.getAllByRole("option")).toHaveLength(1);
|
||||
await userEvent.click(option);
|
||||
fireEvent.change(screen.getByLabelText("Auto Router Name"), { target: { value: "decision-router" } });
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeEnabled());
|
||||
await userEvent.click(screen.getByRole("button", { name: "Add Auto Router" }));
|
||||
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalledOnce());
|
||||
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls[0][0].complexity_router_config).toMatchObject({
|
||||
classifier_type: "oss_classifier",
|
||||
opensource_classifier_config: { deployment_name: alias },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the judge picker empty state when no decision deployments exist", async () => {
|
||||
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
|
||||
renderWithProviders(<Harness />);
|
||||
await waitForAutomaticTiers();
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Decisions Model" }));
|
||||
await userEvent.click(screen.getByRole("combobox", { name: "Decision model" }));
|
||||
expect(await screen.findByText("No results")).toBeVisible();
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("does not refill a tier cleared by the user when models refresh", async () => {
|
||||
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
|
||||
renderWithProviders(<Harness />);
|
||||
await waitForAutomaticTiers();
|
||||
await userEvent.click(within(tierChips("Simple")).getByRole("button", { name: "" }));
|
||||
await act(async () => {
|
||||
await testQueryClient.refetchQueries({ queryKey: ["availableModels"] });
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it.each([1, 0])("defaults to Rule-based with %s v2 slots remaining", async (remaining) => {
|
||||
|
|
@ -272,10 +379,15 @@ describe("AddAutoRouterTab", () => {
|
|||
error: body?.complexity_router_config?.tier_definitions ? blocked : null,
|
||||
};
|
||||
});
|
||||
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
|
||||
mockFetchAvailableModels.mockResolvedValue([
|
||||
...ALL_FAMILY_MODELS,
|
||||
{ model_group: "my-decision", mode: "evaluation" },
|
||||
]);
|
||||
renderWithProviders(<Harness />);
|
||||
await user.click(await screen.findByRole("button", { name: "Choose models for me" }));
|
||||
await user.click(screen.getByRole("radio", { name: "OSS Classifier" }));
|
||||
await waitForAutomaticTiers();
|
||||
await user.click(screen.getByRole("radio", { name: "Decisions Model" }));
|
||||
await userEvent.click(screen.getByRole("combobox", { name: "Decision model" }));
|
||||
await userEvent.click(await screen.findByRole("option", { name: "my-decision" }));
|
||||
await waitFor(() =>
|
||||
expect(apiClient.post).toHaveBeenLastCalledWith(
|
||||
"/auto_router/availability",
|
||||
|
|
@ -302,7 +414,7 @@ describe("AddAutoRouterTab", () => {
|
|||
expect(within(screen.getByRole("alert")).getByRole("link", { name: "Talk to our team" })).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "Restore defaults" }));
|
||||
await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
|
||||
expect(screen.getByRole("radio", { name: "OSS Classifier" })).toBeChecked();
|
||||
expect(screen.getByRole("radio", { name: "Decisions Model" })).toBeChecked();
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeEnabled());
|
||||
await user.click(screen.getByRole("button", { name: "Add Auto Router" }));
|
||||
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
|
||||
|
|
@ -315,10 +427,15 @@ describe("AddAutoRouterTab", () => {
|
|||
|
||||
it("blocks button and Enter submissions until the edited draft is checked", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
|
||||
mockFetchAvailableModels.mockResolvedValue([
|
||||
...ALL_FAMILY_MODELS,
|
||||
{ model_group: "my-decision", mode: "evaluation" },
|
||||
]);
|
||||
renderWithProviders(<Harness />);
|
||||
await user.click(await screen.findByRole("button", { name: "Choose models for me" }));
|
||||
await user.click(screen.getByRole("radio", { name: "OSS Classifier" }));
|
||||
await waitForAutomaticTiers();
|
||||
await user.click(screen.getByRole("radio", { name: "Decisions Model" }));
|
||||
await userEvent.click(screen.getByRole("combobox", { name: "Decision model" }));
|
||||
await userEvent.click(await screen.findByRole("option", { name: "my-decision" }));
|
||||
fireEvent.change(screen.getByLabelText("Auto Router Name"), { target: { value: "checked-router" } });
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeEnabled());
|
||||
let complete: ((result: unknown) => void) | undefined;
|
||||
|
|
@ -358,15 +475,22 @@ describe("AddAutoRouterTab", () => {
|
|||
expect(screen.getByRole("button", { name: "Routing approach" })).toHaveTextContent("Complexity");
|
||||
});
|
||||
|
||||
it.each(["LLM", "OSS Classifier"])(
|
||||
"keeps %s and the frequency when choosing models automatically",
|
||||
it.each(["LLM", "Decisions Model"])(
|
||||
"keeps %s and the frequency selected before automatic models finish loading",
|
||||
async (family) => {
|
||||
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
|
||||
let complete: ((models: ModelGroup[]) => void) | undefined;
|
||||
mockFetchAvailableModels.mockReturnValue(
|
||||
new Promise<ModelGroup[]>((resolve) => {
|
||||
complete = resolve;
|
||||
}),
|
||||
);
|
||||
renderWithProviders(<Harness />);
|
||||
const automatic = await screen.findByRole("button", { name: "Choose models for me" });
|
||||
await userEvent.click(screen.getByRole("radio", { name: family }));
|
||||
await selectAutoRouterOption("How often to classify", "Every new user message");
|
||||
await userEvent.click(automatic);
|
||||
await act(async () => {
|
||||
complete?.(ALL_FAMILY_MODELS);
|
||||
});
|
||||
await waitForAutomaticTiers();
|
||||
expect(screen.getByRole("radio", { name: family })).toBeChecked();
|
||||
expect(screen.getByRole("combobox", { name: "How often to classify" })).toHaveTextContent(
|
||||
"Every new user message",
|
||||
|
|
@ -452,14 +576,14 @@ describe("AddAutoRouterTab", () => {
|
|||
const user = userEvent.setup();
|
||||
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
|
||||
renderWithProviders(<Harness />);
|
||||
await screen.findByTestId("configure-automatically-button");
|
||||
await waitForAutomaticTiers();
|
||||
await selectAutoRouterApproach("Capability");
|
||||
expect(screen.queryByTestId("configure-automatically-button")).not.toBeInTheDocument();
|
||||
await selectAutoRouterApproach("Complexity");
|
||||
expect(screen.getByTestId("configure-automatically-button")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("configure-automatically-button")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("template-selector")).toBeInTheDocument();
|
||||
expandDetailedConfiguration();
|
||||
expect(screen.getByText("Models by tier")).toBeInTheDocument();
|
||||
expect(screen.getByText("Define your tiers")).toBeInTheDocument();
|
||||
for (const label of ["Adaptive Routing", "Context Window Escalation", "Escalation Keywords"]) {
|
||||
openAutoRouterAdvanced(label);
|
||||
expect(screen.getAllByText(label)).not.toHaveLength(0);
|
||||
|
|
@ -507,9 +631,9 @@ describe("AddAutoRouterTab", () => {
|
|||
|
||||
expect(screen.getByRole("button", { name: "Advanced settings" })).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
expect(screen.getByText("Models by tier")).toBeVisible();
|
||||
expect(screen.getByText("Define your tiers")).toBeVisible();
|
||||
|
||||
expect(screen.getByText("Models by tier")).toBeInTheDocument();
|
||||
expect(screen.getByText("Define your tiers")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides automatic setup when no available model is recommended", async () => {
|
||||
|
|
@ -535,8 +659,7 @@ describe("AddAutoRouterTab", () => {
|
|||
mockFetchAllModelDeployments.mockResolvedValue([]);
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
const button = await screen.findByTestId("configure-automatically-button");
|
||||
await userEvent.click(button);
|
||||
await waitForAutomaticTiers();
|
||||
|
||||
expectTierModel("Simple", "gpt-5.6-luna");
|
||||
expectTierModel("Medium", "claude-sonnet-5");
|
||||
|
|
@ -555,8 +678,7 @@ describe("AddAutoRouterTab", () => {
|
|||
mockFetchAllModelDeployments.mockResolvedValue([]);
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
const button = await screen.findByTestId("configure-automatically-button");
|
||||
await userEvent.click(button);
|
||||
await waitForAutomaticTiers();
|
||||
|
||||
expectTierModel("Simple", "gpt-5.6-luna");
|
||||
expectTierModel("Medium", "claude-sonnet-5");
|
||||
|
|
@ -571,9 +693,9 @@ describe("AddAutoRouterTab", () => {
|
|||
|
||||
expect(screen.getByRole("button", { name: "Advanced settings" })).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
await userEvent.click(await screen.findByTestId("configure-automatically-button"));
|
||||
await waitForAutomaticTiers();
|
||||
|
||||
expect(screen.getByText("Models by tier")).toBeInTheDocument();
|
||||
expect(screen.getByText("Define your tiers")).toBeInTheDocument();
|
||||
expectTierModel("Simple", simpleModel);
|
||||
});
|
||||
|
||||
|
|
@ -938,9 +1060,7 @@ describe("AddAutoRouterTab", () => {
|
|||
);
|
||||
vi.mocked(handleAddAutoRouterSubmit).mockImplementationOnce(actualSubmit.handleAddAutoRouterSubmit);
|
||||
renderWithProviders(<Harness />);
|
||||
const setup = await screen.findByRole("button", { name: "Choose models for me" });
|
||||
await waitFor(() => expect(setup).toBeEnabled());
|
||||
await userEvent.click(setup);
|
||||
await waitForAutomaticTiers();
|
||||
fireEvent.change(screen.getByLabelText("Auto Router Name"), { target: { value: "chained-router" } });
|
||||
await userEvent.click(screen.getByRole("radio", { name: "LLM" }));
|
||||
await selectAutoRouterOption("Judge model", ALL_FAMILY_MODELS[0].model_group);
|
||||
|
|
@ -968,16 +1088,16 @@ describe("AddAutoRouterTab", () => {
|
|||
const user = userEvent.setup();
|
||||
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
|
||||
renderWithProviders(<Harness />);
|
||||
const automaticSetup = await screen.findByRole("button", { name: "Choose models for me" });
|
||||
await waitFor(() => expect(automaticSetup).toBeEnabled());
|
||||
await user.click(automaticSetup);
|
||||
await waitForAutomaticTiers();
|
||||
fireEvent.change(screen.getByLabelText("Auto Router Name"), { target: { value: "reset-threshold-router" } });
|
||||
openAutoRouterAdvanced("Classification Method");
|
||||
await selectAutoRouterOption("Heuristic", "Heuristic v2");
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Success threshold" }), { target: { value: "1.1" } });
|
||||
expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeDisabled();
|
||||
|
||||
await user.click(automaticSetup);
|
||||
await act(async () => {
|
||||
await testQueryClient.refetchQueries({ queryKey: ["availableModels"] });
|
||||
});
|
||||
expect(screen.getByRole("textbox", { name: "Success threshold" })).toHaveValue("1.1");
|
||||
expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeDisabled();
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Success threshold" }), { target: { value: "" } });
|
||||
|
|
@ -1014,9 +1134,7 @@ describe("AddAutoRouterTab", () => {
|
|||
const user = userEvent.setup();
|
||||
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
|
||||
renderWithProviders(<Harness />);
|
||||
const setup = await screen.findByRole("button", { name: "Choose models for me" });
|
||||
await waitFor(() => expect(setup).toBeEnabled());
|
||||
await user.click(setup);
|
||||
await waitForAutomaticTiers();
|
||||
fireEvent.change(screen.getByLabelText("Auto Router Name"), { target: { value: "cache-aware-router" } });
|
||||
openAutoRouterAdvanced("Cache-aware routing");
|
||||
const toggle = screen.getByRole("switch", { name: "Cache-aware routing" });
|
||||
|
|
@ -1346,7 +1464,7 @@ describe("AddAutoRouterTab", () => {
|
|||
|
||||
// Custom is the escape hatch, not the headline choice, so it's listed after every bundled preset
|
||||
// rather than first.
|
||||
it("lists Custom Configuration after the bundled presets", async () => {
|
||||
it("lists Start from scratch after the bundled presets", async () => {
|
||||
renderWithProviders(<Harness />);
|
||||
openTemplateDropdown();
|
||||
|
||||
|
|
@ -1358,7 +1476,7 @@ describe("AddAutoRouterTab", () => {
|
|||
"Gemini Family",
|
||||
"Lite",
|
||||
"OpenAI Family",
|
||||
"Custom Configuration",
|
||||
"Start from scratch",
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -1516,11 +1634,11 @@ describe("AddAutoRouterTab", () => {
|
|||
expectTierModel("reasoning", ANTHROPIC_TIERS.REASONING[0]);
|
||||
});
|
||||
|
||||
it("expands detailed configuration when Custom Configuration is chosen", async () => {
|
||||
it("expands detailed configuration when Start from scratch is chosen", async () => {
|
||||
renderWithProviders(<Harness />);
|
||||
openTemplateDropdown();
|
||||
|
||||
await selectTemplate("Custom Configuration");
|
||||
await selectTemplate("Start from scratch");
|
||||
|
||||
openAutoRouterAdvanced("Keyword/Semantic Matching");
|
||||
|
||||
|
|
@ -1534,7 +1652,7 @@ describe("AddAutoRouterTab", () => {
|
|||
await selectTemplate("Anthropic Family");
|
||||
expect(screen.queryByText("Keyword/Semantic Matching")).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Models by tier")).toBeVisible();
|
||||
expect(screen.getByText("Define your tiers")).toBeVisible();
|
||||
|
||||
openAutoRouterAdvanced("Keyword/Semantic Matching");
|
||||
|
||||
|
|
@ -1803,7 +1921,7 @@ describe("AddAutoRouterTab", () => {
|
|||
"Gemini Family",
|
||||
"Lite",
|
||||
"OpenAI Family",
|
||||
"Custom Configuration",
|
||||
"Start from scratch",
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -1941,7 +2059,10 @@ describe("preset catalog fetch states", () => {
|
|||
vi.clearAllMocks();
|
||||
testQueryClient.clear();
|
||||
vi.mocked(handleAddAutoRouterSubmit).mockReset();
|
||||
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
|
||||
mockFetchAvailableModels.mockResolvedValue([
|
||||
...ALL_FAMILY_MODELS,
|
||||
{ model_group: "my-decision", mode: "evaluation" },
|
||||
]);
|
||||
vi.mocked(useAutoRouterPresets).mockReturnValue({
|
||||
...LOADED_PRESETS_QUERY,
|
||||
data: [
|
||||
|
|
@ -1961,6 +2082,8 @@ describe("preset catalog fetch states", () => {
|
|||
renderWithProviders(<Harness />);
|
||||
await waitForPresetEnabled("Bounded JEV");
|
||||
await selectTemplate("Bounded JEV");
|
||||
await userEvent.click(screen.getByRole("combobox", { name: "Decision model" }));
|
||||
await userEvent.click(await screen.findByRole("option", { name: "my-decision" }));
|
||||
fireEvent.change(screen.getByLabelText("Auto Router Name"), { target: { value: "bounded-router" } });
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeEnabled());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add Auto Router" }));
|
||||
|
|
@ -1996,7 +2119,7 @@ describe("preset catalog fetch states", () => {
|
|||
expect(screen.getByText("Loading templates...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("degrades to Custom Configuration with a retry hint that refetches the catalog", async () => {
|
||||
it("degrades to Start from scratch with a retry hint that refetches the catalog", async () => {
|
||||
const refetch = vi.fn();
|
||||
vi.mocked(useAutoRouterPresets).mockReturnValue({
|
||||
...LOADED_PRESETS_QUERY,
|
||||
|
|
@ -2011,7 +2134,7 @@ describe("preset catalog fetch states", () => {
|
|||
openTemplateDropdown();
|
||||
const options = screen.queryAllByRole("option");
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent("Custom Configuration");
|
||||
expect(options[0]).toHaveTextContent("Start from scratch");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(refetch).toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { AutoRouterAvailabilityContext, useAutoRouterAvailability } from "./AutoRouterAvailability";
|
||||
import { decisionDeploymentName } from "./jev_classifier_config";
|
||||
import DecisionModelSelect from "./DecisionModelSelect";
|
||||
import ClassifierPrimarySettings from "./ClassifierPrimarySettings";
|
||||
import AutoRouterClassifierTabs from "./AutoRouterClassifierTabs";
|
||||
import { getForecastConfigError, isForecastClassifier } from "../add_model/forecast_classifier_config";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
|
@ -10,7 +13,7 @@ import { FormField } from "@/components/shared/form/FormField";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
|
|
@ -55,7 +58,6 @@ import {
|
|||
} from "./build_complexity_router_config";
|
||||
import { builderParamsFromValue } from "./complexity_router_builder_params";
|
||||
import { activeTierName, activeTierRows, getCustomTierRowsError, resolveComplexityDefaultModel } from "./tier_rows";
|
||||
import { tierRowLabel } from "./complexity_router_tiers";
|
||||
import { buildAutoRouterTestTargets, AutoRouterTestTarget } from "./build_auto_router_test_targets";
|
||||
import { AutoRouterConnectionTestDialog } from "./auto_router_connection_test";
|
||||
import {
|
||||
|
|
@ -77,7 +79,7 @@ import {
|
|||
} from "@/lib/autorouter_presets";
|
||||
import { useAutoRouterPresets } from "@/app/(dashboard)/hooks/autoRouter/useAutoRouterPresets";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { buildAutomaticRouterConfig, buildPreferredTierModels } from "./auto_setup";
|
||||
import { buildAutomaticRouterConfig, buildPreferredTierModels, prefillEmptyTiers } from "./auto_setup";
|
||||
|
||||
interface AddAutoRouterTabProps {
|
||||
handleOk: () => void;
|
||||
|
|
@ -120,15 +122,6 @@ const isPresetHintAlarming = (availability: PresetAvailability): boolean => avai
|
|||
|
||||
const NO_PRESETS: AutoRouterPreset[] = [];
|
||||
|
||||
// A one-line summary of what's configured, shown when the detailed section is collapsed so a
|
||||
// caller can see the shape of the config without opening it.
|
||||
const tierConfigSummary = (config: ComplexityRouterConfigValue): string => {
|
||||
const parts = activeTierRows(config)
|
||||
.filter((row) => row.models.length > 0)
|
||||
.map((row) => `${tierRowLabel(row, config.tier_labels)}: ${row.models.join(", ")}`);
|
||||
return parts.length > 0 ? parts.join(" · ") : "No tiers configured yet";
|
||||
};
|
||||
|
||||
// Why the submit is unavailable, or null when it is available. The button reads this to disable
|
||||
// itself and to say what is missing, so the two can never give different answers. Checks the
|
||||
// config actually being built, not which preset (if any) it came from: a preset only ever
|
||||
|
|
@ -149,6 +142,9 @@ export const getSubmitBlockedReason = (
|
|||
: getMissingTiersError(activeTierRows(config))) ??
|
||||
getPlanModeTierError(config.plan_mode_min_tier, activeTierRows(config)) ??
|
||||
getKeywordTierRulesError(keywordTierRules, activeTierRows(config)) ??
|
||||
(effectiveClassifierType(config) === "jev" && !config.jev_classifier_config?.deployment_name
|
||||
? "Please select a decision model"
|
||||
: null) ??
|
||||
getClassifierModelError(config) ??
|
||||
getHeuristicV2SuccessThresholdError(config.heuristic_v2_success_threshold) ??
|
||||
getReminderMarkersError(config.reminder_markers) ??
|
||||
|
|
@ -337,7 +333,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
const templateItems = React.useMemo(
|
||||
() => [
|
||||
...sortedPresetOptions.map(({ preset }) => ({ value: preset.key, label: preset.label })),
|
||||
{ value: "custom", label: "Custom Configuration" },
|
||||
{ value: "custom", label: "Start from scratch" },
|
||||
],
|
||||
[sortedPresetOptions],
|
||||
);
|
||||
|
|
@ -353,20 +349,16 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
setEscalationKeywords(prefill.escalationKeywords);
|
||||
};
|
||||
|
||||
const handleAutomaticSetup = () => {
|
||||
if (automaticRouterConfig === null) return;
|
||||
setSelectedPreset(undefined);
|
||||
setComplexityRouterConfig({
|
||||
...complexityRouterConfig,
|
||||
tiers: { ...complexityRouterConfig.tiers, ...automaticRouterConfig.tiers },
|
||||
tier_model_params: { ...complexityRouterConfig.tier_model_params, ...automaticRouterConfig.tier_model_params },
|
||||
});
|
||||
|
||||
toast.success("Models selected", { description: tierConfigSummary(automaticRouterConfig) });
|
||||
};
|
||||
const [prefillAttempted, setPrefillAttempted] = useState(false);
|
||||
const automaticPrefill = automaticSetupLoading || modelsUnverifiable ? null : automaticRouterConfig;
|
||||
if (automaticPrefill && !prefillAttempted) {
|
||||
setPrefillAttempted(true);
|
||||
setComplexityRouterConfig((current) => prefillEmptyTiers(current, automaticPrefill));
|
||||
}
|
||||
|
||||
const handlePresetChange = (presetKey: string | undefined) => {
|
||||
if (!presetKey || presetKey === "custom") {
|
||||
setPrefillAttempted(true);
|
||||
setSelectedPreset(presetKey);
|
||||
applyPrefill(buildEmptyPrefill());
|
||||
|
||||
|
|
@ -380,6 +372,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
const presetState = presetAvailability(preset);
|
||||
if (presetState.kind !== "available") return;
|
||||
|
||||
setPrefillAttempted(true);
|
||||
setSelectedPreset(presetKey);
|
||||
applyPrefill(buildPresetPrefill(preset.complexity_router_config, availability));
|
||||
};
|
||||
|
|
@ -388,6 +381,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
tiers: Object.fromEntries(activeTierRows(complexityRouterConfig).map((row) => [activeTierName(row), row.models])),
|
||||
classifierType: effectiveClassifierType(complexityRouterConfig),
|
||||
classifierLlmConfig: complexityRouterConfig.classifier_llm_config,
|
||||
decisionDeploymentName: decisionDeploymentName(complexityRouterConfig.jev_classifier_config),
|
||||
semanticMatchingEnabled,
|
||||
embeddingModel,
|
||||
defaultModel: complexityRouterConfig.default_model,
|
||||
|
|
@ -546,8 +540,71 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
setIsTestModalVisible(true);
|
||||
};
|
||||
|
||||
const tierSetup = (
|
||||
<div className="mb-4 w-full space-y-2">
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
items={templateItems}
|
||||
value={selectedPreset ?? null}
|
||||
onValueChange={(presetKey: string | null) => handlePresetChange(presetKey ?? undefined)}
|
||||
>
|
||||
<SelectTrigger aria-label="Use a template" data-testid="template-selector" className="w-full">
|
||||
<SelectValue placeholder="Use a template" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="[&_[data-slot=select-item]>div]:min-w-0 [&_[data-slot=select-item]>div]:shrink [&_[data-slot=select-item]>div]:whitespace-normal [&_[data-slot=select-item]>div]:break-words">
|
||||
{sortedPresetOptions.map(({ preset, availability: presetState }) => {
|
||||
const disabledHint = presetDisabledHint(presetState);
|
||||
const hintClass = isPresetHintAlarming(presetState) ? "text-destructive" : "text-muted-foreground";
|
||||
const matchedHint =
|
||||
presetState.kind === "available" && presetState.viaDeployments ? "Matches your deployments" : null;
|
||||
|
||||
return (
|
||||
<SelectItem
|
||||
key={preset.key}
|
||||
value={preset.key}
|
||||
label={preset.label}
|
||||
disabled={disabledHint !== null}
|
||||
title={disabledHint ?? preset.description}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{preset.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{preset.description}</div>
|
||||
<div className="text-xs text-muted-foreground">Replaces classifier and model choices</div>
|
||||
{disabledHint && <div className={`text-xs mt-1 ${hintClass}`}>{disabledHint}</div>}
|
||||
{matchedHint && <div className="text-xs mt-1 text-success">{matchedHint}</div>}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
<SelectSeparator className="mx-0" />
|
||||
<SelectItem value="custom" label="Start from scratch">
|
||||
<div>
|
||||
<div className="font-medium">Start from scratch</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Clears tier models, switches to Heuristics, and resets routing settings
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">Replaces classifier and model choices</p>
|
||||
{presetsPending && <div className="text-xs mt-1 text-muted-foreground">Loading templates...</div>}
|
||||
{presetsUnavailable && (
|
||||
<div className="text-xs mt-1 text-destructive">
|
||||
Could not load templates, so only Start from scratch is shown.{" "}
|
||||
<button type="button" className="underline" onClick={() => void refetchPresets()}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const configurationForm = (
|
||||
<ComplexityRouterConfig
|
||||
creationFlow
|
||||
tierSetup={tierSetup}
|
||||
editingTiers={editingTiers}
|
||||
onEditingTiersChange={setEditingTiers}
|
||||
modelInfo={modelInfo}
|
||||
|
|
@ -571,7 +628,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
showValidationErrors={showValidationErrors}
|
||||
/>
|
||||
);
|
||||
const forecast = isForecastClassifier(complexityRouterConfig.classifier_type);
|
||||
|
||||
return (
|
||||
<AutoRouterAvailabilityContext.Provider value={routerAvailability}>
|
||||
|
|
@ -579,7 +635,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
<Card>
|
||||
<CardContent>
|
||||
<form onSubmit={form.handleSubmit(() => handleAutoRouterSubmit())} noValidate>
|
||||
<div className="mb-6">
|
||||
<div className="mb-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="auto_router_name"
|
||||
|
|
@ -590,6 +646,31 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
)}
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="mb-6 border-b pb-6">
|
||||
{isAdmin && (
|
||||
<div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model_access_group"
|
||||
label={labelWithHint(
|
||||
"Model Access Group",
|
||||
"Use model access groups to control who can access this auto router",
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<AccessGroupTagsCombobox
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={modelAccessGroups}
|
||||
ariaInvalid={ariaInvalid}
|
||||
ariaDescribedBy={ariaDescribedBy}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{requiresTeamScope && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
|
@ -610,129 +691,39 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
|||
</FormField>
|
||||
)}
|
||||
|
||||
<AutoRouterClassifierTabs value={complexityRouterConfig} onChange={handleClassifierChange}>
|
||||
<AutoRouterClassifierTabs
|
||||
value={complexityRouterConfig}
|
||||
onChange={handleClassifierChange}
|
||||
decisionModel={
|
||||
<DecisionModelSelect
|
||||
value={complexityRouterConfig}
|
||||
onChange={setComplexityRouterConfig}
|
||||
modelInfo={modelInfo}
|
||||
deployments={deployments}
|
||||
/>
|
||||
}
|
||||
judgeModel={
|
||||
<ClassifierPrimarySettings
|
||||
section="judge"
|
||||
value={complexityRouterConfig}
|
||||
onChange={setComplexityRouterConfig}
|
||||
modelOptions={modelInfo
|
||||
.filter((model) => model.mode !== "embedding")
|
||||
.map((model) => ({ value: model.model_group, label: model.model_group }))}
|
||||
showValidationErrors={showValidationErrors}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FieldGroup>
|
||||
<div className="space-y-4 empty:hidden">
|
||||
{!forecast && (
|
||||
<>
|
||||
{!automaticSetupLoading && automaticRouterConfig && !complexityRouterConfig.custom_tier_set && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-muted px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Choose models quickly</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Fill the tiers while keeping your classifier and settings.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="configure-automatically-button"
|
||||
onClick={handleAutomaticSetup}
|
||||
>
|
||||
Choose models for me
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground mb-2">Template</label>
|
||||
<Select
|
||||
items={templateItems}
|
||||
value={selectedPreset ?? null}
|
||||
onValueChange={(presetKey: string | null) => handlePresetChange(presetKey ?? undefined)}
|
||||
>
|
||||
<SelectTrigger data-testid="template-selector" className="w-full">
|
||||
<SelectValue placeholder="Choose a template or select Custom to define your own" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortedPresetOptions.map(({ preset, availability: presetState }) => {
|
||||
const disabledHint = presetDisabledHint(presetState);
|
||||
const hintClass = isPresetHintAlarming(presetState)
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground";
|
||||
const matchedHint =
|
||||
presetState.kind === "available" && presetState.viaDeployments
|
||||
? "Matches your deployments"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<SelectItem
|
||||
key={preset.key}
|
||||
value={preset.key}
|
||||
label={preset.label}
|
||||
disabled={disabledHint !== null}
|
||||
title={disabledHint ?? preset.description}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{preset.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{preset.description}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Replaces classifier and model choices
|
||||
</div>
|
||||
{disabledHint && (
|
||||
<div className={`text-xs mt-1 ${hintClass}`}>{disabledHint}</div>
|
||||
)}
|
||||
{matchedHint && <div className="text-xs mt-1 text-success">{matchedHint}</div>}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
<SelectItem value="custom" label="Custom Configuration">
|
||||
<div>
|
||||
<div className="font-medium">Custom Configuration</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Define your auto router from scratch
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{presetsPending && (
|
||||
<div className="text-xs mt-1 text-muted-foreground">Loading templates...</div>
|
||||
)}
|
||||
{presetsUnavailable && (
|
||||
<div className="text-xs mt-1 text-destructive">
|
||||
Could not load templates, so only Custom Configuration is shown.{" "}
|
||||
<button type="button" className="underline" onClick={() => void refetchPresets()}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{modelsUnverifiable && (
|
||||
<div className="text-xs mt-1 text-destructive">
|
||||
Could not load available models.{" "}
|
||||
<button type="button" className="underline" onClick={() => refetchModels()}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{configurationForm}
|
||||
|
||||
{isAdmin && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model_access_group"
|
||||
label={labelWithHint(
|
||||
"Model Access Group",
|
||||
"Use model access groups to control who can access this auto router",
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<AccessGroupTagsCombobox
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={modelAccessGroups}
|
||||
ariaInvalid={ariaInvalid}
|
||||
ariaDescribedBy={ariaDescribedBy}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
{modelsUnverifiable && (
|
||||
<p className="text-sm text-destructive">
|
||||
Could not load available models.{" "}
|
||||
<button type="button" className="underline" onClick={() => void refetchModels()}>
|
||||
Retry
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{configurationForm}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Tooltip>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import { buildModelAvailability, deploymentRefsFromModelInfo } from "@/lib/autorouter_presets";
|
||||
import { buildAutomaticRouterConfig, buildPreferredTierModels, type PreferredTierModels } from "./auto_setup";
|
||||
import {
|
||||
buildAutomaticRouterConfig,
|
||||
buildPreferredTierModels,
|
||||
prefillEmptyTiers,
|
||||
type PreferredTierModels,
|
||||
} from "./auto_setup";
|
||||
|
||||
const models = (...names: string[]) => names.map((model_group) => ({ model_group, mode: "chat" }));
|
||||
const reasoningModel = (model_group: string, supported_reasoning_efforts: string[]) => ({
|
||||
|
|
@ -230,3 +236,39 @@ describe("buildAutomaticRouterConfig", () => {
|
|||
).toEqual(["gpt-4o-mini", "gpt-4o-mini", "gpt-4o-mini", "gpt-4o-mini"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prefillEmptyTiers", () => {
|
||||
const empty: ComplexityRouterConfigValue = {
|
||||
classifier_type: "jev",
|
||||
jev_classifier_config: { model: "jev-custom", timeout_ms: 4200 },
|
||||
classification_mode: "user_turn",
|
||||
tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
};
|
||||
const automatic: ComplexityRouterConfigValue = {
|
||||
classifier_type: "heuristic_v2",
|
||||
tiers: { SIMPLE: ["fast"], MEDIUM: ["mid"], COMPLEX: ["strong"], REASONING: ["strong"] },
|
||||
tier_model_params: { REASONING: { strong: { reasoning_effort: "high" } } },
|
||||
};
|
||||
it("fills tiers while preserving a classifier configured before models load", () => {
|
||||
expect(prefillEmptyTiers(empty, automatic)).toEqual({
|
||||
...empty,
|
||||
tiers: automatic.tiers,
|
||||
tier_model_params: automatic.tier_model_params,
|
||||
});
|
||||
});
|
||||
it("leaves the draft alone when no recommended models are available", () => {
|
||||
expect(prefillEmptyTiers(empty, null)).toBe(empty);
|
||||
});
|
||||
it("preserves a partial manual assignment", () => {
|
||||
const partial = { ...empty, tiers: { ...empty.tiers, SIMPLE: ["manual"] } };
|
||||
expect(prefillEmptyTiers(partial, automatic)).toBe(partial);
|
||||
});
|
||||
it("preserves a custom tier set created before models load", () => {
|
||||
const custom = { ...empty, custom_tier_set: { tiers: [], fallback_tier_id: "" } };
|
||||
expect(prefillEmptyTiers(custom, automatic)).toBe(custom);
|
||||
});
|
||||
it.each(["capability", "llm_v2"] as const)("does not prefill %s solver pools", (classifier_type) => {
|
||||
const solver = { ...empty, classifier_type };
|
||||
expect(prefillEmptyTiers(solver, automatic)).toBe(solver);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { activeTierRows } from "./tier_rows";
|
||||
import { isForecastClassifier } from "./forecast_classifier_config";
|
||||
import { isAutoRouterDeployment, type AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import type { ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import { resolveAvailableModel, type AutoRouterPreset, type ModelAvailability } from "@/lib/autorouter_presets";
|
||||
|
|
@ -93,3 +95,16 @@ export const buildAutomaticRouterConfig = (
|
|||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const prefillEmptyTiers = (
|
||||
current: ComplexityRouterConfigValue,
|
||||
automatic: ComplexityRouterConfigValue | null,
|
||||
): ComplexityRouterConfigValue => {
|
||||
if (!automatic || current.custom_tier_set || isForecastClassifier(current.classifier_type)) return current;
|
||||
if (activeTierRows(current).some((row) => row.models.length > 0)) return current;
|
||||
return {
|
||||
...current,
|
||||
tiers: { ...current.tiers, ...automatic.tiers },
|
||||
tier_model_params: { ...current.tier_model_params, ...automatic.tier_model_params },
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -42,3 +42,24 @@ describe("jevClassifierConfigSchema", () => {
|
|||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves legacy classifier tuning when the backend returns a null deployment reference", () => {
|
||||
const legacyConfig = {
|
||||
deployment_name: null,
|
||||
provider: "laya",
|
||||
model: "english",
|
||||
timeout_ms: 4100,
|
||||
};
|
||||
const parsed = jevClassifierConfigSchema.parse(legacyConfig);
|
||||
expect(parsed).toMatchObject({ provider: "laya", model: "english", timeout_ms: 4100 });
|
||||
expect(parsed.deployment_name).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves a deployment alias independently of legacy provider model validation", () => {
|
||||
const parsed = jevClassifierConfigSchema.parse({
|
||||
deployment_name: "decider",
|
||||
provider: "databricks",
|
||||
timeout_ms: 4100,
|
||||
});
|
||||
expect(parsed.deployment_name).toBe("decider");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ const isSupportedClassifierModel = (provider: OssClassifierProvider | undefined,
|
|||
};
|
||||
|
||||
const jevClassifierConfigFields = {
|
||||
deployment_name: z.preprocess((value) => value ?? undefined, z.string().trim().min(1).optional()),
|
||||
provider: z.preprocess(
|
||||
(value) => (value === "typesafe" ? "jev" : value),
|
||||
z.enum(["jev", "laya", "bespoke", "databricks"]).optional(),
|
||||
|
|
@ -45,10 +46,13 @@ const jevClassifierConfigFields = {
|
|||
export const jevClassifierConfigSchema = z
|
||||
.object(jevClassifierConfigFields)
|
||||
.transform((config) => ({ ...config, model: config.model ?? defaultClassifierModel(config.provider) }))
|
||||
.refine((config) => isSupportedClassifierModel(config.provider, config.model), {
|
||||
error: "Select a supported classifier model, or enter the bare Databricks serving endpoint name",
|
||||
path: ["model"],
|
||||
});
|
||||
.refine(
|
||||
(config) => config.deployment_name !== undefined || isSupportedClassifierModel(config.provider, config.model),
|
||||
{
|
||||
error: "Select a supported classifier model, or enter the bare Databricks serving endpoint name",
|
||||
path: ["model"],
|
||||
},
|
||||
);
|
||||
|
||||
export type JevClassifierConfig = z.infer<typeof jevClassifierConfigSchema>;
|
||||
|
||||
|
|
@ -73,6 +77,7 @@ export const hydrateOssClassifier = (config: {
|
|||
export const normalizeJevClassifierConfig = (
|
||||
config: JevClassifierConfig = defaultJevClassifierConfig(),
|
||||
): JevClassifierConfig => ({
|
||||
...(config.deployment_name !== undefined && { deployment_name: config.deployment_name }),
|
||||
provider: config.provider ?? "jev",
|
||||
model: config.model.trim(),
|
||||
timeout_ms: config.timeout_ms,
|
||||
|
|
@ -82,3 +87,6 @@ export const normalizeJevClassifierConfig = (
|
|||
circuit_breaker_cooldown_seconds: config.circuit_breaker_cooldown_seconds,
|
||||
}),
|
||||
});
|
||||
|
||||
export const decisionDeploymentName = (config: JevClassifierConfig | undefined): string | undefined =>
|
||||
config?.deployment_name;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({
|
||||
useModelCostMap: () => ({ data: { "typesafe/jev-latest": { litellm_provider: "typesafe", mode: "evaluation" } } }),
|
||||
}));
|
||||
|
||||
import { openAutoRouterAdvanced, selectAutoRouterOption } from "../../../tests/autoRouterSetup";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
|
@ -7,6 +11,7 @@ import { act, fireEvent, renderWithProviders, screen, waitFor, within, testQuery
|
|||
import { toast } from "@/lib/toast";
|
||||
import EditAutoRouterModal from "./edit_auto_router_modal";
|
||||
import { apiClient } from "../networking";
|
||||
import { fetchAvailableModels } from "@/components/llm_calls/fetch_models";
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults",
|
||||
async () => await import("../../../tests/mocks/complexityScorerDefaults"),
|
||||
|
|
@ -30,6 +35,12 @@ vi.mock("../networking", () => ({
|
|||
apiClient: { post: vi.fn().mockResolvedValue({ allowances: [], error: null }) },
|
||||
modelPatchUpdateCall,
|
||||
modelAvailableCall,
|
||||
modelInfoCall: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ model_name: "decision-one", litellm_params: { model: "typesafe/jev-latest" } },
|
||||
{ model_name: "decision-two", litellm_params: { model: "typesafe/jev-latest" } },
|
||||
],
|
||||
}),
|
||||
getAutoRouterClassifierDefaultPromptCall,
|
||||
getAutoRouterAssembledPromptCall,
|
||||
validateAutoRouterConfig,
|
||||
|
|
@ -1571,3 +1582,47 @@ describe("EditAutoRouterModal availability checks", () => {
|
|||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalledOnce());
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditAutoRouterModal deployed decision classifier", () => {
|
||||
it.each(["evaluation", undefined])(
|
||||
"shows and changes the saved deployment without ignored legacy model controls (mode=%s)",
|
||||
async (mode) => {
|
||||
vi.mocked(fetchAvailableModels).mockResolvedValueOnce([
|
||||
{ model_group: "decision-one", mode },
|
||||
{ model_group: "decision-two", mode },
|
||||
{ model_group: "gpt-4o-mini", mode: "chat" },
|
||||
]);
|
||||
modelPatchUpdateCall.mockClear();
|
||||
renderModal({
|
||||
modelData: {
|
||||
...MODEL_DATA,
|
||||
litellm_params: {
|
||||
...MODEL_DATA.litellm_params,
|
||||
complexity_router_config: {
|
||||
...STORED_CONFIG,
|
||||
classifier_type: "oss_classifier",
|
||||
opensource_classifier_config: { provider: "jev", model: "jev-latest", deployment_name: "decision-one" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const picker = await screen.findByRole("combobox", { name: "Decision model" });
|
||||
expect(picker).toHaveValue("decision-one");
|
||||
await userEvent.click(picker);
|
||||
await userEvent.click(await screen.findByRole("option", { name: "decision-two" }));
|
||||
expect(picker).toHaveValue("decision-two");
|
||||
openAutoRouterAdvanced("Classification Method");
|
||||
expect(screen.queryByLabelText("Classifier Model")).not.toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("Classifier Timeout (ms)"), { target: { value: "4500" } });
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Save Changes" })).toBeEnabled());
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
|
||||
expect(savedConfig().opensource_classifier_config).toEqual(
|
||||
expect.objectContaining({
|
||||
deployment_name: "decision-two",
|
||||
timeout_ms: 4500,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { autoRouterListKey, fetchAllModelDeployments } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import DecisionModelSelect from "../add_model/DecisionModelSelect";
|
||||
import { AutoRouterAvailabilityContext, useAutoRouterAvailability } from "../add_model/AutoRouterAvailability";
|
||||
import AutoRouterClassifierTabs from "../add_model/AutoRouterClassifierTabs";
|
||||
import { usesClassifierContext } from "../add_model/classifier_types";
|
||||
|
|
@ -236,6 +240,42 @@ export const buildUpdatedComplexityRouterConfig = (
|
|||
};
|
||||
};
|
||||
|
||||
const EditClassifierTabs = ({
|
||||
value,
|
||||
onChange,
|
||||
children,
|
||||
modelInfo,
|
||||
accessToken,
|
||||
userRole,
|
||||
isVisible,
|
||||
}: React.ComponentProps<typeof AutoRouterClassifierTabs> & {
|
||||
modelInfo: ModelGroup[];
|
||||
accessToken: string;
|
||||
userRole: string;
|
||||
isVisible: boolean;
|
||||
}) => {
|
||||
const { userId } = useAuthorized();
|
||||
const hasDeployment = Boolean(value.jev_classifier_config?.deployment_name);
|
||||
const { data: deployments } = useQuery({
|
||||
queryKey: autoRouterListKey(userId ?? "", userRole),
|
||||
queryFn: () => fetchAllModelDeployments(accessToken, userId ?? "", userRole),
|
||||
enabled: Boolean(isVisible && accessToken && hasDeployment),
|
||||
});
|
||||
return (
|
||||
<AutoRouterClassifierTabs
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
decisionModel={
|
||||
hasDeployment ? (
|
||||
<DecisionModelSelect value={value} onChange={onChange} modelInfo={modelInfo} deployments={deployments} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</AutoRouterClassifierTabs>
|
||||
);
|
||||
};
|
||||
|
||||
const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
||||
isVisible,
|
||||
onCancel,
|
||||
|
|
@ -635,7 +675,14 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
{isComplexityRouterModel ? (
|
||||
/* Complexity Router Configuration */
|
||||
<div className="w-full">
|
||||
<AutoRouterClassifierTabs value={complexityRouterConfig} onChange={setComplexityRouterConfig}>
|
||||
<EditClassifierTabs
|
||||
value={complexityRouterConfig}
|
||||
onChange={setComplexityRouterConfig}
|
||||
modelInfo={modelInfo}
|
||||
accessToken={accessToken}
|
||||
userRole={userRole}
|
||||
isVisible={isVisible}
|
||||
>
|
||||
<ComplexityRouterConfig
|
||||
editingTiers={editingTiers}
|
||||
onEditingTiersChange={setEditingTiers}
|
||||
|
|
@ -664,7 +711,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
|
|||
autoRouterCompression={autoRouterCompression}
|
||||
onAutoRouterCompressionChange={isMemberManaged ? undefined : setAutoRouterCompression}
|
||||
/>
|
||||
</AutoRouterClassifierTabs>
|
||||
</EditClassifierTabs>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -506,9 +506,9 @@ describe("provider_info_helpers", () => {
|
|||
it("should list only typesafe decision models for the 'TypeSafe' provider key, not the OpenRouter-hosted one", () => {
|
||||
const modelMap = {
|
||||
"typesafe/jev-latest": { litellm_provider: "typesafe", mode: "evaluation" },
|
||||
"typesafe/jev-preview": { litellm_provider: "typesafe", mode: "evaluation" },
|
||||
"typesafe/jev-preview": { litellm_provider: "typesafe", mode: "decisions" },
|
||||
"openrouter/typesafe/jev-1.13": { litellm_provider: "openrouter", mode: "evaluation" },
|
||||
"strands_decider/strands-decider-2B-hobson-v19": { litellm_provider: "strands_decider", mode: "evaluation" },
|
||||
"strands_decider/strands-decider-2B-hobson-v19": { litellm_provider: "strands_decider", mode: "decisions" },
|
||||
};
|
||||
expect(getProviderModels("TypeSafe" as Providers, modelMap)).toEqual([
|
||||
"typesafe/jev-latest",
|
||||
|
|
@ -518,7 +518,7 @@ describe("provider_info_helpers", () => {
|
|||
|
||||
it("should list only strands_decider models for the 'StrandsDecider' provider key", () => {
|
||||
const modelMap = {
|
||||
"strands_decider/strands-decider-2B-hobson-v19": { litellm_provider: "strands_decider", mode: "evaluation" },
|
||||
"strands_decider/strands-decider-2B-hobson-v19": { litellm_provider: "strands_decider", mode: "decisions" },
|
||||
"typesafe/jev-latest": { litellm_provider: "typesafe", mode: "evaluation" },
|
||||
"openrouter/typesafe/jev-1.13": { litellm_provider: "openrouter", mode: "evaluation" },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -815,6 +815,24 @@ describe("autorouter_presets", () => {
|
|||
});
|
||||
|
||||
describe("getReferencedModelsError", () => {
|
||||
it.each(["jev", "heuristic"] as const)(
|
||||
"only checks a removed decision deployment when %s is active",
|
||||
(classifierType) => {
|
||||
const config = {
|
||||
tiers: { SIMPLE: ["solver"], MEDIUM: [], COMPLEX: [], REASONING: [] },
|
||||
classifierType,
|
||||
classifierLlmConfig: undefined,
|
||||
decisionDeploymentName: "removed-decision",
|
||||
semanticMatchingEnabled: false,
|
||||
embeddingModel: undefined,
|
||||
};
|
||||
const availability = groupsOnly(["solver"]);
|
||||
expect(getReferencedModelsError(config, availability)).toBe(
|
||||
classifierType === "jev" ? "Model(s) no longer available: removed-decision" : null,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const tiers = { SIMPLE: ["gpt-5-nano"], MEDIUM: [], COMPLEX: [], REASONING: [] };
|
||||
const available = groupsOnly(["gpt-5-nano"]);
|
||||
// Both fields are always populated with a model missing from `available`; only the
|
||||
|
|
|
|||
|
|
@ -248,21 +248,27 @@ export const getReferencedModelsError = (
|
|||
tiers: ComplexityRouterConfigPayload["tiers"];
|
||||
classifierType: ClassifierType;
|
||||
classifierLlmConfig: ClassifierLLMConfig | undefined;
|
||||
decisionDeploymentName?: string;
|
||||
semanticMatchingEnabled: boolean;
|
||||
embeddingModel: string | undefined;
|
||||
defaultModel?: string;
|
||||
},
|
||||
availability: ModelAvailability,
|
||||
): string | null => {
|
||||
const missing = getMissingModels(
|
||||
{
|
||||
tiers: params.tiers,
|
||||
default_model: params.defaultModel,
|
||||
classifier_llm_config: usesLlmClassifier(params.classifierType) ? params.classifierLlmConfig : undefined,
|
||||
embedding_model: params.semanticMatchingEnabled ? params.embeddingModel : undefined,
|
||||
},
|
||||
availability,
|
||||
);
|
||||
if (
|
||||
params.classifierType === "jev" &&
|
||||
params.decisionDeploymentName &&
|
||||
!resolveAvailableModel(params.decisionDeploymentName, availability)
|
||||
) {
|
||||
return `Model(s) no longer available: ${params.decisionDeploymentName}`;
|
||||
}
|
||||
const references = {
|
||||
tiers: params.tiers,
|
||||
default_model: params.defaultModel,
|
||||
classifier_llm_config: usesLlmClassifier(params.classifierType) ? params.classifierLlmConfig : undefined,
|
||||
embedding_model: params.semanticMatchingEnabled ? params.embeddingModel : undefined,
|
||||
};
|
||||
const missing = getMissingModels(references, availability);
|
||||
return missing.length > 0 ? `Model(s) no longer available: ${missing.join(", ")}` : null;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ const COST_MAP = {
|
|||
};
|
||||
|
||||
describe("isDecisionMode", () => {
|
||||
it("is true only for the evaluation mode", () => {
|
||||
it("is true for both the decisions and evaluation modes", () => {
|
||||
expect(isDecisionMode("decisions")).toBe(true);
|
||||
expect(isDecisionMode("evaluation")).toBe(true);
|
||||
expect(isDecisionMode("chat")).toBe(false);
|
||||
expect(isDecisionMode(null)).toBe(false);
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ export interface DecisionCatalog {
|
|||
|
||||
export const EMPTY_DECISION_CATALOG: DecisionCatalog = { models: new Set(), providers: new Map() };
|
||||
|
||||
export const isDecisionMode = (mode: string | null | undefined): boolean => mode === "evaluation";
|
||||
export const isDecisionMode = (mode: string | null | undefined): boolean =>
|
||||
mode === "decisions" || mode === "evaluation";
|
||||
|
||||
export interface DecisionModelFields {
|
||||
readonly mode?: string | null;
|
||||
|
|
|
|||
73
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
73
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -13658,6 +13658,31 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/public/whats_new": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Public Whats New
|
||||
* @description Return the launches the dashboard Home page shows under What's new.
|
||||
*
|
||||
* Resolved once per process: fetched from ``litellm.whats_new_url`` (override with ``LITELLM_WHATS_NEW_URL``)
|
||||
* on the first request, falling back to the list bundled with the package on any failure, so an airgapped
|
||||
* proxy serves the bundled list without retrying. Set ``LITELLM_LOCAL_WHATS_NEW=True`` to skip the fetch.
|
||||
* A restart picks up a newly published list.
|
||||
*/
|
||||
get: operations["get_public_whats_new_public_whats_new_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/queue/chat/completions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -27208,7 +27233,7 @@ export interface components {
|
|||
* Mode
|
||||
* @description The mode to test the model with. If not provided, resolved the way /health does: the deployment's model_info.mode (only while the request tests the deployment's own model), then the mode the provider requires for that model, then the model cost map.
|
||||
*/
|
||||
mode?: ("chat" | "completion" | "embedding" | "audio_speech" | "audio_transcription" | "image_generation" | "image_edit" | "video_generation" | "batch" | "rerank" | "realtime" | "responses" | "anthropic_messages" | "ocr" | "evaluation") | null;
|
||||
mode?: ("chat" | "completion" | "embedding" | "audio_speech" | "audio_transcription" | "image_generation" | "image_edit" | "video_generation" | "batch" | "rerank" | "realtime" | "responses" | "anthropic_messages" | "ocr" | "evaluation" | "decisions") | null;
|
||||
/**
|
||||
* Model Info
|
||||
* @description Model info for the health check
|
||||
|
|
@ -39186,6 +39211,8 @@ export interface components {
|
|||
* @default true
|
||||
*/
|
||||
circuit_breaker_enabled: boolean;
|
||||
/** Deployment Name */
|
||||
deployment_name?: string | null;
|
||||
/**
|
||||
* Instructions
|
||||
* @description Replaces the built-in classification instructions
|
||||
|
|
@ -49245,6 +49272,30 @@ export interface components {
|
|||
type: "web_search" | "web_search_2025_08_26";
|
||||
user_location?: components["schemas"]["openai__types__responses__web_search_tool_param__UserLocation"] | null;
|
||||
};
|
||||
/**
|
||||
* WhatsNewLaunch
|
||||
* @description One launch card in the dashboard Home page's What's new section.
|
||||
*/
|
||||
WhatsNewLaunch: {
|
||||
/** Description */
|
||||
description: string;
|
||||
/** Href */
|
||||
href: string;
|
||||
/** Icon */
|
||||
icon: string;
|
||||
/**
|
||||
* Published On
|
||||
* Format: date
|
||||
*/
|
||||
published_on: string;
|
||||
/** Title */
|
||||
title: string;
|
||||
};
|
||||
/** WhatsNewResponse */
|
||||
WhatsNewResponse: {
|
||||
/** Launches */
|
||||
launches: components["schemas"]["WhatsNewLaunch"][];
|
||||
};
|
||||
/** WorkerRegistryEntry */
|
||||
WorkerRegistryEntry: {
|
||||
/** Name */
|
||||
|
|
@ -68630,6 +68681,26 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
get_public_whats_new_public_whats_new_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["WhatsNewResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
async_queue_request_queue_chat_completions_post: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue