v0.2.34 1cc654e

build.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close