feat(terraform): resource and data source parity with the community provider (#38158)

* feat(terraform): close key/team schema gaps, fix team read envelope, add import support

* feat(terraform): add fallback resource/data source and key/team block resources

* feat(terraform): add access group and unified access group resources and data sources

* feat(terraform): add guardrail and prompt resources and data sources

* feat(terraform): add agent and search tool resources and data sources

* feat(terraform): add user and budget resources and data sources

* feat(terraform): add tag and project resources and data sources

* docs(terraform): changelog and readme for parity additions

* feat(terraform): add data sources for keys, teams, models, organizations, and mcp servers

* fix(terraform): key update 400 on empty budget_duration, key info envelope, config-supplied key

* fix(terraform): hash raw keys to SHA-256 tokens in key lookup URLs and block resource IDs
This commit is contained in:
Shivam Rawat 2026-08-28 16:55:40 -07:00 committed by GitHub
parent 733d0b5af5
commit 3653e5893f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
118 changed files with 14520 additions and 42 deletions

View file

@ -17,10 +17,29 @@ longer signal it.
### Added
- **team**: `soft_budget`, `tags`, and `soft_budget_alerting_emails` attributes on `litellm_team`, matching what `/team/new` and `/team/update` already accept; `soft_budget_alerting_emails` is sent under `metadata`, where the proxy reads it
- **user**: New `litellm_user` resource and `litellm_user` / `litellm_users` data sources for managing internal users
- **budget**: New `litellm_budget` resource and `litellm_budget` / `litellm_budgets` data sources for reusable budget objects
- **tag**: New `litellm_tag` resource and `litellm_tag` / `litellm_tags` data sources for spend and routing tags
- **project**: New `litellm_project` resource and `litellm_project` / `litellm_projects` data sources
- **guardrail**: New `litellm_guardrail` resource and `litellm_guardrail` / `litellm_guardrails` data sources; `litellm_params` is sensitive and never read back into state
- **prompt**: New `litellm_prompt` resource and `litellm_prompt` / `litellm_prompts` data sources for prompt templates
- **agent**: New `litellm_agent` resource and `litellm_agent` / `litellm_agents` data sources for A2A agents
- **search_tool**: New `litellm_search_tool` resource and `litellm_search_tool` / `litellm_search_tools` data sources
- **access groups**: New `litellm_access_group` and `litellm_unified_access_group` resources with matching singular and plural data sources
- **fallback**: New `litellm_fallback` resource and data source for per-model fallbacks (general, context window and content policy)
- **block resources**: New `litellm_key_block` and `litellm_team_block` resources to manage the blocked state of existing keys and teams
- **data sources for existing resources**: New `litellm_key` / `litellm_keys`, `litellm_team` / `litellm_teams`, `litellm_model` / `litellm_models`, `litellm_organization` / `litellm_organizations` and `litellm_mcp_server` / `litellm_mcp_servers` data sources
- **key**: New arguments `budget_id`, `enforced_params`, `allowed_routes`, `allowed_passthrough_routes`, `rpm_limit_type`, `tpm_limit_type`, `prompts`, `organization_id` and `project_id`
- **team**: New arguments `model_aliases`, `guardrails`, `prompts`, `team_member_budget`, `team_member_budget_duration`, `team_member_rpm_limit`, `team_member_tpm_limit`, `team_member_key_duration`, `model_rpm_limit`, `model_tpm_limit`, `allowed_passthrough_routes`, `rpm_limit_type` and `tpm_limit_type`
- **import**: `terraform import` support for `litellm_team`, `litellm_model`, `litellm_organization`, `litellm_mcp_server`, `litellm_vector_store` and every new resource
### Fixed
- **team**: Read now decodes the `team_info` envelope `/team/info` actually returns, so team attributes refresh from the proxy instead of always falling back to the prior state
- **key**: Read now unwraps the `info` envelope `/key/info` actually returns; previously reads mapped nothing back into state, so drift on a key was never detected
- **key**: Updates no longer send an empty `budget_duration`, which the proxy rejects with a 400; any update to a key without a configured `budget_duration` previously failed outright
- **key**: A config-supplied `key` value (write-only) is now forwarded to `/key/generate`; previously it was silently dropped and the proxy generated a random key instead
- **security**: The `litellm_key` data source and `litellm_key_block` resource normalize raw `sk-` keys to their SHA-256 token hash before building request URLs and resource IDs, so plaintext keys no longer land in reverse-proxy access logs, Terraform plan output, or state IDs
### Changed

View file

@ -1,6 +1,6 @@
# 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.
This Terraform provider allows you to manage LiteLLM resources through Infrastructure as Code. It provides support for managing models, teams, team members, API keys, users, organizations, budgets, tags, projects, guardrails, prompts, agents, search tools, access groups, fallbacks, MCP servers, credentials and vector stores via the LiteLLM REST API, along with read-only data sources for each of them.
## Source of truth

View file

@ -0,0 +1,34 @@
---
page_title: "litellm_access_group Data Source - terraform-provider-litellm"
subcategory: ""
description: |-
Retrieves information about an existing LiteLLM model access group.
---
# litellm_access_group (Data Source)
Retrieves information about an existing LiteLLM model access group by name.
## Example Usage
```terraform
data "litellm_access_group" "production" {
access_group = "production-models"
}
output "production_models" {
value = data.litellm_access_group.production.model_names
}
```
## Argument Reference
* `access_group` - (Required) Name of the access group to look up.
## Attribute Reference
* `id` - The access group name.
* `model_names` - List of model names in the access group.
* `deployment_count` - Number of deployments tagged with this access group.

View file

@ -0,0 +1,33 @@
---
page_title: "litellm_access_groups Data Source - terraform-provider-litellm"
subcategory: ""
description: |-
Retrieves all LiteLLM model access groups.
---
# litellm_access_groups (Data Source)
Retrieves all LiteLLM model access groups configured on the proxy.
## Example Usage
```terraform
data "litellm_access_groups" "all" {}
output "access_group_names" {
value = data.litellm_access_groups.all.ids
}
```
## Argument Reference
This data source takes no arguments.
## Attribute Reference
* `access_groups` - List of access groups. Each entry exports:
* `access_group` - The access group name.
* `model_names` - List of model names in the access group.
* `deployment_count` - Number of deployments tagged with this access group.
* `ids` - List of all access group names.

View file

@ -0,0 +1,43 @@
# litellm_agent Data Source
Retrieves information about an existing A2A agent on the LiteLLM proxy.
## Example Usage
```hcl
data "litellm_agent" "existing" {
agent_id = "123e4567-e89b-12d3-a456-426614174000"
}
output "agent_card" {
value = jsondecode(data.litellm_agent.existing.agent_card_params)
}
```
## Argument Reference
The following arguments are supported:
* `agent_id` - (Required) Unique identifier of the agent to retrieve.
## Attribute Reference
In addition to all arguments above, the following attributes are exported:
* `agent_name` - Name of the agent.
* `agent_card_params` - The A2A agent card as a JSON object string (decode with `jsondecode`).
* `object_permission` - Access control permissions as a JSON object string.
* `extra_headers` - List of incoming request header names forwarded to the agent.
* `tpm_limit` - Tokens per minute limit.
* `rpm_limit` - Requests per minute limit.
* `session_tpm_limit` - Per-session tokens per minute limit.
* `session_rpm_limit` - Per-session requests per minute limit.
* `spend` - Total spend recorded for this agent.
* `created_at` - Timestamp when the agent was created.
* `updated_at` - Timestamp when the agent was last updated.
* `created_by` - User who created the agent.
* `updated_by` - User who last updated the agent.
## Security Note
`litellm_params` and `static_headers` are not exposed through this data source because they may hold API keys or tokens.

View file

@ -0,0 +1,42 @@
# litellm_agents Data Source
Retrieves the list of A2A agents registered on the LiteLLM proxy.
## Example Usage
```hcl
data "litellm_agents" "all" {}
output "agent_ids" {
value = data.litellm_agents.all.ids
}
# Only agents whose URL is currently reachable (or that have no URL)
data "litellm_agents" "healthy" {
health_check = true
}
```
## Argument Reference
The following arguments are supported:
* `health_check` - (Optional, default `false`) When true, the proxy probes each agent's URL and only returns agents that are reachable or have no URL.
## Attribute Reference
The following attributes are exported:
* `ids` - List of agent IDs.
* `agents` - List of agents. Each entry exports:
* `agent_id` - The unique agent ID.
* `agent_name` - Name of the agent.
* `tpm_limit` - Tokens per minute limit.
* `rpm_limit` - Requests per minute limit.
* `session_tpm_limit` - Per-session tokens per minute limit.
* `session_rpm_limit` - Per-session requests per minute limit.
* `spend` - Total spend recorded for the agent.
* `created_at` - Timestamp when the agent was created.
* `updated_at` - Timestamp when the agent was last updated.
* `created_by` - User who created the agent.
* `updated_by` - User who last updated the agent.

View file

@ -0,0 +1,31 @@
# litellm_budget Data Source
Retrieves information about an existing LiteLLM budget by ID
## Example Usage
```hcl
data "litellm_budget" "engineering" {
budget_id = "engineering-monthly"
}
output "engineering_max_budget" {
value = data.litellm_budget.engineering.max_budget
}
```
## Argument Reference
- `budget_id` (Required) - ID of the budget to retrieve
## Attribute Reference
- `id` - The budget ID
- `max_budget` - Hard budget limit in USD
- `soft_budget` - Soft budget limit in USD that triggers alerts
- `max_parallel_requests` - Maximum concurrent requests allowed for this budget
- `tpm_limit` - Maximum tokens per minute allowed for this budget
- `rpm_limit` - Maximum requests per minute allowed for this budget
- `budget_duration` - Budget reset period
- `model_max_budget` - JSON string of per-model budget config
- `budget_reset_at` - Datetime when the budget is reset

View file

@ -0,0 +1,31 @@
# litellm_budgets Data Source
Retrieves all budgets configured on the LiteLLM proxy
## Example Usage
```hcl
data "litellm_budgets" "all" {}
output "budget_ids" {
value = data.litellm_budgets.all.ids
}
```
## Argument Reference
This data source takes no arguments
## Attribute Reference
- `budgets` - All budgets configured on the proxy. Each entry has:
- `budget_id` - The budget ID
- `max_budget` - Hard budget limit in USD
- `soft_budget` - Soft budget limit in USD that triggers alerts
- `max_parallel_requests` - Maximum concurrent requests allowed for this budget
- `tpm_limit` - Maximum tokens per minute allowed for this budget
- `rpm_limit` - Maximum requests per minute allowed for this budget
- `budget_duration` - Budget reset period
- `model_max_budget` - JSON string of per-model budget config
- `budget_reset_at` - Datetime when the budget is reset
- `ids` - IDs of all budgets configured on the proxy

View file

@ -0,0 +1,38 @@
# litellm_fallback (Data Source)
Retrieves the fallback configuration for a LiteLLM model. Use this to reference fallbacks that were configured outside of Terraform.
## Example Usage
```hcl
data "litellm_fallback" "gpt4" {
model = "gpt-4"
}
output "gpt4_fallback_models" {
value = data.litellm_fallback.gpt4.fallback_models
}
```
### Specific Fallback Type
```hcl
data "litellm_fallback" "gpt4_context_window" {
model = "gpt-4"
fallback_type = "context_window"
}
```
## Argument Reference
The following arguments are supported:
* `model` - (Required) The model name to get fallbacks for.
* `fallback_type` - (Optional) Type of fallback to retrieve. One of `general` (default), `context_window`, or `content_policy`.
## Attribute Reference
In addition to the arguments above, the following attributes are exported:
* `id` - The primary model name.
* `fallback_models` - List of fallback model names in order of priority.

View file

@ -0,0 +1,27 @@
# litellm_guardrail Data Source
Retrieves information about an existing LiteLLM guardrail by ID. Sensitive `litellm_params` are not exposed.
## Example Usage
```hcl
data "litellm_guardrail" "existing" {
guardrail_id = "123e4567-e89b-12d3-a456-426614174000"
}
output "guardrail_name" {
value = data.litellm_guardrail.existing.guardrail_name
}
```
## Argument Reference
* `guardrail_id` - (Required) Unique identifier of the guardrail to retrieve.
## Attribute Reference
* `guardrail_name` - Human-readable name of the guardrail.
* `guardrail_info` - Map of additional metadata for the guardrail.
* `guardrail_definition_location` - Where the guardrail is defined: `config` or `db`.
* `created_at` - Timestamp when the guardrail was created.
* `updated_at` - Timestamp when the guardrail was last updated.

View file

@ -0,0 +1,32 @@
# litellm_guardrails Data Source
Retrieves the list of all guardrails configured on the LiteLLM proxy (from both config and DB). Sensitive `litellm_params` are not exposed.
## Example Usage
```hcl
data "litellm_guardrails" "all" {}
output "guardrail_ids" {
value = data.litellm_guardrails.all.ids
}
output "guardrail_names" {
value = [for g in data.litellm_guardrails.all.guardrails : g.guardrail_name]
}
```
## Argument Reference
This data source takes no arguments.
## Attribute Reference
* `guardrails` - List of guardrails. Each entry contains:
* `guardrail_id` - Unique identifier of the guardrail.
* `guardrail_name` - Human-readable name of the guardrail.
* `guardrail_info` - Map of additional metadata for the guardrail.
* `guardrail_definition_location` - Where the guardrail is defined: `config` or `db`.
* `created_at` - Timestamp when the guardrail was created.
* `updated_at` - Timestamp when the guardrail was last updated.
* `ids` - List of all guardrail IDs.

View file

@ -0,0 +1,57 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "litellm_key Data Source - terraform-provider-litellm"
subcategory: ""
description: |-
Retrieves information about an existing LiteLLM API key.
---
# litellm_key (Data Source)
Retrieves information about an existing LiteLLM API key via `/key/info`. Pass either the raw key or its hashed token. The raw key value is never written to state beyond the input you provide; the data source ID is the hashed token.
## Example Usage
```terraform
data "litellm_key" "ci" {
key = var.ci_key_hash
}
output "ci_key_team" {
value = data.litellm_key.ci.team_id
}
```
## Argument Reference
The following arguments are supported:
* `key` - (Required, Sensitive) The API key (or its hash) to look up.
## Attributes Reference
In addition to all arguments above, the following attributes are exported:
* `token_id` - Hashed token identifier of the key (safe to store in state).
* `key_name` - Redacted display name of the key.
* `key_alias` - User-friendly alias for the key.
* `models` - List of models this key can access.
* `spend` - Amount spent by this key.
* `max_budget` - Maximum budget for this key.
* `user_id` - User ID associated with this key.
* `team_id` - Team ID associated with this key.
* `organization_id` - Organization ID associated with this key.
* `tpm_limit` - Tokens per minute limit.
* `rpm_limit` - Requests per minute limit.
* `max_parallel_requests` - Maximum parallel requests allowed.
* `budget_duration` - Budget reset duration.
* `metadata` - Map of string metadata values for the key.
* `tags` - Tags attached to the key.
* `blocked` - Whether the key is blocked.
* `expires` - Expiry timestamp, if set.
* `created_at` - Timestamp when the key was created.
* `updated_at` - Timestamp when the key was last updated.
## Security Note
The raw key value is only used to perform the lookup; it is never exported as an attribute or used as the data source ID.

View file

@ -0,0 +1,62 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "litellm_keys Data Source - terraform-provider-litellm"
subcategory: ""
description: |-
Lists LiteLLM API keys with optional server-side filters.
---
# litellm_keys (Data Source)
Lists LiteLLM API keys via `/key/list`. Supports server-side filtering and pagination. Raw key values are never returned; each entry is identified by its hashed token.
## Example Usage
```terraform
data "litellm_keys" "team_keys" {
team_id = litellm_team.ml.id
size = 50
}
output "team_key_aliases" {
value = [for k in data.litellm_keys.team_keys.keys : k.key_alias]
}
```
## Argument Reference
The following arguments are supported:
* `page` - (Optional) Page number for pagination. Defaults to `1`.
* `size` - (Optional) Number of keys per page. Defaults to `100`.
* `user_id` - (Optional) Filter keys by user ID.
* `team_id` - (Optional) Filter keys by team ID.
* `organization_id` - (Optional) Filter keys by organization ID.
* `key_alias` - (Optional) Filter keys by key alias.
* `include_team_keys` - (Optional) Include all keys for teams the caller is an admin of.
## Attributes Reference
In addition to all arguments above, the following attributes are exported:
* `total_count` - Total number of keys matching the filters.
* `total_pages` - Total number of pages.
* `current_page` - The page returned.
* `ids` - Hashed token identifiers of the returned keys.
* `keys` - List of key objects. Each entry exports:
* `token_id` - Hashed token identifier.
* `key_name` - Redacted display name.
* `key_alias` - User-friendly alias.
* `spend` - Amount spent by the key.
* `max_budget` - Maximum budget.
* `models` - Models the key can access.
* `user_id` - Associated user ID.
* `team_id` - Associated team ID.
* `organization_id` - Associated organization ID.
* `tpm_limit` - Tokens per minute limit.
* `rpm_limit` - Requests per minute limit.
* `budget_duration` - Budget reset duration.
* `blocked` - Whether the key is blocked.
* `expires` - Expiry timestamp, if set.
* `created_at` - Creation timestamp.
* `updated_at` - Last update timestamp.

View file

@ -0,0 +1,58 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "litellm_mcp_server Data Source - terraform-provider-litellm"
subcategory: ""
description: |-
Retrieves information about an existing LiteLLM MCP server.
---
# litellm_mcp_server (Data Source)
Retrieves information about an existing MCP server via `/v1/mcp/server/{server_id}`. Secret material (environment variables, credentials, and static header values) is never exposed.
## Example Usage
```terraform
data "litellm_mcp_server" "github" {
server_id = "srv-1234"
}
output "github_mcp_url" {
value = data.litellm_mcp_server.github.url
}
```
## Argument Reference
The following arguments are supported:
* `server_id` - (Required) Unique identifier of the MCP server to retrieve.
## Attributes Reference
In addition to all arguments above, the following attributes are exported:
* `server_name` - Name of the MCP server.
* `alias` - Alias for the MCP server.
* `description` - Description of the MCP server.
* `url` - URL of the MCP server.
* `transport` - Transport type (`http`, `sse`, `stdio`).
* `spec_version` - MCP specification version.
* `auth_type` - Authentication type (`none`, `bearer`, `basic`, ...).
* `mcp_access_groups` - Access groups for the MCP server.
* `allowed_tools` - Tools allowed on this server.
* `extra_headers` - Names of request headers forwarded to the MCP server.
* `command` - Command for stdio transport.
* `args` - Arguments for the command (stdio transport).
* `allow_all_keys` - Whether all keys can access the server.
* `status` - Health status (`healthy`, `unhealthy`, `unknown`).
* `last_health_check` - Timestamp of the last health check.
* `health_check_error` - Error message from the last health check, if any.
* `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.
## Security Note
For security reasons, `env`, `credentials`, and `static_headers` are not exposed through this data source since they may hold secrets.

View file

@ -0,0 +1,50 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "litellm_mcp_servers Data Source - terraform-provider-litellm"
subcategory: ""
description: |-
Lists LiteLLM MCP servers.
---
# litellm_mcp_servers (Data Source)
Lists MCP servers via `/v1/mcp/server`. Secret material is never exposed.
## Example Usage
```terraform
data "litellm_mcp_servers" "all" {}
data "litellm_mcp_servers" "team_scoped" {
team_id = litellm_team.ml.id
}
output "mcp_server_urls" {
value = [for s in data.litellm_mcp_servers.all.mcp_servers : s.url]
}
```
## Argument Reference
The following arguments are supported:
* `team_id` - (Optional) Filter to servers this team can access plus globally available (`allow_all_keys`) servers.
## Attributes Reference
In addition to all arguments above, the following attributes are exported:
* `ids` - IDs of the returned MCP servers.
* `mcp_servers` - List of MCP server objects. Each entry exports:
* `server_id` - Unique identifier of the MCP server.
* `server_name` - Name of the MCP server.
* `alias` - Alias for the MCP server.
* `description` - Description of the MCP server.
* `url` - URL of the MCP server.
* `transport` - Transport type (`http`, `sse`, `stdio`).
* `spec_version` - MCP specification version.
* `auth_type` - Authentication type.
* `allow_all_keys` - Whether all keys can access the server.
* `status` - Health status (`healthy`, `unhealthy`, `unknown`).
* `created_at` - Creation timestamp.
* `updated_at` - Last update timestamp.

View file

@ -0,0 +1,50 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "litellm_model Data Source - terraform-provider-litellm"
subcategory: ""
description: |-
Retrieves information about a model deployment on the LiteLLM proxy.
---
# litellm_model (Data Source)
Retrieves information about a single model deployment via `/v1/model/info`. Sensitive `litellm_params` fields (API keys and other credentials) are never exposed; only safe routing metadata is exported.
## Example Usage
```terraform
data "litellm_model" "gpt4o" {
model_id = "0e5x74fab24a7a5245d2ced3536dd8f5"
}
output "gpt4o_provider" {
value = data.litellm_model.gpt4o.custom_llm_provider
}
```
## Argument Reference
The following arguments are supported:
* `model_id` - (Required) LiteLLM model ID (the `x-litellm-model-id` response header value).
## Attributes Reference
In addition to all arguments above, the following attributes are exported:
* `model_name` - Public model name used for routing.
* `model` - The underlying `litellm_params` model, e.g. `openai/gpt-4o`.
* `custom_llm_provider` - Provider for the model.
* `model_api_base` - API base URL, if configured.
* `api_version` - API version, if configured.
* `tpm` - Tokens per minute limit for the deployment.
* `rpm` - Requests per minute limit for the deployment.
* `base_model` - Base model used for pricing and capabilities.
* `tier` - Model tier (`free` or `paid`).
* `mode` - Model mode, e.g. `chat` or `embedding`.
* `team_id` - Team the deployment is scoped to, if any.
* `db_model` - Whether the deployment is stored in the database (as opposed to config).
## Security Note
Credential material inside `litellm_params` (such as `api_key`, `aws_secret_access_key`, and `vertex_credentials`) is never exported by this data source.

View file

@ -0,0 +1,44 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "litellm_models Data Source - terraform-provider-litellm"
subcategory: ""
description: |-
Lists model deployments on the LiteLLM proxy.
---
# litellm_models (Data Source)
Lists all model deployments via `/v1/model/info`. Sensitive `litellm_params` fields (API keys and other credentials) are never exposed.
## Example Usage
```terraform
data "litellm_models" "all" {}
output "model_names" {
value = [for m in data.litellm_models.all.models : m.model_name]
}
```
## Argument Reference
The following arguments are supported:
* `team_id` - (Optional) Filter models to those accessible by this team.
## Attributes Reference
In addition to all arguments above, the following attributes are exported:
* `ids` - LiteLLM model IDs of the returned models.
* `models` - List of model objects. Each entry exports:
* `id` - LiteLLM model ID.
* `model_name` - Public model name used for routing.
* `model` - The underlying `litellm_params` model.
* `custom_llm_provider` - Provider for the model.
* `model_api_base` - API base URL, if configured.
* `base_model` - Base model used for pricing and capabilities.
* `tier` - Model tier (`free` or `paid`).
* `mode` - Model mode, e.g. `chat` or `embedding`.
* `team_id` - Team the deployment is scoped to, if any.
* `db_model` - Whether the deployment is stored in the database.

View file

@ -0,0 +1,48 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "litellm_organization Data Source - terraform-provider-litellm"
subcategory: ""
description: |-
Retrieves information about an existing LiteLLM organization.
---
# litellm_organization (Data Source)
Retrieves information about an existing LiteLLM organization via `/organization/info`, including its attached budget settings.
## Example Usage
```terraform
data "litellm_organization" "main" {
organization_id = "org-1234"
}
resource "litellm_team" "ml" {
team_alias = "ml-team"
organization_id = data.litellm_organization.main.organization_id
}
```
## Argument Reference
The following arguments are supported:
* `organization_id` - (Required) Unique identifier of the organization to retrieve.
## Attributes Reference
In addition to all arguments above, the following attributes are exported:
* `organization_alias` - User-friendly name of the organization.
* `budget_id` - ID of the attached budget.
* `models` - Models the organization can access.
* `spend` - Amount spent by the organization.
* `metadata` - Map of string metadata values for the organization.
* `max_budget` - Maximum budget from the attached budget.
* `soft_budget` - Soft budget alert threshold from the attached budget.
* `tpm_limit` - Tokens per minute limit from the attached budget.
* `rpm_limit` - Requests per minute limit from the attached budget.
* `max_parallel_requests` - Maximum parallel requests from the attached budget.
* `budget_duration` - Budget reset duration from the attached budget.
* `created_at` - Timestamp when the organization was created.
* `updated_at` - Timestamp when the organization was last updated.

View file

@ -0,0 +1,45 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "litellm_organizations Data Source - terraform-provider-litellm"
subcategory: ""
description: |-
Lists LiteLLM organizations.
---
# litellm_organizations (Data Source)
Lists LiteLLM organizations via `/organization/list`.
## Example Usage
```terraform
data "litellm_organizations" "all" {}
output "organization_ids" {
value = data.litellm_organizations.all.ids
}
```
## Argument Reference
The following arguments are supported:
* `org_alias` - (Optional) Filter organizations by alias.
## Attributes Reference
In addition to all arguments above, the following attributes are exported:
* `ids` - IDs of the returned organizations.
* `organizations` - List of organization objects. Each entry exports:
* `organization_id` - Unique identifier of the organization.
* `organization_alias` - User-friendly name of the organization.
* `budget_id` - ID of the attached budget.
* `models` - Models the organization can access.
* `spend` - Amount spent by the organization.
* `max_budget` - Maximum budget from the attached budget.
* `tpm_limit` - Tokens per minute limit from the attached budget.
* `rpm_limit` - Requests per minute limit from the attached budget.
* `budget_duration` - Budget reset duration from the attached budget.
* `created_at` - Creation timestamp.
* `updated_at` - Last update timestamp.

View file

@ -0,0 +1,43 @@
# litellm_project (Data Source)
Retrieves information about an existing LiteLLM project, including its budget settings
## Example Usage
```hcl
data "litellm_project" "ml_experiments" {
project_id = "4a422a4c-e246-4d02-a1eb-13e835cd0725"
}
output "project_spend" {
value = data.litellm_project.ml_experiments.spend
}
```
## Argument Reference
The following arguments are supported:
* `project_id` - (Required) Unique identifier of the project to retrieve
## Attribute Reference
In addition to all arguments above, the following attributes are exported:
* `project_alias` - Human-friendly name for the project
* `description` - Description of the project
* `team_id` - The team ID this project belongs to
* `budget_id` - Budget ID associated with this project
* `models` - List of models the project can access
* `max_budget` - Maximum budget for this project
* `soft_budget` - Soft budget limit for warnings
* `budget_duration` - Budget reset duration
* `tpm_limit` - Tokens per minute limit
* `rpm_limit` - Requests per minute limit
* `max_parallel_requests` - Maximum parallel requests allowed
* `blocked` - Whether the project is blocked from making requests
* `spend` - Current spend for the project
* `created_at` - Timestamp when the project was created
* `updated_at` - Timestamp when the project was last updated
* `created_by` - User that created the project
* `updated_by` - User that last updated the project

View file

@ -0,0 +1,40 @@
# litellm_projects (Data Source)
Retrieves the list of all LiteLLM projects visible to the caller
## Example Usage
```hcl
data "litellm_projects" "all" {}
output "project_ids" {
value = data.litellm_projects.all.ids
}
output "project_aliases" {
value = [for p in data.litellm_projects.all.projects : p.project_alias]
}
```
## Argument Reference
This data source takes no arguments
## Attribute Reference
The following attributes are exported:
* `ids` - IDs of all projects
* `projects` - List of projects. Each entry exports:
* `project_id` - The project ID
* `project_alias` - Human-friendly name for the project
* `description` - Description of the project
* `team_id` - The team ID this project belongs to
* `budget_id` - Budget ID associated with this project
* `models` - List of models the project can access
* `blocked` - Whether the project is blocked from making requests
* `spend` - Current spend for the project
* `created_at` - Timestamp when the project was created
* `updated_at` - Timestamp when the project was last updated
* `created_by` - User that created the project
* `updated_by` - User that last updated the project

View file

@ -0,0 +1,43 @@
# litellm_prompt Data Source
Retrieves information about an existing LiteLLM prompt by ID. The provider API key is not exposed.
## Example Usage
```hcl
data "litellm_prompt" "existing" {
prompt_id = "my-langfuse-prompt"
}
output "prompt_integration" {
value = data.litellm_prompt.existing.prompt_integration
}
```
### With Environment
```hcl
data "litellm_prompt" "prod" {
prompt_id = "my-langfuse-prompt"
environment = "production"
}
```
## Argument Reference
* `prompt_id` - (Required) Unique identifier of the prompt to retrieve.
* `environment` - (Optional) Environment to fetch the prompt from (e.g. `development`, `production`).
## Attribute Reference
* `prompt_integration` - The prompt integration provider.
* `api_base` - Base URL for the prompt provider API.
* `provider_specific_query_params` - JSON string of provider-specific query parameters.
* `ignore_prompt_manager_model` - Whether the model specified in the prompt manager is ignored.
* `ignore_prompt_manager_optional_params` - Whether optional params from the prompt manager are ignored.
* `dotprompt_content` - Content for the dotprompt integration.
* `prompt_type` - Type of prompt: `config` or `db`.
* `version` - Version number of the prompt.
* `environments` - List of environments this prompt exists in.
* `created_at` - Timestamp when the prompt was created.
* `updated_at` - Timestamp when the prompt was last updated.

View file

@ -0,0 +1,37 @@
# litellm_prompts Data Source
Retrieves the list of all prompts configured on the LiteLLM proxy.
## Example Usage
```hcl
data "litellm_prompts" "all" {}
output "prompt_ids" {
value = data.litellm_prompts.all.ids
}
```
### Filter by Environment
```hcl
data "litellm_prompts" "production" {
environment = "production"
}
```
## Argument Reference
* `environment` - (Optional) Filter prompts by environment (e.g. `development`, `production`).
## Attribute Reference
* `prompts` - List of prompts. Each entry contains:
* `prompt_id` - Unique identifier of the prompt.
* `prompt_integration` - The prompt integration provider.
* `prompt_type` - Type of prompt: `config` or `db`.
* `version` - Version number of the prompt.
* `environment` - Environment the prompt belongs to.
* `created_at` - Timestamp when the prompt was created.
* `updated_at` - Timestamp when the prompt was last updated.
* `ids` - List of all prompt IDs.

View file

@ -0,0 +1,34 @@
# litellm_search_tool Data Source
Retrieves information about an existing search tool on the LiteLLM proxy.
## Example Usage
```hcl
data "litellm_search_tool" "existing" {
search_tool_id = "123e4567-e89b-12d3-a456-426614174000"
}
output "search_tool_name" {
value = data.litellm_search_tool.existing.search_tool_name
}
```
## Argument Reference
The following arguments are supported:
* `search_tool_id` - (Required) Unique identifier of the search tool to retrieve.
## Attribute Reference
In addition to all arguments above, the following attributes are exported:
* `search_tool_name` - Name of the search tool.
* `search_tool_info` - Additional metadata as a JSON object string (decode with `jsondecode`).
* `created_at` - Timestamp when the search tool was created.
* `updated_at` - Timestamp when the search tool was last updated.
## Security Note
`litellm_params` is not exposed through this data source because it may hold provider API keys.

View file

@ -0,0 +1,34 @@
# litellm_search_tools Data Source
Retrieves the list of search tools configured on the LiteLLM proxy, from both the database and the proxy config.
## Example Usage
```hcl
data "litellm_search_tools" "all" {}
output "search_tool_ids" {
value = data.litellm_search_tools.all.ids
}
```
## Argument Reference
This data source takes no arguments.
## Attribute Reference
The following attributes are exported:
* `ids` - List of search tool IDs.
* `search_tools` - List of search tools. Each entry exports:
* `search_tool_id` - The unique search tool ID.
* `search_tool_name` - Name of the search tool.
* `search_tool_info` - Additional metadata as a JSON object string.
* `is_from_config` - Whether the search tool comes from the proxy config file rather than the database.
* `created_at` - Timestamp when the search tool was created.
* `updated_at` - Timestamp when the search tool was last updated.
## Security Note
`litellm_params` is not exposed through this data source because it may hold provider API keys.

View file

@ -0,0 +1,38 @@
# litellm_tag (Data Source)
Retrieves information about an existing LiteLLM tag, including its budget settings
## Example Usage
```hcl
data "litellm_tag" "production" {
name = "production"
}
output "production_tag_budget" {
value = data.litellm_tag.production.max_budget
}
```
## Argument Reference
The following arguments are supported:
* `name` - (Required) Name of the tag to retrieve
## Attribute Reference
In addition to all arguments above, the following attributes are exported:
* `description` - Description of the tag
* `models` - Model IDs this tag applies to
* `budget_id` - Budget ID associated with this tag
* `max_budget` - Max budget in USD for this tag
* `soft_budget` - Soft budget in USD for this tag
* `max_parallel_requests` - Max concurrent requests allowed for this tag
* `tpm_limit` - Max tokens per minute for this tag
* `rpm_limit` - Max requests per minute for this tag
* `budget_duration` - Duration for budget reset
* `created_at` - Timestamp when the tag was created
* `updated_at` - Timestamp when the tag was last updated
* `created_by` - User that created the tag

View file

@ -0,0 +1,50 @@
# litellm_tags (Data Source)
Retrieves the list of all LiteLLM tags. This includes stored tags created via `litellm_tag` or the API, and dynamic tags that were passed on requests
## Example Usage
```hcl
data "litellm_tags" "all" {}
output "tag_names" {
value = data.litellm_tags.all.ids
}
```
## Example Usage with Date Filter
```hcl
# Limit dynamic tags to those active in a window; stored tags are always returned
data "litellm_tags" "january" {
start_date = "2026-01-01"
end_date = "2026-01-31"
}
```
## Argument Reference
The following arguments are supported:
* `start_date` - (Optional) Start date (YYYY-MM-DD) limiting dynamic tags to those active in the window. Must be given with `end_date`
* `end_date` - (Optional) End date (YYYY-MM-DD). Must be given with `start_date`
## Attribute Reference
The following attributes are exported:
* `ids` - Names of all tags (tag names are their IDs)
* `tags` - List of tags. Each entry exports:
* `name` - The tag name
* `description` - Description of the tag
* `models` - Model IDs this tag applies to
* `budget_id` - Budget ID associated with this tag
* `max_budget` - Max budget in USD
* `soft_budget` - Soft budget in USD
* `max_parallel_requests` - Max concurrent requests allowed
* `tpm_limit` - Max tokens per minute
* `rpm_limit` - Max requests per minute
* `budget_duration` - Duration for budget reset
* `created_at` - Timestamp when the tag was created
* `updated_at` - Timestamp when the tag was last updated
* `created_by` - User that created the tag

View file

@ -0,0 +1,52 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "litellm_team Data Source - terraform-provider-litellm"
subcategory: ""
description: |-
Retrieves information about an existing LiteLLM team.
---
# litellm_team (Data Source)
Retrieves information about an existing LiteLLM team via `/team/info`. Use it to reference teams created outside of Terraform or in other configurations.
## Example Usage
```terraform
data "litellm_team" "ml" {
team_id = "team-1234"
}
resource "litellm_key" "ml_key" {
team_id = data.litellm_team.ml.team_id
models = data.litellm_team.ml.models
}
```
## Argument Reference
The following arguments are supported:
* `team_id` - (Required) Unique identifier of the team to retrieve.
## Attributes Reference
In addition to all arguments above, the following attributes are exported:
* `team_alias` - User-friendly name of the team.
* `organization_id` - Organization the team belongs to.
* `models` - Models the team can access.
* `metadata` - Map of string metadata values for the team.
* `tags` - Tags for spend tracking and tag-based routing.
* `soft_budget_alerting_emails` - Email addresses alerted when the team crosses `soft_budget`.
* `tpm_limit` - Tokens per minute limit.
* `rpm_limit` - Requests per minute limit.
* `max_parallel_requests` - Maximum parallel requests allowed.
* `max_budget` - Maximum budget for the team.
* `soft_budget` - Soft budget alert threshold.
* `spend` - Amount spent by the team.
* `budget_duration` - Budget reset duration.
* `blocked` - Whether the team is blocked.
* `team_member_permissions` - Permissions granted to team members.
* `created_at` - Timestamp when the team was created.
* `updated_at` - Timestamp when the team was last updated.

View file

@ -0,0 +1,49 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "litellm_teams Data Source - terraform-provider-litellm"
subcategory: ""
description: |-
Lists LiteLLM teams with optional server-side filters.
---
# litellm_teams (Data Source)
Lists LiteLLM teams via `/team/list`. Supports filtering by user and organization.
## Example Usage
```terraform
data "litellm_teams" "org_teams" {
organization_id = litellm_organization.main.id
}
output "team_ids" {
value = data.litellm_teams.org_teams.ids
}
```
## Argument Reference
The following arguments are supported:
* `user_id` - (Optional) Only return teams this user belongs to.
* `organization_id` - (Optional) Only return teams in this organization.
## Attributes Reference
In addition to all arguments above, the following attributes are exported:
* `ids` - IDs of the returned teams.
* `teams` - List of team objects. Each entry exports:
* `team_id` - Unique identifier of the team.
* `team_alias` - User-friendly name of the team.
* `organization_id` - Organization the team belongs to.
* `models` - Models the team can access.
* `spend` - Amount spent by the team.
* `max_budget` - Maximum budget for the team.
* `tpm_limit` - Tokens per minute limit.
* `rpm_limit` - Requests per minute limit.
* `budget_duration` - Budget reset duration.
* `blocked` - Whether the team is blocked.
* `created_at` - Creation timestamp.
* `updated_at` - Last update timestamp.

View file

@ -0,0 +1,52 @@
---
page_title: "litellm_unified_access_group Data Source - terraform-provider-litellm"
subcategory: ""
description: |-
Retrieves information about an existing LiteLLM unified access group.
---
# litellm_unified_access_group (Data Source)
Retrieves information about an existing LiteLLM unified access group by ID.
## Example Usage
```terraform
data "litellm_unified_access_group" "engineering" {
access_group_id = "b6e5f9d0-..."
}
output "engineering_models" {
value = data.litellm_unified_access_group.engineering.access_model_names
}
```
## Argument Reference
* `access_group_id` - (Required) ID of the unified access group to look up.
## Attribute Reference
* `id` - The unified access group ID.
* `access_group_name` - Display name of the unified access group.
* `description` - Description of the unified access group.
* `access_model_names` - Model names the access group grants access to.
* `access_mcp_server_ids` - MCP server IDs the access group grants access to.
* `access_agent_ids` - Agent IDs the access group grants access to.
* `assigned_team_ids` - Team IDs the access group is assigned to.
* `assigned_key_ids` - Key IDs the access group is assigned to.
* `created_at` - Timestamp when the access group was created.
* `created_by` - User who created the access group.
* `updated_at` - Timestamp when the access group was last updated.
* `updated_by` - User who last updated the access group.

View file

@ -0,0 +1,30 @@
---
page_title: "litellm_unified_access_groups Data Source - terraform-provider-litellm"
subcategory: ""
description: |-
Retrieves all LiteLLM unified access groups.
---
# litellm_unified_access_groups (Data Source)
Retrieves all LiteLLM unified access groups configured on the proxy.
## Example Usage
```terraform
data "litellm_unified_access_groups" "all" {}
output "unified_access_group_ids" {
value = data.litellm_unified_access_groups.all.ids
}
```
## Argument Reference
This data source takes no arguments.
## Attribute Reference
* `access_groups` - List of unified access groups. Each entry exports the same attributes as the `litellm_unified_access_group` data source: `access_group_id`, `access_group_name`, `description`, `access_model_names`, `access_mcp_server_ids`, `access_agent_ids`, `assigned_team_ids`, `assigned_key_ids`, `created_at`, `created_by`, `updated_at`, and `updated_by`.
* `ids` - List of all unified access group IDs.

View file

@ -0,0 +1,36 @@
# litellm_user Data Source
Retrieves information about an existing LiteLLM user by ID
## Example Usage
```hcl
data "litellm_user" "alice" {
user_id = "alice-user-id"
}
output "alice_email" {
value = data.litellm_user.alice.user_email
}
```
## Argument Reference
- `user_id` (Required) - ID of the user to retrieve
## Attribute Reference
- `id` - The user ID
- `user_email` - Email address of the user
- `user_alias` - Descriptive name for the user
- `user_role` - Role of the user on the proxy
- `teams` - List of team IDs the user belongs to
- `models` - Models the user is allowed to call
- `max_budget` - Maximum budget in USD for the user
- `spend` - Current spend in USD for the user
- `budget_duration` - Budget reset period for the user
- `tpm_limit` - Tokens per minute limit
- `rpm_limit` - Requests per minute limit
- `max_parallel_requests` - Maximum number of parallel requests
- `metadata` - Map of metadata for the user
- `model_max_budget` - JSON string of per-model budget config

View file

@ -0,0 +1,47 @@
# litellm_users Data Source
Retrieves a page of LiteLLM users, with optional server-side filters
## Example Usage
```hcl
data "litellm_users" "internal" {
role = "internal_user"
page = 1
page_size = 100
}
output "internal_user_ids" {
value = data.litellm_users.internal.ids
}
```
## Argument Reference
- `role` (Optional) - Filter users by role
- `user_ids` (Optional) - Comma-separated list of user IDs to filter by
- `user_email` (Optional) - Filter users by partial email match
- `team` (Optional) - Filter users by team ID
- `page` (Optional, Default `1`) - Page number to fetch
- `page_size` (Optional, Default `25`) - Number of users per page, max 100
- `sort_by` (Optional) - Column to sort by, e.g. `user_id`, `user_email`, `created_at`
- `sort_order` (Optional) - Sort order, `asc` or `desc`
## Attribute Reference
- `users` - Users returned for the requested page. Each entry has:
- `user_id` - The user ID
- `user_email` - Email address of the user
- `user_alias` - Descriptive name for the user
- `user_role` - Role of the user on the proxy
- `teams` - List of team IDs the user belongs to
- `models` - Models the user is allowed to call
- `max_budget` - Maximum budget in USD
- `spend` - Current spend in USD
- `tpm_limit` - Tokens per minute limit
- `rpm_limit` - Requests per minute limit
- `key_count` - Number of API keys owned by the user
- `created_at` - Timestamp when the user was created
- `ids` - IDs of the users returned for the requested page
- `total` - Total number of users matching the filters
- `total_pages` - Total number of pages available

View file

@ -0,0 +1,49 @@
---
page_title: "litellm_access_group Resource - terraform-provider-litellm"
subcategory: ""
description: |-
Manages a LiteLLM model access group.
---
# litellm_access_group (Resource)
Manages a LiteLLM model access group. Access groups bundle model deployments under one name so keys and teams can be granted access to the whole group at once.
## Example Usage
```terraform
resource "litellm_access_group" "production" {
access_group = "production-models"
model_names = ["gpt-4", "claude-3-sonnet"]
}
# Target specific deployments by model ID instead of model name
resource "litellm_access_group" "pinned" {
access_group = "pinned-deployments"
model_ids = ["4dbd9f43-...", "9a1e2c77-..."]
}
```
## Argument Reference
* `access_group` - (Required, Forces new resource) Name of the access group.
* `model_names` - (Optional) List of model names (the `model_name` of each deployment) to include in the group. At least one of `model_names` or `model_ids` must be set.
* `model_ids` - (Optional) List of specific deployment model IDs to include in the group. Takes precedence over `model_names` when both are set.
## Attribute Reference
In addition to the arguments above, the following attributes are exported:
* `id` - The access group name.
* `deployment_count` - Number of deployments currently tagged with this access group.
## Import
Access groups can be imported using the access group name:
```shell
terraform import litellm_access_group.production production-models
```

View file

@ -0,0 +1,88 @@
# litellm_agent Resource
Manages an A2A (Agent-to-Agent) agent on the LiteLLM proxy. Agents are AI-powered entities that can be discovered, invoked, and composed using the A2A protocol.
## Example Usage
```hcl
resource "litellm_agent" "hello_world" {
agent_name = "hello-world-agent"
agent_card_params = jsonencode({
protocolVersion = "1.0"
name = "Hello World Agent"
description = "Just a hello world agent"
url = "http://localhost:9999/"
version = "1.0.0"
defaultInputModes = ["text"]
defaultOutputModes = ["text"]
capabilities = {
streaming = true
}
skills = [
{
id = "hello_world"
name = "Returns hello world"
description = "just returns hello world"
tags = ["hello world"]
examples = ["hi", "hello world"]
}
]
})
litellm_params = jsonencode({
make_public = false
})
object_permission = jsonencode({
models = ["gpt-4-proxy"]
mcp_servers = ["my-mcp-server-id"]
})
static_headers = {
"x-api-key" = var.agent_api_key
}
extra_headers = ["x-request-id"]
tpm_limit = 100000
rpm_limit = 1000
session_tpm_limit = 10000
session_rpm_limit = 100
}
```
## Argument Reference
The following arguments are supported:
* `agent_name` - (Required) Name of the agent. Must be unique on the proxy.
* `agent_card_params` - (Required) The A2A agent card as a JSON object string (use `jsonencode`). Supports the standard A2A card fields: `name`, `description`, `url`, `version`, `protocolVersion`, `capabilities`, `skills`, `defaultInputModes`, `defaultOutputModes`, `preferredTransport`, `iconUrl`, `provider`, `documentationUrl`, and more. The proxy merges LiteLLM-fronting fields (such as `supportedInterfaces`) into the stored card, so the value you configure stays authoritative in state.
* `litellm_params` - (Optional, Sensitive) LiteLLM-specific parameters as a JSON object string. May include secrets such as `api_key`, so the value is never read back from the API; the configured value is authoritative.
* `object_permission` - (Optional) Access control permissions as a JSON object string with keys `mcp_servers`, `mcp_access_groups`, `mcp_tool_permissions`, `models`, and `agents`.
* `static_headers` - (Optional, Sensitive) Map of static headers sent with agent requests. May hold tokens, so it is never read back from the API.
* `extra_headers` - (Optional) List of incoming request header names to forward to the agent.
* `tpm_limit` - (Optional) Tokens per minute limit for the agent.
* `rpm_limit` - (Optional) Requests per minute limit for the agent.
* `session_tpm_limit` - (Optional) Per-session tokens per minute limit.
* `session_rpm_limit` - (Optional) Per-session requests per minute limit.
## Attribute Reference
In addition to all arguments above, the following attributes are exported:
* `id` - The agent ID assigned by LiteLLM.
* `created_at` - Timestamp when the agent was created.
* `updated_at` - Timestamp when the agent was last updated.
* `created_by` - User who created the agent.
* `updated_by` - User who last updated the agent.
## Import
Agents can be imported using the agent ID:
```shell
terraform import litellm_agent.example <agent_id>
```
Note: `litellm_params` and `static_headers` cannot be recovered on import because the API never returns their unmasked values; re-apply after import to set them.

View file

@ -0,0 +1,48 @@
# litellm_budget Resource
Manages a budget object on the LiteLLM proxy. Budgets can be attached to keys, teams, organizations, and end users to enforce spend limits
## Example Usage
```hcl
resource "litellm_budget" "engineering" {
budget_id = "engineering-monthly"
max_budget = 500.0
soft_budget = 400.0
budget_duration = "30d"
tpm_limit = 500000
rpm_limit = 5000
max_parallel_requests = 100
model_max_budget = jsonencode({
"gpt-4o" = {
max_budget = 100.0
budget_duration = "1d"
}
})
}
```
## Argument Reference
- `budget_id` (Optional, Forces new resource) - Unique ID for the budget. Generated by the server if not provided
- `max_budget` (Optional) - Requests fail if this budget in USD is exceeded
- `soft_budget` (Optional) - Requests do not fail if this is exceeded, but alerts fire
- `max_parallel_requests` (Optional) - Maximum concurrent requests allowed for this budget
- `tpm_limit` (Optional) - Maximum tokens per minute allowed for this budget
- `rpm_limit` (Optional) - Maximum requests per minute allowed for this budget
- `budget_duration` (Optional) - Budget reset period, e.g. `1hr`, `1d`, `28d`
- `model_max_budget` (Optional) - JSON string of per-model budget config, e.g. `jsonencode({"gpt-4o" = {max_budget = 10.0}})`
## Attribute Reference
- `id` - The budget ID
- `budget_reset_at` - Datetime when the budget is reset
## Import
Budgets can be imported using the budget ID:
```shell
terraform import litellm_budget.engineering <budget-id>
```

View file

@ -0,0 +1,48 @@
# litellm_fallback Resource
Manages a fallback configuration for a model in LiteLLM. Fallbacks are triggered when a call to the primary model fails after retries.
## Example Usage
### Basic Fallback Configuration
```hcl
resource "litellm_fallback" "gpt4_fallbacks" {
model = "gpt-4"
fallback_models = ["claude-3-sonnet", "gpt-3.5-turbo"]
}
```
### Context Window Fallback
```hcl
resource "litellm_fallback" "gpt4_context_window" {
model = "gpt-4"
fallback_models = ["claude-3-sonnet"]
fallback_type = "context_window"
}
```
## Argument Reference
The following arguments are supported:
* `model` - (Required, Forces new resource) The model name to configure fallbacks for. The model must already exist on the proxy.
* `fallback_models` - (Required) List of fallback model names in order of priority. Each model must exist on the proxy, and the primary model cannot be its own fallback.
* `fallback_type` - (Optional, Forces new resource) Type of fallback. One of `general` (default), `context_window`, or `content_policy`.
## Attribute Reference
In addition to the arguments above, the following attribute is exported:
* `id` - The primary model name.
## Import
Fallback configurations can be imported using the primary model name:
```shell
terraform import litellm_fallback.example gpt-4
```
Note: import always reads the `general` fallback type. Fallbacks of type `context_window` or `content_policy` cannot be imported.

View file

@ -0,0 +1,57 @@
# litellm_guardrail Resource
Manages a guardrail in LiteLLM. Guardrails provide content filtering, PII detection, prompt injection protection, and more.
## Example Usage
```hcl
resource "litellm_guardrail" "bedrock_guard" {
guardrail_name = "my-bedrock-guard"
guardrail = "bedrock"
mode = "pre_call"
default_on = true
litellm_params = jsonencode({
guardrailIdentifier = "ff6ujrregl1q"
guardrailVersion = "DRAFT"
})
guardrail_info = {
description = "Bedrock content moderation guardrail"
}
}
```
### Multiple Modes
```hcl
resource "litellm_guardrail" "pii_guard" {
guardrail_name = "presidio-pii"
guardrail = "presidio"
mode = jsonencode(["pre_call", "post_call"])
}
```
## Argument Reference
* `guardrail_name` - (Required) Human-readable name for the guardrail.
* `guardrail` - (Required) The guardrail integration type (e.g. `bedrock`, `lakera`, `presidio`, `openai_moderation`, `hide_secrets`).
* `mode` - (Required) When to apply the guardrail. A single value (`pre_call`, `post_call`, `during_call`, `logging_only`) or a JSON array of values.
* `default_on` - (Optional) Whether the guardrail is enabled by default for all requests.
* `litellm_params` - (Optional, Sensitive) JSON string with additional provider-specific parameters merged into `litellm_params` (may contain API keys). The API masks these values, so the configured value stays authoritative in state.
* `guardrail_info` - (Optional) Map of additional metadata for the guardrail.
## Attribute Reference
* `id` - The guardrail ID assigned by LiteLLM.
* `created_at` - Timestamp when the guardrail was created.
## Import
Guardrails can be imported using the guardrail ID:
```shell
terraform import litellm_guardrail.example 123e4567-e89b-12d3-a456-426614174000
```
Note: `guardrail`, `mode`, `default_on` and `litellm_params` are not returned unmasked by the API, so after import you must set them in configuration to match the server.

View file

@ -93,6 +93,24 @@ The following arguments are supported:
* `tags` - (Optional) List of tags associated with this key. This can be used for organization and filtering of keys.
* `budget_id` - (Optional) ID of a shared budget (created via `litellm_budget`) to attach to this key.
* `enforced_params` - (Optional) List of request parameters that callers must supply when using this key (for example `user`).
* `allowed_routes` - (Optional) List of proxy routes this key is allowed to call.
* `allowed_passthrough_routes` - (Optional) List of pass-through routes this key is allowed to call.
* `rpm_limit_type` - (Optional) How the RPM limit is enforced. One of `guaranteed_throughput`, `best_effort_throughput` or `dynamic`.
* `tpm_limit_type` - (Optional) How the TPM limit is enforced. One of `guaranteed_throughput`, `best_effort_throughput` or `dynamic`.
* `prompts` - (Optional) List of prompt IDs this key is allowed to use.
* `organization_id` - (Optional) ID of the organization this key belongs to.
* `project_id` - (Optional) ID of the project this key belongs to. Changing this forces a new key to be created.
## Attribute Reference
In addition to all arguments above, the following attributes are exported:

View file

@ -0,0 +1,40 @@
# litellm_key_block Resource
Manages the blocked state of an existing LiteLLM API key. Creating this resource blocks the key; destroying it unblocks the key.
If the key is unblocked outside of Terraform (or deleted), the resource is removed from state and Terraform plans to re-block it on the next apply.
## Example Usage
```hcl
resource "litellm_key" "example" {
models = ["gpt-4"]
}
resource "litellm_key_block" "example" {
key = litellm_key.example.key
}
```
## Argument Reference
The following arguments are supported:
* `key` - (Required, Forces new resource, Sensitive) The API key to block, as the raw `sk-` value or its SHA-256 token hash. The provider normalizes raw values to the hash before talking to the API, so the plaintext key never appears in request URLs, the resource ID, or plan output.
## Attribute Reference
In addition to the arguments above, the following attributes are exported:
* `id` - The SHA-256 token hash of the key.
* `blocked` - Whether the key is currently blocked. Always `true` while this resource exists.
If the same key is also managed by a `litellm_key` resource, that resource's `blocked` attribute will show drift while the block is active; either set `blocked` there instead of using this resource, or add `lifecycle { ignore_changes = [blocked] }` to the `litellm_key`.
## Import
Key blocks can be imported using the key's SHA-256 token hash (shown as the key's ID in `litellm_key` state and in `/key/info`):
```shell
terraform import litellm_key_block.example 88362cbb875f4b48b4b5b56b2ea45f66465e27d55a189816bd54e5643e5410eb
```

View file

@ -0,0 +1,71 @@
# litellm_project Resource
Manages a project in LiteLLM. Projects sit between teams and keys in the hierarchy, allowing fine-grained budget and model access control within a team
## Example Usage
```hcl
resource "litellm_team" "research" {
team_alias = "research-team"
}
resource "litellm_project" "ml_experiments" {
team_id = litellm_team.research.id
project_alias = "ml-experiments"
description = "ML experimentation project"
models = ["gpt-5.6", "claude-opus-5"]
max_budget = 1000.0
soft_budget = 800.0
budget_duration = "30d"
tpm_limit = 500000
rpm_limit = 5000
tags = ["research", "gpu"]
metadata = {
cost_center = "R&D-001"
}
}
```
## Argument Reference
The following arguments are supported:
* `team_id` - (Required, Forces new resource) The team ID this project belongs to
* `project_alias` - (Optional) Human-friendly name for the project
* `description` - (Optional) Description of the project's purpose and use case
* `models` - (Optional) List of models the project can access
* `metadata` - (Optional) Map of metadata for the project
* `tags` - (Optional) Tags associated with the project
* `max_budget` - (Optional) Maximum budget for this project
* `soft_budget` - (Optional) Soft budget limit for warnings
* `budget_duration` - (Optional) Budget reset duration, for example `1h`, `30d`
* `budget_id` - (Optional) Budget ID to associate with this project
* `tpm_limit` - (Optional) Tokens per minute limit
* `rpm_limit` - (Optional) Requests per minute limit
* `max_parallel_requests` - (Optional) Maximum parallel requests allowed
* `model_max_budget` - (Optional) Map of per-model budget limits
* `model_rpm_limit` - (Optional) Map of per-model RPM limits
* `model_tpm_limit` - (Optional) Map of per-model TPM limits
* `blocked` - (Optional) Whether the project is blocked from making requests
## Attribute Reference
In addition to all arguments above, the following attributes are exported:
* `id` - The project ID assigned by LiteLLM
* `spend` - Current spend for the project
* `created_at` - Timestamp when the project was created
* `updated_at` - Timestamp when the project was last updated
* `created_by` - User that created the project
* `updated_by` - User that last updated the project
## Import
Projects can be imported using the project ID:
```shell
terraform import litellm_project.example 4a422a4c-e246-4d02-a1eb-13e835cd0725
```

View file

@ -0,0 +1,61 @@
# litellm_prompt Resource
Manages a prompt in LiteLLM. Prompts let you manage prompt templates from external providers such as Langfuse, or inline dotprompt content.
## Example Usage
```hcl
resource "litellm_prompt" "langfuse_prompt" {
prompt_id = "my-langfuse-prompt"
prompt_integration = "langfuse"
api_base = "https://cloud.langfuse.com"
api_key = var.langfuse_api_key
prompt_type = "db"
litellm_params = jsonencode({
prompt_id = "prompt-name-in-langfuse"
})
}
```
### Dotprompt
```hcl
resource "litellm_prompt" "greeting" {
prompt_id = "greeting"
prompt_integration = "dotprompt"
prompt_type = "db"
dotprompt_content = <<-EOT
---
model: gpt-5.2
---
Say hello to {{name}}.
EOT
}
```
## Argument Reference
* `prompt_id` - (Required, Forces new resource) Unique identifier for the prompt.
* `prompt_integration` - (Required) The prompt integration provider (e.g. `langfuse`, `dotprompt`).
* `api_base` - (Optional) Base URL for the prompt provider API.
* `api_key` - (Optional, Sensitive) API key for the prompt provider. Never read back into state.
* `provider_specific_query_params` - (Optional) JSON string of provider-specific query parameters.
* `ignore_prompt_manager_model` - (Optional) If true, ignore the model specified in the prompt manager.
* `ignore_prompt_manager_optional_params` - (Optional) If true, ignore optional params from the prompt manager.
* `dotprompt_content` - (Optional) Content for the dotprompt integration.
* `litellm_params` - (Optional, Sensitive) JSON string with additional `litellm_params` merged into the request, e.g. the integration's own `prompt_id`, `prompt_directory` or `prompt_data`. Never read back into state.
* `prompt_type` - (Optional) Type of prompt: `config` or `db`.
## Attribute Reference
* `id` - The prompt ID (same as `prompt_id`).
## Import
Prompts can be imported using the prompt ID:
```shell
terraform import litellm_prompt.example my-langfuse-prompt
```

View file

@ -0,0 +1,46 @@
# litellm_search_tool Resource
Manages a search tool configuration on the LiteLLM proxy. Search tools connect the proxy's `/search` endpoints to an external search provider such as Tavily, Perplexity, or Exa.
## Example Usage
```hcl
resource "litellm_search_tool" "tavily" {
search_tool_name = "tavily-search"
litellm_params = jsonencode({
search_provider = "tavily"
api_key = var.tavily_api_key
})
search_tool_info = jsonencode({
description = "Tavily web search"
})
}
```
## Argument Reference
The following arguments are supported:
* `search_tool_name` - (Required) Name of the search tool.
* `litellm_params` - (Required, Sensitive) Search tool parameters as a JSON object string (use `jsonencode`). Must include `search_provider`, and typically an `api_key`; may also carry `api_base`, `timeout`, `max_retries`, and other provider options. The API only returns masked values, so this is never read back; the configured value is authoritative.
* `search_tool_info` - (Optional) Additional metadata as a JSON object string, e.g. a `description`.
## Attribute Reference
In addition to all arguments above, the following attributes are exported:
* `id` - The search tool ID assigned by LiteLLM.
* `created_at` - Timestamp when the search tool was created.
* `updated_at` - Timestamp when the search tool was last updated.
## Import
Search tools can be imported using the search tool ID:
```shell
terraform import litellm_search_tool.example <search_tool_id>
```
Note: `litellm_params` cannot be recovered on import because the API only returns masked values; re-apply after import to set it.

View file

@ -0,0 +1,49 @@
# litellm_tag Resource
Manages a tag in LiteLLM. Tags are used for spend tracking, budgets, and tag-based routing to specific model deployments
## Example Usage
```hcl
resource "litellm_tag" "production" {
name = "production"
description = "Production traffic"
models = ["4a422a4c-e246-4d02-a1eb-13e835cd0725"]
max_budget = 500.0
soft_budget = 400.0
budget_duration = "30d"
tpm_limit = 100000
rpm_limit = 1000
}
```
## Argument Reference
The following arguments are supported:
* `name` - (Required, Forces new resource) Unique name of the tag. Also used as the resource ID
* `description` - (Optional) Description of the tag
* `models` - (Optional) List of model IDs this tag applies to
* `budget_id` - (Optional) Existing budget ID to associate with this tag. If omitted and budget fields are set, the proxy creates a budget
* `max_budget` - (Optional) Max budget in USD for this tag
* `soft_budget` - (Optional) Soft budget in USD for this tag
* `max_parallel_requests` - (Optional) Max concurrent requests allowed for this tag
* `tpm_limit` - (Optional) Max tokens per minute for this tag
* `rpm_limit` - (Optional) Max requests per minute for this tag
* `budget_duration` - (Optional) Duration for budget reset, for example `1h`, `1d`, `30d`
* `model_max_budget` - (Optional) JSON object string with per-model budget configuration
## Attribute Reference
In addition to all arguments above, the following attributes are exported:
* `id` - The tag name
## Import
Tags can be imported using the tag name:
```shell
terraform import litellm_tag.example production
```

View file

@ -122,6 +122,32 @@ The following arguments are supported:
* `team_member_permissions` - (Optional) List of permissions granted to team members. This controls what actions team members can perform within the team context.
* `model_aliases` - (Optional) Map of alias names to model names, letting the team call models under stable alias names.
* `guardrails` - (Optional) List of guardrails applied to every request made by this team.
* `prompts` - (Optional) List of prompt IDs the team is allowed to use.
* `team_member_budget` - (Optional) Budget (in USD) applied to each individual team member.
* `team_member_budget_duration` - (Optional) Reset cycle for the per-member budget (e.g. `30d`, `1mo`).
* `team_member_rpm_limit` - (Optional) Requests per minute limit applied to each individual team member.
* `team_member_tpm_limit` - (Optional) Tokens per minute limit applied to each individual team member.
* `team_member_key_duration` - (Optional) Lifetime for keys created by team members (e.g. `1d`, `1w`).
* `model_rpm_limit` - (Optional) Map of model name to requests per minute limit for that model.
* `model_tpm_limit` - (Optional) Map of model name to tokens per minute limit for that model.
* `allowed_passthrough_routes` - (Optional) List of pass-through routes this team is allowed to call.
* `rpm_limit_type` - (Optional) How the RPM limit is enforced: `guaranteed_throughput` or `best_effort_throughput`. Changing this forces a new team to be created.
* `tpm_limit_type` - (Optional) How the TPM limit is enforced: `guaranteed_throughput` or `best_effort_throughput`. Changing this forces a new team to be created.
## Attribute Reference
In addition to the arguments above, the following attributes are exported:

View file

@ -0,0 +1,38 @@
# litellm_team_block Resource
Manages the blocked state of an existing LiteLLM team. Creating this resource blocks the team (all calls from its keys are rejected); destroying it unblocks the team.
If the team is unblocked outside of Terraform (or deleted), the resource is removed from state and Terraform plans to re-block it on the next apply.
## Example Usage
```hcl
resource "litellm_team" "example" {
team_alias = "suspended-team"
}
resource "litellm_team_block" "example" {
team_id = litellm_team.example.id
}
```
## Argument Reference
The following arguments are supported:
* `team_id` - (Required, Forces new resource) The ID of the team to block.
## Attribute Reference
In addition to the arguments above, the following attributes are exported:
* `id` - The team ID.
* `blocked` - Whether the team is currently blocked. Always `true` while this resource exists.
## Import
Team blocks can be imported using the team ID:
```shell
terraform import litellm_team_block.example team-1234
```

View file

@ -0,0 +1,64 @@
---
page_title: "litellm_unified_access_group Resource - terraform-provider-litellm"
subcategory: ""
description: |-
Manages a LiteLLM unified access group.
---
# litellm_unified_access_group (Resource)
Manages a LiteLLM unified access group. Unified access groups grant access to models, MCP servers, and agents in one bundle, and can be assigned to teams and keys.
## Example Usage
```terraform
resource "litellm_unified_access_group" "engineering" {
access_group_name = "engineering-access"
description = "Models and tools for the engineering org"
access_model_names = ["gpt-4", "claude-3-sonnet"]
access_mcp_server_ids = [litellm_mcp_server.github.id]
assigned_team_ids = [litellm_team.engineering.id]
}
```
## Argument Reference
* `access_group_name` - (Required) Display name of the unified access group.
* `description` - (Optional) Description of the unified access group.
* `access_model_names` - (Optional) Model names this access group grants access to.
* `access_mcp_server_ids` - (Optional) MCP server IDs this access group grants access to.
* `access_agent_ids` - (Optional) Agent IDs this access group grants access to.
* `assigned_team_ids` - (Optional) Team IDs the access group is assigned to.
* `assigned_key_ids` - (Optional) Key IDs (token hashes) the access group is assigned to.
## Attribute Reference
In addition to the arguments above, the following attributes are exported:
* `id` - The unique identifier of the unified access group.
* `access_group_id` - Same as `id`.
* `created_at` - Timestamp when the access group was created.
* `created_by` - User who created the access group.
* `updated_at` - Timestamp when the access group was last updated.
* `updated_by` - User who last updated the access group.
## Import
Unified access groups can be imported using the access group ID:
```shell
terraform import litellm_unified_access_group.engineering <access-group-id>
```

View file

@ -0,0 +1,66 @@
# litellm_user Resource
Manages an internal user on the LiteLLM proxy. Internal users can log into the Admin UI, own API keys, and belong to teams
## Example Usage
```hcl
resource "litellm_user" "alice" {
user_email = "alice@example.com"
user_alias = "Alice"
user_role = "internal_user"
max_budget = 100.0
budget_duration = "30d"
tpm_limit = 100000
rpm_limit = 1000
teams = [litellm_team.engineering.id]
models = ["gpt-4o", "claude-sonnet-4-5"]
metadata = {
department = "engineering"
}
model_max_budget = jsonencode({
"gpt-4o" = {
max_budget = 25.0
}
})
}
```
## Argument Reference
- `user_id` (Optional, Forces new resource) - Unique ID for the user. Generated by the server if not provided
- `user_email` (Optional) - Email address of the user
- `user_alias` (Optional) - Descriptive name for the user
- `user_role` (Optional) - Role of the user. One of `proxy_admin`, `proxy_admin_viewer`, `internal_user`, `internal_user_viewer`
- `teams` (Optional) - List of team IDs the user belongs to
- `models` (Optional) - Models the user is allowed to call
- `max_budget` (Optional) - Maximum budget in USD for the user
- `budget_duration` (Optional) - Budget reset period, e.g. `30s`, `30m`, `30d`
- `tpm_limit` (Optional) - Tokens per minute limit
- `rpm_limit` (Optional) - Requests per minute limit
- `max_parallel_requests` (Optional) - Maximum number of parallel requests
- `metadata` (Optional) - Map of metadata for the user
- `auto_create_key` (Optional, Default `true`, Forces new resource) - Whether to auto-create an API key on creation
- `send_invite_email` (Optional, Default `false`, Forces new resource) - Whether to send an invite email on creation
- `key_alias` (Optional) - Alias for the auto-created API key
- `aliases` (Optional) - Map of model aliases for the user
- `config` (Optional) - Map of config values for the user
- `permissions` (Optional) - Map of permission values for the user
- `model_max_budget` (Optional) - JSON string of per-model budget config, e.g. `jsonencode({"gpt-4o" = {max_budget = 10.0}})`
- `guardrails` (Optional) - List of guardrails applied to the user's requests
- `blocked` (Optional, Default `false`) - Whether the user is blocked from making requests
## Attribute Reference
- `id` - The user ID
- `key` (Sensitive) - The auto-created API key for the user, populated when `auto_create_key` is `true`
## Import
Users can be imported using the user ID:
```shell
terraform import litellm_user.alice <user-id>
```

View file

@ -61,6 +61,17 @@ func (c *Client) GetKey(keyID string) (*Key, error) {
return nil, err
}
// /key/info nests the key's fields under "info"; only "key" itself is
// top-level. Without unwrapping, reads map nothing back into state.
if info, ok := resp["info"].(map[string]interface{}); ok {
if _, present := info["key"]; !present {
if k, ok := resp["key"].(string); ok {
info["key"] = k
}
}
return c.parseKeyResponse(info)
}
return c.parseKeyResponse(resp)
}
@ -70,7 +81,6 @@ func (c *Client) UpdateKey(key *Key) (*Key, error) {
"key": key.Key,
"team_id": key.TeamID,
"metadata": key.Metadata,
"budget_duration": key.BudgetDuration,
"key_alias": key.KeyAlias,
"aliases": key.Aliases,
"permissions": key.Permissions,
@ -80,6 +90,12 @@ func (c *Client) UpdateKey(key *Key) (*Key, error) {
"blocked": key.Blocked,
}
// The proxy rejects an empty-string budget_duration with a 400, so only
// send it when set.
if key.BudgetDuration != "" {
updateData["budget_duration"] = key.BudgetDuration
}
// Only add pointer fields if they are explicitly set
if key.MaxBudget != nil {
updateData["max_budget"] = *key.MaxBudget
@ -107,6 +123,30 @@ func (c *Client) UpdateKey(key *Key) (*Key, error) {
if len(key.Tags) > 0 {
updateData["tags"] = key.Tags
}
if key.BudgetID != "" {
updateData["budget_id"] = key.BudgetID
}
if len(key.EnforcedParams) > 0 {
updateData["enforced_params"] = key.EnforcedParams
}
if len(key.AllowedRoutes) > 0 {
updateData["allowed_routes"] = key.AllowedRoutes
}
if len(key.AllowedPassthroughRoutes) > 0 {
updateData["allowed_passthrough_routes"] = key.AllowedPassthroughRoutes
}
if key.RPMLimitType != "" {
updateData["rpm_limit_type"] = key.RPMLimitType
}
if key.TPMLimitType != "" {
updateData["tpm_limit_type"] = key.TPMLimitType
}
if len(key.Prompts) > 0 {
updateData["prompts"] = key.Prompts
}
if key.OrganizationID != "" {
updateData["organization_id"] = key.OrganizationID
}
resp, err := c.sendRequest("POST", "/key/update", updateData)
if err != nil {
@ -251,6 +291,34 @@ func (c *Client) parseKeyResponse(resp map[string]interface{}) (*Key, error) {
}
}
}
case "budget_id":
if s, ok := v.(string); ok {
createdKey.BudgetID = s
}
case "enforced_params":
createdKey.EnforcedParams = toStringSlice(v)
case "allowed_routes":
createdKey.AllowedRoutes = toStringSlice(v)
case "allowed_passthrough_routes":
createdKey.AllowedPassthroughRoutes = toStringSlice(v)
case "rpm_limit_type":
if s, ok := v.(string); ok {
createdKey.RPMLimitType = s
}
case "tpm_limit_type":
if s, ok := v.(string); ok {
createdKey.TPMLimitType = s
}
case "prompts":
createdKey.Prompts = toStringSlice(v)
case "organization_id":
if s, ok := v.(string); ok {
createdKey.OrganizationID = s
}
case "project_id":
if s, ok := v.(string); ok {
createdKey.ProjectID = s
}
}
}

View file

@ -0,0 +1,140 @@
package litellm
import (
"encoding/json"
"fmt"
"net/http"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
const endpointAccessGroupList = "/access_group/list"
type accessGroupListResponse struct {
AccessGroups []accessGroupInfoResponse `json:"access_groups"`
}
func dataSourceLiteLLMAccessGroup() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMAccessGroupRead,
Schema: map[string]*schema.Schema{
"access_group": {
Type: schema.TypeString,
Required: true,
Description: "Name of the access group to retrieve",
},
"model_names": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"deployment_count": {
Type: schema.TypeInt,
Computed: true,
},
},
}
}
func dataSourceLiteLLMAccessGroupRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
name := d.Get("access_group").(string)
resp, err := MakeRequest(client, "GET", fmt.Sprintf("/access_group/%s/info", name), nil)
if err != nil {
return fmt.Errorf("error reading access group: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("access group '%s' not found", name)
}
if err := handleResponse(resp, "reading access group"); err != nil {
return err
}
var info accessGroupInfoResponse
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return fmt.Errorf("error decoding access group info response: %w", err)
}
d.SetId(GetStringValue(info.AccessGroup, name))
d.Set("access_group", GetStringValue(info.AccessGroup, name))
d.Set("model_names", info.ModelNames)
d.Set("deployment_count", info.DeploymentCount)
return nil
}
func dataSourceLiteLLMAccessGroups() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMAccessGroupsRead,
Schema: map[string]*schema.Schema{
"access_groups": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"access_group": {
Type: schema.TypeString,
Computed: true,
},
"model_names": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"deployment_count": {
Type: schema.TypeInt,
Computed: true,
},
},
},
},
"ids": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
},
}
}
func dataSourceLiteLLMAccessGroupsRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
resp, err := MakeRequest(client, "GET", endpointAccessGroupList, nil)
if err != nil {
return fmt.Errorf("error listing access groups: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "listing access groups"); err != nil {
return err
}
var listResp accessGroupListResponse
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
return fmt.Errorf("error decoding access group list response: %w", err)
}
groups := make([]map[string]interface{}, 0, len(listResp.AccessGroups))
ids := make([]string, 0, len(listResp.AccessGroups))
for _, group := range listResp.AccessGroups {
groups = append(groups, map[string]interface{}{
"access_group": group.AccessGroup,
"model_names": group.ModelNames,
"deployment_count": group.DeploymentCount,
})
ids = append(ids, group.AccessGroup)
}
d.SetId("access_groups")
d.Set("access_groups", groups)
d.Set("ids", ids)
return nil
}

View file

@ -0,0 +1,97 @@
package litellm
import (
"net/http"
"net/http/httptest"
"reflect"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func TestAccessGroupDataSourceRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/access_group/prod-models/info" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
w.Write(accessGroupInfoJSON("prod-models", []string{"gpt-4", "claude-3"}, 2))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMAccessGroup().Schema, map[string]interface{}{
"access_group": "prod-models",
})
if err := dataSourceLiteLLMAccessGroupRead(d, client); err != nil {
t.Fatalf("data source read failed: %v", err)
}
if d.Id() != "prod-models" {
t.Fatalf("expected ID 'prod-models', got %q", d.Id())
}
wantModels := []interface{}{"gpt-4", "claude-3"}
if !reflect.DeepEqual(d.Get("model_names"), wantModels) {
t.Fatalf("expected model_names %v, got %v", wantModels, d.Get("model_names"))
}
if d.Get("deployment_count").(int) != 2 {
t.Fatalf("expected deployment_count 2, got %v", d.Get("deployment_count"))
}
}
func TestAccessGroupDataSourceReadNotFound(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 := schema.TestResourceDataRaw(t, dataSourceLiteLLMAccessGroup().Schema, map[string]interface{}{
"access_group": "missing",
})
if err := dataSourceLiteLLMAccessGroupRead(d, client); err == nil {
t.Fatal("expected error for missing access group, got nil")
}
}
func TestAccessGroupsDataSourceRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/access_group/list" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
w.Write([]byte(`{"access_groups": [` +
`{"access_group": "group-a", "model_names": ["gpt-4"], "deployment_count": 1},` +
`{"access_group": "group-b", "model_names": ["claude-3"], "deployment_count": 2}]}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMAccessGroups().Schema, map[string]interface{}{})
if err := dataSourceLiteLLMAccessGroupsRead(d, client); err != nil {
t.Fatalf("data source read failed: %v", err)
}
groups := d.Get("access_groups").([]interface{})
if len(groups) != 2 {
t.Fatalf("expected 2 access groups, got %d", len(groups))
}
first := groups[0].(map[string]interface{})
if first["access_group"] != "group-a" {
t.Fatalf("expected first access_group 'group-a', got %v", first["access_group"])
}
if !reflect.DeepEqual(first["model_names"], []interface{}{"gpt-4"}) {
t.Fatalf("expected first model_names [gpt-4], got %v", first["model_names"])
}
if first["deployment_count"].(int) != 1 {
t.Fatalf("expected first deployment_count 1, got %v", first["deployment_count"])
}
if !reflect.DeepEqual(d.Get("ids"), []interface{}{"group-a", "group-b"}) {
t.Fatalf("expected ids [group-a group-b], got %v", d.Get("ids"))
}
}

