v0.4.10 5ac0009

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/extensions"
13	"github.com/patriceckhart/zot/packages/agent/skills"
14	"github.com/patriceckhart/zot/packages/agent/tools"
15	"github.com/patriceckhart/zot/packages/core"
16	"github.com/patriceckhart/zot/packages/provider"
17)
18
19// ContextFile is an instruction file loaded into the system prompt.
20type ContextFile struct {
21	Path    string
22	Content string
23}
24
25// Resolved is the effective configuration after merging CLI, config, defaults.
26type Resolved struct {
27	Provider    string
28	Model       string
29	Credential  string // api key or oauth access token
30	AuthMethod  string // "apikey" | "oauth" | "" (no credential yet)
31	AccountID   string // ChatGPT account id (for openai oauth), "" otherwise
32	BaseURL     string
33	InsecureTLS bool
34	CWD         string
35	Reasoning   string
36	Temperature *float32
37
38	ToolRegistry core.Registry
39	ToolSummary  []ToolSummary
40	SystemPrompt string
41	MaxSteps     int
42	Sandbox      *tools.Sandbox
43
44	// MaxOutput is the resolved model's maximum output-token budget
45	// (from the catalog). Passed to the agent so each turn requests
46	// the model's full output capacity instead of the provider's
47	// conservative default (e.g. Bedrock's 4096, which truncates
48	// long writes/edits with stopReason=length).
49	MaxOutput int
50
51	// SkillTool is the on-demand skill loader registered with the
52	// agent's tool registry, or nil if no SKILL.md files were
53	// discovered. Exposed so the tui can list / preview skills.
54	SkillTool *skills.Tool
55
56	// SkillDiagnostics records non-fatal skill discovery failures and
57	// shadowing decisions so each host mode can report them appropriately.
58	SkillDiagnostics []string
59
60	// ContextFiles records the AGENTS.md files appended to SystemPrompt,
61	// in effective load order. Interactive mode uses this metadata to make
62	// otherwise invisible startup context inspectable without adding fake
63	// messages to the provider transcript.
64	ContextFiles []ContextFile
65
66	// Bookkeeping for MergeExtensionTools. Captured at Resolve time
67	// so the system prompt can be rebuilt later without re-running
68	// resolve.
69	credentialOptional bool
70	systemAppend       []string
71	systemCustom       string
72	systemCustomSet    bool
73	toolDescriptions   map[string]string
74}
75
76// HasCredential reports whether the resolved configuration can create a client.
77// Configured custom endpoints may intentionally omit an API credential.
78func (r Resolved) HasCredential() bool {
79	return r.Credential != "" || r.credentialOptional
80}
81
82// MergeExtensionTools folds every tool registered by an extension
83// into r's ToolRegistry and re-renders the system prompt's tool
84// summary so the model sees both built-in and extension tools.
85//
86// Idempotent: calling twice with the same manager state has no
87// effect on the second pass (existing names are preserved). Built-in
88// tools always win on conflict.
89func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
90	if mgr == nil {
91		return
92	}
93	infos := mgr.Tools()
94	if len(infos) == 0 {
95		return
96	}
97	changed := false
98	for _, info := range infos {
99		if _, exists := r.ToolRegistry[info.Name]; exists {
100			continue
101		}
102		r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info)
103		changed = true
104	}
105	if !changed {
106		return
107	}
108	// Re-render the system prompt with the merged tool list. Skill
109	// addendum is preserved by walking the existing append slice.
110	append_ := r.systemAppend
111	r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{
112		CWD:        r.CWD,
113		Tools:      toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions),
114		Custom:     r.systemCustom,
115		CustomSet:  r.systemCustomSet,
116		Append:     append_,
117		ZotDocsDir: filepath.Join(ZotHome(), "docs"),
118	})
119}
120
121// ExtensionToolSource is the slice of the extension manager that
122// MergeExtensionTools needs. Lives here as an interface so the
123// build package doesn't import packages/agent/extensions (which
124// imports core, which imports... avoid the cycle).
125type ExtensionToolSource interface {
126	Tools() []ExtensionToolInfo
127	NewExtensionTool(info ExtensionToolInfo) core.Tool
128}
129
130// ExtensionToolInfo mirrors extensions.ToolInfo so we can declare
131// ExtensionToolSource here without importing the extensions
132// package. The cli wires a tiny adapter to bridge them.
133type ExtensionToolInfo struct {
134	Extension   string
135	Name        string
136	Description string
137	Schema      []byte
138	Deferred    bool
139	Interactive bool
140}
141
142// toolSummariesFromRegistry rebuilds the system-prompt tool list
143// from a (possibly extended) registry, using cached descriptions for
144// the human-readable summary text.
145func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []ToolSummary {
146	out := make([]ToolSummary, 0, len(reg))
147	for name, t := range reg {
148		if d, ok := t.(interface{ Deferred() bool }); ok && d.Deferred() {
149			continue
150		}
151		desc := t.Description()
152		if d, ok := cached[name]; ok && d != "" {
153			desc = d
154		}
155		out = append(out, ToolSummary{Name: name, Description: desc})
156	}
157	return out
158}
159
160// defaultModelForProvider returns the model id zot prefers when the
161// caller didn't pick one. Mirrors the per-provider switch used at
162// multiple points in Resolve; centralised so the unknown-model
163// recovery path and the no-model-configured path can't drift.
164//
165// Returns the empty string for "ollama", which has no built-in
166// default — the caller is expected to special-case ollama and
167// error or use whatever the user passed.
168func defaultModelForProvider(prov string) string {
169	switch prov {
170	case "openai":
171		return "gpt-5"
172	case "openai-codex":
173		return "gpt-5.5"
174	case "openai-responses":
175		return "gpt-5"
176	case "kimi":
177		return "kimi-for-coding"
178	case "deepseek":
179		return "deepseek-v4-pro"
180	case "google":
181		return "gemini-2.5-pro"
182	case "ollama", provider.LlamaCPPProviderID, provider.LMStudioProviderID:
183		return ""
184	case "moonshotai", "moonshotai-cn":
185		return "kimi-k2.6"
186	case "cerebras":
187		return "qwen-3-235b-a22b-instruct-2507"
188	case "groq":
189		return "llama-3.3-70b-versatile"
190	case "xai":
191		return "grok-4.5"
192	case "together":
193		return "Qwen/Qwen3-Coder-480B-A35B-Instruct"
194	case "huggingface":
195		return "moonshotai/Kimi-K2-Instruct"
196	case "openrouter":
197		return "anthropic/claude-sonnet-4.5"
198	case "gondola":
199		return "kimi-k3"
200	case "yolo-auto":
201		return "qwen3.8-flash"
202	case "mistral":
203		return "mistral-large-latest"
204	case "zai":
205		return "glm-4.7"
206	case "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp":
207		return "mimo-v2.5"
208	case "minimax", "minimax-cn":
209		return "MiniMax-M2.7"
210	case "fireworks":
211		return "accounts/fireworks/models/kimi-k2p6"
212	case "vercel-ai-gateway":
213		return "anthropic/claude-sonnet-4.5"
214	case "opencode":
215		return "claude-sonnet-4-5"
216	case "opencode-go":
217		return "kimi-k2.6"
218	case "amazon-bedrock":
219		return "anthropic.claude-sonnet-4-5-20250929-v1:0"
220	case "google-vertex":
221		return "gemini-2.5-pro"
222	case "azure-openai-responses":
223		return "gpt-5"
224	case "github-copilot":
225		return "claude-sonnet-5"
226	default:
227		// Custom providers: pick the first model from the catalog for
228		// that provider, or fall back to the global default.
229		if models := provider.ModelsForProvider(prov); len(models) > 0 {
230			return models[0].ID
231		}
232		return provider.DefaultModel.ID
233	}
234}
235
236// knownProviders is the set of provider ids zot recognises. Used by
237// Resolve to validate args.Provider, by extension-callers, and by the
238// auto-fallback logic that picks any logged-in provider when the user's
239// preferred one has no credentials.
240var knownProviders = []string{
241	"anthropic", "openai", "openai-codex", "openai-responses", "kimi", "deepseek", "google", "ollama", provider.LlamaCPPProviderID, provider.LMStudioProviderID,
242	"moonshotai", "moonshotai-cn",
243	"cerebras", "groq", "xai", "together", "huggingface", "openrouter", "gondola", "yolo-auto",
244	"mistral", "zai",
245	"xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp",
246	"minimax", "minimax-cn",
247	"fireworks", "vercel-ai-gateway",
248	"opencode", "opencode-go",
249	"amazon-bedrock", "google-vertex", "azure-openai-responses",
250	"github-copilot", "cloudflare-workers-ai", "cloudflare-ai-gateway",
251}
252
253func isKnownProvider(name string) bool {
254	for _, p := range knownProviders {
255		if p == name {
256			return true
257		}
258	}
259	_, ok := provider.CustomProviders()[name]
260	return ok
261}
262
263// isBuiltinProvider reports whether name is in the hardcoded
264// knownProviders list (not a user-defined custom provider).
265func isBuiltinProvider(name string) bool {
266	for _, p := range knownProviders {
267		if p == name {
268			return true
269		}
270	}
271	return false
272}
273
274// providerAliases maps common short / alternate provider names to the
275// canonical id in knownProviders. Users (and other agents) reach for
276// "bedrock" or "vertex" far more naturally than the fully-qualified
277// "amazon-bedrock" / "google-vertex"; without this mapping an alias is
278// treated as an unknown provider and Resolve silently falls back to
279// anthropic, producing a misleading "no credential for anthropic" error
280// after the user explicitly picked, say, bedrock.
281var providerAliases = map[string]string{
282	"bedrock":      "amazon-bedrock",
283	"aws-bedrock":  "amazon-bedrock",
284	"amazon":       "amazon-bedrock",
285	"vertex":       "google-vertex",
286	"gcp-vertex":   "google-vertex",
287	"gemini":       "google",
288	"googleai":     "google",
289	"google-ai":    "google",
290	"azure":        "azure-openai-responses",
291	"azure-openai": "azure-openai-responses",
292	"copilot":      "github-copilot",
293	"github":       "github-copilot",
294	"codex":        "openai-codex",
295	"moonshot":     "moonshotai",
296	"kimi-code":    "kimi",
297	"ai-gateway":   "vercel-ai-gateway",
298	"vercel":       "vercel-ai-gateway",
299	"cloudflare":   "cloudflare-workers-ai",
300	"workers-ai":   "cloudflare-workers-ai",
301	"hf":           "huggingface",
302	"llama":        provider.LlamaCPPProviderID,
303	"llamacpp":     provider.LlamaCPPProviderID,
304}
305
306// canonicalProvider normalises a user-supplied provider name: trims
307// surrounding whitespace, lower-cases it, and resolves any known alias
308// to its canonical id. Unknown names are returned trimmed/lower-cased
309// and unchanged so the existing unknown-provider handling still runs.
310func canonicalProvider(name string) string {
311	n := strings.ToLower(strings.TrimSpace(name))
312	if n == "" {
313		return n
314	}
315	if canon, ok := providerAliases[n]; ok {
316		return canon
317	}
318	return n
319}
320
321// Resolve merges args, config, and env into a Resolved set.
322//
323// Unlike the earlier version, Resolve NEVER returns an error for
324// missing credentials: the TUI can start without them and launch a
325// login flow. requireCred controls whether missing credentials are a
326// hard error (used by print/json modes).
327func Resolve(args Args, requireCred bool) (Resolved, error) {
328	cfg, _ := LoadConfig()
329
330	// User-requested provider (explicit > config > default).
331	// Normalise common aliases (e.g. "bedrock" -> "amazon-bedrock")
332	// before validation so an alias is never mistaken for an unknown
333	// provider and silently downgraded to anthropic.
334	argProvider := canonicalProvider(args.Provider)
335	provName := firstNonEmpty(argProvider, canonicalProvider(cfg.Provider), "anthropic")
336	if !isKnownProvider(provName) {
337		// Unknown provider (maybe removed or renamed). Fall back to
338		// the first provider that has credentials, or anthropic.
339		// Custom providers (from models.json) are already accepted
340		// by isKnownProvider, so we only reach here for truly unknown
341		// names.
342		provName = "anthropic"
343		if CredentialAvailable("openai") {
344			provName = "openai"
345		}
346		if CredentialAvailable("openai-codex") {
347			provName = "openai-codex"
348		}
349		if CredentialAvailable("kimi") {
350			provName = "kimi"
351		}
352		if CredentialAvailable("deepseek") {
353			provName = "deepseek"
354		}
355		if CredentialAvailable("anthropic") {
356			provName = "anthropic"
357		}
358		// Reset the saved config so this doesn't keep happening.
359		cfg.Provider = provName
360		cfg.Model = ""
361		_ = SaveConfig(cfg)
362	}
363
364	var (
365		cred      string
366		method    string
367		accountID string
368		credErr   error
369	)
370	customCfg, customProvider := provider.CustomProviders()[provName]
371	keylessCustom := customProvider && !isBuiltinProvider(provName) && customCfg.NoAuth
372	if keylessCustom {
373		// Explicit keyless configuration takes precedence over CLI, env, and
374		// stored credentials. Never execute a stored API key command.
375	} else if args.inheritedCredential != "" {
376		cred = args.inheritedCredential
377		method = args.inheritedAuthMethod
378		accountID = args.inheritedAccountID
379	} else if provName == "ollama" {
380		cred = firstNonEmpty(args.APIKey, "ollama")
381		method = "apikey"
382	} else {
383		cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
384	}
385
386	// Persist --api-key for custom providers so subsequent runs don't
387	// need to pass it again.
388	if !isBuiltinProvider(provName) && !keylessCustom && args.APIKey != "" {
389		if store := AuthStoreFor(); store != nil {
390			_ = store.SetAPIKey(provName, args.APIKey)
391		}
392	}
393
394	// If the user did NOT explicitly pick a provider (neither via --provider
395	// nor by saving one in config.json) and the default one has no
396	// credentials, auto-fall-back to whichever provider is actually logged
397	// in. That way running plain `zot` after `/login` (any provider) never
398	// shows a "not logged in" banner.
399	//
400	// Critical: when the user HAS saved a provider in config.json (e.g.
401	// "deepseek" with "deepseek-v4-flash"), that choice is respected even
402	// if no credential is found — the tui will show the login dialog or
403	// surface a clear credential error rather than silently switching to
404	// a completely different provider and model.
405	// autoFellBack tracks whether the provider was changed by the
406	// auto-fallback loop (no explicit provider from CLI or config,
407	// and the default had no credentials). When false, the provider
408	// came from the user's explicit choice (--provider or config.json)
409	// and must be respected, including any model id that may belong
410	// to a different provider in the catalog — for example when using
411	// a gateway/router like OpenRouter with "deepseek/deepseek-v4-flash".
412	var autoFellBack bool
413	userPickedProvider := args.Provider != "" || cfg.Provider != ""
414	if credErr != nil && !userPickedProvider && provName != "ollama" {
415		autoFellBack = true
416		// Scan every known provider (not a hardcoded subset) so any
417		// env-based credential is discovered, e.g. an env-only
418		// amazon-bedrock setup (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE /
419		// IAM keys) when no config.json pins the provider, such as after
420		// pointing ZOT_HOME at a fresh home dir. Iteration order of
421		// knownProviders defines fallback priority. Local providers without a
422		// default model are skipped because selecting either one here would
423		// fail before the user can choose a model.
424		for _, other := range knownProviders {
425			if other == provName || other == "ollama" || other == provider.LlamaCPPProviderID || other == provider.LMStudioProviderID {
426				continue
427			}
428			if !CredentialAvailable(other) {
429				continue
430			}
431			c, m, a, err := ResolveCredentialFull(other, args.APIKey)
432			provName = other
433			cred, method, accountID, credErr = c, m, a, err
434			break
435		}
436	}
437
438	model := firstNonEmpty(args.Model, cfg.Model)
439	if model == "" {
440		if provName == "ollama" || provName == provider.LlamaCPPProviderID || provName == provider.LMStudioProviderID {
441			return Resolved{}, fmt.Errorf("%s requires --model or a model selected from its manager", provName)
442		}
443		model = defaultModelForProvider(provName)
444	}
445	// If the resolved model belongs to a different provider (e.g. config
446	// says gpt-5 but we auto-fell back to anthropic), pick that provider's
447	// default. This override only fires when the provider was auto-fallback-
448	// changed, NOT when the user explicitly configured the pair — gateway/
449	// router providers (openrouter, vercel-ai-gateway, etc.) can serve
450	// models from any provider in the catalog.
451	if autoFellBack && provName != "ollama" {
452		if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
453			model = defaultModelForProvider(provName)
454		}
455	}
456	if provName == provider.LMStudioProviderID && LMStudioConfigured() {
457		// Restore transient metadata when resuming a local model after restart.
458		_ = RefreshLMStudioModels(context.Background())
459	}
460	if isDiscoverableCustomProvider(provName) {
461		if _, err := provider.FindModel(provName, model); err != nil {
462			// Same restore for discovery-enabled custom providers. Skipped
463			// when the model is already known to avoid a probe per launch.
464			_ = RefreshCustomProviderModels(context.Background())
465		}
466	}
467	resolvedModel, err := provider.FindModel(provName, model)
468	if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID || provName == provider.LMStudioProviderID) {
469		// Local providers are intentionally open-catalogue: any model id the
470		// configured server understands is valid, even if it is not cached.
471		resolvedModel = provider.Model{
472			Provider:      provName,
473			ID:            model,
474			DisplayName:   model,
475			ContextWindow: 128000,
476			MaxOutput:     16384,
477			BaseURL:       args.BaseURL,
478			Source:        provName,
479		}
480		if provName == provider.LMStudioProviderID {
481			resolvedModel.ContextWindow = 32768
482			resolvedModel.MaxOutput = 4096
483		}
484		err = nil
485	}
486	// Custom providers are open-catalogue like ollama: any model id the
487	// endpoint understands is valid. Use the provider-level base URL.
488	if err != nil {
489		if cfg, ok := provider.CustomProviders()[provName]; ok {
490			resolvedModel = provider.Model{
491				Provider:    provName,
492				ID:          model,
493				DisplayName: model,
494				BaseURL:     cfg.BaseURL,
495				Source:      "user",
496			}
497			err = nil
498		}
499	}
500	if cfg, ok := provider.CustomProviders()[provName]; ok && err == nil && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {
501		// Fall back to the provider-level base URL when the model does
502		// not define its own endpoint.
503		resolvedModel.BaseURL = cfg.BaseURL
504	}
505	if err != nil {
506		// The model the user (or persisted config) asked for is no
507		// longer in the active catalogue — they probably removed it
508		// from their models.json or upgraded zot and the id changed.
509		// Refusing to launch is the wrong move: it strands the user
510		// with no way to even open the TUI and pick a new model.
511		// Fall back to the provider's default, warn on stderr, and,
512		// when the stale id came from the persisted config (not an
513		// explicit --model flag), repair the config so the warning
514		// doesn't repeat on every launch.
515
516		// Gateway providers can accept route-qualified ids that are not in
517		// zot's local catalog yet, for example OpenRouter's
518		// "deepseek/deepseek-v4-flash". Preserve only route-qualified ids;
519		// plain unknown values are likely typos and should still fall back.
520		if isGatewayProvider(provName) && isGatewayRoutedModelID(model) {
521			resolvedModel = provider.Model{
522				Provider:      provName,
523				ID:            model,
524				DisplayName:   model,
525				ContextWindow: 1000000,
526				MaxOutput:     64000,
527				BaseURL:       "",
528				Source:        "gateway",
529			}
530			err = nil
531			// Don't repair config — the routed model id may be valid upstream.
532		} else {
533			fallback := defaultModelForProvider(provName)
534			fm, ferr := provider.FindModel(provName, fallback)
535			if ferr != nil {
536				// Even the provider default is gone (catastrophic
537				// catalogue trim). Last resort: any model on this
538				// provider, then the global DefaultModel.
539				if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 {
540					fm = candidates[0]
541					ferr = nil
542				} else {
543					fm = provider.DefaultModel
544					ferr = nil
545				}
546			}
547			fmt.Fprintf(os.Stderr,
548				"zot: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
549				model, fm.ID)
550			if args.Model == "" && cfg.Model == model {
551				cfg.Model = fm.ID
552				_ = SaveConfig(cfg)
553			}
554			resolvedModel = fm
555			model = fm.ID
556		}
557	}
558
559	// Discovered custom-provider models inherit the user's models.json
560	// endpoint, so they count as explicit for --insecure scoping too.
561	discoveredCustom := resolvedModel.Source == "live" && isDiscoverableCustomProvider(provName)
562	explicitBaseURL := args.BaseURL != "" || ((resolvedModel.Source == "user" || discoveredCustom) && resolvedModel.BaseURL != "")
563
564	// If the model defines a base URL (e.g. local ollama) and the
565	// user didn't pass --base-url, use the model's URL. For ollama,
566	// keep http://localhost:11434 as a fallback only after the model
567	// metadata has had a chance to provide a custom baseUrl.
568	if args.BaseURL == "" && resolvedModel.BaseURL != "" && (provName != provider.LMStudioProviderID || resolvedModel.Source == "user") {
569		// LM Studio discovery can retain an older snapshot on failure.
570		// Use its currently registered endpoint below, never a stale URL
571		// with the credentials for a newly registered server.
572		args.BaseURL = resolvedModel.BaseURL
573	}
574	if args.BaseURL == "" && provName == "ollama" {
575		args.BaseURL = "http://localhost:11434"
576	}
577	if args.BaseURL == "" && provName == provider.LMStudioProviderID {
578		root, _, configErr := resolveLMStudioConfig(context.Background(), apiKeyCommandExecute)
579		if configErr != nil {
580			return Resolved{}, configErr
581		}
582		if root == "" {
583			return Resolved{}, fmt.Errorf("LM Studio requires a server URL registered through /login or an explicit --base-url")
584		}
585		args.BaseURL = root + "/v1"
586	}
587	if args.BaseURL == "" && provName == provider.LlamaCPPProviderID {
588		managementURL, _, configErr := ResolveLlamaCPPConfig()
589		if configErr != nil {
590			return Resolved{}, configErr
591		}
592		var baseErr error
593		args.BaseURL, baseErr = provider.LlamaCPPInferenceURL(managementURL)
594		if baseErr != nil {
595			return Resolved{}, baseErr
596		}
597	}
598
599	// Insecure TLS is intentionally scoped to explicit custom endpoints.
600	// Built-in provider base URLs, auth calls, and model discovery keep normal
601	// certificate verification even when --insecure is present.
602	insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL
603
604	// If the model has a base URL, credentials are optional (local
605	// models like ollama don't need real API keys).
606	credentialOptional := false
607	if keylessCustom {
608		if args.BaseURL == "" {
609			return Resolved{}, fmt.Errorf("custom provider %q has auth none but no base URL", provName)
610		}
611		credentialOptional = true
612	} else if resolvedModel.BaseURL != "" && credErr != nil {
613		if _, isCustom := provider.CustomProviders()[provName]; isCustom {
614			// A custom endpoint may be configured for keyless access. Keep the
615			// credential empty so the provider does not send a placeholder token.
616			credentialOptional = true
617		} else {
618			cred = "ollama"
619			if provName == "amazon-bedrock" {
620				// The default AWS CLI profile is resolved by the Bedrock client,
621				// not the general credential resolver. Never pass the local-model
622				// placeholder as a bearer token.
623				cred = "<aws>"
624			}
625		}
626		credErr = nil
627		requireCred = false
628	}
629
630	if credErr != nil && requireCred {
631		return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
632			credErr, envVarName(provName))
633	}
634
635	sandbox := tools.NewSandbox(args.CWD)
636	if cfg.JailByDefault != nil && *cfg.JailByDefault {
637		sandbox.Lock()
638	}
639	if args.PermissionSet != nil {
640		sandbox.SetPermissions(args.PermissionSet)
641	}
642	reg := buildConfiguredToolRegistry(args, args.CWD, sandbox, cfg, runtime.GOOS)
643	if !args.NoTools && provName == "openrouter" && !isOpenRouterPresetModel(model) && OpenRouterServerToolsEnabled() {
644		reg = withOpenRouterServerTools(reg)
645	}
646
647	docsDir, _ := zotdocs.EnsureInstalled(ZotHome())
648
649	// Skill discovery: scan project + global locations + built-in
650	// skills shipped with the binary. If any are found, register
651	// the on-demand `skill` loader tool plus a system-prompt
652	// manifest so the model knows what's available.
653	//
654	// --no-skill bypasses the entire mechanism: no manifest in the
655	// system prompt, no `skill` tool in the registry. Useful for a
656	// clean-room run with zero extra context biasing the model.
657	var (
658		discovered       []*skills.Skill
659		skillTool        *skills.Tool
660		skillAddendum    string
661		skillDiagnostics []string
662	)
663	if !args.NoSkill {
664		homeDir, _ := os.UserHomeDir()
665		var sources []skills.Source
666		if args.WithSkills {
667			for _, loc := range skills.SearchSources(ZotHome(), args.CWD, homeDir) {
668				sources = append(sources, loc)
669			}
670		}
671		if !args.NoExt || len(args.Exts) > 0 {
672			extSources, errs := extensions.PlanSkillSources(ZotHome(), args.CWD, args.Exts, !args.NoExt)
673			sources = append(extSources, sources...)
674			for _, err := range errs {
675				skillDiagnostics = append(skillDiagnostics, err.Error())
676			}
677		}
678		var errs []error
679		discovered, errs = skills.DiscoverSources(sources, true)
680		for _, err := range errs {
681			skillDiagnostics = append(skillDiagnostics, err.Error())
682		}
683		if len(discovered) > 0 {
684			skillTool = skills.NewTool(discovered)
685			reg[skillTool.Name()] = skillTool
686			skillAddendum = skills.SystemPromptAddendum(discovered)
687		}
688	}
689	_ = skillTool
690
691	summaries := toolSummaries(reg, args)
692
693	var contextFiles []ContextFile
694	if !args.NoContextFiles {
695		contextFiles = loadAgentsContext(args.CWD, ZotHome())
696	}
697	append_ := append([]string(nil), args.AppendSystemPrompt...)
698	if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" {
699		append_ = append(append_, agentsAddendum)
700	}
701	if skillAddendum != "" {
702		append_ = append(append_, skillAddendum)
703	}
704	if AutoSwarmEnabled() {
705		append_ = append(append_, AutoSwarmSystemAddendum)
706	}
707
708	// Custom system prompt resolution order:
709	//   1. --system-prompt flag (highest priority; ad-hoc per run)
710	//   2. $ZOT_HOME/SYSTEM.md (persistent user override)
711	//   3. built-in default (defaultIdentity + defaultGuidelines)
712	custom := args.SystemPrompt
713	customSet := args.SystemPromptSet || custom != ""
714	if !customSet {
715		custom = readUserSystemPrompt(ZotHome())
716		customSet = custom != ""
717	}
718
719	sys := BuildSystemPrompt(SystemPromptOpts{
720		CWD:        args.CWD,
721		Tools:      summaries,
722		Custom:     custom,
723		CustomSet:  customSet,
724		Append:     append_,
725		ZotDocsDir: docsDir,
726	})
727
728	reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
729	temperature := args.Temperature
730	if temperature == nil {
731		temperature = cfg.Temperature
732	}
733
734	max := args.MaxSteps // 0 = unlimited
735
736	return Resolved{
737		Provider:           provName,
738		Model:              model,
739		Credential:         cred,
740		AuthMethod:         method,
741		AccountID:          accountID,
742		BaseURL:            args.BaseURL,
743		InsecureTLS:        insecureTLS,
744		CWD:                args.CWD,
745		Reasoning:          reasoning,
746		Temperature:        temperature,
747		ToolRegistry:       reg,
748		ToolSummary:        summaries,
749		SystemPrompt:       sys,
750		MaxSteps:           max,
751		MaxOutput:          resolvedModel.MaxOutput,
752		Sandbox:            sandbox,
753		SkillTool:          skillTool,
754		SkillDiagnostics:   skillDiagnostics,
755		ContextFiles:       contextFiles,
756		credentialOptional: credentialOptional,
757		systemAppend:       append_,
758		systemCustom:       custom,
759		systemCustomSet:    customSet,
760		toolDescriptions:   descMapFromSummaries(summaries),
761	}, nil
762}
763
764// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
765// trimmed contents, or "" when the file is missing / unreadable /
766// empty. Errors are intentionally swallowed: the file is optional,
767// and any failure to read it should fall back to the built-in
768// default system prompt rather than crash the run.
769func readUserSystemPrompt(zotHome string) string {
770	if zotHome == "" {
771		return ""
772	}
773	path := filepath.Join(zotHome, "SYSTEM.md")
774	raw, err := os.ReadFile(path)
775	if err != nil {
776		return ""
777	}
778	return strings.TrimSpace(string(raw))
779}
780
781// loadAgentsContext loads optional AGENTS.md instruction files. No
782// default file is created or required: zot only includes files that
783// already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first,
784// followed by project instructions from the filesystem root down to cwd.
785func loadAgentsContext(cwd, zotHome string) []ContextFile {
786	var files []ContextFile
787	seen := map[string]bool{}
788	add := func(path string) {
789		if path == "" {
790			return
791		}
792		abs, err := filepath.Abs(path)
793		if err == nil {
794			path = abs
795		}
796		if seen[path] {
797			return
798		}
799		raw, err := os.ReadFile(path)
800		if err != nil {
801			return
802		}
803		content := strings.TrimSpace(string(raw))
804		if content == "" {
805			return
806		}
807		seen[path] = true
808		files = append(files, ContextFile{Path: path, Content: content})
809	}
810	addFirstFromDir := func(dir string) {
811		if dir == "" {
812			return
813		}
814		for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
815			path := filepath.Join(dir, name)
816			if _, err := os.Stat(path); err == nil {
817				add(path)
818				return
819			}
820		}
821	}
822
823	addFirstFromDir(zotHome)
824
825	if cwd != "" {
826		abs, err := filepath.Abs(cwd)
827		if err == nil {
828			cwd = abs
829		}
830		var dirs []string
831		for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
832			dirs = append(dirs, dir)
833			parent := filepath.Dir(dir)
834			if parent == dir {
835				break
836			}
837		}
838		for i := len(dirs) - 1; i >= 0; i-- {
839			addFirstFromDir(dirs[i])
840		}
841	}
842
843	return files
844}
845
846func formatAgentsContext(files []ContextFile) string {
847	if len(files) == 0 {
848		return ""
849	}
850	var sb strings.Builder
851	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")
852	for _, f := range files {
853		fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.Path, f.Content)
854	}
855	return strings.TrimSpace(sb.String())
856}
857
858// descMapFromSummaries indexes the human-readable descriptions for
859// the renderToolsSection rebuild path.
860func descMapFromSummaries(summaries []ToolSummary) map[string]string {
861	out := make(map[string]string, len(summaries))
862	for _, s := range summaries {
863		out[s.Name] = s.Description
864	}
865	return out
866}
867
868// NewClient returns a provider.Client for r, choosing the auth mode
869// based on r.AuthMethod. Panics if no credential or usable keyless
870// endpoint is present; callers must check HasCredential() first.
871func (r Resolved) NewClient() provider.Client {
872	if !r.HasCredential() {
873		panic("NewClient called without credential or keyless endpoint; check HasCredential first")
874	}
875	wrap := r.withHTTPClient
876	switch r.Provider {
877	case "ollama":
878		return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
879	case provider.LlamaCPPProviderID, provider.LMStudioProviderID:
880		credential := r.Credential
881		if credential == "local" {
882			credential = ""
883		}
884		return wrap(provider.NewOpenAI(credential, r.BaseURL))
885	case "kimi":
886		// kimi-coding speaks anthropic-messages on api.kimi.com/coding.
887		// Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
888		inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
889		if r.AuthMethod == "oauth" {
890			return r.wrapWithRefresh(inner)
891		}
892		return inner
893	case "moonshotai":
894		return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
895	case "moonshotai-cn":
896		return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
897	case "deepseek":
898		return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
899	case "openai":
900		return provider.NewModelRouter("openai",
901			wrap(provider.NewOpenAI(r.Credential, r.BaseURL)),
902			map[string]provider.Client{
903				provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")),
904			})
905	case "openai-codex":
906		inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
907		return r.wrapWithRefresh(inner)
908	case "openai-responses":
909		// Public OpenAI Responses API (api.openai.com/v1/responses) via
910		// API key. Separate provider from `openai` (Chat Completions) and
911		// from `openai-codex` (ChatGPT subscription OAuth).
912		return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
913	case "google":
914		return wrap(provider.NewGemini(r.Credential, r.BaseURL))
915	case "cerebras":
916		return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
917	case "groq":
918		return wrap(provider.NewGroq(r.Credential, r.BaseURL))
919	case "xai":
920		inner := r.newXAIClient(r.Credential)
921		if r.AuthMethod == "oauth" {
922			return r.wrapWithRefresh(inner)
923		}
924		return inner
925	case "together":
926		return wrap(provider.NewTogether(r.Credential, r.BaseURL))
927	case "huggingface":
928		return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
929	case "openrouter":
930		return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
931	case "gondola":
932		return wrap(provider.NewGondola(r.Credential, r.BaseURL))
933	case "yolo-auto":
934		return wrap(provider.NewYoloAuto(r.Credential, r.BaseURL))
935	case "zai":
936		return wrap(provider.NewZAI(r.Credential, r.BaseURL))
937	case "xiaomi":
938		return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
939	case "xiaomi-token-plan-ams":
940		return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
941	case "xiaomi-token-plan-cn":
942		return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
943	case "xiaomi-token-plan-sgp":
944		return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
945	case "opencode":
946		return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
947	case "opencode-go":
948		return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL))
949	case "minimax":
950		return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
951	case "minimax-cn":
952		return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
953	case "fireworks":
954		return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
955	case "vercel-ai-gateway":
956		return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
957	case "mistral":
958		return wrap(provider.NewMistral(r.Credential, r.BaseURL))
959	case "amazon-bedrock":
960		return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
961	case "google-vertex":
962		return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
963	case "azure-openai-responses":
964		return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
965	case "github-copilot":
966		return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
967	case "cloudflare-workers-ai":
968		return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
969	case "cloudflare-ai-gateway":
970		return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
971	default:
972		// Custom providers: choose wire format from the models.json api field.
973		if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
974			switch cfg.API {
975			case provider.APIResponses:
976				return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider))
977			case "anthropic":
978				return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
979			default: // "openai"
980				return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
981			}
982		}
983		if r.AuthMethod == "oauth" {
984			inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
985			return r.wrapWithRefresh(inner)
986		}
987		return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
988	}
989}
990
991func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
992	if !r.InsecureTLS {
993		return c
994	}
995	return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
996}
997
998func (r Resolved) newXAIClient(token string) provider.Client {
999	return provider.NewModelRouter("xai",
1000		r.withHTTPClient(provider.NewXAI(token, r.BaseURL)),
1001		map[string]provider.Client{
1002			provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")),
1003		})
1004}
1005
1006// wrapWithRefresh wraps an OAuth client so the access token is
1007// refreshed automatically before each API call. Without this, long
1008// sessions (hours) silently fail when the 1-hour token expires.
1009func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
1010	provName := r.Provider
1011	tokenProvider := provName
1012	if provName == "openai-codex" {
1013		tokenProvider = "openai"
1014	}
1015	baseURL := r.BaseURL
1016	accountID := r.AccountID
1017
1018	refreshFn := func(ctx context.Context) (string, error) {
1019		tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
1020		if err != nil {
1021			return "", err
1022		}
1023		return tok.AccessToken, nil
1024	}
1025
1026	factory := func(token string) provider.Client {
1027		switch provName {
1028		case "openai-codex":
1029			return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
1030		case "kimi":
1031			// anthropic-messages on api.kimi.com/coding.
1032			return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
1033		case "xai":
1034			return r.newXAIClient(token)
1035		default:
1036			return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
1037		}
1038	}
1039
1040	return provider.NewRefreshingClient(inner, refreshFn, factory)
1041}
1042
1043// UseSandbox replaces the sandbox pointer that every tool in r's
1044// registry references. Used to keep the /jail state stable across
1045// agent rebuilds (e.g. /login, /model switching providers).
1046func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1047	if s == nil || r == nil {
1048		return
1049	}
1050	if r.Sandbox != nil && r.Sandbox.Permissions != nil {
1051		s.Permissions = r.Sandbox.Permissions
1052	}
1053	r.Sandbox = s
1054	for name, t := range r.ToolRegistry {
1055		switch v := t.(type) {
1056		case *tools.ReadTool:
1057			v.Sandbox = s
1058		case *tools.WriteTool:
1059			v.Sandbox = s
1060		case *tools.EditTool:
1061			v.Sandbox = s
1062		case *tools.BashTool:
1063			v.Sandbox = s
1064		case *tools.PowerShellTool:
1065			v.Sandbox = s
1066		case *tools.GlobTool:
1067			v.Sandbox = s
1068		}
1069		_ = name
1070	}
1071}
1072
1073// NewAgent constructs a core.Agent from r. Requires a credential.
1074func (r Resolved) NewAgent() *core.Agent {
1075	a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
1076	a.MaxSteps = r.MaxSteps
1077	a.MaxTokens = r.MaxOutput
1078	a.Reasoning = r.Reasoning
1079	a.Temperature = r.Temperature
1080	if hasOpenRouterServerTools(r.ToolRegistry) {
1081		a.MaxToolCalls = openRouterServerToolCallLimit()
1082	}
1083	return a
1084}
1085
1086// openRouterServerToolCallLimit returns the configured call limit from
1087// config.json, falling back to the built-in default (4).
1088func openRouterServerToolCallLimit() int {
1089	cfg, err := LoadConfig()
1090	if err != nil {
1091		return tools.OpenRouterServerToolCallLimit
1092	}
1093	if cfg.OpenRouterServerToolCallLimit != nil && *cfg.OpenRouterServerToolCallLimit > 0 {
1094		return *cfg.OpenRouterServerToolCallLimit
1095	}
1096	return tools.OpenRouterServerToolCallLimit
1097}
1098
1099// OpenRouterServerToolsEnabled reports the persisted preference.
1100// nil/missing and unreadable configuration fail closed to disabled.
1101func OpenRouterServerToolsEnabled() bool {
1102	cfg, err := LoadConfig()
1103	if err != nil {
1104		return false
1105	}
1106	return cfg.OpenRouterServerToolsEnabled != nil && *cfg.OpenRouterServerToolsEnabled
1107}
1108
1109func isOpenRouterPresetModel(model string) bool {
1110	return strings.Contains(strings.ToLower(model), "@preset/")
1111}
1112
1113func hasOpenRouterServerTools(reg core.Registry) bool {
1114	for name := range reg {
1115		if tools.IsOpenRouterServerTool(name) {
1116			return true
1117		}
1118	}
1119	return false
1120}
1121
1122func withOpenRouterServerTools(reg core.Registry) core.Registry {
1123	out := core.Registry{}
1124	for name, t := range reg {
1125		out[name] = t
1126	}
1127	for _, t := range tools.OpenRouterServerTools() {
1128		out[t.Name()] = t
1129	}
1130	return out
1131}
1132
1133func buildConfiguredToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, cfg Config, goos string) core.Registry {
1134	reg := buildToolRegistry(args, cwd, sandbox)
1135	if goos == "windows" && !args.NoTools && len(args.Tools) == 0 && cfg.PowerShellEnabled != nil && *cfg.PowerShellEnabled {
1136		reg["powershell"] = &tools.PowerShellTool{CWD: cwd, Sandbox: sandbox}
1137	}
1138	return reg
1139}
1140
1141func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
1142	if args.NoTools {
1143		return core.Registry{}
1144	}
1145	all := map[string]core.Tool{
1146		"read":  &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1147		"write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
1148		"edit":  &tools.EditTool{CWD: cwd, Sandbox: sandbox},
1149		"bash":  &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1150		"glob":  &tools.GlobTool{CWD: cwd, Sandbox: sandbox},
1151	}
1152	reg := core.Registry{}
1153	if len(args.Tools) == 0 {
1154		for _, t := range all {
1155			reg[t.Name()] = t
1156		}
1157		return reg
1158	}
1159	// PowerShell is explicitly selected, never added to the default tool set.
1160	all["powershell"] = &tools.PowerShellTool{CWD: cwd, Sandbox: sandbox}
1161	for _, name := range args.Tools {
1162		if t, ok := all[name]; ok {
1163			reg[name] = t
1164		}
1165	}
1166	return reg
1167}
1168
1169func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1170	order := []string{"read", "write", "edit", "bash", "powershell", "glob"}
1171	var out []ToolSummary
1172	for _, name := range order {
1173		if t, ok := reg[name]; ok {
1174			out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
1175		}
1176	}
1177	return out
1178}
1179
1180func firstNonEmpty(vals ...string) string {
1181	for _, v := range vals {
1182		if v != "" {
1183			return v
1184		}
1185	}
1186	return ""
1187}
1188
1189func kimiCodeHeaders() map[string]string {
1190	host, _ := os.Hostname()
1191	if host == "" {
1192		host = "unknown"
1193	}
1194	deviceID := ""
1195	if home, err := os.UserHomeDir(); err == nil {
1196		if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
1197			deviceID = strings.TrimSpace(string(b))
1198		}
1199	}
1200	if deviceID == "" {
1201		deviceID = "zot"
1202	}
1203	return map[string]string{
1204		"User-Agent":         "KimiCLI/1.41.0",
1205		"X-Msh-Platform":     "kimi_cli",
1206		"X-Msh-Version":      "1.41.0",
1207		"X-Msh-Device-Name":  host,
1208		"X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
1209		"X-Msh-Os-Version":   runtime.GOOS,
1210		"X-Msh-Device-Id":    deviceID,
1211	}
1212}
1213
1214func envVarName(provider string) string {
1215	switch provider {
1216	case "openai", "openai-codex", "openai-responses":
1217		return "OPENAI"
1218	case "kimi":
1219		return "KIMI"
1220	case "deepseek":
1221		return "DEEPSEEK"
1222	case "google":
1223		return "GEMINI"
1224	case "ollama":
1225		return "OLLAMA"
1226	case "llama.cpp":
1227		return "LLAMA"
1228	case "moonshotai", "moonshotai-cn":
1229		return "MOONSHOT"
1230	case "groq":
1231		return "GROQ"
1232	case "xai":
1233		return "XAI"
1234	case "cerebras":
1235		return "CEREBRAS"
1236	case "together":
1237		return "TOGETHER"
1238	case "huggingface":
1239		return "HF"
1240	case "openrouter":
1241		return "OPENROUTER"
1242	case "gondola":
1243		return "GONDOLA"
1244	case "yolo-auto":
1245		return "YOLO_AUTO"
1246	case "mistral":
1247		return "MISTRAL"
1248	case "zai":
1249		return "ZAI"
1250	case "xiaomi":
1251		return "XIAOMI"
1252	case "xiaomi-token-plan-ams":
1253		return "XIAOMI_TOKEN_PLAN_AMS"
1254	case "xiaomi-token-plan-cn":
1255		return "XIAOMI_TOKEN_PLAN_CN"
1256	case "xiaomi-token-plan-sgp":
1257		return "XIAOMI_TOKEN_PLAN_SGP"
1258	case "minimax":
1259		return "MINIMAX"
1260	case "minimax-cn":
1261		return "MINIMAX_CN"
1262	case "fireworks":
1263		return "FIREWORKS"
1264	case "vercel-ai-gateway":
1265		return "AI_GATEWAY"
1266	case "opencode", "opencode-go":
1267		return "OPENCODE"
1268	case "github-copilot":
1269		return "COPILOT_GITHUB_TOKEN"
1270	case "cloudflare-workers-ai", "cloudflare-ai-gateway":
1271		return "CLOUDFLARE"
1272	case "amazon-bedrock":
1273		return "AWS"
1274	case "google-vertex":
1275		return "GOOGLE_CLOUD"
1276	case "azure-openai-responses":
1277		return "AZURE_OPENAI"
1278	default:
1279		return "ANTHROPIC"
1280	}
1281}

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 476

↑↓ moveenter openesc close