diff --git a/.github/PULL_REQUEST_TEMPLATE/general.md b/.github/PULL_REQUEST_TEMPLATE/general.md
index f6ce687a552..aafd968ee0f 100644
--- a/.github/PULL_REQUEST_TEMPLATE/general.md
+++ b/.github/PULL_REQUEST_TEMPLATE/general.md
@@ -9,10 +9,16 @@
+``
+
+``
+
Details
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index b5f4a36f138..d8a57d3e9a1 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -3070,6 +3070,29 @@ redis_usage_cache: RedisCache | None = None # redis cache used for tracking spe
polling_via_cache_enabled: Literal["all"] | list[str] | bool = False
native_background_mode: list[str] = [] # Models that should use native provider background mode instead of polling
polling_cache_ttl: int = 3600 # Default 1 hour TTL for polling cache
+
+
+class _ResponseBackgroundSettings(BaseModel):
+ model_config = ConfigDict(frozen=True)
+
+ polling_via_cache: Literal["all"] | tuple[str, ...] | bool = False
+ native_background_mode: tuple[str, ...] = ()
+ ttl: int = 3600
+
+
+def _apply_background_response_settings(value: SettingsJsonValue) -> None:
+ global polling_via_cache_enabled, native_background_mode, polling_cache_ttl
+
+ responses: Final = _as_settings_mapping(value)
+ settings: Final = _ResponseBackgroundSettings.model_validate(responses.get("background_mode") or {})
+ polling: Final = settings.polling_via_cache
+ polling_via_cache_enabled, native_background_mode, polling_cache_ttl = ( # rebind-ok: Refresh runtime config.
+ list(polling) if isinstance(polling, tuple) else polling,
+ list(settings.native_background_mode),
+ settings.ttl,
+ )
+
+
user_custom_auth = None
user_custom_key_generate = None
# Sentinel: prevents PKCE-no-Redis advisory from re-logging on config hot-reload.
@@ -6932,20 +6955,7 @@ class ProxyConfig:
# see usage here: https://docs.litellm.ai/docs/proxy/caching
pass
elif key == "responses":
- # Initialize global polling via cache settings
- global polling_via_cache_enabled, native_background_mode, polling_cache_ttl
- background_mode = value.get("background_mode", {})
- polling_via_cache_enabled = background_mode.get("polling_via_cache", False)
- native_background_mode = background_mode.get("native_background_mode", [])
- polling_cache_ttl = background_mode.get("ttl", 3600)
- verbose_proxy_logger.debug(
- "%s Initialized polling via cache: enabled=%s, native_background_mode=%s, ttl=%s%s",
- blue_color_code,
- polling_via_cache_enabled,
- native_background_mode,
- polling_cache_ttl,
- reset_color_code,
- )
+ _apply_background_response_settings(value)
elif key == "max_ui_session_budget":
litellm.max_ui_session_budget = float(value) if value is not None else None
verbose_proxy_logger.debug(
@@ -8653,6 +8663,7 @@ class ProxyConfig:
def _apply_litellm_settings_db_values(self, db_values: Mapping[str, SettingsJsonValue]) -> None:
self.litellm_settings.apply_db_row("litellm_settings", db_values)
+ _apply_background_response_settings(self.litellm_settings.get("responses", {}))
for key in LITELLM_SETTINGS_SAFE_DB_OVERRIDES:
if key in db_values and (value := self.litellm_settings.get(key)) is not None:
setattr(litellm, key, value)
@@ -18110,6 +18121,8 @@ async def model_deprecations(
def get_model_group_info(
llm_router: Router, all_models_str: Sequence[str], model_group: str | None, team_id: str | None = None
) -> list[ModelGroupInfoProxy]:
+ from litellm.proxy.response_polling.polling_handler import should_use_polling_for_request
+
model_groups: Final[list[ModelGroupInfoProxy]] = []
unique_models: Final = tuple(dict.fromkeys(all_models_str))
@@ -18119,6 +18132,14 @@ def get_model_group_info(
if model_group is not None and model_group != model:
continue
+ supports_background_chat = should_use_polling_for_request(
+ background_mode=True,
+ polling_via_cache_enabled=polling_via_cache_enabled,
+ redis_cache=redis_usage_cache,
+ model=model,
+ llm_router=llm_router,
+ native_background_mode=native_background_mode,
+ )
_model_group_info = registry.group(model, team_id=team_id)
if _model_group_info is not None:
@@ -18126,6 +18147,7 @@ def get_model_group_info(
ModelGroupInfoProxy.model_validate(
{
**_model_group_info.model_dump(),
+ "supports_background_chat": supports_background_chat,
**model_group_custom_fields(
registry.deployments(
resolve_model_group_alias(registry.aliases(), model) or model,
@@ -18140,6 +18162,7 @@ def get_model_group_info(
model_group_info = ModelGroupInfoProxy(
model_group=model,
providers=[],
+ supports_background_chat=supports_background_chat,
)
model_groups.append(model_group_info)
diff --git a/litellm/proxy/response_polling/background_streaming.py b/litellm/proxy/response_polling/background_streaming.py
index 1eb76f8f558..574152048d4 100644
--- a/litellm/proxy/response_polling/background_streaming.py
+++ b/litellm/proxy/response_polling/background_streaming.py
@@ -11,6 +11,7 @@ https://platform.openai.com/docs/api-reference/responses-streaming
import asyncio
import json
from collections.abc import AsyncIterable, Callable, Mapping, Sequence
+from time import perf_counter
from typing import TYPE_CHECKING, Final, Protocol, TypeAlias, runtime_checkable
from fastapi import Request, Response
@@ -128,6 +129,16 @@ async def background_streaming_task(
https://platform.openai.com/docs/api-reference/responses/object
"""
+ started_at: Final = perf_counter()
+ first_token_at: float | None = None # rebind-ok: first content event closes the TTFT interval
+ finished_at: float | None = None # rebind-ok: terminal event closes the execution interval
+
+ def timing_snapshot() -> Mapping[str, float]:
+ return {
+ **({"time_to_first_token_ms": (first_token_at - started_at) * 1000} if first_token_at is not None else {}),
+ **({"total_latency_ms": (finished_at - started_at) * 1000} if finished_at is not None else {}),
+ }
+
try:
verbose_proxy_logger.info("Starting background streaming for %s", polling_id)
@@ -191,7 +202,7 @@ async def background_streaming_task(
incomplete_details_data = None
state_dirty = False # Track if state needs to be synced
- last_update_time = asyncio.get_event_loop().time()
+ last_update_time = perf_counter()
UPDATE_INTERVAL: Final = 0.150 # 150ms batching interval
# Track the terminal event from the stream (may not be "completed")
@@ -210,13 +221,14 @@ async def background_streaming_task(
"""Flush accumulated state to Redis if interval elapsed or forced"""
nonlocal state_dirty, last_update_time
- current_time: Final = asyncio.get_event_loop().time()
+ current_time: Final = perf_counter()
if state_dirty and (force or (current_time - last_update_time) >= UPDATE_INTERVAL):
# Convert output_items dict to list for update
output_list: Final = list(output_items.values())
await polling_handler.update_state(
polling_id=polling_id,
output=output_list,
+ timing=timing_snapshot(),
)
state_dirty = False
last_update_time = current_time
@@ -242,6 +254,20 @@ async def background_streaming_task(
event: _StreamEvent = _StreamEventParser.parse(chunk_data)
event_type = event.get("type", "")
+ if (
+ first_token_at is None
+ and event_type
+ in (
+ "response.output_text.delta",
+ "response.reasoning_text.delta",
+ "response.reasoning_summary_text.delta",
+ "response.reasoning.delta",
+ )
+ and event.get("delta")
+ ):
+ first_token_at = perf_counter()
+ state_dirty = True
+
# Process different event types based on OpenAI streaming spec
if event_type == "response.output_item.added":
# New output item added
@@ -334,6 +360,7 @@ async def background_streaming_task(
):
# Terminal event - extract all ResponsesAPIResponse fields
# https://platform.openai.com/docs/api-reference/responses-streaming
+ finished_at = perf_counter()
response_data = event.get("response", {})
terminal_status = response_data.get(
"status",
@@ -384,10 +411,13 @@ async def background_streaming_task(
# Use the terminal status from the stream, default to "completed"
final_status: Final = terminal_status or "completed"
+ if finished_at is None:
+ finished_at = perf_counter() # rebind-ok: close the interval when the stream has no terminal event
await polling_handler.update_state(
polling_id=polling_id,
status=final_status,
+ timing=timing_snapshot(),
usage=usage_data,
error=terminal_error,
reasoning=reasoning_data,
@@ -417,6 +447,7 @@ async def background_streaming_task(
)
except Exception as e:
+ finished_at = perf_counter() # rebind-ok: capture execution time at failure
verbose_proxy_logger.error("Error in background streaming task for %s: %s", polling_id, e)
import traceback
@@ -425,6 +456,7 @@ async def background_streaming_task(
await polling_handler.update_state(
polling_id=polling_id,
status="failed",
+ timing=timing_snapshot(),
error={
"type": "internal_error",
"message": str(e),
diff --git a/litellm/proxy/response_polling/polling_handler.py b/litellm/proxy/response_polling/polling_handler.py
index fe224a52cfa..d66773cd048 100644
--- a/litellm/proxy/response_polling/polling_handler.py
+++ b/litellm/proxy/response_polling/polling_handler.py
@@ -3,9 +3,9 @@ Response Polling Handler for Background Responses with Cache
"""
import json
-from collections.abc import Mapping
+from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
-from typing import Final
+from typing import TYPE_CHECKING, Final
from pydantic import TypeAdapter
@@ -15,6 +15,9 @@ from litellm._uuid import uuid4
from litellm.caching.redis_cache import RedisCache
from litellm.types.llms.openai import ResponsesAPIResponse, ResponsesAPIStatus
+if TYPE_CHECKING:
+ from litellm.router import Router
+
_RESPONSE_POLLING_TARGET: Final = "response_polling"
_JSON_OBJECT: Final = TypeAdapter(dict[str, object])
_JSON_LIST: Final = TypeAdapter(list[object])
@@ -116,6 +119,7 @@ class ResponsePollingHandler:
parallel_tool_calls: bool | None = None,
user: str | None = None,
store: bool | None = None,
+ timing: Mapping[str, float] | None = None,
) -> None:
"""
Update the polling state in Redis
@@ -144,6 +148,7 @@ class ResponsePollingHandler:
parallel_tool_calls: Whether parallel tool calls are enabled
user: User identifier
store: Whether to store the response
+ timing: Server execution timings in milliseconds
"""
if not self.redis_cache:
return
@@ -167,6 +172,9 @@ class ResponsePollingHandler:
if output is not None:
state["output"] = output
+ if timing is not None:
+ state["litellm_response_timing"] = timing
+
# Update usage
if usage:
state["usage"] = usage
@@ -264,10 +272,10 @@ class ResponsePollingHandler:
def should_use_polling_for_request(
background_mode: bool,
- polling_via_cache_enabled, # Can be False, "all", or List[str]
- redis_cache, # RedisCache or None
+ polling_via_cache_enabled: bool | str | Sequence[str],
+ redis_cache: RedisCache | None,
model: str,
- llm_router, # Router instance or None
+ llm_router: "Router | None",
native_background_mode: list[str] | None = None, # List of models that should use native background mode
) -> bool:
"""
diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py
index 0efd4e4c619..e5348684d78 100644
--- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py
+++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py
@@ -216,6 +216,20 @@ class _SettingsWithSchema(LiteLLMBaseModel):
field_schema: dict[str, object]
+class BackgroundChatSettings(LiteLLMBaseModel):
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ enabled: bool = Field(description="Enable cache-backed background Responses for Chat and API requests.")
+
+
+class BackgroundChatSettingsResponse(BackgroundChatSettings):
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ redis_configured: bool = Field(description="Whether this proxy has coordination Redis configured.")
+ managed_by_config: bool = Field(description="Whether the responses configuration is owned by the config file.")
+ can_save: bool = Field(description="Whether database-backed configuration updates are available.")
+
+
class SSOSettingsResponse(SettingsResponse):
"""Response model for SSO settings"""
@@ -1815,6 +1829,87 @@ async def sync_ui_settings_to_general_settings(
return apply_runtime_general_settings_flags(parsed)
+def _background_chat_settings_response(config: Mapping[str, object]) -> BackgroundChatSettingsResponse:
+ from litellm.proxy import proxy_server
+
+ settings: Final = _as_settings_section(config.get("litellm_settings"))
+ responses: Final = _as_settings_section(settings.get("responses"))
+ background: Final = _as_settings_section(responses.get("background_mode"))
+ polling: Final = background.get("polling_via_cache", False)
+ return BackgroundChatSettingsResponse(
+ enabled=bool(polling),
+ redis_configured=proxy_server.redis_usage_cache is not None,
+ managed_by_config=proxy_server.proxy_config.litellm_settings.owned_by_config("responses"),
+ can_save=proxy_server.prisma_client is not None and proxy_server.store_model_in_db is True,
+ )
+
+
+@router.get(
+ "/get/background_chat_settings",
+ tags=["Settings"],
+ dependencies=[Depends(user_api_key_auth)],
+ response_model=BackgroundChatSettingsResponse,
+)
+async def get_background_chat_settings() -> BackgroundChatSettingsResponse:
+ """Get saved background-response settings and the prerequisites for changing them."""
+ from litellm.proxy.proxy_server import proxy_config
+
+ config: Final = _as_settings_section(_DECODED_JSON.validate_python(await proxy_config.get_config()))
+ return _background_chat_settings_response(config)
+
+
+@router.patch(
+ "/update/background_chat_settings",
+ tags=["Settings"],
+ dependencies=[Depends(user_api_key_auth)],
+ response_model=BackgroundChatSettingsResponse,
+)
+async def update_background_chat_settings(
+ settings: BackgroundChatSettings,
+ user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
+) -> BackgroundChatSettingsResponse:
+ """Save background-response polling settings for the proxy configuration refresh."""
+ from litellm.proxy.proxy_server import (
+ create_config_audit_log,
+ prisma_client,
+ proxy_config,
+ redis_usage_cache,
+ store_model_in_db,
+ )
+
+ if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
+ raise HTTPException(status_code=403, detail="Only proxy admins can update background chat settings.")
+ if prisma_client is None or store_model_in_db is not True:
+ raise HTTPException(status_code=400, detail="Enable database-backed configuration to save this setting.")
+ if settings.enabled and redis_usage_cache is None:
+ raise HTTPException(status_code=400, detail="Configure coordination Redis and restart the proxy first.")
+
+ raw_config: Final = await proxy_config.get_config()
+ config: Final = _as_settings_section(_DECODED_JSON.validate_python(raw_config))
+ litellm_settings: Final = _as_settings_section(config.get("litellm_settings"))
+ responses: Final = _UI_SETTINGS_OBJECT.validate_python(_as_settings_section(litellm_settings.get("responses")))
+ background: Final = _UI_SETTINGS_OBJECT.validate_python(_as_settings_section(responses.get("background_mode")))
+ updated_responses: Final[Mapping[str, JsonValue]] = {
+ **responses,
+ "background_mode": {**background, "polling_via_cache": "all" if settings.enabled else False},
+ }
+ proxy_config.reject_config_owned_writes(
+ section_name="litellm_settings", changed_keys={"responses": dict(updated_responses)}
+ )
+ raw_config["litellm_settings"] = {**litellm_settings, "responses": dict(updated_responses)}
+ await proxy_config.save_config(new_config=config)
+ asyncio.create_task(
+ create_config_audit_log(
+ param_name="responses.background_mode.polling_via_cache",
+ action="updated",
+ before_value=background.get("polling_via_cache", False),
+ after_value="all" if settings.enabled else False,
+ user_api_key_dict=user_api_key_dict,
+ )
+ )
+ return _background_chat_settings_response(config)
+
+
@router.get(
"/get/ui_settings",
tags=["UI Settings"],
diff --git a/litellm/types/proxy/management_endpoints/model_management_endpoints.py b/litellm/types/proxy/management_endpoints/model_management_endpoints.py
index f032bb20c2a..b12745679df 100644
--- a/litellm/types/proxy/management_endpoints/model_management_endpoints.py
+++ b/litellm/types/proxy/management_endpoints/model_management_endpoints.py
@@ -35,6 +35,9 @@ class PatchModelRequest(updateDeployment):
class ModelGroupInfoProxy(ModelGroupInfo):
+ supports_background_chat: bool = Field(
+ default=False, description="Whether cache-backed background Responses runs are enabled for this model group"
+ )
is_public_model_group: bool = Field(default=False)
health_status: str | None = Field(default=None)
health_response_time: float | None = Field(default=None)
diff --git a/tests/base_sdk_tests/check_sdk_http.py b/tests/base_sdk_tests/check_sdk_http.py
index f7a1e30ac99..16ee4824f2d 100644
--- a/tests/base_sdk_tests/check_sdk_http.py
+++ b/tests/base_sdk_tests/check_sdk_http.py
@@ -70,7 +70,9 @@ def enqueue(body: object, status: int = 200) -> None:
def enqueue_stream() -> None:
chunk: Final = {**CHAT, "object": "chat.completion.chunk", "choices": [{"index": 0, "delta": {"content": "pong"}}]}
- RESPONSES.put((200, f"data: {json.dumps(chunk)}\n\ndata: [DONE]\n\n".encode()))
+ terminal: Final = {**chunk, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}
+ stream: Final = f"data: {json.dumps(chunk)}\n\ndata: {json.dumps(terminal)}\n\ndata: [DONE]\n\n"
+ RESPONSES.put((200, stream.encode()))
def bedrock_event(event: str, payload: object) -> bytes:
diff --git a/tests/unit/caching/test_request_redis_batch_pre_call.py b/tests/unit/caching/test_request_redis_batch_pre_call.py
index 37169e13a0b..94f19dbdc7c 100644
--- a/tests/unit/caching/test_request_redis_batch_pre_call.py
+++ b/tests/unit/caching/test_request_redis_batch_pre_call.py
@@ -290,7 +290,11 @@ def _router(redis_cache: FakeRedisCache, routing_strategy: str = "usage-based-ro
@pytest.mark.asyncio
-async def test_armed_routing_read_rides_the_admission_pipeline_and_routing_issues_no_read_of_its_own():
+async def test_armed_routing_read_rides_the_admission_pipeline_and_routing_issues_no_read_of_its_own(monkeypatch):
+ monkeypatch.setattr(
+ "litellm.router_strategy.lowest_tpm_rpm_v2.get_utc_datetime",
+ lambda: datetime(2026, 1, 1, 12, 0, 30, tzinfo=timezone.utc),
+ )
client = FakeClient(_lua_ok_replies)
redis_cache = FakeRedisCache(client)
router = _router(redis_cache)
diff --git a/tests/unit/litellm_core_utils/test_json_fragment_accumulator.py b/tests/unit/litellm_core_utils/test_json_fragment_accumulator.py
index f27a5fd2307..212dff79daf 100644
--- a/tests/unit/litellm_core_utils/test_json_fragment_accumulator.py
+++ b/tests/unit/litellm_core_utils/test_json_fragment_accumulator.py
@@ -1,6 +1,5 @@
import json
import time
-import tracemalloc
from collections.abc import Iterator
from typing import Final
from unittest.mock import patch
@@ -193,34 +192,15 @@ def _streamed(fragments: tuple[str, ...]) -> JSONFragmentAccumulator:
return accumulator
-def _drain_peak_bytes(fragments: tuple[str, ...]) -> int:
- accumulator: Final = _streamed(fragments)
- first_found, _ = accumulator.pop_next_value()
- assert first_found is True
- already_tracing: Final = tracemalloc.is_tracing()
- if not already_tracing:
- tracemalloc.start()
- tracemalloc.reset_peak()
- baseline, _ = tracemalloc.get_traced_memory()
- try:
- drained: Final = sum(1 for _ in _popped_values(accumulator))
- _, peak = tracemalloc.get_traced_memory()
- finally:
- if not already_tracing:
- tracemalloc.stop()
- assert drained == len(fragments) - 1
- return peak - baseline
-
-
-def test_draining_many_concatenated_values_never_copies_the_remainder():
+def test_draining_many_concatenated_values_yields_every_value_in_order():
value: Final = '{"text": "' + "x" * 20 + '"}'
fragments: Final = tuple(value if index % 2 else f" {value}" for index in range(40_000))
- payload_bytes: Final = sum(len(fragment) for fragment in fragments)
- peak_bytes: Final = _drain_peak_bytes(fragments)
- assert peak_bytes < payload_bytes // 4, (
- f"draining {len(fragments)} values allocated a peak of {peak_bytes} bytes over a {payload_bytes} byte payload, "
- "copying the remainder on every pop?"
- )
+ accumulator: Final = _streamed(fragments)
+
+ drained: Final = tuple(_popped_values(accumulator))
+
+ assert drained == (json.loads(value),) * len(fragments)
+ assert accumulator.pop_next_value() == (False, None)
def test_could_close_json_after_many_blank_fragments_is_not_quadratic():
diff --git a/tests/unit/proxy/response_polling/test_response_polling_handler.py b/tests/unit/proxy/response_polling/test_response_polling_handler.py
index 087b273548a..d6df7a6c69a 100644
--- a/tests/unit/proxy/response_polling/test_response_polling_handler.py
+++ b/tests/unit/proxy/response_polling/test_response_polling_handler.py
@@ -1917,3 +1917,71 @@ async def test_create_initial_state_rejects_request_metadata_that_is_not_an_obje
assert [(error["loc"], error["type"], repr(error["input"])) for error in rejected.value.errors()] == [
(("metadata",), "dict_type", repr(metadata))
]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "delta_type",
+ [
+ "response.output_text.delta",
+ "response.reasoning_text.delta",
+ "response.reasoning_summary_text.delta",
+ "response.reasoning.delta",
+ None,
+ ],
+)
+async def test_background_timings_survive_retrieval_after_completion(delta_type):
+ from litellm.caching.redis_cache import RedisCache
+ from litellm.proxy.response_polling.background_streaming import background_streaming_task
+
+ cache = {}
+ redis = AsyncMock(spec=RedisCache)
+ redis.async_get_cache.side_effect = lambda key: cache.get(key)
+
+ async def save_cache(key, value, **kwargs):
+ cache[key] = value
+
+ redis.async_set_cache.side_effect = save_cache
+ handler = ResponsePollingHandler(redis_cache=redis)
+ await handler.create_initial_state("litellm_poll_timing", {"model": "gpt-4o"})
+ clock = [100.0]
+
+ async def stream():
+ clock[0] = 101.0
+ yield 'data: {"type":"response.output_text.delta","delta":""}\n\n'
+ yield 'data: {"type":"response.mcp_call.in_progress"}\n\n'
+ if delta_type:
+ clock[0] = 102.0
+ yield f"data: {json.dumps({'type': delta_type, 'delta': 'Hello'})}\n\n"
+ partial = await handler.get_state("litellm_poll_timing")
+ assert partial is not None
+ assert partial["status"] == "in_progress"
+ assert partial["litellm_response_timing"] == {"time_to_first_token_ms": 2000.0}
+ clock[0] = 110.0
+ yield 'data: {"type":"response.completed","response":{"status":"completed","output":[]}}\n\n'
+ clock[0] = 5000.0
+ yield "data: [DONE]\n\n"
+
+ from starlette.responses import StreamingResponse
+
+ with (
+ patch(
+ "litellm.proxy.response_polling.background_streaming.perf_counter",
+ create=True,
+ side_effect=lambda: clock[0],
+ ),
+ patch(
+ # test-quality-ok: processor is constructed inside the task; supply the upstream SSE boundary like sibling tests
+ "litellm.proxy.response_polling.background_streaming.ProxyBaseLLMRequestProcessing"
+ ) as processor,
+ ):
+ processor.return_value.base_process_llm_request = AsyncMock(return_value=StreamingResponse(stream()))
+ await background_streaming_task(**_make_background_streaming_kwargs("litellm_poll_timing", handler))
+ state = await handler.get_state("litellm_poll_timing")
+ assert state is not None
+ expected = {"total_latency_ms": 10000.0}
+ if delta_type:
+ expected["time_to_first_token_ms"] = 2000.0
+ assert state["litellm_response_timing"] == expected
+ clock[0] = 10000.0
+ assert (await handler.get_state("litellm_poll_timing"))["litellm_response_timing"] == expected
diff --git a/tests/unit/proxy/test_proxy_server.py b/tests/unit/proxy/test_proxy_server.py
index 6e494f0b24b..e4af36d99f2 100644
--- a/tests/unit/proxy/test_proxy_server.py
+++ b/tests/unit/proxy/test_proxy_server.py
@@ -4561,3 +4561,88 @@ async def test_text_completion_provider_rejection_captures_unknown_usage(
assert envelope["usage"] is None
assert envelope["usage_source"] == "unknown"
assert envelope["pricing"]["reported_cost"] is None
+
+
+@pytest.mark.parametrize(
+ "polling,has_redis,native,expected",
+ [
+ (False, True, False, False),
+ ("all", False, False, False),
+ ("all", True, True, False),
+ ("all", True, False, True),
+ (["openai"], True, False, True),
+ (["anthropic"], True, False, False),
+ ],
+)
+def test_model_groups_advertise_cache_backed_background_chat(
+ monkeypatch: pytest.MonkeyPatch,
+ polling: bool | str | list[str],
+ has_redis: bool,
+ native: bool,
+ expected: bool,
+) -> None:
+ from litellm.caching.redis_cache import RedisCache
+ from litellm.proxy import proxy_server
+
+ router: Final = litellm.Router(
+ model_list=[
+ {
+ "model_name": "research-chat",
+ "litellm_params": {"model": "openai/gpt-6.1-sol", "api_key": "fake-test-key"},
+ }
+ ]
+ )
+ monkeypatch.setattr(proxy_server, "polling_via_cache_enabled", polling)
+ monkeypatch.setattr(
+ proxy_server, "redis_usage_cache", RedisCache(host="localhost", port=6379) if has_redis else None
+ )
+ monkeypatch.setattr(proxy_server, "native_background_mode", ["research-chat"] if native else [])
+ groups: Final = proxy_server.get_model_group_info(router, ["research-chat"], None)
+ assert groups[0].supports_background_chat is expected
+
+
+@pytest.mark.parametrize("config_owned", [False, True])
+def test_background_response_settings_refresh_and_respect_config(
+ monkeypatch: pytest.MonkeyPatch,
+ config_owned: bool,
+) -> None:
+ from litellm.proxy import proxy_server
+
+ monkeypatch.setattr(proxy_server, "polling_via_cache_enabled", False)
+ monkeypatch.setattr(proxy_server, "native_background_mode", [])
+ monkeypatch.setattr(proxy_server, "polling_cache_ttl", 3600)
+ config: Final = proxy_server.ProxyConfig()
+ if config_owned:
+ config.litellm_settings.load_yaml(
+ {
+ "responses": {
+ "background_mode": {
+ "polling_via_cache": ["openai"],
+ "native_background_mode": ["config-native"],
+ "ttl": 900,
+ }
+ },
+ }
+ )
+ config._apply_litellm_settings_db_values(
+ {
+ "responses": {
+ "background_mode": {
+ "polling_via_cache": "all",
+ "native_background_mode": ["db-native"],
+ "ttl": 7200,
+ }
+ },
+ }
+ )
+ assert proxy_server.polling_via_cache_enabled == (["openai"] if config_owned else "all")
+ assert proxy_server.native_background_mode == (["config-native"] if config_owned else ["db-native"])
+ assert proxy_server.polling_cache_ttl == (900 if config_owned else 7200)
+ config._apply_litellm_settings_db_values(
+ {
+ "responses": {"background_mode": {"polling_via_cache": False}},
+ }
+ )
+ assert proxy_server.polling_via_cache_enabled == (["openai"] if config_owned else False)
+ assert proxy_server.native_background_mode == (["config-native"] if config_owned else [])
+ assert proxy_server.polling_cache_ttl == (900 if config_owned else 3600)
diff --git a/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py
index 4360655c8ca..ea39d5d59e2 100644
--- a/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py
+++ b/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py
@@ -4488,3 +4488,114 @@ def test_usage_default_range_reports_config_precedence(mock_auth, monkeypatch: p
assert response.status_code == 200, response.text
assert response.json()["values"]["usage_default_time_range"] == "month_to_date"
assert response.json()["source"]["usage_default_time_range"] == "config"
+
+
+@pytest.fixture
+def background_chat_config(monkeypatch: pytest.MonkeyPatch):
+ from copy import deepcopy
+ from unittest.mock import AsyncMock, MagicMock
+
+ from litellm.caching.redis_cache import RedisCache
+ from litellm.proxy import proxy_server
+ from litellm.proxy._types import UserAPIKeyAuth
+ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
+ from litellm.proxy.config_resolvers import SettingsStore
+
+ config = {
+ "litellm_settings": {
+ "callbacks": ["prometheus"],
+ "responses": {
+ "background_mode": {
+ "polling_via_cache": False,
+ "ttl": 7200,
+ "native_background_mode": ["native-model"],
+ },
+ },
+ }
+ }
+ store = SettingsStore("litellm_settings")
+ monkeypatch.setattr(proxy_server.proxy_config, "litellm_settings", store)
+ monkeypatch.setattr(proxy_server.proxy_config, "_settings_stores", {"litellm_settings": store})
+ monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
+ monkeypatch.setattr(proxy_server, "store_model_in_db", True)
+ monkeypatch.setattr(proxy_server, "redis_usage_cache", RedisCache(host="localhost", port=6379))
+ monkeypatch.setattr(proxy_server, "polling_via_cache_enabled", False)
+ monkeypatch.setattr(proxy_server, "native_background_mode", [])
+ monkeypatch.setattr(proxy_server, "polling_cache_ttl", 3600)
+ monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(side_effect=lambda: deepcopy(config)))
+
+ async def save_config(*, new_config):
+ config.clear()
+ config.update(deepcopy(new_config))
+
+ save = AsyncMock(side_effect=save_config)
+ audit = AsyncMock()
+ monkeypatch.setattr(proxy_server.proxy_config, "save_config", save)
+ monkeypatch.setattr(proxy_server, "create_config_audit_log", audit)
+ app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
+ user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN
+ )
+ yield config, save, audit
+ app.dependency_overrides.pop(user_api_key_auth, None)
+
+
+def test_background_chat_settings_persist_preserving_existing_options(background_chat_config, monkeypatch):
+ from litellm.proxy import proxy_server
+
+ config, _, _ = background_chat_config
+ response = client.patch("/update/background_chat_settings", json={"enabled": True})
+ assert response.status_code == 200
+ assert response.json()["enabled"] is True
+ assert proxy_server.polling_via_cache_enabled is False
+ assert config["litellm_settings"]["responses"]["background_mode"] == {
+ "polling_via_cache": "all",
+ "ttl": 7200,
+ "native_background_mode": ["native-model"],
+ }
+ assert config["litellm_settings"]["callbacks"] == ["prometheus"]
+ assert client.get("/get/background_chat_settings").json()["enabled"] is True
+ proxy_server.proxy_config._apply_litellm_settings_db_values(config["litellm_settings"])
+ assert proxy_server.polling_via_cache_enabled == "all"
+ disabled = client.patch("/update/background_chat_settings", json={"enabled": False})
+ assert disabled.status_code == 200
+ assert disabled.json()["enabled"] is False
+ proxy_server.proxy_config._apply_litellm_settings_db_values(config["litellm_settings"])
+ assert proxy_server.polling_via_cache_enabled is False
+
+
+@pytest.mark.parametrize("blocker", ["role", "redis", "config", "persistence"])
+def test_background_chat_settings_reject_unavailable_updates(background_chat_config, monkeypatch, blocker):
+ from litellm.proxy import proxy_server
+ from litellm.proxy._types import UserAPIKeyAuth
+ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
+
+ config, save, _ = background_chat_config
+ if blocker == "role":
+ app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
+ user_id="viewer", user_role=LitellmUserRoles.INTERNAL_USER
+ )
+ elif blocker == "redis":
+ monkeypatch.setattr(proxy_server, "redis_usage_cache", None)
+ assert client.get("/get/background_chat_settings").json()["redis_configured"] is False
+ elif blocker == "config":
+ proxy_server.proxy_config.litellm_settings.load_yaml(config["litellm_settings"])
+ assert client.get("/get/background_chat_settings").json()["managed_by_config"] is True
+ else:
+ monkeypatch.setattr(proxy_server, "store_model_in_db", False)
+ assert client.get("/get/background_chat_settings").json()["can_save"] is False
+ response = client.patch("/update/background_chat_settings", json={"enabled": True})
+ assert response.status_code == (403 if blocker == "role" else 400)
+ save.assert_not_awaited()
+ assert config["litellm_settings"]["responses"]["background_mode"]["polling_via_cache"] is False
+
+
+def test_background_chat_settings_failed_save_does_not_enable_runtime(background_chat_config):
+ from litellm.proxy import proxy_server
+
+ _, save, audit = background_chat_config
+ save.side_effect = RuntimeError("Database write failed")
+ with pytest.raises(RuntimeError, match="Database write failed"):
+ client.patch("/update/background_chat_settings", json={"enabled": True})
+ assert client.get("/get/background_chat_settings").json()["enabled"] is False
+ assert proxy_server.polling_via_cache_enabled is False
+ audit.assert_not_awaited()
diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json
index c14f3e6f036..d4ac60e501b 100644
--- a/ui/litellm-dashboard/eslint-suppressions.json
+++ b/ui/litellm-dashboard/eslint-suppressions.json
@@ -1142,11 +1142,6 @@
"count": 1
}
},
- "src/app/chat/page.tsx": {
- "no-nested-ternary": {
- "count": 1
- }
- },
"src/app/login/LoginPage.tsx": {
"local/no-complex-jsx-arrow": {
"count": 1
@@ -1737,10 +1732,10 @@
"count": 5
},
"no-restricted-syntax": {
- "count": 139
+ "count": 133
},
"prefer-const": {
- "count": 28
+ "count": 26
}
},
"src/components/object_permissions_view.tsx": {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.test.tsx
index a3d75df9606..fa4b48a5a9d 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.test.tsx
@@ -9,7 +9,7 @@ const { getSettings } = vi.hoisted(() => ({
getSettings: vi.fn().mockResolvedValue({ values: { enable_projects_ui: true } }),
}));
-vi.mock("@/components/networking", () => ({ getUiSettings: getSettings, getUISettings: getSettings }));
+vi.mock("@/components/networking", () => ({ getUiSettings: getSettings }));
vi.mock("@/components/leftnav", () => ({
default: ({ enableProjectsUI }: ComponentProps) => (
diff --git a/ui/litellm-dashboard/src/app/chat/page.background.integration.test.tsx b/ui/litellm-dashboard/src/app/chat/page.background.integration.test.tsx
new file mode 100644
index 00000000000..53be87ce30c
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/chat/page.background.integration.test.tsx
@@ -0,0 +1,229 @@
+import { act, fireEvent, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
+import { ChatShellProvider } from "@/contexts/ChatShellContext";
+import type { Conversation } from "@/components/chat/types";
+import ChatConversationPage from "./page";
+
+vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }) }));
+
+const storageKey = "litellm_chat_history_v1:background-user";
+const requests: Record[] = [];
+let supported: boolean;
+let status: "in_progress" | "completed" | "cancelled";
+let retrievalStatus: number;
+let cancellations: number;
+let acceptance: Promise | undefined;
+const saved = (): Conversation[] => JSON.parse(localStorage.getItem(storageKey) ?? "[]");
+const result = () => ({
+ id: "litellm_poll_research",
+ status,
+ usage: status === "completed" ? { input_tokens: 4, output_tokens: 9, total_tokens: 13, cost: "0.125" } : undefined,
+ litellm_response_timing:
+ status === "completed" ? { time_to_first_token_ms: 250, total_latency_ms: 12500 } : undefined,
+ output:
+ status === "completed"
+ ? [
+ {
+ type: "mcp_call",
+ id: "call_inventory",
+ name: "lookup_inventory",
+ arguments: "{}",
+ output: "12 widgets",
+ server_label: "inventory",
+ },
+ { type: "message", content: [{ type: "output_text", text: "There are 12 widgets in stock." }] },
+ ].filter((item) => item.type !== "mcp_call" || Boolean(requests.at(-1)?.tools))
+ : [],
+});
+
+function renderChat(id?: string) {
+ return renderWithProviders(
+
+
+ ,
+ { searchParams: id ? { id } : {} },
+ );
+}
+
+beforeEach(() => {
+ localStorage.clear();
+ testQueryClient.clear();
+ requests.length = 0;
+ cancellations = 0;
+ acceptance = undefined;
+ supported = true;
+ retrievalStatus = 200;
+ status = "in_progress";
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const request =
+ input instanceof Request ? input : new Request(new URL(String(input), window.location.origin), init);
+ const path = new URL(request.url).pathname;
+ if (path.endsWith("/model_group/info"))
+ return Response.json({
+ data: [{ model_group: "research-chat", mode: "chat", supports_background_chat: supported }],
+ });
+ if (path.endsWith("/v1/mcp/server")) return Response.json([{ server_id: "inventory", server_name: "inventory" }]);
+ if (path.endsWith("/mcp-rest/tools/list")) return Response.json({ tools: [{ name: "lookup_inventory" }] });
+ if (path.endsWith("/responses")) {
+ const body = JSON.parse(await request.text());
+ requests.push(body);
+ if (body.background) {
+ await acceptance;
+ request.signal.throwIfAborted();
+ return Response.json({ ...result(), status: "queued" });
+ }
+ return new Response(
+ 'data: {"type":"response.output_text.delta","delta":"Foreground answer"}\n\ndata: {"type":"response.completed","response":{"id":"resp_foreground","output":[]}}\n\n',
+ { headers: { "Content-Type": "text/event-stream" } },
+ );
+ }
+ if (path.endsWith("/litellm_poll_research/cancel")) {
+ cancellations++;
+ status = "cancelled";
+ return Response.json(result());
+ }
+ if (path.endsWith("/litellm_poll_research"))
+ return Response.json(retrievalStatus === 200 ? result() : { error: { message: "Unavailable" } }, {
+ status: retrievalStatus,
+ });
+ return Response.json({}, { status: 404 });
+ }),
+ );
+});
+afterEach(() => vi.unstubAllGlobals());
+
+async function startResearch(withTools = true) {
+ const user = userEvent.setup();
+ await screen.findByRole("button", { name: "research-chat" });
+ if (withTools) {
+ await user.click(screen.getByRole("button", { name: "Connect tools" }));
+ await user.click(await screen.findByRole("switch"));
+ await user.keyboard("{Escape}");
+ }
+ fireEvent.change(screen.getByRole("textbox", { name: "Message", exact: true }), {
+ target: { value: "Check inventory" },
+ });
+ await user.click(screen.getByRole("button", { name: "Send", exact: true }));
+}
+
+it("should retrieve the completed tool run after closing and reopening without submitting it again", async () => {
+ const first = renderChat();
+ await startResearch();
+ await screen.findByText("Running in the background. You can close this tab and return to this chat.");
+ await waitFor(() => expect(saved()[0]?.messages.at(-1)?.backgroundRun?.id).toBe("litellm_poll_research"));
+ const id = saved()[0].id;
+ first.unmount();
+ expect(cancellations).toBe(0);
+ status = "completed";
+ renderChat(id);
+ expect(await screen.findByText("There are 12 widgets in stock.")).toBeVisible();
+ expect(screen.getByRole("button", { name: "lookup_inventory" })).toBeVisible();
+ expect(screen.getByLabelText(/^Cost:/)).toHaveTextContent("$0.125");
+ expect(screen.getByLabelText(/^TTFT:/)).toHaveTextContent("0.25s");
+ expect(screen.getByLabelText(/^Total Latency:/)).toHaveTextContent("12.50s");
+ await waitFor(() =>
+ expect(saved()[0]?.messages.at(-1)).toMatchObject({ timeToFirstToken: 250, totalLatency: 12500 }),
+ );
+ expect(requests).toHaveLength(1);
+ expect(requests[0]).toMatchObject({
+ background: true,
+ stream: false,
+ tools: [{ type: "mcp", server_label: "inventory" }],
+ });
+ expect(screen.queryByRole("button", { name: "Stop request" })).not.toBeInTheDocument();
+ fireEvent.change(screen.getByRole("textbox", { name: "Message", exact: true }), {
+ target: { value: "What was the result?" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Send", exact: true }));
+ await waitFor(() => expect(requests).toHaveLength(2));
+ expect(requests[1]).toMatchObject({ background: true, stream: false });
+ expect(requests[1]).not.toHaveProperty("previous_response_id");
+ expect(requests[1].input).toEqual(
+ expect.arrayContaining([{ type: "function_call_output", call_id: "call_inventory", output: "12 widgets" }]),
+ );
+});
+
+it("should continue an ordinary chat without MCP tools after closing and reopening", async () => {
+ const first = renderChat();
+ await startResearch(false);
+ await screen.findByText("Running in the background. You can close this tab and return to this chat.");
+ await waitFor(() => expect(saved()[0]?.messages.at(-1)?.backgroundRun?.id).toBe("litellm_poll_research"));
+ expect(requests[0]).toMatchObject({ background: true, stream: false });
+ expect(requests[0]).not.toHaveProperty("tools");
+ const id = saved()[0].id;
+ first.unmount();
+ status = "completed";
+ renderChat(id);
+ expect(await screen.findByText("There are 12 widgets in stock.")).toBeVisible();
+ expect(screen.getByLabelText(/^TTFT:/)).toHaveTextContent("0.25s");
+ expect(screen.getByLabelText(/^Total Latency:/)).toHaveTextContent("12.50s");
+ await waitFor(() =>
+ expect(saved()[0]?.messages.at(-1)).toMatchObject({ timeToFirstToken: 250, totalLatency: 12500 }),
+ );
+ expect(requests).toHaveLength(1);
+ expect(cancellations).toBe(0);
+});
+
+it("should cancel a background run only when Stop is clicked", async () => {
+ renderChat();
+ await startResearch();
+ await screen.findByText("Running in the background. You can close this tab and return to this chat.");
+ fireEvent.click(screen.getByRole("button", { name: "Stop request" }));
+ expect(await screen.findByText("Cancellation requested")).toBeVisible();
+ expect(cancellations).toBe(1);
+});
+
+it("should retain ordinary streaming when background support is unavailable", async () => {
+ supported = false;
+ renderChat();
+ await startResearch();
+ expect(await screen.findByText("Foreground answer")).toBeVisible();
+ expect(requests[0]).toMatchObject({ stream: true });
+ expect(requests[0]).not.toHaveProperty("background");
+ expect(screen.getByText("Keep this tab open. Background runs are not available for this chat.")).toBeVisible();
+});
+
+it("should let the user reconnect after an authentication failure without resubmitting work", async () => {
+ const first = renderChat();
+ await startResearch();
+ await waitFor(() => expect(saved()[0]?.messages.at(-1)?.backgroundRun?.id).toBe("litellm_poll_research"));
+ const id = saved()[0].id;
+ first.unmount();
+ retrievalStatus = 401;
+ renderChat(id);
+ await screen.findByRole("button", { name: "Reconnect" });
+ retrievalStatus = 200;
+ status = "completed";
+ fireEvent.click(screen.getByRole("button", { name: "Reconnect" }));
+ expect(await screen.findByText("There are 12 widgets in stock.")).toBeVisible();
+ expect(requests).toHaveLength(1);
+});
+
+it.each([false, true])(
+ "should honor Stop before a background submission returns its ID (tools=%s)",
+ async (withTools) => {
+ let accept!: () => void;
+ acceptance = new Promise((resolve) => {
+ accept = resolve;
+ });
+ renderChat();
+ await startResearch(withTools);
+ await waitFor(() => expect(requests).toHaveLength(1));
+ fireEvent.click(screen.getByRole("button", { name: "Stop request" }));
+ await act(async () => accept());
+ expect(await screen.findByText("Cancellation requested")).toBeVisible();
+ expect(cancellations).toBe(1);
+ expect(requests).toHaveLength(1);
+ await waitFor(() => expect(saved()[0]?.messages.at(-1)?.backgroundRun?.status).toBe("cancelled"));
+ },
+);
diff --git a/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx b/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx
index 6b333750b78..5ea5db56920 100644
--- a/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx
@@ -64,7 +64,7 @@ vi.mock("@/contexts/ChatShellContext", () => ({
},
}));
-const ONE_TURN_ARG_COUNT = 25;
+const ONE_TURN_ARG_COUNT = 28;
const ON_TIMING_DATA_INDEX = 7;
const ON_USAGE_DATA_INDEX = 8;
const ON_TOTAL_LATENCY_INDEX = 24;
diff --git a/ui/litellm-dashboard/src/app/chat/page.tsx b/ui/litellm-dashboard/src/app/chat/page.tsx
index 85fc7133215..757bf010fd9 100644
--- a/ui/litellm-dashboard/src/app/chat/page.tsx
+++ b/ui/litellm-dashboard/src/app/chat/page.tsx
@@ -15,6 +15,7 @@ import type { AssistantMessageUpdate, ChatAttachment, ChatMessage } from "@/comp
import ChatModelPicker, { isChatModel, modelChatMode, type ChatMode } from "@/components/chat/ChatModelPicker";
import MoyaiChatComposer from "@/components/chat/moyai/MoyaiChatComposer";
import MoyaiChatHome from "@/components/chat/moyai/MoyaiChatHome";
+import { useBackgroundChat } from "@/components/chat/useBackgroundChat";
import { runChatRequest } from "@/components/chat/runChatRequest";
import { readChatAttachment, MAX_CHAT_ATTACHMENTS, CHAT_ATTACHMENT_ACCEPT } from "@/components/chat/attachmentHelpers";
import ChatFileChip from "@/components/chat/ChatFileChip";
@@ -102,7 +103,30 @@ export default function ChatConversationPage() {
const responsesSessionGenerationRef = useRef(0);
const previousResponsesConversationRef = useRef(activeConversationId);
const [prevConversationIdForSessionReset, setPrevConversationIdForSessionReset] = useState(activeConversationId);
- const [isStreaming, setIsStreaming] = useState(false);
+ const [isRequestInFlight, setIsStreaming] = useState(false);
+ const [cancelOnAcceptance, setCancelOnAcceptance] = useState(null);
+ const backgroundRequestRef = useRef(false);
+ const lastMessage = activeConversation?.messages.at(-1);
+ const background = useBackgroundChat(
+ activeConversationId,
+ lastMessage?.backgroundRun,
+ accessToken,
+ updateLastAssistantMessage,
+ );
+ const { pending: backgroundPending, cancelling: backgroundCancelling, cancel: cancelBackground } = background;
+ useEffect(() => {
+ if (!backgroundPending || backgroundCancelling || !activeConversationId) return;
+ if (cancelOnAcceptance !== activeConversationId) return;
+ void cancelBackground().then(() => {
+ setCancelOnAcceptance((current) => (current === activeConversationId ? null : current));
+ });
+ }, [activeConversationId, backgroundPending, backgroundCancelling, cancelBackground, cancelOnAcceptance]);
+
+ const isStreaming = isRequestInFlight || background.pending;
+ const supportsBackground = models.some(
+ (model) => model.model_group === selectedModel && model.supports_background_chat,
+ );
+ const useBackground = mode === "chat" && supportsBackground && !storageUnavailable;
const [inputText, setInputText] = useState("");
const [mcpPopoverOpen, setMcpPopoverOpen] = useState(false);
const [storageBannerDismissed, setStorageBannerDismissed] = useState(false);
@@ -264,6 +288,7 @@ export default function ChatConversationPage() {
setAttachmentError(null);
setIsStreaming(true);
+ setCancelOnAcceptance(null);
abortControllerRef.current = new AbortController();
requestConversationRef.current = convId;
@@ -297,12 +322,14 @@ export default function ChatConversationPage() {
signal: requestController.signal,
messages: history,
previousResponseId,
+ background: requestMode === "chat" && useBackground,
mcpServers: selectedMCPServers,
onResponseId: (id: string | null) => {
if (responsesSessionGenerationRef.current === sessionGeneration) setResponsesSessionId(id);
},
onUpdate: (updates: AssistantMessageUpdate) => updateLastAssistantMessage(convId, updates),
};
+ backgroundRequestRef.current = request.background;
try {
await runChatRequest(request);
} finally {
@@ -326,13 +353,22 @@ export default function ChatConversationPage() {
updateLastAssistantMessage,
isStreaming,
responsesSessionId,
+ useBackground,
setConversationIdInUrl,
],
);
- const handleStop = useCallback(() => {
+ const handleStop = () => {
+ if (background.pending) {
+ void background.cancel();
+ return;
+ }
+ if (backgroundRequestRef.current) {
+ setCancelOnAcceptance(activeConversationId);
+ return;
+ }
abortControllerRef.current?.abort();
- }, []);
+ };
const handleEditAndResend = useCallback(
(messageId: string, newContent: string) => {
@@ -403,7 +439,21 @@ export default function ChatConversationPage() {
const showBlankState = !activeConversation || activeConversation.messages.length === 0;
+ const backgroundHint = () => {
+ if (background.cancelling || (isRequestInFlight && cancelOnAcceptance === activeConversationId))
+ return "Requesting cancellation…";
+ if (background.pending && storageUnavailable)
+ return "Keep this tab open. This run could not be saved in this browser.";
+ if (background.pending) return "Running in the background. You can close this tab and return to this chat.";
+ if (useBackground) return null;
+ return "Keep this tab open. Background runs are not available for this chat.";
+ };
+
const inputBar = (inConversation: boolean) => {
+ const hint = mode === "chat" || background.pending ? backgroundHint() : null;
+ const chatPlaceholder = inConversation
+ ? "Send a message..."
+ : "Describe a change, paste an error, or ask a question…";
const preparing = isLoadingModels || isReadingAttachments;
const submitDisabled = !hasMessageContent(inputText, attachments, mode) || !selectedModel || preparing;
return (
@@ -424,13 +474,7 @@ export default function ChatConversationPage() {
onChange={setInputText}
onSubmit={() => void handleSend(inputText)}
onCancel={handleStop}
- placeholder={
- mode === "image"
- ? "Describe the image you want to create…"
- : inConversation
- ? "Send a message..."
- : "Describe a change, paste an error, or ask a question…"
- }
+ placeholder={mode === "image" ? "Describe the image you want to create…" : chatPlaceholder}
isLoading={isStreaming}
submitLabel={mode === "image" ? "Generate image" : "Send"}
submitDisabled={submitDisabled}
@@ -536,6 +580,29 @@ export default function ChatConversationPage() {
: "No chat models are available for your account."}
)}
+ {background.connectionError && (
+
+ {background.connectionError}
+
+
+ )}
+ {lastMessage?.backgroundError && (
+
+ {lastMessage.backgroundError}
+
+ )}
+ {lastMessage?.backgroundRun?.status === "cancelled" && (
+
+ Cancellation requested
+
+ )}
+ {hint && (
+
+ {hint}
+
+ )}
{mode === "image" ? "Describe an image · Enter to generate" : "Drop files or paste images · Enter to send"}
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/BackgroundChatSettings.integration.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/BackgroundChatSettings.integration.test.tsx
new file mode 100644
index 00000000000..311094909ce
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/BackgroundChatSettings.integration.test.tsx
@@ -0,0 +1,98 @@
+import { fireEvent, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
+import BackgroundChatSettings from "./BackgroundChatSettings";
+
+let settings: {
+ enabled: boolean;
+ redis_configured: boolean;
+ managed_by_config: boolean;
+ can_save: boolean;
+};
+let failLoad: boolean;
+let failSave: boolean;
+const updates: unknown[] = [];
+
+beforeEach(() => {
+ testQueryClient.clear();
+ settings = {
+ enabled: false,
+ redis_configured: true,
+ managed_by_config: false,
+ can_save: true,
+ };
+ failLoad = false;
+ failSave = false;
+ updates.length = 0;
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const request =
+ input instanceof Request ? input : new Request(new URL(String(input), window.location.origin), init);
+ if (request.method === "PATCH") {
+ const body = await request.json();
+ updates.push(body);
+ if (failSave) return Response.json({ detail: "Save failed" }, { status: 500 });
+ settings = { ...settings, enabled: body.enabled };
+ } else if (failLoad) return Response.json({ detail: "Unavailable" }, { status: 503 });
+ return Response.json(settings);
+ }),
+ );
+});
+afterEach(() => vi.unstubAllGlobals());
+
+it("saves background execution next to its explanation and retains it after reopening", async () => {
+ const first = renderWithProviders();
+ fireEvent.click(await screen.findByRole("switch", { name: "Continue chats in the background" }));
+ await waitFor(() => expect(screen.getByRole("switch")).toBeChecked());
+ expect(updates).toEqual([{ enabled: true }]);
+ expect(screen.getByRole("switch")).toBeChecked();
+ first.unmount();
+ testQueryClient.clear();
+ renderWithProviders();
+ expect(await screen.findByRole("switch")).toBeChecked();
+ fireEvent.click(screen.getByRole("switch"));
+ await waitFor(() => expect(screen.getByRole("switch")).not.toBeChecked());
+ expect(updates).toEqual([{ enabled: true }, { enabled: false }]);
+});
+
+it("links to Redis setup and prevents enabling without Redis", async () => {
+ settings.redis_configured = false;
+ renderWithProviders();
+ expect(await screen.findByRole("switch")).toHaveAttribute("aria-disabled", "true");
+ expect(screen.getByRole("link", { name: /Configure it in Caching/ })).toHaveAttribute(
+ "href",
+ expect.stringMatching(/\/caching$/),
+ );
+ expect(updates).toHaveLength(0);
+});
+
+it.each(["managed_by_config", "can_save"] as const)(
+ "explains a setting that cannot be edited because of %s",
+ async (field) => {
+ settings[field] = field === "managed_by_config";
+ renderWithProviders();
+ expect(await screen.findByRole("switch")).toHaveAttribute("aria-disabled", "true");
+ const message =
+ field === "managed_by_config" ? /Managed by your config file/ : /Enable database-backed configuration/;
+ expect(screen.getByText(message)).toBeVisible();
+ },
+);
+
+it("keeps a failed save off", async () => {
+ failSave = true;
+ renderWithProviders();
+ fireEvent.click(await screen.findByRole("switch"));
+ await waitFor(() => expect(updates).toHaveLength(1));
+ expect(screen.getByRole("switch")).not.toBeChecked();
+});
+
+it("offers retry for a settings fetch failure instead of claiming Redis is missing", async () => {
+ failLoad = true;
+ renderWithProviders();
+ await screen.findByRole("button", { name: "Retry" });
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
+ failLoad = false;
+ fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+ expect(await screen.findByRole("switch")).not.toHaveAttribute("aria-disabled", "true");
+});
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/BackgroundChatSettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/BackgroundChatSettings.tsx
new file mode 100644
index 00000000000..428edfff86d
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/BackgroundChatSettings.tsx
@@ -0,0 +1,73 @@
+import { $api } from "@/lib/http/api";
+import { toast } from "@/lib/toast";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Switch } from "@/components/ui/switch";
+import { uiHref } from "@/utils/uiHref";
+
+export default function BackgroundChatSettings() {
+ const query = $api.useQuery("get", "/get/background_chat_settings");
+ const update = $api.useMutation("patch", "/update/background_chat_settings", {
+ onSuccess: () => {
+ void query.refetch();
+ toast.success("Background chat settings saved.");
+ },
+ onError: (error) => toast.fromError(error),
+ });
+
+ if (query.isPending) return ;
+ if (query.isError || !query.data)
+ return (
+
+ Could not load background chat settings.
+
+
+ );
+
+ const settings = query.data;
+ const readOnly = settings.managed_by_config || !settings.can_save;
+ const needsRedis = !settings.redis_configured && !settings.enabled;
+ const disabled = update.isPending || readOnly || needsRedis;
+
+ return (
+
+
update.mutate({ body: { enabled } })}
+ aria-label="Continue chats in the background"
+ aria-describedby="background-chat-description"
+ />
+
+
Continue chats in the background
+
+ Keep replies and tool runs going after users close the browser. Return to the same browser to see the result.
+
+ {!settings.redis_configured && (
+
+ Requires Redis.{" "}
+
+ Configure it in Caching → Coordination Redis
+
+ , then restart the proxy.
+
+ )}
+ {settings.managed_by_config && (
+
+ Managed by your config file. Update its background polling setting to change this.
+
+ )}
+ {!settings.can_save && !settings.managed_by_config && (
+
Enable database-backed configuration to save this setting.
+ )}
+ {!readOnly && (
+
+ Changes apply automatically when the proxy refreshes its configuration.
+
+ )}
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx
index 2aa5c738880..c2bfd6076d4 100644
--- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx
@@ -5,6 +5,8 @@ import userEvent from "@testing-library/user-event";
import UISettings from "./UISettings";
import { toast } from "@/lib/toast";
+vi.mock("./BackgroundChatSettings", () => ({ default: () => null }));
+
const mockUseAuthorized = vi.hoisted(() => vi.fn());
const mockUseUISettings = vi.hoisted(() => vi.fn());
const mockUseUpdateUISettings = vi.hoisted(() => vi.fn());
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx
index 3103851f280..08f345af928 100644
--- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx
@@ -16,6 +16,7 @@ import { relativeTimeOptions } from "@/components/shared/date_range_presets";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import PageVisibilitySettings from "./PageVisibilitySettings";
+import BackgroundChatSettings from "./BackgroundChatSettings";
interface SettingRowProps {
ariaLabel: string;
@@ -429,13 +430,15 @@ export default function UISettings() {
disabled={isUpdating}
onCheckedChange={handleToggleEnableChatUI}
ariaLabel={enableChatUIProperty?.description ?? "Enable Chat page"}
- label="[BETA] Enable Chat page (page will refresh)"
+ label="Enable Chat page (page will refresh)"
description={
enableChatUIProperty?.description ??
"If enabled, shows the Chat page in the UI sidebar, letting users chat with an LLM and connect their own MCP server credentials via OAuth."
}
/>
+
+
diff --git a/ui/litellm-dashboard/src/components/chat/ChatShell.tsx b/ui/litellm-dashboard/src/components/chat/ChatShell.tsx
index 280e2d10b8c..03d91bb13bc 100644
--- a/ui/litellm-dashboard/src/components/chat/ChatShell.tsx
+++ b/ui/litellm-dashboard/src/components/chat/ChatShell.tsx
@@ -192,9 +192,6 @@ const ChatShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
))}
-
- Chat is a pre-v0 preview and may change unexpectedly. Do not use in production.
-
diff --git a/ui/litellm-dashboard/src/components/chat/backgroundChat.test.ts b/ui/litellm-dashboard/src/components/chat/backgroundChat.test.ts
new file mode 100644
index 00000000000..fd3d51883ff
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/chat/backgroundChat.test.ts
@@ -0,0 +1,177 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { Response as ModelResponse } from "openai/resources/responses/responses";
+import { backgroundResponseUpdate, cancelBackgroundChat, watchBackgroundChat } from "./backgroundChat";
+
+vi.mock("@/components/networking", () => ({ getGatewayBaseUrl: () => "http://localhost:4000/v1" }));
+
+afterEach(() => vi.unstubAllGlobals());
+
+it("should restore text, reasoning, tool results and usage from a completed response", () => {
+ const response = {
+ id: "litellm_poll_example",
+ status: "completed",
+ output: [
+ { type: "reasoning", summary: [{ type: "summary_text", text: "Check the inventory" }] },
+ {
+ type: "mcp_call",
+ id: "call_lookup",
+ name: "lookup",
+ arguments: "{}",
+ output: "12 widgets",
+ server_label: "inventory",
+ },
+ { type: "message", content: [{ type: "output_text", text: "There are 12 widgets." }] },
+ ],
+ usage: { input_tokens: 4, output_tokens: 9, total_tokens: 13, output_tokens_details: { reasoning_tokens: 2 } },
+ } as ModelResponse;
+ const update = backgroundResponseUpdate(response);
+ expect(update.content).toBe("There are 12 widgets.");
+ expect(update.reasoningContent).toBe("Check the inventory");
+ expect(update.mcpEvents?.[0].item?.output).toBe("12 widgets");
+ const usage = { promptTokens: 4, completionTokens: 9, totalTokens: 13, reasoningTokens: 2 };
+ expect(update.usage).toMatchObject(usage);
+ expect(update.backgroundRun).toEqual({ id: response.id, status: "completed" });
+});
+
+describe("background response lifecycle", () => {
+ it("should detach polling without cancelling the server run", async () => {
+ const controller = new AbortController();
+ const fetch = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
+ Response.json({ id: "litellm_poll_example", status: "in_progress", output: [] }),
+ );
+ vi.stubGlobal("fetch", fetch);
+ const onUpdate = vi.fn(() => controller.abort());
+ const polling = {
+ accessToken: "test-token",
+ responseId: "litellm_poll_example",
+ signal: controller.signal,
+ onUpdate,
+ };
+ await watchBackgroundChat(polling);
+ expect(onUpdate).toHaveBeenCalledOnce();
+ expect(fetch).toHaveBeenCalledOnce();
+ expect(fetch.mock.calls[0][1]?.method).toBe("GET");
+ });
+
+ it("should mark an expired run unavailable without clearing its partial answer", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => Response.json({ error: { message: "Expired" } }, { status: 404 })),
+ );
+ const onUpdate = vi.fn();
+ const polling = {
+ accessToken: "test-token",
+ responseId: "litellm_poll_expired",
+ signal: new AbortController().signal,
+ onUpdate,
+ };
+ await watchBackgroundChat(polling);
+ expect(onUpdate).toHaveBeenCalledWith({
+ backgroundRun: { id: "litellm_poll_expired", status: "expired" },
+ backgroundError: "This saved run is no longer available. Its result may have expired on the server.",
+ });
+ });
+
+ it("should cancel explicitly through the server and preserve returned partial output", async () => {
+ const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const request = input instanceof Request ? input : new Request(input, init);
+ expect(request.method).toBe("POST");
+ expect(new URL(request.url).pathname).toBe("/v1/responses/litellm_poll_example/cancel");
+ return Response.json({
+ id: "litellm_poll_example",
+ status: "cancelled",
+ output: [{ type: "message", content: [{ type: "output_text", text: "Partial answer" }] }],
+ });
+ });
+ vi.stubGlobal("fetch", fetch);
+ expect(await cancelBackgroundChat("test-token", "litellm_poll_example")).toMatchObject({
+ content: "Partial answer",
+ backgroundRun: { id: "litellm_poll_example", status: "cancelled" },
+ });
+ });
+});
+
+it.each([
+ [0.125, 0.125],
+ ["0.125", 0.125],
+ [0, 0],
+ ["invalid", undefined],
+])("should preserve valid returned cost %s", (cost, expected) => {
+ const response = {
+ id: "litellm_poll_cost",
+ status: "completed",
+ output: [],
+ usage: { input_tokens: 4, output_tokens: 9, total_tokens: 13, cost },
+ } as ModelResponse;
+ expect(backgroundResponseUpdate(response).usage?.cost).toBe(expected);
+});
+
+it("should keep polling through unfinished output items until the answer and tools arrive", async () => {
+ let polls = 0;
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => {
+ polls++;
+ return Response.json({
+ id: "litellm_poll_partial",
+ status: polls === 1 ? "in_progress" : "completed",
+ output:
+ polls === 1
+ ? [
+ { type: "message", id: "message_1" },
+ { type: "mcp_list_tools", id: "tools_1", server_label: "inventory" },
+ ]
+ : [
+ { type: "message", content: [{ type: "output_text", text: "12 widgets" }] },
+ { type: "mcp_list_tools", server_label: "inventory", tools: [{ name: "lookup", input_schema: {} }] },
+ ],
+ });
+ }),
+ );
+ const onUpdate = vi.fn();
+ const polling = {
+ accessToken: "test-token",
+ responseId: "litellm_poll_partial",
+ signal: new AbortController().signal,
+ onUpdate,
+ };
+ await watchBackgroundChat(polling);
+ expect(polls).toBe(2);
+ expect(onUpdate.mock.lastCall?.[0]).toMatchObject({
+ content: "12 widgets",
+ backgroundRun: { status: "completed" },
+ mcpEvents: [{ item: { tools: [{ name: "lookup" }] } }],
+ });
+});
+
+it("should restore server timings from polling without counting time away from the chat", async () => {
+ const response = {
+ id: "litellm_poll_timing",
+ status: "completed",
+ created_at: 1000,
+ output: [],
+ litellm_response_timing: { time_to_first_token_ms: 250, total_latency_ms: 12500 },
+ };
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => Response.json(response)),
+ );
+ const onUpdate = vi.fn();
+ const polling = {
+ accessToken: "test-token",
+ responseId: "litellm_poll_timing",
+ signal: new AbortController().signal,
+ onUpdate,
+ };
+ await watchBackgroundChat(polling);
+ expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({ timeToFirstToken: 250, totalLatency: 12500 }));
+});
+
+it.each(["queued", "completed"] as const)(
+ "should not invent timings for a %s response without measurements",
+ (status) => {
+ const update = backgroundResponseUpdate({ id: "litellm_poll_legacy", status, output: [] } as ModelResponse);
+ expect(update).not.toHaveProperty("timeToFirstToken");
+ expect(update).not.toHaveProperty("totalLatency");
+ },
+);
diff --git a/ui/litellm-dashboard/src/components/chat/backgroundChat.ts b/ui/litellm-dashboard/src/components/chat/backgroundChat.ts
new file mode 100644
index 00000000000..177d25f2e16
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/chat/backgroundChat.ts
@@ -0,0 +1,138 @@
+import OpenAI from "openai";
+import type { Response } from "openai/resources/responses/responses";
+import { getGatewayBaseUrl } from "@/components/networking";
+import { parseUsageCost } from "@/components/llm_calls/usage_cost";
+import { extractPromptCacheTokens } from "@/utils/promptCacheUsage";
+import type { AssistantMessageUpdate, ChatBackgroundRun, MCPEvent } from "./types";
+
+export const isBackgroundRunPending = (run?: ChatBackgroundRun): boolean =>
+ run?.status === "queued" || run?.status === "in_progress";
+
+type TimedResponse = Response & {
+ litellm_response_timing?: {
+ time_to_first_token_ms?: number;
+ total_latency_ms?: number;
+ };
+};
+
+export function backgroundResponseUpdate(response: TimedResponse): AssistantMessageUpdate {
+ const output = response.output ?? [];
+ const content = output
+ .filter((item) => item.type === "message")
+ .flatMap((item) => item.content ?? [])
+ .map((part) => (part.type === "output_text" ? part.text : part.refusal))
+ .join("");
+ const reasoningContent = output
+ .filter((item) => item.type === "reasoning")
+ .flatMap((item) => item.summary ?? [])
+ .map((part) => part.text)
+ .join("\n\n");
+ const mcpEvents: MCPEvent[] = output.flatMap
((item) => {
+ if (item.type === "mcp_call") {
+ return [{ type: "response.output_item.done", item: { ...item, output: item.output ?? undefined } }];
+ }
+ if (item.type === "mcp_list_tools") {
+ return [
+ {
+ type: "response.output_item.done",
+ item: {
+ ...item,
+ tools: (item.tools ?? []).map((tool) => ({
+ name: tool.name,
+ input_schema: tool.input_schema,
+ description: tool.description ?? "",
+ })),
+ },
+ },
+ ];
+ }
+ return [];
+ });
+ const status = response.status ?? "in_progress";
+ const errors: Partial> = {
+ failed: response.error?.message || "The background run failed.",
+ incomplete: "The run ended before completing its answer.",
+ };
+ const backgroundError = errors[status];
+ const cost = response.usage && "cost" in response.usage ? parseUsageCost(response.usage.cost) : undefined;
+ const timing = response.litellm_response_timing;
+ return {
+ content,
+ reasoningContent,
+ mcpEvents,
+ backgroundRun: { id: response.id, status },
+ backgroundError,
+ ...(timing?.time_to_first_token_ms !== undefined && { timeToFirstToken: timing.time_to_first_token_ms }),
+ ...(timing?.total_latency_ms !== undefined && { totalLatency: timing.total_latency_ms }),
+ ...(response.usage && {
+ usage: {
+ promptTokens: response.usage.input_tokens,
+ completionTokens: response.usage.output_tokens,
+ totalTokens: response.usage.total_tokens,
+ reasoningTokens: response.usage.output_tokens_details?.reasoning_tokens,
+ ...extractPromptCacheTokens(response.usage),
+ ...(cost !== undefined ? { cost } : {}),
+ },
+ }),
+ };
+}
+
+const backgroundClient = (accessToken: string) =>
+ new OpenAI({
+ apiKey: accessToken,
+ baseURL: getGatewayBaseUrl(),
+ dangerouslyAllowBrowser: true,
+ });
+
+function waitForPoll(signal: AbortSignal): Promise {
+ return new Promise((resolve, reject) => {
+ const abort = () => {
+ clearTimeout(timer);
+ reject(signal.reason);
+ };
+ const timer = setTimeout(() => {
+ signal.removeEventListener("abort", abort);
+ resolve();
+ }, 1000);
+ signal.addEventListener("abort", abort, { once: true });
+ if (signal.aborted) abort();
+ });
+}
+
+export async function watchBackgroundChat({
+ accessToken,
+ responseId,
+ signal,
+ onUpdate,
+}: {
+ accessToken: string;
+ responseId: string;
+ signal: AbortSignal;
+ onUpdate: (update: AssistantMessageUpdate) => void;
+}): Promise {
+ const client = backgroundClient(accessToken);
+ while (!signal.aborted) {
+ try {
+ const response = await client.responses.retrieve(responseId, { stream: false }, { signal });
+ if (signal.aborted) return;
+ onUpdate(backgroundResponseUpdate(response));
+ if (!isBackgroundRunPending({ id: response.id, status: response.status ?? "in_progress" })) return;
+ await waitForPoll(signal);
+ } catch (error) {
+ if (signal.aborted) return;
+ if (error instanceof OpenAI.APIError && error.status === 404) {
+ onUpdate({
+ backgroundRun: { id: responseId, status: "expired" },
+ backgroundError: "This saved run is no longer available. Its result may have expired on the server.",
+ });
+ return;
+ }
+ throw error;
+ }
+ }
+}
+
+export async function cancelBackgroundChat(accessToken: string, responseId: string): Promise {
+ const response = await backgroundClient(accessToken).responses.cancel(responseId);
+ return backgroundResponseUpdate(response);
+}
diff --git a/ui/litellm-dashboard/src/components/chat/runChatRequest.ts b/ui/litellm-dashboard/src/components/chat/runChatRequest.ts
index 157363d2461..9e8016b608e 100644
--- a/ui/litellm-dashboard/src/components/chat/runChatRequest.ts
+++ b/ui/litellm-dashboard/src/components/chat/runChatRequest.ts
@@ -1,5 +1,6 @@
import OpenAI from "openai";
import { makeOpenAIResponsesRequest } from "@/components/llm_calls/responses_api";
+import { backgroundResponseUpdate } from "./backgroundChat";
import { generateChatImages } from "./imageHelpers";
import { toResponsesMessages } from "./attachmentHelpers";
import type { AssistantMessageUpdate, ChatMessage, MCPEvent } from "./types";
@@ -15,6 +16,7 @@ interface ChatRequestOptions {
"role" | "content" | "attachments" | "generatedImages" | "imageGeneration" | "mcpEvents"
>[];
previousResponseId?: string | null;
+ background?: boolean;
mcpServers?: string[];
onResponseId: (id: string | null) => void;
onUpdate: (update: AssistantMessageUpdate) => void;
@@ -43,6 +45,7 @@ export async function runChatRequest({
signal,
messages,
previousResponseId,
+ background = false,
mcpServers,
onResponseId,
onUpdate,
@@ -94,6 +97,14 @@ export async function runChatRequest({
undefined,
true,
(totalLatency) => onUpdate({ totalLatency }),
+ undefined,
+ undefined,
+ background
+ ? (response) => {
+ onResponseId(null);
+ onUpdate(backgroundResponseUpdate(response));
+ }
+ : undefined,
);
signal.throwIfAborted();
} catch (error: unknown) {
diff --git a/ui/litellm-dashboard/src/components/chat/types.ts b/ui/litellm-dashboard/src/components/chat/types.ts
index 25b232e40c4..e98b61e2fe4 100644
--- a/ui/litellm-dashboard/src/components/chat/types.ts
+++ b/ui/litellm-dashboard/src/components/chat/types.ts
@@ -25,6 +25,11 @@ export interface ChatImageGeneration {
prompt: string;
}
+export interface ChatBackgroundRun {
+ id: string;
+ status: "queued" | "in_progress" | "completed" | "failed" | "cancelled" | "incomplete" | "expired";
+}
+
export interface ChatMessage {
id: string;
role: "user" | "assistant" | "tool";
@@ -33,6 +38,8 @@ export interface ChatMessage {
generatedImages?: ChatImageAttachment[];
imageGeneration?: ChatImageGeneration;
reasoningContent?: string;
+ backgroundRun?: ChatBackgroundRun;
+ backgroundError?: string;
/** MCP tool events that occurred during this assistant turn, in order. */
mcpEvents?: MCPEvent[];
toolName?: string;
@@ -55,6 +62,8 @@ export type AssistantMessageUpdate = Partial<
| "usage"
| "generatedImages"
| "imageGeneration"
+ | "backgroundRun"
+ | "backgroundError"
>
>;
diff --git a/ui/litellm-dashboard/src/components/chat/useBackgroundChat.ts b/ui/litellm-dashboard/src/components/chat/useBackgroundChat.ts
new file mode 100644
index 00000000000..c0d1c43bcde
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/chat/useBackgroundChat.ts
@@ -0,0 +1,62 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { cancelBackgroundChat, isBackgroundRunPending, watchBackgroundChat } from "./backgroundChat";
+import type { AssistantMessageUpdate, ChatBackgroundRun } from "./types";
+
+export function useBackgroundChat(
+ conversationId: string | null,
+ run: ChatBackgroundRun | undefined,
+ accessToken: string,
+ updateMessage: (conversationId: string, update: AssistantMessageUpdate) => void,
+) {
+ const [failure, setFailure] = useState<{ responseId: string; retry: number; message: string } | null>(null);
+ const [retry, setRetry] = useState(0);
+ const [cancellingId, setCancellingId] = useState(null);
+ const controllerRef = useRef(null);
+ const pending = isBackgroundRunPending(run);
+ const responseId = run?.id;
+ const cancelling = Boolean(responseId && cancellingId === responseId);
+ const connectionError =
+ pending && failure?.responseId === responseId && failure?.retry === retry ? failure.message : null;
+
+ useEffect(() => {
+ if (!conversationId || !responseId || !pending) return;
+ const controller = new AbortController();
+ controllerRef.current = controller;
+ const polling = {
+ accessToken,
+ responseId,
+ signal: controller.signal,
+ onUpdate: (update: AssistantMessageUpdate) => updateMessage(conversationId, update),
+ };
+ void watchBackgroundChat(polling).catch(() => {
+ if (!controller.signal.aborted)
+ setFailure({ responseId, retry, message: "Connection lost. Your run may still be running on the server." });
+ });
+ return () => {
+ controller.abort();
+ if (controllerRef.current === controller) controllerRef.current = null;
+ };
+ }, [conversationId, responseId, pending, accessToken, updateMessage, retry]);
+
+ const reconnect = useCallback(() => setRetry((value) => value + 1), []);
+
+ const cancel = useCallback(async () => {
+ if (!conversationId || !responseId || cancelling) return;
+ const controller = controllerRef.current;
+ controller?.abort();
+ setCancellingId(responseId);
+ try {
+ const update = await cancelBackgroundChat(accessToken, responseId);
+ updateMessage(conversationId, update);
+ if (controllerRef.current === controller) reconnect();
+ } catch {
+ if (controllerRef.current === controller) {
+ setFailure({ responseId, retry, message: "Could not stop the run. Reconnect and try Stop again." });
+ }
+ } finally {
+ setCancellingId(null);
+ }
+ }, [conversationId, responseId, accessToken, cancelling, reconnect, retry, updateMessage]);
+
+ return { pending, connectionError, cancelling, reconnect, cancel };
+}
diff --git a/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx b/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx
index d38db631c6e..cac91cf92d4 100644
--- a/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx
+++ b/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx
@@ -9,6 +9,7 @@ export interface ModelGroup {
mode?: string;
supports_reasoning?: boolean;
supports_fast_mode?: boolean;
+ supports_background_chat?: boolean;
supported_reasoning_efforts?: string[] | null;
}
@@ -20,6 +21,7 @@ interface AvailableModel {
mode?: string | null;
supports_reasoning?: boolean | null;
supports_fast_mode?: boolean | null;
+ supports_background_chat?: boolean | null;
supported_reasoning_efforts?: string[] | null;
}
@@ -30,6 +32,7 @@ const toModelGroup = (item: AvailableModel): ModelGroup => {
...(item.providers && { providers: item.providers }),
...(item.mode && { mode: item.mode }),
...(item.supports_reasoning === true && { supports_reasoning: true }),
+ ...(item.supports_background_chat === true && { supports_background_chat: true }),
...(item.supports_fast_mode === true && { supports_fast_mode: true }),
...(item.supported_reasoning_efforts !== undefined && {
supported_reasoning_efforts: item.supported_reasoning_efforts,
diff --git a/ui/litellm-dashboard/src/components/llm_calls/responses_api.tsx b/ui/litellm-dashboard/src/components/llm_calls/responses_api.tsx
index ce1a8aab0c5..ef737c22cdb 100644
--- a/ui/litellm-dashboard/src/components/llm_calls/responses_api.tsx
+++ b/ui/litellm-dashboard/src/components/llm_calls/responses_api.tsx
@@ -1,6 +1,10 @@
import { observeResponseHeaders, type ResponseHeadersListener } from "@/lib/http/observeResponseHeaders";
import openai from "openai";
-import type { ResponseFunctionToolCall, ResponseInputItem } from "openai/resources/responses/responses";
+import type {
+ Response as OpenAIResponse,
+ ResponseFunctionToolCall,
+ ResponseInputItem,
+} from "openai/resources/responses/responses";
import { MessageType } from "../chat_ui/types";
import { TokenUsage } from "../chat_ui/ResponseMetrics";
import { getGatewayBaseUrl, getProxyBaseUrl } from "@/components/networking";
@@ -110,6 +114,7 @@ export async function makeOpenAIResponsesRequest(
onTotalLatency?: (latency: number) => void,
customHeaders?: CustomHeaders,
onResponseHeaders?: ResponseHeadersListener,
+ onBackgroundResponse?: (response: OpenAIResponse) => void,
) {
if (!accessToken) {
throw new Error("Virtual Key is required");
@@ -239,6 +244,16 @@ export async function makeOpenAIResponsesRequest(
...(tools.length > 0 ? { tools, tool_choice: "auto" } : {}),
};
+ if (onBackgroundResponse) {
+ const response = await client.post("/responses", {
+ body: { ...requestBody, background: true, stream: false },
+ signal,
+ maxRetries: 0,
+ });
+ onBackgroundResponse(response);
+ return response;
+ }
+
// Create request to OpenAI responses API
// Use 'any' type to avoid TypeScript issues with the experimental API
const response = streamingEnabled
diff --git a/ui/litellm-dashboard/src/components/model_info_view.integration.test.tsx b/ui/litellm-dashboard/src/components/model_info_view.integration.test.tsx
index f93e01eb583..32e590017b0 100644
--- a/ui/litellm-dashboard/src/components/model_info_view.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/model_info_view.integration.test.tsx
@@ -19,7 +19,6 @@ vi.mock("../../utils/dataUtils", () => ({
}));
vi.mock("./networking", () => ({
- modelInfoV1Call: vi.fn(),
credentialGetCall: vi.fn(),
credentialListCall: vi.fn(),
getGuardrailsList: vi.fn(),
@@ -56,7 +55,6 @@ vi.mock("@/app/(dashboard)/hooks/uiSettings/usePtuCostAttributionEnabled", () =>
}));
const mockToast = vi.mocked(toast);
-const mockModelInfoV1Call = vi.mocked(networking.modelInfoV1Call);
const mockCredentialGetCall = vi.mocked(networking.credentialGetCall);
const mockCredentialListCall = vi.mocked(networking.credentialListCall);
const mockGetGuardrailsList = vi.mocked(networking.getGuardrailsList);
@@ -134,10 +132,6 @@ describe("ModelInfoView", () => {
error: null,
});
- mockModelInfoV1Call.mockResolvedValue({
- data: [defaultModelData],
- });
-
mockCredentialGetCall.mockResolvedValue({
credential_name: "test-credential",
credential_values: {},
@@ -394,7 +388,6 @@ describe("ModelInfoView", () => {
model_info: { ...defaultModelData.model_info, mode: "chat", key: "anthropic.claude-haiku-4-5" },
};
mockUseModelsInfo.mockReturnValue({ data: { data: [displayedModel] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [displayedModel] });
render(, { wrapper });
await waitFor(() => {
@@ -869,7 +862,6 @@ describe("ModelInfoView", () => {
const renderWithPtuModel = () => {
mockUseModelsInfo.mockReturnValue({ data: { data: [ptuModelData] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [ptuModelData] });
return render(, { wrapper });
};
@@ -967,7 +959,6 @@ describe("ModelInfoView", () => {
model_info: { ...defaultModelData.model_info, team_id: "team-1", input_cost_per_token: 0.0000003 },
};
mockUseModelsInfo.mockReturnValue({ data: { data: [seededModel] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [seededModel] });
const user = userEvent.setup();
render(, { wrapper });
@@ -1008,7 +999,6 @@ describe("ModelInfoView", () => {
model_info: { ...ptuModelData.model_info, cost_per_ptu_per_hour: null, ptu_effective_from: null },
};
mockUseModelsInfo.mockReturnValue({ data: { data: [halfSetPtuModel] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [halfSetPtuModel] });
const user = userEvent.setup();
render(, { wrapper });
@@ -1057,7 +1047,6 @@ describe("ModelInfoView", () => {
model_info: { ...ptuModelData.model_info, ptu_effective_from: null, ptu_effective_to: null },
};
mockUseModelsInfo.mockReturnValue({ data: { data: [undatedPtuModel] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [undatedPtuModel] });
const user = userEvent.setup();
render(, { wrapper });
@@ -1201,7 +1190,6 @@ describe("ModelInfoView", () => {
isLoading: false,
error: null,
});
- mockModelInfoV1Call.mockResolvedValue({ data: [maskedModelData] });
const user = userEvent.setup();
render(, { wrapper });
@@ -1508,7 +1496,6 @@ describe("ModelInfoView", () => {
beforeEach(() => {
mockUseModelsInfo.mockReturnValue({ data: { data: [teamModel] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [teamModel] });
});
const readRawJson = async (user: ReturnType) => {
@@ -1555,7 +1542,6 @@ describe("ModelInfoView", () => {
it("shows Not Set and no team_alias for a model without a team", async () => {
mockUseModelsInfo.mockReturnValue({ data: { data: [defaultModelData] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [defaultModelData] });
mockUseTeams.mockReturnValue({
data: [{ team_id: "team-1", team_alias: "alpha" }],
isLoading: false,
@@ -1849,7 +1835,6 @@ describe("ModelInfoView", () => {
beforeEach(() => {
mockUseModelsInfo.mockReturnValue({ data: { data: [withCacheWriteTiers] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [withCacheWriteTiers] });
});
it("shows both configured cache write rates per million tokens", async () => {
@@ -1926,7 +1911,6 @@ describe("ModelInfoView", () => {
model_info: { ...defaultModelData.model_info, team_id: "team-1" },
};
mockUseModelsInfo.mockReturnValue({ data: { data: [teamModel] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [teamModel] });
const user = userEvent.setup();
await enterEditMode(user);
@@ -1952,7 +1936,6 @@ describe("ModelInfoView", () => {
model_info: { ...defaultModelData.model_info, team_id: "team-1" },
};
mockUseModelsInfo.mockReturnValue({ data: { data: [teamModel] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [teamModel] });
const user = userEvent.setup();
await enterEditMode(user);
@@ -1992,7 +1975,6 @@ describe("ModelInfoView", () => {
model_info: { ...defaultModelData.model_info, team_id: "team-1" },
};
mockUseModelsInfo.mockReturnValue({ data: { data: [teamModel] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [teamModel] });
const user = userEvent.setup();
render(, { wrapper });
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
@@ -2069,7 +2051,6 @@ describe("ModelInfoView", () => {
litellm_params: litellmParamsWithoutCredential,
};
mockUseModelsInfo.mockReturnValue({ data: { data: [modelWithoutCredential] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [modelWithoutCredential] });
const user = userEvent.setup();
await enterEditMode(user);
@@ -2150,7 +2131,6 @@ describe("ModelInfoView", () => {
litellm_params: { ...defaultModelData.litellm_params, model: "openai/gpt-4*" },
};
mockUseModelsInfo.mockReturnValue({ data: { data: [wildcard] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [wildcard] });
mockUseModelHub.mockReturnValue({
data: { data: [{ model_group: "openai/gpt-4o", providers: ["openai"] }] },
isLoading: false,
@@ -2207,7 +2187,6 @@ describe("ModelInfoView", () => {
litellm_params: { ...defaultModelData.litellm_params, cache_control_injection_points: points },
};
mockUseModelsInfo.mockReturnValue({ data: { data: [data] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [data] });
};
it("omits the key when the deployment has none and the operator leaves the toggle alone", async () => {
@@ -2330,7 +2309,6 @@ describe("ModelInfoView", () => {
litellm_params: { ...defaultModelData.litellm_params, cache_read_input_token_cost: 0.000001 },
};
mockUseModelsInfo.mockReturnValue({ data: { data: [withCacheRead] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [withCacheRead] });
const user = userEvent.setup();
await enterEditMode(user);
setInputCost("2");
@@ -2365,7 +2343,6 @@ describe("ModelInfoView", () => {
},
};
mockUseModelsInfo.mockReturnValue({ data: { data: [resolved] }, isLoading: false, error: null });
- mockModelInfoV1Call.mockResolvedValue({ data: [resolved] });
const user = userEvent.setup();
await enterEditMode(user);
setInputCost("5");
diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx
index c13052be245..994c2948346 100644
--- a/ui/litellm-dashboard/src/components/networking.tsx
+++ b/ui/litellm-dashboard/src/components/networking.tsx
@@ -48,7 +48,7 @@ export const getAutoRouterClassifierDefaultPromptCall = async (
}
};
-export type AssembledPromptTierSource =
+type AssembledPromptTierSource =
| { tierDefinitions: { name: string; description?: string }[] }
| { tierLabels?: Record; classificationRubric?: string };
@@ -127,16 +127,6 @@ import type {
SubscriptionUsageResponse,
} from "@/app/(dashboard)/usage/_components/components/SubscriptionCost/types";
import type { RoutingDecision } from "./logs/detail/RoutingDecisionCard";
-import type {
- SpanDetail,
- SpanErrorPage,
- SpanErrorQuery,
- SpanQuery,
- Trace,
- TraceDetailQuery,
- TraceListQuery,
- TracePage,
-} from "@litellm/lens-ui";
import {
createApiClient,
deriveErrorMessage,
@@ -541,7 +531,7 @@ export const apiClient = createApiClient({
onError: handleError,
});
-export const gatewayApiClient = createApiClient({
+const gatewayApiClient = createApiClient({
getBaseUrl: getGatewayBaseUrl,
getAuthHeaderName: getGlobalLitellmHeaderName,
onError: handleError,
@@ -1725,37 +1715,6 @@ export const modelInfoCall = async (
}
};
-export const modelInfoV1Call = async (accessToken: string, modelId: string) => {
- /**
- * Get all models on proxy
- */
- try {
- let url = proxyBaseUrl ? `${proxyBaseUrl}/v1/model/info` : `/v1/model/info`;
- url += `?litellm_model_id=${modelId}`;
-
- const response = await fetch(url, {
- method: "GET",
- headers: {
- [globalLitellmHeaderName]: `Bearer ${accessToken}`,
- "Content-Type": "application/json",
- },
- });
-
- if (!response.ok) {
- const errorData = await response.json();
- const errorMessage = deriveErrorMessage(errorData);
- handleError(errorMessage);
- throw new Error(errorMessage);
- }
-
- const data = await response.json();
- return data;
- } catch (error) {
- console.error("Failed to create key:", error);
- throw error;
- }
-};
-
export const modelHubPublicModelsCall = async () => {
const url = proxyBaseUrl ? `${proxyBaseUrl}/public/model_hub` : `/public/model_hub`;
const response = await fetch(url, {
@@ -1906,66 +1865,6 @@ export const modelAvailableCall = async (
}
};
-export const teamSpendLogsCall = async (accessToken: string) => {
- try {
- const data = await apiClient.get(`/global/spend/teams`, { accessToken });
- return data;
- } catch (error) {
- console.error("Failed to create key:", error);
- throw error;
- }
-};
-
-export const tagsSpendLogsCall = async (
- accessToken: string,
- startTime: string | undefined,
- endTime: string | undefined,
- tags: string[] | undefined,
-) => {
- try {
- let url = proxyBaseUrl ? `${proxyBaseUrl}/global/spend/tags` : `/global/spend/tags`;
-
- if (startTime && endTime) {
- url = `${url}?start_date=${startTime}&end_date=${endTime}`;
- }
-
- // if tags, convert the list to a comma separated string
- if (tags) {
- url += `&tags=${tags.join(",")}`;
- }
-
- const response = await fetch(`${url}`, {
- method: "GET",
- headers: {
- [globalLitellmHeaderName]: `Bearer ${accessToken}`,
- "Content-Type": "application/json",
- },
- });
- if (!response.ok) {
- const errorData = await response.json();
- const errorMessage = deriveErrorMessage(errorData);
- handleError(errorMessage);
- throw new Error(errorMessage);
- }
-
- const data = await response.json();
- return data;
- } catch (error) {
- console.error("Failed to create key:", error);
- throw error;
- }
-};
-
-export const allTagNamesCall = async (accessToken: string) => {
- try {
- const data = await apiClient.get(`/global/spend/all_tag_names`, { accessToken });
- return data;
- } catch (error) {
- console.error("Failed to create key:", error);
- throw error;
- }
-};
-
export const userFilterUICall = async (accessToken: string, params: URLSearchParams) => {
try {
return await apiClient.get(`/user/filter/ui`, {
@@ -2081,214 +1980,6 @@ export const uiSpendLogsCall = async ({
}
};
-/**
- * Agent tracing. All three respond 501 `{detail}` when `general_settings.tracing` is not
- * configured; callers can detect that through the thrown `ApiError`'s `status`.
- */
-export const agentTraceListCall = async ({
- accessToken,
- startMs,
- endMs,
- cursor,
-}: {
- accessToken: string;
- startMs: number;
- endMs: number;
- cursor?: string | null;
-}): Promise => {
- const query = { start_ms: startMs, end_ms: endMs, cursor: cursor ?? undefined } satisfies TraceListQuery;
- return apiClient.get(`/v1/traces`, { accessToken, query });
-};
-
-export const sendOtlpTraceCall = async (accessToken: string, exportRequest: object): Promise =>
- apiClient.post(`/v1/traces`, { accessToken, body: exportRequest });
-
-export const agentTraceCall = async (
- accessToken: string,
- traceId: string,
- traceRef?: string,
- cursor?: string | null,
-): Promise =>
- apiClient.get(`/v1/traces/${encodeURIComponent(traceId)}`, {
- accessToken,
- query: { trace_ref: traceRef || undefined, cursor: cursor ?? undefined, page_size: 200 } satisfies TraceDetailQuery,
- });
-
-export const agentTraceSpanCall = async (
- accessToken: string,
- traceId: string,
- spanId: string,
- traceRef?: string,
-): Promise =>
- apiClient.get(`/v1/traces/${encodeURIComponent(traceId)}/spans/${encodeURIComponent(spanId)}`, {
- accessToken,
- query: { trace_ref: traceRef || undefined } satisfies SpanQuery,
- });
-
-export const agentTraceSpanErrorCall = async (
- accessToken: string,
- traceId: string,
- spanId: string,
- options: { traceRef?: string; cursor?: string | null },
-): Promise =>
- apiClient.get(`/v1/traces/${encodeURIComponent(traceId)}/spans/${encodeURIComponent(spanId)}/error`, {
- accessToken,
- query: { trace_ref: options.traceRef || undefined, cursor: options.cursor || undefined } satisfies SpanErrorQuery,
- });
-
-export const adminSpendLogsCall = async (accessToken: string) => {
- try {
- const data = await apiClient.get(`/global/spend/logs`, { accessToken });
- return data;
- } catch (error) {
- console.error("Failed to create key:", error);
- throw error;
- }
-};
-
-export const adminTopKeysCall = async (accessToken: string) => {
- try {
- let url = proxyBaseUrl ? `${proxyBaseUrl}/global/spend/keys?limit=5` : `/global/spend/keys?limit=5`;
-
- const response = await fetch(url, {
- method: "GET",
- headers: {
- [globalLitellmHeaderName]: `Bearer ${accessToken}`,
- "Content-Type": "application/json",
- },
- });
- if (!response.ok) {
- const errorData = await response.json();
- const errorMessage = deriveErrorMessage(errorData);
- handleError(errorMessage);
- throw new Error(errorMessage);
- }
-
- const data = await response.json();
- return data;
- } catch (error) {
- console.error("Failed to create key:", error);
- throw error;
- }
-};
-
-export const adminTopEndUsersCall = async (
- accessToken: string,
- keyToken: string | null,
- startTime: string | undefined,
- endTime: string | undefined,
-) => {
- try {
- const body = keyToken
- ? { api_key: keyToken, startTime: startTime, endTime: endTime }
- : { startTime: startTime, endTime: endTime };
-
- const data = await apiClient.post(`/global/spend/end_users`, { accessToken, body });
- return data;
- } catch (error) {
- console.error("Failed to create key:", error);
- throw error;
- }
-};
-
-export const adminspendByProvider = async (
- accessToken: string,
- startTime: string | undefined,
- endTime: string | undefined,
-) => {
- try {
- const data = await apiClient.get(`/global/spend/provider`, {
- accessToken,
- query: {
- ...(startTime && endTime ? { start_date: startTime, end_date: endTime } : {}),
- },
- });
- return data;
- } catch (error) {
- console.error("Failed to fetch spend data:", error);
- throw error;
- }
-};
-
-export const adminGlobalActivity = async (
- accessToken: string,
- startTime: string | undefined,
- endTime: string | undefined,
-) => {
- try {
- const data = await apiClient.get(`/global/activity`, {
- accessToken,
- query: startTime && endTime ? { start_date: startTime, end_date: endTime } : undefined,
- });
- return data;
- } catch (error) {
- console.error("Failed to fetch spend data:", error);
- throw error;
- }
-};
-
-export const adminGlobalActivityPerModel = async (
- accessToken: string,
- startTime: string | undefined,
- endTime: string | undefined,
-) => {
- try {
- let url = proxyBaseUrl ? `${proxyBaseUrl}/global/activity/model` : `/global/activity/model`;
-
- if (startTime && endTime) {
- url += `?start_date=${startTime}&end_date=${endTime}`;
- }
-
- const requestOptions = {
- method: "GET",
- headers: {
- [globalLitellmHeaderName]: `Bearer ${accessToken}`,
- },
- };
-
- const response = await fetch(url, requestOptions);
-
- if (!response.ok) {
- const errorData = await response.json();
- const errorMessage = deriveErrorMessage(errorData);
- handleError(errorMessage);
- throw new Error(errorMessage);
- }
-
- const data = await response.json();
- return data;
- } catch (error) {
- console.error("Failed to fetch spend data:", error);
- throw error;
- }
-};
-
-export const adminTopModelsCall = async (accessToken: string) => {
- try {
- let url = proxyBaseUrl ? `${proxyBaseUrl}/global/spend/models?limit=5` : `/global/spend/models?limit=5`;
-
- const response = await fetch(url, {
- method: "GET",
- headers: {
- [globalLitellmHeaderName]: `Bearer ${accessToken}`,
- "Content-Type": "application/json",
- },
- });
- if (!response.ok) {
- const errorData = await response.json();
- const errorMessage = deriveErrorMessage(errorData);
- handleError(errorMessage);
- throw new Error(errorMessage);
- }
-
- const data = await response.json();
- return data;
- } catch (error) {
- console.error("Failed to create key:", error);
- throw error;
- }
-};
-
export const testConnectionRequest = async (
accessToken: string,
litellm_params: Record,
@@ -2421,7 +2112,7 @@ export const testAutoRouterRouting = async (
}
};
-export interface ComplexityRouterConfigValidation {
+interface ComplexityRouterConfigValidation {
valid: boolean;
error?: string | null;
}
@@ -2733,7 +2424,7 @@ export interface UserConnectionStartResponse {
flow_handle: string;
}
-export type UserConnectionPollStatus = "pending" | "slow_down" | "expired" | "denied" | "no_copilot_seat" | "connected";
+type UserConnectionPollStatus = "pending" | "slow_down" | "expired" | "denied" | "no_copilot_seat" | "connected";
export interface UserConnectionPollResponse {
status: UserConnectionPollStatus;
@@ -3697,35 +3388,6 @@ export const getProxyUISettings = async (accessToken: string) => {
}
};
-export const getUISettings = async (accessToken: string) => {
- /**
- * Get UI-specific configuration flags from the database
- */
- try {
- const url = proxyBaseUrl ? `${proxyBaseUrl}/get/ui_settings` : `/get/ui_settings`;
- const response = await fetch(url, {
- method: "GET",
- headers: {
- [globalLitellmHeaderName]: `Bearer ${accessToken}`,
- "Content-Type": "application/json",
- },
- });
-
- if (!response.ok) {
- const errorData = await response.json();
- const errorMessage = deriveErrorMessage(errorData);
- console.error("Failed to get UI settings:", errorMessage);
- return null;
- }
-
- const data = await response.json();
- return data;
- } catch (error) {
- console.error("Failed to get UI settings:", error);
- return null;
- }
-};
-
export const getMCPSemanticFilterSettings = async (accessToken: string) => {
/**
* Get MCP semantic filter configuration
@@ -4685,12 +4347,12 @@ export const createAgentCall = async (accessToken: string, agentData: any) => {
export type DiscoveredAgentCard = Agent["agent_card_params"];
-export type DiscoverAgentCardResponse = Omit & {
+type DiscoverAgentCardResponse = Omit & {
agent_card: DiscoveredAgentCard;
};
export type DiscoveryMode = components["schemas"]["DiscoveryMode"];
-export type DiscoverAgentCardOptions = Partial<
+type DiscoverAgentCardOptions = Partial<
Pick
>;
@@ -5613,7 +5275,7 @@ export const vectorStoreListCall = async (
}
};
-export interface IndexesListResponse {
+interface IndexesListResponse {
object: string;
data: VectorStoreIndex[];
}
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index e80df24a818..0dce821f862 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -5849,6 +5849,26 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/get/background_chat_settings": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Background Chat Settings
+ * @description Get saved background-response settings and the prerequisites for changing them.
+ */
+ get: operations["get_background_chat_settings_get_background_chat_settings_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/get/config/callbacks": {
parameters: {
query?: never;
@@ -18240,6 +18260,26 @@ export interface paths {
patch: operations["typesafe_proxy_route_typesafe__endpoint__patch"];
trace?: never;
};
+ "/update/background_chat_settings": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ /**
+ * Update Background Chat Settings
+ * @description Save background-response polling settings for the proxy configuration refresh.
+ */
+ patch: operations["update_background_chat_settings_update_background_chat_settings_patch"];
+ trace?: never;
+ };
"/update/default_team_settings": {
parameters: {
query?: never;
@@ -27157,6 +27197,37 @@ export interface components {
} & {
[key: string]: unknown;
};
+ /** BackgroundChatSettings */
+ BackgroundChatSettings: {
+ /**
+ * Enabled
+ * @description Enable cache-backed background Responses for Chat and API requests.
+ */
+ enabled: boolean;
+ };
+ /** BackgroundChatSettingsResponse */
+ BackgroundChatSettingsResponse: {
+ /**
+ * Can Save
+ * @description Whether database-backed configuration updates are available.
+ */
+ can_save: boolean;
+ /**
+ * Enabled
+ * @description Enable cache-backed background Responses for Chat and API requests.
+ */
+ enabled: boolean;
+ /**
+ * Managed By Config
+ * @description Whether the responses configuration is owned by the config file.
+ */
+ managed_by_config: boolean;
+ /**
+ * Redis Configured
+ * @description Whether this proxy has coordination Redis configured.
+ */
+ redis_configured: boolean;
+ };
/** BaseLitellmParams */
BaseLitellmParams: {
/**
@@ -38804,6 +38875,12 @@ export interface components {
supported_openai_params: string[] | null;
/** Supported Reasoning Efforts */
supported_reasoning_efforts?: string[] | null;
+ /**
+ * Supports Background Chat
+ * @description Whether cache-backed background Responses runs are enabled for this model group
+ * @default false
+ */
+ supports_background_chat: boolean;
/**
* Supports Fast Mode
* @default false
@@ -60982,6 +61059,26 @@ export interface operations {
};
};
};
+ get_background_chat_settings_get_background_chat_settings_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["BackgroundChatSettingsResponse"];
+ };
+ };
+ };
+ };
get_config_get_config_callbacks_get: {
parameters: {
query?: never;
@@ -76302,6 +76399,39 @@ export interface operations {
};
};
};
+ update_background_chat_settings_update_background_chat_settings_patch: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["BackgroundChatSettings"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["BackgroundChatSettingsResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
update_default_team_settings_update_default_team_settings_patch: {
parameters: {
query?: never;