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