coordinator.go

  1package agent
  2
  3import (
  4	"bytes"
  5	"cmp"
  6	"context"
  7	"encoding/json"
  8	"errors"
  9	"fmt"
 10	"io"
 11	"log/slog"
 12	"maps"
 13	"os"
 14	"slices"
 15	"strings"
 16
 17	"charm.land/fantasy"
 18	"github.com/charmbracelet/catwalk/pkg/catwalk"
 19	"github.com/charmbracelet/crush/internal/agent/prompt"
 20	"github.com/charmbracelet/crush/internal/agent/tools"
 21	"github.com/charmbracelet/crush/internal/config"
 22	"github.com/charmbracelet/crush/internal/csync"
 23	"github.com/charmbracelet/crush/internal/history"
 24	"github.com/charmbracelet/crush/internal/hooks"
 25	"github.com/charmbracelet/crush/internal/log"
 26	"github.com/charmbracelet/crush/internal/lsp"
 27	"github.com/charmbracelet/crush/internal/message"
 28	"github.com/charmbracelet/crush/internal/permission"
 29	"github.com/charmbracelet/crush/internal/session"
 30	"golang.org/x/sync/errgroup"
 31
 32	"charm.land/fantasy/providers/anthropic"
 33	"charm.land/fantasy/providers/azure"
 34	"charm.land/fantasy/providers/bedrock"
 35	"charm.land/fantasy/providers/google"
 36	"charm.land/fantasy/providers/openai"
 37	"charm.land/fantasy/providers/openaicompat"
 38	"charm.land/fantasy/providers/openrouter"
 39	openaisdk "github.com/openai/openai-go/v2/option"
 40	"github.com/qjebbs/go-jsons"
 41)
 42
 43type Coordinator interface {
 44	// INFO: (kujtim) this is not used yet we will use this when we have multiple agents
 45	// SetMainAgent(string)
 46	Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error)
 47	Cancel(sessionID string)
 48	CancelAll()
 49	IsSessionBusy(sessionID string) bool
 50	IsBusy() bool
 51	QueuedPrompts(sessionID string) int
 52	ClearQueue(sessionID string)
 53	Summarize(context.Context, string) error
 54	Model() Model
 55	UpdateModels(ctx context.Context) error
 56}
 57
 58type coordinator struct {
 59	cfg          *config.Config
 60	sessions     session.Service
 61	messages     message.Service
 62	permissions  permission.Service
 63	history      history.Service
 64	lspClients   *csync.Map[string, *lsp.Client]
 65	hooksManager hooks.Manager
 66
 67	currentAgent SessionAgent
 68	agents       map[string]SessionAgent
 69
 70	readyWg errgroup.Group
 71}
 72
 73func NewCoordinator(
 74	ctx context.Context,
 75	cfg *config.Config,
 76	sessions session.Service,
 77	messages message.Service,
 78	permissions permission.Service,
 79	history history.Service,
 80	lspClients *csync.Map[string, *lsp.Client],
 81	hooksManager hooks.Manager,
 82) (Coordinator, error) {
 83	c := &coordinator{
 84		cfg:          cfg,
 85		sessions:     sessions,
 86		messages:     messages,
 87		permissions:  permissions,
 88		history:      history,
 89		lspClients:   lspClients,
 90		hooksManager: hooksManager,
 91		agents:       make(map[string]SessionAgent),
 92	}
 93
 94	agentCfg, ok := cfg.Agents[config.AgentCoder]
 95	if !ok {
 96		return nil, errors.New("coder agent not configured")
 97	}
 98
 99	// TODO: make this dynamic when we support multiple agents
100	prompt, err := coderPrompt(prompt.WithWorkingDir(c.cfg.WorkingDir()))
101	if err != nil {
102		return nil, err
103	}
104
105	agent, err := c.buildAgent(ctx, prompt, agentCfg, false)
106	if err != nil {
107		return nil, err
108	}
109	c.currentAgent = agent
110	c.agents[config.AgentCoder] = agent
111	return c, nil
112}
113
114// Run implements Coordinator.
115func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
116	if err := c.readyWg.Wait(); err != nil {
117		return nil, err
118	}
119
120	model := c.currentAgent.Model()
121	maxTokens := model.CatwalkCfg.DefaultMaxTokens
122	if model.ModelCfg.MaxTokens != 0 {
123		maxTokens = model.ModelCfg.MaxTokens
124	}
125
126	if !model.CatwalkCfg.SupportsImages && attachments != nil {
127		attachments = nil
128	}
129
130	providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
131	if !ok {
132		return nil, errors.New("model provider not configured")
133	}
134
135	mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
136
137	return c.currentAgent.Run(ctx, SessionAgentCall{
138		SessionID:        sessionID,
139		Prompt:           prompt,
140		Attachments:      attachments,
141		MaxOutputTokens:  maxTokens,
142		ProviderOptions:  mergedOptions,
143		Temperature:      temp,
144		TopP:             topP,
145		TopK:             topK,
146		FrequencyPenalty: freqPenalty,
147		PresencePenalty:  presPenalty,
148	})
149}
150
151func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions {
152	options := fantasy.ProviderOptions{}
153
154	cfgOpts := []byte("{}")
155	providerCfgOpts := []byte("{}")
156	catwalkOpts := []byte("{}")
157
158	if model.ModelCfg.ProviderOptions != nil {
159		data, err := json.Marshal(model.ModelCfg.ProviderOptions)
160		if err == nil {
161			cfgOpts = data
162		}
163	}
164
165	if providerCfg.ProviderOptions != nil {
166		data, err := json.Marshal(providerCfg.ProviderOptions)
167		if err == nil {
168			providerCfgOpts = data
169		}
170	}
171
172	if model.CatwalkCfg.Options.ProviderOptions != nil {
173		data, err := json.Marshal(model.CatwalkCfg.Options.ProviderOptions)
174		if err == nil {
175			catwalkOpts = data
176		}
177	}
178
179	readers := []io.Reader{
180		bytes.NewReader(catwalkOpts),
181		bytes.NewReader(providerCfgOpts),
182		bytes.NewReader(cfgOpts),
183	}
184
185	got, err := jsons.Merge(readers)
186	if err != nil {
187		slog.Error("Could not merge call config", "err", err)
188		return options
189	}
190
191	mergedOptions := make(map[string]any)
192
193	err = json.Unmarshal([]byte(got), &mergedOptions)
194	if err != nil {
195		slog.Error("Could not create config for call", "err", err)
196		return options
197	}
198
199	switch providerCfg.Type {
200	case openai.Name, azure.Name:
201		_, hasReasoningEffort := mergedOptions["reasoning_effort"]
202		if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
203			mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
204		}
205		if openai.IsResponsesModel(model.CatwalkCfg.ID) {
206			if openai.IsResponsesReasoningModel(model.CatwalkCfg.ID) {
207				mergedOptions["reasoning_summary"] = "auto"
208				mergedOptions["include"] = []openai.IncludeType{openai.IncludeReasoningEncryptedContent}
209			}
210			parsed, err := openai.ParseResponsesOptions(mergedOptions)
211			if err == nil {
212				options[openai.Name] = parsed
213			}
214		} else {
215			parsed, err := openai.ParseOptions(mergedOptions)
216			if err == nil {
217				options[openai.Name] = parsed
218			}
219		}
220	case anthropic.Name:
221		_, hasThink := mergedOptions["thinking"]
222		if !hasThink && model.ModelCfg.Think {
223			mergedOptions["thinking"] = map[string]any{
224				// TODO: kujtim see if we need to make this dynamic
225				"budget_tokens": 2000,
226			}
227		}
228		parsed, err := anthropic.ParseOptions(mergedOptions)
229		if err == nil {
230			options[anthropic.Name] = parsed
231		}
232
233	case openrouter.Name:
234		_, hasReasoning := mergedOptions["reasoning"]
235		if !hasReasoning && model.ModelCfg.ReasoningEffort != "" {
236			mergedOptions["reasoning"] = map[string]any{
237				"enabled": true,
238				"effort":  model.ModelCfg.ReasoningEffort,
239			}
240		}
241		parsed, err := openrouter.ParseOptions(mergedOptions)
242		if err == nil {
243			options[openrouter.Name] = parsed
244		}
245	case google.Name:
246		_, hasReasoning := mergedOptions["thinking_config"]
247		if !hasReasoning {
248			mergedOptions["thinking_config"] = map[string]any{
249				"thinking_budget":  2000,
250				"include_thoughts": true,
251			}
252		}
253		parsed, err := google.ParseOptions(mergedOptions)
254		if err == nil {
255			options[google.Name] = parsed
256		}
257	case openaicompat.Name:
258		_, hasReasoningEffort := mergedOptions["reasoning_effort"]
259		if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
260			mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
261		}
262		parsed, err := openaicompat.ParseOptions(mergedOptions)
263		if err == nil {
264			options[openaicompat.Name] = parsed
265		}
266	}
267
268	return options
269}
270
271func mergeCallOptions(model Model, cfg config.ProviderConfig) (fantasy.ProviderOptions, *float64, *float64, *int64, *float64, *float64) {
272	modelOptions := getProviderOptions(model, cfg)
273	temp := cmp.Or(model.ModelCfg.Temperature, model.CatwalkCfg.Options.Temperature)
274	topP := cmp.Or(model.ModelCfg.TopP, model.CatwalkCfg.Options.TopP)
275	topK := cmp.Or(model.ModelCfg.TopK, model.CatwalkCfg.Options.TopK)
276	freqPenalty := cmp.Or(model.ModelCfg.FrequencyPenalty, model.CatwalkCfg.Options.FrequencyPenalty)
277	presPenalty := cmp.Or(model.ModelCfg.PresencePenalty, model.CatwalkCfg.Options.PresencePenalty)
278	return modelOptions, temp, topP, topK, freqPenalty, presPenalty
279}
280
281func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent, isSubAgent bool) (SessionAgent, error) {
282	large, small, err := c.buildAgentModels(ctx)
283	if err != nil {
284		return nil, err
285	}
286
287	systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg)
288	if err != nil {
289		return nil, err
290	}
291
292	largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
293	result := NewSessionAgent(SessionAgentOptions{
294		LargeModel:           large,
295		SmallModel:           small,
296		SystemPromptPrefix:   largeProviderCfg.SystemPromptPrefix,
297		SystemPrompt:         systemPrompt,
298		DisableAutoSummarize: c.cfg.Options.DisableAutoSummarize,
299		IsYolo:               c.permissions.SkipRequests(),
300		IsSubAgent:           isSubAgent,
301		HooksManager:         c.hooksManager,
302		WorkingDir:           c.cfg.WorkingDir(),
303		Sessions:             c.sessions,
304		Messages:             c.messages,
305		Tools:                nil,
306	})
307	c.readyWg.Go(func() error {
308		tools, err := c.buildTools(ctx, agent)
309		if err != nil {
310			return err
311		}
312		result.SetTools(tools)
313		return nil
314	})
315
316	return result, nil
317}
318
319func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fantasy.AgentTool, error) {
320	var allTools []fantasy.AgentTool
321	if slices.Contains(agent.AllowedTools, AgentToolName) {
322		agentTool, err := c.agentTool(ctx)
323		if err != nil {
324			return nil, err
325		}
326		allTools = append(allTools, agentTool)
327	}
328
329	if slices.Contains(agent.AllowedTools, tools.AgenticFetchToolName) {
330		agenticFetchTool, err := c.agenticFetchTool(ctx, nil)
331		if err != nil {
332			return nil, err
333		}
334		allTools = append(allTools, agenticFetchTool)
335	}
336
337	// Get the model name for the agent
338	modelName := ""
339	if modelCfg, ok := c.cfg.Models[agent.Model]; ok {
340		if model := c.cfg.GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
341			modelName = model.Name
342		}
343	}
344
345	allTools = append(allTools,
346		tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution, modelName),
347		tools.NewJobOutputTool(),
348		tools.NewJobKillTool(),
349		tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
350		tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
351		tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
352		tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
353		tools.NewGlobTool(c.cfg.WorkingDir()),
354		tools.NewGrepTool(c.cfg.WorkingDir()),
355		tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
356		tools.NewSourcegraphTool(nil),
357		tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir()),
358		tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
359	)
360
361	if len(c.cfg.LSP) > 0 {
362		allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients))
363	}
364
365	var filteredTools []fantasy.AgentTool
366	for _, tool := range allTools {
367		if slices.Contains(agent.AllowedTools, tool.Info().Name) {
368			filteredTools = append(filteredTools, tool)
369		}
370	}
371
372	for _, tool := range tools.GetMCPTools(c.permissions, c.cfg.WorkingDir()) {
373		if agent.AllowedMCP == nil {
374			// No MCP restrictions
375			filteredTools = append(filteredTools, tool)
376			continue
377		}
378		if len(agent.AllowedMCP) == 0 {
379			// No MCPs allowed
380			slog.Debug("no MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
381			break
382		}
383
384		for mcp, tools := range agent.AllowedMCP {
385			if mcp != tool.MCP() {
386				continue
387			}
388			if len(tools) == 0 || slices.Contains(tools, tool.MCPToolName()) {
389				filteredTools = append(filteredTools, tool)
390			}
391		}
392		slog.Debug("MCP not allowed", "tool", tool.Name(), "agent", agent.Name)
393	}
394	slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
395		return strings.Compare(a.Info().Name, b.Info().Name)
396	})
397	return filteredTools, nil
398}
399
400// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
401func (c *coordinator) buildAgentModels(ctx context.Context) (Model, Model, error) {
402	largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
403	if !ok {
404		return Model{}, Model{}, errors.New("large model not selected")
405	}
406	smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
407	if !ok {
408		return Model{}, Model{}, errors.New("small model not selected")
409	}
410
411	largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
412	if !ok {
413		return Model{}, Model{}, errors.New("large model provider not configured")
414	}
415
416	largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg)
417	if err != nil {
418		return Model{}, Model{}, err
419	}
420
421	smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
422	if !ok {
423		return Model{}, Model{}, errors.New("large model provider not configured")
424	}
425
426	smallProvider, err := c.buildProvider(smallProviderCfg, largeModelCfg)
427	if err != nil {
428		return Model{}, Model{}, err
429	}
430
431	var largeCatwalkModel *catwalk.Model
432	var smallCatwalkModel *catwalk.Model
433
434	for _, m := range largeProviderCfg.Models {
435		if m.ID == largeModelCfg.Model {
436			largeCatwalkModel = &m
437		}
438	}
439	for _, m := range smallProviderCfg.Models {
440		if m.ID == smallModelCfg.Model {
441			smallCatwalkModel = &m
442		}
443	}
444
445	if largeCatwalkModel == nil {
446		return Model{}, Model{}, errors.New("large model not found in provider config")
447	}
448
449	if smallCatwalkModel == nil {
450		return Model{}, Model{}, errors.New("snall model not found in provider config")
451	}
452
453	largeModelID := largeModelCfg.Model
454	smallModelID := smallModelCfg.Model
455
456	if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
457		largeModelID += ":exacto"
458	}
459
460	if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
461		smallModelID += ":exacto"
462	}
463
464	largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
465	if err != nil {
466		return Model{}, Model{}, err
467	}
468	smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
469	if err != nil {
470		return Model{}, Model{}, err
471	}
472
473	return Model{
474			Model:      largeModel,
475			CatwalkCfg: *largeCatwalkModel,
476			ModelCfg:   largeModelCfg,
477		}, Model{
478			Model:      smallModel,
479			CatwalkCfg: *smallCatwalkModel,
480			ModelCfg:   smallModelCfg,
481		}, nil
482}
483
484func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
485	hasBearerAuth := false
486	for key := range headers {
487		if strings.ToLower(key) == "authorization" {
488			hasBearerAuth = true
489			break
490		}
491	}
492
493	isBearerToken := strings.HasPrefix(apiKey, "Bearer ")
494
495	var opts []anthropic.Option
496	if apiKey != "" && !hasBearerAuth {
497		if isBearerToken {
498			slog.Debug("API key starts with 'Bearer ', using as Authorization header")
499			headers["Authorization"] = apiKey
500			apiKey = "" // clear apiKey to avoid using X-Api-Key header
501		}
502	}
503
504	if apiKey != "" {
505		// Use standard X-Api-Key header
506		opts = append(opts, anthropic.WithAPIKey(apiKey))
507	}
508
509	if len(headers) > 0 {
510		opts = append(opts, anthropic.WithHeaders(headers))
511	}
512
513	if baseURL != "" {
514		opts = append(opts, anthropic.WithBaseURL(baseURL))
515	}
516
517	if c.cfg.Options.Debug {
518		httpClient := log.NewHTTPClient()
519		opts = append(opts, anthropic.WithHTTPClient(httpClient))
520	}
521
522	return anthropic.New(opts...)
523}
524
525func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
526	opts := []openai.Option{
527		openai.WithAPIKey(apiKey),
528		openai.WithUseResponsesAPI(),
529	}
530	if c.cfg.Options.Debug {
531		httpClient := log.NewHTTPClient()
532		opts = append(opts, openai.WithHTTPClient(httpClient))
533	}
534	if len(headers) > 0 {
535		opts = append(opts, openai.WithHeaders(headers))
536	}
537	if baseURL != "" {
538		opts = append(opts, openai.WithBaseURL(baseURL))
539	}
540	return openai.New(opts...)
541}
542
543func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
544	opts := []openrouter.Option{
545		openrouter.WithAPIKey(apiKey),
546	}
547	if c.cfg.Options.Debug {
548		httpClient := log.NewHTTPClient()
549		opts = append(opts, openrouter.WithHTTPClient(httpClient))
550	}
551	if len(headers) > 0 {
552		opts = append(opts, openrouter.WithHeaders(headers))
553	}
554	return openrouter.New(opts...)
555}
556
557func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any) (fantasy.Provider, error) {
558	opts := []openaicompat.Option{
559		openaicompat.WithBaseURL(baseURL),
560		openaicompat.WithAPIKey(apiKey),
561	}
562	if c.cfg.Options.Debug {
563		httpClient := log.NewHTTPClient()
564		opts = append(opts, openaicompat.WithHTTPClient(httpClient))
565	}
566	if len(headers) > 0 {
567		opts = append(opts, openaicompat.WithHeaders(headers))
568	}
569
570	for extraKey, extraValue := range extraBody {
571		opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
572	}
573
574	return openaicompat.New(opts...)
575}
576
577func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
578	opts := []azure.Option{
579		azure.WithBaseURL(baseURL),
580		azure.WithAPIKey(apiKey),
581		azure.WithUseResponsesAPI(),
582	}
583	if c.cfg.Options.Debug {
584		httpClient := log.NewHTTPClient()
585		opts = append(opts, azure.WithHTTPClient(httpClient))
586	}
587	if options == nil {
588		options = make(map[string]string)
589	}
590	if apiVersion, ok := options["apiVersion"]; ok {
591		opts = append(opts, azure.WithAPIVersion(apiVersion))
592	}
593	if len(headers) > 0 {
594		opts = append(opts, azure.WithHeaders(headers))
595	}
596
597	return azure.New(opts...)
598}
599
600func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
601	var opts []bedrock.Option
602	if c.cfg.Options.Debug {
603		httpClient := log.NewHTTPClient()
604		opts = append(opts, bedrock.WithHTTPClient(httpClient))
605	}
606	if len(headers) > 0 {
607		opts = append(opts, bedrock.WithHeaders(headers))
608	}
609	bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
610	if bearerToken != "" {
611		opts = append(opts, bedrock.WithAPIKey(bearerToken))
612	}
613	return bedrock.New(opts...)
614}
615
616func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
617	opts := []google.Option{
618		google.WithBaseURL(baseURL),
619		google.WithGeminiAPIKey(apiKey),
620	}
621	if c.cfg.Options.Debug {
622		httpClient := log.NewHTTPClient()
623		opts = append(opts, google.WithHTTPClient(httpClient))
624	}
625	if len(headers) > 0 {
626		opts = append(opts, google.WithHeaders(headers))
627	}
628	return google.New(opts...)
629}
630
631func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
632	opts := []google.Option{}
633	if c.cfg.Options.Debug {
634		httpClient := log.NewHTTPClient()
635		opts = append(opts, google.WithHTTPClient(httpClient))
636	}
637	if len(headers) > 0 {
638		opts = append(opts, google.WithHeaders(headers))
639	}
640
641	project := options["project"]
642	location := options["location"]
643
644	opts = append(opts, google.WithVertex(project, location))
645
646	return google.New(opts...)
647}
648
649func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
650	if model.Think {
651		return true
652	}
653
654	if model.ProviderOptions == nil {
655		return false
656	}
657
658	opts, err := anthropic.ParseOptions(model.ProviderOptions)
659	if err != nil {
660		return false
661	}
662	if opts.Thinking != nil {
663		return true
664	}
665	return false
666}
667
668func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel) (fantasy.Provider, error) {
669	headers := maps.Clone(providerCfg.ExtraHeaders)
670	if headers == nil {
671		headers = make(map[string]string)
672	}
673
674	// handle special headers for anthropic
675	if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
676		if v, ok := headers["anthropic-beta"]; ok {
677			headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
678		} else {
679			headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
680		}
681	}
682
683	apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
684	baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
685
686	switch providerCfg.Type {
687	case openai.Name:
688		return c.buildOpenaiProvider(baseURL, apiKey, headers)
689	case anthropic.Name:
690		return c.buildAnthropicProvider(baseURL, apiKey, headers)
691	case openrouter.Name:
692		return c.buildOpenrouterProvider(baseURL, apiKey, headers)
693	case azure.Name:
694		return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
695	case bedrock.Name:
696		return c.buildBedrockProvider(headers)
697	case google.Name:
698		return c.buildGoogleProvider(baseURL, apiKey, headers)
699	case "google-vertex":
700		return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
701	case openaicompat.Name:
702		return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody)
703	default:
704		return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
705	}
706}
707
708func isExactoSupported(modelID string) bool {
709	supportedModels := []string{
710		"moonshotai/kimi-k2-0905",
711		"deepseek/deepseek-v3.1-terminus",
712		"z-ai/glm-4.6",
713		"openai/gpt-oss-120b",
714		"qwen/qwen3-coder",
715	}
716	return slices.Contains(supportedModels, modelID)
717}
718
719func (c *coordinator) Cancel(sessionID string) {
720	c.currentAgent.Cancel(sessionID)
721}
722
723func (c *coordinator) CancelAll() {
724	c.currentAgent.CancelAll()
725}
726
727func (c *coordinator) ClearQueue(sessionID string) {
728	c.currentAgent.ClearQueue(sessionID)
729}
730
731func (c *coordinator) IsBusy() bool {
732	return c.currentAgent.IsBusy()
733}
734
735func (c *coordinator) IsSessionBusy(sessionID string) bool {
736	return c.currentAgent.IsSessionBusy(sessionID)
737}
738
739func (c *coordinator) Model() Model {
740	return c.currentAgent.Model()
741}
742
743func (c *coordinator) UpdateModels(ctx context.Context) error {
744	// build the models again so we make sure we get the latest config
745	large, small, err := c.buildAgentModels(ctx)
746	if err != nil {
747		return err
748	}
749	c.currentAgent.SetModels(large, small)
750
751	agentCfg, ok := c.cfg.Agents[config.AgentCoder]
752	if !ok {
753		return errors.New("coder agent not configured")
754	}
755
756	tools, err := c.buildTools(ctx, agentCfg)
757	if err != nil {
758		return err
759	}
760	c.currentAgent.SetTools(tools)
761	return nil
762}
763
764func (c *coordinator) QueuedPrompts(sessionID string) int {
765	return c.currentAgent.QueuedPrompts(sessionID)
766}
767
768func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
769	providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
770	if !ok {
771		return errors.New("model provider not configured")
772	}
773	return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
774}