mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-06 07:48:06 +00:00
fix(logging): preserve deferred config warnings after failed loads
Merge current main and retain both sets of request-processing tests Emit shadow warnings after runtime validation so retries do not lose them
This commit is contained in:
commit
c3ed358cec
134 changed files with 8182 additions and 628 deletions
2
.github/workflows/test-integration.yml
vendored
2
.github/workflows/test-integration.yml
vendored
|
|
@ -51,7 +51,7 @@ jobs:
|
|||
suite: ${{ fromJSON((inputs.suite || 'all') == 'all' && '["providers","extensions","accounting","management","management-replica","database","database-replica","mcp","security","cost","sdk","browser"]' || format('["{0}"]', inputs.suite)) }}
|
||||
env:
|
||||
UV_PYTHON: "3.12"
|
||||
INTEGRATION_WORKERS: ${{ inputs.workers || fromJSON('{"providers":"32","extensions":"32","accounting":"16","management":"16","management-replica":"16","cost":"16","mcp":"8","security":"8","database":"4","database-replica":"4"}')[matrix.suite] || '' }}
|
||||
INTEGRATION_WORKERS: ${{ inputs.workers || fromJSON('{"providers":"32","extensions":"48","accounting":"16","management":"16","management-replica":"16","cost":"16","mcp":"8","security":"8","database":"4","database-replica":"4"}')[matrix.suite] || '' }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16@sha256:e17e86066e5ef83e0952a9347f5c792b7ece00972e2aa787a6986f471b3dd3d5
|
||||
|
|
|
|||
|
|
@ -416,6 +416,7 @@ Set `LITELLM_PROXY_API_BASE` and `LITELLM_PROXY_API_KEY` and every model call th
|
|||
| [Eden AI (`edenai`)](https://docs.litellm.ai/docs/providers/edenai) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | |
|
||||
| [ElevenLabs (`elevenlabs`)](https://docs.litellm.ai/docs/providers/elevenlabs) | ✅ | ✅ | ✅ | | | ✅ | ✅ | | | |
|
||||
| [Empower (`empower`)](https://docs.litellm.ai/docs/providers/empower) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
| [eu/jev Decisions API (`eu_jev`)](https://docs.litellm.ai/docs/pass_through/eu_jev) | | | | | | | | | | |
|
||||
| [Fal AI (`fal_ai`)](https://docs.litellm.ai/docs/providers/fal_ai) | ✅ | ✅ | ✅ | | ✅ | | | | | |
|
||||
| [Featherless AI (`featherless_ai`)](https://docs.litellm.ai/docs/providers/featherless_ai) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
| [Fireworks AI (`fireworks_ai`)](https://docs.litellm.ai/docs/providers/fireworks_ai) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "litellm-enterprise"
|
||||
version = "0.1.80"
|
||||
version = "0.1.81"
|
||||
description = "Package for LiteLLM Enterprise features"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
|
@ -26,7 +26,7 @@ required-version = ">=0.10.9"
|
|||
module-root = ""
|
||||
|
||||
[tool.commitizen]
|
||||
version = "0.1.80"
|
||||
version = "0.1.81"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
"../pyproject.toml:litellm-enterprise==",
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ GATEWAY_EXACT_PATHS: frozenset[str] = frozenset(
|
|||
"/test",
|
||||
"/debug/memory/summary",
|
||||
"/api/event_logging/batch",
|
||||
"/eu_jev/v1/systemone",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "litellm-proxy-extras"
|
||||
version = "0.4.111"
|
||||
version = "0.4.112"
|
||||
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
|
@ -30,7 +30,7 @@ required-version = ">=0.10.9"
|
|||
module-root = ""
|
||||
|
||||
[tool.commitizen]
|
||||
version = "0.4.111"
|
||||
version = "0.4.112"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
"../pyproject.toml:litellm-proxy-extras==",
|
||||
|
|
|
|||
|
|
@ -1784,6 +1784,9 @@ if TYPE_CHECKING:
|
|||
from .llms.bespoke.decisions.transformation import (
|
||||
BespokeDecisionsConfig as BespokeDecisionsConfig,
|
||||
)
|
||||
from .llms.eu_jev.decisions.transformation import (
|
||||
EuJevDecisionsConfig as EuJevDecisionsConfig,
|
||||
)
|
||||
from .llms.openai.decisions.transformation import (
|
||||
OpenAIDecisionsConfig as OpenAIDecisionsConfig,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@ LLM_CONFIG_NAMES: Final = (
|
|||
"OllamaDecisionsConfig",
|
||||
"LayaDecisionsConfig",
|
||||
"BespokeDecisionsConfig",
|
||||
"EuJevDecisionsConfig",
|
||||
"OpenAIDecisionsConfig",
|
||||
"AzureDecisionsConfig",
|
||||
"AzureAIDecisionsConfig",
|
||||
|
|
@ -753,6 +754,7 @@ _LLM_CONFIGS_IMPORT_MAP: Final = {
|
|||
"OllamaDecisionsConfig": (".llms.ollama.decisions.transformation", "OllamaDecisionsConfig"),
|
||||
"LayaDecisionsConfig": (".llms.laya.decisions.transformation", "LayaDecisionsConfig"),
|
||||
"BespokeDecisionsConfig": (".llms.bespoke.decisions.transformation", "BespokeDecisionsConfig"),
|
||||
"EuJevDecisionsConfig": (".llms.eu_jev.decisions.transformation", "EuJevDecisionsConfig"),
|
||||
"OpenAIDecisionsConfig": (".llms.openai.decisions.transformation", "OpenAIDecisionsConfig"),
|
||||
"AzureDecisionsConfig": (".llms.azure.decisions.transformation", "AzureDecisionsConfig"),
|
||||
"AzureAIDecisionsConfig": (".llms.azure_ai.decisions.transformation", "AzureAIDecisionsConfig"),
|
||||
|
|
|
|||
|
|
@ -30,9 +30,11 @@ from litellm.litellm_core_utils.env_utils import get_env_int
|
|||
from litellm.litellm_core_utils.safe_json_dumps import UNSERIALIZABLE_OBJECT, safe_dumps, safe_json_structure
|
||||
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
|
||||
from litellm.litellm_core_utils.secret_redaction import (
|
||||
REDACTED,
|
||||
python_redact_string,
|
||||
python_redact_structured_value,
|
||||
redact_internal_details,
|
||||
redact_structured_value,
|
||||
)
|
||||
from litellm.litellm_core_utils.secret_redaction import (
|
||||
redact_string as redact_secret_string,
|
||||
|
|
@ -224,6 +226,32 @@ def redact_internal_details_from_client_message(value: str) -> str:
|
|||
return redact_internal_details(value)
|
||||
|
||||
|
||||
_CLIENT_FIELDS: Final = TypeAdapter(dict[str, object])
|
||||
_CLIENT_ITEMS: Final = TypeAdapter(list[object])
|
||||
|
||||
|
||||
def _redacted_client_field(key: str | None, value: object) -> object:
|
||||
if isinstance(value, str):
|
||||
return redact_internal_details(redact_structured_value(key, value))
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
child_key: _redacted_client_field(child_key, child)
|
||||
for child_key, child in _CLIENT_FIELDS.validate_python(value).items()
|
||||
}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_redacted_client_field(key, child) for child in _CLIENT_ITEMS.validate_python(value)]
|
||||
if not isinstance(value, (int, float)):
|
||||
return value
|
||||
text: Final = str(value)
|
||||
return value if redact_structured_value(key, text) == text else REDACTED
|
||||
|
||||
|
||||
def redact_internal_details_from_client_fields(fields: Mapping[str, object]) -> object:
|
||||
if not _ENABLE_SECRET_REDACTION:
|
||||
return fields
|
||||
return _redacted_client_field(None, dict(fields))
|
||||
|
||||
|
||||
def _substituted_color_message(record: logging.LogRecord) -> str | None:
|
||||
"""Render a record's ``color_message`` against its args, or None if absent.
|
||||
|
||||
|
|
|
|||
|
|
@ -1887,7 +1887,10 @@ def completion_cost(
|
|||
video_resolution=video_resolution,
|
||||
)
|
||||
elif call_type in _SPEECH_CALL_TYPES:
|
||||
prompt_characters = litellm.utils.count_characters(text=prompt)
|
||||
prompt_characters = _speech_billed_characters(
|
||||
completion_response if isinstance(completion_response, HttpxBinaryResponseContent) else None,
|
||||
prompt,
|
||||
)
|
||||
elif call_type in _TRANSCRIPTION_CALL_TYPES:
|
||||
# Check _hidden_params first (duration stored there to
|
||||
# avoid polluting the response body), then fall back to
|
||||
|
|
@ -2366,6 +2369,11 @@ def response_cost_calculator(
|
|||
raise e
|
||||
|
||||
|
||||
def _speech_billed_characters(speech_response: HttpxBinaryResponseContent | None, prompt: str) -> int:
|
||||
reported: Final = speech_response.hidden_params.get("billed_characters") if speech_response is not None else None
|
||||
return reported if isinstance(reported, int) else litellm.utils.count_characters(text=prompt)
|
||||
|
||||
|
||||
def _video_input_image_cost(
|
||||
model: str,
|
||||
custom_llm_provider: str | None,
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ from litellm.main import (
|
|||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.images.main import ImageEditOptionalRequestParams
|
||||
from litellm.types.llms.openai import ImageGenerationRequestQuality
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.router import CustomPricingLiteLLMParams, GenericLiteLLMParams
|
||||
from litellm.types.utils import (
|
||||
LITELLM_IMAGE_VARIATION_PROVIDERS,
|
||||
LlmProviders,
|
||||
|
|
@ -1010,6 +1010,7 @@ def image_edit(
|
|||
optional_params=dict(image_edit_request_params),
|
||||
litellm_params={
|
||||
**image_edit_request_params,
|
||||
**litellm_params.model_dump(include=set(CustomPricingLiteLLMParams.model_fields), exclude_none=True),
|
||||
"litellm_call_id": litellm_call_id,
|
||||
"model_info": model_info,
|
||||
"vertex_location": litellm_params.vertex_location,
|
||||
|
|
|
|||
|
|
@ -75,6 +75,14 @@ _IMAGE_TOKEN_RATE_KEYS: Final[tuple[str, ...]] = (
|
|||
"output_cost_per_image_token",
|
||||
)
|
||||
|
||||
|
||||
def _deployment_prices_images_without_an_output_price(pricing: ModelInfo | None) -> bool:
|
||||
return pricing is not None and (
|
||||
"input_cost_per_pixel" in pricing
|
||||
or ("input_cost_per_image" in pricing and "output_cost_per_image" not in pricing)
|
||||
)
|
||||
|
||||
|
||||
# Pre-resolved service-tier cost-key suffixes (e.g. "_priority"). Used per
|
||||
# request in the cost-calc path, so the f-strings are built once here instead
|
||||
# of being rebuilt for every model_info key on every call. Longest-first so a
|
||||
|
|
@ -127,7 +135,7 @@ def _get_token_detail_value(details: object, key: str) -> int | None:
|
|||
return value if isinstance(value, int) else None
|
||||
|
||||
|
||||
_IMAGE_SIZE_PATTERN: Final = re.compile(r"\d+(?:x|-x-)\d+")
|
||||
_IMAGE_SIZE_PATTERN: Final = re.compile(r"\d+(?:x|-x-|\*)\d+")
|
||||
|
||||
|
||||
def _requested_image_param(optional_params: Mapping[str, object] | None, key: str) -> str | None:
|
||||
|
|
@ -137,7 +145,7 @@ def _requested_image_param(optional_params: Mapping[str, object] | None, key: st
|
|||
|
||||
def _requested_image_size(optional_params: Mapping[str, object] | None) -> str | None:
|
||||
value: Final = _requested_image_param(optional_params, "size")
|
||||
return value if value is not None and _IMAGE_SIZE_PATTERN.fullmatch(value) else None
|
||||
return value.replace("*", "x") if value is not None and _IMAGE_SIZE_PATTERN.fullmatch(value) else None
|
||||
|
||||
|
||||
def get_web_search_requests(server_tool_use: object) -> int | None:
|
||||
|
|
@ -2090,7 +2098,7 @@ class _ModelPricedImageCostCalculator(Protocol):
|
|||
|
||||
|
||||
def _model_priced_image_cost_calculator(
|
||||
custom_llm_provider: str | None, model: str
|
||||
custom_llm_provider: str | None, model: str, pricing: ModelInfo | None
|
||||
) -> _ModelPricedImageCostCalculator | None:
|
||||
match custom_llm_provider:
|
||||
case litellm.LlmProviders.RECRAFT.value:
|
||||
|
|
@ -2116,6 +2124,14 @@ def _model_priced_image_cost_calculator(
|
|||
from litellm.llms.tencent.image_generation.transformation import is_tokenhub_image_model
|
||||
|
||||
return tencent_image_cost_calculator if is_tokenhub_image_model(model) else None
|
||||
case litellm.LlmProviders.DASHSCOPE.value:
|
||||
if _deployment_prices_images_without_an_output_price(pricing):
|
||||
return None
|
||||
from litellm.llms.dashscope.image_generation.cost_calculator import (
|
||||
cost_calculator as dashscope_image_cost_calculator,
|
||||
)
|
||||
|
||||
return dashscope_image_cost_calculator
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
|
@ -2178,7 +2194,7 @@ class CostCalculatorUtils:
|
|||
)
|
||||
resolved_n: Final = n if n is not None else (len(completion_response.data) if completion_response.data else 0)
|
||||
pricing: Final = deployment_pricing(model_info)
|
||||
model_priced_calculator: Final = _model_priced_image_cost_calculator(custom_llm_provider, model)
|
||||
model_priced_calculator: Final = _model_priced_image_cost_calculator(custom_llm_provider, model, pricing)
|
||||
|
||||
if model_priced_calculator is not None:
|
||||
return model_priced_calculator(model=model, image_response=completion_response, model_info=pricing)
|
||||
|
|
|
|||
27
litellm/litellm_core_utils/stream_usage_chunks.py
Normal file
27
litellm/litellm_core_utils/stream_usage_chunks.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from collections.abc import Iterable
|
||||
from typing import Final
|
||||
|
||||
from litellm.types.utils import ModelResponseStream, StreamingChoices
|
||||
|
||||
|
||||
def is_empty_streaming_choice(choice: StreamingChoices) -> bool:
|
||||
if choice.finish_reason is not None:
|
||||
return False
|
||||
if getattr(choice, "logprobs", None) is not None:
|
||||
return False
|
||||
delta: Final = choice.delta if hasattr(choice, "delta") else None
|
||||
if delta is None:
|
||||
return True
|
||||
return _all_values_none(delta.model_dump().values())
|
||||
|
||||
|
||||
def _all_values_none(values: Iterable[object]) -> bool:
|
||||
return all(value is None for value in values)
|
||||
|
||||
|
||||
def is_injected_stream_usage_artifact(chunk: object) -> bool:
|
||||
if not isinstance(chunk, ModelResponseStream):
|
||||
return False
|
||||
if chunk.provider_specific_fields is not None:
|
||||
return False
|
||||
return all(is_empty_streaming_choice(choice) for choice in chunk.choices or [])
|
||||
|
|
@ -1224,10 +1224,6 @@ def _validate_anthropic_content(content: Mapping[str, object]) -> type:
|
|||
if expected_cls is None:
|
||||
raise ValueError(f"Unknown Anthropic content type: '{content_type}'")
|
||||
|
||||
missing: Final = [k for k in getattr(expected_cls, "__required_keys__", set()) if k not in content]
|
||||
if missing:
|
||||
raise ValueError(f"Missing required fields in {content_type} block: {', '.join(missing)}")
|
||||
|
||||
return expected_cls
|
||||
|
||||
|
||||
|
|
@ -1552,12 +1548,13 @@ def _format_single_function_definition(function: Mapping[str, object]) -> tuple[
|
|||
|
||||
def _format_object_parameters(parameters, indent):
|
||||
properties: Final = parameters.get("properties")
|
||||
if not properties:
|
||||
if not isinstance(properties, Mapping) or not properties:
|
||||
return ""
|
||||
required_params: Final = parameters.get("required", [])
|
||||
required: Final = parameters.get("required")
|
||||
required_params: Final = tuple(required) if isinstance(required, list) else ()
|
||||
lines: Final = []
|
||||
for key, props in properties.items():
|
||||
description = props.get("description")
|
||||
description = props.get("description") if isinstance(props, Mapping) else None
|
||||
if description:
|
||||
lines.append(f"// {description}")
|
||||
question = "?"
|
||||
|
|
@ -1568,19 +1565,21 @@ def _format_object_parameters(parameters, indent):
|
|||
|
||||
|
||||
def _format_type(props, indent):
|
||||
if not isinstance(props, Mapping):
|
||||
return "any"
|
||||
type: Final = props.get("type")
|
||||
enum: Final = props.get("enum")
|
||||
if type == "string":
|
||||
if "enum" in props:
|
||||
return " | ".join([f'"{item}"' for item in props["enum"]])
|
||||
if isinstance(enum, list):
|
||||
return " | ".join([f'"{item}"' for item in enum])
|
||||
return "string"
|
||||
elif type == "array":
|
||||
# items is required, OpenAI throws an error if it's missing
|
||||
return f"{_format_type(props['items'], indent)}[]"
|
||||
return f"{_format_type(props.get('items'), indent)}[]"
|
||||
elif type == "object":
|
||||
return f"{{\n{_format_object_parameters(props, indent + 2)}\n}}"
|
||||
elif type in ["integer", "number"]:
|
||||
if "enum" in props:
|
||||
return " | ".join([f'"{item}"' for item in props["enum"]])
|
||||
if isinstance(enum, list):
|
||||
return " | ".join([f'"{item}"' for item in enum])
|
||||
return "number"
|
||||
elif type == "boolean":
|
||||
return "boolean"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import copy
|
|||
import json
|
||||
import re
|
||||
from collections.abc import Container, Mapping, MutableMapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from types import MappingProxyType
|
||||
from typing import Annotated, Any, ClassVar, Final, Literal, TypeVar
|
||||
|
|
@ -1774,6 +1775,48 @@ def anthropic_request_errors_on_thinking_binding_mismatch(request_body: object)
|
|||
return binding is not None and binding.get("prefix_mismatch_behavior") == "error"
|
||||
|
||||
|
||||
_CLAUDE_STRICT_TOOLS_REJECTION: Final = re.compile(
|
||||
r"Too many strict tools \(\d+\)"
|
||||
r"|Schemas contains too many (?:optional parameters|parameters with union types)"
|
||||
r"|[Cc]ompiled grammar (?:is too large|size)"
|
||||
r"|tools\.\d+\.custom(?:\.strict)?: "
|
||||
r"|Grammar compilation timed out"
|
||||
)
|
||||
_CLAUDE_TOOLS_ADAPTER: Final = TypeAdapter(tuple[Mapping[str, object], ...])
|
||||
|
||||
|
||||
def is_claude_strict_tools_rejection(status_code: int, error_text: str) -> bool:
|
||||
return status_code == 400 and _CLAUDE_STRICT_TOOLS_REJECTION.search(error_text) is not None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ToolsWithoutStrict:
|
||||
tools: tuple[Mapping[str, object], ...]
|
||||
dropped_count: int
|
||||
|
||||
|
||||
def tools_without_strict(tools: object) -> ToolsWithoutStrict | None:
|
||||
try:
|
||||
validated: Final = _CLAUDE_TOOLS_ADAPTER.validate_python(tools)
|
||||
except ValidationError:
|
||||
return None
|
||||
dropped_count: Final = sum(tool.get("strict") is True for tool in validated)
|
||||
if dropped_count == 0:
|
||||
return None
|
||||
return ToolsWithoutStrict(
|
||||
tools=tuple({key: value for key, value in tool.items() if key != "strict"} for tool in validated),
|
||||
dropped_count=dropped_count,
|
||||
)
|
||||
|
||||
|
||||
def warn_claude_strict_tools_dropped(dropped_count: int) -> None:
|
||||
litellm.verbose_logger.warning(
|
||||
"Claude rejected the request's strict schemas, so litellm removed `strict` from every tool (%d had "
|
||||
"strict true) and sent the request again because drop_params is set.",
|
||||
dropped_count,
|
||||
)
|
||||
|
||||
|
||||
def strip_thinking_blocks_from_anthropic_messages(messages: Sequence[object]) -> list[object]:
|
||||
"""
|
||||
Return a new message list with thinking / redacted_thinking content blocks removed
|
||||
|
|
|
|||
|
|
@ -160,6 +160,7 @@ async def prepare_context_managed_request(
|
|||
"""Apply client compaction history, then optional context_management polyfill."""
|
||||
from litellm.llms.anthropic.messages.context_management.editors.compact import (
|
||||
apply_client_compaction_block_history,
|
||||
slice_around_compaction_block,
|
||||
)
|
||||
|
||||
# Skip the client-history pre-processing when a ``compact_20260112``
|
||||
|
|
@ -175,7 +176,7 @@ async def prepare_context_managed_request(
|
|||
|
||||
if polyfill_will_run:
|
||||
history_result: PolyfillResult | None = None
|
||||
working_messages: _AnthropicMessages = messages
|
||||
working_messages: _AnthropicMessages = list(slice_around_compaction_block(messages)[0])
|
||||
working_system: _AnthropicSystem = system
|
||||
else:
|
||||
history_result = apply_client_compaction_block_history(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from litellm._logging import verbose_logger
|
|||
from litellm.litellm_core_utils.asyncify import asyncify
|
||||
from litellm.types.llms.anthropic import AppliedEdit
|
||||
|
||||
from ..convertible_blocks import drop_non_string_tool_result_texts
|
||||
from .constants import CLEAR_TOOL_USES_EDIT_TYPE, COMPACT_EDIT_TYPE
|
||||
from .editors import (
|
||||
CheckedEdit,
|
||||
|
|
@ -103,7 +104,7 @@ async def apply_context_management(
|
|||
|
||||
Every ``clear_tool_uses_20250919`` edit is validated before any editor runs, so a malformed one raises its 400
|
||||
before a ``compact_20260112`` summary call is paid for, or runs without its malformed fields under
|
||||
``drop_invalid_edits``.
|
||||
``drop_invalid_edits``, which also drops non-string ``tool_result`` text first, as the provider never receives it.
|
||||
|
||||
The dispatcher is async so async editors (``compact_20260112``) can
|
||||
``await`` the configured summarization model. Sync editors run in a
|
||||
|
|
@ -119,7 +120,7 @@ async def apply_context_management(
|
|||
raise first_error
|
||||
well_formed_edits: Final = tuple(check.edit for check in checked)
|
||||
|
||||
current_messages = messages
|
||||
current_messages = list(drop_non_string_tool_result_texts(messages, drop_invalid_edits))
|
||||
current_system = system
|
||||
aggregated_applied: Final[list[AppliedEdit]] = []
|
||||
aggregated_compaction_block = None
|
||||
|
|
|
|||
|
|
@ -39,6 +39,21 @@ if TYPE_CHECKING:
|
|||
T = TypeVar("T")
|
||||
# api.anthropic.com, measured 2026-10-19: text results of 1 to 46 bytes are never cleared or counted toward keep
|
||||
_TINY_RESULT_BYTES: Final = 46
|
||||
_TOKEN_COUNTER_BLOCK_TYPES: Final = frozenset(
|
||||
{
|
||||
"text",
|
||||
"image_url",
|
||||
"image",
|
||||
"document",
|
||||
"file",
|
||||
"tool_use",
|
||||
"tool_result",
|
||||
"thinking",
|
||||
"redacted_thinking",
|
||||
"tool_reference",
|
||||
"search_result",
|
||||
}
|
||||
)
|
||||
ToolNames = Literal["all"] | frozenset[str]
|
||||
|
||||
|
||||
|
|
@ -52,16 +67,19 @@ class _ClearAtLeast(BaseModel):
|
|||
class _ClearToolUsesFields(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
trigger: Mapping[str, object] | None = None
|
||||
keep: Mapping[str, object] | None = None
|
||||
clear_at_least: _ClearAtLeast | None = None
|
||||
exclude_tools: tuple[str, ...] | None = None
|
||||
clear_tool_inputs: bool | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
_BLOCKS: Final = TypeAdapter(tuple[Mapping[str, object], ...])
|
||||
# api.anthropic.com, measured 2026-10-19: its wording for the two shapes pydantic words differently
|
||||
# api.anthropic.com, measured 2026-10-19 and 2026-10-28: its wording for the shapes pydantic words differently
|
||||
_API_MESSAGES: Final = MappingProxyType(
|
||||
{
|
||||
"tuple_type": "Input should be a valid array",
|
||||
"dict_type": "Input should be an object",
|
||||
"model_type": "Input does not match the expected shape.",
|
||||
}
|
||||
)
|
||||
|
|
@ -178,10 +196,12 @@ def _cleared_result(block: Mapping[str, object]) -> dict[str, object]:
|
|||
|
||||
def _freed_tokens(model: str, result: Mapping[str, object], use: Mapping[str, object], clears_input: bool) -> int:
|
||||
"""Tokens that clearing this use frees: its result, and its input when ``clear_tool_inputs`` covers it."""
|
||||
before: Final = [result, use] if clears_input else [result]
|
||||
after: Final = [_cleared_result(result), {**use, "input": {}}] if clears_input else [_cleared_result(result)]
|
||||
before: Final[Sequence[Mapping[str, object]]] = (result, use) if clears_input else (result,)
|
||||
after: Final[Sequence[Mapping[str, object]]] = (
|
||||
(_cleared_result(result), {**use, "input": {}}) if clears_input else (_cleared_result(result),)
|
||||
)
|
||||
counts: Final = tuple(
|
||||
litellm.token_counter(model=model, messages=[{"role": "user", "content": blocks}]) for blocks in (before, after)
|
||||
_count_tokens(model, [{"role": "user", "content": blocks}], None) for blocks in (before, after)
|
||||
)
|
||||
return max(counts[0] - counts[1], 0)
|
||||
|
||||
|
|
@ -199,6 +219,35 @@ def _select_in_batches(candidates: Sequence[str], tokens: Mapping[str, int], cle
|
|||
return tuple(candidates[:batch_end])
|
||||
|
||||
|
||||
def _without_uncountable_blocks(block: Mapping[str, object]) -> Mapping[str, object]:
|
||||
nested: Final = _parse(_BLOCKS, block.get("content"))
|
||||
if nested is None:
|
||||
return block
|
||||
return {**block, "content": [inner for inner in nested if inner.get("type") in _TOKEN_COUNTER_BLOCK_TYPES]}
|
||||
|
||||
|
||||
def _countable_message(message: Mapping[str, object]) -> Mapping[str, object]:
|
||||
blocks: Final = _parse(_BLOCKS, message.get("content"))
|
||||
if blocks is None:
|
||||
return message
|
||||
return {
|
||||
**message,
|
||||
"content": [
|
||||
_without_uncountable_blocks(block) for block in blocks if block.get("type") in _TOKEN_COUNTER_BLOCK_TYPES
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _count_tokens(
|
||||
model: str, messages: Sequence[Mapping[str, object]], tools: Sequence[Mapping[str, object]] | None
|
||||
) -> int:
|
||||
return litellm.token_counter(
|
||||
model=model,
|
||||
messages=[_countable_message(message) for message in messages],
|
||||
tools=cast("list[ChatCompletionToolParam] | None", tools),
|
||||
)
|
||||
|
||||
|
||||
def _trigger_met(
|
||||
trigger: dict[str, object],
|
||||
model: str,
|
||||
|
|
@ -218,11 +267,7 @@ def _trigger_met(
|
|||
|
||||
if not isinstance(threshold, int):
|
||||
threshold = DEFAULT_INPUT_TOKENS_TRIGGER
|
||||
message_tokens: Final = litellm.token_counter(
|
||||
model=model,
|
||||
messages=messages,
|
||||
tools=cast("list[ChatCompletionToolParam] | None", tools),
|
||||
)
|
||||
message_tokens: Final = _count_tokens(model, messages, tools)
|
||||
if message_tokens > threshold:
|
||||
return True, message_tokens
|
||||
current_tokens: Final = message_tokens + count_system_tokens(model, system)
|
||||
|
|
@ -375,17 +420,9 @@ def apply_clear_tool_uses_20250919(
|
|||
[_message_without_inputs(message, ids_to_clear, input_tools) for message in edited] if input_tools else edited
|
||||
)
|
||||
|
||||
tokens_before: Final = (
|
||||
counted_tokens
|
||||
if counted_tokens is not None
|
||||
else litellm.token_counter(
|
||||
model=model, messages=kept, tools=cast("list[ChatCompletionToolParam] | None", tools)
|
||||
)
|
||||
)
|
||||
tokens_before: Final = counted_tokens if counted_tokens is not None else _count_tokens(model, kept, tools)
|
||||
kept_after, _ = history_after_client_compaction(final_messages, system)
|
||||
tokens_after: Final = litellm.token_counter(
|
||||
model=model, messages=kept_after, tools=cast("list[ChatCompletionToolParam] | None", tools)
|
||||
)
|
||||
tokens_after: Final = _count_tokens(model, kept_after, tools)
|
||||
cleared_input_tokens: Final = max(tokens_before - tokens_after, 0)
|
||||
|
||||
return final_messages, {
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ def _find_latest_compaction_index(
|
|||
return None, None
|
||||
|
||||
|
||||
def _slice_around_compaction_block(
|
||||
def slice_around_compaction_block(
|
||||
messages: Sequence[_MsgT],
|
||||
) -> tuple[Sequence[_MsgT | dict[str, object]], dict[str, object] | None]:
|
||||
"""Apply Anthropic's "drop everything before the compaction block" rule.
|
||||
|
|
@ -194,7 +194,7 @@ def history_after_client_compaction(
|
|||
system: str | Sequence[Mapping[str, object]] | None,
|
||||
) -> tuple[list[dict[str, object]], str | Sequence[Mapping[str, object]] | None]:
|
||||
"""Return the history the API keeps after the latest compaction block, without it, and the system with its summary."""
|
||||
sliced, block = _slice_around_compaction_block(messages)
|
||||
sliced, block = slice_around_compaction_block(messages)
|
||||
if block is None:
|
||||
return list(messages), system
|
||||
tail: Final = _strip_compaction_blocks(sliced)
|
||||
|
|
@ -615,7 +615,7 @@ def apply_client_compaction_block_history(
|
|||
tail is forwarded unchanged (with compaction blocks stripped) so recent
|
||||
turns the summary does not cover are preserved.
|
||||
"""
|
||||
effective_messages, prior_compaction_block = _slice_around_compaction_block(messages)
|
||||
effective_messages, prior_compaction_block = slice_around_compaction_block(messages)
|
||||
if prior_compaction_block is None:
|
||||
return None
|
||||
|
||||
|
|
@ -686,7 +686,7 @@ async def apply_compact_20260112(
|
|||
# opt-in gate below so that even when summarization is disabled we still
|
||||
# strip Anthropic-only ``compaction`` blocks from messages going to
|
||||
# non-Anthropic backends (which would reject them).
|
||||
effective_messages, prior_compaction_block = _slice_around_compaction_block(messages)
|
||||
effective_messages, prior_compaction_block = slice_around_compaction_block(messages)
|
||||
prior_summary_text: Final = prior_compaction_block.get("content") if prior_compaction_block else None
|
||||
augmented_system: str | list[dict[str, object]] | None = system
|
||||
if isinstance(prior_summary_text, str) and prior_summary_text:
|
||||
|
|
|
|||
|
|
@ -10,11 +10,15 @@ from typing import Final, TypeAlias
|
|||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.integrations.anthropic_cache_control_hook import (
|
||||
AnthropicCacheControlHook,
|
||||
supports_openai_prompt_cache_breakpoint,
|
||||
)
|
||||
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
|
||||
from litellm.litellm_core_utils.asyncify import (
|
||||
run_async_function, # pyright: ignore[reportUnknownVariableType] # untyped helper
|
||||
)
|
||||
from litellm.litellm_core_utils.core_helpers import as_str_mapping, drops_unsupported_params
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
cache_control_as_prompt_cache_breakpoint,
|
||||
prompt_cache_breakpoint_for_wire,
|
||||
|
|
@ -28,14 +32,19 @@ from litellm.types.llms.anthropic import (
|
|||
AnthropicMessagesRequest,
|
||||
AnthropicOutputConfig,
|
||||
AnthropicOutputSchema,
|
||||
ContextManagementResponse,
|
||||
)
|
||||
from litellm.types.llms.anthropic_messages.anthropic_response import (
|
||||
AnthropicMessagesResponse,
|
||||
)
|
||||
from litellm.types.llms.openai import ResponsesAPIResponse
|
||||
from litellm.types.utils import CountContextManagement
|
||||
from litellm.utils import ProviderConfigManager
|
||||
|
||||
from ..adapters.handler import request_safeguards_evaluator
|
||||
from ..adapters.handler import context_management_explicitly_dropped, request_safeguards_evaluator
|
||||
from ..context_management import AnthropicContextManagementError, PolyfillResult, apply_context_management
|
||||
from ..context_management.constants import COMPACT_EDIT_TYPE
|
||||
from ..context_management.dispatcher import normalize_spec
|
||||
from ..convertible_blocks import reject_malformed_tool_result_text
|
||||
from ..safeguards import with_safeguard_results
|
||||
from ..utils import litellm_logging_obj_from_kwargs, local_model_name
|
||||
|
|
@ -45,10 +54,111 @@ from .transformation import LiteLLMAnthropicToResponsesAPIAdapter
|
|||
AnthropicRequestMessages: TypeAlias = list[AllAnthropicMessageValues] | list[dict[str, object]]
|
||||
BlockRewrite: TypeAlias = Callable[[Mapping[str, object]], Mapping[str, object]]
|
||||
_BLOCK_LIST: Final = TypeAdapter(list[dict[str, object]])
|
||||
_SYSTEM_ITEMS: Final = TypeAdapter(list[object])
|
||||
_SPEC: Final = TypeAdapter(dict[str, object])
|
||||
_SplitEdits: TypeAlias = tuple[dict[str, object] | None, Sequence[Mapping[str, object]]]
|
||||
AnthropicContextManagementSpec: TypeAlias = dict[str, object] | list[dict[str, object]] | None
|
||||
|
||||
_ADAPTER: Final = LiteLLMAnthropicToResponsesAPIAdapter()
|
||||
|
||||
|
||||
def _split_context_management(
|
||||
spec: AnthropicContextManagementSpec,
|
||||
kwargs: Mapping[str, object],
|
||||
) -> _SplitEdits:
|
||||
"""Return (the edits the Responses API runs natively, the edits the gateway polyfill runs).
|
||||
|
||||
The Responses API compacts natively (``compact_20260112`` becomes its ``compaction``); any other
|
||||
edit, such as ``clear_tool_uses_20250919``, has no Responses API form and runs in the polyfill, as
|
||||
it does on the chat completions path. An OpenAI-style list spec is not Anthropic's shape and stays
|
||||
unsent, as it was before the polyfill ran here.
|
||||
"""
|
||||
if not isinstance(spec, dict):
|
||||
return None, ()
|
||||
if not spec or context_management_explicitly_dropped(kwargs.get("additional_drop_params")):
|
||||
return spec, ()
|
||||
edits: Final = normalize_spec(spec) or []
|
||||
native: Final = [edit for edit in edits if edit.get("type") == COMPACT_EDIT_TYPE]
|
||||
return ({"edits": native} if native else None), tuple(
|
||||
edit for edit in edits if edit.get("type") != COMPACT_EDIT_TYPE
|
||||
)
|
||||
|
||||
|
||||
def _system_blocks(system: object) -> str | Sequence[Mapping[str, object]] | None:
|
||||
if system is None or isinstance(system, str):
|
||||
return system
|
||||
try:
|
||||
items: Final = _SYSTEM_ITEMS.validate_python(system)
|
||||
except ValidationError:
|
||||
return None
|
||||
return tuple(block for block in map(as_str_mapping, items) if block is not None)
|
||||
|
||||
|
||||
async def _polyfilled(
|
||||
*,
|
||||
model: str,
|
||||
messages: AnthropicRequestMessages,
|
||||
tools: Sequence[AllAnthropicToolsValues | Mapping[str, object]] | None,
|
||||
system: object,
|
||||
edits: Sequence[Mapping[str, object]],
|
||||
kwargs: Mapping[str, object],
|
||||
) -> PolyfillResult | None:
|
||||
"""Run the edits the Responses API can't run natively; raise like the chat completions path does."""
|
||||
if not edits:
|
||||
return None
|
||||
reject_malformed_tool_result_text(messages, model, kwargs)
|
||||
drop_invalid_edits: Final = drops_unsupported_params(kwargs.get("drop_params"))
|
||||
blocks: Final = _system_blocks(system)
|
||||
try:
|
||||
return await apply_context_management(
|
||||
model=model,
|
||||
messages=_BLOCK_LIST.validate_python(messages),
|
||||
tools=_BLOCK_LIST.validate_python(tools) if tools else None,
|
||||
system=blocks if blocks is None or isinstance(blocks, str) else _BLOCK_LIST.validate_python(blocks),
|
||||
context_management_spec={"edits": [dict(edit) for edit in edits]},
|
||||
drop_invalid_edits=drop_invalid_edits,
|
||||
)
|
||||
except AnthropicContextManagementError:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_logger.exception("context_management polyfill failed on the Responses API path: %s", e)
|
||||
raise AnthropicContextManagementError(
|
||||
status_code=500, message=f"context_management polyfill failed: {e}"
|
||||
) from e
|
||||
|
||||
|
||||
async def count_view_messages(
|
||||
*,
|
||||
model: str,
|
||||
messages: Sequence[Mapping[str, object]],
|
||||
tools: Sequence[Mapping[str, object]] | None,
|
||||
system: object,
|
||||
context_management: CountContextManagement | None,
|
||||
) -> Sequence[Mapping[str, object]]:
|
||||
"""The messages /v1/messages sends the Responses API once the polyfill has run the edits it can't run natively."""
|
||||
if context_management is None or not isinstance(context_management.spec, dict):
|
||||
return messages
|
||||
_, edits = _split_context_management(_SPEC.validate_python(context_management.spec), {})
|
||||
result: Final = await _polyfilled(
|
||||
model=model,
|
||||
messages=_BLOCK_LIST.validate_python(messages),
|
||||
tools=tools,
|
||||
system=system,
|
||||
edits=edits,
|
||||
kwargs={"drop_params": context_management.drop_invalid_edits},
|
||||
)
|
||||
return messages if result is None else result.messages
|
||||
|
||||
|
||||
def _with_applied_edits(
|
||||
response: AnthropicMessagesResponse, polyfill_result: PolyfillResult | None
|
||||
) -> AnthropicMessagesResponse:
|
||||
applied_edits: Final = polyfill_result.applied_edits_for_response() if polyfill_result else None
|
||||
if not applied_edits:
|
||||
return response
|
||||
return {**response, "context_management": ContextManagementResponse(applied_edits=list(applied_edits))}
|
||||
|
||||
|
||||
def _record_bridged_response(responses_kwargs: Mapping[str, object], result: ResponsesAPIResponse) -> None:
|
||||
logging_obj: Final = litellm_logging_obj_from_kwargs(responses_kwargs)
|
||||
if logging_obj is not None:
|
||||
|
|
@ -281,7 +391,7 @@ class LiteLLMMessagesToResponsesAPIHandler:
|
|||
max_tokens: int,
|
||||
messages: AnthropicRequestMessages,
|
||||
model: str,
|
||||
context_management: dict[str, object] | None = None,
|
||||
context_management: AnthropicContextManagementSpec = None,
|
||||
metadata: dict[str, object] | None = None,
|
||||
output_config: AnthropicOutputConfig | None = None,
|
||||
stop_sequences: list[str] | None = None,
|
||||
|
|
@ -296,11 +406,15 @@ class LiteLLMMessagesToResponsesAPIHandler:
|
|||
output_format: AnthropicOutputSchema | None = None,
|
||||
**kwargs: object,
|
||||
) -> AnthropicMessagesResponse | AsyncIterator[bytes]:
|
||||
native_context_management, polyfilled_edits = _split_context_management(context_management, kwargs)
|
||||
polyfill_result: Final = await _polyfilled(
|
||||
model=model, messages=messages, tools=tools, system=system, edits=polyfilled_edits, kwargs=kwargs
|
||||
)
|
||||
responses_kwargs: Final = _build_responses_kwargs(
|
||||
max_tokens=max_tokens,
|
||||
messages=messages,
|
||||
messages=polyfill_result.messages if polyfill_result is not None else messages,
|
||||
model=model,
|
||||
context_management=context_management,
|
||||
context_management=native_context_management,
|
||||
metadata=metadata,
|
||||
output_config=output_config,
|
||||
stop_sequences=stop_sequences,
|
||||
|
|
@ -325,13 +439,16 @@ class LiteLLMMessagesToResponsesAPIHandler:
|
|||
model=local_model_name(model, kwargs.get("custom_llm_provider")),
|
||||
litellm_logging_obj=litellm_logging_obj_from_kwargs(responses_kwargs),
|
||||
safeguards_evaluator=safeguards_evaluator,
|
||||
applied_edits=polyfill_result.applied_edits_for_response() if polyfill_result else None,
|
||||
)
|
||||
return wrapper.async_anthropic_sse_wrapper()
|
||||
|
||||
if not isinstance(result, ResponsesAPIResponse):
|
||||
raise ValueError(f"Expected ResponsesAPIResponse, got {type(result)}")
|
||||
|
||||
served: Final = await with_safeguard_results(_ADAPTER.translate_response(result), safeguards_evaluator)
|
||||
served: Final = await with_safeguard_results(
|
||||
_with_applied_edits(_ADAPTER.translate_response(result), polyfill_result), safeguards_evaluator
|
||||
)
|
||||
_record_bridged_response(responses_kwargs, result)
|
||||
return served
|
||||
|
||||
|
|
@ -340,7 +457,7 @@ class LiteLLMMessagesToResponsesAPIHandler:
|
|||
max_tokens: int,
|
||||
messages: AnthropicRequestMessages,
|
||||
model: str,
|
||||
context_management: dict[str, object] | None = None,
|
||||
context_management: AnthropicContextManagementSpec = None,
|
||||
metadata: dict[str, object] | None = None,
|
||||
output_config: AnthropicOutputConfig | None = None,
|
||||
stop_sequences: list[str] | None = None,
|
||||
|
|
@ -382,11 +499,26 @@ class LiteLLMMessagesToResponsesAPIHandler:
|
|||
)
|
||||
|
||||
# Sync path
|
||||
native_context_management, polyfilled_edits = _split_context_management(context_management, kwargs)
|
||||
ran: Final[object] = ( # pyright: ignore[reportUnknownVariableType] # run_async_function is untyped
|
||||
run_async_function(
|
||||
_polyfilled,
|
||||
model=model,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
system=system,
|
||||
edits=polyfilled_edits,
|
||||
kwargs=kwargs,
|
||||
)
|
||||
if polyfilled_edits
|
||||
else None
|
||||
)
|
||||
polyfill_result: Final = ran if isinstance(ran, PolyfillResult) else None
|
||||
responses_kwargs: Final = _build_responses_kwargs(
|
||||
max_tokens=max_tokens,
|
||||
messages=messages,
|
||||
messages=polyfill_result.messages if polyfill_result is not None else messages,
|
||||
model=model,
|
||||
context_management=context_management,
|
||||
context_management=native_context_management,
|
||||
metadata=metadata,
|
||||
output_config=output_config,
|
||||
stop_sequences=stop_sequences,
|
||||
|
|
@ -409,10 +541,11 @@ class LiteLLMMessagesToResponsesAPIHandler:
|
|||
responses_stream=result,
|
||||
model=local_model_name(model, kwargs.get("custom_llm_provider")),
|
||||
litellm_logging_obj=litellm_logging_obj_from_kwargs(responses_kwargs),
|
||||
applied_edits=polyfill_result.applied_edits_for_response() if polyfill_result else None,
|
||||
)
|
||||
return wrapper.async_anthropic_sse_wrapper()
|
||||
|
||||
if not isinstance(result, ResponsesAPIResponse):
|
||||
raise ValueError(f"Expected ResponsesAPIResponse, got {type(result)}")
|
||||
|
||||
return _ADAPTER.translate_response(result)
|
||||
return _with_applied_edits(_ADAPTER.translate_response(result), polyfill_result)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
## Translates OpenAI call to Anthropic `/v1/messages` format
|
||||
import asyncio
|
||||
from collections import deque
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable, Iterator, Mapping
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable, Iterator, Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import anyio
|
||||
|
|
@ -27,6 +27,7 @@ from litellm.responses.streaming_iterator import (
|
|||
partial_responses_incomplete_event_on_disconnect,
|
||||
stream_error_status_and_message,
|
||||
)
|
||||
from litellm.types.llms.anthropic import AppliedEdit, ContextManagementResponse
|
||||
from litellm.types.llms.anthropic_messages.anthropic_response import AnthropicUsage
|
||||
from litellm.types.llms.base import LiteLLMBaseModel
|
||||
|
||||
|
|
@ -129,8 +130,10 @@ class AnthropicResponsesStreamWrapper:
|
|||
model: str,
|
||||
litellm_logging_obj: "LiteLLMLoggingObject | None" = None,
|
||||
safeguards_evaluator: "SafeguardsEvaluator | None" = None,
|
||||
applied_edits: Sequence[AppliedEdit] | None = None,
|
||||
) -> None:
|
||||
self.responses_stream = responses_stream
|
||||
self._applied_edits: Final = tuple(applied_edits or ())
|
||||
self.model = model
|
||||
self.litellm_logging_obj = litellm_logging_obj
|
||||
self.safeguards_evaluator: Final = safeguards_evaluator
|
||||
|
|
@ -445,6 +448,11 @@ class AnthropicResponsesStreamWrapper:
|
|||
"type": "message_delta",
|
||||
"delta": message_delta_payload,
|
||||
"usage": dict(anthropic_usage),
|
||||
**(
|
||||
{"context_management": ContextManagementResponse(applied_edits=list(self._applied_edits))}
|
||||
if self._applied_edits
|
||||
else {}
|
||||
),
|
||||
}
|
||||
)
|
||||
self._chunk_queue.append({"type": "message_stop"})
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Any, Final
|
|||
|
||||
import httpx
|
||||
|
||||
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
|
||||
from litellm.types.llms.anthropic_messages.anthropic_response import (
|
||||
AnthropicMessagesResponse,
|
||||
)
|
||||
|
|
@ -201,11 +202,23 @@ class BaseAnthropicMessagesConfig(ABC):
|
|||
anthropic_request_errors_on_thinking_binding_mismatch,
|
||||
is_anthropic_invalid_thinking_block_error,
|
||||
is_anthropic_thinking_binding_mismatch_error,
|
||||
is_claude_strict_tools_rejection,
|
||||
tools_without_strict,
|
||||
validated_object_mapping,
|
||||
)
|
||||
|
||||
if e.response.status_code != 400:
|
||||
return False
|
||||
error_text: Final[str] = e.response.text
|
||||
body: Final = validated_object_mapping(request_body)
|
||||
drops_rejected_strict_tools: Final = (
|
||||
drops_unsupported_params(litellm_params.get("drop_params"))
|
||||
and is_claude_strict_tools_rejection(e.response.status_code, error_text)
|
||||
and body is not None
|
||||
and tools_without_strict(body.get("tools")) is not None
|
||||
)
|
||||
if drops_rejected_strict_tools:
|
||||
return True
|
||||
caller_asked_for_this_error: Final = anthropic_request_errors_on_thinking_binding_mismatch(
|
||||
request_body
|
||||
) and is_anthropic_thinking_binding_mismatch_error(error_text)
|
||||
|
|
@ -219,11 +232,22 @@ class BaseAnthropicMessagesConfig(ABC):
|
|||
"""
|
||||
from litellm.llms.anthropic.common_utils import (
|
||||
is_anthropic_invalid_thinking_block_error,
|
||||
is_claude_strict_tools_rejection,
|
||||
strip_thinking_blocks_from_anthropic_messages_request_dict,
|
||||
tools_without_strict,
|
||||
warn_claude_strict_tools_dropped,
|
||||
)
|
||||
|
||||
if e.response.status_code == 400 and is_anthropic_invalid_thinking_block_error(e.response.text):
|
||||
strip_thinking_blocks_from_anthropic_messages_request_dict(request_data)
|
||||
without_strict: Final = (
|
||||
tools_without_strict(request_data.get("tools"))
|
||||
if is_claude_strict_tools_rejection(e.response.status_code, e.response.text)
|
||||
else None
|
||||
)
|
||||
if without_strict is not None:
|
||||
warn_claude_strict_tools_dropped(without_strict.dropped_count)
|
||||
request_data["tools"] = list(without_strict.tools) # rebind-ok: the retry loop re-signs this same dict
|
||||
return request_data
|
||||
|
||||
def native_output_fallback_litellm_params(
|
||||
|
|
|
|||
|
|
@ -135,6 +135,18 @@ class BaseTextToSpeechConfig(ABC):
|
|||
Transform provider response to standard format
|
||||
"""
|
||||
|
||||
async def async_transform_text_to_speech_response(
|
||||
self,
|
||||
model: str,
|
||||
raw_response: httpx.Response,
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> "HttpxBinaryResponseContent":
|
||||
return self.transform_text_to_speech_response(
|
||||
model=model,
|
||||
raw_response=raw_response,
|
||||
logging_obj=logging_obj,
|
||||
)
|
||||
|
||||
def get_error_class(self, error_message: str, status_code: int, headers: dict) -> BaseLLMException:
|
||||
from ..chat.transformation import BaseLLMException
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@ from collections.abc import Callable, Coroutine
|
|||
from functools import partial
|
||||
from typing import Final, TypeAlias
|
||||
|
||||
from litellm.llms.anthropic.common_utils import is_claude_strict_tools_rejection
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.bedrock.common_utils import (
|
||||
NativeOutputFallback,
|
||||
is_bedrock_native_output_rejection,
|
||||
is_bedrock_strict_tools_rejection,
|
||||
warn_bedrock_native_output_rejected,
|
||||
warn_bedrock_strict_tools_dropped,
|
||||
)
|
||||
|
|
@ -34,7 +34,7 @@ def _native_output_retry(
|
|||
def _strict_tools_retry(
|
||||
error: BaseLLMException, fallback: NativeOutputFallbackFactory | None, model: str
|
||||
) -> NativeOutputFallback | None:
|
||||
if fallback is None or not is_bedrock_strict_tools_rejection(error.status_code, error.message):
|
||||
if fallback is None or not is_claude_strict_tools_rejection(error.status_code, error.message):
|
||||
return None
|
||||
retry: Final = fallback()
|
||||
if retry is not None:
|
||||
|
|
|
|||
|
|
@ -366,21 +366,10 @@ class NativeOutputFallback:
|
|||
litellm_params: Mapping[str, object]
|
||||
|
||||
|
||||
_BEDROCK_STRICT_TOOLS_REJECTION: Final = re.compile(
|
||||
r"Too many strict tools \(\d+\)"
|
||||
r"|Schemas contains too many (?:optional parameters|parameters with union types)"
|
||||
r"|[Cc]ompiled grammar (?:is too large|size)"
|
||||
r"|tools\.\d+\.custom(?:\.strict)?: "
|
||||
r"|Grammar compilation timed out"
|
||||
)
|
||||
_TOOLS_ADAPTER: Final = TypeAdapter(tuple[Mapping[str, object], ...])
|
||||
_TOOL_SPEC_ADAPTER: Final = TypeAdapter(Mapping[str, object])
|
||||
|
||||
|
||||
def is_bedrock_strict_tools_rejection(status_code: int, error_text: str) -> bool:
|
||||
return status_code == 400 and _BEDROCK_STRICT_TOOLS_REJECTION.search(error_text) is not None
|
||||
|
||||
|
||||
def warn_bedrock_strict_tools_dropped(model: str) -> None:
|
||||
verbose_logger.warning(
|
||||
"Bedrock rejected the strict tool schemas for model=%s, so litellm sent the request again without `strict` "
|
||||
|
|
|
|||
|
|
@ -2467,8 +2467,7 @@ class BaseLLMHTTPHandler:
|
|||
)
|
||||
else:
|
||||
verbose_logger.debug(
|
||||
"Anthropic /v1/messages: invalid thinking signature; "
|
||||
"stripping thinking blocks and retrying (attempt %s/%s).",
|
||||
"Anthropic /v1/messages: retrying after a recoverable HTTP error (attempt %s/%s).",
|
||||
len(recovered_errors) + 2,
|
||||
max_attempts,
|
||||
)
|
||||
|
|
@ -12850,7 +12849,7 @@ class BaseLLMHTTPHandler:
|
|||
provider_config=text_to_speech_provider_config,
|
||||
)
|
||||
|
||||
speech_response: Final = text_to_speech_provider_config.transform_text_to_speech_response(
|
||||
speech_response: Final = await text_to_speech_provider_config.async_transform_text_to_speech_response(
|
||||
model=model,
|
||||
raw_response=response,
|
||||
logging_obj=logging_obj,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ DASHSCOPE_ANTHROPIC_PATH: Final = "/apps/anthropic"
|
|||
DASHSCOPE_NATIVE_V1_PATH: Final = "/api/v1"
|
||||
DASHSCOPE_COMPATIBLE_API_PATH: Final = "/compatible-api/v1"
|
||||
DASHSCOPE_RERANK_PATH: Final = f"{DASHSCOPE_COMPATIBLE_API_PATH}/reranks"
|
||||
DASHSCOPE_IMAGE_PATH: Final = "/api/v1/services/aigc/multimodal-generation/generation"
|
||||
DASHSCOPE_MULTIMODAL_GENERATION_PATH: Final = "/api/v1/services/aigc/multimodal-generation/generation"
|
||||
DASHSCOPE_IMAGE_PATH: Final = DASHSCOPE_MULTIMODAL_GENERATION_PATH
|
||||
DASHSCOPE_DEFAULT_ORIGIN: Final = "https://dashscope.aliyuncs.com"
|
||||
|
||||
_STANDARD_DASHSCOPE_HOSTS: Final = frozenset(
|
||||
|
|
@ -100,6 +101,21 @@ def is_dashscope_native_endpoint_url(api_base: str) -> bool:
|
|||
return urlparse(api_base).path.rstrip("/").startswith(f"{DASHSCOPE_NATIVE_V1_PATH}/")
|
||||
|
||||
|
||||
def dashscope_api_root(api_base: str) -> str:
|
||||
endpoints: Final = resolve_dashscope_endpoints(api_base)
|
||||
if endpoints is not None:
|
||||
return endpoints.origin
|
||||
trimmed: Final = api_base.rstrip("/")
|
||||
if trimmed.endswith(DASHSCOPE_CHAT_COMPATIBLE_PATH):
|
||||
return trimmed.removesuffix(DASHSCOPE_CHAT_COMPATIBLE_PATH)
|
||||
root, separator, _ = trimmed.partition(DASHSCOPE_NATIVE_V1_PATH)
|
||||
return root if separator else trimmed
|
||||
|
||||
|
||||
def is_aliyun_host(host: str | None) -> bool:
|
||||
return host is not None and (host == "aliyuncs.com" or host.endswith(".aliyuncs.com"))
|
||||
|
||||
|
||||
def resolve_dashscope_openai_api_base(
|
||||
api_base: str | None,
|
||||
env_var: str = "DASHSCOPE_API_BASE",
|
||||
|
|
@ -140,9 +156,7 @@ def _chat_shaped_aliyun_origin(api_base: str | None) -> str | None:
|
|||
if api_base is None:
|
||||
return None
|
||||
parsed: Final = urlparse(api_base)
|
||||
host: Final = parsed.hostname or ""
|
||||
on_aliyun_host: Final = host == "aliyuncs.com" or host.endswith(".aliyuncs.com")
|
||||
if not on_aliyun_host or parsed.path.rstrip("/") != DASHSCOPE_CHAT_COMPATIBLE_PATH:
|
||||
if not is_aliyun_host(parsed.hostname) or parsed.path.rstrip("/") != DASHSCOPE_CHAT_COMPATIBLE_PATH:
|
||||
return None
|
||||
return f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
|
|
|
|||
0
litellm/llms/dashscope/image_edit/__init__.py
Normal file
0
litellm/llms/dashscope/image_edit/__init__.py
Normal file
160
litellm/llms/dashscope/image_edit/transformation.py
Normal file
160
litellm/llms/dashscope/image_edit/transformation.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
import httpx
|
||||
from httpx._types import RequestFiles
|
||||
from pydantic import TypeAdapter
|
||||
from typing_extensions import override
|
||||
|
||||
import litellm
|
||||
from litellm.images.utils import image_to_data_url
|
||||
from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
|
||||
from litellm.llms.dashscope.common_utils import (
|
||||
DASHSCOPE_CHAT_COMPATIBLE_PATH,
|
||||
DASHSCOPE_MULTIMODAL_GENERATION_PATH,
|
||||
dashscope_api_root,
|
||||
is_dashscope_native_endpoint_url,
|
||||
resolve_dashscope_endpoints,
|
||||
)
|
||||
from litellm.llms.dashscope.image_generation.transformation import (
|
||||
DEFAULT_API_BASE,
|
||||
OPENAI_TO_DASHSCOPE_SIZE,
|
||||
DashScopeImageGenerationConfig,
|
||||
)
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.images.main import ImageEditOptionalRequestParams
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import ImageResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
|
||||
MAX_INPUT_IMAGES: Final = 3
|
||||
DASHSCOPE_IMAGE_EDIT_PARAMS: Final = ("n", "size", "negative_prompt", "prompt_extend", "watermark", "seed")
|
||||
|
||||
_IMAGES: Final = TypeAdapter(tuple[object, ...])
|
||||
|
||||
|
||||
def _dashscope_size(size: object) -> object:
|
||||
if not isinstance(size, str):
|
||||
return size
|
||||
return OPENAI_TO_DASHSCOPE_SIZE.get(size, size.replace("x", "*"))
|
||||
|
||||
|
||||
def _deployment_edit_url(api_base: str) -> str:
|
||||
derives_from_root: Final = not is_dashscope_native_endpoint_url(api_base) and (
|
||||
resolve_dashscope_endpoints(api_base) is not None
|
||||
or api_base.rstrip("/").endswith(DASHSCOPE_CHAT_COMPATIBLE_PATH)
|
||||
)
|
||||
return f"{dashscope_api_root(api_base)}{DASHSCOPE_MULTIMODAL_GENERATION_PATH}" if derives_from_root else api_base
|
||||
|
||||
|
||||
class DashScopeImageEditConfig(BaseImageEditConfig):
|
||||
def __init__(self, generation_config: DashScopeImageGenerationConfig | None = None) -> None:
|
||||
super().__init__()
|
||||
self._generation_config: Final = generation_config or DashScopeImageGenerationConfig()
|
||||
|
||||
@override
|
||||
def get_supported_openai_params(self, model: str) -> list[str]: # mutable-ok: mirrors override contract
|
||||
return list(DASHSCOPE_IMAGE_EDIT_PARAMS)
|
||||
|
||||
@override
|
||||
def map_openai_params(
|
||||
self,
|
||||
image_edit_optional_params: ImageEditOptionalRequestParams,
|
||||
model: str,
|
||||
drop_params: bool,
|
||||
) -> dict[str, object]: # mutable-ok: mirrors override contract
|
||||
return {
|
||||
key: _dashscope_size(value) if key == "size" else value
|
||||
for key, value in image_edit_optional_params.items()
|
||||
if key in DASHSCOPE_IMAGE_EDIT_PARAMS
|
||||
}
|
||||
|
||||
@override
|
||||
def validate_environment(
|
||||
self,
|
||||
headers: dict[str, str], # mutable-ok: mirrors override contract
|
||||
model: str,
|
||||
api_key: str | None = None,
|
||||
litellm_params: dict[str, object] | None = None, # mutable-ok: mirrors override contract
|
||||
api_base: str | None = None,
|
||||
) -> dict[str, str]: # mutable-ok: mirrors override contract
|
||||
return self._generation_config.validate_environment(
|
||||
headers=dict(headers),
|
||||
model=model,
|
||||
messages=[],
|
||||
optional_params={},
|
||||
litellm_params=dict(litellm_params or {}),
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
)
|
||||
|
||||
@override
|
||||
def get_complete_url(
|
||||
self,
|
||||
model: str,
|
||||
api_base: str | None,
|
||||
litellm_params: dict[str, object], # mutable-ok: mirrors override contract
|
||||
) -> str:
|
||||
if api_base is not None:
|
||||
return _deployment_edit_url(api_base)
|
||||
env_api_base: Final = get_secret_str("DASHSCOPE_API_BASE")
|
||||
derived: Final = (
|
||||
f"{dashscope_api_root(env_api_base)}{DASHSCOPE_MULTIMODAL_GENERATION_PATH}" if env_api_base else None
|
||||
)
|
||||
return get_secret_str("DASHSCOPE_API_BASE_IMAGE") or derived or DEFAULT_API_BASE
|
||||
|
||||
@override
|
||||
def use_multipart_form_data(self) -> bool:
|
||||
return False
|
||||
|
||||
@override
|
||||
def transform_image_edit_request(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str | None,
|
||||
image: object,
|
||||
image_edit_optional_request_params: Mapping[str, object],
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: Mapping[str, str],
|
||||
) -> tuple[dict[str, object], RequestFiles]: # mutable-ok: mirrors override contract
|
||||
candidates: Final = _IMAGES.validate_python(image) if isinstance(image, list) else (image,)
|
||||
images: Final = tuple(candidate for candidate in candidates if candidate is not None)
|
||||
if len(images) > MAX_INPUT_IMAGES:
|
||||
raise litellm.BadRequestError(
|
||||
message=f"DashScope image edit accepts at most {MAX_INPUT_IMAGES} input images, got {len(images)}",
|
||||
model=model,
|
||||
llm_provider="dashscope",
|
||||
)
|
||||
prompt_content: Final = ({"text": prompt},) if prompt is not None else ()
|
||||
return {
|
||||
"model": model,
|
||||
"input": {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [*({"image": image_to_data_url(item)} for item in images), *prompt_content],
|
||||
}
|
||||
]
|
||||
},
|
||||
"parameters": dict(image_edit_optional_request_params),
|
||||
}, ()
|
||||
|
||||
@override
|
||||
def transform_image_edit_response(
|
||||
self,
|
||||
model: str,
|
||||
raw_response: httpx.Response,
|
||||
logging_obj: "LiteLLMLoggingObj",
|
||||
) -> ImageResponse:
|
||||
return self._generation_config.transform_image_generation_response(
|
||||
model=model,
|
||||
raw_response=raw_response,
|
||||
model_response=ImageResponse(),
|
||||
logging_obj=logging_obj,
|
||||
request_data={},
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
encoding=None,
|
||||
)
|
||||
19
litellm/llms/dashscope/image_generation/cost_calculator.py
Normal file
19
litellm/llms/dashscope/image_generation/cost_calculator.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from typing import Final
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import resolve_image_model_info
|
||||
from litellm.types.utils import ImageResponse, ModelInfo
|
||||
|
||||
|
||||
def cost_calculator(model: str, image_response: ImageResponse, model_info: ModelInfo | None = None) -> float:
|
||||
prices: Final = resolve_image_model_info(
|
||||
model=model, custom_llm_provider=litellm.LlmProviders.DASHSCOPE.value, model_info=model_info
|
||||
)
|
||||
output_cost_per_image: Final = prices.get("output_cost_per_image")
|
||||
if output_cost_per_image is None:
|
||||
raise ValueError(f"No output_cost_per_image found for dashscope/{model}")
|
||||
input_images: Final = image_response.hidden_params.get("billable_input_image_count")
|
||||
input_image_cost: Final = (prices.get("input_cost_per_image") or 0.0) * (
|
||||
input_images if isinstance(input_images, int) else 0
|
||||
)
|
||||
return output_cost_per_image * len(image_response.data or ()) + input_image_cost
|
||||
|
|
@ -23,7 +23,7 @@ Response format:
|
|||
}
|
||||
"""
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
from collections.abc import Iterable, Iterator, Mapping
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -61,7 +61,7 @@ _DECODED_JSON: Final = TypeAdapter(object)
|
|||
DEFAULT_API_BASE: Final = "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
|
||||
|
||||
# Maps OpenAI size strings (WxH) to DashScope size strings (W*H)
|
||||
OPENAI_TO_DASHSCOPE_SIZE: Final[dict] = {
|
||||
OPENAI_TO_DASHSCOPE_SIZE: Final[Mapping[str, str]] = {
|
||||
"256x256": "256*256",
|
||||
"512x512": "512*512",
|
||||
"1024x1024": "1024*1024",
|
||||
|
|
@ -71,6 +71,24 @@ OPENAI_TO_DASHSCOPE_SIZE: Final[dict] = {
|
|||
}
|
||||
|
||||
|
||||
def _message_image_urls(message: Mapping[str, object]) -> Iterator[str]:
|
||||
contents: Final = _JSON_OBJECTS.validate_python(message.get("content", []))
|
||||
return (url for url in (item.get("image") for item in contents) if isinstance(url, str) and url)
|
||||
|
||||
|
||||
def _image_urls(response_object: Mapping[str, object]) -> Iterator[str]:
|
||||
output: Final = _JSON_OBJECT.validate_python(response_object.get("output", {}))
|
||||
choices: Final = _JSON_OBJECTS.validate_python(output.get("choices", []))
|
||||
for choice in choices:
|
||||
yield from _message_image_urls(_JSON_OBJECT.validate_python(choice.get("message", {})))
|
||||
|
||||
|
||||
def _dashscope_size(size: object) -> object:
|
||||
if not isinstance(size, str):
|
||||
return size
|
||||
return OPENAI_TO_DASHSCOPE_SIZE.get(size, size.replace("x", "*"))
|
||||
|
||||
|
||||
class DashScopeImageGenerationConfig(BaseImageGenerationConfig):
|
||||
"""
|
||||
Configuration for DashScope image generation (qwen-image-2.0, qwen-image-2.0-pro,
|
||||
|
|
@ -82,24 +100,16 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig):
|
|||
|
||||
def map_openai_params(
|
||||
self,
|
||||
non_default_params: dict,
|
||||
optional_params: dict,
|
||||
non_default_params: dict[str, object],
|
||||
optional_params: dict[str, object],
|
||||
model: str,
|
||||
drop_params: bool,
|
||||
) -> dict:
|
||||
) -> dict[str, object]:
|
||||
supported_params: Final = self.get_supported_openai_params(model)
|
||||
mapped: Final[dict] = {}
|
||||
for k, v in non_default_params.items():
|
||||
if k in optional_params:
|
||||
continue
|
||||
if k not in supported_params:
|
||||
continue
|
||||
if k == "size":
|
||||
# Convert "WxH" → "W*H"
|
||||
mapped["size"] = OPENAI_TO_DASHSCOPE_SIZE.get(v, v.replace("x", "*"))
|
||||
else:
|
||||
mapped[k] = v
|
||||
return mapped
|
||||
requested: Final = {
|
||||
k: v for k, v in non_default_params.items() if k in supported_params and k not in optional_params
|
||||
}
|
||||
return {k: _dashscope_size(v) if k == "size" else v for k, v in requested.items()}
|
||||
|
||||
def _resolve_api_key(self, api_key: str | None) -> str:
|
||||
resolved_api_key: Final = api_key or get_secret_str("DASHSCOPE_API_KEY")
|
||||
|
|
@ -121,8 +131,8 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig):
|
|||
api_base: str | None,
|
||||
api_key: str | None,
|
||||
model: str,
|
||||
optional_params: dict,
|
||||
litellm_params: dict,
|
||||
optional_params: dict[str, object],
|
||||
litellm_params: dict[str, object],
|
||||
stream: bool | None = None,
|
||||
) -> str:
|
||||
image_env_api_base: Final = self._image_api_base_env()
|
||||
|
|
@ -140,14 +150,14 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig):
|
|||
|
||||
def validate_environment(
|
||||
self,
|
||||
headers: dict,
|
||||
headers: dict[str, str],
|
||||
model: str,
|
||||
messages: list[AllMessageValues],
|
||||
optional_params: dict,
|
||||
litellm_params: dict,
|
||||
optional_params: dict[str, object],
|
||||
litellm_params: dict[str, object],
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
) -> dict:
|
||||
) -> dict[str, str]:
|
||||
headers["Authorization"] = f"Bearer {self._resolve_api_key(api_key)}"
|
||||
headers["Content-Type"] = "application/json"
|
||||
return headers
|
||||
|
|
@ -186,9 +196,9 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig):
|
|||
raw_response: httpx.Response,
|
||||
model_response: ImageResponse,
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
request_data: dict,
|
||||
optional_params: dict,
|
||||
litellm_params: dict,
|
||||
request_data: dict[str, object],
|
||||
optional_params: dict[str, object],
|
||||
litellm_params: dict[str, object],
|
||||
encoding: "Tokenizer | None",
|
||||
api_key: str | None = None,
|
||||
json_mode: bool | None = None,
|
||||
|
|
@ -225,17 +235,21 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig):
|
|||
headers=raw_response.headers,
|
||||
)
|
||||
|
||||
if not model_response.data:
|
||||
model_response.data = []
|
||||
|
||||
output: Final = _JSON_OBJECT.validate_python(response_object.get("output", {}))
|
||||
choices: Final = _JSON_OBJECTS.validate_python(output.get("choices", []))
|
||||
for choice in choices:
|
||||
message = _JSON_OBJECT.validate_python(choice.get("message", {}))
|
||||
content_list = _JSON_OBJECTS.validate_python(message.get("content", []))
|
||||
for content_item in content_list:
|
||||
image_url = content_item.get("image")
|
||||
if image_url:
|
||||
model_response.data.append(ImageObject(url=image_url))
|
||||
|
||||
return model_response
|
||||
images: Final = [*(model_response.data or ()), *(ImageObject(url=url) for url in _image_urls(response_object))]
|
||||
usage: Final = response_object.get("usage")
|
||||
usage_fields: Final[Mapping[str, object]] = usage if isinstance(usage, Mapping) else {}
|
||||
width: Final = usage_fields.get("output_width", usage_fields.get("width"))
|
||||
height: Final = usage_fields.get("output_height", usage_fields.get("height"))
|
||||
image_response: Final = model_response.model_copy(
|
||||
update={
|
||||
"data": images,
|
||||
**({"size": f"{width}x{height}"} if isinstance(width, int) and isinstance(height, int) else {}),
|
||||
}
|
||||
)
|
||||
input_image_count: Final = usage_fields.get("input_image_count")
|
||||
if isinstance(input_image_count, int) and input_image_count > 0:
|
||||
image_response.hidden_params = {
|
||||
**model_response.hidden_params,
|
||||
"billable_input_image_count": input_image_count,
|
||||
}
|
||||
return image_response
|
||||
|
|
|
|||
0
litellm/llms/dashscope/text_to_speech/__init__.py
Normal file
0
litellm/llms/dashscope/text_to_speech/__init__.py
Normal file
297
litellm/llms/dashscope/text_to_speech/transformation.py
Normal file
297
litellm/llms/dashscope/text_to_speech/transformation.py
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from itertools import chain
|
||||
from typing import TYPE_CHECKING, Final
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
|
||||
from typing_extensions import override
|
||||
|
||||
import litellm
|
||||
from litellm.exceptions import UnsupportedParamsError
|
||||
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
|
||||
from litellm.litellm_core_utils.url_utils import SSRFError, async_safe_get, safe_get
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.base_llm.text_to_speech.transformation import (
|
||||
BaseTextToSpeechConfig,
|
||||
TextToSpeechRequestData,
|
||||
)
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
AsyncHTTPHandler,
|
||||
HTTPHandler,
|
||||
get_async_httpx_client, # pyright: ignore[reportUnknownVariableType] # factory stub leaves params untyped
|
||||
get_httpx_client, # pyright: ignore[reportUnknownVariableType] # factory stub leaves params untyped
|
||||
)
|
||||
from litellm.llms.dashscope.common_utils import (
|
||||
DASHSCOPE_MULTIMODAL_GENERATION_PATH,
|
||||
DashScopeError,
|
||||
dashscope_api_root,
|
||||
is_aliyun_host,
|
||||
missing_dashscope_family_key_message,
|
||||
resolve_dashscope_family_api_key,
|
||||
)
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.llms.openai import HttpxBinaryResponseContent
|
||||
from litellm.types.utils import Usage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
|
||||
DASHSCOPE_TTS_DEFAULT_API_BASE: Final = "https://dashscope-intl.aliyuncs.com"
|
||||
_SPEECH_SYNTHESIZER_MODEL_PREFIX: Final = "qwen-audio-"
|
||||
_TIMEOUT_PHASES: Final = TypeAdapter(dict[str, float | None])
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _TextToSpeechFamily:
|
||||
path: str
|
||||
response_formats: frozenset[str]
|
||||
sends_format: bool
|
||||
takes_instructions: bool
|
||||
|
||||
|
||||
MULTIMODAL_GENERATION_FAMILY: Final = _TextToSpeechFamily(
|
||||
path=DASHSCOPE_MULTIMODAL_GENERATION_PATH,
|
||||
response_formats=frozenset({"wav"}),
|
||||
sends_format=False,
|
||||
takes_instructions=False,
|
||||
)
|
||||
SPEECH_SYNTHESIZER_FAMILY: Final = _TextToSpeechFamily(
|
||||
path="/api/v1/services/audio/tts/SpeechSynthesizer",
|
||||
response_formats=frozenset({"mp3", "wav", "opus", "pcm"}),
|
||||
sends_format=True,
|
||||
takes_instructions=True,
|
||||
)
|
||||
|
||||
|
||||
class _Audio(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
url: str
|
||||
|
||||
|
||||
class _Output(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
audio: _Audio
|
||||
|
||||
|
||||
class _Usage(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
input_tokens: int | None = None
|
||||
output_tokens: int | None = None
|
||||
characters: int | None = None
|
||||
|
||||
|
||||
class _SpeechResponse(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
output: _Output | None = None
|
||||
usage: _Usage | None = None
|
||||
code: str | None = None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
def text_to_speech_family(model: str) -> _TextToSpeechFamily:
|
||||
if model.startswith(_SPEECH_SYNTHESIZER_MODEL_PREFIX):
|
||||
return SPEECH_SYNTHESIZER_FAMILY
|
||||
return MULTIMODAL_GENERATION_FAMILY
|
||||
|
||||
|
||||
def _mapped_field(family: _TextToSpeechFamily, key: str, value: object) -> Mapping[str, object] | None:
|
||||
match key:
|
||||
case "response_format" if value in family.response_formats:
|
||||
return {"format": value} if family.sends_format else {}
|
||||
case "instructions" if family.takes_instructions:
|
||||
return {"instruction": value}
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
||||
def _unsupported(model: str, unsupported: tuple[str, ...]) -> UnsupportedParamsError:
|
||||
return UnsupportedParamsError(
|
||||
message=f"DashScope {model} does not support {', '.join(unsupported)}, set drop_params=True to ignore it",
|
||||
model=model,
|
||||
llm_provider="dashscope",
|
||||
)
|
||||
|
||||
|
||||
def _speech_response(raw_response: httpx.Response) -> _SpeechResponse:
|
||||
try:
|
||||
return _SpeechResponse.model_validate_json(raw_response.content)
|
||||
except ValidationError as e:
|
||||
raise DashScopeError(
|
||||
status_code=502,
|
||||
message=f"Unexpected DashScope text-to-speech response: {e}",
|
||||
headers=raw_response.headers,
|
||||
) from e
|
||||
|
||||
|
||||
def _https_on_aliyun(url: str) -> str:
|
||||
parsed: Final = urlparse(url)
|
||||
return (
|
||||
parsed._replace(scheme="https").geturl() if parsed.scheme == "http" and is_aliyun_host(parsed.hostname) else url
|
||||
)
|
||||
|
||||
|
||||
def _audio_url(raw_response: httpx.Response) -> tuple[str, _Usage | None]:
|
||||
parsed: Final = _speech_response(raw_response)
|
||||
if parsed.output is None:
|
||||
raise DashScopeError(
|
||||
status_code=max(raw_response.status_code, 400),
|
||||
message=parsed.message or parsed.code or raw_response.text,
|
||||
headers=raw_response.headers,
|
||||
)
|
||||
return _https_on_aliyun(parsed.output.audio.url), parsed.usage
|
||||
|
||||
|
||||
def _request_timeout(raw_response: httpx.Response) -> httpx.Timeout | None:
|
||||
extension: Final = raw_response.request.extensions.get("timeout")
|
||||
if extension is None:
|
||||
return None
|
||||
phases: Final = _TIMEOUT_PHASES.validate_python(extension)
|
||||
return httpx.Timeout(
|
||||
connect=phases.get("connect"), read=phases.get("read"), write=phases.get("write"), pool=phases.get("pool")
|
||||
)
|
||||
|
||||
|
||||
def _token_usage(usage: _Usage | None) -> Usage | None:
|
||||
if usage is None or usage.input_tokens is None or usage.output_tokens is None:
|
||||
return None
|
||||
return Usage(
|
||||
prompt_tokens=usage.input_tokens,
|
||||
completion_tokens=usage.output_tokens,
|
||||
total_tokens=usage.input_tokens + usage.output_tokens,
|
||||
)
|
||||
|
||||
|
||||
def _refused_audio_url(error: SSRFError) -> DashScopeError:
|
||||
return DashScopeError(status_code=502, message=f"DashScope returned an audio URL that was refused: {error}")
|
||||
|
||||
|
||||
def _binary_audio(audio_response: httpx.Response, usage: _Usage | None) -> HttpxBinaryResponseContent:
|
||||
if audio_response.status_code != 200:
|
||||
raise DashScopeError(
|
||||
status_code=502,
|
||||
message=f"DashScope audio download failed with HTTP {audio_response.status_code}",
|
||||
headers=audio_response.headers,
|
||||
)
|
||||
binary: Final = HttpxBinaryResponseContent(audio_response)
|
||||
binary.usage = _token_usage(usage)
|
||||
if usage is not None and usage.characters is not None:
|
||||
binary.hidden_params = {**binary.hidden_params, "billed_characters": usage.characters}
|
||||
return binary
|
||||
|
||||
|
||||
def _shared_async_client() -> AsyncHTTPHandler:
|
||||
return get_async_httpx_client(llm_provider=litellm.LlmProviders.DASHSCOPE)
|
||||
|
||||
|
||||
class DashScopeTextToSpeechConfig(BaseTextToSpeechConfig):
|
||||
def __init__(
|
||||
self,
|
||||
sync_client: Callable[[], HTTPHandler] = get_httpx_client,
|
||||
async_client: Callable[[], AsyncHTTPHandler] = _shared_async_client,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._sync_client: Final = sync_client
|
||||
self._async_client: Final = async_client
|
||||
|
||||
@override
|
||||
def get_supported_openai_params(self, model: str) -> list[str]: # mutable-ok: mirrors override contract
|
||||
family: Final = text_to_speech_family(model)
|
||||
return ["voice", "response_format", *(("instructions",) if family.takes_instructions else ())]
|
||||
|
||||
@override
|
||||
def map_openai_params(
|
||||
self,
|
||||
model: str,
|
||||
optional_params: dict[str, object], # mutable-ok: mirrors override contract
|
||||
voice: str | dict[str, object] | None = None, # mutable-ok: mirrors override contract
|
||||
drop_params: bool = False,
|
||||
kwargs: Mapping[str, object] | None = None,
|
||||
) -> tuple[str | None, dict[str, object]]: # mutable-ok: mirrors override contract
|
||||
family: Final = text_to_speech_family(model)
|
||||
fields: Final = tuple((key, value, _mapped_field(family, key, value)) for key, value in optional_params.items())
|
||||
unsupported: Final = (
|
||||
*(f"{key}={value!r}" for key, value, field in fields if field is None),
|
||||
*(("voice as an object",) if isinstance(voice, dict) else ()),
|
||||
)
|
||||
if unsupported and not (drop_params or drops_unsupported_params((kwargs or {}).get("drop_params"))):
|
||||
raise _unsupported(model, unsupported)
|
||||
supported: Final = tuple(field for _, _, field in fields if field is not None)
|
||||
return voice if isinstance(voice, str) else None, dict(chain.from_iterable(f.items() for f in supported))
|
||||
|
||||
@override
|
||||
def validate_environment(
|
||||
self,
|
||||
headers: dict[str, str], # mutable-ok: mirrors override contract
|
||||
model: str,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
) -> dict[str, str]: # mutable-ok: mirrors override contract
|
||||
resolved_api_key: Final = resolve_dashscope_family_api_key("dashscope", api_key)
|
||||
if resolved_api_key is None:
|
||||
raise ValueError(missing_dashscope_family_key_message("dashscope"))
|
||||
return {**headers, "Authorization": f"Bearer {resolved_api_key}", "Content-Type": "application/json"}
|
||||
|
||||
@override
|
||||
def get_complete_url(
|
||||
self,
|
||||
model: str,
|
||||
api_base: str | None,
|
||||
litellm_params: dict[str, object], # mutable-ok: mirrors override contract
|
||||
) -> str:
|
||||
configured: Final = api_base or get_secret_str("DASHSCOPE_API_BASE")
|
||||
root: Final = dashscope_api_root(configured) if configured else DASHSCOPE_TTS_DEFAULT_API_BASE
|
||||
return f"{root}{text_to_speech_family(model).path}"
|
||||
|
||||
@override
|
||||
def transform_text_to_speech_request(
|
||||
self,
|
||||
model: str,
|
||||
input: str,
|
||||
voice: str | None,
|
||||
optional_params: dict[str, object], # mutable-ok: mirrors override contract
|
||||
litellm_params: dict[str, object], # mutable-ok: mirrors override contract
|
||||
headers: dict[str, str], # mutable-ok: mirrors override contract
|
||||
) -> TextToSpeechRequestData:
|
||||
voice_field: Final = {"voice": voice} if voice is not None else {}
|
||||
return {"dict_body": {"model": model, "input": {"text": input, **voice_field, **optional_params}}}
|
||||
|
||||
@override
|
||||
def transform_text_to_speech_response(
|
||||
self,
|
||||
model: str,
|
||||
raw_response: httpx.Response,
|
||||
logging_obj: "LiteLLMLoggingObj",
|
||||
) -> HttpxBinaryResponseContent:
|
||||
audio_url, usage = _audio_url(raw_response)
|
||||
try:
|
||||
audio_response: Final = safe_get(self._sync_client(), audio_url, timeout=_request_timeout(raw_response))
|
||||
except SSRFError as e:
|
||||
raise _refused_audio_url(e) from e
|
||||
return _binary_audio(audio_response, usage)
|
||||
|
||||
@override
|
||||
async def async_transform_text_to_speech_response(
|
||||
self,
|
||||
model: str,
|
||||
raw_response: httpx.Response,
|
||||
logging_obj: "LiteLLMLoggingObj",
|
||||
) -> HttpxBinaryResponseContent:
|
||||
audio_url, usage = _audio_url(raw_response)
|
||||
try:
|
||||
audio_response: Final = await async_safe_get(
|
||||
self._async_client(), audio_url, timeout=_request_timeout(raw_response)
|
||||
)
|
||||
except SSRFError as e:
|
||||
raise _refused_audio_url(e) from e
|
||||
return _binary_audio(audio_response, usage)
|
||||
|
||||
@override
|
||||
def get_error_class(
|
||||
self,
|
||||
error_message: str,
|
||||
status_code: int,
|
||||
headers: dict[str, str] | httpx.Headers, # mutable-ok: mirrors override contract
|
||||
) -> BaseLLMException:
|
||||
return DashScopeError(status_code=status_code, message=error_message, headers=httpx.Headers(headers))
|
||||
0
litellm/llms/eu_jev/__init__.py
Normal file
0
litellm/llms/eu_jev/__init__.py
Normal file
34
litellm/llms/eu_jev/common_utils.py
Normal file
34
litellm/llms/eu_jev/common_utils.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from collections.abc import Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
EU_JEV_LATEST_MODEL: Final = "eu-jeff-latest"
|
||||
_EU_JEV_CURRENT_RELEASE_NAMES: Final = frozenset((EU_JEV_LATEST_MODEL, "jeff-latest", "jeff-1.0.0"))
|
||||
_EU_JEV_PREFIX: Final = "eu_jev/"
|
||||
|
||||
|
||||
def eu_jev_registry_model(requested_model: str | None) -> str:
|
||||
release: Final = requested_model.removeprefix(_EU_JEV_PREFIX) if requested_model is not None else None
|
||||
if release is None or release in _EU_JEV_CURRENT_RELEASE_NAMES:
|
||||
return EU_JEV_LATEST_MODEL
|
||||
return release
|
||||
|
||||
|
||||
def eu_jev_scoped_model(decision_body: Mapping[str, object]) -> str:
|
||||
requested_model: Final = decision_body.get("model")
|
||||
return f"{_EU_JEV_PREFIX}{eu_jev_registry_model(requested_model if isinstance(requested_model, str) else None)}"
|
||||
|
||||
|
||||
def eu_jev_upstream_body(hook_body: Mapping[str, object], client_body: Mapping[str, object]) -> Mapping[str, object]:
|
||||
hook_model: Final = hook_body.get("model")
|
||||
without_model: Final = {key: value for key, value in hook_body.items() if key != "model"}
|
||||
if hook_model == eu_jev_scoped_model(client_body):
|
||||
client_model: Final = client_body.get("model")
|
||||
if client_model is None:
|
||||
return MappingProxyType(without_model)
|
||||
if isinstance(client_model, str):
|
||||
return MappingProxyType({**without_model, "model": client_model.removeprefix(_EU_JEV_PREFIX)})
|
||||
return MappingProxyType({**without_model, "model": client_model})
|
||||
if isinstance(hook_model, str):
|
||||
return MappingProxyType({**without_model, "model": hook_model.removeprefix(_EU_JEV_PREFIX)})
|
||||
return hook_body
|
||||
9
litellm/llms/eu_jev/decisions/transformation.py
Normal file
9
litellm/llms/eu_jev/decisions/transformation.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig
|
||||
|
||||
|
||||
class EuJevDecisionsConfig(BaseDecisionsConfig):
|
||||
api_key_env = ("EU_JEV_API_KEY",)
|
||||
api_base_env = ("EU_JEV_API_BASE",)
|
||||
|
||||
def get_default_api_base(self) -> str | None:
|
||||
return "https://eu-jev.com"
|
||||
|
|
@ -5,11 +5,30 @@ LiteLLM Proxy supports the OpenAI Responses API natively when the underlying mod
|
|||
This config enables pass-through behavior to the proxy's /v1/responses endpoint.
|
||||
"""
|
||||
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from litellm.litellm_core_utils.url_utils import join_api_base
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.utils import LlmProviders
|
||||
|
||||
_ERROR_BODY: Final = TypeAdapter(dict[str, object])
|
||||
|
||||
|
||||
def _openai_sdk_error_body(
|
||||
error_message: str,
|
||||
) -> dict[str, object] | None: # mutable-ok: BaseLLMException.body is a dict
|
||||
try:
|
||||
decoded: Final = _ERROR_BODY.validate_json(error_message)
|
||||
except ValidationError:
|
||||
return None
|
||||
nested: Final = decoded.get("error")
|
||||
return _ERROR_BODY.validate_python(nested) if isinstance(nested, dict) else decoded
|
||||
|
||||
|
||||
class LiteLLMProxyResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
||||
"""
|
||||
|
|
@ -43,6 +62,19 @@ class LiteLLMProxyResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
|
||||
return join_api_base(api_base, "responses")
|
||||
|
||||
def get_error_class(
|
||||
self,
|
||||
error_message: str,
|
||||
status_code: int,
|
||||
headers: dict[str, object] | httpx.Headers, # mutable-ok: inherited contract
|
||||
) -> BaseLLMException:
|
||||
return BaseLLMException(
|
||||
status_code=status_code,
|
||||
message=error_message,
|
||||
headers=headers,
|
||||
body=_openai_sdk_error_body(error_message),
|
||||
)
|
||||
|
||||
def supports_native_websocket(self) -> bool:
|
||||
"""LiteLLM Proxy does not support native WebSocket for Responses API"""
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ from litellm._logging import verbose_logger
|
|||
from litellm.litellm_core_utils.asyncify import asyncify
|
||||
from litellm.litellm_core_utils.core_helpers import as_str_mapping
|
||||
from litellm.litellm_core_utils.tool_fields import DropPolicy
|
||||
from litellm.llms.anthropic.messages.context_management import AnthropicContextManagementError
|
||||
from litellm.llms.anthropic.messages.convertible_blocks import deployment_drops_params, unconvertible_blocks_error
|
||||
from litellm.llms.anthropic.messages.responses_adapters.handler import count_view_messages
|
||||
from litellm.llms.anthropic.messages.responses_adapters.transformation import (
|
||||
LiteLLMAnthropicToResponsesAPIAdapter,
|
||||
)
|
||||
|
|
@ -95,6 +97,32 @@ def _count_error(model_to_use: str, request_model: str, message: str, status_cod
|
|||
)
|
||||
|
||||
|
||||
async def _sent_messages(
|
||||
model: str,
|
||||
messages: Sequence[Mapping[str, object]],
|
||||
tools: Sequence[Mapping[str, object]] | None,
|
||||
system: object,
|
||||
messages_format: TokenCountMessagesFormat,
|
||||
context_management: CountContextManagement | None,
|
||||
) -> Sequence[Mapping[str, object]] | AnthropicContextManagementError:
|
||||
match messages_format:
|
||||
case "openai":
|
||||
return messages
|
||||
case "anthropic":
|
||||
try:
|
||||
return await count_view_messages(
|
||||
model=model,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
system=system,
|
||||
context_management=context_management,
|
||||
)
|
||||
except AnthropicContextManagementError as error:
|
||||
return error
|
||||
case _:
|
||||
assert_never(messages_format)
|
||||
|
||||
|
||||
class OpenAITokenCounter(BaseTokenCounter):
|
||||
"""Token counter implementation for OpenAI provider using the Responses API."""
|
||||
|
||||
|
|
@ -175,13 +203,28 @@ class OpenAITokenCounter(BaseTokenCounter):
|
|||
rejected_before_provider_call=True,
|
||||
)
|
||||
|
||||
sent_messages: Final = await _sent_messages(
|
||||
model_to_use, messages, tools, system, messages_format, context_management
|
||||
)
|
||||
if isinstance(sent_messages, AnthropicContextManagementError):
|
||||
return TokenCountResponse(
|
||||
total_tokens=0,
|
||||
request_model=request_model,
|
||||
model_used=model_to_use,
|
||||
tokenizer_type="openai_api",
|
||||
error=True,
|
||||
error_message=sent_messages.message,
|
||||
status_code=sent_messages.status_code,
|
||||
rejected_before_provider_call=True,
|
||||
)
|
||||
|
||||
resolved: Final = _count_credential(deployment, request_model or model_to_use)
|
||||
if isinstance(resolved, OpenAIError):
|
||||
return _count_error(model_to_use, request_model, resolved.message, resolved.status_code)
|
||||
|
||||
input_items, instructions, counted_tools, text = self._responses_request_parts(
|
||||
model_to_use=model_to_use,
|
||||
messages=messages,
|
||||
messages=sent_messages,
|
||||
tools=tools,
|
||||
system=system,
|
||||
messages_format=messages_format,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
|
|||
from litellm.litellm_core_utils.litellm_logging import (
|
||||
get_standard_logging_object_payload, # pyright: ignore[reportUnknownVariableType] # legacy helper has an untyped signature
|
||||
)
|
||||
from litellm.llms.eu_jev.common_utils import EU_JEV_LATEST_MODEL, eu_jev_registry_model
|
||||
from litellm.llms.laya.common_utils import laya_response_model
|
||||
from litellm.types.llms.base import LiteLLMBaseModel
|
||||
from litellm.types.utils import ModelResponse, StandardPassThroughResponseObject, Usage
|
||||
|
|
@ -82,13 +83,21 @@ class TypeSafePassthroughLoggingHandler:
|
|||
response_model: Final = (
|
||||
laya_response_model(response_body, request_model) if custom_llm_provider == "laya" else response.model
|
||||
)
|
||||
logged_model: Final = response_model or request_model or "unknown"
|
||||
registry_request_model: Final = (
|
||||
eu_jev_registry_model(request_model) if custom_llm_provider == "eu_jev" else request_model
|
||||
)
|
||||
logged_model: Final = response_model or registry_request_model or "unknown"
|
||||
model_name: Final = f"{custom_llm_provider}/{logged_model}"
|
||||
usage: Final = response.usage or _TypeSafeUsage()
|
||||
input_tokens: Final = usage.input_tokens
|
||||
output_tokens: Final = usage.output_tokens
|
||||
candidate_models: Final = (
|
||||
(response_model, registry_request_model, EU_JEV_LATEST_MODEL)
|
||||
if custom_llm_provider == "eu_jev"
|
||||
else (response_model, registry_request_model)
|
||||
)
|
||||
candidate_model_keys: Final = tuple(
|
||||
f"{custom_llm_provider}/{model}" for model in (response_model, request_model) if model is not None
|
||||
f"{custom_llm_provider}/{model}" for model in candidate_models if model is not None
|
||||
)
|
||||
pricing: Final = _pricing_for(candidate_model_keys)
|
||||
response_cost: Final = (
|
||||
|
|
|
|||
|
|
@ -7980,6 +7980,8 @@ async def atext_completion(*args, **kwargs) -> TextCompletionResponse | TextComp
|
|||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
stream_options=kwargs.get("stream_options"),
|
||||
usage_injected_by_proxy="_litellm_strip_stream_usage" in kwargs
|
||||
and kwargs["_litellm_strip_stream_usage"] is True,
|
||||
)
|
||||
else:
|
||||
## OpenAI / Azure Text Completion Returns here
|
||||
|
|
@ -8829,8 +8831,9 @@ def speech(
|
|||
extra_headers: Final = kwargs.get("extra_headers", None)
|
||||
model_info: Final = kwargs.get("model_info", None)
|
||||
shared_session: Final = kwargs.get("shared_session", None)
|
||||
caller_api_base: Final = api_base or base_url
|
||||
model, custom_llm_provider, dynamic_api_key, api_base = get_llm_provider(
|
||||
model=model, custom_llm_provider=custom_llm_provider, api_base=api_base or base_url
|
||||
model=model, custom_llm_provider=custom_llm_provider, api_base=caller_api_base
|
||||
)
|
||||
kwargs.pop("tags", [])
|
||||
|
||||
|
|
@ -8885,13 +8888,13 @@ def speech(
|
|||
custom_llm_provider=custom_llm_provider,
|
||||
)
|
||||
response: HttpxBinaryResponseContent | Coroutine[object, object, HttpxBinaryResponseContent] | None = None
|
||||
if custom_llm_provider == "edenai":
|
||||
litellm_params_dict["api_base"] = api_base
|
||||
if custom_llm_provider in ("edenai", "dashscope") and text_to_speech_provider_config is not None:
|
||||
litellm_params_dict["api_base"] = caller_api_base if custom_llm_provider == "dashscope" else api_base
|
||||
response = base_llm_http_handler.text_to_speech_handler(
|
||||
model=model,
|
||||
input=input,
|
||||
voice=voice if isinstance(voice, str) else None,
|
||||
text_to_speech_provider_config=text_to_speech_provider_config or litellm.EdenAITextToSpeechConfig(),
|
||||
text_to_speech_provider_config=text_to_speech_provider_config,
|
||||
text_to_speech_optional_params=optional_params,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
litellm_params=litellm_params_dict,
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ _ASYNC_FEATURES: Final[detectors.Detectors] = MappingProxyType(
|
|||
Feature.PRE_REQUEST_HOOK: route_detectors.pre_request_hook,
|
||||
Feature.WEBSEARCH_INTERCEPTION: route_detectors.websearch_interception,
|
||||
Feature.MESSAGES_INTERCEPTOR: route_detectors.intercepted,
|
||||
Feature.STRICT_TOOLS_RETRY: route_detectors.strict_tools_retry,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -18346,25 +18346,31 @@
|
|||
"dashscope/qwen-image-2.0": {
|
||||
"litellm_provider": "dashscope",
|
||||
"mode": "image_generation",
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
|
||||
"output_cost_per_image": 0.035,
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/images/generations"
|
||||
"/v1/images/generations",
|
||||
"/v1/images/edits"
|
||||
]
|
||||
},
|
||||
"dashscope/qwen-image-2.0-pro": {
|
||||
"litellm_provider": "dashscope",
|
||||
"mode": "image_generation",
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
|
||||
"output_cost_per_image": 0.075,
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/images/generations"
|
||||
"/v1/images/generations",
|
||||
"/v1/images/edits"
|
||||
]
|
||||
},
|
||||
"dashscope/qwen-image-3.0": {
|
||||
"litellm_provider": "dashscope",
|
||||
"mode": "image_generation",
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
|
||||
"output_cost_per_image": 0.03,
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/images/generations"
|
||||
"/v1/images/generations",
|
||||
"/v1/images/edits"
|
||||
]
|
||||
},
|
||||
"dashscope/qwen-image-3.0-pro": {
|
||||
|
|
@ -18372,7 +18378,54 @@
|
|||
"mode": "image_generation",
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
|
||||
"supported_endpoints": [
|
||||
"/v1/images/generations"
|
||||
"/v1/images/generations",
|
||||
"/v1/images/edits"
|
||||
]
|
||||
},
|
||||
"dashscope/qwen3-tts-flash": {
|
||||
"input_cost_per_character": 1e-05,
|
||||
"litellm_provider": "dashscope",
|
||||
"mode": "audio_speech",
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/audio/speech"
|
||||
]
|
||||
},
|
||||
"dashscope/qwen-audio-3.1-tts-flash": {
|
||||
"input_cost_per_token": 2.3e-07,
|
||||
"output_cost_per_token": 1.87e-06,
|
||||
"litellm_provider": "dashscope",
|
||||
"mode": "audio_speech",
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/audio/speech"
|
||||
]
|
||||
},
|
||||
"dashscope/qwen-audio-3.1-tts-plus": {
|
||||
"input_cost_per_character": 3e-05,
|
||||
"litellm_provider": "dashscope",
|
||||
"mode": "audio_speech",
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/audio/speech"
|
||||
]
|
||||
},
|
||||
"dashscope/qwen-audio-3.0-tts-plus": {
|
||||
"input_cost_per_character": 2e-05,
|
||||
"litellm_provider": "dashscope",
|
||||
"mode": "audio_speech",
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/audio/speech"
|
||||
]
|
||||
},
|
||||
"dashscope/qwen-audio-3.0-tts-flash": {
|
||||
"input_cost_per_character": 1.5e-05,
|
||||
"litellm_provider": "dashscope",
|
||||
"mode": "audio_speech",
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/audio/speech"
|
||||
]
|
||||
},
|
||||
"dashscope/wan3.0-video": {
|
||||
|
|
@ -72330,6 +72383,28 @@
|
|||
"source": "https://docs.typesafe.ai/models",
|
||||
"supports_box_questions": true
|
||||
},
|
||||
"eu_jev/eu-jeff-1.0.0": {
|
||||
"input_cost_per_token": 4.15e-08,
|
||||
"litellm_provider": "eu_jev",
|
||||
"max_input_tokens": 32768,
|
||||
"mode": "evaluation",
|
||||
"output_cost_per_token": 0.0,
|
||||
"source": "https://eu-jev.com/docs",
|
||||
"supported_endpoints": [
|
||||
"/v1/systemone"
|
||||
]
|
||||
},
|
||||
"eu_jev/eu-jeff-latest": {
|
||||
"input_cost_per_token": 4.15e-08,
|
||||
"litellm_provider": "eu_jev",
|
||||
"max_input_tokens": 32768,
|
||||
"mode": "evaluation",
|
||||
"output_cost_per_token": 0.0,
|
||||
"source": "https://eu-jev.com/docs",
|
||||
"supported_endpoints": [
|
||||
"/v1/systemone"
|
||||
]
|
||||
},
|
||||
"wandb/deepseek-ai/DeepSeek-V4.1-Flash": {
|
||||
"cache_read_input_token_cost": 3e-08,
|
||||
"input_cost_per_token": 2e-07,
|
||||
|
|
|
|||
|
|
@ -1148,9 +1148,9 @@
|
|||
"text_completion": true,
|
||||
"embeddings": false,
|
||||
"image_generations": true,
|
||||
"image_edits": false,
|
||||
"image_edits": true,
|
||||
"audio_transcriptions": false,
|
||||
"audio_speech": false,
|
||||
"audio_speech": true,
|
||||
"rerank": false,
|
||||
"ocr": false,
|
||||
"search": false,
|
||||
|
|
@ -2241,6 +2241,32 @@
|
|||
"generateContent": false
|
||||
}
|
||||
},
|
||||
"eu_jev": {
|
||||
"display_name": "eu/jev (`eu_jev`)",
|
||||
"url": "https://docs.litellm.ai/docs/pass_through/eu_jev",
|
||||
"endpoints": {
|
||||
"chat_completions": false,
|
||||
"responses": false,
|
||||
"text_completion": false,
|
||||
"embeddings": false,
|
||||
"image_generations": false,
|
||||
"image_edits": false,
|
||||
"audio_transcriptions": false,
|
||||
"audio_speech": false,
|
||||
"rerank": false,
|
||||
"ocr": false,
|
||||
"search": false,
|
||||
"moderations": false,
|
||||
"realtime": false,
|
||||
"video_generations": false,
|
||||
"batches": false,
|
||||
"messages": false,
|
||||
"interactions": false,
|
||||
"systemone": true,
|
||||
"a2a": false,
|
||||
"generateContent": false
|
||||
}
|
||||
},
|
||||
"laya": {
|
||||
"display_name": "Laya (`laya`)",
|
||||
"url": "https://docs.litellm.ai/docs/auto_router/decision_classifiers",
|
||||
|
|
|
|||
|
|
@ -238,6 +238,7 @@ LAZY_FEATURES: Final[tuple[LazyFeature, ...]] = (
|
|||
"/tinyfish/",
|
||||
"/transcribe",
|
||||
"/typesafe/",
|
||||
"/eu_jev/",
|
||||
"/laya/",
|
||||
"/bespoke/",
|
||||
"/openrouter/",
|
||||
|
|
|
|||
|
|
@ -23170,6 +23170,39 @@
|
|||
"title": "CachedTokensDetails",
|
||||
"type": "object"
|
||||
},
|
||||
"ChoiceAnswer": {
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"choice": {
|
||||
"title": "Choice",
|
||||
"type": "string"
|
||||
},
|
||||
"confidence": {
|
||||
"title": "Confidence",
|
||||
"type": "number"
|
||||
},
|
||||
"probabilities": {
|
||||
"additionalProperties": {
|
||||
"type": "number"
|
||||
},
|
||||
"title": "Probabilities",
|
||||
"type": "object"
|
||||
},
|
||||
"type": {
|
||||
"const": "choice",
|
||||
"title": "Type",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"choice",
|
||||
"confidence",
|
||||
"probabilities"
|
||||
],
|
||||
"title": "ChoiceAnswer",
|
||||
"type": "object"
|
||||
},
|
||||
"Click": {
|
||||
"additionalProperties": true,
|
||||
"description": "A click action.",
|
||||
|
|
@ -23934,6 +23967,112 @@
|
|||
"title": "ErrorResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"EuJevDecisionResponse": {
|
||||
"properties": {
|
||||
"answers": {
|
||||
"additionalProperties": {
|
||||
"discriminator": {
|
||||
"mapping": {
|
||||
"choice": "#/components/schemas/ChoiceAnswer",
|
||||
"noul": "#/components/schemas/NoulAnswer",
|
||||
"score": "#/components/schemas/ScoreAnswer"
|
||||
},
|
||||
"propertyName": "type"
|
||||
},
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ChoiceAnswer"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/NoulAnswer"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ScoreAnswer"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "One answer per question, under the same names",
|
||||
"title": "Answers",
|
||||
"type": "object"
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/EuJevMeta"
|
||||
},
|
||||
"model": {
|
||||
"description": "The release that answered, such as eu-jeff-1.0.0",
|
||||
"title": "Model",
|
||||
"type": "string"
|
||||
},
|
||||
"usage": {
|
||||
"$ref": "#/components/schemas/EuJevUsage"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"model",
|
||||
"answers",
|
||||
"usage",
|
||||
"meta"
|
||||
],
|
||||
"title": "EuJevDecisionResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"EuJevMeta": {
|
||||
"properties": {
|
||||
"cost_eur": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "The exact charge in euros, on authenticated requests",
|
||||
"title": "Cost Eur"
|
||||
},
|
||||
"latency_ms": {
|
||||
"description": "Server-side time for token quoting, credit reservation and inference",
|
||||
"title": "Latency Ms",
|
||||
"type": "integer"
|
||||
},
|
||||
"mode": {
|
||||
"const": "live",
|
||||
"title": "Mode",
|
||||
"type": "string"
|
||||
},
|
||||
"request_id": {
|
||||
"title": "Request Id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"request_id",
|
||||
"mode",
|
||||
"latency_ms"
|
||||
],
|
||||
"title": "EuJevMeta",
|
||||
"type": "object"
|
||||
},
|
||||
"EuJevUsage": {
|
||||
"properties": {
|
||||
"input_tokens": {
|
||||
"minimum": 0.0,
|
||||
"title": "Input Tokens",
|
||||
"type": "integer"
|
||||
},
|
||||
"output_tokens": {
|
||||
"minimum": 0.0,
|
||||
"title": "Output Tokens",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"input_tokens",
|
||||
"output_tokens"
|
||||
],
|
||||
"title": "EuJevUsage",
|
||||
"type": "object"
|
||||
},
|
||||
"FileSearchTool": {
|
||||
"additionalProperties": true,
|
||||
"description": "A tool that searches for relevant content from uploaded files.\n\nLearn more about the [file search tool](https://platform.openai.com/docs/guides/tools-file-search).",
|
||||
|
|
@ -25594,6 +25733,26 @@
|
|||
"title": "NamespaceTool",
|
||||
"type": "object"
|
||||
},
|
||||
"NoulAnswer": {
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"noul": {
|
||||
"title": "Noul",
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"const": "noul",
|
||||
"title": "Type",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"noul"
|
||||
],
|
||||
"title": "NoulAnswer",
|
||||
"type": "object"
|
||||
},
|
||||
"OperationCreateFile": {
|
||||
"additionalProperties": true,
|
||||
"description": "Instruction describing how to create a file via the apply_patch tool.",
|
||||
|
|
@ -28866,6 +29025,59 @@
|
|||
"title": "Result",
|
||||
"type": "object"
|
||||
},
|
||||
"ScoreAnswer": {
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"confidence": {
|
||||
"title": "Confidence",
|
||||
"type": "number"
|
||||
},
|
||||
"legend": {
|
||||
"additionalProperties": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"items": {},
|
||||
"type": "array"
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": "Legend",
|
||||
"type": "object"
|
||||
},
|
||||
"probabilities": {
|
||||
"additionalProperties": {
|
||||
"type": "number"
|
||||
},
|
||||
"title": "Probabilities",
|
||||
"type": "object"
|
||||
},
|
||||
"score": {
|
||||
"title": "Score",
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"const": "score",
|
||||
"title": "Type",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"score",
|
||||
"confidence",
|
||||
"legend",
|
||||
"probabilities"
|
||||
],
|
||||
"title": "ScoreAnswer",
|
||||
"type": "object"
|
||||
},
|
||||
"Screenshot": {
|
||||
"additionalProperties": true,
|
||||
"description": "A screenshot action.",
|
||||
|
|
@ -31758,6 +31970,315 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/eu_jev/v1/systemone": {
|
||||
"post": {
|
||||
"description": "Forward a request to eu/jev, an EU-hosted decision API, with `EU_JEV_API_KEY`.\n\n`POST /eu_jev/v1/systemone` sends a `state` and one to eight named `questions` (`choice`, `noul` or `score`)\nand returns one answer per question name, with spend booked from the reported `usage`. `custom_body` and\n`stream` are rejected with a 400. A pass-through endpoint configured at this path keeps serving it.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/eu_jev)",
|
||||
"operationId": "eu_jev_proxy_route_eu_jev_v1_systemone_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"model": "eu-jeff-latest",
|
||||
"questions": {
|
||||
"frustration": {
|
||||
"criteria": [
|
||||
"calm",
|
||||
"mildly annoyed",
|
||||
"angry"
|
||||
],
|
||||
"instructions": "How frustrated is the customer?",
|
||||
"type": "score"
|
||||
},
|
||||
"is_refund_request": {
|
||||
"instructions": "Is the customer asking for a refund?",
|
||||
"type": "noul"
|
||||
},
|
||||
"team": {
|
||||
"criteria": {
|
||||
"billing": "Payments, invoices, and refunds",
|
||||
"support": "Technical issues and bugs"
|
||||
},
|
||||
"instructions": "Which team should handle this?",
|
||||
"type": "choice"
|
||||
}
|
||||
},
|
||||
"state": "I was charged twice. Can I get a refund?"
|
||||
},
|
||||
"schema": {
|
||||
"properties": {
|
||||
"model": {
|
||||
"default": "eu-jeff-latest",
|
||||
"description": "eu-jeff-latest follows the current release, eu-jeff-1.0.0 pins it",
|
||||
"title": "Model",
|
||||
"type": "string"
|
||||
},
|
||||
"questions": {
|
||||
"additionalProperties": {
|
||||
"discriminator": {
|
||||
"propertyName": "type"
|
||||
},
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"criteria": {
|
||||
"additionalProperties": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"items": {},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Free text, or a JSON object, array or null that eu/jev serializes to text before inference"
|
||||
},
|
||||
"description": "Two to 64 options, option name to description. A null description uses the option name",
|
||||
"maxProperties": 64,
|
||||
"minProperties": 2,
|
||||
"title": "Criteria",
|
||||
"type": "object"
|
||||
},
|
||||
"instructions": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"items": {},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Free text, or a JSON object, array or null that eu/jev serializes to text before inference",
|
||||
"title": "Instructions"
|
||||
},
|
||||
"type": {
|
||||
"const": "choice",
|
||||
"title": "Type",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"instructions",
|
||||
"criteria"
|
||||
],
|
||||
"title": "EuJevChoiceQuestion",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"criteria": {
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"items": {},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"propertyNames": {
|
||||
"enum": [
|
||||
"true",
|
||||
"false"
|
||||
]
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional descriptions of what true and false mean",
|
||||
"title": "Criteria"
|
||||
},
|
||||
"instructions": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"items": {},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Free text, or a JSON object, array or null that eu/jev serializes to text before inference",
|
||||
"title": "Instructions"
|
||||
},
|
||||
"type": {
|
||||
"const": "noul",
|
||||
"title": "Type",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"instructions"
|
||||
],
|
||||
"title": "EuJevNoulQuestion",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"criteria": {
|
||||
"description": "Two to 10 level descriptions, ordered from low to high",
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"items": {},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Free text, or a JSON object, array or null that eu/jev serializes to text before inference"
|
||||
},
|
||||
"maxItems": 10,
|
||||
"minItems": 2,
|
||||
"title": "Criteria",
|
||||
"type": "array"
|
||||
},
|
||||
"instructions": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"items": {},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Free text, or a JSON object, array or null that eu/jev serializes to text before inference",
|
||||
"title": "Instructions"
|
||||
},
|
||||
"type": {
|
||||
"const": "score",
|
||||
"title": "Type",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"instructions",
|
||||
"criteria"
|
||||
],
|
||||
"title": "EuJevScoreQuestion",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "One to eight questions, keyed by names of up to 80 characters",
|
||||
"maxProperties": 8,
|
||||
"minProperties": 1,
|
||||
"propertyNames": {
|
||||
"maxLength": 80,
|
||||
"minLength": 1
|
||||
},
|
||||
"title": "Questions",
|
||||
"type": "object"
|
||||
},
|
||||
"state": {
|
||||
"anyOf": [
|
||||
{
|
||||
"maxLength": 100000,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"items": {},
|
||||
"type": "array"
|
||||
}
|
||||
],
|
||||
"description": "The content to evaluate, a string of up to 100,000 characters or a JSON object or array",
|
||||
"title": "State"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"state",
|
||||
"questions"
|
||||
],
|
||||
"title": "EuJevDecisionRequest",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/EuJevDecisionResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Eu Jev Proxy Route",
|
||||
"tags": [
|
||||
"llm_passthrough"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/fal_ai/{endpoint}": {
|
||||
"delete": {
|
||||
"operationId": "fal_ai_proxy_route_fal_ai__endpoint__delete",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ from litellm.litellm_core_utils.url_utils import (
|
|||
from litellm.llms.azure.passthrough.transformation import azure_router_model_in_endpoint
|
||||
from litellm.llms.nvidia_nim.passthrough.transformation import nvidia_nim_model_group_in_path
|
||||
from litellm.proxy._types import *
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
from litellm.proxy.common_utils.http_parsing_utils import extract_nested_form_metadata
|
||||
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
|
||||
LITELLM_PASS_THROUGH_ENDPOINT_MARKER,
|
||||
|
|
@ -884,7 +885,6 @@ def route_in_additonal_public_routes(current_route: str):
|
|||
public_routes: ["LiteLLMRoutes.public_routes", "/spend/calculate", "/api/*"]
|
||||
```
|
||||
"""
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
from litellm.proxy.proxy_server import general_settings, premium_user
|
||||
|
||||
try:
|
||||
|
|
@ -2175,6 +2175,13 @@ def _extract_model_candidates_from_request(
|
|||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return _dedupe_model_candidates((f"{provider}/{decision_model}",))
|
||||
if RouteChecks.is_builtin_eu_jev_decision_route(route=route):
|
||||
from litellm.llms.eu_jev.common_utils import eu_jev_registry_model
|
||||
|
||||
requested_model: Final = TypeAdapter(Mapping[str, object]).validate_python(request_data).get("model")
|
||||
if requested_model is not None and not isinstance(requested_model, str):
|
||||
raise HTTPException(status_code=400, detail="eu_jev model must be a string")
|
||||
return [f"eu_jev/{eu_jev_registry_model(requested_model)}"]
|
||||
if route == "/cost/predict-cache":
|
||||
prediction_models: Final = _cache_prediction_model_candidates(request_data, llm_router, team_id) # pyright: ignore[reportUnknownArgumentType] # the typed reader validates each deployment ID from this legacy payload
|
||||
return _dedupe_model_candidates(prediction_models)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ from litellm.llms.custom_httpx.http_handler import (
|
|||
from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth
|
||||
|
||||
|
||||
class OAuth2TokenValidationError(ValueError):
|
||||
"""The token-info or introspection endpoint did not vouch for the token: it rejected it, reported it
|
||||
inactive, or could not be reached."""
|
||||
|
||||
|
||||
class Oauth2Handler:
|
||||
"""
|
||||
Handles OAuth2 token validation.
|
||||
|
|
@ -207,7 +212,7 @@ class Oauth2Handler:
|
|||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
# This will catch any 4xx or 5xx errors
|
||||
raise ValueError(f"Oauth 2.0 Token validation failed: {e}")
|
||||
raise OAuth2TokenValidationError(f"Oauth 2.0 Token validation failed: {e}")
|
||||
except Exception as e:
|
||||
# This will catch any other errors (like network issues)
|
||||
raise ValueError(f"An error occurred during token validation: {e}")
|
||||
raise OAuth2TokenValidationError(f"An error occurred during token validation: {e}")
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ _PROXY_ADMIN_VIEW_ONLY_BLOCKED_ROUTES: Final = frozenset(
|
|||
_PROXY_ADMIN_VIEW_ONLY_BLOCKED_KEY_SUFFIXES: Final = ("/regenerate", "/reset_spend")
|
||||
|
||||
_LLM_API_ROUTE_GROUPS: Final = frozenset(("llm_api_routes", "llm_api_routes_without_mcp"))
|
||||
_EU_JEV_DECISION_ROUTE: Final = "/eu_jev/v1/systemone"
|
||||
_AUTH_ENFORCED_PASS_THROUGH_ROUTE_GROUPS: Final = frozenset(("openai_routes", *_LLM_API_ROUTE_GROUPS))
|
||||
|
||||
# Writes that only touch the caller's own credential row (BYOK connections and
|
||||
|
|
@ -503,7 +504,19 @@ class RouteChecks:
|
|||
for _llm_passthrough_route in LiteLLMRoutes.mapped_pass_through_routes.value:
|
||||
if route == _llm_passthrough_route or route.startswith(_llm_passthrough_route + "/"):
|
||||
return True
|
||||
return False
|
||||
return RouteChecks.is_builtin_eu_jev_decision_route(route=route)
|
||||
|
||||
@staticmethod
|
||||
def is_builtin_eu_jev_decision_route(route: str) -> bool:
|
||||
if route.rstrip("/") != _EU_JEV_DECISION_ROUTE:
|
||||
return False
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( # noqa: PLC0415 # pass_through_endpoints imports the auth stack
|
||||
InitPassThroughEndpointHelpers,
|
||||
)
|
||||
|
||||
return not InitPassThroughEndpointHelpers.has_configured_route_ignoring_trailing_slash(
|
||||
route=_EU_JEV_DECISION_ROUTE
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_get_mcp_server_discovery_route(route: str, request: Request | None) -> bool:
|
||||
|
|
|
|||
|
|
@ -1160,6 +1160,24 @@ def _matches_routing_override(token_claims: dict, override: "JWTRoutingOverride"
|
|||
)
|
||||
|
||||
|
||||
def oauth2_introspection_applies(
|
||||
api_key: str, route: str, *, enable_oauth2_auth: bool, enable_jwt_auth: bool, jwt_handler: JWTHandler
|
||||
) -> bool:
|
||||
"""Whether LiteLLM validates ``api_key`` on ``route`` by OAuth2 token introspection, on LLM API, info and
|
||||
lens routes only: a non-JWT token when OAuth2 auth is on globally, or a JWT matched by a routing override."""
|
||||
is_jwt: Final = enable_jwt_auth and jwt_handler.is_jwt(token=api_key)
|
||||
# Routing uses unverified JWT claims only to choose auth path.
|
||||
# Final authentication is enforced by the selected validator.
|
||||
route_jwt_to_oauth2: Final = is_jwt and _should_route_jwt_to_oauth2_override(token=api_key, jwt_handler=jwt_handler)
|
||||
covered_route: Final = (
|
||||
RouteChecks.is_llm_api_route(route=route)
|
||||
or RouteChecks.is_info_route(route=route)
|
||||
or route == "/lens"
|
||||
or route.startswith("/lens/")
|
||||
)
|
||||
return covered_route and ((enable_oauth2_auth and not is_jwt) or route_jwt_to_oauth2)
|
||||
|
||||
|
||||
def _should_route_jwt_to_oauth2_override(token: str, jwt_handler: JWTHandler) -> bool:
|
||||
routing_overrides: Final = jwt_handler.litellm_jwtauth.routing_overrides
|
||||
if not routing_overrides:
|
||||
|
|
@ -2278,23 +2296,13 @@ async def user_api_key_auth_builder(
|
|||
|
||||
enable_oauth2_auth: Final = general_settings.get("enable_oauth2_auth", False) is True
|
||||
enable_jwt_auth: Final = general_settings.get("enable_jwt_auth", False) is True
|
||||
is_jwt = jwt_handler.is_jwt(token=api_key) if enable_jwt_auth else False
|
||||
|
||||
# Routing uses unverified JWT claims only to choose auth path.
|
||||
# Final authentication is enforced by the selected validator.
|
||||
route_jwt_to_oauth2 = is_jwt and _should_route_jwt_to_oauth2_override(token=api_key, jwt_handler=jwt_handler)
|
||||
|
||||
# OAuth2 applies for:
|
||||
# 1) when global OAuth2 auth is enabled on LLM + info routes
|
||||
# 2) JWT tokens that explicitly match routing_overrides on LLM + info routes
|
||||
route_is_lens: Final = route == "/lens" or route.startswith("/lens/")
|
||||
should_apply_override_oauth2: Final = route_jwt_to_oauth2 and (
|
||||
RouteChecks.is_llm_api_route(route=route) or RouteChecks.is_info_route(route=route) or route_is_lens
|
||||
)
|
||||
should_apply_global_oauth2: Final = enable_oauth2_auth and (
|
||||
RouteChecks.is_llm_api_route(route=route) or RouteChecks.is_info_route(route=route) or route_is_lens
|
||||
)
|
||||
if (should_apply_global_oauth2 and not is_jwt) or should_apply_override_oauth2:
|
||||
if oauth2_introspection_applies(
|
||||
api_key,
|
||||
route,
|
||||
enable_oauth2_auth=enable_oauth2_auth,
|
||||
enable_jwt_auth=enable_jwt_auth,
|
||||
jwt_handler=jwt_handler,
|
||||
):
|
||||
from litellm.proxy.proxy_server import premium_user
|
||||
|
||||
if premium_user is not True:
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ from litellm.proxy.common_utils.openai_error_payload import (
|
|||
error_status_code,
|
||||
openai_error_param,
|
||||
openai_error_type,
|
||||
relayed_provider_specific_fields,
|
||||
)
|
||||
from litellm.proxy.common_utils.sse_keepalive import (
|
||||
SSE_COMMENT_PING_BYTES,
|
||||
|
|
@ -589,6 +590,7 @@ def model_deployments_support_stream_options(
|
|||
|
||||
|
||||
_STREAM_USAGE_FLAGS: Final = ("_litellm_strip_stream_usage", STREAM_USAGE_INJECTED_FIELD)
|
||||
_STREAM_USAGE_TRACKED_ROUTES: Final = frozenset({"acompletion", "atext_completion"})
|
||||
|
||||
|
||||
def stream_usage_tracking_updates(
|
||||
|
|
@ -614,7 +616,7 @@ def stream_usage_tracking_updates(
|
|||
return scrub
|
||||
if always_include is False and route_type == "agenerate_content_stream" and "stream_options" not in data:
|
||||
return {**scrub, "stream_options": {"include_usage": False}}
|
||||
if always_include is False or route_type != "acompletion":
|
||||
if always_include is False or route_type not in _STREAM_USAGE_TRACKED_ROUTES:
|
||||
return scrub
|
||||
if isinstance(stream_options, dict) and stream_options.get("include_usage") is True:
|
||||
return scrub
|
||||
|
|
@ -4486,7 +4488,8 @@ class ProxyBaseLLMRequestProcessing:
|
|||
param=openai_error_param(e),
|
||||
openai_code=getattr(e, "code", None),
|
||||
code=_code,
|
||||
provider_specific_fields=getattr(e, "provider_specific_fields", None),
|
||||
provider_specific_fields=getattr(e, "provider_specific_fields", None)
|
||||
or relayed_provider_specific_fields(e),
|
||||
headers=safe_headers,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ from types import MappingProxyType
|
|||
from typing import Final
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._logging import redact_internal_details_from_client_fields
|
||||
from litellm.constants import STRINGIFIED_NONE
|
||||
from litellm.proxy._types import ProxyErrorTypes, ProxyException, proxy_exception_status_code
|
||||
from litellm.proxy.db.db_lookup_gate import DBLookupDeadlineExceeded
|
||||
|
|
@ -26,6 +28,8 @@ _OPENAI_ERROR_TYPE_BY_STATUS: Final[Mapping[int, str]] = MappingProxyType(
|
|||
|
||||
_AUTH_FAILURE_STATUS_CODES: Final = frozenset({status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN})
|
||||
|
||||
_ERROR_BODY: Final = TypeAdapter(dict[str, object])
|
||||
|
||||
_FAILOVER_SQLSTATES: Final = frozenset({"25006", "57P01", "57P02", "57P03"})
|
||||
|
||||
_FAILOVER_REFUSALS: Final = (
|
||||
|
|
@ -77,6 +81,18 @@ def openai_error_param(exc: object) -> str | None:
|
|||
return carried if isinstance(carried, str) and carried != STRINGIFIED_NONE else None
|
||||
|
||||
|
||||
def relayed_provider_specific_fields(
|
||||
exc: object,
|
||||
) -> dict[str, object] | None: # mutable-ok: ProxyException.provider_specific_fields is a dict
|
||||
body: Final = attribute_of(exc, "body")
|
||||
if not isinstance(body, dict):
|
||||
return None
|
||||
fields: Final = _ERROR_BODY.validate_python(body).get("provider_specific_fields")
|
||||
if not isinstance(fields, dict):
|
||||
return None
|
||||
return _ERROR_BODY.validate_python(redact_internal_details_from_client_fields(_ERROR_BODY.validate_python(fields)))
|
||||
|
||||
|
||||
def proxy_exception_for(exc: Exception, default_status_code: int) -> ProxyException:
|
||||
match exc:
|
||||
case ProxyException():
|
||||
|
|
|
|||
|
|
@ -664,4 +664,7 @@ class GenericGuardrailAPI(CustomGuardrail):
|
|||
GuardrailEventHooks.pre_call,
|
||||
GuardrailEventHooks.post_call,
|
||||
GuardrailEventHooks.during_call,
|
||||
GuardrailEventHooks.pre_mcp_call,
|
||||
GuardrailEventHooks.during_mcp_call,
|
||||
GuardrailEventHooks.post_mcp_call,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2505,6 +2505,7 @@ def refresh_proxy_server_request_body_snapshot(
|
|||
"litellm_logging_obj",
|
||||
VECTOR_STORE_CALLER_METADATA_KWARG,
|
||||
STREAM_USAGE_INJECTED_FIELD,
|
||||
"_litellm_strip_stream_usage",
|
||||
}
|
||||
)
|
||||
| _TRANSPORT_ONLY_CREDENTIAL_KEYS
|
||||
|
|
|
|||
|
|
@ -68,8 +68,11 @@ module materially harder to understand.
|
|||
behind a single generic branch unless tests prove every mode still behaves
|
||||
correctly.
|
||||
- Be especially careful with legacy `delegate_auth_to_upstream: true`. `auth_type: oauth2`
|
||||
with `delegate_auth_to_upstream: true` is deprecated: LiteLLM admission is required
|
||||
for matching MCP routes. Use `auth_type: oauth_delegate` for client-forwarded OAuth.
|
||||
with `delegate_auth_to_upstream: true` is deprecated but still supported. A bare
|
||||
`Authorization` that is a LiteLLM credential is validated and never forwarded. Any other
|
||||
bearer is the upstream token: it is forwarded, and the caller is admitted as the JWT or
|
||||
custom-auth user it also proves when that user is granted the server, else anonymously.
|
||||
New servers should use `auth_type: true_passthrough` or `auth_type: oauth_delegate`.
|
||||
OAuth discovery endpoints stay public so clients can start the RFC 9728 flow.
|
||||
- Keep database-backed fields in sync across migrations, typed models under
|
||||
`litellm/types/mcp.py` or `litellm/types/mcp_server/`, config loading, this
|
||||
|
|
|
|||
|
|
@ -2,14 +2,15 @@ import asyncio
|
|||
import functools
|
||||
import re
|
||||
import secrets
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence
|
||||
from collections.abc import Set as AbstractSet
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from itertools import chain
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final, Literal
|
||||
from typing import TYPE_CHECKING, Final, Literal, TypeAlias
|
||||
|
||||
import jwt
|
||||
from fastapi import HTTPException
|
||||
from pydantic import SecretStr, TypeAdapter
|
||||
from starlette.datastructures import Headers
|
||||
|
|
@ -45,9 +46,11 @@ from litellm.proxy.agent_endpoints.auth.agent_access_groups import (
|
|||
from litellm.proxy.agent_endpoints.auth.agent_caller import agent_caller_auth
|
||||
from litellm.proxy.agent_endpoints.auth.managed_authorization import managed_agent_policy
|
||||
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
|
||||
from litellm.proxy.auth.oauth2_check import OAuth2TokenValidationError
|
||||
from litellm.proxy.auth.user_api_key_auth import ( # noqa: F401 # legacy module exports
|
||||
_get_bearer_token_or_received_api_key, # pyright: ignore[reportPrivateUsage] # shared x-litellm-api-key parser lives with user_api_key_auth
|
||||
_run_centralized_common_checks, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
|
||||
oauth2_introspection_applies,
|
||||
run_centralized_common_checks,
|
||||
user_api_key_auth,
|
||||
)
|
||||
|
|
@ -100,6 +103,9 @@ if TYPE_CHECKING:
|
|||
|
||||
_EMPTY_TOOLSET_GRANTS: Final[Mapping[str, Sequence[str]]] = MappingProxyType({})
|
||||
OPTIONAL_STRING_ADAPTER: Final[TypeAdapter[str | None]] = TypeAdapter(str | None)
|
||||
_PROXY_SETTINGS_ADAPTER: Final = TypeAdapter(Mapping[str, object])
|
||||
_CAUSE_CHAIN_MAX_DEPTH: Final = 16
|
||||
_IdentityCheck: TypeAlias = Literal["oauth2", "oauth2_proxy", "jwt"]
|
||||
_CACHED_VALUE: Final = TypeAdapter(object)
|
||||
|
||||
|
||||
|
|
@ -233,6 +239,136 @@ def _is_legacy_delegate_cold_start(mcp_servers: list[str] | None, client_ip: str
|
|||
return True
|
||||
|
||||
|
||||
def _cause_chain(exc: BaseException) -> Iterator[BaseException]:
|
||||
link: BaseException | None = exc # rebind-ok: advances one link of the cause chain per iteration
|
||||
for _ in range(_CAUSE_CHAIN_MAX_DEPTH):
|
||||
if link is None:
|
||||
return
|
||||
yield link
|
||||
link = link.__cause__ or link.__context__
|
||||
|
||||
|
||||
def _caused_by(exc: BaseException, error_type: type[BaseException]) -> bool:
|
||||
return any(isinstance(link, error_type) for link in _cause_chain(exc))
|
||||
|
||||
|
||||
def _signed_with(token: str, secret: str) -> bool:
|
||||
"""True when ``token`` is a JWT whose HS256 signature verifies with ``secret``, whatever its claims say."""
|
||||
try:
|
||||
jwt.decode(
|
||||
token,
|
||||
secret,
|
||||
algorithms=["HS256"],
|
||||
options={"verify_exp": False, "verify_nbf": False, "verify_iat": False, "verify_aud": False},
|
||||
)
|
||||
except jwt.PyJWTError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def _bearer_is_litellm_credential(token: str) -> bool:
|
||||
"""True when ``token`` is, or may be, a credential LiteLLM issued: the master key or a JWT it signed
|
||||
(dashboard sessions, BYOK sessions), a sealed gateway bearer, or a virtual key the key table holds (a
|
||||
lookup fault counts as held). Only ``sk-`` tokens are looked up, because no other token can be a
|
||||
virtual key."""
|
||||
from litellm.proxy.mcp_server.bridge_token_flow import ( # noqa: PLC0415 # bridge flow imports this module
|
||||
is_sealed_gateway_bearer,
|
||||
virtual_key_may_exist,
|
||||
)
|
||||
from litellm.proxy.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # manager imports this module
|
||||
LITELLM_VIRTUAL_KEY_PREFIX,
|
||||
)
|
||||
from litellm.proxy.proxy_server import master_key # noqa: PLC0415 # startup owns the master key
|
||||
|
||||
if master_key and (secrets.compare_digest(token.encode(), master_key.encode()) or _signed_with(token, master_key)):
|
||||
return True
|
||||
if is_sealed_gateway_bearer(token):
|
||||
return True
|
||||
return token.startswith(LITELLM_VIRTUAL_KEY_PREFIX) and await virtual_key_may_exist(token)
|
||||
|
||||
|
||||
def _identity_check(token: str, route: str, settings: Mapping[str, object]) -> _IdentityCheck | None:
|
||||
"""The identity check LiteLLM's own auth would run on ``token`` for ``route``, in its order: OAuth2
|
||||
introspection, then trusted reverse-proxy identity headers, then a JWT whose issuer may be LiteLLM's."""
|
||||
from litellm.proxy.auth.handle_jwt import JWTHandler # noqa: PLC0415 # proxy import cycle
|
||||
from litellm.proxy.mcp_server.bridge_token_flow import ( # noqa: PLC0415 # bridge flow imports this module
|
||||
jwt_issuer_may_be_litellm,
|
||||
)
|
||||
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # startup owns the active auth configuration
|
||||
jwt_handler,
|
||||
premium_user,
|
||||
)
|
||||
|
||||
enable_jwt_auth: Final = settings.get("enable_jwt_auth") is True
|
||||
if oauth2_introspection_applies(
|
||||
token,
|
||||
route,
|
||||
enable_oauth2_auth=settings.get("enable_oauth2_auth") is True,
|
||||
enable_jwt_auth=enable_jwt_auth,
|
||||
jwt_handler=jwt_handler,
|
||||
):
|
||||
return "oauth2" if premium_user is True else None
|
||||
if settings.get("enable_oauth2_proxy_auth") is True:
|
||||
return "oauth2_proxy"
|
||||
if enable_jwt_auth and premium_user is True and JWTHandler.is_jwt(token=token) and jwt_issuer_may_be_litellm(token):
|
||||
return "jwt"
|
||||
return None
|
||||
|
||||
|
||||
async def _verify_identity(check: Literal["oauth2_proxy", "jwt"], token: str, request: Request) -> None:
|
||||
from litellm.proxy.auth.oauth2_proxy_hook import ( # noqa: PLC0415 # proxy import cycle
|
||||
handle_oauth2_proxy_request,
|
||||
)
|
||||
from litellm.proxy.proxy_server import jwt_handler # noqa: PLC0415 # startup owns the active auth configuration
|
||||
|
||||
match check:
|
||||
case "oauth2_proxy":
|
||||
await handle_oauth2_proxy_request(request=request)
|
||||
case "jwt":
|
||||
await jwt_handler.auth_jwt(token=token) # pyright: ignore[reportUnknownMemberType] # returns a bare dict; only whether it raises matters
|
||||
case _: # pragma: no cover - exhaustiveness guard, unreachable while the Literal holds
|
||||
assert_never(check) # pragma: no cover
|
||||
|
||||
|
||||
async def _upstream_bearer_identity(token: str, litellm_api_key: str, request: Request) -> UserAPIKeyAuth | None:
|
||||
"""The LiteLLM user an upstream bearer also proves, or ``None`` when it proves none.
|
||||
|
||||
Custom auth decides alone, so any rejection is ``None``. Otherwise the identity check LiteLLM would run
|
||||
must vouch for the caller. Reverse-proxy headers and a JWT are verified first, and OAuth2 introspection
|
||||
runs once inside the full check, where a provider that does not vouch is ``None``. Past that, only a
|
||||
user LiteLLM does not know is ``None``; every other refusal (budget, blocked team, disallowed route)
|
||||
propagates."""
|
||||
from litellm.proxy.auth.auth_utils import get_request_route # noqa: PLC0415 # proxy import cycle
|
||||
from litellm.proxy.mcp_server.bridge_token_flow import ( # noqa: PLC0415 # bridge flow imports this module
|
||||
custom_auth_configured,
|
||||
)
|
||||
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # startup owns the active auth configuration
|
||||
general_settings, # pyright: ignore[reportUnknownVariableType] # legacy global is declared as a bare dict
|
||||
)
|
||||
|
||||
if custom_auth_configured():
|
||||
try:
|
||||
return await user_api_key_auth(api_key=litellm_api_key, request=request)
|
||||
except (HTTPException, ProxyException):
|
||||
return None
|
||||
check: Final = _identity_check(
|
||||
token, get_request_route(request), _PROXY_SETTINGS_ADAPTER.validate_python(general_settings)
|
||||
)
|
||||
if check is None:
|
||||
return None
|
||||
if check != "oauth2":
|
||||
try:
|
||||
await _verify_identity(check, token, request)
|
||||
except Exception: # noqa: BLE001 # any verification failure means LiteLLM does not vouch for this bearer
|
||||
return None
|
||||
try:
|
||||
return await user_api_key_auth(api_key=litellm_api_key, request=request)
|
||||
except (HTTPException, ProxyException, OAuth2TokenValidationError) as exc:
|
||||
if _caused_by(exc, UserNotFoundError) or _caused_by(exc, OAuth2TokenValidationError):
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
def _is_litellm_auth_admission_error(exc: Exception) -> bool:
|
||||
if isinstance(exc, HTTPException):
|
||||
return exc.status_code == 401
|
||||
|
|
@ -704,6 +840,19 @@ class MCPRequestHandler:
|
|||
route=request_route,
|
||||
mcp_servers=mcp_servers,
|
||||
)
|
||||
elif oauth2_headers and (
|
||||
legacy_delegate_targets := MCPRequestHandler._legacy_delegate_targets(
|
||||
path=request_route,
|
||||
mcp_servers=mcp_servers,
|
||||
client_ip=IPAddressUtils.get_mcp_client_ip(request),
|
||||
)
|
||||
):
|
||||
validated_user_api_key_auth = await MCPRequestHandler._admit_legacy_delegate_bearer( # rebind-ok: one assignment per admission arm, like its siblings
|
||||
authorization_value=oauth2_headers["Authorization"],
|
||||
litellm_api_key=litellm_api_key,
|
||||
targets=legacy_delegate_targets,
|
||||
request=request,
|
||||
)
|
||||
elif oauth2_headers:
|
||||
# Authorization on a non-delegated server: the bearer must be a real
|
||||
# LiteLLM credential, so a failed validation is a genuine 401/403 and
|
||||
|
|
@ -741,10 +890,14 @@ class MCPRequestHandler:
|
|||
custom_key_header_name: Final = OPTIONAL_STRING_ADAPTER.validate_python(
|
||||
general_settings.get("litellm_key_header_name")
|
||||
)
|
||||
admitted_credential: Final = MCPRequestHandler.caller_admission_credential(
|
||||
headers,
|
||||
validated_user_api_key_auth,
|
||||
custom_key_header_name=custom_key_header_name,
|
||||
admitted_credential: Final = (
|
||||
None
|
||||
if validated_user_api_key_auth.mcp_forwards_caller_authorization
|
||||
else MCPRequestHandler.caller_admission_credential(
|
||||
headers,
|
||||
validated_user_api_key_auth,
|
||||
custom_key_header_name=custom_key_header_name,
|
||||
)
|
||||
)
|
||||
(
|
||||
oauth2_headers,
|
||||
|
|
@ -934,6 +1087,53 @@ class MCPRequestHandler:
|
|||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _legacy_delegate_targets(
|
||||
path: str, mcp_servers: Sequence[str] | None, client_ip: str | None
|
||||
) -> tuple[MCPServer, ...]:
|
||||
"""Every server the request targets when ALL of them are legacy ``delegate_auth_to_upstream``
|
||||
servers, else ``()``. Names resolve the way downstream routing resolves them, so a permissive
|
||||
``x-mcp-servers`` header cannot open the gate for a stricter path target."""
|
||||
from litellm.proxy.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # manager imports this module
|
||||
global_mcp_server_manager,
|
||||
is_legacy_upstream_delegate,
|
||||
)
|
||||
|
||||
servers: Final = tuple(
|
||||
global_mcp_server_manager.get_mcp_server_by_name(name, client_ip=client_ip)
|
||||
for name in MCPRequestHandler._resolve_target_server_names(path=path, mcp_servers_header=mcp_servers)
|
||||
)
|
||||
delegates: Final = tuple(
|
||||
server for server in servers if server is not None and is_legacy_upstream_delegate(server)
|
||||
)
|
||||
return delegates if servers and len(delegates) == len(servers) else ()
|
||||
|
||||
@staticmethod
|
||||
async def _admit_legacy_delegate_bearer(
|
||||
authorization_value: str,
|
||||
litellm_api_key: str,
|
||||
targets: tuple[MCPServer, ...],
|
||||
request: Request,
|
||||
) -> UserAPIKeyAuth:
|
||||
"""Admit a bare ``Authorization`` bearer sent to legacy ``delegate_auth_to_upstream`` servers.
|
||||
|
||||
A LiteLLM credential runs normal admission, and its errors propagate. Any other bearer is the
|
||||
upstream token and is marked for forwarding: a user it also proves who is granted every target is
|
||||
admitted as that user, and every other caller is admitted anonymously."""
|
||||
from litellm.proxy.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # manager imports this module
|
||||
global_mcp_server_manager,
|
||||
)
|
||||
|
||||
token: Final = _normalize_caller_admission_credential(authorization_value)
|
||||
if await _bearer_is_litellm_credential(token):
|
||||
return await user_api_key_auth(api_key=litellm_api_key, request=request)
|
||||
identity: Final = await _upstream_bearer_identity(token, litellm_api_key, request)
|
||||
if identity is not None:
|
||||
allowed: Final = frozenset(await global_mcp_server_manager.get_allowed_mcp_servers(identity))
|
||||
if all(server.server_id in allowed for server in targets):
|
||||
return identity.model_copy(update={"mcp_forwards_caller_authorization": True})
|
||||
return UserAPIKeyAuth().model_copy(update={"mcp_forwards_caller_authorization": True})
|
||||
|
||||
@staticmethod
|
||||
def _single_dcr_bridge_delegate_target(
|
||||
path: str, mcp_servers: list[str] | None, client_ip: str | None
|
||||
|
|
@ -1553,7 +1753,7 @@ class MCPRequestHandler:
|
|||
return expiry >= datetime.now(timezone.utc)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_target_server_names(path: str, mcp_servers_header: list[str] | None) -> list[str]:
|
||||
def _resolve_target_server_names(path: str, mcp_servers_header: Sequence[str] | None) -> Sequence[str]:
|
||||
"""
|
||||
Resolve the target MCP server names exactly as downstream routing
|
||||
does (``server.py::extract_mcp_auth_context``).
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@ async def oauth_authorization_uses_gateway_credential(request: Request) -> bool:
|
|||
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # startup owns the active auth configuration
|
||||
jwt_handler,
|
||||
master_key,
|
||||
user_custom_auth,
|
||||
)
|
||||
|
||||
if "x-litellm-api-key" in request.headers:
|
||||
|
|
@ -71,10 +70,26 @@ async def oauth_authorization_uses_gateway_credential(request: Request) -> bool:
|
|||
return "authorization" in request.headers
|
||||
if token.startswith("sk-") or (master_key and secrets.compare_digest(token.encode(), master_key.encode())):
|
||||
return True
|
||||
if user_custom_auth is not None or jwt_handler.litellm_jwtauth.oidc_userinfo_enabled:
|
||||
if custom_auth_configured() or jwt_handler.litellm_jwtauth.oidc_userinfo_enabled:
|
||||
return True
|
||||
if not JWTHandler.is_jwt(token):
|
||||
return await _opaque_bearer_is_gateway_credential(token)
|
||||
return is_sealed_gateway_bearer(token) or await virtual_key_may_exist(token)
|
||||
return jwt_issuer_may_be_litellm(token)
|
||||
|
||||
|
||||
def custom_auth_configured() -> bool:
|
||||
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # startup owns the active auth configuration
|
||||
user_custom_auth,
|
||||
)
|
||||
|
||||
return user_custom_auth is not None
|
||||
|
||||
|
||||
def jwt_issuer_may_be_litellm(token: str) -> bool:
|
||||
"""False only when the JWT names an issuer LiteLLM's JWT auth is not configured to accept."""
|
||||
from litellm.proxy.auth.handle_jwt import JWTHandler # noqa: PLC0415 # proxy import cycle
|
||||
from litellm.proxy.proxy_server import jwt_handler # noqa: PLC0415 # startup owns the active auth configuration
|
||||
|
||||
claims: Final = JWTHandler.get_unverified_claims(token)
|
||||
issuer: Final = claims.get("iss") if claims is not None else None
|
||||
global_issuer: Final = os.getenv("JWT_ISSUER")
|
||||
|
|
@ -86,25 +101,35 @@ async def oauth_authorization_uses_gateway_credential(request: Request) -> bool:
|
|||
)
|
||||
|
||||
|
||||
async def _opaque_bearer_is_gateway_credential(token: str) -> bool:
|
||||
from litellm.proxy._types import hash_token # noqa: PLC0415 # proxy import cycle
|
||||
def is_sealed_gateway_bearer(token: str) -> bool:
|
||||
"""True for a bearer only the gateway issues: a bridge or refresh envelope, a v2 GCM blob, or a
|
||||
dashboard UI token. A UI token that cannot be read still counts, so a lookup fault never reads as foreign."""
|
||||
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken # noqa: PLC0415 # proxy import cycle
|
||||
from litellm.proxy.auth.resolvers.exceptions import KeyNotFoundError # noqa: PLC0415 # proxy import cycle
|
||||
from litellm.proxy.auth.resolvers.store import IdentityStore # noqa: PLC0415 # proxy import cycle
|
||||
from litellm.proxy.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # envelope imports bridge types
|
||||
is_envelope,
|
||||
is_refresh_envelope,
|
||||
)
|
||||
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # startup owns the identity store dependencies
|
||||
prisma_client,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
if is_envelope(token) or is_refresh_envelope(token) or token.startswith(V2_GCM_PREFIX):
|
||||
return True
|
||||
try:
|
||||
if ExperimentalUIJWTToken.get_key_object_from_ui_hash_key(token) is not None:
|
||||
return True
|
||||
return ExperimentalUIJWTToken.get_key_object_from_ui_hash_key(token) is not None
|
||||
except Exception as exc: # noqa: BLE001 # an identity lookup fault must not permit cookie fallback
|
||||
verbose_logger.debug("OAuth bearer ownership could not be checked (%s)", type(exc).__name__)
|
||||
return True
|
||||
|
||||
|
||||
async def virtual_key_may_exist(token: str) -> bool:
|
||||
"""False only when the key table answers that ``token`` is not a virtual key; a lookup fault counts as held."""
|
||||
from litellm.proxy._types import hash_token # noqa: PLC0415 # proxy import cycle
|
||||
from litellm.proxy.auth.resolvers.exceptions import KeyNotFoundError # noqa: PLC0415 # proxy import cycle
|
||||
from litellm.proxy.auth.resolvers.store import IdentityStore # noqa: PLC0415 # proxy import cycle
|
||||
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # startup owns the identity store dependencies
|
||||
prisma_client,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
try:
|
||||
await IdentityStore(prisma_client, user_api_key_cache).resolve(hashed_token=hash_token(token))
|
||||
except KeyNotFoundError:
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -1196,8 +1196,11 @@ def should_strip_caller_authorization(
|
|||
pass-through cold-start case (RFC 9728) the bearer in
|
||||
``Authorization`` is the upstream OAuth token and must be
|
||||
forwarded, so we keep it.
|
||||
- **Delegated OAuth servers**: admission always runs and there is no
|
||||
anonymous path, so the caller's separate ``Authorization`` is
|
||||
- **Legacy delegate servers**: forward when MCP admission marked the
|
||||
caller's ``Authorization`` as the upstream token
|
||||
(``mcp_forwards_caller_authorization``); otherwise follow the
|
||||
delegated OAuth rule below.
|
||||
- **Delegated OAuth servers**: the caller's separate ``Authorization`` is
|
||||
forwarded only when a distinct ``x-litellm-api-key`` carried
|
||||
admission. Without that header the ``Authorization`` *was* the
|
||||
admission credential — a virtual key, an IdP JWT, or an SSO / OIDC /
|
||||
|
|
@ -1206,6 +1209,12 @@ def should_strip_caller_authorization(
|
|||
- **Every other auth-enabled mode**: strip when ``Authorization`` is the
|
||||
credential admission validated.
|
||||
"""
|
||||
if (
|
||||
user_api_key_auth is not None
|
||||
and user_api_key_auth.mcp_forwards_caller_authorization
|
||||
and is_legacy_upstream_delegate(mcp_server)
|
||||
):
|
||||
return False
|
||||
if _authorization_is_admission_jwt(raw_headers, user_api_key_auth):
|
||||
return True
|
||||
if mcp_server.auth_type == MCPAuth.oauth2_token_exchange:
|
||||
|
|
@ -1557,6 +1566,18 @@ async def _materialize_auth_headers(auth: httpx2.Auth | None) -> dict[str, str]
|
|||
return {header_name: header_value} if header_value else None
|
||||
|
||||
|
||||
def is_legacy_upstream_delegate(server: MCPServer) -> bool:
|
||||
"""True for an interactive ``auth_type=oauth2`` server with ``delegate_auth_to_upstream=true``, whose
|
||||
caller ``Authorization`` is the upstream token. The flow is resolved, not read from the column, so an
|
||||
unstamped M2M-shaped row stays excluded: its stored client credentials drive egress, and admitting it
|
||||
anonymously would let any caller act as LiteLLM's service account."""
|
||||
return (
|
||||
server.auth_type == MCPAuth.oauth2
|
||||
and server.delegate_auth_to_upstream is True
|
||||
and MCPServerManager.effective_oauth2_flow(server) != "client_credentials"
|
||||
)
|
||||
|
||||
|
||||
def _consumes_caller_authorization(server: MCPServer) -> bool:
|
||||
"""True when this server's egress forwards the caller's request-wide ``Authorization`` upstream:
|
||||
the client-forwarded token modes, legacy OAuth pass-through, and legacy upstream-delegated
|
||||
|
|
@ -1726,18 +1747,15 @@ def _warn_overlapping_tool_policies(server: MCPServer) -> None:
|
|||
|
||||
|
||||
def _warn_legacy_delegate_auth_if_applicable(server: MCPServer, *, source: str) -> None:
|
||||
"""Direct legacy delegated OAuth configurations to the admitted replacement."""
|
||||
if server.auth_type != MCPAuth.oauth2:
|
||||
return
|
||||
if getattr(server, "delegate_auth_to_upstream", False) is not True:
|
||||
return
|
||||
if server.has_client_credentials:
|
||||
"""Point legacy delegated OAuth configurations at the client-forwarded replacements."""
|
||||
if not is_legacy_upstream_delegate(server):
|
||||
return
|
||||
label: Final = get_server_prefix(server)
|
||||
verbose_logger.warning(
|
||||
"MCP server %r (id=%s, source=%s) uses deprecated auth_type=oauth2 with "
|
||||
"delegate_auth_to_upstream=true. LiteLLM admission is now required; migrate to "
|
||||
"auth_type=oauth_delegate for client-forwarded OAuth.",
|
||||
"delegate_auth_to_upstream=true. It still works: a caller without a LiteLLM credential is "
|
||||
"admitted anonymously and its upstream token is forwarded. Migrate to auth_type=true_passthrough "
|
||||
"or auth_type=oauth_delegate.",
|
||||
label,
|
||||
server.server_id,
|
||||
source,
|
||||
|
|
@ -3766,12 +3784,12 @@ class MCPServerManager:
|
|||
)
|
||||
)
|
||||
if is_anonymous:
|
||||
passthrough_server_ids: Final = [
|
||||
anonymous_server_ids: Final = [
|
||||
server.server_id
|
||||
for server in self.get_registry().values()
|
||||
if server.auth_type == MCPAuth.true_passthrough
|
||||
if server.auth_type == MCPAuth.true_passthrough or is_legacy_upstream_delegate(server)
|
||||
]
|
||||
combined_servers.update(passthrough_server_ids)
|
||||
combined_servers.update(anonymous_server_ids)
|
||||
|
||||
restrict_allow_all: Final = (
|
||||
resolved_general_settings.get("mcp_allow_all_keys_respects_mcp_scope", False)
|
||||
|
|
|
|||
|
|
@ -588,6 +588,7 @@ if MCP_AVAILABLE:
|
|||
from litellm.proxy.mcp_server.mcp_server_manager import (
|
||||
MCPServerManager,
|
||||
global_mcp_server_manager,
|
||||
is_legacy_upstream_delegate,
|
||||
)
|
||||
|
||||
######################################################
|
||||
|
|
@ -2162,8 +2163,9 @@ if MCP_AVAILABLE:
|
|||
oauth2_headers: Mapping[str, str] | None,
|
||||
mcp_server_auth_headers: dict[str, dict[str, str]] | None,
|
||||
raw_headers: Mapping[str, str] | None,
|
||||
requested_servers: Sequence[str] | None = None,
|
||||
) -> None:
|
||||
"""Probe pass-through upstream servers in parallel before the MCP session starts.
|
||||
"""Probe pass-through and legacy delegate upstream servers in parallel before the MCP session starts.
|
||||
|
||||
Only servers the caller's key is already authorized to reach are probed —
|
||||
the list is derived from _get_allowed_mcp_servers so that a user cannot
|
||||
|
|
@ -2186,6 +2188,18 @@ if MCP_AVAILABLE:
|
|||
mcp_servers=mcp_servers,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
# A legacy delegate is probed only when the single requested name resolves to it the way admission
|
||||
# resolves it, so neither a multi-server connect nor an access group can be failed by one rejected
|
||||
# token; its challenge echoes the requested name, matching the tokenless challenge.
|
||||
requested_scope: Final = requested_servers if requested_servers is not None else mcp_servers
|
||||
single_requested_name: Final = (
|
||||
requested_scope[0] if requested_scope is not None and len(requested_scope) == 1 else None
|
||||
)
|
||||
requested_server: Final = (
|
||||
operations.global_mcp_server_manager.get_mcp_server_by_name(single_requested_name, client_ip=client_ip)
|
||||
if single_requested_name is not None
|
||||
else None
|
||||
)
|
||||
prepared_headers: Final = tuple(
|
||||
(
|
||||
srv,
|
||||
|
|
@ -2201,6 +2215,11 @@ if MCP_AVAILABLE:
|
|||
)
|
||||
for srv in allowed_servers
|
||||
if srv.is_oauth_passthrough
|
||||
or (
|
||||
requested_server is not None
|
||||
and srv.server_id == requested_server.server_id
|
||||
and is_legacy_upstream_delegate(srv)
|
||||
)
|
||||
)
|
||||
probe_headers: Final = tuple(
|
||||
(
|
||||
|
|
@ -2213,7 +2232,7 @@ if MCP_AVAILABLE:
|
|||
for srv, (auth, extra) in prepared_headers
|
||||
)
|
||||
probe_targets: Final[tuple[tuple[MCPServer, str, str], ...]] = tuple(
|
||||
(srv, auth_header, srv.name)
|
||||
(srv, auth_header, srv.name if srv.is_oauth_passthrough else single_requested_name or srv.name)
|
||||
for srv, headers in probe_headers
|
||||
if (
|
||||
auth_header := next(
|
||||
|
|
@ -2311,6 +2330,7 @@ if MCP_AVAILABLE:
|
|||
if context.mcp_server_auth_headers is not None
|
||||
else None,
|
||||
raw_headers=context.raw_headers,
|
||||
requested_servers=context.mcp_servers,
|
||||
)
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import LiteLLMRoutes
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
|
||||
|
||||
class BillableCategory(str, Enum):
|
||||
|
|
@ -116,6 +117,8 @@ def _classify_llm_route(path: str) -> str | None:
|
|||
suffix_match = next((suffix for suffix in _LLM_ROUTE_SUFFIXES if path == suffix or path.endswith(suffix)), None)
|
||||
if suffix_match is not None:
|
||||
return suffix_match
|
||||
if RouteChecks.is_builtin_eu_jev_decision_route(route=path):
|
||||
return path
|
||||
# Deep passthrough paths only: the bare prefix itself is not an inference call.
|
||||
return next((prefix for prefix in _PASSTHROUGH_PREFIXES if path.startswith(f"{prefix}/")), None)
|
||||
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ from litellm.proxy.common_request_processing import (
|
|||
open_sse_before_first_byte,
|
||||
resolve_litellm_call_id,
|
||||
)
|
||||
from litellm.proxy.common_utils.custom_openapi_spec import inline_request_body
|
||||
from litellm.proxy.common_utils.http_parsing_utils import ( # noqa: F401 # legacy module exports
|
||||
_read_request_body, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
|
||||
_safe_get_request_headers, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
|
||||
|
|
@ -119,6 +120,7 @@ from litellm.proxy.pass_through_endpoints.injected_stream_usage import (
|
|||
)
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
||||
HttpPassThroughEndpointHelpers,
|
||||
YieldsToConfiguredPassThroughRoute,
|
||||
create_pass_through_route,
|
||||
create_websocket_passthrough_route,
|
||||
websocket_passthrough_request,
|
||||
|
|
@ -132,6 +134,7 @@ from litellm.proxy.vector_store_endpoints.utils import (
|
|||
is_allowed_to_call_vector_store_endpoint,
|
||||
)
|
||||
from litellm.secret_managers.main import get_secret_str, normalize_nonempty_secret_str, str_to_bool
|
||||
from litellm.types.passthrough_endpoints.eu_jev import EuJevDecisionRequest, EuJevDecisionResponse
|
||||
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
|
||||
LITELLM_PASS_THROUGH_CUSTOM_BODY_STATE_KEY,
|
||||
LITELLM_PASS_THROUGH_DEPLOYMENT_MODEL_INFO_STATE_KEY,
|
||||
|
|
@ -714,6 +717,85 @@ def typesafe_allowlist_model_names() -> tuple[str, ...]:
|
|||
return ("typesafe/*", *jev_models_typesafe_serves)
|
||||
|
||||
|
||||
_EU_JEV_DECISION_REQUEST_BODY: Final = inline_request_body(
|
||||
EuJevDecisionRequest,
|
||||
{
|
||||
"model": "eu-jeff-latest",
|
||||
"state": "I was charged twice. Can I get a refund?",
|
||||
"questions": {
|
||||
"team": {
|
||||
"type": "choice",
|
||||
"instructions": "Which team should handle this?",
|
||||
"criteria": {"billing": "Payments, invoices, and refunds", "support": "Technical issues and bugs"},
|
||||
},
|
||||
"is_refund_request": {"type": "noul", "instructions": "Is the customer asking for a refund?"},
|
||||
"frustration": {
|
||||
"type": "score",
|
||||
"instructions": "How frustrated is the customer?",
|
||||
"criteria": ["calm", "mildly annoyed", "angry"],
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
eu_jev_router: Final = APIRouter(route_class=YieldsToConfiguredPassThroughRoute)
|
||||
|
||||
|
||||
@eu_jev_router.post(
|
||||
"/eu_jev/v1/systemone",
|
||||
tags=["eu/jev Pass-through", "pass-through"],
|
||||
responses={200: {"model": EuJevDecisionResponse}},
|
||||
openapi_extra={"requestBody": _EU_JEV_DECISION_REQUEST_BODY},
|
||||
)
|
||||
async def eu_jev_proxy_route(
|
||||
request: Request,
|
||||
fastapi_response: Response,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
) -> Response:
|
||||
"""
|
||||
Forward a request to eu/jev, an EU-hosted decision API, with `EU_JEV_API_KEY`.
|
||||
|
||||
`POST /eu_jev/v1/systemone` sends a `state` and one to eight named `questions` (`choice`, `noul` or `score`)
|
||||
and returns one answer per question name, with spend booked from the reported `usage`. `custom_body` and
|
||||
`stream` are rejected with a 400. A pass-through endpoint configured at this path keeps serving it.
|
||||
|
||||
[Docs](https://docs.litellm.ai/docs/pass_through/eu_jev)
|
||||
"""
|
||||
body: Final = TypeAdapter(dict[str, object]).validate_python(await read_request_body(request))
|
||||
if "custom_body" in body:
|
||||
raise HTTPException(status_code=400, detail="custom_body is not supported for eu_jev requests")
|
||||
if body.get("stream"):
|
||||
raise HTTPException(status_code=400, detail="Streaming is not supported for eu_jev requests")
|
||||
eu_jev_credentials: Final = passthrough_endpoint_router.get_proxy_wide_credentials(custom_llm_provider="eu_jev")
|
||||
if eu_jev_credentials is None:
|
||||
raise HTTPException(status_code=503, detail="eu_jev is not configured, set EU_JEV_API_KEY on the proxy")
|
||||
base_url: Final = httpx.URL(
|
||||
(eu_jev_credentials.api_base or get_secret_str("EU_JEV_API_BASE") or "https://eu-jev.com")
|
||||
.rstrip("/")
|
||||
.removesuffix("/v1")
|
||||
)
|
||||
updated_url: Final = base_url.copy_with(
|
||||
path=HttpPassThroughEndpointHelpers.join_base_and_endpoint_path(base_url, "/v1/systemone"),
|
||||
)
|
||||
endpoint_func: Final = create_pass_through_route(
|
||||
endpoint="v1/systemone",
|
||||
target=str(updated_url),
|
||||
custom_headers={
|
||||
"Authorization": f"Bearer {eu_jev_credentials.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
custom_llm_provider="eu_jev",
|
||||
is_streaming_request=False,
|
||||
)
|
||||
return TypeAdapter(Response, config=ConfigDict(arbitrary_types_allowed=True)).validate_python(
|
||||
await endpoint_func(request, fastapi_response, user_api_key_dict)
|
||||
)
|
||||
|
||||
|
||||
router.include_router(eu_jev_router)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/laya/v1/systemone",
|
||||
tags=["Laya Pass-through", "pass-through"],
|
||||
|
|
|
|||
|
|
@ -26,9 +26,11 @@ from fastapi import (
|
|||
status,
|
||||
)
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.routing import APIRoute
|
||||
from pydantic import TypeAdapter
|
||||
from starlette.datastructures import UploadFile as StarletteUploadFile
|
||||
from starlette.routing import BaseRoute, Route
|
||||
from starlette.routing import BaseRoute, Match, Route
|
||||
from starlette.types import Scope
|
||||
from starlette.websockets import WebSocketState
|
||||
from typing_extensions import ReadOnly
|
||||
from websockets.asyncio.client import connect
|
||||
|
|
@ -73,6 +75,7 @@ from litellm.llms.base_llm.managed_resources.utils import (
|
|||
resolve_passthrough_managed_id_provider,
|
||||
)
|
||||
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
|
||||
from litellm.llms.eu_jev.common_utils import eu_jev_scoped_model, eu_jev_upstream_body
|
||||
from litellm.llms.oss_decision import validate_oss_request
|
||||
from litellm.passthrough import BasePassthroughUtils
|
||||
from litellm.proxy._lazy_features import lazy_owned_routes
|
||||
|
|
@ -90,6 +93,7 @@ from litellm.proxy.auth.auth_utils import (
|
|||
get_request_route,
|
||||
request_dispatched_to_pass_through_endpoint,
|
||||
)
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_request_processing import (
|
||||
ProxyBaseLLMRequestProcessing,
|
||||
|
|
@ -433,9 +437,9 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
|
|||
parsed_url: Final = urlparse(url)
|
||||
if custom_llm_provider == "chatgpt":
|
||||
return EndpointType.OPENAI
|
||||
if custom_llm_provider in ("typesafe", "laya", "bespoke") and parsed_url.path.removesuffix("/").endswith(
|
||||
"/v1/systemone"
|
||||
):
|
||||
if custom_llm_provider in ("typesafe", "eu_jev", "laya", "bespoke") and parsed_url.path.removesuffix(
|
||||
"/"
|
||||
).endswith("/v1/systemone"):
|
||||
return EndpointType.DECISIONS
|
||||
if (
|
||||
("generateContent") in url
|
||||
|
|
@ -1137,6 +1141,16 @@ def _unfiltered_headers(headers: Mapping[str, str]) -> Mapping[str, str]:
|
|||
return headers
|
||||
|
||||
|
||||
def _eu_jev_client_body(custom_llm_provider: str | None, body: Mapping[str, object]) -> Mapping[str, object] | None:
|
||||
return MappingProxyType(dict(body)) if custom_llm_provider == "eu_jev" else None
|
||||
|
||||
|
||||
def _eu_jev_scoped_model_override(eu_jev_client_body: Mapping[str, object] | None) -> Mapping[str, str]:
|
||||
if eu_jev_client_body is None:
|
||||
return MappingProxyType({})
|
||||
return MappingProxyType({"model": eu_jev_scoped_model(eu_jev_client_body)})
|
||||
|
||||
|
||||
async def pass_through_request(
|
||||
request: Request,
|
||||
target: str,
|
||||
|
|
@ -1255,7 +1269,12 @@ async def pass_through_request(
|
|||
strip_client_guardrail_information(client_body)
|
||||
if not _key_or_team_allows_client_pricing_override(user_api_key_dict):
|
||||
_strip_client_pricing_overrides(client_body)
|
||||
_parsed_body = client_body
|
||||
eu_jev_client_body: Final = _eu_jev_client_body(custom_llm_provider, client_body)
|
||||
scoped_client_body: Final = {
|
||||
**client_body,
|
||||
**_eu_jev_scoped_model_override(eu_jev_client_body),
|
||||
}
|
||||
_parsed_body = scoped_client_body
|
||||
if custom_llm_provider in ("laya", "bespoke"):
|
||||
decision_request: Final = TypeAdapter(Mapping[str, object]).validate_python(_parsed_body)
|
||||
checkpoint: Final = validate_oss_request(custom_llm_provider, decision_request)
|
||||
|
|
@ -1339,6 +1358,12 @@ async def pass_through_request(
|
|||
)
|
||||
_ = validate_oss_request(custom_llm_provider, decision_body)
|
||||
_parsed_body = TypeAdapter(dict[str, object]).validate_python(decision_body)
|
||||
if eu_jev_client_body is not None:
|
||||
_parsed_body = TypeAdapter(dict[str, object]).validate_python( # rebind-ok: body is rebound per stage
|
||||
eu_jev_upstream_body(
|
||||
TypeAdapter(Mapping[str, object]).validate_python(_parsed_body), eu_jev_client_body
|
||||
)
|
||||
)
|
||||
resolved_timeout: Final = resolve_pass_through_request_timeout(timeout)
|
||||
async_client_obj: Final = get_async_httpx_client(
|
||||
llm_provider=httpxSpecialProvider.PassThroughEndpoint,
|
||||
|
|
@ -2240,17 +2265,23 @@ def create_pass_through_route(
|
|||
stream,
|
||||
) = await _parse_request_data_by_content_type(request)
|
||||
|
||||
if not InitPassThroughEndpointHelpers.is_registered_pass_through_route(route=path):
|
||||
if not InitPassThroughEndpointHelpers.is_registered_pass_through_route(route=path) or (
|
||||
custom_llm_provider is None and RouteChecks.is_builtin_eu_jev_decision_route(route=path)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Pass-through endpoint {endpoint} not found. This could have been deleted or not yet added to the proxy.",
|
||||
)
|
||||
|
||||
passthrough_params: Final = InitPassThroughEndpointHelpers.get_registered_pass_through_route(
|
||||
route=path, method=request.method
|
||||
serves_configured_route: Final = custom_llm_provider != "eu_jev"
|
||||
passthrough_params: Final = (
|
||||
InitPassThroughEndpointHelpers.get_registered_pass_through_route(route=path, method=request.method)
|
||||
if serves_configured_route
|
||||
else None
|
||||
)
|
||||
if (
|
||||
passthrough_params is None
|
||||
serves_configured_route
|
||||
and passthrough_params is None
|
||||
and InitPassThroughEndpointHelpers.get_registered_pass_through_route(route=path) is not None
|
||||
):
|
||||
raise HTTPException(
|
||||
|
|
@ -3392,6 +3423,37 @@ class InitPassThroughEndpointHelpers:
|
|||
del _registered_pass_through_routes[key]
|
||||
verbose_proxy_logger.debug("Removed pass-through route from registry: %s", key)
|
||||
|
||||
@staticmethod
|
||||
def _llm_api_routes_of_route(route_info: Mapping[str, object]) -> tuple[str, ...]:
|
||||
path: Final = route_info.get("path")
|
||||
if not isinstance(path, str):
|
||||
return ()
|
||||
if route_info.get("type") == "subpath":
|
||||
return (path, path.rstrip("/") + "/*")
|
||||
return (path,)
|
||||
|
||||
@staticmethod
|
||||
def llm_api_routes_of_endpoint(endpoint_id: str) -> frozenset[str]:
|
||||
route_infos: Final = (
|
||||
route_info
|
||||
for route_info in _registered_pass_through_routes.values()
|
||||
if route_info["endpoint_id"] == endpoint_id
|
||||
)
|
||||
return frozenset(chain.from_iterable(map(InitPassThroughEndpointHelpers._llm_api_routes_of_route, route_infos)))
|
||||
|
||||
@staticmethod
|
||||
def auth_enforced_llm_api_routes() -> frozenset[str]:
|
||||
route_infos: Final = (
|
||||
route_info for route_info in _registered_pass_through_routes.values() if route_info.get("auth") is True
|
||||
)
|
||||
return frozenset(chain.from_iterable(map(InitPassThroughEndpointHelpers._llm_api_routes_of_route, route_infos)))
|
||||
|
||||
@staticmethod
|
||||
def drop_llm_api_routes(routes: frozenset[str]) -> None:
|
||||
openai_routes: Final = LiteLLMRoutes.openai_routes.value
|
||||
for route in routes.intersection(openai_routes):
|
||||
openai_routes.remove(route)
|
||||
|
||||
@staticmethod
|
||||
def clear_all_pass_through_routes():
|
||||
"""Clear all pass-through routes from the registry"""
|
||||
|
|
@ -3436,6 +3498,8 @@ class InitPassThroughEndpointHelpers:
|
|||
return True
|
||||
|
||||
comparison_route: Final = InitPassThroughEndpointHelpers._route_for_registry_lookup(route)
|
||||
if RouteChecks.is_builtin_eu_jev_decision_route(route=comparison_route):
|
||||
return True
|
||||
|
||||
# Fast path: check if any registered route key contains this path
|
||||
# Keys are in format: "{endpoint_id}:exact:{path}:{methods}" or "{endpoint_id}:subpath:{path}:{methods}"
|
||||
|
|
@ -3512,6 +3576,25 @@ class InitPassThroughEndpointHelpers:
|
|||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def has_configured_route_ignoring_trailing_slash(route: str) -> bool:
|
||||
bare_route: Final = route.rstrip("/")
|
||||
return any(
|
||||
InitPassThroughEndpointHelpers.get_registered_pass_through_route(route=form) is not None
|
||||
for form in (bare_route, f"{bare_route}/")
|
||||
)
|
||||
|
||||
|
||||
class YieldsToConfiguredPassThroughRoute(APIRoute):
|
||||
def matches(self, scope: Scope) -> tuple[Match, Scope]:
|
||||
match, child_scope = super().matches(scope)
|
||||
if match is Match.NONE:
|
||||
return match, child_scope
|
||||
configured: Final = InitPassThroughEndpointHelpers.has_configured_route_ignoring_trailing_slash(
|
||||
route=get_request_route(Request(scope))
|
||||
)
|
||||
return (Match.NONE, {}) if configured else (match, child_scope)
|
||||
|
||||
|
||||
def _get_combined_pass_through_endpoints(
|
||||
pass_through_endpoints: list[dict] | list[PassThroughGenericEndpoint],
|
||||
|
|
@ -3971,9 +4054,7 @@ async def update_pass_through_endpoints(
|
|||
|
||||
# Update the list
|
||||
pass_through_endpoint_data[endpoint_index] = endpoint_dict
|
||||
|
||||
# Remove old routes from registry before they get re-registered
|
||||
InitPassThroughEndpointHelpers.remove_endpoint_routes(endpoint_id)
|
||||
replaced_llm_api_routes: Final = InitPassThroughEndpointHelpers.llm_api_routes_of_endpoint(endpoint_id)
|
||||
|
||||
## Update db
|
||||
updated_data: Final = ConfigFieldUpdate(
|
||||
|
|
@ -4023,6 +4104,9 @@ async def update_pass_through_endpoints(
|
|||
auth=updated_endpoint.auth,
|
||||
timeout=updated_endpoint.timeout,
|
||||
)
|
||||
InitPassThroughEndpointHelpers.drop_llm_api_routes(
|
||||
replaced_llm_api_routes - InitPassThroughEndpointHelpers.auth_enforced_llm_api_routes()
|
||||
)
|
||||
|
||||
return PassThroughEndpointResponse(endpoints=[updated_endpoint] if updated_endpoint else [])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import json
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
|
@ -11,6 +12,7 @@ from litellm.integrations.vector_store_integrations.vector_store_pre_call_hook i
|
|||
LiteLLM_ManagedVectorStore,
|
||||
)
|
||||
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
|
||||
from litellm.llms.azure.ptu_capacity import is_str_keyed_mapping
|
||||
from litellm.secret_managers.main import (
|
||||
GOOGLE_OIDC_SECRET_PREFIX,
|
||||
UnresolvableSecretReference,
|
||||
|
|
@ -25,12 +27,23 @@ if TYPE_CHECKING:
|
|||
from litellm.router import Router
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PassThroughCredentials:
|
||||
api_key: str
|
||||
api_base: str | None
|
||||
|
||||
|
||||
def _get_proxy_llm_router() -> "Router | None":
|
||||
from litellm.proxy.proxy_server import llm_router
|
||||
|
||||
return llm_router
|
||||
|
||||
|
||||
def _is_team_deployment(deployment: Mapping[str, object]) -> bool:
|
||||
model_info: Final = deployment.get("model_info")
|
||||
return is_str_keyed_mapping(model_info) and bool(model_info.get("team_id"))
|
||||
|
||||
|
||||
def _get_str_value(values: dict[str, object] | None, key: str) -> str | None:
|
||||
value: Final = values.get(key) if values is not None else None
|
||||
return value if isinstance(value, str) else None
|
||||
|
|
@ -84,12 +97,13 @@ class PassthroughEndpointRouter:
|
|||
custom_llm_provider: str,
|
||||
region_name: str | None,
|
||||
) -> str | None:
|
||||
deployment_api_key: Final = self._get_deployment_api_key(
|
||||
deployment_credentials: Final = self._get_deployment_credentials(
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
region_name=region_name,
|
||||
include_team_deployments=True,
|
||||
)
|
||||
if deployment_api_key is not None:
|
||||
return _resolve_oidc_reference(deployment_api_key)
|
||||
if deployment_credentials is not None:
|
||||
return _resolve_oidc_reference(deployment_credentials.api_key)
|
||||
verbose_router_logger.debug(
|
||||
"No pass-through deployment credentials found for %s, looking for env variable", custom_llm_provider
|
||||
)
|
||||
|
|
@ -98,29 +112,56 @@ class PassthroughEndpointRouter:
|
|||
)
|
||||
return _resolve_oidc_reference(get_secret_str(_env_variable_name))
|
||||
|
||||
def get_proxy_wide_credentials(self, custom_llm_provider: str) -> _PassThroughCredentials | None:
|
||||
deployment_credentials: Final = self._get_deployment_credentials(
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
region_name=None,
|
||||
include_team_deployments=False,
|
||||
)
|
||||
api_key: Final = _resolve_oidc_reference(
|
||||
deployment_credentials.api_key
|
||||
if deployment_credentials is not None
|
||||
else get_secret_str(
|
||||
self._get_default_env_variable_name_passthrough_endpoint(custom_llm_provider=custom_llm_provider)
|
||||
)
|
||||
)
|
||||
if not api_key:
|
||||
return None
|
||||
return _PassThroughCredentials(
|
||||
api_key=api_key,
|
||||
api_base=deployment_credentials.api_base if deployment_credentials is not None else None,
|
||||
)
|
||||
|
||||
def has_credentials(self, custom_llm_provider: str) -> bool:
|
||||
if self._get_deployment_api_key(custom_llm_provider=custom_llm_provider, region_name=None) is not None:
|
||||
if (
|
||||
self._get_deployment_credentials(
|
||||
custom_llm_provider=custom_llm_provider, region_name=None, include_team_deployments=True
|
||||
)
|
||||
is not None
|
||||
):
|
||||
return True
|
||||
env_variable_name: Final = self._get_default_env_variable_name_passthrough_endpoint(
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
)
|
||||
return bool(os.environ.get(env_variable_name))
|
||||
|
||||
def _get_deployment_api_key(
|
||||
def _get_deployment_credentials(
|
||||
self,
|
||||
custom_llm_provider: str,
|
||||
region_name: str | None,
|
||||
) -> str | None:
|
||||
include_team_deployments: bool,
|
||||
) -> _PassThroughCredentials | None:
|
||||
llm_router: Final = self.llm_router_getter()
|
||||
if llm_router is None:
|
||||
return None
|
||||
deployments: Final = llm_router.registry().deployments() or ()
|
||||
return next(
|
||||
(
|
||||
api_key
|
||||
credentials
|
||||
for deployment in deployments
|
||||
if (
|
||||
api_key := self._resolve_matching_deployment_api_key(
|
||||
if (include_team_deployments or not _is_team_deployment(deployment))
|
||||
and (
|
||||
credentials := self._resolve_matching_deployment_credentials(
|
||||
litellm_params=deployment["litellm_params"],
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
region_name=region_name,
|
||||
|
|
@ -131,12 +172,12 @@ class PassthroughEndpointRouter:
|
|||
None,
|
||||
)
|
||||
|
||||
def _resolve_matching_deployment_api_key(
|
||||
def _resolve_matching_deployment_credentials(
|
||||
self,
|
||||
litellm_params: LiteLLMParamsTypedDict,
|
||||
custom_llm_provider: str,
|
||||
region_name: str | None,
|
||||
) -> str | None:
|
||||
) -> _PassThroughCredentials | None:
|
||||
if litellm_params.get("use_in_pass_through") is not True:
|
||||
return None
|
||||
if self._get_deployment_provider(litellm_params) != custom_llm_provider:
|
||||
|
|
@ -152,7 +193,8 @@ class PassthroughEndpointRouter:
|
|||
)
|
||||
if deployment_region != region_name:
|
||||
return None
|
||||
return _get_str_value(credential_values, "api_key") or litellm_params.get("api_key")
|
||||
api_key: Final = _get_str_value(credential_values, "api_key") or litellm_params.get("api_key")
|
||||
return _PassThroughCredentials(api_key=api_key, api_base=api_base) if api_key is not None else None
|
||||
|
||||
def _get_deployment_provider(self, litellm_params: LiteLLMParamsTypedDict) -> str | None:
|
||||
model: Final = litellm_params.get("model")
|
||||
|
|
|
|||
|
|
@ -375,7 +375,7 @@ class PassThroughEndpointLogging:
|
|||
kwargs = transcribe_handler_result["kwargs"] # rebind-ok: elif-chain contract
|
||||
elif (
|
||||
self.is_typesafe_route(custom_llm_provider)
|
||||
or custom_llm_provider in ("laya", "bespoke")
|
||||
or custom_llm_provider in ("eu_jev", "laya", "bespoke")
|
||||
or self.is_openrouter_decisions_route(url_route, custom_llm_provider)
|
||||
):
|
||||
from .llm_provider_handlers.typesafe_passthrough_logging_handler import (
|
||||
|
|
|
|||
|
|
@ -201,7 +201,6 @@ from litellm.types.utils import (
|
|||
CountContextManagement,
|
||||
ModelResponse,
|
||||
ModelResponseStream,
|
||||
StreamingChoices,
|
||||
TextCompletionResponse,
|
||||
TokenCountMessagesFormat,
|
||||
TokenCountResponse,
|
||||
|
|
@ -384,6 +383,7 @@ from litellm.litellm_core_utils.sensitive_data_masker import (
|
|||
SensitiveDataMasker,
|
||||
mask_sensitive_keys,
|
||||
)
|
||||
from litellm.litellm_core_utils.stream_usage_chunks import is_injected_stream_usage_artifact
|
||||
from litellm.llms.anthropic.messages.convertible_blocks import (
|
||||
deployment_drops_params,
|
||||
drop_non_string_tool_result_texts,
|
||||
|
|
@ -6464,6 +6464,8 @@ class ProxyConfig:
|
|||
_log_db_config_param(section, param_value)
|
||||
for message, args in pending_db_warnings:
|
||||
verbose_proxy_logger.warning(message, *args)
|
||||
if prisma_client is not None and store_model_in_db is True:
|
||||
self._warn_about_shadowed_db_settings()
|
||||
|
||||
self.update_config_state(config=config)
|
||||
|
||||
|
|
@ -8544,7 +8546,8 @@ class ProxyConfig:
|
|||
self._prepared_db_settings_values(section, param_value, warning_sink=warning_sink),
|
||||
)
|
||||
|
||||
self._warn_about_shadowed_db_settings(warning_sink=warning_sink)
|
||||
if warning_sink is None:
|
||||
self._warn_about_shadowed_db_settings()
|
||||
return self._config_with_resolved_settings(config)
|
||||
|
||||
async def _apply_db_environment_variables_row(
|
||||
|
|
@ -8593,15 +8596,14 @@ class ProxyConfig:
|
|||
revision: Final = _config_row_revision(row)
|
||||
return revision is None or revision < applied_revision
|
||||
|
||||
def _warn_about_shadowed_db_settings(self, warning_sink: Callable[[_DbConfigWarning], None] | None = None) -> None:
|
||||
def _warn_about_shadowed_db_settings(self) -> None:
|
||||
shadowed: Final[frozenset[tuple[Section, str]]] = frozenset(
|
||||
(section, key) for section, store in self._settings_stores.items() for key in store.shadowed_db_keys()
|
||||
)
|
||||
for section, key in sorted(shadowed - self._warned_shadowed_keys):
|
||||
_warn_db_config(
|
||||
verbose_proxy_logger.warning(
|
||||
"%s",
|
||||
config_ownership_message(section=section, key=key, shadows_db_value=True),
|
||||
warning_sink=warning_sink,
|
||||
)
|
||||
self._warned_shadowed_keys = shadowed
|
||||
|
||||
|
|
@ -10271,29 +10273,6 @@ def _serialize_streaming_chunk(chunk: BaseModel) -> str | bytes:
|
|||
return chunk.model_dump_json(exclude_none=True, exclude_unset=True)
|
||||
|
||||
|
||||
def _is_injected_stream_usage_artifact(chunk: object) -> bool:
|
||||
if not isinstance(chunk, ModelResponseStream):
|
||||
return False
|
||||
if chunk.provider_specific_fields is not None:
|
||||
return False
|
||||
return all(_is_empty_streaming_choice(choice) for choice in chunk.choices or [])
|
||||
|
||||
|
||||
def _is_empty_streaming_choice(choice: StreamingChoices) -> bool:
|
||||
if choice.finish_reason is not None:
|
||||
return False
|
||||
if getattr(choice, "logprobs", None) is not None:
|
||||
return False
|
||||
delta: Final = choice.delta if hasattr(choice, "delta") else None
|
||||
if delta is None:
|
||||
return True
|
||||
return _all_values_none(delta.model_dump().values())
|
||||
|
||||
|
||||
def _all_values_none(values: Iterable[object]) -> bool:
|
||||
return all(value is None for value in values)
|
||||
|
||||
|
||||
async def _apply_streaming_chunk_hooks(
|
||||
*,
|
||||
chunk: Any,
|
||||
|
|
@ -10673,7 +10652,7 @@ async def async_data_generator(
|
|||
fallback_model_from_metadata=fallback_model_from_metadata,
|
||||
)
|
||||
|
||||
if strip_stream_usage and _is_injected_stream_usage_artifact(chunk):
|
||||
if strip_stream_usage and is_injected_stream_usage_artifact(chunk):
|
||||
if pending_fallback_event:
|
||||
yield _format_fallback_metadata_sse_event(
|
||||
fallback_model=fallback_model_from_metadata,
|
||||
|
|
|
|||
|
|
@ -1553,6 +1553,34 @@
|
|||
],
|
||||
"default_model_placeholder": "gpt-3.5-turbo"
|
||||
},
|
||||
{
|
||||
"provider": "EU_JEV",
|
||||
"provider_display_name": "eu/jev",
|
||||
"litellm_provider": "eu_jev",
|
||||
"credential_fields": [
|
||||
{
|
||||
"key": "api_base",
|
||||
"label": "API Base",
|
||||
"placeholder": "https://eu-jev.com",
|
||||
"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": "eu_jev/eu-jeff-latest"
|
||||
},
|
||||
{
|
||||
"provider": "FalAI",
|
||||
"provider_display_name": "Fal AI",
|
||||
|
|
|
|||
|
|
@ -98,6 +98,8 @@ class Feature(str, Enum):
|
|||
"""Replayed thinking blocks, which Python strips and retries once if Anthropic rejects their signature."""
|
||||
TOOL_CUSTOM_FIELD = "tool_custom_field"
|
||||
"""Tools carrying a non-spec custom object, which Python drops or hoists for the provider."""
|
||||
STRICT_TOOLS_RETRY = "strict_tools_retry"
|
||||
"""Strict tools under drop_params, which Python sends again without strict if Claude rejects their limits."""
|
||||
CLIENT_HEADERS = "client_headers"
|
||||
"""Proxy-forwarded headers that Python merges into extra_headers."""
|
||||
DEPLOYMENT_HOOK = "deployment_hook"
|
||||
|
|
@ -179,6 +181,7 @@ MESSAGES_PYTHON_FEATURES: Final = COMMON_PYTHON_FEATURES | {
|
|||
Feature.STREAM_COST_INJECTION,
|
||||
Feature.THINKING_SIGNATURE_RETRY,
|
||||
Feature.TOOL_CUSTOM_FIELD,
|
||||
Feature.STRICT_TOOLS_RETRY,
|
||||
}
|
||||
|
||||
RESPONSES_PYTHON_FEATURES: Final = COMMON_PYTHON_FEATURES | {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
|||
from typing import Final
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
|
||||
from litellm.llms.opencode.common_utils import is_opencode_api_base
|
||||
from litellm.rust_bridge.detectors import api_base, overridden, present, streaming
|
||||
from litellm.rust_bridge.public_call import NativeCall, optional_mapping, optional_sequence, optional_str
|
||||
|
|
@ -48,6 +49,13 @@ def tool_custom_field(request: NativeCall) -> bool:
|
|||
return any("custom" in (optional_mapping(tool) or {}) for tool in tools)
|
||||
|
||||
|
||||
def strict_tools_retry(request: NativeCall) -> bool:
|
||||
tools: Final = optional_sequence(request.resolved.get("tools")) or ()
|
||||
return drops_unsupported_params(request.resolved.get("drop_params")) and any(
|
||||
(optional_mapping(tool) or {}).get("strict") is True for tool in tools
|
||||
)
|
||||
|
||||
|
||||
def stream_cost_injection(request: NativeCall) -> bool:
|
||||
"""Python adds response cost to streaming usage when the global setting is truthy."""
|
||||
return streaming(request) and bool(litellm.include_cost_in_streaming_usage)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from typing import Final
|
||||
|
||||
from litellm.types.utils import Choices, ModelResponse, TextChoices, TextCompletionResponse
|
||||
from litellm.litellm_core_utils.stream_usage_chunks import is_injected_stream_usage_artifact
|
||||
from litellm.types.utils import Choices, ModelResponse, ModelResponseStream, TextChoices, TextCompletionResponse
|
||||
|
||||
|
||||
class TextCompletionStreamWrapper:
|
||||
|
|
@ -10,11 +11,18 @@ class TextCompletionStreamWrapper:
|
|||
model,
|
||||
stream_options: dict | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
usage_injected_by_proxy: bool = False,
|
||||
):
|
||||
self.completion_stream = completion_stream
|
||||
self.model = model
|
||||
self.stream_options = stream_options
|
||||
self.custom_llm_provider = custom_llm_provider
|
||||
self.usage_injected_by_proxy: Final = usage_injected_by_proxy
|
||||
self.include_usage: Final = (
|
||||
not usage_injected_by_proxy
|
||||
and stream_options is not None
|
||||
and stream_options.get("include_usage", False) is True
|
||||
)
|
||||
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
|
@ -22,7 +30,7 @@ class TextCompletionStreamWrapper:
|
|||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
def convert_to_text_completion_object(self, chunk: ModelResponse):
|
||||
def convert_to_text_completion_object(self, chunk: ModelResponse | ModelResponseStream):
|
||||
try:
|
||||
response: Final = TextCompletionResponse()
|
||||
response["id"] = chunk.get("id", None)
|
||||
|
|
@ -43,8 +51,7 @@ class TextCompletionStreamWrapper:
|
|||
else:
|
||||
response["choices"] = []
|
||||
|
||||
# only pass usage when stream_options["include_usage"] is True
|
||||
if self.stream_options and self.stream_options.get("include_usage", False) is True:
|
||||
if self.include_usage:
|
||||
response.usage = chunk.get("usage", None)
|
||||
else:
|
||||
response.usage = None
|
||||
|
|
@ -53,6 +60,9 @@ class TextCompletionStreamWrapper:
|
|||
except Exception as e:
|
||||
raise Exception(f"Error occurred converting to text completion object - chunk: {chunk}; Error: {e}")
|
||||
|
||||
def _is_injected_usage_artifact(self, chunk: ModelResponseStream) -> bool:
|
||||
return self.usage_injected_by_proxy and is_injected_stream_usage_artifact(chunk)
|
||||
|
||||
def __next__(self):
|
||||
# model_response = ModelResponse(stream=True, model=self.model)
|
||||
TextCompletionResponse()
|
||||
|
|
@ -60,8 +70,9 @@ class TextCompletionStreamWrapper:
|
|||
for chunk in self.completion_stream:
|
||||
if chunk == "None" or chunk is None:
|
||||
raise Exception
|
||||
processed_chunk = self.convert_to_text_completion_object(chunk=chunk)
|
||||
return processed_chunk
|
||||
if isinstance(chunk, ModelResponseStream) and self._is_injected_usage_artifact(chunk):
|
||||
continue
|
||||
return self.convert_to_text_completion_object(chunk=chunk)
|
||||
raise StopIteration
|
||||
except StopIteration:
|
||||
raise StopIteration
|
||||
|
|
@ -81,8 +92,9 @@ class TextCompletionStreamWrapper:
|
|||
async for chunk in self.completion_stream:
|
||||
if chunk == "None" or chunk is None:
|
||||
raise Exception
|
||||
processed_chunk = self.convert_to_text_completion_object(chunk=chunk)
|
||||
return processed_chunk
|
||||
if isinstance(chunk, ModelResponseStream) and self._is_injected_usage_artifact(chunk):
|
||||
continue
|
||||
return self.convert_to_text_completion_object(chunk=chunk)
|
||||
raise StopIteration
|
||||
except StopIteration:
|
||||
raise StopAsyncIteration
|
||||
|
|
|
|||
|
|
@ -204,9 +204,11 @@ class MCPServer(LiteLLMBaseModel):
|
|||
access_groups: list[str] | None = None
|
||||
allow_all_keys: bool = False
|
||||
available_on_public_internet: bool = True
|
||||
# Legacy opt-in to upstream-delegated authentication for ``oauth2``
|
||||
# servers. LiteLLM admission still applies; use ``oauth_delegate`` for the
|
||||
# supported client-forwarded OAuth flow.
|
||||
# Deprecated opt-in to upstream-delegated authentication for ``oauth2``
|
||||
# servers. A bare ``Authorization`` that is not a LiteLLM credential is the
|
||||
# upstream token and is forwarded; see
|
||||
# ``MCPRequestHandler._admit_legacy_delegate_bearer``. New servers should use
|
||||
# ``true_passthrough`` or ``oauth_delegate``.
|
||||
#
|
||||
# Honored only for ``auth_type == oauth2``; ignored for any other
|
||||
# ``auth_type``. OAuth pass-through for non-oauth2 servers
|
||||
|
|
|
|||
100
litellm/types/passthrough_endpoints/eu_jev.py
Normal file
100
litellm/types/passthrough_endpoints/eu_jev.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
from collections.abc import Mapping, Sequence
|
||||
from typing import Annotated, Literal, TypeAlias
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from litellm.types.decisions import ChoiceAnswer, DecisionsJSON, NoulAnswer, NoulCriteria, ScoreAnswer
|
||||
from litellm.types.llms.base import LiteLLMPydanticObjectBase
|
||||
|
||||
EuJevEntry: TypeAlias = Annotated[
|
||||
DecisionsJSON | None,
|
||||
Field(description="Free text, or a JSON object, array or null that eu/jev serializes to text before inference"),
|
||||
]
|
||||
|
||||
|
||||
class EuJevChoiceQuestion(LiteLLMPydanticObjectBase):
|
||||
type: Literal["choice"]
|
||||
instructions: EuJevEntry
|
||||
criteria: Annotated[
|
||||
Mapping[str, EuJevEntry],
|
||||
Field(
|
||||
min_length=2,
|
||||
max_length=64,
|
||||
description="Two to 64 options, option name to description. A null description uses the option name",
|
||||
),
|
||||
]
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
|
||||
class EuJevNoulQuestion(LiteLLMPydanticObjectBase):
|
||||
type: Literal["noul"]
|
||||
instructions: EuJevEntry
|
||||
criteria: Annotated[NoulCriteria | None, Field(description="Optional descriptions of what true and false mean")] = (
|
||||
None
|
||||
)
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
|
||||
class EuJevScoreQuestion(LiteLLMPydanticObjectBase):
|
||||
type: Literal["score"]
|
||||
instructions: EuJevEntry
|
||||
criteria: Annotated[
|
||||
Sequence[EuJevEntry],
|
||||
Field(min_length=2, max_length=10, description="Two to 10 level descriptions, ordered from low to high"),
|
||||
]
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
|
||||
EuJevQuestion: TypeAlias = Annotated[
|
||||
EuJevChoiceQuestion | EuJevNoulQuestion | EuJevScoreQuestion,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
|
||||
class EuJevDecisionRequest(LiteLLMPydanticObjectBase):
|
||||
model: Annotated[str, Field(description="eu-jeff-latest follows the current release, eu-jeff-1.0.0 pins it")] = (
|
||||
"eu-jeff-latest"
|
||||
)
|
||||
state: Annotated[
|
||||
Annotated[str, Field(min_length=1, max_length=100_000)] | Mapping[str, object] | Sequence[object],
|
||||
Field(description="The content to evaluate, a string of up to 100,000 characters or a JSON object or array"),
|
||||
]
|
||||
questions: Annotated[
|
||||
Mapping[Annotated[str, Field(min_length=1, max_length=80)], EuJevQuestion],
|
||||
Field(min_length=1, max_length=8, description="One to eight questions, keyed by names of up to 80 characters"),
|
||||
]
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
|
||||
EuJevAnswer: TypeAlias = Annotated[ChoiceAnswer | NoulAnswer | ScoreAnswer, Field(discriminator="type")]
|
||||
|
||||
|
||||
class EuJevUsage(LiteLLMPydanticObjectBase):
|
||||
input_tokens: Annotated[int, Field(ge=0)]
|
||||
output_tokens: Annotated[int, Field(ge=0)]
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
|
||||
class EuJevMeta(LiteLLMPydanticObjectBase):
|
||||
request_id: str
|
||||
mode: Literal["live"]
|
||||
latency_ms: Annotated[
|
||||
int, Field(description="Server-side time for token quoting, credit reservation and inference")
|
||||
]
|
||||
cost_eur: Annotated[str | None, Field(description="The exact charge in euros, on authenticated requests")] = None
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
|
||||
class EuJevDecisionResponse(LiteLLMPydanticObjectBase):
|
||||
model: Annotated[str, Field(description="The release that answered, such as eu-jeff-1.0.0")]
|
||||
answers: Annotated[Mapping[str, EuJevAnswer], Field(description="One answer per question, under the same names")]
|
||||
usage: EuJevUsage
|
||||
meta: EuJevMeta
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
|
@ -169,6 +169,10 @@ class UserAPIKeyAuth(LiteLLM_VerificationTokenView): # the expected response ob
|
|||
# above; a forged value could at most narrow, but the stripping keeps the field's provenance
|
||||
# single-owner so its meaning stays trustworthy.
|
||||
mcp_session_resource_server_ids: tuple[str, ...] | None = Field(default=None, exclude=True)
|
||||
# Set only by MCP admission when a legacy delegate_auth_to_upstream caller's Authorization is the
|
||||
# upstream token rather than a LiteLLM credential, so egress forwards it. Stripped from validated
|
||||
# input like the markers above: a forged value would forward a LiteLLM credential upstream.
|
||||
mcp_forwards_caller_authorization: bool = Field(default=False, exclude=True)
|
||||
mcp_toolset_id: str | None = Field(default=None, exclude=True)
|
||||
authenticated_by_custom_auth: bool = Field(default=False, exclude=True)
|
||||
via_virtual_key: bool = Field(
|
||||
|
|
@ -230,6 +234,7 @@ class UserAPIKeyAuth(LiteLLM_VerificationTokenView): # the expected response ob
|
|||
values.pop("mcp_explicit_grants_only", None)
|
||||
values.pop("mcp_source_team_rpm_limits", None)
|
||||
values.pop("mcp_session_resource_server_ids", None)
|
||||
values.pop("mcp_forwards_caller_authorization", None)
|
||||
values.pop("mcp_toolset_id", None)
|
||||
values.pop("via_virtual_key", None)
|
||||
values.pop("authenticated_by_custom_auth", None)
|
||||
|
|
|
|||
|
|
@ -4489,6 +4489,7 @@ class LlmProviders(str, Enum):
|
|||
STRANDS_DECIDER = "strands_decider"
|
||||
LAYA = "laya"
|
||||
BESPOKE = "bespoke"
|
||||
EU_JEV = "eu_jev"
|
||||
MISTRAL = "mistral"
|
||||
MILVUS = "milvus"
|
||||
GROQ = "groq"
|
||||
|
|
|
|||
|
|
@ -9977,11 +9977,12 @@ class ProviderConfigManager:
|
|||
return litellm.LayaDecisionsConfig()
|
||||
if provider == LlmProviders.BESPOKE:
|
||||
return litellm.BespokeDecisionsConfig()
|
||||
if provider in (LlmProviders.OPENAI, LlmProviders.AZURE, LlmProviders.AZURE_AI):
|
||||
if provider in (LlmProviders.OPENAI, LlmProviders.AZURE, LlmProviders.AZURE_AI, LlmProviders.EU_JEV):
|
||||
configs: Final = {
|
||||
LlmProviders.OPENAI: litellm.OpenAIDecisionsConfig,
|
||||
LlmProviders.AZURE: litellm.AzureDecisionsConfig,
|
||||
LlmProviders.AZURE_AI: litellm.AzureAIDecisionsConfig,
|
||||
LlmProviders.EU_JEV: litellm.EuJevDecisionsConfig,
|
||||
}
|
||||
return configs[provider]()
|
||||
return None
|
||||
|
|
@ -10932,19 +10933,8 @@ class ProviderConfigManager:
|
|||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_provider_image_edit_config(
|
||||
model: str,
|
||||
provider: LlmProviders,
|
||||
) -> BaseImageEditConfig | None:
|
||||
if LlmProviders.OPENAI == provider:
|
||||
from litellm.llms.openai.image_edit import OpenAIImageEditConfig
|
||||
|
||||
return OpenAIImageEditConfig()
|
||||
elif LlmProviders.HOSTED_VLLM == provider:
|
||||
from litellm.llms.hosted_vllm.image_edit import get_hosted_vllm_image_edit_config
|
||||
|
||||
return get_hosted_vllm_image_edit_config(model=model)
|
||||
elif LlmProviders.AZURE == provider:
|
||||
def _get_model_independent_image_edit_config(provider: LlmProviders) -> BaseImageEditConfig | None:
|
||||
if LlmProviders.AZURE == provider:
|
||||
from litellm.llms.azure.image_edit.transformation import (
|
||||
AzureImageEditConfig,
|
||||
)
|
||||
|
|
@ -10962,6 +10952,36 @@ class ProviderConfigManager:
|
|||
)
|
||||
|
||||
return BlackForestLabsImageEditConfig()
|
||||
elif LlmProviders.LITELLM_PROXY == provider:
|
||||
from litellm.llms.litellm_proxy.image_edit.transformation import (
|
||||
LiteLLMProxyImageEditConfig,
|
||||
)
|
||||
|
||||
return LiteLLMProxyImageEditConfig()
|
||||
elif LlmProviders.DASHSCOPE == provider:
|
||||
from litellm.llms.dashscope.image_edit.transformation import (
|
||||
DashScopeImageEditConfig,
|
||||
)
|
||||
|
||||
return DashScopeImageEditConfig()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_provider_image_edit_config(
|
||||
model: str,
|
||||
provider: LlmProviders,
|
||||
) -> BaseImageEditConfig | None:
|
||||
model_independent_config: Final = ProviderConfigManager._get_model_independent_image_edit_config(provider)
|
||||
if model_independent_config is not None:
|
||||
return model_independent_config
|
||||
if LlmProviders.OPENAI == provider:
|
||||
from litellm.llms.openai.image_edit import OpenAIImageEditConfig
|
||||
|
||||
return OpenAIImageEditConfig()
|
||||
elif LlmProviders.HOSTED_VLLM == provider:
|
||||
from litellm.llms.hosted_vllm.image_edit import get_hosted_vllm_image_edit_config
|
||||
|
||||
return get_hosted_vllm_image_edit_config(model=model)
|
||||
elif LlmProviders.FAL_AI == provider:
|
||||
from litellm.llms.fal_ai.image_edit import get_fal_ai_image_edit_config
|
||||
|
||||
|
|
@ -10974,12 +10994,6 @@ class ProviderConfigManager:
|
|||
from litellm.llms.gemini.image_edit import get_gemini_image_edit_config
|
||||
|
||||
return get_gemini_image_edit_config(model)
|
||||
elif LlmProviders.LITELLM_PROXY == provider:
|
||||
from litellm.llms.litellm_proxy.image_edit.transformation import (
|
||||
LiteLLMProxyImageEditConfig,
|
||||
)
|
||||
|
||||
return LiteLLMProxyImageEditConfig()
|
||||
elif LlmProviders.VERTEX_AI == provider:
|
||||
from litellm.llms.vertex_ai.image_edit import (
|
||||
get_vertex_ai_image_edit_config,
|
||||
|
|
@ -11201,6 +11215,12 @@ class ProviderConfigManager:
|
|||
return AWSPollyTextToSpeechConfig()
|
||||
elif litellm.LlmProviders.EDENAI == provider:
|
||||
return litellm.EdenAITextToSpeechConfig()
|
||||
elif litellm.LlmProviders.DASHSCOPE == provider:
|
||||
from litellm.llms.dashscope.text_to_speech.transformation import (
|
||||
DashScopeTextToSpeechConfig,
|
||||
)
|
||||
|
||||
return DashScopeTextToSpeechConfig()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -18346,25 +18346,31 @@
|
|||
"dashscope/qwen-image-2.0": {
|
||||
"litellm_provider": "dashscope",
|
||||
"mode": "image_generation",
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
|
||||
"output_cost_per_image": 0.035,
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/images/generations"
|
||||
"/v1/images/generations",
|
||||
"/v1/images/edits"
|
||||
]
|
||||
},
|
||||
"dashscope/qwen-image-2.0-pro": {
|
||||
"litellm_provider": "dashscope",
|
||||
"mode": "image_generation",
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
|
||||
"output_cost_per_image": 0.075,
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/images/generations"
|
||||
"/v1/images/generations",
|
||||
"/v1/images/edits"
|
||||
]
|
||||
},
|
||||
"dashscope/qwen-image-3.0": {
|
||||
"litellm_provider": "dashscope",
|
||||
"mode": "image_generation",
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
|
||||
"output_cost_per_image": 0.03,
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/images/generations"
|
||||
"/v1/images/generations",
|
||||
"/v1/images/edits"
|
||||
]
|
||||
},
|
||||
"dashscope/qwen-image-3.0-pro": {
|
||||
|
|
@ -18372,7 +18378,54 @@
|
|||
"mode": "image_generation",
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/models",
|
||||
"supported_endpoints": [
|
||||
"/v1/images/generations"
|
||||
"/v1/images/generations",
|
||||
"/v1/images/edits"
|
||||
]
|
||||
},
|
||||
"dashscope/qwen3-tts-flash": {
|
||||
"input_cost_per_character": 1e-05,
|
||||
"litellm_provider": "dashscope",
|
||||
"mode": "audio_speech",
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/audio/speech"
|
||||
]
|
||||
},
|
||||
"dashscope/qwen-audio-3.1-tts-flash": {
|
||||
"input_cost_per_token": 2.3e-07,
|
||||
"output_cost_per_token": 1.87e-06,
|
||||
"litellm_provider": "dashscope",
|
||||
"mode": "audio_speech",
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/audio/speech"
|
||||
]
|
||||
},
|
||||
"dashscope/qwen-audio-3.1-tts-plus": {
|
||||
"input_cost_per_character": 3e-05,
|
||||
"litellm_provider": "dashscope",
|
||||
"mode": "audio_speech",
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/audio/speech"
|
||||
]
|
||||
},
|
||||
"dashscope/qwen-audio-3.0-tts-plus": {
|
||||
"input_cost_per_character": 2e-05,
|
||||
"litellm_provider": "dashscope",
|
||||
"mode": "audio_speech",
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/audio/speech"
|
||||
]
|
||||
},
|
||||
"dashscope/qwen-audio-3.0-tts-flash": {
|
||||
"input_cost_per_character": 1.5e-05,
|
||||
"litellm_provider": "dashscope",
|
||||
"mode": "audio_speech",
|
||||
"source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/audio/speech"
|
||||
]
|
||||
},
|
||||
"dashscope/wan3.0-video": {
|
||||
|
|
@ -72330,6 +72383,28 @@
|
|||
"source": "https://docs.typesafe.ai/models",
|
||||
"supports_box_questions": true
|
||||
},
|
||||
"eu_jev/eu-jeff-1.0.0": {
|
||||
"input_cost_per_token": 4.15e-08,
|
||||
"litellm_provider": "eu_jev",
|
||||
"max_input_tokens": 32768,
|
||||
"mode": "evaluation",
|
||||
"output_cost_per_token": 0.0,
|
||||
"source": "https://eu-jev.com/docs",
|
||||
"supported_endpoints": [
|
||||
"/v1/systemone"
|
||||
]
|
||||
},
|
||||
"eu_jev/eu-jeff-latest": {
|
||||
"input_cost_per_token": 4.15e-08,
|
||||
"litellm_provider": "eu_jev",
|
||||
"max_input_tokens": 32768,
|
||||
"mode": "evaluation",
|
||||
"output_cost_per_token": 0.0,
|
||||
"source": "https://eu-jev.com/docs",
|
||||
"supported_endpoints": [
|
||||
"/v1/systemone"
|
||||
]
|
||||
},
|
||||
"wandb/deepseek-ai/DeepSeek-V4.1-Flash": {
|
||||
"cache_read_input_token_cost": 3e-08,
|
||||
"input_cost_per_token": 2e-07,
|
||||
|
|
|
|||
|
|
@ -1148,9 +1148,9 @@
|
|||
"text_completion": true,
|
||||
"embeddings": false,
|
||||
"image_generations": true,
|
||||
"image_edits": false,
|
||||
"image_edits": true,
|
||||
"audio_transcriptions": false,
|
||||
"audio_speech": false,
|
||||
"audio_speech": true,
|
||||
"rerank": false,
|
||||
"ocr": false,
|
||||
"search": false,
|
||||
|
|
@ -2241,6 +2241,32 @@
|
|||
"generateContent": false
|
||||
}
|
||||
},
|
||||
"eu_jev": {
|
||||
"display_name": "eu/jev (`eu_jev`)",
|
||||
"url": "https://docs.litellm.ai/docs/pass_through/eu_jev",
|
||||
"endpoints": {
|
||||
"chat_completions": false,
|
||||
"responses": false,
|
||||
"text_completion": false,
|
||||
"embeddings": false,
|
||||
"image_generations": false,
|
||||
"image_edits": false,
|
||||
"audio_transcriptions": false,
|
||||
"audio_speech": false,
|
||||
"rerank": false,
|
||||
"ocr": false,
|
||||
"search": false,
|
||||
"moderations": false,
|
||||
"realtime": false,
|
||||
"video_generations": false,
|
||||
"batches": false,
|
||||
"messages": false,
|
||||
"interactions": false,
|
||||
"systemone": true,
|
||||
"a2a": false,
|
||||
"generateContent": false
|
||||
}
|
||||
},
|
||||
"laya": {
|
||||
"display_name": "Laya (`laya`)",
|
||||
"url": "https://docs.litellm.ai/docs/auto_router/decision_classifiers",
|
||||
|
|
|
|||
|
|
@ -84,8 +84,8 @@ proxy = [
|
|||
"mcp>=2.3.0,<3",
|
||||
"httpx2>=2.10.0,<3",
|
||||
"pydantic>=2.12.0,<3",
|
||||
"litellm-proxy-extras==0.4.111",
|
||||
"litellm-enterprise==0.1.80",
|
||||
"litellm-proxy-extras==0.4.112",
|
||||
"litellm-enterprise==0.1.81",
|
||||
"RestrictedPython>=8.5,<9.0",
|
||||
"rich>=13.9.4,<14.0",
|
||||
"InquirerPy>=0.3.4,<1.0",
|
||||
|
|
|
|||
|
|
@ -101,6 +101,23 @@ def _install_termination_handlers() -> None:
|
|||
signal.signal(termination, _exit_on_termination)
|
||||
|
||||
|
||||
def _install_base_native(worktree: Path, repo_root: Path) -> None:
|
||||
env: Final = {
|
||||
**os.environ,
|
||||
"CARGO_TARGET_DIR": str(
|
||||
Path(os.environ.get("CARGO_TARGET_DIR", repo_root / "litellm-rust" / "target")).resolve()
|
||||
),
|
||||
}
|
||||
_run(
|
||||
[
|
||||
"uv", "pip", "install", "--python", sys.executable, "--no-deps",
|
||||
"--target", str(worktree.parent / "native-install"), "--editable", str(worktree),
|
||||
],
|
||||
cwd=worktree,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def base_counts(ref: str, repo_root: Path = REPO_ROOT, checker: Path = CHECKER) -> Mapping[str, int]:
|
||||
"""Rule counts at `ref`, measured with the *current* checker over the base tree."""
|
||||
_install_termination_handlers()
|
||||
|
|
@ -111,6 +128,7 @@ def base_counts(ref: str, repo_root: Path = REPO_ROOT, checker: Path = CHECKER)
|
|||
(worktree / "scripts").mkdir(parents=True, exist_ok=True)
|
||||
base_checker: Final = worktree / "scripts" / "check_openapi_docs.py"
|
||||
shutil.copy(checker, base_checker)
|
||||
_install_base_native(worktree, repo_root)
|
||||
return count_by_rule(_check(worktree, base_checker))
|
||||
finally:
|
||||
# Teardown must never raise, or it masks the real error when the body failed.
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ IGNORE_FUNCTIONS = [
|
|||
"completion_cost", # max depth 1: recursion only fires for mixed-tier Responses WS logging objects, and each split part carries a single service_tier so _split_responses_ws_logging_object_by_service_tier returns None.
|
||||
"_string_leaves", # bounded by the nesting depth of a safe_json_structure output (a finite JSON tree, no cycles possible).
|
||||
"_replace_string_leaves", # bounded by the nesting depth of a safe_json_structure output (a finite JSON tree, no cycles possible).
|
||||
"_redacted_client_field", # bounded by the nesting depth of a JSON-decoded error body (a finite JSON tree, no cycles possible).
|
||||
"_sort_processed_sets", # bounded by the nesting depth of the log-record extra it walks (a finite JSON tree, no cycles possible).
|
||||
"scrub_json_strings", # max depth set (MAX_SCRUB_DEPTH); fails closed by returning "[Filtered]" for anything nested past the cap.
|
||||
"_without_requester_ip_address", # walks only the fresh tree _sanitize_request_body_for_spend_logs_payload returns (mapping cycles already cut), so it never goes deeper than that sanitizer did.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
from typing import Final
|
||||
|
||||
import openapi_docs_gate as gate
|
||||
|
||||
|
||||
def test_base_counts_imports_native_extension_from_detached_worktree() -> None:
|
||||
expected_counts: Final = dict(gate.count_by_rule(gate.head_violations()))
|
||||
assert expected_counts
|
||||
assert dict(gate.base_counts("HEAD")) == expected_counts
|
||||
|
|
@ -0,0 +1,259 @@
|
|||
import base64
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from integration._support.client import JSON_OBJECT, Gateway, gateway_from_environment, list_value, object_value
|
||||
from integration._support.process import graceful_stop_seconds, owned_proxy
|
||||
from integration._support.wire import Reply, Request, Wire, wire_server
|
||||
from integration.providers._dashscope_video_support import deployed, json_reply, rate, settled_spend
|
||||
from pydantic import JsonValue
|
||||
|
||||
_API_KEY: Final = "synthetic-dashscope-key"
|
||||
_MULTIMODAL_PATH: Final = "/api/v1/services/aigc/multimodal-generation/generation"
|
||||
_SYNTHESIZER_PATH: Final = "/api/v1/services/audio/tts/SpeechSynthesizer"
|
||||
_OSS_PATH: Final = "/oss/"
|
||||
_PNG: Final = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
)
|
||||
_PNG_DATA_URI: Final = f"data:image/png;base64,{base64.b64encode(_PNG).decode()}"
|
||||
_EDITED_URL: Final = "https://dashscope-result.example.com/edited.png"
|
||||
_PROMPT: Final = "Turn the red circle into a blue square"
|
||||
_TEXT: Final = "Hello from LiteLLM."
|
||||
_BILLED_CHARACTERS: Final = len(_TEXT)
|
||||
_WAV: Final = b"RIFF\x24\x00\x00\x00WAVEfmt " + uuid.uuid4().bytes
|
||||
_MP3: Final = b"ID3\x04\x00\x00\x00\x00\x00\x00" + uuid.uuid4().bytes
|
||||
_SIGNED_QUERY: Final = "Expires=1793316671&OSSAccessKeyId=LTAI5t&Signature=ab%2Bc%3D"
|
||||
_QWEN_TTS: Final = "dashscope-qwen-tts"
|
||||
_QWEN_AUDIO_TTS: Final = "dashscope-qwen-audio-tts"
|
||||
_INPUT_TOKENS: Final = 8
|
||||
_OUTPUT_TOKENS: Final = 30
|
||||
_OWNED_TIMEOUT: Final = int(2 * graceful_stop_seconds() + 120)
|
||||
|
||||
|
||||
def _edit_api(request: Request) -> Reply:
|
||||
assert (request.method, request.target) == ("POST", _MULTIMODAL_PATH), (request.method, request.target)
|
||||
return json_reply(
|
||||
{
|
||||
"output": {"choices": [{"message": {"role": "assistant", "content": [{"image": _EDITED_URL}]}}]},
|
||||
"usage": {"width": 1024, "height": 1024, "image_count": 1},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _synthesized(request: Request, file_name: str, usage: dict[str, JsonValue]) -> Reply:
|
||||
audio_url: Final = f"http://{request.headers['host']}{_OSS_PATH}{file_name}?{_SIGNED_QUERY}"
|
||||
return json_reply({"output": {"audio": {"url": audio_url, "data": "", "id": "audio-1"}}, "usage": usage})
|
||||
|
||||
|
||||
def _speech_api(request: Request) -> Reply:
|
||||
if request.method == "GET" and request.target.startswith(f"{_OSS_PATH}speech.wav"):
|
||||
return Reply(body=_WAV, content_type="audio/wav")
|
||||
if request.method == "GET" and request.target.startswith(f"{_OSS_PATH}speech.mp3"):
|
||||
return Reply(body=_MP3, content_type="audio/mpeg")
|
||||
if (request.method, request.target) == ("POST", _MULTIMODAL_PATH):
|
||||
return _synthesized(request, "speech.wav", {"characters": _BILLED_CHARACTERS})
|
||||
assert (request.method, request.target) == ("POST", _SYNTHESIZER_PATH), (request.method, request.target)
|
||||
return _synthesized(
|
||||
request,
|
||||
"speech.mp3",
|
||||
{"characters": _BILLED_CHARACTERS, "input_tokens": _INPUT_TOKENS, "output_tokens": _OUTPUT_TOKENS},
|
||||
)
|
||||
|
||||
|
||||
def _pooled_database_url(url: str) -> str:
|
||||
parts: Final = urlsplit(url)
|
||||
query: Final = "&".join(part for part in (parts.query, "connection_limit=5") if part)
|
||||
return urlunsplit(parts._replace(query=query))
|
||||
|
||||
|
||||
def _tts_deployment(name: str, model: str, api_base: str) -> dict[str, JsonValue]:
|
||||
return {"model_name": name, "litellm_params": {"model": model, "api_base": api_base, "api_key": _API_KEY}}
|
||||
|
||||
|
||||
def _error_message(body: bytes) -> str:
|
||||
return str(object_value(JSON_OBJECT.validate_json(body)["error"])["message"])
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rig() -> Iterator[Gateway]:
|
||||
with gateway_from_environment() as gateway:
|
||||
yield gateway
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def speech_wire() -> Iterator[Wire]:
|
||||
with wire_server(_speech_api) as served:
|
||||
yield served
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def speech_proxy(rig: Gateway, speech_wire: Wire, tmp_path_factory: pytest.TempPathFactory) -> Iterator[Gateway]:
|
||||
directory: Final = tmp_path_factory.mktemp("dashscope-speech")
|
||||
config: Final = JSON_OBJECT.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()))
|
||||
path: Final = directory / "dashscope-speech.yaml"
|
||||
path.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
**config,
|
||||
"model_list": [
|
||||
_tts_deployment(_QWEN_TTS, "dashscope/qwen3-tts-flash", speech_wire.url),
|
||||
_tts_deployment(_QWEN_AUDIO_TTS, "dashscope/qwen-audio-3.1-tts-flash", speech_wire.url),
|
||||
],
|
||||
"general_settings": {
|
||||
**object_value(config["general_settings"]),
|
||||
"user_url_allowed_hosts": ["127.0.0.1"],
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
with owned_proxy(
|
||||
rig, directory, {"DATABASE_URL": _pooled_database_url(os.environ["DATABASE_URL"])}, config=path
|
||||
) as owned:
|
||||
yield owned
|
||||
|
||||
|
||||
def test_a_multipart_edit_reaches_dashscope_as_inline_images_and_bills_the_edited_image(gateway: Gateway) -> None:
|
||||
with wire_server(_edit_api) as wire, gateway.scenario() as scenario:
|
||||
model: Final = deployed(
|
||||
gateway,
|
||||
scenario,
|
||||
model="dashscope/qwen-image-2.0",
|
||||
api_base=f"{wire.url}{_MULTIMODAL_PATH}",
|
||||
api_key=_API_KEY,
|
||||
)
|
||||
key: Final = scenario.key(models=[model])
|
||||
response: Final = gateway.client.post(
|
||||
"/v1/images/edits",
|
||||
data={"model": model, "prompt": _PROMPT, "size": "1024x1024", "negative_prompt": "blurry"},
|
||||
files=[("image[]", ("a.png", _PNG, "image/png")), ("image[]", ("b.png", _PNG, "image/png"))],
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
data: Final = list_value(JSON_OBJECT.validate_json(response.content)["data"])
|
||||
assert [object_value(item)["url"] for item in data] == [_EDITED_URL]
|
||||
(request,) = wire.drain()
|
||||
assert request.headers["authorization"] == f"Bearer {_API_KEY}"
|
||||
assert json.loads(request.body) == {
|
||||
"model": "qwen-image-2.0",
|
||||
"input": {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"image": _PNG_DATA_URI}, {"image": _PNG_DATA_URI}, {"text": _PROMPT}],
|
||||
}
|
||||
]
|
||||
},
|
||||
"parameters": {"size": "1024*1024", "negative_prompt": "blurry"},
|
||||
}
|
||||
settled_spend(key, rate("dashscope", "output_cost_per_image", model="qwen-image-2.0"))
|
||||
|
||||
|
||||
def test_a_fourth_image_is_refused_with_a_400_before_dashscope_is_called(gateway: Gateway) -> None:
|
||||
with wire_server(_edit_api) as wire, gateway.scenario() as scenario:
|
||||
model: Final = deployed(
|
||||
gateway,
|
||||
scenario,
|
||||
model="dashscope/qwen-image-2.0",
|
||||
api_base=f"{wire.url}{_MULTIMODAL_PATH}",
|
||||
api_key=_API_KEY,
|
||||
)
|
||||
response: Final = gateway.client.post(
|
||||
"/v1/images/edits",
|
||||
data={"model": model, "prompt": _PROMPT},
|
||||
files=[("image[]", (f"{index}.png", _PNG, "image/png")) for index in range(4)],
|
||||
headers={"Authorization": f"Bearer {gateway.key}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400, response.text
|
||||
assert "at most 3 input images, got 4" in _error_message(response.content)
|
||||
assert wire.drain() == ()
|
||||
|
||||
|
||||
@pytest.mark.timeout(_OWNED_TIMEOUT)
|
||||
def test_qwen3_tts_returns_the_downloaded_wav_and_bills_each_input_character(
|
||||
speech_proxy: Gateway, speech_wire: Wire
|
||||
) -> None:
|
||||
speech_wire.drain()
|
||||
with speech_proxy.scenario() as scenario:
|
||||
key: Final = scenario.key(models=[_QWEN_TTS])
|
||||
response: Final = speech_proxy.request(
|
||||
"POST", "/v1/audio/speech", {"model": _QWEN_TTS, "input": _TEXT, "voice": "Cherry"}, key=key
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.content == _WAV
|
||||
assert response.headers["content-type"] == "audio/wav"
|
||||
synthesis, download = speech_wire.drain()
|
||||
assert (synthesis.method, synthesis.target) == ("POST", _MULTIMODAL_PATH)
|
||||
assert synthesis.headers["authorization"] == f"Bearer {_API_KEY}"
|
||||
assert json.loads(synthesis.body) == {"model": "qwen3-tts-flash", "input": {"text": _TEXT, "voice": "Cherry"}}
|
||||
assert (download.method, download.target) == ("GET", f"{_OSS_PATH}speech.wav?{_SIGNED_QUERY}")
|
||||
assert "authorization" not in download.headers
|
||||
settled_spend(key, _BILLED_CHARACTERS * rate("dashscope", "input_cost_per_character", "qwen3-tts-flash"))
|
||||
|
||||
|
||||
@pytest.mark.timeout(_OWNED_TIMEOUT)
|
||||
def test_qwen_audio_tts_sends_format_and_instruction_returns_the_mp3_and_bills_reported_tokens(
|
||||
speech_proxy: Gateway, speech_wire: Wire
|
||||
) -> None:
|
||||
speech_wire.drain()
|
||||
model: Final = "qwen-audio-3.1-tts-flash"
|
||||
with speech_proxy.scenario() as scenario:
|
||||
key: Final = scenario.key(models=[_QWEN_AUDIO_TTS])
|
||||
response: Final = speech_proxy.request(
|
||||
"POST",
|
||||
"/v1/audio/speech",
|
||||
{
|
||||
"model": _QWEN_AUDIO_TTS,
|
||||
"input": _TEXT,
|
||||
"voice": "longanhuan_v3.1",
|
||||
"response_format": "mp3",
|
||||
"instructions": "Speak slowly.",
|
||||
},
|
||||
key=key,
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.content == _MP3
|
||||
assert response.headers["content-type"] == "audio/mpeg"
|
||||
synthesis, download = speech_wire.drain()
|
||||
assert (synthesis.method, synthesis.target) == ("POST", _SYNTHESIZER_PATH)
|
||||
assert json.loads(synthesis.body) == {
|
||||
"model": model,
|
||||
"input": {"text": _TEXT, "voice": "longanhuan_v3.1", "format": "mp3", "instruction": "Speak slowly."},
|
||||
}
|
||||
assert (download.method, download.target) == ("GET", f"{_OSS_PATH}speech.mp3?{_SIGNED_QUERY}")
|
||||
settled_spend(
|
||||
key,
|
||||
_INPUT_TOKENS * rate("dashscope", "input_cost_per_token", model)
|
||||
+ _OUTPUT_TOKENS * rate("dashscope", "output_cost_per_token", model),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.timeout(_OWNED_TIMEOUT)
|
||||
def test_a_format_qwen3_tts_cannot_produce_answers_400_without_reaching_dashscope(
|
||||
speech_proxy: Gateway, speech_wire: Wire
|
||||
) -> None:
|
||||
speech_wire.drain()
|
||||
refused: Final = speech_proxy.request(
|
||||
"POST", "/v1/audio/speech", {"model": _QWEN_TTS, "input": _TEXT, "voice": "Cherry", "response_format": "mp3"}
|
||||
)
|
||||
served: Final = speech_proxy.request(
|
||||
"POST", "/v1/audio/speech", {"model": _QWEN_TTS, "input": _TEXT, "voice": "Cherry", "response_format": "wav"}
|
||||
)
|
||||
|
||||
assert refused.status_code == 400, refused.text
|
||||
assert "response_format='mp3'" in _error_message(refused.content)
|
||||
assert served.status_code == 200, served.text
|
||||
assert [(request.method, request.target.split("?")[0]) for request in speech_wire.drain()] == [
|
||||
("POST", _MULTIMODAL_PATH),
|
||||
("GET", f"{_OSS_PATH}speech.wav"),
|
||||
]
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from integration._support.client import Gateway, eventually
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.process import owned_proxy
|
||||
from integration._support.wire import Reply, Request, wire_server
|
||||
from pydantic import JsonValue
|
||||
|
||||
PROMPT_TOKENS: Final = 19
|
||||
COMPLETION_TOKENS: Final = 5
|
||||
INPUT_RATE: Final = 0.001
|
||||
OUTPUT_RATE: Final = 0.002
|
||||
EXPECTED_SPEND: Final = PROMPT_TOKENS * INPUT_RATE + COMPLETION_TOKENS * OUTPUT_RATE
|
||||
USAGE: Final[dict[str, JsonValue]] = {
|
||||
"prompt_tokens": PROMPT_TOKENS,
|
||||
"completion_tokens": COMPLETION_TOKENS,
|
||||
"total_tokens": PROMPT_TOKENS + COMPLETION_TOKENS,
|
||||
}
|
||||
PROMPT: Final = "Say hi"
|
||||
|
||||
|
||||
def _sse_frame(payload: dict[str, JsonValue]) -> bytes:
|
||||
return f"data: {json.dumps(payload, separators=(',', ':'))}\n\n".encode()
|
||||
|
||||
|
||||
def _chunk(request_id: str, model: str, delta: dict[str, JsonValue], finish_reason: str | None) -> dict[str, JsonValue]:
|
||||
return {
|
||||
"id": request_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 1,
|
||||
"model": model,
|
||||
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
|
||||
}
|
||||
|
||||
|
||||
def _respond(request: Request) -> Reply:
|
||||
if request.method == "GET" and request.target == "/v1/models":
|
||||
return Reply(body=json.dumps({"object": "list", "data": [{"id": "gpt-5.4-mini", "object": "model"}]}).encode())
|
||||
assert request.method == "POST", request.method
|
||||
assert request.target == "/v1/chat/completions", request.target
|
||||
body: Final = json.loads(request.body)
|
||||
assert body["messages"] == [{"role": "user", "content": PROMPT}], body
|
||||
request_id: Final = f"chatcmpl-{uuid4().hex}"
|
||||
model: Final = str(body["model"])
|
||||
if body.get("stream") is not True:
|
||||
return Reply(
|
||||
body=json.dumps(
|
||||
{
|
||||
"id": request_id,
|
||||
"object": "chat.completion",
|
||||
"created": 1,
|
||||
"model": model,
|
||||
"choices": [
|
||||
{"index": 0, "message": {"role": "assistant", "content": "Hi there"}, "finish_reason": "length"}
|
||||
],
|
||||
"usage": USAGE,
|
||||
}
|
||||
).encode()
|
||||
)
|
||||
stream_options: Final = body.get("stream_options") or {}
|
||||
usage_frames: Final = (
|
||||
(
|
||||
_sse_frame(
|
||||
{
|
||||
"id": request_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 1,
|
||||
"model": model,
|
||||
"choices": [],
|
||||
"usage": USAGE,
|
||||
}
|
||||
),
|
||||
)
|
||||
if stream_options.get("include_usage") is True
|
||||
else ()
|
||||
)
|
||||
return Reply(
|
||||
content_type="text/event-stream",
|
||||
chunks=(
|
||||
_sse_frame(_chunk(request_id, model, {"role": "assistant", "content": "Hi"}, None)),
|
||||
_sse_frame(_chunk(request_id, model, {"content": " there"}, None)),
|
||||
_sse_frame(_chunk(request_id, model, {}, "length")),
|
||||
*usage_frames,
|
||||
b"data: [DONE]\n\n",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _proxy_config(directory: Path, model: str, upstream_url: str) -> Path:
|
||||
config: Final = directory / "text_completion_stream_usage_cost_config.yaml"
|
||||
config.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": model,
|
||||
"litellm_params": {
|
||||
"model": "openai/gpt-5.4-mini",
|
||||
"api_base": f"{upstream_url}/v1",
|
||||
"api_key": "scripted-upstream",
|
||||
"input_cost_per_token": INPUT_RATE,
|
||||
"output_cost_per_token": OUTPUT_RATE,
|
||||
},
|
||||
}
|
||||
],
|
||||
"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,
|
||||
},
|
||||
"router_settings": {"disable_cooldowns": True},
|
||||
}
|
||||
)
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def _data_events(body: str) -> tuple[dict[str, JsonValue], ...]:
|
||||
return tuple(
|
||||
json.loads(line.removeprefix("data:"))
|
||||
for line in body.splitlines()
|
||||
if line.startswith("data:") and not line.endswith("[DONE]")
|
||||
)
|
||||
|
||||
|
||||
def _carries_text_or_finish(frame: dict[str, JsonValue]) -> bool:
|
||||
choices: Final = frame["choices"]
|
||||
assert isinstance(choices, list) and len(choices) == 1, frame
|
||||
choice: Final = choices[0]
|
||||
assert isinstance(choice, dict), frame
|
||||
return choice.get("text") is not None or choice.get("finish_reason") is not None
|
||||
|
||||
|
||||
def _billed(row: dict[str, object]) -> tuple[object, object, float]:
|
||||
return (row["prompt_tokens"], row["completion_tokens"], float(str(row["spend"])))
|
||||
|
||||
|
||||
def test_text_completion_stream_without_stream_options_bills_like_its_twins(gateway: Gateway, tmp_path: Path) -> None:
|
||||
model: Final = f"gpt-5.4-mini-{uuid4().hex}"
|
||||
body: Final[dict[str, JsonValue]] = {"model": model, "prompt": PROMPT, "max_tokens": COMPLETION_TOKENS}
|
||||
|
||||
with wire_server(_respond) as wire:
|
||||
config: Final = _proxy_config(tmp_path, model, wire.url)
|
||||
with owned_proxy(gateway, tmp_path, {}, config=config) as candidate:
|
||||
silent: Final = candidate.request("POST", "/v1/completions", {**body, "stream": True})
|
||||
asked: Final = candidate.request(
|
||||
"POST", "/v1/completions", {**body, "stream": True, "stream_options": {"include_usage": True}}
|
||||
)
|
||||
whole: Final = candidate.request("POST", "/v1/completions", body)
|
||||
assert (silent.status_code, asked.status_code, whole.status_code) == (200, 200, 200), (
|
||||
silent.text,
|
||||
asked.text,
|
||||
whole.text,
|
||||
)
|
||||
silent_frames: Final = _data_events(silent.text)
|
||||
asked_frames: Final = _data_events(asked.text)
|
||||
request_ids: Final = (silent_frames[0]["id"], asked_frames[0]["id"], whole.json()["id"])
|
||||
rows: Final = eventually(
|
||||
lambda: read_rows(
|
||||
'SELECT request_id, prompt_tokens, completion_tokens, spend FROM "LiteLLM_SpendLogs" '
|
||||
"WHERE request_id = ANY(%s)",
|
||||
(list(request_ids),),
|
||||
database_url=candidate.database_url,
|
||||
),
|
||||
lambda values: len(values) == 3,
|
||||
seconds=70,
|
||||
)
|
||||
upstream_stream_options: Final = [
|
||||
json.loads(sent.body).get("stream_options") for sent in wire.drain() if sent.method == "POST"
|
||||
]
|
||||
|
||||
assert upstream_stream_options == [{"include_usage": True}, {"include_usage": True}, None]
|
||||
assert all(_carries_text_or_finish(frame) for frame in silent_frames), silent.text
|
||||
assert all("usage" not in frame for frame in silent_frames), silent.text
|
||||
assert asked_frames[-1]["usage"]["total_tokens"] == PROMPT_TOKENS + COMPLETION_TOKENS, asked.text
|
||||
billed: Final = {str(row["request_id"]): _billed(row) for row in rows}
|
||||
assert billed[str(request_ids[0])] == billed[str(request_ids[1])] == billed[str(request_ids[2])], billed
|
||||
assert billed[str(request_ids[0])] == (PROMPT_TOKENS, COMPLETION_TOKENS, pytest.approx(EXPECTED_SPEND)), billed
|
||||
|
|
@ -442,6 +442,7 @@ class _Agentic(CustomLogger):
|
|||
|
||||
|
||||
ADVISOR_TOOL: Final = {"type": ANTHROPIC_ADVISOR_TOOL_TYPE, "name": "advisor", "model": "claude-opus-5-5"}
|
||||
STRICT_TOOL: Final = {"name": "a", "input_schema": {"type": "object"}, "strict": True}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -527,6 +528,45 @@ def test_each_detected_feature_reaches_its_rule(
|
|||
assert served(kwargs) is python_result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
(
|
||||
pytest.param({"tools": [STRICT_TOOL]}, id="drop-params-unset"),
|
||||
pytest.param({"drop_params": False, "tools": [STRICT_TOOL]}, id="drop-params-off"),
|
||||
pytest.param(
|
||||
{"drop_params": True, "tools": [{"name": "a", "input_schema": {"type": "object"}}]}, id="no-strict"
|
||||
),
|
||||
),
|
||||
)
|
||||
async def test_strict_tools_stay_native_unless_python_could_drop_strict(
|
||||
monkeypatch: pytest.MonkeyPatch, kwargs: Mapping[str, object]
|
||||
) -> None:
|
||||
rules: Final[Rules] = (
|
||||
RouteRule(Route.MESSAGES, Rollout.PYTHON_ONLY, features=frozenset({Feature.STRICT_TOOLS_RETRY})),
|
||||
RouteRule(Route.MESSAGES, Rollout.RUST_REQUIRED),
|
||||
)
|
||||
native_result: Final = response()
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
|
||||
async def python(*call_args: object, **python_kwargs: object) -> AnthropicMessagesResponse: # kwargs-ok: stub
|
||||
pytest.fail("Python cannot drop strict here")
|
||||
|
||||
async def native(request: NativeCall) -> AnthropicMessagesResponse:
|
||||
return native_result
|
||||
|
||||
served: Final = await _ADISPATCH.arun(
|
||||
(16, MESSAGES, "anthropic/claude-sonnet-4-5"),
|
||||
kwargs,
|
||||
python=python,
|
||||
binding=amessages_binding(native),
|
||||
native=lambda hook, request, call_args, native_kwargs: hook(request),
|
||||
rules=rules,
|
||||
)
|
||||
|
||||
assert served is native_result
|
||||
|
||||
|
||||
ASYNC_ONLY_FEATURES: Final = (
|
||||
pytest.param(Feature.PRE_REQUEST_HOOK, {}, {"callbacks": [_Rewrite()]}, id="pre-request-hook"),
|
||||
pytest.param(
|
||||
|
|
@ -538,6 +578,12 @@ ASYNC_ONLY_FEATURES: Final = (
|
|||
{},
|
||||
id="messages-interceptor",
|
||||
),
|
||||
pytest.param(
|
||||
Feature.STRICT_TOOLS_RETRY, {"drop_params": True, "tools": [STRICT_TOOL]}, {}, id="strict-tools-retry"
|
||||
),
|
||||
pytest.param(
|
||||
Feature.STRICT_TOOLS_RETRY, {"tools": [STRICT_TOOL]}, {"drop_params": True}, id="strict-tools-retry-global"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ _PROVIDERS: Final[tuple[tuple[str, str, str, str], ...]] = (
|
|||
),
|
||||
("typesafe", "typesafe/jev-1.13", "https://api.typesafe.ai/v1/systemone", "jev-1.13"),
|
||||
("nace", "nace/drex-v2.0", "https://console.nace.ai/v1/systemone", "drex-v2.0"),
|
||||
("eu_jev", "eu_jev/eu-jeff-latest", "https://eu-jev.com/v1/systemone", "eu-jeff-latest"),
|
||||
(
|
||||
"openrouter",
|
||||
"openrouter/typesafe/jev-1.13",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from typing_extensions import ReadOnly, TypedDict, override
|
|||
|
||||
import litellm
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.types.utils import FileTypes, ImageResponse
|
||||
|
||||
_PNG_SIGNATURE: Final = b"\x89PNG\r\n\x1a\n"
|
||||
|
|
@ -34,6 +35,26 @@ _EDIT_RESPONSE: Final = {
|
|||
},
|
||||
}
|
||||
|
||||
_DASHSCOPE_IMAGE_URL: Final = (
|
||||
"https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
|
||||
)
|
||||
_DASHSCOPE_EDIT_RESPONSE: Final = {
|
||||
"output": {
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"message": {"role": "assistant", "content": [{"image": "https://example.com/edited.png"}]},
|
||||
}
|
||||
]
|
||||
},
|
||||
"usage": {"image_count": 1},
|
||||
}
|
||||
|
||||
_FAL_EDIT_URL: Final = "https://fal.run/fal-ai/flux-2/edit"
|
||||
_FAL_EDIT_RESPONSE: Final = {
|
||||
"images": [{"url": "https://example.com/edited.png", "width": 1024, "height": 768, "content_type": "image/png"}]
|
||||
}
|
||||
|
||||
|
||||
class _LoggedImageEdit(TypedDict):
|
||||
model: ReadOnly[str]
|
||||
|
|
@ -229,3 +250,101 @@ async def test_router_image_edit_returns_the_provider_image(
|
|||
assert isinstance(result, ImageResponse)
|
||||
assert result.data is not None and result.data[0].b64_json == _EDITED_IMAGE_B64
|
||||
assert route.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("deployment_price", "size", "expected_cost"),
|
||||
(
|
||||
({"input_cost_per_image": 0.0123}, None, 0.0123),
|
||||
({"input_cost_per_pixel": 1e-08}, "2048x2048", 2048 * 2048 * 1e-08),
|
||||
),
|
||||
)
|
||||
async def test_an_edit_bills_the_deployment_price_at_its_size_on_a_logging_object_made_before_routing(
|
||||
respx_mock: respx.MockRouter,
|
||||
httpx_transport: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
deployment_price: dict[str, float],
|
||||
size: str | None,
|
||||
expected_cost: float,
|
||||
) -> None:
|
||||
monkeypatch.delenv("DASHSCOPE_API_BASE", raising=False)
|
||||
monkeypatch.delenv("DASHSCOPE_API_BASE_IMAGE", raising=False)
|
||||
respx_mock.post(_DASHSCOPE_IMAGE_URL).mock(return_value=httpx.Response(200, json=_DASHSCOPE_EDIT_RESPONSE))
|
||||
logging_made_before_routing: Final = Logging(
|
||||
model="qwen-image-edit",
|
||||
messages=[],
|
||||
stream=False,
|
||||
call_type="aimage_edit",
|
||||
start_time=datetime.now(),
|
||||
litellm_call_id="edit-call",
|
||||
function_id="edit-call",
|
||||
)
|
||||
|
||||
router: Final = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "qwen-image-edit",
|
||||
"litellm_params": {
|
||||
"model": "dashscope/qwen-image-2.0",
|
||||
"api_key": "fake-key",
|
||||
**deployment_price,
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
result: Final = await router.aimage_edit(
|
||||
model="qwen-image-edit",
|
||||
image=_FIRST_IMAGE,
|
||||
prompt="Turn the circle into a square",
|
||||
size=size,
|
||||
litellm_logging_obj=logging_made_before_routing,
|
||||
litellm_call_id="edit-call",
|
||||
)
|
||||
|
||||
assert isinstance(result, ImageResponse)
|
||||
assert result._hidden_params["response_cost"] == pytest.approx(expected_cost)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_fal_edit_bills_the_deployment_price_per_image_on_a_logging_object_made_before_routing(
|
||||
respx_mock: respx.MockRouter,
|
||||
httpx_transport: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("FAL_AI_API_BASE", raising=False)
|
||||
respx_mock.post(_FAL_EDIT_URL).mock(return_value=httpx.Response(200, json=_FAL_EDIT_RESPONSE))
|
||||
logging_made_before_routing: Final = Logging(
|
||||
model="flux-edit",
|
||||
messages=[],
|
||||
stream=False,
|
||||
call_type="aimage_edit",
|
||||
start_time=datetime.now(),
|
||||
litellm_call_id="fal-edit-call",
|
||||
function_id="fal-edit-call",
|
||||
)
|
||||
|
||||
router: Final = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "flux-edit",
|
||||
"litellm_params": {
|
||||
"model": "fal_ai/fal-ai/flux-2/edit",
|
||||
"api_key": "fake-key",
|
||||
"output_cost_per_image": 0.0456,
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
result: Final = await router.aimage_edit(
|
||||
model="flux-edit",
|
||||
image=_FIRST_IMAGE,
|
||||
prompt="Turn the circle into a square",
|
||||
litellm_logging_obj=logging_made_before_routing,
|
||||
litellm_call_id="fal-edit-call",
|
||||
)
|
||||
|
||||
assert isinstance(result, ImageResponse)
|
||||
assert result._hidden_params["response_cost"] == pytest.approx(0.0456)
|
||||
|
|
|
|||
|
|
@ -523,6 +523,43 @@ def test_token_counter_skips_non_mapping_tools():
|
|||
assert mixed_tokens == valid_tokens
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("shorthand", "explicit"),
|
||||
[({"type": "array"}, {"type": "array", "items": {}}), (True, {})],
|
||||
ids=["array_without_items", "boolean_subschema"],
|
||||
)
|
||||
def test_token_counter_counts_json_schema_shorthands_like_their_explicit_form(shorthand: object, explicit: object) -> None:
|
||||
assert _count_with_property_schema(shorthand) == _count_with_property_schema(explicit)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("malformed", "without_keyword"),
|
||||
[
|
||||
(
|
||||
{"type": "object", "properties": {"id": {"type": "string"}}, "required": True},
|
||||
{"type": "object", "properties": {"id": {"type": "string"}}},
|
||||
),
|
||||
({"type": "object", "properties": [{"id": {"type": "string"}}]}, {"type": "object"}),
|
||||
({"type": "string", "enum": None}, {"type": "string"}),
|
||||
({"type": "integer", "enum": 3}, {"type": "integer"}),
|
||||
],
|
||||
ids=["required_true", "properties_list", "string_enum_null", "integer_enum_number"],
|
||||
)
|
||||
def test_token_counter_counts_a_malformed_schema_keyword_as_absent(malformed: object, without_keyword: object) -> None:
|
||||
assert _count_with_property_schema(malformed) == _count_with_property_schema(without_keyword)
|
||||
|
||||
|
||||
def _count_with_property_schema(property_schema: object) -> int:
|
||||
tool: Final = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_files",
|
||||
"parameters": {"type": "object", "properties": {"paths": property_schema}},
|
||||
},
|
||||
}
|
||||
return token_counter_new(model="gpt-4o", messages=[{"role": "user", "content": "Read the config files"}], tools=[tool])
|
||||
|
||||
|
||||
_WEATHER_FUNCTION: Final = {
|
||||
"name": "get_weather",
|
||||
"description": "Get the current weather for a city",
|
||||
|
|
@ -2132,6 +2169,20 @@ def test_token_counter_counts_anthropic_search_result_text() -> None:
|
|||
)
|
||||
|
||||
|
||||
def test_token_counter_counts_a_search_result_without_a_title() -> None:
|
||||
from litellm.litellm_core_utils.token_counter import _count_content_list
|
||||
|
||||
search_result: Final = {
|
||||
"type": "search_result",
|
||||
"source": "https://example.invalid/notes",
|
||||
"content": [{"type": "text", "text": "The code word is 7f3a."}],
|
||||
}
|
||||
|
||||
assert _count_content_list(len, [search_result], False, None) == len(
|
||||
"https://example.invalid/notesThe code word is 7f3a."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nested", (False, True))
|
||||
def test_unknown_content_type_remains_a_value_error_with_an_optional_fallback(nested: bool) -> None:
|
||||
from litellm.litellm_core_utils.token_counter import _count_content_list
|
||||
|
|
|
|||
|
|
@ -217,6 +217,106 @@ def test_cleared_input_tokens_is_nonnegative():
|
|||
assert applied["cleared_input_tokens"] >= 0
|
||||
|
||||
|
||||
SERVER_TOOL_TURN: Final = {
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "server_tool_use", "id": "srvtoolu_01", "name": "web_search", "input": {"query": "Mumbai rain"}},
|
||||
{
|
||||
"type": "web_search_tool_result",
|
||||
"tool_use_id": "srvtoolu_01",
|
||||
"content": [{"type": "web_search_result", "url": "https://example.com", "title": "rain " * 300}],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _clear_above(
|
||||
messages: list[dict[str, object]], threshold: int
|
||||
) -> tuple[list[dict[str, object]], AppliedEdit | None]:
|
||||
return apply_clear_tool_uses_20250919(
|
||||
model=MODEL,
|
||||
messages=messages,
|
||||
tools=None,
|
||||
system=None,
|
||||
edit_spec={
|
||||
"type": "clear_tool_uses_20250919",
|
||||
"trigger": {"type": "input_tokens", "value": threshold},
|
||||
"keep": {"type": "tool_uses", "value": 1},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_an_input_tokens_trigger_skips_blocks_no_adapter_sends() -> None:
|
||||
history: Final = _make_history(n_pairs=3)
|
||||
sent_tokens: Final = litellm.token_counter(model=MODEL, messages=history)
|
||||
turn: Final = SERVER_TOOL_TURN
|
||||
messages: Final = [history[0], turn, *history[1:]]
|
||||
|
||||
untouched, not_applied = _clear_above(messages, sent_tokens + 100)
|
||||
cleared, applied = _clear_above(messages, sent_tokens - 100)
|
||||
|
||||
assert not_applied is None
|
||||
assert untouched == messages
|
||||
assert applied is not None
|
||||
assert applied["cleared_tool_uses"] == 2
|
||||
assert _cleared_ids(cleared) == {"toolu_00", "toolu_01"}
|
||||
assert cleared[1] == turn
|
||||
|
||||
|
||||
def _first_result_with(blocks: list[dict[str, object]]) -> dict[str, object]:
|
||||
return {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_00", "content": blocks}]}
|
||||
|
||||
|
||||
def test_an_input_tokens_trigger_skips_blocks_no_adapter_sends_inside_a_tool_result() -> None:
|
||||
history: Final = _make_history(n_pairs=3)
|
||||
result_text: Final = {"type": "text", "text": "Result 0: " + "x" * 200}
|
||||
search_result: Final = {"type": "web_search_result", "url": "https://example.com", "title": "rain " * 300}
|
||||
sent_tokens: Final = litellm.token_counter(
|
||||
model=MODEL, messages=[*history[:2], _first_result_with([result_text]), *history[3:]]
|
||||
)
|
||||
messages: Final = [*history[:2], _first_result_with([result_text, search_result]), *history[3:]]
|
||||
|
||||
untouched, not_applied = _clear_above(messages, sent_tokens + 100)
|
||||
cleared, applied = _clear_above(messages, sent_tokens - 100)
|
||||
|
||||
assert not_applied is None
|
||||
assert untouched == messages
|
||||
assert applied is not None
|
||||
assert _cleared_ids(cleared) == {"toolu_00", "toolu_01"}
|
||||
|
||||
|
||||
def _clear_at_least_one_token(
|
||||
messages: list[dict[str, object]],
|
||||
) -> tuple[list[dict[str, object]], AppliedEdit | None]:
|
||||
return apply_clear_tool_uses_20250919(
|
||||
model=MODEL,
|
||||
messages=messages,
|
||||
tools=None,
|
||||
system=None,
|
||||
edit_spec={
|
||||
"type": "clear_tool_uses_20250919",
|
||||
"trigger": {"type": "tool_uses", "value": 0},
|
||||
"keep": {"type": "tool_uses", "value": 1},
|
||||
"clear_at_least": {"type": "input_tokens", "value": 1},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_clear_at_least_frees_only_blocks_an_adapter_sends_inside_a_tool_result() -> None:
|
||||
history: Final = _make_history(n_pairs=3)
|
||||
result_text: Final = {"type": "text", "text": "Result 0: " + "x" * 200}
|
||||
search_result: Final = {"type": "web_search_result", "url": "https://example.com", "title": "rain " * 300}
|
||||
|
||||
_, sent_only = _clear_at_least_one_token([*history[:2], _first_result_with([result_text]), *history[3:]])
|
||||
cleared, applied = _clear_at_least_one_token(
|
||||
[*history[:2], _first_result_with([result_text, search_result]), *history[3:]]
|
||||
)
|
||||
|
||||
assert applied is not None
|
||||
assert applied == sent_only
|
||||
assert _cleared_ids(cleared) == {"toolu_00", "toolu_01"}
|
||||
|
||||
|
||||
def _blocks_of(messages: list[dict[str, object]], block_type: str) -> tuple[dict[str, object], ...]:
|
||||
contents: Final = (message["content"] for message in messages)
|
||||
blocks: Final = chain.from_iterable(content for content in contents if isinstance(content, list))
|
||||
|
|
@ -559,6 +659,9 @@ async def _malformed_spec_error(edits: list[dict[str, object]]) -> AnthropicCont
|
|||
("exclude_tools", "get_weather"),
|
||||
("exclude_tools", [1]),
|
||||
("clear_tool_inputs", "get_weather"),
|
||||
("trigger", "soon"),
|
||||
("trigger", [1]),
|
||||
("keep", 5),
|
||||
],
|
||||
)
|
||||
async def test_malformed_fields_are_a_400_even_below_the_trigger(field: str, value: object) -> None:
|
||||
|
|
@ -576,12 +679,15 @@ async def test_malformed_fields_are_a_400_even_below_the_trigger(field: str, val
|
|||
("clear_at_least", 1200, "clear_at_least: Input does not match the expected shape."),
|
||||
("clear_at_least", {"type": "input_tokens", "value": "1200"}, "value: Input should be a valid integer"),
|
||||
("clear_tool_inputs", 5, "clear_tool_inputs.bool: Input should be a valid boolean, unable to interpret input"),
|
||||
("trigger", "soon", "trigger: Input should be an object"),
|
||||
("keep", 5, "keep: Input should be an object"),
|
||||
],
|
||||
)
|
||||
async def test_malformed_field_400_names_the_edit_and_field_as_the_api_does(
|
||||
field: str, value: object, message: str
|
||||
) -> None:
|
||||
# api.anthropic.com, measured 2026-10-19, answers these four with these messages for the edit at index 1.
|
||||
# api.anthropic.com answers these with these messages for the edit at index 1 (trigger and keep measured
|
||||
# 2026-10-28, the rest 2026-10-19).
|
||||
error = await _malformed_spec_error(
|
||||
[{"type": "clear_thinking_20251015"}, {"type": "clear_tool_uses_20250919", field: value}]
|
||||
)
|
||||
|
|
@ -625,6 +731,28 @@ async def test_drop_params_drops_only_the_malformed_field(field: str, value: obj
|
|||
assert _tool_inputs(result.messages) == {"toolu_00": {}, "toolu_01": {}, "toolu_02": {"location": "City2"}}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("field", "value"), [("trigger", "soon"), ("keep", 5)])
|
||||
async def test_drop_params_treats_a_malformed_trigger_or_keep_as_never_sent(field: str, value: object) -> None:
|
||||
async def run(edit: dict[str, object], drop_invalid_edits: bool) -> PolyfillResult | None:
|
||||
return await _run_polyfill_if_enabled(
|
||||
model=MODEL,
|
||||
messages=_make_history(n_pairs=5),
|
||||
tools=None,
|
||||
system=None,
|
||||
context_management_spec={"edits": [edit]},
|
||||
litellm_metadata={},
|
||||
additional_drop_params=None,
|
||||
llm_router=None,
|
||||
drop_invalid_edits=drop_invalid_edits,
|
||||
)
|
||||
|
||||
malformed: Final = {**_VALID_EDIT, field: value}
|
||||
with pytest.raises(AnthropicContextManagementError, match=rf"\.{field}: Input should be an object$"):
|
||||
await run(malformed, drop_invalid_edits=False)
|
||||
never_sent: Final = {key: spec for key, spec in _VALID_EDIT.items() if key != field}
|
||||
assert await run(malformed, drop_invalid_edits=True) == await run(never_sent, drop_invalid_edits=True)
|
||||
|
||||
|
||||
async def test_malformed_edit_is_rejected_before_a_compact_summary_is_paid_for(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ from litellm.llms.anthropic.messages.context_management.editors.compact import (
|
|||
_augment_system_with_summary,
|
||||
_extract_summary_text,
|
||||
_select_last_user_question,
|
||||
_slice_around_compaction_block,
|
||||
slice_around_compaction_block,
|
||||
_strip_compaction_blocks,
|
||||
apply_client_compaction_block_history,
|
||||
apply_compact_20260112,
|
||||
|
|
@ -153,7 +153,7 @@ def test_applied_edits_for_response_includes_compact_when_block_present():
|
|||
|
||||
def test_slice_around_compaction_block_found():
|
||||
messages = _messages_with_compaction("my summary")
|
||||
sliced, block = _slice_around_compaction_block(messages)
|
||||
sliced, block = slice_around_compaction_block(messages)
|
||||
assert block is not None
|
||||
assert block["type"] == "compaction"
|
||||
assert block["content"] == "my summary"
|
||||
|
|
@ -164,7 +164,7 @@ def test_slice_around_compaction_block_found():
|
|||
|
||||
def test_slice_around_compaction_block_not_found():
|
||||
messages = _simple_messages()
|
||||
sliced, block = _slice_around_compaction_block(messages)
|
||||
sliced, block = slice_around_compaction_block(messages)
|
||||
assert block is None
|
||||
assert sliced is messages # same object, no copy
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Unit tests for the context_management polyfill dispatcher.
|
|||
from litellm.llms.anthropic.messages.context_management import (
|
||||
apply_context_management,
|
||||
)
|
||||
from litellm.llms.anthropic.messages.convertible_blocks import drop_non_string_tool_result_texts
|
||||
|
||||
MODEL = "xai/grok-4"
|
||||
|
||||
|
|
@ -160,3 +161,57 @@ async def test_sync_editor_counts_tokens_off_the_event_loop():
|
|||
|
||||
assert result.messages == messages
|
||||
assert_loop_stayed_free(took, lags)
|
||||
|
||||
|
||||
def _history_with_null_text_beside_a_short_first_result():
|
||||
return [
|
||||
{"role": "user", "content": "Hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "tool_use", "id": "t0", "name": "f", "input": {}}],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "t0",
|
||||
"content": [{"type": "text", "text": "ok"}, {"type": "text", "text": None}],
|
||||
}
|
||||
],
|
||||
},
|
||||
*_history_with_two_tool_pairs()[1:],
|
||||
]
|
||||
|
||||
|
||||
async def test_drop_invalid_edits_clears_as_if_malformed_tool_result_text_was_never_sent():
|
||||
spec = {
|
||||
"edits": [
|
||||
{
|
||||
"type": "clear_tool_uses_20250919",
|
||||
"trigger": {"type": "tool_uses", "value": 2},
|
||||
"keep": {"type": "tool_uses", "value": 1},
|
||||
}
|
||||
]
|
||||
}
|
||||
history = _history_with_null_text_beside_a_short_first_result()
|
||||
|
||||
from_raw = await apply_context_management(
|
||||
model=MODEL,
|
||||
messages=history,
|
||||
tools=None,
|
||||
system=None,
|
||||
context_management_spec=spec,
|
||||
drop_invalid_edits=True,
|
||||
)
|
||||
from_sent = await apply_context_management(
|
||||
model=MODEL,
|
||||
messages=list(drop_non_string_tool_result_texts(history, True)),
|
||||
tools=None,
|
||||
system=None,
|
||||
context_management_spec=spec,
|
||||
drop_invalid_edits=True,
|
||||
)
|
||||
|
||||
assert from_raw.applied_edits == from_sent.applied_edits
|
||||
assert from_raw.messages == from_sent.messages
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import copy
|
|||
import json
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Final, List
|
||||
|
||||
import httpx
|
||||
|
|
@ -10,7 +11,7 @@ import responses
|
|||
import respx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import ValidationError
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
|
@ -2396,3 +2397,61 @@ def test_native_messages_tool_config_point_marks_last_tool_and_drops_the_key(mon
|
|||
assert "cache_control" not in captured["params"]["tools"][0]
|
||||
assert captured["messages"][0]["content"][-1]["cache_control"] == {"type": "ephemeral"}
|
||||
assert tools == tools_before
|
||||
|
||||
|
||||
# Anthropic's 400 for a request past Claude's strict tool limits, seen live on claude-sonnet-4-6 on 2026-10-29
|
||||
_TOO_MANY_STRICT_TOOLS: Final = {
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "invalid_request_error",
|
||||
"message": "Too many strict tools (21). The maximum number of strict tools supported is 20. Try reducing the "
|
||||
"number of tools marked as strict.",
|
||||
},
|
||||
"request_id": "req_011CgWXzv8cmAr9vFs786WV4",
|
||||
}
|
||||
_JSON_BODY: Final = TypeAdapter(dict[str, object])
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _RejectsStrictToolsOnce:
|
||||
sent: tuple[dict[str, object], ...] = ()
|
||||
|
||||
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||
self.sent = (*self.sent, _JSON_BODY.validate_json(request.content))
|
||||
if len(self.sent) == 1:
|
||||
return httpx.Response(400, json=_TOO_MANY_STRICT_TOOLS, request=request)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "msg_strict_retry",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"content": [{"type": "text", "text": "done"}],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": None,
|
||||
"usage": {"input_tokens": 1, "output_tokens": 1},
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_messages_strict_tools_rejection_is_sent_again_without_strict_under_drop_params() -> None:
|
||||
anthropic: Final = _RejectsStrictToolsOnce()
|
||||
tool: Final = {"name": "lookup_record", "input_schema": {"type": "object"}, "strict": True}
|
||||
|
||||
response: Final = await litellm.anthropic_messages(
|
||||
max_tokens=16,
|
||||
messages=[{"role": "user", "content": "Use lookup_record to find invoice 42"}],
|
||||
model="anthropic/claude-sonnet-4-6",
|
||||
api_key="sk-test",
|
||||
tools=[tool],
|
||||
drop_params=True,
|
||||
client=AsyncHTTPHandler(transport=httpx.MockTransport(anthropic)),
|
||||
)
|
||||
|
||||
first, second = anthropic.sent
|
||||
assert first["tools"] == [tool]
|
||||
assert second == {**first, "tools": [{"name": "lookup_record", "input_schema": {"type": "object"}}]}
|
||||
assert _JSON_BODY.validate_python(response)["id"] == "msg_strict_retry"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import datetime
|
|||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
from itertools import chain
|
||||
from typing import Final, cast
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
|
@ -761,3 +762,325 @@ async def test_recomputed_cost_of_a_served_bridge_response_prices_the_reasoning_
|
|||
assert logging_obj.cost_breakdown is not None
|
||||
assert logging_obj.cost_breakdown["output_cost"] == pytest.approx(expected_output)
|
||||
assert logging_obj.cost_breakdown["reasoning_cost"] == pytest.approx(expected_reasoning)
|
||||
|
||||
|
||||
TEXT_RESPONSE: Final = {
|
||||
"id": "resp_cleared",
|
||||
"object": "response",
|
||||
"created_at": 1,
|
||||
"status": "completed",
|
||||
"model": "gpt-5.6-luna",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_1",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": "done", "annotations": []}],
|
||||
}
|
||||
],
|
||||
"parallel_tool_calls": True,
|
||||
"tool_choice": "auto",
|
||||
"tools": [],
|
||||
"usage": {"input_tokens": 3, "output_tokens": 4, "total_tokens": 7},
|
||||
}
|
||||
CLEAR_EDIT: Final = {
|
||||
"type": "clear_tool_uses_20250919",
|
||||
"trigger": {"type": "tool_uses", "value": 1},
|
||||
"keep": {"type": "tool_uses", "value": 1},
|
||||
}
|
||||
COMPACT_EDIT: Final = {"type": "compact_20260112", "trigger": {"type": "input_tokens", "value": 150000}}
|
||||
|
||||
|
||||
def _read(i: int) -> tuple[dict[str, object], dict[str, object]]:
|
||||
return (
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "tool_use", "id": f"toolu_{i}", "name": "Bash", "input": {"command": f"cat {i}"}}],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "tool_result", "tool_use_id": f"toolu_{i}", "content": f"log {i} " * 40}],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _three_reads() -> list[dict[str, object]]:
|
||||
return [{"role": "user", "content": "Read the logs."}, *chain.from_iterable(_read(i) for i in range(3))]
|
||||
|
||||
|
||||
async def _cleared_bridge(
|
||||
respx_mock: respx.MockRouter,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
stream: bool,
|
||||
edits: list[dict[str, object]],
|
||||
drop_params: bool = False,
|
||||
system: object = None,
|
||||
) -> tuple[object, Mapping[str, object]]:
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-clear-test")
|
||||
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
upstream: Final = respx_mock.post("https://api.openai.com/v1/responses")
|
||||
if stream:
|
||||
upstream.respond(status_code=200, headers={"Content-Type": "text/event-stream"}, content=RESPONSES_SSE_BODY)
|
||||
else:
|
||||
upstream.respond(status_code=200, json=TEXT_RESPONSE)
|
||||
response: Final = await LiteLLMMessagesToResponsesAPIHandler.async_anthropic_messages_handler(
|
||||
max_tokens=1024,
|
||||
messages=_three_reads(),
|
||||
model="openai/gpt-5.6-luna",
|
||||
tools=[dict(BASH_TOOL)],
|
||||
stream=stream,
|
||||
custom_llm_provider="openai",
|
||||
context_management={"edits": edits},
|
||||
drop_params=drop_params,
|
||||
system=system,
|
||||
)
|
||||
body: Final = json.loads(upstream.calls.last.request.content)
|
||||
if not stream:
|
||||
return response, body
|
||||
return [json.loads(chunk.decode().split("data: ", 1)[1]) async for chunk in response], body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_tool_uses_clears_old_results_before_the_responses_api_call(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from litellm.llms.anthropic.messages.context_management.constants import CLEARED_TOOL_RESULT_PLACEHOLDER
|
||||
|
||||
served, body = await _cleared_bridge(respx_mock, monkeypatch, stream=False, edits=[dict(CLEAR_EDIT)])
|
||||
|
||||
outputs: Final = {
|
||||
item["call_id"]: item["output"] for item in body["input"] if item.get("type") == "function_call_output"
|
||||
}
|
||||
assert [CLEARED_TOOL_RESULT_PLACEHOLDER in json.dumps(outputs[f"toolu_{i}"]) for i in range(3)] == [
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
]
|
||||
assert "context_management" not in body
|
||||
assert isinstance(served, dict)
|
||||
assert served["context_management"]["applied_edits"][0]["cleared_tool_uses"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamed_clear_tool_uses_reports_its_edits_on_the_final_message_delta(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
events, _ = await _cleared_bridge(respx_mock, monkeypatch, stream=True, edits=[dict(CLEAR_EDIT)])
|
||||
|
||||
assert isinstance(events, list)
|
||||
message_delta: Final = next(event for event in events if event["type"] == "message_delta")
|
||||
assert message_delta["context_management"]["applied_edits"][0]["cleared_tool_uses"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compact_stays_native_while_clear_tool_uses_runs_in_the_gateway(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
served, body = await _cleared_bridge(
|
||||
respx_mock, monkeypatch, stream=False, edits=[dict(CLEAR_EDIT), dict(COMPACT_EDIT)]
|
||||
)
|
||||
|
||||
assert body["context_management"] == [{"type": "compaction", "compact_threshold": 150000}]
|
||||
assert isinstance(served, dict)
|
||||
assert [edit["type"] for edit in served["context_management"]["applied_edits"]] == ["clear_tool_uses_20250919"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_malformed_clear_tool_uses_edit_is_a_400_before_the_responses_api_call(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from litellm.llms.anthropic.messages.context_management import AnthropicContextManagementError
|
||||
|
||||
upstream: Final = respx_mock.post("https://api.openai.com/v1/responses").respond(
|
||||
status_code=200, json=TEXT_RESPONSE
|
||||
)
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-clear-test")
|
||||
with pytest.raises(AnthropicContextManagementError) as raised:
|
||||
await LiteLLMMessagesToResponsesAPIHandler.async_anthropic_messages_handler(
|
||||
max_tokens=1024,
|
||||
messages=_three_reads(),
|
||||
model="openai/gpt-5.6-luna",
|
||||
custom_llm_provider="openai",
|
||||
context_management={"edits": [{**CLEAR_EDIT, "exclude_tools": "Bash"}]},
|
||||
)
|
||||
assert raised.value.status_code == 400
|
||||
assert not upstream.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_malformed_text_in_a_result_clearing_would_drop_is_still_a_400(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
upstream: Final = respx_mock.post("https://api.openai.com/v1/responses").respond(
|
||||
status_code=200, json=TEXT_RESPONSE
|
||||
)
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-clear-test")
|
||||
reads: Final = _three_reads()
|
||||
malformed_oldest_result: Final = {
|
||||
"role": "user",
|
||||
"content": [{"type": "tool_result", "tool_use_id": "toolu_0", "content": [{"type": "text", "text": None}]}],
|
||||
}
|
||||
with pytest.raises(litellm.BadRequestError) as rejected:
|
||||
await LiteLLMMessagesToResponsesAPIHandler.async_anthropic_messages_handler(
|
||||
max_tokens=1024,
|
||||
messages=[*reads[:2], malformed_oldest_result, *reads[3:]],
|
||||
model="openai/gpt-5.6-luna",
|
||||
tools=[dict(BASH_TOOL)],
|
||||
custom_llm_provider="openai",
|
||||
context_management={"edits": [dict(CLEAR_EDIT)]},
|
||||
)
|
||||
assert rejected.value.status_code == 400
|
||||
assert "tool_result toolu_0 content.0.text: Input should be a valid string" in rejected.value.message
|
||||
assert not upstream.called
|
||||
|
||||
|
||||
def test_the_sync_path_clears_old_results_too(respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from litellm.llms.anthropic.messages.context_management.constants import CLEARED_TOOL_RESULT_PLACEHOLDER
|
||||
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-clear-test")
|
||||
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
upstream: Final = respx_mock.post("https://api.openai.com/v1/responses").respond(
|
||||
status_code=200, json=TEXT_RESPONSE
|
||||
)
|
||||
|
||||
served: Final = LiteLLMMessagesToResponsesAPIHandler.anthropic_messages_handler(
|
||||
max_tokens=1024,
|
||||
messages=_three_reads(),
|
||||
model="openai/gpt-5.6-luna",
|
||||
tools=[dict(BASH_TOOL)],
|
||||
custom_llm_provider="openai",
|
||||
context_management={"edits": [dict(CLEAR_EDIT)]},
|
||||
)
|
||||
|
||||
body: Final = json.loads(upstream.calls.last.request.content)
|
||||
outputs: Final = {
|
||||
item["call_id"]: item["output"] for item in body["input"] if item.get("type") == "function_call_output"
|
||||
}
|
||||
assert [CLEARED_TOOL_RESULT_PLACEHOLDER in json.dumps(outputs[f"toolu_{i}"]) for i in range(3)] == [
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
]
|
||||
assert isinstance(served, dict)
|
||||
assert served["context_management"]["applied_edits"][0]["cleared_tool_uses"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_openai_style_context_management_list_is_still_not_sent_to_the_responses_api(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
upstream: Final = respx_mock.post("https://api.openai.com/v1/responses").respond(
|
||||
status_code=200, json=TEXT_RESPONSE
|
||||
)
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-clear-test")
|
||||
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
|
||||
served: Final = await LiteLLMMessagesToResponsesAPIHandler.async_anthropic_messages_handler(
|
||||
max_tokens=1024,
|
||||
messages=_three_reads(),
|
||||
model="openai/gpt-5.6-luna",
|
||||
custom_llm_provider="openai",
|
||||
context_management=[{"type": "compaction", "compact_threshold": 500}],
|
||||
)
|
||||
|
||||
assert "context_management" not in json.loads(upstream.calls.last.request.content)
|
||||
assert isinstance(served, dict)
|
||||
assert "context_management" not in served
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_under_drop_params_a_malformed_edit_still_clears_without_its_bad_field(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from litellm.llms.anthropic.messages.context_management.constants import CLEARED_TOOL_RESULT_PLACEHOLDER
|
||||
|
||||
served, body = await _cleared_bridge(
|
||||
respx_mock, monkeypatch, stream=False, edits=[{**CLEAR_EDIT, "exclude_tools": "Bash"}], drop_params=True
|
||||
)
|
||||
|
||||
outputs: Final = {
|
||||
item["call_id"]: item["output"] for item in body["input"] if item.get("type") == "function_call_output"
|
||||
}
|
||||
assert [CLEARED_TOOL_RESULT_PLACEHOLDER in json.dumps(outputs[f"toolu_{i}"]) for i in range(3)] == [
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
]
|
||||
assert isinstance(served, dict)
|
||||
assert served["context_management"]["applied_edits"][0]["cleared_tool_uses"] == 2
|
||||
|
||||
|
||||
def _cleared_outputs(body: Mapping[str, object]) -> list[bool]:
|
||||
from litellm.llms.anthropic.messages.context_management.constants import CLEARED_TOOL_RESULT_PLACEHOLDER
|
||||
|
||||
outputs: Final = {
|
||||
item["call_id"]: item["output"] for item in body["input"] if item.get("type") == "function_call_output"
|
||||
}
|
||||
return [CLEARED_TOOL_RESULT_PLACEHOLDER in json.dumps(outputs[f"toolu_{i}"]) for i in range(3)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"system",
|
||||
[7, {"type": "text", "text": "Be brief."}, ["Be brief."], ["Be brief.", {"type": "text", "text": "Be kind."}]],
|
||||
ids=["number", "object", "strings", "mixed_list"],
|
||||
)
|
||||
@pytest.mark.parametrize("stream", [False, True], ids=["non_stream", "stream"])
|
||||
async def test_clear_tool_uses_still_clears_when_the_system_prompt_is_not_text_or_a_block_list(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, system: object, stream: bool
|
||||
) -> None:
|
||||
_, body = await _cleared_bridge(respx_mock, monkeypatch, stream=stream, edits=[dict(CLEAR_EDIT)], system=system)
|
||||
|
||||
assert _cleared_outputs(body) == [True, True, False]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("system", "expect_cleared"),
|
||||
[
|
||||
(["Stray.", {"type": "text", "text": "Follow the policy. " * 1500}], [True, True, False]),
|
||||
(["Follow the policy. " * 1500], [False, False, False]),
|
||||
],
|
||||
ids=["text_block_counts", "stray_string_does_not"],
|
||||
)
|
||||
async def test_input_tokens_trigger_counts_the_text_blocks_of_a_system_list_with_stray_items(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, system: object, expect_cleared: list[bool]
|
||||
) -> None:
|
||||
trigger_between_messages_and_system: Final = litellm.token_counter(model="gpt-4o", messages=_three_reads()) + 1000
|
||||
edit: Final = {**CLEAR_EDIT, "trigger": {"type": "input_tokens", "value": trigger_between_messages_and_system}}
|
||||
|
||||
_, body = await _cleared_bridge(respx_mock, monkeypatch, stream=False, edits=[edit], system=system)
|
||||
|
||||
assert _cleared_outputs(body) == expect_cleared
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_sync_path_reports_its_edits_on_the_streamed_message_delta(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-clear-test")
|
||||
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
respx_mock.post("https://api.openai.com/v1/responses").respond(
|
||||
status_code=200, headers={"Content-Type": "text/event-stream"}, content=RESPONSES_SSE_BODY
|
||||
)
|
||||
|
||||
sse: Final = LiteLLMMessagesToResponsesAPIHandler.anthropic_messages_handler(
|
||||
max_tokens=1024,
|
||||
messages=_three_reads(),
|
||||
model="openai/gpt-5.6-luna",
|
||||
tools=[dict(BASH_TOOL)],
|
||||
stream=True,
|
||||
custom_llm_provider="openai",
|
||||
context_management={"edits": [dict(CLEAR_EDIT)]},
|
||||
)
|
||||
events: Final = [
|
||||
json.loads(chunk.decode().split("data: ", 1)[1]) async for chunk in cast(AsyncIterator[bytes], sse)
|
||||
]
|
||||
message_delta: Final = next(event for event in events if event["type"] == "message_delta")
|
||||
assert message_delta["context_management"]["applied_edits"][0]["cleared_tool_uses"] == 2
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import pytest
|
|||
import respx
|
||||
|
||||
import litellm
|
||||
from litellm.llms.anthropic.common_utils import is_claude_tool_target
|
||||
from litellm.llms.anthropic.common_utils import ToolsWithoutStrict, is_claude_tool_target, tools_without_strict
|
||||
from litellm.llms.custom_httpx.http_handler import HTTPHandler
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../..")))
|
||||
|
|
@ -4559,3 +4559,27 @@ def test_shared_legacy_thinking_translation_drops_a_non_numeric_budget_under_dro
|
|||
)
|
||||
def test_is_claude_tool_target(custom_llm_provider: str, model: str, expected: bool):
|
||||
assert is_claude_tool_target(custom_llm_provider=custom_llm_provider, model=model) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tools",
|
||||
[
|
||||
None,
|
||||
"lookup_record",
|
||||
[],
|
||||
[{"name": "a"}],
|
||||
[{"name": "a", "strict": False}],
|
||||
[{"name": "a", "strict": True}, "b"],
|
||||
],
|
||||
ids=["absent", "not_a_list", "empty", "no_strict_key", "strict_false_only", "malformed_tool"],
|
||||
)
|
||||
def test_tools_without_strict_is_none_unless_every_tool_is_an_object_and_one_is_strict(tools: object) -> None:
|
||||
assert tools_without_strict(tools) is None
|
||||
|
||||
|
||||
def test_tools_without_strict_drops_only_the_strict_key_and_counts_the_strict_tools() -> None:
|
||||
tools: Final = [{"name": "a", "strict": True}, {"name": "b"}, {"name": "c", "strict": False}]
|
||||
|
||||
assert tools_without_strict(tools) == ToolsWithoutStrict(
|
||||
tools=({"name": "a"}, {"name": "b"}, {"name": "c"}), dropped_count=1
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import zlib
|
|||
from collections.abc import AsyncIterator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from types import MappingProxyType, SimpleNamespace
|
||||
from typing import Final
|
||||
from unittest.mock import Mock
|
||||
|
||||
|
|
@ -4051,12 +4051,14 @@ _THINKING_HISTORY: Final = (
|
|||
|
||||
@dataclass(slots=True)
|
||||
class ScriptedBedrockInvoke:
|
||||
replies: tuple[tuple[int, Mapping[str, object]], ...]
|
||||
replies: tuple[tuple[int, Mapping[str, object] | bytes], ...]
|
||||
bodies: tuple[dict[str, object], ...] = ()
|
||||
|
||||
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||
self.bodies = (*self.bodies, REASONING_BODY_ADAPTER.validate_json(request.content))
|
||||
status_code, body = self.replies[len(self.bodies) - 1]
|
||||
if isinstance(body, bytes):
|
||||
return httpx.Response(status_code, content=body)
|
||||
return httpx.Response(status_code, json=body)
|
||||
|
||||
|
||||
|
|
@ -4065,6 +4067,7 @@ async def _send_bedrock_messages(
|
|||
model_info: Mapping[str, object] | None,
|
||||
replies: Sequence[tuple[int, Mapping[str, object]]],
|
||||
messages: Sequence[Mapping[str, object]] = ({"role": "user", "content": "Summarise the weather as JSON."},),
|
||||
extra: Mapping[str, object] = MappingProxyType({}),
|
||||
) -> tuple[ScriptedBedrockInvoke, Exception | None]:
|
||||
bedrock: Final = ScriptedBedrockInvoke(replies=tuple(replies))
|
||||
try:
|
||||
|
|
@ -4080,6 +4083,7 @@ async def _send_bedrock_messages(
|
|||
aws_region_name="us-east-1",
|
||||
client=AsyncHTTPHandler(transport=httpx.MockTransport(bedrock)),
|
||||
**({} if model_info is None else {"model_info": model_info}),
|
||||
**extra,
|
||||
)
|
||||
except Exception as error:
|
||||
return bedrock, error
|
||||
|
|
@ -4171,6 +4175,158 @@ async def test_bedrock_messages_400_is_not_retried_unless_it_rejects_opt_in_nati
|
|||
assert len(bedrock.bodies) == 1
|
||||
|
||||
|
||||
# Bedrock's 400 bodies for requests past Claude's strict tool limits, seen live on us.anthropic.claude-sonnet-4-6 on 2026-10-29
|
||||
_STRICT_TOOLS_REJECTIONS: Final = (
|
||||
{
|
||||
"message": "Too many strict tools (21). The maximum number of strict tools supported is 20. Try reducing the "
|
||||
"number of tools marked as strict."
|
||||
},
|
||||
{
|
||||
"message": "Schemas contains too many optional parameters (96), which would make grammar compilation "
|
||||
"inefficient. Reduce the number of optional parameters in your tool schemas (limit: 24)."
|
||||
},
|
||||
{
|
||||
"message": "Schemas contains too many parameters with union types (24 parameters with type arrays or anyOf). "
|
||||
"This causes exponential compilation cost. Reduce the number of nullable or union-typed parameters (limit: 16 "
|
||||
"parameters with unions)."
|
||||
},
|
||||
{
|
||||
"message": "The compiled grammar is too large, which would cause performance issues. Simplify your tool schemas "
|
||||
"or reduce the number of strict tools."
|
||||
},
|
||||
)
|
||||
_LOOKUP_TOOL: Final = {
|
||||
"name": "lookup_record",
|
||||
"description": "Look up records",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"required": ["query"],
|
||||
"properties": {"query": {"type": "string"}},
|
||||
},
|
||||
}
|
||||
_STRICT_TOOLS: Final = ({**_LOOKUP_TOOL, "strict": True}, {**_LOOKUP_TOOL, "name": "lookup_invoice", "strict": True})
|
||||
_STREAMED_MESSAGE: Final = _bedrock_event_frame(
|
||||
{"type": "message_start", "message": {**_ANTHROPIC_MESSAGE, "content": [], "stop_reason": None}}
|
||||
) + _bedrock_event_frame({"type": "message_stop"})
|
||||
|
||||
|
||||
async def _send_strict_tools_to_bedrock(
|
||||
replies: Sequence[tuple[int, Mapping[str, object] | bytes]],
|
||||
drop_params: bool | None,
|
||||
tools: Sequence[Mapping[str, object]] = _STRICT_TOOLS,
|
||||
stream: bool = False,
|
||||
) -> tuple[ScriptedBedrockInvoke, object]:
|
||||
bedrock: Final = ScriptedBedrockInvoke(replies=tuple(replies))
|
||||
try:
|
||||
response: Final = await litellm.anthropic_messages(
|
||||
model="bedrock/invoke/us.anthropic.claude-sonnet-4-6",
|
||||
messages=[{"role": "user", "content": "Use lookup_record to find invoice 42"}],
|
||||
max_tokens=200,
|
||||
tools=[dict(tool) for tool in tools],
|
||||
stream=stream,
|
||||
drop_params=drop_params,
|
||||
aws_access_key_id="test-access-key",
|
||||
aws_secret_access_key="test-secret-key",
|
||||
aws_region_name="us-east-1",
|
||||
client=AsyncHTTPHandler(transport=httpx.MockTransport(bedrock)),
|
||||
)
|
||||
except litellm.BadRequestError as error:
|
||||
return bedrock, error
|
||||
if isinstance(response, AsyncIterator):
|
||||
return bedrock, b"".join([chunk async for chunk in response])
|
||||
return bedrock, response
|
||||
|
||||
|
||||
def _assert_resent_without_strict(bedrock: ScriptedBedrockInvoke) -> None:
|
||||
first, second = bedrock.bodies
|
||||
first_tools: Final = TypeAdapter(list[dict[str, object]]).validate_python(first["tools"])
|
||||
assert [tool["strict"] for tool in first_tools] == [True, True]
|
||||
assert second == {
|
||||
**first,
|
||||
"tools": [{key: value for key, value in tool.items() if key != "strict"} for tool in first_tools],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("local_model_cost_map")
|
||||
@pytest.mark.parametrize("rejection", _STRICT_TOOLS_REJECTIONS, ids=["count", "optional", "union", "grammar"])
|
||||
async def test_bedrock_messages_strict_tools_rejection_is_sent_again_without_strict_under_drop_params(
|
||||
rejection: Mapping[str, object],
|
||||
) -> None:
|
||||
bedrock, response = await _send_strict_tools_to_bedrock([(400, rejection), (200, _ANTHROPIC_MESSAGE)], True)
|
||||
|
||||
_assert_resent_without_strict(bedrock)
|
||||
assert REASONING_BODY_ADAPTER.validate_python(response)["id"] == _ANTHROPIC_MESSAGE["id"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("local_model_cost_map")
|
||||
async def test_bedrock_messages_strict_tools_rejection_is_sent_again_under_global_drop_params(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "drop_params", True)
|
||||
|
||||
bedrock, response = await _send_strict_tools_to_bedrock(
|
||||
[(400, _STRICT_TOOLS_REJECTIONS[0]), (200, _ANTHROPIC_MESSAGE)], None
|
||||
)
|
||||
|
||||
_assert_resent_without_strict(bedrock)
|
||||
assert REASONING_BODY_ADAPTER.validate_python(response)["id"] == _ANTHROPIC_MESSAGE["id"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("local_model_cost_map")
|
||||
async def test_bedrock_messages_streamed_strict_tools_rejection_is_sent_again_without_strict_under_drop_params() -> (
|
||||
None
|
||||
):
|
||||
bedrock, streamed = await _send_strict_tools_to_bedrock(
|
||||
[(400, _STRICT_TOOLS_REJECTIONS[0]), (200, _STREAMED_MESSAGE)], True, stream=True
|
||||
)
|
||||
|
||||
_assert_resent_without_strict(bedrock)
|
||||
assert isinstance(streamed, bytes) and b"event: message_stop" in streamed
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("local_model_cost_map")
|
||||
async def test_bedrock_messages_body_rebuilt_without_native_output_still_drops_the_rejected_strict() -> None:
|
||||
bedrock, error = await _send_bedrock_messages(
|
||||
"us.anthropic.claude-opus-4-8",
|
||||
None,
|
||||
[(400, _STRICT_TOOLS_REJECTIONS[0]), (400, _NATIVE_OUTPUT_REJECTION), (200, _ANTHROPIC_MESSAGE)],
|
||||
extra={"tools": [dict(tool) for tool in _STRICT_TOOLS], "drop_params": True},
|
||||
)
|
||||
|
||||
assert error is None
|
||||
first, _, rebuilt = bedrock.bodies
|
||||
assert "format" in REASONING_BODY_ADAPTER.validate_python(first["output_config"])
|
||||
assert "format" not in REASONING_BODY_ADAPTER.validate_python(rebuilt.get("output_config", {}))
|
||||
assert rebuilt["tools"] == [
|
||||
{key: value for key, value in tool.items() if key != "strict"} for tool in _STRICT_TOOLS
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("local_model_cost_map")
|
||||
@pytest.mark.parametrize(
|
||||
("drop_params", "tools", "rejection"),
|
||||
[
|
||||
(None, _STRICT_TOOLS, _STRICT_TOOLS_REJECTIONS[0]),
|
||||
(False, _STRICT_TOOLS, _STRICT_TOOLS_REJECTIONS[0]),
|
||||
(True, _STRICT_TOOLS, {"message": "messages.0.content: Field required"}),
|
||||
(True, (_LOOKUP_TOOL,), _STRICT_TOOLS_REJECTIONS[0]),
|
||||
(True, ({**_LOOKUP_TOOL, "strict": False},), _STRICT_TOOLS_REJECTIONS[1]),
|
||||
],
|
||||
ids=["drop_params_unset", "drop_params_off", "other_400", "no_strict_tool", "strict_false_only"],
|
||||
)
|
||||
async def test_bedrock_messages_400_reaches_the_caller_unless_drop_params_can_drop_the_rejected_strict(
|
||||
drop_params: bool | None, tools: Sequence[Mapping[str, object]], rejection: Mapping[str, object]
|
||||
) -> None:
|
||||
bedrock, raised = await _send_strict_tools_to_bedrock(
|
||||
[(400, rejection), (200, _ANTHROPIC_MESSAGE)], drop_params, tools
|
||||
)
|
||||
|
||||
assert isinstance(raised, litellm.BadRequestError)
|
||||
assert str(rejection["message"]) in str(raised)
|
||||
assert len(bedrock.bodies) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("trace", ["enabled", "disabled"])
|
||||
def test_bedrock_messages_applies_the_deployment_guardrail_config_as_invoke_headers(trace: str) -> None:
|
||||
from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude_transformation import (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,358 @@
|
|||
import base64
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import CostCalculatorUtils
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
from litellm.llms.dashscope.image_edit.transformation import DashScopeImageEditConfig
|
||||
from litellm.llms.dashscope.text_to_speech.transformation import DashScopeTextToSpeechConfig
|
||||
from litellm.types.utils import ModelInfo
|
||||
|
||||
PNG: Final = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
)
|
||||
PNG_DATA_URI: Final = f"data:image/png;base64,{base64.b64encode(PNG).decode()}"
|
||||
INTL_ENDPOINT: Final = "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
|
||||
EDITED_URL: Final = "https://dashscope-result.example.com/edited.png"
|
||||
EDITED: Final = {
|
||||
"output": {"choices": [{"message": {"role": "assistant", "content": [{"image": EDITED_URL}]}}]},
|
||||
"usage": {"width": 1024, "height": 1024, "image_count": 1},
|
||||
}
|
||||
|
||||
|
||||
def _recording(
|
||||
seen: list[httpx.Request], reply: Callable[[httpx.Request], httpx.Response]
|
||||
) -> httpx.MockTransport: # mutable-ok: test records the requests the transport receives
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
return reply(request)
|
||||
|
||||
return httpx.MockTransport(respond)
|
||||
|
||||
|
||||
def _edited(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json=EDITED)
|
||||
|
||||
|
||||
def _sync_client(
|
||||
seen: list[httpx.Request], reply: Callable[[httpx.Request], httpx.Response] = _edited
|
||||
) -> HTTPHandler: # mutable-ok: shared request log
|
||||
return HTTPHandler(client=httpx.Client(transport=_recording(seen, reply)))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_ambient_dashscope_base(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("DASHSCOPE_API_BASE_IMAGE", raising=False)
|
||||
monkeypatch.delenv("DASHSCOPE_API_BASE", raising=False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("asynchronous", (False, True))
|
||||
async def test_an_edit_posts_every_image_inline_with_provider_params_at_the_top_of_parameters(
|
||||
asynchronous: bool,
|
||||
) -> None:
|
||||
seen: Final[list[httpx.Request]] = [] # mutable-ok: request log
|
||||
arguments: Final = {
|
||||
"model": "dashscope/qwen-image-2.0",
|
||||
"image": [PNG, PNG],
|
||||
"prompt": "Turn the circle into a square",
|
||||
"size": "1024x1024",
|
||||
"n": 1,
|
||||
"negative_prompt": "blurry",
|
||||
"prompt_extend": False,
|
||||
"watermark": False,
|
||||
"seed": 42,
|
||||
"api_key": "sk-dashscope-test",
|
||||
}
|
||||
response: Final = (
|
||||
await litellm.aimage_edit(**arguments, client=AsyncHTTPHandler(transport=_recording(seen, _edited)))
|
||||
if asynchronous
|
||||
else litellm.image_edit(**arguments, client=_sync_client(seen))
|
||||
)
|
||||
|
||||
assert [image.url for image in response.data or []] == [EDITED_URL]
|
||||
(request,) = seen
|
||||
assert str(request.url) == INTL_ENDPOINT
|
||||
assert request.headers["authorization"] == "Bearer sk-dashscope-test"
|
||||
assert json.loads(request.content) == {
|
||||
"model": "qwen-image-2.0",
|
||||
"input": {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"image": PNG_DATA_URI},
|
||||
{"image": PNG_DATA_URI},
|
||||
{"text": "Turn the circle into a square"},
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
"parameters": {
|
||||
"n": 1,
|
||||
"size": "1024*1024",
|
||||
"negative_prompt": "blurry",
|
||||
"prompt_extend": False,
|
||||
"watermark": False,
|
||||
"seed": 42,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("size", "dashscope_size"), (("1792x1024", "1792*1024"), ("1328x1328", "1328*1328")))
|
||||
def test_an_openai_size_reaches_dashscope_in_its_star_format(size: str, dashscope_size: str) -> None:
|
||||
seen: Final[list[httpx.Request]] = [] # mutable-ok: request log
|
||||
|
||||
litellm.image_edit(
|
||||
model="dashscope/qwen-image-2.0", image=PNG, prompt="p", size=size, api_key="k", client=_sync_client(seen)
|
||||
)
|
||||
|
||||
assert json.loads(seen[0].content)["parameters"] == {"size": dashscope_size}
|
||||
|
||||
|
||||
CHINA_ENDPOINT: Final = "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("api_base", "env_base", "image_base", "endpoint"),
|
||||
(
|
||||
("https://dashscope.aliyuncs.com/compatible-mode/v1", None, None, CHINA_ENDPOINT),
|
||||
("https://dashscope-intl.aliyuncs.com/compatible-mode/v1/", None, None, INTL_ENDPOINT),
|
||||
("https://dashscope.aliyuncs.com", None, None, CHINA_ENDPOINT),
|
||||
("https://dashscope-intl.aliyuncs.com/apps/anthropic", None, None, INTL_ENDPOINT),
|
||||
(None, "https://dashscope.aliyuncs.com/apps/anthropic", None, CHINA_ENDPOINT),
|
||||
(
|
||||
"https://gw.example.com/compatible-mode/v1",
|
||||
None,
|
||||
None,
|
||||
"https://gw.example.com/api/v1/services/aigc/multimodal-generation/generation",
|
||||
),
|
||||
(None, "https://dashscope.aliyuncs.com/compatible-mode/v1", None, CHINA_ENDPOINT),
|
||||
(None, "https://dashscope.aliyuncs.com/api/v1", None, CHINA_ENDPOINT),
|
||||
(None, None, None, INTL_ENDPOINT),
|
||||
(
|
||||
"https://images.example.com/edit",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
None,
|
||||
"https://images.example.com/edit",
|
||||
),
|
||||
("https://dashscope.aliyuncs.com/compatible-mode/v1", None, "https://images.example.com/edit", CHINA_ENDPOINT),
|
||||
(
|
||||
None,
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"https://images.example.com/edit",
|
||||
"https://images.example.com/edit",
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_an_edit_goes_to_the_image_endpoint_on_the_host_its_dashscope_base_names(
|
||||
monkeypatch: pytest.MonkeyPatch, api_base: str | None, env_base: str | None, image_base: str | None, endpoint: str
|
||||
) -> None:
|
||||
if env_base is not None:
|
||||
monkeypatch.setenv("DASHSCOPE_API_BASE", env_base)
|
||||
if image_base is not None:
|
||||
monkeypatch.setenv("DASHSCOPE_API_BASE_IMAGE", image_base)
|
||||
seen: Final[list[httpx.Request]] = [] # mutable-ok: request log
|
||||
|
||||
litellm.image_edit(
|
||||
model="dashscope/qwen-image-2.0",
|
||||
image=PNG,
|
||||
prompt="p",
|
||||
api_key="k",
|
||||
api_base=api_base,
|
||||
client=_sync_client(seen),
|
||||
)
|
||||
|
||||
assert str(seen[0].url) == endpoint
|
||||
|
||||
|
||||
@pytest.mark.parametrize("api_base", ("https://dashscope.aliyuncs.com/compatible-mode/v1", None))
|
||||
def test_an_edit_and_a_speech_call_with_the_same_dashscope_base_reach_the_same_host(
|
||||
monkeypatch: pytest.MonkeyPatch, api_base: str | None
|
||||
) -> None:
|
||||
monkeypatch.setenv("DASHSCOPE_API_BASE", "https://gw.example.com/compatible-mode/v1")
|
||||
edit_url: Final = DashScopeImageEditConfig().get_complete_url(
|
||||
model="qwen-image-2.0", api_base=api_base, litellm_params={}
|
||||
)
|
||||
speech_url: Final = DashScopeTextToSpeechConfig().get_complete_url(
|
||||
model="qwen3-tts-flash", api_base=api_base, litellm_params={}
|
||||
)
|
||||
|
||||
assert httpx.URL(edit_url).host == httpx.URL(speech_url).host
|
||||
|
||||
|
||||
def test_a_fourth_image_is_refused_with_a_400_before_any_request_even_under_drop_params() -> None:
|
||||
seen: Final[list[httpx.Request]] = [] # mutable-ok: request log
|
||||
|
||||
with pytest.raises(litellm.BadRequestError) as refused:
|
||||
litellm.image_edit(
|
||||
model="dashscope/qwen-image-2.0",
|
||||
image=[PNG, PNG, PNG, PNG],
|
||||
prompt="p",
|
||||
api_key="k",
|
||||
drop_params=True,
|
||||
client=_sync_client(seen),
|
||||
)
|
||||
|
||||
assert refused.value.status_code == 400
|
||||
assert "at most 3 input images, got 4" in str(refused.value)
|
||||
assert seen == []
|
||||
|
||||
|
||||
def test_three_images_are_accepted() -> None:
|
||||
seen: Final[list[httpx.Request]] = [] # mutable-ok: request log
|
||||
|
||||
litellm.image_edit(
|
||||
model="dashscope/qwen-image-2.0", image=[PNG, PNG, PNG], prompt="p", api_key="k", client=_sync_client(seen)
|
||||
)
|
||||
|
||||
content: Final = json.loads(seen[0].content)["input"]["messages"][0]["content"]
|
||||
assert content == [{"image": PNG_DATA_URI}] * 3 + [{"text": "p"}]
|
||||
|
||||
|
||||
def test_an_unsupported_openai_param_answers_400_and_is_dropped_under_drop_params() -> None:
|
||||
seen: Final[list[httpx.Request]] = [] # mutable-ok: request log
|
||||
client: Final = _sync_client(seen)
|
||||
|
||||
with pytest.raises(litellm.UnsupportedParamsError) as refused:
|
||||
litellm.image_edit(
|
||||
model="dashscope/qwen-image-2.0", image=PNG, prompt="p", quality="high", api_key="k", client=client
|
||||
)
|
||||
litellm.image_edit(
|
||||
model="dashscope/qwen-image-2.0",
|
||||
image=PNG,
|
||||
prompt="p",
|
||||
quality="high",
|
||||
api_key="k",
|
||||
drop_params=True,
|
||||
client=client,
|
||||
)
|
||||
|
||||
assert refused.value.status_code == 400
|
||||
assert "quality" in str(refused.value)
|
||||
(request,) = seen
|
||||
assert json.loads(request.content)["parameters"] == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("reply", "error", "message"),
|
||||
(
|
||||
(
|
||||
httpx.Response(200, json={"code": "InvalidParameter", "message": "Image is too small"}),
|
||||
litellm.APIError,
|
||||
"Image is too small",
|
||||
),
|
||||
(
|
||||
httpx.Response(400, json={"code": "InvalidParameter", "message": "Model not exist."}),
|
||||
litellm.BadRequestError,
|
||||
"Model not exist.",
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_a_dashscope_rejection_reaches_the_caller_with_its_message(
|
||||
reply: httpx.Response, error: type[Exception], message: str
|
||||
) -> None:
|
||||
with pytest.raises(error, match=message):
|
||||
litellm.image_edit(
|
||||
model="dashscope/qwen-image-2.0",
|
||||
image=PNG,
|
||||
prompt="p",
|
||||
api_key="k",
|
||||
client=_sync_client([], lambda _: reply),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", ("qwen-image-2.0", "qwen-image-2.0-pro", "qwen-image-3.0"))
|
||||
@pytest.mark.usefixtures("local_model_cost_map")
|
||||
def test_each_edited_image_is_billed_at_the_model_output_image_price(model: str) -> None:
|
||||
response: Final = litellm.ImageResponse(data=[{"url": EDITED_URL}, {"url": EDITED_URL}])
|
||||
|
||||
cost: Final = litellm.completion_cost(
|
||||
completion_response=response,
|
||||
model=f"dashscope/{model}",
|
||||
call_type="aimage_edit",
|
||||
custom_llm_provider="dashscope",
|
||||
)
|
||||
|
||||
assert cost == pytest.approx(2 * litellm.model_cost[f"dashscope/{model}"]["output_cost_per_image"])
|
||||
assert cost > 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("local_model_cost_map")
|
||||
def test_the_input_images_dashscope_reports_are_billed_on_top_of_the_edited_images() -> None:
|
||||
def reply(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={**EDITED, "usage": {"input_image_count": 2, "output_image_count": 1}})
|
||||
|
||||
response: Final = litellm.image_edit(
|
||||
model="dashscope/qwen-image-3.0", image=[PNG, PNG], prompt="p", api_key="k", client=_sync_client([], reply)
|
||||
)
|
||||
|
||||
assert isinstance(response, litellm.ImageResponse)
|
||||
assert CostCalculatorUtils.route_image_generation_cost_calculator(
|
||||
model="qwen-image-3.0",
|
||||
completion_response=response,
|
||||
custom_llm_provider="dashscope",
|
||||
call_type="aimage_edit",
|
||||
model_info=ModelInfo(input_cost_per_image=0.004, output_cost_per_image=0.04),
|
||||
) == pytest.approx(2 * 0.004 + 0.04)
|
||||
|
||||
|
||||
def _edited_images(count: int, input_images: int) -> litellm.ImageResponse:
|
||||
response: Final = litellm.ImageResponse(data=[{"url": EDITED_URL}] * count)
|
||||
response.hidden_params = {"billable_input_image_count": input_images}
|
||||
return response
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "deployment_price", "size", "expected_cost"),
|
||||
(
|
||||
("qwen-image-2.0", ModelInfo(input_cost_per_image=0.02), None, 2 * 0.02),
|
||||
("qwen-image-3.0-pro", ModelInfo(input_cost_per_pixel=1e-08), "1024x1024", 2 * 1024 * 1024 * 1e-08),
|
||||
),
|
||||
)
|
||||
@pytest.mark.usefixtures("local_model_cost_map")
|
||||
def test_a_deployment_image_price_bills_the_edit_the_way_it_bills_any_image_call(
|
||||
model: str, deployment_price: ModelInfo, size: str | None, expected_cost: float
|
||||
) -> None:
|
||||
cost: Final = CostCalculatorUtils.route_image_generation_cost_calculator(
|
||||
model=model,
|
||||
completion_response=_edited_images(count=2, input_images=3),
|
||||
custom_llm_provider="dashscope",
|
||||
size=size,
|
||||
call_type="aimage_edit",
|
||||
model_info=deployment_price,
|
||||
)
|
||||
|
||||
assert cost == pytest.approx(expected_cost)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("local_model_cost_map")
|
||||
def test_a_deployment_that_copies_both_image_prices_bills_edited_and_input_images_at_its_own_prices() -> None:
|
||||
cost: Final = CostCalculatorUtils.route_image_generation_cost_calculator(
|
||||
model="qwen-image-3.0",
|
||||
completion_response=_edited_images(count=2, input_images=3),
|
||||
custom_llm_provider="dashscope",
|
||||
call_type="aimage_edit",
|
||||
model_info=ModelInfo(input_cost_per_image=0.004, output_cost_per_image=0.04),
|
||||
)
|
||||
|
||||
assert cost == pytest.approx(2 * 0.04 + 3 * 0.004)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("local_model_cost_map")
|
||||
def test_a_deployment_token_price_keeps_the_shared_per_image_billing() -> None:
|
||||
prices: Final = litellm.model_cost["dashscope/qwen-image-3.0"]
|
||||
|
||||
cost: Final = CostCalculatorUtils.route_image_generation_cost_calculator(
|
||||
model="qwen-image-3.0",
|
||||
completion_response=_edited_images(count=1, input_images=2),
|
||||
custom_llm_provider="dashscope",
|
||||
call_type="aimage_edit",
|
||||
model_info=ModelInfo(input_cost_per_token=1e-06),
|
||||
)
|
||||
|
||||
assert cost == pytest.approx(2 * prices.get("input_cost_per_image", 0.0) + prices["output_cost_per_image"])
|
||||
|
|
@ -0,0 +1,415 @@
|
|||
import datetime
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
|
||||
from litellm.llms.dashscope.common_utils import DashScopeError
|
||||
from litellm.llms.dashscope.text_to_speech.transformation import DashScopeTextToSpeechConfig
|
||||
from litellm.types.llms.openai import HttpxBinaryResponseContent
|
||||
from litellm.types.utils import Usage
|
||||
|
||||
INTL: Final = "https://dashscope-intl.aliyuncs.com"
|
||||
MULTIMODAL_PATH: Final = "/api/v1/services/aigc/multimodal-generation/generation"
|
||||
SYNTHESIZER_PATH: Final = "/api/v1/services/audio/tts/SpeechSynthesizer"
|
||||
AUDIO_URL: Final = (
|
||||
"http://93.184.216.34/prod/qwen-tts/a1.mp3?Expires=1793316671&OSSAccessKeyId=LTAI&Signature=ab%2Bc%3D"
|
||||
)
|
||||
AUDIO: Final = b"ID3\x04\x00synthetic-mp3-frames"
|
||||
SYNTHESIZED: Final = {
|
||||
"output": {"audio": {"url": AUDIO_URL, "data": "", "id": "audio_1"}},
|
||||
"usage": {"characters": 6, "input_tokens": 8, "output_tokens": 30, "total_tokens": 38},
|
||||
}
|
||||
|
||||
API_REQUEST: Final = httpx.Request("POST", f"{INTL}{MULTIMODAL_PATH}")
|
||||
|
||||
Reply = Callable[[httpx.Request], httpx.Response]
|
||||
|
||||
|
||||
def _api(reply: dict[str, object]) -> Reply: # mutable-ok: JSON body fixture
|
||||
return lambda _: httpx.Response(200, json=reply)
|
||||
|
||||
|
||||
def _audio(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, content=AUDIO, headers={"content-type": "audio/mpeg"})
|
||||
|
||||
|
||||
def _recording(seen: list[httpx.Request], reply: Reply) -> httpx.MockTransport: # mutable-ok: request log
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
return reply(request)
|
||||
|
||||
return httpx.MockTransport(respond)
|
||||
|
||||
|
||||
def _refusing_blocking_download(request: httpx.Request) -> httpx.Response:
|
||||
raise AssertionError(f"blocking client used for {request.url} on the event loop")
|
||||
|
||||
|
||||
def _logging() -> Logging:
|
||||
return Logging(
|
||||
model="dashscope-tts",
|
||||
messages=[],
|
||||
stream=False,
|
||||
call_type="aspeech",
|
||||
start_time=datetime.datetime(2026, 10, 28, tzinfo=datetime.timezone.utc),
|
||||
litellm_call_id="call-tts",
|
||||
function_id="fn-tts",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_ambient_dashscope_base(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("DASHSCOPE_API_BASE", raising=False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "optional_params", "path", "provider_input"),
|
||||
(
|
||||
(
|
||||
"qwen3-tts-flash",
|
||||
{"response_format": "wav"},
|
||||
MULTIMODAL_PATH,
|
||||
{"text": "Hello.", "voice": "Cherry"},
|
||||
),
|
||||
(
|
||||
"qwen-audio-3.1-tts-flash",
|
||||
{"response_format": "mp3", "instructions": "Speak slowly."},
|
||||
SYNTHESIZER_PATH,
|
||||
{"text": "Hello.", "voice": "Cherry", "format": "mp3", "instruction": "Speak slowly."},
|
||||
),
|
||||
(
|
||||
"qwen-audio-3.0-tts-plus",
|
||||
{},
|
||||
SYNTHESIZER_PATH,
|
||||
{"text": "Hello.", "voice": "Cherry"},
|
||||
),
|
||||
),
|
||||
)
|
||||
async def test_each_family_posts_to_its_endpoint_and_the_async_handler_returns_the_downloaded_audio(
|
||||
model: str,
|
||||
optional_params: dict[str, object], # mutable-ok: parametrized fixture
|
||||
path: str,
|
||||
provider_input: dict[str, object], # mutable-ok: parametrized fixture
|
||||
) -> None:
|
||||
api_seen: Final[list[httpx.Request]] = [] # mutable-ok: request log
|
||||
download_seen: Final[list[httpx.Request]] = [] # mutable-ok: request log
|
||||
config: Final = DashScopeTextToSpeechConfig(
|
||||
sync_client=lambda: HTTPHandler(
|
||||
client=httpx.Client(transport=httpx.MockTransport(_refusing_blocking_download))
|
||||
),
|
||||
async_client=lambda: AsyncHTTPHandler(transport=_recording(download_seen, _audio)),
|
||||
)
|
||||
voice, mapped = config.map_openai_params(model=model, optional_params=optional_params, voice="Cherry")
|
||||
|
||||
speech: Final = await BaseLLMHTTPHandler().async_text_to_speech_handler(
|
||||
model=model,
|
||||
input="Hello.",
|
||||
voice=voice,
|
||||
text_to_speech_provider_config=config,
|
||||
text_to_speech_optional_params=mapped,
|
||||
custom_llm_provider="dashscope",
|
||||
litellm_params={"api_key": "sk-dashscope-test", "api_base": None},
|
||||
logging_obj=_logging(),
|
||||
timeout=30,
|
||||
client=AsyncHTTPHandler(transport=_recording(api_seen, _api(SYNTHESIZED))),
|
||||
)
|
||||
|
||||
assert speech.content == AUDIO
|
||||
(api_request,) = api_seen
|
||||
assert str(api_request.url) == f"{INTL}{path}"
|
||||
assert api_request.headers["authorization"] == "Bearer sk-dashscope-test"
|
||||
assert json.loads(api_request.content) == {"model": model, "input": provider_input}
|
||||
(download,) = download_seen
|
||||
assert str(download.url) == AUDIO_URL
|
||||
assert "authorization" not in download.headers
|
||||
assert download.extensions["timeout"] == api_request.extensions["timeout"]
|
||||
|
||||
|
||||
def test_the_sync_handler_downloads_through_the_injected_blocking_client_within_the_call_timeout() -> None:
|
||||
api_seen: Final[list[httpx.Request]] = [] # mutable-ok: request log
|
||||
download_seen: Final[list[httpx.Request]] = [] # mutable-ok: request log
|
||||
config: Final = DashScopeTextToSpeechConfig(
|
||||
sync_client=lambda: HTTPHandler(client=httpx.Client(transport=_recording(download_seen, _audio)))
|
||||
)
|
||||
|
||||
speech: Final = BaseLLMHTTPHandler().text_to_speech_handler(
|
||||
model="qwen3-tts-flash",
|
||||
input="Hello.",
|
||||
voice="Cherry",
|
||||
text_to_speech_provider_config=config,
|
||||
text_to_speech_optional_params={},
|
||||
custom_llm_provider="dashscope",
|
||||
litellm_params={"api_key": "k", "api_base": None},
|
||||
logging_obj=_logging(),
|
||||
timeout=30,
|
||||
client=HTTPHandler(client=httpx.Client(transport=_recording(api_seen, _api(SYNTHESIZED)))),
|
||||
)
|
||||
|
||||
assert isinstance(speech, HttpxBinaryResponseContent)
|
||||
assert speech.content == AUDIO
|
||||
(api_request,) = api_seen
|
||||
(download,) = download_seen
|
||||
assert str(download.url) == AUDIO_URL
|
||||
assert download.extensions["timeout"] == api_request.extensions["timeout"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("audio_url", "download_url"),
|
||||
(
|
||||
(
|
||||
"http://dashscope-result-sh.oss-cn-shanghai.aliyuncs.com/a1.wav?Expires=1&Signature=ab%2Bc%3D",
|
||||
"https://dashscope-result-sh.oss-cn-shanghai.aliyuncs.com/a1.wav?Expires=1&Signature=ab%2Bc%3D",
|
||||
),
|
||||
(
|
||||
"https://dashscope-result-sh.oss-cn-shanghai.aliyuncs.com/a1.wav",
|
||||
"https://dashscope-result-sh.oss-cn-shanghai.aliyuncs.com/a1.wav",
|
||||
),
|
||||
("http://audio.notaliyuncs.com/a1.wav", "http://audio.notaliyuncs.com/a1.wav"),
|
||||
(AUDIO_URL, AUDIO_URL),
|
||||
),
|
||||
)
|
||||
def test_an_alibaba_cloud_audio_url_is_downloaded_over_https(
|
||||
monkeypatch: pytest.MonkeyPatch, audio_url: str, download_url: str
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "user_url_validation", False)
|
||||
download_seen: Final[list[httpx.Request]] = [] # mutable-ok: request log
|
||||
config: Final = DashScopeTextToSpeechConfig(
|
||||
sync_client=lambda: HTTPHandler(client=httpx.Client(transport=_recording(download_seen, _audio)))
|
||||
)
|
||||
|
||||
speech: Final = BaseLLMHTTPHandler().text_to_speech_handler(
|
||||
model="qwen3-tts-flash",
|
||||
input="Hello.",
|
||||
voice="Cherry",
|
||||
text_to_speech_provider_config=config,
|
||||
text_to_speech_optional_params={},
|
||||
custom_llm_provider="dashscope",
|
||||
litellm_params={"api_key": "k", "api_base": None},
|
||||
logging_obj=_logging(),
|
||||
timeout=30,
|
||||
client=HTTPHandler(
|
||||
client=httpx.Client(
|
||||
transport=_recording([], _api({**SYNTHESIZED, "output": {"audio": {"url": audio_url}}}))
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
assert speech.content == AUDIO
|
||||
(download,) = download_seen
|
||||
assert str(download.url) == download_url
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("api_base", "environment", "model", "url"),
|
||||
(
|
||||
(None, None, "qwen3-tts-flash", f"{INTL}{MULTIMODAL_PATH}"),
|
||||
(None, None, "qwen-audio-3.1-tts-plus", f"{INTL}{SYNTHESIZER_PATH}"),
|
||||
(
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
None,
|
||||
"qwen-audio-3.1-tts-plus",
|
||||
f"https://dashscope.aliyuncs.com{SYNTHESIZER_PATH}",
|
||||
),
|
||||
(f"{INTL}{SYNTHESIZER_PATH}", None, "qwen3-tts-flash", f"{INTL}{MULTIMODAL_PATH}"),
|
||||
(
|
||||
"https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
None,
|
||||
"qwen3-tts-flash",
|
||||
f"https://dashscope.aliyuncs.com{MULTIMODAL_PATH}",
|
||||
),
|
||||
(
|
||||
"https://relay.example.com/dashscope/",
|
||||
None,
|
||||
"qwen3-tts-flash",
|
||||
f"https://relay.example.com/dashscope{MULTIMODAL_PATH}",
|
||||
),
|
||||
(
|
||||
None,
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"qwen3-tts-flash",
|
||||
f"https://dashscope.aliyuncs.com{MULTIMODAL_PATH}",
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_the_endpoint_follows_the_model_family_on_the_configured_host(
|
||||
monkeypatch: pytest.MonkeyPatch, api_base: str | None, environment: str | None, model: str, url: str
|
||||
) -> None:
|
||||
if environment is not None:
|
||||
monkeypatch.setenv("DASHSCOPE_API_BASE", environment)
|
||||
|
||||
assert DashScopeTextToSpeechConfig().get_complete_url(model=model, api_base=api_base, litellm_params={}) == url
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "optional_params", "voice", "refused"),
|
||||
(
|
||||
("qwen3-tts-flash", {"response_format": "mp3"}, "Cherry", "response_format='mp3'"),
|
||||
("qwen3-tts-flash", {"instructions": "Whisper."}, "Cherry", "instructions='Whisper.'"),
|
||||
("qwen-audio-3.1-tts-flash", {"response_format": "aac"}, "Cherry", "response_format='aac'"),
|
||||
("qwen-audio-3.1-tts-flash", {"speed": 1.5}, "Cherry", "speed=1.5"),
|
||||
("qwen-audio-3.1-tts-flash", {}, {"voice_id": "Cherry"}, "voice as an object"),
|
||||
),
|
||||
)
|
||||
def test_an_unsupported_param_answers_400_and_is_dropped_under_drop_params(
|
||||
model: str,
|
||||
optional_params: dict[str, object], # mutable-ok: parametrized fixture
|
||||
voice: str | dict[str, object], # mutable-ok: parametrized fixture
|
||||
refused: str,
|
||||
) -> None:
|
||||
config: Final = DashScopeTextToSpeechConfig()
|
||||
|
||||
with pytest.raises(litellm.UnsupportedParamsError) as rejected:
|
||||
config.map_openai_params(model=model, optional_params=optional_params, voice=voice)
|
||||
dropped_voice, dropped = config.map_openai_params(
|
||||
model=model, optional_params=optional_params, voice=voice, kwargs={"drop_params": True}
|
||||
)
|
||||
|
||||
assert rejected.value.status_code == 400
|
||||
assert refused in str(rejected.value)
|
||||
assert dropped == {}
|
||||
assert dropped_voice == (voice if isinstance(voice, str) else None)
|
||||
|
||||
|
||||
async def test_aspeech_uses_the_native_endpoint_on_the_caller_api_base_and_refuses_a_private_audio_url() -> None:
|
||||
api_seen: Final[list[httpx.Request]] = [] # mutable-ok: request log
|
||||
private_audio: Final = {"output": {"audio": {"url": "http://169.254.169.254/latest/meta-data"}}}
|
||||
|
||||
with pytest.raises(litellm.BadGatewayError) as refused:
|
||||
await litellm.aspeech(
|
||||
model="dashscope/qwen-audio-3.1-tts-flash",
|
||||
input="Hello.",
|
||||
voice="longanhuan_v3.1",
|
||||
response_format="opus",
|
||||
api_key="sk-dashscope-test",
|
||||
api_base="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
client=AsyncHTTPHandler(transport=_recording(api_seen, _api(private_audio))),
|
||||
)
|
||||
|
||||
assert refused.value.status_code == 502
|
||||
assert "audio URL that was refused" in str(refused.value)
|
||||
(api_request,) = api_seen
|
||||
assert str(api_request.url) == f"https://dashscope.aliyuncs.com{SYNTHESIZER_PATH}"
|
||||
assert json.loads(api_request.content) == {
|
||||
"model": "qwen-audio-3.1-tts-flash",
|
||||
"input": {"text": "Hello.", "voice": "longanhuan_v3.1", "format": "opus"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("api_reply", "audio_reply", "status", "message"),
|
||||
(
|
||||
(
|
||||
httpx.Response(200, json={"code": "InvalidParameter", "message": "The voice property is required."}),
|
||||
_audio,
|
||||
400,
|
||||
"The voice property is required.",
|
||||
),
|
||||
(
|
||||
httpx.Response(200, content=b"<html>gateway</html>"),
|
||||
_audio,
|
||||
502,
|
||||
"Unexpected DashScope text-to-speech response",
|
||||
),
|
||||
(
|
||||
httpx.Response(200, json=SYNTHESIZED, request=API_REQUEST),
|
||||
lambda _: httpx.Response(403),
|
||||
502,
|
||||
"audio download failed with HTTP 403",
|
||||
),
|
||||
),
|
||||
)
|
||||
async def test_a_failed_synthesis_or_download_raises_a_dashscope_error(
|
||||
api_reply: httpx.Response, audio_reply: Reply, status: int, message: str
|
||||
) -> None:
|
||||
config: Final = DashScopeTextToSpeechConfig(
|
||||
async_client=lambda: AsyncHTTPHandler(transport=httpx.MockTransport(audio_reply))
|
||||
)
|
||||
|
||||
with pytest.raises(DashScopeError) as failed:
|
||||
await config.async_transform_text_to_speech_response(
|
||||
model="qwen3-tts-flash", raw_response=api_reply, logging_obj=_logging()
|
||||
)
|
||||
|
||||
assert failed.value.status_code == status
|
||||
assert message in failed.value.message
|
||||
|
||||
|
||||
async def test_provider_token_usage_rides_on_the_audio_so_token_priced_speech_is_billed() -> None:
|
||||
config: Final = DashScopeTextToSpeechConfig(
|
||||
async_client=lambda: AsyncHTTPHandler(transport=httpx.MockTransport(_audio))
|
||||
)
|
||||
|
||||
speech: Final = await config.async_transform_text_to_speech_response(
|
||||
model="qwen-audio-3.1-tts-flash",
|
||||
raw_response=httpx.Response(200, json=SYNTHESIZED, request=API_REQUEST),
|
||||
logging_obj=_logging(),
|
||||
)
|
||||
|
||||
assert speech.usage == Usage(prompt_tokens=8, completion_tokens=30, total_tokens=38)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("local_model_cost_map")
|
||||
async def test_character_priced_speech_bills_the_characters_dashscope_reports() -> None:
|
||||
text: Final = "你好 世界。"
|
||||
reported: Final = {**SYNTHESIZED, "usage": {"characters": 10}}
|
||||
config: Final = DashScopeTextToSpeechConfig(
|
||||
async_client=lambda: AsyncHTTPHandler(transport=httpx.MockTransport(_audio))
|
||||
)
|
||||
speech: Final = await config.async_transform_text_to_speech_response(
|
||||
model="qwen3-tts-flash",
|
||||
raw_response=httpx.Response(200, json=reported, request=API_REQUEST),
|
||||
logging_obj=_logging(),
|
||||
)
|
||||
|
||||
cost: Final = litellm.completion_cost(
|
||||
completion_response=speech,
|
||||
model="dashscope/qwen3-tts-flash",
|
||||
call_type="aspeech",
|
||||
prompt=text,
|
||||
custom_llm_provider="dashscope",
|
||||
)
|
||||
|
||||
assert litellm.utils.count_characters(text) != 10
|
||||
assert cost == pytest.approx(10 * litellm.model_cost["dashscope/qwen3-tts-flash"]["input_cost_per_character"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model", ("qwen3-tts-flash", "qwen-audio-3.1-tts-plus", "qwen-audio-3.0-tts-plus", "qwen-audio-3.0-tts-flash")
|
||||
)
|
||||
@pytest.mark.usefixtures("local_model_cost_map")
|
||||
def test_character_priced_speech_bills_each_input_character_at_the_model_rate(model: str) -> None:
|
||||
cost: Final = litellm.completion_cost(
|
||||
completion_response=HttpxBinaryResponseContent(httpx.Response(200, content=AUDIO)),
|
||||
model=f"dashscope/{model}",
|
||||
call_type="aspeech",
|
||||
prompt="Hello.",
|
||||
custom_llm_provider="dashscope",
|
||||
)
|
||||
|
||||
assert cost == pytest.approx(6 * litellm.model_cost[f"dashscope/{model}"]["input_cost_per_character"])
|
||||
assert cost > 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("local_model_cost_map")
|
||||
def test_token_priced_speech_bills_the_provider_reported_input_and_output_tokens() -> None:
|
||||
speech: Final = HttpxBinaryResponseContent(httpx.Response(200, content=AUDIO))
|
||||
speech.usage = Usage(prompt_tokens=8, completion_tokens=30, total_tokens=38)
|
||||
row: Final = litellm.model_cost["dashscope/qwen-audio-3.1-tts-flash"]
|
||||
|
||||
cost: Final = litellm.completion_cost(
|
||||
completion_response=speech,
|
||||
model="dashscope/qwen-audio-3.1-tts-flash",
|
||||
call_type="aspeech",
|
||||
prompt="Hello.",
|
||||
custom_llm_provider="dashscope",
|
||||
)
|
||||
|
||||
assert cost == pytest.approx(8 * row["input_cost_per_token"] + 30 * row["output_cost_per_token"])
|
||||
assert row["output_cost_per_token"] > row["input_cost_per_token"] > 0
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import json
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -94,3 +95,86 @@ def test_compact_request_forwards_metadata_and_user_to_the_downstream_proxy(drop
|
|||
"user": "end-user-1",
|
||||
"temperature": 0.2,
|
||||
}
|
||||
|
||||
|
||||
_GUARDRAIL_BLOCK: Final = {
|
||||
"error": {
|
||||
"message": "Content blocked: secret_project_codename pattern detected",
|
||||
"type": "invalid_request_error",
|
||||
"param": None,
|
||||
"code": "400",
|
||||
"provider_specific_fields": {"guardrail_name": "block-secret-project", "guardrail_mode": "pre_call"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _proxy_rejection(route: str, response: httpx.Response, call: Callable[[], object]) -> litellm.BadRequestError:
|
||||
with respx.mock(assert_all_called=True) as mock:
|
||||
_ = mock.post(f"https://my-proxy.example.com/{route}").mock(return_value=response)
|
||||
with pytest.raises(litellm.BadRequestError) as caught:
|
||||
_ = call()
|
||||
return caught.value
|
||||
|
||||
|
||||
def _consume(stream: Iterable[object]) -> None:
|
||||
for _ in stream:
|
||||
pass
|
||||
|
||||
|
||||
_PROXY_PARAMS: Final = {"api_base": "https://my-proxy.example.com/", "api_key": "sk-proxy-key"}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(httpx_transport.__name__)
|
||||
@pytest.mark.parametrize("stream", [False, True])
|
||||
def test_proxy_400_reaches_responses_caller_with_the_same_body_as_chat_completions(stream: bool) -> None:
|
||||
chat_error: Final = _proxy_rejection(
|
||||
"chat/completions",
|
||||
httpx.Response(400, json=_GUARDRAIL_BLOCK),
|
||||
lambda: litellm.completion(
|
||||
model="litellm_proxy/gpt-5.5", messages=[{"role": "user", "content": "project bluebird"}], **_PROXY_PARAMS
|
||||
),
|
||||
)
|
||||
responses_error: Final = _proxy_rejection(
|
||||
"responses",
|
||||
httpx.Response(400, json=_GUARDRAIL_BLOCK),
|
||||
lambda: (
|
||||
_consume(litellm.responses(model="litellm_proxy/gpt-5.5", input="project bluebird", stream=True, **_PROXY_PARAMS))
|
||||
if stream
|
||||
else litellm.responses(model="litellm_proxy/gpt-5.5", input="project bluebird", **_PROXY_PARAMS)
|
||||
),
|
||||
)
|
||||
assert responses_error.body == chat_error.body == _GUARDRAIL_BLOCK["error"]
|
||||
assert (responses_error.code, responses_error.type) == (chat_error.code, chat_error.type)
|
||||
assert str(responses_error) == str(chat_error)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(httpx_transport.__name__)
|
||||
def test_proxy_error_without_a_json_object_body_still_raises_with_its_text() -> None:
|
||||
error: Final = _proxy_rejection(
|
||||
"responses",
|
||||
httpx.Response(400, text="upstream proxy rejected the request"),
|
||||
lambda: litellm.responses(model="litellm_proxy/gpt-5.5", input="hi", **_PROXY_PARAMS),
|
||||
)
|
||||
assert error.body is None
|
||||
assert "upstream proxy rejected the request" in str(error)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(httpx_transport.__name__)
|
||||
def test_proxy_error_object_without_an_error_member_is_the_body_as_sent() -> None:
|
||||
sent: Final = {"message": "upstream proxy rejected the request", "type": "invalid_request_error"}
|
||||
error: Final = _proxy_rejection(
|
||||
"responses",
|
||||
httpx.Response(400, json=sent),
|
||||
lambda: litellm.responses(model="litellm_proxy/gpt-5.5", input="hi", **_PROXY_PARAMS),
|
||||
)
|
||||
assert error.body == sent
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(httpx_transport.__name__)
|
||||
def test_proxy_error_whose_error_member_is_text_still_raises_with_that_text() -> None:
|
||||
error: Final = _proxy_rejection(
|
||||
"responses",
|
||||
httpx.Response(400, json={"error": "upstream proxy rejected the request"}),
|
||||
lambda: litellm.responses(model="litellm_proxy/gpt-5.5", input="hi", **_PROXY_PARAMS),
|
||||
)
|
||||
assert "upstream proxy rejected the request" in str(error)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import copy
|
|||
import json
|
||||
import operator
|
||||
from collections.abc import Mapping
|
||||
from itertools import chain
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -11,9 +12,10 @@ from pydantic import JsonValue, ValidationError
|
|||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.tool_fields import DropPolicy
|
||||
from litellm.llms.anthropic.messages.context_management.constants import CLEARED_TOOL_RESULT_PLACEHOLDER
|
||||
from litellm.llms.openai.responses.count_tokens.token_counter import OpenAITokenCounter
|
||||
from litellm.responses.utils import ResponsesAPIRequestUtils
|
||||
from litellm.types.utils import TokenCountMessagesFormat, TokenCountResponse
|
||||
from litellm.types.utils import CountContextManagement, TokenCountMessagesFormat, TokenCountResponse
|
||||
|
||||
COUNT_URL: Final = "https://api.openai.com/v1/responses/input_tokens"
|
||||
|
||||
|
|
@ -484,6 +486,122 @@ async def test_anthropic_format_tool_result_text_block_without_text_still_counts
|
|||
assert {"type": "function_call_output", "call_id": "toolu_log", "output": "build ok\n\ndone"} in sent["input"]
|
||||
|
||||
|
||||
def _tool_pair(index: int) -> tuple[dict[str, object], dict[str, object]]:
|
||||
return (
|
||||
{"role": "assistant", "content": [{"type": "tool_use", "id": f"toolu_{index}", "name": "log", "input": {}}]},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "tool_result", "tool_use_id": f"toolu_{index}", "content": f"log {index} " * 40}],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
TOOL_PAIRS: Final[list[dict[str, object]]] = [
|
||||
{"role": "user", "content": "Compare the logs."},
|
||||
*chain.from_iterable(_tool_pair(index) for index in range(3)),
|
||||
]
|
||||
|
||||
|
||||
async def _count_with_edit(
|
||||
monkeypatch: pytest.MonkeyPatch, edit: dict[str, JsonValue], drop_invalid_edits: bool, system: object = None
|
||||
) -> tuple[TokenCountResponse | None, respx.Route]:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
with respx.mock:
|
||||
route: Final = respx.post(COUNT_URL).mock(return_value=httpx.Response(200, json={"input_tokens": 9}))
|
||||
result: Final = await OpenAITokenCounter().count_tokens(
|
||||
model_to_use="gpt-4o",
|
||||
messages=copy.deepcopy(TOOL_PAIRS),
|
||||
contents=None,
|
||||
deployment={"litellm_params": {"api_key": "sk-test"}},
|
||||
request_model="openai/gpt-4o",
|
||||
system=system,
|
||||
messages_format="anthropic",
|
||||
context_management=CountContextManagement(spec={"edits": [edit]}, drop_invalid_edits=drop_invalid_edits),
|
||||
)
|
||||
return result, route
|
||||
|
||||
|
||||
def _sent_outputs(route: respx.Route) -> dict[str, str]:
|
||||
sent: Final = json.loads(route.calls.last.request.content)
|
||||
return {item["call_id"]: item["output"] for item in sent["input"] if item["type"] == "function_call_output"}
|
||||
|
||||
|
||||
CLEAR_EDIT: Final[dict[str, JsonValue]] = {
|
||||
"type": "clear_tool_uses_20250919",
|
||||
"trigger": {"type": "tool_uses", "value": 1},
|
||||
"keep": {"type": "tool_uses", "value": 1},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_format_counts_the_history_the_clear_edit_leaves_for_inference(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
result, route = await _count_with_edit(monkeypatch, CLEAR_EDIT, drop_invalid_edits=False)
|
||||
|
||||
assert result is not None
|
||||
assert (result.error, result.total_tokens) == (False, 9)
|
||||
assert _sent_outputs(route) == {
|
||||
"toolu_0": CLEARED_TOOL_RESULT_PLACEHOLDER,
|
||||
"toolu_1": CLEARED_TOOL_RESULT_PLACEHOLDER,
|
||||
"toolu_2": "log 2 " * 40,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"system",
|
||||
[
|
||||
"Follow the policy. " * 1500,
|
||||
[{"type": "text", "text": "Follow the policy. " * 1500}],
|
||||
["Stray.", {"type": "text", "text": "Follow the policy. " * 1500}],
|
||||
],
|
||||
ids=["string", "blocks", "blocks_beside_a_stray_string"],
|
||||
)
|
||||
async def test_anthropic_format_input_tokens_trigger_counts_the_system_prompt_like_inference(
|
||||
monkeypatch: pytest.MonkeyPatch, system: object
|
||||
) -> None:
|
||||
message_tokens: Final = litellm.token_counter(model="gpt-4o", messages=TOOL_PAIRS)
|
||||
trigger_between_messages_and_system: Final = message_tokens + 1000
|
||||
edit: Final[dict[str, JsonValue]] = {
|
||||
**CLEAR_EDIT,
|
||||
"trigger": {"type": "input_tokens", "value": trigger_between_messages_and_system},
|
||||
}
|
||||
|
||||
_, without_system = await _count_with_edit(monkeypatch, edit, drop_invalid_edits=False)
|
||||
_, with_system = await _count_with_edit(monkeypatch, edit, drop_invalid_edits=False, system=system)
|
||||
|
||||
assert _sent_outputs(without_system)["toolu_0"] == "log 0 " * 40
|
||||
assert _sent_outputs(with_system) == {
|
||||
"toolu_0": CLEARED_TOOL_RESULT_PLACEHOLDER,
|
||||
"toolu_1": CLEARED_TOOL_RESULT_PLACEHOLDER,
|
||||
"toolu_2": "log 2 " * 40,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_format_malformed_clear_edit_answers_400_without_calling_openai(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
result, route = await _count_with_edit(monkeypatch, {**CLEAR_EDIT, "exclude_tools": "log"}, drop_invalid_edits=False)
|
||||
|
||||
assert route.called is False
|
||||
assert result is not None
|
||||
assert (result.error, result.status_code) == (True, 400)
|
||||
assert "exclude_tools" in str(result.error_message)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_format_drop_params_counts_the_clear_without_its_malformed_field(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
result, route = await _count_with_edit(monkeypatch, {**CLEAR_EDIT, "exclude_tools": "log"}, drop_invalid_edits=True)
|
||||
|
||||
assert result is not None
|
||||
assert result.error is False
|
||||
assert _sent_outputs(route)["toolu_0"] == CLEARED_TOOL_RESULT_PLACEHOLDER
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_uses_the_global_openai_key_chat_would_use(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
|
|
|
|||
|
|
@ -2,13 +2,51 @@
|
|||
Test RunwayML text-to-speech transformation
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
import litellm
|
||||
from litellm.llms.runwayml.text_to_speech.transformation import (
|
||||
RunwayMLTextToSpeechConfig,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def httpx_transport(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
yield
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_speech_polls_the_runwayml_task_with_the_caller_key_and_returns_its_audio(
|
||||
respx_mock: respx.MockRouter, httpx_transport: None
|
||||
) -> None:
|
||||
respx_mock.post("https://api.dev.runwayml.com/v1/text_to_speech").mock(
|
||||
return_value=httpx.Response(200, json={"id": "task_1", "status": "PENDING"})
|
||||
)
|
||||
poll: Final = respx_mock.get("https://api.dev.runwayml.com/v1/tasks/task_1").mock(
|
||||
return_value=httpx.Response(
|
||||
200, json={"id": "task_1", "status": "SUCCEEDED", "output": ["https://storage.example.com/a1.mp3"]}
|
||||
)
|
||||
)
|
||||
respx_mock.get("https://storage.example.com/a1.mp3").mock(
|
||||
return_value=httpx.Response(200, content=b"ID3runway-audio")
|
||||
)
|
||||
|
||||
speech: Final = await litellm.aspeech(
|
||||
model="runwayml/eleven_multilingual_v2", input="Hello.", voice="alloy", api_key="rw-key"
|
||||
)
|
||||
|
||||
assert speech.content == b"ID3runway-audio"
|
||||
assert poll.calls.last.request.headers["authorization"] == "Bearer rw-key"
|
||||
|
||||
|
||||
def test_openai_voice_mapping_to_runwayml():
|
||||
"""
|
||||
Test that OpenAI voice names are correctly mapped to RunwayML preset IDs
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ API_BASE_IS_THE_FULL_URL: Final[Mapping[str, str]] = MappingProxyType(
|
|||
"brave.search.transformation.BraveSearchConfig",
|
||||
"cohere.chat.v2_transformation.CohereV2ChatConfig",
|
||||
"cohere.embed.transformation.CohereEmbeddingConfig",
|
||||
"dashscope.image_edit.transformation.DashScopeImageEditConfig",
|
||||
"dashscope.image_generation.transformation.DashScopeImageGenerationConfig",
|
||||
"dataforseo.search.transformation.DataForSEOSearchConfig",
|
||||
"datarobot.chat.transformation.DataRobotConfig",
|
||||
|
|
|
|||
|
|
@ -649,7 +649,7 @@ async def test_anthropic_view_counts_the_unedited_body_without_an_error_log_when
|
|||
assert not any(record.levelno >= logging.ERROR for record in caplog.records)
|
||||
|
||||
|
||||
async def test_anthropic_view_counts_the_turns_after_a_compaction_block_when_an_edit_before_compact_fails() -> None:
|
||||
async def test_anthropic_view_clears_only_the_turns_after_a_compaction_block_when_clear_runs_before_compact() -> None:
|
||||
clear_before_compact: Final[JsonValue] = {
|
||||
"edits": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1028,6 +1028,40 @@ def test_oss_native_model_cannot_implicitly_select_an_unauthorized_checkpoint(pr
|
|||
assert denied.value.status_code == 400
|
||||
|
||||
|
||||
# eu-jev.com/docs (read 2026-10-21): an omitted model means eu-jeff-latest, and the legacy names
|
||||
# jeff-latest and jeff-1.0.0 answer with the current release.
|
||||
@pytest.mark.parametrize(
|
||||
("request_data", "permission_model"),
|
||||
[
|
||||
({}, "eu_jev/eu-jeff-latest"),
|
||||
({"model": None}, "eu_jev/eu-jeff-latest"),
|
||||
({"model": "eu-jeff-latest"}, "eu_jev/eu-jeff-latest"),
|
||||
({"model": "jeff-latest"}, "eu_jev/eu-jeff-latest"),
|
||||
({"model": "jeff-1.0.0"}, "eu_jev/eu-jeff-latest"),
|
||||
({"model": "eu-jeff-1.0.0"}, "eu_jev/eu-jeff-1.0.0"),
|
||||
({"model": "eu_jev/eu-jeff-1.0.0"}, "eu_jev/eu-jeff-1.0.0"),
|
||||
({"model": "eu_jev/jeff-latest"}, "eu_jev/eu-jeff-latest"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("suffix", ["", "/"])
|
||||
def test_eu_jev_decision_is_checked_against_the_model_eu_jev_runs(
|
||||
request_data: dict[str, object], permission_model: str, suffix: str
|
||||
) -> None:
|
||||
assert get_model_from_request(request_data=request_data, route=f"/eu_jev/v1/systemone{suffix}") == permission_model
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", [["eu-jeff-latest"], 7, {"name": "eu-jeff-latest"}])
|
||||
def test_eu_jev_decision_rejects_a_model_that_is_not_a_string(model: object) -> None:
|
||||
with pytest.raises(HTTPException) as denied:
|
||||
get_model_from_request(request_data={"model": model}, route="/eu_jev/v1/systemone")
|
||||
assert denied.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("configured_eu_jev_route")
|
||||
def test_a_pass_through_configured_at_the_eu_jev_decision_path_keeps_the_requested_model() -> None:
|
||||
assert get_model_from_request(request_data={"model": "jeff-latest"}, route="/eu_jev/v1/systemone") == "jeff-latest"
|
||||
|
||||
|
||||
def test_laya_model_normalization_does_not_change_other_provider_routes() -> None:
|
||||
assert get_model_from_request(request_data={"model": "jev-latest"}, route="/typesafe/v1/systemone") == "jev-latest"
|
||||
assert get_model_from_request(request_data={}, route="/laya/health") is None
|
||||
|
|
|
|||
|
|
@ -662,6 +662,41 @@ def test_virtual_key_llm_api_routes_allows_model_info(route):
|
|||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("route", "is_llm_route"),
|
||||
[
|
||||
("/eu_jev/v1/systemone", True),
|
||||
("/eu_jev/v1/systemone/extra", False),
|
||||
("/eu_jev/v1/systemone2", False),
|
||||
("/eu_jev/custom", False),
|
||||
],
|
||||
)
|
||||
def test_only_the_built_in_eu_jev_decision_path_is_an_llm_route(route: str, is_llm_route: bool) -> None:
|
||||
assert RouteChecks.is_llm_api_route(route=route) is is_llm_route
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("configured_eu_jev_route")
|
||||
def test_a_pass_through_configured_at_the_eu_jev_decision_path_is_not_an_llm_route() -> None:
|
||||
assert RouteChecks.is_llm_api_route(route="/eu_jev/v1/systemone") is False
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("configured_eu_jev_route_with_trailing_slash")
|
||||
@pytest.mark.parametrize("route", ["/eu_jev/v1/systemone", "/eu_jev/v1/systemone/"])
|
||||
def test_a_pass_through_configured_at_the_eu_jev_decision_path_with_a_trailing_slash_is_not_an_llm_route(
|
||||
route: str,
|
||||
) -> None:
|
||||
assert RouteChecks.is_llm_api_route(route=route) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("allowed_route", ["llm_api_routes", "llm_api_routes_without_mcp"])
|
||||
def test_an_llm_api_key_can_call_the_built_in_eu_jev_decision_route(allowed_route: str) -> None:
|
||||
valid_token: Final = UserAPIKeyAuth(user_id="test_user", allowed_routes=[allowed_route])
|
||||
|
||||
assert RouteChecks.is_virtual_key_allowed_to_call_route(
|
||||
route="/eu_jev/v1/systemone", valid_token=valid_token, request=_mock_request("POST")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", ["/model/info", "/v1/model/info", "/model_group/info"])
|
||||
def test_model_info_not_classified_as_llm_api(route):
|
||||
"""Membership in `llm_api_routes` must not promote /model/info to an
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue