mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-12 03:48:56 +00:00
Merge pull request #45905 from BerriAI/litellm_backport_decisions_rc_1_105_0
feat(decisions): backport the decisions and Jev follow-ups to rc/1.105.0 without the playground
This commit is contained in:
commit
2616b1a3db
86 changed files with 8519 additions and 1765 deletions
|
|
@ -14,8 +14,9 @@ import argparse
|
|||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from generate_model_prices_schema import SPECIAL_ROOT_KEYS, build_schema, render, validation_errors
|
||||
|
|
@ -25,6 +26,7 @@ BACKUP_PATH: Final = "litellm/model_prices_and_context_window_backup.json"
|
|||
SCHEMA_PATH: Final = "model_prices_and_context_window.schema.json"
|
||||
GUARDED_PATHS: Final = (COST_MAP_PATH, BACKUP_PATH, SCHEMA_PATH)
|
||||
BOT_BRANCH_PREFIX: Final = "litellm_cost_map_sync_"
|
||||
EVALUATION_OUTPUT_PRICE_SOURCES: Final[Mapping[str, str]] = MappingProxyType({})
|
||||
|
||||
CostMap = dict[str, object]
|
||||
|
||||
|
|
@ -51,7 +53,31 @@ def _rendered_schema(cost_map: CostMap) -> str:
|
|||
return str(error)
|
||||
|
||||
|
||||
def _file_failures(head: Snapshot, head_map: CostMap) -> tuple[str, ...]:
|
||||
def _evaluation_output_price_failure(
|
||||
key: str, entry: object, evaluation_output_price_sources: Mapping[str, str]
|
||||
) -> str | None:
|
||||
if not isinstance(entry, dict) or entry.get("mode") != "evaluation":
|
||||
return None
|
||||
output_cost: Final = entry.get("output_cost_per_token")
|
||||
if (
|
||||
not isinstance(output_cost, (int, float))
|
||||
or isinstance(output_cost, bool)
|
||||
or output_cost <= 0
|
||||
or key in evaluation_output_price_sources
|
||||
):
|
||||
return None
|
||||
return (
|
||||
f"{key}: mode evaluation rows bill input only; output_cost_per_token must be 0 unless the vendor "
|
||||
"prices output tokens for this model (add the source to EVALUATION_OUTPUT_PRICE_SOURCES)"
|
||||
)
|
||||
|
||||
|
||||
def _file_failures(
|
||||
head: Snapshot,
|
||||
head_map: CostMap,
|
||||
*,
|
||||
evaluation_output_price_sources: Mapping[str, str] = EVALUATION_OUTPUT_PRICE_SOURCES,
|
||||
) -> tuple[str, ...]:
|
||||
schema_text: Final = _rendered_schema(head_map)
|
||||
if not schema_text.startswith("{"):
|
||||
return (schema_text,)
|
||||
|
|
@ -75,6 +101,16 @@ def _file_failures(head: Snapshot, head_map: CostMap) -> tuple[str, ...]:
|
|||
f"{COST_MAP_PATH} does not validate against its schema: {error}"
|
||||
for error in validation_errors(head_map, json.loads(schema_text))[:20]
|
||||
),
|
||||
*(
|
||||
failure
|
||||
for key, entry in head_map.items()
|
||||
if (
|
||||
failure := _evaluation_output_price_failure(
|
||||
key, entry, evaluation_output_price_sources
|
||||
)
|
||||
)
|
||||
is not None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -121,13 +157,25 @@ def contract_for(bot: bool, changed_files: Sequence[str]) -> str:
|
|||
return "human PR, file checks only" if touches_cost_map(changed_files) else "human PR, cost map untouched"
|
||||
|
||||
|
||||
def guard_failures(base: Snapshot, head: Snapshot, changed_files: Sequence[str], bot: bool) -> tuple[str, ...]:
|
||||
def guard_failures(
|
||||
base: Snapshot,
|
||||
head: Snapshot,
|
||||
changed_files: Sequence[str],
|
||||
bot: bool,
|
||||
*,
|
||||
evaluation_output_price_sources: Mapping[str, str] = EVALUATION_OUTPUT_PRICE_SOURCES,
|
||||
) -> tuple[str, ...]:
|
||||
if not bot and not touches_cost_map(changed_files):
|
||||
return ()
|
||||
head_map: Final = _parse_object(head.cost_map, COST_MAP_PATH)
|
||||
if isinstance(head_map, str):
|
||||
return (head_map,)
|
||||
return (*_file_failures(head, head_map), *(_bot_failures(base, head_map, changed_files) if bot else ()))
|
||||
return (
|
||||
*_file_failures(
|
||||
head, head_map, evaluation_output_price_sources=evaluation_output_price_sources
|
||||
),
|
||||
*(_bot_failures(base, head_map, changed_files) if bot else ()),
|
||||
)
|
||||
|
||||
|
||||
def _git(*args: str) -> str | None:
|
||||
|
|
|
|||
|
|
@ -1474,6 +1474,7 @@ from .rust_bridge import rust
|
|||
from .rag.main import *
|
||||
from .sandbox.main import *
|
||||
from .decisions.main import *
|
||||
from .systemone.main import *
|
||||
from .search.main import *
|
||||
from .realtime_api.main import (
|
||||
_arealtime,
|
||||
|
|
@ -1654,6 +1655,30 @@ if TYPE_CHECKING:
|
|||
from .llms.hosted_vllm.rerank.transformation import (
|
||||
HostedVLLMRerankConfig as HostedVLLMRerankConfig,
|
||||
)
|
||||
from .llms.perplexity.decisions.transformation import (
|
||||
PerplexityDecisionsConfig as PerplexityDecisionsConfig,
|
||||
)
|
||||
from .llms.typesafe.decisions.transformation import (
|
||||
TypeSafeDecisionsConfig as TypeSafeDecisionsConfig,
|
||||
)
|
||||
from .llms.openrouter.decisions.transformation import (
|
||||
OpenRouterDecisionsConfig as OpenRouterDecisionsConfig,
|
||||
)
|
||||
from .llms.cloudflare.decisions.transformation import (
|
||||
CloudflareDecisionsConfig as CloudflareDecisionsConfig,
|
||||
)
|
||||
from .llms.strands_decider.decisions.transformation import (
|
||||
StrandsDeciderDecisionsConfig as StrandsDeciderDecisionsConfig,
|
||||
)
|
||||
from .llms.hosted_vllm.decisions.transformation import (
|
||||
HostedVLLMDecisionsConfig as HostedVLLMDecisionsConfig,
|
||||
)
|
||||
from .llms.openai.decisions.transformation import (
|
||||
OpenAIDecisionsConfig as OpenAIDecisionsConfig,
|
||||
)
|
||||
from .llms.azure_ai.decisions.transformation import (
|
||||
AzureAIDecisionsConfig as AzureAIDecisionsConfig,
|
||||
)
|
||||
from .llms.nvidia_nim.rerank.transformation import (
|
||||
NvidiaNimRerankConfig as NvidiaNimRerankConfig,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -153,6 +153,14 @@ LLM_CONFIG_NAMES: Final = (
|
|||
"JinaAIRerankConfig",
|
||||
"DeepinfraRerankConfig",
|
||||
"HostedVLLMRerankConfig",
|
||||
"PerplexityDecisionsConfig",
|
||||
"TypeSafeDecisionsConfig",
|
||||
"OpenRouterDecisionsConfig",
|
||||
"CloudflareDecisionsConfig",
|
||||
"StrandsDeciderDecisionsConfig",
|
||||
"HostedVLLMDecisionsConfig",
|
||||
"OpenAIDecisionsConfig",
|
||||
"AzureAIDecisionsConfig",
|
||||
"NvidiaNimRerankConfig",
|
||||
"NvidiaNimRankingConfig",
|
||||
"VertexAIRerankConfig",
|
||||
|
|
@ -696,6 +704,20 @@ _LLM_CONFIGS_IMPORT_MAP: Final = {
|
|||
".llms.hosted_vllm.rerank.transformation",
|
||||
"HostedVLLMRerankConfig",
|
||||
),
|
||||
"PerplexityDecisionsConfig": (".llms.perplexity.decisions.transformation", "PerplexityDecisionsConfig"),
|
||||
"TypeSafeDecisionsConfig": (".llms.typesafe.decisions.transformation", "TypeSafeDecisionsConfig"),
|
||||
"OpenRouterDecisionsConfig": (".llms.openrouter.decisions.transformation", "OpenRouterDecisionsConfig"),
|
||||
"CloudflareDecisionsConfig": (".llms.cloudflare.decisions.transformation", "CloudflareDecisionsConfig"),
|
||||
"StrandsDeciderDecisionsConfig": (
|
||||
".llms.strands_decider.decisions.transformation",
|
||||
"StrandsDeciderDecisionsConfig",
|
||||
),
|
||||
"HostedVLLMDecisionsConfig": (
|
||||
".llms.hosted_vllm.decisions.transformation",
|
||||
"HostedVLLMDecisionsConfig",
|
||||
),
|
||||
"OpenAIDecisionsConfig": (".llms.openai.decisions.transformation", "OpenAIDecisionsConfig"),
|
||||
"AzureAIDecisionsConfig": (".llms.azure_ai.decisions.transformation", "AzureAIDecisionsConfig"),
|
||||
"NvidiaNimRerankConfig": (
|
||||
".llms.nvidia_nim.rerank.transformation",
|
||||
"NvidiaNimRerankConfig",
|
||||
|
|
|
|||
|
|
@ -266,6 +266,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"
|
||||
|
|
|
|||
|
|
@ -2014,6 +2014,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,373 +1,131 @@
|
|||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from types import MappingProxyType
|
||||
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.decisions.transformation import (
|
||||
DecisionsProviderConfig,
|
||||
ir_to_systemone_response,
|
||||
systemone_request_to_ir,
|
||||
)
|
||||
from litellm.llms.cloudflare.decisions.transformation import CLOUDFLARE_DECISIONS_ENDPOINT
|
||||
from litellm.llms.custom_httpx.http_handler import _get_httpx_client, get_async_httpx_client
|
||||
from litellm.llms.openai.decisions.transformation import (
|
||||
OPENAI_DECISIONS_ENDPOINT,
|
||||
ir_to_openai_response,
|
||||
openai_request_to_ir,
|
||||
)
|
||||
from litellm.llms.openrouter.decisions.transformation import OPENROUTER_DECISIONS_ENDPOINT
|
||||
from litellm.llms.perplexity.decisions.transformation import PERPLEXITY_DECISIONS_ENDPOINT
|
||||
from litellm.llms.strands_decider.decisions.transformation import STRANDS_DECIDER_DECISIONS_ENDPOINT
|
||||
from litellm.llms.typesafe.decisions.transformation import TYPESAFE_DECISIONS_ENDPOINT
|
||||
from litellm.types.decisions import (
|
||||
DecisionQuestion,
|
||||
DecisionsIRRequest,
|
||||
DecisionsIRResponse,
|
||||
DecisionsJSON,
|
||||
DecisionsRequestBody,
|
||||
DecisionsResponse,
|
||||
OpenAIDecisionInput,
|
||||
OpenAIDecisionQuestion,
|
||||
OpenAIDecisionRequestBody,
|
||||
OpenAIDecisionResponse,
|
||||
UnsupportedDecisionsRequest,
|
||||
from litellm.decisions.call import (
|
||||
DecisionsCall,
|
||||
OpenAIDecisionQuestions,
|
||||
OpenAIDecisionRequestFields,
|
||||
RequestFields,
|
||||
SystemOneQuestions,
|
||||
SystemOneRequestFields,
|
||||
asend,
|
||||
prepare_call,
|
||||
send,
|
||||
)
|
||||
from litellm.types.decisions import DecisionsJSON, DecisionsResponse, OpenAIDecisionInput, OpenAIDecisionResponse
|
||||
from litellm.utils import client
|
||||
|
||||
DECISIONS_ENDPOINTS: Final[Mapping[str, DecisionsProviderConfig]] = MappingProxyType(
|
||||
{
|
||||
"perplexity": PERPLEXITY_DECISIONS_ENDPOINT,
|
||||
"typesafe": TYPESAFE_DECISIONS_ENDPOINT,
|
||||
"openrouter": OPENROUTER_DECISIONS_ENDPOINT,
|
||||
"cloudflare": CLOUDFLARE_DECISIONS_ENDPOINT,
|
||||
"strands_decider": STRANDS_DECIDER_DECISIONS_ENDPOINT,
|
||||
"openai": OPENAI_DECISIONS_ENDPOINT,
|
||||
}
|
||||
_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
|
||||
)
|
||||
|
||||
|
||||
def supported_decisions_providers() -> tuple[str, ...]:
|
||||
return tuple(DECISIONS_ENDPOINTS)
|
||||
|
||||
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)
|
||||
_DECISIONS_PAYLOAD_ADAPTER: Final[TypeAdapter[object]] = TypeAdapter(object)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, repr=False)
|
||||
class _PreparedDecisionsRequest:
|
||||
config: DecisionsProviderConfig
|
||||
provider: str
|
||||
upstream_model: str
|
||||
url: str
|
||||
api_key: str | None = field(repr=False)
|
||||
headers: Mapping[str, str] = field(repr=False)
|
||||
body: Mapping[str, object] = field(repr=False)
|
||||
request: DecisionsRequestFormat = field(repr=False)
|
||||
ir_request: DecisionsIRRequest = field(repr=False)
|
||||
|
||||
|
||||
def _resolve_provider_model(model: str, custom_llm_provider: str | None) -> tuple[str, str]:
|
||||
provider: Final = model.partition("/")[0] if custom_llm_provider is None else custom_llm_provider
|
||||
if provider not in DECISIONS_ENDPOINTS:
|
||||
supported: Final = ", ".join(supported_decisions_providers())
|
||||
raise litellm.BadRequestError(
|
||||
message=f"Unknown Decisions provider '{provider}'. Supported providers: {supported}",
|
||||
model=model,
|
||||
llm_provider=provider,
|
||||
def _request_fields(
|
||||
decision_input: OpenAIDecisionInput | None,
|
||||
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)
|
||||
)
|
||||
upstream_model: Final = model.removeprefix(f"{provider}/") if model.startswith(f"{provider}/") else model
|
||||
if not upstream_model:
|
||||
raise litellm.BadRequestError(
|
||||
message="A model name is required for the Decisions API",
|
||||
model=model,
|
||||
llm_provider=provider,
|
||||
)
|
||||
return provider, upstream_model
|
||||
|
||||
|
||||
def _resolve_api_key(
|
||||
*,
|
||||
provider: str,
|
||||
model: str,
|
||||
endpoint: DecisionsProviderConfig,
|
||||
api_key: str | None,
|
||||
) -> str | None:
|
||||
if api_key is not None:
|
||||
return api_key
|
||||
configured_api_key: Final = endpoint.configured_api_key()
|
||||
if configured_api_key:
|
||||
return configured_api_key
|
||||
if not endpoint.api_key_required:
|
||||
return None
|
||||
raise litellm.AuthenticationError(
|
||||
message=f"Missing API key for Decisions provider '{provider}'",
|
||||
model=model,
|
||||
llm_provider=provider,
|
||||
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 _validate_request(
|
||||
*,
|
||||
state: DecisionsJSON | None,
|
||||
questions: DecisionsQuestions | None,
|
||||
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}
|
||||
)
|
||||
|
||||
|
||||
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_request(
|
||||
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,
|
||||
timeout: float | httpx.Timeout | None,
|
||||
custom_llm_provider: str | None,
|
||||
extra_headers: Mapping[str, str] | None,
|
||||
) -> _PreparedDecisionsRequest:
|
||||
provider, upstream_model = _resolve_provider_model(model, custom_llm_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:
|
||||
validated_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
|
||||
|
||||
endpoint: Final = DECISIONS_ENDPOINTS[provider]
|
||||
resolved_api_base: Final = api_base or endpoint.configured_api_base()
|
||||
if resolved_api_base is None:
|
||||
raise litellm.BadRequestError(
|
||||
message=endpoint.missing_api_base_message(provider),
|
||||
model=model,
|
||||
llm_provider=provider,
|
||||
)
|
||||
|
||||
resolved_api_key: Final = _resolve_api_key(
|
||||
provider=provider,
|
||||
model=model,
|
||||
endpoint=endpoint,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
canonical_model: Final = endpoint.canonical_model(upstream_model)
|
||||
outbound_headers: Final = MappingProxyType(
|
||||
{
|
||||
**{
|
||||
name: value
|
||||
for name, value in (extra_headers or {}).items()
|
||||
if name.lower() not in {"authorization", "content-type"}
|
||||
},
|
||||
**({"Authorization": f"Bearer {resolved_api_key}"} if resolved_api_key is not None else {}),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
ir_request: Final = _ir_request(validated_request)
|
||||
body: Final = endpoint.request_body(endpoint.request_model(canonical_model), ir_request)
|
||||
if isinstance(body, UnsupportedDecisionsRequest):
|
||||
raise litellm.BadRequestError(
|
||||
message=f"Decisions provider '{provider}' cannot serve this request: {body.reason}",
|
||||
model=model,
|
||||
llm_provider=provider,
|
||||
)
|
||||
return _PreparedDecisionsRequest(
|
||||
config=endpoint,
|
||||
provider=provider,
|
||||
upstream_model=canonical_model,
|
||||
url=endpoint.endpoint_url(resolved_api_base, canonical_model),
|
||||
api_key=resolved_api_key,
|
||||
headers=outbound_headers,
|
||||
body=MappingProxyType(body),
|
||||
request=validated_request,
|
||||
ir_request=ir_request,
|
||||
)
|
||||
|
||||
|
||||
def _log_request(
|
||||
prepared: _PreparedDecisionsRequest,
|
||||
kwargs: Mapping[str, object],
|
||||
) -> LiteLLMLoggingObj | None:
|
||||
logging_obj: Final = kwargs.get("litellm_logging_obj")
|
||||
if not isinstance(logging_obj, LiteLLMLoggingObj):
|
||||
return None
|
||||
logging_obj.update_from_kwargs(
|
||||
kwargs=dict(kwargs),
|
||||
model=prepared.upstream_model,
|
||||
litellm_params={
|
||||
"litellm_call_id": kwargs.get("litellm_call_id"),
|
||||
"api_base": prepared.url,
|
||||
},
|
||||
custom_llm_provider=prepared.provider,
|
||||
)
|
||||
request_body: Final = dict(prepared.body)
|
||||
request_headers: Final = dict(prepared.headers)
|
||||
logging_obj.pre_call(
|
||||
input=request_body,
|
||||
api_key=prepared.api_key,
|
||||
model=prepared.upstream_model,
|
||||
additional_args={
|
||||
"api_base": prepared.url,
|
||||
"complete_input_dict": request_body,
|
||||
"headers": request_headers,
|
||||
},
|
||||
)
|
||||
return logging_obj
|
||||
|
||||
|
||||
def _format_response(
|
||||
response: DecisionsIRResponse, prepared: _PreparedDecisionsRequest, model: str
|
||||
) -> DecisionsResponse | OpenAIDecisionResponse:
|
||||
match prepared.request:
|
||||
case DecisionsRequestBody():
|
||||
return ir_to_systemone_response(response, prepared.ir_request)
|
||||
case OpenAIDecisionRequestBody():
|
||||
return ir_to_openai_response(response, prepared.ir_request, model)
|
||||
case _:
|
||||
assert_never(prepared.request)
|
||||
|
||||
|
||||
def _parse_response(
|
||||
response: httpx.Response,
|
||||
prepared: _PreparedDecisionsRequest,
|
||||
model: str,
|
||||
) -> DecisionsResponse | OpenAIDecisionResponse:
|
||||
response.raise_for_status()
|
||||
payload: Final[object] = _DECISIONS_PAYLOAD_ADAPTER.validate_json(response.content)
|
||||
result: Final = _format_response(prepared.config.parse_response(payload, prepared.ir_request), prepared, model)
|
||||
result._hidden_params.update(
|
||||
{
|
||||
"model": f"{prepared.provider}/{prepared.upstream_model}",
|
||||
"custom_llm_provider": prepared.provider,
|
||||
"provider_response_model": f"{prepared.provider}/{prepared.upstream_model}",
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _map_upstream_exception(error: Exception, prepared: _PreparedDecisionsRequest) -> Exception:
|
||||
return litellm.exception_type(
|
||||
model=f"{prepared.provider}/{prepared.upstream_model}",
|
||||
custom_llm_provider=prepared.provider,
|
||||
original_exception=error,
|
||||
) -> DecisionsCall:
|
||||
return prepare_call(
|
||||
model=model,
|
||||
fields=_request_fields(decision_input, questions, kwargs),
|
||||
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={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:
|
||||
prepared: Final = _prepare_request(
|
||||
) -> 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,
|
||||
timeout=timeout,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
extra_headers=extra_headers,
|
||||
kwargs=kwargs,
|
||||
)
|
||||
logging_obj: Final = _log_request(prepared, kwargs)
|
||||
try:
|
||||
handler: Final = get_async_httpx_client(llm_provider=prepared.provider)
|
||||
response: Final = await handler.post(
|
||||
prepared.url,
|
||||
json=dict(prepared.body),
|
||||
headers=dict(prepared.headers),
|
||||
timeout=timeout,
|
||||
logging_obj=logging_obj,
|
||||
)
|
||||
return _parse_response(response=response, prepared=prepared, model=model)
|
||||
except Exception as error:
|
||||
raise _map_upstream_exception(error, prepared) from error
|
||||
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:
|
||||
prepared: Final = _prepare_request(
|
||||
) -> 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,
|
||||
timeout=timeout,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
extra_headers=extra_headers,
|
||||
kwargs=kwargs,
|
||||
)
|
||||
logging_obj: Final = _log_request(prepared, kwargs)
|
||||
try:
|
||||
handler: Final = _get_httpx_client()
|
||||
response: Final = handler.post(
|
||||
prepared.url,
|
||||
json=dict(prepared.body),
|
||||
headers=dict(prepared.headers),
|
||||
timeout=timeout,
|
||||
logging_obj=logging_obj,
|
||||
)
|
||||
return _parse_response(response=response, prepared=prepared, model=model)
|
||||
except Exception as error:
|
||||
raise _map_upstream_exception(error, prepared) from error
|
||||
return send(call)
|
||||
|
||||
|
||||
__all__ = ["DECISIONS_ENDPOINTS", "adecisions", "decisions"]
|
||||
__all__ = ["adecisions", "decisions"]
|
||||
|
|
|
|||
|
|
@ -2146,7 +2146,10 @@ def _map_openrouter_exception(
|
|||
exception_provider: str,
|
||||
extra_information: str,
|
||||
) -> None:
|
||||
if hasattr(original_exception, "status_code"):
|
||||
received_status: Final = hasattr(original_exception, "status_code") and not getattr(
|
||||
original_exception, "status_code_is_synthesized", False
|
||||
)
|
||||
if received_status:
|
||||
if original_exception.status_code == 400:
|
||||
raise BadRequestError(
|
||||
message=f"{exception_provider} - {error_str}",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -30,6 +30,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:
|
||||
|
|
@ -258,11 +273,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),
|
||||
}
|
||||
)
|
||||
|
|
|
|||
24
litellm/llms/azure_ai/decisions/transformation.py
Normal file
24
litellm/llms/azure_ai/decisions/transformation.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import re
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
|
||||
from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig
|
||||
|
||||
_FOUNDRY_ROUTE_SUFFIX: Final = re.compile(r"(/api/projects/[^/]+)?(/openai/v1|/openai|/models|/v1)?$")
|
||||
|
||||
|
||||
class AzureAIDecisionsConfig(BaseDecisionsConfig):
|
||||
"""Microsoft Foundry decision models (Microsoft-Decision-1), where model is the deployment name"""
|
||||
|
||||
path = "/providers/microsoft/v1/systemone"
|
||||
api_key_env = ("AZURE_AI_API_KEY",)
|
||||
api_base_env = ("AZURE_AI_API_BASE",)
|
||||
|
||||
def missing_api_base_message(self, custom_llm_provider: str) -> str:
|
||||
return "Missing AZURE_AI_API_BASE - set AZURE_AI_API_BASE or pass api_base, e.g. https://<resource>.services.ai.azure.com"
|
||||
|
||||
def get_complete_url(self, api_base: str, model: str) -> str:
|
||||
url: Final = httpx.URL(api_base)
|
||||
resource_path: Final = _FOUNDRY_ROUTE_SUFFIX.sub("", url.path.rstrip("/"))
|
||||
return str(url.copy_with(path=f"{resource_path}{self.path}", query=None, fragment=None))
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
from .transformation import DecisionsProviderConfig, JevCompatibleDecisionsEndpoint
|
||||
from .transformation import BaseDecisionsConfig
|
||||
|
||||
__all__ = ["DecisionsProviderConfig", "JevCompatibleDecisionsEndpoint"]
|
||||
__all__ = ["BaseDecisionsConfig"]
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import json
|
||||
from abc import ABC
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Protocol
|
||||
from typing import Final
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
import httpx
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
from typing_extensions import assert_never
|
||||
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.decisions import (
|
||||
ChoiceAnswer,
|
||||
|
|
@ -46,7 +48,9 @@ from litellm.types.decisions import (
|
|||
systemone_choice_key,
|
||||
)
|
||||
|
||||
_PAYLOAD_ADAPTER: Final[TypeAdapter[object]] = TypeAdapter(object)
|
||||
_SYSTEMONE_RESPONSE_ADAPTER: Final[TypeAdapter[DecisionsResponse]] = TypeAdapter(DecisionsResponse)
|
||||
_RESERVED_HEADERS: Final[frozenset[str]] = frozenset({"authorization", "content-type"})
|
||||
_TEXT_ONLY: Final = UnsupportedDecisionsRequest(
|
||||
reason="input_image content parts are not supported because System One providers accept text input only"
|
||||
)
|
||||
|
|
@ -292,22 +296,34 @@ def ir_to_systemone_response(response: DecisionsIRResponse, request: DecisionsIR
|
|||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class JevCompatibleDecisionsEndpoint:
|
||||
default_api_base_value: str | None
|
||||
path: str
|
||||
api_key_env: tuple[str, ...]
|
||||
api_base_env: str
|
||||
class BaseDecisionsConfig(ABC):
|
||||
path: str = "/v1/systemone"
|
||||
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 configured_api_key(self) -> str | None:
|
||||
return next((key for key in (get_secret_str(name) for name in self.api_key_env) if key), None)
|
||||
def get_default_api_base(self) -> str | None:
|
||||
return None
|
||||
|
||||
def configured_api_base(self) -> str | None:
|
||||
return get_secret_str(self.api_base_env) or self.default_api_base_value
|
||||
def provider_reported_cost(self, response: DecisionsIRResponse) -> float | None:
|
||||
return None
|
||||
|
||||
def missing_api_base_message(self, provider: str) -> str:
|
||||
return f"api_base is required for Decisions provider '{provider}'"
|
||||
def missing_api_base_message(self, custom_llm_provider: str) -> str:
|
||||
return f"api_base is required for Decisions provider '{custom_llm_provider}'"
|
||||
|
||||
def resolve_api_base(self, api_base: str | None) -> str | None:
|
||||
return api_base or self._first_secret(self.api_base_env) or self.get_default_api_base()
|
||||
|
||||
def resolve_api_key(self, api_key: str | None) -> str | None:
|
||||
return api_key or self._first_secret(self.api_key_env)
|
||||
|
||||
@staticmethod
|
||||
def _first_secret(names: tuple[str, ...]) -> str | None:
|
||||
return next((value for value in (get_secret_str(name) for name in names) if value), None)
|
||||
|
||||
def canonical_model(self, model: str) -> str:
|
||||
return model
|
||||
|
|
@ -315,36 +331,64 @@ class JevCompatibleDecisionsEndpoint:
|
|||
def request_model(self, model: str) -> str:
|
||||
return model
|
||||
|
||||
def endpoint_url(self, api_base: str, model: str) -> str:
|
||||
def validate_environment(self, headers: Mapping[str, str], model: str, api_key: str | None) -> dict[str, str]:
|
||||
return {
|
||||
**{name: value for name, value in headers.items() if name.lower() not in _RESERVED_HEADERS},
|
||||
**({"Authorization": f"Bearer {api_key}"} if api_key is not None else {}),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def get_complete_url(self, api_base: str, model: str) -> str:
|
||||
return f"{api_base.rstrip('/').removesuffix('/v1')}{self.path}"
|
||||
|
||||
def request_body(
|
||||
self, model: str, request: DecisionsIRRequest
|
||||
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,
|
||||
request: DecisionsIRRequest,
|
||||
custom_llm_provider: str,
|
||||
) -> Mapping[str, object] | UnsupportedDecisionsRequest:
|
||||
return ir_to_systemone_request(model, request)
|
||||
return ir_to_systemone_request(self.request_model(model), request)
|
||||
|
||||
def unwrap_response(self, payload: object) -> object:
|
||||
return payload
|
||||
|
||||
def parse_response(self, payload: object, request: DecisionsIRRequest) -> DecisionsIRResponse:
|
||||
return parse_systemone_response(payload, request)
|
||||
return parse_systemone_response(self.unwrap_response(payload), request)
|
||||
|
||||
def transform_decisions_response(
|
||||
self,
|
||||
model: str,
|
||||
custom_llm_provider: str,
|
||||
raw_response: httpx.Response,
|
||||
request: DecisionsIRRequest,
|
||||
) -> DecisionsIRResponse:
|
||||
payload: Final[object] = _PAYLOAD_ADAPTER.validate_json(raw_response.content)
|
||||
try:
|
||||
return self.parse_response(payload, request)
|
||||
except ValidationError as error:
|
||||
raise BaseLLMException(
|
||||
status_code=500,
|
||||
message=f"Decisions provider '{custom_llm_provider}' returned an unexpected response: {error}",
|
||||
) from error
|
||||
|
||||
class DecisionsProviderConfig(Protocol):
|
||||
@property
|
||||
def api_key_required(self) -> bool: ...
|
||||
|
||||
def configured_api_key(self) -> str | None: ...
|
||||
|
||||
def configured_api_base(self) -> str | None: ...
|
||||
|
||||
def missing_api_base_message(self, provider: str) -> str: ...
|
||||
|
||||
def canonical_model(self, model: str) -> str: ...
|
||||
|
||||
def request_model(self, model: str) -> str: ...
|
||||
|
||||
def endpoint_url(self, api_base: str, model: str) -> str: ...
|
||||
|
||||
def request_body(
|
||||
self, model: str, request: DecisionsIRRequest
|
||||
) -> Mapping[str, object] | UnsupportedDecisionsRequest: ...
|
||||
|
||||
def parse_response(self, payload: object, request: DecisionsIRRequest) -> DecisionsIRResponse: ...
|
||||
def get_error_class(
|
||||
self,
|
||||
error_message: str,
|
||||
status_code: int,
|
||||
headers: dict[str, str] | httpx.Headers,
|
||||
) -> BaseLLMException:
|
||||
return BaseLLMException(status_code=status_code, message=error_message, headers=headers)
|
||||
|
|
|
|||
|
|
@ -1,36 +1,28 @@
|
|||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from litellm.llms.base_llm.decisions.transformation import ir_to_systemone_request, parse_systemone_response
|
||||
from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig
|
||||
from litellm.secret_managers.main import (
|
||||
get_secret_str,
|
||||
normalize_nonempty_secret_str,
|
||||
)
|
||||
from litellm.types.decisions import DecisionsIRRequest, DecisionsIRResponse, UnsupportedDecisionsRequest
|
||||
|
||||
_RESPONSE_MAPPING_ADAPTER: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object])
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CloudflareDecisionsEndpoint:
|
||||
api_key_required: bool = True
|
||||
class CloudflareDecisionsConfig(BaseDecisionsConfig):
|
||||
api_key_env = ("CLOUDFLARE_API_KEY",)
|
||||
api_base_env = ("CLOUDFLARE_API_BASE",)
|
||||
|
||||
def configured_api_key(self) -> str | None:
|
||||
return get_secret_str("CLOUDFLARE_API_KEY") or None
|
||||
|
||||
def configured_api_base(self) -> str | None:
|
||||
env_api_base: Final = get_secret_str("CLOUDFLARE_API_BASE")
|
||||
if env_api_base:
|
||||
return env_api_base
|
||||
def get_default_api_base(self) -> str | None:
|
||||
account_id: Final = normalize_nonempty_secret_str(get_secret_str("CLOUDFLARE_ACCOUNT_ID"))
|
||||
if account_id is None:
|
||||
return None
|
||||
return f"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run"
|
||||
|
||||
def missing_api_base_message(self, provider: str) -> str:
|
||||
def missing_api_base_message(self, custom_llm_provider: str) -> str:
|
||||
return "Missing CLOUDFLARE_ACCOUNT_ID - set CLOUDFLARE_ACCOUNT_ID or pass api_base explicitly"
|
||||
|
||||
def canonical_model(self, model: str) -> str:
|
||||
|
|
@ -41,7 +33,7 @@ class CloudflareDecisionsEndpoint:
|
|||
def request_model(self, model: str) -> str:
|
||||
return model.rsplit("/", maxsplit=1)[-1]
|
||||
|
||||
def endpoint_url(self, api_base: str, model: str) -> str:
|
||||
def get_complete_url(self, api_base: str, model: str) -> str:
|
||||
normalized_api_base: Final = api_base.rstrip("/")
|
||||
if normalized_api_base.endswith("/ai/v1"):
|
||||
return f"{normalized_api_base.removesuffix('/ai/v1')}/ai/run/{model}"
|
||||
|
|
@ -49,25 +41,14 @@ class CloudflareDecisionsEndpoint:
|
|||
return f"{normalized_api_base}/{model}"
|
||||
return f"{normalized_api_base}/ai/run/{model}"
|
||||
|
||||
def request_body(
|
||||
self, model: str, request: DecisionsIRRequest
|
||||
) -> Mapping[str, object] | UnsupportedDecisionsRequest:
|
||||
return ir_to_systemone_request(model, request)
|
||||
|
||||
def parse_response(self, payload: object, request: DecisionsIRRequest) -> DecisionsIRResponse:
|
||||
return parse_systemone_response(_unwrap_result(payload), request)
|
||||
|
||||
|
||||
def _unwrap_result(payload: object) -> object:
|
||||
if not isinstance(payload, Mapping):
|
||||
return payload
|
||||
response_mapping: Final = _RESPONSE_MAPPING_ADAPTER.validate_python(payload)
|
||||
if "answers" in response_mapping:
|
||||
return payload
|
||||
result: Final = response_mapping.get("result")
|
||||
if isinstance(result, Mapping):
|
||||
return result
|
||||
return payload
|
||||
|
||||
|
||||
CLOUDFLARE_DECISIONS_ENDPOINT: Final[CloudflareDecisionsEndpoint] = CloudflareDecisionsEndpoint()
|
||||
def unwrap_response(self, payload: object) -> object:
|
||||
try:
|
||||
response_mapping: Final = _RESPONSE_MAPPING_ADAPTER.validate_python(payload)
|
||||
except ValidationError:
|
||||
return payload
|
||||
if "answers" in response_mapping:
|
||||
return response_mapping
|
||||
try:
|
||||
return _RESPONSE_MAPPING_ADAPTER.validate_python(response_mapping.get("result"))
|
||||
except ValidationError:
|
||||
return response_mapping
|
||||
|
|
|
|||
|
|
@ -3,7 +3,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,
|
||||
|
|
@ -68,6 +68,7 @@ from litellm.llms.base_llm.base_model_iterator import (
|
|||
from litellm.llms.base_llm.batches.transformation import BaseBatchesConfig
|
||||
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
|
||||
from litellm.llms.base_llm.containers.transformation import BaseContainerConfig
|
||||
from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig
|
||||
from litellm.llms.base_llm.embedding.transformation import BaseEmbeddingConfig
|
||||
from litellm.llms.base_llm.evals.transformation import BaseEvalsAPIConfig
|
||||
from litellm.llms.base_llm.files.transformation import (
|
||||
|
|
@ -120,6 +121,7 @@ from litellm.types.containers.main import (
|
|||
ContainerObject,
|
||||
DeleteContainerResult,
|
||||
)
|
||||
from litellm.types.decisions import DecisionsIRRequest, DecisionsIRResponse
|
||||
from litellm.types.files import StreamingMediaUploadConfig, TwoStepFileUploadConfig
|
||||
from litellm.types.integrations.custom_logger import (
|
||||
NON_CODE_INTERPRETER_INTERCEPTION_INTERNAL_PREFIXES,
|
||||
|
|
@ -1413,6 +1415,132 @@ class BaseLLMHTTPHandler:
|
|||
request_data=request_data,
|
||||
)
|
||||
|
||||
def _prepare_decisions_request(
|
||||
self,
|
||||
model: str,
|
||||
logging_obj: LiteLLMLoggingObj | None,
|
||||
provider_config: BaseDecisionsConfig,
|
||||
body: Mapping[str, object],
|
||||
api_base: str,
|
||||
api_key: str | None,
|
||||
headers: Mapping[str, str],
|
||||
) -> tuple[str, dict[str, str], dict[str, object]]:
|
||||
outbound_headers: Final = provider_config.validate_environment(headers=headers, model=model, api_key=api_key)
|
||||
url: Final = provider_config.get_complete_url(api_base=api_base, model=model)
|
||||
data: Final = dict(body)
|
||||
if logging_obj is not None:
|
||||
logging_obj.pre_call(
|
||||
input=data,
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
additional_args={"api_base": url, "complete_input_dict": data, "headers": outbound_headers},
|
||||
)
|
||||
return url, outbound_headers, data
|
||||
|
||||
def decisions(
|
||||
self,
|
||||
model: str,
|
||||
custom_llm_provider: str,
|
||||
logging_obj: LiteLLMLoggingObj | None,
|
||||
provider_config: BaseDecisionsConfig,
|
||||
request: DecisionsIRRequest,
|
||||
body: Mapping[str, object],
|
||||
api_base: str,
|
||||
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(
|
||||
model=model,
|
||||
logging_obj=logging_obj,
|
||||
provider_config=provider_config,
|
||||
body=body,
|
||||
api_base=api_base,
|
||||
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,
|
||||
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)
|
||||
return provider_config.transform_decisions_response(
|
||||
model=model, custom_llm_provider=custom_llm_provider, raw_response=response, request=request
|
||||
)
|
||||
|
||||
async def adecisions(
|
||||
self,
|
||||
model: str,
|
||||
custom_llm_provider: str,
|
||||
logging_obj: LiteLLMLoggingObj | None,
|
||||
provider_config: BaseDecisionsConfig,
|
||||
request: DecisionsIRRequest,
|
||||
body: Mapping[str, object],
|
||||
api_base: str,
|
||||
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(
|
||||
model=model,
|
||||
logging_obj=logging_obj,
|
||||
provider_config=provider_config,
|
||||
body=body,
|
||||
api_base=api_base,
|
||||
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
|
||||
else get_async_httpx_client(llm_provider=litellm.LlmProviders(custom_llm_provider))
|
||||
)
|
||||
try:
|
||||
response: Final = await async_httpx_client.post(
|
||||
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)
|
||||
return provider_config.transform_decisions_response(
|
||||
model=model, custom_llm_provider=custom_llm_provider, raw_response=response, request=request
|
||||
)
|
||||
|
||||
def _prepare_audio_transcription_request(
|
||||
self,
|
||||
model: str,
|
||||
|
|
@ -5926,6 +6054,7 @@ class BaseLLMHTTPHandler:
|
|||
e: Exception,
|
||||
provider_config: Union[
|
||||
BaseConfig,
|
||||
BaseDecisionsConfig,
|
||||
BaseRerankConfig,
|
||||
BaseResponsesAPIConfig,
|
||||
BaseImageEditConfig,
|
||||
|
|
|
|||
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
|
||||
|
|
@ -1,12 +1,11 @@
|
|||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
from typing_extensions import assert_never
|
||||
|
||||
import litellm
|
||||
from litellm.llms.base_llm.decisions.transformation import decisions_text
|
||||
from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig, decisions_text
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.decisions import (
|
||||
DecisionsIRAnswer,
|
||||
|
|
@ -291,40 +290,32 @@ def ir_to_openai_response(
|
|||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OpenAIDecisionsEndpoint:
|
||||
api_key_required: bool = True
|
||||
class OpenAIDecisionsConfig(BaseDecisionsConfig):
|
||||
path = "/v1/decisions"
|
||||
supports_safety_identifier = True
|
||||
|
||||
def configured_api_key(self) -> str | None:
|
||||
return litellm.api_key or litellm.openai_key or get_secret_str("OPENAI_API_KEY")
|
||||
def get_default_api_base(self) -> str | None:
|
||||
return "https://api.openai.com"
|
||||
|
||||
def configured_api_base(self) -> str | None:
|
||||
def resolve_api_base(self, api_base: str | None) -> str | None:
|
||||
return (
|
||||
litellm.api_base
|
||||
api_base
|
||||
or litellm.api_base
|
||||
or get_secret_str("OPENAI_BASE_URL")
|
||||
or get_secret_str("OPENAI_API_BASE")
|
||||
or "https://api.openai.com"
|
||||
or self.get_default_api_base()
|
||||
)
|
||||
|
||||
def missing_api_base_message(self, provider: str) -> str:
|
||||
return f"api_base is required for Decisions provider '{provider}'"
|
||||
def resolve_api_key(self, api_key: str | None) -> str | None:
|
||||
return api_key or litellm.api_key or litellm.openai_key or get_secret_str("OPENAI_API_KEY")
|
||||
|
||||
def canonical_model(self, model: str) -> str:
|
||||
return model
|
||||
|
||||
def request_model(self, model: str) -> str:
|
||||
return model
|
||||
|
||||
def endpoint_url(self, api_base: str, model: str) -> str:
|
||||
return f"{api_base.rstrip('/').removesuffix('/v1')}/v1/decisions"
|
||||
|
||||
def request_body(
|
||||
self, model: str, request: DecisionsIRRequest
|
||||
def transform_decisions_request(
|
||||
self,
|
||||
model: str,
|
||||
request: DecisionsIRRequest,
|
||||
custom_llm_provider: str,
|
||||
) -> Mapping[str, object] | UnsupportedDecisionsRequest:
|
||||
return ir_to_openai_request(model, request)
|
||||
return ir_to_openai_request(self.request_model(model), request)
|
||||
|
||||
def parse_response(self, payload: object, request: DecisionsIRRequest) -> DecisionsIRResponse:
|
||||
return openai_response_to_ir(_OPENAI_RESPONSE_ADAPTER.validate_python(payload), request)
|
||||
|
||||
|
||||
OPENAI_DECISIONS_ENDPOINT: Final[OpenAIDecisionsEndpoint] = OpenAIDecisionsEndpoint()
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ from typing import TYPE_CHECKING, Any, Final, cast
|
|||
|
||||
import httpx
|
||||
|
||||
import litellm
|
||||
from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam
|
||||
|
|
@ -40,18 +39,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,10 +1,19 @@
|
|||
from typing import Final
|
||||
|
||||
from litellm.llms.base_llm.decisions.transformation import JevCompatibleDecisionsEndpoint
|
||||
from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig
|
||||
from litellm.types.decisions import DecisionsIRResponse
|
||||
|
||||
OPENROUTER_DECISIONS_ENDPOINT: Final[JevCompatibleDecisionsEndpoint] = JevCompatibleDecisionsEndpoint(
|
||||
default_api_base_value="https://openrouter.ai/api",
|
||||
path="/alpha/decisions",
|
||||
api_key_env=("OPENROUTER_API_KEY",),
|
||||
api_base_env="OPENROUTER_API_BASE",
|
||||
)
|
||||
|
||||
class OpenRouterDecisionsConfig(BaseDecisionsConfig):
|
||||
path = "/alpha/decisions"
|
||||
api_key_env = ("OPENROUTER_API_KEY",)
|
||||
api_base_env = ("OPENROUTER_API_BASE",)
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
from typing import Final
|
||||
from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig
|
||||
|
||||
from litellm.llms.base_llm.decisions.transformation import JevCompatibleDecisionsEndpoint
|
||||
|
||||
PERPLEXITY_DECISIONS_ENDPOINT: Final[JevCompatibleDecisionsEndpoint] = JevCompatibleDecisionsEndpoint(
|
||||
default_api_base_value="https://api.perplexity.ai",
|
||||
path="/v1/decisions",
|
||||
api_key_env=("PERPLEXITYAI_API_KEY", "PERPLEXITY_API_KEY"),
|
||||
api_base_env="PERPLEXITY_API_BASE",
|
||||
)
|
||||
class PerplexityDecisionsConfig(BaseDecisionsConfig):
|
||||
path = "/v1/decisions"
|
||||
api_key_env = ("PERPLEXITYAI_API_KEY", "PERPLEXITY_API_KEY")
|
||||
api_base_env = ("PERPLEXITY_API_BASE",)
|
||||
|
||||
def get_default_api_base(self) -> str | None:
|
||||
return "https://api.perplexity.ai"
|
||||
|
|
|
|||
|
|
@ -1,11 +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
|
||||
|
||||
from litellm.llms.base_llm.decisions.transformation import JevCompatibleDecisionsEndpoint
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
|
||||
STRANDS_DECIDER_DECISIONS_ENDPOINT: Final[JevCompatibleDecisionsEndpoint] = JevCompatibleDecisionsEndpoint(
|
||||
default_api_base_value=None,
|
||||
path="/v1/systemone",
|
||||
api_key_env=("STRANDS_DECIDER_API_KEY",),
|
||||
api_base_env="STRANDS_DECIDER_API_BASE",
|
||||
api_key_required=False,
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
from typing import Final
|
||||
from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig
|
||||
|
||||
from litellm.llms.base_llm.decisions.transformation import JevCompatibleDecisionsEndpoint
|
||||
|
||||
TYPESAFE_DECISIONS_ENDPOINT: Final[JevCompatibleDecisionsEndpoint] = JevCompatibleDecisionsEndpoint(
|
||||
default_api_base_value="https://api.typesafe.ai",
|
||||
path="/v1/systemone",
|
||||
api_key_env=("TYPESAFE_API_KEY",),
|
||||
api_base_env="TYPESAFE_API_BASE",
|
||||
)
|
||||
class TypeSafeDecisionsConfig(BaseDecisionsConfig):
|
||||
api_key_env = ("TYPESAFE_API_KEY",)
|
||||
api_base_env = ("TYPESAFE_API_BASE",)
|
||||
|
||||
def get_default_api_base(self) -> str | None:
|
||||
return "https://api.typesafe.ai"
|
||||
|
|
|
|||
|
|
@ -79765,5 +79765,12 @@
|
|||
"metadata": {
|
||||
"notes": "Self-hosted decision model; infrastructure costs are paid separately"
|
||||
}
|
||||
},
|
||||
"azure_ai/Microsoft-Decision-1": {
|
||||
"input_cost_per_token": 4.2e-08,
|
||||
"litellm_provider": "azure_ai",
|
||||
"mode": "evaluation",
|
||||
"output_cost_per_token": 0.0,
|
||||
"source": "https://prices.azure.com/api/retail/prices?$filter=productName eq 'Microsoft Decision Models'"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -343,6 +343,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
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@ ProxyRouteType: TypeAlias = Literal[
|
|||
"aocr",
|
||||
"asearch",
|
||||
"adecisions",
|
||||
"asystemone",
|
||||
"avideo_generation",
|
||||
"avideo_list",
|
||||
"avideo_status",
|
||||
|
|
@ -1960,6 +1961,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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1405,7 +1405,7 @@ async def get_guardrail_ui_settings():
|
|||
- 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,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from litellm.types.utils import ModelResponse, StandardPassThroughResponseObject
|
|||
class _TypeSafeUsage(BaseModel):
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
cost: float | None = None
|
||||
|
||||
|
||||
class _TypeSafeResponse(BaseModel):
|
||||
|
|
@ -82,7 +83,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,
|
||||
|
|
|
|||
|
|
@ -3161,6 +3161,34 @@
|
|||
],
|
||||
"default_model_placeholder": "soniox/stt-async-v5"
|
||||
},
|
||||
{
|
||||
"provider": "StrandsDecider",
|
||||
"provider_display_name": "Strands Decider",
|
||||
"litellm_provider": "strands_decider",
|
||||
"credential_fields": [
|
||||
{
|
||||
"key": "api_base",
|
||||
"label": "API Base",
|
||||
"placeholder": "http://localhost:8000",
|
||||
"tooltip": "URL of your self-hosted Strands Decider server",
|
||||
"required": true,
|
||||
"field_type": "text",
|
||||
"options": null,
|
||||
"default_value": null
|
||||
},
|
||||
{
|
||||
"key": "api_key",
|
||||
"label": "API Key",
|
||||
"placeholder": null,
|
||||
"tooltip": null,
|
||||
"required": false,
|
||||
"field_type": "password",
|
||||
"options": null,
|
||||
"default_value": null
|
||||
}
|
||||
],
|
||||
"default_model_placeholder": "strands_decider/strands-decider-2B-hobson-v19"
|
||||
},
|
||||
{
|
||||
"provider": "TEXT_COMPLETION_CODESTRAL",
|
||||
"provider_display_name": "Text-Completion-Codestral",
|
||||
|
|
@ -3263,6 +3291,34 @@
|
|||
],
|
||||
"default_model_placeholder": "gpt-3.5-turbo"
|
||||
},
|
||||
{
|
||||
"provider": "TypeSafe",
|
||||
"provider_display_name": "TypeSafe",
|
||||
"litellm_provider": "typesafe",
|
||||
"credential_fields": [
|
||||
{
|
||||
"key": "api_base",
|
||||
"label": "API Base",
|
||||
"placeholder": "https://api.typesafe.ai",
|
||||
"tooltip": null,
|
||||
"required": false,
|
||||
"field_type": "text",
|
||||
"options": null,
|
||||
"default_value": null
|
||||
},
|
||||
{
|
||||
"key": "api_key",
|
||||
"label": "API Key",
|
||||
"placeholder": null,
|
||||
"tooltip": null,
|
||||
"required": true,
|
||||
"field_type": "password",
|
||||
"options": null,
|
||||
"default_value": null
|
||||
}
|
||||
],
|
||||
"default_model_placeholder": "typesafe/jev-latest"
|
||||
},
|
||||
{
|
||||
"provider": "V0",
|
||||
"provider_display_name": "V0",
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ ROUTE_ENDPOINT_MAPPING: Final = {
|
|||
"aocr": "/ocr",
|
||||
"asearch": "/search",
|
||||
"adecisions": "/decisions",
|
||||
"asystemone": "/systemone",
|
||||
"avideo_generation": "/videos",
|
||||
"avideo_list": "/videos",
|
||||
"avideo_status": "/videos/{video_id}",
|
||||
|
|
@ -375,6 +376,7 @@ RouteType = Literal[
|
|||
"aocr",
|
||||
"asearch",
|
||||
"adecisions",
|
||||
"asystemone",
|
||||
"avideo_generation",
|
||||
"avideo_list",
|
||||
"avideo_status",
|
||||
|
|
|
|||
|
|
@ -2091,6 +2091,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 (
|
||||
|
|
@ -6646,6 +6651,8 @@ class Router:
|
|||
"search",
|
||||
"adecisions",
|
||||
"decisions",
|
||||
"asystemone",
|
||||
"systemone",
|
||||
"aadapter_generate_content",
|
||||
"avideo_generation",
|
||||
"video_generation",
|
||||
|
|
@ -6720,6 +6727,7 @@ class Router:
|
|||
"ocr",
|
||||
"search",
|
||||
"decisions",
|
||||
"systemone",
|
||||
"video_generation",
|
||||
"video_list",
|
||||
"video_status",
|
||||
|
|
@ -6888,6 +6896,7 @@ class Router:
|
|||
"aocr",
|
||||
"ocr",
|
||||
"adecisions",
|
||||
"asystemone",
|
||||
"avideo_generation",
|
||||
"avideo_list",
|
||||
"avideo_status",
|
||||
|
|
|
|||
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"))
|
||||
|
|
@ -121,6 +140,13 @@ class DecisionsUsage(LiteLLMPydanticObjectBase):
|
|||
class _HiddenParamsResponse(LiteLLMPydanticObjectBase):
|
||||
_hidden_params: dict[str, object] = PrivateAttr(default_factory=dict)
|
||||
|
||||
@property
|
||||
def hidden_params(self) -> dict[str, object]: # mutable-ok: API requires mutation
|
||||
return self._hidden_params
|
||||
|
||||
def set_hidden_params(self, params: Mapping[str, object]) -> None:
|
||||
self._hidden_params.update(params)
|
||||
|
||||
|
||||
class DecisionsResponse(_HiddenParamsResponse):
|
||||
model: str | None = None
|
||||
|
|
@ -164,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)
|
||||
|
||||
|
|
@ -185,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)
|
||||
|
||||
|
|
@ -208,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)
|
||||
|
||||
|
|
@ -222,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
|
||||
|
|
|
|||
|
|
@ -469,6 +469,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"
|
||||
|
|
@ -657,6 +659,8 @@ CallTypesLiteral = Literal[
|
|||
"asearch",
|
||||
"decisions",
|
||||
"adecisions",
|
||||
"systemone",
|
||||
"asystemone",
|
||||
"_arealtime",
|
||||
"_aresponses_websocket",
|
||||
"create_batch",
|
||||
|
|
@ -768,8 +772,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],
|
||||
|
|
|
|||
|
|
@ -381,6 +381,7 @@ if TYPE_CHECKING:
|
|||
# Type stubs for lazy-loaded config classes and types
|
||||
from litellm.llms.base_llm.batches.transformation import BaseBatchesConfig
|
||||
from litellm.llms.base_llm.containers.transformation import BaseContainerConfig
|
||||
from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig
|
||||
from litellm.llms.base_llm.embedding.transformation import BaseEmbeddingConfig
|
||||
from litellm.llms.base_llm.files.transformation import BaseFilesConfig
|
||||
from litellm.llms.base_llm.google_genai.transformation import (
|
||||
|
|
@ -971,6 +972,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,
|
||||
|
|
@ -1217,7 +1224,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):
|
||||
|
|
@ -8897,6 +8904,26 @@ class ProviderConfigManager:
|
|||
return get_dashscope_family_rerank_config(provider.value)
|
||||
return litellm.CohereRerankConfig()
|
||||
|
||||
@staticmethod
|
||||
def get_provider_decisions_config(model: str, provider: LlmProviders) -> BaseDecisionsConfig | None:
|
||||
if provider == LlmProviders.PERPLEXITY:
|
||||
return litellm.PerplexityDecisionsConfig()
|
||||
if provider == LlmProviders.TYPESAFE:
|
||||
return litellm.TypeSafeDecisionsConfig()
|
||||
if provider == LlmProviders.OPENROUTER:
|
||||
return litellm.OpenRouterDecisionsConfig()
|
||||
if provider == LlmProviders.CLOUDFLARE:
|
||||
return litellm.CloudflareDecisionsConfig()
|
||||
if provider == LlmProviders.STRANDS_DECIDER:
|
||||
return litellm.StrandsDeciderDecisionsConfig()
|
||||
if provider == LlmProviders.HOSTED_VLLM:
|
||||
return litellm.HostedVLLMDecisionsConfig()
|
||||
if provider == LlmProviders.OPENAI:
|
||||
return litellm.OpenAIDecisionsConfig()
|
||||
if provider == LlmProviders.AZURE_AI:
|
||||
return litellm.AzureAIDecisionsConfig()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_provider_anthropic_messages_config(
|
||||
model: str,
|
||||
|
|
|
|||
|
|
@ -79765,5 +79765,12 @@
|
|||
"metadata": {
|
||||
"notes": "Self-hosted decision model; infrastructure costs are paid separately"
|
||||
}
|
||||
},
|
||||
"azure_ai/Microsoft-Decision-1": {
|
||||
"input_cost_per_token": 4.2e-08,
|
||||
"litellm_provider": "azure_ai",
|
||||
"mode": "evaluation",
|
||||
"output_cost_per_token": 0.0,
|
||||
"source": "https://prices.azure.com/api/retail/prices?$filter=productName eq 'Microsoft Decision Models'"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1369,7 +1369,8 @@
|
|||
"rerank": true,
|
||||
"a2a": true,
|
||||
"interactions": true,
|
||||
"video_generations": true
|
||||
"video_generations": true,
|
||||
"systemone": true
|
||||
}
|
||||
},
|
||||
"huggingface": {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,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.
|
||||
|
|
|
|||
117
tests/integration/_support/forward_proxy.py
Normal file
117
tests/integration/_support/forward_proxy.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import socket
|
||||
import threading
|
||||
from collections.abc import Generator, Mapping
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from queue import SimpleQueue
|
||||
from typing import Final
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Tunnel:
|
||||
method: str
|
||||
target: str
|
||||
headers: Mapping[str, str]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ForwardProxy:
|
||||
url: str
|
||||
port: int
|
||||
received: SimpleQueue[Tunnel]
|
||||
|
||||
def drain(self) -> tuple[Tunnel, ...]:
|
||||
return tuple(self.received.get_nowait() for _ in range(self.received.qsize()))
|
||||
|
||||
def targets(self) -> tuple[str, ...]:
|
||||
return tuple(tunnel.target for tunnel in self.drain())
|
||||
|
||||
|
||||
@contextmanager
|
||||
def refusing_forward_proxy(port: int = 0) -> Generator[ForwardProxy, None, None]:
|
||||
"""Owned HTTP forward proxy: records the host each CONNECT asks for and refuses the tunnel with 403.
|
||||
|
||||
A litellm proxy booted with ``HTTPS_PROXY`` pointed here makes the host it dials for a provider
|
||||
observable without a byte leaving the box; the caller sees litellm's own connection error.
|
||||
"""
|
||||
received: Final[SimpleQueue[Tunnel]] = SimpleQueue()
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
timeout = 5
|
||||
|
||||
def refuse(self) -> None:
|
||||
received.put(Tunnel(self.command, self.path, {name.lower(): value for name, value in self.headers.items()}))
|
||||
self.send_response(403)
|
||||
self.send_header("content-length", "0")
|
||||
self.send_header("connection", "close")
|
||||
self.end_headers()
|
||||
self.close_connection = True
|
||||
|
||||
do_CONNECT = refuse
|
||||
do_GET = refuse
|
||||
do_POST = refuse
|
||||
do_PUT = refuse
|
||||
do_DELETE = refuse
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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()
|
||||
|
|
@ -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={
|
||||
|
|
@ -147,3 +157,32 @@ def test_evaluation_mode_health_check_of_the_self_hosted_strands_model_resolves_
|
|||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
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},
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
|
|
|
|||
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()
|
||||
|
|
@ -25,6 +25,7 @@ _PASS_THROUGH_MODEL: Final = "gpt-6-luna"
|
|||
_PASS_THROUGH_AUTHORIZATION: Final = "Bearer customer-held-upstream-key"
|
||||
_PASS_THROUGH_NEIGHBOUR: Final = "decisions-beside-a-pass-through"
|
||||
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
||||
_QUESTION_MAPPINGS: Final = TypeAdapter(dict[str, dict[str, object]])
|
||||
_USAGE: Final[dict[str, JsonValue]] = {"input_tokens": 367, "output_tokens": 3}
|
||||
_STATE: Final[dict[str, JsonValue]] = {"ticket": "The export job hangs at 99%", "component": "billing"}
|
||||
_QUESTIONS: Final[dict[str, JsonValue]] = {
|
||||
|
|
@ -32,6 +33,7 @@ _QUESTIONS: Final[dict[str, JsonValue]] = {
|
|||
"severity": {"type": "choice", "criteria": {"low": "cosmetic", "high": "blocks users"}, "weight": 2},
|
||||
"confidence": {"type": "score", "instructions": "How sure are you?", "criteria": ["unsure", "sure"]},
|
||||
}
|
||||
_SDK_QUESTIONS: Final = _QUESTION_MAPPINGS.validate_python(_QUESTIONS)
|
||||
_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}},
|
||||
|
|
@ -67,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 = (
|
||||
|
|
@ -87,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",
|
||||
|
|
@ -101,8 +108,16 @@ _PROVIDERS: Final = (
|
|||
True,
|
||||
"cloudflare/@cf/cloudflare/clef",
|
||||
),
|
||||
_Provider(
|
||||
"azure_ai", "azure_ai/decision-1", "/providers/microsoft/v1/systemone", "decision-1", _API_KEY, False, None
|
||||
),
|
||||
)
|
||||
_PERPLEXITY: Final = _PROVIDERS[0]
|
||||
_OPENROUTER: Final = _PROVIDERS[2]
|
||||
_OPENROUTER_CHAT_MODEL: Final = "openrouter/openai/gpt-5-mini"
|
||||
_UNSUPPORTED_PROVIDER_MODEL: Final = "anthropic/claude-opus-5-5"
|
||||
_CONNECTION_ERROR: Final = "litellm.APIConnectionError"
|
||||
_GENERIC_API_ERROR: Final = "litellm.APIError"
|
||||
_INVALID_BODIES: Final[tuple[tuple[str, dict[str, JsonValue]], ...]] = (
|
||||
("missing questions", {"state": _STATE}),
|
||||
("missing state", {"questions": _QUESTIONS}),
|
||||
|
|
@ -123,17 +138,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
|
||||
|
||||
|
||||
|
|
@ -177,6 +207,12 @@ def _spend_row(call_id: str) -> dict[str, JsonValue]:
|
|||
return rows[0]
|
||||
|
||||
|
||||
def _assert_connection_error(response: httpx.Response) -> None:
|
||||
assert 500 <= response.status_code < 600, response.text
|
||||
assert _CONNECTION_ERROR in response.text, response.text
|
||||
assert _GENERIC_API_ERROR not in response.text, response.text
|
||||
|
||||
|
||||
def _free_closed_port() -> int:
|
||||
with socket.socket() as probe:
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
|
|
@ -210,16 +246,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)
|
||||
|
|
@ -235,7 +269,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
|
||||
|
||||
|
||||
|
|
@ -257,10 +291,10 @@ async def test_sdk_sync_and_async_clients_send_the_same_request(gateway: Gateway
|
|||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, _answer_body(provider))
|
||||
synchronous: Final = litellm.decisions(
|
||||
model=provider.model, state=_STATE, questions=_QUESTIONS, api_base=handle.api_base(), api_key=_API_KEY
|
||||
model=provider.model, state=_STATE, questions=_SDK_QUESTIONS, api_base=handle.api_base(), api_key=_API_KEY
|
||||
)
|
||||
asynchronous: Final = await litellm.adecisions(
|
||||
model=provider.model, state=_STATE, questions=_QUESTIONS, api_base=handle.api_base(), api_key=_API_KEY
|
||||
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):
|
||||
assert response.model_dump(mode="json") == {
|
||||
|
|
@ -427,7 +461,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,
|
||||
)
|
||||
|
|
@ -443,16 +477,75 @@ def test_upstream_success_without_answers_is_a_gateway_side_server_error(gateway
|
|||
assert _spend_row(response.headers["x-litellm-call-id"])["status"] == "failure"
|
||||
|
||||
|
||||
def test_unreachable_upstream_fails_only_its_own_deployment(gateway: Gateway) -> None:
|
||||
@pytest.mark.parametrize("provider", _PROVIDERS, ids=lambda provider: provider.name)
|
||||
def test_unreachable_upstream_fails_only_its_own_deployment(gateway: Gateway, provider: _Provider) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, _answer_body(_PERPLEXITY))
|
||||
healthy: Final = _deployment(scenario, handle, _PERPLEXITY)
|
||||
handle: Final = _register(scenario, _answer_body(provider))
|
||||
healthy: Final = _deployment(scenario, handle, provider)
|
||||
dead: Final = scenario.model(
|
||||
model=_PERPLEXITY.model, api_base=f"http://127.0.0.1:{_free_closed_port()}", api_key=_API_KEY
|
||||
model=provider.model, api_base=f"http://127.0.0.1:{_free_closed_port()}", api_key=provider.api_key
|
||||
)
|
||||
failed: Final = _decide(gateway, dead, num_retries=0)
|
||||
assert 500 <= failed.status_code < 600, failed.text
|
||||
_assert_connection_error(failed)
|
||||
assert _spend_row(failed.headers["x-litellm-call-id"])["status"] == "failure"
|
||||
served: Final = _decide(gateway, healthy)
|
||||
assert served.status_code == 200, served.text
|
||||
assert len(_upstream_calls(gateway, handle)) == 1
|
||||
|
||||
|
||||
def test_an_unreachable_openrouter_deployment_reports_a_connection_error_on_chat_embeddings_and_decisions(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
dead_api_base: Final = f"http://127.0.0.1:{_free_closed_port()}"
|
||||
chat_model: Final = scenario.model(model=_OPENROUTER_CHAT_MODEL, api_base=dead_api_base, api_key=_API_KEY)
|
||||
decisions_model: Final = scenario.model(model=_OPENROUTER.model, api_base=dead_api_base, api_key=_API_KEY)
|
||||
chat: Final = _chat(gateway, chat_model, num_retries=0)
|
||||
streamed: Final = _chat(gateway, chat_model, num_retries=0, stream=True)
|
||||
embeddings: Final = gateway.request(
|
||||
"POST", "/v1/embeddings", {"model": chat_model, "input": "hi", "num_retries": 0}
|
||||
)
|
||||
decisions: Final = _decide(gateway, decisions_model, num_retries=0)
|
||||
for response in (chat, streamed, embeddings, decisions):
|
||||
_assert_connection_error(response)
|
||||
for response in (chat, decisions):
|
||||
assert _spend_row(response.headers["x-litellm-call-id"])["status"] == "failure"
|
||||
|
||||
|
||||
async def test_sdk_openrouter_connection_failures_raise_a_connection_error(gateway: Gateway) -> None:
|
||||
dead_api_base: Final = f"http://127.0.0.1:{_free_closed_port()}"
|
||||
with pytest.raises(litellm.APIConnectionError):
|
||||
litellm.completion(
|
||||
model=_OPENROUTER_CHAT_MODEL,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
api_base=dead_api_base,
|
||||
api_key=_API_KEY,
|
||||
)
|
||||
with pytest.raises(litellm.APIConnectionError):
|
||||
await litellm.acompletion(
|
||||
model=_OPENROUTER_CHAT_MODEL,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
api_base=dead_api_base,
|
||||
api_key=_API_KEY,
|
||||
)
|
||||
with pytest.raises(litellm.APIConnectionError):
|
||||
litellm.decisions(
|
||||
model=_OPENROUTER.model, state=_STATE, questions=_SDK_QUESTIONS, api_base=dead_api_base, api_key=_API_KEY
|
||||
)
|
||||
with pytest.raises(litellm.APIConnectionError):
|
||||
await litellm.asystemone(
|
||||
model=_OPENROUTER.model, state=_STATE, questions=_SDK_QUESTIONS, api_base=dead_api_base, api_key=_API_KEY
|
||||
)
|
||||
|
||||
|
||||
def test_a_deployment_whose_provider_has_no_decisions_support_is_refused_naming_every_supported_provider(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
handle: Final = _register(scenario, _answer_body(_PERPLEXITY))
|
||||
model: Final = scenario.model(model=_UNSUPPORTED_PROVIDER_MODEL, api_base=handle.api_base(), api_key=_API_KEY)
|
||||
response: Final = _decide(gateway, model)
|
||||
assert response.status_code == 400, response.text
|
||||
for provider in _PROVIDERS:
|
||||
assert provider.name in response.text, response.text
|
||||
assert _upstream_calls(gateway, handle) == []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1,27 +1,36 @@
|
|||
model_list: []
|
||||
general_settings:
|
||||
master_key: os.environ/LITELLM_MASTER_KEY
|
||||
database_url: os.environ/DATABASE_URL
|
||||
store_model_in_db: true
|
||||
disable_spend_logs: false
|
||||
proxy_batch_write_at: 1
|
||||
proxy_batch_polling_interval: 1
|
||||
litellm_settings:
|
||||
enable_redis_auth_cache: true
|
||||
cache: true
|
||||
cache_params:
|
||||
type: redis
|
||||
host: os.environ/REDIS_HOST
|
||||
port: os.environ/REDIS_PORT
|
||||
router_settings:
|
||||
disable_cooldowns: true
|
||||
vector_store_registry:
|
||||
- vector_store_name: integration-config-store
|
||||
model_list:
|
||||
- model_name: perplexity/pplx-decider-v1-27b
|
||||
litellm_params:
|
||||
vector_store_id: vs_integration_config_store
|
||||
custom_llm_provider: openai
|
||||
api_base: os.environ/INTEGRATION_UPSTREAM_URL
|
||||
api_key: integration-provider-key
|
||||
vector_store_description: declared in tests/integration/proxy_config.yaml
|
||||
environment_variables:
|
||||
PERPLEXITYAI_API_KEY: synthetic-decisions-env-key
|
||||
model: perplexity/pplx-decider-v1-27b
|
||||
api_base: http://127.0.0.1:8191
|
||||
api_key: synthetic-perplexity-key
|
||||
- model_name: typesafe/jev-1.13.0
|
||||
litellm_params:
|
||||
model: typesafe/jev-1.13.0
|
||||
api_base: http://127.0.0.1:8191
|
||||
api_key: synthetic-typesafe-key
|
||||
- model_name: openrouter/typesafe/jev-1.13
|
||||
litellm_params:
|
||||
model: openrouter/typesafe/jev-1.13
|
||||
api_base: http://127.0.0.1:8191
|
||||
api_key: synthetic-openrouter-key
|
||||
- model_name: cloudflare/@cf/cloudflare/clef
|
||||
litellm_params:
|
||||
model: cloudflare/@cf/cloudflare/clef
|
||||
api_base: http://127.0.0.1:8191
|
||||
api_key: synthetic-cloudflare-key
|
||||
- model_name: strands_decider/strands-decider-2B-hobson-v19
|
||||
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
|
||||
api_base: http://127.0.0.1:8191
|
||||
api_key: synthetic-azure-ai-key
|
||||
model_info:
|
||||
base_model: azure_ai/Microsoft-Decision-1
|
||||
|
|
|
|||
0
tests/integration/translation/decisions/__init__.py
Normal file
0
tests/integration/translation/decisions/__init__.py
Normal file
79
tests/integration/translation/decisions/bases/azure_ai.py
Normal file
79
tests/integration/translation/decisions/bases/azure_ai.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
from typing import Final
|
||||
|
||||
from integration.translation.case import TranslationTestCase
|
||||
|
||||
"""Provider request and reply shape from the Microsoft Foundry Decision playground (POST /providers/microsoft/v1/systemone, model is the deployment name). Mock reply captured live from a Microsoft-Decision-1 deployment on 2026-10-09.
|
||||
"""
|
||||
MICROSOFT_DECISION_1_TEST_CASE: Final = TranslationTestCase(
|
||||
scenario="basic",
|
||||
litellm_endpoint="/v1/systemone",
|
||||
litellm_request={
|
||||
"model": "azure_ai/decision-1",
|
||||
"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"]},
|
||||
},
|
||||
"cache": {"no-cache": True},
|
||||
},
|
||||
expected_provider_endpoint="/providers/microsoft/v1/systemone",
|
||||
expected_provider_headers={"authorization": "Bearer synthetic-azure-ai-key", "content-type": "application/json"},
|
||||
expected_provider_request={
|
||||
"model": "decision-1",
|
||||
"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": "microsoft-decision-1",
|
||||
"answers": {
|
||||
"confidence": {
|
||||
"confidence": 0.6351489346076444,
|
||||
"legend": {"0": "unsure", "1": "sure"},
|
||||
"probabilities": {"0": 0.1824255326961778, "1": 0.8175744673038222},
|
||||
"score": 0.8175744673038222,
|
||||
"type": "score",
|
||||
},
|
||||
"defect": {"noul": 0.982013764002278, "type": "noul"},
|
||||
"severity": {
|
||||
"choice": "high",
|
||||
"confidence": 0.9866142978108987,
|
||||
"probabilities": {"high": 0.9933071489054494, "low": 0.006692851094550702},
|
||||
"type": "choice",
|
||||
},
|
||||
},
|
||||
"usage": {"input_tokens": 87, "output_tokens": 3},
|
||||
},
|
||||
expected_litellm_response={
|
||||
"model": "microsoft-decision-1",
|
||||
"answers": {
|
||||
"defect": {"type": "noul", "noul": 0.982013764002278},
|
||||
"severity": {
|
||||
"type": "choice",
|
||||
"choice": "high",
|
||||
"confidence": 0.9866142978108987,
|
||||
"probabilities": {"high": 0.9933071489054494, "low": 0.006692851094550702},
|
||||
},
|
||||
"confidence": {
|
||||
"type": "score",
|
||||
"score": 0.8175744673038222,
|
||||
"confidence": 0.6351489346076444,
|
||||
"legend": {"0": "unsure", "1": "sure"},
|
||||
"probabilities": {"0": 0.1824255326961778, "1": 0.8175744673038222},
|
||||
},
|
||||
},
|
||||
"usage": {"input_tokens": 87, "output_tokens": 3},
|
||||
},
|
||||
)
|
||||
181
tests/integration/translation/decisions/bases/cloudflare.py
Normal file
181
tests/integration/translation/decisions/bases/cloudflare.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
from typing import Final
|
||||
|
||||
from integration.translation.case import TranslationTestCase
|
||||
|
||||
"""Provider request and reply shape from https://developers.cloudflare.com/workers-ai (POST /ai/run/@cf/cloudflare/clef, reply wrapped in result). Mock reply captured live on 2026-10-06.
|
||||
"""
|
||||
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",
|
||||
"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"]},
|
||||
},
|
||||
"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": {
|
||||
"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},
|
||||
},
|
||||
)
|
||||
67
tests/integration/translation/decisions/bases/hosted_vllm.py
Normal file
67
tests/integration/translation/decisions/bases/hosted_vllm.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
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}},
|
||||
},
|
||||
)
|
||||
177
tests/integration/translation/decisions/bases/openrouter.py
Normal file
177
tests/integration/translation/decisions/bases/openrouter.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
from typing import Final
|
||||
|
||||
from integration.translation.case import TranslationTestCase
|
||||
|
||||
"""Provider request and reply shape from https://openrouter.ai/docs (POST /api/alpha/decisions). Mock reply captured live on 2026-10-06.
|
||||
"""
|
||||
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",
|
||||
"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"]},
|
||||
},
|
||||
"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": {
|
||||
"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",
|
||||
},
|
||||
)
|
||||
174
tests/integration/translation/decisions/bases/perplexity.py
Normal file
174
tests/integration/translation/decisions/bases/perplexity.py
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
from typing import Final
|
||||
|
||||
from integration.translation.case import TranslationTestCase
|
||||
|
||||
"""Provider request and reply shape from https://docs.perplexity.ai (POST /v1/decisions). Mock reply captured live on 2026-10-06.
|
||||
"""
|
||||
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",
|
||||
"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"]},
|
||||
},
|
||||
"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": {
|
||||
"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},
|
||||
},
|
||||
)
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
from typing import Final
|
||||
|
||||
from integration.translation.case import TranslationTestCase
|
||||
|
||||
"""Provider request and reply shape from https://github.com/strands-agents/decider (POST /v1/systemone, no auth). Mock reply captured live on 2026-10-06.
|
||||
"""
|
||||
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",
|
||||
"state": "Help! My payouts have been failing for 3 days!",
|
||||
"questions": {"is_urgent": {"type": "noul", "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": {"is_urgent": {"type": "noul", "noul": 0.8277}},
|
||||
"usage": {"input_tokens": 86, "output_tokens": 1},
|
||||
"latency_ms": 140.03,
|
||||
},
|
||||
)
|
||||
171
tests/integration/translation/decisions/bases/typesafe.py
Normal file
171
tests/integration/translation/decisions/bases/typesafe.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
from typing import Final
|
||||
|
||||
from integration.translation.case import TranslationTestCase
|
||||
|
||||
"""Provider request and reply shape from https://docs.typesafe.ai (POST /v1/systemone). Mock reply captured live on 2026-10-06.
|
||||
"""
|
||||
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",
|
||||
"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"]},
|
||||
},
|
||||
"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": {
|
||||
"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},
|
||||
},
|
||||
)
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
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.azure_ai import MICROSOFT_DECISION_1_TEST_CASE
|
||||
from integration.translation.runner import assert_translation
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", [MICROSOFT_DECISION_1_TEST_CASE], ids=lambda case: case.id)
|
||||
def test_decisions_basic_azure_ai(case: TranslationTestCase, gateway: Gateway, provider: SharedProvider) -> None:
|
||||
assert_translation(case, gateway, provider)
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
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_SYSTEMONE_TEST_CASE, CLEF_TEST_CASE
|
||||
from integration.translation.runner import assert_translation
|
||||
|
||||
|
||||
@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,11 @@
|
|||
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_TEST_CASE
|
||||
from integration.translation.runner import assert_translation
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", [QWEN3_0_6B_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)
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
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_SYSTEMONE_TEST_CASE,
|
||||
TYPESAFE_JEV_1_13_TEST_CASE,
|
||||
)
|
||||
from integration.translation.runner import assert_translation
|
||||
|
||||
|
||||
@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)
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
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_SYSTEMONE_TEST_CASE,
|
||||
PPLX_DECIDER_V1_27B_TEST_CASE,
|
||||
)
|
||||
from integration.translation.runner import assert_translation
|
||||
|
||||
|
||||
@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)
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
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_SYSTEMONE_TEST_CASE,
|
||||
STRANDS_DECIDER_2B_HOBSON_V19_TEST_CASE,
|
||||
)
|
||||
from integration.translation.runner import assert_translation
|
||||
|
||||
|
||||
@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)
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
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_SYSTEMONE_TEST_CASE, JEV_1_13_0_TEST_CASE
|
||||
from integration.translation.runner import assert_translation
|
||||
|
||||
|
||||
@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)
|
||||
|
|
@ -45,6 +45,7 @@ import litellm.router as litellm_router_module # noqa: E402 # same import-time
|
|||
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.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,
|
||||
|
|
@ -157,6 +158,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,16 +2,25 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
from collections.abc import Mapping
|
||||
from concurrent.futures import wait
|
||||
from types import MappingProxyType
|
||||
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.constants import AWS_SIGNING_MAX_THREADS
|
||||
from litellm.cost_calculator import get_response_cost_from_hidden_params
|
||||
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,
|
||||
|
|
@ -20,6 +29,8 @@ from litellm.types.decisions import (
|
|||
OpenAIDecisionResponse,
|
||||
ScoreAnswer,
|
||||
)
|
||||
from litellm.types.utils import LlmProviders
|
||||
from litellm.utils import ProviderConfigManager
|
||||
|
||||
_QUESTIONS: Final[Mapping[str, object]] = MappingProxyType(
|
||||
{
|
||||
|
|
@ -138,7 +149,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,
|
||||
|
|
@ -200,7 +211,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={
|
||||
|
|
@ -231,7 +242,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?"}},
|
||||
|
|
@ -251,7 +262,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,
|
||||
|
|
@ -270,10 +281,7 @@ def test_decisions_cost_uses_litellm_token_pricing() -> None:
|
|||
answers={},
|
||||
usage=DecisionsUsage(input_tokens=_INPUT_TOKENS, output_tokens=_OUTPUT_TOKENS),
|
||||
)
|
||||
response._hidden_params = {
|
||||
"model": "perplexity/pplx-decider-v1-27b",
|
||||
"custom_llm_provider": "perplexity",
|
||||
}
|
||||
response.set_hidden_params({"model": "perplexity/pplx-decider-v1-27b", "custom_llm_provider": "perplexity"})
|
||||
|
||||
cost: Final = litellm.completion_cost(completion_response=response)
|
||||
perplexity_cost: Final = litellm.model_cost["perplexity/pplx-decider-v1-27b"]
|
||||
|
|
@ -325,6 +333,15 @@ def test_custom_token_pricing_bills_cached_decisions_input_tokens_once(
|
|||
)
|
||||
|
||||
|
||||
def test_decisions_response_hidden_params_getter_preserves_mutable_identity() -> None:
|
||||
response: Final = DecisionsResponse(model="decider", answers={}, usage=None)
|
||||
|
||||
assert response.hidden_params is response._hidden_params
|
||||
|
||||
response.hidden_params["mutation"] = "visible"
|
||||
assert response._hidden_params["mutation"] == "visible"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decisions_cost_is_in_standard_logging_object(respx_mock: respx.MockRouter) -> None:
|
||||
respx_mock.post("https://api.perplexity.ai/v1/decisions").respond(json=_RESPONSE)
|
||||
|
|
@ -333,7 +350,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?"}},
|
||||
|
|
@ -357,8 +374,8 @@ 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="Supported providers"):
|
||||
await litellm.adecisions(
|
||||
with pytest.raises(litellm.BadRequestError, match="LLM Provider NOT provided"):
|
||||
await litellm.asystemone(
|
||||
model="unknown/jev-1.13",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
|
|
@ -369,23 +386,85 @@ async def test_unknown_provider_is_rejected_before_http(respx_mock: respx.MockRo
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_custom_provider_is_rejected_before_http(respx_mock: respx.MockRouter) -> None:
|
||||
with pytest.raises(litellm.BadRequestError, match="Supported providers"):
|
||||
await litellm.adecisions(
|
||||
model="perplexity/pplx-decider-v1-27b",
|
||||
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.asystemone(
|
||||
model="anthropic/claude-sonnet-4-5",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
api_key="caller-key",
|
||||
custom_llm_provider="",
|
||||
)
|
||||
|
||||
assert len(respx_mock.calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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.asystemone(
|
||||
model="perplexity/pplx-decider-v1-27b",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
api_key="caller-key",
|
||||
custom_llm_provider="",
|
||||
)
|
||||
|
||||
assert route.called
|
||||
assert json.loads(respx_mock.calls[0].request.content)["model"] == "pplx-decider-v1-27b"
|
||||
assert response._hidden_params["custom_llm_provider"] == "perplexity"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upstream_reply_without_answers_is_a_server_error(respx_mock: respx.MockRouter) -> None:
|
||||
respx_mock.post("https://api.perplexity.ai/v1/decisions").respond(
|
||||
json={"model": "pplx-decider-v1-27b", "usage": {"input_tokens": 10, "output_tokens": 0}}
|
||||
)
|
||||
|
||||
with pytest.raises(litellm.InternalServerError, match="unexpected response"):
|
||||
await litellm.asystemone(
|
||||
model="perplexity/pplx-decider-v1-27b",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
api_key="caller-key",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_router_sends_the_openrouter_deployment_key_when_the_provider_is_already_resolved(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
) -> None:
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
router: Final = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "jev",
|
||||
"litellm_params": {
|
||||
"model": "openrouter/typesafe/jev-1.13",
|
||||
"api_key": "deployment-key",
|
||||
"api_base": "https://egress.example/openrouter",
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
upstream: Final = respx_mock.post("https://egress.example/openrouter/alpha/decisions").respond(json=_RESPONSE)
|
||||
|
||||
await router.asystemone(
|
||||
model="jev",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
)
|
||||
|
||||
assert upstream.called
|
||||
assert respx_mock.calls[0].request.headers["authorization"] == "Bearer deployment-key"
|
||||
assert json.loads(respx_mock.calls[0].request.content)["model"] == "typesafe/jev-1.13"
|
||||
|
||||
|
||||
@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"}},
|
||||
|
|
@ -402,7 +481,21 @@ 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?"}},
|
||||
api_key="caller-key",
|
||||
)
|
||||
|
||||
|
||||
def test_unreachable_upstream_maps_to_a_connection_error(respx_mock: respx.MockRouter) -> None:
|
||||
respx_mock.post("https://api.perplexity.ai/v1/decisions").mock(
|
||||
side_effect=httpx.ConnectError("Cannot connect to host api.perplexity.ai:443")
|
||||
)
|
||||
|
||||
with pytest.raises(litellm.APIConnectionError, match="PerplexityException - Cannot connect to host"):
|
||||
litellm.systemone(
|
||||
model="perplexity/pplx-decider-v1-27b",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
|
|
@ -418,7 +511,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?"}},
|
||||
|
|
@ -448,7 +541,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?"}},
|
||||
|
|
@ -478,7 +571,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?"}},
|
||||
|
|
@ -500,7 +593,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?"}},
|
||||
|
|
@ -519,7 +612,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?"}},
|
||||
|
|
@ -540,7 +633,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?"}},
|
||||
|
|
@ -565,7 +658,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"]}},
|
||||
|
|
@ -583,7 +676,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"]}},
|
||||
|
|
@ -598,6 +691,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,
|
||||
|
|
@ -607,7 +727,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"]}},
|
||||
|
|
@ -639,7 +759,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"]}},
|
||||
|
|
@ -650,6 +770,329 @@ 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"},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
_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
|
||||
@pytest.mark.parametrize(
|
||||
("api_base", "url"),
|
||||
|
|
@ -669,7 +1112,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={
|
||||
|
|
@ -701,7 +1144,7 @@ async def test_openai_decisions_translate_systemone_to_the_openai_wire_contract_
|
|||
],
|
||||
}
|
||||
assert response.answers == {"is_defect": NoulAnswer(type="noul", noul=0.9)}
|
||||
assert response._hidden_params["custom_llm_provider"] == "openai"
|
||||
assert response.hidden_params["custom_llm_provider"] == "openai"
|
||||
luna_cost: Final = litellm.model_cost["gpt-6-luna"]
|
||||
expected_cost: Final = (
|
||||
(_INPUT_TOKENS - _CACHED_TOKENS - _CACHE_WRITE_TOKENS) * float(luna_cost["input_cost_per_token"])
|
||||
|
|
@ -754,7 +1197,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?"}},
|
||||
|
|
@ -817,17 +1260,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,
|
||||
|
|
@ -839,3 +1273,257 @@ async def test_requests_a_provider_cannot_serve_are_rejected_before_http(
|
|||
await litellm.adecisions(model=model, api_key="caller-key", **request_kwargs)
|
||||
|
||||
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",
|
||||
(
|
||||
"https://res.services.ai.azure.com",
|
||||
"https://res.services.ai.azure.com/",
|
||||
"https://res.services.ai.azure.com/models",
|
||||
"https://res.services.ai.azure.com/openai/v1",
|
||||
"https://res.services.ai.azure.com/api/projects/proj",
|
||||
"https://res.services.ai.azure.com/api/projects/proj/openai/v1",
|
||||
"https://res.services.ai.azure.com/api/projects/proj/models",
|
||||
"https://res.services.ai.azure.com/models?api-version=2024-05-01-preview",
|
||||
),
|
||||
)
|
||||
async def test_azure_ai_decision_posts_deployment_to_foundry_systemone_route(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
api_base: str,
|
||||
) -> None:
|
||||
monkeypatch.delenv("AZURE_AI_API_BASE", raising=False)
|
||||
monkeypatch.delenv("AZURE_AI_API_KEY", raising=False)
|
||||
route: Final = respx_mock.post("https://res.services.ai.azure.com/providers/microsoft/v1/systemone").respond(
|
||||
json={**_RESPONSE, "model": "microsoft-decision-1"}
|
||||
)
|
||||
|
||||
response: Final = await litellm.asystemone(
|
||||
model="azure_ai/decision-1",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
api_base=api_base,
|
||||
api_key="foundry-key",
|
||||
)
|
||||
|
||||
assert route.called
|
||||
request: Final = respx_mock.calls[0].request
|
||||
assert request.headers["authorization"] == "Bearer foundry-key"
|
||||
assert "api-key" not in request.headers
|
||||
assert json.loads(request.content) == {
|
||||
"model": "decision-1",
|
||||
"state": "review",
|
||||
"questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
}
|
||||
assert response.answers == {"is_defect": NoulAnswer(type="noul", noul=0.9)}
|
||||
assert response._hidden_params["model"] == "azure_ai/decision-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_azure_ai_decision_reads_foundry_env_and_keeps_gateway_path_prefix(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
) -> None:
|
||||
monkeypatch.setenv("AZURE_AI_API_BASE", "https://gateway.example.com/foundry/models")
|
||||
monkeypatch.setenv("AZURE_AI_API_KEY", "env-foundry-key")
|
||||
route: Final = respx_mock.post("https://gateway.example.com/foundry/providers/microsoft/v1/systemone").respond(
|
||||
json=_RESPONSE
|
||||
)
|
||||
|
||||
await litellm.asystemone(
|
||||
model="azure_ai/decision-1",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
)
|
||||
|
||||
assert route.called
|
||||
assert respx_mock.calls[0].request.headers["authorization"] == "Bearer env-foundry-key"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_azure_ai_decision_requires_api_base_before_http(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
) -> None:
|
||||
monkeypatch.delenv("AZURE_AI_API_BASE", raising=False)
|
||||
monkeypatch.setenv("AZURE_AI_API_KEY", "foundry-key")
|
||||
|
||||
with pytest.raises(litellm.BadRequestError, match="Missing AZURE_AI_API_BASE"):
|
||||
await litellm.asystemone(
|
||||
model="azure_ai/decision-1",
|
||||
state="review",
|
||||
questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
|
||||
)
|
||||
|
||||
assert len(respx_mock.calls) == 0
|
||||
|
|
|
|||
|
|
@ -1195,6 +1195,34 @@ def test_branchless_provider_transport_error_maps_to_api_connection_error():
|
|||
)
|
||||
|
||||
|
||||
def test_openrouter_transport_error_maps_to_api_connection_error():
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
|
||||
original_exception = BaseLLMException(status_code=500, message="[Errno 111] Connection refused")
|
||||
original_exception.status_code_is_synthesized = True
|
||||
|
||||
with pytest.raises(litellm.APIConnectionError):
|
||||
exception_type(
|
||||
model="typesafe/jev-1.13",
|
||||
original_exception=original_exception,
|
||||
custom_llm_provider="openrouter",
|
||||
)
|
||||
|
||||
|
||||
def test_openrouter_upstream_500_still_maps_to_api_error():
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
|
||||
original_exception = BaseLLMException(status_code=500, message="upstream exploded")
|
||||
|
||||
with pytest.raises(litellm.APIError) as excinfo:
|
||||
exception_type(
|
||||
model="typesafe/jev-1.13",
|
||||
original_exception=original_exception,
|
||||
custom_llm_provider="openrouter",
|
||||
)
|
||||
assert excinfo.value.status_code == 500
|
||||
|
||||
|
||||
def test_branchless_provider_upstream_500_still_maps_to_internal_server_error():
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
|
||||
|
|
|
|||
|
|
@ -647,3 +647,78 @@ async def test_ahealth_check_probes_strands_through_decisions_without_mode(
|
|||
assert "error" not in result, result
|
||||
assert upstream.called
|
||||
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?"}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from pydantic import TypeAdapter
|
|||
|
||||
from litellm.llms.base_llm.decisions.transformation import ir_to_systemone_response, systemone_request_to_ir
|
||||
from litellm.llms.openai.decisions.transformation import (
|
||||
OPENAI_DECISIONS_ENDPOINT,
|
||||
OpenAIDecisionsConfig,
|
||||
ir_to_openai_request,
|
||||
ir_to_openai_response,
|
||||
openai_request_to_ir,
|
||||
|
|
@ -101,7 +101,7 @@ def test_an_openai_request_reaches_openai_unchanged() -> None:
|
|||
def test_an_openai_response_reaches_the_caller_unchanged() -> None:
|
||||
ir: Final = _openai_ir(_OPENAI_REQUEST)
|
||||
|
||||
parsed: Final = OPENAI_DECISIONS_ENDPOINT.parse_response(_OPENAI_RESPONSE, ir)
|
||||
parsed: Final = OpenAIDecisionsConfig().parse_response(_OPENAI_RESPONSE, ir)
|
||||
|
||||
assert ir_to_openai_response(parsed, ir, "requested").model_dump(mode="json") == _OPENAI_RESPONSE
|
||||
|
||||
|
|
@ -110,7 +110,7 @@ def test_answers_openai_did_not_return_are_refusals() -> None:
|
|||
ir: Final = _openai_ir(_OPENAI_REQUEST)
|
||||
payload: Final = {**_OPENAI_RESPONSE, "answers": [_PREDICATE_ANSWER]}
|
||||
|
||||
response: Final = ir_to_openai_response(OPENAI_DECISIONS_ENDPOINT.parse_response(payload, ir), ir, "requested")
|
||||
response: Final = ir_to_openai_response(OpenAIDecisionsConfig().parse_response(payload, ir), ir, "requested")
|
||||
|
||||
assert [answer.type for answer in response.answers] == ["predicate", "refusal", "refusal", "refusal"]
|
||||
assert [answer.name for answer in response.answers] == ["damaged", None, "severity", "fraud"]
|
||||
|
|
@ -230,7 +230,7 @@ def test_an_openai_response_becomes_systemone_answers_with_the_callers_score_lab
|
|||
],
|
||||
}
|
||||
|
||||
response: Final = ir_to_systemone_response(OPENAI_DECISIONS_ENDPOINT.parse_response(payload, ir), ir)
|
||||
response: Final = ir_to_systemone_response(OpenAIDecisionsConfig().parse_response(payload, ir), ir)
|
||||
|
||||
assert response.model_dump(mode="json") == {
|
||||
"model": "gpt-6-luna",
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
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")
|
||||
|
|
@ -4,7 +4,7 @@ Unit tests for auth_utils functions related to rate limiting and customer ID ext
|
|||
|
||||
import base64
|
||||
import logging
|
||||
from typing import Optional
|
||||
from typing import Final, Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -16,8 +16,6 @@ from litellm.proxy.auth.auth_utils import (
|
|||
abbreviate_api_key,
|
||||
check_complete_credentials,
|
||||
custom_auth_common_checks_warning,
|
||||
log_once_if_budget_reservation_disabled,
|
||||
warn_once_if_custom_auth_skips_common_checks,
|
||||
get_end_user_id_from_request_body,
|
||||
get_key_mcp_rpm_limit,
|
||||
get_key_model_rpm_limit,
|
||||
|
|
@ -29,6 +27,8 @@ from litellm.proxy.auth.auth_utils import (
|
|||
get_project_model_tpm_limit,
|
||||
get_request_route_template,
|
||||
is_request_body_safe,
|
||||
log_once_if_budget_reservation_disabled,
|
||||
warn_once_if_custom_auth_skips_common_checks,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -2727,6 +2727,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"])
|
||||
|
|
|
|||
|
|
@ -12,17 +12,19 @@ import respx
|
|||
from fastapi import FastAPI
|
||||
from fastapi.routing import APIRoute
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import TypeAdapter
|
||||
from starlette.routing import Match
|
||||
|
||||
import litellm
|
||||
from litellm.proxy._lazy_features import LAZY_FEATURES, LazyFeature, attach_lazy_features
|
||||
from litellm.proxy.decisions_endpoints.endpoints import decisions, systemone
|
||||
from litellm.proxy.decisions_endpoints.endpoints import decisions, router, systemone
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import SafeRouteAdder
|
||||
from litellm.proxy.proxy_server import (
|
||||
app,
|
||||
cleanup_router_config_variables,
|
||||
initialize,
|
||||
)
|
||||
from litellm.types.decisions import DecisionsRequestBody, OpenAIDecisionRequestBody
|
||||
|
||||
_INPUT_TOKENS: Final[int] = 367
|
||||
_OUTPUT_TOKENS: Final[int] = 3
|
||||
|
|
@ -530,6 +532,68 @@ def test_openai_format_decisions_reach_an_openai_deployment_unchanged_including_
|
|||
assert float(response.headers["x-litellm-response-cost"]) == pytest.approx(expected_cost)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("safety_identifier", (7, ["end-user-1"]), ids=("numeric", "list"))
|
||||
@pytest.mark.parametrize("deployment_drops_params", (False, True), ids=("strict", "drop_params"))
|
||||
def test_a_non_string_safety_identifier_is_refused_unless_the_deployment_drops_params(
|
||||
client: TestClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
safety_identifier: object,
|
||||
deployment_drops_params: bool,
|
||||
) -> 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)
|
||||
monkeypatch.setattr(
|
||||
litellm.proxy.proxy_server,
|
||||
"llm_router",
|
||||
litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "decider",
|
||||
"litellm_params": {
|
||||
"model": "openai/gpt-6-luna",
|
||||
"api_key": "k",
|
||||
"drop_params": deployment_drops_params,
|
||||
},
|
||||
}
|
||||
]
|
||||
),
|
||||
)
|
||||
upstream: Final = respx_mock.post("https://api.openai.com/v1/decisions").respond(
|
||||
json={
|
||||
"model": "gpt-6-luna",
|
||||
"answers": _OPENAI_FORMAT_ANSWERS[:1],
|
||||
"usage": {
|
||||
"input_tokens": _INPUT_TOKENS,
|
||||
"output_tokens": _OUTPUT_TOKENS,
|
||||
"total_tokens": _INPUT_TOKENS + _OUTPUT_TOKENS,
|
||||
},
|
||||
}
|
||||
)
|
||||
request_body: Final = {
|
||||
"model": "decider",
|
||||
"input": "The package arrived with a broken screen.",
|
||||
"questions": _OPENAI_FORMAT_REQUEST["questions"][:1],
|
||||
"safety_identifier": safety_identifier,
|
||||
}
|
||||
|
||||
response: Final = client.post("/v1/decisions", json=request_body)
|
||||
|
||||
if not deployment_drops_params:
|
||||
assert response.status_code == 400, response.text
|
||||
assert "safety_identifier" in response.json()["error"]["message"]
|
||||
assert not upstream.called
|
||||
return
|
||||
assert response.status_code == 200, response.text
|
||||
assert json.loads(upstream.calls[0].request.content) == {
|
||||
"model": "gpt-6-luna",
|
||||
"input": "The package arrived with a broken screen.",
|
||||
"questions": _OPENAI_FORMAT_REQUEST["questions"][:1],
|
||||
}
|
||||
|
||||
|
||||
def _decisions_feature() -> LazyFeature:
|
||||
return next(feature for feature in LAZY_FEATURES if feature.name == "decisions")
|
||||
|
||||
|
|
@ -581,3 +645,63 @@ def test_with_lazy_routes_disabled_a_config_pass_through_at_v1_decisions_still_w
|
|||
assert client.post("/v1/decisions", json={"model": "gpt-6-luna"}).json() == {"served_by": "pass-through"}
|
||||
assert _serving_endpoint(bare, "/v1/decisions") is pass_through
|
||||
assert _serving_endpoint(bare, "/decisions") is decisions
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def decisions_openapi() -> dict[str, object]:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return app.openapi()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "body_adapter", "response_schema", "required"),
|
||||
(
|
||||
("/v1/systemone", TypeAdapter(DecisionsRequestBody), "DecisionsResponse", {"model", "state", "questions"}),
|
||||
("/systemone", TypeAdapter(DecisionsRequestBody), "DecisionsResponse", {"model", "state", "questions"}),
|
||||
(
|
||||
"/v1/decisions",
|
||||
TypeAdapter(OpenAIDecisionRequestBody),
|
||||
"OpenAIDecisionResponse",
|
||||
{"model", "input", "questions"},
|
||||
),
|
||||
(
|
||||
"/decisions",
|
||||
TypeAdapter(OpenAIDecisionRequestBody),
|
||||
"OpenAIDecisionResponse",
|
||||
{"model", "input", "questions"},
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_swagger_documents_the_request_format_and_response_of_each_decisions_route(
|
||||
decisions_openapi: dict[str, object],
|
||||
path: str,
|
||||
body_adapter: TypeAdapter[DecisionsRequestBody] | TypeAdapter[OpenAIDecisionRequestBody],
|
||||
response_schema: str,
|
||||
required: set[str],
|
||||
) -> None:
|
||||
operation = decisions_openapi["paths"][path]["post"]
|
||||
media = operation["requestBody"]["content"]["application/json"]
|
||||
|
||||
assert "Decisions" in operation["summary"] or "System One" in operation["summary"]
|
||||
assert "docs.litellm.ai/docs/decisions" in operation["description"]
|
||||
assert required <= set(media["schema"]["required"])
|
||||
assert required <= set(media["schema"]["properties"])
|
||||
assert "$ref" not in json.dumps(media["schema"])
|
||||
assert media["schema"]["properties"]["model"]["description"]
|
||||
|
||||
example = {key: value for key, value in media["example"].items() if key != "model"}
|
||||
assert len(body_adapter.validate_python(example).questions) == 3
|
||||
|
||||
response_ref = operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"]
|
||||
assert response_ref == f"#/components/schemas/{response_schema}"
|
||||
assert "answers" in decisions_openapi["components"]["schemas"][response_schema]["properties"]
|
||||
|
||||
|
||||
def test_the_two_decisions_routes_document_different_request_formats(decisions_openapi: dict[str, object]) -> None:
|
||||
def properties(path: str) -> set[str]:
|
||||
operation = decisions_openapi["paths"][path]["post"]
|
||||
return set(operation["requestBody"]["content"]["application/json"]["schema"]["properties"])
|
||||
|
||||
assert "state" in properties("/v1/systemone") and "state" not in properties("/v1/decisions")
|
||||
assert "input" in properties("/v1/decisions") and "input" not in properties("/v1/systemone")
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from datetime import datetime
|
||||
from typing import Final
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
|
|
@ -12,7 +13,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=""))
|
||||
|
||||
|
|
@ -137,14 +138,17 @@ def test_success_handler_dispatches_to_typesafe_handler():
|
|||
assert normalized["kwargs"]["model"] == "typesafe/jev-1.13.0"
|
||||
|
||||
|
||||
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",
|
||||
|
|
@ -156,10 +160,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
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import pytest
|
|||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.public_endpoints import router
|
||||
|
|
@ -16,6 +17,7 @@ from litellm.router_strategy.complexity_router.fuse_presets import get_fuse_pres
|
|||
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
|
||||
ModelGroupInfoProxy,
|
||||
)
|
||||
from litellm.types.proxy.public_endpoints.public_endpoints import ProviderCreateInfo
|
||||
from litellm.types.utils import LlmProviders
|
||||
|
||||
|
||||
|
|
@ -377,6 +379,53 @@ def test_chatgpt_provider_fields():
|
|||
assert chatgpt["credential_fields"] == []
|
||||
|
||||
|
||||
def _decisions_provider_entry(provider: str) -> ProviderCreateInfo:
|
||||
app_instance: Final = FastAPI()
|
||||
app_instance.include_router(router)
|
||||
test_client: Final = TestClient(app_instance)
|
||||
|
||||
response: Final = test_client.get("/public/providers/fields")
|
||||
assert response.status_code == 200
|
||||
providers: Final = TypeAdapter(list[ProviderCreateInfo]).validate_python(response.json())
|
||||
entry: Final = next((p for p in providers if p.provider == provider), None)
|
||||
assert entry is not None, f"{provider} provider entry not found"
|
||||
return entry
|
||||
|
||||
|
||||
def test_typesafe_provider_fields():
|
||||
typesafe: Final = _decisions_provider_entry("TypeSafe")
|
||||
|
||||
assert typesafe.provider_display_name == "TypeSafe"
|
||||
assert typesafe.litellm_provider == LlmProviders.TYPESAFE.value
|
||||
assert typesafe.default_model_placeholder is not None
|
||||
assert typesafe.default_model_placeholder.startswith("typesafe/")
|
||||
|
||||
fields_by_key: Final = {f.key: f for f in typesafe.credential_fields}
|
||||
|
||||
assert fields_by_key["api_key"].required is True
|
||||
assert fields_by_key["api_key"].field_type == "password"
|
||||
|
||||
assert fields_by_key["api_base"].required is False
|
||||
assert fields_by_key["api_base"].field_type == "text"
|
||||
|
||||
|
||||
def test_strands_decider_provider_fields():
|
||||
strands: Final = _decisions_provider_entry("StrandsDecider")
|
||||
|
||||
assert strands.provider_display_name == "Strands Decider"
|
||||
assert strands.litellm_provider == LlmProviders.STRANDS_DECIDER.value
|
||||
assert strands.default_model_placeholder is not None
|
||||
assert strands.default_model_placeholder.startswith("strands_decider/")
|
||||
|
||||
fields_by_key: Final = {f.key: f for f in strands.credential_fields}
|
||||
|
||||
assert fields_by_key["api_base"].required is True
|
||||
assert fields_by_key["api_base"].field_type == "text"
|
||||
|
||||
assert fields_by_key["api_key"].required is False
|
||||
assert fields_by_key["api_key"].field_type == "password"
|
||||
|
||||
|
||||
ADD_MODEL_UNLISTED_PROVIDERS: Final = frozenset(
|
||||
{
|
||||
"a2a",
|
||||
|
|
@ -411,13 +460,11 @@ ADD_MODEL_UNLISTED_PROVIDERS: Final = frozenset(
|
|||
"sagemaker_nova",
|
||||
"scaleway",
|
||||
"stability",
|
||||
"strands_decider",
|
||||
"synthetic",
|
||||
"tencent",
|
||||
"tensormesh",
|
||||
"text-completion-inception",
|
||||
"transcribe",
|
||||
"typesafe",
|
||||
"valkey",
|
||||
"xiaomi_mimo",
|
||||
"zai",
|
||||
|
|
|
|||
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)
|
||||
|
|
@ -2,6 +2,7 @@ import importlib.util
|
|||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Final
|
||||
|
|
@ -62,8 +63,19 @@ def _snapshot(cost_map: dict[str, object], backup: str | None = None, schema: st
|
|||
BASE: Final = _snapshot(BASE_MAP)
|
||||
|
||||
|
||||
def _failures(head: object, changed_files: tuple[str, ...] = MAP_FILES, bot: bool = True) -> tuple[str, ...]:
|
||||
return guard.guard_failures(BASE, head, changed_files, bot)
|
||||
def _failures(
|
||||
head: object,
|
||||
changed_files: tuple[str, ...] = MAP_FILES,
|
||||
bot: bool = True,
|
||||
evaluation_output_price_sources: Mapping[str, str] = guard.EVALUATION_OUTPUT_PRICE_SOURCES,
|
||||
) -> tuple[str, ...]:
|
||||
return guard.guard_failures(
|
||||
BASE,
|
||||
head,
|
||||
changed_files,
|
||||
bot,
|
||||
evaluation_output_price_sources=evaluation_output_price_sources,
|
||||
)
|
||||
|
||||
|
||||
def test_in_sync_files_pass_for_humans_and_bots() -> None:
|
||||
|
|
@ -71,6 +83,52 @@ def test_in_sync_files_pass_for_humans_and_bots() -> None:
|
|||
assert _failures(BASE, bot=True) == ()
|
||||
|
||||
|
||||
EVALUATION_MODEL: Final = "azure_ai/Microsoft-Decision-1"
|
||||
EVALUATION_OUTPUT_PRICE_FAILURE: Final = (
|
||||
f"{EVALUATION_MODEL}: mode evaluation rows bill input only; output_cost_per_token must be 0 unless the vendor "
|
||||
"prices output tokens for this model (add the source to EVALUATION_OUTPUT_PRICE_SOURCES)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bot", (True, False))
|
||||
def test_evaluation_rows_with_output_price_fail_for_humans_and_bots(bot: bool) -> None:
|
||||
head: Final = _snapshot(
|
||||
{**BASE_MAP, EVALUATION_MODEL: _entry(mode="evaluation", output_cost_per_token=4.2e-08)}
|
||||
)
|
||||
assert _failures(head, bot=bot) == (EVALUATION_OUTPUT_PRICE_FAILURE,)
|
||||
|
||||
|
||||
def test_evaluation_rows_with_zero_or_missing_output_price_pass() -> None:
|
||||
zero_output: Final = _snapshot(
|
||||
{**BASE_MAP, EVALUATION_MODEL: _entry(mode="evaluation", output_cost_per_token=0.0)}
|
||||
)
|
||||
missing_output_entry: Final = {
|
||||
key: value
|
||||
for key, value in _entry(mode="evaluation").items()
|
||||
if key != "output_cost_per_token"
|
||||
}
|
||||
missing_output: Final = _snapshot({**BASE_MAP, EVALUATION_MODEL: missing_output_entry})
|
||||
|
||||
assert _failures(zero_output) == ()
|
||||
assert _failures(missing_output) == ()
|
||||
|
||||
|
||||
def test_chat_rows_with_output_price_pass() -> None:
|
||||
head: Final = _snapshot(
|
||||
{**BASE_MAP, "openrouter/chat-with-output": _entry(output_cost_per_token=4.2e-08)}
|
||||
)
|
||||
assert _failures(head) == ()
|
||||
|
||||
|
||||
def test_evaluation_output_price_source_allowlist_passes() -> None:
|
||||
head: Final = _snapshot(
|
||||
{**BASE_MAP, EVALUATION_MODEL: _entry(mode="evaluation", output_cost_per_token=4.2e-08)}
|
||||
)
|
||||
source: Final = 'https://vendor.example/pricing "Output tokens are charged per token."'
|
||||
|
||||
assert _failures(head, evaluation_output_price_sources={EVALUATION_MODEL: source}) == ()
|
||||
|
||||
|
||||
def test_bot_may_add_and_reprice_models() -> None:
|
||||
head = _snapshot({**BASE_MAP, "openrouter/a": _entry(9e-06, supports_vision=True), "openrouter/c": _entry()})
|
||||
assert _failures(head) == ()
|
||||
|
|
|
|||
4
ui/litellm-dashboard/public/assets/logos/strands.svg
Normal file
4
ui/litellm-dashboard/public/assets/logos/strands.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="290" height="463" viewBox="0 0 290 463" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M97.2902 52.7884C85.0674 49.1667 72.2234 56.1389 68.6017 68.3616C64.9801 80.5843 71.9524 93.4283 84.1749 97.0501L235.117 139.775C245.223 142.769 246.357 156.628 236.874 161.226L32.546 260.291C-14.9439 283.316 -9.16107 352.74 41.4835 367.591L189.551 411.009L190.125 411.169C202.183 414.376 214.665 407.396 218.196 395.355C221.784 383.122 214.774 370.296 202.541 366.709L54.4738 323.291C44.3447 320.321 43.1879 306.436 52.6857 301.831L257.014 202.766C304.432 179.776 298.758 110.483 248.233 95.512L97.2902 52.7884Z" fill="#989898"/>
|
||||
<path d="M259.147 0.981812C271.389 -2.57498 284.197 4.46571 287.754 16.7074C291.311 28.9492 284.27 41.757 272.028 45.3138L71.1727 103.671C40.7142 112.521 37.1976 154.262 65.7459 168.083L241.343 253.093C307.872 285.302 299.794 382.546 228.862 403.336L30.4041 461.502C18.1707 465.088 5.34708 458.078 1.76153 445.844C-1.8239 433.611 5.18637 420.787 17.4197 417.202L215.878 359.035C246.277 350.125 249.739 308.449 221.226 294.645L45.6297 209.635C-20.9834 177.386 -12.7772 79.9893 58.2928 59.3402L259.147 0.981812Z" fill="#00FF77"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -209,6 +209,26 @@ describe("provider_info_helpers", () => {
|
|||
const { logo } = getProviderLogoAndName("openai");
|
||||
expect(logo).toContain("openai_small");
|
||||
});
|
||||
|
||||
it("should resolve the typesafe slug and TypeSafe enum key to the TypeSafe name and bundled logo", () => {
|
||||
const fromSlug = getProviderLogoAndName("typesafe");
|
||||
expect(fromSlug.displayName).toBe(Providers.TypeSafe);
|
||||
expect(fromSlug.logo).toContain("typesafe");
|
||||
|
||||
const fromEnumKey = getProviderLogoAndName("TypeSafe");
|
||||
expect(fromEnumKey.displayName).toBe(Providers.TypeSafe);
|
||||
expect(fromEnumKey.logo).toBe(fromSlug.logo);
|
||||
});
|
||||
|
||||
it("should resolve the strands_decider slug and StrandsDecider enum key to the Strands Decider name and bundled logo", () => {
|
||||
const fromSlug = getProviderLogoAndName("strands_decider");
|
||||
expect(fromSlug.displayName).toBe("Strands Decider");
|
||||
expect(fromSlug.logo).toContain("strands");
|
||||
|
||||
const fromEnumKey = getProviderLogoAndName("StrandsDecider");
|
||||
expect(fromEnumKey.displayName).toBe(Providers.StrandsDecider);
|
||||
expect(fromEnumKey.logo).toBe(fromSlug.logo);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPlaceholder", () => {
|
||||
|
|
@ -309,6 +329,11 @@ describe("provider_info_helpers", () => {
|
|||
expect(getPlaceholder("CHATGPT")).toBe("chatgpt/gpt-5.4");
|
||||
});
|
||||
|
||||
it("should return decision model placeholders for the TypeSafe and StrandsDecider dropdown keys", () => {
|
||||
expect(getPlaceholder("TypeSafe")).toBe("typesafe/jev-latest");
|
||||
expect(getPlaceholder("StrandsDecider")).toBe("strands_decider/strands-decider-2B-hobson-v19");
|
||||
});
|
||||
|
||||
it("should return default gpt-3.5-turbo placeholder for unknown provider", () => {
|
||||
expect(getPlaceholder("UnknownProvider" as any)).toBe("gpt-3.5-turbo");
|
||||
});
|
||||
|
|
@ -412,6 +437,30 @@ describe("provider_info_helpers", () => {
|
|||
expect(getProviderModels("Sail" as Providers, modelMap)).toEqual(["sail/openai/gpt-oss-120b"]);
|
||||
});
|
||||
|
||||
it("should list only typesafe decision models for the 'TypeSafe' provider key, not the OpenRouter-hosted one", () => {
|
||||
const modelMap = {
|
||||
"typesafe/jev-latest": { litellm_provider: "typesafe", mode: "evaluation" },
|
||||
"typesafe/jev-preview": { litellm_provider: "typesafe", mode: "evaluation" },
|
||||
"openrouter/typesafe/jev-1.13": { litellm_provider: "openrouter", mode: "evaluation" },
|
||||
"strands_decider/strands-decider-2B-hobson-v19": { litellm_provider: "strands_decider", mode: "evaluation" },
|
||||
};
|
||||
expect(getProviderModels("TypeSafe" as Providers, modelMap)).toEqual([
|
||||
"typesafe/jev-latest",
|
||||
"typesafe/jev-preview",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should list only strands_decider models for the 'StrandsDecider' provider key", () => {
|
||||
const modelMap = {
|
||||
"strands_decider/strands-decider-2B-hobson-v19": { litellm_provider: "strands_decider", mode: "evaluation" },
|
||||
"typesafe/jev-latest": { litellm_provider: "typesafe", mode: "evaluation" },
|
||||
"openrouter/typesafe/jev-1.13": { litellm_provider: "openrouter", mode: "evaluation" },
|
||||
};
|
||||
expect(getProviderModels("StrandsDecider" as Providers, modelMap)).toEqual([
|
||||
"strands_decider/strands-decider-2B-hobson-v19",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should include bedrock converse but exclude standalone bedrock_mantle when called with 'Bedrock' provider key", () => {
|
||||
const modelMap = {
|
||||
"bedrock-base": { litellm_provider: "bedrock" },
|
||||
|
|
|
|||
|
|
@ -55,8 +55,10 @@ import sapLogo from "../../public/assets/logos/sap.png";
|
|||
import scxAiLogo from "../../public/assets/logos/scx_ai.svg";
|
||||
import snowflakeLogo from "../../public/assets/logos/snowflake.svg";
|
||||
import sonioxLogo from "../../public/assets/logos/soniox.svg";
|
||||
import strandsLogo from "../../public/assets/logos/strands.svg";
|
||||
import togetheraiLogo from "../../public/assets/logos/togetherai.svg";
|
||||
import topazLogo from "../../public/assets/logos/topaz.svg";
|
||||
import typesafeLogo from "../../public/assets/logos/typesafe.png";
|
||||
import v0Logo from "../../public/assets/logos/v0.svg";
|
||||
import vercelLogo from "../../public/assets/logos/vercel.svg";
|
||||
import vllmLogo from "../../public/assets/logos/vllm.png";
|
||||
|
|
@ -166,10 +168,12 @@ export enum Providers {
|
|||
SCX_AI = "SCX.ai",
|
||||
Snowflake = "Snowflake",
|
||||
Soniox = "Soniox",
|
||||
StrandsDecider = "Strands Decider",
|
||||
TEXT_COMPLETION_CODESTRAL = "Text-Completion-Codestral",
|
||||
TogetherAI = "TogetherAI",
|
||||
TOPAZ = "Topaz",
|
||||
Triton = "Triton",
|
||||
TypeSafe = "TypeSafe",
|
||||
V0 = "V0",
|
||||
VERCEL_AI_GATEWAY = "Vercel Ai Gateway",
|
||||
Vertex_AI = "Vertex AI (Anthropic, Gemini, etc.)",
|
||||
|
|
@ -285,10 +289,12 @@ export const provider_map: Record<string, string> = {
|
|||
SCX_AI: "scx-ai",
|
||||
Snowflake: "snowflake",
|
||||
Soniox: "soniox",
|
||||
StrandsDecider: "strands_decider",
|
||||
TEXT_COMPLETION_CODESTRAL: "text-completion-codestral",
|
||||
TogetherAI: "together_ai",
|
||||
TOPAZ: "topaz",
|
||||
Triton: "triton",
|
||||
TypeSafe: "typesafe",
|
||||
V0: "v0",
|
||||
VERCEL_AI_GATEWAY: "vercel_ai_gateway",
|
||||
Vertex_AI: "vertex_ai",
|
||||
|
|
@ -384,10 +390,12 @@ export const providerLogoMap: Partial<Record<Providers, string>> = {
|
|||
[Providers.SCX_AI]: scxAiLogo.src,
|
||||
[Providers.Snowflake]: snowflakeLogo.src,
|
||||
[Providers.Soniox]: sonioxLogo.src,
|
||||
[Providers.StrandsDecider]: strandsLogo.src,
|
||||
[Providers.TEXT_COMPLETION_CODESTRAL]: mistralLogo.src,
|
||||
[Providers.TogetherAI]: togetheraiLogo.src,
|
||||
[Providers.TOPAZ]: topazLogo.src,
|
||||
[Providers.Triton]: nvidiaTritonLogo.src,
|
||||
[Providers.TypeSafe]: typesafeLogo.src,
|
||||
[Providers.V0]: v0Logo.src,
|
||||
[Providers.VERCEL_AI_GATEWAY]: vercelLogo.src,
|
||||
[Providers.Vertex_AI]: googleLogo.src,
|
||||
|
|
@ -453,6 +461,8 @@ const providerPlaceholderMap: Partial<Record<Providers, string>> = {
|
|||
[Providers.Sail]: "sail/openai/gpt-oss-120b",
|
||||
[Providers.SCX_AI]: "scx-ai/GLM-5.2",
|
||||
[Providers.Snowflake]: "snowflake/mistral-7b",
|
||||
[Providers.StrandsDecider]: "strands_decider/strands-decider-2B-hobson-v19",
|
||||
[Providers.TypeSafe]: "typesafe/jev-latest",
|
||||
[Providers.Vertex_AI]: "gemini-pro",
|
||||
[Providers.VolcEngine]: "volcengine/<any-model-on-volcengine>",
|
||||
[Providers.Voyage]: "voyage/",
|
||||
|
|
|
|||
1965
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
1965
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue