diff --git a/cmd/thv/app/llm.go b/cmd/thv/app/llm.go index 1cb806c4b4..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,6 +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(&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 } @@ -214,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{ @@ -244,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) @@ -266,6 +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(&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/client/config.go b/pkg/client/config.go index 88e7ea8e19..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. @@ -461,7 +464,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..9d666e0795 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). +// 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": 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..264d38b6f4 100644 --- a/pkg/llm/config.go +++ b/pkg/llm/config.go @@ -6,11 +6,24 @@ 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 + +// 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 @@ -25,11 +38,21 @@ 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. 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. + // 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"` + ConfiguredTools []ToolConfig `yaml:"configured_tools,omitempty" json:"configured_tools,omitempty"` } // ProxyConfig holds configuration for the localhost reverse proxy. @@ -77,6 +100,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 +143,44 @@ func (c *Config) EffectiveProxyPort() int { } return DefaultProxyListenPort } + +// 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 + } + v := *p + if len(v) > maxAnthropicPathPrefixLen { + return fmt.Errorf("must be %d characters or fewer, got %d", maxAnthropicPathPrefixLen, len(v)) + } + if !strings.HasPrefix(v, "/") { + return fmt.Errorf("must start with %q, got %q", "/", v) + } + 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(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 9dd6b03949..43313cdeb7 100644 --- a/pkg/llm/manage.go +++ b/pkg/llm/manage.go @@ -38,6 +38,10 @@ func (c *Config) SetFields(opts SetOptions) error { if opts.TLSSkipVerify != nil { c.TLSSkipVerify = *opts.TLSSkipVerify } + if opts.AnthropicPathPrefix != nil { + v := *opts.AnthropicPathPrefix + c.AnthropicPathPrefix = &v + } if !c.IsConfigured() { return c.ValidatePartial() @@ -47,16 +51,19 @@ 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 - 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 // nil = not provided; &"" = explicit no prefix; &"/x" = explicit value } // DeleteCachedTokens removes all cached OIDC tokens stored under the LLM @@ -99,6 +106,17 @@ func (c *Config) Show(w io.Writer) error { } writef("Gateway URL: %s\n", c.GatewayURL) + 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 != "" { diff --git a/pkg/llm/manage_test.go b/pkg/llm/manage_test.go index 9235317116..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" @@ -119,6 +120,55 @@ 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: stringPtr("/anthropic"), + }, + want: Config{ + GatewayURL: "https://gw.example.com", + 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: stringPtr("anthropic")}, + wantErr: true, + }, + { + name: "AnthropicPathPrefix rejects values with query string", + 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: stringPtr("/anthropic;rm -rf /")}, + wantErr: true, + }, + { + name: "nil AnthropicPathPrefix leaves existing value unchanged", + base: Config{ + GatewayURL: "https://gw.example.com", + AnthropicPathPrefix: stringPtr("/anthropic"), + }, + opts: SetOptions{}, + want: Config{ + GatewayURL: "https://gw.example.com", + AnthropicPathPrefix: stringPtr("/anthropic"), + }, + }, } for _, tt := range tests { @@ -154,11 +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 !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 e9e19c5d4f..9013b95d32 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.EffectiveAnthropicPathPrefix(), 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 }