mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(terraform): add soft_budget, tags, and soft_budget_alerting_emails to litellm_team (#37918)
* fix(terraform): add soft_budget, tags, and soft_budget_alerting_emails to litellm_team The team resource rejected soft_budget and tags at plan time and had no way to express the list-valued metadata.soft_budget_alerting_emails the proxy reads for soft-budget alerts, even though /team/new and /team/update accept all three. Add the attributes, forward them in buildTeamData (alert emails merged under metadata, where the proxy stores them), and send the full metadata map whenever either half changes because /team/update replaces metadata wholesale. Read was decoding /team/info as if the team fields were top-level, but the proxy nests them under team_info, so every attribute silently fell back to prior state. Decode the envelope and split the proxy's metadata back into tags / soft_budget_alerting_emails / string metadata, dropping the server-managed team_member_budget_id. Verified with OpenTofu plan/apply against a live proxy: the attributes are accepted, land on the proxy, refresh into state, re-plan clean, propagate on update, and clear when removed from HCL. * fix(terraform): clear litellm_team.soft_budget in state when the proxy returns null Read only wrote soft_budget when the proxy returned a value, so a soft budget cleared outside Terraform stayed in state and never surfaced as drift. Set it from the response unconditionally so a null clears it.
This commit is contained in:
parent
f005afa146
commit
a72203eae4
5 changed files with 300 additions and 10 deletions
|
|
@ -14,6 +14,14 @@ longer signal it.
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Versioning**: the provider is now published at the LiteLLM version, from the same commit as the proxy, on every LiteLLM release (dev, rc, stable). The `0.x` line ends at `0.4.0`; a `~> 0.4` constraint will not receive further releases, so re-pin to the LiteLLM version your proxy runs (for example `~> 1.99.0`). Existing `0.x` versions remain in the registry and keep verifying
|
- **Versioning**: the provider is now published at the LiteLLM version, from the same commit as the proxy, on every LiteLLM release (dev, rc, stable). The `0.x` line ends at `0.4.0`; a `~> 0.4` constraint will not receive further releases, so re-pin to the LiteLLM version your proxy runs (for example `~> 1.99.0`). Existing `0.x` versions remain in the registry and keep verifying
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,18 @@ resource "litellm_team" "advanced_team" {
|
||||||
|
|
||||||
# Budget and rate limiting
|
# Budget and rate limiting
|
||||||
max_budget = 1000.0
|
max_budget = 1000.0
|
||||||
|
soft_budget = 800.0
|
||||||
budget_duration = "1mo"
|
budget_duration = "1mo"
|
||||||
tpm_limit = 500000
|
tpm_limit = 500000
|
||||||
rpm_limit = 5000
|
rpm_limit = 5000
|
||||||
blocked = false
|
blocked = false
|
||||||
|
|
||||||
|
# Who gets paged when spend crosses soft_budget
|
||||||
|
soft_budget_alerting_emails = ["finops@example.com"]
|
||||||
|
|
||||||
|
# Tags for spend tracking and tag-based routing
|
||||||
|
tags = ["team:ai-research", "environment:production"]
|
||||||
|
|
||||||
# Team member permissions
|
# Team member permissions
|
||||||
team_member_permissions = [
|
team_member_permissions = [
|
||||||
"create_key",
|
"create_key",
|
||||||
|
|
@ -91,7 +98,9 @@ The following arguments are supported:
|
||||||
|
|
||||||
* `models` - (Optional) List of model names that this team can access.
|
* `models` - (Optional) List of model names that this team can access.
|
||||||
|
|
||||||
* `metadata` - (Optional) A map of metadata key-value pairs associated with the team.
|
* `metadata` - (Optional) A map of string metadata key-value pairs associated with the team. `tags` and `soft_budget_alerting_emails` are stored by the proxy under metadata but are managed through their own attributes below, not this map.
|
||||||
|
|
||||||
|
* `tags` - (Optional) List of tags applied to the team, used for [spend tracking](https://docs.litellm.ai/docs/proxy/enterprise#tracking-spend-for-custom-tags) and [tag-based routing](https://docs.litellm.ai/docs/proxy/tag_routing).
|
||||||
|
|
||||||
* `blocked` - (Optional) Whether the team is blocked from making requests. Default is `false`.
|
* `blocked` - (Optional) Whether the team is blocked from making requests. Default is `false`.
|
||||||
|
|
||||||
|
|
@ -101,6 +110,10 @@ The following arguments are supported:
|
||||||
|
|
||||||
* `max_budget` - (Optional) Maximum budget allocated to the team.
|
* `max_budget` - (Optional) Maximum budget allocated to the team.
|
||||||
|
|
||||||
|
* `soft_budget` - (Optional) Spend threshold at which the proxy sends a soft budget alert without blocking requests.
|
||||||
|
|
||||||
|
* `soft_budget_alerting_emails` - (Optional) List of email addresses notified when the team's spend crosses `soft_budget`.
|
||||||
|
|
||||||
* `budget_duration` - (Optional) Duration for the budget cycle. Valid values are:
|
* `budget_duration` - (Optional) Duration for the budget cycle. Valid values are:
|
||||||
* `daily`
|
* `daily`
|
||||||
* `weekly`
|
* `weekly`
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,11 @@ func ResourceLiteLLMTeam() *schema.Resource {
|
||||||
Type: schema.TypeFloat,
|
Type: schema.TypeFloat,
|
||||||
Optional: true,
|
Optional: true,
|
||||||
},
|
},
|
||||||
|
"soft_budget": {
|
||||||
|
Type: schema.TypeFloat,
|
||||||
|
Optional: true,
|
||||||
|
Description: "Spend threshold that triggers a soft budget alert without blocking requests",
|
||||||
|
},
|
||||||
"budget_duration": {
|
"budget_duration": {
|
||||||
Type: schema.TypeString,
|
Type: schema.TypeString,
|
||||||
Optional: true,
|
Optional: true,
|
||||||
|
|
@ -72,6 +77,18 @@ func ResourceLiteLLMTeam() *schema.Resource {
|
||||||
Elem: &schema.Schema{Type: schema.TypeString},
|
Elem: &schema.Schema{Type: schema.TypeString},
|
||||||
Description: "List of permissions granted to team members",
|
Description: "List of permissions granted to team members",
|
||||||
},
|
},
|
||||||
|
"tags": {
|
||||||
|
Type: schema.TypeList,
|
||||||
|
Optional: true,
|
||||||
|
Elem: &schema.Schema{Type: schema.TypeString},
|
||||||
|
Description: "Tags for spend tracking and tag-based routing",
|
||||||
|
},
|
||||||
|
"soft_budget_alerting_emails": {
|
||||||
|
Type: schema.TypeList,
|
||||||
|
Optional: true,
|
||||||
|
Elem: &schema.Schema{Type: schema.TypeString},
|
||||||
|
Description: "Email addresses alerted when the team crosses soft_budget",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -117,21 +134,20 @@ func resourceLiteLLMTeamRead(d *schema.ResourceData, m interface{}) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var teamResp TeamResponse
|
var infoResp TeamInfoResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&teamResp); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&infoResp); err != nil {
|
||||||
return fmt.Errorf("error decoding team info response: %w", err)
|
return fmt.Errorf("error decoding team info response: %w", err)
|
||||||
}
|
}
|
||||||
|
teamResp := infoResp.TeamInfo
|
||||||
|
|
||||||
// Update the state with values from the response or fall back to the data passed in during creation
|
// Update the state with values from the response or fall back to the data passed in during creation
|
||||||
d.Set("team_alias", GetStringValue(teamResp.TeamAlias, d.Get("team_alias").(string)))
|
d.Set("team_alias", GetStringValue(teamResp.TeamAlias, d.Get("team_alias").(string)))
|
||||||
d.Set("organization_id", GetStringValue(teamResp.OrganizationID, d.Get("organization_id").(string)))
|
d.Set("organization_id", GetStringValue(teamResp.OrganizationID, d.Get("organization_id").(string)))
|
||||||
|
|
||||||
// Handle metadata separately as it's a map
|
metadata, tags, alertEmails := splitTeamMetadata(teamResp.Metadata)
|
||||||
if teamResp.Metadata != nil {
|
d.Set("metadata", metadata)
|
||||||
d.Set("metadata", teamResp.Metadata)
|
d.Set("tags", tags)
|
||||||
} else {
|
d.Set("soft_budget_alerting_emails", alertEmails)
|
||||||
d.Set("metadata", d.Get("metadata"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if teamResp.TPMLimit != nil {
|
if teamResp.TPMLimit != nil {
|
||||||
d.Set("tpm_limit", *teamResp.TPMLimit)
|
d.Set("tpm_limit", *teamResp.TPMLimit)
|
||||||
|
|
@ -142,6 +158,7 @@ func resourceLiteLLMTeamRead(d *schema.ResourceData, m interface{}) error {
|
||||||
if teamResp.MaxBudget != nil {
|
if teamResp.MaxBudget != nil {
|
||||||
d.Set("max_budget", *teamResp.MaxBudget)
|
d.Set("max_budget", *teamResp.MaxBudget)
|
||||||
}
|
}
|
||||||
|
d.Set("soft_budget", teamResp.SoftBudget)
|
||||||
d.Set("budget_duration", GetStringValue(teamResp.BudgetDuration, d.Get("budget_duration").(string)))
|
d.Set("budget_duration", GetStringValue(teamResp.BudgetDuration, d.Get("budget_duration").(string)))
|
||||||
|
|
||||||
// Handle models separately as it's a list
|
// Handle models separately as it's a list
|
||||||
|
|
@ -240,15 +257,77 @@ func buildTeamData(d *schema.ResourceData, teamID string) map[string]interface{}
|
||||||
"team_alias": d.Get("team_alias").(string),
|
"team_alias": d.Get("team_alias").(string),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, key := range []string{"organization_id", "metadata", "tpm_limit", "rpm_limit", "max_budget", "budget_duration", "models", "blocked", "team_member_permissions"} {
|
for _, key := range []string{"organization_id", "tpm_limit", "rpm_limit", "max_budget", "budget_duration", "models", "blocked", "team_member_permissions"} {
|
||||||
if v, ok := d.GetOk(key); ok {
|
if v, ok := d.GetOk(key); ok {
|
||||||
teamData[key] = v
|
teamData[key] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if v, ok := d.GetOk("soft_budget"); ok {
|
||||||
|
teamData["soft_budget"] = v
|
||||||
|
} else if d.HasChange("soft_budget") {
|
||||||
|
teamData["soft_budget"] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := d.GetOk("tags"); ok || d.HasChange("tags") {
|
||||||
|
teamData["tags"] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata := buildTeamMetadata(d); metadata != nil {
|
||||||
|
teamData["metadata"] = metadata
|
||||||
|
}
|
||||||
|
|
||||||
return teamData
|
return teamData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /team/update replaces metadata wholesale, so the full map must go out whenever either half changed.
|
||||||
|
func buildTeamMetadata(d *schema.ResourceData) map[string]interface{} {
|
||||||
|
metadata := map[string]interface{}{}
|
||||||
|
for k, v := range d.Get("metadata").(map[string]interface{}) {
|
||||||
|
metadata[k] = v
|
||||||
|
}
|
||||||
|
if v, ok := d.GetOk("soft_budget_alerting_emails"); ok {
|
||||||
|
metadata["soft_budget_alerting_emails"] = v
|
||||||
|
}
|
||||||
|
if len(metadata) == 0 && !d.HasChange("metadata") && !d.HasChange("soft_budget_alerting_emails") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitTeamMetadata(raw map[string]interface{}) (map[string]string, []string, []string) {
|
||||||
|
metadata := map[string]string{}
|
||||||
|
var tags, alertEmails []string
|
||||||
|
for k, v := range raw {
|
||||||
|
switch k {
|
||||||
|
case "tags":
|
||||||
|
tags = toStringSlice(v)
|
||||||
|
case "soft_budget_alerting_emails":
|
||||||
|
alertEmails = toStringSlice(v)
|
||||||
|
case "team_member_budget_id":
|
||||||
|
default:
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
metadata[k] = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return metadata, tags, alertEmails
|
||||||
|
}
|
||||||
|
|
||||||
|
func toStringSlice(v interface{}) []string {
|
||||||
|
items, ok := v.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if s, ok := item.(string); ok {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func handleResponse(resp *http.Response, action string) error {
|
func handleResponse(resp *http.Response, action string) error {
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
|
||||||
184
terraform/provider/litellm/resource_team_test.go
Normal file
184
terraform/provider/litellm/resource_team_test.go
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
package litellm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
|
||||||
|
"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTeamTestServer(t *testing.T, captured *map[string]interface{}, infoBody string) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case endpointTeamNew, endpointTeamUpdate:
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
json.Unmarshal(body, captured)
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
case endpointTeamInfo:
|
||||||
|
w.Write([]byte(infoBody))
|
||||||
|
case endpointTeamPermissionsList:
|
||||||
|
w.Write([]byte(`{"team_id":"team-1","team_member_permissions":[],"all_available_permissions":[]}`))
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamInfoWithSoftBudget = `{
|
||||||
|
"team_id": "team-1",
|
||||||
|
"team_info": {
|
||||||
|
"team_id": "team-1",
|
||||||
|
"team_alias": "insights",
|
||||||
|
"max_budget": 750.0,
|
||||||
|
"soft_budget": 600.0,
|
||||||
|
"models": ["claude-haiku-4-5"],
|
||||||
|
"metadata": {
|
||||||
|
"department": "customer-insights",
|
||||||
|
"tags": ["team:customer-insights", "environment:production"],
|
||||||
|
"soft_budget_alerting_emails": ["finops@example.com"],
|
||||||
|
"team_member_budget_id": "budget-1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"keys": [],
|
||||||
|
"team_memberships": []
|
||||||
|
}`
|
||||||
|
|
||||||
|
func TestTeamCreateSendsSoftBudgetTagsAndAlertEmails(t *testing.T) {
|
||||||
|
var captured map[string]interface{}
|
||||||
|
srv := newTeamTestServer(t, &captured, teamInfoWithSoftBudget)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
d := schema.TestResourceDataRaw(t, ResourceLiteLLMTeam().Schema, map[string]interface{}{
|
||||||
|
"team_alias": "insights",
|
||||||
|
"max_budget": 750.0,
|
||||||
|
"soft_budget": 600.0,
|
||||||
|
"tags": []interface{}{"team:customer-insights", "environment:production"},
|
||||||
|
"soft_budget_alerting_emails": []interface{}{"finops@example.com"},
|
||||||
|
"metadata": map[string]interface{}{"department": "customer-insights"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := resourceLiteLLMTeamCreate(d, NewClient(srv.URL, "test-key", true)); err != nil {
|
||||||
|
t.Fatalf("create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := captured["soft_budget"]; got != 600.0 {
|
||||||
|
t.Fatalf("payload soft_budget = %v, want 600", got)
|
||||||
|
}
|
||||||
|
wantTags := []interface{}{"team:customer-insights", "environment:production"}
|
||||||
|
if got := captured["tags"]; !reflect.DeepEqual(got, wantTags) {
|
||||||
|
t.Fatalf("payload tags = %v, want %v", got, wantTags)
|
||||||
|
}
|
||||||
|
wantMetadata := map[string]interface{}{
|
||||||
|
"department": "customer-insights",
|
||||||
|
"soft_budget_alerting_emails": []interface{}{"finops@example.com"},
|
||||||
|
}
|
||||||
|
if got := captured["metadata"]; !reflect.DeepEqual(got, wantMetadata) {
|
||||||
|
t.Fatalf("payload metadata = %v, want %v", got, wantMetadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTeamReadMapsTeamInfoEnvelope(t *testing.T) {
|
||||||
|
var captured map[string]interface{}
|
||||||
|
srv := newTeamTestServer(t, &captured, teamInfoWithSoftBudget)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
d := schema.TestResourceDataRaw(t, ResourceLiteLLMTeam().Schema, map[string]interface{}{})
|
||||||
|
d.SetId("team-1")
|
||||||
|
|
||||||
|
if err := resourceLiteLLMTeamRead(d, NewClient(srv.URL, "test-key", true)); err != nil {
|
||||||
|
t.Fatalf("read failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := d.Get("team_alias"); got != "insights" {
|
||||||
|
t.Fatalf("team_alias = %v, want insights", got)
|
||||||
|
}
|
||||||
|
if got := d.Get("soft_budget"); got != 600.0 {
|
||||||
|
t.Fatalf("soft_budget = %v, want 600", got)
|
||||||
|
}
|
||||||
|
if got := d.Get("max_budget"); got != 750.0 {
|
||||||
|
t.Fatalf("max_budget = %v, want 750", got)
|
||||||
|
}
|
||||||
|
wantTags := []interface{}{"team:customer-insights", "environment:production"}
|
||||||
|
if got := d.Get("tags"); !reflect.DeepEqual(got, wantTags) {
|
||||||
|
t.Fatalf("tags = %v, want %v", got, wantTags)
|
||||||
|
}
|
||||||
|
wantEmails := []interface{}{"finops@example.com"}
|
||||||
|
if got := d.Get("soft_budget_alerting_emails"); !reflect.DeepEqual(got, wantEmails) {
|
||||||
|
t.Fatalf("soft_budget_alerting_emails = %v, want %v", got, wantEmails)
|
||||||
|
}
|
||||||
|
wantMetadata := map[string]interface{}{"department": "customer-insights"}
|
||||||
|
if got := d.Get("metadata"); !reflect.DeepEqual(got, wantMetadata) {
|
||||||
|
t.Fatalf("metadata = %v, want %v (server-managed team_member_budget_id dropped)", got, wantMetadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTeamUpdateClearsRemovedTagsAndSoftBudget(t *testing.T) {
|
||||||
|
var captured map[string]interface{}
|
||||||
|
srv := newTeamTestServer(t, &captured, `{"team_id":"team-1","team_info":{"team_id":"team-1","team_alias":"insights"},"keys":[],"team_memberships":[]}`)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
res := ResourceLiteLLMTeam()
|
||||||
|
priorData := schema.TestResourceDataRaw(t, res.Schema, map[string]interface{}{
|
||||||
|
"team_alias": "insights",
|
||||||
|
"soft_budget": 600.0,
|
||||||
|
"tags": []interface{}{"team:to-be-removed"},
|
||||||
|
"soft_budget_alerting_emails": []interface{}{"ops@example.com"},
|
||||||
|
"metadata": map[string]interface{}{"department": "eng"},
|
||||||
|
})
|
||||||
|
priorData.SetId("team-1")
|
||||||
|
prior := priorData.State()
|
||||||
|
config := terraform.NewResourceConfigRaw(map[string]interface{}{
|
||||||
|
"team_alias": "insights",
|
||||||
|
"metadata": map[string]interface{}{"department": "eng"},
|
||||||
|
})
|
||||||
|
diff, err := res.Diff(context.Background(), prior, config, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("diff failed: %v", err)
|
||||||
|
}
|
||||||
|
d, err := schema.InternalMap(res.Schema).Data(prior, diff)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("data failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := resourceLiteLLMTeamUpdate(d, NewClient(srv.URL, "test-key", true)); err != nil {
|
||||||
|
t.Fatalf("update failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, ok := captured["soft_budget"]; !ok || got != nil {
|
||||||
|
t.Fatalf("payload soft_budget = %v (present=%v), want explicit null", got, ok)
|
||||||
|
}
|
||||||
|
if got := captured["tags"]; !reflect.DeepEqual(got, []interface{}{}) {
|
||||||
|
t.Fatalf("payload tags = %v, want []", got)
|
||||||
|
}
|
||||||
|
if got := captured["metadata"]; !reflect.DeepEqual(got, map[string]interface{}{"department": "eng"}) {
|
||||||
|
t.Fatalf("payload metadata = %v, want department only", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTeamReadClearsSoftBudgetWhenProxyReturnsNull(t *testing.T) {
|
||||||
|
var captured map[string]interface{}
|
||||||
|
srv := newTeamTestServer(t, &captured, `{"team_id":"team-1","team_info":{"team_id":"team-1","team_alias":"insights","soft_budget":null},"keys":[],"team_memberships":[]}`)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
d := schema.TestResourceDataRaw(t, ResourceLiteLLMTeam().Schema, map[string]interface{}{
|
||||||
|
"team_alias": "insights",
|
||||||
|
"soft_budget": 600.0,
|
||||||
|
})
|
||||||
|
d.SetId("team-1")
|
||||||
|
|
||||||
|
if err := resourceLiteLLMTeamRead(d, NewClient(srv.URL, "test-key", true)); err != nil {
|
||||||
|
t.Fatalf("read failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := d.Get("soft_budget"); got != 0.0 {
|
||||||
|
t.Fatalf("soft_budget = %v, want cleared after the proxy returned null", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,11 @@ type ModelRequest struct {
|
||||||
Additional map[string]interface{} `json:"additional"`
|
Additional map[string]interface{} `json:"additional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TeamInfoResponse struct {
|
||||||
|
TeamID string `json:"team_id"`
|
||||||
|
TeamInfo TeamResponse `json:"team_info"`
|
||||||
|
}
|
||||||
|
|
||||||
// TeamResponse represents a response from the API containing team information.
|
// TeamResponse represents a response from the API containing team information.
|
||||||
type TeamResponse struct {
|
type TeamResponse struct {
|
||||||
TeamID string `json:"team_id,omitempty"`
|
TeamID string `json:"team_id,omitempty"`
|
||||||
|
|
@ -42,6 +47,7 @@ type TeamResponse struct {
|
||||||
TPMLimit *int `json:"tpm_limit,omitempty"`
|
TPMLimit *int `json:"tpm_limit,omitempty"`
|
||||||
RPMLimit *int `json:"rpm_limit,omitempty"`
|
RPMLimit *int `json:"rpm_limit,omitempty"`
|
||||||
MaxBudget *float64 `json:"max_budget,omitempty"`
|
MaxBudget *float64 `json:"max_budget,omitempty"`
|
||||||
|
SoftBudget *float64 `json:"soft_budget,omitempty"`
|
||||||
BudgetDuration string `json:"budget_duration,omitempty"`
|
BudgetDuration string `json:"budget_duration,omitempty"`
|
||||||
Models []string `json:"models"`
|
Models []string `json:"models"`
|
||||||
Blocked bool `json:"blocked,omitempty"`
|
Blocked bool `json:"blocked,omitempty"`
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue