Merge remote-tracking branch 'origin/main' into feat/upstream-rate-scheduling
# Conflicts: # backend/internal/handler/openai_gateway_handler.go
This commit is contained in:
commit
0408bdb34f
103 files changed
+6095
-347
No files matched your search
@@ -255,7 +255,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
|
||||
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
|
||||
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
|
||||
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService)
|
||||
gatewayHandler := handler.NewGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService)
|
||||
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, configConfig)
|
||||
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService)
|
||||
totpHandler := handler.NewTotpHandler(totpService)
|
||||
|
||||
@@ -64,6 +64,7 @@ type channelModelPricingRequest struct {
|
||||
OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"`
|
||||
ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"`
|
||||
PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"`
|
||||
Intervals []pricingIntervalRequest `json:"intervals"`
|
||||
@@ -115,6 +116,7 @@ type channelModelPricingResponse struct {
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
ImageInputPrice *float64 `json:"image_input_price"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
Intervals []pricingIntervalResponse `json:"intervals"`
|
||||
@@ -222,6 +224,7 @@ func pricingToResponse(p *service.ChannelModelPricing) channelModelPricingRespon
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
@@ -273,6 +276,7 @@ func pricingRequestToService(reqs []channelModelPricingRequest) []service.Channe
|
||||
OutputPrice: r.OutputPrice,
|
||||
CacheWritePrice: r.CacheWritePrice,
|
||||
CacheReadPrice: r.CacheReadPrice,
|
||||
ImageInputPrice: r.ImageInputPrice,
|
||||
ImageOutputPrice: r.ImageOutputPrice,
|
||||
PerRequestPrice: r.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
@@ -498,6 +502,7 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) {
|
||||
"output_price": pricing.OutputPricePerToken,
|
||||
"cache_write_price": pricing.CacheCreationPricePerToken,
|
||||
"cache_read_price": pricing.CacheReadPricePerToken,
|
||||
"image_input_price": pricing.ImageInputPricePerToken,
|
||||
"image_output_price": pricing.ImageOutputPricePerToken,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ type userSupportedModelPricing struct {
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
ImageInputPrice *float64 `json:"image_input_price"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
Intervals []userPricingIntervalDTO `json:"intervals"`
|
||||
@@ -284,6 +285,7 @@ func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing {
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestChatCompletionsRejectsGPTImageModelsBeforeScheduling(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
for _, model := range []string{"gpt-image-1", "gpt-image-1.5", "gpt-image-2"} {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
call func(*gin.Context)
|
||||
}{
|
||||
{
|
||||
name: "gateway",
|
||||
call: (&GatewayHandler{}).ChatCompletions,
|
||||
},
|
||||
{
|
||||
name: "openai_gateway",
|
||||
call: newOpenAIImageChatRejectionHandler(t).ChatCompletions,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name+"/"+model, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
body := []byte(`{"model":"` + model + `","messages":[{"role":"user","content":"draw"}]}`)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
|
||||
setImageChatTestAuth(c)
|
||||
|
||||
tc.call(c)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Equal(t, "invalid_request_error", gjson.Get(recorder.Body.String(), "error.type").String())
|
||||
require.Contains(t, gjson.Get(recorder.Body.String(), "error.message").String(), "Chat Completions")
|
||||
_, selected := c.Get(opsAccountIDKey)
|
||||
require.False(t, selected, "rejection must happen before account selection")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIChatCompletionsImageModelRejectionDoesNotAcquireConcurrency(t *testing.T) {
|
||||
var acquireCalls atomic.Int64
|
||||
cache := &concurrencyCacheMock{
|
||||
acquireUserSlotFn: func(context.Context, int64, int, string) (bool, error) {
|
||||
acquireCalls.Add(1)
|
||||
return true, nil
|
||||
},
|
||||
}
|
||||
h := newOpenAIImageChatRejectionHandlerWithCache(t, cache)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(
|
||||
`{"model":"gpt-image-2","messages":[{"role":"user","content":"draw"}]}`,
|
||||
))
|
||||
setImageChatTestAuth(c)
|
||||
|
||||
h.ChatCompletions(c)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Zero(t, acquireCalls.Load(), "rejection must happen before user/account concurrency and scheduling")
|
||||
}
|
||||
|
||||
func newOpenAIImageChatRejectionHandler(t *testing.T) *OpenAIGatewayHandler {
|
||||
t.Helper()
|
||||
return newOpenAIImageChatRejectionHandlerWithCache(t, &concurrencyCacheMock{})
|
||||
}
|
||||
|
||||
func newOpenAIImageChatRejectionHandlerWithCache(t *testing.T, cache *concurrencyCacheMock) *OpenAIGatewayHandler {
|
||||
t.Helper()
|
||||
return &OpenAIGatewayHandler{
|
||||
gatewayService: &service.OpenAIGatewayService{},
|
||||
billingCacheService: &service.BillingCacheService{},
|
||||
apiKeyService: &service.APIKeyService{},
|
||||
concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(cache), SSEPingFormatNone, time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
func setImageChatTestAuth(c *gin.Context) {
|
||||
apiKey := &service.APIKey{ID: 4348, UserID: 4348, User: &service.User{ID: 4348}}
|
||||
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
|
||||
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID, Concurrency: 1})
|
||||
}
|
||||
@@ -634,6 +634,8 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
|
||||
ImageSize: l.ImageSize,
|
||||
ImageInputSize: l.ImageInputSize,
|
||||
ImageOutputSize: l.ImageOutputSize,
|
||||
ImageInputTokens: l.ImageInputTokens,
|
||||
ImageInputCost: l.ImageInputCost,
|
||||
ImageOutputTokens: l.ImageOutputTokens,
|
||||
ImageOutputCost: l.ImageOutputCost,
|
||||
ImageSizeSource: l.ImageSizeSource,
|
||||
|
||||
@@ -503,6 +503,8 @@ type UsageLog struct {
|
||||
ImageSize *string `json:"image_size"`
|
||||
ImageInputSize *string `json:"image_input_size"`
|
||||
ImageOutputSize *string `json:"image_output_size"`
|
||||
ImageInputTokens int `json:"image_input_tokens"`
|
||||
ImageInputCost float64 `json:"image_input_cost"`
|
||||
ImageOutputTokens int `json:"image_output_tokens"`
|
||||
ImageOutputCost float64 `json:"image_output_cost"`
|
||||
ImageSizeSource *string `json:"image_size_source"`
|
||||
|
||||
@@ -39,6 +39,7 @@ var gatewayCompatibilityMetricsLogCounter atomic.Uint64
|
||||
// GatewayHandler handles API gateway requests
|
||||
type GatewayHandler struct {
|
||||
gatewayService *service.GatewayService
|
||||
openAIGatewayService *service.OpenAIGatewayService
|
||||
geminiCompatService *service.GeminiMessagesCompatService
|
||||
antigravityGatewayService *service.AntigravityGatewayService
|
||||
userService *service.UserService
|
||||
@@ -59,6 +60,7 @@ type GatewayHandler struct {
|
||||
// NewGatewayHandler creates a new GatewayHandler
|
||||
func NewGatewayHandler(
|
||||
gatewayService *service.GatewayService,
|
||||
openAIGatewayService *service.OpenAIGatewayService,
|
||||
geminiCompatService *service.GeminiMessagesCompatService,
|
||||
antigravityGatewayService *service.AntigravityGatewayService,
|
||||
userService *service.UserService,
|
||||
@@ -94,6 +96,7 @@ func NewGatewayHandler(
|
||||
|
||||
return &GatewayHandler{
|
||||
gatewayService: gatewayService,
|
||||
openAIGatewayService: openAIGatewayService,
|
||||
geminiCompatService: geminiCompatService,
|
||||
antigravityGatewayService: antigravityGatewayService,
|
||||
userService: userService,
|
||||
|
||||
@@ -80,6 +80,10 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage)
|
||||
return
|
||||
}
|
||||
if service.IsGPTImageGenerationModel(reqModel) {
|
||||
h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "This model is not supported on the Chat Completions endpoint")
|
||||
return
|
||||
}
|
||||
reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream))
|
||||
|
||||
setOpsRequestContext(c, reqModel, reqStream)
|
||||
|
||||
@@ -85,7 +85,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
|
||||
setOpsRequestContext(c, reqModel, reqStream)
|
||||
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
|
||||
requestCtx := c.Request.Context()
|
||||
if service.IsImageGenerationIntent("/v1/responses", reqModel, body) {
|
||||
if service.IsImageGenerationIntentForPlatform("/v1/responses", reqModel, body, openAICompatibleRequestPlatform(apiKey)) {
|
||||
requestCtx = service.WithOpenAIImageGenerationIntent(requestCtx)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const keyBillingInfoSchemaVersion = 1
|
||||
|
||||
type keyBillingInfoResponse struct {
|
||||
Object string `json:"object"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
BillingScope string `json:"billing_scope"`
|
||||
GroupRateMultiplier float64 `json:"group_rate_multiplier"`
|
||||
UserRateMultiplier *float64 `json:"user_rate_multiplier,omitempty"`
|
||||
ResolvedRateMultiplier float64 `json:"resolved_rate_multiplier"`
|
||||
PeakRateEnabled bool `json:"peak_rate_enabled"`
|
||||
PeakStart *string `json:"peak_start,omitempty"`
|
||||
PeakEnd *string `json:"peak_end,omitempty"`
|
||||
PeakRateMultiplier *float64 `json:"peak_rate_multiplier,omitempty"`
|
||||
AppliedPeakMultiplier *float64 `json:"applied_peak_multiplier,omitempty"`
|
||||
EffectiveRateMultiplier float64 `json:"effective_rate_multiplier"`
|
||||
Timezone *string `json:"timezone,omitempty"`
|
||||
ObservedAt time.Time `json:"observed_at"`
|
||||
}
|
||||
|
||||
// KeyBillingInfo returns the token billing multiplier effective for the authenticated API key.
|
||||
// GET /v1/sub2api/billing
|
||||
func (h *GatewayHandler) KeyBillingInfo(c *gin.Context) {
|
||||
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
|
||||
if !ok {
|
||||
h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key")
|
||||
return
|
||||
}
|
||||
if h.cfg != nil && h.cfg.RunMode == config.RunModeSimple {
|
||||
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Billing information is not supported in simple mode")
|
||||
return
|
||||
}
|
||||
if apiKey.GroupID == nil {
|
||||
h.errorResponse(c, http.StatusForbidden, "permission_error", "API key is not assigned to a group")
|
||||
return
|
||||
}
|
||||
if apiKey.Group == nil {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Billing information is unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
resolvedRate, ok := h.resolveKeyBillingRate(c, apiKey)
|
||||
if !ok {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Billing information is unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, buildKeyBillingInfo(apiKey, resolvedRate, timezone.Now()))
|
||||
}
|
||||
|
||||
func (h *GatewayHandler) resolveKeyBillingRate(c *gin.Context, apiKey *service.APIKey) (float64, bool) {
|
||||
groupRate := apiKey.Group.RateMultiplier
|
||||
switch apiKey.Group.Platform {
|
||||
case service.PlatformOpenAI, service.PlatformGrok:
|
||||
if h.openAIGatewayService == nil {
|
||||
return 0, false
|
||||
}
|
||||
return h.openAIGatewayService.ResolveUserGroupRateMultiplier(c.Request.Context(), apiKey.UserID, *apiKey.GroupID, groupRate), true
|
||||
default:
|
||||
if h.gatewayService == nil {
|
||||
return 0, false
|
||||
}
|
||||
return h.gatewayService.ResolveUserGroupRateMultiplier(c.Request.Context(), apiKey.UserID, *apiKey.GroupID, groupRate), true
|
||||
}
|
||||
}
|
||||
|
||||
func buildKeyBillingInfo(apiKey *service.APIKey, resolvedRate float64, now time.Time) keyBillingInfoResponse {
|
||||
groupRate := apiKey.Group.RateMultiplier
|
||||
var userRate *float64
|
||||
if resolvedRate != groupRate {
|
||||
userRate = &resolvedRate
|
||||
}
|
||||
appliedPeak := apiKey.Group.PeakMultiplierAt(now)
|
||||
|
||||
response := keyBillingInfoResponse{
|
||||
Object: "sub2api.key_billing",
|
||||
SchemaVersion: keyBillingInfoSchemaVersion,
|
||||
BillingScope: "token",
|
||||
GroupRateMultiplier: groupRate,
|
||||
UserRateMultiplier: userRate,
|
||||
ResolvedRateMultiplier: resolvedRate,
|
||||
PeakRateEnabled: apiKey.Group.PeakRateEnabled,
|
||||
EffectiveRateMultiplier: resolvedRate * appliedPeak,
|
||||
ObservedAt: now.UTC(),
|
||||
}
|
||||
if apiKey.Group.PeakRateEnabled {
|
||||
response.PeakStart = &apiKey.Group.PeakStart
|
||||
response.PeakEnd = &apiKey.Group.PeakEnd
|
||||
response.PeakRateMultiplier = &apiKey.Group.PeakRateMultiplier
|
||||
response.AppliedPeakMultiplier = &appliedPeak
|
||||
tz := timezone.Location().String()
|
||||
response.Timezone = &tz
|
||||
}
|
||||
return response
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type keyBillingUserGroupRateRepo struct {
|
||||
service.UserGroupRateRepository
|
||||
rate *float64
|
||||
err error
|
||||
gotUserID int64
|
||||
gotGroupID int64
|
||||
lookupCalls int
|
||||
}
|
||||
|
||||
func (r *keyBillingUserGroupRateRepo) GetByUserAndGroup(_ context.Context, userID, groupID int64) (*float64, error) {
|
||||
r.gotUserID = userID
|
||||
r.gotGroupID = groupID
|
||||
r.lookupCalls++
|
||||
return r.rate, r.err
|
||||
}
|
||||
|
||||
func newKeyBillingHandler(repo service.UserGroupRateRepository) *GatewayHandler {
|
||||
return &GatewayHandler{
|
||||
gatewayService: newKeyBillingGatewayService(repo),
|
||||
openAIGatewayService: newKeyBillingOpenAIGatewayService(repo),
|
||||
}
|
||||
}
|
||||
|
||||
func newKeyBillingGatewayService(repo service.UserGroupRateRepository) *service.GatewayService {
|
||||
return service.NewGatewayService(
|
||||
nil, nil, nil, nil, nil, nil, repo, nil, nil, nil, nil, nil, nil, nil,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
}
|
||||
|
||||
func newKeyBillingOpenAIGatewayService(repo service.UserGroupRateRepository) *service.OpenAIGatewayService {
|
||||
return service.NewOpenAIGatewayService(
|
||||
nil, nil, nil, nil, nil, repo, nil, nil, nil, nil, nil,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
}
|
||||
|
||||
func newKeyBillingContext(apiKey *service.APIKey) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1/sub2api/billing", nil)
|
||||
if apiKey != nil {
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), apiKey)
|
||||
}
|
||||
return c, w
|
||||
}
|
||||
|
||||
func TestGatewayHandlerKeyBillingInfoUsesGroupRate(t *testing.T) {
|
||||
groupID := int64(7)
|
||||
apiKey := &service.APIKey{
|
||||
UserID: 11,
|
||||
GroupID: &groupID,
|
||||
Key: "sk-sensitive-value",
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Name: "private-group-name",
|
||||
RateMultiplier: 0.75,
|
||||
},
|
||||
}
|
||||
c, w := newKeyBillingContext(apiKey)
|
||||
|
||||
newKeyBillingHandler(nil).KeyBillingInfo(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Equal(t, "no-store", w.Header().Get("Cache-Control"))
|
||||
var got keyBillingInfoResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
|
||||
require.Equal(t, "sub2api.key_billing", got.Object)
|
||||
require.Equal(t, 1, got.SchemaVersion)
|
||||
require.Equal(t, "token", got.BillingScope)
|
||||
require.Equal(t, 0.75, got.GroupRateMultiplier)
|
||||
require.Nil(t, got.UserRateMultiplier)
|
||||
require.Equal(t, 0.75, got.ResolvedRateMultiplier)
|
||||
require.False(t, got.PeakRateEnabled)
|
||||
require.Nil(t, got.PeakStart)
|
||||
require.Nil(t, got.PeakEnd)
|
||||
require.Nil(t, got.PeakRateMultiplier)
|
||||
require.Nil(t, got.AppliedPeakMultiplier)
|
||||
require.Equal(t, 0.75, got.EffectiveRateMultiplier)
|
||||
require.Nil(t, got.Timezone)
|
||||
require.False(t, got.ObservedAt.IsZero())
|
||||
var fields map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &fields))
|
||||
require.NotContains(t, fields, "user_rate_multiplier")
|
||||
require.NotContains(t, fields, "peak_start")
|
||||
require.NotContains(t, fields, "peak_end")
|
||||
require.NotContains(t, fields, "peak_rate_multiplier")
|
||||
require.NotContains(t, fields, "applied_peak_multiplier")
|
||||
require.NotContains(t, fields, "timezone")
|
||||
require.NotContains(t, w.Body.String(), apiKey.Key)
|
||||
require.NotContains(t, w.Body.String(), apiKey.Group.Name)
|
||||
}
|
||||
|
||||
func TestGatewayHandlerKeyBillingInfoUsesUserOverride(t *testing.T) {
|
||||
groupID := int64(7)
|
||||
userRate := 0.5
|
||||
apiKey := &service.APIKey{
|
||||
UserID: 11,
|
||||
GroupID: &groupID,
|
||||
Group: &service.Group{ID: groupID, RateMultiplier: 0.75},
|
||||
}
|
||||
c, w := newKeyBillingContext(apiKey)
|
||||
repo := &keyBillingUserGroupRateRepo{rate: &userRate}
|
||||
|
||||
newKeyBillingHandler(repo).KeyBillingInfo(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Equal(t, 1, repo.lookupCalls)
|
||||
require.Equal(t, apiKey.UserID, repo.gotUserID)
|
||||
require.Equal(t, groupID, repo.gotGroupID)
|
||||
var got keyBillingInfoResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
|
||||
require.NotNil(t, got.UserRateMultiplier)
|
||||
require.Equal(t, 0.5, *got.UserRateMultiplier)
|
||||
require.Equal(t, 0.5, got.ResolvedRateMultiplier)
|
||||
require.Equal(t, 0.5, got.EffectiveRateMultiplier)
|
||||
}
|
||||
|
||||
func TestBuildKeyBillingInfoAppliesPeakMultiplier(t *testing.T) {
|
||||
groupID := int64(7)
|
||||
apiKey := &service.APIKey{
|
||||
GroupID: &groupID,
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
RateMultiplier: 1.2,
|
||||
SubscriptionType: service.SubscriptionTypeSubscription,
|
||||
PeakRateEnabled: true,
|
||||
PeakStart: "09:00",
|
||||
PeakEnd: "18:00",
|
||||
PeakRateMultiplier: 1.5,
|
||||
},
|
||||
}
|
||||
now := time.Date(2026, time.July, 12, 10, 0, 0, 0, timezone.Location())
|
||||
userRate := 0.8
|
||||
|
||||
got := buildKeyBillingInfo(apiKey, userRate, now)
|
||||
|
||||
require.Equal(t, 1.2, got.GroupRateMultiplier)
|
||||
require.NotNil(t, got.UserRateMultiplier)
|
||||
require.Equal(t, 0.8, *got.UserRateMultiplier)
|
||||
require.Equal(t, 0.8, got.ResolvedRateMultiplier)
|
||||
require.True(t, got.PeakRateEnabled)
|
||||
require.NotNil(t, got.PeakStart)
|
||||
require.Equal(t, "09:00", *got.PeakStart)
|
||||
require.NotNil(t, got.PeakEnd)
|
||||
require.Equal(t, "18:00", *got.PeakEnd)
|
||||
require.NotNil(t, got.PeakRateMultiplier)
|
||||
require.Equal(t, 1.5, *got.PeakRateMultiplier)
|
||||
require.NotNil(t, got.AppliedPeakMultiplier)
|
||||
require.Equal(t, 1.5, *got.AppliedPeakMultiplier)
|
||||
require.InDelta(t, 1.2, got.EffectiveRateMultiplier, 1e-12)
|
||||
require.NotNil(t, got.Timezone)
|
||||
require.Equal(t, timezone.Location().String(), *got.Timezone)
|
||||
require.Equal(t, now.UTC(), got.ObservedAt)
|
||||
|
||||
encoded, err := json.Marshal(got)
|
||||
require.NoError(t, err)
|
||||
var fields map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(encoded, &fields))
|
||||
for _, field := range []string{
|
||||
"user_rate_multiplier",
|
||||
"peak_start",
|
||||
"peak_end",
|
||||
"peak_rate_multiplier",
|
||||
"applied_peak_multiplier",
|
||||
"timezone",
|
||||
} {
|
||||
require.Contains(t, fields, field)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyBillingInfoJSONKeepsZeroPeakMultiplierWhenEnabled(t *testing.T) {
|
||||
groupID := int64(7)
|
||||
apiKey := &service.APIKey{
|
||||
GroupID: &groupID,
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
SubscriptionType: service.SubscriptionTypeSubscription,
|
||||
PeakRateEnabled: true,
|
||||
PeakStart: "00:00",
|
||||
PeakEnd: "23:59",
|
||||
PeakRateMultiplier: 0,
|
||||
},
|
||||
}
|
||||
now := time.Date(2026, time.July, 12, 12, 0, 0, 0, timezone.Location())
|
||||
encoded, err := json.Marshal(buildKeyBillingInfo(apiKey, apiKey.Group.RateMultiplier, now))
|
||||
require.NoError(t, err)
|
||||
|
||||
var fields map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(encoded, &fields))
|
||||
require.JSONEq(t, "0", string(fields["peak_rate_multiplier"]))
|
||||
require.JSONEq(t, "0", string(fields["applied_peak_multiplier"]))
|
||||
}
|
||||
|
||||
func TestGatewayHandlerKeyBillingInfoErrorsAreSafe(t *testing.T) {
|
||||
t.Run("missing API key", func(t *testing.T) {
|
||||
c, w := newKeyBillingContext(nil)
|
||||
newKeyBillingHandler(nil).KeyBillingInfo(c)
|
||||
require.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
})
|
||||
|
||||
t.Run("ungrouped API key", func(t *testing.T) {
|
||||
c, w := newKeyBillingContext(&service.APIKey{})
|
||||
newKeyBillingHandler(nil).KeyBillingInfo(c)
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
})
|
||||
|
||||
t.Run("missing billing service", func(t *testing.T) {
|
||||
groupID := int64(7)
|
||||
c, w := newKeyBillingContext(&service.APIKey{
|
||||
UserID: 11,
|
||||
GroupID: &groupID,
|
||||
Group: &service.Group{ID: groupID, RateMultiplier: 1},
|
||||
})
|
||||
(&GatewayHandler{}).KeyBillingInfo(c)
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
})
|
||||
|
||||
t.Run("rate lookup failure matches billing fallback", func(t *testing.T) {
|
||||
groupID := int64(7)
|
||||
c, w := newKeyBillingContext(&service.APIKey{
|
||||
UserID: 11,
|
||||
GroupID: &groupID,
|
||||
Group: &service.Group{ID: groupID, RateMultiplier: 1},
|
||||
})
|
||||
newKeyBillingHandler(&keyBillingUserGroupRateRepo{err: errors.New("database password leaked")}).KeyBillingInfo(c)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var got keyBillingInfoResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
|
||||
require.Equal(t, 1.0, got.ResolvedRateMultiplier)
|
||||
require.NotContains(t, w.Body.String(), "database password leaked")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGatewayHandlerKeyBillingInfoSharesBillingResolverCacheByPlatform(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
platform string
|
||||
openAI bool
|
||||
}{
|
||||
{name: "anthropic", platform: service.PlatformAnthropic},
|
||||
{name: "openai", platform: service.PlatformOpenAI, openAI: true},
|
||||
{name: "grok", platform: service.PlatformGrok, openAI: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
groupID := int64(7)
|
||||
oldRate, newRate := 0.5, 1.8
|
||||
repo := &keyBillingUserGroupRateRepo{rate: &oldRate}
|
||||
gatewayService := newKeyBillingGatewayService(repo)
|
||||
openAIGatewayService := newKeyBillingOpenAIGatewayService(repo)
|
||||
h := &GatewayHandler{
|
||||
gatewayService: gatewayService,
|
||||
openAIGatewayService: openAIGatewayService,
|
||||
}
|
||||
apiKey := &service.APIKey{
|
||||
UserID: 11,
|
||||
GroupID: &groupID,
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: tc.platform,
|
||||
RateMultiplier: 0.75,
|
||||
},
|
||||
}
|
||||
|
||||
if tc.openAI {
|
||||
require.Equal(t, oldRate, openAIGatewayService.ResolveUserGroupRateMultiplier(context.Background(), apiKey.UserID, groupID, apiKey.Group.RateMultiplier))
|
||||
} else {
|
||||
require.Equal(t, oldRate, gatewayService.ResolveUserGroupRateMultiplier(context.Background(), apiKey.UserID, groupID, apiKey.Group.RateMultiplier))
|
||||
}
|
||||
repo.rate = &newRate
|
||||
|
||||
for range 2 {
|
||||
c, w := newKeyBillingContext(apiKey)
|
||||
h.KeyBillingInfo(c)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var got keyBillingInfoResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
|
||||
require.Equal(t, oldRate, got.ResolvedRateMultiplier)
|
||||
require.Equal(t, oldRate, got.EffectiveRateMultiplier)
|
||||
}
|
||||
|
||||
var billedRate float64
|
||||
if tc.openAI {
|
||||
billedRate = openAIGatewayService.ResolveUserGroupRateMultiplier(context.Background(), apiKey.UserID, groupID, apiKey.Group.RateMultiplier)
|
||||
} else {
|
||||
billedRate = gatewayService.ResolveUserGroupRateMultiplier(context.Background(), apiKey.UserID, groupID, apiKey.Group.RateMultiplier)
|
||||
}
|
||||
require.Equal(t, oldRate, billedRate)
|
||||
require.Equal(t, 1, repo.lookupCalls)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -274,7 +274,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
|
||||
return
|
||||
}
|
||||
if failoverErr.ShouldReportAccountScheduleFailure() {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), false, nil)
|
||||
}
|
||||
if c.Writer.Size() != writerSizeBeforeForward {
|
||||
h.handleFailoverExhausted(c, failoverErr, true)
|
||||
@@ -322,7 +322,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
|
||||
)
|
||||
continue
|
||||
}
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), false, nil)
|
||||
if c.Writer.Size() == writerSizeBeforeForward {
|
||||
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed")
|
||||
}
|
||||
@@ -333,7 +333,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
|
||||
return
|
||||
}
|
||||
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), true, nil)
|
||||
if endpoint.IsGenerationRequest() && strings.TrimSpace(result.ResponseID) != "" {
|
||||
if err := h.gatewayService.BindGrokMediaVideoRequestAccount(requestCtx, apiKey.GroupID, result.ResponseID, account.ID); err != nil {
|
||||
reqLog.Warn("grok_media.bind_video_request_account_failed",
|
||||
|
||||
@@ -118,7 +118,7 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) {
|
||||
requestedModel,
|
||||
failedAccountIDs,
|
||||
service.OpenAIUpstreamTransportHTTPSSE,
|
||||
service.OpenAIEndpointCapabilityChatCompletions,
|
||||
service.OpenAIEndpointCapabilityAlphaSearch,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
@@ -164,7 +164,7 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) {
|
||||
service.SetOpsLatencyMs(c, service.OpsResponseLatencyMsKey, time.Since(forwardStart).Milliseconds())
|
||||
|
||||
if err == nil {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestedModel), true, nil)
|
||||
if result != nil {
|
||||
h.recordAlphaSearchUsage(c, apiKey, account, subscription, channelMapping, requestedModel, body, result, subject.UserID)
|
||||
}
|
||||
@@ -173,7 +173,7 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) {
|
||||
|
||||
var failoverErr *service.UpstreamFailoverError
|
||||
if !errors.As(err, &failoverErr) {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestedModel), false, nil)
|
||||
if c.Writer.Size() == writerSizeBeforeForward {
|
||||
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed")
|
||||
}
|
||||
@@ -181,7 +181,7 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestedModel), false, nil)
|
||||
if c.Writer.Size() != writerSizeBeforeForward {
|
||||
h.handleFailoverExhausted(c, failoverErr, true)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpenAIBodyLimitFailoverExhausted_ReturnsRedactedJSON413(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
|
||||
|
||||
(&OpenAIGatewayHandler{}).handleFailoverExhausted(c, bodyLimitFailoverTestError(), false)
|
||||
|
||||
require.Equal(t, http.StatusRequestEntityTooLarge, rec.Code)
|
||||
var envelope map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
|
||||
errBody, ok := envelope["error"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "invalid_request_error", errBody["type"])
|
||||
require.Equal(t, "Request payload is too large", errBody["message"])
|
||||
require.NotContains(t, rec.Body.String(), "must-not-leak")
|
||||
}
|
||||
|
||||
func TestOpenAIBodyLimitFailoverExhausted_ReturnsRedactedResponsesSSE(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
|
||||
|
||||
(&OpenAIGatewayHandler{}).handleFailoverExhausted(c, bodyLimitFailoverTestError(), true)
|
||||
|
||||
body := rec.Body.String()
|
||||
require.True(t, strings.HasPrefix(body, "event: response.failed\n"))
|
||||
require.Contains(t, body, `"code":"invalid_request"`)
|
||||
require.Contains(t, body, `"message":"Request payload is too large"`)
|
||||
require.NotContains(t, body, "must-not-leak")
|
||||
}
|
||||
|
||||
func bodyLimitFailoverTestError() *service.UpstreamFailoverError {
|
||||
return &service.UpstreamFailoverError{
|
||||
StatusCode: http.StatusRequestEntityTooLarge,
|
||||
ResponseBody: []byte(`{"error":{"message":"proxy limit secret=must-not-leak"}}`),
|
||||
Scope: service.GatewayFailureScopeAccount,
|
||||
Reason: service.GatewayFailureReason("openai_request_body_too_large"),
|
||||
NextAccountAction: service.NextAccountRetry,
|
||||
ClientStatusCode: http.StatusRequestEntityTooLarge,
|
||||
ClientMessage: "Request payload is too large",
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,10 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage)
|
||||
return
|
||||
}
|
||||
if service.IsGPTImageGenerationModel(reqModel) {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "This model is not supported on the Chat Completions endpoint")
|
||||
return
|
||||
}
|
||||
|
||||
reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream))
|
||||
|
||||
@@ -252,7 +256,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if failoverErr.ShouldReportAccountScheduleFailure() {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil)
|
||||
}
|
||||
if !failoverErr.ShouldRetryNextAccount() {
|
||||
h.handleFailoverExhausted(c, failoverErr, streamStarted)
|
||||
@@ -297,7 +301,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
)
|
||||
continue
|
||||
}
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil)
|
||||
upstreamErrorAlreadyCommunicated := openAIForwardErrorAlreadyCommunicated(c, writerSizeBeforeForward, err)
|
||||
wroteFallback := false
|
||||
if !upstreamErrorAlreadyCommunicated {
|
||||
@@ -313,9 +317,9 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if result != nil {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), true, result.FirstTokenMs)
|
||||
} else {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), true, nil)
|
||||
}
|
||||
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
|
||||
@@ -193,7 +193,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
|
||||
h.handleFailoverExhausted(c, failoverErr, true)
|
||||
return
|
||||
}
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil)
|
||||
if failoverClientGone(c) {
|
||||
reqLog.Info("openai_embeddings.failover_aborted_client_disconnected",
|
||||
zap.Int64("account_id", account.ID),
|
||||
@@ -217,7 +217,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
|
||||
)
|
||||
continue
|
||||
}
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil)
|
||||
if c.Writer.Size() == writerSizeBeforeForward {
|
||||
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed")
|
||||
}
|
||||
@@ -228,7 +228,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), true, nil)
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
clientIP := ip.GetClientIP(c)
|
||||
inboundEndpoint := GetInboundEndpoint(c)
|
||||
|
||||
@@ -42,6 +42,10 @@ type OpenAIGatewayHandler struct {
|
||||
|
||||
const maxOpenAIFirstOutputTimeoutSwitches = 1
|
||||
|
||||
func openAIForwardSucceededForScheduling(result *service.OpenAIForwardResult) bool {
|
||||
return result.SucceededForScheduling()
|
||||
}
|
||||
|
||||
func resolveOpenAIMessagesDispatchMappedModel(apiKey *service.APIKey, requestedModel string) string {
|
||||
if apiKey == nil || apiKey.Group == nil {
|
||||
return ""
|
||||
@@ -272,7 +276,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
imageIntent := service.IsImageGenerationIntent("/v1/responses", reqModel, body)
|
||||
imageIntent := service.IsImageGenerationIntentForPlatform("/v1/responses", reqModel, body, openAICompatibleRequestPlatform(apiKey))
|
||||
if imageIntent && !service.GroupAllowsImageGeneration(apiKey.Group) {
|
||||
h.errorResponse(c, http.StatusForbidden, "permission_error", service.ImageGenerationPermissionMessage())
|
||||
return
|
||||
@@ -482,7 +486,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
streamStarted = true
|
||||
}
|
||||
if failoverErr.ShouldReportAccountScheduleFailure() {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil)
|
||||
}
|
||||
if !failoverErr.ShouldRetryNextAccount() {
|
||||
h.handleFailoverExhausted(c, failoverErr, streamStarted)
|
||||
@@ -542,7 +546,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
reqLog.Warn("openai.upstream_failover_switching", failoverSwitchFields...)
|
||||
continue
|
||||
}
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil)
|
||||
upstreamErrorAlreadyCommunicated := openAIForwardErrorAlreadyCommunicated(c, writerSizeBeforeForward, err)
|
||||
wroteFallback := false
|
||||
if !upstreamErrorAlreadyCommunicated {
|
||||
@@ -567,9 +571,9 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
if account.Type == service.AccountTypeOAuth && !account.IsShadow() {
|
||||
h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(c.Request.Context(), account.ID, result.ResponseHeaders)
|
||||
}
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), openAIForwardSucceededForScheduling(result), result.FirstTokenMs)
|
||||
} else {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), openAIForwardSucceededForScheduling(result), nil)
|
||||
}
|
||||
|
||||
// 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context)
|
||||
@@ -1011,7 +1015,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if failoverErr.ShouldReportAccountScheduleFailure() {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(currentRoutingModel), false, nil)
|
||||
}
|
||||
if !failoverErr.ShouldRetryNextAccount() {
|
||||
h.handleAnthropicFailoverExhausted(c, failoverErr, streamStarted)
|
||||
@@ -1063,7 +1067,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
)
|
||||
return
|
||||
}
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(currentRoutingModel), false, nil)
|
||||
wroteFallback := h.ensureAnthropicErrorResponse(c, streamStarted)
|
||||
reqLog.Warn("openai_messages.forward_failed",
|
||||
zap.Int64("account_id", account.ID),
|
||||
@@ -1074,9 +1078,9 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if result != nil {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(currentRoutingModel), true, result.FirstTokenMs)
|
||||
} else {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(currentRoutingModel), true, nil)
|
||||
}
|
||||
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
@@ -1362,6 +1366,28 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
reqLog.Info("openai.websocket_ingress_started")
|
||||
clientIP := ip.GetClientIP(c)
|
||||
userAgent := strings.TrimSpace(c.GetHeader("User-Agent"))
|
||||
ctx := c.Request.Context()
|
||||
maxIngressConnections := 0
|
||||
if h.cfg != nil {
|
||||
maxIngressConnections = h.cfg.Gateway.OpenAIWS.MaxIngressConnectionsPerAPIKey
|
||||
}
|
||||
ingressLease, ingressLeaseAcquired, ingressLeaseErr := h.concurrencyHelper.AcquireOpenAIWSIngressLease(ctx, apiKey.ID, maxIngressConnections)
|
||||
if ingressLeaseErr != nil {
|
||||
reqLog.Error("openai.websocket_ingress_lease_acquire_failed", zap.Error(ingressLeaseErr))
|
||||
h.errorResponse(c, http.StatusServiceUnavailable, "service_unavailable", "WebSocket ingress capacity is temporarily unavailable")
|
||||
return
|
||||
}
|
||||
if !ingressLeaseAcquired {
|
||||
reqLog.Info("openai.websocket_ingress_capacity_rejected", zap.Int("max_ingress_connections_per_api_key", maxIngressConnections))
|
||||
c.Header("Retry-After", "5")
|
||||
h.errorResponse(c, http.StatusTooManyRequests, "rate_limit_error", "Too many open WebSocket connections, please retry later")
|
||||
return
|
||||
}
|
||||
if ingressLease != nil {
|
||||
defer ingressLease.Release()
|
||||
ctx = ingressLease.Context()
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
}
|
||||
|
||||
wsConn, err := coderws.Accept(c.Writer, c.Request, &coderws.AcceptOptions{
|
||||
CompressionMode: coderws.CompressionContextTakeover,
|
||||
@@ -1383,32 +1409,14 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
}()
|
||||
wsConn.SetReadLimit(service.ResolveOpenAIWSClientReadLimitBytes(h.cfg))
|
||||
|
||||
ctx := c.Request.Context()
|
||||
maxIngressConnections := 0
|
||||
if h.cfg != nil {
|
||||
maxIngressConnections = h.cfg.Gateway.OpenAIWS.MaxIngressConnectionsPerAPIKey
|
||||
}
|
||||
ingressLease, ingressLeaseAcquired, ingressLeaseErr := h.concurrencyHelper.AcquireOpenAIWSIngressLease(ctx, apiKey.ID, maxIngressConnections)
|
||||
if ingressLeaseErr != nil {
|
||||
reqLog.Error("openai.websocket_ingress_lease_acquire_failed", zap.Error(ingressLeaseErr))
|
||||
closeOpenAIClientWS(wsConn, coderws.StatusInternalError, "failed to reserve websocket ingress capacity")
|
||||
return
|
||||
}
|
||||
if !ingressLeaseAcquired {
|
||||
reqLog.Info("openai.websocket_ingress_capacity_rejected", zap.Int("max_ingress_connections_per_api_key", maxIngressConnections))
|
||||
closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "too many open websocket connections, please retry later")
|
||||
return
|
||||
}
|
||||
if ingressLease != nil {
|
||||
defer ingressLease.Release()
|
||||
ctx = ingressLease.Context()
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
}
|
||||
|
||||
firstMessageTimeout := service.ResolveOpenAIWSClientFirstMessageTimeout(h.cfg)
|
||||
readCtx, cancel := context.WithTimeout(ctx, firstMessageTimeout)
|
||||
msgType, firstMessage, err := wsConn.Read(readCtx)
|
||||
cancel()
|
||||
msgType, firstMessage, err := service.ReadOpenAIWSClientMessage(
|
||||
ctx,
|
||||
wsConn,
|
||||
firstMessageTimeout,
|
||||
coderws.StatusPolicyViolation,
|
||||
"missing first response.create message",
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(context.Cause(ctx), service.ErrOpenAIWSIngressLeaseLost) {
|
||||
reqLog.Warn("openai.websocket_ingress_lease_lost_before_first_message", zap.Error(err))
|
||||
@@ -1463,7 +1471,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
imageIntent := service.IsImageGenerationIntent("/v1/responses", reqModel, firstMessage)
|
||||
imageIntent := service.IsImageGenerationIntentForPlatform("/v1/responses", reqModel, firstMessage, openAICompatibleRequestPlatform(apiKey))
|
||||
if imageIntent && !service.GroupAllowsImageGeneration(apiKey.Group) {
|
||||
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, service.ImageGenerationPermissionMessage())
|
||||
return
|
||||
@@ -1557,7 +1565,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
return false
|
||||
}
|
||||
if failoverErr.ShouldReportAccountScheduleFailure() {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil)
|
||||
}
|
||||
releaseAccountSlot()
|
||||
if !failoverErr.ShouldRetryNextAccount() {
|
||||
@@ -1777,7 +1785,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
if account.Type == service.AccountTypeOAuth && !account.IsShadow() {
|
||||
h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(ctx, account.ID, result.ResponseHeaders)
|
||||
}
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), openAIForwardSucceededForScheduling(result), result.FirstTokenMs)
|
||||
inboundEndpoint := GetInboundEndpoint(c)
|
||||
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result)
|
||||
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
|
||||
@@ -1857,7 +1865,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil)
|
||||
closeStatus, closeReason := summarizeWSCloseErrorForLog(err)
|
||||
proxyFailedFields := []zap.Field{
|
||||
zap.Int64("account_id", account.ID),
|
||||
@@ -2081,6 +2089,17 @@ func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, failoverE
|
||||
h.handleFailoverExhaustedSimple(c, http.StatusBadGateway, streamStarted)
|
||||
return
|
||||
}
|
||||
if failoverErr.IsOpenAIRequestBodyTooLarge() {
|
||||
service.SetOpsUpstreamError(c, http.StatusRequestEntityTooLarge, service.OpenAIRequestBodyTooLargeClientMessage, "")
|
||||
h.handleStreamingAwareError(
|
||||
c,
|
||||
http.StatusRequestEntityTooLarge,
|
||||
"invalid_request_error",
|
||||
service.OpenAIRequestBodyTooLargeClientMessage,
|
||||
streamStarted,
|
||||
)
|
||||
return
|
||||
}
|
||||
copyFailoverRetryAfter(c, failoverErr.ResponseHeaders)
|
||||
if failoverErr.IsCredentialFailure() {
|
||||
status, message := credentialFailoverClientResponse(failoverErr)
|
||||
|
||||
@@ -96,6 +96,19 @@ func TestOpenAIHandleStreamingAwareError_JSONEscaping(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIForwardSucceededForScheduling(t *testing.T) {
|
||||
require.True(t, openAIForwardSucceededForScheduling(nil))
|
||||
require.True(t, openAIForwardSucceededForScheduling(&service.OpenAIForwardResult{}))
|
||||
require.True(t, openAIForwardSucceededForScheduling(&service.OpenAIForwardResult{
|
||||
OpenAIWSMode: true,
|
||||
UpstreamTerminalEvent: "response.completed",
|
||||
}))
|
||||
require.False(t, openAIForwardSucceededForScheduling(&service.OpenAIForwardResult{
|
||||
OpenAIWSMode: true,
|
||||
UpstreamTerminalEvent: "response.failed",
|
||||
}))
|
||||
}
|
||||
|
||||
func TestResolveOpenAIMessagesMetadataSession_DoesNotDerivePromptCacheKey(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-sonnet-4-5","metadata":{"user_id":"claude-code-session"},"messages":[{"role":"user","content":"hello"}]}`)
|
||||
|
||||
@@ -221,6 +234,43 @@ func TestOpenAIEnsureForwardErrorResponse_ResponsesRouteAfterWrittenEmitsRespons
|
||||
assert.Contains(t, body, "Upstream request failed")
|
||||
}
|
||||
|
||||
func TestOpenAIEnsureForwardErrorResponse_AfterDeltaAppendsSingleValidResponseFailed(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, EndpointResponses, nil)
|
||||
|
||||
delta := `{"type":"response.output_text.delta","delta":"ok","sequence_number":1}`
|
||||
_, err := c.Writer.WriteString("event: response.output_text.delta\ndata: " + delta + "\n\n")
|
||||
require.NoError(t, err)
|
||||
|
||||
h := &OpenAIGatewayHandler{}
|
||||
require.True(t, h.ensureForwardErrorResponse(c, true))
|
||||
|
||||
frames := strings.Split(strings.TrimSuffix(w.Body.String(), "\n\n"), "\n\n")
|
||||
require.Len(t, frames, 2)
|
||||
errorEvents := 0
|
||||
for _, frame := range frames {
|
||||
lines := strings.Split(frame, "\n")
|
||||
require.Len(t, lines, 2)
|
||||
require.True(t, strings.HasPrefix(lines[0], "event: "))
|
||||
require.True(t, strings.HasPrefix(lines[1], "data: "))
|
||||
|
||||
eventType := strings.TrimPrefix(lines[0], "event: ")
|
||||
data := strings.TrimPrefix(lines[1], "data: ")
|
||||
require.True(t, json.Valid([]byte(data)), "each downstream SSE frame must contain valid JSON")
|
||||
var event struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(data), &event))
|
||||
require.Equal(t, eventType, event.Type)
|
||||
if eventType == "response.failed" {
|
||||
errorEvents++
|
||||
}
|
||||
}
|
||||
require.Equal(t, 1, errorEvents)
|
||||
}
|
||||
|
||||
func TestOpenAIEnsureForwardErrorResponse_ImageJSONKeepaliveWritesSingleJSONFallback(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -820,17 +870,36 @@ func TestOpenAIResponsesWebSocket_IngressCapacityRejected(t *testing.T) {
|
||||
defer wsServer.Close()
|
||||
|
||||
dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http")+"/openai/v1/responses", nil)
|
||||
clientConn, response, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http")+"/openai/v1/responses", nil)
|
||||
cancelDial()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = clientConn.CloseNow() }()
|
||||
require.Error(t, err)
|
||||
require.Nil(t, clientConn)
|
||||
require.NotNil(t, response)
|
||||
require.Equal(t, http.StatusTooManyRequests, response.StatusCode)
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
|
||||
readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
_, _, err = clientConn.Read(readCtx)
|
||||
cancelRead()
|
||||
var closeErr coderws.CloseError
|
||||
require.ErrorAs(t, err, &closeErr)
|
||||
require.Equal(t, coderws.StatusTryAgainLater, closeErr.Code)
|
||||
func TestOpenAIResponsesWebSocket_IngressLeaseBackendUnavailableBeforeUpgrade(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cache := &concurrencyCacheMock{
|
||||
acquireIngressLeaseFn: func(context.Context, int64, int, string) (bool, error) {
|
||||
return false, errors.New("redis unavailable")
|
||||
},
|
||||
}
|
||||
h := newOpenAIHandlerForPreviousResponseIDValidation(t, cache)
|
||||
h.cfg = &config.Config{}
|
||||
h.cfg.Gateway.OpenAIWS.MaxIngressConnectionsPerAPIKey = 1
|
||||
wsServer := newOpenAIWSHandlerTestServer(t, h, middleware.AuthSubject{UserID: 1, Concurrency: 1})
|
||||
defer wsServer.Close()
|
||||
|
||||
dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
clientConn, response, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http")+"/openai/v1/responses", nil)
|
||||
cancelDial()
|
||||
require.Error(t, err)
|
||||
require.Nil(t, clientConn)
|
||||
require.NotNil(t, response)
|
||||
require.Equal(t, http.StatusServiceUnavailable, response.StatusCode)
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesWebSocket_FirstMessageTimeoutUsesConfig(t *testing.T) {
|
||||
@@ -902,6 +971,33 @@ func TestOpenAIResponsesWebSocket_IngressLeaseReleasedOnEarlyReturn(t *testing.T
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesWebSocket_IngressLeaseReleasedWhenUpgradeFails(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cache := &concurrencyCacheMock{
|
||||
acquireIngressLeaseFn: func(context.Context, int64, int, string) (bool, error) {
|
||||
return true, nil
|
||||
},
|
||||
}
|
||||
h := newOpenAIHandlerForPreviousResponseIDValidation(t, cache)
|
||||
h.cfg = &config.Config{}
|
||||
h.cfg.Gateway.OpenAIWS.MaxIngressConnectionsPerAPIKey = 1
|
||||
wsServer := newOpenAIWSHandlerTestServer(t, h, middleware.AuthSubject{UserID: 1, Concurrency: 1})
|
||||
defer wsServer.Close()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, wsServer.URL+"/openai/v1/responses", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
req.Header.Set("Connection", "Upgrade")
|
||||
req.Header.Set("Sec-WebSocket-Version", "13")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
require.NotEqual(t, http.StatusSwitchingProtocols, resp.StatusCode)
|
||||
require.Eventually(t, func() bool {
|
||||
return atomic.LoadInt32(&cache.releaseIngressCalled) == 1
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesWebSocket_RejectsMessageIDAsPreviousResponseID(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpenAIGatewayHandlerResponses_GrokPassiveImageToolDeclarationBypassesPermissionGate(t *testing.T) {
|
||||
body := `{"model":"grok-4.5","tools":[{"type":"namespace","name":"image_gen","tools":[{"type":"function","name":"imagegen"}]}],"tool_choice":"auto","input":"write code"}`
|
||||
rec := runOpenAIResponsesImagePermissionGateTest(t, service.PlatformGrok, body)
|
||||
|
||||
require.NotEqual(t, http.StatusForbidden, rec.Code)
|
||||
require.NotContains(t, rec.Body.String(), service.ImageGenerationPermissionMessage())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayHandlerResponses_GrokResponsesLiteImageToolDeclarationBypassesPermissionGate(t *testing.T) {
|
||||
body := `{"model":"grok-4.5","tool_choice":"auto","input":[{"type":"additional_tools","tools":[{"type":"namespace","name":"image_gen","tools":[{"type":"function","name":"imagegen"}]}]},{"type":"message","role":"user","content":"write code"}]}`
|
||||
rec := runOpenAIResponsesImagePermissionGateTest(t, service.PlatformGrok, body)
|
||||
|
||||
require.NotEqual(t, http.StatusForbidden, rec.Code)
|
||||
require.NotContains(t, rec.Body.String(), service.ImageGenerationPermissionMessage())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayHandlerResponses_ImagePermissionHardSignalsStillRejected(t *testing.T) {
|
||||
passiveNamespace := `{"model":"gpt-5.5","tools":[{"type":"namespace","name":"image_gen","tools":[{"type":"function","name":"imagegen"}]}],"tool_choice":"auto","input":"write code"}`
|
||||
tests := []struct {
|
||||
name string
|
||||
platform string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "OpenAI keeps declaration semantics",
|
||||
platform: service.PlatformOpenAI,
|
||||
body: passiveNamespace,
|
||||
},
|
||||
{
|
||||
name: "Grok native image_generation declaration",
|
||||
platform: service.PlatformGrok,
|
||||
body: `{"model":"grok-4.5","tools":[{"type":"image_generation"}],"input":"draw"}`,
|
||||
},
|
||||
{
|
||||
name: "Grok explicit image_gen tool choice",
|
||||
platform: service.PlatformGrok,
|
||||
body: `{"model":"grok-4.5","tools":[{"type":"namespace","name":"image_gen"}],"tool_choice":{"type":"namespace","name":"image_gen"},"input":"draw"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rec := runOpenAIResponsesImagePermissionGateTest(t, tt.platform, tt.body)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), service.ImageGenerationPermissionMessage())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runOpenAIResponsesImagePermissionGateTest(t *testing.T, platform string, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
groupID := int64(6301)
|
||||
userID := int64(6302)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
ID: 6303,
|
||||
GroupID: &groupID,
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: platform,
|
||||
AllowImageGeneration: false,
|
||||
},
|
||||
User: &service.User{ID: userID, Status: service.StatusActive},
|
||||
})
|
||||
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: userID, Concurrency: 1})
|
||||
|
||||
h := &OpenAIGatewayHandler{
|
||||
gatewayService: &service.OpenAIGatewayService{},
|
||||
billingCacheService: service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil),
|
||||
apiKeyService: &service.APIKeyService{},
|
||||
concurrencyHelper: &ConcurrencyHelper{concurrencyService: service.NewConcurrencyService(
|
||||
&helperConcurrencyCacheStub{userSeq: []bool{true}},
|
||||
)},
|
||||
cfg: &config.Config{},
|
||||
imageLimiter: &imageConcurrencyLimiter{},
|
||||
}
|
||||
|
||||
h.Responses(c)
|
||||
return rec
|
||||
}
|
||||
@@ -253,7 +253,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
|
||||
var imageUpstreamErr *service.OpenAIImagesUpstreamError
|
||||
if errors.As(err, &imageUpstreamErr) {
|
||||
retryableServerError := service.IsOpenAIImagesRetryableUpstreamError(imageUpstreamErr)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, !retryableServerError, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), !retryableServerError, nil)
|
||||
logEvent := "openai.images.upstream_user_error"
|
||||
if retryableServerError {
|
||||
logEvent = "openai.images.upstream_server_error_after_flush"
|
||||
@@ -269,7 +269,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
|
||||
}
|
||||
var failoverErr *service.UpstreamFailoverError
|
||||
if errors.As(err, &failoverErr) {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), false, nil)
|
||||
if service.OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c) != writerSizeBeforeForward {
|
||||
reqLog.Warn("openai.images.upstream_failover_skipped_after_flush",
|
||||
zap.Int64("account_id", account.ID),
|
||||
@@ -323,7 +323,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
|
||||
)
|
||||
continue
|
||||
}
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), false, nil)
|
||||
upstreamErrorAlreadyCommunicated := openAIForwardErrorAlreadyCommunicated(c, writerSizeBeforeForward, err)
|
||||
wroteFallback := false
|
||||
if !upstreamErrorAlreadyCommunicated {
|
||||
@@ -348,9 +348,9 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
|
||||
if account.Type == service.AccountTypeOAuth && !account.IsShadow() {
|
||||
h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(c.Request.Context(), account.ID, result.ResponseHeaders)
|
||||
}
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), true, result.FirstTokenMs)
|
||||
} else {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, nil)
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(requestModel), true, nil)
|
||||
}
|
||||
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
|
||||
@@ -28,6 +28,9 @@ func skipIfExternalServiceUnavailable(t *testing.T, err error) {
|
||||
if strings.Contains(errStr, "certificate has expired") ||
|
||||
strings.Contains(errStr, "certificate is not yet valid") ||
|
||||
strings.Contains(errStr, "connection refused") ||
|
||||
strings.Contains(errStr, "connection reset") ||
|
||||
strings.Contains(errStr, "broken pipe") ||
|
||||
strings.Contains(errStr, "EOF") ||
|
||||
strings.Contains(errStr, "no such host") ||
|
||||
strings.Contains(errStr, "network is unreachable") ||
|
||||
strings.Contains(errStr, "timeout") ||
|
||||
@@ -74,14 +77,15 @@ func TestJA3Fingerprint(t *testing.T) {
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read response: %v", err)
|
||||
skipIfExternalServiceUnavailable(t, err)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skipf("skipping test: external service unhealthy: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var fpResp FingerprintResponse
|
||||
if err := json.Unmarshal(body, &fpResp); err != nil {
|
||||
t.Logf("Response body: %s", string(body))
|
||||
t.Fatalf("failed to parse fingerprint response: %v", err)
|
||||
t.Skipf("skipping test: external service returned unparsable response: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("JA3: %s", fpResp.TLS.JA3)
|
||||
@@ -207,16 +211,15 @@ func fetchFingerprint(t *testing.T, profile *Profile) *TLSInfo {
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read response: %v", err)
|
||||
return nil
|
||||
skipIfExternalServiceUnavailable(t, err)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skipf("skipping test: external service unhealthy: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var fpResp FingerprintResponse
|
||||
if err := json.Unmarshal(body, &fpResp); err != nil {
|
||||
t.Logf("Response body: %s", string(body))
|
||||
t.Fatalf("failed to parse fingerprint response: %v", err)
|
||||
return nil
|
||||
t.Skipf("skipping test: external service returned unparsable response: %v", err)
|
||||
}
|
||||
|
||||
return &fpResp.TLS
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
func (r *channelRepository) ListModelPricing(ctx context.Context, channelID int64) ([]service.ChannelModelPricing, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_output_price, per_request_price, created_at, updated_at
|
||||
`SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price, created_at, updated_at
|
||||
FROM channel_model_pricing WHERE channel_id = $1 ORDER BY id`, channelID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -57,10 +57,10 @@ func (r *channelRepository) UpdateModelPricing(ctx context.Context, pricing *ser
|
||||
}
|
||||
result, err := r.db.ExecContext(ctx,
|
||||
`UPDATE channel_model_pricing
|
||||
SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_read_price = $6, image_output_price = $7, per_request_price = $8, platform = $9, updated_at = NOW()
|
||||
WHERE id = $10`,
|
||||
SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_read_price = $6, image_input_price = $7, image_output_price = $8, per_request_price = $9, platform = $10, updated_at = NOW()
|
||||
WHERE id = $11`,
|
||||
modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheReadPrice,
|
||||
pricing.ImageOutputPrice, pricing.PerRequestPrice, pricing.Platform, pricing.ID,
|
||||
pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, pricing.Platform, pricing.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update model pricing: %w", err)
|
||||
@@ -91,7 +91,7 @@ func (r *channelRepository) ReplaceModelPricing(ctx context.Context, channelID i
|
||||
// batchLoadModelPricing 批量加载多个渠道的模型定价(含区间)
|
||||
func (r *channelRepository) batchLoadModelPricing(ctx context.Context, channelIDs []int64) (map[int64][]service.ChannelModelPricing, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_output_price, per_request_price, created_at, updated_at
|
||||
`SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price, created_at, updated_at
|
||||
FROM channel_model_pricing WHERE channel_id = ANY($1) ORDER BY channel_id, id`,
|
||||
pq.Array(channelIDs),
|
||||
)
|
||||
@@ -172,7 +172,7 @@ func scanModelPricingRows(rows *sql.Rows) ([]service.ChannelModelPricing, []int6
|
||||
if err := rows.Scan(
|
||||
&p.ID, &p.ChannelID, &p.Platform, &modelsJSON, &p.BillingMode,
|
||||
&p.InputPrice, &p.OutputPrice, &p.CacheWritePrice, &p.CacheReadPrice,
|
||||
&p.ImageOutputPrice, &p.PerRequestPrice, &p.CreatedAt, &p.UpdatedAt,
|
||||
&p.ImageInputPrice, &p.ImageOutputPrice, &p.PerRequestPrice, &p.CreatedAt, &p.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, nil, fmt.Errorf("scan model pricing: %w", err)
|
||||
}
|
||||
@@ -229,11 +229,11 @@ func createModelPricingExec(ctx context.Context, exec dbExec, pricing *service.C
|
||||
platform = "anthropic"
|
||||
}
|
||||
err = exec.QueryRowContext(ctx,
|
||||
`INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_output_price, per_request_price)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at`,
|
||||
`INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at`,
|
||||
pricing.ChannelID, platform, modelsJSON, billingMode,
|
||||
pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheReadPrice,
|
||||
pricing.ImageOutputPrice, pricing.PerRequestPrice,
|
||||
pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice,
|
||||
).Scan(&pricing.ID, &pricing.CreatedAt, &pricing.UpdatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert model pricing: %w", err)
|
||||
|
||||
@@ -395,6 +395,43 @@ func (c *schedulerCache) SetSnapshot(ctx context.Context, bucket service.Schedul
|
||||
return c.activateSnapshotVersion(ctx, bucket, token, version)
|
||||
}
|
||||
|
||||
// SetSnapshotAndReturnAccountIDs 完整发布快照,并返回 writeAccounts 实际接受的有序账号 ID。
|
||||
// 该可选能力只供同一重建批次复用,返回前仍会完成版本激活与 fencing 校验。
|
||||
func (c *schedulerCache) SetSnapshotAndReturnAccountIDs(ctx context.Context, bucket service.SchedulerBucket, token service.SchedulerBucketWriteToken, accounts []service.Account) ([]int64, error) {
|
||||
if !token.ValidFor(bucket) {
|
||||
return nil, fmt.Errorf("%w: bucket=%s", service.ErrSchedulerBucketWriteFenced, bucket.String())
|
||||
}
|
||||
// 分配版本与激活指针是两个 fencing 边界;中间写入的数据只有通过第二次校验才能发布。
|
||||
version, err := c.allocateSnapshotVersion(ctx, bucket, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accountIDs, err := c.writeSnapshotVersionAndReturnAccountIDs(ctx, bucket, version, accounts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.activateSnapshotVersion(ctx, bucket, token, version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return accountIDs, nil
|
||||
}
|
||||
|
||||
// SetSnapshotByAccountIDs 复用同批次首次完整写入后得到的账号成员。
|
||||
// 每个桶仍独立分配版本、写入有序集合并执行激活 fencing,只省略重复的账号 JSON 与全局键写入。
|
||||
func (c *schedulerCache) SetSnapshotByAccountIDs(ctx context.Context, bucket service.SchedulerBucket, token service.SchedulerBucketWriteToken, accountIDs []int64) error {
|
||||
if !token.ValidFor(bucket) {
|
||||
return fmt.Errorf("%w: bucket=%s", service.ErrSchedulerBucketWriteFenced, bucket.String())
|
||||
}
|
||||
version, err := c.allocateSnapshotVersion(ctx, bucket, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.writeSnapshotAccountIDs(ctx, bucket, version, accountIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.activateSnapshotVersion(ctx, bucket, token, version)
|
||||
}
|
||||
|
||||
func (c *schedulerCache) allocateSnapshotVersion(ctx context.Context, bucket service.SchedulerBucket, token service.SchedulerBucketWriteToken) (string, error) {
|
||||
result, err := allocateSnapshotVersionScript.Run(ctx, c.rdb, []string{
|
||||
schedulerBucketKey(schedulerEpochPrefix, bucket),
|
||||
@@ -411,35 +448,74 @@ func (c *schedulerCache) allocateSnapshotVersion(ctx context.Context, bucket ser
|
||||
}
|
||||
|
||||
func (c *schedulerCache) writeSnapshotVersion(ctx context.Context, bucket service.SchedulerBucket, version string, accounts []service.Account) error {
|
||||
snapshotKey := schedulerSnapshotKey(bucket, version)
|
||||
cacheableAccounts, err := c.writeAccounts(ctx, accounts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.writeSnapshotAccounts(ctx, bucket, version, cacheableAccounts)
|
||||
}
|
||||
|
||||
if len(cacheableAccounts) > 0 {
|
||||
// 使用序号作为 score,保持数据库返回的排序语义。
|
||||
members := make([]redis.Z, 0, len(cacheableAccounts))
|
||||
for idx, account := range cacheableAccounts {
|
||||
members = append(members, redis.Z{
|
||||
Score: float64(idx),
|
||||
Member: strconv.FormatInt(account.ID, 10),
|
||||
})
|
||||
}
|
||||
pipe := c.rdb.Pipeline()
|
||||
for start := 0; start < len(members); start += c.writeChunkSize {
|
||||
end := start + c.writeChunkSize
|
||||
if end > len(members) {
|
||||
end = len(members)
|
||||
}
|
||||
pipe.ZAdd(ctx, snapshotKey, members[start:end]...)
|
||||
}
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
func (c *schedulerCache) writeSnapshotVersionAndReturnAccountIDs(ctx context.Context, bucket service.SchedulerBucket, version string, accounts []service.Account) ([]int64, error) {
|
||||
accountIDs, err := c.writeAccountIDs(ctx, accounts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.writeSnapshotAccountIDs(ctx, bucket, version, accountIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return accountIDs, nil
|
||||
}
|
||||
|
||||
return nil
|
||||
func (c *schedulerCache) writeSnapshotAccounts(ctx context.Context, bucket service.SchedulerBucket, version string, accounts []service.Account) error {
|
||||
if len(accounts) == 0 {
|
||||
return nil
|
||||
}
|
||||
members := make([]redis.Z, 0, len(accounts))
|
||||
for idx, account := range accounts {
|
||||
members = append(members, redis.Z{
|
||||
Score: float64(idx),
|
||||
Member: strconv.FormatInt(account.ID, 10),
|
||||
})
|
||||
}
|
||||
return c.writeSnapshotMembers(ctx, bucket, version, members)
|
||||
}
|
||||
|
||||
func (c *schedulerCache) writeSnapshotAccountIDs(ctx context.Context, bucket service.SchedulerBucket, version string, accountIDs []int64) error {
|
||||
members := schedulerSnapshotMembers(accountIDs)
|
||||
return c.writeSnapshotMembers(ctx, bucket, version, members)
|
||||
}
|
||||
|
||||
func schedulerSnapshotMembers(accountIDs []int64) []redis.Z {
|
||||
if len(accountIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
// 使用序号作为 score,保持数据库返回的排序语义;重复 ID 继续交由 Redis ZADD
|
||||
// 按最后一个 score 覆盖,与直接从账号切片构造成员时的行为一致。
|
||||
members := make([]redis.Z, 0, len(accountIDs))
|
||||
for idx, accountID := range accountIDs {
|
||||
members = append(members, redis.Z{
|
||||
Score: float64(idx),
|
||||
Member: strconv.FormatInt(accountID, 10),
|
||||
})
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
func (c *schedulerCache) writeSnapshotMembers(ctx context.Context, bucket service.SchedulerBucket, version string, members []redis.Z) error {
|
||||
if len(members) == 0 {
|
||||
return nil
|
||||
}
|
||||
snapshotKey := schedulerSnapshotKey(bucket, version)
|
||||
pipe := c.rdb.Pipeline()
|
||||
for start := 0; start < len(members); start += c.writeChunkSize {
|
||||
end := start + c.writeChunkSize
|
||||
if end > len(members) {
|
||||
end = len(members)
|
||||
}
|
||||
pipe.ZAdd(ctx, snapshotKey, members[start:end]...)
|
||||
}
|
||||
_, err := pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *schedulerCache) activateSnapshotVersion(ctx context.Context, bucket service.SchedulerBucket, token service.SchedulerBucketWriteToken, version string) error {
|
||||
@@ -644,12 +720,28 @@ func decodeCachedAccount(val any) (*service.Account, error) {
|
||||
}
|
||||
|
||||
func (c *schedulerCache) writeAccounts(ctx context.Context, accounts []service.Account) ([]service.Account, error) {
|
||||
cacheableAccounts, _, err := c.writeAccountPayloads(ctx, accounts, false)
|
||||
return cacheableAccounts, err
|
||||
}
|
||||
|
||||
func (c *schedulerCache) writeAccountIDs(ctx context.Context, accounts []service.Account) ([]int64, error) {
|
||||
_, accountIDs, err := c.writeAccountPayloads(ctx, accounts, true)
|
||||
return accountIDs, err
|
||||
}
|
||||
|
||||
func (c *schedulerCache) writeAccountPayloads(ctx context.Context, accounts []service.Account, collectIDs bool) ([]service.Account, []int64, error) {
|
||||
if len(accounts) == 0 {
|
||||
return nil, nil
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
pipe := c.rdb.Pipeline()
|
||||
cacheableAccounts := make([]service.Account, 0, len(accounts))
|
||||
var cacheableAccounts []service.Account
|
||||
var accountIDs []int64
|
||||
if collectIDs {
|
||||
accountIDs = make([]int64, 0, len(accounts))
|
||||
} else {
|
||||
cacheableAccounts = make([]service.Account, 0, len(accounts))
|
||||
}
|
||||
pending := 0
|
||||
flush := func() error {
|
||||
if pending == 0 {
|
||||
@@ -676,19 +768,24 @@ func (c *schedulerCache) writeAccounts(ctx context.Context, accounts []service.A
|
||||
id := strconv.FormatInt(account.ID, 10)
|
||||
pipe.Set(ctx, schedulerAccountKey(id), fullPayload, 0)
|
||||
pipe.Set(ctx, schedulerAccountMetaKey(id), metaPayload, 0)
|
||||
cacheableAccounts = append(cacheableAccounts, account)
|
||||
// 复用路径只保留有序 ID,避免先物化完整账号切片再做第二次扫描。
|
||||
if collectIDs {
|
||||
accountIDs = append(accountIDs, account.ID)
|
||||
} else {
|
||||
cacheableAccounts = append(cacheableAccounts, account)
|
||||
}
|
||||
pending++
|
||||
if pending >= c.writeChunkSize {
|
||||
if err := flush(); err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := flush(); err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
return cacheableAccounts, nil
|
||||
return cacheableAccounts, accountIDs, nil
|
||||
}
|
||||
|
||||
func marshalSchedulerCacheAccount(account service.Account) ([]byte, []byte, error) {
|
||||
|
||||
@@ -5,6 +5,9 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -82,6 +85,170 @@ func TestSchedulerCacheUpdateLastUsedClearsUnencodablePayload(t *testing.T) {
|
||||
require.Nil(t, cached)
|
||||
}
|
||||
|
||||
func TestSchedulerCacheSnapshotAccountIDReusePreservesPayloadAndMembers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cache, _ := newSchedulerCacheUnitWithRedis(t)
|
||||
invalidTime := time.Date(10000, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
validOne := service.Account{
|
||||
ID: 701,
|
||||
Name: "first",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Credentials: map[string]any{"model_mapping": map[string]any{"z": "last", "a": "first"}},
|
||||
Extra: map[string]any{"mixed_scheduling": true},
|
||||
GroupIDs: []int64{17},
|
||||
}
|
||||
validTwo := service.Account{ID: 702, Name: "second", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey}
|
||||
invalid := service.Account{ID: 799, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, ExpiresAt: &invalidTime}
|
||||
accounts := []service.Account{validOne, invalid, validTwo, validOne}
|
||||
|
||||
single := service.SchedulerBucket{GroupID: 17, Platform: service.PlatformOpenAI, Mode: service.SchedulerModeSingle}
|
||||
singleToken, err := cache.CaptureBucketWriteToken(ctx, single)
|
||||
require.NoError(t, err)
|
||||
accountIDs, err := cache.SetSnapshotAndReturnAccountIDs(ctx, single, singleToken, accounts)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []int64{701, 702, 701}, accountIDs, "应保留可编码账号的原顺序和重复项")
|
||||
|
||||
wantFull, err := json.Marshal(validOne)
|
||||
require.NoError(t, err)
|
||||
wantMeta, err := json.Marshal(buildSchedulerMetadataAccount(validOne))
|
||||
require.NoError(t, err)
|
||||
fullBefore, err := cache.rdb.Get(ctx, schedulerAccountKey("701")).Bytes()
|
||||
require.NoError(t, err)
|
||||
metaBefore, err := cache.rdb.Get(ctx, schedulerAccountMetaKey("701")).Bytes()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, wantFull, fullBefore)
|
||||
require.Equal(t, wantMeta, metaBefore)
|
||||
|
||||
forced := service.SchedulerBucket{GroupID: 17, Platform: service.PlatformOpenAI, Mode: service.SchedulerModeForced}
|
||||
forcedToken, err := cache.CaptureBucketWriteToken(ctx, forced)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, cache.SetSnapshotByAccountIDs(ctx, forced, forcedToken, accountIDs))
|
||||
|
||||
fullAfter, err := cache.rdb.Get(ctx, schedulerAccountKey("701")).Bytes()
|
||||
require.NoError(t, err)
|
||||
metaAfter, err := cache.rdb.Get(ctx, schedulerAccountMetaKey("701")).Bytes()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fullBefore, fullAfter, "ID-only 路径不得重写完整账号键")
|
||||
require.Equal(t, metaBefore, metaAfter, "ID-only 路径不得重写调度元数据键")
|
||||
|
||||
for _, bucket := range []service.SchedulerBucket{single, forced} {
|
||||
version, err := cache.rdb.Get(ctx, schedulerBucketKey(schedulerActivePrefix, bucket)).Result()
|
||||
require.NoError(t, err)
|
||||
members, err := cache.rdb.ZRange(ctx, schedulerSnapshotKey(bucket, version), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"702", "701"}, members, bucket.String())
|
||||
}
|
||||
missing, err := cache.GetAccount(ctx, invalid.ID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, missing)
|
||||
}
|
||||
|
||||
func TestSchedulerCacheSnapshotAccountIDReuseKeepsEmptySnapshotSemantics(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cache := newSchedulerCacheUnit(t)
|
||||
invalidTime := time.Date(10000, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
accounts := []service.Account{{ID: 811, Platform: service.PlatformOpenAI, ExpiresAt: &invalidTime}}
|
||||
|
||||
single := service.SchedulerBucket{GroupID: 18, Platform: service.PlatformOpenAI, Mode: service.SchedulerModeSingle}
|
||||
singleToken, err := cache.CaptureBucketWriteToken(ctx, single)
|
||||
require.NoError(t, err)
|
||||
accountIDs, err := cache.SetSnapshotAndReturnAccountIDs(ctx, single, singleToken, accounts)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, accountIDs)
|
||||
|
||||
forced := service.SchedulerBucket{GroupID: 18, Platform: service.PlatformOpenAI, Mode: service.SchedulerModeForced}
|
||||
forcedToken, err := cache.CaptureBucketWriteToken(ctx, forced)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, cache.SetSnapshotByAccountIDs(ctx, forced, forcedToken, accountIDs))
|
||||
|
||||
for _, bucket := range []service.SchedulerBucket{single, forced} {
|
||||
ready, err := cache.rdb.Get(ctx, schedulerBucketKey(schedulerReadyPrefix, bucket)).Result()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "1", ready)
|
||||
snapshot, hit, err := cache.GetSnapshot(ctx, bucket)
|
||||
require.NoError(t, err)
|
||||
require.False(t, hit, bucket.String())
|
||||
require.Nil(t, snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedulerCacheSetSnapshotByAccountIDsKeepsFencing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cache := newSchedulerCacheUnit(t)
|
||||
bucket := service.SchedulerBucket{GroupID: 19, Platform: service.PlatformOpenAI, Mode: service.SchedulerModeForced}
|
||||
|
||||
err := cache.SetSnapshotByAccountIDs(ctx, bucket, service.SchedulerBucketWriteToken{}, []int64{901})
|
||||
require.ErrorIs(t, err, service.ErrSchedulerBucketWriteFenced)
|
||||
_, err = cache.rdb.Get(ctx, schedulerBucketKey(schedulerVersionPrefix, bucket)).Result()
|
||||
require.ErrorIs(t, err, redis.Nil)
|
||||
|
||||
token, err := cache.CaptureBucketWriteToken(ctx, bucket)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, cache.RetireBucket(ctx, bucket))
|
||||
err = cache.SetSnapshotByAccountIDs(ctx, bucket, token, []int64{901})
|
||||
require.ErrorIs(t, err, service.ErrSchedulerBucketRetired)
|
||||
}
|
||||
|
||||
func TestSchedulerCacheSetSnapshotByAccountIDsDoesNotResurrectDeletedAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cache := newSchedulerCacheUnit(t)
|
||||
account := service.Account{ID: 902, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth}
|
||||
single := service.SchedulerBucket{GroupID: 20, Platform: service.PlatformOpenAI, Mode: service.SchedulerModeSingle}
|
||||
singleToken, err := cache.CaptureBucketWriteToken(ctx, single)
|
||||
require.NoError(t, err)
|
||||
accountIDs, err := cache.SetSnapshotAndReturnAccountIDs(ctx, single, singleToken, []service.Account{account})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []int64{account.ID}, accountIDs)
|
||||
require.NoError(t, cache.DeleteAccount(ctx, account.ID))
|
||||
|
||||
forced := service.SchedulerBucket{GroupID: 20, Platform: service.PlatformOpenAI, Mode: service.SchedulerModeForced}
|
||||
forcedToken, err := cache.CaptureBucketWriteToken(ctx, forced)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, cache.SetSnapshotByAccountIDs(ctx, forced, forcedToken, accountIDs))
|
||||
|
||||
full, err := cache.GetAccount(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, full, "ID-only 发布不得复活已删除的完整账号键")
|
||||
snapshot, hit, err := cache.GetSnapshot(ctx, forced)
|
||||
require.NoError(t, err)
|
||||
require.False(t, hit, "元数据缺失时必须安全回源,而不是返回残缺快照")
|
||||
require.Nil(t, snapshot)
|
||||
}
|
||||
|
||||
func TestMarshalSchedulerCacheAccountKeepsEncodingJSONWireFormat(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
account service.Account
|
||||
}{
|
||||
{name: "nil collections", account: service.Account{ID: 801}},
|
||||
{name: "empty collections", account: service.Account{
|
||||
ID: 802,
|
||||
Credentials: map[string]any{},
|
||||
Extra: map[string]any{},
|
||||
GroupIDs: []int64{},
|
||||
Groups: []*service.Group{},
|
||||
}},
|
||||
{name: "nested maps and escaping", account: service.Account{
|
||||
ID: 803,
|
||||
Credentials: map[string]any{"model_mapping": map[string]any{"z": "<last>", "a": "&first"}},
|
||||
Extra: map[string]any{"mixed_scheduling": true},
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
full, meta, err := marshalSchedulerCacheAccount(tc.account)
|
||||
require.NoError(t, err)
|
||||
wantFull, err := json.Marshal(tc.account)
|
||||
require.NoError(t, err)
|
||||
wantMeta, err := json.Marshal(buildSchedulerMetadataAccount(tc.account))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, wantFull, full)
|
||||
require.Equal(t, wantMeta, meta)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSchedulerMetadataAccount_KeepsOpenAIWSFlags(t *testing.T) {
|
||||
account := service.Account{
|
||||
ID: 42,
|
||||
@@ -611,3 +778,126 @@ func TestSchedulerCacheGroupLifecycleLeaseRejectsInvalidInput(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, keys)
|
||||
}
|
||||
|
||||
var schedulerCachePayloadBenchmarkSink int
|
||||
|
||||
func BenchmarkSchedulerCacheAccountPayloadReuse(b *testing.B) {
|
||||
for _, size := range []int{1, 100, 10_000} {
|
||||
accounts := schedulerCacheBenchmarkAccounts(size)
|
||||
b.Run(fmt.Sprintf("pair_baseline_%d_accounts", size), func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
first, err := benchmarkSchedulerLegacySnapshotPayload(accounts)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
second, err := benchmarkSchedulerLegacySnapshotPayload(accounts)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
schedulerCachePayloadBenchmarkSink = first + second
|
||||
}
|
||||
})
|
||||
b.Run(fmt.Sprintf("pair_reuse_%d_accounts", size), func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
ids, total, err := benchmarkSchedulerReusableSnapshotPayload(accounts)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
// 第二个桶仍构造成员,只跳过账号 JSON 与全局账号键。
|
||||
total += len(schedulerSnapshotMembers(ids))
|
||||
schedulerCachePayloadBenchmarkSink = total
|
||||
}
|
||||
})
|
||||
b.Run(fmt.Sprintf("first_baseline_%d_accounts", size), func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
total, err := benchmarkSchedulerLegacySnapshotPayload(accounts)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
schedulerCachePayloadBenchmarkSink = total
|
||||
}
|
||||
})
|
||||
b.Run(fmt.Sprintf("first_reuse_%d_accounts", size), func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
ids, total, err := benchmarkSchedulerReusableSnapshotPayload(accounts)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
total += len(ids)
|
||||
schedulerCachePayloadBenchmarkSink = total
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func benchmarkSchedulerLegacySnapshotPayload(accounts []service.Account) (int, error) {
|
||||
cacheable := make([]service.Account, 0, len(accounts))
|
||||
total := 0
|
||||
for _, account := range accounts {
|
||||
full, meta, err := marshalSchedulerCacheAccount(account)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
total += len(full) + len(meta)
|
||||
cacheable = append(cacheable, account)
|
||||
}
|
||||
members := make([]redis.Z, 0, len(cacheable))
|
||||
for idx, account := range cacheable {
|
||||
members = append(members, redis.Z{Score: float64(idx), Member: strconv.FormatInt(account.ID, 10)})
|
||||
}
|
||||
return total + len(members), nil
|
||||
}
|
||||
|
||||
func benchmarkSchedulerReusableSnapshotPayload(accounts []service.Account) ([]int64, int, error) {
|
||||
accountIDs := make([]int64, 0, len(accounts))
|
||||
total := 0
|
||||
for _, account := range accounts {
|
||||
full, meta, err := marshalSchedulerCacheAccount(account)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
total += len(full) + len(meta)
|
||||
accountIDs = append(accountIDs, account.ID)
|
||||
}
|
||||
total += len(schedulerSnapshotMembers(accountIDs))
|
||||
return accountIDs, total, nil
|
||||
}
|
||||
|
||||
func schedulerCacheBenchmarkAccounts(size int) []service.Account {
|
||||
largeValue := strings.Repeat("x", 4096)
|
||||
credentials := map[string]any{
|
||||
"api_key": "benchmark-key",
|
||||
"model_mapping": map[string]any{"z-model": "z-target", "a-model": "a-target"},
|
||||
"large_value": largeValue,
|
||||
}
|
||||
extra := map[string]any{
|
||||
"mixed_scheduling": true,
|
||||
"model_rate_limits": map[string]any{
|
||||
"z-model": map[string]any{"rate_limit_reset_at": "2026-07-16T00:00:00Z"},
|
||||
"a-model": map[string]any{"rate_limit_reset_at": "2026-07-16T00:00:00Z"},
|
||||
},
|
||||
"large_value": largeValue,
|
||||
}
|
||||
accounts := make([]service.Account, size)
|
||||
for i := range accounts {
|
||||
id := int64(i + 1)
|
||||
accounts[i] = service.Account{
|
||||
ID: id,
|
||||
Name: "benchmark-account",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Credentials: credentials,
|
||||
Extra: extra,
|
||||
GroupIDs: []int64{7, 9},
|
||||
AccountGroups: []service.AccountGroup{
|
||||
{AccountID: id, GroupID: 7, Priority: 1},
|
||||
{AccountID: id, GroupID: 9, Priority: 2},
|
||||
},
|
||||
}
|
||||
}
|
||||
return accounts
|
||||
}
|
||||
@@ -41,6 +41,8 @@ var usageLogInsertArgTypes = [...]string{
|
||||
"integer", // cache_creation_1h_tokens
|
||||
"integer", // image_output_tokens
|
||||
"numeric", // image_output_cost
|
||||
"integer", // image_input_tokens
|
||||
"numeric", // image_input_cost
|
||||
"numeric", // input_cost
|
||||
"numeric", // output_cost
|
||||
"numeric", // cache_creation_cost
|
||||
@@ -234,6 +236,8 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor,
|
||||
cache_creation_1h_tokens,
|
||||
image_output_tokens,
|
||||
image_output_cost,
|
||||
image_input_tokens,
|
||||
image_input_cost,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_creation_cost,
|
||||
@@ -277,7 +281,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor,
|
||||
$10, $11, $12, $13,
|
||||
$14, $15, $16, $17,
|
||||
$18, $19, $20, $21, $22, $23,
|
||||
$24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54
|
||||
$24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56
|
||||
)
|
||||
ON CONFLICT (request_id, api_key_id) DO NOTHING
|
||||
RETURNING id, created_at
|
||||
@@ -686,6 +690,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
|
||||
cache_creation_1h_tokens,
|
||||
image_output_tokens,
|
||||
image_output_cost,
|
||||
image_input_tokens,
|
||||
image_input_cost,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_creation_cost,
|
||||
@@ -725,7 +731,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
|
||||
created_at
|
||||
) AS (VALUES `)
|
||||
|
||||
args := make([]any, 0, len(keys)*54)
|
||||
args := make([]any, 0, len(keys)*56)
|
||||
argPos := 1
|
||||
for idx, key := range keys {
|
||||
if idx > 0 {
|
||||
@@ -771,6 +777,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
|
||||
cache_creation_1h_tokens,
|
||||
image_output_tokens,
|
||||
image_output_cost,
|
||||
image_input_tokens,
|
||||
image_input_cost,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_creation_cost,
|
||||
@@ -827,6 +835,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
|
||||
cache_creation_1h_tokens,
|
||||
image_output_tokens,
|
||||
image_output_cost,
|
||||
image_input_tokens,
|
||||
image_input_cost,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_creation_cost,
|
||||
@@ -923,6 +933,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
|
||||
cache_creation_1h_tokens,
|
||||
image_output_tokens,
|
||||
image_output_cost,
|
||||
image_input_tokens,
|
||||
image_input_cost,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_creation_cost,
|
||||
@@ -962,7 +974,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
|
||||
created_at
|
||||
) AS (VALUES `)
|
||||
|
||||
args := make([]any, 0, len(preparedList)*54)
|
||||
args := make([]any, 0, len(preparedList)*56)
|
||||
argPos := 1
|
||||
for idx, prepared := range preparedList {
|
||||
if idx > 0 {
|
||||
@@ -1005,6 +1017,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
|
||||
cache_creation_1h_tokens,
|
||||
image_output_tokens,
|
||||
image_output_cost,
|
||||
image_input_tokens,
|
||||
image_input_cost,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_creation_cost,
|
||||
@@ -1061,6 +1075,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
|
||||
cache_creation_1h_tokens,
|
||||
image_output_tokens,
|
||||
image_output_cost,
|
||||
image_input_tokens,
|
||||
image_input_cost,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_creation_cost,
|
||||
@@ -1125,6 +1141,8 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared
|
||||
cache_creation_1h_tokens,
|
||||
image_output_tokens,
|
||||
image_output_cost,
|
||||
image_input_tokens,
|
||||
image_input_cost,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_creation_cost,
|
||||
@@ -1168,7 +1186,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared
|
||||
$10, $11, $12, $13,
|
||||
$14, $15, $16, $17,
|
||||
$18, $19, $20, $21, $22, $23,
|
||||
$24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54
|
||||
$24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56
|
||||
)
|
||||
ON CONFLICT (request_id, api_key_id) DO NOTHING
|
||||
`, prepared.args...)
|
||||
@@ -1243,6 +1261,8 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared {
|
||||
log.CacheCreation1hTokens,
|
||||
log.ImageOutputTokens,
|
||||
log.ImageOutputCost,
|
||||
log.ImageInputTokens,
|
||||
log.ImageInputCost,
|
||||
log.InputCost,
|
||||
log.OutputCost,
|
||||
log.CacheCreationCost,
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, created_at"
|
||||
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, created_at"
|
||||
|
||||
func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *service.UsageLog, err error) {
|
||||
query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1"
|
||||
@@ -443,6 +443,8 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
|
||||
cacheCreation1h int
|
||||
imageOutputTokens int
|
||||
imageOutputCost float64
|
||||
imageInputTokens int
|
||||
imageInputCost float64
|
||||
inputCost float64
|
||||
outputCost float64
|
||||
cacheCreationCost float64
|
||||
@@ -501,6 +503,8 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
|
||||
&cacheCreation1h,
|
||||
&imageOutputTokens,
|
||||
&imageOutputCost,
|
||||
&imageInputTokens,
|
||||
&imageInputCost,
|
||||
&inputCost,
|
||||
&outputCost,
|
||||
&cacheCreationCost,
|
||||
@@ -557,6 +561,8 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
|
||||
CacheCreation1hTokens: cacheCreation1h,
|
||||
ImageOutputTokens: imageOutputTokens,
|
||||
ImageOutputCost: imageOutputCost,
|
||||
ImageInputTokens: imageInputTokens,
|
||||
ImageInputCost: imageInputCost,
|
||||
InputCost: inputCost,
|
||||
OutputCost: outputCost,
|
||||
CacheCreationCost: cacheCreationCost,
|
||||
|
||||
@@ -58,6 +58,8 @@ func TestUsageLogRepositoryCreateSyncRequestTypeAndLegacyFields(t *testing.T) {
|
||||
log.CacheCreation1hTokens,
|
||||
log.ImageOutputTokens,
|
||||
log.ImageOutputCost,
|
||||
log.ImageInputTokens,
|
||||
log.ImageInputCost,
|
||||
log.InputCost,
|
||||
log.OutputCost,
|
||||
log.CacheCreationCost,
|
||||
@@ -145,6 +147,8 @@ func TestUsageLogRepositoryCreate_PersistsServiceTier(t *testing.T) {
|
||||
log.CacheCreation1hTokens,
|
||||
log.ImageOutputTokens,
|
||||
log.ImageOutputCost,
|
||||
log.ImageInputTokens,
|
||||
log.ImageInputCost,
|
||||
log.InputCost,
|
||||
log.OutputCost,
|
||||
log.CacheCreationCost,
|
||||
@@ -267,11 +271,11 @@ func TestPrepareUsageLogInsert_PersistsImageSizeMetadata(t *testing.T) {
|
||||
CreatedAt: time.Date(2025, 1, 6, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
|
||||
require.Equal(t, sql.NullString{String: imageSize, Valid: true}, prepared.args[34])
|
||||
require.Equal(t, sql.NullString{String: inputSize, Valid: true}, prepared.args[35])
|
||||
require.Equal(t, sql.NullString{String: outputSize, Valid: true}, prepared.args[36])
|
||||
require.Equal(t, sql.NullString{String: source, Valid: true}, prepared.args[37])
|
||||
breakdownJSON, ok := prepared.args[38].(string)
|
||||
require.Equal(t, sql.NullString{String: imageSize, Valid: true}, prepared.args[36])
|
||||
require.Equal(t, sql.NullString{String: inputSize, Valid: true}, prepared.args[37])
|
||||
require.Equal(t, sql.NullString{String: outputSize, Valid: true}, prepared.args[38])
|
||||
require.Equal(t, sql.NullString{String: source, Valid: true}, prepared.args[39])
|
||||
breakdownJSON, ok := prepared.args[40].(string)
|
||||
require.True(t, ok)
|
||||
require.JSONEq(t, `{"1K":1,"4K":1}`, breakdownJSON)
|
||||
}
|
||||
@@ -790,6 +794,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
|
||||
sql.NullInt64{},
|
||||
0, 0, 0, 0, 0, 0,
|
||||
0, 0.0, // image_output_tokens, image_output_cost
|
||||
0, 0.0, // image_input_tokens, image_input_cost
|
||||
0.0, 0.0, 0.0, 0.0, 0.8, 0.8,
|
||||
1.0,
|
||||
sql.NullFloat64{},
|
||||
@@ -857,6 +862,8 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
|
||||
6, // cache_creation_1h_tokens
|
||||
0, // image_output_tokens
|
||||
0.0, // image_output_cost
|
||||
0, // image_input_tokens
|
||||
0.0, // image_input_cost
|
||||
0.1, // input_cost
|
||||
0.2, // output_cost
|
||||
0.3, // cache_creation_cost
|
||||
@@ -918,6 +925,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
|
||||
sql.NullInt64{},
|
||||
1, 2, 3, 4, 5, 6,
|
||||
0, 0.0, // image_output_tokens, image_output_cost
|
||||
0, 0.0, // image_input_tokens, image_input_cost
|
||||
0.1, 0.2, 0.3, 0.4, 1.0, 0.9,
|
||||
1.0,
|
||||
sql.NullFloat64{},
|
||||
@@ -974,6 +982,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
|
||||
sql.NullInt64{},
|
||||
1, 2, 3, 4, 5, 6,
|
||||
0, 0.0, // image_output_tokens, image_output_cost
|
||||
0, 0.0, // image_input_tokens, image_input_cost
|
||||
0.1, 0.2, 0.3, 0.4, 1.0, 0.9,
|
||||
1.0,
|
||||
sql.NullFloat64{},
|
||||
|
||||
@@ -603,6 +603,8 @@ func TestAPIContracts(t *testing.T) {
|
||||
"image_size": null,
|
||||
"image_input_size": null,
|
||||
"image_output_size": null,
|
||||
"image_input_tokens": 0,
|
||||
"image_input_cost": 0,
|
||||
"image_output_tokens": 0,
|
||||
"image_output_cost": 0,
|
||||
"image_size_source": null,
|
||||
|
||||
@@ -25,7 +25,8 @@ func NewAPIKeyAuthMiddleware(apiKeyService *service.APIKeyService, subscriptionS
|
||||
// - 鉴权(Authentication):验证 Key 有效性、用户状态、IP 限制 —— 始终执行
|
||||
// - 计费执行(Billing Enforcement):过期/配额/订阅/余额检查 —— skipBilling 时整块跳过
|
||||
//
|
||||
// /v1/usage 端点只需鉴权,不需要计费执行(允许过期/配额耗尽的 Key 查询自身用量)。
|
||||
// /v1/usage 和 /v1/sub2api/billing 端点只需鉴权,不需要计费执行。
|
||||
// 前者允许过期/配额耗尽的 Key 查询自身用量,后者用于读取当前 Key 的倍率配置。
|
||||
func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// ── 1. 提取 API Key ──────────────────────────────────────────
|
||||
@@ -128,6 +129,8 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti
|
||||
}
|
||||
ctx := context.WithValue(c.Request.Context(), ctxkey.UserID, apiKey.User.ID)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
billingInfoRequest := c.Request.URL.Path == "/v1/sub2api/billing"
|
||||
skipBilling := c.Request.URL.Path == "/v1/usage" || billingInfoRequest
|
||||
|
||||
// ── 4. SimpleMode → early return ─────────────────────────────
|
||||
|
||||
@@ -139,20 +142,20 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti
|
||||
})
|
||||
c.Set(string(ContextKeyUserRole), apiKey.User.Role)
|
||||
setGroupContext(c, apiKey.Group)
|
||||
_ = apiKeyService.TouchLastUsed(c.Request.Context(), apiKey.ID)
|
||||
if !billingInfoRequest {
|
||||
_ = apiKeyService.TouchLastUsed(c.Request.Context(), apiKey.ID)
|
||||
}
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// ── 5. 加载订阅(订阅模式时始终加载) ───────────────────────
|
||||
|
||||
// skipBilling: /v1/usage 只需鉴权,跳过所有计费执行
|
||||
skipBilling := c.Request.URL.Path == "/v1/usage"
|
||||
// ── 5. 按端点需要加载订阅 ───────────────────────────────────
|
||||
|
||||
var subscription *service.UserSubscription
|
||||
isSubscriptionType := apiKey.Group != nil && apiKey.Group.IsSubscriptionType()
|
||||
|
||||
if isSubscriptionType && subscriptionService != nil {
|
||||
// 倍率自省不需要订阅数据;/v1/usage 仍保留原有订阅读取行为。
|
||||
if isSubscriptionType && subscriptionService != nil && !billingInfoRequest {
|
||||
sub, subErr := subscriptionService.GetActiveSubscription(
|
||||
c.Request.Context(),
|
||||
apiKey.User.ID,
|
||||
@@ -237,7 +240,9 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti
|
||||
})
|
||||
c.Set(string(ContextKeyUserRole), apiKey.User.Role)
|
||||
setGroupContext(c, apiKey.Group)
|
||||
_ = apiKeyService.TouchLastUsed(c.Request.Context(), apiKey.ID)
|
||||
if !billingInfoRequest {
|
||||
_ = apiKeyService.TouchLastUsed(c.Request.Context(), apiKey.ID)
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
@@ -1066,6 +1066,129 @@ func TestAPIKeyAuthTouchesLastUsedInStandardMode(t *testing.T) {
|
||||
require.Equal(t, 1, touchCalls)
|
||||
}
|
||||
|
||||
func TestAPIKeyAuthBillingInfoSkipsBillingAndSideEffects(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
group := &service.Group{
|
||||
ID: 42,
|
||||
Name: "subscription",
|
||||
Status: service.StatusActive,
|
||||
Hydrated: true,
|
||||
SubscriptionType: service.SubscriptionTypeSubscription,
|
||||
}
|
||||
user := &service.User{
|
||||
ID: 7,
|
||||
Role: service.RoleUser,
|
||||
Status: service.StatusActive,
|
||||
Balance: 0,
|
||||
Concurrency: 3,
|
||||
}
|
||||
expiredAt := time.Now().Add(-time.Hour)
|
||||
apiKey := &service.APIKey{
|
||||
ID: 100,
|
||||
UserID: user.ID,
|
||||
Key: "billing-info-auth-only",
|
||||
Status: service.StatusAPIKeyQuotaExhausted,
|
||||
User: user,
|
||||
GroupID: &group.ID,
|
||||
Group: group,
|
||||
Quota: 1,
|
||||
QuotaUsed: 1,
|
||||
ExpiresAt: &expiredAt,
|
||||
}
|
||||
|
||||
touchCalls := 0
|
||||
subscriptionCalls := 0
|
||||
apiKeyRepo := &stubApiKeyRepo{
|
||||
getByKey: func(context.Context, string) (*service.APIKey, error) {
|
||||
clone := *apiKey
|
||||
return &clone, nil
|
||||
},
|
||||
updateLastUsed: func(context.Context, int64, time.Time) error {
|
||||
touchCalls++
|
||||
return nil
|
||||
},
|
||||
}
|
||||
subscriptionRepo := &stubUserSubscriptionRepo{
|
||||
getActive: func(context.Context, int64, int64) (*service.UserSubscription, error) {
|
||||
subscriptionCalls++
|
||||
return nil, service.ErrSubscriptionNotFound
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{RunMode: config.RunModeStandard}
|
||||
apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg)
|
||||
subscriptionService := service.NewSubscriptionService(nil, subscriptionRepo, nil, nil, cfg)
|
||||
t.Cleanup(subscriptionService.Stop)
|
||||
router := newAuthTestRouter(apiKeyService, subscriptionService, cfg)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/sub2api/billing", nil)
|
||||
req.Header.Set("x-api-key", apiKey.Key)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Zero(t, subscriptionCalls)
|
||||
require.Zero(t, touchCalls)
|
||||
}
|
||||
|
||||
func TestAPIKeyAuthBillingInfoSkipsLastUsedInSimpleMode(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
user := &service.User{ID: 7, Role: service.RoleUser, Status: service.StatusActive}
|
||||
apiKey := &service.APIKey{ID: 100, UserID: user.ID, Key: "billing-info-simple", Status: service.StatusActive, User: user}
|
||||
touchCalls := 0
|
||||
apiKeyRepo := &stubApiKeyRepo{
|
||||
getByKey: func(context.Context, string) (*service.APIKey, error) {
|
||||
clone := *apiKey
|
||||
return &clone, nil
|
||||
},
|
||||
updateLastUsed: func(context.Context, int64, time.Time) error {
|
||||
touchCalls++
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{RunMode: config.RunModeSimple}
|
||||
apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg)
|
||||
router := newAuthTestRouter(apiKeyService, nil, cfg)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/sub2api/billing", nil)
|
||||
req.Header.Set("x-api-key", apiKey.Key)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Zero(t, touchCalls)
|
||||
}
|
||||
|
||||
func TestAPIKeyAuthUsageStillTouchesLastUsed(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
user := &service.User{ID: 7, Role: service.RoleUser, Status: service.StatusActive, Balance: 10}
|
||||
apiKey := &service.APIKey{ID: 100, UserID: user.ID, Key: "usage-touch", Status: service.StatusActive, User: user}
|
||||
touchCalls := 0
|
||||
apiKeyRepo := &stubApiKeyRepo{
|
||||
getByKey: func(context.Context, string) (*service.APIKey, error) {
|
||||
clone := *apiKey
|
||||
return &clone, nil
|
||||
},
|
||||
updateLastUsed: func(context.Context, int64, time.Time) error {
|
||||
touchCalls++
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{RunMode: config.RunModeStandard}
|
||||
apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg)
|
||||
router := newAuthTestRouter(apiKeyService, nil, cfg)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/usage", nil)
|
||||
req.Header.Set("x-api-key", apiKey.Key)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Equal(t, 1, touchCalls)
|
||||
}
|
||||
|
||||
func TestAPIKeyAuthAllowsBalanceBelowMinimumReserve(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -1155,9 +1278,12 @@ func TestAPIKeyAuthRejectsExhaustedBalance(t *testing.T) {
|
||||
func newAuthTestRouter(apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) *gin.Engine {
|
||||
router := gin.New()
|
||||
router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, cfg)))
|
||||
router.GET("/t", func(c *gin.Context) {
|
||||
ok := func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
}
|
||||
router.GET("/t", ok)
|
||||
router.GET("/v1/usage", ok)
|
||||
router.GET("/v1/sub2api/billing", ok)
|
||||
return router
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ func RegisterGatewayRoutes(
|
||||
gateway.Use(opsErrorLogger)
|
||||
gateway.Use(endpointNorm)
|
||||
gateway.Use(gin.HandlerFunc(apiKeyAuth))
|
||||
gateway.GET("/sub2api/billing", h.Gateway.KeyBillingInfo)
|
||||
gateway.Use(requireGroupAnthropic)
|
||||
{
|
||||
// /v1/messages: auto-route based on group platform
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/Wei-Shaw/sub2api/internal/web"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type keyBillingRouteAPIKeyRepo struct {
|
||||
service.APIKeyRepository
|
||||
apiKey *service.APIKey
|
||||
}
|
||||
|
||||
func (r *keyBillingRouteAPIKeyRepo) GetByKeyForAuth(_ context.Context, key string) (*service.APIKey, error) {
|
||||
if r.apiKey == nil || key != r.apiKey.Key {
|
||||
return nil, service.ErrAPIKeyNotFound
|
||||
}
|
||||
clone := *r.apiKey
|
||||
return &clone, nil
|
||||
}
|
||||
|
||||
type keyBillingRouteRateRepo struct {
|
||||
service.UserGroupRateRepository
|
||||
lookupCalls int
|
||||
}
|
||||
|
||||
func (r *keyBillingRouteRateRepo) GetByUserAndGroup(context.Context, int64, int64) (*float64, error) {
|
||||
r.lookupCalls++
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *keyBillingRouteRateRepo) GetRPMOverrideByUserAndGroup(context.Context, int64, int64) (*int, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func newKeyBillingRouteTestRouter(runMode string) (*gin.Engine, *keyBillingRouteRateRepo, string) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
group := &service.Group{
|
||||
ID: 42,
|
||||
Status: service.StatusActive,
|
||||
Hydrated: true,
|
||||
Platform: service.PlatformOpenAI,
|
||||
SubscriptionType: service.SubscriptionTypeStandard,
|
||||
RateMultiplier: 0.75,
|
||||
}
|
||||
user := &service.User{ID: 7, Role: service.RoleUser, Status: service.StatusActive, Balance: 10}
|
||||
var groupID *int64
|
||||
var apiKeyGroup *service.Group
|
||||
if runMode != config.RunModeSimple {
|
||||
groupID = &group.ID
|
||||
apiKeyGroup = group
|
||||
}
|
||||
apiKey := &service.APIKey{
|
||||
ID: 100,
|
||||
UserID: user.ID,
|
||||
Key: "billing-route-test-key",
|
||||
Status: service.StatusActive,
|
||||
User: user,
|
||||
GroupID: groupID,
|
||||
Group: apiKeyGroup,
|
||||
}
|
||||
cfg := &config.Config{RunMode: runMode}
|
||||
rateRepo := &keyBillingRouteRateRepo{}
|
||||
apiKeyService := service.NewAPIKeyService(
|
||||
&keyBillingRouteAPIKeyRepo{apiKey: apiKey}, nil, nil, nil, rateRepo, nil, cfg,
|
||||
)
|
||||
gatewayService := service.NewGatewayService(
|
||||
nil, nil, nil, nil, nil, nil, rateRepo, nil, cfg, nil, nil, nil, nil, nil,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
openAIGatewayService := service.NewOpenAIGatewayService(
|
||||
nil, nil, nil, nil, nil, rateRepo, nil, cfg, nil, nil, nil,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
gatewayHandler := handler.NewGatewayHandler(
|
||||
gatewayService, openAIGatewayService, nil, nil, nil, nil, nil, nil,
|
||||
apiKeyService, nil, nil, nil, nil, cfg, nil,
|
||||
)
|
||||
|
||||
router := gin.New()
|
||||
if web.HasEmbeddedFrontend() {
|
||||
router.Use(web.ServeEmbeddedFrontend())
|
||||
}
|
||||
RegisterGatewayRoutes(
|
||||
router,
|
||||
&handler.Handlers{Gateway: gatewayHandler, OpenAIGateway: &handler.OpenAIGatewayHandler{}},
|
||||
servermiddleware.NewAPIKeyAuthMiddleware(apiKeyService, nil, cfg),
|
||||
apiKeyService,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
cfg,
|
||||
)
|
||||
return router, rateRepo, apiKey.Key
|
||||
}
|
||||
|
||||
func TestGatewayRoutesKeyBillingInfoPathIsRegistered(t *testing.T) {
|
||||
router := newGatewayRoutesTestRouter()
|
||||
|
||||
for _, route := range router.Routes() {
|
||||
if route.Method == http.MethodGet && route.Path == "/v1/sub2api/billing" {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatal("GET /v1/sub2api/billing should be registered")
|
||||
}
|
||||
|
||||
func TestGatewayRoutesKeyBillingInfoEndToEnd(t *testing.T) {
|
||||
t.Run("missing credentials", func(t *testing.T) {
|
||||
router, rateRepo, _ := newKeyBillingRouteTestRouter(config.RunModeStandard)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/sub2api/billing", nil))
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
require.Contains(t, w.Header().Get("Content-Type"), "application/json")
|
||||
require.NotContains(t, strings.ToLower(w.Body.String()), "<!doctype html>")
|
||||
require.Zero(t, rateRepo.lookupCalls)
|
||||
})
|
||||
|
||||
t.Run("standard mode", func(t *testing.T) {
|
||||
router, rateRepo, key := newKeyBillingRouteTestRouter(config.RunModeStandard)
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/sub2api/billing", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Contains(t, w.Header().Get("Content-Type"), "application/json")
|
||||
require.Equal(t, "no-store", w.Header().Get("Cache-Control"))
|
||||
require.NotContains(t, strings.ToLower(w.Body.String()), "<!doctype html>")
|
||||
var body map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
require.Equal(t, "sub2api.key_billing", body["object"])
|
||||
require.Equal(t, 0.75, body["effective_rate_multiplier"])
|
||||
require.Equal(t, 1, rateRepo.lookupCalls)
|
||||
})
|
||||
|
||||
t.Run("simple mode", func(t *testing.T) {
|
||||
router, rateRepo, key := newKeyBillingRouteTestRouter(config.RunModeSimple)
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/sub2api/billing", nil)
|
||||
req.Header.Set("x-api-key", key)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, w.Code)
|
||||
require.Contains(t, w.Header().Get("Content-Type"), "application/json")
|
||||
require.NotContains(t, strings.ToLower(w.Body.String()), "<!doctype html>")
|
||||
require.JSONEq(t, `{
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "not_found_error",
|
||||
"message": "Billing information is not supported in simple mode"
|
||||
}
|
||||
}`, w.Body.String())
|
||||
require.Zero(t, rateRepo.lookupCalls)
|
||||
})
|
||||
}
|
||||
@@ -87,6 +87,7 @@ const openAILongContextBillingEnabledKey = "openai_long_context_billing_enabled"
|
||||
const (
|
||||
OpenAIEndpointCapabilityChatCompletions OpenAIEndpointCapability = "chat_completions"
|
||||
OpenAIEndpointCapabilityEmbeddings OpenAIEndpointCapability = "embeddings"
|
||||
OpenAIEndpointCapabilityAlphaSearch OpenAIEndpointCapability = "alpha_search"
|
||||
)
|
||||
|
||||
const openAIEndpointCapabilitiesCredentialKey = "openai_capabilities"
|
||||
@@ -1397,6 +1398,13 @@ func (a *Account) SupportsOpenAIEndpointCapability(capability OpenAIEndpointCapa
|
||||
}
|
||||
switch capability {
|
||||
case OpenAIEndpointCapabilityChatCompletions:
|
||||
case OpenAIEndpointCapabilityAlphaSearch:
|
||||
// Codex alpha/search 是 ChatGPT/Codex 后端工具端点,必须使用
|
||||
// OAuth/PAT/AgentIdentity 这类 ChatGPT 账号凭据;API key 被发往
|
||||
// chatgpt.com/backend-api/codex/alpha/search 会稳定 401。
|
||||
if a.Type != AccountTypeOAuth {
|
||||
return false
|
||||
}
|
||||
case OpenAIEndpointCapabilityEmbeddings:
|
||||
if a.Type != AccountTypeAPIKey {
|
||||
return false
|
||||
@@ -1409,6 +1417,9 @@ func (a *Account) SupportsOpenAIEndpointCapability(capability OpenAIEndpointCapa
|
||||
if !found {
|
||||
return true
|
||||
}
|
||||
if capability == OpenAIEndpointCapabilityAlphaSearch && configured[string(OpenAIEndpointCapabilityChatCompletions)] {
|
||||
return true
|
||||
}
|
||||
return configured[string(capability)]
|
||||
}
|
||||
|
||||
|
||||
@@ -153,7 +153,8 @@ type UsageTokens struct {
|
||||
|
||||
// CostBreakdown 费用明细
|
||||
type CostBreakdown struct {
|
||||
InputCost float64
|
||||
InputCost float64 // 文本输入费用(不含图片输入,图片输入单独记入 ImageInputCost)
|
||||
ImageInputCost float64 // 图片输入 token 费用(如 gpt-image-2 图片编辑)
|
||||
OutputCost float64
|
||||
ImageOutputCost float64
|
||||
CacheCreationCost float64
|
||||
@@ -802,6 +803,7 @@ func (s *BillingService) GetModelPricing(model string) (*ModelPricing, error) {
|
||||
LongContextInputThreshold: litellmPricing.LongContextInputTokenThreshold,
|
||||
LongContextInputMultiplier: litellmPricing.LongContextInputCostMultiplier,
|
||||
LongContextOutputMultiplier: litellmPricing.LongContextOutputCostMultiplier,
|
||||
ImageInputPricePerToken: litellmPricing.InputCostPerImageToken,
|
||||
ImageOutputPricePerToken: litellmPricing.OutputCostPerImageToken,
|
||||
}), nil
|
||||
}
|
||||
@@ -859,6 +861,7 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing
|
||||
pricing.ImageOutputPricePerToken = 0
|
||||
}
|
||||
pricing.ImageOutputPriceExplicit = true
|
||||
applyChannelImageInputPrice(channelPricing, pricing)
|
||||
return pricing, nil
|
||||
}
|
||||
|
||||
@@ -1001,7 +1004,8 @@ func (s *BillingService) computeTokenBreakdown(
|
||||
}
|
||||
|
||||
bd := &CostBreakdown{}
|
||||
// 分离图片输入 token 与文本输入 token(多模态 embedding 等图文不同价场景)。
|
||||
// 分离图片输入 token 与文本输入 token(多模态 embedding、图片编辑等图文不同价场景)。
|
||||
// InputCost 仅计文本输入,图片输入费用单独记入 ImageInputCost,便于对账;总额不变。
|
||||
// ImageInputTokens 为 0 时(绝大多数 chat/vision 流量)走原始单价路径,行为不变。
|
||||
if tokens.ImageInputTokens > 0 {
|
||||
imageInputTokens := tokens.ImageInputTokens
|
||||
@@ -1015,7 +1019,8 @@ func (s *BillingService) computeTokenBreakdown(
|
||||
// 未配置图片输入档时回退到文本 input 价(已含 priority / 长上下文调整)
|
||||
imageInputPrice = inputPrice
|
||||
}
|
||||
bd.InputCost = float64(textInputTokens)*inputPrice + float64(imageInputTokens)*imageInputPrice
|
||||
bd.InputCost = float64(textInputTokens) * inputPrice
|
||||
bd.ImageInputCost = float64(imageInputTokens) * imageInputPrice
|
||||
} else {
|
||||
bd.InputCost = float64(tokens.InputTokens) * inputPrice
|
||||
}
|
||||
@@ -1043,13 +1048,14 @@ func (s *BillingService) computeTokenBreakdown(
|
||||
|
||||
if tierMultiplier != 1.0 {
|
||||
bd.InputCost *= tierMultiplier
|
||||
bd.ImageInputCost *= tierMultiplier
|
||||
bd.OutputCost *= tierMultiplier
|
||||
bd.ImageOutputCost *= tierMultiplier
|
||||
bd.CacheCreationCost *= tierMultiplier
|
||||
bd.CacheReadCost *= tierMultiplier
|
||||
}
|
||||
|
||||
bd.TotalCost = bd.InputCost + bd.OutputCost + bd.ImageOutputCost +
|
||||
bd.TotalCost = bd.InputCost + bd.ImageInputCost + bd.OutputCost + bd.ImageOutputCost +
|
||||
bd.CacheCreationCost + bd.CacheReadCost
|
||||
bd.ActualCost = bd.TotalCost * rateMultiplier
|
||||
bd.LongContextBillingApplied = baselineCost != nil && bd.ActualCost > baselineCost.ActualCost
|
||||
@@ -1277,6 +1283,7 @@ func (s *BillingService) CalculateCostWithLongContext(model string, tokens Usage
|
||||
// 合并成本
|
||||
return &CostBreakdown{
|
||||
InputCost: inRangeCost.InputCost + outRangeCost.InputCost,
|
||||
ImageInputCost: inRangeCost.ImageInputCost + outRangeCost.ImageInputCost,
|
||||
OutputCost: inRangeCost.OutputCost,
|
||||
ImageOutputCost: inRangeCost.ImageOutputCost,
|
||||
CacheCreationCost: inRangeCost.CacheCreationCost,
|
||||
|
||||
@@ -755,7 +755,7 @@ func TestGetModelPricing_DoubaoEmbeddingVisionImageInputRate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 验证双档计费:InputCost = 文本token×文本价 + 图片token×图片价;
|
||||
// 验证双档计费:InputCost = 文本token×文本价(不含图片),ImageInputCost = 图片token×图片价;
|
||||
// 且 ImageInputTokens=0 时走原单价路径,ImageInputTokens>InputTokens 时不负计文本。
|
||||
func TestCalculateCost_DoubaoEmbeddingVisionDifferentialInput(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
@@ -764,22 +764,60 @@ func TestCalculateCost_DoubaoEmbeddingVisionDifferentialInput(t *testing.T) {
|
||||
mixed := UsageTokens{InputTokens: 1340, ImageInputTokens: 28}
|
||||
cost, err := svc.CalculateCost("doubao-embedding-vision", mixed, 1.0)
|
||||
require.NoError(t, err)
|
||||
wantMixed := float64(1312)*0.098e-6 + float64(28)*0.252e-6
|
||||
require.InDelta(t, wantMixed, cost.InputCost, 1e-15)
|
||||
require.InDelta(t, wantMixed, cost.TotalCost, 1e-15)
|
||||
wantText := float64(1312) * 0.098e-6
|
||||
wantImage := float64(28) * 0.252e-6
|
||||
require.InDelta(t, wantText, cost.InputCost, 1e-15, "InputCost 仅计文本输入")
|
||||
require.InDelta(t, wantImage, cost.ImageInputCost, 1e-15, "ImageInputCost 单独计图片输入")
|
||||
require.InDelta(t, wantText+wantImage, cost.TotalCost, 1e-15, "TotalCost 口径不变")
|
||||
require.Zero(t, cost.OutputCost)
|
||||
|
||||
// 纯文本:全部按文本档计费,与原单价路径一致。
|
||||
// 纯文本:全部按文本档计费,与原单价路径一致,无图片输入费用。
|
||||
textOnly := UsageTokens{InputTokens: 1340}
|
||||
costText, err := svc.CalculateCost("doubao-embedding-vision", textOnly, 1.0)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, float64(1340)*0.098e-6, costText.InputCost, 1e-15)
|
||||
require.Zero(t, costText.ImageInputCost)
|
||||
|
||||
// 健壮性:ImageInputTokens 超过 InputTokens 时,文本置 0、计费 token 不超过 InputTokens。
|
||||
weird := UsageTokens{InputTokens: 10, ImageInputTokens: 50}
|
||||
costWeird, err := svc.CalculateCost("doubao-embedding-vision", weird, 1.0)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, float64(10)*0.252e-6, costWeird.InputCost, 1e-15)
|
||||
require.Zero(t, costWeird.InputCost, "全为图片输入时文本费用为 0")
|
||||
require.InDelta(t, float64(10)*0.252e-6, costWeird.ImageInputCost, 1e-15)
|
||||
require.InDelta(t, float64(10)*0.252e-6, costWeird.TotalCost, 1e-15)
|
||||
}
|
||||
|
||||
// 复现 issue #4386:gpt-image-2 /v1/images/edits 带 1 张输入图。
|
||||
// 上游 usage:input_tokens=371(image_tokens=352 + text_tokens=19),
|
||||
// output_tokens=439(全部图片输出)。官方定价:文本输入 $5/1M、图片输入 $8/1M、
|
||||
// 文本输出 $10/1M、图片输出 $30/1M。修复前图片输入被并入文本价,单次偏低 ~6.6%。
|
||||
func TestComputeTokenBreakdown_GptImage2ImageEditIssue4386(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
pricing := &ModelPricing{
|
||||
InputPricePerToken: 5e-6,
|
||||
ImageInputPricePerToken: 8e-6,
|
||||
OutputPricePerToken: 10e-6,
|
||||
ImageOutputPricePerToken: 30e-6,
|
||||
ImageOutputPriceExplicit: true,
|
||||
}
|
||||
tokens := UsageTokens{
|
||||
InputTokens: 371,
|
||||
ImageInputTokens: 352,
|
||||
OutputTokens: 439,
|
||||
ImageOutputTokens: 439,
|
||||
}
|
||||
|
||||
cost := svc.computeTokenBreakdown(pricing, tokens, 1.0, "", false)
|
||||
|
||||
wantTextInput := float64(19) * 5e-6 // 0.000095
|
||||
wantImageInput := float64(352) * 8e-6 // 0.002816
|
||||
wantImageOutput := float64(439) * 30e-6 // 0.013170
|
||||
require.InDelta(t, wantTextInput, cost.InputCost, 1e-15, "InputCost 仅含文本输入")
|
||||
require.InDelta(t, wantImageInput, cost.ImageInputCost, 1e-15, "图片输入按 $8/1M 独立计费")
|
||||
require.Zero(t, cost.OutputCost, "输出全部为图片,文本输出费用为 0")
|
||||
require.InDelta(t, wantImageOutput, cost.ImageOutputCost, 1e-15)
|
||||
require.InDelta(t, 0.016081, cost.TotalCost, 1e-9, "总额应为 $0.016081(修复前为 $0.015025)")
|
||||
}
|
||||
func TestCalculateCostWithLongContext_BelowThreshold(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
@@ -92,6 +92,7 @@ type ChannelModelPricing struct {
|
||||
OutputPrice *float64 // 每 token 输出价格(USD)
|
||||
CacheWritePrice *float64 // 缓存写入价格
|
||||
CacheReadPrice *float64 // 缓存读取价格
|
||||
ImageInputPrice *float64 // 图片输入 token 价格(如 gpt-image-2 图片编辑);未配置时回退文本输入价
|
||||
ImageOutputPrice *float64 // 图片输出价格(向后兼容)
|
||||
PerRequestPrice *float64 // 默认按次计费价格(USD)
|
||||
Intervals []PricingInterval // 区间定价列表
|
||||
|
||||
@@ -645,6 +645,7 @@ func checkPricesNotNegative(p ChannelModelPricing) error {
|
||||
{"output_price", p.OutputPrice},
|
||||
{"cache_write_price", p.CacheWritePrice},
|
||||
{"cache_read_price", p.CacheReadPrice},
|
||||
{"image_input_price", p.ImageInputPrice},
|
||||
{"image_output_price", p.ImageOutputPrice},
|
||||
{"per_request_price", p.PerRequestPrice},
|
||||
}
|
||||
|
||||
@@ -29,6 +29,11 @@ func (s *GatewayService) getUserGroupRateMultiplier(ctx context.Context, userID,
|
||||
return resolver.Resolve(ctx, userID, groupID, groupDefaultMultiplier)
|
||||
}
|
||||
|
||||
// ResolveUserGroupRateMultiplier resolves the same cached multiplier used by usage billing.
|
||||
func (s *GatewayService) ResolveUserGroupRateMultiplier(ctx context.Context, userID, groupID int64, groupDefaultMultiplier float64) float64 {
|
||||
return s.getUserGroupRateMultiplier(ctx, userID, groupID, groupDefaultMultiplier)
|
||||
}
|
||||
|
||||
// RecordUsageInput 记录使用量的输入参数。
|
||||
// 异步 worker 只接收计费所需快照,不能持有 ParsedRequest/RequestBodyRef 这类大请求体引用。
|
||||
type RecordUsageInput struct {
|
||||
@@ -662,7 +667,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
|
||||
}
|
||||
if apiKey.GroupID != nil && apiKey.Group != nil {
|
||||
groupDefault := apiKey.Group.RateMultiplier
|
||||
multiplier = s.getUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, groupDefault)
|
||||
multiplier = s.ResolveUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, groupDefault)
|
||||
}
|
||||
// token 倍率叠加高峰因子(token 计费含图片 token,图片按次倍率不受影响)。高峰因子按请求时刻现算,
|
||||
// 不并入上面的 getUserGroupRateMultiplier,以免污染 user:group 倍率缓存。
|
||||
|
||||
@@ -79,6 +79,56 @@ func IsImageGenerationIntent(endpoint string, requestedModel string, body []byte
|
||||
return imageIntent
|
||||
}
|
||||
|
||||
// IsImageGenerationIntentForPlatform applies platform-specific intent rules.
|
||||
//
|
||||
// Codex advertises the image_gen namespace on ordinary Responses requests so
|
||||
// that it is available if the model needs it. Grok strips namespace and
|
||||
// Responses Lite additional_tools declarations before forwarding, so those
|
||||
// declarations alone must not turn every Codex request into an image request.
|
||||
// Native image_generation tools, explicit image selection and image models
|
||||
// remain image intent. Other platforms retain the original declaration rule.
|
||||
func IsImageGenerationIntentForPlatform(endpoint string, requestedModel string, body []byte, platform string) bool {
|
||||
if !strings.EqualFold(strings.TrimSpace(platform), PlatformGrok) {
|
||||
return IsImageGenerationIntent(endpoint, requestedModel, body)
|
||||
}
|
||||
return isExplicitGrokImageGenerationIntent(endpoint, requestedModel, body)
|
||||
}
|
||||
|
||||
func isExplicitGrokImageGenerationIntent(endpoint string, requestedModel string, body []byte) bool {
|
||||
if IsImageGenerationEndpoint(endpoint) || isOpenAIImageGenerationModel(requestedModel) {
|
||||
return true
|
||||
}
|
||||
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
return false
|
||||
}
|
||||
|
||||
var modelSeen, toolsSeen, toolChoiceSeen bool
|
||||
imageIntent := false
|
||||
parseRawJSONView(body).ForEach(func(key, value gjson.Result) bool {
|
||||
switch key.Str {
|
||||
case "model":
|
||||
if !modelSeen {
|
||||
modelSeen = true
|
||||
imageIntent = isOpenAIImageGenerationModel(strings.TrimSpace(value.String()))
|
||||
}
|
||||
case "tools":
|
||||
if !toolsSeen {
|
||||
toolsSeen = true
|
||||
// Grok removes namespace catalogs before forwarding. Native
|
||||
// image_generation remains an explicit capability request.
|
||||
imageIntent = openAIJSONToolsContainNativeImageGeneration(value)
|
||||
}
|
||||
case "tool_choice":
|
||||
if !toolChoiceSeen {
|
||||
toolChoiceSeen = true
|
||||
imageIntent = openAIJSONToolChoiceSelectsExplicitImageGeneration(value)
|
||||
}
|
||||
}
|
||||
return !imageIntent && (!modelSeen || !toolsSeen || !toolChoiceSeen)
|
||||
})
|
||||
return imageIntent
|
||||
}
|
||||
|
||||
// IsImageGenerationIntentMap is the map-backed variant used after service-side request mutation.
|
||||
func IsImageGenerationIntentMap(endpoint string, requestedModel string, reqBody map[string]any) bool {
|
||||
if IsImageGenerationEndpoint(endpoint) {
|
||||
@@ -140,6 +190,18 @@ func openAIJSONToolsContainImageGeneration(tools gjson.Result) bool {
|
||||
return found
|
||||
}
|
||||
|
||||
func openAIJSONToolsContainNativeImageGeneration(tools gjson.Result) bool {
|
||||
if !tools.IsArray() {
|
||||
return false
|
||||
}
|
||||
found := false
|
||||
tools.ForEach(func(_, item gjson.Result) bool {
|
||||
found = isOpenAIImageGenerationType(openAIJSONString(item.Get("type")))
|
||||
return !found
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func isOpenAIImageGenerationType(value string) bool {
|
||||
return strings.TrimSpace(value) == "image_generation"
|
||||
}
|
||||
@@ -148,9 +210,8 @@ func isOpenAIImageGenNamespaceName(value string) bool {
|
||||
return strings.TrimSpace(value) == "image_gen"
|
||||
}
|
||||
|
||||
// isImageGenNamespaceTool detects the Codex namespace-style image generation
|
||||
// tool declaration: { "type": "namespace", "name": "image_gen", ... }.
|
||||
// Codex /image uses this instead of the flat { "type": "image_generation" }.
|
||||
// isImageGenNamespaceTool detects the namespace advertised by Codex's built-in
|
||||
// image-generation extension instead of a hosted image_generation tool.
|
||||
func isImageGenNamespaceTool(tool gjson.Result) bool {
|
||||
return openAIJSONString(tool.Get("type")) == "namespace" &&
|
||||
isOpenAIImageGenNamespaceName(openAIJSONString(tool.Get("name")))
|
||||
@@ -235,6 +296,45 @@ func openAIJSONToolChoiceSelectsImageGeneration(choice gjson.Result) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func openAIJSONToolChoiceSelectsExplicitImageGeneration(choice gjson.Result) bool {
|
||||
if openAIJSONToolChoiceSelectsImageGeneration(choice) {
|
||||
return true
|
||||
}
|
||||
if !choice.IsObject() {
|
||||
return false
|
||||
}
|
||||
if tool := choice.Get("tool"); tool.IsObject() && openAIJSONToolChoiceSelectsExplicitImageGeneration(tool) {
|
||||
return true
|
||||
}
|
||||
if isOpenAIImageGenFunctionReference(
|
||||
openAIJSONString(choice.Get("namespace")),
|
||||
openAIJSONString(choice.Get("name")),
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if fn := choice.Get("function"); fn.IsObject() {
|
||||
return isOpenAIImageGenFunctionReference(
|
||||
openAIJSONString(fn.Get("namespace")),
|
||||
openAIJSONString(fn.Get("name")),
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isOpenAIImageGenFunctionReference(namespace string, name string) bool {
|
||||
namespace = strings.TrimSpace(namespace)
|
||||
name = strings.TrimSpace(name)
|
||||
if namespace == "image_gen" && name == "imagegen" {
|
||||
return true
|
||||
}
|
||||
switch name {
|
||||
case "image_gen.imagegen", "image_gen__imagegen":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func openAIAnyToolChoiceSelectsImageGeneration(choice any) bool {
|
||||
switch v := choice.(type) {
|
||||
case string:
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIsImageGenerationIntentForPlatform_GrokCodexDeclarations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "top-level image_gen namespace is passive",
|
||||
body: `{"model":"grok-4.5","tools":[{"type":"namespace","name":"image_gen","tools":[{"type":"function","name":"imagegen"}]}],"tool_choice":"auto","input":"write code"}`,
|
||||
},
|
||||
{
|
||||
name: "responses lite additional_tools image_gen is passive",
|
||||
body: `{"model":"grok-4.5","tool_choice":"auto","input":[{"type":"additional_tools","tools":[{"type":"namespace","name":"image_gen","tools":[{"type":"function","name":"imagegen"}]}]},{"type":"message","role":"user","content":"write code"}]}`,
|
||||
},
|
||||
{
|
||||
name: "legacy adapter flattened image_gen function declaration is passive",
|
||||
body: `{"model":"grok-4.5","tools":[{"type":"function","name":"image_gen.imagegen"}],"input":"write code"}`,
|
||||
},
|
||||
{
|
||||
name: "native image generation declaration remains explicit",
|
||||
body: `{"model":"grok-4.5","tools":[{"type":"image_generation"}],"input":"draw a cat"}`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "native image generation tool choice is explicit",
|
||||
body: `{"model":"grok-4.5","tools":[{"type":"image_generation"}],"tool_choice":{"type":"image_generation"},"input":"draw a cat"}`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "image_gen namespace tool choice is explicit",
|
||||
body: `{"model":"grok-4.5","tools":[{"type":"namespace","name":"image_gen"}],"tool_choice":{"type":"namespace","name":"image_gen"},"input":"draw a cat"}`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "flattened image_gen function tool choice is explicit",
|
||||
body: `{"model":"grok-4.5","tools":[{"type":"function","name":"image_gen.imagegen"}],"tool_choice":{"type":"function","name":"image_gen.imagegen"},"input":"draw a cat"}`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wrapped image_gen function tool choice is explicit",
|
||||
body: `{"model":"grok-4.5","tools":[{"type":"function","name":"image_gen.imagegen"}],"tool_choice":{"tool":{"type":"function","name":"image_gen.imagegen"}},"input":"draw a cat"}`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "vision input is not image generation",
|
||||
body: `{"model":"grok-4.5","input":[{"type":"message","role":"user","content":[{"type":"input_image","image_url":"data:image/png;base64,AA=="}]}]}`,
|
||||
},
|
||||
{
|
||||
name: "ordinary function declaration is not image generation",
|
||||
body: `{"model":"grok-4.5","tools":[{"type":"function","name":"lookup"}],"tool_choice":"auto","input":"write code"}`,
|
||||
},
|
||||
{
|
||||
name: "image_gen function call history is not current intent",
|
||||
body: `{"model":"grok-4.5","input":[{"type":"function_call","namespace":"image_gen","name":"imagegen","arguments":"{}"}]}`,
|
||||
},
|
||||
{
|
||||
name: "image generation call history is not current intent",
|
||||
body: `{"model":"grok-4.5","input":[{"type":"image_generation_call","id":"ig_1"}]}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, IsImageGenerationIntentForPlatform(
|
||||
openAIResponsesEndpoint,
|
||||
"grok-4.5",
|
||||
[]byte(tt.body),
|
||||
PlatformGrok,
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsImageGenerationIntentForPlatform_GrokPreservesHardSignals(t *testing.T) {
|
||||
require.True(t, IsImageGenerationIntentForPlatform(
|
||||
"/v1/images/generations",
|
||||
"grok-4.5",
|
||||
[]byte(`{"input":"draw"}`),
|
||||
PlatformGrok,
|
||||
))
|
||||
require.True(t, IsImageGenerationIntentForPlatform(
|
||||
openAIResponsesEndpoint,
|
||||
"gpt-image-2",
|
||||
[]byte(`{"input":"draw"}`),
|
||||
PlatformGrok,
|
||||
))
|
||||
require.True(t, IsImageGenerationIntentForPlatform(
|
||||
openAIResponsesEndpoint,
|
||||
"grok-4.5",
|
||||
[]byte(`{"model":"gpt-image-2","input":"draw"}`),
|
||||
PlatformGrok,
|
||||
))
|
||||
}
|
||||
|
||||
func TestIsImageGenerationIntentForPlatform_OtherPlatformsKeepDeclarationSemantics(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5.5","tools":[{"type":"namespace","name":"image_gen","tools":[{"type":"function","name":"imagegen"}]}],"input":"write code"}`)
|
||||
|
||||
require.True(t, IsImageGenerationIntentForPlatform(openAIResponsesEndpoint, "gpt-5.5", body, PlatformOpenAI))
|
||||
require.True(t, IsImageGenerationIntentForPlatform(openAIResponsesEndpoint, "gpt-5.5", body, PlatformAnthropic))
|
||||
}
|
||||
@@ -161,6 +161,7 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin
|
||||
resolved.BasePricing.ImageOutputPricePerToken = 0
|
||||
}
|
||||
resolved.BasePricing.ImageOutputPriceExplicit = true
|
||||
applyChannelImageInputPrice(chPricing, resolved.BasePricing)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -199,6 +200,20 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin
|
||||
resolved.BasePricing.ImageOutputPricePerToken = 0
|
||||
}
|
||||
resolved.BasePricing.ImageOutputPriceExplicit = true
|
||||
applyChannelImageInputPrice(chPricing, resolved.BasePricing)
|
||||
}
|
||||
|
||||
// applyChannelImageInputPrice 应用渠道图片输入价:显式配置则用配置值;
|
||||
// 未配置时归零,使 computeTokenBreakdown 回退到文本输入价(向后兼容,
|
||||
// 避免 commit 引入的 LiteLLM 图片输入价泄漏进渠道自定义定价)。
|
||||
// 与 image_output 不同,此处不设 Explicit 标志——图片输入未配置应回退文本价,
|
||||
// 而非硬置 0。
|
||||
func applyChannelImageInputPrice(chPricing *ChannelModelPricing, pricing *ModelPricing) {
|
||||
if chPricing != nil && chPricing.ImageInputPrice != nil {
|
||||
pricing.ImageInputPricePerToken = *chPricing.ImageInputPrice
|
||||
} else {
|
||||
pricing.ImageInputPricePerToken = 0
|
||||
}
|
||||
}
|
||||
|
||||
// applyRequestTierOverrides 应用按次/图片模式的渠道覆盖
|
||||
@@ -262,12 +277,14 @@ func intervalToModelPricing(iv *PricingInterval, supportsCacheBreakdown bool, ch
|
||||
pricing.CacheReadPricePerToken = *iv.CacheReadPrice
|
||||
pricing.CacheReadPricePerTokenPriority = *iv.CacheReadPrice
|
||||
}
|
||||
// 渠道定价存在时,ImageOutputPrice 显式覆盖
|
||||
// 渠道定价存在时,ImageOutputPrice 显式覆盖;图片输入价用渠道级配置
|
||||
// (区间不携带图片输入价,与 image_output 一致)。
|
||||
if chPricing != nil {
|
||||
pricing.ImageOutputPriceExplicit = true
|
||||
if chPricing.ImageOutputPrice != nil {
|
||||
pricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice
|
||||
}
|
||||
applyChannelImageInputPrice(chPricing, pricing)
|
||||
}
|
||||
return pricing
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
openAIModelTransientFailureWindow = time.Minute
|
||||
openAIModelTransientShortCooldown = 10 * time.Second
|
||||
openAIModelTransientLongCooldown = 45 * time.Second
|
||||
openAIModelTransientDefaultMax = 4096
|
||||
openAIModelTransientMaxModelBytes = 512
|
||||
)
|
||||
|
||||
type openAIAccountModelKey struct {
|
||||
AccountID int64
|
||||
Model string
|
||||
}
|
||||
|
||||
type openAIAccountModelTransientEntry struct {
|
||||
failureStreak int
|
||||
lastFailure time.Time
|
||||
blockUntil time.Time
|
||||
lastTouched time.Time
|
||||
}
|
||||
|
||||
type openAIAccountModelTransientDecision struct {
|
||||
FailureStreak int
|
||||
Cooldown time.Duration
|
||||
BlockUntil time.Time
|
||||
}
|
||||
|
||||
type openAIAccountModelTransientState struct {
|
||||
mu sync.Mutex
|
||||
entries map[openAIAccountModelKey]openAIAccountModelTransientEntry
|
||||
maxEntries int
|
||||
}
|
||||
|
||||
func newOpenAIAccountModelTransientState(maxEntries int) *openAIAccountModelTransientState {
|
||||
if maxEntries <= 0 {
|
||||
maxEntries = openAIModelTransientDefaultMax
|
||||
}
|
||||
return &openAIAccountModelTransientState{
|
||||
entries: make(map[openAIAccountModelKey]openAIAccountModelTransientEntry),
|
||||
maxEntries: maxEntries,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeOpenAIAccountModelTransientModel(model string) string {
|
||||
model = strings.TrimSpace(model)
|
||||
if len(model) > openAIModelTransientMaxModelBytes {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(model)
|
||||
}
|
||||
|
||||
func openAIAccountModelTransientKey(accountID int64, model string) (openAIAccountModelKey, bool) {
|
||||
model = normalizeOpenAIAccountModelTransientModel(model)
|
||||
if accountID <= 0 || model == "" {
|
||||
return openAIAccountModelKey{}, false
|
||||
}
|
||||
return openAIAccountModelKey{AccountID: accountID, Model: model}, true
|
||||
}
|
||||
|
||||
func (s *openAIAccountModelTransientState) recordFailure(accountID int64, model string, now time.Time) openAIAccountModelTransientDecision {
|
||||
key, ok := openAIAccountModelTransientKey(accountID, model)
|
||||
if s == nil || !ok {
|
||||
return openAIAccountModelTransientDecision{}
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.entries == nil {
|
||||
s.entries = make(map[openAIAccountModelKey]openAIAccountModelTransientEntry)
|
||||
}
|
||||
if s.maxEntries <= 0 {
|
||||
s.maxEntries = openAIModelTransientDefaultMax
|
||||
}
|
||||
|
||||
entry, exists := s.entries[key]
|
||||
if !exists {
|
||||
s.evictOldestLocked()
|
||||
}
|
||||
if !exists || entry.lastFailure.IsZero() || now.Sub(entry.lastFailure) > openAIModelTransientFailureWindow || now.Before(entry.lastFailure) {
|
||||
entry.failureStreak = 0
|
||||
entry.blockUntil = time.Time{}
|
||||
}
|
||||
entry.failureStreak++
|
||||
entry.lastFailure = now
|
||||
entry.lastTouched = now
|
||||
|
||||
cooldown := time.Duration(0)
|
||||
switch {
|
||||
case entry.failureStreak >= 3:
|
||||
cooldown = openAIModelTransientLongCooldown
|
||||
case entry.failureStreak == 2:
|
||||
cooldown = openAIModelTransientShortCooldown
|
||||
}
|
||||
if cooldown > 0 {
|
||||
entry.blockUntil = now.Add(cooldown)
|
||||
} else {
|
||||
entry.blockUntil = time.Time{}
|
||||
}
|
||||
s.entries[key] = entry
|
||||
return openAIAccountModelTransientDecision{
|
||||
FailureStreak: entry.failureStreak,
|
||||
Cooldown: cooldown,
|
||||
BlockUntil: entry.blockUntil,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *openAIAccountModelTransientState) recordSuccess(accountID int64, model string) {
|
||||
key, ok := openAIAccountModelTransientKey(accountID, model)
|
||||
if s == nil || !ok {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
delete(s.entries, key)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *openAIAccountModelTransientState) isBlocked(accountID int64, model string, now time.Time) bool {
|
||||
key, ok := openAIAccountModelTransientKey(accountID, model)
|
||||
if s == nil || !ok {
|
||||
return false
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
entry, exists := s.entries[key]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
if !entry.lastFailure.IsZero() && now.Sub(entry.lastFailure) > openAIModelTransientFailureWindow {
|
||||
delete(s.entries, key)
|
||||
return false
|
||||
}
|
||||
entry.lastTouched = now
|
||||
s.entries[key] = entry
|
||||
return !entry.blockUntil.IsZero() && now.Before(entry.blockUntil)
|
||||
}
|
||||
|
||||
func (s *openAIAccountModelTransientState) size() int {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.entries)
|
||||
}
|
||||
|
||||
func (s *openAIAccountModelTransientState) evictOldestLocked() {
|
||||
if len(s.entries) < s.maxEntries {
|
||||
return
|
||||
}
|
||||
var oldestKey openAIAccountModelKey
|
||||
var oldestTime time.Time
|
||||
found := false
|
||||
for key, entry := range s.entries {
|
||||
if !found || entry.lastTouched.Before(oldestTime) {
|
||||
oldestKey = key
|
||||
oldestTime = entry.lastTouched
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if found {
|
||||
delete(s.entries, oldestKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpenAIModelTransient_FirstFailureDoesNotCreateLongBlock(t *testing.T) {
|
||||
state := newOpenAIAccountModelTransientState(128)
|
||||
now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
decision := state.recordFailure(35, "gpt-5.5", now)
|
||||
|
||||
assert.Equal(t, 1, decision.FailureStreak)
|
||||
assert.Zero(t, decision.Cooldown)
|
||||
assert.False(t, state.isBlocked(35, "gpt-5.5", now))
|
||||
}
|
||||
|
||||
func TestOpenAIModelTransient_SecondFailureCreatesShortModelBlock(t *testing.T) {
|
||||
state := newOpenAIAccountModelTransientState(128)
|
||||
now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
|
||||
state.recordFailure(35, "gpt-5.5", now)
|
||||
|
||||
decision := state.recordFailure(35, "gpt-5.5", now.Add(time.Second))
|
||||
|
||||
assert.Equal(t, 2, decision.FailureStreak)
|
||||
assert.Equal(t, openAIModelTransientShortCooldown, decision.Cooldown)
|
||||
assert.True(t, state.isBlocked(35, "gpt-5.5", now.Add(2*time.Second)))
|
||||
assert.False(t, state.isBlocked(35, "gpt-5.5", now.Add(openAIModelTransientShortCooldown+2*time.Second)))
|
||||
}
|
||||
|
||||
func TestOpenAIModelTransient_ThirdFailureCreatesFortyFiveSecondModelBlock(t *testing.T) {
|
||||
state := newOpenAIAccountModelTransientState(128)
|
||||
now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
|
||||
state.recordFailure(35, "gpt-5.5", now)
|
||||
state.recordFailure(35, "gpt-5.5", now.Add(time.Second))
|
||||
|
||||
decision := state.recordFailure(35, "gpt-5.5", now.Add(2*time.Second))
|
||||
|
||||
assert.Equal(t, 3, decision.FailureStreak)
|
||||
assert.Equal(t, 45*time.Second, decision.Cooldown)
|
||||
assert.True(t, state.isBlocked(35, "gpt-5.5", now.Add(40*time.Second)))
|
||||
assert.False(t, state.isBlocked(35, "gpt-5.5", now.Add(48*time.Second)))
|
||||
}
|
||||
|
||||
func TestOpenAIModelTransient_BlockIsIsolatedByModel(t *testing.T) {
|
||||
state := newOpenAIAccountModelTransientState(128)
|
||||
now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
|
||||
state.recordFailure(35, "gpt-5.6-terra", now)
|
||||
state.recordFailure(35, "GPT-5.6-TERRA", now.Add(time.Second))
|
||||
|
||||
assert.True(t, state.isBlocked(35, "gpt-5.6-terra", now.Add(2*time.Second)))
|
||||
assert.False(t, state.isBlocked(35, "gpt-5.5", now.Add(2*time.Second)))
|
||||
assert.False(t, state.isBlocked(47, "gpt-5.6-terra", now.Add(2*time.Second)))
|
||||
}
|
||||
|
||||
func TestOpenAIModelTransient_SuccessClearsStreakAndBlock(t *testing.T) {
|
||||
state := newOpenAIAccountModelTransientState(128)
|
||||
now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
|
||||
state.recordFailure(35, "gpt-5.5", now)
|
||||
state.recordFailure(35, "gpt-5.5", now.Add(time.Second))
|
||||
require.True(t, state.isBlocked(35, "gpt-5.5", now.Add(2*time.Second)))
|
||||
|
||||
state.recordSuccess(35, "gpt-5.5")
|
||||
|
||||
assert.False(t, state.isBlocked(35, "gpt-5.5", now.Add(2*time.Second)))
|
||||
decision := state.recordFailure(35, "gpt-5.5", now.Add(3*time.Second))
|
||||
assert.Equal(t, 1, decision.FailureStreak)
|
||||
assert.Zero(t, decision.Cooldown)
|
||||
}
|
||||
|
||||
func TestOpenAIModelTransient_StaleStreakExpires(t *testing.T) {
|
||||
state := newOpenAIAccountModelTransientState(128)
|
||||
now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
|
||||
state.recordFailure(35, "gpt-5.5", now)
|
||||
|
||||
decision := state.recordFailure(35, "gpt-5.5", now.Add(openAIModelTransientFailureWindow+time.Second))
|
||||
|
||||
assert.Equal(t, 1, decision.FailureStreak)
|
||||
assert.Zero(t, decision.Cooldown)
|
||||
}
|
||||
|
||||
func TestOpenAIModelTransient_IgnoresInvalidKeys(t *testing.T) {
|
||||
state := newOpenAIAccountModelTransientState(128)
|
||||
now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
assert.Zero(t, state.recordFailure(0, "gpt-5.5", now).FailureStreak)
|
||||
assert.Zero(t, state.recordFailure(35, " ", now).FailureStreak)
|
||||
assert.False(t, state.isBlocked(0, "gpt-5.5", now))
|
||||
assert.False(t, state.isBlocked(35, "", now))
|
||||
assert.Equal(t, 0, state.size())
|
||||
}
|
||||
|
||||
func TestOpenAIModelTransient_IgnoresOversizedModelKey(t *testing.T) {
|
||||
state := newOpenAIAccountModelTransientState(128)
|
||||
now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
|
||||
model := strings.Repeat("m", openAIModelTransientMaxModelBytes+1)
|
||||
|
||||
decision := state.recordFailure(35, model, now)
|
||||
|
||||
assert.Zero(t, decision.FailureStreak)
|
||||
assert.False(t, state.isBlocked(35, model, now))
|
||||
assert.Equal(t, 0, state.size())
|
||||
}
|
||||
|
||||
func TestOpenAIModelTransient_StateIsBoundedAndConcurrencySafe(t *testing.T) {
|
||||
const maxEntries = 16
|
||||
state := newOpenAIAccountModelTransientState(maxEntries)
|
||||
now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < 128; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
model := fmt.Sprintf("gpt-test-%d", i)
|
||||
state.recordFailure(int64(i+1), model, now.Add(time.Duration(i)*time.Millisecond))
|
||||
_ = state.isBlocked(int64(i+1), model, now.Add(time.Second))
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
assert.LessOrEqual(t, state.size(), maxEntries)
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -43,7 +45,9 @@ func isOpenAIAccount(account *Account) bool {
|
||||
return account != nil && (account.Platform == PlatformOpenAI || account.Platform == PlatformGrok)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Context, account *Account, statusCode int, headers http.Header, responseBody []byte, requestedModel ...string) bool {
|
||||
// handleOpenAIAccountUpstreamError expects canonicalModel to be the model used
|
||||
// for scheduling after applying account mapping exactly once.
|
||||
func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Context, account *Account, statusCode int, headers http.Header, responseBody []byte, canonicalModel ...string) bool {
|
||||
stateCtx, cancel := openAIAccountStateContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
@@ -64,16 +68,43 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont
|
||||
if s == nil || account == nil || s.rateLimitService == nil {
|
||||
return false
|
||||
}
|
||||
if len(requestedModel) > 0 && s.rateLimitService.HandleUpstreamModelNotFound(stateCtx, account, requestedModel[0], statusCode, responseBody) {
|
||||
if len(canonicalModel) > 0 && s.rateLimitService.HandleUpstreamModelNotFound(stateCtx, account, canonicalModel[0], statusCode, responseBody) {
|
||||
return true
|
||||
}
|
||||
shouldDisable := s.rateLimitService.HandleUpstreamError(stateCtx, account, statusCode, headers, responseBody)
|
||||
if shouldDisable {
|
||||
s.BlockAccountScheduling(account, time.Time{}, "upstream_disable")
|
||||
}
|
||||
if !shouldDisable && account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey && shouldCooldownOpenAITransientUpstreamError(statusCode, responseBody) {
|
||||
model := ""
|
||||
if len(canonicalModel) > 0 {
|
||||
model = canonicalModel[0]
|
||||
}
|
||||
decision := s.recordOpenAIAccountModelTransientFailure(account, model, time.Now())
|
||||
if decision.FailureStreak > 0 {
|
||||
slog.Warn("openai_model_transient_state",
|
||||
"account_id", account.ID,
|
||||
"model", openAIAccountModelTransientModel(model),
|
||||
"failure_streak", decision.FailureStreak,
|
||||
"cooldown_ms", decision.Cooldown.Milliseconds(),
|
||||
"block_scope", "account_model",
|
||||
)
|
||||
}
|
||||
}
|
||||
return shouldDisable
|
||||
}
|
||||
|
||||
func shouldCooldownOpenAITransientUpstreamError(statusCode int, responseBody []byte) bool {
|
||||
switch statusCode {
|
||||
case http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout, 520, 521, 522, 523, 524:
|
||||
return true
|
||||
case http.StatusBadRequest:
|
||||
return isOpenAITransientProcessingError(statusCode, "", responseBody)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) markOpenAIOAuth429RateLimited(ctx context.Context, account *Account, headers http.Header, responseBody []byte) {
|
||||
if s == nil || !isOpenAIOAuthAccount(account) {
|
||||
return
|
||||
@@ -191,6 +222,68 @@ func (s *OpenAIGatewayService) isOpenAIAccountRuntimeBlocked(account *Account) b
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) getOpenAIAccountModelTransientState() *openAIAccountModelTransientState {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.openaiModelTransientOnce.Do(func() {
|
||||
if s.openaiModelTransient == nil {
|
||||
s.openaiModelTransient = newOpenAIAccountModelTransientState(openAIModelTransientDefaultMax)
|
||||
}
|
||||
})
|
||||
return s.openaiModelTransient
|
||||
}
|
||||
|
||||
func canonicalOpenAIAccountSchedulingModel(account *Account, requestedModel string) string {
|
||||
model := strings.TrimSpace(requestedModel)
|
||||
if account == nil || model == "" {
|
||||
return model
|
||||
}
|
||||
if mapped := strings.TrimSpace(account.GetMappedModel(model)); mapped != "" {
|
||||
return mapped
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
func openAIAccountModelTransientModel(canonicalModel string) string {
|
||||
return normalizeOpenAIAccountModelTransientModel(canonicalModel)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) recordOpenAIAccountModelTransientFailure(account *Account, canonicalModel string, now time.Time) openAIAccountModelTransientDecision {
|
||||
if s == nil || account == nil {
|
||||
return openAIAccountModelTransientDecision{}
|
||||
}
|
||||
state := s.getOpenAIAccountModelTransientState()
|
||||
if state == nil {
|
||||
return openAIAccountModelTransientDecision{}
|
||||
}
|
||||
return state.recordFailure(account.ID, openAIAccountModelTransientModel(canonicalModel), now)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) clearOpenAIAccountModelTransientState(accountID int64, model string) {
|
||||
state := s.getOpenAIAccountModelTransientState()
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
state.recordSuccess(accountID, model)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) isOpenAIAccountModelRuntimeBlocked(account *Account, requestedModel string) bool {
|
||||
if s == nil || account == nil {
|
||||
return false
|
||||
}
|
||||
state := s.getOpenAIAccountModelTransientState()
|
||||
if state == nil {
|
||||
return false
|
||||
}
|
||||
canonicalModel := canonicalOpenAIAccountSchedulingModel(account, requestedModel)
|
||||
return state.isBlocked(account.ID, openAIAccountModelTransientModel(canonicalModel), time.Now())
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) isOpenAIAccountRequestRuntimeBlocked(account *Account, requestedModel string) bool {
|
||||
return s != nil && (s.isOpenAIAccountRuntimeBlocked(account) || s.isOpenAIAccountModelRuntimeBlocked(account, requestedModel))
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) recordOpenAIOAuth429() {
|
||||
if s == nil {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type transientCooldownAccountRepo struct {
|
||||
AccountRepository
|
||||
}
|
||||
|
||||
func (transientCooldownAccountRepo) SetOverloaded(context.Context, int64, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestHandleOpenAITransientError_BlocksOnlyRequestedModel(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{}
|
||||
svc.rateLimitService = NewRateLimitService(transientCooldownAccountRepo{}, nil, &config.Config{}, nil, nil)
|
||||
account := &Account{
|
||||
ID: 5105,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
}
|
||||
|
||||
firstShouldDisable := svc.handleOpenAIAccountUpstreamError(context.Background(), account, http.StatusBadGateway, http.Header{}, []byte(`{"error":{"message":"Upstream request failed","type":"upstream_error"}}`), "gpt-5.5")
|
||||
secondShouldDisable := svc.handleOpenAIAccountUpstreamError(context.Background(), account, http.StatusBadGateway, http.Header{}, []byte(`{"error":{"message":"Upstream request failed","type":"upstream_error"}}`), "gpt-5.5")
|
||||
|
||||
require.False(t, firstShouldDisable)
|
||||
require.False(t, secondShouldDisable)
|
||||
require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
|
||||
require.True(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "gpt-5.5"))
|
||||
require.False(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "gpt-5.6-terra"))
|
||||
}
|
||||
|
||||
func TestHandleOpenAITransientError_TransientStatusesUseModelScope(t *testing.T) {
|
||||
for _, statusCode := range []int{http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout, 520, 521, 522, 523, 524} {
|
||||
t.Run(http.StatusText(statusCode), func(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{}
|
||||
svc.rateLimitService = NewRateLimitService(transientCooldownAccountRepo{}, nil, &config.Config{}, nil, nil)
|
||||
account := &Account{
|
||||
ID: int64(5100 + statusCode),
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
}
|
||||
|
||||
firstShouldDisable := svc.handleOpenAIAccountUpstreamError(context.Background(), account, statusCode, http.Header{}, []byte(`{"error":{"message":"temporary upstream failure"}}`), "gpt-5.5")
|
||||
secondShouldDisable := svc.handleOpenAIAccountUpstreamError(context.Background(), account, statusCode, http.Header{}, []byte(`{"error":{"message":"temporary upstream failure"}}`), "gpt-5.5")
|
||||
|
||||
require.False(t, firstShouldDisable)
|
||||
require.False(t, secondShouldDisable)
|
||||
require.False(t, svc.isOpenAIAccountRuntimeBlocked(account), "status %d must not block the whole account", statusCode)
|
||||
require.True(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "gpt-5.5"), "status %d should block the failing model", statusCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOpenAITransientError_529RemainsOverloadOnly(t *testing.T) {
|
||||
require.False(t, shouldCooldownOpenAITransientUpstreamError(529, []byte(`{"error":{"message":"overloaded"}}`)))
|
||||
}
|
||||
|
||||
func TestHandleOpenAITransientError_CanonicalModelIsNotMappedTwice(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{}
|
||||
svc.rateLimitService = NewRateLimitService(transientCooldownAccountRepo{}, nil, &config.Config{}, nil, nil)
|
||||
account := &Account{
|
||||
ID: 5107,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"public-alias": "upstream-a",
|
||||
"upstream-a": "upstream-b",
|
||||
},
|
||||
},
|
||||
}
|
||||
canonicalModel := account.GetMappedModel("public-alias")
|
||||
require.Equal(t, "upstream-a", canonicalModel)
|
||||
|
||||
for range 2 {
|
||||
svc.handleOpenAIAccountUpstreamError(context.Background(), account, http.StatusBadGateway, http.Header{}, []byte(`{"error":{"message":"temporary upstream failure"}}`), canonicalModel)
|
||||
}
|
||||
|
||||
require.True(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "public-alias"))
|
||||
svc.ReportOpenAIAccountScheduleResult(account.ID, canonicalModel, true, nil)
|
||||
require.False(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "public-alias"))
|
||||
}
|
||||
|
||||
func TestHandleOpenAITransientError_DoesNotBlockParameter400(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{}
|
||||
svc.rateLimitService = NewRateLimitService(transientCooldownAccountRepo{}, nil, &config.Config{}, nil, nil)
|
||||
account := &Account{
|
||||
ID: 5103,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
}
|
||||
|
||||
shouldDisable := svc.handleOpenAIAccountUpstreamError(context.Background(), account, http.StatusBadRequest, http.Header{}, []byte(`{"error":{"message":"Invalid type for input[0].arguments"}}`), "gpt-5.5")
|
||||
|
||||
require.False(t, shouldDisable)
|
||||
require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
|
||||
require.False(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "gpt-5.5"))
|
||||
}
|
||||
|
||||
func TestHandleOpenAITransientError_HardDisableStillBlocksWholeAccount(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{}
|
||||
account := &Account{ID: 5106, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
|
||||
svc.BlockAccountScheduling(account, time.Now().Add(time.Minute), "upstream_disable")
|
||||
|
||||
require.True(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.5"))
|
||||
require.True(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.6-sol"))
|
||||
}
|
||||
@@ -1301,7 +1301,7 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance(
|
||||
if !account.IsSchedulable() || account.Platform != normalizeOpenAICompatiblePlatform(req.Platform) || !account.IsOpenAICompatible() {
|
||||
continue
|
||||
}
|
||||
if s.service.isOpenAIAccountRuntimeBlocked(account) {
|
||||
if s.service.isOpenAIAccountRequestRuntimeBlocked(account, req.RequestedModel) {
|
||||
continue
|
||||
}
|
||||
// require_privacy_set: 跳过 privacy 未设置的账号并标记异常
|
||||
@@ -1607,7 +1607,7 @@ func (s *defaultOpenAIAccountScheduler) isAccountRequestCompatible(ctx context.C
|
||||
if account == nil {
|
||||
return false
|
||||
}
|
||||
if s != nil && s.service != nil && s.service.isOpenAIAccountRuntimeBlocked(account) {
|
||||
if s != nil && s.service != nil && s.service.isOpenAIAccountRequestRuntimeBlocked(account, req.RequestedModel) {
|
||||
return false
|
||||
}
|
||||
// Quota auto-pause must be evaluated during the initial filter too. Without it the
|
||||
@@ -2111,7 +2111,10 @@ func (s *OpenAIGatewayService) isOpenAIAccountTransportCompatible(account *Accou
|
||||
return s.getOpenAIWSProtocolResolver().Resolve(account).Transport == requiredTransport
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) ReportOpenAIAccountScheduleResult(accountID int64, success bool, firstTokenMs *int) {
|
||||
func (s *OpenAIGatewayService) ReportOpenAIAccountScheduleResult(accountID int64, model string, success bool, firstTokenMs *int) {
|
||||
if success {
|
||||
s.clearOpenAIAccountModelTransientState(accountID, normalizeOpenAIAccountModelTransientModel(model))
|
||||
}
|
||||
scheduler := s.getOpenAIAccountScheduler(context.Background())
|
||||
if scheduler == nil {
|
||||
return
|
||||
|
||||
@@ -1098,7 +1098,7 @@ func TestOpenAIGatewayService_OpenAIAccountSchedulerMetrics_DisabledNoOp(t *test
|
||||
|
||||
svc := &OpenAIGatewayService{}
|
||||
ttft := 120
|
||||
svc.ReportOpenAIAccountScheduleResult(10, true, &ttft)
|
||||
svc.ReportOpenAIAccountScheduleResult(10, "", true, &ttft)
|
||||
svc.RecordOpenAIAccountSwitch()
|
||||
|
||||
snapshot := svc.SnapshotOpenAIAccountSchedulerMetrics()
|
||||
@@ -2239,6 +2239,30 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_UsesAccountPriorityWith
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIAccountScheduler_SkipsAccountBlockedForRequestedModel(t *testing.T) {
|
||||
now := time.Now()
|
||||
account := &Account{ID: 21633, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
svc := &OpenAIGatewayService{openaiModelTransient: newOpenAIAccountModelTransientState(128)}
|
||||
svc.openaiModelTransient.recordFailure(account.ID, "gpt-5.5", now)
|
||||
svc.openaiModelTransient.recordFailure(account.ID, "gpt-5.5", now.Add(time.Millisecond))
|
||||
scheduler := &defaultOpenAIAccountScheduler{service: svc}
|
||||
|
||||
require.False(t, scheduler.isAccountRequestCompatible(context.Background(), account, OpenAIAccountScheduleRequest{RequestedModel: "gpt-5.5"}))
|
||||
require.True(t, scheduler.isAccountRequestCompatible(context.Background(), account, OpenAIAccountScheduleRequest{RequestedModel: "gpt-5.6-sol"}))
|
||||
}
|
||||
|
||||
func TestReportOpenAIAccountScheduleResult_SuccessClearsModelTransientState(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{openaiModelTransient: newOpenAIAccountModelTransientState(128)}
|
||||
now := time.Now()
|
||||
svc.openaiModelTransient.recordFailure(21636, "gpt-5.5", now)
|
||||
svc.openaiModelTransient.recordFailure(21636, "gpt-5.5", now.Add(time.Millisecond))
|
||||
require.True(t, svc.openaiModelTransient.isBlocked(21636, "gpt-5.5", now.Add(2*time.Millisecond)))
|
||||
|
||||
svc.ReportOpenAIAccountScheduleResult(21636, "gpt-5.5", true, nil)
|
||||
|
||||
require.False(t, svc.openaiModelTransient.isBlocked(21636, "gpt-5.5", now.Add(2*time.Millisecond)))
|
||||
}
|
||||
|
||||
func TestDefaultOpenAIAccountScheduler_ShouldEscapeStickyAccount_ThresholdBoundary(t *testing.T) {
|
||||
stats := newOpenAIAccountRuntimeStats()
|
||||
accountID := int64(21501)
|
||||
@@ -2694,7 +2718,7 @@ func TestOpenAIGatewayService_OpenAIAccountSchedulerMetrics(t *testing.T) {
|
||||
selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "session_hash_metrics", "gpt-5.1", nil, OpenAIUpstreamTransportAny, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, selection)
|
||||
svc.ReportOpenAIAccountScheduleResult(account.ID, true, intPtrForTest(120))
|
||||
svc.ReportOpenAIAccountScheduleResult(account.ID, "", true, intPtrForTest(120))
|
||||
svc.RecordOpenAIAccountSwitch()
|
||||
|
||||
snapshot := svc.SnapshotOpenAIAccountSchedulerMetrics()
|
||||
@@ -3031,7 +3055,7 @@ func TestOpenAIGatewayService_SchedulerWrappersAndDefaults(t *testing.T) {
|
||||
|
||||
svc := &OpenAIGatewayService{}
|
||||
ttft := 120
|
||||
svc.ReportOpenAIAccountScheduleResult(10, true, &ttft)
|
||||
svc.ReportOpenAIAccountScheduleResult(10, "", true, &ttft)
|
||||
svc.RecordOpenAIAccountSwitch()
|
||||
snapshot := svc.SnapshotOpenAIAccountSchedulerMetrics()
|
||||
require.Equal(t, OpenAIAccountSchedulerMetricsSnapshot{}, snapshot)
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -39,21 +40,38 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co
|
||||
if upstreamModel != "" && upstreamModel != requestedModel {
|
||||
body = ReplaceModelInBody(body, upstreamModel)
|
||||
}
|
||||
sanitizedBody, err := sanitizeOpenAIAlphaSearchBody(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sanitize alpha search request body: %w", err)
|
||||
}
|
||||
body = sanitizedBody
|
||||
|
||||
token, _, err := s.GetAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := s.buildOpenAIAlphaSearchRequest(ctx, c, account, body, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
if err := s.ensureOpenAIAlphaSearchAuthMetadata(ctx, account, token, proxyURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Codex Personal Access Token(at-...)目前可访问 ChatGPT Codex
|
||||
// /responses,但会被 standalone /alpha/search 的 access enforcement
|
||||
// 拒绝为 no_matching_rule。对 PAT 账号使用等价的 hosted web_search
|
||||
// Responses 路径兜底,避免把可用账号误判为搜索不可用。
|
||||
if account.IsOpenAIPersonalAccessToken() {
|
||||
return s.forwardAlphaSearchViaResponsesWebSearch(ctx, c, account, body, token, proxyURL, requestedModel, upstreamModel)
|
||||
}
|
||||
|
||||
req, err := s.buildOpenAIAlphaSearchRequest(ctx, c, account, body, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
upstreamStart := time.Now()
|
||||
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
|
||||
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
|
||||
@@ -71,7 +89,14 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co
|
||||
upstreamMessage := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody)))
|
||||
if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMessage, respBody) {
|
||||
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
||||
s.handleFailoverSideEffects(ctx, resp, account, respBody, upstreamModel)
|
||||
// alpha/search 是独立的工具端点,单次 401 不能证明账号的模型调用
|
||||
// 凭据全局失效。若沿用通用 401 逻辑,PAT 会因没有 refresh_token
|
||||
// 被永久标记为 error;历史导入且缺少 auth_mode 标记的 at- token 也会
|
||||
// 漏过 PAT 类型判断。这里仍允许本次请求换号,但不修改任何账号状态;
|
||||
// 真正的凭据失效由普通 Responses 请求或 whoami 校验判定。
|
||||
if shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(resp.StatusCode) {
|
||||
s.handleFailoverSideEffects(ctx, resp, account, respBody, upstreamModel)
|
||||
}
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: resp.StatusCode,
|
||||
ResponseBody: respBody,
|
||||
@@ -102,16 +127,212 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) buildOpenAIAlphaSearchRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token string) (*http.Request, error) {
|
||||
clientBeta := ""
|
||||
if c != nil {
|
||||
clientBeta = c.GetHeader("OpenAI-Beta")
|
||||
func (s *OpenAIGatewayService) forwardAlphaSearchViaResponsesWebSearch(
|
||||
ctx context.Context,
|
||||
c *gin.Context,
|
||||
account *Account,
|
||||
alphaBody []byte,
|
||||
token string,
|
||||
proxyURL string,
|
||||
requestedModel string,
|
||||
upstreamModel string,
|
||||
) (*OpenAIForwardResult, error) {
|
||||
if upstreamModel == "" {
|
||||
upstreamModel = requestedModel
|
||||
}
|
||||
req, err := s.buildUpstreamRequestOpenAIPassthrough(ctx, c, account, body, token)
|
||||
responsesBody, err := buildOpenAIAlphaSearchResponsesWebSearchBody(alphaBody, upstreamModel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := s.buildOpenAIAlphaSearchResponsesWebSearchRequest(ctx, c, account, alphaBody, responsesBody, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
SetActualOpenAIUpstreamEndpoint(c, "/v1/responses")
|
||||
|
||||
upstreamStart := time.Now()
|
||||
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
|
||||
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
|
||||
if err != nil {
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read alpha search responses fallback response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
upstreamMessage := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody)))
|
||||
if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMessage, respBody) {
|
||||
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
||||
// 仍按 alpha/search 工具请求处理:PAT 的工具链路失败不能直接永久置错。
|
||||
if shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(resp.StatusCode) {
|
||||
s.handleFailoverSideEffects(ctx, resp, account, respBody, upstreamModel)
|
||||
}
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: resp.StatusCode,
|
||||
ResponseBody: respBody,
|
||||
RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/json"
|
||||
}
|
||||
c.Data(resp.StatusCode, contentType, respBody)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if !account.IsShadow() {
|
||||
s.UpdateCodexUsageSnapshotFromHeaders(ctx, account.ID, resp.Header)
|
||||
}
|
||||
alphaRespBody, err := openAIAlphaSearchResponseFromResponsesSSE(respBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", alphaRespBody)
|
||||
return &OpenAIForwardResult{
|
||||
RequestID: strings.TrimSpace(resp.Header.Get("x-request-id")),
|
||||
Model: requestedModel,
|
||||
UpstreamModel: upstreamModel,
|
||||
UpstreamEndpoint: "/v1/responses",
|
||||
ResponseHeaders: resp.Header.Clone(),
|
||||
Duration: time.Since(upstreamStart),
|
||||
WebSearchCalls: 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(ctx context.Context, c *gin.Context, account *Account, alphaBody []byte, body []byte, token string) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, chatgptCodexURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
|
||||
|
||||
authHeaders, err := s.buildOpenAIAuthenticationHeaders(ctx, account, token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build openai authentication headers: %w", err)
|
||||
}
|
||||
for key, values := range authHeaders {
|
||||
for _, value := range values {
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
req.Host = "chatgpt.com"
|
||||
if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, req.Header, account); err != nil {
|
||||
return nil, fmt.Errorf("resolve chatgpt account headers: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
req.Header.Set("OpenAI-Beta", "responses=experimental")
|
||||
if turnMetadata := openAIAlphaSearchInboundHeader(c, "X-Codex-Turn-Metadata"); turnMetadata != "" {
|
||||
req.Header.Set("X-Codex-Turn-Metadata", turnMetadata)
|
||||
}
|
||||
if version := openAIAlphaSearchInboundHeader(c, "Version"); version != "" {
|
||||
req.Header.Set("Version", version)
|
||||
} else {
|
||||
req.Header.Set("Version", codexCLIVersion)
|
||||
}
|
||||
if originator := openAIAlphaSearchInboundHeader(c, "Originator"); originator != "" {
|
||||
req.Header.Set("Originator", originator)
|
||||
} else {
|
||||
req.Header.Set("Originator", "codex_cli_rs")
|
||||
}
|
||||
if customUA := account.GetOpenAIUserAgent(); customUA != "" {
|
||||
req.Header.Set("User-Agent", customUA)
|
||||
} else if userAgent := openAIAlphaSearchInboundHeader(c, "User-Agent"); userAgent != "" {
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
} else {
|
||||
req.Header.Set("User-Agent", codexCLIUserAgent)
|
||||
}
|
||||
if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
|
||||
req.Header.Set("User-Agent", codexCLIUserAgent)
|
||||
}
|
||||
apiKeyID := getAPIKeyIDFromContext(c)
|
||||
if sessionID := strings.TrimSpace(gjson.GetBytes(alphaBody, "id").String()); sessionID != "" {
|
||||
isolated := isolateOpenAISessionID(apiKeyID, sessionID)
|
||||
req.Header.Set("Session_ID", isolated)
|
||||
req.Header.Set("Conversation_ID", isolated)
|
||||
}
|
||||
s.overrideBrowserUserAgent(ctx, account, req)
|
||||
enforceCodexIdentityHeaders(req.Header)
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func buildOpenAIAlphaSearchResponsesWebSearchBody(alphaBody []byte, model string) ([]byte, error) {
|
||||
if strings.TrimSpace(model) == "" {
|
||||
return nil, fmt.Errorf("model is required")
|
||||
}
|
||||
tool := map[string]any{"type": "web_search"}
|
||||
if contextSize := strings.TrimSpace(gjson.GetBytes(alphaBody, "settings.search_context_size").String()); contextSize != "" {
|
||||
tool["search_context_size"] = contextSize
|
||||
}
|
||||
if userLocation := gjson.GetBytes(alphaBody, "settings.user_location"); userLocation.IsObject() {
|
||||
var loc map[string]any
|
||||
if err := json.Unmarshal([]byte(userLocation.Raw), &loc); err == nil && len(loc) > 0 {
|
||||
tool["user_location"] = loc
|
||||
}
|
||||
}
|
||||
payload := map[string]any{
|
||||
"model": model,
|
||||
"stream": true,
|
||||
"store": false,
|
||||
"input": []any{
|
||||
map[string]any{
|
||||
"role": "user",
|
||||
"content": []any{
|
||||
map[string]any{
|
||||
"type": "input_text",
|
||||
"text": openAIAlphaSearchResponsesWebSearchPrompt(alphaBody),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"tools": []any{tool},
|
||||
}
|
||||
return json.Marshal(payload)
|
||||
}
|
||||
|
||||
func openAIAlphaSearchResponsesWebSearchPrompt(alphaBody []byte) string {
|
||||
var b strings.Builder
|
||||
_, _ = b.WriteString("Execute this Codex standalone web.run request for another model.\n")
|
||||
_, _ = b.WriteString("Use the hosted web_search tool when web/current information is needed.\n")
|
||||
_, _ = b.WriteString("Return concise source-backed results. Include titles, URLs, dates, and direct answers when available.\n")
|
||||
if commands := strings.TrimSpace(gjson.GetBytes(alphaBody, "commands").Raw); commands != "" {
|
||||
_, _ = b.WriteString("\nCommands JSON:\n")
|
||||
_, _ = b.WriteString(truncateOpenAIAlphaSearchPromptJSON(commands, 12000))
|
||||
}
|
||||
if settings := strings.TrimSpace(gjson.GetBytes(alphaBody, "settings").Raw); settings != "" {
|
||||
_, _ = b.WriteString("\n\nSearch settings JSON:\n")
|
||||
_, _ = b.WriteString(truncateOpenAIAlphaSearchPromptJSON(settings, 4000))
|
||||
}
|
||||
if input := strings.TrimSpace(gjson.GetBytes(alphaBody, "input").Raw); input != "" {
|
||||
_, _ = b.WriteString("\n\nRecent conversation/input JSON:\n")
|
||||
_, _ = b.WriteString(truncateOpenAIAlphaSearchPromptJSON(input, 8000))
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return "Execute the requested web search and return concise source-backed results."
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func truncateOpenAIAlphaSearchPromptJSON(value string, limit int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if limit <= 0 || len(value) <= limit {
|
||||
return value
|
||||
}
|
||||
return value[:limit] + "\n...<truncated>"
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) buildOpenAIAlphaSearchRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token string) (*http.Request, error) {
|
||||
targetURL, err := s.openAIAlphaSearchURL(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -129,19 +350,279 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchRequest(ctx context.Context
|
||||
}
|
||||
parsedURL.RawQuery = query.Encode()
|
||||
}
|
||||
req.URL = parsedURL
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, parsedURL.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
|
||||
|
||||
authHeaders, err := s.buildOpenAIAuthenticationHeaders(ctx, account, token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build openai authentication headers: %w", err)
|
||||
}
|
||||
for key, values := range authHeaders {
|
||||
for _, value := range values {
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if clientBeta == "" {
|
||||
req.Header.Del("OpenAI-Beta")
|
||||
}
|
||||
if version := strings.TrimSpace(c.GetHeader("Version")); version != "" {
|
||||
req.Header.Set("Version", version)
|
||||
} else if account.Type == AccountTypeOAuth {
|
||||
req.Header.Set("Version", codexCLIVersion)
|
||||
|
||||
if account.Type == AccountTypeOAuth {
|
||||
req.Host = "chatgpt.com"
|
||||
if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, req.Header, account); err != nil {
|
||||
return nil, fmt.Errorf("resolve chatgpt account headers: %w", err)
|
||||
}
|
||||
|
||||
if turnMetadata := openAIAlphaSearchInboundHeader(c, "X-Codex-Turn-Metadata"); turnMetadata != "" {
|
||||
req.Header.Set("X-Codex-Turn-Metadata", turnMetadata)
|
||||
}
|
||||
if version := openAIAlphaSearchInboundHeader(c, "Version"); version != "" {
|
||||
req.Header.Set("Version", version)
|
||||
} else {
|
||||
req.Header.Set("Version", codexCLIVersion)
|
||||
}
|
||||
if originator := openAIAlphaSearchInboundHeader(c, "Originator"); originator != "" {
|
||||
req.Header.Set("Originator", originator)
|
||||
} else {
|
||||
req.Header.Set("Originator", "codex_cli_rs")
|
||||
}
|
||||
if customUA := account.GetOpenAIUserAgent(); customUA != "" {
|
||||
req.Header.Set("User-Agent", customUA)
|
||||
} else if userAgent := openAIAlphaSearchInboundHeader(c, "User-Agent"); userAgent != "" {
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
} else {
|
||||
req.Header.Set("User-Agent", codexCLIUserAgent)
|
||||
}
|
||||
if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
|
||||
req.Header.Set("User-Agent", codexCLIUserAgent)
|
||||
}
|
||||
s.overrideBrowserUserAgent(ctx, account, req)
|
||||
enforceCodexIdentityHeaders(req.Header)
|
||||
}
|
||||
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
stripOpenAIAlphaSearchResponsesHeaders(req.Header)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// stripOpenAIAlphaSearchResponsesHeaders 让独立搜索请求与官方 Codex
|
||||
// SearchClient 的线协议保持一致。alpha/search 不是 /responses 的子请求:官方
|
||||
// 客户端仅在 Provider/Auth 基础头之外附加 x-codex-turn-metadata,不发送
|
||||
// OpenAI-Beta、会话隔离或 Responses Lite 状态头。originator 与 User-Agent
|
||||
// 属于官方默认客户端头,必须保留。
|
||||
//
|
||||
// alpha/search 使用专用构造器生成官方 SearchClient 的最小线协议形态;
|
||||
// 该函数作为最后一道防线,避免账号 header 覆写或后续改动重新带入
|
||||
// Responses 专用头,使 PAT 的 alpha/search 被上游按错误认证路径处理。
|
||||
func stripOpenAIAlphaSearchResponsesHeaders(headers http.Header) {
|
||||
if headers == nil {
|
||||
return
|
||||
}
|
||||
for _, key := range []string{
|
||||
"OpenAI-Beta",
|
||||
"Session_ID",
|
||||
"Conversation_ID",
|
||||
"X-Codex-Beta-Features",
|
||||
"X-Codex-Turn-State",
|
||||
responsesLiteHeaderKey,
|
||||
} {
|
||||
headers.Del(key)
|
||||
}
|
||||
}
|
||||
|
||||
func openAIAlphaSearchInboundHeader(c *gin.Context, key string) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(c.GetHeader(key))
|
||||
}
|
||||
|
||||
var openAIAlphaSearchUnsupportedBodyFields = [...]string{
|
||||
// Codex alpha/search 是 SearchRequest 独立协议,不是 /responses 子请求。
|
||||
// 新版 Codex/第三方代理可能把 Responses 公共字段误带到搜索请求里;ChatGPT
|
||||
// alpha/search 会对这些字段返回 Unknown parameter(例如 prompt_cache_key)。
|
||||
"prompt_cache_key",
|
||||
"prompt_cache_retention",
|
||||
}
|
||||
|
||||
func sanitizeOpenAIAlphaSearchBody(body []byte) ([]byte, error) {
|
||||
if len(body) == 0 {
|
||||
return body, nil
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &obj); err != nil || obj == nil {
|
||||
return body, nil
|
||||
}
|
||||
changed := false
|
||||
for _, field := range openAIAlphaSearchUnsupportedBodyFields {
|
||||
if _, ok := obj[field]; ok {
|
||||
delete(obj, field)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return body, nil
|
||||
}
|
||||
out, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) ensureOpenAIAlphaSearchAuthMetadata(ctx context.Context, account *Account, token string, proxyURL string) error {
|
||||
if s == nil || account == nil || !account.IsOpenAIPersonalAccessToken() {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(account.GetChatGPTAccountID()) != "" {
|
||||
return nil
|
||||
}
|
||||
var oauthService *OpenAIOAuthService
|
||||
if s.openAITokenProvider != nil {
|
||||
oauthService = s.openAITokenProvider.openAIOAuthService
|
||||
}
|
||||
if oauthService == nil {
|
||||
return nil
|
||||
}
|
||||
tokenInfo, err := oauthService.ValidateCodexPersonalAccessToken(ctx, token, proxyURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate Codex PAT metadata for alpha/search: %w", err)
|
||||
}
|
||||
credentials := shallowCopyMap(account.Credentials)
|
||||
for key, value := range oauthService.BuildAccountCredentials(tokenInfo) {
|
||||
credentials[key] = value
|
||||
}
|
||||
credentials = NormalizeOpenAIPersonalAccessTokenCredentials(account, tokenInfo, credentials)
|
||||
account.Credentials = shallowCopyMap(credentials)
|
||||
if s.accountRepo != nil {
|
||||
if err := persistAccountCredentials(ctx, s.accountRepo, account, credentials); err != nil {
|
||||
return fmt.Errorf("persist Codex PAT metadata for alpha/search: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(statusCode int) bool {
|
||||
return statusCode != http.StatusUnauthorized
|
||||
}
|
||||
|
||||
func openAIAlphaSearchResponseFromResponsesSSE(body []byte) ([]byte, error) {
|
||||
output, results := parseOpenAIResponsesSSEForAlphaSearch(body)
|
||||
resp := map[string]any{
|
||||
"output": output,
|
||||
}
|
||||
if len(results) > 0 {
|
||||
resp["results"] = results
|
||||
}
|
||||
return json.Marshal(resp)
|
||||
}
|
||||
|
||||
func parseOpenAIResponsesSSEForAlphaSearch(body []byte) (string, []any) {
|
||||
text := strings.ReplaceAll(string(body), "\r\n", "\n")
|
||||
var output strings.Builder
|
||||
var completedResponse any
|
||||
results := make([]any, 0)
|
||||
seenURLs := make(map[string]struct{})
|
||||
|
||||
for _, block := range strings.Split(text, "\n\n") {
|
||||
data := openAIAlphaSearchSSEData(block)
|
||||
if data == "" || data == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
var event map[string]any
|
||||
if err := json.Unmarshal([]byte(data), &event); err != nil {
|
||||
continue
|
||||
}
|
||||
if delta, _ := event["delta"].(string); delta != "" && event["type"] == "response.output_text.delta" {
|
||||
_, _ = output.WriteString(delta)
|
||||
}
|
||||
if event["type"] == "response.completed" {
|
||||
completedResponse = event["response"]
|
||||
}
|
||||
collectOpenAIAlphaSearchURLCitations(event, &results, seenURLs)
|
||||
}
|
||||
|
||||
out := output.String()
|
||||
if strings.TrimSpace(out) == "" && completedResponse != nil {
|
||||
out = extractOpenAIResponsesCompletedText(completedResponse)
|
||||
collectOpenAIAlphaSearchURLCitations(completedResponse, &results, seenURLs)
|
||||
}
|
||||
return out, results
|
||||
}
|
||||
|
||||
func openAIAlphaSearchSSEData(block string) string {
|
||||
var lines []string
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func extractOpenAIResponsesCompletedText(response any) string {
|
||||
resp, ok := response.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
outputItems, _ := resp["output"].([]any)
|
||||
var b strings.Builder
|
||||
for _, item := range outputItems {
|
||||
itemMap, ok := item.(map[string]any)
|
||||
if !ok || itemMap["type"] != "message" {
|
||||
continue
|
||||
}
|
||||
contentItems, _ := itemMap["content"].([]any)
|
||||
for _, content := range contentItems {
|
||||
contentMap, ok := content.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if contentMap["type"] == "output_text" {
|
||||
if text, _ := contentMap["text"].(string); text != "" {
|
||||
_, _ = b.WriteString(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func collectOpenAIAlphaSearchURLCitations(value any, results *[]any, seen map[string]struct{}) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
if typed["type"] == "url_citation" {
|
||||
if urlValue, _ := typed["url"].(string); strings.TrimSpace(urlValue) != "" {
|
||||
urlValue = strings.TrimSpace(urlValue)
|
||||
if _, exists := seen[urlValue]; !exists {
|
||||
seen[urlValue] = struct{}{}
|
||||
result := map[string]any{
|
||||
"type": "text_result",
|
||||
"ref_id": fmt.Sprintf("turn0search%d", len(*results)),
|
||||
"url": urlValue,
|
||||
}
|
||||
if title, _ := typed["title"].(string); strings.TrimSpace(title) != "" {
|
||||
result["title"] = strings.TrimSpace(title)
|
||||
}
|
||||
*results = append(*results, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, child := range typed {
|
||||
collectOpenAIAlphaSearchURLCitations(child, results, seen)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
collectOpenAIAlphaSearchURLCitations(child, results, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) openAIAlphaSearchURL(account *Account) (string, error) {
|
||||
if account == nil {
|
||||
return "", fmt.Errorf("account is required")
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
@@ -15,6 +17,27 @@ import (
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
type alphaSearchAccountStateRepo struct {
|
||||
AccountRepository
|
||||
setErrorCalls int
|
||||
lastError string
|
||||
}
|
||||
|
||||
func (r *alphaSearchAccountStateRepo) SetError(_ context.Context, _ int64, errorMsg string) error {
|
||||
r.setErrorCalls++
|
||||
r.lastError = errorMsg
|
||||
return nil
|
||||
}
|
||||
|
||||
func alphaSearchResponsesSSE(output string) string {
|
||||
return "event: response.output_text.delta\n" +
|
||||
`data: {"type":"response.output_text.delta","delta":` + strconv.Quote(output) + `}` + "\n\n" +
|
||||
"event: response.output_text.annotation.added\n" +
|
||||
`data: {"type":"response.output_text.annotation.added","annotation":{"type":"url_citation","url":"https://example.com/news","title":"Example News"}}` + "\n\n" +
|
||||
"event: response.completed\n" +
|
||||
`data: {"type":"response.completed","response":{"output":[{"type":"message","content":[{"type":"output_text","text":` + strconv.Quote(output) + `}]}]}}` + "\n\n"
|
||||
}
|
||||
|
||||
func TestForwardAlphaSearchOAuthPreservesWire(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{
|
||||
@@ -70,6 +93,143 @@ func TestForwardAlphaSearchOAuthPreservesWire(t *testing.T) {
|
||||
require.JSONEq(t, string(body), string(upstream.lastBody))
|
||||
}
|
||||
|
||||
func TestForwardAlphaSearchPATUsesResponsesWebSearchFallback(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{
|
||||
"id":"search-session",
|
||||
"model":"gpt-5.6-sol",
|
||||
"commands":{"search_query":[{"q":"OpenAI news"}]},
|
||||
"prompt_cache_key":"responses-cache-key",
|
||||
"prompt_cache_retention":"24h"
|
||||
}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/alpha/search", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request.Header.Set("User-Agent", codexCLIUserAgent)
|
||||
c.Request.Header.Set("Originator", "codex_cli_rs")
|
||||
c.Request.Header.Set("Version", "0.144.1")
|
||||
c.Request.Header.Set("OpenAI-Beta", "responses=experimental")
|
||||
c.Request.Header.Set("Accept-Language", "zh-CN")
|
||||
c.Request.Header.Set("Authorization", "Bearer client-token")
|
||||
c.Request.Header.Set("Session_ID", "session-client")
|
||||
c.Request.Header.Set("Conversation_ID", "conversation-client")
|
||||
c.Request.Header.Set("X-Codex-Beta-Features", "feature-a")
|
||||
c.Request.Header.Set("X-Codex-Turn-State", "turn-state")
|
||||
c.Request.Header.Set(responsesLiteHeaderKey, "true")
|
||||
c.Request.Header.Set("X-Codex-Turn-Metadata", `{"turn_id":"turn-1"}`)
|
||||
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"req-search"}},
|
||||
Body: io.NopCloser(strings.NewReader(alphaSearchResponsesSSE("search result"))),
|
||||
}}
|
||||
service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 43,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "at-test-token",
|
||||
"auth_mode": OpenAIAuthModePersonalAccessToken,
|
||||
"chatgpt_account_id": "chatgpt-account",
|
||||
"chatgpt_account_is_fedramp": true,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := service.ForwardAlphaSearch(context.Background(), c, account, body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, 1, result.WebSearchCalls)
|
||||
require.Equal(t, "/v1/responses", result.UpstreamEndpoint)
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.JSONEq(t, `{"output":"search result","results":[{"type":"text_result","ref_id":"turn0search0","url":"https://example.com/news","title":"Example News"}]}`, recorder.Body.String())
|
||||
require.Equal(t, chatgptCodexURL, upstream.lastReq.URL.String())
|
||||
require.Equal(t, "Bearer at-test-token", upstream.lastReq.Header.Get("Authorization"))
|
||||
require.Equal(t, "chatgpt-account", upstream.lastReq.Header.Get("ChatGPT-Account-ID"))
|
||||
require.Equal(t, "true", upstream.lastReq.Header.Get("X-OpenAI-Fedramp"))
|
||||
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, "responses=experimental", upstream.lastReq.Header.Get("OpenAI-Beta"))
|
||||
require.Equal(t, "0.144.1", upstream.lastReq.Header.Get("Version"))
|
||||
require.Equal(t, `{"turn_id":"turn-1"}`, upstream.lastReq.Header.Get("X-Codex-Turn-Metadata"))
|
||||
require.Equal(t, "codex_cli_rs", upstream.lastReq.Header.Get("Originator"))
|
||||
require.Empty(t, upstream.lastReq.Header.Get("X-Codex-Beta-Features"))
|
||||
require.Empty(t, upstream.lastReq.Header.Get("X-Codex-Turn-State"))
|
||||
require.Empty(t, upstream.lastReq.Header.Get(responsesLiteHeaderKey))
|
||||
require.Empty(t, upstream.lastReq.Header.Get("Accept-Language"))
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").Exists())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_retention").Exists())
|
||||
require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(upstream.lastBody, "model").String())
|
||||
require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "store").Bool())
|
||||
require.Equal(t, "web_search", gjson.GetBytes(upstream.lastBody, "tools.0.type").String())
|
||||
require.Contains(t, gjson.GetBytes(upstream.lastBody, "input.0.content.0.text").String(), `"search_query"`)
|
||||
}
|
||||
|
||||
func TestForwardAlphaSearchPATBackfillsMissingChatGPTAccountMetadata(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{"search_query":[{"q":"OpenAI news"}]}}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/alpha/search", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
var whoamiCalls int32
|
||||
whoamiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&whoamiCalls, 1)
|
||||
require.Equal(t, "Bearer at-test-token", r.Header.Get("Authorization"))
|
||||
require.Equal(t, "application/json", r.Header.Get("Accept"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"email":"pat@example.com",
|
||||
"chatgpt_user_id":"user-123",
|
||||
"chatgpt_account_id":"acct-123",
|
||||
"chatgpt_plan_type":"plus",
|
||||
"chatgpt_account_is_fedramp":true
|
||||
}`))
|
||||
}))
|
||||
defer whoamiServer.Close()
|
||||
oldWhoamiURL := openAICodexPATWhoamiURL
|
||||
openAICodexPATWhoamiURL = whoamiServer.URL
|
||||
defer func() { openAICodexPATWhoamiURL = oldWhoamiURL }()
|
||||
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"output":"search result"}`)),
|
||||
}}
|
||||
oauthService := NewOpenAIOAuthService(nil, nil)
|
||||
service := &OpenAIGatewayService{
|
||||
cfg: &config.Config{},
|
||||
httpUpstream: upstream,
|
||||
openAITokenProvider: NewOpenAITokenProvider(nil, nil, oauthService),
|
||||
}
|
||||
account := &Account{
|
||||
ID: 45,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "at-test-token",
|
||||
"auth_mode": OpenAIAuthModePersonalAccessToken,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := service.ForwardAlphaSearch(context.Background(), c, account, body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int32(1), atomic.LoadInt32(&whoamiCalls))
|
||||
require.Equal(t, "acct-123", upstream.lastReq.Header.Get("ChatGPT-Account-ID"))
|
||||
require.Equal(t, "true", upstream.lastReq.Header.Get("X-OpenAI-Fedramp"))
|
||||
require.Equal(t, "acct-123", account.Credentials["chatgpt_account_id"])
|
||||
require.Equal(t, "user-123", account.Credentials["chatgpt_user_id"])
|
||||
require.Equal(t, OpenAIAuthModePersonalAccessToken, account.Credentials["auth_mode"])
|
||||
}
|
||||
|
||||
func TestForwardAlphaSearchAPIKeyMapsModelAndPassesThroughError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{"search_query":[{"q":"news"}]}}`)
|
||||
@@ -143,3 +303,98 @@ func TestForwardAlphaSearchReturnsFailoverBeforeWriting(t *testing.T) {
|
||||
require.False(t, c.Writer.Written())
|
||||
require.Empty(t, recorder.Body.String())
|
||||
}
|
||||
|
||||
func TestForwardAlphaSearchUnauthorizedDoesNotMarkAccountError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{"search_query":[{"q":"news"}]}}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/alpha/search", bytes.NewReader(body))
|
||||
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"detail":"Unauthorized"}`)),
|
||||
}}
|
||||
repo := &alphaSearchAccountStateRepo{}
|
||||
cfg := &config.Config{}
|
||||
service := &OpenAIGatewayService{
|
||||
cfg: cfg,
|
||||
httpUpstream: upstream,
|
||||
accountRepo: repo,
|
||||
rateLimitService: NewRateLimitService(repo, nil, cfg, nil, nil),
|
||||
}
|
||||
account := &Account{
|
||||
ID: 44,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
// 刻意不设置 auth_mode:覆盖历史上把 at- token 当普通 OAuth 导入的账号。
|
||||
"access_token": "at-test-token",
|
||||
"chatgpt_account_id": "chatgpt-account",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := service.ForwardAlphaSearch(context.Background(), c, account, body)
|
||||
|
||||
require.Nil(t, result)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.Equal(t, http.StatusUnauthorized, failoverErr.StatusCode)
|
||||
require.Zero(t, repo.setErrorCalls)
|
||||
require.Empty(t, repo.lastError)
|
||||
require.False(t, c.Writer.Written())
|
||||
}
|
||||
|
||||
func TestForwardAlphaSearchPATResponsesFallbackUnauthorizedDoesNotMarkAccountError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{"search_query":[{"q":"news"}]}}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/alpha/search", bytes.NewReader(body))
|
||||
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"detail":"Unauthorized"}`)),
|
||||
}}
|
||||
repo := &alphaSearchAccountStateRepo{}
|
||||
cfg := &config.Config{}
|
||||
service := &OpenAIGatewayService{
|
||||
cfg: cfg,
|
||||
httpUpstream: upstream,
|
||||
accountRepo: repo,
|
||||
rateLimitService: NewRateLimitService(repo, nil, cfg, nil, nil),
|
||||
}
|
||||
account := &Account{
|
||||
ID: 46,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "at-test-token",
|
||||
"auth_mode": OpenAIAuthModePersonalAccessToken,
|
||||
"chatgpt_account_id": "chatgpt-account",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := service.ForwardAlphaSearch(context.Background(), c, account, body)
|
||||
|
||||
require.Nil(t, result)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.Equal(t, http.StatusUnauthorized, failoverErr.StatusCode)
|
||||
require.Equal(t, chatgptCodexURL, upstream.lastReq.URL.String())
|
||||
require.Equal(t, "text/event-stream", upstream.lastReq.Header.Get("Accept"))
|
||||
require.Equal(t, "responses=experimental", upstream.lastReq.Header.Get("OpenAI-Beta"))
|
||||
require.Zero(t, repo.setErrorCalls)
|
||||
require.Empty(t, repo.lastError)
|
||||
require.False(t, c.Writer.Written())
|
||||
}
|
||||
|
||||
func TestShouldApplyOpenAIAlphaSearchAccountErrorSideEffects(t *testing.T) {
|
||||
require.False(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusUnauthorized))
|
||||
require.True(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusForbidden))
|
||||
require.True(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusTooManyRequests))
|
||||
}
|
||||
@@ -115,12 +115,13 @@ func (s *OpenAIGatewayService) failoverOpenAIUpstreamHTTPError(
|
||||
if account.Platform != PlatformGrok {
|
||||
s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, upstreamModel)
|
||||
}
|
||||
return &UpstreamFailoverError{
|
||||
StatusCode: resp.StatusCode,
|
||||
ResponseBody: respBody,
|
||||
ResponseHeaders: resp.Header.Clone(),
|
||||
RetryableOnSameAccount: account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
|
||||
}
|
||||
return newOpenAIUpstreamFailoverError(
|
||||
resp.StatusCode,
|
||||
resp.Header,
|
||||
respBody,
|
||||
upstreamMsg,
|
||||
account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
|
||||
)
|
||||
}
|
||||
|
||||
// openAIChatCompletionsTargetURL 解析账号的(非 Grok)Chat Completions 上游端点。
|
||||
|
||||
@@ -391,9 +391,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
if maxOutputTokens.Exists() {
|
||||
switch account.Platform {
|
||||
case PlatformOpenAI:
|
||||
if account.Type == AccountTypeAPIKey {
|
||||
markPatchDelete("max_output_tokens")
|
||||
}
|
||||
// Preserve Responses-native output limits unless the selected upstream
|
||||
// explicitly rejects the field in the bounded HTTP retry loop below.
|
||||
case PlatformAnthropic:
|
||||
decoded, decodeErr := ensureReqBody()
|
||||
if decodeErr != nil {
|
||||
@@ -744,6 +743,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
|
||||
httpInvalidEncryptedContentRetryTried := false
|
||||
agentTaskRecoveryTried := false
|
||||
rejectedFieldRetryState := newOpenAIResponsesRejectedFieldRetryState(body)
|
||||
for {
|
||||
// Build upstream request
|
||||
upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
|
||||
@@ -830,11 +830,21 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
return nil, fmt.Errorf("serialize invalid_encrypted_content retry body: %w", err)
|
||||
}
|
||||
httpInvalidEncryptedContentRetryTried = true
|
||||
rejectedFieldRetryState.remember(body)
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Retrying non-WSv2 request once after invalid_encrypted_content (account: %s)", account.Name)
|
||||
continue
|
||||
}
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Skip non-WSv2 invalid_encrypted_content retry because encrypted reasoning items are missing (account: %s)", account.Name)
|
||||
}
|
||||
if retryBody, reason, changed, retryErr := normalizeOpenAIResponsesRejectedFieldRetryBody(resp.StatusCode, body, respBody); retryErr != nil {
|
||||
return nil, fmt.Errorf("normalize rejected Responses field retry body: %w", retryErr)
|
||||
} else if changed && rejectedFieldRetryState.Allow(retryBody) {
|
||||
body = retryBody
|
||||
requestView = newOpenAIRequestView(body)
|
||||
reqBody = nil
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Retrying non-WSv2 request after %s (account: %s)", reason, account.Name)
|
||||
continue
|
||||
}
|
||||
if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) {
|
||||
upstreamDetail := ""
|
||||
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||||
@@ -856,11 +866,13 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
})
|
||||
|
||||
s.handleFailoverSideEffects(ctx, resp, account, respBody, upstreamModel)
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: resp.StatusCode,
|
||||
ResponseBody: respBody,
|
||||
RetryableOnSameAccount: account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
|
||||
}
|
||||
return nil, newOpenAIUpstreamFailoverError(
|
||||
resp.StatusCode,
|
||||
resp.Header,
|
||||
respBody,
|
||||
upstreamMsg,
|
||||
account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
|
||||
)
|
||||
}
|
||||
return s.handleErrorResponse(ctx, resp, c, account, body, billingModel)
|
||||
}
|
||||
|
||||
@@ -461,6 +461,9 @@ func shouldFailoverOpenAIPassthroughResponse(account *Account, statusCode int, r
|
||||
if isOpenAIContextWindowError("", responseBody) {
|
||||
return false
|
||||
}
|
||||
if isOpenAIRequestBodyTooLargeError(statusCode, "", responseBody) {
|
||||
return true
|
||||
}
|
||||
switch statusCode {
|
||||
case http.StatusTooManyRequests, 529:
|
||||
return true
|
||||
@@ -576,7 +579,8 @@ func (s *OpenAIGatewayService) handleFailoverErrorResponsePassthrough(
|
||||
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
|
||||
logOpenAIInstructionsRequiredDebug(ctx, c, account, resp.StatusCode, upstreamMsg, requestBody, body)
|
||||
reqModel, _, _ := extractOpenAIRequestMetaFromBody(requestBody)
|
||||
_ = s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, reqModel)
|
||||
canonicalModel := canonicalOpenAIAccountSchedulingModel(account, reqModel)
|
||||
_ = s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, canonicalModel)
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
@@ -589,12 +593,13 @@ func (s *OpenAIGatewayService) handleFailoverErrorResponsePassthrough(
|
||||
Detail: upstreamDetail,
|
||||
UpstreamResponseBody: upstreamDetail,
|
||||
})
|
||||
return &UpstreamFailoverError{
|
||||
StatusCode: resp.StatusCode,
|
||||
ResponseBody: body,
|
||||
ResponseHeaders: resp.Header.Clone(),
|
||||
RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
|
||||
}
|
||||
return newOpenAIUpstreamFailoverError(
|
||||
resp.StatusCode,
|
||||
resp.Header,
|
||||
body,
|
||||
upstreamMsg,
|
||||
account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) handleErrorResponsePassthrough(
|
||||
@@ -637,7 +642,8 @@ func (s *OpenAIGatewayService) handleErrorResponsePassthrough(
|
||||
// 刚被限流的账号。cyber 例外:不冷却账号。
|
||||
if !cyberHit {
|
||||
reqModel, _, _ := extractOpenAIRequestMetaFromBody(requestBody)
|
||||
_ = s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, reqModel)
|
||||
canonicalModel := canonicalOpenAIAccountSchedulingModel(account, reqModel)
|
||||
_ = s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, canonicalModel)
|
||||
}
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
|
||||
@@ -1015,8 +1015,16 @@ func openAIUsageFromGJSON(value gjson.Result) (OpenAIUsage, bool) {
|
||||
if imageOutputTokens == 0 {
|
||||
imageOutputTokens = value.Get("completion_tokens_details.image_tokens").Int()
|
||||
}
|
||||
// 图片输入 token(如 gpt-image-2 的 /v1/images/edits 带图请求),
|
||||
// 上游在 input_tokens_details.image_tokens 单独回传,用于图/文输入分价计费。
|
||||
// 普通文本请求该字段为 0,走原路径行为不变。
|
||||
imageInputTokens := firstPositiveGJSONInt(
|
||||
value.Get("input_tokens_details.image_tokens"),
|
||||
value.Get("prompt_tokens_details.image_tokens"),
|
||||
)
|
||||
return OpenAIUsage{
|
||||
InputTokens: int(inputTokens),
|
||||
ImageInputTokens: imageInputTokens,
|
||||
OutputTokens: int(outputTokens),
|
||||
CacheCreationInputTokens: cacheCreationTokens,
|
||||
CacheReadInputTokens: cacheReadTokens,
|
||||
|
||||
@@ -659,7 +659,7 @@ func (s *OpenAIGatewayService) tryStickySessionHit(ctx context.Context, groupID
|
||||
_ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash)
|
||||
return nil
|
||||
}
|
||||
if s.isOpenAIAccountRuntimeBlocked(account) {
|
||||
if s.isOpenAIAccountRequestRuntimeBlocked(account, requestedModel) {
|
||||
_ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash)
|
||||
return nil
|
||||
}
|
||||
@@ -863,7 +863,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex
|
||||
_ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash)
|
||||
} else if !s.openAIAccountMatchesSchedulingGroup(account, groupID) {
|
||||
_ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash)
|
||||
} else if s.isOpenAIAccountRuntimeBlocked(account) {
|
||||
} else if s.isOpenAIAccountRequestRuntimeBlocked(account, requestedModel) {
|
||||
_ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash)
|
||||
} else if needsUpstreamCheck && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, account, requestedModel, requireCompact) {
|
||||
_ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash)
|
||||
@@ -926,7 +926,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex
|
||||
if !parentHealthyForShadow(acc, parentLookupL2) {
|
||||
continue
|
||||
}
|
||||
if s.isOpenAIAccountRuntimeBlocked(acc) {
|
||||
if s.isOpenAIAccountRequestRuntimeBlocked(acc, requestedModel) {
|
||||
continue
|
||||
}
|
||||
if needsUpstreamCheck && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, acc, requestedModel, requireCompact) {
|
||||
@@ -1180,7 +1180,7 @@ func (s *OpenAIGatewayService) resolveFreshSchedulableOpenAIAccount(ctx context.
|
||||
if !parentHealthyForShadow(fresh, s.parentAccountLookup(ctx)) {
|
||||
return nil
|
||||
}
|
||||
if s.isOpenAIAccountRuntimeBlocked(fresh) {
|
||||
if s.isOpenAIAccountRequestRuntimeBlocked(fresh, requestedModel) {
|
||||
return nil
|
||||
}
|
||||
return fresh
|
||||
@@ -1228,7 +1228,7 @@ func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDB(ctx context.Co
|
||||
if !parentHealthyForShadow(latest, s.parentAccountLookup(ctx)) {
|
||||
return nil
|
||||
}
|
||||
if s.isOpenAIAccountRuntimeBlocked(latest) {
|
||||
if s.isOpenAIAccountRequestRuntimeBlocked(latest, requestedModel) {
|
||||
return nil
|
||||
}
|
||||
return latest
|
||||
|
||||
@@ -236,22 +236,25 @@ type OpenAIForwardResult struct {
|
||||
ServiceTier *string
|
||||
// ReasoningEffort is extracted from request body (reasoning.effort) or derived from model suffix.
|
||||
// Stored for usage records display; nil means not provided / not applicable.
|
||||
ReasoningEffort *string
|
||||
Stream bool
|
||||
OpenAIWSMode bool
|
||||
ResponseHeaders http.Header
|
||||
Duration time.Duration
|
||||
FirstTokenMs *int
|
||||
ClientDisconnect bool
|
||||
ImageCount int
|
||||
ImageSize string
|
||||
ImageInputSize string
|
||||
ImageOutputSize string
|
||||
ImageOutputSizes []string
|
||||
ImageSizeSource string
|
||||
ImageSizeBreakdown map[string]int
|
||||
VideoCount int
|
||||
VideoResolution string
|
||||
ReasoningEffort *string
|
||||
Stream bool
|
||||
OpenAIWSMode bool
|
||||
// UpstreamTerminalEvent is the normalized terminal event observed on an
|
||||
// upstream Responses WebSocket turn. Empty preserves legacy/non-WS success.
|
||||
UpstreamTerminalEvent string
|
||||
ResponseHeaders http.Header
|
||||
Duration time.Duration
|
||||
FirstTokenMs *int
|
||||
ClientDisconnect bool
|
||||
ImageCount int
|
||||
ImageSize string
|
||||
ImageInputSize string
|
||||
ImageOutputSize string
|
||||
ImageOutputSizes []string
|
||||
ImageSizeSource string
|
||||
ImageSizeBreakdown map[string]int
|
||||
VideoCount int
|
||||
VideoResolution string
|
||||
// VideoDurationSeconds 是提交时请求的生成时长(xAI 按输出秒数计费),已归一化到 1-15 秒。
|
||||
VideoDurationSeconds int
|
||||
// WebSearchCalls 是 Codex alpha/search 网页搜索调用次数(每次成功请求为 1)。
|
||||
@@ -262,6 +265,21 @@ type OpenAIForwardResult struct {
|
||||
wsReplayInputExists bool
|
||||
}
|
||||
|
||||
// SucceededForScheduling reports whether this result is an upstream success
|
||||
// that may clear model-scoped transient state. The zero value remains a success
|
||||
// for existing non-WS callers.
|
||||
func (r *OpenAIForwardResult) SucceededForScheduling() bool {
|
||||
if r == nil || !r.OpenAIWSMode || r.UpstreamTerminalEvent == "" {
|
||||
return true
|
||||
}
|
||||
switch r.UpstreamTerminalEvent {
|
||||
case "response.completed", "response.done":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// SetActualOpenAIUpstreamEndpoint records the endpoint selected by the current
|
||||
// forwarding attempt. It covers error paths where no OpenAIForwardResult is
|
||||
// available for usage and operations logging.
|
||||
@@ -393,12 +411,14 @@ type OpenAIGatewayService struct {
|
||||
openaiWSStateStoreOnce sync.Once
|
||||
openaiSchedulerOnce sync.Once
|
||||
openaiWSPassthroughDialerOnce sync.Once
|
||||
openaiModelTransientOnce sync.Once
|
||||
agentIdentityTaskMu sync.Mutex
|
||||
openaiWSPool *openAIWSConnPool
|
||||
openaiWSStateStore OpenAIWSStateStore
|
||||
openaiScheduler OpenAIAccountScheduler
|
||||
openaiWSPassthroughDialer openAIWSClientDialer
|
||||
openaiAccountStats *openAIAccountRuntimeStats
|
||||
openaiModelTransient *openAIAccountModelTransientState
|
||||
|
||||
openaiWSFallbackUntil sync.Map // key: int64(accountID), value: time.Time
|
||||
openaiAccountRuntimeBlockUntil sync.Map // key: int64(accountID), value: time.Time
|
||||
@@ -475,6 +495,7 @@ func NewOpenAIGatewayService(
|
||||
userPlatformQuotaRepo: userPlatformQuotaRepo,
|
||||
responseHeaderFilter: compileResponseHeaderFilter(cfg),
|
||||
codexSnapshotThrottle: newAccountWriteThrottle(openAICodexSnapshotPersistMinInterval),
|
||||
openaiModelTransient: newOpenAIAccountModelTransientState(openAIModelTransientDefaultMax),
|
||||
}
|
||||
if rateLimitService != nil {
|
||||
rateLimitService.SetAccountRuntimeBlocker(svc)
|
||||
|
||||
@@ -259,6 +259,31 @@ func TestExtractOpenAIResponseIDFromJSONBytes(t *testing.T) {
|
||||
require.Empty(t, extractOpenAIResponseIDFromJSONBytes([]byte(`not-json`)))
|
||||
}
|
||||
|
||||
// 复现 #4386:gpt-image-2 /v1/images/edits 的 usage 携带 input_tokens_details.image_tokens,
|
||||
// 提取器须将图片输入 token 单独填入 ImageInputTokens(此前被丢弃并入 InputTokens 按文本价计费)。
|
||||
func TestExtractOpenAIUsage_CapturesImageInputTokens(t *testing.T) {
|
||||
body := []byte(`{"usage":{"input_tokens":371,"input_tokens_details":{"image_tokens":352,"text_tokens":19},"output_tokens":439,"output_tokens_details":{"image_tokens":439,"text_tokens":0},"total_tokens":810}}`)
|
||||
usage, ok := extractOpenAIUsageFromJSONBytes(body)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 371, usage.InputTokens)
|
||||
require.Equal(t, 352, usage.ImageInputTokens)
|
||||
require.Equal(t, 439, usage.OutputTokens)
|
||||
require.Equal(t, 439, usage.ImageOutputTokens)
|
||||
|
||||
// prompt_tokens_details 回退路径(部分上游用 prompt_tokens 口径)。
|
||||
promptStyle := []byte(`{"usage":{"prompt_tokens":100,"prompt_tokens_details":{"image_tokens":80}}}`)
|
||||
pu, ok := extractOpenAIUsageFromJSONBytes(promptStyle)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 100, pu.InputTokens)
|
||||
require.Equal(t, 80, pu.ImageInputTokens)
|
||||
|
||||
// 纯文本请求:无 image_tokens 时 ImageInputTokens 为 0,行为不变。
|
||||
textOnly := []byte(`{"usage":{"input_tokens":50,"output_tokens":10}}`)
|
||||
tu, ok := extractOpenAIUsageFromJSONBytes(textOnly)
|
||||
require.True(t, ok)
|
||||
require.Zero(t, tu.ImageInputTokens)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_BindHTTPResponseAccount(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -222,12 +222,55 @@ func (s *OpenAIGatewayService) shouldFailoverOpenAIUpstreamResponse(statusCode i
|
||||
if isOpenAIContextWindowError(upstreamMsg, upstreamBody) {
|
||||
return false
|
||||
}
|
||||
if isOpenAIRequestBodyTooLargeError(statusCode, upstreamMsg, upstreamBody) {
|
||||
return true
|
||||
}
|
||||
if s.shouldFailoverUpstreamError(statusCode) {
|
||||
return true
|
||||
}
|
||||
return isOpenAITransientProcessingError(statusCode, upstreamMsg, upstreamBody)
|
||||
}
|
||||
|
||||
// OpenAIRequestBodyTooLargeClientMessage is the fixed downstream message used
|
||||
// after all account-specific request body limit failovers are exhausted.
|
||||
const OpenAIRequestBodyTooLargeClientMessage = "Request payload is too large"
|
||||
|
||||
const openAIRequestBodyTooLargeReason = GatewayFailureReason("openai_request_body_too_large")
|
||||
|
||||
func isOpenAIRequestBodyTooLargeError(statusCode int, upstreamMsg string, upstreamBody []byte) bool {
|
||||
return statusCode == http.StatusRequestEntityTooLarge && !isOpenAIContextWindowError(upstreamMsg, upstreamBody)
|
||||
}
|
||||
|
||||
func newOpenAIUpstreamFailoverError(
|
||||
statusCode int,
|
||||
responseHeaders http.Header,
|
||||
responseBody []byte,
|
||||
upstreamMsg string,
|
||||
retryableOnSameAccount bool,
|
||||
) *UpstreamFailoverError {
|
||||
failoverErr := &UpstreamFailoverError{
|
||||
StatusCode: statusCode,
|
||||
ResponseBody: responseBody,
|
||||
ResponseHeaders: responseHeaders.Clone(),
|
||||
RetryableOnSameAccount: retryableOnSameAccount,
|
||||
}
|
||||
if isOpenAIRequestBodyTooLargeError(statusCode, upstreamMsg, responseBody) {
|
||||
failoverErr.RetryableOnSameAccount = false
|
||||
failoverErr.Scope = GatewayFailureScopeAccount
|
||||
failoverErr.Reason = openAIRequestBodyTooLargeReason
|
||||
failoverErr.NextAccountAction = NextAccountRetry
|
||||
failoverErr.ClientStatusCode = http.StatusRequestEntityTooLarge
|
||||
failoverErr.ClientMessage = OpenAIRequestBodyTooLargeClientMessage
|
||||
}
|
||||
return failoverErr
|
||||
}
|
||||
|
||||
// IsOpenAIRequestBodyTooLarge reports whether another account may accept the
|
||||
// same request even though the selected account rejected its serialized size.
|
||||
func (e *UpstreamFailoverError) IsOpenAIRequestBodyTooLarge() bool {
|
||||
return e != nil && e.Reason == openAIRequestBodyTooLargeReason
|
||||
}
|
||||
|
||||
func marshalOpenAIUpstreamJSON(v any) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
@@ -262,9 +305,9 @@ func (s *OpenAIGatewayService) readUpstreamErrorBody(resp *http.Response) []byte
|
||||
return body
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) handleFailoverSideEffects(ctx context.Context, resp *http.Response, account *Account, responseBody []byte, requestedModel ...string) {
|
||||
if len(requestedModel) > 0 {
|
||||
s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, responseBody, requestedModel[0])
|
||||
func (s *OpenAIGatewayService) handleFailoverSideEffects(ctx context.Context, resp *http.Response, account *Account, responseBody []byte, canonicalModel ...string) {
|
||||
if len(canonicalModel) > 0 {
|
||||
s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, responseBody, canonicalModel[0])
|
||||
return
|
||||
}
|
||||
s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, responseBody)
|
||||
@@ -328,6 +371,27 @@ func (s *OpenAIGatewayService) handleErrorResponse(
|
||||
)
|
||||
}
|
||||
|
||||
if isOpenAIRequestBodyTooLargeError(resp.StatusCode, upstreamMsg, body) {
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: resp.StatusCode,
|
||||
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
||||
Kind: "failover",
|
||||
Message: upstreamMsg,
|
||||
Detail: upstreamDetail,
|
||||
})
|
||||
s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, requestedModel...)
|
||||
return nil, newOpenAIUpstreamFailoverError(
|
||||
resp.StatusCode,
|
||||
resp.Header,
|
||||
body,
|
||||
upstreamMsg,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
if status, errType, errMsg, matched := applyErrorPassthroughRule(
|
||||
c,
|
||||
PlatformOpenAI,
|
||||
@@ -385,6 +449,7 @@ func (s *OpenAIGatewayService) handleErrorResponse(
|
||||
}
|
||||
if reqModel == "" {
|
||||
reqModel, _, _ = extractOpenAIRequestMetaFromBody(requestBody)
|
||||
reqModel = canonicalOpenAIAccountSchedulingModel(account, reqModel)
|
||||
}
|
||||
shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, reqModel)
|
||||
kind := "http_error"
|
||||
|
||||
@@ -98,6 +98,18 @@ func (s *OpenAIGatewayService) RecordCyberPolicyUsageLog(ctx context.Context, in
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveUserGroupRateMultiplier resolves the same cached multiplier used by OpenAI usage billing.
|
||||
func (s *OpenAIGatewayService) ResolveUserGroupRateMultiplier(ctx context.Context, userID, groupID int64, groupDefaultMultiplier float64) float64 {
|
||||
if s == nil {
|
||||
return groupDefaultMultiplier
|
||||
}
|
||||
resolver := s.userGroupRateResolver
|
||||
if resolver == nil {
|
||||
resolver = newUserGroupRateResolver(nil, nil, resolveUserGroupRateCacheTTL(s.cfg), nil, "service.openai_gateway")
|
||||
}
|
||||
return resolver.Resolve(ctx, userID, groupID, groupDefaultMultiplier)
|
||||
}
|
||||
|
||||
// RecordUsage records usage and deducts balance
|
||||
func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRecordUsageInput) error {
|
||||
if input == nil {
|
||||
@@ -142,11 +154,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
|
||||
multiplier = s.cfg.Default.RateMultiplier
|
||||
}
|
||||
if apiKey.GroupID != nil && apiKey.Group != nil {
|
||||
resolver := s.userGroupRateResolver
|
||||
if resolver == nil {
|
||||
resolver = newUserGroupRateResolver(nil, nil, resolveUserGroupRateCacheTTL(s.cfg), nil, "service.openai_gateway")
|
||||
}
|
||||
multiplier = resolver.Resolve(ctx, user.ID, *apiKey.GroupID, apiKey.Group.RateMultiplier)
|
||||
multiplier = s.ResolveUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, apiKey.Group.RateMultiplier)
|
||||
}
|
||||
// token 倍率叠加高峰因子(token 计费含图片 token,图片按次倍率不受影响)。高峰因子按请求时刻现算,
|
||||
// 不并入上面的 Resolve,以免污染 user:group 倍率缓存。
|
||||
@@ -254,6 +262,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
|
||||
OutputTokens: result.Usage.OutputTokens,
|
||||
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
|
||||
CacheReadTokens: result.Usage.CacheReadInputTokens,
|
||||
ImageInputTokens: result.Usage.ImageInputTokens,
|
||||
ImageOutputTokens: result.Usage.ImageOutputTokens,
|
||||
ImageCount: result.ImageCount,
|
||||
ImageSize: optionalTrimmedStringPtr(result.ImageSize),
|
||||
@@ -271,6 +280,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
|
||||
}
|
||||
if cost != nil {
|
||||
usageLog.InputCost = cost.InputCost
|
||||
usageLog.ImageInputCost = cost.ImageInputCost
|
||||
usageLog.OutputCost = cost.OutputCost
|
||||
usageLog.ImageOutputCost = cost.ImageOutputCost
|
||||
usageLog.CacheCreationCost = cost.CacheCreationCost
|
||||
|
||||
@@ -455,8 +455,13 @@ func applyOpenAIImagesDefaults(req *OpenAIImagesRequest) {
|
||||
}
|
||||
|
||||
func isOpenAIImageGenerationModel(model string) bool {
|
||||
return IsGPTImageGenerationModel(model) || isGrokImageGenerationModel(model)
|
||||
}
|
||||
|
||||
// IsGPTImageGenerationModel identifies the GPT native image-generation model family.
|
||||
func IsGPTImageGenerationModel(model string) bool {
|
||||
model = strings.ToLower(strings.TrimSpace(model))
|
||||
return strings.HasPrefix(model, "gpt-image-") || isGrokImageGenerationModel(model)
|
||||
return strings.HasPrefix(model, "gpt-image-")
|
||||
}
|
||||
|
||||
func isGrokImageGenerationModel(model string) bool {
|
||||
@@ -1146,6 +1151,9 @@ func mergeOpenAIUsage(dst *OpenAIUsage, body []byte) {
|
||||
if parsed.CacheReadInputTokens > 0 {
|
||||
dst.CacheReadInputTokens = parsed.CacheReadInputTokens
|
||||
}
|
||||
if parsed.ImageInputTokens > 0 {
|
||||
dst.ImageInputTokens = parsed.ImageInputTokens
|
||||
}
|
||||
if parsed.ImageOutputTokens > 0 {
|
||||
dst.ImageOutputTokens = parsed.ImageOutputTokens
|
||||
}
|
||||
|
||||
@@ -1072,6 +1072,165 @@ func (s *OpenAIGatewayService) tryWriteOpenAIImagesStreamEvent(
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) parseOpenAIImagesSSEUsageBytes(data []byte, usage *OpenAIUsage) {
|
||||
s.parseSSEUsageBytes(data, usage)
|
||||
if usage == nil || !gjson.ValidBytes(data) || gjson.GetBytes(data, "type").String() != "response.completed" {
|
||||
return
|
||||
}
|
||||
if toolUsage, ok := openAIImagesToolUsageFromGJSON(gjson.GetBytes(data, "response.tool_usage.image_gen")); ok {
|
||||
*usage = toolUsage
|
||||
}
|
||||
}
|
||||
|
||||
func openAIImagesToolUsageFromGJSON(value gjson.Result) (OpenAIUsage, bool) {
|
||||
if !value.Exists() || !value.IsObject() {
|
||||
return OpenAIUsage{}, false
|
||||
}
|
||||
inputTokens, inputOK := boundedJSONNonNegativeInt(value.Get("input_tokens"))
|
||||
outputTokens, outputOK := boundedJSONNonNegativeInt(value.Get("output_tokens"))
|
||||
imageOutputTokens, imageOutputOK := boundedJSONNonNegativeInt(value.Get("output_tokens_details.image_tokens"))
|
||||
if !inputOK || !outputOK || !imageOutputOK {
|
||||
return OpenAIUsage{}, false
|
||||
}
|
||||
return OpenAIUsage{
|
||||
InputTokens: inputTokens,
|
||||
OutputTokens: outputTokens,
|
||||
ImageOutputTokens: imageOutputTokens,
|
||||
}, true
|
||||
}
|
||||
|
||||
// boundedJSONNonNegativeInt parses integral JSON exponent notation without
|
||||
// invoking an arbitrary-precision parser on an upstream-controlled exponent.
|
||||
func boundedJSONNonNegativeInt(value gjson.Result) (int, bool) {
|
||||
if !value.Exists() || value.Type != gjson.Number {
|
||||
return 0, false
|
||||
}
|
||||
raw := value.Raw
|
||||
if len(raw) == 0 || len(raw) > 64 || raw[0] == '-' {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
mantissaEnd := len(raw)
|
||||
for i, c := range raw {
|
||||
if c != 'e' && c != 'E' {
|
||||
continue
|
||||
}
|
||||
mantissaEnd = i
|
||||
break
|
||||
}
|
||||
|
||||
digits := raw[:mantissaEnd]
|
||||
fractionDigits := 0
|
||||
digitCount := 0
|
||||
dotSeen := false
|
||||
mantissaIsZero := true
|
||||
for _, c := range digits {
|
||||
switch {
|
||||
case c == '.' && !dotSeen:
|
||||
dotSeen = true
|
||||
case c >= '0' && c <= '9':
|
||||
digitCount++
|
||||
mantissaIsZero = mantissaIsZero && c == '0'
|
||||
if dotSeen {
|
||||
fractionDigits++
|
||||
}
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
exponent := 0
|
||||
if mantissaEnd < len(raw) {
|
||||
exponentRaw := raw[mantissaEnd+1:]
|
||||
negative := false
|
||||
if len(exponentRaw) > 0 && (exponentRaw[0] == '+' || exponentRaw[0] == '-') {
|
||||
negative = exponentRaw[0] == '-'
|
||||
exponentRaw = exponentRaw[1:]
|
||||
}
|
||||
if len(exponentRaw) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
for len(exponentRaw) > 1 && exponentRaw[0] == '0' {
|
||||
exponentRaw = exponentRaw[1:]
|
||||
}
|
||||
for _, digit := range exponentRaw {
|
||||
if digit < '0' || digit > '9' {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
if mantissaIsZero {
|
||||
return 0, true
|
||||
}
|
||||
if len(exponentRaw) > 3 {
|
||||
return 0, false
|
||||
}
|
||||
for _, digit := range exponentRaw {
|
||||
exponent = exponent*10 + int(digit-'0')
|
||||
}
|
||||
if exponent > 100 {
|
||||
return 0, false
|
||||
}
|
||||
if negative {
|
||||
exponent = -exponent
|
||||
}
|
||||
}
|
||||
|
||||
trailingZeros := exponent - fractionDigits
|
||||
scaleReduction := 0
|
||||
if trailingZeros < 0 {
|
||||
scaleReduction = -trailingZeros
|
||||
remaining := scaleReduction
|
||||
allZeros := true
|
||||
for i := len(digits) - 1; i >= 0; i-- {
|
||||
if digits[i] == '.' {
|
||||
continue
|
||||
}
|
||||
if digits[i] != '0' {
|
||||
allZeros = false
|
||||
if remaining > 0 {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
if remaining > 0 {
|
||||
remaining--
|
||||
}
|
||||
}
|
||||
if remaining > 0 {
|
||||
if allZeros {
|
||||
return 0, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
maxInt := int(^uint(0) >> 1)
|
||||
parsed := 0
|
||||
digitsToAccumulate := digitCount - scaleReduction
|
||||
for _, c := range digits {
|
||||
if c == '.' {
|
||||
continue
|
||||
}
|
||||
if digitsToAccumulate <= 0 {
|
||||
break
|
||||
}
|
||||
if parsed > (maxInt-int(c-'0'))/10 {
|
||||
return 0, false
|
||||
}
|
||||
parsed = parsed*10 + int(c-'0')
|
||||
digitsToAccumulate--
|
||||
}
|
||||
if trailingZeros < 0 {
|
||||
return parsed, true
|
||||
}
|
||||
for ; trailingZeros > 0; trailingZeros-- {
|
||||
if parsed > maxInt/10 {
|
||||
return 0, false
|
||||
}
|
||||
parsed *= 10
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) handleOpenAIImagesOAuthNonStreamingResponse(
|
||||
resp *http.Response,
|
||||
c *gin.Context,
|
||||
@@ -1085,7 +1244,7 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthNonStreamingResponse(
|
||||
|
||||
var usage OpenAIUsage
|
||||
forEachOpenAISSEDataPayload(string(body), func(data []byte) {
|
||||
s.parseSSEUsageBytes(data, &usage)
|
||||
s.parseOpenAIImagesSSEUsageBytes(data, &usage)
|
||||
})
|
||||
results, createdAt, usageRaw, firstMeta, _, err := collectOpenAIImagesFromResponsesBody(body)
|
||||
if err != nil {
|
||||
@@ -1192,7 +1351,7 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse(
|
||||
ms := int(time.Since(startTime).Milliseconds())
|
||||
firstTokenMs = &ms
|
||||
}
|
||||
s.parseSSEUsageBytes(dataBytes, &usage)
|
||||
s.parseOpenAIImagesSSEUsageBytes(dataBytes, &usage)
|
||||
if !gjson.ValidBytes(dataBytes) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -492,9 +492,24 @@ func TestAccountSupportsOpenAIEndpointCapability(t *testing.T) {
|
||||
}
|
||||
|
||||
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions))
|
||||
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
|
||||
require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityEmbeddings))
|
||||
})
|
||||
|
||||
t.Run("alpha search 仅允许 OpenAI OAuth/PAT 类账号", func(t *testing.T) {
|
||||
apiKey := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
}
|
||||
oauth := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
}
|
||||
|
||||
require.False(t, apiKey.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
|
||||
require.True(t, oauth.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
|
||||
})
|
||||
|
||||
t.Run("显式列表支持同时声明 chat 和 embeddings", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
@@ -518,9 +533,22 @@ func TestAccountSupportsOpenAIEndpointCapability(t *testing.T) {
|
||||
}
|
||||
|
||||
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions))
|
||||
require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
|
||||
require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityEmbeddings))
|
||||
})
|
||||
|
||||
t.Run("OAuth 显式列表沿用 chat 能力放行 alpha search", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"openai_capabilities": []any{"chat_completions"},
|
||||
},
|
||||
}
|
||||
|
||||
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
|
||||
})
|
||||
|
||||
t.Run("显式 map 支持单独关闭 chat 并开启 embeddings", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
@@ -631,7 +659,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthPassesNAndReturnsAllImages(t *te
|
||||
"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\":3}},\"output\":[{\"type\":\"image_generation_call\",\"result\":\"aW1hZ2UtMQ==\",\"revised_prompt\":\"draw a cat 1\",\"output_format\":\"png\",\"quality\":\"high\",\"size\":\"1024x1024\"},{\"type\":\"image_generation_call\",\"result\":\"aW1hZ2UtMg==\",\"revised_prompt\":\"draw a cat 2\",\"output_format\":\"png\",\"quality\":\"high\",\"size\":\"1024x1024\"},{\"type\":\"image_generation_call\",\"result\":\"aW1hZ2UtMw==\",\"revised_prompt\":\"draw a cat 3\",\"output_format\":\"png\",\"quality\":\"high\",\"size\":\"1024x1024\"}]}}\n\n" +
|
||||
"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\":{\"input_tokens\":46,\"output_tokens\":2459,\"output_tokens_details\":{\"image_tokens\":2459},\"images\":3}},\"output\":[{\"type\":\"image_generation_call\",\"result\":\"aW1hZ2UtMQ==\",\"revised_prompt\":\"draw a cat 1\",\"output_format\":\"png\",\"quality\":\"high\",\"size\":\"1024x1024\"},{\"type\":\"image_generation_call\",\"result\":\"aW1hZ2UtMg==\",\"revised_prompt\":\"draw a cat 2\",\"output_format\":\"png\",\"quality\":\"high\",\"size\":\"1024x1024\"},{\"type\":\"image_generation_call\",\"result\":\"aW1hZ2UtMw==\",\"revised_prompt\":\"draw a cat 3\",\"output_format\":\"png\",\"quality\":\"high\",\"size\":\"1024x1024\"}]}}\n\n" +
|
||||
"data: [DONE]\n\n",
|
||||
)),
|
||||
},
|
||||
@@ -655,9 +683,9 @@ func TestOpenAIGatewayServiceForwardImages_OAuthPassesNAndReturnsAllImages(t *te
|
||||
require.Equal(t, "gpt-image-2", result.Model)
|
||||
require.Equal(t, "gpt-image-2", result.UpstreamModel)
|
||||
require.Equal(t, 3, result.ImageCount)
|
||||
require.Equal(t, 11, result.Usage.InputTokens)
|
||||
require.Equal(t, 22, result.Usage.OutputTokens)
|
||||
require.Equal(t, 7, result.Usage.ImageOutputTokens)
|
||||
require.Equal(t, 46, result.Usage.InputTokens)
|
||||
require.Equal(t, 2459, result.Usage.OutputTokens)
|
||||
require.Equal(t, 2459, result.Usage.ImageOutputTokens)
|
||||
|
||||
require.NotNil(t, upstream.lastReq)
|
||||
require.Equal(t, chatgptCodexURL, upstream.lastReq.URL.String())
|
||||
@@ -688,6 +716,81 @@ func TestOpenAIGatewayServiceForwardImages_OAuthPassesNAndReturnsAllImages(t *te
|
||||
require.Equal(t, "draw a cat 3", gjson.Get(rec.Body.String(), "data.2.revised_prompt").String())
|
||||
}
|
||||
|
||||
func TestParseOpenAIImagesSSEUsageBytes_ToolUsagePrecedenceAndFallback(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{}
|
||||
fallback := OpenAIUsage{InputTokens: 3, OutputTokens: 4, ImageOutputTokens: 2}
|
||||
tests := []struct {
|
||||
name string
|
||||
toolUsage string
|
||||
want OpenAIUsage
|
||||
}{
|
||||
{
|
||||
name: "valid tool usage takes atomic precedence",
|
||||
toolUsage: `{"input_tokens":4.6e1,"output_tokens":2459e0,"output_tokens_details":{"image_tokens":24590e-1}}`,
|
||||
want: OpenAIUsage{InputTokens: 46, OutputTokens: 2459, ImageOutputTokens: 2459},
|
||||
},
|
||||
{name: "absent", want: fallback},
|
||||
{name: "malformed field", toolUsage: `{"input_tokens":"46","output_tokens":2459,"output_tokens_details":{"image_tokens":2459}}`, want: fallback},
|
||||
{name: "fractional field", toolUsage: `{"input_tokens":46,"output_tokens":2459.5,"output_tokens_details":{"image_tokens":2459}}`, want: fallback},
|
||||
{name: "negative field", toolUsage: `{"input_tokens":46,"output_tokens":2459,"output_tokens_details":{"image_tokens":-1}}`, want: fallback},
|
||||
{name: "overflow field", toolUsage: `{"input_tokens":46,"output_tokens":9223372036854775808,"output_tokens_details":{"image_tokens":2459}}`, want: fallback},
|
||||
{name: "incomplete object", toolUsage: `{"input_tokens":46,"output_tokens":2459}`, want: fallback},
|
||||
{name: "hostile huge exponent", toolUsage: `{"input_tokens":1e1000000000,"output_tokens":2459,"output_tokens_details":{"image_tokens":2459}}`, want: fallback},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
toolUsageField := ""
|
||||
if tt.toolUsage != "" {
|
||||
toolUsageField = `,"tool_usage":{"image_gen":` + tt.toolUsage + `}`
|
||||
}
|
||||
payload := []byte(`{"type":"response.completed","response":{"usage":{"input_tokens":3,"output_tokens":4,"output_tokens_details":{"image_tokens":2}}` + toolUsageField + `}}`)
|
||||
var got OpenAIUsage
|
||||
svc.parseOpenAIImagesSSEUsageBytes(payload, &got)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOpenAIImagesSSEUsageBytes_MalformedCompletedDoesNotOverrideUsage(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{}
|
||||
var usage OpenAIUsage
|
||||
|
||||
svc.parseOpenAIImagesSSEUsageBytes([]byte(`{"type":"response.output_item.done","item":{"type":"image_generation_call","result":"aW1hZ2U="}}`), &usage)
|
||||
svc.parseOpenAIImagesSSEUsageBytes([]byte(`{"type":"response.completed","response":{"usage":{"input_tokens":3,"output_tokens":4,"output_tokens_details":{"image_tokens":2}}}}`), &usage)
|
||||
svc.parseOpenAIImagesSSEUsageBytes([]byte(`{"type":"response.completed","response":{"tool_usage":{"image_gen":{"input_tokens":46,"output_tokens":2459,"output_tokens_details":{"image_tokens":2459}}}}} trailing`), &usage)
|
||||
|
||||
require.Equal(t, OpenAIUsage{InputTokens: 3, OutputTokens: 4, ImageOutputTokens: 2}, usage)
|
||||
}
|
||||
|
||||
func TestBoundedJSONNonNegativeInt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{name: "scale reduction before accumulation", raw: `10000000000000000000e-19`, want: 1, ok: true},
|
||||
{name: "decimal scale reduction", raw: `10000000000000000000.0e-19`, want: 1, ok: true},
|
||||
{name: "fractional after scale reduction", raw: `10000000000000000001e-19`, ok: false},
|
||||
{name: "overflow after scale reduction", raw: `92233720368547758080e-1`, ok: false},
|
||||
{name: "zero with negative exponent", raw: `0e-100`, want: 0, ok: true},
|
||||
{name: "zero beyond exponent bound", raw: `0e101`, want: 0, ok: true},
|
||||
{name: "zero padded decimal beyond exponent bound", raw: `0.000000e+000000000000000000000000000000000000000000000000101`, want: 0, ok: true},
|
||||
{name: "zero padded exponent", raw: `1e0000`, want: 1, ok: true},
|
||||
{name: "negative zero syntax", raw: `-0e101`, ok: false},
|
||||
{name: "hostile exponent", raw: `1e-1000`, ok: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := boundedJSONNonNegativeInt(gjson.Parse(tt.raw))
|
||||
require.Equal(t, tt.ok, ok)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceForwardImages_OAuthUpstreamHTTPErrorSurfacesRealError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","response_format":"b64_json"}`)
|
||||
@@ -1244,7 +1347,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthStreamingTransformsEvents(t *tes
|
||||
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: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000001,\"usage\":{\"input_tokens\":5,\"output_tokens\":9,\"output_tokens_details\":{\"image_tokens\":4}},\"tool_usage\":{\"image_gen\":{\"input_tokens\":46,\"output_tokens\":2459,\"output_tokens_details\":{\"image_tokens\":2459},\"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",
|
||||
)),
|
||||
},
|
||||
@@ -1266,6 +1369,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthStreamingTransformsEvents(t *tes
|
||||
require.NotNil(t, result)
|
||||
require.True(t, result.Stream)
|
||||
require.Equal(t, 1, result.ImageCount)
|
||||
require.Equal(t, OpenAIUsage{InputTokens: 46, OutputTokens: 2459, ImageOutputTokens: 2459}, result.Usage)
|
||||
events := parseOpenAIImageTestSSEEvents(rec.Body.String())
|
||||
partial, ok := findOpenAIImageTestSSEEvent(events, "image_generation.partial_image")
|
||||
require.True(t, ok)
|
||||
@@ -1290,7 +1394,7 @@ func TestOpenAIGatewayServiceForwardImages_OAuthStreamingTransformsEvents(t *tes
|
||||
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.JSONEq(t, `{"input_tokens":46,"output_tokens":2459,"output_tokens_details":{"image_tokens":2459},"images":1}`, gjson.Get(completed.Data, "usage").Raw)
|
||||
require.False(t, gjson.Get(completed.Data, "revised_prompt").Exists())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestOpenAIRequestBodyLimitFailover_HTTP413SwitchesAccountsBeforeWrite(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
requestBody := []byte(`{"model":"gpt-5.2","stream":false,"input":"hello"}`)
|
||||
|
||||
for _, passthrough := range []bool{false, true} {
|
||||
name := "native_responses"
|
||||
if passthrough {
|
||||
name = "api_key_passthrough"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
|
||||
|
||||
const upstreamBody = `{"error":{"message":"request body exceeds this account's 16MB proxy limit; secret=must-not-leak","type":"invalid_request_error"}}`
|
||||
body := &passthroughCloseTrackingReadCloser{Reader: strings.NewReader(upstreamBody)}
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusRequestEntityTooLarge,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"X-Request-Id": []string{"rid-body-limit"},
|
||||
},
|
||||
Body: body,
|
||||
}}
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}},
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
account := &Account{
|
||||
ID: 161,
|
||||
Name: name,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-test",
|
||||
"base_url": "https://api.example.test",
|
||||
"pool_mode": true,
|
||||
"pool_mode_retry_status_codes": []any{
|
||||
float64(http.StatusRequestEntityTooLarge),
|
||||
},
|
||||
},
|
||||
Extra: map[string]any{
|
||||
"openai_passthrough": passthrough,
|
||||
"openai_responses_supported": true,
|
||||
},
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, requestBody)
|
||||
|
||||
require.Nil(t, result)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.Equal(t, http.StatusRequestEntityTooLarge, failoverErr.StatusCode)
|
||||
require.Equal(t, GatewayFailureScopeAccount, failoverErr.Scope)
|
||||
require.Equal(t, GatewayFailureReason("openai_request_body_too_large"), failoverErr.Reason)
|
||||
require.Equal(t, NextAccountRetry, failoverErr.NextAccountAction)
|
||||
require.Equal(t, http.StatusRequestEntityTooLarge, failoverErr.ClientStatusCode)
|
||||
require.Equal(t, "Request payload is too large", failoverErr.ClientMessage)
|
||||
require.False(t, failoverErr.RetryableOnSameAccount, "a body limit requires another account, not another attempt on the same account")
|
||||
require.False(t, c.Writer.Written(), "account failover must happen before downstream output is committed")
|
||||
require.Empty(t, rec.Body.String())
|
||||
require.True(t, body.closed)
|
||||
if passthrough {
|
||||
require.Equal(t, requestBody, upstream.lastBody)
|
||||
} else {
|
||||
require.Equal(t, "gpt-5.2", gjson.GetBytes(upstream.lastBody, "model").String())
|
||||
require.Equal(t, "hello", gjson.GetBytes(upstream.lastBody, "input").String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIRequestBodyLimitFailover_ContextWindow413DoesNotSwitchAccounts(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
requestBody := []byte(`{"model":"gpt-5.2","stream":false,"input":"hello"}`)
|
||||
|
||||
for _, passthrough := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("passthrough_%t", passthrough), func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
|
||||
|
||||
const upstreamBody = `{"error":{"message":"Your input exceeds the context window of this model. Please adjust your input and try again.","type":"invalid_request_error"}}`
|
||||
body := &passthroughCloseTrackingReadCloser{Reader: strings.NewReader(upstreamBody)}
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}},
|
||||
httpUpstream: &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusRequestEntityTooLarge,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: body,
|
||||
}},
|
||||
}
|
||||
account := &Account{
|
||||
ID: 162, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1,
|
||||
Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://api.example.test"},
|
||||
Extra: map[string]any{
|
||||
"openai_passthrough": passthrough,
|
||||
"openai_responses_supported": true,
|
||||
},
|
||||
Status: StatusActive, Schedulable: true,
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, requestBody)
|
||||
|
||||
require.Nil(t, result)
|
||||
require.Error(t, err)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.False(t, errors.As(err, &failoverErr), "context-window failures are deterministic request errors")
|
||||
require.True(t, c.Writer.Written())
|
||||
require.Contains(t, rec.Body.String(), "exceeds the context window")
|
||||
require.True(t, body.closed)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,18 +7,23 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// normalizeOpenAIResponsesLiteTools converts private namespace declarations
|
||||
// into the input.additional_tools carrier required by Responses Lite. Other
|
||||
// top-level tools must belong to the small set accepted by the Lite endpoint;
|
||||
// rejecting unsupported hosted tools is intentional because silently dropping
|
||||
// them would change the client's requested behavior.
|
||||
// normalizeOpenAIResponsesLiteTools applies the Responses Lite request
|
||||
// contract: reasoning must cover all turns, and private namespace declarations
|
||||
// use the input.additional_tools carrier. Other top-level tools must belong to
|
||||
// the small set accepted by the Lite endpoint; rejecting unsupported hosted
|
||||
// tools is intentional because silently dropping them would change behavior.
|
||||
func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) {
|
||||
if reqBody == nil {
|
||||
return false, nil
|
||||
}
|
||||
if rawReasoning, exists := reqBody["reasoning"]; exists && rawReasoning != nil {
|
||||
if _, ok := rawReasoning.(map[string]any); !ok {
|
||||
return false, fmt.Errorf("responses Lite requires reasoning to be an object")
|
||||
}
|
||||
}
|
||||
rawTools, exists := reqBody["tools"]
|
||||
if !exists || rawTools == nil {
|
||||
return false, nil
|
||||
return ensureOpenAIResponsesLiteReasoningContext(reqBody)
|
||||
}
|
||||
tools, ok := rawTools.([]any)
|
||||
if !ok {
|
||||
@@ -52,13 +57,16 @@ func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) {
|
||||
}
|
||||
}
|
||||
if len(namespaceTools) == 0 {
|
||||
return false, nil
|
||||
return ensureOpenAIResponsesLiteReasoningContext(reqBody)
|
||||
}
|
||||
|
||||
input, err := appendOpenAIResponsesLiteAdditionalTools(reqBody["input"], namespaceTools)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := ensureOpenAIResponsesLiteReasoningContext(reqBody); err != nil {
|
||||
return false, err
|
||||
}
|
||||
reqBody["input"] = input
|
||||
if len(topLevelTools) == 0 {
|
||||
delete(reqBody, "tools")
|
||||
@@ -68,6 +76,23 @@ func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func ensureOpenAIResponsesLiteReasoningContext(reqBody map[string]any) (bool, error) {
|
||||
rawReasoning, exists := reqBody["reasoning"]
|
||||
if !exists || rawReasoning == nil {
|
||||
reqBody["reasoning"] = map[string]any{"context": "all_turns"}
|
||||
return true, nil
|
||||
}
|
||||
reasoning, ok := rawReasoning.(map[string]any)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("responses Lite requires reasoning to be an object")
|
||||
}
|
||||
if context, ok := reasoning["context"].(string); ok && context == "all_turns" {
|
||||
return false, nil
|
||||
}
|
||||
reasoning["context"] = "all_turns"
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func appendOpenAIResponsesLiteAdditionalTools(input any, namespaceTools []any) ([]any, error) {
|
||||
var items []any
|
||||
switch typed := input.(type) {
|
||||
|
||||
@@ -135,6 +135,7 @@ func TestNormalizeOpenAIResponsesLiteTools_ConvertsStringInput(t *testing.T) {
|
||||
|
||||
func TestNormalizeOpenAIResponsesLiteTools_KeepsSupportedTopLevelTools(t *testing.T) {
|
||||
reqBody := map[string]any{
|
||||
"reasoning": map[string]any{"context": "all_turns"},
|
||||
"tools": []any{
|
||||
map[string]any{"type": "function", "name": "shell"},
|
||||
map[string]any{"type": "custom", "name": "exec"},
|
||||
@@ -150,6 +151,46 @@ func TestNormalizeOpenAIResponsesLiteTools_KeepsSupportedTopLevelTools(t *testin
|
||||
require.Len(t, reqBody["tools"], 4)
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesLiteTools_EnsuresReasoningContext(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
reasoning any
|
||||
}{
|
||||
{name: "missing"},
|
||||
{name: "missing context", reasoning: map[string]any{"effort": "high"}},
|
||||
{name: "wrong context", reasoning: map[string]any{"effort": "medium", "context": "current_turn"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reqBody := map[string]any{"input": "hello"}
|
||||
if tt.reasoning != nil {
|
||||
reqBody["reasoning"] = tt.reasoning
|
||||
}
|
||||
|
||||
changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
reasoning := reqBody["reasoning"].(map[string]any)
|
||||
require.Equal(t, "all_turns", reasoning["context"])
|
||||
if tt.name != "missing" {
|
||||
require.Equal(t, tt.reasoning.(map[string]any)["effort"], reasoning["effort"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesLiteTools_RejectsNonObjectReasoning(t *testing.T) {
|
||||
reqBody := map[string]any{"reasoning": "high"}
|
||||
|
||||
changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
|
||||
|
||||
require.ErrorContains(t, err, "reasoning to be an object")
|
||||
require.False(t, changed)
|
||||
require.Equal(t, "high", reqBody["reasoning"])
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesLiteTools_RejectsUnsupportedTools(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -240,6 +281,7 @@ func TestOpenAIGatewayServiceForward_NormalizesResponsesLiteToolsForOAuth(t *tes
|
||||
}
|
||||
body := []byte(`{
|
||||
"model":"gpt-5.6-terra","stream":true,"instructions":"test",
|
||||
"reasoning":{"effort":"high","context":"current_turn"},
|
||||
"tools":[
|
||||
{"type":"function","name":"shell","parameters":{"type":"object"}},
|
||||
{"type":"custom","name":"exec"},
|
||||
@@ -255,6 +297,8 @@ func TestOpenAIGatewayServiceForward_NormalizesResponsesLiteToolsForOAuth(t *tes
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader))
|
||||
require.Equal(t, "high", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
|
||||
require.Equal(t, "all_turns", gjson.GetBytes(upstream.lastBody, "reasoning.context").String())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="namespace")`).Exists())
|
||||
require.Equal(t, "shell", gjson.GetBytes(upstream.lastBody, `tools.#(type=="function").name`).String())
|
||||
require.Equal(t, "exec", gjson.GetBytes(upstream.lastBody, `tools.#(type=="custom").name`).String())
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
const maxOpenAIResponsesRejectedFieldRetries = 6
|
||||
|
||||
var (
|
||||
openAIResponsesRejectedNamespaceParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.namespace$`)
|
||||
openAIResponsesRejectedMessageParamPattern = regexp.MustCompile(`(?i)(?:unknown|unsupported)[ _-]+parameter\s*(?::|=|is)?\s*["']?(max_output_tokens|input\[\d+\]\.namespace)(?:["']|\b)`)
|
||||
)
|
||||
|
||||
type openAIResponsesRejectedFieldRetryState struct {
|
||||
attempts int
|
||||
seenBodyHashes map[[sha256.Size]byte]struct{}
|
||||
}
|
||||
|
||||
func newOpenAIResponsesRejectedFieldRetryState(initialBody []byte) *openAIResponsesRejectedFieldRetryState {
|
||||
state := &openAIResponsesRejectedFieldRetryState{
|
||||
seenBodyHashes: make(map[[sha256.Size]byte]struct{}, maxOpenAIResponsesRejectedFieldRetries+1),
|
||||
}
|
||||
state.remember(initialBody)
|
||||
return state
|
||||
}
|
||||
|
||||
func (s *openAIResponsesRejectedFieldRetryState) Allow(nextBody []byte) bool {
|
||||
if s == nil || len(nextBody) == 0 || s.attempts >= maxOpenAIResponsesRejectedFieldRetries {
|
||||
return false
|
||||
}
|
||||
bodyHash := sha256.Sum256(nextBody)
|
||||
if _, seen := s.seenBodyHashes[bodyHash]; seen {
|
||||
return false
|
||||
}
|
||||
s.seenBodyHashes[bodyHash] = struct{}{}
|
||||
s.attempts++
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *openAIResponsesRejectedFieldRetryState) remember(body []byte) {
|
||||
if s == nil || len(body) == 0 {
|
||||
return
|
||||
}
|
||||
if s.seenBodyHashes == nil {
|
||||
s.seenBodyHashes = make(map[[sha256.Size]byte]struct{}, maxOpenAIResponsesRejectedFieldRetries+1)
|
||||
}
|
||||
s.seenBodyHashes[sha256.Sum256(body)] = struct{}{}
|
||||
}
|
||||
|
||||
func normalizeOpenAIResponsesRejectedFieldRetryBody(statusCode int, body, responseBody []byte) ([]byte, string, bool, error) {
|
||||
if statusCode != http.StatusBadRequest || len(body) == 0 || len(responseBody) == 0 {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
|
||||
code := strings.ToLower(strings.TrimSpace(extractUpstreamErrorCode(responseBody)))
|
||||
message := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(responseBody)))
|
||||
if !isExplicitOpenAIResponsesFieldRejection(code, message) {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
|
||||
param := strings.ToLower(strings.TrimSpace(gjson.GetBytes(responseBody, "error.param").String()))
|
||||
if param == "" {
|
||||
param = openAIResponsesRejectedParamFromMessage(message)
|
||||
}
|
||||
if index, ok := openAIResponsesRejectedNamespaceIndex(param); ok {
|
||||
return removeOpenAIResponsesRejectedNamespaceAtIndex(body, index)
|
||||
}
|
||||
if param == "max_output_tokens" && gjson.GetBytes(body, "max_output_tokens").Exists() {
|
||||
retryBody, err := sjson.DeleteBytes(body, "max_output_tokens")
|
||||
if err != nil {
|
||||
return nil, "", false, fmt.Errorf("delete rejected max_output_tokens: %w", err)
|
||||
}
|
||||
return retryBody, "max_output_tokens parameter rejection", true, nil
|
||||
}
|
||||
return nil, "", false, nil
|
||||
}
|
||||
|
||||
func isExplicitOpenAIResponsesFieldRejection(code, message string) bool {
|
||||
switch strings.TrimSpace(code) {
|
||||
case "unknown_parameter", "unsupported_parameter":
|
||||
return true
|
||||
}
|
||||
return strings.Contains(message, "unknown parameter") ||
|
||||
strings.Contains(message, "unsupported parameter")
|
||||
}
|
||||
|
||||
func openAIResponsesRejectedParamFromMessage(message string) string {
|
||||
match := openAIResponsesRejectedMessageParamPattern.FindStringSubmatch(strings.TrimSpace(message))
|
||||
if len(match) != 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(match[1]))
|
||||
}
|
||||
|
||||
func openAIResponsesRejectedNamespaceIndex(param string) (int, bool) {
|
||||
match := openAIResponsesRejectedNamespaceParamPattern.FindStringSubmatch(strings.TrimSpace(param))
|
||||
if len(match) != 2 {
|
||||
return 0, false
|
||||
}
|
||||
index, err := strconv.Atoi(match[1])
|
||||
if err == nil && index >= 0 {
|
||||
return index, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func removeOpenAIResponsesRejectedNamespaceAtIndex(body []byte, index int) ([]byte, string, bool, error) {
|
||||
itemPath := fmt.Sprintf("input.%d", index)
|
||||
itemType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, itemPath+".type").String()))
|
||||
switch itemType {
|
||||
case "function_call", "tool_call", "custom_tool_call", "mcp_tool_call":
|
||||
default:
|
||||
return nil, "", false, nil
|
||||
}
|
||||
|
||||
namespacePath := itemPath + ".namespace"
|
||||
if !gjson.GetBytes(body, namespacePath).Exists() {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
retryBody, err := sjson.DeleteBytes(body, namespacePath)
|
||||
if err != nil {
|
||||
return nil, "", false, fmt.Errorf("delete rejected namespace at input[%d]: %w", index, err)
|
||||
}
|
||||
return retryBody, "indexed namespace parameter rejection", true, nil
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestOpenAIResponsesRejectedFieldRetryStateRejectsDuplicateBodyAndCap(t *testing.T) {
|
||||
initialBody := []byte(`{"model":"gpt-5.5"}`)
|
||||
state := newOpenAIResponsesRejectedFieldRetryState(initialBody)
|
||||
|
||||
require.False(t, state.Allow(initialBody))
|
||||
for attempt := 0; attempt < maxOpenAIResponsesRejectedFieldRetries; attempt++ {
|
||||
nextBody := []byte(fmt.Sprintf(`{"model":"gpt-5.5","variant":%d}`, attempt))
|
||||
require.True(t, state.Allow(nextBody))
|
||||
require.False(t, state.Allow(nextBody))
|
||||
}
|
||||
require.False(t, state.Allow([]byte(`{"model":"gpt-5.5","variant":"overflow"}`)))
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsAmbiguousErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
responseBody []byte
|
||||
}{
|
||||
{
|
||||
name: "namespace belongs to message",
|
||||
body: []byte(`{"input":[{"type":"message","namespace":"keep"}]}`),
|
||||
responseBody: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[0].namespace'.","param":"input[0].namespace"}}`),
|
||||
},
|
||||
{
|
||||
name: "max output tokens only mentioned",
|
||||
body: []byte(`{"max_output_tokens":4096}`),
|
||||
responseBody: []byte(`{"error":{"code":"invalid_request_error","message":"max_output_tokens must be positive","param":"max_output_tokens"}}`),
|
||||
},
|
||||
{
|
||||
name: "structured param overrides namespace mention",
|
||||
body: []byte(`{"input":[{"type":"function_call","namespace":"keep","arguments":"{}"}]}`),
|
||||
responseBody: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[0].namespace'.","param":"tools"}}`),
|
||||
},
|
||||
{
|
||||
name: "nested max output tokens param is not top level",
|
||||
body: []byte(`{"max_output_tokens":4096,"input":[{"type":"message","content":{"max_output_tokens":"keep"}}]}`),
|
||||
responseBody: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: input[0].content.max_output_tokens","param":"input[0].content.max_output_tokens"}}`),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, tt.body, tt.responseBody)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Nil(t, retryBody)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyFindsNamespacePathInMessage(t *testing.T) {
|
||||
body := []byte(`{"input":[{"type":"function_call","namespace":"keep","arguments":"{}"},{"type":"function_call","namespace":"remove","arguments":"{}"}]}`)
|
||||
responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"input[0] was accepted; Unknown parameter: 'input[1].namespace'."}}`)
|
||||
|
||||
retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "keep", gjson.GetBytes(retryBody, "input.0.namespace").String())
|
||||
require.False(t, gjson.GetBytes(retryBody, "input.1.namespace").Exists())
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyBindsNamespacePathToRejectionPhrase(t *testing.T) {
|
||||
body := []byte(`{"input":[{"type":"function_call","namespace":"keep","arguments":"{}"},{"type":"function_call","namespace":"remove","arguments":"{}"}]}`)
|
||||
responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"input[0].namespace is supported; Unknown parameter: input[1].namespace."}}`)
|
||||
|
||||
retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "keep", gjson.GetBytes(retryBody, "input.0.namespace").String())
|
||||
require.False(t, gjson.GetBytes(retryBody, "input.1.namespace").Exists())
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyDoesNotTreatMaxOutputTokensSuggestionAsRejection(t *testing.T) {
|
||||
body := []byte(`{"max_tokens":4096,"max_output_tokens":2048}`)
|
||||
responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: max_tokens. Use max_output_tokens instead."}}`)
|
||||
|
||||
retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Nil(t, retryBody)
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyBindsMaxOutputTokensToRejectionPhrase(t *testing.T) {
|
||||
body := []byte(`{"max_output_tokens":2048}`)
|
||||
responseBody := []byte(`{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: max_output_tokens."}}`)
|
||||
|
||||
retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(retryBody, "max_output_tokens").Exists())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_RetriesRejectedIndexedNamespaceField(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"function_call","name":"first","namespace":"keep","arguments":"{}"},{"type":"custom_tool_call","name":"second","namespace":"remove","input":"{}"}]}`)
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[1].namespace'.","param":"input[1].namespace","type":"invalid_request_error"}}`),
|
||||
newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
|
||||
}}
|
||||
|
||||
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
|
||||
context.Background(),
|
||||
newOpenAIRejectedFieldTestContext(body),
|
||||
newOpenAIRejectedFieldTestAccount(),
|
||||
body,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, upstream.bodies, 2)
|
||||
require.Equal(t, "keep", gjson.GetBytes(upstream.bodies[1], "input.0.namespace").String())
|
||||
require.False(t, gjson.GetBytes(upstream.bodies[1], "input.1.namespace").Exists())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_RetriesExplicitMaxOutputTokensRejection(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5.5","stream":false,"max_output_tokens":4096,"input":[{"type":"message","role":"user","content":{"max_output_tokens":"keep"}}]}`)
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: max_output_tokens","param":"max_output_tokens","type":"invalid_request_error"}}`),
|
||||
newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
|
||||
}}
|
||||
|
||||
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
|
||||
context.Background(),
|
||||
newOpenAIRejectedFieldTestContext(body),
|
||||
newOpenAIRejectedFieldTestAccount(),
|
||||
body,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, upstream.bodies, 2)
|
||||
require.Equal(t, int64(4096), gjson.GetBytes(upstream.bodies[0], "max_output_tokens").Int())
|
||||
require.False(t, gjson.GetBytes(upstream.bodies[1], "max_output_tokens").Exists())
|
||||
require.Equal(t, "keep", gjson.GetBytes(upstream.bodies[1], "input.0.content.max_output_tokens").String())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_ComposesDistinctRejectedFieldRetries(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5.5","stream":false,"max_output_tokens":2048,"input":[{"type":"function_call","name":"first","namespace":"keep","arguments":"{}"},{"type":"custom_tool_call","name":"second","namespace":"remove","input":"{}"}]}`)
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[1].namespace'.","param":"input[1].namespace"}}`),
|
||||
newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: max_output_tokens","param":"max_output_tokens"}}`),
|
||||
newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
|
||||
}}
|
||||
|
||||
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
|
||||
context.Background(),
|
||||
newOpenAIRejectedFieldTestContext(body),
|
||||
newOpenAIRejectedFieldTestAccount(),
|
||||
body,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, upstream.bodies, 3)
|
||||
require.True(t, gjson.GetBytes(upstream.bodies[0], "input.1.namespace").Exists())
|
||||
require.False(t, gjson.GetBytes(upstream.bodies[1], "input.1.namespace").Exists())
|
||||
require.Equal(t, int64(2048), gjson.GetBytes(upstream.bodies[1], "max_output_tokens").Int())
|
||||
require.False(t, gjson.GetBytes(upstream.bodies[2], "input.1.namespace").Exists())
|
||||
require.False(t, gjson.GetBytes(upstream.bodies[2], "max_output_tokens").Exists())
|
||||
}
|
||||
|
||||
func newOpenAIRejectedFieldTestService(upstream *httpUpstreamRecorder) *OpenAIGatewayService {
|
||||
return &OpenAIGatewayService{
|
||||
cfg: &config.Config{Security: config.SecurityConfig{
|
||||
URLAllowlist: config.URLAllowlistConfig{Enabled: false},
|
||||
}},
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
}
|
||||
|
||||
func newOpenAIRejectedFieldTestContext(body []byte) *gin.Context {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request.Header.Set("User-Agent", "curl/8.0")
|
||||
return c
|
||||
}
|
||||
|
||||
func newOpenAIRejectedFieldTestAccount() *Account {
|
||||
return &Account{
|
||||
ID: 5107,
|
||||
Name: "responses-compatible",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-test",
|
||||
"base_url": "https://compat.example",
|
||||
},
|
||||
Extra: map[string]any{
|
||||
openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeAuto),
|
||||
openai_compat.ExtraKeyResponsesSupported: true,
|
||||
},
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
}
|
||||
}
|
||||
|
||||
func newOpenAIRejectedFieldTestResponse(status int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,138 @@ func TestOpenAIWSv2StreamingRepairsConcatenatedJSONDocumentsInSingleMessage(t *t
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenAIWSv2RejectsMalformedTypedEventBeforeWritingDownstream(t *testing.T) {
|
||||
largeInProgress, _, _ := openAIConcatenatedJSONTestEvents(t)
|
||||
testOpenAIWSv2RejectsMalformedEventBeforeWritingDownstream(t, []byte(largeInProgress+"unexpected-tail"))
|
||||
}
|
||||
|
||||
func TestOpenAIWSv2RejectsMalformedUntypedMessageBeforeWritingDownstream(t *testing.T) {
|
||||
testOpenAIWSv2RejectsMalformedEventBeforeWritingDownstream(t, []byte("not-json"))
|
||||
}
|
||||
|
||||
func TestOpenAIWSv2RejectsMalformedEventAfterWritingDownstream(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
outputTextDelta := `{"type":"response.output_text.delta","delta":"ok","sequence_number":1}`
|
||||
malformedMessage := `{"type":"response.in_progress"}unexpected-tail`
|
||||
captureConn := &openAIWSCaptureConn{events: [][]byte{
|
||||
[]byte(outputTextDelta),
|
||||
[]byte(malformedMessage),
|
||||
}}
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Security.URLAllowlist.Enabled = false
|
||||
cfg.Gateway.OpenAIWS.Enabled = true
|
||||
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
|
||||
cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
|
||||
cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1
|
||||
cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1
|
||||
cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8
|
||||
cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3
|
||||
cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 5
|
||||
cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3
|
||||
|
||||
pool := newOpenAIWSConnPool(cfg)
|
||||
pool.setClientDialerForTest(&openAIWSCaptureDialer{conn: captureConn})
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: cfg,
|
||||
cache: &stubGatewayCache{},
|
||||
httpUpstream: &httpUpstreamRecorder{},
|
||||
openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
|
||||
openaiWSPool: pool,
|
||||
toolCorrector: NewCodexToolCorrector(),
|
||||
}
|
||||
account := &Account{
|
||||
ID: 5,
|
||||
Name: "ws-malformed-event-after-output",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{"api_key": "sk-test"},
|
||||
Extra: map[string]any{"responses_websockets_v2_enabled": true},
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
groupID := int64(1)
|
||||
c.Set("api_key", &APIKey{GroupID: &groupID})
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.6-sol","stream":true,"input":"hello"}`))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "after downstream output")
|
||||
require.Nil(t, result)
|
||||
require.True(t, captureConn.closed)
|
||||
require.Contains(t, recorder.Body.String(), `"delta":"ok"`)
|
||||
require.NotContains(t, recorder.Body.String(), "unexpected-tail")
|
||||
require.NotContains(t, recorder.Body.String(), "response.in_progress")
|
||||
assertOpenAISSEFrames(t, recorder.Body.String(), []string{"response.output_text.delta"})
|
||||
}
|
||||
|
||||
func testOpenAIWSv2RejectsMalformedEventBeforeWritingDownstream(t *testing.T, malformedMessage []byte) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
_, _, completed := openAIConcatenatedJSONTestEvents(t)
|
||||
outputTextDelta := `{"type":"response.output_text.delta","delta":"ok","sequence_number":3}`
|
||||
captureConn := &openAIWSCaptureConn{events: [][]byte{
|
||||
malformedMessage,
|
||||
[]byte(outputTextDelta),
|
||||
[]byte(completed),
|
||||
}}
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Security.URLAllowlist.Enabled = false
|
||||
cfg.Gateway.OpenAIWS.Enabled = true
|
||||
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
|
||||
cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
|
||||
cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1
|
||||
cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1
|
||||
cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8
|
||||
cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3
|
||||
cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 5
|
||||
cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3
|
||||
|
||||
pool := newOpenAIWSConnPool(cfg)
|
||||
pool.setClientDialerForTest(&openAIWSCaptureDialer{conn: captureConn})
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: cfg,
|
||||
cache: &stubGatewayCache{},
|
||||
httpUpstream: &httpUpstreamRecorder{},
|
||||
openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
|
||||
openaiWSPool: pool,
|
||||
toolCorrector: NewCodexToolCorrector(),
|
||||
}
|
||||
account := &Account{
|
||||
ID: 4,
|
||||
Name: "ws-malformed-event",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{"api_key": "sk-test"},
|
||||
Extra: map[string]any{"responses_websockets_v2_enabled": true},
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
groupID := int64(1)
|
||||
c.Set("api_key", &APIKey{GroupID: &groupID})
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.6-sol","stream":true,"input":"hello"}`))
|
||||
require.Error(t, err)
|
||||
var fallbackErr *openAIWSFallbackError
|
||||
require.ErrorAs(t, err, &fallbackErr)
|
||||
require.Equal(t, "invalid_event_json", fallbackErr.Reason)
|
||||
require.Nil(t, result)
|
||||
require.Empty(t, recorder.Body.String())
|
||||
require.True(t, captureConn.closed)
|
||||
}
|
||||
|
||||
func TestSplitOpenAIConcatenatedJSONDocumentsRejectsPayloadOverRepairLimit(t *testing.T) {
|
||||
first := `{"type":"response.in_progress","padding":"` + strings.Repeat("x", 16*1024*1024) + `"}`
|
||||
second := `{"type":"response.completed"}`
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
coderws "github.com/coder/websocket"
|
||||
)
|
||||
|
||||
type openAIWSClientReadResult struct {
|
||||
messageType coderws.MessageType
|
||||
payload []byte
|
||||
err error
|
||||
}
|
||||
|
||||
// ReadOpenAIWSClientMessage keeps one reader alive while control events send
|
||||
// their close frame, then closes the transport and joins that reader.
|
||||
func ReadOpenAIWSClientMessage(
|
||||
controlCtx context.Context,
|
||||
conn *coderws.Conn,
|
||||
timeout time.Duration,
|
||||
timeoutStatus coderws.StatusCode,
|
||||
timeoutReason string,
|
||||
) (coderws.MessageType, []byte, error) {
|
||||
if conn == nil {
|
||||
return 0, nil, errors.New("openai websocket client connection is nil")
|
||||
}
|
||||
if controlCtx == nil {
|
||||
controlCtx = context.Background()
|
||||
}
|
||||
|
||||
readDone := make(chan openAIWSClientReadResult, 1)
|
||||
go func() {
|
||||
messageType, payload, err := conn.Read(context.Background())
|
||||
readDone <- openAIWSClientReadResult{messageType: messageType, payload: payload, err: err}
|
||||
}()
|
||||
|
||||
var timeoutCh <-chan time.Time
|
||||
var timer *time.Timer
|
||||
if timeout > 0 {
|
||||
timer = time.NewTimer(timeout)
|
||||
timeoutCh = timer.C
|
||||
defer timer.Stop()
|
||||
}
|
||||
|
||||
closeAndJoin := func(status coderws.StatusCode, reason string, cause error) (coderws.MessageType, []byte, error) {
|
||||
_ = conn.Close(status, reason)
|
||||
_ = conn.CloseNow()
|
||||
<-readDone
|
||||
return 0, nil, NewOpenAIWSClientCloseError(status, reason, cause)
|
||||
}
|
||||
|
||||
select {
|
||||
case result := <-readDone:
|
||||
return result.messageType, result.payload, result.err
|
||||
case <-timeoutCh:
|
||||
return closeAndJoin(timeoutStatus, timeoutReason, context.DeadlineExceeded)
|
||||
case <-controlCtx.Done():
|
||||
cause := context.Cause(controlCtx)
|
||||
if errors.Is(cause, ErrOpenAIWSIngressLeaseLost) {
|
||||
return closeAndJoin(
|
||||
coderws.StatusTryAgainLater,
|
||||
"websocket ingress capacity lease lost; please reconnect",
|
||||
cause,
|
||||
)
|
||||
}
|
||||
return closeAndJoin(coderws.StatusGoingAway, "websocket request canceled", cause)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
coderws "github.com/coder/websocket"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestReadOpenAIWSClientMessage_ControlCloseFrames(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
timeout time.Duration
|
||||
timeoutStatus coderws.StatusCode
|
||||
timeoutReason string
|
||||
cancelCause error
|
||||
wantStatus coderws.StatusCode
|
||||
wantReason string
|
||||
}{
|
||||
{
|
||||
name: "inter-turn idle sends normal close",
|
||||
timeout: 25 * time.Millisecond,
|
||||
timeoutStatus: coderws.StatusNormalClosure,
|
||||
timeoutReason: "websocket idle timeout",
|
||||
wantStatus: coderws.StatusNormalClosure,
|
||||
wantReason: "websocket idle timeout",
|
||||
},
|
||||
{
|
||||
name: "first message timeout sends policy close",
|
||||
timeout: 25 * time.Millisecond,
|
||||
timeoutStatus: coderws.StatusPolicyViolation,
|
||||
timeoutReason: "missing first response.create message",
|
||||
wantStatus: coderws.StatusPolicyViolation,
|
||||
wantReason: "missing first response.create message",
|
||||
},
|
||||
{
|
||||
name: "lease loss sends retry close",
|
||||
cancelCause: ErrOpenAIWSIngressLeaseLost,
|
||||
wantStatus: coderws.StatusTryAgainLater,
|
||||
wantReason: "websocket ingress capacity lease lost; please reconnect",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
controlCtx, cancelControl := context.WithCancelCause(context.Background())
|
||||
defer cancelControl(context.Canceled)
|
||||
serverResult := make(chan error, 1)
|
||||
readStarted := make(chan struct{})
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := coderws.Accept(w, r, nil)
|
||||
if err != nil {
|
||||
serverResult <- err
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.CloseNow() }()
|
||||
close(readStarted)
|
||||
_, _, err = ReadOpenAIWSClientMessage(
|
||||
controlCtx,
|
||||
conn,
|
||||
tt.timeout,
|
||||
tt.timeoutStatus,
|
||||
tt.timeoutReason,
|
||||
)
|
||||
serverResult <- err
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
dialCtx, cancelDial := context.WithTimeout(context.Background(), time.Second)
|
||||
clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(server.URL, "http"), nil)
|
||||
cancelDial()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = clientConn.CloseNow() }()
|
||||
<-readStarted
|
||||
if tt.cancelCause != nil {
|
||||
cancelControl(tt.cancelCause)
|
||||
}
|
||||
|
||||
readCtx, cancelRead := context.WithTimeout(context.Background(), time.Second)
|
||||
_, _, err = clientConn.Read(readCtx)
|
||||
cancelRead()
|
||||
var clientClose coderws.CloseError
|
||||
require.ErrorAs(t, err, &clientClose)
|
||||
require.Equal(t, tt.wantStatus, clientClose.Code)
|
||||
require.Equal(t, tt.wantReason, clientClose.Reason)
|
||||
|
||||
select {
|
||||
case serverErr := <-serverResult:
|
||||
var closeErr *OpenAIWSClientCloseError
|
||||
require.ErrorAs(t, serverErr, &closeErr)
|
||||
require.Equal(t, tt.wantStatus, closeErr.StatusCode())
|
||||
require.Equal(t, tt.wantReason, closeErr.Reason())
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("server read goroutine did not exit after close handshake")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOpenAIWSClientMessage_ParentCancellationStillJoinsRead(t *testing.T) {
|
||||
controlCtx, cancelControl := context.WithCancelCause(context.Background())
|
||||
serverResult := make(chan error, 1)
|
||||
readStarted := make(chan struct{})
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := coderws.Accept(w, r, nil)
|
||||
if err != nil {
|
||||
serverResult <- err
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.CloseNow() }()
|
||||
close(readStarted)
|
||||
_, _, err = ReadOpenAIWSClientMessage(controlCtx, conn, 0, 0, "")
|
||||
serverResult <- err
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
dialCtx, cancelDial := context.WithTimeout(context.Background(), time.Second)
|
||||
clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(server.URL, "http"), nil)
|
||||
cancelDial()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = clientConn.CloseNow() }()
|
||||
<-readStarted
|
||||
cancelControl(errors.New("server shutting down"))
|
||||
readCtx, cancelRead := context.WithTimeout(context.Background(), time.Second)
|
||||
_, _, err = clientConn.Read(readCtx)
|
||||
cancelRead()
|
||||
var clientClose coderws.CloseError
|
||||
require.ErrorAs(t, err, &clientClose)
|
||||
require.Equal(t, coderws.StatusGoingAway, clientClose.Code)
|
||||
require.Equal(t, "websocket request canceled", clientClose.Reason)
|
||||
|
||||
select {
|
||||
case <-serverResult:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("server read goroutine leaked after parent cancellation")
|
||||
}
|
||||
}
|
||||
@@ -305,7 +305,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
normalized = stripped
|
||||
logOpenAIWSModeInfo("ingress_ws_codex_spark_image_tool_stripped account_id=%d", account.ID)
|
||||
}
|
||||
imageIntent := IsImageGenerationIntent(openAIResponsesEndpoint, originalModel, normalized)
|
||||
imageIntent := IsImageGenerationIntentForPlatform(openAIResponsesEndpoint, originalModel, normalized, account.Platform)
|
||||
if imageIntent && !imageGenerationAllowed {
|
||||
return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, ImageGenerationPermissionMessage(), nil)
|
||||
}
|
||||
@@ -382,22 +382,18 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
}
|
||||
|
||||
readClientMessage := func() ([]byte, error) {
|
||||
readCtx := ctx
|
||||
idleTimeout := s.openAIWSIngressInterTurnIdleTimeout()
|
||||
cancelRead := func() {}
|
||||
if idleTimeout > 0 {
|
||||
readCtx, cancelRead = context.WithTimeout(ctx, idleTimeout)
|
||||
}
|
||||
msgType, payload, readErr := clientConn.Read(readCtx)
|
||||
cancelRead()
|
||||
msgType, payload, readErr := ReadOpenAIWSClientMessage(
|
||||
ctx,
|
||||
clientConn,
|
||||
idleTimeout,
|
||||
coderws.StatusNormalClosure,
|
||||
"websocket idle timeout",
|
||||
)
|
||||
if readErr != nil {
|
||||
if idleTimeout > 0 && errors.Is(readErr, context.DeadlineExceeded) && ctx.Err() == nil {
|
||||
var closeErr *OpenAIWSClientCloseError
|
||||
if errors.As(readErr, &closeErr) && closeErr.StatusCode() == coderws.StatusNormalClosure {
|
||||
logOpenAIWSModeInfo("ingress_ws_inter_turn_idle_timeout account_id=%d timeout_seconds=%d", account.ID, int(idleTimeout.Seconds()))
|
||||
return nil, NewOpenAIWSClientCloseError(
|
||||
coderws.StatusNormalClosure,
|
||||
"websocket idle timeout",
|
||||
readErr,
|
||||
)
|
||||
}
|
||||
return nil, readErr
|
||||
}
|
||||
@@ -674,6 +670,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
return acquireTurnLease(turn, preferred, forcePreferredConn)
|
||||
}
|
||||
if acquireErr != nil {
|
||||
canonicalModel := canonicalOpenAIAccountSchedulingModel(account, ingressSessionOriginalModel)
|
||||
s.handleOpenAIWSDialTransientFailure(ctx, account, canonicalModel, acquireErr)
|
||||
dialStatus, dialClass, dialCloseStatus, dialCloseReason, dialRespServer, dialRespVia, dialRespCFRay, dialRespReqID := summarizeOpenAIWSDialError(acquireErr)
|
||||
logOpenAIWSModeInfo(
|
||||
"ingress_ws_upstream_acquire_fail account_id=%d turn=%d reason=%s dial_status=%d dial_class=%s dial_close_status=%s dial_close_reason=%s dial_resp_server=%s dial_resp_via=%s dial_resp_cf_ray=%s dial_resp_x_request_id=%s cause=%s preferred_conn_id=%s force_preferred_conn=%v ws_host=%s ws_path=%s proxy_enabled=%v",
|
||||
@@ -818,6 +816,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
lastEventType = eventType
|
||||
}
|
||||
if eventType == "error" {
|
||||
canonicalModel := canonicalOpenAIAccountSchedulingModel(account, originalModel)
|
||||
s.handleOpenAIWSErrorEventTransientFailure(ctx, account, canonicalModel, lease.HandshakeHeaders(), upstreamMessage)
|
||||
errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(upstreamMessage)
|
||||
s.persistOpenAIWSRateLimitSignal(ctx, account, lease.HandshakeHeaders(), upstreamMessage, errCodeRaw, errTypeRaw, errMsgRaw)
|
||||
fallbackReason, _ := classifyOpenAIWSErrorEventFromRaw(errCodeRaw, errTypeRaw, errMsgRaw)
|
||||
@@ -950,6 +950,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
}
|
||||
}
|
||||
if isTerminalEvent {
|
||||
canonicalModel := canonicalOpenAIAccountSchedulingModel(account, originalModel)
|
||||
terminalEvent := s.handleOpenAIWSTerminalTransientFailure(ctx, account, canonicalModel, lease.HandshakeHeaders(), upstreamMessage)
|
||||
// 客户端已断连时,上游连接的 session 状态不可信,标记 broken 避免回池复用。
|
||||
if clientDisconnected {
|
||||
lease.MarkBroken()
|
||||
@@ -977,17 +979,18 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
}
|
||||
imageCount := imageCounter.Count()
|
||||
result := &OpenAIForwardResult{
|
||||
RequestID: responseID,
|
||||
Usage: usage,
|
||||
Model: originalModel,
|
||||
UpstreamModel: mappedModel,
|
||||
ServiceTier: extractOpenAIServiceTierFromBody(payload),
|
||||
ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(payload, mappedModel, originalModel), payload, mappedModel),
|
||||
Stream: reqStream,
|
||||
OpenAIWSMode: true,
|
||||
ResponseHeaders: lease.HandshakeHeaders(),
|
||||
Duration: time.Since(turnStart),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
RequestID: responseID,
|
||||
Usage: usage,
|
||||
Model: originalModel,
|
||||
UpstreamModel: mappedModel,
|
||||
ServiceTier: extractOpenAIServiceTierFromBody(payload),
|
||||
ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(payload, mappedModel, originalModel), payload, mappedModel),
|
||||
Stream: reqStream,
|
||||
OpenAIWSMode: true,
|
||||
UpstreamTerminalEvent: terminalEvent,
|
||||
ResponseHeaders: lease.HandshakeHeaders(),
|
||||
Duration: time.Since(turnStart),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
}
|
||||
if replayInput := replayCollector.Items(); len(replayInput) > 0 {
|
||||
result.wsReplayInput = replayInput
|
||||
|
||||
@@ -72,11 +72,11 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_KeepLeaseAcrossT
|
||||
}
|
||||
|
||||
serverErrCh := make(chan error, 1)
|
||||
turnWSModeCh := make(chan bool, 2)
|
||||
turnTerminalCh := make(chan string, 2)
|
||||
hooks := &OpenAIWSIngressHooks{
|
||||
AfterTurn: func(_ int, result *OpenAIForwardResult, turnErr error) {
|
||||
if turnErr == nil && result != nil {
|
||||
turnWSModeCh <- result.OpenAIWSMode
|
||||
turnTerminalCh <- result.UpstreamTerminalEvent
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -146,8 +146,8 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_KeepLeaseAcrossT
|
||||
secondTurnEvent := readMessage()
|
||||
require.Equal(t, "response.completed", gjson.GetBytes(secondTurnEvent, "type").String())
|
||||
require.Equal(t, "resp_ingress_turn_2", gjson.GetBytes(secondTurnEvent, "response.id").String())
|
||||
require.True(t, <-turnWSModeCh, "首轮 turn 应标记为 WS 模式")
|
||||
require.True(t, <-turnWSModeCh, "第二轮 turn 应标记为 WS 模式")
|
||||
require.Equal(t, "response.completed", <-turnTerminalCh, "首轮 turn 应保留成功终态")
|
||||
require.Equal(t, "response.completed", <-turnTerminalCh, "第二轮 turn 应保留成功终态")
|
||||
|
||||
_ = clientConn.Close(coderws.StatusNormalClosure, "done")
|
||||
|
||||
@@ -249,6 +249,14 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_IdleTimeoutRelea
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String())
|
||||
|
||||
closeReadCtx, cancelCloseRead := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
_, _, err = clientConn.Read(closeReadCtx)
|
||||
cancelCloseRead()
|
||||
var clientClose coderws.CloseError
|
||||
require.ErrorAs(t, err, &clientClose)
|
||||
require.Equal(t, coderws.StatusNormalClosure, clientClose.Code)
|
||||
require.Equal(t, "websocket idle timeout", clientClose.Reason)
|
||||
|
||||
select {
|
||||
case proxyErr := <-serverErrCh:
|
||||
var closeErr *OpenAIWSClientCloseError
|
||||
@@ -531,6 +539,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge
|
||||
"model":"gpt-5.5",
|
||||
"stream":false,
|
||||
"previous_response_id":"resp_codex_image_bridge",
|
||||
"reasoning":{"effort":"high"},
|
||||
"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},
|
||||
"tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent"}]}],
|
||||
"input":[
|
||||
@@ -583,6 +592,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge
|
||||
require.Equal(t, "png", gjson.Get(nonLitePayload, `tools.#(type=="image_generation").output_format`).String())
|
||||
require.Equal(t, "auto", gjson.Get(nonLitePayload, "tool_choice").String())
|
||||
require.Contains(t, gjson.Get(nonLitePayload, "instructions").String(), "image_generation")
|
||||
require.False(t, gjson.Get(nonLitePayload, "reasoning.context").Exists())
|
||||
|
||||
litePayload := requestToJSONString(captureConn.writes[1])
|
||||
require.False(t, gjson.Get(litePayload, `tools.#(type=="image_generation")`).Exists())
|
||||
@@ -593,6 +603,8 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge
|
||||
require.Equal(t, "collaboration", gjson.Get(litePayload, `input.#(type=="additional_tools").tools.1.name`).String())
|
||||
require.Equal(t, "namespace", gjson.Get(litePayload, "tool_choice.type").String())
|
||||
require.Equal(t, "collaboration", gjson.Get(litePayload, "tool_choice.name").String())
|
||||
require.Equal(t, "high", gjson.Get(litePayload, "reasoning.effort").String())
|
||||
require.Equal(t, "all_turns", gjson.Get(litePayload, "reasoning.context").String())
|
||||
|
||||
functionPayload := requestToJSONString(captureConn.writes[2])
|
||||
require.True(t, gjson.Get(functionPayload, `tools.#(name=="image_gen.imagegen")`).Exists())
|
||||
@@ -976,6 +988,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade
|
||||
"model":"gpt-5.1",
|
||||
"stream":false,
|
||||
"prompt_cache_key":"pcache_passthrough",
|
||||
"reasoning":{"effort":"medium","context":"current_turn"},
|
||||
"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},
|
||||
"tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent"}]}],
|
||||
"input":[{"type":"message","role":"user","content":"hello"}],
|
||||
@@ -1009,6 +1022,8 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade
|
||||
require.Equal(t, "collaboration", gjson.Get(forwarded, `input.#(type=="additional_tools").tools.0.name`).String())
|
||||
require.Equal(t, "namespace", gjson.Get(forwarded, "tool_choice.type").String())
|
||||
require.Equal(t, "collaboration", gjson.Get(forwarded, "tool_choice.name").String())
|
||||
require.Equal(t, "medium", gjson.Get(forwarded, "reasoning.effort").String())
|
||||
require.Equal(t, "all_turns", gjson.Get(forwarded, "reasoning.context").String())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRelaysHTTPStream(t *testing.T) {
|
||||
|
||||
@@ -464,10 +464,61 @@ func TestOpenAIGatewayService_Forward_WSv2_RewriteModelAndToolCallsOnCompletedEv
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, "resp_model_tool_1", result.RequestID)
|
||||
require.Equal(t, "response.completed", result.UpstreamTerminalEvent)
|
||||
require.True(t, result.SucceededForScheduling())
|
||||
require.Equal(t, "custom-original-model", gjson.GetBytes(rec.Body.Bytes(), "model").String(), "响应模型应回写为原始请求模型")
|
||||
require.Equal(t, "edit", gjson.GetBytes(rec.Body.Bytes(), "tool_calls.0.function.name").String(), "工具名称应被修正为 OpenCode 规范")
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_Forward_WSv2_ResponseFailedIsNotSchedulingSuccess(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
|
||||
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.98.0")
|
||||
|
||||
cfg := newOpenAIWSV2TestConfig()
|
||||
cfg.Security.URLAllowlist.Enabled = false
|
||||
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
|
||||
cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0
|
||||
|
||||
captureConn := &openAIWSCaptureConn{events: [][]byte{
|
||||
[]byte(`{"type":"response.failed","response":{"id":"resp_failed_1","model":"gpt-5.5","error":{"code":"server_error","message":"Internal error"}}}`),
|
||||
}}
|
||||
pool := newOpenAIWSConnPool(cfg)
|
||||
pool.setClientDialerForTest(&openAIWSCaptureDialer{conn: captureConn})
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: cfg,
|
||||
rateLimitService: NewRateLimitService(transientCooldownAccountRepo{}, nil, cfg, nil, nil),
|
||||
httpUpstream: &httpUpstreamRecorder{},
|
||||
cache: &stubGatewayCache{},
|
||||
openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
|
||||
toolCorrector: NewCodexToolCorrector(),
|
||||
openaiWSPool: pool,
|
||||
}
|
||||
account := &Account{
|
||||
ID: 1302,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{"api_key": "sk-test"},
|
||||
Extra: map[string]any{"responses_websockets_v2_enabled": true},
|
||||
}
|
||||
svc.recordOpenAIAccountModelTransientFailure(account, "gpt-5.5", time.Now())
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.5","stream":false,"input":"hello"}`))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, "response.failed", result.UpstreamTerminalEvent)
|
||||
require.False(t, result.SucceededForScheduling())
|
||||
require.True(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "gpt-5.5"))
|
||||
}
|
||||
|
||||
func TestOpenAIWSPayloadString_OnlyAcceptsStringValues(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"type": nil,
|
||||
|
||||
@@ -204,6 +204,98 @@ func isOpenAIWSTerminalEvent(eventType string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeOpenAIWSTerminalEvent(eventType string) string {
|
||||
switch strings.TrimSpace(eventType) {
|
||||
case "response.completed":
|
||||
return "response.completed"
|
||||
case "response.done":
|
||||
return "response.done"
|
||||
case "response.failed":
|
||||
return "response.failed"
|
||||
case "response.incomplete":
|
||||
return "response.incomplete"
|
||||
case "response.cancelled", "response.canceled":
|
||||
return "response.cancelled"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func openAIWSPayloadTransientStatus(payload []byte) int {
|
||||
if len(payload) == 0 {
|
||||
return 0
|
||||
}
|
||||
status := int(gjson.GetBytes(payload, "response.error.status_code").Int())
|
||||
if status == 0 {
|
||||
status = int(gjson.GetBytes(payload, "response.error.status").Int())
|
||||
}
|
||||
if status == 0 {
|
||||
status = int(gjson.GetBytes(payload, "error.status_code").Int())
|
||||
}
|
||||
if status == 0 {
|
||||
status = int(gjson.GetBytes(payload, "error.status").Int())
|
||||
}
|
||||
if shouldCooldownOpenAITransientUpstreamError(status, payload) {
|
||||
return status
|
||||
}
|
||||
if status != 0 {
|
||||
return 0
|
||||
}
|
||||
code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String()))
|
||||
errType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.type").String()))
|
||||
if code == "" {
|
||||
code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String()))
|
||||
}
|
||||
if errType == "" {
|
||||
errType = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.type").String()))
|
||||
}
|
||||
switch {
|
||||
case code == "server_is_overloaded", code == "slow_down":
|
||||
return http.StatusServiceUnavailable
|
||||
case strings.Contains(code, "server_error"),
|
||||
strings.Contains(code, "internal_error"),
|
||||
strings.Contains(code, "upstream_error"),
|
||||
strings.Contains(errType, "server_error"),
|
||||
strings.Contains(errType, "internal_error"),
|
||||
strings.Contains(errType, "upstream_error"):
|
||||
return http.StatusInternalServerError
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) handleOpenAIWSTerminalTransientFailure(ctx context.Context, account *Account, canonicalModel string, headers http.Header, payload []byte) string {
|
||||
eventType, _, _ := parseOpenAIWSEventEnvelope(payload)
|
||||
terminalEvent := normalizeOpenAIWSTerminalEvent(eventType)
|
||||
if terminalEvent != "response.failed" {
|
||||
return terminalEvent
|
||||
}
|
||||
status := openAIWSPayloadTransientStatus(payload)
|
||||
if status != 0 {
|
||||
s.handleOpenAIAccountUpstreamError(ctx, account, status, headers, payload, canonicalModel)
|
||||
}
|
||||
return terminalEvent
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) handleOpenAIWSErrorEventTransientFailure(ctx context.Context, account *Account, canonicalModel string, headers http.Header, payload []byte) {
|
||||
eventType, _, _ := parseOpenAIWSEventEnvelope(payload)
|
||||
if eventType != "error" {
|
||||
return
|
||||
}
|
||||
status := openAIWSPayloadTransientStatus(payload)
|
||||
if status != 0 {
|
||||
s.handleOpenAIAccountUpstreamError(ctx, account, status, headers, payload, canonicalModel)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) handleOpenAIWSDialTransientFailure(ctx context.Context, account *Account, canonicalModel string, err error) {
|
||||
var dialErr *openAIWSDialError
|
||||
if !errors.As(err, &dialErr) || dialErr == nil || !shouldCooldownOpenAITransientUpstreamError(dialErr.StatusCode, dialErr.ResponseBody) {
|
||||
return
|
||||
}
|
||||
s.handleOpenAIAccountUpstreamError(ctx, account, dialErr.StatusCode, dialErr.ResponseHeaders, dialErr.ResponseBody, canonicalModel)
|
||||
}
|
||||
|
||||
func isOpenAIWSTokenEvent(eventType string) bool {
|
||||
eventType = strings.TrimSpace(eventType)
|
||||
if eventType == "" {
|
||||
@@ -440,7 +532,7 @@ func (s *OpenAIGatewayService) resolveAccountByPreviousResponseIDForCapability(
|
||||
if paused, _ := shouldAutoPauseOpenAIAccountByQuota(ctx, latest); paused {
|
||||
return 0, nil, "", nil
|
||||
}
|
||||
if s.isOpenAIAccountRuntimeBlocked(latest) {
|
||||
if s.isOpenAIAccountRequestRuntimeBlocked(latest, requestedModel) {
|
||||
_ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID)
|
||||
return 0, nil, "", nil
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -107,6 +111,81 @@ func TestOpenAIWSCyberPolicyMark_NonCyberPayload(t *testing.T) {
|
||||
require.False(t, hit, "detectOpenAICyberPolicy should return false for non-cyber_policy error code")
|
||||
}
|
||||
|
||||
func TestOpenAIForwardResultSucceededForScheduling_TerminalEvents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
result *OpenAIForwardResult
|
||||
expected bool
|
||||
}{
|
||||
{name: "nil legacy result", result: nil, expected: true},
|
||||
{name: "non websocket zero value", result: &OpenAIForwardResult{}, expected: true},
|
||||
{name: "websocket legacy empty terminal", result: &OpenAIForwardResult{OpenAIWSMode: true}, expected: true},
|
||||
{name: "completed", result: &OpenAIForwardResult{OpenAIWSMode: true, UpstreamTerminalEvent: "response.completed"}, expected: true},
|
||||
{name: "done", result: &OpenAIForwardResult{OpenAIWSMode: true, UpstreamTerminalEvent: "response.done"}, expected: true},
|
||||
{name: "failed", result: &OpenAIForwardResult{OpenAIWSMode: true, UpstreamTerminalEvent: "response.failed"}, expected: false},
|
||||
{name: "incomplete", result: &OpenAIForwardResult{OpenAIWSMode: true, UpstreamTerminalEvent: "response.incomplete"}, expected: false},
|
||||
{name: "cancelled", result: &OpenAIForwardResult{OpenAIWSMode: true, UpstreamTerminalEvent: "response.cancelled"}, expected: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.expected, tt.result.SucceededForScheduling())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIWSTerminalEvent_ResponseFailedRecordsModelTransient(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{}
|
||||
svc.rateLimitService = NewRateLimitService(transientCooldownAccountRepo{}, nil, &config.Config{}, nil, nil)
|
||||
account := &Account{ID: 5201, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
payload := []byte(`{"type":"response.failed","response":{"error":{"code":"server_error","message":"Internal error"}}}`)
|
||||
|
||||
for range 2 {
|
||||
terminalEvent := svc.handleOpenAIWSTerminalTransientFailure(context.Background(), account, "gpt-5.5", http.Header{}, payload)
|
||||
require.Equal(t, "response.failed", terminalEvent)
|
||||
}
|
||||
|
||||
require.True(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "gpt-5.5"))
|
||||
}
|
||||
|
||||
func TestOpenAIWSErrorEvent_ServerErrorRecordsModelTransient(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{}
|
||||
svc.rateLimitService = NewRateLimitService(transientCooldownAccountRepo{}, nil, &config.Config{}, nil, nil)
|
||||
account := &Account{ID: 5203, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
payload := []byte(`{"type":"error","error":{"code":"server_error","type":"server_error","message":"Internal error"}}`)
|
||||
|
||||
for range 2 {
|
||||
svc.handleOpenAIWSErrorEventTransientFailure(context.Background(), account, "gpt-5.5", http.Header{}, payload)
|
||||
}
|
||||
|
||||
require.True(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "gpt-5.5"))
|
||||
}
|
||||
|
||||
func TestOpenAIWSPayloadTransientStatus_Explicit529IsNotModelTransient(t *testing.T) {
|
||||
payload := []byte(`{"type":"response.failed","response":{"error":{"status_code":529,"code":"server_error","message":"overloaded"}}}`)
|
||||
|
||||
require.Zero(t, openAIWSPayloadTransientStatus(payload))
|
||||
}
|
||||
|
||||
func TestOpenAIWSDial5xxRecordsModelTransient(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{}
|
||||
svc.rateLimitService = NewRateLimitService(transientCooldownAccountRepo{}, nil, &config.Config{}, nil, nil)
|
||||
account := &Account{ID: 5202, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
dialErr := &openAIWSDialError{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
ResponseHeaders: http.Header{"X-Request-Id": []string{"req-ws-502"}},
|
||||
ResponseBody: []byte(`{"error":{"message":"bad gateway"}}`),
|
||||
}
|
||||
|
||||
for range 2 {
|
||||
svc.handleOpenAIWSDialTransientFailure(context.Background(), account, "gpt-5.5", dialErr)
|
||||
}
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return svc.isOpenAIAccountModelRuntimeBlocked(account, "gpt-5.5")
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
}
|
||||
|
||||
// TestIsOpenAIWSTokenEvent_DisjointWithTerminal 守护「token 事件集合与终止事件集合互斥」的不变量。
|
||||
// firstTokenMs 的计算依赖于 isTokenEvent && !isTerminalEvent;
|
||||
// 若两者再次出现交集,则 issue #2651 描述的 latency 误报会重现。
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -199,6 +200,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
|
||||
}
|
||||
return nil, &agentIdentityTaskRecoveredError{}
|
||||
}
|
||||
s.handleOpenAIWSDialTransientFailure(ctx, account, mappedModel, err)
|
||||
dialStatus, dialClass, dialCloseStatus, dialCloseReason, dialRespServer, dialRespVia, dialRespCFRay, dialRespReqID := summarizeOpenAIWSDialError(err)
|
||||
logOpenAIWSModeInfo(
|
||||
"acquire_fail account_id=%d account_type=%s transport=%s reason=%s dial_status=%d dial_class=%s dial_close_status=%s dial_close_reason=%s dial_resp_server=%s dial_resp_via=%s dial_resp_cf_ray=%s dial_resp_x_request_id=%s cause=%s preferred_conn_id=%s force_new_conn=%v ws_host=%s ws_path=%s proxy_enabled=%v",
|
||||
@@ -351,6 +353,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
|
||||
flushedBufferedEventCount := 0
|
||||
firstEventType := ""
|
||||
lastEventType := ""
|
||||
upstreamTerminalEvent := ""
|
||||
|
||||
var flusher http.Flusher
|
||||
if reqStream {
|
||||
@@ -454,6 +457,25 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
|
||||
}
|
||||
}
|
||||
}
|
||||
if readErr == nil && !json.Valid(message) {
|
||||
eventType, _, _ := parseOpenAIWSEventEnvelope(message)
|
||||
if eventType == "" {
|
||||
eventType = "unknown"
|
||||
}
|
||||
lease.MarkBroken()
|
||||
logOpenAIWSModeInfo(
|
||||
"invalid_event_json account_id=%d conn_id=%s event_type=%s bytes=%d wrote_downstream=%v",
|
||||
account.ID,
|
||||
truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen),
|
||||
truncateOpenAIWSLogValue(eventType, openAIWSLogValueMaxLen),
|
||||
len(message),
|
||||
wroteDownstream,
|
||||
)
|
||||
if !wroteDownstream {
|
||||
return nil, wrapOpenAIWSFallback("invalid_event_json", errors.New("upstream websocket returned malformed Responses event JSON"))
|
||||
}
|
||||
return nil, errors.New("upstream websocket returned malformed Responses event JSON after downstream output")
|
||||
}
|
||||
if readErr != nil {
|
||||
lease.MarkBroken()
|
||||
closeStatus, closeReason := summarizeOpenAIWSReadCloseError(readErr)
|
||||
@@ -552,6 +574,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
|
||||
}
|
||||
|
||||
if eventType == "error" {
|
||||
s.handleOpenAIWSErrorEventTransientFailure(ctx, account, mappedModel, lease.HandshakeHeaders(), message)
|
||||
errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(message)
|
||||
s.persistOpenAIWSRateLimitSignal(ctx, account, lease.HandshakeHeaders(), message, errCodeRaw, errTypeRaw, errMsgRaw)
|
||||
errMsg := strings.TrimSpace(errMsgRaw)
|
||||
@@ -650,6 +673,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
|
||||
}
|
||||
|
||||
if isTerminalEvent {
|
||||
upstreamTerminalEvent = s.handleOpenAIWSTerminalTransientFailure(ctx, account, mappedModel, lease.HandshakeHeaders(), message)
|
||||
// A terminal event must be the final JSON document in its WS message.
|
||||
// Ignore any tail for the completed client turn, but never reuse the
|
||||
// ambiguous upstream connection for another request.
|
||||
@@ -721,19 +745,20 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
|
||||
)
|
||||
|
||||
return &OpenAIForwardResult{
|
||||
RequestID: responseID,
|
||||
Usage: *usage,
|
||||
Model: originalModel,
|
||||
UpstreamModel: mappedModel,
|
||||
ImageCount: imageCounter.Count(),
|
||||
ImageOutputSizes: imageCounter.Sizes(),
|
||||
ServiceTier: extractOpenAIServiceTier(reqBody),
|
||||
ReasoningEffort: extractOpenAIReasoningEffort(reqBody, mappedModel, originalModel),
|
||||
Stream: reqStream,
|
||||
OpenAIWSMode: true,
|
||||
ResponseHeaders: lease.HandshakeHeaders(),
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
RequestID: responseID,
|
||||
Usage: *usage,
|
||||
Model: originalModel,
|
||||
UpstreamModel: mappedModel,
|
||||
ImageCount: imageCounter.Count(),
|
||||
ImageOutputSizes: imageCounter.Sizes(),
|
||||
ServiceTier: extractOpenAIServiceTier(reqBody),
|
||||
ReasoningEffort: extractOpenAIReasoningEffort(reqBody, mappedModel, originalModel),
|
||||
Stream: reqStream,
|
||||
OpenAIWSMode: true,
|
||||
UpstreamTerminalEvent: upstreamTerminalEvent,
|
||||
ResponseHeaders: lease.HandshakeHeaders(),
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -235,6 +235,9 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, openAIWSHTTPBridgeErrorBodyLimitBytes))
|
||||
if account.Platform == PlatformGrok {
|
||||
s.handleGrokAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
|
||||
} else if shouldCooldownOpenAITransientUpstreamError(resp.StatusCode, respBody) {
|
||||
canonicalModel := canonicalOpenAIAccountSchedulingModel(account, originalModel)
|
||||
s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, canonicalModel)
|
||||
}
|
||||
upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody)))
|
||||
if upstreamMsg == "" {
|
||||
@@ -258,6 +261,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
|
||||
replayCollector := &openAIWSToolCallReplayCollector{}
|
||||
firstEventType := ""
|
||||
lastEventType := ""
|
||||
upstreamTerminalEvent := ""
|
||||
sawDone := false
|
||||
wroteDownstream := false
|
||||
clientDisconnected := false
|
||||
@@ -275,17 +279,18 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
|
||||
resultWithUsage := func() *OpenAIForwardResult {
|
||||
imageCount := imageCounter.Count()
|
||||
result := &OpenAIForwardResult{
|
||||
RequestID: responseID,
|
||||
Usage: usage,
|
||||
Model: originalModel,
|
||||
UpstreamModel: mappedModel,
|
||||
ServiceTier: extractOpenAIServiceTierFromBody(body),
|
||||
ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(body, mappedModel, originalModel), body, mappedModel),
|
||||
Stream: reqStream,
|
||||
OpenAIWSMode: true,
|
||||
ResponseHeaders: cloneHeader(resp.Header),
|
||||
Duration: time.Since(turnStart),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
RequestID: responseID,
|
||||
Usage: usage,
|
||||
Model: originalModel,
|
||||
UpstreamModel: mappedModel,
|
||||
ServiceTier: extractOpenAIServiceTierFromBody(body),
|
||||
ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(body, mappedModel, originalModel), body, mappedModel),
|
||||
Stream: reqStream,
|
||||
OpenAIWSMode: true,
|
||||
UpstreamTerminalEvent: upstreamTerminalEvent,
|
||||
ResponseHeaders: cloneHeader(resp.Header),
|
||||
Duration: time.Since(turnStart),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
}
|
||||
if replayInput := replayCollector.Items(); len(replayInput) > 0 {
|
||||
result.wsReplayInput = replayInput
|
||||
@@ -384,6 +389,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
|
||||
}
|
||||
|
||||
if eventType == "error" {
|
||||
s.handleOpenAIWSErrorEventTransientFailure(ctx, account, canonicalOpenAIAccountSchedulingModel(account, originalModel), resp.Header, upstreamMessage)
|
||||
errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(upstreamMessage)
|
||||
s.persistOpenAIWSRateLimitSignal(ctx, account, resp.Header, upstreamMessage, errCodeRaw, errTypeRaw, errMsgRaw)
|
||||
errMessage := strings.TrimSpace(errMsgRaw)
|
||||
@@ -393,6 +399,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
|
||||
return resultWithUsage(), errors.New(errMessage)
|
||||
}
|
||||
if isOpenAIWSTerminalEvent(eventType) {
|
||||
upstreamTerminalEvent = s.handleOpenAIWSTerminalTransientFailure(ctx, account, canonicalOpenAIAccountSchedulingModel(account, originalModel), resp.Header, upstreamMessage)
|
||||
terminalEventCount++
|
||||
firstTokenMsValue := -1
|
||||
if firstTokenMs != nil {
|
||||
|
||||
@@ -775,6 +775,14 @@ func TestOpenAIWSHTTPBridge_IdleTimeoutClosesClientSession(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String())
|
||||
|
||||
closeReadCtx, cancelCloseRead := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
_, _, err = clientConn.Read(closeReadCtx)
|
||||
cancelCloseRead()
|
||||
var clientClose coderws.CloseError
|
||||
require.ErrorAs(t, err, &clientClose)
|
||||
require.Equal(t, coderws.StatusNormalClosure, clientClose.Code)
|
||||
require.Equal(t, "websocket idle timeout", clientClose.Reason)
|
||||
|
||||
select {
|
||||
case proxyErr := <-errCh:
|
||||
var closeErr *OpenAIWSClientCloseError
|
||||
|
||||
@@ -239,6 +239,52 @@ func TestOpenAIGatewayService_Forward_WSv2Handshake429PersistsRateLimit(t *testi
|
||||
require.Contains(t, repo.updateExtra[0], "codex_usage_updated_at")
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_Forward_WSv2Handshake502RecordsModelTransient(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("x-request-id", "req-ws-502")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte(`{"error":{"type":"server_error","message":"bad gateway"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := newOpenAIWSV2TestConfig()
|
||||
cfg.Security.URLAllowlist.Enabled = false
|
||||
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
|
||||
account := Account{
|
||||
ID: 504,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{"api_key": "sk-test", "base_url": server.URL},
|
||||
Extra: map[string]any{"responses_websockets_v2_enabled": true},
|
||||
}
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: cfg,
|
||||
rateLimitService: NewRateLimitService(transientCooldownAccountRepo{}, nil, cfg, nil, nil),
|
||||
httpUpstream: &httpUpstreamRecorder{},
|
||||
cache: &stubGatewayCache{},
|
||||
openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
|
||||
toolCorrector: NewCodexToolCorrector(),
|
||||
}
|
||||
body := []byte(`{"model":"gpt-5.5","stream":false,"input":"hello"}`)
|
||||
|
||||
for range 2 {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
|
||||
c.Request.Header.Set("User-Agent", "unit-test-agent/1.0")
|
||||
result, err := svc.Forward(context.Background(), c, &account, body)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
}
|
||||
|
||||
require.True(t, svc.isOpenAIAccountModelRuntimeBlocked(&account, "gpt-5.5"))
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_ErrorEventUsageLimitPersistsRateLimit(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -386,7 +386,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
|
||||
if errors.As(err, &handshakeErr) && handshakeErr != nil {
|
||||
responseBody = handshakeErr.Body
|
||||
}
|
||||
dialErr := &openAIWSDialError{StatusCode: statusCode, ResponseBody: responseBody, Err: err}
|
||||
dialErr := &openAIWSDialError{StatusCode: statusCode, ResponseHeaders: cloneHeader(handshakeHeaders), ResponseBody: responseBody, Err: err}
|
||||
if s.isAgentIdentityAccount(ctx, account) && isAgentIdentityTaskInvalidWSDialError(dialErr) && !agentTaskRecoveryTried {
|
||||
agentTaskRecoveryTried = true
|
||||
if recoveryErr := s.recoverAgentIdentityTask(ctx, account, account.GetCredential("task_id")); recoveryErr != nil {
|
||||
@@ -400,6 +400,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
|
||||
statusCode,
|
||||
truncateOpenAIWSLogValue(err.Error(), openAIWSLogValueMaxLen),
|
||||
)
|
||||
s.handleOpenAIWSDialTransientFailure(ctx, account, capturedSessionModel, dialErr)
|
||||
if statusCode == http.StatusTooManyRequests {
|
||||
s.persistOpenAIWSRateLimitSignal(ctx, account, handshakeHeaders, nil, "rate_limit_exceeded", "rate_limit_error", strings.TrimSpace(err.Error()))
|
||||
return &UpstreamFailoverError{
|
||||
@@ -571,14 +572,15 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
|
||||
CacheReadInputTokens: turn.Usage.CacheReadInputTokens,
|
||||
ImageOutputTokens: turn.Usage.ImageOutputTokens,
|
||||
},
|
||||
Model: turn.RequestModel,
|
||||
ServiceTier: usageMeta.serviceTier.Load(),
|
||||
ReasoningEffort: usageMeta.reasoningEffort.Load(),
|
||||
Stream: true,
|
||||
OpenAIWSMode: true,
|
||||
ResponseHeaders: cloneHeader(handshakeHeaders),
|
||||
Duration: turn.Duration,
|
||||
FirstTokenMs: turn.FirstTokenMs,
|
||||
Model: turn.RequestModel,
|
||||
ServiceTier: usageMeta.serviceTier.Load(),
|
||||
ReasoningEffort: usageMeta.reasoningEffort.Load(),
|
||||
Stream: true,
|
||||
OpenAIWSMode: true,
|
||||
UpstreamTerminalEvent: normalizeOpenAIWSTerminalEvent(turn.TerminalEventType),
|
||||
ResponseHeaders: cloneHeader(handshakeHeaders),
|
||||
Duration: turn.Duration,
|
||||
FirstTokenMs: turn.FirstTokenMs,
|
||||
}
|
||||
logOpenAIWSV2Passthrough(
|
||||
"relay_turn_completed account_id=%d turn=%d request_id=%s terminal_event=%s duration_ms=%d first_token_ms=%d input_tokens=%d output_tokens=%d cache_read_tokens=%d",
|
||||
@@ -597,10 +599,17 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
|
||||
}
|
||||
},
|
||||
BeforeWriteClient: func(msgType coderws.MessageType, payload []byte, wroteDownstream bool) error {
|
||||
if msgType != coderws.MessageText || wroteDownstream {
|
||||
if msgType != coderws.MessageText {
|
||||
return nil
|
||||
}
|
||||
if eventType, _, _ := parseOpenAIWSEventEnvelope(payload); eventType != "error" {
|
||||
eventType, _, _ := parseOpenAIWSEventEnvelope(payload)
|
||||
if isOpenAIWSTerminalEvent(eventType) {
|
||||
s.handleOpenAIWSTerminalTransientFailure(ctx, account, capturedSessionModel, handshakeHeaders, payload)
|
||||
}
|
||||
if eventType == "error" {
|
||||
s.handleOpenAIWSErrorEventTransientFailure(ctx, account, capturedSessionModel, handshakeHeaders, payload)
|
||||
}
|
||||
if wroteDownstream || eventType != "error" {
|
||||
return nil
|
||||
}
|
||||
errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(payload)
|
||||
@@ -646,14 +655,15 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
|
||||
CacheReadInputTokens: relayResult.Usage.CacheReadInputTokens,
|
||||
ImageOutputTokens: relayResult.Usage.ImageOutputTokens,
|
||||
},
|
||||
Model: relayResult.RequestModel,
|
||||
ServiceTier: usageMeta.serviceTier.Load(),
|
||||
ReasoningEffort: usageMeta.reasoningEffort.Load(),
|
||||
Stream: true,
|
||||
OpenAIWSMode: true,
|
||||
ResponseHeaders: cloneHeader(handshakeHeaders),
|
||||
Duration: relayResult.Duration,
|
||||
FirstTokenMs: relayResult.FirstTokenMs,
|
||||
Model: relayResult.RequestModel,
|
||||
ServiceTier: usageMeta.serviceTier.Load(),
|
||||
ReasoningEffort: usageMeta.reasoningEffort.Load(),
|
||||
Stream: true,
|
||||
OpenAIWSMode: true,
|
||||
UpstreamTerminalEvent: normalizeOpenAIWSTerminalEvent(relayResult.TerminalEventType),
|
||||
ResponseHeaders: cloneHeader(handshakeHeaders),
|
||||
Duration: relayResult.Duration,
|
||||
FirstTokenMs: relayResult.FirstTokenMs,
|
||||
}
|
||||
|
||||
turnCount := int(completedTurns.Load())
|
||||
|
||||
@@ -125,6 +125,7 @@ type LiteLLMModelPricing struct {
|
||||
SupportsPromptCaching bool `json:"supports_prompt_caching"`
|
||||
OutputCostPerImage float64 `json:"output_cost_per_image"` // 图片生成模型每张图片价格
|
||||
OutputCostPerImageToken float64 `json:"output_cost_per_image_token"` // 图片输出 token 价格
|
||||
InputCostPerImageToken float64 `json:"input_cost_per_image_token"` // 图片输入 token 价格(如 gpt-image-2 图片编辑)
|
||||
|
||||
// TokenPricingAbsent 表示源数据中 input/output token 价格均缺失(仅有图片价)。
|
||||
// 此类条目只可用于图片计费,token 计费必须回退到 fallback 或 fail-closed,
|
||||
@@ -158,6 +159,7 @@ type LiteLLMRawEntry struct {
|
||||
SupportsPromptCaching bool `json:"supports_prompt_caching"`
|
||||
OutputCostPerImage *float64 `json:"output_cost_per_image"`
|
||||
OutputCostPerImageToken *float64 `json:"output_cost_per_image_token"`
|
||||
InputCostPerImageToken *float64 `json:"input_cost_per_image_token"`
|
||||
}
|
||||
|
||||
// PricingService 动态价格服务
|
||||
@@ -435,7 +437,7 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel
|
||||
}
|
||||
|
||||
// 只保留有有效价格的条目
|
||||
if entry.InputCostPerToken == nil && entry.OutputCostPerToken == nil && entry.OutputCostPerImage == nil && entry.OutputCostPerImageToken == nil {
|
||||
if entry.InputCostPerToken == nil && entry.OutputCostPerToken == nil && entry.OutputCostPerImage == nil && entry.OutputCostPerImageToken == nil && entry.InputCostPerImageToken == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -489,6 +491,9 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel
|
||||
if entry.OutputCostPerImageToken != nil {
|
||||
pricing.OutputCostPerImageToken = *entry.OutputCostPerImageToken
|
||||
}
|
||||
if entry.InputCostPerImageToken != nil {
|
||||
pricing.InputCostPerImageToken = *entry.InputCostPerImageToken
|
||||
}
|
||||
|
||||
result[modelName] = pricing
|
||||
}
|
||||
|
||||
@@ -116,6 +116,72 @@ type batchSnapshotCache struct {
|
||||
beforeSet func()
|
||||
}
|
||||
|
||||
type batchSnapshotAccountIDCache struct {
|
||||
*batchSnapshotCache
|
||||
|
||||
reuseMu sync.Mutex
|
||||
fullCalls map[SchedulerBucket]int
|
||||
idOnlyCalls map[SchedulerBucket]int
|
||||
idOnlyError map[SchedulerBucket]error
|
||||
fullLateErr map[SchedulerBucket]error
|
||||
returnEmpty bool
|
||||
}
|
||||
|
||||
func newBatchSnapshotAccountIDCache() *batchSnapshotAccountIDCache {
|
||||
return &batchSnapshotAccountIDCache{
|
||||
batchSnapshotCache: newBatchSnapshotCache(),
|
||||
fullCalls: make(map[SchedulerBucket]int),
|
||||
idOnlyCalls: make(map[SchedulerBucket]int),
|
||||
idOnlyError: make(map[SchedulerBucket]error),
|
||||
fullLateErr: make(map[SchedulerBucket]error),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *batchSnapshotAccountIDCache) SetSnapshotAndReturnAccountIDs(ctx context.Context, bucket SchedulerBucket, token SchedulerBucketWriteToken, accounts []Account) ([]int64, error) {
|
||||
c.reuseMu.Lock()
|
||||
c.fullCalls[bucket]++
|
||||
c.reuseMu.Unlock()
|
||||
if err := c.batchSnapshotCache.SetSnapshot(ctx, bucket, token, accounts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.reuseMu.Lock()
|
||||
lateErr := c.fullLateErr[bucket]
|
||||
returnEmpty := c.returnEmpty
|
||||
c.reuseMu.Unlock()
|
||||
if lateErr != nil {
|
||||
return nil, lateErr
|
||||
}
|
||||
if returnEmpty {
|
||||
return []int64{}, nil
|
||||
}
|
||||
ids := make([]int64, 0, len(accounts))
|
||||
for _, account := range accounts {
|
||||
ids = append(ids, account.ID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (c *batchSnapshotAccountIDCache) SetSnapshotByAccountIDs(ctx context.Context, bucket SchedulerBucket, token SchedulerBucketWriteToken, accountIDs []int64) error {
|
||||
c.reuseMu.Lock()
|
||||
c.idOnlyCalls[bucket]++
|
||||
err := c.idOnlyError[bucket]
|
||||
c.reuseMu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
accounts := make([]Account, 0, len(accountIDs))
|
||||
for _, id := range accountIDs {
|
||||
accounts = append(accounts, Account{ID: id})
|
||||
}
|
||||
return c.batchSnapshotCache.SetSnapshot(ctx, bucket, token, accounts)
|
||||
}
|
||||
|
||||
func (c *batchSnapshotAccountIDCache) reuseCounts(bucket SchedulerBucket) (full, idOnly int) {
|
||||
c.reuseMu.Lock()
|
||||
defer c.reuseMu.Unlock()
|
||||
return c.fullCalls[bucket], c.idOnlyCalls[bucket]
|
||||
}
|
||||
|
||||
func newBatchSnapshotCache() *batchSnapshotCache {
|
||||
return &batchSnapshotCache{
|
||||
captured: make(map[SchedulerBucket]SchedulerBucketWriteToken),
|
||||
@@ -231,6 +297,186 @@ func TestSchedulerRebuildBatchReusesSingleForcedQueryAndKeepsSnapshotsIndependen
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedulerRebuildBatchReusesAccountPayloadForSingleForced(t *testing.T) {
|
||||
const groupID int64 = 211
|
||||
single := SchedulerBucket{GroupID: groupID, Platform: PlatformOpenAI, Mode: SchedulerModeSingle}
|
||||
forced := SchedulerBucket{GroupID: groupID, Platform: PlatformOpenAI, Mode: SchedulerModeForced}
|
||||
cache := newBatchSnapshotAccountIDCache()
|
||||
repo := newBatchAccountQueryRepo()
|
||||
svc := newBatchQueryTestService(cache, repo, config.RunModeStandard)
|
||||
|
||||
for run := 1; run <= 2; run++ {
|
||||
require.NoError(t, svc.rebuildBuckets(context.Background(), []SchedulerBucket{single, forced}, "reuse"))
|
||||
full, idOnly := cache.reuseCounts(single)
|
||||
require.Equal(t, run, full)
|
||||
require.Zero(t, idOnly)
|
||||
full, idOnly = cache.reuseCounts(forced)
|
||||
require.Zero(t, full)
|
||||
require.Equal(t, run, idOnly)
|
||||
}
|
||||
require.Equal(t, 2, repo.callCount(batchAccountQueryKey{groupID: groupID, platform: PlatformOpenAI}), "账号载荷不得跨重建批次复用")
|
||||
}
|
||||
|
||||
func TestSchedulerRebuildBatchDoesNotReuseAccountPayloadAfterFirstWriterFailure(t *testing.T) {
|
||||
const groupID int64 = 212
|
||||
single := SchedulerBucket{GroupID: groupID, Platform: PlatformOpenAI, Mode: SchedulerModeSingle}
|
||||
forced := SchedulerBucket{GroupID: groupID, Platform: PlatformOpenAI, Mode: SchedulerModeForced}
|
||||
wantErr := errors.New("snapshot write failed")
|
||||
cache := newBatchSnapshotAccountIDCache()
|
||||
cache.setErrors[single] = wantErr
|
||||
svc := newBatchQueryTestService(cache, newBatchAccountQueryRepo(), config.RunModeStandard)
|
||||
|
||||
err := svc.rebuildBuckets(context.Background(), []SchedulerBucket{single, forced}, "failure")
|
||||
require.ErrorIs(t, err, wantErr)
|
||||
full, idOnly := cache.reuseCounts(single)
|
||||
require.Equal(t, 1, full)
|
||||
require.Zero(t, idOnly)
|
||||
full, idOnly = cache.reuseCounts(forced)
|
||||
require.Zero(t, full)
|
||||
require.Zero(t, idOnly)
|
||||
_, attempts, _, writes := cache.bucketState(forced)
|
||||
require.Equal(t, 1, attempts, "首次完整写失败后,后续桶必须走原 SetSnapshot")
|
||||
require.Len(t, writes, 1)
|
||||
}
|
||||
|
||||
func TestSchedulerRebuildBatchDoesNotReuseAccountPayloadAfterLateFirstWriterFailure(t *testing.T) {
|
||||
const groupID int64 = 216
|
||||
single := SchedulerBucket{GroupID: groupID, Platform: PlatformOpenAI, Mode: SchedulerModeSingle}
|
||||
forced := SchedulerBucket{GroupID: groupID, Platform: PlatformOpenAI, Mode: SchedulerModeForced}
|
||||
wantErr := errors.New("snapshot activation failed")
|
||||
cache := newBatchSnapshotAccountIDCache()
|
||||
cache.fullLateErr[single] = wantErr
|
||||
svc := newBatchQueryTestService(cache, newBatchAccountQueryRepo(), config.RunModeStandard)
|
||||
|
||||
err := svc.rebuildBuckets(context.Background(), []SchedulerBucket{single, forced}, "late-failure")
|
||||
require.ErrorIs(t, err, wantErr)
|
||||
full, idOnly := cache.reuseCounts(single)
|
||||
require.Equal(t, 1, full)
|
||||
require.Zero(t, idOnly)
|
||||
full, idOnly = cache.reuseCounts(forced)
|
||||
require.Zero(t, full)
|
||||
require.Zero(t, idOnly)
|
||||
_, attempts, _, writes := cache.bucketState(forced)
|
||||
require.Equal(t, 1, attempts, "首次激活失败后不得登记可复用 ID")
|
||||
require.Len(t, writes, 1)
|
||||
}
|
||||
|
||||
func TestSchedulerRebuildBatchDoesNotReuseAccountPayloadAfterLockBusy(t *testing.T) {
|
||||
const groupID int64 = 213
|
||||
single := SchedulerBucket{GroupID: groupID, Platform: PlatformOpenAI, Mode: SchedulerModeSingle}
|
||||
forced := SchedulerBucket{GroupID: groupID, Platform: PlatformOpenAI, Mode: SchedulerModeForced}
|
||||
cache := newBatchSnapshotAccountIDCache()
|
||||
cache.lockBusy[single] = true
|
||||
svc := newBatchQueryTestService(cache, newBatchAccountQueryRepo(), config.RunModeStandard)
|
||||
|
||||
require.NoError(t, svc.rebuildBuckets(context.Background(), []SchedulerBucket{single, forced}, "busy"))
|
||||
full, idOnly := cache.reuseCounts(single)
|
||||
require.Zero(t, full)
|
||||
require.Zero(t, idOnly)
|
||||
full, idOnly = cache.reuseCounts(forced)
|
||||
require.Zero(t, full)
|
||||
require.Zero(t, idOnly)
|
||||
_, attempts, _, writes := cache.bucketState(forced)
|
||||
require.Equal(t, 1, attempts)
|
||||
require.Len(t, writes, 1)
|
||||
}
|
||||
|
||||
func TestSchedulerRebuildBatchKeepsMixedAndDifferentQueriesOnFullWrites(t *testing.T) {
|
||||
const groupID int64 = 214
|
||||
openAISingle := SchedulerBucket{GroupID: groupID, Platform: PlatformOpenAI, Mode: SchedulerModeSingle}
|
||||
openAIForced := SchedulerBucket{GroupID: groupID, Platform: PlatformOpenAI, Mode: SchedulerModeForced}
|
||||
anthropicSingle := SchedulerBucket{GroupID: groupID, Platform: PlatformAnthropic, Mode: SchedulerModeSingle}
|
||||
anthropicMixed := SchedulerBucket{GroupID: groupID, Platform: PlatformAnthropic, Mode: SchedulerModeMixed}
|
||||
cache := newBatchSnapshotAccountIDCache()
|
||||
svc := newBatchQueryTestService(cache, newBatchAccountQueryRepo(), config.RunModeStandard)
|
||||
|
||||
require.NoError(t, svc.rebuildBuckets(context.Background(), []SchedulerBucket{openAISingle, openAIForced, anthropicSingle, anthropicMixed}, "scope"))
|
||||
full, idOnly := cache.reuseCounts(openAISingle)
|
||||
require.Equal(t, 1, full)
|
||||
require.Zero(t, idOnly)
|
||||
full, idOnly = cache.reuseCounts(openAIForced)
|
||||
require.Zero(t, full)
|
||||
require.Equal(t, 1, idOnly)
|
||||
full, idOnly = cache.reuseCounts(anthropicSingle)
|
||||
require.Zero(t, full)
|
||||
require.Zero(t, idOnly)
|
||||
_, attempts, _, writes := cache.bucketState(anthropicSingle)
|
||||
require.Equal(t, 1, attempts)
|
||||
require.Len(t, writes, 1)
|
||||
full, idOnly = cache.reuseCounts(anthropicMixed)
|
||||
require.Zero(t, full)
|
||||
require.Zero(t, idOnly)
|
||||
_, attempts, _, _ = cache.bucketState(anthropicMixed)
|
||||
require.Equal(t, 1, attempts, "mixed 桶必须继续走原 SetSnapshot")
|
||||
}
|
||||
|
||||
func TestSchedulerRebuildBatchPropagatesAccountIDOnlyWriteFailure(t *testing.T) {
|
||||
const groupID int64 = 215
|
||||
single := SchedulerBucket{GroupID: groupID, Platform: PlatformOpenAI, Mode: SchedulerModeSingle}
|
||||
forced := SchedulerBucket{GroupID: groupID, Platform: PlatformOpenAI, Mode: SchedulerModeForced}
|
||||
wantErr := errors.New("id-only write failed")
|
||||
cache := newBatchSnapshotAccountIDCache()
|
||||
cache.idOnlyError[forced] = wantErr
|
||||
svc := newBatchQueryTestService(cache, newBatchAccountQueryRepo(), config.RunModeStandard)
|
||||
|
||||
err := svc.rebuildBuckets(context.Background(), []SchedulerBucket{single, forced}, "id-error")
|
||||
require.ErrorIs(t, err, wantErr)
|
||||
full, idOnly := cache.reuseCounts(forced)
|
||||
require.Zero(t, full, "ID-only 失败不得静默回退为完整写")
|
||||
require.Equal(t, 1, idOnly)
|
||||
}
|
||||
|
||||
func TestSchedulerRebuildBatchReusesSuccessfulEmptyAccountIDs(t *testing.T) {
|
||||
const groupID int64 = 217
|
||||
single := SchedulerBucket{GroupID: groupID, Platform: PlatformOpenAI, Mode: SchedulerModeSingle}
|
||||
forced := SchedulerBucket{GroupID: groupID, Platform: PlatformOpenAI, Mode: SchedulerModeForced}
|
||||
cache := newBatchSnapshotAccountIDCache()
|
||||
cache.returnEmpty = true
|
||||
svc := newBatchQueryTestService(cache, newBatchAccountQueryRepo(), config.RunModeStandard)
|
||||
|
||||
require.NoError(t, svc.rebuildBuckets(context.Background(), []SchedulerBucket{single, forced}, "empty"))
|
||||
full, idOnly := cache.reuseCounts(single)
|
||||
require.Equal(t, 1, full)
|
||||
require.Zero(t, idOnly)
|
||||
full, idOnly = cache.reuseCounts(forced)
|
||||
require.Zero(t, full)
|
||||
require.Equal(t, 1, idOnly, "已成功缓存的空 ID 集也必须通过 map presence 复用")
|
||||
_, _, _, writes := cache.bucketState(forced)
|
||||
require.Len(t, writes, 1)
|
||||
require.Empty(t, writes[0].accounts)
|
||||
}
|
||||
|
||||
func TestSchedulerRebuildBatchReusesAccountPayloadForSimpleGroupZero(t *testing.T) {
|
||||
single := SchedulerBucket{GroupID: 0, Platform: PlatformOpenAI, Mode: SchedulerModeSingle}
|
||||
forced := SchedulerBucket{GroupID: 0, Platform: PlatformOpenAI, Mode: SchedulerModeForced}
|
||||
cache := newBatchSnapshotAccountIDCache()
|
||||
svc := newBatchQueryTestService(cache, newBatchAccountQueryRepo(), config.RunModeSimple)
|
||||
|
||||
require.NoError(t, svc.rebuildBuckets(context.Background(), []SchedulerBucket{single, forced}, "simple"))
|
||||
full, idOnly := cache.reuseCounts(single)
|
||||
require.Equal(t, 1, full)
|
||||
require.Zero(t, idOnly)
|
||||
full, idOnly = cache.reuseCounts(forced)
|
||||
require.Zero(t, full)
|
||||
require.Equal(t, 1, idOnly)
|
||||
}
|
||||
|
||||
func TestSchedulerAccountQueryCacheReleasesSnapshotAccountIDs(t *testing.T) {
|
||||
single := schedulerBucketWriteTask{bucket: SchedulerBucket{GroupID: 218, Platform: PlatformOpenAI, Mode: SchedulerModeSingle}}
|
||||
forced := schedulerBucketWriteTask{bucket: SchedulerBucket{GroupID: 218, Platform: PlatformOpenAI, Mode: SchedulerModeForced}}
|
||||
queries := newSchedulerAccountQueryCache([]schedulerBucketWriteTask{single, forced})
|
||||
key, ok := schedulerAccountQueryKeyForBucket(single.bucket)
|
||||
require.True(t, ok)
|
||||
queries.snapshotAccountIDs[key] = []int64{1, 2}
|
||||
|
||||
queries.release(single.bucket)
|
||||
require.Contains(t, queries.snapshotAccountIDs, key)
|
||||
queries.release(forced.bucket)
|
||||
require.NotContains(t, queries.snapshotAccountIDs, key)
|
||||
require.Empty(t, queries.remaining)
|
||||
require.Empty(t, queries.accounts)
|
||||
}
|
||||
|
||||
func TestSchedulerRebuildBatchKeepsMixedAndDifferentKeysIndependent(t *testing.T) {
|
||||
const groupID int64 = 202
|
||||
buckets := []SchedulerBucket{
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type bulkEventAccountRepo struct {
|
||||
*batchAccountQueryRepo
|
||||
accounts []*Account
|
||||
}
|
||||
|
||||
func newBulkEventAccountRepo(accounts ...*Account) *bulkEventAccountRepo {
|
||||
return &bulkEventAccountRepo{
|
||||
batchAccountQueryRepo: newBatchAccountQueryRepo(),
|
||||
accounts: accounts,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *bulkEventAccountRepo) GetByIDs(context.Context, []int64) ([]*Account, error) {
|
||||
return append([]*Account(nil), r.accounts...), nil
|
||||
}
|
||||
|
||||
type bulkEventSnapshotCache struct {
|
||||
*batchSnapshotCache
|
||||
|
||||
accountMu sync.Mutex
|
||||
setAccountIDs []int64
|
||||
deleteAccountIDs []int64
|
||||
}
|
||||
|
||||
func newBulkEventSnapshotCache() *bulkEventSnapshotCache {
|
||||
return &bulkEventSnapshotCache{batchSnapshotCache: newBatchSnapshotCache()}
|
||||
}
|
||||
|
||||
func (c *bulkEventSnapshotCache) SetAccount(_ context.Context, account *Account) error {
|
||||
c.accountMu.Lock()
|
||||
defer c.accountMu.Unlock()
|
||||
c.setAccountIDs = append(c.setAccountIDs, account.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *bulkEventSnapshotCache) DeleteAccount(_ context.Context, accountID int64) error {
|
||||
c.accountMu.Lock()
|
||||
defer c.accountMu.Unlock()
|
||||
c.deleteAccountIDs = append(c.deleteAccountIDs, accountID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *bulkEventSnapshotCache) accountWrites() (set []int64, deleted []int64) {
|
||||
c.accountMu.Lock()
|
||||
defer c.accountMu.Unlock()
|
||||
return append([]int64(nil), c.setAccountIDs...), append([]int64(nil), c.deleteAccountIDs...)
|
||||
}
|
||||
|
||||
func (c *bulkEventSnapshotCache) capturedBuckets() []SchedulerBucket {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]SchedulerBucket(nil), c.captures...)
|
||||
}
|
||||
|
||||
func newBulkEventTestService(cache SchedulerCache, accounts AccountRepository) *SchedulerSnapshotService {
|
||||
return NewSchedulerSnapshotService(cache, nil, accounts, nil, &config.Config{RunMode: config.RunModeStandard})
|
||||
}
|
||||
|
||||
func bulkEventPayload(accountIDs []int64, groupIDs []int64) map[string]any {
|
||||
accountValues := make([]any, 0, len(accountIDs))
|
||||
for _, id := range accountIDs {
|
||||
accountValues = append(accountValues, id)
|
||||
}
|
||||
groupValues := make([]any, 0, len(groupIDs))
|
||||
for _, id := range groupIDs {
|
||||
groupValues = append(groupValues, id)
|
||||
}
|
||||
return map[string]any{
|
||||
"account_ids": accountValues,
|
||||
"group_ids": groupValues,
|
||||
}
|
||||
}
|
||||
|
||||
func schedulerBucketsForTest(groupIDs []int64, platforms ...string) []SchedulerBucket {
|
||||
buckets := make([]SchedulerBucket, 0, len(groupIDs)*len(platforms)*3)
|
||||
for _, platform := range platforms {
|
||||
for _, groupID := range groupIDs {
|
||||
buckets = append(buckets,
|
||||
SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeSingle},
|
||||
SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeForced},
|
||||
)
|
||||
if platform == PlatformAnthropic || platform == PlatformGemini {
|
||||
buckets = append(buckets, SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeMixed})
|
||||
}
|
||||
}
|
||||
}
|
||||
return buckets
|
||||
}
|
||||
|
||||
func TestSchedulerBulkAccountEventScopesOpenAIRebuildToFreshPlatform(t *testing.T) {
|
||||
cache := newBulkEventSnapshotCache()
|
||||
repo := newBulkEventAccountRepo(&Account{ID: 1, Platform: PlatformOpenAI, GroupIDs: []int64{12}})
|
||||
svc := newBulkEventTestService(cache, repo)
|
||||
|
||||
err := svc.handleBulkAccountEvent(context.Background(), bulkEventPayload([]int64{1}, []int64{11}), make(map[batchSeenKey]struct{}))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, schedulerBucketsForTest([]int64{11, 12}, PlatformOpenAI), cache.capturedBuckets())
|
||||
set, deleted := cache.accountWrites()
|
||||
require.Equal(t, []int64{1}, set)
|
||||
require.Empty(t, deleted)
|
||||
}
|
||||
|
||||
func TestSchedulerBulkAccountEventRebuildsOpenAIUngroupedBucket(t *testing.T) {
|
||||
cache := newBulkEventSnapshotCache()
|
||||
repo := newBulkEventAccountRepo(&Account{ID: 6, Platform: PlatformOpenAI})
|
||||
svc := newBulkEventTestService(cache, repo)
|
||||
|
||||
err := svc.handleBulkAccountEvent(context.Background(), bulkEventPayload([]int64{6}, nil), make(map[batchSeenKey]struct{}))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, schedulerBucketsForTest([]int64{0}, PlatformOpenAI), cache.capturedBuckets())
|
||||
}
|
||||
|
||||
func TestSchedulerBulkAccountEventKeepsGroupedAndUngroupedBuckets(t *testing.T) {
|
||||
cache := newBulkEventSnapshotCache()
|
||||
repo := newBulkEventAccountRepo(
|
||||
&Account{ID: 7, Platform: PlatformOpenAI, GroupIDs: []int64{51}},
|
||||
&Account{ID: 8, Platform: PlatformOpenAI},
|
||||
)
|
||||
svc := newBulkEventTestService(cache, repo)
|
||||
|
||||
err := svc.handleBulkAccountEvent(context.Background(), bulkEventPayload([]int64{7, 8}, nil), make(map[batchSeenKey]struct{}))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, schedulerBucketsForTest([]int64{0, 51}, PlatformOpenAI), cache.capturedBuckets())
|
||||
}
|
||||
|
||||
func TestSchedulerBulkAccountEventDoesNotCrossCurrentGroupsBetweenPlatforms(t *testing.T) {
|
||||
cache := newBulkEventSnapshotCache()
|
||||
repo := newBulkEventAccountRepo(
|
||||
&Account{ID: 9, Platform: PlatformOpenAI, GroupIDs: []int64{61}},
|
||||
&Account{ID: 10, Platform: PlatformGrok, GroupIDs: []int64{62}},
|
||||
)
|
||||
svc := newBulkEventTestService(cache, repo)
|
||||
|
||||
err := svc.handleBulkAccountEvent(context.Background(), bulkEventPayload([]int64{9, 10}, []int64{63}), make(map[batchSeenKey]struct{}))
|
||||
|
||||
require.NoError(t, err)
|
||||
want := append(
|
||||
schedulerBucketsForTest([]int64{61, 63}, PlatformOpenAI),
|
||||
schedulerBucketsForTest([]int64{62, 63}, PlatformGrok)...,
|
||||
)
|
||||
require.ElementsMatch(t, want, cache.capturedBuckets())
|
||||
}
|
||||
|
||||
func TestSchedulerBulkAccountEventUsesGroupZeroInSimpleMode(t *testing.T) {
|
||||
cache := newBulkEventSnapshotCache()
|
||||
repo := newBulkEventAccountRepo(&Account{ID: 11, Platform: PlatformOpenAI, GroupIDs: []int64{71}})
|
||||
svc := NewSchedulerSnapshotService(cache, nil, repo, nil, &config.Config{RunMode: config.RunModeSimple})
|
||||
|
||||
err := svc.handleBulkAccountEvent(context.Background(), bulkEventPayload([]int64{11}, []int64{72}), make(map[batchSeenKey]struct{}))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, schedulerBucketsForTest([]int64{0}, PlatformOpenAI), cache.capturedBuckets())
|
||||
}
|
||||
|
||||
func TestSchedulerBulkAccountEventConservativelyExpandsAntigravityPlatforms(t *testing.T) {
|
||||
cache := newBulkEventSnapshotCache()
|
||||
// fresh 值可能已经关闭 mixed_scheduling,兼容平台仍要重建以清理旧快照。
|
||||
repo := newBulkEventAccountRepo(&Account{ID: 2, Platform: PlatformAntigravity, GroupIDs: []int64{22}})
|
||||
svc := newBulkEventTestService(cache, repo)
|
||||
|
||||
err := svc.handleBulkAccountEvent(context.Background(), bulkEventPayload([]int64{2}, []int64{21}), make(map[batchSeenKey]struct{}))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t,
|
||||
schedulerBucketsForTest([]int64{21, 22}, PlatformAnthropic, PlatformGemini, PlatformAntigravity),
|
||||
cache.capturedBuckets(),
|
||||
)
|
||||
}
|
||||
|
||||
func TestSchedulerBulkAccountEventMissingAccountFallsBackToAllPlatforms(t *testing.T) {
|
||||
cache := newBulkEventSnapshotCache()
|
||||
repo := newBulkEventAccountRepo(&Account{ID: 3, Platform: PlatformOpenAI, GroupIDs: []int64{32}})
|
||||
svc := newBulkEventTestService(cache, repo)
|
||||
|
||||
err := svc.handleBulkAccountEvent(context.Background(), bulkEventPayload([]int64{3, 4}, []int64{31}), make(map[batchSeenKey]struct{}))
|
||||
|
||||
require.NoError(t, err)
|
||||
platforms := schedulerSnapshotPlatforms()
|
||||
require.ElementsMatch(t, schedulerBucketsForTest([]int64{31, 32}, platforms[:]...), cache.capturedBuckets())
|
||||
set, deleted := cache.accountWrites()
|
||||
require.Equal(t, []int64{3}, set)
|
||||
require.Equal(t, []int64{4}, deleted)
|
||||
}
|
||||
|
||||
func TestSchedulerBulkAccountEventUnknownPlatformFallsBackToAllPlatforms(t *testing.T) {
|
||||
cache := newBulkEventSnapshotCache()
|
||||
repo := newBulkEventAccountRepo(&Account{ID: 5, GroupIDs: []int64{42}})
|
||||
svc := newBulkEventTestService(cache, repo)
|
||||
|
||||
err := svc.handleBulkAccountEvent(context.Background(), bulkEventPayload([]int64{5}, []int64{41}), make(map[batchSeenKey]struct{}))
|
||||
|
||||
require.NoError(t, err)
|
||||
platforms := schedulerSnapshotPlatforms()
|
||||
require.ElementsMatch(t, schedulerBucketsForTest([]int64{41, 42}, platforms[:]...), cache.capturedBuckets())
|
||||
}
|
||||
@@ -55,14 +55,24 @@ type schedulerAccountQueryKey struct {
|
||||
// mixed 与历史模式保持独立。每个 task 都用 defer 消费 remaining,最后一个消费者会立即释放结果,
|
||||
// 避免把账号切片的生命周期扩大到整轮 full rebuild。
|
||||
type schedulerAccountQueryCache struct {
|
||||
remaining map[schedulerAccountQueryKey]int
|
||||
accounts map[schedulerAccountQueryKey][]Account
|
||||
remaining map[schedulerAccountQueryKey]int
|
||||
accounts map[schedulerAccountQueryKey][]Account
|
||||
snapshotAccountIDs map[schedulerAccountQueryKey][]int64
|
||||
}
|
||||
|
||||
// schedulerSnapshotAccountIDWriter 是 SchedulerCache 的可选批次优化能力。
|
||||
// 首次完整发布成功后返回实际可编码账号 ID;同一查询结果的后续桶只需发布这些 ID,
|
||||
// 避免重复序列化并覆盖全局账号缓存。未实现该接口的缓存继续走原 SetSnapshot 路径。
|
||||
type schedulerSnapshotAccountIDWriter interface {
|
||||
SetSnapshotAndReturnAccountIDs(ctx context.Context, bucket SchedulerBucket, token SchedulerBucketWriteToken, accounts []Account) ([]int64, error)
|
||||
SetSnapshotByAccountIDs(ctx context.Context, bucket SchedulerBucket, token SchedulerBucketWriteToken, accountIDs []int64) error
|
||||
}
|
||||
|
||||
func newSchedulerAccountQueryCache(taskSets ...[]schedulerBucketWriteTask) *schedulerAccountQueryCache {
|
||||
queries := &schedulerAccountQueryCache{
|
||||
remaining: make(map[schedulerAccountQueryKey]int),
|
||||
accounts: make(map[schedulerAccountQueryKey][]Account),
|
||||
remaining: make(map[schedulerAccountQueryKey]int),
|
||||
accounts: make(map[schedulerAccountQueryKey][]Account),
|
||||
snapshotAccountIDs: make(map[schedulerAccountQueryKey][]int64),
|
||||
}
|
||||
for _, tasks := range taskSets {
|
||||
for _, task := range tasks {
|
||||
@@ -93,6 +103,7 @@ func (c *schedulerAccountQueryCache) release(bucket SchedulerBucket) {
|
||||
if remaining <= 0 {
|
||||
delete(c.remaining, key)
|
||||
delete(c.accounts, key)
|
||||
delete(c.snapshotAccountIDs, key)
|
||||
return
|
||||
}
|
||||
c.remaining[key] = remaining
|
||||
@@ -529,11 +540,13 @@ func (s *SchedulerSnapshotService) handleBulkAccountEvent(ctx context.Context, p
|
||||
}
|
||||
}
|
||||
|
||||
if s.cache != nil {
|
||||
for _, id := range ids {
|
||||
if _, ok := found[id]; ok {
|
||||
continue
|
||||
}
|
||||
allAccountsFound := true
|
||||
for _, id := range ids {
|
||||
if _, ok := found[id]; ok {
|
||||
continue
|
||||
}
|
||||
allAccountsFound = false
|
||||
if s.cache != nil {
|
||||
if err := s.cache.DeleteAccount(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -544,7 +557,67 @@ func (s *SchedulerSnapshotService) handleBulkAccountEvent(ctx context.Context, p
|
||||
for gid := range rebuildGroupSet {
|
||||
rebuildGroupIDs = append(rebuildGroupIDs, gid)
|
||||
}
|
||||
return s.rebuildByGroupIDs(ctx, rebuildGroupIDs, "account_bulk_change", seen)
|
||||
|
||||
// 缺失账户无法确定原平台,保留五平台重建以避免遗留旧快照。
|
||||
if !allAccountsFound {
|
||||
return s.rebuildByGroupIDs(ctx, rebuildGroupIDs, "account_bulk_change", seen)
|
||||
}
|
||||
|
||||
platformGroupSets := make(map[string]map[int64]struct{}, len(accounts))
|
||||
addPlatformGroups := func(platform string, groupIDs []int64) {
|
||||
groupSet := platformGroupSets[platform]
|
||||
if groupSet == nil {
|
||||
groupSet = make(map[int64]struct{}, len(groupIDs))
|
||||
platformGroupSets[platform] = groupSet
|
||||
}
|
||||
for _, groupID := range groupIDs {
|
||||
groupSet[groupID] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, account := range accounts {
|
||||
if account == nil || account.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
accountGroupIDs := s.normalizeGroupIDs(account.GroupIDs)
|
||||
switch account.Platform {
|
||||
case PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformGrok:
|
||||
addPlatformGroups(account.Platform, accountGroupIDs)
|
||||
case PlatformAntigravity:
|
||||
// 批量更新可能刚关闭 mixed_scheduling,仍需清理两个兼容平台的旧快照。
|
||||
addPlatformGroups(PlatformAntigravity, accountGroupIDs)
|
||||
addPlatformGroups(PlatformAnthropic, accountGroupIDs)
|
||||
addPlatformGroups(PlatformGemini, accountGroupIDs)
|
||||
default:
|
||||
return s.rebuildByGroupIDs(ctx, rebuildGroupIDs, "account_bulk_change", seen)
|
||||
}
|
||||
}
|
||||
|
||||
// payload 携带更新前的组;只扩散到本事件实际涉及的平台,避免平台间交叉重建。
|
||||
if len(preloadGroupIDs) > 0 {
|
||||
preloadGroupIDs = s.normalizeGroupIDs(preloadGroupIDs)
|
||||
for platform := range platformGroupSets {
|
||||
addPlatformGroups(platform, preloadGroupIDs)
|
||||
}
|
||||
}
|
||||
|
||||
bucketCapacity := 0
|
||||
for _, groupSet := range platformGroupSets {
|
||||
bucketCapacity += len(groupSet) * 3
|
||||
}
|
||||
buckets := make([]SchedulerBucket, 0, bucketCapacity)
|
||||
for _, platform := range schedulerSnapshotPlatforms() {
|
||||
groupSet, ok := platformGroupSets[platform]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
platformGroupIDs := make([]int64, 0, len(groupSet))
|
||||
for groupID := range groupSet {
|
||||
platformGroupIDs = append(platformGroupIDs, groupID)
|
||||
}
|
||||
sort.Slice(platformGroupIDs, func(i, j int) bool { return platformGroupIDs[i] < platformGroupIDs[j] })
|
||||
buckets = append(buckets, s.bucketsForPlatform(platform, platformGroupIDs, seen)...)
|
||||
}
|
||||
return s.rebuildBuckets(ctx, buckets, "account_bulk_change")
|
||||
}
|
||||
|
||||
func (s *SchedulerSnapshotService) handleAccountEvent(ctx context.Context, accountID *int64, payload map[string]any, seen map[batchSeenKey]struct{}) error {
|
||||
@@ -869,7 +942,7 @@ func (s *SchedulerSnapshotService) rebuildBucketWithTokenPolicyAndQueryCache(
|
||||
logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] rebuild failed: bucket=%s reason=%s err=%v", bucket.String(), reason, err)
|
||||
return err
|
||||
}
|
||||
if err := s.cache.SetSnapshot(rebuildCtx, bucket, task.token, accounts); err != nil {
|
||||
if err := s.setRebuildSnapshot(rebuildCtx, task, accounts, queries); err != nil {
|
||||
if errors.Is(err, ErrSchedulerBucketRetired) || errors.Is(err, ErrSchedulerBucketWriteFenced) {
|
||||
slog.Debug("[Scheduler] rebuild fenced", "bucket", bucket.String(), "reason", reason)
|
||||
if strict {
|
||||
@@ -884,6 +957,38 @@ func (s *SchedulerSnapshotService) rebuildBucketWithTokenPolicyAndQueryCache(
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SchedulerSnapshotService) setRebuildSnapshot(
|
||||
ctx context.Context,
|
||||
task schedulerBucketWriteTask,
|
||||
accounts []Account,
|
||||
queries *schedulerAccountQueryCache,
|
||||
) error {
|
||||
writer, ok := s.cache.(schedulerSnapshotAccountIDWriter)
|
||||
key, reusable := schedulerAccountQueryKeyForBucket(task.bucket)
|
||||
if !ok || queries == nil || !reusable {
|
||||
return s.cache.SetSnapshot(ctx, task.bucket, task.token, accounts)
|
||||
}
|
||||
|
||||
if accountIDs, exists := queries.snapshotAccountIDs[key]; exists {
|
||||
return writer.SetSnapshotByAccountIDs(ctx, task.bucket, task.token, accountIDs)
|
||||
}
|
||||
if queries.remaining[key] <= 1 {
|
||||
return s.cache.SetSnapshot(ctx, task.bucket, task.token, accounts)
|
||||
}
|
||||
|
||||
accountIDs, err := writer.SetSnapshotAndReturnAccountIDs(ctx, task.bucket, task.token, accounts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if queries.remaining[key] > 1 {
|
||||
// 必须保存 writeAccounts 实际接受的有序 ID,不能从原账号切片重新推导;
|
||||
// 否则不可编码账号会只出现在后续桶中,破坏两个快照的成员一致性。
|
||||
// 返回切片由当前批次独占,直接接管可避免 10k 账号场景再次复制。
|
||||
queries.snapshotAccountIDs[key] = accountIDs
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SchedulerSnapshotService) triggerFullRebuild(reason string) error {
|
||||
if s.cache == nil {
|
||||
return ErrSchedulerCacheNotReady
|
||||
|
||||
@@ -139,6 +139,8 @@ type UsageLog struct {
|
||||
CacheCreation5mTokens int `gorm:"column:cache_creation_5m_tokens"`
|
||||
CacheCreation1hTokens int `gorm:"column:cache_creation_1h_tokens"`
|
||||
|
||||
ImageInputTokens int
|
||||
ImageInputCost float64
|
||||
ImageOutputTokens int
|
||||
ImageOutputCost float64
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 178_channel_image_input_price.sql
|
||||
-- 渠道自定义定价(token 模式)新增图片输入 token 单价列。
|
||||
-- 用于 gpt-image-2 等模型的图片编辑/图生图请求:上游 usage 的
|
||||
-- input_tokens_details.image_tokens 需按独立单价计费,区别于文本 input_price。
|
||||
-- 未配置(NULL)时计费回退到文本输入价,保持向后兼容。
|
||||
|
||||
ALTER TABLE channel_model_pricing
|
||||
ADD COLUMN IF NOT EXISTS image_input_price NUMERIC(20,12);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 179_usage_log_image_input_tokens.sql
|
||||
-- usage_logs 单独记录图片输入 token 数与费用,便于图片编辑/图生图场景对账。
|
||||
-- image_input_tokens 从 input_tokens 中拆出,image_input_cost 从 input_cost 中拆出,
|
||||
-- total_cost 口径不变。
|
||||
ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS image_input_tokens INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS image_input_cost DECIMAL(20, 10) NOT NULL DEFAULT 0;
|
||||
@@ -30,6 +30,7 @@ export interface ChannelModelPricing {
|
||||
output_price: number | null
|
||||
cache_write_price: number | null
|
||||
cache_read_price: number | null
|
||||
image_input_price: number | null
|
||||
image_output_price: number | null
|
||||
per_request_price: number | null
|
||||
intervals: PricingInterval[]
|
||||
@@ -154,6 +155,7 @@ export interface ModelDefaultPricing {
|
||||
output_price?: number
|
||||
cache_write_price?: number
|
||||
cache_read_price?: number
|
||||
image_input_price?: number
|
||||
image_output_price?: number
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface UserSupportedModelPricing {
|
||||
output_price: number | null
|
||||
cache_write_price: number | null
|
||||
cache_read_price: number | null
|
||||
image_input_price: number | null
|
||||
image_output_price: number | null
|
||||
per_request_price: number | null
|
||||
intervals: UserPricingInterval[]
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
{{ t('admin.channels.form.defaultPrices') }}
|
||||
<span class="ml-1 font-normal text-gray-400">$/MTok</span>
|
||||
</label>
|
||||
<div class="mt-1 grid grid-cols-2 gap-2 sm:grid-cols-5">
|
||||
<div class="mt-1 grid grid-cols-2 gap-2 sm:grid-cols-6">
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.inputPrice') }}</label>
|
||||
<input :value="entry.input_price" @input="emitField('input_price', ($event.target as HTMLInputElement).value)"
|
||||
@@ -122,6 +122,11 @@
|
||||
<input :value="entry.cache_read_price" @input="emitField('cache_read_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder')" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.imageInputPrice') }}</label>
|
||||
<input :value="entry.image_input_price" @input="emitField('image_input_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder')" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.imageTokenPrice') }}</label>
|
||||
<input :value="entry.image_output_price" @input="emitField('image_output_price', ($event.target as HTMLInputElement).value)"
|
||||
@@ -328,6 +333,7 @@ async function onModelsUpdate(newModels: string[]) {
|
||||
output_price: perTokenToMTok(result.output_price ?? null),
|
||||
cache_write_price: perTokenToMTok(result.cache_write_price ?? null),
|
||||
cache_read_price: perTokenToMTok(result.cache_read_price ?? null),
|
||||
image_input_price: perTokenToMTok(result.image_input_price ?? null),
|
||||
image_output_price: perTokenToMTok(result.image_output_price ?? null),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface PricingFormEntry {
|
||||
output_price: number | string | null
|
||||
cache_write_price: number | string | null
|
||||
cache_read_price: number | string | null
|
||||
image_input_price: number | string | null
|
||||
image_output_price: number | string | null
|
||||
per_request_price: number | string | null
|
||||
intervals: IntervalFormEntry[]
|
||||
|
||||
@@ -144,6 +144,12 @@
|
||||
<span v-if="row.cache_ttl_overridden" :title="t('usage.cacheTtlOverriddenHint')" class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-200 dark:bg-rose-500/20 dark:text-rose-400 dark:ring-rose-500/30 cursor-help">R</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hasImageInputTokens(row)" class="flex items-center gap-2">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<svg class="h-3.5 w-3.5 text-fuchsia-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
<span class="font-medium text-fuchsia-600 dark:text-fuchsia-400">{{ row.image_input_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hasImageOutputTokens(row)" class="flex items-center gap-2">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<svg class="h-3.5 w-3.5 text-pink-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
@@ -246,10 +252,18 @@
|
||||
<div class="space-y-1.5">
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-gray-300 mb-1">{{ t('usage.tokenDetails') }}</div>
|
||||
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0 && !hasImageInputTokens(tokenTooltipData)" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
|
||||
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="tokenTooltipData && hasImageInputTokens(tokenTooltipData) && textInputTokens(tokenTooltipData) > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
|
||||
<span class="font-medium text-white">{{ textInputTokens(tokenTooltipData).toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="tokenTooltipData && hasImageInputTokens(tokenTooltipData)" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('usage.imageInputTokens') }}</span>
|
||||
<span class="font-medium text-fuchsia-300">{{ tokenTooltipData.image_input_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0 && !hasImageOutputTokens(tokenTooltipData)" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
|
||||
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
|
||||
@@ -327,6 +341,10 @@
|
||||
<span class="text-gray-400">{{ t('admin.usage.inputCost') }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData.input_cost.toFixed(6) }}</span>
|
||||
</div>
|
||||
<div v-if="tooltipData && hasImageInputCost(tooltipData)" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('usage.imageInputCost') }}</span>
|
||||
<span class="font-medium text-fuchsia-300">${{ tooltipData.image_input_cost.toFixed(6) }}</span>
|
||||
</div>
|
||||
<div v-if="tooltipData && tooltipData.output_cost > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
|
||||
@@ -337,9 +355,13 @@
|
||||
</div>
|
||||
<!-- Token billing: show unit prices per 1M tokens -->
|
||||
<template v-if="tooltipData && !isImageUsage(tooltipData) && (!tooltipData.billing_mode || tooltipData.billing_mode === BILLING_MODE_TOKEN)">
|
||||
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<div v-if="tooltipData && textInputTokens(tooltipData) > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
|
||||
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
|
||||
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, textInputTokens(tooltipData)) }} {{ t('usage.perMillionTokens') }}</span>
|
||||
</div>
|
||||
<div v-if="tooltipData && hasImageInputTokens(tooltipData)" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('usage.imageInputTokenPrice') }}</span>
|
||||
<span class="font-medium text-fuchsia-300">{{ formatTokenPricePerMillion(tooltipData.image_input_cost ?? 0, tooltipData.image_input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
|
||||
</div>
|
||||
<div v-if="tooltipData && tooltipData.output_cost > 0 && textOutputTokens(tooltipData) > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span>
|
||||
@@ -471,6 +493,9 @@ import {
|
||||
hasImageOutputTokens,
|
||||
textOutputTokens,
|
||||
hasImageOutputCost,
|
||||
hasImageInputTokens,
|
||||
textInputTokens,
|
||||
hasImageInputCost,
|
||||
} from '@/utils/imageUsage'
|
||||
|
||||
/** Compute the account-billed cost for display: (account_stats_cost ?? total_cost) * rate_multiplier */
|
||||
|
||||
@@ -90,6 +90,13 @@
|
||||
:unit="t(prefixKey('unitPerMillion'))"
|
||||
:scale="perMillionScale"
|
||||
/>
|
||||
<PricingRow
|
||||
v-if="model.pricing.image_input_price != null && model.pricing.image_input_price > 0"
|
||||
:label="t(prefixKey('imageInputPrice'))"
|
||||
:value="model.pricing.image_input_price"
|
||||
:unit="t(prefixKey('unitPerMillion'))"
|
||||
:scale="perMillionScale"
|
||||
/>
|
||||
<PricingRow
|
||||
v-if="model.pricing.image_output_price != null && model.pricing.image_output_price > 0"
|
||||
:label="t(prefixKey('imageOutputPrice'))"
|
||||
|
||||
@@ -29,11 +29,12 @@
|
||||
</p>
|
||||
|
||||
<!-- Client Tabs -->
|
||||
<div v-if="clientTabs.length" class="border-b border-gray-200 dark:border-dark-700">
|
||||
<nav class="-mb-px flex space-x-6" aria-label="Client">
|
||||
<div v-if="clientTabs.length" class="overflow-x-auto border-b border-gray-200 dark:border-dark-700">
|
||||
<nav class="-mb-px flex min-w-max gap-4 sm:gap-6" aria-label="Client">
|
||||
<button
|
||||
v-for="tab in clientTabs"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
@click="activeClientTab = tab.id"
|
||||
:class="[
|
||||
'whitespace-nowrap py-2.5 px-1 border-b-2 font-medium text-sm transition-colors',
|
||||
@@ -102,11 +103,12 @@
|
||||
</div>
|
||||
|
||||
<!-- OS/Shell Tabs -->
|
||||
<div v-if="showShellTabs" class="border-b border-gray-200 dark:border-dark-700">
|
||||
<nav class="-mb-px flex space-x-4" aria-label="Tabs">
|
||||
<div v-if="showShellTabs" class="overflow-x-auto border-b border-gray-200 dark:border-dark-700">
|
||||
<nav class="-mb-px flex min-w-max gap-4" aria-label="Tabs">
|
||||
<button
|
||||
v-for="tab in currentTabs"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
@click="activeTab = tab.id"
|
||||
:class="[
|
||||
'whitespace-nowrap py-2.5 px-1 border-b-2 font-medium text-sm transition-colors',
|
||||
@@ -138,10 +140,11 @@
|
||||
<div class="bg-gray-900 dark:bg-dark-900 rounded-xl overflow-hidden">
|
||||
<!-- Code Header -->
|
||||
<div class="flex items-center justify-between px-4 py-2 bg-gray-800 dark:bg-dark-800 border-b border-gray-700 dark:border-dark-700">
|
||||
<span class="text-xs text-gray-400 font-mono">{{ file.path }}</span>
|
||||
<span class="min-w-0 truncate text-xs text-gray-400 font-mono">{{ file.path }}</span>
|
||||
<button
|
||||
type="button"
|
||||
@click="copyContent(file.content, index)"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg transition-colors"
|
||||
class="flex flex-shrink-0 items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg transition-colors"
|
||||
:class="copiedIndex === index
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-gray-700 hover:bg-gray-600 text-gray-300 hover:text-white'"
|
||||
@@ -353,6 +356,8 @@ const clientTabs = computed((): TabConfig[] => {
|
||||
case 'grok':
|
||||
return [
|
||||
{ id: 'grok', label: t('keys.useKeyModal.cliTabs.grokCli'), icon: TerminalIcon },
|
||||
{ id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon },
|
||||
{ id: 'codex', label: t('keys.useKeyModal.cliTabs.codexCli'), icon: TerminalIcon },
|
||||
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
|
||||
]
|
||||
default:
|
||||
@@ -403,6 +408,12 @@ const platformDescription = computed(() => {
|
||||
case 'antigravity':
|
||||
return t('keys.useKeyModal.antigravity.description')
|
||||
case 'grok':
|
||||
if (activeClientTab.value === 'claude') {
|
||||
return t('keys.useKeyModal.grok.claudeDescription')
|
||||
}
|
||||
if (activeClientTab.value === 'codex') {
|
||||
return t('keys.useKeyModal.grok.codexDescription')
|
||||
}
|
||||
return t('keys.useKeyModal.grok.description')
|
||||
default:
|
||||
return t('keys.useKeyModal.description')
|
||||
@@ -425,6 +436,14 @@ const platformNote = computed(() => {
|
||||
? t('keys.useKeyModal.antigravity.claudeNote')
|
||||
: t('keys.useKeyModal.antigravity.geminiNote')
|
||||
case 'grok':
|
||||
if (activeClientTab.value === 'claude') {
|
||||
return t('keys.useKeyModal.grok.claudeNote')
|
||||
}
|
||||
if (activeClientTab.value === 'codex') {
|
||||
return activeTab.value === 'windows'
|
||||
? t('keys.useKeyModal.grok.codexNoteWindows')
|
||||
: t('keys.useKeyModal.grok.codexNote')
|
||||
}
|
||||
return activeTab.value === 'windows'
|
||||
? t('keys.useKeyModal.grok.noteWindows')
|
||||
: t('keys.useKeyModal.grok.note')
|
||||
@@ -509,6 +528,12 @@ const currentFiles = computed((): FileConfig[] => {
|
||||
}
|
||||
return generateAnthropicFiles(`${baseUrl}/antigravity`, apiKey)
|
||||
case 'grok':
|
||||
if (activeClientTab.value === 'claude') {
|
||||
return generateGrokClaudeFiles(baseRoot, apiKey)
|
||||
}
|
||||
if (activeClientTab.value === 'codex') {
|
||||
return generateGrokCodexFiles(apiBase, apiKey)
|
||||
}
|
||||
return generateGrokFiles(apiBase, apiKey)
|
||||
default:
|
||||
return generateAnthropicFiles(baseUrl, apiKey)
|
||||
@@ -548,9 +573,10 @@ $env:CLAUDE_CODE_ATTRIBUTION_HEADER=0`
|
||||
|
||||
const vscodeSettingsPath = activeTab.value === 'unix'
|
||||
? '~/.claude/settings.json'
|
||||
: '%userprofile%\\.claude\\settings.json'
|
||||
: '%USERPROFILE%\\.claude\\settings.json'
|
||||
|
||||
const vscodeContent = `{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "${baseUrl}",
|
||||
"ANTHROPIC_AUTH_TOKEN": "${apiKey}",
|
||||
@@ -561,7 +587,68 @@ $env:CLAUDE_CODE_ATTRIBUTION_HEADER=0`
|
||||
|
||||
return [
|
||||
{ path, content },
|
||||
{ path: vscodeSettingsPath, content: vscodeContent, hint: 'VSCode Claude Code' }
|
||||
{
|
||||
path: vscodeSettingsPath,
|
||||
content: vscodeContent,
|
||||
hint: t('keys.useKeyModal.claudeSettingsHint')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function generateGrokClaudeFiles(baseUrl: string, apiKey: string): FileConfig[] {
|
||||
const environment = {
|
||||
ANTHROPIC_BASE_URL: baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: apiKey,
|
||||
ANTHROPIC_MODEL: 'grok-4.5',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'grok-4.5',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'grok-4.5',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'grok-4.5',
|
||||
ANTHROPIC_DEFAULT_FABLE_MODEL: 'grok-4.5',
|
||||
CLAUDE_CODE_SUBAGENT_MODEL: 'grok-4.5',
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
||||
CLAUDE_CODE_ATTRIBUTION_HEADER: '0'
|
||||
}
|
||||
let path: string
|
||||
let content: string
|
||||
|
||||
switch (activeTab.value) {
|
||||
case 'unix':
|
||||
path = 'Terminal'
|
||||
content = Object.entries(environment)
|
||||
.map(([name, value]) => `export ${name}="${value}"`)
|
||||
.join('\n')
|
||||
break
|
||||
case 'cmd':
|
||||
path = 'Command Prompt'
|
||||
content = Object.entries(environment)
|
||||
.map(([name, value]) => `set ${name}=${value}`)
|
||||
.join('\n')
|
||||
break
|
||||
case 'powershell':
|
||||
path = 'PowerShell'
|
||||
content = Object.entries(environment)
|
||||
.map(([name, value]) => `$env:${name}="${value}"`)
|
||||
.join('\n')
|
||||
break
|
||||
default:
|
||||
path = 'Terminal'
|
||||
content = ''
|
||||
}
|
||||
|
||||
const settingsPath = activeTab.value === 'unix'
|
||||
? '~/.claude/settings.json'
|
||||
: '%USERPROFILE%\\.claude\\settings.json'
|
||||
|
||||
return [
|
||||
{ path, content },
|
||||
{
|
||||
path: settingsPath,
|
||||
content: JSON.stringify({
|
||||
$schema: 'https://json.schemastore.org/claude-code-settings.json',
|
||||
env: environment
|
||||
}, null, 2),
|
||||
hint: t('keys.useKeyModal.claudeSettingsHint')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -682,6 +769,39 @@ supports_backend_search = true`
|
||||
}]
|
||||
}
|
||||
|
||||
function generateGrokCodexFiles(baseUrl: string, apiKey: string): FileConfig[] {
|
||||
const isWindows = activeTab.value === 'windows'
|
||||
const configPath = isWindows
|
||||
? '%USERPROFILE%\\.codex\\config.toml'
|
||||
: '~/.codex/config.toml'
|
||||
const configContent = `model_provider = "sub2api_grok"
|
||||
model = "grok-4.5"
|
||||
review_model = "grok-4.5"
|
||||
model_reasoning_effort = "xhigh"
|
||||
model_context_window = 1000000
|
||||
|
||||
[model_providers.sub2api_grok]
|
||||
name = "Sub2API Grok"
|
||||
base_url = "${baseUrl}"
|
||||
env_key = "SUB2API_API_KEY"
|
||||
wire_api = "responses"`
|
||||
const environmentContent = isWindows
|
||||
? `$env:SUB2API_API_KEY="${apiKey}"`
|
||||
: `export SUB2API_API_KEY="${apiKey}"`
|
||||
|
||||
return [
|
||||
{
|
||||
path: configPath,
|
||||
content: configContent,
|
||||
hint: t('keys.useKeyModal.grok.codexConfigTomlHint')
|
||||
},
|
||||
{
|
||||
path: isWindows ? 'PowerShell' : 'Terminal',
|
||||
content: environmentContent
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function generateOpenAIWsFiles(baseUrl: string, apiKey: string): FileConfig[] {
|
||||
const isWindows = activeTab.value === 'windows'
|
||||
const configDir = isWindows ? '%userprofile%\\.codex' : '~/.codex'
|
||||
|
||||
@@ -2,6 +2,10 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const { copyToClipboardMock } = vi.hoisted(() => ({
|
||||
copyToClipboardMock: vi.fn().mockResolvedValue(true)
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
@@ -10,7 +14,7 @@ vi.mock('vue-i18n', () => ({
|
||||
|
||||
vi.mock('@/composables/useClipboard', () => ({
|
||||
useClipboard: () => ({
|
||||
copyToClipboard: vi.fn().mockResolvedValue(true)
|
||||
copyToClipboard: copyToClipboardMock
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -78,6 +82,153 @@ describe('UseKeyModal', () => {
|
||||
expect(parsed.provider.grok.models['gpt-5.6']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('renders copyable Claude Code setup through the Grok Messages gateway', async () => {
|
||||
copyToClipboardMock.mockClear()
|
||||
const wrapper = mount(UseKeyModal, {
|
||||
props: {
|
||||
show: true,
|
||||
apiKey: 'sk-grok-claude-test',
|
||||
baseUrl: 'https://example.com/v1',
|
||||
platform: 'grok'
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: {
|
||||
template: '<div><slot /><slot name="footer" /></div>'
|
||||
},
|
||||
Icon: {
|
||||
template: '<span />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const claudeTab = wrapper.findAll('button').find((button) =>
|
||||
button.text().includes('keys.useKeyModal.cliTabs.claudeCode')
|
||||
)
|
||||
expect(claudeTab).toBeDefined()
|
||||
await claudeTab!.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
let codeBlocks = wrapper.findAll('pre code').map((code) => code.text())
|
||||
expect(codeBlocks.join('\n')).toContain('ANTHROPIC_BASE_URL="https://example.com"')
|
||||
expect(codeBlocks.join('\n')).toContain('ANTHROPIC_AUTH_TOKEN="sk-grok-claude-test"')
|
||||
const unixConfig = codeBlocks.find((content) => content.startsWith('export ANTHROPIC_BASE_URL'))
|
||||
expect(unixConfig).toBeDefined()
|
||||
for (const name of [
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
'ANTHROPIC_DEFAULT_FABLE_MODEL',
|
||||
'CLAUDE_CODE_SUBAGENT_MODEL'
|
||||
]) {
|
||||
expect(unixConfig).toContain(`export ${name}="grok-4.5"`)
|
||||
}
|
||||
const settingsConfig = codeBlocks.find((content) => content.includes('"$schema"'))
|
||||
expect(settingsConfig).toBeDefined()
|
||||
const parsedSettings = JSON.parse(settingsConfig!)
|
||||
expect(parsedSettings.$schema).toBe('https://json.schemastore.org/claude-code-settings.json')
|
||||
expect(parsedSettings.env.ANTHROPIC_MODEL).toBe('grok-4.5')
|
||||
expect(wrapper.text()).toContain('keys.useKeyModal.claudeSettingsHint')
|
||||
expect(wrapper.text()).toContain('keys.useKeyModal.grok.claudeNote')
|
||||
expect(wrapper.find('nav[aria-label="Client"]').classes()).toContain('min-w-max')
|
||||
expect(wrapper.find('nav[aria-label="Client"]').element.parentElement?.classList.contains('overflow-x-auto')).toBe(true)
|
||||
|
||||
const cmdTab = wrapper.findAll('button').find(
|
||||
(button) => button.text().trim() === 'Windows CMD'
|
||||
)
|
||||
expect(cmdTab).toBeDefined()
|
||||
await cmdTab!.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
codeBlocks = wrapper.findAll('pre code').map((code) => code.text())
|
||||
expect(codeBlocks.join('\n')).toContain('set ANTHROPIC_MODEL=grok-4.5')
|
||||
expect(codeBlocks.join('\n')).toContain('set ANTHROPIC_DEFAULT_FABLE_MODEL=grok-4.5')
|
||||
expect(codeBlocks.join('\n')).toContain('set CLAUDE_CODE_SUBAGENT_MODEL=grok-4.5')
|
||||
|
||||
const powershellTab = wrapper.findAll('button').find(
|
||||
(button) => button.text().trim() === 'PowerShell'
|
||||
)
|
||||
expect(powershellTab).toBeDefined()
|
||||
await powershellTab!.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
codeBlocks = wrapper.findAll('pre code').map((code) => code.text())
|
||||
expect(codeBlocks.join('\n')).toContain('$env:ANTHROPIC_BASE_URL="https://example.com"')
|
||||
expect(codeBlocks.join('\n')).toContain('$env:ANTHROPIC_MODEL="grok-4.5"')
|
||||
expect(codeBlocks.join('\n')).toContain('$env:ANTHROPIC_DEFAULT_FABLE_MODEL="grok-4.5"')
|
||||
expect(codeBlocks.join('\n')).toContain('$env:CLAUDE_CODE_SUBAGENT_MODEL="grok-4.5"')
|
||||
expect(wrapper.text()).toContain('%USERPROFILE%\\.claude\\settings.json')
|
||||
|
||||
const copyButton = wrapper.findAll('button').find((button) =>
|
||||
button.text().includes('keys.useKeyModal.copy')
|
||||
)
|
||||
expect(copyButton).toBeDefined()
|
||||
await copyButton!.trigger('click')
|
||||
expect(copyToClipboardMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ANTHROPIC_AUTH_TOKEN="sk-grok-claude-test"'),
|
||||
'keys.copied'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders Codex custom provider setup through the Grok Responses gateway', async () => {
|
||||
const wrapper = mount(UseKeyModal, {
|
||||
props: {
|
||||
show: true,
|
||||
apiKey: 'sk-grok-codex-test',
|
||||
baseUrl: 'https://example.com/v1',
|
||||
platform: 'grok'
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: {
|
||||
template: '<div><slot /><slot name="footer" /></div>'
|
||||
},
|
||||
Icon: {
|
||||
template: '<span />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const codexTab = wrapper.findAll('button').find((button) =>
|
||||
button.text().includes('keys.useKeyModal.cliTabs.codexCli')
|
||||
)
|
||||
expect(codexTab).toBeDefined()
|
||||
await codexTab!.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
let codeBlocks = wrapper.findAll('pre code').map((code) => code.text())
|
||||
const configToml = codeBlocks.find((content) => content.includes('[model_providers.sub2api_grok]'))
|
||||
expect(configToml).toBeDefined()
|
||||
expect(configToml).toContain('model_provider = "sub2api_grok"')
|
||||
expect(configToml).toContain('model = "grok-4.5"')
|
||||
expect(configToml).toContain('model_context_window = 1000000')
|
||||
expect(configToml).toContain('base_url = "https://example.com/v1"')
|
||||
expect(configToml).toContain('env_key = "SUB2API_API_KEY"')
|
||||
expect(configToml).toContain('wire_api = "responses"')
|
||||
expect(configToml).not.toContain('requires_openai_auth')
|
||||
expect(configToml).not.toContain('disable_response_storage')
|
||||
expect(configToml).not.toContain('network_access')
|
||||
expect(configToml).not.toContain('windows_wsl_setup_acknowledged')
|
||||
expect(configToml).not.toContain('[features]')
|
||||
expect(configToml).not.toContain('goals = true')
|
||||
expect(codeBlocks).toContain('export SUB2API_API_KEY="sk-grok-codex-test"')
|
||||
expect(wrapper.text()).not.toContain('auth.json')
|
||||
|
||||
const windowsTab = wrapper.findAll('button').find(
|
||||
(button) => button.text().trim() === 'Windows'
|
||||
)
|
||||
expect(windowsTab).toBeDefined()
|
||||
await windowsTab!.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
codeBlocks = wrapper.findAll('pre code').map((code) => code.text())
|
||||
expect(wrapper.text()).toContain('%USERPROFILE%\\.codex\\config.toml')
|
||||
expect(codeBlocks).toContain('$env:SUB2API_API_KEY="sk-grok-codex-test"')
|
||||
})
|
||||
|
||||
it('keeps legacy OpenAI Codex config as the default', () => {
|
||||
const wrapper = mount(UseKeyModal, {
|
||||
props: {
|
||||
|
||||
@@ -100,10 +100,6 @@ onUnmounted(() => {
|
||||
@apply flex-none min-h-fit;
|
||||
}
|
||||
|
||||
.table-page-layout.mobile-mode .table-scroll-container :deep(.table-wrapper) {
|
||||
@apply overflow-visible;
|
||||
}
|
||||
|
||||
.table-page-layout.mobile-mode .table-scroll-container :deep(table) {
|
||||
@apply flex-none;
|
||||
display: table;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const componentPath = resolve(dirname(fileURLToPath(import.meta.url)), '../TablePageLayout.vue')
|
||||
const componentSource = readFileSync(componentPath, 'utf8')
|
||||
|
||||
describe('TablePageLayout responsive table scrolling', () => {
|
||||
it('does not disable the table horizontal scroll container in mobile mode', () => {
|
||||
const tableWrapperBlocks = Array.from(
|
||||
componentSource.matchAll(/([^{}]*:deep\(\.table-wrapper\)[^{}]*)\{([^{}]*)\}/g)
|
||||
)
|
||||
|
||||
expect(tableWrapperBlocks.length).toBeGreaterThan(0)
|
||||
|
||||
const baseBlock = tableWrapperBlocks.find(([selector]) => !selector.includes('.mobile-mode'))
|
||||
const mobileBlocks = tableWrapperBlocks.filter(([selector]) => selector.includes('.mobile-mode'))
|
||||
|
||||
expect(baseBlock?.[2]).toContain('overflow-x-auto')
|
||||
expect(mobileBlocks.every(([, , declarations]) => !declarations.includes('overflow-visible'))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -116,6 +116,7 @@ export default {
|
||||
cacheReadPrice: 'Cache Read',
|
||||
cacheWritePriceShort: 'Cache W',
|
||||
cacheReadPriceShort: 'Cache R',
|
||||
imageInputPrice: 'Image Input',
|
||||
imageTokenPrice: 'Image Output',
|
||||
imageOutputPrice: 'Image Output Price',
|
||||
pricePlaceholder: 'Default',
|
||||
|
||||
@@ -134,6 +134,7 @@ export default {
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
note: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.',
|
||||
claudeSettingsHint: 'User-level persistent configuration. Do not commit this file containing your API key to a project repository.',
|
||||
noGroupTitle: 'Please assign a group first',
|
||||
noGroupDescription: 'This API key has not been assigned to a group. Please click the group column in the key list to assign one before viewing the configuration.',
|
||||
openai: {
|
||||
@@ -167,10 +168,16 @@ export default {
|
||||
note: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.',
|
||||
},
|
||||
grok: {
|
||||
description: 'Configure Grok Build or OpenCode to send Responses API traffic through your Sub2API Grok group.',
|
||||
description: 'Configure Grok Build, Claude Code, Codex, or OpenCode to send requests through your Sub2API Grok group.',
|
||||
claudeDescription: 'Configure Claude Code to send Messages API traffic through your Sub2API Grok group.',
|
||||
codexDescription: 'Configure Codex to send Responses API traffic through your Sub2API Grok group.',
|
||||
configTomlHint: 'Back up an existing config.toml before merging this model entry. Run grok inspect after saving to verify the effective configuration.',
|
||||
codexConfigTomlHint: 'Back up an existing config.toml before merging this provider configuration.',
|
||||
note: 'Save the file as ~/.grok/config.toml, then run grok inspect and select grok from /model.',
|
||||
noteWindows: 'Save the file as %USERPROFILE%\\.grok\\config.toml, then run grok inspect and select grok from /model.',
|
||||
claudeNote: 'Choose one method: run the terminal commands for the current session, or save settings.json for user-level persistent configuration.',
|
||||
codexNote: 'Save config.toml under ~/.codex and set SUB2API_API_KEY before starting Codex.',
|
||||
codexNoteWindows: 'Save config.toml under %USERPROFILE%\\.codex and set SUB2API_API_KEY in PowerShell before starting Codex.',
|
||||
},
|
||||
opencode: {
|
||||
title: 'OpenCode Example',
|
||||
@@ -325,6 +332,9 @@ export default {
|
||||
imageBillingSize: 'Billing size',
|
||||
imageInputSize: 'Input size',
|
||||
imageOutputSize: 'Output size',
|
||||
imageInputTokens: 'Image Input Tokens',
|
||||
imageInputTokenPrice: 'Image Input Price',
|
||||
imageInputCost: 'Image Input Cost',
|
||||
imageOutputTokens: 'Image Output Tokens',
|
||||
imageOutputTokenPrice: 'Image Output Price',
|
||||
imageOutputCost: 'Image Output Cost',
|
||||
@@ -499,6 +509,7 @@ export default {
|
||||
outputPrice: 'Output',
|
||||
cacheWritePrice: 'Cache Write',
|
||||
cacheReadPrice: 'Cache Read',
|
||||
imageInputPrice: 'Image Input',
|
||||
imageOutputPrice: 'Image Output',
|
||||
perRequestPrice: 'Per Request',
|
||||
intervals: 'Tiered Pricing',
|
||||
|
||||
@@ -116,6 +116,7 @@ export default {
|
||||
cacheReadPrice: '缓存读取',
|
||||
cacheWritePriceShort: '缓存写',
|
||||
cacheReadPriceShort: '缓存读',
|
||||
imageInputPrice: '图片输入',
|
||||
imageTokenPrice: '图片输出',
|
||||
imageOutputPrice: '图片输出价格',
|
||||
pricePlaceholder: '默认',
|
||||
|
||||
@@ -133,6 +133,7 @@ export default {
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。',
|
||||
claudeSettingsHint: '用户级持久配置。此文件包含 API 密钥,请勿提交到项目仓库。',
|
||||
noGroupTitle: '请先分配分组',
|
||||
noGroupDescription:
|
||||
'此 API 密钥尚未分配分组,请先在密钥列表中点击分组列进行分配,然后才能查看使用配置。',
|
||||
@@ -171,10 +172,16 @@ export default {
|
||||
note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。'
|
||||
},
|
||||
grok: {
|
||||
description: '配置 Grok Build 或 OpenCode,让 Responses API 请求通过当前 Sub2API Grok 分组发送。',
|
||||
description: '配置 Grok Build、Claude Code、Codex 或 OpenCode,让请求通过当前 Sub2API Grok 分组发送。',
|
||||
claudeDescription: '配置 Claude Code,让 Messages API 请求通过当前 Sub2API Grok 分组发送。',
|
||||
codexDescription: '配置 Codex,让 Responses API 请求通过当前 Sub2API Grok 分组发送。',
|
||||
configTomlHint: '如已有 config.toml,请先备份再合并此模型配置。保存后运行 grok inspect 验证生效配置。',
|
||||
codexConfigTomlHint: '如已有 config.toml,请先备份再合并此服务商配置。',
|
||||
note: '保存为 ~/.grok/config.toml,然后运行 grok inspect,并在 /model 中选择 grok。',
|
||||
noteWindows: '保存为 %USERPROFILE%\\.grok\\config.toml,然后运行 grok inspect,并在 /model 中选择 grok。'
|
||||
noteWindows: '保存为 %USERPROFILE%\\.grok\\config.toml,然后运行 grok inspect,并在 /model 中选择 grok。',
|
||||
claudeNote: '二选一即可:终端命令仅在当前会话生效;保存 settings.json 可作为用户级持久配置。',
|
||||
codexNote: '将 config.toml 保存到 ~/.codex,并在启动 Codex 前设置 SUB2API_API_KEY。',
|
||||
codexNoteWindows: '将 config.toml 保存到 %USERPROFILE%\\.codex,并在 PowerShell 中设置 SUB2API_API_KEY 后启动 Codex。'
|
||||
},
|
||||
opencode: {
|
||||
title: 'OpenCode 配置示例',
|
||||
@@ -330,6 +337,9 @@ export default {
|
||||
imageBillingSize: '计费尺寸',
|
||||
imageInputSize: '输入尺寸',
|
||||
imageOutputSize: '输出尺寸',
|
||||
imageInputTokens: '图片输入 Token',
|
||||
imageInputTokenPrice: '图片输入单价',
|
||||
imageInputCost: '图片输入费用',
|
||||
imageOutputTokens: '图片输出 Token',
|
||||
imageOutputTokenPrice: '图片输出单价',
|
||||
imageOutputCost: '图片输出费用',
|
||||
@@ -504,6 +514,7 @@ export default {
|
||||
outputPrice: '输出',
|
||||
cacheWritePrice: '缓存写入',
|
||||
cacheReadPrice: '缓存读取',
|
||||
imageInputPrice: '图片输入',
|
||||
imageOutputPrice: '图片输出',
|
||||
perRequestPrice: '每次请求',
|
||||
intervals: '阶梯定价',
|
||||
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user