Skip to content

Commit 39a3785

Browse files
yroblataskbotclaude
authored
Add thv llm command group with config types and management commands (#5032)
* 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> * Fix lint issues and regenerate CLI docs - Rename pkg/llm types to drop LLM prefix (revive stutter: LLMConfig → Config, LLMOIDCConfig → OIDCConfig, etc.) - Fix unused cmd parameter in config show RunE (revive) - Use FormatJSON constant instead of raw "json" string (goconst) - Fix gci import ordering in pkg/config/config.go - Regenerate docs/cli/ for the new thv llm command group Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * changes from review --------- Co-authored-by: taskbot <taskbot@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f94b99b commit 39a3785

11 files changed

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

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.Config `yaml:"llm,omitempty"`
5052
}
5153

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

0 commit comments

Comments
 (0)