Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions deploy/charts/litellm-helm/templates/hpa.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ spec:
name: {{ include "litellm.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
{{- if .Values.autoscaling.behavior }}
behavior:
{{- toYaml .Values.autoscaling.behavior | nindent 4 }}
{{- end }}
metrics:
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
Expand Down
36 changes: 36 additions & 0 deletions deploy/charts/litellm-helm/tests/hpa_tests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
suite: "hpa with behavior"
templates:
- hpa.yaml
tests:
- it: "renders behavior when set"
set:
autoscaling.enabled: true
autoscaling.behavior:
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Pods
value: 2
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 90
policies:
- type: Pods
value: 1
periodSeconds: 60
asserts:
- isKind: { of: HorizontalPodAutoscaler }
- equal: { path: spec.behavior.scaleUp.stabilizationWindowSeconds, value: 60 }
- equal: { path: spec.behavior.scaleDown.stabilizationWindowSeconds, value: 90 }

---
suite: "hpa without behavior"
templates:
- hpa.yaml
tests:
- it: "does not render behavior when not set"
set:
autoscaling.enabled: true
asserts:
- isKind: { of: HorizontalPodAutoscaler }
- isNull: { path: spec.behavior }
1 change: 1 addition & 0 deletions deploy/charts/litellm-helm/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ autoscaling:
maxReplicas: 100
targetCPUUtilizationPercentage: 80
# targetMemoryUtilizationPercentage: 80
# behavior: {}

# Autoscaling with keda is mutually exclusive with hpa
keda:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- AlterTable
-- Adds the admin-toggleable pause flag used by the router's blocked filter and the
-- credential lookup helpers; defaults to false so existing rows behave unchanged.
ALTER TABLE "LiteLLM_ProxyModelTable" ADD COLUMN IF NOT EXISTS "blocked" BOOLEAN NOT NULL DEFAULT false;
5 changes: 3 additions & 2 deletions litellm-proxy-extras/litellm_proxy_extras/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,10 @@ model LiteLLM_CredentialsTable {
// Models on proxy
model LiteLLM_ProxyModelTable {
model_id String @id @default(uuid())
model_name String
model_name String
litellm_params Json
model_info Json?
model_info Json?
blocked Boolean @default(false)
created_at DateTime @default(now()) @map("created_at")
created_by String
updated_at DateTime @default(now()) @updatedAt @map("updated_at")
Expand Down
2 changes: 2 additions & 0 deletions litellm/_redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ def _get_redis_cluster_kwargs(client=None):
"azure_tenant_id",
"azure_client_secret",
"max_connections",
"socket_timeout",
"socket_connect_timeout",
}

return available_args
Expand Down
82 changes: 78 additions & 4 deletions litellm/cost_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,17 +173,45 @@ def _cost_per_token_custom_pricing_helper(
prompt_tokens: float = 0,
completion_tokens: float = 0,
response_time_ms: Optional[float] = 0.0,
cached_tokens: float = 0,
cache_creation_tokens: float = 0,
### CUSTOM PRICING ###
custom_cost_per_token: Optional[CostPerToken] = None,
custom_cost_per_second: Optional[float] = None,
) -> Optional[Tuple[float, float]]:
"""Internal helper function for calculating cost, if custom pricing given"""
"""Internal helper function for calculating cost, if custom pricing given.

prompt_tokens is assumed to include both cached_tokens and cache_creation_tokens
(OpenAI-compatible convention). Anthropic-style usage where prompt_tokens excludes
cache tokens is handled at the caller (cost_per_token) before invoking this helper.
"""
if custom_cost_per_token is None and custom_cost_per_second is None:
return None

if custom_cost_per_token is not None:
input_cost = custom_cost_per_token["input_cost_per_token"] * prompt_tokens
output_cost = custom_cost_per_token["output_cost_per_token"] * completion_tokens
input_cost_per_token = custom_cost_per_token["input_cost_per_token"]
output_cost_per_token = custom_cost_per_token["output_cost_per_token"]

cache_read_input_token_cost = custom_cost_per_token.get(
"cache_read_input_token_cost",
input_cost_per_token,
)
cache_creation_input_token_cost = custom_cost_per_token.get(
"cache_creation_input_token_cost",
input_cost_per_token,
)

regular_prompt_tokens = max(
prompt_tokens - cached_tokens - cache_creation_tokens,
0,
)

input_cost = (
regular_prompt_tokens * input_cost_per_token
+ cached_tokens * cache_read_input_token_cost
+ cache_creation_tokens * cache_creation_input_token_cost
)
output_cost = completion_tokens * output_cost_per_token
return input_cost, output_cost
elif custom_cost_per_second is not None:
output_cost = custom_cost_per_second * response_time_ms / 1000 # type: ignore
Expand Down Expand Up @@ -323,10 +351,56 @@ def cost_per_token( # noqa: PLR0915
)

## CUSTOM PRICING ##
# Normalize cache token counts across providers:
# - OpenAI-compatible: usage.prompt_tokens_details.cached_tokens
# (prompt_tokens already INCLUDES cached_tokens)
# - Anthropic: usage.cache_read_input_tokens / cache_creation_input_tokens
# (prompt_tokens does NOT include these — adjust before calling helper)
_cache_read_tokens: float = 0
_cache_creation_tokens: float = 0
_is_anthropic_style = False

if usage_object is not None:
_pt_details = getattr(usage_object, "prompt_tokens_details", None)
if _pt_details is not None:
_cache_read_tokens = float(getattr(_pt_details, "cached_tokens", 0) or 0)
# OpenAI-compatible providers report cache-write tokens under
# either `cache_creation_tokens` or `cache_write_tokens` (kimi-k2
# uses the latter). Mirror db_spend_update_writer to stay symmetric.
_cache_creation_tokens = float(
getattr(_pt_details, "cache_creation_tokens", 0)
or getattr(_pt_details, "cache_write_tokens", 0)
or 0
)

_anthropic_read = getattr(usage_object, "cache_read_input_tokens", None)
_anthropic_create = getattr(usage_object, "cache_creation_input_tokens", None)
if _anthropic_read or _anthropic_create:
_is_anthropic_style = True
if _anthropic_read:
_cache_read_tokens = float(_anthropic_read)
if _anthropic_create:
_cache_creation_tokens = float(_anthropic_create)

if not _cache_read_tokens and cache_read_input_tokens:
_cache_read_tokens = float(cache_read_input_tokens)
_is_anthropic_style = True
if not _cache_creation_tokens and cache_creation_input_tokens:
_cache_creation_tokens = float(cache_creation_input_tokens)
_is_anthropic_style = True

# Anthropic reports prompt_tokens as input_tokens (excluding cache tokens).
# Adjust so the helper's "prompt_tokens includes cache tokens" invariant holds.
_normalized_prompt_tokens = float(prompt_tokens)
if _is_anthropic_style:
_normalized_prompt_tokens += _cache_read_tokens + _cache_creation_tokens

response_cost = _cost_per_token_custom_pricing_helper(
prompt_tokens=prompt_tokens,
prompt_tokens=_normalized_prompt_tokens,
completion_tokens=completion_tokens,
response_time_ms=response_time_ms,
cached_tokens=_cache_read_tokens,
cache_creation_tokens=_cache_creation_tokens,
custom_cost_per_second=custom_cost_per_second,
custom_cost_per_token=custom_cost_per_token,
)
Expand Down
103 changes: 102 additions & 1 deletion litellm/llms/deepseek/chat/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
Translates from OpenAI's `/v1/chat/completions` to DeepSeek's `/v1/chat/completions`
"""

from typing import Any, Coroutine, List, Literal, Optional, Tuple, Union, overload
from typing import Any, Coroutine, List, Literal, Optional, Tuple, Union, cast, overload

import litellm
from litellm.litellm_core_utils.prompt_templates.common_utils import (
handle_messages_with_content_list_to_str_conversion,
)
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import AllMessageValues
from litellm.utils import supports_reasoning

from ...openai.chat.gpt_transformation import OpenAIGPTConfig

Expand Down Expand Up @@ -62,6 +64,45 @@ def map_openai_params(

return optional_params

def _fill_reasoning_content(
self, messages: List[AllMessageValues]
) -> List[AllMessageValues]:
"""
DeepSeek thinking mode requires `reasoning_content` to be passed back on
every assistant message in multi-turn conversations. If it is missing,
the API returns:
"The reasoning_content in the thinking mode must be passed back to the API."

For each assistant message that is missing `reasoning_content`:
1. Promote it from `provider_specific_fields["reasoning_content"]` if present
(LiteLLM stores provider-specific response fields there).
2. Otherwise inject a single space — the minimum value the API accepts.
"""
result: List[AllMessageValues] = []
for msg in messages:
if msg.get("role") == "assistant" and not msg.get("reasoning_content"):
patched = dict(cast(dict, msg))
provider_fields = patched.get("provider_specific_fields") or {}
stored = provider_fields.get("reasoning_content")
if stored:
patched["reasoning_content"] = stored
cleaned = dict(provider_fields)
cleaned.pop("reasoning_content", None)
patched["provider_specific_fields"] = cleaned
else:
litellm.verbose_logger.debug(
"DeepSeek thinking mode: assistant message is missing "
"`reasoning_content`. Injecting a placeholder to satisfy "
"API validation. For best results, preserve "
"`reasoning_content` from the original assistant response "
"when building multi-turn conversation history."
)
patched["reasoning_content"] = " "
result.append(cast(AllMessageValues, patched))
else:
result.append(msg)
return result

@overload
def _transform_messages(
self, messages: List[AllMessageValues], model: str, is_async: Literal[True]
Expand Down Expand Up @@ -91,6 +132,66 @@ def _transform_messages(
messages=messages, model=model, is_async=False
)

def _thinking_mode_active(self, model: str, optional_params: dict) -> bool:
"""
Returns True only when thinking mode is actually active for this request:
- model supports reasoning (capability check)
- user explicitly passed thinking={"type": "enabled"} (opt-in check)
"""
return (
supports_reasoning(model=model, custom_llm_provider="deepseek")
and (optional_params.get("thinking") or {}).get("type") == "enabled"
)

def transform_request(
self,
model: str,
messages: List[AllMessageValues],
optional_params: dict,
litellm_params: dict,
headers: dict,
) -> dict:
"""
Ensures `reasoning_content` is forwarded on assistant messages for
multi-turn thinking-mode conversations (issue #28045).

Only runs when thinking mode is actually active - guarded by both
supports_reasoning() (model capability) and optional_params["thinking"]
(user explicitly enabled it), preventing spurious injection on models
like deepseek-v3.2 that support thinking as opt-in but not always-on.
"""
if self._thinking_mode_active(model=model, optional_params=optional_params):
messages = self._fill_reasoning_content(messages)
return super().transform_request(
model=model,
messages=messages,
optional_params=optional_params,
litellm_params=litellm_params,
headers=headers,
)

async def async_transform_request(
self,
model: str,
messages: List[AllMessageValues],
optional_params: dict,
litellm_params: dict,
headers: dict,
) -> dict:
"""
Async equivalent of transform_request — applies the same reasoning_content
fix for multi-turn thinking-mode conversations.
"""
if self._thinking_mode_active(model=model, optional_params=optional_params):
messages = self._fill_reasoning_content(messages)
return await super().async_transform_request(
model=model,
messages=messages,
optional_params=optional_params,
litellm_params=litellm_params,
headers=headers,
)

def _get_openai_compatible_provider_info(
self, api_base: Optional[str], api_key: Optional[str]
) -> Tuple[Optional[str], Optional[str]]:
Expand Down
1 change: 1 addition & 0 deletions litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -4549,6 +4549,7 @@ class PrismaCompatibleUpdateDBModel(TypedDict, total=False):
model_name: str
litellm_params: str
model_info: str
blocked: bool
updated_at: str
updated_by: str

Expand Down
2 changes: 1 addition & 1 deletion litellm/proxy/auth/auth_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -3694,7 +3694,7 @@ async def _team_max_budget_check(
fallback_spend=team_object.spend or 0.0,
)

if math.isfinite(team_object.max_budget) and spend > team_object.max_budget:
if math.isfinite(team_object.max_budget) and spend >= team_object.max_budget:
if valid_token:
call_info = CallInfo(
token=valid_token.token,
Expand Down
37 changes: 31 additions & 6 deletions litellm/proxy/db/db_spend_update_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,35 @@
ProxyLogging = Any


def _extract_cache_read_tokens(usage_obj: dict) -> int:
"""
Anthropic: top-level cache_read_input_tokens field.
OpenAI-compatible (moonshotai, openai, deepseek, etc.): prompt_tokens_details.cached_tokens.
"""
explicit = usage_obj.get("cache_read_input_tokens", 0) or 0
if explicit:
return int(explicit)
details = usage_obj.get("prompt_tokens_details") or {}
return int(details.get("cached_tokens", 0) or 0)


def _extract_cache_creation_tokens(usage_obj: dict) -> int:
"""
Anthropic: top-level cache_creation_input_tokens field.
OpenAI-compatible (kimi-k2 etc.): prompt_tokens_details.cache_write_tokens
or prompt_tokens_details.cache_creation_tokens.
"""
explicit = usage_obj.get("cache_creation_input_tokens", 0) or 0
if explicit:
return int(explicit)
details = usage_obj.get("prompt_tokens_details") or {}
return int(
details.get("cache_write_tokens", 0)
or details.get("cache_creation_tokens", 0)
or 0
)


class DBSpendUpdateWriter:
"""
Module responsible for
Expand Down Expand Up @@ -1992,12 +2021,8 @@ async def _common_add_spend_log_transaction_to_daily_transaction(
api_requests=1,
successful_requests=1 if request_status == "success" else 0,
failed_requests=1 if request_status != "success" else 0,
cache_read_input_tokens=usage_obj.get("cache_read_input_tokens", 0)
or 0,
cache_creation_input_tokens=usage_obj.get(
"cache_creation_input_tokens", 0
)
or 0,
cache_read_input_tokens=_extract_cache_read_tokens(usage_obj),
cache_creation_input_tokens=_extract_cache_creation_tokens(usage_obj),
)
return daily_transaction
except Exception as e:
Expand Down
Loading
Loading