Skip to content

Commit 8e1eae2

Browse files
author
Nicholas Cecere
committed
fix(model,credential): resolve inconsistent state after apply for numeric params, vanished keys, mode, and credential read-back
- Normalize scientific notation strings in additional_litellm_params to canonical decimal form on both plan and read-back (e.g. '1.75e-07' → '0.000000175') to prevent inconsistent result errors - Preserve known params (input_cost_per_token, output_cost_per_token) in additional_litellm_params when user explicitly configured them, fixing 'element has vanished' errors - Guard mode read-back with null check so API-inferred values (e.g. 'video_generation') don't overwrite unconfigured null state; mark mode as Optional+Computed for import support - Add retry with exponential backoff for credential read-back after creation to handle eventual-consistency 404 errors
1 parent 4a7a1cd commit 8e1eae2

File tree

4 files changed

+404
-29
lines changed

4 files changed

+404
-29
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.0.6] - 2026-02-16
9+
10+
### Fixed
11+
- **`litellm_model`**: Fixed "Provider produced inconsistent result after apply" for `additional_litellm_params` values containing small decimals — the API returns numeric strings in scientific notation (e.g. `"1.75e-07"`) which didn't match the user's decimal notation (e.g. `"0.000000175"`). Both plan and read-back values are now normalized to canonical decimal form.
12+
- **`litellm_model`**: Fixed "element has vanished" error when `input_cost_per_token` or `output_cost_per_token` are set via `additional_litellm_params` — these keys were incorrectly filtered out on read-back because they appeared in the known-params exclusion list. The filter now respects user-configured keys.
13+
- **`litellm_model`**: Fixed "was null, but now `video_generation`" error for the `mode` attribute — when the user didn't set `mode`, the API-inferred value (e.g. `"video_generation"` for sora-2) was written into state, conflicting with the null plan. Mode is now only populated from the API when the user configured it or it was previously set.
14+
- **`litellm_credential`**: Fixed "Credential not found" warning on create — the read-back immediately after creation could fail with 404 due to eventual consistency. Added retry logic with exponential backoff (matching the existing model resource pattern).
15+
16+
### Changed
17+
- **`litellm_model`**: The `mode` attribute is now `Optional + Computed` (was `Optional` only), allowing the API to populate it during import.
18+
819
## [1.0.5] - 2026-02-13
920

1021
### Fixed

