diff --git a/examples/opencode_olpx.jsonc b/examples/opencode_olpx.jsonc new file mode 100644 index 0000000..98112d0 --- /dev/null +++ b/examples/opencode_olpx.jsonc @@ -0,0 +1,66 @@ +{ + // provider.olpx.models entries may include OpenCode-only metadata. + // olpx sync preserves same-name model objects and only manages the alias set. + "$schema": "https://opencode.ai/config.json", + "model": "olpx/gpt-5.4", + "small_model": "olpx/gpt-5.4-mini", + "provider": { + "olpx": { + "models": { + "gpt-5.4": { + "name": "gpt-5.4", + "limit": { + "context": 272000, + "output": 128000 + }, + "cost": { + "input": 1.75, + "output": 14, + "cache_read": 0.175, + "cache_write": 1.75 + }, + "options": { + "serviceTier": "priority", + "include": [ + "reasoning.encrypted_content" + ], + "parallel_tool_calls": true, + "store": false + }, + "variants": { + "xhigh": { + "reasoningEffort": "xhigh", + "reasoningSummary": "auto" + }, + "high": { + "reasoningEffort": "high", + "reasoningSummary": "auto" + }, + "medium": { + "reasoningEffort": "medium", + "reasoningSummary": "auto" + }, + "low": { + "reasoningEffort": "low", + "reasoningSummary": "auto" + } + } + }, + "gpt-5.4-mini": { + "name": "gpt-5.4-mini" + } + }, + "name": "OpenCode LocalProxy CLI", + "npm": "@ai-sdk/openai", + "options": { + "apiKey": "olpx-local", + "baseURL": "http://127.0.0.1:9982/v1", + "setCacheKey": true + } + } + }, + "plugin": [ + "@tarquinen/opencode-dcp@latest", + "oh-my-opencode-slim@latest" + ] +} diff --git a/internal/opencode/opencode.go b/internal/opencode/opencode.go index db74d9e..d4aa4fb 100644 --- a/internal/opencode/opencode.go +++ b/internal/opencode/opencode.go @@ -411,8 +411,10 @@ func lineIndent(data []byte, pos int) string { // EnsureOLPXProvider updates (or creates) the provider.olpx entry with the given // local base URL, local api key and alias set. Existing keys on provider.olpx -// are preserved unless they conflict with the sync intent. Returns true if the -// file would actually change. +// are preserved unless they conflict with the sync intent. For model entries, +// sync owns only the alias set: same-name model objects are left untouched so +// OpenCode-only metadata survives round-trips. Returns true if the file would +// actually change. func EnsureOLPXProvider(raw Raw, baseURL, apiKey string, aliases []string) bool { changed := false if _, ok := raw["$schema"]; !ok { @@ -452,18 +454,14 @@ func EnsureOLPXProvider(raw Raw, baseURL, apiKey string, aliases []string) bool if setIfDiff(opts, "setCacheKey", true) { changed = true } - // Build models map from alias list. Preserve any existing per-model extras - // if the alias key matches; drop aliases removed locally. + // Build models map from alias list. Preserve any existing per-model objects + // verbatim if the alias key matches; drop aliases removed locally. existingModels, _ := olpxRaw["models"].(map[string]any) newModels := map[string]any{} aliasSet := map[string]bool{} for _, a := range aliases { aliasSet[a] = true if existing, ok := existingModels[a].(map[string]any); ok { - // make sure "name" stays consistent with alias key - if setIfDiff(existing, "name", a) { - changed = true - } newModels[a] = existing } else { newModels[a] = map[string]any{"name": a} @@ -523,9 +521,6 @@ func ValidateOLPXProvider(raw Raw, baseURL, apiKey string, aliases []string) err if modelCfg == nil { return fmt.Errorf("provider.olpx.models.%s must be an object", alias) } - if got, _ := modelCfg["name"].(string); got != alias { - return fmt.Errorf("provider.olpx.models.%s.name mismatch", alias) - } actual = append(actual, alias) } sort.Strings(actual) diff --git a/internal/opencode/opencode_test.go b/internal/opencode/opencode_test.go index 60c4c0d..6a3646d 100644 --- a/internal/opencode/opencode_test.go +++ b/internal/opencode/opencode_test.go @@ -83,6 +83,91 @@ func TestValidateOLPXProvider(t *testing.T) { } } +func TestEnsureOLPXProviderPreservesExistingModelMetadata(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", + "limit": map[string]any{ + "context": float64(272000), + "output": float64(128000), + }, + "cost": map[string]any{ + "input": float64(1.75), + "output": float64(14), + }, + "variants": map[string]any{ + "high": map[string]any{"reasoningEffort": "high"}, + }, + "options": map[string]any{"serviceTier": "priority"}, + }, + }, + }, + }, + } + + 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 same-name alias") + } + + providerRaw := raw["provider"].(map[string]any) + olpxRaw := providerRaw["olpx"].(map[string]any) + models := olpxRaw["models"].(map[string]any) + model := models["gpt-5.4"].(map[string]any) + if got := model["name"]; got != "custom-display-name" { + t.Fatalf("model name = %#v, want custom-display-name preserved", got) + } + if _, ok := model["limit"].(map[string]any); !ok { + t.Fatalf("model limit metadata missing: %#v", model["limit"]) + } + if _, ok := model["cost"].(map[string]any); !ok { + t.Fatalf("model cost metadata missing: %#v", model["cost"]) + } + if _, ok := model["variants"].(map[string]any); !ok { + t.Fatalf("model variants metadata missing: %#v", model["variants"]) + } + if _, ok := model["options"].(map[string]any); !ok { + t.Fatalf("model options metadata missing: %#v", model["options"]) + } +} + +func TestValidateOLPXProviderAllowsCustomModelMetadata(t *testing.T) { + raw := Raw{ + "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", + "options": map[string]any{"serviceTier": "priority"}, + }, + }, + }, + }, + } + + if err := ValidateOLPXProvider(raw, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"}); err != nil { + t.Fatalf("ValidateOLPXProvider() unexpected error for custom metadata: %v", err) + } +} + func TestRenderSaveDataReplacesExistingProviderOLPXOnly(t *testing.T) { raw := Raw{ "$schema": "https://opencode.ai/config.json", @@ -271,6 +356,44 @@ func TestRenderSaveDataWritesValidJSONToDisk(t *testing.T) { } } +func TestSavePreservesExistingModelMetadataForSameAlias(t *testing.T) { + path := 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 \"limit\": {\n \"context\": 272000,\n \"output\": 128000\n },\n \"options\": {\n \"serviceTier\": \"priority\"\n }\n }\n }\n }\n }\n}\n") + if err := os.WriteFile(path, seed, 0o600); err != nil { + t.Fatalf("write seed config: %v", err) + } + + raw, err := Load(path) + if err != nil { + t.Fatalf("Load(seed) error: %v", err) + } + changed := EnsureOLPXProvider(raw, "http://127.0.0.1:9982/v1", "olpx-local", []string{"gpt-5.4"}) + if changed { + t.Fatal("EnsureOLPXProvider() reported change for preserved same-name alias metadata") + } + if err := Save(path, raw); err != nil { + t.Fatalf("Save() error: %v", err) + } + + loaded, err := Load(path) + if err != nil { + t.Fatalf("Load(saved) error: %v", err) + } + providerRaw := loaded["provider"].(map[string]any) + olpxRaw := providerRaw["olpx"].(map[string]any) + models := olpxRaw["models"].(map[string]any) + model := models["gpt-5.4"].(map[string]any) + if got := model["name"]; got != "custom-display-name" { + t.Fatalf("saved model name = %#v, want custom-display-name preserved", got) + } + if _, ok := model["limit"].(map[string]any); !ok { + t.Fatalf("saved limit metadata missing: %#v", model["limit"]) + } + if _, ok := model["options"].(map[string]any); !ok { + t.Fatalf("saved options metadata missing: %#v", model["options"]) + } +} + func assertValidJSON(t *testing.T, data []byte) { t.Helper() if !json.Valid(data) {