diff --git a/pkg/agent/claude.go b/pkg/agent/claude.go index 14d6488..ea49a4b 100644 --- a/pkg/agent/claude.go +++ b/pkg/agent/claude.go @@ -25,6 +25,7 @@ import ( workspace "github.com/openkaiden/kdn-api/workspace-configuration/go" kdnconfig "github.com/openkaiden/kdn/pkg/config" + "github.com/openkaiden/kdn/pkg/containerurl" ) const ( @@ -217,9 +218,12 @@ func (c *claudeAgent) SetMCPServers(settings map[string]SettingsFile, mcp *works } // SetModel configures the model ID in Claude settings. -// It sets the model field in .claude/settings.json. -// All other fields in the settings file are preserved. -func (c *claudeAgent) SetModel(settings map[string]SettingsFile, modelID string, _ string) (map[string]SettingsFile, error) { +// It sets the model field in .claude/settings.json. When the model ID +// includes a base URL (provider::model::baseURL), it also sets +// ANTHROPIC_BASE_URL in the env block so Claude Code routes requests +// to the custom endpoint. Localhost URLs are rewritten using +// containerHost so they are reachable from inside the runtime. +func (c *claudeAgent) SetModel(settings map[string]SettingsFile, modelID string, containerHost string) (map[string]SettingsFile, error) { settings = EnsureSettings(settings) existingContent := GetContent(settings, ClaudeSettingsPath, []byte("{}")) @@ -228,9 +232,22 @@ func (c *claudeAgent) SetModel(settings map[string]SettingsFile, modelID string, return nil, fmt.Errorf("failed to parse existing %s: %w", ClaudeSettingsPath, err) } - _, modelName, _ := kdnconfig.ParseModelID(modelID) + _, modelName, baseURL := kdnconfig.ParseModelID(modelID) config["model"] = modelName + if baseURL != "" { + resolvedURL := containerurl.RewriteURLWithHost(baseURL, containerHost) + + env, _ := config["env"].(map[string]interface{}) + if env == nil { + env = make(map[string]interface{}) + } + env["ANTHROPIC_BASE_URL"] = resolvedURL + config["env"] = env + } else if env, ok := config["env"].(map[string]interface{}); ok { + delete(env, "ANTHROPIC_BASE_URL") + } + modifiedContent, err := json.MarshalIndent(config, "", " ") if err != nil { return nil, fmt.Errorf("failed to marshal modified %s: %w", ClaudeSettingsPath, err) diff --git a/pkg/agent/claude_test.go b/pkg/agent/claude_test.go index b01cbf4..2bd87c7 100644 --- a/pkg/agent/claude_test.go +++ b/pkg/agent/claude_test.go @@ -537,6 +537,164 @@ func TestClaude_SetModel_ProviderModelURLFormat(t *testing.T) { if model, ok := config["model"].(string); !ok || model != "gemma2:7b" { t.Errorf("model = %v, want %q", config["model"], "gemma2:7b") } + + env, ok := config["env"].(map[string]interface{}) + if !ok { + t.Fatalf("env is not a map: %v", config["env"]) + } + expectedURL := "http://" + containerurl.ContainerHost + ":11434/v1" + if env["ANTHROPIC_BASE_URL"] != expectedURL { + t.Errorf("ANTHROPIC_BASE_URL = %v, want %q", env["ANTHROPIC_BASE_URL"], expectedURL) + } +} + +func TestClaude_SetModel_BaseURLRewritesLocalhost(t *testing.T) { + t.Parallel() + + agent := NewClaude() + settings := make(map[string]SettingsFile) + + result, err := agent.SetModel(settings, "anthropic::claude-sonnet-4-20250514::http://127.0.0.1:8080", containerurl.ContainerHost) + if err != nil { + t.Fatalf("SetModel() error = %v", err) + } + + var config map[string]interface{} + if err := json.Unmarshal(result[ClaudeSettingsPath].Content, &config); err != nil { + t.Fatalf("Failed to parse result JSON: %v", err) + } + + env := config["env"].(map[string]interface{}) + expectedURL := "http://" + containerurl.ContainerHost + ":8080" + if env["ANTHROPIC_BASE_URL"] != expectedURL { + t.Errorf("ANTHROPIC_BASE_URL = %v, want %q", env["ANTHROPIC_BASE_URL"], expectedURL) + } +} + +func TestClaude_SetModel_BaseURLPreservesRemoteHost(t *testing.T) { + t.Parallel() + + agent := NewClaude() + settings := make(map[string]SettingsFile) + + result, err := agent.SetModel(settings, "anthropic::claude-sonnet-4-20250514::https://proxy.example.com/anthropic", containerurl.ContainerHost) + if err != nil { + t.Fatalf("SetModel() error = %v", err) + } + + var config map[string]interface{} + if err := json.Unmarshal(result[ClaudeSettingsPath].Content, &config); err != nil { + t.Fatalf("Failed to parse result JSON: %v", err) + } + + env := config["env"].(map[string]interface{}) + if env["ANTHROPIC_BASE_URL"] != "https://proxy.example.com/anthropic" { + t.Errorf("ANTHROPIC_BASE_URL = %v, want %q", env["ANTHROPIC_BASE_URL"], "https://proxy.example.com/anthropic") + } +} + +func TestClaude_SetModel_NoBaseURL_NoEnvBlock(t *testing.T) { + t.Parallel() + + agent := NewClaude() + settings := make(map[string]SettingsFile) + + result, err := agent.SetModel(settings, "anthropic::claude-sonnet-4-20250514", containerurl.ContainerHost) + if err != nil { + t.Fatalf("SetModel() error = %v", err) + } + + var config map[string]interface{} + if err := json.Unmarshal(result[ClaudeSettingsPath].Content, &config); err != nil { + t.Fatalf("Failed to parse result JSON: %v", err) + } + + if _, ok := config["env"]; ok { + t.Errorf("env block should not be present when no baseURL is specified, got: %v", config["env"]) + } +} + +func TestClaude_SetModel_NoBaseURL_ClearsStaleBaseURL(t *testing.T) { + t.Parallel() + + agent := NewClaude() + + existingSettings := map[string]interface{}{ + "model": "old-model", + "env": map[string]interface{}{ + "ANTHROPIC_BASE_URL": "http://stale-endpoint:8080", + "CUSTOM_VAR": "keep-me", + }, + } + existingJSON, err := json.Marshal(existingSettings) + if err != nil { + t.Fatalf("Failed to marshal existing settings: %v", err) + } + + settings := map[string]SettingsFile{ + ClaudeSettingsPath: {Content: existingJSON}, + } + + result, err := agent.SetModel(settings, "anthropic::claude-sonnet-4-20250514", containerurl.ContainerHost) + if err != nil { + t.Fatalf("SetModel() error = %v", err) + } + + var config map[string]interface{} + if err := json.Unmarshal(result[ClaudeSettingsPath].Content, &config); err != nil { + t.Fatalf("Failed to parse result JSON: %v", err) + } + + env, ok := config["env"].(map[string]interface{}) + if !ok { + t.Fatalf("env block should still exist (has other vars): %v", config["env"]) + } + if _, exists := env["ANTHROPIC_BASE_URL"]; exists { + t.Errorf("ANTHROPIC_BASE_URL should have been removed, got: %v", env["ANTHROPIC_BASE_URL"]) + } + if env["CUSTOM_VAR"] != "keep-me" { + t.Errorf("CUSTOM_VAR = %v, want %q (other env vars should be preserved)", env["CUSTOM_VAR"], "keep-me") + } +} + +func TestClaude_SetModel_BaseURLPreservesExistingEnv(t *testing.T) { + t.Parallel() + + agent := NewClaude() + + existingSettings := map[string]interface{}{ + "model": "old-model", + "env": map[string]interface{}{ + "CUSTOM_VAR": "custom-value", + }, + } + existingJSON, err := json.Marshal(existingSettings) + if err != nil { + t.Fatalf("Failed to marshal existing settings: %v", err) + } + + settings := map[string]SettingsFile{ + ClaudeSettingsPath: {Content: existingJSON}, + } + + result, err := agent.SetModel(settings, "anthropic::new-model::http://localhost:8080", containerurl.ContainerHost) + if err != nil { + t.Fatalf("SetModel() error = %v", err) + } + + var config map[string]interface{} + if err := json.Unmarshal(result[ClaudeSettingsPath].Content, &config); err != nil { + t.Fatalf("Failed to parse result JSON: %v", err) + } + + env := config["env"].(map[string]interface{}) + if env["CUSTOM_VAR"] != "custom-value" { + t.Errorf("CUSTOM_VAR = %v, want %q (existing env should be preserved)", env["CUSTOM_VAR"], "custom-value") + } + expectedURL := "http://" + containerurl.ContainerHost + ":8080" + if env["ANTHROPIC_BASE_URL"] != expectedURL { + t.Errorf("ANTHROPIC_BASE_URL = %v, want %q", env["ANTHROPIC_BASE_URL"], expectedURL) + } } func TestClaude_SkillsDir(t *testing.T) {