diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index 49f1d906069..a1772102b89 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -4,9 +4,11 @@ on: push: branches: - main + - litellm_internal_staging pull_request: branches: - main + - litellm_internal_staging # Allow CodSpeed to trigger backtest performance analysis # in order to generate initial data workflow_dispatch: @@ -22,7 +24,7 @@ concurrency: jobs: benchmarks: runs-on: ubuntu-24.04 - timeout-minutes: 15 + timeout-minutes: 60 steps: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 diff --git a/.github/workflows/test-terraform-provider.yml b/.github/workflows/test-terraform-provider.yml new file mode 100644 index 00000000000..03d8ff3461c --- /dev/null +++ b/.github/workflows/test-terraform-provider.yml @@ -0,0 +1,113 @@ +name: Terraform Provider + +on: + push: + paths: + - "terraform/provider/**" + - ".github/workflows/test-terraform-provider.yml" + pull_request: + branches: + - main + - litellm_internal_staging + - litellm_oss_staging + - "litellm_**" + paths: + - "terraform/provider/**" + - "litellm/proxy/**" + - ".github/workflows/test-terraform-provider.yml" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + provider-checks: + name: gofmt, vet, build, test + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: terraform/provider + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + with: + go-version-file: terraform/provider/go.mod + cache: true + cache-dependency-path: terraform/provider/go.sum + + - name: gofmt + run: | + UNFORMATTED=$(gofmt -l .) + if [ -n "${UNFORMATTED}" ]; then + echo "::error::gofmt required for: ${UNFORMATTED}" + exit 1 + fi + + - name: go vet + run: go vet ./... + + - name: Build + run: go build ./... + + - name: Test + run: go test -timeout 120s ./... + + endpoint-drift: + name: Provider endpoints vs proxy OpenAPI schema + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + version: "0.10.9" + + - name: Cache uv dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.cache/uv + .venv + key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }} + restore-keys: | + ${{ runner.os }}-uv- + + - name: Install dependencies + run: | + .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router + + - name: Generate Prisma client + env: + PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache + run: | + uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma + + - name: Generate proxy OpenAPI schema + run: | + uv run --no-sync python terraform/provider/tools/dump_openapi.py "${RUNNER_TEMP}/openapi.json" + + - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + with: + go-version-file: terraform/provider/go.mod + cache: true + cache-dependency-path: terraform/provider/go.sum + + - name: Audit provider endpoints against the schema + working-directory: terraform/provider + run: go run ./tools/endpointaudit -provider-dir ./litellm -spec "${RUNNER_TEMP}/openapi.json" diff --git a/CLAUDE.md b/CLAUDE.md index 683993c9476..5255d39b4b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ When writing a PR body, treat the comments and imperative instructions inside @. If you're resolving a linear ticket, in the "## Linear ticket" section of the PR, say "Resolves LIT-1234", replacing "LIT-1234" with the actual ticket id that you're resolving. If you don't have the ticket id, don't make one up or search for it. Just leave the section blank -Never use `pytest` commands or the like as "Screenshots / Proof of Fix". We prefer curl'ing a live proxy instance running on localhost:4000 (I like to run it with `python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log`) and showing both the command run and the output. Also, it should hit real LLM provider APIs, not mocks, and cost real $$$ because that is the most realistic test. The proof of fix should be exactly what the end user / customer would see / do. The run logs in PR #27703 is a prime example of how to do it (not a huge fan of using a python test script that future me and the team will have no visibility into; I prefer just curl commands or a short list of bash commands (e.g., using `for`)). If it's a UI thing, just tell me which URLs to go to (e.g., http://localhost:4000/ui/?page=logs), where to click, what fields to fill out, etc. along with the other commands to run in an ordered list, and I'll do it myself and post the screenshots after you make the PR +Never use `pytest` commands or the like as "Screenshots / Proof of Fix". We prefer curl'ing a live proxy instance running on localhost:4000 (I like to run it with `python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log`; the Admin UI dev server is `npm run dev` in `ui/litellm-dashboard`, served on port 3000) and showing both the command run and the output. Also, it should hit real LLM provider APIs, not mocks, and cost real $$$ because that is the most realistic test. The proof of fix should be exactly what the end user / customer would see / do. The run logs in PR #27703 is a prime example of how to do it (not a huge fan of using a python test script that future me and the team will have no visibility into; I prefer just curl commands or a short list of bash commands (e.g., using `for`)). If it's a UI thing, just tell me which URLs to go to (e.g., http://localhost:4000/ui/?page=logs), where to click, what fields to fill out, etc. along with the other commands to run in an ordered list, and I'll do it myself and post the screenshots after you make the PR If you ever make public-facing PR descriptions, comments, issues, commit messages, etc., always follow these guidelines to sound less AI-y: - don't use emojis diff --git a/enterprise/pyproject.toml b/enterprise/pyproject.toml index f4d756de44c..b3864ce7878 100644 --- a/enterprise/pyproject.toml +++ b/enterprise/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm-enterprise" -version = "0.1.47" +version = "0.1.48" description = "Package for LiteLLM Enterprise features" readme = "README.md" requires-python = ">=3.9" @@ -26,7 +26,7 @@ required-version = ">=0.10.9" module-root = "" [tool.commitizen] -version = "0.1.47" +version = "0.1.48" version_files = [ "pyproject.toml:^version", "../pyproject.toml:litellm-enterprise==", diff --git a/litellm/__init__.py b/litellm/__init__.py index 2ec0830d622..6e2a03b7c7c 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -379,6 +379,7 @@ budget_duration: Optional[str] = ( None # proxy only - resets budget after fixed duration. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d"). ) default_soft_budget: float = DEFAULT_SOFT_BUDGET # by default all litellm proxy keys have a soft budget of 50.0 +budget_exceeded_throttle_percentage: Optional[float] = None forward_traceparent_to_llm_provider: bool = False diff --git a/litellm/a2a_protocol/streaming_iterator.py b/litellm/a2a_protocol/streaming_iterator.py index 529154919f3..1ef174a5eee 100644 --- a/litellm/a2a_protocol/streaming_iterator.py +++ b/litellm/a2a_protocol/streaming_iterator.py @@ -11,7 +11,6 @@ from litellm._logging import verbose_logger from litellm.a2a_protocol.cost_calculator import A2ACostCalculator from litellm.a2a_protocol.utils import A2ARequestUtils from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj -from litellm.litellm_core_utils.thread_pool_executor import executor if TYPE_CHECKING: from a2a.types import SendStreamingMessageRequest, SendStreamingMessageResponse @@ -128,22 +127,15 @@ class A2AStreamingIterator: # Call success handlers - they will build standard_logging_object asyncio.create_task( - self.logging_obj.async_success_handler( - result=result, + self.logging_obj.dispatch_success_handlers( + result, start_time=self.start_time, end_time=end_time, cache_hit=None, + prefer_async_handlers=True, ) ) - executor.submit( - self.logging_obj.success_handler, - result=result, - cache_hit=None, - start_time=self.start_time, - end_time=end_time, - ) - verbose_logger.info( f"A2A streaming completed: prompt_tokens={prompt_tokens}, " f"completion_tokens={completion_tokens}, total_tokens={total_tokens}, " diff --git a/litellm/constants.py b/litellm/constants.py index 1300668cc70..7423d9b2211 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1504,6 +1504,7 @@ LITELLM_SETTINGS_SAFE_DB_OVERRIDES = [ "public_model_groups_links", "cost_discount_config", "cost_margin_config", + "budget_exceeded_throttle_percentage", ] SPECIAL_LITELLM_AUTH_TOKEN = ["ui-token"] DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL = int(os.getenv("DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL", 60)) diff --git a/litellm/integrations/otel/logger.py b/litellm/integrations/otel/logger.py index 5e729e12be0..e258b239d93 100644 --- a/litellm/integrations/otel/logger.py +++ b/litellm/integrations/otel/logger.py @@ -368,7 +368,11 @@ class OpenTelemetryV2(CustomLogger): # it (named provisionally) so it isn't leaked as an open span. carrier.span.end(end_time=to_ns(end_time)) return None - data = LLMCallSpanData.from_standard_logging_payload(payload, capture_content=self.config.capture_span_content) + data = LLMCallSpanData.from_standard_logging_payload( + payload, + capture_content=self.config.capture_span_content, + time_to_first_chunk_seconds=call.time_to_first_chunk_seconds, + ) end_time_ns = to_ns(end_time) if carrier.span is not None: # Born at the boundary: stamp attributes from the typed payload, set diff --git a/litellm/integrations/otel/mappers/genai.py b/litellm/integrations/otel/mappers/genai.py index c5d8c35de7d..f568afa9e3e 100644 --- a/litellm/integrations/otel/mappers/genai.py +++ b/litellm/integrations/otel/mappers/genai.py @@ -55,6 +55,7 @@ class GenAIMapper: GenAI.RESPONSE_MODEL: lambda d: d.response_model, GenAI.RESPONSE_ID: lambda d: d.response_id, GenAI.RESPONSE_FINISH_REASONS: lambda d: list(d.finish_reasons) if d.finish_reasons else None, + GenAI.RESPONSE_TIME_TO_FIRST_CHUNK: lambda d: d.time_to_first_chunk_seconds, GenAI.USAGE_INPUT_TOKENS: lambda d: d.usage.input_tokens, GenAI.USAGE_OUTPUT_TOKENS: lambda d: d.usage.output_tokens, Error.TYPE: lambda d: d.error.error_type if d.error else None, diff --git a/litellm/integrations/otel/model/metadata.py b/litellm/integrations/otel/model/metadata.py index 37bb5464315..7ff4f540908 100644 --- a/litellm/integrations/otel/model/metadata.py +++ b/litellm/integrations/otel/model/metadata.py @@ -41,7 +41,7 @@ from typing import TYPE_CHECKING, Any, Mapping, cast from litellm.constants import LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL from litellm.integrations.otel.model.semconv import resolve_operation -from litellm.integrations.otel.model.utils import as_str +from litellm.integrations.otel.model.utils import as_str, to_seconds if TYPE_CHECKING: from litellm.types.utils import StandardLoggingPayload @@ -201,6 +201,7 @@ class LLMCallEvent: # span is renamed from the typed payload at close (``finish_span``); this only # needs to be reasonable for a span that never gets closed (a leak). provisional_span_name: str + time_to_first_chunk_seconds: float | None @classmethod def from_dict(cls, kwargs: Mapping[str, Any]) -> "LLMCallEvent": @@ -214,9 +215,25 @@ class LLMCallEvent: dynamic_params=kwargs.get("standard_callback_dynamic_params"), is_no_upstream_call=bool(kwargs.get(LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL)), provisional_span_name=f"{operation.value} {model}".strip(), + time_to_first_chunk_seconds=time_to_first_chunk_seconds(kwargs), ) +def time_to_first_chunk_seconds(kwargs: Mapping[str, Any]) -> float | None: + """Seconds from the upstream request being issued (``api_call_start_time``) + to the first streamed chunk (``completion_start_time``); ``None`` for + non-streaming calls, where ``completion_start_time`` is backfilled with the + end time and would not measure first-chunk latency.""" + optional_params = cast(Mapping[str, Any], kwargs.get("optional_params") or {}) + if not optional_params.get("stream"): + return None + api_call_start = to_seconds(kwargs.get("api_call_start_time")) + completion_start = to_seconds(kwargs.get("completion_start_time")) + if api_call_start is None or completion_start is None: + return None + return completion_start - api_call_start + + def _call_id(payload: "StandardLoggingPayload | None", kwargs: Mapping[str, Any]) -> str | None: """The call id from the payload (when closed) or the bare kwargs (at pre_call).""" if payload is not None: diff --git a/litellm/integrations/otel/model/payloads.py b/litellm/integrations/otel/model/payloads.py index b0dcf97b787..fcd710492f0 100644 --- a/litellm/integrations/otel/model/payloads.py +++ b/litellm/integrations/otel/model/payloads.py @@ -305,10 +305,14 @@ class LLMCallSpanData: messages_in: tuple[Mapping[str, object], ...] = () choices_out: tuple[Mapping[str, object], ...] = () system_fingerprint: str | None = None + time_to_first_chunk_seconds: float | None = None @classmethod def from_standard_logging_payload( - cls, payload: "StandardLoggingPayload", capture_content: bool = False + cls, + payload: "StandardLoggingPayload", + capture_content: bool = False, + time_to_first_chunk_seconds: float | None = None, ) -> "LLMCallSpanData": params = cast(Mapping[str, object], payload.get("model_parameters") or {}) # The single parse of the request's metadata — the request-vs-provider @@ -349,6 +353,7 @@ class LLMCallSpanData: messages_in=_dicts(payload.get("messages")) if capture_content else (), choices_out=choices_out if capture_content else (), system_fingerprint=as_str(response.get("system_fingerprint")), + time_to_first_chunk_seconds=time_to_first_chunk_seconds, ) diff --git a/litellm/integrations/otel/model/semconv.py b/litellm/integrations/otel/model/semconv.py index 6315a5a4a89..4e725ae0a29 100644 --- a/litellm/integrations/otel/model/semconv.py +++ b/litellm/integrations/otel/model/semconv.py @@ -69,6 +69,7 @@ class GenAI: RESPONSE_ID: Final = "gen_ai.response.id" RESPONSE_MODEL: Final = "gen_ai.response.model" RESPONSE_FINISH_REASONS: Final = "gen_ai.response.finish_reasons" + RESPONSE_TIME_TO_FIRST_CHUNK: Final = "gen_ai.response.time_to_first_chunk" # usage USAGE_INPUT_TOKENS: Final = "gen_ai.usage.input_tokens" USAGE_OUTPUT_TOKENS: Final = "gen_ai.usage.output_tokens" diff --git a/litellm/integrations/otel/plumbing/metrics.py b/litellm/integrations/otel/plumbing/metrics.py index cb1f9214876..50d0fb75962 100644 --- a/litellm/integrations/otel/plumbing/metrics.py +++ b/litellm/integrations/otel/plumbing/metrics.py @@ -21,6 +21,7 @@ from litellm.integrations.opentelemetry import ( _build_metric_attribute_filter, _resolve_metric_attribute_filter, ) +from litellm.integrations.otel.model.metadata import time_to_first_chunk_seconds from litellm.integrations.otel.model.semconv import Metric, resolve_operation from litellm.integrations.otel.model.utils import to_seconds from litellm.litellm_core_utils.safe_json_dumps import safe_dumps @@ -181,13 +182,10 @@ class GenAIMetricRecorder: self._metrics.token_usage.record(usage.get("completion_tokens", 0), attributes=out_attrs) def _record_time_to_first_token(self, kwargs: Mapping[str, Any], common_attrs: dict) -> None: - if not kwargs.get("optional_params", {}).get("stream", False): + time_to_first_chunk = time_to_first_chunk_seconds(kwargs) + if time_to_first_chunk is None: return - api_call_start = to_seconds(kwargs.get("api_call_start_time")) - completion_start = to_seconds(kwargs.get("completion_start_time")) - if api_call_start is None or completion_start is None: - return - self._metrics.time_to_first_token.record(completion_start - api_call_start, attributes=common_attrs) + self._metrics.time_to_first_token.record(time_to_first_chunk, attributes=common_attrs) def _record_time_per_output_token( self, diff --git a/litellm/interactions/streaming_iterator.py b/litellm/interactions/streaming_iterator.py index 45c5443cfd2..0d9d1b4579c 100644 --- a/litellm/interactions/streaming_iterator.py +++ b/litellm/interactions/streaming_iterator.py @@ -174,22 +174,15 @@ class InteractionsAPIStreamingIterator(BaseInteractionsAPIStreamingIterator): logging_response = copy.deepcopy(self.completed_response) asyncio.create_task( - self.logging_obj.async_success_handler( - result=logging_response, + self.logging_obj.dispatch_success_handlers( + logging_response, start_time=self.start_time, end_time=datetime.now(), cache_hit=None, + prefer_async_handlers=True, ) ) - executor.submit( - self.logging_obj.success_handler, - result=logging_response, - cache_hit=None, - start_time=self.start_time, - end_time=datetime.now(), - ) - class SyncInteractionsAPIStreamingIterator(BaseInteractionsAPIStreamingIterator): """ diff --git a/litellm/litellm_core_utils/realtime_streaming.py b/litellm/litellm_core_utils/realtime_streaming.py index a1a070eb5b7..220d1caa3d2 100644 --- a/litellm/litellm_core_utils/realtime_streaming.py +++ b/litellm/litellm_core_utils/realtime_streaming.py @@ -1,5 +1,4 @@ import asyncio -import concurrent.futures import json from typing import TYPE_CHECKING, Any, Dict, List, Optional, Protocol, Union, cast @@ -25,9 +24,6 @@ if TYPE_CHECKING: else: CLIENT_CONNECTION_CLASS = Any -# Create a thread pool with a maximum of 10 threads -executor = concurrent.futures.ThreadPoolExecutor(max_workers=10) - class RealtimeEventNormalizer(Protocol): def should_drop(self, event: object) -> bool: ... @@ -315,13 +311,12 @@ class RealTimeStreaming: if self.session_tools or self.tool_calls: self.logging_obj.model_call_details["realtime_tools"] = self.session_tools self.logging_obj.model_call_details["realtime_tool_calls"] = self.tool_calls - ## ASYNC LOGGING # Route through the bounded logging worker (per-coroutine timeout + # concurrency cap) instead of a bare create_task, so a slow callback # can't leave suspended tasks pinning each call's response in memory. - GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(self.logging_obj.async_success_handler(self.messages)) - ## SYNC LOGGING - executor.submit(self.logging_obj.success_handler(self.messages)) + GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue( + self.logging_obj.dispatch_success_handlers(self.messages, prefer_async_handlers=True) + ) async def _send_to_backend(self, message: str) -> bool: """Send a message to the backend WebSocket. diff --git a/litellm/llms/bedrock/chat/mantle/transformation.py b/litellm/llms/bedrock/chat/mantle/transformation.py index d84e077c37b..d7ffff65ff0 100644 --- a/litellm/llms/bedrock/chat/mantle/transformation.py +++ b/litellm/llms/bedrock/chat/mantle/transformation.py @@ -7,13 +7,14 @@ The bedrock-mantle endpoint uses the Anthropic Messages API format but is served at a different endpoint (bedrock-mantle.{region}.api.aws) with AWS SigV4 auth. """ -from typing import TYPE_CHECKING, Any, List, Optional +from typing import TYPE_CHECKING, Any, AsyncIterator, Iterator, List, Optional from litellm.llms.bedrock.chat.invoke_transformations.anthropic_claude3_transformation import ( AmazonAnthropicClaudeConfig, ) from litellm.llms.bedrock.common_utils import build_mantle_messages_url from litellm.types.llms.openai import AllMessageValues +from litellm.types.utils import ModelResponse if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj @@ -91,10 +92,14 @@ class AmazonMantleConfig(AmazonAnthropicClaudeConfig): litellm_params=litellm_params, headers=headers, ) - # The parent strips "model" from the body (Invoke API puts it in URL). - # The mantle endpoint (Messages API) requires "model" in the body. - request["model"] = model_id - return request + # The parent strips "model" and "stream" from the body (Invoke API puts + # the model in the URL and streams via a dedicated endpoint). The mantle + # endpoint (Messages API) requires both in the body. + return self._restore_mantle_body_fields( + request=request, + model_id=model_id, + optional_params=optional_params, + ) async def async_transform_request( self, @@ -114,5 +119,31 @@ class AmazonMantleConfig(AmazonAnthropicClaudeConfig): headers=headers, ) await self._async_convert_document_url_sources_to_base64(request) - request["model"] = model_id - return request + return self._restore_mantle_body_fields( + request=request, + model_id=model_id, + optional_params=optional_params, + ) + + @staticmethod + def _restore_mantle_body_fields(request: dict, model_id: str, optional_params: dict) -> dict: + stream_fields: dict = {"stream": True} if optional_params.get("stream") is True else {} + return {**request, "model": model_id, **stream_fields} + + @property + def has_custom_stream_wrapper(self) -> bool: + return False + + def get_model_response_iterator( + self, + streaming_response: Iterator[str] | AsyncIterator[str] | ModelResponse, + sync_stream: bool, + json_mode: Optional[bool] = False, + ) -> Any: + from litellm.llms.anthropic.chat.handler import ModelResponseIterator + + return ModelResponseIterator( + streaming_response=streaming_response, + sync_stream=sync_stream, + json_mode=json_mode, + ) diff --git a/litellm/llms/bedrock/messages/mantle_transformation.py b/litellm/llms/bedrock/messages/mantle_transformation.py index a8a7b7ed1d5..da7b8697a6b 100644 --- a/litellm/llms/bedrock/messages/mantle_transformation.py +++ b/litellm/llms/bedrock/messages/mantle_transformation.py @@ -6,8 +6,13 @@ AmazonAnthropicClaudeMessagesConfig. Overrides only the URL and model-prefix stripping that are specific to the bedrock-mantle endpoint. """ -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, List, Optional, Tuple +import httpx + +from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, +) from litellm.llms.bedrock.common_utils import build_mantle_messages_url from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation import ( AmazonAnthropicClaudeMessagesConfig, @@ -89,8 +94,26 @@ class AmazonMantleMessagesConfig(AmazonAnthropicClaudeMessagesConfig): headers=headers, ) - # Parent (AmazonAnthropicClaudeMessagesConfig) removes "model" from the - # body (Bedrock Invoke puts model in the URL). The mantle endpoint - # (Messages API) requires "model" in the request body. - request["model"] = model_id - return request + # Parent (AmazonAnthropicClaudeMessagesConfig) removes "model" and + # "stream" from the body (Bedrock Invoke puts the model in the URL and + # streams via a dedicated endpoint). The mantle endpoint (Messages API) + # requires both in the request body. + stream_fields: dict[str, bool] = ( + {"stream": True} if anthropic_messages_optional_request_params.get("stream") is True else {} + ) + return {**request, "model": model_id, **stream_fields} + + def get_async_streaming_response_iterator( + self, + model: str, + httpx_response: httpx.Response, + request_body: dict, + litellm_logging_obj: LiteLLMLoggingObj, + ) -> AsyncIterator: + return AnthropicMessagesConfig.get_async_streaming_response_iterator( + self, + model=model, + httpx_response=httpx_response, + request_body=request_body, + litellm_logging_obj=litellm_logging_obj, + ) diff --git a/litellm/main.py b/litellm/main.py index 2ace46a16fb..7d457d9cdd1 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -1082,6 +1082,54 @@ def _build_custom_pricing_entry( return entry +def _get_router_deployment_id(kwargs: dict) -> Optional[str]: + for metadata_key in ("litellm_metadata", "metadata"): + metadata = kwargs.get(metadata_key) or {} + if not isinstance(metadata, dict): + continue + deployment_model_info = metadata.get("model_info") or {} + if not isinstance(deployment_model_info, dict): + continue + deployment_id = deployment_model_info.get("id") + if deployment_id is not None: + return str(deployment_id) + return None + + +def _register_custom_pricing_for_request( + model: str, + custom_llm_provider: str, + kwargs: dict, + model_info: Optional[dict], +) -> None: + """Register per-request custom pricing in litellm.model_cost. + + Router-originated requests (identified by the deployment id the router puts + in metadata) get their full pricing registered under that unique id only; + the shared ``{provider}/{model}`` key receives the entry with pricing fields + stripped, mirroring Router._create_deployment. This keeps one deployment's + pricing overrides (e.g. a zero-cost wildcard) from clobbering built-in + pricing used by sibling deployments of the same backend model. Direct SDK + calls keep the legacy behavior of registering the shared key with pricing. + """ + entry = _build_custom_pricing_entry( + custom_llm_provider=custom_llm_provider, + kwargs=kwargs, + model_info=model_info, + ) + shared_key = f"{custom_llm_provider}/{model}" + deployment_id = _get_router_deployment_id(kwargs) + if deployment_id is None: + litellm.register_model({shared_key: entry}) + return + litellm.register_model( + { + deployment_id: entry, + shared_key: CustomPricingLiteLLMParams.strip_custom_pricing_fields(entry), + } + ) + + def _complete_azure(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult: _azure_detection_model = ctx._azure_detection_model acompletion = ctx.acompletion @@ -5108,14 +5156,11 @@ def completion( # type: ignore if ( input_cost_per_token is not None and output_cost_per_token is not None ) or input_cost_per_second is not None: - litellm.register_model( - { - f"{custom_llm_provider}/{model}": _build_custom_pricing_entry( - custom_llm_provider=custom_llm_provider, - kwargs=kwargs, - model_info=model_info, - ) - } + _register_custom_pricing_for_request( + model=model, + custom_llm_provider=custom_llm_provider, + kwargs=kwargs, + model_info=model_info, ) ### BUILD CUSTOM PROMPT TEMPLATE -- IF GIVEN ### custom_prompt_dict = {} # type: ignore @@ -5959,14 +6004,11 @@ def embedding( ### REGISTER CUSTOM MODEL PRICING -- IF GIVEN ### if (input_cost_per_token is not None and output_cost_per_token is not None) or input_cost_per_second is not None: - litellm.register_model( - { - f"{custom_llm_provider}/{model}": _build_custom_pricing_entry( - custom_llm_provider=custom_llm_provider, - kwargs=kwargs, - model_info=kwargs.get("model_info"), - ) - } + _register_custom_pricing_for_request( + model=model, + custom_llm_provider=custom_llm_provider, + kwargs=kwargs, + model_info=kwargs.get("model_info"), ) litellm_params_dict = get_litellm_params(**kwargs) diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py index 387843ee5b2..d2986a3cd82 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py @@ -8,6 +8,7 @@ from starlette.types import Scope from litellm._logging import verbose_logger from litellm.proxy._types import ( + UI_TEAM_ID, LiteLLM_TeamTable, ProxyException, SpecialHeaders, @@ -357,6 +358,7 @@ class MCPRequestHandler: # Inline imports avoid a circular dependency: mcp_server_manager imports # from this module. from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + MCPServerManager, global_mcp_server_manager, ) from litellm.types.mcp import MCPAuth @@ -382,7 +384,18 @@ class MCPRequestHandler: # fetches the upstream token automatically using stored credentials, # so allowing anonymous bypass would let any external caller invoke # tools authenticated as LiteLLM's service account. - if server.has_client_credentials: + # + # Resolve the flow rather than reading has_client_credentials directly: + # this is a security gate, and a legacy row whose oauth2_flow was never + # stamped still carries the M2M credential shape (client_id/secret + + # token_url, no authorization_url). Treating an unstamped-but-M2M-shaped + # row as non-M2M here would reopen the anonymous bypass the explicit + # column no longer closes on its own. Shares the one resolution helper + # with the egress backstop and the anonymous-delegate allowlist; all fail + # closed on the ambiguous shape and are removed together once no null rows + # remain. A pure-PKCE delegate server (no stored credentials) resolves to a + # non-M2M flow and keeps its bypass. + if MCPServerManager.effective_oauth2_flow(server) == "client_credentials": return False return True @@ -726,6 +739,9 @@ class MCPRequestHandler: if not user_api_key_auth or not user_api_key_auth.team_id or not prisma_client: return None + if user_api_key_auth.team_id == UI_TEAM_ID: + return None + # Get the team object (which has object_permission already loaded) team_obj: Optional[LiteLLM_TeamTable] = await get_team_object( team_id=user_api_key_auth.team_id, @@ -1021,6 +1037,9 @@ class MCPRequestHandler: if user_api_key_auth is None or not user_api_key_auth.team_id or prisma_client is None: return [] + if user_api_key_auth.team_id == UI_TEAM_ID: + return [] + team_obj: Optional[LiteLLM_TeamTable] = await get_team_object( team_id=user_api_key_auth.team_id, prisma_client=prisma_client, @@ -1503,6 +1522,9 @@ class MCPRequestHandler: verbose_logger.debug("prisma_client is None") return [] + if user_api_key_auth.team_id == UI_TEAM_ID: + return [] + try: team_obj: Optional[LiteLLM_TeamTable] = await get_team_object( team_id=user_api_key_auth.team_id, diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 0d28d4d26c4..61ba49729cd 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -581,6 +581,21 @@ def _create_elicitation_callback(): class MCPServerManager: _STDIO_ENV_TEMPLATE_PATTERN = re.compile(r"^\$\{(X-[^}]+)\}$") + @staticmethod + def _explicit_oauth2_flow( + oauth2_flow: Optional[str], + ) -> Optional[Literal["client_credentials", "authorization_code"]]: + """DB rows persist their flow (write-time stamps plus the startup backfill) and + config servers must declare it (validated at load), so both builds read the + value verbatim: unknown or null resolves to None, which + ``needs_user_oauth_token`` already treats as interactive. Field-shape inference + survives only in the request-time security helpers (``effective_oauth2_flow`` / + ``resolve_oauth2_flow_for_request``). + """ + if oauth2_flow in ("client_credentials", "authorization_code"): + return cast(Literal["client_credentials", "authorization_code"], oauth2_flow) + return None + @staticmethod def _resolve_oauth2_flow( *, @@ -591,11 +606,15 @@ class MCPServerManager: client_id: Optional[str], client_secret: Optional[str], ) -> Optional[Literal["client_credentials", "authorization_code"]]: - """Infer oauth2_flow for legacy records that omit the field. + """Infer oauth2_flow from field shape when the value is omitted. - DB rows created before oauth2_flow support may have OAuth2 client - credentials + token_url but a null oauth2_flow. Treat these as M2M, - unless authorization_url is present (interactive OAuth). + Not called directly by security sites; they go through ``effective_oauth2_flow`` + (boolean/enum decisions) or ``resolve_oauth2_flow_for_request`` (the egress object + backstop), which are the single choke points for request-time resolution. DB rows + are stamped at write time and by the startup backfill, config servers must declare + oauth2_flow (validated at load), and both builds read the value verbatim via + ``_explicit_oauth2_flow``. Delete this whole request-time layer once the backstop + warning stays silent in production. """ if oauth2_flow in ("client_credentials", "authorization_code"): return cast(Literal["client_credentials", "authorization_code"], oauth2_flow) @@ -610,6 +629,51 @@ class MCPServerManager: return "client_credentials" return None + @staticmethod + def effective_oauth2_flow(server: "MCPServer") -> Optional[Literal["client_credentials", "authorization_code"]]: + """The oauth2_flow a security decision must use for ``server`` this request. + + Column-first, shape-fallback: a stamped row returns its explicit value; an + unstamped (null) row whose fields carry the M2M shape resolves to + ``client_credentials`` so it is treated as M2M and fails closed. Every + security-sensitive reader (anonymous-delegate allowlist and gate, egress flow + resolution) goes through this one helper rather than reading the bare + ``has_client_credentials`` column, which is unreliable for null rows. + """ + return MCPServerManager._resolve_oauth2_flow( + auth_type=server.auth_type, + oauth2_flow=server.oauth2_flow, + token_url=server.token_url, + authorization_url=server.authorization_url, + client_id=server.client_id, + client_secret=server.client_secret, + ) + + @staticmethod + def resolve_oauth2_flow_for_request(server: "MCPServer") -> "MCPServer": + """Return ``server`` with its effective oauth2_flow applied, for egress paths. + + A stamped row is returned unchanged (its effective flow equals the stored value). + An unstamped M2M-shape row is returned as a per-request copy carrying + ``oauth2_flow=client_credentials`` so downstream ``has_client_credentials`` / + ``needs_user_oauth_token`` compute correctly and the stored client credentials are + used instead of forwarding the caller's Authorization. Use this at every point that + resolves an allowed server id into an ``MCPServer`` for a tool call or listing. + """ + effective = MCPServerManager.effective_oauth2_flow(server) + if effective is None or effective == server.oauth2_flow: + return server + verbose_logger.warning( + "MCP server %s has no persisted oauth2_flow but matches the %s shape; using the " + "inferred flow for this request. The startup backfill leaves this ambiguous M2M " + "shape unstamped on purpose, so it will NOT self-heal: set oauth2_flow explicitly " + "in the dashboard or via PUT /v1/mcp/server (client_credentials for M2M, or " + "authorization_code after an interactive sign-in).", + server.server_id, + effective, + ) + return server.model_copy(update={"oauth2_flow": effective}) + @staticmethod def _obo_needs_endpoint_discovery( auth_type: Optional[MCPAuthType], @@ -842,6 +906,20 @@ class MCPServerManager: mcp_oauth_metadata.registration_url if mcp_oauth_metadata else None ) + config_oauth2_flow = server_config.get("oauth2_flow", None) + if auth_type == MCPAuth.oauth2 and config_oauth2_flow not in ( + "client_credentials", + "authorization_code", + ): + raise ValueError( + f"Invalid config for MCP server '{server_name or server_id}': auth_type oauth2 " + f"requires an explicit oauth2_flow (got {config_oauth2_flow!r}). Set " + "oauth2_flow: client_credentials for machine-to-machine servers (the proxy mints " + "a shared token at token_url using client_id/client_secret, no user interaction) " + "or oauth2_flow: authorization_code for interactive servers (per-user tokens via " + "browser sign-in, including delegate_auth_to_upstream)." + ) + new_server = MCPServer( server_id=server_id, name=name_for_prefix, @@ -855,14 +933,7 @@ class MCPServerManager: # oauth specific fields client_id=server_config.get("client_id", None), client_secret=server_config.get("client_secret", None), - oauth2_flow=self._resolve_oauth2_flow( - auth_type=auth_type, - oauth2_flow=server_config.get("oauth2_flow", None), - token_url=resolved_token_url, - authorization_url=resolved_authorization_url, - client_id=server_config.get("client_id", None), - client_secret=server_config.get("client_secret", None), - ), + oauth2_flow=self._explicit_oauth2_flow(config_oauth2_flow), scopes=resolved_scopes, authorization_url=resolved_authorization_url, token_url=resolved_token_url, @@ -1240,15 +1311,7 @@ class MCPServerManager: env_vars=env_vars_list, client_id=client_id_value or getattr(mcp_server, "client_id", None), client_secret=client_secret_value or getattr(mcp_server, "client_secret", None), - oauth2_flow=self._resolve_oauth2_flow( - auth_type=auth_type, - oauth2_flow=getattr(mcp_server, "oauth2_flow", None), - token_url=mcp_server.token_url or getattr(mcp_oauth_metadata, "token_url", None), - authorization_url=mcp_server.authorization_url - or getattr(mcp_oauth_metadata, "authorization_url", None), - client_id=client_id_value or getattr(mcp_server, "client_id", None), - client_secret=client_secret_value or getattr(mcp_server, "client_secret", None), - ), + oauth2_flow=self._explicit_oauth2_flow(getattr(mcp_server, "oauth2_flow", None)), scopes=resolved_scopes, authorization_url=mcp_server.authorization_url or getattr(mcp_oauth_metadata, "authorization_url", None), token_url=mcp_server.token_url or getattr(mcp_oauth_metadata, "token_url", None), @@ -1556,8 +1619,11 @@ class MCPServerManager: and getattr(server, "delegate_auth_to_upstream", False) is True # M2M servers must not be exposed anonymously: an # unauthenticated caller would get LiteLLM to proxy tool - # calls using its stored client_credentials. - and not server.has_client_credentials + # calls using its stored client_credentials. Resolve the flow + # rather than reading has_client_credentials so an unstamped + # M2M-shape row (null column, verbatim-read as non-M2M) still + # fails closed here, matching the anonymous-delegate auth gate. + and MCPServerManager.effective_oauth2_flow(server) != "client_credentials" ] combined_servers.update(delegate_server_ids) diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index d978771f433..e3812522ded 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -1427,18 +1427,8 @@ if MCP_AVAILABLE: for allowed_mcp_server_id in allowed_mcp_server_ids: mcp_server = global_mcp_server_manager.get_mcp_server_by_id(allowed_mcp_server_id) if mcp_server is not None: - # Apply oauth2_flow resolution for legacy DB rows where it may be NULL - resolved_flow = MCPServerManager._resolve_oauth2_flow( - auth_type=mcp_server.auth_type, - oauth2_flow=mcp_server.oauth2_flow, - token_url=mcp_server.token_url, - authorization_url=mcp_server.authorization_url, - client_id=mcp_server.client_id, - client_secret=mcp_server.client_secret, - ) - if resolved_flow and resolved_flow != mcp_server.oauth2_flow: - # Create a new instance with the resolved flow for this request - mcp_server = mcp_server.model_copy(update={"oauth2_flow": resolved_flow}) + # Apply the request-time oauth2_flow backstop for legacy null rows. + mcp_server = MCPServerManager.resolve_oauth2_flow_for_request(mcp_server) allowed_mcp_servers.append(mcp_server) if mcp_servers is not None: @@ -2800,6 +2790,9 @@ if MCP_AVAILABLE: for allowed_mcp_server_id in allowed_mcp_server_ids: allowed_server = global_mcp_server_manager.get_mcp_server_by_id(allowed_mcp_server_id) if allowed_server is not None: + # Same request-time oauth2_flow backstop the listing path applies, + # so a null-flow M2M-shape row is treated as M2M on tool calls too. + allowed_server = MCPServerManager.resolve_oauth2_flow_for_request(allowed_server) allowed_mcp_servers.append(allowed_server) allowed_mcp_servers = await _get_allowed_mcp_servers_from_mcp_server_names( diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index e1d657f293b..3c16c2c3ed7 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1070,6 +1070,7 @@ class KeyRequestBase(GenerateRequestBase): budget_id: Optional[str] = None tags: Optional[List[str]] = None disable_global_guardrails: Optional[bool] = None + throttle_on_budget_exceeded: Optional[bool] = None enforced_params: Optional[List[str]] = None allowed_routes: Optional[list] = [] allowed_passthrough_routes: Optional[list] = None @@ -2469,6 +2470,7 @@ class UserAPIKeyAuth(LiteLLM_VerificationTokenView): # the expected response ob request_route: Optional[str] = None is_session_token: bool = False budget_reservation: Optional[Dict[str, Any]] = Field(default=None, exclude=True) + budget_throttle_pct: Optional[float] = Field(default=None, exclude=True) user: Optional[Any] = None # Expanded user object when expand=user is used created_by_user: Optional[Any] = None # Expanded created_by user when expand=user is used end_user_object_permission: Optional[LiteLLM_ObjectPermissionTable] = None @@ -3859,6 +3861,7 @@ LiteLLM_ManagementEndpoint_MetadataFields = [ "allowed_vector_store_indexes", "enforced_batch_output_expires_after", "enforced_file_expires_after", + "throttle_on_budget_exceeded", ] LiteLLM_ManagementEndpoint_MetadataFields_Premium = [ diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index b749e9fbe0d..ee548ba0a43 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -60,6 +60,10 @@ from litellm.proxy._types import ( UserAPIKeyAuth, ) from litellm.proxy.auth.route_checks import RouteChecks +from litellm.proxy.auth.budget_throttle import ( + budget_throttle_percentage, + should_throttle_budget_exceeded, +) from litellm.proxy.spend_tracking.budget_reservation import get_budget_window_start from litellm.proxy.common_utils.cache_pydantic_utils import CacheCodec from litellm.proxy.common_utils.http_parsing_utils import ( @@ -2496,6 +2500,7 @@ def _copy_user_api_key_auth_for_cache( ) -> UserAPIKeyAuth: copied_key_obj = user_api_key_obj.model_copy() copied_key_obj.budget_reservation = None + copied_key_obj.budget_throttle_pct = None copied_key_obj.parent_otel_span = None copied_key_obj.request_route = None return copied_key_obj @@ -3428,6 +3433,24 @@ async def is_valid_fallback_model( return True +def _apply_budget_exceeded_throttle(valid_token: UserAPIKeyAuth) -> bool: + """ + Throttle an over-budget key instead of blocking it, when the key opted in + via `throttle_on_budget_exceeded` and a global percentage is configured. + + Records the percentage on the request-scoped `budget_throttle_pct` so the + rate limiter scales the key's TPM/RPM down to it; the persistent limits are + left untouched so the throttle never compounds across requests. Returns True + when the key was throttled (caller skips raising), False when it should still + be hard-blocked. + """ + pct = budget_throttle_percentage() + if pct is None or not should_throttle_budget_exceeded(valid_token): + return False + valid_token.budget_throttle_pct = pct + return True + + async def _virtual_key_max_budget_check( valid_token: UserAPIKeyAuth, proxy_logging_obj: ProxyLogging, @@ -3488,6 +3511,8 @@ async def _virtual_key_max_budget_check( # so a NaN max_budget would silently disable enforcement. Treat a # non-finite max_budget as "no configured limit" rather than as a bypass. if math.isfinite(valid_token.max_budget) and spend >= valid_token.max_budget: + if _apply_budget_exceeded_throttle(valid_token): + return # name the key in the error so operators don't have to reverse-map # spend back to a key; key_name is the masked form (last 4 chars) key_label = valid_token.key_alias or "key" diff --git a/litellm/proxy/auth/budget_throttle.py b/litellm/proxy/auth/budget_throttle.py new file mode 100644 index 00000000000..19dffee462b --- /dev/null +++ b/litellm/proxy/auth/budget_throttle.py @@ -0,0 +1,56 @@ +""" +Throttle a key after it exceeds its own ``max_budget`` instead of blocking it. + +When a key opts in via ``throttle_on_budget_exceeded`` and a global +``budget_exceeded_throttle_percentage`` is configured, an over-budget key keeps +serving requests but at a reduced TPM/RPM (the configured percentage of its +configured limits). The decision (over budget + opted in) is made once during +auth; the scaling is recomputed from the key's original limits on every request +so it never compounds across requests. +""" + +import math +from typing import Optional + +import litellm +from litellm.proxy._types import UserAPIKeyAuth + + +def budget_throttle_percentage() -> Optional[float]: + """ + The global throttle percentage, or None when throttling is disabled / + misconfigured (in which case an over-budget key is hard-blocked, the safe + default). + """ + pct = litellm.budget_exceeded_throttle_percentage + if not isinstance(pct, (int, float)) or isinstance(pct, bool): + return None + if not 0 < pct <= 1: + return None + return float(pct) + + +def should_throttle_budget_exceeded(valid_token: UserAPIKeyAuth) -> bool: + """ + True when a key that exceeded its own ``max_budget`` should be throttled + rather than blocked: it opted in, a valid global percentage is set, and the + key has a TPM or RPM limit to scale down. A key with neither limit has + nothing to throttle, so it stays hard-blocked (the safe default) rather than + serving unlimited requests past its budget. + """ + if (valid_token.metadata or {}).get("throttle_on_budget_exceeded") is not True: + return False + if valid_token.tpm_limit is None and valid_token.rpm_limit is None: + return False + return budget_throttle_percentage() is not None + + +def throttled_limit(limit: Optional[int], pct: Optional[float]) -> Optional[int]: + """ + Scale a TPM/RPM limit to ``pct`` of its value, keeping a trickle of at least + 1 so a throttled key is slowed rather than fully locked out. An unset limit + or unset percentage leaves the limit unchanged. + """ + if limit is None or pct is None: + return limit + return max(1, math.floor(limit * pct)) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 7944bb54d67..2613510bd0c 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -2004,6 +2004,11 @@ async def _user_api_key_auth_builder( valid_token_dict = valid_token.model_dump(exclude_none=True) valid_token_dict.pop("token", None) + # budget_throttle_pct is excluded from model_dump (it must not leak + # into serialized responses), so carry the request-scoped decision + # forward by hand to the auth object the rate limiter receives. + if valid_token.budget_throttle_pct is not None: + valid_token_dict["budget_throttle_pct"] = valid_token.budget_throttle_pct if _end_user_object is not None: valid_token_dict.update(end_user_params) diff --git a/litellm/proxy/db/prisma_client.py b/litellm/proxy/db/prisma_client.py index 4042755f80d..fbccb8a726c 100644 --- a/litellm/proxy/db/prisma_client.py +++ b/litellm/proxy/db/prisma_client.py @@ -137,8 +137,17 @@ class PrismaWrapper: self.on_engine_replaced: Callable[[], None] | None = None def _get_engine_pid(self) -> int: - """Get the PID of the current Prisma engine subprocess, or 0 if unavailable.""" + """Get the PID of the current Prisma engine subprocess, or 0 if unavailable. + + Must never raise: it runs inside the reconnect path, where the client + may be in any broken state. Prisma's ``_engine`` is a property that + raises ``ClientNotConnectedError`` on a disconnected client; if that + escaped here, ``recreate_prisma_client`` would fail before it could + build a replacement client and the reconnect loop could never recover. + """ try: + if self._original_prisma.is_connected() is not True: + return 0 engine = self._original_prisma._engine process = getattr(engine, "process", None) if engine is not None else None if process is not None: diff --git a/litellm/proxy/hooks/mcp_semantic_filter/hook.py b/litellm/proxy/hooks/mcp_semantic_filter/hook.py index a374d9ce18f..7379096bf9b 100644 --- a/litellm/proxy/hooks/mcp_semantic_filter/hook.py +++ b/litellm/proxy/hooks/mcp_semantic_filter/hook.py @@ -129,6 +129,27 @@ class SemanticToolFilterHook(CustomLogger): return openai_tools_as_dicts + async def _filter_expanded_tools( + self, + data: dict, + expanded_tools: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + """ + Apply the semantic filter to expanded MCP tool definitions. + + Expanded tools are flat OpenAI function dicts with a top-level + "name" (see transform_mcp_tool_to_openai_responses_api_tool), so + filter_tools can name-match them against the semantic router. + """ + raw_messages = data.get("messages") or data.get("input") or [] + messages = [{"role": "user", "content": raw_messages}] if isinstance(raw_messages, str) else raw_messages + user_query = self.filter.extract_user_query(messages) + if not user_query: + verbose_proxy_logger.debug("No user query found, skipping semantic filter on expanded MCP tools") + return expanded_tools + + return await self.filter.filter_tools(query=user_query, available_tools=expanded_tools) + def _is_mcp_tool(self, tool: object) -> bool: """ Check whether *tool* is registered in the MCP semantic router. @@ -184,6 +205,32 @@ class SemanticToolFilterHook(CustomLogger): f"Semantic tool filter: all {len(native_tools)} tools are native, no MCP filtering applied" ) + def _emit_filter_metadata_safe( + self, + data: dict, + mcp_tools: list[object], + filtered_mcp_tools: list[object], + native_tools: list[object], + filtered_tools: list[object], + ) -> None: + """ + Emit filter metadata without letting an emission failure abort the + already-filtered request. + """ + try: + self._emit_filter_metadata( + data=data, + mcp_tools=mcp_tools, + filtered_mcp_tools=filtered_mcp_tools, + native_tools=native_tools, + filtered_tools=filtered_tools, + ) + except Exception as e: + verbose_proxy_logger.warning( + f"Failed to emit semantic filter metadata: {e}", + exc_info=True, + ) + async def async_pre_call_hook( self, user_api_key_dict: "UserAPIKeyAuth", @@ -206,9 +253,6 @@ class SemanticToolFilterHook(CustomLogger): verbose_proxy_logger.debug("No tools in request, skipping semantic filter") return None - # Expanded MCP tools are in OpenAI nested format which - # filter_tools/_extract_tool_info cannot name-match, so we skip - # semantic filtering and return early. if self._should_expand_mcp_tools(tools): verbose_proxy_logger.debug("Detected litellm_proxy MCP references, expanding before semantic filtering") @@ -227,11 +271,26 @@ class SemanticToolFilterHook(CustomLogger): verbose_proxy_logger.warning("No tools expanded from MCP references") return None - data["tools"] = native_tools_before_expand + expanded_tools + if not self.filter.enabled: + data["tools"] = native_tools_before_expand + expanded_tools + verbose_proxy_logger.debug("Semantic filter disabled, forwarding expanded MCP tools unfiltered") + return data + + filtered_expanded_tools = await self._filter_expanded_tools(data=data, expanded_tools=expanded_tools) + + combined_tools = native_tools_before_expand + filtered_expanded_tools + data["tools"] = combined_tools + self._emit_filter_metadata_safe( + data=data, + mcp_tools=expanded_tools, + filtered_mcp_tools=filtered_expanded_tools, + native_tools=native_tools_before_expand, + filtered_tools=combined_tools, + ) verbose_proxy_logger.info( f"Expanded MCP references to {len(expanded_tools)} tools " f"({len(native_tools_before_expand)} native preserved), " - f"skipping semantic filter (OpenAI nested format)" + f"semantic filter selected {len(filtered_expanded_tools)}" ) return data @@ -297,19 +356,13 @@ class SemanticToolFilterHook(CustomLogger): data["tools"] = filtered_tools - try: - self._emit_filter_metadata( - data=data, - mcp_tools=mcp_tools, - filtered_mcp_tools=filtered_mcp_tools, - native_tools=native_tools, - filtered_tools=filtered_tools, - ) - except Exception as e: - verbose_proxy_logger.warning( - f"Failed to emit semantic filter metadata: {e}", - exc_info=True, - ) + self._emit_filter_metadata_safe( + data=data, + mcp_tools=mcp_tools, + filtered_mcp_tools=filtered_mcp_tools, + native_tools=native_tools, + filtered_tools=filtered_tools, + ) return data diff --git a/litellm/proxy/hooks/parallel_request_limiter.py b/litellm/proxy/hooks/parallel_request_limiter.py index 1ed76d5b1e3..ee6abb13d6b 100644 --- a/litellm/proxy/hooks/parallel_request_limiter.py +++ b/litellm/proxy/hooks/parallel_request_limiter.py @@ -17,6 +17,7 @@ from litellm.proxy.auth.auth_utils import ( get_key_model_rpm_limit, get_key_model_tpm_limit, ) +from litellm.proxy.auth.budget_throttle import throttled_limit from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError from litellm.proxy.hooks.rate_limiter_utils import resolve_llm_provider_for_rate_limit @@ -248,10 +249,11 @@ class _PROXY_MaxParallelRequestsHandler(CustomLogger): if data is None: data = {} global_max_parallel_requests = data.get("metadata", {}).get("global_max_parallel_requests", None) - tpm_limit = getattr(user_api_key_dict, "tpm_limit", sys.maxsize) + throttle_pct = getattr(user_api_key_dict, "budget_throttle_pct", None) + tpm_limit = throttled_limit(getattr(user_api_key_dict, "tpm_limit", sys.maxsize), throttle_pct) if tpm_limit is None: tpm_limit = sys.maxsize - rpm_limit = getattr(user_api_key_dict, "rpm_limit", sys.maxsize) + rpm_limit = throttled_limit(getattr(user_api_key_dict, "rpm_limit", sys.maxsize), throttle_pct) if rpm_limit is None: rpm_limit = sys.maxsize diff --git a/litellm/proxy/hooks/parallel_request_limiter_v3.py b/litellm/proxy/hooks/parallel_request_limiter_v3.py index 78c43715dad..ee0a0e1789d 100644 --- a/litellm/proxy/hooks/parallel_request_limiter_v3.py +++ b/litellm/proxy/hooks/parallel_request_limiter_v3.py @@ -32,6 +32,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import ( ) from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.auth.auth_utils import get_model_rate_limit_from_metadata +from litellm.proxy.auth.budget_throttle import throttled_limit from litellm.proxy.common_utils.proxy_rate_limit_error import ( ProxyRateLimitError, map_v3_rate_limit_type, @@ -1549,18 +1550,19 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): or user_api_key_dict.tpm_limit is not None or user_api_key_dict.max_parallel_requests is not None ): + throttle_pct = user_api_key_dict.budget_throttle_pct descriptors.append( RateLimitDescriptor( key="api_key", value=user_api_key_dict.api_key, rate_limit={ "requests_per_unit": self._get_enforced_limit( - limit_value=user_api_key_dict.rpm_limit, + limit_value=throttled_limit(user_api_key_dict.rpm_limit, throttle_pct), limit_type=rpm_limit_type, model_has_failures=model_has_failures, ), "tokens_per_unit": self._get_enforced_limit( - limit_value=user_api_key_dict.tpm_limit, + limit_value=throttled_limit(user_api_key_dict.tpm_limit, throttle_pct), limit_type=tpm_limit_type, model_has_failures=model_has_failures, ), diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index d92aea57063..71cf2db3dfb 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -758,6 +758,12 @@ async def _common_key_generation_helper( premium_user=premium_user, ) + if data.throttle_on_budget_exceeded is True and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value: + raise HTTPException( + status_code=403, + detail={"error": "Only proxy admins can enable throttle_on_budget_exceeded on a key."}, + ) + if data.metadata is not None and data.metadata.get("service_account_id") is not None and data.team_id is None: await validate_team_id_used_in_service_account_request( team_id=data.team_id, @@ -1483,6 +1489,7 @@ async def generate_key_fn( - guardrails: Optional[List[str]] - List of active guardrails for the key - policies: Optional[List[str]] - List of policy names to apply to the key. Policies define guardrails, conditions, and inheritance rules. - disable_global_guardrails: Optional[bool] - Whether to disable global guardrails for the key. + - throttle_on_budget_exceeded: Optional[bool] - When the key exceeds its max_budget, throttle its tpm/rpm to the global budget_exceeded_throttle_percentage instead of blocking the key entirely. - permissions: Optional[dict] - key-specific permissions. Currently just used for turning off pii masking (if connected). Example - {"pii": false} - model_max_budget: Optional[Dict[str, BudgetConfig]] - Model-specific budgets {"gpt-4": {"budget_limit": 0.0005, "time_period": "30d"}}}. IF null or {} then no model specific budget. - budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}. @@ -2317,6 +2324,16 @@ async def _validate_update_key_data( or "budget_limits" in data.model_fields_set ) + _existing_metadata = getattr(existing_key_row, "metadata", None) + _existing_throttle = ( + _existing_metadata.get("throttle_on_budget_exceeded") if isinstance(_existing_metadata, dict) else None + ) + if data.throttle_on_budget_exceeded is True and _existing_throttle is not True and not _is_proxy_admin: + raise HTTPException( + status_code=403, + detail={"error": "Only proxy admins can enable throttle_on_budget_exceeded on a key."}, + ) + # Personal-key bypass: the caller both created the key AND still owns it # (user_id == caller). Checking only created_by would let a demoted admin # who originally created a key for another user continue editing it without @@ -2507,6 +2524,7 @@ async def update_key_fn( - guardrails: Optional[List[str]] - List of active guardrails for the key - policies: Optional[List[str]] - List of policy names to apply to the key. Policies define guardrails, conditions, and inheritance rules. - disable_global_guardrails: Optional[bool] - Whether to disable global guardrails for the key. + - throttle_on_budget_exceeded: Optional[bool] - When the key exceeds its max_budget, throttle its tpm/rpm to the global budget_exceeded_throttle_percentage instead of blocking the key entirely. - prompts: Optional[List[str]] - List of prompts that the key is allowed to use. - blocked: Optional[bool] - Whether the key is blocked - aliases: Optional[dict] - Model aliases for the key - [Docs](https://litellm.vercel.app/docs/proxy/virtual_keys#model-aliases) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 5c41c60fcb1..8ec7ec707a2 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -26,6 +26,7 @@ from litellm._uuid import uuid from litellm.integrations.prometheus import PrometheusLogger from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.proxy._types import ( + UI_TEAM_ID, BlockTeamRequest, CommonProxyErrors, DeleteTeamRequest, @@ -1046,6 +1047,13 @@ async def new_team( if data.team_id is None: data.team_id = str(uuid.uuid4()) else: + if data.team_id == UI_TEAM_ID: + raise HTTPException( + status_code=400, + detail={ + "error": f"team_id '{UI_TEAM_ID}' is reserved for LiteLLM UI dashboard sessions and cannot be used for a real team. Please use a different team id." + }, + ) # Check if team_id exists already _existing_team_id = await prisma_client.get_data( team_id=data.team_id, table_name="team", query_type="find_unique" diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 810e94cdc27..38788d140e9 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -14285,6 +14285,9 @@ async def update_config_general_settings( detail={"error": CommonProxyErrors.not_allowed_access.value}, ) + if data.field_name in _GENERAL_SETTINGS_UI_LITELLM_FIELDS: + return await _persist_general_settings_ui_litellm_field(data.field_name, data.field_value, user_api_key_dict) + if data.field_name not in ConfigGeneralSettings.model_fields: raise HTTPException( status_code=400, @@ -14550,6 +14553,55 @@ async def get_config_general_settings( ) +_GENERAL_SETTINGS_UI_LITELLM_FIELDS: dict[str, dict[str, str]] = { + "budget_exceeded_throttle_percentage": { + "type": "Float", + "description": ( + "Fraction (0, 1] of a key's configured TPM/RPM that an over-budget key with " + "'Throttle on budget exceeded' enabled keeps serving at. Leave empty to hard-block " + "over-budget keys." + ), + }, +} + + +def _validate_general_settings_ui_litellm_value(field_name: str, value: Any) -> Optional[float]: + if value is None or value == "": + return None + if isinstance(value, bool) or not isinstance(value, (int, float)) or not (0 < float(value) <= 1): + raise HTTPException( + status_code=400, + detail={"error": f"{field_name} must be a number in (0, 1] or empty"}, + ) + return float(value) + + +async def _persist_general_settings_ui_litellm_field( + field_name: str, value: Any, user_api_key_dict: UserAPIKeyAuth +) -> dict: + validated = _validate_general_settings_ui_litellm_value(field_name, value) + config = await proxy_config.get_config() + before_value = config.get("litellm_settings", {}).get(field_name) + setattr(litellm, field_name, validated) + if "litellm_settings" not in config: + config["litellm_settings"] = {} + config["litellm_settings"][field_name] = validated + await proxy_config.save_config(new_config=config) + asyncio.create_task(create_config_audit_log(field_name, "updated", before_value, validated, user_api_key_dict)) + return {"message": f"Field {field_name} updated", "status": "success"} + + +async def _reset_general_settings_ui_litellm_field(field_name: str, user_api_key_dict: UserAPIKeyAuth) -> dict: + config = await proxy_config.get_config() + before_value = config.get("litellm_settings", {}).get(field_name) + setattr(litellm, field_name, None) + if "litellm_settings" in config: + config["litellm_settings"].pop(field_name, None) + await proxy_config.save_config(new_config=config) + asyncio.create_task(create_config_audit_log(field_name, "deleted", before_value, None, user_api_key_dict)) + return {"message": f"Field {field_name} reset", "status": "success"} + + @router.get( "/config/list", tags=["config.yaml"], @@ -14703,6 +14755,35 @@ async def get_config_list( ) return_val.append(_response_obj) + db_litellm_settings_row = await ConfigRepository(prisma_client).table.find_first( + where={"param_name": "litellm_settings"} + ) + db_litellm_settings: dict = ( + dict(db_litellm_settings_row.param_value) + if db_litellm_settings_row is not None and db_litellm_settings_row.param_value is not None + else {} + ) + for litellm_field_name, spec in _GENERAL_SETTINGS_UI_LITELLM_FIELDS.items(): + current_value: Optional[float] = getattr(litellm, litellm_field_name, None) + stored_in_db_litellm: Optional[bool] + if litellm_field_name in db_litellm_settings: + stored_in_db_litellm = True + elif current_value is not None: + stored_in_db_litellm = False + else: + stored_in_db_litellm = None + return_val.append( + ConfigList( + field_name=litellm_field_name, + field_type=spec["type"], + field_description=spec["description"], + field_value=current_value, + stored_in_db=stored_in_db_litellm, + field_default_value=None, + nested_fields=None, + ) + ) + return return_val @@ -14743,6 +14824,9 @@ async def delete_config_general_settings( }, ) + if data.field_name in _GENERAL_SETTINGS_UI_LITELLM_FIELDS: + return await _reset_general_settings_ui_litellm_field(data.field_name, user_api_key_dict) + if data.field_name not in ConfigGeneralSettings.model_fields: raise HTTPException( status_code=400, diff --git a/litellm/proxy/spend_tracking/budget_reservation.py b/litellm/proxy/spend_tracking/budget_reservation.py index ca6c2e86789..b577513fc0e 100644 --- a/litellm/proxy/spend_tracking/budget_reservation.py +++ b/litellm/proxy/spend_tracking/budget_reservation.py @@ -17,6 +17,7 @@ from litellm.proxy._types import ( UserAPIKeyAuth, ) from litellm.proxy.auth.auth_utils import get_model_from_request +from litellm.proxy.auth.budget_throttle import should_throttle_budget_exceeded from litellm.proxy.auth.route_checks import RouteChecks from litellm.proxy.utils import PrismaClient, ProxyLogging from litellm.router import Router @@ -54,6 +55,61 @@ def get_reserved_counter_keys(budget_reservation: Optional[dict]) -> set: } +def _key_reservation_should_release_for_throttle(counter_key: str, valid_token: Optional[UserAPIKeyAuth]) -> bool: + """ + Whether an over-budget key's own ``max_budget`` reservation should be + released rather than blocked, because the key opted into throttling: the + rate limiter slows it instead. Only the key's own ``max_budget`` counter is + exempt; team/user/window counters still enforce normally, and under-budget + requests never reach this branch so their concurrent-overspend protection is + untouched. + """ + if valid_token is None: + return False + return counter_key == f"spend:key:{valid_token.token}" and should_throttle_budget_exceeded(valid_token) + + +async def _apply_over_budget_reservation_policy( + counter: _BudgetCounter, + valid_token: Optional[UserAPIKeyAuth], + entry: dict[str, Any], + applied_entries: list[dict[str, Any]], + reservation_cost: float, + current_spend: float, +) -> float: + """ + Decide what to do when a counter is over budget, and return the reservation + cost to carry into the next counter. Three outcomes: an over-budget key that + opted into throttling releases its own reservation (the rate limiter slows + it) and keeps the cost; a partially-remaining budget resizes the reservation + down to what is left; anything else hard-blocks by raising. + """ + if _key_reservation_should_release_for_throttle(counter.counter_key, valid_token): + await _release_applied_entries_best_effort(entries=[entry], default_reserved_cost=reservation_cost) + applied_entries.remove(entry) + return reservation_cost + + remaining_before_reservation = counter.max_budget - (current_spend - reservation_cost) + if remaining_before_reservation > 1e-12: + await _resize_applied_reservation( + entries=applied_entries, + current_reserved_cost=reservation_cost, + new_reserved_cost=remaining_before_reservation, + ) + return remaining_before_reservation + + raise litellm.BudgetExceededError( + current_cost=current_spend, + max_budget=counter.max_budget, + message=( + "Budget has been exceeded! " + f"{counter.entity_type}={counter.entity_id} " + f"Current cost: {current_spend}, " + f"Max budget: {counter.max_budget}" + ), + ) + + async def reserve_budget_for_request( request_body: dict, route: str, @@ -130,25 +186,15 @@ async def reserve_budget_for_request( cached_spend = await _get_current_counter_value(counter=counter) current_spend = cached_spend + reservation_cost if current_spend > counter.max_budget: - remaining_before_reservation = counter.max_budget - (current_spend - reservation_cost) - if remaining_before_reservation > 1e-12: - await _resize_applied_reservation( - entries=applied_entries, - current_reserved_cost=reservation_cost, - new_reserved_cost=remaining_before_reservation, - ) - reservation_cost = remaining_before_reservation - continue - raise litellm.BudgetExceededError( - current_cost=current_spend, - max_budget=counter.max_budget, - message=( - "Budget has been exceeded! " - f"{counter.entity_type}={counter.entity_id} " - f"Current cost: {current_spend}, " - f"Max budget: {counter.max_budget}" - ), + reservation_cost = await _apply_over_budget_reservation_policy( + counter=counter, + valid_token=valid_token, + entry=entry, + applied_entries=applied_entries, + reservation_cost=reservation_cost, + current_spend=current_spend, ) + continue except Exception: await _release_applied_entries_best_effort( entries=applied_entries, diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 29a970c3cfb..8f530e3b8ce 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -45,6 +45,8 @@ else: router = APIRouter() +SPEND_LOGS_PAGINATION_COUNT_CAP = 10000 + @router.get( "/spend/keys", @@ -1958,6 +1960,20 @@ async def ui_view_spend_logs( else: _order_expr = order_column + count_query = f""" + SELECT COUNT(*) AS total_count + FROM ( + SELECT 1 + FROM "LiteLLM_SpendLogs" + WHERE {" AND ".join(sql_conditions)} + LIMIT ${p} + ) AS bounded_matches + """ + count_rows = await prisma_client.db.query_raw(count_query, *sql_params, SPEND_LOGS_PAGINATION_COUNT_CAP + 1) + raw_total = int(count_rows[0]["total_count"]) if count_rows else 0 + total_is_capped = raw_total > SPEND_LOGS_PAGINATION_COUNT_CAP + total_records = SPEND_LOGS_PAGINATION_COUNT_CAP if total_is_capped else raw_total + sql_query = f""" SELECT request_id, call_type, api_key, spend, total_tokens, @@ -1967,8 +1983,7 @@ async def ui_view_spend_logs( cache_hit, cache_key, request_tags, team_id, organization_id, end_user, requester_ip_address, session_id, status, mcp_namespaced_tool_name, agent_id, - COALESCE(request_duration_ms, (EXTRACT(EPOCH FROM ("endTime" - "startTime")) * 1000)::INTEGER) AS request_duration_ms, - COUNT(*) OVER () AS total_count + COALESCE(request_duration_ms, (EXTRACT(EPOCH FROM ("endTime" - "startTime")) * 1000)::INTEGER) AS request_duration_ms FROM "LiteLLM_SpendLogs" WHERE {" AND ".join(sql_conditions)} ORDER BY {_order_expr} {_sql_dir}{_nulls_clause} @@ -1978,34 +1993,13 @@ async def ui_view_spend_logs( data = await prisma_client.db.query_raw(sql_query, *sql_params) - # `COUNT(*) OVER ()` folds the total-match count into the same scan as the - # page data; a standalone `COUNT(*)` is a distributed RPC on sharded - # engines like YugabyteDB that contacts every tablet and times out - # regardless of row count (LIT-4027). The hot path (page 1 and in-range - # pages) always carries the count on its rows, so the count round trip is - # gone there. Only an out-of-range page overshoots the last row and comes - # back empty; fall back to a direct count there so total/total_pages stay - # accurate rather than collapsing to zero. - if data: - total_records = int(data[0]["total_count"]) - elif page > 1: - total_records = int( - await SpendLogsRepository(prisma_client).table.count( - where=where_conditions, - ) - ) - else: - total_records = 0 - # query_raw returns the JSONB `metadata` column as a string (the Prisma # serialiser bypasses the model-layer JSON hydration we get on the ORM # path). The UI reads `metadata.status` / `metadata.error_information` # as object fields, so failure rows looked like successes (#29674). - # Re-hydrate to dict here. Also drop the window-function `total_count` - # helper column so it does not leak into the serialised rows. + # Re-hydrate to dict here. for row in data: if isinstance(row, dict): - row.pop("total_count", None) md = row.get("metadata") if isinstance(md, str): try: @@ -2026,6 +2020,7 @@ async def ui_view_spend_logs( page_size, total_pages, enrich_session_counts=not is_v2, + total_is_capped=total_is_capped, ) except Exception as e: verbose_proxy_logger.exception(f"Error in ui_view_spend_logs: {e}") @@ -3334,6 +3329,7 @@ async def _build_ui_spend_logs_response( page_size: int, total_pages: int, enrich_session_counts: bool = True, + total_is_capped: bool = False, ) -> dict: """ Build the paginated response for the UI spend-logs endpoint. @@ -3358,10 +3354,12 @@ async def _build_ui_spend_logs_response( total_pages: Total number of pages. enrich_session_counts: Whether to add ``session_total_count`` to each row. Defaults to ``True``. + total_is_capped: Whether ``total_records`` was clamped to the + pagination count cap (there are more matching rows than the cap). Returns: A dict with ``data`` (enriched rows), ``total``, ``page``, - ``page_size``, and ``total_pages``. + ``page_size``, ``total_pages``, and ``total_is_capped``. """ count_map: dict[str, int] = {} if enrich_session_counts: @@ -3451,6 +3449,7 @@ async def _build_ui_spend_logs_response( "page": page, "page_size": page_size, "total_pages": total_pages, + "total_is_capped": total_is_capped, } diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 2880eef6908..d7649b524aa 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -4097,11 +4097,22 @@ class PrismaClient: raise e def _get_engine_pid(self) -> int: + """Get the PID of the writer's engine subprocess, or 0 if unavailable. + + Must never raise: prisma's ``_engine`` property raises + ``ClientNotConnectedError`` on a disconnected client, and an exception + escaping from the reconnect path would leave it unable to recover. + """ try: - engine = self.db._original_prisma._engine # type: ignore[attr-defined] + prisma_obj = self.writer_db._original_prisma + if prisma_obj.is_connected() is not True: + return 0 + engine = prisma_obj._engine process = getattr(engine, "process", None) if engine is not None else None if process is not None: - return process.pid + pid = process.pid + if isinstance(pid, int): + return pid except (AttributeError, TypeError): pass return 0 diff --git a/litellm/responses/mcp/litellm_proxy_mcp_handler.py b/litellm/responses/mcp/litellm_proxy_mcp_handler.py index c1cf2d967eb..e03f0296109 100644 --- a/litellm/responses/mcp/litellm_proxy_mcp_handler.py +++ b/litellm/responses/mcp/litellm_proxy_mcp_handler.py @@ -20,7 +20,6 @@ from litellm.proxy._experimental.mcp_server.utils import ( split_server_prefix_from_name, strip_known_server_prefix, ) -from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup from litellm.responses.main import aresponses from litellm.responses.streaming_iterator import BaseResponsesAPIStreamingIterator from litellm.types.llms.openai import ResponsesAPIResponse @@ -705,6 +704,10 @@ class LiteLLM_Proxy_MCP_Handler: if request_tags: logging_request_data["metadata"]["tags"] = request_tags if user_api_key_auth is not None: + from litellm.proxy.litellm_pre_call_utils import ( + LiteLLMProxyRequestSetup, + ) + LiteLLMProxyRequestSetup.add_user_api_key_auth_to_request_metadata( data=logging_request_data, user_api_key_dict=user_api_key_auth, diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py index 890b3b636ba..3618331f0f5 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -287,11 +287,12 @@ class BaseResponsesAPIStreamingIterator: end_time = datetime.now() if is_async: asyncio.create_task( - self.logging_obj.async_success_handler( - result=logging_response, + self.logging_obj.dispatch_success_handlers( + logging_response, start_time=self.start_time, end_time=end_time, cache_hit=self._completed_response_cache_hit, + prefer_async_handlers=True, ) ) else: @@ -302,14 +303,13 @@ class BaseResponsesAPIStreamingIterator: end_time=end_time, cache_hit=self._completed_response_cache_hit, ) - - executor.submit( - self.logging_obj.success_handler, - result=logging_response, - cache_hit=self._completed_response_cache_hit, - start_time=self.start_time, - end_time=end_time, - ) + executor.submit( + self.logging_obj.success_handler, + result=logging_response, + cache_hit=self._completed_response_cache_hit, + start_time=self.start_time, + end_time=end_time, + ) self._run_post_success_hooks(end_time=end_time) def _handle_logging_completed_response(self): @@ -1136,7 +1136,6 @@ def _build_synthetic_response_events( # --------------------------------------------------------------------------- from litellm._logging import verbose_logger -from litellm.litellm_core_utils.thread_pool_executor import executor as _ws_executor RESPONSES_WS_LOGGED_EVENT_TYPES = [ "response.created", @@ -1251,8 +1250,7 @@ class ResponsesWebSocketStreaming: if self.input_messages: self.logging_obj.model_call_details["messages"] = self.input_messages if self.messages: - asyncio.create_task(self.logging_obj.async_success_handler(self.messages)) - _ws_executor.submit(self.logging_obj.success_handler, self.messages) + asyncio.create_task(self.logging_obj.dispatch_success_handlers(self.messages, prefer_async_handlers=True)) async def backend_to_client(self) -> None: """Forward events from backend WebSocket to the client.""" diff --git a/litellm/router.py b/litellm/router.py index 12b96430334..5ffe60c2da0 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -7394,8 +7394,7 @@ class Router: # deployment sharing the same backend model name. # Each deployment's full pricing is already stored under its # unique model_id above. - _custom_pricing_fields = CustomPricingLiteLLMParams.model_fields.keys() - _shared_model_info = {k: v for k, v in _model_info.items() if k not in _custom_pricing_fields} + _shared_model_info = CustomPricingLiteLLMParams.strip_custom_pricing_fields(_model_info) _existing_shared_mode = (cast(Optional[dict], litellm.model_cost.get(_model_name, {})) or {}).get("mode") _deployment_mode = _shared_model_info.get("mode") # Keep the built-in bridge mode stable for shared backend keys. @@ -8059,8 +8058,7 @@ class Router: # deployment sharing the same backend model name. # Each deployment's full pricing is already stored under its # unique model_id above (when present). - _custom_pricing_fields = CustomPricingLiteLLMParams.model_fields.keys() - _shared_model_info = {k: v for k, v in _model_info_dict.items() if k not in _custom_pricing_fields} + _shared_model_info = CustomPricingLiteLLMParams.strip_custom_pricing_fields(_model_info_dict) _backend_alias_cost = {_model_name: _shared_model_info} if "responses/" in _model_name: _stripped_model_name = _model_name.replace("responses/", "") diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 380621f88a8..908f5b76424 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3048,6 +3048,17 @@ class CustomPricingLiteLLMParams(BaseModel): regional_processing_uplift_multiplier_eu: Optional[float] = None regional_processing_uplift_multiplier_us: Optional[float] = None + @classmethod + def strip_custom_pricing_fields(cls, model_info: Dict[str, Any]) -> Dict[str, Any]: + """Return a copy of ``model_info`` without per-deployment custom pricing fields. + + Used when registering a deployment's info under the shared + ``{provider}/{model}`` key in ``litellm.model_cost``, so one deployment's + pricing overrides don't pollute sibling deployments that share the same + backend model. Full pricing stays under the deployment's unique model id. + """ + return {k: v for k, v in model_info.items() if k not in cls.model_fields} + # Server-controlled fields that bound or drive an interceptor's agentic loop # (depth, cycle fingerprints, ceiling, code-interpreter sandbox state). Listed diff --git a/pyproject.toml b/pyproject.toml index d1f22224c79..3f5458c5494 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm" -version = "1.92.0" +version = "1.93.0" description = "Library to easily interface with LLM API providers" readme = "README.md" requires-python = ">=3.10, <3.14" @@ -63,7 +63,7 @@ proxy = [ "azure-storage-blob>=12.28.0,<13.0", "mcp>=1.26.0,<2.0", "litellm-proxy-extras==0.4.75", - "litellm-enterprise==0.1.47", + "litellm-enterprise==0.1.48", "RestrictedPython>=8.1,<9.0", "rich>=13.9.4,<14.0", "polars>=1.38.1,<2.0", @@ -279,7 +279,7 @@ members = ["enterprise", "litellm-proxy-extras"] profile = "black" [tool.commitizen] -version = "1.92.0" +version = "1.93.0" version_files = [ "pyproject.toml:^version", ] diff --git a/terraform/provider/.gitignore b/terraform/provider/.gitignore new file mode 100644 index 00000000000..7606b250a4c --- /dev/null +++ b/terraform/provider/.gitignore @@ -0,0 +1,71 @@ +# Local .terraform directories +**/.terraform/* +test_litellm/* + +# .tfstate files +*.tfstate +*.tfstate.* + +# Crash log files +crash.log +crash.*.log + +# Exclude all .tfvars files, which are likely to contain sensitive data +*.tfvars +!*.tfvars.example + +# Ignore override files as they are usually used to override resources locally +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Ignore CLI configuration files +.terraformrc +terraform.rc + +# Binary files +terraform-provider-litellm + +# IDE and editor files +.idea/ +*.swp +*.swo +.vscode/ +*.sublime-workspace +*.sublime-project + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Go specific +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +go.work + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a + +# Log files +*.log + +# Environment files +.env diff --git a/terraform/provider/.goreleaser.yml b/terraform/provider/.goreleaser.yml new file mode 100644 index 00000000000..f41a29406b8 --- /dev/null +++ b/terraform/provider/.goreleaser.yml @@ -0,0 +1,81 @@ +# Visit https://goreleaser.com for documentation on how to customize this +# behavior. +version: 2 +before: + hooks: + # this is just an example and not a requirement for provider building/publishing + - go mod tidy +builds: +- env: + # goreleaser does not work with CGO, it could also complicate + # usage by users in CI/CD systems like HCP Terraform where + # they are unable to install libraries. + - CGO_ENABLED=0 + mod_timestamp: '{{ .CommitTimestamp }}' + flags: + - -trimpath + ldflags: + - '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}' + goos: + - freebsd + - windows + - linux + - darwin + goarch: + - amd64 + - '386' + - arm + - arm64 + ignore: + # macOS doesn't support 32-bit anymore + - goos: darwin + goarch: '386' + # Windows ARM is uncommon for Terraform usage + - goos: windows + goarch: arm + - goos: windows + goarch: arm64 + # FreeBSD ARM is rarely used + - goos: freebsd + goarch: arm + - goos: freebsd + goarch: arm64 + # This builds the following key targets for Terraform users: + # - linux/amd64 (most common CI/CD) + # - linux/arm64 (Graviton, ARM-based CI) + # - linux/386 (legacy 32-bit systems) + # - linux/arm (Raspberry Pi, etc.) + # - darwin/amd64 (Intel Macs) + # - darwin/arm64 (Apple Silicon Macs) + # - windows/amd64 (Windows desktops) + # - freebsd/amd64, freebsd/386 (FreeBSD servers) + binary: '{{ .ProjectName }}_v{{ .Version }}' +archives: +- format: zip + name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}' +checksum: + extra_files: + - glob: 'terraform-registry-manifest.json' + name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json' + name_template: '{{ .ProjectName }}_{{ .Version }}_SHA256SUMS' + algorithm: sha256 +signs: + - artifacts: checksum + args: + # if you are using this in a GitHub action or some other automated pipeline, you + # need to pass the batch flag to indicate its not interactive. + - "--batch" + - "--local-user" + - "{{ .Env.GPG_FINGERPRINT }}" # set this environment variable for your signing key + - "--output" + - "${signature}" + - "--detach-sign" + - "${artifact}" +release: + extra_files: + - glob: 'terraform-registry-manifest.json' + name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json' + # If you want to manually examine the release before its live, uncomment this line: + # draft: true +changelog: + disable: true \ No newline at end of file diff --git a/terraform/provider/CHANGELOG.md b/terraform/provider/CHANGELOG.md new file mode 100644 index 00000000000..101519c0b08 --- /dev/null +++ b/terraform/provider/CHANGELOG.md @@ -0,0 +1,294 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Fixed + +- **organization**: Send `PATCH` instead of `POST` to `/organization/update` and `/organization/member_update`, matching the methods the LiteLLM proxy serves; organization and organization member updates previously failed with a 405 + +### Changed + +- The provider source of truth moved to `terraform/provider/` in [BerriAI/litellm](https://github.com/BerriAI/litellm); this repository is now a release mirror. CI in the monorepo statically audits every endpoint the provider calls against the proxy's OpenAPI schema on every change + +## [0.2.2] - 2026-05-13 + +### Fixed + +- **key**: Include `tags` in `UpdateKey` payload so tag changes on an existing `litellm_key` are applied on update instead of being silently dropped (#41) + +## [0.2.1] - 2026-04-13 + +### Fixed + +- **team, organization**: Use pointer types for `tpm_limit`, `rpm_limit`, and `max_budget` to prevent zero-value diffs on every `terraform plan` when these fields are not configured (#31) + +## [0.2.0] - 2026-04-03 + +### ⚠️ Breaking Changes + +#### `litellm_key`: API keys are no longer stored in Terraform state + +**Why this change?** Storing raw API keys in Terraform state is a security risk — state files are often stored in S3, Terraform Cloud, or other backends where the key could be exposed even with encryption at rest. This release eliminates that risk entirely. + +**What changed:** +- The `key` attribute is now **write-only** — available during `terraform apply` so you can pipe it to a secrets manager, but never persisted to state +- The resource ID has changed from the raw key value to its **SHA-256 hash (`token_id`)** — safe to store in state, cannot be used to authenticate +- **Requires Terraform 1.11+** + +**Migration steps for existing `litellm_key` resources:** + +1. Find the `token_id` for each key via the LiteLLM UI or `GET /key/info?key=` +2. Remove the old resource from state: + ``` + terraform state rm litellm_key.example + ``` +3. Re-import using the token_id: + ``` + terraform import litellm_key.example + ``` + +> ⚠️ After upgrading, you cannot retrieve the raw key from state. Make sure you have the key value stored somewhere safe before migrating, or plan to rotate the key after re-import. + +**Security best practice:** Since the key is only available during the initial `terraform apply`, pipe it directly to a secrets manager: + +```hcl +resource "aws_ssm_parameter" "litellm_key" { + name = "/myapp/litellm-key" + type = "SecureString" + value = litellm_key.example.key +} +``` + +### Fixed + +- **key**: API key is no longer stored in Terraform state. The `key` attribute is now write-only and `token_id` is used as the resource ID (#27) +- **model**: Handle eventual consistency in model reads post-create (#26) + +## [0.1.2] - 2026-02-17 + +### Added +- **Documentation**: Added RELEASING.md with comprehensive release process documentation + - GPG key setup instructions + - Step-by-step release workflow + - Troubleshooting guide + - Security best practices + +## [0.1.1] - 2026-02-11 + +### Added +- **New Model Modes**: Added support for `audio_speech` and `rerank` model modes + - `audio_speech`: For text-to-speech models (e.g., Gemini TTS, OpenAI TTS) + - `rerank`: For reranking/semantic ranking models (e.g., Cohere Rerank, Vertex AI Semantic Ranker) + +### Fixed +- Implemented exponential backoff for credential reads +- Only include cost fields when explicitly set in model resource +- Added litellm_credential_name support + +## [0.3.14] - 2025-08-24 + +### Added +- **Enhanced JSON Parsing**: Added support for JSON string parsing in `additional_litellm_params` + - JSON objects and arrays (starting with `{` or `[`) are now automatically parsed + - Maintains backward compatibility with existing string-to-type conversion + - Enables complex nested parameter configurations +- **Parameter Dropping Feature**: Added `additional_drop_params` special parameter + - Allows removal of unwanted parameters from final `litellm_params` before API submission + - Specified as JSON array string: `"additional_drop_params" = "[\"reasoningEffort\"]"` + - Useful for overriding or removing built-in parameters when needed +- **Enhanced Examples**: Updated `examples/model_additional_params.tf` with comprehensive JSON parsing examples + - Demonstrates all supported value types (boolean, integer, float, string, JSON objects/arrays) + - Includes real-world Azure model configuration with parameter dropping + - Shows both simple and complex use cases + +### Changed +- **Documentation Enhancement**: Updated `docs/resources/model.md` with detailed JSON parsing documentation + - Added comprehensive explanation of conversion rules and behavior + - Included special `additional_drop_params` parameter documentation + - Enhanced examples showing all supported parameter types and JSON parsing capabilities + +### Technical Details +- Enhanced parameter processing logic in `createOrUpdateModel()` function +- Added JSON detection and parsing for string values starting with `[` or `{` +- Implemented parameter filtering system for `additional_drop_params` +- Maintains full backward compatibility with existing configurations + +## [0.3.13] - 2025-08-24 + +### Changed +- Documentation: Performed a documentation audit and improvements across resources and data-sources. Added missing argument references, clarified types/defaults, documented implementation behaviors (e.g., additional_litellm_params parsing and state-preservation), and added an `examples/` directory with runnable HCL examples (starting with `examples/model_additional_params.tf`). +- Docs: Updated `docs/resources/model.md` with missing fields (`vertex_*`, pixel/second cost fields, and `additional_litellm_params`) and added conversion rules and an example. +- Docs Index: Added references to the new `examples/` directory in `docs/index.md`. + +## [0.3.12] - 2025-08-13 + +### Added +- **New AWS Parameters**: Added `aws_session_name` and `aws_role_name` to model resource for cross-account access scenarios + - Support for AWS session names in cross-account access configurations + - Support for AWS IAM role names for cross-account access + - Enhanced AWS Bedrock integration capabilities + +### Changed +- **Documentation Overhaul**: Comprehensive update to all provider documentation + - Updated provider source references from `bitop/litellm` to `registry.terraform.io/ncecere/litellm` + - Consolidated all scattered example files into organized documentation structure + - Enhanced all resource documentation with multiple real-world examples + - Added comprehensive cross-resource integration examples +- **Vector Store Documentation**: Updated to reflect only officially supported LiteLLM providers + - Removed unsupported providers (Pinecone, Weaviate, Chroma, Qdrant, Milvus, FAISS) + - Added accurate examples for supported providers: AWS Bedrock Knowledge Bases, OpenAI Vector Stores, Azure Vector Stores, Vertex AI RAG Engine, PG Vector + - Updated provider-specific parameters with correct configurations + - Added references to official LiteLLM documentation +- **Project Organization**: Cleaned up project structure + - Removed scattered example files from root directory + - Consolidated all examples into comprehensive documentation + - Updated README.md to reflect current capabilities and structure + +### Fixed +- Corrected vector store provider documentation to match LiteLLM's official capabilities +- Updated all documentation links and references for accuracy + +## [0.3.11] - 2025-08-10 + +### Added +- **New Resource**: `litellm_credential` - Manage credentials for secure authentication + - Support for storing sensitive credential values (API keys, tokens, etc.) + - Non-sensitive credential information storage + - Model ID association for credentials + - Secure handling of sensitive data with Terraform's sensitive attribute +- **New Resource**: `litellm_vector_store` - Manage vector stores for embeddings and RAG + - Support for multiple vector store providers (Pinecone, Weaviate, Chroma, Qdrant, etc.) + - Integration with credential management for secure authentication + - Configurable metadata and provider-specific parameters + - Full CRUD operations for vector store lifecycle management +- **New Data Source**: `litellm_credential` - Retrieve information about existing credentials + - Read-only access to credential metadata (sensitive values excluded for security) + - Support for model ID filtering + - Cross-stack and cross-configuration referencing capabilities +- **New Data Source**: `litellm_vector_store` - Retrieve information about existing vector stores + - Complete vector store information retrieval + - Support for monitoring, validation, and cross-referencing use cases + - Metadata-based conditional logic support +- Enhanced API response handling for credential and vector store operations +- Comprehensive documentation and examples for new resources and data sources +- Example Terraform configurations for common use cases + +### Changed +- Extended `utils.go` with specialized API response handlers for credentials and vector stores +- Updated provider configuration to include new resources and data sources +- Enhanced error handling for credential and vector store not found scenarios + +## [0.3.10] - 2025-08-10 + +### Added +- **New Resource**: `litellm_mcp_server` - Manage MCP (Model Context Protocol) servers + - Support for HTTP, SSE, and stdio transport types + - Configurable authentication types (none, bearer, basic) + - MCP access groups for permission management + - Cost tracking configuration for MCP tools + - Environment variables and command arguments for stdio transport + - Health check status monitoring + - Comprehensive documentation and examples + +### Changed +- Updated provider to support MCP server management functionality +- Enhanced API response handling for MCP-specific operations + +## [0.3.9] - 2025-08-10 + +### Fixed +- Fixed issue where omitting `budget_duration` in key resource caused API error "Invalid duration format" +- Added missing `omitempty` JSON tag to `BudgetDuration` field in Key struct to prevent sending empty strings to API + +## [0.3.8] - 2025-08-08 + +### Added +- Added `additional_litellm_params` field to model resource for custom parameters beyond standard ones +- Support for passing custom parameters like `drop_params`, `timeout`, `max_retries`, `organization`, etc. +- Automatic type conversion for string values to appropriate types (boolean, integer, float) +- Full backward compatibility with existing model configurations +- Comprehensive example demonstrating various use cases with different providers + +## [0.3.7] - 2025-08-08 + +### Fixed +- Fixed issue where changing max_budget_in_team didn't update existing team members with new budget +- Added budget change detection using d.HasChange to update ALL existing members when budget changes +- Implemented tracking to avoid duplicate API calls for members already updated +- Enhanced debug logging for budget update operations + +## [0.3.6] - 2025-08-08 + +### Fixed +- Fixed issue where models deleted from LiteLLM proxy caused terraform plan to fail instead of planning recreation +- Enhanced ErrorResponse struct to properly parse LiteLLM proxy error format with Detail field +- Improved isModelNotFoundError function to detect "not found on litellm proxy" messages in Detail.Error field + +## [0.3.5] - 2025-08-08 + +### Fixed +- Fixed team member update behavior to use member_update endpoint instead of delete/re-add +- Restored team_member_permissions functionality to litellm_team resource +- Enhanced team resource with proper permissions management endpoints + +## [0.3.0] - 2025-04-23 + +### Fixed +- Implemented retry mechanism with exponential backoff for model read operations +- Added detailed logging for retry attempts +- Improved error handling for "model not found" errors + +## [0.2.9] - 2025-04-23 + +### Fixed +- Increased delay after model creation from 2 to 5 seconds to fix "model not found" errors +- Added logging to confirm delay is working properly + +## [0.2.8] - 2025-04-23 + +### Fixed +- Added delay after model creation to fix "model not found" errors when the LiteLLM proxy hasn't fully registered the model yet + +## [0.2.7] - 2025-04-23 + +### Fixed +- Fixed issue where `thinking_enabled` and `merge_reasoning_content_in_choices` values were not being preserved in state, causing Terraform to want to modify them on every run + +## [0.2.6] - 2025-03-13 + +### Added +- Added new `merge_reasoning_content_in_choices` option to model resource + +## [0.2.5] - 2025-03-13 + +### Fixed +- Fixed issue where `thinking_budget_tokens` was being added to models that don't have `thinking_enabled = true` + +## [0.2.4] - 2025-03-13 + +### Added +- Added new `thinking` capability to model resource with configurable parameters: + - `thinking_enabled` - Boolean to enable/disable thinking capability (default: false) + - `thinking_budget_tokens` - Integer to set token budget for thinking (default: 1024) + +## [0.2.2] - 2025-02-06 + +### Added +- Added new `reasoning_effort` parameter to model resource with values: "low", "medium", "high" +- Added "chat" mode to model resource + +### Changed +- Updated model mode options to: "completion", "embedding", "image_generation", "chat", "moderation", "audio_transcription" + +## [1.0.0] - 2024-01-17 + +### Added +- Initial release of the LiteLLM Terraform Provider +- Support for managing LiteLLM models +- Support for managing teams and team members +- Comprehensive documentation for all resources diff --git a/terraform/provider/LICENSE b/terraform/provider/LICENSE new file mode 100644 index 00000000000..967d4ac9b42 --- /dev/null +++ b/terraform/provider/LICENSE @@ -0,0 +1,35 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source diff --git a/terraform/provider/Makefile b/terraform/provider/Makefile new file mode 100644 index 00000000000..ddca16e1636 --- /dev/null +++ b/terraform/provider/Makefile @@ -0,0 +1,32 @@ +HOSTNAME=registry.terraform.io +NAMESPACE=local +NAME=litellm +VERSION=1.0.0 +OS_ARCH=darwin_amd64 + +default: install + +build: + go build -o terraform-provider-${NAME} + +install: build + mkdir -p ~/.terraform.d/plugins/${HOSTNAME}/${NAMESPACE}/${NAME}/${VERSION}/${OS_ARCH} + mv terraform-provider-${NAME} ~/.terraform.d/plugins/${HOSTNAME}/${NAMESPACE}/${NAME}/${VERSION}/${OS_ARCH}/terraform-provider-${NAME}_v${VERSION} + +test: + go test ./... + +fmt: + go fmt ./... + +vet: + go vet ./... + +lint: + golangci-lint run + +clean: + rm -f terraform-provider-${NAME} + rm -rf ~/.terraform.d/plugins/${HOSTNAME}/${NAMESPACE}/${NAME}/${VERSION} + +.PHONY: build install test fmt vet lint clean diff --git a/terraform/provider/README.md b/terraform/provider/README.md new file mode 100644 index 00000000000..3b59edd97c6 --- /dev/null +++ b/terraform/provider/README.md @@ -0,0 +1,223 @@ +# LiteLLM Terraform Provider + +This Terraform provider allows you to manage LiteLLM resources through Infrastructure as Code. It provides support for managing models, teams, team members, and API keys via the LiteLLM REST API. + +## Source of truth + +This directory (`terraform/provider/` in [BerriAI/litellm](https://github.com/BerriAI/litellm)) is the source of truth for the provider. [BerriAI/terraform-provider-litellm](https://github.com/BerriAI/terraform-provider-litellm) is a thin release mirror that the public Terraform Registry ingests from; do not open PRs there. Changes land here, where CI builds the provider, runs its tests, and statically audits every endpoint the provider calls against the proxy's generated OpenAPI schema (`tools/endpointaudit/`), so the provider cannot drift from the LiteLLM API silently. Releases are published by mirroring this directory into the split repo and tagging it, which triggers the goreleaser workflow there (see `RELEASING.md`) + +## Features + +- Manage LiteLLM model configurations +- Associate models with specific teams +- Create and manage teams +- Configure team members and their permissions +- Set usage limits and budgets +- Control access to specific models +- Specify model modes (e.g., completion, embedding, image generation) +- Manage API keys with fine-grained controls +- Support for reasoning effort configuration in the model resource + +## Requirements + +- [Terraform](https://www.terraform.io/downloads.html) >= 0.13.x +- [Go](https://golang.org/doc/install) >= 1.16 (for development) + +## Using the Provider + +To use the LiteLLM provider in your Terraform configuration, you need to declare it in the terraform block: + +```hcl +terraform { + required_providers { + litellm = { + source = "BerriAI/litellm" + version = "~> 0.1.1" #HERE UPDATE VERSION ACCORDINGLY + } + } +} + +provider "litellm" { + api_base = var.litellm_api_base + api_key = var.litellm_api_key +} +``` + +Then, you can use the provider to manage LiteLLM resources. Here's an example of creating a model configuration: + +```hcl +resource "litellm_model" "gpt4" { + model_name = "gpt-4-proxy" + custom_llm_provider = "openai" + model_api_key = var.openai_api_key + model_api_base = "https://api.openai.com/v1" + base_model = "gpt-4" + tier = "paid" + mode = "chat" + reasoning_effort = "medium" # Optional: "low", "medium", or "high" + + input_cost_per_million_tokens = 30.0 + output_cost_per_million_tokens = 60.0 +} +``` + +For full details on the litellm_model resource, see the [model resource documentation](docs/resources/model.md). + +Here's an example of creating an API key with various options: + +```hcl +resource "litellm_key" "example_key" { + models = ["gpt-4", "claude-3.5-sonnet"] + max_budget = 100.0 + user_id = "user123" + team_id = "team456" + max_parallel_requests = 5 + tpm_limit = 1000 + rpm_limit = 60 + budget_duration = "monthly" + key_alias = "prod-key-1" + duration = "30d" + metadata = { + environment = "production" + } + allowed_cache_controls = ["no-cache", "max-age=3600"] + soft_budget = 80.0 + aliases = { + "gpt-4" = "gpt4" + } + config = { + default_model = "gpt-4" + } + permissions = { + can_create_keys = "true" + } + model_max_budget = { + "gpt-4" = 50.0 + } + model_rpm_limit = { + "claude-3.5-sonnet" = 30 + } + model_tpm_limit = { + "gpt-4" = 500 + } + guardrails = ["content_filter", "token_limit"] + blocked = false + tags = ["production", "api"] +} +``` + +The litellm_key resource supports the following options: + +- models: List of allowed models for this key +- max_budget: Maximum budget for the key +- user_id and team_id: Associate the key with a user and team +- max_parallel_requests: Limit concurrent requests +- tpm_limit and rpm_limit: Set tokens and requests per minute limits +- budget_duration: Specify budget duration (e.g., "monthly", "weekly") +- key_alias: Set a friendly name for the key +- duration: Set the key's validity period +- metadata: Add custom metadata to the key +- allowed_cache_controls: Specify allowed cache control directives +- soft_budget: Set a soft budget limit +- aliases: Define model aliases +- config: Set configuration options +- permissions: Specify key permissions +- model_max_budget, model_rpm_limit, model_tpm_limit: Set per-model limits +- guardrails: Apply specific guardrails to the key +- blocked: Flag to block/unblock the key +- tags: Add tags for organization and filtering + +For full details on the litellm_key resource, see the [key resource documentation](docs/resources/key.md). + +### Available Resources + +- litellm_model: Manage model configurations. [Documentation](docs/resources/model.md) +- litellm_team: Manage teams. [Documentation](docs/resources/team.md) +- litellm_team_member: Manage team members. [Documentation](docs/resources/team_member.md) +- litellm_team_member_add: Add multiple members to teams. [Documentation](docs/resources/team_member_add.md) +- litellm_key: Manage API keys. [Documentation](docs/resources/key.md) +- litellm_mcp_server: Manage MCP (Model Context Protocol) servers. [Documentation](docs/resources/mcp_server.md) +- litellm_credential: Manage credentials for secure authentication. [Documentation](docs/resources/credential.md) +- litellm_vector_store: Manage vector stores for embeddings and RAG. [Documentation](docs/resources/vector_store.md) + +### Available Data Sources + +- litellm_credential: Retrieve information about existing credentials. [Documentation](docs/data-sources/credential.md) +- litellm_vector_store: Retrieve information about existing vector stores. [Documentation](docs/data-sources/vector_store.md) + +## Development + +### Project Structure + +The project is organized as follows: + +``` +terraform-provider-litellm/ +├── litellm/ +│ ├── provider.go +│ ├── resource_model.go +│ ├── resource_model_crud.go +│ ├── resource_team.go +│ ├── resource_team_member.go +│ ├── resource_key.go +│ ├── resource_key_utils.go +│ ├── types.go +│ └── utils.go +├── main.go +├── go.mod +├── go.sum +├── Makefile +└── ... +``` + +### Building the Provider + +1. Clone the repository: +```sh +git clone https://github.com/your-username/terraform-provider-litellm.git +``` + +2. Enter the repository directory: +```sh +cd terraform-provider-litellm +``` + +3. Build and install the provider: +```sh +make install +``` + +### Development Commands + +The Makefile provides several useful commands for development: + +- `make build`: Builds the provider +- `make install`: Builds and installs the provider +- `make test`: Runs the test suite +- `make fmt`: Formats the code +- `make vet`: Runs go vet +- `make lint`: Runs golangci-lint +- `make clean`: Removes build artifacts and installed provider + +### Testing + +To run the tests: +```sh +make test +``` + +### Contributing + +Contributions are welcome! Please read our [contributing guidelines](CONTRIBUTING.md) first. + +## License + +This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. + +## Notes + +- Always use environment variables or secure secret management solutions to handle sensitive information like API keys and AWS credentials. +- Refer to the comprehensive documentation in the `docs/` directory for detailed usage examples and configuration options. +- Make sure to keep your provider version updated for the latest features and bug fixes. +- The provider now supports AWS cross-account access with `aws_session_name` and `aws_role_name` parameters in the model resource. +- All example configurations have been consolidated into the documentation for better organization and maintenance. diff --git a/terraform/provider/RELEASING.md b/terraform/provider/RELEASING.md new file mode 100644 index 00000000000..1dc296f29b8 --- /dev/null +++ b/terraform/provider/RELEASING.md @@ -0,0 +1,237 @@ +# Release Process + +This document describes the release process for the LiteLLM Terraform Provider. + +## Overview + +Releases are automated via GitHub Actions when a version tag is pushed. The workflow builds the provider for multiple platforms, signs the artifacts with GPG, and publishes them to GitHub Releases. + +## Prerequisites + +### GPG Key Setup (One-Time Setup for Repository Maintainers) + +The Terraform Registry requires all providers to be signed with a GPG key. This must be configured before the first release. + +#### 1. Generate a GPG Key + +If you don't already have a GPG key for provider signing: + +```bash +gpg --full-generate-key +``` + +Configuration: +- Key type: RSA and RSA (default) +- Key size: 4096 bits +- Expiration: No expiration (or set a long expiration period) +- Email: Use an email associated with your GitHub account +- Set a strong passphrase (or leave empty for CI/CD use) + +#### 2. Export the GPG Key + +```bash +# List your keys to get the key ID +gpg --list-secret-keys --keyid-format=long + +# Example output: +# sec rsa4096/ABCD1234EFGH5678 2024-01-01 [SC] +# 1234567890ABCDEF1234567890ABCDEF12345678 +# uid [ultimate] Your Name +# +# The key ID is: ABCD1234EFGH5678 +# The fingerprint is: 1234567890ABCDEF1234567890ABCDEF12345678 + +# Export the private key (ASCII-armored format) +gpg --armor --export-secret-keys ABCD1234EFGH5678 + +# Export the public key +gpg --armor --export ABCD1234EFGH5678 +``` + +#### 3. Configure GitHub Repository Secrets + +Add the following secrets to the repository at: **Settings → Secrets and variables → Actions → New repository secret** + +| Secret Name | Description | Value | +|-------------|-------------|-------| +| `GPG_PRIVATE_KEY` | The GPG private key for signing releases | Full output from `gpg --armor --export-secret-keys` (including `-----BEGIN PGP PRIVATE KEY BLOCK-----` and `-----END PGP PRIVATE KEY BLOCK-----`) | +| `PASSPHRASE` | The passphrase for the GPG key | Your GPG key passphrase (leave empty if no passphrase was set) | + +#### 4. Register Public Key with Terraform Registry + +Before publishing to the Terraform Registry: + +1. Go to https://registry.terraform.io/settings/gpg-keys +2. Click "Add a key" +3. Paste your public GPG key (output from `gpg --armor --export`) +4. Submit + +**Note**: The public key fingerprint must match the key used to sign the provider releases. + +## Release Steps + +### 1. Prepare the Release + +Before creating a release: + +1. **Update CHANGELOG.md** + - Move items from `[Unreleased]` section to a new version section + - Follow [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format + - Use [Semantic Versioning](https://semver.org/spec/v2.0.0.html) for version numbers + - Include all notable changes since the last release + + Example: + ```markdown + ## [0.1.2] - 2026-02-20 + + ### Added + - New feature description + + ### Fixed + - Bug fix description + + ### Changed + - Changed behavior description + ``` + +2. **Verify tests pass** + ```bash + make test + ``` + +3. **Verify the build works locally** + ```bash + make build + ``` + +4. **Land the changes in BerriAI/litellm** + + Open a PR to `BerriAI/litellm` updating `terraform/provider/CHANGELOG.md` (and any source changes) and merge it. Note the merge commit SHA; the release workflow takes it as `git_ref` + +### 2. Mirror and Tag via project-releaser + +The provider source lives at `terraform/provider/` in `BerriAI/litellm`; `BerriAI/terraform-provider-litellm` is a thin release mirror. Do not commit or tag the mirror directly + +1. Go to `BerriAI/project-releaser` > **Actions** > `Publish Terraform provider` +2. Click **Run workflow**: + - `git_ref`: full 40-char commit SHA from `BerriAI/litellm` to release from + - `provider_version`: the new version without the `v` prefix (e.g. `0.3.0`) + - `dry_run`: optional; validates without pushing +3. The workflow rsyncs `terraform/provider/` into the mirror repo, commits, and pushes tag `v` +4. The tag push triggers the mirror's `Release` workflow (goreleaser), which is gated by the `production-release` environment approval + +**Important**: +- Tags must follow the format: `v..` (e.g., `v0.1.2`, `v1.0.0`) +- The workflow refuses to overwrite an existing tag; publish a new version instead + +### 3. Monitor the Release Workflow + +1. Go to: https://github.com/BerriAI/terraform-provider-litellm/actions +2. Find the "Release" workflow run for your tag +3. Monitor the progress and check for any errors + +The workflow will: +- Check out the code +- Set up Go +- Import the GPG key +- Run `go mod tidy` +- Build binaries for multiple platforms (Linux, macOS, Windows, FreeBSD) +- Create archives and checksums +- Sign the checksums with GPG +- Create a GitHub release +- Upload all artifacts + +### 4. Verify the Release + +After the workflow completes successfully: + +1. **Check the GitHub Release** + - Go to: https://github.com/BerriAI/terraform-provider-litellm/releases + - Verify the release was created with the correct version + - Confirm all artifacts are present: + - Binary archives for each platform + - SHA256SUMS file + - SHA256SUMS.sig (GPG signature) + - terraform-registry-manifest.json + +2. **Verify the signature** (optional) + ```bash + # Download the checksums and signature + wget https://github.com/BerriAI/terraform-provider-litellm/releases/download/v0.1.2/terraform-provider-litellm_0.1.2_SHA256SUMS + wget https://github.com/BerriAI/terraform-provider-litellm/releases/download/v0.1.2/terraform-provider-litellm_0.1.2_SHA256SUMS.sig + + # Verify the signature + gpg --verify terraform-provider-litellm_0.1.2_SHA256SUMS.sig terraform-provider-litellm_0.1.2_SHA256SUMS + ``` + +### 5. Publish to Terraform Registry (Optional) + +If this provider is published to the Terraform Registry: + +1. The registry should automatically detect the new release via the GitHub webhook +2. If not, you may need to manually trigger a sync on the Terraform Registry dashboard +3. Verify the new version appears at: https://registry.terraform.io/providers/BerriAI/litellm/latest + +## Troubleshooting + +### Release Workflow Fails with GPG Error + +**Error**: `Input required and not supplied: gpg_private_key` + +**Solution**: +- Verify that `GPG_PRIVATE_KEY` and `PASSPHRASE` secrets are configured in the repository +- Ensure the secrets are not expired +- Check that the secret names match exactly (case-sensitive) + +### GoReleaser Signing Fails + +**Error**: `gpg: signing failed: No secret key` + +**Solution**: +- Verify the `GPG_PRIVATE_KEY` secret contains the complete private key block +- Ensure the passphrase is correct +- Check that the key hasn't expired: `gpg --list-keys` + +### Build Fails + +**Error**: Build errors during compilation + +**Solution**: +- Run `make test` and `make build` locally first +- Ensure `go.mod` and `go.sum` are up to date +- Check that all dependencies are available + +### Tag Already Exists + +**Error**: The publish workflow refuses to push because the tag already exists on the mirror + +**Solution**: Tags are immutable by design. Re-run the workflow with a new patch version instead of deleting or moving an existing tag + +## Version Numbering + +This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html): + +- **MAJOR** version (1.0.0): Incompatible API changes +- **MINOR** version (0.1.0): New functionality in a backward-compatible manner +- **PATCH** version (0.0.1): Backward-compatible bug fixes + +For pre-1.0 releases: +- Breaking changes may occur in minor versions +- Patch versions should only contain bug fixes + +## Security Considerations + +1. **Never commit private keys**: The GPG private key should only be stored as a GitHub secret +2. **Protect repository secrets**: Limit who has access to manage repository secrets +3. **Use a dedicated key**: Consider using a separate GPG key specifically for provider signing +4. **Key rotation**: If the GPG key is compromised, generate a new key, update secrets, and register the new public key with the Terraform Registry +5. **Passphrase**: Use a strong passphrase for the GPG key, or use a passphrase-less key specifically for CI/CD + +## References + +- [GoReleaser Documentation](https://goreleaser.com/) +- [Terraform Provider Publishing](https://www.terraform.io/docs/registry/providers/publishing.html) +- [HashiCorp GPG Signing Requirements](https://www.terraform.io/docs/registry/providers/publishing.html#signing-releases) +- [GitHub Actions Secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) +- [Semantic Versioning](https://semver.org/) +- [Keep a Changelog](https://keepachangelog.com/) diff --git a/terraform/provider/docs/data-sources/credential.md b/terraform/provider/docs/data-sources/credential.md new file mode 100644 index 00000000000..de4b9a8d9e4 --- /dev/null +++ b/terraform/provider/docs/data-sources/credential.md @@ -0,0 +1,153 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "litellm_credential Data Source - terraform-provider-litellm" +subcategory: "" +description: |- + Retrieves information about an existing LiteLLM credential. +--- + +# litellm_credential (Data Source) + +Retrieves information about an existing LiteLLM credential. This data source allows you to reference credentials that were created outside of Terraform or in other Terraform configurations. + +## Example Usage + +```terraform +# Retrieve an existing credential by name +data "litellm_credential" "existing_openai" { + credential_name = "openai-production-key" +} + +# Use the credential in a model resource +resource "litellm_model" "gpt4_with_existing_cred" { + model_name = "gpt-4-with-existing-cred" + custom_llm_provider = "openai" + base_model = "gpt-4" + tier = "paid" + mode = "chat" + + # Reference the existing credential's info + additional_litellm_params = { + credential_name = data.litellm_credential.existing_openai.credential_name + } +} +``` + +## Example Usage with Model ID + +```terraform +# Retrieve a credential associated with a specific model +data "litellm_credential" "model_specific_cred" { + credential_name = "claude-api-key" + model_id = "claude-3-sonnet" +} + +# Use in a vector store +resource "litellm_vector_store" "knowledge_base" { + vector_store_name = "claude-knowledge-base" + custom_llm_provider = "anthropic" + litellm_credential_name = data.litellm_credential.model_specific_cred.credential_name + + vector_store_description = "Knowledge base using Claude credentials" +} +``` + +## Example Usage for Cross-Reference + +```terraform +# Get credential info to use in other resources +data "litellm_credential" "shared_cred" { + credential_name = "shared-api-key" +} + +# Create multiple resources using the same credential +resource "litellm_vector_store" "store_1" { + vector_store_name = "store-1" + custom_llm_provider = "pinecone" + litellm_credential_name = data.litellm_credential.shared_cred.credential_name + + vector_store_description = "First store using shared credential" +} + +resource "litellm_vector_store" "store_2" { + vector_store_name = "store-2" + custom_llm_provider = "pinecone" + litellm_credential_name = data.litellm_credential.shared_cred.credential_name + + vector_store_description = "Second store using shared credential" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `credential_name` - (Required) Name of the credential to retrieve. +* `model_id` - (Optional) Model ID associated with this credential. Use this when the same credential name is used for different models. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `credential_info` - Map of additional non-sensitive information about the credential. + +## Security Note + +For security reasons, the `credential_values` (sensitive data like API keys) are not exposed through data sources. This prevents accidental exposure of sensitive information in Terraform plans and logs. If you need to access credential values, you should manage them through the resource directly or use external secret management systems. + +## Common Use Cases + +### 1. Cross-Stack References +Use data sources to reference credentials created in other Terraform configurations or stacks: + +```terraform +data "litellm_credential" "shared_openai" { + credential_name = "openai-shared-key" +} + +resource "litellm_model" "gpt4" { + model_name = "gpt-4-cross-stack" + custom_llm_provider = "openai" + base_model = "gpt-4" + + additional_litellm_params = { + credential_reference = data.litellm_credential.shared_openai.credential_name + } +} +``` + +### 2. Conditional Logic +Use credential information for conditional resource creation: + +```terraform +data "litellm_credential" "optional_cred" { + credential_name = var.credential_name +} + +resource "litellm_vector_store" "conditional_store" { + count = length(data.litellm_credential.optional_cred.credential_info) > 0 ? 1 : 0 + + vector_store_name = "conditional-store" + custom_llm_provider = "weaviate" + litellm_credential_name = data.litellm_credential.optional_cred.credential_name +} +``` + +### 3. Validation and Verification +Verify that required credentials exist before creating dependent resources: + +```terraform +data "litellm_credential" "required_cred" { + credential_name = "production-api-key" +} + +# This will fail if the credential doesn't exist +resource "litellm_model" "production_model" { + model_name = "production-gpt-4" + custom_llm_provider = "openai" + base_model = "gpt-4" + + additional_litellm_params = { + credential_name = data.litellm_credential.required_cred.credential_name + } +} diff --git a/terraform/provider/docs/data-sources/vector_store.md b/terraform/provider/docs/data-sources/vector_store.md new file mode 100644 index 00000000000..30bc26c163e --- /dev/null +++ b/terraform/provider/docs/data-sources/vector_store.md @@ -0,0 +1,225 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "litellm_vector_store Data Source - terraform-provider-litellm" +subcategory: "" +description: |- + Retrieves information about an existing LiteLLM vector store. +--- + +# litellm_vector_store (Data Source) + +Retrieves information about an existing LiteLLM vector store. This data source allows you to reference vector stores that were created outside of Terraform or in other Terraform configurations. + +## Example Usage + +```terraform +# Retrieve an existing vector store by ID +data "litellm_vector_store" "existing_store" { + vector_store_id = "vs-12345" +} + +# Use the vector store information in outputs +output "vector_store_info" { + value = { + name = data.litellm_vector_store.existing_store.vector_store_name + provider = data.litellm_vector_store.existing_store.custom_llm_provider + created_at = data.litellm_vector_store.existing_store.created_at + } +} +``` + +## Example Usage for Cross-Reference + +```terraform +# Get vector store info to reference in other configurations +data "litellm_vector_store" "shared_store" { + vector_store_id = var.shared_vector_store_id +} + +# Create a model that might use the same credential as the vector store +data "litellm_credential" "store_credential" { + credential_name = data.litellm_vector_store.shared_store.litellm_credential_name +} + +resource "litellm_model" "embedding_model" { + model_name = "embedding-model" + custom_llm_provider = "openai" + base_model = "text-embedding-ada-002" + mode = "embedding" + + additional_litellm_params = { + credential_name = data.litellm_credential.store_credential.credential_name + } +} +``` + +## Example Usage for Validation + +```terraform +# Verify vector store exists and get its configuration +data "litellm_vector_store" "production_store" { + vector_store_id = "production-vector-store-id" +} + +# Create resources only if the vector store is properly configured +resource "litellm_model" "rag_model" { + count = data.litellm_vector_store.production_store.custom_llm_provider == "pinecone" ? 1 : 0 + + model_name = "rag-enabled-model" + custom_llm_provider = "openai" + base_model = "gpt-4" + mode = "chat" + + additional_litellm_params = { + vector_store_id = data.litellm_vector_store.production_store.vector_store_id + } +} +``` + +## Example Usage for Monitoring + +```terraform +# Get vector store details for monitoring and alerting +data "litellm_vector_store" "monitored_stores" { + for_each = toset(var.vector_store_ids) + + vector_store_id = each.value +} + +# Output store information for monitoring systems +output "vector_store_status" { + value = { + for k, v in data.litellm_vector_store.monitored_stores : k => { + name = v.vector_store_name + provider = v.custom_llm_provider + created_at = v.created_at + updated_at = v.updated_at + metadata = v.vector_store_metadata + } + } +} +``` + +## Argument Reference + +The following arguments are supported: + +* `vector_store_id` - (Required) Unique identifier for the vector store to retrieve. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `vector_store_name` - Name of the vector store. +* `custom_llm_provider` - Custom LLM provider for the vector store. +* `vector_store_description` - Description of the vector store. +* `vector_store_metadata` - Map of metadata associated with the vector store. +* `litellm_credential_name` - Name of the LiteLLM credential used. +* `litellm_params` - Map of additional LiteLLM parameters. +* `created_at` - Timestamp when the vector store was created. +* `updated_at` - Timestamp when the vector store was last updated. + +## Common Use Cases + +### 1. Cross-Stack References +Reference vector stores created in other Terraform configurations: + +```terraform +data "litellm_vector_store" "shared_knowledge_base" { + vector_store_id = var.knowledge_base_id +} + +# Use the same credential for consistency +resource "litellm_model" "knowledge_model" { + model_name = "knowledge-retrieval-model" + custom_llm_provider = "openai" + base_model = "gpt-4" + + additional_litellm_params = { + vector_store_credential = data.litellm_vector_store.shared_knowledge_base.litellm_credential_name + } +} +``` + +### 2. Configuration Validation +Validate vector store configuration before creating dependent resources: + +```terraform +data "litellm_vector_store" "target_store" { + vector_store_id = var.target_vector_store_id +} + +# Ensure the vector store uses the expected provider +locals { + is_pinecone_store = data.litellm_vector_store.target_store.custom_llm_provider == "pinecone" +} + +resource "litellm_model" "pinecone_optimized_model" { + count = local.is_pinecone_store ? 1 : 0 + + model_name = "pinecone-optimized" + custom_llm_provider = "openai" + base_model = "text-embedding-ada-002" + mode = "embedding" +} +``` + +### 3. Metadata-Based Logic +Use vector store metadata for conditional resource creation: + +```terraform +data "litellm_vector_store" "environment_store" { + vector_store_id = var.vector_store_id +} + +# Create different resources based on environment metadata +resource "litellm_model" "production_model" { + count = lookup(data.litellm_vector_store.environment_store.vector_store_metadata, "environment", "") == "production" ? 1 : 0 + + model_name = "production-rag-model" + custom_llm_provider = "openai" + base_model = "gpt-4" + mode = "chat" +} + +resource "litellm_model" "development_model" { + count = lookup(data.litellm_vector_store.environment_store.vector_store_metadata, "environment", "") == "development" ? 1 : 0 + + model_name = "development-rag-model" + custom_llm_provider = "openai" + base_model = "gpt-3.5-turbo" + mode = "chat" +} +``` + +### 4. Audit and Compliance +Retrieve vector store information for audit and compliance reporting: + +```terraform +data "litellm_vector_store" "compliance_stores" { + for_each = toset(var.compliance_vector_store_ids) + + vector_store_id = each.value +} + +# Generate compliance report +output "compliance_report" { + value = { + for k, v in data.litellm_vector_store.compliance_stores : k => { + store_name = v.vector_store_name + provider = v.custom_llm_provider + credential = v.litellm_credential_name + created_date = v.created_at + last_updated = v.updated_at + metadata = v.vector_store_metadata + } + } +} +``` + +## Notes + +* Vector store IDs are unique identifiers assigned by the LiteLLM system. +* The data source will fail if the specified vector store ID does not exist. +* All computed attributes reflect the current state of the vector store in the LiteLLM system. +* Use this data source to integrate with existing vector stores or to reference stores created outside of Terraform. diff --git a/terraform/provider/docs/index.md b/terraform/provider/docs/index.md new file mode 100644 index 00000000000..c03071e7ed3 --- /dev/null +++ b/terraform/provider/docs/index.md @@ -0,0 +1,117 @@ +# LiteLLM Provider + +The LiteLLM provider allows Terraform to manage LiteLLM resources. LiteLLM is a proxy service that standardizes the input/output across different LLM APIs, providing a unified interface for various language model providers. + +## Example Usage + +```hcl +terraform { + required_providers { + litellm = { + source = "registry.terraform.io/BerriAI/litellm" + } + } +} + +provider "litellm" { + api_base = "https://your-litellm-proxy.com" + api_key = var.litellm_api_key +} + +# Basic model configuration +resource "litellm_model" "gpt4" { + model_name = "gpt-4-proxy" + custom_llm_provider = "openai" + model_api_key = var.openai_api_key + base_model = "gpt-4" + tier = "paid" + mode = "chat" + + input_cost_per_million_tokens = 30.0 + output_cost_per_million_tokens = 60.0 +} + +# Team configuration +resource "litellm_team" "dev_team" { + team_alias = "development-team" + models = [litellm_model.gpt4.model_name] + max_budget = 100.0 +} +``` + +## Available Resources + +The LiteLLM provider supports the following resources: + +* [`litellm_model`](./resources/model) - Manage LiteLLM model configurations +* [`litellm_team`](./resources/team) - Manage teams and their permissions +* [`litellm_team_member`](./resources/team_member) - Manage team member configurations +* [`litellm_team_member_add`](./resources/team_member_add) - Add members to teams +* [`litellm_key`](./resources/key) - Manage API keys +* [`litellm_mcp_server`](./resources/mcp_server) - Manage MCP (Model Context Protocol) servers +* [`litellm_credential`](./resources/credential) - Manage credentials for various providers +* [`litellm_vector_store`](./resources/vector_store) - Manage vector stores + +## Available Data Sources + +The LiteLLM provider supports the following data sources: + +* [`litellm_credential`](./data-sources/credential) - Retrieve credential information +* [`litellm_vector_store`](./data-sources/vector_store) - Retrieve vector store information + +## Authentication + +The LiteLLM provider requires an API key and base URL for authentication. These can be provided in the provider configuration block or via environment variables. + +### Environment Variables + +- `LITELLM_API_BASE` - The base URL of your LiteLLM instance +- `LITELLM_API_KEY` - Your LiteLLM API key + +### Example with Environment Variables + +```bash +export LITELLM_API_BASE="https://your-litellm-proxy.com" +export LITELLM_API_KEY="your-api-key" +``` + +```hcl +terraform { + required_providers { + litellm = { + source = "registry.terraform.io/BerriAI/litellm" + } + } +} + +# Provider will automatically use environment variables +provider "litellm" {} +``` + +## Provider Arguments + +The following arguments are supported in the provider block: + +* `api_base` - (Required) The base URL of your LiteLLM instance. This can also be provided via the `LITELLM_API_BASE` environment variable. +* `api_key` - (Required) The API key used to authenticate with LiteLLM. This can also be provided via the `LITELLM_API_KEY` environment variable. + +## Getting Started + +1. Install the provider by adding it to your Terraform configuration +2. Configure your LiteLLM instance URL and API key +3. Start creating resources like models, teams, and credentials +4. Use data sources to reference existing configurations + +For detailed examples and configuration options, see the individual resource and data source documentation pages. + +## Examples + +This repository includes an `examples/` directory with curated, ready-to-run HCL examples that demonstrate common and advanced usages of the provider. Examples are grouped by resource and illustrate provider-specific configuration, handling of sensitive values, and advanced options such as `additional_litellm_params`. + +See: +* `examples/model_additional_params.tf` — demonstrates how to use `additional_litellm_params` (booleans, integers, floats, and strings). +* Other example files will be added to `examples/` for credentials, vector stores, and MCP servers. + +You can reference these examples directly or copy snippets into your Terraform configurations for quick starts. + +For detailed examples and configuration options, see the individual resource and data source documentation pages. diff --git a/terraform/provider/docs/resources/credential.md b/terraform/provider/docs/resources/credential.md new file mode 100644 index 00000000000..554ac07c395 --- /dev/null +++ b/terraform/provider/docs/resources/credential.md @@ -0,0 +1,152 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "litellm_credential Resource - terraform-provider-litellm" +subcategory: "" +description: |- + Manages a LiteLLM credential for storing sensitive authentication information. +--- + +# litellm_credential (Resource) + +Manages a LiteLLM credential for storing sensitive authentication information. Credentials can be used to securely store API keys, tokens, and other sensitive data that can be referenced by models and vector stores. + +## Example Usage + +### Basic OpenAI Credential + +```terraform +resource "litellm_credential" "openai_cred" { + credential_name = "openai-api-key" + model_id = "gpt-4" + + credential_info = { + provider = "openai" + region = "us-east-1" + purpose = "chat-completions" + } + + credential_values = { + api_key = var.openai_api_key + org_id = var.openai_org_id + } +} +``` + +### Anthropic Credential + +```terraform +resource "litellm_credential" "anthropic_cred" { + credential_name = "anthropic-api-key" + + credential_info = { + provider = "anthropic" + purpose = "text-generation" + } + + credential_values = { + api_key = var.anthropic_api_key + } +} +``` + +### Pinecone Vector Store Credential + +```terraform +resource "litellm_credential" "pinecone_cred" { + credential_name = "pinecone-production" + + credential_info = { + provider = "pinecone" + environment = "production" + region = "us-east-1" + } + + credential_values = { + api_key = var.pinecone_api_key + index_name = "document-embeddings" + } +} +``` + +### Using Credentials with Vector Store + +```terraform +resource "litellm_vector_store" "example" { + vector_store_name = "my-vector-store" + custom_llm_provider = "pinecone" + litellm_credential_name = litellm_credential.pinecone_cred.credential_name + + vector_store_description = "Example vector store using Pinecone" + + vector_store_metadata = { + environment = "production" + team = "ai-team" + } +} +``` + +### Multiple Provider Credentials + +```terraform +# AWS Bedrock credential +resource "litellm_credential" "aws_bedrock" { + credential_name = "aws-bedrock-cred" + + credential_info = { + provider = "aws" + service = "bedrock" + region = "us-east-1" + } + + credential_values = { + aws_access_key_id = var.aws_access_key_id + aws_secret_access_key = var.aws_secret_access_key + aws_region = "us-east-1" + } +} + +# Azure OpenAI credential +resource "litellm_credential" "azure_openai" { + credential_name = "azure-openai-cred" + + credential_info = { + provider = "azure" + service = "openai" + } + + credential_values = { + api_key = var.azure_openai_key + api_base = var.azure_openai_endpoint + api_version = "2023-12-01-preview" + } +} +``` + +## Argument Reference + +The following arguments are supported: + +* `credential_name` - (Required) Name of the credential. This will be used as the identifier for the credential. +* `credential_values` - (Required, Sensitive) Map of sensitive credential values such as API keys, tokens, etc. +* `model_id` - (Optional) Model ID associated with this credential. +* `credential_info` - (Optional) Map of additional non-sensitive information about the credential. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `credential_name` - The name of the credential. + +## Import + +Credentials can be imported using their name: + +```shell +terraform import litellm_credential.example "credential-name" +``` + +## Security Considerations + +* The `credential_values` field is marked as sensitive and will not be displayed in Terraform output or logs. +* Credential values are not read back from the API for security reasons, so they are preserved in the Terraform state. +* Like every Terraform attribute marked `Sensitive`, `credential_values` is still written in plaintext to the state file. Anyone with read access to the state (or state artifacts such as plan files) can recover the configured secrets. Use an encrypted remote backend with tight access controls, and prefer feeding secrets in via variables sourced from a secret manager rather than hardcoding them in configuration. diff --git a/terraform/provider/docs/resources/key.md b/terraform/provider/docs/resources/key.md new file mode 100644 index 00000000000..b48d3334c14 --- /dev/null +++ b/terraform/provider/docs/resources/key.md @@ -0,0 +1,116 @@ +# litellm_key Resource + +Manages a LiteLLM API key. + +## Example Usage + +```hcl +resource "litellm_key" "example" { + models = ["gpt-3.5-turbo", "gpt-4"] + max_budget = 100.0 + user_id = "user123" + team_id = "team456" + max_parallel_requests = 5 + metadata = { + "environment" = "production" + } + tpm_limit = 1000 + rpm_limit = 60 + budget_duration = "monthly" + allowed_cache_controls = ["no-cache", "max-age=3600"] + soft_budget = 80.0 + key_alias = "prod-key-1" + duration = "30d" + aliases = { + "gpt-3.5-turbo" = "chatgpt" + } + config = { + "default_model" = "gpt-3.5-turbo" + } + permissions = { + "can_create_keys" = "true" + } + model_max_budget = { + "gpt-4" = 50.0 + } + model_rpm_limit = { + "gpt-3.5-turbo" = 30 + } + model_tpm_limit = { + "gpt-4" = 500 + } + guardrails = ["content_filter", "token_limit"] + blocked = false + tags = ["production", "api"] +} +``` + +## Argument Reference + +The following arguments are supported: + +* `models` - (Optional) List of models that can be used with this key. This restricts the key to only use the specified models. + +* `max_budget` - (Optional) Maximum budget for this key. This sets an upper limit on the total spend allowed for this key. + +* `user_id` - (Optional) User ID associated with this key. This links the key to a specific user in the LiteLLM system. + +* `team_id` - (Optional) Team ID associated with this key. This links the key to a specific team in the LiteLLM system. + +* `max_parallel_requests` - (Optional) Maximum number of parallel requests allowed for this key. This helps in controlling concurrent usage. + +* `metadata` - (Optional) Metadata associated with this key. This can be used to store additional, custom information about the key. + +* `tpm_limit` - (Optional) Tokens per minute limit for this key. This sets a rate limit based on the number of tokens processed. + +* `rpm_limit` - (Optional) Requests per minute limit for this key. This sets a rate limit based on the number of API calls. + +* `budget_duration` - (Optional) Duration for the budget (e.g., "monthly", "weekly"). This defines the time period for which the `max_budget` applies. + +* `allowed_cache_controls` - (Optional) List of allowed cache control directives. This can be used to control caching behavior for requests made with this key. + +* `soft_budget` - (Optional) Soft budget limit for this key. This can be used to set a warning threshold before reaching the `max_budget`. + +* `key_alias` - (Optional) Alias for this key. This provides a human-readable identifier for the key. + +* `duration` - (Optional) Duration for which this key is valid. This sets an expiration time for the key. + +* `aliases` - (Optional) Map of model aliases. This allows you to create custom names for models when using this key. + +* `config` - (Optional) Configuration options for this key. This can be used to set key-specific settings. + +* `permissions` - (Optional) Permissions associated with this key. This defines what actions are allowed with this key. + +* `model_max_budget` - (Optional) Maximum budget per model. This allows setting different budget limits for each model. + +* `model_rpm_limit` - (Optional) Requests per minute limit per model. This allows setting different RPM limits for each model. + +* `model_tpm_limit` - (Optional) Tokens per minute limit per model. This allows setting different TPM limits for each model. + +* `guardrails` - (Optional) List of guardrails applied to this key. This can be used to enforce certain safety or quality checks. + +* `blocked` - (Optional) Whether this key is blocked. If set to true, the key will be unable to make any requests. + +* `tags` - (Optional) List of tags associated with this key. This can be used for organization and filtering of keys. + +## Attribute Reference + +In addition to all arguments above, the following attributes are exported: + +* `key` - The generated API key. This is the actual key value that will be used for authentication. + +* `spend` - The current spend for this key. This reflects the total amount spent using this key so far. + +## State Management + +Recent updates have improved how the Key resource manages its state. The provider now ensures that all non-zero and non-empty values are correctly persisted in the Terraform state file. This means that any value you set will be accurately reflected in your state, preventing unnecessary updates and ensuring consistency between your configuration and the actual resource state. + +## Import + +LiteLLM keys can be imported using the `id`, e.g., + +``` +$ terraform import litellm_key.example 12345 +``` + +This allows you to import existing keys into your Terraform state, enabling management of keys that were created outside of Terraform. diff --git a/terraform/provider/docs/resources/mcp_server.md b/terraform/provider/docs/resources/mcp_server.md new file mode 100644 index 00000000000..77457a5ae55 --- /dev/null +++ b/terraform/provider/docs/resources/mcp_server.md @@ -0,0 +1,217 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "litellm_mcp_server Resource - terraform-provider-litellm" +subcategory: "" +description: |- + Manages an MCP (Model Context Protocol) server in LiteLLM. +--- + +# litellm_mcp_server (Resource) + +Manages an MCP (Model Context Protocol) server in LiteLLM. MCP servers provide tools and resources that can be used by LLMs through the LiteLLM proxy. + +## Example Usage + +### Basic HTTP MCP Server + +```terraform +resource "litellm_mcp_server" "github_server" { + server_name = "github-mcp-server" + alias = "github" + description = "GitHub MCP server for repository operations" + url = "https://api.github.com/mcp" + transport = "http" + auth_type = "bearer" + + mcp_access_groups = ["dev_team", "devops_team"] +} +``` + +### SSE MCP Server with Comprehensive Cost Tracking + +```terraform +resource "litellm_mcp_server" "zapier_server" { + server_name = "zapier-automation" + alias = "zapier" + description = "Zapier MCP server for workflow automation" + url = "https://actions.zapier.com/mcp/sk-xxxxx/sse" + transport = "sse" + auth_type = "bearer" + spec_version = "2024-11-05" + + mcp_access_groups = ["automation_team", "marketing_team"] + + mcp_info { + server_name = "Zapier Integration Server" + description = "Provides automation tools through Zapier's MCP interface" + logo_url = "https://zapier.com/assets/images/zapier-logo.png" + + mcp_server_cost_info { + default_cost_per_query = 0.01 + + tool_name_to_cost_per_query = { + "send_email" = 0.05 + "create_document" = 0.03 + "update_spreadsheet" = 0.02 + "post_to_slack" = 0.01 + "create_calendar_event" = 0.04 + } + } + } +} +``` + +### Stdio MCP Server for Local Development + +```terraform +resource "litellm_mcp_server" "local_dev_server" { + server_name = "local-development-tools" + alias = "local-dev" + description = "Local MCP server for development tools" + url = "stdio://local-dev" + transport = "stdio" + auth_type = "none" + + command = "python3" + args = ["/opt/mcp-servers/dev-tools/server.py", "--verbose"] + + env = { + "PYTHONPATH" = "/opt/mcp-servers/dev-tools" + "DEBUG" = "true" + "LOG_LEVEL" = "info" + "WORKSPACE_DIR" = "/workspace" + } + + mcp_access_groups = ["local_developers"] + + mcp_info { + server_name = "Development Tools" + description = "Local development utilities and tools" + + mcp_server_cost_info { + default_cost_per_query = 0.0 # Free for local development + } + } +} +``` + +### Enterprise MCP Server with Full Configuration + +```terraform +resource "litellm_mcp_server" "enterprise_api_server" { + server_name = "enterprise-api-gateway" + alias = "enterprise" + description = "Enterprise API gateway MCP server" + url = "https://api.enterprise.com/mcp/v1" + transport = "http" + auth_type = "bearer" + spec_version = "2024-11-05" + + mcp_access_groups = [ + "enterprise_users", + "api_consumers", + "integration_team" + ] + + mcp_info { + server_name = "Enterprise API Gateway" + description = "Provides access to enterprise APIs and services" + logo_url = "https://enterprise.com/logo.png" + + mcp_server_cost_info { + default_cost_per_query = 0.10 + + tool_name_to_cost_per_query = { + "query_database" = 0.25 + "generate_report" = 0.50 + "send_notification" = 0.05 + "create_user" = 0.15 + "update_permissions" = 0.20 + "audit_log_query" = 0.30 + } + } + } +} +``` + +## Argument Reference + +The following arguments are supported: + +### Required Arguments + +* `server_name` - (Required) Name of the MCP server. +* `url` - (Required) URL of the MCP server. For stdio transport, use `stdio://` prefix. +* `transport` - (Required) Transport type for the MCP server. Valid values: `http`, `sse`, `stdio`. + +### Optional Arguments + +* `alias` - (Optional) Alias for the MCP server. Used for easier reference. +* `description` - (Optional) Description of the MCP server. +* `spec_version` - (Optional) MCP specification version. Defaults to `2024-11-05`. +* `auth_type` - (Optional) Authentication type. Valid values: `none`, `bearer`, `basic`. Defaults to `none`. +* `mcp_access_groups` - (Optional) List of access groups that can use this MCP server. +* `command` - (Optional) Command to run for stdio transport. +* `args` - (Optional) List of arguments for the command (stdio transport only). Do not pass secrets as arguments; args are shown in plans, stored unencrypted in state, and visible in the server's process list. +* `env` - (Optional, Sensitive) Map of environment variables for the command (stdio transport only). Hidden from plan output but still stored unencrypted in state; secure your state backend when configuring tokens here. + +### MCP Info Block + +The `mcp_info` block supports: + +* `server_name` - (Optional) Server name in MCP info. +* `description` - (Optional) Description in MCP info. +* `logo_url` - (Optional) Logo URL for the MCP server. + +#### MCP Server Cost Info Block + +The `mcp_server_cost_info` block within `mcp_info` supports: + +* `default_cost_per_query` - (Optional) Default cost per query for all tools. +* `tool_name_to_cost_per_query` - (Optional) Map of specific tool names to their cost per query. + +## Attribute Reference + +In addition to all arguments above, the following attributes are exported: + +* `server_id` - Unique identifier for the MCP server. +* `created_at` - Timestamp when the server was created. +* `created_by` - User who created the server. +* `updated_at` - Timestamp when the server was last updated. +* `updated_by` - User who last updated the server. +* `status` - Current status of the MCP server. +* `last_health_check` - Timestamp of the last health check. +* `health_check_error` - Error message from the last health check, if any. + +## Import + +MCP servers can be imported using their server ID: + +```shell +terraform import litellm_mcp_server.example server-id-here +``` + +## Transport Types + +### HTTP Transport +- Standard HTTP/HTTPS communication +- Suitable for REST API-based MCP servers +- Supports authentication via `auth_type` + +### SSE (Server-Sent Events) Transport +- Real-time streaming communication +- Ideal for servers that need to push updates +- Commonly used with services like Zapier + +### Stdio Transport +- Standard input/output communication +- Used for local MCP servers or command-line tools +- Requires `command` and optionally `args` and `env` + +## Access Control + +Use `mcp_access_groups` to control which teams or users can access the MCP server tools. This integrates with LiteLLM's permission management system. + +## Cost Tracking + +Configure cost tracking through the `mcp_info.mcp_server_cost_info` block to monitor and control spending on MCP tool usage. diff --git a/terraform/provider/docs/resources/model.md b/terraform/provider/docs/resources/model.md new file mode 100644 index 00000000000..5a46fe2f073 --- /dev/null +++ b/terraform/provider/docs/resources/model.md @@ -0,0 +1,238 @@ +# litellm_model Resource + +Manages a LiteLLM model configuration. This resource allows you to create, update, and delete model configurations in your LiteLLM instance. + +## Example Usage + +### Basic OpenAI Model + +```hcl +resource "litellm_model" "gpt4" { + model_name = "gpt-4-proxy" + custom_llm_provider = "openai" + model_api_key = var.openai_api_key + base_model = "gpt-4" + tier = "paid" + mode = "chat" + + input_cost_per_million_tokens = 30.0 + output_cost_per_million_tokens = 60.0 +} +``` + +### Advanced Model with All Features + +```hcl +resource "litellm_model" "advanced_gpt4" { + model_name = "gpt-4-advanced" + custom_llm_provider = "openai" + model_api_key = var.openai_api_key + model_api_base = "https://api.openai.com/v1" + api_version = "2023-05-15" + base_model = "gpt-4" + tier = "paid" + team_id = "team-123" + mode = "chat" + reasoning_effort = "medium" + thinking_enabled = true + thinking_budget_tokens = 1024 + merge_reasoning_content_in_choices = true + tpm = 100000 + rpm = 1000 + + # Cost configuration (per million tokens) + input_cost_per_million_tokens = 30.0 # $0.03 per 1k tokens = $30 per million + output_cost_per_million_tokens = 60.0 # $0.06 per 1k tokens = $60 per million +} +``` + +### AWS Bedrock Model with Cross-Account Access + +```hcl +resource "litellm_model" "bedrock_claude" { + model_name = "bedrock-claude-proxy" + custom_llm_provider = "bedrock" + base_model = "anthropic.claude-3-sonnet-20240229-v1:0" + tier = "paid" + mode = "chat" + + # AWS configuration with cross-account access + aws_access_key_id = var.aws_access_key_id + aws_secret_access_key = var.aws_secret_access_key + aws_region_name = "us-east-1" + aws_session_name = "litellm-cross-account-session" + aws_role_name = "arn:aws:iam::123456789012:role/LiteLLMCrossAccountRole" + + input_cost_per_million_tokens = 3.0 + output_cost_per_million_tokens = 15.0 +} +``` + +### Anthropic Model + +```hcl +resource "litellm_model" "claude" { + model_name = "claude-proxy" + custom_llm_provider = "anthropic" + model_api_key = var.anthropic_api_key + base_model = "claude-3-sonnet-20240229" + tier = "paid" + mode = "chat" + + input_cost_per_million_tokens = 3.0 + output_cost_per_million_tokens = 15.0 +} +``` + +### Azure OpenAI Model + +```hcl +resource "litellm_model" "azure_gpt4" { + model_name = "azure-gpt4-proxy" + custom_llm_provider = "azure" + model_api_key = var.azure_openai_key + model_api_base = var.azure_openai_endpoint + api_version = "2023-12-01-preview" + base_model = "gpt-4" + tier = "paid" + mode = "chat" + + input_cost_per_million_tokens = 30.0 + output_cost_per_million_tokens = 60.0 +} +``` + +## Argument Reference + +The following arguments are supported: + +* `model_name` - (Required) string. The name of the model configuration used to identify the model in API calls. + +* `custom_llm_provider` - (Required) string. The LLM provider for this model (e.g., "openai", "anthropic", "azure", "bedrock"). + +* `model_api_key` - (Optional) string (Sensitive). The API key for the underlying model provider. Sensitive attributes are hidden from Terraform output but still stored in plaintext in the state file; prefer storing provider secrets in a `litellm_credential` and referencing it via `litellm_credential_name`, and secure your state backend. + +* `model_api_base` - (Optional) string. The base URL for the model provider's API. + +* `api_version` - (Optional) string. The API version to use for the model provider. + +* `base_model` - (Required) string. The actual model identifier from the provider (e.g., "gpt-4", "claude-2"). + +* `litellm_credential_name` - (Optional) string. Name of a LiteLLM credential to use for this model. + +* `tier` - (Optional) string. The usage tier for this model. Valid values are `"free"` or `"paid"`. Default: `"free"`. + +* `team_id` - (Optional) string. Associate the model with a specific team. + +* `mode` - (Optional) string. The intended use of the model. Valid values are: + * `completion` + * `embedding` + * `image_generation` + * `chat` + * `moderation` + * `audio_transcription` + * `audio_speech` + * `rerank` + +* `tpm` - (Optional) integer. Tokens per minute limit for this model. + +* `rpm` - (Optional) integer. Requests per minute limit for this model. + +* `reasoning_effort` - (Optional) string. Configures the model's reasoning effort level. Valid values are: + * `low` + * `medium` + * `high` + +* `thinking_enabled` - (Optional) boolean. Enables the model's thinking capability. Default: `false`. + +* `thinking_budget_tokens` - (Optional) integer. Sets the token budget for the model's thinking capability. Default: `1024`. Note: this field is only relevant when `thinking_enabled = true`. + +* `merge_reasoning_content_in_choices` - (Optional) boolean. When set to `true`, merges reasoning content into the model's choices. + +* `input_cost_per_million_tokens` - (Optional) float. Cost per million input tokens. The provider converts this to a per-token cost sent to the API. + +* `output_cost_per_million_tokens` - (Optional) float. Cost per million output tokens. The provider converts this to a per-token cost sent to the API. + +* `input_cost_per_pixel` - (Optional) float. Cost applied per input pixel for models that charge by image size. + +* `output_cost_per_pixel` - (Optional) float. Cost applied per output pixel for image-generation models. + +* `input_cost_per_second` - (Optional) float. Cost applied per input second for audio/transcription models. + +* `output_cost_per_second` - (Optional) float. Cost applied per output second for audio/transcription models. + +* `vertex_project` - (Optional) string. Vertex AI project id (for `custom_llm_provider = "vertex"`). + +* `vertex_location` - (Optional) string. Vertex AI location (e.g., `us-central1`). + +* `vertex_credentials` - (Optional) string. Vertex credentials (JSON string or path depending on your setup). + +* `additional_litellm_params` - (Optional) map(string). A map of arbitrary additional parameters that will be merged into the `litellm_params` object sent to the LiteLLM API. This is intended for provider-specific or experimental options not exposed as dedicated arguments. + + Conversion and behavior rules (how the provider handles values): + * When values in the map are strings the provider will attempt to coerce them: + * `"true"` / `"false"` (strings) -> boolean true / false + * Numeric strings are parsed first as integers; if integer parsing fails, parsed as floats (e.g., `"16384"` -> 16384, `"0.75"` -> 0.75) + * JSON strings (starting with `[` or `{`) are parsed as JSON objects/arrays + * Non-convertible strings remain strings + * Non-string map values (if supplied) are passed through unchanged. + * The provider merges these keys into the `litellm_params` payload sent to the API. + * Note: the remote API may not echo back all custom parameters; this provider preserves `additional_litellm_params` in state when present in configuration. + + **Special parameter: `additional_drop_params`** + * When `additional_drop_params` is provided as a JSON array string, it specifies parameters to remove from the final `litellm_params` before sending to the API + * This allows you to override or remove built-in parameters if needed + * The `additional_drop_params` key itself is not included in the final parameters + + Example showing booleans, integers, floats, strings, and parameter dropping: + + ```hcl + resource "litellm_model" "with_additional" { + model_name = "custom-model" + custom_llm_provider = "openai" + model_api_key = var.openai_api_key + base_model = "gpt-4" + mode = "chat" + + additional_litellm_params = { + "use_fine_tune" = "true" # becomes boolean true + "max_context" = "16384" # becomes integer 16384 + "scale" = "0.75" # becomes float 0.75 + "note" = "for testing" # stays string + "complex_config" = "{\"nested\": {\"value\": 42}}" # parsed as JSON object + "additional_drop_params" = "[\"reasoningEffort\"]" # removes reasoningEffort parameter + } + } + ``` + +### AWS-specific Configuration + +* `aws_access_key_id` - (Optional) string (Sensitive). AWS access key ID for AWS-based models. + +* `aws_secret_access_key` - (Optional) string (Sensitive). AWS secret access key for AWS-based models. As with `model_api_key`, the value is stored in plaintext in the state file; prefer a `litellm_credential` referenced via `litellm_credential_name` and secure your state backend. + +* `aws_region_name` - (Optional) string. AWS region name for AWS-based models. + +* `aws_session_name` - (Optional) string (Sensitive). AWS session name for cross-account access scenarios. + +* `aws_role_name` - (Optional) string (Sensitive). AWS IAM role name for cross-account access scenarios. + +## Attribute Reference + +In addition to the arguments above, the following attributes are exported: + +* `id` - The ID of the model configuration. + +## Import + +Model configurations can be imported using the model ID: + +```shell +terraform import litellm_model.gpt4 +``` + +Note: The model ID is generated when the model is created and is different from the `model_name`. + +## Security Note + +When using this resource, ensure that sensitive information such as API keys and AWS credentials are stored securely. It's recommended to use environment variables or a secure secret management solution rather than hardcoding these values in your Terraform configuration files. diff --git a/terraform/provider/docs/resources/team.md b/terraform/provider/docs/resources/team.md new file mode 100644 index 00000000000..68535309f10 --- /dev/null +++ b/terraform/provider/docs/resources/team.md @@ -0,0 +1,130 @@ +# litellm_team Resource + +Manages a team configuration in LiteLLM. Teams allow you to group users and manage their access to models and usage limits. + +## Example Usage + +### Basic Team Configuration + +```hcl +resource "litellm_team" "engineering" { + team_alias = "engineering-team" + models = ["gpt-4-proxy", "claude-2"] + max_budget = 1000.0 +} +``` + +### Team with Comprehensive Configuration + +```hcl +resource "litellm_team" "advanced_team" { + team_alias = "ai-research-team" + organization_id = "org_123456" + models = ["gpt-4-proxy", "claude-2", "gpt-3.5-turbo"] + + # Budget and rate limiting + max_budget = 1000.0 + budget_duration = "1mo" + tpm_limit = 500000 + rpm_limit = 5000 + blocked = false + + # Team member permissions + team_member_permissions = [ + "create_key", + "delete_key", + "view_spend", + "edit_team" + ] + + # Metadata for organization + metadata = { + department = "Engineering" + project = "AI Research" + cost_center = "R&D-001" + } +} +``` + +### Team with Model Dependencies + +```hcl +# First create models +resource "litellm_model" "gpt4" { + model_name = "gpt-4-proxy" + custom_llm_provider = "openai" + base_model = "gpt-4" + model_api_key = var.openai_api_key +} + +resource "litellm_model" "claude" { + model_name = "claude-proxy" + custom_llm_provider = "anthropic" + base_model = "claude-3-sonnet-20240229" + model_api_key = var.anthropic_api_key +} + +# Then create team with access to these models +resource "litellm_team" "model_dependent_team" { + team_alias = "model-users" + models = [ + litellm_model.gpt4.model_name, + litellm_model.claude.model_name + ] + + max_budget = 500.0 + budget_duration = "1mo" + + team_member_permissions = [ + "view_spend" + ] +} +``` + +## Argument Reference + +The following arguments are supported: + +* `team_alias` - (Required) A human-readable identifier for the team. + +* `organization_id` - (Optional) The ID of the organization this team belongs to. + +* `models` - (Optional) List of model names that this team can access. + +* `metadata` - (Optional) A map of metadata key-value pairs associated with the team. + +* `blocked` - (Optional) Whether the team is blocked from making requests. Default is `false`. + +* `tpm_limit` - (Optional) Team-wide tokens per minute limit. + +* `rpm_limit` - (Optional) Team-wide requests per minute limit. + +* `max_budget` - (Optional) Maximum budget allocated to the team. + +* `budget_duration` - (Optional) Duration for the budget cycle. Valid values are: + * `daily` + * `weekly` + * `monthly` + * `yearly` + +* `team_member_permissions` - (Optional) List of permissions granted to team members. This controls what actions team members can perform within the team context. + +## Attribute Reference + +In addition to the arguments above, the following attributes are exported: + +* `id` - The unique identifier for the team. + +## Import + +Teams can be imported using the team ID: + +```shell +terraform import litellm_team.engineering +``` + +Note: The team ID is generated when the team is created and is different from the `team_alias`. + +## Note on Team Members + +Team members are managed through the separate `litellm_team_member` resource. This allows for more granular control over team membership and permissions. See the `litellm_team_member` resource documentation for details on managing team members. diff --git a/terraform/provider/docs/resources/team_member.md b/terraform/provider/docs/resources/team_member.md new file mode 100644 index 00000000000..426d8b8892f --- /dev/null +++ b/terraform/provider/docs/resources/team_member.md @@ -0,0 +1,54 @@ +# litellm_team_member Resource + +Manages individual team member configurations in LiteLLM. This resource allows you to add, update, and remove team members with specific permissions and budget limits. + +## Example Usage + +```hcl +resource "litellm_team_member" "engineer" { + team_id = litellm_team.engineering.id + user_id = "user_3" + user_email = "engineer@example.com" + role = "user" + max_budget_in_team = 200.0 +} +``` + +## Argument Reference + +The following arguments are supported: + +* `team_id` - (Required) The ID of the team this member belongs to. + +* `user_id` - (Required) Unique identifier for the user. + +* `user_email` - (Required) Email address of the user. + +* `role` - (Required) The role of the team member. Valid values are: + * `org_admin` + * `internal_user` + * `internal_user_viewer` + * `admin` + * `user` + +* `max_budget_in_team` - (Optional) Maximum budget allocated to this team member within the team's budget. + +## Attribute Reference + +In addition to the arguments above, the following attributes are exported: + +* `id` - The unique identifier for the team member configuration. This is typically a composite of the team_id and user_id. + +## Import + +Team members can be imported using the format `team_id:user_id`: + +```shell +terraform import litellm_team_member.engineer : +``` + +Note: The team_id and user_id should match the values used in the resource configuration. + +## Security Note + +Ensure that sensitive information such as user emails and IDs are handled securely. It's recommended to use variables or a secure secret management solution rather than hardcoding these values in your Terraform configuration files. diff --git a/terraform/provider/docs/resources/team_member_add.md b/terraform/provider/docs/resources/team_member_add.md new file mode 100644 index 00000000000..f5398e49d9c --- /dev/null +++ b/terraform/provider/docs/resources/team_member_add.md @@ -0,0 +1,161 @@ +# Resource: litellm_team_member_add + +Add multiple members to a team with a single resource. This resource efficiently manages team members by using the appropriate API endpoints for each operation: + +- **Adding new members**: Uses `/team/member_add` endpoint +- **Updating existing members**: Uses `/team/member_update` endpoint (preserves member identity) +- **Removing members**: Uses `/team/member_delete` endpoint + +When you modify an existing team member's attributes (like role), the resource will update the member in-place rather than deleting and re-adding them. + +## Example Usage + +### Basic Usage + +```hcl +resource "litellm_team_member_add" "example" { + team_id = "team-123" + + member { + user_id = "user-456" + role = "admin" + } + + member { + user_email = "user@example.com" + role = "user" + } + + max_budget_in_team = 100.0 +} +``` + +### Complete Team Setup with Members + +```hcl +# First create a team +resource "litellm_team" "development" { + team_alias = "development-team" + max_budget = 500.0 + models = ["gpt-4", "gpt-3.5-turbo"] + + team_member_permissions = [ + "create_key", + "view_spend" + ] +} + +# Add members to the team +resource "litellm_team_member_add" "dev_team_members" { + team_id = litellm_team.development.id + + # Team lead with admin role + member { + user_email = "team-lead@company.com" + role = "admin" + } + + # Regular developers + member { + user_email = "developer1@company.com" + role = "user" + } + + member { + user_email = "developer2@company.com" + role = "user" + } + + member { + user_id = "existing-user-123" + role = "user" + } + + # Budget per member + max_budget_in_team = 100.0 +} +``` + +### Dynamic Members Using Locals + +```hcl +locals { + team_members = [ + { + user_id = "user-123" + role = "admin" + }, + { + user_email = "developer1@company.com" + role = "user" + }, + { + user_email = "developer2@company.com" + role = "user" + } + ] +} + +resource "litellm_team_member_add" "dynamic_example" { + team_id = "team-456" + + dynamic "member" { + for_each = local.team_members + content { + user_id = lookup(member.value, "user_id", null) + user_email = lookup(member.value, "user_email", null) + role = member.value.role + } + } + + max_budget_in_team = 200.0 +} +``` + +### Budget Update Example + +```hcl +# This example demonstrates how budget updates work correctly +resource "litellm_team_member_add" "budget_example" { + team_id = litellm_team.example.id + + # Initial budget of $100 per member + max_budget_in_team = 100.0 + + member { + user_email = "user1@example.com" + role = "admin" + } + + member { + user_email = "user2@example.com" + role = "user" + } + + member { + user_id = "user123" + role = "user" + } +} + +# To update the budget: +# 1. Change max_budget_in_team from 100.0 to 120.0 +# 2. Run terraform plan - it will show the budget change +# 3. Run terraform apply - all existing members will be updated with the new budget +``` + +## Argument Reference + +* `team_id` - (Required) The ID of the team to add members to. +* `member` - (Required) One or more member blocks defining team members. Each block supports: + * `user_id` - (Optional) The ID of the user to add to the team. + * `user_email` - (Optional) The email of the user to add to the team. + * `role` - (Required) The role of the user in the team. Must be one of: "admin" or "user". +* `max_budget_in_team` - (Optional) The maximum budget allocated for the team members. + +## Import + +Team members can be imported using a composite ID of the team ID and user ID: + +```shell +terraform import litellm_team_member_add.example team-123:user-456 diff --git a/terraform/provider/docs/resources/vector_store.md b/terraform/provider/docs/resources/vector_store.md new file mode 100644 index 00000000000..b839b327429 --- /dev/null +++ b/terraform/provider/docs/resources/vector_store.md @@ -0,0 +1,274 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "litellm_vector_store Resource - terraform-provider-litellm" +subcategory: "" +description: |- + Manages a LiteLLM vector store for storing and retrieving vector embeddings. +--- + +# litellm_vector_store (Resource) + +Manages a LiteLLM vector store for storing and retrieving vector embeddings. Vector stores enable semantic search and retrieval-augmented generation (RAG) capabilities using officially supported providers including AWS Bedrock Knowledge Bases, OpenAI Vector Stores, Azure Vector Stores, Vertex AI RAG Engine, and PG Vector. + +## Example Usage + +### AWS Bedrock Knowledge Base + +```terraform +resource "litellm_credential" "bedrock_cred" { + credential_name = "bedrock-knowledge-base" + + credential_info = { + provider = "bedrock" + region = "us-east-1" + } + + credential_values = { + aws_access_key_id = var.aws_access_key_id + aws_secret_access_key = var.aws_secret_access_key + aws_region = "us-east-1" + } +} + +resource "litellm_vector_store" "bedrock_kb" { + vector_store_name = "bedrock-litellm-website-knowledgebase" + custom_llm_provider = "bedrock" + litellm_credential_name = litellm_credential.bedrock_cred.credential_name + + vector_store_description = "Bedrock vector store for the LiteLLM website knowledgebase" + + vector_store_metadata = { + source = "https://www.litellm.com/docs" + } + + litellm_params = { + vector_store_id = "T37J8R4WTM" + } +} +``` + +### OpenAI Vector Store + +```terraform +resource "litellm_credential" "openai_cred" { + credential_name = "openai-vector-store" + + credential_info = { + provider = "openai" + } + + credential_values = { + api_key = var.openai_api_key + } +} + +resource "litellm_vector_store" "openai_store" { + vector_store_name = "openai-knowledge-base" + custom_llm_provider = "openai" + litellm_credential_name = litellm_credential.openai_cred.credential_name + + vector_store_description = "OpenAI vector store for document search" + + vector_store_metadata = { + environment = "production" + purpose = "file-search" + } + + litellm_params = { + vector_store_id = "vs_687ae3b2439881918b433cb99d10662e" + } +} +``` + +### Azure Vector Store + +```terraform +resource "litellm_credential" "azure_cred" { + credential_name = "azure-vector-store" + + credential_info = { + provider = "azure" + } + + credential_values = { + api_key = var.azure_openai_key + api_base = var.azure_openai_endpoint + api_version = "2023-12-01-preview" + } +} + +resource "litellm_vector_store" "azure_store" { + vector_store_name = "azure-knowledge-base" + custom_llm_provider = "azure" + litellm_credential_name = litellm_credential.azure_cred.credential_name + + vector_store_description = "Azure vector store for enterprise search" + + vector_store_metadata = { + environment = "production" + team = "enterprise" + } + + litellm_params = { + vector_store_id = "vs_azure_example_id" + } +} +``` + +### Vertex AI RAG Engine + +```terraform +resource "litellm_credential" "vertex_cred" { + credential_name = "vertex-rag-engine" + + credential_info = { + provider = "vertex_ai" + project = "your-gcp-project" + } + + credential_values = { + service_account_key = var.gcp_service_account_key + } +} + +resource "litellm_vector_store" "vertex_rag" { + vector_store_name = "vertex-rag-corpus" + custom_llm_provider = "vertex_ai" + litellm_credential_name = litellm_credential.vertex_cred.credential_name + + vector_store_description = "Vertex AI RAG Engine for enterprise knowledge" + + vector_store_metadata = { + project = "your-gcp-project" + environment = "production" + } + + litellm_params = { + vector_store_id = "6917529027641081856" + } +} +``` + +### PG Vector Store + +```terraform +resource "litellm_credential" "pgvector_cred" { + credential_name = "pgvector-store" + + credential_info = { + provider = "pgvector" + host = "your-pgvector-host.com" + } + + credential_values = { + api_key = var.pgvector_api_key + api_base = "https://your-pgvector-host.com" + } +} + +resource "litellm_vector_store" "pgvector_store" { + vector_store_name = "postgres-vector-store" + custom_llm_provider = "pgvector" + litellm_credential_name = litellm_credential.pgvector_cred.credential_name + + vector_store_description = "PostgreSQL vector store with pgvector extension" + + vector_store_metadata = { + database = "vector_db" + table = "embeddings" + environment = "production" + } + + litellm_params = { + api_base = "https://your-pgvector-host.com" + } +} +``` + +## Argument Reference + +The following arguments are supported: + +* `vector_store_name` - (Required) Name of the vector store. +* `custom_llm_provider` - (Required) The vector store provider. Supported values: "bedrock", "openai", "azure", "vertex_ai", "pgvector". +* `vector_store_description` - (Optional) Description of the vector store. +* `vector_store_metadata` - (Optional) Map of metadata associated with the vector store. +* `litellm_credential_name` - (Optional) Name of the LiteLLM credential to use for authentication. +* `litellm_params` - (Optional, Sensitive) Map of additional parameters specific to the vector store provider. Do not put API keys or other secrets here; this map is stored unencrypted in state. Store secrets in a `litellm_credential` and reference it via `litellm_credential_name`. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `vector_store_id` - The unique identifier of the vector store. +* `created_at` - Timestamp when the vector store was created. +* `updated_at` - Timestamp when the vector store was last updated. + +## Supported Providers + +The following vector store providers are officially supported by LiteLLM: + +* **AWS Bedrock Knowledge Bases** - Managed knowledge bases on AWS Bedrock +* **OpenAI Vector Stores** - OpenAI's native vector store service +* **Azure Vector Stores** - Azure OpenAI vector store integration +* **Vertex AI RAG Engine** - Google Cloud's RAG API for vector search +* **PG Vector** - PostgreSQL with pgvector extension + +## Provider-Specific Parameters + +### AWS Bedrock Knowledge Base + +```terraform +litellm_params = { + vector_store_id = "T37J8R4WTM" # Your Bedrock Knowledge Base ID +} +``` + +### OpenAI Vector Store + +```terraform +litellm_params = { + vector_store_id = "vs_687ae3b2439881918b433cb99d10662e" # Your OpenAI Vector Store ID +} +``` + +### Azure Vector Store + +```terraform +litellm_params = { + vector_store_id = "vs_azure_example_id" # Your Azure Vector Store ID +} +``` + +### Vertex AI RAG Engine + +```terraform +litellm_params = { + vector_store_id = "6917529027641081856" # Your Vertex AI RAG Engine ID +} +``` + +### PG Vector + +```terraform +litellm_params = { + api_base = "https://your-pgvector-host.com" +} +``` + +## Import + +Vector stores can be imported using their ID: + +```shell +terraform import litellm_vector_store.example "vector-store-id" +``` + +## Notes + +* Vector stores require appropriate credentials for the chosen provider. +* The `litellm_params` field allows provider-specific configuration. +* Some providers may require additional setup outside of Terraform (e.g., creating Knowledge Bases in AWS Bedrock, Vector Stores in OpenAI). +* Ensure your vector store provider is properly configured and accessible from your LiteLLM instance. +* Only the officially supported providers listed above are guaranteed to work with LiteLLM's vector store integration. +* For the most up-to-date list of supported providers, refer to the [LiteLLM documentation](https://docs.litellm.ai/docs/completion/knowledgebase). diff --git a/terraform/provider/examples/model_additional_params.tf b/terraform/provider/examples/model_additional_params.tf new file mode 100644 index 00000000000..fb4981ec6fb --- /dev/null +++ b/terraform/provider/examples/model_additional_params.tf @@ -0,0 +1,57 @@ +provider "litellm" { + api_base = "https://your-litellm-proxy.com" + api_key = var.litellm_api_key +} + +# Example: using additional_litellm_params to pass provider-specific options. +# Notes: +# - String values "true"/"false" will be coerced to booleans. +# - Numeric strings will be parsed to integer (if possible) otherwise float. +# - JSON strings (starting with [ or {) will be parsed as JSON objects/arrays. +# - Non-convertible strings remain strings. +# - Non-string map values are passed through unchanged. +# - Use "additional_drop_params" as a JSON array to remove parameters from the final request. + +resource "litellm_model" "with_additional" { + model_name = "custom-model" + custom_llm_provider = "openai" + model_api_key = var.openai_api_key + base_model = "gpt-4" + mode = "chat" + + # Additional parameters not exposed as first-class arguments + additional_litellm_params = { + "use_fine_tune" = "true" # becomes boolean true + "max_context" = "16384" # becomes integer 16384 + "temperature_scale" = "0.75" # becomes float 0.75 + "experimental_feature" = "enabled" # stays string "enabled" + "complex_config" = "{\"nested\": {\"value\": 42}}" # parsed as JSON object + "additional_drop_params" = "[\"reasoningEffort\"]" # removes reasoningEffort parameter + # You may also pass non-string values (they will be passed through unchanged) + # "raw_flag" = true + } + + # Cost configuration (optional) + input_cost_per_million_tokens = 30.0 + output_cost_per_million_tokens = 60.0 +} + +# Example: Azure model with parameter dropping +resource "litellm_model" "azure_with_drop_params" { + model_name = "gpt-5-mini-coder" + custom_llm_provider = "azure" + model_api_key = "your-azure-api-key" + model_api_base = "https://your-azure-endpoint.openai.azure.com/" + api_version = "2025-03-01-preview" + base_model = "gpt-5-mini" + tier = "paid" + mode = "completion" + + # Drop the reasoningEffort parameter that might be automatically added + additional_litellm_params = { + "additional_drop_params" = "[\"reasoningEffort\"]" + } + + input_cost_per_million_tokens = 0.25 + output_cost_per_million_tokens = 2.00 +} diff --git a/terraform/provider/go.mod b/terraform/provider/go.mod new file mode 100644 index 00000000000..899af1a6fbe --- /dev/null +++ b/terraform/provider/go.mod @@ -0,0 +1,61 @@ +module github.com/BerriAI/terraform-provider-litellm + +go 1.25.0 + +require ( + github.com/google/uuid v1.6.0 + github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0 +) + +require ( + github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/agext/levenshtein v1.2.2 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/fatih/color v1.16.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/hashicorp/go-checkpoint v0.5.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-cty v1.5.0 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-plugin v1.7.0 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/go-version v1.8.0 // indirect + github.com/hashicorp/hc-install v0.9.3 // indirect + github.com/hashicorp/hcl/v2 v2.24.0 // indirect + github.com/hashicorp/logutils v1.0.0 // indirect + github.com/hashicorp/terraform-exec v0.25.0 // indirect + github.com/hashicorp/terraform-json v0.27.2 // indirect + github.com/hashicorp/terraform-plugin-go v0.31.0 // indirect + github.com/hashicorp/terraform-plugin-log v0.10.0 // indirect + github.com/hashicorp/terraform-registry-address v0.4.0 // indirect + github.com/hashicorp/terraform-svchost v0.1.1 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/oklog/run v1.1.0 // indirect + github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/zclconf/go-cty v1.17.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.41.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.2 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/terraform/provider/go.sum b/terraform/provider/go.sum new file mode 100644 index 00000000000..890703d4f8a --- /dev/null +++ b/terraform/provider/go.sum @@ -0,0 +1,239 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/agext/levenshtein v1.2.2 h1:0S/Yg6LYmFJ5stwQeRp6EeOcCbj7xiqQSdNelsXvaqE= +github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= +github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= +github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= +github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-checkpoint v0.5.0 h1:MFYpPZCnQqQTE18jFwSII6eUQrD/oxMFp3mlgcqk5mU= +github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-cty v1.5.0 h1:EkQ/v+dDNUqnuVpmS5fPqyY71NXVgT5gf32+57xY8g0= +github.com/hashicorp/go-cty v1.5.0/go.mod h1:lFUCG5kd8exDobgSfyj4ONE/dc822kiYMguVKdHGMLM= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= +github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= +github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/hc-install v0.9.3 h1:1H4dgmgzxEVwT6E/d/vIL5ORGVKz9twRwDw+qA5Hyho= +github.com/hashicorp/hc-install v0.9.3/go.mod h1:FQlQ5I3I/X409N/J1U4pPeQQz1R3BoV0IysB7aiaQE0= +github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= +github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= +github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/terraform-exec v0.25.0 h1:Bkt6m3VkJqYh+laFMrWIpy9KHYFITpOyzRMNI35rNaY= +github.com/hashicorp/terraform-exec v0.25.0/go.mod h1:dl9IwsCfklDU6I4wq9/StFDp7dNbH/h5AnfS1RmiUl8= +github.com/hashicorp/terraform-json v0.27.2 h1:BwGuzM6iUPqf9JYM/Z4AF1OJ5VVJEEzoKST/tRDBJKU= +github.com/hashicorp/terraform-json v0.27.2/go.mod h1:GzPLJ1PLdUG5xL6xn1OXWIjteQRT2CNT9o/6A9mi9hE= +github.com/hashicorp/terraform-plugin-go v0.31.0 h1:0Fz2r9DQ+kNNl6bx8HRxFd1TfMKUvnrOtvJPmp3Z0q8= +github.com/hashicorp/terraform-plugin-go v0.31.0/go.mod h1:A88bDhd/cW7FnwqxQRz3slT+QY6yzbHKc6AOTtmdeS8= +github.com/hashicorp/terraform-plugin-log v0.10.0 h1:eu2kW6/QBVdN4P3Ju2WiB2W3ObjkAsyfBsL3Wh1fj3g= +github.com/hashicorp/terraform-plugin-log v0.10.0/go.mod h1:/9RR5Cv2aAbrqcTSdNmY1NRHP4E3ekrXRGjqORpXyB0= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0 h1:MKS/2URqeJRwJdbOfcbdsZCq/IRrNkqJNN0GtVIsuGs= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0/go.mod h1:PuG4P97Ju3QXW6c6vRkRadWJbvnEu2Xh+oOuqcYOqX4= +github.com/hashicorp/terraform-registry-address v0.4.0 h1:S1yCGomj30Sao4l5BMPjTGZmCNzuv7/GDTDX99E9gTk= +github.com/hashicorp/terraform-registry-address v0.4.0/go.mod h1:LRS1Ay0+mAiRkUyltGT+UHWkIqTFvigGn/LbMshfflE= +github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= +github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= +github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zclconf/go-cty v1.17.0 h1:seZvECve6XX4tmnvRzWtJNHdscMtYEx5R7bnnVyd/d0= +github.com/zclconf/go-cty v1.17.0/go.mod h1:wqFzcImaLTI6A5HfsRwB0nj5n0MRZFwmey8YoFPPs3U= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/terraform/provider/litellm/client.go b/terraform/provider/litellm/client.go new file mode 100644 index 00000000000..e0aba61477d --- /dev/null +++ b/terraform/provider/litellm/client.go @@ -0,0 +1,386 @@ +package litellm + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "regexp" + "strings" +) + +type Client struct { + APIBase string + APIKey string + httpClient *http.Client + InsecureSkipVerify bool +} + +func NewClient(apiBase, apiKey string, insecureSkipVerify bool) *Client { + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: insecureSkipVerify}, + } + + return &Client{ + APIBase: apiBase, + APIKey: apiKey, + httpClient: &http.Client{Transport: tr}, + InsecureSkipVerify: insecureSkipVerify, + } +} + +// Organization member methods +func (c *Client) AddOrganizationMember(data map[string]interface{}) (map[string]interface{}, error) { + return c.sendRequest("POST", "/organization/member_add", data) +} + +func (c *Client) UpdateOrganizationMember(data map[string]interface{}) (map[string]interface{}, error) { + return c.sendRequest("PATCH", "/organization/member_update", data) +} + +func (c *Client) DeleteOrganizationMember(data map[string]interface{}) (map[string]interface{}, error) { + return c.sendRequest("DELETE", "/organization/member_delete", data) +} + +// Key-related methods +func (c *Client) CreateKey(key *Key) (*Key, error) { + resp, err := c.sendRequest("POST", "/key/generate", key) + if err != nil { + return nil, err + } + + return c.parseKeyResponse(resp) +} + +func (c *Client) GetKey(keyID string) (*Key, error) { + resp, err := c.sendRequest("GET", fmt.Sprintf("/key/info?key=%s", keyID), nil) + if err != nil { + return nil, err + } + + return c.parseKeyResponse(resp) +} + +func (c *Client) UpdateKey(key *Key) (*Key, error) { + // Create a new map with only the fields that can be updated + updateData := map[string]interface{}{ + "key": key.Key, + "team_id": key.TeamID, + "metadata": key.Metadata, + "budget_duration": key.BudgetDuration, + "key_alias": key.KeyAlias, + "aliases": key.Aliases, + "permissions": key.Permissions, + "model_max_budget": key.ModelMaxBudget, + "model_rpm_limit": key.ModelRPMLimit, + "model_tpm_limit": key.ModelTPMLimit, + "blocked": key.Blocked, + } + + // Only add pointer fields if they are explicitly set + if key.MaxBudget != nil { + updateData["max_budget"] = *key.MaxBudget + } + if key.SoftBudget != nil { + updateData["soft_budget"] = *key.SoftBudget + } + if key.MaxParallelRequests != nil { + updateData["max_parallel_requests"] = *key.MaxParallelRequests + } + if key.TPMLimit != nil { + updateData["tpm_limit"] = *key.TPMLimit + } + if key.RPMLimit != nil { + updateData["rpm_limit"] = *key.RPMLimit + } + + // Only add array fields if they are non-empty + if len(key.Models) > 0 { + updateData["models"] = key.Models + } + if len(key.Guardrails) > 0 { + updateData["guardrails"] = key.Guardrails + } + if len(key.Tags) > 0 { + updateData["tags"] = key.Tags + } + + resp, err := c.sendRequest("POST", "/key/update", updateData) + if err != nil { + return nil, err + } + + return c.parseKeyResponse(resp) +} + +func (c *Client) DeleteKey(keyID string) error { + payload := map[string]interface{}{ + "keys": []string{keyID}, + } + _, err := c.sendRequest("POST", "/key/delete", payload) + return err +} + +func (c *Client) parseKeyResponse(resp map[string]interface{}) (*Key, error) { + if resp == nil { + return nil, fmt.Errorf("received nil response") + } + + createdKey := &Key{} + + for k, v := range resp { + if v == nil { + continue + } + + switch k { + case "key": + if s, ok := v.(string); ok { + createdKey.Key = s + } + case "token_id": + if s, ok := v.(string); ok { + createdKey.TokenID = s + } + case "models": + if models, ok := v.([]interface{}); ok { + createdKey.Models = make([]string, len(models)) + for i, model := range models { + if s, ok := model.(string); ok { + createdKey.Models[i] = s + } + } + } + case "spend": + if f, ok := v.(float64); ok { + createdKey.Spend = f + } + case "max_budget": + if f, ok := v.(float64); ok { + createdKey.MaxBudget = &f + } + case "user_id": + if s, ok := v.(string); ok { + createdKey.UserID = s + } + case "team_id": + if s, ok := v.(string); ok { + createdKey.TeamID = s + } + case "max_parallel_requests": + if i, ok := v.(float64); ok { + val := int(i) + createdKey.MaxParallelRequests = &val + } + case "metadata": + if m, ok := v.(map[string]interface{}); ok { + createdKey.Metadata = m + } + case "tpm_limit": + if i, ok := v.(float64); ok { + val := int(i) + createdKey.TPMLimit = &val + } + case "rpm_limit": + if i, ok := v.(float64); ok { + val := int(i) + createdKey.RPMLimit = &val + } + case "budget_duration": + if s, ok := v.(string); ok { + createdKey.BudgetDuration = s + } + case "soft_budget": + if f, ok := v.(float64); ok { + createdKey.SoftBudget = &f + } + case "key_alias": + if s, ok := v.(string); ok { + createdKey.KeyAlias = s + } + case "duration": + if s, ok := v.(string); ok { + createdKey.Duration = s + } + case "aliases": + if m, ok := v.(map[string]interface{}); ok { + createdKey.Aliases = m + } + case "config": + if m, ok := v.(map[string]interface{}); ok { + createdKey.Config = m + } + case "permissions": + if m, ok := v.(map[string]interface{}); ok { + createdKey.Permissions = m + } + case "model_max_budget": + if m, ok := v.(map[string]interface{}); ok { + createdKey.ModelMaxBudget = m + } + case "model_rpm_limit": + if m, ok := v.(map[string]interface{}); ok { + createdKey.ModelRPMLimit = m + } + case "model_tpm_limit": + if m, ok := v.(map[string]interface{}); ok { + createdKey.ModelTPMLimit = m + } + case "guardrails": + if guardrails, ok := v.([]interface{}); ok { + createdKey.Guardrails = make([]string, len(guardrails)) + for i, guardrail := range guardrails { + if s, ok := guardrail.(string); ok { + createdKey.Guardrails[i] = s + } + } + } + case "blocked": + if b, ok := v.(bool); ok { + createdKey.Blocked = b + } + case "tags": + if tags, ok := v.([]interface{}); ok { + createdKey.Tags = make([]string, len(tags)) + for i, tag := range tags { + if s, ok := tag.(string); ok { + createdKey.Tags[i] = s + } + } + } + } + } + + return createdKey, nil +} + +func (c *Client) sendRequest(method, path string, body interface{}) (map[string]interface{}, error) { + url := c.APIBase + path + + var req *http.Request + var err error + + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("error marshaling request body: %v", err) + } + log.Printf("Making %s request to %s with body:\n%s", method, url, c.redactSensitiveData(string(jsonBody))) + req, err = http.NewRequest(method, url, bytes.NewBuffer(jsonBody)) + } else { + log.Printf("Making %s request to %s", method, url) + req, err = http.NewRequest(method, url, nil) + } + + if err != nil { + return nil, fmt.Errorf("error creating request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", c.APIKey) + req.Header.Set("accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("error making request: %v", err) + } + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %v", err) + } + + log.Printf("Response status: %d", resp.StatusCode) + log.Printf("Response body: %s", c.redactSensitiveData(string(bodyBytes))) + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API request failed with status code %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var result map[string]interface{} + if err := json.Unmarshal(bodyBytes, &result); err != nil { + if (method == "POST" || method == "PATCH" || method == "PUT" || method == "DELETE") && + (len(bodyBytes) == 0 || string(bodyBytes) == "null") { + return make(map[string]interface{}), nil + } + return nil, fmt.Errorf("error parsing response JSON: %v\nResponse body: %s", err, string(bodyBytes)) + } + + return result, nil +} + +var sensitiveLogFields = map[string]bool{ + "api_key": true, + "key": true, + "token": true, + "password": true, + "secret": true, + "credential": true, + "auth": true, + "model_api_key": true, + "aws_access_key_id": true, + "aws_secret_access_key": true, + "vertex_credentials": true, + "x-api-key": true, + "credential_values": true, +} + +func redactJSONValue(value interface{}) interface{} { + switch typed := value.(type) { + case map[string]interface{}: + redacted := make(map[string]interface{}, len(typed)) + for k, v := range typed { + if sensitiveLogFields[k] { + redacted[k] = "[REDACTED]" + } else { + redacted[k] = redactJSONValue(v) + } + } + return redacted + case []interface{}: + redacted := make([]interface{}, len(typed)) + for i, v := range typed { + redacted[i] = redactJSONValue(v) + } + return redacted + default: + return value + } +} + +var sensitiveLogPatterns = []*regexp.Regexp{ + regexp.MustCompile(`"(api_key|key|token|password|secret|credential|auth)":\s*"[^"]*"`), + regexp.MustCompile(`"(model_api_key|aws_access_key_id|aws_secret_access_key|vertex_credentials)":\s*"[^"]*"`), + regexp.MustCompile(`"(x-api-key)":\s*"[^"]*"`), +} + +func redactWithPatterns(data string) string { + result := data + for _, re := range sensitiveLogPatterns { + result = re.ReplaceAllStringFunc(result, func(match string) string { + parts := strings.SplitN(match, ":", 2) + if len(parts) == 2 { + return parts[0] + `: "[REDACTED]"` + } + return "[REDACTED]" + }) + } + return result +} + +// redactSensitiveData masks sensitive information in logs +func (c *Client) redactSensitiveData(data string) string { + var parsed interface{} + if err := json.Unmarshal([]byte(data), &parsed); err != nil { + return redactWithPatterns(data) + } + redactedBytes, err := json.Marshal(redactJSONValue(parsed)) + if err != nil { + return redactWithPatterns(data) + } + return string(redactedBytes) +} diff --git a/terraform/provider/litellm/client_test.go b/terraform/provider/litellm/client_test.go new file mode 100644 index 00000000000..56f76565616 --- /dev/null +++ b/terraform/provider/litellm/client_test.go @@ -0,0 +1,71 @@ +package litellm + +import ( + "strings" + "testing" +) + +func TestRedactSensitiveDataNestedCredentialValues(t *testing.T) { + c := NewClient("http://localhost:4000", "sk-test", false) + + input := `{"credential_name":"azure-cred","credential_values":{"api_key":"sk-secret-123","config":{"region":"us-east-1","client_secret":"nested-secret"}}}` + got := c.redactSensitiveData(input) + + for _, leaked := range []string{"sk-secret-123", "us-east-1", "nested-secret"} { + if strings.Contains(got, leaked) { + t.Errorf("redacted output leaked %q: %s", leaked, got) + } + } + if !strings.Contains(got, `"credential_values":"[REDACTED]"`) { + t.Errorf("credential_values not redacted: %s", got) + } + if !strings.Contains(got, `"credential_name":"azure-cred"`) { + t.Errorf("non-sensitive field mangled: %s", got) + } +} + +func TestRedactSensitiveDataDeeplyNestedSensitiveKeys(t *testing.T) { + c := NewClient("http://localhost:4000", "sk-test", false) + + input := `{"data":[{"litellm_params":{"model":"gpt-4","api_key":"sk-deep-456","aws_secret_access_key":"aws-secret"}}]}` + got := c.redactSensitiveData(input) + + for _, leaked := range []string{"sk-deep-456", "aws-secret"} { + if strings.Contains(got, leaked) { + t.Errorf("redacted output leaked %q: %s", leaked, got) + } + } + if !strings.Contains(got, `"model":"gpt-4"`) { + t.Errorf("non-sensitive field mangled: %s", got) + } +} + +func TestRedactSensitiveDataTopLevelStringFields(t *testing.T) { + c := NewClient("http://localhost:4000", "sk-test", false) + + input := `{"model_api_key":"sk-top-789","vertex_credentials":"{\"type\":\"service_account\"}","team_alias":"eng"}` + got := c.redactSensitiveData(input) + + for _, leaked := range []string{"sk-top-789", "service_account"} { + if strings.Contains(got, leaked) { + t.Errorf("redacted output leaked %q: %s", leaked, got) + } + } + if !strings.Contains(got, `"team_alias":"eng"`) { + t.Errorf("non-sensitive field mangled: %s", got) + } +} + +func TestRedactSensitiveDataNonJSONFallback(t *testing.T) { + c := NewClient("http://localhost:4000", "sk-test", false) + + input := `error before "api_key": "sk-fallback-000" after` + got := c.redactSensitiveData(input) + + if strings.Contains(got, "sk-fallback-000") { + t.Errorf("fallback redaction leaked secret: %s", got) + } + if !strings.Contains(got, "[REDACTED]") { + t.Errorf("fallback redaction did not redact: %s", got) + } +} diff --git a/terraform/provider/litellm/data_source_credential.go b/terraform/provider/litellm/data_source_credential.go new file mode 100644 index 00000000000..e4533546a67 --- /dev/null +++ b/terraform/provider/litellm/data_source_credential.go @@ -0,0 +1,73 @@ +package litellm + +import ( + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceLiteLLMCredential() *schema.Resource { + return &schema.Resource{ + Read: dataSourceLiteLLMCredentialRead, + + Schema: map[string]*schema.Schema{ + "credential_name": { + Type: schema.TypeString, + Required: true, + Description: "Name of the credential to retrieve", + }, + "model_id": { + Type: schema.TypeString, + Optional: true, + Description: "Model ID associated with this credential", + }, + "credential_info": { + Type: schema.TypeMap, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Additional information about the credential", + }, + // Note: credential_values are not exposed in data sources for security reasons + }, + } +} + +func dataSourceLiteLLMCredentialRead(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + credentialName := d.Get("credential_name").(string) + modelID := d.Get("model_id").(string) + + // Use the same endpoint as the resource read operation + endpoint := fmt.Sprintf("/credentials/by_name/%s", credentialName) + if modelID != "" { + endpoint += fmt.Sprintf("?model_id=%s", modelID) + } + + resp, err := MakeRequest(client, "GET", endpoint, nil) + if err != nil { + return fmt.Errorf("failed to read credential: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("credential '%s' not found", credentialName) + } + + var credentialResp CredentialResponse + err = handleCredentialAPIResponse(resp, &credentialResp, client) + if err != nil { + if err.Error() == "credential_not_found" { + return fmt.Errorf("credential '%s' not found", credentialName) + } + return fmt.Errorf("failed to read credential: %w", err) + } + + // Set the data source ID to the credential name + d.SetId(credentialResp.CredentialName) + d.Set("credential_name", credentialResp.CredentialName) + d.Set("credential_info", credentialResp.CredentialInfo) + // Note: We don't expose credential_values in data sources for security reasons + + return nil +} diff --git a/terraform/provider/litellm/data_source_vector_store.go b/terraform/provider/litellm/data_source_vector_store.go new file mode 100644 index 00000000000..d39a2f92af4 --- /dev/null +++ b/terraform/provider/litellm/data_source_vector_store.go @@ -0,0 +1,107 @@ +package litellm + +import ( + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceLiteLLMVectorStore() *schema.Resource { + return &schema.Resource{ + Read: dataSourceLiteLLMVectorStoreRead, + + Schema: map[string]*schema.Schema{ + "vector_store_id": { + Type: schema.TypeString, + Required: true, + Description: "Unique identifier for the vector store to retrieve", + }, + "vector_store_name": { + Type: schema.TypeString, + Computed: true, + Description: "Name of the vector store", + }, + "custom_llm_provider": { + Type: schema.TypeString, + Computed: true, + Description: "Custom LLM provider for the vector store", + }, + "vector_store_description": { + Type: schema.TypeString, + Computed: true, + Description: "Description of the vector store", + }, + "vector_store_metadata": { + Type: schema.TypeMap, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Metadata associated with the vector store", + }, + "litellm_credential_name": { + Type: schema.TypeString, + Computed: true, + Description: "Name of the LiteLLM credential used", + }, + "litellm_params": { + Type: schema.TypeMap, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Additional LiteLLM parameters", + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + Description: "Timestamp when the vector store was created", + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Timestamp when the vector store was last updated", + }, + }, + } +} + +func dataSourceLiteLLMVectorStoreRead(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + vectorStoreID := d.Get("vector_store_id").(string) + + // Use the info endpoint to get vector store details + infoRequest := VectorStoreInfoRequest{ + VectorStoreID: vectorStoreID, + } + + resp, err := MakeRequest(client, "POST", "/vector_store/info", infoRequest) + if err != nil { + return fmt.Errorf("failed to read vector store: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("vector store '%s' not found", vectorStoreID) + } + + var vectorStoreResp VectorStoreResponse + err = handleVectorStoreAPIResponse(resp, &vectorStoreResp, client) + if err != nil { + if err.Error() == "vector_store_not_found" { + return fmt.Errorf("vector store '%s' not found", vectorStoreID) + } + return fmt.Errorf("failed to read vector store: %w", err) + } + + // Set the data source ID to the vector store ID + d.SetId(vectorStoreResp.VectorStoreID) + d.Set("vector_store_id", vectorStoreResp.VectorStoreID) + d.Set("vector_store_name", vectorStoreResp.VectorStoreName) + d.Set("custom_llm_provider", vectorStoreResp.CustomLLMProvider) + d.Set("vector_store_description", vectorStoreResp.VectorStoreDescription) + d.Set("vector_store_metadata", vectorStoreResp.VectorStoreMetadata) + d.Set("litellm_credential_name", vectorStoreResp.LiteLLMCredentialName) + d.Set("litellm_params", vectorStoreResp.LiteLLMParams) + d.Set("created_at", vectorStoreResp.CreatedAt) + d.Set("updated_at", vectorStoreResp.UpdatedAt) + + return nil +} diff --git a/terraform/provider/litellm/provider.go b/terraform/provider/litellm/provider.go new file mode 100644 index 00000000000..57f9cc24183 --- /dev/null +++ b/terraform/provider/litellm/provider.go @@ -0,0 +1,63 @@ +package litellm + +import ( + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +// Provider returns a terraform.ResourceProvider. +func Provider() *schema.Provider { + return &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "litellm_model": resourceLiteLLMModel(), + "litellm_team": ResourceLiteLLMTeam(), + "litellm_organization": resourceLiteLLMOrganization(), + "litellm_organization_member": resourceLiteLLMOrganizationMember(), + "litellm_organization_member_add": resourceLiteLLMOrganizationMemberAdd(), + "litellm_team_member": resourceLiteLLMTeamMember(), + "litellm_team_member_add": resourceLiteLLMTeamMemberAdd(), + "litellm_key": resourceKey(), + "litellm_mcp_server": resourceLiteLLMMCPServer(), + "litellm_credential": resourceLiteLLMCredential(), + "litellm_vector_store": resourceLiteLLMVectorStore(), + }, + DataSourcesMap: map[string]*schema.Resource{ + "litellm_credential": dataSourceLiteLLMCredential(), + "litellm_vector_store": dataSourceLiteLLMVectorStore(), + }, + Schema: map[string]*schema.Schema{ + "api_base": { + Type: schema.TypeString, + Required: true, + Sensitive: false, + DefaultFunc: schema.EnvDefaultFunc("LITELLM_API_BASE", nil), + Description: "The base URL of the LiteLLM API", + }, + "api_key": { + Type: schema.TypeString, + Required: true, + Sensitive: true, + DefaultFunc: schema.EnvDefaultFunc("LITELLM_API_KEY", nil), + Description: "The API key for authenticating with LiteLLM", + }, + "insecure_skip_verify": { + Type: schema.TypeBool, + Optional: true, + Default: false, + DefaultFunc: schema.EnvDefaultFunc("LITELLM_INSECURE_SKIP_VERIFY", false), + Description: "Skip TLS certificate verification. Only use for development or when using self-signed certificates", + }, + }, + ConfigureFunc: providerConfigure, + } +} + +// providerConfigure configures the provider with the given schema data. +func providerConfigure(d *schema.ResourceData) (interface{}, error) { + config := ProviderConfig{ + APIBase: d.Get("api_base").(string), + APIKey: d.Get("api_key").(string), + InsecureSkipVerify: d.Get("insecure_skip_verify").(bool), + } + + return NewClient(config.APIBase, config.APIKey, config.InsecureSkipVerify), nil +} diff --git a/terraform/provider/litellm/provider_test.go b/terraform/provider/litellm/provider_test.go new file mode 100644 index 00000000000..00817e7c410 --- /dev/null +++ b/terraform/provider/litellm/provider_test.go @@ -0,0 +1,83 @@ +package litellm + +import ( + "os" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +var testAccProviders map[string]*schema.Provider +var testAccProvider *schema.Provider + +func init() { + testAccProvider = Provider() + testAccProviders = map[string]*schema.Provider{ + "litellm": testAccProvider, + } +} + +func TestProvider(t *testing.T) { + if err := Provider().InternalValidate(); err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestProvider_impl(t *testing.T) { + var _ *schema.Provider = Provider() +} + +func testAccPreCheck(t *testing.T) { + if v := os.Getenv("LITELLM_API_BASE"); v == "" { + t.Fatal("LITELLM_API_BASE must be set for acceptance tests") + } + if v := os.Getenv("LITELLM_API_KEY"); v == "" { + t.Fatal("LITELLM_API_KEY must be set for acceptance tests") + } + + // Create test users needed for organization member tests + createTestUsers(t) +} + +func createTestUsers(t *testing.T) { + apiBase := os.Getenv("LITELLM_API_BASE") + apiKey := os.Getenv("LITELLM_API_KEY") + + if apiBase == "" || apiKey == "" { + return + } + + client := NewClient(apiBase, apiKey, false) + + // Create test users + users := []map[string]interface{}{ + { + "user_id": "test-user-1", + "user_email": "test-user-1@example.com", + "user_role": "internal_user", + }, + { + "user_id": "bulk-user-1", + "user_email": "bulk-user-1@example.com", + "user_role": "internal_user", + }, + { + "user_id": "bulk-user-2", + "user_email": "bulk-user-2@example.com", + "user_role": "internal_user", + }, + } + + for _, user := range users { + _, err := client.sendRequest("POST", "/user/new", user) + if err != nil { + // Silently ignore if user already exists (400 error) + // This is expected when running tests multiple times + errStr := err.Error() + if !strings.Contains(errStr, "400") && !strings.Contains(errStr, "already exists") { + t.Logf("Warning: Could not create user %s: %v", user["user_id"], err) + } + } + } +} diff --git a/terraform/provider/litellm/resource_credential.go b/terraform/provider/litellm/resource_credential.go new file mode 100644 index 00000000000..f668a46a324 --- /dev/null +++ b/terraform/provider/litellm/resource_credential.go @@ -0,0 +1,44 @@ +package litellm + +import ( + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceLiteLLMCredential() *schema.Resource { + return &schema.Resource{ + Create: resourceLiteLLMCredentialCreate, + Read: resourceLiteLLMCredentialRead, + Update: resourceLiteLLMCredentialUpdate, + Delete: resourceLiteLLMCredentialDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "credential_name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Name of the credential", + }, + "model_id": { + Type: schema.TypeString, + Optional: true, + Description: "Model ID associated with this credential", + }, + "credential_info": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Additional information about the credential", + }, + "credential_values": { + Type: schema.TypeMap, + Required: true, + Sensitive: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Sensitive credential values (API keys, tokens, etc.)", + }, + }, + } +} diff --git a/terraform/provider/litellm/resource_credential_crud.go b/terraform/provider/litellm/resource_credential_crud.go new file mode 100644 index 00000000000..dd9aef64f76 --- /dev/null +++ b/terraform/provider/litellm/resource_credential_crud.go @@ -0,0 +1,204 @@ +package litellm + +import ( + "fmt" + "log" + "net/http" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +// retryCredentialRead attempts to read a credential with exponential backoff. +// If the read path clears the ID (e.g., transient 404 right after create), +// we treat it as retryable instead of accepting an empty state. +func retryCredentialRead(d *schema.ResourceData, m interface{}, maxRetries int) error { + var err error + delay := 1 * time.Second + maxDelay := 10 * time.Second + origID := d.Id() + + for i := 0; i < maxRetries; i++ { + log.Printf("[INFO] Attempting to read credential (attempt %d/%d)", i+1, maxRetries) + + err = resourceLiteLLMCredentialRead(d, m) + // If read succeeded but wiped the ID, treat as not found so we retry. + if err == nil && d.Id() == "" { + d.SetId(origID) + err = fmt.Errorf("credential_not_found") + } + + if err == nil { + log.Printf("[INFO] Successfully read credential after %d attempts", i+1) + return nil + } + + if !strings.Contains(err.Error(), "credential_not_found") { + return err + } + + if i < maxRetries-1 { + log.Printf("[INFO] Credential not found yet, retrying in %v...", delay) + time.Sleep(delay) + + delay *= 2 + if delay > maxDelay { + delay = maxDelay + } + } + } + + log.Printf("[WARN] Failed to read credential after %d attempts: %v", maxRetries, err) + return err +} + +func resourceLiteLLMCredentialCreate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + credentialName := d.Get("credential_name").(string) + modelID := d.Get("model_id").(string) + credentialInfo := d.Get("credential_info").(map[string]interface{}) + credentialValues := d.Get("credential_values").(map[string]interface{}) + + // Convert credential_info to map[string]interface{} for JSON + credInfoMap := make(map[string]interface{}) + for k, v := range credentialInfo { + credInfoMap[k] = v + } + + // Convert credential_values to map[string]interface{} for JSON + credValuesMap := make(map[string]interface{}) + for k, v := range credentialValues { + credValuesMap[k] = v + } + + credentialRequest := CredentialRequest{ + CredentialName: credentialName, + ModelID: modelID, + CredentialInfo: credInfoMap, + CredentialValues: credValuesMap, + } + + resp, err := MakeRequest(client, "POST", "/credentials", credentialRequest) + if err != nil { + return fmt.Errorf("failed to create credential: %w", err) + } + defer resp.Body.Close() + + err = handleCredentialAPIResponse(resp, nil, client) + if err != nil { + return fmt.Errorf("failed to create credential: %w", err) + } + + // Set the resource ID to the credential name + d.SetId(credentialName) + + log.Printf("[INFO] Credential created with name %s. Starting retry mechanism to read the credential...", credentialName) + return retryCredentialRead(d, m, 5) +} + +func resourceLiteLLMCredentialRead(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + credentialName := d.Id() + + // Try to get credential by name first + modelID := d.Get("model_id").(string) + endpoint := fmt.Sprintf("/credentials/by_name/%s", credentialName) + if modelID != "" { + endpoint += fmt.Sprintf("?model_id=%s", modelID) + } + + resp, err := MakeRequest(client, "GET", endpoint, nil) + if err != nil { + return fmt.Errorf("failed to read credential: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + d.SetId("") + return nil + } + + var credentialResp CredentialResponse + err = handleCredentialAPIResponse(resp, &credentialResp, client) + if err != nil { + if err.Error() == "credential_not_found" { + d.SetId("") + return nil + } + return fmt.Errorf("failed to read credential: %w", err) + } + + d.Set("credential_name", credentialResp.CredentialName) + d.Set("credential_info", credentialResp.CredentialInfo) + // Note: We don't set credential_values from the response for security reasons + // The API might not return sensitive values, and we want to preserve what's in state + + return nil +} + +func resourceLiteLLMCredentialUpdate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + credentialName := d.Id() + + credentialInfo := d.Get("credential_info").(map[string]interface{}) + credentialValues := d.Get("credential_values").(map[string]interface{}) + + // Convert credential_info to map[string]interface{} for JSON + credInfoMap := make(map[string]interface{}) + for k, v := range credentialInfo { + credInfoMap[k] = v + } + + // Convert credential_values to map[string]interface{} for JSON + credValuesMap := make(map[string]interface{}) + for k, v := range credentialValues { + credValuesMap[k] = v + } + + credentialRequest := CredentialRequest{ + CredentialName: credentialName, + CredentialInfo: credInfoMap, + CredentialValues: credValuesMap, + } + + endpoint := fmt.Sprintf("/credentials/%s", credentialName) + resp, err := MakeRequest(client, "PATCH", endpoint, credentialRequest) + if err != nil { + return fmt.Errorf("failed to update credential: %w", err) + } + defer resp.Body.Close() + + err = handleCredentialAPIResponse(resp, nil, client) + if err != nil { + return fmt.Errorf("failed to update credential: %w", err) + } + + log.Printf("[INFO] Credential updated with name %s. Starting retry mechanism to read the credential...", credentialName) + return retryCredentialRead(d, m, 5) +} + +func resourceLiteLLMCredentialDelete(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + credentialName := d.Id() + + endpoint := fmt.Sprintf("/credentials/%s", credentialName) + resp, err := MakeRequest(client, "DELETE", endpoint, nil) + if err != nil { + return fmt.Errorf("failed to delete credential: %w", err) + } + defer resp.Body.Close() + + err = handleCredentialAPIResponse(resp, nil, client) + if err != nil { + if err.Error() == "credential_not_found" { + d.SetId("") + return nil + } + return fmt.Errorf("failed to delete credential: %w", err) + } + + d.SetId("") + return nil +} diff --git a/terraform/provider/litellm/resource_credential_crud_test.go b/terraform/provider/litellm/resource_credential_crud_test.go new file mode 100644 index 00000000000..3398e58dd13 --- /dev/null +++ b/terraform/provider/litellm/resource_credential_crud_test.go @@ -0,0 +1,201 @@ +package litellm + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +// newTestResourceData creates a *schema.ResourceData with the credential schema, +// sets the ID and populates the required fields. +func newTestResourceData(t *testing.T, id string) *schema.ResourceData { + t.Helper() + d := schema.TestResourceDataRaw(t, resourceLiteLLMCredential().Schema, map[string]interface{}{ + "credential_name": id, + "model_id": "", + "credential_info": map[string]interface{}{}, + "credential_values": map[string]interface{}{"key": "val"}, + }) + d.SetId(id) + return d +} + +func TestRetryCredentialRead_SuccessOnFirstAttempt(t *testing.T) { + resp := CredentialResponse{ + CredentialName: "test-cred", + CredentialInfo: map[string]interface{}{"provider": "aws"}, + } + body, _ := json.Marshal(resp) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(body) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := newTestResourceData(t, "test-cred") + + err := retryCredentialRead(d, client, 3) + if err != nil { + t.Fatalf("expected nil error, got: %v", err) + } + if d.Id() != "test-cred" { + t.Fatalf("expected ID 'test-cred', got %q", d.Id()) + } +} + +func TestRetryCredentialRead_SuccessAfterRetries(t *testing.T) { + resp := CredentialResponse{ + CredentialName: "test-cred", + CredentialInfo: map[string]interface{}{"provider": "aws"}, + } + body, _ := json.Marshal(resp) + + var callCount int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&callCount, 1) + w.Header().Set("Content-Type", "application/json") + if n <= 2 { + // First two calls return 404, triggering retry + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + w.Write(body) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := newTestResourceData(t, "test-cred") + + err := retryCredentialRead(d, client, 3) + if err != nil { + t.Fatalf("expected nil error, got: %v", err) + } + if d.Id() != "test-cred" { + t.Fatalf("expected ID 'test-cred', got %q", d.Id()) + } + if atomic.LoadInt32(&callCount) != 3 { + t.Fatalf("expected 3 HTTP calls, got %d", callCount) + } +} + +func TestRetryCredentialRead_ExhaustsRetries(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := newTestResourceData(t, "test-cred") + + err := retryCredentialRead(d, client, 2) + if err == nil { + t.Fatal("expected error after exhausting retries, got nil") + } + if err.Error() != "credential_not_found" { + t.Fatalf("expected 'credential_not_found' error, got: %v", err) + } + // ID should still be restored (not wiped) + if d.Id() != "test-cred" { + t.Fatalf("expected ID to be restored to 'test-cred', got %q", d.Id()) + } +} + +func TestRetryCredentialRead_NonRetryableError(t *testing.T) { + var callCount int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&callCount, 1) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error": "internal server error"}`)) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := newTestResourceData(t, "test-cred") + + err := retryCredentialRead(d, client, 3) + if err == nil { + t.Fatal("expected error for 500 response, got nil") + } + // Should fail on first attempt without retrying + if atomic.LoadInt32(&callCount) != 1 { + t.Fatalf("expected 1 HTTP call (no retries for non-retryable error), got %d", callCount) + } +} + +func TestRetryCredentialRead_IDRestoredBetweenRetries(t *testing.T) { + // Verify the ID is restored after each failed attempt where the read clears it. + // resourceLiteLLMCredentialRead sets ID to "" on 404, and retryCredentialRead + // should restore it before the next attempt. + resp := CredentialResponse{ + CredentialName: "my-cred", + CredentialInfo: map[string]interface{}{}, + } + body, _ := json.Marshal(resp) + + var callCount int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&callCount, 1) + w.Header().Set("Content-Type", "application/json") + if n == 1 { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + w.Write(body) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := newTestResourceData(t, "my-cred") + + err := retryCredentialRead(d, client, 2) + if err != nil { + t.Fatalf("expected nil error, got: %v", err) + } + if d.Id() != "my-cred" { + t.Fatalf("expected ID 'my-cred', got %q", d.Id()) + } +} + +func TestRetryCredentialRead_MaxRetriesOne(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := newTestResourceData(t, "test-cred") + + err := retryCredentialRead(d, client, 1) + if err == nil { + t.Fatal("expected error with maxRetries=1 and always-404, got nil") + } + if err.Error() != "credential_not_found" { + t.Fatalf("expected 'credential_not_found', got: %v", err) + } +} + +func TestRetryCredentialRead_ConnectionError(t *testing.T) { + // Point to a server that's already closed to simulate connection failure + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := newTestResourceData(t, "test-cred") + + err := retryCredentialRead(d, client, 1) + if err == nil { + t.Fatal("expected error for connection failure, got nil") + } + // Connection error should not be retried (not a "credential_not_found") + fmt.Printf("connection error (expected): %v\n", err) +} diff --git a/terraform/provider/litellm/resource_key.go b/terraform/provider/litellm/resource_key.go new file mode 100644 index 00000000000..5c80198cf6a --- /dev/null +++ b/terraform/provider/litellm/resource_key.go @@ -0,0 +1,319 @@ +package litellm + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceKey() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceKeyCreate, + ReadContext: resourceKeyRead, + UpdateContext: resourceKeyUpdate, + DeleteContext: resourceKeyDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + Schema: map[string]*schema.Schema{ + "key": { + Type: schema.TypeString, + Optional: true, + WriteOnly: true, + Sensitive: true, + }, + "token_id": { + Type: schema.TypeString, + Computed: true, + }, + "models": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "max_budget": { + Type: schema.TypeFloat, + Optional: true, + Computed: true, + }, + "user_id": { + Type: schema.TypeString, + Optional: true, + }, + "team_id": { + Type: schema.TypeString, + Optional: true, + }, + "max_parallel_requests": { + Type: schema.TypeInt, + Optional: true, + Computed: true, + }, + "metadata": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "tpm_limit": { + Type: schema.TypeInt, + Optional: true, + Computed: true, + }, + "rpm_limit": { + Type: schema.TypeInt, + Optional: true, + Computed: true, + }, + "budget_duration": { + Type: schema.TypeString, + Optional: true, + }, + "allowed_cache_controls": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "soft_budget": { + Type: schema.TypeFloat, + Optional: true, + Computed: true, + }, + "key_alias": { + Type: schema.TypeString, + Optional: true, + }, + "duration": { + Type: schema.TypeString, + Optional: true, + }, + "aliases": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "config": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "permissions": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "model_max_budget": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeFloat, Computed: true}, + }, + "model_rpm_limit": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeInt, Computed: true}, + }, + "model_tpm_limit": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeInt, Computed: true}, + }, + "guardrails": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "blocked": { + Type: schema.TypeBool, + Optional: true, + }, + "tags": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "spend": { + Type: schema.TypeFloat, + Computed: true, + }, + }, + } +} + +func resourceKeyCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(*Client) + + key := &Key{} + mapResourceDataToKey(d, key) + + createdKey, err := c.CreateKey(key) + if err != nil { + return diag.FromErr(fmt.Errorf("error creating key: %s", err)) + } + + d.SetId(createdKey.TokenID) + // Set the write-only key value so it's available during this apply + // but will not be persisted to state. + d.Set("key", createdKey.Key) + return resourceKeyRead(ctx, d, m) +} + +func resourceKeyRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(*Client) + + key, err := c.GetKey(d.Id()) + if err != nil { + return diag.FromErr(fmt.Errorf("error reading key: %s", err)) + } + + if key == nil { + d.SetId("") + return nil + } + + mapKeyToResourceData(d, key) + return nil +} + +func resourceKeyUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(*Client) + + key := &Key{Key: d.Id()} + mapResourceDataToKey(d, key) + + _, err := c.UpdateKey(key) + if err != nil { + return diag.FromErr(fmt.Errorf("error updating key: %s", err)) + } + + return resourceKeyRead(ctx, d, m) +} + +func resourceKeyDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(*Client) + + err := c.DeleteKey(d.Id()) + if err != nil { + return diag.FromErr(fmt.Errorf("error deleting key: %s", err)) + } + + d.SetId("") + return nil +} + +func mapResourceDataToKey(d *schema.ResourceData, key *Key) { + key.Models = expandStringList(d.Get("models").([]interface{})) + if v, ok := d.GetOk("max_budget"); ok { + val := v.(float64) + key.MaxBudget = &val + } + key.UserID = d.Get("user_id").(string) + key.TeamID = d.Get("team_id").(string) + if v, ok := d.GetOk("max_parallel_requests"); ok { + val := v.(int) + key.MaxParallelRequests = &val + } + key.Metadata = d.Get("metadata").(map[string]interface{}) + if v, ok := d.GetOk("tpm_limit"); ok { + val := v.(int) + key.TPMLimit = &val + } + if v, ok := d.GetOk("rpm_limit"); ok { + val := v.(int) + key.RPMLimit = &val + } + key.BudgetDuration = d.Get("budget_duration").(string) + key.AllowedCacheControls = expandStringList(d.Get("allowed_cache_controls").([]interface{})) + if v, ok := d.GetOk("soft_budget"); ok { + val := v.(float64) + key.SoftBudget = &val + } + key.KeyAlias = d.Get("key_alias").(string) + key.Duration = d.Get("duration").(string) + key.Aliases = d.Get("aliases").(map[string]interface{}) + key.Config = d.Get("config").(map[string]interface{}) + key.Permissions = d.Get("permissions").(map[string]interface{}) + key.ModelMaxBudget = d.Get("model_max_budget").(map[string]interface{}) + key.ModelRPMLimit = d.Get("model_rpm_limit").(map[string]interface{}) + key.ModelTPMLimit = d.Get("model_tpm_limit").(map[string]interface{}) + key.Guardrails = expandStringList(d.Get("guardrails").([]interface{})) + key.Blocked = d.Get("blocked").(bool) + key.Tags = expandStringList(d.Get("tags").([]interface{})) +} + +func mapKeyToResourceData(d *schema.ResourceData, key *Key) { + // token_id is the SHA-256 hash of the key, used as the resource ID. + // It is safe to store in state since it cannot be used to authenticate. + d.Set("token_id", d.Id()) + + // Note: "key" is write-only and must not be set here (Read operations). + // It is only set during Create so it is available during apply. + + if len(key.Models) > 0 { + d.Set("models", key.Models) + } + if key.MaxBudget != nil { + d.Set("max_budget", *key.MaxBudget) + } + if key.UserID != "" { + d.Set("user_id", key.UserID) + } + if key.TeamID != "" { + d.Set("team_id", key.TeamID) + } + if key.MaxParallelRequests != nil { + d.Set("max_parallel_requests", *key.MaxParallelRequests) + } + if key.Metadata != nil { + d.Set("metadata", key.Metadata) + } + if key.TPMLimit != nil { + d.Set("tpm_limit", *key.TPMLimit) + } + if key.RPMLimit != nil { + d.Set("rpm_limit", *key.RPMLimit) + } + if key.BudgetDuration != "" { + d.Set("budget_duration", key.BudgetDuration) + } + if len(key.AllowedCacheControls) > 0 { + d.Set("allowed_cache_controls", key.AllowedCacheControls) + } + if key.SoftBudget != nil { + d.Set("soft_budget", *key.SoftBudget) + } + if key.KeyAlias != "" { + d.Set("key_alias", key.KeyAlias) + } + if key.Duration != "" { + d.Set("duration", key.Duration) + } + if key.Aliases != nil { + d.Set("aliases", key.Aliases) + } + if key.Config != nil { + d.Set("config", key.Config) + } + if key.Permissions != nil { + d.Set("permissions", key.Permissions) + } + if key.ModelMaxBudget != nil { + d.Set("model_max_budget", key.ModelMaxBudget) + } + if key.ModelRPMLimit != nil { + d.Set("model_rpm_limit", key.ModelRPMLimit) + } + if key.ModelTPMLimit != nil { + d.Set("model_tpm_limit", key.ModelTPMLimit) + } + if len(key.Guardrails) > 0 { + d.Set("guardrails", key.Guardrails) + } + d.Set("blocked", key.Blocked) + if len(key.Tags) > 0 { + d.Set("tags", key.Tags) + } + if key.Spend != 0 { + d.Set("spend", key.Spend) + } +} diff --git a/terraform/provider/litellm/resource_key_utils.go b/terraform/provider/litellm/resource_key_utils.go new file mode 100644 index 00000000000..d426fec05b2 --- /dev/null +++ b/terraform/provider/litellm/resource_key_utils.go @@ -0,0 +1,230 @@ +package litellm + +import ( + "fmt" + "log" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func buildKeyData(d *schema.ResourceData) map[string]interface{} { + keyData := make(map[string]interface{}) + + if v, ok := d.GetOkExists("models"); ok { + models := expandStringList(v.([]interface{})) + if len(models) > 0 { + keyData["models"] = models + } + } + if v, ok := d.GetOkExists("max_budget"); ok { + keyData["max_budget"] = v.(float64) + } + if v, ok := d.GetOkExists("user_id"); ok { + keyData["user_id"] = v.(string) + } + if v, ok := d.GetOkExists("team_id"); ok { + keyData["team_id"] = v.(string) + } + if v, ok := d.GetOkExists("max_parallel_requests"); ok { + keyData["max_parallel_requests"] = v.(int) + } + if v, ok := d.GetOkExists("metadata"); ok { + keyData["metadata"] = v.(map[string]interface{}) + } + if v, ok := d.GetOkExists("tpm_limit"); ok { + keyData["tpm_limit"] = v.(int) + } + if v, ok := d.GetOkExists("rpm_limit"); ok { + keyData["rpm_limit"] = v.(int) + } + if v, ok := d.GetOkExists("budget_duration"); ok { + keyData["budget_duration"] = v.(string) + } + if v, ok := d.GetOkExists("allowed_cache_controls"); ok { + cacheControls := expandStringList(v.([]interface{})) + if len(cacheControls) > 0 { + keyData["allowed_cache_controls"] = cacheControls + } + } + if v, ok := d.GetOkExists("soft_budget"); ok { + keyData["soft_budget"] = v.(float64) + } + if v, ok := d.GetOkExists("key_alias"); ok { + keyData["key_alias"] = v.(string) + } + if v, ok := d.GetOkExists("duration"); ok { + keyData["duration"] = v.(string) + } + if v, ok := d.GetOkExists("aliases"); ok { + keyData["aliases"] = v.(map[string]interface{}) + } + if v, ok := d.GetOkExists("config"); ok { + keyData["config"] = v.(map[string]interface{}) + } + if v, ok := d.GetOkExists("permissions"); ok { + keyData["permissions"] = v.(map[string]interface{}) + } + if v, ok := d.GetOkExists("model_max_budget"); ok { + keyData["model_max_budget"] = v.(map[string]interface{}) + } + if v, ok := d.GetOkExists("model_rpm_limit"); ok { + keyData["model_rpm_limit"] = v.(map[string]interface{}) + } + if v, ok := d.GetOkExists("model_tpm_limit"); ok { + keyData["model_tpm_limit"] = v.(map[string]interface{}) + } + if v, ok := d.GetOkExists("guardrails"); ok { + guardrails := expandStringList(v.([]interface{})) + if len(guardrails) > 0 { + keyData["guardrails"] = guardrails + } + } + if v, ok := d.GetOkExists("blocked"); ok { + keyData["blocked"] = v.(bool) + } + if v, ok := d.GetOkExists("tags"); ok { + tags := expandStringList(v.([]interface{})) + if len(tags) > 0 { + keyData["tags"] = tags + } + } + + return keyData +} + +func setKeyResourceData(d *schema.ResourceData, key *Key) error { + fields := map[string]interface{}{ + "key": key.Key, + "models": key.Models, + "spend": key.Spend, + "user_id": key.UserID, + "team_id": key.TeamID, + "metadata": key.Metadata, + "budget_duration": key.BudgetDuration, + "allowed_cache_controls": key.AllowedCacheControls, + "key_alias": key.KeyAlias, + "duration": key.Duration, + "aliases": key.Aliases, + "config": key.Config, + "permissions": key.Permissions, + "model_max_budget": key.ModelMaxBudget, + "model_rpm_limit": key.ModelRPMLimit, + "model_tpm_limit": key.ModelTPMLimit, + "guardrails": key.Guardrails, + "blocked": key.Blocked, + "tags": key.Tags, + } + + for field, value := range fields { + if err := d.Set(field, value); err != nil { + log.Printf("[WARN] Error setting %s: %s", field, err) + return fmt.Errorf("error setting %s: %s", field, err) + } + } + + // Handle pointer fields separately - only set if not nil + if key.MaxBudget != nil { + if err := d.Set("max_budget", *key.MaxBudget); err != nil { + return fmt.Errorf("error setting max_budget: %s", err) + } + } + if key.SoftBudget != nil { + if err := d.Set("soft_budget", *key.SoftBudget); err != nil { + return fmt.Errorf("error setting soft_budget: %s", err) + } + } + if key.MaxParallelRequests != nil { + if err := d.Set("max_parallel_requests", *key.MaxParallelRequests); err != nil { + return fmt.Errorf("error setting max_parallel_requests: %s", err) + } + } + if key.TPMLimit != nil { + if err := d.Set("tpm_limit", *key.TPMLimit); err != nil { + return fmt.Errorf("error setting tpm_limit: %s", err) + } + } + if key.RPMLimit != nil { + if err := d.Set("rpm_limit", *key.RPMLimit); err != nil { + return fmt.Errorf("error setting rpm_limit: %s", err) + } + } + + return nil +} + +func expandStringList(list []interface{}) []string { + result := make([]string, len(list)) + for i, v := range list { + result[i] = v.(string) + } + return result +} + +func mapToKey(data map[string]interface{}) *Key { + key := &Key{} + for k, v := range data { + switch k { + case "key": + key.Key = v.(string) + case "models": + key.Models = v.([]string) + case "max_budget": + if v, ok := v.(float64); ok { + key.MaxBudget = &v + } + case "user_id": + key.UserID = v.(string) + case "team_id": + key.TeamID = v.(string) + case "max_parallel_requests": + if v, ok := v.(int); ok { + key.MaxParallelRequests = &v + } + case "metadata": + key.Metadata = v.(map[string]interface{}) + case "tpm_limit": + if v, ok := v.(int); ok { + key.TPMLimit = &v + } + case "rpm_limit": + if v, ok := v.(int); ok { + key.RPMLimit = &v + } + case "budget_duration": + key.BudgetDuration = v.(string) + case "allowed_cache_controls": + key.AllowedCacheControls = v.([]string) + case "soft_budget": + if v, ok := v.(float64); ok { + key.SoftBudget = &v + } + case "key_alias": + key.KeyAlias = v.(string) + case "duration": + key.Duration = v.(string) + case "aliases": + key.Aliases = v.(map[string]interface{}) + case "config": + key.Config = v.(map[string]interface{}) + case "permissions": + key.Permissions = v.(map[string]interface{}) + case "model_max_budget": + key.ModelMaxBudget = v.(map[string]interface{}) + case "model_rpm_limit": + key.ModelRPMLimit = v.(map[string]interface{}) + case "model_tpm_limit": + key.ModelTPMLimit = v.(map[string]interface{}) + case "guardrails": + key.Guardrails = v.([]string) + case "blocked": + key.Blocked = v.(bool) + case "tags": + key.Tags = v.([]string) + } + } + return key +} + +func buildKeyForCreation(data map[string]interface{}) *Key { + return mapToKey(data) +} diff --git a/terraform/provider/litellm/resource_mcp_server.go b/terraform/provider/litellm/resource_mcp_server.go new file mode 100644 index 00000000000..b3eaef4a468 --- /dev/null +++ b/terraform/provider/litellm/resource_mcp_server.go @@ -0,0 +1,176 @@ +package litellm + +import ( + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceLiteLLMMCPServer() *schema.Resource { + return &schema.Resource{ + Create: resourceLiteLLMMCPServerCreate, + Read: resourceLiteLLMMCPServerRead, + Update: resourceLiteLLMMCPServerUpdate, + Delete: resourceLiteLLMMCPServerDelete, + + Schema: map[string]*schema.Schema{ + "server_name": { + Type: schema.TypeString, + Required: true, + Description: "Name of the MCP server", + }, + "alias": { + Type: schema.TypeString, + Optional: true, + Description: "Alias for the MCP server", + }, + "description": { + Type: schema.TypeString, + Optional: true, + Description: "Description of the MCP server", + }, + "url": { + Type: schema.TypeString, + Required: true, + Description: "URL of the MCP server", + }, + "transport": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{ + "http", + "sse", + "stdio", + }, false), + Description: "Transport type for the MCP server (http, sse, stdio)", + }, + "spec_version": { + Type: schema.TypeString, + Optional: true, + Default: "2024-11-05", + Description: "MCP specification version", + }, + "auth_type": { + Type: schema.TypeString, + Optional: true, + Default: "none", + ValidateFunc: validation.StringInSlice([]string{ + "none", + "bearer", + "basic", + }, false), + Description: "Authentication type (none, bearer, basic)", + }, + "mcp_access_groups": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "List of access groups for the MCP server", + }, + "command": { + Type: schema.TypeString, + Optional: true, + Description: "Command to run for stdio transport", + }, + "args": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Arguments for the command (stdio transport)", + }, + "env": { + Type: schema.TypeMap, + Optional: true, + Sensitive: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Environment variables for the command (stdio transport)", + }, + "mcp_info": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Description: "MCP server information and configuration", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "server_name": { + Type: schema.TypeString, + Optional: true, + Description: "Server name in MCP info", + }, + "description": { + Type: schema.TypeString, + Optional: true, + Description: "Description in MCP info", + }, + "logo_url": { + Type: schema.TypeString, + Optional: true, + Description: "Logo URL for the MCP server", + }, + "mcp_server_cost_info": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Description: "Cost information for MCP server tools", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "default_cost_per_query": { + Type: schema.TypeFloat, + Optional: true, + Description: "Default cost per query", + }, + "tool_name_to_cost_per_query": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeFloat}, + Description: "Map of tool names to their cost per query", + }, + }, + }, + }, + }, + }, + }, + // Read-only computed fields + "server_id": { + Type: schema.TypeString, + Computed: true, + Description: "Unique identifier for the MCP server", + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + Description: "Timestamp when the server was created", + }, + "created_by": { + Type: schema.TypeString, + Computed: true, + Description: "User who created the server", + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Timestamp when the server was last updated", + }, + "updated_by": { + Type: schema.TypeString, + Computed: true, + Description: "User who last updated the server", + }, + "status": { + Type: schema.TypeString, + Computed: true, + Description: "Current status of the MCP server", + }, + "last_health_check": { + Type: schema.TypeString, + Computed: true, + Description: "Timestamp of the last health check", + }, + "health_check_error": { + Type: schema.TypeString, + Computed: true, + Description: "Error message from the last health check, if any", + }, + }, + } +} diff --git a/terraform/provider/litellm/resource_mcp_server_crud.go b/terraform/provider/litellm/resource_mcp_server_crud.go new file mode 100644 index 00000000000..2a8980960f1 --- /dev/null +++ b/terraform/provider/litellm/resource_mcp_server_crud.go @@ -0,0 +1,317 @@ +package litellm + +import ( + "fmt" + "log" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +const ( + endpointMCPServerCreate = "/v1/mcp/server" + endpointMCPServerUpdate = "/v1/mcp/server" + endpointMCPServerRead = "/v1/mcp/server" + endpointMCPServerDelete = "/v1/mcp/server" +) + +// Helper function to convert schema data to MCPServerRequest +func buildMCPServerRequest(d *schema.ResourceData) *MCPServerRequest { + req := &MCPServerRequest{ + ServerName: d.Get("server_name").(string), + URL: d.Get("url").(string), + Transport: d.Get("transport").(string), + SpecVersion: d.Get("spec_version").(string), + AuthType: d.Get("auth_type").(string), + } + + // Set optional fields + if alias, ok := d.GetOk("alias"); ok { + req.Alias = alias.(string) + } + if description, ok := d.GetOk("description"); ok { + req.Description = description.(string) + } + if command, ok := d.GetOk("command"); ok { + req.Command = command.(string) + } + + // Handle access groups + if accessGroups, ok := d.GetOk("mcp_access_groups"); ok { + accessGroupsList := accessGroups.([]interface{}) + req.MCPAccessGroups = make([]string, len(accessGroupsList)) + for i, group := range accessGroupsList { + req.MCPAccessGroups[i] = group.(string) + } + } + + // Handle args + if args, ok := d.GetOk("args"); ok { + argsList := args.([]interface{}) + req.Args = make([]string, len(argsList)) + for i, arg := range argsList { + req.Args[i] = arg.(string) + } + } + + // Handle env + if env, ok := d.GetOk("env"); ok { + envMap := env.(map[string]interface{}) + req.Env = make(map[string]string) + for k, v := range envMap { + req.Env[k] = v.(string) + } + } + + // Handle mcp_info + if mcpInfoList, ok := d.GetOk("mcp_info"); ok { + mcpInfos := mcpInfoList.([]interface{}) + if len(mcpInfos) > 0 { + mcpInfoMap := mcpInfos[0].(map[string]interface{}) + req.MCPInfo = &MCPInfo{} + + if serverName, ok := mcpInfoMap["server_name"]; ok { + req.MCPInfo.ServerName = serverName.(string) + } + if description, ok := mcpInfoMap["description"]; ok { + req.MCPInfo.Description = description.(string) + } + if logoURL, ok := mcpInfoMap["logo_url"]; ok { + req.MCPInfo.LogoURL = logoURL.(string) + } + + // Handle cost info + if costInfoList, ok := mcpInfoMap["mcp_server_cost_info"]; ok { + costInfos := costInfoList.([]interface{}) + if len(costInfos) > 0 { + costInfoMap := costInfos[0].(map[string]interface{}) + req.MCPInfo.MCPServerCostInfo = &MCPServerCostInfo{} + + if defaultCost, ok := costInfoMap["default_cost_per_query"]; ok { + req.MCPInfo.MCPServerCostInfo.DefaultCostPerQuery = defaultCost.(float64) + } + if toolCosts, ok := costInfoMap["tool_name_to_cost_per_query"]; ok { + toolCostMap := toolCosts.(map[string]interface{}) + req.MCPInfo.MCPServerCostInfo.ToolNameToCostPerQuery = make(map[string]float64) + for k, v := range toolCostMap { + req.MCPInfo.MCPServerCostInfo.ToolNameToCostPerQuery[k] = v.(float64) + } + } + } + } + } + } + + return req +} + +// Helper function to update schema data from MCPServerResponse +func updateSchemaFromResponse(d *schema.ResourceData, resp *MCPServerResponse) error { + d.Set("server_id", resp.ServerID) + d.Set("server_name", resp.ServerName) + d.Set("alias", resp.Alias) + d.Set("description", resp.Description) + d.Set("url", resp.URL) + d.Set("transport", resp.Transport) + d.Set("spec_version", resp.SpecVersion) + d.Set("auth_type", resp.AuthType) + d.Set("created_at", resp.CreatedAt) + d.Set("created_by", resp.CreatedBy) + d.Set("updated_at", resp.UpdatedAt) + d.Set("updated_by", resp.UpdatedBy) + d.Set("status", resp.Status) + d.Set("last_health_check", resp.LastHealthCheck) + d.Set("health_check_error", resp.HealthCheckError) + d.Set("command", resp.Command) + + // Set access groups + if resp.MCPAccessGroups != nil { + d.Set("mcp_access_groups", resp.MCPAccessGroups) + } + + // Set args + if resp.Args != nil { + d.Set("args", resp.Args) + } + + // Set mcp_info + if resp.MCPInfo != nil { + mcpInfoList := make([]map[string]interface{}, 1) + mcpInfoMap := make(map[string]interface{}) + + mcpInfoMap["server_name"] = resp.MCPInfo.ServerName + mcpInfoMap["description"] = resp.MCPInfo.Description + mcpInfoMap["logo_url"] = resp.MCPInfo.LogoURL + + if resp.MCPInfo.MCPServerCostInfo != nil { + costInfoList := make([]map[string]interface{}, 1) + costInfoMap := make(map[string]interface{}) + + costInfoMap["default_cost_per_query"] = resp.MCPInfo.MCPServerCostInfo.DefaultCostPerQuery + if resp.MCPInfo.MCPServerCostInfo.ToolNameToCostPerQuery != nil { + costInfoMap["tool_name_to_cost_per_query"] = resp.MCPInfo.MCPServerCostInfo.ToolNameToCostPerQuery + } + + costInfoList[0] = costInfoMap + mcpInfoMap["mcp_server_cost_info"] = costInfoList + } + + mcpInfoList[0] = mcpInfoMap + d.Set("mcp_info", mcpInfoList) + } + + return nil +} + +func resourceLiteLLMMCPServerCreate(d *schema.ResourceData, m interface{}) error { + client, ok := m.(*Client) + if !ok { + return fmt.Errorf("invalid type assertion for client") + } + + req := buildMCPServerRequest(d) + + resp, err := MakeRequest(client, "POST", endpointMCPServerCreate, req) + if err != nil { + return fmt.Errorf("failed to create MCP server: %w", err) + } + defer resp.Body.Close() + + var mcpResp MCPServerResponse + if err := handleMCPAPIResponse(resp, &mcpResp, client); err != nil { + return fmt.Errorf("failed to create MCP server: %w", err) + } + + d.SetId(mcpResp.ServerID) + + // Update the state with the response data + if err := updateSchemaFromResponse(d, &mcpResp); err != nil { + return fmt.Errorf("failed to update state after create: %w", err) + } + + log.Printf("[INFO] MCP server created with ID %s", mcpResp.ServerID) + return nil +} + +func resourceLiteLLMMCPServerRead(d *schema.ResourceData, m interface{}) error { + client, ok := m.(*Client) + if !ok { + return fmt.Errorf("invalid type assertion for client") + } + + serverID := d.Id() + endpoint := fmt.Sprintf("%s/%s", endpointMCPServerRead, serverID) + + resp, err := MakeRequest(client, "GET", endpoint, nil) + if err != nil { + return fmt.Errorf("failed to read MCP server: %w", err) + } + defer resp.Body.Close() + + var mcpResp MCPServerResponse + if err := handleMCPAPIResponse(resp, &mcpResp, client); err != nil { + if err.Error() == "mcp_server_not_found" { + d.SetId("") + return nil + } + return fmt.Errorf("failed to read MCP server: %w", err) + } + + // Update the state with the response data + if err := updateSchemaFromResponse(d, &mcpResp); err != nil { + return fmt.Errorf("failed to update state after read: %w", err) + } + + return nil +} + +func resourceLiteLLMMCPServerUpdate(d *schema.ResourceData, m interface{}) error { + client, ok := m.(*Client) + if !ok { + return fmt.Errorf("invalid type assertion for client") + } + + req := buildMCPServerRequest(d) + req.ServerID = d.Id() // Ensure we include the server ID for updates + + resp, err := MakeRequest(client, "PUT", endpointMCPServerUpdate, req) + if err != nil { + return fmt.Errorf("failed to update MCP server: %w", err) + } + defer resp.Body.Close() + + var mcpResp MCPServerResponse + if err := handleMCPAPIResponse(resp, &mcpResp, client); err != nil { + return fmt.Errorf("failed to update MCP server: %w", err) + } + + // Update the state with the response data + if err := updateSchemaFromResponse(d, &mcpResp); err != nil { + return fmt.Errorf("failed to update state after update: %w", err) + } + + log.Printf("[INFO] MCP server updated with ID %s", mcpResp.ServerID) + return nil +} + +func resourceLiteLLMMCPServerDelete(d *schema.ResourceData, m interface{}) error { + client, ok := m.(*Client) + if !ok { + return fmt.Errorf("invalid type assertion for client") + } + + serverID := d.Id() + endpoint := fmt.Sprintf("%s/%s", endpointMCPServerDelete, serverID) + + resp, err := MakeRequest(client, "DELETE", endpoint, nil) + if err != nil { + return fmt.Errorf("failed to delete MCP server: %w", err) + } + defer resp.Body.Close() + + // For delete operations, we expect a simple string response + if resp.StatusCode != 200 { + return fmt.Errorf("failed to delete MCP server: unexpected status code %d", resp.StatusCode) + } + + d.SetId("") + log.Printf("[INFO] MCP server deleted with ID %s", serverID) + return nil +} + +// retryMCPServerRead attempts to read an MCP server with exponential backoff +func retryMCPServerRead(d *schema.ResourceData, m interface{}, maxRetries int) error { + var err error + delay := 1 * time.Second + maxDelay := 10 * time.Second + + for i := 0; i < maxRetries; i++ { + log.Printf("[INFO] Attempting to read MCP server (attempt %d/%d)", i+1, maxRetries) + + err = resourceLiteLLMMCPServerRead(d, m) + if err == nil { + log.Printf("[INFO] Successfully read MCP server after %d attempts", i+1) + return nil + } + + // Check if this is a "server not found" error + if err.Error() != "failed to read MCP server: mcp_server_not_found" { + // If it's a different error, don't retry + return err + } + + if i < maxRetries-1 { + log.Printf("[INFO] MCP server not found yet, retrying in %v...", delay) + time.Sleep(delay) + + // Exponential backoff with a maximum delay + delay *= 2 + if delay > maxDelay { + delay = maxDelay + } + } + } + + log.Printf("[WARN] Failed to read MCP server after %d attempts: %v", maxRetries, err) + return err +} diff --git a/terraform/provider/litellm/resource_mcp_server_crud_test.go b/terraform/provider/litellm/resource_mcp_server_crud_test.go new file mode 100644 index 00000000000..17300701954 --- /dev/null +++ b/terraform/provider/litellm/resource_mcp_server_crud_test.go @@ -0,0 +1,44 @@ +package litellm + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func TestMCPServerReadDoesNotPersistServerEnv(t *testing.T) { + d := schema.TestResourceDataRaw(t, resourceLiteLLMMCPServer().Schema, map[string]interface{}{ + "server_name": "gh", + "transport": "stdio", + "command": "npx", + "env": map[string]interface{}{ + "GITHUB_TOKEN": "from-config", + }, + }) + d.SetId("srv-1") + + resp := &MCPServerResponse{ + ServerID: "srv-1", + ServerName: "gh", + Transport: "stdio", + Command: "npx", + Env: map[string]string{ + "GITHUB_TOKEN": "raw-from-server", + "DB_PASSWORD": "leaked-secret", + }, + } + if err := updateSchemaFromResponse(d, resp); err != nil { + t.Fatalf("updateSchemaFromResponse failed: %v", err) + } + + got := d.Get("env").(map[string]interface{}) + if got["GITHUB_TOKEN"] != "from-config" { + t.Fatalf("config env overwritten by server response: %v", got) + } + if _, leaked := got["DB_PASSWORD"]; leaked { + t.Fatalf("server-returned env var persisted into state: %v", got) + } + if d.Get("server_name").(string) != "gh" { + t.Fatalf("read did not populate non-sensitive fields") + } +} diff --git a/terraform/provider/litellm/resource_model.go b/terraform/provider/litellm/resource_model.go new file mode 100644 index 00000000000..2858b6e763d --- /dev/null +++ b/terraform/provider/litellm/resource_model.go @@ -0,0 +1,177 @@ +package litellm + +import ( + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceLiteLLMModel() *schema.Resource { + return &schema.Resource{ + Create: resourceLiteLLMModelCreate, + Read: resourceLiteLLMModelRead, + Update: resourceLiteLLMModelUpdate, + Delete: resourceLiteLLMModelDelete, + + Schema: map[string]*schema.Schema{ + "model_name": { + Type: schema.TypeString, + Required: true, + }, + "custom_llm_provider": { + Type: schema.TypeString, + Required: true, + }, + "tpm": { + Type: schema.TypeInt, + Optional: true, + }, + "rpm": { + Type: schema.TypeInt, + Optional: true, + }, + "reasoning_effort": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringInSlice([]string{ + "low", + "medium", + "high", + }, false), + }, + "thinking_enabled": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + "thinking_budget_tokens": { + Type: schema.TypeInt, + Optional: true, + Default: 1024, + DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool { + // Only include thinking_budget_tokens in the diff if thinking_enabled is true + return !d.Get("thinking_enabled").(bool) + }, + }, + "merge_reasoning_content_in_choices": { + Type: schema.TypeBool, + Optional: true, + }, + "model_api_key": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + }, + "model_api_base": { + Type: schema.TypeString, + Optional: true, + }, + "api_version": { + Type: schema.TypeString, + Optional: true, + }, + "base_model": { + Type: schema.TypeString, + Required: true, + }, + "tier": { + Type: schema.TypeString, + Optional: true, + Default: "free", + }, + "team_id": { + Type: schema.TypeString, + Optional: true, + }, + "mode": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringInSlice([]string{ + "completion", + "embedding", + "image_generation", + "chat", + "moderation", + "audio_transcription", + "audio_speech", + "rerank", + }, false), + }, + "input_cost_per_million_tokens": { + Type: schema.TypeFloat, + Optional: true, + }, + "output_cost_per_million_tokens": { + Type: schema.TypeFloat, + Optional: true, + }, + "input_cost_per_pixel": { + Type: schema.TypeFloat, + Optional: true, + }, + "output_cost_per_pixel": { + Type: schema.TypeFloat, + Optional: true, + }, + "input_cost_per_second": { + Type: schema.TypeFloat, + Optional: true, + }, + "output_cost_per_second": { + Type: schema.TypeFloat, + Optional: true, + }, + "aws_access_key_id": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + }, + "aws_secret_access_key": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + }, + "aws_region_name": { + Type: schema.TypeString, + Optional: true, + }, + "aws_session_name": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + }, + "aws_role_name": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + }, + "vertex_project": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + }, + "vertex_location": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + }, + "vertex_credentials": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + }, + "litellm_credential_name": { + Type: schema.TypeString, + Optional: true, + Description: "Name of the LiteLLM credential to use", + }, + "additional_litellm_params": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Description: "Additional parameters to pass to litellm_params beyond the standard ones", + }, + }, + } +} diff --git a/terraform/provider/litellm/resource_model_crud.go b/terraform/provider/litellm/resource_model_crud.go new file mode 100644 index 00000000000..40766c8e312 --- /dev/null +++ b/terraform/provider/litellm/resource_model_crud.go @@ -0,0 +1,407 @@ +package litellm + +import ( + "encoding/json" + "fmt" + "log" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +// retryModelRead attempts to read a model with exponential backoff. +// It handles the case where resourceLiteLLMModelRead returns nil but clears the ID +// (eventual consistency: model created but not yet visible on read-back). +func retryModelRead(d *schema.ResourceData, m interface{}, maxRetries int) error { + delay := 1 * time.Second + maxDelay := 10 * time.Second + modelID := d.Id() + + for i := 0; i < maxRetries; i++ { + log.Printf("[INFO] Attempting to read model (attempt %d/%d)", i+1, maxRetries) + + err := resourceLiteLLMModelRead(d, m) + if err == nil { + if d.Id() != "" { + log.Printf("[INFO] Successfully read model after %d attempts", i+1) + return nil + } + // Read returned nil but cleared the ID — model not yet visible (eventual consistency). + // Restore the ID so we can retry. + d.SetId(modelID) + log.Printf("[INFO] Model not found yet (eventual consistency), retrying in %v...", delay) + } else { + log.Printf("[INFO] Read error, retrying in %v: %v", delay, err) + } + + if i < maxRetries-1 { + time.Sleep(delay) + delay *= 2 + if delay > maxDelay { + delay = maxDelay + } + } + } + + log.Printf("[WARN] Failed to read model after %d attempts", maxRetries) + return fmt.Errorf("model %s not found after %d read attempts post-create; the model may have been created successfully — re-running apply should resolve this", modelID, maxRetries) +} + +const ( + endpointModelNew = "/model/new" + endpointModelUpdate = "/model/update" + endpointModelInfo = "/model/info" + endpointModelDelete = "/model/delete" +) + +func createOrUpdateModel(d *schema.ResourceData, m interface{}, isUpdate bool) error { + client, ok := m.(*Client) + if !ok { + return fmt.Errorf("invalid type assertion for client") + } + + // Construct the model name in the format "custom_llm_provider/base_model" + customLLMProvider := d.Get("custom_llm_provider").(string) + baseModel := d.Get("base_model").(string) + modelName := fmt.Sprintf("%s/%s", customLLMProvider, baseModel) + + // Generate a UUID for new models + modelID := d.Id() + if !isUpdate { + modelID = uuid.New().String() + } + + // Create thinking configuration if enabled + var thinking map[string]interface{} + if d.Get("thinking_enabled").(bool) { + thinking = map[string]interface{}{ + "type": "enabled", + "budget_tokens": d.Get("thinking_budget_tokens").(int), + } + } + + // Build the base litellm_params as a map to allow for additional parameters + litellmParams := map[string]interface{}{ + "custom_llm_provider": customLLMProvider, + "model": modelName, + "merge_reasoning_content_in_choices": d.Get("merge_reasoning_content_in_choices").(bool), + } + + // Add optional parameters only if they have values + if tpm := d.Get("tpm").(int); tpm > 0 { + litellmParams["tpm"] = tpm + } + if rpm := d.Get("rpm").(int); rpm > 0 { + litellmParams["rpm"] = rpm + } + // Only include cost fields if explicitly set (non-zero) + if inputCostPerMillion := d.Get("input_cost_per_million_tokens").(float64); inputCostPerMillion > 0 { + litellmParams["input_cost_per_token"] = inputCostPerMillion / 1000000.0 + } + if outputCostPerMillion := d.Get("output_cost_per_million_tokens").(float64); outputCostPerMillion > 0 { + litellmParams["output_cost_per_token"] = outputCostPerMillion / 1000000.0 + } + if apiKey := d.Get("model_api_key").(string); apiKey != "" { + litellmParams["api_key"] = apiKey + } + if apiBase := d.Get("model_api_base").(string); apiBase != "" { + litellmParams["api_base"] = apiBase + } + if apiVersion := d.Get("api_version").(string); apiVersion != "" { + litellmParams["api_version"] = apiVersion + } + if inputCostPerPixel := d.Get("input_cost_per_pixel").(float64); inputCostPerPixel > 0 { + litellmParams["input_cost_per_pixel"] = inputCostPerPixel + } + if outputCostPerPixel := d.Get("output_cost_per_pixel").(float64); outputCostPerPixel > 0 { + litellmParams["output_cost_per_pixel"] = outputCostPerPixel + } + if inputCostPerSecond := d.Get("input_cost_per_second").(float64); inputCostPerSecond > 0 { + litellmParams["input_cost_per_second"] = inputCostPerSecond + } + if outputCostPerSecond := d.Get("output_cost_per_second").(float64); outputCostPerSecond > 0 { + litellmParams["output_cost_per_second"] = outputCostPerSecond + } + if awsAccessKeyID := d.Get("aws_access_key_id").(string); awsAccessKeyID != "" { + litellmParams["aws_access_key_id"] = awsAccessKeyID + } + if awsSecretAccessKey := d.Get("aws_secret_access_key").(string); awsSecretAccessKey != "" { + litellmParams["aws_secret_access_key"] = awsSecretAccessKey + } + if awsRegionName := d.Get("aws_region_name").(string); awsRegionName != "" { + litellmParams["aws_region_name"] = awsRegionName + } + if awsSessionName := d.Get("aws_session_name").(string); awsSessionName != "" { + litellmParams["aws_session_name"] = awsSessionName + } + if awsRoleName := d.Get("aws_role_name").(string); awsRoleName != "" { + litellmParams["aws_role_name"] = awsRoleName + } + if vertexProject := d.Get("vertex_project").(string); vertexProject != "" { + litellmParams["vertex_project"] = vertexProject + } + if vertexLocation := d.Get("vertex_location").(string); vertexLocation != "" { + litellmParams["vertex_location"] = vertexLocation + } + if vertexCredentials := d.Get("vertex_credentials").(string); vertexCredentials != "" { + litellmParams["vertex_credentials"] = vertexCredentials + } + if reasoningEffort := d.Get("reasoning_effort").(string); reasoningEffort != "" { + litellmParams["reasoning_effort"] = reasoningEffort + } + if thinking != nil { + litellmParams["thinking"] = thinking + } + + // Add additional parameters if provided + if additionalParams, ok := d.GetOk("additional_litellm_params"); ok { + var dropParams []string + + for key, value := range additionalParams.(map[string]interface{}) { + // Convert string values to appropriate types where possible + if strValue, ok := value.(string); ok { + // Check if it's JSON (starts with [ or {) + trimmedValue := strings.TrimSpace(strValue) + if strings.HasPrefix(trimmedValue, "[") || strings.HasPrefix(trimmedValue, "{") { + var parsedValue interface{} + if err := json.Unmarshal([]byte(strValue), &parsedValue); err == nil { + // Successfully parsed JSON + if key == "additional_drop_params" { + // Handle drop params specially + if dropList, ok := parsedValue.([]interface{}); ok { + for _, item := range dropList { + if paramStr, ok := item.(string); ok { + dropParams = append(dropParams, paramStr) + } + } + } + continue // Don't add to litellmParams + } else { + litellmParams[key] = parsedValue + } + } else { + // Not valid JSON, apply existing conversion logic + if strValue == "true" { + litellmParams[key] = true + } else if strValue == "false" { + litellmParams[key] = false + } else { + // Try to convert numeric strings + if intValue, err := strconv.Atoi(strValue); err == nil { + litellmParams[key] = intValue + } else if floatValue, err := strconv.ParseFloat(strValue, 64); err == nil { + litellmParams[key] = floatValue + } else { + // Keep as string + litellmParams[key] = strValue + } + } + } + } else { + // Apply existing conversion logic for non-JSON strings + if strValue == "true" { + litellmParams[key] = true + } else if strValue == "false" { + litellmParams[key] = false + } else { + // Try to convert numeric strings + if intValue, err := strconv.Atoi(strValue); err == nil { + litellmParams[key] = intValue + } else if floatValue, err := strconv.ParseFloat(strValue, 64); err == nil { + litellmParams[key] = floatValue + } else { + // Keep as string + litellmParams[key] = strValue + } + } + } + } else { + litellmParams[key] = value + } + } + + // Apply drop params at the end + for _, paramToDrop := range dropParams { + delete(litellmParams, paramToDrop) + } + } + + // Add litellm_credential_name to litellmParams if provided + if credentialName := d.Get("litellm_credential_name").(string); credentialName != "" { + litellmParams["litellm_credential_name"] = credentialName + } + + modelReq := ModelRequest{ + ModelName: d.Get("model_name").(string), + LiteLLMParams: litellmParams, + ModelInfo: ModelInfo{ + ID: modelID, + DBModel: true, + BaseModel: baseModel, + Tier: d.Get("tier").(string), + Mode: d.Get("mode").(string), + TeamID: d.Get("team_id").(string), + }, + Additional: make(map[string]interface{}), + } + + endpoint := endpointModelNew + if isUpdate { + endpoint = endpointModelUpdate + } + + resp, err := MakeRequest(client, "POST", endpoint, modelReq) + if err != nil { + return fmt.Errorf("failed to %s model: %w", map[bool]string{true: "update", false: "create"}[isUpdate], err) + } + defer resp.Body.Close() + + _, err = handleAPIResponse(resp, modelReq, client) + if err != nil { + if isUpdate && err.Error() == "model_not_found" { + return createOrUpdateModel(d, m, false) + } + return fmt.Errorf("failed to %s model: %w", map[bool]string{true: "update", false: "create"}[isUpdate], err) + } + + d.SetId(modelID) + + log.Printf("[INFO] Model created with ID %s. Starting retry mechanism to read the model...", modelID) + // Read back the resource with retries to ensure the state is consistent + return retryModelRead(d, m, 5) +} + +func resourceLiteLLMModelCreate(d *schema.ResourceData, m interface{}) error { + return createOrUpdateModel(d, m, false) +} + +func resourceLiteLLMModelRead(d *schema.ResourceData, m interface{}) error { + client, ok := m.(*Client) + if !ok { + return fmt.Errorf("invalid type assertion for client") + } + + resp, err := MakeRequest(client, "GET", fmt.Sprintf("%s?litellm_model_id=%s", endpointModelInfo, d.Id()), nil) + if err != nil { + return fmt.Errorf("failed to read model: %w", err) + } + defer resp.Body.Close() + + modelResp, err := handleAPIResponse(resp, nil, client) + if err != nil { + if err.Error() == "model_not_found" { + d.SetId("") + return nil + } + return fmt.Errorf("failed to read model: %w", err) + } + + // Update the state with values from the response or fall back to the data passed in during creation + d.Set("model_name", GetStringValue(modelResp.ModelName, d.Get("model_name").(string))) + d.Set("custom_llm_provider", GetStringValue(modelResp.LiteLLMParams.CustomLLMProvider, d.Get("custom_llm_provider").(string))) + d.Set("tpm", GetIntValue(modelResp.LiteLLMParams.TPM, d.Get("tpm").(int))) + d.Set("rpm", GetIntValue(modelResp.LiteLLMParams.RPM, d.Get("rpm").(int))) + d.Set("model_api_base", GetStringValue(modelResp.LiteLLMParams.APIBase, d.Get("model_api_base").(string))) + d.Set("api_version", GetStringValue(modelResp.LiteLLMParams.APIVersion, d.Get("api_version").(string))) + d.Set("base_model", GetStringValue(modelResp.ModelInfo.BaseModel, d.Get("base_model").(string))) + d.Set("tier", GetStringValue(modelResp.ModelInfo.Tier, d.Get("tier").(string))) + d.Set("mode", GetStringValue(modelResp.ModelInfo.Mode, d.Get("mode").(string))) + d.Set("team_id", GetStringValue(modelResp.ModelInfo.TeamID, d.Get("team_id").(string))) + + // Preserve credential name from state since it might not be returned by API + d.Set("litellm_credential_name", d.Get("litellm_credential_name").(string)) + + // Store sensitive information + d.Set("model_api_key", d.Get("model_api_key")) + d.Set("aws_access_key_id", d.Get("aws_access_key_id")) + d.Set("aws_secret_access_key", d.Get("aws_secret_access_key")) + d.Set("aws_region_name", GetStringValue(modelResp.LiteLLMParams.AWSRegionName, d.Get("aws_region_name").(string))) + d.Set("aws_session_name", d.Get("aws_session_name")) + d.Set("aws_role_name", d.Get("aws_role_name")) + + // Store cost information + d.Set("input_cost_per_million_tokens", d.Get("input_cost_per_million_tokens")) + d.Set("output_cost_per_million_tokens", d.Get("output_cost_per_million_tokens")) + + // Handle thinking configuration + if _, ok := d.GetOk("thinking_enabled"); ok { + // Keep the existing value from state + thinkingEnabled := d.Get("thinking_enabled").(bool) + d.Set("thinking_enabled", thinkingEnabled) + + // Only set thinking_budget_tokens if thinking is enabled and we have a value in state + if thinkingEnabled { + if _, ok := d.GetOk("thinking_budget_tokens"); ok { + d.Set("thinking_budget_tokens", d.Get("thinking_budget_tokens").(int)) + } + } + } else { + // Fall back to API response if no state value exists + if modelResp.LiteLLMParams.Thinking != nil { + if thinkingType, ok := modelResp.LiteLLMParams.Thinking["type"].(string); ok && thinkingType == "enabled" { + d.Set("thinking_enabled", true) + if budgetTokens, ok := modelResp.LiteLLMParams.Thinking["budget_tokens"].(float64); ok { + d.Set("thinking_budget_tokens", int(budgetTokens)) + } + } else { + d.Set("thinking_enabled", false) + } + } else { + d.Set("thinking_enabled", false) + } + } + + // Handle merge_reasoning_content_in_choices - preserve state value if not returned by API + if _, ok := d.GetOk("merge_reasoning_content_in_choices"); ok { + // Keep the existing value from state + d.Set("merge_reasoning_content_in_choices", d.Get("merge_reasoning_content_in_choices").(bool)) + } else { + // Only set from API response if we don't have a value in state + d.Set("merge_reasoning_content_in_choices", modelResp.LiteLLMParams.MergeReasoningContentInChoices) + } + + // Preserve additional_litellm_params from state since API might not return all custom parameters + if _, ok := d.GetOk("additional_litellm_params"); ok { + d.Set("additional_litellm_params", d.Get("additional_litellm_params")) + } + + return nil +} + +func resourceLiteLLMModelUpdate(d *schema.ResourceData, m interface{}) error { + return createOrUpdateModel(d, m, true) +} + +func resourceLiteLLMModelDelete(d *schema.ResourceData, m interface{}) error { + client, ok := m.(*Client) + if !ok { + return fmt.Errorf("invalid type assertion for client") + } + + deleteReq := struct { + ID string `json:"id"` + }{ + ID: d.Id(), + } + + resp, err := MakeRequest(client, "POST", endpointModelDelete, deleteReq) + if err != nil { + return fmt.Errorf("failed to delete model: %w", err) + } + defer resp.Body.Close() + + _, err = handleAPIResponse(resp, deleteReq, client) + if err != nil { + if err.Error() == "model_not_found" { + d.SetId("") + return nil + } + return fmt.Errorf("failed to delete model: %w", err) + } + + d.SetId("") + return nil +} diff --git a/terraform/provider/litellm/resource_organization.go b/terraform/provider/litellm/resource_organization.go new file mode 100644 index 00000000000..30e7feba1ec --- /dev/null +++ b/terraform/provider/litellm/resource_organization.go @@ -0,0 +1,210 @@ +package litellm + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +const ( + endpointOrganizationNew = "/organization/new" + endpointOrganizationInfo = "/organization/info" + endpointOrganizationUpdate = "/organization/update" + endpointOrganizationDelete = "/organization/delete" +) + +func resourceLiteLLMOrganization() *schema.Resource { + return &schema.Resource{ + Create: resourceLiteLLMOrganizationCreate, + Read: resourceLiteLLMOrganizationRead, + Update: resourceLiteLLMOrganizationUpdate, + Delete: resourceLiteLLMOrganizationDelete, + + Schema: map[string]*schema.Schema{ + "organization_alias": { + Type: schema.TypeString, + Required: true, + }, + "metadata": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "models": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "max_budget": { + Type: schema.TypeFloat, + Optional: true, + }, + "budget_duration": { + Type: schema.TypeString, + Optional: true, + }, + "tpm_limit": { + Type: schema.TypeInt, + Optional: true, + }, + "rpm_limit": { + Type: schema.TypeInt, + Optional: true, + }, + "blocked": { + Type: schema.TypeBool, + Optional: true, + }, + }, + } +} + +func resourceLiteLLMOrganizationCreate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + orgID := uuid.New().String() + orgData := buildOrganizationData(d, orgID) + + log.Printf("[DEBUG] Create organization request payload: %+v", orgData) + + resp, err := MakeRequest(client, "POST", endpointOrganizationNew, orgData) + if err != nil { + return fmt.Errorf("error creating organization: %w", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "creating organization"); err != nil { + return err + } + + d.SetId(orgID) + log.Printf("[INFO] Organization created with ID: %s", orgID) + + return resourceLiteLLMOrganizationRead(d, m) +} + +func resourceLiteLLMOrganizationRead(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + log.Printf("[INFO] Reading organization with ID: %s", d.Id()) + + resp, err := MakeRequest(client, "POST", endpointOrganizationInfo, map[string]interface{}{ + "organizations": []string{d.Id()}, + }) + if err != nil { + return fmt.Errorf("error reading organization: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + log.Printf("[WARN] Organization with ID %s not found, removing from state", d.Id()) + d.SetId("") + return nil + } + + var orgResps []OrganizationResponse + if err := json.NewDecoder(resp.Body).Decode(&orgResps); err != nil { + return fmt.Errorf("error decoding organization info response: %w", err) + } + + if len(orgResps) == 0 { + log.Printf("[WARN] Organization with ID %s not found in response, removing from state", d.Id()) + d.SetId("") + return nil + } + + orgResp := orgResps[0] + + d.Set("organization_alias", GetStringValue(orgResp.OrganizationAlias, d.Get("organization_alias").(string))) + + if orgResp.Metadata != nil { + d.Set("metadata", orgResp.Metadata) + } else { + d.Set("metadata", d.Get("metadata")) + } + + if orgResp.Models != nil { + d.Set("models", orgResp.Models) + } else { + d.Set("models", d.Get("models")) + } + + if orgResp.MaxBudget != nil { + d.Set("max_budget", *orgResp.MaxBudget) + } + d.Set("budget_duration", GetStringValue(orgResp.BudgetDuration, d.Get("budget_duration").(string))) + if orgResp.TPMLimit != nil { + d.Set("tpm_limit", *orgResp.TPMLimit) + } + if orgResp.RPMLimit != nil { + d.Set("rpm_limit", *orgResp.RPMLimit) + } + d.Set("blocked", GetBoolValue(orgResp.Blocked, d.Get("blocked").(bool))) + + log.Printf("[INFO] Successfully read organization with ID: %s", d.Id()) + return nil +} + +func resourceLiteLLMOrganizationUpdate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + orgData := buildOrganizationData(d, d.Id()) + log.Printf("[DEBUG] Update organization request payload: %+v", orgData) + + resp, err := MakeRequest(client, "PATCH", endpointOrganizationUpdate, orgData) + if err != nil { + return fmt.Errorf("error updating organization: %w", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "updating organization"); err != nil { + return err + } + + log.Printf("[INFO] Successfully updated organization with ID: %s", d.Id()) + return resourceLiteLLMOrganizationRead(d, m) +} + +func resourceLiteLLMOrganizationDelete(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + log.Printf("[INFO] Deleting organization with ID: %s", d.Id()) + + deleteData := map[string]interface{}{ + "organization_ids": []string{d.Id()}, + } + + resp, err := MakeRequest(client, "DELETE", endpointOrganizationDelete, deleteData) + + if err != nil { + return fmt.Errorf("error deleting organization: %w", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "deleting organization"); err != nil { + return err + } + + log.Printf("[INFO] Successfully deleted organization with ID: %s", d.Id()) + d.SetId("") + return nil +} + +func buildOrganizationData(d *schema.ResourceData, orgID string) map[string]interface{} { + orgData := map[string]interface{}{ + "organization_id": orgID, + "organization_alias": d.Get("organization_alias").(string), + } + + for _, key := range []string{"metadata", "models", "max_budget", "budget_duration", "tpm_limit", "rpm_limit", "blocked"} { + if v, ok := d.GetOk(key); ok { + orgData[key] = v + } + } + + return orgData +} diff --git a/terraform/provider/litellm/resource_organization_member.go b/terraform/provider/litellm/resource_organization_member.go new file mode 100644 index 00000000000..e9abd26b9ec --- /dev/null +++ b/terraform/provider/litellm/resource_organization_member.go @@ -0,0 +1,126 @@ +package litellm + +import ( + "fmt" + "log" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceLiteLLMOrganizationMember() *schema.Resource { + return &schema.Resource{ + Create: resourceLiteLLMOrganizationMemberCreate, + Read: resourceLiteLLMOrganizationMemberRead, + Update: resourceLiteLLMOrganizationMemberUpdate, + Delete: resourceLiteLLMOrganizationMemberDelete, + + Schema: map[string]*schema.Schema{ + "organization_id": { + Type: schema.TypeString, + Required: true, + }, + "user_id": { + Type: schema.TypeString, + Required: true, + }, + "user_email": { + Type: schema.TypeString, + Optional: true, + }, + "role": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{ + "org_admin", + "internal_user", + "internal_user_viewer", + }, false), + }, + }, + } +} + +func resourceLiteLLMOrganizationMemberCreate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + memberData := map[string]interface{}{ + "member": []map[string]interface{}{ + { + "role": d.Get("role").(string), + "user_id": d.Get("user_id").(string), + "user_email": d.Get("user_email").(string), + }, + }, + "organization_id": d.Get("organization_id").(string), + } + + log.Printf("[DEBUG] Create organization member request payload: %+v", memberData) + + resp, err := client.AddOrganizationMember(memberData) + if err != nil { + return fmt.Errorf("error creating organization member: %v", err) + } + + log.Printf("[DEBUG] Create organization member response: %+v", resp) + + // Set a composite ID since there's no specific member ID returned + d.SetId(fmt.Sprintf("%s:%s", d.Get("organization_id").(string), d.Get("user_id").(string))) + + log.Printf("[INFO] Organization member created with ID: %s", d.Id()) + + return resourceLiteLLMOrganizationMemberRead(d, m) +} + +func resourceLiteLLMOrganizationMemberRead(d *schema.ResourceData, m interface{}) error { + // There's no specific endpoint to read a single organization member + // We'll just return the data we have in the state + log.Printf("[INFO] Reading organization member with ID: %s", d.Id()) + return nil +} + +func resourceLiteLLMOrganizationMemberUpdate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + updateData := map[string]interface{}{ + "user_id": d.Get("user_id").(string), + "user_email": d.Get("user_email").(string), + "organization_id": d.Get("organization_id").(string), + "role": d.Get("role").(string), + } + + log.Printf("[DEBUG] Update organization member request payload: %+v", updateData) + + resp, err := client.UpdateOrganizationMember(updateData) + if err != nil { + return fmt.Errorf("error updating organization member: %v", err) + } + + log.Printf("[DEBUG] Update organization member response: %+v", resp) + + log.Printf("[INFO] Successfully updated organization member with ID: %s", d.Id()) + + return resourceLiteLLMOrganizationMemberRead(d, m) +} + +func resourceLiteLLMOrganizationMemberDelete(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + deleteData := map[string]interface{}{ + "user_id": d.Get("user_id").(string), + "user_email": d.Get("user_email").(string), + "organization_id": d.Get("organization_id").(string), + } + + log.Printf("[DEBUG] Delete organization member request payload: %+v", deleteData) + + _, err := client.DeleteOrganizationMember(deleteData) + if err != nil { + return fmt.Errorf("error deleting organization member: %v", err) + } + + log.Printf("[INFO] Successfully deleted organization member with ID: %s", d.Id()) + + d.SetId("") + return nil +} diff --git a/terraform/provider/litellm/resource_organization_member_add.go b/terraform/provider/litellm/resource_organization_member_add.go new file mode 100644 index 00000000000..9bb4de09861 --- /dev/null +++ b/terraform/provider/litellm/resource_organization_member_add.go @@ -0,0 +1,260 @@ +package litellm + +import ( + "fmt" + "log" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceLiteLLMOrganizationMemberAdd() *schema.Resource { + return &schema.Resource{ + Create: resourceLiteLLMOrganizationMemberAddCreate, + Read: resourceLiteLLMOrganizationMemberAddRead, + Update: resourceLiteLLMOrganizationMemberAddUpdate, + Delete: resourceLiteLLMOrganizationMemberAddDelete, + + Schema: map[string]*schema.Schema{ + "organization_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "member": { + Type: schema.TypeSet, + Required: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "user_id": { + Type: schema.TypeString, + Optional: true, + }, + "user_email": { + Type: schema.TypeString, + Optional: true, + }, + "role": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{ + "org_admin", + "internal_user", + "internal_user_viewer", + }, false), + }, + }, + }, + }, + }, + } +} + +func resourceLiteLLMOrganizationMemberAddCreate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + orgID := d.Get("organization_id").(string) + members := d.Get("member").(*schema.Set) + + // Convert members to the expected format + membersList := make([]map[string]interface{}, 0, members.Len()) + for _, member := range members.List() { + m := member.(map[string]interface{}) + memberData := map[string]interface{}{ + "role": m["role"].(string), + } + if userID, ok := m["user_id"].(string); ok && userID != "" { + memberData["user_id"] = userID + } + if userEmail, ok := m["user_email"].(string); ok && userEmail != "" { + memberData["user_email"] = userEmail + } + membersList = append(membersList, memberData) + } + + memberData := map[string]interface{}{ + "member": membersList, + "organization_id": orgID, + } + + log.Printf("[DEBUG] Create organization members request payload: %+v", memberData) + + resp, err := client.AddOrganizationMember(memberData) + if err != nil { + return fmt.Errorf("error adding organization members: %v", err) + } + + log.Printf("[DEBUG] Create organization members response: %+v", resp) + + // Set ID as organization_id since this resource manages all members for an organization + d.SetId(orgID) + + return resourceLiteLLMOrganizationMemberAddRead(d, m) +} + +func resourceLiteLLMOrganizationMemberAddRead(d *schema.ResourceData, m interface{}) error { + // The API doesn't provide a way to read specific organization members easily + // We'll maintain the state as is + return nil +} + +func resourceLiteLLMOrganizationMemberAddUpdate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + orgID := d.Get("organization_id").(string) + + o, n := d.GetChange("member") + oldMembers := o.(*schema.Set) + newMembers := n.(*schema.Set) + + // Create maps for easier lookup by user identifier + oldMemberMap := make(map[string]map[string]interface{}) + newMemberMap := make(map[string]map[string]interface{}) + + // Build old member map using user_id or user_email as key + for _, member := range oldMembers.List() { + m := member.(map[string]interface{}) + key := getOrgMemberKey(m) + if key != "" { + oldMemberMap[key] = m + } + } + + // Build new member map using user_id or user_email as key + for _, member := range newMembers.List() { + m := member.(map[string]interface{}) + key := getOrgMemberKey(m) + if key != "" { + newMemberMap[key] = m + } + } + + // Find members to delete (in old but not in new) + for key, oldMember := range oldMemberMap { + if _, exists := newMemberMap[key]; !exists { + deleteData := map[string]interface{}{ + "organization_id": orgID, + } + if userID, ok := oldMember["user_id"].(string); ok && userID != "" { + deleteData["user_id"] = userID + } + if userEmail, ok := oldMember["user_email"].(string); ok && userEmail != "" { + deleteData["user_email"] = userEmail + } + + log.Printf("[DEBUG] Delete organization member request payload: %+v", deleteData) + + _, err := client.DeleteOrganizationMember(deleteData) + if err != nil { + return fmt.Errorf("error deleting organization member: %v", err) + } + } + } + + // Find members to update (exist in both but with different attributes) + for key, newMember := range newMemberMap { + if oldMember, exists := oldMemberMap[key]; exists { + // Check if member attributes have changed + if orgMemberAttributesChanged(oldMember, newMember) { + updateData := map[string]interface{}{ + "organization_id": orgID, + "role": newMember["role"].(string), + } + if userID, ok := newMember["user_id"].(string); ok && userID != "" { + updateData["user_id"] = userID + } + if userEmail, ok := newMember["user_email"].(string); ok && userEmail != "" { + updateData["user_email"] = userEmail + } + + log.Printf("[DEBUG] Update organization member request payload: %+v", updateData) + + _, err := client.UpdateOrganizationMember(updateData) + if err != nil { + return fmt.Errorf("error updating organization member: %v", err) + } + } + } + } + + // Find members to add (in new but not in old) + var membersToAdd []map[string]interface{} + for key, newMember := range newMemberMap { + if _, exists := oldMemberMap[key]; !exists { + memberData := map[string]interface{}{ + "role": newMember["role"].(string), + } + if userID, ok := newMember["user_id"].(string); ok && userID != "" { + memberData["user_id"] = userID + } + if userEmail, ok := newMember["user_email"].(string); ok && userEmail != "" { + memberData["user_email"] = userEmail + } + membersToAdd = append(membersToAdd, memberData) + } + } + + if len(membersToAdd) > 0 { + memberData := map[string]interface{}{ + "member": membersToAdd, + "organization_id": orgID, + } + + log.Printf("[DEBUG] Adding new organization members request payload: %+v", memberData) + + resp, err := client.AddOrganizationMember(memberData) + if err != nil { + return fmt.Errorf("error adding organization members: %v", err) + } + + log.Printf("[DEBUG] Add organization members response: %+v", resp) + } + + return resourceLiteLLMOrganizationMemberAddRead(d, m) +} + +// getOrgMemberKey returns a unique key for a member based on user_id or user_email +func getOrgMemberKey(member map[string]interface{}) string { + if userID, ok := member["user_id"].(string); ok && userID != "" { + return "id:" + userID + } + if userEmail, ok := member["user_email"].(string); ok && userEmail != "" { + return "email:" + userEmail + } + return "" +} + +// orgMemberAttributesChanged checks if member attributes have changed between old and new +func orgMemberAttributesChanged(oldMember, newMember map[string]interface{}) bool { + // Compare role + oldRole, _ := oldMember["role"].(string) + newRole, _ := newMember["role"].(string) + return oldRole != newRole +} + +func resourceLiteLLMOrganizationMemberAddDelete(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + orgID := d.Get("organization_id").(string) + members := d.Get("member").(*schema.Set) + + // Delete each member + for _, member := range members.List() { + m := member.(map[string]interface{}) + deleteData := map[string]interface{}{ + "organization_id": orgID, + } + if userID, ok := m["user_id"].(string); ok && userID != "" { + deleteData["user_id"] = userID + } + if userEmail, ok := m["user_email"].(string); ok && userEmail != "" { + deleteData["user_email"] = userEmail + } + + _, err := client.DeleteOrganizationMember(deleteData) + if err != nil { + return fmt.Errorf("error deleting organization member: %v", err) + } + } + + d.SetId("") + return nil +} diff --git a/terraform/provider/litellm/resource_organization_member_add_test.go b/terraform/provider/litellm/resource_organization_member_add_test.go new file mode 100644 index 00000000000..a26c9ed5812 --- /dev/null +++ b/terraform/provider/litellm/resource_organization_member_add_test.go @@ -0,0 +1,74 @@ +package litellm + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccLiteLLMOrganizationMemberAdd_basic(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccLiteLLMOrganizationMemberAddConfig("test-org-bulk", "bulk-user-1", "bulk-user-2"), + Check: resource.ComposeTestCheckFunc( + testAccCheckLiteLLMOrganizationMemberAddExists("litellm_organization_member_add.test_members"), + resource.TestCheckResourceAttr("litellm_organization_member_add.test_members", "member.#", "2"), + ), + }, + }, + }) +} + +func testAccCheckLiteLLMOrganizationMemberAddExists(n string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No ID is set") + } + + return nil + } +} + +func testAccLiteLLMOrganizationMemberAddConfig(orgAlias, user1, user2 string) string { + return fmt.Sprintf(` +resource "litellm_model" "test_model" { + model_name = "gpt-3.5-turbo" + custom_llm_provider = "openai" + base_model = "gpt-3.5-turbo" +} + +resource "litellm_organization" "test_org_bulk" { + organization_alias = "%s" + max_budget = 100.0 + budget_duration = "30d" + + depends_on = [litellm_model.test_model] +} + +resource "litellm_organization_member_add" "test_members" { + organization_id = litellm_organization.test_org_bulk.id + + member { + user_id = "%s" + user_email = "%s@example.com" + role = "org_admin" + } + + member { + user_id = "%s" + user_email = "%s@example.com" + role = "internal_user" + } +} +`, orgAlias, user1, user1, user2, user2) +} diff --git a/terraform/provider/litellm/resource_organization_member_test.go b/terraform/provider/litellm/resource_organization_member_test.go new file mode 100644 index 00000000000..8818ed81052 --- /dev/null +++ b/terraform/provider/litellm/resource_organization_member_test.go @@ -0,0 +1,66 @@ +package litellm + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccLiteLLMOrganizationMember_basic(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccLiteLLMOrganizationMemberConfig("test-org-member", "test-user-1"), + Check: resource.ComposeTestCheckFunc( + testAccCheckLiteLLMOrganizationMemberExists("litellm_organization_member.test_member"), + resource.TestCheckResourceAttr("litellm_organization_member.test_member", "role", "org_admin"), + resource.TestCheckResourceAttr("litellm_organization_member.test_member", "user_id", "test-user-1"), + ), + }, + }, + }) +} + +func testAccCheckLiteLLMOrganizationMemberExists(n string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No ID is set") + } + + return nil + } +} + +func testAccLiteLLMOrganizationMemberConfig(orgAlias, userID string) string { + return fmt.Sprintf(` +resource "litellm_model" "test_model" { + model_name = "gpt-3.5-turbo" + custom_llm_provider = "openai" + base_model = "gpt-3.5-turbo" +} + +resource "litellm_organization" "test_org" { + organization_alias = "%s" + max_budget = 100.0 + budget_duration = "30d" + + depends_on = [litellm_model.test_model] +} + +resource "litellm_organization_member" "test_member" { + organization_id = litellm_organization.test_org.id + user_id = "%s" + user_email = "%s@example.com" + role = "org_admin" +} +`, orgAlias, userID, userID) +} diff --git a/terraform/provider/litellm/resource_organization_test.go b/terraform/provider/litellm/resource_organization_test.go new file mode 100644 index 00000000000..2a2c32438fe --- /dev/null +++ b/terraform/provider/litellm/resource_organization_test.go @@ -0,0 +1,59 @@ +package litellm + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccLiteLLMOrganization_basic(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccLiteLLMOrganizationConfig("test-org", "test-org-alias"), + Check: resource.ComposeTestCheckFunc( + testAccCheckLiteLLMOrganizationExists("litellm_organization.test"), + resource.TestCheckResourceAttr("litellm_organization.test", "organization_alias", "test-org-alias"), + resource.TestCheckResourceAttr("litellm_organization.test", "max_budget", "100"), + ), + }, + }, + }) +} + +func testAccCheckLiteLLMOrganizationExists(n string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No ID is set") + } + + return nil + } +} + +func testAccLiteLLMOrganizationConfig(name, alias string) string { + return fmt.Sprintf(` +resource "litellm_model" "test_model" { + model_name = "gpt-3.5-turbo" + custom_llm_provider = "openai" + base_model = "gpt-3.5-turbo" +} + +resource "litellm_organization" "test" { + organization_alias = "%s" + max_budget = 100.0 + budget_duration = "30d" + + depends_on = [litellm_model.test_model] +} +`, alias) +} diff --git a/terraform/provider/litellm/resource_team.go b/terraform/provider/litellm/resource_team.go new file mode 100644 index 00000000000..88e0dcd4811 --- /dev/null +++ b/terraform/provider/litellm/resource_team.go @@ -0,0 +1,311 @@ +package litellm + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +const ( + endpointTeamNew = "/team/new" + endpointTeamInfo = "/team/info" + endpointTeamUpdate = "/team/update" + endpointTeamDelete = "/team/delete" + endpointTeamPermissionsList = "/team/permissions_list" + endpointTeamPermissionsUpdate = "/team/permissions_update" +) + +func ResourceLiteLLMTeam() *schema.Resource { + return &schema.Resource{ + Create: resourceLiteLLMTeamCreate, + Read: resourceLiteLLMTeamRead, + Update: resourceLiteLLMTeamUpdate, + Delete: resourceLiteLLMTeamDelete, + + Schema: map[string]*schema.Schema{ + "team_alias": { + Type: schema.TypeString, + Required: true, + }, + "organization_id": { + Type: schema.TypeString, + Optional: true, + }, + "metadata": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "tpm_limit": { + Type: schema.TypeInt, + Optional: true, + }, + "rpm_limit": { + Type: schema.TypeInt, + Optional: true, + }, + "max_budget": { + Type: schema.TypeFloat, + Optional: true, + }, + "budget_duration": { + Type: schema.TypeString, + Optional: true, + }, + "models": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "blocked": { + Type: schema.TypeBool, + Optional: true, + }, + "team_member_permissions": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "List of permissions granted to team members", + }, + }, + } +} + +func resourceLiteLLMTeamCreate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + teamID := uuid.New().String() + teamData := buildTeamData(d, teamID) + + log.Printf("[DEBUG] Create team request payload: %+v", teamData) + + resp, err := MakeRequest(client, "POST", endpointTeamNew, teamData) + if err != nil { + return fmt.Errorf("error creating team: %w", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "creating team"); err != nil { + return err + } + + d.SetId(teamID) + log.Printf("[INFO] Team created with ID: %s", teamID) + + return resourceLiteLLMTeamRead(d, m) +} + +func resourceLiteLLMTeamRead(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + log.Printf("[INFO] Reading team with ID: %s", d.Id()) + + resp, err := MakeRequest(client, "GET", fmt.Sprintf("%s?team_id=%s", endpointTeamInfo, d.Id()), nil) + if err != nil { + return fmt.Errorf("error reading team: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + log.Printf("[WARN] Team with ID %s not found, removing from state", d.Id()) + d.SetId("") + return nil + } + + var teamResp TeamResponse + if err := json.NewDecoder(resp.Body).Decode(&teamResp); err != nil { + return fmt.Errorf("error decoding team info response: %w", err) + } + + // Update the state with values from the response or fall back to the data passed in during creation + d.Set("team_alias", GetStringValue(teamResp.TeamAlias, d.Get("team_alias").(string))) + d.Set("organization_id", GetStringValue(teamResp.OrganizationID, d.Get("organization_id").(string))) + + // Handle metadata separately as it's a map + if teamResp.Metadata != nil { + d.Set("metadata", teamResp.Metadata) + } else { + d.Set("metadata", d.Get("metadata")) + } + + if teamResp.TPMLimit != nil { + d.Set("tpm_limit", *teamResp.TPMLimit) + } + if teamResp.RPMLimit != nil { + d.Set("rpm_limit", *teamResp.RPMLimit) + } + if teamResp.MaxBudget != nil { + d.Set("max_budget", *teamResp.MaxBudget) + } + d.Set("budget_duration", GetStringValue(teamResp.BudgetDuration, d.Get("budget_duration").(string))) + + // Handle models separately as it's a list + if teamResp.Models != nil { + d.Set("models", teamResp.Models) + } else { + d.Set("models", d.Get("models")) + } + + d.Set("blocked", GetBoolValue(teamResp.Blocked, d.Get("blocked").(bool))) + + // Explicitly fetch the current permissions from the API + permResp, err := getTeamPermissions(client, d.Id()) + if err != nil { + log.Printf("[WARN] Error fetching team permissions: %s", err) + // Fall back to the permissions from the team info response + if teamResp.TeamMemberPermissions != nil { + d.Set("team_member_permissions", teamResp.TeamMemberPermissions) + } else { + d.Set("team_member_permissions", d.Get("team_member_permissions")) + } + } else { + // Use the permissions from the permissions_list endpoint + log.Printf("[DEBUG] Team permissions from API: %+v", permResp.TeamMemberPermissions) + d.Set("team_member_permissions", permResp.TeamMemberPermissions) + } + + log.Printf("[INFO] Successfully read team with ID: %s", d.Id()) + return nil +} + +func resourceLiteLLMTeamUpdate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + teamData := buildTeamData(d, d.Id()) + log.Printf("[DEBUG] Update team request payload: %+v", teamData) + + resp, err := MakeRequest(client, "POST", endpointTeamUpdate, teamData) + if err != nil { + return fmt.Errorf("error updating team: %w", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "updating team"); err != nil { + return err + } + + // Check if team_member_permissions have changed and explicitly update them + if d.HasChange("team_member_permissions") { + _, newPerms := d.GetChange("team_member_permissions") + if newPerms != nil { + // Convert interface{} to []string + var permissions []string + for _, perm := range newPerms.([]interface{}) { + permissions = append(permissions, perm.(string)) + } + + log.Printf("[DEBUG] Explicitly updating team permissions: %+v", permissions) + if err := updateTeamPermissions(client, d.Id(), permissions); err != nil { + return fmt.Errorf("error updating team permissions: %w", err) + } + } + } + + log.Printf("[INFO] Successfully updated team with ID: %s", d.Id()) + return resourceLiteLLMTeamRead(d, m) +} + +func resourceLiteLLMTeamDelete(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + log.Printf("[INFO] Deleting team with ID: %s", d.Id()) + + deleteData := map[string]interface{}{ + "team_ids": []string{d.Id()}, + } + + resp, err := MakeRequest(client, "POST", endpointTeamDelete, deleteData) + if err != nil { + return fmt.Errorf("error deleting team: %w", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "deleting team"); err != nil { + return err + } + + log.Printf("[INFO] Successfully deleted team with ID: %s", d.Id()) + d.SetId("") + return nil +} + +func buildTeamData(d *schema.ResourceData, teamID string) map[string]interface{} { + teamData := map[string]interface{}{ + "team_id": teamID, + "team_alias": d.Get("team_alias").(string), + } + + for _, key := range []string{"organization_id", "metadata", "tpm_limit", "rpm_limit", "max_budget", "budget_duration", "models", "blocked", "team_member_permissions"} { + if v, ok := d.GetOk(key); ok { + teamData[key] = v + } + } + + return teamData +} + +func handleResponse(resp *http.Response, action string) error { + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("error %s: %s - %s", action, resp.Status, string(body)) + } + return nil +} + +// TeamPermissionsResponse represents a response from the API containing team permissions information. +type TeamPermissionsResponse struct { + TeamID string `json:"team_id"` + TeamMemberPermissions []string `json:"team_member_permissions"` + AllAvailablePermissions []string `json:"all_available_permissions"` +} + +// getTeamPermissions retrieves the current permissions and available permissions for a team. +func getTeamPermissions(client *Client, teamID string) (*TeamPermissionsResponse, error) { + log.Printf("[INFO] Getting permissions for team with ID: %s", teamID) + + resp, err := MakeRequest(client, "GET", fmt.Sprintf("%s?team_id=%s", endpointTeamPermissionsList, teamID), nil) + if err != nil { + return nil, fmt.Errorf("error getting team permissions: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("error getting team permissions: %s - %s", resp.Status, string(body)) + } + + var permResp TeamPermissionsResponse + if err := json.NewDecoder(resp.Body).Decode(&permResp); err != nil { + return nil, fmt.Errorf("error decoding team permissions response: %w", err) + } + + return &permResp, nil +} + +// updateTeamPermissions updates the permissions for a team. +func updateTeamPermissions(client *Client, teamID string, permissions []string) error { + log.Printf("[INFO] Updating permissions for team with ID: %s", teamID) + + permData := map[string]interface{}{ + "team_id": teamID, + "team_member_permissions": permissions, + } + + resp, err := MakeRequest(client, "POST", endpointTeamPermissionsUpdate, permData) + if err != nil { + return fmt.Errorf("error updating team permissions: %w", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "updating team permissions"); err != nil { + return err + } + + log.Printf("[INFO] Successfully updated permissions for team with ID: %s", teamID) + return nil +} diff --git a/terraform/provider/litellm/resource_team_member.go b/terraform/provider/litellm/resource_team_member.go new file mode 100644 index 00000000000..84db07239fd --- /dev/null +++ b/terraform/provider/litellm/resource_team_member.go @@ -0,0 +1,146 @@ +package litellm + +import ( + "fmt" + "log" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceLiteLLMTeamMember() *schema.Resource { + return &schema.Resource{ + Create: resourceLiteLLMTeamMemberCreate, + Read: resourceLiteLLMTeamMemberRead, + Update: resourceLiteLLMTeamMemberUpdate, + Delete: resourceLiteLLMTeamMemberDelete, + + Schema: map[string]*schema.Schema{ + "team_id": { + Type: schema.TypeString, + Required: true, + }, + "user_id": { + Type: schema.TypeString, + Required: true, + }, + "user_email": { + Type: schema.TypeString, + Required: true, + }, + "role": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{ + "org_admin", + "internal_user", + "internal_user_viewer", + "admin", + "user", + }, false), + }, + "max_budget_in_team": { + Type: schema.TypeFloat, + Optional: true, + }, + }, + } +} + +func resourceLiteLLMTeamMemberCreate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + memberData := map[string]interface{}{ + "member": []map[string]interface{}{ + { + "role": d.Get("role").(string), + "user_id": d.Get("user_id").(string), + "user_email": d.Get("user_email").(string), + }, + }, + "team_id": d.Get("team_id").(string), + "max_budget_in_team": d.Get("max_budget_in_team").(float64), + } + + log.Printf("[DEBUG] Create team member request payload: %+v", memberData) + + resp, err := MakeRequest(client, "POST", "/team/member_add", memberData) + if err != nil { + return fmt.Errorf("error creating team member: %v", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "creating team member"); err != nil { + return err + } + + // Set a composite ID since there's no specific member ID returned + d.SetId(fmt.Sprintf("%s:%s", d.Get("team_id").(string), d.Get("user_id").(string))) + + log.Printf("[INFO] Team member created with ID: %s", d.Id()) + + return resourceLiteLLMTeamMemberRead(d, m) +} + +func resourceLiteLLMTeamMemberRead(d *schema.ResourceData, m interface{}) error { + // There's no specific endpoint to read a single team member + // We might need to read the entire team and find the member + // For now, we'll just return the data we have in the state + log.Printf("[INFO] Reading team member with ID: %s", d.Id()) + return nil +} + +func resourceLiteLLMTeamMemberUpdate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + updateData := map[string]interface{}{ + "user_id": d.Get("user_id").(string), + "user_email": d.Get("user_email").(string), + "team_id": d.Get("team_id").(string), + "role": d.Get("role").(string), + "max_budget_in_team": d.Get("max_budget_in_team").(float64), + } + + log.Printf("[DEBUG] Update team member request payload: %+v", updateData) + + resp, err := MakeRequest(client, "POST", "/team/member_update", updateData) + if err != nil { + return fmt.Errorf("error updating team member: %v", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "updating team member"); err != nil { + return err + } + + log.Printf("[INFO] Successfully updated team member with ID: %s", d.Id()) + + return resourceLiteLLMTeamMemberRead(d, m) +} + +func resourceLiteLLMTeamMemberDelete(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + deleteData := map[string]interface{}{ + "user_id": d.Get("user_id").(string), + "user_email": d.Get("user_email").(string), + "team_id": d.Get("team_id").(string), + } + + log.Printf("[DEBUG] Delete team member request payload: %+v", deleteData) + + resp, err := MakeRequest(client, "POST", "/team/member_delete", deleteData) + if err != nil { + return fmt.Errorf("error deleting team member: %v", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "deleting team member"); err != nil { + return err + } + + log.Printf("[INFO] Successfully deleted team member with ID: %s", d.Id()) + + d.SetId("") + return nil +} diff --git a/terraform/provider/litellm/resource_team_member_add.go b/terraform/provider/litellm/resource_team_member_add.go new file mode 100644 index 00000000000..da5c7a6ebd7 --- /dev/null +++ b/terraform/provider/litellm/resource_team_member_add.go @@ -0,0 +1,342 @@ +package litellm + +import ( + "fmt" + "log" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceLiteLLMTeamMemberAdd() *schema.Resource { + return &schema.Resource{ + Create: resourceLiteLLMTeamMemberAddCreate, + Read: resourceLiteLLMTeamMemberAddRead, + Update: resourceLiteLLMTeamMemberAddUpdate, + Delete: resourceLiteLLMTeamMemberAddDelete, + + Schema: map[string]*schema.Schema{ + "team_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "member": { + Type: schema.TypeSet, + Required: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "user_id": { + Type: schema.TypeString, + Optional: true, + }, + "user_email": { + Type: schema.TypeString, + Optional: true, + }, + "role": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{ + "admin", + "user", + }, false), + }, + }, + }, + }, + "max_budget_in_team": { + Type: schema.TypeFloat, + Optional: true, + }, + }, + } +} + +func resourceLiteLLMTeamMemberAddCreate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + teamID := d.Get("team_id").(string) + members := d.Get("member").(*schema.Set) + maxBudget := d.Get("max_budget_in_team").(float64) + + // Convert members to the expected format + membersList := make([]map[string]interface{}, 0, members.Len()) + for _, member := range members.List() { + m := member.(map[string]interface{}) + memberData := map[string]interface{}{ + "role": m["role"].(string), + } + if userID, ok := m["user_id"].(string); ok && userID != "" { + memberData["user_id"] = userID + } + if userEmail, ok := m["user_email"].(string); ok && userEmail != "" { + memberData["user_email"] = userEmail + } + membersList = append(membersList, memberData) + } + + memberData := map[string]interface{}{ + "member": membersList, + "team_id": teamID, + "max_budget_in_team": maxBudget, + } + + log.Printf("[DEBUG] Create team members request payload: %+v", memberData) + + resp, err := MakeRequest(client, "POST", "/team/member_add", memberData) + if err != nil { + return fmt.Errorf("error adding team members: %v", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "adding team members"); err != nil { + return err + } + + // Set ID as team_id since this resource manages all members for a team + d.SetId(teamID) + + return resourceLiteLLMTeamMemberAddRead(d, m) +} + +func resourceLiteLLMTeamMemberAddRead(d *schema.ResourceData, m interface{}) error { + // The API doesn't provide a way to read specific team members + // We'll maintain the state as is + return nil +} + +func resourceLiteLLMTeamMemberAddUpdate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + teamID := d.Get("team_id").(string) + maxBudget := d.Get("max_budget_in_team").(float64) + + o, n := d.GetChange("member") + oldMembers := o.(*schema.Set) + newMembers := n.(*schema.Set) + + // Create maps for easier lookup by user identifier + oldMemberMap := make(map[string]map[string]interface{}) + newMemberMap := make(map[string]map[string]interface{}) + + // Build old member map using user_id or user_email as key + for _, member := range oldMembers.List() { + m := member.(map[string]interface{}) + key := getMemberKey(m) + if key != "" { + oldMemberMap[key] = m + } + } + + // Build new member map using user_id or user_email as key + for _, member := range newMembers.List() { + m := member.(map[string]interface{}) + key := getMemberKey(m) + if key != "" { + newMemberMap[key] = m + } + } + + // Track which members have been updated to avoid duplicates + updatedMembers := make(map[string]bool) + + // Check if max_budget_in_team has changed + if d.HasChange("max_budget_in_team") { + log.Printf("[DEBUG] max_budget_in_team changed, updating all existing members with new budget: %f", maxBudget) + + // Update ALL existing members with the new budget + for key, newMember := range newMemberMap { + if _, exists := oldMemberMap[key]; exists { + updateData := map[string]interface{}{ + "team_id": teamID, + "role": newMember["role"].(string), + "max_budget_in_team": maxBudget, + } + if userID, ok := newMember["user_id"].(string); ok && userID != "" { + updateData["user_id"] = userID + } + if userEmail, ok := newMember["user_email"].(string); ok && userEmail != "" { + updateData["user_email"] = userEmail + } + + log.Printf("[DEBUG] Update team member budget request payload: %+v", updateData) + + resp, err := MakeRequest(client, "POST", "/team/member_update", updateData) + if err != nil { + return fmt.Errorf("error updating team member budget: %v", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "updating team member budget"); err != nil { + return err + } + + // Mark this member as updated + updatedMembers[key] = true + } + } + } + + // Find members to delete (in old but not in new) + for key, oldMember := range oldMemberMap { + if _, exists := newMemberMap[key]; !exists { + deleteData := map[string]interface{}{ + "team_id": teamID, + } + if userID, ok := oldMember["user_id"].(string); ok && userID != "" { + deleteData["user_id"] = userID + } + if userEmail, ok := oldMember["user_email"].(string); ok && userEmail != "" { + deleteData["user_email"] = userEmail + } + + log.Printf("[DEBUG] Delete team member request payload: %+v", deleteData) + + resp, err := MakeRequest(client, "POST", "/team/member_delete", deleteData) + if err != nil { + return fmt.Errorf("error deleting team member: %v", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "deleting team member"); err != nil { + return err + } + } + } + + // Find members to update (exist in both but with different attributes) + // Skip members that were already updated due to budget change + for key, newMember := range newMemberMap { + if oldMember, exists := oldMemberMap[key]; exists { + // Skip if already updated due to budget change + if updatedMembers[key] { + continue + } + + // Check if member attributes have changed + if memberAttributesChanged(oldMember, newMember) { + updateData := map[string]interface{}{ + "team_id": teamID, + "role": newMember["role"].(string), + "max_budget_in_team": maxBudget, + } + if userID, ok := newMember["user_id"].(string); ok && userID != "" { + updateData["user_id"] = userID + } + if userEmail, ok := newMember["user_email"].(string); ok && userEmail != "" { + updateData["user_email"] = userEmail + } + + log.Printf("[DEBUG] Update team member request payload: %+v", updateData) + + resp, err := MakeRequest(client, "POST", "/team/member_update", updateData) + if err != nil { + return fmt.Errorf("error updating team member: %v", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "updating team member"); err != nil { + return err + } + } + } + } + + // Find members to add (in new but not in old) + var membersToAdd []map[string]interface{} + for key, newMember := range newMemberMap { + if _, exists := oldMemberMap[key]; !exists { + memberData := map[string]interface{}{ + "role": newMember["role"].(string), + } + if userID, ok := newMember["user_id"].(string); ok && userID != "" { + memberData["user_id"] = userID + } + if userEmail, ok := newMember["user_email"].(string); ok && userEmail != "" { + memberData["user_email"] = userEmail + } + membersToAdd = append(membersToAdd, memberData) + } + } + + if len(membersToAdd) > 0 { + memberData := map[string]interface{}{ + "member": membersToAdd, + "team_id": teamID, + "max_budget_in_team": maxBudget, + } + + log.Printf("[DEBUG] Adding new team members request payload: %+v", memberData) + + resp, err := MakeRequest(client, "POST", "/team/member_add", memberData) + if err != nil { + return fmt.Errorf("error adding team members: %v", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "adding team members"); err != nil { + return err + } + } + + return resourceLiteLLMTeamMemberAddRead(d, m) +} + +// getMemberKey returns a unique key for a member based on user_id or user_email +func getMemberKey(member map[string]interface{}) string { + if userID, ok := member["user_id"].(string); ok && userID != "" { + return "id:" + userID + } + if userEmail, ok := member["user_email"].(string); ok && userEmail != "" { + return "email:" + userEmail + } + return "" +} + +// memberAttributesChanged checks if member attributes have changed between old and new +func memberAttributesChanged(oldMember, newMember map[string]interface{}) bool { + // Compare role + oldRole, _ := oldMember["role"].(string) + newRole, _ := newMember["role"].(string) + if oldRole != newRole { + return true + } + + // Note: max_budget_in_team is handled at the resource level, not per member + // so we don't need to compare it here + + return false +} + +func resourceLiteLLMTeamMemberAddDelete(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + teamID := d.Get("team_id").(string) + members := d.Get("member").(*schema.Set) + + // Delete each member + for _, member := range members.List() { + m := member.(map[string]interface{}) + deleteData := map[string]interface{}{ + "team_id": teamID, + } + if userID, ok := m["user_id"].(string); ok && userID != "" { + deleteData["user_id"] = userID + } + if userEmail, ok := m["user_email"].(string); ok && userEmail != "" { + deleteData["user_email"] = userEmail + } + + resp, err := MakeRequest(client, "POST", "/team/member_delete", deleteData) + if err != nil { + return fmt.Errorf("error deleting team member: %v", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "deleting team member"); err != nil { + return err + } + } + + d.SetId("") + return nil +} diff --git a/terraform/provider/litellm/resource_team_member_test.go b/terraform/provider/litellm/resource_team_member_test.go new file mode 100644 index 00000000000..156c4b3abfa --- /dev/null +++ b/terraform/provider/litellm/resource_team_member_test.go @@ -0,0 +1,44 @@ +package litellm + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func TestTeamMemberUpdateSendsRole(t *testing.T) { + var captured map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &captured) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := schema.TestResourceDataRaw(t, resourceLiteLLMTeamMember().Schema, map[string]interface{}{ + "team_id": "team-1", + "user_id": "user-1", + "user_email": "user@example.com", + "role": "user", + }) + d.SetId("team-1:user-1") + + if err := resourceLiteLLMTeamMemberUpdate(d, client); err != nil { + t.Fatalf("update failed: %v", err) + } + + role, ok := captured["role"] + if !ok { + t.Fatalf("update payload missing role field: %v", captured) + } + if role != "user" { + t.Fatalf("update payload sent role %v, want user", role) + } +} diff --git a/terraform/provider/litellm/resource_vector_store.go b/terraform/provider/litellm/resource_vector_store.go new file mode 100644 index 00000000000..f77ba18c6d4 --- /dev/null +++ b/terraform/provider/litellm/resource_vector_store.go @@ -0,0 +1,65 @@ +package litellm + +import ( + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceLiteLLMVectorStore() *schema.Resource { + return &schema.Resource{ + Create: resourceLiteLLMVectorStoreCreate, + Read: resourceLiteLLMVectorStoreRead, + Update: resourceLiteLLMVectorStoreUpdate, + Delete: resourceLiteLLMVectorStoreDelete, + + Schema: map[string]*schema.Schema{ + "vector_store_id": { + Type: schema.TypeString, + Computed: true, + Description: "Unique identifier for the vector store", + }, + "vector_store_name": { + Type: schema.TypeString, + Required: true, + Description: "Name of the vector store", + }, + "custom_llm_provider": { + Type: schema.TypeString, + Required: true, + Description: "Custom LLM provider for the vector store", + }, + "vector_store_description": { + Type: schema.TypeString, + Optional: true, + Description: "Description of the vector store", + }, + "vector_store_metadata": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Metadata associated with the vector store", + }, + "litellm_credential_name": { + Type: schema.TypeString, + Optional: true, + Description: "Name of the LiteLLM credential to use", + }, + "litellm_params": { + Type: schema.TypeMap, + Optional: true, + Sensitive: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Additional LiteLLM parameters", + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + Description: "Timestamp when the vector store was created", + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Timestamp when the vector store was last updated", + }, + }, + } +} diff --git a/terraform/provider/litellm/resource_vector_store_crud.go b/terraform/provider/litellm/resource_vector_store_crud.go new file mode 100644 index 00000000000..b05017f7125 --- /dev/null +++ b/terraform/provider/litellm/resource_vector_store_crud.go @@ -0,0 +1,168 @@ +package litellm + +import ( + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceLiteLLMVectorStoreCreate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + vectorStoreName := d.Get("vector_store_name").(string) + customLLMProvider := d.Get("custom_llm_provider").(string) + vectorStoreDescription := d.Get("vector_store_description").(string) + vectorStoreMetadata := d.Get("vector_store_metadata").(map[string]interface{}) + litellmCredentialName := d.Get("litellm_credential_name").(string) + litellmParams := d.Get("litellm_params").(map[string]interface{}) + + // Convert metadata to map[string]interface{} for JSON + metadataMap := make(map[string]interface{}) + for k, v := range vectorStoreMetadata { + metadataMap[k] = v + } + + // Convert litellm_params to map[string]interface{} for JSON + paramsMap := make(map[string]interface{}) + for k, v := range litellmParams { + paramsMap[k] = v + } + + vectorStoreRequest := VectorStoreRequest{ + CustomLLMProvider: customLLMProvider, + VectorStoreName: vectorStoreName, + VectorStoreDescription: vectorStoreDescription, + VectorStoreMetadata: metadataMap, + LiteLLMCredentialName: litellmCredentialName, + LiteLLMParams: paramsMap, + } + + resp, err := MakeRequest(client, "POST", "/vector_store/new", vectorStoreRequest) + if err != nil { + return fmt.Errorf("failed to create vector store: %w", err) + } + defer resp.Body.Close() + + err = handleVectorStoreAPIResponse(resp, nil, client) + if err != nil { + return fmt.Errorf("failed to create vector store: %w", err) + } + + // Set the resource ID to the vector store name for now + // We'll update this after reading the response to get the actual ID + d.SetId(vectorStoreName) + + return resourceLiteLLMVectorStoreRead(d, m) +} + +func resourceLiteLLMVectorStoreRead(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + vectorStoreID := d.Id() + + // Use the info endpoint to get vector store details + infoRequest := VectorStoreInfoRequest{ + VectorStoreID: vectorStoreID, + } + + resp, err := MakeRequest(client, "POST", "/vector_store/info", infoRequest) + if err != nil { + return fmt.Errorf("failed to read vector store: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + d.SetId("") + return nil + } + + var vectorStoreResp VectorStoreResponse + err = handleVectorStoreAPIResponse(resp, &vectorStoreResp, client) + if err != nil { + if err.Error() == "vector_store_not_found" { + d.SetId("") + return nil + } + return fmt.Errorf("failed to read vector store: %w", err) + } + + // Update the resource ID to the actual vector store ID from the response + if vectorStoreResp.VectorStoreID != "" { + d.SetId(vectorStoreResp.VectorStoreID) + } + + d.Set("vector_store_id", vectorStoreResp.VectorStoreID) + d.Set("vector_store_name", vectorStoreResp.VectorStoreName) + d.Set("custom_llm_provider", vectorStoreResp.CustomLLMProvider) + d.Set("vector_store_description", vectorStoreResp.VectorStoreDescription) + d.Set("vector_store_metadata", vectorStoreResp.VectorStoreMetadata) + d.Set("litellm_credential_name", vectorStoreResp.LiteLLMCredentialName) + d.Set("created_at", vectorStoreResp.CreatedAt) + d.Set("updated_at", vectorStoreResp.UpdatedAt) + + return nil +} + +func resourceLiteLLMVectorStoreUpdate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + vectorStoreID := d.Id() + + vectorStoreName := d.Get("vector_store_name").(string) + customLLMProvider := d.Get("custom_llm_provider").(string) + vectorStoreDescription := d.Get("vector_store_description").(string) + vectorStoreMetadata := d.Get("vector_store_metadata").(map[string]interface{}) + + // Convert metadata to map[string]interface{} for JSON + metadataMap := make(map[string]interface{}) + for k, v := range vectorStoreMetadata { + metadataMap[k] = v + } + + vectorStoreRequest := VectorStoreRequest{ + VectorStoreID: vectorStoreID, + CustomLLMProvider: customLLMProvider, + VectorStoreName: vectorStoreName, + VectorStoreDescription: vectorStoreDescription, + VectorStoreMetadata: metadataMap, + } + + resp, err := MakeRequest(client, "POST", "/vector_store/update", vectorStoreRequest) + if err != nil { + return fmt.Errorf("failed to update vector store: %w", err) + } + defer resp.Body.Close() + + err = handleVectorStoreAPIResponse(resp, nil, client) + if err != nil { + return fmt.Errorf("failed to update vector store: %w", err) + } + + return resourceLiteLLMVectorStoreRead(d, m) +} + +func resourceLiteLLMVectorStoreDelete(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + vectorStoreID := d.Id() + + deleteRequest := VectorStoreDeleteRequest{ + VectorStoreID: vectorStoreID, + } + + resp, err := MakeRequest(client, "POST", "/vector_store/delete", deleteRequest) + if err != nil { + return fmt.Errorf("failed to delete vector store: %w", err) + } + defer resp.Body.Close() + + err = handleVectorStoreAPIResponse(resp, nil, client) + if err != nil { + if err.Error() == "vector_store_not_found" { + d.SetId("") + return nil + } + return fmt.Errorf("failed to delete vector store: %w", err) + } + + d.SetId("") + return nil +} diff --git a/terraform/provider/litellm/resource_vector_store_crud_test.go b/terraform/provider/litellm/resource_vector_store_crud_test.go new file mode 100644 index 00000000000..485ec54346d --- /dev/null +++ b/terraform/provider/litellm/resource_vector_store_crud_test.go @@ -0,0 +1,55 @@ +package litellm + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func TestVectorStoreReadDoesNotPersistServerLitellmParams(t *testing.T) { + resp := VectorStoreResponse{ + VectorStoreID: "vs-123", + VectorStoreName: "kb", + CustomLLMProvider: "openai", + LiteLLMParams: map[string]interface{}{ + "api_key": "sk-from-server", + "api_base": "https://upstream.example.com", + }, + } + body, _ := json.Marshal(resp) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(body) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := schema.TestResourceDataRaw(t, resourceLiteLLMVectorStore().Schema, map[string]interface{}{ + "vector_store_name": "kb", + "custom_llm_provider": "openai", + "litellm_params": map[string]interface{}{ + "vector_store_id": "vs-123", + }, + }) + d.SetId("vs-123") + + if err := resourceLiteLLMVectorStoreRead(d, client); err != nil { + t.Fatalf("read failed: %v", err) + } + + got := d.Get("litellm_params").(map[string]interface{}) + if _, leaked := got["api_key"]; leaked { + t.Fatalf("server-returned api_key persisted into state: %v", got) + } + if got["vector_store_id"] != "vs-123" { + t.Fatalf("config litellm_params not preserved: %v", got) + } + if d.Get("vector_store_name").(string) != "kb" { + t.Fatalf("read did not populate non-sensitive fields") + } +} diff --git a/terraform/provider/litellm/types.go b/terraform/provider/litellm/types.go new file mode 100644 index 00000000000..069fe4b3e23 --- /dev/null +++ b/terraform/provider/litellm/types.go @@ -0,0 +1,248 @@ +package litellm + +// ProviderConfig holds the configuration for the LiteLLM provider. +type ProviderConfig struct { + APIBase string + APIKey string + InsecureSkipVerify bool +} + +// ErrorResponse represents an error response from the API. +type ErrorResponse struct { + Error struct { + Message interface{} `json:"message"` + } `json:"error"` + Detail struct { + Error string `json:"error"` + } `json:"detail"` +} + +// ModelResponse represents a response from the API containing model information. +type ModelResponse struct { + ModelName string `json:"model_name"` + LiteLLMParams LiteLLMParams `json:"litellm_params"` + ModelInfo ModelInfo `json:"model_info"` + Additional map[string]interface{} `json:"additional"` +} + +// ModelRequest represents a request to create or update a model. +type ModelRequest struct { + ModelName string `json:"model_name"` + LiteLLMParams map[string]interface{} `json:"litellm_params"` + ModelInfo ModelInfo `json:"model_info"` + Additional map[string]interface{} `json:"additional"` +} + +// TeamResponse represents a response from the API containing team information. +type TeamResponse struct { + TeamID string `json:"team_id,omitempty"` + TeamAlias string `json:"team_alias,omitempty"` + OrganizationID string `json:"organization_id,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + TPMLimit *int `json:"tpm_limit,omitempty"` + RPMLimit *int `json:"rpm_limit,omitempty"` + MaxBudget *float64 `json:"max_budget,omitempty"` + BudgetDuration string `json:"budget_duration,omitempty"` + Models []string `json:"models"` + Blocked bool `json:"blocked,omitempty"` + TeamMemberPermissions []string `json:"team_member_permissions,omitempty"` +} + +// OrganizationResponse represents a response from the API containing organization information. +type OrganizationResponse struct { + OrganizationID string `json:"organization_id,omitempty"` + OrganizationAlias string `json:"organization_alias,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + Models []string `json:"models,omitempty"` + MaxBudget *float64 `json:"max_budget,omitempty"` + BudgetDuration string `json:"budget_duration,omitempty"` + TPMLimit *int `json:"tpm_limit,omitempty"` + RPMLimit *int `json:"rpm_limit,omitempty"` + Blocked bool `json:"blocked,omitempty"` +} + +// LiteLLMParams represents the parameters for LiteLLM. +type LiteLLMParams struct { + CustomLLMProvider string `json:"custom_llm_provider"` + TPM int `json:"tpm,omitempty"` + RPM int `json:"rpm,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + Thinking map[string]interface{} `json:"thinking,omitempty"` + MergeReasoningContentInChoices bool `json:"merge_reasoning_content_in_choices,omitempty"` + APIKey string `json:"api_key,omitempty"` + APIBase string `json:"api_base,omitempty"` + APIVersion string `json:"api_version,omitempty"` + Model string `json:"model"` + InputCostPerToken float64 `json:"input_cost_per_token,omitempty"` + OutputCostPerToken float64 `json:"output_cost_per_token,omitempty"` + InputCostPerPixel float64 `json:"input_cost_per_pixel,omitempty"` + OutputCostPerPixel float64 `json:"output_cost_per_pixel,omitempty"` + InputCostPerSecond float64 `json:"input_cost_per_second,omitempty"` + OutputCostPerSecond float64 `json:"output_cost_per_second,omitempty"` + AWSAccessKeyID string `json:"aws_access_key_id,omitempty"` + AWSSecretAccessKey string `json:"aws_secret_access_key,omitempty"` + AWSRegionName string `json:"aws_region_name,omitempty"` + AWSSessionName string `json:"aws_session_name,omitempty"` + AWSRoleName string `json:"aws_role_name,omitempty"` + VertexProject string `json:"vertex_project,omitempty"` + VertexLocation string `json:"vertex_location,omitempty"` + VertexCredentials string `json:"vertex_credentials,omitempty"` +} + +// ModelInfo represents information about a model. +type ModelInfo struct { + ID string `json:"id"` + DBModel bool `json:"db_model"` + BaseModel string `json:"base_model"` + Tier string `json:"tier"` + Mode string `json:"mode"` + TeamID string `json:"team_id,omitempty"` +} + +// Key represents a LiteLLM API key. +type Key struct { + Key string `json:"key,omitempty"` + TokenID string `json:"token_id,omitempty"` + Models []string `json:"models"` + Spend float64 `json:"spend,omitempty"` + MaxBudget *float64 `json:"max_budget,omitempty"` + UserID string `json:"user_id,omitempty"` + TeamID string `json:"team_id,omitempty"` + MaxParallelRequests *int `json:"max_parallel_requests,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + TPMLimit *int `json:"tpm_limit,omitempty"` + RPMLimit *int `json:"rpm_limit,omitempty"` + BudgetDuration string `json:"budget_duration,omitempty"` + AllowedCacheControls []string `json:"allowed_cache_controls,omitempty"` + SoftBudget *float64 `json:"soft_budget,omitempty"` + KeyAlias string `json:"key_alias,omitempty"` + Duration string `json:"duration,omitempty"` + Aliases map[string]interface{} `json:"aliases,omitempty"` + Config map[string]interface{} `json:"config,omitempty"` + Permissions map[string]interface{} `json:"permissions,omitempty"` + ModelMaxBudget map[string]interface{} `json:"model_max_budget,omitempty"` + ModelRPMLimit map[string]interface{} `json:"model_rpm_limit,omitempty"` + ModelTPMLimit map[string]interface{} `json:"model_tpm_limit,omitempty"` + Guardrails []string `json:"guardrails,omitempty"` + Blocked bool `json:"blocked"` + Tags []string `json:"tags,omitempty"` +} + +// KeyResponse represents a response from the API containing key information. +type KeyResponse struct { + Key string `json:"key"` +} + +// MCPServerCostInfo represents cost information for MCP server tools. +type MCPServerCostInfo struct { + DefaultCostPerQuery float64 `json:"default_cost_per_query,omitempty"` + ToolNameToCostPerQuery map[string]float64 `json:"tool_name_to_cost_per_query,omitempty"` +} + +// MCPInfo represents MCP server information and configuration. +type MCPInfo struct { + ServerName string `json:"server_name,omitempty"` + Description string `json:"description,omitempty"` + LogoURL string `json:"logo_url,omitempty"` + MCPServerCostInfo *MCPServerCostInfo `json:"mcp_server_cost_info,omitempty"` +} + +// MCPServerRequest represents a request to create or update an MCP server. +type MCPServerRequest struct { + ServerID string `json:"server_id,omitempty"` + ServerName string `json:"server_name"` + Alias string `json:"alias,omitempty"` + Description string `json:"description,omitempty"` + Transport string `json:"transport"` + SpecVersion string `json:"spec_version,omitempty"` + AuthType string `json:"auth_type,omitempty"` + URL string `json:"url"` + MCPInfo *MCPInfo `json:"mcp_info,omitempty"` + MCPAccessGroups []string `json:"mcp_access_groups,omitempty"` + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` +} + +// MCPServerResponse represents a response from the API containing MCP server information. +type MCPServerResponse struct { + ServerID string `json:"server_id"` + ServerName string `json:"server_name"` + Alias string `json:"alias,omitempty"` + Description string `json:"description,omitempty"` + URL string `json:"url"` + Transport string `json:"transport"` + SpecVersion string `json:"spec_version,omitempty"` + AuthType string `json:"auth_type,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + CreatedBy string `json:"created_by,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + UpdatedBy string `json:"updated_by,omitempty"` + Teams []map[string]string `json:"teams,omitempty"` + MCPAccessGroups []string `json:"mcp_access_groups,omitempty"` + MCPInfo *MCPInfo `json:"mcp_info,omitempty"` + Status string `json:"status,omitempty"` + LastHealthCheck string `json:"last_health_check,omitempty"` + HealthCheckError string `json:"health_check_error,omitempty"` + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` +} + +// CredentialRequest represents a request to create or update a credential. +type CredentialRequest struct { + CredentialName string `json:"credential_name"` + CredentialInfo map[string]interface{} `json:"credential_info,omitempty"` + CredentialValues map[string]interface{} `json:"credential_values,omitempty"` + ModelID string `json:"model_id,omitempty"` +} + +// CredentialResponse represents a response from the API containing credential information. +type CredentialResponse struct { + CredentialName string `json:"credential_name"` + CredentialInfo map[string]interface{} `json:"credential_info,omitempty"` + CredentialValues map[string]interface{} `json:"credential_values,omitempty"` +} + +// VectorStoreRequest represents a request to create or update a vector store. +type VectorStoreRequest struct { + VectorStoreID string `json:"vector_store_id,omitempty"` + CustomLLMProvider string `json:"custom_llm_provider"` + VectorStoreName string `json:"vector_store_name"` + VectorStoreDescription string `json:"vector_store_description,omitempty"` + VectorStoreMetadata map[string]interface{} `json:"vector_store_metadata,omitempty"` + LiteLLMCredentialName string `json:"litellm_credential_name,omitempty"` + LiteLLMParams map[string]interface{} `json:"litellm_params,omitempty"` +} + +// VectorStoreResponse represents a response from the API containing vector store information. +type VectorStoreResponse struct { + VectorStoreID string `json:"vector_store_id"` + CustomLLMProvider string `json:"custom_llm_provider"` + VectorStoreName string `json:"vector_store_name"` + VectorStoreDescription string `json:"vector_store_description,omitempty"` + VectorStoreMetadata map[string]interface{} `json:"vector_store_metadata,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + LiteLLMCredentialName string `json:"litellm_credential_name,omitempty"` + LiteLLMParams map[string]interface{} `json:"litellm_params,omitempty"` +} + +// VectorStoreListResponse represents a response from the API containing a list of vector stores. +type VectorStoreListResponse struct { + Object string `json:"object"` + Data []VectorStoreResponse `json:"data"` + TotalCount int `json:"total_count"` + CurrentPage int `json:"current_page"` + TotalPages int `json:"total_pages"` +} + +// VectorStoreDeleteRequest represents a request to delete a vector store. +type VectorStoreDeleteRequest struct { + VectorStoreID string `json:"vector_store_id"` +} + +// VectorStoreInfoRequest represents a request to get vector store information. +type VectorStoreInfoRequest struct { + VectorStoreID string `json:"vector_store_id"` +} diff --git a/terraform/provider/litellm/utils.go b/terraform/provider/litellm/utils.go new file mode 100644 index 00000000000..01d8045300c --- /dev/null +++ b/terraform/provider/litellm/utils.go @@ -0,0 +1,279 @@ +package litellm + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +func isModelNotFoundError(errResp ErrorResponse) bool { + if msg, ok := errResp.Error.Message.(string); ok { + if strings.Contains(msg, "model not found") { + return true + } + } + + if msgMap, ok := errResp.Error.Message.(map[string]interface{}); ok { + if errStr, ok := msgMap["error"].(string); ok { + if strings.Contains(errStr, "Model with id=") && strings.Contains(errStr, "not found in db") { + return true + } + } + } + + // Check Detail.Error field for LiteLLM proxy error format + if errResp.Detail.Error != "" { + if strings.Contains(errResp.Detail.Error, "not found on litellm proxy") { + return true + } + } + + return false +} + +func handleAPIResponse(resp *http.Response, reqBody interface{}, client *Client) (*ModelResponse, error) { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %v", err) + } + + if resp.StatusCode != http.StatusOK { + var errResp ErrorResponse + if err := json.Unmarshal(bodyBytes, &errResp); err == nil { + if isModelNotFoundError(errResp) { + return nil, fmt.Errorf("model_not_found") + } + } + reqBodyBytes, _ := json.Marshal(reqBody) + return nil, fmt.Errorf("API request failed: Status: %s, Response: %s, Request: %s", + resp.Status, client.redactSensitiveData(string(bodyBytes)), client.redactSensitiveData(string(reqBodyBytes))) + } + + var modelResp ModelResponse + if err := json.Unmarshal(bodyBytes, &modelResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %v", err) + } + + return &modelResp, nil +} + +// MakeRequest is a helper function to make HTTP requests +func MakeRequest(client *Client, method, endpoint string, body interface{}) (*http.Response, error) { + var req *http.Request + var err error + + if body != nil { + jsonData, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + req, err = http.NewRequest(method, fmt.Sprintf("%s%s", client.APIBase, endpoint), bytes.NewBuffer(jsonData)) + } else { + req, err = http.NewRequest(method, fmt.Sprintf("%s%s", client.APIBase, endpoint), nil) + } + + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", client.APIKey) + + return client.httpClient.Do(req) +} + +// Helper functions to handle potential nil values from the API response +func GetStringValue(apiValue, defaultValue string) string { + if apiValue != "" { + return apiValue + } + return defaultValue +} + +func GetIntValue(apiValue, defaultValue int) int { + if apiValue != 0 { + return apiValue + } + return defaultValue +} + +func GetFloatValue(apiValue, defaultValue float64) float64 { + if apiValue != 0 { + return apiValue + } + return defaultValue +} + +func GetBoolValue(apiValue, defaultValue bool) bool { + return apiValue +} + +// handleMCPAPIResponse handles API responses specifically for MCP server operations +func handleMCPAPIResponse(resp *http.Response, result interface{}, client *Client) error { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %v", err) + } + + if resp.StatusCode != http.StatusOK { + var errResp ErrorResponse + if err := json.Unmarshal(bodyBytes, &errResp); err == nil { + if isMCPServerNotFoundError(errResp) { + return fmt.Errorf("mcp_server_not_found") + } + } + return fmt.Errorf("API request failed: Status: %s, Response: %s", + resp.Status, client.redactSensitiveData(string(bodyBytes))) + } + + if err := json.Unmarshal(bodyBytes, result); err != nil { + return fmt.Errorf("failed to parse response: %v", err) + } + + return nil +} + +// isMCPServerNotFoundError checks if the error response indicates an MCP server not found +func isMCPServerNotFoundError(errResp ErrorResponse) bool { + if msg, ok := errResp.Error.Message.(string); ok { + if strings.Contains(msg, "mcp server not found") || strings.Contains(msg, "server not found") { + return true + } + } + + if msgMap, ok := errResp.Error.Message.(map[string]interface{}); ok { + if errStr, ok := msgMap["error"].(string); ok { + if strings.Contains(errStr, "MCP server with id=") && strings.Contains(errStr, "not found") { + return true + } + } + } + + // Check Detail.Error field for LiteLLM proxy error format + if errResp.Detail.Error != "" { + if strings.Contains(errResp.Detail.Error, "not found") { + return true + } + } + + return false +} + +// isCredentialNotFoundError checks if the error response indicates a credential not found +func isCredentialNotFoundError(errResp ErrorResponse) bool { + if msg, ok := errResp.Error.Message.(string); ok { + if strings.Contains(msg, "credential not found") { + return true + } + } + + if msgMap, ok := errResp.Error.Message.(map[string]interface{}); ok { + if errStr, ok := msgMap["error"].(string); ok { + if strings.Contains(errStr, "Credential with name=") && strings.Contains(errStr, "not found") { + return true + } + } + } + + // Check Detail.Error field for LiteLLM proxy error format + if errResp.Detail.Error != "" { + if strings.Contains(errResp.Detail.Error, "credential not found") { + return true + } + } + + return false +} + +// handleCredentialAPIResponse handles API responses specifically for credential operations +func handleCredentialAPIResponse(resp *http.Response, result interface{}, client *Client) error { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %v", err) + } + + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("credential_not_found") + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + var errResp ErrorResponse + if err := json.Unmarshal(bodyBytes, &errResp); err == nil { + if isCredentialNotFoundError(errResp) { + return fmt.Errorf("credential_not_found") + } + } + return fmt.Errorf("API request failed: Status: %s, Response: %s", + resp.Status, client.redactSensitiveData(string(bodyBytes))) + } + + // For credential operations, we might get a simple string response or a credential object + if result != nil { + if err := json.Unmarshal(bodyBytes, result); err != nil { + // If parsing fails, it might be a simple string response which is fine for create/update/delete + return nil + } + } + + return nil +} + +// isVectorStoreNotFoundError checks if the error response indicates a vector store not found +func isVectorStoreNotFoundError(errResp ErrorResponse) bool { + if msg, ok := errResp.Error.Message.(string); ok { + if strings.Contains(msg, "vector store not found") { + return true + } + } + + if msgMap, ok := errResp.Error.Message.(map[string]interface{}); ok { + if errStr, ok := msgMap["error"].(string); ok { + if strings.Contains(errStr, "Vector store with id=") && strings.Contains(errStr, "not found") { + return true + } + } + } + + // Check Detail.Error field for LiteLLM proxy error format + if errResp.Detail.Error != "" { + if strings.Contains(errResp.Detail.Error, "vector store not found") { + return true + } + } + + return false +} + +// handleVectorStoreAPIResponse handles API responses specifically for vector store operations +func handleVectorStoreAPIResponse(resp *http.Response, result interface{}, client *Client) error { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %v", err) + } + + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("vector_store_not_found") + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + var errResp ErrorResponse + if err := json.Unmarshal(bodyBytes, &errResp); err == nil { + if isVectorStoreNotFoundError(errResp) { + return fmt.Errorf("vector_store_not_found") + } + } + return fmt.Errorf("API request failed: Status: %s, Response: %s", + resp.Status, client.redactSensitiveData(string(bodyBytes))) + } + + if result != nil { + if err := json.Unmarshal(bodyBytes, result); err != nil { + return fmt.Errorf("failed to parse response: %v", err) + } + } + + return nil +} diff --git a/terraform/provider/main.go b/terraform/provider/main.go new file mode 100644 index 00000000000..abe83718899 --- /dev/null +++ b/terraform/provider/main.go @@ -0,0 +1,14 @@ +package main + +import ( + "github.com/BerriAI/terraform-provider-litellm/litellm" + "github.com/hashicorp/terraform-plugin-sdk/v2/plugin" +) + +// main is the entry point for the plugin. It serves the provider +// using the Terraform plugin SDK. +func main() { + plugin.Serve(&plugin.ServeOpts{ + ProviderFunc: litellm.Provider, + }) +} diff --git a/terraform/provider/terraform-registry-manifest.json b/terraform/provider/terraform-registry-manifest.json new file mode 100644 index 00000000000..295001a07f7 --- /dev/null +++ b/terraform/provider/terraform-registry-manifest.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "metadata": { + "protocol_versions": ["6.0"] + } +} diff --git a/terraform/provider/tools/dump_openapi.py b/terraform/provider/tools/dump_openapi.py new file mode 100644 index 00000000000..b4f2dceeb09 --- /dev/null +++ b/terraform/provider/tools/dump_openapi.py @@ -0,0 +1,23 @@ +"""Dump the LiteLLM proxy's OpenAPI schema to the path given as the only argument. + +Run from the litellm repo root with the proxy dependencies installed: + + python terraform/provider/tools/dump_openapi.py openapi.json +""" + +import json +import sys + +from litellm.proxy.proxy_server import app + + +def main(out_path: str) -> None: + with open(out_path, "w") as f: + json.dump(app.openapi(), f) + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("usage: python terraform/provider/tools/dump_openapi.py ", file=sys.stderr) + sys.exit(2) + main(sys.argv[1]) diff --git a/terraform/provider/tools/endpointaudit/main.go b/terraform/provider/tools/endpointaudit/main.go new file mode 100644 index 00000000000..ebc011ee910 --- /dev/null +++ b/terraform/provider/tools/endpointaudit/main.go @@ -0,0 +1,345 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "regexp" + "sort" + "strconv" + "strings" +) + +type endpointCall struct { + Method string + Path string + Pos string +} + +type extraction struct { + Calls []endpointCall + Unresolved []string +} + +var formatVerbPattern = regexp.MustCompile(`%[sdv]`) + +func normalizePath(raw string) string { + withoutQuery := strings.SplitN(raw, "?", 2)[0] + return formatVerbPattern.ReplaceAllString(withoutQuery, "{param}") +} + +func stringLit(expr ast.Expr) (string, bool) { + lit, ok := expr.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return "", false + } + value, err := strconv.Unquote(lit.Value) + if err != nil { + return "", false + } + return value, true +} + +func packageConsts(files []*ast.File) map[string]string { + consts := make(map[string]string) + for _, file := range files { + for _, decl := range file.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || (genDecl.Tok != token.CONST && genDecl.Tok != token.VAR) { + continue + } + for _, spec := range genDecl.Specs { + valueSpec, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + for i, name := range valueSpec.Names { + if i >= len(valueSpec.Values) { + continue + } + if value, ok := stringLit(valueSpec.Values[i]); ok { + consts[name.Name] = value + } + } + } + } + } + return consts +} + +func isSprintf(call *ast.CallExpr) bool { + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "Sprintf" { + return false + } + pkg, ok := sel.X.(*ast.Ident) + return ok && pkg.Name == "fmt" +} + +func resolveExpr(expr ast.Expr, fn *ast.FuncDecl, consts map[string]string) []string { + switch node := expr.(type) { + case *ast.BasicLit: + if value, ok := stringLit(node); ok { + return []string{value} + } + case *ast.Ident: + if value, ok := consts[node.Name]; ok { + return []string{value} + } + return resolveLocalIdent(node, fn, consts) + case *ast.CallExpr: + if isSprintf(node) && len(node.Args) > 0 { + return resolveSprintf(node, fn, consts) + } + } + return nil +} + +func resolveSprintf(call *ast.CallExpr, fn *ast.FuncDecl, consts map[string]string) []string { + formats := resolveExpr(call.Args[0], fn, consts) + results := formats + for _, arg := range call.Args[1:] { + argValues := resolveExpr(arg, fn, consts) + substituted := make([]string, 0, len(results)) + for _, format := range results { + verb := formatVerbPattern.FindStringIndex(format) + if verb == nil { + substituted = append(substituted, format) + continue + } + if len(argValues) == 0 { + substituted = append(substituted, format[:verb[0]]+"\x00param\x00"+format[verb[1]:]) + continue + } + for _, argValue := range argValues { + substituted = append(substituted, format[:verb[0]]+argValue+format[verb[1]:]) + } + } + results = substituted + } + restored := make([]string, 0, len(results)) + for _, result := range results { + restored = append(restored, strings.ReplaceAll(result, "\x00param\x00", "%s")) + } + return restored +} + +func resolveLocalIdent(ident *ast.Ident, fn *ast.FuncDecl, consts map[string]string) []string { + if fn == nil { + return nil + } + var values []string + ast.Inspect(fn.Body, func(node ast.Node) bool { + assign, ok := node.(*ast.AssignStmt) + if !ok { + return true + } + for i, lhs := range assign.Lhs { + lhsIdent, ok := lhs.(*ast.Ident) + if !ok || lhsIdent.Name != ident.Name || i >= len(assign.Rhs) { + continue + } + values = append(values, resolveExpr(assign.Rhs[i], fn, consts)...) + } + return true + }) + return values +} + +func requestCallMethodAndPath(call *ast.CallExpr) (methodArg ast.Expr, pathArg ast.Expr, matched bool) { + switch fun := call.Fun.(type) { + case *ast.SelectorExpr: + if fun.Sel.Name == "sendRequest" && len(call.Args) >= 2 { + return call.Args[0], call.Args[1], true + } + case *ast.Ident: + if fun.Name == "MakeRequest" && len(call.Args) >= 3 { + return call.Args[1], call.Args[2], true + } + } + return nil, nil, false +} + +func isRawHTTPRequest(call *ast.CallExpr) bool { + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || (sel.Sel.Name != "NewRequest" && sel.Sel.Name != "NewRequestWithContext") { + return false + } + pkg, ok := sel.X.(*ast.Ident) + return ok && pkg.Name == "http" +} + +func extractFromFiles(fset *token.FileSet, files []*ast.File, helperFiles map[string]bool) extraction { + consts := packageConsts(files) + var result extraction + for _, file := range files { + fileName := fset.Position(file.Pos()).Filename + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil { + continue + } + ast.Inspect(fn.Body, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + pos := fset.Position(call.Pos()).String() + if isRawHTTPRequest(call) && !helperFiles[fileName] { + result.Unresolved = append(result.Unresolved, + fmt.Sprintf("%s: raw http.NewRequest outside the request helpers; route it through Client.sendRequest or MakeRequest", pos)) + return true + } + methodArg, pathArg, matched := requestCallMethodAndPath(call) + if !matched { + return true + } + methods := resolveExpr(methodArg, fn, consts) + paths := resolveExpr(pathArg, fn, consts) + if len(methods) == 0 || len(paths) == 0 { + result.Unresolved = append(result.Unresolved, + fmt.Sprintf("%s: cannot statically resolve method or path; use a string literal, package const, or fmt.Sprintf with a literal format", pos)) + return true + } + for _, method := range methods { + for _, path := range paths { + result.Calls = append(result.Calls, endpointCall{Method: method, Path: normalizePath(path), Pos: pos}) + } + } + return true + }) + } + } + return result +} + +func extractProviderCalls(providerDir string) (extraction, error) { + fset := token.NewFileSet() + pkgs, err := parser.ParseDir(fset, providerDir, func(info os.FileInfo) bool { + return !strings.HasSuffix(info.Name(), "_test.go") + }, 0) + if err != nil { + return extraction{}, err + } + var files []*ast.File + helperFiles := make(map[string]bool) + for _, pkg := range pkgs { + fileNames := make([]string, 0, len(pkg.Files)) + for name := range pkg.Files { + fileNames = append(fileNames, name) + } + sort.Strings(fileNames) + for _, name := range fileNames { + files = append(files, pkg.Files[name]) + base := name[strings.LastIndex(name, "/")+1:] + if base == "client.go" || base == "utils.go" { + helperFiles[name] = true + } + } + } + return extractFromFiles(fset, files, helperFiles), nil +} + +func loadSpecPaths(specPath string) (map[string]map[string]json.RawMessage, error) { + data, err := os.ReadFile(specPath) + if err != nil { + return nil, err + } + var spec struct { + Paths map[string]map[string]json.RawMessage `json:"paths"` + } + if err := json.Unmarshal(data, &spec); err != nil { + return nil, err + } + if len(spec.Paths) == 0 { + return nil, fmt.Errorf("spec %s contains no paths", specPath) + } + return spec.Paths, nil +} + +func segmentsMatch(providerSegment, specSegment string) bool { + if providerSegment == "{param}" { + return strings.HasPrefix(specSegment, "{") && strings.HasSuffix(specSegment, "}") + } + return providerSegment == specSegment +} + +func pathMatches(providerPath, specPath string) bool { + providerSegments := strings.Split(strings.Trim(providerPath, "/"), "/") + specSegments := strings.Split(strings.Trim(specPath, "/"), "/") + if len(providerSegments) != len(specSegments) { + return false + } + for i := range providerSegments { + if !segmentsMatch(providerSegments[i], specSegments[i]) { + return false + } + } + return true +} + +func auditCalls(calls []endpointCall, specPaths map[string]map[string]json.RawMessage) []string { + var violations []string + for _, call := range calls { + pathFound := false + methodFound := false + for specPath, operations := range specPaths { + if !pathMatches(call.Path, specPath) { + continue + } + pathFound = true + if _, ok := operations[strings.ToLower(call.Method)]; ok { + methodFound = true + break + } + } + if !pathFound { + violations = append(violations, fmt.Sprintf("%s: %s %s is not served by the proxy", call.Pos, call.Method, call.Path)) + } else if !methodFound { + violations = append(violations, fmt.Sprintf("%s: %s %s: path exists but method not allowed", call.Pos, call.Method, call.Path)) + } + } + return violations +} + +func run(providerDir, specPath string) error { + extracted, err := extractProviderCalls(providerDir) + if err != nil { + return err + } + if len(extracted.Unresolved) > 0 { + return fmt.Errorf("unresolved call sites:\n %s", strings.Join(extracted.Unresolved, "\n ")) + } + if len(extracted.Calls) == 0 { + return fmt.Errorf("extracted zero request call sites from %s; extractor or provider layout changed", providerDir) + } + specPaths, err := loadSpecPaths(specPath) + if err != nil { + return err + } + violations := auditCalls(extracted.Calls, specPaths) + if len(violations) > 0 { + sort.Strings(violations) + return fmt.Errorf("provider/proxy endpoint drift:\n %s", strings.Join(violations, "\n ")) + } + fmt.Printf("OK: %d request call sites verified against %d proxy OpenAPI paths\n", len(extracted.Calls), len(specPaths)) + return nil +} + +func main() { + providerDir := flag.String("provider-dir", "./litellm", "directory containing the provider Go source") + specPath := flag.String("spec", "", "path to the proxy OpenAPI schema JSON") + flag.Parse() + if *specPath == "" { + fmt.Fprintln(os.Stderr, "error: -spec is required") + os.Exit(2) + } + if err := run(*providerDir, *specPath); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} diff --git a/terraform/provider/tools/endpointaudit/main_test.go b/terraform/provider/tools/endpointaudit/main_test.go new file mode 100644 index 00000000000..d3d5e7dec9c --- /dev/null +++ b/terraform/provider/tools/endpointaudit/main_test.go @@ -0,0 +1,187 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +func writeFixture(t *testing.T, dir, name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func extractFixture(t *testing.T, files map[string]string) extraction { + t.Helper() + dir := t.TempDir() + for name, body := range files { + writeFixture(t, dir, name, body) + } + result, err := extractProviderCalls(dir) + if err != nil { + t.Fatal(err) + } + return result +} + +func callSet(calls []endpointCall) []string { + set := make(map[string]bool) + for _, call := range calls { + set[call.Method+" "+call.Path] = true + } + keys := make([]string, 0, len(set)) + for key := range set { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func TestExtractResolvesAllCallShapes(t *testing.T) { + result := extractFixture(t, map[string]string{ + "consts.go": `package p + +const ( + endpointModelNew = "/model/new" + endpointModelUpdate = "/model/update" + endpointMCPRead = "/v1/mcp/server" +) +`, + "calls.go": `package p + +import "fmt" + +func (c *Client) a() { + c.sendRequest("POST", "/team/new", nil) + c.sendRequest("GET", fmt.Sprintf("/team/info?team_id=%s", "x"), nil) +} + +func b(client *Client, isUpdate bool, serverID string) { + MakeRequest(client, "POST", "/credentials", nil) + endpoint := endpointModelNew + if isUpdate { + endpoint = endpointModelUpdate + } + MakeRequest(client, "POST", endpoint, nil) + readEndpoint := fmt.Sprintf("%s/%s", endpointMCPRead, serverID) + MakeRequest(client, "GET", readEndpoint, nil) +} +`, + }) + if len(result.Unresolved) != 0 { + t.Fatalf("unexpected unresolved: %v", result.Unresolved) + } + got := callSet(result.Calls) + want := []string{ + "GET /team/info", + "GET /v1/mcp/server/{param}", + "POST /credentials", + "POST /model/new", + "POST /model/update", + "POST /team/new", + } + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestExtractFailsClosedOnDynamicPath(t *testing.T) { + result := extractFixture(t, map[string]string{ + "calls.go": `package p + +func a(c *Client, path string) { + c.sendRequest("GET", path, nil) +} +`, + }) + if len(result.Unresolved) != 1 { + t.Fatalf("want 1 unresolved call site, got %v", result.Unresolved) + } +} + +func TestExtractFlagsRawHTTPRequestOutsideHelpers(t *testing.T) { + result := extractFixture(t, map[string]string{ + "rogue.go": `package p + +import "net/http" + +func a() { + http.NewRequest("GET", "http://example.com/model/new", nil) +} +`, + }) + if len(result.Unresolved) != 1 || !strings.Contains(result.Unresolved[0], "raw http.NewRequest") { + t.Fatalf("want raw request violation, got %v", result.Unresolved) + } +} + +func TestExtractAllowsRawHTTPRequestInHelpers(t *testing.T) { + result := extractFixture(t, map[string]string{ + "utils.go": `package p + +import "net/http" + +func MakeRequest(client *Client, method, endpoint string, body interface{}) { + http.NewRequest(method, endpoint, nil) +} +`, + }) + if len(result.Unresolved) != 0 { + t.Fatalf("unexpected unresolved: %v", result.Unresolved) + } +} + +func specFixture(t *testing.T) map[string]map[string]json.RawMessage { + t.Helper() + raw := `{ + "paths": { + "/team/new": {"post": {}}, + "/organization/update": {"patch": {}}, + "/credentials/{credential_name}": {"get": {}, "delete": {}} + } + }` + dir := t.TempDir() + specPath := filepath.Join(dir, "spec.json") + if err := os.WriteFile(specPath, []byte(raw), 0o644); err != nil { + t.Fatal(err) + } + paths, err := loadSpecPaths(specPath) + if err != nil { + t.Fatal(err) + } + return paths +} + +func TestAuditDetectsMissingPathAndWrongMethod(t *testing.T) { + spec := specFixture(t) + violations := auditCalls([]endpointCall{ + {Method: "POST", Path: "/team/new", Pos: "a.go:1"}, + {Method: "GET", Path: "/credentials/{param}", Pos: "a.go:2"}, + {Method: "POST", Path: "/organization/update", Pos: "a.go:3"}, + {Method: "POST", Path: "/gone/away", Pos: "a.go:4"}, + }, spec) + if len(violations) != 2 { + t.Fatalf("want 2 violations, got %v", violations) + } + joined := strings.Join(violations, "\n") + if !strings.Contains(joined, "POST /organization/update: path exists but method not allowed") { + t.Fatalf("missing method violation: %v", violations) + } + if !strings.Contains(joined, "POST /gone/away is not served by the proxy") { + t.Fatalf("missing path violation: %v", violations) + } +} + +func TestNormalizePathStripsQueryAndVerbs(t *testing.T) { + if got := normalizePath("/key/info?key=%s"); got != "/key/info" { + t.Fatalf("got %q", got) + } + if got := normalizePath("/credentials/%s"); got != "/credentials/{param}" { + t.Fatalf("got %q", got) + } +} diff --git a/tests/llm_responses_api_testing/test_base_responses_api_streaming_iterator.py b/tests/llm_responses_api_testing/test_base_responses_api_streaming_iterator.py index 2acced4c679..5388c5aef83 100644 --- a/tests/llm_responses_api_testing/test_base_responses_api_streaming_iterator.py +++ b/tests/llm_responses_api_testing/test_base_responses_api_streaming_iterator.py @@ -647,9 +647,10 @@ class TestBaseResponsesAPIStreamingIterator: assert result.type == ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE assert iterator.completed_response == result - # Success handler should have been called (via _handle_logging_completed_response) + # Success handlers are dispatched as one async task (via _handle_logging_completed_response); + # the sync handler must never be submitted to the executor concurrently (LIT-4210) mock_create_task.assert_called_once() - mock_executor.submit.assert_called_once() + mock_executor.submit.assert_not_called() # Failure handlers should NOT have been called mock_logging_obj.async_failure_handler.assert_not_called() diff --git a/tests/llm_responses_api_testing/test_responses_hooks.py b/tests/llm_responses_api_testing/test_responses_hooks.py index 3cc5e3984e2..2344a62de4d 100644 --- a/tests/llm_responses_api_testing/test_responses_hooks.py +++ b/tests/llm_responses_api_testing/test_responses_hooks.py @@ -38,6 +38,11 @@ class _FakeLoggingObj: self.model_call_details = {"litellm_params": {}} # Signature alignment with Logging handlers + async def dispatch_success_handlers(self, *args, **kwargs): + kwargs.pop("prefer_async_handlers", None) + await self.async_success_handler(*args, **kwargs) + self.success_handler(*args, **kwargs) + def success_handler(self, *args, **kwargs): self.success_calls += 1 self.last_success_kwargs = kwargs diff --git a/tests/local_testing/test_cost_calc.py b/tests/local_testing/test_cost_calc.py index ab4d44d2240..3623af59848 100644 --- a/tests/local_testing/test_cost_calc.py +++ b/tests/local_testing/test_cost_calc.py @@ -101,7 +101,16 @@ def test_run(model: str): pytest.skip( "LLM API returning inconsistent usage" ) # handles transient openai errors - streaming_cost_calc = completion_cost(response) * 100 + streaming_cost_calc = ( + completion_cost( + response, + custom_cost_per_token={ + "input_cost_per_token": kwargs["input_cost_per_token"], + "output_cost_per_token": kwargs["output_cost_per_token"], + }, + ) + * 100 + ) print(f"Stream output : {output}") print(f"Stream usage : {response.usage}") # type: ignore diff --git a/tests/local_testing/test_router_fallbacks.py b/tests/local_testing/test_router_fallbacks.py index 6547a3eb663..7c09c978029 100644 --- a/tests/local_testing/test_router_fallbacks.py +++ b/tests/local_testing/test_router_fallbacks.py @@ -1330,6 +1330,8 @@ def test_router_fallbacks_with_custom_model_costs(): Goal: make sure custom model doesn't override default model costs. """ + default_model_info = litellm.get_model_info(model="claude-sonnet-4-5-20250929") + model_list = [ { "model_name": "claude-sonnet-4-5-20250929", @@ -1383,8 +1385,8 @@ def test_router_fallbacks_with_custom_model_costs(): print(f"key: {model_info['key']}") - assert model_info["input_cost_per_token"] == 30 - assert model_info["output_cost_per_token"] == 60 + assert model_info["input_cost_per_token"] == default_model_info["input_cost_per_token"] + assert model_info["output_cost_per_token"] == default_model_info["output_cost_per_token"] @pytest.mark.parametrize("sync_mode", [True, False]) diff --git a/tests/test_litellm/a2a_protocol/test_a2a_streaming_iterator.py b/tests/test_litellm/a2a_protocol/test_a2a_streaming_iterator.py new file mode 100644 index 00000000000..d86cbb94a91 --- /dev/null +++ b/tests/test_litellm/a2a_protocol/test_a2a_streaming_iterator.py @@ -0,0 +1,102 @@ +""" +Regression test for LIT-4210: completing an A2A stream must not run the sync +success_handler on the thread-pool executor concurrently with +async_success_handler (cross-thread pydantic mutation segfaults pydantic-core). +""" + +import asyncio +import time +from types import SimpleNamespace + +import pytest + +import litellm +from litellm.a2a_protocol import streaming_iterator as a2a_streaming_iterator_module +from litellm.a2a_protocol.streaming_iterator import A2AStreamingIterator +from litellm.integrations.custom_logger import CustomLogger +from litellm.litellm_core_utils import thread_pool_executor as thread_pool_executor_module +from litellm.litellm_core_utils.litellm_logging import Logging as LitellmLogging + + +class RecordingCustomLogger(CustomLogger): + def __init__(self): + super().__init__() + self.async_hook_fired = False + + async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): + self.async_hook_fired = True + + async def async_log_stream_event(self, kwargs, response_obj, start_time, end_time): + self.async_hook_fired = True + + +class RecordingExecutor: + def __init__(self, inner): + self._inner = inner + self.submits: list = [] + + def submit(self, fn, *args, **kwargs): + self.submits.append(fn) + return self._inner.submit(fn, *args, **kwargs) + + def submitted_for(self, logging_obj) -> list: + return [fn for fn in self.submits if getattr(fn, "__self__", None) is logging_obj] + + +@pytest.fixture(autouse=True) +def _isolate_callbacks(): + saved = ( + litellm.callbacks, + litellm.success_callback, + litellm._async_success_callback, + litellm.failure_callback, + litellm._async_failure_callback, + ) + yield + ( + litellm.callbacks, + litellm.success_callback, + litellm._async_success_callback, + litellm.failure_callback, + litellm._async_failure_callback, + ) = saved + + +@pytest.mark.asyncio +async def test_custom_logger_only_never_submits_sync_success_handler(monkeypatch): + recording_executor = RecordingExecutor(thread_pool_executor_module.executor) + monkeypatch.setattr(thread_pool_executor_module, "executor", recording_executor) + monkeypatch.setattr(a2a_streaming_iterator_module, "executor", recording_executor, raising=False) + + recorder = RecordingCustomLogger() + litellm.success_callback = [recorder] + litellm._async_success_callback = [recorder] + + logging_obj = LitellmLogging( + model="a2a/test-agent", + messages=[{"role": "user", "content": "hi"}], + stream=True, + call_type="a2a_send_message_streaming", + start_time=time.time(), + litellm_call_id="lit-4210-test", + function_id="lit-4210-test", + ) + + async def _empty_stream(): + return + yield + + iterator = A2AStreamingIterator( + stream=_empty_stream(), + request=SimpleNamespace( + params=SimpleNamespace(message={"role": "user", "parts": [{"kind": "text", "text": "hi"}]}) + ), + logging_obj=logging_obj, + agent_name="test-agent", + ) + + await iterator._handle_stream_complete() + await asyncio.sleep(0.5) + + assert recorder.async_hook_fired is True + assert recording_executor.submitted_for(logging_obj) == [] diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_logger.py b/tests/test_litellm/integrations/otel/test_otel_v2_logger.py index 674b2bec829..697b9293eea 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_logger.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_logger.py @@ -168,6 +168,43 @@ def test_async_log_success_event_emits_llm_call_span(): assert span.status.status_code is StatusCode.UNSET +def test_streaming_span_carries_time_to_first_chunk(): + logger, exporter = _logger() + kwargs = { + **_kwargs(payload=_payload(stream=True)), + "optional_params": {"stream": True}, + "api_call_start_time": datetime(2026, 5, 26, 12, 0, 0, tzinfo=timezone.utc), + "completion_start_time": datetime(2026, 5, 26, 12, 0, 0, 750000, tzinfo=timezone.utc), + } + _emit_llm(logger, kwargs) + (span,) = exporter.get_finished_spans() + assert span.attributes[GenAI.RESPONSE_TIME_TO_FIRST_CHUNK] == pytest.approx(0.75) + + +def test_non_streaming_span_has_no_time_to_first_chunk(): + logger, exporter = _logger() + kwargs = { + **_kwargs(), + "optional_params": {}, + "api_call_start_time": datetime(2026, 5, 26, 12, 0, 0, tzinfo=timezone.utc), + "completion_start_time": datetime(2026, 5, 26, 12, 0, 5, tzinfo=timezone.utc), + } + _emit_llm(logger, kwargs) + (span,) = exporter.get_finished_spans() + assert GenAI.RESPONSE_TIME_TO_FIRST_CHUNK not in span.attributes + + +def test_streaming_span_without_timing_omits_time_to_first_chunk(): + logger, exporter = _logger() + kwargs = { + **_kwargs(payload=_payload(stream=True)), + "optional_params": {"stream": True}, + } + _emit_llm(logger, kwargs) + (span,) = exporter.get_finished_spans() + assert GenAI.RESPONSE_TIME_TO_FIRST_CHUNK not in span.attributes + + def test_async_log_failure_event_marks_error_status(): logger, exporter = _logger() payload = _payload( diff --git a/tests/test_litellm/interactions/test_interactions_streaming_iterator.py b/tests/test_litellm/interactions/test_interactions_streaming_iterator.py new file mode 100644 index 00000000000..9f88b2c9611 --- /dev/null +++ b/tests/test_litellm/interactions/test_interactions_streaming_iterator.py @@ -0,0 +1,97 @@ +""" +Regression test for LIT-4210: completing an async Interactions API stream must +not run the sync success_handler on the thread-pool executor concurrently with +async_success_handler (cross-thread pydantic mutation segfaults pydantic-core). +""" + +import asyncio +import time + +import httpx +import pytest + +import litellm +from litellm.integrations.custom_logger import CustomLogger +from litellm.interactions import streaming_iterator as interactions_streaming_iterator_module +from litellm.interactions.streaming_iterator import InteractionsAPIStreamingIterator +from litellm.litellm_core_utils import thread_pool_executor as thread_pool_executor_module +from litellm.litellm_core_utils.litellm_logging import Logging as LitellmLogging +from litellm.types.interactions import InteractionsAPIStreamingResponse + + +class RecordingCustomLogger(CustomLogger): + def __init__(self): + super().__init__() + self.async_hook_fired = False + + async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): + self.async_hook_fired = True + + async def async_log_stream_event(self, kwargs, response_obj, start_time, end_time): + self.async_hook_fired = True + + +class RecordingExecutor: + def __init__(self, inner): + self._inner = inner + self.submits: list = [] + + def submit(self, fn, *args, **kwargs): + self.submits.append(fn) + return self._inner.submit(fn, *args, **kwargs) + + def submitted_for(self, logging_obj) -> list: + return [fn for fn in self.submits if getattr(fn, "__self__", None) is logging_obj] + + +@pytest.fixture(autouse=True) +def _isolate_callbacks(): + saved = ( + litellm.callbacks, + litellm.success_callback, + litellm._async_success_callback, + litellm.failure_callback, + litellm._async_failure_callback, + ) + yield + ( + litellm.callbacks, + litellm.success_callback, + litellm._async_success_callback, + litellm.failure_callback, + litellm._async_failure_callback, + ) = saved + + +@pytest.mark.asyncio +async def test_custom_logger_only_never_submits_sync_success_handler(monkeypatch): + recording_executor = RecordingExecutor(thread_pool_executor_module.executor) + monkeypatch.setattr(thread_pool_executor_module, "executor", recording_executor) + monkeypatch.setattr(interactions_streaming_iterator_module, "executor", recording_executor) + + recorder = RecordingCustomLogger() + litellm.success_callback = [recorder] + litellm._async_success_callback = [recorder] + + logging_obj = LitellmLogging( + model="gemini/gemini-3-pro-preview", + messages=[{"role": "user", "content": "hi"}], + stream=True, + call_type="ainteraction", + start_time=time.time(), + litellm_call_id="lit-4210-test", + function_id="lit-4210-test", + ) + iterator = InteractionsAPIStreamingIterator( + response=httpx.Response(200), + model="gemini/gemini-3-pro-preview", + interactions_api_config=None, + logging_obj=logging_obj, + ) + iterator.completed_response = InteractionsAPIStreamingResponse() + + iterator._handle_logging_completed_response() + await asyncio.sleep(0.5) + + assert recorder.async_hook_fired is True + assert recording_executor.submitted_for(logging_obj) == [] diff --git a/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py b/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py index 766befd1a99..dff54515098 100644 --- a/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py +++ b/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py @@ -2961,10 +2961,13 @@ async def test_log_messages_routes_async_logging_through_bounded_worker(): with ( patch("litellm.litellm_core_utils.realtime_streaming.GLOBAL_LOGGING_WORKER") as mock_worker, patch("litellm.litellm_core_utils.realtime_streaming.asyncio.create_task") as mock_create_task, - patch("litellm.litellm_core_utils.realtime_streaming.executor.submit"), ): await streaming.log_messages() mock_worker.ensure_initialized_and_enqueue.assert_called_once() + enqueued = mock_worker.ensure_initialized_and_enqueue.call_args + assert (enqueued.args or tuple(enqueued.kwargs.values()))[0] is logging_obj.dispatch_success_handlers.return_value + logging_obj.dispatch_success_handlers.assert_called_once_with(streaming.messages, prefer_async_handlers=True) + logging_obj.success_handler.assert_not_called() # the bare create_task path must no longer be used for success logging mock_create_task.assert_not_called() diff --git a/tests/test_litellm/llms/bedrock/test_mantle.py b/tests/test_litellm/llms/bedrock/test_mantle.py index f7f8f582abc..c5ce0d5aba7 100644 --- a/tests/test_litellm/llms/bedrock/test_mantle.py +++ b/tests/test_litellm/llms/bedrock/test_mantle.py @@ -204,6 +204,88 @@ def test_mantle_transform_request_strips_prefix_and_adds_model(): ) assert request["model"] == "anthropic.claude-mythos-preview" assert "mantle/" not in request["model"] + assert "stream" not in request + + +def test_mantle_transform_request_keeps_stream_in_body(): + config = AmazonMantleConfig() + request = config.transform_request( + model="mantle/anthropic.claude-mythos-preview", + messages=[{"role": "user", "content": "Hello"}], + optional_params={"max_tokens": 100, "stream": True}, + litellm_params={}, + headers={}, + ) + assert request["stream"] is True + assert request["model"] == "anthropic.claude-mythos-preview" + + +@pytest.mark.asyncio +async def test_mantle_async_transform_request_keeps_stream_in_body(): + config = AmazonMantleConfig() + request = await config.async_transform_request( + model="mantle/anthropic.claude-mythos-preview", + messages=[{"role": "user", "content": "Hello"}], + optional_params={"max_tokens": 100, "stream": True}, + litellm_params={}, + headers={}, + ) + assert request["stream"] is True + assert request["model"] == "anthropic.claude-mythos-preview" + + +@pytest.mark.asyncio +async def test_mantle_async_transform_request_omits_stream_when_not_streaming(): + config = AmazonMantleConfig() + request = await config.async_transform_request( + model="mantle/anthropic.claude-mythos-preview", + messages=[{"role": "user", "content": "Hello"}], + optional_params={"max_tokens": 100}, + litellm_params={}, + headers={}, + ) + assert "stream" not in request + + +def test_mantle_messages_transform_request_keeps_stream_in_body(): + from litellm.types.router import GenericLiteLLMParams + + config = AmazonMantleMessagesConfig() + request = config.transform_anthropic_messages_request( + model="mantle/anthropic.claude-mythos-preview", + messages=[{"role": "user", "content": "Hello"}], + anthropic_messages_optional_request_params={"max_tokens": 100, "stream": True}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert request["stream"] is True + assert request["model"] == "anthropic.claude-mythos-preview" + + +def test_mantle_messages_transform_request_omits_stream_when_not_streaming(): + from litellm.types.router import GenericLiteLLMParams + + config = AmazonMantleMessagesConfig() + request = config.transform_anthropic_messages_request( + model="mantle/anthropic.claude-mythos-preview", + messages=[{"role": "user", "content": "Hello"}], + anthropic_messages_optional_request_params={"max_tokens": 100}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert "stream" not in request + + +def test_mantle_chat_streaming_uses_anthropic_sse_iterator(): + from litellm.llms.anthropic.chat.handler import ModelResponseIterator + + config = AmazonMantleConfig() + assert config.has_custom_stream_wrapper is False + iterator = config.get_model_response_iterator( + streaming_response=iter([]), + sync_stream=True, + ) + assert isinstance(iterator, ModelResponseIterator) def test_mantle_validate_environment_sets_workspace_header(): @@ -347,3 +429,164 @@ async def test_mantle_anthropic_messages_routes_to_vpc_api_base(): assert len(urls) == 1 assert urls[0] == f"{_VPC_ENDPOINT}/anthropic/v1/messages" assert "api.aws" not in urls[0] + + +_ANTHROPIC_SSE_EVENTS = ( + ( + "message_start", + { + "type": "message_start", + "message": { + "id": "msg_stream_test", + "type": "message", + "role": "assistant", + "model": "anthropic.claude-mythos-preview", + "content": [], + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 10, "output_tokens": 1}, + }, + }, + ), + ( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ), + ( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "pong"}, + }, + ), + ("content_block_stop", {"type": "content_block_stop", "index": 0}), + ( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 2}, + }, + ), + ("message_stop", {"type": "message_stop"}), +) + + +def _anthropic_sse_bytes() -> bytes: + return "".join( + f"event: {event}\ndata: {json.dumps(payload)}\n\n" + for event, payload in _ANTHROPIC_SSE_EVENTS + ).encode() + + +def _anthropic_sse_response(url: str) -> httpx.Response: + return httpx.Response( + status_code=200, + content=_anthropic_sse_bytes(), + headers={"content-type": "text/event-stream"}, + request=httpx.Request("POST", url), + ) + + +def test_mantle_completion_streaming_sends_stream_and_decodes_sse(): + import litellm + + requests = [] + + def mock_post(self, url, data=None, headers=None, **kwargs): + requests.append(_capture_request(url=url, headers=headers or {}, data=data)) + return _anthropic_sse_response(url) + + with patch("litellm.llms.custom_httpx.http_handler.HTTPHandler.post", mock_post): + response = litellm.completion( + model="bedrock/mantle/anthropic.claude-mythos-preview", + messages=[{"role": "user", "content": "ping"}], + max_tokens=10, + stream=True, + aws_access_key_id="fake-key", + aws_secret_access_key="fake-secret", + aws_region_name="us-east-1", + ) + chunks = list(response) + + assert len(requests) == 1 + assert requests[0]["body"]["stream"] is True + content = "".join(chunk.choices[0].delta.content or "" for chunk in chunks) + assert content == "pong" + assert chunks[-1].choices[0].finish_reason == "stop" + + +@pytest.mark.asyncio +async def test_mantle_acompletion_streaming_sends_stream_and_decodes_sse(): + import litellm + + requests = [] + + async def mock_post(self, url, data=None, headers=None, **kwargs): + requests.append(_capture_request(url=url, headers=headers or {}, data=data)) + return _anthropic_sse_response(url) + + try: + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", + new=mock_post, + ): + response = await litellm.acompletion( + model="bedrock/mantle/anthropic.claude-mythos-preview", + messages=[{"role": "user", "content": "ping"}], + max_tokens=10, + stream=True, + aws_access_key_id="fake-key", + aws_secret_access_key="fake-secret", + aws_region_name="us-east-1", + ) + chunks = [chunk async for chunk in response] + finally: + await litellm.close_litellm_async_clients() + + assert len(requests) == 1 + assert requests[0]["body"]["stream"] is True + content = "".join(chunk.choices[0].delta.content or "" for chunk in chunks) + assert content == "pong" + assert chunks[-1].choices[0].finish_reason == "stop" + + +@pytest.mark.asyncio +async def test_mantle_anthropic_messages_streaming_sends_stream_and_passes_through_sse(): + import litellm + + requests = [] + + async def mock_post(self, url, data=None, headers=None, **kwargs): + requests.append(_capture_request(url=url, headers=headers or {}, data=data)) + return _anthropic_sse_response(str(url)) + + try: + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", + new=mock_post, + ): + response = await litellm.anthropic_messages( + model="bedrock/mantle/anthropic.claude-mythos-preview", + messages=[{"role": "user", "content": "ping"}], + max_tokens=10, + stream=True, + aws_access_key_id="fake-key", + aws_secret_access_key="fake-secret", + aws_region_name="us-east-1", + ) + raw = b"".join([chunk async for chunk in response]) + finally: + await litellm.close_litellm_async_clients() + + assert len(requests) == 1 + assert requests[0]["body"]["stream"] is True + text = raw.decode() + assert "event: message_start" in text + assert '"text": "pong"' in text + assert "event: message_stop" in text diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index 3607d448aad..3db0f8540f9 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -2146,6 +2146,104 @@ class TestMCPDelegateAuthToUpstream: assert exc_info.value.status_code == 401 mock_auth.assert_called_once() + async def test_delegate_ignored_for_unstamped_m2m_shaped_server(self): + """ + oauth2 + delegate + oauth2_flow=None but the M2M credential shape + (client_id/secret + token_url, no authorization_url) → bypass must NOT + fire. A legacy row that was never stamped still resolves to + client_credentials by shape, and reading the bare column here would + reopen the anonymous bypass to a server that runs upstream as LiteLLM's + service account. Fails closed like the client_credentials case above. + """ + from fastapi import HTTPException + + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/legacy_m2m_server", + "headers": [], + } + + legacy_m2m_server = MCPServer( + server_id="legacy-m2m-id", + name="legacy_m2m_server", + transport="http", + auth_type=MCPAuth.oauth2, + delegate_auth_to_upstream=True, + oauth2_flow=None, + client_id="cid", + client_secret="csecret", + token_url="https://idp.example.com/token", + ) + assert legacy_m2m_server.has_client_credentials is False + + async def mock_auth_raises(*_args, **_kwargs): + raise HTTPException(status_code=401, detail="No key provided") + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + side_effect=mock_auth_raises, + ) as mock_auth, + patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" + ) as mock_mgr, + ): + mock_mgr.get_mcp_server_by_name.return_value = legacy_m2m_server + with pytest.raises(HTTPException) as exc_info: + await MCPRequestHandler.process_mcp_request(scope) + assert exc_info.value.status_code == 401 + mock_auth.assert_called_once() + + async def test_delegate_bypass_for_pure_pkce_server(self): + """ + oauth2 + delegate + oauth2_flow=None and NO stored client credentials + (pure PKCE, the common delegate case) → bypass must still fire. The + shape resolves to a non-M2M flow, so the security gate leaves it alone; + the fail-closed rule targets the M2M shape specifically, not every + unstamped row. + """ + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/pkce_server", + "headers": [], + } + + pkce_server = MCPServer( + server_id="pkce-server-id", + name="pkce_server", + transport="http", + auth_type=MCPAuth.oauth2, + delegate_auth_to_upstream=True, + oauth2_flow=None, + ) + + async def mock_auth_raises(*_args, **_kwargs): + from fastapi import HTTPException + + raise HTTPException(status_code=401, detail="No key provided") + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + side_effect=mock_auth_raises, + ) as mock_auth, + patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" + ) as mock_mgr, + ): + mock_mgr.get_mcp_server_by_name.return_value = pkce_server + auth, *_rest = await MCPRequestHandler.process_mcp_request(scope) + mock_auth.assert_not_called() + assert auth.api_key is None + async def test_delegate_bypass_for_internal_server(self): """ Delegate + oauth2 interactive servers bypass LiteLLM auth even when @@ -2234,6 +2332,56 @@ class TestMCPDelegateAuthToUpstream: assert "pkce-server" in result assert "m2m-server" not in result + async def test_get_allowed_servers_excludes_unstamped_m2m_shape_delegate(self): + """ + The anonymous allow-list must also exclude an M2M-shape delegate server whose + oauth2_flow was never stamped (null column, verbatim-read as non-M2M). Reading + the bare has_client_credentials here would surface it to anonymous callers; the + resolved-flow check fails closed on the shape, matching the auth gate. + """ + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + MCPServerManager, + ) + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + manager = MCPServerManager() + pkce_server = MCPServer( + server_id="pkce-server", + name="pkce_server", + transport="http", + auth_type=MCPAuth.oauth2, + delegate_auth_to_upstream=True, + available_on_public_internet=True, + ) + unstamped_m2m = MCPServer( + server_id="unstamped-m2m", + name="unstamped_m2m", + transport="http", + auth_type=MCPAuth.oauth2, + delegate_auth_to_upstream=True, + oauth2_flow=None, + client_id="cid", + client_secret="csecret", + token_url="https://idp.example.com/token", + ) + assert unstamped_m2m.has_client_credentials is False + manager.registry = { + pkce_server.server_id: pkce_server, + unstamped_m2m.server_id: unstamped_m2m, + } + + with patch.object( + MCPRequestHandler, + "get_allowed_mcp_servers", + new_callable=AsyncMock, + return_value=[], + ): + result = await manager.get_allowed_mcp_servers(None) + + assert "pkce-server" in result + assert "unstamped-m2m" not in result + async def test_get_allowed_servers_includes_internal_delegate(self): """ Internal-only (available_on_public_internet=False) delegate servers @@ -3105,6 +3253,106 @@ async def test_get_team_object_permission_with_core_auth_auto_loading(): mock_get_team.assert_called_once() +@pytest.mark.asyncio +async def test_get_team_object_permission_ui_session_team_skips_db_lookup(): + """ + UI session tokens carry the virtual team_id "litellm-dashboard" (UI_TEAM_ID), + which is never persisted. The lookup must short-circuit to None without + calling get_team_object; otherwise every MCP tools listing from the + dashboard logs a "Team doesn't exist in db" warning per server. + """ + from litellm.proxy._types import UI_TEAM_ID + + mock_user_auth = UserAPIKeyAuth( + api_key="test-key", + user_id="test-user", + team_id=UI_TEAM_ID, + ) + + mock_prisma = MagicMock() + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + with patch("litellm.proxy.auth.auth_checks.get_team_object") as mock_get_team: + result = await MCPRequestHandler._get_team_object_permission( + mock_user_auth + ) + + assert result is None + mock_get_team.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "helper_name,expected", + [ + ("_get_allowed_mcp_servers_for_team", []), + ("_get_mcp_access_groups_for_team", []), + ], +) +async def test_team_mcp_helpers_ui_session_team_skip_db_lookup(helper_name, expected): + """ + The server-permission and access-group helpers hit get_team_object with the + session's team_id too; for the virtual UI team each used to 404 into its + own swallowed warning per MCP listing. They must short-circuit without a + DB lookup. + """ + from litellm.proxy._types import UI_TEAM_ID + + mock_user_auth = UserAPIKeyAuth( + api_key="test-key", + user_id="test-user", + team_id=UI_TEAM_ID, + ) + + mock_prisma = MagicMock() + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + with patch("litellm.proxy.auth.auth_checks.get_team_object") as mock_get_team: + helper = getattr(MCPRequestHandler, helper_name) + result = await helper(mock_user_auth) + + assert result == expected + mock_get_team.assert_not_called() + + +@pytest.mark.asyncio +async def test_get_allowed_tools_for_server_ui_session_team_keeps_key_restrictions(): + """ + Regression: the 404 raised by get_team_object for the virtual UI team used + to escape into get_allowed_tools_for_server's blanket except, dropping + key-level tool restrictions (fail-open) and logging a warning. With the + short-circuit, key restrictions still apply for UI sessions. + """ + from fastapi import HTTPException + + from litellm.proxy._types import UI_TEAM_ID + + user_api_key_auth = UserAPIKeyAuth( + api_key="test-key", + user_id="test-user", + team_id=UI_TEAM_ID, + ) + key_perm = MagicMock() + key_perm.mcp_tool_permissions = {"server_1": ["tool_a"]} + + mock_prisma = MagicMock() + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + with patch( + "litellm.proxy.auth.auth_checks.get_team_object", + side_effect=HTTPException( + status_code=404, + detail={"error": "Team doesn't exist in db. Team=litellm-dashboard."}, + ), + ): + with patch.object( + MCPRequestHandler, "_get_key_object_permission", return_value=key_perm + ): + result = await MCPRequestHandler.get_allowed_tools_for_server( + server_id="server_1", + user_api_key_auth=user_api_key_auth, + ) + + assert result == ["tool_a"] + + @pytest.mark.asyncio async def test_get_allowed_mcp_servers_for_team_uses_helper(): """ diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index 44f1d105093..1f44160aef4 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -6592,3 +6592,65 @@ async def test_get_active_submitted_mcp_server_ids_for_user_empty_user_id_skips_ assert await get_active_submitted_mcp_server_ids_for_user(prisma_client, "") == [] prisma_client.db.litellm_mcpservertable.find_many.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_call_tool_with_legacy_db_m2m_server_resolves_oauth2_flow(): + """ + Finding 3 regression: the call_mcp_tool path must apply the same request-time + oauth2_flow backstop the listing path does. A legacy DB row with oauth2_flow=NULL + but the M2M credential shape must reach execute_mcp_tool resolved to + client_credentials, or the caller's Authorization would be forwarded to an M2M + upstream on tool execution during a backfill gap (the list path was covered, the + call path was not). + """ + try: + from litellm.proxy._experimental.mcp_server.server import call_mcp_tool + from litellm.proxy._types import UserAPIKeyAuth + from litellm.types.mcp import MCPAuth + except ImportError: + pytest.skip("MCP server not available") + + user_auth = UserAPIKeyAuth(api_key="sk-1234", user_id="test-user") + + legacy_server = MCPServer( + server_id="legacy-m2m-id", + name="legacy_m2m", + alias="legacy_m2m", + server_name="legacy_m2m", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + oauth2_flow=None, # legacy: unstamped + token_url="https://oauth.example.com/token", + client_id="client-id", + client_secret="client-secret", + ) + assert legacy_server.has_client_credentials is False + + captured_servers = {} + + async def capture_execute(*args, **kwargs): + captured_servers["allowed"] = kwargs.get("allowed_mcp_servers") + return MagicMock(name="call_tool_result") + + with ( + patch( + "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager", + ) as mock_manager, + patch( + "litellm.proxy._experimental.mcp_server.server.execute_mcp_tool", + side_effect=capture_execute, + ), + patch( + "litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers_from_mcp_server_names", + new=AsyncMock(side_effect=lambda mcp_servers, allowed_mcp_servers: allowed_mcp_servers), + ), + ): + mock_manager.get_allowed_mcp_servers = AsyncMock(return_value=["legacy-m2m-id"]) + mock_manager.get_mcp_server_by_id = MagicMock(return_value=legacy_server) + + await call_mcp_tool(name="legacy_m2m-tool", arguments={}, user_api_key_auth=user_auth) + + resolved = captured_servers["allowed"] + assert resolved and resolved[0].oauth2_flow == "client_credentials" + assert resolved[0].has_client_credentials is True diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index 358c0409db4..306c74c0d83 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -293,6 +293,86 @@ class TestMCPServerManager: assert server.alias == "friendly_alias" assert server.server_name == "validserver" + def _oauth2_config(self, **overrides): + base = { + "url": "https://example.com/mcp", + "transport": MCPTransport.http, + "auth_type": MCPAuth.oauth2, + "token_url": "https://idp.example.com/token", + "client_id": "cid", + "client_secret": "csec", + } + base.update(overrides) + return {"m2mserver": base} + + @pytest.mark.asyncio + async def test_load_servers_from_config_requires_oauth2_flow(self): + """auth_type oauth2 without an explicit oauth2_flow is a config error: the + credential shape is ambiguous (a DCR interactive server looks identical to M2M), + so the config must assert the flow instead of the proxy guessing it.""" + + manager = MCPServerManager() + + with ( + patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)), + pytest.raises(ValueError) as exc_info, + ): + await manager.load_servers_from_config(self._oauth2_config()) + + assert "oauth2_flow: client_credentials" in str(exc_info.value) + assert "oauth2_flow: authorization_code" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_load_servers_from_config_rejects_unknown_oauth2_flow(self): + manager = MCPServerManager() + + with ( + patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)), + pytest.raises(ValueError) as exc_info, + ): + await manager.load_servers_from_config(self._oauth2_config(oauth2_flow="m2m")) + + assert "got 'm2m'" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_load_servers_from_config_accepts_explicit_client_credentials(self): + manager = MCPServerManager() + + with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)): + await manager.load_servers_from_config(self._oauth2_config(oauth2_flow="client_credentials")) + + server = next(iter(manager.config_mcp_servers.values())) + assert server.oauth2_flow == "client_credentials" + assert server.has_client_credentials is True + + @pytest.mark.asyncio + async def test_load_servers_from_config_accepts_explicit_authorization_code(self): + manager = MCPServerManager() + + with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)): + await manager.load_servers_from_config(self._oauth2_config(oauth2_flow="authorization_code")) + + server = next(iter(manager.config_mcp_servers.values())) + assert server.oauth2_flow == "authorization_code" + assert server.needs_user_oauth_token is True + + @pytest.mark.asyncio + async def test_load_servers_from_config_non_oauth2_needs_no_flow(self): + manager = MCPServerManager() + config = { + "apiserver": { + "url": "https://example.com/mcp", + "transport": MCPTransport.http, + "auth_type": MCPAuth.api_key, + "auth_value": "sk-upstream", + } + } + + await manager.load_servers_from_config(config) + + server = next(iter(manager.config_mcp_servers.values())) + assert server.oauth2_flow is None + @pytest.mark.asyncio async def test_load_servers_from_config_coerces_cost_string_to_float(self): """YAML 1.1 parses `7e-05` as a string; ingest must coerce it to float.""" @@ -1637,6 +1717,7 @@ class TestMCPServerManager: "url": "https://example.com/mcp", "transport": MCPTransport.http, "auth_type": MCPAuth.oauth2, + "oauth2_flow": "authorization_code", "scopes": ["config"], "authorization_url": "https://config.example.com/auth", } @@ -1700,6 +1781,7 @@ class TestMCPServerManager: "url": "https://example.com/mcp", "transport": MCPTransport.http, "auth_type": MCPAuth.oauth2, + "oauth2_flow": "authorization_code", "scopes": ["config"], "authorization_url": "https://config.example.com/auth", } @@ -6076,3 +6158,127 @@ async def test_aggregate_list_still_absorbs_step_up_challenged_server(): result = await manager.list_tools() assert [t.name for t in result] == ["good-do_thing"] + + +class TestDbBuildReadsOauth2FlowColumnVerbatim: + """The DB build must not re-infer the flow from field shape: rows are stamped at + write time and by the startup backfill, and a DCR-registered interactive server + has the exact M2M shape (client creds + token_url, no persisted authorization_url) + whenever discovery is unavailable. Inference survives only for config-loaded + servers and the request-time backstop in _get_allowed_mcp_servers.""" + + def _row(self, oauth2_flow): + return LiteLLM_MCPServerTable( + server_id="flow-column-row", + alias="flow_column_row", + description="", + url="https://up.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + oauth2_flow=oauth2_flow, + token_url="https://idp.example.com/token", + credentials={"client_id": "cid", "client_secret": "csec"}, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + @pytest.mark.asyncio + async def test_null_flow_m2m_shape_row_is_not_inferred_m2m(self): + manager = MCPServerManager() + with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)): + built = await manager.build_mcp_server_from_table(self._row(None), credentials_are_encrypted=False) + + assert built.oauth2_flow is None + assert built.has_client_credentials is False + assert built.needs_user_oauth_token is True + + @pytest.mark.asyncio + async def test_explicit_flow_column_is_read_verbatim(self): + manager = MCPServerManager() + with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)): + built = await manager.build_mcp_server_from_table( + self._row("client_credentials"), credentials_are_encrypted=False + ) + + assert built.oauth2_flow == "client_credentials" + assert built.has_client_credentials is True + assert built.needs_user_oauth_token is False + + @pytest.mark.asyncio + async def test_authorization_code_flow_column_is_read_verbatim(self): + manager = MCPServerManager() + with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)): + built = await manager.build_mcp_server_from_table( + self._row("authorization_code"), credentials_are_encrypted=False + ) + + assert built.oauth2_flow == "authorization_code" + assert built.has_client_credentials is False + assert built.needs_user_oauth_token is True + + +class TestRequestTimeOauth2FlowBackstop: + """The single request-time resolution helpers every security site shares: + effective_oauth2_flow (the enum/boolean decision) and + resolve_oauth2_flow_for_request (the egress object copy).""" + + def _oauth2_server(self, **overrides): + base = dict( + server_id="flow-server", + name="flow_server", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + ) + base.update(overrides) + return MCPServer(**base) + + def test_effective_flow_stamped_values_returned_verbatim(self): + assert ( + MCPServerManager.effective_oauth2_flow(self._oauth2_server(oauth2_flow="client_credentials")) + == "client_credentials" + ) + assert ( + MCPServerManager.effective_oauth2_flow(self._oauth2_server(oauth2_flow="authorization_code")) + == "authorization_code" + ) + + def test_effective_flow_null_m2m_shape_resolves_client_credentials(self): + server = self._oauth2_server( + oauth2_flow=None, + client_id="cid", + client_secret="csecret", + token_url="https://idp.example.com/token", + ) + assert MCPServerManager.effective_oauth2_flow(server) == "client_credentials" + + def test_effective_flow_null_pure_pkce_resolves_none(self): + assert MCPServerManager.effective_oauth2_flow(self._oauth2_server(oauth2_flow=None)) is None + + def test_resolve_for_request_stamped_row_is_unchanged_identity(self): + server = self._oauth2_server(oauth2_flow="client_credentials") + assert MCPServerManager.resolve_oauth2_flow_for_request(server) is server + + def test_resolve_for_request_null_pure_pkce_is_unchanged_identity(self): + server = self._oauth2_server(oauth2_flow=None) + assert MCPServerManager.resolve_oauth2_flow_for_request(server) is server + + def test_resolve_for_request_null_m2m_shape_copies_client_credentials(self, caplog): + import logging + + server = self._oauth2_server( + oauth2_flow=None, + client_id="cid", + client_secret="csecret", + token_url="https://idp.example.com/token", + ) + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + resolved = MCPServerManager.resolve_oauth2_flow_for_request(server) + + assert resolved is not server + assert resolved.oauth2_flow == "client_credentials" + assert server.oauth2_flow is None # original untouched + # Finding 2: the warning must NOT promise the backfill will stamp this row. + joined = " ".join(caplog.messages) + assert "no persisted oauth2_flow" in joined + assert "next proxy boot" not in joined + assert "will NOT self-heal" in joined diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py index 39e630cb1e0..82c0aa3ccda 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py @@ -773,6 +773,251 @@ async def test_semantic_filter_hook_responses_api_name_collision(): print("✅ Responses API tool with MCP-matching name correctly classified as native") +@pytest.mark.asyncio +async def test_semantic_filter_hook_filters_expanded_litellm_proxy_tools(): + """ + Regression test (LIT-4214): litellm_proxy MCP references must be + semantically filtered after expansion, with real filter stats. + + Given: A /v1/responses-style request whose tools are a single + {"type": "mcp", "server_url": "litellm_proxy"} reference that + expands to 5 flat OpenAI function dicts + When: The hook processes the request + Then: The expanded tools go through the semantic filter (top_k=2) + and litellm_semantic_filter_stats reports pre/post counts, so + the x-litellm-semantic-filter header shows how many tools + were filtered out instead of silently forwarding all tools + with no stats. + """ + from litellm.proxy._experimental.mcp_server.semantic_tool_filter import ( + SemanticMCPToolFilter, + ) + from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook + from litellm.types.utils import Embedding, EmbeddingResponse + + mock_router = Mock() + + def mock_embedding_sync(*args, **kwargs): + return EmbeddingResponse( + data=[Embedding(embedding=[0.1] * 1536, index=0, object="embedding")], + model="text-embedding-3-small", + object="list", + usage={"prompt_tokens": 10, "total_tokens": 10}, + ) + + async def mock_embedding_async(*args, **kwargs): + return mock_embedding_sync() + + mock_router.embedding = mock_embedding_sync + mock_router.aembedding = mock_embedding_async + + filter_instance = SemanticMCPToolFilter( + embedding_model="text-embedding-3-small", + litellm_router_instance=mock_router, + top_k=2, + similarity_threshold=0.3, + enabled=True, + ) + + registry_tools = [ + MCPTool( + name=f"srv-tool_{i}", + description=f"Registry tool {i}", + inputSchema={"type": "object"}, + ) + for i in range(5) + ] + filter_instance._build_router(registry_tools) + + expanded_tools = [ + { + "type": "function", + "name": f"srv-tool_{i}", + "description": f"Registry tool {i}", + "parameters": {"type": "object", "properties": {}}, + } + for i in range(5) + ] + + hook = SemanticToolFilterHook(filter_instance) + hook._expand_mcp_tools = AsyncMock( # type: ignore[method-assign] + return_value=expanded_tools + ) + + data = { + "model": "gpt-4", + "input": [{"role": "user", "content": "Send an email", "type": "message"}], + "tools": [ + { + "type": "mcp", + "server_url": "litellm_proxy", + "require_approval": "never", + } + ], + "metadata": {}, + } + + result = await hook.async_pre_call_hook( + user_api_key_dict=Mock(), + cache=Mock(), + data=data, + call_type="aresponses", + ) + + assert result is not None, "Hook should return modified data" + filtered = result["tools"] + + assert len(filtered) <= 2, f"Expanded tools should be filtered to top_k=2, got {len(filtered)}" + assert len(filtered) < len(expanded_tools), ( + f"Hook must not forward all {len(expanded_tools)} expanded tools unfiltered, got {len(filtered)}" + ) + for tool in filtered: + assert tool in expanded_tools, "Filtered tools must be the original expanded tool dicts" + + assert ( + "litellm_semantic_filter_stats" in result["metadata"] + ), "Filter stats must be emitted for the litellm_proxy expansion path" + stats = result["metadata"]["litellm_semantic_filter_stats"] + total, selected = stats.split("->") + assert int(total) == 5, f"Stats 'from' should be pre-filter expanded count (5), got {total}" + assert int(selected) == len(filtered), f"Stats 'to' should match post-filter count, got {selected}" + + print(f"✅ Expanded litellm_proxy tools filtered: {len(expanded_tools)} -> {len(filtered)}, stats={stats}") + + +@pytest.mark.asyncio +async def test_semantic_filter_hook_filters_expanded_tools_with_string_input(): + """ + Responses API requests may pass ``input`` as a plain string; the + expanded-tool filtering must treat it as the user query instead of + crashing (which would silently disable MCP expansion). + """ + from litellm.proxy._experimental.mcp_server.semantic_tool_filter import ( + SemanticMCPToolFilter, + ) + from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook + from litellm.types.utils import Embedding, EmbeddingResponse + + mock_router = Mock() + + def mock_embedding_sync(*args, **kwargs): + return EmbeddingResponse( + data=[Embedding(embedding=[0.1] * 1536, index=0, object="embedding")], + model="text-embedding-3-small", + object="list", + usage={"prompt_tokens": 10, "total_tokens": 10}, + ) + + async def mock_embedding_async(*args, **kwargs): + return mock_embedding_sync() + + mock_router.embedding = mock_embedding_sync + mock_router.aembedding = mock_embedding_async + + filter_instance = SemanticMCPToolFilter( + embedding_model="text-embedding-3-small", + litellm_router_instance=mock_router, + top_k=2, + similarity_threshold=0.3, + enabled=True, + ) + + registry_tools = [ + MCPTool( + name=f"srv-tool_{i}", + description=f"Registry tool {i}", + inputSchema={"type": "object"}, + ) + for i in range(5) + ] + filter_instance._build_router(registry_tools) + + expanded_tools = [ + { + "type": "function", + "name": f"srv-tool_{i}", + "description": f"Registry tool {i}", + "parameters": {"type": "object", "properties": {}}, + } + for i in range(5) + ] + + hook = SemanticToolFilterHook(filter_instance) + + filtered = await hook._filter_expanded_tools( + data={"input": "Send an email"}, + expanded_tools=expanded_tools, + ) + + assert len(filtered) <= 2, f"String input must still drive semantic filtering, got {len(filtered)} tools" + + print(f"✅ String input filtered expanded tools: {len(expanded_tools)} -> {len(filtered)}") + + +@pytest.mark.asyncio +async def test_semantic_filter_hook_expansion_skips_filter_when_disabled(): + """ + When the filter is disabled at runtime (e.g. via the UI toggle), the + expansion path must forward all expanded tools and emit NO filter + stats, mirroring the generic path's enabled guard. + """ + from litellm.proxy._experimental.mcp_server.semantic_tool_filter import ( + SemanticMCPToolFilter, + ) + from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook + + filter_instance = SemanticMCPToolFilter( + embedding_model="text-embedding-3-small", + litellm_router_instance=Mock(), + top_k=2, + similarity_threshold=0.3, + enabled=False, + ) + + expanded_tools = [ + { + "type": "function", + "name": f"srv-tool_{i}", + "description": f"Registry tool {i}", + "parameters": {"type": "object", "properties": {}}, + } + for i in range(5) + ] + + hook = SemanticToolFilterHook(filter_instance) + hook._expand_mcp_tools = AsyncMock( # type: ignore[method-assign] + return_value=expanded_tools + ) + + data = { + "model": "gpt-4", + "input": [{"role": "user", "content": "Send an email", "type": "message"}], + "tools": [ + { + "type": "mcp", + "server_url": "litellm_proxy", + "require_approval": "never", + } + ], + "metadata": {}, + } + + result = await hook.async_pre_call_hook( + user_api_key_dict=Mock(), + cache=Mock(), + data=data, + call_type="aresponses", + ) + + assert result is not None, "Hook should still expand MCP references when the filter is disabled" + assert len(result["tools"]) == 5, f"All expanded tools must be forwarded when disabled, got {len(result['tools'])}" + assert ( + "litellm_semantic_filter_stats" not in result["metadata"] + ), "No filter stats may be emitted when the filter is disabled" + + print("✅ Disabled filter: expansion preserved, no spurious stats") + + @pytest.mark.asyncio async def test_semantic_filter_hook_preserves_tool_order(): """ diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index bc2f41c8cb4..d12ff20ee5b 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -2635,6 +2635,170 @@ async def test_virtual_key_budget_check_fallback_no_counter(): assert exc_info.value.current_cost == 15.0 +# ===================================================================== +# Throttle-on-budget-exceeded tests (LIT-3894): an over-budget key that +# opted in is throttled to a global % of its TPM/RPM instead of blocked. +# ===================================================================== + + +def _over_budget_token(**overrides) -> UserAPIKeyAuth: + base = dict( + token="throttle-token", + spend=20.0, + max_budget=10.0, + user_id="test-user", + ) + base.update(overrides) + return UserAPIKeyAuth(**base) + + +def _patched_spend(value: float): + async def mock_get_current_spend( + counter_key, fallback_spend, max_budget=None, **kwargs + ): + return value + + return patch("litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend) + + +def _budget_logging_obj(): + from litellm.proxy.utils import ProxyLogging + + proxy_logging_obj = ProxyLogging(user_api_key_cache=None) + proxy_logging_obj.budget_alerts = AsyncMock() + return proxy_logging_obj + + +@pytest.mark.parametrize( + "limit, pct, expected", + [ + (1000, 0.1, 100), + (100, 0.1, 10), + (1, 0.1, 1), # floor would be 0; trickle of 1 keeps the key alive + (None, 0.1, None), + (50, 0.5, 25), + (1000, None, 1000), # no percentage -> limit unchanged + ], +) +def test_throttled_limit(limit, pct, expected): + from litellm.proxy.auth.budget_throttle import throttled_limit + + assert throttled_limit(limit, pct) == expected + + +@pytest.mark.asyncio +async def test_budget_exceeded_throttles_instead_of_blocking(monkeypatch): + monkeypatch.setattr(litellm, "budget_exceeded_throttle_percentage", 0.1) + valid_token = _over_budget_token( + tpm_limit=1000, + rpm_limit=100, + metadata={"throttle_on_budget_exceeded": True}, + ) + + with _patched_spend(20.0): + await _virtual_key_max_budget_check( + valid_token=valid_token, + proxy_logging_obj=_budget_logging_obj(), + ) + + # persistent limits are untouched (so the throttle never compounds); the + # request-scoped percentage is what the rate limiter scales by + assert valid_token.budget_throttle_pct == 0.1 + assert valid_token.tpm_limit == 1000 + assert valid_token.rpm_limit == 100 + # the request-scoped decision must not leak into serialized responses + assert "budget_throttle_pct" not in valid_token.model_dump() + + +@pytest.mark.asyncio +async def test_budget_throttle_decision_cleared_before_caching(): + """The request-scoped throttle decision must not persist into the key cache, + otherwise it would re-apply (and compound) on every subsequent request.""" + from litellm.proxy.auth.auth_checks import _copy_user_api_key_auth_for_cache + + valid_token = _over_budget_token( + tpm_limit=1000, rpm_limit=100, metadata={"throttle_on_budget_exceeded": True} + ) + valid_token.budget_throttle_pct = 0.1 + + cached = _copy_user_api_key_auth_for_cache(user_api_key_obj=valid_token) + + assert cached.budget_throttle_pct is None + assert cached.tpm_limit == 1000 + assert cached.rpm_limit == 100 + + +@pytest.mark.asyncio +async def test_budget_exceeded_throttle_no_configured_limits(monkeypatch): + monkeypatch.setattr(litellm, "budget_exceeded_throttle_percentage", 0.1) + valid_token = _over_budget_token(metadata={"throttle_on_budget_exceeded": True}) + assert valid_token.tpm_limit is None + assert valid_token.rpm_limit is None + + with _patched_spend(20.0): + with pytest.raises(litellm.BudgetExceededError): + await _virtual_key_max_budget_check( + valid_token=valid_token, + proxy_logging_obj=_budget_logging_obj(), + ) + + assert valid_token.budget_throttle_pct is None + + +@pytest.mark.asyncio +async def test_budget_exceeded_not_opted_in_still_blocks(monkeypatch): + monkeypatch.setattr(litellm, "budget_exceeded_throttle_percentage", 0.1) + valid_token = _over_budget_token(tpm_limit=1000, rpm_limit=100) + + with _patched_spend(20.0): + with pytest.raises(litellm.BudgetExceededError): + await _virtual_key_max_budget_check( + valid_token=valid_token, + proxy_logging_obj=_budget_logging_obj(), + ) + + assert valid_token.budget_throttle_pct is None + + +@pytest.mark.parametrize("pct", [None, 0, 1.5, -0.1, True]) +@pytest.mark.asyncio +async def test_budget_exceeded_invalid_percentage_blocks(monkeypatch, pct): + monkeypatch.setattr(litellm, "budget_exceeded_throttle_percentage", pct) + valid_token = _over_budget_token( + tpm_limit=1000, + rpm_limit=100, + metadata={"throttle_on_budget_exceeded": True}, + ) + + with _patched_spend(20.0): + with pytest.raises(litellm.BudgetExceededError): + await _virtual_key_max_budget_check( + valid_token=valid_token, + proxy_logging_obj=_budget_logging_obj(), + ) + + assert valid_token.budget_throttle_pct is None + + +@pytest.mark.asyncio +async def test_under_budget_does_not_throttle(monkeypatch): + monkeypatch.setattr(litellm, "budget_exceeded_throttle_percentage", 0.1) + valid_token = _over_budget_token( + max_budget=100.0, + tpm_limit=1000, + rpm_limit=100, + metadata={"throttle_on_budget_exceeded": True}, + ) + + with _patched_spend(5.0): + await _virtual_key_max_budget_check( + valid_token=valid_token, + proxy_logging_obj=_budget_logging_obj(), + ) + + assert valid_token.budget_throttle_pct is None + + @pytest.mark.asyncio async def test_team_budget_check_reads_from_spend_counter(): """Team budget check should use get_current_spend when counter exists.""" diff --git a/tests/test_litellm/proxy/conftest.py b/tests/test_litellm/proxy/conftest.py index 607315eb246..1d71035b67f 100644 --- a/tests/test_litellm/proxy/conftest.py +++ b/tests/test_litellm/proxy/conftest.py @@ -20,6 +20,31 @@ _PROXY_MODULE_GLOBALS_TO_ISOLATE = ( ) +class StubClientNotConnectedError(Exception): + pass + + +class DisconnectedPrisma: + """Mimics prisma-client-py after disconnect(): ``is_connected()`` is False + and the ``_engine`` property raises ``ClientNotConnectedError``.""" + + def is_connected(self) -> bool: + return False + + @property + def _engine(self) -> None: + raise StubClientNotConnectedError( + "Client is not connected to the query engine, you must call `connect()` " + "before attempting to query data." + ) + + +@pytest.fixture +def disconnected_prisma() -> DisconnectedPrisma: + """A stand-in for a Prisma client wedged in the disconnected state.""" + return DisconnectedPrisma() + + @pytest.fixture(autouse=True) def _isolate_proxy_module_globals(): """ diff --git a/tests/test_litellm/proxy/db/test_prisma_client.py b/tests/test_litellm/proxy/db/test_prisma_client.py index 397e3f36e41..eeaf726941f 100644 --- a/tests/test_litellm/proxy/db/test_prisma_client.py +++ b/tests/test_litellm/proxy/db/test_prisma_client.py @@ -92,6 +92,7 @@ async def test_recreate_prisma_client_kills_old_engine_on_disconnect_failure( """When disconnect() fails, recreate_prisma_client must SIGTERM/SIGKILL the old engine PID.""" mock_prisma = AsyncMock() mock_prisma.disconnect.side_effect = Exception("engine hung") + mock_prisma.is_connected = MagicMock(return_value=True) # Simulate engine subprocess with a known PID mock_engine = MagicMock() @@ -122,6 +123,7 @@ async def test_recreate_prisma_client_skips_kill_on_successful_disconnect( ): """When disconnect() succeeds, no kill should be attempted.""" mock_prisma = AsyncMock() + mock_prisma.is_connected = MagicMock(return_value=True) mock_prisma.disconnect.return_value = None wrapper = PrismaWrapper(original_prisma=mock_prisma, iam_token_db_auth=False) @@ -142,6 +144,7 @@ async def test_recreate_prisma_client_handles_missing_engine_pid( ): """When engine PID is unavailable (no _engine attr), kill is skipped gracefully.""" mock_prisma = AsyncMock() + mock_prisma.is_connected = MagicMock(return_value=True) mock_prisma.disconnect.side_effect = Exception("engine hung") mock_prisma._engine = None # No engine subprocess @@ -158,3 +161,35 @@ async def test_recreate_prisma_client_handles_missing_engine_pid( mock_kill.assert_not_called() # PID was 0, kill skipped mock_new_prisma.connect.assert_awaited_once() + + +def test_get_engine_pid_returns_zero_for_disconnected_client(disconnected_prisma): + """A disconnected client must read as "no engine" instead of raising, + otherwise the reconnect path can never recover.""" + wrapper = PrismaWrapper( + original_prisma=disconnected_prisma, iam_token_db_auth=False + ) + + assert wrapper._get_engine_pid() == 0 + + +@pytest.mark.asyncio +async def test_recreate_prisma_client_recovers_from_disconnected_client( + mock_prisma_binary, disconnected_prisma +): + """recreate_prisma_client must still build a replacement client when the + current one is disconnected.""" + wrapper = PrismaWrapper( + original_prisma=disconnected_prisma, iam_token_db_auth=False + ) + + mock_new_prisma = AsyncMock() + mock_prisma_binary.Prisma.return_value = mock_new_prisma + + with patch("os.kill") as mock_kill: + result = await wrapper.recreate_prisma_client("postgresql://new") + + assert result is True + mock_kill.assert_not_called() + assert wrapper._original_prisma is mock_new_prisma + mock_new_prisma.connect.assert_awaited_once() diff --git a/tests/test_litellm/proxy/db/test_prisma_planned_engine_restart.py b/tests/test_litellm/proxy/db/test_prisma_planned_engine_restart.py index 5e74004cc0b..9b382a41964 100644 --- a/tests/test_litellm/proxy/db/test_prisma_planned_engine_restart.py +++ b/tests/test_litellm/proxy/db/test_prisma_planned_engine_restart.py @@ -42,6 +42,7 @@ def mock_prisma_binary(): def _make_wrapper(engine_pid: int = 111, iam: bool = False) -> PrismaWrapper: mock_prisma = MagicMock() mock_prisma.connect = AsyncMock() + mock_prisma.is_connected = MagicMock(return_value=True) mock_prisma._engine = MagicMock() mock_prisma._engine.process.pid = engine_pid return PrismaWrapper(original_prisma=mock_prisma, iam_token_db_auth=iam) diff --git a/tests/test_litellm/proxy/db/test_prisma_self_heal.py b/tests/test_litellm/proxy/db/test_prisma_self_heal.py index 35ef0a965f3..7f723fa3ae0 100644 --- a/tests/test_litellm/proxy/db/test_prisma_self_heal.py +++ b/tests/test_litellm/proxy/db/test_prisma_self_heal.py @@ -20,7 +20,7 @@ def mock_prisma_binary(): """Mock prisma.Prisma to avoid requiring generated Prisma binaries for unit tests.""" mock_module = MagicMock() with patch.dict(sys.modules, {"prisma": mock_module}): - yield + yield mock_module @pytest.fixture @@ -515,6 +515,43 @@ async def test_engine_confirmed_dead_persists_across_failed_heavy_reconnect( assert client._engine_confirmed_dead is True +@pytest.mark.asyncio +async def test_heavy_reconnect_recovers_from_disconnected_prisma_client( + mock_proxy_logging, mock_prisma_binary, disconnected_prisma +): + """Once the active Prisma client is in the disconnected state, every DB + call raises ClientNotConnectedError. The heavy reconnect path is the only + way out, so it must not re-raise that same error while inspecting the + broken client; otherwise `recreate_prisma_client` fails before it can + build a replacement and the proxy loops on failed reconnects forever. + + The full real reconnect path (attempt_db_reconnect -> _run_reconnect_cycle + -> recreate_prisma_client) must succeed from that wedged state. + """ + client = PrismaClient( + database_url="mock://test", proxy_logging_obj=mock_proxy_logging + ) + client.db._original_prisma = disconnected_prisma + client._engine_confirmed_dead = True + client._start_engine_watcher = AsyncMock() + + replacement = MagicMock() + replacement.connect = AsyncMock() + mock_prisma_binary.Prisma.return_value = replacement + + with patch.dict(os.environ, {"DATABASE_URL": "postgresql://test"}): + result = await client.attempt_db_reconnect( + reason="unit_test_disconnected_client", + force=True, + ) + + assert result is True + assert client.db._original_prisma is replacement + replacement.connect.assert_awaited_once() + assert client._consecutive_reconnect_failures == 0 + assert client._engine_confirmed_dead is False + + @pytest.mark.asyncio async def test_db_health_watchdog_should_reconnect_degraded_writer( mock_proxy_logging, diff --git a/tests/test_litellm/proxy/hooks/test_parallel_request_limiter_v3.py b/tests/test_litellm/proxy/hooks/test_parallel_request_limiter_v3.py index 50f471721b1..12f0a64a179 100644 --- a/tests/test_litellm/proxy/hooks/test_parallel_request_limiter_v3.py +++ b/tests/test_litellm/proxy/hooks/test_parallel_request_limiter_v3.py @@ -48,6 +48,42 @@ def time_controller(monkeypatch): return controller +@pytest.mark.parametrize( + "throttle_pct, expected_rpm, expected_tpm", + [ + (None, 100, 1000), # no throttle -> configured limits + (0.1, 10, 100), # 10% of configured + (0.5, 50, 500), + ], +) +def test_api_key_descriptor_applies_budget_throttle( + throttle_pct, expected_rpm, expected_tpm +): + """The api_key rate-limit descriptor scales the key's configured TPM/RPM by + the request-scoped budget_throttle_pct, leaving the configured limits intact.""" + handler = _PROXY_MaxParallelRequestsHandler( + internal_usage_cache=InternalUsageCache(DualCache()) + ) + user_api_key_dict = UserAPIKeyAuth( + api_key=hash_token("sk-throttle"), + rpm_limit=100, + tpm_limit=1000, + budget_throttle_pct=throttle_pct, + ) + + descriptors = handler._create_rate_limit_descriptors( + user_api_key_dict=user_api_key_dict, + data={}, + rpm_limit_type=None, + tpm_limit_type=None, + model_has_failures=False, + ) + + api_key_descriptor = next(d for d in descriptors if d["key"] == "api_key") + assert api_key_descriptor["rate_limit"]["requests_per_unit"] == expected_rpm + assert api_key_descriptor["rate_limit"]["tokens_per_unit"] == expected_tpm + + @pytest.mark.flaky(reruns=3) @pytest.mark.asyncio async def test_sliding_window_rate_limit_v3(monkeypatch, time_controller): diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 7b97ae60443..4fb3df52cf6 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -1495,6 +1495,57 @@ async def test_generate_service_account_works_with_team_id(): ) +@pytest.mark.asyncio +async def test_generate_key_throttle_rejected_for_non_admin(): + """Security regression: a non-admin creating a key must not be able to set + throttle_on_budget_exceeded=true, which would let the new key keep spending + past an admin-imposed per-key budget ceiling instead of hard-blocking. The + /key/update gate does not cover generate, so generate needs its own admin + check. Only the enable value is gated, so this must 403.""" + mock_prisma_client = AsyncMock() + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client): + with pytest.raises(HTTPException) as exc: + await _common_key_generation_helper( + data=GenerateKeyRequest(throttle_on_budget_exceeded=True), + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-alice", + user_id="alice", + ), + litellm_changed_by=None, + team_table=None, + ) + assert int(getattr(exc.value, "status_code", 0)) == 403 + assert "Only proxy admins can enable throttle_on_budget_exceeded" in str(exc.value.detail) + + +@pytest.mark.asyncio +async def test_generate_key_throttle_allowed_for_admin(): + """A proxy admin may create a key with throttle_on_budget_exceeded=true; the + generate admin gate must let the admin through to key creation.""" + with ( + patch("litellm.proxy.proxy_server.prisma_client", AsyncMock()), + patch("litellm.proxy.proxy_server.llm_router", None), + patch("litellm.proxy.proxy_server.premium_user", False), + patch( + "litellm.proxy.management_endpoints.key_management_endpoints.generate_key_helper_fn" + ) as mock_generate_key, + ): + mock_generate_key.return_value = { + "key": "sk-test-key", + "expires": None, + "user_id": "admin", + "team_id": None, + } + await _common_key_generation_helper( + data=GenerateKeyRequest(throttle_on_budget_exceeded=True), + user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-1"), + litellm_changed_by=None, + team_table=None, + ) + assert mock_generate_key.called + + @pytest.mark.asyncio async def test_update_service_account_requires_team_id(): data = UpdateKeyRequest(key="sk-1", metadata={"service_account_id": "sa"}) @@ -9577,6 +9628,165 @@ async def test_update_key_non_budget_fields_allowed_for_internal_user(monkeypatc assert result is not None +@pytest.mark.asyncio +async def test_update_key_throttle_on_budget_exceeded_rejected_for_internal_user( + monkeypatch, +): + """Security regression: throttle_on_budget_exceeded turns an admin-imposed + hard budget block into a soft throttle that keeps spending past max_budget, + so it is a budget-enforcement change. A non-admin key owner (same setup that + is allowed to change non-budget fields via the caller_is_creator shortcut) + must NOT be able to self-opt-in to it; it has to route through the admin-only + _check_key_admin_access and return 403. Without treating the flag as a budget + change this update would succeed, letting the owner bypass their own cap.""" + from litellm.proxy.management_endpoints.key_management_endpoints import ( + update_key_fn, + ) + + mock_prisma_client = AsyncMock() + mock_user_api_key_cache = AsyncMock() + mock_proxy_logging_obj = MagicMock() + + test_hashed_token = "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd" + + # Owner of the key (created_by == user_id) so caller_is_creator is True. + # This is exactly the setup that is allowed to change non-budget fields; + # the throttle flag must still be rejected. + mock_existing_key = MagicMock() + mock_existing_key.token = test_hashed_token + mock_existing_key.user_id = "internal_user" + mock_existing_key.created_by = "internal_user" + mock_existing_key.team_id = None + mock_existing_key.project_id = None + mock_existing_key.max_budget = 10.0 + mock_existing_key.key_alias = None + mock_existing_key.models = [] + mock_existing_key.metadata = {} + mock_existing_key.model_dump.return_value = { + "token": test_hashed_token, + "user_id": "internal_user", + "team_id": None, + "max_budget": 10.0, + } + + mock_prisma_client.get_data = AsyncMock(return_value=mock_existing_key) + mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=mock_existing_key) + + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", mock_user_api_key_cache) + monkeypatch.setattr("litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging_obj) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None) + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True) + + mock_request = MagicMock() + mock_request.query_params = {} + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-internal", + user_id="internal_user", + ) + + with pytest.raises(ProxyException) as exc: + await update_key_fn( + request=mock_request, + data=UpdateKeyRequest( + key=test_hashed_token, + throttle_on_budget_exceeded=True, + ), + user_api_key_dict=user_api_key_dict, + litellm_changed_by=None, + ) + + assert str(exc.value.code) == "403" + assert "Only proxy admins can enable throttle_on_budget_exceeded" in str(exc.value.message) + + +@pytest.mark.asyncio +async def test_update_key_throttle_unchanged_allows_non_budget_edit_for_internal_user( + monkeypatch, +): + """A non-admin owner editing a non-budget field must not be blocked just + because the UI resends throttle_on_budget_exceeded unchanged (the edit form + always includes it). Only the transition to enabled is admin-gated, so an + unchanged False here leaves the key owner's non-budget edit working.""" + from litellm.proxy.management_endpoints.key_management_endpoints import ( + update_key_fn, + ) + + mock_prisma_client = AsyncMock() + mock_user_api_key_cache = AsyncMock() + mock_proxy_logging_obj = MagicMock() + + test_hashed_token = "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd" + + mock_existing_key = MagicMock() + mock_existing_key.token = test_hashed_token + mock_existing_key.user_id = "internal_user" + mock_existing_key.created_by = "internal_user" + mock_existing_key.team_id = None + mock_existing_key.project_id = None + mock_existing_key.max_budget = 10.0 + mock_existing_key.key_alias = None + mock_existing_key.models = [] + mock_existing_key.metadata = {"throttle_on_budget_exceeded": False} + mock_existing_key.model_dump.return_value = { + "token": test_hashed_token, + "user_id": "internal_user", + "team_id": None, + "max_budget": 10.0, + } + + mock_updated_key = MagicMock() + mock_updated_key.token = test_hashed_token + mock_updated_key.key_alias = "my-alias" + + mock_prisma_client.get_data = AsyncMock(return_value=mock_existing_key) + mock_prisma_client.update_data = AsyncMock(return_value=mock_updated_key) + mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=mock_existing_key) + + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", mock_user_api_key_cache) + monkeypatch.setattr("litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging_obj) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None) + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True) + monkeypatch.setattr("litellm.store_audit_logs", False) + + monkeypatch.setattr("litellm.proxy.proxy_server.hash_token", lambda token: test_hashed_token) + + async def _noop(**kwargs): + pass + + monkeypatch.setattr( + "litellm.proxy.management_endpoints.key_management_endpoints._delete_cache_key_object", + _noop, + ) + monkeypatch.setattr( + "litellm.proxy.management_endpoints.key_management_endpoints._enforce_unique_key_alias", + _noop, + ) + + mock_request = MagicMock() + mock_request.query_params = {} + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-internal", + user_id="internal_user", + ) + + result = await update_key_fn( + request=mock_request, + data=UpdateKeyRequest( + key=test_hashed_token, + key_alias="my-alias", + throttle_on_budget_exceeded=False, + ), + user_api_key_dict=user_api_key_dict, + litellm_changed_by=None, + ) + + assert result is not None + + @pytest.mark.asyncio async def test_update_key_non_budget_rejects_cross_user_modification(monkeypatch): """Regression: previously _check_key_admin_access was gated on diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 5f3974b46fb..180fb1d3d8f 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -9495,3 +9495,43 @@ class TestEmitTeamMembersMetric: # A metric failure must be swallowed, not propagated to the handler. _emit_team_members_metric(self._team(1)) fake_logger.set_team_members_metric.assert_called_once() + + +@pytest.mark.asyncio +async def test_new_team_rejects_reserved_ui_session_team_id(): + """ + /team/new must reject team_id "litellm-dashboard" (UI_TEAM_ID): it is the + virtual team stamped on every UI dashboard session token, so a real DB row + with that id would bind its budget and permissions to every UI session. + """ + from fastapi import Request + + from litellm.proxy._types import UI_TEAM_ID, NewTeamRequest + from litellm.proxy.management_endpoints.team_endpoints import new_team + + team_request = NewTeamRequest( + team_alias="dashboard-clone", + team_id=UI_TEAM_ID, + ) + dummy_request = MagicMock(spec=Request) + + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server._license_check") as mock_license, + ): + mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) + mock_license.is_team_count_over_limit.return_value = False + mock_prisma.get_data = AsyncMock(return_value=None) + + with pytest.raises(ProxyException) as exc_info: + await new_team( + data=team_request, + http_request=dummy_request, + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN + ), + ) + + assert exc_info.value.code == "400" + assert "reserved" in str(exc_info.value.message) + mock_prisma.get_data.assert_not_called() diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py index 4acc94c737a..1e9818534c9 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -66,13 +66,14 @@ def _reconstruct_ui_where_from_sql(sql_query, params): Rebuild the Prisma-style ``where`` dict the filter_fns below expect from the raw SQL + params the endpoint emits. - ``ui_view_spend_logs`` folds the total into the page query via - ``COUNT(*) OVER ()`` and no longer issues a separate ``count(where=...)`` - call, so the mock derives the active filter from the one query it sees - instead of from the (now absent) count call. + ``ui_view_spend_logs`` computes the total with a bounded + ``SELECT COUNT(*) FROM (SELECT 1 ... LIMIT $cap+1)`` query and fetches the + page with a separate ``ORDER BY ... LIMIT/OFFSET`` query. Both carry the + same WHERE clause, so the terminator can be ``ORDER BY`` (page query) or + ``LIMIT`` (bounded count query). """ where: dict = {} - clause = re.search(r"WHERE (.*) ORDER BY", sql_query, re.DOTALL) + clause = re.search(r"WHERE (.*?)\s+(?:ORDER BY|LIMIT)", sql_query, re.DOTALL) if clause is None: return where @@ -163,13 +164,13 @@ def make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn, team_lookup_fn=No async def query_raw(self, sql_query, *params): filtered = filter_fn(_reconstruct_ui_where_from_sql(sql_query, params)) + total = len(filtered) + if "COUNT(*)" in sql_query: + cap_plus_one = params[-1] + return [{"total_count": min(total, cap_plus_one)}] page_size = params[-2] if len(params) >= 2 else 50 skip = params[-1] if len(params) >= 1 else 0 - total = len(filtered) - return [ - {**row, "total_count": total} - for row in filtered[skip : skip + page_size] - ] + return [row for row in filtered[skip : skip + page_size]] class MockPrismaClient: def __init__(self): @@ -684,6 +685,8 @@ async def test_ui_view_spend_logs_sort_by_and_sort_order( return len(base_logs) async def mock_query_raw(sql_query, *params): + if "COUNT(*)" in sql_query: + return [{"total_count": len(base_logs)}] # Endpoint uses raw SQL with ORDER BY startTime DESC; mock returns sorted data order = ( {"startTime": "desc"} @@ -693,10 +696,7 @@ async def test_ui_view_spend_logs_sort_by_and_sort_order( sorted_logs = _sort_logs(base_logs, order) page_size = params[-2] if len(params) >= 2 else 50 skip = params[-1] if len(params) >= 1 else 0 - return [ - {**row, "total_count": len(base_logs)} - for row in sorted_logs[skip : skip + page_size] - ] + return [row for row in sorted_logs[skip : skip + page_size]] class MockPrismaClient: def __init__(self): @@ -830,16 +830,15 @@ async def test_ui_view_spend_logs_sort_by_request_duration_ms(client, monkeypatc return len(base_logs) async def mock_query_raw(sql_query, *params): + if "COUNT(*)" in sql_query: + return [{"total_count": len(base_logs)}] reverse = "DESC" in sql_query sorted_logs = sorted( base_logs, key=lambda x: x.get("request_duration_ms", 0), reverse=reverse ) page_size = params[-2] if len(params) >= 2 else 50 skip = params[-1] if len(params) >= 1 else 0 - return [ - {**row, "total_count": len(base_logs)} - for row in sorted_logs[skip : skip + page_size] - ] + return [row for row in sorted_logs[skip : skip + page_size]] class MockPrismaClient: def __init__(self): @@ -926,6 +925,8 @@ async def test_ui_view_spend_logs_sort_by_model( return len(base_logs) async def mock_query_raw(sql_query, *params): + if "COUNT(*)" in sql_query: + return [{"total_count": len(base_logs)}] assert "model" in sql_query # model is non-nullable in the schema, so NULLS LAST should NOT be # appended — only ttft_ms gets that clause. This guards against @@ -937,10 +938,7 @@ async def test_ui_view_spend_logs_sort_by_model( ) page_size = params[-2] if len(params) >= 2 else 50 skip = params[-1] if len(params) >= 1 else 0 - return [ - {**row, "total_count": len(base_logs)} - for row in sorted_logs[skip : skip + page_size] - ] + return [row for row in sorted_logs[skip : skip + page_size]] class MockPrismaClient: def __init__(self): @@ -1040,6 +1038,8 @@ async def test_ui_view_spend_logs_sort_by_ttft_ms(client, monkeypatch): return len(base_logs) async def mock_query_raw(sql_query, *params): + if "COUNT(*)" in sql_query: + return [{"total_count": len(base_logs)}] # Endpoint must compute TTFT inline and use NULLS LAST. assert "completionStartTime" in sql_query assert "NULLS LAST" in sql_query @@ -1051,10 +1051,7 @@ async def test_ui_view_spend_logs_sort_by_ttft_ms(client, monkeypatch): page_size = params[-2] if len(params) >= 2 else 50 skip = params[-1] if len(params) >= 1 else 0 return [ - { - **{k: v for k, v in row.items() if k != "_ttft_ms"}, - "total_count": len(base_logs), - } + {k: v for k, v in row.items() if k != "_ttft_ms"} for row in sorted_logs[skip : skip + page_size] ] diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_query_optimization.py b/tests/test_litellm/proxy/spend_tracking/test_spend_query_optimization.py index e8950e84f55..19083486974 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_query_optimization.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_query_optimization.py @@ -203,31 +203,42 @@ async def test_spend_logs_ui_wraps_params_in_at_time_zone_utc(monkeypatch): ) -@pytest.mark.asyncio -async def test_spend_logs_ui_folds_count_into_window_function(monkeypatch): +def _make_ui_spend_logs_mock(count_total, page_rows): """ - /spend/logs/ui must not issue a separate `COUNT(*)` round trip to compute - the total. On sharded engines like YugabyteDB a standalone `COUNT(*)` is a - distributed RPC that contacts every tablet and times out regardless of row - count, so the logs tab 500s (LIT-4027). The total is folded into the page - query via `COUNT(*) OVER ()` and read off the returned rows instead. + Build a prisma mock whose first `query_raw` (the bounded count) returns + `count_total` and whose second `query_raw` (the page data) returns + `page_rows`. + """ + mock_prisma = MagicMock() + mock_prisma.db = MagicMock() + mock_prisma.db.query_raw = AsyncMock( + side_effect=[[{"total_count": count_total}], page_rows] + ) + mock_prisma.db.litellm_spendlogs = MagicMock() + mock_prisma.db.litellm_spendlogs.count = AsyncMock(return_value=0) + return mock_prisma + + +@pytest.mark.asyncio +async def test_spend_logs_ui_uses_bounded_count_not_full_scan(monkeypatch): + """ + /spend/logs/ui must compute its pagination total with a bounded + `SELECT COUNT(*) FROM (SELECT 1 ... LIMIT $cap+1)` so it never scans the + whole time window of a huge LiteLLM_SpendLogs table (Aurora ACU spike, + LIT-4119). It must also avoid the unbounded prisma `.count()` / + `COUNT(*) OVER ()` full-window count that reads every matching row. """ from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.spend_tracking.spend_management_endpoints import ( + SPEND_LOGS_PAGINATION_COUNT_CAP, ui_view_spend_logs, ) - rows = [ - {"request_id": "req-1", "metadata": "{}", "session_id": None, "total_count": 137}, - {"request_id": "req-2", "metadata": "{}", "session_id": None, "total_count": 137}, + page_rows = [ + {"request_id": "req-1", "metadata": "{}", "session_id": None}, + {"request_id": "req-2", "metadata": "{}", "session_id": None}, ] - - mock_prisma = MagicMock() - mock_prisma.db = MagicMock() - mock_prisma.db.query_raw = AsyncMock(return_value=rows) - mock_prisma.db.litellm_spendlogs = MagicMock() - mock_prisma.db.litellm_spendlogs.count = AsyncMock(return_value=0) - + mock_prisma = _make_ui_spend_logs_mock(count_total=137, page_rows=page_rows) monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin") @@ -250,33 +261,90 @@ async def test_spend_logs_ui_folds_count_into_window_function(monkeypatch): mock_prisma.db.litellm_spendlogs.count.assert_not_called() - sql = mock_prisma.db.query_raw.call_args[0][0] - assert "COUNT(*) OVER ()" in sql, ( - "the page query must carry a window-function count so a separate " - f"distributed COUNT(*) is avoided. SQL was:\n{sql}" + count_call = mock_prisma.db.query_raw.call_args_list[0] + count_sql = count_call[0][0] + assert "COUNT(*) OVER ()" not in count_sql + assert "LIMIT" in count_sql and "FROM (" in count_sql, ( + "the total must come from a bounded subquery count, not a full-window " + f"scan. SQL was:\n{count_sql}" + ) + assert count_call[0][-1] == SPEND_LOGS_PAGINATION_COUNT_CAP + 1, ( + "the bounded count must probe at most cap+1 rows" + ) + + page_sql = mock_prisma.db.query_raw.call_args_list[1][0][0] + assert "COUNT(*) OVER ()" not in page_sql, ( + "the page query must not carry a window count that forces a full-window " + f"scan. SQL was:\n{page_sql}" ) assert response["total"] == 137 + assert response["total_is_capped"] is False assert response["total_pages"] == (137 + 50 - 1) // 50 for row in response["data"]: assert "total_count" not in row, "the window-function helper column must be stripped before serialising rows" +@pytest.mark.asyncio +async def test_spend_logs_ui_caps_total_for_large_result_sets(monkeypatch): + """ + When more than the cap match, /spend/logs/ui reports the cap and flags + `total_is_capped` so the UI can render `+` instead of an exact total + that would require scanning the whole window (LIT-4119). + """ + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.spend_tracking.spend_management_endpoints import ( + SPEND_LOGS_PAGINATION_COUNT_CAP, + ui_view_spend_logs, + ) + + page_rows = [{"request_id": "req-1", "metadata": "{}", "session_id": None}] + mock_prisma = _make_ui_spend_logs_mock( + count_total=SPEND_LOGS_PAGINATION_COUNT_CAP + 1, page_rows=page_rows + ) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) + + auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin") + mock_request = MagicMock() + mock_request.url.path = "/spend/logs/ui" + + response = await ui_view_spend_logs( + request=mock_request, + api_key=None, + user_id=None, + request_id=None, + start_date="2026-02-16 00:00:00", + end_date="2026-02-16 23:59:59", + page=1, + page_size=50, + sort_by="startTime", + sort_order="desc", + user_api_key_dict=auth, + ) + + assert response["total"] == SPEND_LOGS_PAGINATION_COUNT_CAP + assert response["total_is_capped"] is True + assert response["total_pages"] == (SPEND_LOGS_PAGINATION_COUNT_CAP + 50 - 1) // 50 + + @pytest.mark.asyncio async def test_spend_logs_ui_empty_page_reports_zero_total(monkeypatch): """ - When a page matches no rows the window-function count row is absent, so the - total must fall back to zero without issuing a separate `COUNT(*)`. + When nothing matches, the bounded count query returns a single row with a + zero count (real `COUNT(*)` always returns one row) and the page query + returns no rows, so the total is zero without an unbounded prisma `.count()`. """ from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.spend_tracking.spend_management_endpoints import ( ui_view_spend_logs, ) + # First query_raw call is the bounded count (0 matches), second is the empty + # page. mock_prisma = MagicMock() mock_prisma.db = MagicMock() - mock_prisma.db.query_raw = AsyncMock(return_value=[]) + mock_prisma.db.query_raw = AsyncMock(side_effect=[[{"total_count": 0}], []]) mock_prisma.db.litellm_spendlogs = MagicMock() mock_prisma.db.litellm_spendlogs.count = AsyncMock(return_value=0) @@ -307,24 +375,25 @@ async def test_spend_logs_ui_empty_page_reports_zero_total(monkeypatch): @pytest.mark.asyncio -async def test_spend_logs_ui_out_of_range_page_falls_back_to_count(monkeypatch): +async def test_spend_logs_ui_out_of_range_page_keeps_total(monkeypatch): """ - An out-of-range page (offset past the last matching row) returns no rows, so - the window-function count is unavailable. The total must not collapse to zero - there; it falls back to a direct count so total/total_pages stay accurate. - This fallback only fires off the hot path (page > 1 with an empty result), so - the YugabyteDB timeout the fix removes from page 1 stays removed. + An out-of-range page (offset past the last matching row) returns no rows, + but the bounded count query runs independently of the page query, so the + total must not collapse to zero and no unbounded prisma `.count()` is + needed. total/total_pages stay accurate off the hot path too. """ from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.spend_tracking.spend_management_endpoints import ( ui_view_spend_logs, ) + # First query_raw call is the bounded count (7 matches), second is the + # out-of-range page (empty). mock_prisma = MagicMock() mock_prisma.db = MagicMock() - mock_prisma.db.query_raw = AsyncMock(return_value=[]) + mock_prisma.db.query_raw = AsyncMock(side_effect=[[{"total_count": 7}], []]) mock_prisma.db.litellm_spendlogs = MagicMock() - mock_prisma.db.litellm_spendlogs.count = AsyncMock(return_value=7) + mock_prisma.db.litellm_spendlogs.count = AsyncMock(return_value=0) monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) @@ -346,9 +415,10 @@ async def test_spend_logs_ui_out_of_range_page_falls_back_to_count(monkeypatch): user_api_key_dict=auth, ) - mock_prisma.db.litellm_spendlogs.count.assert_called_once() + mock_prisma.db.litellm_spendlogs.count.assert_not_called() assert response["total"] == 7 assert response["total_pages"] == (7 + 2 - 1) // 2 + assert response["data"] == [] @pytest.mark.asyncio diff --git a/tests/test_litellm/proxy/test_budget_reservation.py b/tests/test_litellm/proxy/test_budget_reservation.py index d940f592a83..75242af81f4 100644 --- a/tests/test_litellm/proxy/test_budget_reservation.py +++ b/tests/test_litellm/proxy/test_budget_reservation.py @@ -58,6 +58,101 @@ def _request_body() -> dict: } +async def _reserve(valid_token, cost, key_cache, proxy_logging_obj): + with patch( + "litellm.proxy.spend_tracking.budget_reservation.estimate_request_max_cost", + return_value=cost, + ): + return await reserve_budget_for_request( + request_body=_request_body(), + route="/chat/completions", + llm_router=None, + valid_token=valid_token, + team_object=None, + user_object=None, + prisma_client=None, + user_api_key_cache=key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + + +@pytest.mark.asyncio +async def test_reservation_still_protects_under_budget_throttled_key( + spend_counter_state, monkeypatch +): + """An opted-in key that is still under budget keeps its reservation counter, + so concurrent requests can't collectively overshoot max_budget.""" + monkeypatch.setattr(litellm, "budget_exceeded_throttle_percentage", 0.1) + counter_cache, key_cache = spend_counter_state + proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache) + valid_token = UserAPIKeyAuth( + token="key-throttle-under", + spend=0.0, + max_budget=1.0, + metadata={"throttle_on_budget_exceeded": True}, + ) + + reservation = await _reserve(valid_token, 0.6, key_cache, proxy_logging_obj) + + assert reservation is not None + assert ( + counter_cache.in_memory_cache.get_cache(key="spend:key:key-throttle-under") + == 0.6 + ) + + +@pytest.mark.asyncio +async def test_reservation_does_not_block_over_budget_throttled_key( + spend_counter_state, monkeypatch +): + """Once an opted-in key is over budget the reservation path must not raise; + the rate limiter throttles it instead.""" + monkeypatch.setattr(litellm, "budget_exceeded_throttle_percentage", 0.1) + counter_cache, key_cache = spend_counter_state + proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache) + valid_token = UserAPIKeyAuth( + token="key-throttle-over", + spend=0.0, + max_budget=1.0, + tpm_limit=1000, + rpm_limit=100, + metadata={"throttle_on_budget_exceeded": True}, + ) + + # first reservation lands under budget (counter -> 0.6) + await _reserve(valid_token, 0.6, key_cache, proxy_logging_obj) + + # any further request is over budget (0.6 + 0.6 > 1.0): the opted-in key is + # released and allowed through (None), not blocked, and its over-budget + # increment is released so the counter is not permanently inflated + result = await _reserve(valid_token, 0.6, key_cache, proxy_logging_obj) + assert result is None + assert ( + counter_cache.in_memory_cache.get_cache(key="spend:key:key-throttle-over") + == 0.6 + ) + + +@pytest.mark.asyncio +async def test_reservation_blocks_over_budget_non_throttled_key( + spend_counter_state, monkeypatch +): + monkeypatch.setattr(litellm, "budget_exceeded_throttle_percentage", 0.1) + counter_cache, key_cache = spend_counter_state + proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache) + valid_token = UserAPIKeyAuth( + token="key-no-optin-over", + spend=0.0, + max_budget=1.0, + ) + + await _reserve(valid_token, 0.6, key_cache, proxy_logging_obj) + await _reserve(valid_token, 0.6, key_cache, proxy_logging_obj) # counter -> 1.0 + + with pytest.raises(litellm.BudgetExceededError): + await _reserve(valid_token, 0.6, key_cache, proxy_logging_obj) + + def test_should_not_serialize_budget_reservation_on_user_api_key_auth(): auth = UserAPIKeyAuth( token="key-budget-runtime-state", diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 2dc67c827e3..d06a1c16ab9 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -8644,6 +8644,149 @@ def test_get_config_list_includes_cancel_on_disconnect(monkeypatch): app.dependency_overrides.clear() +def test_get_config_list_includes_budget_exceeded_throttle_percentage(monkeypatch): + """The throttle fraction is a litellm_settings scalar surfaced on the General + Settings table as a Float field so it sits with the other global limits; it + must appear in /config/list reading its live litellm. value.""" + import types + from unittest.mock import AsyncMock, MagicMock + + from fastapi.testclient import TestClient + + import litellm.proxy.proxy_server as ps + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.proxy_server import app + + mock_prisma = MagicMock() + mock_config_table = MagicMock() + mock_config_table.find_first = AsyncMock(return_value=None) + mock_prisma.db = types.SimpleNamespace(litellm_config=mock_config_table) + monkeypatch.setattr(ps, "prisma_client", mock_prisma) + monkeypatch.setattr(litellm, "budget_exceeded_throttle_percentage", 0.15) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN + ) + try: + client = TestClient(app) + resp = client.get("/config/list", params={"config_type": "general_settings"}) + assert resp.status_code == 200, resp.text + fields = {item["field_name"]: item for item in resp.json()} + assert "budget_exceeded_throttle_percentage" in fields + assert fields["budget_exceeded_throttle_percentage"]["field_type"] == "Float" + assert fields["budget_exceeded_throttle_percentage"]["field_value"] == 0.15 + finally: + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_update_config_field_throttle_persists_to_litellm_settings(monkeypatch): + """Editing the throttle Float row on the General Settings table routes to + litellm_settings (not general_settings): it sets litellm. live and + persists under litellm_settings so the runtime read is unchanged.""" + from unittest.mock import MagicMock + + import litellm.proxy.proxy_server as ps + from litellm.proxy._types import ( + ConfigFieldUpdate, + LitellmUserRoles, + UserAPIKeyAuth, + ) + from litellm.proxy.proxy_server import update_config_general_settings + + saved: dict = {} + + async def fake_get_config(): + return {"litellm_settings": {}} + + async def fake_save_config(new_config=None): + saved.update(new_config or {}) + + monkeypatch.setattr(ps.proxy_config, "get_config", fake_get_config) + monkeypatch.setattr(ps.proxy_config, "save_config", fake_save_config) + monkeypatch.setattr(ps, "prisma_client", MagicMock()) + monkeypatch.setattr(litellm, "store_audit_logs", False) + monkeypatch.setattr(litellm, "budget_exceeded_throttle_percentage", None) + + admin = UserAPIKeyAuth(api_key="k", user_id="a", user_role=LitellmUserRoles.PROXY_ADMIN) + await update_config_general_settings( + data=ConfigFieldUpdate( + field_name="budget_exceeded_throttle_percentage", + field_value=0.1, + config_type="general_settings", + ), + user_api_key_dict=admin, + ) + + assert litellm.budget_exceeded_throttle_percentage == 0.1 + assert saved["litellm_settings"]["budget_exceeded_throttle_percentage"] == 0.1 + + +@pytest.mark.parametrize("bad_value", [0, -0.1, 1.5, True]) +@pytest.mark.asyncio +async def test_update_config_field_throttle_rejects_invalid(monkeypatch, bad_value): + from unittest.mock import MagicMock + + from fastapi import HTTPException + + import litellm.proxy.proxy_server as ps + from litellm.proxy._types import ( + ConfigFieldUpdate, + LitellmUserRoles, + UserAPIKeyAuth, + ) + from litellm.proxy.proxy_server import update_config_general_settings + + async def fake_get_config(): + return {"litellm_settings": {}} + + monkeypatch.setattr(ps.proxy_config, "get_config", fake_get_config) + monkeypatch.setattr(ps, "prisma_client", MagicMock()) + monkeypatch.setattr(litellm, "budget_exceeded_throttle_percentage", None) + + admin = UserAPIKeyAuth(api_key="k", user_id="a", user_role=LitellmUserRoles.PROXY_ADMIN) + with pytest.raises(HTTPException) as exc: + await update_config_general_settings( + data=ConfigFieldUpdate( + field_name="budget_exceeded_throttle_percentage", + field_value=bad_value, + config_type="general_settings", + ), + user_api_key_dict=admin, + ) + assert exc.value.status_code == 400 + assert litellm.budget_exceeded_throttle_percentage is None + + +@pytest.mark.asyncio +async def test_update_config_field_throttle_rejected_for_non_admin(monkeypatch): + from unittest.mock import MagicMock + + from fastapi import HTTPException + + import litellm.proxy.proxy_server as ps + from litellm.proxy._types import ( + ConfigFieldUpdate, + LitellmUserRoles, + UserAPIKeyAuth, + ) + from litellm.proxy.proxy_server import update_config_general_settings + + monkeypatch.setattr(ps, "prisma_client", MagicMock()) + monkeypatch.setattr(litellm, "budget_exceeded_throttle_percentage", None) + + non_admin = UserAPIKeyAuth(api_key="k", user_id="u", user_role=LitellmUserRoles.INTERNAL_USER) + with pytest.raises(HTTPException): + await update_config_general_settings( + data=ConfigFieldUpdate( + field_name="budget_exceeded_throttle_percentage", + field_value=0.1, + config_type="general_settings", + ), + user_api_key_dict=non_admin, + ) + assert litellm.budget_exceeded_throttle_percentage is None + + def test_preserve_redacted_plugin_keys_keeps_stored_credential(): """A redacted or blank plugin_key on update must not overwrite the real key.""" from litellm.proxy.proxy_server import _preserve_redacted_plugin_keys diff --git a/tests/test_litellm/proxy/utils/prisma_and_spend/test_prisma_client_engine_watcher.py b/tests/test_litellm/proxy/utils/prisma_and_spend/test_prisma_client_engine_watcher.py index 2fedd6bb134..25c04caabba 100644 --- a/tests/test_litellm/proxy/utils/prisma_and_spend/test_prisma_client_engine_watcher.py +++ b/tests/test_litellm/proxy/utils/prisma_and_spend/test_prisma_client_engine_watcher.py @@ -42,6 +42,7 @@ def test_get_engine_pid_extracts_process_pid(prisma_client: PrismaClient) -> Non fake_engine.process = MagicMock() fake_engine.process.pid = 4242 prisma_client.db._original_prisma = MagicMock() + prisma_client.db._original_prisma.is_connected = MagicMock(return_value=True) prisma_client.db._original_prisma._engine = fake_engine actual = { "pid": prisma_client._get_engine_pid(), @@ -58,6 +59,15 @@ def test_get_engine_pid_returns_zero_when_engine_attr_missing( assert prisma_client._get_engine_pid() == 0 +def test_get_engine_pid_returns_zero_when_client_disconnected( + prisma_client: PrismaClient, disconnected_prisma +) -> None: + """The reconnect path calls this on an arbitrarily-broken client; it must + report "no engine" instead of re-raising ClientNotConnectedError.""" + prisma_client.db._original_prisma = disconnected_prisma + assert prisma_client._get_engine_pid() == 0 + + def test_is_engine_alive_true_when_pid_zero(prisma_client: PrismaClient) -> None: prisma_client._engine_pid = 0 pinned = { diff --git a/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py b/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py index 80e2eb48f62..6fdbb0741aa 100644 --- a/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py +++ b/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py @@ -1,4 +1,6 @@ +import subprocess import sys +import textwrap import types from unittest.mock import AsyncMock, MagicMock @@ -557,3 +559,49 @@ async def test_execute_tool_calls_propagates_request_tags_to_function_setup(monk ) assert captured["metadata"]["tags"] == ["team-a", "prod"] + + +def test_completion_with_function_tools_works_without_fastapi_installed(): + script = textwrap.dedent( + """ + import sys + + class _FastapiBlocker: + def find_spec(self, fullname, path=None, target=None): + if fullname == "fastapi" or fullname.startswith("fastapi."): + raise ModuleNotFoundError("No module named 'fastapi'") + return None + + sys.meta_path.insert(0, _FastapiBlocker()) + + import litellm + + response = litellm.completion( + model="openai/gpt-5.5", + messages=[{"role": "user", "content": "What is the weather in SF?"}], + tools=[ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather for a location", + "parameters": { + "type": "object", + "properties": {"location": {"type": "string"}}, + "required": ["location"], + }, + }, + } + ], + mock_response="sunny", + ) + assert response.choices[0].message.content == "sunny" + """ + ) + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + timeout=120, + ) + assert result.returncode == 0, result.stderr diff --git a/tests/test_litellm/responses/test_responses_streaming_iterator.py b/tests/test_litellm/responses/test_responses_streaming_iterator.py new file mode 100644 index 00000000000..9ba7dfcaa80 --- /dev/null +++ b/tests/test_litellm/responses/test_responses_streaming_iterator.py @@ -0,0 +1,158 @@ +""" +Regression tests for LIT-4210: the streaming iterators must never run the sync +success_handler on the thread-pool executor concurrently with +async_success_handler. Concurrent mutation of the shared response object / +model_call_details from two threads segfaults pydantic-core (customer pods +crashed with exit 139 whenever any CustomLogger was registered). +""" + +import asyncio +import time + +import httpx +import pytest + +import litellm +from litellm.integrations.custom_logger import CustomLogger +from litellm.litellm_core_utils import thread_pool_executor as thread_pool_executor_module +from litellm.responses import streaming_iterator as responses_streaming_iterator_module +from litellm.litellm_core_utils.litellm_logging import Logging as LitellmLogging +from litellm.responses.streaming_iterator import ResponsesAPIStreamingIterator +from litellm.types.llms.openai import ResponsesAPIResponse + + +class RecordingCustomLogger(CustomLogger): + def __init__(self): + super().__init__() + self.async_hook_started: float | None = None + self.async_hook_finished: float | None = None + + async def _record(self): + self.async_hook_started = time.monotonic() + await asyncio.sleep(0.2) + self.async_hook_finished = time.monotonic() + + async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): + await self._record() + + async def async_log_stream_event(self, kwargs, response_obj, start_time, end_time): + await self._record() + + +class RecordingExecutor: + def __init__(self, inner): + self._inner = inner + self.submits: list = [] + + def submit(self, fn, *args, **kwargs): + self.submits.append((time.monotonic(), fn)) + return self._inner.submit(fn, *args, **kwargs) + + def submit_times_for(self, logging_obj) -> list: + return [t for t, fn in self.submits if getattr(fn, "__self__", None) is logging_obj] + + +@pytest.fixture(autouse=True) +def _isolate_callbacks(): + saved = ( + litellm.callbacks, + litellm.success_callback, + litellm._async_success_callback, + litellm.failure_callback, + litellm._async_failure_callback, + ) + yield + ( + litellm.callbacks, + litellm.success_callback, + litellm._async_success_callback, + litellm.failure_callback, + litellm._async_failure_callback, + ) = saved + + +@pytest.fixture +def recording_executor(monkeypatch): + recording = RecordingExecutor(thread_pool_executor_module.executor) + monkeypatch.setattr(thread_pool_executor_module, "executor", recording) + monkeypatch.setattr(responses_streaming_iterator_module, "executor", recording) + return recording + + +def _make_logging_obj() -> LitellmLogging: + logging_obj = LitellmLogging( + model="gpt-5.4-nano", + messages=[{"role": "user", "content": "hi"}], + stream=True, + call_type="aresponses", + start_time=time.time(), + litellm_call_id="lit-4210-test", + function_id="lit-4210-test", + ) + logging_obj.model_call_details["litellm_params"] = {"aresponses": True} + return logging_obj + + +def _make_iterator(logging_obj: LitellmLogging) -> ResponsesAPIStreamingIterator: + iterator = ResponsesAPIStreamingIterator( + response=httpx.Response(200), + model="gpt-5.4-nano", + responses_api_provider_config=None, + logging_obj=logging_obj, + ) + iterator.completed_response = ResponsesAPIResponse( + id="resp_lit4210", + created_at=1700000000.0, + model="gpt-5.4-nano", + object="response", + output=[], + parallel_tool_calls=True, + tool_choice="auto", + tools=[], + error=None, + incomplete_details=None, + instructions=None, + metadata={}, + temperature=1.0, + top_p=1.0, + ) + return iterator + + +@pytest.mark.asyncio +async def test_custom_logger_only_never_submits_sync_success_handler(recording_executor): + recorder = RecordingCustomLogger() + litellm.success_callback = [recorder] + litellm._async_success_callback = [recorder] + + logging_obj = _make_logging_obj() + iterator = _make_iterator(logging_obj) + + iterator._log_completed_response(is_async=True) + await asyncio.sleep(0.6) + + assert recorder.async_hook_started is not None + assert recording_executor.submit_times_for(logging_obj) == [] + + +@pytest.mark.asyncio +async def test_sync_callbacks_run_only_after_async_handler_completes(recording_executor): + recorder = RecordingCustomLogger() + sync_events: list = [] + + def sync_callback(kwargs, response_obj, start_time, end_time): + sync_events.append(time.monotonic()) + + litellm.success_callback = [recorder, sync_callback] + litellm._async_success_callback = [recorder] + + logging_obj = _make_logging_obj() + iterator = _make_iterator(logging_obj) + + iterator._log_completed_response(is_async=True) + await asyncio.sleep(0.8) + + assert recorder.async_hook_finished is not None + submit_times = recording_executor.submit_times_for(logging_obj) + assert len(submit_times) == 1 + assert submit_times[0] >= recorder.async_hook_finished diff --git a/tests/test_litellm/responses/test_responses_websocket_all_providers.py b/tests/test_litellm/responses/test_responses_websocket_all_providers.py index 0d13ff4fd05..4509abc7749 100644 --- a/tests/test_litellm/responses/test_responses_websocket_all_providers.py +++ b/tests/test_litellm/responses/test_responses_websocket_all_providers.py @@ -1122,7 +1122,7 @@ class TestNativeWebSocketGuardrails: client_ws = MagicMock() client_ws.send_text = AsyncMock() logging_obj = MagicMock() - logging_obj.async_success_handler = AsyncMock() + logging_obj.dispatch_success_handlers = AsyncMock() delta_event = json.dumps( {"type": "response.output_text.delta", "delta": "alice@example.com"} @@ -1196,7 +1196,7 @@ class TestNativeWebSocketGuardrails: client_ws = MagicMock() client_ws.send_text = AsyncMock() logging_obj = MagicMock() - logging_obj.async_success_handler = AsyncMock() + logging_obj.dispatch_success_handlers = AsyncMock() done_events = [ json.dumps( @@ -1895,7 +1895,7 @@ class TestNativeWebSocketGuardrailMasking: ] ) logging_obj = MagicMock() - logging_obj.async_success_handler = AsyncMock() + logging_obj.dispatch_success_handlers = AsyncMock() handler = _make_streaming( websocket=websocket, @@ -1951,7 +1951,7 @@ class TestNativeWebSocketGuardrailMasking: ] ) logging_obj = MagicMock() - logging_obj.async_success_handler = AsyncMock() + logging_obj.dispatch_success_handlers = AsyncMock() handler = _make_streaming( websocket=websocket, @@ -2014,7 +2014,7 @@ class TestNativeWebSocketGuardrailMasking: ] ) logging_obj = MagicMock() - logging_obj.async_success_handler = AsyncMock() + logging_obj.dispatch_success_handlers = AsyncMock() handler = _make_streaming( websocket=websocket, @@ -2077,7 +2077,7 @@ class TestNativeWebSocketGuardrailMasking: ] ) logging_obj = MagicMock() - logging_obj.async_success_handler = AsyncMock() + logging_obj.dispatch_success_handlers = AsyncMock() handler = _make_streaming( websocket=websocket, diff --git a/tests/test_litellm/test_register_model_custom_pricing.py b/tests/test_litellm/test_register_model_custom_pricing.py index e384d3e1161..8c3f690982b 100644 --- a/tests/test_litellm/test_register_model_custom_pricing.py +++ b/tests/test_litellm/test_register_model_custom_pricing.py @@ -9,15 +9,32 @@ mode, and supports_prompt_caching were dropped, causing incorrect cost calculations for DB-sourced models with prompt caching pricing. """ +import copy import os import sys +import pytest + sys.path.insert( 0, os.path.abspath("../..") ) # Adds the parent directory to the system path import litellm from litellm.main import _build_custom_pricing_entry +from litellm.utils import _invalidate_model_cost_lowercase_map + + +def _snapshot_model_cost_entries(keys): + return {key: copy.deepcopy(litellm.model_cost.get(key)) for key in keys} + + +def _restore_model_cost_entries(original_entries): + for key, value in original_entries.items(): + if value is None: + litellm.model_cost.pop(key, None) + else: + litellm.model_cost[key] = value + _invalidate_model_cost_lowercase_map() def test_build_custom_pricing_entry_includes_all_kwargs_fields(): @@ -471,3 +488,166 @@ def test_register_model_router_add_deployment_custom_pricing_applies(): litellm.model_cost.pop(model_key, None) litellm.model_cost.pop(deployment_model, None) del router + + +def test_embedding_router_zero_pricing_does_not_clobber_builtin_pricing(): + """LIT-3991: a router-originated embedding request that carries explicit + zero custom pricing (e.g. resolved through an ``openai/*`` wildcard + deployment with ``input_cost_per_token: 0``) must not overwrite the shared + ``openai/text-embedding-3-small`` entry in ``litellm.model_cost``. Before + the fix, one call through the wildcard poisoned the shared key and every + sibling deployment relying on built-in pricing logged $0 until restart. + """ + shared_key = "openai/text-embedding-3-small" + deployment_id = "lit3991-wildcard-embed-zero" + snapshot = _snapshot_model_cost_entries( + [shared_key, "text-embedding-3-small", deployment_id] + ) + builtin_input_cost = litellm.get_model_info(model=shared_key)[ + "input_cost_per_token" + ] + assert builtin_input_cost > 0 + + try: + litellm.embedding( + model=shared_key, + input=["hello"], + api_key="fake-key", + input_cost_per_token=0.0, + output_cost_per_token=0.0, + model_info={"id": deployment_id}, + metadata={"model_info": {"id": deployment_id}}, + mock_response=[0.1, 0.2], + ) + + assert ( + litellm.get_model_info(model=shared_key)["input_cost_per_token"] + == builtin_input_cost + ), "wildcard deployment's zero pricing leaked into the shared model_cost key" + assert litellm.model_cost[deployment_id]["input_cost_per_token"] == 0.0 + assert litellm.model_cost[deployment_id]["output_cost_per_token"] == 0.0 + + sibling_response = litellm.embedding( + model=shared_key, + input=["hello"], + api_key="fake-key", + mock_response=[0.1, 0.2], + ) + sibling_cost = litellm.completion_cost( + completion_response=sibling_response, call_type="embedding" + ) + assert sibling_cost == pytest.approx(10 * builtin_input_cost) + finally: + _restore_model_cost_entries(snapshot) + + +def test_embedding_router_custom_pricing_costs_request_via_deployment_id(): + """The request that carries custom pricing must still be costed with that + pricing (via its deployment id entry), while the shared backend key keeps + the built-in rate for siblings. + """ + shared_key = "openai/text-embedding-3-small" + deployment_id = "lit3991-wildcard-embed-custom" + override_input_cost = 5e-05 + snapshot = _snapshot_model_cost_entries( + [shared_key, "text-embedding-3-small", deployment_id] + ) + builtin_input_cost = litellm.get_model_info(model=shared_key)[ + "input_cost_per_token" + ] + assert builtin_input_cost != override_input_cost + + try: + response = litellm.embedding( + model=shared_key, + input=["hello"], + api_key="fake-key", + input_cost_per_token=override_input_cost, + output_cost_per_token=override_input_cost * 2, + model_info={"id": deployment_id}, + metadata={"model_info": {"id": deployment_id}}, + mock_response=[0.1, 0.2], + ) + + request_cost = litellm.completion_cost( + completion_response=response, + model=shared_key, + custom_llm_provider="openai", + call_type="embedding", + custom_pricing=True, + router_model_id=deployment_id, + ) + assert request_cost == pytest.approx(10 * override_input_cost) + assert ( + litellm.get_model_info(model=shared_key)["input_cost_per_token"] + == builtin_input_cost + ) + finally: + _restore_model_cost_entries(snapshot) + + +def test_completion_router_zero_pricing_does_not_clobber_builtin_pricing(): + """Same isolation as the embedding path, exercised through completion().""" + shared_key = "openai/gpt-4o-mini" + deployment_id = "lit3991-wildcard-chat-zero" + snapshot = _snapshot_model_cost_entries( + [shared_key, "gpt-4o-mini", deployment_id] + ) + builtin_input_cost = litellm.get_model_info(model=shared_key)[ + "input_cost_per_token" + ] + assert builtin_input_cost > 0 + + try: + litellm.completion( + model=shared_key, + messages=[{"role": "user", "content": "hello"}], + api_key="fake-key", + input_cost_per_token=0.0, + output_cost_per_token=0.0, + model_info={"id": deployment_id}, + metadata={"model_info": {"id": deployment_id}}, + mock_response="hello back", + ) + + assert ( + litellm.get_model_info(model=shared_key)["input_cost_per_token"] + == builtin_input_cost + ), "wildcard deployment's zero pricing leaked into the shared model_cost key" + assert litellm.model_cost[deployment_id]["input_cost_per_token"] == 0.0 + finally: + _restore_model_cost_entries(snapshot) + + +def test_embedding_direct_sdk_custom_pricing_still_registers_shared_key(): + """Direct SDK calls (no router deployment id in metadata) keep the legacy + behavior: custom pricing is registered under ``{provider}/{model}`` and the + request is costed with it. + """ + model_key = "openai/lit3991-direct-sdk-embed-model" + override_input_cost = 3e-05 + try: + response = litellm.embedding( + model=model_key, + input=["hello"], + api_key="fake-key", + input_cost_per_token=override_input_cost, + output_cost_per_token=override_input_cost * 2, + mock_response=[0.1, 0.2], + ) + + assert ( + litellm.model_cost[model_key]["input_cost_per_token"] + == override_input_cost + ) + cost = litellm.completion_cost( + completion_response=response, + model=model_key, + custom_llm_provider="openai", + call_type="embedding", + custom_pricing=True, + ) + assert cost == pytest.approx(10 * override_input_cost) + finally: + litellm.model_cost.pop(model_key, None) + _invalidate_model_cost_lowercase_map() diff --git a/tests/test_litellm/test_router_model_cost_isolation.py b/tests/test_litellm/test_router_model_cost_isolation.py index d4ac9659f00..6db7b04b3b7 100644 --- a/tests/test_litellm/test_router_model_cost_isolation.py +++ b/tests/test_litellm/test_router_model_cost_isolation.py @@ -681,3 +681,76 @@ def test_custom_pricing_isolated_from_sibling_via_proxy_model_info_path(): assert resolved["gemini-2.5-flash"] != resolved["custom-priced-flash"] finally: _restore_model_cost_entries(model_keys) + + +def test_wildcard_zero_cost_request_does_not_poison_named_deployment_pricing(): + """LIT-3991 end to end: a proxy has a named text-embedding-3-small + deployment relying on built-in pricing plus an ``openai/*`` wildcard with + explicit zero pricing. One embedding call routed through the wildcard must + not clobber the shared ``openai/text-embedding-3-small`` pricing; requests + to the named deployment afterwards must still cost non-zero. + """ + shared_key = "openai/text-embedding-3-small" + model_keys = { + shared_key: copy.deepcopy(litellm.model_cost.get(shared_key)), + "text-embedding-3-small": copy.deepcopy( + litellm.model_cost.get("text-embedding-3-small") + ), + "openai/*": copy.deepcopy(litellm.model_cost.get("openai/*")), + "lit3991-named": litellm.model_cost.get("lit3991-named"), + "lit3991-wildcard": litellm.model_cost.get("lit3991-wildcard"), + } + builtin_input_cost = litellm.get_model_info(model=shared_key)[ + "input_cost_per_token" + ] + assert builtin_input_cost > 0 + + try: + router = Router( + model_list=[ + { + "model_name": "text-embedding-3-small", + "litellm_params": { + "model": "openai/text-embedding-3-small", + "api_key": "fake-key-named", + }, + "model_info": {"id": "lit3991-named"}, + }, + { + "model_name": "openai/*", + "litellm_params": { + "model": "openai/*", + "api_key": "fake-key-wildcard", + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + }, + "model_info": {"id": "lit3991-wildcard"}, + }, + ], + ) + + router.embedding( + model="openai/text-embedding-3-small", + input=["hello"], + mock_response=[0.1, 0.2], + ) + + assert ( + litellm.get_model_info(model=shared_key)["input_cost_per_token"] + == builtin_input_cost + ), ( + "one call through the zero-cost wildcard poisoned the shared " + f"{shared_key} pricing for the named deployment" + ) + + named_response = router.embedding( + model="text-embedding-3-small", + input=["hello"], + mock_response=[0.1, 0.2], + ) + named_cost = litellm.completion_cost( + completion_response=named_response, call_type="embedding" + ) + assert named_cost == pytest.approx(10 * builtin_input_cost) + finally: + _restore_model_cost_entries(model_keys) diff --git a/ui/litellm-dashboard/components.json b/ui/litellm-dashboard/components.json index c4d45aa0989..48f5c3d0f37 100644 --- a/ui/litellm-dashboard/components.json +++ b/ui/litellm-dashboard/components.json @@ -1,6 +1,6 @@ { "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", + "style": "base-vega", "rsc": true, "tsx": true, "tailwind": { diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 8b92683db67..ae3660f59e9 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@ant-design/cssinjs": "1.24.0", "@anthropic-ai/sdk": "0.92.0", + "@base-ui/react": "^1.6.0", "@headlessui/tailwindcss": "0.2.2", "@heroicons/react": "1.0.6", "@tanstack/react-pacer": "0.2.0", @@ -19,7 +20,7 @@ "@types/papaparse": "5.5.2", "antd": "5.29.3", "cva": "1.0.0-beta.4", - "date-fns": "3.6.0", + "date-fns": "^4.4.0", "dayjs": "1.11.19", "jwt-decode": "4.0.0", "lucide-react": "0.513.0", @@ -27,7 +28,6 @@ "next": "16.2.6", "openai": "4.104.0", "papaparse": "5.5.3", - "radix-ui": "1.6.1", "react": "18.3.1", "react-copy-to-clipboard": "5.1.1", "react-dom": "18.3.1", @@ -560,6 +560,79 @@ "node": ">=6.9.0" } }, + "node_modules/@base-ui/react": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.6.0.tgz", + "integrity": "sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@base-ui/utils": "0.3.1", + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@date-fns/tz": "^1.2.0", + "@types/react": "^17 || ^18 || ^19", + "date-fns": "^4.0.0", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@date-fns/tz": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "date-fns": { + "optional": true + } + } + }, + "node_modules/@base-ui/react/node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@base-ui/utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.3.1.tgz", + "integrity": "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@floating-ui/utils": "^0.2.11", + "reselect": "^5.2.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@bcoe/v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", @@ -2100,14 +2173,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -2616,1512 +2689,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@radix-ui/number": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", - "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==", - "license": "MIT" - }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", - "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", - "license": "MIT" - }, - "node_modules/@radix-ui/react-accessible-icon": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.11.tgz", - "integrity": "sha512-HQDOFTKwSnmUij6l54wYJJtxTAnxI71+YJLOrjm2ladFB8HAV5Jt7hwaZPhWTGBkYoW4+ZAOfNZrLDh/qvxSYA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-visually-hidden": "1.2.7" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-accordion": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.15.tgz", - "integrity": "sha512-24Zz/0SYx8F2bSVThBnQrdJs2VbKelyuJordcFRRdA0fRAhrq/wSegGCqaQz34VQoiWqSMGYCYXEhynLSlyQlg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collapsible": "1.1.15", - "@radix-ui/react-collection": "1.1.11", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-alert-dialog": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.18.tgz", - "integrity": "sha512-6c2cXpNlAgHDhKguK24XcWHHayMpK+lk7/WwBXBco+ZJ4Dv7xP++GBM280KgTD/HCRu3jSdfe8WQiZssonYaIA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dialog": "1.1.18", - "@radix-ui/react-primitive": "2.1.7" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-arrow": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.11.tgz", - "integrity": "sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.7" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-aspect-ratio": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.11.tgz", - "integrity": "sha512-IUAhIVpBUvP5NNICjlaB1OFmtRLGqQqTF3ZOSGPoq3XeLXRFtHiWTRxSVEULgOd9GQR2c7tsYqDnhUennapZnw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.7" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-avatar": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.1.tgz", - "integrity": "sha512-+8PWoLLZv3AVb5m0pvoiOca/bQGzc9vPVb+982HB2x3Un0DpYEPM3zLMl4oqRpBsocJuNqLkiv/HXTnTrlwr4g==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-is-hydrated": "0.1.1", - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-checkbox": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.6.tgz", - "integrity": "sha512-eUEUoGMDpfkgHWSE97ZZaUJtzR1M7EKnNIpD1Q16+8JR9NWghcaqMulx9PuCQ720w0UclfYn6FEbCdd5Hx087g==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-use-size": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.15.tgz", - "integrity": "sha512-8A1zibu5skAQ+UVbaeNH5hVMibiFCRJzgMuM14LTWGttnTZKQL9jwYnhAbHRuxrtCqPXa4JvvnVUq1pTNgyZYw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collection": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.11.tgz", - "integrity": "sha512-djW9+zeg137KQdlPtmE8xnaD+K2rcXXMWFrSg0hsmYZ6HRbdTA7tDHFgpaW9+huWVEu0RCabL+985T4TA0BE7g==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", - "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", - "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context-menu": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.2.tgz", - "integrity": "sha512-qzsA/ZPhF6yMxBOTIk1nlCkoy2mswSbwYL+ErBa2iP0s4WWrlxmczArYqMcpVfEjmM7KJj/ADPXky0yZfbSxtQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-menu": "2.1.19", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.18.tgz", - "integrity": "sha512-apa28mldjMgORmE6g/w3sCcA0Y9UAVeeDVoozN4i7kOw12mLl9RBchfzK3Nn6qxOWjrZhK1Lfy7f07kyzxtnBw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.14", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.11", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-portal": "1.1.13", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-controllable-state": "1.2.3", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.7.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-direction": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", - "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.14.tgz", - "integrity": "sha512-4lUhWTWAjbDIqFrAPWJ3WqBOpO5YchVZ88X3nh6H9Lu5AFi5nCUeTPj3D8FSDmabmFeRe9ME0BDA4MwKTha5GQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-effect-event": "0.0.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.19.tgz", - "integrity": "sha512-HZccBkbK0LOi8nYKIp5jll/zIRW0cCOmG6WWyqsSpmXCU+ZlcBbTqIwlBvPCu886C5RVu6c/kHV7xSP8IgYNHw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-menu": "2.1.19", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", - "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.11.tgz", - "integrity": "sha512-Mn88Vg2whaRocGJNOH+DKFqYm6ySFPQaiwHNxZPyjn99B52KAEJWWY9NP83+nWdk2HM3rdov+STu9AG471Rt9w==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-form": { - "version": "0.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.11.tgz", - "integrity": "sha512-0mTMJHv1gQAuEQoq5VDpTD3MRgmfUFdXAVFhpqR7wBeUr+tyRsof0wv/4XdPHLwQrefhoH2FiGHCggrCJhalIw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-label": "2.1.11", - "@radix-ui/react-primitive": "2.1.7" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-hover-card": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.18.tgz", - "integrity": "sha512-rt+Fx4HoCeEwFL2IdoV2QaPltqDLlzxN77i9nwB3Y70scFlfAHh1QCdE2TXKuFJtA1TNygb0oivnFBZifgtZOw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.14", - "@radix-ui/react-popper": "1.3.2", - "@radix-ui/react-portal": "1.1.13", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", - "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-label": { - "version": "2.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.11.tgz", - "integrity": "sha512-3PKvDDxOn62k0oV1n4QtNtD2vpu+zYjXR7ojLBPaO6SPvhy53yg0vAmgNeBQeJW5rV3dffoRG+HYfLBZuzw0CQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.7" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menu": { - "version": "2.1.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.19.tgz", - "integrity": "sha512-Mht9BVd1AIsNFVQr4KG3bIK7XQn5IXF0TL/2ObsrzOdc1loaly/+kBDL5roSCYn8j8XZkvpOD0WYLz2FQtH1Eg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.11", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.14", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.11", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.2", - "@radix-ui/react-portal": "1.1.13", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.14", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-callback-ref": "1.1.2", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.7.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menubar": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.19.tgz", - "integrity": "sha512-Glt6mebxcgQvLeVkH3HiqV5bgQubE+31ELxLs7q0GlYI5k0XYkOkeuPrhXoylxK8eufvIt9CJjzY1TfFMXK3qw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.11", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-menu": "2.1.19", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.14", - "@radix-ui/react-use-controllable-state": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-navigation-menu": { - "version": "1.2.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.17.tgz", - "integrity": "sha512-fYeYQvbeNn5AQk2RBbpO7koLm2YbS00UYxC/IL2sgLlninEH5UNIv+X3E0KJ1Vy4WIo+dhN9w8GNqSHhbHWCIg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.11", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.14", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.7" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-one-time-password-field": { - "version": "0.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.11.tgz", - "integrity": "sha512-Rsgab65u73E5kPVh8OS6PgPwJgPyf08GFfJDGAbMdF4DL7CgDhFOaDnXuk/DiMEVF6kgQwl0oJmFklvipmiOLg==", - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.2", - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.11", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.14", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-is-hydrated": "0.1.1", - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-password-toggle-field": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.6.tgz", - "integrity": "sha512-pQ3xGp/uemomASPH97Eb3shfXX8QlG11bBJyEvRBV+vwtO4HvQlS06Yj9f31Ao7XepvF98SFrRgVDQ7jv+2xjQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-is-hydrated": "0.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.18.tgz", - "integrity": "sha512-qdXDes+eHlnMUGlBAAAe5EG7oOQvqsXuq4mq585diMudg80iB+jHbsSeG3+Q4eWNsogNyhqU2p/3i+Y0iEepqg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.14", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.11", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.2", - "@radix-ui/react-portal": "1.1.13", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-controllable-state": "1.2.3", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.7.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.2.tgz", - "integrity": "sha512-3QXNeMkdshed1MR3LNoiCirBywRFPkD8ETJa/HlPuLwSajaQixf2ro+isoDNJlGABg9ug41XuZpINZJIle4XWg==", - "license": "MIT", - "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.11", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-rect": "1.1.2", - "@radix-ui/react-use-size": "1.1.2", - "@radix-ui/rect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper/node_modules/@floating-ui/react-dom": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", - "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.7.6" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.13.tgz", - "integrity": "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", - "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", - "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.3.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-progress": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.11.tgz", - "integrity": "sha512-KqiGJcFaZDc+BvveAgU3ZhACg2MvSUDrCBx4lRR/ZVRNal0bvt8lBpvnSkep9heeOuF8Qfw3fszLDX4OpQ2NVw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-primitive": "2.1.7" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-radio-group": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.2.tgz", - "integrity": "sha512-W8Uo9riHnlzLLWy+r2mVHUyuEWqD/+be4PZzbEvaGoFSBDHkm+GYWjtcE6u3AmPKNyfanWpnVfpZ2GqPCdzzsw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.14", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-use-size": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.14.tgz", - "integrity": "sha512-8Qcnx9447tx/aCBgw6Jenfqg4Skq+vqab9mCBmuGNipIS5YXvL275wbKEu7+ICYHIlAPgCduUMJH1XOYewKF6Q==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.11", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.13.tgz", - "integrity": "sha512-7tncSubo2G0UY1e8rk+72qe3XRzrGnOLtZQ1PL1KoBfRUNX0NrJT5akb+0kfwSCc3gVR4wdHqyhAQBDpDNOwDw==", - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.2", - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.2.tgz", - "integrity": "sha512-brXD6C/V0fVK0DDbscLVw6LsXrjQ+ay8jdOBaN+tLb4vsHsAMm6Gt6eT77wHX1Eq8GPtD5rJ+RxFtfDozsb4+Q==", - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.2", - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.11", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.14", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.11", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.2", - "@radix-ui/react-portal": "1.1.13", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.7", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.7.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-separator": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.11.tgz", - "integrity": "sha512-jRhe86+8PF7VZ1u14eOWVOuh2BuAhALg/FT1VcMC4OHedMTRUazDnDlKTt+yxo5cRNKHMfmvZ4sSQtWDeMV4CQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.7" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slider": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.2.tgz", - "integrity": "sha512-qt5C1ppJz66aUDrH1VccjPrq7aFchK0wBrn6xsxlCHNUyE57dRRQ7lp1QFpF7OscMexZF8MCGBTVBlENHPkNiA==", - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.2", - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.11", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-use-size": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slot": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", - "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-switch": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.2.tgz", - "integrity": "sha512-tgRBI3DdNwAJYE4BBZyZcz/HRRCvAsPkRvG1wvKc+41tBGMxPn/a87T/wikXAvyDypNQ9kaZwHbeZe+veHCGpA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-use-size": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.16.tgz", - "integrity": "sha512-v3Ab2l7z6U7tRB4xA0IyKdq0OsqaO1o9ZjsIEoKKnSZ/l96mZz8aCTX0NCXw+YVHJXr8Km4d+Mn6/Q8YjXa+gw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.14", - "@radix-ui/react-use-controllable-state": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toast": { - "version": "1.2.18", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.18.tgz", - "integrity": "sha512-YNEnTHV47hPep+U0QvVM02OJNka9uygREc+k4Nh5VSZBg4MmE+myI442x3hCGfRpX7N2WSSYSJKws4gE+Z8lgg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.11", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.14", - "@radix-ui/react-portal": "1.1.13", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.7" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toggle": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.13.tgz", - "integrity": "sha512-bI2ILJrzwgmAsH05TsJ9pVrzqQwAip7OM2/krqAdYn0R16bl86UPWbe5VPHsALat0EnqpV01cGtkleaUKPNdNg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toggle-group": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.14.tgz", - "integrity": "sha512-TK1vusNKb8IRhF23FTbRgUNZ9zfs5rGIyI7LfR3h26p9LrQ060i0uW9QWeD8baZMddaaP0DBGlIa6pbZG+mitg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.14", - "@radix-ui/react-toggle": "1.1.13", - "@radix-ui/react-use-controllable-state": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toolbar": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.14.tgz", - "integrity": "sha512-L/EkWVqlnj3lL2toHh4C7PwH2jxfa7OCq6lGfXSCii99ve2S4Ux5rc9HnOa7LN9exHa/Nl9kmCAmP9BuDPy5UA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.14", - "@radix-ui/react-separator": "1.1.11", - "@radix-ui/react-toggle-group": "1.1.14" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tooltip": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.11.tgz", - "integrity": "sha512-8XZ6Py3y3W2nEzAUGCN5cfVKaUi+CVApcz1d6lrNVVf2hvYEixMRkq8k9ggPKnQUpRRuOV5avt8uvxViH2jLwA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.14", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.2", - "@radix-ui/react-portal": "1.1.13", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-visually-hidden": "1.2.7" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", - "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", - "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", - "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.3.tgz", - "integrity": "sha512-3wEkMiPHXha/2VadZ68rYBcmYnPINVGl4Y3gtcM7fKRjANk0OscK+cdqBgUWdozb7YJxsh0vefM7vgAMHXOjqg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-is-hydrated": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.1.tgz", - "integrity": "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", - "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-previous": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz", - "integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", - "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", - "license": "MIT", - "dependencies": { - "@radix-ui/rect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-size": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", - "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.7.tgz", - "integrity": "sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.7" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/rect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", - "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", - "license": "MIT" - }, "node_modules/@rc-component/async-validator": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.0.tgz", @@ -4718,9 +3285,9 @@ "license": "MIT" }, "node_modules/@swc/helpers": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", - "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.8.0" @@ -5248,9 +3815,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -5474,7 +4041,7 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -7325,9 +5892,9 @@ } }, "node_modules/date-fns": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", - "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", "license": "MIT", "funding": { "type": "github", @@ -7464,12 +6031,6 @@ "node": ">=8" } }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "license": "MIT" - }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -8688,15 +7249,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -10423,9 +8975,9 @@ } }, "node_modules/lru-cache": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", - "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -11593,15 +10145,6 @@ } } }, - "node_modules/next/node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -12144,9 +10687,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -12317,9 +10860,9 @@ "license": "MIT" }, "node_modules/property-information": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", "license": "MIT", "funding": { "type": "github", @@ -12357,83 +10900,6 @@ ], "license": "MIT" }, - "node_modules/radix-ui": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.1.tgz", - "integrity": "sha512-QXDXJtB6sK83mLASONYUZCauatcWb+knFviFpN1EhtdbbmlsRmzCLrbZSKztnNiem2KOHIBbiDbauVB7SORXMw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-accessible-icon": "1.1.11", - "@radix-ui/react-accordion": "1.2.15", - "@radix-ui/react-alert-dialog": "1.1.18", - "@radix-ui/react-arrow": "1.1.11", - "@radix-ui/react-aspect-ratio": "1.1.11", - "@radix-ui/react-avatar": "1.2.1", - "@radix-ui/react-checkbox": "1.3.6", - "@radix-ui/react-collapsible": "1.1.15", - "@radix-ui/react-collection": "1.1.11", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-context-menu": "2.3.2", - "@radix-ui/react-dialog": "1.1.18", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.14", - "@radix-ui/react-dropdown-menu": "2.1.19", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.11", - "@radix-ui/react-form": "0.1.11", - "@radix-ui/react-hover-card": "1.1.18", - "@radix-ui/react-label": "2.1.11", - "@radix-ui/react-menu": "2.1.19", - "@radix-ui/react-menubar": "1.1.19", - "@radix-ui/react-navigation-menu": "1.2.17", - "@radix-ui/react-one-time-password-field": "0.1.11", - "@radix-ui/react-password-toggle-field": "0.1.6", - "@radix-ui/react-popover": "1.1.18", - "@radix-ui/react-popper": "1.3.2", - "@radix-ui/react-portal": "1.1.13", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-progress": "1.1.11", - "@radix-ui/react-radio-group": "1.4.2", - "@radix-ui/react-roving-focus": "1.1.14", - "@radix-ui/react-scroll-area": "1.2.13", - "@radix-ui/react-select": "2.3.2", - "@radix-ui/react-separator": "1.1.11", - "@radix-ui/react-slider": "1.4.2", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-switch": "1.3.2", - "@radix-ui/react-tabs": "1.1.16", - "@radix-ui/react-toast": "1.2.18", - "@radix-ui/react-toggle": "1.1.13", - "@radix-ui/react-toggle-group": "1.1.14", - "@radix-ui/react-toolbar": "1.1.14", - "@radix-ui/react-tooltip": "1.2.11", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-escape-keydown": "1.1.3", - "@radix-ui/react-use-is-hydrated": "0.1.1", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-size": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.7" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/rc-cascader": { "version": "3.34.0", "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz", @@ -13165,53 +11631,6 @@ "react": ">=18" } }, - "node_modules/react-remove-scroll": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", - "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", - "license": "MIT", - "dependencies": { - "react-remove-scroll-bar": "^2.3.7", - "react-style-singleton": "^2.2.3", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.3", - "use-sidecar": "^1.1.3" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-remove-scroll-bar": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", - "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", - "license": "MIT", - "dependencies": { - "react-style-singleton": "^2.2.2", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/react-smooth": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", @@ -13244,28 +11663,6 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/react-style-singleton": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", - "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", - "license": "MIT", - "dependencies": { - "get-nonce": "^1.0.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/react-syntax-highlighter": { "version": "15.6.6", "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.6.tgz", @@ -13588,6 +11985,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -13805,9 +12208,9 @@ } }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "devOptional": true, "license": "ISC", "bin": { @@ -14975,49 +13378,6 @@ "dev": true, "license": "MIT" }, - "node_modules/use-callback-ref": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", - "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sidecar": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", - "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", - "license": "MIT", - "dependencies": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index cf2c234938d..1b0ce315e4d 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -26,6 +26,7 @@ "dependencies": { "@ant-design/cssinjs": "1.24.0", "@anthropic-ai/sdk": "0.92.0", + "@base-ui/react": "^1.6.0", "@headlessui/tailwindcss": "0.2.2", "@heroicons/react": "1.0.6", "@tanstack/react-pacer": "0.2.0", @@ -35,7 +36,7 @@ "@types/papaparse": "5.5.2", "antd": "5.29.3", "cva": "1.0.0-beta.4", - "date-fns": "3.6.0", + "date-fns": "^4.4.0", "dayjs": "1.11.19", "jwt-decode": "4.0.0", "lucide-react": "0.513.0", @@ -43,7 +44,6 @@ "next": "16.2.6", "openai": "4.104.0", "papaparse": "5.5.3", - "radix-ui": "1.6.1", "react": "18.3.1", "react-copy-to-clipboard": "5.1.1", "react-dom": "18.3.1", @@ -96,7 +96,8 @@ "braces": "3.0.3", "axios": "1.13.6", "postcss": "8.5.13", - "esbuild": "0.28.1" + "esbuild": "0.28.1", + "date-fns": "^4.4.0" }, "engines": { "node": ">=20.9.0", diff --git a/ui/litellm-dashboard/src/app/chat/page.tsx b/ui/litellm-dashboard/src/app/chat/page.tsx index 57bb740fbde..ae86ca6e926 100644 --- a/ui/litellm-dashboard/src/app/chat/page.tsx +++ b/ui/litellm-dashboard/src/app/chat/page.tsx @@ -403,32 +403,34 @@ export default function ChatConversationPage() { if (!open) setModelSearchText(""); }} > - - - + + {selectedModel ? ( + <> + {(() => { + const provider = getProviderFromModelName(selectedModel); + const { logo } = provider ? getProviderLogoAndName(provider) : { logo: "" }; + return logo ? ( + { + (e.currentTarget as HTMLImageElement).style.display = "none"; + }} + /> + ) : null; + })()} + {selectedModel} + + ) : ( + Select model + )} + + + } + /> {modelSelectorContent} @@ -456,14 +458,16 @@ export default function ChatConversationPage() {
{modelSelectorTrigger} - - - + + + {selectedMCPServers.length > 0 && ( + {selectedMCPServers.length} + )} + + } + /> { expect(screen.queryByText("Semantic filtering is disabled")).not.toBeInTheDocument(); }); - it("should display test results when testResult is provided", () => { + it("should display selected and filtered-out counts when testResult is provided", () => { const testResult: TestResult = { totalTools: 10, selectedTools: 3, @@ -98,8 +98,8 @@ describe("MCPSemanticFilterTestPanel", () => { }; render(); - expect(screen.getByText("3 tools selected")).toBeInTheDocument(); - expect(screen.getByText("Filtered from 10 available tools")).toBeInTheDocument(); + expect(screen.getByText("3 of 10 tools selected")).toBeInTheDocument(); + expect(screen.getByText("7 tools filtered out")).toBeInTheDocument(); expect(screen.getByText("wiki-fetch")).toBeInTheDocument(); expect(screen.getByText("github-search")).toBeInTheDocument(); expect(screen.getByText("slack-post")).toBeInTheDocument(); @@ -117,6 +117,18 @@ describe("MCPSemanticFilterTestPanel", () => { expect(screen.getByText("+5 more selected tools not shown")).toBeInTheDocument(); }); + it("should surface a zero filtered-out count when the filter selected every tool", () => { + const testResult: TestResult = { + totalTools: 207, + selectedTools: 207, + tools: ["tool-a", "tool-b"], + }; + render(); + + expect(screen.getByText("207 of 207 tools selected")).toBeInTheDocument(); + expect(screen.getByText("0 tools filtered out")).toBeInTheDocument(); + }); + it("should not render the results section when testResult is null", () => { render(); expect(screen.queryByText("Results")).not.toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx index 550eabf1f58..74850020aa8 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx @@ -86,9 +86,9 @@ export default function MCPSemanticFilterTestPanel({
Results 0 ? "success" : "warning"} + message={`${testResult.selectedTools} of ${testResult.totalTools} tools selected`} + description={`${testResult.totalTools - testResult.selectedTools} tools filtered out`} showIcon style={{ marginBottom: 16 }} /> diff --git a/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx b/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx index b9f4fb7c3bc..dc58973d4ca 100644 --- a/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx +++ b/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx @@ -150,21 +150,23 @@ function UserBubble({ message, onEdit, isStreaming }: UserBubbleProps) { >
{hovered && !isStreaming && onEdit && ( - + - - - + { + setEditValue(message.content); + setEditing(true); + }} + className="text-muted-foreground hover:text-foreground shrink-0" + > + + + } + />

Edit message

@@ -265,18 +267,20 @@ function CopyButton({ text }: { text: string }) { return (
- + - - - + + {copied ? : } + + } + />

{copied ? "Copied!" : "Copy"}

diff --git a/ui/litellm-dashboard/src/components/chat/ConversationList.tsx b/ui/litellm-dashboard/src/components/chat/ConversationList.tsx index 91c18c09935..da57b70d096 100644 --- a/ui/litellm-dashboard/src/components/chat/ConversationList.tsx +++ b/ui/litellm-dashboard/src/components/chat/ConversationList.tsx @@ -143,13 +143,15 @@ const ConversationRow: React.FC = ({ conv, isActive, onSel className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" onClick={(e) => e.stopPropagation()} > - + - - - + + + + } + />

Rename

@@ -157,15 +159,23 @@ const ConversationRow: React.FC = ({ conv, isActive, onSel
- + - - - - - + + + + } + /> + } + />

Delete

diff --git a/ui/litellm-dashboard/src/components/chat/MCPCredentialsTab.tsx b/ui/litellm-dashboard/src/components/chat/MCPCredentialsTab.tsx index f73d18081d8..4f80a941511 100644 --- a/ui/litellm-dashboard/src/components/chat/MCPCredentialsTab.tsx +++ b/ui/litellm-dashboard/src/components/chat/MCPCredentialsTab.tsx @@ -184,21 +184,23 @@ const MCPCredentialsTab: React.FC = ({ accessToken }) => { - - - + + {isRevoking ? ( + + ) : ( + + )} + + } + /> Revoke connection? diff --git a/ui/litellm-dashboard/src/components/general_settings.tsx b/ui/litellm-dashboard/src/components/general_settings.tsx index a7a8af2691e..5b8dec39505 100644 --- a/ui/litellm-dashboard/src/components/general_settings.tsx +++ b/ui/litellm-dashboard/src/components/general_settings.tsx @@ -161,6 +161,14 @@ const GeneralSettings: React.FC = ({ accessToken, user checked={value.field_value === true || value.field_value === "true"} onChange={(checked) => handleInputChange(value.field_name, checked)} /> + ) : value.field_type == "Float" ? ( + handleInputChange(value.field_name, newValue)} + /> ) : null} diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index ca5766a3682..bf0f0cc3fae 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -1163,6 +1163,21 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp form={form} showDetailedDescriptions={true} /> + + Throttle on budget exceeded{" "} + + + + + } + name="throttle_on_budget_exceeded" + valuePropName="checked" + > + + diff --git a/ui/litellm-dashboard/src/components/shared/usage_date_picker.test.tsx b/ui/litellm-dashboard/src/components/shared/usage_date_picker.test.tsx new file mode 100644 index 00000000000..46fd8181560 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/usage_date_picker.test.tsx @@ -0,0 +1,50 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import UsageDatePicker from "./usage_date_picker"; + +beforeAll(() => { + vi.stubGlobal("requestIdleCallback", (cb: IdleRequestCallback) => { + cb({ didTimeout: false, timeRemaining: () => 50 } as IdleDeadline); + return 0; + }); +}); + +describe("UsageDatePicker (tremor DateRangePicker on date-fns 4)", () => { + const value = { from: new Date(2026, 5, 1), to: new Date(2026, 5, 15) }; + + it("renders the formatted range label", () => { + render( {}} />); + + const triggerText = screen.getAllByRole("button")[0].textContent ?? ""; + expect(triggerText).toMatch(/Jun/); + expect(triggerText).toMatch(/2026/); + expect(triggerText).toMatch(/15/); + }); + + it("opens the calendar and renders a full month grid", async () => { + const user = userEvent.setup(); + render( {}} />); + + await user.click(screen.getAllByRole("button")[0]); + + const grid = await screen.findByRole("grid"); + const dayCells = within(grid).getAllByRole("gridcell"); + expect(dayCells.length).toBeGreaterThanOrEqual(28); + expect(within(grid).getByText("15")).toBeInTheDocument(); + }); + + it("fires onValueChange when a day is selected", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render(); + + await user.click(screen.getAllByRole("button")[0]); + const grid = await screen.findByRole("grid"); + await user.click(within(grid).getByText("10")); + + expect(onValueChange).toHaveBeenCalled(); + const newValue = onValueChange.mock.calls[0][0]; + expect(newValue.from).toBeInstanceOf(Date); + }); +}); diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx index 40c82c51031..ba68124beee 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx @@ -335,6 +335,36 @@ describe("KeyEditView", () => { }); }); + it("should initialize and submit throttle_on_budget_exceeded from key metadata", async () => { + const onSubmitMock = vi.fn().mockResolvedValue(undefined); + const keyDataWithThrottle = { + ...MOCK_KEY_DATA, + metadata: { ...MOCK_KEY_DATA.metadata, throttle_on_budget_exceeded: true }, + }; + + renderWithProviders( + {}} + onSubmit={onSubmitMock} + accessToken={"test-token"} + userID={"test-user"} + userRole={"admin"} + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByText("Throttle on budget exceeded")).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalledWith(expect.objectContaining({ throttle_on_budget_exceeded: true })); + }); + }); + it("should disable models field when management routes are selected", async () => { const keyDataWithManagementRoutes = { ...MOCK_KEY_DATA, diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx index 7dda555daa4..4821ea86b87 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -181,6 +181,7 @@ export function KeyEditView({ metadata: formatMetadataForDisplay(stripTagsFromMetadata(keyData.metadata)), guardrails: keyData.metadata?.guardrails, disable_global_guardrails: keyData.metadata?.disable_global_guardrails || false, + throttle_on_budget_exceeded: keyData.metadata?.throttle_on_budget_exceeded || false, prompts: keyData.metadata?.prompts, tags: keyData.metadata?.tags, vector_stores: keyData.object_permission?.vector_stores || [], @@ -222,6 +223,7 @@ export function KeyEditView({ accessGroups: keyData.object_permission?.mcp_access_groups || [], }, mcp_tool_permissions: keyData.object_permission?.mcp_tool_permissions || {}, + throttle_on_budget_exceeded: keyData.metadata?.throttle_on_budget_exceeded || false, logging_settings: extractLoggingSettings(keyData.metadata), disabled_callbacks: Array.isArray(keyData.metadata?.litellm_disabled_callbacks) ? mapInternalToDisplayNames(keyData.metadata.litellm_disabled_callbacks) @@ -512,6 +514,21 @@ export function KeyEditView({ + + Throttle on budget exceeded{" "} + + + + + } + name="throttle_on_budget_exceeded" + valuePropName="checked" + > + + + diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index 197f87b6dfe..1c0d916f0f3 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -542,6 +542,9 @@ export default function KeyInfoView({
TPM: {currentKeyData.tpm_limit !== null ? currentKeyData.tpm_limit : "Unlimited"} RPM: {currentKeyData.rpm_limit !== null ? currentKeyData.rpm_limit : "Unlimited"} + {Boolean(currentKeyData.metadata?.throttle_on_budget_exceeded) && ( + Throttle on budget exceeded: Yes + )}
diff --git a/ui/litellm-dashboard/src/components/ui/alert-dialog.test.tsx b/ui/litellm-dashboard/src/components/ui/alert-dialog.test.tsx new file mode 100644 index 00000000000..4b0a197b129 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/alert-dialog.test.tsx @@ -0,0 +1,61 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "./alert-dialog"; +import { Button } from "./button"; + +function ConfirmDialog({ onConfirm }: { onConfirm: () => void }) { + return ( + + Open} /> + + + Delete this? + Cannot be undone + + + Cancel + Confirm + + + + ); +} + +describe("AlertDialog", () => { + it("fires the action handler and closes the dialog on confirm", async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: "Open" })); + expect(screen.getByText("Delete this?")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Confirm" })); + + expect(onConfirm).toHaveBeenCalledOnce(); + expect(screen.queryByText("Delete this?")).not.toBeInTheDocument(); + }); + + it("closes without firing the action on cancel", async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: "Open" })); + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onConfirm).not.toHaveBeenCalled(); + expect(screen.queryByText("Delete this?")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/ui/alert-dialog.tsx b/ui/litellm-dashboard/src/components/ui/alert-dialog.tsx index 1561cc76cb8..164dc310ae7 100644 --- a/ui/litellm-dashboard/src/components/ui/alert-dialog.tsx +++ b/ui/litellm-dashboard/src/components/ui/alert-dialog.tsx @@ -1,29 +1,29 @@ "use client"; import * as React from "react"; -import { AlertDialog as AlertDialogPrimitive } from "radix-ui"; +import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"; import { cn } from "@/lib/cva.config"; import { Button } from "@/components/ui/button"; -function AlertDialog({ ...props }: React.ComponentProps) { +function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) { return ; } -function AlertDialogTrigger({ ...props }: React.ComponentProps) { +function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) { return ; } -function AlertDialogPortal({ ...props }: React.ComponentProps) { +function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) { return ; } -function AlertDialogOverlay({ className, ...props }: React.ComponentProps) { +function AlertDialogOverlay({ className, ...props }: AlertDialogPrimitive.Backdrop.Props) { return ( - & { +}: AlertDialogPrimitive.Popup.Props & { size?: "default" | "sm"; }) { return ( - ) ); } +function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + function AlertDialogTitle({ className, ...props }: React.ComponentProps) { return ( - ); -} - -function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) { - return ( -
& - Pick, "variant" | "size">) { +}: AlertDialogPrimitive.Close.Props & Pick, "variant" | "size">) { return ( - + } + {...props} + /> ); } @@ -138,12 +143,14 @@ function AlertDialogCancel({ variant = "outline", size = "default", ...props -}: React.ComponentProps & - Pick, "variant" | "size">) { +}: AlertDialogPrimitive.Close.Props & Pick, "variant" | "size">) { return ( - + } + {...props} + /> ); } diff --git a/ui/litellm-dashboard/src/components/ui/button.test.tsx b/ui/litellm-dashboard/src/components/ui/button.test.tsx index 16863d5c309..1c8483634b9 100644 --- a/ui/litellm-dashboard/src/components/ui/button.test.tsx +++ b/ui/litellm-dashboard/src/components/ui/button.test.tsx @@ -9,7 +9,6 @@ describe("Button", () => { const button = screen.getByRole("button", { name: "Save" }); expect(button).toHaveClass("bg-primary"); expect(button).toHaveAttribute("data-slot", "button"); - expect(button).toHaveAttribute("data-variant", "default"); }); it("applies variant and size props", () => { @@ -19,9 +18,8 @@ describe("Button", () => { , ); const button = screen.getByRole("button", { name: "Delete" }); - expect(button).toHaveClass("bg-destructive"); + expect(button).toHaveClass("bg-destructive/10"); expect(button).toHaveClass("h-8"); - expect(button).toHaveAttribute("data-variant", "destructive"); }); it("resolves conflicting classes through twMerge so className wins", () => { @@ -31,15 +29,12 @@ describe("Button", () => { expect(button).not.toHaveClass("bg-primary"); }); - it("renders the child element when asChild is set", () => { - render( - , - ); - const link = screen.getByRole("link", { name: "Docs" }); - expect(link).toHaveClass("bg-primary"); - expect(screen.queryByRole("button")).not.toBeInTheDocument(); + it("renders the element passed via the render prop with button semantics", () => { + render( - - )} + {showCloseButton && }>Close}
); } -function DialogTitle({ className, ...props }: React.ComponentProps) { +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { return ( - + ); } -function DialogDescription({ className, ...props }: React.ComponentProps) { +function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) { return ( ); diff --git a/ui/litellm-dashboard/src/components/ui/label.tsx b/ui/litellm-dashboard/src/components/ui/label.tsx index 77c2cc6254d..ded2dfc1a7b 100644 --- a/ui/litellm-dashboard/src/components/ui/label.tsx +++ b/ui/litellm-dashboard/src/components/ui/label.tsx @@ -1,13 +1,12 @@ "use client"; import * as React from "react"; -import { Label as LabelPrimitive } from "radix-ui"; import { cn } from "@/lib/cva.config"; -function Label({ className, ...props }: React.ComponentProps) { +function Label({ className, ...props }: React.ComponentProps<"label">) { return ( - ) { +function Popover({ ...props }: PopoverPrimitive.Root.Props) { return ; } -function PopoverTrigger({ ...props }: React.ComponentProps) { +function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) { return ; } function PopoverContent({ className, align = "center", + alignOffset = 0, + side = "bottom", sideOffset = 4, ...props -}: React.ComponentProps) { +}: PopoverPrimitive.Popup.Props & + Pick) { return ( - + className="isolate z-50" + > + + ); } -function PopoverAnchor({ ...props }: React.ComponentProps) { - return ; -} - function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { return
; } -function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) { - return
; +function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) { + return ; } -function PopoverDescription({ className, ...props }: React.ComponentProps<"p">) { - return

; +function PopoverDescription({ className, ...props }: PopoverPrimitive.Description.Props) { + return ( + + ); } -export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverHeader, PopoverTitle, PopoverDescription }; +export { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger }; diff --git a/ui/litellm-dashboard/src/components/ui/scroll-area.tsx b/ui/litellm-dashboard/src/components/ui/scroll-area.tsx index b23d2daebc3..74106ce80ae 100644 --- a/ui/litellm-dashboard/src/components/ui/scroll-area.tsx +++ b/ui/litellm-dashboard/src/components/ui/scroll-area.tsx @@ -1,11 +1,11 @@ "use client"; import * as React from "react"; -import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"; +import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"; import { cn } from "@/lib/cva.config"; -function ScrollArea({ className, children, ...props }: React.ComponentProps) { +function ScrollArea({ className, children, ...props }: ScrollAreaPrimitive.Root.Props) { return ( ) { +function ScrollBar({ className, orientation = "vertical", ...props }: ScrollAreaPrimitive.Scrollbar.Props) { return ( - - - + + ); } diff --git a/ui/litellm-dashboard/src/components/ui/select.tsx b/ui/litellm-dashboard/src/components/ui/select.tsx index be6bf72c744..7d009b53084 100644 --- a/ui/litellm-dashboard/src/components/ui/select.tsx +++ b/ui/litellm-dashboard/src/components/ui/select.tsx @@ -1,21 +1,21 @@ "use client"; import * as React from "react"; -import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; -import { Select as SelectPrimitive } from "radix-ui"; +import { Select as SelectPrimitive } from "@base-ui/react/select"; import { cn } from "@/lib/cva.config"; +import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"; -function Select({ ...props }: React.ComponentProps) { - return ; +const Select = SelectPrimitive.Root; + +function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) { + return ; } -function SelectGroup({ ...props }: React.ComponentProps) { - return ; -} - -function SelectValue({ ...props }: React.ComponentProps) { - return ; +function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) { + return ( + + ); } function SelectTrigger({ @@ -23,7 +23,7 @@ function SelectTrigger({ size = "default", children, ...props -}: React.ComponentProps & { +}: SelectPrimitive.Trigger.Props & { size?: "sm" | "default"; }) { return ( @@ -31,15 +31,13 @@ function SelectTrigger({ data-slot="select-trigger" data-size={size} className={cn( - "flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", + "flex w-fit items-center justify-between gap-1.5 rounded-md border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className, )} {...props} > {children} - - - + } /> ); } @@ -47,43 +45,45 @@ function SelectTrigger({ function SelectContent({ className, children, - position = "item-aligned", + side = "bottom", + sideOffset = 4, align = "center", + alignOffset = 0, + alignItemWithTrigger = true, ...props -}: React.ComponentProps) { +}: SelectPrimitive.Popup.Props & + Pick) { return ( - - - - {children} - - - + + {children} + + + ); } -function SelectLabel({ className, ...props }: React.ComponentProps) { +function SelectLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) { return ( - ) { +function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) { return ( - - - - - - {children} + + {children} + + } + > + + ); } -function SelectSeparator({ className, ...props }: React.ComponentProps) { +function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) { return ( ) { +function SelectScrollUpButton({ className, ...props }: React.ComponentProps) { return ( - - - + + ); } -function SelectScrollDownButton({ - className, - ...props -}: React.ComponentProps) { +function SelectScrollDownButton({ className, ...props }: React.ComponentProps) { return ( - - - + + ); } diff --git a/ui/litellm-dashboard/src/components/ui/separator.tsx b/ui/litellm-dashboard/src/components/ui/separator.tsx index 6a1a78d1022..443f8e905f9 100644 --- a/ui/litellm-dashboard/src/components/ui/separator.tsx +++ b/ui/litellm-dashboard/src/components/ui/separator.tsx @@ -1,23 +1,16 @@ "use client"; -import * as React from "react"; -import { Separator as SeparatorPrimitive } from "radix-ui"; +import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"; import { cn } from "@/lib/cva.config"; -function Separator({ - className, - orientation = "horizontal", - decorative = true, - ...props -}: React.ComponentProps) { +function Separator({ className, orientation = "horizontal", ...props }: SeparatorPrimitive.Props) { return ( - & { +}: SwitchPrimitive.Root.Props & { size?: "sm" | "default"; }) { return ( @@ -17,16 +16,14 @@ function Switch({ data-slot="switch" data-size={size} className={cn( - "peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80", + "peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50", className, )} {...props} > ); diff --git a/ui/litellm-dashboard/src/components/ui/tabs.tsx b/ui/litellm-dashboard/src/components/ui/tabs.tsx index d4d4eed75b0..773b9e31081 100644 --- a/ui/litellm-dashboard/src/components/ui/tabs.tsx +++ b/ui/litellm-dashboard/src/components/ui/tabs.tsx @@ -1,25 +1,23 @@ "use client"; -import * as React from "react"; +import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"; import { type VariantProps } from "cva"; -import { Tabs as TabsPrimitive } from "radix-ui"; import { cn, cva } from "@/lib/cva.config"; -function Tabs({ className, orientation = "horizontal", ...props }: React.ComponentProps) { +function Tabs({ className, orientation = "horizontal", ...props }: TabsPrimitive.Root.Props) { return ( ); } const tabsListVariants = cva({ - base: "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none", + base: "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-9 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", variants: { variant: { default: "bg-muted", @@ -35,7 +33,7 @@ function TabsList({ className, variant = "default", ...props -}: React.ComponentProps & VariantProps) { +}: TabsPrimitive.List.Props & VariantProps) { return ( ) { +function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { return ( - ) { - return ; +function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) { + return ( + + ); } export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }; diff --git a/ui/litellm-dashboard/src/components/ui/tooltip.tsx b/ui/litellm-dashboard/src/components/ui/tooltip.tsx index 569ff709bc8..5e21eab67f5 100644 --- a/ui/litellm-dashboard/src/components/ui/tooltip.tsx +++ b/ui/litellm-dashboard/src/components/ui/tooltip.tsx @@ -1,42 +1,52 @@ "use client"; -import * as React from "react"; -import { Tooltip as TooltipPrimitive } from "radix-ui"; +import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; import { cn } from "@/lib/cva.config"; -function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps) { - return ; +function TooltipProvider({ delay = 0, ...props }: TooltipPrimitive.Provider.Props) { + return ; } -function Tooltip({ ...props }: React.ComponentProps) { +function Tooltip({ ...props }: TooltipPrimitive.Root.Props) { return ; } -function TooltipTrigger({ ...props }: React.ComponentProps) { +function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) { return ; } function TooltipContent({ className, - sideOffset = 0, + side = "top", + sideOffset = 4, + align = "center", + alignOffset = 0, children, ...props -}: React.ComponentProps) { +}: TooltipPrimitive.Popup.Props & + Pick) { return ( - - {children} - - + + {children} + + + ); } diff --git a/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx b/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx index b20281ed0da..f65ff2cc6ca 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx @@ -193,15 +193,24 @@ export function LogsTableToolbar({

- + Showing {isLoading ? "..." : filteredLogs ? (currentPage - 1) * pageSize + 1 : 0} -{" "} {isLoading ? "..." : filteredLogs ? Math.min(currentPage * pageSize, filteredLogs.total) : 0} of{" "} - {isLoading ? "..." : filteredLogs ? filteredLogs.total : 0} results + {isLoading ? "..." : filteredLogs ? filteredLogs.total : 0} + {!isLoading && filteredLogs?.total_is_capped ? "+" : ""} results
Page {isLoading ? "..." : currentPage} of{" "} {isLoading ? "..." : filteredLogs ? filteredLogs.total_pages : 1} + {!isLoading && filteredLogs?.total_is_capped ? "+" : ""}