Skip to content

Commit 602942b

Browse files
authored
feat: add GitHub Copilot provider with per-user token authentication (#137)
## Description Adds support for GitHub Copilot as an AI provider. Unlike other providers that use a global API key, Copilot uses per-user keys passed in the `Authorization` header. Copilot's API is OpenAI-compatible, so the provider reuses the existing OpenAI interceptors. ## Changes * Add `Copilot` provider in with support for: * `/chat/completions` endpoint * `/responses` endpoint * Passthrough routes: `/models`, `/agents/`, `/mcp/` * `InjectAuthHeader` is a no-op since Copilot uses per-user keys from the original request * Add Copilot specific tests Related to: coder/internal#1235
1 parent f8a8947 commit 602942b

5 files changed

Lines changed: 559 additions & 0 deletions

File tree

api.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
const (
1818
ProviderAnthropic = config.ProviderAnthropic
1919
ProviderOpenAI = config.ProviderOpenAI
20+
ProviderCopilot = config.ProviderCopilot
2021
)
2122

2223
type (
@@ -35,6 +36,7 @@ type (
3536
AnthropicConfig = config.Anthropic
3637
AWSBedrockConfig = config.AWSBedrock
3738
OpenAIConfig = config.OpenAI
39+
CopilotConfig = config.Copilot
3840
)
3941

4042
func AsActor(ctx context.Context, actorID string, metadata recorder.Metadata) context.Context {
@@ -49,6 +51,10 @@ func NewOpenAIProvider(cfg config.OpenAI) provider.Provider {
4951
return provider.NewOpenAI(cfg)
5052
}
5153

54+
func NewCopilotProvider(cfg config.Copilot) provider.Provider {
55+
return provider.NewCopilot(cfg)
56+
}
57+
5258
func NewMetrics(reg prometheus.Registerer) *metrics.Metrics {
5359
return metrics.NewMetrics(reg)
5460
}

config/config.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import "time"
55
const (
66
ProviderAnthropic = "anthropic"
77
ProviderOpenAI = "openai"
8+
ProviderCopilot = "copilot"
89
)
910

1011
type Anthropic struct {
@@ -31,6 +32,7 @@ type OpenAI struct {
3132
APIDumpDir string
3233
CircuitBreaker *CircuitBreaker
3334
SendActorHeaders bool
35+
ExtraHeaders map[string]string
3436
}
3537

3638
// CircuitBreaker holds configuration for circuit breakers.
@@ -60,3 +62,9 @@ func DefaultCircuitBreaker() CircuitBreaker {
6062
MaxRequests: 3,
6163
}
6264
}
65+
66+
type Copilot struct {
67+
BaseURL string
68+
APIDumpDir string
69+
CircuitBreaker *CircuitBreaker
70+
}

intercept/chatcompletions/base.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ type interceptionBase struct {
3939
func (i *interceptionBase) newCompletionsService() openai.ChatCompletionService {
4040
opts := []option.RequestOption{option.WithAPIKey(i.cfg.Key), option.WithBaseURL(i.cfg.BaseURL)}
4141

42+
// Add extra headers if configured.
43+
// Some providers require additional headers that are not added by the SDK.
44+
for key, value := range i.cfg.ExtraHeaders {
45+
opts = append(opts, option.WithHeader(key, value))
46+
}
47+
4248
// Add API dump middleware if configured
4349
if mw := apidump.NewMiddleware(i.cfg.APIDumpDir, config.ProviderOpenAI, i.Model(), i.id, i.logger, quartz.NewReal()); mw != nil {
4450
opts = append(opts, option.WithMiddleware(mw))

provider/copilot.go

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
package provider
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"os"
9+
"strings"
10+
11+
"github.com/coder/aibridge/config"
12+
"github.com/coder/aibridge/intercept"
13+
"github.com/coder/aibridge/intercept/chatcompletions"
14+
"github.com/coder/aibridge/intercept/responses"
15+
"github.com/coder/aibridge/tracing"
16+
"github.com/google/uuid"
17+
"go.opentelemetry.io/otel/codes"
18+
"go.opentelemetry.io/otel/trace"
19+
)
20+
21+
const (
22+
copilotBaseURL = "https://api.individual.githubcopilot.com"
23+
24+
// Copilot exposes an OpenAI-compatible API, including for Anthropic models.
25+
routeCopilotChatCompletions = "/copilot/chat/completions"
26+
routeCopilotResponses = "/copilot/responses"
27+
)
28+
29+
var copilotOpenErrorResponse = func() []byte {
30+
return []byte(`{"error":{"message":"circuit breaker is open","type":"server_error","code":"service_unavailable"}}`)
31+
}
32+
33+
// Headers that need to be forwarded to Copilot API.
34+
// These were determined through manual testing as there is no reference
35+
// of the headers in the official documentation.
36+
// LiteLLM uses the same headers:
37+
// https://docs.litellm.ai/docs/providers/github_copilot
38+
var copilotForwardHeaders = []string{
39+
"Editor-Version",
40+
"Copilot-Integration-Id",
41+
}
42+
43+
// Copilot implements the Provider interface for GitHub Copilot.
44+
// Unlike other providers, Copilot uses per-user API keys that are passed through
45+
// the request headers rather than configured statically.
46+
type Copilot struct {
47+
cfg config.Copilot
48+
circuitBreaker *config.CircuitBreaker
49+
}
50+
51+
var _ Provider = &Copilot{}
52+
53+
func NewCopilot(cfg config.Copilot) *Copilot {
54+
if cfg.BaseURL == "" {
55+
cfg.BaseURL = copilotBaseURL
56+
}
57+
if cfg.APIDumpDir == "" {
58+
cfg.APIDumpDir = os.Getenv("BRIDGE_DUMP_DIR")
59+
}
60+
if cfg.CircuitBreaker != nil {
61+
cfg.CircuitBreaker.OpenErrorResponse = copilotOpenErrorResponse
62+
}
63+
return &Copilot{
64+
cfg: cfg,
65+
circuitBreaker: cfg.CircuitBreaker,
66+
}
67+
}
68+
69+
func (p *Copilot) Name() string {
70+
return config.ProviderCopilot
71+
}
72+
73+
func (p *Copilot) BaseURL() string {
74+
return p.cfg.BaseURL
75+
}
76+
77+
func (p *Copilot) BridgedRoutes() []string {
78+
return []string{
79+
routeCopilotChatCompletions,
80+
routeCopilotResponses,
81+
}
82+
}
83+
84+
func (p *Copilot) PassthroughRoutes() []string {
85+
return []string{
86+
"/models",
87+
"/models/",
88+
"/agents/",
89+
"/mcp/",
90+
}
91+
}
92+
93+
func (p *Copilot) AuthHeader() string {
94+
return "Authorization"
95+
}
96+
97+
// InjectAuthHeader is a no-op for Copilot.
98+
// Copilot uses per-user tokens passed in the original Authorization header,
99+
// rather than a global key configured at the provider level.
100+
// The original Authorization header flows through untouched from the client.
101+
func (p *Copilot) InjectAuthHeader(_ *http.Header) {}
102+
103+
func (p *Copilot) CircuitBreakerConfig() *config.CircuitBreaker {
104+
return p.circuitBreaker
105+
}
106+
107+
func (p *Copilot) CreateInterceptor(_ http.ResponseWriter, r *http.Request, tracer trace.Tracer) (_ intercept.Interceptor, outErr error) {
108+
_, span := tracer.Start(r.Context(), "Intercept.CreateInterceptor")
109+
defer tracing.EndSpanErr(span, &outErr)
110+
111+
// Extract the per-user Copilot key from the Authorization header.
112+
key := extractBearerToken(r.Header.Get("Authorization"))
113+
if key == "" {
114+
span.SetStatus(codes.Error, "missing authorization")
115+
return nil, fmt.Errorf("missing Copilot authorization: Authorization header not found or invalid")
116+
}
117+
118+
id := uuid.New()
119+
120+
// Build config for the interceptor using the per-request key.
121+
// Copilot's API is OpenAI-compatible, so it uses the OpenAI interceptors
122+
// that require a config.OpenAI.
123+
cfg := config.OpenAI{
124+
BaseURL: p.cfg.BaseURL,
125+
Key: key,
126+
APIDumpDir: p.cfg.APIDumpDir,
127+
CircuitBreaker: p.cfg.CircuitBreaker,
128+
ExtraHeaders: extractCopilotHeaders(r),
129+
}
130+
131+
var interceptor intercept.Interceptor
132+
133+
switch r.URL.Path {
134+
case routeCopilotChatCompletions:
135+
var req chatcompletions.ChatCompletionNewParamsWrapper
136+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
137+
return nil, fmt.Errorf("unmarshal chat completions request body: %w", err)
138+
}
139+
140+
if req.Stream {
141+
interceptor = chatcompletions.NewStreamingInterceptor(id, &req, cfg, tracer)
142+
} else {
143+
interceptor = chatcompletions.NewBlockingInterceptor(id, &req, cfg, tracer)
144+
}
145+
146+
case routeCopilotResponses:
147+
payload, err := io.ReadAll(r.Body)
148+
if err != nil {
149+
return nil, fmt.Errorf("read body: %w", err)
150+
}
151+
var req responses.ResponsesNewParamsWrapper
152+
if err := json.Unmarshal(payload, &req); err != nil {
153+
return nil, fmt.Errorf("unmarshal responses request body: %w", err)
154+
}
155+
156+
if req.Stream {
157+
interceptor = responses.NewStreamingInterceptor(id, &req, payload, cfg, req.Model, tracer)
158+
} else {
159+
interceptor = responses.NewBlockingInterceptor(id, &req, payload, cfg, req.Model, tracer)
160+
}
161+
162+
default:
163+
span.SetStatus(codes.Error, "unknown route: "+r.URL.Path)
164+
return nil, UnknownRoute
165+
}
166+
167+
span.SetAttributes(interceptor.TraceAttributes(r)...)
168+
return interceptor, nil
169+
}
170+
171+
// extractBearerToken extracts the token from a "Bearer <token>" authorization header.
172+
func extractBearerToken(auth string) string {
173+
if auth := strings.TrimSpace(auth); auth != "" {
174+
fields := strings.Fields(auth)
175+
if len(fields) == 2 && strings.EqualFold(fields[0], "Bearer") {
176+
return fields[1]
177+
}
178+
}
179+
return ""
180+
}
181+
182+
// extractCopilotHeaders extracts headers required by the Copilot API from the
183+
// incoming request. Copilot requires certain client headers to be forwarded.
184+
func extractCopilotHeaders(r *http.Request) map[string]string {
185+
headers := make(map[string]string, len(copilotForwardHeaders))
186+
for _, h := range copilotForwardHeaders {
187+
if v := r.Header.Get(h); v != "" {
188+
headers[h] = v
189+
}
190+
}
191+
return headers
192+
}

0 commit comments

Comments
 (0)