diff --git a/.github/workflows/test-terraform-provider.yml b/.github/workflows/test-terraform-provider.yml new file mode 100644 index 00000000000..03d8ff3461c --- /dev/null +++ b/.github/workflows/test-terraform-provider.yml @@ -0,0 +1,113 @@ +name: Terraform Provider + +on: + push: + paths: + - "terraform/provider/**" + - ".github/workflows/test-terraform-provider.yml" + pull_request: + branches: + - main + - litellm_internal_staging + - litellm_oss_staging + - "litellm_**" + paths: + - "terraform/provider/**" + - "litellm/proxy/**" + - ".github/workflows/test-terraform-provider.yml" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + provider-checks: + name: gofmt, vet, build, test + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: terraform/provider + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + with: + go-version-file: terraform/provider/go.mod + cache: true + cache-dependency-path: terraform/provider/go.sum + + - name: gofmt + run: | + UNFORMATTED=$(gofmt -l .) + if [ -n "${UNFORMATTED}" ]; then + echo "::error::gofmt required for: ${UNFORMATTED}" + exit 1 + fi + + - name: go vet + run: go vet ./... + + - name: Build + run: go build ./... + + - name: Test + run: go test -timeout 120s ./... + + endpoint-drift: + name: Provider endpoints vs proxy OpenAPI schema + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + version: "0.10.9" + + - name: Cache uv dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.cache/uv + .venv + key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }} + restore-keys: | + ${{ runner.os }}-uv- + + - name: Install dependencies + run: | + .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router + + - name: Generate Prisma client + env: + PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache + run: | + uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma + + - name: Generate proxy OpenAPI schema + run: | + uv run --no-sync python terraform/provider/tools/dump_openapi.py "${RUNNER_TEMP}/openapi.json" + + - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + with: + go-version-file: terraform/provider/go.mod + cache: true + cache-dependency-path: terraform/provider/go.sum + + - name: Audit provider endpoints against the schema + working-directory: terraform/provider + run: go run ./tools/endpointaudit -provider-dir ./litellm -spec "${RUNNER_TEMP}/openapi.json" diff --git a/terraform/provider/.gitignore b/terraform/provider/.gitignore new file mode 100644 index 00000000000..7606b250a4c --- /dev/null +++ b/terraform/provider/.gitignore @@ -0,0 +1,71 @@ +# Local .terraform directories +**/.terraform/* +test_litellm/* + +# .tfstate files +*.tfstate +*.tfstate.* + +# Crash log files +crash.log +crash.*.log + +# Exclude all .tfvars files, which are likely to contain sensitive data +*.tfvars +!*.tfvars.example + +# Ignore override files as they are usually used to override resources locally +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Ignore CLI configuration files +.terraformrc +terraform.rc + +# Binary files +terraform-provider-litellm + +# IDE and editor files +.idea/ +*.swp +*.swo +.vscode/ +*.sublime-workspace +*.sublime-project + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Go specific +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +go.work + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a + +# Log files +*.log + +# Environment files +.env diff --git a/terraform/provider/.goreleaser.yml b/terraform/provider/.goreleaser.yml new file mode 100644 index 00000000000..f41a29406b8 --- /dev/null +++ b/terraform/provider/.goreleaser.yml @@ -0,0 +1,81 @@ +# Visit https://goreleaser.com for documentation on how to customize this +# behavior. +version: 2 +before: + hooks: + # this is just an example and not a requirement for provider building/publishing + - go mod tidy +builds: +- env: + # goreleaser does not work with CGO, it could also complicate + # usage by users in CI/CD systems like HCP Terraform where + # they are unable to install libraries. + - CGO_ENABLED=0 + mod_timestamp: '{{ .CommitTimestamp }}' + flags: + - -trimpath + ldflags: + - '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}' + goos: + - freebsd + - windows + - linux + - darwin + goarch: + - amd64 + - '386' + - arm + - arm64 + ignore: + # macOS doesn't support 32-bit anymore + - goos: darwin + goarch: '386' + # Windows ARM is uncommon for Terraform usage + - goos: windows + goarch: arm + - goos: windows + goarch: arm64 + # FreeBSD ARM is rarely used + - goos: freebsd + goarch: arm + - goos: freebsd + goarch: arm64 + # This builds the following key targets for Terraform users: + # - linux/amd64 (most common CI/CD) + # - linux/arm64 (Graviton, ARM-based CI) + # - linux/386 (legacy 32-bit systems) + # - linux/arm (Raspberry Pi, etc.) + # - darwin/amd64 (Intel Macs) + # - darwin/arm64 (Apple Silicon Macs) + # - windows/amd64 (Windows desktops) + # - freebsd/amd64, freebsd/386 (FreeBSD servers) + binary: '{{ .ProjectName }}_v{{ .Version }}' +archives: +- format: zip + name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}' +checksum: + extra_files: + - glob: 'terraform-registry-manifest.json' + name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json' + name_template: '{{ .ProjectName }}_{{ .Version }}_SHA256SUMS' + algorithm: sha256 +signs: + - artifacts: checksum + args: + # if you are using this in a GitHub action or some other automated pipeline, you + # need to pass the batch flag to indicate its not interactive. + - "--batch" + - "--local-user" + - "{{ .Env.GPG_FINGERPRINT }}" # set this environment variable for your signing key + - "--output" + - "${signature}" + - "--detach-sign" + - "${artifact}" +release: + extra_files: + - glob: 'terraform-registry-manifest.json' + name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json' + # If you want to manually examine the release before its live, uncomment this line: + # draft: true +changelog: + disable: true \ No newline at end of file diff --git a/terraform/provider/CHANGELOG.md b/terraform/provider/CHANGELOG.md new file mode 100644 index 00000000000..101519c0b08 --- /dev/null +++ b/terraform/provider/CHANGELOG.md @@ -0,0 +1,294 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Fixed + +- **organization**: Send `PATCH` instead of `POST` to `/organization/update` and `/organization/member_update`, matching the methods the LiteLLM proxy serves; organization and organization member updates previously failed with a 405 + +### Changed + +- The provider source of truth moved to `terraform/provider/` in [BerriAI/litellm](https://github.com/BerriAI/litellm); this repository is now a release mirror. CI in the monorepo statically audits every endpoint the provider calls against the proxy's OpenAPI schema on every change + +## [0.2.2] - 2026-05-13 + +### Fixed + +- **key**: Include `tags` in `UpdateKey` payload so tag changes on an existing `litellm_key` are applied on update instead of being silently dropped (#41) + +## [0.2.1] - 2026-04-13 + +### Fixed + +- **team, organization**: Use pointer types for `tpm_limit`, `rpm_limit`, and `max_budget` to prevent zero-value diffs on every `terraform plan` when these fields are not configured (#31) + +## [0.2.0] - 2026-04-03 + +### ⚠️ Breaking Changes + +#### `litellm_key`: API keys are no longer stored in Terraform state + +**Why this change?** Storing raw API keys in Terraform state is a security risk — state files are often stored in S3, Terraform Cloud, or other backends where the key could be exposed even with encryption at rest. This release eliminates that risk entirely. + +**What changed:** +- The `key` attribute is now **write-only** — available during `terraform apply` so you can pipe it to a secrets manager, but never persisted to state +- The resource ID has changed from the raw key value to its **SHA-256 hash (`token_id`)** — safe to store in state, cannot be used to authenticate +- **Requires Terraform 1.11+** + +**Migration steps for existing `litellm_key` resources:** + +1. Find the `token_id` for each key via the LiteLLM UI or `GET /key/info?key=` +2. Remove the old resource from state: + ``` + terraform state rm litellm_key.example + ``` +3. Re-import using the token_id: + ``` + terraform import litellm_key.example + ``` + +> ⚠️ After upgrading, you cannot retrieve the raw key from state. Make sure you have the key value stored somewhere safe before migrating, or plan to rotate the key after re-import. + +**Security best practice:** Since the key is only available during the initial `terraform apply`, pipe it directly to a secrets manager: + +```hcl +resource "aws_ssm_parameter" "litellm_key" { + name = "/myapp/litellm-key" + type = "SecureString" + value = litellm_key.example.key +} +``` + +### Fixed + +- **key**: API key is no longer stored in Terraform state. The `key` attribute is now write-only and `token_id` is used as the resource ID (#27) +- **model**: Handle eventual consistency in model reads post-create (#26) + +## [0.1.2] - 2026-02-17 + +### Added +- **Documentation**: Added RELEASING.md with comprehensive release process documentation + - GPG key setup instructions + - Step-by-step release workflow + - Troubleshooting guide + - Security best practices + +## [0.1.1] - 2026-02-11 + +### Added +- **New Model Modes**: Added support for `audio_speech` and `rerank` model modes + - `audio_speech`: For text-to-speech models (e.g., Gemini TTS, OpenAI TTS) + - `rerank`: For reranking/semantic ranking models (e.g., Cohere Rerank, Vertex AI Semantic Ranker) + +### Fixed +- Implemented exponential backoff for credential reads +- Only include cost fields when explicitly set in model resource +- Added litellm_credential_name support + +## [0.3.14] - 2025-08-24 + +### Added +- **Enhanced JSON Parsing**: Added support for JSON string parsing in `additional_litellm_params` + - JSON objects and arrays (starting with `{` or `[`) are now automatically parsed + - Maintains backward compatibility with existing string-to-type conversion + - Enables complex nested parameter configurations +- **Parameter Dropping Feature**: Added `additional_drop_params` special parameter + - Allows removal of unwanted parameters from final `litellm_params` before API submission + - Specified as JSON array string: `"additional_drop_params" = "[\"reasoningEffort\"]"` + - Useful for overriding or removing built-in parameters when needed +- **Enhanced Examples**: Updated `examples/model_additional_params.tf` with comprehensive JSON parsing examples + - Demonstrates all supported value types (boolean, integer, float, string, JSON objects/arrays) + - Includes real-world Azure model configuration with parameter dropping + - Shows both simple and complex use cases + +### Changed +- **Documentation Enhancement**: Updated `docs/resources/model.md` with detailed JSON parsing documentation + - Added comprehensive explanation of conversion rules and behavior + - Included special `additional_drop_params` parameter documentation + - Enhanced examples showing all supported parameter types and JSON parsing capabilities + +### Technical Details +- Enhanced parameter processing logic in `createOrUpdateModel()` function +- Added JSON detection and parsing for string values starting with `[` or `{` +- Implemented parameter filtering system for `additional_drop_params` +- Maintains full backward compatibility with existing configurations + +## [0.3.13] - 2025-08-24 + +### Changed +- Documentation: Performed a documentation audit and improvements across resources and data-sources. Added missing argument references, clarified types/defaults, documented implementation behaviors (e.g., additional_litellm_params parsing and state-preservation), and added an `examples/` directory with runnable HCL examples (starting with `examples/model_additional_params.tf`). +- Docs: Updated `docs/resources/model.md` with missing fields (`vertex_*`, pixel/second cost fields, and `additional_litellm_params`) and added conversion rules and an example. +- Docs Index: Added references to the new `examples/` directory in `docs/index.md`. + +## [0.3.12] - 2025-08-13 + +### Added +- **New AWS Parameters**: Added `aws_session_name` and `aws_role_name` to model resource for cross-account access scenarios + - Support for AWS session names in cross-account access configurations + - Support for AWS IAM role names for cross-account access + - Enhanced AWS Bedrock integration capabilities + +### Changed +- **Documentation Overhaul**: Comprehensive update to all provider documentation + - Updated provider source references from `bitop/litellm` to `registry.terraform.io/ncecere/litellm` + - Consolidated all scattered example files into organized documentation structure + - Enhanced all resource documentation with multiple real-world examples + - Added comprehensive cross-resource integration examples +- **Vector Store Documentation**: Updated to reflect only officially supported LiteLLM providers + - Removed unsupported providers (Pinecone, Weaviate, Chroma, Qdrant, Milvus, FAISS) + - Added accurate examples for supported providers: AWS Bedrock Knowledge Bases, OpenAI Vector Stores, Azure Vector Stores, Vertex AI RAG Engine, PG Vector + - Updated provider-specific parameters with correct configurations + - Added references to official LiteLLM documentation +- **Project Organization**: Cleaned up project structure + - Removed scattered example files from root directory + - Consolidated all examples into comprehensive documentation + - Updated README.md to reflect current capabilities and structure + +### Fixed +- Corrected vector store provider documentation to match LiteLLM's official capabilities +- Updated all documentation links and references for accuracy + +## [0.3.11] - 2025-08-10 + +### Added +- **New Resource**: `litellm_credential` - Manage credentials for secure authentication + - Support for storing sensitive credential values (API keys, tokens, etc.) + - Non-sensitive credential information storage + - Model ID association for credentials + - Secure handling of sensitive data with Terraform's sensitive attribute +- **New Resource**: `litellm_vector_store` - Manage vector stores for embeddings and RAG + - Support for multiple vector store providers (Pinecone, Weaviate, Chroma, Qdrant, etc.) + - Integration with credential management for secure authentication + - Configurable metadata and provider-specific parameters + - Full CRUD operations for vector store lifecycle management +- **New Data Source**: `litellm_credential` - Retrieve information about existing credentials + - Read-only access to credential metadata (sensitive values excluded for security) + - Support for model ID filtering + - Cross-stack and cross-configuration referencing capabilities +- **New Data Source**: `litellm_vector_store` - Retrieve information about existing vector stores + - Complete vector store information retrieval + - Support for monitoring, validation, and cross-referencing use cases + - Metadata-based conditional logic support +- Enhanced API response handling for credential and vector store operations +- Comprehensive documentation and examples for new resources and data sources +- Example Terraform configurations for common use cases + +### Changed +- Extended `utils.go` with specialized API response handlers for credentials and vector stores +- Updated provider configuration to include new resources and data sources +- Enhanced error handling for credential and vector store not found scenarios + +## [0.3.10] - 2025-08-10 + +### Added +- **New Resource**: `litellm_mcp_server` - Manage MCP (Model Context Protocol) servers + - Support for HTTP, SSE, and stdio transport types + - Configurable authentication types (none, bearer, basic) + - MCP access groups for permission management + - Cost tracking configuration for MCP tools + - Environment variables and command arguments for stdio transport + - Health check status monitoring + - Comprehensive documentation and examples + +### Changed +- Updated provider to support MCP server management functionality +- Enhanced API response handling for MCP-specific operations + +## [0.3.9] - 2025-08-10 + +### Fixed +- Fixed issue where omitting `budget_duration` in key resource caused API error "Invalid duration format" +- Added missing `omitempty` JSON tag to `BudgetDuration` field in Key struct to prevent sending empty strings to API + +## [0.3.8] - 2025-08-08 + +### Added +- Added `additional_litellm_params` field to model resource for custom parameters beyond standard ones +- Support for passing custom parameters like `drop_params`, `timeout`, `max_retries`, `organization`, etc. +- Automatic type conversion for string values to appropriate types (boolean, integer, float) +- Full backward compatibility with existing model configurations +- Comprehensive example demonstrating various use cases with different providers + +## [0.3.7] - 2025-08-08 + +### Fixed +- Fixed issue where changing max_budget_in_team didn't update existing team members with new budget +- Added budget change detection using d.HasChange to update ALL existing members when budget changes +- Implemented tracking to avoid duplicate API calls for members already updated +- Enhanced debug logging for budget update operations + +## [0.3.6] - 2025-08-08 + +### Fixed +- Fixed issue where models deleted from LiteLLM proxy caused terraform plan to fail instead of planning recreation +- Enhanced ErrorResponse struct to properly parse LiteLLM proxy error format with Detail field +- Improved isModelNotFoundError function to detect "not found on litellm proxy" messages in Detail.Error field + +## [0.3.5] - 2025-08-08 + +### Fixed +- Fixed team member update behavior to use member_update endpoint instead of delete/re-add +- Restored team_member_permissions functionality to litellm_team resource +- Enhanced team resource with proper permissions management endpoints + +## [0.3.0] - 2025-04-23 + +### Fixed +- Implemented retry mechanism with exponential backoff for model read operations +- Added detailed logging for retry attempts +- Improved error handling for "model not found" errors + +## [0.2.9] - 2025-04-23 + +### Fixed +- Increased delay after model creation from 2 to 5 seconds to fix "model not found" errors +- Added logging to confirm delay is working properly + +## [0.2.8] - 2025-04-23 + +### Fixed +- Added delay after model creation to fix "model not found" errors when the LiteLLM proxy hasn't fully registered the model yet + +## [0.2.7] - 2025-04-23 + +### Fixed +- Fixed issue where `thinking_enabled` and `merge_reasoning_content_in_choices` values were not being preserved in state, causing Terraform to want to modify them on every run + +## [0.2.6] - 2025-03-13 + +### Added +- Added new `merge_reasoning_content_in_choices` option to model resource + +## [0.2.5] - 2025-03-13 + +### Fixed +- Fixed issue where `thinking_budget_tokens` was being added to models that don't have `thinking_enabled = true` + +## [0.2.4] - 2025-03-13 + +### Added +- Added new `thinking` capability to model resource with configurable parameters: + - `thinking_enabled` - Boolean to enable/disable thinking capability (default: false) + - `thinking_budget_tokens` - Integer to set token budget for thinking (default: 1024) + +## [0.2.2] - 2025-02-06 + +### Added +- Added new `reasoning_effort` parameter to model resource with values: "low", "medium", "high" +- Added "chat" mode to model resource + +### Changed +- Updated model mode options to: "completion", "embedding", "image_generation", "chat", "moderation", "audio_transcription" + +## [1.0.0] - 2024-01-17 + +### Added +- Initial release of the LiteLLM Terraform Provider +- Support for managing LiteLLM models +- Support for managing teams and team members +- Comprehensive documentation for all resources diff --git a/terraform/provider/LICENSE b/terraform/provider/LICENSE new file mode 100644 index 00000000000..967d4ac9b42 --- /dev/null +++ b/terraform/provider/LICENSE @@ -0,0 +1,35 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source diff --git a/terraform/provider/Makefile b/terraform/provider/Makefile new file mode 100644 index 00000000000..ddca16e1636 --- /dev/null +++ b/terraform/provider/Makefile @@ -0,0 +1,32 @@ +HOSTNAME=registry.terraform.io +NAMESPACE=local +NAME=litellm +VERSION=1.0.0 +OS_ARCH=darwin_amd64 + +default: install + +build: + go build -o terraform-provider-${NAME} + +install: build + mkdir -p ~/.terraform.d/plugins/${HOSTNAME}/${NAMESPACE}/${NAME}/${VERSION}/${OS_ARCH} + mv terraform-provider-${NAME} ~/.terraform.d/plugins/${HOSTNAME}/${NAMESPACE}/${NAME}/${VERSION}/${OS_ARCH}/terraform-provider-${NAME}_v${VERSION} + +test: + go test ./... + +fmt: + go fmt ./... + +vet: + go vet ./... + +lint: + golangci-lint run + +clean: + rm -f terraform-provider-${NAME} + rm -rf ~/.terraform.d/plugins/${HOSTNAME}/${NAMESPACE}/${NAME}/${VERSION} + +.PHONY: build install test fmt vet lint clean diff --git a/terraform/provider/README.md b/terraform/provider/README.md new file mode 100644 index 00000000000..3b59edd97c6 --- /dev/null +++ b/terraform/provider/README.md @@ -0,0 +1,223 @@ +# LiteLLM Terraform Provider + +This Terraform provider allows you to manage LiteLLM resources through Infrastructure as Code. It provides support for managing models, teams, team members, and API keys via the LiteLLM REST API. + +## Source of truth + +This directory (`terraform/provider/` in [BerriAI/litellm](https://github.com/BerriAI/litellm)) is the source of truth for the provider. [BerriAI/terraform-provider-litellm](https://github.com/BerriAI/terraform-provider-litellm) is a thin release mirror that the public Terraform Registry ingests from; do not open PRs there. Changes land here, where CI builds the provider, runs its tests, and statically audits every endpoint the provider calls against the proxy's generated OpenAPI schema (`tools/endpointaudit/`), so the provider cannot drift from the LiteLLM API silently. Releases are published by mirroring this directory into the split repo and tagging it, which triggers the goreleaser workflow there (see `RELEASING.md`) + +## Features + +- Manage LiteLLM model configurations +- Associate models with specific teams +- Create and manage teams +- Configure team members and their permissions +- Set usage limits and budgets +- Control access to specific models +- Specify model modes (e.g., completion, embedding, image generation) +- Manage API keys with fine-grained controls +- Support for reasoning effort configuration in the model resource + +## Requirements + +- [Terraform](https://www.terraform.io/downloads.html) >= 0.13.x +- [Go](https://golang.org/doc/install) >= 1.16 (for development) + +## Using the Provider + +To use the LiteLLM provider in your Terraform configuration, you need to declare it in the terraform block: + +```hcl +terraform { + required_providers { + litellm = { + source = "BerriAI/litellm" + version = "~> 0.1.1" #HERE UPDATE VERSION ACCORDINGLY + } + } +} + +provider "litellm" { + api_base = var.litellm_api_base + api_key = var.litellm_api_key +} +``` + +Then, you can use the provider to manage LiteLLM resources. Here's an example of creating a model configuration: + +```hcl +resource "litellm_model" "gpt4" { + model_name = "gpt-4-proxy" + custom_llm_provider = "openai" + model_api_key = var.openai_api_key + model_api_base = "https://api.openai.com/v1" + base_model = "gpt-4" + tier = "paid" + mode = "chat" + reasoning_effort = "medium" # Optional: "low", "medium", or "high" + + input_cost_per_million_tokens = 30.0 + output_cost_per_million_tokens = 60.0 +} +``` + +For full details on the litellm_model resource, see the [model resource documentation](docs/resources/model.md). + +Here's an example of creating an API key with various options: + +```hcl +resource "litellm_key" "example_key" { + models = ["gpt-4", "claude-3.5-sonnet"] + max_budget = 100.0 + user_id = "user123" + team_id = "team456" + max_parallel_requests = 5 + tpm_limit = 1000 + rpm_limit = 60 + budget_duration = "monthly" + key_alias = "prod-key-1" + duration = "30d" + metadata = { + environment = "production" + } + allowed_cache_controls = ["no-cache", "max-age=3600"] + soft_budget = 80.0 + aliases = { + "gpt-4" = "gpt4" + } + config = { + default_model = "gpt-4" + } + permissions = { + can_create_keys = "true" + } + model_max_budget = { + "gpt-4" = 50.0 + } + model_rpm_limit = { + "claude-3.5-sonnet" = 30 + } + model_tpm_limit = { + "gpt-4" = 500 + } + guardrails = ["content_filter", "token_limit"] + blocked = false + tags = ["production", "api"] +} +``` + +The litellm_key resource supports the following options: + +- models: List of allowed models for this key +- max_budget: Maximum budget for the key +- user_id and team_id: Associate the key with a user and team +- max_parallel_requests: Limit concurrent requests +- tpm_limit and rpm_limit: Set tokens and requests per minute limits +- budget_duration: Specify budget duration (e.g., "monthly", "weekly") +- key_alias: Set a friendly name for the key +- duration: Set the key's validity period +- metadata: Add custom metadata to the key +- allowed_cache_controls: Specify allowed cache control directives +- soft_budget: Set a soft budget limit +- aliases: Define model aliases +- config: Set configuration options +- permissions: Specify key permissions +- model_max_budget, model_rpm_limit, model_tpm_limit: Set per-model limits +- guardrails: Apply specific guardrails to the key +- blocked: Flag to block/unblock the key +- tags: Add tags for organization and filtering + +For full details on the litellm_key resource, see the [key resource documentation](docs/resources/key.md). + +### Available Resources + +- litellm_model: Manage model configurations. [Documentation](docs/resources/model.md) +- litellm_team: Manage teams. [Documentation](docs/resources/team.md) +- litellm_team_member: Manage team members. [Documentation](docs/resources/team_member.md) +- litellm_team_member_add: Add multiple members to teams. [Documentation](docs/resources/team_member_add.md) +- litellm_key: Manage API keys. [Documentation](docs/resources/key.md) +- litellm_mcp_server: Manage MCP (Model Context Protocol) servers. [Documentation](docs/resources/mcp_server.md) +- litellm_credential: Manage credentials for secure authentication. [Documentation](docs/resources/credential.md) +- litellm_vector_store: Manage vector stores for embeddings and RAG. [Documentation](docs/resources/vector_store.md) + +### Available Data Sources + +- litellm_credential: Retrieve information about existing credentials. [Documentation](docs/data-sources/credential.md) +- litellm_vector_store: Retrieve information about existing vector stores. [Documentation](docs/data-sources/vector_store.md) + +## Development + +### Project Structure + +The project is organized as follows: + +``` +terraform-provider-litellm/ +├── litellm/ +│ ├── provider.go +│ ├── resource_model.go +│ ├── resource_model_crud.go +│ ├── resource_team.go +│ ├── resource_team_member.go +│ ├── resource_key.go +│ ├── resource_key_utils.go +│ ├── types.go +│ └── utils.go +├── main.go +├── go.mod +├── go.sum +├── Makefile +└── ... +``` + +### Building the Provider + +1. Clone the repository: +```sh +git clone https://github.com/your-username/terraform-provider-litellm.git +``` + +2. Enter the repository directory: +```sh +cd terraform-provider-litellm +``` + +3. Build and install the provider: +```sh +make install +``` + +### Development Commands + +The Makefile provides several useful commands for development: + +- `make build`: Builds the provider +- `make install`: Builds and installs the provider +- `make test`: Runs the test suite +- `make fmt`: Formats the code +- `make vet`: Runs go vet +- `make lint`: Runs golangci-lint +- `make clean`: Removes build artifacts and installed provider + +### Testing + +To run the tests: +```sh +make test +``` + +### Contributing + +Contributions are welcome! Please read our [contributing guidelines](CONTRIBUTING.md) first. + +## License + +This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. + +## Notes + +- Always use environment variables or secure secret management solutions to handle sensitive information like API keys and AWS credentials. +- Refer to the comprehensive documentation in the `docs/` directory for detailed usage examples and configuration options. +- Make sure to keep your provider version updated for the latest features and bug fixes. +- The provider now supports AWS cross-account access with `aws_session_name` and `aws_role_name` parameters in the model resource. +- All example configurations have been consolidated into the documentation for better organization and maintenance. diff --git a/terraform/provider/RELEASING.md b/terraform/provider/RELEASING.md new file mode 100644 index 00000000000..1dc296f29b8 --- /dev/null +++ b/terraform/provider/RELEASING.md @@ -0,0 +1,237 @@ +# Release Process + +This document describes the release process for the LiteLLM Terraform Provider. + +## Overview + +Releases are automated via GitHub Actions when a version tag is pushed. The workflow builds the provider for multiple platforms, signs the artifacts with GPG, and publishes them to GitHub Releases. + +## Prerequisites + +### GPG Key Setup (One-Time Setup for Repository Maintainers) + +The Terraform Registry requires all providers to be signed with a GPG key. This must be configured before the first release. + +#### 1. Generate a GPG Key + +If you don't already have a GPG key for provider signing: + +```bash +gpg --full-generate-key +``` + +Configuration: +- Key type: RSA and RSA (default) +- Key size: 4096 bits +- Expiration: No expiration (or set a long expiration period) +- Email: Use an email associated with your GitHub account +- Set a strong passphrase (or leave empty for CI/CD use) + +#### 2. Export the GPG Key + +```bash +# List your keys to get the key ID +gpg --list-secret-keys --keyid-format=long + +# Example output: +# sec rsa4096/ABCD1234EFGH5678 2024-01-01 [SC] +# 1234567890ABCDEF1234567890ABCDEF12345678 +# uid [ultimate] Your Name +# +# The key ID is: ABCD1234EFGH5678 +# The fingerprint is: 1234567890ABCDEF1234567890ABCDEF12345678 + +# Export the private key (ASCII-armored format) +gpg --armor --export-secret-keys ABCD1234EFGH5678 + +# Export the public key +gpg --armor --export ABCD1234EFGH5678 +``` + +#### 3. Configure GitHub Repository Secrets + +Add the following secrets to the repository at: **Settings → Secrets and variables → Actions → New repository secret** + +| Secret Name | Description | Value | +|-------------|-------------|-------| +| `GPG_PRIVATE_KEY` | The GPG private key for signing releases | Full output from `gpg --armor --export-secret-keys` (including `-----BEGIN PGP PRIVATE KEY BLOCK-----` and `-----END PGP PRIVATE KEY BLOCK-----`) | +| `PASSPHRASE` | The passphrase for the GPG key | Your GPG key passphrase (leave empty if no passphrase was set) | + +#### 4. Register Public Key with Terraform Registry + +Before publishing to the Terraform Registry: + +1. Go to https://registry.terraform.io/settings/gpg-keys +2. Click "Add a key" +3. Paste your public GPG key (output from `gpg --armor --export`) +4. Submit + +**Note**: The public key fingerprint must match the key used to sign the provider releases. + +## Release Steps + +### 1. Prepare the Release + +Before creating a release: + +1. **Update CHANGELOG.md** + - Move items from `[Unreleased]` section to a new version section + - Follow [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format + - Use [Semantic Versioning](https://semver.org/spec/v2.0.0.html) for version numbers + - Include all notable changes since the last release + + Example: + ```markdown + ## [0.1.2] - 2026-02-20 + + ### Added + - New feature description + + ### Fixed + - Bug fix description + + ### Changed + - Changed behavior description + ``` + +2. **Verify tests pass** + ```bash + make test + ``` + +3. **Verify the build works locally** + ```bash + make build + ``` + +4. **Land the changes in BerriAI/litellm** + + Open a PR to `BerriAI/litellm` updating `terraform/provider/CHANGELOG.md` (and any source changes) and merge it. Note the merge commit SHA; the release workflow takes it as `git_ref` + +### 2. Mirror and Tag via project-releaser + +The provider source lives at `terraform/provider/` in `BerriAI/litellm`; `BerriAI/terraform-provider-litellm` is a thin release mirror. Do not commit or tag the mirror directly + +1. Go to `BerriAI/project-releaser` > **Actions** > `Publish Terraform provider` +2. Click **Run workflow**: + - `git_ref`: full 40-char commit SHA from `BerriAI/litellm` to release from + - `provider_version`: the new version without the `v` prefix (e.g. `0.3.0`) + - `dry_run`: optional; validates without pushing +3. The workflow rsyncs `terraform/provider/` into the mirror repo, commits, and pushes tag `v` +4. The tag push triggers the mirror's `Release` workflow (goreleaser), which is gated by the `production-release` environment approval + +**Important**: +- Tags must follow the format: `v..` (e.g., `v0.1.2`, `v1.0.0`) +- The workflow refuses to overwrite an existing tag; publish a new version instead + +### 3. Monitor the Release Workflow + +1. Go to: https://github.com/BerriAI/terraform-provider-litellm/actions +2. Find the "Release" workflow run for your tag +3. Monitor the progress and check for any errors + +The workflow will: +- Check out the code +- Set up Go +- Import the GPG key +- Run `go mod tidy` +- Build binaries for multiple platforms (Linux, macOS, Windows, FreeBSD) +- Create archives and checksums +- Sign the checksums with GPG +- Create a GitHub release +- Upload all artifacts + +### 4. Verify the Release + +After the workflow completes successfully: + +1. **Check the GitHub Release** + - Go to: https://github.com/BerriAI/terraform-provider-litellm/releases + - Verify the release was created with the correct version + - Confirm all artifacts are present: + - Binary archives for each platform + - SHA256SUMS file + - SHA256SUMS.sig (GPG signature) + - terraform-registry-manifest.json + +2. **Verify the signature** (optional) + ```bash + # Download the checksums and signature + wget https://github.com/BerriAI/terraform-provider-litellm/releases/download/v0.1.2/terraform-provider-litellm_0.1.2_SHA256SUMS + wget https://github.com/BerriAI/terraform-provider-litellm/releases/download/v0.1.2/terraform-provider-litellm_0.1.2_SHA256SUMS.sig + + # Verify the signature + gpg --verify terraform-provider-litellm_0.1.2_SHA256SUMS.sig terraform-provider-litellm_0.1.2_SHA256SUMS + ``` + +### 5. Publish to Terraform Registry (Optional) + +If this provider is published to the Terraform Registry: + +1. The registry should automatically detect the new release via the GitHub webhook +2. If not, you may need to manually trigger a sync on the Terraform Registry dashboard +3. Verify the new version appears at: https://registry.terraform.io/providers/BerriAI/litellm/latest + +## Troubleshooting + +### Release Workflow Fails with GPG Error + +**Error**: `Input required and not supplied: gpg_private_key` + +**Solution**: +- Verify that `GPG_PRIVATE_KEY` and `PASSPHRASE` secrets are configured in the repository +- Ensure the secrets are not expired +- Check that the secret names match exactly (case-sensitive) + +### GoReleaser Signing Fails + +**Error**: `gpg: signing failed: No secret key` + +**Solution**: +- Verify the `GPG_PRIVATE_KEY` secret contains the complete private key block +- Ensure the passphrase is correct +- Check that the key hasn't expired: `gpg --list-keys` + +### Build Fails + +**Error**: Build errors during compilation + +**Solution**: +- Run `make test` and `make build` locally first +- Ensure `go.mod` and `go.sum` are up to date +- Check that all dependencies are available + +### Tag Already Exists + +**Error**: The publish workflow refuses to push because the tag already exists on the mirror + +**Solution**: Tags are immutable by design. Re-run the workflow with a new patch version instead of deleting or moving an existing tag + +## Version Numbering + +This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html): + +- **MAJOR** version (1.0.0): Incompatible API changes +- **MINOR** version (0.1.0): New functionality in a backward-compatible manner +- **PATCH** version (0.0.1): Backward-compatible bug fixes + +For pre-1.0 releases: +- Breaking changes may occur in minor versions +- Patch versions should only contain bug fixes + +## Security Considerations + +1. **Never commit private keys**: The GPG private key should only be stored as a GitHub secret +2. **Protect repository secrets**: Limit who has access to manage repository secrets +3. **Use a dedicated key**: Consider using a separate GPG key specifically for provider signing +4. **Key rotation**: If the GPG key is compromised, generate a new key, update secrets, and register the new public key with the Terraform Registry +5. **Passphrase**: Use a strong passphrase for the GPG key, or use a passphrase-less key specifically for CI/CD + +## References + +- [GoReleaser Documentation](https://goreleaser.com/) +- [Terraform Provider Publishing](https://www.terraform.io/docs/registry/providers/publishing.html) +- [HashiCorp GPG Signing Requirements](https://www.terraform.io/docs/registry/providers/publishing.html#signing-releases) +- [GitHub Actions Secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) +- [Semantic Versioning](https://semver.org/) +- [Keep a Changelog](https://keepachangelog.com/) diff --git a/terraform/provider/docs/data-sources/credential.md b/terraform/provider/docs/data-sources/credential.md new file mode 100644 index 00000000000..de4b9a8d9e4 --- /dev/null +++ b/terraform/provider/docs/data-sources/credential.md @@ -0,0 +1,153 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "litellm_credential Data Source - terraform-provider-litellm" +subcategory: "" +description: |- + Retrieves information about an existing LiteLLM credential. +--- + +# litellm_credential (Data Source) + +Retrieves information about an existing LiteLLM credential. This data source allows you to reference credentials that were created outside of Terraform or in other Terraform configurations. + +## Example Usage + +```terraform +# Retrieve an existing credential by name +data "litellm_credential" "existing_openai" { + credential_name = "openai-production-key" +} + +# Use the credential in a model resource +resource "litellm_model" "gpt4_with_existing_cred" { + model_name = "gpt-4-with-existing-cred" + custom_llm_provider = "openai" + base_model = "gpt-4" + tier = "paid" + mode = "chat" + + # Reference the existing credential's info + additional_litellm_params = { + credential_name = data.litellm_credential.existing_openai.credential_name + } +} +``` + +## Example Usage with Model ID + +```terraform +# Retrieve a credential associated with a specific model +data "litellm_credential" "model_specific_cred" { + credential_name = "claude-api-key" + model_id = "claude-3-sonnet" +} + +# Use in a vector store +resource "litellm_vector_store" "knowledge_base" { + vector_store_name = "claude-knowledge-base" + custom_llm_provider = "anthropic" + litellm_credential_name = data.litellm_credential.model_specific_cred.credential_name + + vector_store_description = "Knowledge base using Claude credentials" +} +``` + +## Example Usage for Cross-Reference + +```terraform +# Get credential info to use in other resources +data "litellm_credential" "shared_cred" { + credential_name = "shared-api-key" +} + +# Create multiple resources using the same credential +resource "litellm_vector_store" "store_1" { + vector_store_name = "store-1" + custom_llm_provider = "pinecone" + litellm_credential_name = data.litellm_credential.shared_cred.credential_name + + vector_store_description = "First store using shared credential" +} + +resource "litellm_vector_store" "store_2" { + vector_store_name = "store-2" + custom_llm_provider = "pinecone" + litellm_credential_name = data.litellm_credential.shared_cred.credential_name + + vector_store_description = "Second store using shared credential" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `credential_name` - (Required) Name of the credential to retrieve. +* `model_id` - (Optional) Model ID associated with this credential. Use this when the same credential name is used for different models. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `credential_info` - Map of additional non-sensitive information about the credential. + +## Security Note + +For security reasons, the `credential_values` (sensitive data like API keys) are not exposed through data sources. This prevents accidental exposure of sensitive information in Terraform plans and logs. If you need to access credential values, you should manage them through the resource directly or use external secret management systems. + +## Common Use Cases + +### 1. Cross-Stack References +Use data sources to reference credentials created in other Terraform configurations or stacks: + +```terraform +data "litellm_credential" "shared_openai" { + credential_name = "openai-shared-key" +} + +resource "litellm_model" "gpt4" { + model_name = "gpt-4-cross-stack" + custom_llm_provider = "openai" + base_model = "gpt-4" + + additional_litellm_params = { + credential_reference = data.litellm_credential.shared_openai.credential_name + } +} +``` + +### 2. Conditional Logic +Use credential information for conditional resource creation: + +```terraform +data "litellm_credential" "optional_cred" { + credential_name = var.credential_name +} + +resource "litellm_vector_store" "conditional_store" { + count = length(data.litellm_credential.optional_cred.credential_info) > 0 ? 1 : 0 + + vector_store_name = "conditional-store" + custom_llm_provider = "weaviate" + litellm_credential_name = data.litellm_credential.optional_cred.credential_name +} +``` + +### 3. Validation and Verification +Verify that required credentials exist before creating dependent resources: + +```terraform +data "litellm_credential" "required_cred" { + credential_name = "production-api-key" +} + +# This will fail if the credential doesn't exist +resource "litellm_model" "production_model" { + model_name = "production-gpt-4" + custom_llm_provider = "openai" + base_model = "gpt-4" + + additional_litellm_params = { + credential_name = data.litellm_credential.required_cred.credential_name + } +} diff --git a/terraform/provider/docs/data-sources/vector_store.md b/terraform/provider/docs/data-sources/vector_store.md new file mode 100644 index 00000000000..30bc26c163e --- /dev/null +++ b/terraform/provider/docs/data-sources/vector_store.md @@ -0,0 +1,225 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "litellm_vector_store Data Source - terraform-provider-litellm" +subcategory: "" +description: |- + Retrieves information about an existing LiteLLM vector store. +--- + +# litellm_vector_store (Data Source) + +Retrieves information about an existing LiteLLM vector store. This data source allows you to reference vector stores that were created outside of Terraform or in other Terraform configurations. + +## Example Usage + +```terraform +# Retrieve an existing vector store by ID +data "litellm_vector_store" "existing_store" { + vector_store_id = "vs-12345" +} + +# Use the vector store information in outputs +output "vector_store_info" { + value = { + name = data.litellm_vector_store.existing_store.vector_store_name + provider = data.litellm_vector_store.existing_store.custom_llm_provider + created_at = data.litellm_vector_store.existing_store.created_at + } +} +``` + +## Example Usage for Cross-Reference + +```terraform +# Get vector store info to reference in other configurations +data "litellm_vector_store" "shared_store" { + vector_store_id = var.shared_vector_store_id +} + +# Create a model that might use the same credential as the vector store +data "litellm_credential" "store_credential" { + credential_name = data.litellm_vector_store.shared_store.litellm_credential_name +} + +resource "litellm_model" "embedding_model" { + model_name = "embedding-model" + custom_llm_provider = "openai" + base_model = "text-embedding-ada-002" + mode = "embedding" + + additional_litellm_params = { + credential_name = data.litellm_credential.store_credential.credential_name + } +} +``` + +## Example Usage for Validation + +```terraform +# Verify vector store exists and get its configuration +data "litellm_vector_store" "production_store" { + vector_store_id = "production-vector-store-id" +} + +# Create resources only if the vector store is properly configured +resource "litellm_model" "rag_model" { + count = data.litellm_vector_store.production_store.custom_llm_provider == "pinecone" ? 1 : 0 + + model_name = "rag-enabled-model" + custom_llm_provider = "openai" + base_model = "gpt-4" + mode = "chat" + + additional_litellm_params = { + vector_store_id = data.litellm_vector_store.production_store.vector_store_id + } +} +``` + +## Example Usage for Monitoring + +```terraform +# Get vector store details for monitoring and alerting +data "litellm_vector_store" "monitored_stores" { + for_each = toset(var.vector_store_ids) + + vector_store_id = each.value +} + +# Output store information for monitoring systems +output "vector_store_status" { + value = { + for k, v in data.litellm_vector_store.monitored_stores : k => { + name = v.vector_store_name + provider = v.custom_llm_provider + created_at = v.created_at + updated_at = v.updated_at + metadata = v.vector_store_metadata + } + } +} +``` + +## Argument Reference + +The following arguments are supported: + +* `vector_store_id` - (Required) Unique identifier for the vector store to retrieve. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `vector_store_name` - Name of the vector store. +* `custom_llm_provider` - Custom LLM provider for the vector store. +* `vector_store_description` - Description of the vector store. +* `vector_store_metadata` - Map of metadata associated with the vector store. +* `litellm_credential_name` - Name of the LiteLLM credential used. +* `litellm_params` - Map of additional LiteLLM parameters. +* `created_at` - Timestamp when the vector store was created. +* `updated_at` - Timestamp when the vector store was last updated. + +## Common Use Cases + +### 1. Cross-Stack References +Reference vector stores created in other Terraform configurations: + +```terraform +data "litellm_vector_store" "shared_knowledge_base" { + vector_store_id = var.knowledge_base_id +} + +# Use the same credential for consistency +resource "litellm_model" "knowledge_model" { + model_name = "knowledge-retrieval-model" + custom_llm_provider = "openai" + base_model = "gpt-4" + + additional_litellm_params = { + vector_store_credential = data.litellm_vector_store.shared_knowledge_base.litellm_credential_name + } +} +``` + +### 2. Configuration Validation +Validate vector store configuration before creating dependent resources: + +```terraform +data "litellm_vector_store" "target_store" { + vector_store_id = var.target_vector_store_id +} + +# Ensure the vector store uses the expected provider +locals { + is_pinecone_store = data.litellm_vector_store.target_store.custom_llm_provider == "pinecone" +} + +resource "litellm_model" "pinecone_optimized_model" { + count = local.is_pinecone_store ? 1 : 0 + + model_name = "pinecone-optimized" + custom_llm_provider = "openai" + base_model = "text-embedding-ada-002" + mode = "embedding" +} +``` + +### 3. Metadata-Based Logic +Use vector store metadata for conditional resource creation: + +```terraform +data "litellm_vector_store" "environment_store" { + vector_store_id = var.vector_store_id +} + +# Create different resources based on environment metadata +resource "litellm_model" "production_model" { + count = lookup(data.litellm_vector_store.environment_store.vector_store_metadata, "environment", "") == "production" ? 1 : 0 + + model_name = "production-rag-model" + custom_llm_provider = "openai" + base_model = "gpt-4" + mode = "chat" +} + +resource "litellm_model" "development_model" { + count = lookup(data.litellm_vector_store.environment_store.vector_store_metadata, "environment", "") == "development" ? 1 : 0 + + model_name = "development-rag-model" + custom_llm_provider = "openai" + base_model = "gpt-3.5-turbo" + mode = "chat" +} +``` + +### 4. Audit and Compliance +Retrieve vector store information for audit and compliance reporting: + +```terraform +data "litellm_vector_store" "compliance_stores" { + for_each = toset(var.compliance_vector_store_ids) + + vector_store_id = each.value +} + +# Generate compliance report +output "compliance_report" { + value = { + for k, v in data.litellm_vector_store.compliance_stores : k => { + store_name = v.vector_store_name + provider = v.custom_llm_provider + credential = v.litellm_credential_name + created_date = v.created_at + last_updated = v.updated_at + metadata = v.vector_store_metadata + } + } +} +``` + +## Notes + +* Vector store IDs are unique identifiers assigned by the LiteLLM system. +* The data source will fail if the specified vector store ID does not exist. +* All computed attributes reflect the current state of the vector store in the LiteLLM system. +* Use this data source to integrate with existing vector stores or to reference stores created outside of Terraform. diff --git a/terraform/provider/docs/index.md b/terraform/provider/docs/index.md new file mode 100644 index 00000000000..c03071e7ed3 --- /dev/null +++ b/terraform/provider/docs/index.md @@ -0,0 +1,117 @@ +# LiteLLM Provider + +The LiteLLM provider allows Terraform to manage LiteLLM resources. LiteLLM is a proxy service that standardizes the input/output across different LLM APIs, providing a unified interface for various language model providers. + +## Example Usage + +```hcl +terraform { + required_providers { + litellm = { + source = "registry.terraform.io/BerriAI/litellm" + } + } +} + +provider "litellm" { + api_base = "https://your-litellm-proxy.com" + api_key = var.litellm_api_key +} + +# Basic model configuration +resource "litellm_model" "gpt4" { + model_name = "gpt-4-proxy" + custom_llm_provider = "openai" + model_api_key = var.openai_api_key + base_model = "gpt-4" + tier = "paid" + mode = "chat" + + input_cost_per_million_tokens = 30.0 + output_cost_per_million_tokens = 60.0 +} + +# Team configuration +resource "litellm_team" "dev_team" { + team_alias = "development-team" + models = [litellm_model.gpt4.model_name] + max_budget = 100.0 +} +``` + +## Available Resources + +The LiteLLM provider supports the following resources: + +* [`litellm_model`](./resources/model) - Manage LiteLLM model configurations +* [`litellm_team`](./resources/team) - Manage teams and their permissions +* [`litellm_team_member`](./resources/team_member) - Manage team member configurations +* [`litellm_team_member_add`](./resources/team_member_add) - Add members to teams +* [`litellm_key`](./resources/key) - Manage API keys +* [`litellm_mcp_server`](./resources/mcp_server) - Manage MCP (Model Context Protocol) servers +* [`litellm_credential`](./resources/credential) - Manage credentials for various providers +* [`litellm_vector_store`](./resources/vector_store) - Manage vector stores + +## Available Data Sources + +The LiteLLM provider supports the following data sources: + +* [`litellm_credential`](./data-sources/credential) - Retrieve credential information +* [`litellm_vector_store`](./data-sources/vector_store) - Retrieve vector store information + +## Authentication + +The LiteLLM provider requires an API key and base URL for authentication. These can be provided in the provider configuration block or via environment variables. + +### Environment Variables + +- `LITELLM_API_BASE` - The base URL of your LiteLLM instance +- `LITELLM_API_KEY` - Your LiteLLM API key + +### Example with Environment Variables + +```bash +export LITELLM_API_BASE="https://your-litellm-proxy.com" +export LITELLM_API_KEY="your-api-key" +``` + +```hcl +terraform { + required_providers { + litellm = { + source = "registry.terraform.io/BerriAI/litellm" + } + } +} + +# Provider will automatically use environment variables +provider "litellm" {} +``` + +## Provider Arguments + +The following arguments are supported in the provider block: + +* `api_base` - (Required) The base URL of your LiteLLM instance. This can also be provided via the `LITELLM_API_BASE` environment variable. +* `api_key` - (Required) The API key used to authenticate with LiteLLM. This can also be provided via the `LITELLM_API_KEY` environment variable. + +## Getting Started + +1. Install the provider by adding it to your Terraform configuration +2. Configure your LiteLLM instance URL and API key +3. Start creating resources like models, teams, and credentials +4. Use data sources to reference existing configurations + +For detailed examples and configuration options, see the individual resource and data source documentation pages. + +## Examples + +This repository includes an `examples/` directory with curated, ready-to-run HCL examples that demonstrate common and advanced usages of the provider. Examples are grouped by resource and illustrate provider-specific configuration, handling of sensitive values, and advanced options such as `additional_litellm_params`. + +See: +* `examples/model_additional_params.tf` — demonstrates how to use `additional_litellm_params` (booleans, integers, floats, and strings). +* Other example files will be added to `examples/` for credentials, vector stores, and MCP servers. + +You can reference these examples directly or copy snippets into your Terraform configurations for quick starts. + +For detailed examples and configuration options, see the individual resource and data source documentation pages. diff --git a/terraform/provider/docs/resources/credential.md b/terraform/provider/docs/resources/credential.md new file mode 100644 index 00000000000..554ac07c395 --- /dev/null +++ b/terraform/provider/docs/resources/credential.md @@ -0,0 +1,152 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "litellm_credential Resource - terraform-provider-litellm" +subcategory: "" +description: |- + Manages a LiteLLM credential for storing sensitive authentication information. +--- + +# litellm_credential (Resource) + +Manages a LiteLLM credential for storing sensitive authentication information. Credentials can be used to securely store API keys, tokens, and other sensitive data that can be referenced by models and vector stores. + +## Example Usage + +### Basic OpenAI Credential + +```terraform +resource "litellm_credential" "openai_cred" { + credential_name = "openai-api-key" + model_id = "gpt-4" + + credential_info = { + provider = "openai" + region = "us-east-1" + purpose = "chat-completions" + } + + credential_values = { + api_key = var.openai_api_key + org_id = var.openai_org_id + } +} +``` + +### Anthropic Credential + +```terraform +resource "litellm_credential" "anthropic_cred" { + credential_name = "anthropic-api-key" + + credential_info = { + provider = "anthropic" + purpose = "text-generation" + } + + credential_values = { + api_key = var.anthropic_api_key + } +} +``` + +### Pinecone Vector Store Credential + +```terraform +resource "litellm_credential" "pinecone_cred" { + credential_name = "pinecone-production" + + credential_info = { + provider = "pinecone" + environment = "production" + region = "us-east-1" + } + + credential_values = { + api_key = var.pinecone_api_key + index_name = "document-embeddings" + } +} +``` + +### Using Credentials with Vector Store + +```terraform +resource "litellm_vector_store" "example" { + vector_store_name = "my-vector-store" + custom_llm_provider = "pinecone" + litellm_credential_name = litellm_credential.pinecone_cred.credential_name + + vector_store_description = "Example vector store using Pinecone" + + vector_store_metadata = { + environment = "production" + team = "ai-team" + } +} +``` + +### Multiple Provider Credentials + +```terraform +# AWS Bedrock credential +resource "litellm_credential" "aws_bedrock" { + credential_name = "aws-bedrock-cred" + + credential_info = { + provider = "aws" + service = "bedrock" + region = "us-east-1" + } + + credential_values = { + aws_access_key_id = var.aws_access_key_id + aws_secret_access_key = var.aws_secret_access_key + aws_region = "us-east-1" + } +} + +# Azure OpenAI credential +resource "litellm_credential" "azure_openai" { + credential_name = "azure-openai-cred" + + credential_info = { + provider = "azure" + service = "openai" + } + + credential_values = { + api_key = var.azure_openai_key + api_base = var.azure_openai_endpoint + api_version = "2023-12-01-preview" + } +} +``` + +## Argument Reference + +The following arguments are supported: + +* `credential_name` - (Required) Name of the credential. This will be used as the identifier for the credential. +* `credential_values` - (Required, Sensitive) Map of sensitive credential values such as API keys, tokens, etc. +* `model_id` - (Optional) Model ID associated with this credential. +* `credential_info` - (Optional) Map of additional non-sensitive information about the credential. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `credential_name` - The name of the credential. + +## Import + +Credentials can be imported using their name: + +```shell +terraform import litellm_credential.example "credential-name" +``` + +## Security Considerations + +* The `credential_values` field is marked as sensitive and will not be displayed in Terraform output or logs. +* Credential values are not read back from the API for security reasons, so they are preserved in the Terraform state. +* Like every Terraform attribute marked `Sensitive`, `credential_values` is still written in plaintext to the state file. Anyone with read access to the state (or state artifacts such as plan files) can recover the configured secrets. Use an encrypted remote backend with tight access controls, and prefer feeding secrets in via variables sourced from a secret manager rather than hardcoding them in configuration. diff --git a/terraform/provider/docs/resources/key.md b/terraform/provider/docs/resources/key.md new file mode 100644 index 00000000000..b48d3334c14 --- /dev/null +++ b/terraform/provider/docs/resources/key.md @@ -0,0 +1,116 @@ +# litellm_key Resource + +Manages a LiteLLM API key. + +## Example Usage + +```hcl +resource "litellm_key" "example" { + models = ["gpt-3.5-turbo", "gpt-4"] + max_budget = 100.0 + user_id = "user123" + team_id = "team456" + max_parallel_requests = 5 + metadata = { + "environment" = "production" + } + tpm_limit = 1000 + rpm_limit = 60 + budget_duration = "monthly" + allowed_cache_controls = ["no-cache", "max-age=3600"] + soft_budget = 80.0 + key_alias = "prod-key-1" + duration = "30d" + aliases = { + "gpt-3.5-turbo" = "chatgpt" + } + config = { + "default_model" = "gpt-3.5-turbo" + } + permissions = { + "can_create_keys" = "true" + } + model_max_budget = { + "gpt-4" = 50.0 + } + model_rpm_limit = { + "gpt-3.5-turbo" = 30 + } + model_tpm_limit = { + "gpt-4" = 500 + } + guardrails = ["content_filter", "token_limit"] + blocked = false + tags = ["production", "api"] +} +``` + +## Argument Reference + +The following arguments are supported: + +* `models` - (Optional) List of models that can be used with this key. This restricts the key to only use the specified models. + +* `max_budget` - (Optional) Maximum budget for this key. This sets an upper limit on the total spend allowed for this key. + +* `user_id` - (Optional) User ID associated with this key. This links the key to a specific user in the LiteLLM system. + +* `team_id` - (Optional) Team ID associated with this key. This links the key to a specific team in the LiteLLM system. + +* `max_parallel_requests` - (Optional) Maximum number of parallel requests allowed for this key. This helps in controlling concurrent usage. + +* `metadata` - (Optional) Metadata associated with this key. This can be used to store additional, custom information about the key. + +* `tpm_limit` - (Optional) Tokens per minute limit for this key. This sets a rate limit based on the number of tokens processed. + +* `rpm_limit` - (Optional) Requests per minute limit for this key. This sets a rate limit based on the number of API calls. + +* `budget_duration` - (Optional) Duration for the budget (e.g., "monthly", "weekly"). This defines the time period for which the `max_budget` applies. + +* `allowed_cache_controls` - (Optional) List of allowed cache control directives. This can be used to control caching behavior for requests made with this key. + +* `soft_budget` - (Optional) Soft budget limit for this key. This can be used to set a warning threshold before reaching the `max_budget`. + +* `key_alias` - (Optional) Alias for this key. This provides a human-readable identifier for the key. + +* `duration` - (Optional) Duration for which this key is valid. This sets an expiration time for the key. + +* `aliases` - (Optional) Map of model aliases. This allows you to create custom names for models when using this key. + +* `config` - (Optional) Configuration options for this key. This can be used to set key-specific settings. + +* `permissions` - (Optional) Permissions associated with this key. This defines what actions are allowed with this key. + +* `model_max_budget` - (Optional) Maximum budget per model. This allows setting different budget limits for each model. + +* `model_rpm_limit` - (Optional) Requests per minute limit per model. This allows setting different RPM limits for each model. + +* `model_tpm_limit` - (Optional) Tokens per minute limit per model. This allows setting different TPM limits for each model. + +* `guardrails` - (Optional) List of guardrails applied to this key. This can be used to enforce certain safety or quality checks. + +* `blocked` - (Optional) Whether this key is blocked. If set to true, the key will be unable to make any requests. + +* `tags` - (Optional) List of tags associated with this key. This can be used for organization and filtering of keys. + +## Attribute Reference + +In addition to all arguments above, the following attributes are exported: + +* `key` - The generated API key. This is the actual key value that will be used for authentication. + +* `spend` - The current spend for this key. This reflects the total amount spent using this key so far. + +## State Management + +Recent updates have improved how the Key resource manages its state. The provider now ensures that all non-zero and non-empty values are correctly persisted in the Terraform state file. This means that any value you set will be accurately reflected in your state, preventing unnecessary updates and ensuring consistency between your configuration and the actual resource state. + +## Import + +LiteLLM keys can be imported using the `id`, e.g., + +``` +$ terraform import litellm_key.example 12345 +``` + +This allows you to import existing keys into your Terraform state, enabling management of keys that were created outside of Terraform. diff --git a/terraform/provider/docs/resources/mcp_server.md b/terraform/provider/docs/resources/mcp_server.md new file mode 100644 index 00000000000..77457a5ae55 --- /dev/null +++ b/terraform/provider/docs/resources/mcp_server.md @@ -0,0 +1,217 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "litellm_mcp_server Resource - terraform-provider-litellm" +subcategory: "" +description: |- + Manages an MCP (Model Context Protocol) server in LiteLLM. +--- + +# litellm_mcp_server (Resource) + +Manages an MCP (Model Context Protocol) server in LiteLLM. MCP servers provide tools and resources that can be used by LLMs through the LiteLLM proxy. + +## Example Usage + +### Basic HTTP MCP Server + +```terraform +resource "litellm_mcp_server" "github_server" { + server_name = "github-mcp-server" + alias = "github" + description = "GitHub MCP server for repository operations" + url = "https://api.github.com/mcp" + transport = "http" + auth_type = "bearer" + + mcp_access_groups = ["dev_team", "devops_team"] +} +``` + +### SSE MCP Server with Comprehensive Cost Tracking + +```terraform +resource "litellm_mcp_server" "zapier_server" { + server_name = "zapier-automation" + alias = "zapier" + description = "Zapier MCP server for workflow automation" + url = "https://actions.zapier.com/mcp/sk-xxxxx/sse" + transport = "sse" + auth_type = "bearer" + spec_version = "2024-11-05" + + mcp_access_groups = ["automation_team", "marketing_team"] + + mcp_info { + server_name = "Zapier Integration Server" + description = "Provides automation tools through Zapier's MCP interface" + logo_url = "https://zapier.com/assets/images/zapier-logo.png" + + mcp_server_cost_info { + default_cost_per_query = 0.01 + + tool_name_to_cost_per_query = { + "send_email" = 0.05 + "create_document" = 0.03 + "update_spreadsheet" = 0.02 + "post_to_slack" = 0.01 + "create_calendar_event" = 0.04 + } + } + } +} +``` + +### Stdio MCP Server for Local Development + +```terraform +resource "litellm_mcp_server" "local_dev_server" { + server_name = "local-development-tools" + alias = "local-dev" + description = "Local MCP server for development tools" + url = "stdio://local-dev" + transport = "stdio" + auth_type = "none" + + command = "python3" + args = ["/opt/mcp-servers/dev-tools/server.py", "--verbose"] + + env = { + "PYTHONPATH" = "/opt/mcp-servers/dev-tools" + "DEBUG" = "true" + "LOG_LEVEL" = "info" + "WORKSPACE_DIR" = "/workspace" + } + + mcp_access_groups = ["local_developers"] + + mcp_info { + server_name = "Development Tools" + description = "Local development utilities and tools" + + mcp_server_cost_info { + default_cost_per_query = 0.0 # Free for local development + } + } +} +``` + +### Enterprise MCP Server with Full Configuration + +```terraform +resource "litellm_mcp_server" "enterprise_api_server" { + server_name = "enterprise-api-gateway" + alias = "enterprise" + description = "Enterprise API gateway MCP server" + url = "https://api.enterprise.com/mcp/v1" + transport = "http" + auth_type = "bearer" + spec_version = "2024-11-05" + + mcp_access_groups = [ + "enterprise_users", + "api_consumers", + "integration_team" + ] + + mcp_info { + server_name = "Enterprise API Gateway" + description = "Provides access to enterprise APIs and services" + logo_url = "https://enterprise.com/logo.png" + + mcp_server_cost_info { + default_cost_per_query = 0.10 + + tool_name_to_cost_per_query = { + "query_database" = 0.25 + "generate_report" = 0.50 + "send_notification" = 0.05 + "create_user" = 0.15 + "update_permissions" = 0.20 + "audit_log_query" = 0.30 + } + } + } +} +``` + +## Argument Reference + +The following arguments are supported: + +### Required Arguments + +* `server_name` - (Required) Name of the MCP server. +* `url` - (Required) URL of the MCP server. For stdio transport, use `stdio://` prefix. +* `transport` - (Required) Transport type for the MCP server. Valid values: `http`, `sse`, `stdio`. + +### Optional Arguments + +* `alias` - (Optional) Alias for the MCP server. Used for easier reference. +* `description` - (Optional) Description of the MCP server. +* `spec_version` - (Optional) MCP specification version. Defaults to `2024-11-05`. +* `auth_type` - (Optional) Authentication type. Valid values: `none`, `bearer`, `basic`. Defaults to `none`. +* `mcp_access_groups` - (Optional) List of access groups that can use this MCP server. +* `command` - (Optional) Command to run for stdio transport. +* `args` - (Optional) List of arguments for the command (stdio transport only). Do not pass secrets as arguments; args are shown in plans, stored unencrypted in state, and visible in the server's process list. +* `env` - (Optional, Sensitive) Map of environment variables for the command (stdio transport only). Hidden from plan output but still stored unencrypted in state; secure your state backend when configuring tokens here. + +### MCP Info Block + +The `mcp_info` block supports: + +* `server_name` - (Optional) Server name in MCP info. +* `description` - (Optional) Description in MCP info. +* `logo_url` - (Optional) Logo URL for the MCP server. + +#### MCP Server Cost Info Block + +The `mcp_server_cost_info` block within `mcp_info` supports: + +* `default_cost_per_query` - (Optional) Default cost per query for all tools. +* `tool_name_to_cost_per_query` - (Optional) Map of specific tool names to their cost per query. + +## Attribute Reference + +In addition to all arguments above, the following attributes are exported: + +* `server_id` - Unique identifier for the MCP server. +* `created_at` - Timestamp when the server was created. +* `created_by` - User who created the server. +* `updated_at` - Timestamp when the server was last updated. +* `updated_by` - User who last updated the server. +* `status` - Current status of the MCP server. +* `last_health_check` - Timestamp of the last health check. +* `health_check_error` - Error message from the last health check, if any. + +## Import + +MCP servers can be imported using their server ID: + +```shell +terraform import litellm_mcp_server.example server-id-here +``` + +## Transport Types + +### HTTP Transport +- Standard HTTP/HTTPS communication +- Suitable for REST API-based MCP servers +- Supports authentication via `auth_type` + +### SSE (Server-Sent Events) Transport +- Real-time streaming communication +- Ideal for servers that need to push updates +- Commonly used with services like Zapier + +### Stdio Transport +- Standard input/output communication +- Used for local MCP servers or command-line tools +- Requires `command` and optionally `args` and `env` + +## Access Control + +Use `mcp_access_groups` to control which teams or users can access the MCP server tools. This integrates with LiteLLM's permission management system. + +## Cost Tracking + +Configure cost tracking through the `mcp_info.mcp_server_cost_info` block to monitor and control spending on MCP tool usage. diff --git a/terraform/provider/docs/resources/model.md b/terraform/provider/docs/resources/model.md new file mode 100644 index 00000000000..5a46fe2f073 --- /dev/null +++ b/terraform/provider/docs/resources/model.md @@ -0,0 +1,238 @@ +# litellm_model Resource + +Manages a LiteLLM model configuration. This resource allows you to create, update, and delete model configurations in your LiteLLM instance. + +## Example Usage + +### Basic OpenAI Model + +```hcl +resource "litellm_model" "gpt4" { + model_name = "gpt-4-proxy" + custom_llm_provider = "openai" + model_api_key = var.openai_api_key + base_model = "gpt-4" + tier = "paid" + mode = "chat" + + input_cost_per_million_tokens = 30.0 + output_cost_per_million_tokens = 60.0 +} +``` + +### Advanced Model with All Features + +```hcl +resource "litellm_model" "advanced_gpt4" { + model_name = "gpt-4-advanced" + custom_llm_provider = "openai" + model_api_key = var.openai_api_key + model_api_base = "https://api.openai.com/v1" + api_version = "2023-05-15" + base_model = "gpt-4" + tier = "paid" + team_id = "team-123" + mode = "chat" + reasoning_effort = "medium" + thinking_enabled = true + thinking_budget_tokens = 1024 + merge_reasoning_content_in_choices = true + tpm = 100000 + rpm = 1000 + + # Cost configuration (per million tokens) + input_cost_per_million_tokens = 30.0 # $0.03 per 1k tokens = $30 per million + output_cost_per_million_tokens = 60.0 # $0.06 per 1k tokens = $60 per million +} +``` + +### AWS Bedrock Model with Cross-Account Access + +```hcl +resource "litellm_model" "bedrock_claude" { + model_name = "bedrock-claude-proxy" + custom_llm_provider = "bedrock" + base_model = "anthropic.claude-3-sonnet-20240229-v1:0" + tier = "paid" + mode = "chat" + + # AWS configuration with cross-account access + aws_access_key_id = var.aws_access_key_id + aws_secret_access_key = var.aws_secret_access_key + aws_region_name = "us-east-1" + aws_session_name = "litellm-cross-account-session" + aws_role_name = "arn:aws:iam::123456789012:role/LiteLLMCrossAccountRole" + + input_cost_per_million_tokens = 3.0 + output_cost_per_million_tokens = 15.0 +} +``` + +### Anthropic Model + +```hcl +resource "litellm_model" "claude" { + model_name = "claude-proxy" + custom_llm_provider = "anthropic" + model_api_key = var.anthropic_api_key + base_model = "claude-3-sonnet-20240229" + tier = "paid" + mode = "chat" + + input_cost_per_million_tokens = 3.0 + output_cost_per_million_tokens = 15.0 +} +``` + +### Azure OpenAI Model + +```hcl +resource "litellm_model" "azure_gpt4" { + model_name = "azure-gpt4-proxy" + custom_llm_provider = "azure" + model_api_key = var.azure_openai_key + model_api_base = var.azure_openai_endpoint + api_version = "2023-12-01-preview" + base_model = "gpt-4" + tier = "paid" + mode = "chat" + + input_cost_per_million_tokens = 30.0 + output_cost_per_million_tokens = 60.0 +} +``` + +## Argument Reference + +The following arguments are supported: + +* `model_name` - (Required) string. The name of the model configuration used to identify the model in API calls. + +* `custom_llm_provider` - (Required) string. The LLM provider for this model (e.g., "openai", "anthropic", "azure", "bedrock"). + +* `model_api_key` - (Optional) string (Sensitive). The API key for the underlying model provider. Sensitive attributes are hidden from Terraform output but still stored in plaintext in the state file; prefer storing provider secrets in a `litellm_credential` and referencing it via `litellm_credential_name`, and secure your state backend. + +* `model_api_base` - (Optional) string. The base URL for the model provider's API. + +* `api_version` - (Optional) string. The API version to use for the model provider. + +* `base_model` - (Required) string. The actual model identifier from the provider (e.g., "gpt-4", "claude-2"). + +* `litellm_credential_name` - (Optional) string. Name of a LiteLLM credential to use for this model. + +* `tier` - (Optional) string. The usage tier for this model. Valid values are `"free"` or `"paid"`. Default: `"free"`. + +* `team_id` - (Optional) string. Associate the model with a specific team. + +* `mode` - (Optional) string. The intended use of the model. Valid values are: + * `completion` + * `embedding` + * `image_generation` + * `chat` + * `moderation` + * `audio_transcription` + * `audio_speech` + * `rerank` + +* `tpm` - (Optional) integer. Tokens per minute limit for this model. + +* `rpm` - (Optional) integer. Requests per minute limit for this model. + +* `reasoning_effort` - (Optional) string. Configures the model's reasoning effort level. Valid values are: + * `low` + * `medium` + * `high` + +* `thinking_enabled` - (Optional) boolean. Enables the model's thinking capability. Default: `false`. + +* `thinking_budget_tokens` - (Optional) integer. Sets the token budget for the model's thinking capability. Default: `1024`. Note: this field is only relevant when `thinking_enabled = true`. + +* `merge_reasoning_content_in_choices` - (Optional) boolean. When set to `true`, merges reasoning content into the model's choices. + +* `input_cost_per_million_tokens` - (Optional) float. Cost per million input tokens. The provider converts this to a per-token cost sent to the API. + +* `output_cost_per_million_tokens` - (Optional) float. Cost per million output tokens. The provider converts this to a per-token cost sent to the API. + +* `input_cost_per_pixel` - (Optional) float. Cost applied per input pixel for models that charge by image size. + +* `output_cost_per_pixel` - (Optional) float. Cost applied per output pixel for image-generation models. + +* `input_cost_per_second` - (Optional) float. Cost applied per input second for audio/transcription models. + +* `output_cost_per_second` - (Optional) float. Cost applied per output second for audio/transcription models. + +* `vertex_project` - (Optional) string. Vertex AI project id (for `custom_llm_provider = "vertex"`). + +* `vertex_location` - (Optional) string. Vertex AI location (e.g., `us-central1`). + +* `vertex_credentials` - (Optional) string. Vertex credentials (JSON string or path depending on your setup). + +* `additional_litellm_params` - (Optional) map(string). A map of arbitrary additional parameters that will be merged into the `litellm_params` object sent to the LiteLLM API. This is intended for provider-specific or experimental options not exposed as dedicated arguments. + + Conversion and behavior rules (how the provider handles values): + * When values in the map are strings the provider will attempt to coerce them: + * `"true"` / `"false"` (strings) -> boolean true / false + * Numeric strings are parsed first as integers; if integer parsing fails, parsed as floats (e.g., `"16384"` -> 16384, `"0.75"` -> 0.75) + * JSON strings (starting with `[` or `{`) are parsed as JSON objects/arrays + * Non-convertible strings remain strings + * Non-string map values (if supplied) are passed through unchanged. + * The provider merges these keys into the `litellm_params` payload sent to the API. + * Note: the remote API may not echo back all custom parameters; this provider preserves `additional_litellm_params` in state when present in configuration. + + **Special parameter: `additional_drop_params`** + * When `additional_drop_params` is provided as a JSON array string, it specifies parameters to remove from the final `litellm_params` before sending to the API + * This allows you to override or remove built-in parameters if needed + * The `additional_drop_params` key itself is not included in the final parameters + + Example showing booleans, integers, floats, strings, and parameter dropping: + + ```hcl + resource "litellm_model" "with_additional" { + model_name = "custom-model" + custom_llm_provider = "openai" + model_api_key = var.openai_api_key + base_model = "gpt-4" + mode = "chat" + + additional_litellm_params = { + "use_fine_tune" = "true" # becomes boolean true + "max_context" = "16384" # becomes integer 16384 + "scale" = "0.75" # becomes float 0.75 + "note" = "for testing" # stays string + "complex_config" = "{\"nested\": {\"value\": 42}}" # parsed as JSON object + "additional_drop_params" = "[\"reasoningEffort\"]" # removes reasoningEffort parameter + } + } + ``` + +### AWS-specific Configuration + +* `aws_access_key_id` - (Optional) string (Sensitive). AWS access key ID for AWS-based models. + +* `aws_secret_access_key` - (Optional) string (Sensitive). AWS secret access key for AWS-based models. As with `model_api_key`, the value is stored in plaintext in the state file; prefer a `litellm_credential` referenced via `litellm_credential_name` and secure your state backend. + +* `aws_region_name` - (Optional) string. AWS region name for AWS-based models. + +* `aws_session_name` - (Optional) string (Sensitive). AWS session name for cross-account access scenarios. + +* `aws_role_name` - (Optional) string (Sensitive). AWS IAM role name for cross-account access scenarios. + +## Attribute Reference + +In addition to the arguments above, the following attributes are exported: + +* `id` - The ID of the model configuration. + +## Import + +Model configurations can be imported using the model ID: + +```shell +terraform import litellm_model.gpt4 +``` + +Note: The model ID is generated when the model is created and is different from the `model_name`. + +## Security Note + +When using this resource, ensure that sensitive information such as API keys and AWS credentials are stored securely. It's recommended to use environment variables or a secure secret management solution rather than hardcoding these values in your Terraform configuration files. diff --git a/terraform/provider/docs/resources/team.md b/terraform/provider/docs/resources/team.md new file mode 100644 index 00000000000..68535309f10 --- /dev/null +++ b/terraform/provider/docs/resources/team.md @@ -0,0 +1,130 @@ +# litellm_team Resource + +Manages a team configuration in LiteLLM. Teams allow you to group users and manage their access to models and usage limits. + +## Example Usage + +### Basic Team Configuration + +```hcl +resource "litellm_team" "engineering" { + team_alias = "engineering-team" + models = ["gpt-4-proxy", "claude-2"] + max_budget = 1000.0 +} +``` + +### Team with Comprehensive Configuration + +```hcl +resource "litellm_team" "advanced_team" { + team_alias = "ai-research-team" + organization_id = "org_123456" + models = ["gpt-4-proxy", "claude-2", "gpt-3.5-turbo"] + + # Budget and rate limiting + max_budget = 1000.0 + budget_duration = "1mo" + tpm_limit = 500000 + rpm_limit = 5000 + blocked = false + + # Team member permissions + team_member_permissions = [ + "create_key", + "delete_key", + "view_spend", + "edit_team" + ] + + # Metadata for organization + metadata = { + department = "Engineering" + project = "AI Research" + cost_center = "R&D-001" + } +} +``` + +### Team with Model Dependencies + +```hcl +# First create models +resource "litellm_model" "gpt4" { + model_name = "gpt-4-proxy" + custom_llm_provider = "openai" + base_model = "gpt-4" + model_api_key = var.openai_api_key +} + +resource "litellm_model" "claude" { + model_name = "claude-proxy" + custom_llm_provider = "anthropic" + base_model = "claude-3-sonnet-20240229" + model_api_key = var.anthropic_api_key +} + +# Then create team with access to these models +resource "litellm_team" "model_dependent_team" { + team_alias = "model-users" + models = [ + litellm_model.gpt4.model_name, + litellm_model.claude.model_name + ] + + max_budget = 500.0 + budget_duration = "1mo" + + team_member_permissions = [ + "view_spend" + ] +} +``` + +## Argument Reference + +The following arguments are supported: + +* `team_alias` - (Required) A human-readable identifier for the team. + +* `organization_id` - (Optional) The ID of the organization this team belongs to. + +* `models` - (Optional) List of model names that this team can access. + +* `metadata` - (Optional) A map of metadata key-value pairs associated with the team. + +* `blocked` - (Optional) Whether the team is blocked from making requests. Default is `false`. + +* `tpm_limit` - (Optional) Team-wide tokens per minute limit. + +* `rpm_limit` - (Optional) Team-wide requests per minute limit. + +* `max_budget` - (Optional) Maximum budget allocated to the team. + +* `budget_duration` - (Optional) Duration for the budget cycle. Valid values are: + * `daily` + * `weekly` + * `monthly` + * `yearly` + +* `team_member_permissions` - (Optional) List of permissions granted to team members. This controls what actions team members can perform within the team context. + +## Attribute Reference + +In addition to the arguments above, the following attributes are exported: + +* `id` - The unique identifier for the team. + +## Import + +Teams can be imported using the team ID: + +```shell +terraform import litellm_team.engineering +``` + +Note: The team ID is generated when the team is created and is different from the `team_alias`. + +## Note on Team Members + +Team members are managed through the separate `litellm_team_member` resource. This allows for more granular control over team membership and permissions. See the `litellm_team_member` resource documentation for details on managing team members. diff --git a/terraform/provider/docs/resources/team_member.md b/terraform/provider/docs/resources/team_member.md new file mode 100644 index 00000000000..426d8b8892f --- /dev/null +++ b/terraform/provider/docs/resources/team_member.md @@ -0,0 +1,54 @@ +# litellm_team_member Resource + +Manages individual team member configurations in LiteLLM. This resource allows you to add, update, and remove team members with specific permissions and budget limits. + +## Example Usage + +```hcl +resource "litellm_team_member" "engineer" { + team_id = litellm_team.engineering.id + user_id = "user_3" + user_email = "engineer@example.com" + role = "user" + max_budget_in_team = 200.0 +} +``` + +## Argument Reference + +The following arguments are supported: + +* `team_id` - (Required) The ID of the team this member belongs to. + +* `user_id` - (Required) Unique identifier for the user. + +* `user_email` - (Required) Email address of the user. + +* `role` - (Required) The role of the team member. Valid values are: + * `org_admin` + * `internal_user` + * `internal_user_viewer` + * `admin` + * `user` + +* `max_budget_in_team` - (Optional) Maximum budget allocated to this team member within the team's budget. + +## Attribute Reference + +In addition to the arguments above, the following attributes are exported: + +* `id` - The unique identifier for the team member configuration. This is typically a composite of the team_id and user_id. + +## Import + +Team members can be imported using the format `team_id:user_id`: + +```shell +terraform import litellm_team_member.engineer : +``` + +Note: The team_id and user_id should match the values used in the resource configuration. + +## Security Note + +Ensure that sensitive information such as user emails and IDs are handled securely. It's recommended to use variables or a secure secret management solution rather than hardcoding these values in your Terraform configuration files. diff --git a/terraform/provider/docs/resources/team_member_add.md b/terraform/provider/docs/resources/team_member_add.md new file mode 100644 index 00000000000..f5398e49d9c --- /dev/null +++ b/terraform/provider/docs/resources/team_member_add.md @@ -0,0 +1,161 @@ +# Resource: litellm_team_member_add + +Add multiple members to a team with a single resource. This resource efficiently manages team members by using the appropriate API endpoints for each operation: + +- **Adding new members**: Uses `/team/member_add` endpoint +- **Updating existing members**: Uses `/team/member_update` endpoint (preserves member identity) +- **Removing members**: Uses `/team/member_delete` endpoint + +When you modify an existing team member's attributes (like role), the resource will update the member in-place rather than deleting and re-adding them. + +## Example Usage + +### Basic Usage + +```hcl +resource "litellm_team_member_add" "example" { + team_id = "team-123" + + member { + user_id = "user-456" + role = "admin" + } + + member { + user_email = "user@example.com" + role = "user" + } + + max_budget_in_team = 100.0 +} +``` + +### Complete Team Setup with Members + +```hcl +# First create a team +resource "litellm_team" "development" { + team_alias = "development-team" + max_budget = 500.0 + models = ["gpt-4", "gpt-3.5-turbo"] + + team_member_permissions = [ + "create_key", + "view_spend" + ] +} + +# Add members to the team +resource "litellm_team_member_add" "dev_team_members" { + team_id = litellm_team.development.id + + # Team lead with admin role + member { + user_email = "team-lead@company.com" + role = "admin" + } + + # Regular developers + member { + user_email = "developer1@company.com" + role = "user" + } + + member { + user_email = "developer2@company.com" + role = "user" + } + + member { + user_id = "existing-user-123" + role = "user" + } + + # Budget per member + max_budget_in_team = 100.0 +} +``` + +### Dynamic Members Using Locals + +```hcl +locals { + team_members = [ + { + user_id = "user-123" + role = "admin" + }, + { + user_email = "developer1@company.com" + role = "user" + }, + { + user_email = "developer2@company.com" + role = "user" + } + ] +} + +resource "litellm_team_member_add" "dynamic_example" { + team_id = "team-456" + + dynamic "member" { + for_each = local.team_members + content { + user_id = lookup(member.value, "user_id", null) + user_email = lookup(member.value, "user_email", null) + role = member.value.role + } + } + + max_budget_in_team = 200.0 +} +``` + +### Budget Update Example + +```hcl +# This example demonstrates how budget updates work correctly +resource "litellm_team_member_add" "budget_example" { + team_id = litellm_team.example.id + + # Initial budget of $100 per member + max_budget_in_team = 100.0 + + member { + user_email = "user1@example.com" + role = "admin" + } + + member { + user_email = "user2@example.com" + role = "user" + } + + member { + user_id = "user123" + role = "user" + } +} + +# To update the budget: +# 1. Change max_budget_in_team from 100.0 to 120.0 +# 2. Run terraform plan - it will show the budget change +# 3. Run terraform apply - all existing members will be updated with the new budget +``` + +## Argument Reference + +* `team_id` - (Required) The ID of the team to add members to. +* `member` - (Required) One or more member blocks defining team members. Each block supports: + * `user_id` - (Optional) The ID of the user to add to the team. + * `user_email` - (Optional) The email of the user to add to the team. + * `role` - (Required) The role of the user in the team. Must be one of: "admin" or "user". +* `max_budget_in_team` - (Optional) The maximum budget allocated for the team members. + +## Import + +Team members can be imported using a composite ID of the team ID and user ID: + +```shell +terraform import litellm_team_member_add.example team-123:user-456 diff --git a/terraform/provider/docs/resources/vector_store.md b/terraform/provider/docs/resources/vector_store.md new file mode 100644 index 00000000000..b839b327429 --- /dev/null +++ b/terraform/provider/docs/resources/vector_store.md @@ -0,0 +1,274 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "litellm_vector_store Resource - terraform-provider-litellm" +subcategory: "" +description: |- + Manages a LiteLLM vector store for storing and retrieving vector embeddings. +--- + +# litellm_vector_store (Resource) + +Manages a LiteLLM vector store for storing and retrieving vector embeddings. Vector stores enable semantic search and retrieval-augmented generation (RAG) capabilities using officially supported providers including AWS Bedrock Knowledge Bases, OpenAI Vector Stores, Azure Vector Stores, Vertex AI RAG Engine, and PG Vector. + +## Example Usage + +### AWS Bedrock Knowledge Base + +```terraform +resource "litellm_credential" "bedrock_cred" { + credential_name = "bedrock-knowledge-base" + + credential_info = { + provider = "bedrock" + region = "us-east-1" + } + + credential_values = { + aws_access_key_id = var.aws_access_key_id + aws_secret_access_key = var.aws_secret_access_key + aws_region = "us-east-1" + } +} + +resource "litellm_vector_store" "bedrock_kb" { + vector_store_name = "bedrock-litellm-website-knowledgebase" + custom_llm_provider = "bedrock" + litellm_credential_name = litellm_credential.bedrock_cred.credential_name + + vector_store_description = "Bedrock vector store for the LiteLLM website knowledgebase" + + vector_store_metadata = { + source = "https://www.litellm.com/docs" + } + + litellm_params = { + vector_store_id = "T37J8R4WTM" + } +} +``` + +### OpenAI Vector Store + +```terraform +resource "litellm_credential" "openai_cred" { + credential_name = "openai-vector-store" + + credential_info = { + provider = "openai" + } + + credential_values = { + api_key = var.openai_api_key + } +} + +resource "litellm_vector_store" "openai_store" { + vector_store_name = "openai-knowledge-base" + custom_llm_provider = "openai" + litellm_credential_name = litellm_credential.openai_cred.credential_name + + vector_store_description = "OpenAI vector store for document search" + + vector_store_metadata = { + environment = "production" + purpose = "file-search" + } + + litellm_params = { + vector_store_id = "vs_687ae3b2439881918b433cb99d10662e" + } +} +``` + +### Azure Vector Store + +```terraform +resource "litellm_credential" "azure_cred" { + credential_name = "azure-vector-store" + + credential_info = { + provider = "azure" + } + + credential_values = { + api_key = var.azure_openai_key + api_base = var.azure_openai_endpoint + api_version = "2023-12-01-preview" + } +} + +resource "litellm_vector_store" "azure_store" { + vector_store_name = "azure-knowledge-base" + custom_llm_provider = "azure" + litellm_credential_name = litellm_credential.azure_cred.credential_name + + vector_store_description = "Azure vector store for enterprise search" + + vector_store_metadata = { + environment = "production" + team = "enterprise" + } + + litellm_params = { + vector_store_id = "vs_azure_example_id" + } +} +``` + +### Vertex AI RAG Engine + +```terraform +resource "litellm_credential" "vertex_cred" { + credential_name = "vertex-rag-engine" + + credential_info = { + provider = "vertex_ai" + project = "your-gcp-project" + } + + credential_values = { + service_account_key = var.gcp_service_account_key + } +} + +resource "litellm_vector_store" "vertex_rag" { + vector_store_name = "vertex-rag-corpus" + custom_llm_provider = "vertex_ai" + litellm_credential_name = litellm_credential.vertex_cred.credential_name + + vector_store_description = "Vertex AI RAG Engine for enterprise knowledge" + + vector_store_metadata = { + project = "your-gcp-project" + environment = "production" + } + + litellm_params = { + vector_store_id = "6917529027641081856" + } +} +``` + +### PG Vector Store + +```terraform +resource "litellm_credential" "pgvector_cred" { + credential_name = "pgvector-store" + + credential_info = { + provider = "pgvector" + host = "your-pgvector-host.com" + } + + credential_values = { + api_key = var.pgvector_api_key + api_base = "https://your-pgvector-host.com" + } +} + +resource "litellm_vector_store" "pgvector_store" { + vector_store_name = "postgres-vector-store" + custom_llm_provider = "pgvector" + litellm_credential_name = litellm_credential.pgvector_cred.credential_name + + vector_store_description = "PostgreSQL vector store with pgvector extension" + + vector_store_metadata = { + database = "vector_db" + table = "embeddings" + environment = "production" + } + + litellm_params = { + api_base = "https://your-pgvector-host.com" + } +} +``` + +## Argument Reference + +The following arguments are supported: + +* `vector_store_name` - (Required) Name of the vector store. +* `custom_llm_provider` - (Required) The vector store provider. Supported values: "bedrock", "openai", "azure", "vertex_ai", "pgvector". +* `vector_store_description` - (Optional) Description of the vector store. +* `vector_store_metadata` - (Optional) Map of metadata associated with the vector store. +* `litellm_credential_name` - (Optional) Name of the LiteLLM credential to use for authentication. +* `litellm_params` - (Optional, Sensitive) Map of additional parameters specific to the vector store provider. Do not put API keys or other secrets here; this map is stored unencrypted in state. Store secrets in a `litellm_credential` and reference it via `litellm_credential_name`. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `vector_store_id` - The unique identifier of the vector store. +* `created_at` - Timestamp when the vector store was created. +* `updated_at` - Timestamp when the vector store was last updated. + +## Supported Providers + +The following vector store providers are officially supported by LiteLLM: + +* **AWS Bedrock Knowledge Bases** - Managed knowledge bases on AWS Bedrock +* **OpenAI Vector Stores** - OpenAI's native vector store service +* **Azure Vector Stores** - Azure OpenAI vector store integration +* **Vertex AI RAG Engine** - Google Cloud's RAG API for vector search +* **PG Vector** - PostgreSQL with pgvector extension + +## Provider-Specific Parameters + +### AWS Bedrock Knowledge Base + +```terraform +litellm_params = { + vector_store_id = "T37J8R4WTM" # Your Bedrock Knowledge Base ID +} +``` + +### OpenAI Vector Store + +```terraform +litellm_params = { + vector_store_id = "vs_687ae3b2439881918b433cb99d10662e" # Your OpenAI Vector Store ID +} +``` + +### Azure Vector Store + +```terraform +litellm_params = { + vector_store_id = "vs_azure_example_id" # Your Azure Vector Store ID +} +``` + +### Vertex AI RAG Engine + +```terraform +litellm_params = { + vector_store_id = "6917529027641081856" # Your Vertex AI RAG Engine ID +} +``` + +### PG Vector + +```terraform +litellm_params = { + api_base = "https://your-pgvector-host.com" +} +``` + +## Import + +Vector stores can be imported using their ID: + +```shell +terraform import litellm_vector_store.example "vector-store-id" +``` + +## Notes + +* Vector stores require appropriate credentials for the chosen provider. +* The `litellm_params` field allows provider-specific configuration. +* Some providers may require additional setup outside of Terraform (e.g., creating Knowledge Bases in AWS Bedrock, Vector Stores in OpenAI). +* Ensure your vector store provider is properly configured and accessible from your LiteLLM instance. +* Only the officially supported providers listed above are guaranteed to work with LiteLLM's vector store integration. +* For the most up-to-date list of supported providers, refer to the [LiteLLM documentation](https://docs.litellm.ai/docs/completion/knowledgebase). diff --git a/terraform/provider/examples/model_additional_params.tf b/terraform/provider/examples/model_additional_params.tf new file mode 100644 index 00000000000..fb4981ec6fb --- /dev/null +++ b/terraform/provider/examples/model_additional_params.tf @@ -0,0 +1,57 @@ +provider "litellm" { + api_base = "https://your-litellm-proxy.com" + api_key = var.litellm_api_key +} + +# Example: using additional_litellm_params to pass provider-specific options. +# Notes: +# - String values "true"/"false" will be coerced to booleans. +# - Numeric strings will be parsed to integer (if possible) otherwise float. +# - JSON strings (starting with [ or {) will be parsed as JSON objects/arrays. +# - Non-convertible strings remain strings. +# - Non-string map values are passed through unchanged. +# - Use "additional_drop_params" as a JSON array to remove parameters from the final request. + +resource "litellm_model" "with_additional" { + model_name = "custom-model" + custom_llm_provider = "openai" + model_api_key = var.openai_api_key + base_model = "gpt-4" + mode = "chat" + + # Additional parameters not exposed as first-class arguments + additional_litellm_params = { + "use_fine_tune" = "true" # becomes boolean true + "max_context" = "16384" # becomes integer 16384 + "temperature_scale" = "0.75" # becomes float 0.75 + "experimental_feature" = "enabled" # stays string "enabled" + "complex_config" = "{\"nested\": {\"value\": 42}}" # parsed as JSON object + "additional_drop_params" = "[\"reasoningEffort\"]" # removes reasoningEffort parameter + # You may also pass non-string values (they will be passed through unchanged) + # "raw_flag" = true + } + + # Cost configuration (optional) + input_cost_per_million_tokens = 30.0 + output_cost_per_million_tokens = 60.0 +} + +# Example: Azure model with parameter dropping +resource "litellm_model" "azure_with_drop_params" { + model_name = "gpt-5-mini-coder" + custom_llm_provider = "azure" + model_api_key = "your-azure-api-key" + model_api_base = "https://your-azure-endpoint.openai.azure.com/" + api_version = "2025-03-01-preview" + base_model = "gpt-5-mini" + tier = "paid" + mode = "completion" + + # Drop the reasoningEffort parameter that might be automatically added + additional_litellm_params = { + "additional_drop_params" = "[\"reasoningEffort\"]" + } + + input_cost_per_million_tokens = 0.25 + output_cost_per_million_tokens = 2.00 +} diff --git a/terraform/provider/go.mod b/terraform/provider/go.mod new file mode 100644 index 00000000000..899af1a6fbe --- /dev/null +++ b/terraform/provider/go.mod @@ -0,0 +1,61 @@ +module github.com/BerriAI/terraform-provider-litellm + +go 1.25.0 + +require ( + github.com/google/uuid v1.6.0 + github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0 +) + +require ( + github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/agext/levenshtein v1.2.2 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/fatih/color v1.16.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/hashicorp/go-checkpoint v0.5.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-cty v1.5.0 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-plugin v1.7.0 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/go-version v1.8.0 // indirect + github.com/hashicorp/hc-install v0.9.3 // indirect + github.com/hashicorp/hcl/v2 v2.24.0 // indirect + github.com/hashicorp/logutils v1.0.0 // indirect + github.com/hashicorp/terraform-exec v0.25.0 // indirect + github.com/hashicorp/terraform-json v0.27.2 // indirect + github.com/hashicorp/terraform-plugin-go v0.31.0 // indirect + github.com/hashicorp/terraform-plugin-log v0.10.0 // indirect + github.com/hashicorp/terraform-registry-address v0.4.0 // indirect + github.com/hashicorp/terraform-svchost v0.1.1 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/oklog/run v1.1.0 // indirect + github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/zclconf/go-cty v1.17.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.41.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.2 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/terraform/provider/go.sum b/terraform/provider/go.sum new file mode 100644 index 00000000000..890703d4f8a --- /dev/null +++ b/terraform/provider/go.sum @@ -0,0 +1,239 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/agext/levenshtein v1.2.2 h1:0S/Yg6LYmFJ5stwQeRp6EeOcCbj7xiqQSdNelsXvaqE= +github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= +github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= +github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= +github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-checkpoint v0.5.0 h1:MFYpPZCnQqQTE18jFwSII6eUQrD/oxMFp3mlgcqk5mU= +github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-cty v1.5.0 h1:EkQ/v+dDNUqnuVpmS5fPqyY71NXVgT5gf32+57xY8g0= +github.com/hashicorp/go-cty v1.5.0/go.mod h1:lFUCG5kd8exDobgSfyj4ONE/dc822kiYMguVKdHGMLM= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= +github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= +github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/hc-install v0.9.3 h1:1H4dgmgzxEVwT6E/d/vIL5ORGVKz9twRwDw+qA5Hyho= +github.com/hashicorp/hc-install v0.9.3/go.mod h1:FQlQ5I3I/X409N/J1U4pPeQQz1R3BoV0IysB7aiaQE0= +github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= +github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= +github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/terraform-exec v0.25.0 h1:Bkt6m3VkJqYh+laFMrWIpy9KHYFITpOyzRMNI35rNaY= +github.com/hashicorp/terraform-exec v0.25.0/go.mod h1:dl9IwsCfklDU6I4wq9/StFDp7dNbH/h5AnfS1RmiUl8= +github.com/hashicorp/terraform-json v0.27.2 h1:BwGuzM6iUPqf9JYM/Z4AF1OJ5VVJEEzoKST/tRDBJKU= +github.com/hashicorp/terraform-json v0.27.2/go.mod h1:GzPLJ1PLdUG5xL6xn1OXWIjteQRT2CNT9o/6A9mi9hE= +github.com/hashicorp/terraform-plugin-go v0.31.0 h1:0Fz2r9DQ+kNNl6bx8HRxFd1TfMKUvnrOtvJPmp3Z0q8= +github.com/hashicorp/terraform-plugin-go v0.31.0/go.mod h1:A88bDhd/cW7FnwqxQRz3slT+QY6yzbHKc6AOTtmdeS8= +github.com/hashicorp/terraform-plugin-log v0.10.0 h1:eu2kW6/QBVdN4P3Ju2WiB2W3ObjkAsyfBsL3Wh1fj3g= +github.com/hashicorp/terraform-plugin-log v0.10.0/go.mod h1:/9RR5Cv2aAbrqcTSdNmY1NRHP4E3ekrXRGjqORpXyB0= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0 h1:MKS/2URqeJRwJdbOfcbdsZCq/IRrNkqJNN0GtVIsuGs= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0/go.mod h1:PuG4P97Ju3QXW6c6vRkRadWJbvnEu2Xh+oOuqcYOqX4= +github.com/hashicorp/terraform-registry-address v0.4.0 h1:S1yCGomj30Sao4l5BMPjTGZmCNzuv7/GDTDX99E9gTk= +github.com/hashicorp/terraform-registry-address v0.4.0/go.mod h1:LRS1Ay0+mAiRkUyltGT+UHWkIqTFvigGn/LbMshfflE= +github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= +github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= +github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zclconf/go-cty v1.17.0 h1:seZvECve6XX4tmnvRzWtJNHdscMtYEx5R7bnnVyd/d0= +github.com/zclconf/go-cty v1.17.0/go.mod h1:wqFzcImaLTI6A5HfsRwB0nj5n0MRZFwmey8YoFPPs3U= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/terraform/provider/litellm/client.go b/terraform/provider/litellm/client.go new file mode 100644 index 00000000000..e0aba61477d --- /dev/null +++ b/terraform/provider/litellm/client.go @@ -0,0 +1,386 @@ +package litellm + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "regexp" + "strings" +) + +type Client struct { + APIBase string + APIKey string + httpClient *http.Client + InsecureSkipVerify bool +} + +func NewClient(apiBase, apiKey string, insecureSkipVerify bool) *Client { + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: insecureSkipVerify}, + } + + return &Client{ + APIBase: apiBase, + APIKey: apiKey, + httpClient: &http.Client{Transport: tr}, + InsecureSkipVerify: insecureSkipVerify, + } +} + +// Organization member methods +func (c *Client) AddOrganizationMember(data map[string]interface{}) (map[string]interface{}, error) { + return c.sendRequest("POST", "/organization/member_add", data) +} + +func (c *Client) UpdateOrganizationMember(data map[string]interface{}) (map[string]interface{}, error) { + return c.sendRequest("PATCH", "/organization/member_update", data) +} + +func (c *Client) DeleteOrganizationMember(data map[string]interface{}) (map[string]interface{}, error) { + return c.sendRequest("DELETE", "/organization/member_delete", data) +} + +// Key-related methods +func (c *Client) CreateKey(key *Key) (*Key, error) { + resp, err := c.sendRequest("POST", "/key/generate", key) + if err != nil { + return nil, err + } + + return c.parseKeyResponse(resp) +} + +func (c *Client) GetKey(keyID string) (*Key, error) { + resp, err := c.sendRequest("GET", fmt.Sprintf("/key/info?key=%s", keyID), nil) + if err != nil { + return nil, err + } + + return c.parseKeyResponse(resp) +} + +func (c *Client) UpdateKey(key *Key) (*Key, error) { + // Create a new map with only the fields that can be updated + updateData := map[string]interface{}{ + "key": key.Key, + "team_id": key.TeamID, + "metadata": key.Metadata, + "budget_duration": key.BudgetDuration, + "key_alias": key.KeyAlias, + "aliases": key.Aliases, + "permissions": key.Permissions, + "model_max_budget": key.ModelMaxBudget, + "model_rpm_limit": key.ModelRPMLimit, + "model_tpm_limit": key.ModelTPMLimit, + "blocked": key.Blocked, + } + + // Only add pointer fields if they are explicitly set + if key.MaxBudget != nil { + updateData["max_budget"] = *key.MaxBudget + } + if key.SoftBudget != nil { + updateData["soft_budget"] = *key.SoftBudget + } + if key.MaxParallelRequests != nil { + updateData["max_parallel_requests"] = *key.MaxParallelRequests + } + if key.TPMLimit != nil { + updateData["tpm_limit"] = *key.TPMLimit + } + if key.RPMLimit != nil { + updateData["rpm_limit"] = *key.RPMLimit + } + + // Only add array fields if they are non-empty + if len(key.Models) > 0 { + updateData["models"] = key.Models + } + if len(key.Guardrails) > 0 { + updateData["guardrails"] = key.Guardrails + } + if len(key.Tags) > 0 { + updateData["tags"] = key.Tags + } + + resp, err := c.sendRequest("POST", "/key/update", updateData) + if err != nil { + return nil, err + } + + return c.parseKeyResponse(resp) +} + +func (c *Client) DeleteKey(keyID string) error { + payload := map[string]interface{}{ + "keys": []string{keyID}, + } + _, err := c.sendRequest("POST", "/key/delete", payload) + return err +} + +func (c *Client) parseKeyResponse(resp map[string]interface{}) (*Key, error) { + if resp == nil { + return nil, fmt.Errorf("received nil response") + } + + createdKey := &Key{} + + for k, v := range resp { + if v == nil { + continue + } + + switch k { + case "key": + if s, ok := v.(string); ok { + createdKey.Key = s + } + case "token_id": + if s, ok := v.(string); ok { + createdKey.TokenID = s + } + case "models": + if models, ok := v.([]interface{}); ok { + createdKey.Models = make([]string, len(models)) + for i, model := range models { + if s, ok := model.(string); ok { + createdKey.Models[i] = s + } + } + } + case "spend": + if f, ok := v.(float64); ok { + createdKey.Spend = f + } + case "max_budget": + if f, ok := v.(float64); ok { + createdKey.MaxBudget = &f + } + case "user_id": + if s, ok := v.(string); ok { + createdKey.UserID = s + } + case "team_id": + if s, ok := v.(string); ok { + createdKey.TeamID = s + } + case "max_parallel_requests": + if i, ok := v.(float64); ok { + val := int(i) + createdKey.MaxParallelRequests = &val + } + case "metadata": + if m, ok := v.(map[string]interface{}); ok { + createdKey.Metadata = m + } + case "tpm_limit": + if i, ok := v.(float64); ok { + val := int(i) + createdKey.TPMLimit = &val + } + case "rpm_limit": + if i, ok := v.(float64); ok { + val := int(i) + createdKey.RPMLimit = &val + } + case "budget_duration": + if s, ok := v.(string); ok { + createdKey.BudgetDuration = s + } + case "soft_budget": + if f, ok := v.(float64); ok { + createdKey.SoftBudget = &f + } + case "key_alias": + if s, ok := v.(string); ok { + createdKey.KeyAlias = s + } + case "duration": + if s, ok := v.(string); ok { + createdKey.Duration = s + } + case "aliases": + if m, ok := v.(map[string]interface{}); ok { + createdKey.Aliases = m + } + case "config": + if m, ok := v.(map[string]interface{}); ok { + createdKey.Config = m + } + case "permissions": + if m, ok := v.(map[string]interface{}); ok { + createdKey.Permissions = m + } + case "model_max_budget": + if m, ok := v.(map[string]interface{}); ok { + createdKey.ModelMaxBudget = m + } + case "model_rpm_limit": + if m, ok := v.(map[string]interface{}); ok { + createdKey.ModelRPMLimit = m + } + case "model_tpm_limit": + if m, ok := v.(map[string]interface{}); ok { + createdKey.ModelTPMLimit = m + } + case "guardrails": + if guardrails, ok := v.([]interface{}); ok { + createdKey.Guardrails = make([]string, len(guardrails)) + for i, guardrail := range guardrails { + if s, ok := guardrail.(string); ok { + createdKey.Guardrails[i] = s + } + } + } + case "blocked": + if b, ok := v.(bool); ok { + createdKey.Blocked = b + } + case "tags": + if tags, ok := v.([]interface{}); ok { + createdKey.Tags = make([]string, len(tags)) + for i, tag := range tags { + if s, ok := tag.(string); ok { + createdKey.Tags[i] = s + } + } + } + } + } + + return createdKey, nil +} + +func (c *Client) sendRequest(method, path string, body interface{}) (map[string]interface{}, error) { + url := c.APIBase + path + + var req *http.Request + var err error + + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("error marshaling request body: %v", err) + } + log.Printf("Making %s request to %s with body:\n%s", method, url, c.redactSensitiveData(string(jsonBody))) + req, err = http.NewRequest(method, url, bytes.NewBuffer(jsonBody)) + } else { + log.Printf("Making %s request to %s", method, url) + req, err = http.NewRequest(method, url, nil) + } + + if err != nil { + return nil, fmt.Errorf("error creating request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", c.APIKey) + req.Header.Set("accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("error making request: %v", err) + } + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %v", err) + } + + log.Printf("Response status: %d", resp.StatusCode) + log.Printf("Response body: %s", c.redactSensitiveData(string(bodyBytes))) + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API request failed with status code %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var result map[string]interface{} + if err := json.Unmarshal(bodyBytes, &result); err != nil { + if (method == "POST" || method == "PATCH" || method == "PUT" || method == "DELETE") && + (len(bodyBytes) == 0 || string(bodyBytes) == "null") { + return make(map[string]interface{}), nil + } + return nil, fmt.Errorf("error parsing response JSON: %v\nResponse body: %s", err, string(bodyBytes)) + } + + return result, nil +} + +var sensitiveLogFields = map[string]bool{ + "api_key": true, + "key": true, + "token": true, + "password": true, + "secret": true, + "credential": true, + "auth": true, + "model_api_key": true, + "aws_access_key_id": true, + "aws_secret_access_key": true, + "vertex_credentials": true, + "x-api-key": true, + "credential_values": true, +} + +func redactJSONValue(value interface{}) interface{} { + switch typed := value.(type) { + case map[string]interface{}: + redacted := make(map[string]interface{}, len(typed)) + for k, v := range typed { + if sensitiveLogFields[k] { + redacted[k] = "[REDACTED]" + } else { + redacted[k] = redactJSONValue(v) + } + } + return redacted + case []interface{}: + redacted := make([]interface{}, len(typed)) + for i, v := range typed { + redacted[i] = redactJSONValue(v) + } + return redacted + default: + return value + } +} + +var sensitiveLogPatterns = []*regexp.Regexp{ + regexp.MustCompile(`"(api_key|key|token|password|secret|credential|auth)":\s*"[^"]*"`), + regexp.MustCompile(`"(model_api_key|aws_access_key_id|aws_secret_access_key|vertex_credentials)":\s*"[^"]*"`), + regexp.MustCompile(`"(x-api-key)":\s*"[^"]*"`), +} + +func redactWithPatterns(data string) string { + result := data + for _, re := range sensitiveLogPatterns { + result = re.ReplaceAllStringFunc(result, func(match string) string { + parts := strings.SplitN(match, ":", 2) + if len(parts) == 2 { + return parts[0] + `: "[REDACTED]"` + } + return "[REDACTED]" + }) + } + return result +} + +// redactSensitiveData masks sensitive information in logs +func (c *Client) redactSensitiveData(data string) string { + var parsed interface{} + if err := json.Unmarshal([]byte(data), &parsed); err != nil { + return redactWithPatterns(data) + } + redactedBytes, err := json.Marshal(redactJSONValue(parsed)) + if err != nil { + return redactWithPatterns(data) + } + return string(redactedBytes) +} diff --git a/terraform/provider/litellm/client_test.go b/terraform/provider/litellm/client_test.go new file mode 100644 index 00000000000..56f76565616 --- /dev/null +++ b/terraform/provider/litellm/client_test.go @@ -0,0 +1,71 @@ +package litellm + +import ( + "strings" + "testing" +) + +func TestRedactSensitiveDataNestedCredentialValues(t *testing.T) { + c := NewClient("http://localhost:4000", "sk-test", false) + + input := `{"credential_name":"azure-cred","credential_values":{"api_key":"sk-secret-123","config":{"region":"us-east-1","client_secret":"nested-secret"}}}` + got := c.redactSensitiveData(input) + + for _, leaked := range []string{"sk-secret-123", "us-east-1", "nested-secret"} { + if strings.Contains(got, leaked) { + t.Errorf("redacted output leaked %q: %s", leaked, got) + } + } + if !strings.Contains(got, `"credential_values":"[REDACTED]"`) { + t.Errorf("credential_values not redacted: %s", got) + } + if !strings.Contains(got, `"credential_name":"azure-cred"`) { + t.Errorf("non-sensitive field mangled: %s", got) + } +} + +func TestRedactSensitiveDataDeeplyNestedSensitiveKeys(t *testing.T) { + c := NewClient("http://localhost:4000", "sk-test", false) + + input := `{"data":[{"litellm_params":{"model":"gpt-4","api_key":"sk-deep-456","aws_secret_access_key":"aws-secret"}}]}` + got := c.redactSensitiveData(input) + + for _, leaked := range []string{"sk-deep-456", "aws-secret"} { + if strings.Contains(got, leaked) { + t.Errorf("redacted output leaked %q: %s", leaked, got) + } + } + if !strings.Contains(got, `"model":"gpt-4"`) { + t.Errorf("non-sensitive field mangled: %s", got) + } +} + +func TestRedactSensitiveDataTopLevelStringFields(t *testing.T) { + c := NewClient("http://localhost:4000", "sk-test", false) + + input := `{"model_api_key":"sk-top-789","vertex_credentials":"{\"type\":\"service_account\"}","team_alias":"eng"}` + got := c.redactSensitiveData(input) + + for _, leaked := range []string{"sk-top-789", "service_account"} { + if strings.Contains(got, leaked) { + t.Errorf("redacted output leaked %q: %s", leaked, got) + } + } + if !strings.Contains(got, `"team_alias":"eng"`) { + t.Errorf("non-sensitive field mangled: %s", got) + } +} + +func TestRedactSensitiveDataNonJSONFallback(t *testing.T) { + c := NewClient("http://localhost:4000", "sk-test", false) + + input := `error before "api_key": "sk-fallback-000" after` + got := c.redactSensitiveData(input) + + if strings.Contains(got, "sk-fallback-000") { + t.Errorf("fallback redaction leaked secret: %s", got) + } + if !strings.Contains(got, "[REDACTED]") { + t.Errorf("fallback redaction did not redact: %s", got) + } +} diff --git a/terraform/provider/litellm/data_source_credential.go b/terraform/provider/litellm/data_source_credential.go new file mode 100644 index 00000000000..e4533546a67 --- /dev/null +++ b/terraform/provider/litellm/data_source_credential.go @@ -0,0 +1,73 @@ +package litellm + +import ( + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceLiteLLMCredential() *schema.Resource { + return &schema.Resource{ + Read: dataSourceLiteLLMCredentialRead, + + Schema: map[string]*schema.Schema{ + "credential_name": { + Type: schema.TypeString, + Required: true, + Description: "Name of the credential to retrieve", + }, + "model_id": { + Type: schema.TypeString, + Optional: true, + Description: "Model ID associated with this credential", + }, + "credential_info": { + Type: schema.TypeMap, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Additional information about the credential", + }, + // Note: credential_values are not exposed in data sources for security reasons + }, + } +} + +func dataSourceLiteLLMCredentialRead(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + credentialName := d.Get("credential_name").(string) + modelID := d.Get("model_id").(string) + + // Use the same endpoint as the resource read operation + endpoint := fmt.Sprintf("/credentials/by_name/%s", credentialName) + if modelID != "" { + endpoint += fmt.Sprintf("?model_id=%s", modelID) + } + + resp, err := MakeRequest(client, "GET", endpoint, nil) + if err != nil { + return fmt.Errorf("failed to read credential: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("credential '%s' not found", credentialName) + } + + var credentialResp CredentialResponse + err = handleCredentialAPIResponse(resp, &credentialResp, client) + if err != nil { + if err.Error() == "credential_not_found" { + return fmt.Errorf("credential '%s' not found", credentialName) + } + return fmt.Errorf("failed to read credential: %w", err) + } + + // Set the data source ID to the credential name + d.SetId(credentialResp.CredentialName) + d.Set("credential_name", credentialResp.CredentialName) + d.Set("credential_info", credentialResp.CredentialInfo) + // Note: We don't expose credential_values in data sources for security reasons + + return nil +} diff --git a/terraform/provider/litellm/data_source_vector_store.go b/terraform/provider/litellm/data_source_vector_store.go new file mode 100644 index 00000000000..d39a2f92af4 --- /dev/null +++ b/terraform/provider/litellm/data_source_vector_store.go @@ -0,0 +1,107 @@ +package litellm + +import ( + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceLiteLLMVectorStore() *schema.Resource { + return &schema.Resource{ + Read: dataSourceLiteLLMVectorStoreRead, + + Schema: map[string]*schema.Schema{ + "vector_store_id": { + Type: schema.TypeString, + Required: true, + Description: "Unique identifier for the vector store to retrieve", + }, + "vector_store_name": { + Type: schema.TypeString, + Computed: true, + Description: "Name of the vector store", + }, + "custom_llm_provider": { + Type: schema.TypeString, + Computed: true, + Description: "Custom LLM provider for the vector store", + }, + "vector_store_description": { + Type: schema.TypeString, + Computed: true, + Description: "Description of the vector store", + }, + "vector_store_metadata": { + Type: schema.TypeMap, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Metadata associated with the vector store", + }, + "litellm_credential_name": { + Type: schema.TypeString, + Computed: true, + Description: "Name of the LiteLLM credential used", + }, + "litellm_params": { + Type: schema.TypeMap, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Additional LiteLLM parameters", + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + Description: "Timestamp when the vector store was created", + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Timestamp when the vector store was last updated", + }, + }, + } +} + +func dataSourceLiteLLMVectorStoreRead(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + vectorStoreID := d.Get("vector_store_id").(string) + + // Use the info endpoint to get vector store details + infoRequest := VectorStoreInfoRequest{ + VectorStoreID: vectorStoreID, + } + + resp, err := MakeRequest(client, "POST", "/vector_store/info", infoRequest) + if err != nil { + return fmt.Errorf("failed to read vector store: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("vector store '%s' not found", vectorStoreID) + } + + var vectorStoreResp VectorStoreResponse + err = handleVectorStoreAPIResponse(resp, &vectorStoreResp, client) + if err != nil { + if err.Error() == "vector_store_not_found" { + return fmt.Errorf("vector store '%s' not found", vectorStoreID) + } + return fmt.Errorf("failed to read vector store: %w", err) + } + + // Set the data source ID to the vector store ID + d.SetId(vectorStoreResp.VectorStoreID) + d.Set("vector_store_id", vectorStoreResp.VectorStoreID) + d.Set("vector_store_name", vectorStoreResp.VectorStoreName) + d.Set("custom_llm_provider", vectorStoreResp.CustomLLMProvider) + d.Set("vector_store_description", vectorStoreResp.VectorStoreDescription) + d.Set("vector_store_metadata", vectorStoreResp.VectorStoreMetadata) + d.Set("litellm_credential_name", vectorStoreResp.LiteLLMCredentialName) + d.Set("litellm_params", vectorStoreResp.LiteLLMParams) + d.Set("created_at", vectorStoreResp.CreatedAt) + d.Set("updated_at", vectorStoreResp.UpdatedAt) + + return nil +} diff --git a/terraform/provider/litellm/provider.go b/terraform/provider/litellm/provider.go new file mode 100644 index 00000000000..57f9cc24183 --- /dev/null +++ b/terraform/provider/litellm/provider.go @@ -0,0 +1,63 @@ +package litellm + +import ( + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +// Provider returns a terraform.ResourceProvider. +func Provider() *schema.Provider { + return &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "litellm_model": resourceLiteLLMModel(), + "litellm_team": ResourceLiteLLMTeam(), + "litellm_organization": resourceLiteLLMOrganization(), + "litellm_organization_member": resourceLiteLLMOrganizationMember(), + "litellm_organization_member_add": resourceLiteLLMOrganizationMemberAdd(), + "litellm_team_member": resourceLiteLLMTeamMember(), + "litellm_team_member_add": resourceLiteLLMTeamMemberAdd(), + "litellm_key": resourceKey(), + "litellm_mcp_server": resourceLiteLLMMCPServer(), + "litellm_credential": resourceLiteLLMCredential(), + "litellm_vector_store": resourceLiteLLMVectorStore(), + }, + DataSourcesMap: map[string]*schema.Resource{ + "litellm_credential": dataSourceLiteLLMCredential(), + "litellm_vector_store": dataSourceLiteLLMVectorStore(), + }, + Schema: map[string]*schema.Schema{ + "api_base": { + Type: schema.TypeString, + Required: true, + Sensitive: false, + DefaultFunc: schema.EnvDefaultFunc("LITELLM_API_BASE", nil), + Description: "The base URL of the LiteLLM API", + }, + "api_key": { + Type: schema.TypeString, + Required: true, + Sensitive: true, + DefaultFunc: schema.EnvDefaultFunc("LITELLM_API_KEY", nil), + Description: "The API key for authenticating with LiteLLM", + }, + "insecure_skip_verify": { + Type: schema.TypeBool, + Optional: true, + Default: false, + DefaultFunc: schema.EnvDefaultFunc("LITELLM_INSECURE_SKIP_VERIFY", false), + Description: "Skip TLS certificate verification. Only use for development or when using self-signed certificates", + }, + }, + ConfigureFunc: providerConfigure, + } +} + +// providerConfigure configures the provider with the given schema data. +func providerConfigure(d *schema.ResourceData) (interface{}, error) { + config := ProviderConfig{ + APIBase: d.Get("api_base").(string), + APIKey: d.Get("api_key").(string), + InsecureSkipVerify: d.Get("insecure_skip_verify").(bool), + } + + return NewClient(config.APIBase, config.APIKey, config.InsecureSkipVerify), nil +} diff --git a/terraform/provider/litellm/provider_test.go b/terraform/provider/litellm/provider_test.go new file mode 100644 index 00000000000..00817e7c410 --- /dev/null +++ b/terraform/provider/litellm/provider_test.go @@ -0,0 +1,83 @@ +package litellm + +import ( + "os" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +var testAccProviders map[string]*schema.Provider +var testAccProvider *schema.Provider + +func init() { + testAccProvider = Provider() + testAccProviders = map[string]*schema.Provider{ + "litellm": testAccProvider, + } +} + +func TestProvider(t *testing.T) { + if err := Provider().InternalValidate(); err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestProvider_impl(t *testing.T) { + var _ *schema.Provider = Provider() +} + +func testAccPreCheck(t *testing.T) { + if v := os.Getenv("LITELLM_API_BASE"); v == "" { + t.Fatal("LITELLM_API_BASE must be set for acceptance tests") + } + if v := os.Getenv("LITELLM_API_KEY"); v == "" { + t.Fatal("LITELLM_API_KEY must be set for acceptance tests") + } + + // Create test users needed for organization member tests + createTestUsers(t) +} + +func createTestUsers(t *testing.T) { + apiBase := os.Getenv("LITELLM_API_BASE") + apiKey := os.Getenv("LITELLM_API_KEY") + + if apiBase == "" || apiKey == "" { + return + } + + client := NewClient(apiBase, apiKey, false) + + // Create test users + users := []map[string]interface{}{ + { + "user_id": "test-user-1", + "user_email": "test-user-1@example.com", + "user_role": "internal_user", + }, + { + "user_id": "bulk-user-1", + "user_email": "bulk-user-1@example.com", + "user_role": "internal_user", + }, + { + "user_id": "bulk-user-2", + "user_email": "bulk-user-2@example.com", + "user_role": "internal_user", + }, + } + + for _, user := range users { + _, err := client.sendRequest("POST", "/user/new", user) + if err != nil { + // Silently ignore if user already exists (400 error) + // This is expected when running tests multiple times + errStr := err.Error() + if !strings.Contains(errStr, "400") && !strings.Contains(errStr, "already exists") { + t.Logf("Warning: Could not create user %s: %v", user["user_id"], err) + } + } + } +} diff --git a/terraform/provider/litellm/resource_credential.go b/terraform/provider/litellm/resource_credential.go new file mode 100644 index 00000000000..f668a46a324 --- /dev/null +++ b/terraform/provider/litellm/resource_credential.go @@ -0,0 +1,44 @@ +package litellm + +import ( + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceLiteLLMCredential() *schema.Resource { + return &schema.Resource{ + Create: resourceLiteLLMCredentialCreate, + Read: resourceLiteLLMCredentialRead, + Update: resourceLiteLLMCredentialUpdate, + Delete: resourceLiteLLMCredentialDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "credential_name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Name of the credential", + }, + "model_id": { + Type: schema.TypeString, + Optional: true, + Description: "Model ID associated with this credential", + }, + "credential_info": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Additional information about the credential", + }, + "credential_values": { + Type: schema.TypeMap, + Required: true, + Sensitive: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Sensitive credential values (API keys, tokens, etc.)", + }, + }, + } +} diff --git a/terraform/provider/litellm/resource_credential_crud.go b/terraform/provider/litellm/resource_credential_crud.go new file mode 100644 index 00000000000..dd9aef64f76 --- /dev/null +++ b/terraform/provider/litellm/resource_credential_crud.go @@ -0,0 +1,204 @@ +package litellm + +import ( + "fmt" + "log" + "net/http" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +// retryCredentialRead attempts to read a credential with exponential backoff. +// If the read path clears the ID (e.g., transient 404 right after create), +// we treat it as retryable instead of accepting an empty state. +func retryCredentialRead(d *schema.ResourceData, m interface{}, maxRetries int) error { + var err error + delay := 1 * time.Second + maxDelay := 10 * time.Second + origID := d.Id() + + for i := 0; i < maxRetries; i++ { + log.Printf("[INFO] Attempting to read credential (attempt %d/%d)", i+1, maxRetries) + + err = resourceLiteLLMCredentialRead(d, m) + // If read succeeded but wiped the ID, treat as not found so we retry. + if err == nil && d.Id() == "" { + d.SetId(origID) + err = fmt.Errorf("credential_not_found") + } + + if err == nil { + log.Printf("[INFO] Successfully read credential after %d attempts", i+1) + return nil + } + + if !strings.Contains(err.Error(), "credential_not_found") { + return err + } + + if i < maxRetries-1 { + log.Printf("[INFO] Credential not found yet, retrying in %v...", delay) + time.Sleep(delay) + + delay *= 2 + if delay > maxDelay { + delay = maxDelay + } + } + } + + log.Printf("[WARN] Failed to read credential after %d attempts: %v", maxRetries, err) + return err +} + +func resourceLiteLLMCredentialCreate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + credentialName := d.Get("credential_name").(string) + modelID := d.Get("model_id").(string) + credentialInfo := d.Get("credential_info").(map[string]interface{}) + credentialValues := d.Get("credential_values").(map[string]interface{}) + + // Convert credential_info to map[string]interface{} for JSON + credInfoMap := make(map[string]interface{}) + for k, v := range credentialInfo { + credInfoMap[k] = v + } + + // Convert credential_values to map[string]interface{} for JSON + credValuesMap := make(map[string]interface{}) + for k, v := range credentialValues { + credValuesMap[k] = v + } + + credentialRequest := CredentialRequest{ + CredentialName: credentialName, + ModelID: modelID, + CredentialInfo: credInfoMap, + CredentialValues: credValuesMap, + } + + resp, err := MakeRequest(client, "POST", "/credentials", credentialRequest) + if err != nil { + return fmt.Errorf("failed to create credential: %w", err) + } + defer resp.Body.Close() + + err = handleCredentialAPIResponse(resp, nil, client) + if err != nil { + return fmt.Errorf("failed to create credential: %w", err) + } + + // Set the resource ID to the credential name + d.SetId(credentialName) + + log.Printf("[INFO] Credential created with name %s. Starting retry mechanism to read the credential...", credentialName) + return retryCredentialRead(d, m, 5) +} + +func resourceLiteLLMCredentialRead(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + credentialName := d.Id() + + // Try to get credential by name first + modelID := d.Get("model_id").(string) + endpoint := fmt.Sprintf("/credentials/by_name/%s", credentialName) + if modelID != "" { + endpoint += fmt.Sprintf("?model_id=%s", modelID) + } + + resp, err := MakeRequest(client, "GET", endpoint, nil) + if err != nil { + return fmt.Errorf("failed to read credential: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + d.SetId("") + return nil + } + + var credentialResp CredentialResponse + err = handleCredentialAPIResponse(resp, &credentialResp, client) + if err != nil { + if err.Error() == "credential_not_found" { + d.SetId("") + return nil + } + return fmt.Errorf("failed to read credential: %w", err) + } + + d.Set("credential_name", credentialResp.CredentialName) + d.Set("credential_info", credentialResp.CredentialInfo) + // Note: We don't set credential_values from the response for security reasons + // The API might not return sensitive values, and we want to preserve what's in state + + return nil +} + +func resourceLiteLLMCredentialUpdate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + credentialName := d.Id() + + credentialInfo := d.Get("credential_info").(map[string]interface{}) + credentialValues := d.Get("credential_values").(map[string]interface{}) + + // Convert credential_info to map[string]interface{} for JSON + credInfoMap := make(map[string]interface{}) + for k, v := range credentialInfo { + credInfoMap[k] = v + } + + // Convert credential_values to map[string]interface{} for JSON + credValuesMap := make(map[string]interface{}) + for k, v := range credentialValues { + credValuesMap[k] = v + } + + credentialRequest := CredentialRequest{ + CredentialName: credentialName, + CredentialInfo: credInfoMap, + CredentialValues: credValuesMap, + } + + endpoint := fmt.Sprintf("/credentials/%s", credentialName) + resp, err := MakeRequest(client, "PATCH", endpoint, credentialRequest) + if err != nil { + return fmt.Errorf("failed to update credential: %w", err) + } + defer resp.Body.Close() + + err = handleCredentialAPIResponse(resp, nil, client) + if err != nil { + return fmt.Errorf("failed to update credential: %w", err) + } + + log.Printf("[INFO] Credential updated with name %s. Starting retry mechanism to read the credential...", credentialName) + return retryCredentialRead(d, m, 5) +} + +func resourceLiteLLMCredentialDelete(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + credentialName := d.Id() + + endpoint := fmt.Sprintf("/credentials/%s", credentialName) + resp, err := MakeRequest(client, "DELETE", endpoint, nil) + if err != nil { + return fmt.Errorf("failed to delete credential: %w", err) + } + defer resp.Body.Close() + + err = handleCredentialAPIResponse(resp, nil, client) + if err != nil { + if err.Error() == "credential_not_found" { + d.SetId("") + return nil + } + return fmt.Errorf("failed to delete credential: %w", err) + } + + d.SetId("") + return nil +} diff --git a/terraform/provider/litellm/resource_credential_crud_test.go b/terraform/provider/litellm/resource_credential_crud_test.go new file mode 100644 index 00000000000..3398e58dd13 --- /dev/null +++ b/terraform/provider/litellm/resource_credential_crud_test.go @@ -0,0 +1,201 @@ +package litellm + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +// newTestResourceData creates a *schema.ResourceData with the credential schema, +// sets the ID and populates the required fields. +func newTestResourceData(t *testing.T, id string) *schema.ResourceData { + t.Helper() + d := schema.TestResourceDataRaw(t, resourceLiteLLMCredential().Schema, map[string]interface{}{ + "credential_name": id, + "model_id": "", + "credential_info": map[string]interface{}{}, + "credential_values": map[string]interface{}{"key": "val"}, + }) + d.SetId(id) + return d +} + +func TestRetryCredentialRead_SuccessOnFirstAttempt(t *testing.T) { + resp := CredentialResponse{ + CredentialName: "test-cred", + CredentialInfo: map[string]interface{}{"provider": "aws"}, + } + body, _ := json.Marshal(resp) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(body) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := newTestResourceData(t, "test-cred") + + err := retryCredentialRead(d, client, 3) + if err != nil { + t.Fatalf("expected nil error, got: %v", err) + } + if d.Id() != "test-cred" { + t.Fatalf("expected ID 'test-cred', got %q", d.Id()) + } +} + +func TestRetryCredentialRead_SuccessAfterRetries(t *testing.T) { + resp := CredentialResponse{ + CredentialName: "test-cred", + CredentialInfo: map[string]interface{}{"provider": "aws"}, + } + body, _ := json.Marshal(resp) + + var callCount int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&callCount, 1) + w.Header().Set("Content-Type", "application/json") + if n <= 2 { + // First two calls return 404, triggering retry + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + w.Write(body) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := newTestResourceData(t, "test-cred") + + err := retryCredentialRead(d, client, 3) + if err != nil { + t.Fatalf("expected nil error, got: %v", err) + } + if d.Id() != "test-cred" { + t.Fatalf("expected ID 'test-cred', got %q", d.Id()) + } + if atomic.LoadInt32(&callCount) != 3 { + t.Fatalf("expected 3 HTTP calls, got %d", callCount) + } +} + +func TestRetryCredentialRead_ExhaustsRetries(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := newTestResourceData(t, "test-cred") + + err := retryCredentialRead(d, client, 2) + if err == nil { + t.Fatal("expected error after exhausting retries, got nil") + } + if err.Error() != "credential_not_found" { + t.Fatalf("expected 'credential_not_found' error, got: %v", err) + } + // ID should still be restored (not wiped) + if d.Id() != "test-cred" { + t.Fatalf("expected ID to be restored to 'test-cred', got %q", d.Id()) + } +} + +func TestRetryCredentialRead_NonRetryableError(t *testing.T) { + var callCount int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&callCount, 1) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error": "internal server error"}`)) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := newTestResourceData(t, "test-cred") + + err := retryCredentialRead(d, client, 3) + if err == nil { + t.Fatal("expected error for 500 response, got nil") + } + // Should fail on first attempt without retrying + if atomic.LoadInt32(&callCount) != 1 { + t.Fatalf("expected 1 HTTP call (no retries for non-retryable error), got %d", callCount) + } +} + +func TestRetryCredentialRead_IDRestoredBetweenRetries(t *testing.T) { + // Verify the ID is restored after each failed attempt where the read clears it. + // resourceLiteLLMCredentialRead sets ID to "" on 404, and retryCredentialRead + // should restore it before the next attempt. + resp := CredentialResponse{ + CredentialName: "my-cred", + CredentialInfo: map[string]interface{}{}, + } + body, _ := json.Marshal(resp) + + var callCount int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&callCount, 1) + w.Header().Set("Content-Type", "application/json") + if n == 1 { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + w.Write(body) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := newTestResourceData(t, "my-cred") + + err := retryCredentialRead(d, client, 2) + if err != nil { + t.Fatalf("expected nil error, got: %v", err) + } + if d.Id() != "my-cred" { + t.Fatalf("expected ID 'my-cred', got %q", d.Id()) + } +} + +func TestRetryCredentialRead_MaxRetriesOne(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := newTestResourceData(t, "test-cred") + + err := retryCredentialRead(d, client, 1) + if err == nil { + t.Fatal("expected error with maxRetries=1 and always-404, got nil") + } + if err.Error() != "credential_not_found" { + t.Fatalf("expected 'credential_not_found', got: %v", err) + } +} + +func TestRetryCredentialRead_ConnectionError(t *testing.T) { + // Point to a server that's already closed to simulate connection failure + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := newTestResourceData(t, "test-cred") + + err := retryCredentialRead(d, client, 1) + if err == nil { + t.Fatal("expected error for connection failure, got nil") + } + // Connection error should not be retried (not a "credential_not_found") + fmt.Printf("connection error (expected): %v\n", err) +} diff --git a/terraform/provider/litellm/resource_key.go b/terraform/provider/litellm/resource_key.go new file mode 100644 index 00000000000..5c80198cf6a --- /dev/null +++ b/terraform/provider/litellm/resource_key.go @@ -0,0 +1,319 @@ +package litellm + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceKey() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceKeyCreate, + ReadContext: resourceKeyRead, + UpdateContext: resourceKeyUpdate, + DeleteContext: resourceKeyDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + Schema: map[string]*schema.Schema{ + "key": { + Type: schema.TypeString, + Optional: true, + WriteOnly: true, + Sensitive: true, + }, + "token_id": { + Type: schema.TypeString, + Computed: true, + }, + "models": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "max_budget": { + Type: schema.TypeFloat, + Optional: true, + Computed: true, + }, + "user_id": { + Type: schema.TypeString, + Optional: true, + }, + "team_id": { + Type: schema.TypeString, + Optional: true, + }, + "max_parallel_requests": { + Type: schema.TypeInt, + Optional: true, + Computed: true, + }, + "metadata": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "tpm_limit": { + Type: schema.TypeInt, + Optional: true, + Computed: true, + }, + "rpm_limit": { + Type: schema.TypeInt, + Optional: true, + Computed: true, + }, + "budget_duration": { + Type: schema.TypeString, + Optional: true, + }, + "allowed_cache_controls": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "soft_budget": { + Type: schema.TypeFloat, + Optional: true, + Computed: true, + }, + "key_alias": { + Type: schema.TypeString, + Optional: true, + }, + "duration": { + Type: schema.TypeString, + Optional: true, + }, + "aliases": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "config": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "permissions": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "model_max_budget": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeFloat, Computed: true}, + }, + "model_rpm_limit": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeInt, Computed: true}, + }, + "model_tpm_limit": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeInt, Computed: true}, + }, + "guardrails": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "blocked": { + Type: schema.TypeBool, + Optional: true, + }, + "tags": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "spend": { + Type: schema.TypeFloat, + Computed: true, + }, + }, + } +} + +func resourceKeyCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(*Client) + + key := &Key{} + mapResourceDataToKey(d, key) + + createdKey, err := c.CreateKey(key) + if err != nil { + return diag.FromErr(fmt.Errorf("error creating key: %s", err)) + } + + d.SetId(createdKey.TokenID) + // Set the write-only key value so it's available during this apply + // but will not be persisted to state. + d.Set("key", createdKey.Key) + return resourceKeyRead(ctx, d, m) +} + +func resourceKeyRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(*Client) + + key, err := c.GetKey(d.Id()) + if err != nil { + return diag.FromErr(fmt.Errorf("error reading key: %s", err)) + } + + if key == nil { + d.SetId("") + return nil + } + + mapKeyToResourceData(d, key) + return nil +} + +func resourceKeyUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(*Client) + + key := &Key{Key: d.Id()} + mapResourceDataToKey(d, key) + + _, err := c.UpdateKey(key) + if err != nil { + return diag.FromErr(fmt.Errorf("error updating key: %s", err)) + } + + return resourceKeyRead(ctx, d, m) +} + +func resourceKeyDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(*Client) + + err := c.DeleteKey(d.Id()) + if err != nil { + return diag.FromErr(fmt.Errorf("error deleting key: %s", err)) + } + + d.SetId("") + return nil +} + +func mapResourceDataToKey(d *schema.ResourceData, key *Key) { + key.Models = expandStringList(d.Get("models").([]interface{})) + if v, ok := d.GetOk("max_budget"); ok { + val := v.(float64) + key.MaxBudget = &val + } + key.UserID = d.Get("user_id").(string) + key.TeamID = d.Get("team_id").(string) + if v, ok := d.GetOk("max_parallel_requests"); ok { + val := v.(int) + key.MaxParallelRequests = &val + } + key.Metadata = d.Get("metadata").(map[string]interface{}) + if v, ok := d.GetOk("tpm_limit"); ok { + val := v.(int) + key.TPMLimit = &val + } + if v, ok := d.GetOk("rpm_limit"); ok { + val := v.(int) + key.RPMLimit = &val + } + key.BudgetDuration = d.Get("budget_duration").(string) + key.AllowedCacheControls = expandStringList(d.Get("allowed_cache_controls").([]interface{})) + if v, ok := d.GetOk("soft_budget"); ok { + val := v.(float64) + key.SoftBudget = &val + } + key.KeyAlias = d.Get("key_alias").(string) + key.Duration = d.Get("duration").(string) + key.Aliases = d.Get("aliases").(map[string]interface{}) + key.Config = d.Get("config").(map[string]interface{}) + key.Permissions = d.Get("permissions").(map[string]interface{}) + key.ModelMaxBudget = d.Get("model_max_budget").(map[string]interface{}) + key.ModelRPMLimit = d.Get("model_rpm_limit").(map[string]interface{}) + key.ModelTPMLimit = d.Get("model_tpm_limit").(map[string]interface{}) + key.Guardrails = expandStringList(d.Get("guardrails").([]interface{})) + key.Blocked = d.Get("blocked").(bool) + key.Tags = expandStringList(d.Get("tags").([]interface{})) +} + +func mapKeyToResourceData(d *schema.ResourceData, key *Key) { + // token_id is the SHA-256 hash of the key, used as the resource ID. + // It is safe to store in state since it cannot be used to authenticate. + d.Set("token_id", d.Id()) + + // Note: "key" is write-only and must not be set here (Read operations). + // It is only set during Create so it is available during apply. + + if len(key.Models) > 0 { + d.Set("models", key.Models) + } + if key.MaxBudget != nil { + d.Set("max_budget", *key.MaxBudget) + } + if key.UserID != "" { + d.Set("user_id", key.UserID) + } + if key.TeamID != "" { + d.Set("team_id", key.TeamID) + } + if key.MaxParallelRequests != nil { + d.Set("max_parallel_requests", *key.MaxParallelRequests) + } + if key.Metadata != nil { + d.Set("metadata", key.Metadata) + } + if key.TPMLimit != nil { + d.Set("tpm_limit", *key.TPMLimit) + } + if key.RPMLimit != nil { + d.Set("rpm_limit", *key.RPMLimit) + } + if key.BudgetDuration != "" { + d.Set("budget_duration", key.BudgetDuration) + } + if len(key.AllowedCacheControls) > 0 { + d.Set("allowed_cache_controls", key.AllowedCacheControls) + } + if key.SoftBudget != nil { + d.Set("soft_budget", *key.SoftBudget) + } + if key.KeyAlias != "" { + d.Set("key_alias", key.KeyAlias) + } + if key.Duration != "" { + d.Set("duration", key.Duration) + } + if key.Aliases != nil { + d.Set("aliases", key.Aliases) + } + if key.Config != nil { + d.Set("config", key.Config) + } + if key.Permissions != nil { + d.Set("permissions", key.Permissions) + } + if key.ModelMaxBudget != nil { + d.Set("model_max_budget", key.ModelMaxBudget) + } + if key.ModelRPMLimit != nil { + d.Set("model_rpm_limit", key.ModelRPMLimit) + } + if key.ModelTPMLimit != nil { + d.Set("model_tpm_limit", key.ModelTPMLimit) + } + if len(key.Guardrails) > 0 { + d.Set("guardrails", key.Guardrails) + } + d.Set("blocked", key.Blocked) + if len(key.Tags) > 0 { + d.Set("tags", key.Tags) + } + if key.Spend != 0 { + d.Set("spend", key.Spend) + } +} diff --git a/terraform/provider/litellm/resource_key_utils.go b/terraform/provider/litellm/resource_key_utils.go new file mode 100644 index 00000000000..d426fec05b2 --- /dev/null +++ b/terraform/provider/litellm/resource_key_utils.go @@ -0,0 +1,230 @@ +package litellm + +import ( + "fmt" + "log" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func buildKeyData(d *schema.ResourceData) map[string]interface{} { + keyData := make(map[string]interface{}) + + if v, ok := d.GetOkExists("models"); ok { + models := expandStringList(v.([]interface{})) + if len(models) > 0 { + keyData["models"] = models + } + } + if v, ok := d.GetOkExists("max_budget"); ok { + keyData["max_budget"] = v.(float64) + } + if v, ok := d.GetOkExists("user_id"); ok { + keyData["user_id"] = v.(string) + } + if v, ok := d.GetOkExists("team_id"); ok { + keyData["team_id"] = v.(string) + } + if v, ok := d.GetOkExists("max_parallel_requests"); ok { + keyData["max_parallel_requests"] = v.(int) + } + if v, ok := d.GetOkExists("metadata"); ok { + keyData["metadata"] = v.(map[string]interface{}) + } + if v, ok := d.GetOkExists("tpm_limit"); ok { + keyData["tpm_limit"] = v.(int) + } + if v, ok := d.GetOkExists("rpm_limit"); ok { + keyData["rpm_limit"] = v.(int) + } + if v, ok := d.GetOkExists("budget_duration"); ok { + keyData["budget_duration"] = v.(string) + } + if v, ok := d.GetOkExists("allowed_cache_controls"); ok { + cacheControls := expandStringList(v.([]interface{})) + if len(cacheControls) > 0 { + keyData["allowed_cache_controls"] = cacheControls + } + } + if v, ok := d.GetOkExists("soft_budget"); ok { + keyData["soft_budget"] = v.(float64) + } + if v, ok := d.GetOkExists("key_alias"); ok { + keyData["key_alias"] = v.(string) + } + if v, ok := d.GetOkExists("duration"); ok { + keyData["duration"] = v.(string) + } + if v, ok := d.GetOkExists("aliases"); ok { + keyData["aliases"] = v.(map[string]interface{}) + } + if v, ok := d.GetOkExists("config"); ok { + keyData["config"] = v.(map[string]interface{}) + } + if v, ok := d.GetOkExists("permissions"); ok { + keyData["permissions"] = v.(map[string]interface{}) + } + if v, ok := d.GetOkExists("model_max_budget"); ok { + keyData["model_max_budget"] = v.(map[string]interface{}) + } + if v, ok := d.GetOkExists("model_rpm_limit"); ok { + keyData["model_rpm_limit"] = v.(map[string]interface{}) + } + if v, ok := d.GetOkExists("model_tpm_limit"); ok { + keyData["model_tpm_limit"] = v.(map[string]interface{}) + } + if v, ok := d.GetOkExists("guardrails"); ok { + guardrails := expandStringList(v.([]interface{})) + if len(guardrails) > 0 { + keyData["guardrails"] = guardrails + } + } + if v, ok := d.GetOkExists("blocked"); ok { + keyData["blocked"] = v.(bool) + } + if v, ok := d.GetOkExists("tags"); ok { + tags := expandStringList(v.([]interface{})) + if len(tags) > 0 { + keyData["tags"] = tags + } + } + + return keyData +} + +func setKeyResourceData(d *schema.ResourceData, key *Key) error { + fields := map[string]interface{}{ + "key": key.Key, + "models": key.Models, + "spend": key.Spend, + "user_id": key.UserID, + "team_id": key.TeamID, + "metadata": key.Metadata, + "budget_duration": key.BudgetDuration, + "allowed_cache_controls": key.AllowedCacheControls, + "key_alias": key.KeyAlias, + "duration": key.Duration, + "aliases": key.Aliases, + "config": key.Config, + "permissions": key.Permissions, + "model_max_budget": key.ModelMaxBudget, + "model_rpm_limit": key.ModelRPMLimit, + "model_tpm_limit": key.ModelTPMLimit, + "guardrails": key.Guardrails, + "blocked": key.Blocked, + "tags": key.Tags, + } + + for field, value := range fields { + if err := d.Set(field, value); err != nil { + log.Printf("[WARN] Error setting %s: %s", field, err) + return fmt.Errorf("error setting %s: %s", field, err) + } + } + + // Handle pointer fields separately - only set if not nil + if key.MaxBudget != nil { + if err := d.Set("max_budget", *key.MaxBudget); err != nil { + return fmt.Errorf("error setting max_budget: %s", err) + } + } + if key.SoftBudget != nil { + if err := d.Set("soft_budget", *key.SoftBudget); err != nil { + return fmt.Errorf("error setting soft_budget: %s", err) + } + } + if key.MaxParallelRequests != nil { + if err := d.Set("max_parallel_requests", *key.MaxParallelRequests); err != nil { + return fmt.Errorf("error setting max_parallel_requests: %s", err) + } + } + if key.TPMLimit != nil { + if err := d.Set("tpm_limit", *key.TPMLimit); err != nil { + return fmt.Errorf("error setting tpm_limit: %s", err) + } + } + if key.RPMLimit != nil { + if err := d.Set("rpm_limit", *key.RPMLimit); err != nil { + return fmt.Errorf("error setting rpm_limit: %s", err) + } + } + + return nil +} + +func expandStringList(list []interface{}) []string { + result := make([]string, len(list)) + for i, v := range list { + result[i] = v.(string) + } + return result +} + +func mapToKey(data map[string]interface{}) *Key { + key := &Key{} + for k, v := range data { + switch k { + case "key": + key.Key = v.(string) + case "models": + key.Models = v.([]string) + case "max_budget": + if v, ok := v.(float64); ok { + key.MaxBudget = &v + } + case "user_id": + key.UserID = v.(string) + case "team_id": + key.TeamID = v.(string) + case "max_parallel_requests": + if v, ok := v.(int); ok { + key.MaxParallelRequests = &v + } + case "metadata": + key.Metadata = v.(map[string]interface{}) + case "tpm_limit": + if v, ok := v.(int); ok { + key.TPMLimit = &v + } + case "rpm_limit": + if v, ok := v.(int); ok { + key.RPMLimit = &v + } + case "budget_duration": + key.BudgetDuration = v.(string) + case "allowed_cache_controls": + key.AllowedCacheControls = v.([]string) + case "soft_budget": + if v, ok := v.(float64); ok { + key.SoftBudget = &v + } + case "key_alias": + key.KeyAlias = v.(string) + case "duration": + key.Duration = v.(string) + case "aliases": + key.Aliases = v.(map[string]interface{}) + case "config": + key.Config = v.(map[string]interface{}) + case "permissions": + key.Permissions = v.(map[string]interface{}) + case "model_max_budget": + key.ModelMaxBudget = v.(map[string]interface{}) + case "model_rpm_limit": + key.ModelRPMLimit = v.(map[string]interface{}) + case "model_tpm_limit": + key.ModelTPMLimit = v.(map[string]interface{}) + case "guardrails": + key.Guardrails = v.([]string) + case "blocked": + key.Blocked = v.(bool) + case "tags": + key.Tags = v.([]string) + } + } + return key +} + +func buildKeyForCreation(data map[string]interface{}) *Key { + return mapToKey(data) +} diff --git a/terraform/provider/litellm/resource_mcp_server.go b/terraform/provider/litellm/resource_mcp_server.go new file mode 100644 index 00000000000..b3eaef4a468 --- /dev/null +++ b/terraform/provider/litellm/resource_mcp_server.go @@ -0,0 +1,176 @@ +package litellm + +import ( + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceLiteLLMMCPServer() *schema.Resource { + return &schema.Resource{ + Create: resourceLiteLLMMCPServerCreate, + Read: resourceLiteLLMMCPServerRead, + Update: resourceLiteLLMMCPServerUpdate, + Delete: resourceLiteLLMMCPServerDelete, + + Schema: map[string]*schema.Schema{ + "server_name": { + Type: schema.TypeString, + Required: true, + Description: "Name of the MCP server", + }, + "alias": { + Type: schema.TypeString, + Optional: true, + Description: "Alias for the MCP server", + }, + "description": { + Type: schema.TypeString, + Optional: true, + Description: "Description of the MCP server", + }, + "url": { + Type: schema.TypeString, + Required: true, + Description: "URL of the MCP server", + }, + "transport": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{ + "http", + "sse", + "stdio", + }, false), + Description: "Transport type for the MCP server (http, sse, stdio)", + }, + "spec_version": { + Type: schema.TypeString, + Optional: true, + Default: "2024-11-05", + Description: "MCP specification version", + }, + "auth_type": { + Type: schema.TypeString, + Optional: true, + Default: "none", + ValidateFunc: validation.StringInSlice([]string{ + "none", + "bearer", + "basic", + }, false), + Description: "Authentication type (none, bearer, basic)", + }, + "mcp_access_groups": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "List of access groups for the MCP server", + }, + "command": { + Type: schema.TypeString, + Optional: true, + Description: "Command to run for stdio transport", + }, + "args": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Arguments for the command (stdio transport)", + }, + "env": { + Type: schema.TypeMap, + Optional: true, + Sensitive: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Environment variables for the command (stdio transport)", + }, + "mcp_info": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Description: "MCP server information and configuration", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "server_name": { + Type: schema.TypeString, + Optional: true, + Description: "Server name in MCP info", + }, + "description": { + Type: schema.TypeString, + Optional: true, + Description: "Description in MCP info", + }, + "logo_url": { + Type: schema.TypeString, + Optional: true, + Description: "Logo URL for the MCP server", + }, + "mcp_server_cost_info": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Description: "Cost information for MCP server tools", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "default_cost_per_query": { + Type: schema.TypeFloat, + Optional: true, + Description: "Default cost per query", + }, + "tool_name_to_cost_per_query": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeFloat}, + Description: "Map of tool names to their cost per query", + }, + }, + }, + }, + }, + }, + }, + // Read-only computed fields + "server_id": { + Type: schema.TypeString, + Computed: true, + Description: "Unique identifier for the MCP server", + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + Description: "Timestamp when the server was created", + }, + "created_by": { + Type: schema.TypeString, + Computed: true, + Description: "User who created the server", + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Timestamp when the server was last updated", + }, + "updated_by": { + Type: schema.TypeString, + Computed: true, + Description: "User who last updated the server", + }, + "status": { + Type: schema.TypeString, + Computed: true, + Description: "Current status of the MCP server", + }, + "last_health_check": { + Type: schema.TypeString, + Computed: true, + Description: "Timestamp of the last health check", + }, + "health_check_error": { + Type: schema.TypeString, + Computed: true, + Description: "Error message from the last health check, if any", + }, + }, + } +} diff --git a/terraform/provider/litellm/resource_mcp_server_crud.go b/terraform/provider/litellm/resource_mcp_server_crud.go new file mode 100644 index 00000000000..2a8980960f1 --- /dev/null +++ b/terraform/provider/litellm/resource_mcp_server_crud.go @@ -0,0 +1,317 @@ +package litellm + +import ( + "fmt" + "log" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +const ( + endpointMCPServerCreate = "/v1/mcp/server" + endpointMCPServerUpdate = "/v1/mcp/server" + endpointMCPServerRead = "/v1/mcp/server" + endpointMCPServerDelete = "/v1/mcp/server" +) + +// Helper function to convert schema data to MCPServerRequest +func buildMCPServerRequest(d *schema.ResourceData) *MCPServerRequest { + req := &MCPServerRequest{ + ServerName: d.Get("server_name").(string), + URL: d.Get("url").(string), + Transport: d.Get("transport").(string), + SpecVersion: d.Get("spec_version").(string), + AuthType: d.Get("auth_type").(string), + } + + // Set optional fields + if alias, ok := d.GetOk("alias"); ok { + req.Alias = alias.(string) + } + if description, ok := d.GetOk("description"); ok { + req.Description = description.(string) + } + if command, ok := d.GetOk("command"); ok { + req.Command = command.(string) + } + + // Handle access groups + if accessGroups, ok := d.GetOk("mcp_access_groups"); ok { + accessGroupsList := accessGroups.([]interface{}) + req.MCPAccessGroups = make([]string, len(accessGroupsList)) + for i, group := range accessGroupsList { + req.MCPAccessGroups[i] = group.(string) + } + } + + // Handle args + if args, ok := d.GetOk("args"); ok { + argsList := args.([]interface{}) + req.Args = make([]string, len(argsList)) + for i, arg := range argsList { + req.Args[i] = arg.(string) + } + } + + // Handle env + if env, ok := d.GetOk("env"); ok { + envMap := env.(map[string]interface{}) + req.Env = make(map[string]string) + for k, v := range envMap { + req.Env[k] = v.(string) + } + } + + // Handle mcp_info + if mcpInfoList, ok := d.GetOk("mcp_info"); ok { + mcpInfos := mcpInfoList.([]interface{}) + if len(mcpInfos) > 0 { + mcpInfoMap := mcpInfos[0].(map[string]interface{}) + req.MCPInfo = &MCPInfo{} + + if serverName, ok := mcpInfoMap["server_name"]; ok { + req.MCPInfo.ServerName = serverName.(string) + } + if description, ok := mcpInfoMap["description"]; ok { + req.MCPInfo.Description = description.(string) + } + if logoURL, ok := mcpInfoMap["logo_url"]; ok { + req.MCPInfo.LogoURL = logoURL.(string) + } + + // Handle cost info + if costInfoList, ok := mcpInfoMap["mcp_server_cost_info"]; ok { + costInfos := costInfoList.([]interface{}) + if len(costInfos) > 0 { + costInfoMap := costInfos[0].(map[string]interface{}) + req.MCPInfo.MCPServerCostInfo = &MCPServerCostInfo{} + + if defaultCost, ok := costInfoMap["default_cost_per_query"]; ok { + req.MCPInfo.MCPServerCostInfo.DefaultCostPerQuery = defaultCost.(float64) + } + if toolCosts, ok := costInfoMap["tool_name_to_cost_per_query"]; ok { + toolCostMap := toolCosts.(map[string]interface{}) + req.MCPInfo.MCPServerCostInfo.ToolNameToCostPerQuery = make(map[string]float64) + for k, v := range toolCostMap { + req.MCPInfo.MCPServerCostInfo.ToolNameToCostPerQuery[k] = v.(float64) + } + } + } + } + } + } + + return req +} + +// Helper function to update schema data from MCPServerResponse +func updateSchemaFromResponse(d *schema.ResourceData, resp *MCPServerResponse) error { + d.Set("server_id", resp.ServerID) + d.Set("server_name", resp.ServerName) + d.Set("alias", resp.Alias) + d.Set("description", resp.Description) + d.Set("url", resp.URL) + d.Set("transport", resp.Transport) + d.Set("spec_version", resp.SpecVersion) + d.Set("auth_type", resp.AuthType) + d.Set("created_at", resp.CreatedAt) + d.Set("created_by", resp.CreatedBy) + d.Set("updated_at", resp.UpdatedAt) + d.Set("updated_by", resp.UpdatedBy) + d.Set("status", resp.Status) + d.Set("last_health_check", resp.LastHealthCheck) + d.Set("health_check_error", resp.HealthCheckError) + d.Set("command", resp.Command) + + // Set access groups + if resp.MCPAccessGroups != nil { + d.Set("mcp_access_groups", resp.MCPAccessGroups) + } + + // Set args + if resp.Args != nil { + d.Set("args", resp.Args) + } + + // Set mcp_info + if resp.MCPInfo != nil { + mcpInfoList := make([]map[string]interface{}, 1) + mcpInfoMap := make(map[string]interface{}) + + mcpInfoMap["server_name"] = resp.MCPInfo.ServerName + mcpInfoMap["description"] = resp.MCPInfo.Description + mcpInfoMap["logo_url"] = resp.MCPInfo.LogoURL + + if resp.MCPInfo.MCPServerCostInfo != nil { + costInfoList := make([]map[string]interface{}, 1) + costInfoMap := make(map[string]interface{}) + + costInfoMap["default_cost_per_query"] = resp.MCPInfo.MCPServerCostInfo.DefaultCostPerQuery + if resp.MCPInfo.MCPServerCostInfo.ToolNameToCostPerQuery != nil { + costInfoMap["tool_name_to_cost_per_query"] = resp.MCPInfo.MCPServerCostInfo.ToolNameToCostPerQuery + } + + costInfoList[0] = costInfoMap + mcpInfoMap["mcp_server_cost_info"] = costInfoList + } + + mcpInfoList[0] = mcpInfoMap + d.Set("mcp_info", mcpInfoList) + } + + return nil +} + +func resourceLiteLLMMCPServerCreate(d *schema.ResourceData, m interface{}) error { + client, ok := m.(*Client) + if !ok { + return fmt.Errorf("invalid type assertion for client") + } + + req := buildMCPServerRequest(d) + + resp, err := MakeRequest(client, "POST", endpointMCPServerCreate, req) + if err != nil { + return fmt.Errorf("failed to create MCP server: %w", err) + } + defer resp.Body.Close() + + var mcpResp MCPServerResponse + if err := handleMCPAPIResponse(resp, &mcpResp, client); err != nil { + return fmt.Errorf("failed to create MCP server: %w", err) + } + + d.SetId(mcpResp.ServerID) + + // Update the state with the response data + if err := updateSchemaFromResponse(d, &mcpResp); err != nil { + return fmt.Errorf("failed to update state after create: %w", err) + } + + log.Printf("[INFO] MCP server created with ID %s", mcpResp.ServerID) + return nil +} + +func resourceLiteLLMMCPServerRead(d *schema.ResourceData, m interface{}) error { + client, ok := m.(*Client) + if !ok { + return fmt.Errorf("invalid type assertion for client") + } + + serverID := d.Id() + endpoint := fmt.Sprintf("%s/%s", endpointMCPServerRead, serverID) + + resp, err := MakeRequest(client, "GET", endpoint, nil) + if err != nil { + return fmt.Errorf("failed to read MCP server: %w", err) + } + defer resp.Body.Close() + + var mcpResp MCPServerResponse + if err := handleMCPAPIResponse(resp, &mcpResp, client); err != nil { + if err.Error() == "mcp_server_not_found" { + d.SetId("") + return nil + } + return fmt.Errorf("failed to read MCP server: %w", err) + } + + // Update the state with the response data + if err := updateSchemaFromResponse(d, &mcpResp); err != nil { + return fmt.Errorf("failed to update state after read: %w", err) + } + + return nil +} + +func resourceLiteLLMMCPServerUpdate(d *schema.ResourceData, m interface{}) error { + client, ok := m.(*Client) + if !ok { + return fmt.Errorf("invalid type assertion for client") + } + + req := buildMCPServerRequest(d) + req.ServerID = d.Id() // Ensure we include the server ID for updates + + resp, err := MakeRequest(client, "PUT", endpointMCPServerUpdate, req) + if err != nil { + return fmt.Errorf("failed to update MCP server: %w", err) + } + defer resp.Body.Close() + + var mcpResp MCPServerResponse + if err := handleMCPAPIResponse(resp, &mcpResp, client); err != nil { + return fmt.Errorf("failed to update MCP server: %w", err) + } + + // Update the state with the response data + if err := updateSchemaFromResponse(d, &mcpResp); err != nil { + return fmt.Errorf("failed to update state after update: %w", err) + } + + log.Printf("[INFO] MCP server updated with ID %s", mcpResp.ServerID) + return nil +} + +func resourceLiteLLMMCPServerDelete(d *schema.ResourceData, m interface{}) error { + client, ok := m.(*Client) + if !ok { + return fmt.Errorf("invalid type assertion for client") + } + + serverID := d.Id() + endpoint := fmt.Sprintf("%s/%s", endpointMCPServerDelete, serverID) + + resp, err := MakeRequest(client, "DELETE", endpoint, nil) + if err != nil { + return fmt.Errorf("failed to delete MCP server: %w", err) + } + defer resp.Body.Close() + + // For delete operations, we expect a simple string response + if resp.StatusCode != 200 { + return fmt.Errorf("failed to delete MCP server: unexpected status code %d", resp.StatusCode) + } + + d.SetId("") + log.Printf("[INFO] MCP server deleted with ID %s", serverID) + return nil +} + +// retryMCPServerRead attempts to read an MCP server with exponential backoff +func retryMCPServerRead(d *schema.ResourceData, m interface{}, maxRetries int) error { + var err error + delay := 1 * time.Second + maxDelay := 10 * time.Second + + for i := 0; i < maxRetries; i++ { + log.Printf("[INFO] Attempting to read MCP server (attempt %d/%d)", i+1, maxRetries) + + err = resourceLiteLLMMCPServerRead(d, m) + if err == nil { + log.Printf("[INFO] Successfully read MCP server after %d attempts", i+1) + return nil + } + + // Check if this is a "server not found" error + if err.Error() != "failed to read MCP server: mcp_server_not_found" { + // If it's a different error, don't retry + return err + } + + if i < maxRetries-1 { + log.Printf("[INFO] MCP server not found yet, retrying in %v...", delay) + time.Sleep(delay) + + // Exponential backoff with a maximum delay + delay *= 2 + if delay > maxDelay { + delay = maxDelay + } + } + } + + log.Printf("[WARN] Failed to read MCP server after %d attempts: %v", maxRetries, err) + return err +} diff --git a/terraform/provider/litellm/resource_mcp_server_crud_test.go b/terraform/provider/litellm/resource_mcp_server_crud_test.go new file mode 100644 index 00000000000..17300701954 --- /dev/null +++ b/terraform/provider/litellm/resource_mcp_server_crud_test.go @@ -0,0 +1,44 @@ +package litellm + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func TestMCPServerReadDoesNotPersistServerEnv(t *testing.T) { + d := schema.TestResourceDataRaw(t, resourceLiteLLMMCPServer().Schema, map[string]interface{}{ + "server_name": "gh", + "transport": "stdio", + "command": "npx", + "env": map[string]interface{}{ + "GITHUB_TOKEN": "from-config", + }, + }) + d.SetId("srv-1") + + resp := &MCPServerResponse{ + ServerID: "srv-1", + ServerName: "gh", + Transport: "stdio", + Command: "npx", + Env: map[string]string{ + "GITHUB_TOKEN": "raw-from-server", + "DB_PASSWORD": "leaked-secret", + }, + } + if err := updateSchemaFromResponse(d, resp); err != nil { + t.Fatalf("updateSchemaFromResponse failed: %v", err) + } + + got := d.Get("env").(map[string]interface{}) + if got["GITHUB_TOKEN"] != "from-config" { + t.Fatalf("config env overwritten by server response: %v", got) + } + if _, leaked := got["DB_PASSWORD"]; leaked { + t.Fatalf("server-returned env var persisted into state: %v", got) + } + if d.Get("server_name").(string) != "gh" { + t.Fatalf("read did not populate non-sensitive fields") + } +} diff --git a/terraform/provider/litellm/resource_model.go b/terraform/provider/litellm/resource_model.go new file mode 100644 index 00000000000..2858b6e763d --- /dev/null +++ b/terraform/provider/litellm/resource_model.go @@ -0,0 +1,177 @@ +package litellm + +import ( + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceLiteLLMModel() *schema.Resource { + return &schema.Resource{ + Create: resourceLiteLLMModelCreate, + Read: resourceLiteLLMModelRead, + Update: resourceLiteLLMModelUpdate, + Delete: resourceLiteLLMModelDelete, + + Schema: map[string]*schema.Schema{ + "model_name": { + Type: schema.TypeString, + Required: true, + }, + "custom_llm_provider": { + Type: schema.TypeString, + Required: true, + }, + "tpm": { + Type: schema.TypeInt, + Optional: true, + }, + "rpm": { + Type: schema.TypeInt, + Optional: true, + }, + "reasoning_effort": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringInSlice([]string{ + "low", + "medium", + "high", + }, false), + }, + "thinking_enabled": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + "thinking_budget_tokens": { + Type: schema.TypeInt, + Optional: true, + Default: 1024, + DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool { + // Only include thinking_budget_tokens in the diff if thinking_enabled is true + return !d.Get("thinking_enabled").(bool) + }, + }, + "merge_reasoning_content_in_choices": { + Type: schema.TypeBool, + Optional: true, + }, + "model_api_key": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + }, + "model_api_base": { + Type: schema.TypeString, + Optional: true, + }, + "api_version": { + Type: schema.TypeString, + Optional: true, + }, + "base_model": { + Type: schema.TypeString, + Required: true, + }, + "tier": { + Type: schema.TypeString, + Optional: true, + Default: "free", + }, + "team_id": { + Type: schema.TypeString, + Optional: true, + }, + "mode": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringInSlice([]string{ + "completion", + "embedding", + "image_generation", + "chat", + "moderation", + "audio_transcription", + "audio_speech", + "rerank", + }, false), + }, + "input_cost_per_million_tokens": { + Type: schema.TypeFloat, + Optional: true, + }, + "output_cost_per_million_tokens": { + Type: schema.TypeFloat, + Optional: true, + }, + "input_cost_per_pixel": { + Type: schema.TypeFloat, + Optional: true, + }, + "output_cost_per_pixel": { + Type: schema.TypeFloat, + Optional: true, + }, + "input_cost_per_second": { + Type: schema.TypeFloat, + Optional: true, + }, + "output_cost_per_second": { + Type: schema.TypeFloat, + Optional: true, + }, + "aws_access_key_id": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + }, + "aws_secret_access_key": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + }, + "aws_region_name": { + Type: schema.TypeString, + Optional: true, + }, + "aws_session_name": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + }, + "aws_role_name": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + }, + "vertex_project": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + }, + "vertex_location": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + }, + "vertex_credentials": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + }, + "litellm_credential_name": { + Type: schema.TypeString, + Optional: true, + Description: "Name of the LiteLLM credential to use", + }, + "additional_litellm_params": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Description: "Additional parameters to pass to litellm_params beyond the standard ones", + }, + }, + } +} diff --git a/terraform/provider/litellm/resource_model_crud.go b/terraform/provider/litellm/resource_model_crud.go new file mode 100644 index 00000000000..40766c8e312 --- /dev/null +++ b/terraform/provider/litellm/resource_model_crud.go @@ -0,0 +1,407 @@ +package litellm + +import ( + "encoding/json" + "fmt" + "log" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +// retryModelRead attempts to read a model with exponential backoff. +// It handles the case where resourceLiteLLMModelRead returns nil but clears the ID +// (eventual consistency: model created but not yet visible on read-back). +func retryModelRead(d *schema.ResourceData, m interface{}, maxRetries int) error { + delay := 1 * time.Second + maxDelay := 10 * time.Second + modelID := d.Id() + + for i := 0; i < maxRetries; i++ { + log.Printf("[INFO] Attempting to read model (attempt %d/%d)", i+1, maxRetries) + + err := resourceLiteLLMModelRead(d, m) + if err == nil { + if d.Id() != "" { + log.Printf("[INFO] Successfully read model after %d attempts", i+1) + return nil + } + // Read returned nil but cleared the ID — model not yet visible (eventual consistency). + // Restore the ID so we can retry. + d.SetId(modelID) + log.Printf("[INFO] Model not found yet (eventual consistency), retrying in %v...", delay) + } else { + log.Printf("[INFO] Read error, retrying in %v: %v", delay, err) + } + + if i < maxRetries-1 { + time.Sleep(delay) + delay *= 2 + if delay > maxDelay { + delay = maxDelay + } + } + } + + log.Printf("[WARN] Failed to read model after %d attempts", maxRetries) + return fmt.Errorf("model %s not found after %d read attempts post-create; the model may have been created successfully — re-running apply should resolve this", modelID, maxRetries) +} + +const ( + endpointModelNew = "/model/new" + endpointModelUpdate = "/model/update" + endpointModelInfo = "/model/info" + endpointModelDelete = "/model/delete" +) + +func createOrUpdateModel(d *schema.ResourceData, m interface{}, isUpdate bool) error { + client, ok := m.(*Client) + if !ok { + return fmt.Errorf("invalid type assertion for client") + } + + // Construct the model name in the format "custom_llm_provider/base_model" + customLLMProvider := d.Get("custom_llm_provider").(string) + baseModel := d.Get("base_model").(string) + modelName := fmt.Sprintf("%s/%s", customLLMProvider, baseModel) + + // Generate a UUID for new models + modelID := d.Id() + if !isUpdate { + modelID = uuid.New().String() + } + + // Create thinking configuration if enabled + var thinking map[string]interface{} + if d.Get("thinking_enabled").(bool) { + thinking = map[string]interface{}{ + "type": "enabled", + "budget_tokens": d.Get("thinking_budget_tokens").(int), + } + } + + // Build the base litellm_params as a map to allow for additional parameters + litellmParams := map[string]interface{}{ + "custom_llm_provider": customLLMProvider, + "model": modelName, + "merge_reasoning_content_in_choices": d.Get("merge_reasoning_content_in_choices").(bool), + } + + // Add optional parameters only if they have values + if tpm := d.Get("tpm").(int); tpm > 0 { + litellmParams["tpm"] = tpm + } + if rpm := d.Get("rpm").(int); rpm > 0 { + litellmParams["rpm"] = rpm + } + // Only include cost fields if explicitly set (non-zero) + if inputCostPerMillion := d.Get("input_cost_per_million_tokens").(float64); inputCostPerMillion > 0 { + litellmParams["input_cost_per_token"] = inputCostPerMillion / 1000000.0 + } + if outputCostPerMillion := d.Get("output_cost_per_million_tokens").(float64); outputCostPerMillion > 0 { + litellmParams["output_cost_per_token"] = outputCostPerMillion / 1000000.0 + } + if apiKey := d.Get("model_api_key").(string); apiKey != "" { + litellmParams["api_key"] = apiKey + } + if apiBase := d.Get("model_api_base").(string); apiBase != "" { + litellmParams["api_base"] = apiBase + } + if apiVersion := d.Get("api_version").(string); apiVersion != "" { + litellmParams["api_version"] = apiVersion + } + if inputCostPerPixel := d.Get("input_cost_per_pixel").(float64); inputCostPerPixel > 0 { + litellmParams["input_cost_per_pixel"] = inputCostPerPixel + } + if outputCostPerPixel := d.Get("output_cost_per_pixel").(float64); outputCostPerPixel > 0 { + litellmParams["output_cost_per_pixel"] = outputCostPerPixel + } + if inputCostPerSecond := d.Get("input_cost_per_second").(float64); inputCostPerSecond > 0 { + litellmParams["input_cost_per_second"] = inputCostPerSecond + } + if outputCostPerSecond := d.Get("output_cost_per_second").(float64); outputCostPerSecond > 0 { + litellmParams["output_cost_per_second"] = outputCostPerSecond + } + if awsAccessKeyID := d.Get("aws_access_key_id").(string); awsAccessKeyID != "" { + litellmParams["aws_access_key_id"] = awsAccessKeyID + } + if awsSecretAccessKey := d.Get("aws_secret_access_key").(string); awsSecretAccessKey != "" { + litellmParams["aws_secret_access_key"] = awsSecretAccessKey + } + if awsRegionName := d.Get("aws_region_name").(string); awsRegionName != "" { + litellmParams["aws_region_name"] = awsRegionName + } + if awsSessionName := d.Get("aws_session_name").(string); awsSessionName != "" { + litellmParams["aws_session_name"] = awsSessionName + } + if awsRoleName := d.Get("aws_role_name").(string); awsRoleName != "" { + litellmParams["aws_role_name"] = awsRoleName + } + if vertexProject := d.Get("vertex_project").(string); vertexProject != "" { + litellmParams["vertex_project"] = vertexProject + } + if vertexLocation := d.Get("vertex_location").(string); vertexLocation != "" { + litellmParams["vertex_location"] = vertexLocation + } + if vertexCredentials := d.Get("vertex_credentials").(string); vertexCredentials != "" { + litellmParams["vertex_credentials"] = vertexCredentials + } + if reasoningEffort := d.Get("reasoning_effort").(string); reasoningEffort != "" { + litellmParams["reasoning_effort"] = reasoningEffort + } + if thinking != nil { + litellmParams["thinking"] = thinking + } + + // Add additional parameters if provided + if additionalParams, ok := d.GetOk("additional_litellm_params"); ok { + var dropParams []string + + for key, value := range additionalParams.(map[string]interface{}) { + // Convert string values to appropriate types where possible + if strValue, ok := value.(string); ok { + // Check if it's JSON (starts with [ or {) + trimmedValue := strings.TrimSpace(strValue) + if strings.HasPrefix(trimmedValue, "[") || strings.HasPrefix(trimmedValue, "{") { + var parsedValue interface{} + if err := json.Unmarshal([]byte(strValue), &parsedValue); err == nil { + // Successfully parsed JSON + if key == "additional_drop_params" { + // Handle drop params specially + if dropList, ok := parsedValue.([]interface{}); ok { + for _, item := range dropList { + if paramStr, ok := item.(string); ok { + dropParams = append(dropParams, paramStr) + } + } + } + continue // Don't add to litellmParams + } else { + litellmParams[key] = parsedValue + } + } else { + // Not valid JSON, apply existing conversion logic + if strValue == "true" { + litellmParams[key] = true + } else if strValue == "false" { + litellmParams[key] = false + } else { + // Try to convert numeric strings + if intValue, err := strconv.Atoi(strValue); err == nil { + litellmParams[key] = intValue + } else if floatValue, err := strconv.ParseFloat(strValue, 64); err == nil { + litellmParams[key] = floatValue + } else { + // Keep as string + litellmParams[key] = strValue + } + } + } + } else { + // Apply existing conversion logic for non-JSON strings + if strValue == "true" { + litellmParams[key] = true + } else if strValue == "false" { + litellmParams[key] = false + } else { + // Try to convert numeric strings + if intValue, err := strconv.Atoi(strValue); err == nil { + litellmParams[key] = intValue + } else if floatValue, err := strconv.ParseFloat(strValue, 64); err == nil { + litellmParams[key] = floatValue + } else { + // Keep as string + litellmParams[key] = strValue + } + } + } + } else { + litellmParams[key] = value + } + } + + // Apply drop params at the end + for _, paramToDrop := range dropParams { + delete(litellmParams, paramToDrop) + } + } + + // Add litellm_credential_name to litellmParams if provided + if credentialName := d.Get("litellm_credential_name").(string); credentialName != "" { + litellmParams["litellm_credential_name"] = credentialName + } + + modelReq := ModelRequest{ + ModelName: d.Get("model_name").(string), + LiteLLMParams: litellmParams, + ModelInfo: ModelInfo{ + ID: modelID, + DBModel: true, + BaseModel: baseModel, + Tier: d.Get("tier").(string), + Mode: d.Get("mode").(string), + TeamID: d.Get("team_id").(string), + }, + Additional: make(map[string]interface{}), + } + + endpoint := endpointModelNew + if isUpdate { + endpoint = endpointModelUpdate + } + + resp, err := MakeRequest(client, "POST", endpoint, modelReq) + if err != nil { + return fmt.Errorf("failed to %s model: %w", map[bool]string{true: "update", false: "create"}[isUpdate], err) + } + defer resp.Body.Close() + + _, err = handleAPIResponse(resp, modelReq, client) + if err != nil { + if isUpdate && err.Error() == "model_not_found" { + return createOrUpdateModel(d, m, false) + } + return fmt.Errorf("failed to %s model: %w", map[bool]string{true: "update", false: "create"}[isUpdate], err) + } + + d.SetId(modelID) + + log.Printf("[INFO] Model created with ID %s. Starting retry mechanism to read the model...", modelID) + // Read back the resource with retries to ensure the state is consistent + return retryModelRead(d, m, 5) +} + +func resourceLiteLLMModelCreate(d *schema.ResourceData, m interface{}) error { + return createOrUpdateModel(d, m, false) +} + +func resourceLiteLLMModelRead(d *schema.ResourceData, m interface{}) error { + client, ok := m.(*Client) + if !ok { + return fmt.Errorf("invalid type assertion for client") + } + + resp, err := MakeRequest(client, "GET", fmt.Sprintf("%s?litellm_model_id=%s", endpointModelInfo, d.Id()), nil) + if err != nil { + return fmt.Errorf("failed to read model: %w", err) + } + defer resp.Body.Close() + + modelResp, err := handleAPIResponse(resp, nil, client) + if err != nil { + if err.Error() == "model_not_found" { + d.SetId("") + return nil + } + return fmt.Errorf("failed to read model: %w", err) + } + + // Update the state with values from the response or fall back to the data passed in during creation + d.Set("model_name", GetStringValue(modelResp.ModelName, d.Get("model_name").(string))) + d.Set("custom_llm_provider", GetStringValue(modelResp.LiteLLMParams.CustomLLMProvider, d.Get("custom_llm_provider").(string))) + d.Set("tpm", GetIntValue(modelResp.LiteLLMParams.TPM, d.Get("tpm").(int))) + d.Set("rpm", GetIntValue(modelResp.LiteLLMParams.RPM, d.Get("rpm").(int))) + d.Set("model_api_base", GetStringValue(modelResp.LiteLLMParams.APIBase, d.Get("model_api_base").(string))) + d.Set("api_version", GetStringValue(modelResp.LiteLLMParams.APIVersion, d.Get("api_version").(string))) + d.Set("base_model", GetStringValue(modelResp.ModelInfo.BaseModel, d.Get("base_model").(string))) + d.Set("tier", GetStringValue(modelResp.ModelInfo.Tier, d.Get("tier").(string))) + d.Set("mode", GetStringValue(modelResp.ModelInfo.Mode, d.Get("mode").(string))) + d.Set("team_id", GetStringValue(modelResp.ModelInfo.TeamID, d.Get("team_id").(string))) + + // Preserve credential name from state since it might not be returned by API + d.Set("litellm_credential_name", d.Get("litellm_credential_name").(string)) + + // Store sensitive information + d.Set("model_api_key", d.Get("model_api_key")) + d.Set("aws_access_key_id", d.Get("aws_access_key_id")) + d.Set("aws_secret_access_key", d.Get("aws_secret_access_key")) + d.Set("aws_region_name", GetStringValue(modelResp.LiteLLMParams.AWSRegionName, d.Get("aws_region_name").(string))) + d.Set("aws_session_name", d.Get("aws_session_name")) + d.Set("aws_role_name", d.Get("aws_role_name")) + + // Store cost information + d.Set("input_cost_per_million_tokens", d.Get("input_cost_per_million_tokens")) + d.Set("output_cost_per_million_tokens", d.Get("output_cost_per_million_tokens")) + + // Handle thinking configuration + if _, ok := d.GetOk("thinking_enabled"); ok { + // Keep the existing value from state + thinkingEnabled := d.Get("thinking_enabled").(bool) + d.Set("thinking_enabled", thinkingEnabled) + + // Only set thinking_budget_tokens if thinking is enabled and we have a value in state + if thinkingEnabled { + if _, ok := d.GetOk("thinking_budget_tokens"); ok { + d.Set("thinking_budget_tokens", d.Get("thinking_budget_tokens").(int)) + } + } + } else { + // Fall back to API response if no state value exists + if modelResp.LiteLLMParams.Thinking != nil { + if thinkingType, ok := modelResp.LiteLLMParams.Thinking["type"].(string); ok && thinkingType == "enabled" { + d.Set("thinking_enabled", true) + if budgetTokens, ok := modelResp.LiteLLMParams.Thinking["budget_tokens"].(float64); ok { + d.Set("thinking_budget_tokens", int(budgetTokens)) + } + } else { + d.Set("thinking_enabled", false) + } + } else { + d.Set("thinking_enabled", false) + } + } + + // Handle merge_reasoning_content_in_choices - preserve state value if not returned by API + if _, ok := d.GetOk("merge_reasoning_content_in_choices"); ok { + // Keep the existing value from state + d.Set("merge_reasoning_content_in_choices", d.Get("merge_reasoning_content_in_choices").(bool)) + } else { + // Only set from API response if we don't have a value in state + d.Set("merge_reasoning_content_in_choices", modelResp.LiteLLMParams.MergeReasoningContentInChoices) + } + + // Preserve additional_litellm_params from state since API might not return all custom parameters + if _, ok := d.GetOk("additional_litellm_params"); ok { + d.Set("additional_litellm_params", d.Get("additional_litellm_params")) + } + + return nil +} + +func resourceLiteLLMModelUpdate(d *schema.ResourceData, m interface{}) error { + return createOrUpdateModel(d, m, true) +} + +func resourceLiteLLMModelDelete(d *schema.ResourceData, m interface{}) error { + client, ok := m.(*Client) + if !ok { + return fmt.Errorf("invalid type assertion for client") + } + + deleteReq := struct { + ID string `json:"id"` + }{ + ID: d.Id(), + } + + resp, err := MakeRequest(client, "POST", endpointModelDelete, deleteReq) + if err != nil { + return fmt.Errorf("failed to delete model: %w", err) + } + defer resp.Body.Close() + + _, err = handleAPIResponse(resp, deleteReq, client) + if err != nil { + if err.Error() == "model_not_found" { + d.SetId("") + return nil + } + return fmt.Errorf("failed to delete model: %w", err) + } + + d.SetId("") + return nil +} diff --git a/terraform/provider/litellm/resource_organization.go b/terraform/provider/litellm/resource_organization.go new file mode 100644 index 00000000000..30e7feba1ec --- /dev/null +++ b/terraform/provider/litellm/resource_organization.go @@ -0,0 +1,210 @@ +package litellm + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +const ( + endpointOrganizationNew = "/organization/new" + endpointOrganizationInfo = "/organization/info" + endpointOrganizationUpdate = "/organization/update" + endpointOrganizationDelete = "/organization/delete" +) + +func resourceLiteLLMOrganization() *schema.Resource { + return &schema.Resource{ + Create: resourceLiteLLMOrganizationCreate, + Read: resourceLiteLLMOrganizationRead, + Update: resourceLiteLLMOrganizationUpdate, + Delete: resourceLiteLLMOrganizationDelete, + + Schema: map[string]*schema.Schema{ + "organization_alias": { + Type: schema.TypeString, + Required: true, + }, + "metadata": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "models": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "max_budget": { + Type: schema.TypeFloat, + Optional: true, + }, + "budget_duration": { + Type: schema.TypeString, + Optional: true, + }, + "tpm_limit": { + Type: schema.TypeInt, + Optional: true, + }, + "rpm_limit": { + Type: schema.TypeInt, + Optional: true, + }, + "blocked": { + Type: schema.TypeBool, + Optional: true, + }, + }, + } +} + +func resourceLiteLLMOrganizationCreate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + orgID := uuid.New().String() + orgData := buildOrganizationData(d, orgID) + + log.Printf("[DEBUG] Create organization request payload: %+v", orgData) + + resp, err := MakeRequest(client, "POST", endpointOrganizationNew, orgData) + if err != nil { + return fmt.Errorf("error creating organization: %w", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "creating organization"); err != nil { + return err + } + + d.SetId(orgID) + log.Printf("[INFO] Organization created with ID: %s", orgID) + + return resourceLiteLLMOrganizationRead(d, m) +} + +func resourceLiteLLMOrganizationRead(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + log.Printf("[INFO] Reading organization with ID: %s", d.Id()) + + resp, err := MakeRequest(client, "POST", endpointOrganizationInfo, map[string]interface{}{ + "organizations": []string{d.Id()}, + }) + if err != nil { + return fmt.Errorf("error reading organization: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + log.Printf("[WARN] Organization with ID %s not found, removing from state", d.Id()) + d.SetId("") + return nil + } + + var orgResps []OrganizationResponse + if err := json.NewDecoder(resp.Body).Decode(&orgResps); err != nil { + return fmt.Errorf("error decoding organization info response: %w", err) + } + + if len(orgResps) == 0 { + log.Printf("[WARN] Organization with ID %s not found in response, removing from state", d.Id()) + d.SetId("") + return nil + } + + orgResp := orgResps[0] + + d.Set("organization_alias", GetStringValue(orgResp.OrganizationAlias, d.Get("organization_alias").(string))) + + if orgResp.Metadata != nil { + d.Set("metadata", orgResp.Metadata) + } else { + d.Set("metadata", d.Get("metadata")) + } + + if orgResp.Models != nil { + d.Set("models", orgResp.Models) + } else { + d.Set("models", d.Get("models")) + } + + if orgResp.MaxBudget != nil { + d.Set("max_budget", *orgResp.MaxBudget) + } + d.Set("budget_duration", GetStringValue(orgResp.BudgetDuration, d.Get("budget_duration").(string))) + if orgResp.TPMLimit != nil { + d.Set("tpm_limit", *orgResp.TPMLimit) + } + if orgResp.RPMLimit != nil { + d.Set("rpm_limit", *orgResp.RPMLimit) + } + d.Set("blocked", GetBoolValue(orgResp.Blocked, d.Get("blocked").(bool))) + + log.Printf("[INFO] Successfully read organization with ID: %s", d.Id()) + return nil +} + +func resourceLiteLLMOrganizationUpdate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + orgData := buildOrganizationData(d, d.Id()) + log.Printf("[DEBUG] Update organization request payload: %+v", orgData) + + resp, err := MakeRequest(client, "PATCH", endpointOrganizationUpdate, orgData) + if err != nil { + return fmt.Errorf("error updating organization: %w", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "updating organization"); err != nil { + return err + } + + log.Printf("[INFO] Successfully updated organization with ID: %s", d.Id()) + return resourceLiteLLMOrganizationRead(d, m) +} + +func resourceLiteLLMOrganizationDelete(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + log.Printf("[INFO] Deleting organization with ID: %s", d.Id()) + + deleteData := map[string]interface{}{ + "organization_ids": []string{d.Id()}, + } + + resp, err := MakeRequest(client, "DELETE", endpointOrganizationDelete, deleteData) + + if err != nil { + return fmt.Errorf("error deleting organization: %w", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "deleting organization"); err != nil { + return err + } + + log.Printf("[INFO] Successfully deleted organization with ID: %s", d.Id()) + d.SetId("") + return nil +} + +func buildOrganizationData(d *schema.ResourceData, orgID string) map[string]interface{} { + orgData := map[string]interface{}{ + "organization_id": orgID, + "organization_alias": d.Get("organization_alias").(string), + } + + for _, key := range []string{"metadata", "models", "max_budget", "budget_duration", "tpm_limit", "rpm_limit", "blocked"} { + if v, ok := d.GetOk(key); ok { + orgData[key] = v + } + } + + return orgData +} diff --git a/terraform/provider/litellm/resource_organization_member.go b/terraform/provider/litellm/resource_organization_member.go new file mode 100644 index 00000000000..e9abd26b9ec --- /dev/null +++ b/terraform/provider/litellm/resource_organization_member.go @@ -0,0 +1,126 @@ +package litellm + +import ( + "fmt" + "log" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceLiteLLMOrganizationMember() *schema.Resource { + return &schema.Resource{ + Create: resourceLiteLLMOrganizationMemberCreate, + Read: resourceLiteLLMOrganizationMemberRead, + Update: resourceLiteLLMOrganizationMemberUpdate, + Delete: resourceLiteLLMOrganizationMemberDelete, + + Schema: map[string]*schema.Schema{ + "organization_id": { + Type: schema.TypeString, + Required: true, + }, + "user_id": { + Type: schema.TypeString, + Required: true, + }, + "user_email": { + Type: schema.TypeString, + Optional: true, + }, + "role": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{ + "org_admin", + "internal_user", + "internal_user_viewer", + }, false), + }, + }, + } +} + +func resourceLiteLLMOrganizationMemberCreate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + memberData := map[string]interface{}{ + "member": []map[string]interface{}{ + { + "role": d.Get("role").(string), + "user_id": d.Get("user_id").(string), + "user_email": d.Get("user_email").(string), + }, + }, + "organization_id": d.Get("organization_id").(string), + } + + log.Printf("[DEBUG] Create organization member request payload: %+v", memberData) + + resp, err := client.AddOrganizationMember(memberData) + if err != nil { + return fmt.Errorf("error creating organization member: %v", err) + } + + log.Printf("[DEBUG] Create organization member response: %+v", resp) + + // Set a composite ID since there's no specific member ID returned + d.SetId(fmt.Sprintf("%s:%s", d.Get("organization_id").(string), d.Get("user_id").(string))) + + log.Printf("[INFO] Organization member created with ID: %s", d.Id()) + + return resourceLiteLLMOrganizationMemberRead(d, m) +} + +func resourceLiteLLMOrganizationMemberRead(d *schema.ResourceData, m interface{}) error { + // There's no specific endpoint to read a single organization member + // We'll just return the data we have in the state + log.Printf("[INFO] Reading organization member with ID: %s", d.Id()) + return nil +} + +func resourceLiteLLMOrganizationMemberUpdate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + updateData := map[string]interface{}{ + "user_id": d.Get("user_id").(string), + "user_email": d.Get("user_email").(string), + "organization_id": d.Get("organization_id").(string), + "role": d.Get("role").(string), + } + + log.Printf("[DEBUG] Update organization member request payload: %+v", updateData) + + resp, err := client.UpdateOrganizationMember(updateData) + if err != nil { + return fmt.Errorf("error updating organization member: %v", err) + } + + log.Printf("[DEBUG] Update organization member response: %+v", resp) + + log.Printf("[INFO] Successfully updated organization member with ID: %s", d.Id()) + + return resourceLiteLLMOrganizationMemberRead(d, m) +} + +func resourceLiteLLMOrganizationMemberDelete(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + deleteData := map[string]interface{}{ + "user_id": d.Get("user_id").(string), + "user_email": d.Get("user_email").(string), + "organization_id": d.Get("organization_id").(string), + } + + log.Printf("[DEBUG] Delete organization member request payload: %+v", deleteData) + + _, err := client.DeleteOrganizationMember(deleteData) + if err != nil { + return fmt.Errorf("error deleting organization member: %v", err) + } + + log.Printf("[INFO] Successfully deleted organization member with ID: %s", d.Id()) + + d.SetId("") + return nil +} diff --git a/terraform/provider/litellm/resource_organization_member_add.go b/terraform/provider/litellm/resource_organization_member_add.go new file mode 100644 index 00000000000..9bb4de09861 --- /dev/null +++ b/terraform/provider/litellm/resource_organization_member_add.go @@ -0,0 +1,260 @@ +package litellm + +import ( + "fmt" + "log" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceLiteLLMOrganizationMemberAdd() *schema.Resource { + return &schema.Resource{ + Create: resourceLiteLLMOrganizationMemberAddCreate, + Read: resourceLiteLLMOrganizationMemberAddRead, + Update: resourceLiteLLMOrganizationMemberAddUpdate, + Delete: resourceLiteLLMOrganizationMemberAddDelete, + + Schema: map[string]*schema.Schema{ + "organization_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "member": { + Type: schema.TypeSet, + Required: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "user_id": { + Type: schema.TypeString, + Optional: true, + }, + "user_email": { + Type: schema.TypeString, + Optional: true, + }, + "role": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{ + "org_admin", + "internal_user", + "internal_user_viewer", + }, false), + }, + }, + }, + }, + }, + } +} + +func resourceLiteLLMOrganizationMemberAddCreate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + orgID := d.Get("organization_id").(string) + members := d.Get("member").(*schema.Set) + + // Convert members to the expected format + membersList := make([]map[string]interface{}, 0, members.Len()) + for _, member := range members.List() { + m := member.(map[string]interface{}) + memberData := map[string]interface{}{ + "role": m["role"].(string), + } + if userID, ok := m["user_id"].(string); ok && userID != "" { + memberData["user_id"] = userID + } + if userEmail, ok := m["user_email"].(string); ok && userEmail != "" { + memberData["user_email"] = userEmail + } + membersList = append(membersList, memberData) + } + + memberData := map[string]interface{}{ + "member": membersList, + "organization_id": orgID, + } + + log.Printf("[DEBUG] Create organization members request payload: %+v", memberData) + + resp, err := client.AddOrganizationMember(memberData) + if err != nil { + return fmt.Errorf("error adding organization members: %v", err) + } + + log.Printf("[DEBUG] Create organization members response: %+v", resp) + + // Set ID as organization_id since this resource manages all members for an organization + d.SetId(orgID) + + return resourceLiteLLMOrganizationMemberAddRead(d, m) +} + +func resourceLiteLLMOrganizationMemberAddRead(d *schema.ResourceData, m interface{}) error { + // The API doesn't provide a way to read specific organization members easily + // We'll maintain the state as is + return nil +} + +func resourceLiteLLMOrganizationMemberAddUpdate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + orgID := d.Get("organization_id").(string) + + o, n := d.GetChange("member") + oldMembers := o.(*schema.Set) + newMembers := n.(*schema.Set) + + // Create maps for easier lookup by user identifier + oldMemberMap := make(map[string]map[string]interface{}) + newMemberMap := make(map[string]map[string]interface{}) + + // Build old member map using user_id or user_email as key + for _, member := range oldMembers.List() { + m := member.(map[string]interface{}) + key := getOrgMemberKey(m) + if key != "" { + oldMemberMap[key] = m + } + } + + // Build new member map using user_id or user_email as key + for _, member := range newMembers.List() { + m := member.(map[string]interface{}) + key := getOrgMemberKey(m) + if key != "" { + newMemberMap[key] = m + } + } + + // Find members to delete (in old but not in new) + for key, oldMember := range oldMemberMap { + if _, exists := newMemberMap[key]; !exists { + deleteData := map[string]interface{}{ + "organization_id": orgID, + } + if userID, ok := oldMember["user_id"].(string); ok && userID != "" { + deleteData["user_id"] = userID + } + if userEmail, ok := oldMember["user_email"].(string); ok && userEmail != "" { + deleteData["user_email"] = userEmail + } + + log.Printf("[DEBUG] Delete organization member request payload: %+v", deleteData) + + _, err := client.DeleteOrganizationMember(deleteData) + if err != nil { + return fmt.Errorf("error deleting organization member: %v", err) + } + } + } + + // Find members to update (exist in both but with different attributes) + for key, newMember := range newMemberMap { + if oldMember, exists := oldMemberMap[key]; exists { + // Check if member attributes have changed + if orgMemberAttributesChanged(oldMember, newMember) { + updateData := map[string]interface{}{ + "organization_id": orgID, + "role": newMember["role"].(string), + } + if userID, ok := newMember["user_id"].(string); ok && userID != "" { + updateData["user_id"] = userID + } + if userEmail, ok := newMember["user_email"].(string); ok && userEmail != "" { + updateData["user_email"] = userEmail + } + + log.Printf("[DEBUG] Update organization member request payload: %+v", updateData) + + _, err := client.UpdateOrganizationMember(updateData) + if err != nil { + return fmt.Errorf("error updating organization member: %v", err) + } + } + } + } + + // Find members to add (in new but not in old) + var membersToAdd []map[string]interface{} + for key, newMember := range newMemberMap { + if _, exists := oldMemberMap[key]; !exists { + memberData := map[string]interface{}{ + "role": newMember["role"].(string), + } + if userID, ok := newMember["user_id"].(string); ok && userID != "" { + memberData["user_id"] = userID + } + if userEmail, ok := newMember["user_email"].(string); ok && userEmail != "" { + memberData["user_email"] = userEmail + } + membersToAdd = append(membersToAdd, memberData) + } + } + + if len(membersToAdd) > 0 { + memberData := map[string]interface{}{ + "member": membersToAdd, + "organization_id": orgID, + } + + log.Printf("[DEBUG] Adding new organization members request payload: %+v", memberData) + + resp, err := client.AddOrganizationMember(memberData) + if err != nil { + return fmt.Errorf("error adding organization members: %v", err) + } + + log.Printf("[DEBUG] Add organization members response: %+v", resp) + } + + return resourceLiteLLMOrganizationMemberAddRead(d, m) +} + +// getOrgMemberKey returns a unique key for a member based on user_id or user_email +func getOrgMemberKey(member map[string]interface{}) string { + if userID, ok := member["user_id"].(string); ok && userID != "" { + return "id:" + userID + } + if userEmail, ok := member["user_email"].(string); ok && userEmail != "" { + return "email:" + userEmail + } + return "" +} + +// orgMemberAttributesChanged checks if member attributes have changed between old and new +func orgMemberAttributesChanged(oldMember, newMember map[string]interface{}) bool { + // Compare role + oldRole, _ := oldMember["role"].(string) + newRole, _ := newMember["role"].(string) + return oldRole != newRole +} + +func resourceLiteLLMOrganizationMemberAddDelete(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + orgID := d.Get("organization_id").(string) + members := d.Get("member").(*schema.Set) + + // Delete each member + for _, member := range members.List() { + m := member.(map[string]interface{}) + deleteData := map[string]interface{}{ + "organization_id": orgID, + } + if userID, ok := m["user_id"].(string); ok && userID != "" { + deleteData["user_id"] = userID + } + if userEmail, ok := m["user_email"].(string); ok && userEmail != "" { + deleteData["user_email"] = userEmail + } + + _, err := client.DeleteOrganizationMember(deleteData) + if err != nil { + return fmt.Errorf("error deleting organization member: %v", err) + } + } + + d.SetId("") + return nil +} diff --git a/terraform/provider/litellm/resource_organization_member_add_test.go b/terraform/provider/litellm/resource_organization_member_add_test.go new file mode 100644 index 00000000000..a26c9ed5812 --- /dev/null +++ b/terraform/provider/litellm/resource_organization_member_add_test.go @@ -0,0 +1,74 @@ +package litellm + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccLiteLLMOrganizationMemberAdd_basic(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccLiteLLMOrganizationMemberAddConfig("test-org-bulk", "bulk-user-1", "bulk-user-2"), + Check: resource.ComposeTestCheckFunc( + testAccCheckLiteLLMOrganizationMemberAddExists("litellm_organization_member_add.test_members"), + resource.TestCheckResourceAttr("litellm_organization_member_add.test_members", "member.#", "2"), + ), + }, + }, + }) +} + +func testAccCheckLiteLLMOrganizationMemberAddExists(n string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No ID is set") + } + + return nil + } +} + +func testAccLiteLLMOrganizationMemberAddConfig(orgAlias, user1, user2 string) string { + return fmt.Sprintf(` +resource "litellm_model" "test_model" { + model_name = "gpt-3.5-turbo" + custom_llm_provider = "openai" + base_model = "gpt-3.5-turbo" +} + +resource "litellm_organization" "test_org_bulk" { + organization_alias = "%s" + max_budget = 100.0 + budget_duration = "30d" + + depends_on = [litellm_model.test_model] +} + +resource "litellm_organization_member_add" "test_members" { + organization_id = litellm_organization.test_org_bulk.id + + member { + user_id = "%s" + user_email = "%s@example.com" + role = "org_admin" + } + + member { + user_id = "%s" + user_email = "%s@example.com" + role = "internal_user" + } +} +`, orgAlias, user1, user1, user2, user2) +} diff --git a/terraform/provider/litellm/resource_organization_member_test.go b/terraform/provider/litellm/resource_organization_member_test.go new file mode 100644 index 00000000000..8818ed81052 --- /dev/null +++ b/terraform/provider/litellm/resource_organization_member_test.go @@ -0,0 +1,66 @@ +package litellm + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccLiteLLMOrganizationMember_basic(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccLiteLLMOrganizationMemberConfig("test-org-member", "test-user-1"), + Check: resource.ComposeTestCheckFunc( + testAccCheckLiteLLMOrganizationMemberExists("litellm_organization_member.test_member"), + resource.TestCheckResourceAttr("litellm_organization_member.test_member", "role", "org_admin"), + resource.TestCheckResourceAttr("litellm_organization_member.test_member", "user_id", "test-user-1"), + ), + }, + }, + }) +} + +func testAccCheckLiteLLMOrganizationMemberExists(n string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No ID is set") + } + + return nil + } +} + +func testAccLiteLLMOrganizationMemberConfig(orgAlias, userID string) string { + return fmt.Sprintf(` +resource "litellm_model" "test_model" { + model_name = "gpt-3.5-turbo" + custom_llm_provider = "openai" + base_model = "gpt-3.5-turbo" +} + +resource "litellm_organization" "test_org" { + organization_alias = "%s" + max_budget = 100.0 + budget_duration = "30d" + + depends_on = [litellm_model.test_model] +} + +resource "litellm_organization_member" "test_member" { + organization_id = litellm_organization.test_org.id + user_id = "%s" + user_email = "%s@example.com" + role = "org_admin" +} +`, orgAlias, userID, userID) +} diff --git a/terraform/provider/litellm/resource_organization_test.go b/terraform/provider/litellm/resource_organization_test.go new file mode 100644 index 00000000000..2a2c32438fe --- /dev/null +++ b/terraform/provider/litellm/resource_organization_test.go @@ -0,0 +1,59 @@ +package litellm + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccLiteLLMOrganization_basic(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccLiteLLMOrganizationConfig("test-org", "test-org-alias"), + Check: resource.ComposeTestCheckFunc( + testAccCheckLiteLLMOrganizationExists("litellm_organization.test"), + resource.TestCheckResourceAttr("litellm_organization.test", "organization_alias", "test-org-alias"), + resource.TestCheckResourceAttr("litellm_organization.test", "max_budget", "100"), + ), + }, + }, + }) +} + +func testAccCheckLiteLLMOrganizationExists(n string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No ID is set") + } + + return nil + } +} + +func testAccLiteLLMOrganizationConfig(name, alias string) string { + return fmt.Sprintf(` +resource "litellm_model" "test_model" { + model_name = "gpt-3.5-turbo" + custom_llm_provider = "openai" + base_model = "gpt-3.5-turbo" +} + +resource "litellm_organization" "test" { + organization_alias = "%s" + max_budget = 100.0 + budget_duration = "30d" + + depends_on = [litellm_model.test_model] +} +`, alias) +} diff --git a/terraform/provider/litellm/resource_team.go b/terraform/provider/litellm/resource_team.go new file mode 100644 index 00000000000..88e0dcd4811 --- /dev/null +++ b/terraform/provider/litellm/resource_team.go @@ -0,0 +1,311 @@ +package litellm + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +const ( + endpointTeamNew = "/team/new" + endpointTeamInfo = "/team/info" + endpointTeamUpdate = "/team/update" + endpointTeamDelete = "/team/delete" + endpointTeamPermissionsList = "/team/permissions_list" + endpointTeamPermissionsUpdate = "/team/permissions_update" +) + +func ResourceLiteLLMTeam() *schema.Resource { + return &schema.Resource{ + Create: resourceLiteLLMTeamCreate, + Read: resourceLiteLLMTeamRead, + Update: resourceLiteLLMTeamUpdate, + Delete: resourceLiteLLMTeamDelete, + + Schema: map[string]*schema.Schema{ + "team_alias": { + Type: schema.TypeString, + Required: true, + }, + "organization_id": { + Type: schema.TypeString, + Optional: true, + }, + "metadata": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "tpm_limit": { + Type: schema.TypeInt, + Optional: true, + }, + "rpm_limit": { + Type: schema.TypeInt, + Optional: true, + }, + "max_budget": { + Type: schema.TypeFloat, + Optional: true, + }, + "budget_duration": { + Type: schema.TypeString, + Optional: true, + }, + "models": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "blocked": { + Type: schema.TypeBool, + Optional: true, + }, + "team_member_permissions": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "List of permissions granted to team members", + }, + }, + } +} + +func resourceLiteLLMTeamCreate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + teamID := uuid.New().String() + teamData := buildTeamData(d, teamID) + + log.Printf("[DEBUG] Create team request payload: %+v", teamData) + + resp, err := MakeRequest(client, "POST", endpointTeamNew, teamData) + if err != nil { + return fmt.Errorf("error creating team: %w", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "creating team"); err != nil { + return err + } + + d.SetId(teamID) + log.Printf("[INFO] Team created with ID: %s", teamID) + + return resourceLiteLLMTeamRead(d, m) +} + +func resourceLiteLLMTeamRead(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + log.Printf("[INFO] Reading team with ID: %s", d.Id()) + + resp, err := MakeRequest(client, "GET", fmt.Sprintf("%s?team_id=%s", endpointTeamInfo, d.Id()), nil) + if err != nil { + return fmt.Errorf("error reading team: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + log.Printf("[WARN] Team with ID %s not found, removing from state", d.Id()) + d.SetId("") + return nil + } + + var teamResp TeamResponse + if err := json.NewDecoder(resp.Body).Decode(&teamResp); err != nil { + return fmt.Errorf("error decoding team info response: %w", err) + } + + // Update the state with values from the response or fall back to the data passed in during creation + d.Set("team_alias", GetStringValue(teamResp.TeamAlias, d.Get("team_alias").(string))) + d.Set("organization_id", GetStringValue(teamResp.OrganizationID, d.Get("organization_id").(string))) + + // Handle metadata separately as it's a map + if teamResp.Metadata != nil { + d.Set("metadata", teamResp.Metadata) + } else { + d.Set("metadata", d.Get("metadata")) + } + + if teamResp.TPMLimit != nil { + d.Set("tpm_limit", *teamResp.TPMLimit) + } + if teamResp.RPMLimit != nil { + d.Set("rpm_limit", *teamResp.RPMLimit) + } + if teamResp.MaxBudget != nil { + d.Set("max_budget", *teamResp.MaxBudget) + } + d.Set("budget_duration", GetStringValue(teamResp.BudgetDuration, d.Get("budget_duration").(string))) + + // Handle models separately as it's a list + if teamResp.Models != nil { + d.Set("models", teamResp.Models) + } else { + d.Set("models", d.Get("models")) + } + + d.Set("blocked", GetBoolValue(teamResp.Blocked, d.Get("blocked").(bool))) + + // Explicitly fetch the current permissions from the API + permResp, err := getTeamPermissions(client, d.Id()) + if err != nil { + log.Printf("[WARN] Error fetching team permissions: %s", err) + // Fall back to the permissions from the team info response + if teamResp.TeamMemberPermissions != nil { + d.Set("team_member_permissions", teamResp.TeamMemberPermissions) + } else { + d.Set("team_member_permissions", d.Get("team_member_permissions")) + } + } else { + // Use the permissions from the permissions_list endpoint + log.Printf("[DEBUG] Team permissions from API: %+v", permResp.TeamMemberPermissions) + d.Set("team_member_permissions", permResp.TeamMemberPermissions) + } + + log.Printf("[INFO] Successfully read team with ID: %s", d.Id()) + return nil +} + +func resourceLiteLLMTeamUpdate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + teamData := buildTeamData(d, d.Id()) + log.Printf("[DEBUG] Update team request payload: %+v", teamData) + + resp, err := MakeRequest(client, "POST", endpointTeamUpdate, teamData) + if err != nil { + return fmt.Errorf("error updating team: %w", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "updating team"); err != nil { + return err + } + + // Check if team_member_permissions have changed and explicitly update them + if d.HasChange("team_member_permissions") { + _, newPerms := d.GetChange("team_member_permissions") + if newPerms != nil { + // Convert interface{} to []string + var permissions []string + for _, perm := range newPerms.([]interface{}) { + permissions = append(permissions, perm.(string)) + } + + log.Printf("[DEBUG] Explicitly updating team permissions: %+v", permissions) + if err := updateTeamPermissions(client, d.Id(), permissions); err != nil { + return fmt.Errorf("error updating team permissions: %w", err) + } + } + } + + log.Printf("[INFO] Successfully updated team with ID: %s", d.Id()) + return resourceLiteLLMTeamRead(d, m) +} + +func resourceLiteLLMTeamDelete(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + log.Printf("[INFO] Deleting team with ID: %s", d.Id()) + + deleteData := map[string]interface{}{ + "team_ids": []string{d.Id()}, + } + + resp, err := MakeRequest(client, "POST", endpointTeamDelete, deleteData) + if err != nil { + return fmt.Errorf("error deleting team: %w", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "deleting team"); err != nil { + return err + } + + log.Printf("[INFO] Successfully deleted team with ID: %s", d.Id()) + d.SetId("") + return nil +} + +func buildTeamData(d *schema.ResourceData, teamID string) map[string]interface{} { + teamData := map[string]interface{}{ + "team_id": teamID, + "team_alias": d.Get("team_alias").(string), + } + + for _, key := range []string{"organization_id", "metadata", "tpm_limit", "rpm_limit", "max_budget", "budget_duration", "models", "blocked", "team_member_permissions"} { + if v, ok := d.GetOk(key); ok { + teamData[key] = v + } + } + + return teamData +} + +func handleResponse(resp *http.Response, action string) error { + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("error %s: %s - %s", action, resp.Status, string(body)) + } + return nil +} + +// TeamPermissionsResponse represents a response from the API containing team permissions information. +type TeamPermissionsResponse struct { + TeamID string `json:"team_id"` + TeamMemberPermissions []string `json:"team_member_permissions"` + AllAvailablePermissions []string `json:"all_available_permissions"` +} + +// getTeamPermissions retrieves the current permissions and available permissions for a team. +func getTeamPermissions(client *Client, teamID string) (*TeamPermissionsResponse, error) { + log.Printf("[INFO] Getting permissions for team with ID: %s", teamID) + + resp, err := MakeRequest(client, "GET", fmt.Sprintf("%s?team_id=%s", endpointTeamPermissionsList, teamID), nil) + if err != nil { + return nil, fmt.Errorf("error getting team permissions: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("error getting team permissions: %s - %s", resp.Status, string(body)) + } + + var permResp TeamPermissionsResponse + if err := json.NewDecoder(resp.Body).Decode(&permResp); err != nil { + return nil, fmt.Errorf("error decoding team permissions response: %w", err) + } + + return &permResp, nil +} + +// updateTeamPermissions updates the permissions for a team. +func updateTeamPermissions(client *Client, teamID string, permissions []string) error { + log.Printf("[INFO] Updating permissions for team with ID: %s", teamID) + + permData := map[string]interface{}{ + "team_id": teamID, + "team_member_permissions": permissions, + } + + resp, err := MakeRequest(client, "POST", endpointTeamPermissionsUpdate, permData) + if err != nil { + return fmt.Errorf("error updating team permissions: %w", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "updating team permissions"); err != nil { + return err + } + + log.Printf("[INFO] Successfully updated permissions for team with ID: %s", teamID) + return nil +} diff --git a/terraform/provider/litellm/resource_team_member.go b/terraform/provider/litellm/resource_team_member.go new file mode 100644 index 00000000000..84db07239fd --- /dev/null +++ b/terraform/provider/litellm/resource_team_member.go @@ -0,0 +1,146 @@ +package litellm + +import ( + "fmt" + "log" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceLiteLLMTeamMember() *schema.Resource { + return &schema.Resource{ + Create: resourceLiteLLMTeamMemberCreate, + Read: resourceLiteLLMTeamMemberRead, + Update: resourceLiteLLMTeamMemberUpdate, + Delete: resourceLiteLLMTeamMemberDelete, + + Schema: map[string]*schema.Schema{ + "team_id": { + Type: schema.TypeString, + Required: true, + }, + "user_id": { + Type: schema.TypeString, + Required: true, + }, + "user_email": { + Type: schema.TypeString, + Required: true, + }, + "role": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{ + "org_admin", + "internal_user", + "internal_user_viewer", + "admin", + "user", + }, false), + }, + "max_budget_in_team": { + Type: schema.TypeFloat, + Optional: true, + }, + }, + } +} + +func resourceLiteLLMTeamMemberCreate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + memberData := map[string]interface{}{ + "member": []map[string]interface{}{ + { + "role": d.Get("role").(string), + "user_id": d.Get("user_id").(string), + "user_email": d.Get("user_email").(string), + }, + }, + "team_id": d.Get("team_id").(string), + "max_budget_in_team": d.Get("max_budget_in_team").(float64), + } + + log.Printf("[DEBUG] Create team member request payload: %+v", memberData) + + resp, err := MakeRequest(client, "POST", "/team/member_add", memberData) + if err != nil { + return fmt.Errorf("error creating team member: %v", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "creating team member"); err != nil { + return err + } + + // Set a composite ID since there's no specific member ID returned + d.SetId(fmt.Sprintf("%s:%s", d.Get("team_id").(string), d.Get("user_id").(string))) + + log.Printf("[INFO] Team member created with ID: %s", d.Id()) + + return resourceLiteLLMTeamMemberRead(d, m) +} + +func resourceLiteLLMTeamMemberRead(d *schema.ResourceData, m interface{}) error { + // There's no specific endpoint to read a single team member + // We might need to read the entire team and find the member + // For now, we'll just return the data we have in the state + log.Printf("[INFO] Reading team member with ID: %s", d.Id()) + return nil +} + +func resourceLiteLLMTeamMemberUpdate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + updateData := map[string]interface{}{ + "user_id": d.Get("user_id").(string), + "user_email": d.Get("user_email").(string), + "team_id": d.Get("team_id").(string), + "role": d.Get("role").(string), + "max_budget_in_team": d.Get("max_budget_in_team").(float64), + } + + log.Printf("[DEBUG] Update team member request payload: %+v", updateData) + + resp, err := MakeRequest(client, "POST", "/team/member_update", updateData) + if err != nil { + return fmt.Errorf("error updating team member: %v", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "updating team member"); err != nil { + return err + } + + log.Printf("[INFO] Successfully updated team member with ID: %s", d.Id()) + + return resourceLiteLLMTeamMemberRead(d, m) +} + +func resourceLiteLLMTeamMemberDelete(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + deleteData := map[string]interface{}{ + "user_id": d.Get("user_id").(string), + "user_email": d.Get("user_email").(string), + "team_id": d.Get("team_id").(string), + } + + log.Printf("[DEBUG] Delete team member request payload: %+v", deleteData) + + resp, err := MakeRequest(client, "POST", "/team/member_delete", deleteData) + if err != nil { + return fmt.Errorf("error deleting team member: %v", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "deleting team member"); err != nil { + return err + } + + log.Printf("[INFO] Successfully deleted team member with ID: %s", d.Id()) + + d.SetId("") + return nil +} diff --git a/terraform/provider/litellm/resource_team_member_add.go b/terraform/provider/litellm/resource_team_member_add.go new file mode 100644 index 00000000000..da5c7a6ebd7 --- /dev/null +++ b/terraform/provider/litellm/resource_team_member_add.go @@ -0,0 +1,342 @@ +package litellm + +import ( + "fmt" + "log" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceLiteLLMTeamMemberAdd() *schema.Resource { + return &schema.Resource{ + Create: resourceLiteLLMTeamMemberAddCreate, + Read: resourceLiteLLMTeamMemberAddRead, + Update: resourceLiteLLMTeamMemberAddUpdate, + Delete: resourceLiteLLMTeamMemberAddDelete, + + Schema: map[string]*schema.Schema{ + "team_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "member": { + Type: schema.TypeSet, + Required: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "user_id": { + Type: schema.TypeString, + Optional: true, + }, + "user_email": { + Type: schema.TypeString, + Optional: true, + }, + "role": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{ + "admin", + "user", + }, false), + }, + }, + }, + }, + "max_budget_in_team": { + Type: schema.TypeFloat, + Optional: true, + }, + }, + } +} + +func resourceLiteLLMTeamMemberAddCreate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + teamID := d.Get("team_id").(string) + members := d.Get("member").(*schema.Set) + maxBudget := d.Get("max_budget_in_team").(float64) + + // Convert members to the expected format + membersList := make([]map[string]interface{}, 0, members.Len()) + for _, member := range members.List() { + m := member.(map[string]interface{}) + memberData := map[string]interface{}{ + "role": m["role"].(string), + } + if userID, ok := m["user_id"].(string); ok && userID != "" { + memberData["user_id"] = userID + } + if userEmail, ok := m["user_email"].(string); ok && userEmail != "" { + memberData["user_email"] = userEmail + } + membersList = append(membersList, memberData) + } + + memberData := map[string]interface{}{ + "member": membersList, + "team_id": teamID, + "max_budget_in_team": maxBudget, + } + + log.Printf("[DEBUG] Create team members request payload: %+v", memberData) + + resp, err := MakeRequest(client, "POST", "/team/member_add", memberData) + if err != nil { + return fmt.Errorf("error adding team members: %v", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "adding team members"); err != nil { + return err + } + + // Set ID as team_id since this resource manages all members for a team + d.SetId(teamID) + + return resourceLiteLLMTeamMemberAddRead(d, m) +} + +func resourceLiteLLMTeamMemberAddRead(d *schema.ResourceData, m interface{}) error { + // The API doesn't provide a way to read specific team members + // We'll maintain the state as is + return nil +} + +func resourceLiteLLMTeamMemberAddUpdate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + teamID := d.Get("team_id").(string) + maxBudget := d.Get("max_budget_in_team").(float64) + + o, n := d.GetChange("member") + oldMembers := o.(*schema.Set) + newMembers := n.(*schema.Set) + + // Create maps for easier lookup by user identifier + oldMemberMap := make(map[string]map[string]interface{}) + newMemberMap := make(map[string]map[string]interface{}) + + // Build old member map using user_id or user_email as key + for _, member := range oldMembers.List() { + m := member.(map[string]interface{}) + key := getMemberKey(m) + if key != "" { + oldMemberMap[key] = m + } + } + + // Build new member map using user_id or user_email as key + for _, member := range newMembers.List() { + m := member.(map[string]interface{}) + key := getMemberKey(m) + if key != "" { + newMemberMap[key] = m + } + } + + // Track which members have been updated to avoid duplicates + updatedMembers := make(map[string]bool) + + // Check if max_budget_in_team has changed + if d.HasChange("max_budget_in_team") { + log.Printf("[DEBUG] max_budget_in_team changed, updating all existing members with new budget: %f", maxBudget) + + // Update ALL existing members with the new budget + for key, newMember := range newMemberMap { + if _, exists := oldMemberMap[key]; exists { + updateData := map[string]interface{}{ + "team_id": teamID, + "role": newMember["role"].(string), + "max_budget_in_team": maxBudget, + } + if userID, ok := newMember["user_id"].(string); ok && userID != "" { + updateData["user_id"] = userID + } + if userEmail, ok := newMember["user_email"].(string); ok && userEmail != "" { + updateData["user_email"] = userEmail + } + + log.Printf("[DEBUG] Update team member budget request payload: %+v", updateData) + + resp, err := MakeRequest(client, "POST", "/team/member_update", updateData) + if err != nil { + return fmt.Errorf("error updating team member budget: %v", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "updating team member budget"); err != nil { + return err + } + + // Mark this member as updated + updatedMembers[key] = true + } + } + } + + // Find members to delete (in old but not in new) + for key, oldMember := range oldMemberMap { + if _, exists := newMemberMap[key]; !exists { + deleteData := map[string]interface{}{ + "team_id": teamID, + } + if userID, ok := oldMember["user_id"].(string); ok && userID != "" { + deleteData["user_id"] = userID + } + if userEmail, ok := oldMember["user_email"].(string); ok && userEmail != "" { + deleteData["user_email"] = userEmail + } + + log.Printf("[DEBUG] Delete team member request payload: %+v", deleteData) + + resp, err := MakeRequest(client, "POST", "/team/member_delete", deleteData) + if err != nil { + return fmt.Errorf("error deleting team member: %v", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "deleting team member"); err != nil { + return err + } + } + } + + // Find members to update (exist in both but with different attributes) + // Skip members that were already updated due to budget change + for key, newMember := range newMemberMap { + if oldMember, exists := oldMemberMap[key]; exists { + // Skip if already updated due to budget change + if updatedMembers[key] { + continue + } + + // Check if member attributes have changed + if memberAttributesChanged(oldMember, newMember) { + updateData := map[string]interface{}{ + "team_id": teamID, + "role": newMember["role"].(string), + "max_budget_in_team": maxBudget, + } + if userID, ok := newMember["user_id"].(string); ok && userID != "" { + updateData["user_id"] = userID + } + if userEmail, ok := newMember["user_email"].(string); ok && userEmail != "" { + updateData["user_email"] = userEmail + } + + log.Printf("[DEBUG] Update team member request payload: %+v", updateData) + + resp, err := MakeRequest(client, "POST", "/team/member_update", updateData) + if err != nil { + return fmt.Errorf("error updating team member: %v", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "updating team member"); err != nil { + return err + } + } + } + } + + // Find members to add (in new but not in old) + var membersToAdd []map[string]interface{} + for key, newMember := range newMemberMap { + if _, exists := oldMemberMap[key]; !exists { + memberData := map[string]interface{}{ + "role": newMember["role"].(string), + } + if userID, ok := newMember["user_id"].(string); ok && userID != "" { + memberData["user_id"] = userID + } + if userEmail, ok := newMember["user_email"].(string); ok && userEmail != "" { + memberData["user_email"] = userEmail + } + membersToAdd = append(membersToAdd, memberData) + } + } + + if len(membersToAdd) > 0 { + memberData := map[string]interface{}{ + "member": membersToAdd, + "team_id": teamID, + "max_budget_in_team": maxBudget, + } + + log.Printf("[DEBUG] Adding new team members request payload: %+v", memberData) + + resp, err := MakeRequest(client, "POST", "/team/member_add", memberData) + if err != nil { + return fmt.Errorf("error adding team members: %v", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "adding team members"); err != nil { + return err + } + } + + return resourceLiteLLMTeamMemberAddRead(d, m) +} + +// getMemberKey returns a unique key for a member based on user_id or user_email +func getMemberKey(member map[string]interface{}) string { + if userID, ok := member["user_id"].(string); ok && userID != "" { + return "id:" + userID + } + if userEmail, ok := member["user_email"].(string); ok && userEmail != "" { + return "email:" + userEmail + } + return "" +} + +// memberAttributesChanged checks if member attributes have changed between old and new +func memberAttributesChanged(oldMember, newMember map[string]interface{}) bool { + // Compare role + oldRole, _ := oldMember["role"].(string) + newRole, _ := newMember["role"].(string) + if oldRole != newRole { + return true + } + + // Note: max_budget_in_team is handled at the resource level, not per member + // so we don't need to compare it here + + return false +} + +func resourceLiteLLMTeamMemberAddDelete(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + teamID := d.Get("team_id").(string) + members := d.Get("member").(*schema.Set) + + // Delete each member + for _, member := range members.List() { + m := member.(map[string]interface{}) + deleteData := map[string]interface{}{ + "team_id": teamID, + } + if userID, ok := m["user_id"].(string); ok && userID != "" { + deleteData["user_id"] = userID + } + if userEmail, ok := m["user_email"].(string); ok && userEmail != "" { + deleteData["user_email"] = userEmail + } + + resp, err := MakeRequest(client, "POST", "/team/member_delete", deleteData) + if err != nil { + return fmt.Errorf("error deleting team member: %v", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "deleting team member"); err != nil { + return err + } + } + + d.SetId("") + return nil +} diff --git a/terraform/provider/litellm/resource_team_member_test.go b/terraform/provider/litellm/resource_team_member_test.go new file mode 100644 index 00000000000..156c4b3abfa --- /dev/null +++ b/terraform/provider/litellm/resource_team_member_test.go @@ -0,0 +1,44 @@ +package litellm + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func TestTeamMemberUpdateSendsRole(t *testing.T) { + var captured map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &captured) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := schema.TestResourceDataRaw(t, resourceLiteLLMTeamMember().Schema, map[string]interface{}{ + "team_id": "team-1", + "user_id": "user-1", + "user_email": "user@example.com", + "role": "user", + }) + d.SetId("team-1:user-1") + + if err := resourceLiteLLMTeamMemberUpdate(d, client); err != nil { + t.Fatalf("update failed: %v", err) + } + + role, ok := captured["role"] + if !ok { + t.Fatalf("update payload missing role field: %v", captured) + } + if role != "user" { + t.Fatalf("update payload sent role %v, want user", role) + } +} diff --git a/terraform/provider/litellm/resource_vector_store.go b/terraform/provider/litellm/resource_vector_store.go new file mode 100644 index 00000000000..f77ba18c6d4 --- /dev/null +++ b/terraform/provider/litellm/resource_vector_store.go @@ -0,0 +1,65 @@ +package litellm + +import ( + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceLiteLLMVectorStore() *schema.Resource { + return &schema.Resource{ + Create: resourceLiteLLMVectorStoreCreate, + Read: resourceLiteLLMVectorStoreRead, + Update: resourceLiteLLMVectorStoreUpdate, + Delete: resourceLiteLLMVectorStoreDelete, + + Schema: map[string]*schema.Schema{ + "vector_store_id": { + Type: schema.TypeString, + Computed: true, + Description: "Unique identifier for the vector store", + }, + "vector_store_name": { + Type: schema.TypeString, + Required: true, + Description: "Name of the vector store", + }, + "custom_llm_provider": { + Type: schema.TypeString, + Required: true, + Description: "Custom LLM provider for the vector store", + }, + "vector_store_description": { + Type: schema.TypeString, + Optional: true, + Description: "Description of the vector store", + }, + "vector_store_metadata": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Metadata associated with the vector store", + }, + "litellm_credential_name": { + Type: schema.TypeString, + Optional: true, + Description: "Name of the LiteLLM credential to use", + }, + "litellm_params": { + Type: schema.TypeMap, + Optional: true, + Sensitive: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Additional LiteLLM parameters", + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + Description: "Timestamp when the vector store was created", + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Timestamp when the vector store was last updated", + }, + }, + } +} diff --git a/terraform/provider/litellm/resource_vector_store_crud.go b/terraform/provider/litellm/resource_vector_store_crud.go new file mode 100644 index 00000000000..b05017f7125 --- /dev/null +++ b/terraform/provider/litellm/resource_vector_store_crud.go @@ -0,0 +1,168 @@ +package litellm + +import ( + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceLiteLLMVectorStoreCreate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + + vectorStoreName := d.Get("vector_store_name").(string) + customLLMProvider := d.Get("custom_llm_provider").(string) + vectorStoreDescription := d.Get("vector_store_description").(string) + vectorStoreMetadata := d.Get("vector_store_metadata").(map[string]interface{}) + litellmCredentialName := d.Get("litellm_credential_name").(string) + litellmParams := d.Get("litellm_params").(map[string]interface{}) + + // Convert metadata to map[string]interface{} for JSON + metadataMap := make(map[string]interface{}) + for k, v := range vectorStoreMetadata { + metadataMap[k] = v + } + + // Convert litellm_params to map[string]interface{} for JSON + paramsMap := make(map[string]interface{}) + for k, v := range litellmParams { + paramsMap[k] = v + } + + vectorStoreRequest := VectorStoreRequest{ + CustomLLMProvider: customLLMProvider, + VectorStoreName: vectorStoreName, + VectorStoreDescription: vectorStoreDescription, + VectorStoreMetadata: metadataMap, + LiteLLMCredentialName: litellmCredentialName, + LiteLLMParams: paramsMap, + } + + resp, err := MakeRequest(client, "POST", "/vector_store/new", vectorStoreRequest) + if err != nil { + return fmt.Errorf("failed to create vector store: %w", err) + } + defer resp.Body.Close() + + err = handleVectorStoreAPIResponse(resp, nil, client) + if err != nil { + return fmt.Errorf("failed to create vector store: %w", err) + } + + // Set the resource ID to the vector store name for now + // We'll update this after reading the response to get the actual ID + d.SetId(vectorStoreName) + + return resourceLiteLLMVectorStoreRead(d, m) +} + +func resourceLiteLLMVectorStoreRead(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + vectorStoreID := d.Id() + + // Use the info endpoint to get vector store details + infoRequest := VectorStoreInfoRequest{ + VectorStoreID: vectorStoreID, + } + + resp, err := MakeRequest(client, "POST", "/vector_store/info", infoRequest) + if err != nil { + return fmt.Errorf("failed to read vector store: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + d.SetId("") + return nil + } + + var vectorStoreResp VectorStoreResponse + err = handleVectorStoreAPIResponse(resp, &vectorStoreResp, client) + if err != nil { + if err.Error() == "vector_store_not_found" { + d.SetId("") + return nil + } + return fmt.Errorf("failed to read vector store: %w", err) + } + + // Update the resource ID to the actual vector store ID from the response + if vectorStoreResp.VectorStoreID != "" { + d.SetId(vectorStoreResp.VectorStoreID) + } + + d.Set("vector_store_id", vectorStoreResp.VectorStoreID) + d.Set("vector_store_name", vectorStoreResp.VectorStoreName) + d.Set("custom_llm_provider", vectorStoreResp.CustomLLMProvider) + d.Set("vector_store_description", vectorStoreResp.VectorStoreDescription) + d.Set("vector_store_metadata", vectorStoreResp.VectorStoreMetadata) + d.Set("litellm_credential_name", vectorStoreResp.LiteLLMCredentialName) + d.Set("created_at", vectorStoreResp.CreatedAt) + d.Set("updated_at", vectorStoreResp.UpdatedAt) + + return nil +} + +func resourceLiteLLMVectorStoreUpdate(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + vectorStoreID := d.Id() + + vectorStoreName := d.Get("vector_store_name").(string) + customLLMProvider := d.Get("custom_llm_provider").(string) + vectorStoreDescription := d.Get("vector_store_description").(string) + vectorStoreMetadata := d.Get("vector_store_metadata").(map[string]interface{}) + + // Convert metadata to map[string]interface{} for JSON + metadataMap := make(map[string]interface{}) + for k, v := range vectorStoreMetadata { + metadataMap[k] = v + } + + vectorStoreRequest := VectorStoreRequest{ + VectorStoreID: vectorStoreID, + CustomLLMProvider: customLLMProvider, + VectorStoreName: vectorStoreName, + VectorStoreDescription: vectorStoreDescription, + VectorStoreMetadata: metadataMap, + } + + resp, err := MakeRequest(client, "POST", "/vector_store/update", vectorStoreRequest) + if err != nil { + return fmt.Errorf("failed to update vector store: %w", err) + } + defer resp.Body.Close() + + err = handleVectorStoreAPIResponse(resp, nil, client) + if err != nil { + return fmt.Errorf("failed to update vector store: %w", err) + } + + return resourceLiteLLMVectorStoreRead(d, m) +} + +func resourceLiteLLMVectorStoreDelete(d *schema.ResourceData, m interface{}) error { + client := m.(*Client) + vectorStoreID := d.Id() + + deleteRequest := VectorStoreDeleteRequest{ + VectorStoreID: vectorStoreID, + } + + resp, err := MakeRequest(client, "POST", "/vector_store/delete", deleteRequest) + if err != nil { + return fmt.Errorf("failed to delete vector store: %w", err) + } + defer resp.Body.Close() + + err = handleVectorStoreAPIResponse(resp, nil, client) + if err != nil { + if err.Error() == "vector_store_not_found" { + d.SetId("") + return nil + } + return fmt.Errorf("failed to delete vector store: %w", err) + } + + d.SetId("") + return nil +} diff --git a/terraform/provider/litellm/resource_vector_store_crud_test.go b/terraform/provider/litellm/resource_vector_store_crud_test.go new file mode 100644 index 00000000000..485ec54346d --- /dev/null +++ b/terraform/provider/litellm/resource_vector_store_crud_test.go @@ -0,0 +1,55 @@ +package litellm + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func TestVectorStoreReadDoesNotPersistServerLitellmParams(t *testing.T) { + resp := VectorStoreResponse{ + VectorStoreID: "vs-123", + VectorStoreName: "kb", + CustomLLMProvider: "openai", + LiteLLMParams: map[string]interface{}{ + "api_key": "sk-from-server", + "api_base": "https://upstream.example.com", + }, + } + body, _ := json.Marshal(resp) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(body) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := schema.TestResourceDataRaw(t, resourceLiteLLMVectorStore().Schema, map[string]interface{}{ + "vector_store_name": "kb", + "custom_llm_provider": "openai", + "litellm_params": map[string]interface{}{ + "vector_store_id": "vs-123", + }, + }) + d.SetId("vs-123") + + if err := resourceLiteLLMVectorStoreRead(d, client); err != nil { + t.Fatalf("read failed: %v", err) + } + + got := d.Get("litellm_params").(map[string]interface{}) + if _, leaked := got["api_key"]; leaked { + t.Fatalf("server-returned api_key persisted into state: %v", got) + } + if got["vector_store_id"] != "vs-123" { + t.Fatalf("config litellm_params not preserved: %v", got) + } + if d.Get("vector_store_name").(string) != "kb" { + t.Fatalf("read did not populate non-sensitive fields") + } +} diff --git a/terraform/provider/litellm/types.go b/terraform/provider/litellm/types.go new file mode 100644 index 00000000000..069fe4b3e23 --- /dev/null +++ b/terraform/provider/litellm/types.go @@ -0,0 +1,248 @@ +package litellm + +// ProviderConfig holds the configuration for the LiteLLM provider. +type ProviderConfig struct { + APIBase string + APIKey string + InsecureSkipVerify bool +} + +// ErrorResponse represents an error response from the API. +type ErrorResponse struct { + Error struct { + Message interface{} `json:"message"` + } `json:"error"` + Detail struct { + Error string `json:"error"` + } `json:"detail"` +} + +// ModelResponse represents a response from the API containing model information. +type ModelResponse struct { + ModelName string `json:"model_name"` + LiteLLMParams LiteLLMParams `json:"litellm_params"` + ModelInfo ModelInfo `json:"model_info"` + Additional map[string]interface{} `json:"additional"` +} + +// ModelRequest represents a request to create or update a model. +type ModelRequest struct { + ModelName string `json:"model_name"` + LiteLLMParams map[string]interface{} `json:"litellm_params"` + ModelInfo ModelInfo `json:"model_info"` + Additional map[string]interface{} `json:"additional"` +} + +// TeamResponse represents a response from the API containing team information. +type TeamResponse struct { + TeamID string `json:"team_id,omitempty"` + TeamAlias string `json:"team_alias,omitempty"` + OrganizationID string `json:"organization_id,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + TPMLimit *int `json:"tpm_limit,omitempty"` + RPMLimit *int `json:"rpm_limit,omitempty"` + MaxBudget *float64 `json:"max_budget,omitempty"` + BudgetDuration string `json:"budget_duration,omitempty"` + Models []string `json:"models"` + Blocked bool `json:"blocked,omitempty"` + TeamMemberPermissions []string `json:"team_member_permissions,omitempty"` +} + +// OrganizationResponse represents a response from the API containing organization information. +type OrganizationResponse struct { + OrganizationID string `json:"organization_id,omitempty"` + OrganizationAlias string `json:"organization_alias,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + Models []string `json:"models,omitempty"` + MaxBudget *float64 `json:"max_budget,omitempty"` + BudgetDuration string `json:"budget_duration,omitempty"` + TPMLimit *int `json:"tpm_limit,omitempty"` + RPMLimit *int `json:"rpm_limit,omitempty"` + Blocked bool `json:"blocked,omitempty"` +} + +// LiteLLMParams represents the parameters for LiteLLM. +type LiteLLMParams struct { + CustomLLMProvider string `json:"custom_llm_provider"` + TPM int `json:"tpm,omitempty"` + RPM int `json:"rpm,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + Thinking map[string]interface{} `json:"thinking,omitempty"` + MergeReasoningContentInChoices bool `json:"merge_reasoning_content_in_choices,omitempty"` + APIKey string `json:"api_key,omitempty"` + APIBase string `json:"api_base,omitempty"` + APIVersion string `json:"api_version,omitempty"` + Model string `json:"model"` + InputCostPerToken float64 `json:"input_cost_per_token,omitempty"` + OutputCostPerToken float64 `json:"output_cost_per_token,omitempty"` + InputCostPerPixel float64 `json:"input_cost_per_pixel,omitempty"` + OutputCostPerPixel float64 `json:"output_cost_per_pixel,omitempty"` + InputCostPerSecond float64 `json:"input_cost_per_second,omitempty"` + OutputCostPerSecond float64 `json:"output_cost_per_second,omitempty"` + AWSAccessKeyID string `json:"aws_access_key_id,omitempty"` + AWSSecretAccessKey string `json:"aws_secret_access_key,omitempty"` + AWSRegionName string `json:"aws_region_name,omitempty"` + AWSSessionName string `json:"aws_session_name,omitempty"` + AWSRoleName string `json:"aws_role_name,omitempty"` + VertexProject string `json:"vertex_project,omitempty"` + VertexLocation string `json:"vertex_location,omitempty"` + VertexCredentials string `json:"vertex_credentials,omitempty"` +} + +// ModelInfo represents information about a model. +type ModelInfo struct { + ID string `json:"id"` + DBModel bool `json:"db_model"` + BaseModel string `json:"base_model"` + Tier string `json:"tier"` + Mode string `json:"mode"` + TeamID string `json:"team_id,omitempty"` +} + +// Key represents a LiteLLM API key. +type Key struct { + Key string `json:"key,omitempty"` + TokenID string `json:"token_id,omitempty"` + Models []string `json:"models"` + Spend float64 `json:"spend,omitempty"` + MaxBudget *float64 `json:"max_budget,omitempty"` + UserID string `json:"user_id,omitempty"` + TeamID string `json:"team_id,omitempty"` + MaxParallelRequests *int `json:"max_parallel_requests,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + TPMLimit *int `json:"tpm_limit,omitempty"` + RPMLimit *int `json:"rpm_limit,omitempty"` + BudgetDuration string `json:"budget_duration,omitempty"` + AllowedCacheControls []string `json:"allowed_cache_controls,omitempty"` + SoftBudget *float64 `json:"soft_budget,omitempty"` + KeyAlias string `json:"key_alias,omitempty"` + Duration string `json:"duration,omitempty"` + Aliases map[string]interface{} `json:"aliases,omitempty"` + Config map[string]interface{} `json:"config,omitempty"` + Permissions map[string]interface{} `json:"permissions,omitempty"` + ModelMaxBudget map[string]interface{} `json:"model_max_budget,omitempty"` + ModelRPMLimit map[string]interface{} `json:"model_rpm_limit,omitempty"` + ModelTPMLimit map[string]interface{} `json:"model_tpm_limit,omitempty"` + Guardrails []string `json:"guardrails,omitempty"` + Blocked bool `json:"blocked"` + Tags []string `json:"tags,omitempty"` +} + +// KeyResponse represents a response from the API containing key information. +type KeyResponse struct { + Key string `json:"key"` +} + +// MCPServerCostInfo represents cost information for MCP server tools. +type MCPServerCostInfo struct { + DefaultCostPerQuery float64 `json:"default_cost_per_query,omitempty"` + ToolNameToCostPerQuery map[string]float64 `json:"tool_name_to_cost_per_query,omitempty"` +} + +// MCPInfo represents MCP server information and configuration. +type MCPInfo struct { + ServerName string `json:"server_name,omitempty"` + Description string `json:"description,omitempty"` + LogoURL string `json:"logo_url,omitempty"` + MCPServerCostInfo *MCPServerCostInfo `json:"mcp_server_cost_info,omitempty"` +} + +// MCPServerRequest represents a request to create or update an MCP server. +type MCPServerRequest struct { + ServerID string `json:"server_id,omitempty"` + ServerName string `json:"server_name"` + Alias string `json:"alias,omitempty"` + Description string `json:"description,omitempty"` + Transport string `json:"transport"` + SpecVersion string `json:"spec_version,omitempty"` + AuthType string `json:"auth_type,omitempty"` + URL string `json:"url"` + MCPInfo *MCPInfo `json:"mcp_info,omitempty"` + MCPAccessGroups []string `json:"mcp_access_groups,omitempty"` + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` +} + +// MCPServerResponse represents a response from the API containing MCP server information. +type MCPServerResponse struct { + ServerID string `json:"server_id"` + ServerName string `json:"server_name"` + Alias string `json:"alias,omitempty"` + Description string `json:"description,omitempty"` + URL string `json:"url"` + Transport string `json:"transport"` + SpecVersion string `json:"spec_version,omitempty"` + AuthType string `json:"auth_type,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + CreatedBy string `json:"created_by,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + UpdatedBy string `json:"updated_by,omitempty"` + Teams []map[string]string `json:"teams,omitempty"` + MCPAccessGroups []string `json:"mcp_access_groups,omitempty"` + MCPInfo *MCPInfo `json:"mcp_info,omitempty"` + Status string `json:"status,omitempty"` + LastHealthCheck string `json:"last_health_check,omitempty"` + HealthCheckError string `json:"health_check_error,omitempty"` + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` +} + +// CredentialRequest represents a request to create or update a credential. +type CredentialRequest struct { + CredentialName string `json:"credential_name"` + CredentialInfo map[string]interface{} `json:"credential_info,omitempty"` + CredentialValues map[string]interface{} `json:"credential_values,omitempty"` + ModelID string `json:"model_id,omitempty"` +} + +// CredentialResponse represents a response from the API containing credential information. +type CredentialResponse struct { + CredentialName string `json:"credential_name"` + CredentialInfo map[string]interface{} `json:"credential_info,omitempty"` + CredentialValues map[string]interface{} `json:"credential_values,omitempty"` +} + +// VectorStoreRequest represents a request to create or update a vector store. +type VectorStoreRequest struct { + VectorStoreID string `json:"vector_store_id,omitempty"` + CustomLLMProvider string `json:"custom_llm_provider"` + VectorStoreName string `json:"vector_store_name"` + VectorStoreDescription string `json:"vector_store_description,omitempty"` + VectorStoreMetadata map[string]interface{} `json:"vector_store_metadata,omitempty"` + LiteLLMCredentialName string `json:"litellm_credential_name,omitempty"` + LiteLLMParams map[string]interface{} `json:"litellm_params,omitempty"` +} + +// VectorStoreResponse represents a response from the API containing vector store information. +type VectorStoreResponse struct { + VectorStoreID string `json:"vector_store_id"` + CustomLLMProvider string `json:"custom_llm_provider"` + VectorStoreName string `json:"vector_store_name"` + VectorStoreDescription string `json:"vector_store_description,omitempty"` + VectorStoreMetadata map[string]interface{} `json:"vector_store_metadata,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + LiteLLMCredentialName string `json:"litellm_credential_name,omitempty"` + LiteLLMParams map[string]interface{} `json:"litellm_params,omitempty"` +} + +// VectorStoreListResponse represents a response from the API containing a list of vector stores. +type VectorStoreListResponse struct { + Object string `json:"object"` + Data []VectorStoreResponse `json:"data"` + TotalCount int `json:"total_count"` + CurrentPage int `json:"current_page"` + TotalPages int `json:"total_pages"` +} + +// VectorStoreDeleteRequest represents a request to delete a vector store. +type VectorStoreDeleteRequest struct { + VectorStoreID string `json:"vector_store_id"` +} + +// VectorStoreInfoRequest represents a request to get vector store information. +type VectorStoreInfoRequest struct { + VectorStoreID string `json:"vector_store_id"` +} diff --git a/terraform/provider/litellm/utils.go b/terraform/provider/litellm/utils.go new file mode 100644 index 00000000000..01d8045300c --- /dev/null +++ b/terraform/provider/litellm/utils.go @@ -0,0 +1,279 @@ +package litellm + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +func isModelNotFoundError(errResp ErrorResponse) bool { + if msg, ok := errResp.Error.Message.(string); ok { + if strings.Contains(msg, "model not found") { + return true + } + } + + if msgMap, ok := errResp.Error.Message.(map[string]interface{}); ok { + if errStr, ok := msgMap["error"].(string); ok { + if strings.Contains(errStr, "Model with id=") && strings.Contains(errStr, "not found in db") { + return true + } + } + } + + // Check Detail.Error field for LiteLLM proxy error format + if errResp.Detail.Error != "" { + if strings.Contains(errResp.Detail.Error, "not found on litellm proxy") { + return true + } + } + + return false +} + +func handleAPIResponse(resp *http.Response, reqBody interface{}, client *Client) (*ModelResponse, error) { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %v", err) + } + + if resp.StatusCode != http.StatusOK { + var errResp ErrorResponse + if err := json.Unmarshal(bodyBytes, &errResp); err == nil { + if isModelNotFoundError(errResp) { + return nil, fmt.Errorf("model_not_found") + } + } + reqBodyBytes, _ := json.Marshal(reqBody) + return nil, fmt.Errorf("API request failed: Status: %s, Response: %s, Request: %s", + resp.Status, client.redactSensitiveData(string(bodyBytes)), client.redactSensitiveData(string(reqBodyBytes))) + } + + var modelResp ModelResponse + if err := json.Unmarshal(bodyBytes, &modelResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %v", err) + } + + return &modelResp, nil +} + +// MakeRequest is a helper function to make HTTP requests +func MakeRequest(client *Client, method, endpoint string, body interface{}) (*http.Response, error) { + var req *http.Request + var err error + + if body != nil { + jsonData, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + req, err = http.NewRequest(method, fmt.Sprintf("%s%s", client.APIBase, endpoint), bytes.NewBuffer(jsonData)) + } else { + req, err = http.NewRequest(method, fmt.Sprintf("%s%s", client.APIBase, endpoint), nil) + } + + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", client.APIKey) + + return client.httpClient.Do(req) +} + +// Helper functions to handle potential nil values from the API response +func GetStringValue(apiValue, defaultValue string) string { + if apiValue != "" { + return apiValue + } + return defaultValue +} + +func GetIntValue(apiValue, defaultValue int) int { + if apiValue != 0 { + return apiValue + } + return defaultValue +} + +func GetFloatValue(apiValue, defaultValue float64) float64 { + if apiValue != 0 { + return apiValue + } + return defaultValue +} + +func GetBoolValue(apiValue, defaultValue bool) bool { + return apiValue +} + +// handleMCPAPIResponse handles API responses specifically for MCP server operations +func handleMCPAPIResponse(resp *http.Response, result interface{}, client *Client) error { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %v", err) + } + + if resp.StatusCode != http.StatusOK { + var errResp ErrorResponse + if err := json.Unmarshal(bodyBytes, &errResp); err == nil { + if isMCPServerNotFoundError(errResp) { + return fmt.Errorf("mcp_server_not_found") + } + } + return fmt.Errorf("API request failed: Status: %s, Response: %s", + resp.Status, client.redactSensitiveData(string(bodyBytes))) + } + + if err := json.Unmarshal(bodyBytes, result); err != nil { + return fmt.Errorf("failed to parse response: %v", err) + } + + return nil +} + +// isMCPServerNotFoundError checks if the error response indicates an MCP server not found +func isMCPServerNotFoundError(errResp ErrorResponse) bool { + if msg, ok := errResp.Error.Message.(string); ok { + if strings.Contains(msg, "mcp server not found") || strings.Contains(msg, "server not found") { + return true + } + } + + if msgMap, ok := errResp.Error.Message.(map[string]interface{}); ok { + if errStr, ok := msgMap["error"].(string); ok { + if strings.Contains(errStr, "MCP server with id=") && strings.Contains(errStr, "not found") { + return true + } + } + } + + // Check Detail.Error field for LiteLLM proxy error format + if errResp.Detail.Error != "" { + if strings.Contains(errResp.Detail.Error, "not found") { + return true + } + } + + return false +} + +// isCredentialNotFoundError checks if the error response indicates a credential not found +func isCredentialNotFoundError(errResp ErrorResponse) bool { + if msg, ok := errResp.Error.Message.(string); ok { + if strings.Contains(msg, "credential not found") { + return true + } + } + + if msgMap, ok := errResp.Error.Message.(map[string]interface{}); ok { + if errStr, ok := msgMap["error"].(string); ok { + if strings.Contains(errStr, "Credential with name=") && strings.Contains(errStr, "not found") { + return true + } + } + } + + // Check Detail.Error field for LiteLLM proxy error format + if errResp.Detail.Error != "" { + if strings.Contains(errResp.Detail.Error, "credential not found") { + return true + } + } + + return false +} + +// handleCredentialAPIResponse handles API responses specifically for credential operations +func handleCredentialAPIResponse(resp *http.Response, result interface{}, client *Client) error { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %v", err) + } + + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("credential_not_found") + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + var errResp ErrorResponse + if err := json.Unmarshal(bodyBytes, &errResp); err == nil { + if isCredentialNotFoundError(errResp) { + return fmt.Errorf("credential_not_found") + } + } + return fmt.Errorf("API request failed: Status: %s, Response: %s", + resp.Status, client.redactSensitiveData(string(bodyBytes))) + } + + // For credential operations, we might get a simple string response or a credential object + if result != nil { + if err := json.Unmarshal(bodyBytes, result); err != nil { + // If parsing fails, it might be a simple string response which is fine for create/update/delete + return nil + } + } + + return nil +} + +// isVectorStoreNotFoundError checks if the error response indicates a vector store not found +func isVectorStoreNotFoundError(errResp ErrorResponse) bool { + if msg, ok := errResp.Error.Message.(string); ok { + if strings.Contains(msg, "vector store not found") { + return true + } + } + + if msgMap, ok := errResp.Error.Message.(map[string]interface{}); ok { + if errStr, ok := msgMap["error"].(string); ok { + if strings.Contains(errStr, "Vector store with id=") && strings.Contains(errStr, "not found") { + return true + } + } + } + + // Check Detail.Error field for LiteLLM proxy error format + if errResp.Detail.Error != "" { + if strings.Contains(errResp.Detail.Error, "vector store not found") { + return true + } + } + + return false +} + +// handleVectorStoreAPIResponse handles API responses specifically for vector store operations +func handleVectorStoreAPIResponse(resp *http.Response, result interface{}, client *Client) error { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %v", err) + } + + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("vector_store_not_found") + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + var errResp ErrorResponse + if err := json.Unmarshal(bodyBytes, &errResp); err == nil { + if isVectorStoreNotFoundError(errResp) { + return fmt.Errorf("vector_store_not_found") + } + } + return fmt.Errorf("API request failed: Status: %s, Response: %s", + resp.Status, client.redactSensitiveData(string(bodyBytes))) + } + + if result != nil { + if err := json.Unmarshal(bodyBytes, result); err != nil { + return fmt.Errorf("failed to parse response: %v", err) + } + } + + return nil +} diff --git a/terraform/provider/main.go b/terraform/provider/main.go new file mode 100644 index 00000000000..abe83718899 --- /dev/null +++ b/terraform/provider/main.go @@ -0,0 +1,14 @@ +package main + +import ( + "github.com/BerriAI/terraform-provider-litellm/litellm" + "github.com/hashicorp/terraform-plugin-sdk/v2/plugin" +) + +// main is the entry point for the plugin. It serves the provider +// using the Terraform plugin SDK. +func main() { + plugin.Serve(&plugin.ServeOpts{ + ProviderFunc: litellm.Provider, + }) +} diff --git a/terraform/provider/terraform-registry-manifest.json b/terraform/provider/terraform-registry-manifest.json new file mode 100644 index 00000000000..295001a07f7 --- /dev/null +++ b/terraform/provider/terraform-registry-manifest.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "metadata": { + "protocol_versions": ["6.0"] + } +} diff --git a/terraform/provider/tools/dump_openapi.py b/terraform/provider/tools/dump_openapi.py new file mode 100644 index 00000000000..b4f2dceeb09 --- /dev/null +++ b/terraform/provider/tools/dump_openapi.py @@ -0,0 +1,23 @@ +"""Dump the LiteLLM proxy's OpenAPI schema to the path given as the only argument. + +Run from the litellm repo root with the proxy dependencies installed: + + python terraform/provider/tools/dump_openapi.py openapi.json +""" + +import json +import sys + +from litellm.proxy.proxy_server import app + + +def main(out_path: str) -> None: + with open(out_path, "w") as f: + json.dump(app.openapi(), f) + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("usage: python terraform/provider/tools/dump_openapi.py ", file=sys.stderr) + sys.exit(2) + main(sys.argv[1]) diff --git a/terraform/provider/tools/endpointaudit/main.go b/terraform/provider/tools/endpointaudit/main.go new file mode 100644 index 00000000000..ebc011ee910 --- /dev/null +++ b/terraform/provider/tools/endpointaudit/main.go @@ -0,0 +1,345 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "regexp" + "sort" + "strconv" + "strings" +) + +type endpointCall struct { + Method string + Path string + Pos string +} + +type extraction struct { + Calls []endpointCall + Unresolved []string +} + +var formatVerbPattern = regexp.MustCompile(`%[sdv]`) + +func normalizePath(raw string) string { + withoutQuery := strings.SplitN(raw, "?", 2)[0] + return formatVerbPattern.ReplaceAllString(withoutQuery, "{param}") +} + +func stringLit(expr ast.Expr) (string, bool) { + lit, ok := expr.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return "", false + } + value, err := strconv.Unquote(lit.Value) + if err != nil { + return "", false + } + return value, true +} + +func packageConsts(files []*ast.File) map[string]string { + consts := make(map[string]string) + for _, file := range files { + for _, decl := range file.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || (genDecl.Tok != token.CONST && genDecl.Tok != token.VAR) { + continue + } + for _, spec := range genDecl.Specs { + valueSpec, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + for i, name := range valueSpec.Names { + if i >= len(valueSpec.Values) { + continue + } + if value, ok := stringLit(valueSpec.Values[i]); ok { + consts[name.Name] = value + } + } + } + } + } + return consts +} + +func isSprintf(call *ast.CallExpr) bool { + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "Sprintf" { + return false + } + pkg, ok := sel.X.(*ast.Ident) + return ok && pkg.Name == "fmt" +} + +func resolveExpr(expr ast.Expr, fn *ast.FuncDecl, consts map[string]string) []string { + switch node := expr.(type) { + case *ast.BasicLit: + if value, ok := stringLit(node); ok { + return []string{value} + } + case *ast.Ident: + if value, ok := consts[node.Name]; ok { + return []string{value} + } + return resolveLocalIdent(node, fn, consts) + case *ast.CallExpr: + if isSprintf(node) && len(node.Args) > 0 { + return resolveSprintf(node, fn, consts) + } + } + return nil +} + +func resolveSprintf(call *ast.CallExpr, fn *ast.FuncDecl, consts map[string]string) []string { + formats := resolveExpr(call.Args[0], fn, consts) + results := formats + for _, arg := range call.Args[1:] { + argValues := resolveExpr(arg, fn, consts) + substituted := make([]string, 0, len(results)) + for _, format := range results { + verb := formatVerbPattern.FindStringIndex(format) + if verb == nil { + substituted = append(substituted, format) + continue + } + if len(argValues) == 0 { + substituted = append(substituted, format[:verb[0]]+"\x00param\x00"+format[verb[1]:]) + continue + } + for _, argValue := range argValues { + substituted = append(substituted, format[:verb[0]]+argValue+format[verb[1]:]) + } + } + results = substituted + } + restored := make([]string, 0, len(results)) + for _, result := range results { + restored = append(restored, strings.ReplaceAll(result, "\x00param\x00", "%s")) + } + return restored +} + +func resolveLocalIdent(ident *ast.Ident, fn *ast.FuncDecl, consts map[string]string) []string { + if fn == nil { + return nil + } + var values []string + ast.Inspect(fn.Body, func(node ast.Node) bool { + assign, ok := node.(*ast.AssignStmt) + if !ok { + return true + } + for i, lhs := range assign.Lhs { + lhsIdent, ok := lhs.(*ast.Ident) + if !ok || lhsIdent.Name != ident.Name || i >= len(assign.Rhs) { + continue + } + values = append(values, resolveExpr(assign.Rhs[i], fn, consts)...) + } + return true + }) + return values +} + +func requestCallMethodAndPath(call *ast.CallExpr) (methodArg ast.Expr, pathArg ast.Expr, matched bool) { + switch fun := call.Fun.(type) { + case *ast.SelectorExpr: + if fun.Sel.Name == "sendRequest" && len(call.Args) >= 2 { + return call.Args[0], call.Args[1], true + } + case *ast.Ident: + if fun.Name == "MakeRequest" && len(call.Args) >= 3 { + return call.Args[1], call.Args[2], true + } + } + return nil, nil, false +} + +func isRawHTTPRequest(call *ast.CallExpr) bool { + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || (sel.Sel.Name != "NewRequest" && sel.Sel.Name != "NewRequestWithContext") { + return false + } + pkg, ok := sel.X.(*ast.Ident) + return ok && pkg.Name == "http" +} + +func extractFromFiles(fset *token.FileSet, files []*ast.File, helperFiles map[string]bool) extraction { + consts := packageConsts(files) + var result extraction + for _, file := range files { + fileName := fset.Position(file.Pos()).Filename + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil { + continue + } + ast.Inspect(fn.Body, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + pos := fset.Position(call.Pos()).String() + if isRawHTTPRequest(call) && !helperFiles[fileName] { + result.Unresolved = append(result.Unresolved, + fmt.Sprintf("%s: raw http.NewRequest outside the request helpers; route it through Client.sendRequest or MakeRequest", pos)) + return true + } + methodArg, pathArg, matched := requestCallMethodAndPath(call) + if !matched { + return true + } + methods := resolveExpr(methodArg, fn, consts) + paths := resolveExpr(pathArg, fn, consts) + if len(methods) == 0 || len(paths) == 0 { + result.Unresolved = append(result.Unresolved, + fmt.Sprintf("%s: cannot statically resolve method or path; use a string literal, package const, or fmt.Sprintf with a literal format", pos)) + return true + } + for _, method := range methods { + for _, path := range paths { + result.Calls = append(result.Calls, endpointCall{Method: method, Path: normalizePath(path), Pos: pos}) + } + } + return true + }) + } + } + return result +} + +func extractProviderCalls(providerDir string) (extraction, error) { + fset := token.NewFileSet() + pkgs, err := parser.ParseDir(fset, providerDir, func(info os.FileInfo) bool { + return !strings.HasSuffix(info.Name(), "_test.go") + }, 0) + if err != nil { + return extraction{}, err + } + var files []*ast.File + helperFiles := make(map[string]bool) + for _, pkg := range pkgs { + fileNames := make([]string, 0, len(pkg.Files)) + for name := range pkg.Files { + fileNames = append(fileNames, name) + } + sort.Strings(fileNames) + for _, name := range fileNames { + files = append(files, pkg.Files[name]) + base := name[strings.LastIndex(name, "/")+1:] + if base == "client.go" || base == "utils.go" { + helperFiles[name] = true + } + } + } + return extractFromFiles(fset, files, helperFiles), nil +} + +func loadSpecPaths(specPath string) (map[string]map[string]json.RawMessage, error) { + data, err := os.ReadFile(specPath) + if err != nil { + return nil, err + } + var spec struct { + Paths map[string]map[string]json.RawMessage `json:"paths"` + } + if err := json.Unmarshal(data, &spec); err != nil { + return nil, err + } + if len(spec.Paths) == 0 { + return nil, fmt.Errorf("spec %s contains no paths", specPath) + } + return spec.Paths, nil +} + +func segmentsMatch(providerSegment, specSegment string) bool { + if providerSegment == "{param}" { + return strings.HasPrefix(specSegment, "{") && strings.HasSuffix(specSegment, "}") + } + return providerSegment == specSegment +} + +func pathMatches(providerPath, specPath string) bool { + providerSegments := strings.Split(strings.Trim(providerPath, "/"), "/") + specSegments := strings.Split(strings.Trim(specPath, "/"), "/") + if len(providerSegments) != len(specSegments) { + return false + } + for i := range providerSegments { + if !segmentsMatch(providerSegments[i], specSegments[i]) { + return false + } + } + return true +} + +func auditCalls(calls []endpointCall, specPaths map[string]map[string]json.RawMessage) []string { + var violations []string + for _, call := range calls { + pathFound := false + methodFound := false + for specPath, operations := range specPaths { + if !pathMatches(call.Path, specPath) { + continue + } + pathFound = true + if _, ok := operations[strings.ToLower(call.Method)]; ok { + methodFound = true + break + } + } + if !pathFound { + violations = append(violations, fmt.Sprintf("%s: %s %s is not served by the proxy", call.Pos, call.Method, call.Path)) + } else if !methodFound { + violations = append(violations, fmt.Sprintf("%s: %s %s: path exists but method not allowed", call.Pos, call.Method, call.Path)) + } + } + return violations +} + +func run(providerDir, specPath string) error { + extracted, err := extractProviderCalls(providerDir) + if err != nil { + return err + } + if len(extracted.Unresolved) > 0 { + return fmt.Errorf("unresolved call sites:\n %s", strings.Join(extracted.Unresolved, "\n ")) + } + if len(extracted.Calls) == 0 { + return fmt.Errorf("extracted zero request call sites from %s; extractor or provider layout changed", providerDir) + } + specPaths, err := loadSpecPaths(specPath) + if err != nil { + return err + } + violations := auditCalls(extracted.Calls, specPaths) + if len(violations) > 0 { + sort.Strings(violations) + return fmt.Errorf("provider/proxy endpoint drift:\n %s", strings.Join(violations, "\n ")) + } + fmt.Printf("OK: %d request call sites verified against %d proxy OpenAPI paths\n", len(extracted.Calls), len(specPaths)) + return nil +} + +func main() { + providerDir := flag.String("provider-dir", "./litellm", "directory containing the provider Go source") + specPath := flag.String("spec", "", "path to the proxy OpenAPI schema JSON") + flag.Parse() + if *specPath == "" { + fmt.Fprintln(os.Stderr, "error: -spec is required") + os.Exit(2) + } + if err := run(*providerDir, *specPath); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} diff --git a/terraform/provider/tools/endpointaudit/main_test.go b/terraform/provider/tools/endpointaudit/main_test.go new file mode 100644 index 00000000000..d3d5e7dec9c --- /dev/null +++ b/terraform/provider/tools/endpointaudit/main_test.go @@ -0,0 +1,187 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +func writeFixture(t *testing.T, dir, name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func extractFixture(t *testing.T, files map[string]string) extraction { + t.Helper() + dir := t.TempDir() + for name, body := range files { + writeFixture(t, dir, name, body) + } + result, err := extractProviderCalls(dir) + if err != nil { + t.Fatal(err) + } + return result +} + +func callSet(calls []endpointCall) []string { + set := make(map[string]bool) + for _, call := range calls { + set[call.Method+" "+call.Path] = true + } + keys := make([]string, 0, len(set)) + for key := range set { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func TestExtractResolvesAllCallShapes(t *testing.T) { + result := extractFixture(t, map[string]string{ + "consts.go": `package p + +const ( + endpointModelNew = "/model/new" + endpointModelUpdate = "/model/update" + endpointMCPRead = "/v1/mcp/server" +) +`, + "calls.go": `package p + +import "fmt" + +func (c *Client) a() { + c.sendRequest("POST", "/team/new", nil) + c.sendRequest("GET", fmt.Sprintf("/team/info?team_id=%s", "x"), nil) +} + +func b(client *Client, isUpdate bool, serverID string) { + MakeRequest(client, "POST", "/credentials", nil) + endpoint := endpointModelNew + if isUpdate { + endpoint = endpointModelUpdate + } + MakeRequest(client, "POST", endpoint, nil) + readEndpoint := fmt.Sprintf("%s/%s", endpointMCPRead, serverID) + MakeRequest(client, "GET", readEndpoint, nil) +} +`, + }) + if len(result.Unresolved) != 0 { + t.Fatalf("unexpected unresolved: %v", result.Unresolved) + } + got := callSet(result.Calls) + want := []string{ + "GET /team/info", + "GET /v1/mcp/server/{param}", + "POST /credentials", + "POST /model/new", + "POST /model/update", + "POST /team/new", + } + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestExtractFailsClosedOnDynamicPath(t *testing.T) { + result := extractFixture(t, map[string]string{ + "calls.go": `package p + +func a(c *Client, path string) { + c.sendRequest("GET", path, nil) +} +`, + }) + if len(result.Unresolved) != 1 { + t.Fatalf("want 1 unresolved call site, got %v", result.Unresolved) + } +} + +func TestExtractFlagsRawHTTPRequestOutsideHelpers(t *testing.T) { + result := extractFixture(t, map[string]string{ + "rogue.go": `package p + +import "net/http" + +func a() { + http.NewRequest("GET", "http://example.com/model/new", nil) +} +`, + }) + if len(result.Unresolved) != 1 || !strings.Contains(result.Unresolved[0], "raw http.NewRequest") { + t.Fatalf("want raw request violation, got %v", result.Unresolved) + } +} + +func TestExtractAllowsRawHTTPRequestInHelpers(t *testing.T) { + result := extractFixture(t, map[string]string{ + "utils.go": `package p + +import "net/http" + +func MakeRequest(client *Client, method, endpoint string, body interface{}) { + http.NewRequest(method, endpoint, nil) +} +`, + }) + if len(result.Unresolved) != 0 { + t.Fatalf("unexpected unresolved: %v", result.Unresolved) + } +} + +func specFixture(t *testing.T) map[string]map[string]json.RawMessage { + t.Helper() + raw := `{ + "paths": { + "/team/new": {"post": {}}, + "/organization/update": {"patch": {}}, + "/credentials/{credential_name}": {"get": {}, "delete": {}} + } + }` + dir := t.TempDir() + specPath := filepath.Join(dir, "spec.json") + if err := os.WriteFile(specPath, []byte(raw), 0o644); err != nil { + t.Fatal(err) + } + paths, err := loadSpecPaths(specPath) + if err != nil { + t.Fatal(err) + } + return paths +} + +func TestAuditDetectsMissingPathAndWrongMethod(t *testing.T) { + spec := specFixture(t) + violations := auditCalls([]endpointCall{ + {Method: "POST", Path: "/team/new", Pos: "a.go:1"}, + {Method: "GET", Path: "/credentials/{param}", Pos: "a.go:2"}, + {Method: "POST", Path: "/organization/update", Pos: "a.go:3"}, + {Method: "POST", Path: "/gone/away", Pos: "a.go:4"}, + }, spec) + if len(violations) != 2 { + t.Fatalf("want 2 violations, got %v", violations) + } + joined := strings.Join(violations, "\n") + if !strings.Contains(joined, "POST /organization/update: path exists but method not allowed") { + t.Fatalf("missing method violation: %v", violations) + } + if !strings.Contains(joined, "POST /gone/away is not served by the proxy") { + t.Fatalf("missing path violation: %v", violations) + } +} + +func TestNormalizePathStripsQueryAndVerbs(t *testing.T) { + if got := normalizePath("/key/info?key=%s"); got != "/key/info" { + t.Fatalf("got %q", got) + } + if got := normalizePath("/credentials/%s"); got != "/credentials/{param}" { + t.Fatalf("got %q", got) + } +}