Skip to content

Commit 020220c

Browse files
taskbotclaude
andcommitted
Add thv llm command group with config types and management commands
Introduces the pkg/llm package with LLMConfig types (IsConfigured, Validate, EffectiveProxyPort, EffectiveScopes), adds ScopeLLM to the secrets scoped provider, wires the LLM field into the top-level Config struct, and adds the thv llm cobra command group with fully-implemented config set/show/reset subcommands plus stubbed setup, teardown, proxy start, and token commands. Part of #5016. Closes #5027. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 037a0e8 commit 020220c

7 files changed

Lines changed: 668 additions & 0 deletions

File tree

cmd/thv/app/commands.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ func NewRootCmd(enableUpdates bool) *cobra.Command {
7171
rootCmd.AddCommand(inspectorCommand())
7272
rootCmd.AddCommand(newMCPCommand())
7373
rootCmd.AddCommand(newVMCPCommand())
74+
rootCmd.AddCommand(newLLMCommand())
7475
rootCmd.AddCommand(groupCmd)
7576
rootCmd.AddCommand(skillCmd)
7677
rootCmd.AddCommand(statusCmd)
@@ -113,6 +114,7 @@ func IsInformationalCommand(args []string) bool {
113114
"mcp": true,
114115
"skill": true,
115116
"vmcp": true,
117+
"llm": true,
116118
}
117119

118120
return informationalCommands[command]

cmd/thv/app/llm.go

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package app
5+
6+
import (
7+
"context"
8+
"encoding/json"
9+
"fmt"
10+
11+
"github.com/spf13/cobra"
12+
13+
"github.com/stacklok/toolhive/pkg/auth/secrets"
14+
"github.com/stacklok/toolhive/pkg/config"
15+
"github.com/stacklok/toolhive/pkg/llm"
16+
pkgsecrets "github.com/stacklok/toolhive/pkg/secrets"
17+
)
18+
19+
func newLLMCommand() *cobra.Command {
20+
cmd := &cobra.Command{
21+
Use: "llm",
22+
Short: "Manage LLM gateway authentication",
23+
Long: `Configure and manage authentication for OIDC-protected LLM gateways.
24+
25+
The llm command bridges AI coding tools to LLM gateways by handling OIDC
26+
authentication transparently. Two modes are supported:
27+
28+
Proxy mode — a localhost reverse proxy injects fresh tokens for tools
29+
that only accept static API keys (e.g. Cursor).
30+
Token helper — "thv llm token" prints a fresh JWT suitable for use as
31+
apiKeyHelper or auth.command in OIDC-capable tools
32+
(e.g. Claude Code).
33+
34+
Run "thv llm setup" to get started.`,
35+
}
36+
37+
cmd.AddCommand(newLLMConfigCommand())
38+
cmd.AddCommand(newLLMSetupCommand())
39+
cmd.AddCommand(newLLMTeardownCommand())
40+
cmd.AddCommand(newLLMProxyCommand())
41+
cmd.AddCommand(newLLMTokenCommand())
42+
43+
return cmd
44+
}
45+
46+
// ── config subcommand group ───────────────────────────────────────────────────
47+
48+
func newLLMConfigCommand() *cobra.Command {
49+
cmd := &cobra.Command{
50+
Use: "config",
51+
Short: "Manage LLM gateway configuration",
52+
Long: "The config command provides subcommands to manage LLM gateway connection settings.",
53+
}
54+
55+
cmd.AddCommand(newLLMConfigSetCommand())
56+
cmd.AddCommand(newLLMConfigShowCommand())
57+
cmd.AddCommand(newLLMConfigResetCommand())
58+
59+
return cmd
60+
}
61+
62+
func newLLMConfigSetCommand() *cobra.Command {
63+
var (
64+
gatewayURL string
65+
issuer string
66+
clientID string
67+
audience string
68+
proxyPort int
69+
callbackPort int
70+
)
71+
72+
cmd := &cobra.Command{
73+
Use: "set",
74+
Short: "Set LLM gateway connection settings",
75+
Long: `Persist LLM gateway connection settings to config.yaml.
76+
77+
Example:
78+
thv llm config set \
79+
--gateway-url https://llm.example.com \
80+
--issuer https://auth.example.com \
81+
--client-id my-client-id`,
82+
Args: cobra.NoArgs,
83+
RunE: func(_ *cobra.Command, _ []string) error {
84+
return config.UpdateConfig(func(c *config.Config) error {
85+
if gatewayURL != "" {
86+
c.LLM.GatewayURL = gatewayURL
87+
}
88+
if issuer != "" {
89+
c.LLM.OIDC.Issuer = issuer
90+
}
91+
if clientID != "" {
92+
c.LLM.OIDC.ClientID = clientID
93+
}
94+
if audience != "" {
95+
c.LLM.OIDC.Audience = audience
96+
}
97+
if proxyPort != 0 {
98+
c.LLM.Proxy.ListenPort = proxyPort
99+
}
100+
if callbackPort != 0 {
101+
c.LLM.OIDC.CallbackPort = callbackPort
102+
}
103+
return c.LLM.Validate()
104+
})
105+
},
106+
}
107+
108+
cmd.Flags().StringVar(&gatewayURL, "gateway-url", "", "LLM gateway base URL (must use HTTPS)")
109+
cmd.Flags().StringVar(&issuer, "issuer", "", "OIDC issuer URL")
110+
cmd.Flags().StringVar(&clientID, "client-id", "", "OIDC client ID")
111+
cmd.Flags().StringVar(&audience, "audience", "", "OIDC audience (optional)")
112+
cmd.Flags().IntVar(&proxyPort, "proxy-port", 0, "Localhost proxy listen port (default 14000)")
113+
cmd.Flags().IntVar(&callbackPort, "callback-port", 0, "OIDC callback port (default: ephemeral)")
114+
115+
return cmd
116+
}
117+
118+
func newLLMConfigShowCommand() *cobra.Command {
119+
var outputFormat string
120+
121+
cmd := &cobra.Command{
122+
Use: "show",
123+
Short: "Display current LLM gateway configuration",
124+
Args: cobra.NoArgs,
125+
RunE: func(cmd *cobra.Command, _ []string) error {
126+
provider := config.NewDefaultProvider()
127+
llmCfg := provider.GetConfig().LLM
128+
129+
if outputFormat == "json" {
130+
enc, err := json.MarshalIndent(llmCfg, "", " ")
131+
if err != nil {
132+
return fmt.Errorf("failed to encode config as JSON: %w", err)
133+
}
134+
fmt.Println(string(enc))
135+
return nil
136+
}
137+
138+
if !llmCfg.IsConfigured() {
139+
fmt.Println("LLM gateway is not configured. Run \"thv llm config set\" to configure it.")
140+
return nil
141+
}
142+
143+
fmt.Printf("Gateway URL: %s\n", llmCfg.GatewayURL)
144+
fmt.Printf("OIDC Issuer: %s\n", llmCfg.OIDC.Issuer)
145+
fmt.Printf("OIDC Client: %s\n", llmCfg.OIDC.ClientID)
146+
if llmCfg.OIDC.Audience != "" {
147+
fmt.Printf("Audience: %s\n", llmCfg.OIDC.Audience)
148+
}
149+
fmt.Printf("Proxy Port: %d\n", llmCfg.EffectiveProxyPort())
150+
fmt.Printf("Scopes: %v\n", llmCfg.OIDC.EffectiveScopes())
151+
if len(llmCfg.ConfiguredTools) > 0 {
152+
fmt.Println("Configured tools:")
153+
for _, t := range llmCfg.ConfiguredTools {
154+
fmt.Printf(" - %s (%s) %s\n", t.Tool, t.Mode, t.ConfigPath)
155+
}
156+
}
157+
158+
return nil
159+
},
160+
}
161+
162+
cmd.Flags().StringVarP(&outputFormat, "output", "o", "", "Output format (json)")
163+
164+
return cmd
165+
}
166+
167+
func newLLMConfigResetCommand() *cobra.Command {
168+
return &cobra.Command{
169+
Use: "reset",
170+
Short: "Clear all LLM gateway configuration and cached tokens",
171+
Long: `Remove all LLM gateway settings from config.yaml and delete cached OIDC
172+
tokens from the secrets provider.`,
173+
Args: cobra.NoArgs,
174+
RunE: func(cmd *cobra.Command, _ []string) error {
175+
// Delete cached tokens from the secrets provider first.
176+
if err := deleteLLMSecrets(cmd.Context()); err != nil {
177+
// Non-fatal: log and continue so the config is still cleared.
178+
fmt.Printf("Warning: could not remove cached LLM tokens: %v\n", err)
179+
}
180+
181+
return config.UpdateConfig(func(c *config.Config) error {
182+
c.LLM = llm.LLMConfig{}
183+
return nil
184+
})
185+
},
186+
}
187+
}
188+
189+
// deleteLLMSecrets removes all secrets stored under the LLM scope.
190+
func deleteLLMSecrets(_ context.Context) error {
191+
provider, err := secrets.GetSystemSecretsProvider()
192+
if err != nil {
193+
return fmt.Errorf("failed to get secrets provider: %w", err)
194+
}
195+
scoped := pkgsecrets.NewScopedProvider(provider, pkgsecrets.ScopeLLM)
196+
return scoped.Cleanup()
197+
}
198+
199+
// ── setup / teardown stubs ────────────────────────────────────────────────────
200+
201+
func newLLMSetupCommand() *cobra.Command {
202+
return &cobra.Command{
203+
Use: "setup",
204+
Short: "Detect installed AI tools, configure them, and trigger OIDC login",
205+
Long: `Detect installed AI coding tools, configure each to use the LLM gateway,
206+
start the background proxy for proxy-mode tools, and trigger an OIDC browser login.
207+
208+
Run "thv llm config set" first to set the gateway URL, issuer, and client ID.`,
209+
Args: cobra.NoArgs,
210+
RunE: func(_ *cobra.Command, _ []string) error {
211+
return fmt.Errorf("not implemented: coming in a future release")
212+
},
213+
}
214+
}
215+
216+
func newLLMTeardownCommand() *cobra.Command {
217+
cmd := &cobra.Command{
218+
Use: "teardown [tool-name]",
219+
Short: "Remove LLM gateway configuration from all tools and stop the proxy",
220+
Long: `Remove LLM gateway configuration from all configured AI tools and stop the
221+
background proxy. Optionally target a single tool by name.
222+
223+
Use --purge-tokens to also delete cached OIDC tokens from the secrets provider.`,
224+
Args: cobra.MaximumNArgs(1),
225+
RunE: func(_ *cobra.Command, _ []string) error {
226+
return fmt.Errorf("not implemented: coming in a future release")
227+
},
228+
}
229+
230+
cmd.Flags().Bool("purge-tokens", false, "Also delete cached OIDC tokens from the secrets provider")
231+
232+
return cmd
233+
}
234+
235+
// ── proxy subcommand group ────────────────────────────────────────────────────
236+
237+
func newLLMProxyCommand() *cobra.Command {
238+
cmd := &cobra.Command{
239+
Use: "proxy",
240+
Short: "Manage the LLM gateway localhost proxy",
241+
}
242+
243+
cmd.AddCommand(newLLMProxyStartCommand())
244+
245+
return cmd
246+
}
247+
248+
func newLLMProxyStartCommand() *cobra.Command {
249+
return &cobra.Command{
250+
Use: "start",
251+
Short: "Start the LLM proxy in the foreground (for debugging)",
252+
Long: `Start the localhost reverse proxy in the foreground with full log output.
253+
This is a debugging aid — use "thv llm setup" to start the proxy in the background.`,
254+
Args: cobra.NoArgs,
255+
RunE: func(_ *cobra.Command, _ []string) error {
256+
return fmt.Errorf("not implemented: coming in a future release")
257+
},
258+
}
259+
}
260+
261+
// ── token helper (hidden) ─────────────────────────────────────────────────────
262+
263+
func newLLMTokenCommand() *cobra.Command {
264+
cmd := &cobra.Command{
265+
Use: "token",
266+
Hidden: true,
267+
Short: "Print a fresh LLM gateway access token to stdout",
268+
Long: `Print a fresh OIDC access token to stdout (all other output on stderr).
269+
Intended for use as apiKeyHelper or auth.command in OIDC-capable AI tools.
270+
Runs non-interactively — will not launch a browser flow.`,
271+
Args: cobra.NoArgs,
272+
RunE: func(_ *cobra.Command, _ []string) error {
273+
return fmt.Errorf("not implemented: coming in a future release")
274+
},
275+
}
276+
277+
return cmd
278+
}

pkg/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919

2020
"github.com/stacklok/toolhive-core/env"
2121
"github.com/stacklok/toolhive/pkg/container/templates"
22+
"github.com/stacklok/toolhive/pkg/llm"
2223
"github.com/stacklok/toolhive/pkg/lockfile"
2324
"github.com/stacklok/toolhive/pkg/secrets"
2425
)
@@ -47,6 +48,7 @@ type Config struct {
4748
BuildAuthFiles map[string]string `yaml:"build_auth_files,omitempty"`
4849
RuntimeConfigs map[string]*templates.RuntimeConfig `yaml:"runtime_configs,omitempty"`
4950
RegistryAuth RegistryAuth `yaml:"registry_auth,omitempty"`
51+
LLM llm.LLMConfig `yaml:"llm,omitempty"`
5052
}
5153

5254
// RegistryAuthTypeOAuth is the auth type for OAuth/OIDC authentication.

0 commit comments

Comments
 (0)