feat: publish v1.0.0-rc.36-custom.1
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 9m20s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 55m7s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 9m20s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 55m7s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
This commit is contained in:
commit
bb7968b612
2381 files changed
+551494
No files matched your search
@@ -0,0 +1,90 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func ResolveIncomingBillingExprRequestInput(c *gin.Context, info *relaycommon.RelayInfo) (billingexpr.RequestInput, error) {
|
||||
if info != nil && info.BillingRequestInput != nil {
|
||||
input := cloneRequestInput(*info.BillingRequestInput)
|
||||
merged := cloneStringMap(info.RequestHeaders)
|
||||
maps.Copy(merged, input.Headers)
|
||||
input.Headers = merged
|
||||
return input, nil
|
||||
}
|
||||
|
||||
input := billingexpr.RequestInput{}
|
||||
if info != nil {
|
||||
input.Headers = cloneStringMap(info.RequestHeaders)
|
||||
}
|
||||
|
||||
bodyBytes, err := readIncomingBillingExprBody(c)
|
||||
if err != nil {
|
||||
return billingexpr.RequestInput{}, err
|
||||
}
|
||||
input.Body = bodyBytes
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func BuildBillingExprRequestInputFromRequest(request dto.Request, headers map[string]string) (billingexpr.RequestInput, error) {
|
||||
input := billingexpr.RequestInput{
|
||||
Headers: cloneStringMap(headers),
|
||||
}
|
||||
if request == nil {
|
||||
return input, nil
|
||||
}
|
||||
|
||||
bodyBytes, err := common.Marshal(request)
|
||||
if err != nil {
|
||||
return billingexpr.RequestInput{}, err
|
||||
}
|
||||
input.Body = bodyBytes
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func readIncomingBillingExprBody(c *gin.Context) ([]byte, error) {
|
||||
if c == nil || c.Request == nil || !isJSONContentType(c.Request.Header.Get("Content-Type")) {
|
||||
return nil, nil
|
||||
}
|
||||
storage, err := common.GetBodyStorage(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return storage.Bytes()
|
||||
}
|
||||
|
||||
func cloneRequestInput(src billingexpr.RequestInput) billingexpr.RequestInput {
|
||||
input := billingexpr.RequestInput{
|
||||
Headers: cloneStringMap(src.Headers),
|
||||
}
|
||||
if len(src.Body) > 0 {
|
||||
input.Body = append([]byte(nil), src.Body...)
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func isJSONContentType(contentType string) bool {
|
||||
contentType = strings.ToLower(strings.TrimSpace(contentType))
|
||||
return strings.HasPrefix(contentType, "application/json")
|
||||
}
|
||||
|
||||
func cloneStringMap(src map[string]string) map[string]string {
|
||||
if len(src) == 0 {
|
||||
return map[string]string{}
|
||||
}
|
||||
dst := make(map[string]string, len(src))
|
||||
for key, value := range src {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
continue
|
||||
}
|
||||
dst[key] = value
|
||||
}
|
||||
return dst
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestResolveIncomingBillingExprRequestInput(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
body := []byte(`{"service_tier":"fast"}`)
|
||||
ctx.Request.Body = io.NopCloser(bytes.NewReader(body))
|
||||
ctx.Set(common.KeyRequestBody, body)
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
RequestHeaders: map[string]string{"Content-Type": "application/json"},
|
||||
}
|
||||
|
||||
input, err := ResolveIncomingBillingExprRequestInput(ctx, info)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, body, input.Body)
|
||||
require.Equal(t, "application/json", input.Headers["Content-Type"])
|
||||
}
|
||||
|
||||
func TestBuildBillingExprRequestInputFromRequest(t *testing.T) {
|
||||
request := &dto.GeneralOpenAIRequest{
|
||||
Model: "gemini-3.1-pro-preview",
|
||||
Stream: lo.ToPtr(true),
|
||||
Messages: []dto.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "hi",
|
||||
},
|
||||
},
|
||||
MaxTokens: lo.ToPtr(uint(3000)),
|
||||
}
|
||||
|
||||
input, err := BuildBillingExprRequestInputFromRequest(request, map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"X-Test": "1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "application/json", input.Headers["Content-Type"])
|
||||
require.Equal(t, "1", input.Headers["X-Test"])
|
||||
require.True(t, gjson.GetBytes(input.Body, "stream").Bool())
|
||||
require.Equal(t, "user", gjson.GetBytes(input.Body, "messages.0.role").String())
|
||||
require.Equal(t, float64(3000), gjson.GetBytes(input.Body, "max_tokens").Float())
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func FlushWriter(c *gin.Context) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("flush panic recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
if c == nil || c.Writer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if requestContextDone(c) {
|
||||
return fmt.Errorf("request context done: %w", c.Request.Context().Err())
|
||||
}
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
return errors.New("streaming error: flusher not found")
|
||||
}
|
||||
|
||||
flusher.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
func requestContextDone(c *gin.Context) bool {
|
||||
return c != nil && c.Request != nil && c.Request.Context().Err() != nil
|
||||
}
|
||||
|
||||
func SetEventStreamHeaders(c *gin.Context) {
|
||||
// 检查是否已经设置过头部
|
||||
if _, exists := c.Get("event_stream_headers_set"); exists {
|
||||
return
|
||||
}
|
||||
|
||||
// 设置标志,表示头部已经设置过
|
||||
c.Set("event_stream_headers_set", true)
|
||||
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Header().Set("Transfer-Encoding", "chunked")
|
||||
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||
}
|
||||
|
||||
func ClaudeData(c *gin.Context, resp dto.ClaudeResponse) error {
|
||||
if requestContextDone(c) {
|
||||
return nil
|
||||
}
|
||||
|
||||
jsonData, err := common.Marshal(resp)
|
||||
if err != nil {
|
||||
common.SysError("error marshalling stream response: " + err.Error())
|
||||
} else {
|
||||
c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("event: %s\n", resp.Type)})
|
||||
c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonData)})
|
||||
}
|
||||
_ = FlushWriter(c)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ClaudeChunkData(c *gin.Context, resp dto.ClaudeResponse, data string) {
|
||||
if requestContextDone(c) {
|
||||
return
|
||||
}
|
||||
|
||||
c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("event: %s\n", resp.Type)})
|
||||
c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("data: %s\n", data)})
|
||||
_ = FlushWriter(c)
|
||||
}
|
||||
|
||||
func ResponseChunkData(c *gin.Context, resp dto.ResponsesStreamResponse, data string) error {
|
||||
if requestContextDone(c) {
|
||||
return fmt.Errorf("request context done: %w", c.Request.Context().Err())
|
||||
}
|
||||
|
||||
c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("event: %s\n", resp.Type)})
|
||||
c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("data: %s", data)})
|
||||
return FlushWriter(c)
|
||||
}
|
||||
|
||||
func StringData(c *gin.Context, str string) error {
|
||||
if c == nil || c.Writer == nil {
|
||||
return errors.New("context or writer is nil")
|
||||
}
|
||||
|
||||
if requestContextDone(c) {
|
||||
return fmt.Errorf("request context done: %w", c.Request.Context().Err())
|
||||
}
|
||||
|
||||
c.Render(-1, common.CustomEvent{Data: "data: " + str})
|
||||
return FlushWriter(c)
|
||||
}
|
||||
|
||||
func PingData(c *gin.Context) error {
|
||||
if c == nil || c.Writer == nil {
|
||||
return errors.New("context or writer is nil")
|
||||
}
|
||||
|
||||
if requestContextDone(c) {
|
||||
return fmt.Errorf("request context done: %w", c.Request.Context().Err())
|
||||
}
|
||||
|
||||
if _, err := c.Writer.Write([]byte(": PING\n\n")); err != nil {
|
||||
return fmt.Errorf("write ping data failed: %w", err)
|
||||
}
|
||||
return FlushWriter(c)
|
||||
}
|
||||
|
||||
func ObjectData(c *gin.Context, object any) error {
|
||||
if object == nil {
|
||||
return errors.New("object is nil")
|
||||
}
|
||||
jsonData, err := common.Marshal(object)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshalling object: %w", err)
|
||||
}
|
||||
return StringData(c, string(jsonData))
|
||||
}
|
||||
|
||||
func Done(c *gin.Context) {
|
||||
_ = StringData(c, "[DONE]")
|
||||
}
|
||||
|
||||
func WssString(c *gin.Context, ws *websocket.Conn, str string) error {
|
||||
if ws == nil {
|
||||
logger.LogError(c, "websocket connection is nil")
|
||||
return errors.New("websocket connection is nil")
|
||||
}
|
||||
//common.LogInfo(c, fmt.Sprintf("sending message: %s", str))
|
||||
return ws.WriteMessage(1, []byte(str))
|
||||
}
|
||||
|
||||
func WssObject(c *gin.Context, ws *websocket.Conn, object any) error {
|
||||
jsonData, err := common.Marshal(object)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshalling object: %w", err)
|
||||
}
|
||||
if ws == nil {
|
||||
logger.LogError(c, "websocket connection is nil")
|
||||
return errors.New("websocket connection is nil")
|
||||
}
|
||||
//common.LogInfo(c, fmt.Sprintf("sending message: %s", jsonData))
|
||||
return ws.WriteMessage(1, jsonData)
|
||||
}
|
||||
|
||||
func WssError(c *gin.Context, ws *websocket.Conn, openaiError types.OpenAIError) {
|
||||
if ws == nil {
|
||||
return
|
||||
}
|
||||
errorObj := &dto.RealtimeEvent{
|
||||
Type: "error",
|
||||
EventId: GetLocalRealtimeID(c),
|
||||
Error: &openaiError,
|
||||
}
|
||||
_ = WssObject(c, ws, errorObj)
|
||||
}
|
||||
|
||||
func GetResponseID(c *gin.Context) string {
|
||||
logID := c.GetString(common.RequestIdKey)
|
||||
return fmt.Sprintf("chatcmpl-%s", logID)
|
||||
}
|
||||
|
||||
func GetLocalRealtimeID(c *gin.Context) string {
|
||||
logID := c.GetString(common.RequestIdKey)
|
||||
return fmt.Sprintf("evt_%s", logID)
|
||||
}
|
||||
|
||||
func GenerateStartEmptyResponse(id string, createAt int64, model string, systemFingerprint *string) *dto.ChatCompletionsStreamResponse {
|
||||
return &dto.ChatCompletionsStreamResponse{
|
||||
Id: id,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: createAt,
|
||||
Model: model,
|
||||
SystemFingerprint: systemFingerprint,
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||
{
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
|
||||
Role: "assistant",
|
||||
Content: common.GetPointer(""),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func GenerateStopResponse(id string, createAt int64, model string, finishReason string) *dto.ChatCompletionsStreamResponse {
|
||||
return &dto.ChatCompletionsStreamResponse{
|
||||
Id: id,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: createAt,
|
||||
Model: model,
|
||||
SystemFingerprint: nil,
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||
{
|
||||
FinishReason: &finishReason,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func GenerateFinalUsageResponse(id string, createAt int64, model string, usage dto.Usage) *dto.ChatCompletionsStreamResponse {
|
||||
return &dto.ChatCompletionsStreamResponse{
|
||||
Id: id,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: createAt,
|
||||
Model: model,
|
||||
SystemFingerprint: nil,
|
||||
Choices: make([]dto.ChatCompletionsStreamResponseChoice, 0),
|
||||
Usage: &usage,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/QuantumNous/new-api/setting/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGroupModelPricingPreservesOtherGroupsAndSnapshot(t *testing.T) {
|
||||
saved := map[string]string{}
|
||||
require.NoError(t, config.GlobalConfig.SaveToDB(func(k, v string) error { saved[k] = v; return nil }))
|
||||
t.Cleanup(func() { require.NoError(t, config.GlobalConfig.LoadFromDB(saved)) })
|
||||
require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{
|
||||
"billing_setting.billing_mode": `{"gpt-5.6-sol":"tiered_expr"}`,
|
||||
"billing_setting.billing_expr": `{"gpt-5.6-sol":"tier(\"standard\", p * 4 + c * 20 + cr * 0.4)"}`,
|
||||
"billing_setting.group_billing_expr": `{"example-premium":{"gpt-5.6-sol":"tier(\"standard\", p * 5 + c * 30 + cr * 0.5)"}}`,
|
||||
"group_ratio_setting.group_ratio": `{"example-premium":0.25,"example-standard":0.5}`,
|
||||
}))
|
||||
for _, tc := range []struct {
|
||||
group string
|
||||
expected int
|
||||
cost float64
|
||||
}{
|
||||
{"example-premium", 1000, 8500},
|
||||
{"example-standard", 1500, 6400},
|
||||
} {
|
||||
t.Run(tc.group, func(t *testing.T) {
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
ctx.Set("auto_group", tc.group)
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "gpt-5.6-sol", UserGroup: "default", UsingGroup: "auto",
|
||||
BillingRequestInput: &billingexpr.RequestInput{Body: []byte(`{}`)},
|
||||
}
|
||||
data, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{MaxTokens: 100})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expected, data.QuotaToPreConsume)
|
||||
require.Equal(t, tc.group, info.UsingGroup)
|
||||
raw, _, err := billingexpr.RunExprWithRequest(info.TieredBillingSnapshot.ExprString,
|
||||
billingexpr.TokenParams{P: 1000, C: 100, CR: 1000, Len: 2000}, billingexpr.RequestInput{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.cost, raw)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestMaxTokensBounds guards the billing invariant that user-supplied max
|
||||
// token fields are bounded on every relay format. These values feed
|
||||
// pre-consume quota math (preConsumedTokens * ratio); a huge or
|
||||
// wrapped-negative value (e.g. 18446744073686646784 parsed into *uint) must
|
||||
// be rejected at validation instead of corrupting the pre-charge.
|
||||
func TestMaxTokensBounds(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
newJSONContext := func(t *testing.T, body string) *gin.Context {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/relay", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
return c
|
||||
}
|
||||
|
||||
const hugeN = "18446744073686646784"
|
||||
|
||||
t.Run("openai max_tokens overflow rejected", func(t *testing.T) {
|
||||
c := newJSONContext(t, `{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}],"max_tokens":`+hugeN+`}`)
|
||||
_, err := GetAndValidateTextRequest(c, relayconstant.RelayModeChatCompletions)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "max_tokens is invalid")
|
||||
})
|
||||
|
||||
t.Run("openai max_completion_tokens overflow rejected", func(t *testing.T) {
|
||||
c := newJSONContext(t, `{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}],"max_completion_tokens":`+hugeN+`}`)
|
||||
_, err := GetAndValidateTextRequest(c, relayconstant.RelayModeChatCompletions)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "max_tokens is invalid")
|
||||
})
|
||||
|
||||
t.Run("claude max_tokens overflow rejected", func(t *testing.T) {
|
||||
c := newJSONContext(t, `{"model":"claude-sonnet-4","messages":[{"role":"user","content":"hi"}],"max_tokens":`+hugeN+`}`)
|
||||
_, err := GetAndValidateClaudeRequest(c)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "max_tokens is invalid")
|
||||
})
|
||||
|
||||
t.Run("claude normal max_tokens accepted", func(t *testing.T) {
|
||||
c := newJSONContext(t, `{"model":"claude-sonnet-4","messages":[{"role":"user","content":"hi"}],"max_tokens":8192}`)
|
||||
req, err := GetAndValidateClaudeRequest(c)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 8192, *req.MaxTokens)
|
||||
})
|
||||
|
||||
t.Run("gemini maxOutputTokens overflow rejected", func(t *testing.T) {
|
||||
c := newJSONContext(t, `{"contents":[{"parts":[{"text":"hi"}]}],"generationConfig":{"maxOutputTokens":`+hugeN+`}}`)
|
||||
_, err := GetAndValidateGeminiRequest(c)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "maxOutputTokens is invalid")
|
||||
})
|
||||
|
||||
t.Run("responses max_output_tokens overflow rejected", func(t *testing.T) {
|
||||
c := newJSONContext(t, `{"model":"gpt-4o","input":"hi","max_output_tokens":`+hugeN+`}`)
|
||||
_, err := GetAndValidateResponsesRequest(c)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "max_output_tokens is invalid")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
rootcommon "github.com/QuantumNous/new-api/common"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
hostreasoning "github.com/QuantumNous/new-api/setting/reasoning"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func ModelMappedHelper(c *gin.Context, info *relaycommon.RelayInfo, request dto.Request) error {
|
||||
if info.ChannelMeta == nil {
|
||||
info.ChannelMeta = &relaycommon.ChannelMeta{}
|
||||
}
|
||||
|
||||
// map model name
|
||||
modelMapping := c.GetString("model_mapping")
|
||||
if modelMapping != "" && modelMapping != "{}" {
|
||||
modelMap := make(map[string]string)
|
||||
err := rootcommon.Unmarshal([]byte(modelMapping), &modelMap)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unmarshal_model_mapping_failed")
|
||||
}
|
||||
|
||||
// 支持链式模型重定向,最终使用链尾的模型
|
||||
currentModel := info.OriginModelName
|
||||
visitedModels := map[string]bool{
|
||||
currentModel: true,
|
||||
}
|
||||
for {
|
||||
mappedModel, exists := modelMap[currentModel]
|
||||
baseModel := hostreasoning.BaseModelName(currentModel)
|
||||
if (!exists || mappedModel == "") && baseModel != currentModel {
|
||||
mappedModel, exists = modelMap[baseModel]
|
||||
}
|
||||
if exists && mappedModel != "" {
|
||||
// 模型重定向循环检测,避免无限循环
|
||||
if visitedModels[mappedModel] {
|
||||
if mappedModel == currentModel {
|
||||
if currentModel == info.OriginModelName {
|
||||
info.IsModelMapped = false
|
||||
return nil
|
||||
}
|
||||
|
||||
info.IsModelMapped = true
|
||||
break
|
||||
}
|
||||
return errors.New("model_mapping_contains_cycle")
|
||||
}
|
||||
visitedModels[mappedModel] = true
|
||||
currentModel = mappedModel
|
||||
info.IsModelMapped = true
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
if info.IsModelMapped {
|
||||
info.UpstreamModelName = currentModel
|
||||
}
|
||||
}
|
||||
|
||||
if request != nil {
|
||||
request.SetModelName(info.UpstreamModelName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
|
||||
type parsedModelModifiers struct {
|
||||
base string
|
||||
hasSyntax bool
|
||||
intent reasoning.Intent
|
||||
hasThinking bool
|
||||
temperature *float64
|
||||
topP *float64
|
||||
hasTemperature bool
|
||||
hasTopP bool
|
||||
diagnostics []types.ConversionDiagnostic
|
||||
}
|
||||
|
||||
const modelModifierExemptionHint = `If this segment is part of the real model name, add the model to the "Models that skip thinking suffix processing" setting (re: regex entries are supported)`
|
||||
|
||||
func modelModifierClientError(message string) error {
|
||||
return fmt.Errorf("%s. %s", message, modelModifierExemptionHint)
|
||||
}
|
||||
|
||||
func parseExplicitModelModifiers(modelName string) (parsedModelModifiers, error) {
|
||||
spec := reasoning.ParseModelModifiers(modelName)
|
||||
parsed := parsedModelModifiers{base: spec.Base, hasSyntax: spec.HasModifiers()}
|
||||
last := make(map[string]int, len(spec.Modifiers))
|
||||
|
||||
for index, modifier := range spec.Modifiers {
|
||||
if _, duplicate := last[modifier.Key]; duplicate {
|
||||
parsed.diagnostics = append(parsed.diagnostics, modelModifierDiagnostic(
|
||||
"duplicate_model_modifier",
|
||||
modifier.Key,
|
||||
fmt.Sprintf("model modifier %q is repeated; the rightmost value is used", modifier.Key),
|
||||
))
|
||||
}
|
||||
last[modifier.Key] = index
|
||||
}
|
||||
|
||||
for index, modifier := range spec.Modifiers {
|
||||
if last[modifier.Key] != index {
|
||||
continue
|
||||
}
|
||||
switch modifier.Key {
|
||||
case "thinking":
|
||||
intent, ok := reasoning.ParseThinkingModifier(modifier.Value)
|
||||
if !ok {
|
||||
return parsedModelModifiers{}, modelModifierClientError(
|
||||
fmt.Sprintf("invalid thinking modifier value %q", modifier.Value),
|
||||
)
|
||||
}
|
||||
if parsed.hasThinking && intent.Mode != reasoning.ModeDisabled && intent.BudgetTokens == nil {
|
||||
parsed.intent.Mode = intent.Mode
|
||||
parsed.intent.Source = reasoning.SourceSuffix
|
||||
} else if parsed.hasThinking && intent.Mode != reasoning.ModeDisabled {
|
||||
parsed.intent.Mode = intent.Mode
|
||||
parsed.intent.BudgetTokens = intent.BudgetTokens
|
||||
parsed.intent.BudgetSource = intent.BudgetSource
|
||||
parsed.intent.Source = reasoning.SourceSuffix
|
||||
} else {
|
||||
parsed.intent = intent
|
||||
}
|
||||
parsed.hasThinking = true
|
||||
case "effort":
|
||||
effort, err := reasoning.ParseEffort(modifier.Value)
|
||||
if err != nil || effort == "" {
|
||||
return parsedModelModifiers{}, modelModifierClientError(
|
||||
fmt.Sprintf("invalid effort modifier value %q: must be one of none/low/medium/high/xhigh/max", modifier.Value),
|
||||
)
|
||||
}
|
||||
if effort == reasoning.EffortNone {
|
||||
parsed.intent = reasoning.Intent{Mode: reasoning.ModeDisabled, Effort: reasoning.EffortNone, Source: reasoning.SourceSuffix}
|
||||
} else {
|
||||
if parsed.intent.Mode == reasoning.ModeUnset || parsed.intent.Mode == reasoning.ModeDisabled {
|
||||
parsed.intent.Mode = reasoning.ModeEnabled
|
||||
}
|
||||
parsed.intent.Effort = effort
|
||||
parsed.intent.Source = reasoning.SourceSuffix
|
||||
}
|
||||
parsed.hasThinking = true
|
||||
case "temperature":
|
||||
value, ok := parseFiniteFloat(modifier.Value)
|
||||
if !ok {
|
||||
return parsedModelModifiers{}, modelModifierClientError(
|
||||
fmt.Sprintf("invalid temperature modifier value %q: must be a finite number", modifier.Value),
|
||||
)
|
||||
}
|
||||
parsed.temperature = &value
|
||||
parsed.hasTemperature = true
|
||||
case "topp":
|
||||
value, ok := parseFiniteFloat(modifier.Value)
|
||||
if !ok {
|
||||
return parsedModelModifiers{}, modelModifierClientError(
|
||||
fmt.Sprintf("invalid topp modifier value %q: must be a finite number", modifier.Value),
|
||||
)
|
||||
}
|
||||
parsed.topP = &value
|
||||
parsed.hasTopP = true
|
||||
default:
|
||||
return parsedModelModifiers{}, modelModifierClientError(
|
||||
fmt.Sprintf("unsupported model modifier %q", modifier.Key),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func parseFiniteFloat(raw string) (float64, bool) {
|
||||
value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
||||
if err != nil || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func extractTemperature(req dto.Request) (float64, bool) {
|
||||
switch request := req.(type) {
|
||||
case *dto.GeneralOpenAIRequest:
|
||||
if request != nil && request.Temperature != nil {
|
||||
return *request.Temperature, true
|
||||
}
|
||||
case *dto.OpenAIResponsesRequest:
|
||||
if request != nil && request.Temperature != nil {
|
||||
return *request.Temperature, true
|
||||
}
|
||||
case *dto.ClaudeRequest:
|
||||
if request != nil && request.Temperature != nil {
|
||||
return *request.Temperature, true
|
||||
}
|
||||
case *dto.GeminiChatRequest:
|
||||
if request != nil && request.GenerationConfig.Temperature != nil {
|
||||
return *request.GenerationConfig.Temperature, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func extractTopP(req dto.Request) (float64, bool) {
|
||||
switch request := req.(type) {
|
||||
case *dto.GeneralOpenAIRequest:
|
||||
if request != nil && request.TopP != nil {
|
||||
return *request.TopP, true
|
||||
}
|
||||
case *dto.OpenAIResponsesRequest:
|
||||
if request != nil && request.TopP != nil {
|
||||
return *request.TopP, true
|
||||
}
|
||||
case *dto.ClaudeRequest:
|
||||
if request != nil && request.TopP != nil {
|
||||
return *request.TopP, true
|
||||
}
|
||||
case *dto.GeminiChatRequest:
|
||||
if request != nil && request.GenerationConfig.TopP != nil {
|
||||
return *request.GenerationConfig.TopP, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func modelModifierDiagnostic(code string, key string, message string) types.ConversionDiagnostic {
|
||||
return types.ConversionDiagnostic{
|
||||
Code: code,
|
||||
Path: "model.@" + key,
|
||||
Message: message,
|
||||
Severity: types.ConversionDiagnosticWarning,
|
||||
}
|
||||
}
|
||||
|
||||
func applyModelControls(req dto.Request, parsed parsedModelModifiers) error {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch request := req.(type) {
|
||||
case *dto.GeneralOpenAIRequest:
|
||||
if parsed.hasTemperature {
|
||||
request.Temperature = parsed.temperature
|
||||
}
|
||||
if parsed.hasTopP {
|
||||
request.TopP = parsed.topP
|
||||
}
|
||||
if parsed.hasThinking {
|
||||
request.ReasoningConversion = reasoning.StateFromIntent(parsed.intent)
|
||||
reasoningConfig := make(map[string]any)
|
||||
if len(request.Reasoning) > 0 {
|
||||
if common.GetJsonType(request.Reasoning) != "object" {
|
||||
return fmt.Errorf("OpenAI reasoning must be a JSON object")
|
||||
}
|
||||
if err := common.Unmarshal(request.Reasoning, &reasoningConfig); err != nil {
|
||||
return fmt.Errorf("invalid OpenAI reasoning config: %w", err)
|
||||
}
|
||||
}
|
||||
if parsed.intent.BudgetTokens != nil {
|
||||
reasoningConfig["enabled"] = parsed.intent.Mode != reasoning.ModeDisabled
|
||||
reasoningConfig["max_tokens"] = *parsed.intent.BudgetTokens
|
||||
delete(reasoningConfig, "effort")
|
||||
request.ReasoningEffort = ""
|
||||
} else {
|
||||
delete(reasoningConfig, "enabled")
|
||||
delete(reasoningConfig, "effort")
|
||||
delete(reasoningConfig, "max_tokens")
|
||||
request.ReasoningEffort = ""
|
||||
if parsed.intent.Effort != "" {
|
||||
request.ReasoningEffort = string(parsed.intent.Effort)
|
||||
}
|
||||
}
|
||||
if len(reasoningConfig) == 0 {
|
||||
request.Reasoning = nil
|
||||
} else {
|
||||
encoded, err := common.Marshal(reasoningConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Reasoning = encoded
|
||||
}
|
||||
}
|
||||
case *dto.OpenAIResponsesRequest:
|
||||
if parsed.hasTemperature {
|
||||
request.Temperature = parsed.temperature
|
||||
}
|
||||
if parsed.hasTopP {
|
||||
request.TopP = parsed.topP
|
||||
}
|
||||
if parsed.hasThinking {
|
||||
request.ReasoningConversion = reasoning.StateFromIntent(parsed.intent)
|
||||
if parsed.intent.Effort != "" {
|
||||
if request.Reasoning == nil {
|
||||
request.Reasoning = &dto.Reasoning{}
|
||||
}
|
||||
request.Reasoning.Effort = string(parsed.intent.Effort)
|
||||
} else if request.Reasoning != nil && parsed.intent.BudgetTokens == nil {
|
||||
request.Reasoning.Effort = ""
|
||||
}
|
||||
}
|
||||
case *dto.ClaudeRequest:
|
||||
if parsed.hasTemperature {
|
||||
request.Temperature = parsed.temperature
|
||||
}
|
||||
if parsed.hasTopP {
|
||||
request.TopP = parsed.topP
|
||||
}
|
||||
if parsed.hasThinking {
|
||||
request.Thinking = nil
|
||||
if len(request.OutputConfig) > 0 && common.GetJsonType(request.OutputConfig) == "object" {
|
||||
var output map[string]any
|
||||
if err := common.Unmarshal(request.OutputConfig, &output); err == nil {
|
||||
delete(output, "effort")
|
||||
encoded, err := common.Marshal(output)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.OutputConfig = encoded
|
||||
}
|
||||
}
|
||||
}
|
||||
case *dto.GeminiChatRequest:
|
||||
if parsed.hasTemperature {
|
||||
request.GenerationConfig.Temperature = parsed.temperature
|
||||
}
|
||||
if parsed.hasTopP {
|
||||
request.GenerationConfig.TopP = parsed.topP
|
||||
}
|
||||
if parsed.hasThinking {
|
||||
request.GenerationConfig.ThinkingConfig = nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestGetAndValidOpenAIImageRequestMultipartStream verifies multipart image
|
||||
// edit parsing: the stream field is parsed and validated, and the request body
|
||||
// stays replayable for the upstream request.
|
||||
func TestGetAndValidOpenAIImageRequestMultipartStream(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
newContext := func(t *testing.T, streamValue string, withImage bool) (*gin.Context, string) {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
require.NoError(t, writer.WriteField("model", "gpt-image-1"))
|
||||
require.NoError(t, writer.WriteField("prompt", "edit this image"))
|
||||
require.NoError(t, writer.WriteField("stream", streamValue))
|
||||
if withImage {
|
||||
part, err := writer.CreateFormFile("image", "input.png")
|
||||
require.NoError(t, err)
|
||||
_, err = part.Write([]byte("fake image"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, writer.Close())
|
||||
originalBody := body.String()
|
||||
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body)
|
||||
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
return c, originalBody
|
||||
}
|
||||
|
||||
t.Run("valid stream value keeps body replayable", func(t *testing.T) {
|
||||
c, originalBody := newContext(t, "true", true)
|
||||
|
||||
req, err := GetAndValidOpenAIImageRequest(c, relayconstant.RelayModeImagesEdits)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.Stream)
|
||||
require.True(t, *req.Stream)
|
||||
require.True(t, req.IsStream(c.Request))
|
||||
|
||||
bodyAfterValidation, err := io.ReadAll(c.Request.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, originalBody, string(bodyAfterValidation))
|
||||
|
||||
form, err := common.ParseMultipartFormReusable(c)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "true", url.Values(form.Value).Get("stream"))
|
||||
require.Len(t, form.File["image"], 1)
|
||||
})
|
||||
|
||||
t.Run("invalid stream value is rejected", func(t *testing.T) {
|
||||
c, _ := newContext(t, "notabool", false)
|
||||
|
||||
_, err := GetAndValidOpenAIImageRequest(c, relayconstant.RelayModeImagesEdits)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "invalid stream value")
|
||||
})
|
||||
}
|
||||
|
||||
// TestGetAndValidOpenAIImageRequestNBounds guards the billing invariant that
|
||||
// the image generation count can never reach quota calculation with a value
|
||||
// large enough to overflow int64 into a negative charge.
|
||||
func TestGetAndValidOpenAIImageRequestNBounds(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
newJSONContext := func(t *testing.T, body string) *gin.Context {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
return c
|
||||
}
|
||||
|
||||
boundErr := fmt.Sprintf("n must be an integer between 1 and %d", dto.MaxImageN)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantErr string
|
||||
wantN uint
|
||||
}{
|
||||
{
|
||||
name: "overflowed uint64 n is rejected",
|
||||
body: `{"model":"gpt-image-1","prompt":"a cat","n":18446744073686646784}`,
|
||||
wantErr: boundErr,
|
||||
},
|
||||
{
|
||||
name: "n above max is rejected",
|
||||
body: fmt.Sprintf(`{"model":"gpt-image-1","prompt":"a cat","n":%d}`, dto.MaxImageN+1),
|
||||
wantErr: boundErr,
|
||||
},
|
||||
{
|
||||
name: "n at max is accepted",
|
||||
body: fmt.Sprintf(`{"model":"gpt-image-1","prompt":"a cat","n":%d}`, dto.MaxImageN),
|
||||
wantN: dto.MaxImageN,
|
||||
},
|
||||
{
|
||||
name: "explicit n is accepted",
|
||||
body: `{"model":"gpt-image-1","prompt":"a cat","n":3}`,
|
||||
wantN: 3,
|
||||
},
|
||||
{
|
||||
name: "zero n defaults to 1",
|
||||
body: `{"model":"gpt-image-1","prompt":"a cat","n":0}`,
|
||||
wantN: 1,
|
||||
},
|
||||
{
|
||||
name: "absent n defaults to 1",
|
||||
body: `{"model":"gpt-image-1","prompt":"a cat"}`,
|
||||
wantN: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := newJSONContext(t, tt.body)
|
||||
req, err := GetAndValidOpenAIImageRequest(c, relayconstant.RelayModeImagesGenerations)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.N)
|
||||
require.Equal(t, tt.wantN, *req.N)
|
||||
require.Equal(t, float64(tt.wantN), req.GetTokenCountMeta().BillingRatios["n"])
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("negative multipart n is rejected", func(t *testing.T) {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
require.NoError(t, writer.WriteField("model", "gpt-image-1"))
|
||||
require.NoError(t, writer.WriteField("prompt", "edit this image"))
|
||||
require.NoError(t, writer.WriteField("n", "-22904832"))
|
||||
require.NoError(t, writer.Close())
|
||||
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body)
|
||||
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
_, err := GetAndValidOpenAIImageRequest(c, relayconstant.RelayModeImagesEdits)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), boundErr)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/QuantumNous/new-api/setting/billing_setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
hostreasoning "github.com/QuantumNous/new-api/setting/reasoning"
|
||||
hosttypes "github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func modelPriceNotConfiguredError(modelName string, userId int) error {
|
||||
if model.IsAdmin(userId) {
|
||||
return fmt.Errorf(
|
||||
"模型 %s 的价格未配置。请前往「系统设置 → 运营设置」开启自用模式,或在「系统设置 → 分组与模型定价设置」中为该模型配置价格;"+
|
||||
"Model %s price not configured. Go to System Settings → Operation Settings to enable self-use mode, or configure the model price in System Settings → Group & Model Pricing.",
|
||||
modelName, modelName,
|
||||
)
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"模型 %s 的价格尚未由管理员配置,暂时无法使用,请联系站点管理员开启该模型;"+
|
||||
"Model %s has not been priced by the administrator yet. Please contact the site administrator to enable this model.",
|
||||
modelName, modelName,
|
||||
)
|
||||
}
|
||||
|
||||
// https://docs.claude.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration
|
||||
const claudeCacheCreation1hMultiplier = 6 / 3.75
|
||||
|
||||
// defaultTieredPreConsumeMaxTokens is the fallback completion-token estimate
|
||||
// used for tiered expression pre-consume when the client omits max_tokens, so
|
||||
// the pre-consumed quota still reflects a plausible output cost in paid groups.
|
||||
const defaultTieredPreConsumeMaxTokens = 8192
|
||||
|
||||
// HandleGroupRatio checks for "auto_group" in the context and updates the group ratio and relayInfo.UsingGroup if present
|
||||
func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) hosttypes.GroupRatioInfo {
|
||||
groupRatioInfo := hosttypes.GroupRatioInfo{
|
||||
GroupRatio: 1.0, // default ratio
|
||||
GroupSpecialRatio: -1,
|
||||
}
|
||||
|
||||
// check auto group
|
||||
autoGroup, exists := ctx.Get("auto_group")
|
||||
if exists {
|
||||
logger.LogDebug(ctx, "final group: %s", autoGroup)
|
||||
relayInfo.UsingGroup = autoGroup.(string)
|
||||
}
|
||||
|
||||
// check user group special ratio
|
||||
userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup)
|
||||
if ok {
|
||||
// user group special ratio
|
||||
groupRatioInfo.GroupSpecialRatio = userGroupRatio
|
||||
groupRatioInfo.GroupRatio = userGroupRatio
|
||||
groupRatioInfo.HasSpecialRatio = true
|
||||
} else {
|
||||
// normal group ratio
|
||||
groupRatioInfo.GroupRatio = ratio_setting.GetGroupRatio(relayInfo.UsingGroup)
|
||||
}
|
||||
|
||||
return groupRatioInfo
|
||||
}
|
||||
|
||||
func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, meta *types.TokenCountMeta) (hosttypes.PriceData, error) {
|
||||
if info != nil {
|
||||
if matched := resolveBillingModelName(info.GetOriginModelName()); matched != "" && matched != info.OriginModelName {
|
||||
info.BillingModelName = matched
|
||||
}
|
||||
}
|
||||
billingModelName := info.GetBillingModelName()
|
||||
modelPrice, usePrice := ratio_setting.GetModelPrice(billingModelName, false)
|
||||
|
||||
groupRatioInfo := HandleGroupRatio(c, info)
|
||||
|
||||
// Check if this model uses tiered_expr billing
|
||||
_, hasGroupExpr := billing_setting.GetGroupBillingExpr(info.UsingGroup, billingModelName)
|
||||
if hasGroupExpr || billing_setting.GetBillingMode(billingModelName) == billing_setting.BillingModeTieredExpr {
|
||||
return modelPriceHelperTiered(c, info, billingModelName, promptTokens, meta, groupRatioInfo)
|
||||
}
|
||||
|
||||
var preConsumedQuota int
|
||||
var modelRatio float64
|
||||
var completionRatio float64
|
||||
var cacheRatio float64
|
||||
var imageRatio float64
|
||||
var cacheCreationRatio float64
|
||||
var cacheCreationRatio5m float64
|
||||
var cacheCreationRatio1h float64
|
||||
var audioRatio float64
|
||||
var audioCompletionRatio float64
|
||||
var freeModel bool
|
||||
if !usePrice {
|
||||
preConsumedTokens := common.Max(promptTokens, common.PreConsumedQuota)
|
||||
if meta.MaxTokens != 0 {
|
||||
preConsumedTokens += meta.MaxTokens
|
||||
}
|
||||
var success bool
|
||||
var matchName string
|
||||
modelRatio, success, matchName = ratio_setting.GetModelRatio(billingModelName)
|
||||
if !success {
|
||||
acceptUnsetRatio := false
|
||||
if info.UserSetting.AcceptUnsetRatioModel {
|
||||
acceptUnsetRatio = true
|
||||
}
|
||||
if !acceptUnsetRatio {
|
||||
return hosttypes.PriceData{}, modelPriceNotConfiguredError(matchName, info.UserId)
|
||||
}
|
||||
}
|
||||
completionRatio = ratio_setting.GetCompletionRatio(billingModelName)
|
||||
cacheRatio, _ = ratio_setting.GetCacheRatio(billingModelName)
|
||||
cacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(billingModelName)
|
||||
cacheCreationRatio5m = cacheCreationRatio
|
||||
// 固定1h和5min缓存写入价格的比例
|
||||
cacheCreationRatio1h = cacheCreationRatio * claudeCacheCreation1hMultiplier
|
||||
imageRatio, _ = ratio_setting.GetImageRatio(billingModelName)
|
||||
audioRatio = ratio_setting.GetAudioRatio(billingModelName)
|
||||
audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(billingModelName)
|
||||
ratio := modelRatio * groupRatioInfo.GroupRatio
|
||||
quota, err := common.QuotaFromFloatStrict(float64(preConsumedTokens) * ratio)
|
||||
if err != nil {
|
||||
return hosttypes.PriceData{}, err
|
||||
}
|
||||
preConsumedQuota = quota
|
||||
} else {
|
||||
if meta.ImagePriceRatio != 0 {
|
||||
modelPrice = modelPrice * meta.ImagePriceRatio
|
||||
}
|
||||
}
|
||||
|
||||
// check if free model pre-consume is disabled
|
||||
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
|
||||
// if model price or ratio is 0, do not pre-consume quota
|
||||
if groupRatioInfo.GroupRatio == 0 {
|
||||
preConsumedQuota = 0
|
||||
freeModel = true
|
||||
} else if usePrice {
|
||||
if modelPrice == 0 {
|
||||
preConsumedQuota = 0
|
||||
freeModel = true
|
||||
}
|
||||
} else {
|
||||
if modelRatio == 0 {
|
||||
preConsumedQuota = 0
|
||||
freeModel = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
priceData := hosttypes.PriceData{
|
||||
FreeModel: freeModel,
|
||||
ModelPrice: modelPrice,
|
||||
ModelRatio: modelRatio,
|
||||
CompletionRatio: completionRatio,
|
||||
GroupRatioInfo: groupRatioInfo,
|
||||
UsePrice: usePrice,
|
||||
CacheRatio: cacheRatio,
|
||||
ImageRatio: imageRatio,
|
||||
AudioRatio: audioRatio,
|
||||
AudioCompletionRatio: audioCompletionRatio,
|
||||
CacheCreationRatio: cacheCreationRatio,
|
||||
CacheCreation5mRatio: cacheCreationRatio5m,
|
||||
CacheCreation1hRatio: cacheCreationRatio1h,
|
||||
QuotaToPreConsume: preConsumedQuota,
|
||||
}
|
||||
if usePrice {
|
||||
for name, ratio := range meta.BillingRatios {
|
||||
priceData.AddOtherRatio(name, ratio)
|
||||
}
|
||||
quotaToPreConsume := priceData.ApplyOtherRatiosToFloat(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
|
||||
quota, err := common.QuotaFromFloatStrict(quotaToPreConsume)
|
||||
if err != nil {
|
||||
return hosttypes.PriceData{}, err
|
||||
}
|
||||
priceData.QuotaToPreConsume = quota
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
logger.LogDebug(c, "model_price_helper result: %s", priceData.ToSetting())
|
||||
}
|
||||
info.PriceData = priceData
|
||||
return priceData, nil
|
||||
}
|
||||
|
||||
// ModelPriceHelperPerCall 按次/按量计费的 PriceHelper (MJ、Task)
|
||||
func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (hosttypes.PriceData, error) {
|
||||
groupRatioInfo := HandleGroupRatio(c, info)
|
||||
|
||||
modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true)
|
||||
usePrice := success
|
||||
var modelRatio float64
|
||||
|
||||
if !success {
|
||||
defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName]
|
||||
if ok {
|
||||
modelPrice = defaultPrice
|
||||
usePrice = true
|
||||
} else {
|
||||
var ratioSuccess bool
|
||||
var matchName string
|
||||
modelRatio, ratioSuccess, matchName = ratio_setting.GetModelRatio(info.OriginModelName)
|
||||
acceptUnsetRatio := false
|
||||
if info.UserSetting.AcceptUnsetRatioModel {
|
||||
acceptUnsetRatio = true
|
||||
}
|
||||
if !ratioSuccess && !acceptUnsetRatio {
|
||||
return hosttypes.PriceData{}, modelPriceNotConfiguredError(matchName, info.UserId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var quota int
|
||||
freeModel := false
|
||||
|
||||
if usePrice {
|
||||
var err error
|
||||
quota, err = common.QuotaFromFloatStrict(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
|
||||
if err != nil {
|
||||
return hosttypes.PriceData{}, err
|
||||
}
|
||||
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
|
||||
if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 {
|
||||
quota = 0
|
||||
freeModel = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 按量计费:以模型倍率的一半作为预扣额度
|
||||
var err error
|
||||
quota, err = common.QuotaFromFloatStrict(modelRatio / 2 * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
|
||||
if err != nil {
|
||||
return hosttypes.PriceData{}, err
|
||||
}
|
||||
modelPrice = -1
|
||||
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
|
||||
if groupRatioInfo.GroupRatio == 0 || modelRatio == 0 {
|
||||
quota = 0
|
||||
freeModel = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
priceData := hosttypes.PriceData{
|
||||
FreeModel: freeModel,
|
||||
ModelPrice: modelPrice,
|
||||
ModelRatio: modelRatio,
|
||||
UsePrice: usePrice,
|
||||
Quota: quota,
|
||||
GroupRatioInfo: groupRatioInfo,
|
||||
}
|
||||
return priceData, nil
|
||||
}
|
||||
|
||||
func HasModelBillingConfig(modelName string) bool {
|
||||
if _, ok := ratio_setting.GetModelPrice(modelName, false); ok {
|
||||
return true
|
||||
}
|
||||
if _, ok, _ := ratio_setting.GetModelRatio(modelName); ok {
|
||||
return true
|
||||
}
|
||||
if billing_setting.GetBillingMode(modelName) != billing_setting.BillingModeTieredExpr {
|
||||
return false
|
||||
}
|
||||
expr, ok := billing_setting.GetBillingExpr(modelName)
|
||||
return ok && strings.TrimSpace(expr) != ""
|
||||
}
|
||||
|
||||
// HasPriceOrRatioEntry reports whether name has a configured price, ratio, or
|
||||
// tiered billing-mode entry after a single wildcard normalization. Self-use
|
||||
// fallback does not count as a configured ratio.
|
||||
func HasPriceOrRatioEntry(name string) bool {
|
||||
formatted := ratio_setting.FormatMatchingModelName(name)
|
||||
if _, ok := ratio_setting.GetModelPrice(formatted, false); ok {
|
||||
return true
|
||||
}
|
||||
if ratio_setting.HasConfiguredModelRatio(formatted) {
|
||||
return true
|
||||
}
|
||||
return billing_setting.GetBillingMode(formatted) == billing_setting.BillingModeTieredExpr
|
||||
}
|
||||
|
||||
func resolveBillingModelName(origin string) string {
|
||||
var candidates []string
|
||||
if !reasoning.ParseModelModifiers(origin).HasModifiers() {
|
||||
candidates = append(candidates, origin)
|
||||
}
|
||||
candidates = append(candidates, hostreasoning.CanonicalBillingModelNames(origin)...)
|
||||
base := hostreasoning.BaseModelName(origin)
|
||||
candidates = append(candidates, base)
|
||||
|
||||
seen := make(map[string]struct{}, len(candidates))
|
||||
matched := ""
|
||||
for _, name := range candidates {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[name]; ok {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
if HasPriceOrRatioEntry(name) {
|
||||
matched = name
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched == "" {
|
||||
matched = base
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, billingModelName string, promptTokens int, meta *types.TokenCountMeta, groupRatioInfo hosttypes.GroupRatioInfo) (hosttypes.PriceData, error) {
|
||||
exprStr, ok := billing_setting.GetBillingExprForGroup(info.UsingGroup, billingModelName)
|
||||
if !ok {
|
||||
return hosttypes.PriceData{}, fmt.Errorf("model %s is configured as tiered_expr but has no billing expression", billingModelName)
|
||||
}
|
||||
|
||||
estimatedCompletionTokens := meta.MaxTokens
|
||||
if estimatedCompletionTokens == 0 && groupRatioInfo.GroupRatio != 0 {
|
||||
estimatedCompletionTokens = defaultTieredPreConsumeMaxTokens
|
||||
}
|
||||
|
||||
requestInput, err := ResolveIncomingBillingExprRequestInput(c, info)
|
||||
if err != nil {
|
||||
return hosttypes.PriceData{}, err
|
||||
}
|
||||
|
||||
rawCost, trace, err := billingexpr.RunExprWithRequest(exprStr, billingexpr.TokenParams{
|
||||
P: float64(promptTokens),
|
||||
C: float64(estimatedCompletionTokens),
|
||||
Len: float64(promptTokens),
|
||||
}, requestInput)
|
||||
if err != nil {
|
||||
return hosttypes.PriceData{}, fmt.Errorf("model %s tiered expr run failed: %w", billingModelName, err)
|
||||
}
|
||||
|
||||
// Expression coefficients are $/1M tokens prices; convert to quota the same way per-call billing does.
|
||||
quotaBeforeGroup := rawCost / 1_000_000 * common.QuotaPerUnit
|
||||
preConsumedQuota, err := billingexpr.QuotaRoundStrict(quotaBeforeGroup * groupRatioInfo.GroupRatio)
|
||||
if err != nil {
|
||||
return hosttypes.PriceData{}, err
|
||||
}
|
||||
|
||||
freeModel := false
|
||||
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
|
||||
if groupRatioInfo.GroupRatio == 0 {
|
||||
preConsumedQuota = 0
|
||||
freeModel = true
|
||||
}
|
||||
}
|
||||
|
||||
exprHash := billingexpr.ExprHashString(exprStr)
|
||||
snapshot := &billingexpr.BillingSnapshot{
|
||||
BillingMode: billing_setting.BillingModeTieredExpr,
|
||||
ModelName: billingModelName,
|
||||
ExprString: exprStr,
|
||||
ExprHash: exprHash,
|
||||
GroupRatio: groupRatioInfo.GroupRatio,
|
||||
EstimatedPromptTokens: promptTokens,
|
||||
EstimatedCompletionTokens: estimatedCompletionTokens,
|
||||
EstimatedQuotaBeforeGroup: quotaBeforeGroup,
|
||||
EstimatedQuotaAfterGroup: preConsumedQuota,
|
||||
EstimatedTier: trace.MatchedTier,
|
||||
QuotaPerUnit: common.QuotaPerUnit,
|
||||
ExprVersion: billingexpr.ExprVersion(exprStr),
|
||||
}
|
||||
info.TieredBillingSnapshot = snapshot
|
||||
info.BillingRequestInput = &requestInput
|
||||
|
||||
priceData := hosttypes.PriceData{
|
||||
FreeModel: freeModel,
|
||||
GroupRatioInfo: groupRatioInfo,
|
||||
QuotaToPreConsume: preConsumedQuota,
|
||||
}
|
||||
|
||||
logger.LogDebug(c, "model_price_helper_tiered result: model=%s preConsume=%d quotaBeforeGroup=%.2f groupRatio=%.2f tier=%s", billingModelName, preConsumedQuota, quotaBeforeGroup, groupRatioInfo.GroupRatio, trace.MatchedTier)
|
||||
|
||||
info.PriceData = priceData
|
||||
return priceData, nil
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/QuantumNous/new-api/setting/billing_setting"
|
||||
"github.com/QuantumNous/new-api/setting/config"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestModelPriceHelperTieredUsesPreloadedRequestInput(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
saved := map[string]string{}
|
||||
require.NoError(t, config.GlobalConfig.SaveToDB(func(key, value string) error {
|
||||
saved[key] = value
|
||||
return nil
|
||||
}))
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, config.GlobalConfig.LoadFromDB(saved))
|
||||
})
|
||||
|
||||
require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{
|
||||
"billing_setting.billing_mode": `{"tiered-test-model":"tiered_expr"}`,
|
||||
"billing_setting.billing_expr": `{"tiered-test-model":"param(\"stream\") == true ? tier(\"stream\", p * 3) : tier(\"base\", p * 2)"}`,
|
||||
}))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/channel/test/1", nil)
|
||||
req.Body = nil
|
||||
req.ContentLength = 0
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
ctx.Request = req
|
||||
ctx.Set("group", "default")
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "tiered-test-model",
|
||||
UserGroup: "default",
|
||||
UsingGroup: "default",
|
||||
RequestHeaders: map[string]string{"Content-Type": "application/json"},
|
||||
BillingRequestInput: &billingexpr.RequestInput{
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
Body: []byte(`{"stream":true}`),
|
||||
},
|
||||
}
|
||||
|
||||
priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{
|
||||
BillingRatios: map[string]float64{"n": 3},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1500, priceData.QuotaToPreConsume)
|
||||
require.NotNil(t, info.TieredBillingSnapshot)
|
||||
require.Equal(t, "stream", info.TieredBillingSnapshot.EstimatedTier)
|
||||
require.Equal(t, billing_setting.BillingModeTieredExpr, info.TieredBillingSnapshot.BillingMode)
|
||||
require.Equal(t, common.QuotaPerUnit, info.TieredBillingSnapshot.QuotaPerUnit)
|
||||
}
|
||||
|
||||
func TestModelPriceHelperTieredPreConsumeMaxTokensFallback(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
saved := map[string]string{}
|
||||
require.NoError(t, config.GlobalConfig.SaveToDB(func(key, value string) error {
|
||||
saved[key] = value
|
||||
return nil
|
||||
}))
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, config.GlobalConfig.LoadFromDB(saved))
|
||||
})
|
||||
|
||||
require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{
|
||||
"billing_setting.billing_mode": `{"tiered-fallback-model":"tiered_expr"}`,
|
||||
"billing_setting.billing_expr": `{"tiered-fallback-model":"tier(\"base\", p * 3 + c * 15)"}`,
|
||||
"group_ratio_setting.group_ratio": `{"default":1,"free":0}`,
|
||||
}))
|
||||
|
||||
const promptTokens = 1000
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
group string
|
||||
maxTokens int
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
// max_tokens omitted in a paid group -> fall back to 8192 completion tokens.
|
||||
// p*3 + c*15 = 1000*3 + 8192*15 = 125880 -> /1e6 * 500000 = 62940
|
||||
name: "non-free group falls back to 8192 completion tokens",
|
||||
group: "default",
|
||||
maxTokens: 0,
|
||||
expected: 62940,
|
||||
},
|
||||
{
|
||||
// explicit max_tokens is used verbatim, no fallback.
|
||||
// 1000*3 + 100*15 = 4500 -> /1e6 * 500000 = 2250
|
||||
name: "explicit max_tokens is used verbatim",
|
||||
group: "default",
|
||||
maxTokens: 100,
|
||||
expected: 2250,
|
||||
},
|
||||
{
|
||||
// free group (ratio 0) stays zero; fallback is gated on non-zero group ratio.
|
||||
name: "free group stays zero without fallback",
|
||||
group: "free",
|
||||
maxTokens: 0,
|
||||
expected: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
ctx.Request = req
|
||||
ctx.Set("group", tc.group)
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "tiered-fallback-model",
|
||||
UserGroup: tc.group,
|
||||
UsingGroup: tc.group,
|
||||
RequestHeaders: map[string]string{"Content-Type": "application/json"},
|
||||
BillingRequestInput: &billingexpr.RequestInput{
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
Body: []byte(`{}`),
|
||||
},
|
||||
}
|
||||
|
||||
priceData, err := ModelPriceHelper(ctx, info, promptTokens, &types.TokenCountMeta{MaxTokens: tc.maxTokens})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expected, priceData.QuotaToPreConsume)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelPriceHelperTieredRejectsPreConsumeOverflow(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
saved := map[string]string{}
|
||||
require.NoError(t, config.GlobalConfig.SaveToDB(func(key, value string) error {
|
||||
saved[key] = value
|
||||
return nil
|
||||
}))
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, config.GlobalConfig.LoadFromDB(saved))
|
||||
})
|
||||
|
||||
require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{
|
||||
"billing_setting.billing_mode": `{"tiered-overflow-model":"tiered_expr"}`,
|
||||
"billing_setting.billing_expr": `{"tiered-overflow-model":"tier(\"overflow\", p * 100000000000000000)"}`,
|
||||
"group_ratio_setting.group_ratio": `{"default":1}`,
|
||||
}))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
ctx.Set("group", "default")
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "tiered-overflow-model",
|
||||
UserGroup: "default",
|
||||
UsingGroup: "default",
|
||||
BillingRequestInput: &billingexpr.RequestInput{
|
||||
Body: []byte(`{}`),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
|
||||
|
||||
var clamp *common.QuotaClamp
|
||||
require.ErrorAs(t, err, &clamp)
|
||||
require.Equal(t, "QuotaRound", clamp.Op)
|
||||
require.Equal(t, common.QuotaClampOverflow, clamp.Kind)
|
||||
}
|
||||
|
||||
func TestModelPriceHelperRequestBillingRatiosOnlyApplyToFixedPrice(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
savedModelPrices := ratio_setting.ModelPrice2JSONString()
|
||||
savedModelRatios := ratio_setting.ModelRatio2JSONString()
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(savedModelPrices))
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedModelRatios))
|
||||
})
|
||||
|
||||
modelPrices, err := common.Marshal(map[string]float64{
|
||||
"fixed-image-price": 0.04,
|
||||
"fractional-image-price": 0.0000012,
|
||||
"overflow-image-price": float64(common.MaxQuota) / common.QuotaPerUnit / 2,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(string(modelPrices)))
|
||||
modelRatios, err := common.Marshal(map[string]float64{"ratio-image-price": 15})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(modelRatios)))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
wantQuota int
|
||||
wantUsePrice bool
|
||||
wantImageCount bool
|
||||
}{
|
||||
{
|
||||
name: "fixed price applies image count",
|
||||
model: "fixed-image-price",
|
||||
wantQuota: 180000,
|
||||
wantUsePrice: true,
|
||||
wantImageCount: true,
|
||||
},
|
||||
{
|
||||
name: "ratio price ignores request billing ratios",
|
||||
model: "ratio-image-price",
|
||||
wantQuota: 15000,
|
||||
wantUsePrice: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Set("group", "default")
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: tt.model,
|
||||
UserGroup: "default",
|
||||
UsingGroup: "default",
|
||||
}
|
||||
meta := &types.TokenCountMeta{
|
||||
ImagePriceRatio: 3,
|
||||
BillingRatios: map[string]float64{"n": 3},
|
||||
}
|
||||
|
||||
priceData, err := ModelPriceHelper(ctx, info, 1000, meta)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantQuota, priceData.QuotaToPreConsume)
|
||||
require.Equal(t, tt.wantUsePrice, priceData.UsePrice)
|
||||
require.Equal(t, tt.wantImageCount, priceData.HasOtherRatio("n"))
|
||||
require.Equal(t, priceData.OtherRatios(), info.PriceData.OtherRatios())
|
||||
})
|
||||
}
|
||||
|
||||
newInfo := func(model string) (*gin.Context, *relaycommon.RelayInfo) {
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Set("group", "default")
|
||||
return ctx, &relaycommon.RelayInfo{
|
||||
OriginModelName: model,
|
||||
UserGroup: "default",
|
||||
UsingGroup: "default",
|
||||
}
|
||||
}
|
||||
meta := &types.TokenCountMeta{BillingRatios: map[string]float64{"n": 3}}
|
||||
|
||||
ctx, info := newInfo("fractional-image-price")
|
||||
priceData, err := ModelPriceHelper(ctx, info, 0, meta)
|
||||
require.NoError(t, err)
|
||||
// 0.0000012 * 500000 * 3 = 1.8, then truncate once to 1.
|
||||
require.Equal(t, 1, priceData.QuotaToPreConsume)
|
||||
|
||||
ctx, info = newInfo("overflow-image-price")
|
||||
_, err = ModelPriceHelper(ctx, info, 0, meta)
|
||||
var clamp *common.QuotaClamp
|
||||
require.ErrorAs(t, err, &clamp)
|
||||
require.Equal(t, "QuotaFromFloat", clamp.Op)
|
||||
require.Equal(t, common.QuotaClampOverflow, clamp.Kind)
|
||||
require.Nil(t, info.Billing)
|
||||
}
|
||||
|
||||
// Pricing identity is resolved once in ModelPriceHelper via the candidate
|
||||
// ladder: raw name (only when it has no @ modifiers) → canonical
|
||||
// base@effort:E@thinking:S → base@thinking:S → base. Each level is looked up
|
||||
// after FormatMatchingModelName wildcard normalization. A hit on the raw
|
||||
// gemini-2.5-flash-thinking-* wildcard must keep the client origin as the
|
||||
// consume-log name.
|
||||
func TestModelPriceHelperUsesSuffixedOriginLikeMain(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
savedRatios := ratio_setting.ModelRatio2JSONString()
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedRatios))
|
||||
})
|
||||
ratios := ratio_setting.GetModelRatioCopy()
|
||||
ratios["gemini-2.5-flash"] = 0.15
|
||||
ratios["gemini-2.5-flash-thinking-*"] = 0.075
|
||||
ratioJSON, err := common.Marshal(ratios)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
|
||||
|
||||
oldSelfUse := operation_setting.SelfUseModeEnabled
|
||||
operation_setting.SelfUseModeEnabled = true
|
||||
t.Cleanup(func() { operation_setting.SelfUseModeEnabled = oldSelfUse })
|
||||
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Set("group", "default")
|
||||
|
||||
suffixed := &relaycommon.RelayInfo{
|
||||
OriginModelName: "gemini-2.5-flash-thinking-8192",
|
||||
UserGroup: "default",
|
||||
UsingGroup: "default",
|
||||
}
|
||||
suffixedPrice, err := ModelPriceHelper(ctx, suffixed, 1000, &types.TokenCountMeta{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, suffixed.BillingModelName)
|
||||
assert.Equal(t, "gemini-2.5-flash-thinking-8192", suffixed.GetBillingModelName())
|
||||
assert.Equal(t, 0.075, suffixedPrice.ModelRatio)
|
||||
|
||||
geminiSettings := model_setting.GetGeminiSettings()
|
||||
oldThinking := geminiSettings.ThinkingAdapterEnabled
|
||||
geminiSettings.ThinkingAdapterEnabled = true
|
||||
t.Cleanup(func() { geminiSettings.ThinkingAdapterEnabled = oldThinking })
|
||||
|
||||
adapterOn := &relaycommon.RelayInfo{
|
||||
OriginModelName: "gemini-2.5-flash-thinking-8192",
|
||||
UserGroup: "default",
|
||||
UsingGroup: "default",
|
||||
}
|
||||
adapterOnPrice, err := ModelPriceHelper(ctx, adapterOn, 1000, &types.TokenCountMeta{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, adapterOn.BillingModelName)
|
||||
assert.Equal(t, "gemini-2.5-flash-thinking-8192", adapterOn.GetBillingModelName())
|
||||
assert.Equal(t, 0.075, adapterOnPrice.ModelRatio)
|
||||
|
||||
base := &relaycommon.RelayInfo{
|
||||
OriginModelName: "gemini-2.5-flash",
|
||||
UserGroup: "default",
|
||||
UsingGroup: "default",
|
||||
}
|
||||
basePrice, err := ModelPriceHelper(ctx, base, 1000, &types.TokenCountMeta{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, base.BillingModelName)
|
||||
assert.Equal(t, "gemini-2.5-flash", base.GetBillingModelName())
|
||||
assert.Equal(t, 0.15, basePrice.ModelRatio)
|
||||
}
|
||||
|
||||
func TestModelPriceHelperHonorsCustomClaudeThinkingAlias(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
savedRatios := ratio_setting.ModelRatio2JSONString()
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedRatios))
|
||||
})
|
||||
ratios := ratio_setting.GetModelRatioCopy()
|
||||
ratios["claude-3-7-sonnet"] = 1.5
|
||||
ratios["claude-3-7-sonnet-thinking"] = 3.0
|
||||
ratioJSON, err := common.Marshal(ratios)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
|
||||
|
||||
oldSelfUse := operation_setting.SelfUseModeEnabled
|
||||
operation_setting.SelfUseModeEnabled = false
|
||||
t.Cleanup(func() { operation_setting.SelfUseModeEnabled = oldSelfUse })
|
||||
|
||||
claudeSettings := model_setting.GetClaudeSettings()
|
||||
oldThinking := claudeSettings.ThinkingAdapterEnabled
|
||||
claudeSettings.ThinkingAdapterEnabled = true
|
||||
t.Cleanup(func() { claudeSettings.ThinkingAdapterEnabled = oldThinking })
|
||||
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Set("group", "default")
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "claude-3-7-sonnet-thinking",
|
||||
UserGroup: "default",
|
||||
UsingGroup: "default",
|
||||
}
|
||||
priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, info.BillingModelName)
|
||||
assert.Equal(t, "claude-3-7-sonnet-thinking", info.GetBillingModelName())
|
||||
assert.Equal(t, 3.0, priceData.ModelRatio)
|
||||
}
|
||||
|
||||
func TestModelPriceHelperCanonicalBillingLadder(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
savedRatios := ratio_setting.ModelRatio2JSONString()
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedRatios))
|
||||
})
|
||||
oldSelfUse := operation_setting.SelfUseModeEnabled
|
||||
operation_setting.SelfUseModeEnabled = false
|
||||
t.Cleanup(func() { operation_setting.SelfUseModeEnabled = oldSelfUse })
|
||||
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Set("group", "default")
|
||||
|
||||
t.Run("level2 full form", func(t *testing.T) {
|
||||
ratios := ratio_setting.GetModelRatioCopy()
|
||||
delete(ratios, "qwen3-max")
|
||||
ratios["qwen3-max@effort:high@thinking:on"] = 4.0
|
||||
ratios["qwen3-max@thinking:on"] = 3.0
|
||||
ratioJSON, err := common.Marshal(ratios)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "qwen3-max@thinking:on@effort:high@temperature:0.2",
|
||||
UserGroup: "default",
|
||||
UsingGroup: "default",
|
||||
}
|
||||
priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "qwen3-max@effort:high@thinking:on", info.BillingModelName)
|
||||
assert.Equal(t, 4.0, priceData.ModelRatio)
|
||||
})
|
||||
|
||||
t.Run("level3 thinking form shuffled budget", func(t *testing.T) {
|
||||
ratios := ratio_setting.GetModelRatioCopy()
|
||||
delete(ratios, "qwen3-max")
|
||||
delete(ratios, "qwen3-max@effort:high@thinking:on")
|
||||
ratios["qwen3-max@thinking:on"] = 3.0
|
||||
ratioJSON, err := common.Marshal(ratios)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "qwen3-max@temperature:0.3@thinking:8192",
|
||||
UserGroup: "default",
|
||||
UsingGroup: "default",
|
||||
}
|
||||
priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "qwen3-max@thinking:on", info.BillingModelName)
|
||||
assert.Equal(t, 3.0, priceData.ModelRatio)
|
||||
})
|
||||
|
||||
t.Run("level4 base fallback", func(t *testing.T) {
|
||||
ratios := ratio_setting.GetModelRatioCopy()
|
||||
delete(ratios, "qwen3-max@thinking:on")
|
||||
delete(ratios, "qwen3-max@effort:high@thinking:on")
|
||||
ratios["qwen3-max"] = 1.25
|
||||
ratioJSON, err := common.Marshal(ratios)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "qwen3-max@thinking:off",
|
||||
UserGroup: "default",
|
||||
UsingGroup: "default",
|
||||
}
|
||||
priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "qwen3-max", info.BillingModelName)
|
||||
assert.Equal(t, 1.25, priceData.ModelRatio)
|
||||
})
|
||||
|
||||
t.Run("thinking minus one bills as on", func(t *testing.T) {
|
||||
ratios := ratio_setting.GetModelRatioCopy()
|
||||
ratios["qwen3-max@thinking:on"] = 3.0
|
||||
ratioJSON, err := common.Marshal(ratios)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "qwen3-max@thinking:-1",
|
||||
UserGroup: "default",
|
||||
UsingGroup: "default",
|
||||
}
|
||||
priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "qwen3-max@thinking:on", info.BillingModelName)
|
||||
assert.Equal(t, 3.0, priceData.ModelRatio)
|
||||
})
|
||||
}
|
||||
|
||||
func TestModelPriceHelperMigratesLegacyGeminiWildcardToCanonical(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
savedRatios := ratio_setting.ModelRatio2JSONString()
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedRatios))
|
||||
})
|
||||
ratios := ratio_setting.GetModelRatioCopy()
|
||||
delete(ratios, "gemini-2.5-flash-thinking-*")
|
||||
ratios["gemini-2.5-flash"] = 0.15
|
||||
ratios["gemini-2.5-flash@thinking:on"] = 0.09
|
||||
ratioJSON, err := common.Marshal(ratios)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
|
||||
|
||||
oldSelfUse := operation_setting.SelfUseModeEnabled
|
||||
operation_setting.SelfUseModeEnabled = false
|
||||
t.Cleanup(func() { operation_setting.SelfUseModeEnabled = oldSelfUse })
|
||||
|
||||
geminiSettings := model_setting.GetGeminiSettings()
|
||||
oldThinking := geminiSettings.ThinkingAdapterEnabled
|
||||
geminiSettings.ThinkingAdapterEnabled = true
|
||||
t.Cleanup(func() { geminiSettings.ThinkingAdapterEnabled = oldThinking })
|
||||
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Set("group", "default")
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "gemini-2.5-flash-thinking-8192",
|
||||
UserGroup: "default",
|
||||
UsingGroup: "default",
|
||||
}
|
||||
priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "gemini-2.5-flash@thinking:on", info.BillingModelName)
|
||||
assert.Equal(t, 0.09, priceData.ModelRatio)
|
||||
}
|
||||
|
||||
func TestModelPriceHelperModifierNameFallsBackToBase(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
savedRatios := ratio_setting.ModelRatio2JSONString()
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedRatios))
|
||||
})
|
||||
ratios := ratio_setting.GetModelRatioCopy()
|
||||
ratios["qwen3.8-max"] = 2.0
|
||||
ratioJSON, err := common.Marshal(ratios)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
|
||||
|
||||
oldSelfUse := operation_setting.SelfUseModeEnabled
|
||||
operation_setting.SelfUseModeEnabled = false
|
||||
t.Cleanup(func() { operation_setting.SelfUseModeEnabled = oldSelfUse })
|
||||
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Set("group", "default")
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "qwen3.8-max@thinking:on@temperature:0.2",
|
||||
UserGroup: "default",
|
||||
UsingGroup: "default",
|
||||
}
|
||||
priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "qwen3.8-max", info.BillingModelName)
|
||||
assert.Equal(t, 2.0, priceData.ModelRatio)
|
||||
}
|
||||
|
||||
func TestModelPriceHelperExemptAtNameBillsVerbatim(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
settings := model_setting.GetGlobalSettings()
|
||||
originalBlacklist := append([]string(nil), settings.ThinkingModelBlacklist...)
|
||||
t.Cleanup(func() { settings.ThinkingModelBlacklist = originalBlacklist })
|
||||
settings.ThinkingModelBlacklist = append(originalBlacklist, "re:.*@sha256:.*")
|
||||
|
||||
savedRatios := ratio_setting.ModelRatio2JSONString()
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedRatios))
|
||||
})
|
||||
ratios := ratio_setting.GetModelRatioCopy()
|
||||
ratios["opaque"] = 1.0
|
||||
ratios["opaque@sha256:deadbeef"] = 7.0
|
||||
ratioJSON, err := common.Marshal(ratios)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
|
||||
|
||||
oldSelfUse := operation_setting.SelfUseModeEnabled
|
||||
operation_setting.SelfUseModeEnabled = false
|
||||
t.Cleanup(func() { operation_setting.SelfUseModeEnabled = oldSelfUse })
|
||||
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Set("group", "default")
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "opaque@sha256:deadbeef",
|
||||
UserGroup: "default",
|
||||
UsingGroup: "default",
|
||||
}
|
||||
priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, info.BillingModelName)
|
||||
assert.Equal(t, "opaque@sha256:deadbeef", info.GetBillingModelName())
|
||||
assert.Equal(t, 7.0, priceData.ModelRatio)
|
||||
}
|
||||
|
||||
func TestModelPriceHelperPreservesGpt51CodexMaxIdentity(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
savedRatios := ratio_setting.ModelRatio2JSONString()
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedRatios))
|
||||
})
|
||||
ratios := ratio_setting.GetModelRatioCopy()
|
||||
ratios["gpt-5.1-codex-max"] = 1.75
|
||||
ratios["gpt-5.1-codex"] = 9.9
|
||||
ratioJSON, err := common.Marshal(ratios)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
|
||||
|
||||
oldSelfUse := operation_setting.SelfUseModeEnabled
|
||||
operation_setting.SelfUseModeEnabled = false
|
||||
t.Cleanup(func() { operation_setting.SelfUseModeEnabled = oldSelfUse })
|
||||
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Set("group", "default")
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "gpt-5.1-codex-max",
|
||||
UserGroup: "default",
|
||||
UsingGroup: "default",
|
||||
}
|
||||
priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, info.BillingModelName)
|
||||
assert.Equal(t, "gpt-5.1-codex-max", info.GetBillingModelName())
|
||||
assert.Equal(t, 1.75, priceData.ModelRatio)
|
||||
}
|
||||
|
||||
func TestModelPriceHelperNativeGeminiNoThinkingDoesNotAliasBillingModel(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
savedRatios := ratio_setting.ModelRatio2JSONString()
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedRatios))
|
||||
})
|
||||
ratios := ratio_setting.GetModelRatioCopy()
|
||||
ratios["gemini-3-pro"] = 1.25
|
||||
ratioJSON, err := common.Marshal(ratios)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
|
||||
|
||||
oldSelfUse := operation_setting.SelfUseModeEnabled
|
||||
operation_setting.SelfUseModeEnabled = true
|
||||
t.Cleanup(func() { operation_setting.SelfUseModeEnabled = oldSelfUse })
|
||||
|
||||
geminiSettings := model_setting.GetGeminiSettings()
|
||||
oldThinking := geminiSettings.ThinkingAdapterEnabled
|
||||
geminiSettings.ThinkingAdapterEnabled = true
|
||||
t.Cleanup(func() { geminiSettings.ThinkingAdapterEnabled = oldThinking })
|
||||
|
||||
budget := 0
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Set("group", "default")
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "gemini-3-pro",
|
||||
UserGroup: "default",
|
||||
UsingGroup: "default",
|
||||
Request: &dto.GeminiChatRequest{
|
||||
GenerationConfig: dto.GeminiChatGenerationConfig{
|
||||
ThinkingConfig: &dto.GeminiThinkingConfig{
|
||||
ThinkingBudget: &budget,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, info.BillingModelName)
|
||||
assert.Equal(t, "gemini-3-pro", info.GetBillingModelName())
|
||||
assert.Equal(t, 1.25, priceData.ModelRatio)
|
||||
assert.NotEqual(t, 37.5, priceData.ModelRatio)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
hostreasoning "github.com/QuantumNous/new-api/setting/reasoning"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ApplyReasoningModelSuffix parses host-private reasoning suffixes from the
|
||||
// origin and mapped model names, attaches the resulting intent to RelayInfo,
|
||||
// and normalizes UpstreamModelName to the unsuffixed base. Optional outbound
|
||||
// requests are the DeepCopy the handler will send upstream; they must be
|
||||
// synced here because info.Request is the original, not that copy. Explicit
|
||||
// model modifiers override request fields; mapped-model modifiers override
|
||||
// origin-model modifiers. Pass-through (global or channel) is a no-op so the
|
||||
// request body stays byte-identical. c is used only to correlate diagnostics.
|
||||
func ApplyReasoningModelSuffix(c *gin.Context, info *relaycommon.RelayInfo, outbound ...dto.Request) error {
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
if model_setting.GetGlobalSettings().PassThroughRequestEnabled ||
|
||||
info.ChannelMeta != nil && info.ChannelSetting.PassThroughBodyEnabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
opts := info.ConvOptions()
|
||||
origin := info.GetOriginModelName()
|
||||
upstream := ""
|
||||
if info.ChannelMeta != nil {
|
||||
upstream = info.UpstreamModelName
|
||||
}
|
||||
originParsed, err := parseRequestModelName(origin, opts)
|
||||
if err != nil {
|
||||
return reasoning.AsClientError(err)
|
||||
}
|
||||
upstreamParsed := originParsed
|
||||
if upstream != origin {
|
||||
upstreamParsed, err = parseRequestModelName(upstream, opts)
|
||||
if err != nil {
|
||||
return reasoning.AsClientError(err)
|
||||
}
|
||||
}
|
||||
diagnostics := append([]types.ConversionDiagnostic(nil), originParsed.diagnostics...)
|
||||
if upstream != origin {
|
||||
diagnostics = append(diagnostics, upstreamParsed.diagnostics...)
|
||||
}
|
||||
|
||||
selected := originParsed
|
||||
if upstream != origin {
|
||||
selected, diagnostics = overlayMappedModelModifiers(selected, upstreamParsed, diagnostics)
|
||||
}
|
||||
|
||||
if selected.hasThinking {
|
||||
explicit, err := explicitIntentFromRequest(info.Request)
|
||||
if err != nil {
|
||||
return reasoning.AsClientError(err)
|
||||
}
|
||||
diagnostics = append(diagnostics, modifierRequestOverrideDiagnostics(explicit, selected.intent)...)
|
||||
if selected.intent.IncludeThoughts == nil {
|
||||
selected.intent.IncludeThoughts = explicit.IncludeThoughts
|
||||
}
|
||||
info.ReasoningConversion = reasoning.StateFromIntent(selected.intent)
|
||||
}
|
||||
if info.ChannelMeta != nil {
|
||||
info.UpstreamModelName = selected.base
|
||||
}
|
||||
|
||||
if selected.hasTemperature {
|
||||
if current, exists := extractTemperature(info.Request); exists && *selected.temperature != current {
|
||||
diagnostics = append(diagnostics, modelModifierDiagnostic(
|
||||
"model_modifier_overrode_request",
|
||||
"temperature",
|
||||
fmt.Sprintf("model temperature modifier overrides request temperature %v", current),
|
||||
))
|
||||
}
|
||||
}
|
||||
if selected.hasTopP {
|
||||
if current, exists := extractTopP(info.Request); exists && *selected.topP != current {
|
||||
diagnostics = append(diagnostics, modelModifierDiagnostic(
|
||||
"model_modifier_overrode_request",
|
||||
"topp",
|
||||
fmt.Sprintf("model topp modifier overrides request top_p %v", current),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Handlers DeepCopy before this helper; info.Request is the original.
|
||||
// Sync every outbound copy the caller is about to send upstream.
|
||||
for _, outbound := range outbound {
|
||||
if outbound == nil {
|
||||
continue
|
||||
}
|
||||
if err := applyModelControls(outbound, selected); err != nil {
|
||||
return reasoning.AsClientError(err)
|
||||
}
|
||||
outbound.SetModelName(info.UpstreamModelName)
|
||||
}
|
||||
if info.Request != nil {
|
||||
info.Request.SetModelName(info.UpstreamModelName)
|
||||
}
|
||||
if selected.hasThinking {
|
||||
info.SetReasoningEffort(string(reasoning.EffectiveEffort(selected.intent)))
|
||||
}
|
||||
for i := range diagnostics {
|
||||
diagnostics[i].From = info.RelayFormat
|
||||
}
|
||||
diagnosticContext := context.Background()
|
||||
if c != nil {
|
||||
diagnosticContext = c
|
||||
}
|
||||
info.RecordConversionDiagnostics(diagnosticContext, diagnostics)
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseRequestModelName(name string, opts *convmeta.Options) (parsedModelModifiers, error) {
|
||||
if opts.ShouldPreserveThinkingSuffix(name) {
|
||||
return parsedModelModifiers{base: name}, nil
|
||||
}
|
||||
parsed, err := parseExplicitModelModifiers(name)
|
||||
if err != nil {
|
||||
return parsedModelModifiers{}, err
|
||||
}
|
||||
if opts.ShouldPreserveThinkingSuffix(parsed.base) {
|
||||
return parsed, nil
|
||||
}
|
||||
legacyBase, legacyIntent, legacyFound, err := parseHostModelSuffix(parsed.base, opts)
|
||||
if err != nil {
|
||||
return parsedModelModifiers{}, err
|
||||
}
|
||||
parsed.base = legacyBase
|
||||
if legacyFound && !parsed.hasThinking {
|
||||
parsed.intent = legacyIntent
|
||||
parsed.hasThinking = true
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func overlayMappedModelModifiers(origin parsedModelModifiers, mapped parsedModelModifiers, diagnostics []types.ConversionDiagnostic) (parsedModelModifiers, []types.ConversionDiagnostic) {
|
||||
origin.base = mapped.base
|
||||
origin.hasSyntax = origin.hasSyntax || mapped.hasSyntax
|
||||
origin.diagnostics = nil
|
||||
if mapped.hasThinking {
|
||||
if origin.hasThinking && !sameModifierIntent(origin.intent, mapped.intent) {
|
||||
diagnostics = append(diagnostics, modelModifierDiagnostic(
|
||||
"mapped_model_modifier_override",
|
||||
"thinking",
|
||||
"mapped-model thinking modifier overrides the origin-model modifier",
|
||||
))
|
||||
}
|
||||
origin.intent = mapped.intent
|
||||
origin.hasThinking = true
|
||||
}
|
||||
if mapped.hasTemperature {
|
||||
if origin.hasTemperature && *origin.temperature != *mapped.temperature {
|
||||
diagnostics = append(diagnostics, modelModifierDiagnostic(
|
||||
"mapped_model_modifier_override",
|
||||
"temperature",
|
||||
"mapped-model temperature modifier overrides the origin-model modifier",
|
||||
))
|
||||
}
|
||||
origin.temperature = mapped.temperature
|
||||
origin.hasTemperature = true
|
||||
}
|
||||
if mapped.hasTopP {
|
||||
if origin.hasTopP && *origin.topP != *mapped.topP {
|
||||
diagnostics = append(diagnostics, modelModifierDiagnostic(
|
||||
"mapped_model_modifier_override",
|
||||
"topp",
|
||||
"mapped-model topp modifier overrides the origin-model modifier",
|
||||
))
|
||||
}
|
||||
origin.topP = mapped.topP
|
||||
origin.hasTopP = true
|
||||
}
|
||||
return origin, diagnostics
|
||||
}
|
||||
|
||||
func modifierRequestOverrideDiagnostics(explicit reasoning.Intent, modifier reasoning.Intent) []types.ConversionDiagnostic {
|
||||
if !explicit.HasStrength() || sameModifierIntent(explicit, modifier) {
|
||||
return nil
|
||||
}
|
||||
return []types.ConversionDiagnostic{modelModifierDiagnostic(
|
||||
"model_modifier_overrode_request",
|
||||
"thinking",
|
||||
"model thinking modifier overrides structured request reasoning fields",
|
||||
)}
|
||||
}
|
||||
|
||||
func sameModifierIntent(left reasoning.Intent, right reasoning.Intent) bool {
|
||||
if left.Mode != right.Mode || left.Effort != right.Effort {
|
||||
return false
|
||||
}
|
||||
if left.BudgetTokens == nil || right.BudgetTokens == nil {
|
||||
return left.BudgetTokens == nil && right.BudgetTokens == nil
|
||||
}
|
||||
return *left.BudgetTokens == *right.BudgetTokens
|
||||
}
|
||||
|
||||
func parseHostModelSuffix(name string, opts *convmeta.Options) (string, reasoning.Intent, bool, error) {
|
||||
if name == "" {
|
||||
return name, reasoning.Intent{}, false, nil
|
||||
}
|
||||
return hostreasoning.ParseLegacyModelSuffix(
|
||||
name,
|
||||
opts.Claude.ThinkingAdapterEnabled,
|
||||
opts.Gemini.ThinkingAdapterEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
func explicitIntentFromRequest(req dto.Request) (reasoning.Intent, error) {
|
||||
switch r := req.(type) {
|
||||
case *dto.ClaudeRequest:
|
||||
return reasoning.FromClaude(r)
|
||||
case *dto.GeminiChatRequest:
|
||||
return reasoning.FromGemini(r)
|
||||
case *dto.GeneralOpenAIRequest:
|
||||
return reasoning.FromOpenAIChat(r)
|
||||
case *dto.OpenAIResponsesRequest:
|
||||
return reasoning.FromOpenAIResponses(r)
|
||||
default:
|
||||
return reasoning.Intent{}, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
hostreasoning "github.com/QuantumNous/new-api/setting/reasoning"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func mustApplyReasoningModelSuffix(t *testing.T, info *relaycommon.RelayInfo, outbound ...dto.Request) {
|
||||
t.Helper()
|
||||
require.NoError(t, ApplyReasoningModelSuffix(nil, info, outbound...))
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixTrimsUpstreamAndAttachesState(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "claude-3-7-sonnet-thinking",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "claude-3-7-sonnet-thinking",
|
||||
},
|
||||
}
|
||||
|
||||
mustApplyReasoningModelSuffix(t, info)
|
||||
assert.Equal(t, "claude-3-7-sonnet", info.UpstreamModelName)
|
||||
require.NotNil(t, info.ReasoningConversion)
|
||||
assert.Equal(t, "enabled", info.ReasoningConversion.Mode)
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixRetryKeepsEquivalentState(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "claude-opus-4-8-high",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "claude-opus-4-8-high",
|
||||
},
|
||||
}
|
||||
|
||||
mustApplyReasoningModelSuffix(t, info)
|
||||
require.NotNil(t, info.ReasoningConversion)
|
||||
firstMode := info.ReasoningConversion.Mode
|
||||
firstEffort := info.ReasoningConversion.Effort
|
||||
|
||||
info.UpstreamModelName = info.OriginModelName
|
||||
mustApplyReasoningModelSuffix(t, info)
|
||||
require.NotNil(t, info.ReasoningConversion)
|
||||
assert.Equal(t, firstMode, info.ReasoningConversion.Mode)
|
||||
assert.Equal(t, firstEffort, info.ReasoningConversion.Effort)
|
||||
assert.Equal(t, "claude-opus-4-8", info.UpstreamModelName)
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixRetryClearsStateWhenNewChannelHasNoSuffix(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
req := &dto.ClaudeRequest{Model: "claude-3-7-sonnet"}
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "claude-3-7-sonnet",
|
||||
Request: req,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "claude-3-7-sonnet-thinking",
|
||||
IsModelMapped: true,
|
||||
},
|
||||
}
|
||||
mustApplyReasoningModelSuffix(t, info)
|
||||
require.NotNil(t, info.ReasoningState())
|
||||
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
common.SetContextKey(ctx, constant.ContextKeyOriginalModel, "claude-3-7-sonnet")
|
||||
common.SetContextKey(ctx, constant.ContextKeyChannelType, constant.ChannelTypeAnthropic)
|
||||
info.InitChannelMeta(ctx)
|
||||
assert.Nil(t, info.ReasoningState())
|
||||
|
||||
mustApplyReasoningModelSuffix(t, info)
|
||||
assert.Nil(t, info.ReasoningState())
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixPassThroughDoesNotTrim(t *testing.T) {
|
||||
settings := model_setting.GetGlobalSettings()
|
||||
original := settings.PassThroughRequestEnabled
|
||||
t.Cleanup(func() { settings.PassThroughRequestEnabled = original })
|
||||
settings.PassThroughRequestEnabled = true
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "claude-3-7-sonnet-thinking",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "claude-3-7-sonnet-thinking",
|
||||
},
|
||||
}
|
||||
|
||||
mustApplyReasoningModelSuffix(t, info)
|
||||
assert.Equal(t, "claude-3-7-sonnet-thinking", info.UpstreamModelName)
|
||||
assert.Nil(t, info.ReasoningConversion)
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixBlacklistDoesNotTrim(t *testing.T) {
|
||||
settings := model_setting.GetGlobalSettings()
|
||||
original := append([]string(nil), settings.ThinkingModelBlacklist...)
|
||||
t.Cleanup(func() { settings.ThinkingModelBlacklist = original })
|
||||
settings.ThinkingModelBlacklist = append(settings.ThinkingModelBlacklist, "claude-3-7-sonnet-thinking")
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "claude-3-7-sonnet-thinking",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "claude-3-7-sonnet-thinking",
|
||||
},
|
||||
}
|
||||
|
||||
mustApplyReasoningModelSuffix(t, info)
|
||||
assert.Equal(t, "claude-3-7-sonnet-thinking", info.UpstreamModelName)
|
||||
assert.Nil(t, info.ReasoningConversion)
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixModifierOverridesExplicitConflict(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "claude-3-7-sonnet-thinking",
|
||||
Request: &dto.ClaudeRequest{
|
||||
Model: "claude-3-7-sonnet-thinking",
|
||||
Thinking: &dto.Thinking{Type: "disabled"},
|
||||
},
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "claude-3-7-sonnet-thinking",
|
||||
},
|
||||
}
|
||||
|
||||
mustApplyReasoningModelSuffix(t, info, info.Request)
|
||||
require.NotNil(t, info.ReasoningConversion)
|
||||
assert.Equal(t, "enabled", info.ReasoningConversion.Mode)
|
||||
assert.Nil(t, info.Request.(*dto.ClaudeRequest).Thinking)
|
||||
diagnostics := info.ConversionDiagnostics()
|
||||
require.NotEmpty(t, diagnostics)
|
||||
assert.Equal(t, "model_modifier_overrode_request", diagnostics[0].Code)
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixGeminiNoThinkingWhenAdapterEnabled(t *testing.T) {
|
||||
settings := model_setting.GetGeminiSettings()
|
||||
original := settings.ThinkingAdapterEnabled
|
||||
t.Cleanup(func() { settings.ThinkingAdapterEnabled = original })
|
||||
settings.ThinkingAdapterEnabled = true
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "gemini-2.5-flash-nothinking",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "gemini-2.5-flash-nothinking",
|
||||
},
|
||||
}
|
||||
|
||||
mustApplyReasoningModelSuffix(t, info)
|
||||
assert.Equal(t, "gemini-2.5-flash", info.UpstreamModelName)
|
||||
require.NotNil(t, info.ReasoningConversion)
|
||||
assert.Equal(t, "disabled", info.ReasoningConversion.Mode)
|
||||
assert.Equal(t, "none", info.ReasoningConversion.Effort)
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixPreservesEffortTailModelID(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "qwen-max",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "qwen-max",
|
||||
},
|
||||
}
|
||||
|
||||
mustApplyReasoningModelSuffix(t, info)
|
||||
assert.Equal(t, "qwen-max", info.UpstreamModelName)
|
||||
assert.Nil(t, info.ReasoningConversion)
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixLeavesDeepSeekV4SuffixForAdaptor(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "deepseek-v4-chat-max",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
ChannelType: constant.ChannelTypeDeepSeek,
|
||||
UpstreamModelName: "deepseek-v4-chat-max",
|
||||
},
|
||||
}
|
||||
|
||||
mustApplyReasoningModelSuffix(t, info)
|
||||
assert.Equal(t, "deepseek-v4-chat-max", info.UpstreamModelName)
|
||||
assert.Nil(t, info.ReasoningConversion)
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixLeavesVolcengineDeepSeekThinkingForAdaptor(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "deepseek-r1-thinking",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
ChannelType: constant.ChannelTypeVolcEngine,
|
||||
UpstreamModelName: "deepseek-r1-thinking",
|
||||
},
|
||||
}
|
||||
|
||||
mustApplyReasoningModelSuffix(t, info)
|
||||
assert.Equal(t, "deepseek-r1-thinking", info.UpstreamModelName)
|
||||
assert.Nil(t, info.ReasoningConversion)
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixStillParsesOpenAIEffortTail(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "gpt-5.1-high",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
ChannelType: constant.ChannelTypeOpenAI,
|
||||
UpstreamModelName: "gpt-5.1-high",
|
||||
},
|
||||
}
|
||||
|
||||
mustApplyReasoningModelSuffix(t, info)
|
||||
assert.Equal(t, "gpt-5.1", info.UpstreamModelName)
|
||||
require.NotNil(t, info.ReasoningConversion)
|
||||
assert.Equal(t, "enabled", info.ReasoningConversion.Mode)
|
||||
assert.Equal(t, "high", info.ReasoningConversion.Effort)
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixLeavesUnknownOpenRouterThinkingModel(t *testing.T) {
|
||||
openRouter := &relaycommon.RelayInfo{
|
||||
OriginModelName: "some-model-thinking",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
ChannelType: constant.ChannelTypeOpenRouter,
|
||||
UpstreamModelName: "some-model-thinking",
|
||||
},
|
||||
}
|
||||
mustApplyReasoningModelSuffix(t, openRouter)
|
||||
assert.Equal(t, "some-model-thinking", openRouter.UpstreamModelName)
|
||||
assert.Nil(t, openRouter.ReasoningConversion)
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixLeavesVersionedQwenMaxUntouched(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "qwen3.8-max",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "qwen3.8-max",
|
||||
},
|
||||
}
|
||||
|
||||
mustApplyReasoningModelSuffix(t, info)
|
||||
assert.Equal(t, "qwen3.8-max", info.UpstreamModelName)
|
||||
assert.Empty(t, info.BillingModelName)
|
||||
assert.Nil(t, info.ReasoningConversion)
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixTreatsCloudflareAtAsModelName(t *testing.T) {
|
||||
const model = "@cf/meta/llama-3.1-8b-instruct"
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: model,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: model},
|
||||
}
|
||||
|
||||
mustApplyReasoningModelSuffix(t, info)
|
||||
assert.Equal(t, model, info.UpstreamModelName)
|
||||
assert.Nil(t, info.ReasoningConversion)
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixAppliesExplicitModifierChain(t *testing.T) {
|
||||
temperature := 0.9
|
||||
topP := 1.0
|
||||
request := &dto.GeneralOpenAIRequest{
|
||||
Model: "qwen3.8-max@thinking:on@effort:high@temperature:0.2@topp:0.8",
|
||||
ReasoningEffort: "low",
|
||||
Temperature: &temperature,
|
||||
TopP: &topP,
|
||||
}
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: request.Model,
|
||||
Request: request,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: request.Model,
|
||||
},
|
||||
}
|
||||
|
||||
mustApplyReasoningModelSuffix(t, info, request)
|
||||
assert.Equal(t, "qwen3.8-max", info.UpstreamModelName)
|
||||
assert.Empty(t, info.BillingModelName)
|
||||
assert.Equal(t, "qwen3.8-max", request.Model)
|
||||
assert.Equal(t, 0.2, *request.Temperature)
|
||||
assert.Equal(t, 0.8, *request.TopP)
|
||||
assert.Equal(t, "high", request.ReasoningEffort)
|
||||
require.NotNil(t, info.ReasoningConversion)
|
||||
assert.Equal(t, "enabled", info.ReasoningConversion.Mode)
|
||||
assert.Equal(t, "high", info.ReasoningConversion.Effort)
|
||||
assert.Contains(t, info.ConversionDiagnostics(), types.ConversionDiagnostic{
|
||||
Code: "model_modifier_overrode_request",
|
||||
Path: "model.@temperature",
|
||||
Message: "model temperature modifier overrides request temperature 0.9",
|
||||
Severity: types.ConversionDiagnosticWarning,
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixRejectsUnknownModifierKey(t *testing.T) {
|
||||
request := &dto.GeneralOpenAIRequest{Model: "m@thinkin:on"}
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: request.Model,
|
||||
Request: request,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: request.Model,
|
||||
},
|
||||
}
|
||||
|
||||
err := ApplyReasoningModelSuffix(nil, info, request)
|
||||
require.Error(t, err)
|
||||
assert.True(t, kitreasoning.IsClientError(err))
|
||||
assert.Contains(t, err.Error(), `unsupported model modifier "thinkin"`)
|
||||
assert.Contains(t, err.Error(), "Models that skip thinking suffix processing")
|
||||
assert.Contains(t, err.Error(), "re:")
|
||||
assert.Equal(t, "m@thinkin:on", info.UpstreamModelName)
|
||||
assert.Nil(t, info.ReasoningConversion)
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixThinkingOnOnly(t *testing.T) {
|
||||
request := &dto.GeneralOpenAIRequest{Model: "qwen3.8-max@thinking:on"}
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: request.Model,
|
||||
Request: request,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: request.Model,
|
||||
},
|
||||
}
|
||||
|
||||
mustApplyReasoningModelSuffix(t, info, request)
|
||||
assert.Equal(t, "qwen3.8-max", info.UpstreamModelName)
|
||||
assert.Equal(t, "", request.ReasoningEffort)
|
||||
require.NotNil(t, info.ReasoningConversion)
|
||||
assert.Equal(t, "enabled", info.ReasoningConversion.Mode)
|
||||
assert.Equal(t, "", info.ReasoningConversion.Effort)
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixThinkingOff(t *testing.T) {
|
||||
request := &dto.GeneralOpenAIRequest{Model: "qwen3.8-max@thinking:off"}
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: request.Model,
|
||||
Request: request,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: request.Model,
|
||||
},
|
||||
}
|
||||
|
||||
mustApplyReasoningModelSuffix(t, info, request)
|
||||
assert.Equal(t, "none", request.ReasoningEffort)
|
||||
require.NotNil(t, info.ReasoningConversion)
|
||||
assert.Equal(t, "disabled", info.ReasoningConversion.Mode)
|
||||
assert.Equal(t, "none", info.ReasoningConversion.Effort)
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixThinkingLegacyValuesRejected(t *testing.T) {
|
||||
request := &dto.GeneralOpenAIRequest{Model: "qwen3.8-max@thinking:enabled"}
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: request.Model,
|
||||
Request: request,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: request.Model,
|
||||
},
|
||||
}
|
||||
|
||||
err := ApplyReasoningModelSuffix(nil, info, request)
|
||||
require.Error(t, err)
|
||||
assert.True(t, kitreasoning.IsClientError(err))
|
||||
assert.Contains(t, err.Error(), `invalid thinking modifier value "enabled"`)
|
||||
assert.Contains(t, err.Error(), "Models that skip thinking suffix processing")
|
||||
assert.Equal(t, "qwen3.8-max@thinking:enabled", info.UpstreamModelName)
|
||||
assert.Equal(t, "qwen3.8-max@thinking:enabled", request.Model)
|
||||
assert.Nil(t, info.ReasoningConversion)
|
||||
assert.Empty(t, info.ConversionDiagnostics())
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixDuplicateModifierLastWins(t *testing.T) {
|
||||
request := &dto.GeneralOpenAIRequest{Model: "qwen3.8-max@thinking:on@thinking:off"}
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: request.Model,
|
||||
Request: request,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: request.Model,
|
||||
},
|
||||
}
|
||||
|
||||
mustApplyReasoningModelSuffix(t, info, request)
|
||||
assert.Equal(t, "qwen3.8-max", info.UpstreamModelName)
|
||||
require.NotNil(t, info.ReasoningConversion)
|
||||
assert.Equal(t, "disabled", info.ReasoningConversion.Mode)
|
||||
assert.Contains(t, info.ConversionDiagnostics(), types.ConversionDiagnostic{
|
||||
Code: "duplicate_model_modifier",
|
||||
Path: "model.@thinking",
|
||||
Message: "model modifier \"thinking\" is repeated; the rightmost value is used",
|
||||
Severity: types.ConversionDiagnosticWarning,
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixExactExemptionKeepsOpaqueName(t *testing.T) {
|
||||
const model = "opaque@sha256:abc"
|
||||
settings := model_setting.GetGlobalSettings()
|
||||
original := append([]string(nil), settings.ThinkingModelBlacklist...)
|
||||
t.Cleanup(func() { settings.ThinkingModelBlacklist = original })
|
||||
settings.ThinkingModelBlacklist = append(original, model)
|
||||
|
||||
request := &dto.GeneralOpenAIRequest{Model: model}
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: model,
|
||||
Request: request,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: model,
|
||||
},
|
||||
}
|
||||
|
||||
mustApplyReasoningModelSuffix(t, info, request)
|
||||
assert.Equal(t, model, info.UpstreamModelName)
|
||||
assert.Equal(t, model, request.Model)
|
||||
assert.Nil(t, info.ReasoningConversion)
|
||||
assert.Empty(t, info.ConversionDiagnostics())
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixRegexExemptionKeepsOpaqueName(t *testing.T) {
|
||||
const model = "m@sha256:abc"
|
||||
settings := model_setting.GetGlobalSettings()
|
||||
original := append([]string(nil), settings.ThinkingModelBlacklist...)
|
||||
t.Cleanup(func() { settings.ThinkingModelBlacklist = original })
|
||||
settings.ThinkingModelBlacklist = append(original, "re:.*@sha256:.*")
|
||||
|
||||
request := &dto.GeneralOpenAIRequest{Model: model}
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: model,
|
||||
Request: request,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: model,
|
||||
},
|
||||
}
|
||||
|
||||
mustApplyReasoningModelSuffix(t, info, request)
|
||||
assert.Equal(t, model, info.UpstreamModelName)
|
||||
assert.Equal(t, model, request.Model)
|
||||
assert.Nil(t, info.ReasoningConversion)
|
||||
assert.Empty(t, info.ConversionDiagnostics())
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixEffortNoneDisables(t *testing.T) {
|
||||
request := &dto.GeneralOpenAIRequest{Model: "qwen3.8-max@effort:none"}
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: request.Model,
|
||||
Request: request,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: request.Model,
|
||||
},
|
||||
}
|
||||
|
||||
mustApplyReasoningModelSuffix(t, info, request)
|
||||
assert.Equal(t, "none", request.ReasoningEffort)
|
||||
require.NotNil(t, info.ReasoningConversion)
|
||||
assert.Equal(t, "disabled", info.ReasoningConversion.Mode)
|
||||
assert.Equal(t, "none", info.ReasoningConversion.Effort)
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixPassThroughKeepsModifierBodyVerbatim(t *testing.T) {
|
||||
settings := model_setting.GetGlobalSettings()
|
||||
original := settings.PassThroughRequestEnabled
|
||||
t.Cleanup(func() { settings.PassThroughRequestEnabled = original })
|
||||
settings.PassThroughRequestEnabled = true
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
const model = "qwen3.8-max@thinking:on@effort:high@temperature:0.2@topp:0.8"
|
||||
body := `{"model":"` + model + `","messages":[],"vendor_extension":{"keep":true}}`
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
request := &dto.GeneralOpenAIRequest{Model: model}
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: model,
|
||||
Request: request,
|
||||
RelayFormat: types.RelayFormatOpenAI,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: model,
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, ApplyReasoningModelSuffix(c, info, request))
|
||||
assert.Equal(t, model, info.UpstreamModelName)
|
||||
assert.Equal(t, model, request.Model)
|
||||
assert.Nil(t, info.ReasoningConversion)
|
||||
assert.Empty(t, info.ConversionDiagnostics())
|
||||
storage, err := common.GetBodyStorage(c)
|
||||
require.NoError(t, err)
|
||||
got, err := storage.Bytes()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, body, string(got))
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixChannelPassThroughKeepsModifierBodyVerbatim(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const model = "qwen3.8-max@thinking:on"
|
||||
body := `{"model":"` + model + `","messages":[]}`
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
request := &dto.GeneralOpenAIRequest{Model: model}
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: model,
|
||||
Request: request,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: model,
|
||||
ChannelSetting: dto.ChannelSettings{PassThroughBodyEnabled: true},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, ApplyReasoningModelSuffix(c, info, request))
|
||||
assert.Equal(t, model, info.UpstreamModelName)
|
||||
assert.Nil(t, info.ReasoningConversion)
|
||||
storage, err := common.GetBodyStorage(c)
|
||||
require.NoError(t, err)
|
||||
got, err := storage.Bytes()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, body, string(got))
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixPassThroughAllowsUnknownModifier(t *testing.T) {
|
||||
settings := model_setting.GetGlobalSettings()
|
||||
original := settings.PassThroughRequestEnabled
|
||||
t.Cleanup(func() { settings.PassThroughRequestEnabled = original })
|
||||
settings.PassThroughRequestEnabled = true
|
||||
|
||||
const model = "m@sha256:abc"
|
||||
request := &dto.GeneralOpenAIRequest{Model: model}
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: model,
|
||||
Request: request,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: model,
|
||||
},
|
||||
}
|
||||
|
||||
mustApplyReasoningModelSuffix(t, info, request)
|
||||
assert.Equal(t, model, info.UpstreamModelName)
|
||||
assert.Nil(t, info.ReasoningConversion)
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixAppliesModifiersWhenPassThroughOff(t *testing.T) {
|
||||
request := &dto.GeneralOpenAIRequest{Model: "qwen3.8-max@thinking:on@effort:high"}
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: request.Model,
|
||||
Request: request,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: request.Model,
|
||||
},
|
||||
}
|
||||
|
||||
mustApplyReasoningModelSuffix(t, info, request)
|
||||
assert.Equal(t, "qwen3.8-max", info.UpstreamModelName)
|
||||
assert.Equal(t, "high", request.ReasoningEffort)
|
||||
require.NotNil(t, info.ReasoningConversion)
|
||||
assert.Equal(t, "enabled", info.ReasoningConversion.Mode)
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixThinkingMinusOnePassthrough(t *testing.T) {
|
||||
request := &dto.GeneralOpenAIRequest{Model: "m@thinking:-1"}
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: request.Model,
|
||||
Request: request,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: request.Model,
|
||||
},
|
||||
}
|
||||
|
||||
require.NotPanics(t, func() {
|
||||
mustApplyReasoningModelSuffix(t, info, request)
|
||||
})
|
||||
assert.Equal(t, "m", info.UpstreamModelName)
|
||||
require.NotNil(t, info.ReasoningConversion)
|
||||
require.NotNil(t, info.ReasoningConversion.BudgetTokens)
|
||||
assert.Equal(t, -1, *info.ReasoningConversion.BudgetTokens)
|
||||
require.NotEmpty(t, request.Reasoning)
|
||||
assert.Equal(t, float64(-1), gjson.GetBytes(request.Reasoning, "max_tokens").Num)
|
||||
}
|
||||
|
||||
func TestApplyReasoningModelSuffixPreservesGpt51CodexMax(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "gpt-5.1-codex-max",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "gpt-5.1-codex-max",
|
||||
},
|
||||
}
|
||||
|
||||
mustApplyReasoningModelSuffix(t, info)
|
||||
assert.Equal(t, "gpt-5.1-codex-max", info.UpstreamModelName)
|
||||
assert.Nil(t, info.ReasoningConversion)
|
||||
assert.Equal(t, "gpt-5.1-codex-max", hostreasoning.BaseModelName("gpt-5.1-codex-max"))
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
)
|
||||
|
||||
// StreamResult is passed to each dataHandler invocation, providing methods
|
||||
// to record soft errors, signal fatal stops, or mark normal completion.
|
||||
// StreamScannerHandler checks IsStopped() after each callback invocation.
|
||||
type StreamResult struct {
|
||||
status *relaycommon.StreamStatus
|
||||
stopped bool
|
||||
}
|
||||
|
||||
func newStreamResult(status *relaycommon.StreamStatus) *StreamResult {
|
||||
return &StreamResult{status: status}
|
||||
}
|
||||
|
||||
// Error records a soft error. The stream continues processing.
|
||||
// Can be called multiple times per chunk.
|
||||
func (r *StreamResult) Error(err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
r.status.RecordError(err.Error())
|
||||
}
|
||||
|
||||
// Stop records a fatal error and marks the stream to stop after this chunk.
|
||||
func (r *StreamResult) Stop(err error) {
|
||||
if err != nil {
|
||||
r.status.RecordError(err.Error())
|
||||
}
|
||||
r.status.SetEndReason(relaycommon.StreamEndReasonHandlerStop, err)
|
||||
r.stopped = true
|
||||
}
|
||||
|
||||
// Done signals that the handler has finished processing normally
|
||||
// (e.g., Dify "message_end"). The stream stops after this chunk.
|
||||
func (r *StreamResult) Done() {
|
||||
r.status.SetEndReason(relaycommon.StreamEndReasonDone, nil)
|
||||
r.stopped = true
|
||||
}
|
||||
|
||||
// IsStopped returns whether Stop() or Done() was called during this chunk.
|
||||
func (r *StreamResult) IsStopped() bool {
|
||||
return r.stopped
|
||||
}
|
||||
|
||||
// reset clears the per-chunk stopped flag so the object can be reused.
|
||||
func (r *StreamResult) reset() {
|
||||
r.stopped = false
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
InitialScannerBufferSize = 64 << 10 // 64KB (64*1024)
|
||||
DefaultMaxScannerBufferSize = 128 << 20 // 64MB (64*1024*1024) default SSE buffer size
|
||||
DefaultPingInterval = 10 * time.Second
|
||||
// streamWriteTimeout bounds a single blocked write to a slow client so the
|
||||
// unconditional wg.Wait() in cleanup can always finish. Without it, a slow
|
||||
// but connected client (full TCP buffer, no server WriteTimeout) could hang
|
||||
// the handler forever.
|
||||
streamWriteTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
func getScannerBufferSize() int {
|
||||
if constant.StreamScannerMaxBufferMB > 0 {
|
||||
return constant.StreamScannerMaxBufferMB << 20
|
||||
}
|
||||
return DefaultMaxScannerBufferSize
|
||||
}
|
||||
|
||||
func NewStreamScanner(reader io.Reader) *bufio.Scanner {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
scanner.Buffer(make([]byte, InitialScannerBufferSize), getScannerBufferSize())
|
||||
return scanner
|
||||
}
|
||||
|
||||
func copyCodexSSEHeaders(c *gin.Context, resp *http.Response) {
|
||||
if c == nil || c.Writer == nil || resp == nil {
|
||||
return
|
||||
}
|
||||
// codex
|
||||
for _, name := range []string{"X-Reasoning-Included", "X-Codex-Turn-State"} {
|
||||
values := resp.Header.Values(name)
|
||||
if !service.ShouldCopyUpstreamHeader(c, name, values) {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
c.Writer.Header().Add(name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ExtendWriteDeadline pushes the connection write deadline forward before each
|
||||
// stream write. Best-effort: writers that don't support deadlines (e.g.
|
||||
// httptest recorders) are silently ignored.
|
||||
func ExtendWriteDeadline(c *gin.Context) {
|
||||
if c == nil || c.Writer == nil {
|
||||
return
|
||||
}
|
||||
_ = http.NewResponseController(c.Writer).SetWriteDeadline(time.Now().Add(streamWriteTimeout))
|
||||
}
|
||||
|
||||
func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, dataHandler func(data string, sr *StreamResult)) {
|
||||
|
||||
if resp == nil || dataHandler == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 无条件新建 StreamStatus
|
||||
info.StreamStatus = relaycommon.NewStreamStatus()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
streamingTimeout := time.Duration(constant.StreamingTimeout) * time.Second
|
||||
|
||||
var (
|
||||
stopChan = make(chan bool, 3) // 增加缓冲区避免阻塞
|
||||
scanner = NewStreamScanner(resp.Body)
|
||||
ticker = time.NewTicker(streamingTimeout)
|
||||
pingTicker *time.Ticker
|
||||
writeMutex sync.Mutex // Mutex to protect concurrent writes
|
||||
wg sync.WaitGroup // 用于等待所有 goroutine 退出
|
||||
cleanupOnce sync.Once
|
||||
stopOnce sync.Once
|
||||
)
|
||||
|
||||
stop := func() {
|
||||
stopOnce.Do(func() {
|
||||
close(stopChan)
|
||||
})
|
||||
}
|
||||
|
||||
generalSettings := operation_setting.GetGeneralSetting()
|
||||
pingEnabled := generalSettings.PingIntervalEnabled && !info.DisablePing
|
||||
pingInterval := time.Duration(generalSettings.PingIntervalSeconds) * time.Second
|
||||
if pingInterval <= 0 {
|
||||
pingInterval = DefaultPingInterval
|
||||
}
|
||||
|
||||
if pingEnabled {
|
||||
pingTicker = time.NewTicker(pingInterval)
|
||||
}
|
||||
|
||||
logger.LogDebug(c, "relay timeout seconds: %d", common.RelayTimeout)
|
||||
logger.LogDebug(c, "relay max idle conns: %d", common.RelayMaxIdleConns)
|
||||
logger.LogDebug(c, "relay max idle conns per host: %d", common.RelayMaxIdleConnsPerHost)
|
||||
logger.LogDebug(c, "streaming timeout seconds: %d", int64(streamingTimeout.Seconds()))
|
||||
logger.LogDebug(c, "ping interval seconds: %d", int64(pingInterval.Seconds()))
|
||||
|
||||
cleanup := func() {
|
||||
cleanupOnce.Do(func() {
|
||||
cancel()
|
||||
stop()
|
||||
if resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
ticker.Stop()
|
||||
if pingTicker != nil {
|
||||
pingTicker.Stop()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
// Ensure gin.Context is not returned to Gin's pool while any stream goroutine can still use it.
|
||||
defer cleanup()
|
||||
|
||||
scanner.Split(bufio.ScanLines)
|
||||
copyCodexSSEHeaders(c, resp)
|
||||
SetEventStreamHeaders(c)
|
||||
|
||||
ctx = context.WithValue(ctx, "stop_chan", stopChan)
|
||||
|
||||
// Handle ping data sending with improved error handling
|
||||
if pingEnabled && pingTicker != nil {
|
||||
wg.Add(1)
|
||||
gopool.Go(func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.LogError(c, fmt.Sprintf("ping goroutine panic: %v", r))
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPanic, fmt.Errorf("ping panic: %v", r))
|
||||
stop()
|
||||
}
|
||||
logger.LogDebug(c, "ping goroutine exited")
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
// 添加超时保护,防止 goroutine 无限运行
|
||||
maxPingDuration := 30 * time.Minute // 最大 ping 持续时间
|
||||
pingTimeout := time.NewTimer(maxPingDuration)
|
||||
defer pingTimeout.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-pingTicker.C:
|
||||
var err error
|
||||
func() {
|
||||
writeMutex.Lock()
|
||||
defer writeMutex.Unlock()
|
||||
ExtendWriteDeadline(c)
|
||||
err = PingData(c)
|
||||
}()
|
||||
if err != nil {
|
||||
logger.LogError(c, "ping data error: "+err.Error())
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPingFail, err)
|
||||
return
|
||||
}
|
||||
logger.LogDebug(c, "ping data sent")
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-stopChan:
|
||||
return
|
||||
case <-c.Request.Context().Done():
|
||||
// 监听客户端断开连接
|
||||
return
|
||||
case <-pingTimeout.C:
|
||||
logger.LogError(c, "ping goroutine max duration reached")
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
dataChan := make(chan string, 10)
|
||||
|
||||
wg.Add(1)
|
||||
gopool.Go(func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.LogError(c, fmt.Sprintf("data handler goroutine panic: %v", r))
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPanic, fmt.Errorf("handler panic: %v", r))
|
||||
}
|
||||
stop()
|
||||
wg.Done()
|
||||
}()
|
||||
sr := newStreamResult(info.StreamStatus)
|
||||
for data := range dataChan {
|
||||
sr.reset()
|
||||
func() {
|
||||
writeMutex.Lock()
|
||||
defer writeMutex.Unlock()
|
||||
ExtendWriteDeadline(c)
|
||||
dataHandler(data, sr)
|
||||
}()
|
||||
if sr.IsStopped() {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Scanner goroutine with improved error handling
|
||||
wg.Add(1)
|
||||
common.RelayCtxGo(ctx, func() {
|
||||
defer func() {
|
||||
close(dataChan)
|
||||
if r := recover(); r != nil {
|
||||
logger.LogError(c, fmt.Sprintf("scanner goroutine panic: %v", r))
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPanic, fmt.Errorf("scanner panic: %v", r))
|
||||
}
|
||||
stop()
|
||||
logger.LogDebug(c, "scanner goroutine exited")
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
for scanner.Scan() {
|
||||
// 检查是否需要停止
|
||||
select {
|
||||
case <-stopChan:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
ticker.Reset(streamingTimeout)
|
||||
data := scanner.Text()
|
||||
logger.LogDebug(c, "stream scanner data: %s", data)
|
||||
|
||||
if len(data) < 6 {
|
||||
continue
|
||||
}
|
||||
if data[:5] != "data:" && data[:6] != "[DONE]" {
|
||||
continue
|
||||
}
|
||||
data = data[5:]
|
||||
data = strings.TrimSpace(data)
|
||||
if data == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(data, "[DONE]") {
|
||||
info.SetFirstResponseTime()
|
||||
info.ReceivedResponseCount++
|
||||
|
||||
select {
|
||||
case dataChan <- data:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
} else {
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonDone, nil)
|
||||
logger.LogDebug(c, "received [DONE], stopping scanner")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
if err != io.EOF {
|
||||
logger.LogError(c, "scanner error: "+err.Error())
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonScannerErr, err)
|
||||
}
|
||||
}
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonEOF, nil)
|
||||
})
|
||||
|
||||
// 主循环等待完成或超时
|
||||
select {
|
||||
case <-ticker.C:
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonTimeout, nil)
|
||||
case <-stopChan:
|
||||
// EndReason already set by the goroutine that triggered stopChan
|
||||
case <-c.Request.Context().Done():
|
||||
// 客户端断开:立即 cleanup 关闭上游 resp.Body,解除 scanner 阻塞并让上游停止生成,
|
||||
// 避免为已放弃的请求继续消费上游 token。
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, c.Request.Context().Err())
|
||||
}
|
||||
|
||||
cleanup()
|
||||
if info.StreamStatus.IsNormalEnd() && !info.StreamStatus.HasErrors() {
|
||||
logger.LogInfo(c, fmt.Sprintf("stream ended: %s", info.StreamStatus.Summary()))
|
||||
} else {
|
||||
logger.LogError(c, fmt.Sprintf("stream ended: %s, received=%d", info.StreamStatus.Summary(), info.ReceivedResponseCount))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
if constant.StreamingTimeout == 0 {
|
||||
constant.StreamingTimeout = 30
|
||||
}
|
||||
}
|
||||
|
||||
func setupStreamTest(t *testing.T, body io.Reader) (*gin.Context, *http.Response, *relaycommon.RelayInfo) {
|
||||
t.Helper()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
resp := &http.Response{
|
||||
Body: io.NopCloser(body),
|
||||
}
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
ChannelMeta: &relaycommon.ChannelMeta{},
|
||||
}
|
||||
|
||||
return c, resp, info
|
||||
}
|
||||
|
||||
func buildSSEBody(n int) string {
|
||||
var b strings.Builder
|
||||
for i := range n {
|
||||
fmt.Fprintf(&b, "data: {\"id\":%d,\"choices\":[{\"delta\":{\"content\":\"token_%d\"}}]}\n", i, i)
|
||||
}
|
||||
b.WriteString("data: [DONE]\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ---------- Basic correctness ----------
|
||||
|
||||
func TestStreamScannerHandler_NilInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
|
||||
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}}
|
||||
|
||||
StreamScannerHandler(c, nil, info, func(data string, sr *StreamResult) {})
|
||||
StreamScannerHandler(c, &http.Response{Body: io.NopCloser(strings.NewReader(""))}, info, nil)
|
||||
}
|
||||
|
||||
func TestNewStreamScanner_AllowsLargeStreamLine(t *testing.T) {
|
||||
oldBufferMB := constant.StreamScannerMaxBufferMB
|
||||
constant.StreamScannerMaxBufferMB = 1
|
||||
t.Cleanup(func() {
|
||||
constant.StreamScannerMaxBufferMB = oldBufferMB
|
||||
})
|
||||
|
||||
payload := strings.Repeat("x", 128<<10)
|
||||
scanner := NewStreamScanner(strings.NewReader("data: " + payload + "\n"))
|
||||
scanner.Split(bufio.ScanLines)
|
||||
|
||||
require.True(t, scanner.Scan())
|
||||
assert.Equal(t, "data: "+payload, scanner.Text())
|
||||
require.NoError(t, scanner.Err())
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_EmptyBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, resp, info := setupStreamTest(t, strings.NewReader(""))
|
||||
|
||||
var called atomic.Bool
|
||||
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
|
||||
called.Store(true)
|
||||
})
|
||||
|
||||
assert.False(t, called.Load(), "handler should not be called for empty body")
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_1000Chunks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const numChunks = 1000
|
||||
body := buildSSEBody(numChunks)
|
||||
c, resp, info := setupStreamTest(t, strings.NewReader(body))
|
||||
|
||||
var count atomic.Int64
|
||||
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
|
||||
count.Add(1)
|
||||
})
|
||||
|
||||
assert.Equal(t, int64(numChunks), count.Load())
|
||||
assert.Equal(t, numChunks, info.ReceivedResponseCount)
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_OrderPreserved(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const numChunks = 500
|
||||
body := buildSSEBody(numChunks)
|
||||
c, resp, info := setupStreamTest(t, strings.NewReader(body))
|
||||
|
||||
var mu sync.Mutex
|
||||
received := make([]string, 0, numChunks)
|
||||
|
||||
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
|
||||
mu.Lock()
|
||||
received = append(received, data)
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
require.Equal(t, numChunks, len(received))
|
||||
for i := range numChunks {
|
||||
expected := fmt.Sprintf("{\"id\":%d,\"choices\":[{\"delta\":{\"content\":\"token_%d\"}}]}", i, i)
|
||||
assert.Equal(t, expected, received[i], "chunk %d out of order", i)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_DoneStopsScanner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := buildSSEBody(50) + "data: should_not_appear\n"
|
||||
c, resp, info := setupStreamTest(t, strings.NewReader(body))
|
||||
|
||||
var count atomic.Int64
|
||||
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
|
||||
count.Add(1)
|
||||
})
|
||||
|
||||
assert.Equal(t, int64(50), count.Load(), "data after [DONE] must not be processed")
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_StopStopsStream(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const numChunks = 200
|
||||
body := buildSSEBody(numChunks)
|
||||
c, resp, info := setupStreamTest(t, strings.NewReader(body))
|
||||
|
||||
const stopAt int64 = 50
|
||||
var count atomic.Int64
|
||||
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
|
||||
n := count.Add(1)
|
||||
if n >= stopAt {
|
||||
sr.Stop(fmt.Errorf("fatal at %d", n))
|
||||
}
|
||||
})
|
||||
|
||||
assert.Equal(t, stopAt, count.Load())
|
||||
require.NotNil(t, info.StreamStatus)
|
||||
assert.Equal(t, relaycommon.StreamEndReasonHandlerStop, info.StreamStatus.EndReason)
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_SkipsNonDataLines(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(": comment line\n")
|
||||
b.WriteString("event: message\n")
|
||||
b.WriteString("id: 12345\n")
|
||||
b.WriteString("retry: 5000\n")
|
||||
for i := range 100 {
|
||||
fmt.Fprintf(&b, "data: payload_%d\n", i)
|
||||
b.WriteString(": interleaved comment\n")
|
||||
}
|
||||
b.WriteString("data: [DONE]\n")
|
||||
|
||||
c, resp, info := setupStreamTest(t, strings.NewReader(b.String()))
|
||||
|
||||
var count atomic.Int64
|
||||
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
|
||||
count.Add(1)
|
||||
})
|
||||
|
||||
assert.Equal(t, int64(100), count.Load())
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_DataWithExtraSpaces(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := "data: {\"trimmed\":true} \ndata: [DONE]\n"
|
||||
c, resp, info := setupStreamTest(t, strings.NewReader(body))
|
||||
|
||||
var got string
|
||||
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
|
||||
got = data
|
||||
})
|
||||
|
||||
assert.Equal(t, "{\"trimmed\":true}", got)
|
||||
}
|
||||
|
||||
// TestStreamScannerHandler_ClientCancelAbortsUpstreamAndReturns pins the
|
||||
// disconnect contract: when the client goes away, the handler must return
|
||||
// promptly (all goroutines joined, so the gin.Context can never leak into a
|
||||
// pooled reuse), the upstream body must be closed to stop token generation,
|
||||
// and no data received after the disconnect may be processed or written.
|
||||
func TestStreamScannerHandler_ClientCancelAbortsUpstreamAndReturns(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
t.Cleanup(func() {
|
||||
_ = pr.Close()
|
||||
_ = pw.Close()
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil).WithContext(ctx)
|
||||
|
||||
resp := &http.Response{Body: pr}
|
||||
info := &relaycommon.RelayInfo{
|
||||
DisablePing: true,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{},
|
||||
}
|
||||
|
||||
var count atomic.Int64
|
||||
firstHandled := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
|
||||
count.Add(1)
|
||||
_ = StringData(c, data)
|
||||
if data == "first" {
|
||||
close(firstHandled)
|
||||
}
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
_, err := fmt.Fprint(pw, "data: first\n")
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-firstHandled:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for first chunk")
|
||||
}
|
||||
|
||||
cancel()
|
||||
|
||||
// The handler must return without any further upstream input: cleanup
|
||||
// closes resp.Body, which unblocks the scanner goroutine.
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("handler did not return after client disconnect")
|
||||
}
|
||||
|
||||
// Upstream read side must be closed so the provider stops generating
|
||||
// (and billing) for a request nobody is listening to.
|
||||
_, err = fmt.Fprint(pw, "data: second\n")
|
||||
require.ErrorIs(t, err, io.ErrClosedPipe, "upstream body should be closed after client disconnect")
|
||||
|
||||
assert.Equal(t, int64(1), count.Load(), "no chunk after disconnect should be processed")
|
||||
require.NotNil(t, info.StreamStatus)
|
||||
assert.Equal(t, relaycommon.StreamEndReasonClientGone, info.StreamStatus.EndReason)
|
||||
|
||||
body := recorder.Body.String()
|
||||
assert.Contains(t, body, "first")
|
||||
assert.NotContains(t, body, "second")
|
||||
}
|
||||
|
||||
// ---------- Ping tests ----------
|
||||
|
||||
func TestStreamScannerHandler_PingSentDuringSlowUpstream(t *testing.T) {
|
||||
setting := operation_setting.GetGeneralSetting()
|
||||
oldEnabled := setting.PingIntervalEnabled
|
||||
oldSeconds := setting.PingIntervalSeconds
|
||||
setting.PingIntervalEnabled = true
|
||||
setting.PingIntervalSeconds = 1
|
||||
t.Cleanup(func() {
|
||||
setting.PingIntervalEnabled = oldEnabled
|
||||
setting.PingIntervalSeconds = oldSeconds
|
||||
})
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
go func() {
|
||||
defer pw.Close()
|
||||
for i := range 4 {
|
||||
fmt.Fprintf(pw, "data: chunk_%d\n", i)
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
}
|
||||
fmt.Fprint(pw, "data: [DONE]\n")
|
||||
}()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
resp := &http.Response{Body: pr}
|
||||
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}}
|
||||
|
||||
var count atomic.Int64
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
|
||||
count.Add(1)
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for stream to finish")
|
||||
}
|
||||
|
||||
assert.Equal(t, int64(4), count.Load())
|
||||
|
||||
body := recorder.Body.String()
|
||||
pingCount := strings.Count(body, ": PING")
|
||||
assert.GreaterOrEqual(t, pingCount, 1,
|
||||
"expected at least 1 ping during slow stream with 1s interval; got %d", pingCount)
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_PingDisabledByRelayInfo(t *testing.T) {
|
||||
setting := operation_setting.GetGeneralSetting()
|
||||
oldEnabled := setting.PingIntervalEnabled
|
||||
oldSeconds := setting.PingIntervalSeconds
|
||||
setting.PingIntervalEnabled = true
|
||||
setting.PingIntervalSeconds = 1
|
||||
t.Cleanup(func() {
|
||||
setting.PingIntervalEnabled = oldEnabled
|
||||
setting.PingIntervalSeconds = oldSeconds
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
resp := &http.Response{Body: io.NopCloser(strings.NewReader(buildSSEBody(5)))}
|
||||
info := &relaycommon.RelayInfo{
|
||||
DisablePing: true,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{},
|
||||
}
|
||||
|
||||
var count atomic.Int64
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
|
||||
count.Add(1)
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out")
|
||||
}
|
||||
|
||||
assert.Equal(t, int64(5), count.Load())
|
||||
|
||||
body := recorder.Body.String()
|
||||
pingCount := strings.Count(body, ": PING")
|
||||
assert.Equal(t, 0, pingCount, "pings should be disabled when DisablePing=true")
|
||||
}
|
||||
|
||||
// ---------- StreamStatus integration ----------
|
||||
|
||||
func TestStreamScannerHandler_StreamStatus_DoneReason(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := buildSSEBody(10)
|
||||
c, resp, info := setupStreamTest(t, strings.NewReader(body))
|
||||
|
||||
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {})
|
||||
|
||||
require.NotNil(t, info.StreamStatus)
|
||||
assert.Equal(t, relaycommon.StreamEndReasonDone, info.StreamStatus.EndReason)
|
||||
assert.Nil(t, info.StreamStatus.EndError)
|
||||
assert.True(t, info.StreamStatus.IsNormalEnd())
|
||||
assert.False(t, info.StreamStatus.HasErrors())
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_StreamStatus_EOFWithoutDone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
for i := range 5 {
|
||||
fmt.Fprintf(&b, "data: {\"id\":%d}\n", i)
|
||||
}
|
||||
c, resp, info := setupStreamTest(t, strings.NewReader(b.String()))
|
||||
|
||||
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {})
|
||||
|
||||
require.NotNil(t, info.StreamStatus)
|
||||
assert.Equal(t, relaycommon.StreamEndReasonEOF, info.StreamStatus.EndReason)
|
||||
assert.True(t, info.StreamStatus.IsNormalEnd())
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_StreamStatus_HandlerStop(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := buildSSEBody(100)
|
||||
c, resp, info := setupStreamTest(t, strings.NewReader(body))
|
||||
|
||||
var count atomic.Int64
|
||||
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
|
||||
n := count.Add(1)
|
||||
if n >= 10 {
|
||||
sr.Stop(fmt.Errorf("stop at 10"))
|
||||
}
|
||||
})
|
||||
|
||||
require.NotNil(t, info.StreamStatus)
|
||||
assert.Equal(t, relaycommon.StreamEndReasonHandlerStop, info.StreamStatus.EndReason)
|
||||
assert.True(t, info.StreamStatus.HasErrors())
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_StreamStatus_HandlerDone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := buildSSEBody(20)
|
||||
c, resp, info := setupStreamTest(t, strings.NewReader(body))
|
||||
|
||||
var count atomic.Int64
|
||||
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
|
||||
n := count.Add(1)
|
||||
if n >= 5 {
|
||||
sr.Done()
|
||||
}
|
||||
})
|
||||
|
||||
assert.Equal(t, int64(5), count.Load())
|
||||
require.NotNil(t, info.StreamStatus)
|
||||
assert.Equal(t, relaycommon.StreamEndReasonDone, info.StreamStatus.EndReason)
|
||||
assert.False(t, info.StreamStatus.HasErrors())
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_StreamStatus_Timeout(t *testing.T) {
|
||||
// Not parallel: modifies global constant.StreamingTimeout
|
||||
oldTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 1
|
||||
t.Cleanup(func() { constant.StreamingTimeout = oldTimeout })
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
go func() {
|
||||
fmt.Fprint(pw, "data: {\"id\":1}\n")
|
||||
time.Sleep(2 * time.Second)
|
||||
pw.Close()
|
||||
}()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
resp := &http.Response{Body: pr}
|
||||
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for stream timeout")
|
||||
}
|
||||
|
||||
require.NotNil(t, info.StreamStatus)
|
||||
assert.Equal(t, relaycommon.StreamEndReasonTimeout, info.StreamStatus.EndReason)
|
||||
assert.False(t, info.StreamStatus.IsNormalEnd())
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_StreamStatus_SoftErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := buildSSEBody(10)
|
||||
c, resp, info := setupStreamTest(t, strings.NewReader(body))
|
||||
|
||||
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
|
||||
sr.Error(fmt.Errorf("soft error for chunk"))
|
||||
})
|
||||
|
||||
require.NotNil(t, info.StreamStatus)
|
||||
assert.Equal(t, relaycommon.StreamEndReasonDone, info.StreamStatus.EndReason)
|
||||
assert.True(t, info.StreamStatus.HasErrors())
|
||||
assert.Equal(t, 10, info.StreamStatus.TotalErrorCount())
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_StreamStatus_MultipleErrorsPerChunk(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := buildSSEBody(5)
|
||||
c, resp, info := setupStreamTest(t, strings.NewReader(body))
|
||||
|
||||
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
|
||||
sr.Error(fmt.Errorf("error A"))
|
||||
sr.Error(fmt.Errorf("error B"))
|
||||
})
|
||||
|
||||
require.NotNil(t, info.StreamStatus)
|
||||
assert.Equal(t, relaycommon.StreamEndReasonDone, info.StreamStatus.EndReason)
|
||||
assert.Equal(t, 10, info.StreamStatus.TotalErrorCount())
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_StreamStatus_ErrorThenStop(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Use a large body without [DONE] to avoid race between scanner's [DONE]
|
||||
// and handler's Stop on the sync.Once EndReason.
|
||||
var b strings.Builder
|
||||
for i := range 100 {
|
||||
fmt.Fprintf(&b, "data: {\"id\":%d}\n", i)
|
||||
}
|
||||
c, resp, info := setupStreamTest(t, strings.NewReader(b.String()))
|
||||
|
||||
var count atomic.Int64
|
||||
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
|
||||
count.Add(1)
|
||||
sr.Error(fmt.Errorf("soft error"))
|
||||
sr.Stop(fmt.Errorf("fatal"))
|
||||
})
|
||||
|
||||
assert.Equal(t, int64(1), count.Load())
|
||||
require.NotNil(t, info.StreamStatus)
|
||||
assert.Equal(t, relaycommon.StreamEndReasonHandlerStop, info.StreamStatus.EndReason)
|
||||
assert.Equal(t, 2, info.StreamStatus.TotalErrorCount())
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_StreamStatus_InitializedIfNil(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := buildSSEBody(1)
|
||||
c, resp, info := setupStreamTest(t, strings.NewReader(body))
|
||||
|
||||
assert.Nil(t, info.StreamStatus)
|
||||
|
||||
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {})
|
||||
|
||||
assert.NotNil(t, info.StreamStatus)
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_StreamStatus_ReplacesPreInitialized(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := buildSSEBody(5)
|
||||
c, resp, info := setupStreamTest(t, strings.NewReader(body))
|
||||
|
||||
info.StreamStatus = relaycommon.NewStreamStatus()
|
||||
info.StreamStatus.RecordError("pre-existing error")
|
||||
|
||||
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {})
|
||||
|
||||
assert.Equal(t, relaycommon.StreamEndReasonDone, info.StreamStatus.EndReason)
|
||||
assert.Equal(t, 0, info.StreamStatus.TotalErrorCount())
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/samber/lo"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetAndValidateRequest(c *gin.Context, format types.RelayFormat) (request dto.Request, err error) {
|
||||
relayMode := relayconstant.Path2RelayMode(c.Request.URL.Path)
|
||||
|
||||
switch format {
|
||||
case types.RelayFormatOpenAI:
|
||||
request, err = GetAndValidateTextRequest(c, relayMode)
|
||||
case types.RelayFormatGemini:
|
||||
if strings.Contains(c.Request.URL.Path, ":embedContent") {
|
||||
request, err = GetAndValidateGeminiEmbeddingRequest(c)
|
||||
} else if strings.Contains(c.Request.URL.Path, ":batchEmbedContents") {
|
||||
request, err = GetAndValidateGeminiBatchEmbeddingRequest(c)
|
||||
} else {
|
||||
request, err = GetAndValidateGeminiRequest(c)
|
||||
}
|
||||
case types.RelayFormatClaude:
|
||||
request, err = GetAndValidateClaudeRequest(c)
|
||||
case types.RelayFormatOpenAIResponses:
|
||||
request, err = GetAndValidateResponsesRequest(c)
|
||||
case types.RelayFormatOpenAIResponsesCompaction:
|
||||
request, err = GetAndValidateResponsesCompactionRequest(c)
|
||||
case types.RelayFormatOpenAIAlphaSearch:
|
||||
request, err = GetAndValidateAlphaSearchRequest(c)
|
||||
|
||||
case types.RelayFormatOpenAIImage:
|
||||
request, err = GetAndValidOpenAIImageRequest(c, relayMode)
|
||||
case types.RelayFormatEmbedding:
|
||||
request, err = GetAndValidateEmbeddingRequest(c, relayMode)
|
||||
case types.RelayFormatRerank:
|
||||
request, err = GetAndValidateRerankRequest(c)
|
||||
case types.RelayFormatOpenAIAudio:
|
||||
request, err = GetAndValidAudioRequest(c, relayMode)
|
||||
case types.RelayFormatOpenAIRealtime:
|
||||
request = &dto.BaseRequest{}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported relay format: %s", format)
|
||||
}
|
||||
return request, err
|
||||
}
|
||||
|
||||
func GetAndValidAudioRequest(c *gin.Context, relayMode int) (*dto.AudioRequest, error) {
|
||||
audioRequest := &dto.AudioRequest{}
|
||||
err := common.UnmarshalBodyReusable(c, audioRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch relayMode {
|
||||
case relayconstant.RelayModeAudioSpeech:
|
||||
if audioRequest.Model == "" {
|
||||
return nil, errors.New("model is required")
|
||||
}
|
||||
default:
|
||||
if audioRequest.Model == "" {
|
||||
return nil, errors.New("model is required")
|
||||
}
|
||||
if audioRequest.ResponseFormat == "" {
|
||||
audioRequest.ResponseFormat = "json"
|
||||
}
|
||||
}
|
||||
return audioRequest, nil
|
||||
}
|
||||
|
||||
func GetAndValidateRerankRequest(c *gin.Context) (*dto.RerankRequest, error) {
|
||||
var rerankRequest *dto.RerankRequest
|
||||
err := common.UnmarshalBodyReusable(c, &rerankRequest)
|
||||
if err != nil {
|
||||
logger.LogError(c, fmt.Sprintf("getAndValidateTextRequest failed: %s", err.Error()))
|
||||
return nil, types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
if rerankRequest.Query == "" {
|
||||
return nil, types.NewError(fmt.Errorf("query is empty"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
if len(rerankRequest.Documents) == 0 {
|
||||
return nil, types.NewError(fmt.Errorf("documents is empty"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
return rerankRequest, nil
|
||||
}
|
||||
|
||||
func GetAndValidateEmbeddingRequest(c *gin.Context, relayMode int) (*dto.EmbeddingRequest, error) {
|
||||
var embeddingRequest *dto.EmbeddingRequest
|
||||
err := common.UnmarshalBodyReusable(c, &embeddingRequest)
|
||||
if err != nil {
|
||||
logger.LogError(c, fmt.Sprintf("getAndValidateTextRequest failed: %s", err.Error()))
|
||||
return nil, types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
if embeddingRequest.Input == nil {
|
||||
return nil, fmt.Errorf("input is empty")
|
||||
}
|
||||
if relayMode == relayconstant.RelayModeModerations && embeddingRequest.Model == "" {
|
||||
embeddingRequest.Model = "omni-moderation-latest"
|
||||
}
|
||||
if relayMode == relayconstant.RelayModeEmbeddings && embeddingRequest.Model == "" {
|
||||
embeddingRequest.Model = c.Param("model")
|
||||
}
|
||||
return embeddingRequest, nil
|
||||
}
|
||||
|
||||
// maxTokensLimit bounds user-supplied max token fields. These values feed
|
||||
// pre-consume quota math (preConsumedTokens * ratio); an unbounded value can
|
||||
// overflow the conversion and corrupt billing.
|
||||
const maxTokensLimit = math.MaxInt32 / 2
|
||||
|
||||
func exceedsMaxTokensLimit(values ...*uint) bool {
|
||||
for _, v := range values {
|
||||
if lo.FromPtrOr(v, uint(0)) > maxTokensLimit {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func GetAndValidateResponsesRequest(c *gin.Context) (*dto.OpenAIResponsesRequest, error) {
|
||||
request := &dto.OpenAIResponsesRequest{}
|
||||
err := common.UnmarshalBodyReusable(c, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if request.Model == "" {
|
||||
return nil, errors.New("model is required")
|
||||
}
|
||||
if request.Input == nil {
|
||||
return nil, errors.New("input is required")
|
||||
}
|
||||
if exceedsMaxTokensLimit(request.MaxOutputTokens) {
|
||||
return nil, errors.New("max_output_tokens is invalid")
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func GetAndValidateAlphaSearchRequest(c *gin.Context) (*dto.AlphaSearchRequest, error) {
|
||||
request := &dto.AlphaSearchRequest{}
|
||||
if err := common.UnmarshalBodyReusable(c, request); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if request.Model == "" {
|
||||
return nil, errors.New("model is required")
|
||||
}
|
||||
storage, err := common.GetBodyStorage(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawBody, err := storage.Bytes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.RawBody = rawBody
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func GetAndValidateResponsesCompactionRequest(c *gin.Context) (*dto.OpenAIResponsesCompactionRequest, error) {
|
||||
request := &dto.OpenAIResponsesCompactionRequest{}
|
||||
if err := common.UnmarshalBodyReusable(c, request); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if request.Model == "" {
|
||||
return nil, errors.New("model is required")
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageRequest, error) {
|
||||
imageRequest := &dto.ImageRequest{}
|
||||
|
||||
switch relayMode {
|
||||
case relayconstant.RelayModeImagesEdits:
|
||||
if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
|
||||
form, err := common.ParseMultipartFormReusable(c)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse image edit form request: %w", err)
|
||||
}
|
||||
formData := url.Values(form.Value)
|
||||
c.Request.MultipartForm = form
|
||||
c.Request.PostForm = formData
|
||||
imageRequest.Prompt = formData.Get("prompt")
|
||||
imageRequest.Model = formData.Get("model")
|
||||
if nValue := strings.TrimSpace(formData.Get("n")); nValue != "" {
|
||||
n, err := strconv.Atoi(nValue)
|
||||
if err != nil || n < 0 || n > dto.MaxImageN {
|
||||
return nil, fmt.Errorf("n must be an integer between 1 and %d", dto.MaxImageN)
|
||||
}
|
||||
imageRequest.N = common.GetPointer(uint(n))
|
||||
}
|
||||
imageRequest.Quality = formData.Get("quality")
|
||||
imageRequest.Size = formData.Get("size")
|
||||
if streamValue := strings.TrimSpace(formData.Get("stream")); streamValue != "" {
|
||||
stream, err := strconv.ParseBool(streamValue)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid stream value: %w", err)
|
||||
}
|
||||
imageRequest.Stream = common.GetPointer(stream)
|
||||
}
|
||||
if imageValue := formData.Get("image"); imageValue != "" {
|
||||
imageRequest.Image, _ = common.Marshal(imageValue)
|
||||
}
|
||||
|
||||
if imageRequest.Model == "gpt-image-1" {
|
||||
if imageRequest.Quality == "" {
|
||||
imageRequest.Quality = "standard"
|
||||
}
|
||||
}
|
||||
if imageRequest.N == nil || *imageRequest.N == 0 {
|
||||
imageRequest.N = common.GetPointer(uint(1))
|
||||
}
|
||||
|
||||
hasWatermark := formData.Has("watermark")
|
||||
if hasWatermark {
|
||||
watermark := formData.Get("watermark") == "true"
|
||||
imageRequest.Watermark = &watermark
|
||||
}
|
||||
break
|
||||
}
|
||||
fallthrough
|
||||
default:
|
||||
err := common.UnmarshalBodyReusable(c, imageRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if imageRequest.Model == "" {
|
||||
//imageRequest.Model = "dall-e-3"
|
||||
return nil, errors.New("model is required")
|
||||
}
|
||||
|
||||
if strings.Contains(imageRequest.Size, "×") {
|
||||
return nil, errors.New("size an unexpected error occurred in the parameter, please use 'x' instead of the multiplication sign '×'")
|
||||
}
|
||||
|
||||
if imageRequest.N != nil && *imageRequest.N > dto.MaxImageN {
|
||||
return nil, fmt.Errorf("n must be an integer between 1 and %d", dto.MaxImageN)
|
||||
}
|
||||
|
||||
// Not "256x256", "512x512", or "1024x1024"
|
||||
if imageRequest.Model == "dall-e-2" || imageRequest.Model == "dall-e" {
|
||||
if imageRequest.Size != "" && imageRequest.Size != "256x256" && imageRequest.Size != "512x512" && imageRequest.Size != "1024x1024" {
|
||||
return nil, errors.New("size must be one of 256x256, 512x512, or 1024x1024 for dall-e-2 or dall-e")
|
||||
}
|
||||
if imageRequest.Size == "" {
|
||||
imageRequest.Size = "1024x1024"
|
||||
}
|
||||
} else if imageRequest.Model == "dall-e-3" {
|
||||
if imageRequest.Size != "" && imageRequest.Size != "1024x1024" && imageRequest.Size != "1024x1792" && imageRequest.Size != "1792x1024" {
|
||||
return nil, errors.New("size must be one of 1024x1024, 1024x1792 or 1792x1024 for dall-e-3")
|
||||
}
|
||||
if imageRequest.Quality == "" {
|
||||
imageRequest.Quality = "standard"
|
||||
}
|
||||
if imageRequest.Size == "" {
|
||||
imageRequest.Size = "1024x1024"
|
||||
}
|
||||
} else if imageRequest.Model == "gpt-image-1" {
|
||||
if imageRequest.Quality == "" {
|
||||
imageRequest.Quality = "auto"
|
||||
}
|
||||
}
|
||||
|
||||
//if imageRequest.Prompt == "" {
|
||||
// return nil, errors.New("prompt is required")
|
||||
//}
|
||||
|
||||
if imageRequest.N == nil || *imageRequest.N == 0 {
|
||||
imageRequest.N = common.GetPointer(uint(1))
|
||||
}
|
||||
}
|
||||
|
||||
return imageRequest, nil
|
||||
}
|
||||
|
||||
func GetAndValidateClaudeRequest(c *gin.Context) (textRequest *dto.ClaudeRequest, err error) {
|
||||
textRequest = &dto.ClaudeRequest{}
|
||||
err = common.UnmarshalBodyReusable(c, textRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if textRequest.Messages == nil || len(textRequest.Messages) == 0 {
|
||||
return nil, errors.New("field messages is required")
|
||||
}
|
||||
if textRequest.Model == "" {
|
||||
return nil, errors.New("field model is required")
|
||||
}
|
||||
if exceedsMaxTokensLimit(textRequest.MaxTokens, textRequest.MaxTokensToSample) {
|
||||
return nil, errors.New("max_tokens is invalid")
|
||||
}
|
||||
|
||||
//if textRequest.Stream {
|
||||
// relayInfo.IsStream = true
|
||||
//}
|
||||
|
||||
return textRequest, nil
|
||||
}
|
||||
|
||||
func GetAndValidateTextRequest(c *gin.Context, relayMode int) (*dto.GeneralOpenAIRequest, error) {
|
||||
textRequest := &dto.GeneralOpenAIRequest{}
|
||||
err := common.UnmarshalBodyReusable(c, textRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if relayMode == relayconstant.RelayModeModerations && textRequest.Model == "" {
|
||||
textRequest.Model = "text-moderation-latest"
|
||||
}
|
||||
if relayMode == relayconstant.RelayModeEmbeddings && textRequest.Model == "" {
|
||||
textRequest.Model = c.Param("model")
|
||||
}
|
||||
|
||||
if exceedsMaxTokensLimit(textRequest.MaxTokens, textRequest.MaxCompletionTokens) {
|
||||
return nil, errors.New("max_tokens is invalid")
|
||||
}
|
||||
if textRequest.Model == "" {
|
||||
return nil, errors.New("model is required")
|
||||
}
|
||||
if textRequest.WebSearchOptions != nil {
|
||||
if textRequest.WebSearchOptions.SearchContextSize != "" {
|
||||
validSizes := map[string]bool{
|
||||
"high": true,
|
||||
"medium": true,
|
||||
"low": true,
|
||||
}
|
||||
if !validSizes[textRequest.WebSearchOptions.SearchContextSize] {
|
||||
return nil, errors.New("invalid search_context_size, must be one of: high, medium, low")
|
||||
}
|
||||
} else {
|
||||
textRequest.WebSearchOptions.SearchContextSize = "medium"
|
||||
}
|
||||
}
|
||||
switch relayMode {
|
||||
case relayconstant.RelayModeCompletions:
|
||||
if textRequest.Prompt == "" {
|
||||
return nil, errors.New("field prompt is required")
|
||||
}
|
||||
case relayconstant.RelayModeChatCompletions:
|
||||
// For FIM (Fill-in-the-middle) requests with prefix/suffix, messages is optional
|
||||
// It will be filled by provider-specific adaptors if needed (e.g., SiliconFlow)。Or it is allowed by model vendor(s) (e.g., DeepSeek)
|
||||
if len(textRequest.Messages) == 0 && textRequest.Prefix == nil && textRequest.Suffix == nil {
|
||||
return nil, errors.New("field messages is required")
|
||||
}
|
||||
case relayconstant.RelayModeEmbeddings:
|
||||
case relayconstant.RelayModeModerations:
|
||||
if textRequest.Input == nil || textRequest.Input == "" {
|
||||
return nil, errors.New("field input is required")
|
||||
}
|
||||
case relayconstant.RelayModeEdits:
|
||||
if textRequest.Instruction == "" {
|
||||
return nil, errors.New("field instruction is required")
|
||||
}
|
||||
}
|
||||
return textRequest, nil
|
||||
}
|
||||
|
||||
func GetAndValidateGeminiRequest(c *gin.Context) (*dto.GeminiChatRequest, error) {
|
||||
request := &dto.GeminiChatRequest{}
|
||||
err := common.UnmarshalBodyReusable(c, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(request.Contents) == 0 && len(request.Requests) == 0 {
|
||||
return nil, errors.New("contents is required")
|
||||
}
|
||||
if exceedsMaxTokensLimit(request.GenerationConfig.MaxOutputTokens) {
|
||||
return nil, errors.New("maxOutputTokens is invalid")
|
||||
}
|
||||
|
||||
//if c.Query("alt") == "sse" {
|
||||
// relayInfo.IsStream = true
|
||||
//}
|
||||
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func GetAndValidateGeminiEmbeddingRequest(c *gin.Context) (*dto.GeminiEmbeddingRequest, error) {
|
||||
request := &dto.GeminiEmbeddingRequest{}
|
||||
err := common.UnmarshalBodyReusable(c, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func GetAndValidateGeminiBatchEmbeddingRequest(c *gin.Context) (*dto.GeminiBatchEmbeddingRequest, error) {
|
||||
request := &dto.GeminiBatchEmbeddingRequest{}
|
||||
err := common.UnmarshalBodyReusable(c, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
Reference in new issue
Block a user