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