Merge remote-tracking branch 'upstream/main' into feat/channel-insights
# Conflicts: # backend/cmd/server/wire_gen.go
This commit is contained in:
@@ -930,10 +930,8 @@ func (a *Account) SupportsOpenAIImageCapability(capability OpenAIImagesCapabilit
|
||||
return false
|
||||
}
|
||||
switch capability {
|
||||
case OpenAIImagesCapabilityBasic:
|
||||
case OpenAIImagesCapabilityBasic, OpenAIImagesCapabilityNative:
|
||||
return a.Type == AccountTypeOAuth || a.Type == AccountTypeAPIKey
|
||||
case OpenAIImagesCapabilityNative:
|
||||
return a.Type == AccountTypeAPIKey
|
||||
default:
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -1138,7 +1137,7 @@ func (s *AccountTestService) testOpenAIImageAPIKey(c *gin.Context, ctx context.C
|
||||
return nil
|
||||
}
|
||||
|
||||
// testOpenAIImageOAuth tests OpenAI image generation using an OAuth account via ChatGPT backend API.
|
||||
// testOpenAIImageOAuth tests OpenAI image generation using an OAuth account via Codex /responses API.
|
||||
func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Context, account *Account, modelID, prompt string) error {
|
||||
authToken := account.GetOpenAIAccessToken()
|
||||
if authToken == "" {
|
||||
@@ -1153,69 +1152,46 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
|
||||
c.Writer.Flush()
|
||||
|
||||
s.sendEvent(c, TestEvent{Type: "test_start", Model: modelID})
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: "Initializing ChatGPT backend...\n"})
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: "Calling Codex /responses image tool...\n"})
|
||||
|
||||
// Build headers (replicating buildOpenAIBackendAPIHeaders logic)
|
||||
headers := buildOpenAIBackendAPIHeadersForTest(ctx, account, authToken, s.accountRepo)
|
||||
parsed := &OpenAIImagesRequest{
|
||||
Endpoint: openAIImagesGenerationsEndpoint,
|
||||
Model: strings.TrimSpace(modelID),
|
||||
Prompt: prompt,
|
||||
}
|
||||
applyOpenAIImagesDefaults(parsed)
|
||||
|
||||
responsesBody, err := buildOpenAIImagesResponsesRequest(parsed, parsed.Model)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to build image request: %s", err.Error()))
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, chatgptCodexAPIURL, bytes.NewReader(responsesBody))
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, "Failed to create request")
|
||||
}
|
||||
req.Host = "chatgpt.com"
|
||||
req.Header.Set("Authorization", "Bearer "+authToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
req.Header.Set("OpenAI-Beta", "responses=experimental")
|
||||
req.Header.Set("originator", "opencode")
|
||||
if customUA := strings.TrimSpace(account.GetOpenAIUserAgent()); customUA != "" {
|
||||
req.Header.Set("User-Agent", customUA)
|
||||
} else {
|
||||
req.Header.Set("User-Agent", codexCLIUserAgent)
|
||||
}
|
||||
if chatgptAccountID := strings.TrimSpace(account.GetChatGPTAccountID()); chatgptAccountID != "" {
|
||||
req.Header.Set("chatgpt-account-id", chatgptAccountID)
|
||||
}
|
||||
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
client, err := newOpenAIBackendAPIClient(proxyURL)
|
||||
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to create client: %s", err.Error()))
|
||||
}
|
||||
|
||||
// Bootstrap
|
||||
if bootstrapErr := bootstrapOpenAIBackendAPI(ctx, client, headers); bootstrapErr != nil {
|
||||
log.Printf("OpenAI image test bootstrap warning: %v", bootstrapErr)
|
||||
}
|
||||
|
||||
// Fetch chat requirements
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: "Fetching chat requirements...\n"})
|
||||
chatReqs, err := fetchOpenAIChatRequirements(ctx, client, headers)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Chat requirements failed: %s", err.Error()))
|
||||
}
|
||||
if chatReqs.Arkose.Required {
|
||||
return s.sendErrorAndEnd(c, "Unsupported challenge: arkose required")
|
||||
}
|
||||
|
||||
// Initialize and prepare conversation
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: "Preparing image conversation...\n"})
|
||||
parentMessageID := uuid.NewString()
|
||||
proofToken := generateOpenAIProofToken(chatReqs.ProofOfWork.Required, chatReqs.ProofOfWork.Seed, chatReqs.ProofOfWork.Difficulty, headers.Get("User-Agent"))
|
||||
_ = initializeOpenAIImageConversation(ctx, client, headers)
|
||||
conduitToken, err := prepareOpenAIImageConversation(ctx, client, headers, prompt, parentMessageID, chatReqs.Token, proofToken)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Conversation prepare failed: %s", err.Error()))
|
||||
}
|
||||
|
||||
// Build simplified conversation request (no file uploads)
|
||||
convReq := buildOpenAIImageTestConversationRequest(prompt, parentMessageID)
|
||||
convHeaders := cloneHTTPHeader(headers)
|
||||
convHeaders.Set("Accept", "text/event-stream")
|
||||
convHeaders.Set("Content-Type", "application/json")
|
||||
convHeaders.Set("openai-sentinel-chat-requirements-token", chatReqs.Token)
|
||||
if conduitToken != "" {
|
||||
convHeaders.Set("x-conduit-token", conduitToken)
|
||||
}
|
||||
if proofToken != "" {
|
||||
convHeaders.Set("openai-sentinel-proof-token", proofToken)
|
||||
}
|
||||
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: "Generating image...\n"})
|
||||
|
||||
resp, err := client.R().
|
||||
SetContext(ctx).
|
||||
DisableAutoReadResponse().
|
||||
SetHeaders(headerToMap(convHeaders)).
|
||||
SetBodyJsonMarshal(convReq).
|
||||
Post(openAIChatGPTConversationURL)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Conversation request failed: %s", err.Error()))
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Responses API request failed: %s", err.Error()))
|
||||
}
|
||||
defer func() {
|
||||
if resp != nil && resp.Body != nil {
|
||||
@@ -1223,49 +1199,35 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
|
||||
}
|
||||
}()
|
||||
if resp.StatusCode >= 400 {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Conversation API returned %d", resp.StatusCode))
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||
message := strings.TrimSpace(extractUpstreamErrorMessage(body))
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("Responses API returned %d", resp.StatusCode)
|
||||
}
|
||||
return s.sendErrorAndEnd(c, message)
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
conversationID, pointerInfos, _, _, err := readOpenAIImageConversationStream(resp, startTime)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Stream read failed: %s", err.Error()))
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to read image response: %s", err.Error()))
|
||||
}
|
||||
|
||||
pointerInfos = mergeOpenAIImagePointerInfos(pointerInfos, nil)
|
||||
if conversationID != "" && !hasOpenAIFileServicePointerInfos(pointerInfos) {
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: "Waiting for image generation to complete...\n"})
|
||||
polledPointers, pollErr := pollOpenAIImageConversation(ctx, client, headers, conversationID)
|
||||
if pollErr != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Poll failed: %s", pollErr.Error()))
|
||||
}
|
||||
pointerInfos = mergeOpenAIImagePointerInfos(pointerInfos, polledPointers)
|
||||
results, _, _, _, _, err := collectOpenAIImagesFromResponsesBody(body)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to parse image response: %s", err.Error()))
|
||||
}
|
||||
pointerInfos = preferOpenAIFileServicePointerInfos(pointerInfos)
|
||||
if len(pointerInfos) == 0 {
|
||||
return s.sendErrorAndEnd(c, "No images returned from conversation")
|
||||
if len(results) == 0 {
|
||||
return s.sendErrorAndEnd(c, "No images returned from responses API")
|
||||
}
|
||||
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: "Downloading generated image...\n"})
|
||||
|
||||
// Download and encode each image
|
||||
for _, pointer := range pointerInfos {
|
||||
downloadURL, err := fetchOpenAIImageDownloadURL(ctx, client, headers, conversationID, pointer.Pointer)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Download URL fetch failed: %s", err.Error()))
|
||||
}
|
||||
data, err := downloadOpenAIImageBytes(ctx, client, headers, downloadURL)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Image download failed: %s", err.Error()))
|
||||
}
|
||||
b64 := base64.StdEncoding.EncodeToString(data)
|
||||
mimeType := http.DetectContentType(data)
|
||||
if pointer.Prompt != "" {
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: pointer.Prompt})
|
||||
for _, item := range results {
|
||||
if item.RevisedPrompt != "" {
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: item.RevisedPrompt})
|
||||
}
|
||||
mimeType := openAIImageOutputMIMEType(item.OutputFormat)
|
||||
s.sendEvent(c, TestEvent{
|
||||
Type: "image",
|
||||
ImageURL: "data:" + mimeType + ";base64," + b64,
|
||||
ImageURL: "data:" + mimeType + ";base64," + item.Result,
|
||||
MimeType: mimeType,
|
||||
})
|
||||
}
|
||||
@@ -1274,107 +1236,6 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildOpenAIBackendAPIHeadersForTest builds ChatGPT backend API headers for test purposes.
|
||||
// Replicates the logic from OpenAIGatewayService.buildOpenAIBackendAPIHeaders without
|
||||
// requiring the full gateway service dependency.
|
||||
func buildOpenAIBackendAPIHeadersForTest(ctx context.Context, account *Account, token string, repo AccountRepository) http.Header {
|
||||
// Ensure device and session IDs exist
|
||||
deviceID := account.GetOpenAIDeviceID()
|
||||
sessionID := account.GetOpenAISessionID()
|
||||
if deviceID == "" || sessionID == "" {
|
||||
updates := map[string]any{}
|
||||
if deviceID == "" {
|
||||
deviceID = uuid.NewString()
|
||||
updates["openai_device_id"] = deviceID
|
||||
}
|
||||
if sessionID == "" {
|
||||
sessionID = uuid.NewString()
|
||||
updates["openai_session_id"] = sessionID
|
||||
}
|
||||
if account.Extra == nil {
|
||||
account.Extra = map[string]any{}
|
||||
}
|
||||
for key, value := range updates {
|
||||
account.Extra[key] = value
|
||||
}
|
||||
if repo != nil {
|
||||
updateCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
_ = repo.UpdateExtra(updateCtx, account.ID, updates)
|
||||
}
|
||||
}
|
||||
|
||||
headers := make(http.Header)
|
||||
headers.Set("Authorization", "Bearer "+token)
|
||||
headers.Set("Accept", "application/json")
|
||||
headers.Set("Origin", "https://chatgpt.com")
|
||||
headers.Set("Referer", "https://chatgpt.com/")
|
||||
headers.Set("Sec-Fetch-Dest", "empty")
|
||||
headers.Set("Sec-Fetch-Mode", "cors")
|
||||
headers.Set("Sec-Fetch-Site", "same-origin")
|
||||
headers.Set("User-Agent", openAIImageBackendUserAgent)
|
||||
if customUA := strings.TrimSpace(account.GetOpenAIUserAgent()); customUA != "" {
|
||||
headers.Set("User-Agent", customUA)
|
||||
}
|
||||
if chatgptAccountID := strings.TrimSpace(account.GetChatGPTAccountID()); chatgptAccountID != "" {
|
||||
headers.Set("chatgpt-account-id", chatgptAccountID)
|
||||
}
|
||||
if deviceID != "" {
|
||||
headers.Set("oai-device-id", deviceID)
|
||||
headers.Set("Cookie", "oai-did="+deviceID)
|
||||
}
|
||||
if sessionID != "" {
|
||||
headers.Set("oai-session-id", sessionID)
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
// buildOpenAIImageTestConversationRequest creates a simplified image generation conversation request.
|
||||
func buildOpenAIImageTestConversationRequest(prompt, parentMessageID string) map[string]any {
|
||||
promptText := strings.TrimSpace(prompt)
|
||||
if promptText == "" {
|
||||
promptText = "Generate an image."
|
||||
}
|
||||
metadata := map[string]any{
|
||||
"developer_mode_connector_ids": []any{},
|
||||
"selected_github_repos": []any{},
|
||||
"selected_all_github_repos": false,
|
||||
"system_hints": []string{"picture_v2"},
|
||||
"serialization_metadata": map[string]any{
|
||||
"custom_symbol_offsets": []any{},
|
||||
},
|
||||
}
|
||||
message := map[string]any{
|
||||
"id": uuid.NewString(),
|
||||
"author": map[string]any{"role": "user"},
|
||||
"content": map[string]any{
|
||||
"content_type": "text",
|
||||
"parts": []any{promptText},
|
||||
},
|
||||
"metadata": metadata,
|
||||
"create_time": float64(time.Now().UnixMilli()) / 1000,
|
||||
}
|
||||
return map[string]any{
|
||||
"action": "next",
|
||||
"client_prepare_state": "sent",
|
||||
"parent_message_id": parentMessageID,
|
||||
"messages": []any{message},
|
||||
"model": "auto",
|
||||
"timezone_offset_min": openAITimezoneOffsetMinutes(),
|
||||
"timezone": openAITimezoneName(),
|
||||
"conversation_mode": map[string]any{"kind": "primary_assistant"},
|
||||
"system_hints": []string{"picture_v2"},
|
||||
"supports_buffering": true,
|
||||
"supported_encodings": []string{"v1"},
|
||||
"client_contextual_info": map[string]any{"app_name": "chatgpt.com"},
|
||||
"force_nulligen": false,
|
||||
"force_paragen": false,
|
||||
"force_paragen_model_slug": "",
|
||||
"force_rate_limit": false,
|
||||
"websocket_request_id": uuid.NewString(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AccountTestService) sendEvent(c *gin.Context, event TestEvent) {
|
||||
eventJSON, _ := json.Marshal(event)
|
||||
if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", eventJSON); err != nil {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAccountTestService_OpenAIImageOAuthHandlesOutputItemDoneFallback(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/1/test", nil)
|
||||
|
||||
upstream := &httpUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"text/event-stream"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
"data: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"ig_123\",\"type\":\"image_generation_call\",\"result\":\"aGVsbG8=\",\"revised_prompt\":\"draw a cat\",\"output_format\":\"png\"}}\n\n" +
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000006,\"tool_usage\":{\"image_gen\":{\"images\":1}},\"output\":[]}}\n\n" +
|
||||
"data: [DONE]\n\n",
|
||||
)),
|
||||
},
|
||||
}
|
||||
svc := &AccountTestService{httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 53,
|
||||
Name: "openai-oauth",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token-123",
|
||||
},
|
||||
}
|
||||
|
||||
err := svc.testOpenAIImageOAuth(c, context.Background(), account, "gpt-image-2", "draw a cat")
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, rec.Body.String(), "Calling Codex /responses image tool")
|
||||
require.Contains(t, rec.Body.String(), "data:image/png;base64,aGVsbG8=")
|
||||
require.Contains(t, rec.Body.String(), "\"success\":true")
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -32,6 +33,7 @@ type AdminService interface {
|
||||
UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error)
|
||||
GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]APIKey, int64, error)
|
||||
GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error)
|
||||
GetUserRPMStatus(ctx context.Context, userID int64) (*UserRPMStatus, error)
|
||||
// GetUserBalanceHistory returns paginated balance/concurrency change records for a user.
|
||||
// codeType is optional - pass empty string to return all types.
|
||||
// Also returns totalRecharged (sum of all positive balance top-ups).
|
||||
@@ -50,6 +52,8 @@ type AdminService interface {
|
||||
GetGroupRateMultipliers(ctx context.Context, groupID int64) ([]UserGroupRateEntry, error)
|
||||
ClearGroupRateMultipliers(ctx context.Context, groupID int64) error
|
||||
BatchSetGroupRateMultipliers(ctx context.Context, groupID int64, entries []GroupRateMultiplierInput) error
|
||||
ClearGroupRPMOverrides(ctx context.Context, groupID int64) error
|
||||
BatchSetGroupRPMOverrides(ctx context.Context, groupID int64, entries []GroupRPMOverrideInput) error
|
||||
UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error
|
||||
|
||||
// API Key management (admin)
|
||||
@@ -114,6 +118,7 @@ type CreateUserInput struct {
|
||||
Notes string
|
||||
Balance float64
|
||||
Concurrency int
|
||||
RPMLimit int
|
||||
AllowedGroups []int64
|
||||
}
|
||||
|
||||
@@ -124,6 +129,7 @@ type UpdateUserInput struct {
|
||||
Notes *string
|
||||
Balance *float64 // 使用指针区分"未提供"和"设置为0"
|
||||
Concurrency *int // 使用指针区分"未提供"和"设置为0"
|
||||
RPMLimit *int // 使用指针区分"未提供"和"设置为0"
|
||||
Status string
|
||||
AllowedGroups *[]int64 // 使用指针区分"未提供"和"设置为空数组"
|
||||
// GroupRates 用户专属分组倍率配置
|
||||
@@ -199,6 +205,8 @@ type CreateGroupInput struct {
|
||||
RequireOAuthOnly bool
|
||||
RequirePrivacySet bool
|
||||
MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig
|
||||
// RPMLimit 分组 RPM 上限(0 = 不限制)
|
||||
RPMLimit int
|
||||
// 从指定分组复制账号(创建分组后在同一事务内绑定)
|
||||
CopyAccountsFromGroupIDs []int64
|
||||
}
|
||||
@@ -234,6 +242,8 @@ type UpdateGroupInput struct {
|
||||
RequireOAuthOnly *bool
|
||||
RequirePrivacySet *bool
|
||||
MessagesDispatchModelConfig *OpenAIMessagesDispatchModelConfig
|
||||
// RPMLimit 分组 RPM 上限(0 = 不限制),nil 表示未提供不改动。
|
||||
RPMLimit *int
|
||||
// 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号)
|
||||
CopyAccountsFromGroupIDs []int64
|
||||
}
|
||||
@@ -317,6 +327,22 @@ type ReplaceUserGroupResult struct {
|
||||
MigratedKeys int64 // 迁移的 Key 数量
|
||||
}
|
||||
|
||||
// UserRPMStatus describes a user's current per-minute RPM usage.
|
||||
type UserRPMStatus struct {
|
||||
UserRPMUsed int `json:"user_rpm_used"`
|
||||
UserRPMLimit int `json:"user_rpm_limit"`
|
||||
PerGroup []UserGroupRPMStatus `json:"per_group"`
|
||||
}
|
||||
|
||||
// UserGroupRPMStatus describes current per-minute RPM usage for one user/group pair.
|
||||
type UserGroupRPMStatus struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
GroupName string `json:"group_name"`
|
||||
Used int `json:"used"`
|
||||
Limit int `json:"limit"`
|
||||
Source string `json:"source"` // "group" | "override"
|
||||
}
|
||||
|
||||
// BulkUpdateAccountsResult is the aggregated response for bulk updates.
|
||||
type BulkUpdateAccountsResult struct {
|
||||
Success int `json:"success"`
|
||||
@@ -463,6 +489,8 @@ const (
|
||||
proxyQualityClientUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
var ErrRPMStatusUnavailable = infraerrors.New(http.StatusNotImplemented, "RPM_STATUS_UNAVAILABLE", "RPM cache not available")
|
||||
|
||||
// adminServiceImpl implements AdminService
|
||||
type adminServiceImpl struct {
|
||||
userRepo UserRepository
|
||||
@@ -472,6 +500,7 @@ type adminServiceImpl struct {
|
||||
apiKeyRepo APIKeyRepository
|
||||
redeemCodeRepo RedeemCodeRepository
|
||||
userGroupRateRepo UserGroupRateRepository
|
||||
userRPMCache UserRPMCache
|
||||
billingCacheService *BillingCacheService
|
||||
proxyProber ProxyExitInfoProber
|
||||
proxyLatencyCache ProxyLatencyCache
|
||||
@@ -496,6 +525,7 @@ func NewAdminService(
|
||||
apiKeyRepo APIKeyRepository,
|
||||
redeemCodeRepo RedeemCodeRepository,
|
||||
userGroupRateRepo UserGroupRateRepository,
|
||||
userRPMCache UserRPMCache,
|
||||
billingCacheService *BillingCacheService,
|
||||
proxyProber ProxyExitInfoProber,
|
||||
proxyLatencyCache ProxyLatencyCache,
|
||||
@@ -514,6 +544,7 @@ func NewAdminService(
|
||||
apiKeyRepo: apiKeyRepo,
|
||||
redeemCodeRepo: redeemCodeRepo,
|
||||
userGroupRateRepo: userGroupRateRepo,
|
||||
userRPMCache: userRPMCache,
|
||||
billingCacheService: billingCacheService,
|
||||
proxyProber: proxyProber,
|
||||
proxyLatencyCache: proxyLatencyCache,
|
||||
@@ -617,6 +648,7 @@ func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInpu
|
||||
Role: RoleUser, // Always create as regular user, never admin
|
||||
Balance: input.Balance,
|
||||
Concurrency: input.Concurrency,
|
||||
RPMLimit: input.RPMLimit,
|
||||
Status: StatusActive,
|
||||
AllowedGroups: input.AllowedGroups,
|
||||
}
|
||||
@@ -670,6 +702,7 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
|
||||
oldConcurrency := user.Concurrency
|
||||
oldStatus := user.Status
|
||||
oldRole := user.Role
|
||||
oldRPMLimit := user.RPMLimit
|
||||
|
||||
if input.Email != "" {
|
||||
user.Email = input.Email
|
||||
@@ -695,6 +728,10 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
|
||||
user.Concurrency = *input.Concurrency
|
||||
}
|
||||
|
||||
if input.RPMLimit != nil {
|
||||
user.RPMLimit = *input.RPMLimit
|
||||
}
|
||||
|
||||
if input.AllowedGroups != nil {
|
||||
user.AllowedGroups = *input.AllowedGroups
|
||||
}
|
||||
@@ -711,7 +748,9 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
|
||||
}
|
||||
|
||||
if s.authCacheInvalidator != nil {
|
||||
if user.Concurrency != oldConcurrency || user.Status != oldStatus || user.Role != oldRole {
|
||||
// RPMLimit 直接参与 billing_cache_service.checkRPM 的三级级联,
|
||||
// 不失效缓存会让修改在一个 L2 TTL 内失去效果。
|
||||
if user.Concurrency != oldConcurrency || user.Status != oldStatus || user.Role != oldRole || user.RPMLimit != oldRPMLimit {
|
||||
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, user.ID)
|
||||
}
|
||||
}
|
||||
@@ -833,6 +872,81 @@ func (s *adminServiceImpl) GetUserAPIKeys(ctx context.Context, userID int64, pag
|
||||
return keys, result.Total, nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) GetUserRPMStatus(ctx context.Context, userID int64) (*UserRPMStatus, error) {
|
||||
if s.userRPMCache == nil {
|
||||
return nil, ErrRPMStatusUnavailable
|
||||
}
|
||||
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userRPMUsed, err := s.userRPMCache.GetUserRPM(ctx, userID)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.admin", "failed to get user rpm: user_id=%d err=%v", userID, err)
|
||||
}
|
||||
|
||||
keys, _, err := s.GetUserAPIKeys(ctx, userID, 1, 1000, "", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
groupIDSet := make(map[int64]struct{})
|
||||
for _, key := range keys {
|
||||
if key.GroupID != nil && *key.GroupID > 0 {
|
||||
groupIDSet[*key.GroupID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
groupIDs := make([]int64, 0, len(groupIDSet))
|
||||
for groupID := range groupIDSet {
|
||||
groupIDs = append(groupIDs, groupID)
|
||||
}
|
||||
sort.Slice(groupIDs, func(i, j int) bool { return groupIDs[i] < groupIDs[j] })
|
||||
|
||||
var perGroup []UserGroupRPMStatus
|
||||
for _, groupID := range groupIDs {
|
||||
used, getErr := s.userRPMCache.GetUserGroupRPM(ctx, userID, groupID)
|
||||
if getErr != nil {
|
||||
logger.LegacyPrintf("service.admin", "failed to get user group rpm: user_id=%d group_id=%d err=%v", userID, groupID, getErr)
|
||||
}
|
||||
|
||||
entry := UserGroupRPMStatus{
|
||||
GroupID: groupID,
|
||||
Used: used,
|
||||
}
|
||||
|
||||
if s.groupRepo != nil {
|
||||
if group, groupErr := s.groupRepo.GetByIDLite(ctx, groupID); groupErr == nil && group != nil {
|
||||
entry.GroupName = group.Name
|
||||
entry.Limit = group.RPMLimit
|
||||
entry.Source = "group"
|
||||
} else if groupErr != nil {
|
||||
logger.LegacyPrintf("service.admin", "failed to get group rpm status metadata: group_id=%d err=%v", groupID, groupErr)
|
||||
}
|
||||
}
|
||||
|
||||
if s.userGroupRateRepo != nil {
|
||||
override, overrideErr := s.userGroupRateRepo.GetRPMOverrideByUserAndGroup(ctx, userID, groupID)
|
||||
if overrideErr != nil {
|
||||
logger.LegacyPrintf("service.admin", "failed to get rpm override: user_id=%d group_id=%d err=%v", userID, groupID, overrideErr)
|
||||
} else if override != nil {
|
||||
entry.Limit = *override
|
||||
entry.Source = "override"
|
||||
}
|
||||
}
|
||||
|
||||
perGroup = append(perGroup, entry)
|
||||
}
|
||||
|
||||
return &UserRPMStatus{
|
||||
UserRPMUsed: userRPMUsed,
|
||||
UserRPMLimit: user.RPMLimit,
|
||||
PerGroup: perGroup,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error) {
|
||||
// Return mock data for now
|
||||
return map[string]any{
|
||||
@@ -1314,6 +1428,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
|
||||
RequirePrivacySet: input.RequirePrivacySet,
|
||||
DefaultMappedModel: input.DefaultMappedModel,
|
||||
MessagesDispatchModelConfig: normalizeOpenAIMessagesDispatchModelConfig(input.MessagesDispatchModelConfig),
|
||||
RPMLimit: input.RPMLimit,
|
||||
}
|
||||
sanitizeGroupMessagesDispatchFields(group)
|
||||
if err := s.groupRepo.Create(ctx, group); err != nil {
|
||||
@@ -1548,12 +1663,19 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
|
||||
if input.MessagesDispatchModelConfig != nil {
|
||||
group.MessagesDispatchModelConfig = normalizeOpenAIMessagesDispatchModelConfig(*input.MessagesDispatchModelConfig)
|
||||
}
|
||||
if input.RPMLimit != nil {
|
||||
group.RPMLimit = *input.RPMLimit
|
||||
}
|
||||
sanitizeGroupMessagesDispatchFields(group)
|
||||
|
||||
if err := s.groupRepo.Update(ctx, group); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.authCacheInvalidator != nil {
|
||||
s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, id)
|
||||
}
|
||||
|
||||
// 如果指定了复制账号的源分组,同步绑定(替换当前分组的账号)
|
||||
if len(input.CopyAccountsFromGroupIDs) > 0 {
|
||||
// 去重源分组 IDs
|
||||
@@ -1622,9 +1744,6 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
|
||||
}
|
||||
}
|
||||
|
||||
if s.authCacheInvalidator != nil {
|
||||
s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, id)
|
||||
}
|
||||
return group, nil
|
||||
}
|
||||
|
||||
@@ -1700,6 +1819,39 @@ func (s *adminServiceImpl) BatchSetGroupRateMultipliers(ctx context.Context, gro
|
||||
return s.userGroupRateRepo.SyncGroupRateMultipliers(ctx, groupID, entries)
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) ClearGroupRPMOverrides(ctx context.Context, groupID int64) error {
|
||||
if s.userGroupRateRepo == nil {
|
||||
return nil
|
||||
}
|
||||
if err := s.userGroupRateRepo.ClearGroupRPMOverrides(ctx, groupID); err != nil {
|
||||
return err
|
||||
}
|
||||
// RPM override 已嵌入 auth cache snapshot (v7),变更后必须失效相关缓存。
|
||||
if s.authCacheInvalidator != nil {
|
||||
s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, groupID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) BatchSetGroupRPMOverrides(ctx context.Context, groupID int64, entries []GroupRPMOverrideInput) error {
|
||||
if s.userGroupRateRepo == nil {
|
||||
return nil
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.RPMOverride != nil && *e.RPMOverride < 0 {
|
||||
return infraerrors.BadRequest("INVALID_RPM_OVERRIDE", fmt.Sprintf("rpm_override must be >= 0 (user_id=%d)", e.UserID))
|
||||
}
|
||||
}
|
||||
if err := s.userGroupRateRepo.SyncGroupRPMOverrides(ctx, groupID, entries); err != nil {
|
||||
return err
|
||||
}
|
||||
// RPM override 已嵌入 auth cache snapshot (v7),变更后必须失效相关缓存。
|
||||
if s.authCacheInvalidator != nil {
|
||||
s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, groupID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error {
|
||||
return s.groupRepo.UpdateSortOrders(ctx, updates)
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -21,6 +23,10 @@ type userGroupRateRepoStubForGroupRate struct {
|
||||
syncedGroupID int64
|
||||
syncedEntries []GroupRateMultiplierInput
|
||||
syncGroupErr error
|
||||
|
||||
rpmSyncedGroupID int64
|
||||
rpmSyncedEntries []GroupRPMOverrideInput
|
||||
rpmSyncErr error
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForGroupRate) GetByUserID(_ context.Context, _ int64) (map[int64]float64, error) {
|
||||
@@ -31,6 +37,10 @@ func (s *userGroupRateRepoStubForGroupRate) GetByUserAndGroup(_ context.Context,
|
||||
panic("unexpected GetByUserAndGroup call")
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForGroupRate) GetRPMOverrideByUserAndGroup(_ context.Context, _, _ int64) (*int, error) {
|
||||
panic("unexpected GetRPMOverrideByUserAndGroup call")
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForGroupRate) GetByGroupID(_ context.Context, groupID int64) ([]UserGroupRateEntry, error) {
|
||||
if s.getByGroupIDErr != nil {
|
||||
return nil, s.getByGroupIDErr
|
||||
@@ -48,6 +58,16 @@ func (s *userGroupRateRepoStubForGroupRate) SyncGroupRateMultipliers(_ context.C
|
||||
return s.syncGroupErr
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForGroupRate) SyncGroupRPMOverrides(_ context.Context, groupID int64, entries []GroupRPMOverrideInput) error {
|
||||
s.rpmSyncedGroupID = groupID
|
||||
s.rpmSyncedEntries = entries
|
||||
return s.rpmSyncErr
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForGroupRate) ClearGroupRPMOverrides(_ context.Context, _ int64) error {
|
||||
panic("unexpected ClearGroupRPMOverrides call")
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForGroupRate) DeleteByGroupID(_ context.Context, groupID int64) error {
|
||||
s.deletedGroupIDs = append(s.deletedGroupIDs, groupID)
|
||||
return s.deleteByGroupErr
|
||||
@@ -62,8 +82,8 @@ func TestAdminService_GetGroupRateMultipliers(t *testing.T) {
|
||||
repo := &userGroupRateRepoStubForGroupRate{
|
||||
getByGroupIDData: map[int64][]UserGroupRateEntry{
|
||||
10: {
|
||||
{UserID: 1, UserName: "alice", UserEmail: "alice@test.com", RateMultiplier: 1.5},
|
||||
{UserID: 2, UserName: "bob", UserEmail: "bob@test.com", RateMultiplier: 0.8},
|
||||
{UserID: 1, UserName: "alice", UserEmail: "alice@test.com", RateMultiplier: ptrFloat(1.5)},
|
||||
{UserID: 2, UserName: "bob", UserEmail: "bob@test.com", RateMultiplier: ptrFloat(0.8)},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -74,9 +94,11 @@ func TestAdminService_GetGroupRateMultipliers(t *testing.T) {
|
||||
require.Len(t, entries, 2)
|
||||
require.Equal(t, int64(1), entries[0].UserID)
|
||||
require.Equal(t, "alice", entries[0].UserName)
|
||||
require.Equal(t, 1.5, entries[0].RateMultiplier)
|
||||
require.NotNil(t, entries[0].RateMultiplier)
|
||||
require.Equal(t, 1.5, *entries[0].RateMultiplier)
|
||||
require.Equal(t, int64(2), entries[1].UserID)
|
||||
require.Equal(t, 0.8, entries[1].RateMultiplier)
|
||||
require.NotNil(t, entries[1].RateMultiplier)
|
||||
require.Equal(t, 0.8, *entries[1].RateMultiplier)
|
||||
})
|
||||
|
||||
t.Run("returns nil when repo is nil", func(t *testing.T) {
|
||||
@@ -174,3 +196,30 @@ func TestAdminService_BatchSetGroupRateMultipliers(t *testing.T) {
|
||||
require.Contains(t, err.Error(), "sync failed")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAdminService_BatchSetGroupRPMOverrides(t *testing.T) {
|
||||
t.Run("syncs entries to repo", func(t *testing.T) {
|
||||
repo := &userGroupRateRepoStubForGroupRate{}
|
||||
svc := &adminServiceImpl{userGroupRateRepo: repo}
|
||||
override := 20
|
||||
entries := []GroupRPMOverrideInput{{UserID: 2, RPMOverride: &override}}
|
||||
|
||||
err := svc.BatchSetGroupRPMOverrides(context.Background(), 10, entries)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(10), repo.rpmSyncedGroupID)
|
||||
require.Equal(t, entries, repo.rpmSyncedEntries)
|
||||
})
|
||||
|
||||
t.Run("rejects negative override as bad request", func(t *testing.T) {
|
||||
repo := &userGroupRateRepoStubForGroupRate{}
|
||||
svc := &adminServiceImpl{userGroupRateRepo: repo}
|
||||
negative := -1
|
||||
|
||||
err := svc.BatchSetGroupRPMOverrides(context.Background(), 10, []GroupRPMOverrideInput{
|
||||
{UserID: 2, RPMOverride: &negative},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, infraerrors.Code(err))
|
||||
require.Zero(t, repo.rpmSyncedGroupID)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -266,6 +266,31 @@ func TestAdminService_UpdateGroup_PartialImagePricing(t *testing.T) {
|
||||
require.Nil(t, repo.updated.ImagePrice4K)
|
||||
}
|
||||
|
||||
func TestAdminService_UpdateGroup_InvalidatesAuthCacheOnRPMLimitChange(t *testing.T) {
|
||||
existingGroup := &Group{
|
||||
ID: 1,
|
||||
Name: "existing-group",
|
||||
Platform: PlatformAnthropic,
|
||||
Status: StatusActive,
|
||||
RPMLimit: 10,
|
||||
}
|
||||
repo := &groupRepoStubForAdmin{getByID: existingGroup}
|
||||
invalidator := &authCacheInvalidatorStub{}
|
||||
svc := &adminServiceImpl{
|
||||
groupRepo: repo,
|
||||
authCacheInvalidator: invalidator,
|
||||
}
|
||||
|
||||
rpmLimit := 60
|
||||
group, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{
|
||||
RPMLimit: &rpmLimit,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, group)
|
||||
require.Equal(t, 60, repo.updated.RPMLimit)
|
||||
require.Equal(t, []int64{1}, invalidator.groupIDs, "分组 RPMLimit 写入 auth snapshot,变更后必须失效 API Key 认证缓存")
|
||||
}
|
||||
|
||||
func TestAdminService_CreateGroup_NormalizesMessagesDispatchModelConfig(t *testing.T) {
|
||||
repo := &groupRepoStubForAdmin{}
|
||||
svc := &adminServiceImpl{groupRepo: repo}
|
||||
|
||||
@@ -89,6 +89,10 @@ func (s *userGroupRateRepoStubForListUsers) GetByUserAndGroup(_ context.Context,
|
||||
panic("unexpected GetByUserAndGroup call")
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForListUsers) GetRPMOverrideByUserAndGroup(_ context.Context, _, _ int64) (*int, error) {
|
||||
panic("unexpected GetRPMOverrideByUserAndGroup call")
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForListUsers) SyncUserGroupRates(_ context.Context, userID int64, rates map[int64]*float64) error {
|
||||
panic("unexpected SyncUserGroupRates call")
|
||||
}
|
||||
@@ -101,6 +105,14 @@ func (s *userGroupRateRepoStubForListUsers) SyncGroupRateMultipliers(_ context.C
|
||||
panic("unexpected SyncGroupRateMultipliers call")
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForListUsers) SyncGroupRPMOverrides(_ context.Context, _ int64, _ []GroupRPMOverrideInput) error {
|
||||
panic("unexpected SyncGroupRPMOverrides call")
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForListUsers) ClearGroupRPMOverrides(_ context.Context, _ int64) error {
|
||||
panic("unexpected ClearGroupRPMOverrides call")
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForListUsers) DeleteByGroupID(_ context.Context, _ int64) error {
|
||||
panic("unexpected DeleteByGroupID call")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type rpmStatusUserRepoStub struct {
|
||||
UserRepository
|
||||
user *User
|
||||
}
|
||||
|
||||
func (s *rpmStatusUserRepoStub) GetByID(_ context.Context, _ int64) (*User, error) {
|
||||
return s.user, nil
|
||||
}
|
||||
|
||||
type rpmStatusAPIKeyRepoStub struct {
|
||||
APIKeyRepository
|
||||
keys []APIKey
|
||||
}
|
||||
|
||||
func (s *rpmStatusAPIKeyRepoStub) ListByUserID(_ context.Context, _ int64, _ pagination.PaginationParams, _ APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) {
|
||||
return s.keys, &pagination.PaginationResult{Total: int64(len(s.keys))}, nil
|
||||
}
|
||||
|
||||
type rpmStatusGroupRepoStub struct {
|
||||
GroupRepository
|
||||
groups map[int64]*Group
|
||||
}
|
||||
|
||||
func (s *rpmStatusGroupRepoStub) GetByIDLite(_ context.Context, id int64) (*Group, error) {
|
||||
return s.groups[id], nil
|
||||
}
|
||||
|
||||
type rpmStatusRateRepoStub struct {
|
||||
UserGroupRateRepository
|
||||
overrides map[int64]*int
|
||||
}
|
||||
|
||||
func (s *rpmStatusRateRepoStub) GetRPMOverrideByUserAndGroup(_ context.Context, _, groupID int64) (*int, error) {
|
||||
return s.overrides[groupID], nil
|
||||
}
|
||||
|
||||
type rpmStatusCacheStub struct {
|
||||
UserRPMCache
|
||||
userUsed int
|
||||
groupUsed map[int64]int
|
||||
}
|
||||
|
||||
func (s *rpmStatusCacheStub) IncrementUserGroupRPM(context.Context, int64, int64) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *rpmStatusCacheStub) IncrementUserRPM(context.Context, int64) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *rpmStatusCacheStub) GetUserGroupRPM(_ context.Context, _, groupID int64) (int, error) {
|
||||
return s.groupUsed[groupID], nil
|
||||
}
|
||||
|
||||
func (s *rpmStatusCacheStub) GetUserRPM(context.Context, int64) (int, error) {
|
||||
return s.userUsed, nil
|
||||
}
|
||||
|
||||
func TestAdminService_GetUserRPMStatus_AggregatesUserAndGroupLimits(t *testing.T) {
|
||||
groupOneID := int64(1)
|
||||
groupTwoID := int64(2)
|
||||
override := 7
|
||||
svc := &adminServiceImpl{
|
||||
userRepo: &rpmStatusUserRepoStub{user: &User{
|
||||
ID: 42,
|
||||
RPMLimit: 20,
|
||||
}},
|
||||
apiKeyRepo: &rpmStatusAPIKeyRepoStub{keys: []APIKey{
|
||||
{ID: 100, UserID: 42, GroupID: &groupTwoID},
|
||||
{ID: 101, UserID: 42, GroupID: &groupOneID},
|
||||
{ID: 102, UserID: 42, GroupID: &groupTwoID},
|
||||
{ID: 103, UserID: 42},
|
||||
}},
|
||||
groupRepo: &rpmStatusGroupRepoStub{groups: map[int64]*Group{
|
||||
groupOneID: {ID: groupOneID, Name: "group-one", RPMLimit: 10},
|
||||
groupTwoID: {ID: groupTwoID, Name: "group-two", RPMLimit: 60},
|
||||
}},
|
||||
userGroupRateRepo: &rpmStatusRateRepoStub{overrides: map[int64]*int{
|
||||
groupTwoID: &override,
|
||||
}},
|
||||
userRPMCache: &rpmStatusCacheStub{
|
||||
userUsed: 5,
|
||||
groupUsed: map[int64]int{
|
||||
groupOneID: 3,
|
||||
groupTwoID: 4,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
status, err := svc.GetUserRPMStatus(context.Background(), 42)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, &UserRPMStatus{
|
||||
UserRPMUsed: 5,
|
||||
UserRPMLimit: 20,
|
||||
PerGroup: []UserGroupRPMStatus{
|
||||
{GroupID: groupOneID, GroupName: "group-one", Used: 3, Limit: 10, Source: "group"},
|
||||
{GroupID: groupTwoID, GroupName: "group-two", Used: 4, Limit: 7, Source: "override"},
|
||||
},
|
||||
}, status)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// rpmUserRepoStub 复用 admin_service_update_balance_test.go 的基础 stub 结构,
|
||||
// 只在 Update 时把入参克隆一份,便于断言修改后的 RPMLimit。
|
||||
type rpmUserRepoStub struct {
|
||||
*userRepoStub
|
||||
lastUpdated *User
|
||||
}
|
||||
|
||||
func (s *rpmUserRepoStub) Update(_ context.Context, user *User) error {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *user
|
||||
s.lastUpdated = &clone
|
||||
if s.userRepoStub != nil {
|
||||
s.userRepoStub.user = &clone
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAdminService_UpdateUser_InvalidatesAuthCacheOnRPMLimitChange(t *testing.T) {
|
||||
base := &userRepoStub{user: &User{ID: 42, Email: "u@example.com", RPMLimit: 10}}
|
||||
repo := &rpmUserRepoStub{userRepoStub: base}
|
||||
invalidator := &authCacheInvalidatorStub{}
|
||||
svc := &adminServiceImpl{
|
||||
userRepo: repo,
|
||||
redeemCodeRepo: &redeemRepoStub{},
|
||||
authCacheInvalidator: invalidator,
|
||||
}
|
||||
|
||||
newRPM := 60
|
||||
updated, err := svc.UpdateUser(context.Background(), 42, &UpdateUserInput{
|
||||
RPMLimit: &newRPM,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updated)
|
||||
require.Equal(t, 60, updated.RPMLimit)
|
||||
require.Equal(t, []int64{42}, invalidator.userIDs, "仅修改 RPMLimit 也应失效 API Key 认证缓存")
|
||||
}
|
||||
|
||||
func TestAdminService_UpdateUser_NoInvalidateWhenRPMLimitUnchanged(t *testing.T) {
|
||||
base := &userRepoStub{user: &User{ID: 42, Email: "u@example.com", RPMLimit: 10, Username: "old"}}
|
||||
repo := &rpmUserRepoStub{userRepoStub: base}
|
||||
invalidator := &authCacheInvalidatorStub{}
|
||||
svc := &adminServiceImpl{
|
||||
userRepo: repo,
|
||||
redeemCodeRepo: &redeemRepoStub{},
|
||||
authCacheInvalidator: invalidator,
|
||||
}
|
||||
|
||||
newName := "new"
|
||||
sameRPM := 10
|
||||
_, err := svc.UpdateUser(context.Background(), 42, &UpdateUserInput{
|
||||
Username: &newName,
|
||||
RPMLimit: &sameRPM,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, invalidator.userIDs, "只改 username 不应触发认证缓存失效")
|
||||
}
|
||||
@@ -43,6 +43,13 @@ type APIKeyAuthUserSnapshot struct {
|
||||
BalanceNotifyThreshold *float64 `json:"balance_notify_threshold,omitempty"`
|
||||
BalanceNotifyExtraEmails []NotifyEmailEntry `json:"balance_notify_extra_emails,omitempty"`
|
||||
TotalRecharged float64 `json:"total_recharged"`
|
||||
|
||||
// RPMLimit 用户级每分钟请求数上限(0 = 不限制);用于 billing_cache_service.checkRPM 兜底判断。
|
||||
RPMLimit int `json:"rpm_limit"`
|
||||
|
||||
// UserGroupRPMOverride 该 API Key 对应的 (user, group) 专属 RPM 覆盖值。
|
||||
// nil = 无 override(回退到 group/user 级);0 = 不限流;>0 = 专属上限。
|
||||
UserGroupRPMOverride *int `json:"user_group_rpm_override,omitempty"`
|
||||
}
|
||||
|
||||
// APIKeyAuthGroupSnapshot 分组快照
|
||||
@@ -76,6 +83,9 @@ type APIKeyAuthGroupSnapshot struct {
|
||||
AllowMessagesDispatch bool `json:"allow_messages_dispatch"`
|
||||
DefaultMappedModel string `json:"default_mapped_model,omitempty"`
|
||||
MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"`
|
||||
|
||||
// RPMLimit 分组级每分钟请求数上限(0 = 不限制);用于 billing_cache_service.checkRPM 级联判断。
|
||||
RPMLimit int `json:"rpm_limit"`
|
||||
}
|
||||
|
||||
// APIKeyAuthCacheEntry 缓存条目,支持负缓存
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/dgraph-io/ristretto"
|
||||
)
|
||||
|
||||
const apiKeyAuthSnapshotVersion = 5 // v5: added TotalRecharged for percentage threshold
|
||||
const apiKeyAuthSnapshotVersion = 7 // v7: added UserGroupRPMOverride on user snapshot
|
||||
|
||||
type apiKeyAuthCacheConfig struct {
|
||||
l1Size int
|
||||
@@ -176,7 +176,7 @@ func (s *APIKeyService) loadAuthCacheEntry(ctx context.Context, key, cacheKey st
|
||||
return nil, fmt.Errorf("get api key: %w", err)
|
||||
}
|
||||
apiKey.Key = key
|
||||
snapshot := s.snapshotFromAPIKey(apiKey)
|
||||
snapshot := s.snapshotFromAPIKey(ctx, apiKey)
|
||||
if snapshot == nil {
|
||||
return nil, fmt.Errorf("get api key: %w", ErrAPIKeyNotFound)
|
||||
}
|
||||
@@ -201,7 +201,7 @@ func (s *APIKeyService) applyAuthCacheEntry(key string, entry *APIKeyAuthCacheEn
|
||||
return s.snapshotToAPIKey(key, entry.Snapshot), true, nil
|
||||
}
|
||||
|
||||
func (s *APIKeyService) snapshotFromAPIKey(apiKey *APIKey) *APIKeyAuthSnapshot {
|
||||
func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) *APIKeyAuthSnapshot {
|
||||
if apiKey == nil || apiKey.User == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -232,8 +232,18 @@ func (s *APIKeyService) snapshotFromAPIKey(apiKey *APIKey) *APIKeyAuthSnapshot {
|
||||
BalanceNotifyThreshold: apiKey.User.BalanceNotifyThreshold,
|
||||
BalanceNotifyExtraEmails: apiKey.User.BalanceNotifyExtraEmails,
|
||||
TotalRecharged: apiKey.User.TotalRecharged,
|
||||
RPMLimit: apiKey.User.RPMLimit,
|
||||
},
|
||||
}
|
||||
|
||||
// 填充 (user, group) RPM override —— snapshot 构建时查一次 DB,后续请求零 DB 往返。
|
||||
if apiKey.GroupID != nil && *apiKey.GroupID > 0 && s.userGroupRateRepo != nil {
|
||||
override, err := s.userGroupRateRepo.GetRPMOverrideByUserAndGroup(ctx, apiKey.UserID, *apiKey.GroupID)
|
||||
if err == nil && override != nil {
|
||||
snapshot.User.UserGroupRPMOverride = override
|
||||
}
|
||||
// 查询失败或无 override 时留 nil,checkRPM 会回退到 DB 查询
|
||||
}
|
||||
if apiKey.Group != nil {
|
||||
snapshot.Group = &APIKeyAuthGroupSnapshot{
|
||||
ID: apiKey.Group.ID,
|
||||
@@ -258,6 +268,7 @@ func (s *APIKeyService) snapshotFromAPIKey(apiKey *APIKey) *APIKeyAuthSnapshot {
|
||||
AllowMessagesDispatch: apiKey.Group.AllowMessagesDispatch,
|
||||
DefaultMappedModel: apiKey.Group.DefaultMappedModel,
|
||||
MessagesDispatchModelConfig: apiKey.Group.MessagesDispatchModelConfig,
|
||||
RPMLimit: apiKey.Group.RPMLimit,
|
||||
}
|
||||
}
|
||||
return snapshot
|
||||
@@ -294,6 +305,8 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
|
||||
BalanceNotifyThreshold: snapshot.User.BalanceNotifyThreshold,
|
||||
BalanceNotifyExtraEmails: snapshot.User.BalanceNotifyExtraEmails,
|
||||
TotalRecharged: snapshot.User.TotalRecharged,
|
||||
RPMLimit: snapshot.User.RPMLimit,
|
||||
UserGroupRPMOverride: snapshot.User.UserGroupRPMOverride,
|
||||
},
|
||||
}
|
||||
if snapshot.Group != nil {
|
||||
@@ -321,6 +334,7 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
|
||||
AllowMessagesDispatch: snapshot.Group.AllowMessagesDispatch,
|
||||
DefaultMappedModel: snapshot.Group.DefaultMappedModel,
|
||||
MessagesDispatchModelConfig: snapshot.Group.MessagesDispatchModelConfig,
|
||||
RPMLimit: snapshot.Group.RPMLimit,
|
||||
}
|
||||
}
|
||||
s.compileAPIKeyIPRules(apiKey)
|
||||
|
||||
@@ -263,7 +263,7 @@ func TestAPIKeyService_SnapshotRoundTrip_PreservesMessagesDispatchModelConfig(t
|
||||
},
|
||||
}
|
||||
|
||||
snapshot := svc.snapshotFromAPIKey(apiKey)
|
||||
snapshot := svc.snapshotFromAPIKey(context.Background(), apiKey)
|
||||
roundTrip := svc.snapshotToAPIKey(apiKey.Key, snapshot)
|
||||
|
||||
require.NotNil(t, roundTrip)
|
||||
|
||||
@@ -196,6 +196,12 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
|
||||
|
||||
grantPlan := s.resolveSignupGrantPlan(ctx, "email")
|
||||
|
||||
// 新用户默认 RPM(0 = 不限制)。注册时写入,后续作为用户级兜底。
|
||||
var defaultRPMLimit int
|
||||
if s.settingService != nil {
|
||||
defaultRPMLimit = s.settingService.GetDefaultUserRPMLimit(ctx)
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
user := &User{
|
||||
Email: email,
|
||||
@@ -203,6 +209,7 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
|
||||
Role: RoleUser,
|
||||
Balance: grantPlan.Balance,
|
||||
Concurrency: grantPlan.Concurrency,
|
||||
RPMLimit: defaultRPMLimit,
|
||||
Status: StatusActive,
|
||||
}
|
||||
|
||||
@@ -481,6 +488,10 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username
|
||||
|
||||
signupSource := inferLegacySignupSource(email)
|
||||
grantPlan := s.resolveSignupGrantPlan(ctx, signupSource)
|
||||
var defaultRPMLimit int
|
||||
if s.settingService != nil {
|
||||
defaultRPMLimit = s.settingService.GetDefaultUserRPMLimit(ctx)
|
||||
}
|
||||
|
||||
newUser := &User{
|
||||
Email: email,
|
||||
@@ -489,6 +500,7 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username
|
||||
Role: RoleUser,
|
||||
Balance: grantPlan.Balance,
|
||||
Concurrency: grantPlan.Concurrency,
|
||||
RPMLimit: defaultRPMLimit,
|
||||
Status: StatusActive,
|
||||
SignupSource: signupSource,
|
||||
}
|
||||
@@ -592,6 +604,10 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
|
||||
|
||||
signupSource := inferLegacySignupSource(email)
|
||||
grantPlan := s.resolveSignupGrantPlan(ctx, signupSource)
|
||||
var defaultRPMLimit int
|
||||
if s.settingService != nil {
|
||||
defaultRPMLimit = s.settingService.GetDefaultUserRPMLimit(ctx)
|
||||
}
|
||||
|
||||
newUser := &User{
|
||||
Email: email,
|
||||
@@ -600,6 +616,7 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
|
||||
Role: RoleUser,
|
||||
Balance: grantPlan.Balance,
|
||||
Concurrency: grantPlan.Concurrency,
|
||||
RPMLimit: defaultRPMLimit,
|
||||
Status: StatusActive,
|
||||
SignupSource: signupSource,
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ import (
|
||||
var (
|
||||
ErrSubscriptionInvalid = infraerrors.Forbidden("SUBSCRIPTION_INVALID", "subscription is invalid or expired")
|
||||
ErrBillingServiceUnavailable = infraerrors.ServiceUnavailable("BILLING_SERVICE_ERROR", "Billing service temporarily unavailable. Please retry later.")
|
||||
// RPM 超限错误。gateway_handler 负责映射为 HTTP 429。
|
||||
ErrGroupRPMExceeded = infraerrors.TooManyRequests("GROUP_RPM_EXCEEDED", "group requests-per-minute limit exceeded")
|
||||
ErrUserRPMExceeded = infraerrors.TooManyRequests("USER_RPM_EXCEEDED", "user requests-per-minute limit exceeded")
|
||||
)
|
||||
|
||||
// subscriptionCacheData 订阅缓存数据结构(内部使用)
|
||||
@@ -87,6 +90,8 @@ type BillingCacheService struct {
|
||||
userRepo UserRepository
|
||||
subRepo UserSubscriptionRepository
|
||||
apiKeyRateLimitLoader apiKeyRateLimitLoader
|
||||
userRPMCache UserRPMCache
|
||||
userGroupRateRepo UserGroupRateRepository
|
||||
cfg *config.Config
|
||||
circuitBreaker *billingCircuitBreaker
|
||||
|
||||
@@ -104,12 +109,22 @@ type BillingCacheService struct {
|
||||
}
|
||||
|
||||
// NewBillingCacheService 创建计费缓存服务
|
||||
func NewBillingCacheService(cache BillingCache, userRepo UserRepository, subRepo UserSubscriptionRepository, apiKeyRepo APIKeyRepository, cfg *config.Config) *BillingCacheService {
|
||||
func NewBillingCacheService(
|
||||
cache BillingCache,
|
||||
userRepo UserRepository,
|
||||
subRepo UserSubscriptionRepository,
|
||||
apiKeyRepo APIKeyRepository,
|
||||
userRPMCache UserRPMCache,
|
||||
userGroupRateRepo UserGroupRateRepository,
|
||||
cfg *config.Config,
|
||||
) *BillingCacheService {
|
||||
svc := &BillingCacheService{
|
||||
cache: cache,
|
||||
userRepo: userRepo,
|
||||
subRepo: subRepo,
|
||||
apiKeyRateLimitLoader: apiKeyRepo,
|
||||
userRPMCache: userRPMCache,
|
||||
userGroupRateRepo: userGroupRateRepo,
|
||||
cfg: cfg,
|
||||
}
|
||||
svc.circuitBreaker = newBillingCircuitBreaker(cfg.Billing.CircuitBreaker)
|
||||
@@ -664,6 +679,95 @@ func (s *BillingCacheService) CheckBillingEligibility(ctx context.Context, user
|
||||
}
|
||||
}
|
||||
|
||||
// RPM 限流:级联回落(Override → Group → User),放在最后以避免为注定失败的请求增加计数。
|
||||
if err := s.checkRPM(ctx, user, group); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkRPM 执行并行 RPM 限流,所有适用的限制同时生效,任一超限即拒绝:
|
||||
//
|
||||
// 1. (用户, 分组) rpm_override — 最细粒度:管理员为特定用户在特定分组设定的专属限额。
|
||||
// override=0 表示该用户在该分组免检(绿灯),但 user 级全局上限仍然生效。
|
||||
// 2. group.rpm_limit — 分组级:该分组的统一 RPM 容量(仅当无 override 时生效)。
|
||||
// 3. user.rpm_limit — 用户级全局硬上限:无论 override/group 如何配置,始终生效。
|
||||
//
|
||||
// 与旧版"级联互斥"设计不同,新版确保 user.rpm_limit 作为全局天花板不会被 group 或 override 覆盖。
|
||||
// Redis 故障一律 fail-open(打 warning,不阻塞业务)。
|
||||
func (s *BillingCacheService) checkRPM(ctx context.Context, user *User, group *Group) error {
|
||||
if s == nil || s.userRPMCache == nil || user == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── 第一层:分组级检查(override 或 group.rpm_limit) ──
|
||||
if group != nil {
|
||||
// 解析 override:优先从 auth cache snapshot,nil 时回退 DB。
|
||||
var override *int
|
||||
if user.UserGroupRPMOverride != nil {
|
||||
override = user.UserGroupRPMOverride
|
||||
} else if s.userGroupRateRepo != nil {
|
||||
dbOverride, err := s.userGroupRateRepo.GetRPMOverrideByUserAndGroup(ctx, user.ID, group.ID)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf(
|
||||
"service.billing_cache",
|
||||
"Warning: rpm override lookup failed for user=%d group=%d: %v",
|
||||
user.ID, group.ID, err,
|
||||
)
|
||||
} else {
|
||||
override = dbOverride
|
||||
}
|
||||
}
|
||||
|
||||
if override != nil {
|
||||
// override=0 → 该用户在该分组免检(但 user 级仍会在下面检查)。
|
||||
if *override > 0 {
|
||||
count, incErr := s.userRPMCache.IncrementUserGroupRPM(ctx, user.ID, group.ID)
|
||||
if incErr != nil {
|
||||
logger.LegacyPrintf(
|
||||
"service.billing_cache",
|
||||
"Warning: rpm increment (override) failed for user=%d group=%d: %v",
|
||||
user.ID, group.ID, incErr,
|
||||
)
|
||||
// fail-open
|
||||
} else if count > *override {
|
||||
return ErrGroupRPMExceeded
|
||||
}
|
||||
}
|
||||
// override 命中后跳过 group.rpm_limit(override 替代 group),但不 return——继续检查 user 级。
|
||||
} else if group.RPMLimit > 0 {
|
||||
// 无 override,检查 group.rpm_limit。
|
||||
count, err := s.userRPMCache.IncrementUserGroupRPM(ctx, user.ID, group.ID)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf(
|
||||
"service.billing_cache",
|
||||
"Warning: rpm increment (group) failed for user=%d group=%d: %v",
|
||||
user.ID, group.ID, err,
|
||||
)
|
||||
// fail-open
|
||||
} else if count > group.RPMLimit {
|
||||
return ErrGroupRPMExceeded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 第二层:用户级全局硬上限(始终生效) ──
|
||||
if user.RPMLimit > 0 {
|
||||
count, err := s.userRPMCache.IncrementUserRPM(ctx, user.ID)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf(
|
||||
"service.billing_cache",
|
||||
"Warning: rpm increment (user) failed for user=%d: %v",
|
||||
user.ID, err,
|
||||
)
|
||||
return nil // fail-open
|
||||
}
|
||||
if count > user.RPMLimit {
|
||||
return ErrUserRPMExceeded
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// userRPMCacheStub 记录每种计数器被调用的次数,并可注入返回值与错误。
|
||||
type userRPMCacheStub struct {
|
||||
userGroupCalls int32
|
||||
userCalls int32
|
||||
|
||||
userGroupCounts []int // 依次返回的计数值
|
||||
userGroupErr error
|
||||
userCounts []int
|
||||
userErr error
|
||||
}
|
||||
|
||||
func (s *userRPMCacheStub) IncrementUserGroupRPM(_ context.Context, _, _ int64) (int, error) {
|
||||
idx := int(atomic.AddInt32(&s.userGroupCalls, 1)) - 1
|
||||
if s.userGroupErr != nil {
|
||||
return 0, s.userGroupErr
|
||||
}
|
||||
if idx < len(s.userGroupCounts) {
|
||||
return s.userGroupCounts[idx], nil
|
||||
}
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (s *userRPMCacheStub) IncrementUserRPM(_ context.Context, _ int64) (int, error) {
|
||||
idx := int(atomic.AddInt32(&s.userCalls, 1)) - 1
|
||||
if s.userErr != nil {
|
||||
return 0, s.userErr
|
||||
}
|
||||
if idx < len(s.userCounts) {
|
||||
return s.userCounts[idx], nil
|
||||
}
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (s *userRPMCacheStub) GetUserGroupRPM(_ context.Context, _, _ int64) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *userRPMCacheStub) GetUserRPM(_ context.Context, _ int64) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// rpmOverrideRepoStub 专用于 checkRPM 分支测试,只实现必要方法。
|
||||
type rpmOverrideRepoStub struct {
|
||||
UserGroupRateRepository
|
||||
|
||||
override *int
|
||||
err error
|
||||
calls int32
|
||||
}
|
||||
|
||||
func (s *rpmOverrideRepoStub) GetRPMOverrideByUserAndGroup(_ context.Context, _, _ int64) (*int, error) {
|
||||
atomic.AddInt32(&s.calls, 1)
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return s.override, nil
|
||||
}
|
||||
|
||||
func newBillingServiceForRPM(t *testing.T, cache UserRPMCache, rateRepo UserGroupRateRepository) *BillingCacheService {
|
||||
t.Helper()
|
||||
// 用 nil BillingCache 走 "无缓存" 分支,避免 CheckBillingEligibility 副作用。
|
||||
// 我们只直接测 checkRPM。
|
||||
svc := NewBillingCacheService(nil, nil, nil, nil, cache, rateRepo, &config.Config{})
|
||||
t.Cleanup(svc.Stop)
|
||||
return svc
|
||||
}
|
||||
|
||||
func TestBillingCacheService_CheckRPM_OverrideTakesPrecedenceOverGroup(t *testing.T) {
|
||||
override := 2
|
||||
// user-group 计数: 1, 2, 3;user 计数: 默认返回 1(远小于 RPMLimit=100,不干扰)
|
||||
cache := &userRPMCacheStub{userGroupCounts: []int{1, 2, 3}}
|
||||
repo := &rpmOverrideRepoStub{override: &override}
|
||||
svc := newBillingServiceForRPM(t, cache, repo)
|
||||
|
||||
user := &User{ID: 1, RPMLimit: 100} // 全局上限设高,不干扰 override 测试
|
||||
group := &Group{ID: 10, RPMLimit: 100}
|
||||
|
||||
require.NoError(t, svc.checkRPM(context.Background(), user, group))
|
||||
require.NoError(t, svc.checkRPM(context.Background(), user, group))
|
||||
require.ErrorIs(t, svc.checkRPM(context.Background(), user, group), ErrGroupRPMExceeded)
|
||||
|
||||
require.EqualValues(t, 3, atomic.LoadInt32(&cache.userGroupCalls), "override 命中分支应走 user-group 计数")
|
||||
// 并行设计:前 2 次 override 未超→继续检查 user;第 3 次 override 超了→直接 return,不检查 user
|
||||
require.EqualValues(t, 2, atomic.LoadInt32(&cache.userCalls), "override 超限前 user 计数器应被调用")
|
||||
require.EqualValues(t, 3, atomic.LoadInt32(&repo.calls))
|
||||
}
|
||||
|
||||
func TestBillingCacheService_CheckRPM_UserLimitIsGlobalHardCap(t *testing.T) {
|
||||
override := 100 // override 很高
|
||||
// user-group 计数: 默认返回 1(远小于 override);user 计数: 1, 2, 3
|
||||
cache := &userRPMCacheStub{userCounts: []int{1, 2, 3}}
|
||||
repo := &rpmOverrideRepoStub{override: &override}
|
||||
svc := newBillingServiceForRPM(t, cache, repo)
|
||||
|
||||
user := &User{ID: 1, RPMLimit: 2} // 全局硬上限=2,应覆盖 override=100
|
||||
group := &Group{ID: 10, RPMLimit: 100}
|
||||
|
||||
require.NoError(t, svc.checkRPM(context.Background(), user, group))
|
||||
require.NoError(t, svc.checkRPM(context.Background(), user, group))
|
||||
require.ErrorIs(t, svc.checkRPM(context.Background(), user, group), ErrUserRPMExceeded, "user 全局硬上限应优先于 override")
|
||||
}
|
||||
|
||||
func TestBillingCacheService_CheckRPM_OverrideZeroSkipsGroupButUserStillApplies(t *testing.T) {
|
||||
zero := 0
|
||||
// user 计数: 依次返回 1..6
|
||||
cache := &userRPMCacheStub{userCounts: []int{1, 2, 3, 4, 5, 6}}
|
||||
repo := &rpmOverrideRepoStub{override: &zero}
|
||||
svc := newBillingServiceForRPM(t, cache, repo)
|
||||
|
||||
user := &User{ID: 1, RPMLimit: 5}
|
||||
group := &Group{ID: 10, RPMLimit: 100}
|
||||
|
||||
// override=0 跳过分组计数,但 user.RPMLimit=5 仍生效
|
||||
for i := 0; i < 5; i++ {
|
||||
require.NoError(t, svc.checkRPM(context.Background(), user, group), "request %d should pass", i+1)
|
||||
}
|
||||
require.ErrorIs(t, svc.checkRPM(context.Background(), user, group), ErrUserRPMExceeded,
|
||||
"override=0 跳过分组但 user 全局上限仍应生效")
|
||||
require.EqualValues(t, 0, atomic.LoadInt32(&cache.userGroupCalls), "override=0 不应触发分组计数器")
|
||||
require.EqualValues(t, 6, atomic.LoadInt32(&cache.userCalls), "user 计数器应被调用")
|
||||
}
|
||||
|
||||
func TestBillingCacheService_CheckRPM_OverrideZeroAndUserZeroIsFullyUnlimited(t *testing.T) {
|
||||
zero := 0
|
||||
cache := &userRPMCacheStub{}
|
||||
repo := &rpmOverrideRepoStub{override: &zero}
|
||||
svc := newBillingServiceForRPM(t, cache, repo)
|
||||
|
||||
user := &User{ID: 1, RPMLimit: 0} // user 也不限
|
||||
group := &Group{ID: 10, RPMLimit: 100}
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
require.NoError(t, svc.checkRPM(context.Background(), user, group))
|
||||
}
|
||||
require.EqualValues(t, 0, atomic.LoadInt32(&cache.userGroupCalls), "override=0 不触发分组计数")
|
||||
require.EqualValues(t, 0, atomic.LoadInt32(&cache.userCalls), "user.RPMLimit=0 也不触发用户计数")
|
||||
}
|
||||
|
||||
func TestBillingCacheService_CheckRPM_NilOverrideFallsThroughToGroup(t *testing.T) {
|
||||
// user-group 计数: 5, 6;user 计数: 默认 1(不干扰)
|
||||
cache := &userRPMCacheStub{userGroupCounts: []int{5, 6}}
|
||||
repo := &rpmOverrideRepoStub{override: nil}
|
||||
svc := newBillingServiceForRPM(t, cache, repo)
|
||||
|
||||
user := &User{ID: 1, RPMLimit: 999} // 全局上限很高,group 先超
|
||||
group := &Group{ID: 10, RPMLimit: 5}
|
||||
|
||||
require.NoError(t, svc.checkRPM(context.Background(), user, group)) // ug=5, user=1, 都没超
|
||||
require.ErrorIs(t, svc.checkRPM(context.Background(), user, group), ErrGroupRPMExceeded) // ug=6 > 5
|
||||
|
||||
require.EqualValues(t, 2, atomic.LoadInt32(&cache.userGroupCalls))
|
||||
// 并行模式:第 1 次 group 没超 → 继续检查 user;第 2 次 group 超了 → 直接 return,不检查 user
|
||||
require.EqualValues(t, 1, atomic.LoadInt32(&cache.userCalls), "group 未超时 user 也应检查;group 超时直接返回")
|
||||
}
|
||||
|
||||
func TestBillingCacheService_CheckRPM_OverrideLookupErrorFallsThroughToGroup(t *testing.T) {
|
||||
cache := &userRPMCacheStub{userGroupCounts: []int{3}}
|
||||
repo := &rpmOverrideRepoStub{err: errors.New("db down")}
|
||||
svc := newBillingServiceForRPM(t, cache, repo)
|
||||
|
||||
user := &User{ID: 1, RPMLimit: 0}
|
||||
group := &Group{ID: 10, RPMLimit: 10}
|
||||
|
||||
// override 查询失败后应继续尝试 group 分支(不直接拒绝)
|
||||
require.NoError(t, svc.checkRPM(context.Background(), user, group))
|
||||
require.EqualValues(t, 1, atomic.LoadInt32(&cache.userGroupCalls))
|
||||
require.EqualValues(t, 1, atomic.LoadInt32(&repo.calls))
|
||||
}
|
||||
|
||||
func TestBillingCacheService_CheckRPM_UserLevelFallbackWhenGroupUnlimited(t *testing.T) {
|
||||
cache := &userRPMCacheStub{userCounts: []int{1, 2, 3}}
|
||||
repo := &rpmOverrideRepoStub{override: nil}
|
||||
svc := newBillingServiceForRPM(t, cache, repo)
|
||||
|
||||
user := &User{ID: 1, RPMLimit: 2}
|
||||
group := &Group{ID: 10, RPMLimit: 0} // 分组未设限
|
||||
|
||||
require.NoError(t, svc.checkRPM(context.Background(), user, group))
|
||||
require.NoError(t, svc.checkRPM(context.Background(), user, group))
|
||||
require.ErrorIs(t, svc.checkRPM(context.Background(), user, group), ErrUserRPMExceeded)
|
||||
|
||||
require.EqualValues(t, 0, atomic.LoadInt32(&cache.userGroupCalls), "group 未设限时不应 INCR user-group 键")
|
||||
require.EqualValues(t, 3, atomic.LoadInt32(&cache.userCalls))
|
||||
}
|
||||
|
||||
func TestBillingCacheService_CheckRPM_NoLimitsConfiguredIsNoop(t *testing.T) {
|
||||
cache := &userRPMCacheStub{}
|
||||
repo := &rpmOverrideRepoStub{override: nil}
|
||||
svc := newBillingServiceForRPM(t, cache, repo)
|
||||
|
||||
user := &User{ID: 1, RPMLimit: 0}
|
||||
group := &Group{ID: 10, RPMLimit: 0}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
require.NoError(t, svc.checkRPM(context.Background(), user, group))
|
||||
}
|
||||
require.EqualValues(t, 0, atomic.LoadInt32(&cache.userGroupCalls))
|
||||
require.EqualValues(t, 0, atomic.LoadInt32(&cache.userCalls))
|
||||
}
|
||||
|
||||
func TestBillingCacheService_CheckRPM_RedisErrorFailOpen(t *testing.T) {
|
||||
cache := &userRPMCacheStub{userGroupErr: errors.New("redis unavailable")}
|
||||
repo := &rpmOverrideRepoStub{override: nil}
|
||||
svc := newBillingServiceForRPM(t, cache, repo)
|
||||
|
||||
user := &User{ID: 1, RPMLimit: 0}
|
||||
group := &Group{ID: 10, RPMLimit: 5}
|
||||
|
||||
// Redis 故障时应 fail-open,不拒绝请求
|
||||
require.NoError(t, svc.checkRPM(context.Background(), user, group))
|
||||
require.EqualValues(t, 1, atomic.LoadInt32(&cache.userGroupCalls))
|
||||
}
|
||||
|
||||
func TestBillingCacheService_CheckRPM_NoGroupUsesUserOnly(t *testing.T) {
|
||||
cache := &userRPMCacheStub{userCounts: []int{1, 2, 3}}
|
||||
repo := &rpmOverrideRepoStub{}
|
||||
svc := newBillingServiceForRPM(t, cache, repo)
|
||||
|
||||
user := &User{ID: 1, RPMLimit: 2}
|
||||
|
||||
// 无 group(纯用户级限流场景),不应查询 rpm_override。
|
||||
require.NoError(t, svc.checkRPM(context.Background(), user, nil))
|
||||
require.NoError(t, svc.checkRPM(context.Background(), user, nil))
|
||||
require.ErrorIs(t, svc.checkRPM(context.Background(), user, nil), ErrUserRPMExceeded)
|
||||
|
||||
require.EqualValues(t, 0, atomic.LoadInt32(&repo.calls), "无 group 时不应查询 rpm_override")
|
||||
require.EqualValues(t, 3, atomic.LoadInt32(&cache.userCalls))
|
||||
}
|
||||
|
||||
func TestBillingCacheService_CheckRPM_NilUserIsNoop(t *testing.T) {
|
||||
cache := &userRPMCacheStub{}
|
||||
repo := &rpmOverrideRepoStub{}
|
||||
svc := newBillingServiceForRPM(t, cache, repo)
|
||||
|
||||
require.NoError(t, svc.checkRPM(context.Background(), nil, &Group{ID: 1, RPMLimit: 10}))
|
||||
require.EqualValues(t, 0, atomic.LoadInt32(&cache.userGroupCalls))
|
||||
require.EqualValues(t, 0, atomic.LoadInt32(&cache.userCalls))
|
||||
require.EqualValues(t, 0, atomic.LoadInt32(&repo.calls))
|
||||
}
|
||||
@@ -100,7 +100,7 @@ func TestBillingCacheServiceGetUserBalance_Singleflight(t *testing.T) {
|
||||
delay: 80 * time.Millisecond,
|
||||
balance: 12.34,
|
||||
}
|
||||
svc := NewBillingCacheService(cache, userRepo, nil, nil, &config.Config{})
|
||||
svc := NewBillingCacheService(cache, userRepo, nil, nil, nil, nil, &config.Config{})
|
||||
t.Cleanup(svc.Stop)
|
||||
|
||||
const goroutines = 16
|
||||
|
||||
@@ -70,7 +70,7 @@ func (b *billingCacheWorkerStub) InvalidateAPIKeyRateLimit(ctx context.Context,
|
||||
|
||||
func TestBillingCacheServiceQueueHighLoad(t *testing.T) {
|
||||
cache := &billingCacheWorkerStub{}
|
||||
svc := NewBillingCacheService(cache, nil, nil, nil, &config.Config{})
|
||||
svc := NewBillingCacheService(cache, nil, nil, nil, nil, nil, &config.Config{})
|
||||
t.Cleanup(svc.Stop)
|
||||
|
||||
start := time.Now()
|
||||
@@ -92,7 +92,7 @@ func TestBillingCacheServiceQueueHighLoad(t *testing.T) {
|
||||
|
||||
func TestBillingCacheServiceEnqueueAfterStopReturnsFalse(t *testing.T) {
|
||||
cache := &billingCacheWorkerStub{}
|
||||
svc := NewBillingCacheService(cache, nil, nil, nil, &config.Config{})
|
||||
svc := NewBillingCacheService(cache, nil, nil, nil, nil, nil, &config.Config{})
|
||||
svc.Stop()
|
||||
|
||||
enqueued := svc.enqueueCacheWrite(cacheWriteTask{
|
||||
|
||||
@@ -217,6 +217,9 @@ func (s *BillingService) initFallbackPricing() {
|
||||
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
|
||||
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
|
||||
}
|
||||
// GPT-5.5 暂无独立定价,回退到 GPT-5.4
|
||||
s.fallbackPrices["gpt-5.5"] = s.fallbackPrices["gpt-5.4"]
|
||||
|
||||
s.fallbackPrices["gpt-5.4-mini"] = &ModelPricing{
|
||||
InputPricePerToken: 7.5e-7,
|
||||
OutputPricePerToken: 4.5e-6,
|
||||
@@ -288,6 +291,8 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
|
||||
if strings.Contains(modelLower, "gpt-5") || strings.Contains(modelLower, "codex") {
|
||||
normalized := normalizeCodexModel(modelLower)
|
||||
switch normalized {
|
||||
case "gpt-5.5":
|
||||
return s.fallbackPrices["gpt-5.5"]
|
||||
case "gpt-5.4-mini":
|
||||
return s.fallbackPrices["gpt-5.4-mini"]
|
||||
case "gpt-5.4":
|
||||
@@ -637,7 +642,8 @@ func isOpenAIGPT54Model(model string) bool {
|
||||
if !strings.Contains(trimmed, "gpt-5") && !strings.Contains(trimmed, "codex") {
|
||||
return false
|
||||
}
|
||||
return normalizeCodexModel(trimmed) == "gpt-5.4"
|
||||
normalized := normalizeCodexModel(trimmed)
|
||||
return normalized == "gpt-5.4" || normalized == "gpt-5.5"
|
||||
}
|
||||
|
||||
// CalculateCostWithConfig 使用配置中的默认倍率计算费用
|
||||
|
||||
@@ -170,9 +170,10 @@ const (
|
||||
SettingKeyCustomEndpoints = "custom_endpoints" // 自定义端点列表(JSON 数组)
|
||||
|
||||
// 默认配置
|
||||
SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量
|
||||
SettingKeyDefaultBalance = "default_balance" // 新用户默认余额
|
||||
SettingKeyDefaultSubscriptions = "default_subscriptions" // 新用户默认订阅列表(JSON)
|
||||
SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量
|
||||
SettingKeyDefaultBalance = "default_balance" // 新用户默认余额
|
||||
SettingKeyDefaultSubscriptions = "default_subscriptions" // 新用户默认订阅列表(JSON)
|
||||
SettingKeyDefaultUserRPMLimit = "default_user_rpm_limit" // 新用户默认 RPM 限制(0 = 不限制)
|
||||
|
||||
// 第三方认证来源默认授予配置
|
||||
SettingKeyAuthSourceDefaultEmailBalance = "auth_source_default_email_balance"
|
||||
|
||||
@@ -59,6 +59,10 @@ type Group struct {
|
||||
DefaultMappedModel string
|
||||
MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig
|
||||
|
||||
// RPMLimit 分组级每分钟请求数上限(0 = 不限制)。
|
||||
// 一旦设置即接管该分组用户的限流(覆盖用户级 rpm_limit),可被 user-group rpm_override 进一步覆盖。
|
||||
RPMLimit int
|
||||
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package service
|
||||
|
||||
import "context"
|
||||
|
||||
// OpenAI403CounterCache 追踪 OpenAI 账号连续 403 失败次数。
|
||||
type OpenAI403CounterCache interface {
|
||||
// IncrementOpenAI403Count 原子递增 403 计数并返回当前值。
|
||||
IncrementOpenAI403Count(ctx context.Context, accountID int64, windowMinutes int) (int64, error)
|
||||
// ResetOpenAI403Count 成功后清零计数器。
|
||||
ResetOpenAI403Count(ctx context.Context, accountID int64) error
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
)
|
||||
|
||||
var codexModelMap = map[string]string{
|
||||
"gpt-5.5": "gpt-5.5",
|
||||
"gpt-5.4": "gpt-5.4",
|
||||
"gpt-5.4-mini": "gpt-5.4-mini",
|
||||
"gpt-5.4-none": "gpt-5.4",
|
||||
@@ -207,6 +208,9 @@ func normalizeCodexModel(model string) string {
|
||||
|
||||
normalized := strings.ToLower(modelID)
|
||||
|
||||
if strings.Contains(normalized, "gpt-5.5") || strings.Contains(normalized, "gpt 5.5") {
|
||||
return "gpt-5.5"
|
||||
}
|
||||
if strings.Contains(normalized, "gpt-5.4-mini") || strings.Contains(normalized, "gpt 5.4 mini") {
|
||||
return "gpt-5.4-mini"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type openAI403CounterResetStub struct {
|
||||
resetCalls []int64
|
||||
}
|
||||
|
||||
func (s *openAI403CounterResetStub) IncrementOpenAI403Count(context.Context, int64, int) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *openAI403CounterResetStub) ResetOpenAI403Count(_ context.Context, accountID int64) error {
|
||||
s.resetCalls = append(s.resetCalls, accountID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceRecordUsage_ResetsOpenAI403CounterBeforeZeroUsageReturn(t *testing.T) {
|
||||
counter := &openAI403CounterResetStub{}
|
||||
rateLimitSvc := NewRateLimitService(nil, nil, nil, nil, nil)
|
||||
rateLimitSvc.SetOpenAI403CounterCache(counter)
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
rateLimitService: rateLimitSvc,
|
||||
}
|
||||
|
||||
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
|
||||
Result: &OpenAIForwardResult{},
|
||||
Account: &Account{ID: 777, Platform: PlatformOpenAI},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []int64{777}, counter.resetCalls)
|
||||
}
|
||||
@@ -1098,3 +1098,50 @@ func TestOpenAIGatewayServiceRecordUsage_ImageOnlyUsageStillPersists(t *testing.
|
||||
require.NotNil(t, usageRepo.lastLog.BillingMode)
|
||||
require.Equal(t, string(BillingModeImage), *usageRepo.lastLog.BillingMode)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceRecordUsage_ImageUsesPerImageBillingEvenWithUsageTokens(t *testing.T) {
|
||||
imagePrice := 0.02
|
||||
groupID := int64(12)
|
||||
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
||||
userRepo := &openAIRecordUsageUserRepoStub{}
|
||||
subRepo := &openAIRecordUsageSubRepoStub{}
|
||||
svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil)
|
||||
|
||||
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
|
||||
Result: &OpenAIForwardResult{
|
||||
RequestID: "resp_image_per_request",
|
||||
Model: "gpt-image-2",
|
||||
Usage: OpenAIUsage{
|
||||
InputTokens: 1110,
|
||||
OutputTokens: 1756,
|
||||
ImageOutputTokens: 1756,
|
||||
},
|
||||
ImageCount: 2,
|
||||
ImageSize: "1K",
|
||||
Duration: time.Second,
|
||||
},
|
||||
APIKey: &APIKey{
|
||||
ID: 1008,
|
||||
GroupID: i64p(groupID),
|
||||
Group: &Group{
|
||||
ID: groupID,
|
||||
RateMultiplier: 1.0,
|
||||
ImagePrice1K: &imagePrice,
|
||||
},
|
||||
},
|
||||
User: &User{ID: 2008},
|
||||
Account: &Account{ID: 3008},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, usageRepo.lastLog)
|
||||
require.NotNil(t, usageRepo.lastLog.BillingMode)
|
||||
require.Equal(t, string(BillingModeImage), *usageRepo.lastLog.BillingMode)
|
||||
require.Equal(t, 2, usageRepo.lastLog.ImageCount)
|
||||
require.InDelta(t, 0.04, usageRepo.lastLog.TotalCost, 1e-12)
|
||||
require.InDelta(t, 0.04, usageRepo.lastLog.ActualCost, 1e-12)
|
||||
require.InDelta(t, 0.0, usageRepo.lastLog.InputCost, 1e-12)
|
||||
require.InDelta(t, 0.0, usageRepo.lastLog.OutputCost, 1e-12)
|
||||
require.InDelta(t, 0.0, usageRepo.lastLog.ImageOutputCost, 1e-12)
|
||||
}
|
||||
|
||||
@@ -4425,6 +4425,9 @@ type OpenAIRecordUsageInput struct {
|
||||
// RecordUsage records usage and deducts balance
|
||||
func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRecordUsageInput) error {
|
||||
result := input.Result
|
||||
if s.rateLimitService != nil && input != nil && input.Account != nil && input.Account.Platform == PlatformOpenAI {
|
||||
s.rateLimitService.ResetOpenAI403Counter(ctx, input.Account.ID)
|
||||
}
|
||||
|
||||
// 跳过所有 token 均为零的用量记录——上游未返回 usage 时不应写入数据库
|
||||
if result.Usage.InputTokens == 0 && result.Usage.OutputTokens == 0 &&
|
||||
@@ -4622,12 +4625,6 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost(
|
||||
serviceTier string,
|
||||
) (*CostBreakdown, error) {
|
||||
if result != nil && result.ImageCount > 0 {
|
||||
if hasOpenAIImageUsageTokens(result) {
|
||||
cost, err := s.calculateOpenAIImageTokenCost(ctx, apiKey, billingModel, multiplier, tokens, serviceTier, result.ImageSize)
|
||||
if err == nil {
|
||||
return cost, nil
|
||||
}
|
||||
}
|
||||
return s.calculateOpenAIImageCost(ctx, billingModel, apiKey, result, multiplier), nil
|
||||
}
|
||||
if s.resolver != nil && apiKey.Group != nil {
|
||||
@@ -4646,32 +4643,6 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost(
|
||||
return s.billingService.CalculateCostWithServiceTier(billingModel, tokens, multiplier, serviceTier)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) calculateOpenAIImageTokenCost(
|
||||
ctx context.Context,
|
||||
apiKey *APIKey,
|
||||
billingModel string,
|
||||
multiplier float64,
|
||||
tokens UsageTokens,
|
||||
serviceTier string,
|
||||
sizeTier string,
|
||||
) (*CostBreakdown, error) {
|
||||
if s.resolver != nil && apiKey.Group != nil {
|
||||
gid := apiKey.Group.ID
|
||||
return s.billingService.CalculateCostUnified(CostInput{
|
||||
Ctx: ctx,
|
||||
Model: billingModel,
|
||||
GroupID: &gid,
|
||||
Tokens: tokens,
|
||||
RequestCount: 1,
|
||||
SizeTier: sizeTier,
|
||||
RateMultiplier: multiplier,
|
||||
ServiceTier: serviceTier,
|
||||
Resolver: s.resolver,
|
||||
})
|
||||
}
|
||||
return s.billingService.CalculateCostWithServiceTier(billingModel, tokens, multiplier, serviceTier)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) calculateOpenAIImageCost(
|
||||
ctx context.Context,
|
||||
billingModel string,
|
||||
@@ -4679,7 +4650,8 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost(
|
||||
result *OpenAIForwardResult,
|
||||
multiplier float64,
|
||||
) *CostBreakdown {
|
||||
if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved != nil {
|
||||
if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved != nil &&
|
||||
(resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage) {
|
||||
gid := apiKey.Group.ID
|
||||
cost, err := s.billingService.CalculateCostUnified(CostInput{
|
||||
Ctx: ctx,
|
||||
@@ -4720,17 +4692,6 @@ func (s *OpenAIGatewayService) resolveOpenAIChannelPricing(ctx context.Context,
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasOpenAIImageUsageTokens(result *OpenAIForwardResult) bool {
|
||||
if result == nil {
|
||||
return false
|
||||
}
|
||||
return result.Usage.InputTokens > 0 ||
|
||||
result.Usage.OutputTokens > 0 ||
|
||||
result.Usage.CacheCreationInputTokens > 0 ||
|
||||
result.Usage.CacheReadInputTokens > 0 ||
|
||||
result.Usage.ImageOutputTokens > 0
|
||||
}
|
||||
|
||||
// ParseCodexRateLimitHeaders extracts Codex usage limits from response headers.
|
||||
// Exported for use in ratelimit_service when handling OpenAI 429 responses.
|
||||
func ParseCodexRateLimitHeaders(headers http.Header) *OpenAICodexUsageSnapshot {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,853 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
type openAIResponsesImageResult struct {
|
||||
Result string
|
||||
RevisedPrompt string
|
||||
OutputFormat string
|
||||
Size string
|
||||
Background string
|
||||
Quality string
|
||||
Model string
|
||||
}
|
||||
|
||||
func openAIResponsesImageResultKey(itemID string, result openAIResponsesImageResult) string {
|
||||
if strings.TrimSpace(result.Result) != "" {
|
||||
return strings.TrimSpace(result.OutputFormat) + "|" + strings.TrimSpace(result.Result)
|
||||
}
|
||||
return "item:" + strings.TrimSpace(itemID)
|
||||
}
|
||||
|
||||
func appendOpenAIResponsesImageResultDedup(results *[]openAIResponsesImageResult, seen map[string]struct{}, itemID string, result openAIResponsesImageResult) bool {
|
||||
if results == nil {
|
||||
return false
|
||||
}
|
||||
key := openAIResponsesImageResultKey(itemID, result)
|
||||
if key != "" {
|
||||
if _, exists := seen[key]; exists {
|
||||
return false
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
*results = append(*results, result)
|
||||
return true
|
||||
}
|
||||
|
||||
func mergeOpenAIResponsesImageMeta(dst *openAIResponsesImageResult, src openAIResponsesImageResult) {
|
||||
if dst == nil {
|
||||
return
|
||||
}
|
||||
if trimmed := strings.TrimSpace(src.OutputFormat); trimmed != "" {
|
||||
dst.OutputFormat = trimmed
|
||||
}
|
||||
if trimmed := strings.TrimSpace(src.Size); trimmed != "" {
|
||||
dst.Size = trimmed
|
||||
}
|
||||
if trimmed := strings.TrimSpace(src.Background); trimmed != "" {
|
||||
dst.Background = trimmed
|
||||
}
|
||||
if trimmed := strings.TrimSpace(src.Quality); trimmed != "" {
|
||||
dst.Quality = trimmed
|
||||
}
|
||||
if trimmed := strings.TrimSpace(src.Model); trimmed != "" {
|
||||
dst.Model = trimmed
|
||||
}
|
||||
}
|
||||
|
||||
func extractOpenAIResponsesImageMetaFromLifecycleEvent(payload []byte) (openAIResponsesImageResult, int64, bool) {
|
||||
switch gjson.GetBytes(payload, "type").String() {
|
||||
case "response.created", "response.in_progress", "response.completed":
|
||||
default:
|
||||
return openAIResponsesImageResult{}, 0, false
|
||||
}
|
||||
|
||||
response := gjson.GetBytes(payload, "response")
|
||||
if !response.Exists() {
|
||||
return openAIResponsesImageResult{}, 0, false
|
||||
}
|
||||
|
||||
meta := openAIResponsesImageResult{
|
||||
OutputFormat: strings.TrimSpace(response.Get("tools.0.output_format").String()),
|
||||
Size: strings.TrimSpace(response.Get("tools.0.size").String()),
|
||||
Background: strings.TrimSpace(response.Get("tools.0.background").String()),
|
||||
Quality: strings.TrimSpace(response.Get("tools.0.quality").String()),
|
||||
Model: strings.TrimSpace(response.Get("tools.0.model").String()),
|
||||
}
|
||||
return meta, response.Get("created_at").Int(), true
|
||||
}
|
||||
|
||||
func buildOpenAIImagesStreamPartialPayload(
|
||||
eventType string,
|
||||
b64 string,
|
||||
partialImageIndex int64,
|
||||
responseFormat string,
|
||||
createdAt int64,
|
||||
meta openAIResponsesImageResult,
|
||||
) []byte {
|
||||
if createdAt <= 0 {
|
||||
createdAt = time.Now().Unix()
|
||||
}
|
||||
|
||||
payload := []byte(`{"type":"","created_at":0,"partial_image_index":0,"b64_json":""}`)
|
||||
payload, _ = sjson.SetBytes(payload, "type", eventType)
|
||||
payload, _ = sjson.SetBytes(payload, "created_at", createdAt)
|
||||
payload, _ = sjson.SetBytes(payload, "partial_image_index", partialImageIndex)
|
||||
payload, _ = sjson.SetBytes(payload, "b64_json", b64)
|
||||
if strings.EqualFold(strings.TrimSpace(responseFormat), "url") {
|
||||
payload, _ = sjson.SetBytes(payload, "url", "data:"+openAIImageOutputMIMEType(meta.OutputFormat)+";base64,"+b64)
|
||||
}
|
||||
if meta.Background != "" {
|
||||
payload, _ = sjson.SetBytes(payload, "background", meta.Background)
|
||||
}
|
||||
if meta.OutputFormat != "" {
|
||||
payload, _ = sjson.SetBytes(payload, "output_format", meta.OutputFormat)
|
||||
}
|
||||
if meta.Quality != "" {
|
||||
payload, _ = sjson.SetBytes(payload, "quality", meta.Quality)
|
||||
}
|
||||
if meta.Size != "" {
|
||||
payload, _ = sjson.SetBytes(payload, "size", meta.Size)
|
||||
}
|
||||
if meta.Model != "" {
|
||||
payload, _ = sjson.SetBytes(payload, "model", meta.Model)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func buildOpenAIImagesStreamCompletedPayload(
|
||||
eventType string,
|
||||
img openAIResponsesImageResult,
|
||||
responseFormat string,
|
||||
createdAt int64,
|
||||
usageRaw []byte,
|
||||
) []byte {
|
||||
if createdAt <= 0 {
|
||||
createdAt = time.Now().Unix()
|
||||
}
|
||||
|
||||
payload := []byte(`{"type":"","created_at":0,"b64_json":""}`)
|
||||
payload, _ = sjson.SetBytes(payload, "type", eventType)
|
||||
payload, _ = sjson.SetBytes(payload, "created_at", createdAt)
|
||||
payload, _ = sjson.SetBytes(payload, "b64_json", img.Result)
|
||||
if strings.EqualFold(strings.TrimSpace(responseFormat), "url") {
|
||||
payload, _ = sjson.SetBytes(payload, "url", "data:"+openAIImageOutputMIMEType(img.OutputFormat)+";base64,"+img.Result)
|
||||
}
|
||||
if img.Background != "" {
|
||||
payload, _ = sjson.SetBytes(payload, "background", img.Background)
|
||||
}
|
||||
if img.OutputFormat != "" {
|
||||
payload, _ = sjson.SetBytes(payload, "output_format", img.OutputFormat)
|
||||
}
|
||||
if img.Quality != "" {
|
||||
payload, _ = sjson.SetBytes(payload, "quality", img.Quality)
|
||||
}
|
||||
if img.Size != "" {
|
||||
payload, _ = sjson.SetBytes(payload, "size", img.Size)
|
||||
}
|
||||
if img.Model != "" {
|
||||
payload, _ = sjson.SetBytes(payload, "model", img.Model)
|
||||
}
|
||||
if len(usageRaw) > 0 && gjson.ValidBytes(usageRaw) {
|
||||
payload, _ = sjson.SetRawBytes(payload, "usage", usageRaw)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func openAIImageOutputMIMEType(outputFormat string) string {
|
||||
if outputFormat == "" {
|
||||
return "image/png"
|
||||
}
|
||||
if strings.Contains(outputFormat, "/") {
|
||||
return outputFormat
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(outputFormat)) {
|
||||
case "png":
|
||||
return "image/png"
|
||||
case "jpg", "jpeg":
|
||||
return "image/jpeg"
|
||||
case "webp":
|
||||
return "image/webp"
|
||||
default:
|
||||
return "image/png"
|
||||
}
|
||||
}
|
||||
|
||||
func openAIImageUploadToDataURL(upload OpenAIImagesUpload) (string, error) {
|
||||
if len(upload.Data) == 0 {
|
||||
return "", fmt.Errorf("upload %q is empty", strings.TrimSpace(upload.FileName))
|
||||
}
|
||||
contentType := strings.TrimSpace(upload.ContentType)
|
||||
if contentType == "" {
|
||||
contentType = http.DetectContentType(upload.Data)
|
||||
}
|
||||
return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(upload.Data), nil
|
||||
}
|
||||
|
||||
func buildOpenAIImagesResponsesRequest(parsed *OpenAIImagesRequest, toolModel string) ([]byte, error) {
|
||||
if parsed == nil {
|
||||
return nil, fmt.Errorf("parsed images request is required")
|
||||
}
|
||||
prompt := strings.TrimSpace(parsed.Prompt)
|
||||
if prompt == "" {
|
||||
return nil, fmt.Errorf("prompt is required")
|
||||
}
|
||||
|
||||
inputImages := make([]string, 0, len(parsed.InputImageURLs)+len(parsed.Uploads))
|
||||
for _, imageURL := range parsed.InputImageURLs {
|
||||
if trimmed := strings.TrimSpace(imageURL); trimmed != "" {
|
||||
inputImages = append(inputImages, trimmed)
|
||||
}
|
||||
}
|
||||
for _, upload := range parsed.Uploads {
|
||||
dataURL, err := openAIImageUploadToDataURL(upload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inputImages = append(inputImages, dataURL)
|
||||
}
|
||||
if parsed.IsEdits() && len(inputImages) == 0 {
|
||||
return nil, fmt.Errorf("image input is required")
|
||||
}
|
||||
|
||||
req := []byte(`{"instructions":"","stream":true,"reasoning":{"effort":"medium","summary":"auto"},"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"model":"","store":false,"tool_choice":{"type":"image_generation"}}`)
|
||||
req, _ = sjson.SetBytes(req, "model", openAIImagesResponsesMainModel)
|
||||
|
||||
input := []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`)
|
||||
input, _ = sjson.SetBytes(input, "0.content.0.text", prompt)
|
||||
for index, imageURL := range inputImages {
|
||||
part := []byte(`{"type":"input_image","image_url":""}`)
|
||||
part, _ = sjson.SetBytes(part, "image_url", imageURL)
|
||||
input, _ = sjson.SetRawBytes(input, fmt.Sprintf("0.content.%d", index+1), part)
|
||||
}
|
||||
req, _ = sjson.SetRawBytes(req, "input", input)
|
||||
|
||||
action := "generate"
|
||||
if parsed.IsEdits() {
|
||||
action = "edit"
|
||||
}
|
||||
tool := []byte(`{"type":"image_generation","action":"","model":""}`)
|
||||
tool, _ = sjson.SetBytes(tool, "action", action)
|
||||
tool, _ = sjson.SetBytes(tool, "model", strings.TrimSpace(toolModel))
|
||||
|
||||
for _, field := range []struct {
|
||||
path string
|
||||
value string
|
||||
}{
|
||||
{path: "size", value: parsed.Size},
|
||||
{path: "quality", value: parsed.Quality},
|
||||
{path: "background", value: parsed.Background},
|
||||
{path: "output_format", value: parsed.OutputFormat},
|
||||
{path: "moderation", value: parsed.Moderation},
|
||||
{path: "style", value: parsed.Style},
|
||||
} {
|
||||
if trimmed := strings.TrimSpace(field.value); trimmed != "" {
|
||||
tool, _ = sjson.SetBytes(tool, field.path, trimmed)
|
||||
}
|
||||
}
|
||||
if parsed.OutputCompression != nil {
|
||||
tool, _ = sjson.SetBytes(tool, "output_compression", *parsed.OutputCompression)
|
||||
}
|
||||
if parsed.PartialImages != nil {
|
||||
tool, _ = sjson.SetBytes(tool, "partial_images", *parsed.PartialImages)
|
||||
}
|
||||
|
||||
maskImageURL := strings.TrimSpace(parsed.MaskImageURL)
|
||||
if parsed.MaskUpload != nil {
|
||||
dataURL, err := openAIImageUploadToDataURL(*parsed.MaskUpload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
maskImageURL = dataURL
|
||||
}
|
||||
if maskImageURL != "" {
|
||||
tool, _ = sjson.SetBytes(tool, "input_image_mask.image_url", maskImageURL)
|
||||
}
|
||||
|
||||
req, _ = sjson.SetRawBytes(req, "tools", []byte(`[]`))
|
||||
req, _ = sjson.SetRawBytes(req, "tools.-1", tool)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func extractOpenAIImagesFromResponsesCompleted(payload []byte) ([]openAIResponsesImageResult, int64, []byte, openAIResponsesImageResult, error) {
|
||||
if gjson.GetBytes(payload, "type").String() != "response.completed" {
|
||||
return nil, 0, nil, openAIResponsesImageResult{}, fmt.Errorf("unexpected event type")
|
||||
}
|
||||
|
||||
createdAt := gjson.GetBytes(payload, "response.created_at").Int()
|
||||
if createdAt <= 0 {
|
||||
createdAt = time.Now().Unix()
|
||||
}
|
||||
|
||||
var (
|
||||
results []openAIResponsesImageResult
|
||||
firstMeta openAIResponsesImageResult
|
||||
)
|
||||
output := gjson.GetBytes(payload, "response.output")
|
||||
if output.IsArray() {
|
||||
for _, item := range output.Array() {
|
||||
if item.Get("type").String() != "image_generation_call" {
|
||||
continue
|
||||
}
|
||||
result := strings.TrimSpace(item.Get("result").String())
|
||||
if result == "" {
|
||||
continue
|
||||
}
|
||||
entry := openAIResponsesImageResult{
|
||||
Result: result,
|
||||
RevisedPrompt: strings.TrimSpace(item.Get("revised_prompt").String()),
|
||||
OutputFormat: strings.TrimSpace(item.Get("output_format").String()),
|
||||
Size: strings.TrimSpace(item.Get("size").String()),
|
||||
Background: strings.TrimSpace(item.Get("background").String()),
|
||||
Quality: strings.TrimSpace(item.Get("quality").String()),
|
||||
}
|
||||
if len(results) == 0 {
|
||||
firstMeta = entry
|
||||
}
|
||||
results = append(results, entry)
|
||||
}
|
||||
}
|
||||
|
||||
var usageRaw []byte
|
||||
if usage := gjson.GetBytes(payload, "response.tool_usage.image_gen"); usage.Exists() && usage.IsObject() {
|
||||
usageRaw = []byte(usage.Raw)
|
||||
}
|
||||
return results, createdAt, usageRaw, firstMeta, nil
|
||||
}
|
||||
|
||||
func extractOpenAIImageFromResponsesOutputItemDone(payload []byte) (openAIResponsesImageResult, string, bool, error) {
|
||||
if gjson.GetBytes(payload, "type").String() != "response.output_item.done" {
|
||||
return openAIResponsesImageResult{}, "", false, fmt.Errorf("unexpected event type")
|
||||
}
|
||||
|
||||
item := gjson.GetBytes(payload, "item")
|
||||
if !item.Exists() || item.Get("type").String() != "image_generation_call" {
|
||||
return openAIResponsesImageResult{}, "", false, nil
|
||||
}
|
||||
|
||||
result := strings.TrimSpace(item.Get("result").String())
|
||||
if result == "" {
|
||||
return openAIResponsesImageResult{}, "", false, nil
|
||||
}
|
||||
|
||||
entry := openAIResponsesImageResult{
|
||||
Result: result,
|
||||
RevisedPrompt: strings.TrimSpace(item.Get("revised_prompt").String()),
|
||||
OutputFormat: strings.TrimSpace(item.Get("output_format").String()),
|
||||
Size: strings.TrimSpace(item.Get("size").String()),
|
||||
Background: strings.TrimSpace(item.Get("background").String()),
|
||||
Quality: strings.TrimSpace(item.Get("quality").String()),
|
||||
}
|
||||
return entry, strings.TrimSpace(item.Get("id").String()), true, nil
|
||||
}
|
||||
|
||||
func collectOpenAIImagesFromResponsesBody(body []byte) ([]openAIResponsesImageResult, int64, []byte, openAIResponsesImageResult, bool, error) {
|
||||
var (
|
||||
fallbackResults []openAIResponsesImageResult
|
||||
fallbackSeen = make(map[string]struct{})
|
||||
createdAt int64
|
||||
usageRaw []byte
|
||||
foundFinal bool
|
||||
responseMeta openAIResponsesImageResult
|
||||
)
|
||||
|
||||
for _, line := range bytes.Split(body, []byte("\n")) {
|
||||
line = bytes.TrimRight(line, "\r")
|
||||
data, ok := extractOpenAISSEDataLine(string(line))
|
||||
if !ok || data == "" || data == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
payload := []byte(data)
|
||||
if !gjson.ValidBytes(payload) {
|
||||
continue
|
||||
}
|
||||
if meta, eventCreatedAt, ok := extractOpenAIResponsesImageMetaFromLifecycleEvent(payload); ok {
|
||||
mergeOpenAIResponsesImageMeta(&responseMeta, meta)
|
||||
if eventCreatedAt > 0 {
|
||||
createdAt = eventCreatedAt
|
||||
}
|
||||
}
|
||||
|
||||
switch gjson.GetBytes(payload, "type").String() {
|
||||
case "response.output_item.done":
|
||||
result, itemID, ok, err := extractOpenAIImageFromResponsesOutputItemDone(payload)
|
||||
if err != nil {
|
||||
return nil, 0, nil, openAIResponsesImageResult{}, false, err
|
||||
}
|
||||
if ok {
|
||||
mergeOpenAIResponsesImageMeta(&result, responseMeta)
|
||||
appendOpenAIResponsesImageResultDedup(&fallbackResults, fallbackSeen, itemID, result)
|
||||
}
|
||||
case "response.completed":
|
||||
results, completedAt, completedUsageRaw, firstMeta, err := extractOpenAIImagesFromResponsesCompleted(payload)
|
||||
if err != nil {
|
||||
return nil, 0, nil, openAIResponsesImageResult{}, false, err
|
||||
}
|
||||
foundFinal = true
|
||||
if completedAt > 0 {
|
||||
createdAt = completedAt
|
||||
}
|
||||
if len(completedUsageRaw) > 0 {
|
||||
usageRaw = completedUsageRaw
|
||||
}
|
||||
if len(results) > 0 {
|
||||
mergeOpenAIResponsesImageMeta(&firstMeta, responseMeta)
|
||||
return results, createdAt, usageRaw, firstMeta, true, nil
|
||||
}
|
||||
if len(fallbackResults) > 0 {
|
||||
firstMeta = fallbackResults[0]
|
||||
mergeOpenAIResponsesImageMeta(&firstMeta, responseMeta)
|
||||
return fallbackResults, createdAt, usageRaw, firstMeta, true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(fallbackResults) > 0 {
|
||||
firstMeta := fallbackResults[0]
|
||||
mergeOpenAIResponsesImageMeta(&firstMeta, responseMeta)
|
||||
return fallbackResults, createdAt, usageRaw, firstMeta, foundFinal, nil
|
||||
}
|
||||
return nil, createdAt, usageRaw, openAIResponsesImageResult{}, foundFinal, nil
|
||||
}
|
||||
|
||||
func buildOpenAIImagesAPIResponse(
|
||||
results []openAIResponsesImageResult,
|
||||
createdAt int64,
|
||||
usageRaw []byte,
|
||||
firstMeta openAIResponsesImageResult,
|
||||
responseFormat string,
|
||||
) ([]byte, error) {
|
||||
if createdAt <= 0 {
|
||||
createdAt = time.Now().Unix()
|
||||
}
|
||||
out := []byte(`{"created":0,"data":[]}`)
|
||||
out, _ = sjson.SetBytes(out, "created", createdAt)
|
||||
|
||||
format := strings.ToLower(strings.TrimSpace(responseFormat))
|
||||
if format == "" {
|
||||
format = "b64_json"
|
||||
}
|
||||
for _, img := range results {
|
||||
item := []byte(`{}`)
|
||||
if format == "url" {
|
||||
item, _ = sjson.SetBytes(item, "url", "data:"+openAIImageOutputMIMEType(img.OutputFormat)+";base64,"+img.Result)
|
||||
} else {
|
||||
item, _ = sjson.SetBytes(item, "b64_json", img.Result)
|
||||
}
|
||||
if img.RevisedPrompt != "" {
|
||||
item, _ = sjson.SetBytes(item, "revised_prompt", img.RevisedPrompt)
|
||||
}
|
||||
out, _ = sjson.SetRawBytes(out, "data.-1", item)
|
||||
}
|
||||
if firstMeta.Background != "" {
|
||||
out, _ = sjson.SetBytes(out, "background", firstMeta.Background)
|
||||
}
|
||||
if firstMeta.OutputFormat != "" {
|
||||
out, _ = sjson.SetBytes(out, "output_format", firstMeta.OutputFormat)
|
||||
}
|
||||
if firstMeta.Quality != "" {
|
||||
out, _ = sjson.SetBytes(out, "quality", firstMeta.Quality)
|
||||
}
|
||||
if firstMeta.Size != "" {
|
||||
out, _ = sjson.SetBytes(out, "size", firstMeta.Size)
|
||||
}
|
||||
if firstMeta.Model != "" {
|
||||
out, _ = sjson.SetBytes(out, "model", firstMeta.Model)
|
||||
}
|
||||
if len(usageRaw) > 0 && gjson.ValidBytes(usageRaw) {
|
||||
out, _ = sjson.SetRawBytes(out, "usage", usageRaw)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func openAIImagesStreamPrefix(parsed *OpenAIImagesRequest) string {
|
||||
if parsed != nil && parsed.IsEdits() {
|
||||
return "image_edit"
|
||||
}
|
||||
return "image_generation"
|
||||
}
|
||||
|
||||
func buildOpenAIImagesStreamErrorBody(message string) []byte {
|
||||
body := []byte(`{"type":"error","error":{"type":"upstream_error","message":""}}`)
|
||||
if strings.TrimSpace(message) == "" {
|
||||
message = "upstream request failed"
|
||||
}
|
||||
body, _ = sjson.SetBytes(body, "error.message", message)
|
||||
return body
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) writeOpenAIImagesStreamEvent(c *gin.Context, flusher http.Flusher, eventName string, payload []byte) error {
|
||||
if strings.TrimSpace(eventName) != "" {
|
||||
if _, err := fmt.Fprintf(c.Writer, "event: %s\n", eventName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", payload); err != nil {
|
||||
return err
|
||||
}
|
||||
flusher.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) handleOpenAIImagesOAuthNonStreamingResponse(
|
||||
resp *http.Response,
|
||||
c *gin.Context,
|
||||
responseFormat string,
|
||||
fallbackModel string,
|
||||
) (OpenAIUsage, int, error) {
|
||||
body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
|
||||
if err != nil {
|
||||
return OpenAIUsage{}, 0, err
|
||||
}
|
||||
|
||||
var usage OpenAIUsage
|
||||
for _, line := range bytes.Split(body, []byte("\n")) {
|
||||
line = bytes.TrimRight(line, "\r")
|
||||
data, ok := extractOpenAISSEDataLine(string(line))
|
||||
if !ok || data == "" || data == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
dataBytes := []byte(data)
|
||||
s.parseSSEUsageBytes(dataBytes, &usage)
|
||||
}
|
||||
results, createdAt, usageRaw, firstMeta, _, err := collectOpenAIImagesFromResponsesBody(body)
|
||||
if err != nil {
|
||||
return OpenAIUsage{}, 0, err
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return OpenAIUsage{}, 0, fmt.Errorf("upstream did not return image output")
|
||||
}
|
||||
if strings.TrimSpace(firstMeta.Model) == "" {
|
||||
firstMeta.Model = strings.TrimSpace(fallbackModel)
|
||||
}
|
||||
|
||||
responseBody, err := buildOpenAIImagesAPIResponse(results, createdAt, usageRaw, firstMeta, responseFormat)
|
||||
if err != nil {
|
||||
return OpenAIUsage{}, 0, err
|
||||
}
|
||||
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
|
||||
c.Data(resp.StatusCode, "application/json; charset=utf-8", responseBody)
|
||||
return usage, len(results), nil
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse(
|
||||
resp *http.Response,
|
||||
c *gin.Context,
|
||||
startTime time.Time,
|
||||
responseFormat string,
|
||||
streamPrefix string,
|
||||
fallbackModel string,
|
||||
) (OpenAIUsage, int, *int, error) {
|
||||
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Status(resp.StatusCode)
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
return OpenAIUsage{}, 0, nil, fmt.Errorf("streaming is not supported by response writer")
|
||||
}
|
||||
|
||||
format := strings.ToLower(strings.TrimSpace(responseFormat))
|
||||
if format == "" {
|
||||
format = "b64_json"
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
usage := OpenAIUsage{}
|
||||
imageCount := 0
|
||||
var firstTokenMs *int
|
||||
emitted := make(map[string]struct{})
|
||||
pendingResults := make([]openAIResponsesImageResult, 0, 1)
|
||||
pendingSeen := make(map[string]struct{})
|
||||
streamMeta := openAIResponsesImageResult{Model: strings.TrimSpace(fallbackModel)}
|
||||
var createdAt int64
|
||||
|
||||
for {
|
||||
line, err := reader.ReadBytes('\n')
|
||||
if len(line) > 0 {
|
||||
trimmedLine := strings.TrimRight(string(line), "\r\n")
|
||||
data, ok := extractOpenAISSEDataLine(trimmedLine)
|
||||
if ok && data != "" && data != "[DONE]" {
|
||||
if firstTokenMs == nil {
|
||||
ms := int(time.Since(startTime).Milliseconds())
|
||||
firstTokenMs = &ms
|
||||
}
|
||||
dataBytes := []byte(data)
|
||||
s.parseSSEUsageBytes(dataBytes, &usage)
|
||||
if gjson.ValidBytes(dataBytes) {
|
||||
if meta, eventCreatedAt, ok := extractOpenAIResponsesImageMetaFromLifecycleEvent(dataBytes); ok {
|
||||
mergeOpenAIResponsesImageMeta(&streamMeta, meta)
|
||||
if eventCreatedAt > 0 {
|
||||
createdAt = eventCreatedAt
|
||||
}
|
||||
}
|
||||
switch gjson.GetBytes(dataBytes, "type").String() {
|
||||
case "response.image_generation_call.partial_image":
|
||||
b64 := strings.TrimSpace(gjson.GetBytes(dataBytes, "partial_image_b64").String())
|
||||
if b64 != "" {
|
||||
eventName := streamPrefix + ".partial_image"
|
||||
partialMeta := streamMeta
|
||||
mergeOpenAIResponsesImageMeta(&partialMeta, openAIResponsesImageResult{
|
||||
OutputFormat: strings.TrimSpace(gjson.GetBytes(dataBytes, "output_format").String()),
|
||||
Background: strings.TrimSpace(gjson.GetBytes(dataBytes, "background").String()),
|
||||
})
|
||||
payload := buildOpenAIImagesStreamPartialPayload(
|
||||
eventName,
|
||||
b64,
|
||||
gjson.GetBytes(dataBytes, "partial_image_index").Int(),
|
||||
format,
|
||||
createdAt,
|
||||
partialMeta,
|
||||
)
|
||||
if writeErr := s.writeOpenAIImagesStreamEvent(c, flusher, eventName, payload); writeErr != nil {
|
||||
return OpenAIUsage{}, imageCount, firstTokenMs, writeErr
|
||||
}
|
||||
}
|
||||
case "response.output_item.done":
|
||||
img, itemID, ok, extractErr := extractOpenAIImageFromResponsesOutputItemDone(dataBytes)
|
||||
if extractErr != nil {
|
||||
_ = s.writeOpenAIImagesStreamEvent(c, flusher, "error", buildOpenAIImagesStreamErrorBody(extractErr.Error()))
|
||||
return OpenAIUsage{}, imageCount, firstTokenMs, extractErr
|
||||
}
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
mergeOpenAIResponsesImageMeta(&streamMeta, img)
|
||||
mergeOpenAIResponsesImageMeta(&img, streamMeta)
|
||||
key := openAIResponsesImageResultKey(itemID, img)
|
||||
if _, exists := emitted[key]; exists {
|
||||
break
|
||||
}
|
||||
if _, exists := pendingSeen[key]; exists {
|
||||
break
|
||||
}
|
||||
pendingSeen[key] = struct{}{}
|
||||
pendingResults = append(pendingResults, img)
|
||||
case "response.completed":
|
||||
results, _, usageRaw, firstMeta, extractErr := extractOpenAIImagesFromResponsesCompleted(dataBytes)
|
||||
if extractErr != nil {
|
||||
_ = s.writeOpenAIImagesStreamEvent(c, flusher, "error", buildOpenAIImagesStreamErrorBody(extractErr.Error()))
|
||||
return OpenAIUsage{}, imageCount, firstTokenMs, extractErr
|
||||
}
|
||||
mergeOpenAIResponsesImageMeta(&streamMeta, firstMeta)
|
||||
finalResults := make([]openAIResponsesImageResult, 0, len(results)+len(pendingResults))
|
||||
finalSeen := make(map[string]struct{})
|
||||
for _, img := range results {
|
||||
mergeOpenAIResponsesImageMeta(&img, streamMeta)
|
||||
appendOpenAIResponsesImageResultDedup(&finalResults, finalSeen, "", img)
|
||||
}
|
||||
for _, img := range pendingResults {
|
||||
mergeOpenAIResponsesImageMeta(&img, streamMeta)
|
||||
appendOpenAIResponsesImageResultDedup(&finalResults, finalSeen, "", img)
|
||||
}
|
||||
if len(finalResults) == 0 {
|
||||
err = fmt.Errorf("upstream did not return image output")
|
||||
_ = s.writeOpenAIImagesStreamEvent(c, flusher, "error", buildOpenAIImagesStreamErrorBody(err.Error()))
|
||||
return OpenAIUsage{}, imageCount, firstTokenMs, err
|
||||
}
|
||||
eventName := streamPrefix + ".completed"
|
||||
for _, img := range finalResults {
|
||||
key := openAIResponsesImageResultKey("", img)
|
||||
if _, exists := emitted[key]; exists {
|
||||
continue
|
||||
}
|
||||
payload := buildOpenAIImagesStreamCompletedPayload(eventName, img, format, createdAt, usageRaw)
|
||||
if writeErr := s.writeOpenAIImagesStreamEvent(c, flusher, eventName, payload); writeErr != nil {
|
||||
return OpenAIUsage{}, imageCount, firstTokenMs, writeErr
|
||||
}
|
||||
emitted[key] = struct{}{}
|
||||
}
|
||||
imageCount = len(emitted)
|
||||
return usage, imageCount, firstTokenMs, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
_ = s.writeOpenAIImagesStreamEvent(c, flusher, "error", buildOpenAIImagesStreamErrorBody(err.Error()))
|
||||
return OpenAIUsage{}, imageCount, firstTokenMs, err
|
||||
}
|
||||
}
|
||||
|
||||
if imageCount > 0 {
|
||||
return usage, imageCount, firstTokenMs, nil
|
||||
}
|
||||
if len(pendingResults) > 0 {
|
||||
eventName := streamPrefix + ".completed"
|
||||
for _, img := range pendingResults {
|
||||
mergeOpenAIResponsesImageMeta(&img, streamMeta)
|
||||
key := openAIResponsesImageResultKey("", img)
|
||||
if _, exists := emitted[key]; exists {
|
||||
continue
|
||||
}
|
||||
payload := buildOpenAIImagesStreamCompletedPayload(eventName, img, format, createdAt, nil)
|
||||
if writeErr := s.writeOpenAIImagesStreamEvent(c, flusher, eventName, payload); writeErr != nil {
|
||||
return OpenAIUsage{}, imageCount, firstTokenMs, writeErr
|
||||
}
|
||||
emitted[key] = struct{}{}
|
||||
}
|
||||
imageCount = len(emitted)
|
||||
return usage, imageCount, firstTokenMs, nil
|
||||
}
|
||||
|
||||
streamErr := fmt.Errorf("stream disconnected before image generation completed")
|
||||
_ = s.writeOpenAIImagesStreamEvent(c, flusher, "error", buildOpenAIImagesStreamErrorBody(streamErr.Error()))
|
||||
return OpenAIUsage{}, imageCount, firstTokenMs, streamErr
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
|
||||
ctx context.Context,
|
||||
c *gin.Context,
|
||||
account *Account,
|
||||
parsed *OpenAIImagesRequest,
|
||||
channelMappedModel string,
|
||||
) (*OpenAIForwardResult, error) {
|
||||
startTime := time.Now()
|
||||
requestModel := strings.TrimSpace(parsed.Model)
|
||||
if mapped := strings.TrimSpace(channelMappedModel); mapped != "" {
|
||||
requestModel = mapped
|
||||
}
|
||||
if requestModel == "" {
|
||||
requestModel = "gpt-image-2"
|
||||
}
|
||||
if err := validateOpenAIImagesModel(requestModel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.LegacyPrintf(
|
||||
"service.openai_gateway",
|
||||
"[OpenAI] Images request routing request_model=%s endpoint=%s account_type=%s uploads=%d",
|
||||
requestModel,
|
||||
parsed.Endpoint,
|
||||
account.Type,
|
||||
len(parsed.Uploads),
|
||||
)
|
||||
if parsed.N > 1 {
|
||||
logger.LegacyPrintf(
|
||||
"service.openai_gateway",
|
||||
"[Warning] Codex /responses image tool requested n=%d; falling back to n=1 request_model=%s endpoint=%s",
|
||||
parsed.N,
|
||||
requestModel,
|
||||
parsed.Endpoint,
|
||||
)
|
||||
}
|
||||
|
||||
token, _, err := s.GetAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
responsesBody, err := buildOpenAIImagesResponsesRequest(parsed, requestModel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setOpsUpstreamRequestBody(c, responsesBody)
|
||||
|
||||
upstreamReq, err := s.buildUpstreamRequest(ctx, c, account, responsesBody, token, true, parsed.StickySessionSeed(), false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
upstreamReq.Header.Set("Content-Type", "application/json")
|
||||
upstreamReq.Header.Set("Accept", "text/event-stream")
|
||||
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
upstreamStart := time.Now()
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
|
||||
if err != nil {
|
||||
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
||||
setOpsUpstreamError(c, 0, safeErr, "")
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: 0,
|
||||
UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()),
|
||||
Kind: "request_error",
|
||||
Message: safeErr,
|
||||
})
|
||||
return nil, fmt.Errorf("upstream request failed: %s", safeErr)
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||
_ = resp.Body.Close()
|
||||
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
||||
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
|
||||
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
|
||||
if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) {
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: resp.StatusCode,
|
||||
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
||||
UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()),
|
||||
Kind: "failover",
|
||||
Message: upstreamMsg,
|
||||
})
|
||||
s.handleFailoverSideEffects(ctx, resp, account)
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: resp.StatusCode,
|
||||
ResponseBody: respBody,
|
||||
RetryableOnSameAccount: account.IsPoolMode() && isPoolModeRetryableStatus(resp.StatusCode),
|
||||
}
|
||||
}
|
||||
return s.handleErrorResponse(ctx, resp, c, account, responsesBody)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var (
|
||||
usage OpenAIUsage
|
||||
imageCount int
|
||||
firstTokenMs *int
|
||||
)
|
||||
if parsed.Stream {
|
||||
usage, imageCount, firstTokenMs, err = s.handleOpenAIImagesOAuthStreamingResponse(resp, c, startTime, parsed.ResponseFormat, openAIImagesStreamPrefix(parsed), requestModel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
usage, imageCount, err = s.handleOpenAIImagesOAuthNonStreamingResponse(resp, c, parsed.ResponseFormat, requestModel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if imageCount <= 0 {
|
||||
imageCount = parsed.N
|
||||
}
|
||||
return &OpenAIForwardResult{
|
||||
RequestID: resp.Header.Get("x-request-id"),
|
||||
Usage: usage,
|
||||
Model: requestModel,
|
||||
UpstreamModel: requestModel,
|
||||
Stream: parsed.Stream,
|
||||
ResponseHeaders: resp.Header.Clone(),
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
ImageCount: imageCount,
|
||||
ImageSize: parsed.SizeTier,
|
||||
}, nil
|
||||
}
|
||||
@@ -3,13 +3,17 @@ package service
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestOpenAIGatewayServiceParseOpenAIImagesRequest_JSON(t *testing.T) {
|
||||
@@ -70,6 +74,58 @@ func TestOpenAIGatewayServiceParseOpenAIImagesRequest_MultipartEdit(t *testing.T
|
||||
require.Equal(t, OpenAIImagesCapabilityNative, parsed.RequiredCapability)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceParseOpenAIImagesRequest_MultipartEditWithMaskAndNativeOptions(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
require.NoError(t, writer.WriteField("model", "gpt-image-2"))
|
||||
require.NoError(t, writer.WriteField("prompt", "replace foreground"))
|
||||
require.NoError(t, writer.WriteField("output_format", "png"))
|
||||
require.NoError(t, writer.WriteField("input_fidelity", "high"))
|
||||
require.NoError(t, writer.WriteField("output_compression", "80"))
|
||||
require.NoError(t, writer.WriteField("partial_images", "2"))
|
||||
|
||||
imageHeader := make(textproto.MIMEHeader)
|
||||
imageHeader.Set("Content-Disposition", `form-data; name="image"; filename="source.png"`)
|
||||
imageHeader.Set("Content-Type", "image/png")
|
||||
imagePart, err := writer.CreatePart(imageHeader)
|
||||
require.NoError(t, err)
|
||||
_, err = imagePart.Write([]byte("source-image-bytes"))
|
||||
require.NoError(t, err)
|
||||
|
||||
maskHeader := make(textproto.MIMEHeader)
|
||||
maskHeader.Set("Content-Disposition", `form-data; name="mask"; filename="mask.png"`)
|
||||
maskHeader.Set("Content-Type", "image/png")
|
||||
maskPart, err := writer.CreatePart(maskHeader)
|
||||
require.NoError(t, err)
|
||||
_, err = maskPart.Write([]byte("mask-image-bytes"))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, writer.Close())
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/images/edits", bytes.NewReader(body.Bytes()))
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
|
||||
svc := &OpenAIGatewayService{}
|
||||
parsed, err := svc.ParseOpenAIImagesRequest(c, body.Bytes())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, parsed)
|
||||
require.Len(t, parsed.Uploads, 1)
|
||||
require.NotNil(t, parsed.MaskUpload)
|
||||
require.True(t, parsed.HasMask)
|
||||
require.Equal(t, "png", parsed.OutputFormat)
|
||||
require.Equal(t, "high", parsed.InputFidelity)
|
||||
require.NotNil(t, parsed.OutputCompression)
|
||||
require.Equal(t, 80, *parsed.OutputCompression)
|
||||
require.NotNil(t, parsed.PartialImages)
|
||||
require.Equal(t, 2, *parsed.PartialImages)
|
||||
require.Equal(t, OpenAIImagesCapabilityNative, parsed.RequiredCapability)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceParseOpenAIImagesRequest_PromptOnlyDefaultsRemainBasic(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"prompt":"draw a cat"}`)
|
||||
@@ -121,6 +177,40 @@ func TestOpenAIGatewayServiceParseOpenAIImagesRequest_RejectsNonImageModel(t *te
|
||||
require.ErrorContains(t, err, `images endpoint requires an image model, got "gpt-5.4"`)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceParseOpenAIImagesRequest_JSONEditURLs(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{
|
||||
"model":"gpt-image-2",
|
||||
"prompt":"replace the background",
|
||||
"images":[{"image_url":"https://example.com/source.png"}],
|
||||
"mask":{"image_url":"https://example.com/mask.png"},
|
||||
"input_fidelity":"high",
|
||||
"output_compression":90,
|
||||
"partial_images":2,
|
||||
"response_format":"url"
|
||||
}`)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/images/edits", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
|
||||
svc := &OpenAIGatewayService{}
|
||||
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, parsed)
|
||||
require.Equal(t, []string{"https://example.com/source.png"}, parsed.InputImageURLs)
|
||||
require.Equal(t, "https://example.com/mask.png", parsed.MaskImageURL)
|
||||
require.Equal(t, "high", parsed.InputFidelity)
|
||||
require.NotNil(t, parsed.OutputCompression)
|
||||
require.Equal(t, 90, *parsed.OutputCompression)
|
||||
require.NotNil(t, parsed.PartialImages)
|
||||
require.Equal(t, 2, *parsed.PartialImages)
|
||||
require.True(t, parsed.HasMask)
|
||||
require.Equal(t, OpenAIImagesCapabilityNative, parsed.RequiredCapability)
|
||||
}
|
||||
|
||||
func TestCollectOpenAIImagePointers_RecognizesDirectAssets(t *testing.T) {
|
||||
items := collectOpenAIImagePointers([]byte(`{
|
||||
"revised_prompt": "cat astronaut",
|
||||
@@ -157,3 +247,472 @@ func TestResolveOpenAIImageBytes_PrefersInlineBase64(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("ABC"), data)
|
||||
}
|
||||
|
||||
func TestAccountSupportsOpenAIImageCapability_OAuthSupportsNative(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
}
|
||||
|
||||
require.True(t, account.SupportsOpenAIImageCapability(OpenAIImagesCapabilityBasic))
|
||||
require.True(t, account.SupportsOpenAIImageCapability(OpenAIImagesCapabilityNative))
|
||||
}
|
||||
|
||||
type openAIImageTestSSEEvent struct {
|
||||
Name string
|
||||
Data string
|
||||
}
|
||||
|
||||
func parseOpenAIImageTestSSEEvents(body string) []openAIImageTestSSEEvent {
|
||||
chunks := strings.Split(body, "\n\n")
|
||||
events := make([]openAIImageTestSSEEvent, 0, len(chunks))
|
||||
for _, chunk := range chunks {
|
||||
chunk = strings.TrimSpace(chunk)
|
||||
if chunk == "" {
|
||||
continue
|
||||
}
|
||||
var event openAIImageTestSSEEvent
|
||||
for _, line := range strings.Split(chunk, "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "event: "):
|
||||
event.Name = strings.TrimSpace(strings.TrimPrefix(line, "event: "))
|
||||
case strings.HasPrefix(line, "data: "):
|
||||
event.Data = strings.TrimSpace(strings.TrimPrefix(line, "data: "))
|
||||
}
|
||||
}
|
||||
if event.Name != "" || event.Data != "" {
|
||||
events = append(events, event)
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func findOpenAIImageTestSSEEvent(events []openAIImageTestSSEEvent, name string) (openAIImageTestSSEEvent, bool) {
|
||||
for _, event := range events {
|
||||
if event.Name == name {
|
||||
return event, true
|
||||
}
|
||||
}
|
||||
return openAIImageTestSSEEvent{}, false
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceForwardImages_OAuthUsesResponsesAPI(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"1024x1024","quality":"high","n":2}`)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("api_key", &APIKey{ID: 42})
|
||||
|
||||
svc := &OpenAIGatewayService{}
|
||||
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
|
||||
require.NoError(t, err)
|
||||
|
||||
upstream := &httpUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"text/event-stream"},
|
||||
"X-Request-Id": []string{"req_img_123"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000000,\"usage\":{\"input_tokens\":11,\"output_tokens\":22,\"input_tokens_details\":{\"cached_tokens\":3},\"output_tokens_details\":{\"image_tokens\":7}},\"tool_usage\":{\"image_gen\":{\"images\":1}},\"output\":[{\"type\":\"image_generation_call\",\"result\":\"aGVsbG8=\",\"revised_prompt\":\"draw a cat\",\"output_format\":\"png\",\"quality\":\"high\",\"size\":\"1024x1024\"}]}}\n\n" +
|
||||
"data: [DONE]\n\n",
|
||||
)),
|
||||
},
|
||||
}
|
||||
svc.httpUpstream = upstream
|
||||
|
||||
account := &Account{
|
||||
ID: 1,
|
||||
Name: "openai-oauth",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token-123",
|
||||
"chatgpt_account_id": "acct-123",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, "gpt-image-2", result.Model)
|
||||
require.Equal(t, "gpt-image-2", result.UpstreamModel)
|
||||
require.Equal(t, 1, result.ImageCount)
|
||||
require.Equal(t, 11, result.Usage.InputTokens)
|
||||
require.Equal(t, 22, result.Usage.OutputTokens)
|
||||
require.Equal(t, 7, result.Usage.ImageOutputTokens)
|
||||
|
||||
require.NotNil(t, upstream.lastReq)
|
||||
require.Equal(t, chatgptCodexURL, upstream.lastReq.URL.String())
|
||||
require.Equal(t, "chatgpt.com", upstream.lastReq.Host)
|
||||
require.Equal(t, "application/json", upstream.lastReq.Header.Get("Content-Type"))
|
||||
require.Equal(t, "text/event-stream", upstream.lastReq.Header.Get("Accept"))
|
||||
require.Equal(t, "acct-123", upstream.lastReq.Header.Get("chatgpt-account-id"))
|
||||
require.Equal(t, "responses=experimental", upstream.lastReq.Header.Get("OpenAI-Beta"))
|
||||
|
||||
require.Equal(t, openAIImagesResponsesMainModel, gjson.GetBytes(upstream.lastBody, "model").String())
|
||||
require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool())
|
||||
require.Equal(t, "image_generation", gjson.GetBytes(upstream.lastBody, "tools.0.type").String())
|
||||
require.Equal(t, "generate", gjson.GetBytes(upstream.lastBody, "tools.0.action").String())
|
||||
require.Equal(t, "gpt-image-2", gjson.GetBytes(upstream.lastBody, "tools.0.model").String())
|
||||
require.Equal(t, "1024x1024", gjson.GetBytes(upstream.lastBody, "tools.0.size").String())
|
||||
require.Equal(t, "high", gjson.GetBytes(upstream.lastBody, "tools.0.quality").String())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "tools.0.n").Exists())
|
||||
require.Equal(t, "draw a cat", gjson.GetBytes(upstream.lastBody, "input.0.content.0.text").String())
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "gpt-image-2", gjson.Get(rec.Body.String(), "model").String())
|
||||
require.Equal(t, "aGVsbG8=", gjson.Get(rec.Body.String(), "data.0.b64_json").String())
|
||||
require.Equal(t, "draw a cat", gjson.Get(rec.Body.String(), "data.0.revised_prompt").String())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceForwardImages_OAuthStreamingTransformsEvents(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"url"}`)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
|
||||
svc := &OpenAIGatewayService{}
|
||||
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
|
||||
require.NoError(t, err)
|
||||
|
||||
upstream := &httpUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"text/event-stream"},
|
||||
"X-Request-Id": []string{"req_img_stream"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"created_at\":1710000001,\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-2\",\"background\":\"auto\",\"output_format\":\"png\",\"quality\":\"high\",\"size\":\"1024x1024\"}]}}\n\n" +
|
||||
"data: {\"type\":\"response.image_generation_call.partial_image\",\"partial_image_b64\":\"cGFydGlhbA==\",\"partial_image_index\":0,\"output_format\":\"png\",\"background\":\"auto\"}\n\n" +
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000001,\"usage\":{\"input_tokens\":5,\"output_tokens\":9,\"output_tokens_details\":{\"image_tokens\":4}},\"tool_usage\":{\"image_gen\":{\"images\":1}},\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-2\",\"background\":\"auto\",\"output_format\":\"png\",\"quality\":\"high\",\"size\":\"1024x1024\"}],\"output\":[{\"type\":\"image_generation_call\",\"result\":\"ZmluYWw=\",\"output_format\":\"png\"}]}}\n\n" +
|
||||
"data: [DONE]\n\n",
|
||||
)),
|
||||
},
|
||||
}
|
||||
svc.httpUpstream = upstream
|
||||
|
||||
account := &Account{
|
||||
ID: 2,
|
||||
Name: "openai-oauth",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token-123",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.True(t, result.Stream)
|
||||
require.Equal(t, 1, result.ImageCount)
|
||||
events := parseOpenAIImageTestSSEEvents(rec.Body.String())
|
||||
partial, ok := findOpenAIImageTestSSEEvent(events, "image_generation.partial_image")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "image_generation.partial_image", gjson.Get(partial.Data, "type").String())
|
||||
require.Equal(t, int64(1710000001), gjson.Get(partial.Data, "created_at").Int())
|
||||
require.Equal(t, "cGFydGlhbA==", gjson.Get(partial.Data, "b64_json").String())
|
||||
require.Equal(t, "data:image/png;base64,cGFydGlhbA==", gjson.Get(partial.Data, "url").String())
|
||||
require.Equal(t, "gpt-image-2", gjson.Get(partial.Data, "model").String())
|
||||
require.Equal(t, "png", gjson.Get(partial.Data, "output_format").String())
|
||||
require.Equal(t, "high", gjson.Get(partial.Data, "quality").String())
|
||||
require.Equal(t, "1024x1024", gjson.Get(partial.Data, "size").String())
|
||||
require.Equal(t, "auto", gjson.Get(partial.Data, "background").String())
|
||||
|
||||
completed, ok := findOpenAIImageTestSSEEvent(events, "image_generation.completed")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "image_generation.completed", gjson.Get(completed.Data, "type").String())
|
||||
require.Equal(t, int64(1710000001), gjson.Get(completed.Data, "created_at").Int())
|
||||
require.Equal(t, "ZmluYWw=", gjson.Get(completed.Data, "b64_json").String())
|
||||
require.Equal(t, "data:image/png;base64,ZmluYWw=", gjson.Get(completed.Data, "url").String())
|
||||
require.Equal(t, "gpt-image-2", gjson.Get(completed.Data, "model").String())
|
||||
require.Equal(t, "png", gjson.Get(completed.Data, "output_format").String())
|
||||
require.Equal(t, "high", gjson.Get(completed.Data, "quality").String())
|
||||
require.Equal(t, "1024x1024", gjson.Get(completed.Data, "size").String())
|
||||
require.Equal(t, "auto", gjson.Get(completed.Data, "background").String())
|
||||
require.JSONEq(t, `{"images":1}`, gjson.Get(completed.Data, "usage").Raw)
|
||||
require.False(t, gjson.Get(completed.Data, "revised_prompt").Exists())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceForwardImages_OAuthEditsMultipartUsesResponsesAPI(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
require.NoError(t, writer.WriteField("model", "gpt-image-2"))
|
||||
require.NoError(t, writer.WriteField("prompt", "replace background with aurora"))
|
||||
require.NoError(t, writer.WriteField("input_fidelity", "high"))
|
||||
require.NoError(t, writer.WriteField("output_format", "webp"))
|
||||
require.NoError(t, writer.WriteField("quality", "high"))
|
||||
|
||||
imageHeader := make(textproto.MIMEHeader)
|
||||
imageHeader.Set("Content-Disposition", `form-data; name="image"; filename="source.png"`)
|
||||
imageHeader.Set("Content-Type", "image/png")
|
||||
imagePart, err := writer.CreatePart(imageHeader)
|
||||
require.NoError(t, err)
|
||||
_, err = imagePart.Write([]byte("png-image-content"))
|
||||
require.NoError(t, err)
|
||||
|
||||
maskHeader := make(textproto.MIMEHeader)
|
||||
maskHeader.Set("Content-Disposition", `form-data; name="mask"; filename="mask.png"`)
|
||||
maskHeader.Set("Content-Type", "image/png")
|
||||
maskPart, err := writer.CreatePart(maskHeader)
|
||||
require.NoError(t, err)
|
||||
_, err = maskPart.Write([]byte("png-mask-content"))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, writer.Close())
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/images/edits", bytes.NewReader(body.Bytes()))
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("api_key", &APIKey{ID: 100})
|
||||
|
||||
svc := &OpenAIGatewayService{}
|
||||
parsed, err := svc.ParseOpenAIImagesRequest(c, body.Bytes())
|
||||
require.NoError(t, err)
|
||||
|
||||
upstream := &httpUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"text/event-stream"},
|
||||
"X-Request-Id": []string{"req_img_edit_123"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000002,\"usage\":{\"input_tokens\":13,\"output_tokens\":21,\"output_tokens_details\":{\"image_tokens\":8}},\"tool_usage\":{\"image_gen\":{\"images\":1}},\"output\":[{\"type\":\"image_generation_call\",\"result\":\"ZWRpdGVk\",\"revised_prompt\":\"replace background with aurora\",\"output_format\":\"webp\",\"quality\":\"high\"}]}}\n\n" +
|
||||
"data: [DONE]\n\n",
|
||||
)),
|
||||
},
|
||||
}
|
||||
svc.httpUpstream = upstream
|
||||
|
||||
account := &Account{
|
||||
ID: 3,
|
||||
Name: "openai-oauth",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token-123",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.ForwardImages(context.Background(), c, account, body.Bytes(), parsed, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, 1, result.ImageCount)
|
||||
require.Equal(t, "gpt-image-2", gjson.GetBytes(upstream.lastBody, "tools.0.model").String())
|
||||
require.Equal(t, "edit", gjson.GetBytes(upstream.lastBody, "tools.0.action").String())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "tools.0.input_fidelity").Exists())
|
||||
require.Equal(t, "webp", gjson.GetBytes(upstream.lastBody, "tools.0.output_format").String())
|
||||
require.True(t, strings.HasPrefix(gjson.GetBytes(upstream.lastBody, "input.0.content.1.image_url").String(), "data:image/png;base64,"))
|
||||
require.True(t, strings.HasPrefix(gjson.GetBytes(upstream.lastBody, "tools.0.input_image_mask.image_url").String(), "data:image/png;base64,"))
|
||||
require.Equal(t, "replace background with aurora", gjson.GetBytes(upstream.lastBody, "input.0.content.0.text").String())
|
||||
require.Equal(t, "ZWRpdGVk", gjson.Get(rec.Body.String(), "data.0.b64_json").String())
|
||||
require.Equal(t, "replace background with aurora", gjson.Get(rec.Body.String(), "data.0.revised_prompt").String())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceForwardImages_OAuthEditsStreamingTransformsEvents(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{
|
||||
"model":"gpt-image-2",
|
||||
"prompt":"replace background with aurora",
|
||||
"images":[{"image_url":"https://example.com/source.png"}],
|
||||
"mask":{"image_url":"https://example.com/mask.png"},
|
||||
"stream":true,
|
||||
"response_format":"url"
|
||||
}`)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/images/edits", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
|
||||
svc := &OpenAIGatewayService{}
|
||||
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
|
||||
require.NoError(t, err)
|
||||
|
||||
upstream := &httpUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"text/event-stream"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"created_at\":1710000003,\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-2\",\"background\":\"transparent\",\"output_format\":\"webp\",\"quality\":\"high\",\"size\":\"1024x1024\"}]}}\n\n" +
|
||||
"data: {\"type\":\"response.image_generation_call.partial_image\",\"partial_image_b64\":\"cGFydGlhbA==\",\"partial_image_index\":0,\"output_format\":\"webp\",\"background\":\"transparent\"}\n\n" +
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000003,\"usage\":{\"input_tokens\":7,\"output_tokens\":10,\"output_tokens_details\":{\"image_tokens\":5}},\"tool_usage\":{\"image_gen\":{\"images\":1}},\"tools\":[{\"type\":\"image_generation\",\"model\":\"gpt-image-2\",\"background\":\"transparent\",\"output_format\":\"webp\",\"quality\":\"high\",\"size\":\"1024x1024\"}],\"output\":[{\"type\":\"image_generation_call\",\"result\":\"ZWRpdGVk\",\"revised_prompt\":\"replace background with aurora\",\"output_format\":\"webp\"}]}}\n\n" +
|
||||
"data: [DONE]\n\n",
|
||||
)),
|
||||
},
|
||||
}
|
||||
svc.httpUpstream = upstream
|
||||
|
||||
account := &Account{
|
||||
ID: 4,
|
||||
Name: "openai-oauth",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token-123",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, 1, result.ImageCount)
|
||||
require.Equal(t, "edit", gjson.GetBytes(upstream.lastBody, "tools.0.action").String())
|
||||
require.Equal(t, "https://example.com/source.png", gjson.GetBytes(upstream.lastBody, "input.0.content.1.image_url").String())
|
||||
require.Equal(t, "https://example.com/mask.png", gjson.GetBytes(upstream.lastBody, "tools.0.input_image_mask.image_url").String())
|
||||
events := parseOpenAIImageTestSSEEvents(rec.Body.String())
|
||||
partial, ok := findOpenAIImageTestSSEEvent(events, "image_edit.partial_image")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "image_edit.partial_image", gjson.Get(partial.Data, "type").String())
|
||||
require.Equal(t, int64(1710000003), gjson.Get(partial.Data, "created_at").Int())
|
||||
require.Equal(t, "cGFydGlhbA==", gjson.Get(partial.Data, "b64_json").String())
|
||||
require.Equal(t, "data:image/webp;base64,cGFydGlhbA==", gjson.Get(partial.Data, "url").String())
|
||||
require.Equal(t, "gpt-image-2", gjson.Get(partial.Data, "model").String())
|
||||
require.Equal(t, "webp", gjson.Get(partial.Data, "output_format").String())
|
||||
require.Equal(t, "high", gjson.Get(partial.Data, "quality").String())
|
||||
require.Equal(t, "1024x1024", gjson.Get(partial.Data, "size").String())
|
||||
require.Equal(t, "transparent", gjson.Get(partial.Data, "background").String())
|
||||
|
||||
completed, ok := findOpenAIImageTestSSEEvent(events, "image_edit.completed")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "image_edit.completed", gjson.Get(completed.Data, "type").String())
|
||||
require.Equal(t, int64(1710000003), gjson.Get(completed.Data, "created_at").Int())
|
||||
require.Equal(t, "ZWRpdGVk", gjson.Get(completed.Data, "b64_json").String())
|
||||
require.Equal(t, "data:image/webp;base64,ZWRpdGVk", gjson.Get(completed.Data, "url").String())
|
||||
require.Equal(t, "gpt-image-2", gjson.Get(completed.Data, "model").String())
|
||||
require.Equal(t, "webp", gjson.Get(completed.Data, "output_format").String())
|
||||
require.Equal(t, "high", gjson.Get(completed.Data, "quality").String())
|
||||
require.Equal(t, "1024x1024", gjson.Get(completed.Data, "size").String())
|
||||
require.Equal(t, "transparent", gjson.Get(completed.Data, "background").String())
|
||||
require.JSONEq(t, `{"images":1}`, gjson.Get(completed.Data, "usage").Raw)
|
||||
require.False(t, gjson.Get(completed.Data, "revised_prompt").Exists())
|
||||
}
|
||||
|
||||
func TestBuildOpenAIImagesResponsesRequest_DowngradesMultipleImagesToSingle(t *testing.T) {
|
||||
parsed := &OpenAIImagesRequest{
|
||||
Endpoint: openAIImagesGenerationsEndpoint,
|
||||
Model: "gpt-image-2",
|
||||
Prompt: "draw a cat",
|
||||
N: 2,
|
||||
}
|
||||
|
||||
body, err := buildOpenAIImagesResponsesRequest(parsed, "gpt-image-2")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, body)
|
||||
require.False(t, gjson.GetBytes(body, "tools.0.n").Exists())
|
||||
require.Equal(t, "gpt-image-2", gjson.GetBytes(body, "tools.0.model").String())
|
||||
require.Equal(t, "draw a cat", gjson.GetBytes(body, "input.0.content.0.text").String())
|
||||
}
|
||||
|
||||
func TestBuildOpenAIImagesResponsesRequest_StripsInputFidelity(t *testing.T) {
|
||||
parsed := &OpenAIImagesRequest{
|
||||
Endpoint: openAIImagesEditsEndpoint,
|
||||
Model: "gpt-image-2",
|
||||
Prompt: "replace background",
|
||||
InputFidelity: "high",
|
||||
InputImageURLs: []string{
|
||||
"https://example.com/source.png",
|
||||
},
|
||||
}
|
||||
|
||||
body, err := buildOpenAIImagesResponsesRequest(parsed, "gpt-image-2")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, body)
|
||||
require.False(t, gjson.GetBytes(body, "tools.0.input_fidelity").Exists())
|
||||
require.Equal(t, "edit", gjson.GetBytes(body, "tools.0.action").String())
|
||||
}
|
||||
|
||||
func TestCollectOpenAIImagesFromResponsesBody_FallsBackToOutputItemDone(t *testing.T) {
|
||||
body := []byte(
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"created_at\":1710000004}}\n\n" +
|
||||
"data: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"ig_123\",\"type\":\"image_generation_call\",\"result\":\"aGVsbG8=\",\"revised_prompt\":\"draw a cat\",\"output_format\":\"png\",\"quality\":\"high\"}}\n\n" +
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000004,\"tool_usage\":{\"image_gen\":{\"images\":1}},\"output\":[]}}\n\n" +
|
||||
"data: [DONE]\n\n",
|
||||
)
|
||||
|
||||
results, createdAt, usageRaw, firstMeta, foundFinal, err := collectOpenAIImagesFromResponsesBody(body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, foundFinal)
|
||||
require.Equal(t, int64(1710000004), createdAt)
|
||||
require.Len(t, results, 1)
|
||||
require.Equal(t, "aGVsbG8=", results[0].Result)
|
||||
require.Equal(t, "draw a cat", results[0].RevisedPrompt)
|
||||
require.Equal(t, "png", firstMeta.OutputFormat)
|
||||
require.JSONEq(t, `{"images":1}`, string(usageRaw))
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceForwardImages_OAuthStreamingHandlesOutputItemDoneFallback(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"url"}`)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
|
||||
svc := &OpenAIGatewayService{}
|
||||
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
|
||||
require.NoError(t, err)
|
||||
|
||||
upstream := &httpUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"text/event-stream"},
|
||||
"X-Request-Id": []string{"req_img_stream_output_item_done"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
"data: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"ig_123\",\"type\":\"image_generation_call\",\"result\":\"ZmluYWw=\",\"revised_prompt\":\"draw a cat\",\"output_format\":\"png\"}}\n\n" +
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000005,\"usage\":{\"input_tokens\":5,\"output_tokens\":9,\"output_tokens_details\":{\"image_tokens\":4}},\"tool_usage\":{\"image_gen\":{\"images\":1}},\"output\":[]}}\n\n" +
|
||||
"data: [DONE]\n\n",
|
||||
)),
|
||||
},
|
||||
}
|
||||
svc.httpUpstream = upstream
|
||||
|
||||
account := &Account{
|
||||
ID: 5,
|
||||
Name: "openai-oauth",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token-123",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.True(t, result.Stream)
|
||||
require.Equal(t, 1, result.ImageCount)
|
||||
events := parseOpenAIImageTestSSEEvents(rec.Body.String())
|
||||
completed, ok := findOpenAIImageTestSSEEvent(events, "image_generation.completed")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "image_generation.completed", gjson.Get(completed.Data, "type").String())
|
||||
require.Equal(t, int64(1710000005), gjson.Get(completed.Data, "created_at").Int())
|
||||
require.Equal(t, "ZmluYWw=", gjson.Get(completed.Data, "b64_json").String())
|
||||
require.Equal(t, "data:image/png;base64,ZmluYWw=", gjson.Get(completed.Data, "url").String())
|
||||
require.Equal(t, "gpt-image-2", gjson.Get(completed.Data, "model").String())
|
||||
require.JSONEq(t, `{"images":1}`, gjson.Get(completed.Data, "usage").Raw)
|
||||
require.NotContains(t, rec.Body.String(), "event: error")
|
||||
}
|
||||
|
||||
@@ -794,6 +794,13 @@ func (s *PricingService) matchOpenAIModel(model string) *LiteLLMModelPricing {
|
||||
}
|
||||
}
|
||||
|
||||
// GPT-5.5 回退到 GPT-5.4 定价
|
||||
if strings.HasPrefix(model, "gpt-5.5") {
|
||||
logger.With(zap.String("component", "service.pricing")).
|
||||
Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.4(static)"))
|
||||
return openAIGPT54FallbackPricing
|
||||
}
|
||||
|
||||
if strings.HasPrefix(model, "gpt-5.4-mini") {
|
||||
logger.With(zap.String("component", "service.pricing")).
|
||||
Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.4-mini(static)"))
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -23,6 +25,7 @@ type RateLimitService struct {
|
||||
geminiQuotaService *GeminiQuotaService
|
||||
tempUnschedCache TempUnschedCache
|
||||
timeoutCounterCache TimeoutCounterCache
|
||||
openAI403CounterCache OpenAI403CounterCache
|
||||
settingService *SettingService
|
||||
tokenCacheInvalidator TokenCacheInvalidator
|
||||
usageCacheMu sync.RWMutex
|
||||
@@ -52,6 +55,12 @@ type geminiUsageTotalsBatchProvider interface {
|
||||
|
||||
const geminiPrecheckCacheTTL = time.Minute
|
||||
|
||||
const (
|
||||
openAI403CooldownMinutesDefault = 10
|
||||
openAI403DisableThreshold = 3
|
||||
openAI403CounterWindowMinutes = 180
|
||||
)
|
||||
|
||||
// NewRateLimitService 创建RateLimitService实例
|
||||
func NewRateLimitService(accountRepo AccountRepository, usageRepo UsageLogRepository, cfg *config.Config, geminiQuotaService *GeminiQuotaService, tempUnschedCache TempUnschedCache) *RateLimitService {
|
||||
return &RateLimitService{
|
||||
@@ -69,6 +78,11 @@ func (s *RateLimitService) SetTimeoutCounterCache(cache TimeoutCounterCache) {
|
||||
s.timeoutCounterCache = cache
|
||||
}
|
||||
|
||||
// SetOpenAI403CounterCache 设置 OpenAI 403 连续失败计数器(可选依赖)
|
||||
func (s *RateLimitService) SetOpenAI403CounterCache(cache OpenAI403CounterCache) {
|
||||
s.openAI403CounterCache = cache
|
||||
}
|
||||
|
||||
// SetSettingService 设置系统设置服务(可选依赖)
|
||||
func (s *RateLimitService) SetSettingService(settingService *SettingService) {
|
||||
s.settingService = settingService
|
||||
@@ -655,6 +669,30 @@ func (s *RateLimitService) handleAuthError(ctx context.Context, account *Account
|
||||
slog.Warn("account_disabled_auth_error", "account_id", account.ID, "error", errorMsg)
|
||||
}
|
||||
|
||||
func buildForbiddenErrorMessage(prefix string, upstreamMsg string, responseBody []byte, fallback string) string {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if prefix != "" && !strings.HasSuffix(prefix, " ") {
|
||||
prefix += " "
|
||||
}
|
||||
|
||||
if msg := strings.TrimSpace(upstreamMsg); msg != "" {
|
||||
return prefix + msg
|
||||
}
|
||||
|
||||
rawBody := bytes.TrimSpace(responseBody)
|
||||
if len(rawBody) > 0 {
|
||||
if json.Valid(rawBody) {
|
||||
var compact bytes.Buffer
|
||||
if err := json.Compact(&compact, rawBody); err == nil {
|
||||
return prefix + truncateForLog(compact.Bytes(), 512)
|
||||
}
|
||||
}
|
||||
return prefix + truncateForLog(rawBody, 512)
|
||||
}
|
||||
|
||||
return prefix + fallback
|
||||
}
|
||||
|
||||
// handle403 处理 403 Forbidden 错误
|
||||
// Antigravity 平台区分 validation/violation/generic 三种类型,均 SetError 永久禁用;
|
||||
// 其他平台保持原有 SetError 行为。
|
||||
@@ -662,15 +700,64 @@ func (s *RateLimitService) handle403(ctx context.Context, account *Account, upst
|
||||
if account.Platform == PlatformAntigravity {
|
||||
return s.handleAntigravity403(ctx, account, upstreamMsg, responseBody)
|
||||
}
|
||||
// 非 Antigravity 平台:保持原有行为
|
||||
msg := "Access forbidden (403): account may be suspended or lack permissions"
|
||||
if upstreamMsg != "" {
|
||||
msg = "Access forbidden (403): " + upstreamMsg
|
||||
if account.Platform == PlatformOpenAI {
|
||||
return s.handleOpenAI403(ctx, account, upstreamMsg, responseBody)
|
||||
}
|
||||
// 非 Antigravity 平台:保持原有行为
|
||||
msg := buildForbiddenErrorMessage(
|
||||
"Access forbidden (403):",
|
||||
upstreamMsg,
|
||||
responseBody,
|
||||
"account may be suspended or lack permissions",
|
||||
)
|
||||
s.handleAuthError(ctx, account, msg)
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *RateLimitService) handleOpenAI403(ctx context.Context, account *Account, upstreamMsg string, responseBody []byte) (shouldDisable bool) {
|
||||
msg := buildForbiddenErrorMessage(
|
||||
"Access forbidden (403):",
|
||||
upstreamMsg,
|
||||
responseBody,
|
||||
"account may be suspended or lack permissions",
|
||||
)
|
||||
|
||||
if s.openAI403CounterCache == nil {
|
||||
s.handleAuthError(ctx, account, msg)
|
||||
return true
|
||||
}
|
||||
|
||||
count, err := s.openAI403CounterCache.IncrementOpenAI403Count(ctx, account.ID, openAI403CounterWindowMinutes)
|
||||
if err != nil {
|
||||
slog.Warn("openai_403_increment_failed", "account_id", account.ID, "error", err)
|
||||
s.handleAuthError(ctx, account, msg)
|
||||
return true
|
||||
}
|
||||
|
||||
if count >= openAI403DisableThreshold {
|
||||
msg = fmt.Sprintf("%s | consecutive_403=%d/%d", msg, count, openAI403DisableThreshold)
|
||||
s.handleAuthError(ctx, account, msg)
|
||||
return true
|
||||
}
|
||||
|
||||
until := time.Now().Add(time.Duration(openAI403CooldownMinutesDefault) * time.Minute)
|
||||
reason := fmt.Sprintf("OpenAI 403 temporary cooldown (%d/%d): %s", count, openAI403DisableThreshold, msg)
|
||||
if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, reason); err != nil {
|
||||
slog.Warn("openai_403_set_temp_unschedulable_failed", "account_id", account.ID, "error", err)
|
||||
s.handleAuthError(ctx, account, msg)
|
||||
return true
|
||||
}
|
||||
|
||||
slog.Warn(
|
||||
"openai_403_temp_unschedulable",
|
||||
"account_id", account.ID,
|
||||
"until", until,
|
||||
"count", count,
|
||||
"threshold", openAI403DisableThreshold,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
// handleAntigravity403 处理 Antigravity 平台的 403 错误
|
||||
// validation(需要验证)→ 永久 SetError(需人工去 Google 验证后恢复)
|
||||
// violation(违规封号)→ 永久 SetError(需人工处理)
|
||||
@@ -681,10 +768,12 @@ func (s *RateLimitService) handleAntigravity403(ctx context.Context, account *Ac
|
||||
switch fbType {
|
||||
case forbiddenTypeValidation:
|
||||
// VALIDATION_REQUIRED: 永久禁用,需人工去 Google 验证后手动恢复
|
||||
msg := "Validation required (403): account needs Google verification"
|
||||
if upstreamMsg != "" {
|
||||
msg = "Validation required (403): " + upstreamMsg
|
||||
}
|
||||
msg := buildForbiddenErrorMessage(
|
||||
"Validation required (403):",
|
||||
upstreamMsg,
|
||||
responseBody,
|
||||
"account needs Google verification",
|
||||
)
|
||||
if validationURL := extractValidationURL(string(responseBody)); validationURL != "" {
|
||||
msg += " | validation_url: " + validationURL
|
||||
}
|
||||
@@ -693,19 +782,23 @@ func (s *RateLimitService) handleAntigravity403(ctx context.Context, account *Ac
|
||||
|
||||
case forbiddenTypeViolation:
|
||||
// 违规封号: 永久禁用,需人工处理
|
||||
msg := "Account violation (403): terms of service violation"
|
||||
if upstreamMsg != "" {
|
||||
msg = "Account violation (403): " + upstreamMsg
|
||||
}
|
||||
msg := buildForbiddenErrorMessage(
|
||||
"Account violation (403):",
|
||||
upstreamMsg,
|
||||
responseBody,
|
||||
"terms of service violation",
|
||||
)
|
||||
s.handleAuthError(ctx, account, msg)
|
||||
return true
|
||||
|
||||
default:
|
||||
// 通用 403: 保持原有行为
|
||||
msg := "Access forbidden (403): account may be suspended or lack permissions"
|
||||
if upstreamMsg != "" {
|
||||
msg = "Access forbidden (403): " + upstreamMsg
|
||||
}
|
||||
msg := buildForbiddenErrorMessage(
|
||||
"Access forbidden (403):",
|
||||
upstreamMsg,
|
||||
responseBody,
|
||||
"account may be suspended or lack permissions",
|
||||
)
|
||||
s.handleAuthError(ctx, account, msg)
|
||||
return true
|
||||
}
|
||||
@@ -1221,9 +1314,19 @@ func (s *RateLimitService) ClearRateLimit(ctx context.Context, accountID int64)
|
||||
slog.Warn("temp_unsched_cache_delete_failed", "account_id", accountID, "error", err)
|
||||
}
|
||||
}
|
||||
s.ResetOpenAI403Counter(ctx, accountID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RateLimitService) ResetOpenAI403Counter(ctx context.Context, accountID int64) {
|
||||
if s == nil || s.openAI403CounterCache == nil || accountID <= 0 {
|
||||
return
|
||||
}
|
||||
if err := s.openAI403CounterCache.ResetOpenAI403Count(ctx, accountID); err != nil {
|
||||
slog.Warn("openai_403_reset_failed", "account_id", accountID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// RecoverAccountState 按需恢复账号的可恢复运行时状态。
|
||||
func (s *RateLimitService) RecoverAccountState(ctx context.Context, accountID int64, options AccountRecoveryOptions) (*SuccessfulTestRecoveryResult, error) {
|
||||
account, err := s.accountRepo.GetByID(ctx, accountID)
|
||||
@@ -1250,6 +1353,9 @@ func (s *RateLimitService) RecoverAccountState(ctx context.Context, accountID in
|
||||
}
|
||||
result.ClearedRateLimit = true
|
||||
}
|
||||
if result.ClearedError || result.ClearedRateLimit {
|
||||
s.ResetOpenAI403Counter(ctx, accountID)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ type rateLimitAccountRepoStub struct {
|
||||
updateCredentialsCalls int
|
||||
lastCredentials map[string]any
|
||||
lastErrorMsg string
|
||||
lastTempReason string
|
||||
}
|
||||
|
||||
func (r *rateLimitAccountRepoStub) SetError(ctx context.Context, id int64, errorMsg string) error {
|
||||
@@ -30,6 +31,7 @@ func (r *rateLimitAccountRepoStub) SetError(ctx context.Context, id int64, error
|
||||
|
||||
func (r *rateLimitAccountRepoStub) SetTempUnschedulable(ctx context.Context, id int64, until time.Time, reason string) error {
|
||||
r.tempCalls++
|
||||
r.lastTempReason = reason
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -44,6 +46,29 @@ type tokenCacheInvalidatorRecorder struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type openAI403CounterCacheStub struct {
|
||||
counts []int64
|
||||
resetCalls []int64
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *openAI403CounterCacheStub) IncrementOpenAI403Count(_ context.Context, _ int64, _ int) (int64, error) {
|
||||
if s.err != nil {
|
||||
return 0, s.err
|
||||
}
|
||||
if len(s.counts) == 0 {
|
||||
return 1, nil
|
||||
}
|
||||
count := s.counts[0]
|
||||
s.counts = s.counts[1:]
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *openAI403CounterCacheStub) ResetOpenAI403Count(_ context.Context, accountID int64) error {
|
||||
s.resetCalls = append(s.resetCalls, accountID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *tokenCacheInvalidatorRecorder) InvalidateToken(ctx context.Context, account *Account) error {
|
||||
r.accounts = append(r.accounts, account)
|
||||
return r.err
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRateLimitService_HandleUpstreamError_OpenAI403FirstHitTempUnschedulable(t *testing.T) {
|
||||
repo := &rateLimitAccountRepoStub{}
|
||||
counter := &openAI403CounterCacheStub{counts: []int64{1}}
|
||||
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
|
||||
service.SetOpenAI403CounterCache(counter)
|
||||
account := &Account{
|
||||
ID: 301,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
}
|
||||
|
||||
shouldDisable := service.HandleUpstreamError(
|
||||
context.Background(),
|
||||
account,
|
||||
http.StatusForbidden,
|
||||
http.Header{},
|
||||
[]byte(`{"error":{"message":"temporary edge rejection"}}`),
|
||||
)
|
||||
|
||||
require.True(t, shouldDisable)
|
||||
require.Equal(t, 0, repo.setErrorCalls)
|
||||
require.Equal(t, 1, repo.tempCalls)
|
||||
require.Contains(t, repo.lastTempReason, "temporary edge rejection")
|
||||
require.Contains(t, repo.lastTempReason, "(1/3)")
|
||||
}
|
||||
|
||||
func TestRateLimitService_HandleUpstreamError_OpenAI403ThresholdDisables(t *testing.T) {
|
||||
repo := &rateLimitAccountRepoStub{}
|
||||
counter := &openAI403CounterCacheStub{counts: []int64{3}}
|
||||
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
|
||||
service.SetOpenAI403CounterCache(counter)
|
||||
account := &Account{
|
||||
ID: 302,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
}
|
||||
|
||||
shouldDisable := service.HandleUpstreamError(
|
||||
context.Background(),
|
||||
account,
|
||||
http.StatusForbidden,
|
||||
http.Header{},
|
||||
[]byte(`{"error":{"message":"workspace forbidden by policy"}}`),
|
||||
)
|
||||
|
||||
require.True(t, shouldDisable)
|
||||
require.Equal(t, 1, repo.setErrorCalls)
|
||||
require.Equal(t, 0, repo.tempCalls)
|
||||
require.Contains(t, repo.lastErrorMsg, "workspace forbidden by policy")
|
||||
require.Contains(t, repo.lastErrorMsg, "consecutive_403=3/3")
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCalculateOpenAI429ResetTime_7dExhausted(t *testing.T) {
|
||||
@@ -259,6 +262,53 @@ func TestNormalizedCodexLimits_OnlyPrimaryData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitService_HandleUpstreamError_403PreservesOriginalUpstreamMessage(t *testing.T) {
|
||||
repo := &rateLimitAccountRepoStub{}
|
||||
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
|
||||
account := &Account{
|
||||
ID: 201,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
}
|
||||
|
||||
shouldDisable := service.HandleUpstreamError(
|
||||
context.Background(),
|
||||
account,
|
||||
403,
|
||||
http.Header{},
|
||||
[]byte(`{"error":{"message":"workspace forbidden by policy","type":"invalid_request_error"}}`),
|
||||
)
|
||||
|
||||
require.True(t, shouldDisable)
|
||||
require.Equal(t, 1, repo.setErrorCalls)
|
||||
require.Contains(t, repo.lastErrorMsg, "workspace forbidden by policy")
|
||||
require.NotContains(t, repo.lastErrorMsg, "account may be suspended or lack permissions")
|
||||
}
|
||||
|
||||
func TestRateLimitService_HandleUpstreamError_403FallsBackToRawBody(t *testing.T) {
|
||||
repo := &rateLimitAccountRepoStub{}
|
||||
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
|
||||
account := &Account{
|
||||
ID: 202,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
}
|
||||
|
||||
shouldDisable := service.HandleUpstreamError(
|
||||
context.Background(),
|
||||
account,
|
||||
403,
|
||||
http.Header{},
|
||||
[]byte(`{"error":{"type":"access_denied","details":{"reason":"ip_blocked"}}}`),
|
||||
)
|
||||
|
||||
require.True(t, shouldDisable)
|
||||
require.Equal(t, 1, repo.setErrorCalls)
|
||||
require.Contains(t, repo.lastErrorMsg, `"access_denied"`)
|
||||
require.Contains(t, repo.lastErrorMsg, `"ip_blocked"`)
|
||||
require.NotContains(t, repo.lastErrorMsg, "account may be suspended or lack permissions")
|
||||
}
|
||||
|
||||
func TestNormalizedCodexLimits_OnlySecondaryData(t *testing.T) {
|
||||
// Test when only secondary has data, no window_minutes
|
||||
sUsed := 60.0
|
||||
|
||||
@@ -1167,6 +1167,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
|
||||
// 默认配置
|
||||
updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency)
|
||||
updates[SettingKeyDefaultBalance] = strconv.FormatFloat(settings.DefaultBalance, 'f', 8, 64)
|
||||
updates[SettingKeyDefaultUserRPMLimit] = strconv.Itoa(settings.DefaultUserRPMLimit)
|
||||
defaultSubsJSON, err := json.Marshal(settings.DefaultSubscriptions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal default subscriptions: %w", err)
|
||||
@@ -1538,6 +1539,18 @@ func (s *SettingService) GetDefaultBalance(ctx context.Context) float64 {
|
||||
return s.cfg.Default.UserBalance
|
||||
}
|
||||
|
||||
// GetDefaultUserRPMLimit 获取新用户默认 RPM 限制(0 = 不限制)。未配置则返回 0。
|
||||
func (s *SettingService) GetDefaultUserRPMLimit(ctx context.Context) int {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultUserRPMLimit)
|
||||
if err != nil || value == "" {
|
||||
return 0
|
||||
}
|
||||
if v, err := strconv.Atoi(value); err == nil && v >= 0 {
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetDefaultSubscriptions 获取新用户默认订阅配置列表。
|
||||
func (s *SettingService) GetDefaultSubscriptions(ctx context.Context) []DefaultSubscriptionSetting {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultSubscriptions)
|
||||
@@ -1706,6 +1719,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
||||
SettingKeyOIDCConnectUserInfoUsernamePath: "",
|
||||
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
|
||||
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
|
||||
SettingKeyDefaultUserRPMLimit: "0",
|
||||
SettingKeyDefaultSubscriptions: "[]",
|
||||
SettingKeyAuthSourceDefaultEmailBalance: "0",
|
||||
SettingKeyAuthSourceDefaultEmailConcurrency: "5",
|
||||
@@ -1822,6 +1836,10 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
||||
result.DefaultConcurrency = s.cfg.Default.UserConcurrency
|
||||
}
|
||||
|
||||
if rpm, err := strconv.Atoi(settings[SettingKeyDefaultUserRPMLimit]); err == nil && rpm >= 0 {
|
||||
result.DefaultUserRPMLimit = rpm
|
||||
}
|
||||
|
||||
// 解析浮点数类型
|
||||
if balance, err := strconv.ParseFloat(settings[SettingKeyDefaultBalance], 64); err == nil {
|
||||
result.DefaultBalance = balance
|
||||
|
||||
@@ -106,6 +106,7 @@ type SystemSettings struct {
|
||||
|
||||
DefaultConcurrency int
|
||||
DefaultBalance float64
|
||||
DefaultUserRPMLimit int
|
||||
DefaultSubscriptions []DefaultSubscriptionSetting
|
||||
|
||||
// Model fallback configuration
|
||||
|
||||
@@ -49,6 +49,15 @@ type User struct {
|
||||
BalanceNotifyExtraEmails []NotifyEmailEntry
|
||||
TotalRecharged float64
|
||||
|
||||
// RPMLimit 用户级每分钟请求数上限(0 = 不限制)。仅在所用分组未设置 rpm_limit
|
||||
// 且该 (用户, 分组) 无 rpm_override 时作为全局兜底生效,计数键 rpm:u:{userID}:{min}。
|
||||
RPMLimit int
|
||||
|
||||
// UserGroupRPMOverride 来自 auth cache snapshot 的 (user, group) RPM 覆盖值。
|
||||
// nil = 该 API Key 对应的 (user, group) 无 override;非 nil 时 checkRPM 直接使用,
|
||||
// 避免每请求查 DB。字段不持久化到数据库。
|
||||
UserGroupRPMOverride *int
|
||||
|
||||
APIKeys []APIKey
|
||||
Subscriptions []UserSubscription
|
||||
}
|
||||
|
||||
@@ -2,14 +2,16 @@ package service
|
||||
|
||||
import "context"
|
||||
|
||||
// UserGroupRateEntry 分组下用户专属倍率条目
|
||||
// UserGroupRateEntry 分组下用户专属倍率/RPM 条目。
|
||||
// RateMultiplier 与 RPMOverride 均为指针以支持"未设置"语义(NULL)。
|
||||
type UserGroupRateEntry struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
UserEmail string `json:"user_email"`
|
||||
UserNotes string `json:"user_notes"`
|
||||
UserStatus string `json:"user_status"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
UserEmail string `json:"user_email"`
|
||||
UserNotes string `json:"user_notes"`
|
||||
UserStatus string `json:"user_status"`
|
||||
RateMultiplier *float64 `json:"rate_multiplier,omitempty"`
|
||||
RPMOverride *int `json:"rpm_override,omitempty"`
|
||||
}
|
||||
|
||||
// GroupRateMultiplierInput 批量设置分组倍率的输入条目
|
||||
@@ -18,30 +20,44 @@ type GroupRateMultiplierInput struct {
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
}
|
||||
|
||||
// UserGroupRateRepository 用户专属分组倍率仓储接口
|
||||
// 允许管理员为特定用户设置分组的专属计费倍率,覆盖分组默认倍率
|
||||
// GroupRPMOverrideInput 批量设置分组 RPM override 的输入条目。
|
||||
// RPMOverride 为 *int 以支持清除(nil)语义。
|
||||
type GroupRPMOverrideInput struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
RPMOverride *int `json:"rpm_override"`
|
||||
}
|
||||
|
||||
// UserGroupRateRepository 用户专属分组倍率/RPM 仓储接口。
|
||||
// 允许管理员为特定用户设置分组的专属计费倍率与 RPM 上限,覆盖分组默认值。
|
||||
type UserGroupRateRepository interface {
|
||||
// GetByUserID 获取用户的所有专属分组倍率
|
||||
// 返回 map[groupID]rateMultiplier
|
||||
// GetByUserID 获取用户所有专属分组 rate_multiplier(仅返回非 NULL 的条目)
|
||||
GetByUserID(ctx context.Context, userID int64) (map[int64]float64, error)
|
||||
|
||||
// GetByUserAndGroup 获取用户在特定分组的专属倍率
|
||||
// 如果未设置专属倍率,返回 nil
|
||||
// GetByUserAndGroup 获取用户在特定分组的专属 rate_multiplier(NULL 返回 nil)
|
||||
GetByUserAndGroup(ctx context.Context, userID, groupID int64) (*float64, error)
|
||||
|
||||
// GetByGroupID 获取指定分组下所有用户的专属倍率
|
||||
// GetRPMOverrideByUserAndGroup 获取用户在特定分组的 rpm_override(NULL 返回 nil)
|
||||
GetRPMOverrideByUserAndGroup(ctx context.Context, userID, groupID int64) (*int, error)
|
||||
|
||||
// GetByGroupID 获取指定分组下所有用户的专属配置(rate 与 rpm_override 任一非 NULL 即返回)
|
||||
GetByGroupID(ctx context.Context, groupID int64) ([]UserGroupRateEntry, error)
|
||||
|
||||
// SyncUserGroupRates 同步用户的分组专属倍率
|
||||
// rates: map[groupID]*rateMultiplier,nil 表示删除该分组的专属倍率
|
||||
// SyncUserGroupRates 同步用户的分组专属倍率;nil 表示清空该分组的 rate_multiplier
|
||||
SyncUserGroupRates(ctx context.Context, userID int64, rates map[int64]*float64) error
|
||||
|
||||
// SyncGroupRateMultipliers 批量同步分组的用户专属倍率(替换整组数据)
|
||||
// SyncGroupRateMultipliers 批量同步分组的用户专属倍率(替换整组 rate 部分)
|
||||
SyncGroupRateMultipliers(ctx context.Context, groupID int64, entries []GroupRateMultiplierInput) error
|
||||
|
||||
// DeleteByGroupID 删除指定分组的所有用户专属倍率(分组删除时调用)
|
||||
// SyncGroupRPMOverrides 批量同步分组的用户专属 RPM(替换整组 rpm_override 部分)。
|
||||
// 条目中 RPMOverride 为 nil 时清空对应行的 rpm_override;非 nil 时 upsert。
|
||||
SyncGroupRPMOverrides(ctx context.Context, groupID int64, entries []GroupRPMOverrideInput) error
|
||||
|
||||
// ClearGroupRPMOverrides 清空指定分组的所有 rpm_override(整组 rpm 部分归 NULL)
|
||||
ClearGroupRPMOverrides(ctx context.Context, groupID int64) error
|
||||
|
||||
// DeleteByGroupID 删除指定分组的所有用户专属条目(分组删除时调用)
|
||||
DeleteByGroupID(ctx context.Context, groupID int64) error
|
||||
|
||||
// DeleteByUserID 删除指定用户的所有专属倍率(用户删除时调用)
|
||||
// DeleteByUserID 删除指定用户的所有专属条目(用户删除时调用)
|
||||
DeleteByUserID(ctx context.Context, userID int64) error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package service
|
||||
|
||||
import "context"
|
||||
|
||||
// UserRPMCache 用户/分组级 RPM 计数器接口。
|
||||
//
|
||||
// 与账号级 RPMCache 的区别:
|
||||
// - RPMCache —— 按外部 AI provider 账号聚合(key: rpm:{accountID}:{min})。
|
||||
// - UserRPMCache —— 按用户或 (用户, 分组) 聚合,杜绝"同一用户创建多个 API Key 绕过 RPM"的路径。
|
||||
// key 形如 rpm:ug:{userID}:{groupID}:{min} 或 rpm:u:{userID}:{min}。
|
||||
type UserRPMCache interface {
|
||||
// IncrementUserGroupRPM 原子递增 (user, group) 级分钟计数并返回最新值。
|
||||
// 用于分组 rpm_limit 与 user-group rpm_override 两种命中分支。
|
||||
IncrementUserGroupRPM(ctx context.Context, userID, groupID int64) (count int, err error)
|
||||
|
||||
// IncrementUserRPM 原子递增用户级分钟计数并返回最新值。
|
||||
// 用于用户全局 rpm_limit 兜底分支(分组未设且无 override 时)。
|
||||
IncrementUserRPM(ctx context.Context, userID int64) (count int, err error)
|
||||
|
||||
// GetUserGroupRPM 获取 (user, group) 当前分钟已用 RPM(只读,不递增)。
|
||||
GetUserGroupRPM(ctx context.Context, userID, groupID int64) (count int, err error)
|
||||
|
||||
// GetUserRPM 获取用户当前分钟已用 RPM(只读,不递增)。
|
||||
GetUserRPM(ctx context.Context, userID int64) (count int, err error)
|
||||
}
|
||||
@@ -39,6 +39,11 @@ func ProvideEmailQueueService(emailService *EmailService) *EmailQueueService {
|
||||
return NewEmailQueueService(emailService, 3)
|
||||
}
|
||||
|
||||
// ProvideOAuthRefreshAPI creates OAuthRefreshAPI with the default lock TTL.
|
||||
func ProvideOAuthRefreshAPI(accountRepo AccountRepository, tokenCache GeminiTokenCache) *OAuthRefreshAPI {
|
||||
return NewOAuthRefreshAPI(accountRepo, tokenCache)
|
||||
}
|
||||
|
||||
// ProvideTokenRefreshService creates and starts TokenRefreshService
|
||||
func ProvideTokenRefreshService(
|
||||
accountRepo AccountRepository,
|
||||
@@ -210,11 +215,13 @@ func ProvideRateLimitService(
|
||||
geminiQuotaService *GeminiQuotaService,
|
||||
tempUnschedCache TempUnschedCache,
|
||||
timeoutCounterCache TimeoutCounterCache,
|
||||
openAI403CounterCache OpenAI403CounterCache,
|
||||
settingService *SettingService,
|
||||
tokenCacheInvalidator TokenCacheInvalidator,
|
||||
) *RateLimitService {
|
||||
svc := NewRateLimitService(accountRepo, usageRepo, cfg, geminiQuotaService, tempUnschedCache)
|
||||
svc.SetTimeoutCounterCache(timeoutCounterCache)
|
||||
svc.SetOpenAI403CounterCache(openAI403CounterCache)
|
||||
svc.SetSettingService(settingService)
|
||||
svc.SetTokenCacheInvalidator(tokenCacheInvalidator)
|
||||
return svc
|
||||
@@ -384,6 +391,19 @@ func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupReposit
|
||||
return svc
|
||||
}
|
||||
|
||||
// ProvideBillingCacheService wires BillingCacheService with its RPM dependencies.
|
||||
func ProvideBillingCacheService(
|
||||
cache BillingCache,
|
||||
userRepo UserRepository,
|
||||
subRepo UserSubscriptionRepository,
|
||||
apiKeyRepo APIKeyRepository,
|
||||
rpmCache UserRPMCache,
|
||||
rateRepo UserGroupRateRepository,
|
||||
cfg *config.Config,
|
||||
) *BillingCacheService {
|
||||
return NewBillingCacheService(cache, userRepo, subRepo, apiKeyRepo, rpmCache, rateRepo, cfg)
|
||||
}
|
||||
|
||||
// ProviderSet is the Wire provider set for all services
|
||||
var ProviderSet = wire.NewSet(
|
||||
// Core services
|
||||
@@ -400,7 +420,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewDashboardService,
|
||||
ProvidePricingService,
|
||||
NewBillingService,
|
||||
NewBillingCacheService,
|
||||
ProvideBillingCacheService,
|
||||
NewAnnouncementService,
|
||||
NewAdminService,
|
||||
NewGatewayService,
|
||||
@@ -412,7 +432,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewCompositeTokenCacheInvalidator,
|
||||
wire.Bind(new(TokenCacheInvalidator), new(*CompositeTokenCacheInvalidator)),
|
||||
NewAntigravityOAuthService,
|
||||
NewOAuthRefreshAPI,
|
||||
ProvideOAuthRefreshAPI,
|
||||
ProvideGeminiTokenProvider,
|
||||
NewGeminiMessagesCompatService,
|
||||
ProvideAntigravityTokenProvider,
|
||||
|
||||
Reference in New Issue
Block a user