Skip to content

Commit 9057f7a

Browse files
yroblataskbot
andauthored
Correct Gemini CLI LLM gateway config to proxy mode (#5142)
* Correct Gemini CLI LLM gateway config to proxy mode Gemini CLI has no dynamic token-command equivalent (no apiKeyHelper analogue), so it must use proxy mode like Cursor rather than direct mode. The previous config used two invalid settings.json keys: - /auth/tokenCommand — not a recognised Gemini CLI field - /baseUrl — silently ignored as a top-level key Replace with the correct proxy-mode keys: - /security/auth/selectedType = "gemini-api-key" (required so that GOOGLE_GEMINI_BASE_URL is honoured; OAuth auth ignores the override, fixed in gemini-cli v0.40.0 PR #25357) - /env/GEMINI_API_KEY = placeholder key (proxy does not validate it) - /env/GOOGLE_GEMINI_BASE_URL = local proxy address Fixes #5141 * fixes from review --------- Co-authored-by: taskbot <taskbot@users.noreply.github.com>
1 parent 8c90184 commit 9057f7a

9 files changed

Lines changed: 341 additions & 69 deletions

File tree

cmd/thv/app/llm.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,10 @@ func (a *clientManagerAdapter) LLMGatewayModeFor(clientType string) string {
375375
return a.cm.LLMGatewayModeFor(client.ClientApp(clientType))
376376
}
377377

378+
func (a *clientManagerAdapter) LLMSetupNoteFor(clientType string) string {
379+
return a.cm.LLMSetupNoteFor(client.ClientApp(clientType))
380+
}
381+
378382
func (a *clientManagerAdapter) RevertLLMGateway(clientType, configPath string) error {
379383
return a.cm.RevertLLMGateway(client.ClientApp(clientType), configPath)
380384
}

pkg/client/config.go

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -159,16 +159,29 @@ const (
159159
// path (e.g. "/apiKeyHelper" or "/env/ANTHROPIC_BASE_URL"). Dots in flat
160160
// top-level key names (e.g. "/cursor.general.openAIBaseURL") are treated as
161161
// literals by hujson.Patch.
162-
// ValueField names which LLMApplyConfig field to write: "GatewayURL",
163-
// "ProxyBaseURL", "TokenHelperCommand", "PlaceholderAPIKey" (constant "thv-proxy"),
164-
// or "NodeTLSRejectUnauthorized" (writes "0" when TLSSkipVerify is true).
165-
// ClearWhenEmpty: when true and the resolved value is empty, the key is removed
166-
// from the settings file rather than skipped. Use for conditional keys like
167-
// NODE_TLS_REJECT_UNAUTHORIZED that must be cleaned up when the flag is cleared.
162+
//
163+
// Exactly one of ValueField or Literal must be set:
164+
// - ValueField names which ApplyConfig field to write. Valid values:
165+
// "GatewayURL", "ProxyBaseURL", "ProxyOrigin", "TokenHelperCommand",
166+
// "PlaceholderAPIKey", "NodeTLSRejectUnauthorized".
167+
// An unrecognised ValueField is a programming error and causes
168+
// ConfigureLLMGateway to return an error.
169+
// - Literal is written verbatim into the settings key (e.g. a fixed auth
170+
// type string). Use Literal instead of ValueField for constant values so
171+
// that typos in ValueField are caught as errors rather than silently
172+
// written as unexpected strings.
173+
//
174+
// ClearWhenEmpty: when true and the resolved value is empty, the key is
175+
// removed from the settings file rather than skipped. Use for conditional
176+
// keys like NODE_TLS_REJECT_UNAUTHORIZED that must be cleaned up when the
177+
// flag is cleared. Ignored when Literal is set (literals are never empty).
168178
type LLMGatewayKeySpec struct {
169-
JSONPointer string // RFC 6901 path
170-
ValueField string // "GatewayURL" | "ProxyBaseURL" | "TokenHelperCommand" | "PlaceholderAPIKey" | "NodeTLSRejectUnauthorized"
171-
ClearWhenEmpty bool // remove the key when the resolved value is empty
179+
JSONPointer string // RFC 6901 path
180+
// ValueField: "GatewayURL" | "ProxyBaseURL" | "ProxyOrigin" |
181+
// "TokenHelperCommand" | "PlaceholderAPIKey" | "NodeTLSRejectUnauthorized"
182+
ValueField string
183+
Literal string // constant value written verbatim; mutually exclusive with ValueField
184+
ClearWhenEmpty bool // remove the key when the resolved value is empty (ignored for Literal)
172185
}
173186

174187
// clientAppConfig represents a configuration path for a supported MCP client.
@@ -223,6 +236,10 @@ type clientAppConfig struct {
223236
// LLMGatewayKeys lists the JSON Pointer paths and value-field mappings to
224237
// apply when setting up (or reverting) LLM gateway access.
225238
LLMGatewayKeys []LLMGatewayKeySpec
239+
// LLMSetupNote is an optional message printed to stdout after a successful
240+
// "thv llm setup" for this client. Use it to surface manual steps that
241+
// toolhive cannot automate (e.g. env vars the tool reads from a .env file).
242+
LLMSetupNote string
226243
}
227244

228245
// extractServersKeyFromConfig extracts the servers key from MCPServersPathPrefix
@@ -839,17 +856,29 @@ var supportedClientIntegrations = []clientAppConfig{
839856
SupportsSkills: true,
840857
SkillsGlobalPath: []string{".agents", skillsDirName},
841858
SkillsProjectPath: []string{".agents", skillsDirName},
842-
// LLM gateway: patches the same settings.json used for MCP
843-
LLMGatewayMode: "direct",
859+
// LLM gateway: patches the same settings.json used for MCP.
860+
// Gemini CLI has no dynamic token-command equivalent, so it uses the
861+
// proxy path. GOOGLE_GEMINI_BASE_URL is only honoured when
862+
// security.auth.selectedType is "gemini-api-key" (fixed in
863+
// gemini-cli v0.40.0, PR #25357); OAuth auth ignores the override.
864+
//
865+
// NODE_TLS_REJECT_UNAUTHORIZED is intentionally omitted: in proxy mode
866+
// the tool connects to the local proxy over plain HTTP, so there is no
867+
// TLS handshake on that leg. Setting the env var would globally disable
868+
// TLS verification for all other HTTPS requests the Gemini CLI process
869+
// makes, which is an unacceptable side-effect.
870+
LLMGatewayMode: "proxy",
844871
LLMBinaryName: "gemini",
845872
LLMSettingsFile: "settings.json",
846873
LLMSettingsRelPath: []string{".gemini"},
847874
LLMGatewayKeys: []LLMGatewayKeySpec{
848-
{JSONPointer: "/auth/tokenCommand", ValueField: "TokenHelperCommand"},
849-
{JSONPointer: "/baseUrl", ValueField: "GatewayURL"},
850-
// NODE_TLS_REJECT_UNAUTHORIZED is only written when --tls-skip-verify is set.
851-
// ClearWhenEmpty ensures it is removed when the flag is later cleared.
852-
{JSONPointer: "/env/NODE_TLS_REJECT_UNAUTHORIZED", ValueField: "NodeTLSRejectUnauthorized", ClearWhenEmpty: true},
875+
// Force API-key auth so GOOGLE_GEMINI_BASE_URL is respected.
876+
{JSONPointer: "/security/auth/selectedType", Literal: "gemini-api-key"},
877+
// Placeholder API key (proxy does not validate it).
878+
{JSONPointer: "/env/GEMINI_API_KEY", Literal: llmPlaceholderAPIKey},
879+
// Proxy origin for Gemini CLI; path is stripped because Gemini
880+
// CLI appends its own /v1beta/... path to the base URL.
881+
{JSONPointer: "/env/GOOGLE_GEMINI_BASE_URL", ValueField: "ProxyOrigin"},
853882
},
854883
},
855884
{

pkg/client/llm_gateway.go

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,20 @@ func (cm *ClientManager) ConfigureLLMGateway(clientType ClientApp, cfg llmgatewa
8080
// allowing conditional keys (e.g. NODE_TLS_REJECT_UNAUTHORIZED) to be cleaned
8181
// up when the associated flag is cleared.
8282
func applyLLMGatewayKeys(v *hujson.Value, specs []LLMGatewayKeySpec, cfg llmgateway.ApplyConfig, filePath string) error {
83+
// Resolve all values up front so an unknown ValueField fails fast before
84+
// any file is modified.
85+
values := make([]string, len(specs))
86+
for i, spec := range specs {
87+
val, err := llmValueForSpec(spec, cfg)
88+
if err != nil {
89+
return err
90+
}
91+
values[i] = val
92+
}
93+
8394
// Ensure ancestors only for specs that will be written (not removed).
84-
for _, spec := range specs {
85-
if spec.ClearWhenEmpty && llmValueForSpec(spec.ValueField, cfg) == "" {
95+
for i, spec := range specs {
96+
if spec.ClearWhenEmpty && values[i] == "" {
8697
continue
8798
}
8899
if err := ensureLLMAncestors(v, spec.JSONPointer, filePath); err != nil {
@@ -96,8 +107,8 @@ func applyLLMGatewayKeys(v *hujson.Value, specs []LLMGatewayKeySpec, cfg llmgate
96107
return fmt.Errorf("standardizing %s: %w", filePath, err)
97108
}
98109

99-
for _, spec := range specs {
100-
value := llmValueForSpec(spec.ValueField, cfg)
110+
for i, spec := range specs {
111+
value := values[i]
101112
if spec.ClearWhenEmpty && value == "" {
102113
if err := removeLLMKey(v, spec.JSONPointer, filePath, standardized); err != nil {
103114
return err
@@ -219,6 +230,15 @@ func (cm *ClientManager) LLMGatewayModeFor(clientType ClientApp) string {
219230
return cfg.LLMGatewayMode
220231
}
221232

233+
// LLMSetupNoteFor returns the post-setup note for clientType, or "" if none.
234+
func (cm *ClientManager) LLMSetupNoteFor(clientType ClientApp) string {
235+
cfg := cm.lookupClientAppConfig(clientType)
236+
if cfg == nil {
237+
return ""
238+
}
239+
return cfg.LLMSetupNote
240+
}
241+
222242
// DetectedLLMGatewayClients returns the subset of LLM-gateway-capable clients
223243
// that are installed on this machine. A client is considered installed when:
224244
// 1. Its settings directory exists on disk.
@@ -258,26 +278,35 @@ func (cm *ClientManager) buildLLMSettingsPath(cfg *clientAppConfig) string {
258278
)
259279
}
260280

261-
// llmValueForSpec returns the config value corresponding to the ValueField name.
262-
// For "NodeTLSRejectUnauthorized", returns "0" when TLSSkipVerify is true, or ""
263-
// when false (which triggers removal when ClearWhenEmpty is set on the spec).
264-
func llmValueForSpec(valueField string, cfg llmgateway.ApplyConfig) string {
265-
switch valueField {
281+
// llmValueForSpec returns the value to write for spec given cfg.
282+
// If spec.Literal is set it is returned directly.
283+
// Otherwise spec.ValueField is resolved against cfg; an unrecognised ValueField
284+
// returns an error so typos are caught at call time rather than silently written
285+
// as unexpected strings into the user's settings file.
286+
func llmValueForSpec(spec LLMGatewayKeySpec, cfg llmgateway.ApplyConfig) (string, error) {
287+
if spec.Literal != "" {
288+
return spec.Literal, nil
289+
}
290+
switch spec.ValueField {
266291
case "GatewayURL":
267-
return cfg.GatewayURL
292+
return cfg.GatewayURL, nil
268293
case "ProxyBaseURL":
269-
return cfg.ProxyBaseURL
294+
return cfg.ProxyBaseURL, nil
270295
case "TokenHelperCommand":
271-
return cfg.TokenHelperCommand
296+
return cfg.TokenHelperCommand, nil
272297
case "PlaceholderAPIKey":
273-
return llmPlaceholderAPIKey
298+
return llmPlaceholderAPIKey, nil
274299
case "NodeTLSRejectUnauthorized":
275300
if cfg.TLSSkipVerify {
276-
return "0"
301+
return "0", nil
277302
}
278-
return ""
303+
return "", nil
304+
case "ProxyOrigin":
305+
// Like ProxyBaseURL but with the path stripped; see llmgateway.ProxyOriginOf.
306+
return llmgateway.ProxyOriginOf(cfg.ProxyBaseURL), nil
279307
default:
280-
return valueField // treat unknown field names as literal values
308+
return "", fmt.Errorf("unknown ValueField %q in LLMGatewayKeySpec for %q; use Literal for constant values",
309+
spec.ValueField, spec.JSONPointer)
281310
}
282311
}
283312

0 commit comments

Comments
 (0)