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