feat(terraform): vendor terraform-provider-litellm as source of truth with endpoint drift CI (#32241)

* feat(terraform): vendor terraform-provider-litellm as source of truth with endpoint drift CI

* fix(terraform): address review feedback on vendored provider

Replace deprecated io/ioutil with io. Remove the unused org/team CRUD
client methods so the endpoint audit only tracks live call sites
(54 -> 46). Redact request/response logs by parsing the JSON and
recursively masking sensitive fields, which fixes the nested-object
leak in the old credential_values regex, with a regex fallback for
non-JSON payloads; covered by new unit tests. Docs: stop showing
api_key inside vector store litellm_params and document that Sensitive
attributes still persist in plaintext state, recommending
litellm_credential_name and an encrypted state backend.

* fix(terraform): stop persisting server-returned litellm_params into vector store state

The vector store Read wrote litellm_params straight back from the API
response into state. The proxy redacts secrets in those responses, so
the readback overwrote user config with redaction sentinels and caused
perpetual diffs, and against a server that returns raw values it would
persist secrets into a non-Sensitive attribute. Read now preserves the
config value like the credential and model resources do, litellm_params
is marked Sensitive, and a regression test pins that a server-returned
api_key never lands in state

* fix(terraform): send role on team member update and stop persisting server env into MCP state

The team member update payload omitted role, and the proxy leaves role
unchanged when the field is absent, so a role downgrade reported as
applied by Terraform never took effect on the proxy. The update now
always sends the configured role (the attribute is Required).

The MCP server resource wrote env straight back from API responses
into a non-Sensitive attribute, pulling admin-visible secrets into
state and, for sanitized responses, blanking user config. Read now
preserves the config value, env is marked Sensitive, and the docs warn
against passing secrets via args. Regression tests cover both fixes
and fail against the previous behavior.
This commit is contained in:
Yassin Kortam 2026-07-07 19:16:59 +03:00 committed by GitHub
parent 3116ed211b
commit ce2582e9d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 9210 additions and 0 deletions

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"

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