Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions internal/sync/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,25 @@ func NewClient(transport resources.Client, accessToken, baseURI string) Client {
}
}

// ModelConfig returns the latest version of one model config.
func (client Client) ModelConfig(projectKey, modelConfigKey string) (ModelConfig, error) {
endpoint, err := url.JoinPath(client.baseURI, "api/v2/projects", projectKey, "ai-configs/model-configs", modelConfigKey)
if err != nil {
return ModelConfig{}, fmt.Errorf("build model config endpoint: %w", err)
}

response, err := client.transport.MakeRequest(client.accessToken, http.MethodGet, endpoint, "", nil, nil, false)
if err != nil {
return ModelConfig{}, fmt.Errorf("get model config %q: %w", modelConfigKey, err)
}

var modelConfig ModelConfig
if err := json.Unmarshal(response, &modelConfig); err != nil {
return ModelConfig{}, fmt.Errorf("decode model config response: %w", err)
}
return modelConfig, nil
}

// ReadVariation returns one variation and its parent config mode.
func (client Client) ReadVariation(projectKey, configKey, variationKey string) (VariationState, error) {
config, err := NewCatalogClient(client.transport, client.accessToken, client.baseURI).Config(projectKey, configKey)
Expand Down
24 changes: 21 additions & 3 deletions internal/sync/fingerprint.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"maps"
)

const variationFingerprintSchema = "launchdarkly.config.variation/v1"
Expand All @@ -17,9 +18,7 @@ func FingerprintVariation(projectKey, lookupKey string, variation Variation) (st
}

normalized := variation
if len(normalized.Model) == 0 {
normalized.Model = nil
}
normalized.Model = normalizeModelForFingerprint(normalized.Model)
if len(normalized.Messages) == 0 {
normalized.Messages = nil
}
Expand Down Expand Up @@ -57,6 +56,25 @@ func FingerprintVariation(projectKey, lookupKey string, variation Variation) (st
return "sha256:" + hex.EncodeToString(sum[:]), nil
}

// normalizeModelForFingerprint removes only defaults that the variation API
// adds without changing model behavior. Other empty objects remain meaningful.
func normalizeModelForFingerprint(model map[string]any) map[string]any {
if len(model) == 0 {
return nil
}

normalized := maps.Clone(model)
for _, key := range []string{"parameters", "custom"} {
if value, ok := normalized[key].(map[string]any); ok && len(value) == 0 {
delete(normalized, key)
}
}
if len(normalized) == 0 {
return nil
}
return normalized
}

// ValidateDirectAPIVariation rejects fields that the existing variation APIs
// cannot round-trip without the sync endpoints.
func ValidateDirectAPIVariation(variation Variation) error {
Expand Down
61 changes: 61 additions & 0 deletions internal/sync/fingerprint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,67 @@ func TestFingerprintVariationNormalizesPromptLineEndings(t *testing.T) {
}
}

func TestFingerprintVariationNormalizesModelDefaults(t *testing.T) {
withoutDefaults := Variation{
Mode: VariationModeAgent,
Key: "agent",
Name: "Agent",
Model: map[string]any{
"modelName": "claude",
"metadata": map[string]any{},
"retries": 1,
},
}
withDefaults := withoutDefaults
withDefaults.Model = map[string]any{
"modelName": "claude",
"parameters": map[string]any{},
"custom": map[string]any{},
"metadata": map[string]any{},
"retries": 1.0,
}

withoutDefaultsFingerprint, err := FingerprintVariation("project", "config/agent", withoutDefaults)
require.NoError(t, err)
withDefaultsFingerprint, err := FingerprintVariation("project", "config/agent", withDefaults)
require.NoError(t, err)

require.Equal(t, withoutDefaultsFingerprint, withDefaultsFingerprint)
require.Contains(t, withDefaults.Model, "parameters")
require.Contains(t, withDefaults.Model, "custom")
}

