Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_shadcn-sidebar-api-ref

This commit is contained in:
Devin AI 2026-07-07 17:40:50 +00:00
commit d42933bb46
161 changed files with 13282 additions and 2378 deletions

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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==",

View file

@ -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

View file

@ -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}, "

View file

@ -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))

View file

@ -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

View file

@ -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,

View file

@ -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:

View file

@ -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,
)

View file

@ -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"

View file

@ -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,

View file

@ -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):
"""

View file

@ -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.

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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)

View file

@ -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,

View file

@ -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)

View file

@ -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(

View file

@ -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 = [

View file

@ -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"

View file

@ -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))

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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,
),

View file

@ -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)

View file

@ -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"

View file

@ -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,

View file

@ -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,

View file

@ -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,
}

View file

@ -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

View file

@ -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,

View file

@ -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."""

View file

@ -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/", "")

View file

@ -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

View file

@ -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",
]

71
terraform/provider/.gitignore vendored Normal file
View file

@ -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

View file

@ -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

View file

@ -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=<your-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 <token_id>
```
> ⚠️ 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

View file

@ -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

View file

@ -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

View file

@ -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 <code>terraform</code> 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 <code>litellm_model</code> 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 <code>litellm_key</code> resource supports the following options:
- <code>models</code>: List of allowed models for this key
- <code>max_budget</code>: Maximum budget for the key
- <code>user_id</code> and <code>team_id</code>: Associate the key with a user and team
- <code>max_parallel_requests</code>: Limit concurrent requests
- <code>tpm_limit</code> and <code>rpm_limit</code>: Set tokens and requests per minute limits
- <code>budget_duration</code>: Specify budget duration (e.g., "monthly", "weekly")
- <code>key_alias</code>: Set a friendly name for the key
- <code>duration</code>: Set the key's validity period
- <code>metadata</code>: Add custom metadata to the key
- <code>allowed_cache_controls</code>: Specify allowed cache control directives
- <code>soft_budget</code>: Set a soft budget limit
- <code>aliases</code>: Define model aliases
- <code>config</code>: Set configuration options
- <code>permissions</code>: Specify key permissions
- <code>model_max_budget</code>, <code>model_rpm_limit</code>, <code>model_tpm_limit</code>: Set per-model limits
- <code>guardrails</code>: Apply specific guardrails to the key
- <code>blocked</code>: Flag to block/unblock the key
- <code>tags</code>: Add tags for organization and filtering
For full details on the <code>litellm_key</code> resource, see the [key resource documentation](docs/resources/key.md).
### Available Resources
- <code>litellm_model</code>: Manage model configurations. [Documentation](docs/resources/model.md)
- <code>litellm_team</code>: Manage teams. [Documentation](docs/resources/team.md)
- <code>litellm_team_member</code>: Manage team members. [Documentation](docs/resources/team_member.md)
- <code>litellm_team_member_add</code>: Add multiple members to teams. [Documentation](docs/resources/team_member_add.md)
- <code>litellm_key</code>: Manage API keys. [Documentation](docs/resources/key.md)
- <code>litellm_mcp_server</code>: Manage MCP (Model Context Protocol) servers. [Documentation](docs/resources/mcp_server.md)
- <code>litellm_credential</code>: Manage credentials for secure authentication. [Documentation](docs/resources/credential.md)
- <code>litellm_vector_store</code>: Manage vector stores for embeddings and RAG. [Documentation](docs/resources/vector_store.md)
### Available Data Sources
- <code>litellm_credential</code>: Retrieve information about existing credentials. [Documentation](docs/data-sources/credential.md)
- <code>litellm_vector_store</code>: 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.

View file

@ -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 <your.email@example.com>
#
# 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<provider_version>`
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<MAJOR>.<MINOR>.<PATCH>` (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/)

View file

@ -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
}
}

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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 <model-id>
```
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.

View file

@ -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 <team-id>
```
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.

View file

@ -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 <team_id>:<user_id>
```
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.

View file

@ -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

View file

@ -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).

View file

@ -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
}

61
terraform/provider/go.mod Normal file
View file

@ -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
)

239
terraform/provider/go.sum Normal file
View file

@ -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=

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}
}
}
}

View file

@ -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.)",
},
},
}
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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",
},
},
}
}

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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",
},
},
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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",
},
},
}
}

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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"`
}

View file

@ -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
}

View file

@ -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,
})
}

View file

@ -0,0 +1,6 @@
{
"version": 1,
"metadata": {
"protocol_versions": ["6.0"]
}
}

View file

@ -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 <out_path>", file=sys.stderr)
sys.exit(2)
main(sys.argv[1])

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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()

View file

@ -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

View file

@ -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

Some files were not shown because too many files have changed in this diff Show more