Skip to content

Commit 17c7e03

Browse files
feat: add chatgpt provider
1 parent b97c9d4 commit 17c7e03

File tree

3 files changed

+192
-6
lines changed

3 files changed

+192
-6
lines changed

api.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const (
1818
ProviderAnthropic = config.ProviderAnthropic
1919
ProviderOpenAI = config.ProviderOpenAI
2020
ProviderCopilot = config.ProviderCopilot
21+
ProviderChatGPT = config.ProviderChatGPT
2122
)
2223

2324
type (
@@ -38,6 +39,7 @@ type (
3839
AWSBedrockConfig = config.AWSBedrock
3940
OpenAIConfig = config.OpenAI
4041
CopilotConfig = config.Copilot
42+
ChatGPTConfig = config.ChatGPT
4143
)
4244

4345
func AsActor(ctx context.Context, actorID string, metadata recorder.Metadata) context.Context {
@@ -56,6 +58,10 @@ func NewCopilotProvider(cfg config.Copilot) provider.Provider {
5658
return provider.NewCopilot(cfg)
5759
}
5860

61+
func NewChatGPTProvider(cfg config.ChatGPT) provider.Provider {
62+
return provider.NewChatGPT(cfg)
63+
}
64+
5965
func NewMetrics(reg prometheus.Registerer) *metrics.Metrics {
6066
return metrics.NewMetrics(reg)
6167
}

config/config.go

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const (
66
ProviderAnthropic = "anthropic"
77
ProviderOpenAI = "openai"
88
ProviderCopilot = "copilot"
9+
ProviderChatGPT = "chatgpt"
910
)
1011

1112
type Anthropic struct {
@@ -40,6 +41,23 @@ type OpenAI struct {
4041
ExtraHeaders map[string]string
4142
}
4243

44+
type Copilot struct {
45+
BaseURL string
46+
APIDumpDir string
47+
CircuitBreaker *CircuitBreaker
48+
}
49+
50+
// ChatGPT is similar to OpenAI but targets the ChatGPT backend.
51+
// Since it authenticates exclusively via per-user credentials, it does not
52+
// require a centralized API key.
53+
type ChatGPT struct {
54+
BaseURL string
55+
APIDumpDir string
56+
CircuitBreaker *CircuitBreaker
57+
SendActorHeaders bool
58+
ExtraHeaders map[string]string
59+
}
60+
4361
// CircuitBreaker holds configuration for circuit breakers.
4462
type CircuitBreaker struct {
4563
// MaxRequests is the maximum number of requests allowed in half-open state.
@@ -67,9 +85,3 @@ func DefaultCircuitBreaker() CircuitBreaker {
6785
MaxRequests: 3,
6886
}
6987
}
70-
71-
type Copilot struct {
72-
BaseURL string
73-
APIDumpDir string
74-
CircuitBreaker *CircuitBreaker
75-
}

provider/chatgpt.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
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/coder/aibridge/utils"
17+
"github.com/google/uuid"
18+
"go.opentelemetry.io/otel/codes"
19+
"go.opentelemetry.io/otel/trace"
20+
)
21+
22+
const (
23+
chatGPTBaseURL = "https://chatgpt.com/backend-api/codex"
24+
25+
routeChatGPTChatCompletions = "/chat/completions"
26+
routeChatGPTResponses = "/responses"
27+
)
28+
29+
var chatGPTOpenErrorResponse = func() []byte {
30+
return []byte(`{"error":{"message":"circuit breaker is open","type":"server_error","code":"service_unavailable"}}`)
31+
}
32+
33+
// ChatGPT implements the Provider interface for the ChatGPT backend.
34+
// ChatGPT uses per-user credentials passed through the request headers
35+
// rather than a statically configured API key.
36+
// The implementation mirrors the OpenAI provider. The ChatGPT backend API is not
37+
// publicly documented, but manual testing suggests it follows the same route
38+
// structure as the OpenAI API.
39+
type ChatGPT struct {
40+
cfg config.ChatGPT
41+
circuitBreaker *config.CircuitBreaker
42+
}
43+
44+
var _ Provider = &ChatGPT{}
45+
46+
func NewChatGPT(cfg config.ChatGPT) *ChatGPT {
47+
if cfg.BaseURL == "" {
48+
cfg.BaseURL = chatGPTBaseURL
49+
}
50+
if cfg.APIDumpDir == "" {
51+
cfg.APIDumpDir = os.Getenv("BRIDGE_DUMP_DIR")
52+
}
53+
if cfg.CircuitBreaker != nil {
54+
cfg.CircuitBreaker.OpenErrorResponse = chatGPTOpenErrorResponse
55+
}
56+
57+
return &ChatGPT{
58+
cfg: cfg,
59+
circuitBreaker: cfg.CircuitBreaker,
60+
}
61+
}
62+
63+
func (p *ChatGPT) Name() string {
64+
return config.ProviderChatGPT
65+
}
66+
67+
func (p *ChatGPT) RoutePrefix() string {
68+
return fmt.Sprintf("/%s/v1", p.Name())
69+
}
70+
71+
func (p *ChatGPT) BridgedRoutes() []string {
72+
return []string{
73+
routeChatGPTChatCompletions,
74+
routeChatGPTResponses,
75+
}
76+
}
77+
78+
func (p *ChatGPT) PassthroughRoutes() []string {
79+
return []string{
80+
"/conversations",
81+
"/conversations/",
82+
"/models",
83+
"/models/",
84+
"/responses/",
85+
}
86+
}
87+
88+
func (p *ChatGPT) CreateInterceptor(w http.ResponseWriter, r *http.Request, tracer trace.Tracer) (_ intercept.Interceptor, outErr error) {
89+
id := uuid.New()
90+
91+
_, span := tracer.Start(r.Context(), "Intercept.CreateInterceptor")
92+
defer tracing.EndSpanErr(span, &outErr)
93+
94+
var interceptor intercept.Interceptor
95+
96+
// Extract the per-user ChatGPT token from the Authorization header.
97+
token := utils.ExtractBearerToken(r.Header.Get("Authorization"))
98+
if token == "" {
99+
span.SetStatus(codes.Error, "missing authorization")
100+
return nil, fmt.Errorf("missing ChatGPT authorization: Authorization header not found or invalid")
101+
}
102+
103+
// Build config for the interceptor using the per-request token.
104+
// ChatGPT's API is OpenAI-compatible, so it uses the OpenAI interceptors
105+
// that require a config.OpenAI.
106+
openAICfg := config.OpenAI{
107+
BaseURL: p.cfg.BaseURL,
108+
Key: token,
109+
APIDumpDir: p.cfg.APIDumpDir,
110+
CircuitBreaker: p.cfg.CircuitBreaker,
111+
SendActorHeaders: p.cfg.SendActorHeaders,
112+
ExtraHeaders: p.cfg.ExtraHeaders,
113+
}
114+
115+
path := strings.TrimPrefix(r.URL.Path, p.RoutePrefix())
116+
switch path {
117+
case routeChatGPTChatCompletions:
118+
var req chatcompletions.ChatCompletionNewParamsWrapper
119+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
120+
return nil, fmt.Errorf("unmarshal request body: %w", err)
121+
}
122+
123+
if req.Stream {
124+
interceptor = chatcompletions.NewStreamingInterceptor(id, &req, openAICfg, r.Header, p.AuthHeader(), tracer)
125+
} else {
126+
interceptor = chatcompletions.NewBlockingInterceptor(id, &req, openAICfg, r.Header, p.AuthHeader(), tracer)
127+
}
128+
129+
case routeChatGPTResponses:
130+
payload, err := io.ReadAll(r.Body)
131+
if err != nil {
132+
return nil, fmt.Errorf("read body: %w", err)
133+
}
134+
reqPayload, err := responses.NewResponsesRequestPayload(payload)
135+
if err != nil {
136+
return nil, fmt.Errorf("unmarshal request body: %w", err)
137+
}
138+
if reqPayload.Stream() {
139+
interceptor = responses.NewStreamingInterceptor(id, reqPayload, openAICfg, r.Header, p.AuthHeader(), tracer)
140+
} else {
141+
interceptor = responses.NewBlockingInterceptor(id, reqPayload, openAICfg, r.Header, p.AuthHeader(), tracer)
142+
}
143+
144+
default:
145+
span.SetStatus(codes.Error, "unknown route: "+r.URL.Path)
146+
return nil, UnknownRoute
147+
}
148+
span.SetAttributes(interceptor.TraceAttributes(r)...)
149+
return interceptor, nil
150+
}
151+
152+
func (p *ChatGPT) BaseURL() string {
153+
return p.cfg.BaseURL
154+
}
155+
156+
func (p *ChatGPT) AuthHeader() string {
157+
return "Authorization"
158+
}
159+
160+
func (p *ChatGPT) InjectAuthHeader(headers *http.Header) {}
161+
162+
func (p *ChatGPT) CircuitBreakerConfig() *config.CircuitBreaker {
163+
return p.circuitBreaker
164+
}
165+
166+
func (p *ChatGPT) APIDumpDir() string {
167+
return p.cfg.APIDumpDir
168+
}

0 commit comments

Comments
 (0)