func TestFingerprintVariationPreservesMeaningfulModelChanges(t *testing.T) {
base := Variation{
Mode: VariationModeAgent,
Key: "agent",
Name: "Agent",
Model: map[string]any{
"modelName": "claude",
},
}
withEmptyMetadata := base
withEmptyMetadata.Model = map[string]any{
"modelName": "claude",
"metadata": map[string]any{},
}
withMetadata := base
withMetadata.Model = map[string]any{
"modelName": "claude",
"metadata": map[string]any{"region": "us-east"},
}

baseFingerprint, err := FingerprintVariation("project", "config/agent", base)
require.NoError(t, err)
emptyMetadataFingerprint, err := FingerprintVariation("project", "config/agent", withEmptyMetadata)
require.NoError(t, err)
metadataFingerprint, err := FingerprintVariation("project", "config/agent", withMetadata)
require.NoError(t, err)

require.NotEqual(t, baseFingerprint, emptyMetadataFingerprint)
require.NotEqual(t, emptyMetadataFingerprint, metadataFingerprint)
}

func TestValidateDirectAPIVariationSupportsModelConfigVersion(t *testing.T) {
base := Variation{Mode: VariationModeAgent, Key: "default", Name: "Default"}

Expand Down
124 changes: 100 additions & 24 deletions internal/sync/prompt/acceptance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,17 @@ import (
"github.com/launchdarkly/ldcli/internal/analytics"
"github.com/launchdarkly/ldcli/internal/resources"
syncdomain "github.com/launchdarkly/ldcli/internal/sync"
syncapi "github.com/launchdarkly/ldcli/internal/sync/api"
synclocal "github.com/launchdarkly/ldcli/internal/sync/local"
syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest"
syncreference "github.com/launchdarkly/ldcli/internal/sync/reference"
)

type directAPI struct {
variation *syncdomain.Variation
variationState string
canonicalizeCreatedModel bool
requests []string
variation *syncdomain.Variation
variationState string
modelConfigs []syncapi.ModelConfig
requests []string
}

type directAPIVariation struct {
Expand All @@ -46,6 +47,14 @@ func (api *directAPI) MakeRequest(
_ bool,
) ([]byte, error) {
api.requests = append(api.requests, method+" "+path)
if method == "GET" && strings.Contains(path, "/model-configs/") {
for _, modelConfig := range api.modelConfigs {
if strings.HasSuffix(path, "/"+modelConfig.Key) {
return json.Marshal(modelConfig)
}
}
return nil, fmt.Errorf("model config not found")
}

switch method {
case "GET":
Expand All @@ -65,11 +74,6 @@ func (api *directAPI) MakeRequest(
return nil, err
}
variation.Mode = syncdomain.VariationModeAgent
if api.canonicalizeCreatedModel {
variation.Model = map[string]any{
"modelName": variation.ModelConfigKey, "parameters": map[string]any{}, "custom": map[string]any{},
}
}
api.variation = &variation
api.variationState = "published"
case "PATCH":
Expand Down Expand Up @@ -140,38 +144,64 @@ func TestPromptFirstSyncAdoptsMatchingStateWithoutMutation(t *testing.T) {

require.NoError(t, err)
assertManifestFingerprint(t, root, local)
for _, request := range api.requests {
assert.True(t, strings.HasPrefix(request, "GET "), request)
}
requireOnlyReads(t, api.requests)
}

func TestPromptFirstSyncCreatesUpsertVariation(t *testing.T) {
root := initRepository(t)
local := variation("New")
local.ModelConfigKey = "gemini"
local.Model = map[string]any{"modelName": "gemini"}
writeVariation(t, root, local, true)
api := &directAPI{}
api := &directAPI{modelConfigs: []syncapi.ModelConfig{{Key: "gemini", ID: "gemini", Version: 4}}}

_, _, err := runPrompt(t, root, api, "--yes")

require.NoError(t, err)
require.NotNil(t, api.variation)
assert.Equal(t, local.Name, api.variation.Name)
assert.Equal(t, 4, api.variation.ModelConfigVersion)

local.ModelConfigVersion = 4
assertManifestFingerprint(t, root, local)
requireLocalModelConfigVersion(t, root, 0)

api.requests = nil
_, _, err = runPrompt(t, root, api, "--yes")
require.NoError(t, err)
requireOnlyReads(t, api.requests)
}

func TestPromptCreateAcceptsSuccessfulServerCanonicalization(t *testing.T) {
func TestPromptUpdateResolvesOmittedModelConfigVersionToLatest(t *testing.T) {
root := initRepository(t)
local := variation("New")
local.ModelConfigKey = "gemini"
baseline := variation("Matching")
baseline.ModelConfigKey = "gemini"
baseline.ModelConfigVersion = 2
baseline.Model = map[string]any{"modelName": "gemini"}

local := baseline
local.ModelConfigVersion = 0
writeVariation(t, root, local, true)
api := &directAPI{canonicalizeCreatedModel: true}
writeManifest(t, root, baseline)
api := &directAPI{
variation: pointer(baseline),
modelConfigs: []syncapi.ModelConfig{{Key: "gemini", ID: "gemini", Version: 4}},
}

_, _, err := runPrompt(t, root, api, "--yes")

require.NoError(t, err)
require.NotNil(t, api.variation)
assert.NotEmpty(t, api.variation.Model)
assert.Equal(t, 4, api.variation.ModelConfigVersion)

local.ModelConfigVersion = 4
assertManifestFingerprint(t, root, local)
requireLocalModelConfigVersion(t, root, 0)

api.requests = nil
_, _, err = runPrompt(t, root, api, "--yes")
require.NoError(t, err)
requireOnlyReads(t, api.requests)
}

func TestPromptPushesLocalChangeAndAdvancesManifest(t *testing.T) {
Expand Down Expand Up @@ -211,6 +241,37 @@ func TestPromptPullsServerChangeAndAdvancesManifest(t *testing.T) {
assertManifestFingerprint(t, root, server)
}

func TestPromptPullPreservesFollowLatestModelConfig(t *testing.T) {
root := initRepository(t)
baseline := variation("Baseline")
baseline.ModelConfigKey = "gemini"
baseline.ModelConfigVersion = 4
baseline.Model = map[string]any{"modelName": "gemini"}

local := baseline
local.ModelConfigVersion = 0
writeVariation(t, root, local, false)
writeManifest(t, root, baseline)

server := baseline
server.Name = "Server"
api := &directAPI{
variation: pointer(server),
modelConfigs: []syncapi.ModelConfig{{Key: "gemini", ID: "gemini", Version: 4}},
}

_, _, err := runPrompt(t, root, api, "--yes")

require.NoError(t, err)
requireLocalModelConfigVersion(t, root, 0)
assertManifestFingerprint(t, root, server)

api.requests = nil
_, _, err = runPrompt(t, root, api, "--yes")
require.NoError(t, err)
requireOnlyReads(t, api.requests)
}

func TestPromptPushesLinkedFileContent(t *testing.T) {
root := initRepository(t)
local := variation("Linked")
Expand Down Expand Up @@ -315,9 +376,7 @@ func TestPromptRejectsDivergentChangesWithoutMutation(t *testing.T) {
_, _, err := runPrompt(t, root, api, "--yes")

require.ErrorContains(t, err, "interactive conflict resolution requires a terminal")
for _, request := range api.requests {
assert.True(t, strings.HasPrefix(request, "GET "), request)
}
requireOnlyReads(t, api.requests)
}

func TestPromptRevalidatesBeforeWriting(t *testing.T) {
Expand All @@ -330,9 +389,7 @@ func TestPromptRevalidatesBeforeWriting(t *testing.T) {
_, _, err := runPrompt(t, root, api, "--yes")

require.ErrorContains(t, err, "sync state changed after review")
for _, request := range api.requests {
assert.True(t, strings.HasPrefix(request, "GET "), request)
}
requireOnlyReads(t, api.requests)
}

func TestPromptAcceptsConfirmedWriteAfterAmbiguousError(t *testing.T) {
Expand Down Expand Up @@ -568,6 +625,25 @@ func assertManifestFingerprints(t *testing.T, root string, values ...syncdomain.
}
}

func requireLocalModelConfigVersion(t *testing.T, root string, expected int) {
t.Helper()
resources, err := synclocal.CompileWorkspace(root)
require.NoError(t, err)
require.Len(t, resources, 1)

var persisted syncdomain.Variation
require.NoError(t, json.Unmarshal(resources[0].Payload, &persisted))
require.Equal(t, expected, persisted.ModelConfigVersion)
}

func requireOnlyReads(t *testing.T, requests []string) {
t.Helper()
require.NotEmpty(t, requests)
for _, request := range requests {
require.True(t, strings.HasPrefix(request, "GET "), request)
}
}

func fingerprint(t *testing.T, value syncdomain.Variation) string {
t.Helper()
result, err := syncdomain.FingerprintVariation("production", "support/"+value.Key, value)
Expand Down
Loading
Loading