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 can opt out of the effective host/child fast-mode setting,
784 // but it does not enable fast mode by itself. Keep this as a
785 // restriction on the effective value rather than an override so an
786 // explicit child --fast-mode flag cannot bypass fastMode: false.
787 if selectedProfile != nil && selectedProfile.FastMode != nil {
788 fastMode = fastMode && *selectedProfile.FastMode
789 }
790
791 max := args.MaxSteps // 0 = unlimited
792
793 return Resolved{
794 Provider: provName,
795 Model: model,
796 Credential: cred,
797 AuthMethod: method,
798 AccountID: accountID,
799 BaseURL: args.BaseURL,
800 InsecureTLS: insecureTLS,
801 CWD: args.CWD,
802 Reasoning: reasoning,
803 Temperature: temperature,
804 FastMode: fastMode,
805 ToolRegistry: reg,
806 ToolSummary: summaries,
807 SystemPrompt: sys,
808 MaxSteps: max,
809 MaxOutput: resolvedModel.MaxOutput,
810 Sandbox: sandbox,
811 SkillTool: skillTool,
812 skillsEnabled: skillsEnabled,
813 ContextFiles: contextFiles,
814 systemAppend: append_,
815 systemCustom: custom,
816 skillAddendum: skillAddendum,
817 toolDescriptions: descMapFromSummaries(summaries),
818 }, nil
819}
820
821// readUserSystemPrompt looks for $ZUT_HOME/SYSTEM.md and returns its
822// trimmed contents, or "" when the file is missing / unreadable /
823// empty. Errors are intentionally swallowed: the file is optional,
824// and any failure to read it should fall back to the built-in
825// default system prompt rather than crash the run.
826func readUserSystemPrompt(zutHome string) string {
827 if zutHome == "" {
828 return ""
829 }
830 path := filepath.Join(zutHome, "SYSTEM.md")
831 raw, err := os.ReadFile(path)
832 if err != nil {
833 return ""
834 }
835 return strings.TrimSpace(string(raw))
836}
837
838// loadAgentsContext loads optional AGENTS.md instruction files. No
839// default file is created or required: zut only includes files that
840// already exist. Global instructions ($ZUT_HOME/AGENTS.md) come first,
841// followed by project instructions from the filesystem root down to cwd.
842func loadAgentsContext(cwd, zutHome string) []ContextFile {
843 var files []ContextFile
844 seen := map[string]bool{}
845 add := func(path string) {
846 if path == "" {
847 return
848 }
849 abs, err := filepath.Abs(path)
850 if err == nil {
851 path = abs
852 }
853 if seen[path] {
854 return
855 }
856 raw, err := os.ReadFile(path)
857 if err != nil {
858 return
859 }
860 content := strings.TrimSpace(string(raw))
861 if content == "" {
862 return
863 }
864 seen[path] = true
865 files = append(files, ContextFile{Path: path, Content: content})
866 }
867 addFirstFromDir := func(dir string) {
868 if dir == "" {
869 return
870 }
871 for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
872 path := filepath.Join(dir, name)
873 if _, err := os.Stat(path); err == nil {
874 add(path)
875 return
876 }
877 }
878 }
879
880 addFirstFromDir(zutHome)
881
882 if cwd != "" {
883 abs, err := filepath.Abs(cwd)
884 if err == nil {
885 cwd = abs
886 }
887 var dirs []string
888 for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
889 dirs = append(dirs, dir)
890 parent := filepath.Dir(dir)
891 if parent == dir {
892 break
893 }
894 }
895 for i := len(dirs) - 1; i >= 0; i-- {
896 addFirstFromDir(dirs[i])
897 }
898 }
899
900 return files
901}
902
903func formatAgentsContext(files []ContextFile) string {
904 if len(files) == 0 {
905 return ""
906 }
907 var sb strings.Builder
908 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")
909 for _, f := range files {
910 fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.Path, f.Content)
911 }
912 return strings.TrimSpace(sb.String())
913}
914
915// descMapFromSummaries indexes the human-readable descriptions for
916// the renderToolsSection rebuild path.
917func descMapFromSummaries(summaries []ToolSummary) map[string]string {
918 out := make(map[string]string, len(summaries))
919 for _, s := range summaries {
920 out[s.Name] = s.Description
921 }
922 return out
923}
924
925// NewClient returns a provider.Client for r, choosing the auth mode
926// based on r.AuthMethod. Panics if no credential is present; callers
927// must check HasCredential() first.
928func (r Resolved) NewClient() provider.Client {
929 if !r.HasCredential() {
930 panic("NewClient called without credential; check HasCredential first")
931 }
932 wrap := r.withHTTPClient
933 switch r.Provider {
934 case "ollama":
935 return wrap(provider.NewOpenAICompat("ollama", r.Credential, r.BaseURL, ""))
936 case provider.LlamaCPPProviderID:
937 credential := r.Credential
938 if credential == "local" {
939 credential = ""
940 }
941 return wrap(provider.NewOpenAICompat(provider.LlamaCPPProviderID, credential, r.BaseURL, ""))
942 case "kimi":
943 // kimi-coding speaks anthropic-messages on api.kimi.com/coding.
944 // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
945 inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
946 if r.AuthMethod == "oauth" {
947 return r.wrapWithRefresh(inner)
948 }
949 return inner
950 case "moonshotai":
951 return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
952 case "moonshotai-cn":
953 return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
954 case "deepseek":
955 return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
956 case "openai":
957 return provider.NewModelRouter("openai",
958 wrap(provider.NewOpenAI(r.Credential, r.BaseURL)),
959 map[string]provider.Client{
960 provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")),
961 })
962 case "openai-codex":
963 inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
964 return r.wrapWithRefresh(inner)
965 case "openai-responses":
966 // Public OpenAI Responses API (api.openai.com/v1/responses) via
967 // API key. Separate provider from `openai` (Chat Completions) and
968 // from `openai-codex` (ChatGPT subscription OAuth).
969 return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
970 case "google":
971 return wrap(provider.NewGemini(r.Credential, r.BaseURL))
972 case "cerebras":
973 return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
974 case "groq":
975 return wrap(provider.NewGroq(r.Credential, r.BaseURL))
976 case "xai":
977 inner := r.newXAIClient(r.Credential)
978 if r.AuthMethod == "oauth" {
979 return r.wrapWithRefresh(inner)
980 }
981 return inner
982 case "together":
983 return wrap(provider.NewTogether(r.Credential, r.BaseURL))
984 case "huggingface":
985 return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
986 case "openrouter":
987 return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
988 case "zai":
989 return wrap(provider.NewZAI(r.Credential, r.BaseURL))
990 case "xiaomi":
991 return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
992 case "xiaomi-token-plan-ams":
993 return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
994 case "xiaomi-token-plan-cn":
995 return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
996 case "xiaomi-token-plan-sgp":
997 return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
998 case "opencode":
999 return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
1000 case "opencode-go":
1001 return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL))
1002 case "minimax":
1003 return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
1004 case "minimax-cn":
1005 return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
1006 case "fireworks":
1007 return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
1008 case "vercel-ai-gateway":
1009 return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
1010 case "mistral":
1011 return wrap(provider.NewMistral(r.Credential, r.BaseURL))
1012 case "amazon-bedrock":
1013 return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
1014 case "google-vertex":
1015 return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
1016 case "azure-openai-responses":
1017 return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
1018 case "github-copilot":
1019 return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
1020 case "cloudflare-workers-ai":
1021 return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
1022 case "cloudflare-ai-gateway":
1023 return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
1024 default:
1025 // Custom providers: choose wire format from the models.json api field.
1026 if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
1027 switch cfg.API {
1028 case provider.APIResponses:
1029 return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider))
1030 case "anthropic":
1031 return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
1032 default: // "openai"
1033 return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
1034 }
1035 }
1036 if r.AuthMethod == "oauth" {
1037 inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
1038 return r.wrapWithRefresh(inner)
1039 }
1040 return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
1041 }
1042}
1043
1044func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
1045 if !r.InsecureTLS {
1046 return c
1047 }
1048 return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
1049}
1050
1051func (r Resolved) newXAIClient(token string) provider.Client {
1052 return provider.NewModelRouter("xai",
1053 r.withHTTPClient(provider.NewXAI(token, r.BaseURL)),
1054 map[string]provider.Client{
1055 provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")),
1056 })
1057}
1058
1059// wrapWithRefresh wraps an OAuth client so the access token is
1060// refreshed automatically before each API call. Without this, long
1061// sessions (hours) silently fail when the 1-hour token expires.
1062func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
1063 provName := r.Provider
1064 tokenProvider := provName
1065 if provName == "openai-codex" {
1066 tokenProvider = "openai"
1067 }
1068 baseURL := r.BaseURL
1069 accountID := r.AccountID
1070
1071 refreshFn := func(ctx context.Context) (string, error) {
1072 tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
1073 if err != nil {
1074 return "", err
1075 }
1076 return tok.AccessToken, nil
1077 }
1078
1079 factory := func(token string) provider.Client {
1080 switch provName {
1081 case "openai-codex":
1082 return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
1083 case "kimi":
1084 // anthropic-messages on api.kimi.com/coding.
1085 return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
1086 case "xai":
1087 return r.newXAIClient(token)
1088 default:
1089 return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
1090 }
1091 }
1092
1093 return provider.NewRefreshingClient(inner, refreshFn, factory)
1094}
1095
1096// UseSandbox replaces the sandbox pointer that every tool in r's
1097// registry references. Used to keep the /jail state stable across
1098// agent rebuilds (e.g. /login, /model switching providers).
1099func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1100 if s == nil || r == nil {
1101 return
1102 }
1103 if r.Sandbox != nil && r.Sandbox.Permissions != nil {
1104 s.Permissions = r.Sandbox.Permissions
1105 }
1106 r.Sandbox = s
1107 for name, t := range r.ToolRegistry {
1108 switch v := t.(type) {
1109 case *tools.ReadTool:
1110 v.Sandbox = s
1111 case *tools.WriteTool:
1112 v.Sandbox = s
1113 case *tools.EditTool:
1114 v.Sandbox = s
1115 case *tools.BashTool:
1116 v.Sandbox = s
1117 case *tools.LSPTool:
1118 v.Sandbox = s
1119 }
1120 _ = name
1121 }
1122}
1123
1124// NewAgent constructs a core.Agent from r. Requires a credential.
1125func (r Resolved) NewAgent() *core.Agent {
1126 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
1127 a.MaxSteps = r.MaxSteps
1128 a.MaxTokens = r.MaxOutput
1129 a.Reasoning = r.Reasoning
1130 a.Temperature = r.Temperature
1131 a.FastMode = r.FastMode
1132 return a
1133}
1134
1135func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled, diagnosticsOnWrite, diagnosticsOnEdit bool) core.Registry {
1136 if args.NoTools {
1137 return core.Registry{}
1138 }
1139 var manager *lsp.Manager
1140 if lspEnabled && lspManagerNeeded(args, diagnosticsOnWrite, diagnosticsOnEdit) {
1141 options := lsp.ManagerOptions{}
1142 if sandbox != nil {
1143 options.CheckWritePath = sandbox.CheckWritePath
1144 }
1145 manager = lsp.NewManagerWithOptions(options)
1146 }
1147 all := map[string]core.Tool{
1148 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1149 "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1150 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1151 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1152 }
1153 if manager != nil {
1154 lspTool := tools.NewLSPTool(cwd, manager)
1155 lspTool.Sandbox = sandbox
1156 all["lsp"] = lspTool
1157 }
1158 reg := core.Registry{}
1159 if len(args.Tools) == 0 {
1160 for _, t := range all {
1161 reg[t.Name()] = t
1162 }
1163 return reg
1164 }
1165 for _, name := range args.Tools {
1166 if t, ok := all[name]; ok {
1167 reg[name] = t
1168 }
1169 }
1170 return reg
1171}
1172
1173func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) bool {
1174 if len(args.Tools) == 0 {
1175 return true
1176 }
1177 for _, name := range args.Tools {
1178 switch name {
1179 case "lsp":
1180 return true
1181 case "write":
1182 if diagnosticsOnWrite {
1183 return true
1184 }
1185 case "edit":
1186 if diagnosticsOnEdit {
1187 return true
1188 }
1189 }
1190 }
1191 return false
1192}
1193
1194func autoSubagentsToolAllowed(args Args) bool {
1195 return autoSubagentsToolAllowedFor(args, "subagent_spawn")
1196}
1197
1198func autoSubagentsStatusToolAllowed(args Args) bool {
1199 return autoSubagentsToolAllowedFor(args, "subagent_status")
1200}
1201
1202func autoSubagentsAnyToolAllowed(args Args) bool {
1203 return autoSubagentsToolAllowed(args) || autoSubagentsStatusToolAllowed(args)
1204}
1205
1206func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
1207 if args.NoTools || args.PermissionSet != nil {
1208 return false
1209 }
1210 if len(args.Tools) == 0 {
1211 return true
1212 }
1213 for _, name := range args.Tools {
1214 if name == toolName {
1215 return true
1216 }
1217 }
1218 return false
1219}
1220
1221func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1222 order := []string{"read", "write", "edit", "bash", "lsp"}
1223 var out []ToolSummary
1224 for _, name := range order {
1225 if t, ok := reg[name]; ok {
1226 out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
1227 }
1228 }
1229 return out
1230}
1231
1232func firstNonEmpty(vals ...string) string {
1233 for _, v := range vals {
1234 if v != "" {
1235 return v
1236 }
1237 }
1238 return ""
1239}
1240
1241func kimiCodeHeaders() map[string]string {
1242 host, _ := os.Hostname()
1243 if host == "" {
1244 host = "unknown"
1245 }
1246 deviceID := ""
1247 if home, err := os.UserHomeDir(); err == nil {
1248 if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
1249 deviceID = strings.TrimSpace(string(b))
1250 }
1251 }
1252 if deviceID == "" {
1253 deviceID = "zut"
1254 }
1255 return map[string]string{
1256 "User-Agent": "KimiCLI/1.41.0",
1257 "X-Msh-Platform": "kimi_cli",
1258 "X-Msh-Version": "1.41.0",
1259 "X-Msh-Device-Name": host,
1260 "X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
1261 "X-Msh-Os-Version": runtime.GOOS,
1262 "X-Msh-Device-Id": deviceID,
1263 }
1264}
1265
1266func envVarName(provider string) string {
1267 switch provider {
1268 case "openai", "openai-codex", "openai-responses":
1269 return "OPENAI"
1270 case "kimi":
1271 return "KIMI"
1272 case "deepseek":
1273 return "DEEPSEEK"
1274 case "google":
1275 return "GEMINI"
1276 case "ollama":
1277 return "OLLAMA"
1278 case "llama.cpp":
1279 return "LLAMA"
1280 case "moonshotai", "moonshotai-cn":
1281 return "MOONSHOT"
1282 case "groq":
1283 return "GROQ"
1284 case "xai":
1285 return "XAI"
1286 case "cerebras":
1287 return "CEREBRAS"
1288 case "together":
1289 return "TOGETHER"
1290 case "huggingface":
1291 return "HF"
1292 case "openrouter":
1293 return "OPENROUTER"
1294 case "mistral":
1295 return "MISTRAL"
1296 case "zai":
1297 return "ZAI"
1298 case "xiaomi":
1299 return "XIAOMI"
1300 case "xiaomi-token-plan-ams":
1301 return "XIAOMI_TOKEN_PLAN_AMS"
1302 case "xiaomi-token-plan-cn":
1303 return "XIAOMI_TOKEN_PLAN_CN"
1304 case "xiaomi-token-plan-sgp":
1305 return "XIAOMI_TOKEN_PLAN_SGP"
1306 case "minimax":
1307 return "MINIMAX"
1308 case "minimax-cn":
1309 return "MINIMAX_CN"
1310 case "fireworks":
1311 return "FIREWORKS"
1312 case "vercel-ai-gateway":
1313 return "AI_GATEWAY"
1314 case "opencode", "opencode-go":
1315 return "OPENCODE"
1316 case "github-copilot":
1317 return "COPILOT_GITHUB_TOKEN"
1318 case "cloudflare-workers-ai", "cloudflare-ai-gateway":
1319 return "CLOUDFLARE"
1320 case "amazon-bedrock":
1321 return "AWS"
1322 case "google-vertex":
1323 return "GOOGLE_CLOUD"
1324 case "azure-openai-responses":
1325 return "AZURE_OPENAI"
1326 default:
1327 return "ANTHROPIC"
1328 }
1329}