View file

@ -0,0 +1,281 @@
package litellm
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func dataSourceLiteLLMAgent() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMAgentRead,
Schema: map[string]*schema.Schema{
"agent_id": {
Type: schema.TypeString,
Required: true,
Description: "Unique identifier of the agent to retrieve.",
},
"agent_name": {
Type: schema.TypeString,
Computed: true,
},
"agent_card_params": {
Type: schema.TypeString,
Computed: true,
Description: "A2A agent card as a JSON object string.",
},
"object_permission": {
Type: schema.TypeString,
Computed: true,
Description: "Access control permissions as a JSON object string.",
},
"extra_headers": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"tpm_limit": {
Type: schema.TypeInt,
Computed: true,
},
"rpm_limit": {
Type: schema.TypeInt,
Computed: true,
},
"session_tpm_limit": {
Type: schema.TypeInt,
Computed: true,
},
"session_rpm_limit": {
Type: schema.TypeInt,
Computed: true,
},
"spend": {
Type: schema.TypeFloat,
Computed: true,
},
"created_at": {
Type: schema.TypeString,
Computed: true,
},
"updated_at": {
Type: schema.TypeString,
Computed: true,
},
"created_by": {
Type: schema.TypeString,
Computed: true,
},
"updated_by": {
Type: schema.TypeString,
Computed: true,
},
},
}
}
func dataSourceLiteLLMAgentRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
agentID := d.Get("agent_id").(string)
resp, err := MakeRequest(client, "GET", fmt.Sprintf(endpointAgentByID, agentID), nil)
if err != nil {
return fmt.Errorf("error reading agent: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("agent '%s' not found", agentID)
}
if err := handleResponse(resp, "reading agent"); err != nil {
return err
}
var agentResp agentAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&agentResp); err != nil {
return fmt.Errorf("error decoding agent info response: %w", err)
}
d.SetId(agentResp.AgentID)
d.Set("agent_name", agentResp.AgentName)
if agentResp.AgentCardParams != nil {
cardJSON, err := json.Marshal(agentResp.AgentCardParams)
if err != nil {
return fmt.Errorf("error encoding agent_card_params: %w", err)
}
d.Set("agent_card_params", string(cardJSON))
}
if agentResp.ObjectPermission != nil {
permJSON, err := json.Marshal(agentResp.ObjectPermission)
if err != nil {
return fmt.Errorf("error encoding object_permission: %w", err)
}
d.Set("object_permission", string(permJSON))
}
if agentResp.ExtraHeaders != nil {
d.Set("extra_headers", agentResp.ExtraHeaders)
}
if agentResp.TPMLimit != nil {
d.Set("tpm_limit", *agentResp.TPMLimit)
}
if agentResp.RPMLimit != nil {
d.Set("rpm_limit", *agentResp.RPMLimit)
}
if agentResp.SessionTPMLimit != nil {
d.Set("session_tpm_limit", *agentResp.SessionTPMLimit)
}
if agentResp.SessionRPMLimit != nil {
d.Set("session_rpm_limit", *agentResp.SessionRPMLimit)
}
if agentResp.Spend != nil {
d.Set("spend", *agentResp.Spend)
}
d.Set("created_at", agentResp.CreatedAt)
d.Set("updated_at", agentResp.UpdatedAt)
d.Set("created_by", agentResp.CreatedBy)
d.Set("updated_by", agentResp.UpdatedBy)
return nil
}
func dataSourceLiteLLMAgents() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMAgentsRead,
Schema: map[string]*schema.Schema{
"health_check": {
Type: schema.TypeBool,
Optional: true,
Default: false,
Description: "When true, the proxy probes each agent's URL and only returns agents that are " +
"reachable or have no URL.",
},
"ids": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"agents": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"agent_id": {
Type: schema.TypeString,
Computed: true,
},
"agent_name": {
Type: schema.TypeString,
Computed: true,
},
"tpm_limit": {
Type: schema.TypeInt,
Computed: true,
},
"rpm_limit": {
Type: schema.TypeInt,
Computed: true,
},
"session_tpm_limit": {
Type: schema.TypeInt,
Computed: true,
},
"session_rpm_limit": {
Type: schema.TypeInt,
Computed: true,
},
"spend": {
Type: schema.TypeFloat,
Computed: true,
},
"created_at": {
Type: schema.TypeString,
Computed: true,
},
"updated_at": {
Type: schema.TypeString,
Computed: true,
},
"created_by": {
Type: schema.TypeString,
Computed: true,
},
"updated_by": {
Type: schema.TypeString,
Computed: true,
},
},
},
},
},
}
}
func dataSourceLiteLLMAgentsRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
endpoint := endpointAgents
if d.Get("health_check").(bool) {
endpoint = fmt.Sprintf("%s?health_check=true", endpointAgents)
}
resp, err := MakeRequest(client, "GET", endpoint, nil)
if err != nil {
return fmt.Errorf("error listing agents: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "listing agents"); err != nil {
return err
}
var agentResps []agentAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&agentResps); err != nil {
return fmt.Errorf("error decoding agents list response: %w", err)
}
ids := make([]string, 0, len(agentResps))
agents := make([]map[string]interface{}, 0, len(agentResps))
for _, agentResp := range agentResps {
ids = append(ids, agentResp.AgentID)
agent := map[string]interface{}{
"agent_id": agentResp.AgentID,
"agent_name": agentResp.AgentName,
"created_at": agentResp.CreatedAt,
"updated_at": agentResp.UpdatedAt,
"created_by": agentResp.CreatedBy,
"updated_by": agentResp.UpdatedBy,
}
if agentResp.TPMLimit != nil {
agent["tpm_limit"] = *agentResp.TPMLimit
}
if agentResp.RPMLimit != nil {
agent["rpm_limit"] = *agentResp.RPMLimit
}
if agentResp.SessionTPMLimit != nil {
agent["session_tpm_limit"] = *agentResp.SessionTPMLimit
}
if agentResp.SessionRPMLimit != nil {
agent["session_rpm_limit"] = *agentResp.SessionRPMLimit
}
if agentResp.Spend != nil {
agent["spend"] = *agentResp.Spend
}
agents = append(agents, agent)
}
d.SetId(strconv.FormatInt(time.Now().UnixNano(), 10))
d.Set("ids", ids)
d.Set("agents", agents)
return nil
}

