1package agent
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "runtime"
9 "strings"
10
11 zotdocs "github.com/patriceckhart/zot"
12 "github.com/patriceckhart/zot/packages/agent/skills"
13 "github.com/patriceckhart/zot/packages/agent/tools"
14 "github.com/patriceckhart/zot/packages/core"
15 "github.com/patriceckhart/zot/packages/provider"
16)
17
18// Resolved is the effective configuration after merging CLI, config, defaults.
19type Resolved struct {
20 Provider string
21 Model string
22 Credential string // api key or oauth access token
23 AuthMethod string // "apikey" | "oauth" | "" (no credential yet)
24 AccountID string // ChatGPT account id (for openai oauth), "" otherwise
25 BaseURL string
26 CWD string
27 Reasoning string
28
29 ToolRegistry core.Registry
30 ToolSummary []ToolSummary
31 SystemPrompt string
32 MaxSteps int
33 Sandbox *tools.Sandbox
34
35 // SkillTool is the on-demand skill loader registered with the
36 // agent's tool registry, or nil if no SKILL.md files were
37 // discovered. Exposed so the tui can list / preview skills.
38 SkillTool *skills.Tool
39
40 // Bookkeeping for MergeExtensionTools. Captured at Resolve time
41 // so the system prompt can be rebuilt later without re-running
42 // resolve.
43 systemAppend []string
44 systemCustom string
45 toolDescriptions map[string]string
46}
47
48// HasCredential reports whether a credential was resolved.
49func (r Resolved) HasCredential() bool { return r.Credential != "" }
50
51// MergeExtensionTools folds every tool registered by an extension
52// into r's ToolRegistry and re-renders the system prompt's tool
53// summary so the model sees both built-in and extension tools.
54//
55// Idempotent: calling twice with the same manager state has no
56// effect on the second pass (existing names are preserved). Built-in
57// tools always win on conflict.
58func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
59 if mgr == nil {
60 return
61 }
62 infos := mgr.Tools()
63 if len(infos) == 0 {
64 return
65 }
66 changed := false
67 for _, info := range infos {
68 if _, exists := r.ToolRegistry[info.Name]; exists {
69 continue
70 }
71 r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info)
72 changed = true
73 }
74 if !changed {
75 return
76 }
77 // Re-render the system prompt with the merged tool list. Skill
78 // addendum is preserved by walking the existing append slice.
79 append_ := r.systemAppend
80 r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{
81 CWD: r.CWD,
82 Tools: toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions),
83 Custom: r.systemCustom,
84 Append: append_,
85 ZotDocsDir: filepath.Join(ZotHome(), "docs"),
86 })
87}
88
89// ExtensionToolSource is the slice of the extension manager that
90// MergeExtensionTools needs. Lives here as an interface so the
91// build package doesn't import packages/agent/extensions (which
92// imports core, which imports... avoid the cycle).
93type ExtensionToolSource interface {
94 Tools() []ExtensionToolInfo
95 NewExtensionTool(info ExtensionToolInfo) core.Tool
96}
97
98// ExtensionToolInfo mirrors extensions.ToolInfo so we can declare
99// ExtensionToolSource here without importing the extensions
100// package. The cli wires a tiny adapter to bridge them.
101type ExtensionToolInfo struct {
102 Extension string
103 Name string
104 Description string
105 Schema []byte
106}
107
108// toolSummariesFromRegistry rebuilds the system-prompt tool list
109// from a (possibly extended) registry, using cached descriptions for
110// the human-readable summary text.
111func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []ToolSummary {
112 out := make([]ToolSummary, 0, len(reg))
113 for name, t := range reg {
114 desc := t.Description()
115 if d, ok := cached[name]; ok && d != "" {
116 desc = d
117 }
118 out = append(out, ToolSummary{Name: name, Description: desc})
119 }
120 return out
121}
122
123// defaultModelForProvider returns the model id zot prefers when the
124// caller didn't pick one. Mirrors the per-provider switch used at
125// multiple points in Resolve; centralised so the unknown-model
126// recovery path and the no-model-configured path can't drift.
127//
128// Returns the empty string for "ollama", which has no built-in
129// default — the caller is expected to special-case ollama and
130// error or use whatever the user passed.
131func defaultModelForProvider(prov string) string {
132 switch prov {
133 case "openai":
134 return "gpt-5"
135 case "openai-codex":
136 return "gpt-5.5"
137 case "openai-responses":
138 return "gpt-5"
139 case "kimi":
140 return "kimi-for-coding"
141 case "deepseek":
142 return "deepseek-v4-pro"
143 case "google":
144 return "gemini-2.5-pro"
145 case "ollama":
146 return ""
147 case "moonshotai", "moonshotai-cn":
148 return "kimi-k2.6"
149 case "cerebras":
150 return "qwen-3-235b-a22b-instruct-2507"
151 case "groq":
152 return "llama-3.3-70b-versatile"
153 case "xai":
154 return "grok-code-fast-1"
155 case "together":
156 return "Qwen/Qwen3-Coder-480B-A35B-Instruct"
157 case "huggingface":
158 return "moonshotai/Kimi-K2-Instruct"
159 case "openrouter":
160 return "anthropic/claude-sonnet-4.5"
161 case "mistral":
162 return "mistral-large-latest"
163 case "zai":
164 return "glm-4.7"
165 case "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp":
166 return "mimo-v2.5"
167 case "minimax", "minimax-cn":
168 return "MiniMax-M2.7"
169 case "fireworks":
170 return "accounts/fireworks/models/kimi-k2p6"
171 case "vercel-ai-gateway":
172 return "anthropic/claude-sonnet-4.5"
173 case "opencode":
174 return "claude-sonnet-4-5"
175 case "opencode-go":
176 return "kimi-k2.6"
177 case "amazon-bedrock":
178 return "anthropic.claude-sonnet-4-5-20250929-v1:0"
179 case "google-vertex":
180 return "gemini-2.5-pro"
181 case "azure-openai-responses":
182 return "gpt-5"
183 case "github-copilot":
184 return "claude-sonnet-4.5"
185 default:
186 return provider.DefaultModel.ID
187 }
188}
189
190// knownProviders is the set of provider ids zot recognises. Used by
191// Resolve to validate args.Provider, by extension-callers, and by the
192// auto-fallback logic that picks any logged-in provider when the user's
193// preferred one has no credentials.
194var knownProviders = []string{
195 "anthropic", "openai", "openai-codex", "openai-responses", "kimi", "deepseek", "google", "ollama",
196 "moonshotai", "moonshotai-cn",
197 "cerebras", "groq", "xai", "together", "huggingface", "openrouter",
198 "mistral", "zai",
199 "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp",
200 "minimax", "minimax-cn",
201 "fireworks", "vercel-ai-gateway",
202 "opencode", "opencode-go",
203 "amazon-bedrock", "google-vertex", "azure-openai-responses",
204 "github-copilot", "cloudflare-workers-ai", "cloudflare-ai-gateway",
205}
206
207func isKnownProvider(name string) bool {
208 for _, p := range knownProviders {
209 if p == name {
210 return true
211 }
212 }
213 return false
214}
215
216// providerAliases maps common short / alternate provider names to the
217// canonical id in knownProviders. Users (and other agents) reach for
218// "bedrock" or "vertex" far more naturally than the fully-qualified
219// "amazon-bedrock" / "google-vertex"; without this mapping an alias is
220// treated as an unknown provider and Resolve silently falls back to
221// anthropic, producing a misleading "no credential for anthropic" error
222// after the user explicitly picked, say, bedrock.
223var providerAliases = map[string]string{
224 "bedrock": "amazon-bedrock",
225 "aws-bedrock": "amazon-bedrock",
226 "amazon": "amazon-bedrock",
227 "vertex": "google-vertex",
228 "gcp-vertex": "google-vertex",
229 "gemini": "google",
230 "googleai": "google",
231 "google-ai": "google",
232 "azure": "azure-openai-responses",
233 "azure-openai": "azure-openai-responses",
234 "copilot": "github-copilot",
235 "github": "github-copilot",
236 "codex": "openai-codex",
237 "moonshot": "moonshotai",
238 "kimi-code": "kimi",
239 "ai-gateway": "vercel-ai-gateway",
240 "vercel": "vercel-ai-gateway",
241 "cloudflare": "cloudflare-workers-ai",
242 "workers-ai": "cloudflare-workers-ai",
243 "hf": "huggingface",
244}
245
246// canonicalProvider normalises a user-supplied provider name: trims
247// surrounding whitespace, lower-cases it, and resolves any known alias
248// to its canonical id. Unknown names are returned trimmed/lower-cased
249// and unchanged so the existing unknown-provider handling still runs.
250func canonicalProvider(name string) string {
251 n := strings.ToLower(strings.TrimSpace(name))
252 if n == "" {
253 return n
254 }
255 if canon, ok := providerAliases[n]; ok {
256 return canon
257 }
258 return n
259}
260
261// Resolve merges args, config, and env into a Resolved set.
262//
263// Unlike the earlier version, Resolve NEVER returns an error for
264// missing credentials: the TUI can start without them and launch a
265// login flow. requireCred controls whether missing credentials are a
266// hard error (used by print/json modes).
267func Resolve(args Args, requireCred bool) (Resolved, error) {
268 cfg, _ := LoadConfig()
269
270 // User-requested provider (explicit > config > default).
271 // Normalise common aliases (e.g. "bedrock" -> "amazon-bedrock")
272 // before validation so an alias is never mistaken for an unknown
273 // provider and silently downgraded to anthropic.
274 argProvider := canonicalProvider(args.Provider)
275 provName := firstNonEmpty(argProvider, canonicalProvider(cfg.Provider), "anthropic")
276 if !isKnownProvider(provName) {
277 // Unknown provider (maybe removed or renamed). Fall back to
278 // the first provider that has credentials, or anthropic.
279 provName = "anthropic"
280 if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {
281 provName = "openai"
282 }
283 if _, _, _, err := ResolveCredentialFull("openai-codex", ""); err == nil {
284 provName = "openai-codex"
285 }
286 if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil {
287 provName = "kimi"
288 }
289 if _, _, _, err := ResolveCredentialFull("deepseek", ""); err == nil {
290 provName = "deepseek"
291 }
292 if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil {
293 provName = "anthropic"
294 }
295 // Reset the saved config so this doesn't keep happening.
296 cfg.Provider = provName
297 cfg.Model = ""
298 _ = SaveConfig(cfg)
299 }
300
301 var (
302 cred string
303 method string
304 accountID string
305 credErr error
306 )
307 if provName == "ollama" {
308 cred = firstNonEmpty(args.APIKey, "ollama")
309 method = "apikey"
310 } else {
311 cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
312 }
313
314 // If the user did NOT explicitly pick a provider and the default one
315 // has no credentials, auto-fall-back to whichever provider is actually
316 // logged in. That way running plain `zot` after `/login` (any provider)
317 // never shows a "not logged in" banner.
318 userPickedProvider := args.Provider != ""
319 if credErr != nil && !userPickedProvider && provName != "ollama" {
320 // Scan every known provider (not a hardcoded subset) so any
321 // env-based credential is discovered, e.g. an env-only
322 // amazon-bedrock setup (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE /
323 // IAM keys) when no config.json pins the provider, such as after
324 // pointing ZOT_HOME at a fresh home dir. Iteration order of
325 // knownProviders defines fallback priority. ollama is skipped:
326 // it has no credential and would always "match".
327 for _, other := range knownProviders {
328 if other == provName || other == "ollama" {
329 continue
330 }
331 if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
332 provName = other
333 cred, method, accountID, credErr = c, m, a, err
334 break
335 }
336 }
337 }
338
339 model := firstNonEmpty(args.Model, cfg.Model)
340 if model == "" {
341 if provName == "ollama" {
342 return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")
343 }
344 model = defaultModelForProvider(provName)
345 }
346 // If the resolved model belongs to a different provider (e.g. config
347 // says gpt-5 but we fell back to anthropic), pick that provider's default.
348 if provName != "ollama" {
349 if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
350 model = defaultModelForProvider(provName)
351 }
352 }
353 resolvedModel, err := provider.FindModel(provName, model)
354 if err != nil && provName == "ollama" {
355 // ollama is intentionally open-catalogue: any model id the
356 // local server understands is valid, even if not in the
357 // baked-in catalog.
358 resolvedModel = provider.Model{
359 Provider: "ollama",
360 ID: model,
361 DisplayName: model,
362 ContextWindow: 32768,
363 MaxOutput: 8192,
364 BaseURL: args.BaseURL,
365 Source: "ollama",
366 }
367 err = nil
368 }
369 if err != nil {
370 // The model the user (or persisted config) asked for is no
371 // longer in the active catalogue — they probably removed it
372 // from their models.json or upgraded zot and the id changed.
373 // Refusing to launch is the wrong move: it strands the user
374 // with no way to even open the TUI and pick a new model.
375 // Fall back to the provider's default, warn on stderr, and,
376 // when the stale id came from the persisted config (not an
377 // explicit --model flag), repair the config so the warning
378 // doesn't repeat on every launch.
379 fallback := defaultModelForProvider(provName)
380 fm, ferr := provider.FindModel(provName, fallback)
381 if ferr != nil {
382 // Even the provider default is gone (catastrophic
383 // catalogue trim). Last resort: any model on this
384 // provider, then the global DefaultModel.
385 if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 {
386 fm = candidates[0]
387 ferr = nil
388 } else {
389 fm = provider.DefaultModel
390 ferr = nil
391 }
392 }
393 fmt.Fprintf(os.Stderr,
394 "zot: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
395 model, fm.ID)
396 if args.Model == "" && cfg.Model == model {
397 cfg.Model = fm.ID
398 _ = SaveConfig(cfg)
399 }
400 resolvedModel = fm
401 model = fm.ID
402 }
403
404 // If the model defines a base URL (e.g. local ollama) and the
405 // user didn't pass --base-url, use the model's URL. For ollama,
406 // keep http://localhost:11434 as a fallback only after the model
407 // metadata has had a chance to provide a custom baseUrl.
408 if args.BaseURL == "" && resolvedModel.BaseURL != "" {
409 args.BaseURL = resolvedModel.BaseURL
410 }
411 if args.BaseURL == "" && provName == "ollama" {
412 args.BaseURL = "http://localhost:11434"
413 }
414
415 // If the model has a base URL, credentials are optional (local
416 // models like ollama don't need real API keys).
417 if resolvedModel.BaseURL != "" && credErr != nil {
418 cred = "ollama"
419 credErr = nil
420 requireCred = false
421 }
422
423 if credErr != nil && requireCred {
424 return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
425 credErr, envVarName(provName))
426 }
427
428 sandbox := tools.NewSandbox(args.CWD)
429 reg := buildToolRegistry(args, args.CWD, sandbox)
430
431 docsDir, _ := zotdocs.EnsureInstalled(ZotHome())
432
433 // Skill discovery: scan project + global locations + built-in
434 // skills shipped with the binary. If any are found, register
435 // the on-demand `skill` loader tool plus a system-prompt
436 // manifest so the model knows what's available.
437 //
438 // --no-skill bypasses the entire mechanism: no manifest in the
439 // system prompt, no `skill` tool in the registry. Useful for a
440 // clean-room run with zero extra context biasing the model.
441 var (
442 discovered []*skills.Skill
443 skillTool *skills.Tool
444 skillAddendum string
445 )
446 if !args.NoSkill {
447 homeDir, _ := os.UserHomeDir()
448 discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
449 if len(discovered) > 0 {
450 skillTool = skills.NewTool(discovered)
451 reg[skillTool.Name()] = skillTool
452 skillAddendum = skills.SystemPromptAddendum(discovered)
453 }
454 }
455 _ = skillTool
456
457 summaries := toolSummaries(reg, args)
458
459 append_ := append([]string(nil), args.AppendSystemPrompt...)
460 if agentsAddendum := readAgentsContext(args.CWD, ZotHome()); agentsAddendum != "" {
461 append_ = append(append_, agentsAddendum)
462 }
463 if skillAddendum != "" {
464 append_ = append(append_, skillAddendum)
465 }
466 if AutoSwarmEnabled() {
467 append_ = append(append_, AutoSwarmSystemAddendum)
468 }
469
470 // Custom system prompt resolution order:
471 // 1. --system-prompt flag (highest priority; ad-hoc per run)
472 // 2. $ZOT_HOME/SYSTEM.md (persistent user override)
473 // 3. built-in default (defaultIdentity + defaultGuidelines)
474 custom := args.SystemPrompt
475 if custom == "" {
476 custom = readUserSystemPrompt(ZotHome())
477 }
478
479 sys := BuildSystemPrompt(SystemPromptOpts{
480 CWD: args.CWD,
481 Tools: summaries,
482 Custom: custom,
483 Append: append_,
484 ZotDocsDir: docsDir,
485 })
486
487 reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
488
489 max := args.MaxSteps // 0 = unlimited
490
491 return Resolved{
492 Provider: provName,
493 Model: model,
494 Credential: cred,
495 AuthMethod: method,
496 AccountID: accountID,
497 BaseURL: args.BaseURL,
498 CWD: args.CWD,
499 Reasoning: reasoning,
500 ToolRegistry: reg,
501 ToolSummary: summaries,
502 SystemPrompt: sys,
503 MaxSteps: max,
504 Sandbox: sandbox,
505 SkillTool: skillTool,
506 systemAppend: append_,
507 systemCustom: custom,
508 toolDescriptions: descMapFromSummaries(summaries),
509 }, nil
510}
511
512// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
513// trimmed contents, or "" when the file is missing / unreadable /
514// empty. Errors are intentionally swallowed: the file is optional,
515// and any failure to read it should fall back to the built-in
516// default system prompt rather than crash the run.
517func readUserSystemPrompt(zotHome string) string {
518 if zotHome == "" {
519 return ""
520 }
521 path := filepath.Join(zotHome, "SYSTEM.md")
522 raw, err := os.ReadFile(path)
523 if err != nil {
524 return ""
525 }
526 return strings.TrimSpace(string(raw))
527}
528
529// readAgentsContext loads optional AGENTS.md instruction files. No
530// default file is created or required: zot only includes files that
531// already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first,
532// followed by project instructions from the top-most parent down to cwd.
533func readAgentsContext(cwd, zotHome string) string {
534 type contextFile struct {
535 path string
536 content string
537 }
538 var files []contextFile
539 seen := map[string]bool{}
540 add := func(path string) {
541 if path == "" {
542 return
543 }
544 abs, err := filepath.Abs(path)
545 if err == nil {
546 path = abs
547 }
548 if seen[path] {
549 return
550 }
551 raw, err := os.ReadFile(path)
552 if err != nil {
553 return
554 }
555 content := strings.TrimSpace(string(raw))
556 if content == "" {
557 return
558 }
559 seen[path] = true
560 files = append(files, contextFile{path: path, content: content})
561 }
562 addFirstFromDir := func(dir string) {
563 if dir == "" {
564 return
565 }
566 for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
567 path := filepath.Join(dir, name)
568 if _, err := os.Stat(path); err == nil {
569 add(path)
570 return
571 }
572 }
573 }
574
575 addFirstFromDir(zotHome)
576
577 if cwd != "" {
578 abs, err := filepath.Abs(cwd)
579 if err == nil {
580 cwd = abs
581 }
582 var dirs []string
583 for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
584 dirs = append(dirs, dir)
585 parent := filepath.Dir(dir)
586 if parent == dir {
587 break
588 }
589 }
590 for i := len(dirs) - 1; i >= 0; i-- {
591 addFirstFromDir(dirs[i])
592 }
593 }
594
595 if len(files) == 0 {
596 return ""
597 }
598 var sb strings.Builder
599 sb.WriteString("Project context instructions loaded from AGENTS.md files. Follow them when working in this repository. Later files are more specific and may override earlier ones.\n")
600 for _, f := range files {
601 fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.path, f.content)
602 }
603 return strings.TrimSpace(sb.String())
604}
605
606// descMapFromSummaries indexes the human-readable descriptions for
607// the renderToolsSection rebuild path.
608func descMapFromSummaries(summaries []ToolSummary) map[string]string {
609 out := make(map[string]string, len(summaries))
610 for _, s := range summaries {
611 out[s.Name] = s.Description
612 }
613 return out
614}
615
616// NewClient returns a provider.Client for r, choosing the auth mode
617// based on r.AuthMethod. Panics if no credential is present; callers
618// must check HasCredential() first.
619func (r Resolved) NewClient() provider.Client {
620 if !r.HasCredential() {
621 panic("NewClient called without credential; check HasCredential first")
622 }
623 switch r.Provider {
624 case "ollama":
625 return provider.NewOpenAI(r.Credential, r.BaseURL)
626 case "kimi":
627 // kimi-coding speaks anthropic-messages on api.kimi.com/coding.
628 // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
629 inner := provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())
630 if r.AuthMethod == "oauth" {
631 return r.wrapWithRefresh(inner)
632 }
633 return inner
634 case "moonshotai":
635 return provider.NewMoonshot(r.Credential, r.BaseURL)
636 case "moonshotai-cn":
637 return provider.NewMoonshotCN(r.Credential, r.BaseURL)
638 case "deepseek":
639 return provider.NewDeepSeek(r.Credential, r.BaseURL)
640 case "openai":
641 return provider.NewOpenAI(r.Credential, r.BaseURL)
642 case "openai-codex":
643 inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
644 return r.wrapWithRefresh(inner)
645 case "openai-responses":
646 // Public OpenAI Responses API (api.openai.com/v1/responses) via
647 // API key. Separate provider from `openai` (Chat Completions) and
648 // from `openai-codex` (ChatGPT subscription OAuth).
649 return provider.NewOpenAIResponses(r.Credential, r.BaseURL)
650 case "google":
651 return provider.NewGemini(r.Credential, r.BaseURL)
652 case "cerebras":
653 return provider.NewCerebras(r.Credential, r.BaseURL)
654 case "groq":
655 return provider.NewGroq(r.Credential, r.BaseURL)
656 case "xai":
657 return provider.NewXAI(r.Credential, r.BaseURL)
658 case "together":
659 return provider.NewTogether(r.Credential, r.BaseURL)
660 case "huggingface":
661 return provider.NewHuggingFace(r.Credential, r.BaseURL)
662 case "openrouter":
663 return provider.NewOpenRouter(r.Credential, r.BaseURL)
664 case "zai":
665 return provider.NewZAI(r.Credential, r.BaseURL)
666 case "xiaomi":
667 return provider.NewXiaomi(r.Credential, r.BaseURL)
668 case "xiaomi-token-plan-ams":
669 return provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL)
670 case "xiaomi-token-plan-cn":
671 return provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL)
672 case "xiaomi-token-plan-sgp":
673 return provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL)
674 case "opencode":
675 return provider.NewOpenCode(r.Credential, r.BaseURL)
676 case "opencode-go":
677 return provider.NewOpenCodeGo(r.Credential, r.BaseURL)
678 case "minimax":
679 return provider.NewMinimaxAnthropic(r.Credential, r.BaseURL)
680 case "minimax-cn":
681 return provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL)
682 case "fireworks":
683 return provider.NewFireworksAnthropic(r.Credential, r.BaseURL)
684 case "vercel-ai-gateway":
685 return provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL)
686 case "mistral":
687 return provider.NewMistral(r.Credential, r.BaseURL)
688 case "amazon-bedrock":
689 return provider.NewBedrock(r.Credential, r.BaseURL)
690 case "google-vertex":
691 return provider.NewGoogleVertex(r.Credential, r.BaseURL)
692 case "azure-openai-responses":
693 return provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL)
694 case "github-copilot":
695 return provider.NewGithubCopilot(r.Credential, r.BaseURL)
696 case "cloudflare-workers-ai":
697 return provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL)
698 case "cloudflare-ai-gateway":
699 return provider.NewCloudflareAIGateway(r.Credential, r.BaseURL)
700 default:
701 if r.AuthMethod == "oauth" {
702 inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
703 return r.wrapWithRefresh(inner)
704 }
705 return provider.NewAnthropic(r.Credential, r.BaseURL)
706 }
707}
708
709// wrapWithRefresh wraps an OAuth client so the access token is
710// refreshed automatically before each API call. Without this, long
711// sessions (hours) silently fail when the 1-hour token expires.
712func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
713 provName := r.Provider
714 tokenProvider := provName
715 if provName == "openai-codex" {
716 tokenProvider = "openai"
717 }
718 baseURL := r.BaseURL
719 accountID := r.AccountID
720
721 refreshFn := func(ctx context.Context) (string, error) {
722 tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
723 if err != nil {
724 return "", err
725 }
726 return tok.AccessToken, nil
727 }
728
729 factory := func(token string) provider.Client {
730 switch provName {
731 case "openai-codex":
732 return provider.NewOpenAICodex(token, accountID, baseURL)
733 case "kimi":
734 // anthropic-messages on api.kimi.com/coding.
735 return provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders())
736 default:
737 return provider.NewAnthropicOAuth(token, baseURL)
738 }
739 }
740
741 return provider.NewRefreshingClient(inner, refreshFn, factory)
742}
743
744// UseSandbox replaces the sandbox pointer that every tool in r's
745// registry references. Used to keep the /jail state stable across
746// agent rebuilds (e.g. /login, /model switching providers).
747func (r *Resolved) UseSandbox(s *tools.Sandbox) {
748 if s == nil || r == nil {
749 return
750 }
751 r.Sandbox = s
752 for name, t := range r.ToolRegistry {
753 switch v := t.(type) {
754 case *tools.ReadTool:
755 v.Sandbox = s
756 case *tools.WriteTool:
757 v.Sandbox = s
758 case *tools.EditTool:
759 v.Sandbox = s
760 case *tools.BashTool:
761 v.Sandbox = s
762 }
763 _ = name
764 }
765}
766
767// NewAgent constructs a core.Agent from r. Requires a credential.
768func (r Resolved) NewAgent() *core.Agent {
769 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
770 a.MaxSteps = r.MaxSteps
771 a.Reasoning = r.Reasoning
772 return a
773}
774
775func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
776 if args.NoTools {
777 return core.Registry{}
778 }
779 all := map[string]core.Tool{
780 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
781 "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
782 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox},
783 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
784 }
785 reg := core.Registry{}
786 if len(args.Tools) == 0 {
787 for _, t := range all {
788 reg[t.Name()] = t
789 }
790 return reg
791 }
792 for _, name := range args.Tools {
793 if t, ok := all[name]; ok {
794 reg[name] = t
795 }
796 }
797 return reg
798}
799
800func toolSummaries(reg core.Registry, args Args) []ToolSummary {
801 order := []string{"read", "write", "edit", "bash"}
802 var out []ToolSummary
803 for _, name := range order {
804 if t, ok := reg[name]; ok {
805 out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
806 }
807 }
808 return out
809}
810
811func firstNonEmpty(vals ...string) string {
812 for _, v := range vals {
813 if v != "" {
814 return v
815 }
816 }
817 return ""
818}
819
820func kimiCodeHeaders() map[string]string {
821 host, _ := os.Hostname()
822 if host == "" {
823 host = "unknown"
824 }
825 deviceID := ""
826 if home, err := os.UserHomeDir(); err == nil {
827 if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
828 deviceID = strings.TrimSpace(string(b))
829 }
830 }
831 if deviceID == "" {
832 deviceID = "zot"
833 }
834 return map[string]string{
835 "User-Agent": "KimiCLI/1.41.0",
836 "X-Msh-Platform": "kimi_cli",
837 "X-Msh-Version": "1.41.0",
838 "X-Msh-Device-Name": host,
839 "X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
840 "X-Msh-Os-Version": runtime.GOOS,
841 "X-Msh-Device-Id": deviceID,
842 }
843}
844
845func envVarName(provider string) string {
846 switch provider {
847 case "openai", "openai-codex", "openai-responses":
848 return "OPENAI"
849 case "kimi":
850 return "KIMI"
851 case "deepseek":
852 return "DEEPSEEK"
853 case "google":
854 return "GEMINI"
855 case "ollama":
856 return "OLLAMA"
857 case "moonshotai", "moonshotai-cn":
858 return "MOONSHOT"
859 case "groq":
860 return "GROQ"
861 case "xai":
862 return "XAI"
863 case "cerebras":
864 return "CEREBRAS"
865 case "together":
866 return "TOGETHER"
867 case "huggingface":
868 return "HF"
869 case "openrouter":
870 return "OPENROUTER"
871 case "mistral":
872 return "MISTRAL"
873 case "zai":
874 return "ZAI"
875 case "xiaomi":
876 return "XIAOMI"
877 case "xiaomi-token-plan-ams":
878 return "XIAOMI_TOKEN_PLAN_AMS"
879 case "xiaomi-token-plan-cn":
880 return "XIAOMI_TOKEN_PLAN_CN"
881 case "xiaomi-token-plan-sgp":
882 return "XIAOMI_TOKEN_PLAN_SGP"
883 case "minimax":
884 return "MINIMAX"
885 case "minimax-cn":
886 return "MINIMAX_CN"
887 case "fireworks":
888 return "FIREWORKS"
889 case "vercel-ai-gateway":
890 return "AI_GATEWAY"
891 case "opencode", "opencode-go":
892 return "OPENCODE"
893 case "github-copilot":
894 return "COPILOT_GITHUB_TOKEN"
895 case "cloudflare-workers-ai", "cloudflare-ai-gateway":
896 return "CLOUDFLARE"
897 case "amazon-bedrock":
898 return "AWS"
899 case "google-vertex":
900 return "GOOGLE_CLOUD"
901 case "azure-openai-responses":
902 return "AZURE_OPENAI"
903 default:
904 return "ANTHROPIC"
905 }
906}