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