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