View file

@ -0,0 +1,94 @@
package litellm
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func TestDataSourceLiteLLMAgentRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/v1/agents/agent-123" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write(agentReadResponseBody())
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMAgent().Schema, map[string]interface{}{
"agent_id": "agent-123",
})
if err := dataSourceLiteLLMAgentRead(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if d.Id() != "agent-123" {
t.Fatalf("expected ID 'agent-123', got %q", d.Id())
}
if d.Get("agent_name").(string) != "my-agent" {
t.Errorf("expected agent_name 'my-agent', got %q", d.Get("agent_name").(string))
}
var card map[string]interface{}
if err := json.Unmarshal([]byte(d.Get("agent_card_params").(string)), &card); err != nil {
t.Fatalf("agent_card_params not populated as JSON: %v", err)
}
if card["url"] != "http://agent.local:9999/" {
t.Errorf("expected card url, got %v", card["url"])
}
if d.Get("spend").(float64) != 1.5 {
t.Errorf("expected spend 1.5, got %v", d.Get("spend"))
}
if d.Get("tpm_limit").(int) != 1000 {
t.Errorf("expected tpm_limit 1000, got %d", d.Get("tpm_limit").(int))
}
}
func TestDataSourceLiteLLMAgentsRead(t *testing.T) {
var gotQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/v1/agents" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
gotQuery = r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
body, _ := json.Marshal([]map[string]interface{}{
{"agent_id": "agent-1", "agent_name": "first", "tpm_limit": 100, "spend": 0.5},
{"agent_id": "agent-2", "agent_name": "second"},
})
w.Write(body)
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMAgents().Schema, map[string]interface{}{
"health_check": true,
})
if err := dataSourceLiteLLMAgentsRead(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if gotQuery != "health_check=true" {
t.Errorf("expected health_check=true query, got %q", gotQuery)
}
ids := d.Get("ids").([]interface{})
if len(ids) != 2 || ids[0] != "agent-1" || ids[1] != "agent-2" {
t.Fatalf("expected ids [agent-1 agent-2], got %v", ids)
}
agents := d.Get("agents").([]interface{})
if len(agents) != 2 {
t.Fatalf("expected 2 agents, got %d", len(agents))
}
first := agents[0].(map[string]interface{})
if first["agent_name"] != "first" || first["tpm_limit"] != 100 || first["spend"] != 0.5 {
t.Errorf("unexpected first agent entry: %v", first)
}
if d.Id() == "" {
t.Fatal("expected data source ID to be set")
}
}

View file

@ -0,0 +1,195 @@
package litellm
import (
"encoding/json"
"fmt"
"net/http"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
const endpointBudgetList = "/budget/list"
func dataSourceLiteLLMBudget() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMBudgetRead,
Schema: map[string]*schema.Schema{
"budget_id": {
Type: schema.TypeString,
Required: true,
Description: "ID of the budget to retrieve",
},
"max_budget": {
Type: schema.TypeFloat,
Computed: true,
Description: "Hard budget limit in USD",
},
"soft_budget": {
Type: schema.TypeFloat,
Computed: true,
Description: "Soft budget limit in USD that triggers alerts",
},
"max_parallel_requests": {
Type: schema.TypeInt,
Computed: true,
Description: "Maximum concurrent requests allowed for this budget",
},
"tpm_limit": {
Type: schema.TypeInt,
Computed: true,
Description: "Maximum tokens per minute allowed for this budget",
},
"rpm_limit": {
Type: schema.TypeInt,
Computed: true,
Description: "Maximum requests per minute allowed for this budget",
},
"budget_duration": {
Type: schema.TypeString,
Computed: true,
Description: "Budget reset period",
},
"model_max_budget": {
Type: schema.TypeString,
Computed: true,
Description: "JSON string of per-model budget config",
},
"budget_reset_at": {
Type: schema.TypeString,
Computed: true,
Description: "Datetime when the budget is reset",
},
},
}
}
func dataSourceLiteLLMBudgetRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
budgetID := d.Get("budget_id").(string)
resp, err := MakeRequest(client, "POST", endpointBudgetInfo, map[string]interface{}{
"budgets": []string{budgetID},
})
if err != nil {
return fmt.Errorf("failed to read budget: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("budget '%s' not found", budgetID)
}
if err := handleResponse(resp, "reading budget"); err != nil {
return err
}
var budgetResps []budgetResponse
if err := json.NewDecoder(resp.Body).Decode(&budgetResps); err != nil {
return fmt.Errorf("error decoding budget info response: %w", err)
}
if len(budgetResps) == 0 {
return fmt.Errorf("budget '%s' not found", budgetID)
}
d.SetId(budgetID)
setBudgetState(d, budgetResps[0])
return nil
}
func dataSourceLiteLLMBudgets() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMBudgetsRead,
Schema: map[string]*schema.Schema{
"budgets": {
Type: schema.TypeList,
Computed: true,
Description: "All budgets configured on the proxy",
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"budget_id": {Type: schema.TypeString, Computed: true},
"max_budget": {Type: schema.TypeFloat, Computed: true},
"soft_budget": {Type: schema.TypeFloat, Computed: true},
"max_parallel_requests": {Type: schema.TypeInt, Computed: true},
"tpm_limit": {Type: schema.TypeInt, Computed: true},
"rpm_limit": {Type: schema.TypeInt, Computed: true},
"budget_duration": {Type: schema.TypeString, Computed: true},
"model_max_budget": {Type: schema.TypeString, Computed: true},
"budget_reset_at": {Type: schema.TypeString, Computed: true},
},
},
},
"ids": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "IDs of all budgets configured on the proxy",
},
},
}
}
func budgetListEntry(budgetResp budgetResponse) map[string]interface{} {
entry := map[string]interface{}{
"budget_id": budgetResp.BudgetID,
}
if budgetResp.MaxBudget != nil {
entry["max_budget"] = *budgetResp.MaxBudget
}
if budgetResp.SoftBudget != nil {
entry["soft_budget"] = *budgetResp.SoftBudget
}
if budgetResp.MaxParallelRequests != nil {
entry["max_parallel_requests"] = *budgetResp.MaxParallelRequests
}
if budgetResp.TPMLimit != nil {
entry["tpm_limit"] = *budgetResp.TPMLimit
}
if budgetResp.RPMLimit != nil {
entry["rpm_limit"] = *budgetResp.RPMLimit
}
if budgetResp.BudgetDuration != nil {
entry["budget_duration"] = *budgetResp.BudgetDuration
}
if encoded, ok := budgetModelMaxBudgetString(budgetResp.ModelMaxBudget); ok {
entry["model_max_budget"] = encoded
}
if budgetResp.BudgetResetAt != nil {
entry["budget_reset_at"] = *budgetResp.BudgetResetAt
}
return entry
}
func dataSourceLiteLLMBudgetsRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
resp, err := MakeRequest(client, "GET", endpointBudgetList, nil)
if err != nil {
return fmt.Errorf("failed to list budgets: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "listing budgets"); err != nil {
return err
}
var budgetResps []budgetResponse
if err := json.NewDecoder(resp.Body).Decode(&budgetResps); err != nil {
return fmt.Errorf("error decoding budget list response: %w", err)
}
budgets := make([]map[string]interface{}, 0, len(budgetResps))
ids := make([]string, 0, len(budgetResps))
for _, budgetResp := range budgetResps {
budgets = append(budgets, budgetListEntry(budgetResp))
ids = append(ids, budgetResp.BudgetID)
}
d.SetId("budgets")
d.Set("budgets", budgets)
d.Set("ids", ids)
return nil
}

View file

@ -0,0 +1,107 @@
package litellm
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func TestDataSourceBudgetRead_MapsFields(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/budget/info" || r.Method != http.MethodPost {
t.Errorf("expected POST /budget/info, got %s %s", r.Method, r.URL.Path)
}
var payload map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("failed to decode info payload: %v", err)
}
budgets, ok := payload["budgets"].([]interface{})
if !ok || len(budgets) != 1 || budgets[0] != "bud-ds" {
t.Errorf("expected budgets ['bud-ds'], got %v", payload["budgets"])
}
w.Write(budgetInfoBody("bud-ds"))
}))
defer srv.Close()
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMBudget().Schema, map[string]interface{}{
"budget_id": "bud-ds",
})
if err := dataSourceLiteLLMBudgetRead(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("read failed: %v", err)
}
if d.Id() != "bud-ds" {
t.Fatalf("expected ID 'bud-ds', got %q", d.Id())
}
if got := d.Get("max_budget").(float64); got != 100.0 {
t.Errorf("expected max_budget 100.0, got %v", got)
}
if got := d.Get("budget_duration").(string); got != "30d" {
t.Errorf("expected budget_duration '30d', got %q", got)
}
if got := d.Get("budget_reset_at").(string); got != "2026-09-01T00:00:00Z" {
t.Errorf("expected budget_reset_at set, got %q", got)
}
}
func TestDataSourceBudgetsRead_MapsList(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/budget/list" || r.Method != http.MethodGet {
t.Errorf("expected GET /budget/list, got %s %s", r.Method, r.URL.Path)
}
body, _ := json.Marshal([]map[string]interface{}{
{
"budget_id": "bud-1",
"max_budget": 10.0,
"tpm_limit": 500,
"model_max_budget": map[string]interface{}{"gpt-4o": map[string]interface{}{"max_budget": 1.0}},
},
{
"budget_id": "bud-2",
"soft_budget": 5.0,
},
})
w.Write(body)
}))
defer srv.Close()
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMBudgets().Schema, map[string]interface{}{})
if err := dataSourceLiteLLMBudgetsRead(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("read failed: %v", err)
}
budgets := d.Get("budgets").([]interface{})
if len(budgets) != 2 {
t.Fatalf("expected 2 budgets, got %d", len(budgets))
}
first := budgets[0].(map[string]interface{})
if got := first["budget_id"].(string); got != "bud-1" {
t.Errorf("expected first budget_id 'bud-1', got %q", got)
}
if got := first["max_budget"].(float64); got != 10.0 {
t.Errorf("expected first max_budget 10.0, got %v", got)
}
if got := first["tpm_limit"].(int); got != 500 {
t.Errorf("expected first tpm_limit 500, got %d", got)
}
var mmb map[string]interface{}
if err := json.Unmarshal([]byte(first["model_max_budget"].(string)), &mmb); err != nil {
t.Fatalf("model_max_budget is not valid JSON: %v", err)
}
if _, ok := mmb["gpt-4o"]; !ok {
t.Errorf("expected gpt-4o key in model_max_budget, got %v", mmb)
}
second := budgets[1].(map[string]interface{})
if got := second["soft_budget"].(float64); got != 5.0 {
t.Errorf("expected second soft_budget 5.0, got %v", got)
}
ids := d.Get("ids").([]interface{})
if len(ids) != 2 || ids[0] != "bud-1" || ids[1] != "bud-2" {
t.Errorf("expected ids [bud-1 bud-2], got %v", ids)
}
}

View file

@ -0,0 +1,71 @@
package litellm
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
)
func dataSourceLiteLLMFallback() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMFallbackRead,
Schema: map[string]*schema.Schema{
"model": {
Type: schema.TypeString,
Required: true,
Description: "The model name to get fallbacks for",
},
"fallback_type": {
Type: schema.TypeString,
Optional: true,
Default: "general",
ValidateFunc: validation.StringInSlice([]string{"general", "context_window", "content_policy"}, false),
Description: "Type of fallback: 'general' (default), 'context_window', or 'content_policy'",
},
"fallback_models": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "List of fallback model names in order of priority",
},
},
}
}
func dataSourceLiteLLMFallbackRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
model := d.Get("model").(string)
fallbackType := GetStringValue(d.Get("fallback_type").(string), "general")
endpoint := fmt.Sprintf("/fallback/%s?fallback_type=%s", url.PathEscape(model), url.QueryEscape(fallbackType))
resp, err := MakeRequest(client, "GET", endpoint, nil)
if err != nil {
return fmt.Errorf("failed to read fallback: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("no %s fallbacks configured for model '%s'", fallbackType, model)
}
if err := handleResponse(resp, "reading fallback"); err != nil {
return err
}
var fallbackResp FallbackGetResponse
if err := json.NewDecoder(resp.Body).Decode(&fallbackResp); err != nil {
return fmt.Errorf("error decoding fallback response: %w", err)
}
d.SetId(model)
d.Set("model", GetStringValue(fallbackResp.Model, model))
d.Set("fallback_models", fallbackResp.FallbackModels)
d.Set("fallback_type", GetStringValue(fallbackResp.FallbackType, fallbackType))
return nil
}

View file

@ -0,0 +1,63 @@
package litellm
import (
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func TestDataSourceLiteLLMFallbackRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/fallback/gpt-4" {
t.Errorf("expected path /fallback/gpt-4, got %s", r.URL.Path)
}
if got := r.URL.Query().Get("fallback_type"); got != "general" {
t.Errorf("expected fallback_type query 'general', got %q", got)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"model":"gpt-4","fallback_models":["claude-3","gpt-3.5-turbo"],"fallback_type":"general"}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMFallback().Schema, map[string]interface{}{
"model": "gpt-4",
"fallback_type": "general",
})
if err := dataSourceLiteLLMFallbackRead(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if d.Id() != "gpt-4" {
t.Fatalf("expected ID 'gpt-4', got %q", d.Id())
}
got := d.Get("fallback_models").([]interface{})
if !reflect.DeepEqual(got, []interface{}{"claude-3", "gpt-3.5-turbo"}) {
t.Fatalf("expected fallback_models [claude-3 gpt-3.5-turbo], got %+v", got)
}
}
func TestDataSourceLiteLLMFallbackRead_NotFoundErrors(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 := schema.TestResourceDataRaw(t, dataSourceLiteLLMFallback().Schema, map[string]interface{}{
"model": "missing-model",
"fallback_type": "general",
})
err := dataSourceLiteLLMFallbackRead(d, client)
if err == nil {
t.Fatal("expected error for missing fallback, got nil")
}
if !strings.Contains(err.Error(), "missing-model") {
t.Fatalf("expected error to name the model, got: %v", err)
}
}

View file

@ -0,0 +1,178 @@
package litellm
import (
"encoding/json"
"fmt"
"net/http"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
const endpointGuardrailList = "/guardrails/list"
func dataSourceLiteLLMGuardrail() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMGuardrailRead,
Schema: map[string]*schema.Schema{
"guardrail_id": {
Type: schema.TypeString,
Required: true,
Description: "Unique identifier of the guardrail to retrieve",
},
"guardrail_name": {
Type: schema.TypeString,
Computed: true,
},
"guardrail_info": {
Type: schema.TypeMap,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"guardrail_definition_location": {
Type: schema.TypeString,
Computed: true,
Description: "Where the guardrail is defined: 'config' or 'db'",
},
"created_at": {
Type: schema.TypeString,
Computed: true,
},
"updated_at": {
Type: schema.TypeString,
Computed: true,
},
},
}
}
type guardrailListItemAPIResponse struct {
GuardrailID string `json:"guardrail_id"`
GuardrailName string `json:"guardrail_name"`
GuardrailInfo map[string]interface{} `json:"guardrail_info"`
GuardrailDefinitionLocation string `json:"guardrail_definition_location"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func dataSourceLiteLLMGuardrailRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
guardrailID := d.Get("guardrail_id").(string)
resp, err := MakeRequest(client, "GET", fmt.Sprintf(endpointGuardrailInfo, guardrailID), nil)
if err != nil {
return fmt.Errorf("failed to read guardrail: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("guardrail '%s' not found", guardrailID)
}
if err := handleResponse(resp, "reading guardrail"); err != nil {
return err
}
var info guardrailListItemAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return fmt.Errorf("error decoding guardrail info response: %w", err)
}
d.SetId(guardrailID)
d.Set("guardrail_name", info.GuardrailName)
d.Set("guardrail_info", guardrailInfoToStringMap(info.GuardrailInfo))
d.Set("guardrail_definition_location", info.GuardrailDefinitionLocation)
d.Set("created_at", info.CreatedAt)
d.Set("updated_at", info.UpdatedAt)
// litellm_params is intentionally not exposed: it can carry API keys.
return nil
}
func dataSourceLiteLLMGuardrails() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMGuardrailsRead,
Schema: map[string]*schema.Schema{
"guardrails": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"guardrail_id": {
Type: schema.TypeString,
Computed: true,
},
"guardrail_name": {
Type: schema.TypeString,
Computed: true,
},
"guardrail_info": {
Type: schema.TypeMap,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"guardrail_definition_location": {
Type: schema.TypeString,
Computed: true,
},
"created_at": {
Type: schema.TypeString,
Computed: true,
},
"updated_at": {
Type: schema.TypeString,
Computed: true,
},
},
},
},
"ids": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
},
}
}
func dataSourceLiteLLMGuardrailsRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
resp, err := MakeRequest(client, "GET", endpointGuardrailList, nil)
if err != nil {
return fmt.Errorf("failed to list guardrails: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "listing guardrails"); err != nil {
return err
}
var listResp struct {
Guardrails []guardrailListItemAPIResponse `json:"guardrails"`
}
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
return fmt.Errorf("error decoding guardrails list response: %w", err)
}
guardrails := make([]map[string]interface{}, 0, len(listResp.Guardrails))
ids := make([]string, 0, len(listResp.Guardrails))
for _, g := range listResp.Guardrails {
guardrails = append(guardrails, map[string]interface{}{
"guardrail_id": g.GuardrailID,
"guardrail_name": g.GuardrailName,
"guardrail_info": guardrailInfoToStringMap(g.GuardrailInfo),
"guardrail_definition_location": g.GuardrailDefinitionLocation,
"created_at": g.CreatedAt,
"updated_at": g.UpdatedAt,
})
ids = append(ids, g.GuardrailID)
}
d.SetId("guardrails")
d.Set("guardrails", guardrails)
d.Set("ids", ids)
return nil
}

View file

@ -0,0 +1,83 @@
package litellm
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func TestDataSourceGuardrailRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/guardrails/gid-1/info" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"guardrail_id": "gid-1",
"guardrail_name": "guard1",
"guardrail_info": {"description": "pii guard"},
"guardrail_definition_location": "db",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-02T00:00:00Z"
}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMGuardrail().Schema, map[string]interface{}{
"guardrail_id": "gid-1",
})
if err := dataSourceLiteLLMGuardrailRead(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if d.Id() != "gid-1" {
t.Fatalf("expected ID 'gid-1', got %q", d.Id())
}
if got := d.Get("guardrail_name").(string); got != "guard1" {
t.Errorf("expected guardrail_name 'guard1', got %q", got)
}
if got := d.Get("guardrail_definition_location").(string); got != "db" {
t.Errorf("expected guardrail_definition_location 'db', got %q", got)
}
info := d.Get("guardrail_info").(map[string]interface{})
if info["description"] != "pii guard" {
t.Errorf("expected guardrail_info from API, got: %v", info)
}
}
func TestDataSourceGuardrailsRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/guardrails/list" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"guardrails": [
{"guardrail_id": "gid-1", "guardrail_name": "guard1", "guardrail_definition_location": "db"},
{"guardrail_id": "gid-2", "guardrail_name": "guard2", "guardrail_definition_location": "config"}
]}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMGuardrails().Schema, map[string]interface{}{})
if err := dataSourceLiteLLMGuardrailsRead(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
guardrails := d.Get("guardrails").([]interface{})
if len(guardrails) != 2 {
t.Fatalf("expected 2 guardrails, got %d", len(guardrails))
}
first := guardrails[0].(map[string]interface{})
if first["guardrail_id"] != "gid-1" || first["guardrail_name"] != "guard1" {
t.Errorf("unexpected first guardrail: %v", first)
}
ids := d.Get("ids").([]interface{})
if len(ids) != 2 || ids[0] != "gid-1" || ids[1] != "gid-2" {
t.Errorf("unexpected ids: %v", ids)
}
}

View file

@ -0,0 +1,384 @@
package litellm
import (
"encoding/json"
"fmt"
"log"
"net/url"
"strconv"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
const (
endpointKeyInfo = "/key/info"
endpointKeyList = "/key/list"
)
type keyInfoDetail struct {
Token string `json:"token"`
KeyName string `json:"key_name"`
KeyAlias string `json:"key_alias"`
Spend float64 `json:"spend"`
MaxBudget *float64 `json:"max_budget"`
Models []string `json:"models"`
UserID string `json:"user_id"`
TeamID string `json:"team_id"`
OrgID string `json:"org_id"`
TPMLimit *int `json:"tpm_limit"`
RPMLimit *int `json:"rpm_limit"`
MaxParallelRequests *int `json:"max_parallel_requests"`
BudgetDuration string `json:"budget_duration"`
Metadata map[string]interface{} `json:"metadata"`
Blocked *bool `json:"blocked"`
Expires string `json:"expires"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type keyInfoEnvelope struct {
Key string `json:"key"`
Info keyInfoDetail `json:"info"`
}
type keyListEnvelope struct {
Keys []keyInfoDetail `json:"keys"`
TotalCount int `json:"total_count"`
CurrentPage int `json:"current_page"`
TotalPages int `json:"total_pages"`
}
func dataSourceLiteLLMKey() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMKeyRead,
Schema: map[string]*schema.Schema{
"key": {
Type: schema.TypeString,
Required: true,
Sensitive: true,
Description: "The API key (or its hash) to look up",
},
"token_id": {
Type: schema.TypeString,
Computed: true,
Description: "Hashed token identifier of the key",
},
"key_name": {
Type: schema.TypeString,
Computed: true,
Description: "Redacted display name of the key",
},
"key_alias": {
Type: schema.TypeString,
Computed: true,
},
"models": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"spend": {
Type: schema.TypeFloat,
Computed: true,
},
"max_budget": {
Type: schema.TypeFloat,
Computed: true,
},
"user_id": {
Type: schema.TypeString,
Computed: true,
},
"team_id": {
Type: schema.TypeString,
Computed: true,
},
"organization_id": {
Type: schema.TypeString,
Computed: true,
},
"tpm_limit": {
Type: schema.TypeInt,
Computed: true,
},
"rpm_limit": {
Type: schema.TypeInt,
Computed: true,
},
"max_parallel_requests": {
Type: schema.TypeInt,
Computed: true,
},
"budget_duration": {
Type: schema.TypeString,
Computed: true,
},
"metadata": {
Type: schema.TypeMap,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"tags": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"blocked": {
Type: schema.TypeBool,
Computed: true,
},
"expires": {
Type: schema.TypeString,
Computed: true,
},
"created_at": {
Type: schema.TypeString,
Computed: true,
},
"updated_at": {
Type: schema.TypeString,
Computed: true,
},
},
}
}
func dataSourceLiteLLMKeyRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
// Look up by the SHA-256 token hash so the raw key never appears in the
// request URL, where reverse-proxy access logs could record it.
key := hashedKeyToken(d.Get("key").(string))
resp, err := MakeRequest(client, "GET", fmt.Sprintf("%s?key=%s", endpointKeyInfo, url.QueryEscape(key)), nil)
if err != nil {
return fmt.Errorf("failed to read key info: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "reading key info"); err != nil {
return err
}
var envelope keyInfoEnvelope
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
return fmt.Errorf("failed to decode key info response: %w", err)
}
info := envelope.Info
// Never persist the raw key as the ID; the hashed token is safe to store.
d.SetId(GetStringValue(info.Token, "key"))
d.Set("token_id", info.Token)
d.Set("key_name", info.KeyName)
d.Set("key_alias", info.KeyAlias)
d.Set("models", info.Models)
d.Set("spend", info.Spend)
if info.MaxBudget != nil {
d.Set("max_budget", *info.MaxBudget)
}
d.Set("user_id", info.UserID)
d.Set("team_id", info.TeamID)
d.Set("organization_id", info.OrgID)
if info.TPMLimit != nil {
d.Set("tpm_limit", *info.TPMLimit)
}
if info.RPMLimit != nil {
d.Set("rpm_limit", *info.RPMLimit)
}
if info.MaxParallelRequests != nil {
d.Set("max_parallel_requests", *info.MaxParallelRequests)
}
d.Set("budget_duration", info.BudgetDuration)
metadata := map[string]string{}
for k, v := range info.Metadata {
if s, ok := v.(string); ok {
metadata[k] = s
}
}
d.Set("metadata", metadata)
d.Set("tags", toStringSlice(info.Metadata["tags"]))
if info.Blocked != nil {
d.Set("blocked", *info.Blocked)
}
d.Set("expires", info.Expires)
d.Set("created_at", info.CreatedAt)
d.Set("updated_at", info.UpdatedAt)
log.Printf("[INFO] Successfully read key info for token: %s", info.Token)
return nil
}
func dataSourceLiteLLMKeys() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMKeysRead,
Schema: map[string]*schema.Schema{
"page": {
Type: schema.TypeInt,
Optional: true,
Default: 1,
Description: "Page number for pagination",
},
"size": {
Type: schema.TypeInt,
Optional: true,
Default: 100,
Description: "Number of keys per page",
},
"user_id": {
Type: schema.TypeString,
Optional: true,
Description: "Filter keys by user ID",
},
"team_id": {
Type: schema.TypeString,
Optional: true,
Description: "Filter keys by team ID",
},
"organization_id": {
Type: schema.TypeString,
Optional: true,
Description: "Filter keys by organization ID",
},
"key_alias": {
Type: schema.TypeString,
Optional: true,
Description: "Filter keys by key alias",
},
"include_team_keys": {
Type: schema.TypeBool,
Optional: true,
Description: "Include all keys for teams the caller is an admin of",
},
"total_count": {
Type: schema.TypeInt,
Computed: true,
},
"total_pages": {
Type: schema.TypeInt,
Computed: true,
},
"current_page": {
Type: schema.TypeInt,
Computed: true,
},
"ids": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "Hashed token identifiers of the returned keys",
},
"keys": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"token_id": {Type: schema.TypeString, Computed: true},
"key_name": {Type: schema.TypeString, Computed: true},
"key_alias": {Type: schema.TypeString, Computed: true},
"spend": {Type: schema.TypeFloat, Computed: true},
"max_budget": {Type: schema.TypeFloat, Computed: true},
"models": {Type: schema.TypeList, Computed: true, Elem: &schema.Schema{Type: schema.TypeString}},
"user_id": {Type: schema.TypeString, Computed: true},
"team_id": {Type: schema.TypeString, Computed: true},
"organization_id": {Type: schema.TypeString, Computed: true},
"tpm_limit": {Type: schema.TypeInt, Computed: true},
"rpm_limit": {Type: schema.TypeInt, Computed: true},
"budget_duration": {Type: schema.TypeString, Computed: true},
"blocked": {Type: schema.TypeBool, Computed: true},
"expires": {Type: schema.TypeString, Computed: true},
"created_at": {Type: schema.TypeString, Computed: true},
"updated_at": {Type: schema.TypeString, Computed: true},
},
},
},
},
}
}
func dataSourceLiteLLMKeysRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
query := url.Values{}
query.Set("return_full_object", "true")
query.Set("page", strconv.Itoa(d.Get("page").(int)))
query.Set("size", strconv.Itoa(d.Get("size").(int)))
for param, attr := range map[string]string{
"user_id": "user_id",
"team_id": "team_id",
"organization_id": "organization_id",
"key_alias": "key_alias",
} {
if v, ok := d.GetOk(attr); ok {
query.Set(param, v.(string))
}
}
if d.Get("include_team_keys").(bool) {
query.Set("include_team_keys", "true")
}
resp, err := MakeRequest(client, "GET", fmt.Sprintf("%s?%s", endpointKeyList, query.Encode()), nil)
if err != nil {
return fmt.Errorf("failed to list keys: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "listing keys"); err != nil {
return err
}
var envelope keyListEnvelope
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
return fmt.Errorf("failed to decode key list response: %w", err)
}
ids := make([]string, 0, len(envelope.Keys))
keys := make([]map[string]interface{}, 0, len(envelope.Keys))
for _, k := range envelope.Keys {
ids = append(ids, k.Token)
keys = append(keys, map[string]interface{}{
"token_id": k.Token,
"key_name": k.KeyName,
"key_alias": k.KeyAlias,
"spend": k.Spend,
"max_budget": keyDerefFloat(k.MaxBudget),
"models": k.Models,
"user_id": k.UserID,
"team_id": k.TeamID,
"organization_id": k.OrgID,
"tpm_limit": keyDerefInt(k.TPMLimit),
"rpm_limit": keyDerefInt(k.RPMLimit),
"budget_duration": k.BudgetDuration,
"blocked": k.Blocked != nil && *k.Blocked,
"expires": k.Expires,
"created_at": k.CreatedAt,
"updated_at": k.UpdatedAt,
})
}
d.SetId(query.Encode())
d.Set("total_count", envelope.TotalCount)
d.Set("total_pages", envelope.TotalPages)
d.Set("current_page", envelope.CurrentPage)
d.Set("ids", ids)
d.Set("keys", keys)
log.Printf("[INFO] Successfully listed %d keys", len(keys))
return nil
}
func keyDerefFloat(v *float64) float64 {
if v == nil {
return 0
}
return *v
}
func keyDerefInt(v *int) int {
if v == nil {
return 0
}
return *v
}

View file

