Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const (
ProviderAnthropic = config.ProviderAnthropic
ProviderOpenAI = config.ProviderOpenAI
ProviderCopilot = config.ProviderCopilot
ProviderChatGPT = config.ProviderChatGPT
)

type (
Expand All @@ -38,6 +39,7 @@ type (
AWSBedrockConfig = config.AWSBedrock
OpenAIConfig = config.OpenAI
CopilotConfig = config.Copilot
ChatGPTConfig = config.ChatGPT
)

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

func NewChatGPTProvider(cfg config.ChatGPT) provider.Provider {
return provider.NewChatGPT(cfg)
}

func NewMetrics(reg prometheus.Registerer) *metrics.Metrics {
return metrics.NewMetrics(reg)
}
Expand Down
12 changes: 12 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const (
ProviderAnthropic = "anthropic"
ProviderOpenAI = "openai"
ProviderCopilot = "copilot"
ProviderChatGPT = "chatgpt"
)

type Anthropic struct {
Expand Down Expand Up @@ -40,6 +41,17 @@ type OpenAI struct {
ExtraHeaders map[string]string
}

// ChatGPT holds configuration for the ChatGPT subscription backend
// (chatgpt.com/backend-api/codex). Unlike the OpenAI API, this backend
// is used by ChatGPT Plus/Pro subscribers authenticating via OAuth.
type ChatGPT struct {
BaseURL string
APIDumpDir string
CircuitBreaker *CircuitBreaker
SendActorHeaders bool
ExtraHeaders map[string]string
}

// CircuitBreaker holds configuration for circuit breakers.
type CircuitBreaker struct {
// MaxRequests is the maximum number of requests allowed in half-open state.
Expand Down
173 changes: 173 additions & 0 deletions provider/chatgpt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package provider

import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"

"github.com/coder/aibridge/config"
"github.com/coder/aibridge/intercept"
"github.com/coder/aibridge/intercept/chatcompletions"
"github.com/coder/aibridge/intercept/responses"
"github.com/coder/aibridge/tracing"
"github.com/coder/aibridge/utils"
"github.com/google/uuid"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)

const (
chatGPTBaseURL = "https://chatgpt.com/backend-api/codex"

// ChatGPT exposes an OpenAI-compatible API.
routeChatGPTChatCompletions = "/chat/completions"
routeChatGPTResponses = "/responses"
)

var chatGPTOpenErrorResponse = func() []byte {
return []byte(`{"error":{"message":"circuit breaker is open","type":"server_error","code":"service_unavailable"}}`)
}

// ChatGPT targets the ChatGPT subscription backend used by Plus/Pro subscribers.
// It reuses the OpenAI interceptors since the backend is API-compatible, but routes
// to chatgpt.com/backend-api/codex instead of api.openai.com/v1.
type ChatGPT struct {
cfg config.ChatGPT
circuitBreaker *config.CircuitBreaker
}

var _ Provider = &ChatGPT{}

func NewChatGPT(cfg config.ChatGPT) *ChatGPT {
if cfg.BaseURL == "" {
cfg.BaseURL = chatGPTBaseURL
}
if cfg.APIDumpDir == "" {
cfg.APIDumpDir = os.Getenv("BRIDGE_DUMP_DIR")
}
if cfg.CircuitBreaker != nil {
cfg.CircuitBreaker.OpenErrorResponse = chatGPTOpenErrorResponse
}

return &ChatGPT{
cfg: cfg,
circuitBreaker: cfg.CircuitBreaker,
}
}

func (p *ChatGPT) Name() string {
return config.ProviderChatGPT
}

func (p *ChatGPT) RoutePrefix() string {
// Includes /v1 to match the OpenAI SDK base URL convention used by Codex.
return fmt.Sprintf("/%s/v1", p.Name())
}

func (p *ChatGPT) BridgedRoutes() []string {
return []string{
routeChatGPTChatCompletions,
routeChatGPTResponses,
}
}

// PassthroughRoutes define the routes which are not currently intercepted
// but must be passed through to the upstream.
// The /v1/completions legacy API is deprecated and will not be passed through.
// See https://platform.openai.com/docs/api-reference/completions.
func (p *ChatGPT) PassthroughRoutes() []string {
return []string{
// See https://pkg.go.dev/net/http#hdr-Trailing_slash_redirection-ServeMux.
// but without non trailing slash route requests to `/v1/conversations` are going to catch all
"/conversations",
"/conversations/",
"/models",
"/models/",
"/responses/", // Forwards other responses API endpoints, eg: https://platform.openai.com/docs/api-reference/responses/get
}
}

func (p *ChatGPT) CreateInterceptor(w http.ResponseWriter, r *http.Request, tracer trace.Tracer) (_ intercept.Interceptor, outErr error) {
id := uuid.New()

_, span := tracer.Start(r.Context(), "Intercept.CreateInterceptor")
defer tracing.EndSpanErr(span, &outErr)

var interceptor intercept.Interceptor

// Extract the per-user ChatGPT OAuth token from the Authorization header.
token := utils.ExtractBearerToken(r.Header.Get("Authorization"))
if token == "" {
span.SetStatus(codes.Error, "missing authorization")
return nil, fmt.Errorf("missing ChatGPT authorization: Authorization header not found or invalid")
}

// The interceptors are typed to config.OpenAI since they were originally
// built for the OpenAI provider. ChatGPT's backend is API-compatible,
// so we convert the config here.
openAICfg := config.OpenAI{
BaseURL: p.cfg.BaseURL,
Key: token,
APIDumpDir: p.cfg.APIDumpDir,
CircuitBreaker: p.cfg.CircuitBreaker,
SendActorHeaders: p.cfg.SendActorHeaders,
ExtraHeaders: p.cfg.ExtraHeaders,
}

path := strings.TrimPrefix(r.URL.Path, p.RoutePrefix())
switch path {
case routeChatGPTChatCompletions:
var req chatcompletions.ChatCompletionNewParamsWrapper
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return nil, fmt.Errorf("unmarshal request body: %w", err)
}

if req.Stream {
interceptor = chatcompletions.NewStreamingInterceptor(id, &req, openAICfg, r.Header, p.AuthHeader(), tracer)
} else {
interceptor = chatcompletions.NewBlockingInterceptor(id, &req, openAICfg, r.Header, p.AuthHeader(), tracer)
}

case routeChatGPTResponses:
payload, err := io.ReadAll(r.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
reqPayload, err := responses.NewResponsesRequestPayload(payload)
if err != nil {
return nil, fmt.Errorf("unmarshal request body: %w", err)
}
if reqPayload.Stream() {
interceptor = responses.NewStreamingInterceptor(id, reqPayload, openAICfg, r.Header, p.AuthHeader(), tracer)
} else {
interceptor = responses.NewBlockingInterceptor(id, reqPayload, openAICfg, r.Header, p.AuthHeader(), tracer)
}

default:
span.SetStatus(codes.Error, "unknown route: "+r.URL.Path)
return nil, UnknownRoute
}
span.SetAttributes(interceptor.TraceAttributes(r)...)
return interceptor, nil
}

func (p *ChatGPT) BaseURL() string {
return p.cfg.BaseURL
}

func (p *ChatGPT) AuthHeader() string {
return "Authorization"
}

func (p *ChatGPT) InjectAuthHeader(headers *http.Header) {}

func (p *ChatGPT) CircuitBreakerConfig() *config.CircuitBreaker {
return p.circuitBreaker
}

func (p *ChatGPT) APIDumpDir() string {
return p.cfg.APIDumpDir
}
Loading