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 hyper.Name, openaicompat.Name:
296		if strings.HasPrefix(model.CatwalkCfg.ID, "claude") {
297			_, hasThink := mergedOptions["thinking"]
298			if !hasThink && model.ModelCfg.Think {
299				mergedOptions["thinking"] = map[string]any{
300					"budget_tokens": 2000,
301				}
302			}
303			parsed, err := anthropic.ParseOptions(mergedOptions)
304			if err == nil {
305				options[anthropic.Name] = parsed
306			}
307		} else {
308			_, hasReasoningEffort := mergedOptions["reasoning_effort"]
309			if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
310				mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
311			}
312			if openai.IsResponsesModel(model.CatwalkCfg.ID) {
313				if openai.IsResponsesReasoningModel(model.CatwalkCfg.ID) {
314					mergedOptions["reasoning_summary"] = "auto"
315					mergedOptions["include"] = []openai.IncludeType{openai.IncludeReasoningEncryptedContent}
316				}
317				parsed, err := openai.ParseResponsesOptions(mergedOptions)
318				if err == nil {
319					options[openai.Name] = parsed
320				}
321			} else {
322				parsed, err := openai.ParseOptions(mergedOptions)
323				if err == nil {
324					options[openai.Name] = parsed
325				}
326			}
327		}
328	}
329
330	return options
331}
332
333func mergeCallOptions(model Model, cfg config.ProviderConfig) (fantasy.ProviderOptions, *float64, *float64, *int64, *float64, *float64) {
334	modelOptions := getProviderOptions(model, cfg)
335	temp := cmp.Or(model.ModelCfg.Temperature, model.CatwalkCfg.Options.Temperature)
336	topP := cmp.Or(model.ModelCfg.TopP, model.CatwalkCfg.Options.TopP)
337	topK := cmp.Or(model.ModelCfg.TopK, model.CatwalkCfg.Options.TopK)
338	freqPenalty := cmp.Or(model.ModelCfg.FrequencyPenalty, model.CatwalkCfg.Options.FrequencyPenalty)
339	presPenalty := cmp.Or(model.ModelCfg.PresencePenalty, model.CatwalkCfg.Options.PresencePenalty)
340	return modelOptions, temp, topP, topK, freqPenalty, presPenalty
341}
342
343func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent, isSubAgent bool) (SessionAgent, error) {
344	large, small, err := c.buildAgentModels(ctx, isSubAgent)
345	if err != nil {
346		return nil, err
347	}
348
349	largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
350	result := NewSessionAgent(SessionAgentOptions{
351		large,
352		small,
353		largeProviderCfg.SystemPromptPrefix,
354		"",
355		isSubAgent,
356		c.cfg.Options.DisableAutoSummarize,
357		c.permissions.SkipRequests(),
358		c.sessions,
359		c.messages,
360		nil,
361	})
362
363	c.readyWg.Go(func() error {
364		systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg)
365		if err != nil {
366			return err
367		}
368		result.SetSystemPrompt(systemPrompt)
369		return nil
370	})
371
372	c.readyWg.Go(func() error {
373		tools, err := c.buildTools(ctx, agent)
374		if err != nil {
375			return err
376		}
377		result.SetTools(tools)
378		return nil
379	})
380
381	return result, nil
382}
383
384func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fantasy.AgentTool, error) {
385	var allTools []fantasy.AgentTool
386	if slices.Contains(agent.AllowedTools, AgentToolName) {
387		agentTool, err := c.agentTool(ctx)
388		if err != nil {
389			return nil, err
390		}
391		allTools = append(allTools, agentTool)
392	}
393
394	if slices.Contains(agent.AllowedTools, tools.AgenticFetchToolName) {
395		agenticFetchTool, err := c.agenticFetchTool(ctx, nil)
396		if err != nil {
397			return nil, err
398		}
399		allTools = append(allTools, agenticFetchTool)
400	}
401
402	// Get the model name for the agent
403	modelName := ""
404	if modelCfg, ok := c.cfg.Models[agent.Model]; ok {
405		if model := c.cfg.GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
406			modelName = model.Name
407		}
408	}
409
410	allTools = append(allTools,
411		tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution, modelName),
412		tools.NewJobOutputTool(),
413		tools.NewJobKillTool(),
414		tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
415		tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
416		tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
417		tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
418		tools.NewGlobTool(c.cfg.WorkingDir()),
419		tools.NewGrepTool(c.cfg.WorkingDir()),
420		tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
421		tools.NewSourcegraphTool(nil),
422		tools.NewTodosTool(c.sessions),
423		tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
424		tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
425	)
426
427	if len(c.cfg.LSP) > 0 {
428		allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients))
429	}
430
431	var filteredTools []fantasy.AgentTool
432	for _, tool := range allTools {
433		if slices.Contains(agent.AllowedTools, tool.Info().Name) {
434			filteredTools = append(filteredTools, tool)
435		}
436	}
437
438	for _, tool := range tools.GetMCPTools(c.permissions, c.cfg.WorkingDir()) {
439		if agent.AllowedMCP == nil {
440			// No MCP restrictions
441			filteredTools = append(filteredTools, tool)
442			continue
443		}
444		if len(agent.AllowedMCP) == 0 {
445			// No MCPs allowed
446			slog.Debug("no MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
447			break
448		}
449
450		for mcp, tools := range agent.AllowedMCP {
451			if mcp != tool.MCP() {
452				continue
453			}
454			if len(tools) == 0 || slices.Contains(tools, tool.MCPToolName()) {
455				filteredTools = append(filteredTools, tool)
456			}
457		}
458		slog.Debug("MCP not allowed", "tool", tool.Name(), "agent", agent.Name)
459	}
460	slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
461		return strings.Compare(a.Info().Name, b.Info().Name)
462	})
463	return filteredTools, nil
464}
465
466// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
467func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Model, Model, error) {
468	largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
469	if !ok {
470		return Model{}, Model{}, errors.New("large model not selected")
471	}
472	smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
473	if !ok {
474		return Model{}, Model{}, errors.New("small model not selected")
475	}
476
477	largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
478	if !ok {
479		return Model{}, Model{}, errors.New("large model provider not configured")
480	}
481
482	largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg, isSubAgent)
483	if err != nil {
484		return Model{}, Model{}, err
485	}
486
487	smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
488	if !ok {
489		return Model{}, Model{}, errors.New("large model provider not configured")
490	}
491
492	smallProvider, err := c.buildProvider(smallProviderCfg, largeModelCfg, true)
493	if err != nil {
494		return Model{}, Model{}, err
495	}
496
497	var largeCatwalkModel *catwalk.Model
498	var smallCatwalkModel *catwalk.Model
499
500	for _, m := range largeProviderCfg.Models {
501		if m.ID == largeModelCfg.Model {
502			largeCatwalkModel = &m
503		}
504	}
505	for _, m := range smallProviderCfg.Models {
506		if m.ID == smallModelCfg.Model {
507			smallCatwalkModel = &m
508		}
509	}
510
511	if largeCatwalkModel == nil {
512		return Model{}, Model{}, errors.New("large model not found in provider config")
513	}
514
515	if smallCatwalkModel == nil {
516		return Model{}, Model{}, errors.New("small model not found in provider config")
517	}
518
519	largeModelID := largeModelCfg.Model
520	smallModelID := smallModelCfg.Model
521
522	if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
523		largeModelID += ":exacto"
524	}
525
526	if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
527		smallModelID += ":exacto"
528	}
529
530	largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
531	if err != nil {
532		return Model{}, Model{}, err
533	}
534	smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
535	if err != nil {
536		return Model{}, Model{}, err
537	}
538
539	return Model{
540			Model:      largeModel,
541			CatwalkCfg: *largeCatwalkModel,
542			ModelCfg:   largeModelCfg,
543		}, Model{
544			Model:      smallModel,
545			CatwalkCfg: *smallCatwalkModel,
546			ModelCfg:   smallModelCfg,
547		}, nil
548}
549
550func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
551	var opts []anthropic.Option
552
553	if strings.HasPrefix(apiKey, "Bearer ") {
554		// NOTE: Prevent the SDK from picking up the API key from env.
555		os.Setenv("ANTHROPIC_API_KEY", "")
556		headers["Authorization"] = apiKey
557	} else if apiKey != "" {
558		// X-Api-Key header
559		opts = append(opts, anthropic.WithAPIKey(apiKey))
560	}
561
562	if len(headers) > 0 {
563		opts = append(opts, anthropic.WithHeaders(headers))
564	}
565
566	if baseURL != "" {
567		opts = append(opts, anthropic.WithBaseURL(baseURL))
568	}
569
570	if c.cfg.Options.Debug {
571		httpClient := log.NewHTTPClient()
572		opts = append(opts, anthropic.WithHTTPClient(httpClient))
573	}
574	return anthropic.New(opts...)
575}
576
577func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
578	opts := []openai.Option{
579		openai.WithAPIKey(apiKey),
580		openai.WithUseResponsesAPI(),
581	}
582	if c.cfg.Options.Debug {
583		httpClient := log.NewHTTPClient()
584		opts = append(opts, openai.WithHTTPClient(httpClient))
585	}
586	if len(headers) > 0 {
587		opts = append(opts, openai.WithHeaders(headers))
588	}
589	if baseURL != "" {
590		opts = append(opts, openai.WithBaseURL(baseURL))
591	}
592	return openai.New(opts...)
593}
594
595func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
596	opts := []openrouter.Option{
597		openrouter.WithAPIKey(apiKey),
598	}
599	if c.cfg.Options.Debug {
600		httpClient := log.NewHTTPClient()
601		opts = append(opts, openrouter.WithHTTPClient(httpClient))
602	}
603	if len(headers) > 0 {
604		opts = append(opts, openrouter.WithHeaders(headers))
605	}
606	return openrouter.New(opts...)
607}
608
609func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any, providerID string, isSubAgent bool) (fantasy.Provider, error) {
610	opts := []openaicompat.Option{
611		openaicompat.WithBaseURL(baseURL),
612		openaicompat.WithAPIKey(apiKey),
613	}
614
615	// Set HTTP client based on provider and debug mode.
616	var httpClient *http.Client
617	if providerID == string(catwalk.InferenceProviderCopilot) {
618		opts = append(opts, openaicompat.WithUseResponsesAPI())
619		httpClient = copilot.NewClient(isSubAgent, c.cfg.Options.Debug)
620	} else if c.cfg.Options.Debug {
621		httpClient = log.NewHTTPClient()
622	}
623	if httpClient != nil {
624		opts = append(opts, openaicompat.WithHTTPClient(httpClient))
625	}
626
627	if len(headers) > 0 {
628		opts = append(opts, openaicompat.WithHeaders(headers))
629	}
630
631	for extraKey, extraValue := range extraBody {
632		opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
633	}
634
635	return openaicompat.New(opts...)
636}
637
638func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
639	opts := []azure.Option{
640		azure.WithBaseURL(baseURL),
641		azure.WithAPIKey(apiKey),
642		azure.WithUseResponsesAPI(),
643	}
644	if c.cfg.Options.Debug {
645		httpClient := log.NewHTTPClient()
646		opts = append(opts, azure.WithHTTPClient(httpClient))
647	}
648	if options == nil {
649		options = make(map[string]string)
650	}
651	if apiVersion, ok := options["apiVersion"]; ok {
652		opts = append(opts, azure.WithAPIVersion(apiVersion))
653	}
654	if len(headers) > 0 {
655		opts = append(opts, azure.WithHeaders(headers))
656	}
657
658	return azure.New(opts...)
659}
660
661func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
662	var opts []bedrock.Option
663	if c.cfg.Options.Debug {
664		httpClient := log.NewHTTPClient()
665		opts = append(opts, bedrock.WithHTTPClient(httpClient))
666	}
667	if len(headers) > 0 {
668		opts = append(opts, bedrock.WithHeaders(headers))
669	}
670	bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
671	if bearerToken != "" {
672		opts = append(opts, bedrock.WithAPIKey(bearerToken))
673	}
674	return bedrock.New(opts...)
675}
676
677func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
678	opts := []google.Option{
679		google.WithBaseURL(baseURL),
680		google.WithGeminiAPIKey(apiKey),
681	}
682	if c.cfg.Options.Debug {
683		httpClient := log.NewHTTPClient()
684		opts = append(opts, google.WithHTTPClient(httpClient))
685	}
686	if len(headers) > 0 {
687		opts = append(opts, google.WithHeaders(headers))
688	}
689	return google.New(opts...)
690}
691
692func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
693	opts := []google.Option{}
694	if c.cfg.Options.Debug {
695		httpClient := log.NewHTTPClient()
696		opts = append(opts, google.WithHTTPClient(httpClient))
697	}
698	if len(headers) > 0 {
699		opts = append(opts, google.WithHeaders(headers))
700	}
701
702	project := options["project"]
703	location := options["location"]
704
705	opts = append(opts, google.WithVertex(project, location))
706
707	return google.New(opts...)
708}
709
710func (c *coordinator) buildHyperProvider(baseURL, apiKey string) (fantasy.Provider, error) {
711	opts := []hyper.Option{
712		hyper.WithBaseURL(baseURL),
713		hyper.WithAPIKey(apiKey),
714	}
715	if c.cfg.Options.Debug {
716		httpClient := log.NewHTTPClient()
717		opts = append(opts, hyper.WithHTTPClient(httpClient))
718	}
719	return hyper.New(opts...)
720}
721
722func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
723	if model.Think {
724		return true
725	}
726
727	if model.ProviderOptions == nil {
728		return false
729	}
730
731	opts, err := anthropic.ParseOptions(model.ProviderOptions)
732	if err != nil {
733		return false
734	}
735	if opts.Thinking != nil {
736		return true
737	}
738	return false
739}
740
741func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel, isSubAgent bool) (fantasy.Provider, error) {
742	headers := maps.Clone(providerCfg.ExtraHeaders)
743	if headers == nil {
744		headers = make(map[string]string)
745	}
746
747	// handle special headers for anthropic
748	if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
749		if v, ok := headers["anthropic-beta"]; ok {
750			headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
751		} else {
752			headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
753		}
754	}
755
756	apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
757	baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
758
759	switch providerCfg.Type {
760	case openai.Name:
761		return c.buildOpenaiProvider(baseURL, apiKey, headers)
762	case anthropic.Name:
763		return c.buildAnthropicProvider(baseURL, apiKey, headers)
764	case openrouter.Name:
765		return c.buildOpenrouterProvider(baseURL, apiKey, headers)
766	case azure.Name:
767		return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
768	case bedrock.Name:
769		return c.buildBedrockProvider(headers)
770	case google.Name:
771		return c.buildGoogleProvider(baseURL, apiKey, headers)
772	case "google-vertex":
773		return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
774	case openaicompat.Name:
775		if providerCfg.ID == string(catwalk.InferenceProviderZAI) {
776			if providerCfg.ExtraBody == nil {
777				providerCfg.ExtraBody = map[string]any{}
778			}
779			providerCfg.ExtraBody["tool_stream"] = true
780		}
781		return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody, providerCfg.ID, isSubAgent)
782	case hyper.Name:
783		return c.buildHyperProvider(baseURL, apiKey)
784	default:
785		return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
786	}
787}
788
789func isExactoSupported(modelID string) bool {
790	supportedModels := []string{
791		"moonshotai/kimi-k2-0905",
792		"deepseek/deepseek-v3.1-terminus",
793		"z-ai/glm-4.6",
794		"openai/gpt-oss-120b",
795		"qwen/qwen3-coder",
796	}
797	return slices.Contains(supportedModels, modelID)
798}
799
800func (c *coordinator) Cancel(sessionID string) {
801	c.currentAgent.Cancel(sessionID)
802}
803
804func (c *coordinator) CancelAll() {
805	c.currentAgent.CancelAll()
806}
807
808func (c *coordinator) ClearQueue(sessionID string) {
809	c.currentAgent.ClearQueue(sessionID)
810}
811
812func (c *coordinator) IsBusy() bool {
813	return c.currentAgent.IsBusy()
814}
815
816func (c *coordinator) IsSessionBusy(sessionID string) bool {
817	return c.currentAgent.IsSessionBusy(sessionID)
818}
819
820func (c *coordinator) Model() Model {
821	return c.currentAgent.Model()
822}
823
824func (c *coordinator) UpdateModels(ctx context.Context) error {
825	// build the models again so we make sure we get the latest config
826	large, small, err := c.buildAgentModels(ctx, false)
827	if err != nil {
828		return err
829	}
830	c.currentAgent.SetModels(large, small)
831
832	agentCfg, ok := c.cfg.Agents[config.AgentCoder]
833	if !ok {
834		return errors.New("coder agent not configured")
835	}
836
837	tools, err := c.buildTools(ctx, agentCfg)
838	if err != nil {
839		return err
840	}
841	c.currentAgent.SetTools(tools)
842	return nil
843}
844
845func (c *coordinator) QueuedPrompts(sessionID string) int {
846	return c.currentAgent.QueuedPrompts(sessionID)
847}
848
849func (c *coordinator) QueuedPromptsList(sessionID string) []string {
850	return c.currentAgent.QueuedPromptsList(sessionID)
851}
852
853func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
854	providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
855	if !ok {
856		return errors.New("model provider not configured")
857	}
858	return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
859}
860
861func (c *coordinator) isUnauthorized(err error) bool {
862	var providerErr *fantasy.ProviderError
863	return errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized
864}
865
866func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
867	if err := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); err != nil {
868		slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
869		return err
870	}
871	if err := c.UpdateModels(ctx); err != nil {
872		return err
873	}
874	return nil
875}
876
877func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg config.ProviderConfig) error {
878	newAPIKey, err := c.cfg.Resolve(providerCfg.APIKeyTemplate)
879	if err != nil {
880		slog.Error("Failed to re-resolve API key after 401 error", "provider", providerCfg.ID, "error", err)
881		return err
882	}
883
884	providerCfg.APIKey = newAPIKey
885	c.cfg.Providers.Set(providerCfg.ID, providerCfg)
886
887	if err := c.UpdateModels(ctx); err != nil {
888		return err
889	}
890	return nil
891}