@ -0,0 +1,198 @@
package litellm
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func TestDataSourceKeyRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/key/info" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if got := r.URL.Query().Get("key"); got != "43d0a3c1b9dc2739952a8ffc4ee4f41ea34da6587cbc717c3a51185b9fac611c" {
t.Errorf("expected key query param to be the token hash, got %q", got)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"key": "sk-raw-secret",
"info": {
"token": "hashed-token-123",
"key_name": "sk-...cret",
"key_alias": "ci-key",
"spend": 12.5,
"max_budget": 100,
"models": ["gpt-4o", "claude-3"],
"user_id": "user-1",
"team_id": "team-1",
"org_id": "org-1",
"tpm_limit": 1000,
"rpm_limit": 60,
"max_parallel_requests": 5,
"budget_duration": "30d",
"metadata": {"env": "prod", "tags": ["alpha", "beta"]},
"blocked": true,
"expires": "2027-01-01T00:00:00Z",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-02-01T00:00:00Z"
}
}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMKey().Schema, map[string]interface{}{
"key": "sk-raw-secret",
})
if err := dataSourceLiteLLMKeyRead(d, client); err != nil {
t.Fatalf("read failed: %v", err)
}
if d.Id() != "hashed-token-123" {
t.Fatalf("expected ID 'hashed-token-123', got %q", d.Id())
}
checks := map[string]interface{}{
"token_id": "hashed-token-123",
"key_name": "sk-...cret",
"key_alias": "ci-key",
"spend": 12.5,
"max_budget": 100.0,
"user_id": "user-1",
"team_id": "team-1",
"organization_id": "org-1",
"tpm_limit": 1000,
"rpm_limit": 60,
"max_parallel_requests": 5,
"budget_duration": "30d",
"blocked": true,
"expires": "2027-01-01T00:00:00Z",
}
for attr, want := range checks {
if got := d.Get(attr); got != want {
t.Errorf("attr %s: expected %v, got %v", attr, want, got)
}
}
models := d.Get("models").([]interface{})
if len(models) != 2 || models[0] != "gpt-4o" {
t.Errorf("unexpected models: %v", models)
}
tags := d.Get("tags").([]interface{})
if len(tags) != 2 || tags[0] != "alpha" {
t.Errorf("unexpected tags: %v", tags)
}
metadata := d.Get("metadata").(map[string]interface{})
if metadata["env"] != "prod" {
t.Errorf("unexpected metadata: %v", metadata)
}
if _, hasTags := metadata["tags"]; hasTags {
t.Errorf("non-string metadata value should not be in the metadata map: %v", metadata)
}
}
func TestDataSourceKeyReadNotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"detail": {"error": "key not found"}}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMKey().Schema, map[string]interface{}{
"key": "sk-missing",
})
if err := dataSourceLiteLLMKeyRead(d, client); err == nil {
t.Fatal("expected error for missing key, got nil")
}
}
func TestDataSourceKeysRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/key/list" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
query := r.URL.Query()
if query.Get("return_full_object") != "true" {
t.Errorf("expected return_full_object=true, got %q", query.Get("return_full_object"))
}
if query.Get("team_id") != "team-1" {
t.Errorf("expected team_id=team-1, got %q", query.Get("team_id"))
}
if query.Get("page") != "2" || query.Get("size") != "10" {
t.Errorf("expected page=2 size=10, got page=%q size=%q", query.Get("page"), query.Get("size"))
}
if query.Get("include_team_keys") != "true" {
t.Errorf("expected include_team_keys=true, got %q", query.Get("include_team_keys"))
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"keys": [
{"token": "tok-1", "key_alias": "a", "team_id": "team-1", "spend": 1.5, "max_budget": 10, "models": ["m1"], "blocked": false},
{"token": "tok-2", "key_alias": "b", "team_id": "team-1", "spend": 0, "blocked": true}
],
"total_count": 2,
"current_page": 2,
"total_pages": 1
}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMKeys().Schema, map[string]interface{}{
"team_id": "team-1",
"page": 2,
"size": 10,
"include_team_keys": true,
})
if err := dataSourceLiteLLMKeysRead(d, client); err != nil {
t.Fatalf("read failed: %v", err)
}
if d.Id() == "" {
t.Fatal("expected data source ID to be set")
}
if got := d.Get("total_count").(int); got != 2 {
t.Errorf("expected total_count 2, got %d", got)
}
ids := d.Get("ids").([]interface{})
if len(ids) != 2 || ids[0] != "tok-1" || ids[1] != "tok-2" {
t.Errorf("unexpected ids: %v", ids)
}
keys := d.Get("keys").([]interface{})
if len(keys) != 2 {
t.Fatalf("expected 2 keys, got %d", len(keys))
}
first := keys[0].(map[string]interface{})
if first["token_id"] != "tok-1" || first["key_alias"] != "a" || first["max_budget"] != 10.0 {
t.Errorf("unexpected first key: %v", first)
}
second := keys[1].(map[string]interface{})
if second["blocked"] != true || second["max_budget"] != 0.0 {
t.Errorf("unexpected second key: %v", second)
}
}
// Regression for the security review finding: the singular key data source
// must query /key/info by the SHA-256 token hash, never the raw sk- value.
func TestDataSourceKeyQueriesByTokenHash(t *testing.T) {
var gotQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.Query().Get("key")
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"key": "hash", "info": {"token": "hash", "key_alias": "a"}}`))
}))
defer srv.Close()
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMKey().Schema, map[string]interface{}{"key": "sk-test-123"})
if err := dataSourceLiteLLMKeyRead(d, NewClient(srv.URL, "master-key", true)); err != nil {
t.Fatalf("read failed: %v", err)
}
if gotQuery != keyBlockTestHash {
t.Fatalf("query key = %q, want the token hash %q", gotQuery, keyBlockTestHash)
}
}

View file

@ -0,0 +1,271 @@
package litellm
import (
"encoding/json"
"fmt"
"log"
"net/url"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
// mcpServerDetail intentionally omits env, credentials, and static_headers:
// those may hold secrets and must never reach data source state.
type mcpServerDetail struct {
ServerID string `json:"server_id"`
ServerName string `json:"server_name"`
Alias string `json:"alias"`
Description string `json:"description"`
URL string `json:"url"`
Transport string `json:"transport"`
SpecVersion string `json:"spec_version"`
AuthType string `json:"auth_type"`
MCPAccessGroups []string `json:"mcp_access_groups"`
AllowedTools []string `json:"allowed_tools"`
ExtraHeaders []string `json:"extra_headers"`
Command string `json:"command"`
Args []string `json:"args"`
AllowAllKeys bool `json:"allow_all_keys"`
Status string `json:"status"`
LastHealthCheck string `json:"last_health_check"`
HealthCheckError string `json:"health_check_error"`
CreatedAt string `json:"created_at"`
CreatedBy string `json:"created_by"`
UpdatedAt string `json:"updated_at"`
UpdatedBy string `json:"updated_by"`
}
func dataSourceLiteLLMMCPServer() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMMCPServerRead,
Schema: map[string]*schema.Schema{
"server_id": {
Type: schema.TypeString,
Required: true,
Description: "Unique identifier of the MCP server to retrieve",
},
"server_name": {
Type: schema.TypeString,
Computed: true,
},
"alias": {
Type: schema.TypeString,
Computed: true,
},
"description": {
Type: schema.TypeString,
Computed: true,
},
"url": {
Type: schema.TypeString,
Computed: true,
},
"transport": {
Type: schema.TypeString,
Computed: true,
},
"spec_version": {
Type: schema.TypeString,
Computed: true,
},
"auth_type": {
Type: schema.TypeString,
Computed: true,
},
"mcp_access_groups": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"allowed_tools": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"extra_headers": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "Names of request headers forwarded to the MCP server",
},
"command": {
Type: schema.TypeString,
Computed: true,
},
"args": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"allow_all_keys": {
Type: schema.TypeBool,
Computed: true,
},
"status": {
Type: schema.TypeString,
Computed: true,
},
"last_health_check": {
Type: schema.TypeString,
Computed: true,
},
"health_check_error": {
Type: schema.TypeString,
Computed: true,
},
"created_at": {
Type: schema.TypeString,
Computed: true,
},
"created_by": {
Type: schema.TypeString,
Computed: true,
},
"updated_at": {
Type: schema.TypeString,
Computed: true,
},
"updated_by": {
Type: schema.TypeString,
Computed: true,
},
},
}
}
func dataSourceLiteLLMMCPServerRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
serverID := d.Get("server_id").(string)
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 server mcpServerDetail
if err := handleMCPAPIResponse(resp, &server, client); err != nil {
if err.Error() == "mcp_server_not_found" {
return fmt.Errorf("MCP server %q not found", serverID)
}
return fmt.Errorf("failed to read MCP server: %w", err)
}
d.SetId(GetStringValue(server.ServerID, serverID))
d.Set("server_name", server.ServerName)
d.Set("alias", server.Alias)
d.Set("description", server.Description)
d.Set("url", server.URL)
d.Set("transport", server.Transport)
d.Set("spec_version", server.SpecVersion)
d.Set("auth_type", server.AuthType)
d.Set("mcp_access_groups", server.MCPAccessGroups)
d.Set("allowed_tools", server.AllowedTools)
d.Set("extra_headers", server.ExtraHeaders)
d.Set("command", server.Command)
d.Set("args", server.Args)
d.Set("allow_all_keys", server.AllowAllKeys)
d.Set("status", server.Status)
d.Set("last_health_check", server.LastHealthCheck)
d.Set("health_check_error", server.HealthCheckError)
d.Set("created_at", server.CreatedAt)
d.Set("created_by", server.CreatedBy)
d.Set("updated_at", server.UpdatedAt)
d.Set("updated_by", server.UpdatedBy)
log.Printf("[INFO] Successfully read MCP server with ID: %s", serverID)
return nil
}
func dataSourceLiteLLMMCPServers() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMMCPServersRead,
Schema: map[string]*schema.Schema{
"team_id": {
Type: schema.TypeString,
Optional: true,
Description: "Filter to servers this team can access plus globally available servers",
},
"ids": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "IDs of the returned MCP servers",
},
"mcp_servers": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"server_id": {Type: schema.TypeString, Computed: true},
"server_name": {Type: schema.TypeString, Computed: true},
"alias": {Type: schema.TypeString, Computed: true},
"description": {Type: schema.TypeString, Computed: true},
"url": {Type: schema.TypeString, Computed: true},
"transport": {Type: schema.TypeString, Computed: true},
"spec_version": {Type: schema.TypeString, Computed: true},
"auth_type": {Type: schema.TypeString, Computed: true},
"allow_all_keys": {Type: schema.TypeBool, Computed: true},
"status": {Type: schema.TypeString, Computed: true},
"created_at": {Type: schema.TypeString, Computed: true},
"updated_at": {Type: schema.TypeString, Computed: true},
},
},
},
},
}
}
func dataSourceLiteLLMMCPServersRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
endpoint := endpointMCPServerRead
if v, ok := d.GetOk("team_id"); ok {
endpoint = fmt.Sprintf("%s?team_id=%s", endpointMCPServerRead, url.QueryEscape(v.(string)))
}
resp, err := MakeRequest(client, "GET", endpoint, nil)
if err != nil {
return fmt.Errorf("failed to list MCP servers: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "listing MCP servers"); err != nil {
return err
}
var serverList []mcpServerDetail
if err := json.NewDecoder(resp.Body).Decode(&serverList); err != nil {
return fmt.Errorf("failed to decode MCP server list response: %w", err)
}
ids := make([]string, 0, len(serverList))
servers := make([]map[string]interface{}, 0, len(serverList))
for _, server := range serverList {
ids = append(ids, server.ServerID)
servers = append(servers, map[string]interface{}{
"server_id": server.ServerID,
"server_name": server.ServerName,
"alias": server.Alias,
"description": server.Description,
"url": server.URL,
"transport": server.Transport,
"spec_version": server.SpecVersion,
"auth_type": server.AuthType,
"allow_all_keys": server.AllowAllKeys,
"status": server.Status,
"created_at": server.CreatedAt,
"updated_at": server.UpdatedAt,
})
}
d.SetId(GetStringValue(d.Get("team_id").(string), "all"))
d.Set("ids", ids)
d.Set("mcp_servers", servers)
log.Printf("[INFO] Successfully listed %d MCP servers", len(servers))
return nil
}

View file

@ -0,0 +1,150 @@
package litellm
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func TestDataSourceMCPServerRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/v1/mcp/server/srv-123" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"server_id": "srv-123",
"server_name": "github-mcp",
"alias": "gh",
"description": "GitHub MCP server",
"url": "https://mcp.example.com",
"transport": "http",
"spec_version": "2024-11-05",
"auth_type": "bearer",
"mcp_access_groups": ["dev"],
"allowed_tools": ["list_repos"],
"extra_headers": ["x-request-id"],
"command": "",
"args": [],
"env": {"SECRET_TOKEN": "should-never-surface"},
"static_headers": {"Authorization": "Bearer should-never-surface"},
"allow_all_keys": true,
"status": "healthy",
"last_health_check": "2026-02-01T00:00:00Z",
"health_check_error": "",
"created_at": "2026-01-01T00:00:00Z",
"created_by": "admin",
"updated_at": "2026-02-01T00:00:00Z",
"updated_by": "admin"
}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMMCPServer().Schema, map[string]interface{}{
"server_id": "srv-123",
})
if err := dataSourceLiteLLMMCPServerRead(d, client); err != nil {
t.Fatalf("read failed: %v", err)
}
if d.Id() != "srv-123" {
t.Fatalf("expected ID 'srv-123', got %q", d.Id())
}
checks := map[string]interface{}{
"server_name": "github-mcp",
"alias": "gh",
"description": "GitHub MCP server",
"url": "https://mcp.example.com",
"transport": "http",
"spec_version": "2024-11-05",
"auth_type": "bearer",
"allow_all_keys": true,
"status": "healthy",
"last_health_check": "2026-02-01T00:00:00Z",
"created_by": "admin",
}
for attr, want := range checks {
if got := d.Get(attr); got != want {
t.Errorf("attr %s: expected %v, got %v", attr, want, got)
}
}
groups := d.Get("mcp_access_groups").([]interface{})
if len(groups) != 1 || groups[0] != "dev" {
t.Errorf("unexpected access groups: %v", groups)
}
tools := d.Get("allowed_tools").([]interface{})
if len(tools) != 1 || tools[0] != "list_repos" {
t.Errorf("unexpected allowed tools: %v", tools)
}
headers := d.Get("extra_headers").([]interface{})
if len(headers) != 1 || headers[0] != "x-request-id" {
t.Errorf("unexpected extra headers: %v", headers)
}
}
func TestDataSourceMCPServerReadNotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"detail": {"error": "MCP server not found"}}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMMCPServer().Schema, map[string]interface{}{
"server_id": "srv-missing",
})
if err := dataSourceLiteLLMMCPServerRead(d, client); err == nil {
t.Fatal("expected error for missing MCP server, got nil")
}
}
func TestDataSourceMCPServersRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/v1/mcp/server" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if got := r.URL.Query().Get("team_id"); got != "team-1" {
t.Errorf("expected team_id 'team-1', got %q", got)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`[
{"server_id": "srv-1", "server_name": "one", "url": "https://one.example.com", "transport": "http", "status": "healthy", "allow_all_keys": false},
{"server_id": "srv-2", "server_name": "two", "url": "https://two.example.com", "transport": "sse", "status": "unknown", "allow_all_keys": true}
]`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMMCPServers().Schema, map[string]interface{}{
"team_id": "team-1",
})
if err := dataSourceLiteLLMMCPServersRead(d, client); err != nil {
t.Fatalf("read failed: %v", err)
}
if d.Id() != "team-1" {
t.Fatalf("expected ID 'team-1', got %q", d.Id())
}
ids := d.Get("ids").([]interface{})
if len(ids) != 2 || ids[0] != "srv-1" || ids[1] != "srv-2" {
t.Errorf("unexpected ids: %v", ids)
}
servers := d.Get("mcp_servers").([]interface{})
if len(servers) != 2 {
t.Fatalf("expected 2 servers, got %d", len(servers))
}
first := servers[0].(map[string]interface{})
if first["server_name"] != "one" || first["transport"] != "http" || first["allow_all_keys"] != false {
t.Errorf("unexpected first server: %v", first)
}
second := servers[1].(map[string]interface{})
if second["status"] != "unknown" || second["allow_all_keys"] != true {
t.Errorf("unexpected second server: %v", second)
}
}

View file

@ -0,0 +1,260 @@
package litellm
import (
"encoding/json"
"fmt"
"log"
"net/url"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
const endpointModelInfoV1 = "/v1/model/info"
// modelInfoParams intentionally maps only the non-sensitive litellm_params fields;
// credentials (api_key, aws_secret_access_key, ...) must never reach state.
type modelInfoParams struct {
Model string `json:"model"`
CustomLLMProvider string `json:"custom_llm_provider"`
APIBase string `json:"api_base"`
APIVersion string `json:"api_version"`
TPM int `json:"tpm"`
RPM int `json:"rpm"`
}
type modelInfoMeta 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"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type modelInfoEntry struct {
ModelName string `json:"model_name"`
LiteLLMParams modelInfoParams `json:"litellm_params"`
ModelInfo modelInfoMeta `json:"model_info"`
}
type modelInfoEnvelope struct {
Data json.RawMessage `json:"data"`
}
// /v1/model/info returns data as a single object on the DB path and as a
// one-element list on the config path, so both shapes must be handled.
func modelDecodeInfoEntries(raw json.RawMessage) ([]modelInfoEntry, error) {
var single modelInfoEntry
if err := json.Unmarshal(raw, &single); err == nil {
return []modelInfoEntry{single}, nil
}
var list []modelInfoEntry
if err := json.Unmarshal(raw, &list); err != nil {
return nil, fmt.Errorf("failed to decode model info data: %w", err)
}
return list, nil
}
func dataSourceLiteLLMModel() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMModelRead,
Schema: map[string]*schema.Schema{
"model_id": {
Type: schema.TypeString,
Required: true,
Description: "LiteLLM model ID (the x-litellm-model-id response header value)",
},
"model_name": {
Type: schema.TypeString,
Computed: true,
},
"model": {
Type: schema.TypeString,
Computed: true,
Description: "The underlying litellm_params model, e.g. openai/gpt-4o",
},
"custom_llm_provider": {
Type: schema.TypeString,
Computed: true,
},
"model_api_base": {
Type: schema.TypeString,
Computed: true,
},
"api_version": {
Type: schema.TypeString,
Computed: true,
},
"tpm": {
Type: schema.TypeInt,
Computed: true,
},
"rpm": {
Type: schema.TypeInt,
Computed: true,
},
"base_model": {
Type: schema.TypeString,
Computed: true,
},
"tier": {
Type: schema.TypeString,
Computed: true,
},
"mode": {
Type: schema.TypeString,
Computed: true,
},
"team_id": {
Type: schema.TypeString,
Computed: true,
},
"db_model": {
Type: schema.TypeBool,
Computed: true,
},
},
}
}
func dataSourceLiteLLMModelRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
modelID := d.Get("model_id").(string)
endpoint := fmt.Sprintf("%s?litellm_model_id=%s", endpointModelInfoV1, url.QueryEscape(modelID))
resp, err := MakeRequest(client, "GET", endpoint, nil)
if err != nil {
return fmt.Errorf("failed to read model info: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "reading model info"); err != nil {
return err
}
var envelope modelInfoEnvelope
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
return fmt.Errorf("failed to decode model info response: %w", err)
}
entries, err := modelDecodeInfoEntries(envelope.Data)
if err != nil {
return err
}
if len(entries) == 0 {
return fmt.Errorf("model with id %q not found", modelID)
}
entry := entries[0]
d.SetId(GetStringValue(entry.ModelInfo.ID, modelID))
d.Set("model_name", entry.ModelName)
d.Set("model", entry.LiteLLMParams.Model)
d.Set("custom_llm_provider", entry.LiteLLMParams.CustomLLMProvider)
d.Set("model_api_base", entry.LiteLLMParams.APIBase)
d.Set("api_version", entry.LiteLLMParams.APIVersion)
d.Set("tpm", entry.LiteLLMParams.TPM)
d.Set("rpm", entry.LiteLLMParams.RPM)
d.Set("base_model", entry.ModelInfo.BaseModel)
d.Set("tier", entry.ModelInfo.Tier)
d.Set("mode", entry.ModelInfo.Mode)
d.Set("team_id", entry.ModelInfo.TeamID)
d.Set("db_model", entry.ModelInfo.DBModel)
log.Printf("[INFO] Successfully read model with ID: %s", modelID)
return nil
}
func dataSourceLiteLLMModels() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMModelsRead,
Schema: map[string]*schema.Schema{
"team_id": {
Type: schema.TypeString,
Optional: true,
Description: "Filter models to those accessible by this team",
},
"ids": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "LiteLLM model IDs of the returned models",
},
"models": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"id": {Type: schema.TypeString, Computed: true},
"model_name": {Type: schema.TypeString, Computed: true},
"model": {Type: schema.TypeString, Computed: true},
"custom_llm_provider": {Type: schema.TypeString, Computed: true},
"model_api_base": {Type: schema.TypeString, Computed: true},
"base_model": {Type: schema.TypeString, Computed: true},
"tier": {Type: schema.TypeString, Computed: true},
"mode": {Type: schema.TypeString, Computed: true},
"team_id": {Type: schema.TypeString, Computed: true},
"db_model": {Type: schema.TypeBool, Computed: true},
},
},
},
},
}
}
func dataSourceLiteLLMModelsRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
endpoint := endpointModelInfoV1
if v, ok := d.GetOk("team_id"); ok {
endpoint = fmt.Sprintf("%s?teamId=%s", endpointModelInfoV1, url.QueryEscape(v.(string)))
}
resp, err := MakeRequest(client, "GET", endpoint, nil)
if err != nil {
return fmt.Errorf("failed to list models: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "listing models"); err != nil {
return err
}
var envelope modelInfoEnvelope
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
return fmt.Errorf("failed to decode model list response: %w", err)
}
entries, err := modelDecodeInfoEntries(envelope.Data)
if err != nil {
return err
}
ids := make([]string, 0, len(entries))
models := make([]map[string]interface{}, 0, len(entries))
for _, entry := range entries {
ids = append(ids, entry.ModelInfo.ID)
models = append(models, map[string]interface{}{
"id": entry.ModelInfo.ID,
"model_name": entry.ModelName,
"model": entry.LiteLLMParams.Model,
"custom_llm_provider": entry.LiteLLMParams.CustomLLMProvider,
"model_api_base": entry.LiteLLMParams.APIBase,
"base_model": entry.ModelInfo.BaseModel,
"tier": entry.ModelInfo.Tier,
"mode": entry.ModelInfo.Mode,
"team_id": entry.ModelInfo.TeamID,
"db_model": entry.ModelInfo.DBModel,
})
}
d.SetId(GetStringValue(d.Get("team_id").(string), "all"))
d.Set("ids", ids)
d.Set("models", models)
log.Printf("[INFO] Successfully listed %d models", len(models))
return nil
}

View file

@ -0,0 +1,149 @@
package litellm
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func TestDataSourceModelReadSingleObject(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/v1/model/info" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if got := r.URL.Query().Get("litellm_model_id"); got != "model-abc" {
t.Errorf("expected litellm_model_id 'model-abc', got %q", got)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"data": {
"model_name": "gpt-4o-alias",
"litellm_params": {
"model": "openai/gpt-4o",
"custom_llm_provider": "openai",
"api_base": "https://api.openai.com/v1",
"api_version": "2024-06-01",
"api_key": "sk-should-never-surface",
"tpm": 100000,
"rpm": 500
},
"model_info": {
"id": "model-abc",
"db_model": true,
"base_model": "gpt-4o",
"tier": "paid",
"mode": "chat",
"team_id": "team-1"
}
}
}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMModel().Schema, map[string]interface{}{
"model_id": "model-abc",
})
if err := dataSourceLiteLLMModelRead(d, client); err != nil {
t.Fatalf("read failed: %v", err)
}
if d.Id() != "model-abc" {
t.Fatalf("expected ID 'model-abc', got %q", d.Id())
}
checks := map[string]interface{}{
"model_name": "gpt-4o-alias",
"model": "openai/gpt-4o",
"custom_llm_provider": "openai",
"model_api_base": "https://api.openai.com/v1",
"api_version": "2024-06-01",
"tpm": 100000,
"rpm": 500,
"base_model": "gpt-4o",
"tier": "paid",
"mode": "chat",
"team_id": "team-1",
"db_model": true,
}
for attr, want := range checks {
if got := d.Get(attr); got != want {
t.Errorf("attr %s: expected %v, got %v", attr, want, got)
}
}
}
func TestDataSourceModelReadListShape(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"data": [{
"model_name": "claude-alias",
"litellm_params": {"model": "anthropic/claude-opus-4", "custom_llm_provider": "anthropic"},
"model_info": {"id": "model-xyz", "mode": "chat"}
}]
}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMModel().Schema, map[string]interface{}{
"model_id": "model-xyz",
})
if err := dataSourceLiteLLMModelRead(d, client); err != nil {
t.Fatalf("read failed: %v", err)
}
if d.Id() != "model-xyz" {
t.Fatalf("expected ID 'model-xyz', got %q", d.Id())
}
if got := d.Get("model").(string); got != "anthropic/claude-opus-4" {
t.Errorf("expected model 'anthropic/claude-opus-4', got %q", got)
}
}
func TestDataSourceModelsRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/v1/model/info" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if got := r.URL.Query().Get("teamId"); got != "team-1" {
t.Errorf("expected teamId 'team-1', got %q", got)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"data": [
{"model_name": "a", "litellm_params": {"model": "openai/a", "custom_llm_provider": "openai"}, "model_info": {"id": "id-1", "db_model": true}},
{"model_name": "b", "litellm_params": {"model": "anthropic/b", "custom_llm_provider": "anthropic"}, "model_info": {"id": "id-2"}}
]
}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMModels().Schema, map[string]interface{}{
"team_id": "team-1",
})
if err := dataSourceLiteLLMModelsRead(d, client); err != nil {
t.Fatalf("read failed: %v", err)
}
if d.Id() != "team-1" {
t.Fatalf("expected ID 'team-1', got %q", d.Id())
}
ids := d.Get("ids").([]interface{})
if len(ids) != 2 || ids[0] != "id-1" || ids[1] != "id-2" {
t.Errorf("unexpected ids: %v", ids)
}
models := d.Get("models").([]interface{})
if len(models) != 2 {
t.Fatalf("expected 2 models, got %d", len(models))
}
first := models[0].(map[string]interface{})
if first["model_name"] != "a" || first["custom_llm_provider"] != "openai" || first["db_model"] != true {
t.Errorf("unexpected first model: %v", first)
}
}

View file

@ -0,0 +1,270 @@
package litellm
import (
"encoding/json"
"fmt"
"log"
"net/url"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
const endpointOrganizationList = "/organization/list"
type organizationBudget struct {
MaxBudget *float64 `json:"max_budget"`
SoftBudget *float64 `json:"soft_budget"`
TPMLimit *int `json:"tpm_limit"`
RPMLimit *int `json:"rpm_limit"`
MaxParallelRequests *int `json:"max_parallel_requests"`
BudgetDuration string `json:"budget_duration"`
}
type organizationDetail struct {
OrganizationID string `json:"organization_id"`
OrganizationAlias string `json:"organization_alias"`
BudgetID string `json:"budget_id"`
Models []string `json:"models"`
Spend float64 `json:"spend"`
Metadata map[string]interface{} `json:"metadata"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Budget *organizationBudget `json:"litellm_budget_table"`
}
func dataSourceLiteLLMOrganization() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMOrganizationRead,
Schema: map[string]*schema.Schema{
"organization_id": {
Type: schema.TypeString,
Required: true,
Description: "Unique identifier of the organization to retrieve",
},
"organization_alias": {
Type: schema.TypeString,
Computed: true,
},
"budget_id": {
Type: schema.TypeString,
Computed: true,
},
"models": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"spend": {
Type: schema.TypeFloat,
Computed: true,
},
"metadata": {
Type: schema.TypeMap,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"max_budget": {
Type: schema.TypeFloat,
Computed: true,
},
"soft_budget": {
Type: schema.TypeFloat,
Computed: true,
},
"tpm_limit": {
Type: schema.TypeInt,
Computed: true,
},
"rpm_limit": {
Type: schema.TypeInt,
Computed: true,
},
"max_parallel_requests": {
Type: schema.TypeInt,
Computed: true,
},
"budget_duration": {
Type: schema.TypeString,
Computed: true,
},
"created_at": {
Type: schema.TypeString,
Computed: true,
},
"updated_at": {
Type: schema.TypeString,
Computed: true,
},
},
}
}
func dataSourceLiteLLMOrganizationRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
orgID := d.Get("organization_id").(string)
endpoint := fmt.Sprintf("%s?organization_id=%s", endpointOrganizationInfo, url.QueryEscape(orgID))
resp, err := MakeRequest(client, "GET", endpoint, nil)
if err != nil {
return fmt.Errorf("failed to read organization: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "reading organization info"); err != nil {
return err
}
var org organizationDetail
if err := json.NewDecoder(resp.Body).Decode(&org); err != nil {
return fmt.Errorf("failed to decode organization info response: %w", err)
}
d.SetId(GetStringValue(org.OrganizationID, orgID))
organizationSetDetail(d, org)
log.Printf("[INFO] Successfully read organization with ID: %s", orgID)
return nil
}
func organizationSetDetail(d *schema.ResourceData, org organizationDetail) {
d.Set("organization_alias", org.OrganizationAlias)
d.Set("budget_id", org.BudgetID)
d.Set("models", org.Models)
d.Set("spend", org.Spend)
metadata := map[string]string{}
for k, v := range org.Metadata {
if s, ok := v.(string); ok {
metadata[k] = s
}
}
d.Set("metadata", metadata)
if org.Budget != nil {
if org.Budget.MaxBudget != nil {
d.Set("max_budget", *org.Budget.MaxBudget)
}
if org.Budget.SoftBudget != nil {
d.Set("soft_budget", *org.Budget.SoftBudget)
}
if org.Budget.TPMLimit != nil {
d.Set("tpm_limit", *org.Budget.TPMLimit)
}
if org.Budget.RPMLimit != nil {
d.Set("rpm_limit", *org.Budget.RPMLimit)
}
if org.Budget.MaxParallelRequests != nil {
d.Set("max_parallel_requests", *org.Budget.MaxParallelRequests)
}
d.Set("budget_duration", org.Budget.BudgetDuration)
}
d.Set("created_at", org.CreatedAt)
d.Set("updated_at", org.UpdatedAt)
}
func dataSourceLiteLLMOrganizations() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMOrganizationsRead,
Schema: map[string]*schema.Schema{
"org_alias": {
Type: schema.TypeString,
Optional: true,
Description: "Filter organizations by alias",
},
"ids": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "IDs of the returned organizations",
},
"organizations": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"organization_id": {Type: schema.TypeString, Computed: true},
"organization_alias": {Type: schema.TypeString, Computed: true},
"budget_id": {Type: schema.TypeString, Computed: true},
"models": {Type: schema.TypeList, Computed: true, Elem: &schema.Schema{Type: schema.TypeString}},
"spend": {Type: schema.TypeFloat, Computed: true},
"max_budget": {Type: schema.TypeFloat, Computed: true},
"tpm_limit": {Type: schema.TypeInt, Computed: true},
"rpm_limit": {Type: schema.TypeInt, Computed: true},
"budget_duration": {Type: schema.TypeString, Computed: true},
"created_at": {Type: schema.TypeString, Computed: true},
"updated_at": {Type: schema.TypeString, Computed: true},
},
},
},
},
}
}
func dataSourceLiteLLMOrganizationsRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
endpoint := endpointOrganizationList
if v, ok := d.GetOk("org_alias"); ok {
endpoint = fmt.Sprintf("%s?org_alias=%s", endpointOrganizationList, url.QueryEscape(v.(string)))
}
resp, err := MakeRequest(client, "GET", endpoint, nil)
if err != nil {
return fmt.Errorf("failed to list organizations: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "listing organizations"); err != nil {
return err
}
var orgList []organizationDetail
if err := json.NewDecoder(resp.Body).Decode(&orgList); err != nil {
return fmt.Errorf("failed to decode organization list response: %w", err)
}
ids := make([]string, 0, len(orgList))
orgs := make([]map[string]interface{}, 0, len(orgList))
for _, org := range orgList {
ids = append(ids, org.OrganizationID)
item := map[string]interface{}{
"organization_id": org.OrganizationID,
"organization_alias": org.OrganizationAlias,
"budget_id": org.BudgetID,
"models": org.Models,
"spend": org.Spend,
"created_at": org.CreatedAt,
"updated_at": org.UpdatedAt,
}
if org.Budget != nil {
item["max_budget"] = organizationDerefFloat(org.Budget.MaxBudget)
item["tpm_limit"] = organizationDerefInt(org.Budget.TPMLimit)
item["rpm_limit"] = organizationDerefInt(org.Budget.RPMLimit)
item["budget_duration"] = org.Budget.BudgetDuration
}
orgs = append(orgs, item)
}
d.SetId(GetStringValue(d.Get("org_alias").(string), "all"))
d.Set("ids", ids)
d.Set("organizations", orgs)
log.Printf("[INFO] Successfully listed %d organizations", len(orgs))
return nil
}
func organizationDerefFloat(v *float64) float64 {
if v == nil {
return 0
}
return *v
}
func organizationDerefInt(v *int) int {
if v == nil {
return 0
}
return *v
}

View file

@ -0,0 +1,120 @@
package litellm
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func TestDataSourceOrganizationRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/organization/info" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if got := r.URL.Query().Get("organization_id"); got != "org-123" {
t.Errorf("expected organization_id 'org-123', got %q", got)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"organization_id": "org-123",
"organization_alias": "acme-org",
"budget_id": "budget-1",
"models": ["gpt-4o"],
"spend": 77.5,
"metadata": {"env": "prod"},
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-02-01T00:00:00Z",
"litellm_budget_table": {
"max_budget": 1000,
"soft_budget": 800,
"tpm_limit": 50000,
"rpm_limit": 500,
"max_parallel_requests": 20,
"budget_duration": "30d"
}
}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMOrganization().Schema, map[string]interface{}{
"organization_id": "org-123",
})
if err := dataSourceLiteLLMOrganizationRead(d, client); err != nil {
t.Fatalf("read failed: %v", err)
}
if d.Id() != "org-123" {
t.Fatalf("expected ID 'org-123', got %q", d.Id())
}
checks := map[string]interface{}{
"organization_alias": "acme-org",
"budget_id": "budget-1",
"spend": 77.5,
"max_budget": 1000.0,
"soft_budget": 800.0,
"tpm_limit": 50000,
"rpm_limit": 500,
"max_parallel_requests": 20,
"budget_duration": "30d",
"created_at": "2026-01-01T00:00:00Z",
}
for attr, want := range checks {
if got := d.Get(attr); got != want {
t.Errorf("attr %s: expected %v, got %v", attr, want, got)
}
}
metadata := d.Get("metadata").(map[string]interface{})
if metadata["env"] != "prod" {
t.Errorf("unexpected metadata: %v", metadata)
}
}
func TestDataSourceOrganizationsRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/organization/list" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if got := r.URL.Query().Get("org_alias"); got != "acme" {
t.Errorf("expected org_alias 'acme', got %q", got)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`[
{"organization_id": "org-1", "organization_alias": "acme", "spend": 1.5, "litellm_budget_table": {"max_budget": 100, "tpm_limit": 10, "rpm_limit": 5, "budget_duration": "7d"}},
{"organization_id": "org-2", "organization_alias": "acme-eu", "spend": 0}
]`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMOrganizations().Schema, map[string]interface{}{
"org_alias": "acme",
})
if err := dataSourceLiteLLMOrganizationsRead(d, client); err != nil {
t.Fatalf("read failed: %v", err)
}
if d.Id() != "acme" {
t.Fatalf("expected ID 'acme', got %q", d.Id())
}
ids := d.Get("ids").([]interface{})
if len(ids) != 2 || ids[0] != "org-1" || ids[1] != "org-2" {
t.Errorf("unexpected ids: %v", ids)
}
orgs := d.Get("organizations").([]interface{})
if len(orgs) != 2 {
t.Fatalf("expected 2 organizations, got %d", len(orgs))
}
first := orgs[0].(map[string]interface{})
if first["organization_alias"] != "acme" || first["max_budget"] != 100.0 || first["budget_duration"] != "7d" {
t.Errorf("unexpected first organization: %v", first)
}
second := orgs[1].(map[string]interface{})
if second["organization_id"] != "org-2" || second["max_budget"] != 0.0 {
t.Errorf("unexpected second organization: %v", second)
}
}

View file

@ -0,0 +1,255 @@
package litellm
import (
"encoding/json"
"fmt"
"net/http"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
const endpointProjectList = "/project/list"
func dataSourceLiteLLMProject() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMProjectRead,
Schema: map[string]*schema.Schema{
"project_id": {
Type: schema.TypeString,
Required: true,
Description: "Unique identifier of the project to retrieve",
},
"project_alias": {
Type: schema.TypeString,
Computed: true,
Description: "Human-friendly name for the project",
},
"description": {
Type: schema.TypeString,
Computed: true,
Description: "Description of the project",
},
"team_id": {
Type: schema.TypeString,
Computed: true,
Description: "The team ID this project belongs to",
},
"budget_id": {
Type: schema.TypeString,
Computed: true,
Description: "Budget ID associated with this project",
},
"models": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "List of models the project can access",
},
"max_budget": {
Type: schema.TypeFloat,
Computed: true,
Description: "Maximum budget for this project",
},
"soft_budget": {
Type: schema.TypeFloat,
Computed: true,
Description: "Soft budget limit for warnings",
},
"budget_duration": {
Type: schema.TypeString,
Computed: true,
Description: "Budget reset duration",
},
"tpm_limit": {
Type: schema.TypeInt,
Computed: true,
Description: "Tokens per minute limit",
},
"rpm_limit": {
Type: schema.TypeInt,
Computed: true,
Description: "Requests per minute limit",
},
"max_parallel_requests": {
Type: schema.TypeInt,
Computed: true,
Description: "Maximum parallel requests allowed",
},
"blocked": {
Type: schema.TypeBool,
Computed: true,
Description: "Whether the project is blocked from making requests",
},
"spend": {
Type: schema.TypeFloat,
Computed: true,
Description: "Current spend for the project",
},
"created_at": {
Type: schema.TypeString,
Computed: true,
Description: "Timestamp when the project was created",
},
"updated_at": {
Type: schema.TypeString,
Computed: true,
Description: "Timestamp when the project was last updated",
},
"created_by": {
Type: schema.TypeString,
Computed: true,
Description: "User that created the project",
},
"updated_by": {
Type: schema.TypeString,
Computed: true,
Description: "User that last updated the project",
},
},
}
}
func dataSourceLiteLLMProjectRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
projectID := d.Get("project_id").(string)
resp, err := MakeRequest(client, "GET", fmt.Sprintf("%s?project_id=%s", endpointProjectInfo, projectID), nil)
if err != nil {
return fmt.Errorf("failed to read project: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("project '%s' not found", projectID)
}
if err := handleResponse(resp, "reading project"); err != nil {
return err
}
var projResp projectResponse
if err := json.NewDecoder(resp.Body).Decode(&projResp); err != nil {
return fmt.Errorf("error decoding project info response: %w", err)
}
d.SetId(projResp.ProjectID)
d.Set("project_id", projResp.ProjectID)
d.Set("project_alias", projResp.ProjectAlias)
d.Set("description", projResp.Description)
d.Set("team_id", projResp.TeamID)
d.Set("budget_id", projResp.BudgetID)
d.Set("models", projResp.Models)
d.Set("blocked", projResp.Blocked)
d.Set("spend", projResp.Spend)
d.Set("created_at", projResp.CreatedAt)
d.Set("updated_at", projResp.UpdatedAt)
d.Set("created_by", projResp.CreatedBy)
d.Set("updated_by", projResp.UpdatedBy)
if bt := projResp.LitellmBudgetTable; bt != nil {
if bt.MaxBudget != nil {
d.Set("max_budget", *bt.MaxBudget)
}
if bt.SoftBudget != nil {
d.Set("soft_budget", *bt.SoftBudget)
}
if bt.MaxParallelRequests != nil {
d.Set("max_parallel_requests", *bt.MaxParallelRequests)
}
if bt.TPMLimit != nil {
d.Set("tpm_limit", *bt.TPMLimit)
}
if bt.RPMLimit != nil {
d.Set("rpm_limit", *bt.RPMLimit)
}
d.Set("budget_duration", bt.BudgetDuration)
}
return nil
}
func dataSourceLiteLLMProjects() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMProjectsRead,
Schema: map[string]*schema.Schema{
"ids": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "IDs of all projects",
},
"projects": {
Type: schema.TypeList,
Computed: true,
Description: "List of projects",
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"project_id": {Type: schema.TypeString, Computed: true},
"project_alias": {Type: schema.TypeString, Computed: true},
"description": {Type: schema.TypeString, Computed: true},
"team_id": {Type: schema.TypeString, Computed: true},
"budget_id": {Type: schema.TypeString, Computed: true},
"models": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"blocked": {Type: schema.TypeBool, Computed: true},
"spend": {Type: schema.TypeFloat, Computed: true},
"created_at": {Type: schema.TypeString, Computed: true},
"updated_at": {Type: schema.TypeString, Computed: true},
"created_by": {Type: schema.TypeString, Computed: true},
"updated_by": {Type: schema.TypeString, Computed: true},
},
},
},
},
}
}
func dataSourceLiteLLMProjectsRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
resp, err := MakeRequest(client, "GET", endpointProjectList, nil)
if err != nil {
return fmt.Errorf("failed to list projects: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "listing projects"); err != nil {
return err
}
var projResps []projectResponse
if err := json.NewDecoder(resp.Body).Decode(&projResps); err != nil {
return fmt.Errorf("error decoding project list response: %w", err)
}
ids := make([]string, 0, len(projResps))
projects := make([]map[string]interface{}, 0, len(projResps))
for _, projResp := range projResps {
ids = append(ids, projResp.ProjectID)
projects = append(projects, map[string]interface{}{
"project_id": projResp.ProjectID,
"project_alias": projResp.ProjectAlias,
"description": projResp.Description,
"team_id": projResp.TeamID,
"budget_id": projResp.BudgetID,
"models": projResp.Models,
"blocked": projResp.Blocked,
"spend": projResp.Spend,
"created_at": projResp.CreatedAt,
"updated_at": projResp.UpdatedAt,
"created_by": projResp.CreatedBy,
"updated_by": projResp.UpdatedBy,
})
}
d.SetId("litellm-projects")
d.Set("ids", ids)
d.Set("projects", projects)
return nil
}

