Skip to content
Closed
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
26 changes: 21 additions & 5 deletions cmd/thv/app/llm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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)
})
Expand All @@ -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
}
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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)
Expand All @@ -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.")

Expand Down
14 changes: 10 additions & 4 deletions pkg/client/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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},
Expand Down
5 changes: 5 additions & 0 deletions pkg/client/llm_gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
55 changes: 55 additions & 0 deletions pkg/client/llm_gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
78 changes: 73 additions & 5 deletions pkg/llm/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
35 changes: 35 additions & 0 deletions pkg/llm/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
36 changes: 27 additions & 9 deletions pkg/llm/manage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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 != "" {
Expand Down
Loading
Loading