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