mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-25 05:54:53 +00:00
merge: sync rc/1.106.0 with Databricks ai_decide backport
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
commit
bbcbebe410
108 changed files with 9430 additions and 1458 deletions
2
.github/workflows/test-unit.yml
vendored
2
.github/workflows/test-unit.yml
vendored
|
|
@ -139,6 +139,7 @@ jobs:
|
|||
test-path: |-
|
||||
tests/unit/decisions
|
||||
tests/unit/litellm_core_utils
|
||||
tests/unit/systemone
|
||||
python-version: "3.12"
|
||||
workers: 4
|
||||
reruns: 0
|
||||
|
|
@ -343,6 +344,7 @@ jobs:
|
|||
tests/unit/llms/snowflake
|
||||
tests/unit/llms/soniox
|
||||
tests/unit/llms/stability
|
||||
tests/unit/llms/strands_decider
|
||||
tests/unit/llms/tavily
|
||||
tests/unit/llms/tencent
|
||||
tests/unit/llms/tinyfish
|
||||
|
|
|
|||
|
|
@ -1478,6 +1478,7 @@ from .rust_bridge import rust
|
|||
from .rag.main import *
|
||||
from .sandbox.main import *
|
||||
from .decisions.main import *
|
||||
from .systemone.main import *
|
||||
from .tool_loop import ToolLoopMaxRoundsExceeded, arun_tool_loop, run_tool_loop
|
||||
from .search.main import *
|
||||
from .realtime_api.main import (
|
||||
|
|
@ -1684,6 +1685,9 @@ if TYPE_CHECKING:
|
|||
from .llms.databricks.decisions.transformation import (
|
||||
DatabricksDecisionsConfig as DatabricksDecisionsConfig,
|
||||
)
|
||||
from .llms.hosted_vllm.decisions.transformation import (
|
||||
HostedVLLMDecisionsConfig as HostedVLLMDecisionsConfig,
|
||||
)
|
||||
from .llms.openai.decisions.transformation import (
|
||||
OpenAIDecisionsConfig as OpenAIDecisionsConfig,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ LLM_CONFIG_NAMES: Final = (
|
|||
"CloudflareDecisionsConfig",
|
||||
"StrandsDeciderDecisionsConfig",
|
||||
"DatabricksDecisionsConfig",
|
||||
"HostedVLLMDecisionsConfig",
|
||||
"OpenAIDecisionsConfig",
|
||||
"AzureAIDecisionsConfig",
|
||||
"NvidiaNimRerankConfig",
|
||||
|
|
@ -726,6 +727,10 @@ _LLM_CONFIGS_IMPORT_MAP: Final = {
|
|||
"StrandsDeciderDecisionsConfig",
|
||||
),
|
||||
"DatabricksDecisionsConfig": (".llms.databricks.decisions.transformation", "DatabricksDecisionsConfig"),
|
||||
"HostedVLLMDecisionsConfig": (
|
||||
".llms.hosted_vllm.decisions.transformation",
|
||||
"HostedVLLMDecisionsConfig",
|
||||
),
|
||||
"OpenAIDecisionsConfig": (".llms.openai.decisions.transformation", "OpenAIDecisionsConfig"),
|
||||
"AzureAIDecisionsConfig": (".llms.azure_ai.decisions.transformation", "AzureAIDecisionsConfig"),
|
||||
"NvidiaNimRerankConfig": (
|
||||
|
|
|
|||
|
|
@ -285,6 +285,7 @@ DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET: Final = int(
|
|||
|
||||
# Provider-specific API base URLs
|
||||
XAI_API_BASE: Final = "https://api.x.ai/v1"
|
||||
PLACEHOLDER_API_KEY: Final = "fake-api-key"
|
||||
OPEN_SANDBOX_API_BASE_ENV_VAR: Final = "OPEN_SANDBOX_API_BASE"
|
||||
OPEN_SANDBOX_API_KEY_ENV_VAR: Final = "OPEN_SANDBOX_API_KEY"
|
||||
OPEN_SANDBOX_DEFAULT_TEMPLATE: Final = "opensandbox/code-interpreter:v1.1.0"
|
||||
|
|
|
|||
|
|
@ -2026,6 +2026,8 @@ def response_cost_calculator(
|
|||
"asearch",
|
||||
"decisions",
|
||||
"adecisions",
|
||||
"systemone",
|
||||
"asystemone",
|
||||
],
|
||||
optional_params: dict,
|
||||
cache_hit: bool | None = None,
|
||||
|
|
|
|||
347
litellm/decisions/call.py
Normal file
347
litellm/decisions/call.py
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import Final, TypeAlias
|
||||
|
||||
import httpx
|
||||
from pydantic import ConfigDict, TypeAdapter, ValidationError
|
||||
from typing_extensions import assert_never
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.core_helpers import RESPONSE_COST_HEADER, normalize_drop_params
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.base_llm.decisions.transformation import (
|
||||
BaseDecisionsConfig,
|
||||
ir_to_systemone_response,
|
||||
systemone_request_to_ir,
|
||||
)
|
||||
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
|
||||
from litellm.llms.openai.decisions.transformation import ir_to_openai_response, openai_request_to_ir
|
||||
from litellm.types.decisions import (
|
||||
DecisionQuestion,
|
||||
DecisionsIRRequest,
|
||||
DecisionsIRResponse,
|
||||
DecisionsJSON,
|
||||
DecisionsRequestBody,
|
||||
DecisionsResponse,
|
||||
OpenAIDecisionInput,
|
||||
OpenAIDecisionQuestion,
|
||||
OpenAIDecisionRequestBody,
|
||||
OpenAIDecisionResponse,
|
||||
UnsupportedDecisionsRequest,
|
||||
)
|
||||
from litellm.types.utils import LlmProviders
|
||||
from litellm.utils import ProviderConfigManager
|
||||
|
||||
SystemOneQuestions: TypeAlias = Mapping[str, DecisionQuestion | Mapping[str, object]]
|
||||
OpenAIDecisionQuestions: TypeAlias = Sequence[OpenAIDecisionQuestion | Mapping[str, object]]
|
||||
DecisionsRequestFormat: TypeAlias = DecisionsRequestBody | OpenAIDecisionRequestBody
|
||||
|
||||
_SYSTEMONE_REQUEST_ADAPTER: Final[TypeAdapter[DecisionsRequestBody]] = TypeAdapter(DecisionsRequestBody)
|
||||
_OPENAI_REQUEST_ADAPTER: Final[TypeAdapter[OpenAIDecisionRequestBody]] = TypeAdapter(OpenAIDecisionRequestBody)
|
||||
_SAFETY_IDENTIFIER_ADAPTER: Final[TypeAdapter[str | None]] = TypeAdapter(
|
||||
str | None, config=ConfigDict(title="safety_identifier")
|
||||
)
|
||||
_HANDLER: Final = BaseLLMHTTPHandler()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SystemOneRequestFields:
|
||||
state: DecisionsJSON | None
|
||||
questions: SystemOneQuestions | None
|
||||
|
||||
def validate(self, safety_identifier: str | None) -> DecisionsRequestBody:
|
||||
return _SYSTEMONE_REQUEST_ADAPTER.validate_python({"state": self.state, "questions": self.questions})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OpenAIDecisionRequestFields:
|
||||
input: OpenAIDecisionInput | None
|
||||
questions: OpenAIDecisionQuestions | None
|
||||
|
||||
def validate(self, safety_identifier: str | None) -> OpenAIDecisionRequestBody:
|
||||
return _OPENAI_REQUEST_ADAPTER.validate_python(
|
||||
{"input": self.input, "questions": self.questions, "safety_identifier": safety_identifier}
|
||||
)
|
||||
|
||||
|
||||
RequestFields: TypeAlias = SystemOneRequestFields | OpenAIDecisionRequestFields
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, repr=False)
|
||||
class DecisionsCall:
|
||||
model: str
|
||||
requested_model: str
|
||||
custom_llm_provider: str
|
||||
provider_config: BaseDecisionsConfig
|
||||
request: DecisionsRequestFormat
|
||||
ir_request: DecisionsIRRequest
|
||||
body: Mapping[str, object] = field(repr=False)
|
||||
api_base: str
|
||||
api_key: str | None = field(repr=False)
|
||||
logging_obj: LiteLLMLoggingObj | None
|
||||
headers: Mapping[str, str]
|
||||
timeout: float | httpx.Timeout | None
|
||||
litellm_params: Mapping[str, object] = field(repr=False)
|
||||
|
||||
|
||||
def supported_decisions_providers() -> tuple[str, ...]:
|
||||
return tuple(
|
||||
provider.value
|
||||
for provider in LlmProviders
|
||||
if ProviderConfigManager.get_provider_decisions_config(model="", provider=provider) is not None
|
||||
)
|
||||
|
||||
|
||||
def _provider_config(model: str, custom_llm_provider: str) -> BaseDecisionsConfig:
|
||||
provider: Final = next((member for member in LlmProviders if member.value == custom_llm_provider), None)
|
||||
provider_config: Final = (
|
||||
None if provider is None else ProviderConfigManager.get_provider_decisions_config(model, provider)
|
||||
)
|
||||
if provider_config is None:
|
||||
supported: Final = ", ".join(supported_decisions_providers())
|
||||
raise litellm.BadRequestError(
|
||||
message=f"Unknown Decisions provider '{custom_llm_provider}'. Supported providers: {supported}",
|
||||
model=model,
|
||||
llm_provider=custom_llm_provider,
|
||||
)
|
||||
return provider_config
|
||||
|
||||
|
||||
def _ir_request(request: DecisionsRequestFormat, safety_identifier: str | None) -> DecisionsIRRequest:
|
||||
match request:
|
||||
case DecisionsRequestBody():
|
||||
return replace(systemone_request_to_ir(request), safety_identifier=safety_identifier)
|
||||
case OpenAIDecisionRequestBody():
|
||||
return openai_request_to_ir(request)
|
||||
case _:
|
||||
assert_never(request)
|
||||
|
||||
|
||||
def _drops_params(kwargs: Mapping[str, object]) -> bool:
|
||||
return litellm.drop_params is True or normalize_drop_params(kwargs.get("drop_params")) is True
|
||||
|
||||
|
||||
def _request_safety_identifier(safety_identifier: object, kwargs: Mapping[str, object]) -> str | None:
|
||||
if isinstance(safety_identifier, str) or not _drops_params(kwargs):
|
||||
return _SAFETY_IDENTIFIER_ADAPTER.validate_python(safety_identifier)
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _UnsupportedSafetyIdentifier:
|
||||
pass
|
||||
|
||||
|
||||
def _provider_ir_request(
|
||||
request: DecisionsRequestFormat,
|
||||
*,
|
||||
safety_identifier: str | None,
|
||||
provider_config: BaseDecisionsConfig,
|
||||
kwargs: Mapping[str, object],
|
||||
) -> DecisionsIRRequest | _UnsupportedSafetyIdentifier:
|
||||
ir_request: Final = _ir_request(request, safety_identifier)
|
||||
if ir_request.safety_identifier is None or provider_config.supports_safety_identifier:
|
||||
return ir_request
|
||||
if _drops_params(kwargs):
|
||||
return replace(ir_request, safety_identifier=None)
|
||||
return _UnsupportedSafetyIdentifier()
|
||||
|
||||
|
||||
def prepare_call(
|
||||
*,
|
||||
model: str,
|
||||
fields: RequestFields,
|
||||
safety_identifier: str | None,
|
||||
api_key: str | None,
|
||||
api_base: str | None,
|
||||
timeout: float | httpx.Timeout | None,
|
||||
custom_llm_provider: str | None,
|
||||
extra_headers: Mapping[str, str] | None,
|
||||
kwargs: Mapping[str, object],
|
||||
) -> DecisionsCall:
|
||||
upstream_model, provider, dynamic_api_key, dynamic_api_base = litellm.get_llm_provider(
|
||||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
api_base=api_base,
|
||||
api_key=api_key,
|
||||
)
|
||||
provider_config: Final = _provider_config(upstream_model, provider)
|
||||
if not upstream_model:
|
||||
raise litellm.BadRequestError(
|
||||
message="A model name is required for the Decisions API",
|
||||
model=model,
|
||||
llm_provider=provider,
|
||||
)
|
||||
try:
|
||||
canonical_model: Final = provider_config.canonical_model(upstream_model)
|
||||
except ValueError as error:
|
||||
raise litellm.BadRequestError(message=str(error), model=model, llm_provider=provider) from error
|
||||
try:
|
||||
request_safety_identifier: Final = _request_safety_identifier(safety_identifier, kwargs)
|
||||
request: Final = fields.validate(request_safety_identifier)
|
||||
except ValidationError as error:
|
||||
raise litellm.BadRequestError(
|
||||
message=f"Invalid Decisions request: {error}",
|
||||
model=model,
|
||||
llm_provider=provider,
|
||||
) from error
|
||||
|
||||
resolved_api_base: Final = provider_config.resolve_api_base(dynamic_api_base or api_base)
|
||||
if resolved_api_base is None:
|
||||
raise litellm.BadRequestError(
|
||||
message=provider_config.missing_api_base_message(provider),
|
||||
model=model,
|
||||
llm_provider=provider,
|
||||
)
|
||||
resolved_api_key: Final = provider_config.resolve_api_key(dynamic_api_key or api_key)
|
||||
if resolved_api_key is None and provider_config.api_key_required:
|
||||
raise litellm.AuthenticationError(
|
||||
message=f"Missing API key for Decisions provider '{provider}'",
|
||||
model=model,
|
||||
llm_provider=provider,
|
||||
)
|
||||
|
||||
ir_request: Final = _provider_ir_request(
|
||||
request,
|
||||
safety_identifier=request_safety_identifier,
|
||||
provider_config=provider_config,
|
||||
kwargs=kwargs,
|
||||
)
|
||||
if isinstance(ir_request, _UnsupportedSafetyIdentifier):
|
||||
raise litellm.UnsupportedParamsError(
|
||||
message=(
|
||||
f"{provider} does not support parameters: ['safety_identifier'], for model={model}. "
|
||||
"To drop these, set `litellm.drop_params=True` or for proxy:\n\n"
|
||||
"`litellm_settings:\n drop_params: true`\n"
|
||||
),
|
||||
model=model,
|
||||
llm_provider=provider,
|
||||
)
|
||||
body: Final = provider_config.transform_decisions_request(
|
||||
model=canonical_model, request=ir_request, custom_llm_provider=provider
|
||||
)
|
||||
if isinstance(body, UnsupportedDecisionsRequest):
|
||||
raise litellm.BadRequestError(
|
||||
message=f"Decisions provider '{provider}' cannot serve this request: {body.reason}",
|
||||
model=model,
|
||||
llm_provider=provider,
|
||||
)
|
||||
|
||||
logging_obj: Final = kwargs.get("litellm_logging_obj")
|
||||
if isinstance(logging_obj, LiteLLMLoggingObj):
|
||||
logging_obj.update_from_kwargs(
|
||||
kwargs=dict(kwargs),
|
||||
model=canonical_model,
|
||||
litellm_params={
|
||||
"litellm_call_id": kwargs.get("litellm_call_id"),
|
||||
"api_base": provider_config.get_complete_url(resolved_api_base, canonical_model),
|
||||
},
|
||||
custom_llm_provider=provider,
|
||||
)
|
||||
return DecisionsCall(
|
||||
model=canonical_model,
|
||||
requested_model=model,
|
||||
custom_llm_provider=provider,
|
||||
provider_config=provider_config,
|
||||
request=request,
|
||||
ir_request=ir_request,
|
||||
body=body,
|
||||
api_base=resolved_api_base,
|
||||
api_key=resolved_api_key,
|
||||
logging_obj=logging_obj if isinstance(logging_obj, LiteLLMLoggingObj) else None,
|
||||
headers=extra_headers or {},
|
||||
timeout=timeout,
|
||||
litellm_params=kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _format_response(response: DecisionsIRResponse, call: DecisionsCall) -> DecisionsResponse | OpenAIDecisionResponse:
|
||||
formatted: Final = _formatted_response(response, call)
|
||||
provider_cost: Final = call.provider_config.provider_reported_cost(response)
|
||||
formatted.set_hidden_params(
|
||||
{
|
||||
"model": f"{call.custom_llm_provider}/{call.model}",
|
||||
"custom_llm_provider": call.custom_llm_provider,
|
||||
"provider_response_model": f"{call.custom_llm_provider}/{call.model}",
|
||||
**({"additional_headers": {RESPONSE_COST_HEADER: provider_cost}} if provider_cost is not None else {}),
|
||||
}
|
||||
)
|
||||
return formatted
|
||||
|
||||
|
||||
def _formatted_response(
|
||||
response: DecisionsIRResponse, call: DecisionsCall
|
||||
) -> DecisionsResponse | OpenAIDecisionResponse:
|
||||
match call.request:
|
||||
case DecisionsRequestBody():
|
||||
return ir_to_systemone_response(response, call.ir_request)
|
||||
case OpenAIDecisionRequestBody():
|
||||
return ir_to_openai_response(response, call.ir_request, call.requested_model)
|
||||
case _:
|
||||
assert_never(call.request)
|
||||
|
||||
|
||||
def _map_upstream_exception(error: Exception, call: DecisionsCall) -> Exception:
|
||||
if isinstance(error, BaseLLMException) and error.status_code_is_synthesized:
|
||||
provider_label: Final = f"{call.custom_llm_provider[0].upper()}{call.custom_llm_provider[1:]}Exception"
|
||||
return litellm.APIConnectionError(
|
||||
message=f"{provider_label} - {error.message}",
|
||||
llm_provider=call.custom_llm_provider,
|
||||
model=f"{call.custom_llm_provider}/{call.model}",
|
||||
)
|
||||
return litellm.exception_type(
|
||||
model=f"{call.custom_llm_provider}/{call.model}",
|
||||
custom_llm_provider=call.custom_llm_provider,
|
||||
original_exception=error,
|
||||
)
|
||||
|
||||
|
||||
async def asend(call: DecisionsCall) -> DecisionsResponse | OpenAIDecisionResponse:
|
||||
try:
|
||||
response: Final = await _HANDLER.adecisions(
|
||||
model=call.model,
|
||||
custom_llm_provider=call.custom_llm_provider,
|
||||
logging_obj=call.logging_obj,
|
||||
provider_config=call.provider_config,
|
||||
request=call.ir_request,
|
||||
body=call.body,
|
||||
api_base=call.api_base,
|
||||
api_key=call.api_key,
|
||||
headers=call.headers,
|
||||
timeout=call.timeout,
|
||||
litellm_params=call.litellm_params,
|
||||
)
|
||||
except Exception as error:
|
||||
raise _map_upstream_exception(error, call) from error
|
||||
return _format_response(response, call)
|
||||
|
||||
|
||||
def send(call: DecisionsCall) -> DecisionsResponse | OpenAIDecisionResponse:
|
||||
try:
|
||||
response: Final = _HANDLER.decisions(
|
||||
model=call.model,
|
||||
custom_llm_provider=call.custom_llm_provider,
|
||||
logging_obj=call.logging_obj,
|
||||
provider_config=call.provider_config,
|
||||
request=call.ir_request,
|
||||
body=call.body,
|
||||
api_base=call.api_base,
|
||||
api_key=call.api_key,
|
||||
headers=call.headers,
|
||||
timeout=call.timeout,
|
||||
litellm_params=call.litellm_params,
|
||||
)
|
||||
except Exception as error:
|
||||
raise _map_upstream_exception(error, call) from error
|
||||
return _format_response(response, call)
|
||||
|
||||
|
||||
def reject_other_format(*, model: str, kwargs: Mapping[str, object], field_name: str, other_function: str) -> None:
|
||||
if field_name not in kwargs:
|
||||
return
|
||||
raise litellm.BadRequestError(
|
||||
message=f"Requests with '{field_name}' go through litellm.{other_function}",
|
||||
model=model,
|
||||
llm_provider="",
|
||||
)
|
||||
|
|
@ -1,116 +1,56 @@
|
|||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Final, TypeAlias
|
||||
import warnings
|
||||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
from typing_extensions import assert_never
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.base_llm.decisions.transformation import (
|
||||
BaseDecisionsConfig,
|
||||
ir_to_systemone_response,
|
||||
systemone_request_to_ir,
|
||||
from litellm.decisions.call import (
|
||||
DecisionsCall,
|
||||
OpenAIDecisionQuestions,
|
||||
OpenAIDecisionRequestFields,
|
||||
RequestFields,
|
||||
SystemOneQuestions,
|
||||
SystemOneRequestFields,
|
||||
asend,
|
||||
prepare_call,
|
||||
send,
|
||||
)
|
||||
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
|
||||
from litellm.llms.openai.decisions.transformation import ir_to_openai_response, openai_request_to_ir
|
||||
from litellm.types.decisions import (
|
||||
DecisionQuestion,
|
||||
DecisionsIRRequest,
|
||||
DecisionsIRResponse,
|
||||
DecisionsJSON,
|
||||
DecisionsRequestBody,
|
||||
DecisionsResponse,
|
||||
OpenAIDecisionInput,
|
||||
OpenAIDecisionQuestion,
|
||||
OpenAIDecisionRequestBody,
|
||||
OpenAIDecisionResponse,
|
||||
UnsupportedDecisionsRequest,
|
||||
from litellm.types.decisions import DecisionsJSON, DecisionsResponse, OpenAIDecisionInput, OpenAIDecisionResponse
|
||||
from litellm.utils import client
|
||||
|
||||
_STATE_ADAPTER: Final[TypeAdapter[DecisionsJSON | None]] = TypeAdapter(DecisionsJSON | None)
|
||||
_SYSTEMONE_QUESTIONS_ADAPTER: Final[TypeAdapter[SystemOneQuestions | None]] = TypeAdapter(SystemOneQuestions | None)
|
||||
_OPENAI_QUESTIONS_ADAPTER: Final[TypeAdapter[OpenAIDecisionQuestions | None]] = TypeAdapter(
|
||||
OpenAIDecisionQuestions | None
|
||||
)
|
||||
from litellm.types.utils import LlmProviders
|
||||
from litellm.utils import ProviderConfigManager, client
|
||||
|
||||
DecisionsQuestions: TypeAlias = (
|
||||
Mapping[str, DecisionQuestion | Mapping[str, object]] | Sequence[OpenAIDecisionQuestion | Mapping[str, object]]
|
||||
)
|
||||
DecisionsRequestFormat: TypeAlias = DecisionsRequestBody | OpenAIDecisionRequestBody
|
||||
|
||||
_SYSTEMONE_REQUEST_ADAPTER: Final[TypeAdapter[DecisionsRequestBody]] = TypeAdapter(DecisionsRequestBody)
|
||||
_OPENAI_REQUEST_ADAPTER: Final[TypeAdapter[OpenAIDecisionRequestBody]] = TypeAdapter(OpenAIDecisionRequestBody)
|
||||
_HANDLER: Final = BaseLLMHTTPHandler()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, repr=False)
|
||||
class _DecisionsCall:
|
||||
model: str
|
||||
requested_model: str
|
||||
custom_llm_provider: str
|
||||
provider_config: BaseDecisionsConfig
|
||||
request: DecisionsRequestFormat
|
||||
ir_request: DecisionsIRRequest
|
||||
body: Mapping[str, object] = field(repr=False)
|
||||
api_base: str
|
||||
api_key: str | None = field(repr=False)
|
||||
logging_obj: LiteLLMLoggingObj | None
|
||||
headers: Mapping[str, str]
|
||||
timeout: float | httpx.Timeout | None
|
||||
|
||||
|
||||
def supported_decisions_providers() -> tuple[str, ...]:
|
||||
return tuple(
|
||||
provider.value
|
||||
for provider in LlmProviders
|
||||
if ProviderConfigManager.get_provider_decisions_config(model="", provider=provider) is not None
|
||||
)
|
||||
|
||||
|
||||
def _provider_config(model: str, custom_llm_provider: str) -> BaseDecisionsConfig:
|
||||
provider: Final = next((member for member in LlmProviders if member.value == custom_llm_provider), None)
|
||||
provider_config: Final = (
|
||||
None if provider is None else ProviderConfigManager.get_provider_decisions_config(model, provider)
|
||||
)
|
||||
if provider_config is None:
|
||||
supported: Final = ", ".join(supported_decisions_providers())
|
||||
raise litellm.BadRequestError(
|
||||
message=f"Unknown Decisions provider '{custom_llm_provider}'. Supported providers: {supported}",
|
||||
model=model,
|
||||
llm_provider=custom_llm_provider,
|
||||
)
|
||||
return provider_config
|
||||
|
||||
|
||||
def _validate_request(
|
||||
*,
|
||||
state: DecisionsJSON | None,
|
||||
questions: DecisionsQuestions | None,
|
||||
def _request_fields(
|
||||
decision_input: OpenAIDecisionInput | None,
|
||||
safety_identifier: str | None,
|
||||
) -> DecisionsRequestFormat:
|
||||
if decision_input is None:
|
||||
return _SYSTEMONE_REQUEST_ADAPTER.validate_python({"state": state, "questions": questions})
|
||||
return _OPENAI_REQUEST_ADAPTER.validate_python(
|
||||
{"input": decision_input, "questions": questions, "safety_identifier": safety_identifier}
|
||||
questions: OpenAIDecisionQuestions | SystemOneQuestions | None,
|
||||
kwargs: Mapping[str, object],
|
||||
) -> RequestFields:
|
||||
if "state" not in kwargs:
|
||||
return OpenAIDecisionRequestFields(
|
||||
input=decision_input, questions=_OPENAI_QUESTIONS_ADAPTER.validate_python(questions)
|
||||
)
|
||||
warnings.warn(
|
||||
"litellm.decisions(state=...) is deprecated, call litellm.systemone for the System One request format",
|
||||
UserWarning,
|
||||
stacklevel=5,
|
||||
)
|
||||
return SystemOneRequestFields(
|
||||
state=_STATE_ADAPTER.validate_python(kwargs["state"]),
|
||||
questions=_SYSTEMONE_QUESTIONS_ADAPTER.validate_python(questions),
|
||||
)
|
||||
|
||||
|
||||
def _ir_request(request: DecisionsRequestFormat) -> DecisionsIRRequest:
|
||||
match request:
|
||||
case DecisionsRequestBody():
|
||||
return systemone_request_to_ir(request)
|
||||
case OpenAIDecisionRequestBody():
|
||||
return openai_request_to_ir(request)
|
||||
case _:
|
||||
assert_never(request)
|
||||
|
||||
|
||||
def _prepare_call(
|
||||
def _prepare_decisions_call(
|
||||
*,
|
||||
model: str,
|
||||
state: DecisionsJSON | None,
|
||||
questions: DecisionsQuestions | None,
|
||||
decision_input: OpenAIDecisionInput | None,
|
||||
questions: OpenAIDecisionQuestions | SystemOneQuestions | None,
|
||||
safety_identifier: str | None,
|
||||
api_key: str | None,
|
||||
api_base: str | None,
|
||||
|
|
@ -118,152 +58,37 @@ def _prepare_call(
|
|||
custom_llm_provider: str | None,
|
||||
extra_headers: Mapping[str, str] | None,
|
||||
kwargs: Mapping[str, object],
|
||||
) -> _DecisionsCall:
|
||||
upstream_model, provider, dynamic_api_key, dynamic_api_base = litellm.get_llm_provider(
|
||||
) -> DecisionsCall:
|
||||
return prepare_call(
|
||||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
api_base=api_base,
|
||||
fields=_request_fields(decision_input, questions, kwargs),
|
||||
safety_identifier=safety_identifier,
|
||||
api_key=api_key,
|
||||
)
|
||||
provider_config: Final = _provider_config(upstream_model, provider)
|
||||
if not upstream_model:
|
||||
raise litellm.BadRequestError(
|
||||
message="A model name is required for the Decisions API",
|
||||
model=model,
|
||||
llm_provider=provider,
|
||||
)
|
||||
if state is not None and decision_input is not None:
|
||||
raise litellm.BadRequestError(
|
||||
message="Pass either state (System One format) or input (OpenAI format) to the Decisions API, not both",
|
||||
model=model,
|
||||
llm_provider=provider,
|
||||
)
|
||||
try:
|
||||
canonical_model: Final = provider_config.canonical_model(upstream_model)
|
||||
except ValueError as error:
|
||||
raise litellm.BadRequestError(message=str(error), model=model, llm_provider=provider) from error
|
||||
try:
|
||||
request: Final = _validate_request(
|
||||
state=state, questions=questions, decision_input=decision_input, safety_identifier=safety_identifier
|
||||
)
|
||||
except ValidationError as error:
|
||||
raise litellm.BadRequestError(
|
||||
message=f"Invalid Decisions request: {error}",
|
||||
model=model,
|
||||
llm_provider=provider,
|
||||
) from error
|
||||
|
||||
resolved_api_base: Final = provider_config.resolve_api_base(dynamic_api_base or api_base)
|
||||
if resolved_api_base is None:
|
||||
raise litellm.BadRequestError(
|
||||
message=provider_config.missing_api_base_message(provider),
|
||||
model=model,
|
||||
llm_provider=provider,
|
||||
)
|
||||
resolved_api_key: Final = provider_config.resolve_api_key(dynamic_api_key or api_key)
|
||||
if resolved_api_key is None and provider_config.api_key_required:
|
||||
raise litellm.AuthenticationError(
|
||||
message=f"Missing API key for Decisions provider '{provider}'",
|
||||
model=model,
|
||||
llm_provider=provider,
|
||||
)
|
||||
|
||||
ir_request: Final = _ir_request(request)
|
||||
body: Final = provider_config.transform_decisions_request(
|
||||
model=canonical_model, request=ir_request, custom_llm_provider=provider
|
||||
)
|
||||
if isinstance(body, UnsupportedDecisionsRequest):
|
||||
raise litellm.BadRequestError(
|
||||
message=f"Decisions provider '{provider}' cannot serve this request: {body.reason}",
|
||||
model=model,
|
||||
llm_provider=provider,
|
||||
)
|
||||
|
||||
logging_obj: Final = kwargs.get("litellm_logging_obj")
|
||||
if isinstance(logging_obj, LiteLLMLoggingObj):
|
||||
logging_obj.update_from_kwargs(
|
||||
kwargs=dict(kwargs),
|
||||
model=canonical_model,
|
||||
litellm_params={
|
||||
"litellm_call_id": kwargs.get("litellm_call_id"),
|
||||
"api_base": provider_config.get_complete_url(resolved_api_base, canonical_model),
|
||||
},
|
||||
custom_llm_provider=provider,
|
||||
)
|
||||
return _DecisionsCall(
|
||||
model=canonical_model,
|
||||
requested_model=model,
|
||||
custom_llm_provider=provider,
|
||||
provider_config=provider_config,
|
||||
request=request,
|
||||
ir_request=ir_request,
|
||||
body=body,
|
||||
api_base=resolved_api_base,
|
||||
api_key=resolved_api_key,
|
||||
logging_obj=logging_obj if isinstance(logging_obj, LiteLLMLoggingObj) else None,
|
||||
headers=extra_headers or {},
|
||||
api_base=api_base,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def _format_response(response: DecisionsIRResponse, call: _DecisionsCall) -> DecisionsResponse | OpenAIDecisionResponse:
|
||||
formatted: Final = _formatted_response(response, call)
|
||||
formatted.set_hidden_params(
|
||||
{
|
||||
"model": f"{call.custom_llm_provider}/{call.model}",
|
||||
"custom_llm_provider": call.custom_llm_provider,
|
||||
"provider_response_model": f"{call.custom_llm_provider}/{call.model}",
|
||||
}
|
||||
)
|
||||
return formatted
|
||||
|
||||
|
||||
def _formatted_response(
|
||||
response: DecisionsIRResponse, call: _DecisionsCall
|
||||
) -> DecisionsResponse | OpenAIDecisionResponse:
|
||||
match call.request:
|
||||
case DecisionsRequestBody():
|
||||
return ir_to_systemone_response(response, call.ir_request)
|
||||
case OpenAIDecisionRequestBody():
|
||||
return ir_to_openai_response(response, call.ir_request, call.requested_model)
|
||||
case _:
|
||||
assert_never(call.request)
|
||||
|
||||
|
||||
def _map_upstream_exception(error: Exception, call: _DecisionsCall) -> Exception:
|
||||
if isinstance(error, BaseLLMException) and error.status_code_is_synthesized:
|
||||
provider_label: Final = f"{call.custom_llm_provider[0].upper()}{call.custom_llm_provider[1:]}Exception"
|
||||
return litellm.APIConnectionError(
|
||||
message=f"{provider_label} - {error.message}",
|
||||
llm_provider=call.custom_llm_provider,
|
||||
model=f"{call.custom_llm_provider}/{call.model}",
|
||||
)
|
||||
return litellm.exception_type(
|
||||
model=f"{call.custom_llm_provider}/{call.model}",
|
||||
custom_llm_provider=call.custom_llm_provider,
|
||||
original_exception=error,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
extra_headers=extra_headers,
|
||||
kwargs={key: value for key, value in kwargs.items() if key != "state"},
|
||||
)
|
||||
|
||||
|
||||
@client
|
||||
async def adecisions(
|
||||
model: str,
|
||||
state: DecisionsJSON | None = None,
|
||||
questions: DecisionsQuestions | None = None,
|
||||
input: OpenAIDecisionInput | None = None,
|
||||
questions: OpenAIDecisionQuestions | SystemOneQuestions | None = None,
|
||||
safety_identifier: str | None = None,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
extra_headers: Mapping[str, str] | None = None,
|
||||
input: OpenAIDecisionInput | None = None,
|
||||
safety_identifier: str | None = None,
|
||||
**kwargs: object,
|
||||
) -> DecisionsResponse | OpenAIDecisionResponse:
|
||||
call: Final = _prepare_call(
|
||||
) -> OpenAIDecisionResponse | DecisionsResponse:
|
||||
call: Final = _prepare_decisions_call(
|
||||
model=model,
|
||||
state=state,
|
||||
questions=questions,
|
||||
decision_input=input,
|
||||
questions=questions,
|
||||
safety_identifier=safety_identifier,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
|
|
@ -272,43 +97,26 @@ async def adecisions(
|
|||
extra_headers=extra_headers,
|
||||
kwargs=kwargs,
|
||||
)
|
||||
try:
|
||||
response: Final = await _HANDLER.adecisions(
|
||||
model=call.model,
|
||||
custom_llm_provider=call.custom_llm_provider,
|
||||
logging_obj=call.logging_obj,
|
||||
provider_config=call.provider_config,
|
||||
request=call.ir_request,
|
||||
body=call.body,
|
||||
api_base=call.api_base,
|
||||
api_key=call.api_key,
|
||||
headers=call.headers,
|
||||
timeout=call.timeout,
|
||||
)
|
||||
except Exception as error:
|
||||
raise _map_upstream_exception(error, call) from error
|
||||
return _format_response(response, call)
|
||||
return await asend(call)
|
||||
|
||||
|
||||
@client
|
||||
def decisions(
|
||||
model: str,
|
||||
state: DecisionsJSON | None = None,
|
||||
questions: DecisionsQuestions | None = None,
|
||||
input: OpenAIDecisionInput | None = None,
|
||||
questions: OpenAIDecisionQuestions | SystemOneQuestions | None = None,
|
||||
safety_identifier: str | None = None,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
extra_headers: Mapping[str, str] | None = None,
|
||||
input: OpenAIDecisionInput | None = None,
|
||||
safety_identifier: str | None = None,
|
||||
**kwargs: object,
|
||||
) -> DecisionsResponse | OpenAIDecisionResponse:
|
||||
call: Final = _prepare_call(
|
||||
) -> OpenAIDecisionResponse | DecisionsResponse:
|
||||
call: Final = _prepare_decisions_call(
|
||||
model=model,
|
||||
state=state,
|
||||
questions=questions,
|
||||
decision_input=input,
|
||||
questions=questions,
|
||||
safety_identifier=safety_identifier,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
|
|
@ -317,22 +125,7 @@ def decisions(
|
|||
extra_headers=extra_headers,
|
||||
kwargs=kwargs,
|
||||
)
|
||||
try:
|
||||
response: Final = _HANDLER.decisions(
|
||||
model=call.model,
|
||||
custom_llm_provider=call.custom_llm_provider,
|
||||
logging_obj=call.logging_obj,
|
||||
provider_config=call.provider_config,
|
||||
request=call.ir_request,
|
||||
body=call.body,
|
||||
api_base=call.api_base,
|
||||
api_key=call.api_key,
|
||||
headers=call.headers,
|
||||
timeout=call.timeout,
|
||||
)
|
||||
except Exception as error:
|
||||
raise _map_upstream_exception(error, call) from error
|
||||
return _format_response(response, call)
|
||||
return send(call)
|
||||
|
||||
|
||||
__all__ = ["adecisions", "decisions"]
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ Helper functions for health check calls.
|
|||
"""
|
||||
|
||||
import base64
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from typing import TYPE_CHECKING, Final, Literal
|
||||
|
||||
from litellm.llms.base_llm.ocr.transformation import DocumentType
|
||||
|
|
@ -56,6 +56,21 @@ def get_image_file_for_health_check() -> bytes:
|
|||
return base64.b64decode(TEST_IMAGE_BASE64)
|
||||
|
||||
|
||||
def _decisions_health_check_questions(model: str, custom_llm_provider: str) -> Mapping[str, Mapping[str, object]]:
|
||||
import litellm
|
||||
from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig
|
||||
from litellm.utils import ProviderConfigManager
|
||||
|
||||
provider: Final = next((member for member in litellm.LlmProviders if member.value == custom_llm_provider), None)
|
||||
config: Final = (
|
||||
None
|
||||
if provider is None
|
||||
else ProviderConfigManager.get_provider_decisions_config(model=model, provider=provider)
|
||||
)
|
||||
questions: Final = BaseDecisionsConfig.health_check_questions if config is None else config.health_check_questions
|
||||
return {name: dict(question) for name, question in questions.items()}
|
||||
|
||||
|
||||
def _ocr_health_check_document(model: str, custom_llm_provider: str) -> DocumentType:
|
||||
native: Final = NATIVE_OCR_HEALTH_CHECK_DOCUMENT.load()
|
||||
if native is None:
|
||||
|
|
@ -296,11 +311,13 @@ 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.adecisions(
|
||||
"evaluation": lambda: litellm.asystemone(
|
||||
**DECISIONS_CALL_PARAMS.validate_python(
|
||||
{
|
||||
"state": prompt or "health check",
|
||||
"questions": {"reachable": {"type": "noul", "instructions": "Is the service reachable?"}},
|
||||
"questions": _decisions_health_check_questions(
|
||||
model=model, custom_llm_provider=custom_llm_provider
|
||||
),
|
||||
**_filter_model_params(model_params=model_params),
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -301,10 +301,17 @@ class BaseDecisionsConfig(ABC):
|
|||
api_key_env: tuple[str, ...] = ()
|
||||
api_base_env: tuple[str, ...] = ()
|
||||
api_key_required: bool = True
|
||||
supports_safety_identifier: bool = False
|
||||
health_check_questions: Mapping[str, Mapping[str, object]] = MappingProxyType(
|
||||
{"reachable": MappingProxyType({"type": "noul", "instructions": "Is the service reachable?"})}
|
||||
)
|
||||
|
||||
def get_default_api_base(self) -> str | None:
|
||||
return None
|
||||
|
||||
def provider_reported_cost(self, response: DecisionsIRResponse) -> float | None:
|
||||
return None
|
||||
|
||||
def missing_api_base_message(self, custom_llm_provider: str) -> str:
|
||||
return f"api_base is required for Decisions provider '{custom_llm_provider}'"
|
||||
|
||||
|
|
@ -334,6 +341,20 @@ class BaseDecisionsConfig(ABC):
|
|||
def get_complete_url(self, api_base: str, model: str) -> str:
|
||||
return f"{api_base.rstrip('/').removesuffix('/v1')}{self.path}"
|
||||
|
||||
def signs_with_aws(self, api_base: str) -> bool:
|
||||
return False
|
||||
|
||||
def sign_request(
|
||||
self,
|
||||
headers: Mapping[str, str],
|
||||
url: str,
|
||||
api_base: str,
|
||||
body: Mapping[str, object],
|
||||
api_key: str | None,
|
||||
litellm_params: Mapping[str, object],
|
||||
) -> tuple[Mapping[str, str], bytes | None]:
|
||||
return headers, None
|
||||
|
||||
def transform_decisions_request(
|
||||
self,
|
||||
model: str,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import json
|
|||
import ssl
|
||||
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Coroutine, Iterator, Mapping, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from functools import lru_cache
|
||||
from functools import lru_cache, partial
|
||||
from types import MappingProxyType
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
|
|
@ -1517,6 +1517,7 @@ class BaseLLMHTTPHandler:
|
|||
api_key: str | None,
|
||||
headers: Mapping[str, str],
|
||||
timeout: float | httpx.Timeout | None,
|
||||
litellm_params: Mapping[str, object],
|
||||
client: HTTPHandler | None = None,
|
||||
) -> DecisionsIRResponse:
|
||||
url, outbound_headers, data = self._prepare_decisions_request(
|
||||
|
|
@ -1528,10 +1529,23 @@ class BaseLLMHTTPHandler:
|
|||
api_key=api_key,
|
||||
headers=headers,
|
||||
)
|
||||
signed_headers, signed_body = provider_config.sign_request(
|
||||
headers=outbound_headers,
|
||||
url=url,
|
||||
api_base=api_base,
|
||||
body=data,
|
||||
api_key=api_key,
|
||||
litellm_params=litellm_params,
|
||||
)
|
||||
sync_httpx_client: Final = client if client is not None else get_httpx_client()
|
||||
try:
|
||||
response: Final = sync_httpx_client.post(
|
||||
url, json=data, headers=outbound_headers, timeout=timeout, logging_obj=logging_obj
|
||||
url,
|
||||
content=signed_body,
|
||||
json=data if signed_body is None else None,
|
||||
headers=dict(signed_headers),
|
||||
timeout=timeout,
|
||||
logging_obj=logging_obj,
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
raise self._handle_error(e=e, provider_config=provider_config)
|
||||
|
|
@ -1551,6 +1565,7 @@ class BaseLLMHTTPHandler:
|
|||
api_key: str | None,
|
||||
headers: Mapping[str, str],
|
||||
timeout: float | httpx.Timeout | None,
|
||||
litellm_params: Mapping[str, object],
|
||||
client: AsyncHTTPHandler | None = None,
|
||||
) -> DecisionsIRResponse:
|
||||
url, outbound_headers, data = self._prepare_decisions_request(
|
||||
|
|
@ -1562,6 +1577,18 @@ class BaseLLMHTTPHandler:
|
|||
api_key=api_key,
|
||||
headers=headers,
|
||||
)
|
||||
sign: Final = partial(
|
||||
provider_config.sign_request,
|
||||
headers=outbound_headers,
|
||||
url=url,
|
||||
api_base=api_base,
|
||||
body=data,
|
||||
api_key=api_key,
|
||||
litellm_params=litellm_params,
|
||||
)
|
||||
signed_headers, signed_body = (
|
||||
await run_aws_signing(sign) if provider_config.signs_with_aws(api_base) else sign()
|
||||
)
|
||||
async_httpx_client: Final = (
|
||||
client
|
||||
if client is not None
|
||||
|
|
@ -1569,7 +1596,12 @@ class BaseLLMHTTPHandler:
|
|||
)
|
||||
try:
|
||||
response: Final = await async_httpx_client.post(
|
||||
url, json=data, headers=outbound_headers, timeout=timeout, logging_obj=logging_obj
|
||||
url,
|
||||
content=signed_body,
|
||||
json=data if signed_body is None else None,
|
||||
headers=dict(signed_headers),
|
||||
timeout=timeout,
|
||||
logging_obj=logging_obj,
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
raise self._handle_error(e=e, provider_config=provider_config)
|
||||
|
|
|
|||
|
|
@ -19,14 +19,20 @@ def is_ai_decide_model(model: str) -> bool:
|
|||
return model == DATABRICKS_AI_DECIDE_MODEL
|
||||
|
||||
|
||||
def validate_serving_endpoint_name(name: str) -> str:
|
||||
if _SERVING_ENDPOINT_NAME.fullmatch(name) is None:
|
||||
raise ValueError(
|
||||
"Databricks decisions model must be 'ai_decide' (the ai_decide AI Function) or a bare serving endpoint "
|
||||
"name (letters, digits, '-', '_' and '.', with no '/', '?', '#', spaces, or a leading '.'), e.g. "
|
||||
"databricks-openjev-qwen35-4b"
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def validate_databricks_decisions_model(model: str) -> str:
|
||||
if is_ai_decide_model(model) or _SERVING_ENDPOINT_NAME.fullmatch(model) is not None:
|
||||
if is_ai_decide_model(model):
|
||||
return model
|
||||
raise ValueError(
|
||||
f"Databricks decisions model must be {DATABRICKS_AI_DECIDE_MODEL!r} (the ai_decide AI Function) or a bare "
|
||||
"serving endpoint name (letters, digits, '-', '_' and '.', with no '/', '?', '#', spaces, or a leading '.'), "
|
||||
"e.g. databricks-openjev-qwen35-4b"
|
||||
)
|
||||
return validate_serving_endpoint_name(model)
|
||||
|
||||
|
||||
def databricks_workspace_host(api_base: str) -> str:
|
||||
|
|
|
|||
26
litellm/llms/hosted_vllm/decisions/transformation.py
Normal file
26
litellm/llms/hosted_vllm/decisions/transformation.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from litellm.constants import PLACEHOLDER_API_KEY
|
||||
from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig
|
||||
|
||||
|
||||
class HostedVLLMDecisionsConfig(BaseDecisionsConfig):
|
||||
api_key_env = ("HOSTED_VLLM_API_KEY",)
|
||||
api_base_env = ("HOSTED_VLLM_API_BASE",)
|
||||
api_key_required = False
|
||||
health_check_questions = MappingProxyType(
|
||||
{
|
||||
"reachable": MappingProxyType(
|
||||
{
|
||||
"type": "choice",
|
||||
"instructions": "Is the service reachable?",
|
||||
"criteria": {"yes": None, "no": None},
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
def resolve_api_key(self, api_key: str | None) -> str | None:
|
||||
resolved: Final = super().resolve_api_key(api_key)
|
||||
return None if resolved == PLACEHOLDER_API_KEY else resolved
|
||||
|
|
@ -292,6 +292,7 @@ def ir_to_openai_response(
|
|||
|
||||
class OpenAIDecisionsConfig(BaseDecisionsConfig):
|
||||
path = "/v1/decisions"
|
||||
supports_safety_identifier = True
|
||||
|
||||
def get_default_api_base(self) -> str | None:
|
||||
return "https://api.openai.com"
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ from typing import TYPE_CHECKING, Any, Final, cast
|
|||
|
||||
import httpx
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.hidden_params import HIDDEN_PARAMS_ATTR, set_hidden_params
|
||||
from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
|
|
@ -41,18 +40,10 @@ class CacheControlSupportedModels(str, Enum):
|
|||
class OpenrouterConfig(OpenAIGPTConfig):
|
||||
def get_supported_openai_params(self, model: str) -> list:
|
||||
"""
|
||||
Allow reasoning parameters for models flagged as reasoning-capable.
|
||||
Include reasoning parameters regardless of model-map capabilities.
|
||||
"""
|
||||
supported_params: Final = super().get_supported_openai_params(model=model)
|
||||
try:
|
||||
if litellm.supports_reasoning(model=model, custom_llm_provider="openrouter") or litellm.supports_reasoning(
|
||||
model=model
|
||||
):
|
||||
supported_params.append("reasoning_effort")
|
||||
supported_params.append("thinking")
|
||||
except Exception:
|
||||
pass
|
||||
return list(dict.fromkeys(supported_params))
|
||||
return list(dict.fromkeys([*supported_params, "reasoning_effort", "thinking"]))
|
||||
|
||||
def map_openai_params(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
from typing import Final
|
||||
|
||||
from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig
|
||||
from litellm.types.decisions import DecisionsIRResponse
|
||||
|
||||
|
||||
class OpenRouterDecisionsConfig(BaseDecisionsConfig):
|
||||
|
|
@ -8,3 +11,9 @@ class OpenRouterDecisionsConfig(BaseDecisionsConfig):
|
|||
|
||||
def get_default_api_base(self) -> str | None:
|
||||
return "https://openrouter.ai/api"
|
||||
|
||||
def provider_reported_cost(self, response: DecisionsIRResponse) -> float | None:
|
||||
cost: Final = response.usage.extra.get("cost")
|
||||
if not isinstance(cost, (int, float)):
|
||||
return None
|
||||
return float(cost)
|
||||
|
|
|
|||
|
|
@ -8,11 +8,25 @@ encrypted_content for multi-turn stateless workflows.
|
|||
Docs: https://openrouter.ai/docs/api/reference/responses/overview
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.core_helpers import RESPONSE_COST_HEADER
|
||||
from litellm.litellm_core_utils.hidden_params import set_hidden_params
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.llms.openai import (
|
||||
ResponseCompletedEvent,
|
||||
ResponseFailedEvent,
|
||||
ResponseIncompleteEvent,
|
||||
ResponsesAPIResponse,
|
||||
ResponsesAPIStreamingResponse,
|
||||
)
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import LlmProviders
|
||||
|
||||
|
|
@ -33,6 +47,62 @@ class OpenRouterResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
def custom_llm_provider(self) -> LlmProviders:
|
||||
return LlmProviders.OPENROUTER
|
||||
|
||||
def transform_response_api_response(
|
||||
self,
|
||||
model: str,
|
||||
raw_response: httpx.Response,
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> ResponsesAPIResponse:
|
||||
response: Final = super().transform_response_api_response(
|
||||
model=model,
|
||||
raw_response=raw_response,
|
||||
logging_obj=logging_obj,
|
||||
)
|
||||
self._set_provider_reported_cost(response)
|
||||
return response
|
||||
|
||||
def transform_streaming_response(
|
||||
self,
|
||||
model: str,
|
||||
parsed_chunk: Mapping[str, object],
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> ResponsesAPIStreamingResponse:
|
||||
response_event: Final = super().transform_streaming_response(
|
||||
model=model,
|
||||
parsed_chunk=dict(parsed_chunk),
|
||||
logging_obj=logging_obj,
|
||||
)
|
||||
terminal_response: Final = (
|
||||
response_event.response
|
||||
if isinstance(
|
||||
response_event,
|
||||
(ResponseCompletedEvent, ResponseFailedEvent, ResponseIncompleteEvent),
|
||||
)
|
||||
else None
|
||||
)
|
||||
if terminal_response is not None:
|
||||
self._set_provider_reported_cost(terminal_response)
|
||||
return response_event
|
||||
|
||||
@staticmethod
|
||||
def _set_provider_reported_cost(response: ResponsesAPIResponse) -> None:
|
||||
cost: Final = response.usage.cost if response.usage is not None else None
|
||||
if cost is None:
|
||||
return
|
||||
additional_headers: Final = TypeAdapter(dict[str, object]).validate_python(
|
||||
response.hidden_params.get("additional_headers", {})
|
||||
)
|
||||
set_hidden_params(
|
||||
response,
|
||||
{
|
||||
**response.hidden_params,
|
||||
"additional_headers": {
|
||||
**additional_headers,
|
||||
RESPONSE_COST_HEADER: float(cost),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def validate_environment(
|
||||
self,
|
||||
headers: dict,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,161 @@
|
|||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.litellm_core_utils.aws_partition import get_aws_dns_suffix
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig
|
||||
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM, sign_aws_json_post
|
||||
from litellm.types.decisions import DecisionsIRRequest, DecisionsIRResponse
|
||||
from litellm.types.llms.bedrock import AwsAuthParams
|
||||
|
||||
AGENTCORE_SESSION_HEADER: Final = "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id"
|
||||
_RESERVED_HEADER_PREFIXES: Final = ("x-amzn-bedrock-agentcore-runtime-", "x-amz-")
|
||||
_RUNTIME_ARN: Final = re.compile(
|
||||
r"\Aarn:aws(?:-[a-z]+)*:bedrock-agentcore:(?P<region>[a-z0-9-]+):[0-9]{12}:runtime/[A-Za-z0-9_-]+\Z"
|
||||
)
|
||||
_INVOCATIONS_PATH: Final = re.compile(r"\A/runtimes/(?P<arn>[^/]+)/invocations\Z")
|
||||
_RUNTIME_ERROR_STATUS: Final = MappingProxyType({"bad_request": 400, "invalid_request": 400, "loading": 503})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentCoreRuntime:
|
||||
arn: str
|
||||
region: str
|
||||
|
||||
@property
|
||||
def invocations_url(self) -> str:
|
||||
host: Final = f"bedrock-agentcore.{self.region}.{get_aws_dns_suffix(self.region)}"
|
||||
return f"https://{host}/runtimes/{quote(self.arn, safe='')}/invocations"
|
||||
|
||||
@property
|
||||
def default_session_id(self) -> str:
|
||||
return f"litellm-decider-{hashlib.sha256(self.arn.encode()).hexdigest()}"
|
||||
|
||||
|
||||
def agentcore_runtime(api_base: str) -> AgentCoreRuntime | None:
|
||||
match: Final = _RUNTIME_ARN.match(api_base)
|
||||
return None if match is None else AgentCoreRuntime(arn=api_base, region=match["region"])
|
||||
|
||||
|
||||
def invoked_runtime(url: httpx.URL) -> AgentCoreRuntime | None:
|
||||
match: Final = _INVOCATIONS_PATH.match(url.raw_path.decode("ascii"))
|
||||
runtime: Final = None if match is None else agentcore_runtime(unquote(match["arn"]))
|
||||
return runtime if runtime is not None and runtime.invocations_url == str(url) else None
|
||||
|
||||
|
||||
class _RuntimeError(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
code: str
|
||||
message: str = ""
|
||||
|
||||
|
||||
class _RuntimeReply(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
error: _RuntimeError | None = None
|
||||
answers: object = None
|
||||
|
||||
|
||||
def _runtime_error(content: bytes) -> _RuntimeError | None:
|
||||
try:
|
||||
reply: Final = _RuntimeReply.model_validate_json(content)
|
||||
except ValidationError:
|
||||
return None
|
||||
return reply.error if reply.answers is None else None
|
||||
|
||||
|
||||
class _SessionParams(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
agentcore_runtime_session_id: str | None = None
|
||||
|
||||
|
||||
def _is_reserved_header(name: str) -> bool:
|
||||
lowered: Final = name.lower()
|
||||
return lowered == "host" or lowered.startswith(_RESERVED_HEADER_PREFIXES)
|
||||
|
||||
|
||||
def _runtime_headers(
|
||||
headers: Mapping[str, str], runtime: AgentCoreRuntime, litellm_params: Mapping[str, object]
|
||||
) -> Mapping[str, str]:
|
||||
dropped: Final = sorted(name for name in headers if _is_reserved_header(name))
|
||||
if dropped:
|
||||
verbose_logger.warning("Strands Decider: not forwarding reserved AgentCore header(s) %s", dropped)
|
||||
configured: Final = _SessionParams.model_validate(litellm_params).agentcore_runtime_session_id
|
||||
return {
|
||||
**{name: value for name, value in headers.items() if not _is_reserved_header(name)},
|
||||
AGENTCORE_SESSION_HEADER: runtime.default_session_id if configured is None else configured,
|
||||
}
|
||||
|
||||
|
||||
class StrandsDeciderDecisionsConfig(BaseDecisionsConfig):
|
||||
api_key_env = ("STRANDS_DECIDER_API_KEY",)
|
||||
api_base_env = ("STRANDS_DECIDER_API_BASE",)
|
||||
api_key_required = False
|
||||
|
||||
def __init__(self, aws: BaseAWSLLM | None = None) -> None:
|
||||
self._aws: Final = aws if aws is not None else BaseAWSLLM()
|
||||
|
||||
def get_complete_url(self, api_base: str, model: str) -> str:
|
||||
runtime: Final = agentcore_runtime(api_base)
|
||||
return super().get_complete_url(api_base, model) if runtime is None else runtime.invocations_url
|
||||
|
||||
def signs_with_aws(self, api_base: str) -> bool:
|
||||
return agentcore_runtime(api_base) is not None
|
||||
|
||||
def sign_request(
|
||||
self,
|
||||
headers: Mapping[str, str],
|
||||
url: str,
|
||||
api_base: str,
|
||||
body: Mapping[str, object],
|
||||
api_key: str | None,
|
||||
litellm_params: Mapping[str, object],
|
||||
) -> tuple[Mapping[str, str], bytes | None]:
|
||||
runtime: Final = agentcore_runtime(api_base)
|
||||
if runtime is None:
|
||||
return headers, None
|
||||
session_headers: Final = _runtime_headers(headers, runtime, litellm_params)
|
||||
if api_key is not None:
|
||||
return session_headers, None
|
||||
payload: Final = json.dumps(body)
|
||||
signed: Final = sign_aws_json_post(
|
||||
get_credentials=partial(
|
||||
self._aws.resolve_credentials, AwsAuthParams.model_validate(litellm_params), runtime.region
|
||||
),
|
||||
service_name="bedrock-agentcore",
|
||||
aws_region_name=runtime.region,
|
||||
url=url,
|
||||
body=payload,
|
||||
headers=session_headers,
|
||||
)
|
||||
return dict(signed.headers.items()), payload.encode()
|
||||
|
||||
def transform_decisions_response(
|
||||
self,
|
||||
model: str,
|
||||
custom_llm_provider: str,
|
||||
raw_response: httpx.Response,
|
||||
request: DecisionsIRRequest,
|
||||
) -> DecisionsIRResponse:
|
||||
runtime_error: Final = (
|
||||
None if invoked_runtime(raw_response.request.url) is None else _runtime_error(raw_response.content)
|
||||
)
|
||||
if runtime_error is not None:
|
||||
raise BaseLLMException(
|
||||
status_code=_RUNTIME_ERROR_STATUS.get(runtime_error.code, 500),
|
||||
message=f"Strands Decider runtime error '{runtime_error.code}': {runtime_error.message}",
|
||||
)
|
||||
return super().transform_decisions_response(model, custom_llm_provider, raw_response, request)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -411,6 +411,8 @@ _BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = (
|
|||
"anthropic_workspace_id",
|
||||
"anthropic-workspace-id",
|
||||
"bedrock_tags",
|
||||
# Deployment-pinned AgentCore session: every new caller-chosen value provisions another microVM.
|
||||
"agentcore_runtime_session_id",
|
||||
# Provider-specific endpoint overrides that flow into the outbound
|
||||
# request via ``optional_params``. Same threat as ``api_base``:
|
||||
# ``s3_endpoint_url`` redirects Bedrock file uploads to attacker
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@ ProxyRouteType: TypeAlias = Literal[
|
|||
"aocr",
|
||||
"asearch",
|
||||
"adecisions",
|
||||
"asystemone",
|
||||
"avideo_generation",
|
||||
"avideo_list",
|
||||
"avideo_status",
|
||||
|
|
@ -1986,6 +1987,7 @@ class ProxyBaseLLMRequestProcessing:
|
|||
"aocr",
|
||||
"asearch",
|
||||
"adecisions",
|
||||
"asystemone",
|
||||
"avideo_generation",
|
||||
"avideo_list",
|
||||
"avideo_status",
|
||||
|
|
|
|||
|
|
@ -437,3 +437,41 @@ class CustomOpenAPISpec:
|
|||
|
||||
# Add responses API request schema
|
||||
return CustomOpenAPISpec.add_responses_api_request_schema(with_embeddings)
|
||||
|
||||
|
||||
_MAX_INLINE_DEPTH: Final = 32
|
||||
|
||||
|
||||
def _inlined(node: JsonValue, defs: Mapping[str, JsonValue], depth: int = 0) -> JsonValue:
|
||||
if depth >= _MAX_INLINE_DEPTH or not isinstance(node, (list, dict)):
|
||||
return node
|
||||
if isinstance(node, list):
|
||||
return [_inlined(item, defs, depth + 1) for item in node]
|
||||
ref: Final = node.get("$ref")
|
||||
if isinstance(ref, str) and ref.startswith("#/$defs/"):
|
||||
target: Final = _inlined(defs[ref.removeprefix("#/$defs/")], defs, depth + 1)
|
||||
siblings: Final = {key: _inlined(value, defs, depth + 1) for key, value in node.items() if key != "$ref"}
|
||||
return {**target, **siblings} if isinstance(target, dict) else siblings
|
||||
if "propertyName" in node and "mapping" in node:
|
||||
return {"propertyName": node["propertyName"]}
|
||||
return {key: _inlined(value, defs, depth + 1) for key, value in node.items() if key != "$defs"}
|
||||
|
||||
|
||||
def inline_request_body(model_class: type, example: Mapping[str, object]) -> JsonObject:
|
||||
"""
|
||||
Build an ``openapi_extra["requestBody"]`` for a route that reads its body with ``request.body()``.
|
||||
|
||||
Swagger UI resolves ``$ref`` against the whole document, so a schema placed inline in an operation
|
||||
cannot keep Pydantic's ``#/$defs/...`` references. Every reference is expanded in place instead,
|
||||
which also keeps the operation self-contained inside the lazy OpenAPI snapshot.
|
||||
"""
|
||||
schema: Final = CustomOpenAPISpec.get_pydantic_schema(model_class)
|
||||
media: Final[JsonObject] = {"example": cast(JsonValue, dict(example))} # cast-ok: JSON example literal
|
||||
if schema is None:
|
||||
return {"required": True, "content": {"application/json": media}}
|
||||
raw_defs: Final = schema.get("$defs")
|
||||
defs: Final[Mapping[str, JsonValue]] = raw_defs if isinstance(raw_defs, dict) else MappingProxyType({})
|
||||
return {
|
||||
"required": True,
|
||||
"content": {"application/json": {"schema": _inlined(schema, defs), **media}},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from collections.abc import Mapping
|
||||
from typing import Annotated, Final
|
||||
from types import MappingProxyType
|
||||
from typing import Annotated, Final, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from fastapi.responses import ORJSONResponse # pyright: ignore[reportDeprecated] # required endpoint contract
|
||||
|
|
@ -8,7 +9,15 @@ from pydantic import TypeAdapter, ValidationError
|
|||
from litellm.exceptions import BadRequestError
|
||||
from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth, user_api_key_auth
|
||||
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
|
||||
from litellm.types.decisions import DecisionsRequestBody, OpenAIDecisionRequestBody
|
||||
from litellm.proxy.common_utils.custom_openapi_spec import inline_request_body
|
||||
from litellm.types.decisions import (
|
||||
DecisionsRequest,
|
||||
DecisionsRequestBody,
|
||||
DecisionsResponse,
|
||||
OpenAIDecisionRequest,
|
||||
OpenAIDecisionRequestBody,
|
||||
OpenAIDecisionResponse,
|
||||
)
|
||||
|
||||
router: Final = APIRouter()
|
||||
_REQUEST_DATA_ADAPTER: Final[TypeAdapter[dict[str, object]]] = TypeAdapter(dict[str, object])
|
||||
|
|
@ -19,6 +28,51 @@ _OPENAI_DECISION_REQUEST_BODY_ADAPTER: Final[TypeAdapter[OpenAIDecisionRequestBo
|
|||
_GENERAL_SETTINGS_ADAPTER: Final[TypeAdapter[dict[str, object]]] = TypeAdapter(dict[str, object])
|
||||
_OPTIONAL_STRING_ADAPTER: Final[TypeAdapter[str | None]] = TypeAdapter(str | None)
|
||||
_OPTIONAL_FLOAT_ADAPTER: Final[TypeAdapter[float | None]] = TypeAdapter(float | None)
|
||||
_SYSTEMONE_REQUEST_BODY: Final = inline_request_body(
|
||||
DecisionsRequest,
|
||||
{
|
||||
"model": "jev",
|
||||
"state": "Customer wrote: I was charged twice for order #4411 and want one charge refunded today.",
|
||||
"questions": {
|
||||
"is_refund_request": {"type": "noul", "instructions": "Is the customer asking for a refund?"},
|
||||
"urgency": {
|
||||
"type": "choice",
|
||||
"instructions": "How urgent is this?",
|
||||
"criteria": {"low": "can wait a week", "high": "needs action today"},
|
||||
},
|
||||
"frustration": {
|
||||
"type": "score",
|
||||
"instructions": "How frustrated is the customer?",
|
||||
"criteria": ["calm", "mildly annoyed", "angry"],
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
_DECISIONS_REQUEST_BODY: Final = inline_request_body(
|
||||
OpenAIDecisionRequest,
|
||||
{
|
||||
"model": "luna",
|
||||
"input": "Customer wrote: I was charged twice for order #4411 and want one charge refunded today.",
|
||||
"questions": [
|
||||
{"type": "predicate", "name": "is_refund_request", "instructions": "Is the customer asking for a refund?"},
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "urgency",
|
||||
"instructions": "How urgent is this?",
|
||||
"choices": [
|
||||
{"value": "low", "description": "can wait a week"},
|
||||
{"value": "high", "description": "needs action today"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "score",
|
||||
"name": "frustration",
|
||||
"instructions": "How frustrated is the customer?",
|
||||
"levels": [{"label": "calm"}, {"label": "mildly annoyed"}, {"label": "angry"}],
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _invalid_request(
|
||||
|
|
@ -38,6 +92,10 @@ async def _invalid_request(
|
|||
)
|
||||
|
||||
|
||||
def _fields_checked_before_routing(data: Mapping[str, object]) -> Mapping[str, object]:
|
||||
return MappingProxyType({key: value for key, value in data.items() if key != "safety_identifier"})
|
||||
|
||||
|
||||
async def _request_data(request: Request, user_api_key_dict: UserAPIKeyAuth) -> dict[str, object]:
|
||||
body: Final = await request.body()
|
||||
try:
|
||||
|
|
@ -51,6 +109,7 @@ async def _process_decisions(
|
|||
fastapi_response: Response,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
body_adapter: TypeAdapter[DecisionsRequestBody] | TypeAdapter[OpenAIDecisionRequestBody],
|
||||
route_type: Literal["adecisions", "asystemone"],
|
||||
) -> object:
|
||||
from litellm.proxy.proxy_server import (
|
||||
general_settings as proxy_general_settings,
|
||||
|
|
@ -75,7 +134,7 @@ async def _process_decisions(
|
|||
|
||||
data: Final = await _request_data(request, user_api_key_dict)
|
||||
try:
|
||||
body_adapter.validate_python(data)
|
||||
body_adapter.validate_python(_fields_checked_before_routing(data))
|
||||
except ValidationError as error:
|
||||
raise await _invalid_request(raw_data=data, error=error, user_api_key_dict=user_api_key_dict)
|
||||
general_settings: Final = _GENERAL_SETTINGS_ADAPTER.validate_python(proxy_general_settings)
|
||||
|
|
@ -88,7 +147,7 @@ async def _process_decisions(
|
|||
request=request,
|
||||
fastapi_response=fastapi_response,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
route_type="adecisions",
|
||||
route_type=route_type,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
llm_router=llm_router,
|
||||
general_settings=general_settings,
|
||||
|
|
@ -116,23 +175,41 @@ async def _process_decisions(
|
|||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_class=ORJSONResponse, # pyright: ignore[reportDeprecated] # required endpoint contract
|
||||
tags=["decisions"],
|
||||
summary="Ask named questions about a state (System One format)",
|
||||
responses={200: {"model": DecisionsResponse}},
|
||||
openapi_extra={"requestBody": _SYSTEMONE_REQUEST_BODY},
|
||||
)
|
||||
@router.post(
|
||||
"/systemone",
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_class=ORJSONResponse, # pyright: ignore[reportDeprecated] # required endpoint contract
|
||||
tags=["decisions"],
|
||||
summary="Ask named questions about a state (System One format)",
|
||||
responses={200: {"model": DecisionsResponse}},
|
||||
openapi_extra={"requestBody": _SYSTEMONE_REQUEST_BODY},
|
||||
)
|
||||
async def systemone(
|
||||
request: Request,
|
||||
fastapi_response: Response,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
):
|
||||
"""
|
||||
Judge a `state` against 1 to 128 named `questions` and get one calibrated answer per question name.
|
||||
|
||||
Question types are `noul` (probability the answer is yes), `choice` (one label out of `criteria`, with
|
||||
`confidence` and `probabilities`) and `score` (an index into the `criteria` levels, with `confidence`,
|
||||
`legend` and `probabilities`). Requests that break these rules are rejected with a 400 before any provider
|
||||
is called. `model` is any decision model in the proxy model list. OpenAI decision models accept this format
|
||||
too, LiteLLM translates it and keeps the answers keyed by question name. Streaming is not supported.
|
||||
|
||||
[Docs](https://docs.litellm.ai/docs/decisions)
|
||||
"""
|
||||
return await _process_decisions(
|
||||
request=request,
|
||||
fastapi_response=fastapi_response,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
body_adapter=_DECISIONS_REQUEST_BODY_ADAPTER,
|
||||
route_type="asystemone",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -141,21 +218,39 @@ async def systemone(
|
|||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_class=ORJSONResponse, # pyright: ignore[reportDeprecated] # required endpoint contract
|
||||
tags=["decisions"],
|
||||
summary="Ask questions about an input (OpenAI Decisions format)",
|
||||
responses={200: {"model": OpenAIDecisionResponse}},
|
||||
openapi_extra={"requestBody": _DECISIONS_REQUEST_BODY},
|
||||
)
|
||||
@router.post(
|
||||
"/decisions",
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_class=ORJSONResponse, # pyright: ignore[reportDeprecated] # required endpoint contract
|
||||
tags=["decisions"],
|
||||
summary="Ask questions about an input (OpenAI Decisions format)",
|
||||
responses={200: {"model": OpenAIDecisionResponse}},
|
||||
openapi_extra={"requestBody": _DECISIONS_REQUEST_BODY},
|
||||
)
|
||||
async def decisions(
|
||||
request: Request,
|
||||
fastapi_response: Response,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
):
|
||||
"""
|
||||
Judge an `input` against 1 to 128 `questions` and get the answers back in the same order.
|
||||
|
||||
Question types are `predicate` (yes/no with `probability`), `choice` (one of the `choices`, with
|
||||
`probabilities`) and `score` (an index into `levels`, with `probabilities`). Requests that break these
|
||||
rules are rejected with a 400 before any provider is called. `model` is any decision model in the proxy
|
||||
model list. System One decision models accept this format too, LiteLLM translates the request and the
|
||||
answers. Streaming is not supported.
|
||||
|
||||
[Docs](https://docs.litellm.ai/docs/decisions)
|
||||
"""
|
||||
return await _process_decisions(
|
||||
request=request,
|
||||
fastapi_response=fastapi_response,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
body_adapter=_OPENAI_DECISION_REQUEST_BODY_ADAPTER,
|
||||
route_type="adecisions",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1496,7 +1496,7 @@ async def get_guardrail_ui_settings() -> GuardrailUIAddGuardrailSettings:
|
|||
- PII entity categories for UI organization
|
||||
- Content filter settings (patterns and categories)
|
||||
"""
|
||||
from litellm.decisions.main import supported_decisions_providers
|
||||
from litellm.decisions.call import supported_decisions_providers
|
||||
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.patterns import (
|
||||
PATTERN_CATEGORIES,
|
||||
get_available_content_categories,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ if TYPE_CHECKING:
|
|||
class _TypeSafeUsage(LiteLLMBaseModel):
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
cost: float | None = None
|
||||
|
||||
|
||||
class _TypeSafeResponse(LiteLLMBaseModel):
|
||||
|
|
@ -88,7 +89,9 @@ class TypeSafePassthroughLoggingHandler:
|
|||
)
|
||||
pricing: Final = _pricing_for(candidate_model_keys)
|
||||
response_cost: Final = (
|
||||
input_tokens * pricing.input_cost_per_token + output_tokens * pricing.output_cost_per_token
|
||||
usage.cost
|
||||
if custom_llm_provider == "openrouter" and usage.cost is not None
|
||||
else input_tokens * pricing.input_cost_per_token + output_tokens * pricing.output_cost_per_token
|
||||
)
|
||||
usage_object: Final = Usage(
|
||||
prompt_tokens=input_tokens,
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ ROUTE_ENDPOINT_MAPPING: Final = {
|
|||
"aocr": "/ocr",
|
||||
"asearch": "/search",
|
||||
"adecisions": "/decisions",
|
||||
"asystemone": "/systemone",
|
||||
"avideo_generation": "/videos",
|
||||
"avideo_list": "/videos",
|
||||
"avideo_status": "/videos/{video_id}",
|
||||
|
|
@ -519,6 +520,7 @@ RouteType = Literal[
|
|||
"aocr",
|
||||
"asearch",
|
||||
"adecisions",
|
||||
"asystemone",
|
||||
"avideo_generation",
|
||||
"avideo_list",
|
||||
"avideo_status",
|
||||
|
|
|
|||
|
|
@ -2232,6 +2232,11 @@ class Router:
|
|||
self.adecisions = self.factory_function(adecisions, call_type="adecisions")
|
||||
self.decisions = self.factory_function(decisions, call_type="decisions")
|
||||
|
||||
from litellm.systemone import asystemone, systemone
|
||||
|
||||
self.asystemone = self.factory_function(asystemone, call_type="asystemone") # pyright: ignore[reportUnknownMemberType] # factory_function takes a bare Callable
|
||||
self.systemone = self.factory_function(systemone, call_type="systemone") # pyright: ignore[reportUnknownMemberType] # factory_function takes a bare Callable
|
||||
|
||||
def _initialize_video_endpoints(self):
|
||||
"""Initialize video endpoints."""
|
||||
from litellm.videos import (
|
||||
|
|
@ -6972,6 +6977,8 @@ class Router:
|
|||
"search",
|
||||
"adecisions",
|
||||
"decisions",
|
||||
"asystemone",
|
||||
"systemone",
|
||||
"aadapter_generate_content",
|
||||
"avideo_generation",
|
||||
"video_generation",
|
||||
|
|
@ -7046,6 +7053,7 @@ class Router:
|
|||
"ocr",
|
||||
"search",
|
||||
"decisions",
|
||||
"systemone",
|
||||
"video_generation",
|
||||
"video_list",
|
||||
"video_status",
|
||||
|
|
@ -7214,6 +7222,7 @@ class Router:
|
|||
"aocr",
|
||||
"ocr",
|
||||
"adecisions",
|
||||
"asystemone",
|
||||
"avideo_generation",
|
||||
"avideo_list",
|
||||
"avideo_status",
|
||||
|
|
|
|||
|
|
@ -136,7 +136,6 @@ class HttpJevClassifierClient:
|
|||
return DATABRICKS_DECISIONS_CONFIG.classifier_response(body, requested_model)
|
||||
case "typesafe" | "bespoke":
|
||||
return body
|
||||
|
||||
def _log_response(
|
||||
self,
|
||||
request: JevSystemOneRequest,
|
||||
|
|
|
|||
3
litellm/systemone/__init__.py
Normal file
3
litellm/systemone/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from litellm.systemone.main import asystemone, systemone
|
||||
|
||||
__all__ = ["asystemone", "systemone"]
|
||||
107
litellm/systemone/main.py
Normal file
107
litellm/systemone/main.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
|
||||
from litellm.decisions.call import (
|
||||
DecisionsCall,
|
||||
SystemOneQuestions,
|
||||
SystemOneRequestFields,
|
||||
asend,
|
||||
prepare_call,
|
||||
reject_other_format,
|
||||
send,
|
||||
)
|
||||
from litellm.types.decisions import DecisionsJSON, DecisionsResponse
|
||||
from litellm.utils import client # pyright: ignore[reportUnknownVariableType] # client is an untyped decorator
|
||||
|
||||
|
||||
def _prepare_systemone_call(
|
||||
*,
|
||||
model: str,
|
||||
state: DecisionsJSON | None,
|
||||
questions: SystemOneQuestions | None,
|
||||
safety_identifier: str | None,
|
||||
api_key: str | None,
|
||||
api_base: str | None,
|
||||
timeout: float | httpx.Timeout | None,
|
||||
custom_llm_provider: str | None,
|
||||
extra_headers: Mapping[str, str] | None,
|
||||
kwargs: Mapping[str, object],
|
||||
) -> DecisionsCall:
|
||||
reject_other_format(model=model, kwargs=kwargs, field_name="input", other_function="decisions")
|
||||
return prepare_call(
|
||||
model=model,
|
||||
fields=SystemOneRequestFields(state=state, questions=questions),
|
||||
safety_identifier=safety_identifier,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
timeout=timeout,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
extra_headers=extra_headers,
|
||||
kwargs=kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _systemone_response(response: object) -> DecisionsResponse:
|
||||
assert isinstance(response, DecisionsResponse)
|
||||
return response
|
||||
|
||||
|
||||
@client
|
||||
async def asystemone(
|
||||
model: str,
|
||||
state: DecisionsJSON | None = None,
|
||||
questions: SystemOneQuestions | None = None,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
extra_headers: Mapping[str, str] | None = None,
|
||||
safety_identifier: str | None = None,
|
||||
**kwargs: object, # kwargs-ok: litellm_params forwarded by @client
|
||||
) -> DecisionsResponse:
|
||||
call: Final = _prepare_systemone_call(
|
||||
model=model,
|
||||
state=state,
|
||||
questions=questions,
|
||||
safety_identifier=safety_identifier,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
timeout=timeout,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
extra_headers=extra_headers,
|
||||
kwargs=kwargs,
|
||||
)
|
||||
return _systemone_response(await asend(call))
|
||||
|
||||
|
||||
@client
|
||||
def systemone(
|
||||
model: str,
|
||||
state: DecisionsJSON | None = None,
|
||||
questions: SystemOneQuestions | None = None,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
extra_headers: Mapping[str, str] | None = None,
|
||||
safety_identifier: str | None = None,
|
||||
**kwargs: object, # kwargs-ok: litellm_params forwarded by @client
|
||||
) -> DecisionsResponse:
|
||||
call: Final = _prepare_systemone_call(
|
||||
model=model,
|
||||
state=state,
|
||||
questions=questions,
|
||||
safety_identifier=safety_identifier,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
timeout=timeout,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
extra_headers=extra_headers,
|
||||
kwargs=kwargs,
|
||||
)
|
||||
return _systemone_response(send(call))
|
||||
|
||||
|
||||
__all__ = ["asystemone", "systemone"]
|
||||
|
|
@ -15,8 +15,13 @@ MAX_DECISION_QUESTIONS: Final = 128
|
|||
|
||||
class NoulQuestion(LiteLLMPydanticObjectBase):
|
||||
type: Literal["noul"]
|
||||
instructions: DecisionsJSON | None = None
|
||||
criteria: NoulCriteria | None = None
|
||||
instructions: Annotated[DecisionsJSON | None, Field(description="The yes/no question to ask about the state")] = (
|
||||
None
|
||||
)
|
||||
criteria: Annotated[
|
||||
NoulCriteria | None,
|
||||
Field(description="Optional descriptions of what makes the answer true and what makes it false"),
|
||||
] = None
|
||||
|
||||
model_config = ConfigDict(extra="allow", frozen=True)
|
||||
|
||||
|
|
@ -29,16 +34,24 @@ class NoulQuestion(LiteLLMPydanticObjectBase):
|
|||
|
||||
class ChoiceQuestion(LiteLLMPydanticObjectBase):
|
||||
type: Literal["choice"]
|
||||
instructions: DecisionsJSON | None = None
|
||||
criteria: Annotated[Mapping[str, DecisionsJSON | None], Field(min_length=1, max_length=255)]
|
||||
instructions: Annotated[DecisionsJSON | None, Field(description="The question to ask about the state")] = None
|
||||
criteria: Annotated[
|
||||
Mapping[str, DecisionsJSON | None],
|
||||
Field(min_length=1, max_length=255, description="Candidate labels mapped to an optional description of each"),
|
||||
]
|
||||
|
||||
model_config = ConfigDict(extra="allow", frozen=True)
|
||||
|
||||
|
||||
class ScoreQuestion(LiteLLMPydanticObjectBase):
|
||||
type: Literal["score"]
|
||||
instructions: DecisionsJSON | None = None
|
||||
criteria: Annotated[Sequence[DecisionsJSON], Field(min_length=1, max_length=10)]
|
||||
instructions: Annotated[DecisionsJSON | None, Field(description="The question to ask about the state")] = None
|
||||
criteria: Annotated[
|
||||
Sequence[DecisionsJSON],
|
||||
Field(
|
||||
min_length=1, max_length=10, description="Scale levels from lowest to highest. The score is a level index"
|
||||
),
|
||||
]
|
||||
|
||||
model_config = ConfigDict(extra="allow", frozen=True)
|
||||
|
||||
|
|
@ -55,14 +68,20 @@ DecisionQuestionMap: TypeAlias = Annotated[
|
|||
|
||||
|
||||
class DecisionsRequestBody(LiteLLMPydanticObjectBase):
|
||||
state: DecisionsJSON
|
||||
questions: DecisionQuestionMap
|
||||
state: Annotated[
|
||||
DecisionsJSON,
|
||||
Field(description="The thing being judged, such as a support ticket, a document or a chat transcript"),
|
||||
]
|
||||
questions: Annotated[
|
||||
DecisionQuestionMap,
|
||||
Field(description="Named noul, choice and score questions. Each key becomes a key in the answers"),
|
||||
]
|
||||
|
||||
model_config = ConfigDict(extra="allow", frozen=True)
|
||||
|
||||
|
||||
class DecisionsRequest(DecisionsRequestBody):
|
||||
model: str
|
||||
model: Annotated[str, Field(description="A decision model from the proxy model_list")]
|
||||
|
||||
|
||||
@with_config(ConfigDict(extra="allow"))
|
||||
|
|
@ -171,8 +190,8 @@ OpenAIDecisionInput: TypeAlias = str | Sequence[OpenAIDecisionInputMessage]
|
|||
|
||||
class OpenAIPredicateQuestion(LiteLLMPydanticObjectBase):
|
||||
type: Literal["predicate"]
|
||||
name: str | None = None
|
||||
instructions: str
|
||||
name: Annotated[str | None, Field(description="Echoed in the matching answer")] = None
|
||||
instructions: Annotated[str, Field(description="The yes/no question to ask about the input")]
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
|
@ -192,9 +211,12 @@ def systemone_choice_key(value: str | bool) -> str:
|
|||
|
||||
class OpenAIChoiceQuestion(LiteLLMPydanticObjectBase):
|
||||
type: Literal["choice"]
|
||||
name: str | None = None
|
||||
instructions: str
|
||||
choices: Annotated[Sequence[OpenAIChoiceOption], Field(min_length=2, max_length=255)]
|
||||
name: Annotated[str | None, Field(description="Echoed in the matching answer")] = None
|
||||
instructions: Annotated[str, Field(description="The question to ask about the input")]
|
||||
choices: Annotated[
|
||||
Sequence[OpenAIChoiceOption],
|
||||
Field(min_length=2, max_length=255, description="The options the model picks from. Values must be unique"),
|
||||
]
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
|
@ -215,9 +237,14 @@ class OpenAIScoreLevel(LiteLLMPydanticObjectBase):
|
|||
|
||||
class OpenAIScoreQuestion(LiteLLMPydanticObjectBase):
|
||||
type: Literal["score"]
|
||||
name: str | None = None
|
||||
instructions: str
|
||||
levels: Annotated[Sequence[OpenAIScoreLevel], Field(min_length=2, max_length=10)]
|
||||
name: Annotated[str | None, Field(description="Echoed in the matching answer")] = None
|
||||
instructions: Annotated[str, Field(description="The question to ask about the input")]
|
||||
levels: Annotated[
|
||||
Sequence[OpenAIScoreLevel],
|
||||
Field(
|
||||
min_length=2, max_length=10, description="Scale levels from lowest to highest. The score is a level index"
|
||||
),
|
||||
]
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
|
@ -229,13 +256,32 @@ OpenAIDecisionQuestion: TypeAlias = Annotated[
|
|||
|
||||
|
||||
class OpenAIDecisionRequestBody(LiteLLMPydanticObjectBase):
|
||||
input: OpenAIDecisionInput
|
||||
questions: Annotated[Sequence[OpenAIDecisionQuestion], Field(min_length=1, max_length=MAX_DECISION_QUESTIONS)]
|
||||
safety_identifier: str | None = None
|
||||
input: Annotated[
|
||||
OpenAIDecisionInput,
|
||||
Field(
|
||||
description="The text being judged, or user messages whose content mixes input_text and input_image parts"
|
||||
),
|
||||
]
|
||||
questions: Annotated[
|
||||
Sequence[OpenAIDecisionQuestion],
|
||||
Field(
|
||||
min_length=1,
|
||||
max_length=MAX_DECISION_QUESTIONS,
|
||||
description="Predicate, choice and score questions. Answers come back in the same order",
|
||||
),
|
||||
]
|
||||
safety_identifier: Annotated[
|
||||
str | None,
|
||||
Field(description="A stable id for your end user. Sent to OpenAI and dropped for other providers"),
|
||||
] = None
|
||||
|
||||
model_config = ConfigDict(extra="allow", frozen=True)
|
||||
|
||||
|
||||
class OpenAIDecisionRequest(OpenAIDecisionRequestBody):
|
||||
model: Annotated[str, Field(description="A decision model from the proxy model_list")]
|
||||
|
||||
|
||||
class OpenAIPredicateAnswer(LiteLLMPydanticObjectBase):
|
||||
type: Literal["predicate"] = "predicate"
|
||||
name: str | None
|
||||
|
|
|
|||
|
|
@ -489,6 +489,8 @@ class CallTypes(str, Enum):
|
|||
asearch = "asearch"
|
||||
decisions = "decisions"
|
||||
adecisions = "adecisions"
|
||||
systemone = "systemone"
|
||||
asystemone = "asystemone"
|
||||
arealtime = "_arealtime"
|
||||
aresponses_websocket = "_aresponses_websocket"
|
||||
create_batch = "create_batch"
|
||||
|
|
@ -677,6 +679,8 @@ CallTypesLiteral = Literal[
|
|||
"asearch",
|
||||
"decisions",
|
||||
"adecisions",
|
||||
"systemone",
|
||||
"asystemone",
|
||||
"_arealtime",
|
||||
"_aresponses_websocket",
|
||||
"create_batch",
|
||||
|
|
@ -788,8 +792,8 @@ API_ROUTE_TO_CALL_TYPES: Final[Mapping[str, Sequence[CallTypes]]] = {
|
|||
"/v1/search": [CallTypes.asearch, CallTypes.search],
|
||||
"/decisions": [CallTypes.adecisions, CallTypes.decisions],
|
||||
"/v1/decisions": [CallTypes.adecisions, CallTypes.decisions],
|
||||
"/systemone": [CallTypes.adecisions, CallTypes.decisions],
|
||||
"/v1/systemone": [CallTypes.adecisions, CallTypes.decisions],
|
||||
"/systemone": [CallTypes.asystemone, CallTypes.systemone],
|
||||
"/v1/systemone": [CallTypes.asystemone, CallTypes.systemone],
|
||||
# Batches
|
||||
"/batches": [CallTypes.acreate_batch, CallTypes.create_batch],
|
||||
"/v1/batches": [CallTypes.acreate_batch, CallTypes.create_batch],
|
||||
|
|
|
|||
|
|
@ -974,6 +974,12 @@ def _ocr_document_summary(document: object) -> str:
|
|||
|
||||
|
||||
# Runs once per call to check if the user wants to send their data anywhere - PostHog/Sentry/Slack/etc.
|
||||
|
||||
_DECISION_CALL_TYPES: Final[frozenset[str]] = frozenset(
|
||||
(CallTypes.decisions.value, CallTypes.adecisions.value, CallTypes.systemone.value, CallTypes.asystemone.value)
|
||||
)
|
||||
|
||||
|
||||
def function_setup(
|
||||
original_function: str,
|
||||
rules_obj: Rules,
|
||||
|
|
@ -1220,7 +1226,7 @@ def function_setup(
|
|||
if isinstance(search_query, list)
|
||||
else search_query
|
||||
)
|
||||
elif call_type in (CallTypes.decisions.value, CallTypes.adecisions.value):
|
||||
elif call_type in _DECISION_CALL_TYPES:
|
||||
decisions_state: Final = args[1] if len(args) > 1 else kwargs.get("state") or kwargs.get("input") or ""
|
||||
messages = decisions_state if isinstance(decisions_state, str) else json.dumps(decisions_state, default=str)
|
||||
elif call_type in (CallTypes.image_edit.value, CallTypes.aimage_edit.value):
|
||||
|
|
@ -8991,6 +8997,8 @@ class ProviderConfigManager:
|
|||
return litellm.StrandsDeciderDecisionsConfig()
|
||||
if provider == LlmProviders.DATABRICKS:
|
||||
return litellm.DatabricksDecisionsConfig()
|
||||
if provider == LlmProviders.HOSTED_VLLM:
|
||||
return litellm.HostedVLLMDecisionsConfig()
|
||||
if provider == LlmProviders.OPENAI:
|
||||
return litellm.OpenAIDecisionsConfig()
|
||||
if provider == LlmProviders.AZURE_AI:
|
||||
|
|
|
|||
|
|
@ -1370,7 +1370,8 @@
|
|||
"rerank": true,
|
||||
"a2a": true,
|
||||
"interactions": true,
|
||||
"video_generations": true
|
||||
"video_generations": true,
|
||||
"systemone": true
|
||||
}
|
||||
},
|
||||
"huggingface": {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ IGNORE_FUNCTIONS = [
|
|||
"sanitize_oci_schema", # OCI: bounded by JSON-schema tree depth.
|
||||
"_freeze_for_dedupe", # OTEL: max depth set (default 16, _FREEZE_MAX_DEPTH); fails closed by returning repr(value) at the cap.
|
||||
"apply_json_merge_patch", # max depth set (_MAX_MERGE_DEPTH=64); fails closed by raising ValueError at the cap.
|
||||
"_inlined", # max depth set (_MAX_INLINE_DEPTH=32); passes the schema node through untouched at the cap. Walks a Pydantic JSON schema once at import time.
|
||||
"_filter_argument_value", # max depth set (DEFAULT_MAX_RECURSE_DEPTH); fails closed by blocking the tool call at the cap.
|
||||
"_redact_scanned_content", # max depth set (DEFAULT_MAX_RECURSE_DEPTH); fails closed by returning "[REDACTED]" at the cap.
|
||||
"replace_ciphertexts", # max depth set (DEFAULT_MAX_RECURSE_DEPTH); walks stored JSON, which has no cycles, and leaves values below the cap untouched.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import socket
|
||||
import threading
|
||||
from collections.abc import Generator, Mapping
|
||||
from contextlib import contextmanager
|
||||
|
|
@ -69,3 +71,47 @@ def refusing_forward_proxy(port: int = 0) -> Generator[ForwardProxy, None, None]
|
|||
thread.join(timeout=6)
|
||||
assert not thread.is_alive(), "Owned forward proxy survived cleanup"
|
||||
server.server_close()
|
||||
|
||||
|
||||
def _pipe(source: socket.socket, sink: socket.socket) -> None:
|
||||
with contextlib.suppress(OSError):
|
||||
for chunk in iter(lambda: source.recv(65536), b""):
|
||||
sink.sendall(chunk)
|
||||
with contextlib.suppress(OSError):
|
||||
sink.shutdown(socket.SHUT_WR)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def tunnelling_forward_proxy(destination_port: int, port: int = 0) -> Generator[ForwardProxy, None, None]:
|
||||
received: Final[SimpleQueue[Tunnel]] = SimpleQueue()
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
rbufsize = 0
|
||||
timeout = 30
|
||||
request: socket.socket
|
||||
|
||||
def do_CONNECT(self) -> None:
|
||||
received.put(Tunnel(self.command, self.path, {name.lower(): value for name, value in self.headers.items()}))
|
||||
self.close_connection = True
|
||||
with socket.create_connection(("127.0.0.1", destination_port), timeout=30) as destination:
|
||||
self.send_response(200, "Connection established")
|
||||
self.end_headers()
|
||||
outbound: Final = threading.Thread(target=_pipe, args=(self.request, destination))
|
||||
outbound.start()
|
||||
_pipe(destination, self.request)
|
||||
outbound.join(timeout=35)
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
pass
|
||||
|
||||
with ThreadingHTTPServer(("127.0.0.1", port), Handler) as server:
|
||||
thread: Final = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05})
|
||||
thread.start()
|
||||
try:
|
||||
yield ForwardProxy(f"http://127.0.0.1:{server.server_port}", server.server_port, received)
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join(timeout=6)
|
||||
assert not thread.is_alive(), "Owned forward proxy survived cleanup"
|
||||
server.server_close()
|
||||
|
|
|
|||
|
|
@ -1,33 +1,27 @@
|
|||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from integration._support.client import Gateway, Scenario, eventually, object_value, string_value
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.openai_wire import answering_model_discovery, chat_reply
|
||||
from integration._support.wire import Reply, Request, Wire, wire_server
|
||||
from pydantic import JsonValue
|
||||
|
||||
from litellm.router_strategy.complexity_router.config import DEFAULT_JEV_INSTRUCTIONS
|
||||
|
||||
_MODEL: Final = "ai_decide"
|
||||
_ROUTE: Final = "/api/2.0/ai-functions/ai-decide"
|
||||
_ENDPOINT: Final = "databricks-openjev-qwen35-4b"
|
||||
_SECOND_WORKSPACE: Final = "/second-workspace"
|
||||
_SECOND_ENDPOINT: Final = "databricks-openjev-qwen35-4b-canary"
|
||||
_API_KEY: Final = "synthetic-databricks-key"
|
||||
_MODEL_RULE: Final = (
|
||||
"Databricks decisions model must be 'ai_decide' (the ai_decide AI Function) or a bare serving endpoint name "
|
||||
"(letters, digits, '-', '_' and '.', with no '/', '?', '#', spaces, or a leading '.'), e.g. "
|
||||
"databricks-openjev-qwen35-4b"
|
||||
_BARE_NAME_RULE: Final = (
|
||||
"must be the bare endpoint name (letters, digits, '-', '_' and '.', with no '/', '?', '#', spaces, or a "
|
||||
"leading '.'), e.g. databricks-openjev-qwen35-4b"
|
||||
)
|
||||
_REJECTED_AT_LOAD: Final = "The router would drop this deployment at load time, so the write is rejected instead."
|
||||
_MODEL_REQUIRED: Final = (
|
||||
"opensource_classifier_config.model is required for provider 'databricks': 'ai_decide' or a serving endpoint "
|
||||
"name, e.g. databricks-openjev-qwen35-4b"
|
||||
"opensource_classifier_config.model is required for provider 'databricks': the serving endpoint name, "
|
||||
"e.g. databricks-openjev-qwen35-4b"
|
||||
)
|
||||
_BASE_NEEDS_KEY: Final = (
|
||||
"opensource_classifier_config.api_base requires opensource_classifier_config.api_key: DATABRICKS_API_KEY or "
|
||||
|
|
@ -36,26 +30,17 @@ _BASE_NEEDS_KEY: Final = (
|
|||
_BLANK_KEY: Final = (
|
||||
"opensource_classifier_config.api_key must be non-empty; omit it to use the provider environment key"
|
||||
)
|
||||
_ANSWERS: Final[dict[str, JsonValue]] = {
|
||||
"tier": {
|
||||
"type": "choice",
|
||||
"choice": "COMPLEX",
|
||||
"confidence": 0.91,
|
||||
"probabilities": {"SIMPLE": 0.03, "MEDIUM": 0.06, "COMPLEX": 0.91},
|
||||
}
|
||||
}
|
||||
_AI_DECIDE_ANSWER: Final[dict[str, JsonValue]] = {"response": {"answers": _ANSWERS}, "metadata": {"version": "1.0"}}
|
||||
_ENDPOINT_ANSWER: Final[dict[str, JsonValue]] = {
|
||||
"model": "/mosaicml/local_model",
|
||||
"answers": _ANSWERS,
|
||||
_CLASSIFIER_ANSWER: Final[dict[str, JsonValue]] = {
|
||||
"answers": {
|
||||
"tier": {
|
||||
"type": "choice",
|
||||
"choice": "COMPLEX",
|
||||
"confidence": 0.91,
|
||||
"probabilities": {"SIMPLE": 0.03, "MEDIUM": 0.06, "COMPLEX": 0.91},
|
||||
}
|
||||
},
|
||||
"usage": {"input_tokens": 12, "output_tokens": 1},
|
||||
}
|
||||
_TIER_CRITERIA: Final[dict[str, JsonValue]] = {
|
||||
"SIMPLE": "Greetings, chitchat, or short factual lookups with known answers",
|
||||
"MEDIUM": "Everyday requests needing explanation, light reasoning, or minor technical work",
|
||||
"COMPLEX": "Non-trivial code, architecture, multi-step work, or specialized domain depth",
|
||||
"REASONING": "Open-ended analysis, proofs, tradeoffs, or tasks requiring careful thought",
|
||||
}
|
||||
_ROWS_QUERY: Final = (
|
||||
"SELECT request_id, status, metadata->>'internal_call_origin' AS origin "
|
||||
'FROM "LiteLLM_SpendLogs" WHERE model_group = %s'
|
||||
|
|
@ -63,8 +48,7 @@ _ROWS_QUERY: Final = (
|
|||
|
||||
|
||||
def _classifier(request: Request) -> Reply:
|
||||
answer: Final = _ENDPOINT_ANSWER if request.target.endswith("/invocations") else _AI_DECIDE_ANSWER
|
||||
return Reply(body=json.dumps(answer).encode())
|
||||
return Reply(body=json.dumps(_CLASSIFIER_ANSWER).encode())
|
||||
|
||||
|
||||
def _tier(text: str) -> Callable[[Request], Reply]:
|
||||
|
|
@ -78,7 +62,7 @@ def _tier(text: str) -> Callable[[Request], Reply]:
|
|||
def _classifier_config(api_base: str, **overrides: JsonValue) -> dict[str, JsonValue]:
|
||||
return {
|
||||
"provider": "databricks",
|
||||
"model": _MODEL,
|
||||
"model": _ENDPOINT,
|
||||
"api_base": api_base,
|
||||
"api_key": _API_KEY,
|
||||
"timeout_ms": 20000,
|
||||
|
|
@ -147,39 +131,21 @@ async def _burst(base_url: str, key: str, model: str, count: int) -> tuple[httpx
|
|||
)
|
||||
|
||||
|
||||
def _request_rows(rows: Sequence[dict[str, JsonValue]]) -> tuple[dict[str, JsonValue], ...]:
|
||||
return tuple(row for row in rows if row["origin"] != "autorouter_classifier")
|
||||
|
||||
|
||||
def _rows(router: str, *, requests: int) -> tuple[dict[str, JsonValue], ...]:
|
||||
return tuple(
|
||||
eventually(
|
||||
lambda: read_rows(_ROWS_QUERY, (router,)),
|
||||
lambda found: len(_request_rows(found)) >= requests,
|
||||
seconds=70,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_validate_accepts_a_databricks_ai_decide_classifier_with_or_without_the_serving_endpoints_suffix(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
for api_base in (
|
||||
"https://dbc-example.cloud.databricks.com",
|
||||
"https://dbc-example.cloud.databricks.com/serving-endpoints",
|
||||
):
|
||||
config: Final = _router_config(_classifier_config(api_base), "simple", "complex")
|
||||
assert _validate(gateway, config) == {"valid": True, "error": None}, api_base
|
||||
def _rows(router: str, *, want: int) -> tuple[dict[str, JsonValue], ...]:
|
||||
return tuple(eventually(lambda: read_rows(_ROWS_QUERY, (router,)), lambda found: len(found) >= want, seconds=70))
|
||||
|
||||
|
||||
def test_validate_accepts_a_databricks_classifier_with_a_bare_endpoint_name_of_any_length(gateway: Gateway) -> None:
|
||||
for model in (_ENDPOINT, "e" * 5000):
|
||||
config: Final = _router_config(
|
||||
_classifier_config("https://dbc-example.cloud.databricks.com/serving-endpoints", model=model),
|
||||
"simple",
|
||||
"complex",
|
||||
)
|
||||
assert _validate(gateway, config) == {"valid": True, "error": None}, model[:40]
|
||||
config: Final = _router_config(
|
||||
_classifier_config("https://dbc-example.cloud.databricks.com/serving-endpoints"), "simple", "complex"
|
||||
)
|
||||
assert _validate(gateway, config) == {"valid": True, "error": None}
|
||||
long_name: Final = _router_config(
|
||||
_classifier_config("https://dbc-example.cloud.databricks.com/serving-endpoints", model="e" * 5000),
|
||||
"simple",
|
||||
"complex",
|
||||
)
|
||||
assert _validate(gateway, long_name) == {"valid": True, "error": None}
|
||||
|
||||
|
||||
def test_validate_names_each_databricks_classifier_misconfiguration(gateway: Gateway) -> None:
|
||||
|
|
@ -191,10 +157,10 @@ def test_validate_names_each_databricks_classifier_misconfiguration(gateway: Gat
|
|||
_without(base, "api_key"),
|
||||
f"at opensource_classifier_config: Value error, {_BASE_NEEDS_KEY}",
|
||||
),
|
||||
("slash in the name", {**base, "model": "a/b"}, _MODEL_RULE),
|
||||
("space in the name", {**base, "model": "a b"}, _MODEL_RULE),
|
||||
("leading dot", {**base, "model": ".hidden"}, _MODEL_RULE),
|
||||
("empty name", {**base, "model": ""}, _MODEL_RULE),
|
||||
("slash in the name", {**base, "model": "a/b"}, f"Databricks serving endpoint name 'a/b' {_BARE_NAME_RULE}"),
|
||||
("space in the name", {**base, "model": "a b"}, f"Databricks serving endpoint name 'a b' {_BARE_NAME_RULE}"),
|
||||
("leading dot", {**base, "model": ".hidden"}, f"Databricks serving endpoint name '.hidden' {_BARE_NAME_RULE}"),
|
||||
("empty name", {**base, "model": ""}, f"Databricks serving endpoint name '' {_BARE_NAME_RULE}"),
|
||||
(
|
||||
"blank api_key",
|
||||
{**base, "api_key": ""},
|
||||
|
|
@ -217,13 +183,8 @@ def test_validate_names_each_databricks_classifier_misconfiguration(gateway: Gat
|
|||
assert error.endswith(_REJECTED_AT_LOAD), (label, error)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "target", "cost"),
|
||||
[(_MODEL, _ROUTE, {}), (_ENDPOINT, f"/{_ENDPOINT}/invocations", {"classifier_cost": 0.0})],
|
||||
ids=["ai_decide", "serving_endpoint"],
|
||||
)
|
||||
def test_test_routing_classifies_through_databricks_without_calling_the_tier_it_picked(
|
||||
gateway: Gateway, model: str, target: str, cost: dict[str, JsonValue]
|
||||
def test_test_routing_classifies_through_the_databricks_endpoint_without_calling_the_tier_it_picked(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
prompt: Final = f"design a distributed cache {uuid.uuid4().hex}"
|
||||
with (
|
||||
|
|
@ -234,7 +195,7 @@ def test_test_routing_classifies_through_databricks_without_calling_the_tier_it_
|
|||
):
|
||||
simple_model: Final = scenario.model(model="openai/simple-tier", api_base=simple.url)
|
||||
complex_model: Final = scenario.model(model="openai/complex-tier", api_base=complex_tier.url)
|
||||
config: Final = _router_config(_classifier_config(judge.url, model=model), simple_model, complex_model)
|
||||
config: Final = _router_config(_classifier_config(judge.url), simple_model, complex_model)
|
||||
response: Final = gateway.request(
|
||||
"POST", "/auto_router/test_routing", {"prompt": prompt, "complexity_router_config": config}
|
||||
)
|
||||
|
|
@ -255,27 +216,23 @@ def test_test_routing_classifies_through_databricks_without_calling_the_tier_it_
|
|||
"tier-probability:MEDIUM=0.060000",
|
||||
"tier-probability:COMPLEX=0.910000",
|
||||
],
|
||||
"classifier_model": f"databricks/{model}",
|
||||
**cost,
|
||||
"classifier_model": f"databricks/{_ENDPOINT}",
|
||||
"classifier_cost": 0.0,
|
||||
"classifier_probabilities": {"SIMPLE": 0.03, "MEDIUM": 0.06, "COMPLEX": 0.91},
|
||||
"classifier_confidence": 0.91,
|
||||
"conversation_continuing": False,
|
||||
},
|
||||
}, response.text
|
||||
(call,) = [request for request in judge.drain() if request.method == "POST"]
|
||||
assert call.target == target, call.target
|
||||
assert call.target == f"/{_ENDPOINT}/invocations", call.target
|
||||
assert call.headers.get("authorization") == f"Bearer {_API_KEY}", call.headers
|
||||
assert json.loads(call.body) == {
|
||||
"state": f"\nClassify this message:\n{prompt}",
|
||||
**({} if model == _MODEL else {"model": model}),
|
||||
"questions": {
|
||||
"tier": {"type": "choice", "instructions": DEFAULT_JEV_INSTRUCTIONS, "criteria": _TIER_CRITERIA}
|
||||
},
|
||||
}, call.body
|
||||
body: Final = json.loads(call.body)
|
||||
assert body["model"] == _ENDPOINT, body
|
||||
assert body["state"] == f"\nClassify this message:\n{prompt}", body
|
||||
assert _judge_targets(simple) == () and _judge_targets(complex_tier) == ()
|
||||
|
||||
|
||||
def test_model_new_refuses_a_databricks_classifier_without_a_model_and_stores_nothing(
|
||||
def test_model_new_refuses_a_databricks_classifier_without_an_endpoint_name_and_stores_nothing(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
name: Final = f"router-{uuid.uuid4().hex[:10]}"
|
||||
|
|
@ -302,7 +259,7 @@ def test_model_new_refuses_a_databricks_classifier_without_a_model_and_stores_no
|
|||
assert [entry for entry in map(object_value, listed) if entry["model_name"] == name] == []
|
||||
|
||||
|
||||
def test_model_update_refuses_dropping_the_model_and_keeps_the_stored_classifier_serving(
|
||||
def test_model_update_refuses_dropping_the_endpoint_name_and_keeps_the_stored_classifier_serving(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
with (
|
||||
|
|
@ -325,15 +282,15 @@ def test_model_update_refuses_dropping_the_model_and_keeps_the_stored_classifier
|
|||
assert _MODEL_REQUIRED in string_value(object_value(object_value(response.json())["error"])["message"]), (
|
||||
response.text
|
||||
)
|
||||
assert _stored_classifier(gateway, identity)["model"] == _MODEL
|
||||
assert _stored_classifier(gateway, identity)["model"] == _ENDPOINT
|
||||
chat: Final = gateway.request("POST", "/v1/chat/completions", _chat_body(name))
|
||||
assert chat.status_code == 200, chat.text
|
||||
assert chat.json()["choices"][0]["message"]["content"] == "complex answer", chat.text
|
||||
assert chat.headers["x-litellm-complexity-router-cause"] == "jev_classifier", dict(chat.headers)
|
||||
assert _judge_targets(judge) == (_ROUTE,)
|
||||
assert _judge_targets(judge) == (f"/{_ENDPOINT}/invocations",)
|
||||
|
||||
|
||||
async def test_moving_the_workspace_under_a_burst_moves_the_classifier_and_logs_every_request_once(
|
||||
async def test_renaming_the_endpoint_under_a_burst_moves_the_classifier_and_logs_every_request_once(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
base_url: Final = str(gateway.client.base_url)
|
||||
|
|
@ -348,35 +305,30 @@ async def test_moving_the_workspace_under_a_burst_moves_the_classifier_and_logs_
|
|||
classifier: Final = _classifier_config(judge.url)
|
||||
name, identity = _create_router(scenario, _router_config(classifier, simple_model, complex_model))
|
||||
burst: Final = asyncio.create_task(_burst(base_url, gateway.key, name, 24))
|
||||
moved: Final = _router_config(
|
||||
{**classifier, "api_base": f"{judge.url}{_SECOND_WORKSPACE}"}, simple_model, complex_model
|
||||
)
|
||||
renamed: Final = _router_config({**classifier, "model": _SECOND_ENDPOINT}, simple_model, complex_model)
|
||||
patched: Final = gateway.request(
|
||||
"PATCH",
|
||||
f"/model/{identity}/update",
|
||||
{"litellm_params": {"model": "auto_router/complexity_router", "complexity_router_config": moved}},
|
||||
{"litellm_params": {"model": "auto_router/complexity_router", "complexity_router_config": renamed}},
|
||||
)
|
||||
assert patched.status_code == 200, patched.text
|
||||
during: Final = await burst
|
||||
assert [response.status_code for response in during] == [200] * 24, [response.text for response in during]
|
||||
assert set(_judge_targets(judge)) <= {_ROUTE, f"{_SECOND_WORKSPACE}{_ROUTE}"}
|
||||
assert _stored_classifier(gateway, identity)["api_base"] == f"{judge.url}{_SECOND_WORKSPACE}"
|
||||
|
||||
polled: Final[list[str]] = []
|
||||
assert set(_judge_targets(judge)) <= {f"/{_ENDPOINT}/invocations", f"/{_SECOND_ENDPOINT}/invocations"}
|
||||
assert _stored_classifier(gateway, identity)["model"] == _SECOND_ENDPOINT
|
||||
|
||||
def classifier_target() -> tuple[str, ...]:
|
||||
chat: Final = gateway.request("POST", "/v1/chat/completions", _chat_body(name))
|
||||
assert chat.status_code == 200, chat.text
|
||||
polled.append(string_value(chat.json()["id"]))
|
||||
return _judge_targets(judge)
|
||||
|
||||
eventually(classifier_target, lambda targets: targets == (f"{_SECOND_WORKSPACE}{_ROUTE}",), seconds=30)
|
||||
eventually(classifier_target, lambda targets: targets == (f"/{_SECOND_ENDPOINT}/invocations",), seconds=30)
|
||||
after: Final = await _burst(base_url, gateway.key, name, 6)
|
||||
assert [response.status_code for response in after] == [200] * 6, [response.text for response in after]
|
||||
assert set(_judge_targets(judge)) == {f"{_SECOND_WORKSPACE}{_ROUTE}"}
|
||||
identities: Final = (*(string_value(response.json()["id"]) for response in (*during, *after)), *polled)
|
||||
assert set(_judge_targets(judge)) == {f"/{_SECOND_ENDPOINT}/invocations"}
|
||||
identities: Final = tuple(string_value(response.json()["id"]) for response in (*during, *after))
|
||||
assert len(set(identities)) == len(identities), identities
|
||||
rows: Final = _rows(name, requests=len(identities))
|
||||
request_rows: Final = _request_rows(rows)
|
||||
rows: Final = _rows(name, want=len(identities))
|
||||
request_rows: Final = [row for row in rows if row["origin"] != "autorouter_classifier"]
|
||||
assert sorted(string_value(row["request_id"]) for row in request_rows) == sorted(identities), request_rows
|
||||
assert all(row["status"] == "success" for row in rows), rows
|
||||
|
|
|
|||
|
|
@ -24,6 +24,16 @@ _CONFIGURED_PROBE_REPLY: Final = JsonResponse(
|
|||
"usage": {"input_tokens": 10, "output_tokens": 1},
|
||||
},
|
||||
)
|
||||
_VLLM_PROBE_REPLY: Final = JsonResponse(
|
||||
content_type="application/json",
|
||||
body={
|
||||
"model": "Qwen/Qwen3-0.6B",
|
||||
"answers": {
|
||||
"reachable": {"type": "choice", "choice": "yes", "confidence": 1.0, "probabilities": {"yes": 1.0, "no": 0.0}}
|
||||
},
|
||||
"usage": {"input_tokens": 10, "output_tokens": 1},
|
||||
},
|
||||
)
|
||||
_STRANDS_PROBE_REPLY: Final = JsonResponse(
|
||||
content_type="application/json",
|
||||
body={
|
||||
|
|
@ -149,7 +159,36 @@ def test_evaluation_mode_health_check_of_the_self_hosted_strands_model_resolves_
|
|||
]
|
||||
|
||||
|
||||
_DATABRICKS_ENDPOINT_PROBE_REPLY: Final = JsonResponse(
|
||||
def test_evaluation_mode_health_check_of_hosted_vllm_sends_a_choice_probe(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = register_scenario(f"health-decisions-{uuid.uuid4().hex[:12]}", _VLLM_PROBE_REPLY)
|
||||
scenario.cleanups.callback(delete_scenario, handle)
|
||||
model: Final = scenario.model(
|
||||
model="hosted_vllm/Qwen/Qwen3-0.6B",
|
||||
api_base=handle.api_base(),
|
||||
model_info={"mode": "evaluation"},
|
||||
)
|
||||
report: Final = _health_report(gateway, model)
|
||||
assert (report["healthy_count"], report["unhealthy_count"]) == (1, 0), report
|
||||
assert _probes_sent_to(gateway, handle) == [
|
||||
(
|
||||
f"/{handle.scenario_id}/v1/systemone",
|
||||
{
|
||||
"model": "Qwen/Qwen3-0.6B",
|
||||
"state": os.environ.get("DEFAULT_HEALTH_CHECK_PROMPT", "test from litellm"),
|
||||
"questions": {
|
||||
"reachable": {
|
||||
"type": "choice",
|
||||
"instructions": "Is the service reachable?",
|
||||
"criteria": {"yes": None, "no": None},
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
_DATABRICKS_PROBE_REPLY: Final = JsonResponse(
|
||||
content_type="application/json",
|
||||
body={
|
||||
"model": "databricks-openjev-qwen35-4b",
|
||||
|
|
@ -163,7 +202,7 @@ def test_evaluation_mode_health_check_of_a_databricks_serving_endpoint_resolves_
|
|||
gateway: Gateway,
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = register_scenario(f"health-decisions-{uuid.uuid4().hex[:12]}", _DATABRICKS_ENDPOINT_PROBE_REPLY)
|
||||
handle: Final = register_scenario(f"health-decisions-{uuid.uuid4().hex[:12]}", _DATABRICKS_PROBE_REPLY)
|
||||
scenario.cleanups.callback(delete_scenario, handle)
|
||||
model: Final = scenario.model(
|
||||
model="databricks/databricks-openjev-qwen35-4b",
|
||||
|
|
@ -187,41 +226,3 @@ def test_evaluation_mode_health_check_of_a_databricks_serving_endpoint_resolves_
|
|||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
_DATABRICKS_AI_DECIDE_PROBE_REPLY: Final = JsonResponse(
|
||||
content_type="application/json",
|
||||
body={
|
||||
"response": {"answers": {"reachable": {"type": "noul", "probability": 1.0}}},
|
||||
"metadata": {"version": "1.0"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_evaluation_mode_health_check_of_databricks_ai_decide_resolves_the_mode_from_the_cost_map(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = register_scenario(f"health-decisions-{uuid.uuid4().hex[:12]}", _DATABRICKS_AI_DECIDE_PROBE_REPLY)
|
||||
scenario.cleanups.callback(delete_scenario, handle)
|
||||
model: Final = scenario.model(
|
||||
model="databricks/ai_decide",
|
||||
api_base=handle.api_base(),
|
||||
api_key="synthetic-databricks-key",
|
||||
)
|
||||
listed: Final = gateway.request("GET", "/v2/model/info", params={"model": model})
|
||||
assert listed.status_code == 200, listed.text
|
||||
assert [object_value(object_value(entry)["model_info"])["mode"] for entry in listed.json()["data"]] == [
|
||||
"evaluation"
|
||||
], listed.text
|
||||
report: Final = _health_report(gateway, model)
|
||||
assert (report["healthy_count"], report["unhealthy_count"]) == (1, 0), report
|
||||
assert _probes_sent_to(gateway, handle) == [
|
||||
(
|
||||
f"/{handle.scenario_id}/api/2.0/ai-functions/ai-decide",
|
||||
{
|
||||
"state": os.environ.get("DEFAULT_HEALTH_CHECK_PROMPT", "test from litellm"),
|
||||
"questions": {"reachable": {"type": "noul", "instructions": "Is the service reachable?"}},
|
||||
},
|
||||
)
|
||||
]
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@ from integration._support.upstream import ScenarioHandle, delete_scenario, regis
|
|||
from integration.cost_calculation.cost_tracking_case import JsonResponse
|
||||
from pydantic import JsonValue
|
||||
|
||||
_MODEL: Final = "ai_decide"
|
||||
_ROUTE: Final = "/api/2.0/ai-functions/ai-decide"
|
||||
_ENDPOINT: Final = "databricks-openjev-qwen35-4b"
|
||||
_API_KEY: Final = "synthetic-databricks-key"
|
||||
_ENV_KEY: Final = "synthetic-databricks-env-key"
|
||||
|
|
@ -40,18 +38,9 @@ _ANSWERS: Final[dict[str, JsonValue]] = {
|
|||
}
|
||||
_USAGE: Final[dict[str, JsonValue]] = {"input_tokens": 367, "output_tokens": 3}
|
||||
_ANSWER_BODY: Final[dict[str, JsonValue]] = {"model": _ENDPOINT, "answers": _ANSWERS, "usage": _USAGE}
|
||||
_METADATA: Final[dict[str, JsonValue]] = {"version": "1.0"}
|
||||
_AI_DECIDE_BODY: Final[dict[str, JsonValue]] = {"response": {"answers": _ANSWERS}, "metadata": _METADATA}
|
||||
_SYSTEM_ONE_BODY: Final[dict[str, JsonValue]] = {
|
||||
"model": None,
|
||||
"answers": _ANSWERS,
|
||||
"usage": {"input_tokens": 0, "output_tokens": 0},
|
||||
"metadata": _METADATA,
|
||||
}
|
||||
_MODEL_RULE: Final = (
|
||||
"Databricks decisions model must be 'ai_decide' (the ai_decide AI Function) or a bare serving endpoint name "
|
||||
"(letters, digits, '-', '_' and '.', with no '/', '?', '#', spaces, or a leading '.'), e.g. "
|
||||
"databricks-openjev-qwen35-4b"
|
||||
_BARE_NAME_RULE: Final = (
|
||||
"must be the bare endpoint name (letters, digits, '-', '_' and '.', with no '/', '?', '#', spaces, or a "
|
||||
"leading '.'), e.g. databricks-openjev-qwen35-4b"
|
||||
)
|
||||
_MISSING_KEY: Final = "Missing API key for Decisions provider 'databricks'"
|
||||
_NON_BARE_NAMES: Final[tuple[tuple[str, str], ...]] = (
|
||||
|
|
@ -60,17 +49,10 @@ _NON_BARE_NAMES: Final[tuple[tuple[str, str], ...]] = (
|
|||
("leading-dot", ".hidden"),
|
||||
("question-mark", "a?b"),
|
||||
("hash", "a#b"),
|
||||
("route-path", "serving-endpoints/ai_decide"),
|
||||
)
|
||||
_DATABRICKS_ERRORS: Final[tuple[tuple[str, str, int, dict[str, JsonValue]], ...]] = (
|
||||
(_MODEL, _ROUTE, 400, {"error_code": "BAD_REQUEST", "message": "Invalid input: questions must not be empty"}),
|
||||
(_MODEL, _ROUTE, 403, {"error_code": "PERMISSION_DENIED", "message": "ai_decide is not enabled for this workspace"}),
|
||||
(
|
||||
_ENDPOINT,
|
||||
f"/{_ENDPOINT}/invocations",
|
||||
404,
|
||||
{"error_code": "RESOURCE_DOES_NOT_EXIST", "message": f"Endpoint with name '{_ENDPOINT}' does not exist."},
|
||||
),
|
||||
_DATABRICKS_ERRORS: Final[tuple[tuple[int, dict[str, JsonValue]], ...]] = (
|
||||
(400, {"error_code": "BAD_REQUEST", "message": "Invalid input: questions must not be empty"}),
|
||||
(404, {"error_code": "RESOURCE_DOES_NOT_EXIST", "message": f"Endpoint with name '{_ENDPOINT}' does not exist."}),
|
||||
)
|
||||
_SPEND_QUERY: Final = (
|
||||
'SELECT spend, status, call_type, model_group, custom_llm_provider, api_base FROM "LiteLLM_SpendLogs" '
|
||||
|
|
@ -78,7 +60,6 @@ _SPEND_QUERY: Final = (
|
|||
)
|
||||
_OWNED_PROXY_BUDGET: Final = int(2 * graceful_stop_seconds() + 120)
|
||||
_MEGABYTE_NAME: Final = "a" * 1_000_000 + "/"
|
||||
_TENTH_MEGABYTE_NAME: Final = "a" * 100_000 + "/"
|
||||
|
||||
|
||||
def _number(value: JsonValue) -> float:
|
||||
|
|
@ -111,12 +92,6 @@ def _upstream_calls(gateway: Gateway, handle: ScenarioHandle) -> list[dict[str,
|
|||
return _calls_to(_observed(gateway), handle)
|
||||
|
||||
|
||||
def _timed_decide(gateway: Gateway, model: str) -> tuple[httpx.Response, float]:
|
||||
started: Final = time.perf_counter()
|
||||
response: Final = _decide(gateway, model)
|
||||
return response, time.perf_counter() - started
|
||||
|
||||
|
||||
def _spend_row(call_id: str) -> dict[str, JsonValue]:
|
||||
rows: Final = eventually(lambda: read_rows(_SPEND_QUERY, (call_id,)), lambda found: len(found) == 1, seconds=70)
|
||||
return rows[0]
|
||||
|
|
@ -141,7 +116,7 @@ def _router_config(classifier: Mapping[str, JsonValue], simple: str, complex_: s
|
|||
|
||||
|
||||
def _environment_classifier() -> dict[str, JsonValue]:
|
||||
return {"provider": "databricks", "model": _MODEL, "timeout_ms": 20000, "circuit_breaker_enabled": False}
|
||||
return {"provider": "databricks", "model": _ENDPOINT, "timeout_ms": 20000, "circuit_breaker_enabled": False}
|
||||
|
||||
|
||||
def _router(scenario: Scenario, classifier: Mapping[str, JsonValue]) -> str:
|
||||
|
|
@ -180,8 +155,8 @@ def _assert_classified_by_the_complex_tier(response: httpx.Response, router: str
|
|||
response.headers["x-litellm-model-name"],
|
||||
response.headers["x-litellm-complexity-router-tier"],
|
||||
response.headers["x-litellm-complexity-router-cause"],
|
||||
) == (router, "openai/complex-tier", "COMPLEX", "jev_classifier"), dict(response.headers)
|
||||
assert "x-litellm-classifier-cost" not in response.headers, dict(response.headers)
|
||||
response.headers["x-litellm-classifier-cost"],
|
||||
) == (router, "openai/complex-tier", "COMPLEX", "jev_classifier", "0.0"), dict(response.headers)
|
||||
|
||||
|
||||
def _assert_classifier_call(
|
||||
|
|
@ -190,31 +165,33 @@ def _assert_classifier_call(
|
|||
assert call["path"] == path, call
|
||||
assert call["authorization"] == authorization, call
|
||||
body: Final = object_value(call["body"])
|
||||
assert "model" not in body, body
|
||||
assert body["model"] == _ENDPOINT, body
|
||||
assert prompt_marker in string_value(body["state"]), body
|
||||
assert sorted(object_value(object_value(body["questions"])["tier"])) == ["criteria", "instructions", "type"], body
|
||||
|
||||
|
||||
def _assert_refused_before_any_upstream_call(gateway: Gateway, model: str, message: str) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, _AI_DECIDE_BODY)
|
||||
handle: Final = _register(scenario, _ANSWER_BODY)
|
||||
deployment: Final = scenario.model(model=model, api_base=handle.api_base(), api_key=_API_KEY)
|
||||
response: Final = _decide(gateway, deployment)
|
||||
assert response.status_code == 400, response.text
|
||||
assert message in response.text, response.text
|
||||
assert _upstream_calls(gateway, handle) == []
|
||||
row: Final = _spend_row(response.headers["x-litellm-call-id"])
|
||||
assert (row["status"], row["call_type"], _number(row["spend"])) == ("failure", "adecisions", 0.0), row
|
||||
assert (row["status"], row["call_type"], _number(row["spend"])) == ("failure", "asystemone", 0.0), row
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", [name for _, name in _NON_BARE_NAMES], ids=[label for label, _ in _NON_BARE_NAMES])
|
||||
def test_a_deployment_naming_a_non_bare_endpoint_is_refused_before_any_upstream_call(
|
||||
gateway: Gateway, name: str
|
||||
) -> None:
|
||||
_assert_refused_before_any_upstream_call(gateway, f"databricks/{name}", _MODEL_RULE)
|
||||
_assert_refused_before_any_upstream_call(
|
||||
gateway, f"databricks/{name}", f"Databricks serving endpoint name {name!r} {_BARE_NAME_RULE}"
|
||||
)
|
||||
|
||||
|
||||
def test_a_deployment_naming_no_model_is_refused_before_any_upstream_call(gateway: Gateway) -> None:
|
||||
def test_a_deployment_naming_no_endpoint_is_refused_before_any_upstream_call(gateway: Gateway) -> None:
|
||||
_assert_refused_before_any_upstream_call(gateway, "databricks/", "A model name is required for the Decisions API")
|
||||
|
||||
|
||||
|
|
@ -274,87 +251,11 @@ def test_an_openai_format_request_at_v1_decisions_reaches_the_serving_endpoint_a
|
|||
row["model_group"],
|
||||
row["api_base"],
|
||||
_number(row["spend"]),
|
||||
) == ("success", "adecisions", "databricks", deployment, f"{handle.api_base()}/{_ENDPOINT}/invocations", 0.0), (
|
||||
) == ("success", "asystemone", "databricks", deployment, f"{handle.api_base()}/{_ENDPOINT}/invocations", 0.0), (
|
||||
row
|
||||
)
|
||||
|
||||
|
||||
def test_an_openai_format_request_at_v1_decisions_reaches_the_ai_decide_route_without_a_model_field(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, _AI_DECIDE_BODY)
|
||||
deployment: Final = scenario.model(model=f"databricks/{_MODEL}", api_base=handle.api_base(), api_key=_API_KEY)
|
||||
response: Final = gateway.request(
|
||||
"POST",
|
||||
"/v1/decisions",
|
||||
{
|
||||
"model": deployment,
|
||||
"input": _OPENAI_INPUT,
|
||||
"questions": [_OPENAI_TIER_QUESTION],
|
||||
"safety_identifier": "end-user-1",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == {
|
||||
"model": f"databricks/{_MODEL}",
|
||||
"answers": [
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "tier",
|
||||
"choice": "COMPLEX",
|
||||
"probabilities": [
|
||||
{"value": "SIMPLE", "probability": 0.03},
|
||||
{"value": "MEDIUM", "probability": 0.06},
|
||||
{"value": "COMPLEX", "probability": 0.91},
|
||||
],
|
||||
"confidence": 0.91,
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"input_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 0},
|
||||
"output_tokens": 0,
|
||||
"output_tokens_details": {"reasoning_tokens": 0},
|
||||
"total_tokens": 0,
|
||||
},
|
||||
}, response.text
|
||||
assert response.headers["x-litellm-model-name"] == f"databricks/{_MODEL}", dict(response.headers)
|
||||
(call,) = _upstream_calls(gateway, handle)
|
||||
assert call["path"] == f"/{handle.scenario_id}{_ROUTE}", call
|
||||
assert call["authorization"] == f"Bearer {_API_KEY}", call
|
||||
assert call["body"] == {
|
||||
"state": _OPENAI_INPUT,
|
||||
"questions": {"tier": {"type": "choice", "instructions": "Which tier?", "criteria": _TIER_CRITERIA}},
|
||||
}, call
|
||||
row: Final = _spend_row(response.headers["x-litellm-call-id"])
|
||||
assert (
|
||||
row["status"],
|
||||
row["call_type"],
|
||||
row["custom_llm_provider"],
|
||||
row["model_group"],
|
||||
row["api_base"],
|
||||
_number(row["spend"]),
|
||||
) == ("success", "adecisions", "databricks", deployment, f"{handle.api_base()}{_ROUTE}", 0.0), row
|
||||
|
||||
|
||||
def test_a_noul_probability_reaches_the_caller_as_the_system_one_noul_answer(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(
|
||||
scenario, {"response": {"answers": {"defect": {"type": "noul", "probability": 0.8}}}, "metadata": _METADATA}
|
||||
)
|
||||
deployment: Final = scenario.model(model=f"databricks/{_MODEL}", api_base=handle.api_base(), api_key=_API_KEY)
|
||||
response: Final = gateway.request(
|
||||
"POST",
|
||||
"/v1/systemone",
|
||||
{"model": deployment, "state": _STATE, "questions": {"defect": {"type": "noul", "instructions": "Bug?"}}},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["answers"] == {"defect": {"type": "noul", "noul": 0.8}}, response.text
|
||||
(call,) = _upstream_calls(gateway, handle)
|
||||
assert call["body"] == {"state": _STATE, "questions": {"defect": {"type": "noul", "instructions": "Bug?"}}}
|
||||
|
||||
|
||||
def test_a_wildcard_deployment_sends_each_request_to_the_named_endpoint_and_bills_each_once(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, _ANSWER_BODY)
|
||||
|
|
@ -383,48 +284,17 @@ def test_a_wildcard_deployment_sends_each_request_to_the_named_endpoint_and_bill
|
|||
row["model_group"],
|
||||
row["api_base"],
|
||||
_number(row["spend"]),
|
||||
) == ("success", "adecisions", "databricks", model, f"{handle.api_base()}/{_ENDPOINT}/invocations", 0.0), (
|
||||
) == ("success", "asystemone", "databricks", model, f"{handle.api_base()}/{_ENDPOINT}/invocations", 0.0), (
|
||||
row
|
||||
)
|
||||
|
||||
|
||||
def test_a_wildcard_deployment_sends_each_ai_decide_request_to_the_route_and_logs_each_once(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, _AI_DECIDE_BODY)
|
||||
prefix: Final = _wildcard_deployment(scenario, api_base=handle.api_base(), api_key=_API_KEY)
|
||||
model: Final = f"{prefix}/{_MODEL}"
|
||||
responses: Final = tuple(_decide(gateway, model) for _ in range(2))
|
||||
for response in responses:
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == _SYSTEM_ONE_BODY, response.text
|
||||
assert response.headers["x-litellm-model-group"] == model, dict(response.headers)
|
||||
assert response.headers["x-litellm-model-name"] == f"databricks/{_MODEL}", dict(response.headers)
|
||||
call_ids: Final = tuple(response.headers["x-litellm-call-id"] for response in responses)
|
||||
assert len(set(call_ids)) == 2, call_ids
|
||||
calls: Final = _upstream_calls(gateway, handle)
|
||||
assert len(calls) == 2, calls
|
||||
for call in calls:
|
||||
assert call["path"] == f"/{handle.scenario_id}{_ROUTE}", call
|
||||
assert call["authorization"] == f"Bearer {_API_KEY}", call
|
||||
assert call["body"] == {"state": _STATE, "questions": _QUESTIONS}, call
|
||||
for call_id in call_ids:
|
||||
row: Final = _spend_row(call_id)
|
||||
assert (
|
||||
row["status"],
|
||||
row["call_type"],
|
||||
row["custom_llm_provider"],
|
||||
row["model_group"],
|
||||
row["api_base"],
|
||||
_number(row["spend"]),
|
||||
) == ("success", "adecisions", "databricks", model, f"{handle.api_base()}{_ROUTE}", 0.0), row
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", (5, ["a"]), ids=("int", "list"))
|
||||
def test_a_non_string_model_is_refused_at_the_gateway_without_an_upstream_call(
|
||||
gateway: Gateway, model: JsonValue
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, _AI_DECIDE_BODY)
|
||||
handle: Final = _register(scenario, _ANSWER_BODY)
|
||||
_wildcard_deployment(scenario, api_base=handle.api_base(), api_key=_API_KEY)
|
||||
response: Final = gateway.request(
|
||||
"POST", "/v1/systemone", {"model": model, "state": _STATE, "questions": _QUESTIONS}
|
||||
|
|
@ -452,30 +322,28 @@ def test_a_five_kilobyte_endpoint_name_reaches_the_upstream_and_its_not_found_an
|
|||
row: Final = _spend_row(response.headers["x-litellm-call-id"])
|
||||
assert (row["status"], row["call_type"], row["model_group"], _number(row["spend"])) == (
|
||||
"failure",
|
||||
"adecisions",
|
||||
"asystemone",
|
||||
f"{prefix}/{name}",
|
||||
0.0,
|
||||
), row
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name,path,status,body", _DATABRICKS_ERRORS, ids=("ai_decide-400", "ai_decide-403", "serving_endpoint-404")
|
||||
)
|
||||
@pytest.mark.parametrize("status,body", _DATABRICKS_ERRORS, ids=("400", "404"))
|
||||
def test_a_databricks_error_keeps_its_status_and_message_and_bills_nothing(
|
||||
gateway: Gateway, name: str, path: str, status: int, body: dict[str, JsonValue]
|
||||
gateway: Gateway, status: int, body: dict[str, JsonValue]
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, body, status=status)
|
||||
model: Final = scenario.model(model=f"databricks/{name}", api_base=handle.api_base(), api_key=_API_KEY)
|
||||
model: Final = scenario.model(model=f"databricks/{_ENDPOINT}", api_base=handle.api_base(), api_key=_API_KEY)
|
||||
response: Final = _decide(gateway, model)
|
||||
assert response.status_code == status, response.text
|
||||
assert string_value(body["message"]) in response.text, response.text
|
||||
(call,) = _upstream_calls(gateway, handle)
|
||||
assert call["path"] == f"/{handle.scenario_id}{path}", call
|
||||
assert call["path"] == f"/{handle.scenario_id}/{_ENDPOINT}/invocations", call
|
||||
row: Final = _spend_row(response.headers["x-litellm-call-id"])
|
||||
assert (row["status"], row["call_type"], row["model_group"], _number(row["spend"])) == (
|
||||
"failure",
|
||||
"adecisions",
|
||||
"asystemone",
|
||||
model,
|
||||
0.0,
|
||||
), row
|
||||
|
|
@ -485,8 +353,8 @@ def test_a_deployment_without_a_key_and_no_environment_key_is_refused_before_any
|
|||
gateway: Gateway,
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, _AI_DECIDE_BODY)
|
||||
model: Final = scenario.model(model=f"databricks/{_MODEL}", api_base=handle.api_base(), api_key=None)
|
||||
handle: Final = _register(scenario, _ANSWER_BODY)
|
||||
model: Final = scenario.model(model=f"databricks/{_ENDPOINT}", api_base=handle.api_base(), api_key=None)
|
||||
response: Final = _decide(gateway, model)
|
||||
assert response.status_code == 401, response.text
|
||||
assert _MISSING_KEY in response.text, response.text
|
||||
|
|
@ -498,9 +366,9 @@ def test_the_environment_key_and_base_serve_a_deployment_a_classifier_and_a_team
|
|||
gateway: Gateway, tmp_path: Path
|
||||
) -> None:
|
||||
with gateway.scenario() as rig_scenario:
|
||||
handle: Final = _register(rig_scenario, _AI_DECIDE_BODY)
|
||||
environment_base: Final = f"{handle.api_base()}/environment-workspace/serving-endpoints"
|
||||
environment_path: Final = f"/{handle.scenario_id}/environment-workspace{_ROUTE}"
|
||||
handle: Final = _register(rig_scenario, _ANSWER_BODY)
|
||||
environment_base: Final = f"{handle.api_base()}/serving-endpoints"
|
||||
environment_path: Final = f"/{handle.scenario_id}/serving-endpoints/{_ENDPOINT}/invocations"
|
||||
environment: Final = {
|
||||
"DATABRICKS_API_KEY": _ENV_KEY,
|
||||
"DATABRICKS_TOKEN": _ENV_TOKEN,
|
||||
|
|
@ -508,14 +376,14 @@ def test_the_environment_key_and_base_serve_a_deployment_a_classifier_and_a_team
|
|||
}
|
||||
with owned_proxy_process(gateway, tmp_path, environment) as owned, owned.gateway.scenario() as scenario:
|
||||
candidate: Final = owned.gateway
|
||||
with_base: Final = scenario.model(model=f"databricks/{_MODEL}", api_base=handle.api_base(), api_key=None)
|
||||
bare: Final = scenario.model(model=f"databricks/{_MODEL}", api_base=None, api_key=None)
|
||||
with_base: Final = scenario.model(model=f"databricks/{_ENDPOINT}", api_base=handle.api_base(), api_key=None)
|
||||
bare: Final = scenario.model(model=f"databricks/{_ENDPOINT}", api_base=None, api_key=None)
|
||||
decided: Final = tuple(_decide(candidate, model) for model in (with_base, bare))
|
||||
for response in decided:
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == _SYSTEM_ONE_BODY, response.text
|
||||
assert response.json() == _ANSWER_BODY, response.text
|
||||
assert [(call["path"], call["authorization"]) for call in _upstream_calls(candidate, handle)] == [
|
||||
(f"/{handle.scenario_id}{_ROUTE}", f"Bearer {_ENV_KEY}"),
|
||||
(f"/{handle.scenario_id}/{_ENDPOINT}/invocations", f"Bearer {_ENV_KEY}"),
|
||||
(environment_path, f"Bearer {_ENV_KEY}"),
|
||||
]
|
||||
for response, model in zip(decided, (with_base, bare)):
|
||||
|
|
@ -523,7 +391,7 @@ def test_the_environment_key_and_base_serve_a_deployment_a_classifier_and_a_team
|
|||
assert (row["status"], row["model_group"], _number(row["spend"])) == ("success", model, 0.0), row
|
||||
assert (
|
||||
_spend_row(decided[1].headers["x-litellm-call-id"])["api_base"]
|
||||
== f"{handle.api_base()}/environment-workspace{_ROUTE}"
|
||||
== f"{environment_base}/{_ENDPOINT}/invocations"
|
||||
)
|
||||
|
||||
router: Final = _router(scenario, _environment_classifier())
|
||||
|
|
@ -567,7 +435,7 @@ def test_the_environment_key_and_base_serve_a_deployment_a_classifier_and_a_team
|
|||
"opensource_classifier_config"
|
||||
]
|
||||
)
|
||||
assert (stored_classifier["provider"], stored_classifier["model"]) == ("databricks", _MODEL), entry
|
||||
assert (stored_classifier["provider"], stored_classifier["model"]) == ("databricks", _ENDPOINT), entry
|
||||
assert "api_key" not in stored_classifier and "api_base" not in stored_classifier, entry
|
||||
member_chat: Final = _chat(candidate, team_router, key=member_key)
|
||||
_assert_classified_by_the_complex_tier(member_chat, team_router)
|
||||
|
|
@ -585,21 +453,21 @@ def test_the_environment_token_serves_a_deployment_and_a_classifier_when_no_envi
|
|||
gateway: Gateway, tmp_path: Path
|
||||
) -> None:
|
||||
with gateway.scenario() as rig_scenario:
|
||||
handle: Final = _register(rig_scenario, _AI_DECIDE_BODY)
|
||||
environment_path: Final = f"/{handle.scenario_id}/environment-workspace{_ROUTE}"
|
||||
handle: Final = _register(rig_scenario, _ANSWER_BODY)
|
||||
environment_path: Final = f"/{handle.scenario_id}/serving-endpoints/{_ENDPOINT}/invocations"
|
||||
environment: Final = {
|
||||
"DATABRICKS_TOKEN": _ENV_TOKEN,
|
||||
"DATABRICKS_API_BASE": f"{handle.api_base()}/environment-workspace/serving-endpoints",
|
||||
"DATABRICKS_API_BASE": f"{handle.api_base()}/serving-endpoints",
|
||||
}
|
||||
with (
|
||||
owned_proxy_process(gateway, tmp_path, environment, remove_environment=("DATABRICKS_API_KEY",)) as owned,
|
||||
owned.gateway.scenario() as scenario,
|
||||
):
|
||||
candidate: Final = owned.gateway
|
||||
bare: Final = scenario.model(model=f"databricks/{_MODEL}", api_base=None, api_key=None)
|
||||
bare: Final = scenario.model(model=f"databricks/{_ENDPOINT}", api_base=None, api_key=None)
|
||||
decided: Final = _decide(candidate, bare)
|
||||
assert decided.status_code == 200, decided.text
|
||||
assert decided.json() == _SYSTEM_ONE_BODY, decided.text
|
||||
assert decided.json() == _ANSWER_BODY, decided.text
|
||||
(decision_call,) = _upstream_calls(candidate, handle)
|
||||
assert (decision_call["path"], decision_call["authorization"]) == (environment_path, f"Bearer {_ENV_TOKEN}")
|
||||
assert _spend_row(decided.headers["x-litellm-call-id"])["status"] == "success"
|
||||
|
|
@ -615,16 +483,15 @@ def test_the_environment_token_serves_a_deployment_and_a_classifier_when_no_envi
|
|||
)
|
||||
|
||||
|
||||
def test_a_megabyte_model_name_is_refused_in_linear_time_while_liveliness_stays_up(gateway: Gateway) -> None:
|
||||
def test_a_megabyte_endpoint_name_is_refused_in_linear_time_while_liveliness_stays_fast(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, _AI_DECIDE_BODY)
|
||||
handle: Final = _register(scenario, _ANSWER_BODY)
|
||||
prefix: Final = _wildcard_deployment(scenario, api_base=handle.api_base(), api_key=_API_KEY)
|
||||
model: Final = f"{prefix}/{_MEGABYTE_NAME}"
|
||||
anonymous: Final = gateway.client.post(
|
||||
"/v1/systemone", json={"model": model, "state": _STATE, "questions": _QUESTIONS}
|
||||
)
|
||||
assert anonymous.status_code == 401, anonymous.text[:300]
|
||||
tenth_response, tenth_elapsed = _timed_decide(gateway, f"{prefix}/{_TENTH_MEGABYTE_NAME}")
|
||||
liveliness: Final[list[tuple[int, float]]] = []
|
||||
stop: Final = threading.Event()
|
||||
|
||||
|
|
@ -636,15 +503,16 @@ def test_a_megabyte_model_name_is_refused_in_linear_time_while_liveliness_stays_
|
|||
|
||||
poller: Final = threading.Thread(target=poll)
|
||||
poller.start()
|
||||
response, elapsed = _timed_decide(gateway, model)
|
||||
started: Final = time.perf_counter()
|
||||
response: Final = _decide(gateway, model)
|
||||
elapsed: Final = time.perf_counter() - started
|
||||
stop.set()
|
||||
poller.join()
|
||||
assert tenth_response.status_code == 400, tenth_response.text[:300]
|
||||
assert elapsed < 25 * tenth_elapsed, (elapsed, tenth_elapsed)
|
||||
assert elapsed < 3, elapsed
|
||||
assert liveliness and all(status == 200 for status, _ in liveliness), liveliness
|
||||
assert max(seconds for _, seconds in liveliness) < max(1.0, elapsed / 2), (elapsed, liveliness)
|
||||
assert max(seconds for _, seconds in liveliness) < 1, liveliness
|
||||
assert response.status_code == 400, response.text[:300]
|
||||
assert _MODEL_RULE in response.text, response.text[:400]
|
||||
assert _BARE_NAME_RULE in response.text, response.text[-400:]
|
||||
assert _upstream_calls(gateway, handle) == []
|
||||
row: Final = _spend_row(response.headers["x-litellm-call-id"])
|
||||
assert (row["status"], row["call_type"], _number(row["spend"])) == ("failure", "adecisions", 0.0), row
|
||||
assert (row["status"], row["call_type"], _number(row["spend"])) == ("failure", "asystemone", 0.0), row
|
||||
|
|
|
|||
593
tests/integration/providers/test_decisions_openai_format_wire.py
Normal file
593
tests/integration/providers/test_decisions_openai_format_wire.py
Normal file
|
|
@ -0,0 +1,593 @@
|
|||
import math
|
||||
import uuid
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import yaml
|
||||
from integration._support.client import Gateway, Scenario, eventually, object_value, string_value
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.process import owned_proxy_process
|
||||
from integration._support.upstream import ScenarioHandle, delete_scenario, register_scenario
|
||||
from integration.cost_calculation.cost_tracking_case import JsonResponse
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
import litellm
|
||||
|
||||
_API_KEY: Final = "synthetic-decisions-key"
|
||||
_SAFETY_IDENTIFIER: Final = "end-user-7"
|
||||
_DROPPING_DEPLOYMENT: Final = "decisions-under-litellm-settings-drop-params"
|
||||
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
||||
_INPUT: Final = "Ticket (billing): The export job hangs at 99%"
|
||||
_FOLLOW_UP: Final = "Customer: still stuck after retrying"
|
||||
_QUESTIONS: Final[list[JsonValue]] = [
|
||||
{"type": "predicate", "name": "defect", "instructions": "Is this a defect?"},
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "severity",
|
||||
"instructions": "How severe is it?",
|
||||
"choices": [{"value": "low", "description": "cosmetic"}, {"value": "high", "description": "blocks users"}],
|
||||
},
|
||||
{
|
||||
"type": "score",
|
||||
"name": "confidence",
|
||||
"instructions": "How sure are you?",
|
||||
"levels": [{"label": "unsure"}, {"label": "sure"}],
|
||||
},
|
||||
]
|
||||
_SYSTEM_ONE_QUESTIONS: Final[dict[str, JsonValue]] = {
|
||||
"defect": {"type": "noul", "instructions": "Is this a defect?"},
|
||||
"severity": {
|
||||
"type": "choice",
|
||||
"instructions": "How severe is it?",
|
||||
"criteria": {"low": "cosmetic", "high": "blocks users"},
|
||||
},
|
||||
"confidence": {"type": "score", "instructions": "How sure are you?", "criteria": ["unsure", "sure"]},
|
||||
}
|
||||
_SYSTEM_ONE_ANSWERS: Final[dict[str, JsonValue]] = {
|
||||
"defect": {"type": "noul", "noul": 0.93},
|
||||
"severity": {"type": "choice", "choice": "high", "confidence": 0.8, "probabilities": {"low": 0.2, "high": 0.8}},
|
||||
"confidence": {
|
||||
"type": "score",
|
||||
"score": 1.0,
|
||||
"confidence": 0.7,
|
||||
"legend": {"0": "unsure", "1": "sure"},
|
||||
"probabilities": {"0": 0.3, "1": 0.7},
|
||||
},
|
||||
}
|
||||
_ANSWERS: Final[list[JsonValue]] = [
|
||||
{"type": "predicate", "name": "defect", "probability": 0.93},
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "severity",
|
||||
"choice": "high",
|
||||
"probabilities": [{"value": "low", "probability": 0.2}, {"value": "high", "probability": 0.8}],
|
||||
"confidence": 0.8,
|
||||
},
|
||||
{
|
||||
"type": "score",
|
||||
"name": "confidence",
|
||||
"score": 1.0,
|
||||
"probabilities": [
|
||||
{"value": 0, "label": "unsure", "probability": 0.3},
|
||||
{"value": 1, "label": "sure", "probability": 0.7},
|
||||
],
|
||||
"confidence": 0.7,
|
||||
},
|
||||
]
|
||||
_INPUT_TOKENS: Final = 367
|
||||
_OUTPUT_TOKENS: Final = 3
|
||||
_SYSTEM_ONE_USAGE: Final[dict[str, JsonValue]] = {"input_tokens": _INPUT_TOKENS, "output_tokens": _OUTPUT_TOKENS}
|
||||
_USAGE: Final[dict[str, JsonValue]] = {
|
||||
"input_tokens": _INPUT_TOKENS,
|
||||
"input_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 0},
|
||||
"output_tokens": _OUTPUT_TOKENS,
|
||||
"output_tokens_details": {"reasoning_tokens": 0},
|
||||
"total_tokens": _INPUT_TOKENS + _OUTPUT_TOKENS,
|
||||
}
|
||||
_SDK_QUESTIONS: Final = TypeAdapter(list[dict[str, object]]).validate_python(_QUESTIONS)
|
||||
_SPEND_QUERY: Final = (
|
||||
"SELECT spend, status, call_type, model_group, custom_llm_provider, api_base, prompt_tokens, completion_tokens, "
|
||||
'request_tags FROM "LiteLLM_SpendLogs" WHERE request_id = %s'
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Provider:
|
||||
name: str
|
||||
model: str
|
||||
path: str
|
||||
body_model: str
|
||||
api_key: str | None
|
||||
wraps_result: bool
|
||||
cost_map_key: str | None
|
||||
speaks_openai: bool = False
|
||||
provider_reported_cost: float | None = None
|
||||
|
||||
def upstream_body(self) -> dict[str, JsonValue]:
|
||||
if self.speaks_openai:
|
||||
return {"model": self.body_model, "input": _INPUT, "questions": _QUESTIONS}
|
||||
return {"model": self.body_model, "state": _INPUT, "questions": _SYSTEM_ONE_QUESTIONS}
|
||||
|
||||
def _with_reported_cost(self, usage: dict[str, JsonValue]) -> dict[str, JsonValue]:
|
||||
if self.provider_reported_cost is None:
|
||||
return usage
|
||||
return {**usage, "cost": self.provider_reported_cost}
|
||||
|
||||
def upstream_reply(self) -> dict[str, JsonValue]:
|
||||
if self.speaks_openai:
|
||||
return {"model": self.body_model, "answers": _ANSWERS, "usage": self._with_reported_cost(_USAGE)}
|
||||
answer: Final[dict[str, JsonValue]] = {
|
||||
"model": self.body_model,
|
||||
"answers": _SYSTEM_ONE_ANSWERS,
|
||||
"usage": self._with_reported_cost(_SYSTEM_ONE_USAGE),
|
||||
}
|
||||
return {"result": answer, "success": True} if self.wraps_result else answer
|
||||
|
||||
def litellm_response(self) -> dict[str, JsonValue]:
|
||||
return {"model": self.body_model, "answers": _ANSWERS, "usage": _USAGE}
|
||||
|
||||
|
||||
_PROVIDERS: Final = (
|
||||
_Provider(
|
||||
"perplexity",
|
||||
"perplexity/pplx-decider-v1-27b",
|
||||
"/v1/decisions",
|
||||
"pplx-decider-v1-27b",
|
||||
_API_KEY,
|
||||
False,
|
||||
"perplexity/pplx-decider-v1-27b",
|
||||
),
|
||||
_Provider("typesafe", "typesafe/jev-1.13.0", "/v1/systemone", "jev-1.13.0", _API_KEY, False, "typesafe/jev-1.13.0"),
|
||||
_Provider(
|
||||
"openrouter",
|
||||
"openrouter/typesafe/jev-1.13",
|
||||
"/alpha/decisions",
|
||||
"typesafe/jev-1.13",
|
||||
_API_KEY,
|
||||
False,
|
||||
None,
|
||||
provider_reported_cost=1.5834e-5,
|
||||
),
|
||||
_Provider(
|
||||
"strands_decider", "strands_decider/systemone-decider", "/v1/systemone", "systemone-decider", None, False, None
|
||||
),
|
||||
_Provider(
|
||||
"cloudflare",
|
||||
"cloudflare/clef",
|
||||
"/ai/run/@cf/cloudflare/clef",
|
||||
"clef",
|
||||
_API_KEY,
|
||||
True,
|
||||
"cloudflare/@cf/cloudflare/clef",
|
||||
),
|
||||
_Provider("hosted_vllm", "hosted_vllm/Qwen/Qwen3-0.6B", "/v1/systemone", "Qwen/Qwen3-0.6B", None, False, None),
|
||||
_Provider(
|
||||
"databricks",
|
||||
"databricks/databricks-openjev-qwen35-4b",
|
||||
"/databricks-openjev-qwen35-4b/invocations",
|
||||
"databricks-openjev-qwen35-4b",
|
||||
_API_KEY,
|
||||
False,
|
||||
None,
|
||||
),
|
||||
_Provider(
|
||||
"azure_ai", "azure_ai/decision-1", "/providers/microsoft/v1/systemone", "decision-1", _API_KEY, False, None
|
||||
),
|
||||
_Provider("openai", "openai/gpt-6-luna", "/v1/decisions", "gpt-6-luna", _API_KEY, False, "gpt-6-luna", True),
|
||||
)
|
||||
_SYSTEM_ONE_PROVIDERS: Final = tuple(provider for provider in _PROVIDERS if not provider.speaks_openai)
|
||||
_PERPLEXITY: Final = _PROVIDERS[0]
|
||||
_TYPESAFE: Final = _PROVIDERS[1]
|
||||
_OPENAI: Final = next(provider for provider in _PROVIDERS if provider.speaks_openai)
|
||||
_PREDICATE: Final[dict[str, JsonValue]] = {"type": "predicate", "name": "q", "instructions": "Is it?"}
|
||||
_INVALID_BODIES: Final[tuple[tuple[str, dict[str, JsonValue]], ...]] = (
|
||||
("missing questions", {"input": _INPUT}),
|
||||
("missing input", {"questions": _QUESTIONS}),
|
||||
("numeric input", {"input": 5, "questions": _QUESTIONS}),
|
||||
("assistant message input", {"input": [{"role": "assistant", "content": "hi"}], "questions": _QUESTIONS}),
|
||||
("empty questions", {"input": _INPUT, "questions": []}),
|
||||
("questions as a map", {"input": _INPUT, "questions": {"q": _PREDICATE}}),
|
||||
("predicate without instructions", {"input": _INPUT, "questions": [{"type": "predicate", "name": "q"}]}),
|
||||
(
|
||||
"choice with one choice",
|
||||
{"input": _INPUT, "questions": [{**_PREDICATE, "type": "choice", "choices": [{"value": "only"}]}]},
|
||||
),
|
||||
(
|
||||
"score with one level",
|
||||
{"input": _INPUT, "questions": [{**_PREDICATE, "type": "score", "levels": [{"label": "only"}]}]},
|
||||
),
|
||||
("unknown question type", {"input": _INPUT, "questions": [{**_PREDICATE, "type": "ranking"}]}),
|
||||
("numeric safety_identifier", {"input": _INPUT, "questions": _QUESTIONS, "safety_identifier": 7}),
|
||||
("list safety_identifier", {"input": _INPUT, "questions": _QUESTIONS, "safety_identifier": [_SAFETY_IDENTIFIER]}),
|
||||
)
|
||||
_IMAGE_INPUT: Final[list[JsonValue]] = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": _INPUT},
|
||||
{"type": "input_image", "image_url": "data:image/png;base64,iVBORw0KGgo=", "detail": "auto"},
|
||||
],
|
||||
}
|
||||
]
|
||||
_OPENAI_IMAGE_MESSAGES: Final[list[JsonValue]] = [
|
||||
{
|
||||
"role": "user",
|
||||
"type": "message",
|
||||
"content": [
|
||||
{"type": "input_text", "text": _INPUT},
|
||||
{"type": "input_image", "image_url": "data:image/png;base64,iVBORw0KGgo=", "detail": "auto"},
|
||||
],
|
||||
}
|
||||
]
|
||||
_MESSAGE_LIST_INPUT: Final[list[JsonValue]] = [
|
||||
{"role": "user", "content": _INPUT},
|
||||
{"role": "user", "content": [{"type": "input_text", "text": _FOLLOW_UP}]},
|
||||
]
|
||||
|
||||
|
||||
def _response_cost(response: httpx.Response) -> float:
|
||||
return float(response.headers["x-litellm-response-cost"]) if "x-litellm-response-cost" in response.headers else 0.0
|
||||
|
||||
|
||||
def _provider_id(provider: _Provider) -> str:
|
||||
return provider.name
|
||||
|
||||
|
||||
def _number(value: JsonValue) -> float:
|
||||
assert isinstance(value, (int, float)) and not isinstance(value, bool), value
|
||||
return float(value)
|
||||
|
||||
|
||||
def _expected_spend(provider: _Provider) -> float:
|
||||
if provider.provider_reported_cost is not None:
|
||||
return provider.provider_reported_cost
|
||||
if provider.cost_map_key is None:
|
||||
return 0.0
|
||||
cost_map: Final = _JSON_OBJECT.validate_json(Path("model_prices_and_context_window.json").read_bytes())
|
||||
prices: Final = object_value(cost_map[provider.cost_map_key])
|
||||
return _INPUT_TOKENS * _number(prices["input_cost_per_token"]) + _OUTPUT_TOKENS * _number(
|
||||
prices["output_cost_per_token"]
|
||||
)
|
||||
|
||||
|
||||
def _register(scenario: Scenario, body: dict[str, JsonValue], *, status: int = 200) -> ScenarioHandle:
|
||||
handle: Final = register_scenario(
|
||||
f"decisions-{uuid.uuid4().hex[:12]}", JsonResponse(content_type="application/json", body=body, status=status)
|
||||
)
|
||||
scenario.cleanups.callback(delete_scenario, handle)
|
||||
return handle
|
||||
|
||||
|
||||
def _deployment(scenario: Scenario, handle: ScenarioHandle, provider: _Provider, *, drop_params: bool = False) -> str:
|
||||
if drop_params:
|
||||
return scenario.model(
|
||||
model=provider.model, api_base=handle.api_base(), api_key=provider.api_key, drop_params=True
|
||||
)
|
||||
return scenario.model(model=provider.model, api_base=handle.api_base(), api_key=provider.api_key)
|
||||
|
||||
|
||||
def _decide(gateway: Gateway, model: str, *, route: str = "/v1/decisions", **extra: JsonValue) -> httpx.Response:
|
||||
return gateway.request("POST", route, {"model": model, "input": _INPUT, "questions": _QUESTIONS, **extra})
|
||||
|
||||
|
||||
def _decide_in_system_one_format(gateway: Gateway, model: str, **extra: JsonValue) -> httpx.Response:
|
||||
return gateway.request(
|
||||
"POST", "/v1/systemone", {"model": model, "state": _INPUT, "questions": _SYSTEM_ONE_QUESTIONS, **extra}
|
||||
)
|
||||
|
||||
|
||||
def _observed_requests(gateway: Gateway) -> tuple[dict[str, JsonValue], ...]:
|
||||
with httpx.Client(base_url=gateway.upstream_url, timeout=5, trust_env=False) as upstream:
|
||||
observations: Final = _JSON_OBJECT.validate_json(upstream.get("/__observations").content)["requests"]
|
||||
assert isinstance(observations, list), observations
|
||||
return tuple(map(object_value, observations))
|
||||
|
||||
|
||||
def _calls_to(requests: Sequence[dict[str, JsonValue]], handle: ScenarioHandle) -> list[dict[str, JsonValue]]:
|
||||
return [request for request in requests if string_value(request["path"]).startswith(f"/{handle.scenario_id}/")]
|
||||
|
||||
|
||||
def _upstream_calls(gateway: Gateway, handle: ScenarioHandle) -> list[dict[str, JsonValue]]:
|
||||
return _calls_to(_observed_requests(gateway), handle)
|
||||
|
||||
|
||||
def _spend_row(call_id: str) -> dict[str, JsonValue]:
|
||||
rows: Final = eventually(lambda: read_rows(_SPEND_QUERY, (call_id,)), lambda found: len(found) == 1, seconds=70)
|
||||
return rows[0]
|
||||
|
||||
|
||||
def _assert_refused_as_invalid(gateway: Gateway, model: str, label: str, body: dict[str, JsonValue]) -> None:
|
||||
response: Final = gateway.request("POST", "/v1/decisions", {"model": model, **body})
|
||||
assert response.status_code == 400, (label, response.text)
|
||||
assert "Invalid Decisions request" in response.text, (label, response.text)
|
||||
|
||||
|
||||
def _assert_refused_for_safety_identifier(response: httpx.Response) -> None:
|
||||
assert response.status_code == 400, response.text
|
||||
assert "safety_identifier" in response.text and "drop_params" in response.text, response.text
|
||||
|
||||
|
||||
def _drop_params_config(directory: Path, api_base: str) -> Path:
|
||||
base: Final = _JSON_OBJECT.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()))
|
||||
config: Final = {
|
||||
**base,
|
||||
"litellm_settings": {**object_value(base["litellm_settings"]), "drop_params": True},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": _DROPPING_DEPLOYMENT,
|
||||
"litellm_params": {"model": _PERPLEXITY.model, "api_base": api_base, "api_key": _API_KEY},
|
||||
}
|
||||
],
|
||||
}
|
||||
path: Final = directory / "decisions-drop-params.yaml"
|
||||
path.write_text(yaml.safe_dump(config))
|
||||
return path
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider", _PROVIDERS, ids=_provider_id)
|
||||
def test_each_provider_gets_its_own_path_key_and_body_and_is_billed(gateway: Gateway, provider: _Provider) -> None:
|
||||
expected_spend: Final = _expected_spend(provider)
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, provider.upstream_reply())
|
||||
model: Final = _deployment(scenario, handle, provider)
|
||||
response: Final = _decide(gateway, model)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == provider.litellm_response()
|
||||
assert response.headers["x-litellm-model-group"] == model
|
||||
assert math.isclose(_response_cost(response), expected_spend, rel_tol=1e-9)
|
||||
(call,) = _upstream_calls(gateway, handle)
|
||||
assert call["path"] == f"/{handle.scenario_id}{provider.path}"
|
||||
assert call["authorization"] == (f"Bearer {provider.api_key}" if provider.api_key else "")
|
||||
assert call["body"] == provider.upstream_body()
|
||||
row: Final = _spend_row(response.headers["x-litellm-call-id"])
|
||||
assert (
|
||||
row["status"],
|
||||
row["call_type"],
|
||||
row["custom_llm_provider"],
|
||||
row["model_group"],
|
||||
row["api_base"],
|
||||
row["prompt_tokens"],
|
||||
row["completion_tokens"],
|
||||
) == (
|
||||
"success",
|
||||
"adecisions",
|
||||
provider.name,
|
||||
model,
|
||||
f"{handle.api_base()}{provider.path}",
|
||||
_INPUT_TOKENS,
|
||||
_OUTPUT_TOKENS,
|
||||
)
|
||||
assert math.isclose(_number(row["spend"]), expected_spend, rel_tol=1e-9), row
|
||||
|
||||
|
||||
def test_the_unversioned_alias_serves_the_same_request(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, _PERPLEXITY.upstream_reply())
|
||||
model: Final = _deployment(scenario, handle, _PERPLEXITY)
|
||||
response: Final = _decide(gateway, model, route="/decisions")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == _PERPLEXITY.litellm_response()
|
||||
(call,) = _upstream_calls(gateway, handle)
|
||||
assert call["body"] == _PERPLEXITY.upstream_body()
|
||||
|
||||
|
||||
def test_repeated_identical_requests_each_reach_the_upstream_and_are_each_billed(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, _PERPLEXITY.upstream_reply())
|
||||
model: Final = _deployment(scenario, handle, _PERPLEXITY)
|
||||
responses: Final = tuple(_decide(gateway, model) for _ in range(2))
|
||||
assert [response.status_code for response in responses] == [200, 200], [r.text for r in responses]
|
||||
call_ids: Final = tuple(response.headers["x-litellm-call-id"] for response in responses)
|
||||
assert len(set(call_ids)) == 2, call_ids
|
||||
assert len(_upstream_calls(gateway, handle)) == 2
|
||||
for call_id in call_ids:
|
||||
assert _spend_row(call_id)["status"] == "success"
|
||||
|
||||
|
||||
async def test_sdk_sync_and_async_clients_send_the_same_request(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, _TYPESAFE.upstream_reply())
|
||||
synchronous: Final = litellm.decisions(
|
||||
model=_TYPESAFE.model, input=_INPUT, questions=_SDK_QUESTIONS, api_base=handle.api_base(), api_key=_API_KEY
|
||||
)
|
||||
asynchronous: Final = await litellm.adecisions(
|
||||
model=_TYPESAFE.model, input=_INPUT, questions=_SDK_QUESTIONS, api_base=handle.api_base(), api_key=_API_KEY
|
||||
)
|
||||
for response in (synchronous, asynchronous):
|
||||
assert response.model_dump(mode="json") == _TYPESAFE.litellm_response()
|
||||
calls: Final = _upstream_calls(gateway, handle)
|
||||
assert len(calls) == 2, calls
|
||||
for call in calls:
|
||||
assert call["path"] == f"/{handle.scenario_id}{_TYPESAFE.path}"
|
||||
assert call["authorization"] == f"Bearer {_API_KEY}"
|
||||
assert call["body"] == _TYPESAFE.upstream_body()
|
||||
|
||||
|
||||
def test_gateway_only_fields_stay_at_the_gateway_and_tags_reach_the_spend_log(gateway: Gateway) -> None:
|
||||
tag: Final = f"decisions-openai-format-{uuid.uuid4().hex[:8]}"
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, _PERPLEXITY.upstream_reply())
|
||||
model: Final = _deployment(scenario, handle, _PERPLEXITY)
|
||||
response: Final = _decide(
|
||||
gateway, model, user="auditor", num_retries=0, temperature=0.2, metadata={"tags": [tag]}
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
(call,) = _upstream_calls(gateway, handle)
|
||||
assert call["body"] == _PERPLEXITY.upstream_body()
|
||||
row: Final = _spend_row(response.headers["x-litellm-call-id"])
|
||||
tags: Final = row["request_tags"]
|
||||
assert isinstance(tags, list) and tag in tags, row
|
||||
|
||||
|
||||
def test_invalid_bodies_are_refused_at_the_gateway_without_an_upstream_call(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, _PERPLEXITY.upstream_reply())
|
||||
model: Final = _deployment(scenario, handle, _PERPLEXITY)
|
||||
for label, body in _INVALID_BODIES:
|
||||
_assert_refused_as_invalid(gateway, model, label, body)
|
||||
assert _upstream_calls(gateway, handle) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider", _SYSTEM_ONE_PROVIDERS, ids=_provider_id)
|
||||
def test_system_one_providers_refuse_images_at_the_gateway_without_an_upstream_call(
|
||||
gateway: Gateway, provider: _Provider
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, provider.upstream_reply())
|
||||
model: Final = _deployment(scenario, handle, provider)
|
||||
response: Final = _decide(gateway, model, input=_IMAGE_INPUT)
|
||||
assert response.status_code == 400, response.text
|
||||
assert "input_image" in response.text
|
||||
assert _upstream_calls(gateway, handle) == []
|
||||
|
||||
|
||||
def test_openai_forwards_image_input_in_its_own_message_shape(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, _OPENAI.upstream_reply())
|
||||
model: Final = _deployment(scenario, handle, _OPENAI)
|
||||
response: Final = _decide(gateway, model, input=_IMAGE_INPUT)
|
||||
assert response.status_code == 200, response.text
|
||||
(call,) = _upstream_calls(gateway, handle)
|
||||
assert call["body"] == {"model": _OPENAI.body_model, "input": _OPENAI_IMAGE_MESSAGES, "questions": _QUESTIONS}
|
||||
|
||||
|
||||
def test_a_message_list_input_reaches_a_system_one_provider_as_its_flattened_text(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, _PERPLEXITY.upstream_reply())
|
||||
model: Final = _deployment(scenario, handle, _PERPLEXITY)
|
||||
response: Final = _decide(gateway, model, input=_MESSAGE_LIST_INPUT)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == _PERPLEXITY.litellm_response()
|
||||
(call,) = _upstream_calls(gateway, handle)
|
||||
assert call["body"] == {
|
||||
"model": _PERPLEXITY.body_model,
|
||||
"state": f"{_INPUT}\n\n{_FOLLOW_UP}",
|
||||
"questions": _SYSTEM_ONE_QUESTIONS,
|
||||
}
|
||||
row: Final = _spend_row(response.headers["x-litellm-call-id"])
|
||||
assert (row["status"], row["call_type"], row["prompt_tokens"], row["completion_tokens"]) == (
|
||||
"success",
|
||||
"adecisions",
|
||||
_INPUT_TOKENS,
|
||||
_OUTPUT_TOKENS,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider", _SYSTEM_ONE_PROVIDERS, ids=_provider_id)
|
||||
def test_safety_identifier_is_refused_by_system_one_providers_unless_the_deployment_drops_params(
|
||||
gateway: Gateway, provider: _Provider
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, provider.upstream_reply())
|
||||
strict: Final = _deployment(scenario, handle, provider)
|
||||
refused: Final = _decide(gateway, strict, safety_identifier=_SAFETY_IDENTIFIER)
|
||||
_assert_refused_for_safety_identifier(refused)
|
||||
assert _upstream_calls(gateway, handle) == []
|
||||
|
||||
dropping: Final = _deployment(scenario, handle, provider, drop_params=True)
|
||||
accepted: Final = _decide(gateway, dropping, safety_identifier=_SAFETY_IDENTIFIER)
|
||||
assert accepted.status_code == 200, accepted.text
|
||||
assert accepted.json() == provider.litellm_response()
|
||||
(call,) = _upstream_calls(gateway, handle)
|
||||
assert call["body"] == provider.upstream_body()
|
||||
row: Final = _spend_row(accepted.headers["x-litellm-call-id"])
|
||||
assert (row["status"], row["call_type"], row["model_group"]) == ("success", "adecisions", dropping)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ("", "x" * 5000), ids=("empty", "5kb"))
|
||||
def test_every_string_safety_identifier_is_refused_or_dropped_like_the_usual_one(gateway: Gateway, value: str) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, _PERPLEXITY.upstream_reply())
|
||||
strict: Final = _deployment(scenario, handle, _PERPLEXITY)
|
||||
_assert_refused_for_safety_identifier(_decide(gateway, strict, safety_identifier=value))
|
||||
dropping: Final = _deployment(scenario, handle, _PERPLEXITY, drop_params=True)
|
||||
accepted: Final = _decide(gateway, dropping, safety_identifier=value)
|
||||
assert accepted.status_code == 200, accepted.text
|
||||
(call,) = _upstream_calls(gateway, handle)
|
||||
assert call["body"] == _PERPLEXITY.upstream_body()
|
||||
|
||||
|
||||
def test_a_request_body_drop_params_drops_the_safety_identifier_like_chat(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, _PERPLEXITY.upstream_reply())
|
||||
strict: Final = _deployment(scenario, handle, _PERPLEXITY)
|
||||
response: Final = _decide(gateway, strict, safety_identifier=_SAFETY_IDENTIFIER, drop_params=True)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == _PERPLEXITY.litellm_response()
|
||||
(call,) = _upstream_calls(gateway, handle)
|
||||
assert call["body"] == _PERPLEXITY.upstream_body()
|
||||
|
||||
|
||||
def test_openai_keeps_the_safety_identifier_on_the_wire_without_drop_params(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, _OPENAI.upstream_reply())
|
||||
model: Final = _deployment(scenario, handle, _OPENAI)
|
||||
response: Final = _decide(gateway, model, safety_identifier=_SAFETY_IDENTIFIER)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == _OPENAI.litellm_response()
|
||||
(call,) = _upstream_calls(gateway, handle)
|
||||
assert call["body"] == {**_OPENAI.upstream_body(), "safety_identifier": _SAFETY_IDENTIFIER}
|
||||
|
||||
|
||||
def test_a_system_one_format_safety_identifier_is_refused_unless_the_deployment_drops_params(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, _PERPLEXITY.upstream_reply())
|
||||
strict: Final = _deployment(scenario, handle, _PERPLEXITY)
|
||||
refused: Final = _decide_in_system_one_format(gateway, strict, safety_identifier=_SAFETY_IDENTIFIER)
|
||||
_assert_refused_for_safety_identifier(refused)
|
||||
assert _upstream_calls(gateway, handle) == []
|
||||
|
||||
dropping: Final = _deployment(scenario, handle, _PERPLEXITY, drop_params=True)
|
||||
accepted: Final = _decide_in_system_one_format(gateway, dropping, safety_identifier=_SAFETY_IDENTIFIER)
|
||||
assert accepted.status_code == 200, accepted.text
|
||||
assert accepted.json()["answers"] == _SYSTEM_ONE_ANSWERS
|
||||
(call,) = _upstream_calls(gateway, handle)
|
||||
assert call["body"] == _PERPLEXITY.upstream_body()
|
||||
|
||||
|
||||
def test_openai_keeps_a_system_one_format_safety_identifier_on_the_wire(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, _OPENAI.upstream_reply())
|
||||
model: Final = _deployment(scenario, handle, _OPENAI)
|
||||
response: Final = _decide_in_system_one_format(gateway, model, safety_identifier=_SAFETY_IDENTIFIER)
|
||||
assert response.status_code == 200, response.text
|
||||
(call,) = _upstream_calls(gateway, handle)
|
||||
assert call["body"] == {**_OPENAI.upstream_body(), "safety_identifier": _SAFETY_IDENTIFIER}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("decide", (_decide, _decide_in_system_one_format), ids=("openai_format", "system_one_format"))
|
||||
@pytest.mark.parametrize("value", (7, [_SAFETY_IDENTIFIER]), ids=("numeric", "list"))
|
||||
def test_a_non_string_safety_identifier_is_refused_as_invalid_unless_the_deployment_drops_params(
|
||||
gateway: Gateway, value: JsonValue, decide: Callable[..., httpx.Response]
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, _OPENAI.upstream_reply())
|
||||
strict: Final = _deployment(scenario, handle, _OPENAI)
|
||||
refused: Final = decide(gateway, strict, safety_identifier=value)
|
||||
assert refused.status_code == 400, refused.text
|
||||
assert "Invalid Decisions request" in refused.text, refused.text
|
||||
assert _upstream_calls(gateway, handle) == []
|
||||
|
||||
dropping: Final = _deployment(scenario, handle, _OPENAI, drop_params=True)
|
||||
accepted: Final = decide(gateway, dropping, safety_identifier=value)
|
||||
assert accepted.status_code == 200, accepted.text
|
||||
(call,) = _upstream_calls(gateway, handle)
|
||||
assert call["body"] == _OPENAI.upstream_body()
|
||||
|
||||
|
||||
def test_litellm_settings_drop_params_drops_the_safety_identifier_for_a_strict_deployment(
|
||||
gateway: Gateway, tmp_path: Path
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, _PERPLEXITY.upstream_reply())
|
||||
config: Final = _drop_params_config(tmp_path, handle.api_base())
|
||||
with owned_proxy_process(gateway, tmp_path, {}, config=config) as owned:
|
||||
response: Final = _decide(owned.gateway, _DROPPING_DEPLOYMENT, safety_identifier=_SAFETY_IDENTIFIER)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == _PERPLEXITY.litellm_response()
|
||||
(call,) = _upstream_calls(gateway, handle)
|
||||
assert call["body"] == _PERPLEXITY.upstream_body()
|
||||
|
|
@ -69,6 +69,7 @@ class _Provider:
|
|||
api_key: str | None
|
||||
wraps_result: bool
|
||||
cost_map_key: str | None
|
||||
provider_reported_cost: float | None = None
|
||||
|
||||
|
||||
_PROVIDERS: Final = (
|
||||
|
|
@ -89,11 +90,15 @@ _PROVIDERS: Final = (
|
|||
"typesafe/jev-1.13",
|
||||
_API_KEY,
|
||||
False,
|
||||
"openrouter/typesafe/jev-1.13",
|
||||
None,
|
||||
1.5834e-5,
|
||||
),
|
||||
_Provider(
|
||||
"strands_decider", "strands_decider/systemone-decider", "/v1/systemone", "systemone-decider", None, False, None
|
||||
),
|
||||
_Provider(
|
||||
"hosted_vllm", "hosted_vllm/Qwen/Qwen3-0.6B", "/v1/systemone", "Qwen/Qwen3-0.6B", None, False, None
|
||||
),
|
||||
_Provider(
|
||||
"cloudflare",
|
||||
"cloudflare/clef",
|
||||
|
|
@ -142,17 +147,32 @@ def _number(value: JsonValue) -> float:
|
|||
return float(value)
|
||||
|
||||
|
||||
def _expected_spend(cost_map_key: str | None) -> float:
|
||||
if cost_map_key is None:
|
||||
def _expected_spend(provider: _Provider) -> float:
|
||||
if provider.provider_reported_cost is not None:
|
||||
return provider.provider_reported_cost
|
||||
if provider.cost_map_key is None:
|
||||
return 0.0
|
||||
prices: Final = object_value(json.loads(Path("model_prices_and_context_window.json").read_text())[cost_map_key])
|
||||
prices: Final = object_value(
|
||||
json.loads(Path("model_prices_and_context_window.json").read_text())[provider.cost_map_key]
|
||||
)
|
||||
return _number(_USAGE["input_tokens"]) * _number(prices["input_cost_per_token"]) + _number(
|
||||
_USAGE["output_tokens"]
|
||||
) * _number(prices["output_cost_per_token"])
|
||||
|
||||
|
||||
def _usage(provider: _Provider) -> dict[str, JsonValue]:
|
||||
return {
|
||||
**_USAGE,
|
||||
**({"cost": provider.provider_reported_cost} if provider.provider_reported_cost is not None else {}),
|
||||
}
|
||||
|
||||
|
||||
def _answer_body(provider: _Provider) -> dict[str, JsonValue]:
|
||||
answer: Final[dict[str, JsonValue]] = {"model": provider.body_model, "answers": _ANSWERS, "usage": _USAGE}
|
||||
answer: Final[dict[str, JsonValue]] = {
|
||||
"model": provider.body_model,
|
||||
"answers": _ANSWERS,
|
||||
"usage": _usage(provider),
|
||||
}
|
||||
return {"result": answer, "success": True} if provider.wraps_result else answer
|
||||
|
||||
|
||||
|
|
@ -235,16 +255,14 @@ def _pass_through_config(directory: Path, pass_through_target: str, native_api_b
|
|||
|
||||
|
||||
@pytest.mark.parametrize("provider", _PROVIDERS, ids=lambda provider: provider.name)
|
||||
def test_each_provider_gets_its_own_path_key_and_body_and_is_billed_from_the_cost_map(
|
||||
gateway: Gateway, provider: _Provider
|
||||
) -> None:
|
||||
expected_spend: Final = _expected_spend(provider.cost_map_key)
|
||||
def test_each_provider_gets_its_own_path_key_and_body_and_is_billed(gateway: Gateway, provider: _Provider) -> None:
|
||||
expected_spend: Final = _expected_spend(provider)
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, _answer_body(provider))
|
||||
model: Final = _deployment(scenario, handle, provider)
|
||||
response: Final = _decide(gateway, model)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == {"model": provider.body_model, "answers": _ANSWERS, "usage": _USAGE}
|
||||
assert response.json() == {"model": provider.body_model, "answers": _ANSWERS, "usage": _usage(provider)}
|
||||
assert response.headers["x-litellm-model-group"] == model
|
||||
assert math.isclose(float(response.headers.get("x-litellm-response-cost", "0")), expected_spend, rel_tol=1e-9)
|
||||
(call,) = _upstream_calls(gateway, handle)
|
||||
|
|
@ -260,7 +278,7 @@ def test_each_provider_gets_its_own_path_key_and_body_and_is_billed_from_the_cos
|
|||
row["api_base"],
|
||||
row["prompt_tokens"],
|
||||
row["completion_tokens"],
|
||||
) == ("success", "adecisions", provider.name, model, f"{handle.api_base()}{provider.path}", 367, 3)
|
||||
) == ("success", "asystemone", provider.name, model, f"{handle.api_base()}{provider.path}", 367, 3)
|
||||
assert math.isclose(_number(row["spend"]), expected_spend, rel_tol=1e-9), row
|
||||
|
||||
|
||||
|
|
@ -301,7 +319,7 @@ async def test_sdk_sync_and_async_clients_send_the_same_request(gateway: Gateway
|
|||
synchronous: Final = litellm.decisions(
|
||||
model=provider.model, state=_STATE, questions=_SDK_QUESTIONS, api_base=handle.api_base(), api_key=_API_KEY
|
||||
)
|
||||
asynchronous: Final = await litellm.adecisions(
|
||||
asynchronous: Final = await litellm.asystemone(
|
||||
model=provider.model, state=_STATE, questions=_SDK_QUESTIONS, api_base=handle.api_base(), api_key=_API_KEY
|
||||
)
|
||||
for response in (synchronous, asynchronous):
|
||||
|
|
@ -469,7 +487,7 @@ def test_upstream_errors_keep_their_status_and_log_an_unbilled_failure(gateway:
|
|||
row: Final = _spend_row(response.headers["x-litellm-call-id"])
|
||||
assert (row["status"], row["call_type"], row["model_group"], _number(row["spend"])) == (
|
||||
"failure",
|
||||
"adecisions",
|
||||
"asystemone",
|
||||
model,
|
||||
0.0,
|
||||
)
|
||||
|
|
@ -541,7 +559,7 @@ async def test_sdk_openrouter_connection_failures_raise_a_connection_error(gatew
|
|||
model=_OPENROUTER.model, state=_STATE, questions=_SDK_QUESTIONS, api_base=dead_api_base, api_key=_API_KEY
|
||||
)
|
||||
with pytest.raises(litellm.APIConnectionError):
|
||||
await litellm.adecisions(
|
||||
await litellm.asystemone(
|
||||
model=_OPENROUTER.model, state=_STATE, questions=_SDK_QUESTIONS, api_base=dead_api_base, api_key=_API_KEY
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,827 @@
|
|||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable, Iterator, Mapping, Sequence
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass, replace
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import yaml
|
||||
from integration._support.client import JSON_OBJECT, Gateway, Scenario, eventually, gateway_from_environment
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.forward_proxy import ForwardProxy, tunnelling_forward_proxy
|
||||
from integration._support.process import owned_proxy
|
||||
from integration._support.sigv4 import encoded_path, signature
|
||||
from integration._support.tls import server_context, write_self_signed_cert
|
||||
from integration._support.wire import Reply, Request, Wire, wire_server
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm.llms.strands_decider.decisions.transformation import StrandsDeciderDecisionsConfig
|
||||
|
||||
_ACCOUNT: Final = "123456789012"
|
||||
_ACCESS_KEY: Final = "AKIAAGENTCOREAUDIT01"
|
||||
_SECRET_KEY: Final = "synthetic-agentcore-audit-secret"
|
||||
_SIGNING: Final[Mapping[str, JsonValue]] = {"aws_access_key_id": _ACCESS_KEY, "aws_secret_access_key": _SECRET_KEY}
|
||||
_JWT: Final = "synthetic-agentcore-jwt"
|
||||
_ENV_JWT: Final = "synthetic-agentcore-env-jwt"
|
||||
_US_EAST: Final = "us-east-1"
|
||||
_EU_WEST: Final = "eu-west-1"
|
||||
_MODEL: Final = "strands_decider/systemone-decider"
|
||||
_BODY_MODEL: Final = "systemone-decider"
|
||||
_SESSION_HEADER: Final = "x-amzn-bedrock-agentcore-runtime-session-id"
|
||||
_CONFIGURED_SESSION: Final = "agentcore-audit-configured-session-0000000001"
|
||||
_STATE: Final[dict[str, JsonValue]] = {"ticket": "Refund requested twice for one order", "queue": "payments"}
|
||||
_QUESTIONS: Final[dict[str, JsonValue]] = {
|
||||
"duplicate": {"type": "noul", "instructions": "Is this a duplicate refund request?"},
|
||||
"urgency": {"type": "choice", "criteria": {"low": "can wait", "high": "money at risk"}},
|
||||
}
|
||||
_SDK_QUESTIONS: Final = TypeAdapter(dict[str, dict[str, object]]).validate_python(_QUESTIONS)
|
||||
_ANSWERS: Final[dict[str, JsonValue]] = {
|
||||
"duplicate": {"type": "noul", "noul": 0.88},
|
||||
"urgency": {"type": "choice", "choice": "high", "confidence": 0.75, "probabilities": {"low": 0.25, "high": 0.75}},
|
||||
}
|
||||
_USAGE: Final[dict[str, JsonValue]] = {"input_tokens": 211, "output_tokens": 2}
|
||||
_UPSTREAM_BODY: Final[dict[str, JsonValue]] = {"model": _BODY_MODEL, "state": _STATE, "questions": _QUESTIONS}
|
||||
_INPUT: Final = "Refund requested twice for one order"
|
||||
_OPENAI_QUESTIONS: Final[list[JsonValue]] = [
|
||||
{"type": "predicate", "name": "duplicate", "instructions": "Is this a duplicate refund request?"},
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "urgency",
|
||||
"instructions": "How urgent is it?",
|
||||
"choices": [{"value": "low", "description": "can wait"}, {"value": "high", "description": "money at risk"}],
|
||||
},
|
||||
]
|
||||
_OPENAI_UPSTREAM_BODY: Final[dict[str, JsonValue]] = {
|
||||
"model": _BODY_MODEL,
|
||||
"state": _INPUT,
|
||||
"questions": {
|
||||
"duplicate": {"type": "noul", "instructions": "Is this a duplicate refund request?"},
|
||||
"urgency": {
|
||||
"type": "choice",
|
||||
"instructions": "How urgent is it?",
|
||||
"criteria": {"low": "can wait", "high": "money at risk"},
|
||||
},
|
||||
},
|
||||
}
|
||||
_OPENAI_ANSWER: Final[dict[str, JsonValue]] = {
|
||||
"model": _BODY_MODEL,
|
||||
"answers": [
|
||||
{"type": "predicate", "name": "duplicate", "probability": 0.88},
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "urgency",
|
||||
"choice": "high",
|
||||
"probabilities": [{"value": "low", "probability": 0.25}, {"value": "high", "probability": 0.75}],
|
||||
"confidence": 0.75,
|
||||
},
|
||||
],
|
||||
"usage": {
|
||||
"input_tokens": 211,
|
||||
"input_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 0},
|
||||
"output_tokens": 2,
|
||||
"output_tokens_details": {"reasoning_tokens": 0},
|
||||
"total_tokens": 213,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Route:
|
||||
path: str
|
||||
body: Mapping[str, JsonValue]
|
||||
upstream: Mapping[str, JsonValue]
|
||||
answer: Mapping[str, JsonValue]
|
||||
|
||||
|
||||
_SYSTEMONE: Final = _Route(
|
||||
"/v1/systemone",
|
||||
{"state": _STATE, "questions": _QUESTIONS},
|
||||
_UPSTREAM_BODY,
|
||||
{"model": _BODY_MODEL, "answers": _ANSWERS, "usage": _USAGE},
|
||||
)
|
||||
_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"))
|
||||
_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'
|
||||
)
|
||||
_RUNTIME_TARGET: Final = re.compile(
|
||||
r"\A/runtimes/arn:aws:bedrock-agentcore:[a-z0-9-]+:[0-9]{12}:runtime/(?P<runtime>[A-Za-z0-9_-]+)/invocations\Z"
|
||||
)
|
||||
_ERROR_STATUS: Final = {"bad_request": 400, "invalid_request": 400, "loading": 503, "inference_error": 500}
|
||||
_HTTP_ERRORS: Final = {
|
||||
403: "AccessDeniedException",
|
||||
404: "ResourceNotFoundException",
|
||||
429: "ThrottlingException",
|
||||
}
|
||||
_MALFORMED_SESSIONS: Final[Mapping[str, JsonValue]] = {"int": 1234567, "list": ["a" * 40], "empty": ""}
|
||||
_LONG_SESSION: Final = "s" * 5120
|
||||
_NEAR_MISS_ARNS: Final = {
|
||||
"endpoint": f"arn:aws:bedrock-agentcore:us-east-1:{_ACCOUNT}:runtime/audit_qualified/runtime-endpoint/DEFAULT",
|
||||
"account": "arn:aws:bedrock-agentcore:us-east-1:12345:runtime/audit_short_account",
|
||||
"service": f"arn:aws:bedrock:us-east-1:{_ACCOUNT}:runtime/audit_other_service",
|
||||
"host": f"arn:aws:bedrock-agentcore:us-east-1.example.com:{_ACCOUNT}:runtime/audit_crafted_host",
|
||||
}
|
||||
_DEPLOYMENT_HEADERS: Final[dict[str, JsonValue]] = {
|
||||
"x-amz-target": "AmazonBedrockAgentCore.Forged",
|
||||
"X-Amzn-Bedrock-AgentCore-Runtime-Session-Id": "deployment-forged-session-00000000000001",
|
||||
"Host": "bedrock-agentcore.us-west-2.amazonaws.com",
|
||||
"x-audit-trace": "deployment-trace",
|
||||
}
|
||||
_SIGNED: Final = "agentcore-audit-signed"
|
||||
_REGION: Final = "agentcore-audit-region"
|
||||
_SESSION: Final = "agentcore-audit-session"
|
||||
_LONG: Final = "agentcore-audit-session-long"
|
||||
_BEARER: Final = "agentcore-audit-bearer"
|
||||
_HEADERS: Final = "agentcore-audit-headers"
|
||||
_OPT_IN: Final = "agentcore-audit-opt-in"
|
||||
_HEALTH: Final = "agentcore-audit-health"
|
||||
_DROPPED: Final = "agentcore-audit-dropped"
|
||||
_OUTAGE: Final = "agentcore-audit-outage"
|
||||
_ENV: Final = "agentcore-audit-env"
|
||||
_UNSIGNED: Final = "agentcore-audit-unsigned"
|
||||
_SCRUBBED_PREFIXES: Final = ("AWS_", "STRANDS_DECIDER_")
|
||||
_SCRUBBED_NAMES: Final = frozenset(
|
||||
{"HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY", "SSL_VERIFY", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"}
|
||||
)
|
||||
_load_yaml: Final[Callable[[str], object]] = yaml.safe_load
|
||||
_JSON: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
|
||||
_OWNED_PROXY_CELL_SECONDS: Final = 2 * max(30.0, float(os.environ.get("INTEGRATION_PROXY_READY_SECONDS") or 70)) + 120
|
||||
pytestmark: Final = pytest.mark.timeout(_OWNED_PROXY_CELL_SECONDS)
|
||||
|
||||
|
||||
def _host(region: str) -> str:
|
||||
return f"bedrock-agentcore.{region}.amazonaws.com"
|
||||
|
||||
|
||||
def _arn(runtime: str, region: str = _US_EAST) -> str:
|
||||
return f"arn:aws:bedrock-agentcore:{region}:{_ACCOUNT}:runtime/{runtime}"
|
||||
|
||||
|
||||
def _invocations_url(runtime: str, region: str = _US_EAST) -> str:
|
||||
return f"https://{_host(region)}/runtimes/{quote(_arn(runtime, region), safe='')}/invocations"
|
||||
|
||||
|
||||
def _default_session(runtime: str, region: str = _US_EAST) -> str:
|
||||
return f"litellm-decider-{hashlib.sha256(_arn(runtime, region).encode()).hexdigest()}"
|
||||
|
||||
|
||||
def _runtime_of(request: Request) -> str | None:
|
||||
match: Final = _RUNTIME_TARGET.match(unquote(request.target))
|
||||
return None if match is None else match["runtime"]
|
||||
|
||||
|
||||
def _json_reply(body: Mapping[str, JsonValue], status: int = 200, **headers: str) -> Reply:
|
||||
return Reply(status=status, body=json.dumps(body).encode(), headers=headers)
|
||||
|
||||
|
||||
def _respond(outage: threading.Event, request: Request) -> Reply:
|
||||
runtime: Final = _runtime_of(request) or ""
|
||||
if runtime == "audit_dropped" or (runtime == "audit_outage" and outage.is_set()):
|
||||
return Reply(drop_connection=True)
|
||||
if runtime.startswith("audit_error_"):
|
||||
code: Final = runtime.removeprefix("audit_error_")
|
||||
return _json_reply({"error": {"code": code, "message": f"runtime refused with {code}"}})
|
||||
if runtime.startswith("audit_status_"):
|
||||
status: Final = int(runtime.removeprefix("audit_status_"))
|
||||
return _json_reply(
|
||||
{"message": f"scripted {_HTTP_ERRORS[status]}"}, status, **{"x-amzn-errortype": _HTTP_ERRORS[status]}
|
||||
)
|
||||
if not runtime:
|
||||
return _json_reply({"message": f"unknown target {request.target}"}, 404)
|
||||
return _json_reply({"model": _BODY_MODEL, "answers": _ANSWERS, "usage": _USAGE})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Runtime:
|
||||
wire: Wire
|
||||
tunnel: ForwardProxy
|
||||
cert: Path
|
||||
outage: threading.Event
|
||||
|
||||
def reset(self) -> None:
|
||||
self.outage.clear()
|
||||
self.wire.drain()
|
||||
self.tunnel.drain()
|
||||
|
||||
def calls(self) -> tuple[Request, ...]:
|
||||
return self.wire.drain()
|
||||
|
||||
|
||||
def _calls_to(calls: Sequence[Request], runtime: str) -> tuple[Request, ...]:
|
||||
return tuple(call for call in calls if _runtime_of(call) == runtime)
|
||||
|
||||
|
||||
def _deployment(name: str, **litellm_params: JsonValue) -> dict[str, JsonValue]:
|
||||
return {
|
||||
"model_name": name,
|
||||
"litellm_params": {"model": _MODEL, **litellm_params},
|
||||
"model_info": {"id": name, "mode": "evaluation"},
|
||||
}
|
||||
|
||||
|
||||
def _signed_deployment(name: str, runtime: str, region: str = _US_EAST, **extra: JsonValue) -> dict[str, JsonValue]:
|
||||
return _deployment(name, api_base=_arn(runtime, region), **_SIGNING, **extra)
|
||||
|
||||
|
||||
def _deployments() -> list[dict[str, JsonValue]]:
|
||||
return [
|
||||
_signed_deployment(_SIGNED, "audit_signed", aws_region_name=_US_EAST),
|
||||
_signed_deployment(_REGION, "audit_region", _EU_WEST, aws_region_name=_US_EAST),
|
||||
_signed_deployment(_SESSION, "audit_session", agentcore_runtime_session_id=_CONFIGURED_SESSION),
|
||||
_signed_deployment(_LONG, "audit_session_long", agentcore_runtime_session_id=_LONG_SESSION),
|
||||
_deployment(_BEARER, api_base=_arn("audit_bearer"), api_key=_JWT),
|
||||
_signed_deployment(_HEADERS, "audit_headers", extra_headers=_DEPLOYMENT_HEADERS),
|
||||
_signed_deployment(
|
||||
_OPT_IN, "audit_opt_in", configurable_clientside_auth_params=["agentcore_runtime_session_id"]
|
||||
),
|
||||
_signed_deployment(_HEALTH, "audit_health"),
|
||||
_signed_deployment(_DROPPED, "audit_dropped"),
|
||||
_signed_deployment(_OUTAGE, "audit_outage"),
|
||||
_deployment(_ENV, **_SIGNING),
|
||||
_deployment(_UNSIGNED, api_base=_arn("audit_unsigned")),
|
||||
*(_signed_deployment(f"agentcore-audit-error-{code}", f"audit_error_{code}") for code in _ERROR_STATUS),
|
||||
*(_signed_deployment(f"agentcore-audit-status-{status}", f"audit_status_{status}") for status in _HTTP_ERRORS),
|
||||
*(
|
||||
_signed_deployment(
|
||||
f"agentcore-audit-session-{label}", f"audit_session_{label}", agentcore_runtime_session_id=value
|
||||
)
|
||||
for label, value in _MALFORMED_SESSIONS.items()
|
||||
),
|
||||
*(
|
||||
_deployment(f"agentcore-audit-near-miss-{label}", api_base=value, **_SIGNING)
|
||||
for label, value in _NEAR_MISS_ARNS.items()
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _write_config(directory: Path) -> Path:
|
||||
base: Final = JSON_OBJECT.validate_python(_load_yaml(Path("tests/integration/proxy_config.yaml").read_text()))
|
||||
configuration: Final = {
|
||||
**base,
|
||||
"model_list": _deployments(),
|
||||
"router_settings": {"disable_cooldowns": True, "num_retries": 0},
|
||||
}
|
||||
path: Final = directory / "agentcore.yaml"
|
||||
path.write_text(yaml.safe_dump(configuration))
|
||||
return path
|
||||
|
||||
|
||||
def _scrubbed() -> tuple[str, ...]:
|
||||
return tuple(
|
||||
name for name in os.environ if name.upper().startswith(_SCRUBBED_PREFIXES) or name.upper() in _SCRUBBED_NAMES
|
||||
)
|
||||
|
||||
|
||||
def _isolated_aws(directory: Path) -> dict[str, str]:
|
||||
return {
|
||||
"AWS_EC2_METADATA_DISABLED": "true",
|
||||
"AWS_SHARED_CREDENTIALS_FILE": str(directory / "absent-credentials"),
|
||||
"AWS_CONFIG_FILE": str(directory / "absent-config"),
|
||||
}
|
||||
|
||||
|
||||
def _proxy_environment(runtime: _Runtime, directory: Path) -> dict[str, str]:
|
||||
return {
|
||||
"HTTPS_PROXY": runtime.tunnel.url,
|
||||
"NO_PROXY": "127.0.0.1,localhost",
|
||||
"SSL_CERT_FILE": str(runtime.cert),
|
||||
"STRANDS_DECIDER_API_BASE": _arn("audit_env"),
|
||||
**_isolated_aws(directory),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def module_gateway() -> Iterator[Gateway]:
|
||||
with gateway_from_environment() as value:
|
||||
yield value
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def runtime(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_Runtime]:
|
||||
cert, key = write_self_signed_cert(tmp_path_factory.mktemp("agentcore-cert"), (_host(_US_EAST), _host(_EU_WEST)))
|
||||
outage: Final = threading.Event()
|
||||
with wire_server(partial(_respond, outage), tls=server_context(cert, key)) as wire:
|
||||
with tunnelling_forward_proxy(int(wire.url.rsplit(":", 1)[1])) as tunnel:
|
||||
yield _Runtime(wire, tunnel, cert, outage)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def agentcore(
|
||||
module_gateway: Gateway, runtime: _Runtime, tmp_path_factory: pytest.TempPathFactory
|
||||
) -> Iterator[Gateway]:
|
||||
directory: Final = tmp_path_factory.mktemp("agentcore-proxy")
|
||||
with owned_proxy(
|
||||
module_gateway,
|
||||
directory,
|
||||
_proxy_environment(runtime, directory),
|
||||
config=_write_config(directory),
|
||||
remove_environment=_scrubbed(),
|
||||
workers=2,
|
||||
) as candidate:
|
||||
yield candidate
|
||||
|
||||
|
||||
def _decide(gateway: Gateway, model: str, route: _Route = _SYSTEMONE, **extra: JsonValue) -> httpx.Response:
|
||||
return gateway.request("POST", route.path, {"model": model, **route.body, **extra})
|
||||
|
||||
|
||||
def _authorization_fields(request: Request) -> dict[str, str]:
|
||||
authorization: Final = request.headers.get("authorization", "")
|
||||
assert authorization.startswith("AWS4-HMAC-SHA256 "), dict(request.headers)
|
||||
return dict(part.split("=", 1) for part in authorization.removeprefix("AWS4-HMAC-SHA256 ").split(", "))
|
||||
|
||||
|
||||
def _assert_signed(request: Request, region: str = _US_EAST) -> None:
|
||||
fields: Final = _authorization_fields(request)
|
||||
access_key, scope = fields["Credential"].split("/", 1)
|
||||
assert access_key == _ACCESS_KEY, fields
|
||||
assert scope == f"{request.headers['x-amz-date'][:8]}/{region}/bedrock-agentcore/aws4_request", fields
|
||||
signed: Final = fields["SignedHeaders"]
|
||||
assert {"host", "x-amz-date", _SESSION_HEADER}.issubset(signed.split(";")), signed
|
||||
expected: Final = signature(
|
||||
"POST", encoded_path(request.target), request.headers, signed, request.body, _SECRET_KEY, scope
|
||||
)
|
||||
assert fields["Signature"] == expected[1], (request.target, fields)
|
||||
|
||||
|
||||
def _assert_answered(response: httpx.Response, model: str, route: _Route = _SYSTEMONE) -> None:
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == route.answer, response.text
|
||||
assert response.headers["x-litellm-model-group"] == model, dict(response.headers)
|
||||
|
||||
|
||||
def _assert_runtime_target(request: Request, runtime: str, region: str = _US_EAST) -> None:
|
||||
assert request.method == "POST", request.method
|
||||
assert request.target == f"/runtimes/{quote(_arn(runtime, region), safe='')}/invocations", request.target
|
||||
assert request.headers["host"] == _host(region), dict(request.headers)
|
||||
|
||||
|
||||
def _assert_runtime_call(request: Request, runtime: str, region: str = _US_EAST, route: _Route = _SYSTEMONE) -> None:
|
||||
_assert_runtime_target(request, runtime, region)
|
||||
assert json.loads(request.body) == route.upstream, request.body
|
||||
|
||||
|
||||
def _canonical(body: JsonValue | Mapping[str, JsonValue]) -> str:
|
||||
return json.dumps(body, sort_keys=True)
|
||||
|
||||
|
||||
def _spend_row(call_id: str) -> dict[str, JsonValue]:
|
||||
rows: Final = eventually(lambda: read_rows(_SPEND_QUERY, (call_id,)), lambda found: len(found) == 1, seconds=70)
|
||||
return rows[0]
|
||||
|
||||
|
||||
def _spend_rows(call_ids: Sequence[str]) -> list[dict[str, JsonValue]]:
|
||||
query: Final = (
|
||||
"SELECT request_id, status FROM \"LiteLLM_SpendLogs\" WHERE request_id = ANY(string_to_array(%s, ','))"
|
||||
)
|
||||
return eventually(
|
||||
lambda: read_rows(query, (",".join(call_ids),)), lambda found: len(found) >= len(call_ids), seconds=70
|
||||
)
|
||||
|
||||
|
||||
def _connect_targets(runtime: _Runtime) -> frozenset[str]:
|
||||
return frozenset(runtime.tunnel.targets())
|
||||
|
||||
|
||||
def test_an_arn_deployment_answers_on_every_decisions_route_signed_for_bedrock_agentcore(
|
||||
agentcore: Gateway, runtime: _Runtime
|
||||
) -> None:
|
||||
runtime.reset()
|
||||
responses: Final = tuple(_decide(agentcore, _SIGNED, route) for route in _ROUTES)
|
||||
for response, route in zip(responses, _ROUTES, strict=True):
|
||||
_assert_answered(response, _SIGNED, route)
|
||||
assert _connect_targets(runtime) == {f"{_host(_US_EAST)}:443"}
|
||||
calls: Final = runtime.calls()
|
||||
assert len(calls) == len(_ROUTES), calls
|
||||
for call, route in zip(calls, _ROUTES, strict=True):
|
||||
_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:
|
||||
row = _spend_row(response.headers["x-litellm-call-id"])
|
||||
assert row == {
|
||||
"status": "success",
|
||||
"call_type": "asystemone",
|
||||
"custom_llm_provider": "strands_decider",
|
||||
"model_group": _SIGNED,
|
||||
"api_base": _invocations_url("audit_signed"),
|
||||
"prompt_tokens": 211,
|
||||
"completion_tokens": 2,
|
||||
}, row
|
||||
|
||||
|
||||
def test_the_arn_region_picks_the_host_and_signing_scope_over_aws_region_name(
|
||||
agentcore: Gateway, runtime: _Runtime
|
||||
) -> None:
|
||||
runtime.reset()
|
||||
_assert_answered(_decide(agentcore, _REGION), _REGION)
|
||||
assert _connect_targets(runtime) == {f"{_host(_EU_WEST)}:443"}
|
||||
(call,) = runtime.calls()
|
||||
_assert_runtime_call(call, "audit_region", _EU_WEST)
|
||||
_assert_signed(call, _EU_WEST)
|
||||
|
||||
|
||||
def test_a_configured_session_id_is_sent_as_written_and_signed(agentcore: Gateway, runtime: _Runtime) -> None:
|
||||
runtime.reset()
|
||||
_assert_answered(_decide(agentcore, _SESSION), _SESSION)
|
||||
_assert_answered(_decide(agentcore, _LONG), _LONG)
|
||||
calls: Final = runtime.calls()
|
||||
(configured,) = _calls_to(calls, "audit_session")
|
||||
(long,) = _calls_to(calls, "audit_session_long")
|
||||
assert configured.headers[_SESSION_HEADER] == _CONFIGURED_SESSION, dict(configured.headers)
|
||||
assert long.headers[_SESSION_HEADER] == _LONG_SESSION
|
||||
_assert_signed(configured)
|
||||
_assert_signed(long)
|
||||
|
||||
|
||||
def test_a_deployment_api_key_sends_bearer_and_the_session_without_sigv4(agentcore: Gateway, runtime: _Runtime) -> None:
|
||||
runtime.reset()
|
||||
_assert_answered(_decide(agentcore, _BEARER), _BEARER)
|
||||
(call,) = runtime.calls()
|
||||
_assert_runtime_call(call, "audit_bearer")
|
||||
assert call.headers["authorization"] == f"Bearer {_JWT}", dict(call.headers)
|
||||
assert call.headers[_SESSION_HEADER] == _default_session("audit_bearer"), dict(call.headers)
|
||||
assert "x-amz-date" not in call.headers, dict(call.headers)
|
||||
|
||||
|
||||
def test_reserved_extra_headers_are_dropped_and_the_rest_are_kept(agentcore: Gateway, runtime: _Runtime) -> None:
|
||||
runtime.reset()
|
||||
_assert_answered(_decide(agentcore, _HEADERS), _HEADERS)
|
||||
caller_headers: Final[dict[str, JsonValue]] = {
|
||||
"x-amz-security-token": "caller-forged-token",
|
||||
"X-Amzn-Bedrock-AgentCore-Runtime-Session-Id": "caller-forged-session-0000000000000000001",
|
||||
"x-audit-caller": "caller-trace",
|
||||
}
|
||||
_assert_answered(_decide(agentcore, _HEADERS, extra_headers=caller_headers), _HEADERS)
|
||||
deployment, caller = runtime.calls()
|
||||
for call in (deployment, caller):
|
||||
_assert_runtime_call(call, "audit_headers")
|
||||
assert call.headers[_SESSION_HEADER] == _default_session("audit_headers"), dict(call.headers)
|
||||
assert "x-amz-target" not in call.headers, dict(call.headers)
|
||||
assert "x-amz-security-token" not in call.headers, dict(call.headers)
|
||||
_assert_signed(call)
|
||||
assert deployment.headers["x-audit-trace"] == "deployment-trace", dict(deployment.headers)
|
||||
assert caller.headers["x-audit-caller"] == "caller-trace", dict(caller.headers)
|
||||
|
||||
|
||||
def test_an_opted_in_caller_session_id_becomes_the_header_and_stays_out_of_the_body(
|
||||
agentcore: Gateway, runtime: _Runtime
|
||||
) -> None:
|
||||
runtime.reset()
|
||||
chosen: Final = f"agentcore-audit-caller-session-{uuid.uuid4().hex}"
|
||||
_assert_answered(_decide(agentcore, _OPT_IN, agentcore_runtime_session_id=chosen), _OPT_IN)
|
||||
_assert_answered(_decide(agentcore, _OPT_IN), _OPT_IN)
|
||||
first, second = runtime.calls()
|
||||
for call in (first, second):
|
||||
_assert_runtime_call(call, "audit_opt_in")
|
||||
_assert_signed(call)
|
||||
assert first.headers[_SESSION_HEADER] == chosen, dict(first.headers)
|
||||
assert second.headers[_SESSION_HEADER] == _default_session("audit_opt_in"), dict(second.headers)
|
||||
|
||||
|
||||
def test_an_arn_deployment_without_api_base_takes_the_runtime_from_the_environment(
|
||||
agentcore: Gateway, runtime: _Runtime
|
||||
) -> None:
|
||||
runtime.reset()
|
||||
response: Final = _decide(agentcore, _ENV)
|
||||
_assert_answered(response, _ENV)
|
||||
(call,) = runtime.calls()
|
||||
_assert_runtime_call(call, "audit_env")
|
||||
_assert_signed(call)
|
||||
assert _spend_row(response.headers["x-litellm-call-id"])["api_base"] == _invocations_url("audit_env")
|
||||
|
||||
|
||||
def test_an_arn_deployment_added_through_the_management_api_answers_signed(
|
||||
agentcore: Gateway, runtime: _Runtime
|
||||
) -> None:
|
||||
runtime.reset()
|
||||
with agentcore.scenario() as scenario:
|
||||
model: Final = scenario.model(
|
||||
model_info=None,
|
||||
model=_MODEL,
|
||||
api_base=_arn("audit_stored"),
|
||||
api_key=None,
|
||||
aws_access_key_id=_ACCESS_KEY,
|
||||
aws_secret_access_key=_SECRET_KEY,
|
||||
)
|
||||
_assert_answered(_decide(agentcore, model), model)
|
||||
(call,) = runtime.calls()
|
||||
_assert_runtime_call(call, "audit_stored")
|
||||
_assert_signed(call)
|
||||
|
||||
|
||||
def test_health_and_test_connection_probe_the_runtime_signed(agentcore: Gateway, runtime: _Runtime) -> None:
|
||||
runtime.reset()
|
||||
health: Final = agentcore.request("GET", "/health", params={"model": _HEALTH})
|
||||
assert health.status_code == 200, health.text
|
||||
assert (health.json()["healthy_count"], health.json()["unhealthy_count"]) == (1, 0), health.text
|
||||
(probe,) = runtime.calls()
|
||||
assert probe.target == f"/runtimes/{quote(_arn('audit_health'), safe='')}/invocations", probe.target
|
||||
_assert_signed(probe)
|
||||
connection: Final = agentcore.request(
|
||||
"POST",
|
||||
"/health/test_connection",
|
||||
{
|
||||
"litellm_params": {"model": _MODEL, "api_base": _arn("audit_test_connection"), **_SIGNING},
|
||||
"mode": "evaluation",
|
||||
},
|
||||
)
|
||||
assert connection.status_code == 200, connection.text
|
||||
assert connection.json()["status"] == "success", connection.text
|
||||
(tested,) = runtime.calls()
|
||||
assert _runtime_of(tested) == "audit_test_connection", tested.target
|
||||
_assert_signed(tested)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("code", "status"), tuple(_ERROR_STATUS.items()), ids=tuple(_ERROR_STATUS))
|
||||
def test_a_runtime_error_reply_maps_to_its_status_with_the_runtime_message(
|
||||
agentcore: Gateway, runtime: _Runtime, code: str, status: int
|
||||
) -> None:
|
||||
runtime.reset()
|
||||
model: Final = f"agentcore-audit-error-{code}"
|
||||
response: Final = _decide(agentcore, model)
|
||||
assert response.status_code == status, response.text
|
||||
assert f"Strands Decider runtime error '{code}': runtime refused with {code}" in response.text, response.text
|
||||
assert len(_calls_to(runtime.calls(), f"audit_error_{code}")) == 1
|
||||
row: Final = _spend_row(response.headers["x-litellm-call-id"])
|
||||
assert (row["status"], row["model_group"]) == ("failure", model), row
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", tuple(_HTTP_ERRORS), ids=tuple(str(status) for status in _HTTP_ERRORS))
|
||||
def test_a_runtime_http_error_keeps_its_status_and_message(agentcore: Gateway, runtime: _Runtime, status: int) -> None:
|
||||
runtime.reset()
|
||||
model: Final = f"agentcore-audit-status-{status}"
|
||||
response: Final = _decide(agentcore, model)
|
||||
assert response.status_code == status, response.text
|
||||
assert f"scripted {_HTTP_ERRORS[status]}" in response.text, response.text
|
||||
assert len(_calls_to(runtime.calls(), f"audit_status_{status}")) == 1
|
||||
assert _spend_row(response.headers["x-litellm-call-id"])["status"] == "failure"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("label", tuple(_MALFORMED_SESSIONS))
|
||||
def test_a_malformed_configured_session_id_fails_only_its_own_deployment(
|
||||
agentcore: Gateway, runtime: _Runtime, label: str
|
||||
) -> None:
|
||||
runtime.reset()
|
||||
model: Final = f"agentcore-audit-session-{label}"
|
||||
response: Final = _decide(agentcore, model)
|
||||
calls: Final = _calls_to(runtime.calls(), f"audit_session_{label}")
|
||||
if label == "empty":
|
||||
_assert_answered(response, model)
|
||||
(call,) = calls
|
||||
assert call.headers[_SESSION_HEADER] == "", dict(call.headers)
|
||||
else:
|
||||
assert response.status_code == 500, response.text
|
||||
assert "agentcore_runtime_session_id" in response.text, response.text
|
||||
assert calls == ()
|
||||
_assert_answered(_decide(agentcore, _SIGNED), _SIGNED)
|
||||
|
||||
|
||||
def test_a_deployment_without_aws_credentials_fails_before_dialing_the_runtime(
|
||||
agentcore: Gateway, runtime: _Runtime
|
||||
) -> None:
|
||||
runtime.reset()
|
||||
response: Final = _decide(agentcore, _UNSIGNED)
|
||||
assert response.status_code == 500, response.text
|
||||
assert "credentials" in response.text.lower(), response.text
|
||||
assert runtime.calls() == ()
|
||||
assert _connect_targets(runtime) == frozenset()
|
||||
_assert_answered(_decide(agentcore, _SIGNED), _SIGNED)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("label", tuple(_NEAR_MISS_ARNS))
|
||||
def test_a_near_miss_arn_keeps_the_plain_api_base_path(agentcore: Gateway, runtime: _Runtime, label: str) -> None:
|
||||
runtime.reset()
|
||||
response: Final = _decide(agentcore, f"agentcore-audit-near-miss-{label}")
|
||||
assert 500 <= response.status_code < 600, response.text
|
||||
assert "litellm.APIConnectionError" in response.text, response.text
|
||||
assert _connect_targets(runtime) == frozenset()
|
||||
assert runtime.calls() == ()
|
||||
|
||||
|
||||
def test_a_dropped_connection_is_a_connection_error_after_one_attempt(agentcore: Gateway, runtime: _Runtime) -> None:
|
||||
runtime.reset()
|
||||
response: Final = _decide(agentcore, _DROPPED)
|
||||
assert response.status_code == 500, response.text
|
||||
assert "litellm.APIConnectionError" in response.text, response.text
|
||||
assert len(_calls_to(runtime.calls(), "audit_dropped")) == 1
|
||||
assert _spend_row(response.headers["x-litellm-call-id"])["status"] == "failure"
|
||||
|
||||
|
||||
def _burst_routes(size: int) -> tuple[_Route, ...]:
|
||||
return tuple(_ROUTES[index % len(_ROUTES)] for index in range(size))
|
||||
|
||||
|
||||
def _burst(gateway: Gateway, model: str, size: int) -> tuple[httpx.Response, ...]:
|
||||
with ThreadPoolExecutor(max_workers=size) as pool:
|
||||
return tuple(pool.map(partial(_decide, gateway, model), _burst_routes(size)))
|
||||
|
||||
|
||||
def _health_counts(gateway: Gateway, model: str) -> tuple[int, int, int]:
|
||||
response: Final = gateway.request("GET", "/health", params={"model": model})
|
||||
body: Final = JSON_OBJECT.validate_json(response.content)
|
||||
healthy: Final = body["healthy_count"]
|
||||
unhealthy: Final = body["unhealthy_count"]
|
||||
assert isinstance(healthy, int) and isinstance(unhealthy, int), body
|
||||
return response.status_code, healthy, unhealthy
|
||||
|
||||
|
||||
def test_a_runtime_outage_mid_burst_fails_each_call_once_and_recovers_signed(
|
||||
agentcore: Gateway, runtime: _Runtime
|
||||
) -> None:
|
||||
runtime.reset()
|
||||
runtime.outage.set()
|
||||
failed: Final = _burst(agentcore, _OUTAGE, 10)
|
||||
assert [response.status_code for response in failed] == [500] * 10, [response.text for response in failed]
|
||||
assert _health_counts(agentcore, _OUTAGE) == (503, 0, 1)
|
||||
runtime.outage.clear()
|
||||
runtime.calls()
|
||||
served: Final = _burst(agentcore, _OUTAGE, 20)
|
||||
for response, route in zip(served, _burst_routes(20), strict=True):
|
||||
_assert_answered(response, _OUTAGE, route)
|
||||
calls: Final = runtime.calls()
|
||||
assert len(calls) == 20, calls
|
||||
for call in calls:
|
||||
_assert_runtime_target(call, "audit_outage")
|
||||
_assert_signed(call)
|
||||
assert sorted(_canonical(_JSON.validate_json(call.body)) for call in calls) == sorted(
|
||||
_canonical(route.upstream) for route in _burst_routes(20)
|
||||
)
|
||||
assert _health_counts(agentcore, _OUTAGE) == (200, 1, 0)
|
||||
expected: Final = {
|
||||
**{response.headers["x-litellm-call-id"]: "failure" for response in failed},
|
||||
**{response.headers["x-litellm-call-id"]: "success" for response in served},
|
||||
}
|
||||
assert len(expected) == 30, expected
|
||||
rows: Final = _spend_rows(tuple(expected))
|
||||
assert sorted((str(row["request_id"]), row["status"]) for row in rows) == sorted(expected.items()), rows
|
||||
|
||||
|
||||
async def test_sdk_sync_and_async_calls_reach_the_runtime_signed(
|
||||
runtime: _Runtime, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
runtime.reset()
|
||||
for name in _scrubbed():
|
||||
monkeypatch.delenv(name)
|
||||
for name, value in {
|
||||
"HTTPS_PROXY": runtime.tunnel.url,
|
||||
"NO_PROXY": "127.0.0.1,localhost",
|
||||
"SSL_CERT_FILE": str(runtime.cert),
|
||||
**_isolated_aws(tmp_path),
|
||||
}.items():
|
||||
monkeypatch.setenv(name, value)
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
try:
|
||||
synchronous: Final = litellm.decisions(
|
||||
model=_MODEL,
|
||||
state=_STATE,
|
||||
questions=_SDK_QUESTIONS,
|
||||
api_base=_arn("audit_sdk"),
|
||||
aws_access_key_id=_ACCESS_KEY,
|
||||
aws_secret_access_key=_SECRET_KEY,
|
||||
)
|
||||
asynchronous: Final = await litellm.asystemone(
|
||||
model=_MODEL,
|
||||
state=_STATE,
|
||||
questions=_SDK_QUESTIONS,
|
||||
api_base=_arn("audit_sdk"),
|
||||
aws_access_key_id=_ACCESS_KEY,
|
||||
aws_secret_access_key=_SECRET_KEY,
|
||||
)
|
||||
monkeypatch.setenv("STRANDS_DECIDER_API_KEY", _ENV_JWT)
|
||||
bearer: Final = await litellm.asystemone(
|
||||
model=_MODEL, state=_STATE, questions=_SDK_QUESTIONS, api_base=_arn("audit_sdk_env_key")
|
||||
)
|
||||
finally:
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
for response in (synchronous, asynchronous, bearer):
|
||||
assert response.model_dump(mode="json") == {"model": _BODY_MODEL, "answers": _ANSWERS, "usage": _USAGE}
|
||||
calls: Final = runtime.calls()
|
||||
signed: Final = _calls_to(calls, "audit_sdk")
|
||||
assert len(signed) == 2, calls
|
||||
for call in signed:
|
||||
_assert_runtime_call(call, "audit_sdk")
|
||||
assert call.headers[_SESSION_HEADER] == _default_session("audit_sdk"), dict(call.headers)
|
||||
_assert_signed(call)
|
||||
(env_key,) = _calls_to(calls, "audit_sdk_env_key")
|
||||
assert env_key.headers["authorization"] == f"Bearer {_ENV_JWT}", dict(env_key.headers)
|
||||
assert "x-amz-date" not in env_key.headers, dict(env_key.headers)
|
||||
|
||||
|
||||
def _answering(request: Request) -> Reply:
|
||||
return _json_reply({"model": _BODY_MODEL, "answers": _ANSWERS, "usage": _USAGE})
|
||||
|
||||
|
||||
def _envelope(request: Request) -> Reply:
|
||||
return _json_reply({"error": {"code": "bad_request", "message": "plain server refused"}})
|
||||
|
||||
|
||||
def _plain_model(scenario: Scenario, wire: Wire, **extra: JsonValue) -> str:
|
||||
return scenario.model(model_info=None, model=_MODEL, api_base=wire.url, api_key=None, **extra)
|
||||
|
||||
|
||||
def test_a_request_body_session_id_is_refused_on_every_route_without_an_upstream_call(gateway: Gateway) -> None:
|
||||
with wire_server(_answering) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _plain_model(scenario, wire)
|
||||
responses: Final = tuple(
|
||||
_decide(gateway, model, route, agentcore_runtime_session_id=f"caller-{uuid.uuid4().hex}")
|
||||
for route in _ROUTES
|
||||
)
|
||||
for response in responses:
|
||||
assert response.status_code == 401, response.text
|
||||
assert "agentcore_runtime_session_id is not allowed in request body" in response.text, response.text
|
||||
assert wire.drain() == ()
|
||||
|
||||
|
||||
def test_a_plain_api_base_opted_into_the_session_param_sends_no_session_header_or_body_field(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
with wire_server(_answering) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _plain_model(
|
||||
scenario, wire, configurable_clientside_auth_params=["agentcore_runtime_session_id"]
|
||||
)
|
||||
_assert_answered(_decide(gateway, model, agentcore_runtime_session_id=f"caller-{uuid.uuid4().hex}"), model)
|
||||
(call,) = wire.drain()
|
||||
assert call.target == "/v1/systemone", call.target
|
||||
assert _SESSION_HEADER not in call.headers, dict(call.headers)
|
||||
assert json.loads(call.body) == _UPSTREAM_BODY, call.body
|
||||
|
||||
|
||||
def test_a_plain_api_base_error_envelope_keeps_the_unexpected_response_server_error(gateway: Gateway) -> None:
|
||||
with wire_server(_envelope) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _plain_model(scenario, wire)
|
||||
response: Final = _decide(gateway, model)
|
||||
assert response.status_code == 500, response.text
|
||||
assert "Strands Decider runtime error" not in response.text, response.text
|
||||
assert len(wire.drain()) == 1
|
||||
|
||||
|
||||
def test_a_plain_api_base_forwards_amazon_shaped_extra_headers_unchanged(gateway: Gateway) -> None:
|
||||
headers: Final[dict[str, JsonValue]] = {
|
||||
"x-amz-target": "plain-target",
|
||||
"X-Amzn-Bedrock-AgentCore-Runtime-Session-Id": "plain-session-kept",
|
||||
"x-audit-trace": "plain-trace",
|
||||
}
|
||||
with wire_server(_answering) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _plain_model(scenario, wire, extra_headers=headers)
|
||||
_assert_answered(_decide(gateway, model), model)
|
||||
(call,) = wire.drain()
|
||||
assert (call.headers["x-amz-target"], call.headers[_SESSION_HEADER], call.headers["x-audit-trace"]) == (
|
||||
"plain-target",
|
||||
"plain-session-kept",
|
||||
"plain-trace",
|
||||
), dict(call.headers)
|
||||
assert "authorization" not in call.headers, dict(call.headers)
|
||||
assert json.loads(call.body) == _UPSTREAM_BODY, call.body
|
||||
|
||||
|
||||
def _worst_case_api_base() -> str:
|
||||
return "arn:aws" + "-a" * 131072 + "!"
|
||||
|
||||
|
||||
def test_a_worst_case_runtime_arn_pattern_input_is_classified_in_linear_time(
|
||||
gateway: Gateway, record_property: Callable[[str, object], None]
|
||||
) -> None:
|
||||
worst: Final = _worst_case_api_base()
|
||||
started: Final = time.perf_counter()
|
||||
url: Final = StrandsDeciderDecisionsConfig().get_complete_url(worst, _BODY_MODEL)
|
||||
classified: Final = time.perf_counter() - started
|
||||
record_property("classifier_seconds", f"{classified:.6f}")
|
||||
assert url == f"{worst}/v1/systemone"
|
||||
assert classified < 0.5, classified
|
||||
with wire_server(_answering) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _plain_model(scenario, wire, configurable_clientside_auth_params=["api_base"])
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
timed: Final = pool.submit(_timed_decide, gateway, model, worst)
|
||||
liveliness_started: Final = time.perf_counter()
|
||||
liveliness: Final = gateway.request("GET", "/health/liveliness")
|
||||
liveliness_seconds: Final = time.perf_counter() - liveliness_started
|
||||
response, decide_seconds = timed.result()
|
||||
record_property("decide_seconds", f"{decide_seconds:.6f}")
|
||||
record_property("liveliness_seconds", f"{liveliness_seconds:.6f}")
|
||||
assert liveliness.status_code == 200, liveliness.text
|
||||
assert 500 <= response.status_code < 600, response.text
|
||||
assert "litellm.APIConnectionError" in response.text, response.text[:2000]
|
||||
assert decide_seconds < 5, decide_seconds
|
||||
assert liveliness_seconds < 2, liveliness_seconds
|
||||
assert wire.drain() == ()
|
||||
|
||||
|
||||
def _timed_decide(gateway: Gateway, model: str, api_base: str) -> tuple[httpx.Response, float]:
|
||||
started: Final = time.perf_counter()
|
||||
response: Final = _decide(gateway, model, api_base=api_base)
|
||||
return response, time.perf_counter() - started
|
||||
|
|
@ -346,6 +346,10 @@ model_list:
|
|||
litellm_params:
|
||||
model: strands_decider/strands-decider-2B-hobson-v19
|
||||
api_base: http://127.0.0.1:8191
|
||||
- model_name: hosted_vllm/Qwen/Qwen3-0.6B
|
||||
litellm_params:
|
||||
model: hosted_vllm/Qwen/Qwen3-0.6B
|
||||
api_base: http://127.0.0.1:8191
|
||||
- model_name: azure_ai/decision-1
|
||||
litellm_params:
|
||||
model: azure_ai/decision-1
|
||||
|
|
|
|||
|
|
@ -31,10 +31,9 @@ from pydantic import JsonValue, TypeAdapter
|
|||
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_if_encrypted_with
|
||||
from litellm.router_strategy.complexity_router.config import DEFAULT_JEV_INSTRUCTIONS
|
||||
|
||||
_MODEL: Final = "ai_decide"
|
||||
_ROUTE: Final = "/api/2.0/ai-functions/ai-decide"
|
||||
_ENDPOINT: Final = "databricks-openjev-qwen35-4b"
|
||||
_API_KEY: Final = "synthetic-databricks-key"
|
||||
_CLASSIFIER_MODEL: Final = f"databricks/{_MODEL}"
|
||||
_CLASSIFIER_MODEL: Final = f"databricks/{_ENDPOINT}"
|
||||
_TYPESAFE_MODEL: Final = "jev-1.13.0"
|
||||
_TYPESAFE_KEY: Final = "synthetic-typesafe-key"
|
||||
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
||||
|
|
@ -49,12 +48,10 @@ _TIER_CRITERIA: Final[dict[str, JsonValue]] = {
|
|||
}
|
||||
_PROBABILITIES: Final[dict[str, JsonValue]] = {"SIMPLE": 0.03, "MEDIUM": 0.06, "COMPLEX": 0.91}
|
||||
_USAGE: Final[dict[str, JsonValue]] = {"input_tokens": 12, "output_tokens": 1}
|
||||
_ANSWERS: Final[dict[str, JsonValue]] = {
|
||||
"tier": {"type": "choice", "choice": "COMPLEX", "confidence": 0.91, "probabilities": _PROBABILITIES}
|
||||
_ANSWER: Final[dict[str, JsonValue]] = {
|
||||
"answers": {"tier": {"type": "choice", "choice": "COMPLEX", "confidence": 0.91, "probabilities": _PROBABILITIES}},
|
||||
"usage": _USAGE,
|
||||
}
|
||||
_METADATA: Final[dict[str, JsonValue]] = {"version": "1.0"}
|
||||
_ANSWER: Final[dict[str, JsonValue]] = {"response": {"answers": _ANSWERS}, "metadata": _METADATA}
|
||||
_TYPESAFE_ANSWER: Final[dict[str, JsonValue]] = {"model": _TYPESAFE_MODEL, "answers": _ANSWERS, "usage": _USAGE}
|
||||
_ROWS_QUERY: Final = (
|
||||
"SELECT request_id, status, model, call_type, cache_hit, custom_llm_provider, spend, "
|
||||
"metadata->>'internal_call_origin' AS origin "
|
||||
|
|
@ -158,7 +155,7 @@ def _wires(classifier: Callable[[Request], Reply] | None = None) -> Iterator[tup
|
|||
def _classifier_config(judge_url: str, **overrides: JsonValue) -> dict[str, JsonValue]:
|
||||
return {
|
||||
"provider": "databricks",
|
||||
"model": _MODEL,
|
||||
"model": _ENDPOINT,
|
||||
"api_base": judge_url,
|
||||
"api_key": _API_KEY,
|
||||
"timeout_ms": 20000,
|
||||
|
|
@ -212,25 +209,25 @@ def _state(prompt: str, system: str | None) -> str:
|
|||
|
||||
|
||||
def _assert_classifier_call(
|
||||
call: Request, *, prompt: str, system: str | None = None, key: str = _API_KEY, endpoint: str | None = None
|
||||
call: Request, *, prompt: str, system: str | None = None, endpoint: str = _ENDPOINT, key: str = _API_KEY
|
||||
) -> None:
|
||||
assert call.target == (_ROUTE if endpoint is None else f"/{endpoint}/invocations"), call.target
|
||||
assert call.target == f"/{endpoint}/invocations", call.target
|
||||
assert call.headers.get("authorization") == f"Bearer {key}", call.headers
|
||||
assert json.loads(call.body) == {
|
||||
"state": _state(prompt, system),
|
||||
**({} if endpoint is None else {"model": endpoint}),
|
||||
"model": endpoint,
|
||||
"questions": {"tier": {"type": "choice", "instructions": DEFAULT_JEV_INSTRUCTIONS, "criteria": _TIER_CRITERIA}},
|
||||
}, call.body
|
||||
|
||||
|
||||
def _assert_classified(headers: Mapping[str, str], *, router: str) -> None:
|
||||
def _assert_classified(headers: Mapping[str, str], *, router: str, cost: str | None = "0.0") -> None:
|
||||
assert (
|
||||
headers.get("x-litellm-model-group"),
|
||||
headers.get("x-litellm-model-name"),
|
||||
headers.get("x-litellm-complexity-router-tier"),
|
||||
headers.get("x-litellm-complexity-router-cause"),
|
||||
headers.get("x-litellm-classifier-cost"),
|
||||
) == (router, "openai/complex-tier", "COMPLEX", "jev_classifier", None), dict(headers)
|
||||
) == (router, "openai/complex-tier", "COMPLEX", "jev_classifier", cost), dict(headers)
|
||||
|
||||
|
||||
def _assert_fell_back(headers: Mapping[str, str], *, router: str) -> None:
|
||||
|
|
@ -625,19 +622,10 @@ def test_messages_stream_over_httpx_is_classified_and_logged_under_the_message_s
|
|||
_assert_logged_once(_rows(routed.name, want=2), route="/v1/messages", request_id=identity)
|
||||
|
||||
|
||||
def test_a_serving_endpoint_name_is_classified_through_its_invocations_route_and_logged_at_zero(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
def test_a_non_catalog_endpoint_name_is_classified_without_a_cost_header_and_logged_at_zero(gateway: Gateway) -> None:
|
||||
prompt: Final = _prompt()
|
||||
endpoint: Final = "my-jev-endpoint"
|
||||
with (
|
||||
_wires(_answering({"model": "/mosaicml/local_model", "answers": _ANSWERS, "usage": _USAGE})) as (
|
||||
judge,
|
||||
simple,
|
||||
complex_,
|
||||
),
|
||||
gateway.scenario() as scenario,
|
||||
):
|
||||
with _wires() as (judge, simple, complex_), gateway.scenario() as scenario:
|
||||
routed: Final = _deploy(scenario, judge, simple, complex_, _classifier_config(judge.url, model=endpoint))
|
||||
response: Final = gateway.request(
|
||||
"POST", "/v1/chat/completions", _body("/v1/chat/completions", routed.name, prompt, stream=False)
|
||||
|
|
@ -645,7 +633,7 @@ def test_a_serving_endpoint_name_is_classified_through_its_invocations_route_and
|
|||
assert response.status_code == 200, response.text
|
||||
identity, text = _identity_and_text("/v1/chat/completions", _JSON_OBJECT.validate_json(response.content))
|
||||
assert text == _COMPLEX_TEXT, response.text
|
||||
_assert_classified(response.headers, router=routed.name)
|
||||
_assert_classified(response.headers, router=routed.name, cost=None)
|
||||
(classifier_call,) = _posts(judge)
|
||||
_assert_classifier_call(classifier_call, prompt=prompt, endpoint=endpoint)
|
||||
assert len(_posts(complex_)) == 1 and _posts(simple) == ()
|
||||
|
|
@ -698,7 +686,7 @@ _FAILURES: Final = (
|
|||
_Failure("401", _failing(401), {}, "classifier_error", "MaskedHTTPStatusError", 0),
|
||||
_Failure(
|
||||
"malformed",
|
||||
_answering({"response": {"answers": {"tier": {"type": "choice", "choice": "COMPLEX", "confidence": 0.91}}}}),
|
||||
_answering({"answers": {"tier": {"type": "choice", "choice": "COMPLEX", "confidence": 0.91}}, "usage": _USAGE}),
|
||||
{},
|
||||
"invalid_response",
|
||||
"ValidationError",
|
||||
|
|
@ -708,17 +696,15 @@ _FAILURES: Final = (
|
|||
"unknown-tier",
|
||||
_answering(
|
||||
{
|
||||
"response": {
|
||||
"answers": {
|
||||
"tier": {
|
||||
"type": "choice",
|
||||
"choice": "GALAXY",
|
||||
"confidence": 0.91,
|
||||
"probabilities": {"GALAXY": 0.91},
|
||||
}
|
||||
"answers": {
|
||||
"tier": {
|
||||
"type": "choice",
|
||||
"choice": "GALAXY",
|
||||
"confidence": 0.91,
|
||||
"probabilities": {"GALAXY": 0.91},
|
||||
}
|
||||
},
|
||||
"metadata": _METADATA,
|
||||
"usage": _USAGE,
|
||||
}
|
||||
),
|
||||
{},
|
||||
|
|
@ -807,7 +793,7 @@ def test_the_default_jev_provider_still_posts_to_systemone_and_bills_from_the_co
|
|||
prompt: Final = _prompt()
|
||||
expected_cost: Final = _typesafe_cost()
|
||||
with (
|
||||
_wires(_answering(_TYPESAFE_ANSWER)) as (judge, simple, complex_),
|
||||
_wires(_answering({**_ANSWER, "model": _TYPESAFE_MODEL})) as (judge, simple, complex_),
|
||||
gateway.scenario() as scenario,
|
||||
):
|
||||
classifier: Final[dict[str, JsonValue]] = {
|
||||
|
|
@ -862,7 +848,7 @@ async def test_a_classifier_outage_mid_traffic_falls_back_and_recovers_with_ever
|
|||
before_prompts: Final = tuple(_prompt() for _ in calls)
|
||||
before: Final = await _burst(base_url, gateway.key, routed.name, calls, before_prompts)
|
||||
assert [item.cause for item in before] == ["jev_classifier"] * 12, before
|
||||
assert [item.classifier_cost for item in before] == [None] * 12, before
|
||||
assert [item.classifier_cost for item in before] == ["0.0"] * 12, before
|
||||
assert [item.text for item in before] == [_COMPLEX_TEXT] * 12, before
|
||||
assert sorted(json.loads(call.body)["state"] for call in _posts(judge)) == sorted(
|
||||
_state(prompt, None) for prompt in before_prompts
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ from pydantic import JsonValue
|
|||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class TranslationTestCase:
|
||||
"""One request through the proxy to a deployment in `proxy_config.yaml`: what the test sends to LiteLLM, the
|
||||
exact request the provider must receive, the fake provider's reply, and the exact response LiteLLM must return."""
|
||||
exact request the provider must receive, the fake provider's status and reply, and the exact response LiteLLM must
|
||||
return."""
|
||||
|
||||
scenario: str
|
||||
litellm_endpoint: str
|
||||
|
|
@ -15,6 +16,7 @@ class TranslationTestCase:
|
|||
expected_provider_endpoint: str
|
||||
expected_provider_headers: Mapping[str, str]
|
||||
expected_provider_request: Mapping[str, JsonValue]
|
||||
mock_provider_status_code: int = 200
|
||||
mock_provider_response: Mapping[str, JsonValue]
|
||||
expected_litellm_status_code: int = 200
|
||||
expected_litellm_response: Mapping[str, JsonValue]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,103 @@ from integration.translation.case import TranslationTestCase
|
|||
"""
|
||||
CLEF_TEST_CASE: Final = TranslationTestCase(
|
||||
scenario="basic",
|
||||
litellm_endpoint="/v1/decisions",
|
||||
litellm_request={
|
||||
"model": "cloudflare/@cf/cloudflare/clef",
|
||||
"input": "Ticket (billing): The export job hangs at 99% and never finishes",
|
||||
"questions": [
|
||||
{"type": "predicate", "name": "defect", "instructions": "Is this a defect?"},
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "severity",
|
||||
"instructions": "How severe is it?",
|
||||
"choices": [
|
||||
{"value": "low", "description": "cosmetic"},
|
||||
{"value": "high", "description": "blocks users"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "score",
|
||||
"name": "confidence",
|
||||
"instructions": "How sure are you?",
|
||||
"levels": [{"label": "unsure"}, {"label": "sure"}],
|
||||
},
|
||||
],
|
||||
"cache": {"no-cache": True},
|
||||
},
|
||||
expected_provider_endpoint="/ai/run/@cf/cloudflare/clef",
|
||||
expected_provider_headers={"authorization": "Bearer synthetic-cloudflare-key", "content-type": "application/json"},
|
||||
expected_provider_request={
|
||||
"model": "clef",
|
||||
"state": "Ticket (billing): The export job hangs at 99% and never finishes",
|
||||
"questions": {
|
||||
"defect": {"type": "noul", "instructions": "Is this a defect?"},
|
||||
"severity": {
|
||||
"type": "choice",
|
||||
"instructions": "How severe is it?",
|
||||
"criteria": {"low": "cosmetic", "high": "blocks users"},
|
||||
},
|
||||
"confidence": {"type": "score", "instructions": "How sure are you?", "criteria": ["unsure", "sure"]},
|
||||
},
|
||||
},
|
||||
mock_provider_response={
|
||||
"result": {
|
||||
"model": "clef",
|
||||
"answers": {
|
||||
"defect": {"type": "noul", "noul": 0.9345},
|
||||
"severity": {
|
||||
"type": "choice",
|
||||
"choice": "high",
|
||||
"confidence": 0.8067,
|
||||
"probabilities": {"low": 0.0509, "high": 0.9491},
|
||||
},
|
||||
"confidence": {
|
||||
"type": "score",
|
||||
"score": 0.9036,
|
||||
"confidence": 0.6515,
|
||||
"legend": {"0": "unsure", "1": "sure"},
|
||||
"probabilities": {"0": 0.0964, "1": 0.9036},
|
||||
},
|
||||
},
|
||||
"usage": {"input_tokens": 290, "output_tokens": 0},
|
||||
},
|
||||
"success": True,
|
||||
"errors": [],
|
||||
"messages": [],
|
||||
},
|
||||
expected_litellm_response={
|
||||
"model": "clef",
|
||||
"answers": [
|
||||
{"type": "predicate", "name": "defect", "probability": 0.9345},
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "severity",
|
||||
"choice": "high",
|
||||
"probabilities": [{"value": "low", "probability": 0.0509}, {"value": "high", "probability": 0.9491}],
|
||||
"confidence": 0.8067,
|
||||
},
|
||||
{
|
||||
"type": "score",
|
||||
"name": "confidence",
|
||||
"score": 0.9036,
|
||||
"probabilities": [
|
||||
{"value": 0, "label": "unsure", "probability": 0.0964},
|
||||
{"value": 1, "label": "sure", "probability": 0.9036},
|
||||
],
|
||||
"confidence": 0.6515,
|
||||
},
|
||||
],
|
||||
"usage": {
|
||||
"input_tokens": 290,
|
||||
"input_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 0},
|
||||
"output_tokens": 0,
|
||||
"output_tokens_details": {"reasoning_tokens": 0},
|
||||
"total_tokens": 290,
|
||||
},
|
||||
},
|
||||
)
|
||||
CLEF_SYSTEMONE_TEST_CASE: Final = TranslationTestCase(
|
||||
scenario="systemone",
|
||||
litellm_endpoint="/v1/systemone",
|
||||
litellm_request={
|
||||
"model": "cloudflare/@cf/cloudflare/clef",
|
||||
|
|
|
|||
175
tests/integration/translation/decisions/bases/hosted_vllm.py
Normal file
175
tests/integration/translation/decisions/bases/hosted_vllm.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import json
|
||||
from dataclasses import replace
|
||||
from typing import Final
|
||||
|
||||
from integration.translation.case import TranslationTestCase
|
||||
|
||||
"""Provider request and reply shape from a real vLLM serve (POST /v1/systemone, no auth). Mock reply captured live on 2026-10-09 from a vLLM CPU build of vllm main 702ce313f serving Qwen/Qwen3-0.6B.
|
||||
"""
|
||||
QWEN3_0_6B_TEST_CASE: Final = TranslationTestCase(
|
||||
scenario="basic",
|
||||
litellm_endpoint="/v1/systemone",
|
||||
litellm_request={
|
||||
"model": "hosted_vllm/Qwen/Qwen3-0.6B",
|
||||
"state": "Help! My payouts have been failing for 3 days!",
|
||||
"questions": {
|
||||
"is_urgent": {
|
||||
"type": "choice",
|
||||
"instructions": "Does this convey urgency?",
|
||||
"criteria": {"yes": "the message conveys urgency", "no": "the message does not convey urgency"},
|
||||
}
|
||||
},
|
||||
"cache": {"no-cache": True},
|
||||
},
|
||||
expected_provider_endpoint="/v1/systemone",
|
||||
expected_provider_headers={"content-type": "application/json"},
|
||||
expected_provider_request={
|
||||
"model": "Qwen/Qwen3-0.6B",
|
||||
"state": "Help! My payouts have been failing for 3 days!",
|
||||
"questions": {
|
||||
"is_urgent": {
|
||||
"type": "choice",
|
||||
"instructions": "Does this convey urgency?",
|
||||
"criteria": {"yes": "the message conveys urgency", "no": "the message does not convey urgency"},
|
||||
}
|
||||
},
|
||||
},
|
||||
mock_provider_response={
|
||||
"id": "decision-9c26c028d7f4457e",
|
||||
"object": "structured_decision",
|
||||
"created": 1791517765,
|
||||
"model": "Qwen/Qwen3-0.6B",
|
||||
"answers": {
|
||||
"is_urgent": {
|
||||
"type": "choice",
|
||||
"choice": "yes",
|
||||
"probabilities": {"yes": 0.9669140238756562, "no": 0.03308597612434374},
|
||||
"confidence": 0.9649145337527952,
|
||||
}
|
||||
},
|
||||
"usage": {"input_tokens": 61, "output_tokens": 1},
|
||||
"diagnostics": {"is_urgent": {"label_mass": 0.9979320910923947, "argmax_is_label": True}},
|
||||
},
|
||||
expected_litellm_response={
|
||||
"id": "decision-9c26c028d7f4457e",
|
||||
"object": "structured_decision",
|
||||
"created": 1791517765,
|
||||
"model": "Qwen/Qwen3-0.6B",
|
||||
"answers": {
|
||||
"is_urgent": {
|
||||
"type": "choice",
|
||||
"choice": "yes",
|
||||
"probabilities": {"yes": 0.9669140238756562, "no": 0.03308597612434374},
|
||||
"confidence": 0.9649145337527952,
|
||||
}
|
||||
},
|
||||
"usage": {"input_tokens": 61, "output_tokens": 1},
|
||||
"diagnostics": {"is_urgent": {"label_mass": 0.9979320910923947, "argmax_is_label": True}},
|
||||
},
|
||||
)
|
||||
|
||||
"""OpenAI Decisions client shape in, vLLM /v1/systemone body out. Mock reply captured live on 2026-10-09 from the same
|
||||
vLLM build.
|
||||
"""
|
||||
QWEN3_0_6B_DECISIONS_TEST_CASE: Final = replace(
|
||||
QWEN3_0_6B_TEST_CASE,
|
||||
scenario="decisions",
|
||||
litellm_endpoint="/v1/decisions",
|
||||
litellm_request={
|
||||
"model": "hosted_vllm/Qwen/Qwen3-0.6B",
|
||||
"input": "The checkout page crashed twice today",
|
||||
"questions": [
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "is_defect",
|
||||
"instructions": "Is this a defect?",
|
||||
"choices": [{"value": "yes"}, {"value": "no"}],
|
||||
}
|
||||
],
|
||||
"cache": {"no-cache": True},
|
||||
},
|
||||
expected_provider_request={
|
||||
"model": "Qwen/Qwen3-0.6B",
|
||||
"state": "The checkout page crashed twice today",
|
||||
"questions": {
|
||||
"is_defect": {"type": "choice", "instructions": "Is this a defect?", "criteria": {"yes": None, "no": None}}
|
||||
},
|
||||
},
|
||||
mock_provider_response={
|
||||
"id": "decision-91ce41627dbf016f",
|
||||
"object": "structured_decision",
|
||||
"created": 1791577835,
|
||||
"model": "Qwen/Qwen3-0.6B",
|
||||
"answers": {
|
||||
"is_defect": {
|
||||
"type": "choice",
|
||||
"choice": "yes",
|
||||
"probabilities": {"yes": 0.8670357477770336, "no": 0.13296425222296632},
|
||||
"confidence": 0.8669148648509282,
|
||||
}
|
||||
},
|
||||
"usage": {"input_tokens": 43, "output_tokens": 1},
|
||||
"diagnostics": {"is_defect": {"label_mass": 0.9998605790748358, "argmax_is_label": True}},
|
||||
},
|
||||
expected_litellm_response={
|
||||
"model": "Qwen/Qwen3-0.6B",
|
||||
"answers": [
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "is_defect",
|
||||
"choice": "yes",
|
||||
"probabilities": [
|
||||
{"value": "yes", "probability": 0.8670357477770336},
|
||||
{"value": "no", "probability": 0.13296425222296632},
|
||||
],
|
||||
"confidence": 0.8669148648509282,
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"input_tokens": 43,
|
||||
"input_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 0},
|
||||
"output_tokens": 1,
|
||||
"output_tokens_details": {"reasoning_tokens": 0},
|
||||
"total_tokens": 44,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
"""vLLM only scores `choice` questions, so an OpenAI `predicate` goes out as `noul` and comes back as vLLM's 400,
|
||||
which LiteLLM passes through as a 400.
|
||||
"""
|
||||
VLLM_NOUL_REJECTED: Final = {
|
||||
"error": {
|
||||
"message": "unknown question type 'noul'; supported: ['choice']",
|
||||
"type": "BadRequestError",
|
||||
"param": None,
|
||||
"code": 400,
|
||||
}
|
||||
}
|
||||
QWEN3_0_6B_PREDICATE_REJECTED_TEST_CASE: Final = replace(
|
||||
QWEN3_0_6B_DECISIONS_TEST_CASE,
|
||||
scenario="predicate_rejected",
|
||||
litellm_request={
|
||||
"model": "hosted_vllm/Qwen/Qwen3-0.6B",
|
||||
"input": "The checkout page crashed twice today",
|
||||
"questions": [{"type": "predicate", "name": "is_bug", "instructions": "Is this a bug?"}],
|
||||
"cache": {"no-cache": True},
|
||||
},
|
||||
expected_provider_request={
|
||||
"model": "Qwen/Qwen3-0.6B",
|
||||
"state": "The checkout page crashed twice today",
|
||||
"questions": {"is_bug": {"type": "noul", "instructions": "Is this a bug?"}},
|
||||
},
|
||||
mock_provider_status_code=400,
|
||||
mock_provider_response=VLLM_NOUL_REJECTED,
|
||||
expected_litellm_status_code=400,
|
||||
expected_litellm_response={
|
||||
"error": {
|
||||
"message": f"litellm.BadRequestError: Hosted_vllmException - {json.dumps(VLLM_NOUL_REJECTED)}\n\n"
|
||||
"LiteLLM: model group 'hosted_vllm/Qwen/Qwen3-0.6B' failed with the error above. No fallback was attempted.",
|
||||
"type": "invalid_request_error",
|
||||
"param": None,
|
||||
"code": "400",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -6,6 +6,100 @@ from integration.translation.case import TranslationTestCase
|
|||
"""
|
||||
TYPESAFE_JEV_1_13_TEST_CASE: Final = TranslationTestCase(
|
||||
scenario="basic",
|
||||
litellm_endpoint="/v1/decisions",
|
||||
litellm_request={
|
||||
"model": "openrouter/typesafe/jev-1.13",
|
||||
"input": "Ticket (billing): The export job hangs at 99% and never finishes",
|
||||
"questions": [
|
||||
{"type": "predicate", "name": "defect", "instructions": "Is this a defect?"},
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "severity",
|
||||
"instructions": "How severe is it?",
|
||||
"choices": [
|
||||
{"value": "low", "description": "cosmetic"},
|
||||
{"value": "high", "description": "blocks users"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "score",
|
||||
"name": "confidence",
|
||||
"instructions": "How sure are you?",
|
||||
"levels": [{"label": "unsure"}, {"label": "sure"}],
|
||||
},
|
||||
],
|
||||
"cache": {"no-cache": True},
|
||||
},
|
||||
expected_provider_endpoint="/alpha/decisions",
|
||||
expected_provider_headers={"authorization": "Bearer synthetic-openrouter-key", "content-type": "application/json"},
|
||||
expected_provider_request={
|
||||
"model": "typesafe/jev-1.13",
|
||||
"state": "Ticket (billing): The export job hangs at 99% and never finishes",
|
||||
"questions": {
|
||||
"defect": {"type": "noul", "instructions": "Is this a defect?"},
|
||||
"severity": {
|
||||
"type": "choice",
|
||||
"instructions": "How severe is it?",
|
||||
"criteria": {"low": "cosmetic", "high": "blocks users"},
|
||||
},
|
||||
"confidence": {"type": "score", "instructions": "How sure are you?", "criteria": ["unsure", "sure"]},
|
||||
},
|
||||
},
|
||||
mock_provider_response={
|
||||
"model": "typesafe/jev-1.13-20260917",
|
||||
"answers": {
|
||||
"defect": {"type": "noul", "noul": 0.81},
|
||||
"severity": {
|
||||
"type": "choice",
|
||||
"choice": "high",
|
||||
"confidence": 0.99,
|
||||
"probabilities": {"low": 0.01, "high": 0.99},
|
||||
},
|
||||
"confidence": {
|
||||
"type": "score",
|
||||
"score": 0.5,
|
||||
"confidence": 0,
|
||||
"legend": {"0": "unsure", "1": "sure"},
|
||||
"probabilities": {"0": 0.5, "1": 0.5},
|
||||
},
|
||||
},
|
||||
"usage": {"input_tokens": 377, "output_tokens": 62, "cost": 1.5834e-05},
|
||||
"id": "gen-dec-1791323839-GA15kY0nt34oiJ7srfki",
|
||||
"provider": "TypeSafe",
|
||||
},
|
||||
expected_litellm_response={
|
||||
"model": "typesafe/jev-1.13-20260917",
|
||||
"answers": [
|
||||
{"type": "predicate", "name": "defect", "probability": 0.81},
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "severity",
|
||||
"choice": "high",
|
||||
"probabilities": [{"value": "low", "probability": 0.01}, {"value": "high", "probability": 0.99}],
|
||||
"confidence": 0.99,
|
||||
},
|
||||
{
|
||||
"type": "score",
|
||||
"name": "confidence",
|
||||
"score": 0.5,
|
||||
"probabilities": [
|
||||
{"value": 0, "label": "unsure", "probability": 0.5},
|
||||
{"value": 1, "label": "sure", "probability": 0.5},
|
||||
],
|
||||
"confidence": 0,
|
||||
},
|
||||
],
|
||||
"usage": {
|
||||
"input_tokens": 377,
|
||||
"input_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 0},
|
||||
"output_tokens": 62,
|
||||
"output_tokens_details": {"reasoning_tokens": 0},
|
||||
"total_tokens": 439,
|
||||
},
|
||||
},
|
||||
)
|
||||
TYPESAFE_JEV_1_13_SYSTEMONE_TEST_CASE: Final = TranslationTestCase(
|
||||
scenario="systemone",
|
||||
litellm_endpoint="/v1/systemone",
|
||||
litellm_request={
|
||||
"model": "openrouter/typesafe/jev-1.13",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,101 @@ from integration.translation.case import TranslationTestCase
|
|||
"""
|
||||
PPLX_DECIDER_V1_27B_TEST_CASE: Final = TranslationTestCase(
|
||||
scenario="basic",
|
||||
litellm_endpoint="/v1/decisions",
|
||||
litellm_request={
|
||||
"model": "perplexity/pplx-decider-v1-27b",
|
||||
"input": "Ticket (billing): The export job hangs at 99% and never finishes",
|
||||
"questions": [
|
||||
{"type": "predicate", "name": "defect", "instructions": "Is this a defect?"},
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "severity",
|
||||
"instructions": "How severe is it?",
|
||||
"choices": [
|
||||
{"value": "low", "description": "cosmetic"},
|
||||
{"value": "high", "description": "blocks users"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "score",
|
||||
"name": "confidence",
|
||||
"instructions": "How sure are you?",
|
||||
"levels": [{"label": "unsure"}, {"label": "sure"}],
|
||||
},
|
||||
],
|
||||
"cache": {"no-cache": True},
|
||||
},
|
||||
expected_provider_endpoint="/v1/decisions",
|
||||
expected_provider_headers={"authorization": "Bearer synthetic-perplexity-key", "content-type": "application/json"},
|
||||
expected_provider_request={
|
||||
"model": "pplx-decider-v1-27b",
|
||||
"state": "Ticket (billing): The export job hangs at 99% and never finishes",
|
||||
"questions": {
|
||||
"defect": {"type": "noul", "instructions": "Is this a defect?"},
|
||||
"severity": {
|
||||
"type": "choice",
|
||||
"instructions": "How severe is it?",
|
||||
"criteria": {"low": "cosmetic", "high": "blocks users"},
|
||||
},
|
||||
"confidence": {"type": "score", "instructions": "How sure are you?", "criteria": ["unsure", "sure"]},
|
||||
},
|
||||
},
|
||||
mock_provider_response={
|
||||
"model": "pplx-decider-v1-27b",
|
||||
"answers": {
|
||||
"defect": {"type": "noul", "noul": 0.9989100737587077},
|
||||
"severity": {
|
||||
"type": "choice",
|
||||
"choice": "high",
|
||||
"confidence": 0.9964631215356778,
|
||||
"probabilities": {"low": 0.0017684392321610232, "high": 0.9982315607678389},
|
||||
},
|
||||
"confidence": {
|
||||
"type": "score",
|
||||
"score": 0.07367392327139817,
|
||||
"confidence": 0.8526521534572037,
|
||||
"legend": {"0": "unsure", "1": "sure"},
|
||||
"probabilities": {"0": 0.9263260767286018, "1": 0.07367392327139817},
|
||||
},
|
||||
},
|
||||
"usage": {"input_tokens": 318, "output_tokens": 3},
|
||||
},
|
||||
expected_litellm_response={
|
||||
"model": "pplx-decider-v1-27b",
|
||||
"answers": [
|
||||
{"type": "predicate", "name": "defect", "probability": 0.9989100737587077},
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "severity",
|
||||
"choice": "high",
|
||||
"probabilities": [
|
||||
{"value": "low", "probability": 0.0017684392321610232},
|
||||
{"value": "high", "probability": 0.9982315607678389},
|
||||
],
|
||||
"confidence": 0.9964631215356778,
|
||||
},
|
||||
{
|
||||
"type": "score",
|
||||
"name": "confidence",
|
||||
"score": 0.07367392327139817,
|
||||
"probabilities": [
|
||||
{"value": 0, "label": "unsure", "probability": 0.9263260767286018},
|
||||
{"value": 1, "label": "sure", "probability": 0.07367392327139817},
|
||||
],
|
||||
"confidence": 0.8526521534572037,
|
||||
},
|
||||
],
|
||||
"usage": {
|
||||
"input_tokens": 318,
|
||||
"input_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 0},
|
||||
"output_tokens": 3,
|
||||
"output_tokens_details": {"reasoning_tokens": 0},
|
||||
"total_tokens": 321,
|
||||
},
|
||||
},
|
||||
)
|
||||
PPLX_DECIDER_V1_27B_SYSTEMONE_TEST_CASE: Final = TranslationTestCase(
|
||||
scenario="systemone",
|
||||
litellm_endpoint="/v1/systemone",
|
||||
litellm_request={
|
||||
"model": "perplexity/pplx-decider-v1-27b",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,40 @@ from integration.translation.case import TranslationTestCase
|
|||
"""
|
||||
STRANDS_DECIDER_2B_HOBSON_V19_TEST_CASE: Final = TranslationTestCase(
|
||||
scenario="basic",
|
||||
litellm_endpoint="/v1/decisions",
|
||||
litellm_request={
|
||||
"model": "strands_decider/strands-decider-2B-hobson-v19",
|
||||
"input": "Help! My payouts have been failing for 3 days!",
|
||||
"questions": [{"type": "predicate", "name": "is_urgent", "instructions": "Does this convey urgency?"}],
|
||||
"cache": {"no-cache": True},
|
||||
},
|
||||
expected_provider_endpoint="/v1/systemone",
|
||||
expected_provider_headers={"content-type": "application/json"},
|
||||
expected_provider_request={
|
||||
"model": "strands-decider-2B-hobson-v19",
|
||||
"state": "Help! My payouts have been failing for 3 days!",
|
||||
"questions": {"is_urgent": {"type": "noul", "instructions": "Does this convey urgency?"}},
|
||||
},
|
||||
mock_provider_response={
|
||||
"model": "strands-decider-2B-hobson-v19",
|
||||
"answers": {"is_urgent": {"type": "noul", "noul": 0.8277}},
|
||||
"usage": {"input_tokens": 86, "output_tokens": 1},
|
||||
"latency_ms": 140.03,
|
||||
},
|
||||
expected_litellm_response={
|
||||
"model": "strands-decider-2B-hobson-v19",
|
||||
"answers": [{"type": "predicate", "name": "is_urgent", "probability": 0.8277}],
|
||||
"usage": {
|
||||
"input_tokens": 86,
|
||||
"input_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 0},
|
||||
"output_tokens": 1,
|
||||
"output_tokens_details": {"reasoning_tokens": 0},
|
||||
"total_tokens": 87,
|
||||
},
|
||||
},
|
||||
)
|
||||
STRANDS_DECIDER_2B_HOBSON_V19_SYSTEMONE_TEST_CASE: Final = TranslationTestCase(
|
||||
scenario="systemone",
|
||||
litellm_endpoint="/v1/systemone",
|
||||
litellm_request={
|
||||
"model": "strands_decider/strands-decider-2B-hobson-v19",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,98 @@ from integration.translation.case import TranslationTestCase
|
|||
"""
|
||||
JEV_1_13_0_TEST_CASE: Final = TranslationTestCase(
|
||||
scenario="basic",
|
||||
litellm_endpoint="/v1/decisions",
|
||||
litellm_request={
|
||||
"model": "typesafe/jev-1.13.0",
|
||||
"input": "Ticket (billing): The export job hangs at 99% and never finishes",
|
||||
"questions": [
|
||||
{"type": "predicate", "name": "defect", "instructions": "Is this a defect?"},
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "severity",
|
||||
"instructions": "How severe is it?",
|
||||
"choices": [
|
||||
{"value": "low", "description": "cosmetic"},
|
||||
{"value": "high", "description": "blocks users"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "score",
|
||||
"name": "confidence",
|
||||
"instructions": "How sure are you?",
|
||||
"levels": [{"label": "unsure"}, {"label": "sure"}],
|
||||
},
|
||||
],
|
||||
"cache": {"no-cache": True},
|
||||
},
|
||||
expected_provider_endpoint="/v1/systemone",
|
||||
expected_provider_headers={"authorization": "Bearer synthetic-typesafe-key", "content-type": "application/json"},
|
||||
expected_provider_request={
|
||||
"model": "jev-1.13.0",
|
||||
"state": "Ticket (billing): The export job hangs at 99% and never finishes",
|
||||
"questions": {
|
||||
"defect": {"type": "noul", "instructions": "Is this a defect?"},
|
||||
"severity": {
|
||||
"type": "choice",
|
||||
"instructions": "How severe is it?",
|
||||
"criteria": {"low": "cosmetic", "high": "blocks users"},
|
||||
},
|
||||
"confidence": {"type": "score", "instructions": "How sure are you?", "criteria": ["unsure", "sure"]},
|
||||
},
|
||||
},
|
||||
mock_provider_response={
|
||||
"model": "jev-1.13.0",
|
||||
"answers": {
|
||||
"defect": {"type": "noul", "noul": 0.78},
|
||||
"severity": {
|
||||
"type": "choice",
|
||||
"choice": "high",
|
||||
"confidence": 0.99,
|
||||
"probabilities": {"low": 0.01, "high": 0.99},
|
||||
},
|
||||
"confidence": {
|
||||
"type": "score",
|
||||
"score": 0.51,
|
||||
"confidence": 0.03,
|
||||
"legend": {"0": "unsure", "1": "sure"},
|
||||
"probabilities": {"0": 0.49, "1": 0.51},
|
||||
},
|
||||
},
|
||||
"usage": {"input_tokens": 377, "output_tokens": 62},
|
||||
},
|
||||
expected_litellm_response={
|
||||
"model": "jev-1.13.0",
|
||||
"answers": [
|
||||
{"type": "predicate", "name": "defect", "probability": 0.78},
|
||||
{
|
||||
"type": "choice",
|
||||
"name": "severity",
|
||||
"choice": "high",
|
||||
"probabilities": [{"value": "low", "probability": 0.01}, {"value": "high", "probability": 0.99}],
|
||||
"confidence": 0.99,
|
||||
},
|
||||
{
|
||||
"type": "score",
|
||||
"name": "confidence",
|
||||
"score": 0.51,
|
||||
"probabilities": [
|
||||
{"value": 0, "label": "unsure", "probability": 0.49},
|
||||
{"value": 1, "label": "sure", "probability": 0.51},
|
||||
],
|
||||
"confidence": 0.03,
|
||||
},
|
||||
],
|
||||
"usage": {
|
||||
"input_tokens": 377,
|
||||
"input_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 0},
|
||||
"output_tokens": 62,
|
||||
"output_tokens_details": {"reasoning_tokens": 0},
|
||||
"total_tokens": 439,
|
||||
},
|
||||
},
|
||||
)
|
||||
JEV_1_13_0_SYSTEMONE_TEST_CASE: Final = TranslationTestCase(
|
||||
scenario="systemone",
|
||||
litellm_endpoint="/v1/systemone",
|
||||
litellm_request={
|
||||
"model": "typesafe/jev-1.13.0",
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import pytest
|
|||
from integration._support.client import Gateway
|
||||
from integration._support.provider import SharedProvider
|
||||
from integration.translation.case import TranslationTestCase
|
||||
from integration.translation.decisions.bases.cloudflare import CLEF_TEST_CASE
|
||||
from integration.translation.decisions.bases.cloudflare import CLEF_TEST_CASE, CLEF_SYSTEMONE_TEST_CASE
|
||||
from integration.translation.runner import assert_translation
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", [CLEF_TEST_CASE], ids=lambda case: case.id)
|
||||
@pytest.mark.parametrize("case", [CLEF_TEST_CASE, CLEF_SYSTEMONE_TEST_CASE], ids=lambda case: case.id)
|
||||
def test_decisions_basic_cloudflare(case: TranslationTestCase, gateway: Gateway, provider: SharedProvider) -> None:
|
||||
assert_translation(case, gateway, provider)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import pytest
|
||||
from integration._support.client import Gateway
|
||||
from integration._support.provider import SharedProvider
|
||||
from integration.translation.case import TranslationTestCase
|
||||
from integration.translation.decisions.bases.hosted_vllm import (
|
||||
QWEN3_0_6B_DECISIONS_TEST_CASE,
|
||||
QWEN3_0_6B_PREDICATE_REJECTED_TEST_CASE,
|
||||
QWEN3_0_6B_TEST_CASE,
|
||||
)
|
||||
from integration.translation.runner import assert_translation
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
[QWEN3_0_6B_TEST_CASE, QWEN3_0_6B_DECISIONS_TEST_CASE, QWEN3_0_6B_PREDICATE_REJECTED_TEST_CASE],
|
||||
ids=lambda case: case.id,
|
||||
)
|
||||
def test_decisions_basic_hosted_vllm(case: TranslationTestCase, gateway: Gateway, provider: SharedProvider) -> None:
|
||||
assert_translation(case, gateway, provider)
|
||||
|
|
@ -2,10 +2,15 @@ import pytest
|
|||
from integration._support.client import Gateway
|
||||
from integration._support.provider import SharedProvider
|
||||
from integration.translation.case import TranslationTestCase
|
||||
from integration.translation.decisions.bases.openrouter import TYPESAFE_JEV_1_13_TEST_CASE
|
||||
from integration.translation.decisions.bases.openrouter import (
|
||||
TYPESAFE_JEV_1_13_TEST_CASE,
|
||||
TYPESAFE_JEV_1_13_SYSTEMONE_TEST_CASE,
|
||||
)
|
||||
from integration.translation.runner import assert_translation
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", [TYPESAFE_JEV_1_13_TEST_CASE], ids=lambda case: case.id)
|
||||
@pytest.mark.parametrize(
|
||||
"case", [TYPESAFE_JEV_1_13_TEST_CASE, TYPESAFE_JEV_1_13_SYSTEMONE_TEST_CASE], ids=lambda case: case.id
|
||||
)
|
||||
def test_decisions_basic_openrouter(case: TranslationTestCase, gateway: Gateway, provider: SharedProvider) -> None:
|
||||
assert_translation(case, gateway, provider)
|
||||
|
|
|
|||
|
|
@ -2,10 +2,15 @@ import pytest
|
|||
from integration._support.client import Gateway
|
||||
from integration._support.provider import SharedProvider
|
||||
from integration.translation.case import TranslationTestCase
|
||||
from integration.translation.decisions.bases.perplexity import PPLX_DECIDER_V1_27B_TEST_CASE
|
||||
from integration.translation.decisions.bases.perplexity import (
|
||||
PPLX_DECIDER_V1_27B_TEST_CASE,
|
||||
PPLX_DECIDER_V1_27B_SYSTEMONE_TEST_CASE,
|
||||
)
|
||||
from integration.translation.runner import assert_translation
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", [PPLX_DECIDER_V1_27B_TEST_CASE], ids=lambda case: case.id)
|
||||
@pytest.mark.parametrize(
|
||||
"case", [PPLX_DECIDER_V1_27B_TEST_CASE, PPLX_DECIDER_V1_27B_SYSTEMONE_TEST_CASE], ids=lambda case: case.id
|
||||
)
|
||||
def test_decisions_basic_perplexity(case: TranslationTestCase, gateway: Gateway, provider: SharedProvider) -> None:
|
||||
assert_translation(case, gateway, provider)
|
||||
|
|
|
|||
|
|
@ -2,10 +2,17 @@ import pytest
|
|||
from integration._support.client import Gateway
|
||||
from integration._support.provider import SharedProvider
|
||||
from integration.translation.case import TranslationTestCase
|
||||
from integration.translation.decisions.bases.strands_decider import STRANDS_DECIDER_2B_HOBSON_V19_TEST_CASE
|
||||
from integration.translation.decisions.bases.strands_decider import (
|
||||
STRANDS_DECIDER_2B_HOBSON_V19_TEST_CASE,
|
||||
STRANDS_DECIDER_2B_HOBSON_V19_SYSTEMONE_TEST_CASE,
|
||||
)
|
||||
from integration.translation.runner import assert_translation
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", [STRANDS_DECIDER_2B_HOBSON_V19_TEST_CASE], ids=lambda case: case.id)
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
[STRANDS_DECIDER_2B_HOBSON_V19_TEST_CASE, STRANDS_DECIDER_2B_HOBSON_V19_SYSTEMONE_TEST_CASE],
|
||||
ids=lambda case: case.id,
|
||||
)
|
||||
def test_decisions_basic_strands_decider(case: TranslationTestCase, gateway: Gateway, provider: SharedProvider) -> None:
|
||||
assert_translation(case, gateway, provider)
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import pytest
|
|||
from integration._support.client import Gateway
|
||||
from integration._support.provider import SharedProvider
|
||||
from integration.translation.case import TranslationTestCase
|
||||
from integration.translation.decisions.bases.typesafe import JEV_1_13_0_TEST_CASE
|
||||
from integration.translation.decisions.bases.typesafe import JEV_1_13_0_TEST_CASE, JEV_1_13_0_SYSTEMONE_TEST_CASE
|
||||
from integration.translation.runner import assert_translation
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", [JEV_1_13_0_TEST_CASE], ids=lambda case: case.id)
|
||||
@pytest.mark.parametrize("case", [JEV_1_13_0_TEST_CASE, JEV_1_13_0_SYSTEMONE_TEST_CASE], ids=lambda case: case.id)
|
||||
def test_decisions_basic_typesafe(case: TranslationTestCase, gateway: Gateway, provider: SharedProvider) -> None:
|
||||
assert_translation(case, gateway, provider)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ TRANSPORT_HEADERS: Final = frozenset({"host", "accept", "accept-encoding", "conn
|
|||
|
||||
|
||||
def assert_translation(case: TranslationTestCase, gateway: Gateway, provider: SharedProvider) -> None:
|
||||
provider.expect(Reply(body=json.dumps(case.mock_provider_response).encode()))
|
||||
provider.expect(Reply(status=case.mock_provider_status_code, body=json.dumps(case.mock_provider_response).encode()))
|
||||
response: Final = gateway.request("POST", case.litellm_endpoint, case.litellm_request)
|
||||
received: Final = provider.received()
|
||||
assert [(request.method, request.target) for request in received] == [("POST", case.expected_provider_endpoint)]
|
||||
|
|
|
|||
|
|
@ -45,17 +45,25 @@ import litellm # noqa: E402 # litellm reads LITELLM_LOCAL_MODEL_COST_MAP at im
|
|||
import litellm.router as litellm_router_module # noqa: E402 # same import-time dependency
|
||||
import litellm.utils as litellm_utils_module # noqa: E402 # same import-time dependency
|
||||
from litellm._logging import ALL_LOGGERS # noqa: E402 # same import-time dependency
|
||||
from litellm.anthropic_beta_headers_manager import (
|
||||
reload_beta_headers_config, # noqa: E402 # same import-time dependency
|
||||
)
|
||||
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER # noqa: E402 # same import-time dependency
|
||||
from litellm.anthropic_beta_headers_manager import reload_beta_headers_config # noqa: E402 # same import-time dependency
|
||||
from litellm.litellm_core_utils.prompt_templates import factory as prompt_factory_module # noqa: E402 # same import-time dependency
|
||||
from litellm.litellm_core_utils.prompt_templates import (
|
||||
factory as prompt_factory_module, # noqa: E402 # same import-time dependency
|
||||
)
|
||||
from litellm.litellm_core_utils.prompt_templates import ( # noqa: E402 # same import-time dependency
|
||||
image_handling as image_handling_module,
|
||||
)
|
||||
from litellm.llms.gemini.chat import transformation as gemini_chat_transformation_module # noqa: E402 # same import-time dependency
|
||||
from litellm.llms.custom_httpx.async_client_cleanup import ( # noqa: E402 # same import-time dependency
|
||||
close_litellm_async_clients,
|
||||
)
|
||||
from litellm.proxy.db import tool_registry_writer as tool_registry_writer_module # noqa: E402 # same import-time dependency
|
||||
from litellm.llms.gemini.chat import (
|
||||
transformation as gemini_chat_transformation_module, # noqa: E402 # same import-time dependency
|
||||
)
|
||||
from litellm.proxy.db import (
|
||||
tool_registry_writer as tool_registry_writer_module, # noqa: E402 # same import-time dependency
|
||||
)
|
||||
|
||||
LOOPBACK_HOSTS: Final = ["127.0.0.1", "::1", "localhost"]
|
||||
AMBIENT_AZURE_CREDENTIAL_ENV_VARS: Final = (
|
||||
|
|
@ -159,6 +167,31 @@ def _run_coroutine_if_needed(result: object) -> None:
|
|||
loop.create_task(coroutine)
|
||||
|
||||
|
||||
async def _complete_test_logging() -> None:
|
||||
await GLOBAL_LOGGING_WORKER.flush()
|
||||
await GLOBAL_LOGGING_WORKER.stop()
|
||||
|
||||
|
||||
def _flush_completed_test_logging() -> None:
|
||||
queue: Final = GLOBAL_LOGGING_WORKER._queue
|
||||
if queue is None or queue._unfinished_tasks == 0:
|
||||
return
|
||||
worker_loop: Final = GLOBAL_LOGGING_WORKER._bound_loop
|
||||
if worker_loop is None or worker_loop.is_closed():
|
||||
_run_coroutine_if_needed(_complete_test_logging())
|
||||
return
|
||||
if worker_loop.is_running():
|
||||
asyncio.run_coroutine_threadsafe(_complete_test_logging(), worker_loop).result()
|
||||
return
|
||||
worker_loop.run_until_complete(_complete_test_logging())
|
||||
|
||||
|
||||
@pytest.hookimpl(hookwrapper=True)
|
||||
def pytest_runtest_call() -> Iterator[None]:
|
||||
yield
|
||||
_flush_completed_test_logging()
|
||||
|
||||
|
||||
def _close_handler_if_needed(handler: object) -> None:
|
||||
close: Final = getattr(handler, "close", None)
|
||||
if not callable(close):
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
from concurrent.futures import wait
|
||||
from collections.abc import Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
|
@ -9,10 +11,16 @@ from typing import Final
|
|||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from botocore.auth import SigV4Auth
|
||||
from botocore.awsrequest import AWSRequest
|
||||
from botocore.credentials import Credentials
|
||||
|
||||
import litellm
|
||||
from litellm.cost_calculator import get_response_cost_from_hidden_params
|
||||
from litellm.constants import AWS_SIGNING_MAX_THREADS
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
||||
from litellm.llms.bedrock.base_aws_llm import AWS_SIGNING_EXECUTOR
|
||||
from litellm.types.decisions import (
|
||||
ChoiceAnswer,
|
||||
DecisionsResponse,
|
||||
|
|
@ -70,7 +78,7 @@ _STRANDS_RESPONSE: Final[Mapping[str, object]] = {
|
|||
"latency_ms": 3722.17,
|
||||
}
|
||||
_DATABRICKS_ENDPOINT: Final = "databricks-openjev-qwen35-4b"
|
||||
_DATABRICKS_ENDPOINT_RESPONSE: Final[Mapping[str, object]] = {
|
||||
_DATABRICKS_RESPONSE: Final[Mapping[str, object]] = {
|
||||
"model": "/mosaicml/local_model",
|
||||
"answers": {
|
||||
"is_defect": {"type": "noul", "noul": 0.9},
|
||||
|
|
@ -84,7 +92,7 @@ _DATABRICKS_ENDPOINT_RESPONSE: Final[Mapping[str, object]] = {
|
|||
"usage": {"input_tokens": 214, "output_tokens": 0},
|
||||
}
|
||||
_DATABRICKS_ROUTE: Final = "https://workspace.example/api/2.0/ai-functions/ai-decide"
|
||||
_DATABRICKS_RESPONSE: Final[Mapping[str, object]] = {
|
||||
_DATABRICKS_AI_DECIDE_RESPONSE: Final[Mapping[str, object]] = {
|
||||
"response": {
|
||||
"answers": {
|
||||
"is_defect": {"type": "noul", "probability": 0.9},
|
||||
|
|
@ -170,7 +178,7 @@ async def test_adecisions_sends_the_provider_wire_contract(
|
|||
) -> None:
|
||||
route: Final = respx_mock.post(url).respond(json=_RESPONSE)
|
||||
|
||||
response: Final = await litellm.adecisions(
|
||||
response: Final = await litellm.asystemone(
|
||||
model=model,
|
||||
state={"source": "unit-test"},
|
||||
questions=_QUESTIONS,
|
||||
|
|
@ -232,7 +240,7 @@ async def test_router_dispatches_typesafe_decisions_without_api_base(
|
|||
)
|
||||
upstream: Final = respx_mock.post("https://api.typesafe.ai/v1/systemone").respond(json=_RESPONSE)
|
||||
|
||||
response: Final = await router.adecisions(
|
||||
response: Final = await router.asystemone(
|
||||
model="jev",
|
||||
state="router-test",
|
||||
questions={
|
||||
|
|
@ -263,7 +271,7 @@ async def test_router_dispatches_typesafe_decisions_without_api_base(
|
|||
def test_decisions_uses_the_same_wire_contract_for_sync_calls(respx_mock: respx.MockRouter) -> None:
|
||||
route: Final = respx_mock.post("https://api.perplexity.ai/v1/decisions").respond(json=_RESPONSE)
|
||||
|
||||
response: Final = litellm.decisions(
|
||||
response: Final = litellm.systemone(
|
||||
model="perplexity/pplx-decider-v1-27b",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
|
|
@ -283,7 +291,7 @@ def test_openrouter_response_keeps_provider_fields(respx_mock: respx.MockRouter)
|
|||
}
|
||||
respx_mock.post("https://openrouter.ai/api/alpha/decisions").respond(json=payload)
|
||||
|
||||
response: Final = litellm.decisions(
|
||||
response: Final = litellm.systemone(
|
||||
model="openrouter/typesafe/jev-1.13",
|
||||
state="review",
|
||||
questions=_QUESTIONS,
|
||||
|
|
@ -371,7 +379,7 @@ async def test_decisions_cost_is_in_standard_logging_object(respx_mock: respx.Mo
|
|||
litellm.callbacks = [recording_logger]
|
||||
|
||||
try:
|
||||
await litellm.adecisions(
|
||||
await litellm.asystemone(
|
||||
model="perplexity/pplx-decider-v1-27b",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
|
|
@ -396,7 +404,7 @@ async def test_decisions_cost_is_in_standard_logging_object(respx_mock: respx.Mo
|
|||
@pytest.mark.asyncio
|
||||
async def test_unknown_provider_is_rejected_before_http(respx_mock: respx.MockRouter) -> None:
|
||||
with pytest.raises(litellm.BadRequestError, match="LLM Provider NOT provided"):
|
||||
await litellm.adecisions(
|
||||
await litellm.asystemone(
|
||||
model="unknown/jev-1.13",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
|
|
@ -409,7 +417,7 @@ async def test_unknown_provider_is_rejected_before_http(respx_mock: respx.MockRo
|
|||
@pytest.mark.asyncio
|
||||
async def test_provider_without_decisions_support_is_rejected_before_http(respx_mock: respx.MockRouter) -> None:
|
||||
with pytest.raises(litellm.BadRequestError, match=r"Unknown Decisions provider 'anthropic'\. Supported providers"):
|
||||
await litellm.adecisions(
|
||||
await litellm.asystemone(
|
||||
model="anthropic/claude-sonnet-4-5",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
|
|
@ -423,7 +431,7 @@ async def test_provider_without_decisions_support_is_rejected_before_http(respx_
|
|||
async def test_empty_custom_provider_falls_back_to_the_model_prefix(respx_mock: respx.MockRouter) -> None:
|
||||
route: Final = respx_mock.post("https://api.perplexity.ai/v1/decisions").respond(json=_RESPONSE)
|
||||
|
||||
response: Final = await litellm.adecisions(
|
||||
response: Final = await litellm.asystemone(
|
||||
model="perplexity/pplx-decider-v1-27b",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
|
|
@ -443,7 +451,7 @@ async def test_upstream_reply_without_answers_is_a_server_error(respx_mock: resp
|
|||
)
|
||||
|
||||
with pytest.raises(litellm.InternalServerError, match="unexpected response"):
|
||||
await litellm.adecisions(
|
||||
await litellm.asystemone(
|
||||
model="perplexity/pplx-decider-v1-27b",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
|
|
@ -471,7 +479,7 @@ async def test_router_sends_the_openrouter_deployment_key_when_the_provider_is_a
|
|||
)
|
||||
upstream: Final = respx_mock.post("https://egress.example/openrouter/alpha/decisions").respond(json=_RESPONSE)
|
||||
|
||||
await router.adecisions(
|
||||
await router.asystemone(
|
||||
model="jev",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
|
|
@ -485,7 +493,7 @@ async def test_router_sends_the_openrouter_deployment_key_when_the_provider_is_a
|
|||
@pytest.mark.asyncio
|
||||
async def test_invalid_question_is_rejected_before_http(respx_mock: respx.MockRouter) -> None:
|
||||
with pytest.raises(litellm.BadRequestError, match="Invalid Decisions request"):
|
||||
await litellm.adecisions(
|
||||
await litellm.asystemone(
|
||||
model="perplexity/pplx-decider-v1-27b",
|
||||
state="review",
|
||||
questions={"sentiment": {"type": "choice"}},
|
||||
|
|
@ -502,7 +510,7 @@ def test_upstream_bad_request_maps_to_litellm_error(respx_mock: respx.MockRouter
|
|||
)
|
||||
|
||||
with pytest.raises(litellm.BadRequestError):
|
||||
litellm.decisions(
|
||||
litellm.systemone(
|
||||
model="perplexity/pplx-decider-v1-27b",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
|
|
@ -516,7 +524,7 @@ def test_unreachable_upstream_maps_to_a_connection_error(respx_mock: respx.MockR
|
|||
)
|
||||
|
||||
with pytest.raises(litellm.APIConnectionError, match="PerplexityException - Cannot connect to host"):
|
||||
litellm.decisions(
|
||||
litellm.systemone(
|
||||
model="perplexity/pplx-decider-v1-27b",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
|
|
@ -532,7 +540,7 @@ def test_server_key_is_sent_to_an_explicit_api_base(
|
|||
monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False)
|
||||
route: Final = respx_mock.post("https://egress.example/perplexity/v1/decisions").respond(json=_RESPONSE)
|
||||
|
||||
litellm.decisions(
|
||||
litellm.systemone(
|
||||
model="perplexity/pplx-decider-v1-27b",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
|
|
@ -562,7 +570,7 @@ async def test_cloudflare_clef_resolves_model_and_response_envelope(
|
|||
"https://api.cloudflare.com/client/v4/accounts/acct/ai/run/@cf/cloudflare/clef"
|
||||
).respond(json=response_body)
|
||||
|
||||
response: Final = await litellm.adecisions(
|
||||
response: Final = await litellm.asystemone(
|
||||
model=model,
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
|
|
@ -592,7 +600,7 @@ async def test_cloudflare_clef_flash_uses_flash_endpoint_and_request_model(
|
|||
"https://api.cloudflare.com/client/v4/accounts/acct/ai/run/@cf/cloudflare/clef-flash"
|
||||
).respond(json=_RESPONSE)
|
||||
|
||||
await litellm.adecisions(
|
||||
await litellm.asystemone(
|
||||
model="cloudflare/clef-flash",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
|
|
@ -614,7 +622,7 @@ async def test_cloudflare_api_base_from_env_uses_workers_ai_run_path(
|
|||
"https://api.cloudflare.com/client/v4/accounts/acct/ai/run/@cf/cloudflare/clef"
|
||||
).respond(json=_RESPONSE)
|
||||
|
||||
await litellm.adecisions(
|
||||
await litellm.asystemone(
|
||||
model="cloudflare/clef",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
|
|
@ -633,7 +641,7 @@ async def test_cloudflare_requires_account_id_or_api_base_before_http(
|
|||
monkeypatch.setenv("CLOUDFLARE_API_KEY", "cloudflare-key")
|
||||
|
||||
with pytest.raises(litellm.BadRequestError, match="Missing CLOUDFLARE_ACCOUNT_ID - set CLOUDFLARE_ACCOUNT_ID"):
|
||||
await litellm.adecisions(
|
||||
await litellm.asystemone(
|
||||
model="cloudflare/clef",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
|
|
@ -654,7 +662,7 @@ async def test_cloudflare_clef_cost_uses_the_model_cost_map(
|
|||
json=_RESPONSE
|
||||
)
|
||||
|
||||
response: Final = await litellm.adecisions(
|
||||
response: Final = await litellm.asystemone(
|
||||
model="cloudflare/clef",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
|
|
@ -679,7 +687,7 @@ async def test_strands_decider_requires_api_base_before_http(
|
|||
monkeypatch.delenv("STRANDS_DECIDER_API_KEY", raising=False)
|
||||
|
||||
with pytest.raises(litellm.BadRequestError, match="api_base is required"):
|
||||
await litellm.adecisions(
|
||||
await litellm.asystemone(
|
||||
model="strands_decider/strands-decider-2B-hobson-v19",
|
||||
state="review",
|
||||
questions={"severity": {"type": "score", "criteria": ["none", "low", "high"]}},
|
||||
|
|
@ -697,7 +705,7 @@ async def test_strands_decider_without_key_preserves_response_extras(
|
|||
monkeypatch.delenv("STRANDS_DECIDER_API_KEY", raising=False)
|
||||
route: Final = respx_mock.post("https://strands.example/v1/systemone").respond(json=_STRANDS_RESPONSE)
|
||||
|
||||
response: Final = await litellm.adecisions(
|
||||
response: Final = await litellm.asystemone(
|
||||
model="strands_decider/strands-decider-2B-hobson-v19",
|
||||
state="review",
|
||||
questions={"severity": {"type": "score", "criteria": ["none", "low", "high"]}},
|
||||
|
|
@ -712,6 +720,33 @@ async def test_strands_decider_without_key_preserves_response_extras(
|
|||
assert severity.legend == {"0": "none", "1": "low", "2": "high"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strands_decider_plain_api_base_answers_while_every_aws_signing_thread_is_busy(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
) -> None:
|
||||
monkeypatch.delenv("STRANDS_DECIDER_API_KEY", raising=False)
|
||||
respx_mock.post("https://strands.example/v1/systemone").respond(json=_STRANDS_RESPONSE)
|
||||
release: Final = threading.Event()
|
||||
busy: Final = tuple(AWS_SIGNING_EXECUTOR.submit(release.wait) for _ in range(AWS_SIGNING_MAX_THREADS))
|
||||
|
||||
try:
|
||||
response: Final = await asyncio.wait_for(
|
||||
litellm.asystemone(
|
||||
model="strands_decider/strands-decider-2B-hobson-v19",
|
||||
state="review",
|
||||
questions={"severity": {"type": "score", "criteria": ["none", "low", "high"]}},
|
||||
api_base="https://strands.example",
|
||||
),
|
||||
timeout=5,
|
||||
)
|
||||
finally:
|
||||
release.set()
|
||||
wait(busy)
|
||||
|
||||
assert isinstance(response.answers["severity"], ScoreAnswer)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strands_decider_uses_key_from_matching_environment_base(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
|
@ -721,7 +756,7 @@ async def test_strands_decider_uses_key_from_matching_environment_base(
|
|||
monkeypatch.setenv("STRANDS_DECIDER_API_KEY", "strands-key")
|
||||
route: Final = respx_mock.post("https://strands.example/v1/systemone").respond(json=_STRANDS_RESPONSE)
|
||||
|
||||
await litellm.adecisions(
|
||||
await litellm.asystemone(
|
||||
model="strands_decider/strands-decider-2B-hobson-v19",
|
||||
state="review",
|
||||
questions={"severity": {"type": "score", "criteria": ["none", "low", "high"]}},
|
||||
|
|
@ -753,7 +788,7 @@ async def test_strands_decider_provider_resolution_and_router_dispatch(
|
|||
)
|
||||
route: Final = respx_mock.post("https://strands.example/v1/systemone").respond(json=_STRANDS_RESPONSE)
|
||||
|
||||
response: Final = await router.adecisions(
|
||||
response: Final = await router.asystemone(
|
||||
model="strands",
|
||||
state="review",
|
||||
questions={"severity": {"type": "score", "criteria": ["none", "low", "high"]}},
|
||||
|
|
@ -764,6 +799,463 @@ async def test_strands_decider_provider_resolution_and_router_dispatch(
|
|||
assert response.model == _STRANDS_RESPONSE["model"]
|
||||
|
||||
|
||||
_RUNTIME_ARN: Final = "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/qa_decider-AbC123xyz0"
|
||||
_RUNTIME_URL: Final = (
|
||||
"https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/"
|
||||
"arn%3Aaws%3Abedrock-agentcore%3Aus-east-1%3A123456789012%3Aruntime%2Fqa_decider-AbC123xyz0/invocations"
|
||||
)
|
||||
_RUNTIME_CREDENTIALS: Final = Credentials(access_key="AKIDEXAMPLE", secret_key="example-secret")
|
||||
_SESSION_HEADER: Final = "x-amzn-bedrock-agentcore-runtime-session-id"
|
||||
|
||||
|
||||
def _sigv4_verifies(request: httpx.Request) -> bool:
|
||||
authorization: Final = request.headers["authorization"]
|
||||
signed_names: Final = authorization.split("SignedHeaders=")[1].split(",")[0].split(";")
|
||||
replayed: Final = AWSRequest(
|
||||
method=request.method,
|
||||
url=str(request.url),
|
||||
data=request.content,
|
||||
headers={name: request.headers[name] for name in signed_names},
|
||||
)
|
||||
replayed.context["timestamp"] = request.headers["x-amz-date"]
|
||||
auth: Final = SigV4Auth(_RUNTIME_CREDENTIALS, "bedrock-agentcore", "us-east-1")
|
||||
string_to_sign: Final = auth.string_to_sign(replayed, auth.canonical_request(replayed))
|
||||
return authorization.endswith(f"Signature={auth.signature(string_to_sign, replayed)}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_async", [True, False])
|
||||
@pytest.mark.asyncio
|
||||
async def test_strands_decider_runtime_arn_sends_a_sigv4_signed_invoke_agent_runtime_call(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
use_async: bool,
|
||||
) -> None:
|
||||
monkeypatch.delenv("STRANDS_DECIDER_API_KEY", raising=False)
|
||||
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
|
||||
route: Final = respx_mock.post(_RUNTIME_URL).respond(json=_STRANDS_RESPONSE)
|
||||
call_args: Final[Mapping[str, object]] = {
|
||||
"model": "strands_decider/strands-decider-2B-hobson-v19",
|
||||
"state": "review",
|
||||
"questions": {"severity": {"type": "score", "criteria": ["none", "low", "high"]}},
|
||||
"api_base": _RUNTIME_ARN,
|
||||
"aws_access_key_id": _RUNTIME_CREDENTIALS.access_key,
|
||||
"aws_secret_access_key": _RUNTIME_CREDENTIALS.secret_key,
|
||||
}
|
||||
|
||||
response: Final = await litellm.asystemone(**call_args) if use_async else litellm.systemone(**call_args)
|
||||
|
||||
assert route.call_count == 1
|
||||
sent: Final = route.calls[0].request
|
||||
assert _sigv4_verifies(sent)
|
||||
assert json.loads(sent.content)["model"] == "strands-decider-2B-hobson-v19"
|
||||
assert sent.headers[_SESSION_HEADER].startswith("litellm-decider-")
|
||||
assert response.model == _STRANDS_RESPONSE["model"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strands_decider_runtime_arn_router_deployment_answers_openai_format_calls(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
) -> None:
|
||||
monkeypatch.delenv("STRANDS_DECIDER_API_KEY", raising=False)
|
||||
router: Final = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "strands",
|
||||
"litellm_params": {
|
||||
"model": "strands_decider/strands-decider-2B-hobson-v19",
|
||||
"api_base": _RUNTIME_ARN,
|
||||
"aws_access_key_id": _RUNTIME_CREDENTIALS.access_key,
|
||||
"aws_secret_access_key": _RUNTIME_CREDENTIALS.secret_key,
|
||||
"aws_region_name": "eu-west-1",
|
||||
"agentcore_runtime_session_id": "pooled-session-0123456789abcdef0123456",
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
route: Final = respx_mock.post(_RUNTIME_URL).respond(json=_STRANDS_RESPONSE)
|
||||
|
||||
response: Final = await router.adecisions(
|
||||
model="strands",
|
||||
input="review",
|
||||
extra_headers={_SESSION_HEADER: "caller-session-0123456789abcdef01234567"},
|
||||
questions=[
|
||||
{
|
||||
"type": "score",
|
||||
"name": "severity",
|
||||
"instructions": "How bad?",
|
||||
"levels": [{"label": "none"}, {"label": "low"}, {"label": "high"}],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
sent: Final = route.calls[0].request
|
||||
assert _sigv4_verifies(sent)
|
||||
assert sent.headers[_SESSION_HEADER] == "pooled-session-0123456789abcdef0123456"
|
||||
assert isinstance(response, OpenAIDecisionResponse)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strands_decider_runtime_arn_with_api_key_sends_the_bearer_token_unsigned(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
) -> None:
|
||||
monkeypatch.delenv("STRANDS_DECIDER_API_KEY", raising=False)
|
||||
route: Final = respx_mock.post(_RUNTIME_URL).respond(json=_STRANDS_RESPONSE)
|
||||
|
||||
await litellm.asystemone(
|
||||
model="strands_decider/strands-decider-2B-hobson-v19",
|
||||
state="review",
|
||||
questions={"severity": {"type": "score", "criteria": ["none", "low", "high"]}},
|
||||
api_base=_RUNTIME_ARN,
|
||||
api_key="runtime-jwt",
|
||||
)
|
||||
|
||||
sent: Final = route.calls[0].request
|
||||
assert sent.headers["authorization"] == "Bearer runtime-jwt"
|
||||
assert "x-amz-date" not in sent.headers
|
||||
assert _SESSION_HEADER in sent.headers
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("code", "error_class"),
|
||||
[
|
||||
("bad_request", litellm.BadRequestError),
|
||||
("loading", litellm.ServiceUnavailableError),
|
||||
("inference_error", litellm.InternalServerError),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_strands_decider_runtime_error_envelope_raises_instead_of_answering(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
code: str,
|
||||
error_class: type[Exception],
|
||||
) -> None:
|
||||
monkeypatch.delenv("STRANDS_DECIDER_API_KEY", raising=False)
|
||||
respx_mock.post(_RUNTIME_URL).respond(json={"error": {"code": code, "message": "at most 16 questions"}})
|
||||
|
||||
with pytest.raises(error_class, match="at most 16 questions"):
|
||||
await litellm.asystemone(
|
||||
model="strands_decider/strands-decider-2B-hobson-v19",
|
||||
state="review",
|
||||
questions={"severity": {"type": "score", "criteria": ["none", "low", "high"]}},
|
||||
api_base=_RUNTIME_ARN,
|
||||
aws_access_key_id=_RUNTIME_CREDENTIALS.access_key,
|
||||
aws_secret_access_key=_RUNTIME_CREDENTIALS.secret_key,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strands_decider_plain_api_base_error_envelope_stays_an_unexpected_response(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
) -> None:
|
||||
monkeypatch.delenv("STRANDS_DECIDER_API_KEY", raising=False)
|
||||
respx_mock.post("https://strands.example/v1/systemone").respond(
|
||||
json={"error": {"code": "bad_request", "message": "at most 16 questions"}}
|
||||
)
|
||||
|
||||
with pytest.raises(litellm.InternalServerError, match="returned an unexpected response"):
|
||||
await litellm.asystemone(
|
||||
model="strands_decider/strands-decider-2B-hobson-v19",
|
||||
state="review",
|
||||
questions={"severity": {"type": "score", "criteria": ["none", "low", "high"]}},
|
||||
api_base="https://strands.example",
|
||||
)
|
||||
|
||||
|
||||
_VLLM_RESPONSE: Final[Mapping[str, object]] = {
|
||||
"id": "systemone-4af3d2c1",
|
||||
"object": "structured_decision",
|
||||
"created": 1760012345,
|
||||
"model": "Qwen/Qwen3-0.6B",
|
||||
"answers": {
|
||||
"is_defect": {
|
||||
"type": "choice",
|
||||
"choice": "yes",
|
||||
"confidence": 0.91,
|
||||
"probabilities": {"yes": 0.91, "no": 0.09},
|
||||
}
|
||||
},
|
||||
"usage": {"input_tokens": 154, "output_tokens": 2},
|
||||
"diagnostics": {"engine": "vllm", "logprobs_mode": "raw_logprobs"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hosted_vllm_requires_api_base_before_http(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
) -> None:
|
||||
monkeypatch.delenv("HOSTED_VLLM_API_BASE", raising=False)
|
||||
monkeypatch.delenv("HOSTED_VLLM_API_KEY", raising=False)
|
||||
|
||||
with pytest.raises(litellm.BadRequestError, match="api_base is required"):
|
||||
await litellm.asystemone(
|
||||
model="hosted_vllm/Qwen/Qwen3-0.6B",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "choice", "criteria": {"yes": None, "no": None}}},
|
||||
)
|
||||
|
||||
assert len(respx_mock.calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hosted_vllm_without_key_sends_no_authorization_and_preserves_response_extras(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
) -> None:
|
||||
monkeypatch.delenv("HOSTED_VLLM_API_BASE", raising=False)
|
||||
monkeypatch.delenv("HOSTED_VLLM_API_KEY", raising=False)
|
||||
route: Final = respx_mock.post("http://vllm.local:8000/v1/systemone").respond(json=_VLLM_RESPONSE)
|
||||
|
||||
response: Final = await litellm.asystemone(
|
||||
model="hosted_vllm/Qwen/Qwen3-0.6B",
|
||||
state="The package arrived broken.",
|
||||
questions={"is_defect": {"type": "choice", "criteria": {"yes": None, "no": None}}},
|
||||
api_base="http://vllm.local:8000",
|
||||
)
|
||||
|
||||
assert route.called
|
||||
assert "authorization" not in respx_mock.calls[0].request.headers
|
||||
assert response.model_extra["id"] == _VLLM_RESPONSE["id"]
|
||||
assert response.model_extra["object"] == _VLLM_RESPONSE["object"]
|
||||
assert response.model_extra["diagnostics"] == _VLLM_RESPONSE["diagnostics"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hosted_vllm_uses_key_and_base_from_environment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
) -> None:
|
||||
monkeypatch.setenv("HOSTED_VLLM_API_BASE", "http://vllm.local:8000/v1")
|
||||
monkeypatch.setenv("HOSTED_VLLM_API_KEY", "vllm-key")
|
||||
route: Final = respx_mock.post("http://vllm.local:8000/v1/systemone").respond(json=_VLLM_RESPONSE)
|
||||
|
||||
await litellm.asystemone(
|
||||
model="hosted_vllm/Qwen/Qwen3-0.6B",
|
||||
state="The package arrived broken.",
|
||||
questions={"is_defect": {"type": "choice", "criteria": {"yes": None, "no": None}}},
|
||||
)
|
||||
|
||||
assert route.called
|
||||
assert respx_mock.calls[0].request.headers["authorization"] == "Bearer vllm-key"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hosted_vllm_api_base_with_v1_suffix_posts_to_v1_systemone(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
) -> None:
|
||||
monkeypatch.delenv("HOSTED_VLLM_API_BASE", raising=False)
|
||||
monkeypatch.delenv("HOSTED_VLLM_API_KEY", raising=False)
|
||||
route: Final = respx_mock.post("http://vllm.local:8000/v1/systemone").respond(json=_VLLM_RESPONSE)
|
||||
|
||||
await litellm.asystemone(
|
||||
model="hosted_vllm/Qwen/Qwen3-0.6B",
|
||||
state="The package arrived broken.",
|
||||
questions={"is_defect": {"type": "choice", "criteria": {"yes": None, "no": None}}},
|
||||
api_base="http://vllm.local:8000/v1",
|
||||
)
|
||||
|
||||
assert route.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hosted_vllm_choice_question_goes_out_as_a_jev_choice_with_the_served_model_name(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
) -> None:
|
||||
monkeypatch.delenv("HOSTED_VLLM_API_BASE", raising=False)
|
||||
monkeypatch.delenv("HOSTED_VLLM_API_KEY", raising=False)
|
||||
route: Final = respx_mock.post("http://vllm.local:8000/v1/systemone").respond(json=_VLLM_RESPONSE)
|
||||
|
||||
await litellm.asystemone(
|
||||
model="hosted_vllm/Qwen/Qwen3-0.6B",
|
||||
state="The package arrived broken.",
|
||||
questions={
|
||||
"is_defect": {
|
||||
"type": "choice",
|
||||
"instructions": "Is this a defect?",
|
||||
"criteria": {"yes": "defective", "no": "intact"},
|
||||
}
|
||||
},
|
||||
api_base="http://vllm.local:8000",
|
||||
)
|
||||
|
||||
assert route.called
|
||||
assert json.loads(respx_mock.calls[0].request.content) == {
|
||||
"model": "Qwen/Qwen3-0.6B",
|
||||
"state": "The package arrived broken.",
|
||||
"questions": {
|
||||
"is_defect": {
|
||||
"type": "choice",
|
||||
"instructions": "Is this a defect?",
|
||||
"criteria": {"yes": "defective", "no": "intact"},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_databricks_requires_api_base_before_http(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
) -> None:
|
||||
monkeypatch.delenv("DATABRICKS_API_BASE", raising=False)
|
||||
monkeypatch.setenv("DATABRICKS_API_KEY", "dapi-key")
|
||||
|
||||
with pytest.raises(litellm.BadRequestError, match="DATABRICKS_API_BASE"):
|
||||
await litellm.asystemone(
|
||||
model=f"databricks/{_DATABRICKS_ENDPOINT}",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
)
|
||||
|
||||
assert len(respx_mock.calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"endpoint_name", ["serving-endpoints/openjev", "openjev?x=1", "openjev#frag", "..", "open jev"]
|
||||
)
|
||||
async def test_databricks_rejects_a_serving_endpoint_name_that_would_rewrite_the_url(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
endpoint_name: str,
|
||||
) -> None:
|
||||
monkeypatch.setenv("DATABRICKS_API_BASE", "https://workspace.example/serving-endpoints")
|
||||
monkeypatch.setenv("DATABRICKS_API_KEY", "dapi-key")
|
||||
|
||||
with pytest.raises(litellm.BadRequestError, match="bare endpoint name"):
|
||||
await litellm.asystemone(
|
||||
model=f"databricks/{endpoint_name}",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
)
|
||||
|
||||
assert len(respx_mock.calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_databricks_requires_a_key_before_http(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
) -> None:
|
||||
monkeypatch.setenv("DATABRICKS_API_BASE", "https://workspace.example/serving-endpoints")
|
||||
monkeypatch.delenv("DATABRICKS_API_KEY", raising=False)
|
||||
monkeypatch.delenv("DATABRICKS_TOKEN", raising=False)
|
||||
|
||||
with pytest.raises(litellm.AuthenticationError, match="Missing API key for Decisions provider 'databricks'"):
|
||||
await litellm.asystemone(
|
||||
model=f"databricks/{_DATABRICKS_ENDPOINT}",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
)
|
||||
|
||||
assert len(respx_mock.calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"api_base", ["https://workspace.example/serving-endpoints", "https://workspace.example/serving-endpoints/"]
|
||||
)
|
||||
async def test_databricks_posts_the_jev_body_to_the_serving_endpoint_invocations_route(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
api_base: str,
|
||||
) -> None:
|
||||
monkeypatch.setenv("DATABRICKS_API_BASE", api_base)
|
||||
monkeypatch.delenv("DATABRICKS_API_KEY", raising=False)
|
||||
monkeypatch.setenv("DATABRICKS_TOKEN", "dapi-token")
|
||||
route: Final = respx_mock.post(
|
||||
f"https://workspace.example/serving-endpoints/{_DATABRICKS_ENDPOINT}/invocations"
|
||||
).respond(json=_DATABRICKS_RESPONSE)
|
||||
questions: Final = {
|
||||
"is_defect": {"type": "noul", "instructions": "Is this a defect?"},
|
||||
"sentiment": {"type": "choice", "criteria": {"positive": None, "negative": "unhappy"}},
|
||||
}
|
||||
|
||||
response: Final = await litellm.asystemone(
|
||||
model=f"databricks/{_DATABRICKS_ENDPOINT}",
|
||||
state={"ticket": "export hangs"},
|
||||
questions=questions,
|
||||
)
|
||||
|
||||
assert route.called
|
||||
sent: Final = respx_mock.calls[0].request
|
||||
assert sent.headers["authorization"] == "Bearer dapi-token"
|
||||
assert json.loads(sent.content) == {
|
||||
"model": _DATABRICKS_ENDPOINT,
|
||||
"state": {"ticket": "export hangs"},
|
||||
"questions": questions,
|
||||
}
|
||||
assert response.model == "/mosaicml/local_model"
|
||||
assert isinstance(response.answers["is_defect"], NoulAnswer)
|
||||
assert isinstance(response.answers["sentiment"], ChoiceAnswer)
|
||||
assert response.hidden_params["custom_llm_provider"] == "databricks"
|
||||
assert response.hidden_params["model"] == f"databricks/{_DATABRICKS_ENDPOINT}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_databricks_router_deployment_sends_its_own_key_to_its_own_base(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
) -> None:
|
||||
monkeypatch.setenv("DATABRICKS_API_BASE", "https://other-workspace.example/serving-endpoints")
|
||||
monkeypatch.setenv("DATABRICKS_API_KEY", "env-key-stays-home")
|
||||
router: Final = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "decider",
|
||||
"litellm_params": {
|
||||
"model": f"databricks/{_DATABRICKS_ENDPOINT}",
|
||||
"api_base": "https://workspace.example/serving-endpoints",
|
||||
"api_key": "deployment-key",
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
route: Final = respx_mock.post(
|
||||
f"https://workspace.example/serving-endpoints/{_DATABRICKS_ENDPOINT}/invocations"
|
||||
).respond(json=_DATABRICKS_RESPONSE)
|
||||
|
||||
response: Final = await router.asystemone(
|
||||
model="decider",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
)
|
||||
|
||||
assert route.called
|
||||
assert respx_mock.calls[0].request.headers["authorization"] == "Bearer deployment-key"
|
||||
assert response.answers["is_defect"] == NoulAnswer(type="noul", noul=0.9)
|
||||
|
||||
|
||||
_PROVIDERS_WHOSE_CHAT_MODELS_ALSO_DECIDE: Final = frozenset(
|
||||
{LlmProviders.OPENAI.value, LlmProviders.OPENROUTER.value, LlmProviders.HOSTED_VLLM.value}
|
||||
)
|
||||
_DEDICATED_DECIDER_PROVIDERS: Final[tuple[str, ...]] = tuple(
|
||||
sorted(
|
||||
provider.value
|
||||
for provider in LlmProviders
|
||||
if provider.value not in _PROVIDERS_WHOSE_CHAT_MODELS_ALSO_DECIDE
|
||||
and ProviderConfigManager.get_provider_decisions_config(model="", provider=provider) is not None
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider", _DEDICATED_DECIDER_PROVIDERS)
|
||||
def test_every_dedicated_decider_provider_ships_an_evaluation_mode_cost_map_entry(provider: str) -> None:
|
||||
evaluation_entries: Final = tuple(
|
||||
name
|
||||
for name, info in litellm.model_cost.items()
|
||||
if isinstance(info, Mapping) and info.get("litellm_provider") == provider and info.get("mode") == "evaluation"
|
||||
)
|
||||
|
||||
assert evaluation_entries, f"a {provider} decider would be health-checked as chat without a mode: evaluation entry"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_databricks_requires_api_base_before_http(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
|
@ -837,7 +1329,7 @@ async def test_databricks_posts_the_jev_body_to_the_serving_endpoint_invocations
|
|||
monkeypatch.setenv("DATABRICKS_TOKEN", "dapi-token")
|
||||
route: Final = respx_mock.post(
|
||||
f"https://workspace.example/serving-endpoints/{_DATABRICKS_ENDPOINT}/invocations"
|
||||
).respond(json=_DATABRICKS_ENDPOINT_RESPONSE)
|
||||
).respond(json=_DATABRICKS_RESPONSE)
|
||||
questions: Final = {
|
||||
"is_defect": {"type": "noul", "instructions": "Is this a defect?"},
|
||||
"sentiment": {"type": "choice", "criteria": {"positive": None, "negative": "unhappy"}},
|
||||
|
|
@ -882,7 +1374,7 @@ async def test_databricks_posts_the_systemone_body_without_model_to_the_ai_decid
|
|||
monkeypatch.setenv("DATABRICKS_API_BASE", api_base)
|
||||
monkeypatch.delenv("DATABRICKS_API_KEY", raising=False)
|
||||
monkeypatch.setenv("DATABRICKS_TOKEN", "dapi-token")
|
||||
route: Final = respx_mock.post(_DATABRICKS_ROUTE).respond(json=_DATABRICKS_RESPONSE)
|
||||
route: Final = respx_mock.post(_DATABRICKS_ROUTE).respond(json=_DATABRICKS_AI_DECIDE_RESPONSE)
|
||||
questions: Final = {
|
||||
"is_defect": {"type": "noul", "instructions": "Is this a defect?"},
|
||||
"sentiment": {"type": "choice", "criteria": {"positive": None, "negative": "unhappy"}},
|
||||
|
|
@ -925,7 +1417,7 @@ async def test_databricks_router_deployment_sends_its_own_key_to_its_own_base(
|
|||
}
|
||||
]
|
||||
)
|
||||
route: Final = respx_mock.post(_DATABRICKS_ROUTE).respond(json=_DATABRICKS_RESPONSE)
|
||||
route: Final = respx_mock.post(_DATABRICKS_ROUTE).respond(json=_DATABRICKS_AI_DECIDE_RESPONSE)
|
||||
|
||||
response: Final = await router.adecisions(
|
||||
model="decider",
|
||||
|
|
@ -981,7 +1473,7 @@ async def test_openai_decisions_translate_systemone_to_the_openai_wire_contract_
|
|||
monkeypatch.delenv("OPENAI_API_BASE", raising=False)
|
||||
route: Final = respx_mock.post(url).respond(json=_OPENAI_RESPONSE)
|
||||
|
||||
response: Final = await litellm.adecisions(
|
||||
response: Final = await litellm.asystemone(
|
||||
model="openai/gpt-6-luna",
|
||||
state="The package arrived broken.",
|
||||
questions={
|
||||
|
|
@ -1066,7 +1558,7 @@ def test_openai_decisions_use_the_same_settings_as_other_openai_calls(
|
|||
monkeypatch.setenv(name, value)
|
||||
route: Final = respx_mock.post(url).respond(json=_OPENAI_RESPONSE)
|
||||
|
||||
litellm.decisions(
|
||||
litellm.systemone(
|
||||
model="openai/gpt-6-luna",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
|
|
@ -1129,17 +1621,8 @@ async def test_openai_format_calls_to_a_systemone_provider_get_openai_format_ans
|
|||
},
|
||||
"cannot serve this request",
|
||||
),
|
||||
(
|
||||
"openai/gpt-6-luna",
|
||||
{
|
||||
"state": "review",
|
||||
"input": "review",
|
||||
"questions": [{"type": "predicate", "instructions": "Is this a defect?"}],
|
||||
},
|
||||
"not both",
|
||||
),
|
||||
),
|
||||
ids=("image_to_systemone_provider", "state_and_input"),
|
||||
ids=("image_to_systemone_provider",),
|
||||
)
|
||||
async def test_requests_a_provider_cannot_serve_are_rejected_before_http(
|
||||
respx_mock: respx.MockRouter,
|
||||
|
|
@ -1153,6 +1636,175 @@ async def test_requests_a_provider_cannot_serve_are_rejected_before_http(
|
|||
assert len(respx_mock.calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openrouter_decisions_uses_provider_reported_cost_without_cost_map(
|
||||
respx_mock: respx.MockRouter,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delitem(litellm.model_cost, "openrouter/typesafe/jev-1.13", raising=False)
|
||||
cost: Final = 1.1802e-5
|
||||
route: Final = respx_mock.post("https://openrouter.ai/api/alpha/decisions").respond(
|
||||
json={
|
||||
"model": "typesafe/jev-1.13",
|
||||
"answers": _RESPONSE["answers"],
|
||||
"usage": {
|
||||
"input_tokens": _INPUT_TOKENS,
|
||||
"output_tokens": _OUTPUT_TOKENS,
|
||||
"cost": cost,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
response: Final = await litellm.asystemone(
|
||||
model="openrouter/typesafe/jev-1.13",
|
||||
state={"source": "unit-test"},
|
||||
questions=_QUESTIONS,
|
||||
api_key="caller-key",
|
||||
)
|
||||
|
||||
assert route.called
|
||||
assert get_response_cost_from_hidden_params(response.hidden_params) == cost
|
||||
|
||||
|
||||
_SAFETY_IDENTIFIER: Final = "end-user-7"
|
||||
_PREDICATE_QUESTIONS: Final[tuple[Mapping[str, object], ...]] = (
|
||||
{"type": "predicate", "name": "is_defect", "instructions": "Is this a defect?"},
|
||||
)
|
||||
_PREDICATE_REQUESTS: Final[tuple[tuple[str, Mapping[str, object]], ...]] = (
|
||||
("decisions", MappingProxyType({"input": "review", "questions": _PREDICATE_QUESTIONS})),
|
||||
(
|
||||
"systemone",
|
||||
MappingProxyType(
|
||||
{"state": "review", "questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}}}
|
||||
),
|
||||
),
|
||||
)
|
||||
_PREDICATE_REQUEST_IDS: Final = ("decisions", "systemone")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("api", "request_kwargs"), _PREDICATE_REQUESTS, ids=_PREDICATE_REQUEST_IDS)
|
||||
def test_safety_identifier_is_refused_before_http_when_the_provider_cannot_take_it(
|
||||
api: str, request_kwargs: Mapping[str, object], respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
route: Final = respx_mock.post("https://api.perplexity.ai/v1/decisions").respond(json=_RESPONSE)
|
||||
|
||||
with pytest.raises(litellm.UnsupportedParamsError, match=r"safety_identifier.*drop_params") as caught:
|
||||
getattr(litellm, api)(
|
||||
model="perplexity/pplx-decider-v1-27b",
|
||||
safety_identifier=_SAFETY_IDENTIFIER,
|
||||
api_key="caller-key",
|
||||
**request_kwargs,
|
||||
)
|
||||
|
||||
assert caught.value.status_code == 400
|
||||
assert not route.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("scope", ("call", "global"))
|
||||
async def test_safety_identifier_is_dropped_from_the_wire_under_drop_params(
|
||||
scope: str, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "drop_params", scope == "global")
|
||||
route: Final = respx_mock.post("https://api.perplexity.ai/v1/decisions").respond(json=_RESPONSE)
|
||||
|
||||
response: Final = await litellm.adecisions(
|
||||
model="perplexity/pplx-decider-v1-27b",
|
||||
input="review",
|
||||
questions=_PREDICATE_QUESTIONS,
|
||||
safety_identifier=_SAFETY_IDENTIFIER,
|
||||
api_key="caller-key",
|
||||
**({"drop_params": True} if scope == "call" else {}),
|
||||
)
|
||||
|
||||
assert route.called
|
||||
assert json.loads(respx_mock.calls[0].request.content) == {
|
||||
"model": "pplx-decider-v1-27b",
|
||||
"state": "review",
|
||||
"questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
}
|
||||
assert isinstance(response, OpenAIDecisionResponse)
|
||||
assert [answer.model_dump(mode="json") for answer in response.answers] == [
|
||||
{"type": "predicate", "name": "is_defect", "probability": 0.9}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(("api", "request_kwargs"), _PREDICATE_REQUESTS, ids=_PREDICATE_REQUEST_IDS)
|
||||
@pytest.mark.parametrize("drop_params", (False, True), ids=("strict", "drop_params"))
|
||||
async def test_safety_identifier_reaches_openai_whether_or_not_params_are_dropped(
|
||||
drop_params: bool,
|
||||
api: str,
|
||||
request_kwargs: Mapping[str, object],
|
||||
respx_mock: respx.MockRouter,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "drop_params", drop_params)
|
||||
monkeypatch.setattr(litellm, "api_base", None)
|
||||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_BASE", raising=False)
|
||||
route: Final = respx_mock.post("https://api.openai.com/v1/decisions").respond(json=_OPENAI_RESPONSE)
|
||||
|
||||
await getattr(litellm, f"a{api}")(
|
||||
model="openai/gpt-6-luna",
|
||||
safety_identifier=_SAFETY_IDENTIFIER,
|
||||
api_key="caller-key",
|
||||
**request_kwargs,
|
||||
)
|
||||
|
||||
assert route.called
|
||||
assert json.loads(respx_mock.calls[0].request.content) == {
|
||||
"model": "gpt-6-luna",
|
||||
"input": "review",
|
||||
"questions": [{"type": "predicate", "name": "is_defect", "instructions": "Is this a defect?"}],
|
||||
"safety_identifier": _SAFETY_IDENTIFIER,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(("api", "request_kwargs"), _PREDICATE_REQUESTS, ids=_PREDICATE_REQUEST_IDS)
|
||||
async def test_non_string_safety_identifier_is_rejected_before_http(
|
||||
api: str, request_kwargs: Mapping[str, object], respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
route: Final = respx_mock.post("https://api.openai.com/v1/decisions").respond(json=_OPENAI_RESPONSE)
|
||||
invalid_safety_identifier: Final[Mapping[str, object]] = MappingProxyType({"safety_identifier": 7})
|
||||
|
||||
with pytest.raises(litellm.BadRequestError, match=r"(?s)Invalid Decisions request.*safety_identifier"):
|
||||
await getattr(litellm, f"a{api}")(
|
||||
model="openai/gpt-6-luna", api_key="caller-key", **request_kwargs, **invalid_safety_identifier
|
||||
)
|
||||
|
||||
assert not route.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(("api", "request_kwargs"), _PREDICATE_REQUESTS, ids=_PREDICATE_REQUEST_IDS)
|
||||
async def test_non_string_safety_identifier_is_dropped_from_the_wire_under_drop_params(
|
||||
api: str, request_kwargs: Mapping[str, object], respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
monkeypatch.setattr(litellm, "api_base", None)
|
||||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_BASE", raising=False)
|
||||
route: Final = respx_mock.post("https://api.openai.com/v1/decisions").respond(json=_OPENAI_RESPONSE)
|
||||
dropped_safety_identifier: Final[Mapping[str, object]] = MappingProxyType(
|
||||
{"safety_identifier": 7, "drop_params": True}
|
||||
)
|
||||
|
||||
await getattr(litellm, f"a{api}")(
|
||||
model="openai/gpt-6-luna", api_key="caller-key", **request_kwargs, **dropped_safety_identifier
|
||||
)
|
||||
|
||||
assert route.called
|
||||
assert json.loads(respx_mock.calls[0].request.content) == {
|
||||
"model": "gpt-6-luna",
|
||||
"input": "review",
|
||||
"questions": [{"type": "predicate", "name": "is_defect", "instructions": "Is this a defect?"}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"api_base",
|
||||
|
|
@ -1178,7 +1830,7 @@ async def test_azure_ai_decision_posts_deployment_to_foundry_systemone_route(
|
|||
json={**_RESPONSE, "model": "microsoft-decision-1"}
|
||||
)
|
||||
|
||||
response: Final = await litellm.adecisions(
|
||||
response: Final = await litellm.asystemone(
|
||||
model="azure_ai/decision-1",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
|
|
@ -1210,7 +1862,7 @@ async def test_azure_ai_decision_reads_foundry_env_and_keeps_gateway_path_prefix
|
|||
json=_RESPONSE
|
||||
)
|
||||
|
||||
await litellm.adecisions(
|
||||
await litellm.asystemone(
|
||||
model="azure_ai/decision-1",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
|
|
@ -1229,7 +1881,7 @@ async def test_azure_ai_decision_requires_api_base_before_http(
|
|||
monkeypatch.setenv("AZURE_AI_API_KEY", "foundry-key")
|
||||
|
||||
with pytest.raises(litellm.BadRequestError, match="Missing AZURE_AI_API_BASE"):
|
||||
await litellm.adecisions(
|
||||
await litellm.asystemone(
|
||||
model="azure_ai/decision-1",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
|
|
|
|||
|
|
@ -679,6 +679,81 @@ async def test_ahealth_check_probes_strands_through_decisions_without_mode(
|
|||
assert "authorization" not in upstream.calls[0].request.headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ahealth_check_probes_hosted_vllm_with_a_choice_question(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
) -> None:
|
||||
monkeypatch.delenv("HOSTED_VLLM_API_KEY", raising=False)
|
||||
monkeypatch.delenv("HOSTED_VLLM_API_BASE", raising=False)
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
upstream: Final = respx_mock.post("http://vllm.local:8000/v1/systemone").respond(
|
||||
json={
|
||||
"model": "Qwen/Qwen3-0.6B",
|
||||
"answers": {
|
||||
"reachable": {
|
||||
"type": "choice",
|
||||
"choice": "yes",
|
||||
"confidence": 1.0,
|
||||
"probabilities": {"yes": 1.0, "no": 0.0},
|
||||
}
|
||||
},
|
||||
"usage": {"input_tokens": 12, "output_tokens": 1},
|
||||
}
|
||||
)
|
||||
|
||||
result: Final = await ahealth_check(
|
||||
{
|
||||
"model": "hosted_vllm/Qwen/Qwen3-0.6B",
|
||||
"api_base": "http://vllm.local:8000",
|
||||
},
|
||||
mode="evaluation",
|
||||
)
|
||||
|
||||
assert "error" not in result, result
|
||||
assert upstream.called
|
||||
sent: Final = json.loads(upstream.calls[0].request.content)
|
||||
assert sent["questions"] == {
|
||||
"reachable": {
|
||||
"type": "choice",
|
||||
"instructions": "Is the service reachable?",
|
||||
"criteria": {"yes": None, "no": None},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ahealth_check_keeps_the_noul_probe_for_other_decisions_providers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
upstream: Final = respx_mock.post("https://api.typesafe.ai/v1/systemone").respond(
|
||||
json={
|
||||
"model": "jev-1.13",
|
||||
"answers": {"reachable": {"type": "noul", "noul": 1.0}},
|
||||
"usage": {"input_tokens": 12, "output_tokens": 1},
|
||||
}
|
||||
)
|
||||
|
||||
result: Final = await ahealth_check(
|
||||
{
|
||||
"model": "typesafe/jev-1.13",
|
||||
"api_key": "sk-test",
|
||||
},
|
||||
mode="evaluation",
|
||||
)
|
||||
|
||||
assert "error" not in result, result
|
||||
assert upstream.called
|
||||
sent: Final = json.loads(upstream.calls[0].request.content)
|
||||
assert sent["questions"] == {
|
||||
"reachable": {"type": "noul", "instructions": "Is the service reachable?"}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "custom_llm_provider", "expected"),
|
||||
(
|
||||
|
|
|
|||
|
|
@ -471,6 +471,7 @@ def test_default_max_retries_env_var_reaches_azure_sdk_client():
|
|||
"llm_passthrough_route",
|
||||
"asearch",
|
||||
"adecisions",
|
||||
"asystemone",
|
||||
"avector_store_create",
|
||||
"avector_store_search",
|
||||
"acreate_skill",
|
||||
|
|
|
|||
|
|
@ -96,6 +96,15 @@ def test_openrouter_extra_body_transformation():
|
|||
]
|
||||
|
||||
|
||||
def test_openrouter_supported_params_include_reasoning_for_unmapped_model():
|
||||
supported_params = OpenrouterConfig().get_supported_openai_params(
|
||||
"openrouter/unmapped-model"
|
||||
)
|
||||
|
||||
assert "reasoning_effort" in supported_params
|
||||
assert "thinking" in supported_params
|
||||
|
||||
|
||||
def test_openrouter_cache_control_flag_removal():
|
||||
transformed_request = OpenrouterConfig().transform_request(
|
||||
model="openrouter/deepseek/deepseek-chat",
|
||||
|
|
@ -537,17 +546,15 @@ def test_openrouter_reasoning_models_allow_reasoning_effort_param():
|
|||
assert supported_params.count("reasoning_effort") == 1
|
||||
|
||||
|
||||
def test_openrouter_non_reasoning_models_do_not_add_reasoning_effort():
|
||||
"""
|
||||
Models without reasoning support should not gain reasoning-specific params.
|
||||
"""
|
||||
def test_openrouter_non_reasoning_models_support_reasoning_params():
|
||||
config = OpenrouterConfig()
|
||||
|
||||
supported_params = config.get_supported_openai_params(
|
||||
model="openrouter/anthropic/claude-3-5-haiku"
|
||||
)
|
||||
|
||||
assert "reasoning_effort" not in supported_params
|
||||
assert "reasoning_effort" in supported_params
|
||||
assert "thinking" in supported_params
|
||||
|
||||
|
||||
def test_openrouter_reasoning_effort_max_maps_to_xhigh():
|
||||
|
|
|
|||
|
|
@ -9,12 +9,22 @@ reasoning.encrypted_content for multi-turn stateless workflows.
|
|||
Related issue: https://github.com/BerriAI/litellm/issues/22189
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.cost_calculator import get_response_cost_from_hidden_params
|
||||
from litellm.litellm_core_utils.core_helpers import RESPONSE_COST_HEADER
|
||||
from litellm.llms.openrouter.responses.transformation import (
|
||||
OpenRouterResponsesAPIConfig,
|
||||
)
|
||||
from litellm.types.llms.openai import (
|
||||
ResponseCompletedEvent,
|
||||
ResponseFailedEvent,
|
||||
ResponseIncompleteEvent,
|
||||
)
|
||||
from litellm.types.utils import LlmProviders
|
||||
from litellm.utils import ProviderConfigManager
|
||||
|
||||
|
|
@ -116,3 +126,66 @@ class TestOpenRouterResponsesAPIRegistration:
|
|||
# The URL should point to OpenRouter's responses endpoint
|
||||
url = config.get_complete_url(api_base=None, litellm_params={})
|
||||
assert "/responses" in url
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cost", [1.23e-5, None])
|
||||
def test_openrouter_responses_returns_provider_reported_cost(cost):
|
||||
usage: dict[str, object] = {
|
||||
"input_tokens": 2,
|
||||
"output_tokens": 3,
|
||||
"total_tokens": 5,
|
||||
}
|
||||
if cost is not None:
|
||||
usage["cost"] = cost
|
||||
response = OpenRouterResponsesAPIConfig().transform_response_api_response(
|
||||
model="openrouter/unmapped-model",
|
||||
raw_response=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "resp_test",
|
||||
"created_at": 1,
|
||||
"model": "unmapped-model",
|
||||
"object": "response",
|
||||
"output": [],
|
||||
"status": "completed",
|
||||
"usage": usage,
|
||||
},
|
||||
),
|
||||
logging_obj=MagicMock(),
|
||||
)
|
||||
|
||||
assert get_response_cost_from_hidden_params(response.hidden_params) == cost
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"event_type",
|
||||
["response.completed", "response.incomplete", "response.failed"],
|
||||
)
|
||||
def test_openrouter_terminal_stream_events_return_provider_reported_cost(event_type):
|
||||
event = OpenRouterResponsesAPIConfig().transform_streaming_response(
|
||||
model="openrouter/unmapped-model",
|
||||
parsed_chunk={
|
||||
"type": event_type,
|
||||
"response": {
|
||||
"id": "resp_test",
|
||||
"created_at": 1,
|
||||
"model": "unmapped-model",
|
||||
"object": "response",
|
||||
"output": [],
|
||||
"status": "completed",
|
||||
"usage": {
|
||||
"input_tokens": 2,
|
||||
"output_tokens": 3,
|
||||
"total_tokens": 5,
|
||||
"cost": 1.23e-5,
|
||||
},
|
||||
},
|
||||
},
|
||||
logging_obj=MagicMock(),
|
||||
)
|
||||
assert isinstance(
|
||||
event,
|
||||
(ResponseCompletedEvent, ResponseIncompleteEvent, ResponseFailedEvent),
|
||||
)
|
||||
|
||||
assert event.response.hidden_params["additional_headers"][RESPONSE_COST_HEADER] == 1.23e-5
|
||||
|
|
|
|||
0
tests/unit/llms/strands_decider/__init__.py
Normal file
0
tests/unit/llms/strands_decider/__init__.py
Normal file
0
tests/unit/llms/strands_decider/decisions/__init__.py
Normal file
0
tests/unit/llms/strands_decider/decisions/__init__.py
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from botocore.credentials import Credentials
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.base_llm.decisions.transformation import systemone_request_to_ir
|
||||
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
|
||||
from litellm.llms.strands_decider.decisions.transformation import (
|
||||
AGENTCORE_SESSION_HEADER,
|
||||
StrandsDeciderDecisionsConfig,
|
||||
)
|
||||
from litellm.types.decisions import DecisionsIRResponse, DecisionsRequestBody
|
||||
from litellm.types.llms.bedrock import AwsAuthParams
|
||||
|
||||
_ARN: Final = "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/qa_decider-AbC123xyz0"
|
||||
_BODY: Final[Mapping[str, object]] = {
|
||||
"model": "strands-decider-2B-hobson-v21",
|
||||
"state": "My order arrived broken.",
|
||||
"questions": {"is_complaint": {"type": "noul", "instructions": "Is this a complaint?"}},
|
||||
}
|
||||
_IR_REQUEST: Final = systemone_request_to_ir(
|
||||
TypeAdapter(DecisionsRequestBody).validate_python({"state": _BODY["state"], "questions": _BODY["questions"]})
|
||||
)
|
||||
|
||||
|
||||
class _RecordingAWS(BaseAWSLLM):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.resolved: tuple[tuple[AwsAuthParams, str | None], ...] = ()
|
||||
|
||||
def resolve_credentials(self, auth_params: AwsAuthParams, aws_region_name: str | None) -> Credentials:
|
||||
self.resolved = (*self.resolved, (auth_params, aws_region_name))
|
||||
return Credentials(access_key="AKIDEXAMPLE", secret_key="example-secret")
|
||||
|
||||
|
||||
def _sign(
|
||||
config: StrandsDeciderDecisionsConfig,
|
||||
api_base: str,
|
||||
headers: Mapping[str, str],
|
||||
api_key: str | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> tuple[Mapping[str, str], bytes | None]:
|
||||
return config.sign_request(
|
||||
headers=headers,
|
||||
url=config.get_complete_url(api_base=api_base, model="strands-decider-2B-hobson-v21"),
|
||||
api_base=api_base,
|
||||
body=_BODY,
|
||||
api_key=api_key,
|
||||
litellm_params=litellm_params or {},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("arn", "expected_url"),
|
||||
[
|
||||
(
|
||||
_ARN,
|
||||
"https://bedrock-agentcore.us-west-2.amazonaws.com/runtimes/"
|
||||
"arn%3Aaws%3Abedrock-agentcore%3Aus-west-2%3A123456789012%3Aruntime%2Fqa_decider-AbC123xyz0/invocations",
|
||||
),
|
||||
(
|
||||
"arn:aws-cn:bedrock-agentcore:cn-north-1:123456789012:runtime/qa_decider-AbC123xyz0",
|
||||
"https://bedrock-agentcore.cn-north-1.amazonaws.com.cn/runtimes/"
|
||||
"arn%3Aaws-cn%3Abedrock-agentcore%3Acn-north-1%3A123456789012%3Aruntime%2Fqa_decider-AbC123xyz0"
|
||||
"/invocations",
|
||||
),
|
||||
(
|
||||
"arn:aws-us-gov:bedrock-agentcore:us-gov-west-1:123456789012:runtime/qa_decider-AbC123xyz0",
|
||||
"https://bedrock-agentcore.us-gov-west-1.amazonaws.com/runtimes/"
|
||||
"arn%3Aaws-us-gov%3Abedrock-agentcore%3Aus-gov-west-1%3A123456789012%3Aruntime%2Fqa_decider-AbC123xyz0"
|
||||
"/invocations",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_runtime_arn_targets_the_regional_invoke_agent_runtime_url(arn: str, expected_url: str) -> None:
|
||||
assert StrandsDeciderDecisionsConfig().get_complete_url(api_base=arn, model="any") == expected_url
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"api_base",
|
||||
[
|
||||
"https://strands.example",
|
||||
"arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/qa_decider-AbC123xyz0/runtime-endpoint/DEFAULT",
|
||||
"arn:aws:bedrock-agentcore:evil.example/x:123456789012:runtime/qa_decider-AbC123xyz0",
|
||||
"arn:aws:bedrock:us-west-2:123456789012:runtime/qa_decider-AbC123xyz0",
|
||||
],
|
||||
)
|
||||
def test_other_api_bases_keep_the_plain_systemone_request(api_base: str) -> None:
|
||||
aws: Final = _RecordingAWS()
|
||||
config: Final = StrandsDeciderDecisionsConfig(aws=aws)
|
||||
headers: Final = {"Content-Type": "application/json"}
|
||||
|
||||
assert config.get_complete_url(api_base=api_base, model="any") == f"{api_base}/v1/systemone"
|
||||
assert _sign(config, api_base, headers) == (headers, None)
|
||||
assert aws.resolved == ()
|
||||
|
||||
|
||||
def test_sign_request_signs_the_body_for_the_arn_region_with_deployment_credentials() -> None:
|
||||
aws: Final = _RecordingAWS()
|
||||
config: Final = StrandsDeciderDecisionsConfig(aws=aws)
|
||||
|
||||
signed_headers, signed_body = _sign(
|
||||
config,
|
||||
_ARN,
|
||||
{"Content-Type": "application/json"},
|
||||
litellm_params={"aws_profile_name": "qa", "aws_role_name": "decider-role", "aws_region_name": "us-east-1"},
|
||||
)
|
||||
|
||||
assert signed_body == json.dumps(_BODY).encode()
|
||||
assert len(aws.resolved) == 1
|
||||
auth_params, region = aws.resolved[0]
|
||||
assert (auth_params.aws_profile_name, auth_params.aws_role_name, region) == ("qa", "decider-role", "us-west-2")
|
||||
authorization: Final = signed_headers["Authorization"]
|
||||
assert authorization.startswith("AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/")
|
||||
assert "/us-west-2/bedrock-agentcore/aws4_request" in authorization
|
||||
assert AGENTCORE_SESSION_HEADER.lower() in authorization
|
||||
|
||||
|
||||
def test_default_session_id_is_stable_per_runtime_and_within_the_agentcore_length_bounds() -> None:
|
||||
other_arn: Final = _ARN.replace("AbC123xyz0", "ZyX987cba1")
|
||||
|
||||
first, _ = _sign(StrandsDeciderDecisionsConfig(aws=_RecordingAWS()), _ARN, {})
|
||||
again, _ = _sign(StrandsDeciderDecisionsConfig(aws=_RecordingAWS()), _ARN, {})
|
||||
other, _ = _sign(StrandsDeciderDecisionsConfig(aws=_RecordingAWS()), other_arn, {})
|
||||
|
||||
session_id: Final = first[AGENTCORE_SESSION_HEADER]
|
||||
assert session_id == again[AGENTCORE_SESSION_HEADER]
|
||||
assert session_id != other[AGENTCORE_SESSION_HEADER]
|
||||
assert 33 <= len(session_id) <= 256
|
||||
|
||||
|
||||
_CALLER_RESERVED_HEADERS: Final = {
|
||||
AGENTCORE_SESSION_HEADER.lower(): "caller-session-0123456789abcdef0123456789",
|
||||
"X-Amzn-Bedrock-AgentCore-Runtime-User-Id": "someone-else",
|
||||
"X-Amz-Security-Token": "caller-token",
|
||||
"Host": "evil.example",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("api_key", [None, "runtime-jwt"])
|
||||
def test_caller_reserved_agentcore_headers_never_reach_the_runtime(api_key: str | None) -> None:
|
||||
signed_headers, _ = _sign(
|
||||
StrandsDeciderDecisionsConfig(aws=_RecordingAWS()),
|
||||
_ARN,
|
||||
{**_CALLER_RESERVED_HEADERS, "X-Request-Source": "batch"},
|
||||
api_key=api_key,
|
||||
)
|
||||
default_headers, _ = _sign(StrandsDeciderDecisionsConfig(aws=_RecordingAWS()), _ARN, {}, api_key=api_key)
|
||||
|
||||
forwarded: Final = {name.lower(): value for name, value in signed_headers.items()}
|
||||
assert forwarded[AGENTCORE_SESSION_HEADER.lower()] == default_headers[AGENTCORE_SESSION_HEADER]
|
||||
assert not any(value in forwarded.values() for value in _CALLER_RESERVED_HEADERS.values())
|
||||
assert "x-amzn-bedrock-agentcore-runtime-user-id" not in forwarded
|
||||
assert "host" not in forwarded
|
||||
assert forwarded["x-request-source"] == "batch"
|
||||
|
||||
|
||||
def test_deployment_session_param_picks_the_session_over_the_default_and_any_caller_header() -> None:
|
||||
replica_session: Final = "strands-decider-replica-a-0000000000"
|
||||
|
||||
signed_headers, _ = _sign(
|
||||
StrandsDeciderDecisionsConfig(aws=_RecordingAWS()),
|
||||
_ARN,
|
||||
_CALLER_RESERVED_HEADERS,
|
||||
litellm_params={"agentcore_runtime_session_id": replica_session},
|
||||
)
|
||||
|
||||
session_headers: Final = [
|
||||
value for name, value in signed_headers.items() if name.lower() == "x-amzn-bedrock-agentcore-runtime-session-id"
|
||||
]
|
||||
assert session_headers == [replica_session]
|
||||
assert AGENTCORE_SESSION_HEADER.lower() in signed_headers["Authorization"]
|
||||
|
||||
|
||||
def test_api_key_on_a_runtime_arn_sends_the_bearer_token_without_sigv4() -> None:
|
||||
aws: Final = _RecordingAWS()
|
||||
|
||||
signed_headers, signed_body = _sign(
|
||||
StrandsDeciderDecisionsConfig(aws=aws), _ARN, {"Authorization": "Bearer runtime-jwt"}, api_key="runtime-jwt"
|
||||
)
|
||||
|
||||
assert signed_body is None
|
||||
assert signed_headers["Authorization"] == "Bearer runtime-jwt"
|
||||
assert AGENTCORE_SESSION_HEADER in signed_headers
|
||||
assert aws.resolved == ()
|
||||
|
||||
|
||||
def _response(url: str, payload: object) -> httpx.Response:
|
||||
return httpx.Response(200, json=payload, request=httpx.Request("POST", url))
|
||||
|
||||
|
||||
def _transform(api_base: str, payload: object) -> DecisionsIRResponse:
|
||||
config: Final = StrandsDeciderDecisionsConfig()
|
||||
url: Final = config.get_complete_url(api_base=api_base, model="strands-decider-2B-hobson-v21")
|
||||
return config.transform_decisions_response(
|
||||
model="strands-decider-2B-hobson-v21",
|
||||
custom_llm_provider="strands_decider",
|
||||
raw_response=_response(url, payload),
|
||||
request=_IR_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("code", "status_code"),
|
||||
[("bad_request", 400), ("invalid_request", 400), ("loading", 503), ("inference_error", 500), ("new_code", 500)],
|
||||
)
|
||||
def test_runtime_error_envelope_raises_with_the_mapped_status(code: str, status_code: int) -> None:
|
||||
with pytest.raises(BaseLLMException) as raised:
|
||||
_transform(_ARN, {"error": {"code": code, "message": "at most 16 questions"}})
|
||||
|
||||
assert raised.value.status_code == status_code
|
||||
assert code in raised.value.message
|
||||
assert "at most 16 questions" in raised.value.message
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", ["bad_request", "loading", "inference_error"])
|
||||
def test_plain_api_base_error_envelope_keeps_the_unexpected_response_error(code: str) -> None:
|
||||
with pytest.raises(BaseLLMException) as raised:
|
||||
_transform("https://strands.example", {"error": {"code": code, "message": "at most 16 questions"}})
|
||||
|
||||
assert raised.value.status_code == 500
|
||||
assert raised.value.message.startswith("Decisions provider 'strands_decider' returned an unexpected response")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"https://strands.example/runtimes/arn%3Aaws%3Abedrock-agentcore%3Aus-west-2%3A123456789012%3Aruntime"
|
||||
"%2Fqa_decider-AbC123xyz0/invocations",
|
||||
"https://bedrock-agentcore.us-west-2.amazonaws.com/runtimes/not-an-arn/invocations",
|
||||
],
|
||||
)
|
||||
def test_error_envelope_from_a_url_that_is_not_the_runtime_keeps_the_unexpected_response_error(url: str) -> None:
|
||||
with pytest.raises(BaseLLMException) as raised:
|
||||
StrandsDeciderDecisionsConfig().transform_decisions_response(
|
||||
model="strands-decider-2B-hobson-v21",
|
||||
custom_llm_provider="strands_decider",
|
||||
raw_response=_response(url, {"error": {"code": "bad_request", "message": "at most 16 questions"}}),
|
||||
request=_IR_REQUEST,
|
||||
)
|
||||
|
||||
assert raised.value.status_code == 500
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [["not", "an", "object"], {"error": "unstructured"}])
|
||||
def test_runtime_reply_that_is_not_an_error_envelope_keeps_the_unexpected_response_error(payload: object) -> None:
|
||||
with pytest.raises(BaseLLMException) as raised:
|
||||
_transform(_ARN, payload)
|
||||
|
||||
assert raised.value.status_code == 500
|
||||
assert raised.value.message.startswith("Decisions provider 'strands_decider' returned an unexpected response")
|
||||
|
|
@ -2854,6 +2854,22 @@ class TestIsRequestBodySafeBlocksBedrockProjectOverride:
|
|||
)
|
||||
|
||||
|
||||
def test_agentcore_runtime_session_id_in_request_body_is_rejected_without_admin_opt_in():
|
||||
body: Final = {"model": "strands-decider", "agentcore_runtime_session_id": "caller-session-0123456789abcdef0123"}
|
||||
|
||||
with pytest.raises(ValueError, match="agentcore_runtime_session_id"):
|
||||
is_request_body_safe(request_body=body, general_settings={}, llm_router=None, model="strands-decider")
|
||||
assert (
|
||||
is_request_body_safe(
|
||||
request_body=body,
|
||||
general_settings={"allow_client_side_credentials": True},
|
||||
llm_router=None,
|
||||
model="strands-decider",
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
class TestIsRequestBodySafeBlocksClaudePlatformWorkspaceOverride:
|
||||
@pytest.mark.parametrize(
|
||||
"alias", ["workspace_id", "aws_workspace_id", "anthropic_workspace_id", "anthropic-workspace-id"]
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@ Simple unit tests for CustomOpenAPISpec class.
|
|||
Tests basic functionality of OpenAPI schema generation.
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
import json
|
||||
from typing import Annotated, Literal
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from litellm.proxy.common_utils.custom_openapi_spec import CustomOpenAPISpec
|
||||
from litellm.proxy.common_utils.custom_openapi_spec import CustomOpenAPISpec, inline_request_body
|
||||
|
||||
|
||||
class TestCustomOpenAPISpec:
|
||||
|
|
@ -27,19 +30,13 @@ class TestCustomOpenAPISpec:
|
|||
},
|
||||
}
|
||||
|
||||
@patch(
|
||||
"litellm.proxy.common_utils.custom_openapi_spec.CustomOpenAPISpec.add_request_schema"
|
||||
)
|
||||
def test_add_chat_completion_request_schema(
|
||||
self, mock_add_schema, base_openapi_schema
|
||||
):
|
||||
@patch("litellm.proxy.common_utils.custom_openapi_spec.CustomOpenAPISpec.add_request_schema")
|
||||
def test_add_chat_completion_request_schema(self, mock_add_schema, base_openapi_schema):
|
||||
"""Test that chat completion schema is added correctly."""
|
||||
mock_add_schema.return_value = base_openapi_schema
|
||||
|
||||
with patch("litellm.proxy._types.ProxyChatCompletionRequest") as mock_model:
|
||||
result = CustomOpenAPISpec.add_chat_completion_request_schema(
|
||||
base_openapi_schema
|
||||
)
|
||||
result = CustomOpenAPISpec.add_chat_completion_request_schema(base_openapi_schema)
|
||||
|
||||
mock_add_schema.assert_called_once_with(
|
||||
openapi_schema=base_openapi_schema,
|
||||
|
|
@ -50,9 +47,7 @@ class TestCustomOpenAPISpec:
|
|||
)
|
||||
assert result == base_openapi_schema
|
||||
|
||||
@patch(
|
||||
"litellm.proxy.common_utils.custom_openapi_spec.CustomOpenAPISpec.add_request_schema"
|
||||
)
|
||||
@patch("litellm.proxy.common_utils.custom_openapi_spec.CustomOpenAPISpec.add_request_schema")
|
||||
def test_add_embedding_request_schema(self, mock_add_schema, base_openapi_schema):
|
||||
"""Test that embedding schema is added correctly."""
|
||||
mock_add_schema.return_value = base_openapi_schema
|
||||
|
|
@ -69,19 +64,13 @@ class TestCustomOpenAPISpec:
|
|||
)
|
||||
assert result == base_openapi_schema
|
||||
|
||||
@patch(
|
||||
"litellm.proxy.common_utils.custom_openapi_spec.CustomOpenAPISpec.add_request_schema"
|
||||
)
|
||||
def test_add_responses_api_request_schema(
|
||||
self, mock_add_schema, base_openapi_schema
|
||||
):
|
||||
@patch("litellm.proxy.common_utils.custom_openapi_spec.CustomOpenAPISpec.add_request_schema")
|
||||
def test_add_responses_api_request_schema(self, mock_add_schema, base_openapi_schema):
|
||||
"""Test that responses API schema is added correctly."""
|
||||
mock_add_schema.return_value = base_openapi_schema
|
||||
|
||||
with patch("litellm.types.llms.openai.ResponsesAPIRequestParams") as mock_model:
|
||||
result = CustomOpenAPISpec.add_responses_api_request_schema(
|
||||
base_openapi_schema
|
||||
)
|
||||
result = CustomOpenAPISpec.add_responses_api_request_schema(base_openapi_schema)
|
||||
|
||||
mock_add_schema.assert_called_once_with(
|
||||
openapi_schema=base_openapi_schema,
|
||||
|
|
@ -123,15 +112,11 @@ def test_defs_rewritten_in_add_schema_to_components():
|
|||
)
|
||||
assert "$defs" not in openapi_schema
|
||||
assert (
|
||||
openapi_schema["components"]["schemas"]["SchemaName"]["properties"]["messages"][
|
||||
"items"
|
||||
]["anyOf"][0]["$ref"]
|
||||
openapi_schema["components"]["schemas"]["SchemaName"]["properties"]["messages"]["items"]["anyOf"][0]["$ref"]
|
||||
== "#/components/schemas/UserMessage"
|
||||
)
|
||||
assert (
|
||||
openapi_schema["components"]["schemas"]["SchemaName"]["properties"]["messages"][
|
||||
"items"
|
||||
]["anyOf"][1]["$ref"]
|
||||
openapi_schema["components"]["schemas"]["SchemaName"]["properties"]["messages"]["items"]["anyOf"][1]["$ref"]
|
||||
== "#/components/schemas/AssistantMessage"
|
||||
)
|
||||
|
||||
|
|
@ -188,14 +173,8 @@ def test_rewrite_defs_refs():
|
|||
rewritten = CustomOpenAPISpec._rewrite_defs_refs(schema=schema, renames={})
|
||||
|
||||
assert "$defs" not in rewritten
|
||||
assert (
|
||||
rewritten["properties"]["messages"]["items"]["anyOf"][0]["$ref"]
|
||||
== "#/components/schemas/UserMessage"
|
||||
)
|
||||
assert (
|
||||
rewritten["properties"]["messages"]["items"]["anyOf"][1]["$ref"]
|
||||
== "#/components/schemas/AssistantMessage"
|
||||
)
|
||||
assert rewritten["properties"]["messages"]["items"]["anyOf"][0]["$ref"] == "#/components/schemas/UserMessage"
|
||||
assert rewritten["properties"]["messages"]["items"]["anyOf"][1]["$ref"] == "#/components/schemas/AssistantMessage"
|
||||
|
||||
|
||||
def test_get_pydantic_schema_generates_schema_for_responses_request_typed_dict():
|
||||
|
|
@ -390,3 +369,42 @@ def test_add_schema_to_components_renames_def_with_different_required_set():
|
|||
assert schemas["Block"]["required"] == ["type", "x", "y"]
|
||||
assert schemas["Req_Block"]["required"] == ["keys", "type", "x", "y"]
|
||||
assert schemas["Req"]["properties"]["b"]["$ref"] == "#/components/schemas/Req_Block"
|
||||
|
||||
|
||||
class _Cat(BaseModel):
|
||||
kind: Literal["cat"]
|
||||
lives: int = 9
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
|
||||
class _Dog(BaseModel):
|
||||
kind: Literal["dog"]
|
||||
good: bool = True
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
|
||||
class _Shelter(BaseModel):
|
||||
pets: dict[str, Annotated[_Cat | _Dog, Field(discriminator="kind")]]
|
||||
favorite: _Cat | None = None
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
|
||||
def test_inline_request_body_expands_every_ref_so_the_operation_stands_alone():
|
||||
body = inline_request_body(_Shelter, {"pets": {"tom": {"kind": "cat"}}})
|
||||
|
||||
media = body["content"]["application/json"]
|
||||
assert body["required"] is True
|
||||
assert media["example"] == {"pets": {"tom": {"kind": "cat"}}}
|
||||
assert "$ref" not in json.dumps(media["schema"])
|
||||
assert "$defs" not in media["schema"]
|
||||
pet_schema = media["schema"]["properties"]["pets"]["additionalProperties"]
|
||||
variants = {variant["properties"]["kind"]["const"]: variant for variant in pet_schema["oneOf"]}
|
||||
assert variants["cat"]["properties"]["lives"]["default"] == 9
|
||||
assert variants["dog"]["properties"]["good"]["type"] == "boolean"
|
||||
assert pet_schema["discriminator"] == {"propertyName": "kind"}
|
||||
favorite = media["schema"]["properties"]["favorite"]
|
||||
assert favorite["default"] is None
|
||||
assert any(option.get("properties", {}).get("lives") for option in favorite["anyOf"])
|
||||
|
|
|
|||
|
|
@ -414,7 +414,7 @@ def test_initialize_rejects_invalid_streaming_scan_settings(mock_mgr, overrides,
|
|||
def test_add_guardrail_settings_returns_decisions_providers():
|
||||
import asyncio
|
||||
|
||||
from litellm.decisions.main import supported_decisions_providers
|
||||
from litellm.decisions.call import supported_decisions_providers
|
||||
from litellm.proxy.guardrails.guardrail_endpoints import get_guardrail_ui_settings
|
||||
|
||||
settings: Final = asyncio.run(get_guardrail_ui_settings())
|
||||
|
|
|
|||
|
|
@ -148,6 +148,8 @@ def test_tier_config_is_normalized_and_unknown_router_extras_are_rejected() -> N
|
|||
({"provider": "bespoke", "model": "nimble-latest", "api_key": "sk-member"}, "api_key"),
|
||||
({"provider": "databricks", "model": "ai_decide", "api_base": "https://collector.invalid"}, "opensource_classifier_config"),
|
||||
({"provider": "databricks", "model": "ai_decide", "api_key": "dapi-member"}, "api_key"),
|
||||
({"provider": "databricks", "model": "my-openjev", "api_base": "https://collector.invalid"}, "opensource_classifier_config"),
|
||||
({"provider": "databricks", "model": "my-openjev", "api_key": "dapi-member"}, "api_key"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("legacy", [False, True])
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from litellm.proxy.pass_through_endpoints.success_handler import PassThroughEndp
|
|||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def local_model_cost_map(monkeypatch: pytest.MonkeyPatch):
|
||||
def local_model_cost_map(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
|
||||
|
||||
|
|
@ -222,14 +222,17 @@ async def test_oss_gateway_accounts_for_checkpoint_usage_and_registered_cost(
|
|||
await budget_limiter.is_key_within_model_budget(auth, f"{provider}/{requested}")
|
||||
|
||||
|
||||
def test_openrouter_decisions_response_is_priced_from_request_model_registry_row():
|
||||
def test_openrouter_decisions_response_uses_provider_cost_without_cost_map_row(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
logging_obj = _logging_obj()
|
||||
model_cost = litellm.model_cost["openrouter/typesafe/jev-1.13"]
|
||||
cost: Final = 1.5834e-5
|
||||
monkeypatch.delitem(litellm.model_cost, "openrouter/typesafe/jev-1.13", raising=False)
|
||||
response = TypeSafePassthroughLoggingHandler.typesafe_passthrough_handler(
|
||||
httpx_response=_response(),
|
||||
response_body={
|
||||
"model": "typesafe/jev-1.13-20260917",
|
||||
"usage": {"input_tokens": 282, "output_tokens": 20},
|
||||
"usage": {"input_tokens": 282, "output_tokens": 20, "cost": cost},
|
||||
},
|
||||
logging_obj=logging_obj,
|
||||
url_route="https://openrouter.ai/api/alpha/decisions",
|
||||
|
|
@ -241,10 +244,9 @@ def test_openrouter_decisions_response_is_priced_from_request_model_registry_row
|
|||
custom_llm_provider="openrouter",
|
||||
)
|
||||
|
||||
expected_cost = 282 * model_cost["input_cost_per_token"] + 20 * model_cost["output_cost_per_token"]
|
||||
assert response["kwargs"]["model"] == "openrouter/typesafe/jev-1.13-20260917"
|
||||
assert response["kwargs"]["custom_llm_provider"] == "openrouter"
|
||||
assert response["kwargs"]["response_cost"] == pytest.approx(expected_cost)
|
||||
assert response["kwargs"]["response_cost"] == cost
|
||||
assert response["kwargs"]["combined_usage_object"].prompt_tokens == 282
|
||||
assert response["kwargs"]["combined_usage_object"].completion_tokens == 20
|
||||
assert response["kwargs"]["combined_usage_object"].total_tokens == 302
|
||||
|
|
|
|||
0
tests/unit/systemone/__init__.py
Normal file
0
tests/unit/systemone/__init__.py
Normal file
138
tests/unit/systemone/test_main.py
Normal file
138
tests/unit/systemone/test_main.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
from respx.models import Call
|
||||
|
||||
import litellm
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
||||
from litellm.types.decisions import DecisionsResponse, OpenAIDecisionResponse
|
||||
|
||||
_STATE: Final = "Customer asked for a refund"
|
||||
_SYSTEMONE_QUESTIONS: Final[Mapping[str, object]] = MappingProxyType(
|
||||
{"is_refund": {"type": "noul", "instructions": "Is this a refund request?"}}
|
||||
)
|
||||
_OPENAI_QUESTIONS: Final[tuple[Mapping[str, object], ...]] = (
|
||||
{"type": "predicate", "name": "is_refund", "instructions": "Is this a refund request?"},
|
||||
)
|
||||
_SYSTEMONE_RESPONSE: Final[Mapping[str, object]] = {
|
||||
"model": "jev-1.13",
|
||||
"answers": {"is_refund": {"type": "noul", "noul": 0.93}},
|
||||
"usage": {"input_tokens": 12, "output_tokens": 1},
|
||||
}
|
||||
_TYPESAFE_URL: Final = "https://api.typesafe.ai/v1/systemone"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _httpx_transport(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
|
||||
|
||||
class _CallTypeRecorder(CustomLogger):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.call_types: list[str] = [] # mutable-ok: test recorder collects logger callbacks
|
||||
|
||||
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time) -> None:
|
||||
self.call_types.append(kwargs["call_type"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("use_async", (True, False), ids=("asystemone", "systemone"))
|
||||
async def test_systemone_sends_the_systemone_wire_format_and_returns_a_systemone_response(
|
||||
use_async: bool, respx_mock: respx.MockRouter
|
||||
) -> None:
|
||||
route: Final = respx_mock.post(_TYPESAFE_URL).respond(json=_SYSTEMONE_RESPONSE)
|
||||
kwargs: Final[Mapping[str, object]] = MappingProxyType(
|
||||
{"model": "typesafe/jev-1.13", "state": _STATE, "questions": _SYSTEMONE_QUESTIONS, "api_key": "caller-key"}
|
||||
)
|
||||
|
||||
response: Final = await litellm.asystemone(**kwargs) if use_async else litellm.systemone(**kwargs)
|
||||
|
||||
assert route.called
|
||||
call: Final[Call] = respx_mock.calls.last
|
||||
assert json.loads(call.request.content) == {
|
||||
"model": "jev-1.13",
|
||||
"state": _STATE,
|
||||
"questions": {"is_refund": {"type": "noul", "instructions": "Is this a refund request?"}},
|
||||
}
|
||||
assert isinstance(response, DecisionsResponse)
|
||||
assert response.answers["is_refund"].model_dump(mode="json") == {"type": "noul", "noul": 0.93}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_systemone_rejects_openai_input_and_points_at_decisions(respx_mock: respx.MockRouter) -> None:
|
||||
route: Final = respx_mock.post(_TYPESAFE_URL).respond(json=_SYSTEMONE_RESPONSE)
|
||||
|
||||
with pytest.raises(litellm.BadRequestError, match=r"litellm\.decisions") as caught:
|
||||
await litellm.asystemone(
|
||||
model="typesafe/jev-1.13", input=_STATE, questions=_SYSTEMONE_QUESTIONS, api_key="caller-key"
|
||||
)
|
||||
|
||||
assert caught.value.status_code == 400
|
||||
assert not route.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("use_async", (True, False), ids=("adecisions", "decisions"))
|
||||
async def test_decisions_with_state_still_sends_the_systemone_format_and_warns(
|
||||
use_async: bool, respx_mock: respx.MockRouter
|
||||
) -> None:
|
||||
route: Final = respx_mock.post(_TYPESAFE_URL).respond(json=_SYSTEMONE_RESPONSE)
|
||||
kwargs: Final[Mapping[str, object]] = MappingProxyType(
|
||||
{"model": "typesafe/jev-1.13", "state": _STATE, "questions": _SYSTEMONE_QUESTIONS, "api_key": "caller-key"}
|
||||
)
|
||||
|
||||
with pytest.warns(UserWarning, match=r"litellm\.systemone"):
|
||||
response: Final = await litellm.adecisions(**kwargs) if use_async else litellm.decisions(**kwargs)
|
||||
|
||||
assert route.called
|
||||
call: Final[Call] = respx_mock.calls.last
|
||||
assert json.loads(call.request.content) == {
|
||||
"model": "jev-1.13",
|
||||
"state": _STATE,
|
||||
"questions": {"is_refund": {"type": "noul", "instructions": "Is this a refund request?"}},
|
||||
}
|
||||
assert isinstance(response, DecisionsResponse)
|
||||
assert response.answers["is_refund"].model_dump(mode="json") == {"type": "noul", "noul": 0.93}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_systemone_rejects_openai_question_lists_before_http(respx_mock: respx.MockRouter) -> None:
|
||||
route: Final = respx_mock.post(_TYPESAFE_URL).respond(json=_SYSTEMONE_RESPONSE)
|
||||
|
||||
with pytest.raises(litellm.BadRequestError, match="Invalid Decisions request"):
|
||||
await litellm.asystemone(
|
||||
model="typesafe/jev-1.13", state=_STATE, questions=_OPENAI_QUESTIONS, api_key="caller-key"
|
||||
)
|
||||
|
||||
assert not route.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_router_asystemone_logs_the_systemone_call_type(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
respx_mock.post(_TYPESAFE_URL).respond(json=_SYSTEMONE_RESPONSE)
|
||||
recorder: Final = _CallTypeRecorder()
|
||||
monkeypatch.setattr(litellm, "callbacks", [recorder])
|
||||
router: Final = litellm.Router(
|
||||
model_list=[{"model_name": "jev", "litellm_params": {"model": "typesafe/jev-1.13", "api_key": "caller-key"}}]
|
||||
)
|
||||
|
||||
response: Final = await router.asystemone(model="jev", state=_STATE, questions=_SYSTEMONE_QUESTIONS)
|
||||
await asyncio.sleep(0)
|
||||
GLOBAL_LOGGING_WORKER.start()
|
||||
await asyncio.wait_for(GLOBAL_LOGGING_WORKER.flush(), timeout=10.0)
|
||||
|
||||
assert isinstance(response, DecisionsResponse)
|
||||
assert recorder.call_types == ["asystemone"]
|
||||
assert not isinstance(response, OpenAIDecisionResponse)
|
||||
|
|
@ -22,15 +22,7 @@ vi.mock("./detailNavigation", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@/components/molecules/DecisionModelsBanner", () => ({
|
||||
default: ({ onAddModel }: { onAddModel?: () => void }) => (
|
||||
<section aria-label="Decision models">
|
||||
{onAddModel && (
|
||||
<button type="button" onClick={onAddModel}>
|
||||
Add a decision model
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
),
|
||||
default: () => <section aria-label="Decision models" />,
|
||||
}));
|
||||
vi.mock("@/components/model_info_view", () => ({
|
||||
default: ({ modelId }: { modelId: string }) => <div data-testid="model-info">model:{modelId}</div>,
|
||||
|
|
@ -107,21 +99,6 @@ describe("ModelsAndEndpointsPage", () => {
|
|||
expect(screen.queryByTestId("panel-all-models")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the Add Model tab from the decision models banner", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await user.click(screen.getByRole("button", { name: "Add a decision model" }));
|
||||
expect(screen.getByTestId("panel-add")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("panel-all-models")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not offer the banner's Add a decision model to a session that cannot add models", () => {
|
||||
mockUseAuthorized.mockReturnValue(NON_ADMIN);
|
||||
renderPage();
|
||||
expect(screen.getByRole("region", { name: "Decision models" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Add a decision model" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show a view-only session the decision models banner, whose Playground it cannot open", () => {
|
||||
mockUseAuthorized.mockReturnValue(VIEW_ONLY_ADMIN);
|
||||
renderPage();
|
||||
|
|
|
|||
|
|
@ -177,9 +177,7 @@ export default function ModelsAndEndpointsPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{!isViewOnly && (
|
||||
<DecisionModelsBanner onAddModel={canCreate && !modelId ? () => setActiveKey("add") : undefined} />
|
||||
)}
|
||||
{!isViewOnly && <DecisionModelsBanner />}
|
||||
|
||||
{modelId ? (
|
||||
<ModelInfoView
|
||||
|
|
|
|||
|
|
@ -563,14 +563,6 @@ describe("AddModelPanel decision models", () => {
|
|||
|
||||
const decisionNotice = () => screen.queryByRole("note", { name: "Decision model notice" });
|
||||
|
||||
it("finds a provider by the name of one of its decision models", async () => {
|
||||
const { user } = await setup();
|
||||
await user.type(screen.getByRole("combobox", { name: /provider/i }), "jev");
|
||||
|
||||
expect(await screen.findByRole("option", { name: /TypeSafe/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("option", { name: /Anthropic/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("points a decision-only provider at the decision routes, the docs, and the playground", async () => {
|
||||
const { user } = await setup();
|
||||
await user.type(screen.getByRole("combobox", { name: /provider/i }), "TypeSafe");
|
||||
|
|
@ -582,7 +574,7 @@ describe("AddModelPanel decision models", () => {
|
|||
"href",
|
||||
"https://docs.litellm.ai/docs/decisions",
|
||||
);
|
||||
expect(within(note).getByRole("link", { name: "test it in the System One playground" })).toHaveAttribute(
|
||||
expect(within(note).getByRole("link", { name: "test it in the Decisions playground" })).toHaveAttribute(
|
||||
"href",
|
||||
"/ui/playground?tab=system-one",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
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 type { PayloadValidation } from "./lib/validatePayload";
|
||||
|
||||
export function RemoveButton({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<Button variant="ghost" size="icon" aria-label={label} title={label} onClick={onClick}>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddButton({ children, onClick }: { children: ReactNode; onClick: () => void }) {
|
||||
return (
|
||||
<Button variant="outline" size="sm" className="w-fit" onClick={onClick}>
|
||||
<Plus />
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormFrame({ validation, children }: { validation: PayloadValidation<unknown>; children: ReactNode }) {
|
||||
const issuesId = useId();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-96 flex-1 flex-col overflow-hidden rounded-md border bg-background",
|
||||
!validation.isValid && "border-destructive/60",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<span className="text-sm font-medium">Request form</span>
|
||||
<ValidationStatus validation={validation} />
|
||||
</div>
|
||||
<div className="grid min-h-80 flex-1 content-start gap-4 overflow-auto p-3">{children}</div>
|
||||
<IssueList id={issuesId} validation={validation} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { CircleAlert, CircleCheck, TriangleAlert } from "lucide-react";
|
||||
import { useId, useMemo, useRef } from "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 { findRootBlocks, ROOT_BLOCK_STYLES, type RootBlock } from "./lib/rootBlocks";
|
||||
import type { SystemOnePayloadValidation } from "./lib/validatePayload";
|
||||
import { vs, vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import type { PayloadValidation } from "./lib/validatePayload";
|
||||
|
||||
SyntaxHighlighter.registerLanguage("json", json);
|
||||
|
||||
|
|
@ -14,24 +15,54 @@ const EDITOR_TEXT = "m-0 whitespace-pre-wrap wrap-anywhere py-3 font-mono text-x
|
|||
const GUTTER_WIDTH = "w-11";
|
||||
type LineRendererProps = Parameters<NonNullable<SyntaxHighlighterProps["renderer"]>>[0];
|
||||
const CONTENT_INSET = "pl-14 pr-3";
|
||||
const CODE_TAG_PROPS = { className: "language-json", style: { whiteSpace: "pre-wrap" } } as const;
|
||||
type SyntaxTheme = NonNullable<SyntaxHighlighterProps["style"]>;
|
||||
const THEME_BLOCKS = ['pre[class*="language-"]', 'code[class*="language-"]'] as const;
|
||||
|
||||
const TOKEN_COLORS = [
|
||||
"[&_.token.property]:text-sky-700 dark:[&_.token.property]:text-sky-300",
|
||||
"[&_.token.string]:text-emerald-700 dark:[&_.token.string]:text-emerald-300",
|
||||
"[&_.token.number]:text-amber-700 dark:[&_.token.number]:text-amber-300",
|
||||
"[&_.token.boolean]:text-violet-700 dark:[&_.token.boolean]:text-violet-300",
|
||||
"[&_.token.null]:text-violet-700 dark:[&_.token.null]:text-violet-300",
|
||||
"[&_.token.punctuation]:text-muted-foreground [&_.token.operator]:text-muted-foreground",
|
||||
].join(" ");
|
||||
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: SystemOnePayloadValidation;
|
||||
validation: PayloadValidation<unknown>;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function ValidationStatus({ validation }: { validation: SystemOnePayloadValidation }) {
|
||||
export function ValidationStatus({ validation }: { validation: PayloadValidation<unknown> }) {
|
||||
const errorCount = validation.issues.filter((issue) => issue.severity === "error").length;
|
||||
if (errorCount > 0) {
|
||||
return (
|
||||
|
|
@ -43,7 +74,7 @@ export function ValidationStatus({ validation }: { validation: SystemOnePayloadV
|
|||
return <Badge variant="secondary">Valid payload</Badge>;
|
||||
}
|
||||
|
||||
export function IssueList({ id, validation }: { id: string; validation: SystemOnePayloadValidation }) {
|
||||
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">
|
||||
|
|
@ -69,39 +100,30 @@ export function IssueList({ id, validation }: { id: string; validation: SystemOn
|
|||
);
|
||||
}
|
||||
|
||||
function renderLines(rootBlocks: readonly RootBlock[]) {
|
||||
return function LineRows({ rows, stylesheet, useInlineStyles }: LineRendererProps) {
|
||||
return rows.map((row, line) => {
|
||||
const lineElement = { node: row, stylesheet, useInlineStyles, key: line };
|
||||
const block = rootBlocks.find(({ startLine, endLine }) => line >= startLine && line <= endLine);
|
||||
return (
|
||||
<div key={line} className="flex">
|
||||
<span className={cn(GUTTER_WIDTH, "shrink-0 select-none pr-3 text-right text-muted-foreground")}>
|
||||
{line + 1}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"relative min-h-5 min-w-0 flex-1 px-3",
|
||||
block && [
|
||||
"before:absolute before:inset-y-0 before:left-0 before:w-0.5",
|
||||
ROOT_BLOCK_STYLES[block.key].band,
|
||||
line === block.startLine && "rounded-t-sm before:rounded-t-sm",
|
||||
line === block.endLine && "rounded-b-sm before:rounded-b-sm",
|
||||
],
|
||||
)}
|
||||
>
|
||||
{createElement(lineElement)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
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 }: JsonEditorProps) {
|
||||
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 renderer = useMemo(() => renderLines(findRootBlocks(value)), [value]);
|
||||
const syntaxTheme = useTheme().resolvedTheme === "dark" ? VS_CODE_DARK_PLUS : VS_CODE_LIGHT_PLUS;
|
||||
const lineCount = value.split("\n").length;
|
||||
const hasErrors = !validation.isValid;
|
||||
|
||||
|
|
@ -121,26 +143,28 @@ export default function JsonEditor({ value, onChange, validation }: JsonEditorPr
|
|||
{lineCount} {lineCount === 1 ? "line" : "lines"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative min-h-80 flex-1">
|
||||
<div aria-hidden="true" className={cn(GUTTER_WIDTH, "absolute inset-y-0 left-0 border-r bg-muted/50")} />
|
||||
<div className="relative min-h-80 flex-1 bg-background dark:bg-[#1e1e1e]">
|
||||
<div
|
||||
ref={highlightRef}
|
||||
aria-hidden="true"
|
||||
className={cn("absolute inset-0 overflow-hidden", EDITOR_TEXT, TOKEN_COLORS)}
|
||||
>
|
||||
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={{}}
|
||||
useInlineStyles={false}
|
||||
style={syntaxTheme}
|
||||
customStyle={TRANSPARENT_PRE}
|
||||
PreTag="div"
|
||||
codeTagProps={CODE_TAG_PROPS}
|
||||
renderer={renderer}
|
||||
renderer={LineRows}
|
||||
>
|
||||
{value}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
<textarea
|
||||
aria-label="System One JSON payload"
|
||||
aria-label={label}
|
||||
aria-invalid={hasErrors}
|
||||
aria-describedby={issuesId}
|
||||
value={value}
|
||||
|
|
@ -153,7 +177,7 @@ export default function JsonEditor({ value, onChange, validation }: JsonEditorPr
|
|||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
placeholder="Paste or write a System One request"
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
EDITOR_TEXT,
|
||||
CONTENT_INSET,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,267 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useId, useMemo } from "react";
|
||||
import { AddButton, FormFrame, RemoveButton } from "./DecisionFormControls";
|
||||
import { freeName, removeAt, replaceAt } from "./lib/formPayload";
|
||||
import {
|
||||
readDecisionsForm,
|
||||
retypeDecisionQuestion,
|
||||
type DecisionsFormPayload,
|
||||
type DecisionsFormQuestion,
|
||||
type DecisionsQuestionType,
|
||||
} from "./lib/openAIDecisionsForm";
|
||||
import type { OpenAIDecisionsRequest } from "./lib/openAIDecisions";
|
||||
import type { PayloadValidation } from "./lib/validatePayload";
|
||||
|
||||
const QUESTION_TYPES: readonly DecisionsQuestionType[] = ["predicate", "choice", "score"];
|
||||
const TYPE_LABELS = { predicate: "Predicate", choice: "Choice", score: "Score" };
|
||||
|
||||
interface QuestionEditorProps<Q extends DecisionsFormQuestion = DecisionsFormQuestion> {
|
||||
question: Q;
|
||||
onChange: (question: DecisionsFormQuestion) => void;
|
||||
}
|
||||
|
||||
function Choices({ question, onChange }: QuestionEditorProps<Extract<DecisionsFormQuestion, { type: "choice" }>>) {
|
||||
const choices = question.choices ?? [];
|
||||
const write = (next: typeof choices) => onChange({ ...question, choices: next });
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<span className="text-sm font-medium">Choices</span>
|
||||
{choices.map((choice, index) => (
|
||||
<div key={index} className="grid gap-2 rounded-md border p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={typeof choice.value === "string" ? "text" : String(choice.value)}
|
||||
onValueChange={(type) => {
|
||||
if (type === "text" || type === "true" || type === "false") {
|
||||
const value = type === "text" ? String(choice.value) : type === "true";
|
||||
write(replaceAt(choices, index, { ...choice, value }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-40" aria-label={`Choice ${index + 1} value type`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text">Text</SelectItem>
|
||||
<SelectItem value="true">true (boolean)</SelectItem>
|
||||
<SelectItem value="false">false (boolean)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{typeof choice.value === "string" && (
|
||||
<Input
|
||||
aria-label={`Choice ${index + 1} value`}
|
||||
value={choice.value}
|
||||
onChange={(event) => write(replaceAt(choices, index, { ...choice, value: event.target.value }))}
|
||||
/>
|
||||
)}
|
||||
<RemoveButton label={`Remove choice ${index + 1}`} onClick={() => write(removeAt(choices, index))} />
|
||||
</div>
|
||||
<Input
|
||||
aria-label={`Choice ${index + 1} description`}
|
||||
value={choice.description ?? ""}
|
||||
placeholder="Optional description"
|
||||
onChange={(event) => write(replaceAt(choices, index, { ...choice, description: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<AddButton
|
||||
onClick={() =>
|
||||
write([
|
||||
...choices,
|
||||
{
|
||||
value: freeName(
|
||||
choices.map((c) => String(c.value)),
|
||||
"option",
|
||||
),
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
Add choice
|
||||
</AddButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Levels({ question, onChange }: QuestionEditorProps<Extract<DecisionsFormQuestion, { type: "score" }>>) {
|
||||
const levels = question.levels ?? [];
|
||||
const write = (next: typeof levels) => onChange({ ...question, levels: next });
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<span className="text-sm font-medium">Levels, lowest first</span>
|
||||
{levels.map((level, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
aria-label={`Level ${index + 1} label`}
|
||||
value={level.label}
|
||||
placeholder="Label"
|
||||
onChange={(event) => write(replaceAt(levels, index, { ...level, label: event.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
aria-label={`Level ${index + 1} description`}
|
||||
value={level.description ?? ""}
|
||||
placeholder="Optional description"
|
||||
onChange={(event) => write(replaceAt(levels, index, { ...level, description: event.target.value }))}
|
||||
/>
|
||||
<RemoveButton label={`Remove level ${index + 1}`} onClick={() => write(removeAt(levels, index))} />
|
||||
</div>
|
||||
))}
|
||||
<AddButton onClick={() => write([...levels, { label: String(levels.length) }])}>Add level</AddButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface QuestionCardProps extends QuestionEditorProps {
|
||||
index: number;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function QuestionCard({ question, index, onChange, onRemove }: QuestionCardProps) {
|
||||
const instructionsId = useId();
|
||||
|
||||
return (
|
||||
<fieldset aria-label={`Question ${index + 1}`} className="grid gap-3 rounded-md border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
aria-label="Question name"
|
||||
className="min-w-0 flex-1"
|
||||
value={question.name ?? ""}
|
||||
placeholder="Optional question name"
|
||||
onChange={(event) => onChange({ ...question, name: event.target.value })}
|
||||
/>
|
||||
<Select
|
||||
value={question.type}
|
||||
onValueChange={(type) => {
|
||||
const next = QUESTION_TYPES.find((value) => value === type);
|
||||
if (next) onChange(retypeDecisionQuestion(question, next));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-36" aria-label="Answer type">
|
||||
<SelectValue>{TYPE_LABELS[question.type]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{QUESTION_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{TYPE_LABELS[type]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<RemoveButton label={`Remove question ${index + 1}`} onClick={onRemove} />
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={instructionsId}>Instructions</Label>
|
||||
<Textarea
|
||||
id={instructionsId}
|
||||
rows={2}
|
||||
value={question.instructions ?? ""}
|
||||
placeholder="What should the model decide?"
|
||||
onChange={(event) => onChange({ ...question, instructions: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
{question.type === "choice" && <Choices question={question} onChange={onChange} />}
|
||||
{question.type === "score" && <Levels question={question} onChange={onChange} />}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
interface OpenAIDecisionsFormProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
validation: PayloadValidation<OpenAIDecisionsRequest>;
|
||||
onOpenJson: () => void;
|
||||
}
|
||||
|
||||
export default function OpenAIDecisionsForm({ value, onChange, validation, onOpenJson }: OpenAIDecisionsFormProps) {
|
||||
const inputId = useId();
|
||||
const safetyId = useId();
|
||||
const form = useMemo(() => readDecisionsForm(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. Edit it in JSON to fix its shape.
|
||||
</p>
|
||||
<Button variant="outline" className="w-fit" onClick={onOpenJson}>
|
||||
Edit in JSON
|
||||
</Button>
|
||||
</FormFrame>
|
||||
);
|
||||
}
|
||||
|
||||
const questions = form.questions ?? [];
|
||||
const write = (next: DecisionsFormPayload) => onChange(JSON.stringify(next, null, 2));
|
||||
const writeQuestions = (next: DecisionsFormQuestion[]) => write({ ...form, questions: next });
|
||||
|
||||
return (
|
||||
<FormFrame validation={validation}>
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Label htmlFor={inputId}>Input</Label>
|
||||
<Textarea
|
||||
id={inputId}
|
||||
rows={4}
|
||||
value={form.input ?? ""}
|
||||
placeholder="Paste the text the model should decide about"
|
||||
onChange={(event) => write({ ...form, input: event.target.value })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{questions.map((question, index) => (
|
||||
<QuestionCard
|
||||
key={index}
|
||||
index={index}
|
||||
question={question}
|
||||
onChange={(next) => writeQuestions(replaceAt(questions, index, next))}
|
||||
onRemove={() => writeQuestions(removeAt(questions, index))}
|
||||
/>
|
||||
))}
|
||||
<AddButton
|
||||
onClick={() =>
|
||||
writeQuestions([
|
||||
...questions,
|
||||
{
|
||||
type: "predicate",
|
||||
name: freeName(
|
||||
questions.map((q) => q.name ?? ""),
|
||||
"question",
|
||||
),
|
||||
instructions: "",
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
Add question
|
||||
</AddButton>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={safetyId}>Safety identifier</Label>
|
||||
<Input
|
||||
id={safetyId}
|
||||
value={form.safety_identifier ?? ""}
|
||||
placeholder="Optional"
|
||||
onChange={(event) => write({ ...form, safety_identifier: event.target.value || undefined })}
|
||||
/>
|
||||
</div>
|
||||
</FormFrame>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { ROOT_BLOCK_STYLES } from "./lib/rootBlocks";
|
||||
import type { PlaygroundQuestion, PlaygroundRequest } from "./lib/schemas";
|
||||
|
||||
function formatState(state: unknown): string {
|
||||
|
|
@ -74,10 +72,7 @@ export default function QuestionBreakdown({ payload }: { payload?: PlaygroundReq
|
|||
<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={cn("grid gap-2 border-l-2 pl-3", ROOT_BLOCK_STYLES.state.accent)}
|
||||
>
|
||||
<section aria-labelledby="system-one-state-heading" className="grid gap-2">
|
||||
<h3 id="system-one-state-heading" className="text-sm font-medium">
|
||||
State
|
||||
</h3>
|
||||
|
|
@ -85,7 +80,7 @@ export default function QuestionBreakdown({ payload }: { payload?: PlaygroundReq
|
|||
{formatState(payload.state)}
|
||||
</pre>
|
||||
</section>
|
||||
<div className={cn("grid gap-3 border-l-2 pl-3", ROOT_BLOCK_STYLES.questions.accent)}>
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,164 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ResponseView from "./ResponseView";
|
||||
import type { SystemOneResponse } from "./lib/schemas";
|
||||
import type { OpenAIDecisionsResponse } from "./lib/openAIDecisions";
|
||||
|
||||
const sampleResponse: SystemOneResponse = {
|
||||
model: "jev-1.13.0",
|
||||
answers: {
|
||||
human: { type: "noul", noul: 0.99 },
|
||||
dept: {
|
||||
type: "choice",
|
||||
choice: "technical",
|
||||
confidence: 0.27,
|
||||
probabilities: { billing: 0.36, technical: 0.64 },
|
||||
},
|
||||
frustration: {
|
||||
type: "score",
|
||||
score: 2,
|
||||
confidence: 0.99,
|
||||
legend: { "0": "calm", "1": "mild", "2": "angry" },
|
||||
probabilities: { "0": 0, "1": 0, "2": 1 },
|
||||
},
|
||||
},
|
||||
usage: { input_tokens: 378, output_tokens: 63 },
|
||||
};
|
||||
|
||||
describe("ResponseView integration", () => {
|
||||
it("shows OpenAI Decisions answer cards in Form view and the complete response in JSON view", async () => {
|
||||
const response: OpenAIDecisionsResponse = {
|
||||
model: "jev-latest",
|
||||
answers: [
|
||||
{ name: null, type: "predicate", probability: 0 },
|
||||
{
|
||||
name: "route",
|
||||
type: "choice",
|
||||
choice: false,
|
||||
confidence: 0.8,
|
||||
probabilities: [
|
||||
{ value: false, probability: 0.8 },
|
||||
{ value: "other", probability: 0.2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "severity",
|
||||
type: "score",
|
||||
score: 0,
|
||||
confidence: 0,
|
||||
probabilities: [
|
||||
{ value: 0, label: "low", probability: 1 },
|
||||
{ value: 1, label: "high", probability: 0 },
|
||||
],
|
||||
},
|
||||
{ name: "blocked", type: "refusal" },
|
||||
{ name: "blocked", type: "refusal" },
|
||||
],
|
||||
usage: { input_tokens: 3, output_tokens: 4, total_tokens: 7 },
|
||||
request_id: "keep-extra-fields",
|
||||
};
|
||||
const { rerender } = render(<ResponseView response={response} isLoading={false} view="form" />);
|
||||
expect(screen.getByText("Question 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("0% yes")).toBeInTheDocument();
|
||||
expect(screen.getByRole("meter", { name: "false probability" })).toHaveAttribute("aria-valuetext", "80%, selected");
|
||||
expect(screen.getByRole("meter", { name: "0: low probability" })).toHaveAttribute(
|
||||
"aria-valuetext",
|
||||
"100%, selected",
|
||||
);
|
||||
expect(screen.getByText("0% confidence")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Model refused to answer")).toHaveLength(2);
|
||||
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" />);
|
||||
expect(screen.queryByRole("meter")).not.toBeInTheDocument();
|
||||
expect(JSON.parse(screen.getByRole("region", { name: "Decisions response JSON" }).textContent ?? "")).toEqual(
|
||||
response,
|
||||
);
|
||||
rerender(<ResponseView response={response} isLoading={false} view="form" />);
|
||||
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" />);
|
||||
expect(screen.queryByRole("meter")).not.toBeInTheDocument();
|
||||
expect(JSON.parse(screen.getByRole("region", { name: "Decisions response JSON" }).textContent ?? "")).toEqual(
|
||||
sampleResponse,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders calibrated probabilities for choice, noul, and score answers", () => {
|
||||
render(<ResponseView response={sampleResponse} isLoading={false} latencyMs={120} />);
|
||||
|
||||
expect(screen.getAllByText("technical")).toHaveLength(2);
|
||||
const meters = screen.getAllByRole("meter");
|
||||
expect(meters.map((meter) => meter.getAttribute("aria-label"))).toEqual([
|
||||
"Yes probability",
|
||||
"technical probability",
|
||||
"billing probability",
|
||||
"0: calm probability",
|
||||
"1: mild probability",
|
||||
"2: angry probability",
|
||||
]);
|
||||
expect(screen.getByRole("meter", { name: "technical probability" })).toHaveAttribute("aria-valuenow", "64");
|
||||
expect(screen.getByRole("meter", { name: "billing probability" })).toHaveAttribute("aria-valuenow", "36");
|
||||
expect(screen.getByText("99% yes")).toBeInTheDocument();
|
||||
expect(screen.getByRole("meter", { name: "Yes probability" })).toHaveAttribute("aria-valuenow", "99");
|
||||
expect(screen.getByRole("meter", { name: "2: angry probability" })).toBeInTheDocument();
|
||||
expect(screen.getByText("378 input / 63 output tokens")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selects the score level nearest a fractional calibrated score", () => {
|
||||
const fractional: SystemOneResponse = {
|
||||
answers: {
|
||||
severity: { type: "score", score: 2.39, probabilities: { "1": 0, "2": 0.62, "3": 0.38 } },
|
||||
},
|
||||
};
|
||||
render(<ResponseView response={fractional} isLoading={false} />);
|
||||
|
||||
expect(screen.getByRole("meter", { name: "2 probability" })).toHaveAttribute("aria-valuetext", "62%, selected");
|
||||
expect(screen.getByRole("meter", { name: "3 probability" })).toHaveAttribute("aria-valuetext", "38%");
|
||||
expect(screen.getByRole("meter", { name: "1 probability" })).toHaveAttribute("aria-valuetext", "0%");
|
||||
});
|
||||
|
||||
it("rounds the calibrated score pill to two decimals and leaves integers bare", () => {
|
||||
const precise: SystemOneResponse = {
|
||||
answers: {
|
||||
severity: { type: "score", score: 3.9809950180008897, probabilities: { "3": 0.02, "4": 0.98 } },
|
||||
clarity: { type: "score", score: 2, probabilities: { "2": 1 }, legend: { "2": "Clear enough" } },
|
||||
},
|
||||
};
|
||||
render(<ResponseView response={precise} isLoading={false} />);
|
||||
|
||||
expect(screen.getByText("3.98")).toBeInTheDocument();
|
||||
expect(screen.queryByText("3.9809950180008897")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders structured decision score legends without coercing objects to strings", () => {
|
||||
const response: SystemOneResponse = {
|
||||
answers: {
|
||||
severity: {
|
||||
type: "score",
|
||||
score: 0,
|
||||
probabilities: { "0": 1 },
|
||||
legend: { "0": { description: "Low severity" } },
|
||||
},
|
||||
},
|
||||
usage: null,
|
||||
};
|
||||
render(<ResponseView response={response} isLoading={false} />);
|
||||
expect(screen.getByRole("meter", { name: '0: {"description":"Low severity"} probability' })).toHaveAttribute(
|
||||
"aria-valuenow",
|
||||
"100",
|
||||
);
|
||||
expect(screen.queryByText(/\[object Object\]/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an inline error message", () => {
|
||||
render(<ResponseView error="Upstream request failed" isLoading={false} />);
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Upstream request failed");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ResponseView from "./ResponseView";
|
||||
import type { SystemOneResponse } from "./lib/schemas";
|
||||
|
||||
const sampleResponse: SystemOneResponse = {
|
||||
model: "jev-1.13.0",
|
||||
answers: {
|
||||
human: { type: "noul", noul: 0.99 },
|
||||
dept: {
|
||||
type: "choice",
|
||||
choice: "technical",
|
||||
confidence: 0.27,
|
||||
probabilities: { billing: 0.36, technical: 0.64 },
|
||||
},
|
||||
frustration: {
|
||||
type: "score",
|
||||
score: 2,
|
||||
confidence: 0.99,
|
||||
legend: { "0": "calm", "1": "mild", "2": "angry" },
|
||||
probabilities: { "0": 0, "1": 0, "2": 1 },
|
||||
},
|
||||
},
|
||||
usage: { input_tokens: 378, output_tokens: 63 },
|
||||
};
|
||||
|
||||
describe("ResponseView", () => {
|
||||
it("renders calibrated probabilities for choice, noul, and score answers", () => {
|
||||
render(<ResponseView response={sampleResponse} isLoading={false} latencyMs={120} />);
|
||||
|
||||
expect(screen.getAllByText("technical")).toHaveLength(2);
|
||||
const meters = screen.getAllByRole("meter");
|
||||
expect(meters.map((meter) => meter.getAttribute("aria-label"))).toEqual([
|
||||
"Yes probability",
|
||||
"technical probability",
|
||||
"billing probability",
|
||||
"0: calm probability",
|
||||
"1: mild probability",
|
||||
"2: angry probability",
|
||||
]);
|
||||
expect(screen.getByRole("meter", { name: "technical probability" })).toHaveAttribute("aria-valuenow", "64");
|
||||
expect(screen.getByRole("meter", { name: "billing probability" })).toHaveAttribute("aria-valuenow", "36");
|
||||
expect(screen.getByText("99% yes")).toBeInTheDocument();
|
||||
expect(screen.getByRole("meter", { name: "Yes probability" })).toHaveAttribute("aria-valuenow", "99");
|
||||
expect(screen.getByRole("meter", { name: "2: angry probability" })).toBeInTheDocument();
|
||||
expect(screen.getByText("378 input / 63 output tokens")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selects the score level nearest a fractional calibrated score", () => {
|
||||
const fractional: SystemOneResponse = {
|
||||
answers: {
|
||||
severity: { type: "score", score: 2.39, probabilities: { "1": 0, "2": 0.62, "3": 0.38 } },
|
||||
},
|
||||
};
|
||||
render(<ResponseView response={fractional} isLoading={false} />);
|
||||
|
||||
expect(screen.getByRole("meter", { name: "2 probability" })).toHaveAttribute("aria-valuetext", "62%, selected");
|
||||
expect(screen.getByRole("meter", { name: "3 probability" })).toHaveAttribute("aria-valuetext", "38%");
|
||||
expect(screen.getByRole("meter", { name: "1 probability" })).toHaveAttribute("aria-valuetext", "0%");
|
||||
});
|
||||
|
||||
it("renders structured decision score legends without coercing objects to strings", () => {
|
||||
const response: SystemOneResponse = {
|
||||
answers: {
|
||||
severity: {
|
||||
type: "score",
|
||||
score: 0,
|
||||
probabilities: { "0": 1 },
|
||||
legend: { "0": { description: "Low severity" } },
|
||||
},
|
||||
},
|
||||
usage: null,
|
||||
};
|
||||
render(<ResponseView response={response} isLoading={false} />);
|
||||
expect(screen.getByRole("meter", { name: '0: {"description":"Low severity"} probability' })).toHaveAttribute(
|
||||
"aria-valuenow",
|
||||
"100",
|
||||
);
|
||||
expect(screen.queryByText(/\[object Object\]/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an inline error message", () => {
|
||||
render(<ResponseView error="Upstream request failed" isLoading={false} />);
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Upstream request failed");
|
||||
});
|
||||
});
|
||||
|
|
@ -6,9 +6,13 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
|
|||
import { ChevronDown, LoaderCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { SystemOneAnswer, SystemOneResponse } from "./lib/schemas";
|
||||
import type { OpenAIDecisionsResponse } from "./lib/openAIDecisions";
|
||||
|
||||
const SCORE_FORMAT = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 });
|
||||
|
||||
interface ResponseViewProps {
|
||||
response?: SystemOneResponse;
|
||||
response?: SystemOneResponse | OpenAIDecisionsResponse;
|
||||
view?: "form" | "json";
|
||||
fallbackModel?: string;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
|
|
@ -85,7 +89,7 @@ function AnswerDetails({ answer }: { answer: SystemOneAnswer }) {
|
|||
<div className="grid gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Score</span>
|
||||
<Badge>{answer.score}</Badge>
|
||||
<Badge>{SCORE_FORMAT.format(answer.score)}</Badge>
|
||||
{answer.confidence !== undefined && (
|
||||
<Badge variant="outline">{Math.round(answer.confidence * 100)}% confidence</Badge>
|
||||
)}
|
||||
|
|
@ -111,7 +115,87 @@ function AnswerDetails({ answer }: { answer: SystemOneAnswer }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function ResponseView({ response, fallbackModel, latencyMs, error, isLoading }: ResponseViewProps) {
|
||||
function OpenAIAnswerDetails({ answer }: { answer: OpenAIDecisionsResponse["answers"][number] }) {
|
||||
switch (answer.type) {
|
||||
case "refusal":
|
||||
return <p className="text-sm text-muted-foreground">Model refused to answer</p>;
|
||||
case "predicate":
|
||||
return <AnswerDetails answer={{ type: "noul", noul: answer.probability }} />;
|
||||
case "choice":
|
||||
return (
|
||||
<AnswerDetails
|
||||
answer={{
|
||||
type: "choice",
|
||||
choice: String(answer.choice),
|
||||
confidence: answer.confidence,
|
||||
probabilities: Object.fromEntries(
|
||||
answer.probabilities.map(({ value, probability }) => [String(value), probability]),
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "score":
|
||||
return (
|
||||
<AnswerDetails
|
||||
answer={{
|
||||
type: "score",
|
||||
score: answer.score,
|
||||
confidence: answer.confidence,
|
||||
probabilities: Object.fromEntries(
|
||||
answer.probabilities.map(({ value, probability }) => [String(value), probability]),
|
||||
),
|
||||
legend: Object.fromEntries(answer.probabilities.map(({ value, label }) => [String(value), label])),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function RawResponse({ response }: { response: NonNullable<ResponseViewProps["response"]> }) {
|
||||
return (
|
||||
<section aria-label="Decisions response JSON">
|
||||
<pre className="whitespace-pre-wrap wrap-anywhere rounded-md bg-muted p-3 font-mono text-xs">
|
||||
{JSON.stringify(response, null, 2)}
|
||||
</pre>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AnswerCards({ answers }: { answers: SystemOneResponse["answers"] | OpenAIDecisionsResponse["answers"] }) {
|
||||
const cards = Array.isArray(answers)
|
||||
? answers.map((answer, index) => ({
|
||||
id: index,
|
||||
name: answer.name ?? `Question ${index + 1}`,
|
||||
type: answer.type,
|
||||
details: <OpenAIAnswerDetails answer={answer} />,
|
||||
}))
|
||||
: Object.entries(answers).map(([id, answer]) => ({
|
||||
id,
|
||||
name: id,
|
||||
type: answer.type,
|
||||
details: <AnswerDetails answer={answer} />,
|
||||
}));
|
||||
return cards.map(({ id, name, type, details }) => (
|
||||
<Card key={id} size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-mono">{name}</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="secondary">{type}</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>{details}</CardContent>
|
||||
</Card>
|
||||
));
|
||||
}
|
||||
|
||||
export default function ResponseView({
|
||||
response,
|
||||
view = "form",
|
||||
fallbackModel,
|
||||
latencyMs,
|
||||
error,
|
||||
isLoading,
|
||||
}: ResponseViewProps) {
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
|
||||
if (isLoading) {
|
||||
|
|
@ -128,7 +212,7 @@ export default function ResponseView({ response, fallbackModel, latencyMs, error
|
|||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>System One request failed</AlertTitle>
|
||||
<AlertTitle>Decisions request failed</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
|
@ -139,7 +223,7 @@ export default function ResponseView({ response, fallbackModel, latencyMs, error
|
|||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Calibrated probabilities</CardTitle>
|
||||
<CardDescription>Send a request to see System One answers and probabilities.</CardDescription>
|
||||
<CardDescription>Send a request to see decision answers and probabilities.</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
|
|
@ -152,7 +236,7 @@ export default function ResponseView({ response, fallbackModel, latencyMs, error
|
|||
<CardHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="grid gap-1">
|
||||
<CardTitle>Calibrated probabilities</CardTitle>
|
||||
<CardTitle>{view === "json" ? "Response JSON" : "Calibrated probabilities"}</CardTitle>
|
||||
{model && <CardDescription>{model}</CardDescription>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
|
|
@ -166,34 +250,22 @@ export default function ResponseView({ response, fallbackModel, latencyMs, error
|
|||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
{Object.entries(response.answers).map(([id, answer]) => (
|
||||
<Card key={id} size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-mono">{id}</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="secondary">{answer.type}</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AnswerDetails answer={answer} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<Collapsible open={showRaw} onOpenChange={setShowRaw}>
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="sm">
|
||||
<ChevronDown className="size-4" />
|
||||
Raw response
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CollapsibleContent>
|
||||
<pre className="whitespace-pre-wrap wrap-anywhere rounded-md bg-muted p-3 font-mono text-xs">
|
||||
{JSON.stringify(response, null, 2)}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
{view === "json" ? <RawResponse response={response} /> : <AnswerCards answers={response.answers} />}
|
||||
{view === "form" && (
|
||||
<Collapsible open={showRaw} onOpenChange={setShowRaw}>
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="sm">
|
||||
<ChevronDown className="size-4" />
|
||||
Raw response
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CollapsibleContent>
|
||||
<RawResponse response={response} />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,10 +5,8 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useId, useMemo, useState, type ReactNode } from "react";
|
||||
import { IssueList, ValidationStatus } from "./JsonEditor";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { AddButton, FormFrame, RemoveButton } from "./DecisionFormControls";
|
||||
import {
|
||||
blankQuestion,
|
||||
freeName,
|
||||
|
|
@ -29,7 +27,12 @@ import {
|
|||
import type { SystemOnePayloadValidation } from "./lib/validatePayload";
|
||||
|
||||
const QUESTION_TYPES: readonly QuestionType[] = ["choice", "noul", "score"];
|
||||
const TYPE_LABELS: Record<QuestionType, string> = { choice: "Choice", noul: "Yes / no", score: "Score" };
|
||||
const TYPE_LABELS: Record<QuestionType, string> = { choice: "Choice", noul: "noul", score: "Score" };
|
||||
const TYPE_HELP: Record<QuestionType, string | undefined> = {
|
||||
choice: undefined,
|
||||
noul: "noul is a yes / no question",
|
||||
score: undefined,
|
||||
};
|
||||
|
||||
const isQuestionType = (value: unknown): value is QuestionType => QUESTION_TYPES.some((type) => type === value);
|
||||
|
||||
|
|
@ -97,23 +100,6 @@ function KeyInput({ value, taken, onCommit, label, placeholder }: KeyInputProps)
|
|||
);
|
||||
}
|
||||
|
||||
function RemoveButton({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<Button variant="ghost" size="icon" aria-label={label} title={label} onClick={onClick}>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function AddButton({ children, onClick }: { children: ReactNode; onClick: () => void }) {
|
||||
return (
|
||||
<Button variant="outline" size="sm" className="w-fit" onClick={onClick}>
|
||||
<Plus />
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function ChoiceOptions({ question, onChange }: QuestionEditorProps<ChoiceQuestion>) {
|
||||
const labels = Object.keys(question.criteria);
|
||||
const writeCriteria = (criteria: Record<string, string>) => onChange({ ...question, criteria });
|
||||
|
|
@ -238,7 +224,7 @@ function QuestionCard({ name, question, taken, onChange, onRename, onRemove }: Q
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{QUESTION_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
<SelectItem key={type} value={type} title={TYPE_HELP[type]}>
|
||||
{TYPE_LABELS[type]}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
|
@ -261,26 +247,6 @@ function QuestionCard({ name, question, taken, onChange, onRename, onRemove }: Q
|
|||
);
|
||||
}
|
||||
|
||||
function FormFrame({ validation, children }: { validation: SystemOnePayloadValidation; children: ReactNode }) {
|
||||
const issuesId = useId();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-96 flex-1 flex-col overflow-hidden rounded-md border bg-background",
|
||||
!validation.isValid && "border-destructive/60",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<span className="text-sm font-medium">Request form</span>
|
||||
<ValidationStatus validation={validation} />
|
||||
</div>
|
||||
<div className="grid min-h-80 flex-1 content-start gap-4 overflow-auto p-3">{children}</div>
|
||||
<IssueList id={issuesId} validation={validation} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SystemOneForm({ value, onChange, validation, onOpenJson }: SystemOneFormProps) {
|
||||
const stateId = useId();
|
||||
const form = useMemo(() => readForm(value), [value]);
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ import {
|
|||
import type { ReactElement } from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import SystemOneUI from "./SystemOneUI";
|
||||
import SystemOneUI, { NOUL_HELP } from "./SystemOneUI";
|
||||
import type { SystemOneResponse } from "./lib/schemas";
|
||||
import type { OpenAIDecisionsResponse } from "./lib/openAIDecisions";
|
||||
|
||||
interface ModelGroupInfo {
|
||||
model_group: string;
|
||||
|
|
@ -64,7 +65,19 @@ const openJsonEditor = async () => {
|
|||
return screen.getByRole("textbox", { name: "System One JSON payload" });
|
||||
};
|
||||
|
||||
const createResponse = (body: SystemOneResponse, status = 200, errorText = "") =>
|
||||
const selectEndpoint = async (endpoint: "/v1/decisions" | "/v1/systemone") => {
|
||||
screen.getByRole("combobox", { name: "Decision endpoint" }).focus();
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
await userEvent.click(await screen.findByRole("option", { name: endpoint, exact: true }));
|
||||
};
|
||||
|
||||
const selectPreset = async (label: string) => {
|
||||
screen.getByRole("combobox", { name: "Example" }).focus();
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
await userEvent.click(await screen.findByRole("option", { name: label }));
|
||||
};
|
||||
|
||||
const createResponse = (body: SystemOneResponse | OpenAIDecisionsResponse, status = 200, errorText = "") =>
|
||||
({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
|
|
@ -92,28 +105,94 @@ describe("SystemOneUI integration", () => {
|
|||
document.cookie = "token=; path=/; max-age=0";
|
||||
});
|
||||
|
||||
it("restores the example after the request is edited", async () => {
|
||||
const user = userEvent.setup();
|
||||
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 resetButton = screen.getByRole("button", { name: "Reset example" });
|
||||
expect(resetButton).toBeDisabled();
|
||||
const picker = screen.getByRole("combobox", { name: "Example" });
|
||||
expect(picker).toHaveTextContent("Triage a bug report");
|
||||
|
||||
fireEvent.change(editor, { target: { value: "{}" } });
|
||||
expect(resetButton).toBeEnabled();
|
||||
await user.click(resetButton);
|
||||
|
||||
expect(picker).toHaveTextContent("Custom");
|
||||
await selectPreset("Triage a bug report");
|
||||
expect(editor).toHaveValue(example);
|
||||
expect(resetButton).toBeDisabled();
|
||||
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" } } });
|
||||
});
|
||||
|
||||
it("explains the native decision endpoint after opting in without announcing it as an alert", async () => {
|
||||
it("clears the whole request and lets a preset be picked from nothing", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
const clear = screen.getByRole("button", { name: "Clear" });
|
||||
expect(screen.getByRole("textbox", { name: "Input" })).toHaveDisplayValue(/streaming responses/);
|
||||
|
||||
await user.click(clear);
|
||||
expect(screen.getByRole("textbox", { name: "Input" })).toHaveValue("");
|
||||
expect(screen.queryAllByRole("group", { name: /Question / })).toHaveLength(0);
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
expect(clear).toBeDisabled();
|
||||
expect(screen.getByRole("combobox", { name: "Example" })).toHaveTextContent("Custom");
|
||||
|
||||
await selectPreset("Route a support ticket");
|
||||
expect(screen.getByRole("textbox", { name: "Input" })).toHaveDisplayValue(/charged twice/);
|
||||
expect(screen.getAllByRole("group", { name: /Question / })).toHaveLength(1);
|
||||
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 () => {
|
||||
mockModelLookup.mockResolvedValue(modelGroupInfoResponse(DECISION_AND_CHAT_MODELS));
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
await waitFor(() => expect(screen.getByRole("combobox", { name: "Decision model" })).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) },
|
||||
});
|
||||
expect(picker).toHaveTextContent("Triage a bug report");
|
||||
|
||||
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 selectPreset("Score a sales lead");
|
||||
expect(JSON.parse((editor as HTMLTextAreaElement).value)).not.toHaveProperty("model");
|
||||
});
|
||||
|
||||
it("offers the same presets on /v1/decisions in the OpenAI shape and clears to an empty input", async () => {
|
||||
const user = userEvent.setup();
|
||||
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);
|
||||
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(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("offers both literal proxy endpoints without TypeSafe or product prefixes", async () => {
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
|
||||
expect(screen.getByRole("combobox", { name: "Decision endpoint" })).toHaveTextContent("/v1/systemone");
|
||||
screen.getByRole("combobox", { name: "Decision endpoint" }).focus();
|
||||
await user.keyboard("{ArrowDown}");
|
||||
await user.click(await screen.findByRole("option", { name: "System One · /v1/systemone" }));
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
expect(await screen.findByRole("option", { name: "/v1/decisions", exact: true })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "/v1/systemone", exact: true })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("option")).toHaveLength(2);
|
||||
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.",
|
||||
|
|
@ -134,7 +213,7 @@ describe("SystemOneUI integration", () => {
|
|||
mockModelLookup.mockResolvedValue(modelGroupInfoResponse(DECISION_AND_CHAT_MODELS));
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
|
||||
expect(await screen.findByRole("combobox", { name: "Decision model" })).toHaveValue("jev-latest");
|
||||
await waitFor(() => expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveValue("jev-latest"));
|
||||
expect(lookupAuthHeaders(mockModelLookup.mock.calls)).toEqual([expect.arrayContaining(["Bearer session-key"])]);
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
|
|
@ -147,6 +226,297 @@ describe("SystemOneUI integration", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("sends each endpoint's real shape and switches existing responses between cards and JSON without resending", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockModelLookup.mockResolvedValue(modelGroupInfoResponse(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 } });
|
||||
|
||||
await selectEndpoint("/v1/decisions");
|
||||
const editor = screen.getByRole("textbox", { name: "Decisions JSON payload" });
|
||||
const nativeExample = (editor as HTMLTextAreaElement).value;
|
||||
expect(JSON.parse(nativeExample)).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) } });
|
||||
const response: OpenAIDecisionsResponse = {
|
||||
model: "jev-latest",
|
||||
answers: [{ name: "urgent", 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(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(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();
|
||||
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(screen.getByRole("meter", { name: "technical probability" })).toHaveAttribute("aria-valuenow", "90");
|
||||
await user.click(screen.getByRole("tab", { name: "JSON" }));
|
||||
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));
|
||||
|
||||
await selectEndpoint("/v1/decisions");
|
||||
expect(screen.getByRole("textbox", { name: "Decisions JSON payload" })).toHaveValue(JSON.stringify(payload));
|
||||
await selectPreset("Triage a bug report");
|
||||
expect(screen.getByRole("textbox", { name: "Decisions JSON payload" })).toHaveValue(nativeExample);
|
||||
await selectEndpoint("/v1/systemone");
|
||||
expect(screen.getByRole("textbox", { name: "System One JSON payload" })).toHaveValue(systemOneDraft);
|
||||
});
|
||||
|
||||
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: "{" } });
|
||||
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("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 () => {
|
||||
const user = userEvent.setup();
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
createResponse({
|
||||
model: "jev-latest",
|
||||
answers: [],
|
||||
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
|
||||
}),
|
||||
);
|
||||
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" } });
|
||||
fireEvent.change(firstQuestion.getByRole("textbox", { name: "Instructions" }), {
|
||||
target: { value: "Which team owns this?" },
|
||||
});
|
||||
fireEvent.change(firstQuestion.getByRole("textbox", { name: "Choice 1 value" }), {
|
||||
target: { value: "infra" },
|
||||
});
|
||||
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);
|
||||
expect(payload).toMatchObject({ input: "Login is broken", safety_identifier: "qa-session" });
|
||||
const expectedQuestion = {
|
||||
name: "team",
|
||||
type: "choice",
|
||||
instructions: "Which team owns this?",
|
||||
choices: expect.arrayContaining([expect.objectContaining({ value: "infra" })]),
|
||||
};
|
||||
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(await screen.findByRole("button", { name: "Raw response" })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("tab", { name: "JSON" }));
|
||||
expect(screen.getByRole("region", { name: "Decisions response JSON" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds and removes Decisions questions, boolean choices and score levels without stale fields", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
await selectEndpoint("/v1/decisions");
|
||||
const question = (index: number) => within(screen.getByRole("group", { name: `Question ${index}` }));
|
||||
const pick = async (control: HTMLElement, label: string) => {
|
||||
control.focus();
|
||||
await user.keyboard("{ArrowDown}");
|
||||
await user.click(await screen.findByRole("option", { name: label, exact: true }));
|
||||
};
|
||||
await user.click(screen.getByRole("button", { name: "Add question" }));
|
||||
fireEvent.change(question(4).getByRole("textbox", { name: "Instructions" }), {
|
||||
target: { value: "Should we escalate?" },
|
||||
});
|
||||
await pick(question(4).getByRole("combobox", { name: "Answer type" }), "Choice");
|
||||
await user.click(question(4).getByRole("button", { name: "Remove choice 1" }));
|
||||
expect(question(4).getByRole("textbox", { name: "Choice 1 value" })).toHaveValue("option_2");
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
await user.click(question(4).getByRole("button", { name: "Add choice" }));
|
||||
await pick(question(4).getByRole("combobox", { name: "Choice 1 value type" }), "false (boolean)");
|
||||
fireEvent.change(question(4).getByRole("textbox", { name: "Choice 1 description" }), {
|
||||
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 expectedChoice = {
|
||||
type: "choice",
|
||||
name: "question_1",
|
||||
instructions: "Should we escalate?",
|
||||
choices: [{ value: false, description: "Not urgent" }, { value: "option_1" }],
|
||||
};
|
||||
expect(choicePayload.questions[3]).toEqual(expectedChoice);
|
||||
await user.click(screen.getByRole("tab", { name: "Form" }));
|
||||
await pick(question(4).getByRole("combobox", { name: "Answer type" }), "Score");
|
||||
fireEvent.change(question(4).getByRole("textbox", { name: "Level 1 label" }), { target: { value: "low" } });
|
||||
fireEvent.change(question(4).getByRole("textbox", { name: "Level 1 description" }), {
|
||||
target: { value: "No impact" },
|
||||
});
|
||||
await user.click(question(4).getByRole("button", { name: "Add level" }));
|
||||
await user.click(question(4).getByRole("button", { name: "Remove level 2" }));
|
||||
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,
|
||||
);
|
||||
expect(scorePayload.questions).toHaveLength(3);
|
||||
const expectedScore = {
|
||||
type: "score",
|
||||
name: "question_1",
|
||||
instructions: "Should we escalate?",
|
||||
levels: [{ label: "low", description: "No impact" }, { label: "2" }],
|
||||
};
|
||||
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,
|
||||
);
|
||||
expect(predicatePayload.questions[2]).toEqual({
|
||||
type: "predicate",
|
||||
name: "question_1",
|
||||
instructions: "Should we escalate?",
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
|
||||
rtlRender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SystemOneUI accessToken="session-key" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Send" }));
|
||||
await screen.findByRole("button", { name: "Cancel request" });
|
||||
await selectEndpoint("/v1/decisions");
|
||||
expect(mockFetch.mock.calls[0]?.[1]?.signal?.aborted).toBe(true);
|
||||
await act(async () => {
|
||||
if (outcome === "success") {
|
||||
pending.resolve(createResponse({ model: "stale-model", answers: {} }));
|
||||
} else {
|
||||
pending.reject(new Error("Stale request failed"));
|
||||
}
|
||||
await pending.promise.catch(() => undefined);
|
||||
});
|
||||
await waitFor(() => expect(queryClient.isMutating()).toBe(0));
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("stale-model")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Form" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("offers only decision models and writes the picked one into the request", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockModelLookup.mockResolvedValue(modelGroupInfoResponse(DECISION_AND_CHAT_MODELS));
|
||||
|
|
@ -165,16 +535,31 @@ describe("SystemOneUI integration", () => {
|
|||
questions: { has_repro_steps: { type: "noul" } },
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
await user.click(screen.getByRole("tab", { name: "Form" }));
|
||||
expect(await screen.findByText("Selected choice")).toBeInTheDocument();
|
||||
expect(JSON.parse(mockFetch.mock.calls[0]?.[1]?.body as string).model).toBe("pplx-decider");
|
||||
});
|
||||
|
||||
it("links to Add Model when the key cannot call any decision model", async () => {
|
||||
it("routes a configured TypeSafe model through the same proxy endpoint", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockModelLookup.mockResolvedValue(
|
||||
modelGroupInfoResponse([{ model_group: "typesafe/jev-latest", mode: "evaluation" }]),
|
||||
);
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveValue("typesafe/jev-latest"),
|
||||
);
|
||||
|
||||
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(mockFetch.mock.calls[0]?.[0]).not.toMatch(/\/typesafe\//);
|
||||
expect(JSON.parse(mockFetch.mock.calls[0]?.[1]?.body as string).model).toBe("typesafe/jev-latest");
|
||||
});
|
||||
|
||||
it("links to Add Model when the key cannot call any decision model", async () => {
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
screen.getByRole("combobox", { name: "Decision endpoint" }).focus();
|
||||
await user.keyboard("{ArrowDown}");
|
||||
await user.click(await screen.findByRole("option", { name: "System One · /v1/systemone" }));
|
||||
|
||||
expect(await screen.findByRole("link", { name: "Add a decision model" })).toHaveAttribute(
|
||||
"href",
|
||||
|
|
@ -193,7 +578,7 @@ describe("SystemOneUI integration", () => {
|
|||
|
||||
await user.type(screen.getByLabelText("Virtual Key", { exact: true }), "sk-typed-key");
|
||||
|
||||
expect(await screen.findByRole("combobox", { name: "Decision model" })).toHaveValue("jev-latest");
|
||||
await waitFor(() => expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveValue("jev-latest"));
|
||||
expect(lookupAuthHeaders(mockModelLookup.mock.calls)).toEqual([expect.arrayContaining(["Bearer sk-typed-key"])]);
|
||||
});
|
||||
|
||||
|
|
@ -209,10 +594,10 @@ describe("SystemOneUI integration", () => {
|
|||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
expect(document.cookie).toContain("token=ui-session");
|
||||
expect(screen.getByRole("combobox", { name: "Decision endpoint" })).toHaveTextContent("/typesafe/v1/systemone");
|
||||
expect(screen.getByRole("combobox", { name: "Decision endpoint" })).toHaveTextContent("/v1/systemone");
|
||||
});
|
||||
|
||||
it("keeps an edited TypeSafe request in place when decision models load afterwards", async () => {
|
||||
it("keeps an edited request in place when decision models load afterwards", async () => {
|
||||
const models = Promise.withResolvers<Response>();
|
||||
mockModelLookup.mockReturnValue(models.promise);
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
|
|
@ -226,9 +611,37 @@ describe("SystemOneUI integration", () => {
|
|||
});
|
||||
|
||||
expect(editor).toHaveValue(draft);
|
||||
expect(screen.queryByRole("combobox", { name: "Decision model" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveValue("");
|
||||
});
|
||||
|
||||
it.each(["/v1/decisions", "/v1/systemone"] as const)(
|
||||
"omits model while discovery is pending on %s",
|
||||
async (endpoint) => {
|
||||
const models = Promise.withResolvers<Response>();
|
||||
mockModelLookup.mockReturnValue(models.promise);
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
createResponse({
|
||||
model: "jev-latest",
|
||||
answers: endpoint === "/v1/decisions" ? [] : {},
|
||||
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
|
||||
}),
|
||||
);
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
if (endpoint === "/v1/decisions") {
|
||||
await selectEndpoint(endpoint);
|
||||
}
|
||||
await userEvent.click(screen.getByRole("button", { name: "Send" }));
|
||||
expect(await screen.findByText("jev-latest")).toBeInTheDocument();
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toMatch(new RegExp(`${endpoint}$`));
|
||||
expect(JSON.parse(mockFetch.mock.calls[0]?.[1]?.body as string)).not.toHaveProperty("model");
|
||||
await act(async () => {
|
||||
models.resolve(modelGroupInfoResponse(DECISION_AND_CHAT_MODELS));
|
||||
await models.promise;
|
||||
});
|
||||
expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveValue("");
|
||||
},
|
||||
);
|
||||
|
||||
it("shows invalid JSON and disables Send", async () => {
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
|
||||
|
|
@ -240,7 +653,7 @@ describe("SystemOneUI integration", () => {
|
|||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("preserves the untouched TypeSafe default and renders calibrated answers", async () => {
|
||||
it("sends to /v1/systemone even without discovered models and renders calibrated answers", async () => {
|
||||
const user = userEvent.setup();
|
||||
sessionStorage.setItem("customProxyBaseUrl", "https://stale.example.com/");
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
|
|
@ -251,18 +664,19 @@ describe("SystemOneUI integration", () => {
|
|||
expect(await screen.findAllByText("technical")).toHaveLength(2);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, options] = mockFetch.mock.calls[0] ?? [];
|
||||
expect(url).toBe("https://tenant.example.com/typesafe/v1/systemone");
|
||||
expect(url).toBe("https://tenant.example.com/v1/systemone");
|
||||
expect(options?.method).toBe("POST");
|
||||
expect(options?.headers).toMatchObject({ "Content-Type": "application/json" });
|
||||
expect(Object.values(options?.headers as Record<string, string>)).toContain("Bearer session-key");
|
||||
expect(JSON.parse(options?.body as string)).toMatchObject({
|
||||
model: "jev-latest",
|
||||
questions: { has_repro_steps: { type: "noul" } },
|
||||
});
|
||||
expect(JSON.parse(options?.body as string)).not.toHaveProperty("model");
|
||||
});
|
||||
|
||||
it("uses the requested model when the response omits it", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockModelLookup.mockResolvedValue(modelGroupInfoResponse(DECISION_AND_CHAT_MODELS));
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
createResponse({
|
||||
answers: responseBody.answers,
|
||||
|
|
@ -270,13 +684,15 @@ describe("SystemOneUI integration", () => {
|
|||
);
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveValue("jev-latest"));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
expect(await screen.findByText("jev-latest")).toBeInTheDocument();
|
||||
expect(screen.getByText("Selected choice")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps legacy validation when question values have invalid types", async () => {
|
||||
it("validates native question values before sending", async () => {
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
|
||||
fireEvent.change(await openJsonEditor(), {
|
||||
|
|
@ -286,25 +702,22 @@ describe("SystemOneUI integration", () => {
|
|||
questions: {
|
||||
category: {
|
||||
type: "choice",
|
||||
instructions: { text: "Route the message" },
|
||||
criteria: { support: { text: "Help" } },
|
||||
instructions: 1,
|
||||
criteria: { support: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText("Instructions must be a string.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Choice descriptions must be strings.")).toBeInTheDocument();
|
||||
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("preserves each endpoint draft and sends legacy requests to TypeSafe", async () => {
|
||||
it("sends structured native questions without changing their payload", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
screen.getByRole("combobox", { name: "Decision endpoint" }).focus();
|
||||
await user.keyboard("{ArrowDown}");
|
||||
await user.click(await screen.findByRole("option", { name: "System One · /v1/systemone" }));
|
||||
const editor = await openJsonEditor();
|
||||
const draft = JSON.stringify({
|
||||
model: "my-decider",
|
||||
|
|
@ -315,33 +728,18 @@ describe("SystemOneUI integration", () => {
|
|||
});
|
||||
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();
|
||||
|
||||
screen.getByRole("combobox", { name: "Decision endpoint" }).focus();
|
||||
await user.keyboard("{ArrowDown}");
|
||||
await user.click(await screen.findByRole("option", { name: "TypeSafe · /typesafe/v1/systemone" }));
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
expect(await screen.findByText("Selected choice")).toBeInTheDocument();
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toMatch(/\/typesafe\/v1\/systemone$/);
|
||||
expect(JSON.parse(mockFetch.mock.calls[0]?.[1]?.body as string).model).toBe("jev-latest");
|
||||
|
||||
screen.getByRole("combobox", { name: "Decision endpoint" }).focus();
|
||||
await user.keyboard("{ArrowDown}");
|
||||
await user.click(await screen.findByRole("option", { name: "System One · /v1/systemone" }));
|
||||
expect(editor).toHaveValue(draft);
|
||||
expect(screen.queryByText("Selected choice")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
expect(await screen.findByText("Selected choice")).toBeInTheDocument();
|
||||
expect(mockFetch.mock.calls[1]?.[0]).toMatch(/\/v1\/systemone$/);
|
||||
expect(JSON.parse(mockFetch.mock.calls[1]?.[1]?.body as string)).toEqual(JSON.parse(draft));
|
||||
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" />);
|
||||
screen.getByRole("combobox", { name: "Decision endpoint" }).focus();
|
||||
await user.keyboard("{ArrowDown}");
|
||||
await user.click(await screen.findByRole("option", { name: "System One · /v1/systemone" }));
|
||||
const payload = { state: "An outage", questions: { urgent: { type: "noul", instructions: "Is this urgent?" } } };
|
||||
fireEvent.change(await openJsonEditor(), {
|
||||
target: { value: JSON.stringify(payload) },
|
||||
|
|
@ -355,7 +753,7 @@ describe("SystemOneUI integration", () => {
|
|||
expect(body).not.toHaveProperty("model");
|
||||
});
|
||||
|
||||
it.each(["success", "error"])("ignores a late %s after switching endpoints", async (outcome) => {
|
||||
it.each(["success", "error"])("ignores a late %s after editing the request", async (outcome) => {
|
||||
const user = userEvent.setup();
|
||||
const pending = Promise.withResolvers<Response>();
|
||||
mockFetch.mockReturnValueOnce(pending.promise);
|
||||
|
|
@ -367,9 +765,7 @@ describe("SystemOneUI integration", () => {
|
|||
);
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
await screen.findByRole("button", { name: "Cancel request" });
|
||||
screen.getByRole("combobox", { name: "Decision endpoint" }).focus();
|
||||
await user.keyboard("{ArrowDown}");
|
||||
await user.click(await screen.findByRole("option", { name: "System One · /v1/systemone" }));
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Input" }), { target: { value: "A new request" } });
|
||||
expect(mockFetch.mock.calls[0]?.[1]?.signal?.aborted).toBe(true);
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeEnabled();
|
||||
|
||||
|
|
@ -437,7 +833,7 @@ describe("SystemOneUI integration", () => {
|
|||
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("System One response has an invalid shape.");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("/v1/systemone response has an invalid shape.");
|
||||
expect(screen.queryByText("jev-1.13.0")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -524,6 +920,22 @@ describe("SystemOneUI integration", () => {
|
|||
fireEvent.blur(field);
|
||||
}
|
||||
|
||||
it("explains noul on hover in the banner and 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(
|
||||
"title",
|
||||
"noul is a yes / no question",
|
||||
);
|
||||
expect(screen.getByRole("option", { name: "Choice" })).not.toHaveAttribute("title");
|
||||
});
|
||||
|
||||
it("sends a choice and a yes/no question built in the form without opening JSON", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
|
|
@ -545,7 +957,7 @@ describe("SystemOneUI integration", () => {
|
|||
fill(question("team").getByRole("textbox", { name: "Description of infra" }), "Deploys");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Add question" }));
|
||||
await pickAnswerType(user, "question_1", "Yes / no");
|
||||
await pickAnswerType(user, "question_1", "noul");
|
||||
rename(question("question_1").getByRole("textbox", { name: "Question name" }), "urgent");
|
||||
fill(question("urgent").getByRole("textbox", { name: "Instructions" }), "Is this blocking users?");
|
||||
fill(question("urgent").getByRole("textbox", { name: "Yes means" }), "Nobody can log in");
|
||||
|
|
@ -554,9 +966,8 @@ describe("SystemOneUI integration", () => {
|
|||
|
||||
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(/\/typesafe\/v1\/systemone$/);
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toMatch(/\/v1\/systemone$/);
|
||||
expect(sentBody()).toEqual({
|
||||
model: "jev-latest",
|
||||
state: "The login page is blank after the upgrade",
|
||||
questions: {
|
||||
team: {
|
||||
|
|
@ -575,7 +986,7 @@ describe("SystemOneUI integration", () => {
|
|||
const user = userEvent.setup();
|
||||
mockModelLookup.mockResolvedValue(modelGroupInfoResponse(DECISION_AND_CHAT_MODELS));
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
expect(await screen.findByRole("combobox", { name: "Decision model" })).toHaveValue("jev-latest");
|
||||
await waitFor(() => expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveValue("jev-latest"));
|
||||
|
||||
const level = question("severity").getByRole("textbox", { name: "Level 0" });
|
||||
fill(level, "Typo");
|
||||
|
|
|
|||
|
|
@ -5,21 +5,22 @@ import { Button } 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 { DECISIONS_DOCS_URL } from "@/lib/decisionModels";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Code, Info, LoaderCircle, RotateCcw, Send } from "lucide-react";
|
||||
import { Code, Eraser, Info, LoaderCircle, Send } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { makeSystemOneRequest } from "../../llm_calls/system_one";
|
||||
import { PLACEHOLDER_DECISION_MODEL, SYSTEM_ONE_EXAMPLE, decisionsExample } from "./lib/example";
|
||||
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 type { DecisionEndpoint, PlaygroundRequest } from "./lib/schemas";
|
||||
import JsonEditor from "./JsonEditor";
|
||||
import QuestionBreakdown from "./QuestionBreakdown";
|
||||
import ResponseView from "./ResponseView";
|
||||
import SystemOneForm from "./SystemOneForm";
|
||||
import { validateSystemOnePayload } from "./lib/validatePayload";
|
||||
import OpenAIDecisionsForm from "./OpenAIDecisionsForm";
|
||||
import { validateSystemOnePayload, validateOpenAIDecisionsPayload } from "./lib/validatePayload";
|
||||
import { useDecisionModels, type ApiKeySource } from "./useDecisionModels";
|
||||
|
||||
interface SystemOneUIProps {
|
||||
|
|
@ -30,15 +31,42 @@ interface SystemOneUIProps {
|
|||
type EditorView = "form" | "json";
|
||||
|
||||
interface SystemOneSendVariables {
|
||||
payload: PlaygroundRequest;
|
||||
endpoint: DecisionEndpoint;
|
||||
request: PlaygroundDecisionRequest;
|
||||
apiKey: string;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
const EXAMPLE_PAYLOAD = JSON.stringify(SYSTEM_ONE_EXAMPLE, null, 2);
|
||||
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;
|
||||
onPick: (id: string | null) => void;
|
||||
}
|
||||
|
||||
function PresetPicker({ activePreset, onPick }: PresetPickerProps) {
|
||||
return (
|
||||
<Select value={activePreset?.id ?? CUSTOM_PRESET} onValueChange={onPick}>
|
||||
<SelectTrigger className="w-56" aria-label="Example">
|
||||
<SelectValue>{activePreset?.label ?? "Custom"}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{activePreset === undefined && (
|
||||
<SelectItem value={CUSTOM_PRESET} disabled>
|
||||
Custom
|
||||
</SelectItem>
|
||||
)}
|
||||
{DECISION_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.id} value={preset.id}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function getCustomProxyBaseUrl(): string | undefined {
|
||||
return typeof window === "undefined" ? undefined : window.sessionStorage.getItem("customProxyBaseUrl") || undefined;
|
||||
}
|
||||
|
|
@ -52,23 +80,27 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
|
|||
effectiveApiKey,
|
||||
getCustomProxyBaseUrl(),
|
||||
);
|
||||
const [chosenEndpoint, setChosenEndpoint] = useState<DecisionEndpoint | null>(null);
|
||||
const endpoint: DecisionEndpoint =
|
||||
chosenEndpoint ?? (decisionModels.length > 0 ? "/v1/systemone" : "/typesafe/v1/systemone");
|
||||
const [drafts, setDrafts] = useState<Partial<Record<DecisionEndpoint, string>>>({});
|
||||
const [endpoint, setEndpoint] = useState<PlaygroundDecisionRequest["endpoint"]>("/v1/systemone");
|
||||
const [drafts, setDrafts] = useState<Partial<Record<PlaygroundDecisionRequest["endpoint"], string>>>({});
|
||||
const [view, setView] = useState<EditorView>("form");
|
||||
const examplePayload =
|
||||
endpoint === "/v1/systemone"
|
||||
? JSON.stringify(decisionsExample(decisionModels[0] ?? PLACEHOLDER_DECISION_MODEL), null, 2)
|
||||
: EXAMPLE_PAYLOAD;
|
||||
const rawPayload = drafts[endpoint] ?? examplePayload;
|
||||
const rawPayload = drafts[endpoint] ?? presetPayload(DECISION_PRESETS[0], endpoint, decisionModels[0]);
|
||||
const draftModel = drafts[endpoint] === undefined ? decisionModels[0] : payloadModel(rawPayload);
|
||||
const activePreset = matchPreset(endpoint, rawPayload);
|
||||
const clearedPayload = emptyPayload(endpoint, draftModel);
|
||||
const activeController = useRef<AbortController | null>(null);
|
||||
const validation = useMemo(() => validateSystemOnePayload(rawPayload, endpoint), [rawPayload, endpoint]);
|
||||
const validated = useMemo(
|
||||
() =>
|
||||
endpoint === "/v1/decisions"
|
||||
? ({ endpoint, validation: validateOpenAIDecisionsPayload(rawPayload) } as const)
|
||||
: ({ endpoint, validation: validateSystemOnePayload(rawPayload, endpoint) } as const),
|
||||
[rawPayload, endpoint],
|
||||
);
|
||||
const validation = validated.validation;
|
||||
const hasSyntaxError = validation.issues.some((issue) => issue.path === "syntax");
|
||||
|
||||
const systemOne = useMutation({
|
||||
mutationFn: ({ payload, apiKey, signal, endpoint }: SystemOneSendVariables) =>
|
||||
makeSystemOneRequest(payload, apiKey, getCustomProxyBaseUrl(), { signal, endpoint }),
|
||||
mutationFn: ({ request, apiKey, signal }: SystemOneSendVariables) =>
|
||||
makePlaygroundDecisionRequest(request, apiKey, getCustomProxyBaseUrl(), { signal }),
|
||||
});
|
||||
const isLoading = systemOne.isPending;
|
||||
const { reset: resetSystemOne } = systemOne;
|
||||
|
|
@ -90,15 +122,15 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
|
|||
function handlePayloadChange(value: string) {
|
||||
if (value !== rawPayload) {
|
||||
clearRequestState();
|
||||
setChosenEndpoint(endpoint);
|
||||
setDrafts((current) => ({ ...current, [endpoint]: value }));
|
||||
}
|
||||
}
|
||||
|
||||
function handleResetExample() {
|
||||
clearRequestState();
|
||||
setChosenEndpoint(endpoint);
|
||||
setDrafts((current) => Object.fromEntries(Object.entries(current).filter(([key]) => key !== endpoint)));
|
||||
function handlePresetPick(id: string | null) {
|
||||
const preset = DECISION_PRESETS.find((candidate) => candidate.id === id);
|
||||
if (preset !== undefined) {
|
||||
handlePayloadChange(presetPayload(preset, endpoint, draftModel));
|
||||
}
|
||||
}
|
||||
|
||||
function handleModelPick(model: string | null) {
|
||||
|
|
@ -115,14 +147,18 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
|
|||
}
|
||||
|
||||
function handleSend() {
|
||||
if (!validation.payload || !effectiveApiKey) {
|
||||
const request: PlaygroundDecisionRequest | undefined =
|
||||
validated.endpoint === "/v1/decisions"
|
||||
? validated.validation.payload && { endpoint: validated.endpoint, payload: validated.validation.payload }
|
||||
: validated.validation.payload && { endpoint: validated.endpoint, payload: validated.validation.payload };
|
||||
if (!request || !effectiveApiKey) {
|
||||
return;
|
||||
}
|
||||
clearRequestState();
|
||||
setChosenEndpoint(endpoint);
|
||||
setDrafts((current) => ({ ...current, [endpoint]: rawPayload }));
|
||||
const controller = new AbortController();
|
||||
activeController.current = controller;
|
||||
const variables = { payload: validation.payload, apiKey: effectiveApiKey, signal: controller.signal, endpoint };
|
||||
const variables = { request, apiKey: effectiveApiKey, signal: controller.signal };
|
||||
systemOne.mutate(variables);
|
||||
}
|
||||
|
||||
|
|
@ -134,43 +170,37 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
|
|||
<Select
|
||||
value={endpoint}
|
||||
onValueChange={(value) => {
|
||||
if (value === "/v1/systemone" || value === "/typesafe/v1/systemone") {
|
||||
if (value === "/v1/decisions" || value === "/v1/systemone") {
|
||||
clearRequestState();
|
||||
setChosenEndpoint(value);
|
||||
setEndpoint(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-80" aria-label="Decision endpoint">
|
||||
<SelectValue>
|
||||
{endpoint === "/v1/systemone" ? "System One · /v1/systemone" : "TypeSafe · /typesafe/v1/systemone"}
|
||||
</SelectValue>
|
||||
<SelectValue>{endpoint}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="/v1/systemone">System One · /v1/systemone</SelectItem>
|
||||
<SelectItem value="/typesafe/v1/systemone">TypeSafe · /typesafe/v1/systemone</SelectItem>
|
||||
<SelectItem value="/v1/decisions">/v1/decisions</SelectItem>
|
||||
<SelectItem value="/v1/systemone">/v1/systemone</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{endpoint === "/v1/systemone" && (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
<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">
|
||||
|
|
@ -207,9 +237,14 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
|
|||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleResetExample} disabled={rawPayload === examplePayload}>
|
||||
<RotateCcw />
|
||||
Reset example
|
||||
<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}>
|
||||
|
|
@ -230,13 +265,20 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
|
|||
</div>
|
||||
<Alert role="note" aria-label="Decision endpoint notice">
|
||||
<Info />
|
||||
<AlertTitle>
|
||||
{endpoint === "/v1/systemone" ? "Decision models · System One" : "TypeSafe Jev · System One"}
|
||||
</AlertTitle>
|
||||
<AlertTitle>{endpoint}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{endpoint === "/v1/systemone"
|
||||
? "Sends choice, noul, and score questions through /v1/systemone to a decision model on your proxy. Pick one under Model, or omit model to use the proxy's configured default."
|
||||
: "Sends requests through /typesafe/v1/systemone and requires TYPESAFE_API_KEY on the proxy."}{" "}
|
||||
{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>
|
||||
|
|
@ -249,7 +291,7 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
|
|||
</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="System One request editor">
|
||||
<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)}
|
||||
|
|
@ -260,27 +302,45 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
|
|||
<TabsTrigger value="json">JSON</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="form" className="flex min-h-0 flex-col">
|
||||
<SystemOneForm
|
||||
{validated.endpoint === "/v1/decisions" ? (
|
||||
<OpenAIDecisionsForm
|
||||
value={rawPayload}
|
||||
onChange={handlePayloadChange}
|
||||
validation={validated.validation}
|
||||
onOpenJson={() => setView("json")}
|
||||
/>
|
||||
) : (
|
||||
<SystemOneForm
|
||||
value={rawPayload}
|
||||
onChange={handlePayloadChange}
|
||||
validation={validated.validation}
|
||||
onOpenJson={() => setView("json")}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="json" className="flex min-h-0 flex-col">
|
||||
<JsonEditor
|
||||
value={rawPayload}
|
||||
onChange={handlePayloadChange}
|
||||
validation={validation}
|
||||
onOpenJson={() => setView("json")}
|
||||
label={endpoint === "/v1/decisions" ? "Decisions JSON payload" : "System One JSON payload"}
|
||||
placeholder={`Paste or write a ${endpoint} request`}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="json" className="flex min-h-0 flex-col">
|
||||
<JsonEditor value={rawPayload} onChange={handlePayloadChange} validation={validation} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
<section className="grid content-start gap-4 xl:min-h-0 xl:overflow-auto" aria-label="System One results">
|
||||
<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}
|
||||
/>
|
||||
<QuestionBreakdown payload={validation.payload} />
|
||||
{view === "form" && validated.endpoint === "/v1/systemone" && (
|
||||
<QuestionBreakdown payload={validated.validation.payload} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { validateSystemOnePayload } from "./validatePayload";
|
||||
import { validateSystemOnePayload, validateOpenAIDecisionsPayload } from "./validatePayload";
|
||||
import { systemOneResponseSchema } from "./schemas";
|
||||
import { openAIDecisionsExample } from "./example";
|
||||
|
||||
const request = {
|
||||
model: "configured-decision-model",
|
||||
|
|
@ -81,3 +82,53 @@ describe("native decisions validation", () => {
|
|||
expect(systemOneResponseSchema.parse(response)).toEqual(response);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/v1/decisions validation", () => {
|
||||
const example = openAIDecisionsExample("jev-latest");
|
||||
const validateOpenAI = (value: unknown) => validateOpenAIDecisionsPayload(JSON.stringify(value));
|
||||
|
||||
it("keeps input, question arrays, provider extensions and optional model omission intact", () => {
|
||||
const payload = { input: example.input, questions: example.questions, provider_option: { enabled: true } };
|
||||
expect(validateOpenAI(payload)).toEqual({ isValid: true, payload, issues: [] });
|
||||
expect(validateOpenAI(example).isValid).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts multimodal messages and boolean choices without converting them to strings", () => {
|
||||
const payload = {
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_text", text: "Does this show an error?" },
|
||||
{ type: "input_image", image_url: "data:image/png;base64,example" },
|
||||
],
|
||||
},
|
||||
],
|
||||
questions: [{ type: "choice", instructions: "Is this an error?", choices: [{ value: true }, { value: false }] }],
|
||||
};
|
||||
expect(validateOpenAI(payload)).toEqual({ isValid: true, payload, issues: [] });
|
||||
});
|
||||
|
||||
it.each([
|
||||
request,
|
||||
{ ...example, questions: [] },
|
||||
{ ...example, questions: request.questions },
|
||||
{ ...example, input: {} },
|
||||
{ ...example, questions: [{ type: "noul", instructions: "Test" }] },
|
||||
{ ...example, questions: [{ type: "predicate" }] },
|
||||
{ ...example, questions: [{ type: "predicate", instructions: "Test", criteria: {} }] },
|
||||
{ ...example, questions: [{ type: "choice", instructions: "Test", choices: [{ value: true }] }] },
|
||||
{
|
||||
...example,
|
||||
questions: [{ type: "choice", instructions: "Test", choices: [{ value: true }, { value: "true" }] }],
|
||||
},
|
||||
{ ...example, questions: [{ type: "score", instructions: "Test", levels: [{ label: "Low" }] }] },
|
||||
])("rejects the wrong endpoint's shape and invalid question definitions %#", (payload) => {
|
||||
expect(validateOpenAI(payload).isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects blank and malformed JSON without throwing", () => {
|
||||
expect(validateOpenAIDecisionsPayload(" ").issues[0]?.path).toBe("root");
|
||||
expect(validateOpenAIDecisionsPayload("{").issues[0]?.path).toBe("syntax");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DECISION_PRESETS,
|
||||
emptyPayload,
|
||||
openAIDecisionsExample,
|
||||
presetPayload,
|
||||
toOpenAIDecisionsRequest,
|
||||
matchPreset,
|
||||
} from "./example";
|
||||
import { validateOpenAIDecisionsPayload, validateSystemOnePayload } from "./validatePayload";
|
||||
|
||||
describe("decision presets", () => {
|
||||
it.each(DECISION_PRESETS.map((preset) => [preset.label, preset] as const))(
|
||||
"%s is a valid request on both endpoints",
|
||||
(_label, preset) => {
|
||||
expect(
|
||||
validateSystemOnePayload(presetPayload(preset, "/v1/systemone", "jev-latest"), "/v1/systemone"),
|
||||
).toMatchObject({ isValid: true, issues: [] });
|
||||
expect(validateOpenAIDecisionsPayload(presetPayload(preset, "/v1/decisions", "jev-latest"))).toMatchObject({
|
||||
isValid: true,
|
||||
issues: [],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("covers every question type across the presets", () => {
|
||||
const types = new Set(
|
||||
DECISION_PRESETS.flatMap((preset) => Object.values(preset.request.questions).map((question) => question.type)),
|
||||
);
|
||||
expect([...types].sort()).toEqual(["choice", "noul", "score"]);
|
||||
});
|
||||
|
||||
it("maps System One questions onto the OpenAI shape by name", () => {
|
||||
const request = toOpenAIDecisionsRequest(DECISION_PRESETS[0].request, "jev-latest");
|
||||
expect(request.questions.map((question) => [question.type, question.name])).toEqual([
|
||||
["choice", "area"],
|
||||
["predicate", "has_repro_steps"],
|
||||
["score", "severity"],
|
||||
]);
|
||||
expect(request).toEqual(openAIDecisionsExample("jev-latest"));
|
||||
});
|
||||
|
||||
it("recognises a preset whatever the model key holds or where it sits", () => {
|
||||
const lead = DECISION_PRESETS.find((preset) => preset.id === "lead-scoring");
|
||||
const parsed = JSON.parse(presetPayload(lead!, "/v1/systemone", "jev-latest")) as Record<string, unknown>;
|
||||
const { model: _model, ...rest } = parsed;
|
||||
expect(matchPreset("/v1/systemone", JSON.stringify({ ...rest, model: "pplx-decider" }))).toBe(lead);
|
||||
expect(matchPreset("/v1/systemone", JSON.stringify(rest))).toBe(lead);
|
||||
expect(matchPreset("/v1/decisions", presetPayload(lead!, "/v1/decisions"))).toBe(lead);
|
||||
expect(matchPreset("/v1/decisions", presetPayload(lead!, "/v1/systemone"))).toBeUndefined();
|
||||
expect(matchPreset("/v1/systemone", JSON.stringify({ ...rest, state: "edited" }))).toBeUndefined();
|
||||
expect(matchPreset("/v1/systemone", "{")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits the model from payloads when none is known", () => {
|
||||
expect(JSON.parse(presetPayload(DECISION_PRESETS[1], "/v1/systemone"))).not.toHaveProperty("model");
|
||||
expect(JSON.parse(emptyPayload("/v1/decisions"))).toEqual({ input: "", questions: [] });
|
||||
expect(validateSystemOnePayload(emptyPayload("/v1/systemone"), "/v1/systemone").isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,39 @@
|
|||
import type { DecisionRequest, SystemOneRequest } from "./schemas";
|
||||
import type { OpenAIDecisionsRequest } from "./openAIDecisions";
|
||||
import { parseJson } from "./validatePayload";
|
||||
|
||||
type PresetChoice = {
|
||||
type: "choice";
|
||||
instructions: string;
|
||||
criteria: Record<string, string>;
|
||||
};
|
||||
|
||||
type PresetNoul = {
|
||||
type: "noul";
|
||||
instructions: string;
|
||||
criteria?: { true: string; false: string };
|
||||
};
|
||||
|
||||
type PresetScore = {
|
||||
type: "score";
|
||||
instructions: string;
|
||||
criteria: string[];
|
||||
};
|
||||
|
||||
export type PresetQuestion = PresetChoice | PresetNoul | PresetScore;
|
||||
|
||||
export type PresetRequest = {
|
||||
state: string;
|
||||
questions: Record<string, PresetQuestion>;
|
||||
};
|
||||
|
||||
type PresetEndpoint = "/v1/decisions" | "/v1/systemone";
|
||||
|
||||
export interface DecisionPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
request: PresetRequest;
|
||||
}
|
||||
|
||||
export const SYSTEM_ONE_EXAMPLE = {
|
||||
model: "jev-latest",
|
||||
|
|
@ -37,6 +72,132 @@ export const SYSTEM_ONE_EXAMPLE = {
|
|||
},
|
||||
} satisfies SystemOneRequest;
|
||||
|
||||
export const PLACEHOLDER_DECISION_MODEL = "your-decision-model";
|
||||
const { model: _exampleModel, ...bugTriage }: SystemOneRequest & PresetRequest = SYSTEM_ONE_EXAMPLE;
|
||||
|
||||
export const decisionsExample = (model: string): DecisionRequest => ({ ...SYSTEM_ONE_EXAMPLE, model });
|
||||
const supportRouting = {
|
||||
state:
|
||||
"Hi, I was charged twice for my team plan this month and the second charge pushed my card over its limit. I need the duplicate refunded today and want to know why it happened.",
|
||||
questions: {
|
||||
department: {
|
||||
type: "choice",
|
||||
instructions: "Which team should handle this ticket?",
|
||||
criteria: {
|
||||
billing: "Invoices, charges, refunds, and plan changes",
|
||||
technical: "Bugs, errors, outages, and integration help",
|
||||
account: "Login, permissions, and profile settings",
|
||||
sales: "Pricing questions and new purchases",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies PresetRequest;
|
||||
|
||||
const moderation = {
|
||||
state:
|
||||
"This guide is garbage and whoever wrote it should be fired. The fix that actually works is setting stream=false, took me two days to figure that out.",
|
||||
questions: {
|
||||
breaks_rules: {
|
||||
type: "noul",
|
||||
instructions: "Does this comment break the community guidelines?",
|
||||
criteria: {
|
||||
true: "Insults, harassment, slurs, or threats aimed at a person",
|
||||
false: "Blunt or frustrated but still about the content",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies PresetRequest;
|
||||
|
||||
const leadScoring = {
|
||||
state:
|
||||
"Inbound form: VP Engineering at a 400 person fintech. Running a self hosted gateway today, evaluating replacements this quarter, budget approved, needs SSO and audit logs.",
|
||||
questions: {
|
||||
fit: {
|
||||
type: "score",
|
||||
instructions: "How well does this lead fit our ideal customer?",
|
||||
criteria: [
|
||||
"No fit, wrong market or no budget",
|
||||
"Weak fit, early curiosity only",
|
||||
"Possible fit, needs discovery",
|
||||
"Strong fit, active evaluation",
|
||||
"Ideal fit, ready to buy this quarter",
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies PresetRequest;
|
||||
|
||||
export const DECISION_PRESETS: readonly DecisionPreset[] = [
|
||||
{ id: "bug-triage", label: "Triage a bug report", request: bugTriage },
|
||||
{ id: "support-routing", label: "Route a support ticket", request: supportRouting },
|
||||
{ id: "moderation", label: "Moderate a forum comment", request: moderation },
|
||||
{ id: "lead-scoring", label: "Score a sales lead", request: leadScoring },
|
||||
];
|
||||
|
||||
const toOpenAIQuestion = (name: string, question: PresetQuestion): OpenAIDecisionsRequest["questions"][number] => {
|
||||
switch (question.type) {
|
||||
case "choice":
|
||||
return {
|
||||
type: "choice",
|
||||
name,
|
||||
instructions: question.instructions,
|
||||
choices: Object.entries(question.criteria).map(([value, description]) => ({ value, description })),
|
||||
};
|
||||
case "noul":
|
||||
return { type: "predicate", name, instructions: question.instructions };
|
||||
case "score":
|
||||
return {
|
||||
type: "score",
|
||||
name,
|
||||
instructions: question.instructions,
|
||||
levels: question.criteria.map((description, index) => ({ label: String(index), description })),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const toSystemOneRequest = (request: PresetRequest, model?: string): DecisionRequest => ({ model, ...request });
|
||||
|
||||
export const toOpenAIDecisionsRequest = (request: PresetRequest, model?: string): OpenAIDecisionsRequest => ({
|
||||
model,
|
||||
input: request.state,
|
||||
questions: Object.entries(request.questions).map(([name, question]) => toOpenAIQuestion(name, question)),
|
||||
});
|
||||
|
||||
export const presetPayload = (preset: DecisionPreset, endpoint: PresetEndpoint, model?: string): string =>
|
||||
JSON.stringify(
|
||||
endpoint === "/v1/decisions"
|
||||
? toOpenAIDecisionsRequest(preset.request, model)
|
||||
: toSystemOneRequest(preset.request, model),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
const stableJson = (value: unknown): string =>
|
||||
JSON.stringify(value, (_key, item: unknown) =>
|
||||
item !== null && typeof item === "object" && !Array.isArray(item)
|
||||
? Object.fromEntries(Object.entries(item as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)))
|
||||
: item,
|
||||
);
|
||||
|
||||
const withoutModel = (value: unknown): string => {
|
||||
const { model: _model, ...rest } = (value ?? {}) as Record<string, unknown>;
|
||||
return stableJson(rest);
|
||||
};
|
||||
|
||||
export const matchPreset = (endpoint: PresetEndpoint, raw: string): DecisionPreset | undefined => {
|
||||
const json = parseJson(raw);
|
||||
if (!json.ok) {
|
||||
return undefined;
|
||||
}
|
||||
const current = withoutModel(json.value);
|
||||
return DECISION_PRESETS.find((preset) => withoutModel(JSON.parse(presetPayload(preset, endpoint))) === current);
|
||||
};
|
||||
|
||||
export const emptyPayload = (endpoint: PresetEndpoint, model?: string): string =>
|
||||
JSON.stringify(
|
||||
endpoint === "/v1/decisions" ? { model, input: "", questions: [] } : { model, state: "", questions: {} },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
export const decisionsExample = (model?: string): DecisionRequest => toSystemOneRequest(bugTriage, model);
|
||||
|
||||
export const openAIDecisionsExample = (model?: string): OpenAIDecisionsRequest =>
|
||||
toOpenAIDecisionsRequest(bugTriage, model);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const contentPart = z.discriminatedUnion("type", [
|
||||
z.strictObject({ type: z.literal("input_text"), text: z.string() }),
|
||||
z.strictObject({ type: z.literal("input_image"), image_url: z.string(), detail: z.string().nullish() }),
|
||||
]);
|
||||
const inputMessage = z.strictObject({
|
||||
role: z.literal("user").optional(),
|
||||
type: z.literal("message").optional(),
|
||||
content: z.union([z.string(), z.array(contentPart)]),
|
||||
});
|
||||
const questionFields = { name: z.string().nullish(), instructions: z.string() };
|
||||
const choiceValue = z.union([z.string(), z.boolean()]);
|
||||
const choiceQuestion = z
|
||||
.strictObject({
|
||||
...questionFields,
|
||||
type: z.literal("choice"),
|
||||
choices: z
|
||||
.array(z.strictObject({ value: choiceValue, description: z.string().nullish() }))
|
||||
.min(2)
|
||||
.max(255),
|
||||
})
|
||||
.refine(
|
||||
(question) => new Set(question.choices.map((choice) => String(choice.value))).size === question.choices.length,
|
||||
{
|
||||
message: "Choice values must be unique, including their string representations.",
|
||||
path: ["choices"],
|
||||
},
|
||||
);
|
||||
|
||||
const requestFields = {
|
||||
model: z
|
||||
.string()
|
||||
.refine((value) => value.trim().length > 0, "Model must be a non-empty string.")
|
||||
.optional(),
|
||||
input: z.union([z.string(), z.array(inputMessage)]),
|
||||
questions: z
|
||||
.array(
|
||||
z.discriminatedUnion("type", [
|
||||
z.strictObject({ ...questionFields, type: z.literal("predicate") }),
|
||||
choiceQuestion,
|
||||
z.strictObject({
|
||||
...questionFields,
|
||||
type: z.literal("score"),
|
||||
levels: z
|
||||
.array(z.strictObject({ label: z.string(), description: z.string().nullish() }))
|
||||
.min(2)
|
||||
.max(10),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.min(1)
|
||||
.max(128),
|
||||
safety_identifier: z.string().nullish(),
|
||||
};
|
||||
export const openAIDecisionsRequestSchema = z.looseObject(requestFields);
|
||||
|
||||
const probability = z.number().finite().min(0).max(1);
|
||||
const answerFields = { name: z.string().nullable() };
|
||||
const tokenCount = z.number().int().nonnegative();
|
||||
const choiceAnswerFields = {
|
||||
...answerFields,
|
||||
type: z.literal("choice"),
|
||||
choice: choiceValue,
|
||||
confidence: probability,
|
||||
probabilities: z.array(z.looseObject({ value: choiceValue, probability })),
|
||||
};
|
||||
const scoreAnswerFields = {
|
||||
...answerFields,
|
||||
type: z.literal("score"),
|
||||
score: z.number().finite(),
|
||||
confidence: probability,
|
||||
probabilities: z.array(z.looseObject({ value: z.number().int(), label: z.string(), probability })),
|
||||
};
|
||||
const usageFields = {
|
||||
input_tokens: tokenCount,
|
||||
output_tokens: tokenCount,
|
||||
total_tokens: tokenCount,
|
||||
input_tokens_details: z
|
||||
.looseObject({ cached_tokens: tokenCount.optional(), cache_write_tokens: tokenCount.optional() })
|
||||
.optional(),
|
||||
output_tokens_details: z.looseObject({ reasoning_tokens: tokenCount.optional() }).optional(),
|
||||
};
|
||||
export const openAIDecisionsResponseSchema = z.looseObject({
|
||||
model: z.string(),
|
||||
answers: z.array(
|
||||
z.discriminatedUnion("type", [
|
||||
z.looseObject({ ...answerFields, type: z.literal("predicate"), probability }),
|
||||
z.looseObject(choiceAnswerFields),
|
||||
z.looseObject(scoreAnswerFields),
|
||||
z.looseObject({ ...answerFields, type: z.literal("refusal") }),
|
||||
]),
|
||||
),
|
||||
usage: z.looseObject(usageFields),
|
||||
});
|
||||
|
||||
export type OpenAIDecisionsRequest = z.infer<typeof openAIDecisionsRequestSchema>;
|
||||
export type OpenAIDecisionsResponse = z.infer<typeof openAIDecisionsResponseSchema>;
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { openAIDecisionsExample } from "./example";
|
||||
import { readDecisionsForm, retypeDecisionQuestion, type DecisionsFormQuestion } from "./openAIDecisionsForm";
|
||||
import { validateOpenAIDecisionsPayload } from "./validatePayload";
|
||||
|
||||
describe("readDecisionsForm", () => {
|
||||
it("round-trips examples and preserves input order, extension fields, false choices and null names", () => {
|
||||
const example = openAIDecisionsExample();
|
||||
expect(readDecisionsForm(JSON.stringify(example))).toEqual(JSON.parse(JSON.stringify(example)));
|
||||
const payload = {
|
||||
metadata: { source: "playground" },
|
||||
input: [{ role: "user", content: [{ type: "input_image", image_url: "https://example.com/image.png" }] }],
|
||||
safety_identifier: null,
|
||||
questions: [{ type: "choice", name: null, instructions: "Choose", choices: [{ value: false }, { value: true }] }],
|
||||
};
|
||||
expect(JSON.stringify(readDecisionsForm(JSON.stringify(payload)))).toEqual(JSON.stringify(payload));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{},
|
||||
{ input: "", questions: [] },
|
||||
{ input: "text", questions: [{ type: "choice", choices: [] }] },
|
||||
{ input: "text", questions: [{ type: "score", instructions: "Score", levels: [{ label: "0" }] }] },
|
||||
{
|
||||
input: "text",
|
||||
questions: [{ type: "choice", instructions: "Choose", choices: [{ value: "same" }, { value: "same" }] }],
|
||||
},
|
||||
])("keeps incomplete requests editable without marking them valid for Send: %j", (payload) => {
|
||||
const raw = JSON.stringify(payload);
|
||||
expect(readDecisionsForm(raw)).toEqual(payload);
|
||||
expect(validateOpenAIDecisionsPayload(raw).isValid).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["{", "", "null", "[]", '{"input":42}', '{"questions":{}}', '{"questions":[{"type":"noul"}]}'])(
|
||||
"rejects unsupported form shapes without replacing the JSON: %s",
|
||||
(raw) => {
|
||||
expect(readDecisionsForm(raw)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("retypeDecisionQuestion", () => {
|
||||
const choice: DecisionsFormQuestion = {
|
||||
type: "choice",
|
||||
name: "route",
|
||||
instructions: "Where should it go?",
|
||||
choices: [{ value: false }, { value: "other" }],
|
||||
};
|
||||
|
||||
it("keeps choices unchanged when the type does not change", () => {
|
||||
expect(retypeDecisionQuestion(choice, "choice")).toEqual(choice);
|
||||
});
|
||||
|
||||
it("removes type-specific fields while preserving names, instructions and extras", () => {
|
||||
const score = retypeDecisionQuestion({ ...choice, custom: "keep" }, "score");
|
||||
const expectedScore = {
|
||||
type: "score",
|
||||
name: "route",
|
||||
instructions: "Where should it go?",
|
||||
custom: "keep",
|
||||
levels: [{ label: "0" }, { label: "1" }],
|
||||
};
|
||||
expect(score).toEqual(expectedScore);
|
||||
const predicate = retypeDecisionQuestion(score, "predicate");
|
||||
const expectedPredicate = {
|
||||
type: "predicate",
|
||||
name: "route",
|
||||
instructions: "Where should it go?",
|
||||
custom: "keep",
|
||||
};
|
||||
expect(predicate).toEqual(expectedPredicate);
|
||||
expect(retypeDecisionQuestion(predicate, "choice")).toEqual({
|
||||
...predicate,
|
||||
type: "choice",
|
||||
choices: [{ value: "option_1" }, { value: "option_2" }],
|
||||
});
|
||||
expect(choice.choices).toEqual([{ value: false }, { value: "other" }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { z } from "zod";
|
||||
import { openAIDecisionsRequestSchema } from "./openAIDecisions";
|
||||
import { parseJson } from "./validatePayload";
|
||||
|
||||
const questionFields = { name: z.string().nullish(), instructions: z.string().optional() };
|
||||
const choiceFields = { value: z.union([z.string(), z.boolean()]), description: z.string().nullish() };
|
||||
const levelFields = { label: z.string(), description: z.string().nullish() };
|
||||
const formQuestion = z.discriminatedUnion("type", [
|
||||
z.looseObject({ ...questionFields, type: z.literal("predicate") }),
|
||||
z.looseObject({
|
||||
...questionFields,
|
||||
type: z.literal("choice"),
|
||||
choices: z.array(z.looseObject(choiceFields)).optional(),
|
||||
}),
|
||||
z.looseObject({
|
||||
...questionFields,
|
||||
type: z.literal("score"),
|
||||
levels: z.array(z.looseObject(levelFields)).optional(),
|
||||
}),
|
||||
]);
|
||||
const formPayload = z.looseObject({
|
||||
input: openAIDecisionsRequestSchema.shape.input.optional(),
|
||||
questions: z.array(formQuestion).optional(),
|
||||
safety_identifier: z.string().nullish(),
|
||||
});
|
||||
|
||||
export type DecisionsFormPayload = z.infer<typeof formPayload>;
|
||||
export type DecisionsFormQuestion = z.infer<typeof formQuestion>;
|
||||
export type DecisionsQuestionType = DecisionsFormQuestion["type"];
|
||||
|
||||
const formPayloadInInputOrder = z.custom<DecisionsFormPayload>((value) => formPayload.safeParse(value).success);
|
||||
|
||||
export function readDecisionsForm(raw: string): DecisionsFormPayload | undefined {
|
||||
const json = parseJson(raw);
|
||||
return json.ok ? formPayloadInInputOrder.safeParse(json.value).data : undefined;
|
||||
}
|
||||
|
||||
export function retypeDecisionQuestion(
|
||||
question: DecisionsFormQuestion,
|
||||
type: DecisionsQuestionType,
|
||||
): DecisionsFormQuestion {
|
||||
if (question.type === type) {
|
||||
return question;
|
||||
}
|
||||
const { type: _type, choices: _choices, levels: _levels, ...rest } = question;
|
||||
switch (type) {
|
||||
case "predicate":
|
||||
return { ...rest, type };
|
||||
case "choice":
|
||||
return { ...rest, type, choices: [{ value: "option_1" }, { value: "option_2" }] };
|
||||
case "score":
|
||||
return { ...rest, type, levels: [{ label: "0" }, { label: "1" }] };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { findRootBlocks } from "./rootBlocks";
|
||||
|
||||
describe("findRootBlocks", () => {
|
||||
it("maps each top-level request key to the lines its value spans", () => {
|
||||
const text = JSON.stringify(
|
||||
{
|
||||
model: "jev-latest",
|
||||
state: { message: "Hi" },
|
||||
questions: { escalate: { type: "noul", instructions: "Escalate?" } },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
expect(findRootBlocks(text)).toEqual([
|
||||
{ key: "model", startLine: 1, endLine: 1 },
|
||||
{ key: "state", startLine: 2, endLine: 4 },
|
||||
{ key: "questions", startLine: 5, endLine: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores nested keys, unrelated root keys and braces or quotes inside strings", () => {
|
||||
const text = [
|
||||
"{",
|
||||
' "temperature": 0,',
|
||||
' "state": "a } tricky \\" { string",',
|
||||
' "questions": {',
|
||||
' "model": { "state": "nested" }',
|
||||
" }",
|
||||
"}",
|
||||
].join("\n");
|
||||
|
||||
expect(findRootBlocks(text)).toEqual([
|
||||
{ key: "state", startLine: 2, endLine: 2 },
|
||||
{ key: "questions", startLine: 3, endLine: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds every block on a single minified line", () => {
|
||||
expect(findRootBlocks('{"state":"Hi","questions":{"a":{"type":"noul"}}}')).toEqual([
|
||||
{ key: "state", startLine: 0, endLine: 0 },
|
||||
{ key: "questions", startLine: 0, endLine: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps highlighting an unfinished block while the user is typing", () => {
|
||||
expect(findRootBlocks('{\n "state": "Hi",\n "questions": {\n "a": {')).toEqual([
|
||||
{ key: "state", startLine: 1, endLine: 1 },
|
||||
{ key: "questions", startLine: 2, endLine: 3 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
export type RootBlockKey = "model" | "state" | "questions";
|
||||
|
||||
export interface RootBlock {
|
||||
readonly key: RootBlockKey;
|
||||
readonly startLine: number;
|
||||
readonly endLine: number;
|
||||
}
|
||||
|
||||
export const ROOT_BLOCK_STYLES: Readonly<Record<RootBlockKey, { band: string; accent: string }>> = {
|
||||
model: { band: "bg-blue-500/10 before:bg-blue-500/60", accent: "border-l-blue-500/60" },
|
||||
state: { band: "bg-green-500/10 before:bg-green-500/60", accent: "border-l-green-500/60" },
|
||||
questions: { band: "bg-orange-500/10 before:bg-orange-500/60", accent: "border-l-orange-500/60" },
|
||||
};
|
||||
|
||||
interface Token {
|
||||
readonly text: string;
|
||||
readonly line: number;
|
||||
}
|
||||
|
||||
interface ScanState {
|
||||
readonly depth: number;
|
||||
readonly previous?: Token;
|
||||
readonly open?: { readonly key: RootBlockKey; readonly startLine: number };
|
||||
readonly blocks: readonly RootBlock[];
|
||||
}
|
||||
|
||||
const JSON_TOKEN = /"(?:[^"\\\n]|\\.)*"?|[{}[\],:]|[^\s{}[\],:"]+/g;
|
||||
|
||||
const isRootBlockKey = (key: string): key is RootBlockKey => Object.hasOwn(ROOT_BLOCK_STYLES, key);
|
||||
|
||||
const tokenize = (text: string): Token[] =>
|
||||
text
|
||||
.split("\n")
|
||||
.flatMap((lineText, line) => Array.from(lineText.matchAll(JSON_TOKEN), ([match]) => ({ text: match, line })));
|
||||
|
||||
const closeOpenBlock = ({ open, previous, blocks }: ScanState): readonly RootBlock[] =>
|
||||
open && previous ? [...blocks, { ...open, endLine: previous.line }] : blocks;
|
||||
|
||||
function step(state: ScanState, token: Token): ScanState {
|
||||
const next = { ...state, previous: token };
|
||||
switch (token.text) {
|
||||
case "{":
|
||||
case "[":
|
||||
return { ...next, depth: state.depth + 1 };
|
||||
case "}":
|
||||
case "]":
|
||||
return state.depth === 1
|
||||
? { ...next, depth: 0, open: undefined, blocks: closeOpenBlock(state) }
|
||||
: { ...next, depth: state.depth - 1 };
|
||||
case ",":
|
||||
return state.depth === 1 ? { ...next, open: undefined, blocks: closeOpenBlock(state) } : next;
|
||||
case ":": {
|
||||
const key = state.previous?.text.slice(1, -1) ?? "";
|
||||
return state.depth === 1 && state.previous?.text.startsWith('"') && isRootBlockKey(key)
|
||||
? { ...next, open: { key, startLine: state.previous.line } }
|
||||
: next;
|
||||
}
|
||||
default:
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
export function findRootBlocks(text: string): readonly RootBlock[] {
|
||||
return closeOpenBlock(tokenize(text).reduce(step, { depth: 0, blocks: [] }));
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import { openAIDecisionsRequestSchema, type OpenAIDecisionsRequest } from "./openAIDecisions";
|
||||
import {
|
||||
decisionsRequestSchema,
|
||||
systemOneRequestSchema,
|
||||
|
|
@ -13,15 +15,17 @@ export interface SystemOnePayloadIssue {
|
|||
severity: "error" | "warning";
|
||||
}
|
||||
|
||||
export interface SystemOnePayloadValidation {
|
||||
export interface PayloadValidation<T> {
|
||||
isValid: boolean;
|
||||
payload?: PlaygroundRequest;
|
||||
payload?: T;
|
||||
issues: SystemOnePayloadIssue[];
|
||||
}
|
||||
|
||||
const invalid = (path: string, message: string): SystemOnePayloadValidation => ({
|
||||
export type SystemOnePayloadValidation = PayloadValidation<PlaygroundRequest>;
|
||||
|
||||
const invalid = (path: string, message: string) => ({
|
||||
isValid: false,
|
||||
issues: [{ path, message, severity: "error" }],
|
||||
issues: [{ path, message, severity: "error" as const }],
|
||||
});
|
||||
|
||||
export function parseJson(raw: string): { ok: true; value: unknown } | { ok: false; message: string } {
|
||||
|
|
@ -41,10 +45,7 @@ const scoreLevelWarnings = (payload: PlaygroundRequest): SystemOnePayloadIssue[]
|
|||
severity: "warning",
|
||||
}));
|
||||
|
||||
export function validateSystemOnePayload(
|
||||
raw: string,
|
||||
endpoint: DecisionEndpoint = "/typesafe/v1/systemone",
|
||||
): SystemOnePayloadValidation {
|
||||
function validatePayload<T>(raw: string, schema: z.ZodType<T>): PayloadValidation<T> {
|
||||
if (!raw.trim()) {
|
||||
return invalid("root", "Payload cannot be empty.");
|
||||
}
|
||||
|
|
@ -54,7 +55,6 @@ export function validateSystemOnePayload(
|
|||
return invalid("syntax", `Invalid JSON syntax: ${json.message}`);
|
||||
}
|
||||
|
||||
const schema = endpoint === "/v1/systemone" ? decisionsRequestSchema : systemOneRequestSchema;
|
||||
const result = schema.safeParse(json.value);
|
||||
if (!result.success) {
|
||||
return {
|
||||
|
|
@ -67,5 +67,18 @@ export function validateSystemOnePayload(
|
|||
};
|
||||
}
|
||||
|
||||
return { isValid: true, payload: result.data, issues: scoreLevelWarnings(result.data) };
|
||||
return { isValid: true, payload: result.data, issues: [] };
|
||||
}
|
||||
|
||||
export function validateSystemOnePayload(
|
||||
raw: string,
|
||||
endpoint: DecisionEndpoint = "/typesafe/v1/systemone",
|
||||
): SystemOnePayloadValidation {
|
||||
const schema = endpoint === "/v1/systemone" ? decisionsRequestSchema : systemOneRequestSchema;
|
||||
const result = validatePayload<PlaygroundRequest>(raw, schema);
|
||||
return result.payload ? { ...result, issues: scoreLevelWarnings(result.payload) } : result;
|
||||
}
|
||||
|
||||
export function validateOpenAIDecisionsPayload(raw: string): PayloadValidation<OpenAIDecisionsRequest> {
|
||||
return validatePayload(raw, openAIDecisionsRequestSchema);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchDecisionModels, makeSystemOneRequest } from "./system_one";
|
||||
import { fetchDecisionModels, makeSystemOneRequest, makePlaygroundDecisionRequest } from "./system_one";
|
||||
import { openAIDecisionsExample } from "../components/systemOneUI/lib/example";
|
||||
import type { OpenAIDecisionsResponse } from "../components/systemOneUI/lib/openAIDecisions";
|
||||
import type { SystemOneRequest, SystemOneResponse } from "../components/systemOneUI/lib/schemas";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
|
|
@ -72,7 +74,7 @@ describe("makeSystemOneRequest", () => {
|
|||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://custom.example.com/typesafe/v1/systemone",
|
||||
"https://custom.example.com/v1/systemone",
|
||||
expect.objectContaining(expectedRequest),
|
||||
);
|
||||
expect(result.response).toEqual(responseBody);
|
||||
|
|
@ -82,7 +84,7 @@ describe("makeSystemOneRequest", () => {
|
|||
it("uses the proxy base URL when no custom base is provided", async () => {
|
||||
await makeSystemOneRequest(payload, "session-key");
|
||||
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://proxy.example.com/typesafe/v1/systemone");
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://proxy.example.com/v1/systemone");
|
||||
});
|
||||
|
||||
it("throws the upstream error text for failed requests", async () => {
|
||||
|
|
@ -102,11 +104,95 @@ describe("makeSystemOneRequest", () => {
|
|||
} as Response);
|
||||
|
||||
await expect(makeSystemOneRequest(payload, "session-key")).rejects.toThrow(
|
||||
"System One response has an invalid shape.",
|
||||
"/v1/systemone response has an invalid shape.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/v1/decisions requests", () => {
|
||||
const mockFetch = vi.fn<typeof fetch>();
|
||||
const request = { endpoint: "/v1/decisions", payload: openAIDecisionsExample("jev-latest") } as const;
|
||||
const response: OpenAIDecisionsResponse = {
|
||||
model: "jev-latest",
|
||||
answers: [
|
||||
{ name: "urgent", type: "predicate", probability: 0.9 },
|
||||
{
|
||||
name: "area",
|
||||
type: "choice",
|
||||
choice: true,
|
||||
confidence: 0.8,
|
||||
probabilities: [
|
||||
{ value: true, probability: 0.8 },
|
||||
{ value: false, probability: 0.2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "severity",
|
||||
type: "score",
|
||||
score: 0.6,
|
||||
confidence: 0.6,
|
||||
probabilities: [
|
||||
{ value: 0, label: "low", probability: 0.4 },
|
||||
{ value: 1, label: "high", probability: 0.6 },
|
||||
],
|
||||
},
|
||||
{ name: null, type: "refusal" },
|
||||
],
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 2,
|
||||
total_tokens: 12,
|
||||
input_tokens_details: { cached_tokens: 3 },
|
||||
output_tokens_details: { reasoning_tokens: 1 },
|
||||
},
|
||||
provider_metadata: { id: "native-result" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockFetch.mockResolvedValue({ ok: true, text: async () => JSON.stringify(response) } as Response);
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it.each([undefined, "https://custom.example.com/"])(
|
||||
"sends the OpenAI shape unchanged and preserves all answer types (%s)",
|
||||
async (baseUrl) => {
|
||||
const controller = new AbortController();
|
||||
const result = await makePlaygroundDecisionRequest(request, "session-key", baseUrl, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
const expectedRequest = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(request.payload),
|
||||
signal: controller.signal,
|
||||
headers: expect.objectContaining({ Authorization: "Bearer session-key", "Content-Type": "application/json" }),
|
||||
};
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
`${baseUrl?.replace(/\/$/, "") ?? "https://proxy.example.com"}/v1/decisions`,
|
||||
expect.objectContaining(expectedRequest),
|
||||
);
|
||||
expect(result.response).toEqual(response);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a System One response rather than disguising its shape", async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => JSON.stringify(responseBody) } as Response);
|
||||
await expect(makePlaygroundDecisionRequest(request, "session-key")).rejects.toThrow(
|
||||
"/v1/decisions response has an invalid shape.",
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces errors from the selected endpoint", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: async () => "Unsupported decision model",
|
||||
} as Response);
|
||||
await expect(makePlaygroundDecisionRequest(request, "session-key")).rejects.toThrow("Unsupported decision model");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchDecisionModels", () => {
|
||||
const mockFetch = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>();
|
||||
const modelGroupInfo = (data: unknown) => ({ ok: true, text: async () => JSON.stringify({ data }) }) as Response;
|
||||
|
|
|
|||
|
|
@ -5,16 +5,26 @@ import { createApiClient } from "@/lib/http/client";
|
|||
import { isDecisionMode } from "@/lib/decisionModels";
|
||||
import {
|
||||
systemOneResponseSchema,
|
||||
type DecisionEndpoint,
|
||||
type PlaygroundRequest,
|
||||
type SystemOneResponse,
|
||||
} from "../components/systemOneUI/lib/schemas";
|
||||
import {
|
||||
openAIDecisionsResponseSchema,
|
||||
type OpenAIDecisionsRequest,
|
||||
type OpenAIDecisionsResponse,
|
||||
} from "../components/systemOneUI/lib/openAIDecisions";
|
||||
|
||||
export interface SystemOneResult {
|
||||
response: SystemOneResponse;
|
||||
export interface DecisionResult<T> {
|
||||
response: T;
|
||||
latencyMs: number;
|
||||
}
|
||||
|
||||
export type SystemOneResult = DecisionResult<SystemOneResponse>;
|
||||
export type OpenAIDecisionsResult = DecisionResult<OpenAIDecisionsResponse>;
|
||||
export type PlaygroundDecisionRequest =
|
||||
| { endpoint: "/v1/systemone"; payload: PlaygroundRequest }
|
||||
| { endpoint: "/v1/decisions"; payload: OpenAIDecisionsRequest };
|
||||
|
||||
const modelGroupInfoSchema = z.object({
|
||||
data: z.array(z.object({ model_group: z.string(), mode: z.string().nullish() })),
|
||||
});
|
||||
|
|
@ -51,21 +61,47 @@ export async function fetchDecisionModels(
|
|||
.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export async function makeSystemOneRequest(
|
||||
payload: PlaygroundRequest,
|
||||
async function sendDecisionRequest<T>(
|
||||
request: { endpoint: PlaygroundDecisionRequest["endpoint"]; payload: object; schema: z.ZodType<T> },
|
||||
accessToken: string,
|
||||
customBaseUrl?: string,
|
||||
{ signal, endpoint = "/typesafe/v1/systemone" }: { signal?: AbortSignal; endpoint?: DecisionEndpoint } = {},
|
||||
): Promise<SystemOneResult> {
|
||||
signal?: AbortSignal,
|
||||
): Promise<DecisionResult<T>> {
|
||||
const startedAt = performance.now();
|
||||
const body = await proxyClient(customBaseUrl).post<unknown>(endpoint, {
|
||||
body: payload,
|
||||
const body = await proxyClient(customBaseUrl).post<unknown>(request.endpoint, {
|
||||
body: request.payload,
|
||||
headers: authHeaders(accessToken),
|
||||
signal,
|
||||
});
|
||||
const parsed = systemOneResponseSchema.safeParse(body);
|
||||
const parsed = request.schema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new Error("System One response has an invalid shape.");
|
||||
throw new Error(`${request.endpoint} response has an invalid shape.`);
|
||||
}
|
||||
return { response: parsed.data, latencyMs: performance.now() - startedAt };
|
||||
}
|
||||
|
||||
export function makeSystemOneRequest(
|
||||
payload: PlaygroundRequest,
|
||||
accessToken: string,
|
||||
customBaseUrl?: string,
|
||||
{ signal }: { signal?: AbortSignal } = {},
|
||||
): Promise<SystemOneResult> {
|
||||
return sendDecisionRequest(
|
||||
{ endpoint: "/v1/systemone", payload, schema: systemOneResponseSchema },
|
||||
accessToken,
|
||||
customBaseUrl,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
export function makePlaygroundDecisionRequest(
|
||||
request: PlaygroundDecisionRequest,
|
||||
accessToken: string,
|
||||
customBaseUrl?: string,
|
||||
{ signal }: { signal?: AbortSignal } = {},
|
||||
): Promise<SystemOneResult | OpenAIDecisionsResult> {
|
||||
if (request.endpoint === "/v1/systemone") {
|
||||
return makeSystemOneRequest(request.payload, accessToken, customBaseUrl, { signal });
|
||||
}
|
||||
return sendDecisionRequest({ ...request, schema: openAIDecisionsResponseSchema }, accessToken, customBaseUrl, signal);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,10 +87,10 @@ describe("PlaygroundPage ?tab= deep link", () => {
|
|||
expect(screen.getByRole("tab", { name: "Chat" })).toHaveAttribute("aria-selected", "false");
|
||||
});
|
||||
|
||||
it("activates System One from ?tab=system-one", () => {
|
||||
it("activates Decisions from ?tab=system-one", () => {
|
||||
renderWithProviders(<PlaygroundPage />, { searchParams: { tab: "system-one" } });
|
||||
|
||||
const tab = screen.getByRole("tab", { name: /^System One/ });
|
||||
const tab = screen.getByRole("tab", { name: /^Decisions/ });
|
||||
expect(tab).toHaveAttribute("aria-selected", "true");
|
||||
expect(tab).toHaveTextContent("Beta");
|
||||
expect(screen.getByTestId("system-one-ui")).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export default function PlaygroundPage() {
|
|||
Compliance
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="system-one" className="flex-none">
|
||||
<BetaBadge>System One</BetaBadge>
|
||||
<BetaBadge>Decisions</BetaBadge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="agent-builder" className="flex-none">
|
||||
Agent Builder (Experimental)
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ import ConnectionErrorDisplay from "./model_connection_test";
|
|||
import ProviderSpecificFields from "./provider_specific_fields";
|
||||
import { TEST_MODES } from "./add_model_modes";
|
||||
import DecisionModelNote from "./DecisionModelNote";
|
||||
import { decisionModelsSublabel, type DecisionCatalog } from "@/lib/decisionModels";
|
||||
import { type DecisionCatalog } from "@/lib/decisionModels";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { credentialsKeys } from "@/app/(dashboard)/hooks/credentials/useCredentials";
|
||||
import { extractProxyErrorMessage } from "@/lib/http/client";
|
||||
|
|
@ -153,10 +153,9 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
sortedProviderMetadata.map((providerInfo) => ({
|
||||
label: providerInfo.provider_display_name,
|
||||
value: providerInfo.provider,
|
||||
sublabel: decisionModelsSublabel(decisionCatalog, providerInfo.litellm_provider),
|
||||
icon: <ProviderLogo provider={providerInfo.provider} className="w-5 h-5" />,
|
||||
})),
|
||||
[sortedProviderMetadata, decisionCatalog],
|
||||
[sortedProviderMetadata],
|
||||
);
|
||||
|
||||
const pickedLitellmProvider = sortedProviderMetadata.find(
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue