mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
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:
parent
733d0b5af5
commit
3653e5893f
118 changed files with 14520 additions and 42 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
34
terraform/provider/docs/data-sources/access_group.md
Normal file
34
terraform/provider/docs/data-sources/access_group.md
Normal 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.
|
||||
33
terraform/provider/docs/data-sources/access_groups.md
Normal file
33
terraform/provider/docs/data-sources/access_groups.md
Normal 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.
|
||||
43
terraform/provider/docs/data-sources/agent.md
Normal file
43
terraform/provider/docs/data-sources/agent.md
Normal 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.
|
||||
42
terraform/provider/docs/data-sources/agents.md
Normal file
42
terraform/provider/docs/data-sources/agents.md
Normal 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.
|
||||
31
terraform/provider/docs/data-sources/budget.md
Normal file
31
terraform/provider/docs/data-sources/budget.md
Normal 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
|
||||
31
terraform/provider/docs/data-sources/budgets.md
Normal file
31
terraform/provider/docs/data-sources/budgets.md
Normal 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
|
||||
38
terraform/provider/docs/data-sources/fallback.md
Normal file
38
terraform/provider/docs/data-sources/fallback.md
Normal 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.
|
||||
27
terraform/provider/docs/data-sources/guardrail.md
Normal file
27
terraform/provider/docs/data-sources/guardrail.md
Normal 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.
|
||||
32
terraform/provider/docs/data-sources/guardrails.md
Normal file
32
terraform/provider/docs/data-sources/guardrails.md
Normal 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.
|
||||
57
terraform/provider/docs/data-sources/key.md
Normal file
57
terraform/provider/docs/data-sources/key.md
Normal 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.
|
||||
62
terraform/provider/docs/data-sources/keys.md
Normal file
62
terraform/provider/docs/data-sources/keys.md
Normal 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.
|
||||
58
terraform/provider/docs/data-sources/mcp_server.md
Normal file
58
terraform/provider/docs/data-sources/mcp_server.md
Normal 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.
|
||||
50
terraform/provider/docs/data-sources/mcp_servers.md
Normal file
50
terraform/provider/docs/data-sources/mcp_servers.md
Normal 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.
|
||||
50
terraform/provider/docs/data-sources/model.md
Normal file
50
terraform/provider/docs/data-sources/model.md
Normal 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.
|
||||
44
terraform/provider/docs/data-sources/models.md
Normal file
44
terraform/provider/docs/data-sources/models.md
Normal 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.
|
||||
48
terraform/provider/docs/data-sources/organization.md
Normal file
48
terraform/provider/docs/data-sources/organization.md
Normal 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.
|
||||
45
terraform/provider/docs/data-sources/organizations.md
Normal file
45
terraform/provider/docs/data-sources/organizations.md
Normal 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.
|
||||
43
terraform/provider/docs/data-sources/project.md
Normal file
43
terraform/provider/docs/data-sources/project.md
Normal 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
|
||||
40
terraform/provider/docs/data-sources/projects.md
Normal file
40
terraform/provider/docs/data-sources/projects.md
Normal 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
|
||||
43
terraform/provider/docs/data-sources/prompt.md
Normal file
43
terraform/provider/docs/data-sources/prompt.md
Normal 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.
|
||||
37
terraform/provider/docs/data-sources/prompts.md
Normal file
37
terraform/provider/docs/data-sources/prompts.md
Normal 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.
|
||||
34
terraform/provider/docs/data-sources/search_tool.md
Normal file
34
terraform/provider/docs/data-sources/search_tool.md
Normal 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.
|
||||
34
terraform/provider/docs/data-sources/search_tools.md
Normal file
34
terraform/provider/docs/data-sources/search_tools.md
Normal 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.
|
||||
38
terraform/provider/docs/data-sources/tag.md
Normal file
38
terraform/provider/docs/data-sources/tag.md
Normal 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
|
||||
50
terraform/provider/docs/data-sources/tags.md
Normal file
50
terraform/provider/docs/data-sources/tags.md
Normal 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
|
||||
52
terraform/provider/docs/data-sources/team.md
Normal file
52
terraform/provider/docs/data-sources/team.md
Normal 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.
|
||||
49
terraform/provider/docs/data-sources/teams.md
Normal file
49
terraform/provider/docs/data-sources/teams.md
Normal 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.
|
||||
52
terraform/provider/docs/data-sources/unified_access_group.md
Normal file
52
terraform/provider/docs/data-sources/unified_access_group.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
36
terraform/provider/docs/data-sources/user.md
Normal file
36
terraform/provider/docs/data-sources/user.md
Normal 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
|
||||
47
terraform/provider/docs/data-sources/users.md
Normal file
47
terraform/provider/docs/data-sources/users.md
Normal 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
|
||||
49
terraform/provider/docs/resources/access_group.md
Normal file
49
terraform/provider/docs/resources/access_group.md
Normal 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
|
||||
```
|
||||
88
terraform/provider/docs/resources/agent.md
Normal file
88
terraform/provider/docs/resources/agent.md
Normal 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.
|
||||
48
terraform/provider/docs/resources/budget.md
Normal file
48
terraform/provider/docs/resources/budget.md
Normal 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>
|
||||
```
|
||||
48
terraform/provider/docs/resources/fallback.md
Normal file
48
terraform/provider/docs/resources/fallback.md
Normal 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.
|
||||
57
terraform/provider/docs/resources/guardrail.md
Normal file
57
terraform/provider/docs/resources/guardrail.md
Normal 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.
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
40
terraform/provider/docs/resources/key_block.md
Normal file
40
terraform/provider/docs/resources/key_block.md
Normal 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
|
||||
```
|
||||
71
terraform/provider/docs/resources/project.md
Normal file
71
terraform/provider/docs/resources/project.md
Normal 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
|
||||
```
|
||||
61
terraform/provider/docs/resources/prompt.md
Normal file
61
terraform/provider/docs/resources/prompt.md
Normal 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
|
||||
```
|
||||
46
terraform/provider/docs/resources/search_tool.md
Normal file
46
terraform/provider/docs/resources/search_tool.md
Normal 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.
|
||||
49
terraform/provider/docs/resources/tag.md
Normal file
49
terraform/provider/docs/resources/tag.md
Normal 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
|
||||
```
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
38
terraform/provider/docs/resources/team_block.md
Normal file
38
terraform/provider/docs/resources/team_block.md
Normal 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
|
||||
```
|
||||
64
terraform/provider/docs/resources/unified_access_group.md
Normal file
64
terraform/provider/docs/resources/unified_access_group.md
Normal 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>
|
||||
```
|
||||
66
terraform/provider/docs/resources/user.md
Normal file
66
terraform/provider/docs/resources/user.md
Normal 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>
|
||||
```
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
140
terraform/provider/litellm/data_source_access_group.go
Normal file
140
terraform/provider/litellm/data_source_access_group.go
Normal 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
|
||||
}
|
||||
97
terraform/provider/litellm/data_source_access_group_test.go
Normal file
97
terraform/provider/litellm/data_source_access_group_test.go
Normal 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"))
|
||||
}
|
||||
}
|
||||
281
terraform/provider/litellm/data_source_agent.go
Normal file
281
terraform/provider/litellm/data_source_agent.go
Normal 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
|
||||
}
|
||||
94
terraform/provider/litellm/data_source_agent_test.go
Normal file
94
terraform/provider/litellm/data_source_agent_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
195
terraform/provider/litellm/data_source_budget.go
Normal file
195
terraform/provider/litellm/data_source_budget.go
Normal 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
|
||||
}
|
||||
107
terraform/provider/litellm/data_source_budget_test.go
Normal file
107
terraform/provider/litellm/data_source_budget_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
71
terraform/provider/litellm/data_source_fallback.go
Normal file
71
terraform/provider/litellm/data_source_fallback.go
Normal 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
|
||||
}
|
||||
63
terraform/provider/litellm/data_source_fallback_test.go
Normal file
63
terraform/provider/litellm/data_source_fallback_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
178
terraform/provider/litellm/data_source_guardrail.go
Normal file
178
terraform/provider/litellm/data_source_guardrail.go
Normal 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
|
||||
}
|
||||
83
terraform/provider/litellm/data_source_guardrail_test.go
Normal file
83
terraform/provider/litellm/data_source_guardrail_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
384
terraform/provider/litellm/data_source_key.go
Normal file
384
terraform/provider/litellm/data_source_key.go
Normal 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
|
||||
}
|
||||
198
terraform/provider/litellm/data_source_key_test.go
Normal file
198
terraform/provider/litellm/data_source_key_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
271
terraform/provider/litellm/data_source_mcp_server.go
Normal file
271
terraform/provider/litellm/data_source_mcp_server.go
Normal 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
|
||||
}
|
||||
150
terraform/provider/litellm/data_source_mcp_server_test.go
Normal file
150
terraform/provider/litellm/data_source_mcp_server_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
260
terraform/provider/litellm/data_source_model.go
Normal file
260
terraform/provider/litellm/data_source_model.go
Normal 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
|
||||
}
|
||||
149
terraform/provider/litellm/data_source_model_test.go
Normal file
149
terraform/provider/litellm/data_source_model_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
270
terraform/provider/litellm/data_source_organization.go
Normal file
270
terraform/provider/litellm/data_source_organization.go
Normal 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
|
||||
}
|
||||
120
terraform/provider/litellm/data_source_organization_test.go
Normal file
120
terraform/provider/litellm/data_source_organization_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
255
terraform/provider/litellm/data_source_project.go
Normal file
255
terraform/provider/litellm/data_source_project.go
Normal 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
|
||||
}
|
||||
104
terraform/provider/litellm/data_source_project_test.go
Normal file
104
terraform/provider/litellm/data_source_project_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
243
terraform/provider/litellm/data_source_prompt.go
Normal file
243
terraform/provider/litellm/data_source_prompt.go
Normal 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
|
||||
}
|
||||
92
terraform/provider/litellm/data_source_prompt_test.go
Normal file
92
terraform/provider/litellm/data_source_prompt_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
179
terraform/provider/litellm/data_source_search_tool.go
Normal file
179
terraform/provider/litellm/data_source_search_tool.go
Normal 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
|
||||
}
|
||||
95
terraform/provider/litellm/data_source_search_tool_test.go
Normal file
95
terraform/provider/litellm/data_source_search_tool_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
246
terraform/provider/litellm/data_source_tag.go
Normal file
246
terraform/provider/litellm/data_source_tag.go
Normal 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
|
||||
}
|
||||
116
terraform/provider/litellm/data_source_tag_test.go
Normal file
116
terraform/provider/litellm/data_source_tag_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
294
terraform/provider/litellm/data_source_team.go
Normal file
294
terraform/provider/litellm/data_source_team.go
Normal 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
|
||||
}
|
||||
145
terraform/provider/litellm/data_source_team_test.go
Normal file
145
terraform/provider/litellm/data_source_team_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
189
terraform/provider/litellm/data_source_unified_access_group.go
Normal file
189
terraform/provider/litellm/data_source_unified_access_group.go
Normal 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
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
307
terraform/provider/litellm/data_source_user.go
Normal file
307
terraform/provider/litellm/data_source_user.go
Normal 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
|
||||
}
|
||||
144
terraform/provider/litellm/data_source_user_test.go
Normal file
144
terraform/provider/litellm/data_source_user_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
161
terraform/provider/litellm/resource_access_group.go
Normal file
161
terraform/provider/litellm/resource_access_group.go
Normal 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
|
||||
}
|
||||
185
terraform/provider/litellm/resource_access_group_test.go
Normal file
185
terraform/provider/litellm/resource_access_group_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
320
terraform/provider/litellm/resource_agent.go
Normal file
320
terraform/provider/litellm/resource_agent.go
Normal 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
|
||||
}
|
||||
235
terraform/provider/litellm/resource_agent_test.go
Normal file
235
terraform/provider/litellm/resource_agent_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
287
terraform/provider/litellm/resource_budget.go
Normal file
287
terraform/provider/litellm/resource_budget.go
Normal 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
|
||||
}
|
||||
268
terraform/provider/litellm/resource_budget_test.go
Normal file
268
terraform/provider/litellm/resource_budget_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
155
terraform/provider/litellm/resource_fallback.go
Normal file
155
terraform/provider/litellm/resource_fallback.go
Normal 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
|
||||
}
|
||||
180
terraform/provider/litellm/resource_fallback_test.go
Normal file
180
terraform/provider/litellm/resource_fallback_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
255
terraform/provider/litellm/resource_guardrail.go
Normal file
255
terraform/provider/litellm/resource_guardrail.go
Normal 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
|
||||
}
|
||||
271
terraform/provider/litellm/resource_guardrail_test.go
Normal file
271
terraform/provider/litellm/resource_guardrail_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
135
terraform/provider/litellm/resource_key_block.go
Normal file
135
terraform/provider/litellm/resource_key_block.go
Normal 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
|
||||
}
|
||||
160
terraform/provider/litellm/resource_key_block_test.go
Normal file
160
terraform/provider/litellm/resource_key_block_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
256
terraform/provider/litellm/resource_key_test.go
Normal file
256
terraform/provider/litellm/resource_key_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
352
terraform/provider/litellm/resource_project.go
Normal file
352
terraform/provider/litellm/resource_project.go
Normal 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
Loading…
Add table
Reference in a new issue