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