From 72900bfc4ab7b0ee21a6da48a9d1bb38b35e7552 Mon Sep 17 00:00:00 2001 From: Jeremy Drouillard Date: Fri, 1 May 2026 11:33:54 -0700 Subject: [PATCH 1/3] Append Anthropic path prefix when writing ANTHROPIC_BASE_URL Envoy AI Gateway routes native-Anthropic traffic at /anthropic/v1/messages, not /v1/messages. Without the prefix, Claude Code's requests fall through to the catchall route and the user sees "model may not exist or you may not have access" while gateway logs show 404s. Add a persisted llm.anthropic_path_prefix setting and an --anthropic-path-prefix flag on both "thv llm setup" and "thv llm config set". When non-empty, the prefix is appended to the gateway URL before ANTHROPIC_BASE_URL is written into Claude Code's settings file. The proxy-mode upstream is unaffected because proxy-mode tools forward OpenAI-shaped traffic on a separate route. Closes #5158 Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/thv/app/llm.go | 6 ++++ pkg/client/config.go | 5 +++- pkg/client/llm_gateway.go | 5 ++++ pkg/client/llm_gateway_test.go | 55 ++++++++++++++++++++++++++++++++++ pkg/llm/config.go | 51 +++++++++++++++++++++++++++---- pkg/llm/manage.go | 21 ++++++++----- pkg/llm/manage_test.go | 41 +++++++++++++++++++++++++ pkg/llm/setup.go | 15 ++++++---- pkg/llmgateway/config.go | 7 +++++ 9 files changed, 187 insertions(+), 19 deletions(-) diff --git a/cmd/thv/app/llm.go b/cmd/thv/app/llm.go index 1cb806c4b4..51a60edfa2 100644 --- a/cmd/thv/app/llm.go +++ b/cmd/thv/app/llm.go @@ -107,6 +107,9 @@ Example: cmd.Flags().IntVar(&opts.CallbackPort, "callback-port", 0, "OIDC callback port (omit to keep current; default: ephemeral)") cmd.Flags().BoolVar(&tlsSkipVerify, "tls-skip-verify", false, "Skip TLS certificate verification for the upstream gateway (local dev only; use --tls-skip-verify=false to clear)") + cmd.Flags().StringVar(&opts.AnthropicPathPrefix, "anthropic-path-prefix", "", + "Path prefix appended to the gateway URL when writing ANTHROPIC_BASE_URL "+ + "(e.g. \"/anthropic\" for Envoy AI Gateway). Leave empty for LiteLLM or direct Anthropic.") return cmd } @@ -266,6 +269,9 @@ Run "thv llm teardown" to revert all changes.`, "For direct-mode tools (Claude Code, Gemini CLI) this sets NODE_TLS_REJECT_UNAUTHORIZED=0, "+ "disabling TLS for ALL of that tool's outbound connections. "+ "For proxy-mode tools only the proxy-to-gateway connection is affected.") + cmd.Flags().StringVar(&opts.AnthropicPathPrefix, "anthropic-path-prefix", "", + "Path prefix appended to the gateway URL when writing ANTHROPIC_BASE_URL "+ + "(e.g. \"/anthropic\" for Envoy AI Gateway). Leave empty for LiteLLM or direct Anthropic.") cmd.Flags().StringVar(&targetClient, "client", "", "Configure only this AI tool by name (e.g. claude-code, cursor). Omit to configure all detected tools.") diff --git a/pkg/client/config.go b/pkg/client/config.go index 88e7ea8e19..a1ad31bab7 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -461,7 +461,10 @@ var supportedClientIntegrations = []clientAppConfig{ LLMSettingsRelPath: []string{".claude"}, LLMGatewayKeys: []LLMGatewayKeySpec{ {JSONPointer: "/apiKeyHelper", ValueField: "TokenHelperCommand"}, - {JSONPointer: "/env/ANTHROPIC_BASE_URL", ValueField: "GatewayURL"}, + // AnthropicBaseURL appends llm.anthropic_path_prefix to the gateway + // URL so Envoy AI Gateway (which routes native-Anthropic traffic at + // /anthropic) works without manual edits. + {JSONPointer: "/env/ANTHROPIC_BASE_URL", ValueField: "AnthropicBaseURL"}, // NODE_TLS_REJECT_UNAUTHORIZED is only written when --tls-skip-verify is set. // ClearWhenEmpty ensures it is removed when the flag is later cleared. {JSONPointer: "/env/NODE_TLS_REJECT_UNAUTHORIZED", ValueField: "NodeTLSRejectUnauthorized", ClearWhenEmpty: true}, diff --git a/pkg/client/llm_gateway.go b/pkg/client/llm_gateway.go index b0b6052675..631f3d064e 100644 --- a/pkg/client/llm_gateway.go +++ b/pkg/client/llm_gateway.go @@ -261,10 +261,15 @@ func (cm *ClientManager) buildLLMSettingsPath(cfg *clientAppConfig) string { // llmValueForSpec returns the config value corresponding to the ValueField name. // For "NodeTLSRejectUnauthorized", returns "0" when TLSSkipVerify is true, or "" // when false (which triggers removal when ClearWhenEmpty is set on the spec). +// "AnthropicBaseURL" returns GatewayURL with AnthropicPathPrefix appended, so +// gateways that route Anthropic-shaped traffic under a sub-path (e.g. Envoy AI +// Gateway at /anthropic) work without a manual edit. func llmValueForSpec(valueField string, cfg llmgateway.ApplyConfig) string { switch valueField { case "GatewayURL": return cfg.GatewayURL + case "AnthropicBaseURL": + return cfg.GatewayURL + cfg.AnthropicPathPrefix case "ProxyBaseURL": return cfg.ProxyBaseURL case "TokenHelperCommand": diff --git a/pkg/client/llm_gateway_test.go b/pkg/client/llm_gateway_test.go index a1e64e2ca9..93194c6209 100644 --- a/pkg/client/llm_gateway_test.go +++ b/pkg/client/llm_gateway_test.go @@ -539,6 +539,61 @@ func TestRealClientConfigs_LLMBinaryNames(t *testing.T) { } } +// ── AnthropicPathPrefix ────────────────────────────────────────────────────── + +// TestConfigureLLMGateway_AnthropicPathPrefix verifies that when a non-empty +// AnthropicPathPrefix is supplied (e.g. for Envoy AI Gateway, which serves +// native-Anthropic traffic at /anthropic/v1/messages), the prefix is appended +// to the gateway URL written to ANTHROPIC_BASE_URL. With an empty prefix the +// gateway URL is written unchanged so non-prefixed gateways (LiteLLM, direct +// Anthropic) continue to work. +func TestConfigureLLMGateway_AnthropicPathPrefix(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + prefix string + wantURL string // exact JSON-encoded value, including closing quote + }{ + { + name: "bare gateway URL when prefix is empty", + prefix: "", + wantURL: `"https://gw.example.com"`, + }, + { + name: "prefix is appended for Envoy AI Gateway", + prefix: "/anthropic", + wantURL: `"https://gw.example.com/anthropic"`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + home := t.TempDir() + cm := NewTestClientManager(home, nil, supportedClientIntegrations, nil) + + claudeDir := filepath.Join(home, ".claude") + require.NoError(t, os.MkdirAll(claudeDir, 0o700)) + + path, err := cm.ConfigureLLMGateway(ClaudeCode, llmgateway.ApplyConfig{ + GatewayURL: "https://gw.example.com", + AnthropicPathPrefix: tc.prefix, + TokenHelperCommand: `"thv" llm token`, + }) + require.NoError(t, err) + + data, err := os.ReadFile(path) + require.NoError(t, err) + s := string(data) + assert.Contains(t, s, "ANTHROPIC_BASE_URL") + assert.Contains(t, s, tc.wantURL, + "ANTHROPIC_BASE_URL must be written as %s", tc.wantURL) + }) + } +} + // ── TLSSkipVerify / NodeTLSRejectUnauthorized / ClearWhenEmpty ─────────────── func newTLSTestManager(t *testing.T) (*ClientManager, string) { diff --git a/pkg/llm/config.go b/pkg/llm/config.go index a4140a36ca..871283f2fc 100644 --- a/pkg/llm/config.go +++ b/pkg/llm/config.go @@ -6,11 +6,17 @@ package llm import ( "errors" "fmt" + "strings" "github.com/stacklok/toolhive/pkg/networking" pkgoidc "github.com/stacklok/toolhive/pkg/oidc" ) +// maxAnthropicPathPrefixLen caps the AnthropicPathPrefix length to a value +// large enough for any realistic gateway route while preventing absurd inputs +// from being persisted into ToolHive config or Claude Code's settings file. +const maxAnthropicPathPrefixLen = 256 + const ( // DefaultProxyListenPort is the default port the localhost proxy listens on. DefaultProxyListenPort = 14000 @@ -25,11 +31,16 @@ type OIDCConfig = pkgoidc.ClientConfig // Config holds all LLM gateway settings persisted under the llm: key in // ToolHive's config.yaml. type Config struct { - GatewayURL string `yaml:"gateway_url,omitempty" json:"gateway_url,omitempty"` - TLSSkipVerify bool `yaml:"tls_skip_verify,omitempty" json:"tls_skip_verify,omitempty"` - OIDC OIDCConfig `yaml:"oidc,omitempty" json:"oidc,omitempty"` - Proxy ProxyConfig `yaml:"proxy,omitempty" json:"proxy,omitempty"` - ConfiguredTools []ToolConfig `yaml:"configured_tools,omitempty" json:"configured_tools,omitempty"` + GatewayURL string `yaml:"gateway_url,omitempty" json:"gateway_url,omitempty"` + TLSSkipVerify bool `yaml:"tls_skip_verify,omitempty" json:"tls_skip_verify,omitempty"` + // AnthropicPathPrefix is appended to GatewayURL when writing + // ANTHROPIC_BASE_URL for direct-mode tools. Set to "/anthropic" for Envoy + // AI Gateway, leave empty for LiteLLM or direct Anthropic. Must start with + // "/" when non-empty and contain no query string or fragment. + AnthropicPathPrefix string `yaml:"anthropic_path_prefix,omitempty" json:"anthropic_path_prefix,omitempty"` + OIDC OIDCConfig `yaml:"oidc,omitempty" json:"oidc,omitempty"` + Proxy ProxyConfig `yaml:"proxy,omitempty" json:"proxy,omitempty"` + ConfiguredTools []ToolConfig `yaml:"configured_tools,omitempty" json:"configured_tools,omitempty"` } // ProxyConfig holds configuration for the localhost reverse proxy. @@ -77,6 +88,10 @@ func (c *Config) ValidatePartial() error { errs = append(errs, fmt.Errorf("proxy.listen_port must be between 1024 and 65535, got: %d", c.Proxy.ListenPort)) } + if err := validateAnthropicPathPrefix(c.AnthropicPathPrefix); err != nil { + errs = append(errs, fmt.Errorf("anthropic_path_prefix: %w", err)) + } + // Reuse networking.ValidateCallbackPort for the OIDC callback port — same // range check (1024–65535), zero means ephemeral (auto-assigned). Pass the // client ID so the validator applies strict availability checking for @@ -116,3 +131,29 @@ func (c *Config) EffectiveProxyPort() int { } return DefaultProxyListenPort } + +// validateAnthropicPathPrefix enforces that the prefix, when non-empty, is a +// well-formed URL path: starts with "/", contains no query string, fragment, +// or shell-unsafe characters, and stays under maxAnthropicPathPrefixLen. The +// shell check is defensive — the value flows into a Node-process env var, not +// a shell command, but rejecting metacharacters keeps surprises out of any +// future caller that does invoke a shell. +func validateAnthropicPathPrefix(p string) error { + if p == "" { + return nil + } + if len(p) > maxAnthropicPathPrefixLen { + return fmt.Errorf("must be %d characters or fewer, got %d", maxAnthropicPathPrefixLen, len(p)) + } + if !strings.HasPrefix(p, "/") { + return fmt.Errorf("must start with %q, got %q", "/", p) + } + if strings.ContainsAny(p, "?#") { + return fmt.Errorf("must not contain a query string or fragment, got %q", p) + } + const shellUnsafe = `"\;$` + "`\n\r '" + if strings.ContainsAny(p, shellUnsafe) { + return fmt.Errorf("must not contain whitespace or shell metacharacters, got %q", p) + } + return nil +} diff --git a/pkg/llm/manage.go b/pkg/llm/manage.go index 9dd6b03949..98af7a7510 100644 --- a/pkg/llm/manage.go +++ b/pkg/llm/manage.go @@ -38,6 +38,9 @@ func (c *Config) SetFields(opts SetOptions) error { if opts.TLSSkipVerify != nil { c.TLSSkipVerify = *opts.TLSSkipVerify } + if opts.AnthropicPathPrefix != "" { + c.AnthropicPathPrefix = opts.AnthropicPathPrefix + } if !c.IsConfigured() { return c.ValidatePartial() @@ -50,13 +53,14 @@ func (c *Config) SetFields(opts SetOptions) error { // field unchanged. TLSSkipVerify uses a pointer so that false can be // distinguished from "not provided" (enabling explicit clear via config set). type SetOptions struct { - GatewayURL string - Issuer string - ClientID string - Audience string - ProxyPort int - CallbackPort int - TLSSkipVerify *bool // nil = not provided; &false = explicitly disable + GatewayURL string + Issuer string + ClientID string + Audience string + ProxyPort int + CallbackPort int + TLSSkipVerify *bool // nil = not provided; &false = explicitly disable + AnthropicPathPrefix string } // DeleteCachedTokens removes all cached OIDC tokens stored under the LLM @@ -99,6 +103,9 @@ func (c *Config) Show(w io.Writer) error { } writef("Gateway URL: %s\n", c.GatewayURL) + if c.AnthropicPathPrefix != "" { + writef("Anthropic path: %s\n", c.AnthropicPathPrefix) + } writef("OIDC Issuer: %s\n", c.OIDC.Issuer) writef("OIDC Client: %s\n", c.OIDC.ClientID) if c.OIDC.Audience != "" { diff --git a/pkg/llm/manage_test.go b/pkg/llm/manage_test.go index 9235317116..f0b1677ed5 100644 --- a/pkg/llm/manage_test.go +++ b/pkg/llm/manage_test.go @@ -119,6 +119,44 @@ func TestConfig_SetFields(t *testing.T) { opts: SetOptions{}, want: Config{GatewayURL: "https://gw.example.com", TLSSkipVerify: true}, }, + { + name: "AnthropicPathPrefix accepts a leading-slash path", + opts: SetOptions{ + GatewayURL: "https://gw.example.com", + AnthropicPathPrefix: "/anthropic", + }, + want: Config{ + GatewayURL: "https://gw.example.com", + AnthropicPathPrefix: "/anthropic", + }, + }, + { + name: "AnthropicPathPrefix rejects values without leading slash", + opts: SetOptions{GatewayURL: "https://gw.example.com", AnthropicPathPrefix: "anthropic"}, + wantErr: true, + }, + { + name: "AnthropicPathPrefix rejects values with query string", + opts: SetOptions{GatewayURL: "https://gw.example.com", AnthropicPathPrefix: "/anthropic?x=1"}, + wantErr: true, + }, + { + name: "AnthropicPathPrefix rejects values with shell metacharacters", + opts: SetOptions{GatewayURL: "https://gw.example.com", AnthropicPathPrefix: "/anthropic;rm -rf /"}, + wantErr: true, + }, + { + name: "empty AnthropicPathPrefix leaves existing value unchanged", + base: Config{ + GatewayURL: "https://gw.example.com", + AnthropicPathPrefix: "/anthropic", + }, + opts: SetOptions{}, + want: Config{ + GatewayURL: "https://gw.example.com", + AnthropicPathPrefix: "/anthropic", + }, + }, } for _, tt := range tests { @@ -154,6 +192,9 @@ func TestConfig_SetFields(t *testing.T) { if cfg.TLSSkipVerify != tt.want.TLSSkipVerify { t.Errorf("TLSSkipVerify = %v, want %v", cfg.TLSSkipVerify, tt.want.TLSSkipVerify) } + if cfg.AnthropicPathPrefix != tt.want.AnthropicPathPrefix { + t.Errorf("AnthropicPathPrefix = %q, want %q", cfg.AnthropicPathPrefix, tt.want.AnthropicPathPrefix) + } }) } } diff --git a/pkg/llm/setup.go b/pkg/llm/setup.go index e9e19c5d4f..28e188f03a 100644 --- a/pkg/llm/setup.go +++ b/pkg/llm/setup.go @@ -113,7 +113,9 @@ func Setup( _, _ = fmt.Fprintln(out, "Login successful.") configured, err := configureDetectedTools( - out, errOut, gm, detected, llmCfg.GatewayURL, proxyBaseURL, tokenHelperCommand, llmCfg.TLSSkipVerify, + out, errOut, gm, detected, + llmCfg.GatewayURL, llmCfg.AnthropicPathPrefix, proxyBaseURL, tokenHelperCommand, + llmCfg.TLSSkipVerify, ) if err != nil { return err @@ -321,16 +323,17 @@ func configureDetectedTools( out, errOut io.Writer, gm GatewayManager, detected []string, - gatewayURL, proxyBaseURL, tokenHelperCommand string, + gatewayURL, anthropicPathPrefix, proxyBaseURL, tokenHelperCommand string, tlsSkipVerify bool, ) ([]ToolConfig, error) { var configured []ToolConfig for _, clientType := range detected { configPath, err := gm.ConfigureLLMGateway(clientType, llmgateway.ApplyConfig{ - GatewayURL: gatewayURL, - ProxyBaseURL: proxyBaseURL, - TokenHelperCommand: tokenHelperCommand, - TLSSkipVerify: tlsSkipVerify, + GatewayURL: gatewayURL, + AnthropicPathPrefix: anthropicPathPrefix, + ProxyBaseURL: proxyBaseURL, + TokenHelperCommand: tokenHelperCommand, + TLSSkipVerify: tlsSkipVerify, }) if err != nil { _, _ = fmt.Fprintf(errOut, "Warning: failed to configure %s: %v\n", clientType, err) diff --git a/pkg/llmgateway/config.go b/pkg/llmgateway/config.go index 5de820147f..dac01c4091 100644 --- a/pkg/llmgateway/config.go +++ b/pkg/llmgateway/config.go @@ -13,4 +13,11 @@ type ApplyConfig struct { ProxyBaseURL string // proxy-mode: URL of the localhost reverse proxy TokenHelperCommand string // direct-mode: shell command that prints a fresh token TLSSkipVerify bool // when true, instruct the tool to skip TLS verification + // AnthropicPathPrefix is appended to GatewayURL when writing the base URL + // for Anthropic-shaped APIs (e.g. ANTHROPIC_BASE_URL for Claude Code). Set + // to "/anthropic" for Envoy AI Gateway, leave empty for LiteLLM or direct + // Anthropic. The prefix is not applied to the localhost proxy upstream + // because proxy-mode tools forward OpenAI-shaped traffic on a separate + // route. + AnthropicPathPrefix string } From 89ad4da992dd9108cf9ac93874282238fc04cb13 Mon Sep 17 00:00:00 2001 From: Jeremy Drouillard Date: Fri, 1 May 2026 11:42:25 -0700 Subject: [PATCH 2/3] Default Anthropic path prefix to /anthropic for AI Gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most ToolHive deployments target Envoy AI Gateway, which routes native-Anthropic traffic at /anthropic. Make that the default so users do not have to know to pass --anthropic-path-prefix=/anthropic. To distinguish "not set, use default" from "explicitly empty" (LiteLLM or direct Anthropic), Config.AnthropicPathPrefix becomes a *string and SetOptions.AnthropicPathPrefix follows the same TLSSkipVerify pointer pattern. Add EffectiveAnthropicPathPrefix() to resolve nil → default. The CLI flags use cmd.Flags().Changed() so --anthropic-path-prefix="" correctly persists an empty override instead of being discarded as a zero value. Show now reports the effective value with (default) or (none — direct Anthropic / LiteLLM) annotations so users can tell at a glance which mode they are in. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/thv/app/llm.go | 32 +++++++++++++-------- pkg/llm/config.go | 64 +++++++++++++++++++++++++++++------------- pkg/llm/config_test.go | 35 +++++++++++++++++++++++ pkg/llm/manage.go | 21 +++++++++----- pkg/llm/manage_test.go | 50 +++++++++++++++++++++++++-------- pkg/llm/setup.go | 2 +- 6 files changed, 154 insertions(+), 50 deletions(-) diff --git a/cmd/thv/app/llm.go b/cmd/thv/app/llm.go index 51a60edfa2..befd5649ac 100644 --- a/cmd/thv/app/llm.go +++ b/cmd/thv/app/llm.go @@ -74,8 +74,9 @@ func newConfigCommand() *cobra.Command { func newConfigSetCommand() *cobra.Command { var ( - opts llm.SetOptions - tlsSkipVerify bool + opts llm.SetOptions + tlsSkipVerify bool + anthropicPathPrefix string ) cmd := &cobra.Command{ @@ -93,6 +94,9 @@ Example: if cmd.Flags().Changed("tls-skip-verify") { opts.TLSSkipVerify = &tlsSkipVerify } + if cmd.Flags().Changed("anthropic-path-prefix") { + opts.AnthropicPathPrefix = &anthropicPathPrefix + } return config.UpdateConfig(func(c *config.Config) error { return c.LLM.SetFields(opts) }) @@ -107,9 +111,10 @@ Example: cmd.Flags().IntVar(&opts.CallbackPort, "callback-port", 0, "OIDC callback port (omit to keep current; default: ephemeral)") cmd.Flags().BoolVar(&tlsSkipVerify, "tls-skip-verify", false, "Skip TLS certificate verification for the upstream gateway (local dev only; use --tls-skip-verify=false to clear)") - cmd.Flags().StringVar(&opts.AnthropicPathPrefix, "anthropic-path-prefix", "", - "Path prefix appended to the gateway URL when writing ANTHROPIC_BASE_URL "+ - "(e.g. \"/anthropic\" for Envoy AI Gateway). Leave empty for LiteLLM or direct Anthropic.") + cmd.Flags().StringVar(&anthropicPathPrefix, "anthropic-path-prefix", llm.DefaultAnthropicPathPrefix, + `Path prefix appended to the gateway URL when writing ANTHROPIC_BASE_URL. `+ + `Defaults to "/anthropic" (Envoy AI Gateway). `+ + `Pass --anthropic-path-prefix="" for LiteLLM or direct Anthropic.`) return cmd } @@ -217,9 +222,10 @@ func buildLLMTokenSource(cfg *llm.Config, interactive bool) (*llm.TokenSource, e func newLLMSetupCommand() *cobra.Command { var ( - opts llm.SetOptions - tlsSkipVerify bool - targetClient string + opts llm.SetOptions + tlsSkipVerify bool + anthropicPathPrefix string + targetClient string ) cmd := &cobra.Command{ @@ -247,6 +253,9 @@ Run "thv llm teardown" to revert all changes.`, if cmd.Flags().Changed("tls-skip-verify") { opts.TLSSkipVerify = &tlsSkipVerify } + if cmd.Flags().Changed("anthropic-path-prefix") { + opts.AnthropicPathPrefix = &anthropicPathPrefix + } cm, err := client.NewClientManager() if err != nil { return fmt.Errorf("initializing client manager: %w", err) @@ -269,9 +278,10 @@ Run "thv llm teardown" to revert all changes.`, "For direct-mode tools (Claude Code, Gemini CLI) this sets NODE_TLS_REJECT_UNAUTHORIZED=0, "+ "disabling TLS for ALL of that tool's outbound connections. "+ "For proxy-mode tools only the proxy-to-gateway connection is affected.") - cmd.Flags().StringVar(&opts.AnthropicPathPrefix, "anthropic-path-prefix", "", - "Path prefix appended to the gateway URL when writing ANTHROPIC_BASE_URL "+ - "(e.g. \"/anthropic\" for Envoy AI Gateway). Leave empty for LiteLLM or direct Anthropic.") + cmd.Flags().StringVar(&anthropicPathPrefix, "anthropic-path-prefix", llm.DefaultAnthropicPathPrefix, + `Path prefix appended to the gateway URL when writing ANTHROPIC_BASE_URL. `+ + `Defaults to "/anthropic" (Envoy AI Gateway). `+ + `Pass --anthropic-path-prefix="" for LiteLLM or direct Anthropic.`) cmd.Flags().StringVar(&targetClient, "client", "", "Configure only this AI tool by name (e.g. claude-code, cursor). Omit to configure all detected tools.") diff --git a/pkg/llm/config.go b/pkg/llm/config.go index 871283f2fc..fdef1df7f9 100644 --- a/pkg/llm/config.go +++ b/pkg/llm/config.go @@ -17,6 +17,13 @@ import ( // from being persisted into ToolHive config or Claude Code's settings file. const maxAnthropicPathPrefixLen = 256 +// DefaultAnthropicPathPrefix is the path appended to the gateway URL when the +// user has not explicitly set llm.anthropic_path_prefix. It targets Envoy AI +// Gateway, which routes native-Anthropic traffic at /anthropic/v1/messages. +// Users on LiteLLM or direct Anthropic must opt out with +// `--anthropic-path-prefix=""` so EffectiveAnthropicPathPrefix returns "". +const DefaultAnthropicPathPrefix = "/anthropic" + const ( // DefaultProxyListenPort is the default port the localhost proxy listens on. DefaultProxyListenPort = 14000 @@ -34,10 +41,12 @@ type Config struct { GatewayURL string `yaml:"gateway_url,omitempty" json:"gateway_url,omitempty"` TLSSkipVerify bool `yaml:"tls_skip_verify,omitempty" json:"tls_skip_verify,omitempty"` // AnthropicPathPrefix is appended to GatewayURL when writing - // ANTHROPIC_BASE_URL for direct-mode tools. Set to "/anthropic" for Envoy - // AI Gateway, leave empty for LiteLLM or direct Anthropic. Must start with - // "/" when non-empty and contain no query string or fragment. - AnthropicPathPrefix string `yaml:"anthropic_path_prefix,omitempty" json:"anthropic_path_prefix,omitempty"` + // ANTHROPIC_BASE_URL for direct-mode tools. nil means "use the default" + // (DefaultAnthropicPathPrefix, which targets Envoy AI Gateway). An + // explicit empty string means "no prefix" — required for LiteLLM or direct + // Anthropic. Use EffectiveAnthropicPathPrefix to read the resolved value. + // Must start with "/" when non-nil and non-empty. + AnthropicPathPrefix *string `yaml:"anthropic_path_prefix,omitempty" json:"anthropic_path_prefix,omitempty"` OIDC OIDCConfig `yaml:"oidc,omitempty" json:"oidc,omitempty"` Proxy ProxyConfig `yaml:"proxy,omitempty" json:"proxy,omitempty"` ConfiguredTools []ToolConfig `yaml:"configured_tools,omitempty" json:"configured_tools,omitempty"` @@ -132,28 +141,43 @@ func (c *Config) EffectiveProxyPort() int { return DefaultProxyListenPort } -// validateAnthropicPathPrefix enforces that the prefix, when non-empty, is a -// well-formed URL path: starts with "/", contains no query string, fragment, -// or shell-unsafe characters, and stays under maxAnthropicPathPrefixLen. The -// shell check is defensive — the value flows into a Node-process env var, not -// a shell command, but rejecting metacharacters keeps surprises out of any -// future caller that does invoke a shell. -func validateAnthropicPathPrefix(p string) error { - if p == "" { +// EffectiveAnthropicPathPrefix returns the path prefix that should be appended +// to the gateway URL when writing ANTHROPIC_BASE_URL. A nil persisted value +// means "use the default" (DefaultAnthropicPathPrefix, targeting Envoy AI +// Gateway). An explicit empty string disables the prefix for LiteLLM or +// direct Anthropic. +func (c *Config) EffectiveAnthropicPathPrefix() string { + if c.AnthropicPathPrefix == nil { + return DefaultAnthropicPathPrefix + } + return *c.AnthropicPathPrefix +} + +// validateAnthropicPathPrefix enforces that the prefix, when non-nil and +// non-empty, is a well-formed URL path: starts with "/", contains no query +// string, fragment, or shell-unsafe characters, and stays under +// maxAnthropicPathPrefixLen. nil means "use the default" and an explicit +// empty string means "no prefix" — both are valid. The shell check is +// defensive: the value flows into a Node-process env var, not a shell +// command, but rejecting metacharacters keeps surprises out of any future +// caller that does invoke a shell. +func validateAnthropicPathPrefix(p *string) error { + if p == nil || *p == "" { return nil } - if len(p) > maxAnthropicPathPrefixLen { - return fmt.Errorf("must be %d characters or fewer, got %d", maxAnthropicPathPrefixLen, len(p)) + v := *p + if len(v) > maxAnthropicPathPrefixLen { + return fmt.Errorf("must be %d characters or fewer, got %d", maxAnthropicPathPrefixLen, len(v)) } - if !strings.HasPrefix(p, "/") { - return fmt.Errorf("must start with %q, got %q", "/", p) + if !strings.HasPrefix(v, "/") { + return fmt.Errorf("must start with %q, got %q", "/", v) } - if strings.ContainsAny(p, "?#") { - return fmt.Errorf("must not contain a query string or fragment, got %q", p) + if strings.ContainsAny(v, "?#") { + return fmt.Errorf("must not contain a query string or fragment, got %q", v) } const shellUnsafe = `"\;$` + "`\n\r '" - if strings.ContainsAny(p, shellUnsafe) { - return fmt.Errorf("must not contain whitespace or shell metacharacters, got %q", p) + if strings.ContainsAny(v, shellUnsafe) { + return fmt.Errorf("must not contain whitespace or shell metacharacters, got %q", v) } return nil } diff --git a/pkg/llm/config_test.go b/pkg/llm/config_test.go index f2d69f9a4d..dc607a5d98 100644 --- a/pkg/llm/config_test.go +++ b/pkg/llm/config_test.go @@ -316,6 +316,41 @@ func TestConfig_EffectiveProxyPort(t *testing.T) { }) } +func TestConfig_EffectiveAnthropicPathPrefix(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg Config + want string + }{ + { + name: "nil falls back to /anthropic default for Envoy AI Gateway", + cfg: Config{}, + want: DefaultAnthropicPathPrefix, + }, + { + name: "explicit empty opts out of the default", + cfg: Config{AnthropicPathPrefix: stringPtr("")}, + want: "", + }, + { + name: "explicit value passes through", + cfg: Config{AnthropicPathPrefix: stringPtr("/anthropic/v2")}, + want: "/anthropic/v2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.cfg.EffectiveAnthropicPathPrefix(); got != tt.want { + t.Errorf("EffectiveAnthropicPathPrefix() = %q, want %q", got, tt.want) + } + }) + } +} + func TestOIDCConfig_EffectiveScopes(t *testing.T) { t.Parallel() diff --git a/pkg/llm/manage.go b/pkg/llm/manage.go index 98af7a7510..f4655c8602 100644 --- a/pkg/llm/manage.go +++ b/pkg/llm/manage.go @@ -38,7 +38,7 @@ func (c *Config) SetFields(opts SetOptions) error { if opts.TLSSkipVerify != nil { c.TLSSkipVerify = *opts.TLSSkipVerify } - if opts.AnthropicPathPrefix != "" { + if opts.AnthropicPathPrefix != nil { c.AnthropicPathPrefix = opts.AnthropicPathPrefix } @@ -50,8 +50,10 @@ func (c *Config) SetFields(opts SetOptions) error { // SetOptions carries the flag values for the "config set" command. // Zero values are treated as "not provided" and leave the existing config -// field unchanged. TLSSkipVerify uses a pointer so that false can be -// distinguished from "not provided" (enabling explicit clear via config set). +// field unchanged. Pointer-typed fields use nil for "not provided" so that +// the zero value (false, "") can still be set explicitly — required for +// TLSSkipVerify=false (clear) and AnthropicPathPrefix="" (opt out of the +// /anthropic default for LiteLLM or direct Anthropic). type SetOptions struct { GatewayURL string Issuer string @@ -59,8 +61,8 @@ type SetOptions struct { Audience string ProxyPort int CallbackPort int - TLSSkipVerify *bool // nil = not provided; &false = explicitly disable - AnthropicPathPrefix string + TLSSkipVerify *bool // nil = not provided; &false = explicitly disable + AnthropicPathPrefix *string // nil = not provided; &"" = explicit no prefix; &"/x" = explicit value } // DeleteCachedTokens removes all cached OIDC tokens stored under the LLM @@ -103,8 +105,13 @@ func (c *Config) Show(w io.Writer) error { } writef("Gateway URL: %s\n", c.GatewayURL) - if c.AnthropicPathPrefix != "" { - writef("Anthropic path: %s\n", c.AnthropicPathPrefix) + switch { + case c.AnthropicPathPrefix == nil: + writef("Anthropic path: %s (default)\n", DefaultAnthropicPathPrefix) + case *c.AnthropicPathPrefix == "": + writef("Anthropic path: (none — direct Anthropic / LiteLLM)\n") + default: + writef("Anthropic path: %s\n", *c.AnthropicPathPrefix) } writef("OIDC Issuer: %s\n", c.OIDC.Issuer) writef("OIDC Client: %s\n", c.OIDC.ClientID) diff --git a/pkg/llm/manage_test.go b/pkg/llm/manage_test.go index f0b1677ed5..04785f27f6 100644 --- a/pkg/llm/manage_test.go +++ b/pkg/llm/manage_test.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "errors" + "fmt" "strings" "testing" @@ -123,38 +124,49 @@ func TestConfig_SetFields(t *testing.T) { name: "AnthropicPathPrefix accepts a leading-slash path", opts: SetOptions{ GatewayURL: "https://gw.example.com", - AnthropicPathPrefix: "/anthropic", + AnthropicPathPrefix: stringPtr("/anthropic"), }, want: Config{ GatewayURL: "https://gw.example.com", - AnthropicPathPrefix: "/anthropic", + AnthropicPathPrefix: stringPtr("/anthropic"), + }, + }, + { + name: "explicit empty AnthropicPathPrefix opts out of the default", + opts: SetOptions{ + GatewayURL: "https://gw.example.com", + AnthropicPathPrefix: stringPtr(""), + }, + want: Config{ + GatewayURL: "https://gw.example.com", + AnthropicPathPrefix: stringPtr(""), }, }, { name: "AnthropicPathPrefix rejects values without leading slash", - opts: SetOptions{GatewayURL: "https://gw.example.com", AnthropicPathPrefix: "anthropic"}, + opts: SetOptions{GatewayURL: "https://gw.example.com", AnthropicPathPrefix: stringPtr("anthropic")}, wantErr: true, }, { name: "AnthropicPathPrefix rejects values with query string", - opts: SetOptions{GatewayURL: "https://gw.example.com", AnthropicPathPrefix: "/anthropic?x=1"}, + opts: SetOptions{GatewayURL: "https://gw.example.com", AnthropicPathPrefix: stringPtr("/anthropic?x=1")}, wantErr: true, }, { name: "AnthropicPathPrefix rejects values with shell metacharacters", - opts: SetOptions{GatewayURL: "https://gw.example.com", AnthropicPathPrefix: "/anthropic;rm -rf /"}, + opts: SetOptions{GatewayURL: "https://gw.example.com", AnthropicPathPrefix: stringPtr("/anthropic;rm -rf /")}, wantErr: true, }, { - name: "empty AnthropicPathPrefix leaves existing value unchanged", + name: "nil AnthropicPathPrefix leaves existing value unchanged", base: Config{ GatewayURL: "https://gw.example.com", - AnthropicPathPrefix: "/anthropic", + AnthropicPathPrefix: stringPtr("/anthropic"), }, opts: SetOptions{}, want: Config{ GatewayURL: "https://gw.example.com", - AnthropicPathPrefix: "/anthropic", + AnthropicPathPrefix: stringPtr("/anthropic"), }, }, } @@ -192,14 +204,30 @@ func TestConfig_SetFields(t *testing.T) { if cfg.TLSSkipVerify != tt.want.TLSSkipVerify { t.Errorf("TLSSkipVerify = %v, want %v", cfg.TLSSkipVerify, tt.want.TLSSkipVerify) } - if cfg.AnthropicPathPrefix != tt.want.AnthropicPathPrefix { - t.Errorf("AnthropicPathPrefix = %q, want %q", cfg.AnthropicPathPrefix, tt.want.AnthropicPathPrefix) + if !stringPtrEqual(cfg.AnthropicPathPrefix, tt.want.AnthropicPathPrefix) { + t.Errorf("AnthropicPathPrefix = %s, want %s", + stringPtrFmt(cfg.AnthropicPathPrefix), stringPtrFmt(tt.want.AnthropicPathPrefix)) } }) } } -func boolPtr(b bool) *bool { return &b } +func boolPtr(b bool) *bool { return &b } +func stringPtr(s string) *string { return &s } + +func stringPtrEqual(a, b *string) bool { + if a == nil || b == nil { + return a == b + } + return *a == *b +} + +func stringPtrFmt(p *string) string { + if p == nil { + return "" + } + return fmt.Sprintf("%q", *p) +} // ── DeleteCachedTokens ─────────────────────────────────────────────────────── diff --git a/pkg/llm/setup.go b/pkg/llm/setup.go index 28e188f03a..9013b95d32 100644 --- a/pkg/llm/setup.go +++ b/pkg/llm/setup.go @@ -114,7 +114,7 @@ func Setup( configured, err := configureDetectedTools( out, errOut, gm, detected, - llmCfg.GatewayURL, llmCfg.AnthropicPathPrefix, proxyBaseURL, tokenHelperCommand, + llmCfg.GatewayURL, llmCfg.EffectiveAnthropicPathPrefix(), proxyBaseURL, tokenHelperCommand, llmCfg.TLSSkipVerify, ) if err != nil { From 2a0d83a4495d136d3e7d815157e39ef642ef658f Mon Sep 17 00:00:00 2001 From: Jeremy Drouillard Date: Fri, 1 May 2026 11:54:46 -0700 Subject: [PATCH 3/3] Address code review feedback on anthropic path prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Copy *string value in SetFields rather than aliasing the pointer, per the copy-before-mutating-caller-input rule - Note in the AnthropicPathPrefix field comment that omitempty on a *string applies only to nil — a non-nil pointer to "" is persisted, which is required for the "explicit no prefix" opt-out to survive restarts - Refactor Show to compute the display string via an IIFE before passing it to writef, matching the immutable-assignment pattern - Expand the ValueField enum comment in LLMGatewayKeySpec to include "AnthropicBaseURL"; move it to a pre-field block comment to avoid the continuation line dangling between two struct fields - Fix llmValueForSpec comment style to match the adjacent "For ..." form Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/client/config.go | 9 ++++++--- pkg/client/llm_gateway.go | 6 +++--- pkg/llm/config.go | 3 +++ pkg/llm/manage.go | 22 +++++++++++++--------- 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/pkg/client/config.go b/pkg/client/config.go index a1ad31bab7..1ea112ce9e 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -163,9 +163,12 @@ const ( // from the settings file rather than skipped. Use for conditional keys like // NODE_TLS_REJECT_UNAUTHORIZED that must be cleaned up when the flag is cleared. type LLMGatewayKeySpec struct { - JSONPointer string // RFC 6901 path - ValueField string // "GatewayURL" | "ProxyBaseURL" | "TokenHelperCommand" | "PlaceholderAPIKey" | "NodeTLSRejectUnauthorized" - ClearWhenEmpty bool // remove the key when the resolved value is empty + JSONPointer string // RFC 6901 path + // ValueField names which ApplyConfig field to write: + // "AnthropicBaseURL" | "GatewayURL" | "ProxyBaseURL" | + // "TokenHelperCommand" | "PlaceholderAPIKey" | "NodeTLSRejectUnauthorized" + ValueField string + ClearWhenEmpty bool // remove the key when the resolved value is empty } // clientAppConfig represents a configuration path for a supported MCP client. diff --git a/pkg/client/llm_gateway.go b/pkg/client/llm_gateway.go index 631f3d064e..9d666e0795 100644 --- a/pkg/client/llm_gateway.go +++ b/pkg/client/llm_gateway.go @@ -261,9 +261,9 @@ func (cm *ClientManager) buildLLMSettingsPath(cfg *clientAppConfig) string { // llmValueForSpec returns the config value corresponding to the ValueField name. // For "NodeTLSRejectUnauthorized", returns "0" when TLSSkipVerify is true, or "" // when false (which triggers removal when ClearWhenEmpty is set on the spec). -// "AnthropicBaseURL" returns GatewayURL with AnthropicPathPrefix appended, so -// gateways that route Anthropic-shaped traffic under a sub-path (e.g. Envoy AI -// Gateway at /anthropic) work without a manual edit. +// For "AnthropicBaseURL", returns GatewayURL with AnthropicPathPrefix appended, +// so gateways that route Anthropic-shaped traffic under a sub-path (e.g. Envoy +// AI Gateway at /anthropic) work without a manual edit. func llmValueForSpec(valueField string, cfg llmgateway.ApplyConfig) string { switch valueField { case "GatewayURL": diff --git a/pkg/llm/config.go b/pkg/llm/config.go index fdef1df7f9..264d38b6f4 100644 --- a/pkg/llm/config.go +++ b/pkg/llm/config.go @@ -46,6 +46,9 @@ type Config struct { // explicit empty string means "no prefix" — required for LiteLLM or direct // Anthropic. Use EffectiveAnthropicPathPrefix to read the resolved value. // Must start with "/" when non-nil and non-empty. + // Note: omitempty applies only when the pointer is nil — a non-nil pointer + // to "" is persisted as an explicit empty string, preserving the "no + // prefix" intent across restarts. AnthropicPathPrefix *string `yaml:"anthropic_path_prefix,omitempty" json:"anthropic_path_prefix,omitempty"` OIDC OIDCConfig `yaml:"oidc,omitempty" json:"oidc,omitempty"` Proxy ProxyConfig `yaml:"proxy,omitempty" json:"proxy,omitempty"` diff --git a/pkg/llm/manage.go b/pkg/llm/manage.go index f4655c8602..43313cdeb7 100644 --- a/pkg/llm/manage.go +++ b/pkg/llm/manage.go @@ -39,7 +39,8 @@ func (c *Config) SetFields(opts SetOptions) error { c.TLSSkipVerify = *opts.TLSSkipVerify } if opts.AnthropicPathPrefix != nil { - c.AnthropicPathPrefix = opts.AnthropicPathPrefix + v := *opts.AnthropicPathPrefix + c.AnthropicPathPrefix = &v } if !c.IsConfigured() { @@ -105,14 +106,17 @@ func (c *Config) Show(w io.Writer) error { } writef("Gateway URL: %s\n", c.GatewayURL) - switch { - case c.AnthropicPathPrefix == nil: - writef("Anthropic path: %s (default)\n", DefaultAnthropicPathPrefix) - case *c.AnthropicPathPrefix == "": - writef("Anthropic path: (none — direct Anthropic / LiteLLM)\n") - default: - writef("Anthropic path: %s\n", *c.AnthropicPathPrefix) - } + anthropicPathDisplay := func() string { + switch { + case c.AnthropicPathPrefix == nil: + return fmt.Sprintf("%s (default)", DefaultAnthropicPathPrefix) + case *c.AnthropicPathPrefix == "": + return "(none — direct Anthropic / LiteLLM)" + default: + return *c.AnthropicPathPrefix + } + }() + writef("Anthropic path: %s\n", anthropicPathDisplay) writef("OIDC Issuer: %s\n", c.OIDC.Issuer) writef("OIDC Client: %s\n", c.OIDC.ClientID) if c.OIDC.Audience != "" {