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 // amazon-bedrock is included so an env-only bedrock setup
321 // (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE / IAM keys) is still
322 // discovered when no config.json pins the provider, e.g. after
323 // pointing ZOT_HOME at a fresh home dir.
324 for _, other := range []string{"anthropic", "openai", "openai-codex", "kimi", "deepseek", "google", "amazon-bedrock"} {
325 if other == provName {
326 continue
327 }
328 if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
329 provName = other
330 cred, method, accountID, credErr = c, m, a, err
331 break
332 }
333 }
334 }
335
336 model := firstNonEmpty(args.Model, cfg.Model)
337 if model == "" {
338 if provName == "ollama" {
339 return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")
340 }
341 model = defaultModelForProvider(provName)
342 }
343 // If the resolved model belongs to a different provider (e.g. config
344 // says gpt-5 but we fell back to anthropic), pick that provider's default.
345 if provName != "ollama" {
346 if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
347 model = defaultModelForProvider(provName)
348 }
349 }
350 resolvedModel, err := provider.FindModel(provName, model)
351 if err != nil && provName == "ollama" {
352 // ollama is intentionally open-catalogue: any model id the
353 // local server understands is valid, even if not in the
354 // baked-in catalog.
355 resolvedModel = provider.Model{
356 Provider: "ollama",
357 ID: model,
358 DisplayName: model,
359 ContextWindow: 32768,
360 MaxOutput: 8192,
361 BaseURL: args.BaseURL,
362 Source: "ollama",
363 }
364 err = nil
365 }
366 if err != nil {
367 // The model the user (or persisted config) asked for is no
368 // longer in the active catalogue — they probably removed it
369 // from their models.json or upgraded zot and the id changed.
370 // Refusing to launch is the wrong move: it strands the user
371 // with no way to even open the TUI and pick a new model.
372 // Fall back to the provider's default, warn on stderr, and,
373 // when the stale id came from the persisted config (not an
374 // explicit --model flag), repair the config so the warning
375 // doesn't repeat on every launch.
376 fallback := defaultModelForProvider(provName)
377 fm, ferr := provider.FindModel(provName, fallback)
378 if ferr != nil {
379 // Even the provider default is gone (catastrophic
380 // catalogue trim). Last resort: any model on this
381 // provider, then the global DefaultModel.
382 if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 {
383 fm = candidates[0]
384 ferr = nil
385 } else {
386 fm = provider.DefaultModel
387 ferr = nil
388 }
389 }
390 fmt.Fprintf(os.Stderr,
391 "zot: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
392 model, fm.ID)
393 if args.Model == "" && cfg.Model == model {
394 cfg.Model = fm.ID
395 _ = SaveConfig(cfg)
396 }
397 resolvedModel = fm
398 model = fm.ID
399 }
400
401 // If the model defines a base URL (e.g. local ollama) and the
402 // user didn't pass --base-url, use the model's URL. For ollama,
403 // keep http://localhost:11434 as a fallback only after the model
404 // metadata has had a chance to provide a custom baseUrl.
405 if args.BaseURL == "" && resolvedModel.BaseURL != "" {
406 args.BaseURL = resolvedModel.BaseURL
407 }
408 if args.BaseURL == "" && provName == "ollama" {
409 args.BaseURL = "http://localhost:11434"
410 }
411
412 // If the model has a base URL, credentials are optional (local
413 // models like ollama don't need real API keys).
414 if resolvedModel.BaseURL != "" && credErr != nil {
415 cred = "ollama"
416 credErr = nil
417 requireCred = false
418 }
419
420 if credErr != nil && requireCred {
421 return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
422 credErr, envVarName(provName))
423 }
424
425 sandbox := tools.NewSandbox(args.CWD)
426 reg := buildToolRegistry(args, args.CWD, sandbox)
427
428 docsDir, _ := zotdocs.EnsureInstalled(ZotHome())
429
430 // Skill discovery: scan project + global locations + built-in
431 // skills shipped with the binary. If any are found, register
432 // the on-demand `skill` loader tool plus a system-prompt
433 // manifest so the model knows what's available.
434 //
435 // --no-skill bypasses the entire mechanism: no manifest in the
436 // system prompt, no `skill` tool in the registry. Useful for a
437 // clean-room run with zero extra context biasing the model.
438 var (
439 discovered []*skills.Skill
440 skillTool *skills.Tool
441 skillAddendum string
442 )
443 if !args.NoSkill {
444 homeDir, _ := os.UserHomeDir()
445 discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
446 if len(discovered) > 0 {
447 skillTool = skills.NewTool(discovered)
448 reg[skillTool.Name()] = skillTool
449 skillAddendum = skills.SystemPromptAddendum(discovered)
450 }
451 }
452 _ = skillTool
453
454 summaries := toolSummaries(reg, args)
455
456 append_ := append([]string(nil), args.AppendSystemPrompt...)
457 if agentsAddendum := readAgentsContext(args.CWD, ZotHome()); agentsAddendum != "" {
458 append_ = append(append_, agentsAddendum)
459 }
460 if skillAddendum != "" {
461 append_ = append(append_, skillAddendum)
462 }
463 if AutoSwarmEnabled() {
464 append_ = append(append_, AutoSwarmSystemAddendum)
465 }
466
467 // Custom system prompt resolution order:
468 // 1. --system-prompt flag (highest priority; ad-hoc per run)
469 // 2. $ZOT_HOME/SYSTEM.md (persistent user override)
470 // 3. built-in default (defaultIdentity + defaultGuidelines)
471 custom := args.SystemPrompt
472 if custom == "" {
473 custom = readUserSystemPrompt(ZotHome())
474 }
475
476 sys := BuildSystemPrompt(SystemPromptOpts{
477 CWD: args.CWD,
478 Tools: summaries,
479 Custom: custom,
480 Append: append_,
481 ZotDocsDir: docsDir,
482 })
483
484 reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
485
486 max := args.MaxSteps // 0 = unlimited
487
488 return Resolved{
489 Provider: provName,
490 Model: model,
491 Credential: cred,
492 AuthMethod: method,
493 AccountID: accountID,
494 BaseURL: args.BaseURL,
495 CWD: args.CWD,
496 Reasoning: reasoning,
497 ToolRegistry: reg,
498 ToolSummary: summaries,
499 SystemPrompt: sys,
500 MaxSteps: max,
501 Sandbox: sandbox,
502 SkillTool: skillTool,
503 systemAppend: append_,
504 systemCustom: custom,
505 toolDescriptions: descMapFromSummaries(summaries),
506 }, nil
507}
508
509// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
510// trimmed contents, or "" when the file is missing / unreadable /
511// empty. Errors are intentionally swallowed: the file is optional,
512// and any failure to read it should fall back to the built-in
513// default system prompt rather than crash the run.
514func readUserSystemPrompt(zotHome string) string {
515 if zotHome == "" {
516 return ""
517 }
518 path := filepath.Join(zotHome, "SYSTEM.md")
519 raw, err := os.ReadFile(path)
520 if err != nil {
521 return ""
522 }
523 return strings.TrimSpace(string(raw))
524}
525
526// readAgentsContext loads optional AGENTS.md instruction files. No
527// default file is created or required: zot only includes files that
528// already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first,
529// followed by project instructions from the top-most parent down to cwd.
530func readAgentsContext(cwd, zotHome string) string {
531 type contextFile struct {
532 path string
533 content string
534 }
535 var files []contextFile
536 seen := map[string]bool{}
537 add := func(path string) {
538 if path == "" {
539 return
540 }
541 abs, err := filepath.Abs(path)
542 if err == nil {
543 path = abs
544 }
545 if seen[path] {
546 return
547 }
548 raw, err := os.ReadFile(path)
549 if err != nil {
550 return
551 }
552 content := strings.TrimSpace(string(raw))
553 if content == "" {
554 return
555 }
556 seen[path] = true
557 files = append(files, contextFile{path: path, content: content})
558 }
559 addFirstFromDir := func(dir string) {
560 if dir == "" {
561 return
562 }
563 for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
564 path := filepath.Join(dir, name)
565 if _, err := os.Stat(path); err == nil {
566 add(path)
567 return
568 }
569 }
570 }
571
572 addFirstFromDir(zotHome)
573
574 if cwd != "" {
575 abs, err := filepath.Abs(cwd)
576 if err == nil {
577 cwd = abs
578 }
579 var dirs []string
580 for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
581 dirs = append(dirs, dir)
582 parent := filepath.Dir(dir)
583 if parent == dir {
584 break
585 }
586 }
587 for i := len(dirs) - 1; i >= 0; i-- {
588 addFirstFromDir(dirs[i])
589 }
590 }
591
592 if len(files) == 0 {
593 return ""
594 }
595 var sb strings.Builder
596 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")
597 for _, f := range files {
598 fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.path, f.content)
599 }
600 return strings.TrimSpace(sb.String())
601}
602
603// descMapFromSummaries indexes the human-readable descriptions for
604// the renderToolsSection rebuild path.
605func descMapFromSummaries(summaries []ToolSummary) map[string]string {
606 out := make(map[string]string, len(summaries))
607 for _, s := range summaries {
608 out[s.Name] = s.Description
609 }
610 return out
611}
612
613// NewClient returns a provider.Client for r, choosing the auth mode
614// based on r.AuthMethod. Panics if no credential is present; callers
615// must check HasCredential() first.
616func (r Resolved) NewClient() provider.Client {
617 if !r.HasCredential() {
618 panic("NewClient called without credential; check HasCredential first")
619 }
620 switch r.Provider {
621 case "ollama":
622 return provider.NewOpenAI(r.Credential, r.BaseURL)
623 case "kimi":
624 // kimi-coding speaks anthropic-messages on api.kimi.com/coding.
625 // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
626 inner := provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())
627 if r.AuthMethod == "oauth" {
628 return r.wrapWithRefresh(inner)
629 }
630 return inner
631 case "moonshotai":
632 return provider.NewMoonshot(r.Credential, r.BaseURL)
633 case "moonshotai-cn":
634 return provider.NewMoonshotCN(r.Credential, r.BaseURL)
635 case "deepseek":
636 return provider.NewDeepSeek(r.Credential, r.BaseURL)
637 case "openai":
638 return provider.NewOpenAI(r.Credential, r.BaseURL)
639 case "openai-codex":
640 inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
641 return r.wrapWithRefresh(inner)
642 case "openai-responses":
643 // Public OpenAI Responses API (api.openai.com/v1/responses) via
644 // API key. Separate provider from `openai` (Chat Completions) and
645 // from `openai-codex` (ChatGPT subscription OAuth).
646 return provider.NewOpenAIResponses(r.Credential, r.BaseURL)
647 case "google":
648 return provider.NewGemini(r.Credential, r.BaseURL)
649 case "cerebras":
650 return provider.NewCerebras(r.Credential, r.BaseURL)
651 case "groq":
652 return provider.NewGroq(r.Credential, r.BaseURL)
653 case "xai":
654 return provider.NewXAI(r.Credential, r.BaseURL)
655 case "together":
656 return provider.NewTogether(r.Credential, r.BaseURL)
657 case "huggingface":
658 return provider.NewHuggingFace(r.Credential, r.BaseURL)
659 case "openrouter":
660 return provider.NewOpenRouter(r.Credential, r.BaseURL)
661 case "zai":
662 return provider.NewZAI(r.Credential, r.BaseURL)
663 case "xiaomi":
664 return provider.NewXiaomi(r.Credential, r.BaseURL)
665 case "xiaomi-token-plan-ams":
666 return provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL)
667 case "xiaomi-token-plan-cn":
668 return provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL)
669 case "xiaomi-token-plan-sgp":
670 return provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL)
671 case "opencode":
672 return provider.NewOpenCode(r.Credential, r.BaseURL)
673 case "opencode-go":
674 return provider.NewOpenCodeGo(r.Credential, r.BaseURL)
675 case "minimax":
676 return provider.NewMinimaxAnthropic(r.Credential, r.BaseURL)
677 case "minimax-cn":
678 return provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL)
679 case "fireworks":
680 return provider.NewFireworksAnthropic(r.Credential, r.BaseURL)
681 case "vercel-ai-gateway":
682 return provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL)
683 case "mistral":
684 return provider.NewMistral(r.Credential, r.BaseURL)
685 case "amazon-bedrock":
686 return provider.NewBedrock(r.Credential, r.BaseURL)
687 case "google-vertex":
688 return provider.NewGoogleVertex(r.Credential, r.BaseURL)
689 case "azure-openai-responses":
690 return provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL)
691 case "github-copilot":
692 return provider.NewGithubCopilot(r.Credential, r.BaseURL)
693 case "cloudflare-workers-ai":
694 return provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL)
695 case "cloudflare-ai-gateway":
696 return provider.NewCloudflareAIGateway(r.Credential, r.BaseURL)
697 default:
698 if r.AuthMethod == "oauth" {
699 inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
700 return r.wrapWithRefresh(inner)
701 }
702 return provider.NewAnthropic(r.Credential, r.BaseURL)
703 }
704}
705
706// wrapWithRefresh wraps an OAuth client so the access token is
707// refreshed automatically before each API call. Without this, long
708// sessions (hours) silently fail when the 1-hour token expires.
709func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
710 provName := r.Provider
711 tokenProvider := provName
712 if provName == "openai-codex" {
713 tokenProvider = "openai"
714 }
715 baseURL := r.BaseURL
716 accountID := r.AccountID
717
718 refreshFn := func(ctx context.Context) (string, error) {
719 tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
720 if err != nil {
721 return "", err
722 }
723 return tok.AccessToken, nil
724 }
725
726 factory := func(token string) provider.Client {
727 switch provName {
728 case "openai-codex":
729 return provider.NewOpenAICodex(token, accountID, baseURL)
730 case "kimi":
731 // anthropic-messages on api.kimi.com/coding.
732 return provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders())
733 default:
734 return provider.NewAnthropicOAuth(token, baseURL)
735 }
736 }
737
738 return provider.NewRefreshingClient(inner, refreshFn, factory)
739}
740
741// UseSandbox replaces the sandbox pointer that every tool in r's
742// registry references. Used to keep the /jail state stable across
743// agent rebuilds (e.g. /login, /model switching providers).
744func (r *Resolved) UseSandbox(s *tools.Sandbox) {
745 if s == nil || r == nil {
746 return
747 }
748 r.Sandbox = s
749 for name, t := range r.ToolRegistry {
750 switch v := t.(type) {
751 case *tools.ReadTool:
752 v.Sandbox = s
753 case *tools.WriteTool:
754 v.Sandbox = s
755 case *tools.EditTool:
756 v.Sandbox = s
757 case *tools.BashTool:
758 v.Sandbox = s
759 }
760 _ = name
761 }
762}
763
764// NewAgent constructs a core.Agent from r. Requires a credential.
765func (r Resolved) NewAgent() *core.Agent {
766 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
767 a.MaxSteps = r.MaxSteps
768 a.Reasoning = r.Reasoning
769 return a
770}
771
772func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
773 if args.NoTools {
774 return core.Registry{}
775 }
776 all := map[string]core.Tool{
777 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
778 "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
779 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox},
780 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
781 }
782 reg := core.Registry{}
783 if len(args.Tools) == 0 {
784 for _, t := range all {
785 reg[t.Name()] = t
786 }
787 return reg
788 }
789 for _, name := range args.Tools {
790 if t, ok := all[name]; ok {
791 reg[name] = t
792 }
793 }
794 return reg
795}
796
797func toolSummaries(reg core.Registry, args Args) []ToolSummary {
798 order := []string{"read", "write", "edit", "bash"}
799 var out []ToolSummary
800 for _, name := range order {
801 if t, ok := reg[name]; ok {
802 out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
803 }
804 }
805 return out
806}
807
808func firstNonEmpty(vals ...string) string {
809 for _, v := range vals {
810 if v != "" {
811 return v
812 }
813 }
814 return ""
815}
816
817func kimiCodeHeaders() map[string]string {
818 host, _ := os.Hostname()
819 if host == "" {
820 host = "unknown"
821 }
822 deviceID := ""
823 if home, err := os.UserHomeDir(); err == nil {
824 if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
825 deviceID = strings.TrimSpace(string(b))
826 }
827 }
828 if deviceID == "" {
829 deviceID = "zot"
830 }
831 return map[string]string{
832 "User-Agent": "KimiCLI/1.41.0",
833 "X-Msh-Platform": "kimi_cli",
834 "X-Msh-Version": "1.41.0",
835 "X-Msh-Device-Name": host,
836 "X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
837 "X-Msh-Os-Version": runtime.GOOS,
838 "X-Msh-Device-Id": deviceID,
839 }
840}
841
842func envVarName(provider string) string {
843 switch provider {
844 case "openai", "openai-codex", "openai-responses":
845 return "OPENAI"
846 case "kimi":
847 return "KIMI"
848 case "deepseek":
849 return "DEEPSEEK"
850 case "google":
851 return "GEMINI"
852 case "ollama":
853 return "OLLAMA"
854 case "moonshotai", "moonshotai-cn":
855 return "MOONSHOT"
856 case "groq":
857 return "GROQ"
858 case "xai":
859 return "XAI"
860 case "cerebras":
861 return "CEREBRAS"
862 case "together":
863 return "TOGETHER"
864 case "huggingface":
865 return "HF"
866 case "openrouter":
867 return "OPENROUTER"
868 case "mistral":
869 return "MISTRAL"
870 case "zai":
871 return "ZAI"
872 case "xiaomi":
873 return "XIAOMI"
874 case "xiaomi-token-plan-ams":
875 return "XIAOMI_TOKEN_PLAN_AMS"
876 case "xiaomi-token-plan-cn":
877 return "XIAOMI_TOKEN_PLAN_CN"
878 case "xiaomi-token-plan-sgp":
879 return "XIAOMI_TOKEN_PLAN_SGP"
880 case "minimax":
881 return "MINIMAX"
882 case "minimax-cn":
883 return "MINIMAX_CN"
884 case "fireworks":
885 return "FIREWORKS"
886 case "vercel-ai-gateway":
887 return "AI_GATEWAY"
888 case "opencode", "opencode-go":
889 return "OPENCODE"
890 case "github-copilot":
891 return "COPILOT_GITHUB_TOKEN"
892 case "cloudflare-workers-ai", "cloudflare-ai-gateway":
893 return "CLOUDFLARE"
894 case "amazon-bedrock":
895 return "AWS"
896 case "google-vertex":
897 return "GOOGLE_CLOUD"
898 case "azure-openai-responses":
899 return "AZURE_OPENAI"
900 default:
901 return "ANTHROPIC"
902 }
903}