diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 0193013..0008cd5 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1,6 +1,7 @@ package cli import ( + "bytes" "os" "path/filepath" "testing" @@ -165,3 +166,57 @@ func TestProviderEnableDisableCommands(t *testing.T) { t.Fatalf("provider after enable = %#v, want disabled=false", provider) } } + +func TestOpencodeSyncDoesNotPanicOnSliceModelMetadata(t *testing.T) { + t.Setenv("OLPX_CONFIG", filepath.Join(t.TempDir(), "olpx.json")) + configPath = "" + + cfg, err := loadCfg() + if err != nil { + t.Fatalf("loadCfg: %v", err) + } + cfg.UpsertProvider(config.Provider{ + ID: "p1", + BaseURL: "https://example.com/v1", + }) + cfg.UpsertAlias(config.Alias{ + Alias: "gpt-5.4", + Enabled: true, + Targets: []config.Target{{Provider: "p1", Model: "up-1", Enabled: true}}, + }) + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + target := filepath.Join(t.TempDir(), "opencode.jsonc") + seed := []byte("{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"provider\": {\n \"olpx\": {\n \"npm\": \"@ai-sdk/openai\",\n \"name\": \"OpenCode LocalProxy CLI\",\n \"options\": {\n \"baseURL\": \"http://127.0.0.1:9982/v1\",\n \"apiKey\": \"olpx-local\",\n \"setCacheKey\": true\n },\n \"models\": {\n \"gpt-5.4\": {\n \"name\": \"custom-display-name\",\n \"tags\": [\"reasoning\", \"priority\"],\n \"variants\": [\n {\"name\": \"high\", \"effort\": \"high\"}\n ]\n }\n }\n }\n }\n}\n") + if err := os.WriteFile(target, seed, 0o600); err != nil { + t.Fatalf("write target config: %v", err) + } + + cmd := newOpencodeSyncCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stdout) + cmd.SetArgs([]string{"--target", target}) + + defer func() { + if r := recover(); r != nil { + t.Fatalf("opencode sync panicked with slice metadata: %v", r) + } + }() + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute opencode sync: %v", err) + } + if got := stdout.String(); got != "✓ no changes required at "+target+"\n" { + t.Fatalf("stdout = %q", got) + } + data, err := os.ReadFile(target) + if err != nil { + t.Fatalf("read target config: %v", err) + } + if !bytes.Equal(data, seed) { + t.Fatalf("sync rewrote unchanged config:\n%s", string(data)) + } +} diff --git a/internal/opencode/opencode.go b/internal/opencode/opencode.go index d4aa4fb..63b1ed1 100644 --- a/internal/opencode/opencode.go +++ b/internal/opencode/opencode.go @@ -10,6 +10,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "sort" "github.com/tidwall/jsonc" @@ -601,25 +602,5 @@ func ImportCustomProviders(raw Raw) []ImportableProvider { // mapsEqualShallow compares two string-keyed maps for structural equality. func mapsEqualShallow(a, b map[string]any) bool { - if len(a) != len(b) { - return false - } - for k, v := range a { - bv, ok := b[k] - if !ok { - return false - } - am, amOk := v.(map[string]any) - bm, bmOk := bv.(map[string]any) - if amOk && bmOk { - if !mapsEqualShallow(am, bm) { - return false - } - continue - } - if v != bv { - return false - } - } - return true + return reflect.DeepEqual(a, b) } diff --git a/internal/opencode/opencode_test.go b/internal/opencode/opencode_test.go index 6a3646d..a16fe1b 100644 --- a/internal/opencode/opencode_test.go +++ b/internal/opencode/opencode_test.go @@ -142,6 +142,43 @@ func TestEnsureOLPXProviderPreservesExistingModelMetadata(t *testing.T) { } } +func TestEnsureOLPXProviderDoesNotPanicOnComparableMetadata(t *testing.T) { + raw := Raw{ + "$schema": "https://opencode.ai/config.json", + "provider": map[string]any{ + "olpx": map[string]any{ + "npm": "@ai-sdk/openai", + "name": "OpenCode LocalProxy CLI", + "options": map[string]any{ + "baseURL": "http://127.0.0.1:9982/v1", + "apiKey": "olpx-local", + "setCacheKey": true, + }, + "models": map[string]any{ + "gpt-5.4": map[string]any{ + "name": "custom-display-name", + "tags": []any{"reasoning", "priority"}, + "variants": []any{ + map[string]any{"name": "high", "effort": "high"}, + }, + }, + }, + }, + }, + } + + defer func() { + if r := recover(); r != nil { + t.Fatalf("EnsureOLPXProvider() panicked with slice metadata: %v", r) + } + }() + + changed := EnsureOLPXProvider(raw, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"}) + if changed { + t.Fatal("EnsureOLPXProvider() reported change for unchanged alias metadata with slices") + } +} + func TestValidateOLPXProviderAllowsCustomModelMetadata(t *testing.T) { raw := Raw{ "provider": map[string]any{