internal/provider/resource_credential.go

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package provider
33
import (
44
"context"
55
"fmt"
6+
"time"
67

78
"github.com/hashicorp/terraform-plugin-framework/attr"
89
"github.com/hashicorp/terraform-plugin-framework/path"
@@ -109,8 +110,9 @@ func (r *CredentialResource) Create(ctx context.Context, req resource.CreateRequ
109110
// Set the ID to credential name
110111
data.ID = data.CredentialName
111112

112-
// Read back for full state (note: credential_values won't be returned for security)
113-
if err := r.readCredential(ctx, &data); err != nil {
113+
// Read back for full state with retry (note: credential_values won't be returned for security).
114+
// The retry handles eventual-consistency delays after creating a credential.
115+
if err := r.readCredentialWithRetry(ctx, &data, 5); err != nil {
114116
resp.Diagnostics.AddWarning("Read Error", fmt.Sprintf("Credential created but failed to read back: %s", err))
115117
}
116118

@@ -271,3 +273,32 @@ func (r *CredentialResource) readCredential(ctx context.Context, data *Credentia
271273

272274
return nil
273275
}
276+
277+
// readCredentialWithRetry retries the read operation with exponential backoff.
278+
// This handles eventual-consistency delays after creating a credential.
279+
func (r *CredentialResource) readCredentialWithRetry(ctx context.Context, data *CredentialResourceModel, maxRetries int) error {
280+
var err error
281+
delay := 1 * time.Second
282+
maxDelay := 10 * time.Second
283+
284+
for i := 0; i < maxRetries; i++ {
285+
err = r.readCredential(ctx, data)
286+
if err == nil {
287+
return nil
288+
}
289+
290+
if !IsNotFoundError(err) {
291+
return err
292+
}
293+
294+
if i < maxRetries-1 {
295+
time.Sleep(delay)
296+
delay *= 2
297+
if delay > maxDelay {
298+
delay = maxDelay
299+
}
300+
}
301+
}
302+
303+
return err
304+
}

internal/provider/resource_model.go

Lines changed: 89 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,10 @@ func (r *ModelResource) Schema(ctx context.Context, req resource.SchemaRequest,
152152
"mode": schema.StringAttribute{
153153
Description: "Model mode. Supported values: chat, completion, embedding, audio_speech, audio_transcription, image_generation, video_generation, batch, rerank, realtime, responses, ocr, moderation.",
154154
Optional: true,
155+
Computed: true,
156+
PlanModifiers: []planmodifier.String{
157+
stringplanmodifier.UseStateForUnknown(),
158+
},
155159
},
156160
"litellm_credential_name": schema.StringAttribute{
157161
Description: "Name of the credential to use for this model. References a credential created via litellm_credential resource.",
@@ -260,6 +264,10 @@ func (r *ModelResource) Create(ctx context.Context, req resource.CreateRequest,
260264
return
261265
}
262266

267+
// Normalise numeric strings in additional_litellm_params so that the
268+
// planned value uses the same canonical form as the read-back value.
269+
data.AdditionalLiteLLMParams = normalizeAdditionalParams(ctx, data.AdditionalLiteLLMParams)
270+
263271
modelID := uuid.New().String()
264272

265273
if err := r.createOrUpdateModel(ctx, &data, modelID, false); err != nil {
@@ -306,6 +314,10 @@ func (r *ModelResource) Update(ctx context.Context, req resource.UpdateRequest,
306314
return
307315
}
308316

317+
// Normalise numeric strings in additional_litellm_params so that the
318+
// planned value uses the same canonical form as the read-back value.
319+
data.AdditionalLiteLLMParams = normalizeAdditionalParams(ctx, data.AdditionalLiteLLMParams)
320+
309321
var state ModelResourceModel
310322
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
311323
if resp.Diagnostics.HasError() {
@@ -556,30 +568,30 @@ func (r *ModelResource) readModel(ctx context.Context, data *ModelResourceModel)
556568

557569
// Handle additional_litellm_params map - preserve state when API omits custom params.
558570
knownLiteLLMParams := map[string]struct{}{
559-
"custom_llm_provider": {},
560-
"model": {},
561-
"input_cost_per_token": {},
562-
"output_cost_per_token": {},
563-
"tpm": {},
564-
"rpm": {},
565-
"api_key": {},
566-
"api_base": {},
567-
"api_version": {},
568-
"reasoning_effort": {},
569-
"thinking": {},
570-
"aws_access_key_id": {},
571-
"aws_secret_access_key": {},
572-
"aws_region_name": {},
573-
"aws_session_name": {},
574-
"aws_role_name": {},
575-
"vertex_project": {},
576-
"vertex_location": {},
577-
"vertex_credentials": {},
578-
"litellm_credential_name": {},
579-
"input_cost_per_pixel": {},
580-
"output_cost_per_pixel": {},
581-
"input_cost_per_second": {},
582-
"output_cost_per_second": {},
571+
"custom_llm_provider": {},
572+
"model": {},
573+
"input_cost_per_token": {},
574+
"output_cost_per_token": {},
575+
"tpm": {},
576+
"rpm": {},
577+
"api_key": {},
578+
"api_base": {},
579+
"api_version": {},
580+
"reasoning_effort": {},
581+
"thinking": {},
582+
"aws_access_key_id": {},
583+
"aws_secret_access_key": {},
584+
"aws_region_name": {},
585+
"aws_session_name": {},
586+
"aws_role_name": {},
587+
"vertex_project": {},
588+
"vertex_location": {},
589+
"vertex_credentials": {},
590+
"litellm_credential_name": {},
591+
"input_cost_per_pixel": {},
592+
"output_cost_per_pixel": {},
593+
"input_cost_per_second": {},
594+
"output_cost_per_second": {},
583595
}
584596

585597
// Build a set of keys the user configured in additional_litellm_params.
@@ -598,8 +610,14 @@ func (r *ModelResource) readModel(ctx context.Context, data *ModelResourceModel)
598610

599611
additionalParams := make(map[string]attr.Value)
600612
for key, rawValue := range litellmParams {
613+
// Skip "known" params (handled by top-level attributes) UNLESS the
614+
// user explicitly placed them in additional_litellm_params. Without
615+
// this exception, keys like input_cost_per_token would be silently
616+
// dropped on read-back, causing "element has vanished" errors.
601617
if _, isKnown := knownLiteLLMParams[key]; isKnown {
602-
continue
618+
if _, inState := stateKeys[key]; !inState {
619+
continue
620+
}
603621
}
604622
// Only filter by state keys during normal Read (not Import).
605623
// This prevents API-added defaults from causing drift.
@@ -611,7 +629,13 @@ func (r *ModelResource) readModel(ctx context.Context, data *ModelResourceModel)
611629

612630
switch v := rawValue.(type) {
613631
case string:
614-
additionalParams[key] = types.StringValue(v)
632+
// Normalize numeric strings to decimal notation so that
633+
// "1.75e-07" (from API) matches "0.000000175" (from config).
634+
if f, err := strconv.ParseFloat(v, 64); err == nil {
635+
additionalParams[key] = types.StringValue(strconv.FormatFloat(f, 'f', -1, 64))
636+
} else {
637+
additionalParams[key] = types.StringValue(v)
638+
}
615639
case bool:
616640
additionalParams[key] = types.StringValue(strconv.FormatBool(v))
617641
case float64:
@@ -644,7 +668,14 @@ func (r *ModelResource) readModel(ctx context.Context, data *ModelResourceModel)
644668
data.Tier = types.StringValue(tier)
645669
}
646670
if mode, ok := modelInfo["mode"].(string); ok && mode != "" {
647-
data.Mode = types.StringValue(mode)
671+
// Only update mode from the API when the user configured it or it
672+
// was previously set (not null). This prevents an API-inferred
673+
// mode (e.g. "video_generation") from appearing when the user
674+
// didn't set it, which would cause "was null, but now ..." errors.
675+
// During Import, mode will be Unknown, so we always populate it.
676+
if !data.Mode.IsNull() {
677+
data.Mode = types.StringValue(mode)
678+
}
648679
}
649680
if teamID, ok := modelInfo["team_id"].(string); ok && teamID != "" {
650681
data.TeamID = types.StringValue(teamID)
@@ -855,6 +886,37 @@ func (r *ModelResource) patchModel(ctx context.Context, data *ModelResourceModel
855886
return r.client.DoRequestWithResponse(ctx, "PATCH", endpoint, patchReq, nil)
856887
}
857888

889+
// normalizeNumericString normalises a string that represents a number into a
890+
// canonical decimal form. This ensures that "2.5e-06" and "0.0000025" both
891+
// become "0.0000025", preventing Terraform from seeing a diff between the
892+
// planned value and the value read back from the API.
893+
func normalizeNumericString(s string) string {
894+
// Try integer first – "500" stays "500".
895+
if _, err := strconv.ParseInt(s, 10, 64); err == nil {
896+
return s // already canonical
897+
}
898+
if f, err := strconv.ParseFloat(s, 64); err == nil {
899+
return strconv.FormatFloat(f, 'f', -1, 64)
900+
}
901+
return s
902+
}
903+
904+
// normalizeAdditionalParams returns a new MapValue where every numeric string
905+
// has been normalised to decimal notation.
906+
func normalizeAdditionalParams(ctx context.Context, m types.Map) types.Map {
907+
if m.IsNull() || m.IsUnknown() {
908+
return m
909+
}
910+
elements := make(map[string]string)
911+
m.ElementsAs(ctx, &elements, false)
912+
normalised := make(map[string]attr.Value, len(elements))
913+
for k, v := range elements {
914+
normalised[k] = types.StringValue(normalizeNumericString(v))
915+
}
916+
result, _ := types.MapValue(types.StringType, normalised)
917+
return result
918+
}
919+
858920
// convertStringValue converts a string to its most appropriate Go type.
859921
// This allows additional_litellm_params values (which are stored as strings in
860922
// Terraform state) to be sent as native JSON types in the API request.

0 commit comments

Comments
 (0)