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