From 17c7e03491d0b1c4e327d10027b7fc48ec54d33d Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Sat, 28 Mar 2026 22:02:41 +0000 Subject: [PATCH] feat: add chatgpt provider --- api.go | 6 ++ config/config.go | 24 +++++-- provider/chatgpt.go | 168 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 provider/chatgpt.go diff --git a/api.go b/api.go index e0486d77..1b74dd19 100644 --- a/api.go +++ b/api.go @@ -18,6 +18,7 @@ const ( ProviderAnthropic = config.ProviderAnthropic ProviderOpenAI = config.ProviderOpenAI ProviderCopilot = config.ProviderCopilot + ProviderChatGPT = config.ProviderChatGPT ) type ( @@ -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 { @@ -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) } diff --git a/config/config.go b/config/config.go index 3e8cdf47..fe4accac 100644 --- a/config/config.go +++ b/config/config.go @@ -6,6 +6,7 @@ const ( ProviderAnthropic = "anthropic" ProviderOpenAI = "openai" ProviderCopilot = "copilot" + ProviderChatGPT = "chatgpt" ) type Anthropic struct { @@ -40,6 +41,23 @@ type OpenAI struct { ExtraHeaders map[string]string } +type Copilot struct { + BaseURL string + APIDumpDir string + CircuitBreaker *CircuitBreaker +} + +// ChatGPT is similar to OpenAI but targets the ChatGPT backend. +// Since it authenticates exclusively via per-user credentials, it does not +// require a centralized API key. +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. @@ -67,9 +85,3 @@ func DefaultCircuitBreaker() CircuitBreaker { MaxRequests: 3, } } - -type Copilot struct { - BaseURL string - APIDumpDir string - CircuitBreaker *CircuitBreaker -} diff --git a/provider/chatgpt.go b/provider/chatgpt.go new file mode 100644 index 00000000..e6db4533 --- /dev/null +++ b/provider/chatgpt.go @@ -0,0 +1,168 @@ +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" + + routeChatGPTChatCompletions = "/chat/completions" + routeChatGPTResponses = "/responses" +) + +var chatGPTOpenErrorResponse = func() []byte { + return []byte(`{"error":{"message":"circuit breaker is open","type":"server_error","code":"service_unavailable"}}`) +} + +// ChatGPT implements the Provider interface for the ChatGPT backend. +// ChatGPT uses per-user credentials passed through the request headers +// rather than a statically configured API key. +// The implementation mirrors the OpenAI provider. The ChatGPT backend API is not +// publicly documented, but manual testing suggests it follows the same route +// structure as the OpenAI API. +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 { + return fmt.Sprintf("/%s/v1", p.Name()) +} + +func (p *ChatGPT) BridgedRoutes() []string { + return []string{ + routeChatGPTChatCompletions, + routeChatGPTResponses, + } +} + +func (p *ChatGPT) PassthroughRoutes() []string { + return []string{ + "/conversations", + "/conversations/", + "/models", + "/models/", + "/responses/", + } +} + +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 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") + } + + // Build config for the interceptor using the per-request token. + // ChatGPT's API is OpenAI-compatible, so it uses the OpenAI interceptors + // that require a config.OpenAI. + 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 +}