mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 07:33:06 +08:00
- BillingSummary 输出 prepaid/monthly_used/on_demand 绝对金额,并映射官方 7d/30d 进度条 - 账号编辑页支持 credentials.account_scheduling_threshold 覆盖;Settings 文案细化 - groups.search_price_per_1k + 管理端/缓存全链路;SearchCount/AudioUsage 请求级计费 - Voice TTS/STT/Realtime 成功路径 RecordUsage;独立 /v1/web_search 原生 Grok 搜索
254 lines
8.3 KiB
Go
254 lines
8.3 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
|
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
coderws "github.com/coder/websocket"
|
|
"github.com/gin-gonic/gin"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// GrokRealtime exposes xAI's native Voice Realtime WebSocket.
|
|
// Only Grok-platform API keys may use this endpoint.
|
|
func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) {
|
|
if c == nil || c.Request == nil || !isOpenAIWSUpgradeRequest(c.Request) {
|
|
h.errorResponse(c, http.StatusUpgradeRequired, "invalid_request_error", "WebSocket upgrade required (Upgrade: websocket)")
|
|
return
|
|
}
|
|
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
|
|
if !ok || apiKey.Group == nil || apiKey.Group.Platform != service.PlatformGrok {
|
|
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Realtime API is not supported for this platform")
|
|
return
|
|
}
|
|
if !h.ensureResponsesDependencies(c, nil) {
|
|
return
|
|
}
|
|
|
|
selection, _, err := h.gatewayService.SelectAccountWithSchedulerForCapability(
|
|
c.Request.Context(),
|
|
apiKey.GroupID,
|
|
"",
|
|
"",
|
|
"grok-4.5",
|
|
nil,
|
|
service.OpenAIUpstreamTransportHTTPSSE,
|
|
// Grok only advertises chat_completions + media capabilities on HEAD.
|
|
service.OpenAIEndpointCapabilityChatCompletions,
|
|
false,
|
|
false,
|
|
false,
|
|
service.PlatformGrok,
|
|
)
|
|
if err != nil || selection == nil || selection.Account == nil {
|
|
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available Grok accounts")
|
|
return
|
|
}
|
|
|
|
var streamStarted bool
|
|
reqLog := requestLogger(c, "handler.openai_gateway.grok_realtime")
|
|
release, slotStatus := h.acquireResponsesAccountSlot(c, apiKey.GroupID, "", selection, true, &streamStarted, reqLog)
|
|
if slotStatus != openAISlotAcquireOK {
|
|
return
|
|
}
|
|
defer release()
|
|
|
|
token, _, err := h.gatewayService.GetRequestCredential(c.Request.Context(), c, selection.Account)
|
|
if err != nil {
|
|
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Grok credential unavailable")
|
|
return
|
|
}
|
|
|
|
conn, err := coderws.Accept(c.Writer, c.Request, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover})
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.CloseNow()
|
|
|
|
model := c.Query("model")
|
|
if strings.TrimSpace(model) == "" {
|
|
model = "grok-voice-latest"
|
|
}
|
|
started := time.Now()
|
|
proxyErr := h.gatewayService.ProxyGrokRealtime(c.Request.Context(), c, conn, selection.Account, token, model)
|
|
elapsed := time.Since(started)
|
|
if proxyErr != nil {
|
|
reqLog.Info("grok_realtime.proxy_failed", zap.Error(proxyErr))
|
|
_ = conn.Close(coderws.StatusInternalError, "upstream realtime websocket failed")
|
|
}
|
|
// Bill completed (or partially completed) realtime sessions by elapsed minutes
|
|
// when group audio_realtime_price_per_min is configured.
|
|
if elapsed > 0 {
|
|
subscription, _ := middleware2.GetSubscriptionFromContext(c)
|
|
result := &service.OpenAIForwardResult{
|
|
Model: model,
|
|
Duration: elapsed,
|
|
AudioUsage: &service.AudioUsage{Mode: "realtime", DurationOrUnits: elapsed.Minutes()},
|
|
}
|
|
h.recordGrokVoiceUsage(c, apiKey, selection.Account, subscription, "realtime", nil, result)
|
|
}
|
|
}
|
|
|
|
// GrokVoice handles xAI Voice HTTP endpoints. endpoint is "tts", "stt", or "custom-voices".
|
|
func (h *OpenAIGatewayHandler) GrokVoice(c *gin.Context, endpoint string) {
|
|
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
|
|
if !ok || apiKey.Group == nil || apiKey.Group.Platform != service.PlatformGrok {
|
|
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Voice API is not supported for this platform")
|
|
return
|
|
}
|
|
if !h.ensureResponsesDependencies(c, nil) {
|
|
return
|
|
}
|
|
|
|
body, err := readGrokVoiceGatewayBody(c)
|
|
if err != nil {
|
|
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
|
return
|
|
}
|
|
contentType := c.GetHeader("Content-Type")
|
|
if strings.TrimSpace(contentType) == "" {
|
|
contentType = "application/json"
|
|
}
|
|
|
|
failed := map[int64]struct{}{}
|
|
var last *service.UpstreamFailoverError
|
|
reqLog := requestLogger(c, "handler.openai_gateway.grok_voice", zap.String("endpoint", endpoint))
|
|
selectionModel := "grok-4.5"
|
|
|
|
for attempts := 0; attempts < 4; attempts++ {
|
|
selection, _, selectErr := h.gatewayService.SelectAccountWithSchedulerForCapability(
|
|
c.Request.Context(),
|
|
apiKey.GroupID,
|
|
"",
|
|
"",
|
|
selectionModel,
|
|
failed,
|
|
service.OpenAIUpstreamTransportHTTPSSE,
|
|
service.OpenAIEndpointCapabilityChatCompletions,
|
|
false,
|
|
false,
|
|
false,
|
|
service.PlatformGrok,
|
|
)
|
|
if selectErr != nil || selection == nil || selection.Account == nil {
|
|
if last != nil {
|
|
h.handleFailoverExhausted(c, last, false)
|
|
} else {
|
|
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available Grok accounts")
|
|
}
|
|
return
|
|
}
|
|
account := selection.Account
|
|
var started bool
|
|
release, status := h.acquireResponsesAccountSlot(c, apiKey.GroupID, "", selection, false, &started, reqLog)
|
|
if status == openAISlotAcquireProfitVetoed {
|
|
failed[account.ID] = struct{}{}
|
|
continue
|
|
}
|
|
if status != openAISlotAcquireOK {
|
|
// Failed already wrote error response (or transient reject).
|
|
if status == openAISlotAcquireFailed && len(failed) == 0 {
|
|
// Slot path wrote the response; stop.
|
|
return
|
|
}
|
|
failed[account.ID] = struct{}{}
|
|
continue
|
|
}
|
|
result, forwardErr := func() (*service.OpenAIForwardResult, error) {
|
|
defer release()
|
|
return h.gatewayService.ForwardGrokVoice(c.Request.Context(), c, account, endpoint, body, contentType)
|
|
}()
|
|
if forwardErr == nil {
|
|
subscription, _ := middleware2.GetSubscriptionFromContext(c)
|
|
h.recordGrokVoiceUsage(c, apiKey, account, subscription, endpoint, body, result)
|
|
return
|
|
}
|
|
var failoverErr *service.UpstreamFailoverError
|
|
if errors.As(forwardErr, &failoverErr) && failoverErr.ShouldRetryNextAccount() {
|
|
failed[account.ID] = struct{}{}
|
|
last = failoverErr
|
|
continue
|
|
}
|
|
// Non-failover errors: handleGrokMediaErrorResponse / transport already wrote response.
|
|
return
|
|
}
|
|
if last != nil {
|
|
h.handleFailoverExhausted(c, last, false)
|
|
}
|
|
}
|
|
|
|
// recordGrokVoiceUsage bills TTS/STT/realtime via group audio prices when AudioUsage is set.
|
|
func (h *OpenAIGatewayHandler) recordGrokVoiceUsage(
|
|
c *gin.Context,
|
|
apiKey *service.APIKey,
|
|
account *service.Account,
|
|
subscription *service.UserSubscription,
|
|
endpoint string,
|
|
body []byte,
|
|
result *service.OpenAIForwardResult,
|
|
) {
|
|
if h == nil || c == nil || apiKey == nil || account == nil || result == nil {
|
|
return
|
|
}
|
|
if result.AudioUsage == nil {
|
|
return
|
|
}
|
|
userAgent := c.GetHeader("User-Agent")
|
|
clientIP := ip.GetClientIP(c)
|
|
sessionID := service.ExtractClientSessionID(c)
|
|
requestPayloadHash := service.HashUsageRequestPayload(body)
|
|
if requestPayloadHash == "" {
|
|
requestPayloadHash = service.HashUsageRequestPayload([]byte(endpoint))
|
|
}
|
|
inboundEndpoint := GetInboundEndpoint(c)
|
|
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
|
|
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
|
|
model := strings.TrimSpace(result.Model)
|
|
if model == "" {
|
|
model = endpoint
|
|
}
|
|
|
|
h.submitMandatoryUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
|
|
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
|
|
Result: result,
|
|
APIKey: apiKey,
|
|
User: apiKey.User,
|
|
Account: account,
|
|
Subscription: subscription,
|
|
InboundEndpoint: inboundEndpoint,
|
|
UpstreamEndpoint: upstreamEndpoint,
|
|
UserAgent: userAgent,
|
|
IPAddress: clientIP,
|
|
RequestPayloadHash: requestPayloadHash,
|
|
APIKeyService: h.apiKeyService,
|
|
QuotaPlatform: quotaPlatform,
|
|
SessionID: sessionID,
|
|
ChannelUsageFields: clientRequestedUsageFields(c, service.ChannelMappingResult{}, model, result.UpstreamModel),
|
|
}); err != nil {
|
|
logger.L().With(
|
|
zap.String("component", "handler.openai_gateway.grok_voice"),
|
|
zap.Int64("user_id", apiKey.User.ID),
|
|
zap.Int64("api_key_id", apiKey.ID),
|
|
zap.Any("group_id", apiKey.GroupID),
|
|
zap.String("endpoint", endpoint),
|
|
zap.Int64("account_id", account.ID),
|
|
).Error("grok_voice.record_usage_failed", zap.Error(err))
|
|
}
|
|
})
|
|
}
|
|
|
|
func readGrokVoiceGatewayBody(c *gin.Context) ([]byte, error) {
|
|
if c == nil || c.Request == nil || c.Request.Body == nil {
|
|
return nil, errors.New("request body is required")
|
|
}
|
|
return io.ReadAll(c.Request.Body)
|
|
}
|