Skip to content

Commit f9489a4

Browse files
authored
Merge branch 'main' into feat/authserver-standalone-redis
2 parents babf7c3 + 68f4c2f commit f9489a4

21 files changed

Lines changed: 2600 additions & 74 deletions

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: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
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+
"time"
12+
13+
"github.com/spf13/cobra"
14+
15+
"github.com/stacklok/toolhive/pkg/auth/secrets"
16+
"github.com/stacklok/toolhive/pkg/config"
17+
"github.com/stacklok/toolhive/pkg/llm"
18+
pkgsecrets "github.com/stacklok/toolhive/pkg/secrets"
19+
)
20+
21+
func newLLMCommand() *cobra.Command {
22+
cmd := &cobra.Command{
23+
Use: "llm",
24+
Hidden: true,
25+
Short: "Manage LLM gateway authentication",
26+
Long: `Configure and manage authentication for OIDC-protected LLM gateways.
27+
28+
The llm command bridges AI coding tools to LLM gateways by handling OIDC
29+
authentication transparently. Two modes are planned:
30+
31+
Proxy mode — a localhost reverse proxy injects fresh tokens for tools
32+
that only accept static API keys (e.g. Cursor).
33+
Token helper — "thv llm token" prints a fresh JWT suitable for use as
34+
apiKeyHelper or auth.command in OIDC-capable tools
35+
(e.g. Claude Code).
36+
37+
To configure the gateway connection settings, use:
38+
39+
thv llm config set --gateway-url https://llm.example.com \
40+
--issuer https://auth.example.com \
41+
--client-id my-client-id
42+
43+
Use "thv llm config show" to view the current configuration.`,
44+
}
45+
46+
cmd.AddCommand(newConfigCommand())
47+
cmd.AddCommand(newLLMSetupCommand())
48+
cmd.AddCommand(newLLMTeardownCommand())
49+
cmd.AddCommand(newLLMProxyCommand())
50+
cmd.AddCommand(newLLMTokenCommand())
51+
52+
return cmd
53+
}
54+
55+
// ── config subcommand group ───────────────────────────────────────────────────
56+
57+
func newConfigCommand() *cobra.Command {
58+
cmd := &cobra.Command{
59+
Use: "config",
60+
Short: "Manage LLM gateway configuration",
61+
Long: "The config command provides subcommands to manage LLM gateway connection settings.",
62+
}
63+
64+
cmd.AddCommand(newConfigSetCommand())
65+
cmd.AddCommand(newConfigShowCommand())
66+
cmd.AddCommand(newConfigResetCommand())
67+
68+
return cmd
69+
}
70+
71+
func newConfigSetCommand() *cobra.Command {
72+
var opts llm.SetOptions
73+
74+
cmd := &cobra.Command{
75+
Use: "set",
76+
Short: "Set LLM gateway connection settings",
77+
Long: `Persist LLM gateway connection settings to config.yaml.
78+
79+
Example:
80+
thv llm config set \
81+
--gateway-url https://llm.example.com \
82+
--issuer https://auth.example.com \
83+
--client-id my-client-id`,
84+
Args: cobra.NoArgs,
85+
RunE: func(_ *cobra.Command, _ []string) error {
86+
return config.UpdateConfig(func(c *config.Config) error {
87+
return c.LLM.SetFields(opts)
88+
})
89+
},
90+
}
91+
92+
cmd.Flags().StringVar(&opts.GatewayURL, "gateway-url", "", "LLM gateway base URL (must use HTTPS)")
93+
cmd.Flags().StringVar(&opts.Issuer, "issuer", "", "OIDC issuer URL")
94+
cmd.Flags().StringVar(&opts.ClientID, "client-id", "", "OIDC client ID")
95+
cmd.Flags().StringVar(&opts.Audience, "audience", "", "OIDC audience (optional)")
96+
cmd.Flags().IntVar(&opts.ProxyPort, "proxy-port", 0, "Localhost proxy listen port (default 14000)")
97+
cmd.Flags().IntVar(&opts.CallbackPort, "callback-port", 0, "OIDC callback port (default: ephemeral)")
98+
99+
return cmd
100+
}
101+
102+
func newConfigShowCommand() *cobra.Command {
103+
var outputFormat string
104+
105+
cmd := &cobra.Command{
106+
Use: "show",
107+
Short: "Display current LLM gateway configuration",
108+
Args: cobra.NoArgs,
109+
PreRunE: ValidateFormat(&outputFormat, FormatJSON, FormatText),
110+
RunE: func(_ *cobra.Command, _ []string) error {
111+
provider := config.NewDefaultProvider()
112+
llmCfg := provider.GetConfig().LLM
113+
114+
if outputFormat == FormatJSON {
115+
enc, err := json.MarshalIndent(llmCfg, "", " ")
116+
if err != nil {
117+
return fmt.Errorf("failed to encode config as JSON: %w", err)
118+
}
119+
fmt.Println(string(enc))
120+
return nil
121+
}
122+
123+
return llmCfg.Show(os.Stdout)
124+
},
125+
}
126+
127+
AddFormatFlag(cmd, &outputFormat, FormatJSON, FormatText)
128+
129+
return cmd
130+
}
131+
132+
func newConfigResetCommand() *cobra.Command {
133+
return &cobra.Command{
134+
Use: "reset",
135+
Short: "Clear all LLM gateway configuration and cached tokens",
136+
Long: `Remove all LLM gateway settings from config.yaml and delete cached OIDC
137+
tokens from the secrets provider.`,
138+
Args: cobra.NoArgs,
139+
RunE: func(cmd *cobra.Command, _ []string) error {
140+
// Delete cached tokens from the secrets provider first.
141+
provider, err := secrets.GetSystemSecretsProvider()
142+
if err != nil {
143+
// Non-fatal: log and continue so the config is still cleared.
144+
fmt.Fprintf(os.Stderr, "Warning: could not get secrets provider: %v\n", err)
145+
} else if err := llm.DeleteCachedTokens(cmd.Context(), provider); err != nil {
146+
fmt.Fprintf(os.Stderr, "Warning: could not remove cached LLM tokens: %v\n", err)
147+
}
148+
149+
return config.UpdateConfig(func(c *config.Config) error {
150+
c.LLM = llm.Config{}
151+
return nil
152+
})
153+
},
154+
}
155+
}
156+
157+
// runLLMToken prints a fresh LLM gateway access token to stdout.
158+
// All diagnostic output goes to stderr so the caller can capture the token
159+
// cleanly (e.g. apiKeyHelper or auth.command in Claude Code / Cursor).
160+
func runLLMToken(ctx context.Context) error {
161+
provider := config.NewDefaultProvider()
162+
llmCfg := provider.GetConfig().LLM
163+
164+
if !llmCfg.IsConfigured() {
165+
return fmt.Errorf("LLM gateway is not configured — run \"thv llm config set\" first")
166+
}
167+
168+
secretsProvider, err := secrets.GetSystemSecretsProvider()
169+
if err != nil {
170+
return fmt.Errorf("failed to get secrets provider: %w", err)
171+
}
172+
scoped := pkgsecrets.NewScopedProvider(secretsProvider, pkgsecrets.ScopeLLM)
173+
174+
updater := func(key string, expiry time.Time) {
175+
if err := config.UpdateConfig(func(c *config.Config) error {
176+
c.LLM.OIDC.CachedRefreshTokenRef = key
177+
c.LLM.OIDC.CachedTokenExpiry = expiry
178+
return nil
179+
}); err != nil {
180+
fmt.Fprintf(os.Stderr, "Warning: failed to persist LLM token reference: %v\n", err)
181+
}
182+
}
183+
184+
ts := llm.NewTokenSource(&llmCfg, scoped, false /* non-interactive */, updater)
185+
token, err := ts.Token(ctx)
186+
if err != nil {
187+
return err
188+
}
189+
190+
fmt.Println(token)
191+
return nil
192+
}
193+
194+
// ── setup / teardown stubs ────────────────────────────────────────────────────
195+
196+
func newLLMSetupCommand() *cobra.Command {
197+
return &cobra.Command{
198+
Use: "setup",
199+
Short: "Detect installed AI tools, configure them, and trigger OIDC login (coming soon)",
200+
Args: cobra.NoArgs,
201+
RunE: func(_ *cobra.Command, _ []string) error {
202+
return fmt.Errorf("not implemented: coming in a future release")
203+
},
204+
}
205+
}
206+
207+
func newLLMTeardownCommand() *cobra.Command {
208+
cmd := &cobra.Command{
209+
Use: "teardown [tool-name]",
210+
Short: "Remove LLM gateway configuration from all tools and stop the proxy (coming soon)",
211+
Args: cobra.MaximumNArgs(1),
212+
RunE: func(_ *cobra.Command, _ []string) error {
213+
return fmt.Errorf("not implemented: coming in a future release")
214+
},
215+
}
216+
217+
cmd.Flags().Bool("purge-tokens", false, "Also delete cached OIDC tokens from the secrets provider")
218+
219+
return cmd
220+
}
221+
222+
// ── proxy subcommand group ────────────────────────────────────────────────────
223+
224+
func newLLMProxyCommand() *cobra.Command {
225+
cmd := &cobra.Command{
226+
Use: "proxy",
227+
Short: "Manage the LLM gateway localhost proxy",
228+
}
229+
230+
cmd.AddCommand(newLLMProxyStartCommand())
231+
232+
return cmd
233+
}
234+
235+
func newLLMProxyStartCommand() *cobra.Command {
236+
return &cobra.Command{
237+
Use: "start",
238+
Short: "Start the LLM proxy in the foreground (coming soon)",
239+
Args: cobra.NoArgs,
240+
RunE: func(_ *cobra.Command, _ []string) error {
241+
return fmt.Errorf("not implemented: coming in a future release")
242+
},
243+
}
244+
}
245+
246+
// ── token helper (hidden) ─────────────────────────────────────────────────────
247+
248+
func newLLMTokenCommand() *cobra.Command {
249+
cmd := &cobra.Command{
250+
Use: "token",
251+
Hidden: true,
252+
Short: "Print a fresh LLM gateway access token to stdout",
253+
Long: `Print a fresh OIDC access token to stdout (all other output on stderr).
254+
Intended for use as apiKeyHelper or auth.command in OIDC-capable AI tools.
255+
Runs non-interactively — will not launch a browser flow.`,
256+
Args: cobra.NoArgs,
257+
RunE: func(cmd *cobra.Command, _ []string) error {
258+
return runLLMToken(cmd.Context())
259+
},
260+
}
261+
262+
return cmd
263+
}

pkg/config/config.go

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ 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"
24+
"github.com/stacklok/toolhive/pkg/oidc"
2325
"github.com/stacklok/toolhive/pkg/secrets"
2426
)
2527

@@ -47,6 +49,7 @@ type Config struct {
4749
BuildAuthFiles map[string]string `yaml:"build_auth_files,omitempty"`
4850
RuntimeConfigs map[string]*templates.RuntimeConfig `yaml:"runtime_configs,omitempty"`
4951
RegistryAuth RegistryAuth `yaml:"registry_auth,omitempty"`
52+
LLM llm.Config `yaml:"llm,omitempty"`
5053
}
5154

5255
// RegistryAuthTypeOAuth is the auth type for OAuth/OIDC authentication.
@@ -63,17 +66,10 @@ type RegistryAuth struct {
6366

6467
// RegistryOAuthConfig holds OAuth/OIDC configuration for registry authentication.
6568
// PKCE (S256) is always enforced per OAuth 2.1 requirements for public clients.
66-
type RegistryOAuthConfig struct {
67-
Issuer string `yaml:"issuer"`
68-
ClientID string `yaml:"client_id"`
69-
Scopes []string `yaml:"scopes,omitempty"`
70-
Audience string `yaml:"audience,omitempty"`
71-
CallbackPort int `yaml:"callback_port,omitempty"`
72-
73-
// Cached token references for session restoration across CLI invocations.
74-
CachedRefreshTokenRef string `yaml:"cached_refresh_token_ref,omitempty"`
75-
CachedTokenExpiry time.Time `yaml:"cached_token_expiry,omitempty"`
76-
}
69+
//
70+
// This is a type alias for oidc.ClientConfig so that registry and LLM gateway
71+
// authentication always share the same field set and validation logic.
72+
type RegistryOAuthConfig = oidc.ClientConfig
7773

7874
// Secrets contains the settings for secrets management.
7975
type Secrets struct {

0 commit comments

Comments
 (0)