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	"git.secluded.site/crush/internal/agent/prompt"
 19	"git.secluded.site/crush/internal/agent/tools"
 20	"git.secluded.site/crush/internal/config"
 21	"git.secluded.site/crush/internal/csync"
 22	"git.secluded.site/crush/internal/history"
 23	"git.secluded.site/crush/internal/log"
 24	"git.secluded.site/crush/internal/lsp"
 25	"git.secluded.site/crush/internal/message"
 26	"git.secluded.site/crush/internal/notification"
 27	"git.secluded.site/crush/internal/permission"
 28	"git.secluded.site/crush/internal/session"
 29	"github.com/charmbracelet/catwalk/pkg/catwalk"
 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	// Get the model name for the agent
364	modelName := ""
365	if modelCfg, ok := c.cfg.Models[agent.Model]; ok {
366		if model := c.cfg.GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
367			modelName = model.Name
368		}
369	}
370
371	allTools = append(allTools,
372		tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution, modelName),
373		tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
374		tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
375		tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
376		tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
377		tools.NewGlobTool(c.cfg.WorkingDir()),
378		tools.NewGrepTool(c.cfg.WorkingDir()),
379		tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
380		tools.NewSourcegraphTool(nil),
381		tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir()),
382		tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
383	)
384
385	if len(c.cfg.LSP) > 0 {
386		allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients))
387	}
388
389	var filteredTools []fantasy.AgentTool
390	for _, tool := range allTools {
391		if slices.Contains(agent.AllowedTools, tool.Info().Name) {
392			filteredTools = append(filteredTools, tool)
393		}
394	}
395
396	for _, tool := range tools.GetMCPTools(c.permissions, c.cfg.WorkingDir()) {
397		if agent.AllowedMCP == nil {
398			// No MCP restrictions
399			filteredTools = append(filteredTools, tool)
400			continue
401		}
402		if len(agent.AllowedMCP) == 0 {
403			// No MCPs allowed
404			slog.Warn("MCPs not allowed")
405			break
406		}
407
408		for mcp, tools := range agent.AllowedMCP {
409			if mcp != tool.MCP() {
410				continue
411			}
412			if len(tools) == 0 || slices.Contains(tools, tool.MCPToolName()) {
413				filteredTools = append(filteredTools, tool)
414			}
415		}
416	}
417	slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
418		return strings.Compare(a.Info().Name, b.Info().Name)
419	})
420	return filteredTools, nil
421}
422
423// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
424func (c *coordinator) buildAgentModels(ctx context.Context) (Model, Model, error) {
425	largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
426	if !ok {
427		return Model{}, Model{}, errors.New("large model not selected")
428	}
429	smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
430	if !ok {
431		return Model{}, Model{}, errors.New("small model not selected")
432	}
433
434	largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
435	if !ok {
436		return Model{}, Model{}, errors.New("large model provider not configured")
437	}
438
439	largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg)
440	if err != nil {
441		return Model{}, Model{}, err
442	}
443
444	smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
445	if !ok {
446		return Model{}, Model{}, errors.New("large model provider not configured")
447	}
448
449	smallProvider, err := c.buildProvider(smallProviderCfg, largeModelCfg)
450	if err != nil {
451		return Model{}, Model{}, err
452	}
453
454	var largeCatwalkModel *catwalk.Model
455	var smallCatwalkModel *catwalk.Model
456
457	for _, m := range largeProviderCfg.Models {
458		if m.ID == largeModelCfg.Model {
459			largeCatwalkModel = &m
460		}
461	}
462	for _, m := range smallProviderCfg.Models {
463		if m.ID == smallModelCfg.Model {
464			smallCatwalkModel = &m
465		}
466	}
467
468	if largeCatwalkModel == nil {
469		return Model{}, Model{}, errors.New("large model not found in provider config")
470	}
471
472	if smallCatwalkModel == nil {
473		return Model{}, Model{}, errors.New("snall model not found in provider config")
474	}
475
476	largeModelID := largeModelCfg.Model
477	smallModelID := smallModelCfg.Model
478
479	if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
480		largeModelID += ":exacto"
481	}
482
483	if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
484		smallModelID += ":exacto"
485	}
486
487	largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
488	if err != nil {
489		return Model{}, Model{}, err
490	}
491	smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
492	if err != nil {
493		return Model{}, Model{}, err
494	}
495
496	return Model{
497			Model:      largeModel,
498			CatwalkCfg: *largeCatwalkModel,
499			ModelCfg:   largeModelCfg,
500		}, Model{
501			Model:      smallModel,
502			CatwalkCfg: *smallCatwalkModel,
503			ModelCfg:   smallModelCfg,
504		}, nil
505}
506
507func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
508	hasBearerAuth := false
509	for key := range headers {
510		if strings.ToLower(key) == "authorization" {
511			hasBearerAuth = true
512			break
513		}
514	}
515
516	isBearerToken := strings.HasPrefix(apiKey, "Bearer ")
517
518	var opts []anthropic.Option
519	if apiKey != "" && !hasBearerAuth {
520		if isBearerToken {
521			slog.Debug("API key starts with 'Bearer ', using as Authorization header")
522			headers["Authorization"] = apiKey
523			apiKey = "" // clear apiKey to avoid using X-Api-Key header
524		}
525	}
526
527	if apiKey != "" {
528		// Use standard X-Api-Key header
529		opts = append(opts, anthropic.WithAPIKey(apiKey))
530	}
531
532	if len(headers) > 0 {
533		opts = append(opts, anthropic.WithHeaders(headers))
534	}
535
536	if baseURL != "" {
537		opts = append(opts, anthropic.WithBaseURL(baseURL))
538	}
539
540	if c.cfg.Options.Debug {
541		httpClient := log.NewHTTPClient()
542		opts = append(opts, anthropic.WithHTTPClient(httpClient))
543	}
544
545	return anthropic.New(opts...)
546}
547
548func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
549	opts := []openai.Option{
550		openai.WithAPIKey(apiKey),
551		openai.WithUseResponsesAPI(),
552	}
553	if c.cfg.Options.Debug {
554		httpClient := log.NewHTTPClient()
555		opts = append(opts, openai.WithHTTPClient(httpClient))
556	}
557	if len(headers) > 0 {
558		opts = append(opts, openai.WithHeaders(headers))
559	}
560	if baseURL != "" {
561		opts = append(opts, openai.WithBaseURL(baseURL))
562	}
563	return openai.New(opts...)
564}
565
566func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
567	opts := []openrouter.Option{
568		openrouter.WithAPIKey(apiKey),
569	}
570	if c.cfg.Options.Debug {
571		httpClient := log.NewHTTPClient()
572		opts = append(opts, openrouter.WithHTTPClient(httpClient))
573	}
574	if len(headers) > 0 {
575		opts = append(opts, openrouter.WithHeaders(headers))
576	}
577	return openrouter.New(opts...)
578}
579
580func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any) (fantasy.Provider, error) {
581	opts := []openaicompat.Option{
582		openaicompat.WithBaseURL(baseURL),
583		openaicompat.WithAPIKey(apiKey),
584	}
585	if c.cfg.Options.Debug {
586		httpClient := log.NewHTTPClient()
587		opts = append(opts, openaicompat.WithHTTPClient(httpClient))
588	}
589	if len(headers) > 0 {
590		opts = append(opts, openaicompat.WithHeaders(headers))
591	}
592
593	for extraKey, extraValue := range extraBody {
594		opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
595	}
596
597	return openaicompat.New(opts...)
598}
599
600func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
601	opts := []azure.Option{
602		azure.WithBaseURL(baseURL),
603		azure.WithAPIKey(apiKey),
604		azure.WithUseResponsesAPI(),
605	}
606	if c.cfg.Options.Debug {
607		httpClient := log.NewHTTPClient()
608		opts = append(opts, azure.WithHTTPClient(httpClient))
609	}
610	if options == nil {
611		options = make(map[string]string)
612	}
613	if apiVersion, ok := options["apiVersion"]; ok {
614		opts = append(opts, azure.WithAPIVersion(apiVersion))
615	}
616	if len(headers) > 0 {
617		opts = append(opts, azure.WithHeaders(headers))
618	}
619
620	return azure.New(opts...)
621}
622
623func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
624	var opts []bedrock.Option
625	if c.cfg.Options.Debug {
626		httpClient := log.NewHTTPClient()
627		opts = append(opts, bedrock.WithHTTPClient(httpClient))
628	}
629	if len(headers) > 0 {
630		opts = append(opts, bedrock.WithHeaders(headers))
631	}
632	bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
633	if bearerToken != "" {
634		opts = append(opts, bedrock.WithAPIKey(bearerToken))
635	}
636	return bedrock.New(opts...)
637}
638
639func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
640	opts := []google.Option{
641		google.WithBaseURL(baseURL),
642		google.WithGeminiAPIKey(apiKey),
643	}
644	if c.cfg.Options.Debug {
645		httpClient := log.NewHTTPClient()
646		opts = append(opts, google.WithHTTPClient(httpClient))
647	}
648	if len(headers) > 0 {
649		opts = append(opts, google.WithHeaders(headers))
650	}
651	return google.New(opts...)
652}
653
654func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
655	opts := []google.Option{}
656	if c.cfg.Options.Debug {
657		httpClient := log.NewHTTPClient()
658		opts = append(opts, google.WithHTTPClient(httpClient))
659	}
660	if len(headers) > 0 {
661		opts = append(opts, google.WithHeaders(headers))
662	}
663
664	project := options["project"]
665	location := options["location"]
666
667	opts = append(opts, google.WithVertex(project, location))
668
669	return google.New(opts...)
670}
671
672func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
673	if model.Think {
674		return true
675	}
676
677	if model.ProviderOptions == nil {
678		return false
679	}
680
681	opts, err := anthropic.ParseOptions(model.ProviderOptions)
682	if err != nil {
683		return false
684	}
685	if opts.Thinking != nil {
686		return true
687	}
688	return false
689}
690
691func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel) (fantasy.Provider, error) {
692	headers := maps.Clone(providerCfg.ExtraHeaders)
693	if headers == nil {
694		headers = make(map[string]string)
695	}
696
697	// handle special headers for anthropic
698	if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
699		if v, ok := headers["anthropic-beta"]; ok {
700			headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
701		} else {
702			headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
703		}
704	}
705
706	apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
707	baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
708
709	switch providerCfg.Type {
710	case openai.Name:
711		return c.buildOpenaiProvider(baseURL, apiKey, headers)
712	case anthropic.Name:
713		return c.buildAnthropicProvider(baseURL, apiKey, headers)
714	case openrouter.Name:
715		return c.buildOpenrouterProvider(baseURL, apiKey, headers)
716	case azure.Name:
717		return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
718	case bedrock.Name:
719		return c.buildBedrockProvider(headers)
720	case google.Name:
721		return c.buildGoogleProvider(baseURL, apiKey, headers)
722	case "google-vertex":
723		return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
724	case openaicompat.Name:
725		return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody)
726	default:
727		return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
728	}
729}
730
731func isExactoSupported(modelID string) bool {
732	supportedModels := []string{
733		"moonshotai/kimi-k2-0905",
734		"deepseek/deepseek-v3.1-terminus",
735		"z-ai/glm-4.6",
736		"openai/gpt-oss-120b",
737		"qwen/qwen3-coder",
738	}
739	return slices.Contains(supportedModels, modelID)
740}
741
742func (c *coordinator) Cancel(sessionID string) {
743	c.currentAgent.Cancel(sessionID)
744}
745
746func (c *coordinator) CancelAll() {
747	c.currentAgent.CancelAll()
748}
749
750func (c *coordinator) ClearQueue(sessionID string) {
751	c.currentAgent.ClearQueue(sessionID)
752}
753
754func (c *coordinator) IsBusy() bool {
755	return c.currentAgent.IsBusy()
756}
757
758func (c *coordinator) IsSessionBusy(sessionID string) bool {
759	return c.currentAgent.IsSessionBusy(sessionID)
760}
761
762func (c *coordinator) Model() Model {
763	return c.currentAgent.Model()
764}
765
766func (c *coordinator) UpdateModels(ctx context.Context) error {
767	// build the models again so we make sure we get the latest config
768	large, small, err := c.buildAgentModels(ctx)
769	if err != nil {
770		return err
771	}
772	c.currentAgent.SetModels(large, small)
773
774	agentCfg, ok := c.cfg.Agents[config.AgentCoder]
775	if !ok {
776		return errors.New("coder agent not configured")
777	}
778
779	tools, err := c.buildTools(ctx, agentCfg)
780	if err != nil {
781		return err
782	}
783	c.currentAgent.SetTools(tools)
784	return nil
785}
786
787func (c *coordinator) QueuedPrompts(sessionID string) int {
788	return c.currentAgent.QueuedPrompts(sessionID)
789}
790
791func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
792	providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
793	if !ok {
794		return errors.New("model provider not configured")
795	}
796	return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
797}