diff --git a/core/providers/openai/chatgpt_passthrough.go b/core/providers/openai/chatgpt_passthrough.go new file mode 100644 index 0000000000..fd7015df2a --- /dev/null +++ b/core/providers/openai/chatgpt_passthrough.go @@ -0,0 +1,57 @@ +package openai + +import ( + "encoding/base64" + "strings" + + "github.com/bytedance/sonic" + "github.com/maximhq/bifrost/core/schemas" +) + +const ( + chatGPTAccountIDKey = "chatgpt_account_id" + openAIAuthClaim = "https://api.openai.com/auth" + + // ChatGPTCodexURL is the full upstream URL for ChatGPT subscription token requests. + ChatGPTCodexURL = "https://chatgpt.com/backend-api/codex/responses" +) + +// ParseChatGPTJWT parses a raw bearer token, checks for the ChatGPT subscription +// JWT claim, and returns the chatgpt_account_id. No signature verification is +// Returns ("", false) for any non-ChatGPT or malformed token. +func ParseChatGPTJWT(token string) (accountID string, ok bool) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return "", false + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", false + } + + // Extract the nested claim: {"https://api.openai.com/auth": {"chatgpt_account_id": "..."}} + var claims map[string]interface{} + if err := sonic.Unmarshal(payload, &claims); err != nil { + return "", false + } + + authClaim, ok := claims[openAIAuthClaim].(map[string]interface{}) + if !ok { + return "", false + } + + accountID, ok = authClaim[chatGPTAccountIDKey].(string) + if !ok || accountID == "" { + return "", false + } + + return accountID, true +} + +// IsChatGPTPassthrough reports whether the current request was auto-detected +// as a ChatGPT subscription token and should be routed to chatgpt.com. +func IsChatGPTPassthrough(ctx *schemas.BifrostContext) bool { + v, _ := ctx.Value(schemas.BifrostContextKeyChatGPTPassthrough).(bool) + return v +} diff --git a/core/providers/openai/chatgpt_passthrough_test.go b/core/providers/openai/chatgpt_passthrough_test.go new file mode 100644 index 0000000000..1b2bb53e81 --- /dev/null +++ b/core/providers/openai/chatgpt_passthrough_test.go @@ -0,0 +1,90 @@ +package openai + +import ( + "encoding/base64" + "fmt" + "testing" +) + +// makeTestJWT builds a syntactically valid JWT with arbitrary header/payload JSON. +// The signature segment is a fixed placeholder — ParseChatGPTJWT never verifies it. +func makeTestJWT(payloadJSON string) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) + payload := base64.RawURLEncoding.EncodeToString([]byte(payloadJSON)) + return fmt.Sprintf("%s.%s.fakesig", header, payload) +} + +func TestParseChatGPTJWT(t *testing.T) { + validAccountID := "9dce4683-94cd-4aeb-ade4-4ecce82ebac5" + + tests := []struct { + name string + token string + wantID string + wantOK bool + }{ + { + name: "valid ChatGPT JWT returns account ID", + token: makeTestJWT(fmt.Sprintf( + `{"aud":["https://api.openai.com/v1"],"https://api.openai.com/auth":{"chatgpt_account_id":%q}}`, + validAccountID, + )), + wantID: validAccountID, + wantOK: true, + }, + { + name: "JWT missing chatgpt_account_id claim returns false", + token: makeTestJWT(`{"aud":["https://api.openai.com/v1"],"sub":"user-abc"}`), + wantID: "", + wantOK: false, + }, + { + name: "JWT with https://api.openai.com/auth but no chatgpt_account_id returns false", + token: makeTestJWT(`{"https://api.openai.com/auth":{"other_field":"value"}}`), + wantID: "", + wantOK: false, + }, + { + name: "not a JWT (sk- API key) returns false", + token: "sk-proj-abcdefghijklmnopqrstuvwxyz", + wantID: "", + wantOK: false, + }, + { + name: "empty string returns false", + token: "", + wantID: "", + wantOK: false, + }, + { + name: "only two segments returns false", + token: "header.payload", + wantID: "", + wantOK: false, + }, + { + name: "invalid base64 in payload returns false", + token: "header.!!!invalid!!!.sig", + wantID: "", + wantOK: false, + }, + { + name: "payload is valid base64 but not JSON returns false", + token: fmt.Sprintf("header.%s.sig", base64.RawURLEncoding.EncodeToString([]byte("not-json"))), + wantID: "", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotID, gotOK := ParseChatGPTJWT(tt.token) + if gotOK != tt.wantOK { + t.Errorf("ParseChatGPTJWT() ok = %v, want %v", gotOK, tt.wantOK) + } + if gotID != tt.wantID { + t.Errorf("ParseChatGPTJWT() accountID = %q, want %q", gotID, tt.wantID) + } + }) + } +} diff --git a/core/providers/openai/openai.go b/core/providers/openai/openai.go index 84bddc58ad..23c23987ae 100644 --- a/core/providers/openai/openai.go +++ b/core/providers/openai/openai.go @@ -1389,7 +1389,7 @@ func HandleOpenAIChatCompletionStreaming( // Responses performs a responses request to the OpenAI API. func (provider *OpenAIProvider) Responses(ctx *schemas.BifrostContext, key schemas.Key, request *schemas.BifrostResponsesRequest) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { - if provider.shouldFallbackResponsesToChat(schemas.ResponsesRequest, schemas.ChatCompletionRequest) { + if !IsChatGPTPassthrough(ctx) && provider.shouldFallbackResponsesToChat(schemas.ResponsesRequest, schemas.ChatCompletionRequest) { chatResponse, err := provider.ChatCompletion(ctx, key, request.ToChatRequest()) if err != nil { return nil, err @@ -1402,17 +1402,22 @@ func (provider *OpenAIProvider) Responses(ctx *schemas.BifrostContext, key schem return nil, err } - if provider.disableStore { + if provider.disableStore || IsChatGPTPassthrough(ctx) { if request.Params == nil { request.Params = &schemas.ResponsesParameters{} } request.Params.Store = schemas.Ptr(false) } + url := provider.buildRequestURL(ctx, "/v1/responses", schemas.ResponsesRequest) + if IsChatGPTPassthrough(ctx) { + url = ChatGPTCodexURL + } + return HandleOpenAIResponsesRequest( ctx, provider.client, - provider.buildRequestURL(ctx, "/v1/responses", schemas.ResponsesRequest), + url, request, key, provider.networkConfig.ExtraHeaders, @@ -1560,7 +1565,7 @@ func HandleOpenAIResponsesRequest( // ResponsesStream performs a streaming responses request to the OpenAI API. func (provider *OpenAIProvider) ResponsesStream(ctx *schemas.BifrostContext, postHookRunner schemas.PostHookRunner, postHookSpanFinalizer func(context.Context), key schemas.Key, request *schemas.BifrostResponsesRequest) (chan *schemas.BifrostStreamChunk, *schemas.BifrostError) { - if provider.shouldFallbackResponsesToChat(schemas.ResponsesStreamRequest, schemas.ChatCompletionStreamRequest) { + if !IsChatGPTPassthrough(ctx) && provider.shouldFallbackResponsesToChat(schemas.ResponsesStreamRequest, schemas.ChatCompletionStreamRequest) { ctx.SetValue(schemas.BifrostContextKeyIsResponsesToChatCompletionFallback, true) return provider.ChatCompletionStream(ctx, postHookRunner, postHookSpanFinalizer, key, request.ToChatRequest()) } @@ -1573,18 +1578,23 @@ func (provider *OpenAIProvider) ResponsesStream(ctx *schemas.BifrostContext, pos if key.Value.GetValue() != "" { authHeader = map[string]string{"Authorization": "Bearer " + key.Value.GetValue()} } - if provider.disableStore { + if provider.disableStore || IsChatGPTPassthrough(ctx) { if request.Params == nil { request.Params = &schemas.ResponsesParameters{} } request.Params.Store = schemas.Ptr(false) } + streamURL := provider.buildRequestURL(ctx, "/v1/responses", schemas.ResponsesStreamRequest) + if IsChatGPTPassthrough(ctx) { + streamURL = ChatGPTCodexURL + } + // Use shared streaming logic return HandleOpenAIResponsesStreaming( ctx, provider.streamingClient, - provider.buildRequestURL(ctx, "/v1/responses", schemas.ResponsesStreamRequest), + streamURL, request, authHeader, provider.networkConfig.ExtraHeaders, diff --git a/core/schemas/bifrost.go b/core/schemas/bifrost.go index 370ba8567b..22646e67d6 100644 --- a/core/schemas/bifrost.go +++ b/core/schemas/bifrost.go @@ -349,6 +349,11 @@ const ( BifrostContextKeyConnectionClosed BifrostContextKey = "connection_closed" BifrostContextKeyTempTokenScope BifrostContextKey = "bifrost-temp-token-scope" // string (set by auth middleware when a temp token authorized the request - names the scope from the temptoken registry) BifrostContextKeyTempTokenResourceID BifrostContextKey = "bifrost-temp-token-resource-id" // string (set by auth middleware alongside the scope - the resource_id the token is bound to, e.g. an OAuth flow ID for mcp_auth) + + // ChatGPT subscription token auto-detection. Set by the OpenAI transport pre-hook + // when the incoming bearer JWT contains the chatgpt_account_id claim. The provider + // uses this to reroute to chatgpt.com/backend-api/codex/responses. + BifrostContextKeyChatGPTPassthrough BifrostContextKey = "bifrost-chatgpt-passthrough" // bool ) const ( diff --git a/transports/bifrost-http/integrations/openai.go b/transports/bifrost-http/integrations/openai.go index 50c038f5a5..bc2285c897 100644 --- a/transports/bifrost-http/integrations/openai.go +++ b/transports/bifrost-http/integrations/openai.go @@ -756,6 +756,22 @@ func CreateOpenAIRouteConfigs(pathPrefix string, handlerStore lib.HandlerStore) if isAzureSDKRequest(ctx) { bifrostCtx.SetValue(schemas.BifrostContextKeyIsAzureUserAgent, true) } + if authHeader := string(ctx.Request.Header.Peek("Authorization")); strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { + token := authHeader[7:] + if _, ok := openai.ParseChatGPTJWT(token); ok { + bifrostCtx.SetValue(schemas.BifrostContextKeyChatGPTPassthrough, true) + bifrostCtx.SetValue(schemas.BifrostContextKeySkipKeySelection, true) + existing, _ := bifrostCtx.Value(schemas.BifrostContextKeyExtraHeaders).(map[string][]string) + headers := make(map[string][]string, len(existing)+1) + for k, v := range existing { + if !strings.EqualFold(k, "authorization") { + headers[k] = v + } + } + headers["Authorization"] = []string{"Bearer " + token} + bifrostCtx.SetValue(schemas.BifrostContextKeyExtraHeaders, headers) + } + } return nil }, })