on main help sign in
fix: copilot quota handling (#1738)
aeadffdKujtim Hoxha
internal/agent/coordinator.go+13−4
@@ -26,6 +26,7 @@ import (
26 "github.com/charmbracelet/crush/internal/log"
26 "github.com/charmbracelet/crush/internal/log"
27 "github.com/charmbracelet/crush/internal/lsp"
27 "github.com/charmbracelet/crush/internal/lsp"
28 "github.com/charmbracelet/crush/internal/message"
28 "github.com/charmbracelet/crush/internal/message"
29+ "github.com/charmbracelet/crush/internal/oauth/copilot"
29 "github.com/charmbracelet/crush/internal/permission"
30 "github.com/charmbracelet/crush/internal/permission"
30 "github.com/charmbracelet/crush/internal/session"
31 "github.com/charmbracelet/crush/internal/session"
31 "golang.org/x/sync/errgroup"
32 "golang.org/x/sync/errgroup"
@@ -582,15 +583,23 @@ func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[stri
582 return openrouter.New(opts...)
583 return openrouter.New(opts...)
583}
584}
584
585
585−func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any) (fantasy.Provider, error) {
586+func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any, providerID string) (fantasy.Provider, error) {
586 opts := []openaicompat.Option{
587 opts := []openaicompat.Option{
587 openaicompat.WithBaseURL(baseURL),
588 openaicompat.WithBaseURL(baseURL),
588 openaicompat.WithAPIKey(apiKey),
589 openaicompat.WithAPIKey(apiKey),
589 }
590 }
591+
592+ // Set HTTP client based on provider and debug mode.
593+ var httpClient *http.Client
594+ if providerID == string(catwalk.InferenceProviderCopilot) {
590− if c.cfg.Options.Debug {
595+ httpClient = copilot.NewClient(c.cfg.Options.Debug)
596+ } else if c.cfg.Options.Debug {
591− httpClient := log.NewHTTPClient()
597+ httpClient = log.NewHTTPClient()
598+ }
599+ if httpClient != nil {
592 opts = append(opts, openaicompat.WithHTTPClient(httpClient))
600 opts = append(opts, openaicompat.WithHTTPClient(httpClient))
593 }
601 }
602+
594 if len(headers) > 0 {
603 if len(headers) > 0 {
595 opts = append(opts, openaicompat.WithHeaders(headers))
604 opts = append(opts, openaicompat.WithHeaders(headers))
596 }
605 }
@@ -745,7 +754,7 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con
745 }
754 }
746 providerCfg.ExtraBody["tool_stream"] = true
755 providerCfg.ExtraBody["tool_stream"] = true
747 }
756 }
748− return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody)
757+ return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody, providerCfg.ID)
749 case hyper.Name:
758 case hyper.Name:
750 return c.buildHyperProvider(baseURL, apiKey)
759 return c.buildHyperProvider(baseURL, apiKey)
751 default:
760 default:
internal/oauth/copilot/client.gonew file+77
@@ -0,0 +1,77 @@
1+// Package copilot provides GitHub Copilot integration.
2+package copilot
3+
4+import (
5+ "bytes"
6+ "fmt"
7+ "io"
8+ "log/slog"
9+ "net/http"
10+ "regexp"
11+
12+ "github.com/charmbracelet/crush/internal/log"
13+)
14+
15+// NewClient creates a new HTTP client with a custom transport that adds the
16+// X-Initiator header based on message history in the request body.
17+func NewClient(debug bool) *http.Client {
18+ return &http.Client{
19+ Transport: &initiatorTransport{debug: debug},
20+ }
21+}
22+
23+type initiatorTransport struct {
24+ debug bool
25+}
26+
27+func (t *initiatorTransport) RoundTrip(req *http.Request) (*http.Response, error) {
28+ const (
29+ xInitiatorHeader = "X-Initiator"
30+ userInitiator = "user"
31+ agentInitiator = "agent"
32+ )
33+
34+ if req == nil {
35+ return nil, fmt.Errorf("HTTP request is nil")
36+ }
37+ if req.Body == http.NoBody {
38+ // No body to inspect; default to user.
39+ req.Header.Set(xInitiatorHeader, userInitiator)
40+ slog.Debug("Setting X-Initiator header to user (no request body)")
41+ return t.roundTrip(req)
42+ }
43+
44+ // Clone request to avoid modifying the original.
45+ req = req.Clone(req.Context())
46+
47+ // Read the original body into bytes so we can examine it.
48+ bodyBytes, err := io.ReadAll(req.Body)
49+ if err != nil {
50+ return nil, fmt.Errorf("failed to read request body: %w", err)
51+ }
52+ defer req.Body.Close()
53+
54+ // Restore the original body using the preserved bytes.
55+ req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
56+
57+ // Check for assistant messages using regex to handle whitespace
58+ // variations in the JSON while avoiding full unmarshalling overhead.
59+ initiator := userInitiator
60+ assistantRolePattern := regexp.MustCompile(`"role"\s*:\s*"assistant"`)
61+ if assistantRolePattern.Match(bodyBytes) {
62+ slog.Debug("Setting X-Initiator header to agent (found assistant messages in history)")
63+ initiator = agentInitiator
64+ } else {
65+ slog.Debug("Setting X-Initiator header to user (no assistant messages)")
66+ }
67+ req.Header.Set(xInitiatorHeader, initiator)
68+
69+ return t.roundTrip(req)
70+}
71+
72+func (t *initiatorTransport) roundTrip(req *http.Request) (*http.Response, error) {
73+ if t.debug {
74+ return log.NewHTTPClient().Transport.RoundTrip(req)
75+ }
76+ return http.DefaultTransport.RoundTrip(req)
77+}