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