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