View file

@ -0,0 +1,104 @@
package litellm
import (
"net/http"
"net/http/httptest"
"reflect"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func TestDataSourceLiteLLMProjectRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/project/info" || r.Method != http.MethodGet {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if got := r.URL.Query().Get("project_id"); got != "proj-123" {
t.Errorf("expected project_id query 'proj-123', got %q", got)
}
w.Write([]byte(projectInfoBody))
}))
defer srv.Close()
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMProject().Schema, map[string]interface{}{
"project_id": "proj-123",
})
if err := dataSourceLiteLLMProjectRead(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("read failed: %v", err)
}
if d.Id() != "proj-123" {
t.Fatalf("expected ID 'proj-123', got %q", d.Id())
}
checks := map[string]interface{}{
"project_alias": "ml-experiments",
"description": "ML experimentation project",
"team_id": "team-1",
"budget_id": "bud-9",
"spend": 12.5,
"max_budget": 100.0,
"tpm_limit": 5000,
"budget_duration": "30d",
"created_by": "admin",
}
for key, want := range checks {
if got := d.Get(key); got != want {
t.Errorf("expected %s %v, got %v", key, want, got)
}
}
if !reflect.DeepEqual(d.Get("models"), []interface{}{"gpt-4"}) {
t.Errorf("expected models ['gpt-4'], got %v", d.Get("models"))
}
}
func TestDataSourceLiteLLMProjectRead_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMProject().Schema, map[string]interface{}{
"project_id": "gone",
})
if err := dataSourceLiteLLMProjectRead(d, NewClient(srv.URL, "test-key", true)); err == nil {
t.Fatal("expected error for missing project, got nil")
}
}
func TestDataSourceLiteLLMProjectsRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/project/list" || r.Method != http.MethodGet {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.Write([]byte(`[
` + projectInfoBody + `,
{"project_id": "proj-456", "project_alias": "second", "team_id": "team-2", "models": [], "spend": 0.0}
]`))
}))
defer srv.Close()
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMProjects().Schema, map[string]interface{}{})
if err := dataSourceLiteLLMProjectsRead(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("read failed: %v", err)
}
if !reflect.DeepEqual(d.Get("ids"), []interface{}{"proj-123", "proj-456"}) {
t.Errorf("expected ids ['proj-123', 'proj-456'], got %v", d.Get("ids"))
}
if got := d.Get("projects.#").(int); got != 2 {
t.Fatalf("expected 2 projects, got %d", got)
}
if got := d.Get("projects.0.project_alias").(string); got != "ml-experiments" {
t.Errorf("expected projects.0.project_alias 'ml-experiments', got %q", got)
}
if got := d.Get("projects.0.spend").(float64); got != 12.5 {
t.Errorf("expected projects.0.spend 12.5, got %v", got)
}
if got := d.Get("projects.1.team_id").(string); got != "team-2" {
t.Errorf("expected projects.1.team_id 'team-2', got %q", got)
}
}

View file

@ -0,0 +1,243 @@
package litellm
import (
"encoding/json"
"fmt"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func dataSourceLiteLLMPrompt() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMPromptRead,
Schema: map[string]*schema.Schema{
"prompt_id": {
Type: schema.TypeString,
Required: true,
Description: "Unique identifier of the prompt to retrieve",
},
"environment": {
Type: schema.TypeString,
Optional: true,
Description: "Environment to fetch the prompt from (e.g. 'development', 'production')",
},
"prompt_integration": {
Type: schema.TypeString,
Computed: true,
},
"api_base": {
Type: schema.TypeString,
Computed: true,
},
"provider_specific_query_params": {
Type: schema.TypeString,
Computed: true,
},
"ignore_prompt_manager_model": {
Type: schema.TypeBool,
Computed: true,
},
"ignore_prompt_manager_optional_params": {
Type: schema.TypeBool,
Computed: true,
},
"dotprompt_content": {
Type: schema.TypeString,
Computed: true,
},
"prompt_type": {
Type: schema.TypeString,
Computed: true,
},
"version": {
Type: schema.TypeInt,
Computed: true,
},
"environments": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"created_at": {
Type: schema.TypeString,
Computed: true,
},
"updated_at": {
Type: schema.TypeString,
Computed: true,
},
},
}
}
func dataSourceLiteLLMPromptRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
promptID := d.Get("prompt_id").(string)
endpoint := fmt.Sprintf(endpointPromptInfo, promptID)
if env := d.Get("environment").(string); env != "" {
endpoint = fmt.Sprintf("/prompts/%s/info?environment=%s", promptID, env)
}
resp, err := MakeRequest(client, "GET", endpoint, nil)
if err != nil {
return fmt.Errorf("failed to read prompt: %w", err)
}
defer resp.Body.Close()
if promptIsNotFoundResponse(resp) {
return fmt.Errorf("prompt '%s' not found", promptID)
}
if err := handleResponse(resp, "reading prompt"); err != nil {
return err
}
var info struct {
PromptSpec promptSpecAPIResponse `json:"prompt_spec"`
Environments []string `json:"environments"`
}
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return fmt.Errorf("error decoding prompt info response: %w", err)
}
d.SetId(info.PromptSpec.PromptID)
d.Set("prompt_id", info.PromptSpec.PromptID)
d.Set("version", info.PromptSpec.Version)
d.Set("environments", info.Environments)
d.Set("created_at", info.PromptSpec.CreatedAt)
d.Set("updated_at", info.PromptSpec.UpdatedAt)
params := info.PromptSpec.LitellmParams
if v, ok := params["prompt_integration"].(string); ok {
d.Set("prompt_integration", v)
}
if v, ok := params["api_base"].(string); ok {
d.Set("api_base", v)
}
if v, ok := params["dotprompt_content"].(string); ok {
d.Set("dotprompt_content", v)
}
if v, ok := params["ignore_prompt_manager_model"].(bool); ok {
d.Set("ignore_prompt_manager_model", v)
}
if v, ok := params["ignore_prompt_manager_optional_params"].(bool); ok {
d.Set("ignore_prompt_manager_optional_params", v)
}
if v, ok := params["provider_specific_query_params"].(map[string]interface{}); ok {
if encoded, err := json.Marshal(v); err == nil {
d.Set("provider_specific_query_params", string(encoded))
}
}
if v, ok := info.PromptSpec.PromptInfo["prompt_type"].(string); ok {
d.Set("prompt_type", v)
}
// api_key is intentionally not exposed.
return nil
}
func dataSourceLiteLLMPrompts() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMPromptsRead,
Schema: map[string]*schema.Schema{
"environment": {
Type: schema.TypeString,
Optional: true,
Description: "Filter prompts by environment (e.g. 'development', 'production')",
},
"prompts": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"prompt_id": {
Type: schema.TypeString,
Computed: true,
},
"prompt_integration": {
Type: schema.TypeString,
Computed: true,
},
"prompt_type": {
Type: schema.TypeString,
Computed: true,
},
"version": {
Type: schema.TypeInt,
Computed: true,
},
"environment": {
Type: schema.TypeString,
Computed: true,
},
"created_at": {
Type: schema.TypeString,
Computed: true,
},
"updated_at": {
Type: schema.TypeString,
Computed: true,
},
},
},
},
"ids": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
},
}
}
func dataSourceLiteLLMPromptsRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
endpoint := endpointPromptList
if env := d.Get("environment").(string); env != "" {
endpoint = fmt.Sprintf("/prompts/list?environment=%s", env)
}
resp, err := MakeRequest(client, "GET", endpoint, nil)
if err != nil {
return fmt.Errorf("failed to list prompts: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "listing prompts"); err != nil {
return err
}
var listResp struct {
Prompts []promptSpecAPIResponse `json:"prompts"`
}
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
return fmt.Errorf("error decoding prompts list response: %w", err)
}
prompts := make([]map[string]interface{}, 0, len(listResp.Prompts))
ids := make([]string, 0, len(listResp.Prompts))
for _, p := range listResp.Prompts {
integration, _ := p.LitellmParams["prompt_integration"].(string)
promptType, _ := p.PromptInfo["prompt_type"].(string)
prompts = append(prompts, map[string]interface{}{
"prompt_id": p.PromptID,
"prompt_integration": integration,
"prompt_type": promptType,
"version": p.Version,
"environment": p.Environment,
"created_at": p.CreatedAt,
"updated_at": p.UpdatedAt,
})
ids = append(ids, p.PromptID)
}
d.SetId("prompts")
d.Set("prompts", prompts)
d.Set("ids", ids)
return nil
}

View file

@ -0,0 +1,92 @@
package litellm
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func TestDataSourcePromptRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/prompts/p1/info" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(promptInfoJSON("p1")))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMPrompt().Schema, map[string]interface{}{
"prompt_id": "p1",
})
if err := dataSourceLiteLLMPromptRead(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if d.Id() != "p1" {
t.Fatalf("expected ID 'p1', got %q", d.Id())
}
if got := d.Get("prompt_integration").(string); got != "langfuse" {
t.Errorf("expected prompt_integration 'langfuse', got %q", got)
}
if got := d.Get("prompt_type").(string); got != "db" {
t.Errorf("expected prompt_type 'db', got %q", got)
}
if got := d.Get("version").(int); got != 3 {
t.Errorf("expected version 3, got %d", got)
}
envs := d.Get("environments").([]interface{})
if len(envs) != 1 || envs[0] != "development" {
t.Errorf("unexpected environments: %v", envs)
}
}
func TestDataSourcePromptsRead_WithEnvironmentFilter(t *testing.T) {
var gotQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/prompts/list" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
gotQuery = r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"prompts": [
{
"prompt_id": "p1",
"litellm_params": {"prompt_integration": "langfuse"},
"prompt_info": {"prompt_type": "db"},
"version": 2,
"environment": "production"
}
]}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMPrompts().Schema, map[string]interface{}{
"environment": "production",
})
if err := dataSourceLiteLLMPromptsRead(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if gotQuery != "environment=production" {
t.Fatalf("expected environment filter in query, got %q", gotQuery)
}
prompts := d.Get("prompts").([]interface{})
if len(prompts) != 1 {
t.Fatalf("expected 1 prompt, got %d", len(prompts))
}
first := prompts[0].(map[string]interface{})
if first["prompt_id"] != "p1" || first["prompt_integration"] != "langfuse" ||
first["prompt_type"] != "db" || first["version"] != 2 || first["environment"] != "production" {
t.Errorf("unexpected prompt item: %v", first)
}
ids := d.Get("ids").([]interface{})
if len(ids) != 1 || ids[0] != "p1" {
t.Errorf("unexpected ids: %v", ids)
}
}

View file

@ -0,0 +1,179 @@
package litellm
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func dataSourceLiteLLMSearchTool() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMSearchToolRead,
Schema: map[string]*schema.Schema{
"search_tool_id": {
Type: schema.TypeString,
Required: true,
Description: "Unique identifier of the search tool to retrieve.",
},
"search_tool_name": {
Type: schema.TypeString,
Computed: true,
},
"search_tool_info": {
Type: schema.TypeString,
Computed: true,
Description: "Additional metadata as a JSON object string.",
},
"created_at": {
Type: schema.TypeString,
Computed: true,
},
"updated_at": {
Type: schema.TypeString,
Computed: true,
},
},
}
}
func dataSourceLiteLLMSearchToolRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
searchToolID := d.Get("search_tool_id").(string)
resp, err := MakeRequest(client, "GET", fmt.Sprintf(endpointSearchToolByID, searchToolID), nil)
if err != nil {
return fmt.Errorf("error reading search tool: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("search tool '%s' not found", searchToolID)
}
if err := handleResponse(resp, "reading search tool"); err != nil {
return err
}
var searchToolResp searchToolAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&searchToolResp); err != nil {
return fmt.Errorf("error decoding search tool info response: %w", err)
}
// litellm_params is intentionally never exposed: it may hold provider API keys.
d.SetId(searchToolResp.SearchToolID)
d.Set("search_tool_name", searchToolResp.SearchToolName)
if searchToolResp.SearchToolInfo != nil {
infoJSON, err := json.Marshal(searchToolResp.SearchToolInfo)
if err != nil {
return fmt.Errorf("error encoding search_tool_info: %w", err)
}
d.Set("search_tool_info", string(infoJSON))
}
d.Set("created_at", searchToolResp.CreatedAt)
d.Set("updated_at", searchToolResp.UpdatedAt)
return nil
}
func dataSourceLiteLLMSearchTools() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMSearchToolsRead,
Schema: map[string]*schema.Schema{
"ids": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"search_tools": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"search_tool_id": {
Type: schema.TypeString,
Computed: true,
},
"search_tool_name": {
Type: schema.TypeString,
Computed: true,
},
"search_tool_info": {
Type: schema.TypeString,
Computed: true,
Description: "Additional metadata as a JSON object string.",
},
"is_from_config": {
Type: schema.TypeBool,
Computed: true,
},
"created_at": {
Type: schema.TypeString,
Computed: true,
},
"updated_at": {
Type: schema.TypeString,
Computed: true,
},
},
},
},
},
}
}
func dataSourceLiteLLMSearchToolsRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
resp, err := MakeRequest(client, "GET", endpointSearchToolsList, nil)
if err != nil {
return fmt.Errorf("error listing search tools: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "listing search tools"); err != nil {
return err
}
var listResp struct {
SearchTools []searchToolAPIResponse `json:"search_tools"`
}
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
return fmt.Errorf("error decoding search tools list response: %w", err)
}
ids := make([]string, 0, len(listResp.SearchTools))
searchTools := make([]map[string]interface{}, 0, len(listResp.SearchTools))
for _, searchToolResp := range listResp.SearchTools {
ids = append(ids, searchToolResp.SearchToolID)
searchTool := map[string]interface{}{
"search_tool_id": searchToolResp.SearchToolID,
"search_tool_name": searchToolResp.SearchToolName,
"created_at": searchToolResp.CreatedAt,
"updated_at": searchToolResp.UpdatedAt,
}
if searchToolResp.SearchToolInfo != nil {
infoJSON, err := json.Marshal(searchToolResp.SearchToolInfo)
if err != nil {
return fmt.Errorf("error encoding search_tool_info: %w", err)
}
searchTool["search_tool_info"] = string(infoJSON)
}
if searchToolResp.IsFromConfig != nil {
searchTool["is_from_config"] = *searchToolResp.IsFromConfig
}
searchTools = append(searchTools, searchTool)
}
d.SetId(strconv.FormatInt(time.Now().UnixNano(), 10))
d.Set("ids", ids)
d.Set("search_tools", searchTools)
return nil
}

View file

@ -0,0 +1,95 @@
package litellm
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func TestDataSourceLiteLLMSearchToolRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/search_tools/st-123" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write(searchToolReadResponseBody())
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMSearchTool().Schema, map[string]interface{}{
"search_tool_id": "st-123",
})
if err := dataSourceLiteLLMSearchToolRead(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if d.Id() != "st-123" {
t.Fatalf("expected ID 'st-123', got %q", d.Id())
}
if d.Get("search_tool_name").(string) != "my-search" {
t.Errorf("expected search_tool_name 'my-search', got %q", d.Get("search_tool_name").(string))
}
var info map[string]interface{}
if err := json.Unmarshal([]byte(d.Get("search_tool_info").(string)), &info); err != nil {
t.Fatalf("search_tool_info not populated as JSON: %v", err)
}
if info["description"] != "Tavily search" {
t.Errorf("expected description 'Tavily search', got %v", info["description"])
}
}
func TestDataSourceLiteLLMSearchToolsRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/search_tools/list" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
body, _ := json.Marshal(map[string]interface{}{
"search_tools": []map[string]interface{}{
{
"search_tool_id": "st-1",
"search_tool_name": "first",
"search_tool_info": map[string]interface{}{"description": "first tool"},
"is_from_config": true,
},
{"search_tool_id": "st-2", "search_tool_name": "second"},
},
})
w.Write(body)
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMSearchTools().Schema, map[string]interface{}{})
if err := dataSourceLiteLLMSearchToolsRead(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
ids := d.Get("ids").([]interface{})
if len(ids) != 2 || ids[0] != "st-1" || ids[1] != "st-2" {
t.Fatalf("expected ids [st-1 st-2], got %v", ids)
}
searchTools := d.Get("search_tools").([]interface{})
if len(searchTools) != 2 {
t.Fatalf("expected 2 search tools, got %d", len(searchTools))
}
first := searchTools[0].(map[string]interface{})
if first["search_tool_name"] != "first" || first["is_from_config"] != true {
t.Errorf("unexpected first search tool entry: %v", first)
}
var info map[string]interface{}
if err := json.Unmarshal([]byte(first["search_tool_info"].(string)), &info); err != nil {
t.Fatalf("search_tool_info not JSON-encoded in list: %v", err)
}
if info["description"] != "first tool" {
t.Errorf("expected description 'first tool', got %v", info["description"])
}
if d.Id() == "" {
t.Fatal("expected data source ID to be set")
}
}

View file

@ -0,0 +1,246 @@
package litellm
import (
"encoding/json"
"fmt"
"net/url"
"strings"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
const endpointTagList = "/tag/list"
func dataSourceLiteLLMTag() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMTagRead,
Schema: map[string]*schema.Schema{
"name": {
Type: schema.TypeString,
Required: true,
Description: "Name of the tag to retrieve",
},
"description": {
Type: schema.TypeString,
Computed: true,
Description: "Description of the tag",
},
"models": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "Model IDs this tag applies to",
},
"budget_id": {
Type: schema.TypeString,
Computed: true,
Description: "Budget ID associated with this tag",
},
"max_budget": {
Type: schema.TypeFloat,
Computed: true,
Description: "Max budget in USD for this tag",
},
"soft_budget": {
Type: schema.TypeFloat,
Computed: true,
Description: "Soft budget in USD for this tag",
},
"max_parallel_requests": {
Type: schema.TypeInt,
Computed: true,
Description: "Max concurrent requests allowed for this tag",
},
"tpm_limit": {
Type: schema.TypeInt,
Computed: true,
Description: "Max tokens per minute for this tag",
},
"rpm_limit": {
Type: schema.TypeInt,
Computed: true,
Description: "Max requests per minute for this tag",
},
"budget_duration": {
Type: schema.TypeString,
Computed: true,
Description: "Duration for budget reset",
},
"created_at": {
Type: schema.TypeString,
Computed: true,
Description: "Timestamp when the tag was created",
},
"updated_at": {
Type: schema.TypeString,
Computed: true,
Description: "Timestamp when the tag was last updated",
},
"created_by": {
Type: schema.TypeString,
Computed: true,
Description: "User that created the tag",
},
},
}
}
func dataSourceLiteLLMTagRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
name := d.Get("name").(string)
entry, gone, err := fetchTagInfo(client, name)
if err != nil {
return fmt.Errorf("failed to read tag: %w", err)
}
if gone {
return fmt.Errorf("tag '%s' not found", name)
}
d.SetId(name)
d.Set("description", entry.Description)
d.Set("models", entry.Models)
d.Set("created_at", entry.CreatedAt)
d.Set("updated_at", entry.UpdatedAt)
d.Set("created_by", entry.CreatedBy)
if bt := entry.LitellmBudgetTable; bt != nil {
d.Set("budget_id", bt.BudgetID)
if bt.MaxBudget != nil {
d.Set("max_budget", *bt.MaxBudget)
}
if bt.SoftBudget != nil {
d.Set("soft_budget", *bt.SoftBudget)
}
if bt.MaxParallelRequests != nil {
d.Set("max_parallel_requests", *bt.MaxParallelRequests)
}
if bt.TPMLimit != nil {
d.Set("tpm_limit", *bt.TPMLimit)
}
if bt.RPMLimit != nil {
d.Set("rpm_limit", *bt.RPMLimit)
}
d.Set("budget_duration", bt.BudgetDuration)
}
return nil
}
func dataSourceLiteLLMTags() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMTagsRead,
Schema: map[string]*schema.Schema{
"start_date": {
Type: schema.TypeString,
Optional: true,
Description: "Optional start date (YYYY-MM-DD) limiting dynamic tags to those active in the window",
},
"end_date": {
Type: schema.TypeString,
Optional: true,
Description: "Optional end date (YYYY-MM-DD), must be given with start_date",
},
"ids": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "Names of all tags (tag names are their IDs)",
},
"tags": {
Type: schema.TypeList,
Computed: true,
Description: "List of tags",
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"name": {Type: schema.TypeString, Computed: true},
"description": {Type: schema.TypeString, Computed: true},
"models": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"budget_id": {Type: schema.TypeString, Computed: true},
"max_budget": {Type: schema.TypeFloat, Computed: true},
"soft_budget": {Type: schema.TypeFloat, Computed: true},
"max_parallel_requests": {Type: schema.TypeInt, Computed: true},
"tpm_limit": {Type: schema.TypeInt, Computed: true},
"rpm_limit": {Type: schema.TypeInt, Computed: true},
"budget_duration": {Type: schema.TypeString, Computed: true},
"created_at": {Type: schema.TypeString, Computed: true},
"updated_at": {Type: schema.TypeString, Computed: true},
"created_by": {Type: schema.TypeString, Computed: true},
},
},
},
},
}
}
func dataSourceLiteLLMTagsRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
endpoint := endpointTagList
if startDate, ok := d.GetOk("start_date"); ok {
endpoint = fmt.Sprintf("%s?start_date=%s&end_date=%s", endpointTagList,
url.QueryEscape(startDate.(string)), url.QueryEscape(d.Get("end_date").(string)))
}
resp, err := MakeRequest(client, "GET", endpoint, nil)
if err != nil {
return fmt.Errorf("failed to list tags: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "listing tags"); err != nil {
return err
}
var entries []tagInfoEntry
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
return fmt.Errorf("error decoding tag list response: %w", err)
}
ids := make([]string, 0, len(entries))
tags := make([]map[string]interface{}, 0, len(entries))
for _, entry := range entries {
ids = append(ids, entry.Name)
tag := map[string]interface{}{
"name": entry.Name,
"description": entry.Description,
"models": entry.Models,
"created_at": entry.CreatedAt,
"updated_at": entry.UpdatedAt,
"created_by": entry.CreatedBy,
}
if bt := entry.LitellmBudgetTable; bt != nil {
tag["budget_id"] = bt.BudgetID
tag["budget_duration"] = bt.BudgetDuration
if bt.MaxBudget != nil {
tag["max_budget"] = *bt.MaxBudget
}
if bt.SoftBudget != nil {
tag["soft_budget"] = *bt.SoftBudget
}
if bt.MaxParallelRequests != nil {
tag["max_parallel_requests"] = *bt.MaxParallelRequests
}
if bt.TPMLimit != nil {
tag["tpm_limit"] = *bt.TPMLimit
}
if bt.RPMLimit != nil {
tag["rpm_limit"] = *bt.RPMLimit
}
}
tags = append(tags, tag)
}
d.SetId(strings.Join([]string{"litellm-tags", d.Get("start_date").(string), d.Get("end_date").(string)}, "-"))
d.Set("ids", ids)
d.Set("tags", tags)
return nil
}

View file

@ -0,0 +1,116 @@
package litellm
import (
"net/http"
"net/http/httptest"
"reflect"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func TestDataSourceLiteLLMTagRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/tag/info" || r.Method != http.MethodPost {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.Write([]byte(tagInfoBody("prod")))
}))
defer srv.Close()
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMTag().Schema, map[string]interface{}{"name": "prod"})
if err := dataSourceLiteLLMTagRead(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("read failed: %v", err)
}
if d.Id() != "prod" {
t.Fatalf("expected ID 'prod', got %q", d.Id())
}
checks := map[string]interface{}{
"description": "Production traffic",
"budget_id": "bud-1",
"max_budget": 50.5,
"tpm_limit": 1000,
"created_at": "2026-01-01T00:00:00",
"created_by": "admin",
}
for key, want := range checks {
if got := d.Get(key); got != want {
t.Errorf("expected %s %v, got %v", key, want, got)
}
}
}
func TestDataSourceLiteLLMTagRead_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMTag().Schema, map[string]interface{}{"name": "gone"})
if err := dataSourceLiteLLMTagRead(d, NewClient(srv.URL, "test-key", true)); err == nil {
t.Fatal("expected error for missing tag, got nil")
}
}
func TestDataSourceLiteLLMTagsRead(t *testing.T) {
var gotQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/tag/list" || r.Method != http.MethodGet {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
gotQuery = r.URL.RawQuery
w.Write([]byte(`[
{
"name": "prod",
"description": "Production traffic",
"models": ["model-1"],
"created_at": "2026-01-01T00:00:00",
"updated_at": "2026-01-02T00:00:00",
"created_by": "admin",
"litellm_budget_table": {"budget_id": "bud-1", "max_budget": 50.5}
},
{
"name": "dynamic-tag",
"description": "This is just a spend tag that was passed dynamically in a request.",
"models": null,
"created_at": "2026-02-01T00:00:00",
"updated_at": "2026-02-02T00:00:00"
}
]`))
}))
defer srv.Close()
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMTags().Schema, map[string]interface{}{
"start_date": "2026-01-01",
"end_date": "2026-03-01",
})
if err := dataSourceLiteLLMTagsRead(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("read failed: %v", err)
}
if gotQuery != "start_date=2026-01-01&end_date=2026-03-01" {
t.Errorf("expected date filter query params, got %q", gotQuery)
}
if !reflect.DeepEqual(d.Get("ids"), []interface{}{"prod", "dynamic-tag"}) {
t.Errorf("expected ids ['prod', 'dynamic-tag'], got %v", d.Get("ids"))
}
if got := d.Get("tags.#").(int); got != 2 {
t.Fatalf("expected 2 tags, got %d", got)
}
if got := d.Get("tags.0.name").(string); got != "prod" {
t.Errorf("expected tags.0.name 'prod', got %q", got)
}
if got := d.Get("tags.0.max_budget").(float64); got != 50.5 {
t.Errorf("expected tags.0.max_budget 50.5, got %v", got)
}
if got := d.Get("tags.1.name").(string); got != "dynamic-tag" {
t.Errorf("expected tags.1.name 'dynamic-tag', got %q", got)
}
if got := d.Get("tags.1.budget_id").(string); got != "" {
t.Errorf("expected empty budget_id for dynamic tag, got %q", got)
}
}

View file

@ -0,0 +1,294 @@
package litellm
import (
"encoding/json"
"fmt"
"log"
"net/url"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
const endpointTeamList = "/team/list"
type teamDetail struct {
TeamID string `json:"team_id"`
TeamAlias string `json:"team_alias"`
OrganizationID string `json:"organization_id"`
Models []string `json:"models"`
Metadata map[string]interface{} `json:"metadata"`
TPMLimit *int `json:"tpm_limit"`
RPMLimit *int `json:"rpm_limit"`
MaxParallelRequests *int `json:"max_parallel_requests"`
MaxBudget *float64 `json:"max_budget"`
SoftBudget *float64 `json:"soft_budget"`
Spend *float64 `json:"spend"`
BudgetDuration string `json:"budget_duration"`
Blocked bool `json:"blocked"`
TeamMemberPermissions []string `json:"team_member_permissions"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type teamInfoEnvelope struct {
TeamID string `json:"team_id"`
TeamInfo teamDetail `json:"team_info"`
}
func dataSourceLiteLLMTeam() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMTeamRead,
Schema: map[string]*schema.Schema{
"team_id": {
Type: schema.TypeString,
Required: true,
Description: "Unique identifier of the team to retrieve",
},
"team_alias": {
Type: schema.TypeString,
Computed: true,
},
"organization_id": {
Type: schema.TypeString,
Computed: true,
},
"models": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"metadata": {
Type: schema.TypeMap,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"tags": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"soft_budget_alerting_emails": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"tpm_limit": {
Type: schema.TypeInt,
Computed: true,
},
"rpm_limit": {
Type: schema.TypeInt,
Computed: true,
},
"max_parallel_requests": {
Type: schema.TypeInt,
Computed: true,
},
"max_budget": {
Type: schema.TypeFloat,
Computed: true,
},
"soft_budget": {
Type: schema.TypeFloat,
Computed: true,
},
"spend": {
Type: schema.TypeFloat,
Computed: true,
},
"budget_duration": {
Type: schema.TypeString,
Computed: true,
},
"blocked": {
Type: schema.TypeBool,
Computed: true,
},
"team_member_permissions": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"created_at": {
Type: schema.TypeString,
Computed: true,
},
"updated_at": {
Type: schema.TypeString,
Computed: true,
},
},
}
}
func dataSourceLiteLLMTeamRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
teamID := d.Get("team_id").(string)
resp, err := MakeRequest(client, "GET", fmt.Sprintf("%s?team_id=%s", endpointTeamInfo, url.QueryEscape(teamID)), nil)
if err != nil {
return fmt.Errorf("failed to read team: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "reading team info"); err != nil {
return err
}
var envelope teamInfoEnvelope
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
return fmt.Errorf("failed to decode team info response: %w", err)
}
team := envelope.TeamInfo
d.SetId(teamID)
d.Set("team_alias", team.TeamAlias)
d.Set("organization_id", team.OrganizationID)
d.Set("models", team.Models)
metadata, tags, alertEmails := splitTeamMetadata(team.Metadata)
d.Set("metadata", metadata)
d.Set("tags", tags)
d.Set("soft_budget_alerting_emails", alertEmails)
if team.TPMLimit != nil {
d.Set("tpm_limit", *team.TPMLimit)
}
if team.RPMLimit != nil {
d.Set("rpm_limit", *team.RPMLimit)
}
if team.MaxParallelRequests != nil {
d.Set("max_parallel_requests", *team.MaxParallelRequests)
}
if team.MaxBudget != nil {
d.Set("max_budget", *team.MaxBudget)
}
if team.SoftBudget != nil {
d.Set("soft_budget", *team.SoftBudget)
}
if team.Spend != nil {
d.Set("spend", *team.Spend)
}
d.Set("budget_duration", team.BudgetDuration)
d.Set("blocked", team.Blocked)
d.Set("team_member_permissions", team.TeamMemberPermissions)
d.Set("created_at", team.CreatedAt)
d.Set("updated_at", team.UpdatedAt)
log.Printf("[INFO] Successfully read team with ID: %s", teamID)
return nil
}
func dataSourceLiteLLMTeams() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMTeamsRead,
Schema: map[string]*schema.Schema{
"user_id": {
Type: schema.TypeString,
Optional: true,
Description: "Only return teams this user belongs to",
},
"organization_id": {
Type: schema.TypeString,
Optional: true,
Description: "Only return teams in this organization",
},
"ids": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "IDs of the returned teams",
},
"teams": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"team_id": {Type: schema.TypeString, Computed: true},
"team_alias": {Type: schema.TypeString, Computed: true},
"organization_id": {Type: schema.TypeString, Computed: true},
"models": {Type: schema.TypeList, Computed: true, Elem: &schema.Schema{Type: schema.TypeString}},
"spend": {Type: schema.TypeFloat, Computed: true},
"max_budget": {Type: schema.TypeFloat, Computed: true},
"tpm_limit": {Type: schema.TypeInt, Computed: true},
"rpm_limit": {Type: schema.TypeInt, Computed: true},
"budget_duration": {Type: schema.TypeString, Computed: true},
"blocked": {Type: schema.TypeBool, Computed: true},
"created_at": {Type: schema.TypeString, Computed: true},
"updated_at": {Type: schema.TypeString, Computed: true},
},
},
},
},
}
}
func dataSourceLiteLLMTeamsRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
query := url.Values{}
if v, ok := d.GetOk("user_id"); ok {
query.Set("user_id", v.(string))
}
if v, ok := d.GetOk("organization_id"); ok {
query.Set("organization_id", v.(string))
}
resp, err := MakeRequest(client, "GET", fmt.Sprintf("%s?%s", endpointTeamList, query.Encode()), nil)
if err != nil {
return fmt.Errorf("failed to list teams: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "listing teams"); err != nil {
return err
}
var teamList []teamDetail
if err := json.NewDecoder(resp.Body).Decode(&teamList); err != nil {
return fmt.Errorf("failed to decode team list response: %w", err)
}
ids := make([]string, 0, len(teamList))
teams := make([]map[string]interface{}, 0, len(teamList))
for _, team := range teamList {
ids = append(ids, team.TeamID)
teams = append(teams, map[string]interface{}{
"team_id": team.TeamID,
"team_alias": team.TeamAlias,
"organization_id": team.OrganizationID,
"models": team.Models,
"spend": teamDerefFloat(team.Spend),
"max_budget": teamDerefFloat(team.MaxBudget),
"tpm_limit": teamDerefInt(team.TPMLimit),
"rpm_limit": teamDerefInt(team.RPMLimit),
"budget_duration": team.BudgetDuration,
"blocked": team.Blocked,
"created_at": team.CreatedAt,
"updated_at": team.UpdatedAt,
})
}
d.SetId(GetStringValue(query.Encode(), "all"))
d.Set("ids", ids)
d.Set("teams", teams)
log.Printf("[INFO] Successfully listed %d teams", len(teams))
return nil
}
func teamDerefFloat(v *float64) float64 {
if v == nil {
return 0
}
return *v
}
func teamDerefInt(v *int) int {
if v == nil {
return 0
}
return *v
}

View file

@ -0,0 +1,145 @@
package litellm
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func TestDataSourceTeamRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/team/info" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if got := r.URL.Query().Get("team_id"); got != "team-123" {
t.Errorf("expected team_id 'team-123', got %q", got)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"team_id": "team-123",
"team_info": {
"team_id": "team-123",
"team_alias": "ml-team",
"organization_id": "org-1",
"models": ["gpt-4o"],
"metadata": {"env": "prod", "tags": ["ml"], "soft_budget_alerting_emails": ["ops@example.com"]},
"tpm_limit": 5000,
"rpm_limit": 100,
"max_budget": 250.5,
"soft_budget": 200,
"spend": 42.25,
"budget_duration": "30d",
"blocked": true,
"team_member_permissions": ["/key/generate"],
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-02-01T00:00:00Z"
}
}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMTeam().Schema, map[string]interface{}{
"team_id": "team-123",
})
if err := dataSourceLiteLLMTeamRead(d, client); err != nil {
t.Fatalf("read failed: %v", err)
}
if d.Id() != "team-123" {
t.Fatalf("expected ID 'team-123', got %q", d.Id())
}
checks := map[string]interface{}{
"team_alias": "ml-team",
"organization_id": "org-1",
"tpm_limit": 5000,
"rpm_limit": 100,
"max_budget": 250.5,
"soft_budget": 200.0,
"spend": 42.25,
"budget_duration": "30d",
"blocked": true,
}
for attr, want := range checks {
if got := d.Get(attr); got != want {
t.Errorf("attr %s: expected %v, got %v", attr, want, got)
}
}
tags := d.Get("tags").([]interface{})
if len(tags) != 1 || tags[0] != "ml" {
t.Errorf("unexpected tags: %v", tags)
}
emails := d.Get("soft_budget_alerting_emails").([]interface{})
if len(emails) != 1 || emails[0] != "ops@example.com" {
t.Errorf("unexpected alerting emails: %v", emails)
}
metadata := d.Get("metadata").(map[string]interface{})
if metadata["env"] != "prod" || len(metadata) != 1 {
t.Errorf("unexpected metadata: %v", metadata)
}
perms := d.Get("team_member_permissions").([]interface{})
if len(perms) != 1 || perms[0] != "/key/generate" {
t.Errorf("unexpected permissions: %v", perms)
}
}
func TestDataSourceTeamsRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/team/list" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if got := r.URL.Query().Get("organization_id"); got != "org-1" {
t.Errorf("expected organization_id 'org-1', got %q", got)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`[
{"team_id": "team-1", "team_alias": "alpha", "organization_id": "org-1", "spend": 5, "max_budget": 50, "tpm_limit": 100, "rpm_limit": 10, "models": ["m1"], "blocked": false},
{"team_id": "team-2", "team_alias": "beta", "organization_id": "org-1", "blocked": true}
]`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMTeams().Schema, map[string]interface{}{
"organization_id": "org-1",
})
if err := dataSourceLiteLLMTeamsRead(d, client); err != nil {
t.Fatalf("read failed: %v", err)
}
ids := d.Get("ids").([]interface{})
if len(ids) != 2 || ids[0] != "team-1" || ids[1] != "team-2" {
t.Errorf("unexpected ids: %v", ids)
}
teams := d.Get("teams").([]interface{})
if len(teams) != 2 {
t.Fatalf("expected 2 teams, got %d", len(teams))
}
first := teams[0].(map[string]interface{})
if first["team_alias"] != "alpha" || first["max_budget"] != 50.0 || first["tpm_limit"] != 100 {
t.Errorf("unexpected first team: %v", first)
}
second := teams[1].(map[string]interface{})
if second["blocked"] != true || second["max_budget"] != 0.0 {
t.Errorf("unexpected second team: %v", second)
}
}
func TestDataSourceTeamsReadError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error": "boom"}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMTeams().Schema, map[string]interface{}{})
if err := dataSourceLiteLLMTeamsRead(d, client); err == nil {
t.Fatal("expected error on server failure, got nil")
}
}

View file

