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