Files
new-api/relay/common/relay_utils.go
T
Calcium-Ion c36418c863 feat: enhance text protocol conversion and advanced custom routing (#5825)
* refactor: consolidate relay protocol converters

* refactor relayconvert text converters

* feat: refine relay converters and advanced custom routing

* refactor: enhance logging and add thought signature handling for Gemini requests

* refactor: enhance channel cache and pricing endpoint handling for advanced custom models

* feat: preserve billing usage semantics

* feat: add protocol-aware billing usage

* Delete useless files

* chore: update action versions in workflow files

* chore: update Docker action versions in workflow files

* fix: harden billing usage settlement and hot-path route matching

- estimate Gemini completion tokens locally when billable usageMetadata is
  prompt-only but output content was received (e.g. client aborts the stream
  before the final chunk), and rebuild the attached billing_usage as estimated
  so settlement does not bill zero output tokens
- guard NewClaudeMessagesBillingUsage against all-zero ClaudeUsage, matching
  the OpenAI/Gemini constructors, so a zero billing_usage cannot override a
  non-zero top-level usage during settlement
- cache compiled advanced-custom route model regexes; they run on the request
  hot path and were recompiled per request
- move the effectiveBillingUsage remap to PostTextConsumeQuota only, and
  document that calculateTextQuotaSummary expects remapped usage
- document the updatePricingLock -> channelSyncLock lock ordering that
  InitChannelCache/CacheUpdateChannel rely on, and the aux-struct pitfall in
  GeminiChatResponse.UnmarshalJSON
2026-07-11 20:44:12 +08:00

314 lines
7.8 KiB
Go

package common
import (
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
type HasPrompt interface {
GetPrompt() string
}
type HasImage interface {
HasImage() bool
}
func GetFullRequestURL(baseURL string, requestURL string, channelType int) string {
fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL)
if strings.HasPrefix(baseURL, "https://gateway.ai.cloudflare.com") {
switch channelType {
case constant.ChannelTypeOpenAI:
fullRequestURL = fmt.Sprintf("%s%s", baseURL, strings.TrimPrefix(requestURL, "/v1"))
case constant.ChannelTypeAzure:
fullRequestURL = fmt.Sprintf("%s%s", baseURL, strings.TrimPrefix(requestURL, "/openai/deployments"))
}
}
return fullRequestURL
}
func SanitizeURLForLog(rawURL string) string {
if rawURL == "" {
return rawURL
}
parsedURL, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
query := parsedURL.Query()
if len(query) == 0 {
return rawURL
}
changed := false
for key := range query {
if isSensitiveURLQueryKey(key) {
query.Set(key, "***masked***")
changed = true
}
}
if !changed {
return rawURL
}
parsedURL.RawQuery = query.Encode()
return parsedURL.String()
}
func isSensitiveURLQueryKey(key string) bool {
normalized := strings.ToLower(strings.TrimSpace(key))
switch normalized {
case "key",
"api_key",
"api-key",
"apikey",
"x-api-key",
"access_token",
"refresh_token",
"id_token",
"token",
"authorization",
"auth",
"client_secret",
"secret",
"password",
"passwd",
"signature",
"sig",
"awsaccesskeyid",
"x-amz-credential",
"x-amz-security-token",
"x-amz-signature":
return true
}
return strings.Contains(normalized, "token") ||
strings.Contains(normalized, "secret") ||
strings.Contains(normalized, "signature")
}
func GetAPIVersion(c *gin.Context) string {
query := c.Request.URL.Query()
apiVersion := query.Get("api-version")
if apiVersion == "" {
apiVersion = c.GetString("api_version")
}
return apiVersion
}
func createTaskError(err error, code string, statusCode int, localError bool) *dto.TaskError {
return &dto.TaskError{
Code: code,
Message: err.Error(),
StatusCode: statusCode,
LocalError: localError,
Error: err,
}
}
func storeTaskRequest(c *gin.Context, info *RelayInfo, action string, requestObj TaskSubmitReq) {
info.Action = action
c.Set("task_request", requestObj)
}
func GetTaskRequest(c *gin.Context) (TaskSubmitReq, error) {
v, exists := c.Get("task_request")
if !exists {
return TaskSubmitReq{}, fmt.Errorf("request not found in context")
}
req, ok := v.(TaskSubmitReq)
if !ok {
return TaskSubmitReq{}, fmt.Errorf("invalid task request type")
}
return req, nil
}
func validatePrompt(prompt string) *dto.TaskError {
if strings.TrimSpace(prompt) == "" {
return createTaskError(fmt.Errorf("prompt is required"), "invalid_request", http.StatusBadRequest, true)
}
return nil
}
// MaxTaskDurationSeconds caps user-supplied video duration. Duration is used
// as a billing multiplier (OtherRatio "seconds"); an unbounded value could
// overflow quota calculation into a negative charge.
const MaxTaskDurationSeconds = 3600
func validateTaskDurationBounds(req TaskSubmitReq) *dto.TaskError {
seconds := req.Duration
if seconds == 0 && req.Seconds != "" {
seconds, _ = strconv.Atoi(req.Seconds)
}
if seconds < 0 || seconds > MaxTaskDurationSeconds {
return createTaskError(fmt.Errorf("seconds must be between 1 and %d", MaxTaskDurationSeconds), "invalid_seconds", http.StatusBadRequest, true)
}
return nil
}
func validateMultipartTaskRequest(c *gin.Context, info *RelayInfo, action string) (TaskSubmitReq, error) {
var req TaskSubmitReq
if _, err := c.MultipartForm(); err != nil {
return req, err
}
formData := c.Request.PostForm
req = TaskSubmitReq{
Prompt: formData.Get("prompt"),
Model: formData.Get("model"),
Mode: formData.Get("mode"),
Image: formData.Get("image"),
Size: formData.Get("size"),
Metadata: make(map[string]interface{}),
}
if durationStr := formData.Get("seconds"); durationStr != "" {
if duration, err := strconv.Atoi(durationStr); err == nil {
req.Duration = duration
}
}
if images := formData["images"]; len(images) > 0 {
req.Images = images
}
for key, values := range formData {
if len(values) > 0 && !isKnownTaskField(key) {
if intVal, err := strconv.Atoi(values[0]); err == nil {
req.Metadata[key] = intVal
} else if floatVal, err := strconv.ParseFloat(values[0], 64); err == nil {
req.Metadata[key] = floatVal
} else {
req.Metadata[key] = values[0]
}
}
}
return req, nil
}
func ValidateMultipartDirect(c *gin.Context, info *RelayInfo) *dto.TaskError {
var prompt string
var model string
var seconds int
var size string
var hasInputReference bool
var req TaskSubmitReq
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
return createTaskError(err, "invalid_json", http.StatusBadRequest, true)
}
prompt = req.Prompt
model = req.Model
size = req.Size
seconds, _ = strconv.Atoi(req.Seconds)
if seconds == 0 {
seconds = req.Duration
}
if req.InputReference != "" {
req.Images = []string{req.InputReference}
} else if len(req.Images) == 0 && strings.TrimSpace(req.Image) != "" {
// 兼容单图上传
req.Images = []string{strings.TrimSpace(req.Image)}
}
if strings.TrimSpace(req.Model) == "" {
return createTaskError(fmt.Errorf("model field is required"), "missing_model", http.StatusBadRequest, true)
}
if req.HasImage() {
hasInputReference = true
}
if taskErr := validatePrompt(prompt); taskErr != nil {
return taskErr
}
if taskErr := validateTaskDurationBounds(req); taskErr != nil {
return taskErr
}
action := constant.TaskActionTextGenerate
if hasInputReference {
action = constant.TaskActionGenerate
}
if strings.HasPrefix(model, "sora-2") {
if size == "" {
size = "720x1280"
}
if seconds <= 0 {
seconds = 4
}
if model == "sora-2" && !lo.Contains([]string{"720x1280", "1280x720"}, size) {
return createTaskError(fmt.Errorf("sora-2 size is invalid"), "invalid_size", http.StatusBadRequest, true)
}
if model == "sora-2-pro" && !lo.Contains([]string{"720x1280", "1280x720", "1792x1024", "1024x1792"}, size) {
return createTaskError(fmt.Errorf("sora-2 size is invalid"), "invalid_size", http.StatusBadRequest, true)
}
// OtherRatios 已移到 Sora adaptor 的 EstimateBilling 中设置
}
storeTaskRequest(c, info, action, req)
return nil
}
func isKnownTaskField(field string) bool {
knownFields := map[string]bool{
"prompt": true,
"model": true,
"mode": true,
"image": true,
"images": true,
"size": true,
"duration": true,
"input_reference": true, // Sora 特有字段
}
return knownFields[field]
}
func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *dto.TaskError {
var err error
contentType := c.GetHeader("Content-Type")
var req TaskSubmitReq
if strings.HasPrefix(contentType, "multipart/form-data") {
req, err = validateMultipartTaskRequest(c, info, action)
if err != nil {
return createTaskError(err, "invalid_multipart_form", http.StatusBadRequest, true)
}
}
// 为了metadata字段的兼容性,统一UnmarshalBodyReusable
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
return createTaskError(err, "invalid_request", http.StatusBadRequest, true)
}
if taskErr := validatePrompt(req.Prompt); taskErr != nil {
return taskErr
}
if taskErr := validateTaskDurationBounds(req); taskErr != nil {
return taskErr
}
if len(req.Images) == 0 && strings.TrimSpace(req.Image) != "" {
// 兼容单图上传
req.Images = []string{req.Image}
}
storeTaskRequest(c, info, action, req)
return nil
}