Skip to content
Draft
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
25 changes: 21 additions & 4 deletions pkg/agent/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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("{}"))

Expand All @@ -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")
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

modifiedContent, err := json.MarshalIndent(config, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal modified %s: %w", ClaudeSettingsPath, err)
Expand Down
158 changes: 158 additions & 0 deletions pkg/agent/claude_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading