1// Package agent wires the provider, core, tools, auth, and modes into a CLI.
2package agent
3
4import (
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "os"
10 "path/filepath"
11 "runtime"
12 "strings"
13 "time"
14
15 "github.com/patriceckhart/zot/packages/provider/auth"
16)
17
18// QuickModelShortcut is one configured keyboard shortcut slot.
19type QuickModelShortcut struct {
20 Provider string `json:"provider"`
21 Model string `json:"model"`
22}
23
24// Config is the persisted user configuration.
25type Config struct {
26 Provider string `json:"provider"`
27 Model string `json:"model"`
28 Reasoning string `json:"reasoning"`
29 Temperature *float32 `json:"temperature,omitempty"`
30 Theme string `json:"theme"`
31
32 // ToolRender selects how tool calls are drawn in interactive mode.
33 // "box" (default, or empty) wraps each call in a bordered panel;
34 // "flat" drops the frame for a quiet header line plus indented,
35 // frameless output. The ZOT_FLAT_TOOLS env var overrides this when
36 // set ("1"/"true" forces flat, "0"/"false" forces box).
37 ToolRender string `json:"tool_render,omitempty"`
38
39 // CompactInput renders sent user messages as a single quiet gutter
40 // line instead of a padded, background-tinted bubble. nil/false
41 // (the default) keeps the bubble. The ZOT_COMPACT_INPUT env var
42 // overrides this when set.
43 CompactInput *bool `json:"compact_input,omitempty"`
44
45 // QuickModelShortcuts maps slots 1-9 to provider/model pairs used by
46 // Ctrl+1..9. Cmd+1..9 may also work on terminals that forward Super.
47 QuickModelShortcuts []QuickModelShortcut `json:"quick_model_shortcuts,omitempty"`
48
49 // InlineImagesEnabled controls whether zot draws screenshots inline
50 // when the terminal supports an image protocol. nil/missing means
51 // auto (enabled when supported); false disables; true forces the
52 // detected protocol when available.
53 InlineImagesEnabled *bool `json:"inline_images_enabled,omitempty"`
54
55 // AutoSwarmEnabled lets the main agent spawn background sub-agents
56 // for parallel sub-tasks via a built-in swarm_spawn tool. Off by
57 // default; nil/missing means disabled. Toggle from /settings.
58 AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"`
59
60 // JailByDefault confines tools to the session working directory when
61 // a new agent starts. Off by default; nil/missing means disabled.
62 // The live session can still override this with /jail or /unjail.
63 JailByDefault *bool `json:"jail_by_default,omitempty"`
64
65 // RecursiveFileSuggest controls the @-mention file picker. When true
66 // the picker fuzzy-searches the whole project tree below the working
67 // directory; nil/missing/false keeps the default directory-by-
68 // directory browse. Toggle from /settings.
69 RecursiveFileSuggest *bool `json:"recursive_file_suggest,omitempty"`
70
71 // RespectGitignore controls whether the @-mention file picker hides
72 // files and directories matched by the project's root .gitignore (in
73 // both flat and recursive modes). nil/missing means the default,
74 // which is on; false shows ignored entries. Toggle from /settings.
75 RespectGitignore *bool `json:"respect_gitignore,omitempty"`
76
77 // CompactMode renders the interactive transcript with less chrome:
78 // tool calls use flat headers instead of bordered panels, and sent
79 // user messages render without padded background bubbles. Off by
80 // default; nil/missing means disabled. Toggle from /settings.
81 CompactMode *bool `json:"compact_mode,omitempty"`
82
83 // TUIInputStyle controls the main input rendering. Supported values:
84 // "plain" (default), "lines", and "block".
85 TUIInputStyle string `json:"tui_input_style,omitempty"`
86
87 // TUIStatusPosition controls whether model, usage, and cwd information
88 // render above or below the main input. Supported values: "above_input"
89 // (default) and "below_input".
90 TUIStatusPosition string `json:"tui_status_position,omitempty"`
91
92 // TUIWorkingPosition controls whether the busy/working spinner renders
93 // above or below the main input. Supported values: "above_input"
94 // (default) and "below_input".
95 TUIWorkingPosition string `json:"tui_working_position,omitempty"`
96
97 // Insecure skips TLS verification for custom inference endpoints.
98 Insecure bool `json:"insecure,omitempty"`
99
100 // LastChangelogShown is the version whose release-notes
101 // dialog the user has already seen. When the running binary's
102 // version differs, the next interactive run shows the
103 // changelog (fetched from the GitHub release page) once and
104 // updates this field. Empty means "never shown".
105 LastChangelogShown string `json:"last_changelog_shown,omitempty"`
106}
107
108// ZotHome returns $ZOT_HOME or the OS-default data dir.
109//
110// All zot state (config.json, auth.json, sessions/, logs/) lives under
111// this directory.
112func ZotHome() string {
113 if v := os.Getenv("ZOT_HOME"); v != "" {
114 return v
115 }
116 if v := os.Getenv("XDG_STATE_HOME"); v != "" {
117 return filepath.Join(v, "zot")
118 }
119 switch runtime.GOOS {
120 case "darwin":
121 if home, err := os.UserHomeDir(); err == nil {
122 return filepath.Join(home, "Library", "Application Support", "zot")
123 }
124 case "windows":
125 if v := os.Getenv("LOCALAPPDATA"); v != "" {
126 return filepath.Join(v, "zot")
127 }
128 }
129 if home, err := os.UserHomeDir(); err == nil {
130 return filepath.Join(home, ".local", "state", "zot")
131 }
132 return ".zot"
133}
134
135// ConfigPath returns the path to config.json.
136func ConfigPath() string { return filepath.Join(ZotHome(), "config.json") }
137
138// FlatToolRender reports whether tool calls should render flat (no
139// bordered panel). The ZOT_FLAT_TOOLS env var takes precedence over
140// the config when set: "1"/"true"/"yes"/"on" force flat, "0"/"false"/
141// "no"/"off" force box. Otherwise the config's tool_render is
142// consulted; "flat" is flat, anything else (including empty) is box.
143func (c Config) FlatToolRender() bool {
144 if v := strings.TrimSpace(strings.ToLower(os.Getenv("ZOT_FLAT_TOOLS"))); v != "" {
145 switch v {
146 case "1", "true", "yes", "on", "flat":
147 return true
148 case "0", "false", "no", "off", "box":
149 return false
150 }
151 }
152 return strings.EqualFold(strings.TrimSpace(c.ToolRender), "flat")
153}
154
155// CompactUserInput reports whether sent user messages should render as
156// a single quiet gutter line instead of a padded, background-tinted
157// bubble. The ZOT_COMPACT_INPUT env var takes precedence over the
158// config when set: "1"/"true"/"yes"/"on" force compact, "0"/"false"/
159// "no"/"off" force the bubble. Otherwise the config's compact_input
160// is consulted (nil/false means the bubble).
161func (c Config) CompactUserInput() bool {
162 if v := strings.TrimSpace(strings.ToLower(os.Getenv("ZOT_COMPACT_INPUT"))); v != "" {
163 switch v {
164 case "1", "true", "yes", "on", "compact":
165 return true
166 case "0", "false", "no", "off", "bubble":
167 return false
168 }
169 }
170 return c.CompactInput != nil && *c.CompactInput
171}
172
173// AuthPath returns the path to auth.json.
174func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") }
175
176// KimiCLIFallbackDisabledPath returns a sentinel that disables falling
177// back to the official Kimi Code CLI token after `zot /logout kimi`.
178func KimiCLIFallbackDisabledPath() string {
179 return filepath.Join(ZotHome(), "kimi-cli-fallback-disabled")
180}
181
182// SessionsPath returns the directory holding session files.
183func SessionsPath() string { return filepath.Join(ZotHome(), "sessions") }
184
185// LogsPath returns the directory holding log files.
186func LogsPath() string { return filepath.Join(ZotHome(), "logs") }
187
188// LoadConfig reads the config file, returning defaults if missing.
189func LoadConfig() (Config, error) {
190 var c Config
191 b, err := os.ReadFile(ConfigPath())
192 if errors.Is(err, os.ErrNotExist) {
193 return c, nil
194 }
195 if err != nil {
196 return c, err
197 }
198 if err := json.Unmarshal(b, &c); err != nil {
199 return c, fmt.Errorf("parse config: %w", err)
200 }
201 return c, nil
202}
203
204// SaveConfig writes the config file, creating parent dirs.
205func SaveConfig(c Config) error {
206 if err := os.MkdirAll(ZotHome(), 0o755); err != nil {
207 return err
208 }
209 b, err := json.MarshalIndent(c, "", " ")
210 if err != nil {
211 return err
212 }
213 return os.WriteFile(ConfigPath(), b, 0o644)
214}
215
216// AuthStoreFor returns the auth.Store backed by AuthPath().
217func AuthStoreFor() *auth.Store { return auth.NewStore(AuthPath()) }
218
219// ResolveCredential returns the credential (api key or oauth access
220// token), the method ("apikey"/"oauth"), and an error when no
221// credential is available.
222//
223// Lookup order:
224// 1. explicit (e.g. --api-key): treated as API key
225// 2. provider-specific env var: treated as API key
226// 3. auth.json: api key OR oauth, whichever is present
227func ResolveCredential(provider, explicit string) (cred, method string, err error) {
228 cred, method, _, err = ResolveCredentialFull(provider, explicit)
229 return cred, method, err
230}
231
232// ResolveCredentialFull is like ResolveCredential but also returns a
233// provider-specific accountID when the credential is an OpenAI OAuth
234// token (the ChatGPT account id extracted from the stored id_token).
235// accountID is "" for API-key auth and for anthropic.
236func ResolveCredentialFull(provider, explicit string) (cred, method, accountID string, err error) {
237 if explicit != "" {
238 return explicit, "apikey", "", nil
239 }
240 switch provider {
241 case "anthropic":
242 // ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY.
243 // Useful when both are set and the user wants subscription auth
244 // without editing auth.json.
245 if v := os.Getenv("ANTHROPIC_OAUTH_TOKEN"); v != "" {
246 return v, "oauth", "", nil
247 }
248 if v := os.Getenv("ANTHROPIC_API_KEY"); v != "" {
249 return v, "apikey", "", nil
250 }
251 case "openai":
252 if v := os.Getenv("OPENAI_API_KEY"); v != "" {
253 return v, "apikey", "", nil
254 }
255 case "openai-codex":
256 // ChatGPT/Codex subscription route. It intentionally ignores
257 // OPENAI_API_KEY so users can keep both OpenAI API and Codex
258 // subscription credentials configured and choose by provider.
259 case "openai-responses":
260 // Public OpenAI Responses API. Same env var as the chat-completions
261 // `openai` provider; users pick the wire format by provider id.
262 if v := os.Getenv("OPENAI_API_KEY"); v != "" {
263 return v, "apikey", "", nil
264 }
265 case "kimi":
266 if v := os.Getenv("KIMI_API_KEY"); v != "" {
267 return v, "apikey", "", nil
268 }
269 if v := os.Getenv("MOONSHOT_API_KEY"); v != "" {
270 return v, "apikey", "", nil
271 }
272 case "google":
273 // Both env names are widely-used in the Google ecosystem;
274 // GEMINI_API_KEY is the AI Studio default, GOOGLE_API_KEY
275 // is the older / generic name. Either works.
276 if v := os.Getenv("GEMINI_API_KEY"); v != "" {
277 return v, "apikey", "", nil
278 }
279 if v := os.Getenv("GOOGLE_API_KEY"); v != "" {
280 return v, "apikey", "", nil
281 }
282 case "deepseek":
283 if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" {
284 return v, "apikey", "", nil
285 }
286 case "moonshotai", "moonshotai-cn":
287 // Moonshot direct API (separate from kimi-coding, which is the
288 // Anthropic-Messages-fronted /coding endpoint with subscription OAuth).
289 if v := os.Getenv("MOONSHOT_API_KEY"); v != "" {
290 return v, "apikey", "", nil
291 }
292 case "groq":
293 if v := os.Getenv("GROQ_API_KEY"); v != "" {
294 return v, "apikey", "", nil
295 }
296 case "xai":
297 if v := os.Getenv("XAI_API_KEY"); v != "" {
298 return v, "apikey", "", nil
299 }
300 case "cerebras":
301 if v := os.Getenv("CEREBRAS_API_KEY"); v != "" {
302 return v, "apikey", "", nil
303 }
304 case "together":
305 if v := os.Getenv("TOGETHER_API_KEY"); v != "" {
306 return v, "apikey", "", nil
307 }
308 case "huggingface":
309 if v := os.Getenv("HF_TOKEN"); v != "" {
310 return v, "apikey", "", nil
311 }
312 case "openrouter":
313 if v := os.Getenv("OPENROUTER_API_KEY"); v != "" {
314 return v, "apikey", "", nil
315 }
316 case "mistral":
317 if v := os.Getenv("MISTRAL_API_KEY"); v != "" {
318 return v, "apikey", "", nil
319 }
320 case "zai":
321 if v := os.Getenv("ZAI_API_KEY"); v != "" {
322 return v, "apikey", "", nil
323 }
324 case "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp":
325 envVar := "XIAOMI_API_KEY"
326 switch provider {
327 case "xiaomi-token-plan-ams":
328 envVar = "XIAOMI_TOKEN_PLAN_AMS_API_KEY"
329 case "xiaomi-token-plan-cn":
330 envVar = "XIAOMI_TOKEN_PLAN_CN_API_KEY"
331 case "xiaomi-token-plan-sgp":
332 envVar = "XIAOMI_TOKEN_PLAN_SGP_API_KEY"
333 }
334 if v := os.Getenv(envVar); v != "" {
335 return v, "apikey", "", nil
336 }
337 case "minimax":
338 if v := os.Getenv("MINIMAX_API_KEY"); v != "" {
339 return v, "apikey", "", nil
340 }
341 case "minimax-cn":
342 if v := os.Getenv("MINIMAX_CN_API_KEY"); v != "" {
343 return v, "apikey", "", nil
344 }
345 if v := os.Getenv("MINIMAX_API_KEY"); v != "" {
346 return v, "apikey", "", nil
347 }
348 case "fireworks":
349 if v := os.Getenv("FIREWORKS_API_KEY"); v != "" {
350 return v, "apikey", "", nil
351 }
352 case "vercel-ai-gateway":
353 if v := os.Getenv("AI_GATEWAY_API_KEY"); v != "" {
354 return v, "apikey", "", nil
355 }
356 case "opencode", "opencode-go":
357 if v := os.Getenv("OPENCODE_API_KEY"); v != "" {
358 return v, "apikey", "", nil
359 }
360 case "github-copilot":
361 if v := os.Getenv("COPILOT_GITHUB_TOKEN"); v != "" {
362 return v, "apikey", "", nil
363 }
364 if v := os.Getenv("GITHUB_COPILOT_TOKEN"); v != "" {
365 return v, "apikey", "", nil
366 }
367 case "cloudflare-workers-ai", "cloudflare-ai-gateway":
368 if v := os.Getenv("CLOUDFLARE_API_KEY"); v != "" {
369 return v, "apikey", "", nil
370 }
371 case "amazon-bedrock":
372 // Bedrock has many credential sources (AWS_PROFILE, IAM keys,
373 // container creds, IRSA, bearer token). We surface a sentinel so
374 // Resolve doesn't error on missing key; the real client (when
375 // implemented) will resolve credentials through aws-sdk-go-v2.
376 if os.Getenv("AWS_ACCESS_KEY_ID") != "" || os.Getenv("AWS_PROFILE") != "" ||
377 os.Getenv("AWS_BEARER_TOKEN_BEDROCK") != "" {
378 return "<aws>", "apikey", "", nil
379 }
380 case "google-vertex":
381 if v := os.Getenv("GOOGLE_CLOUD_API_KEY"); v != "" {
382 return v, "apikey", "", nil
383 }
384 // Check for Application Default Credentials (gcloud auth application-default login)
385 // or service account JSON, matching the actual NewVertex client behavior.
386 if os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") != "" {
387 return "<adc>", "apikey", "", nil
388 }
389 // Check for the platform-specific default ADC path that gcloud writes to.
390 if adcPath, err := auth.GoogleApplicationDefaultCredentialsPath(); err == nil {
391 if _, err := os.Stat(adcPath); err == nil {
392 return "<adc>", "apikey", "", nil
393 }
394 }
395 case "azure-openai-responses":
396 if v := os.Getenv("AZURE_OPENAI_API_KEY"); v != "" {
397 return v, "apikey", "", nil
398 }
399 }
400 // Generic env var fallback for custom providers: normalize the
401 // provider id to a shell-friendly env var name (hyphens to
402 // underscores) and check {NAME}_API_KEY before auth.json.
403 if v := os.Getenv(normalizeCustomProviderEnvVar(provider) + "_API_KEY"); v != "" {
404 return v, "apikey", "", nil
405 }
406 c, err := AuthStoreFor().Load()
407 if err != nil {
408 return "", "", "", err
409 }
410 if pc, ok := c.AdditionalAPIKeyCreds[provider]; ok {
411 if pc.APIKey != "" {
412 return pc.APIKey, "apikey", "", nil
413 }
414 if provider == "xai" && pc.OAuth != nil && pc.OAuth.AccessToken != "" {
415 tok, _ := refreshIfExpired(provider, pc.OAuth)
416 return tok.AccessToken, "oauth", "", nil
417 }
418 }
419 switch provider {
420 case "anthropic":
421 if c.Anthropic.APIKey != "" {
422 return c.Anthropic.APIKey, "apikey", "", nil
423 }
424 if c.Anthropic.OAuth != nil && c.Anthropic.OAuth.AccessToken != "" {
425 tok, _ := refreshIfExpired("anthropic", c.Anthropic.OAuth)
426 return tok.AccessToken, "oauth", "", nil
427 }
428 case "openai":
429 if c.OpenAI.APIKey != "" {
430 return c.OpenAI.APIKey, "apikey", "", nil
431 }
432 case "openai-codex":
433 if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" {
434 tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth)
435 return tok.AccessToken, "oauth", tok.AccountID, nil
436 }
437 case "kimi":
438 if c.Kimi.APIKey != "" {
439 return c.Kimi.APIKey, "apikey", "", nil
440 }
441 if c.Kimi.OAuth != nil && c.Kimi.OAuth.AccessToken != "" {
442 tok, _ := refreshIfExpired("kimi", c.Kimi.OAuth)
443 return tok.AccessToken, "oauth", "", nil
444 }
445 if kimiCLIFallbackDisabled() {
446 break
447 }
448 if tok := loadKimiCodeCLIToken(); tok != nil && tok.AccessToken != "" {
449 tok, _ = refreshIfExpired("kimi", tok)
450 return tok.AccessToken, "oauth", "", nil
451 }
452 case "deepseek":
453 if c.DeepSeek.APIKey != "" {
454 return c.DeepSeek.APIKey, "apikey", "", nil
455 }
456 case "google":
457 // Google is API-key only — no OAuth path. We still load
458 // auth.json so /login api-key flows work without exporting
459 // an env var.
460 if c.Google.APIKey != "" {
461 return c.Google.APIKey, "apikey", "", nil
462 }
463 case "github-copilot":
464 if c.GithubCopilot.APIKey != "" {
465 return c.GithubCopilot.APIKey, "apikey", "", nil
466 }
467 if c.GithubCopilot.OAuth != nil && c.GithubCopilot.OAuth.AccessToken != "" {
468 return c.GithubCopilot.OAuth.AccessToken, "oauth", "", nil
469 }
470 }
471 return "", "", "", fmt.Errorf("no credential for %s", provider)
472}
473
474func kimiCLIFallbackDisabled() bool {
475 _, err := os.Stat(KimiCLIFallbackDisabledPath())
476 return err == nil
477}
478
479func SetKimiCLIFallbackDisabled(disabled bool) error {
480 path := KimiCLIFallbackDisabledPath()
481 if !disabled {
482 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
483 return err
484 }
485 return nil
486 }
487 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
488 return err
489 }
490 return os.WriteFile(path, []byte("disabled\n"), 0o600)
491}
492
493// normalizeCustomProviderEnvVar converts a provider id such as
494// "my-company" to "MY_COMPANY", matching the common convention for
495// shell environment variables.
496func normalizeCustomProviderEnvVar(provider string) string {
497 provider = strings.ToUpper(provider)
498 provider = strings.ReplaceAll(provider, "-", "_")
499 return provider
500}
501
502func loadKimiCodeCLIToken() *auth.OAuthToken {
503 home, err := os.UserHomeDir()
504 if err != nil {
505 return nil
506 }
507 b, err := os.ReadFile(filepath.Join(home, ".kimi", "credentials", "kimi-code.json"))
508 if err != nil {
509 return nil
510 }
511 var raw struct {
512 AccessToken string `json:"access_token"`
513 RefreshToken string `json:"refresh_token"`
514 TokenType string `json:"token_type"`
515 ExpiresAt float64 `json:"expires_at"`
516 Scope string `json:"scope"`
517 ExpiresIn float64 `json:"expires_in"`
518 }
519 if err := json.Unmarshal(b, &raw); err != nil || raw.AccessToken == "" {
520 return nil
521 }
522 sec := int64(raw.ExpiresAt)
523 nsec := int64((raw.ExpiresAt - float64(sec)) * 1e9)
524 return &auth.OAuthToken{
525 AccessToken: raw.AccessToken,
526 RefreshToken: raw.RefreshToken,
527 TokenType: raw.TokenType,
528 Scope: raw.Scope,
529 ClientID: auth.KimiOAuth.ClientID,
530 Expiry: time.Unix(sec, nsec),
531 }
532}
533
534// loadOAuthToken reads the current OAuth token from auth.json for the
535// given provider. Returns nil if no token is stored.
536func loadOAuthToken(providerName string) *auth.OAuthToken {
537 c, err := AuthStoreFor().Load()
538 if err != nil {
539 return nil
540 }
541 switch providerName {
542 case "anthropic":
543 if c.Anthropic.OAuth != nil {
544 return c.Anthropic.OAuth
545 }
546 case "openai":
547 if c.OpenAI.OAuth != nil {
548 return c.OpenAI.OAuth
549 }
550 case "kimi":
551 if c.Kimi.OAuth != nil {
552 return c.Kimi.OAuth
553 }
554 if kimiCLIFallbackDisabled() {
555 return nil
556 }
557 return loadKimiCodeCLIToken()
558 case "github-copilot":
559 if c.GithubCopilot.OAuth != nil {
560 return c.GithubCopilot.OAuth
561 }
562 }
563 if pc, ok := c.AdditionalAPIKeyCreds[providerName]; ok {
564 return pc.OAuth
565 }
566 return nil
567}
568
569// refreshIfExpired returns a usable OAuth token for the given provider,
570// refreshing it synchronously when it's past (or near) expiry. The
571// refreshed token is persisted to auth.json.
572//
573// Failures return the original token unchanged — the caller then makes
574// a request with the stale access_token, which will 401. That's still
575// better than crashing at credential-resolution time.
576func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthToken, error) {
577 if tok == nil {
578 return &auth.OAuthToken{}, fmt.Errorf("nil token")
579 }
580 if !tok.Expired() {
581 return tok, nil
582 }
583 if tok.RefreshToken == "" {
584 return tok, fmt.Errorf("%s oauth token expired and no refresh_token available — run /login again", providerName)
585 }
586
587 if providerName == "xai" {
588 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
589 defer cancel()
590 next, err := auth.RefreshXAIToken(ctx, tok.RefreshToken)
591 if err != nil {
592 return tok, fmt.Errorf("refresh %s: %w", providerName, err)
593 }
594 if next.RefreshToken == "" {
595 next.RefreshToken = tok.RefreshToken
596 }
597 if err := AuthStoreFor().SetOAuth(providerName, *next); err != nil {
598 return next, fmt.Errorf("persist refreshed token: %w", err)
599 }
600 return next, nil
601 }
602
603 var op auth.OAuthProvider
604 switch providerName {
605 case "anthropic":
606 op = auth.AnthropicOAuth
607 case "openai":
608 op = auth.OpenAIOAuth
609 case "kimi":
610 op = auth.KimiOAuth
611 default:
612 return tok, fmt.Errorf("unknown provider %q", providerName)
613 }
614
615 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
616 defer cancel()
617 next, err := op.Refresh(ctx, tok.RefreshToken)
618 if err != nil {
619 return tok, fmt.Errorf("refresh %s: %w", providerName, err)
620 }
621 // Preserve the refresh token if the server omitted it (Anthropic often does).
622 if next.RefreshToken == "" {
623 next.RefreshToken = tok.RefreshToken
624 }
625 // Carry over account id (openai) / id_token across refreshes.
626 if next.AccountID == "" {
627 next.AccountID = tok.AccountID
628 }
629 if next.IDToken == "" {
630 next.IDToken = tok.IDToken
631 }
632 if err := AuthStoreFor().SetOAuth(providerName, *next); err != nil {
633 return next, fmt.Errorf("persist refreshed token: %w", err)
634 }
635 return next, nil
636}