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 if AutoSwarmEnabled() {
409 append_ = append(append_, AutoSwarmSystemAddendum)
410 }
411
412 // Custom system prompt resolution order:
413 // 1. --system-prompt flag (highest priority; ad-hoc per run)
414 // 2. $ZOT_HOME/SYSTEM.md (persistent user override)
415 // 3. built-in default (defaultIdentity + defaultGuidelines)
416 custom := args.SystemPrompt
417 if custom == "" {
418 custom = readUserSystemPrompt(ZotHome())
419 }
420
421 sys := BuildSystemPrompt(SystemPromptOpts{
422 CWD: args.CWD,
423 Tools: summaries,
424 Custom: custom,
425 Append: append_,
426 ZotDocsDir: docsDir,
427 })
428
429 reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
430
431 max := args.MaxSteps // 0 = unlimited
432
433 return Resolved{
434 Provider: provName,
435 Model: model,
436 Credential: cred,
437 AuthMethod: method,
438 AccountID: accountID,
439 BaseURL: args.BaseURL,
440 CWD: args.CWD,
441 Reasoning: reasoning,
442 ToolRegistry: reg,
443 ToolSummary: summaries,
444 SystemPrompt: sys,
445 MaxSteps: max,
446 Sandbox: sandbox,
447 SkillTool: skillTool,
448 systemAppend: append_,
449 systemCustom: custom,
450 toolDescriptions: descMapFromSummaries(summaries),
451 }, nil
452}
453
454// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
455// trimmed contents, or "" when the file is missing / unreadable /
456// empty. Errors are intentionally swallowed: the file is optional,
457// and any failure to read it should fall back to the built-in
458// default system prompt rather than crash the run.
459func readUserSystemPrompt(zotHome string) string {
460 if zotHome == "" {
461 return ""
462 }
463 path := filepath.Join(zotHome, "SYSTEM.md")
464 raw, err := os.ReadFile(path)
465 if err != nil {
466 return ""
467 }
468 return strings.TrimSpace(string(raw))
469}
470
471// readAgentsContext loads optional AGENTS.md instruction files. No
472// default file is created or required: zot only includes files that
473// already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first,
474// followed by project instructions from the top-most parent down to cwd.
475func readAgentsContext(cwd, zotHome string) string {
476 type contextFile struct {
477 path string
478 content string
479 }
480 var files []contextFile
481 seen := map[string]bool{}
482 add := func(path string) {
483 if path == "" {
484 return
485 }
486 abs, err := filepath.Abs(path)
487 if err == nil {
488 path = abs
489 }
490 if seen[path] {
491 return
492 }
493 raw, err := os.ReadFile(path)
494 if err != nil {
495 return
496 }
497 content := strings.TrimSpace(string(raw))
498 if content == "" {
499 return
500 }
501 seen[path] = true
502 files = append(files, contextFile{path: path, content: content})
503 }
504 addFirstFromDir := func(dir string) {
505 if dir == "" {
506 return
507 }
508 for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
509 path := filepath.Join(dir, name)
510 if _, err := os.Stat(path); err == nil {
511 add(path)
512 return
513 }
514 }
515 }
516
517 addFirstFromDir(zotHome)
518
519 if cwd != "" {
520 abs, err := filepath.Abs(cwd)
521 if err == nil {
522 cwd = abs
523 }
524 var dirs []string
525 for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
526 dirs = append(dirs, dir)
527 parent := filepath.Dir(dir)
528 if parent == dir {
529 break
530 }
531 }
532 for i := len(dirs) - 1; i >= 0; i-- {
533 addFirstFromDir(dirs[i])
534 }
535 }
536
537 if len(files) == 0 {
538 return ""
539 }
540 var sb strings.Builder
541 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")
542 for _, f := range files {
543 fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.path, f.content)
544 }
545 return strings.TrimSpace(sb.String())
546}
547
548// descMapFromSummaries indexes the human-readable descriptions for
549// the renderToolsSection rebuild path.
550func descMapFromSummaries(summaries []ToolSummary) map[string]string {
551 out := make(map[string]string, len(summaries))
552 for _, s := range summaries {
553 out[s.Name] = s.Description
554 }
555 return out
556}
557
558// NewClient returns a provider.Client for r, choosing the auth mode
559// based on r.AuthMethod. Panics if no credential is present; callers
560// must check HasCredential() first.
561func (r Resolved) NewClient() provider.Client {
562 if !r.HasCredential() {
563 panic("NewClient called without credential; check HasCredential first")
564 }
565 switch r.Provider {
566 case "ollama":
567 return provider.NewOpenAI(r.Credential, r.BaseURL)
568 case "kimi":
569 // kimi-coding speaks anthropic-messages on api.kimi.com/coding.
570 // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
571 inner := provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())
572 if r.AuthMethod == "oauth" {
573 return r.wrapWithRefresh(inner)
574 }
575 return inner
576 case "moonshotai":
577 return provider.NewMoonshot(r.Credential, r.BaseURL)
578 case "moonshotai-cn":
579 return provider.NewMoonshotCN(r.Credential, r.BaseURL)
580 case "deepseek":
581 return provider.NewDeepSeek(r.Credential, r.BaseURL)
582 case "openai":
583 return provider.NewOpenAI(r.Credential, r.BaseURL)
584 case "openai-codex":
585 inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
586 return r.wrapWithRefresh(inner)
587 case "openai-responses":
588 // Public OpenAI Responses API (api.openai.com/v1/responses) via
589 // API key. Separate provider from `openai` (Chat Completions) and
590 // from `openai-codex` (ChatGPT subscription OAuth).
591 return provider.NewOpenAIResponses(r.Credential, r.BaseURL)
592 case "google":
593 return provider.NewGemini(r.Credential, r.BaseURL)
594 case "cerebras":
595 return provider.NewCerebras(r.Credential, r.BaseURL)
596 case "groq":
597 return provider.NewGroq(r.Credential, r.BaseURL)
598 case "xai":
599 return provider.NewXAI(r.Credential, r.BaseURL)
600 case "together":
601 return provider.NewTogether(r.Credential, r.BaseURL)
602 case "huggingface":
603 return provider.NewHuggingFace(r.Credential, r.BaseURL)
604 case "openrouter":
605 return provider.NewOpenRouter(r.Credential, r.BaseURL)
606 case "zai":
607 return provider.NewZAI(r.Credential, r.BaseURL)
608 case "xiaomi":
609 return provider.NewXiaomi(r.Credential, r.BaseURL)
610 case "xiaomi-token-plan-ams":
611 return provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL)
612 case "xiaomi-token-plan-cn":
613 return provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL)
614 case "xiaomi-token-plan-sgp":
615 return provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL)
616 case "opencode":
617 return provider.NewOpenCode(r.Credential, r.BaseURL)
618 case "opencode-go":
619 return provider.NewOpenCodeGo(r.Credential, r.BaseURL)
620 case "minimax":
621 return provider.NewMinimaxAnthropic(r.Credential, r.BaseURL)
622 case "minimax-cn":
623 return provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL)
624 case "fireworks":
625 return provider.NewFireworksAnthropic(r.Credential, r.BaseURL)
626 case "vercel-ai-gateway":
627 return provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL)
628 case "mistral":
629 return provider.NewMistral(r.Credential, r.BaseURL)
630 case "amazon-bedrock":
631 return provider.NewBedrock(r.Credential, r.BaseURL)
632 case "google-vertex":
633 return provider.NewGoogleVertex(r.Credential, r.BaseURL)
634 case "azure-openai-responses":
635 return provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL)
636 case "github-copilot":
637 return provider.NewGithubCopilot(r.Credential, r.BaseURL)
638 case "cloudflare-workers-ai":
639 return provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL)
640 case "cloudflare-ai-gateway":
641 return provider.NewCloudflareAIGateway(r.Credential, r.BaseURL)
642 default:
643 if r.AuthMethod == "oauth" {
644 inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
645 return r.wrapWithRefresh(inner)
646 }
647 return provider.NewAnthropic(r.Credential, r.BaseURL)
648 }
649}
650
651// wrapWithRefresh wraps an OAuth client so the access token is
652// refreshed automatically before each API call. Without this, long
653// sessions (hours) silently fail when the 1-hour token expires.
654func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
655 provName := r.Provider
656 tokenProvider := provName
657 if provName == "openai-codex" {
658 tokenProvider = "openai"
659 }
660 baseURL := r.BaseURL
661 accountID := r.AccountID
662
663 refreshFn := func(ctx context.Context) (string, error) {
664 tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
665 if err != nil {
666 return "", err
667 }
668 return tok.AccessToken, nil
669 }
670
671 factory := func(token string) provider.Client {
672 switch provName {
673 case "openai-codex":
674 return provider.NewOpenAICodex(token, accountID, baseURL)
675 case "kimi":
676 // anthropic-messages on api.kimi.com/coding.
677 return provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders())
678 default:
679 return provider.NewAnthropicOAuth(token, baseURL)
680 }
681 }
682
683 return provider.NewRefreshingClient(inner, refreshFn, factory)
684}
685
686// UseSandbox replaces the sandbox pointer that every tool in r's
687// registry references. Used to keep the /jail state stable across
688// agent rebuilds (e.g. /login, /model switching providers).
689func (r *Resolved) UseSandbox(s *tools.Sandbox) {
690 if s == nil || r == nil {
691 return
692 }
693 r.Sandbox = s
694 for name, t := range r.ToolRegistry {
695 switch v := t.(type) {
696 case *tools.ReadTool:
697 v.Sandbox = s
698 case *tools.WriteTool:
699 v.Sandbox = s
700 case *tools.EditTool:
701 v.Sandbox = s
702 case *tools.BashTool:
703 v.Sandbox = s
704 }
705 _ = name
706 }
707}
708
709// NewAgent constructs a core.Agent from r. Requires a credential.
710func (r Resolved) NewAgent() *core.Agent {
711 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
712 a.MaxSteps = r.MaxSteps
713 a.Reasoning = r.Reasoning
714 return a
715}
716
717func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
718 if args.NoTools {
719 return core.Registry{}
720 }
721 all := map[string]core.Tool{
722 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
723 "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
724 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox},
725 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
726 }
727 reg := core.Registry{}
728 if len(args.Tools) == 0 {
729 for _, t := range all {
730 reg[t.Name()] = t
731 }
732 return reg
733 }
734 for _, name := range args.Tools {
735 if t, ok := all[name]; ok {
736 reg[name] = t
737 }
738 }
739 return reg
740}
741
742func toolSummaries(reg core.Registry, args Args) []ToolSummary {
743 order := []string{"read", "write", "edit", "bash"}
744 var out []ToolSummary
745 for _, name := range order {
746 if t, ok := reg[name]; ok {
747 out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
748 }
749 }
750 return out
751}
752
753func firstNonEmpty(vals ...string) string {
754 for _, v := range vals {
755 if v != "" {
756 return v
757 }
758 }
759 return ""
760}
761
762func kimiCodeHeaders() map[string]string {
763 host, _ := os.Hostname()
764 if host == "" {
765 host = "unknown"
766 }
767 deviceID := ""
768 if home, err := os.UserHomeDir(); err == nil {
769 if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
770 deviceID = strings.TrimSpace(string(b))
771 }
772 }
773 if deviceID == "" {
774 deviceID = "zot"
775 }
776 return map[string]string{
777 "User-Agent": "KimiCLI/1.41.0",
778 "X-Msh-Platform": "kimi_cli",
779 "X-Msh-Version": "1.41.0",
780 "X-Msh-Device-Name": host,
781 "X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
782 "X-Msh-Os-Version": runtime.GOOS,
783 "X-Msh-Device-Id": deviceID,
784 }
785}
786
787func envVarName(provider string) string {
788 switch provider {
789 case "openai", "openai-codex", "openai-responses":
790 return "OPENAI"
791 case "kimi":
792 return "KIMI"
793 case "deepseek":
794 return "DEEPSEEK"
795 case "google":
796 return "GEMINI"
797 case "ollama":
798 return "OLLAMA"
799 case "moonshotai", "moonshotai-cn":
800 return "MOONSHOT"
801 case "groq":
802 return "GROQ"
803 case "xai":
804 return "XAI"
805 case "cerebras":
806 return "CEREBRAS"
807 case "together":
808 return "TOGETHER"
809 case "huggingface":
810 return "HF"
811 case "openrouter":
812 return "OPENROUTER"
813 case "mistral":
814 return "MISTRAL"
815 case "zai":
816 return "ZAI"
817 case "xiaomi":
818 return "XIAOMI"
819 case "xiaomi-token-plan-ams":
820 return "XIAOMI_TOKEN_PLAN_AMS"
821 case "xiaomi-token-plan-cn":
822 return "XIAOMI_TOKEN_PLAN_CN"
823 case "xiaomi-token-plan-sgp":
824 return "XIAOMI_TOKEN_PLAN_SGP"
825 case "minimax":
826 return "MINIMAX"
827 case "minimax-cn":
828 return "MINIMAX_CN"
829 case "fireworks":
830 return "FIREWORKS"
831 case "vercel-ai-gateway":
832 return "AI_GATEWAY"
833 case "opencode", "opencode-go":
834 return "OPENCODE"
835 case "github-copilot":
836 return "COPILOT_GITHUB_TOKEN"
837 case "cloudflare-workers-ai", "cloudflare-ai-gateway":
838 return "CLOUDFLARE"
839 case "amazon-bedrock":
840 return "AWS"
841 case "google-vertex":
842 return "GOOGLE_CLOUD"
843 case "azure-openai-responses":
844 return "AZURE_OPENAI"
845 default:
846 return "ANTHROPIC"
847 }
848}