@ -0,0 +1,189 @@
package litellm
import (
"encoding/json"
"fmt"
"net/http"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
const endpointUnifiedAccessGroupList = "/v1/unified_access_group"
func unifiedAccessGroupComputedSchema() map[string]*schema.Schema {
return map[string]*schema.Schema{
"access_group_name": {
Type: schema.TypeString,
Computed: true,
},
"description": {
Type: schema.TypeString,
Computed: true,
},
"access_model_names": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"access_mcp_server_ids": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"access_agent_ids": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"assigned_team_ids": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"assigned_key_ids": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"created_at": {
Type: schema.TypeString,
Computed: true,
},
"created_by": {
Type: schema.TypeString,
Computed: true,
},
"updated_at": {
Type: schema.TypeString,
Computed: true,
},
"updated_by": {
Type: schema.TypeString,
Computed: true,
},
}
}
func dataSourceLiteLLMUnifiedAccessGroup() *schema.Resource {
dsSchema := unifiedAccessGroupComputedSchema()
dsSchema["access_group_id"] = &schema.Schema{
Type: schema.TypeString,
Required: true,
Description: "ID of the unified access group to retrieve",
}
return &schema.Resource{
Read: dataSourceLiteLLMUnifiedAccessGroupRead,
Schema: dsSchema,
}
}
func dataSourceLiteLLMUnifiedAccessGroupRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
groupID := d.Get("access_group_id").(string)
resp, err := MakeRequest(client, "GET", fmt.Sprintf("/v1/unified_access_group/%s", groupID), nil)
if err != nil {
return fmt.Errorf("error reading unified access group: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("unified access group '%s' not found", groupID)
}
if err := handleResponse(resp, "reading unified access group"); err != nil {
return err
}
var group unifiedAccessGroupResponse
if err := json.NewDecoder(resp.Body).Decode(&group); err != nil {
return fmt.Errorf("error decoding unified access group info response: %w", err)
}
d.SetId(GetStringValue(group.AccessGroupID, groupID))
setUnifiedAccessGroupFields(d, group)
return nil
}
func dataSourceLiteLLMUnifiedAccessGroups() *schema.Resource {
itemSchema := unifiedAccessGroupComputedSchema()
itemSchema["access_group_id"] = &schema.Schema{
Type: schema.TypeString,
Computed: true,
}
return &schema.Resource{
Read: dataSourceLiteLLMUnifiedAccessGroupsRead,
Schema: map[string]*schema.Schema{
"access_groups": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Resource{Schema: itemSchema},
},
"ids": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
},
}
}
func dataSourceLiteLLMUnifiedAccessGroupsRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
resp, err := MakeRequest(client, "GET", endpointUnifiedAccessGroupList, nil)
if err != nil {
return fmt.Errorf("error listing unified access groups: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "listing unified access groups"); err != nil {
return err
}
var groups []unifiedAccessGroupResponse
if err := json.NewDecoder(resp.Body).Decode(&groups); err != nil {
return fmt.Errorf("error decoding unified access group list response: %w", err)
}
items := make([]map[string]interface{}, 0, len(groups))
ids := make([]string, 0, len(groups))
for _, group := range groups {
items = append(items, unifiedAccessGroupFlatten(group))
ids = append(ids, group.AccessGroupID)
}
d.SetId("unified_access_groups")
d.Set("access_groups", items)
d.Set("ids", ids)
return nil
}
func unifiedAccessGroupFlatten(group unifiedAccessGroupResponse) map[string]interface{} {
item := map[string]interface{}{
"access_group_id": group.AccessGroupID,
"access_group_name": group.AccessGroupName,
"access_model_names": group.AccessModelNames,
"access_mcp_server_ids": group.AccessMCPServerIDs,
"access_agent_ids": group.AccessAgentIDs,
"assigned_team_ids": group.AssignedTeamIDs,
"assigned_key_ids": group.AssignedKeyIDs,
"created_at": group.CreatedAt,
"updated_at": group.UpdatedAt,
}
if group.Description != nil {
item["description"] = *group.Description
}
if group.CreatedBy != nil {
item["created_by"] = *group.CreatedBy
}
if group.UpdatedBy != nil {
item["updated_by"] = *group.UpdatedBy
}
return item
}

View file

@ -0,0 +1,112 @@
package litellm
import (
"net/http"
"net/http/httptest"
"reflect"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func TestUnifiedAccessGroupDataSourceRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/unified_access_group/uag-123" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
w.Write(unifiedAccessGroupJSON("uag-123"))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMUnifiedAccessGroup().Schema, map[string]interface{}{
"access_group_id": "uag-123",
})
if err := dataSourceLiteLLMUnifiedAccessGroupRead(d, client); err != nil {
t.Fatalf("data source read failed: %v", err)
}
if d.Id() != "uag-123" {
t.Fatalf("expected ID 'uag-123', got %q", d.Id())
}
if d.Get("access_group_name").(string) != "prod-group" {
t.Fatalf("expected access_group_name 'prod-group', got %v", d.Get("access_group_name"))
}
if d.Get("description").(string) != "prod access" {
t.Fatalf("expected description 'prod access', got %v", d.Get("description"))
}
if !reflect.DeepEqual(d.Get("access_model_names"), []interface{}{"gpt-4"}) {
t.Fatalf("expected access_model_names [gpt-4], got %v", d.Get("access_model_names"))
}
if !reflect.DeepEqual(d.Get("assigned_team_ids"), []interface{}{"team-1"}) {
t.Fatalf("expected assigned_team_ids [team-1], got %v", d.Get("assigned_team_ids"))
}
}
func TestUnifiedAccessGroupDataSourceReadNotFound(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 := schema.TestResourceDataRaw(t, dataSourceLiteLLMUnifiedAccessGroup().Schema, map[string]interface{}{
"access_group_id": "missing",
})
if err := dataSourceLiteLLMUnifiedAccessGroupRead(d, client); err == nil {
t.Fatal("expected error for missing unified access group, got nil")
}
}
func TestUnifiedAccessGroupsDataSourceRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/unified_access_group" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
w.Write([]byte(`[` +
`{"access_group_id": "uag-1", "access_group_name": "group-one", "description": "first",` +
` "access_model_names": ["gpt-4"], "access_mcp_server_ids": [], "access_agent_ids": [],` +
` "assigned_team_ids": ["team-1"], "assigned_key_ids": [],` +
` "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-02T00:00:00Z"},` +
`{"access_group_id": "uag-2", "access_group_name": "group-two",` +
` "access_model_names": [], "access_mcp_server_ids": ["mcp-1"], "access_agent_ids": [],` +
` "assigned_team_ids": [], "assigned_key_ids": [],` +
` "created_at": "2026-01-03T00:00:00Z", "updated_at": "2026-01-04T00:00:00Z"}]`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMUnifiedAccessGroups().Schema, map[string]interface{}{})
if err := dataSourceLiteLLMUnifiedAccessGroupsRead(d, client); err != nil {
t.Fatalf("data source read failed: %v", err)
}
groups := d.Get("access_groups").([]interface{})
if len(groups) != 2 {
t.Fatalf("expected 2 unified access groups, got %d", len(groups))
}
first := groups[0].(map[string]interface{})
if first["access_group_id"] != "uag-1" {
t.Fatalf("expected first access_group_id 'uag-1', got %v", first["access_group_id"])
}
if first["access_group_name"] != "group-one" {
t.Fatalf("expected first access_group_name 'group-one', got %v", first["access_group_name"])
}
if first["description"] != "first" {
t.Fatalf("expected first description 'first', got %v", first["description"])
}
second := groups[1].(map[string]interface{})
if !reflect.DeepEqual(second["access_mcp_server_ids"], []interface{}{"mcp-1"}) {
t.Fatalf("expected second access_mcp_server_ids [mcp-1], got %v", second["access_mcp_server_ids"])
}
if !reflect.DeepEqual(d.Get("ids"), []interface{}{"uag-1", "uag-2"}) {
t.Fatalf("expected ids [uag-1 uag-2], got %v", d.Get("ids"))
}
}

View file

@ -0,0 +1,307 @@
package litellm
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
const endpointUserList = "/user/list"
func dataSourceLiteLLMUser() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMUserRead,
Schema: map[string]*schema.Schema{
"user_id": {
Type: schema.TypeString,
Required: true,
Description: "ID of the user to retrieve",
},
"user_email": {
Type: schema.TypeString,
Computed: true,
Description: "Email address of the user",
},
"user_alias": {
Type: schema.TypeString,
Computed: true,
Description: "Descriptive name for the user",
},
"user_role": {
Type: schema.TypeString,
Computed: true,
Description: "Role of the user on the proxy",
},
"teams": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "List of team IDs the user belongs to",
},
"models": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "Models the user is allowed to call",
},
"max_budget": {
Type: schema.TypeFloat,
Computed: true,
Description: "Maximum budget in USD for the user",
},
"spend": {
Type: schema.TypeFloat,
Computed: true,
Description: "Current spend in USD for the user",
},
"budget_duration": {
Type: schema.TypeString,
Computed: true,
Description: "Budget reset period for the user",
},
"tpm_limit": {
Type: schema.TypeInt,
Computed: true,
Description: "Tokens per minute limit for the user",
},
"rpm_limit": {
Type: schema.TypeInt,
Computed: true,
Description: "Requests per minute limit for the user",
},
"max_parallel_requests": {
Type: schema.TypeInt,
Computed: true,
Description: "Maximum number of parallel requests for the user",
},
"metadata": {
Type: schema.TypeMap,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "Metadata for the user",
},
"model_max_budget": {
Type: schema.TypeString,
Computed: true,
Description: "JSON string of per-model budget config",
},
},
}
}
func dataSourceLiteLLMUserRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
userID := d.Get("user_id").(string)
resp, err := MakeRequest(client, "GET", fmt.Sprintf("%s?user_id=%s", endpointUserInfo, url.QueryEscape(userID)), nil)
if err != nil {
return fmt.Errorf("failed to read user: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("user '%s' not found", userID)
}
if err := handleResponse(resp, "reading user"); err != nil {
return err
}
var infoResp userInfoResponse
if err := json.NewDecoder(resp.Body).Decode(&infoResp); err != nil {
return fmt.Errorf("error decoding user info response: %w", err)
}
if infoResp.UserInfo == nil {
return fmt.Errorf("user '%s' not found", userID)
}
d.SetId(userID)
setUserStateFromInfo(d, infoResp.UserInfo)
if v, ok := infoResp.UserInfo["spend"].(float64); ok {
d.Set("spend", v)
}
return nil
}
func dataSourceLiteLLMUsers() *schema.Resource {
return &schema.Resource{
Read: dataSourceLiteLLMUsersRead,
Schema: map[string]*schema.Schema{
"role": {
Type: schema.TypeString,
Optional: true,
Description: "Filter users by role",
},
"user_ids": {
Type: schema.TypeString,
Optional: true,
Description: "Comma-separated list of user IDs to filter by",
},
"user_email": {
Type: schema.TypeString,
Optional: true,
Description: "Filter users by partial email match",
},
"team": {
Type: schema.TypeString,
Optional: true,
Description: "Filter users by team ID",
},
"page": {
Type: schema.TypeInt,
Optional: true,
Default: 1,
Description: "Page number to fetch",
},
"page_size": {
Type: schema.TypeInt,
Optional: true,
Default: 25,
Description: "Number of users per page (max 100)",
},
"sort_by": {
Type: schema.TypeString,
Optional: true,
Description: "Column to sort by (e.g. 'user_id', 'user_email', 'created_at')",
},
"sort_order": {
Type: schema.TypeString,
Optional: true,
Description: "Sort order, 'asc' or 'desc'",
},
"users": {
Type: schema.TypeList,
Computed: true,
Description: "Users returned for the requested page",
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"user_id": {Type: schema.TypeString, Computed: true},
"user_email": {Type: schema.TypeString, Computed: true},
"user_alias": {Type: schema.TypeString, Computed: true},
"user_role": {Type: schema.TypeString, Computed: true},
"teams": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"models": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"max_budget": {Type: schema.TypeFloat, Computed: true},
"spend": {Type: schema.TypeFloat, Computed: true},
"tpm_limit": {Type: schema.TypeInt, Computed: true},
"rpm_limit": {Type: schema.TypeInt, Computed: true},
"key_count": {Type: schema.TypeInt, Computed: true},
"created_at": {Type: schema.TypeString, Computed: true},
},
},
},
"ids": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "IDs of the users returned for the requested page",
},
"total": {
Type: schema.TypeInt,
Computed: true,
Description: "Total number of users matching the filters",
},
"total_pages": {
Type: schema.TypeInt,
Computed: true,
Description: "Total number of pages available",
},
},
}
}
type userListResponse struct {
Users []map[string]interface{} `json:"users"`
Total int `json:"total"`
TotalPages int `json:"total_pages"`
}
func userListQuery(d *schema.ResourceData) string {
query := url.Values{}
for _, key := range []string{"role", "user_ids", "user_email", "team", "sort_by", "sort_order"} {
if v, ok := d.GetOk(key); ok {
query.Set(key, v.(string))
}
}
query.Set("page", strconv.Itoa(d.Get("page").(int)))
query.Set("page_size", strconv.Itoa(d.Get("page_size").(int)))
return query.Encode()
}
func userListEntry(user map[string]interface{}) map[string]interface{} {
entry := map[string]interface{}{}
for _, key := range []string{"user_id", "user_email", "user_alias", "user_role", "created_at"} {
if v, ok := user[key].(string); ok {
entry[key] = v
}
}
for _, key := range []string{"max_budget", "spend"} {
if v, ok := user[key].(float64); ok {
entry[key] = v
}
}
for _, key := range []string{"tpm_limit", "rpm_limit", "key_count"} {
if v, ok := user[key].(float64); ok {
entry[key] = int(v)
}
}
for _, key := range []string{"teams", "models"} {
if v, ok := user[key].([]interface{}); ok {
entry[key] = v
}
}
return entry
}
func dataSourceLiteLLMUsersRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
query := userListQuery(d)
resp, err := MakeRequest(client, "GET", fmt.Sprintf("%s?%s", endpointUserList, query), nil)
if err != nil {
return fmt.Errorf("failed to list users: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "listing users"); err != nil {
return err
}
var listResp userListResponse
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
return fmt.Errorf("error decoding user list response: %w", err)
}
users := make([]map[string]interface{}, 0, len(listResp.Users))
ids := make([]string, 0, len(listResp.Users))
for _, user := range listResp.Users {
entry := userListEntry(user)
if id, ok := entry["user_id"].(string); ok {
ids = append(ids, id)
}
users = append(users, entry)
}
d.SetId(fmt.Sprintf("users?%s", query))
d.Set("users", users)
d.Set("ids", ids)
d.Set("total", listResp.Total)
d.Set("total_pages", listResp.TotalPages)
return nil
}

View file

@ -0,0 +1,144 @@
package litellm
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func TestDataSourceUserRead_MapsFields(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/user/info" || r.Method != http.MethodGet {
t.Errorf("expected GET /user/info, got %s %s", r.Method, r.URL.Path)
}
if got := r.URL.Query().Get("user_id"); got != "u-ds" {
t.Errorf("expected user_id query 'u-ds', got %q", got)
}
w.Write(userInfoBody("u-ds", map[string]interface{}{
"user_email": "carol@example.com",
"user_role": "internal_user",
"max_budget": 42.0,
"spend": 1.5,
"models": []interface{}{"gpt-4o"},
"model_max_budget": map[string]interface{}{"gpt-4o": map[string]interface{}{"max_budget": 2.0}},
}))
}))
defer srv.Close()
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMUser().Schema, map[string]interface{}{
"user_id": "u-ds",
})
if err := dataSourceLiteLLMUserRead(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("read failed: %v", err)
}
if d.Id() != "u-ds" {
t.Fatalf("expected ID 'u-ds', got %q", d.Id())
}
if got := d.Get("user_email").(string); got != "carol@example.com" {
t.Errorf("expected user_email 'carol@example.com', got %q", got)
}
if got := d.Get("spend").(float64); got != 1.5 {
t.Errorf("expected spend 1.5, got %v", got)
}
models := d.Get("models").([]interface{})
if len(models) != 1 || models[0] != "gpt-4o" {
t.Errorf("expected models [gpt-4o], got %v", models)
}
var mmb map[string]interface{}
if err := json.Unmarshal([]byte(d.Get("model_max_budget").(string)), &mmb); err != nil {
t.Fatalf("model_max_budget in state is not valid JSON: %v", err)
}
if _, ok := mmb["gpt-4o"]; !ok {
t.Errorf("expected gpt-4o key in model_max_budget state, got %v", mmb)
}
}
func TestDataSourceUsersRead_FiltersAndMapsList(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/user/list" || r.Method != http.MethodGet {
t.Errorf("expected GET /user/list, got %s %s", r.Method, r.URL.Path)
}
query := r.URL.Query()
if got := query.Get("role"); got != "internal_user" {
t.Errorf("expected role query 'internal_user', got %q", got)
}
if got := query.Get("page"); got != "2" {
t.Errorf("expected page query '2', got %q", got)
}
if got := query.Get("page_size"); got != "50" {
t.Errorf("expected page_size query '50', got %q", got)
}
body, _ := json.Marshal(map[string]interface{}{
"users": []map[string]interface{}{
{
"user_id": "u-1",
"user_email": "one@example.com",
"user_role": "internal_user",
"max_budget": 10.0,
"spend": 2.0,
"tpm_limit": 100,
"key_count": 3,
},
{
"user_id": "u-2",
"user_email": "two@example.com",
"teams": []string{"team-x"},
},
},
"total": 52,
"page": 2,
"page_size": 50,
"total_pages": 2,
})
w.Write(body)
}))
defer srv.Close()
d := schema.TestResourceDataRaw(t, dataSourceLiteLLMUsers().Schema, map[string]interface{}{
"role": "internal_user",
"page": 2,
"page_size": 50,
})
if err := dataSourceLiteLLMUsersRead(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("read failed: %v", err)
}
users := d.Get("users").([]interface{})
if len(users) != 2 {
t.Fatalf("expected 2 users, got %d", len(users))
}
first := users[0].(map[string]interface{})
if got := first["user_id"].(string); got != "u-1" {
t.Errorf("expected first user_id 'u-1', got %q", got)
}
if got := first["spend"].(float64); got != 2.0 {
t.Errorf("expected first spend 2.0, got %v", got)
}
if got := first["tpm_limit"].(int); got != 100 {
t.Errorf("expected first tpm_limit 100, got %d", got)
}
if got := first["key_count"].(int); got != 3 {
t.Errorf("expected first key_count 3, got %d", got)
}
second := users[1].(map[string]interface{})
teams := second["teams"].([]interface{})
if len(teams) != 1 || teams[0] != "team-x" {
t.Errorf("expected second user teams [team-x], got %v", teams)
}
ids := d.Get("ids").([]interface{})
if len(ids) != 2 || ids[0] != "u-1" || ids[1] != "u-2" {
t.Errorf("expected ids [u-1 u-2], got %v", ids)
}
if got := d.Get("total").(int); got != 52 {
t.Errorf("expected total 52, got %d", got)
}
if got := d.Get("total_pages").(int); got != 2 {
t.Errorf("expected total_pages 2, got %d", got)
}
}

View file

