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 // Custom providers: pick the first model from the catalog for
196 // that provider, or fall back to the global default.
197 if models := provider.ModelsForProvider(prov); len(models) > 0 {
198 return models[0].ID
199 }
200 return provider.DefaultModel.ID
201 }
202}
203
204// knownProviders is the set of provider ids zot recognises. Used by
205// Resolve to validate args.Provider, by extension-callers, and by the
206// auto-fallback logic that picks any logged-in provider when the user's
207// preferred one has no credentials.
208var knownProviders = []string{
209 "anthropic", "openai", "openai-codex", "openai-responses", "kimi", "deepseek", "google", "ollama",
210 "moonshotai", "moonshotai-cn",
211 "cerebras", "groq", "xai", "together", "huggingface", "openrouter",
212 "mistral", "zai",
213 "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp",
214 "minimax", "minimax-cn",
215 "fireworks", "vercel-ai-gateway",
216 "opencode", "opencode-go",
217 "amazon-bedrock", "google-vertex", "azure-openai-responses",
218 "github-copilot", "cloudflare-workers-ai", "cloudflare-ai-gateway",
219}
220
221func isKnownProvider(name string) bool {
222 for _, p := range knownProviders {
223 if p == name {
224 return true
225 }
226 }
227 _, ok := provider.CustomProviders()[name]
228 return ok
229}
230
231// isBuiltinProvider reports whether name is in the hardcoded
232// knownProviders list (not a user-defined custom provider).
233func isBuiltinProvider(name string) bool {
234 for _, p := range knownProviders {
235 if p == name {
236 return true
237 }
238 }
239 return false
240}
241
242// providerAliases maps common short / alternate provider names to the
243// canonical id in knownProviders. Users (and other agents) reach for
244// "bedrock" or "vertex" far more naturally than the fully-qualified
245// "amazon-bedrock" / "google-vertex"; without this mapping an alias is
246// treated as an unknown provider and Resolve silently falls back to
247// anthropic, producing a misleading "no credential for anthropic" error
248// after the user explicitly picked, say, bedrock.
249var providerAliases = map[string]string{
250 "bedrock": "amazon-bedrock",
251 "aws-bedrock": "amazon-bedrock",
252 "amazon": "amazon-bedrock",
253 "vertex": "google-vertex",
254 "gcp-vertex": "google-vertex",
255 "gemini": "google",
256 "googleai": "google",
257 "google-ai": "google",
258 "azure": "azure-openai-responses",
259 "azure-openai": "azure-openai-responses",
260 "copilot": "github-copilot",
261 "github": "github-copilot",
262 "codex": "openai-codex",
263 "moonshot": "moonshotai",
264 "kimi-code": "kimi",
265 "ai-gateway": "vercel-ai-gateway",
266 "vercel": "vercel-ai-gateway",
267 "cloudflare": "cloudflare-workers-ai",
268 "workers-ai": "cloudflare-workers-ai",
269 "hf": "huggingface",
270}
271
272// canonicalProvider normalises a user-supplied provider name: trims
273// surrounding whitespace, lower-cases it, and resolves any known alias
274// to its canonical id. Unknown names are returned trimmed/lower-cased
275// and unchanged so the existing unknown-provider handling still runs.
276func canonicalProvider(name string) string {
277 n := strings.ToLower(strings.TrimSpace(name))
278 if n == "" {
279 return n
280 }
281 if canon, ok := providerAliases[n]; ok {
282 return canon
283 }
284 return n
285}
286
287// Resolve merges args, config, and env into a Resolved set.
288//
289// Unlike the earlier version, Resolve NEVER returns an error for
290// missing credentials: the TUI can start without them and launch a
291// login flow. requireCred controls whether missing credentials are a
292// hard error (used by print/json modes).
293func Resolve(args Args, requireCred bool) (Resolved, error) {
294 cfg, _ := LoadConfig()
295
296 // User-requested provider (explicit > config > default).
297 // Normalise common aliases (e.g. "bedrock" -> "amazon-bedrock")
298 // before validation so an alias is never mistaken for an unknown
299 // provider and silently downgraded to anthropic.
300 argProvider := canonicalProvider(args.Provider)
301 provName := firstNonEmpty(argProvider, canonicalProvider(cfg.Provider), "anthropic")
302 if !isKnownProvider(provName) {
303 // Unknown provider (maybe removed or renamed). Fall back to
304 // the first provider that has credentials, or anthropic.
305 // Custom providers (from models.json) are already accepted
306 // by isKnownProvider, so we only reach here for truly unknown
307 // names.
308 provName = "anthropic"
309 if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {
310 provName = "openai"
311 }
312 if _, _, _, err := ResolveCredentialFull("openai-codex", ""); err == nil {
313 provName = "openai-codex"
314 }
315 if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil {
316 provName = "kimi"
317 }
318 if _, _, _, err := ResolveCredentialFull("deepseek", ""); err == nil {
319 provName = "deepseek"
320 }
321 if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil {
322 provName = "anthropic"
323 }
324 // Reset the saved config so this doesn't keep happening.
325 cfg.Provider = provName
326 cfg.Model = ""
327 _ = SaveConfig(cfg)
328 }
329
330 var (
331 cred string
332 method string
333 accountID string
334 credErr error
335 )
336 if provName == "ollama" {
337 cred = firstNonEmpty(args.APIKey, "ollama")
338 method = "apikey"
339 } else {
340 cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
341 }
342
343 // Persist --api-key for custom providers so subsequent runs don't
344 // need to pass it again.
345 if !isBuiltinProvider(provName) && args.APIKey != "" {
346 if store := AuthStoreFor(); store != nil {
347 _ = store.SetAPIKey(provName, args.APIKey)
348 }
349 }
350
351 // If the user did NOT explicitly pick a provider and the default one
352 // has no credentials, auto-fall-back to whichever provider is actually
353 // logged in. That way running plain `zot` after `/login` (any provider)
354 // never shows a "not logged in" banner.
355 userPickedProvider := args.Provider != ""
356 if credErr != nil && !userPickedProvider && provName != "ollama" {
357 // Scan every known provider (not a hardcoded subset) so any
358 // env-based credential is discovered, e.g. an env-only
359 // amazon-bedrock setup (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE /
360 // IAM keys) when no config.json pins the provider, such as after
361 // pointing ZOT_HOME at a fresh home dir. Iteration order of
362 // knownProviders defines fallback priority. ollama is skipped:
363 // it has no credential and would always "match".
364 for _, other := range knownProviders {
365 if other == provName || other == "ollama" {
366 continue
367 }
368 if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
369 provName = other
370 cred, method, accountID, credErr = c, m, a, err
371 break
372 }
373 }
374 }
375
376 model := firstNonEmpty(args.Model, cfg.Model)
377 if model == "" {
378 if provName == "ollama" {
379 return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")
380 }
381 model = defaultModelForProvider(provName)
382 }
383 // If the resolved model belongs to a different provider (e.g. config
384 // says gpt-5 but we fell back to anthropic), pick that provider's default.
385 if provName != "ollama" {
386 if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
387 model = defaultModelForProvider(provName)
388 }
389 }
390 resolvedModel, err := provider.FindModel(provName, model)
391 if err != nil && provName == "ollama" {
392 // ollama is intentionally open-catalogue: any model id the
393 // local server understands is valid, even if not in the
394 // baked-in catalog.
395 resolvedModel = provider.Model{
396 Provider: "ollama",
397 ID: model,
398 DisplayName: model,
399 ContextWindow: 32768,
400 MaxOutput: 8192,
401 BaseURL: args.BaseURL,
402 Source: "ollama",
403 }
404 err = nil
405 }
406 // Custom providers are open-catalogue like ollama: any model id the
407 // endpoint understands is valid. Use the provider-level base URL.
408 if err != nil {
409 if cfg, ok := provider.CustomProviders()[provName]; ok {
410 resolvedModel = provider.Model{
411 Provider: provName,
412 ID: model,
413 DisplayName: model,
414 BaseURL: cfg.BaseURL,
415 Source: "user",
416 }
417 err = nil
418 }
419 } else if cfg, ok := provider.CustomProviders()[provName]; ok && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {
420 // Fall back to the provider-level base URL when the model does
421 // not define its own endpoint.
422 resolvedModel.BaseURL = cfg.BaseURL
423 }
424 if err != nil {
425 // The model the user (or persisted config) asked for is no
426 // longer in the active catalogue — they probably removed it
427 // from their models.json or upgraded zot and the id changed.
428 // Refusing to launch is the wrong move: it strands the user
429 // with no way to even open the TUI and pick a new model.
430 // Fall back to the provider's default, warn on stderr, and,
431 // when the stale id came from the persisted config (not an
432 // explicit --model flag), repair the config so the warning
433 // doesn't repeat on every launch.
434 fallback := defaultModelForProvider(provName)
435 fm, ferr := provider.FindModel(provName, fallback)
436 if ferr != nil {
437 // Even the provider default is gone (catastrophic
438 // catalogue trim). Last resort: any model on this
439 // provider, then the global DefaultModel.
440 if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 {
441 fm = candidates[0]
442 ferr = nil
443 } else {
444 fm = provider.DefaultModel
445 ferr = nil
446 }
447 }
448 fmt.Fprintf(os.Stderr,
449 "zot: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
450 model, fm.ID)
451 if args.Model == "" && cfg.Model == model {
452 cfg.Model = fm.ID
453 _ = SaveConfig(cfg)
454 }
455 resolvedModel = fm
456 model = fm.ID
457 }
458
459 explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "")
460
461 // If the model defines a base URL (e.g. local ollama) and the
462 // user didn't pass --base-url, use the model's URL. For ollama,
463 // keep http://localhost:11434 as a fallback only after the model
464 // metadata has had a chance to provide a custom baseUrl.
465 if args.BaseURL == "" && resolvedModel.BaseURL != "" {
466 args.BaseURL = resolvedModel.BaseURL
467 }
468 if args.BaseURL == "" && provName == "ollama" {
469 args.BaseURL = "http://localhost:11434"
470 }
471
472 // Insecure TLS is intentionally scoped to explicit custom endpoints.
473 // Built-in provider base URLs, auth calls, and model discovery keep normal
474 // certificate verification even when --insecure is present.
475 insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL
476
477 // If the model has a base URL, credentials are optional (local
478 // models like ollama don't need real API keys).
479 if resolvedModel.BaseURL != "" && credErr != nil {
480 cred = "ollama"
481 credErr = nil
482 requireCred = false
483 }
484
485 if credErr != nil && requireCred {
486 return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
487 credErr, envVarName(provName))
488 }
489
490 sandbox := tools.NewSandbox(args.CWD)
491 reg := buildToolRegistry(args, args.CWD, sandbox)
492
493 docsDir, _ := zotdocs.EnsureInstalled(ZotHome())
494
495 // Skill discovery: scan project + global locations + built-in
496 // skills shipped with the binary. If any are found, register
497 // the on-demand `skill` loader tool plus a system-prompt
498 // manifest so the model knows what's available.
499 //
500 // --no-skill bypasses the entire mechanism: no manifest in the
501 // system prompt, no `skill` tool in the registry. Useful for a
502 // clean-room run with zero extra context biasing the model.
503 var (
504 discovered []*skills.Skill
505 skillTool *skills.Tool
506 skillAddendum string
507 )
508 if !args.NoSkill {
509 homeDir, _ := os.UserHomeDir()
510 discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
511 if len(discovered) > 0 {
512 skillTool = skills.NewTool(discovered)
513 reg[skillTool.Name()] = skillTool
514 skillAddendum = skills.SystemPromptAddendum(discovered)
515 }
516 }
517 _ = skillTool
518
519 summaries := toolSummaries(reg, args)
520
521 append_ := append([]string(nil), args.AppendSystemPrompt...)
522 if agentsAddendum := readAgentsContext(args.CWD, ZotHome()); agentsAddendum != "" {
523 append_ = append(append_, agentsAddendum)
524 }
525 if skillAddendum != "" {
526 append_ = append(append_, skillAddendum)
527 }
528 if AutoSwarmEnabled() {
529 append_ = append(append_, AutoSwarmSystemAddendum)
530 }
531
532 // Custom system prompt resolution order:
533 // 1. --system-prompt flag (highest priority; ad-hoc per run)
534 // 2. $ZOT_HOME/SYSTEM.md (persistent user override)
535 // 3. built-in default (defaultIdentity + defaultGuidelines)
536 custom := args.SystemPrompt
537 if custom == "" {
538 custom = readUserSystemPrompt(ZotHome())
539 }
540
541 sys := BuildSystemPrompt(SystemPromptOpts{
542 CWD: args.CWD,
543 Tools: summaries,
544 Custom: custom,
545 Append: append_,
546 ZotDocsDir: docsDir,
547 })
548
549 reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
550 temperature := args.Temperature
551 if temperature == nil {
552 temperature = cfg.Temperature
553 }
554
555 max := args.MaxSteps // 0 = unlimited
556
557 return Resolved{
558 Provider: provName,
559 Model: model,
560 Credential: cred,
561 AuthMethod: method,
562 AccountID: accountID,
563 BaseURL: args.BaseURL,
564 InsecureTLS: insecureTLS,
565 CWD: args.CWD,
566 Reasoning: reasoning,
567 Temperature: temperature,
568 ToolRegistry: reg,
569 ToolSummary: summaries,
570 SystemPrompt: sys,
571 MaxSteps: max,
572 MaxOutput: resolvedModel.MaxOutput,
573 Sandbox: sandbox,
574 SkillTool: skillTool,
575 systemAppend: append_,
576 systemCustom: custom,
577 toolDescriptions: descMapFromSummaries(summaries),
578 }, nil
579}
580
581// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
582// trimmed contents, or "" when the file is missing / unreadable /
583// empty. Errors are intentionally swallowed: the file is optional,
584// and any failure to read it should fall back to the built-in
585// default system prompt rather than crash the run.
586func readUserSystemPrompt(zotHome string) string {
587 if zotHome == "" {
588 return ""
589 }
590 path := filepath.Join(zotHome, "SYSTEM.md")
591 raw, err := os.ReadFile(path)
592 if err != nil {
593 return ""
594 }
595 return strings.TrimSpace(string(raw))
596}
597
598// readAgentsContext loads optional AGENTS.md instruction files. No
599// default file is created or required: zot only includes files that
600// already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first,
601// followed by project instructions from the top-most parent down to cwd.
602func readAgentsContext(cwd, zotHome string) string {
603 type contextFile struct {
604 path string
605 content string
606 }
607 var files []contextFile
608 seen := map[string]bool{}
609 add := func(path string) {
610 if path == "" {
611 return
612 }
613 abs, err := filepath.Abs(path)
614 if err == nil {
615 path = abs
616 }
617 if seen[path] {
618 return
619 }
620 raw, err := os.ReadFile(path)
621 if err != nil {
622 return
623 }
624 content := strings.TrimSpace(string(raw))
625 if content == "" {
626 return
627 }
628 seen[path] = true
629 files = append(files, contextFile{path: path, content: content})
630 }
631 addFirstFromDir := func(dir string) {
632 if dir == "" {
633 return
634 }
635 for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
636 path := filepath.Join(dir, name)
637 if _, err := os.Stat(path); err == nil {
638 add(path)
639 return
640 }
641 }
642 }
643
644 addFirstFromDir(zotHome)
645
646 if cwd != "" {
647 abs, err := filepath.Abs(cwd)
648 if err == nil {
649 cwd = abs
650 }
651 var dirs []string
652 for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
653 dirs = append(dirs, dir)
654 parent := filepath.Dir(dir)
655 if parent == dir {
656 break
657 }
658 }
659 for i := len(dirs) - 1; i >= 0; i-- {
660 addFirstFromDir(dirs[i])
661 }
662 }
663
664 if len(files) == 0 {
665 return ""
666 }
667 var sb strings.Builder
668 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")
669 for _, f := range files {
670 fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.path, f.content)
671 }
672 return strings.TrimSpace(sb.String())
673}
674
675// descMapFromSummaries indexes the human-readable descriptions for
676// the renderToolsSection rebuild path.
677func descMapFromSummaries(summaries []ToolSummary) map[string]string {
678 out := make(map[string]string, len(summaries))
679 for _, s := range summaries {
680 out[s.Name] = s.Description
681 }
682 return out
683}
684
685// NewClient returns a provider.Client for r, choosing the auth mode
686// based on r.AuthMethod. Panics if no credential is present; callers
687// must check HasCredential() first.
688func (r Resolved) NewClient() provider.Client {
689 if !r.HasCredential() {
690 panic("NewClient called without credential; check HasCredential first")
691 }
692 wrap := r.withHTTPClient
693 switch r.Provider {
694 case "ollama":
695 return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
696 case "kimi":
697 // kimi-coding speaks anthropic-messages on api.kimi.com/coding.
698 // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
699 inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
700 if r.AuthMethod == "oauth" {
701 return r.wrapWithRefresh(inner)
702 }
703 return inner
704 case "moonshotai":
705 return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
706 case "moonshotai-cn":
707 return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
708 case "deepseek":
709 return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
710 case "openai":
711 return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
712 case "openai-codex":
713 inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
714 return r.wrapWithRefresh(inner)
715 case "openai-responses":
716 // Public OpenAI Responses API (api.openai.com/v1/responses) via
717 // API key. Separate provider from `openai` (Chat Completions) and
718 // from `openai-codex` (ChatGPT subscription OAuth).
719 return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
720 case "google":
721 return wrap(provider.NewGemini(r.Credential, r.BaseURL))
722 case "cerebras":
723 return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
724 case "groq":
725 return wrap(provider.NewGroq(r.Credential, r.BaseURL))
726 case "xai":
727 return wrap(provider.NewXAI(r.Credential, r.BaseURL))
728 case "together":
729 return wrap(provider.NewTogether(r.Credential, r.BaseURL))
730 case "huggingface":
731 return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
732 case "openrouter":
733 return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
734 case "zai":
735 return wrap(provider.NewZAI(r.Credential, r.BaseURL))
736 case "xiaomi":
737 return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
738 case "xiaomi-token-plan-ams":
739 return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
740 case "xiaomi-token-plan-cn":
741 return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
742 case "xiaomi-token-plan-sgp":
743 return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
744 case "opencode":
745 return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
746 case "opencode-go":
747 return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL))
748 case "minimax":
749 return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
750 case "minimax-cn":
751 return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
752 case "fireworks":
753 return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
754 case "vercel-ai-gateway":
755 return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
756 case "mistral":
757 return wrap(provider.NewMistral(r.Credential, r.BaseURL))
758 case "amazon-bedrock":
759 return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
760 case "google-vertex":
761 return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
762 case "azure-openai-responses":
763 return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
764 case "github-copilot":
765 return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
766 case "cloudflare-workers-ai":
767 return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
768 case "cloudflare-ai-gateway":
769 return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
770 default:
771 // Custom providers: choose wire format from the models.json api field.
772 if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
773 switch cfg.API {
774 case "anthropic":
775 return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
776 default: // "openai"
777 return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
778 }
779 }
780 if r.AuthMethod == "oauth" {
781 inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
782 return r.wrapWithRefresh(inner)
783 }
784 return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
785 }
786}
787
788func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
789 if !r.InsecureTLS {
790 return c
791 }
792 return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
793}
794
795// wrapWithRefresh wraps an OAuth client so the access token is
796// refreshed automatically before each API call. Without this, long
797// sessions (hours) silently fail when the 1-hour token expires.
798func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
799 provName := r.Provider
800 tokenProvider := provName
801 if provName == "openai-codex" {
802 tokenProvider = "openai"
803 }
804 baseURL := r.BaseURL
805 accountID := r.AccountID
806
807 refreshFn := func(ctx context.Context) (string, error) {
808 tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
809 if err != nil {
810 return "", err
811 }
812 return tok.AccessToken, nil
813 }
814
815 factory := func(token string) provider.Client {
816 switch provName {
817 case "openai-codex":
818 return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
819 case "kimi":
820 // anthropic-messages on api.kimi.com/coding.
821 return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
822 default:
823 return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
824 }
825 }
826
827 return provider.NewRefreshingClient(inner, refreshFn, factory)
828}
829
830// UseSandbox replaces the sandbox pointer that every tool in r's
831// registry references. Used to keep the /jail state stable across
832// agent rebuilds (e.g. /login, /model switching providers).
833func (r *Resolved) UseSandbox(s *tools.Sandbox) {
834 if s == nil || r == nil {
835 return
836 }
837 r.Sandbox = s
838 for name, t := range r.ToolRegistry {
839 switch v := t.(type) {
840 case *tools.ReadTool:
841 v.Sandbox = s
842 case *tools.WriteTool:
843 v.Sandbox = s
844 case *tools.EditTool:
845 v.Sandbox = s
846 case *tools.BashTool:
847 v.Sandbox = s
848 }
849 _ = name
850 }
851}
852
853// NewAgent constructs a core.Agent from r. Requires a credential.
854func (r Resolved) NewAgent() *core.Agent {
855 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
856 a.MaxSteps = r.MaxSteps
857 a.MaxTokens = r.MaxOutput
858 a.Reasoning = r.Reasoning
859 a.Temperature = r.Temperature
860 return a
861}
862
863func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
864 if args.NoTools {
865 return core.Registry{}
866 }
867 all := map[string]core.Tool{
868 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
869 "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
870 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox},
871 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
872 }
873 reg := core.Registry{}
874 if len(args.Tools) == 0 {
875 for _, t := range all {
876 reg[t.Name()] = t
877 }
878 return reg
879 }
880 for _, name := range args.Tools {
881 if t, ok := all[name]; ok {
882 reg[name] = t
883 }
884 }
885 return reg
886}
887
888func toolSummaries(reg core.Registry, args Args) []ToolSummary {
889 order := []string{"read", "write", "edit", "bash"}
890 var out []ToolSummary
891 for _, name := range order {
892 if t, ok := reg[name]; ok {
893 out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
894 }
895 }
896 return out
897}
898
899func firstNonEmpty(vals ...string) string {
900 for _, v := range vals {
901 if v != "" {
902 return v
903 }
904 }
905 return ""
906}
907
908func kimiCodeHeaders() map[string]string {
909 host, _ := os.Hostname()
910 if host == "" {
911 host = "unknown"
912 }
913 deviceID := ""
914 if home, err := os.UserHomeDir(); err == nil {
915 if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
916 deviceID = strings.TrimSpace(string(b))
917 }
918 }
919 if deviceID == "" {
920 deviceID = "zot"
921 }
922 return map[string]string{
923 "User-Agent": "KimiCLI/1.41.0",
924 "X-Msh-Platform": "kimi_cli",
925 "X-Msh-Version": "1.41.0",
926 "X-Msh-Device-Name": host,
927 "X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
928 "X-Msh-Os-Version": runtime.GOOS,
929 "X-Msh-Device-Id": deviceID,
930 }
931}
932
933func envVarName(provider string) string {
934 switch provider {
935 case "openai", "openai-codex", "openai-responses":
936 return "OPENAI"
937 case "kimi":
938 return "KIMI"
939 case "deepseek":
940 return "DEEPSEEK"
941 case "google":
942 return "GEMINI"
943 case "ollama":
944 return "OLLAMA"
945 case "moonshotai", "moonshotai-cn":
946 return "MOONSHOT"
947 case "groq":
948 return "GROQ"
949 case "xai":
950 return "XAI"
951 case "cerebras":
952 return "CEREBRAS"
953 case "together":
954 return "TOGETHER"
955 case "huggingface":
956 return "HF"
957 case "openrouter":
958 return "OPENROUTER"
959 case "mistral":
960 return "MISTRAL"
961 case "zai":
962 return "ZAI"
963 case "xiaomi":
964 return "XIAOMI"
965 case "xiaomi-token-plan-ams":
966 return "XIAOMI_TOKEN_PLAN_AMS"
967 case "xiaomi-token-plan-cn":
968 return "XIAOMI_TOKEN_PLAN_CN"
969 case "xiaomi-token-plan-sgp":
970 return "XIAOMI_TOKEN_PLAN_SGP"
971 case "minimax":
972 return "MINIMAX"
973 case "minimax-cn":
974 return "MINIMAX_CN"
975 case "fireworks":
976 return "FIREWORKS"
977 case "vercel-ai-gateway":
978 return "AI_GATEWAY"
979 case "opencode", "opencode-go":
980 return "OPENCODE"
981 case "github-copilot":
982 return "COPILOT_GITHUB_TOKEN"
983 case "cloudflare-workers-ai", "cloudflare-ai-gateway":
984 return "CLOUDFLARE"
985 case "amazon-bedrock":
986 return "AWS"
987 case "google-vertex":
988 return "GOOGLE_CLOUD"
989 case "azure-openai-responses":
990 return "AZURE_OPENAI"
991 default:
992 return "ANTHROPIC"
993 }
994}