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