on main help sign in
fix: copilot quota handling (#1738)

Co-authored-by: Gustave-241021 <2909789120@qq.com> Co-authored-by: Christian Rocha <christian@rocha.is>

commitaeadffde3bbb407bec508f7c8b95acf0991b1f3c authorKujtim Hoxha <kujtimii.h@gmail.com> dateSat 3 Jan 2026, 18:36 (9 months ago) parenta8f5b39
2 files changed+90−4
internal/agent/coordinator.go+13−4 internal/oauth/copilot/client.go+77
internal/agent/coordinator.go+13−4
@@ -26,6 +26,7 @@ import (
2626 "github.com/charmbracelet/crush/internal/log"
2727 "github.com/charmbracelet/crush/internal/lsp"
2828 "github.com/charmbracelet/crush/internal/message"
29+ "github.com/charmbracelet/crush/internal/oauth/copilot"
2930 "github.com/charmbracelet/crush/internal/permission"
3031 "github.com/charmbracelet/crush/internal/session"
3132 "golang.org/x/sync/errgroup"
@@ -582,15 +583,23 @@ func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[stri
582583 return openrouter.New(opts...)
583584}
584585
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) {
586587 opts := []openaicompat.Option{
587588 openaicompat.WithBaseURL(baseURL),
588589 openaicompat.WithAPIKey(apiKey),
589590 }
590− if c.cfg.Options.Debug {
591− httpClient := log.NewHTTPClient()
591+
592+ // Set HTTP client based on provider and debug mode.
593+ var httpClient *http.Client
594+ if providerID == string(catwalk.InferenceProviderCopilot) {
595+ httpClient = copilot.NewClient(c.cfg.Options.Debug)
596+ } else if c.cfg.Options.Debug {
597+ httpClient = log.NewHTTPClient()
598+ }
599+ if httpClient != nil {
592600 opts = append(opts, openaicompat.WithHTTPClient(httpClient))
593601 }
602+
594603 if len(headers) > 0 {
595604 opts = append(opts, openaicompat.WithHeaders(headers))
596605 }
@@ -745,7 +754,7 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con
745754 }
746755 providerCfg.ExtraBody["tool_stream"] = true
747756 }
748− return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody)
757+ return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody, providerCfg.ID)
749758 case hyper.Name:
750759 return c.buildHyperProvider(baseURL, apiKey)
751760 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+}