From 5140445630d1a55d102bd09007fad9194c20aeab Mon Sep 17 00:00:00 2001 From: Marcel Bertagnini Date: Mon, 18 May 2026 13:09:00 +0200 Subject: [PATCH 1/2] feat(agent): support custom baseURL for Claude Code workspaces When a model ID includes a base URL (provider::model::baseURL), write ANTHROPIC_BASE_URL into .claude/settings.json so Claude Code routes requests to the custom endpoint. This enables local inference engines (e.g. llama-server) and API proxies. Part of openkaiden/kaiden#1855 Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Marcel Bertagnini --- pkg/agent/claude.go | 23 ++++++-- pkg/agent/claude_test.go | 115 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 4 deletions(-) diff --git a/pkg/agent/claude.go b/pkg/agent/claude.go index 14d6488..6ffc598 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,20 @@ 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 + } + 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..66fd086 100644 --- a/pkg/agent/claude_test.go +++ b/pkg/agent/claude_test.go @@ -537,6 +537,121 @@ 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_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) { From 6fdea5b11e9919131ae8e4ff5663b58bf0d27b4c Mon Sep 17 00:00:00 2001 From: Marcel Bertagnini Date: Mon, 18 May 2026 13:34:35 +0200 Subject: [PATCH 2/2] fix(agent): clear stale ANTHROPIC_BASE_URL when no custom endpoint If a pre-existing .claude/settings.json contains ANTHROPIC_BASE_URL from a previous configuration, remove it when the new model ID does not include a custom base URL to prevent unintended routing. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Marcel Bertagnini --- pkg/agent/claude.go | 2 ++ pkg/agent/claude_test.go | 43 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/pkg/agent/claude.go b/pkg/agent/claude.go index 6ffc598..ea49a4b 100644 --- a/pkg/agent/claude.go +++ b/pkg/agent/claude.go @@ -244,6 +244,8 @@ func (c *claudeAgent) SetModel(settings map[string]SettingsFile, modelID string, } 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, "", " ") diff --git a/pkg/agent/claude_test.go b/pkg/agent/claude_test.go index 66fd086..2bd87c7 100644 --- a/pkg/agent/claude_test.go +++ b/pkg/agent/claude_test.go @@ -614,6 +614,49 @@ func TestClaude_SetModel_NoBaseURL_NoEnvBlock(t *testing.T) { } } +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()