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