mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
feat(terraform): add litellm_guardrail resource and data source
This commit is contained in:
parent
be658d5d29
commit
b81affffef
11 changed files with 899 additions and 0 deletions
|
|
@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **guardrail**: New `litellm_guardrail` resource and data source to manage database-backed guardrails through the proxy's `/guardrails` API. Provider-specific parameters are supplied as a JSON `litellm_params` object; sensitive values are masked by the proxy on read, so that field is treated as write-mostly to avoid spurious diffs (#33392)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **organization**: Send `PATCH` instead of `POST` to `/organization/update` and `/organization/member_update`, matching the methods the LiteLLM proxy serves; organization and organization member updates previously failed with a 405
|
||||
|
|
|
|||
|
|
@ -139,11 +139,13 @@ For full details on the <code>litellm_key</code> resource, see the [key resource
|
|||
- <code>litellm_mcp_server</code>: Manage MCP (Model Context Protocol) servers. [Documentation](docs/resources/mcp_server.md)
|
||||
- <code>litellm_credential</code>: Manage credentials for secure authentication. [Documentation](docs/resources/credential.md)
|
||||
- <code>litellm_vector_store</code>: Manage vector stores for embeddings and RAG. [Documentation](docs/resources/vector_store.md)
|
||||
- <code>litellm_guardrail</code>: Manage guardrails for content filtering, PII detection, and prompt injection protection. [Documentation](docs/resources/guardrail.md)
|
||||
|
||||
### Available Data Sources
|
||||
|
||||
- <code>litellm_credential</code>: Retrieve information about existing credentials. [Documentation](docs/data-sources/credential.md)
|
||||
- <code>litellm_vector_store</code>: Retrieve information about existing vector stores. [Documentation](docs/data-sources/vector_store.md)
|
||||
- <code>litellm_guardrail</code>: Retrieve information about existing guardrails. [Documentation](docs/data-sources/guardrail.md)
|
||||
|
||||
## Development
|
||||
|
||||
|
|
|
|||
47
terraform/provider/docs/data-sources/guardrail.md
Normal file
47
terraform/provider/docs/data-sources/guardrail.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
# generated by https://github.com/hashicorp/terraform-plugin-docs
|
||||
page_title: "litellm_guardrail Data Source - terraform-provider-litellm"
|
||||
subcategory: ""
|
||||
description: |-
|
||||
Retrieves information about an existing LiteLLM guardrail.
|
||||
---
|
||||
|
||||
# litellm_guardrail (Data Source)
|
||||
|
||||
Retrieves information about an existing LiteLLM guardrail by ID. Use this data source to reference guardrails that were created outside of Terraform or in other Terraform configurations.
|
||||
|
||||
## Example Usage
|
||||
|
||||
```terraform
|
||||
data "litellm_guardrail" "existing" {
|
||||
guardrail_id = "guard-12345"
|
||||
}
|
||||
|
||||
output "guardrail_type" {
|
||||
value = data.litellm_guardrail.existing.guardrail
|
||||
}
|
||||
```
|
||||
|
||||
## Argument Reference
|
||||
|
||||
The following arguments are supported:
|
||||
|
||||
* `guardrail_id` - (Required) Unique identifier of the guardrail to retrieve.
|
||||
|
||||
## Attributes Reference
|
||||
|
||||
In addition to all arguments above, the following attributes are exported:
|
||||
|
||||
* `guardrail_name` - Human-readable name for the guardrail.
|
||||
* `guardrail` - The guardrail integration type.
|
||||
* `mode` - When the guardrail runs. A JSON array string when the guardrail runs in multiple modes.
|
||||
* `default_on` - Whether the guardrail runs on every request by default.
|
||||
* `litellm_params` - A JSON object of the guardrail's `litellm_params`. The proxy masks sensitive values such as API keys.
|
||||
* `guardrail_info` - A JSON object of free-form metadata stored alongside the guardrail.
|
||||
* `created_at` - Timestamp when the guardrail was created.
|
||||
* `updated_at` - Timestamp when the guardrail was last updated.
|
||||
|
||||
## Notes
|
||||
|
||||
* The data source fails if the specified guardrail ID does not exist.
|
||||
* Sensitive values in `litellm_params` are masked by the proxy, so they are not returned in full.
|
||||
101
terraform/provider/docs/resources/guardrail.md
Normal file
101
terraform/provider/docs/resources/guardrail.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
---
|
||||
# generated by https://github.com/hashicorp/terraform-plugin-docs
|
||||
page_title: "litellm_guardrail Resource - terraform-provider-litellm"
|
||||
subcategory: ""
|
||||
description: |-
|
||||
Manages a LiteLLM guardrail for content filtering, PII detection, prompt injection protection, and more.
|
||||
---
|
||||
|
||||
# litellm_guardrail (Resource)
|
||||
|
||||
Manages a LiteLLM guardrail. Guardrails run before, during, after, or alongside a request to enforce policies such as content moderation, PII masking, prompt injection detection, and secret redaction. This resource manages database-backed guardrails through the proxy's `/guardrails` management API, so the proxy must be connected to a database.
|
||||
|
||||
Only proxy admin keys can manage guardrails, so the provider's `api_key` must be the master key or an admin key.
|
||||
|
||||
## Example Usage
|
||||
|
||||
### AWS Bedrock Guardrail
|
||||
|
||||
```terraform
|
||||
resource "litellm_guardrail" "bedrock" {
|
||||
guardrail_name = "bedrock-content-moderation"
|
||||
guardrail = "bedrock"
|
||||
mode = "pre_call"
|
||||
default_on = true
|
||||
|
||||
litellm_params = jsonencode({
|
||||
guardrailIdentifier = "ff6ujrregl1q"
|
||||
guardrailVersion = "DRAFT"
|
||||
aws_region_name = "us-east-1"
|
||||
})
|
||||
|
||||
guardrail_info = jsonencode({
|
||||
description = "Bedrock content moderation guardrail"
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Presidio PII Masking (multiple modes)
|
||||
|
||||
```terraform
|
||||
resource "litellm_guardrail" "presidio" {
|
||||
guardrail_name = "presidio-pii"
|
||||
guardrail = "presidio"
|
||||
mode = jsonencode(["pre_call", "post_call"])
|
||||
|
||||
litellm_params = jsonencode({
|
||||
presidio_analyzer_api_base = "http://presidio-analyzer:5002"
|
||||
presidio_anonymizer_api_base = "http://presidio-anonymizer:5001"
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Lakera Prompt Injection Guardrail
|
||||
|
||||
```terraform
|
||||
resource "litellm_guardrail" "lakera" {
|
||||
guardrail_name = "lakera-prompt-injection"
|
||||
guardrail = "lakera_v2"
|
||||
mode = "during_call"
|
||||
|
||||
litellm_params = jsonencode({
|
||||
api_base = "https://api.lakera.ai"
|
||||
api_key = var.lakera_api_key
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Argument Reference
|
||||
|
||||
The following arguments are supported:
|
||||
|
||||
* `guardrail_name` - (Required) Human-readable name for the guardrail.
|
||||
* `guardrail` - (Required) The guardrail integration type, for example `bedrock`, `presidio`, `lakera_v2`, `aporia`, `openai_moderation`, or `hide_secrets`. See the [supported guardrails](https://docs.litellm.ai/docs/proxy/guardrails/quick_start) for the full list.
|
||||
* `mode` - (Required) When to run the guardrail. One of `pre_call`, `post_call`, `during_call`, or `logging_only`. Pass a JSON array string (for example `jsonencode(["pre_call", "post_call"])`) to run the guardrail in multiple modes.
|
||||
* `default_on` - (Optional) Whether the guardrail runs on every request by default. Defaults to `false`.
|
||||
* `litellm_params` - (Optional, Sensitive) A JSON object of additional provider-specific parameters merged into `litellm_params` (for example `guardrailIdentifier`, `api_base`, `api_key`). Use `jsonencode(...)` to build it. This value is stored unencrypted in state, so source secrets from variables or the environment. The proxy masks sensitive values when it returns them, so this field is not refreshed from the API and drift in these parameters is not detected.
|
||||
* `guardrail_info` - (Optional) A JSON object of free-form metadata stored alongside the guardrail. Use `jsonencode(...)` to build it.
|
||||
|
||||
## Attributes Reference
|
||||
|
||||
In addition to all arguments above, the following attributes are exported:
|
||||
|
||||
* `guardrail_id` - The unique identifier of the guardrail.
|
||||
* `created_at` - Timestamp when the guardrail was created.
|
||||
* `updated_at` - Timestamp when the guardrail was last updated.
|
||||
|
||||
## Import
|
||||
|
||||
Guardrails can be imported using their ID:
|
||||
|
||||
```shell
|
||||
terraform import litellm_guardrail.example "guardrail-id"
|
||||
```
|
||||
|
||||
After import, run `terraform plan` and set `litellm_params` in your configuration to match the guardrail, since the proxy masks those values and they cannot be read back into state.
|
||||
|
||||
## Notes
|
||||
|
||||
* Managing guardrails requires an admin or master `api_key` and a database-backed proxy.
|
||||
* Guardrails defined in the proxy `config.yaml` are not managed by this resource; only database-backed guardrails created through the API can be managed here.
|
||||
* Do not put secrets you cannot rotate into `litellm_params`; the value is stored in Terraform state.
|
||||
116
terraform/provider/litellm/data_source_guardrail.go
Normal file
116
terraform/provider/litellm/data_source_guardrail.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package litellm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
|
||||
)
|
||||
|
||||
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,
|
||||
Description: "Human-readable name for the guardrail",
|
||||
},
|
||||
"guardrail": {
|
||||
Type: schema.TypeString,
|
||||
Computed: true,
|
||||
Description: "The guardrail integration type",
|
||||
},
|
||||
"mode": {
|
||||
Type: schema.TypeString,
|
||||
Computed: true,
|
||||
Description: "When the guardrail runs. A JSON array string when the guardrail runs in multiple modes",
|
||||
},
|
||||
"default_on": {
|
||||
Type: schema.TypeBool,
|
||||
Computed: true,
|
||||
Description: "Whether the guardrail runs on every request by default",
|
||||
},
|
||||
"litellm_params": {
|
||||
Type: schema.TypeString,
|
||||
Computed: true,
|
||||
Sensitive: true,
|
||||
Description: "JSON object of the guardrail's litellm_params, with sensitive values masked by the proxy",
|
||||
},
|
||||
"guardrail_info": {
|
||||
Type: schema.TypeString,
|
||||
Computed: true,
|
||||
Description: "JSON object of free-form metadata stored alongside the guardrail",
|
||||
},
|
||||
"created_at": {
|
||||
Type: schema.TypeString,
|
||||
Computed: true,
|
||||
Description: "Timestamp when the guardrail was created",
|
||||
},
|
||||
"updated_at": {
|
||||
Type: schema.TypeString,
|
||||
Computed: true,
|
||||
Description: "Timestamp when the guardrail was last updated",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func dataSourceLiteLLMGuardrailRead(d *schema.ResourceData, m interface{}) error {
|
||||
client := m.(*Client)
|
||||
guardrailID := d.Get("guardrail_id").(string)
|
||||
|
||||
resp, err := MakeRequest(client, "GET", fmt.Sprintf("/guardrails/%s/info", guardrailID), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read guardrail: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var guardrailResp GuardrailResponse
|
||||
if err := handleGuardrailAPIResponse(resp, &guardrailResp, client); err != nil {
|
||||
if err.Error() == "guardrail_not_found" {
|
||||
return fmt.Errorf("guardrail '%s' not found", guardrailID)
|
||||
}
|
||||
return fmt.Errorf("failed to read guardrail: %w", err)
|
||||
}
|
||||
|
||||
d.SetId(guardrailResp.GuardrailID)
|
||||
d.Set("guardrail_id", guardrailResp.GuardrailID)
|
||||
d.Set("guardrail_name", guardrailResp.GuardrailName)
|
||||
d.Set("created_at", guardrailResp.CreatedAt)
|
||||
d.Set("updated_at", guardrailResp.UpdatedAt)
|
||||
|
||||
if guardrail, ok := guardrailResp.LiteLLMParams["guardrail"].(string); ok {
|
||||
d.Set("guardrail", guardrail)
|
||||
}
|
||||
if defaultOn, ok := guardrailResp.LiteLLMParams["default_on"].(bool); ok {
|
||||
d.Set("default_on", defaultOn)
|
||||
}
|
||||
if err := setGuardrailMode(d, guardrailResp.LiteLLMParams["mode"]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(guardrailResp.LiteLLMParams) > 0 {
|
||||
encoded, err := json.Marshal(guardrailResp.LiteLLMParams)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode litellm_params: %w", err)
|
||||
}
|
||||
d.Set("litellm_params", string(encoded))
|
||||
}
|
||||
|
||||
if len(guardrailResp.GuardrailInfo) > 0 {
|
||||
encoded, err := json.Marshal(guardrailResp.GuardrailInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode guardrail_info: %w", err)
|
||||
}
|
||||
d.Set("guardrail_info", string(encoded))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -19,10 +19,12 @@ func Provider() *schema.Provider {
|
|||
"litellm_mcp_server": resourceLiteLLMMCPServer(),
|
||||
"litellm_credential": resourceLiteLLMCredential(),
|
||||
"litellm_vector_store": resourceLiteLLMVectorStore(),
|
||||
"litellm_guardrail": resourceLiteLLMGuardrail(),
|
||||
},
|
||||
DataSourcesMap: map[string]*schema.Resource{
|
||||
"litellm_credential": dataSourceLiteLLMCredential(),
|
||||
"litellm_vector_store": dataSourceLiteLLMVectorStore(),
|
||||
"litellm_guardrail": dataSourceLiteLLMGuardrail(),
|
||||
},
|
||||
Schema: map[string]*schema.Schema{
|
||||
"api_base": {
|
||||
|
|
|
|||
69
terraform/provider/litellm/resource_guardrail.go
Normal file
69
terraform/provider/litellm/resource_guardrail.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package litellm
|
||||
|
||||
import (
|
||||
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
|
||||
)
|
||||
|
||||
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\", \"presidio\", \"lakera_v2\", \"aporia\")",
|
||||
},
|
||||
"mode": {
|
||||
Type: schema.TypeString,
|
||||
Required: true,
|
||||
Description: "When to run the guardrail: \"pre_call\", \"post_call\", \"during_call\", or \"logging_only\". A JSON array string (e.g. \"[\\\"pre_call\\\", \\\"post_call\\\"]\") runs the guardrail in multiple modes",
|
||||
},
|
||||
"default_on": {
|
||||
Type: schema.TypeBool,
|
||||
Optional: true,
|
||||
Default: false,
|
||||
Description: "Whether the guardrail runs on every request by default",
|
||||
},
|
||||
"litellm_params": {
|
||||
Type: schema.TypeString,
|
||||
Optional: true,
|
||||
Sensitive: true,
|
||||
DiffSuppressFunc: suppressEquivalentJSON,
|
||||
Description: "JSON object of additional provider-specific parameters merged into litellm_params (e.g. guardrailIdentifier, api_key, api_base). Stored unencrypted in state, so prefer referencing secrets from the environment. Values are masked when read back, so this field is not refreshed from the API to avoid spurious diffs",
|
||||
},
|
||||
"guardrail_info": {
|
||||
Type: schema.TypeString,
|
||||
Optional: true,
|
||||
DiffSuppressFunc: suppressEquivalentJSON,
|
||||
Description: "JSON object of free-form metadata stored alongside the guardrail",
|
||||
},
|
||||
"guardrail_id": {
|
||||
Type: schema.TypeString,
|
||||
Computed: true,
|
||||
Description: "Unique identifier for the guardrail",
|
||||
},
|
||||
"created_at": {
|
||||
Type: schema.TypeString,
|
||||
Computed: true,
|
||||
Description: "Timestamp when the guardrail was created",
|
||||
},
|
||||
"updated_at": {
|
||||
Type: schema.TypeString,
|
||||
Computed: true,
|
||||
Description: "Timestamp when the guardrail was last updated",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
221
terraform/provider/litellm/resource_guardrail_crud.go
Normal file
221
terraform/provider/litellm/resource_guardrail_crud.go
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
package litellm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
|
||||
)
|
||||
|
||||
func buildGuardrailSpec(d *schema.ResourceData) (GuardrailSpec, error) {
|
||||
litellmParams := map[string]interface{}{
|
||||
"guardrail": d.Get("guardrail").(string),
|
||||
"mode": parseGuardrailMode(d.Get("mode").(string)),
|
||||
"default_on": d.Get("default_on").(bool),
|
||||
}
|
||||
|
||||
if raw, ok := d.GetOk("litellm_params"); ok {
|
||||
extra, err := decodeJSONObject(raw.(string))
|
||||
if err != nil {
|
||||
return GuardrailSpec{}, fmt.Errorf("litellm_params must be a JSON object: %w", err)
|
||||
}
|
||||
for k, v := range extra {
|
||||
litellmParams[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
spec := GuardrailSpec{
|
||||
GuardrailName: d.Get("guardrail_name").(string),
|
||||
LiteLLMParams: litellmParams,
|
||||
}
|
||||
|
||||
if raw, ok := d.GetOk("guardrail_info"); ok {
|
||||
info, err := decodeJSONObject(raw.(string))
|
||||
if err != nil {
|
||||
return GuardrailSpec{}, fmt.Errorf("guardrail_info must be a JSON object: %w", err)
|
||||
}
|
||||
spec.GuardrailInfo = info
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// parseGuardrailMode returns a []string when the value is a JSON array, otherwise the raw string.
|
||||
func parseGuardrailMode(mode string) interface{} {
|
||||
trimmed := strings.TrimSpace(mode)
|
||||
if strings.HasPrefix(trimmed, "[") {
|
||||
var modes []string
|
||||
if err := json.Unmarshal([]byte(trimmed), &modes); err == nil {
|
||||
return modes
|
||||
}
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
func decodeJSONObject(raw string) (map[string]interface{}, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func resourceLiteLLMGuardrailCreate(d *schema.ResourceData, m interface{}) error {
|
||||
client := m.(*Client)
|
||||
|
||||
spec, err := buildGuardrailSpec(d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := MakeRequest(client, "POST", "/guardrails", GuardrailRequest{Guardrail: spec})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create guardrail: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var guardrailResp GuardrailResponse
|
||||
if err := handleGuardrailAPIResponse(resp, &guardrailResp, client); err != nil {
|
||||
return fmt.Errorf("failed to create guardrail: %w", err)
|
||||
}
|
||||
|
||||
if guardrailResp.GuardrailID == "" {
|
||||
return fmt.Errorf("guardrail created but the API did not return a guardrail_id")
|
||||
}
|
||||
|
||||
d.SetId(guardrailResp.GuardrailID)
|
||||
|
||||
return resourceLiteLLMGuardrailRead(d, m)
|
||||
}
|
||||
|
||||
func resourceLiteLLMGuardrailRead(d *schema.ResourceData, m interface{}) error {
|
||||
client := m.(*Client)
|
||||
|
||||
resp, err := MakeRequest(client, "GET", fmt.Sprintf("/guardrails/%s/info", d.Id()), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read guardrail: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var guardrailResp GuardrailResponse
|
||||
if err := handleGuardrailAPIResponse(resp, &guardrailResp, client); err != nil {
|
||||
if err.Error() == "guardrail_not_found" {
|
||||
d.SetId("")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to read guardrail: %w", err)
|
||||
}
|
||||
|
||||
d.Set("guardrail_id", guardrailResp.GuardrailID)
|
||||
d.Set("guardrail_name", guardrailResp.GuardrailName)
|
||||
d.Set("created_at", guardrailResp.CreatedAt)
|
||||
d.Set("updated_at", guardrailResp.UpdatedAt)
|
||||
|
||||
// Reconcile the non-sensitive params that live inside litellm_params. The
|
||||
// rest of litellm_params is masked by the proxy on read, so persisting it
|
||||
// would produce perpetual diffs; the configured value is left untouched.
|
||||
if guardrail, ok := guardrailResp.LiteLLMParams["guardrail"].(string); ok {
|
||||
d.Set("guardrail", guardrail)
|
||||
}
|
||||
if defaultOn, ok := guardrailResp.LiteLLMParams["default_on"].(bool); ok {
|
||||
d.Set("default_on", defaultOn)
|
||||
}
|
||||
if err := setGuardrailMode(d, guardrailResp.LiteLLMParams["mode"]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(guardrailResp.GuardrailInfo) > 0 {
|
||||
encoded, err := json.Marshal(guardrailResp.GuardrailInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode guardrail_info: %w", err)
|
||||
}
|
||||
d.Set("guardrail_info", string(encoded))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setGuardrailMode(d *schema.ResourceData, mode interface{}) error {
|
||||
switch value := mode.(type) {
|
||||
case string:
|
||||
d.Set("mode", value)
|
||||
case []interface{}:
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode guardrail mode: %w", err)
|
||||
}
|
||||
d.Set("mode", string(encoded))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resourceLiteLLMGuardrailUpdate(d *schema.ResourceData, m interface{}) error {
|
||||
client := m.(*Client)
|
||||
|
||||
spec, err := buildGuardrailSpec(d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec.GuardrailID = d.Id()
|
||||
|
||||
resp, err := MakeRequest(client, "PUT", fmt.Sprintf("/guardrails/%s", d.Id()), GuardrailRequest{Guardrail: spec})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update guardrail: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := handleGuardrailAPIResponse(resp, nil, client); err != nil {
|
||||
return fmt.Errorf("failed to update guardrail: %w", err)
|
||||
}
|
||||
|
||||
return resourceLiteLLMGuardrailRead(d, m)
|
||||
}
|
||||
|
||||
func resourceLiteLLMGuardrailDelete(d *schema.ResourceData, m interface{}) error {
|
||||
client := m.(*Client)
|
||||
|
||||
resp, err := MakeRequest(client, "DELETE", fmt.Sprintf("/guardrails/%s", d.Id()), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete guardrail: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := handleGuardrailAPIResponse(resp, nil, client); err != nil {
|
||||
if err.Error() == "guardrail_not_found" {
|
||||
d.SetId("")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to delete guardrail: %w", err)
|
||||
}
|
||||
|
||||
d.SetId("")
|
||||
return nil
|
||||
}
|
||||
|
||||
// suppressEquivalentJSON suppresses diffs between two JSON strings that are
|
||||
// semantically equal but differ in key ordering or whitespace.
|
||||
func suppressEquivalentJSON(_, oldValue, newValue string, _ *schema.ResourceData) bool {
|
||||
if oldValue == newValue {
|
||||
return true
|
||||
}
|
||||
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
|
||||
}
|
||||
oldNorm, err := json.Marshal(oldObj)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
newNorm, err := json.Marshal(newObj)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return string(oldNorm) == string(newNorm)
|
||||
}
|
||||
266
terraform/provider/litellm/resource_guardrail_crud_test.go
Normal file
266
terraform/provider/litellm/resource_guardrail_crud_test.go
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
package litellm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
|
||||
)
|
||||
|
||||
func newGuardrailResourceData(t *testing.T, raw map[string]interface{}) *schema.ResourceData {
|
||||
t.Helper()
|
||||
return schema.TestResourceDataRaw(t, resourceLiteLLMGuardrail().Schema, raw)
|
||||
}
|
||||
|
||||
func TestBuildGuardrailSpecMergesParams(t *testing.T) {
|
||||
d := newGuardrailResourceData(t, map[string]interface{}{
|
||||
"guardrail_name": "bedrock-guard",
|
||||
"guardrail": "bedrock",
|
||||
"mode": "pre_call",
|
||||
"default_on": true,
|
||||
"litellm_params": `{"guardrailIdentifier": "ff6ujrregl1q", "guardrailVersion": "DRAFT"}`,
|
||||
"guardrail_info": `{"description": "content moderation"}`,
|
||||
})
|
||||
|
||||
spec, err := buildGuardrailSpec(d)
|
||||
if err != nil {
|
||||
t.Fatalf("buildGuardrailSpec failed: %v", err)
|
||||
}
|
||||
|
||||
if spec.GuardrailName != "bedrock-guard" {
|
||||
t.Fatalf("unexpected guardrail_name: %q", spec.GuardrailName)
|
||||
}
|
||||
if spec.LiteLLMParams["guardrail"] != "bedrock" {
|
||||
t.Fatalf("guardrail type not set in litellm_params: %v", spec.LiteLLMParams)
|
||||
}
|
||||
if spec.LiteLLMParams["mode"] != "pre_call" {
|
||||
t.Fatalf("mode not set in litellm_params: %v", spec.LiteLLMParams["mode"])
|
||||
}
|
||||
if spec.LiteLLMParams["default_on"] != true {
|
||||
t.Fatalf("default_on not merged: %v", spec.LiteLLMParams["default_on"])
|
||||
}
|
||||
if spec.LiteLLMParams["guardrailIdentifier"] != "ff6ujrregl1q" {
|
||||
t.Fatalf("extra litellm_params not merged: %v", spec.LiteLLMParams)
|
||||
}
|
||||
if spec.GuardrailInfo["description"] != "content moderation" {
|
||||
t.Fatalf("guardrail_info not parsed: %v", spec.GuardrailInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGuardrailSpecParsesModeArray(t *testing.T) {
|
||||
d := newGuardrailResourceData(t, map[string]interface{}{
|
||||
"guardrail_name": "multi-mode",
|
||||
"guardrail": "presidio",
|
||||
"mode": `["pre_call", "post_call"]`,
|
||||
})
|
||||
|
||||
spec, err := buildGuardrailSpec(d)
|
||||
if err != nil {
|
||||
t.Fatalf("buildGuardrailSpec failed: %v", err)
|
||||
}
|
||||
|
||||
modes, ok := spec.LiteLLMParams["mode"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("mode was not parsed into a slice: %T %v", spec.LiteLLMParams["mode"], spec.LiteLLMParams["mode"])
|
||||
}
|
||||
if len(modes) != 2 || modes[0] != "pre_call" || modes[1] != "post_call" {
|
||||
t.Fatalf("unexpected parsed modes: %v", modes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGuardrailSpecRejectsInvalidJSON(t *testing.T) {
|
||||
d := newGuardrailResourceData(t, map[string]interface{}{
|
||||
"guardrail_name": "bad",
|
||||
"guardrail": "bedrock",
|
||||
"mode": "pre_call",
|
||||
"litellm_params": `not-json`,
|
||||
})
|
||||
|
||||
if _, err := buildGuardrailSpec(d); err == nil {
|
||||
t.Fatal("expected error for invalid litellm_params JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardrailCreateSendsWrappedRequestAndSetsID(t *testing.T) {
|
||||
var captured GuardrailRequest
|
||||
|
||||
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 == "/guardrails":
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if err := json.Unmarshal(body, &captured); err != nil {
|
||||
t.Errorf("failed to decode create request: %v", err)
|
||||
}
|
||||
json.NewEncoder(w).Encode(GuardrailResponse{
|
||||
GuardrailID: "guard-123",
|
||||
GuardrailName: "bedrock-guard",
|
||||
LiteLLMParams: map[string]interface{}{"guardrail": "bedrock", "mode": "pre_call", "default_on": true},
|
||||
})
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/guardrails/guard-123/info":
|
||||
json.NewEncoder(w).Encode(GuardrailResponse{
|
||||
GuardrailID: "guard-123",
|
||||
GuardrailName: "bedrock-guard",
|
||||
LiteLLMParams: map[string]interface{}{"guardrail": "bedrock", "mode": "pre_call", "default_on": true},
|
||||
CreatedAt: "2026-01-01T00:00:00Z",
|
||||
})
|
||||
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 := newGuardrailResourceData(t, map[string]interface{}{
|
||||
"guardrail_name": "bedrock-guard",
|
||||
"guardrail": "bedrock",
|
||||
"mode": "pre_call",
|
||||
"default_on": true,
|
||||
})
|
||||
|
||||
if err := resourceLiteLLMGuardrailCreate(d, client); err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
|
||||
if d.Id() != "guard-123" {
|
||||
t.Fatalf("resource ID not set from response: %q", d.Id())
|
||||
}
|
||||
if captured.Guardrail.GuardrailName != "bedrock-guard" {
|
||||
t.Fatalf("request was not wrapped under \"guardrail\": %+v", captured)
|
||||
}
|
||||
if captured.Guardrail.LiteLLMParams["guardrail"] != "bedrock" {
|
||||
t.Fatalf("guardrail type not sent: %v", captured.Guardrail.LiteLLMParams)
|
||||
}
|
||||
if d.Get("created_at").(string) != "2026-01-01T00:00:00Z" {
|
||||
t.Fatalf("created_at not populated from read-back: %q", d.Get("created_at"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardrailReadDoesNotPersistMaskedParams(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/guardrails/guard-123/info" {
|
||||
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(GuardrailResponse{
|
||||
GuardrailID: "guard-123",
|
||||
GuardrailName: "bedrock-guard",
|
||||
LiteLLMParams: map[string]interface{}{
|
||||
"guardrail": "bedrock",
|
||||
"mode": "pre_call",
|
||||
"default_on": true,
|
||||
"api_key": "sk-1****",
|
||||
},
|
||||
GuardrailInfo: map[string]interface{}{"description": "content moderation"},
|
||||
CreatedAt: "2026-01-01T00:00:00Z",
|
||||
UpdatedAt: "2026-01-02T00:00:00Z",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "test-key", true)
|
||||
d := newGuardrailResourceData(t, map[string]interface{}{
|
||||
"guardrail_name": "bedrock-guard",
|
||||
"guardrail": "bedrock",
|
||||
"mode": "pre_call",
|
||||
"litellm_params": `{"api_key": "sk-secret-value"}`,
|
||||
})
|
||||
d.SetId("guard-123")
|
||||
|
||||
if err := resourceLiteLLMGuardrailRead(d, client); err != nil {
|
||||
t.Fatalf("read failed: %v", err)
|
||||
}
|
||||
|
||||
if got := d.Get("litellm_params").(string); got != `{"api_key": "sk-secret-value"}` {
|
||||
t.Fatalf("configured litellm_params was overwritten with masked server value: %q", got)
|
||||
}
|
||||
if d.Get("guardrail").(string) != "bedrock" {
|
||||
t.Fatalf("guardrail not reconciled from read: %q", d.Get("guardrail"))
|
||||
}
|
||||
if d.Get("default_on").(bool) != true {
|
||||
t.Fatalf("default_on not reconciled from read")
|
||||
}
|
||||
if d.Get("updated_at").(string) != "2026-01-02T00:00:00Z" {
|
||||
t.Fatalf("updated_at not populated: %q", d.Get("updated_at"))
|
||||
}
|
||||
if d.Get("guardrail_info").(string) != `{"description":"content moderation"}` {
|
||||
t.Fatalf("guardrail_info not reconciled: %q", d.Get("guardrail_info"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardrailReadModeArrayRoundTrips(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(GuardrailResponse{
|
||||
GuardrailID: "guard-9",
|
||||
GuardrailName: "multi",
|
||||
LiteLLMParams: map[string]interface{}{
|
||||
"guardrail": "presidio",
|
||||
"mode": []interface{}{"pre_call", "post_call"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "test-key", true)
|
||||
d := newGuardrailResourceData(t, map[string]interface{}{
|
||||
"guardrail_name": "multi",
|
||||
"guardrail": "presidio",
|
||||
"mode": `["pre_call", "post_call"]`,
|
||||
})
|
||||
d.SetId("guard-9")
|
||||
|
||||
if err := resourceLiteLLMGuardrailRead(d, client); err != nil {
|
||||
t.Fatalf("read failed: %v", err)
|
||||
}
|
||||
|
||||
if got := d.Get("mode").(string); got != `["pre_call","post_call"]` {
|
||||
t.Fatalf("mode array did not round-trip: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardrailReadRemovesResourceOn404(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 := newGuardrailResourceData(t, map[string]interface{}{
|
||||
"guardrail_name": "gone",
|
||||
"guardrail": "bedrock",
|
||||
"mode": "pre_call",
|
||||
})
|
||||
d.SetId("guard-missing")
|
||||
|
||||
if err := resourceLiteLLMGuardrailRead(d, client); err != nil {
|
||||
t.Fatalf("read of missing guardrail should not error: %v", err)
|
||||
}
|
||||
if d.Id() != "" {
|
||||
t.Fatalf("expected resource to be removed from state, still has ID %q", d.Id())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuppressEquivalentJSON(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
oldV, newV string
|
||||
wantSuppressed bool
|
||||
}{
|
||||
{"reordered keys", `{"a":1,"b":2}`, `{"b":2,"a":1}`, true},
|
||||
{"whitespace", `{"a":1}`, `{ "a": 1 }`, true},
|
||||
{"different values", `{"a":1}`, `{"a":2}`, false},
|
||||
{"invalid json", `{"a":1}`, `not-json`, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := suppressEquivalentJSON("", tc.oldV, tc.newV, nil); got != tc.wantSuppressed {
|
||||
t.Fatalf("suppressEquivalentJSON(%q,%q)=%v want %v", tc.oldV, tc.newV, got, tc.wantSuppressed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -246,3 +246,26 @@ type VectorStoreDeleteRequest struct {
|
|||
type VectorStoreInfoRequest struct {
|
||||
VectorStoreID string `json:"vector_store_id"`
|
||||
}
|
||||
|
||||
// GuardrailSpec is the guardrail object sent to and returned by the proxy.
|
||||
type GuardrailSpec struct {
|
||||
GuardrailID string `json:"guardrail_id,omitempty"`
|
||||
GuardrailName string `json:"guardrail_name"`
|
||||
LiteLLMParams map[string]interface{} `json:"litellm_params"`
|
||||
GuardrailInfo map[string]interface{} `json:"guardrail_info,omitempty"`
|
||||
}
|
||||
|
||||
// GuardrailRequest wraps a guardrail spec for create and update calls.
|
||||
type GuardrailRequest struct {
|
||||
Guardrail GuardrailSpec `json:"guardrail"`
|
||||
}
|
||||
|
||||
// GuardrailResponse represents guardrail information returned by the proxy.
|
||||
type GuardrailResponse struct {
|
||||
GuardrailID string `json:"guardrail_id"`
|
||||
GuardrailName string `json:"guardrail_name"`
|
||||
LiteLLMParams map[string]interface{} `json:"litellm_params"`
|
||||
GuardrailInfo map[string]interface{} `json:"guardrail_info,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -247,6 +247,54 @@ func isVectorStoreNotFoundError(errResp ErrorResponse) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// isGuardrailNotFoundError checks if the error response indicates a guardrail not found
|
||||
func isGuardrailNotFoundError(errResp ErrorResponse) bool {
|
||||
if msg, ok := errResp.Error.Message.(string); ok {
|
||||
if strings.Contains(msg, "not found") && strings.Contains(msg, "uardrail") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if errResp.Detail.Error != "" {
|
||||
if strings.Contains(errResp.Detail.Error, "not found") && strings.Contains(errResp.Detail.Error, "uardrail") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// handleGuardrailAPIResponse handles API responses specifically for guardrail operations
|
||||
func handleGuardrailAPIResponse(resp *http.Response, result interface{}, client *Client) error {
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response body: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return fmt.Errorf("guardrail_not_found")
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
var errResp ErrorResponse
|
||||
if err := json.Unmarshal(bodyBytes, &errResp); err == nil {
|
||||
if isGuardrailNotFoundError(errResp) {
|
||||
return fmt.Errorf("guardrail_not_found")
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("API request failed: Status: %s, Response: %s",
|
||||
resp.Status, client.redactSensitiveData(string(bodyBytes)))
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
if err := json.Unmarshal(bodyBytes, result); err != nil {
|
||||
return fmt.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleVectorStoreAPIResponse handles API responses specifically for vector store operations
|
||||
func handleVectorStoreAPIResponse(resp *http.Response, result interface{}, client *Client) error {
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue