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