diff --git a/internal/adapter/common/blocks.go b/internal/adapter/common/blocks.go index 5823b35..9330eac 100644 --- a/internal/adapter/common/blocks.go +++ b/internal/adapter/common/blocks.go @@ -1,7 +1,14 @@ // Package common provides shared helpers for harness adapters. package common -import "github.com/lukaszraczylo/harness-sync/internal/canonical" +import ( + "encoding/json" + "strings" + + "github.com/lukaszraczylo/harness-sync/internal/canonical" +) + +const gatewayProviderID = "harness-sync-gateway" // ProviderEntry is a generic provider record. Adapters may massage further. type ProviderEntry = map[string]any @@ -13,7 +20,7 @@ func BuildProviders(p *canonical.Profile) []ProviderEntry { out := make([]ProviderEntry, 0, len(p.Upstreams)+1) if p.Gateway.URL != "" { gw := ProviderEntry{ - "id": "harness-sync-gateway", + "id": gatewayProviderID, "name": "harness-sync gateway", "base_url": p.Gateway.URL, "api_key": p.Gateway.Token, @@ -48,30 +55,81 @@ func buildModels(models []canonical.Model) []map[string]any { return out } +// crushModel builds a 10-field model entry required by the crush schema. +func crushModel(id string) map[string]any { + return map[string]any{ + "id": id, + "name": id, + "cost_per_1m_in": 0, + "cost_per_1m_out": 0, + "cost_per_1m_in_cached": 0, + "cost_per_1m_out_cached": 0, + "context_window": 200000, + "default_max_tokens": 8192, + "can_reason": false, + "supports_attachments": true, + } +} + +// ProvidersAsCrushMap returns a map[providerID]providerObject for the crush +// harness. Each provider has type, base_url, api_key, and models[]. +// models[] entries carry all 10 required crush schema fields. +func ProvidersAsCrushMap(p *canonical.Profile) map[string]any { + out := map[string]any{} + if p.Gateway.URL == "" { + return out + } + models := make([]map[string]any, 0) + for _, m := range p.Models { + models = append(models, crushModel(m.ID)) + } + if len(models) == 0 && p.Gateway.DefaultModel != "" { + models = append(models, crushModel(p.Gateway.DefaultModel)) + } + out[gatewayProviderID] = map[string]any{ + "type": "openai-compat", + "base_url": p.Gateway.URL, + "api_key": p.Gateway.Token, + "models": models, + } + return out +} + +// CrushRoleModels returns the top-level models map for crush with large, small, +// and title roles all pointing at the gateway provider + default model. +func CrushRoleModels(p *canonical.Profile) map[string]any { + if p.Gateway.DefaultModel == "" { + return map[string]any{} + } + sel := map[string]any{ + "model": p.Gateway.DefaultModel, + "provider": gatewayProviderID, + } + return map[string]any{ + "large": sel, + "small": sel, + "title": sel, + } +} + // ProvidersAsMap returns a name-keyed map of provider objects for harnesses -// like opencode that use `provider` (singular, map-shaped) instead of a slice. -// The gateway entry is keyed "harness-sync-gateway"; upstreams use their name. +// like opencode/kilo that use `provider` (singular, map-shaped) instead of a +// slice. Each entry has npm + options.{baseURL,apiKey} per the opencode schema. func ProvidersAsMap(p *canonical.Profile) map[string]any { out := map[string]any{} if p.Gateway.URL != "" { - entry := map[string]any{ - "name": "harness-sync gateway", + out[gatewayProviderID] = map[string]any{ + "npm": "@ai-sdk/openai-compatible", "options": map[string]any{ "baseURL": p.Gateway.URL, "apiKey": p.Gateway.Token, }, } - if models := buildModels(p.Models); len(models) > 0 { - modelsMap := map[string]any{} - for _, m := range p.Models { - modelsMap[m.ID] = map[string]any{"name": m.ID} - } - entry["models"] = modelsMap - } - out["harness-sync-gateway"] = entry } for _, up := range p.Upstreams { - entry := map[string]any{"name": up.Name} + entry := map[string]any{ + "npm": "@ai-sdk/openai-compatible", + } opts := map[string]any{} if up.BaseURL != "" { opts["baseURL"] = up.BaseURL @@ -87,6 +145,121 @@ func ProvidersAsMap(p *canonical.Profile) map[string]any { return out } +// KiloModelString returns the "providerID/modelID" string used by kilo/opencode. +func KiloModelString(p *canonical.Profile) string { + if p.Gateway.DefaultModel == "" { + return "" + } + return gatewayProviderID + "/" + p.Gateway.DefaultModel +} + +// ProvidersAsCagentMap returns the providers block for cagent: +// map[providerID]{base_url, token_key, provider}. +func ProvidersAsCagentMap(p *canonical.Profile) map[string]any { + out := map[string]any{} + if p.Gateway.URL == "" { + return out + } + out[gatewayProviderID] = map[string]any{ + "base_url": p.Gateway.URL, + "token_key": "HARNESS_SYNC_GATEWAY_TOKEN", + "provider": "openai", + } + return out +} + +// ZedLanguageModels returns the language_models.openai block for Zed when a +// gateway URL is configured. +func ZedLanguageModels(p *canonical.Profile) map[string]any { + if p.Gateway.URL == "" { + return map[string]any{} + } + models := make([]map[string]any, 0) + for _, m := range p.Models { + models = append(models, map[string]any{ + "name": m.ID, + "display_name": func() string { + if m.Alias != "" { + return m.Alias + } + return m.ID + }(), + "max_tokens": 8192, + }) + } + if len(models) == 0 && p.Gateway.DefaultModel != "" { + models = append(models, map[string]any{ + "name": p.Gateway.DefaultModel, + "display_name": p.Gateway.DefaultModel, + "max_tokens": 8192, + }) + } + return map[string]any{ + "openai": map[string]any{ + "api_url": p.Gateway.URL, + "available_models": models, + }, + } +} + +// GooseCustomProviderFile returns the JSON body and provider ID for the goose +// custom_providers/.json file. Returns nil body when Gateway.URL is empty. +func GooseCustomProviderFile(p *canonical.Profile) ([]byte, string) { + if p.Gateway.URL == "" { + return nil, "" + } + providerID := gatewayProviderID + providerName := "custom_" + providerID + + models := make([]map[string]any, 0) + for _, m := range p.Models { + models = append(models, map[string]any{ + "name": m.ID, + "context_limit": 200000, + }) + } + if len(models) == 0 && p.Gateway.DefaultModel != "" { + // strip optional "provider/" prefix from DefaultModel + modelID := p.Gateway.DefaultModel + if idx := strings.IndexByte(modelID, '/'); idx >= 0 { + modelID = modelID[idx+1:] + } + models = append(models, map[string]any{ + "name": modelID, + "context_limit": 200000, + }) + } + + // display_name: hostname of the gateway URL, or just providerID. + displayName := providerID + if idx := strings.Index(p.Gateway.URL, "://"); idx >= 0 { + rest := p.Gateway.URL[idx+3:] + if slash := strings.IndexByte(rest, '/'); slash >= 0 { + rest = rest[:slash] + } + if rest != "" { + displayName = rest + } + } + + chatURL := strings.TrimRight(p.Gateway.URL, "/") + "/v1/chat/completions" + + entry := map[string]any{ + "name": providerName, + "engine": "openai", + "display_name": displayName, + "base_url": chatURL, + "models": models, + "requires_auth": true, + "api_key_env": "CUSTOM_HARNESS_SYNC_GATEWAY_API_KEY", + } + body, err := json.MarshalIndent(entry, "", " ") + if err != nil { + return nil, "" + } + return body, providerName +} + // BuildMCPMap returns canonical MCP servers as a name -> object map. // Empty map (not nil) is returned when there are no servers so the caller // can decide whether to include the key. diff --git a/internal/adapters/cagent/cagent.go b/internal/adapters/cagent/cagent.go index 8698e2f..ae16489 100644 --- a/internal/adapters/cagent/cagent.go +++ b/internal/adapters/cagent/cagent.go @@ -10,6 +10,7 @@ import ( "path/filepath" "github.com/lukaszraczylo/harness-sync/internal/adapter" + "github.com/lukaszraczylo/harness-sync/internal/adapter/common" "github.com/lukaszraczylo/harness-sync/internal/canonical" "gopkg.in/yaml.v3" @@ -76,6 +77,12 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) { mcps[s.Name] = e } + // inline model shorthand: "providerID/modelID" + modelRef := "" + if b.Profile.Gateway.DefaultModel != "" { + modelRef = "harness-sync-gateway/" + b.Profile.Gateway.DefaultModel + } + cfg := map[string]any{ "version": 8, "metadata": map[string]any{ @@ -83,14 +90,15 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) { }, "agents": map[string]any{ "default": map[string]any{ - "model": "gateway/" + b.Profile.Gateway.DefaultModel, + "model": modelRef, "instruction": instructions, }, }, + "providers": common.ProvidersAsCagentMap(&b.Profile), "models": map[string]any{ - "gateway": map[string]any{ - "provider": "openai", // cagent gateway via openai-compat - "base_url": b.Profile.Gateway.URL, + "harness-sync-gateway": map[string]any{ + "provider": "harness-sync-gateway", + "model": b.Profile.Gateway.DefaultModel, }, }, } diff --git a/internal/adapters/cagent/cagent_test.go b/internal/adapters/cagent/cagent_test.go index fa2d3b3..0fa267f 100644 --- a/internal/adapters/cagent/cagent_test.go +++ b/internal/adapters/cagent/cagent_test.go @@ -48,19 +48,63 @@ func TestCagentRenderProducesExpectedTargets(t *testing.T) { agents := parsed["agents"].(map[string]any) def := agents["default"].(map[string]any) - assert.Equal(t, "gateway/claude-sonnet-4-6", def["model"]) + assert.Equal(t, "harness-sync-gateway/claude-sonnet-4-6", def["model"]) assert.Contains(t, def["instruction"], "# global instruction") + // providers map (new schema). + providers := parsed["providers"].(map[string]any) + gwProv := providers["harness-sync-gateway"].(map[string]any) + assert.Equal(t, "https://gw", gwProv["base_url"]) + assert.Equal(t, "HARNESS_SYNC_GATEWAY_TOKEN", gwProv["token_key"]) + assert.Equal(t, "openai", gwProv["provider"]) + + // models map. models := parsed["models"].(map[string]any) - gw := models["gateway"].(map[string]any) - assert.Equal(t, "openai", gw["provider"]) - assert.Equal(t, "https://gw", gw["base_url"]) + gw := models["harness-sync-gateway"].(map[string]any) + assert.Equal(t, "harness-sync-gateway", gw["provider"]) + assert.Equal(t, "claude-sonnet-4-6", gw["model"]) mcps := parsed["mcps"].(map[string]any) fp := mcps["filepuff"].(map[string]any) assert.Equal(t, "/bin/filepuff", fp["command"]) } +func TestCagentRenderProvidersAndModelsMaps(t *testing.T) { + home := t.TempDir() + ad := New(WithHome(home)) + b := &canonical.Bundle{ + Profile: canonical.Profile{ + Gateway: canonical.Gateway{ + URL: "https://gw", + Token: "tok", + DefaultModel: "claude-sonnet-4-6", + }, + }, + } + fs, err := ad.Render(b) + require.NoError(t, err) + seen := map[string]adapter.File{} + fs.ForEach(func(f adapter.File) { seen[f.Dest] = f }) + + cfgDest := filepath.Join(home, ".config", "cagent", "default.yaml") + var parsed map[string]any + require.NoError(t, yaml.Unmarshal(seen[cfgDest].Content, &parsed)) + + // providers block present. + providers, ok := parsed["providers"].(map[string]any) + require.True(t, ok, "providers must be a map") + gw, ok := providers["harness-sync-gateway"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "https://gw", gw["base_url"]) + assert.Equal(t, "HARNESS_SYNC_GATEWAY_TOKEN", gw["token_key"]) + assert.Equal(t, "openai", gw["provider"]) + + // agents.default.model uses inline shorthand. + agents := parsed["agents"].(map[string]any) + def := agents["default"].(map[string]any) + assert.Equal(t, "harness-sync-gateway/claude-sonnet-4-6", def["model"]) +} + func TestCagentRenderNoMCP(t *testing.T) { home := t.TempDir() ad := New(WithHome(home)) diff --git a/internal/adapters/claudecode/claudecode.go b/internal/adapters/claudecode/claudecode.go index 76869f7..b41ad5c 100644 --- a/internal/adapters/claudecode/claudecode.go +++ b/internal/adapters/claudecode/claudecode.go @@ -78,6 +78,29 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) { Content: []byte(instructions), }) + // settings.json holds the gateway env vars + model override. + // Only emit when a gateway URL is configured. + if b.Profile.Gateway.URL != "" { + settingsPath := filepath.Join(base, "settings.json") + settingsExisting, _ := os.ReadFile(settingsPath) + settingsOverlay := map[string]any{ + "model": b.Profile.Gateway.DefaultModel, + "env": map[string]any{ + "ANTHROPIC_BASE_URL": b.Profile.Gateway.URL, + "ANTHROPIC_AUTH_TOKEN": b.Profile.Gateway.Token, + }, + } + settingsMerged, err := common.MergeJSONKeys(settingsExisting, settingsOverlay) + if err != nil { + return nil, err + } + fs.Add(adapter.File{ + Dest: settingsPath, + Kind: adapter.RenderedFile, + Content: settingsMerged, + }) + } + // Claude Code reads MCP servers from two files on disk: // * ~/.claude.json (live, written by `claude mcp add`) // * ~/.claude/mcp_servers.json (older / fallback location) diff --git a/internal/adapters/claudecode/claudecode_test.go b/internal/adapters/claudecode/claudecode_test.go index 16b7828..6b705e5 100644 --- a/internal/adapters/claudecode/claudecode_test.go +++ b/internal/adapters/claudecode/claudecode_test.go @@ -94,6 +94,53 @@ func TestRenderMergesLiveClaudeJSON(t *testing.T) { assert.Equal(t, "stdio", fp["type"]) } +func TestClaudeCodeRenderSetsAnthropicBaseURLInSettings(t *testing.T) { + home := t.TempDir() + ad := New(WithHome(home)) + b := &canonical.Bundle{ + Root: "/canon", + Profile: canonical.Profile{ + Gateway: canonical.Gateway{ + URL: "https://gw", + Token: "tok", + DefaultModel: "claude-sonnet-4-6", + }, + }, + } + fs, err := ad.Render(b) + require.NoError(t, err) + seen := map[string]adapter.File{} + fs.ForEach(func(f adapter.File) { seen[f.Dest] = f }) + + settingsDest := filepath.Join(home, ".claude", "settings.json") + require.Contains(t, seen, settingsDest) + assert.Equal(t, adapter.RenderedFile, seen[settingsDest].Kind) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(seen[settingsDest].Content, &parsed)) + assert.Equal(t, "claude-sonnet-4-6", parsed["model"]) + env, ok := parsed["env"].(map[string]any) + require.True(t, ok, "env must be a map") + assert.Equal(t, "https://gw", env["ANTHROPIC_BASE_URL"]) + assert.Equal(t, "tok", env["ANTHROPIC_AUTH_TOKEN"]) +} + +func TestClaudeCodeRenderNoGatewayNoSettings(t *testing.T) { + home := t.TempDir() + ad := New(WithHome(home)) + b := &canonical.Bundle{ + Root: "/canon", + Profile: canonical.Profile{}, + } + fs, err := ad.Render(b) + require.NoError(t, err) + seen := map[string]adapter.File{} + fs.ForEach(func(f adapter.File) { seen[f.Dest] = f }) + + settingsDest := filepath.Join(home, ".claude", "settings.json") + assert.NotContains(t, seen, settingsDest, "settings.json must not be written when no gateway URL") +} + func TestImport(t *testing.T) { home := t.TempDir() base := filepath.Join(home, ".claude") diff --git a/internal/adapters/crush/crush.go b/internal/adapters/crush/crush.go index 0b2f581..d1d27a5 100644 --- a/internal/adapters/crush/crush.go +++ b/internal/adapters/crush/crush.go @@ -62,9 +62,12 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) { cfgPath := filepath.Join(base, "crush.json") existing, _ := os.ReadFile(cfgPath) - overlay := map[string]any{ - "providers": common.BuildProviders(&b.Profile), - "default_model": b.Profile.Gateway.DefaultModel, + overlay := map[string]any{} + if providers := common.ProvidersAsCrushMap(&b.Profile); len(providers) > 0 { + overlay["providers"] = providers + } + if roleModels := common.CrushRoleModels(&b.Profile); len(roleModels) > 0 { + overlay["models"] = roleModels } if mcp := common.BuildMCPMapStyled(&b.MCP, common.MCPCrushStyle); len(mcp) > 0 { overlay["mcp"] = mcp diff --git a/internal/adapters/crush/crush_test.go b/internal/adapters/crush/crush_test.go index 343c955..3144a9e 100644 --- a/internal/adapters/crush/crush_test.go +++ b/internal/adapters/crush/crush_test.go @@ -41,9 +41,20 @@ func TestCrushRenderProducesExpectedTargets(t *testing.T) { var parsed map[string]any require.NoError(t, json.Unmarshal(seen[cfgDest].Content, &parsed)) assert.NotNil(t, parsed) - // Must use "default_model", not "model". - assert.Contains(t, parsed, "default_model") - // Must not write "mcpServers" — crush uses "mcp". + // providers must be a map (not array), keyed by providerID. + providers, ok := parsed["providers"].(map[string]any) + require.True(t, ok, "providers must be a map") + gw, ok := providers["harness-sync-gateway"].(map[string]any) + require.True(t, ok, "harness-sync-gateway provider missing") + assert.Equal(t, "openai-compat", gw["type"]) + // models must be role-map with large/small/title. + models, ok := parsed["models"].(map[string]any) + require.True(t, ok, "models must be a map") + assert.Contains(t, models, "large") + assert.Contains(t, models, "small") + assert.Contains(t, models, "title") + // Must NOT write "default_model" or "mcpServers". + assert.NotContains(t, parsed, "default_model") assert.NotContains(t, parsed, "mcpServers") } @@ -105,6 +116,54 @@ func TestCrushRenderMergesExistingKeys(t *testing.T) { assert.Contains(t, parsed, "lsp") assert.Contains(t, parsed, "permissions") } +func TestCrushRenderProducesMapProvidersAndRoleModels(t *testing.T) { + home := t.TempDir() + ad := New(WithHome(home)) + b := &canonical.Bundle{ + Profile: canonical.Profile{ + Gateway: canonical.Gateway{URL: "https://gw", Token: "tok", DefaultModel: "claude-sonnet-4-6"}, + Models: []canonical.Model{{ID: "claude-sonnet-4-6"}}, + }, + } + fs, err := ad.Render(b) + require.NoError(t, err) + seen := map[string]adapter.File{} + fs.ForEach(func(f adapter.File) { seen[f.Dest] = f }) + + cfgDest := filepath.Join(home, ".config", "crush", "crush.json") + var parsed map[string]any + require.NoError(t, json.Unmarshal(seen[cfgDest].Content, &parsed)) + + // providers is a map, not an array. + providers, ok := parsed["providers"].(map[string]any) + require.True(t, ok, "providers must be a map") + gw, ok := providers["harness-sync-gateway"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "openai-compat", gw["type"]) + assert.Equal(t, "https://gw", gw["base_url"]) + provModels, ok := gw["models"].([]any) + require.True(t, ok, "models must be array inside provider") + require.Len(t, provModels, 1) + m0 := provModels[0].(map[string]any) + assert.Equal(t, "claude-sonnet-4-6", m0["id"]) + // All 10 required fields present. + for _, field := range []string{"id", "name", "cost_per_1m_in", "cost_per_1m_out", + "cost_per_1m_in_cached", "cost_per_1m_out_cached", "context_window", + "default_max_tokens", "can_reason", "supports_attachments"} { + assert.Contains(t, m0, field, "missing field %s", field) + } + + // top-level models is role-map. + roleModels, ok := parsed["models"].(map[string]any) + require.True(t, ok) + for _, role := range []string{"large", "small", "title"} { + sel, ok := roleModels[role].(map[string]any) + require.True(t, ok, "role %s missing", role) + assert.Equal(t, "claude-sonnet-4-6", sel["model"]) + assert.Equal(t, "harness-sync-gateway", sel["provider"]) + } +} + func TestCrushImportRoundtrip(t *testing.T) { home := t.TempDir() base := filepath.Join(home, ".config", "crush") diff --git a/internal/adapters/goose/goose.go b/internal/adapters/goose/goose.go index 9a373d7..ccd77f5 100644 --- a/internal/adapters/goose/goose.go +++ b/internal/adapters/goose/goose.go @@ -4,9 +4,9 @@ // MCP-equivalent servers go under "extensions" (not "mcp"). // Model is split into GOOSE_PROVIDER + GOOSE_MODEL. // -// Custom provider files (~/.config/goose/custom_providers/.yaml) are -// out of v1 scope. If a gateway URL is configured we set GOOSE_PROVIDER to -// signal the intent; the user must hand-write the custom_providers file. +// When a gateway URL is configured we: +// - Write ~/.config/goose/custom_providers/.json +// - Set GOOSE_PROVIDER to in config.yaml package goose import ( @@ -59,6 +59,8 @@ func (a *Adapter) Detect() bool { // Render produces the FileSet that applies the canonical bundle to goose. // config.yaml is MERGED; only GOOSE_PROVIDER, GOOSE_MODEL, and the // managed extension entries are overlaid. +// When Gateway.URL is set, a custom_providers/.json file is +// also added to the FileSet so goose can load the provider on first run. func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) { fs := adapter.NewFileSet() cfgPath := filepath.Join(a.home, ".config", "goose", "config.yaml") @@ -67,9 +69,17 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) { provider, model := splitProviderModel(b.Profile.Gateway.DefaultModel) if b.Profile.Gateway.URL != "" { - // Custom gateway: signal with provider name. User must create - // ~/.config/goose/custom_providers/harness-sync.yaml manually. - provider = "harness-sync" + // Emit the custom provider JSON file. + body, providerName := common.GooseCustomProviderFile(&b.Profile) + if body != nil { + cpDir := filepath.Join(a.home, ".config", "goose", "custom_providers") + fs.Add(adapter.File{ + Dest: filepath.Join(cpDir, providerName+".json"), + Kind: adapter.RenderedFile, + Content: body, + }) + provider = providerName + } } overlay := map[string]any{ diff --git a/internal/adapters/goose/goose_test.go b/internal/adapters/goose/goose_test.go index e901d0e..a633b68 100644 --- a/internal/adapters/goose/goose_test.go +++ b/internal/adapters/goose/goose_test.go @@ -1,6 +1,7 @@ package goose import ( + "encoding/json" "os" "path/filepath" "testing" @@ -89,8 +90,40 @@ func TestGooseRenderGatewayURL(t *testing.T) { cfgDest := filepath.Join(home, ".config", "goose", "config.yaml") var parsed map[string]any require.NoError(t, yaml.Unmarshal(seen[cfgDest].Content, &parsed)) - // Custom gateway → provider is "harness-sync" (user must write custom_providers file). - assert.Equal(t, "harness-sync", parsed["GOOSE_PROVIDER"]) + // Custom gateway → GOOSE_PROVIDER is the custom provider name. + assert.Equal(t, "custom_harness-sync-gateway", parsed["GOOSE_PROVIDER"]) +} + +func TestGooseRenderProducesCustomProviderFile(t *testing.T) { + home := t.TempDir() + ad := New(WithHome(home)) + b := &canonical.Bundle{ + Profile: canonical.Profile{ + Gateway: canonical.Gateway{URL: "https://gw", Token: "tok", DefaultModel: "sonnet"}, + Models: []canonical.Model{{ID: "claude-sonnet-4-6"}}, + }, + } + fs, err := ad.Render(b) + require.NoError(t, err) + + seen := map[string]adapter.File{} + fs.ForEach(func(f adapter.File) { seen[f.Dest] = f }) + + cpDest := filepath.Join(home, ".config", "goose", "custom_providers", "custom_harness-sync-gateway.json") + require.Contains(t, seen, cpDest) + assert.Equal(t, adapter.RenderedFile, seen[cpDest].Kind) + + var cp map[string]any + require.NoError(t, json.Unmarshal(seen[cpDest].Content, &cp)) + assert.Equal(t, "custom_harness-sync-gateway", cp["name"]) + assert.Equal(t, "openai", cp["engine"]) + assert.Equal(t, true, cp["requires_auth"]) + assert.Equal(t, "CUSTOM_HARNESS_SYNC_GATEWAY_API_KEY", cp["api_key_env"]) + models, ok := cp["models"].([]any) + require.True(t, ok) + require.Len(t, models, 1) + m0 := models[0].(map[string]any) + assert.Equal(t, "claude-sonnet-4-6", m0["name"]) } func TestGooseImport(t *testing.T) { diff --git a/internal/adapters/kilo/kilo.go b/internal/adapters/kilo/kilo.go index 65fd311..9496a81 100644 --- a/internal/adapters/kilo/kilo.go +++ b/internal/adapters/kilo/kilo.go @@ -66,8 +66,14 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) { cfgPath := filepath.Join(base, "kilo.json") existing, _ := os.ReadFile(cfgPath) - overlay := map[string]any{ - "model": b.Profile.Gateway.DefaultModel, + modelStr := common.KiloModelString(&b.Profile) + overlay := map[string]any{} + if providers := common.ProvidersAsMap(&b.Profile); len(providers) > 0 { + overlay["provider"] = providers + } + if modelStr != "" { + overlay["model"] = modelStr + overlay["small_model"] = modelStr } if mcp := common.BuildMCPMapStyled(&b.MCP, common.MCPOpencodeStyle); len(mcp) > 0 { overlay["mcp"] = mcp diff --git a/internal/adapters/kilo/kilo_test.go b/internal/adapters/kilo/kilo_test.go index b304b58..ba59d72 100644 --- a/internal/adapters/kilo/kilo_test.go +++ b/internal/adapters/kilo/kilo_test.go @@ -42,12 +42,18 @@ func TestKiloRender(t *testing.T) { var parsed map[string]any require.NoError(t, json.Unmarshal(seen[cfgDest].Content, &parsed)) - assert.Equal(t, "sonnet", parsed["model"]) + // model is "providerID/modelID" string. + assert.Equal(t, "harness-sync-gateway/sonnet", parsed["model"]) + assert.Equal(t, "harness-sync-gateway/sonnet", parsed["small_model"]) + // provider map present with npm field. + provider, ok := parsed["provider"].(map[string]any) + require.True(t, ok, "provider must be a map") + gw, ok := provider["harness-sync-gateway"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "@ai-sdk/openai-compatible", gw["npm"]) assert.Contains(t, parsed, "mcp") assert.NotContains(t, parsed, "default_model") assert.NotContains(t, parsed, "mcpServers") - // kilo has no "providers" concept. - assert.NotContains(t, parsed, "providers") // MCP entries use local/remote type (MCPOpencodeStyle). mcp := parsed["mcp"].(map[string]any) fp := mcp["filepuff"].(map[string]any) @@ -85,11 +91,43 @@ func TestKiloRenderMergesExistingKeys(t *testing.T) { require.NoError(t, json.Unmarshal(seen[cfgDest].Content, &parsed)) // User keys preserved. assert.Equal(t, "https://kilo.schema/v1", parsed["$schema"]) - assert.Equal(t, "claude-haiku", parsed["small_model"]) assert.Contains(t, parsed, "compaction") - // Model updated. - assert.Equal(t, "sonnet", parsed["model"]) + // Model updated to providerID/modelID; small_model also set. + assert.Equal(t, "harness-sync-gateway/sonnet", parsed["model"]) + assert.Equal(t, "harness-sync-gateway/sonnet", parsed["small_model"]) } +func TestKiloRenderEmitsModelString(t *testing.T) { + home := t.TempDir() + ad := New(WithHome(home)) + b := &canonical.Bundle{ + Profile: canonical.Profile{ + Gateway: canonical.Gateway{URL: "https://gw", Token: "tok", DefaultModel: "claude-sonnet-4-6"}, + }, + } + fs, err := ad.Render(b) + require.NoError(t, err) + seen := map[string]adapter.File{} + fs.ForEach(func(f adapter.File) { seen[f.Dest] = f }) + + cfgDest := filepath.Join(home, ".config", "kilo", "kilo.json") + var parsed map[string]any + require.NoError(t, json.Unmarshal(seen[cfgDest].Content, &parsed)) + + // model must be "harness-sync-gateway/". + assert.Equal(t, "harness-sync-gateway/claude-sonnet-4-6", parsed["model"]) + assert.Equal(t, "harness-sync-gateway/claude-sonnet-4-6", parsed["small_model"]) + + // provider map has npm field. + provider, ok := parsed["provider"].(map[string]any) + require.True(t, ok) + gw, ok := provider["harness-sync-gateway"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "@ai-sdk/openai-compatible", gw["npm"]) + opts, ok := gw["options"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "https://gw", opts["baseURL"]) +} + func TestKiloImport(t *testing.T) { home := t.TempDir() base := filepath.Join(home, ".config", "kilo") diff --git a/internal/adapters/opencode/opencode.go b/internal/adapters/opencode/opencode.go index 2fbb614..29e0f1e 100644 --- a/internal/adapters/opencode/opencode.go +++ b/internal/adapters/opencode/opencode.go @@ -59,7 +59,7 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) { existing, _ := os.ReadFile(cfgPath) overlay := map[string]any{ "provider": common.ProvidersAsMap(&b.Profile), - "model": b.Profile.Gateway.DefaultModel, + "model": common.KiloModelString(&b.Profile), } if mcp := common.BuildMCPMapStyled(&b.MCP, common.MCPOpencodeStyle); len(mcp) > 0 { overlay["mcp"] = mcp diff --git a/internal/adapters/opencode/opencode_test.go b/internal/adapters/opencode/opencode_test.go index ec862bf..71be998 100644 --- a/internal/adapters/opencode/opencode_test.go +++ b/internal/adapters/opencode/opencode_test.go @@ -109,6 +109,37 @@ func TestOpencodeRenderMergesExistingKeys(t *testing.T) { assert.Contains(t, parsed, "model") assert.Contains(t, parsed, "provider") } +func TestOpencodeRenderProviderHasNpm(t *testing.T) { + home := t.TempDir() + ad := New(WithHome(home)) + b := &canonical.Bundle{ + Profile: canonical.Profile{ + Gateway: canonical.Gateway{URL: "https://gw", Token: "tok", DefaultModel: "claude-sonnet-4-6"}, + }, + } + fs, err := ad.Render(b) + require.NoError(t, err) + seen := map[string]adapter.File{} + fs.ForEach(func(f adapter.File) { seen[f.Dest] = f }) + + cfgDest := filepath.Join(home, ".config", "opencode", "opencode.jsonc") + var parsed map[string]any + require.NoError(t, json.Unmarshal(seen[cfgDest].Content, &parsed)) + + // provider map has npm field. + provider, ok := parsed["provider"].(map[string]any) + require.True(t, ok) + gw, ok := provider["harness-sync-gateway"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "@ai-sdk/openai-compatible", gw["npm"]) + opts, ok := gw["options"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "https://gw", opts["baseURL"]) + + // model is "harness-sync-gateway/modelID". + assert.Equal(t, "harness-sync-gateway/claude-sonnet-4-6", parsed["model"]) +} + func TestOpencodeImportStripsComments(t *testing.T) { home := t.TempDir() base := filepath.Join(home, ".config", "opencode") diff --git a/internal/adapters/zed/zed.go b/internal/adapters/zed/zed.go index ffc1700..3da2c2a 100644 --- a/internal/adapters/zed/zed.go +++ b/internal/adapters/zed/zed.go @@ -74,6 +74,10 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) { } } + if lm := common.ZedLanguageModels(&b.Profile); len(lm) > 0 { + overlay["language_models"] = lm + } + merged, err := common.MergeJSONKeys(existing, overlay) if err != nil { return nil, err diff --git a/internal/adapters/zed/zed_test.go b/internal/adapters/zed/zed_test.go index 25f52c8..c495417 100644 --- a/internal/adapters/zed/zed_test.go +++ b/internal/adapters/zed/zed_test.go @@ -111,6 +111,37 @@ func TestZedRenderNoMCP(t *testing.T) { assert.Empty(t, cs) } +func TestZedRenderEmitsLanguageModelsOpenAI(t *testing.T) { + home := t.TempDir() + ad := New(WithHome(home)) + b := &canonical.Bundle{ + Profile: canonical.Profile{ + Gateway: canonical.Gateway{URL: "https://gw", Token: "tok", DefaultModel: "claude-sonnet-4-6"}, + Models: []canonical.Model{{ID: "claude-sonnet-4-6", Alias: "sonnet"}}, + }, + } + fs, err := ad.Render(b) + require.NoError(t, err) + seen := map[string]adapter.File{} + fs.ForEach(func(f adapter.File) { seen[f.Dest] = f }) + + cfgDest := filepath.Join(home, ".config", "zed", "settings.json") + var parsed map[string]any + require.NoError(t, json.Unmarshal(seen[cfgDest].Content, &parsed)) + + lm, ok := parsed["language_models"].(map[string]any) + require.True(t, ok, "language_models must be present") + oai, ok := lm["openai"].(map[string]any) + require.True(t, ok, "language_models.openai must be present") + assert.Equal(t, "https://gw", oai["api_url"]) + avail, ok := oai["available_models"].([]any) + require.True(t, ok) + require.Len(t, avail, 1) + m0 := avail[0].(map[string]any) + assert.Equal(t, "claude-sonnet-4-6", m0["name"]) + assert.Equal(t, "sonnet", m0["display_name"]) +} + func TestZedImport(t *testing.T) { home := t.TempDir() base := filepath.Join(home, ".config", "zed")