fix(oauth): mimic Claude Code metadata and beta headers
This commit is contained in:
@@ -16,7 +16,12 @@ const (
|
|||||||
const DefaultBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaFineGrainedToolStreaming
|
const DefaultBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaFineGrainedToolStreaming
|
||||||
|
|
||||||
// MessageBetaHeaderNoTools /v1/messages 在无工具时的 beta header
|
// MessageBetaHeaderNoTools /v1/messages 在无工具时的 beta header
|
||||||
const MessageBetaHeaderNoTools = BetaOAuth + "," + BetaInterleavedThinking
|
//
|
||||||
|
// NOTE: Claude Code OAuth credentials are scoped to Claude Code. When we "mimic"
|
||||||
|
// Claude Code for non-Claude-Code clients, we must include the claude-code beta
|
||||||
|
// even if the request doesn't use tools, otherwise upstream may reject the
|
||||||
|
// request as a non-Claude-Code API request.
|
||||||
|
const MessageBetaHeaderNoTools = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking
|
||||||
|
|
||||||
// MessageBetaHeaderWithTools /v1/messages 在有工具时的 beta header
|
// MessageBetaHeaderWithTools /v1/messages 在有工具时的 beta header
|
||||||
const MessageBetaHeaderWithTools = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking
|
const MessageBetaHeaderWithTools = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking
|
||||||
|
|||||||
62
backend/internal/service/gateway_oauth_metadata_test.go
Normal file
62
backend/internal/service/gateway_oauth_metadata_test.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildOAuthMetadataUserID_FallbackWithoutAccountUUID(t *testing.T) {
|
||||||
|
svc := &GatewayService{}
|
||||||
|
|
||||||
|
parsed := &ParsedRequest{
|
||||||
|
Model: "claude-sonnet-4-5",
|
||||||
|
Stream: true,
|
||||||
|
MetadataUserID: "",
|
||||||
|
System: nil,
|
||||||
|
Messages: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
account := &Account{
|
||||||
|
ID: 123,
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Extra: map[string]any{}, // intentionally missing account_uuid / claude_user_id
|
||||||
|
}
|
||||||
|
|
||||||
|
fp := &Fingerprint{ClientID: "deadbeef"} // should be used as user id in legacy format
|
||||||
|
|
||||||
|
got := svc.buildOAuthMetadataUserID(parsed, account, fp)
|
||||||
|
require.NotEmpty(t, got)
|
||||||
|
|
||||||
|
// Legacy format: user_{client}_account__session_{uuid}
|
||||||
|
re := regexp.MustCompile(`^user_[a-zA-Z0-9]+_account__session_[a-f0-9-]{36}$`)
|
||||||
|
require.True(t, re.MatchString(got), "unexpected user_id format: %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildOAuthMetadataUserID_UsesAccountUUIDWhenPresent(t *testing.T) {
|
||||||
|
svc := &GatewayService{}
|
||||||
|
|
||||||
|
parsed := &ParsedRequest{
|
||||||
|
Model: "claude-sonnet-4-5",
|
||||||
|
Stream: true,
|
||||||
|
MetadataUserID: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
account := &Account{
|
||||||
|
ID: 123,
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Extra: map[string]any{
|
||||||
|
"account_uuid": "acc-uuid",
|
||||||
|
"claude_user_id": "clientid123",
|
||||||
|
"anthropic_user_id": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := svc.buildOAuthMetadataUserID(parsed, account, nil)
|
||||||
|
require.NotEmpty(t, got)
|
||||||
|
|
||||||
|
// New format: user_{client}_account_{account_uuid}_session_{uuid}
|
||||||
|
re := regexp.MustCompile(`^user_clientid123_account_acc-uuid_session_[a-f0-9-]{36}$`)
|
||||||
|
require.True(t, re.MatchString(got), "unexpected user_id format: %s", got)
|
||||||
|
}
|
||||||
@@ -795,17 +795,15 @@ func (s *GatewayService) buildOAuthMetadataUserID(parsed *ParsedRequest, account
|
|||||||
if parsed.MetadataUserID != "" {
|
if parsed.MetadataUserID != "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
accountUUID := account.GetExtraString("account_uuid")
|
|
||||||
if accountUUID == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := strings.TrimSpace(account.GetClaudeUserID())
|
userID := strings.TrimSpace(account.GetClaudeUserID())
|
||||||
if userID == "" && fp != nil {
|
if userID == "" && fp != nil {
|
||||||
userID = fp.ClientID
|
userID = fp.ClientID
|
||||||
}
|
}
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
return ""
|
// Fall back to a random, well-formed client id so we can still satisfy
|
||||||
|
// Claude Code OAuth requirements when account metadata is incomplete.
|
||||||
|
userID = generateClientID()
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionHash := s.GenerateSessionHash(parsed)
|
sessionHash := s.GenerateSessionHash(parsed)
|
||||||
@@ -814,7 +812,14 @@ func (s *GatewayService) buildOAuthMetadataUserID(parsed *ParsedRequest, account
|
|||||||
seed := fmt.Sprintf("%d::%s", account.ID, sessionHash)
|
seed := fmt.Sprintf("%d::%s", account.ID, sessionHash)
|
||||||
sessionID = generateSessionUUID(seed)
|
sessionID = generateSessionUUID(seed)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("user_%s_account_%s_session_%s", userID, accountUUID, sessionID)
|
|
||||||
|
// Prefer the newer format that includes account_uuid (if present),
|
||||||
|
// otherwise fall back to the legacy Claude Code format.
|
||||||
|
accountUUID := strings.TrimSpace(account.GetExtraString("account_uuid"))
|
||||||
|
if accountUUID != "" {
|
||||||
|
return fmt.Sprintf("user_%s_account_%s_session_%s", userID, accountUUID, sessionID)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("user_%s_account__session_%s", userID, sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateSessionUUID(seed string) string {
|
func generateSessionUUID(seed string) string {
|
||||||
|
|||||||
Reference in New Issue
Block a user