Merge remote-tracking branch 'origin/main' into feat/upstream-rate-scheduling

# Conflicts:
#	backend/internal/handler/openai_gateway_handler.go
This commit is contained in:
shaw committed 2026-07-16 10:31:21 +08:00
commit 0408bdb34f
103 files changed
+6095 -347

No files matched your search

+1 -1
View File
@@ -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})
}
+2
View File
@@ -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,
+2
View File
@@ -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)
})
}
}
+3 -3
View File
@@ -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
}
+5 -5
View File
@@ -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)
+125 -28
View File
@@ -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)
})
}
+11
View File
@@ -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)]
}
+11 -4
View File
@@ -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()
+1
View File
@@ -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)
+500 -19
View File
@@ -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
+9 -1
View File
@@ -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
}
+110 -6
View File
@@ -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())
+6 -1
View File
@@ -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
+2
View File
@@ -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;
+2
View File
@@ -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
}
+1
View File
@@ -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'))"
+128 -8
View File
@@ -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',
+12 -1
View File
@@ -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: '默认',
+13 -2
View File
@@ -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