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