From 155ab58e28b1704677aa89c753fae210ca9442a3 Mon Sep 17 00:00:00 2001 From: Matthew Howard <78384492+matthowardcohere@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:39:07 -0400 Subject: [PATCH 1/8] fix(credential): adopt existing credential on name conflict instead of failing credential_name is the natural key. If a credential with that name already exists, take ownership and update it to match the configured values rather than erroring on the unique-constraint conflict. Fixes https://github.com/BerriAI/terraform-provider-litellm/issues/8 --- terraform/provider/litellm/resource_credential_crud.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/terraform/provider/litellm/resource_credential_crud.go b/terraform/provider/litellm/resource_credential_crud.go index dd9aef64f76..ace0d97d5a0 100644 --- a/terraform/provider/litellm/resource_credential_crud.go +++ b/terraform/provider/litellm/resource_credential_crud.go @@ -88,6 +88,16 @@ func resourceLiteLLMCredentialCreate(d *schema.ResourceData, m interface{}) erro err = handleCredentialAPIResponse(resp, nil, client) if err != nil { + // If a credential with this name already exists, adopt it instead of + // failing. credential_name is the natural key, so we take ownership + // and update the existing credential to match the configured values + // rather than erroring on the unique-constraint conflict. See + // https://github.com/BerriAI/terraform-provider-litellm/issues/8. + if err.Error() == "credential_conflict" { + log.Printf("[WARN] Credential %q already exists; adopting it and updating to match configuration.", credentialName) + d.SetId(credentialName) + return resourceLiteLLMCredentialUpdate(d, m) + } return fmt.Errorf("failed to create credential: %w", err) } From cff3a5e9b6200fe1a5a99c7413b1e7391249fcb3 Mon Sep 17 00:00:00 2001 From: Matthew Howard <78384492+matthowardcohere@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:39:42 -0400 Subject: [PATCH 2/8] fix(credential): recognize the credential_name unique-constraint conflict Adds isCredentialConflictError and wires it into handleCredentialAPIResponse so resourceLiteLLMCredentialCreate can tell a name conflict apart from other failures. Fixes https://github.com/BerriAI/terraform-provider-litellm/issues/8 --- terraform/provider/litellm/utils.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/terraform/provider/litellm/utils.go b/terraform/provider/litellm/utils.go index 5e81766d3f3..2a35973c929 100644 --- a/terraform/provider/litellm/utils.go +++ b/terraform/provider/litellm/utils.go @@ -202,6 +202,28 @@ func isCredentialNotFoundError(errResp ErrorResponse) bool { return false } +// isCredentialConflictError checks if the error response indicates a credential +// name collision. LiteLLM surfaces this as a 500 carrying the underlying Prisma +// unique-constraint message on credential_name. See +// https://github.com/BerriAI/terraform-provider-litellm/issues/8. +func isCredentialConflictError(errResp ErrorResponse) bool { + if msg, ok := errResp.Error.Message.(string); ok { + if strings.Contains(msg, "Unique constraint failed") && strings.Contains(msg, "credential_name") { + return true + } + } + + if msgMap, ok := errResp.Error.Message.(map[string]interface{}); ok { + if errStr, ok := msgMap["error"].(string); ok { + if strings.Contains(errStr, "Unique constraint failed") && strings.Contains(errStr, "credential_name") { + return true + } + } + } + + return false +} + // handleCredentialAPIResponse handles API responses specifically for credential operations func handleCredentialAPIResponse(resp *http.Response, result interface{}, client *Client) error { bodyBytes, err := io.ReadAll(resp.Body) @@ -219,6 +241,9 @@ func handleCredentialAPIResponse(resp *http.Response, result interface{}, client if isCredentialNotFoundError(errResp) { return fmt.Errorf("credential_not_found") } + if isCredentialConflictError(errResp) { + return fmt.Errorf("credential_conflict") + } } return fmt.Errorf("API request failed: Status: %s, Response: %s", resp.Status, client.redactSensitiveData(string(bodyBytes))) From 9d316207a23aade5d47b6e86ff69745b64bef3e7 Mon Sep 17 00:00:00 2001 From: Matthew Howard <78384492+matthowardcohere@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:07:56 -0400 Subject: [PATCH 3/8] test(credential): cover adopt-on-conflict create path Fixes https://github.com/BerriAI/terraform-provider-litellm/issues/8 --- .../litellm/resource_credential_crud_test.go | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/terraform/provider/litellm/resource_credential_crud_test.go b/terraform/provider/litellm/resource_credential_crud_test.go index 3398e58dd13..879f5904723 100644 --- a/terraform/provider/litellm/resource_credential_crud_test.go +++ b/terraform/provider/litellm/resource_credential_crud_test.go @@ -199,3 +199,57 @@ func TestRetryCredentialRead_ConnectionError(t *testing.T) { // Connection error should not be retried (not a "credential_not_found") fmt.Printf("connection error (expected): %v\n", err) } + +// A credential that already exists in LiteLLM (created out of band, or left +// behind by a prior apply that dropped state) must be adopted on create +// instead of failing on the credential_name unique-constraint conflict. +func TestResourceLiteLLMCredentialCreate_AdoptsOnConflict(t *testing.T) { + var createCalls, updateCalls, readCalls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/credentials": + atomic.AddInt32(&createCalls, 1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":{"message":"Unique constraint failed on the fields: (` + "`credential_name`" + `)","type":"internal_server_error","code":"500"}}`)) + case r.Method == http.MethodPatch: + atomic.AddInt32(&updateCalls, 1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + case r.Method == http.MethodGet: + atomic.AddInt32(&readCalls, 1) + resp := CredentialResponse{CredentialName: "conflict-test", CredentialInfo: map[string]interface{}{}} + body, _ := json.Marshal(resp) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(body) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := schema.TestResourceDataRaw(t, resourceLiteLLMCredential().Schema, map[string]interface{}{ + "credential_name": "conflict-test", + "credential_info": map[string]interface{}{}, + "credential_values": map[string]interface{}{"key": "val"}, + }) + + if err := resourceLiteLLMCredentialCreate(d, client); err != nil { + t.Fatalf("expected create to adopt the existing credential, got error: %v", err) + } + if d.Id() != "conflict-test" { + t.Fatalf("expected ID %q, got %q", "conflict-test", d.Id()) + } + if atomic.LoadInt32(&createCalls) != 1 { + t.Fatalf("expected exactly 1 POST /credentials call, got %d", createCalls) + } + if atomic.LoadInt32(&updateCalls) != 1 { + t.Fatalf("expected the conflict to trigger exactly 1 PATCH (adopt-and-update), got %d", updateCalls) + } + if atomic.LoadInt32(&readCalls) < 1 { + t.Fatalf("expected the post-adopt retry read to run at least once, got %d", readCalls) + } +} From d88d28c33f75bc0e2591ff910f5aa86bd4a506c1 Mon Sep 17 00:00:00 2001 From: Matthew Howard <78384492+matthowardcohere@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:09:40 -0400 Subject: [PATCH 4/8] docs(changelog): note credential adopt-on-conflict fix --- terraform/provider/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/terraform/provider/CHANGELOG.md b/terraform/provider/CHANGELOG.md index 842bfb4bdb1..d68ee62ed65 100644 --- a/terraform/provider/CHANGELOG.md +++ b/terraform/provider/CHANGELOG.md @@ -36,6 +36,7 @@ longer signal it. ### Fixed +- **credential**: `litellm_credential` create now adopts an existing credential on a `credential_name` conflict instead of failing with a 500; `apply` is idempotent again once state loses track of a credential that still exists on the proxy - **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 From 1312a16435a3a8039fd52e382711ce57617fdfc8 Mon Sep 17 00:00:00 2001 From: Matthew Howard <78384492+matthowardcohere@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:58:48 -0400 Subject: [PATCH 5/8] fix(credential): don't taint state on a failed adopt; carry model_id into the adopt-update Two bugs in the adopt-on-conflict path, both found in review: - d.SetId ran before the adopt PATCH could fail. A failed PATCH left a tainted entry for a credential this run doesn't own, so the next apply would destroy it. The ID now only sticks once the adopt actually succeeds. - The adopt path routed through resourceLiteLLMCredentialUpdate, whose request never carried model_id, so adopting a model_id-scoped credential skipped the proxy's model-based credential resolution and could silently apply the wrong values. --- .../litellm/resource_credential_crud.go | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/terraform/provider/litellm/resource_credential_crud.go b/terraform/provider/litellm/resource_credential_crud.go index ace0d97d5a0..cb5031ee04e 100644 --- a/terraform/provider/litellm/resource_credential_crud.go +++ b/terraform/provider/litellm/resource_credential_crud.go @@ -89,14 +89,24 @@ func resourceLiteLLMCredentialCreate(d *schema.ResourceData, m interface{}) erro err = handleCredentialAPIResponse(resp, nil, client) if err != nil { // If a credential with this name already exists, adopt it instead of - // failing. credential_name is the natural key, so we take ownership - // and update the existing credential to match the configured values + // failing: take ownership and update the existing credential's + // values (merged onto whatever it already had - not a full replace) // rather than erroring on the unique-constraint conflict. See // https://github.com/BerriAI/terraform-provider-litellm/issues/8. if err.Error() == "credential_conflict" { log.Printf("[WARN] Credential %q already exists; adopting it and updating to match configuration.", credentialName) d.SetId(credentialName) - return resourceLiteLLMCredentialUpdate(d, m) + if updateErr := resourceLiteLLMCredentialUpdate(d, m); updateErr != nil { + // Adoption failed before this run took ownership of + // anything real. Clear the ID so create is reported as + // failed outright (matching pre-adoption behavior) instead + // of tainting state for a credential this run doesn't own - + // state that would otherwise get destroyed on the next + // apply. + d.SetId("") + return fmt.Errorf("failed to adopt existing credential %q: %w", credentialName, updateErr) + } + return nil } return fmt.Errorf("failed to create credential: %w", err) } @@ -152,6 +162,7 @@ func resourceLiteLLMCredentialUpdate(d *schema.ResourceData, m interface{}) erro client := m.(*Client) credentialName := d.Id() + modelID := d.Get("model_id").(string) credentialInfo := d.Get("credential_info").(map[string]interface{}) credentialValues := d.Get("credential_values").(map[string]interface{}) @@ -167,8 +178,13 @@ func resourceLiteLLMCredentialUpdate(d *schema.ResourceData, m interface{}) erro credValuesMap[k] = v } + // model_id must travel with the update the same way it does on create, + // so the proxy's model-based credential resolution still applies. Without + // it, updating (or adopting) a model_id-scoped credential silently loses + // that association. credentialRequest := CredentialRequest{ CredentialName: credentialName, + ModelID: modelID, CredentialInfo: credInfoMap, CredentialValues: credValuesMap, } From 84dd8cb39df76d5109581d2c5b5791d3b779bebb Mon Sep 17 00:00:00 2001 From: Matthew Howard <78384492+matthowardcohere@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:59:29 -0400 Subject: [PATCH 6/8] fix(credential): check Detail.Error in isCredentialConflictError for consistency Matches the pattern every other isXNotFoundError classifier in this file already follows. --- terraform/provider/litellm/utils.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/terraform/provider/litellm/utils.go b/terraform/provider/litellm/utils.go index 2a35973c929..a123f5d350a 100644 --- a/terraform/provider/litellm/utils.go +++ b/terraform/provider/litellm/utils.go @@ -221,6 +221,13 @@ func isCredentialConflictError(errResp ErrorResponse) bool { } } + // Check Detail.Error field for LiteLLM proxy error format + if errResp.Detail.Error != "" { + if strings.Contains(errResp.Detail.Error, "Unique constraint failed") && strings.Contains(errResp.Detail.Error, "credential_name") { + return true + } + } + return false } From d0fb841c848074fb4a005fbdf170ad71dc89b45b Mon Sep 17 00:00:00 2001 From: Matthew Howard <78384492+matthowardcohere@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:00:21 -0400 Subject: [PATCH 7/8] test(credential): assert PATCH path/body, cover model_id, taint, and non-conflict cases Replaces the method-only PATCH/GET stubs (which stayed green even if adoption hit the wrong endpoint or dropped a field) with assertions on the actual request. Adds coverage for the two bugs fixed in this branch, plus the case where a non-conflict error must not adopt. --- .../litellm/resource_credential_crud_test.go | 140 +++++++++++++++--- 1 file changed, 120 insertions(+), 20 deletions(-) diff --git a/terraform/provider/litellm/resource_credential_crud_test.go b/terraform/provider/litellm/resource_credential_crud_test.go index 879f5904723..02b23a358b0 100644 --- a/terraform/provider/litellm/resource_credential_crud_test.go +++ b/terraform/provider/litellm/resource_credential_crud_test.go @@ -3,6 +3,7 @@ package litellm import ( "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "sync/atomic" @@ -200,11 +201,13 @@ func TestRetryCredentialRead_ConnectionError(t *testing.T) { fmt.Printf("connection error (expected): %v\n", err) } -// A credential that already exists in LiteLLM (created out of band, or left -// behind by a prior apply that dropped state) must be adopted on create -// instead of failing on the credential_name unique-constraint conflict. -func TestResourceLiteLLMCredentialCreate_AdoptsOnConflict(t *testing.T) { - var createCalls, updateCalls, readCalls int32 +// conflictServer builds the shared conflict-then-recover mock used by the +// adoption tests below. patchStatus/patchBody control the PATCH response, so +// callers can exercise both the success and failure paths. +func conflictServer(t *testing.T, patchStatus int, patchBody string) (*httptest.Server, *int32, *int32, *[]byte) { + t.Helper() + var createCalls, patchCalls int32 + var capturedPatchBody []byte srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && r.URL.Path == "/credentials": @@ -213,12 +216,16 @@ func TestResourceLiteLLMCredentialCreate_AdoptsOnConflict(t *testing.T) { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(`{"error":{"message":"Unique constraint failed on the fields: (` + "`credential_name`" + `)","type":"internal_server_error","code":"500"}}`)) case r.Method == http.MethodPatch: - atomic.AddInt32(&updateCalls, 1) + atomic.AddInt32(&patchCalls, 1) + if r.URL.Path != "/credentials/conflict-test" { + t.Errorf("PATCH went to %q, want /credentials/conflict-test", r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + capturedPatchBody = body w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{}`)) + w.WriteHeader(patchStatus) + w.Write([]byte(patchBody)) case r.Method == http.MethodGet: - atomic.AddInt32(&readCalls, 1) resp := CredentialResponse{CredentialName: "conflict-test", CredentialInfo: map[string]interface{}{}} body, _ := json.Marshal(resp) w.Header().Set("Content-Type", "application/json") @@ -228,6 +235,61 @@ func TestResourceLiteLLMCredentialCreate_AdoptsOnConflict(t *testing.T) { http.NotFound(w, r) } })) + return srv, &createCalls, &patchCalls, &capturedPatchBody +} + +// A credential that already exists in LiteLLM (created out of band, or left +// behind by a prior apply that dropped state) must be adopted on create +// instead of failing on the credential_name unique-constraint conflict, and +// the adopt PATCH must carry model_id so model-based credential resolution +// still applies (previously dropped - see +// https://github.com/BerriAI/litellm/pull/39745). +func TestResourceLiteLLMCredentialCreate_AdoptsOnConflict(t *testing.T) { + srv, createCalls, patchCalls, patchBody := conflictServer(t, http.StatusOK, `{}`) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := schema.TestResourceDataRaw(t, resourceLiteLLMCredential().Schema, map[string]interface{}{ + "credential_name": "conflict-test", + "model_id": "model-1", + "credential_info": map[string]interface{}{"custom_llm_provider": "bedrock"}, + "credential_values": map[string]interface{}{"aws_access_key_id": "val"}, + }) + + if err := resourceLiteLLMCredentialCreate(d, client); err != nil { + t.Fatalf("expected create to adopt the existing credential, got error: %v", err) + } + if d.Id() != "conflict-test" { + t.Fatalf("expected ID %q, got %q", "conflict-test", d.Id()) + } + if got := atomic.LoadInt32(createCalls); got != 1 { + t.Fatalf("expected exactly 1 POST /credentials call, got %d", got) + } + if got := atomic.LoadInt32(patchCalls); got != 1 { + t.Fatalf("expected the conflict to trigger exactly 1 PATCH (adopt-and-update), got %d", got) + } + + var sent map[string]interface{} + if err := json.Unmarshal(*patchBody, &sent); err != nil { + t.Fatalf("PATCH body was not valid JSON: %v (%s)", err, *patchBody) + } + if sent["credential_name"] != "conflict-test" { + t.Errorf("PATCH body credential_name = %v, want conflict-test", sent["credential_name"]) + } + if sent["model_id"] != "model-1" { + t.Errorf("PATCH body model_id = %v, want model-1 (adoption must not drop model-based credential resolution)", sent["model_id"]) + } + credInfo, _ := sent["credential_info"].(map[string]interface{}) + if credInfo["custom_llm_provider"] != "bedrock" { + t.Errorf("PATCH body credential_info = %v, want custom_llm_provider=bedrock", sent["credential_info"]) + } +} + +// If the adopt PATCH itself fails, create must not have set the resource ID +// for a credential this run doesn't own - otherwise Terraform taints the +// entry and the *next* apply destroys a credential nobody here created. +func TestResourceLiteLLMCredentialCreate_FailedAdoptDoesNotTaint(t *testing.T) { + srv, createCalls, patchCalls, _ := conflictServer(t, http.StatusInternalServerError, `{"error":{"message":"Internal Server Error"}}`) defer srv.Close() client := NewClient(srv.URL, "test-key", true) @@ -237,19 +299,57 @@ func TestResourceLiteLLMCredentialCreate_AdoptsOnConflict(t *testing.T) { "credential_values": map[string]interface{}{"key": "val"}, }) - if err := resourceLiteLLMCredentialCreate(d, client); err != nil { - t.Fatalf("expected create to adopt the existing credential, got error: %v", err) + err := resourceLiteLLMCredentialCreate(d, client) + if err == nil { + t.Fatal("expected an error when the adopt PATCH fails, got nil") } - if d.Id() != "conflict-test" { - t.Fatalf("expected ID %q, got %q", "conflict-test", d.Id()) + if got := atomic.LoadInt32(createCalls); got != 1 { + t.Fatalf("expected exactly 1 POST /credentials call, got %d", got) } - if atomic.LoadInt32(&createCalls) != 1 { - t.Fatalf("expected exactly 1 POST /credentials call, got %d", createCalls) + if got := atomic.LoadInt32(patchCalls); got != 1 { + t.Fatalf("expected exactly 1 PATCH attempt, got %d", got) } - if atomic.LoadInt32(&updateCalls) != 1 { - t.Fatalf("expected the conflict to trigger exactly 1 PATCH (adopt-and-update), got %d", updateCalls) - } - if atomic.LoadInt32(&readCalls) < 1 { - t.Fatalf("expected the post-adopt retry read to run at least once, got %d", readCalls) + if d.Id() != "" { + t.Fatalf("resource ID must stay empty after a failed adopt, got %q (a tainted entry would be destroyed on the next apply)", d.Id()) + } +} + +// A non-conflict failure (a plain 500, for example) must return the original +// error and never attempt to adopt anything. +func TestResourceLiteLLMCredentialCreate_NonConflictErrorDoesNotAdopt(t *testing.T) { + var createCalls, patchCalls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/credentials": + atomic.AddInt32(&createCalls, 1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":{"message":"Internal Server Error","type":"internal_server_error"}}`)) + case r.Method == http.MethodPatch: + atomic.AddInt32(&patchCalls, 1) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := schema.TestResourceDataRaw(t, resourceLiteLLMCredential().Schema, map[string]interface{}{ + "credential_name": "some-cred", + "credential_info": map[string]interface{}{}, + "credential_values": map[string]interface{}{"key": "val"}, + }) + + err := resourceLiteLLMCredentialCreate(d, client) + if err == nil { + t.Fatal("expected an error for a non-conflict failure, got nil") + } + if got := atomic.LoadInt32(&patchCalls); got != 0 { + t.Fatalf("expected no PATCH attempt for a non-conflict error, got %d", got) + } + if d.Id() != "" { + t.Fatalf("resource ID must stay empty on a non-conflict failure, got %q", d.Id()) } } From 1b201cf0d4d20f44ff37e08a701d4e4fe4b19fe7 Mon Sep 17 00:00:00 2001 From: Matthew Howard <78384492+matthowardcohere@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:41:44 -0400 Subject: [PATCH 8/8] test(credential): assert the post-adopt read path too, per Greptile P2 The GET case only matched on method, so a broken by_name/model_id read-back after a successful adopt would have gone unnoticed. --- terraform/provider/litellm/resource_credential_crud_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/terraform/provider/litellm/resource_credential_crud_test.go b/terraform/provider/litellm/resource_credential_crud_test.go index 02b23a358b0..6e0b818fe33 100644 --- a/terraform/provider/litellm/resource_credential_crud_test.go +++ b/terraform/provider/litellm/resource_credential_crud_test.go @@ -226,6 +226,9 @@ func conflictServer(t *testing.T, patchStatus int, patchBody string) (*httptest. w.WriteHeader(patchStatus) w.Write([]byte(patchBody)) case r.Method == http.MethodGet: + if r.URL.Path != "/credentials/by_name/conflict-test" || r.URL.Query().Get("model_id") != "model-1" { + t.Errorf("GET went to %q (query %q), want /credentials/by_name/conflict-test?model_id=model-1", r.URL.Path, r.URL.RawQuery) + } resp := CredentialResponse{CredentialName: "conflict-test", CredentialInfo: map[string]interface{}{}} body, _ := json.Marshal(resp) w.Header().Set("Content-Type", "application/json")