@ -19,10 +19,54 @@ func Provider() *schema.Provider {
"litellm_mcp_server": resourceLiteLLMMCPServer(),
"litellm_credential": resourceLiteLLMCredential(),
"litellm_vector_store": resourceLiteLLMVectorStore(),
"litellm_fallback": resourceLiteLLMFallback(),
"litellm_key_block": resourceLiteLLMKeyBlock(),
"litellm_team_block": resourceLiteLLMTeamBlock(),
"litellm_access_group": resourceLiteLLMAccessGroup(),
"litellm_unified_access_group": resourceLiteLLMUnifiedAccessGroup(),
"litellm_guardrail": resourceLiteLLMGuardrail(),
"litellm_prompt": resourceLiteLLMPrompt(),
"litellm_agent": resourceLiteLLMAgent(),
"litellm_search_tool": resourceLiteLLMSearchTool(),
"litellm_user": resourceLiteLLMUser(),
"litellm_budget": resourceLiteLLMBudget(),
"litellm_tag": resourceLiteLLMTag(),
"litellm_project": resourceLiteLLMProject(),
},
DataSourcesMap: map[string]*schema.Resource{
"litellm_credential": dataSourceLiteLLMCredential(),
"litellm_vector_store": dataSourceLiteLLMVectorStore(),
"litellm_credential": dataSourceLiteLLMCredential(),
"litellm_vector_store": dataSourceLiteLLMVectorStore(),
"litellm_fallback": dataSourceLiteLLMFallback(),
"litellm_access_group": dataSourceLiteLLMAccessGroup(),
"litellm_access_groups": dataSourceLiteLLMAccessGroups(),
"litellm_unified_access_group": dataSourceLiteLLMUnifiedAccessGroup(),
"litellm_unified_access_groups": dataSourceLiteLLMUnifiedAccessGroups(),
"litellm_guardrail": dataSourceLiteLLMGuardrail(),
"litellm_guardrails": dataSourceLiteLLMGuardrails(),
"litellm_prompt": dataSourceLiteLLMPrompt(),
"litellm_prompts": dataSourceLiteLLMPrompts(),
"litellm_agent": dataSourceLiteLLMAgent(),
"litellm_agents": dataSourceLiteLLMAgents(),
"litellm_search_tool": dataSourceLiteLLMSearchTool(),
"litellm_search_tools": dataSourceLiteLLMSearchTools(),
"litellm_user": dataSourceLiteLLMUser(),
"litellm_users": dataSourceLiteLLMUsers(),
"litellm_budget": dataSourceLiteLLMBudget(),
"litellm_budgets": dataSourceLiteLLMBudgets(),
"litellm_tag": dataSourceLiteLLMTag(),
"litellm_tags": dataSourceLiteLLMTags(),
"litellm_project": dataSourceLiteLLMProject(),
"litellm_projects": dataSourceLiteLLMProjects(),
"litellm_key": dataSourceLiteLLMKey(),
"litellm_keys": dataSourceLiteLLMKeys(),
"litellm_team": dataSourceLiteLLMTeam(),
"litellm_teams": dataSourceLiteLLMTeams(),
"litellm_model": dataSourceLiteLLMModel(),
"litellm_models": dataSourceLiteLLMModels(),
"litellm_organization": dataSourceLiteLLMOrganization(),
"litellm_organizations": dataSourceLiteLLMOrganizations(),
"litellm_mcp_server": dataSourceLiteLLMMCPServer(),
"litellm_mcp_servers": dataSourceLiteLLMMCPServers(),
},
Schema: map[string]*schema.Schema{
"api_base": {

View file

@ -0,0 +1,161 @@
package litellm
import (
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
const endpointAccessGroupNew = "/access_group/new"
type accessGroupInfoResponse struct {
AccessGroup string `json:"access_group"`
ModelNames []string `json:"model_names"`
DeploymentCount int `json:"deployment_count"`
}
func resourceLiteLLMAccessGroup() *schema.Resource {
return &schema.Resource{
Create: resourceLiteLLMAccessGroupCreate,
Read: resourceLiteLLMAccessGroupRead,
Update: resourceLiteLLMAccessGroupUpdate,
Delete: resourceLiteLLMAccessGroupDelete,
Importer: &schema.ResourceImporter{StateContext: schema.ImportStatePassthroughContext},
Schema: map[string]*schema.Schema{
"access_group": {
Type: schema.TypeString,
Required: true,
ForceNew: true,
},
"model_names": {
Type: schema.TypeList,
Optional: true,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"model_ids": {
Type: schema.TypeList,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"deployment_count": {
Type: schema.TypeInt,
Computed: true,
},
},
}
}
func buildAccessGroupData(d *schema.ResourceData) map[string]interface{} {
data := map[string]interface{}{}
for _, key := range []string{"model_names", "model_ids"} {
if v, ok := d.GetOk(key); ok {
data[key] = v
}
}
return data
}
func resourceLiteLLMAccessGroupCreate(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
name := d.Get("access_group").(string)
groupData := buildAccessGroupData(d)
groupData["access_group"] = name
log.Printf("[DEBUG] Create access group request payload: %+v", groupData)
resp, err := MakeRequest(client, "POST", endpointAccessGroupNew, groupData)
if err != nil {
return fmt.Errorf("error creating access group: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "creating access group"); err != nil {
return err
}
d.SetId(name)
log.Printf("[INFO] Access group created with name: %s", name)
return resourceLiteLLMAccessGroupRead(d, m)
}
func resourceLiteLLMAccessGroupRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
log.Printf("[INFO] Reading access group: %s", d.Id())
resp, err := MakeRequest(client, "GET", fmt.Sprintf("/access_group/%s/info", d.Id()), nil)
if err != nil {
return fmt.Errorf("error reading access group: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
log.Printf("[WARN] Access group %s not found, removing from state", d.Id())
d.SetId("")
return nil
}
if err := handleResponse(resp, "reading access group"); err != nil {
return err
}
var info accessGroupInfoResponse
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return fmt.Errorf("error decoding access group info response: %w", err)
}
d.Set("access_group", GetStringValue(info.AccessGroup, d.Id()))
d.Set("model_names", info.ModelNames)
d.Set("deployment_count", info.DeploymentCount)
log.Printf("[INFO] Successfully read access group: %s", d.Id())
return nil
}
func resourceLiteLLMAccessGroupUpdate(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
groupData := buildAccessGroupData(d)
log.Printf("[DEBUG] Update access group request payload: %+v", groupData)
resp, err := MakeRequest(client, "PUT", fmt.Sprintf("/access_group/%s/update", d.Id()), groupData)
if err != nil {
return fmt.Errorf("error updating access group: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "updating access group"); err != nil {
return err
}
log.Printf("[INFO] Successfully updated access group: %s", d.Id())
return resourceLiteLLMAccessGroupRead(d, m)
}
func resourceLiteLLMAccessGroupDelete(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
log.Printf("[INFO] Deleting access group: %s", d.Id())
resp, err := MakeRequest(client, "DELETE", fmt.Sprintf("/access_group/%s/delete", d.Id()), nil)
if err != nil {
return fmt.Errorf("error deleting access group: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "deleting access group"); err != nil {
return err
}
log.Printf("[INFO] Successfully deleted access group: %s", d.Id())
d.SetId("")
return nil
}

View file

@ -0,0 +1,185 @@
package litellm
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func accessGroupTestData(t *testing.T, raw map[string]interface{}) *schema.ResourceData {
t.Helper()
return schema.TestResourceDataRaw(t, resourceLiteLLMAccessGroup().Schema, raw)
}
func accessGroupInfoJSON(name string, modelNames []string, deploymentCount int) []byte {
body, _ := json.Marshal(accessGroupInfoResponse{
AccessGroup: name,
ModelNames: modelNames,
DeploymentCount: deploymentCount,
})
return body
}
func TestAccessGroupCreate(t *testing.T) {
var createPayload map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.URL.Path {
case "POST /access_group/new":
if err := json.NewDecoder(r.Body).Decode(&createPayload); err != nil {
t.Errorf("failed to decode create payload: %v", err)
}
w.Write([]byte(`{"access_group": "prod-models", "models_updated": 2}`))
case "GET /access_group/prod-models/info":
w.Write(accessGroupInfoJSON("prod-models", []string{"gpt-4", "claude-3"}, 2))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := accessGroupTestData(t, map[string]interface{}{
"access_group": "prod-models",
"model_names": []interface{}{"gpt-4", "claude-3"},
})
if err := resourceLiteLLMAccessGroupCreate(d, client); err != nil {
t.Fatalf("create failed: %v", err)
}
if createPayload["access_group"] != "prod-models" {
t.Fatalf("expected access_group 'prod-models' in payload, got %v", createPayload["access_group"])
}
wantModels := []interface{}{"gpt-4", "claude-3"}
if !reflect.DeepEqual(createPayload["model_names"], wantModels) {
t.Fatalf("expected model_names %v in payload, got %v", wantModels, createPayload["model_names"])
}
if d.Id() != "prod-models" {
t.Fatalf("expected ID 'prod-models', got %q", d.Id())
}
if d.Get("deployment_count").(int) != 2 {
t.Fatalf("expected deployment_count 2, got %v", d.Get("deployment_count"))
}
}
func TestAccessGroupRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/access_group/prod-models/info" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
w.Write(accessGroupInfoJSON("prod-models", []string{"gpt-4"}, 1))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := accessGroupTestData(t, map[string]interface{}{"access_group": "prod-models"})
d.SetId("prod-models")
if err := resourceLiteLLMAccessGroupRead(d, client); err != nil {
t.Fatalf("read failed: %v", err)
}
if d.Get("access_group").(string) != "prod-models" {
t.Fatalf("expected access_group 'prod-models', got %v", d.Get("access_group"))
}
wantModels := []interface{}{"gpt-4"}
if !reflect.DeepEqual(d.Get("model_names"), wantModels) {
t.Fatalf("expected model_names %v, got %v", wantModels, d.Get("model_names"))
}
if d.Get("deployment_count").(int) != 1 {
t.Fatalf("expected deployment_count 1, got %v", d.Get("deployment_count"))
}
}
func TestAccessGroupReadNotFound(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 := accessGroupTestData(t, map[string]interface{}{"access_group": "gone"})
d.SetId("gone")
if err := resourceLiteLLMAccessGroupRead(d, client); err != nil {
t.Fatalf("expected nil error on 404, got: %v", err)
}
if d.Id() != "" {
t.Fatalf("expected ID to be cleared on 404, got %q", d.Id())
}
}
func TestAccessGroupUpdate(t *testing.T) {
var updatePayload map[string]interface{}
var updatePath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "PUT":
updatePath = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&updatePayload); err != nil {
t.Errorf("failed to decode update payload: %v", err)
}
w.Write([]byte(`{"access_group": "prod-models", "models_updated": 1}`))
case "GET":
w.Write(accessGroupInfoJSON("prod-models", []string{"gpt-4o"}, 1))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := accessGroupTestData(t, map[string]interface{}{
"access_group": "prod-models",
"model_names": []interface{}{"gpt-4o"},
})
d.SetId("prod-models")
if err := resourceLiteLLMAccessGroupUpdate(d, client); err != nil {
t.Fatalf("update failed: %v", err)
}
if updatePath != "/access_group/prod-models/update" {
t.Fatalf("expected update path '/access_group/prod-models/update', got %q", updatePath)
}
wantModels := []interface{}{"gpt-4o"}
if !reflect.DeepEqual(updatePayload["model_names"], wantModels) {
t.Fatalf("expected model_names %v in payload, got %v", wantModels, updatePayload["model_names"])
}
if _, ok := updatePayload["access_group"]; ok {
t.Fatalf("update payload must not include access_group, got %v", updatePayload["access_group"])
}
}
func TestAccessGroupDelete(t *testing.T) {
var deleteMethod, deletePath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
deleteMethod = r.Method
deletePath = r.URL.Path
w.Write([]byte(`{"access_group": "prod-models", "models_updated": 2, "message": "deleted"}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := accessGroupTestData(t, map[string]interface{}{"access_group": "prod-models"})
d.SetId("prod-models")
if err := resourceLiteLLMAccessGroupDelete(d, client); err != nil {
t.Fatalf("delete failed: %v", err)
}
if deleteMethod != "DELETE" || deletePath != "/access_group/prod-models/delete" {
t.Fatalf("expected DELETE /access_group/prod-models/delete, got %s %s", deleteMethod, deletePath)
}
if d.Id() != "" {
t.Fatalf("expected ID to be cleared after delete, got %q", d.Id())
}
}

View file

@ -0,0 +1,320 @@
package litellm
import (
"encoding/json"
"fmt"
"log"
"net/http"
"reflect"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
const (
endpointAgents = "/v1/agents"
endpointAgentByID = "/v1/agents/%s"
)
type agentAPIResponse struct {
AgentID string `json:"agent_id"`
AgentName string `json:"agent_name"`
AgentCardParams map[string]interface{} `json:"agent_card_params"`
ObjectPermission map[string]interface{} `json:"object_permission"`
ExtraHeaders []string `json:"extra_headers"`
TPMLimit *int `json:"tpm_limit"`
RPMLimit *int `json:"rpm_limit"`
SessionTPMLimit *int `json:"session_tpm_limit"`
SessionRPMLimit *int `json:"session_rpm_limit"`
Spend *float64 `json:"spend"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
CreatedBy string `json:"created_by"`
UpdatedBy string `json:"updated_by"`
}
func agentSuppressEquivalentJSON(k, oldValue, newValue string, d *schema.ResourceData) bool {
var oldObj, newObj interface{}
if err := json.Unmarshal([]byte(oldValue), &oldObj); err != nil {
return false
}
if err := json.Unmarshal([]byte(newValue), &newObj); err != nil {
return false
}
return reflect.DeepEqual(oldObj, newObj)
}
func agentParseJSONObject(raw, field string) (map[string]interface{}, error) {
var obj map[string]interface{}
if err := json.Unmarshal([]byte(raw), &obj); err != nil {
return nil, fmt.Errorf("%s must be a JSON object: %w", field, err)
}
return obj, nil
}
func resourceLiteLLMAgent() *schema.Resource {
return &schema.Resource{
Create: resourceLiteLLMAgentCreate,
Read: resourceLiteLLMAgentRead,
Update: resourceLiteLLMAgentUpdate,
Delete: resourceLiteLLMAgentDelete,
Importer: &schema.ResourceImporter{StateContext: schema.ImportStatePassthroughContext},
Schema: map[string]*schema.Schema{
"agent_name": {
Type: schema.TypeString,
Required: true,
Description: "Name of the agent.",
},
"agent_card_params": {
Type: schema.TypeString,
Required: true,
DiffSuppressFunc: agentSuppressEquivalentJSON,
Description: "A2A agent card as a JSON object string (name, description, url, version, " +
"capabilities, skills, ...). The proxy merges in LiteLLM-fronting fields, so the configured " +
"value stays authoritative in state.",
},
"litellm_params": {
Type: schema.TypeString,
Optional: true,
Sensitive: true,
DiffSuppressFunc: agentSuppressEquivalentJSON,
Description: "LiteLLM-specific parameters as a JSON object string (may include model, api_key, ...). " +
"Never read back from the API.",
},
"object_permission": {
Type: schema.TypeString,
Optional: true,
DiffSuppressFunc: agentSuppressEquivalentJSON,
Description: "Access control permissions as a JSON object string " +
"(mcp_servers, mcp_access_groups, mcp_tool_permissions, models, agents).",
},
"static_headers": {
Type: schema.TypeMap,
Optional: true,
Sensitive: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "Static headers sent with agent requests (may hold tokens). Never read back from the API.",
},
"extra_headers": {
Type: schema.TypeList,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "Names of incoming request headers to forward to the agent.",
},
"tpm_limit": {
Type: schema.TypeInt,
Optional: true,
},
"rpm_limit": {
Type: schema.TypeInt,
Optional: true,
},
"session_tpm_limit": {
Type: schema.TypeInt,
Optional: true,
},
"session_rpm_limit": {
Type: schema.TypeInt,
Optional: true,
},
"created_at": {
Type: schema.TypeString,
Computed: true,
},
"updated_at": {
Type: schema.TypeString,
Computed: true,
},
"created_by": {
Type: schema.TypeString,
Computed: true,
},
"updated_by": {
Type: schema.TypeString,
Computed: true,
},
},
}
}
func buildAgentData(d *schema.ResourceData) (map[string]interface{}, error) {
card, err := agentParseJSONObject(d.Get("agent_card_params").(string), "agent_card_params")
if err != nil {
return nil, err
}
agentData := map[string]interface{}{
"agent_name": d.Get("agent_name").(string),
"agent_card_params": card,
}
for _, key := range []string{"litellm_params", "object_permission"} {
raw, ok := d.GetOk(key)
if !ok || raw.(string) == "" {
continue
}
obj, err := agentParseJSONObject(raw.(string), key)
if err != nil {
return nil, err
}
agentData[key] = obj
}
for _, key := range []string{"static_headers", "extra_headers", "tpm_limit", "rpm_limit", "session_tpm_limit", "session_rpm_limit"} {
if v, ok := d.GetOk(key); ok {
agentData[key] = v
}
}
return agentData, nil
}
func resourceLiteLLMAgentCreate(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
agentData, err := buildAgentData(d)
if err != nil {
return err
}
log.Printf("[DEBUG] Create agent request for: %s", d.Get("agent_name").(string))
resp, err := MakeRequest(client, "POST", endpointAgents, agentData)
if err != nil {
return fmt.Errorf("error creating agent: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "creating agent"); err != nil {
return err
}
var agentResp agentAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&agentResp); err != nil {
return fmt.Errorf("error decoding create agent response: %w", err)
}
if agentResp.AgentID == "" {
return fmt.Errorf("create agent response did not contain an agent_id")
}
d.SetId(agentResp.AgentID)
log.Printf("[INFO] Agent created with ID: %s", agentResp.AgentID)
return resourceLiteLLMAgentRead(d, m)
}
func resourceLiteLLMAgentRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
log.Printf("[INFO] Reading agent with ID: %s", d.Id())
resp, err := MakeRequest(client, "GET", fmt.Sprintf(endpointAgentByID, d.Id()), nil)
if err != nil {
return fmt.Errorf("error reading agent: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
log.Printf("[WARN] Agent with ID %s not found, removing from state", d.Id())
d.SetId("")
return nil
}
if err := handleResponse(resp, "reading agent"); err != nil {
return err
}
var agentResp agentAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&agentResp); err != nil {
return fmt.Errorf("error decoding agent info response: %w", err)
}
d.Set("agent_name", agentResp.AgentName)
// The proxy merges LiteLLM-fronting fields into the stored card, so the configured
// JSON stays authoritative; only populate from the API when importing.
if d.Get("agent_card_params").(string) == "" && agentResp.AgentCardParams != nil {
cardJSON, err := json.Marshal(agentResp.AgentCardParams)
if err != nil {
return fmt.Errorf("error encoding agent_card_params: %w", err)
}
d.Set("agent_card_params", string(cardJSON))
}
if d.Get("object_permission").(string) == "" && agentResp.ObjectPermission != nil {
permJSON, err := json.Marshal(agentResp.ObjectPermission)
if err != nil {
return fmt.Errorf("error encoding object_permission: %w", err)
}
d.Set("object_permission", string(permJSON))
}
if agentResp.ExtraHeaders != nil {
d.Set("extra_headers", agentResp.ExtraHeaders)
}
if agentResp.TPMLimit != nil {
d.Set("tpm_limit", *agentResp.TPMLimit)
}
if agentResp.RPMLimit != nil {
d.Set("rpm_limit", *agentResp.RPMLimit)
}
if agentResp.SessionTPMLimit != nil {
d.Set("session_tpm_limit", *agentResp.SessionTPMLimit)
}
if agentResp.SessionRPMLimit != nil {
d.Set("session_rpm_limit", *agentResp.SessionRPMLimit)
}
d.Set("created_at", agentResp.CreatedAt)
d.Set("updated_at", agentResp.UpdatedAt)
d.Set("created_by", agentResp.CreatedBy)
d.Set("updated_by", agentResp.UpdatedBy)
log.Printf("[INFO] Successfully read agent with ID: %s", d.Id())
return nil
}
func resourceLiteLLMAgentUpdate(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
agentData, err := buildAgentData(d)
if err != nil {
return err
}
log.Printf("[DEBUG] Update agent request for ID: %s", d.Id())
resp, err := MakeRequest(client, "PATCH", fmt.Sprintf(endpointAgentByID, d.Id()), agentData)
if err != nil {
return fmt.Errorf("error updating agent: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "updating agent"); err != nil {
return err
}
log.Printf("[INFO] Successfully updated agent with ID: %s", d.Id())
return resourceLiteLLMAgentRead(d, m)
}
func resourceLiteLLMAgentDelete(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
log.Printf("[INFO] Deleting agent with ID: %s", d.Id())
resp, err := MakeRequest(client, "DELETE", fmt.Sprintf(endpointAgentByID, d.Id()), nil)
if err != nil {
return fmt.Errorf("error deleting agent: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
if err := handleResponse(resp, "deleting agent"); err != nil {
return err
}
}
log.Printf("[INFO] Successfully deleted agent with ID: %s", d.Id())
d.SetId("")
return nil
}

View file

@ -0,0 +1,235 @@
package litellm
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
const testAgentCardJSON = `{"name": "Hello Agent", "url": "http://agent.local:9999/", "version": "1.0.0"}`
func newAgentTestResourceData(t *testing.T) *schema.ResourceData {
t.Helper()
return schema.TestResourceDataRaw(t, resourceLiteLLMAgent().Schema, map[string]interface{}{
"agent_name": "my-agent",
"agent_card_params": testAgentCardJSON,
"litellm_params": `{"model": "gpt-5.2", "api_key": "sk-secret"}`,
"extra_headers": []interface{}{"x-request-id"},
"tpm_limit": 1000,
})
}
func agentReadResponseBody() []byte {
body, _ := json.Marshal(map[string]interface{}{
"agent_id": "agent-123",
"agent_name": "my-agent",
"agent_card_params": map[string]interface{}{
"name": "Hello Agent",
"url": "http://agent.local:9999/",
"version": "1.0.0",
"supportedInterfaces": []string{"http://proxy/a2a/agent-123"},
},
"litellm_params": map[string]interface{}{"model": "gpt-5.2", "api_key": "sk-1****"},
"extra_headers": []string{"x-request-id"},
"tpm_limit": 1000,
"spend": 1.5,
"created_at": "2026-01-01T00:00:00",
"updated_at": "2026-01-02T00:00:00",
"created_by": "admin",
"updated_by": "admin",
})
return body
}
func TestResourceLiteLLMAgentCreate(t *testing.T) {
var createPayload map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPost && r.URL.Path == "/v1/agents":
if err := json.NewDecoder(r.Body).Decode(&createPayload); err != nil {
t.Errorf("failed to decode create payload: %v", err)
}
w.Write([]byte(`{"agent_id": "agent-123", "agent_name": "my-agent", "agent_card_params": {}}`))
case r.Method == http.MethodGet && r.URL.Path == "/v1/agents/agent-123":
w.Write(agentReadResponseBody())
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := newAgentTestResourceData(t)
if err := resourceLiteLLMAgentCreate(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if d.Id() != "agent-123" {
t.Fatalf("expected ID 'agent-123', got %q", d.Id())
}
if createPayload["agent_name"] != "my-agent" {
t.Errorf("expected agent_name 'my-agent' in payload, got %v", createPayload["agent_name"])
}
card, ok := createPayload["agent_card_params"].(map[string]interface{})
if !ok || card["url"] != "http://agent.local:9999/" {
t.Errorf("expected agent_card_params sent as JSON object with url, got %v", createPayload["agent_card_params"])
}
params, ok := createPayload["litellm_params"].(map[string]interface{})
if !ok || params["api_key"] != "sk-secret" {
t.Errorf("expected litellm_params sent as JSON object, got %v", createPayload["litellm_params"])
}
if createPayload["tpm_limit"] != float64(1000) {
t.Errorf("expected tpm_limit 1000 in payload, got %v", createPayload["tpm_limit"])
}
if d.Get("created_at").(string) != "2026-01-01T00:00:00" {
t.Errorf("expected created_at from read-back, got %q", d.Get("created_at").(string))
}
if got := d.Get("agent_card_params").(string); got != testAgentCardJSON {
t.Errorf("expected configured agent_card_params to stay authoritative, got %q", got)
}
if got := d.Get("litellm_params").(string); got != `{"model": "gpt-5.2", "api_key": "sk-secret"}` {
t.Errorf("expected litellm_params to keep configured value, got %q", got)
}
}
func TestResourceLiteLLMAgentCreateInvalidCardJSON(t *testing.T) {
d := schema.TestResourceDataRaw(t, resourceLiteLLMAgent().Schema, map[string]interface{}{
"agent_name": "my-agent",
"agent_card_params": "not-json",
})
client := NewClient("http://unused.invalid", "test-key", true)
if err := resourceLiteLLMAgentCreate(d, client); err == nil {
t.Fatal("expected error for invalid agent_card_params JSON, got nil")
}
}
func TestResourceLiteLLMAgentReadPopulatesStateOnImport(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/v1/agents/agent-123" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write(agentReadResponseBody())
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := schema.TestResourceDataRaw(t, resourceLiteLLMAgent().Schema, map[string]interface{}{})
d.SetId("agent-123")
if err := resourceLiteLLMAgentRead(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if d.Get("agent_name").(string) != "my-agent" {
t.Errorf("expected agent_name 'my-agent', got %q", d.Get("agent_name").(string))
}
var card map[string]interface{}
if err := json.Unmarshal([]byte(d.Get("agent_card_params").(string)), &card); err != nil {
t.Fatalf("agent_card_params not populated as JSON on import: %v", err)
}
if card["name"] != "Hello Agent" {
t.Errorf("expected card name 'Hello Agent', got %v", card["name"])
}
if d.Get("tpm_limit").(int) != 1000 {
t.Errorf("expected tpm_limit 1000, got %d", d.Get("tpm_limit").(int))
}
headers := d.Get("extra_headers").([]interface{})
if len(headers) != 1 || headers[0] != "x-request-id" {
t.Errorf("expected extra_headers ['x-request-id'], got %v", headers)
}
if d.Get("litellm_params").(string) != "" {
t.Errorf("expected litellm_params to never be read back, got %q", d.Get("litellm_params").(string))
}
}
func TestResourceLiteLLMAgentRead404ClearsID(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 := newAgentTestResourceData(t)
d.SetId("agent-123")
if err := resourceLiteLLMAgentRead(d, client); err != nil {
t.Fatalf("expected nil error on 404, got: %v", err)
}
if d.Id() != "" {
t.Fatalf("expected ID cleared on 404, got %q", d.Id())
}
}
func TestResourceLiteLLMAgentUpdate(t *testing.T) {
var updateMethod, updatePath string
var updatePayload map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodGet {
w.Write(agentReadResponseBody())
return
}
updateMethod = r.Method
updatePath = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&updatePayload); err != nil {
t.Errorf("failed to decode update payload: %v", err)
}
w.Write([]byte(`{}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := newAgentTestResourceData(t)
d.SetId("agent-123")
if err := resourceLiteLLMAgentUpdate(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if updateMethod != http.MethodPatch {
t.Errorf("expected PATCH, got %s", updateMethod)
}
if updatePath != "/v1/agents/agent-123" {
t.Errorf("expected path '/v1/agents/agent-123', got %q", updatePath)
}
if updatePayload["agent_name"] != "my-agent" {
t.Errorf("expected agent_name in update payload, got %v", updatePayload["agent_name"])
}
if updatePayload["tpm_limit"] != float64(1000) {
t.Errorf("expected tpm_limit 1000 in update payload, got %v", updatePayload["tpm_limit"])
}
}
func TestResourceLiteLLMAgentDelete(t *testing.T) {
var deleteMethod, deletePath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
deleteMethod = r.Method
deletePath = r.URL.Path
w.Write([]byte(`{}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := newAgentTestResourceData(t)
d.SetId("agent-123")
if err := resourceLiteLLMAgentDelete(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if deleteMethod != http.MethodDelete {
t.Errorf("expected DELETE, got %s", deleteMethod)
}
if deletePath != "/v1/agents/agent-123" {
t.Errorf("expected path '/v1/agents/agent-123', got %q", deletePath)
}
if d.Id() != "" {
t.Fatalf("expected ID cleared after delete, got %q", d.Id())
}
}

View file

@ -0,0 +1,287 @@
package litellm
import (
"encoding/json"
"fmt"
"log"
"net/http"
"reflect"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
)
const (
endpointBudgetNew = "/budget/new"
endpointBudgetInfo = "/budget/info"
endpointBudgetUpdate = "/budget/update"
endpointBudgetDelete = "/budget/delete"
)
func budgetSuppressEquivalentJSON(k, oldValue, newValue string, d *schema.ResourceData) bool {
var oldParsed, newParsed interface{}
if err := json.Unmarshal([]byte(oldValue), &oldParsed); err != nil {
return false
}
if err := json.Unmarshal([]byte(newValue), &newParsed); err != nil {
return false
}
return reflect.DeepEqual(oldParsed, newParsed)
}
func resourceLiteLLMBudget() *schema.Resource {
return &schema.Resource{
Create: resourceLiteLLMBudgetCreate,
Read: resourceLiteLLMBudgetRead,
Update: resourceLiteLLMBudgetUpdate,
Delete: resourceLiteLLMBudgetDelete,
Importer: &schema.ResourceImporter{
StateContext: schema.ImportStatePassthroughContext,
},
Schema: map[string]*schema.Schema{
"budget_id": {
Type: schema.TypeString,
Optional: true,
Computed: true,
ForceNew: true,
Description: "Unique ID for the budget. Generated by the server if not provided",
},
"max_budget": {
Type: schema.TypeFloat,
Optional: true,
Description: "Requests fail if this budget in USD is exceeded",
},
"soft_budget": {
Type: schema.TypeFloat,
Optional: true,
Description: "Requests do not fail if this is exceeded, but alerts fire",
},
"max_parallel_requests": {
Type: schema.TypeInt,
Optional: true,
Description: "Maximum concurrent requests allowed for this budget",
},
"tpm_limit": {
Type: schema.TypeInt,
Optional: true,
Description: "Maximum tokens per minute allowed for this budget",
},
"rpm_limit": {
Type: schema.TypeInt,
Optional: true,
Description: "Maximum requests per minute allowed for this budget",
},
"budget_duration": {
Type: schema.TypeString,
Optional: true,
Description: "Budget reset period (e.g. '1hr', '1d', '28d')",
},
"model_max_budget": {
Type: schema.TypeString,
Optional: true,
ValidateFunc: validation.StringIsJSON,
DiffSuppressFunc: budgetSuppressEquivalentJSON,
Description: "JSON string of per-model budget config (e.g. '{\"gpt-4o\": {\"max_budget\": 10.0}}')",
},
"budget_reset_at": {
Type: schema.TypeString,
Computed: true,
Description: "Datetime when the budget is reset",
},
},
}
}
type budgetResponse struct {
BudgetID string `json:"budget_id"`
MaxBudget *float64 `json:"max_budget"`
SoftBudget *float64 `json:"soft_budget"`
MaxParallelRequests *int `json:"max_parallel_requests"`
TPMLimit *int `json:"tpm_limit"`
RPMLimit *int `json:"rpm_limit"`
BudgetDuration *string `json:"budget_duration"`
ModelMaxBudget interface{} `json:"model_max_budget"`
BudgetResetAt *string `json:"budget_reset_at"`
}
func budgetModelMaxBudgetString(v interface{}) (string, bool) {
switch typed := v.(type) {
case string:
return typed, typed != ""
case map[string]interface{}:
if len(typed) == 0 {
return "", false
}
encoded, err := json.Marshal(typed)
return string(encoded), err == nil
}
return "", false
}
func setBudgetState(d *schema.ResourceData, budgetResp budgetResponse) {
if budgetResp.MaxBudget != nil {
d.Set("max_budget", *budgetResp.MaxBudget)
}
if budgetResp.SoftBudget != nil {
d.Set("soft_budget", *budgetResp.SoftBudget)
}
if budgetResp.MaxParallelRequests != nil {
d.Set("max_parallel_requests", *budgetResp.MaxParallelRequests)
}
if budgetResp.TPMLimit != nil {
d.Set("tpm_limit", *budgetResp.TPMLimit)
}
if budgetResp.RPMLimit != nil {
d.Set("rpm_limit", *budgetResp.RPMLimit)
}
if budgetResp.BudgetDuration != nil {
d.Set("budget_duration", *budgetResp.BudgetDuration)
}
if encoded, ok := budgetModelMaxBudgetString(budgetResp.ModelMaxBudget); ok {
d.Set("model_max_budget", encoded)
}
if budgetResp.BudgetResetAt != nil {
d.Set("budget_reset_at", *budgetResp.BudgetResetAt)
}
}
func resourceLiteLLMBudgetCreate(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
budgetData := buildBudgetData(d)
if v, ok := d.GetOk("budget_id"); ok {
budgetData["budget_id"] = v.(string)
}
log.Printf("[DEBUG] Create budget request payload: %+v", budgetData)
resp, err := MakeRequest(client, "POST", endpointBudgetNew, budgetData)
if err != nil {
return fmt.Errorf("error creating budget: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "creating budget"); err != nil {
return err
}
var budgetResp budgetResponse
if err := json.NewDecoder(resp.Body).Decode(&budgetResp); err != nil {
return fmt.Errorf("error decoding create budget response: %w", err)
}
if budgetResp.BudgetID == "" {
return fmt.Errorf("create budget response did not contain a budget_id")
}
d.SetId(budgetResp.BudgetID)
log.Printf("[INFO] Budget created with ID: %s", budgetResp.BudgetID)
return resourceLiteLLMBudgetRead(d, m)
}
func resourceLiteLLMBudgetRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
log.Printf("[INFO] Reading budget with ID: %s", d.Id())
resp, err := MakeRequest(client, "POST", endpointBudgetInfo, map[string]interface{}{
"budgets": []string{d.Id()},
})
if err != nil {
return fmt.Errorf("error reading budget: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
log.Printf("[WARN] Budget with ID %s not found, removing from state", d.Id())
d.SetId("")
return nil
}
if err := handleResponse(resp, "reading budget"); err != nil {
return err
}
var budgetResps []budgetResponse
if err := json.NewDecoder(resp.Body).Decode(&budgetResps); err != nil {
return fmt.Errorf("error decoding budget info response: %w", err)
}
if len(budgetResps) == 0 {
log.Printf("[WARN] Budget with ID %s not found in response, removing from state", d.Id())
d.SetId("")
return nil
}
d.Set("budget_id", d.Id())
setBudgetState(d, budgetResps[0])
log.Printf("[INFO] Successfully read budget with ID: %s", d.Id())
return nil
}
func resourceLiteLLMBudgetUpdate(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
budgetData := buildBudgetData(d)
budgetData["budget_id"] = d.Id()
log.Printf("[DEBUG] Update budget request payload: %+v", budgetData)
resp, err := MakeRequest(client, "POST", endpointBudgetUpdate, budgetData)
if err != nil {
return fmt.Errorf("error updating budget: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "updating budget"); err != nil {
return err
}
log.Printf("[INFO] Successfully updated budget with ID: %s", d.Id())
return resourceLiteLLMBudgetRead(d, m)
}
func resourceLiteLLMBudgetDelete(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
log.Printf("[INFO] Deleting budget with ID: %s", d.Id())
resp, err := MakeRequest(client, "POST", endpointBudgetDelete, map[string]interface{}{
"id": d.Id(),
})
if err != nil {
return fmt.Errorf("error deleting budget: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "deleting budget"); err != nil {
return err
}
log.Printf("[INFO] Successfully deleted budget with ID: %s", d.Id())
d.SetId("")
return nil
}
func buildBudgetData(d *schema.ResourceData) map[string]interface{} {
budgetData := map[string]interface{}{}
for _, key := range []string{
"max_budget", "soft_budget", "max_parallel_requests", "tpm_limit", "rpm_limit", "budget_duration",
} {
if v, ok := d.GetOk(key); ok {
budgetData[key] = v
}
}
if v, ok := d.GetOk("model_max_budget"); ok {
var parsed map[string]interface{}
if err := json.Unmarshal([]byte(v.(string)), &parsed); err == nil {
budgetData["model_max_budget"] = parsed
}
}
return budgetData
}

View file

@ -0,0 +1,268 @@
package litellm
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func budgetInfoBody(budgetID string) []byte {
body, _ := json.Marshal([]map[string]interface{}{{
"budget_id": budgetID,
"max_budget": 100.0,
"soft_budget": 80.0,
"max_parallel_requests": 10,
"tpm_limit": 1000,
"rpm_limit": 60,
"budget_duration": "30d",
"model_max_budget": map[string]interface{}{"gpt-4o": map[string]interface{}{"max_budget": 5.0}},
"budget_reset_at": "2026-09-01T00:00:00Z",
}})
return body
}
func TestResourceBudgetCreate_ServerGeneratedID(t *testing.T) {
var createPayload map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/budget/new":
if r.Method != http.MethodPost {
t.Errorf("expected POST /budget/new, got %s", r.Method)
}
if err := json.NewDecoder(r.Body).Decode(&createPayload); err != nil {
t.Fatalf("failed to decode create payload: %v", err)
}
w.Write([]byte(`{"budget_id": "bud-generated", "max_budget": 100.0}`))
case "/budget/info":
var infoPayload map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&infoPayload); err != nil {
t.Fatalf("failed to decode info payload: %v", err)
}
budgets, ok := infoPayload["budgets"].([]interface{})
if !ok || len(budgets) != 1 || budgets[0] != "bud-generated" {
t.Errorf("expected budgets ['bud-generated'], got %v", infoPayload["budgets"])
}
w.Write(budgetInfoBody("bud-generated"))
default:
t.Errorf("unexpected request to %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
d := schema.TestResourceDataRaw(t, resourceLiteLLMBudget().Schema, map[string]interface{}{
"max_budget": 100.0,
"soft_budget": 80.0,
"tpm_limit": 1000,
"model_max_budget": `{"gpt-4o": {"max_budget": 5.0}}`,
})
if err := resourceLiteLLMBudgetCreate(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("create failed: %v", err)
}
if d.Id() != "bud-generated" {
t.Fatalf("expected ID 'bud-generated', got %q", d.Id())
}
if _, ok := createPayload["budget_id"]; ok {
t.Errorf("budget_id must be omitted when not configured, got %v", createPayload["budget_id"])
}
if got := createPayload["max_budget"]; got != 100.0 {
t.Errorf("expected max_budget 100.0 in payload, got %v", got)
}
if got := createPayload["soft_budget"]; got != 80.0 {
t.Errorf("expected soft_budget 80.0 in payload, got %v", got)
}
mmb, ok := createPayload["model_max_budget"].(map[string]interface{})
if !ok {
t.Fatalf("expected model_max_budget object in payload, got %v", createPayload["model_max_budget"])
}
if _, ok := mmb["gpt-4o"]; !ok {
t.Errorf("expected gpt-4o key in model_max_budget, got %v", mmb)
}
if got := d.Get("budget_reset_at").(string); got != "2026-09-01T00:00:00Z" {
t.Errorf("expected budget_reset_at from read, got %q", got)
}
}
func TestResourceBudgetCreate_ConfiguredID(t *testing.T) {
var createPayload map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/budget/new":
if err := json.NewDecoder(r.Body).Decode(&createPayload); err != nil {
t.Fatalf("failed to decode create payload: %v", err)
}
w.Write([]byte(`{"budget_id": "my-budget"}`))
case "/budget/info":
w.Write(budgetInfoBody("my-budget"))
default:
t.Errorf("unexpected request to %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
d := schema.TestResourceDataRaw(t, resourceLiteLLMBudget().Schema, map[string]interface{}{
"budget_id": "my-budget",
"max_budget": 100.0,
})
if err := resourceLiteLLMBudgetCreate(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("create failed: %v", err)
}
if d.Id() != "my-budget" {
t.Fatalf("expected ID 'my-budget', got %q", d.Id())
}
if got := createPayload["budget_id"]; got != "my-budget" {
t.Errorf("expected budget_id 'my-budget' in payload, got %v", got)
}
}
func TestResourceBudgetRead_MapsFields(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(budgetInfoBody("bud-1"))
}))
defer srv.Close()
d := schema.TestResourceDataRaw(t, resourceLiteLLMBudget().Schema, map[string]interface{}{})
d.SetId("bud-1")
if err := resourceLiteLLMBudgetRead(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("read failed: %v", err)
}
if got := d.Get("max_budget").(float64); got != 100.0 {
t.Errorf("expected max_budget 100.0, got %v", got)
}
if got := d.Get("soft_budget").(float64); got != 80.0 {
t.Errorf("expected soft_budget 80.0, got %v", got)
}
if got := d.Get("max_parallel_requests").(int); got != 10 {
t.Errorf("expected max_parallel_requests 10, got %d", got)
}
if got := d.Get("tpm_limit").(int); got != 1000 {
t.Errorf("expected tpm_limit 1000, got %d", got)
}
if got := d.Get("rpm_limit").(int); got != 60 {
t.Errorf("expected rpm_limit 60, got %d", got)
}
if got := d.Get("budget_duration").(string); got != "30d" {
t.Errorf("expected budget_duration '30d', got %q", got)
}
var mmb map[string]interface{}
if err := json.Unmarshal([]byte(d.Get("model_max_budget").(string)), &mmb); err != nil {
t.Fatalf("model_max_budget in state is not valid JSON: %v", err)
}
if _, ok := mmb["gpt-4o"]; !ok {
t.Errorf("expected gpt-4o key in model_max_budget state, got %v", mmb)
}
}
func TestResourceBudgetRead_EmptyListClearsID(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`[]`))
}))
defer srv.Close()
d := schema.TestResourceDataRaw(t, resourceLiteLLMBudget().Schema, map[string]interface{}{})
d.SetId("gone-budget")
if err := resourceLiteLLMBudgetRead(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("expected nil error on empty response, got: %v", err)
}
if d.Id() != "" {
t.Fatalf("expected ID to be cleared, got %q", d.Id())
}
}
func TestResourceBudgetRead_404ClearsID(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
d := schema.TestResourceDataRaw(t, resourceLiteLLMBudget().Schema, map[string]interface{}{})
d.SetId("gone-budget")
if err := resourceLiteLLMBudgetRead(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("expected nil error on 404, got: %v", err)
}
if d.Id() != "" {
t.Fatalf("expected ID to be cleared on 404, got %q", d.Id())
}
}
func TestResourceBudgetUpdate_SendsPayload(t *testing.T) {
var updatePayload map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/budget/update":
if r.Method != http.MethodPost {
t.Errorf("expected POST /budget/update, got %s", r.Method)
}
if err := json.NewDecoder(r.Body).Decode(&updatePayload); err != nil {
t.Fatalf("failed to decode update payload: %v", err)
}
w.Write([]byte(`{"budget_id": "bud-1"}`))
case "/budget/info":
w.Write(budgetInfoBody("bud-1"))
default:
t.Errorf("unexpected request to %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
d := schema.TestResourceDataRaw(t, resourceLiteLLMBudget().Schema, map[string]interface{}{
"max_budget": 200.0,
"rpm_limit": 120,
})
d.SetId("bud-1")
if err := resourceLiteLLMBudgetUpdate(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("update failed: %v", err)
}
if got := updatePayload["budget_id"]; got != "bud-1" {
t.Errorf("expected budget_id 'bud-1' in payload, got %v", got)
}
if got := updatePayload["max_budget"]; got != 200.0 {
t.Errorf("expected max_budget 200.0 in payload, got %v", got)
}
if got := updatePayload["rpm_limit"]; got != 120.0 {
t.Errorf("expected rpm_limit 120 in payload, got %v", got)
}
}
func TestResourceBudgetDelete_SendsID(t *testing.T) {
var deletePayload map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/budget/delete" || r.Method != http.MethodPost {
t.Errorf("expected POST /budget/delete, got %s %s", r.Method, r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&deletePayload); err != nil {
t.Fatalf("failed to decode delete payload: %v", err)
}
w.Write([]byte(`{}`))
}))
defer srv.Close()
d := schema.TestResourceDataRaw(t, resourceLiteLLMBudget().Schema, map[string]interface{}{})
d.SetId("bud-del")
if err := resourceLiteLLMBudgetDelete(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("delete failed: %v", err)
}
if got := deletePayload["id"]; got != "bud-del" {
t.Fatalf("expected id 'bud-del' in payload, got %v", got)
}
if d.Id() != "" {
t.Fatalf("expected ID to be cleared after delete, got %q", d.Id())
}
}

View file

@ -0,0 +1,155 @@
package litellm
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
)
const endpointFallbackCreate = "/fallback"
type FallbackGetResponse struct {
Model string `json:"model"`
FallbackModels []string `json:"fallback_models"`
FallbackType string `json:"fallback_type"`
}
func resourceLiteLLMFallback() *schema.Resource {
return &schema.Resource{
Create: resourceLiteLLMFallbackCreate,
Read: resourceLiteLLMFallbackRead,
Update: resourceLiteLLMFallbackUpdate,
Delete: resourceLiteLLMFallbackDelete,
Importer: &schema.ResourceImporter{
StateContext: schema.ImportStatePassthroughContext,
},
Schema: map[string]*schema.Schema{
"model": {
Type: schema.TypeString,
Required: true,
ForceNew: true,
Description: "The model name to configure fallbacks for",
},
"fallback_models": {
Type: schema.TypeList,
Required: true,
MinItems: 1,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "List of fallback model names in order of priority",
},
"fallback_type": {
Type: schema.TypeString,
Optional: true,
ForceNew: true,
Default: "general",
ValidateFunc: validation.StringInSlice([]string{"general", "context_window", "content_policy"}, false),
Description: "Type of fallback: 'general' (default), 'context_window', or 'content_policy'",
},
},
}
}
func fallbackTypeFromState(d *schema.ResourceData) string {
return GetStringValue(d.Get("fallback_type").(string), "general")
}
func buildFallbackData(d *schema.ResourceData) map[string]interface{} {
return map[string]interface{}{
"model": d.Get("model").(string),
"fallback_models": d.Get("fallback_models"),
"fallback_type": fallbackTypeFromState(d),
}
}
func upsertLiteLLMFallback(d *schema.ResourceData, m interface{}, action string) error {
client := m.(*Client)
fallbackData := buildFallbackData(d)
log.Printf("[DEBUG] %s fallback request payload: %+v", action, fallbackData)
resp, err := MakeRequest(client, "POST", endpointFallbackCreate, fallbackData)
if err != nil {
return fmt.Errorf("error %s fallback: %w", action, err)
}
defer resp.Body.Close()
if err := handleResponse(resp, action+" fallback"); err != nil {
return err
}
d.SetId(d.Get("model").(string))
return resourceLiteLLMFallbackRead(d, m)
}
func resourceLiteLLMFallbackCreate(d *schema.ResourceData, m interface{}) error {
return upsertLiteLLMFallback(d, m, "creating")
}
func resourceLiteLLMFallbackRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
log.Printf("[INFO] Reading fallback for model: %s", d.Id())
endpoint := fmt.Sprintf("/fallback/%s?fallback_type=%s",
url.PathEscape(d.Id()), url.QueryEscape(fallbackTypeFromState(d)))
resp, err := MakeRequest(client, "GET", endpoint, nil)
if err != nil {
return fmt.Errorf("error reading fallback: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
log.Printf("[WARN] Fallback for model %s not found, removing from state", d.Id())
d.SetId("")
return nil
}
if err := handleResponse(resp, "reading fallback"); err != nil {
return err
}
var fallbackResp FallbackGetResponse
if err := json.NewDecoder(resp.Body).Decode(&fallbackResp); err != nil {
return fmt.Errorf("error decoding fallback response: %w", err)
}
d.Set("model", GetStringValue(fallbackResp.Model, d.Id()))
d.Set("fallback_models", fallbackResp.FallbackModels)
d.Set("fallback_type", GetStringValue(fallbackResp.FallbackType, fallbackTypeFromState(d)))
return nil
}
func resourceLiteLLMFallbackUpdate(d *schema.ResourceData, m interface{}) error {
return upsertLiteLLMFallback(d, m, "updating")
}
func resourceLiteLLMFallbackDelete(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
log.Printf("[INFO] Deleting fallback for model: %s", d.Id())
endpoint := fmt.Sprintf("/fallback/%s?fallback_type=%s",
url.PathEscape(d.Id()), url.QueryEscape(fallbackTypeFromState(d)))
resp, err := MakeRequest(client, "DELETE", endpoint, nil)
if err != nil {
return fmt.Errorf("error deleting fallback: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
if err := handleResponse(resp, "deleting fallback"); err != nil {
return err
}
}
d.SetId("")
return nil
}

View file

@ -0,0 +1,180 @@
package litellm
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func newFallbackTestResourceData(t *testing.T, model string, fallbackModels []interface{}, fallbackType string) *schema.ResourceData {
t.Helper()
d := schema.TestResourceDataRaw(t, resourceLiteLLMFallback().Schema, map[string]interface{}{
"model": model,
"fallback_models": fallbackModels,
"fallback_type": fallbackType,
})
return d
}
func fallbackGetHandler(t *testing.T, wantPath string, resp FallbackGetResponse) http.HandlerFunc {
t.Helper()
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("expected GET, got %s", r.Method)
}
if r.URL.Path != wantPath {
t.Errorf("expected path %s, got %s", wantPath, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
}
func TestResourceLiteLLMFallbackCreate(t *testing.T) {
var createPayload map[string]interface{}
mux := http.NewServeMux()
mux.HandleFunc("/fallback", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if err := json.NewDecoder(r.Body).Decode(&createPayload); err != nil {
t.Fatalf("failed to decode create payload: %v", err)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"model":"gpt-4","fallback_models":["claude-3","gpt-3.5-turbo"],"fallback_type":"general","message":"ok"}`))
})
mux.Handle("/fallback/gpt-4", fallbackGetHandler(t, "/fallback/gpt-4", FallbackGetResponse{
Model: "gpt-4",
FallbackModels: []string{"claude-3", "gpt-3.5-turbo"},
FallbackType: "general",
}))
srv := httptest.NewServer(mux)
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := newFallbackTestResourceData(t, "gpt-4", []interface{}{"claude-3", "gpt-3.5-turbo"}, "general")
if err := resourceLiteLLMFallbackCreate(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if d.Id() != "gpt-4" {
t.Fatalf("expected ID 'gpt-4', got %q", d.Id())
}
want := map[string]interface{}{
"model": "gpt-4",
"fallback_models": []interface{}{"claude-3", "gpt-3.5-turbo"},
"fallback_type": "general",
}
if !reflect.DeepEqual(createPayload, want) {
t.Fatalf("unexpected create payload: %+v, want %+v", createPayload, want)
}
}
func TestResourceLiteLLMFallbackRead_MapsFields(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/fallback/gpt-4" {
t.Errorf("expected path /fallback/gpt-4, got %s", r.URL.Path)
}
if got := r.URL.Query().Get("fallback_type"); got != "context_window" {
t.Errorf("expected fallback_type query 'context_window', got %q", got)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"model":"gpt-4","fallback_models":["claude-3"],"fallback_type":"context_window"}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := newFallbackTestResourceData(t, "gpt-4", []interface{}{"stale-model"}, "context_window")
d.SetId("gpt-4")
if err := resourceLiteLLMFallbackRead(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
got := d.Get("fallback_models").([]interface{})
if !reflect.DeepEqual(got, []interface{}{"claude-3"}) {
t.Fatalf("expected fallback_models [claude-3], got %+v", got)
}
if d.Get("fallback_type").(string) != "context_window" {
t.Fatalf("expected fallback_type 'context_window', got %q", d.Get("fallback_type"))
}
if d.Get("model").(string) != "gpt-4" {
t.Fatalf("expected model 'gpt-4', got %q", d.Get("model"))
}
}
func TestResourceLiteLLMFallbackRead_404ClearsID(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 := newFallbackTestResourceData(t, "gpt-4", []interface{}{"claude-3"}, "general")
d.SetId("gpt-4")
if err := resourceLiteLLMFallbackRead(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if d.Id() != "" {
t.Fatalf("expected ID cleared on 404, got %q", d.Id())
}
}
func TestResourceLiteLLMFallbackUpdate_SendsChangedModels(t *testing.T) {
var updatePayload map[string]interface{}
mux := http.NewServeMux()
mux.HandleFunc("/fallback", func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&updatePayload)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"model":"gpt-4","fallback_models":["new-model"],"fallback_type":"general","message":"ok"}`))
})
mux.Handle("/fallback/gpt-4", fallbackGetHandler(t, "/fallback/gpt-4", FallbackGetResponse{
Model: "gpt-4",
FallbackModels: []string{"new-model"},
FallbackType: "general",
}))
srv := httptest.NewServer(mux)
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := newFallbackTestResourceData(t, "gpt-4", []interface{}{"new-model"}, "general")
d.SetId("gpt-4")
if err := resourceLiteLLMFallbackUpdate(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if !reflect.DeepEqual(updatePayload["fallback_models"], []interface{}{"new-model"}) {
t.Fatalf("expected updated fallback_models [new-model], got %+v", updatePayload["fallback_models"])
}
}
func TestResourceLiteLLMFallbackDelete(t *testing.T) {
var gotMethod, gotPath, gotType string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
gotType = r.URL.Query().Get("fallback_type")
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"model":"gpt-4","fallback_type":"general","message":"deleted"}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := newFallbackTestResourceData(t, "gpt-4", []interface{}{"claude-3"}, "general")
d.SetId("gpt-4")
if err := resourceLiteLLMFallbackDelete(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if gotMethod != http.MethodDelete || gotPath != "/fallback/gpt-4" || gotType != "general" {
t.Fatalf("expected DELETE /fallback/gpt-4?fallback_type=general, got %s %s?fallback_type=%s",
gotMethod, gotPath, gotType)
}
if d.Id() != "" {
t.Fatalf("expected ID cleared after delete, got %q", d.Id())
}
}

View file

@ -0,0 +1,255 @@
package litellm
import (
"encoding/json"
"fmt"
"log"
"net/http"
"reflect"
"strings"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
const (
endpointGuardrailCreate = "/guardrails"
endpointGuardrailByID = "/guardrails/%s"
endpointGuardrailInfo = "/guardrails/%s/info"
)
func resourceLiteLLMGuardrail() *schema.Resource {
return &schema.Resource{
Create: resourceLiteLLMGuardrailCreate,
Read: resourceLiteLLMGuardrailRead,
Update: resourceLiteLLMGuardrailUpdate,
Delete: resourceLiteLLMGuardrailDelete,
Importer: &schema.ResourceImporter{StateContext: schema.ImportStatePassthroughContext},
Schema: map[string]*schema.Schema{
"guardrail_name": {
Type: schema.TypeString,
Required: true,
Description: "Human-readable name for the guardrail",
},
"guardrail": {
Type: schema.TypeString,
Required: true,
Description: "The guardrail integration type (e.g. 'bedrock', 'lakera', 'presidio', 'hide_secrets')",
},
"mode": {
Type: schema.TypeString,
Required: true,
Description: "When to apply the guardrail: a single value ('pre_call', 'post_call', 'during_call', " +
"'logging_only') or a JSON array of values (e.g. '[\"pre_call\", \"post_call\"]')",
},
"default_on": {
Type: schema.TypeBool,
Optional: true,
Description: "Whether the guardrail is enabled by default for all requests",
},
"litellm_params": {
Type: schema.TypeString,
Optional: true,
Sensitive: true,
DiffSuppressFunc: guardrailSuppressJSONDiff,
Description: "JSON string with additional provider-specific litellm_params (may contain API keys)",
},
"guardrail_info": {
Type: schema.TypeMap,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "Additional metadata for the guardrail",
},
"created_at": {
Type: schema.TypeString,
Computed: true,
},
},
}
}
func guardrailSuppressJSONDiff(k, oldValue, newValue string, d *schema.ResourceData) bool {
var oldParsed, newParsed interface{}
if json.Unmarshal([]byte(oldValue), &oldParsed) != nil || json.Unmarshal([]byte(newValue), &newParsed) != nil {
return false
}
return reflect.DeepEqual(oldParsed, newParsed)
}
func guardrailParseMode(mode string) interface{} {
if strings.HasPrefix(strings.TrimSpace(mode), "[") {
var modes []string
if err := json.Unmarshal([]byte(mode), &modes); err == nil {
return modes
}
}
return mode
}
func buildGuardrailData(d *schema.ResourceData, guardrailID string) (map[string]interface{}, error) {
litellmParams := map[string]interface{}{
"guardrail": d.Get("guardrail").(string),
"mode": guardrailParseMode(d.Get("mode").(string)),
"default_on": d.Get("default_on").(bool),
}
if raw := d.Get("litellm_params").(string); raw != "" {
var extra map[string]interface{}
if err := json.Unmarshal([]byte(raw), &extra); err != nil {
return nil, fmt.Errorf("litellm_params is not valid JSON: %w", err)
}
for k, v := range extra {
litellmParams[k] = v
}
}
guardrail := map[string]interface{}{
"guardrail_name": d.Get("guardrail_name").(string),
"litellm_params": litellmParams,
}
if guardrailID != "" {
guardrail["guardrail_id"] = guardrailID
}
if v, ok := d.GetOk("guardrail_info"); ok {
guardrail["guardrail_info"] = v
}
return map[string]interface{}{"guardrail": guardrail}, nil
}
type guardrailInfoAPIResponse struct {
GuardrailID string `json:"guardrail_id"`
GuardrailName string `json:"guardrail_name"`
GuardrailInfo map[string]interface{} `json:"guardrail_info"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func resourceLiteLLMGuardrailCreate(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
guardrailData, err := buildGuardrailData(d, "")
if err != nil {
return err
}
log.Printf("[DEBUG] Create guardrail request for: %s", d.Get("guardrail_name").(string))
resp, err := MakeRequest(client, "POST", endpointGuardrailCreate, guardrailData)
if err != nil {
return fmt.Errorf("error creating guardrail: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "creating guardrail"); err != nil {
return err
}
var created guardrailInfoAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
return fmt.Errorf("error decoding create guardrail response: %w", err)
}
if created.GuardrailID == "" {
return fmt.Errorf("create guardrail response did not contain a guardrail_id")
}
d.SetId(created.GuardrailID)
log.Printf("[INFO] Guardrail created with ID: %s", created.GuardrailID)
return resourceLiteLLMGuardrailRead(d, m)
}
func resourceLiteLLMGuardrailRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
log.Printf("[INFO] Reading guardrail with ID: %s", d.Id())
resp, err := MakeRequest(client, "GET", fmt.Sprintf(endpointGuardrailInfo, d.Id()), nil)
if err != nil {
return fmt.Errorf("error reading guardrail: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
log.Printf("[WARN] Guardrail with ID %s not found, removing from state", d.Id())
d.SetId("")
return nil
}
if err := handleResponse(resp, "reading guardrail"); err != nil {
return err
}
var info guardrailInfoAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return fmt.Errorf("error decoding guardrail info response: %w", err)
}
d.Set("guardrail_name", info.GuardrailName)
d.Set("created_at", info.CreatedAt)
if len(info.GuardrailInfo) > 0 {
d.Set("guardrail_info", guardrailInfoToStringMap(info.GuardrailInfo))
}
// guardrail, mode, default_on and litellm_params are intentionally not read
// back: the API masks litellm_params values, so state keeps the configured
// values authoritative.
return nil
}
func resourceLiteLLMGuardrailUpdate(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
guardrailData, err := buildGuardrailData(d, d.Id())
if err != nil {
return err
}
log.Printf("[DEBUG] Update guardrail request for ID: %s", d.Id())
resp, err := MakeRequest(client, "PUT", fmt.Sprintf(endpointGuardrailByID, d.Id()), guardrailData)
if err != nil {
return fmt.Errorf("error updating guardrail: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "updating guardrail"); err != nil {
return err
}
log.Printf("[INFO] Successfully updated guardrail with ID: %s", d.Id())
return resourceLiteLLMGuardrailRead(d, m)
}
func resourceLiteLLMGuardrailDelete(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
log.Printf("[INFO] Deleting guardrail with ID: %s", d.Id())
resp, err := MakeRequest(client, "DELETE", fmt.Sprintf(endpointGuardrailByID, d.Id()), nil)
if err != nil {
return fmt.Errorf("error deleting guardrail: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
if err := handleResponse(resp, "deleting guardrail"); err != nil {
return err
}
}
log.Printf("[INFO] Successfully deleted guardrail with ID: %s", d.Id())
d.SetId("")
return nil
}
func guardrailInfoToStringMap(info map[string]interface{}) map[string]string {
result := make(map[string]string, len(info))
for k, v := range info {
result[k] = fmt.Sprintf("%v", v)
}
return result
}

View file

@ -0,0 +1,271 @@
package litellm
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func newGuardrailTestData(t *testing.T, raw map[string]interface{}) *schema.ResourceData {
t.Helper()
return schema.TestResourceDataRaw(t, resourceLiteLLMGuardrail().Schema, raw)
}
func guardrailInfoJSON(id, name string) string {
body, _ := json.Marshal(map[string]interface{}{
"guardrail_id": id,
"guardrail_name": name,
"guardrail_info": map[string]interface{}{"description": "test guardrail"},
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-02T00:00:00Z",
})
return string(body)
}
func TestGuardrailCreate_SendsPayloadAndSetsID(t *testing.T) {
var createPayload map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == "POST" && r.URL.Path == "/guardrails":
if err := json.NewDecoder(r.Body).Decode(&createPayload); err != nil {
t.Errorf("failed to decode create payload: %v", err)
}
w.Write([]byte(guardrailInfoJSON("gid-123", "guard1")))
case r.Method == "GET" && r.URL.Path == "/guardrails/gid-123/info":
w.Write([]byte(guardrailInfoJSON("gid-123", "guard1")))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := newGuardrailTestData(t, map[string]interface{}{
"guardrail_name": "guard1",
"guardrail": "bedrock",
"mode": "pre_call",
"default_on": true,
"litellm_params": `{"api_key": "sk-123", "guardrailIdentifier": "abc"}`,
"guardrail_info": map[string]interface{}{"description": "test guardrail"},
})
if err := resourceLiteLLMGuardrailCreate(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if d.Id() != "gid-123" {
t.Fatalf("expected ID 'gid-123', got %q", d.Id())
}
guardrail, ok := createPayload["guardrail"].(map[string]interface{})
if !ok {
t.Fatalf("expected payload wrapped in 'guardrail' key, got: %v", createPayload)
}
if guardrail["guardrail_name"] != "guard1" {
t.Errorf("expected guardrail_name 'guard1', got %v", guardrail["guardrail_name"])
}
params, ok := guardrail["litellm_params"].(map[string]interface{})
if !ok {
t.Fatalf("expected litellm_params object, got: %v", guardrail["litellm_params"])
}
if params["guardrail"] != "bedrock" || params["mode"] != "pre_call" || params["default_on"] != true {
t.Errorf("unexpected base litellm_params: %v", params)
}
if params["api_key"] != "sk-123" || params["guardrailIdentifier"] != "abc" {
t.Errorf("expected merged extra litellm_params, got: %v", params)
}
info, ok := guardrail["guardrail_info"].(map[string]interface{})
if !ok || info["description"] != "test guardrail" {
t.Errorf("expected guardrail_info to be sent, got: %v", guardrail["guardrail_info"])
}
}
func TestGuardrailCreate_ModeJSONArray(t *testing.T) {
var createPayload map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == "POST" {
json.NewDecoder(r.Body).Decode(&createPayload)
}
w.Write([]byte(guardrailInfoJSON("gid-456", "guard2")))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := newGuardrailTestData(t, map[string]interface{}{
"guardrail_name": "guard2",
"guardrail": "lakera",
"mode": `["pre_call", "post_call"]`,
})
if err := resourceLiteLLMGuardrailCreate(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
params := createPayload["guardrail"].(map[string]interface{})["litellm_params"].(map[string]interface{})
mode, ok := params["mode"].([]interface{})
if !ok {
t.Fatalf("expected mode to be a JSON array, got: %v", params["mode"])
}
if !reflect.DeepEqual(mode, []interface{}{"pre_call", "post_call"}) {
t.Errorf("unexpected mode array: %v", mode)
}
}
func TestGuardrailCreate_InvalidLitellmParamsJSON(t *testing.T) {
client := NewClient("http://unused.invalid", "test-key", true)
d := newGuardrailTestData(t, map[string]interface{}{
"guardrail_name": "guard1",
"guardrail": "bedrock",
"mode": "pre_call",
"litellm_params": "{not json",
})
if err := resourceLiteLLMGuardrailCreate(d, client); err == nil {
t.Fatal("expected error for invalid litellm_params JSON, got nil")
}
}
func TestGuardrailRead_MapsFieldsAndKeepsConfiguredParams(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/guardrails/gid-1/info" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(guardrailInfoJSON("gid-1", "renamed-guard")))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := newGuardrailTestData(t, map[string]interface{}{
"guardrail_name": "old-name",
"guardrail": "bedrock",
"mode": "pre_call",
"litellm_params": `{"api_key": "sk-123"}`,
})
d.SetId("gid-1")
if err := resourceLiteLLMGuardrailRead(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if got := d.Get("guardrail_name").(string); got != "renamed-guard" {
t.Errorf("expected guardrail_name 'renamed-guard', got %q", got)
}
if got := d.Get("created_at").(string); got != "2026-01-01T00:00:00Z" {
t.Errorf("expected created_at to be set, got %q", got)
}
if got := d.Get("litellm_params").(string); got != `{"api_key": "sk-123"}` {
t.Errorf("expected configured litellm_params to stay authoritative, got %q", got)
}
info := d.Get("guardrail_info").(map[string]interface{})
if info["description"] != "test guardrail" {
t.Errorf("expected guardrail_info from API, got: %v", info)
}
}
func TestGuardrailRead_404ClearsID(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 := newGuardrailTestData(t, map[string]interface{}{
"guardrail_name": "guard1",
"guardrail": "bedrock",
"mode": "pre_call",
})
d.SetId("gid-gone")
if err := resourceLiteLLMGuardrailRead(d, client); err != nil {
t.Fatalf("expected nil error on 404, got: %v", err)
}
if d.Id() != "" {
t.Fatalf("expected ID to be cleared on 404, got %q", d.Id())
}
}
func TestGuardrailUpdate_SendsPUTToGuardrailEndpoint(t *testing.T) {
var updateMethod, updatePath string
var updatePayload map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == "PUT" {
updateMethod, updatePath = r.Method, r.URL.Path
json.NewDecoder(r.Body).Decode(&updatePayload)
}
w.Write([]byte(guardrailInfoJSON("gid-1", "new-name")))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := newGuardrailTestData(t, map[string]interface{}{
"guardrail_name": "new-name",
"guardrail": "bedrock",
"mode": "post_call",
})
d.SetId("gid-1")
if err := resourceLiteLLMGuardrailUpdate(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if updateMethod != "PUT" || updatePath != "/guardrails/gid-1" {
t.Fatalf("expected PUT /guardrails/gid-1, got %s %s", updateMethod, updatePath)
}
guardrail := updatePayload["guardrail"].(map[string]interface{})
if guardrail["guardrail_name"] != "new-name" {
t.Errorf("expected updated guardrail_name, got %v", guardrail["guardrail_name"])
}
if guardrail["guardrail_id"] != "gid-1" {
t.Errorf("expected guardrail_id in update payload, got %v", guardrail["guardrail_id"])
}
params := guardrail["litellm_params"].(map[string]interface{})
if params["mode"] != "post_call" {
t.Errorf("expected updated mode 'post_call', got %v", params["mode"])
}
}
func TestGuardrailDelete_CallsDeleteEndpoint(t *testing.T) {
var deleteMethod, deletePath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
deleteMethod, deletePath = r.Method, r.URL.Path
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"message": "deleted"}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := newGuardrailTestData(t, map[string]interface{}{
"guardrail_name": "guard1",
"guardrail": "bedrock",
"mode": "pre_call",
})
d.SetId("gid-1")
if err := resourceLiteLLMGuardrailDelete(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if deleteMethod != "DELETE" || deletePath != "/guardrails/gid-1" {
t.Fatalf("expected DELETE /guardrails/gid-1, got %s %s", deleteMethod, deletePath)
}
if d.Id() != "" {
t.Fatalf("expected ID to be cleared after delete, got %q", d.Id())
}
}
func TestGuardrailSuppressJSONDiff(t *testing.T) {
if !guardrailSuppressJSONDiff("", `{"a": 1, "b": "x"}`, `{"b":"x","a":1}`, nil) {
t.Error("expected semantically equal JSON to be suppressed")
}
if guardrailSuppressJSONDiff("", `{"a": 1}`, `{"a": 2}`, nil) {
t.Error("expected different JSON not to be suppressed")
}
if guardrailSuppressJSONDiff("", "", `{"a": 1}`, nil) {
t.Error("expected empty old value not to be suppressed")
}
}

View file

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"github.com/hashicorp/go-cty/cty"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
@ -136,6 +137,49 @@ func resourceKey() *schema.Resource {
Type: schema.TypeFloat,
Computed: true,
},
"budget_id": {
Type: schema.TypeString,
Optional: true,
},
"enforced_params": {
Type: schema.TypeList,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"allowed_routes": {
Type: schema.TypeList,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"allowed_passthrough_routes": {
Type: schema.TypeList,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"rpm_limit_type": {
Type: schema.TypeString,
Optional: true,
Description: "One of 'guaranteed_throughput', 'best_effort_throughput' or 'dynamic'",
},
"tpm_limit_type": {
Type: schema.TypeString,
Optional: true,
Description: "One of 'guaranteed_throughput', 'best_effort_throughput' or 'dynamic'",
},
"prompts": {
Type: schema.TypeList,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"organization_id": {
Type: schema.TypeString,
Optional: true,
},
"project_id": {
Type: schema.TypeString,
Optional: true,
ForceNew: true,
},
},
}
}
@ -145,6 +189,14 @@ func resourceKeyCreate(ctx context.Context, d *schema.ResourceData, m interface{
key := &Key{}
mapResourceDataToKey(d, key)
// A config-supplied key value becomes the key itself; when absent the
// proxy generates one. Write-only attributes are invisible to d.Get in
// real Terraform runs, so read the raw config first.
if raw, err := d.GetRawConfigAt(cty.GetAttrPath("key")); err == nil && !raw.IsNull() && raw.Type() == cty.String && raw.AsString() != "" {
key.Key = raw.AsString()
} else if v := d.Get("key").(string); v != "" {
key.Key = v
}
createdKey, err := c.CreateKey(key)
if err != nil {
@ -239,6 +291,15 @@ func mapResourceDataToKey(d *schema.ResourceData, key *Key) {
key.Guardrails = expandStringList(d.Get("guardrails").([]interface{}))
key.Blocked = d.Get("blocked").(bool)
key.Tags = expandStringList(d.Get("tags").([]interface{}))
key.BudgetID = d.Get("budget_id").(string)
key.EnforcedParams = expandStringList(d.Get("enforced_params").([]interface{}))
key.AllowedRoutes = expandStringList(d.Get("allowed_routes").([]interface{}))
key.AllowedPassthroughRoutes = expandStringList(d.Get("allowed_passthrough_routes").([]interface{}))
key.RPMLimitType = d.Get("rpm_limit_type").(string)
key.TPMLimitType = d.Get("tpm_limit_type").(string)
key.Prompts = expandStringList(d.Get("prompts").([]interface{}))
key.OrganizationID = d.Get("organization_id").(string)
key.ProjectID = d.Get("project_id").(string)
}
func mapKeyToResourceData(d *schema.ResourceData, key *Key) {
@ -316,4 +377,31 @@ func mapKeyToResourceData(d *schema.ResourceData, key *Key) {
if key.Spend != 0 {
d.Set("spend", key.Spend)
}
if key.BudgetID != "" {
d.Set("budget_id", key.BudgetID)
}
if len(key.EnforcedParams) > 0 {
d.Set("enforced_params", key.EnforcedParams)
}
if len(key.AllowedRoutes) > 0 {
d.Set("allowed_routes", key.AllowedRoutes)
}
if len(key.AllowedPassthroughRoutes) > 0 {
d.Set("allowed_passthrough_routes", key.AllowedPassthroughRoutes)
}
if key.RPMLimitType != "" {
d.Set("rpm_limit_type", key.RPMLimitType)
}
if key.TPMLimitType != "" {
d.Set("tpm_limit_type", key.TPMLimitType)
}
if len(key.Prompts) > 0 {
d.Set("prompts", key.Prompts)
}
if key.OrganizationID != "" {
d.Set("organization_id", key.OrganizationID)
}
if key.ProjectID != "" {
d.Set("project_id", key.ProjectID)
}
}

View file

@ -0,0 +1,135 @@
package litellm
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
const (
endpointKeyBlock = "/key/block"
endpointKeyUnblock = "/key/unblock"
)
type KeyBlockInfoResponse struct {
Info struct {
Blocked *bool `json:"blocked"`
} `json:"info"`
}
func resourceLiteLLMKeyBlock() *schema.Resource {
return &schema.Resource{
Create: resourceLiteLLMKeyBlockCreate,
Read: resourceLiteLLMKeyBlockRead,
Delete: resourceLiteLLMKeyBlockDelete,
Importer: &schema.ResourceImporter{
StateContext: schema.ImportStatePassthroughContext,
},
Schema: map[string]*schema.Schema{
"key": {
Type: schema.TypeString,
Required: true,
ForceNew: true,
Sensitive: true,
Description: "The API key to block, as the raw sk- value or its SHA-256 token hash. Destroying this resource unblocks the key",
DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool {
return old != "" && hashedKeyToken(old) == hashedKeyToken(new)
},
},
"blocked": {
Type: schema.TypeBool,
Computed: true,
Description: "Whether the key is currently blocked",
},
},
}
}
func resourceLiteLLMKeyBlockCreate(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
// Block by the SHA-256 token hash so the raw key never appears in the
// request, the resource ID, or Terraform plan output.
token := hashedKeyToken(d.Get("key").(string))
log.Printf("[INFO] Blocking key")
resp, err := MakeRequest(client, "POST", endpointKeyBlock, map[string]interface{}{"key": token})
if err != nil {
return fmt.Errorf("error blocking key: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "blocking key"); err != nil {
return err
}
d.SetId(token)
return resourceLiteLLMKeyBlockRead(d, m)
}
func resourceLiteLLMKeyBlockRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
key := d.Id()
resp, err := MakeRequest(client, "GET", fmt.Sprintf("/key/info?key=%s", url.QueryEscape(key)), nil)
if err != nil {
return fmt.Errorf("error reading key info: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
log.Printf("[WARN] Key not found, removing key block from state")
d.SetId("")
return nil
}
if err := handleResponse(resp, "reading key info"); err != nil {
return err
}
var infoResp KeyBlockInfoResponse
if err := json.NewDecoder(resp.Body).Decode(&infoResp); err != nil {
return fmt.Errorf("error decoding key info response: %w", err)
}
if infoResp.Info.Blocked == nil || !*infoResp.Info.Blocked {
log.Printf("[WARN] Key is no longer blocked, removing key block from state")
d.SetId("")
return nil
}
// Keep the configured key value; only fill it from the hashed ID when
// importing, where no configured value exists yet.
if _, ok := d.GetOk("key"); !ok {
d.Set("key", key)
}
d.Set("blocked", true)
return nil
}
func resourceLiteLLMKeyBlockDelete(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
log.Printf("[INFO] Unblocking key")
resp, err := MakeRequest(client, "POST", endpointKeyUnblock, map[string]interface{}{"key": d.Id()})
if err != nil {
return fmt.Errorf("error unblocking key: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
if err := handleResponse(resp, "unblocking key"); err != nil {
return err
}
}
d.SetId("")
return nil
}

View file

@ -0,0 +1,160 @@
package litellm
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
// SHA-256 of "sk-test-123", the token hash the proxy stores for that key.
const keyBlockTestHash = "e0dbaa0c6455768bf812d8345ec96a2677d1e3bf17dbb0020b115c80092811e6"
func newKeyBlockTestResourceData(t *testing.T, key string) *schema.ResourceData {
t.Helper()
return schema.TestResourceDataRaw(t, resourceLiteLLMKeyBlock().Schema, map[string]interface{}{
"key": key,
})
}
func TestResourceLiteLLMKeyBlockCreate(t *testing.T) {
var blockPayload map[string]interface{}
mux := http.NewServeMux()
mux.HandleFunc("/key/block", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if err := json.NewDecoder(r.Body).Decode(&blockPayload); err != nil {
t.Fatalf("failed to decode block payload: %v", err)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"blocked":true}`))
})
mux.HandleFunc("/key/info", func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("key"); got != keyBlockTestHash {
t.Errorf("expected key query to be the token hash %q, got %q", keyBlockTestHash, got)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"key":"sk-test-123","info":{"blocked":true}}`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := newKeyBlockTestResourceData(t, "sk-test-123")
if err := resourceLiteLLMKeyBlockCreate(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if d.Id() != keyBlockTestHash {
t.Fatalf("expected ID to be the token hash %q, got %q", keyBlockTestHash, d.Id())
}
if blockPayload["key"] != keyBlockTestHash {
t.Fatalf("expected block payload to carry the token hash, got %+v", blockPayload)
}
if !d.Get("blocked").(bool) {
t.Fatal("expected blocked=true in state")
}
}
func TestResourceLiteLLMKeyBlockRead_UnblockedClearsID(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"key":"sk-test-123","info":{"blocked":false}}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := newKeyBlockTestResourceData(t, "sk-test-123")
d.SetId(keyBlockTestHash)
if err := resourceLiteLLMKeyBlockRead(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if d.Id() != "" {
t.Fatalf("expected ID cleared for unblocked key, got %q", d.Id())
}
}
func TestResourceLiteLLMKeyBlockRead_404ClearsID(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 := newKeyBlockTestResourceData(t, "sk-test-123")
d.SetId(keyBlockTestHash)
if err := resourceLiteLLMKeyBlockRead(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if d.Id() != "" {
t.Fatalf("expected ID cleared on 404, got %q", d.Id())
}
}
func TestResourceLiteLLMKeyBlockDelete(t *testing.T) {
var gotPath string
var unblockPayload map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
json.NewDecoder(r.Body).Decode(&unblockPayload)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"blocked":false}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := newKeyBlockTestResourceData(t, "sk-test-123")
d.SetId(keyBlockTestHash)
if err := resourceLiteLLMKeyBlockDelete(d, client); err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if gotPath != "/key/unblock" {
t.Fatalf("expected path /key/unblock, got %s", gotPath)
}
if unblockPayload["key"] != keyBlockTestHash {
t.Fatalf("expected unblock payload to carry the token hash, got %+v", unblockPayload)
}
if d.Id() != "" {
t.Fatalf("expected ID cleared after delete, got %q", d.Id())
}
}
// Regression for the security review finding: a raw sk- key must never leave
// the provider in a URL, request body, or resource ID; only its SHA-256 token
// hash may.
func TestKeyBlockNeverSendsRawKey(t *testing.T) {
var seen []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
seen = append(seen, r.URL.String()+" "+string(body))
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"key":"x","info":{"blocked":true}}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "master-key", true)
d := newKeyBlockTestResourceData(t, "sk-test-123")
if err := resourceLiteLLMKeyBlockCreate(d, client); err != nil {
t.Fatalf("create failed: %v", err)
}
if err := resourceLiteLLMKeyBlockRead(d, client); err != nil {
t.Fatalf("read failed: %v", err)
}
if err := resourceLiteLLMKeyBlockDelete(d, client); err != nil {
t.Fatalf("delete failed: %v", err)
}
for _, req := range seen {
if strings.Contains(req, "sk-test-123") {
t.Fatalf("raw key leaked to the API: %s", req)
}
}
}

View file

@ -0,0 +1,256 @@
package litellm
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func newKeyResourceData(t *testing.T, raw map[string]interface{}) *schema.ResourceData {
t.Helper()
return schema.TestResourceDataRaw(t, resourceKey().Schema, raw)
}
func TestMapResourceDataToKeyNewFields(t *testing.T) {
d := newKeyResourceData(t, map[string]interface{}{
"budget_id": "budget-1",
"enforced_params": []interface{}{"user"},
"allowed_routes": []interface{}{"/chat/completions"},
"allowed_passthrough_routes": []interface{}{"/vertex-ai"},
"rpm_limit_type": "guaranteed_throughput",
"tpm_limit_type": "best_effort_throughput",
"prompts": []interface{}{"prompt-1"},
"organization_id": "org-1",
"project_id": "proj-1",
})
key := &Key{}
mapResourceDataToKey(d, key)
if key.BudgetID != "budget-1" {
t.Errorf("BudgetID = %q, want budget-1", key.BudgetID)
}
if len(key.EnforcedParams) != 1 || key.EnforcedParams[0] != "user" {
t.Errorf("EnforcedParams = %v, want [user]", key.EnforcedParams)
}
if len(key.AllowedRoutes) != 1 || key.AllowedRoutes[0] != "/chat/completions" {
t.Errorf("AllowedRoutes = %v", key.AllowedRoutes)
}
if len(key.AllowedPassthroughRoutes) != 1 || key.AllowedPassthroughRoutes[0] != "/vertex-ai" {
t.Errorf("AllowedPassthroughRoutes = %v", key.AllowedPassthroughRoutes)
}
if key.RPMLimitType != "guaranteed_throughput" {
t.Errorf("RPMLimitType = %q", key.RPMLimitType)
}
if key.TPMLimitType != "best_effort_throughput" {
t.Errorf("TPMLimitType = %q", key.TPMLimitType)
}
if len(key.Prompts) != 1 || key.Prompts[0] != "prompt-1" {
t.Errorf("Prompts = %v", key.Prompts)
}
if key.OrganizationID != "org-1" {
t.Errorf("OrganizationID = %q", key.OrganizationID)
}
if key.ProjectID != "proj-1" {
t.Errorf("ProjectID = %q", key.ProjectID)
}
}
func TestUpdateKeySendsNewFields(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.Write([]byte(`{"key": "sk-test"}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
_, err := client.UpdateKey(&Key{
Key: "sk-test",
BudgetID: "budget-1",
EnforcedParams: []string{"user"},
AllowedRoutes: []string{"/chat/completions"},
AllowedPassthroughRoutes: []string{"/vertex-ai"},
RPMLimitType: "guaranteed_throughput",
TPMLimitType: "dynamic",
Prompts: []string{"prompt-1"},
OrganizationID: "org-1",
})
if err != nil {
t.Fatalf("UpdateKey returned error: %v", err)
}
want := map[string]interface{}{
"budget_id": "budget-1",
"rpm_limit_type": "guaranteed_throughput",
"tpm_limit_type": "dynamic",
"organization_id": "org-1",
}
for k, v := range want {
if captured[k] != v {
t.Errorf("update payload %s = %v, want %v", k, captured[k], v)
}
}
for _, k := range []string{"enforced_params", "allowed_routes", "allowed_passthrough_routes", "prompts"} {
list, ok := captured[k].([]interface{})
if !ok || len(list) != 1 {
t.Errorf("update payload %s = %v, want single-element list", k, captured[k])
}
}
}
func TestUpdateKeyOmitsUnsetNewFields(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.Write([]byte(`{"key": "sk-test"}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
if _, err := client.UpdateKey(&Key{Key: "sk-test"}); err != nil {
t.Fatalf("UpdateKey returned error: %v", err)
}
for _, k := range []string{
"budget_id", "enforced_params", "allowed_routes", "allowed_passthrough_routes",
"rpm_limit_type", "tpm_limit_type", "prompts", "organization_id",
} {
if _, present := captured[k]; present {
t.Errorf("update payload unexpectedly contains %s", k)
}
}
}
func TestParseKeyResponseNewFields(t *testing.T) {
client := NewClient("http://localhost:4000", "test-key", true)
resp := map[string]interface{}{
"key": "sk-test",
"budget_id": "budget-1",
"enforced_params": []interface{}{"user"},
"allowed_routes": []interface{}{"/chat/completions"},
"allowed_passthrough_routes": []interface{}{"/vertex-ai"},
"rpm_limit_type": "guaranteed_throughput",
"tpm_limit_type": "best_effort_throughput",
"prompts": []interface{}{"prompt-1"},
"organization_id": "org-1",
"project_id": "proj-1",
}
key, err := client.parseKeyResponse(resp)
if err != nil {
t.Fatalf("parseKeyResponse returned error: %v", err)
}
if key.BudgetID != "budget-1" || key.OrganizationID != "org-1" || key.ProjectID != "proj-1" {
t.Errorf("string fields not parsed: %+v", key)
}
if key.RPMLimitType != "guaranteed_throughput" || key.TPMLimitType != "best_effort_throughput" {
t.Errorf("limit types not parsed: %+v", key)
}
if len(key.EnforcedParams) != 1 || len(key.AllowedRoutes) != 1 || len(key.AllowedPassthroughRoutes) != 1 || len(key.Prompts) != 1 {
t.Errorf("list fields not parsed: %+v", key)
}
}
// A config-supplied key value must be forwarded to /key/generate; previously
// it was silently dropped and the proxy generated a random key instead.
func TestCreateKeySendsConfigSuppliedKey(t *testing.T) {
var captured map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/key/generate" {
body, _ := io.ReadAll(r.Body)
json.Unmarshal(body, &captured)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"key": "sk-custom", "token_id": "hash-1"}`))
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"key": "sk-custom", "token_id": "hash-1"}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := newKeyResourceData(t, map[string]interface{}{"key": "sk-custom"})
diags := resourceKeyCreate(context.Background(), d, client)
if diags.HasError() {
t.Fatalf("create returned error: %v", diags)
}
if captured["key"] != "sk-custom" {
t.Errorf("create payload key = %v, want sk-custom", captured["key"])
}
if d.Id() != "hash-1" {
t.Errorf("resource ID = %q, want hash-1", d.Id())
}
}
// The proxy 400s on budget_duration: "", so an unset duration must be
// omitted from the update payload entirely.
func TestUpdateKeyOmitsEmptyBudgetDuration(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.Write([]byte(`{"key": "sk-test"}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
if _, err := client.UpdateKey(&Key{Key: "sk-test"}); err != nil {
t.Fatalf("UpdateKey returned error: %v", err)
}
if _, present := captured["budget_duration"]; present {
t.Errorf("update payload contains empty budget_duration: %v", captured["budget_duration"])
}
if _, err := client.UpdateKey(&Key{Key: "sk-test", BudgetDuration: "30d"}); err != nil {
t.Fatalf("UpdateKey returned error: %v", err)
}
if captured["budget_duration"] != "30d" {
t.Errorf("budget_duration = %v, want 30d", captured["budget_duration"])
}
}
// /key/info nests the key's fields under "info"; GetKey must unwrap that
// envelope or reads map nothing back into state.
func TestGetKeyUnwrapsInfoEnvelope(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"key": "hash-1",
"info": {
"key_alias": "envelope-alias",
"models": ["gpt-4o-mini"],
"budget_id": "budget-1",
"team_id": "team-1",
"rpm_limit": 100
}
}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
key, err := client.GetKey("hash-1")
if err != nil {
t.Fatalf("GetKey returned error: %v", err)
}
if key.KeyAlias != "envelope-alias" {
t.Errorf("KeyAlias = %q, want envelope-alias (info envelope not unwrapped)", key.KeyAlias)
}
if key.BudgetID != "budget-1" || key.TeamID != "team-1" {
t.Errorf("nested fields not parsed: %+v", key)
}
if key.RPMLimit == nil || *key.RPMLimit != 100 {
t.Errorf("RPMLimit not parsed: %+v", key.RPMLimit)
}
}

View file

@ -11,6 +11,9 @@ func resourceLiteLLMMCPServer() *schema.Resource {
Read: resourceLiteLLMMCPServerRead,
Update: resourceLiteLLMMCPServerUpdate,
Delete: resourceLiteLLMMCPServerDelete,
Importer: &schema.ResourceImporter{
StateContext: schema.ImportStatePassthroughContext,
},
Schema: map[string]*schema.Schema{
"server_name": {

View file

@ -11,6 +11,9 @@ func resourceLiteLLMModel() *schema.Resource {
Read: resourceLiteLLMModelRead,
Update: resourceLiteLLMModelUpdate,
Delete: resourceLiteLLMModelDelete,
Importer: &schema.ResourceImporter{
StateContext: schema.ImportStatePassthroughContext,
},
Schema: map[string]*schema.Schema{
"model_name": {

View file

@ -23,6 +23,9 @@ func resourceLiteLLMOrganization() *schema.Resource {
Read: resourceLiteLLMOrganizationRead,
Update: resourceLiteLLMOrganizationUpdate,
Delete: resourceLiteLLMOrganizationDelete,
Importer: &schema.ResourceImporter{
StateContext: schema.ImportStatePassthroughContext,
},
Schema: map[string]*schema.Schema{
"organization_alias": {

View file

@ -0,0 +1,352 @@
package litellm
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
const (
endpointProjectNew = "/project/new"
endpointProjectInfo = "/project/info"
endpointProjectUpdate = "/project/update"
endpointProjectDelete = "/project/delete"
)
type projectBudgetTable struct {
MaxBudget *float64 `json:"max_budget"`
SoftBudget *float64 `json:"soft_budget"`
MaxParallelRequests *int `json:"max_parallel_requests"`
TPMLimit *int `json:"tpm_limit"`
RPMLimit *int `json:"rpm_limit"`
BudgetDuration string `json:"budget_duration"`
}
type projectResponse struct {
ProjectID string `json:"project_id"`
ProjectAlias string `json:"project_alias"`
Description string `json:"description"`
TeamID string `json:"team_id"`
BudgetID string `json:"budget_id"`
Metadata map[string]interface{} `json:"metadata"`
Models []string `json:"models"`
Spend float64 `json:"spend"`
Blocked bool `json:"blocked"`
CreatedBy string `json:"created_by"`
UpdatedBy string `json:"updated_by"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
LitellmBudgetTable *projectBudgetTable `json:"litellm_budget_table"`
}
func resourceLiteLLMProject() *schema.Resource {
return &schema.Resource{
Create: resourceLiteLLMProjectCreate,
Read: resourceLiteLLMProjectRead,
Update: resourceLiteLLMProjectUpdate,
Delete: resourceLiteLLMProjectDelete,
Importer: &schema.ResourceImporter{StateContext: schema.ImportStatePassthroughContext},
Schema: map[string]*schema.Schema{
"team_id": {
Type: schema.TypeString,
Required: true,
ForceNew: true,
Description: "The team ID this project belongs to.",
},
"project_alias": {
Type: schema.TypeString,
Optional: true,
Description: "Human-friendly name for the project.",
},
"description": {
Type: schema.TypeString,
Optional: true,
Description: "Description of the project's purpose and use case.",
},
"models": {
Type: schema.TypeList,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "List of models the project can access.",
},
"metadata": {
Type: schema.TypeMap,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "Metadata for the project.",
},
"tags": {
Type: schema.TypeList,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "Tags associated with the project.",
},
"max_budget": {
Type: schema.TypeFloat,
Optional: true,
Description: "Maximum budget for this project.",
},
"soft_budget": {
Type: schema.TypeFloat,
Optional: true,
Description: "Soft budget limit for warnings.",
},
"budget_duration": {
Type: schema.TypeString,
Optional: true,
Description: "Budget reset duration (e.g. '30d', '1h').",
},
"budget_id": {
Type: schema.TypeString,
Optional: true,
Description: "Budget ID to associate with this project.",
},
"tpm_limit": {
Type: schema.TypeInt,
Optional: true,
Description: "Tokens per minute limit.",
},
"rpm_limit": {
Type: schema.TypeInt,
Optional: true,
Description: "Requests per minute limit.",
},
"max_parallel_requests": {
Type: schema.TypeInt,
Optional: true,
Description: "Maximum parallel requests allowed.",
},
"model_max_budget": {
Type: schema.TypeMap,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeFloat},
Description: "Per-model budget limits.",
},
"model_rpm_limit": {
Type: schema.TypeMap,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeInt},
Description: "Per-model RPM limits.",
},
"model_tpm_limit": {
Type: schema.TypeMap,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeInt},
Description: "Per-model TPM limits.",
},
"blocked": {
Type: schema.TypeBool,
Optional: true,
Description: "Whether the project is blocked from making requests.",
},
"spend": {
Type: schema.TypeFloat,
Computed: true,
Description: "Current spend for the project.",
},
"created_at": {
Type: schema.TypeString,
Computed: true,
Description: "Timestamp when the project was created.",
},
"updated_at": {
Type: schema.TypeString,
Computed: true,
Description: "Timestamp when the project was last updated.",
},
"created_by": {
Type: schema.TypeString,
Computed: true,
Description: "User that created the project.",
},
"updated_by": {
Type: schema.TypeString,
Computed: true,
Description: "User that last updated the project.",
},
},
}
}
func buildProjectData(d *schema.ResourceData) map[string]interface{} {
projectData := map[string]interface{}{
"team_id": d.Get("team_id").(string),
}
for _, key := range []string{"project_alias", "description", "models", "metadata", "tags",
"max_budget", "soft_budget", "budget_duration", "budget_id", "tpm_limit", "rpm_limit",
"max_parallel_requests", "model_max_budget", "model_rpm_limit", "model_tpm_limit", "blocked"} {
if v, ok := d.GetOk(key); ok {
projectData[key] = v
}
}
return projectData
}
func resourceLiteLLMProjectCreate(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
projectData := buildProjectData(d)
log.Printf("[DEBUG] Create project request payload: %+v", projectData)
resp, err := MakeRequest(client, "POST", endpointProjectNew, projectData)
if err != nil {
return fmt.Errorf("error creating project: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("error reading create project response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("error creating project: %s - %s", resp.Status, string(body))
}
var projResp projectResponse
if err := json.Unmarshal(body, &projResp); err != nil {
return fmt.Errorf("error decoding create project response: %w", err)
}
if projResp.ProjectID == "" {
return fmt.Errorf("create project response did not contain a project_id: %s", string(body))
}
d.SetId(projResp.ProjectID)
log.Printf("[INFO] Project created with ID: %s", projResp.ProjectID)
return resourceLiteLLMProjectRead(d, m)
}
func resourceLiteLLMProjectRead(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
log.Printf("[INFO] Reading project with ID: %s", d.Id())
resp, err := MakeRequest(client, "GET", fmt.Sprintf("%s?project_id=%s", endpointProjectInfo, d.Id()), nil)
if err != nil {
return fmt.Errorf("error reading project: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
log.Printf("[WARN] Project with ID %s not found, removing from state", d.Id())
d.SetId("")
return nil
}
if err := handleResponse(resp, "reading project"); err != nil {
return err
}
var projResp projectResponse
if err := json.NewDecoder(resp.Body).Decode(&projResp); err != nil {
return fmt.Errorf("error decoding project info response: %w", err)
}
d.Set("team_id", GetStringValue(projResp.TeamID, d.Get("team_id").(string)))
d.Set("project_alias", GetStringValue(projResp.ProjectAlias, d.Get("project_alias").(string)))
d.Set("description", GetStringValue(projResp.Description, d.Get("description").(string)))
d.Set("budget_id", GetStringValue(projResp.BudgetID, d.Get("budget_id").(string)))
if projResp.Models != nil {
d.Set("models", projResp.Models)
}
setProjectMetadataAndTags(d, projResp.Metadata)
d.Set("blocked", projResp.Blocked)
d.Set("spend", projResp.Spend)
d.Set("created_at", projResp.CreatedAt)
d.Set("updated_at", projResp.UpdatedAt)
d.Set("created_by", projResp.CreatedBy)
d.Set("updated_by", projResp.UpdatedBy)
if bt := projResp.LitellmBudgetTable; bt != nil {
if bt.MaxBudget != nil {
d.Set("max_budget", *bt.MaxBudget)
}
if bt.SoftBudget != nil {
d.Set("soft_budget", *bt.SoftBudget)
}
if bt.MaxParallelRequests != nil {
d.Set("max_parallel_requests", *bt.MaxParallelRequests)
}
if bt.TPMLimit != nil {
d.Set("tpm_limit", *bt.TPMLimit)
}
if bt.RPMLimit != nil {
d.Set("rpm_limit", *bt.RPMLimit)
}
d.Set("budget_duration", GetStringValue(bt.BudgetDuration, d.Get("budget_duration").(string)))
}
log.Printf("[INFO] Successfully read project with ID: %s", d.Id())
return nil
}
// The proxy stores project tags inside metadata; split them back out so state matches the config shape.
func setProjectMetadataAndTags(d *schema.ResourceData, metadata map[string]interface{}) {
if metadata == nil {
return
}
if tags, ok := metadata["tags"].([]interface{}); ok {
d.Set("tags", tags)
}
stringMetadata := map[string]interface{}{}
for k, v := range metadata {
if s, ok := v.(string); ok {
stringMetadata[k] = s
}
}
d.Set("metadata", stringMetadata)
}
func resourceLiteLLMProjectUpdate(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
projectData := buildProjectData(d)
projectData["project_id"] = d.Id()
log.Printf("[DEBUG] Update project request payload: %+v", projectData)
resp, err := MakeRequest(client, "POST", endpointProjectUpdate, projectData)
if err != nil {
return fmt.Errorf("error updating project: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "updating project"); err != nil {
return err
}
log.Printf("[INFO] Successfully updated project with ID: %s", d.Id())
return resourceLiteLLMProjectRead(d, m)
}
func resourceLiteLLMProjectDelete(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
log.Printf("[INFO] Deleting project with ID: %s", d.Id())
resp, err := MakeRequest(client, "DELETE", endpointProjectDelete, map[string]interface{}{
"project_ids": []string{d.Id()},
})
if err != nil {
return fmt.Errorf("error deleting project: %w", err)
}
defer resp.Body.Close()
if err := handleResponse(resp, "deleting project"); err != nil {
return err
}
log.Printf("[INFO] Successfully deleted project with ID: %s", d.Id())
d.SetId("")
return nil
}

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