diff --git a/cmd/thv/app/llm.go b/cmd/thv/app/llm.go index 92556b0c79..83bb7e74f9 100644 --- a/cmd/thv/app/llm.go +++ b/cmd/thv/app/llm.go @@ -214,9 +214,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 + targetClient string + anthropicPathPrefix string ) cmd := &cobra.Command{ @@ -250,7 +251,8 @@ Run "thv llm teardown" to revert all changes.`, } return runLLMSetup( cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), - cm, config.NewDefaultProvider(), oidcLogin, opts, targetClient, + cm, config.NewDefaultProvider(), oidcLogin, opts, + anthropicPathPrefix, cmd.Flags().Changed("anthropic-path-prefix"), targetClient, ) }, } @@ -266,6 +268,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(&anthropicPathPrefix, "anthropic-path-prefix", "", + "Path prefix appended to the gateway URL when writing ANTHROPIC_BASE_URL for direct-mode tools "+ + "(e.g. /anthropic). When omitted, the gateway is probed automatically.") cmd.Flags().StringVar(&targetClient, "client", "", "Configure only this AI tool by name (e.g. claude-code, cursor). Omit to configure all detected tools.") @@ -286,9 +291,13 @@ func oidcLogin(ctx context.Context, cfg *llm.Config) error { func runLLMSetup( ctx context.Context, out, errOut io.Writer, cm *client.ClientManager, provider config.Provider, login llm.LoginFunc, - inlineOpts llm.SetOptions, targetClient string, + inlineOpts llm.SetOptions, anthropicPathPrefix string, anthropicPathPrefixSet bool, targetClient string, ) error { - return llm.Setup(ctx, out, errOut, &clientManagerAdapter{cm}, &configUpdaterAdapter{provider}, login, inlineOpts, targetClient) + return llm.Setup( + ctx, out, errOut, + &clientManagerAdapter{cm}, &configUpdaterAdapter{provider}, login, + inlineOpts, anthropicPathPrefix, anthropicPathPrefixSet, targetClient, + ) } func newLLMTeardownCommand() *cobra.Command { diff --git a/cmd/thv/app/llm_test.go b/cmd/thv/app/llm_test.go index 2558af28fc..46f82d180b 100644 --- a/cmd/thv/app/llm_test.go +++ b/cmd/thv/app/llm_test.go @@ -71,7 +71,7 @@ func TestRunLLMSetup_NotConfigured(t *testing.T) { provider := llmProvider(t, llm.Config{}) // no gateway URL var stdout, stderr bytes.Buffer - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "") + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "") require.Error(t, err) assert.Contains(t, err.Error(), "not configured") } @@ -98,7 +98,7 @@ func TestRunLLMSetup_NoDetectedTools(t *testing.T) { }) var stdout, stderr bytes.Buffer - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "") + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "") require.NoError(t, err) assert.Contains(t, stdout.String(), "No supported AI tools detected") } @@ -142,7 +142,7 @@ func TestRunLLMSetup_PartialFailure(t *testing.T) { }) var stdout, stderr bytes.Buffer - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "") + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "") require.NoError(t, err) assert.Contains(t, stderr.String(), "Warning: failed to configure claude-code") assert.Contains(t, stdout.String(), "Configured gemini-cli") @@ -176,7 +176,7 @@ func TestRunLLMSetup_RollbackOnConfigUpdateFailure(t *testing.T) { provider := &errOnUpdateProvider{cfg: c, updateErr: errors.New("disk full")} var stdout, stderr bytes.Buffer - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "") + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "") require.Error(t, err) assert.Contains(t, err.Error(), "persisting tool configuration") @@ -226,7 +226,7 @@ func TestRunLLMSetup_RollbackBothToolsOnConfigUpdateFailure(t *testing.T) { provider := &errOnUpdateProvider{cfg: c, updateErr: errors.New("disk full")} var stdout, stderr bytes.Buffer - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "") + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "") require.Error(t, err) assert.Contains(t, err.Error(), "persisting tool configuration") @@ -273,7 +273,7 @@ func TestRunLLMSetup_LoginFailureLeavesNoState(t *testing.T) { var stdout, stderr bytes.Buffer err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, func(_ context.Context, _ *llm.Config) error { return loginErr }, - llm.SetOptions{}, "", + llm.SetOptions{}, "", false, "", ) require.Error(t, err) assert.Contains(t, err.Error(), "OIDC login failed") @@ -474,7 +474,7 @@ func TestRunLLMSetup_ClientFlag_ConfiguresSingleTool(t *testing.T) { }) var stdout, stderr bytes.Buffer - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "claude-code") + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "claude-code") require.NoError(t, err) assert.Contains(t, stdout.String(), "Configured claude-code") assert.NotContains(t, stdout.String(), "gemini-cli") @@ -509,7 +509,7 @@ func TestRunLLMSetup_ClientFlag_NotInstalled(t *testing.T) { var stdout, stderr bytes.Buffer // cursor is not installed (no dir); expect an error. - err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "cursor") + err := runLLMSetup(context.Background(), &stdout, &stderr, cm, provider, noopLogin, llm.SetOptions{}, "", false, "cursor") require.Error(t, err) assert.Contains(t, err.Error(), `"cursor" is not installed or not detected`) } diff --git a/pkg/client/config.go b/pkg/client/config.go index 12d2ef9554..c5fb0cf928 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -162,8 +162,8 @@ const ( // // Exactly one of ValueField or Literal must be set: // - ValueField names which ApplyConfig field to write. Valid values: -// "GatewayURL", "ProxyBaseURL", "ProxyOrigin", "TokenHelperCommand", -// "PlaceholderAPIKey", "NodeTLSRejectUnauthorized". +// "GatewayURL", "AnthropicBaseURL", "ProxyBaseURL", "ProxyOrigin", +// "TokenHelperCommand", "PlaceholderAPIKey", "NodeTLSRejectUnauthorized". // An unrecognised ValueField is a programming error and causes // ConfigureLLMGateway to return an error. // - Literal is written verbatim into the settings key (e.g. a fixed auth @@ -177,7 +177,7 @@ const ( // flag is cleared. Ignored when Literal is set (literals are never empty). type LLMGatewayKeySpec struct { JSONPointer string // RFC 6901 path - // ValueField: "GatewayURL" | "ProxyBaseURL" | "ProxyOrigin" | + // ValueField: "GatewayURL" | "AnthropicBaseURL" | "ProxyBaseURL" | "ProxyOrigin" | // "TokenHelperCommand" | "PlaceholderAPIKey" | "NodeTLSRejectUnauthorized" ValueField string Literal string // constant value written verbatim; mutually exclusive with ValueField @@ -481,7 +481,7 @@ var supportedClientIntegrations = []clientAppConfig{ LLMSettingsRelPath: []string{".claude"}, LLMGatewayKeys: []LLMGatewayKeySpec{ {JSONPointer: "/apiKeyHelper", ValueField: "TokenHelperCommand"}, - {JSONPointer: "/env/ANTHROPIC_BASE_URL", ValueField: "GatewayURL"}, + {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 6ecba61037..969c49e010 100644 --- a/pkg/client/llm_gateway.go +++ b/pkg/client/llm_gateway.go @@ -290,6 +290,13 @@ func llmValueForSpec(spec LLMGatewayKeySpec, cfg llmgateway.ApplyConfig) (string switch spec.ValueField { case "GatewayURL": return cfg.GatewayURL, nil + case "AnthropicBaseURL": + // Use the pre-computed Anthropic base URL when available; fall back to + // GatewayURL so existing configs continue to work without the prefix. + if cfg.AnthropicBaseURL != "" { + return cfg.AnthropicBaseURL, nil + } + return cfg.GatewayURL, nil case "ProxyBaseURL": return cfg.ProxyBaseURL, nil case "TokenHelperCommand": diff --git a/pkg/llm/setup.go b/pkg/llm/setup.go index c58b3d2297..3085aec0c1 100644 --- a/pkg/llm/setup.go +++ b/pkg/llm/setup.go @@ -5,8 +5,11 @@ package llm import ( "context" + "crypto/tls" "fmt" "io" + "net/http" + "net/url" "os" "strings" "time" @@ -56,7 +59,7 @@ type ConfigUpdater interface { func Setup( ctx context.Context, out, errOut io.Writer, gm GatewayManager, provider ConfigUpdater, login LoginFunc, - inlineOpts SetOptions, targetClient string, + inlineOpts SetOptions, anthropicPathPrefix string, anthropicPathPrefixSet bool, targetClient string, ) error { llmCfg := provider.GetLLMConfig() @@ -71,31 +74,12 @@ func Setup( return fmt.Errorf("LLM gateway is not configured — run \"thv llm config set\" first") } - self, err := os.Executable() + tokenHelperCommand, err := buildTokenHelperCommand() if err != nil { - return fmt.Errorf("resolving thv executable path: %w", err) - } - // Reject paths that contain shell metacharacters: the token-helper command - // is written verbatim into long-lived tool config files and re-executed by - // the shell inside Claude Code / Gemini CLI. A path with '"', '\', ';', - // '$', '`', newline, or carriage-return would silently produce a broken or - // exploitable command. '$' and '`' are included because they trigger - // variable and command substitution even inside double-quoted strings. - // - // Note: backslashes are Windows path separators, so this check effectively - // makes "thv llm setup" unsupported on Windows — consistent with the rest - // of the LLM gateway feature (token-helper tools use POSIX-style shells). - const shellUnsafe = `"\;$` + "`\n\r" - if strings.ContainsAny(self, shellUnsafe) { - return fmt.Errorf( - "executable path %q contains shell-unsafe characters; "+ - "move thv to a path without quotes, backslashes, semicolons, "+ - "dollar signs, or backticks "+ - "(Windows paths are not supported by thv llm setup)", self) + return err } proxyBaseURL := fmt.Sprintf("http://localhost:%d/v1", llmCfg.EffectiveProxyPort()) - tokenHelperCommand := fmt.Sprintf(`"%s" llm token`, self) // Detect tools before login so we skip the interactive browser flow when // there is nothing to configure. Login still runs before any files are @@ -115,8 +99,18 @@ func Setup( } _, _ = fmt.Fprintln(out, "Login successful.") + // Resolve the effective path prefix for ANTHROPIC_BASE_URL. + // If the caller supplied --anthropic-path-prefix, use it directly. + // Otherwise auto-probe: a HEAD request to /anthropic/v1/messages + // that returns 401 (rather than 404) indicates the gateway uses the + // /anthropic prefix, so we apply it automatically. + // Only probe if at least one detected tool uses direct mode; proxy-mode + // tools ignore the Anthropic prefix entirely. + anthropicPrefix := resolveAnthropicPrefix(ctx, gm, detected, llmCfg, anthropicPathPrefix, anthropicPathPrefixSet) + configured, err := configureDetectedTools( - out, errOut, gm, detected, llmCfg.GatewayURL, proxyBaseURL, tokenHelperCommand, llmCfg.TLSSkipVerify, + out, errOut, gm, detected, llmCfg.GatewayURL, proxyBaseURL, tokenHelperCommand, + llmCfg.TLSSkipVerify, anthropicPrefix, ) if err != nil { return err @@ -326,11 +320,26 @@ func configureDetectedTools( detected []string, gatewayURL, proxyBaseURL, tokenHelperCommand string, tlsSkipVerify bool, + anthropicPathPrefix string, ) ([]ToolConfig, error) { var configured []ToolConfig for _, clientType := range detected { + mode := gm.LLMGatewayModeFor(clientType) + + // Only apply the Anthropic path prefix for direct-mode tools. + // Proxy-mode tools (Cursor, VS Code, Xcode) do not use ANTHROPIC_BASE_URL. + anthropicBaseURL := "" + if mode == "direct" && anthropicPathPrefix != "" { + // Trim any leading slash: url.JoinPath docs say elements should not + // start with "/", and path.Join already handles the join correctly. + if joined, err := url.JoinPath(gatewayURL, strings.TrimLeft(anthropicPathPrefix, "/")); err == nil { + anthropicBaseURL = joined + } + } + configPath, err := gm.ConfigureLLMGateway(clientType, llmgateway.ApplyConfig{ GatewayURL: gatewayURL, + AnthropicBaseURL: anthropicBaseURL, ProxyBaseURL: proxyBaseURL, TokenHelperCommand: tokenHelperCommand, TLSSkipVerify: tlsSkipVerify, @@ -339,7 +348,6 @@ func configureDetectedTools( _, _ = fmt.Fprintf(errOut, "Warning: failed to configure %s: %v\n", clientType, err) continue } - mode := gm.LLMGatewayModeFor(clientType) configured = append(configured, ToolConfig{ Tool: clientType, Mode: mode, @@ -356,6 +364,109 @@ func configureDetectedTools( return configured, nil } +// resolveAnthropicPrefix returns the effective Anthropic path prefix. When the +// caller explicitly set the flag (anthropicPathPrefixSet), the provided value is +// returned as-is (including empty string, which disables the prefix). Otherwise +// the gateway is auto-probed when at least one direct-mode client is present. +func resolveAnthropicPrefix( + ctx context.Context, gm GatewayManager, detected []string, + llmCfg Config, anthropicPathPrefix string, anthropicPathPrefixSet bool, +) string { + if anthropicPathPrefixSet || !hasDirectModeClient(gm, detected) { + return anthropicPathPrefix + } + return probeAnthropicPrefix(ctx, llmCfg.GatewayURL, llmCfg.TLSSkipVerify) +} + +// probeAnthropicPrefix performs a HEAD request to /anthropic/v1/messages. +// If the server responds with HTTP 401 (Unauthorized) — meaning the path exists but +// requires authentication — it returns "/anthropic" as the path prefix so that +// ANTHROPIC_BASE_URL is constructed as /anthropic rather than . +// Any other status code (including 404 Not Found) or any network error is treated +// as "no prefix needed" and the function returns "". +func probeAnthropicPrefix(ctx context.Context, gatewayURL string, tlsSkipVerify bool) string { + if gatewayURL == "" { + return "" + } + probeURL, err := url.JoinPath(gatewayURL, "anthropic/v1/messages") + if err != nil { + return "" + } + + // Build an http.Client that honours --tls-skip-verify so the probe works + // against gateways with self-signed certificates (local dev). Clone + // http.DefaultTransport to preserve all production defaults (timeouts, + // ProxyFromEnvironment, HTTP/2, connection pooling) and only toggle + // InsecureSkipVerify. + //nolint:forcetypeassert // DefaultTransport is always *http.Transport + transport := http.DefaultTransport.(*http.Transport).Clone() + if tlsSkipVerify { + if transport.TLSClientConfig == nil { + transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12} + } + transport.TLSClientConfig.InsecureSkipVerify = true //nolint:gosec // G402: intentional for local dev with self-signed certs + } + httpClient := &http.Client{Transport: transport} + + // Use a short timeout so setup is not significantly slowed by an unreachable gateway. + probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(probeCtx, http.MethodHead, probeURL, nil) + if err != nil { + return "" + } + resp, err := httpClient.Do(req) + if err != nil { + return "" + } + _ = resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + return "/anthropic" + } + return "" +} + +// buildTokenHelperCommand returns the shell command string used as the +// token-helper for direct-mode tools. It rejects executable paths that contain +// shell metacharacters, since the command is written verbatim into long-lived +// tool config files and re-executed by the shell inside Claude Code / Gemini CLI. +// A path with '"', '\', ';', '$', '`', newline, or carriage-return would +// silently produce a broken or exploitable command. '$' and '`' are included +// because they trigger variable/command substitution inside double-quoted strings. +// +// Note: backslashes are Windows path separators, so this effectively makes +// "thv llm setup" unsupported on Windows — consistent with the rest of the LLM +// gateway feature (token-helper tools use POSIX-style shells). +func buildTokenHelperCommand() (string, error) { + self, err := os.Executable() + if err != nil { + return "", fmt.Errorf("resolving thv executable path: %w", err) + } + const shellUnsafe = `"\;$` + "`\n\r" + if strings.ContainsAny(self, shellUnsafe) { + return "", fmt.Errorf( + "executable path %q contains shell-unsafe characters; "+ + "move thv to a path without quotes, backslashes, semicolons, "+ + "dollar signs, or backticks "+ + "(Windows paths are not supported by thv llm setup)", self) + } + return fmt.Sprintf(`"%s" llm token`, self), nil +} + +// hasDirectModeClient reports whether any client in the detected list uses +// direct mode. Used to skip the Anthropic-prefix probe when no direct-mode +// tools are present (proxy-mode tools ignore ANTHROPIC_BASE_URL entirely). +func hasDirectModeClient(gm GatewayManager, detected []string) bool { + for _, clientType := range detected { + if gm.LLMGatewayModeFor(clientType) == "direct" { + return true + } + } + return false +} + // hasProxyMode reports whether any of the given tool configs uses proxy mode. func hasProxyMode(cfgs []ToolConfig) bool { for _, t := range cfgs { diff --git a/pkg/llm/setup_test.go b/pkg/llm/setup_test.go index 2bd987a5cc..b6a061828b 100644 --- a/pkg/llm/setup_test.go +++ b/pkg/llm/setup_test.go @@ -6,6 +6,8 @@ package llm import ( "bytes" "context" + "net/http" + "net/http/httptest" "testing" "time" @@ -172,3 +174,119 @@ func TestTeardown_NoPurge_LeavesTokenRefsIntact(t *testing.T) { assert.Equal(t, "some-ref", provider.cfg.OIDC.CachedRefreshTokenRef) assert.Equal(t, expiry, provider.cfg.OIDC.CachedTokenExpiry) } + +// ── AnthropicPathPrefix / configureDetectedTools ────────────────────────────── + +// capturingGatewayManager records the ApplyConfig passed to ConfigureLLMGateway. +type capturingGatewayManager struct { + mode string // returned by LLMGatewayModeFor + applied []llmgateway.ApplyConfig +} + +func (*capturingGatewayManager) DetectedLLMGatewayClients() []string { return nil } +func (g *capturingGatewayManager) ConfigureLLMGateway(_ string, cfg llmgateway.ApplyConfig) (string, error) { + g.applied = append(g.applied, cfg) + return "/path/to/settings.json", nil +} +func (g *capturingGatewayManager) LLMGatewayModeFor(_ string) string { return g.mode } +func (*capturingGatewayManager) LLMSetupNoteFor(_ string) string { return "" } +func (*capturingGatewayManager) RevertLLMGateway(_, _ string) error { return nil } + +func TestConfigureDetectedTools_PathPrefixAppendedForDirectMode(t *testing.T) { + t.Parallel() + + gm := &capturingGatewayManager{mode: "direct"} + var out, errOut bytes.Buffer + + _, err := configureDetectedTools( + &out, &errOut, gm, + []string{"claude-code"}, + "https://gw.example.com", "http://localhost:14000/v1", `"thv" llm token`, + false, "/anthropic", + ) + require.NoError(t, err) + require.Len(t, gm.applied, 1) + + // The Anthropic base URL must be gateway + prefix, not just the gateway. + assert.Equal(t, "https://gw.example.com/anthropic", gm.applied[0].AnthropicBaseURL) + assert.Equal(t, "https://gw.example.com", gm.applied[0].GatewayURL) +} + +func TestConfigureDetectedTools_NoPrefixWhenEmpty(t *testing.T) { + t.Parallel() + + gm := &capturingGatewayManager{mode: "direct"} + var out, errOut bytes.Buffer + + _, err := configureDetectedTools( + &out, &errOut, gm, + []string{"claude-code"}, + "https://gw.example.com", "http://localhost:14000/v1", `"thv" llm token`, + false, "", // no prefix + ) + require.NoError(t, err) + require.Len(t, gm.applied, 1) + + // AnthropicBaseURL must be empty so llmValueForSpec falls back to GatewayURL. + assert.Empty(t, gm.applied[0].AnthropicBaseURL) +} + +func TestConfigureDetectedTools_PrefixNotAppliedForProxyMode(t *testing.T) { + t.Parallel() + + gm := &capturingGatewayManager{mode: "proxy"} + var out, errOut bytes.Buffer + + _, err := configureDetectedTools( + &out, &errOut, gm, + []string{"cursor"}, + "https://gw.example.com", "http://localhost:14000/v1", `"thv" llm token`, + false, "/anthropic", + ) + require.NoError(t, err) + require.Len(t, gm.applied, 1) + + // Proxy-mode tools must never receive an AnthropicBaseURL. + assert.Empty(t, gm.applied[0].AnthropicBaseURL) +} + +// ── probeAnthropicPrefix ────────────────────────────────────────────────────── + +func TestProbeAnthropicPrefix_Returns_Anthropic_On_401(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + t.Cleanup(srv.Close) + + prefix := probeAnthropicPrefix(context.Background(), srv.URL, false) + assert.Equal(t, "/anthropic", prefix) +} + +func TestProbeAnthropicPrefix_Returns_Empty_On_404(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(srv.Close) + + prefix := probeAnthropicPrefix(context.Background(), srv.URL, false) + assert.Empty(t, prefix) +} + +func TestProbeAnthropicPrefix_Returns_Empty_On_NetworkError(t *testing.T) { + t.Parallel() + + // Use a URL that will immediately refuse connections. + prefix := probeAnthropicPrefix(context.Background(), "http://127.0.0.1:1", false) + assert.Empty(t, prefix) +} + +func TestProbeAnthropicPrefix_Returns_Empty_For_EmptyGatewayURL(t *testing.T) { + t.Parallel() + + prefix := probeAnthropicPrefix(context.Background(), "", false) + assert.Empty(t, prefix) +} diff --git a/pkg/llmgateway/config.go b/pkg/llmgateway/config.go index e286384123..3ad2895de9 100644 --- a/pkg/llmgateway/config.go +++ b/pkg/llmgateway/config.go @@ -35,6 +35,7 @@ func ProxyOriginOf(rawURL string) string { // the caller has multiple similar string values in scope. type ApplyConfig struct { GatewayURL string // direct-mode: URL of the upstream LLM gateway + AnthropicBaseURL string // direct-mode: effective ANTHROPIC_BASE_URL (gateway + optional path prefix) 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