feat: publish sanitized custom.97 source

This commit is contained in:
cneicy committed 2026-10-27 20:43:39 +08:00
1 parent 8e6ed0317b
commit 86c0276156
1022 files changed
+69903 -19207

No files matched your search

+1 -12
View File
@@ -101,18 +101,7 @@ func AlphaSearchHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError
}
// Upstream alpha search returns no usage; bill one web_search_preview call.
if info.ResponsesUsageInfo == nil {
info.ResponsesUsageInfo = &relaycommon.ResponsesUsageInfo{
BuiltInTools: make(map[string]*relaycommon.BuildInToolInfo),
}
}
if info.ResponsesUsageInfo.BuiltInTools == nil {
info.ResponsesUsageInfo.BuiltInTools = make(map[string]*relaycommon.BuildInToolInfo)
}
info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview] = &relaycommon.BuildInToolInfo{
ToolName: dto.BuildInToolWebSearchPreview,
CallCount: 1,
}
info.SetBillableToolCount(dto.BuildInToolWebSearchPreview, 1)
usage := &dto.Usage{}
service.PostTextConsumeQuota(c, info, usage, nil)
+117
View File
@@ -1,9 +1,20 @@
package relay
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -45,3 +56,109 @@ func TestBuildAlphaSearchRequestBodyNoMappingKeepsRawBytes(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, raw, out)
}
type recordedSettlement struct {
settled []int
refunded bool
}
func (s *recordedSettlement) Settle(quota int) error {
s.settled = append(s.settled, quota)
return nil
}
func (s *recordedSettlement) Refund(*gin.Context) { s.refunded = true }
func (*recordedSettlement) NeedsRefund() bool { return false }
func (*recordedSettlement) GetPreConsumedQuota() int { return 0 }
func (*recordedSettlement) Reserve(int) error { return nil }
// /v1/tools/search and /v1/tools/fetch follow /v1/alpha/search: the raw body
// reaches /v1/tools/* unchanged and the response returns unchanged. With only
// the built-in prices, a response DeepSeek bills (results, page content)
// settles one call at the routing name's request price and no tokens, an
// empty one releases the reservation, an upstream error keeps its status, and
// a channel that cannot relay the endpoint fails retryably without sending
// anything.
func TestDeepSeekToolsHelper(t *testing.T) {
service.InitHttpClient()
gin.SetMode(gin.TestMode)
batch, logConsume := common.BatchUpdateEnabled, common.LogConsumeEnabled
common.BatchUpdateEnabled, common.LogConsumeEnabled = true, false
t.Cleanup(func() { common.BatchUpdateEnabled, common.LogConsumeEnabled = batch, logConsume })
const search, fetch = "/v1/tools/search", "/v1/tools/fetch"
const results = `{"results":[{"title":"DeepSeek","url":"https://deepseek.com/","rerank_score":2.1}]}`
tests := []struct {
name, path, body, response string
channelType, status int
settled []int
refunded bool
}{
{name: "search with results", path: search, body: `{"query":"deepseek","count":2,"future_field":{"nested":true}}`,
response: results, channelType: constant.ChannelTypeDeepSeek, status: http.StatusOK, settled: []int{375}},
{name: "search without results", path: search, body: `{"query":"site:none.invalid deepseek"}`,
response: `{"results":[]}`, channelType: constant.ChannelTypeDeepSeek, status: http.StatusOK, refunded: true},
{name: "fetch with page content", path: fetch, body: `{"url":"https://example.com/"}`,
response: `{"title":"Example Domain","content":"# Example Domain"}`, channelType: constant.ChannelTypeDeepSeek, status: http.StatusOK, settled: []int{375}},
{name: "fetch failure keeps its status", path: fetch, body: `{"url":"https://example.com/missing"}`,
response: `{"error":{"message":"failed to fetch URL","type":"not_found_error","param":null,"code":"invalid_request_error"}}`,
channelType: constant.ChannelTypeDeepSeek, status: http.StatusNotFound},
{name: "search through a New API channel", path: search, body: `{"query":"deepseek"}`,
response: results, channelType: constant.ChannelTypeNewAPI, status: http.StatusOK, settled: []int{375}},
{name: "channel without the endpoint", path: search, body: `{"query":"deepseek"}`, channelType: constant.ChannelTypeOpenAI},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var gotPath string
var gotBody []byte
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotBody, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tc.status)
_, _ = io.WriteString(w, tc.response)
}))
t.Cleanup(upstream.Close)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.body))
c.Request.Header.Set("Content-Type", "application/json")
routingName := map[string]string{search: constant.DeepSeekSearchModel, fetch: constant.DeepSeekFetchModel}[tc.path]
common.SetContextKey(c, constant.ContextKeyOriginalModel, routingName)
common.SetContextKey(c, constant.ContextKeyUsingGroup, "default")
common.SetContextKey(c, constant.ContextKeyUserGroup, "default")
common.SetContextKey(c, constant.ContextKeyChannelType, tc.channelType)
common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, upstream.URL)
common.SetContextKey(c, constant.ContextKeyChannelKey, "sk-test")
request, err := helper.GetAndValidateRequest(c, types.RelayFormatDeepSeekTools)
require.NoError(t, err)
info, err := relaycommon.GenRelayInfo(c, types.RelayFormatDeepSeekTools, request, nil)
require.NoError(t, err)
info.BillingRequestInput = &billingexpr.RequestInput{Body: []byte(tc.body)}
_, err = helper.ModelPriceHelper(c, info, 0, request.GetTokenCountMeta())
require.NoError(t, err, "the routing name is priced out of the box")
settlement := &recordedSettlement{}
info.Billing = settlement
apiErr := DeepSeekToolsHelper(c, info)
if tc.status == 0 {
require.NotNil(t, apiErr)
assert.False(t, types.IsSkipRetryError(apiErr))
assert.Empty(t, gotPath)
return
}
assert.Equal(t, tc.path, gotPath)
assert.Equal(t, tc.body, string(gotBody))
assert.Equal(t, tc.settled, settlement.settled)
assert.Equal(t, tc.refunded, settlement.refunded)
if tc.status != http.StatusOK {
require.NotNil(t, apiErr)
assert.Equal(t, tc.status, apiErr.StatusCode)
return
}
require.Nil(t, apiErr)
assert.Equal(t, tc.status, recorder.Code)
assert.Equal(t, tc.response, recorder.Body.String())
})
}
}
+1 -1
View File
@@ -68,7 +68,7 @@ func AudioHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
return newAPIError
}
if usage.(*dto.Usage).CompletionTokenDetails.AudioTokens > 0 || usage.(*dto.Usage).PromptTokensDetails.AudioTokens > 0 {
service.PostAudioConsumeQuota(c, info, usage.(*dto.Usage), "")
service.PostAudioConsumeQuota(c, info, usage.(*dto.Usage), nil)
} else {
service.PostTextConsumeQuota(c, info, usage.(*dto.Usage), nil)
}
+2 -1
View File
@@ -1,6 +1,7 @@
package channel
import (
"context"
"io"
"net/http"
@@ -114,7 +115,7 @@ type TaskContentRequest struct {
}
type TaskContentRequestProvider interface {
BuildContentRequest(task *model.Task, artifactKey string, clientRequest TaskArtifactClientRequest) (*TaskContentRequest, error)
BuildContentRequest(ctx context.Context, task *model.Task, artifactKey string, clientRequest TaskArtifactClientRequest) (*TaskContentRequest, error)
}
type TaskUsageFactsProvider interface {
+22 -1
View File
@@ -78,6 +78,8 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
return a.claudeAdaptor.ConvertClaudeRequest(c, info, request)
case relayconvert.ConverterClaudeMessagesToOpenAIChat:
return a.convertCrossProtocolChatRequest(c, info, converter, request)
case relayconvert.ConverterClaudeMessagesToOpenAIResponses:
return convertCrossProtocolResponsesRequest(c, info, converter, request)
default:
return nil, fmt.Errorf("converter %q does not support Anthropic Messages requests", converter)
}
@@ -94,6 +96,8 @@ func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayIn
return a.geminiAdaptor.ConvertGeminiRequest(c, info, request)
case relayconvert.ConverterGeminiContentToOpenAIChat:
return a.convertCrossProtocolChatRequest(c, info, converter, request)
case relayconvert.ConverterGeminiContentToOpenAIResponses:
return convertCrossProtocolResponsesRequest(c, info, converter, request)
default:
return nil, fmt.Errorf("converter %q does not support Gemini generateContent requests", converter)
}
@@ -296,7 +300,10 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
return a.geminiAdaptor.DoResponse(c, resp, info)
case relayconvert.ConverterOpenAIResponsesToGemini:
return a.geminiAdaptor.DoResponse(c, resp, info)
case relayconvert.ConverterOpenAIChatToOpenAIResponses:
case relayconvert.ConverterOpenAIChatToOpenAIResponses,
relayconvert.ConverterClaudeMessagesToOpenAIResponses,
relayconvert.ConverterGeminiContentToOpenAIResponses:
// These handlers convert the Responses upstream into info.RelayFormat.
if info.IsStream {
return openai.OaiResponsesToChatStreamHandler(c, info, resp)
}
@@ -531,6 +538,20 @@ func (a *Adaptor) convertCrossProtocolChatRequest(c *gin.Context, info *relaycom
return a.convertOpenAICompatibleRequest(c, info, chatRequest)
}
// convertCrossProtocolResponsesRequest converts a Claude or Gemini client
// request for an OpenAI Responses upstream.
func convertCrossProtocolResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, converter string, request any) (any, error) {
result, err := service.ConvertRequestByID(c, info, converter, request)
if err != nil {
return nil, err
}
responsesRequest, ok := result.Value.(*dto.OpenAIResponsesRequest)
if !ok {
return nil, fmt.Errorf("expected OpenAI Responses request, got %T", result.Value)
}
return responsesRequest, nil
}
func (a *Adaptor) convertOpenAICompatibleRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
old := info.ChannelType
info.ChannelType = constant.ChannelTypeOpenAI
@@ -2,10 +2,12 @@ package advancedcustom
import (
"bytes"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
@@ -19,6 +21,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestAdaptorUsesExactRouteAndQueryAuth(t *testing.T) {
@@ -51,6 +54,47 @@ func TestAdaptorUsesExactRouteAndQueryAuth(t *testing.T) {
assert.Equal(t, "sk-test", parsedURL.Query().Get("api_key"))
}
// An absolute route sends the request to a host other than the channel base
// URL, and a channel proxy is dialed before the upstream; a failed handshake
// must hide both.
func TestAdaptorMasksDialedHostsInDialFailure(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer upstream.Close()
upstreamURL, err := url.Parse(upstream.URL)
require.NoError(t, err)
for _, tc := range []struct {
name string
proxy string
hidden string
}{
{name: "absolute route host", hidden: "localhost"},
{name: "channel proxy host", proxy: "http://egress.private.invalid:8080", hidden: "egress.private.invalid"},
} {
t.Run(tc.name, func(t *testing.T) {
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
Routes: []dto.AdvancedCustomRoute{{IncomingPath: "/v1/realtime", UpstreamPath: "http://localhost:" + upstreamURL.Port() + "/v1/realtime", Converter: relayconvert.ConverterNone}},
})
info.RelayMode = relayconstant.RelayModeRealtime
info.RequestURLPath = "/v1/realtime"
info.ChannelSetting.Proxy = tc.proxy
c := advancedCustomGinContext("/v1/realtime")
common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, info.ChannelBaseUrl)
common.SetContextKey(c, constant.ContextKeyChannelSetting, info.ChannelSetting)
_, err := (&Adaptor{}).DoRequest(c, info, nil)
var apiErr *types.NewAPIError
require.ErrorAs(t, err, &apiErr)
require.Contains(t, apiErr.Error(), tc.hidden)
message := apiErr.ToOpenAIError(common.UpstreamAddresses(c)...).Message
assert.Contains(t, message, "dial failed to ws://***:"+upstreamURL.Port()+"/***/***")
assert.NotContains(t, message, tc.hidden)
})
}
}
func TestAdaptorJoinsUpstreamPathWithChannelBaseURL(t *testing.T) {
adaptor := &Adaptor{}
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
@@ -966,6 +1010,98 @@ func TestAdaptorCrossProtocolChatUpstreamRequestsStreamUsage(t *testing.T) {
}
}
// Claude Messages and Gemini Generate Content clients on an OpenAI Responses
// upstream: the request carries the hosted web search as a Responses tool and
// the Responses answer, JSON or stream, is converted back to the client format.
func TestAdaptorConvertsClaudeAndGeminiRequestsToResponsesUpstream(t *testing.T) {
oldTimeout := constant.StreamingTimeout
constant.StreamingTimeout = 30
t.Cleanup(func() {
constant.StreamingTimeout = oldTimeout
})
const responsesJSON = `{"id":"resp_1","object":"response","model":"gpt-test","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"Go 1.25","annotations":[]}]}],"usage":{"input_tokens":12,"output_tokens":4,"total_tokens":16}}`
responsesStream := "data: " + `{"type":"response.created","sequence_number":0,"response":{"id":"resp_1","object":"response","model":"gpt-test","status":"in_progress","output":[]}}` + "\n\n" +
"data: " + `{"type":"response.output_item.added","sequence_number":1,"output_index":0,"item":{"type":"message","id":"msg_1","role":"assistant","status":"in_progress","content":[]}}` + "\n\n" +
"data: " + `{"type":"response.output_text.delta","sequence_number":2,"output_index":0,"item_id":"msg_1","content_index":0,"delta":"Go 1.25"}` + "\n\n" +
"data: " + `{"type":"response.completed","sequence_number":3,"response":` + responsesJSON + `}` + "\n\n"
tests := []struct {
name string
route dto.AdvancedCustomRoute
relayFormat types.RelayFormat
requestPath string
convert func(*Adaptor, *gin.Context, *relaycommon.RelayInfo) (any, error)
wantResponse map[bool]string // stream -> marker in the client body
}{
{
name: "claude",
route: dto.AdvancedCustomRoute{IncomingPath: "/v1/messages", UpstreamPath: "/v1/responses", Converter: relayconvert.ConverterClaudeMessagesToOpenAIResponses},
relayFormat: types.RelayFormatClaude,
requestPath: "/v1/messages",
convert: func(a *Adaptor, c *gin.Context, info *relaycommon.RelayInfo) (any, error) {
return a.ConvertClaudeRequest(c, info, &dto.ClaudeRequest{
Model: "gpt-test",
Messages: []dto.ClaudeMessage{{Role: "user", Content: "Latest Go?"}},
Tools: []any{map[string]any{"type": "web_search_20250305", "name": "web_search"}},
})
},
wantResponse: map[bool]string{false: `"type":"message"`, true: "event: message_stop"},
},
{
name: "gemini",
route: dto.AdvancedCustomRoute{IncomingPath: "/v1beta/models/{model}:generateContent", UpstreamPath: "/v1/responses", Converter: relayconvert.ConverterGeminiContentToOpenAIResponses},
relayFormat: types.RelayFormatGemini,
requestPath: "/v1beta/models/gpt-test:generateContent",
convert: func(a *Adaptor, c *gin.Context, info *relaycommon.RelayInfo) (any, error) {
return a.ConvertGeminiRequest(c, info, &dto.GeminiChatRequest{
Contents: []dto.GeminiChatContent{{Role: "user", Parts: []dto.GeminiPart{{Text: "Latest Go?"}}}},
Tools: mustAdvancedCustomRawMessage(t, []map[string]any{{"googleSearch": map[string]any{}}}),
})
},
wantResponse: map[bool]string{false: `"candidates"`, true: `"candidates"`},
},
}
for _, tt := range tests {
for _, stream := range []bool{false, true} {
t.Run(fmt.Sprintf("%s stream=%v", tt.name, stream), func(t *testing.T) {
adaptor := &Adaptor{}
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{Routes: []dto.AdvancedCustomRoute{tt.route}})
info.RelayFormat = tt.relayFormat
info.RequestURLPath = tt.requestPath
info.IsStream = stream
info.DisablePing = true
info.ClaudeConvertInfo = &relaycommon.ClaudeConvertInfo{LastMessagesType: relaycommon.LastMessageTypeNone}
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, tt.requestPath, nil)
converted, err := tt.convert(adaptor, c, info)
require.NoError(t, err)
wire, err := common.Marshal(converted)
require.NoError(t, err)
assert.JSONEq(t, `[{"type":"web_search"}]`, gjson.GetBytes(wire, "tools").Raw)
url, err := adaptor.GetRequestURL(info)
require.NoError(t, err)
assert.Equal(t, "https://fallback.example/v1/responses", url)
upstream, contentType := responsesJSON, "application/json"
if stream {
upstream, contentType = responsesStream, "text/event-stream"
}
usage, apiErr := adaptor.DoResponse(c, &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{contentType}},
Body: io.NopCloser(strings.NewReader(upstream)),
}, info)
require.Nil(t, apiErr)
assert.Equal(t, 12, usage.(*dto.Usage).PromptTokens)
assert.Contains(t, recorder.Body.String(), "Go 1.25")
assert.Contains(t, recorder.Body.String(), tt.wantResponse[stream])
})
}
}
}
func advancedCustomRelayInfo(config *dto.AdvancedCustomConfig) *relaycommon.RelayInfo {
return &relaycommon.RelayInfo{
RelayFormat: types.RelayFormatOpenAI,
+28 -88
View File
@@ -16,26 +16,17 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
type Adaptor struct {
IsSyncImageModel bool
}
const aliAnthropicMessagesModelsEnv = "ALI_ANTHROPIC_MESSAGES_MODELS"
const defaultAliAnthropicMessagesModels = "qwen,deepseek-v4,kimi,glm,minimax-m"
/*
var syncModels = []string{
"z-image",
"qwen-image",
"wan2.6",
}
*/
func supportsAliAnthropicMessages(modelName string) bool {
normalizedModelName := strings.ToLower(strings.TrimSpace(modelName))
if normalizedModelName == "" {
@@ -55,16 +46,6 @@ func aliAnthropicMessagesModelPatterns() []string {
})
}
var syncModels = []string{
"z-image",
"qwen-image",
"wan2.6",
}
func isSyncImageModel(modelName string) bool {
return model_setting.IsSyncImageModel(modelName)
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
@@ -72,6 +53,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
if supportsAliAnthropicMessages(info.UpstreamModelName) {
ensureAnthropicWebSearchEntrypoint(req)
return req, nil
}
@@ -90,6 +72,18 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
if info.RelayMode == constant.RelayModeResponses {
info.VendorToolUsage = aliResponsesToolUsage
}
if info.RelayFormat == types.RelayFormatClaude {
if supportsAliAnthropicMessages(info.UpstreamModelName) {
info.WebSearchBillingKey = aliSearchAgentKey
} else {
// Claude clients on models outside the Anthropic-compatible list
// are converted to compatible-mode Chat.
info.ConvOptions().WebSearch = encodeWebSearch(info)
}
}
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
@@ -108,21 +102,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
case constant.RelayModeRerank:
fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.ChannelBaseUrl)
case constant.RelayModeResponses:
fullRequestURL = fmt.Sprintf("%s/api/v2/apps/protocols/compatible-mode/v1/responses", info.ChannelBaseUrl)
case constant.RelayModeImagesGenerations:
if isSyncImageModel(info.UpstreamModelName) {
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl)
} else {
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.ChannelBaseUrl)
}
case constant.RelayModeImagesEdits:
if isOldWanModel(info.UpstreamModelName) {
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/image2image/image-synthesis", info.ChannelBaseUrl)
} else if isWanModel(info.UpstreamModelName) {
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/image-generation/generation", info.ChannelBaseUrl)
} else {
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl)
}
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/responses", info.ChannelBaseUrl)
case constant.RelayModeCompletions:
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/completions", info.ChannelBaseUrl)
default:
@@ -142,19 +122,6 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
if c.GetString("plugin") != "" {
req.Set("X-DashScope-Plugin", c.GetString("plugin"))
}
if info.RelayMode == constant.RelayModeImagesGenerations {
if isSyncImageModel(info.UpstreamModelName) {
} else {
req.Set("X-DashScope-Async", "enable")
}
}
if info.RelayMode == constant.RelayModeImagesEdits {
if isWanModel(info.UpstreamModelName) {
req.Set("X-DashScope-Async", "enable")
}
req.Set("Content-Type", "application/json")
}
return nil
}
@@ -181,44 +148,21 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
}
}
// ConvertImageRequest is not implemented: Ali image generation and editing
// are served by the alibaba task plugin through the openai_image host
// protocol, which claims every declared image model on /v1/images/*. A model
// reaching this adaptor is not declared by the plugin (or is named
// differently from the Bailian model list), so the request cannot be served.
// The rejection is a 400 that skips channel retries: every channel of this
// type refuses the same name, and a retryable 500 would only hide the
// misconfiguration behind unrelated channels.
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
if info.RelayMode == constant.RelayModeImagesGenerations {
if isSyncImageModel(info.UpstreamModelName) {
a.IsSyncImageModel = true
}
aliRequest, err := oaiImage2AliImageRequest(info, request, a.IsSyncImageModel)
if err != nil {
return nil, fmt.Errorf("convert image request to async ali image request failed: %w", err)
}
return aliRequest, nil
} else if info.RelayMode == constant.RelayModeImagesEdits {
if isOldWanModel(info.UpstreamModelName) {
return oaiFormEdit2WanxImageEdit(c, info, request)
}
if isSyncImageModel(info.UpstreamModelName) {
if isWanModel(info.UpstreamModelName) {
a.IsSyncImageModel = false
} else {
a.IsSyncImageModel = true
}
}
// ali image edit https://bailian.console.aliyun.com/?tab=api#/api/?type=model&url=2976416
// 如果用户使用表单,则需要解析表单数据
if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
aliRequest, err := oaiFormEdit2AliImageEdit(c, info, request)
if err != nil {
return nil, fmt.Errorf("convert image edit form request failed: %w", err)
}
return aliRequest, nil
} else {
aliRequest, err := oaiImage2AliImageRequest(info, request, a.IsSyncImageModel)
if err != nil {
return nil, fmt.Errorf("convert image request to async ali image request failed: %w", err)
}
return aliRequest, nil
}
}
return nil, fmt.Errorf("unsupported image relay mode: %d", info.RelayMode)
return nil, types.NewErrorWithStatusCode(
fmt.Errorf("image model %q is not served by the alibaba task plugin; use a Bailian image model name declared by the plugin or map it with the channel model mapping", info.UpstreamModelName),
types.ErrorCodeInvalidRequest,
http.StatusBadRequest,
types.ErrOptionWithSkipRetry(),
)
}
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
@@ -254,10 +198,6 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
return adaptor.DoResponse(c, resp, info)
default:
switch info.RelayMode {
case constant.RelayModeImagesGenerations:
err, usage = aliImageHandler(a, c, resp, info)
case constant.RelayModeImagesEdits:
err, usage = aliImageHandler(a, c, resp, info)
case constant.RelayModeRerank:
err, usage = RerankHandler(c, resp, info)
default:
+185 -171
View File
@@ -1,71 +1,24 @@
package ali
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
rootconstant "github.com/QuantumNous/new-api/constant"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/constant"
relayhelper "github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestAliMultipartEditsUseValidatedProviderQuantity(t *testing.T) {
for _, tc := range []struct {
name string
convert func(*gin.Context, *relaycommon.RelayInfo, dto.ImageRequest) (*AliImageRequest, error)
}{
{"multimodal edit", oaiFormEdit2AliImageEdit},
{"legacy Wan edit", oaiFormEdit2WanxImageEdit},
} {
t.Run(tc.name, func(t *testing.T) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
require.NoError(t, writer.WriteField("model", "fixture-image"))
require.NoError(t, writer.WriteField("n", "2"))
require.NoError(t, writer.WriteField("parameters", `{"n":3,"prompt_extend":false}`))
part, err := writer.CreateFormFile("image", "input.png")
require.NoError(t, err)
_, err = part.Write([]byte("fixture image"))
require.NoError(t, err)
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())
common.SetContextKey(c, rootconstant.ContextKeyChannelType, rootconstant.ChannelTypeAli)
request, err := relayhelper.GetAndValidOpenAIImageRequest(c, constant.RelayModeImagesEdits)
require.NoError(t, err)
info := &relaycommon.RelayInfo{Request: request}
converted, err := tc.convert(c, info, *request)
require.NoError(t, err)
require.NotNil(t, converted.Parameters.N)
assert.Equal(t, uint(3), *converted.Parameters.N)
require.NotNil(t, converted.Parameters.PromptExtend)
assert.False(t, *converted.Parameters.PromptExtend)
assert.Equal(t, uint(2), *request.N, "conversion must preserve the incoming request")
})
}
}
func TestConvertOpenAIRequestFiltersThinkingBudgetByUpstreamModel(t *testing.T) {
tests := []struct {
name string
@@ -178,147 +131,208 @@ func TestConvertOpenAIRequestPreservesExplicitZeroForMappedQwenModel(t *testing.
assert.Equal(t, int64(0), value.Int())
}
func TestMappedAliImageModelUsesUpstreamProtocol(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
info := &relaycommon.RelayInfo{
RelayMode: constant.RelayModeImagesGenerations,
OriginModelName: "customer-image-model",
ChannelMeta: &relaycommon.ChannelMeta{
ChannelBaseUrl: "https://dashscope.aliyuncs.com",
UpstreamModelName: "qwen-image-3.0-pro",
},
}
adaptor := &Adaptor{}
url, err := adaptor.GetRequestURL(info)
require.NoError(t, err)
assert.Equal(t, "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", url)
header := http.Header{}
require.NoError(t, adaptor.SetupRequestHeader(c, &header, info))
assert.Empty(t, header.Get("X-DashScope-Async"))
converted, err := adaptor.ConvertImageRequest(c, info, dto.ImageRequest{
Model: info.UpstreamModelName,
Prompt: "poster",
})
require.NoError(t, err)
assert.True(t, adaptor.IsSyncImageModel)
assert.IsType(t, &AliImageRequest{}, converted)
}
func TestAliImageCountMatchesLegacyReservation(t *testing.T) {
saved := ratio_setting.ModelPrice2JSONString()
require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{"z-image":0.04}`))
t.Cleanup(func() { require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(saved)) })
for _, tc := range []struct {
name, body string
count int
multiplier float64
}{
{"provider override", `{"model":"z-image","n":2,"parameters":{"n":4}}`, 4, 1},
{"empty parameters inherit top-level", `{"model":"z-image","n":2,"parameters":{}}`, 2, 1},
{"null count inherits top-level", `{"model":"z-image","n":2,"parameters":{"n":null}}`, 2, 1},
{"default count", `{"model":"z-image"}`, 1, 1},
{"prompt extension", `{"model":"z-image","parameters":{"n":3,"prompt_extend":true}}`, 3, 2},
} {
t.Run(tc.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", strings.NewReader(tc.body))
c.Request.Header.Set("Content-Type", "application/json")
common.SetContextKey(c, rootconstant.ContextKeyChannelType, rootconstant.ChannelTypeAli)
request, err := relayhelper.GetAndValidOpenAIImageRequest(c, constant.RelayModeImagesGenerations)
require.NoError(t, err)
info := &relaycommon.RelayInfo{Request: request, OriginModelName: request.Model, UserGroup: "default", UsingGroup: "default"}
price, err := relayhelper.ModelPriceHelper(c, info, 0, request.GetTokenCountMeta())
require.NoError(t, err)
assert.Equal(t, common.QuotaFromFloat(0.04*float64(tc.count)*tc.multiplier*common.QuotaPerUnit), price.QuotaToPreConsume)
info.ChannelMeta = &relaycommon.ChannelMeta{ChannelType: rootconstant.ChannelTypeAli, UpstreamModelName: request.Model}
converted, err := oaiImage2AliImageRequest(info, *request, true)
require.NoError(t, err)
encoded, err := common.Marshal(converted)
require.NoError(t, err)
assert.Equal(t, int64(tc.count), gjson.GetBytes(encoded, "parameters.n").Int())
})
// Image models the alibaba task plugin does not claim reach this adaptor only
// through a channel misconfiguration. The rejection is a client error that
// skips channel retries; a retryable 500 would be re-attempted on unrelated
// channels and still end as a 500 for the client.
func TestConvertImageRequestRejectsUnclaimedModelWithoutRetry(t *testing.T) {
for _, name := range []string{"wanx-style-repaint-v1", "custom-image-model"} {
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: name}}
_, err := (&Adaptor{}).ConvertImageRequest(nil, info, dto.ImageRequest{Model: name, Prompt: "a cat"})
var apiErr *types.NewAPIError
require.ErrorAs(t, err, &apiErr, name)
assert.Equal(t, http.StatusBadRequest, apiErr.StatusCode, name)
assert.True(t, types.IsSkipRetryError(apiErr), name)
assert.Contains(t, apiErr.Error(), name)
}
}
func TestAliImageHandlerHonorsRequestResponseFormat(t *testing.T) {
imageBytes := []byte("ali-image")
var downloads atomic.Int32
imageServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
downloads.Add(1)
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(imageBytes)
}))
t.Cleanup(imageServer.Close)
fetchSetting := system_setting.GetFetchSetting()
require.NotNil(t, fetchSetting)
originalFetchSetting := *fetchSetting
fetchSetting.EnableSSRFProtection = false
t.Cleanup(func() {
*fetchSetting = originalFetchSetting
})
originalMaxFileDownloadMB := rootconstant.MaxFileDownloadMB
rootconstant.MaxFileDownloadMB = 1
t.Cleanup(func() {
rootconstant.MaxFileDownloadMB = originalMaxFileDownloadMB
})
service.InitHttpClient()
// Claude clients reach DashScope two ways: models on the Anthropic-compatible
// list keep Claude Messages and get the cc_entrypoint marker block (block
// form verified live 2026-10-05; a plain string system did not search) and
// the agent billing key; other models are converted to compatible-mode Chat,
// where hosted web search becomes enable_search / search_options and a forced
// search is billed as one turbo call.
func TestConvertClaudeRequestWebSearch(t *testing.T) {
t.Setenv(aliAnthropicMessagesModelsEnv, defaultAliAnthropicMessagesModels)
const (
chatModel = "llama-4-scout"
anthropicModel = "qwen3.8-flash"
marker = `{"type":"text","text":"x-anthropic-billing-header: cc_entrypoint=cli;"}`
messages = `"messages":[{"role":"user","content":"Weather?"}]`
searchTool = `{"type":"web_search_20250305","name":"web_search"}`
forcedChoice = `"tool_choice":{"type":"tool","name":"web_search"},`
)
domains := make([]string, 26)
for i := range domains {
domains[i] = fmt.Sprintf("d%d.example", i)
}
tooManyDomains, err := common.Marshal(domains)
require.NoError(t, err)
tests := []struct {
name string
responseFormat string
wantBase64 string
wantDownloads int32
name string
model string
body string // Claude request fields after model and max_tokens
want map[string]string // gjson path -> raw JSON; "" means absent
wantCodes []string
noCodes []string
wantTurbo int
}{
{
name: "base64",
responseFormat: "b64_json",
wantBase64: base64.StdEncoding.EncodeToString(imageBytes),
wantDownloads: 1,
name: "auto search on Chat",
model: chatModel,
body: messages + `,"tools":[` + searchTool + `]`,
want: map[string]string{"enable_search": "true", "search_options": "", "tool_choice": "", "web_search_options": ""},
},
{
name: "url",
responseFormat: "url",
name: "forced search on Chat",
model: chatModel,
body: forcedChoice + messages + `,"tools":[` + searchTool + `]`,
want: map[string]string{"enable_search": "true", "search_options": `{"forced_search":true}`, "tool_choice": "", "web_search_options": ""},
wantTurbo: 1,
},
{
name: "default",
name: "allowed domains become assigned_site_list",
model: chatModel,
body: messages + `,"tools":[{"type":"web_search_20250305","name":"web_search","allowed_domains":["a.example","b.example"]}]`,
want: map[string]string{"search_options": `{"assigned_site_list":["a.example","b.example"]}`},
noCodes: []string{"unsupported_domain_filter"},
},
{
name: "more than 25 allowed domains are dropped",
model: chatModel,
body: messages + `,"tools":[{"type":"web_search_20250305","name":"web_search","allowed_domains":` + string(tooManyDomains) + `}]`,
want: map[string]string{"enable_search": "true", "search_options": ""},
wantCodes: []string{"unsupported_domain_filter"},
},
{
name: "search limits and excluded domains are semantic loss",
model: chatModel,
body: messages + `,"tools":[{"type":"web_search_20250305","name":"web_search","max_uses":2,"blocked_domains":["a.example"]}]`,
want: map[string]string{"enable_search": "true"},
wantCodes: []string{"unsupported_search_limit", "unsupported_domain_filter"},
},
{
name: "user location is presentation loss",
model: chatModel,
body: messages + `,"tools":[{"type":"web_search_20250305","name":"web_search","user_location":{"type":"approximate","city":"Paris"}}]`,
want: map[string]string{"enable_search": "true", "search_options": ""},
wantCodes: []string{"unsupported_search_tuning"},
},
{
name: "marker without system",
model: anthropicModel,
body: messages + `,"tools":[` + searchTool + `]`,
want: map[string]string{"system": `[` + marker + `]`},
},
{
name: "marker before string system",
model: anthropicModel,
body: `"system":"Be brief.",` + messages + `,"tools":[` + searchTool + `]`,
want: map[string]string{"system": `[` + marker + `,{"type":"text","text":"Be brief."}]`},
},
{
name: "marker before array system",
model: anthropicModel,
body: `"system":[{"type":"text","text":"Be brief."}],` + messages + `,"tools":[` + searchTool + `]`,
want: map[string]string{"system": `[` + marker + `,{"type":"text","text":"Be brief."}]`},
},
{
name: "marker for empty array system",
model: anthropicModel,
body: `"system":[],` + messages + `,"tools":[` + searchTool + `]`,
want: map[string]string{"system": `[` + marker + `]`},
},
{
name: "marker already sent by the client",
model: anthropicModel,
body: `"system":"x-anthropic-billing-header: cc_version=2.1; cc_entrypoint=cli;",` + messages + `,"tools":[` + searchTool + `]`,
want: map[string]string{"system": `"x-anthropic-billing-header: cc_version=2.1; cc_entrypoint=cli;"`},
},
{
name: "no marker without a search tool",
model: anthropicModel,
body: `"system":"Be brief.",` + messages + `,"tools":[{"name":"f","input_schema":{"type":"object"}}]`,
want: map[string]string{"system": `"Be brief."`},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
downloads.Store(0)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
info := &relaycommon.RelayInfo{
RelayMode: constant.RelayModeImagesGenerations,
StartTime: time.Unix(1, 0),
Request: &dto.ImageRequest{
ResponseFormat: tt.responseFormat,
},
RelayFormat: types.RelayFormatClaude,
OriginModelName: tt.model,
ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: tt.model},
}
responseBody := fmt.Sprintf(`{"output":{"results":[{"url":%q}]}}`, imageServer.URL)
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{},
Body: io.NopCloser(strings.NewReader(responseBody)),
adaptor := &Adaptor{}
adaptor.Init(info)
var request dto.ClaudeRequest
require.NoError(t, common.UnmarshalJsonStr(`{"model":"`+tt.model+`","max_tokens":64,`+tt.body+`}`, &request))
var converted any
for range 2 { // converting the same request twice adds one marker
converted, err = adaptor.ConvertClaudeRequest(c, info, &request)
require.NoError(t, err)
}
newAPIError, usage := aliImageHandler(&Adaptor{IsSyncImageModel: true}, c, resp, info)
require.Nil(t, newAPIError)
require.NotNil(t, usage)
var imageResponse dto.ImageResponse
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &imageResponse))
require.Len(t, imageResponse.Data, 1)
assert.Equal(t, imageServer.URL, imageResponse.Data[0].Url)
assert.Equal(t, tt.wantBase64, imageResponse.Data[0].B64Json)
assert.Equal(t, tt.wantDownloads, downloads.Load())
wire, err := common.Marshal(converted)
require.NoError(t, err)
for path, want := range tt.want {
value := gjson.GetBytes(wire, path)
if want == "" {
assert.False(t, value.Exists(), "%s in %s", path, wire)
continue
}
assert.JSONEq(t, want, value.Raw, path)
}
var codes []string
for _, diagnostic := range info.ConversionDiagnostics() {
codes = append(codes, diagnostic.Code)
}
for _, code := range tt.wantCodes {
assert.Contains(t, codes, code)
}
for _, code := range tt.noCodes {
assert.NotContains(t, codes, code)
}
turbo := 0
if info.ResponsesUsageInfo != nil && info.ResponsesUsageInfo.BuiltInTools[aliSearchTurboKey] != nil {
turbo = info.ResponsesUsageInfo.BuiltInTools[aliSearchTurboKey].CallCount
}
assert.Equal(t, tt.wantTurbo, turbo)
wantKey := ""
if tt.model == anthropicModel {
wantKey = aliSearchAgentKey
}
assert.Equal(t, wantKey, info.WebSearchBillingKey)
})
}
}
// DashScope Responses bills web search like the agent strategy and reports
// the count in usage.x_tools; it replaces the web_search_call items counted
// under the protocol keys.
func TestAliResponsesWebSearchUsage(t *testing.T) {
for _, raw := range []string{
`{"status":"completed","usage":{"x_tools":{"web_search":{"count":2}}}}`,
`{"type":"response.completed","response":{"usage":{"x_tools":{"web_search":{"count":2}}}}}`,
} {
info := &relaycommon.RelayInfo{
RelayMode: constant.RelayModeResponses,
RelayFormat: types.RelayFormatOpenAIResponses,
ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://dashscope.invalid"},
}
adaptor := &Adaptor{}
adaptor.Init(info)
url, err := adaptor.GetRequestURL(info)
require.NoError(t, err)
assert.Equal(t, "https://dashscope.invalid/compatible-mode/v1/responses", url)
info.CountBillableToolCall(dto.BuildInCallWebSearchCall, "")
info.ApplyVendorToolUsage([]byte(raw))
counts := map[string]int{}
for name, tool := range info.ResponsesUsageInfo.BuiltInTools {
counts[name] = tool.CallCount
}
assert.Equal(t, map[string]int{aliSearchAgentKey: 2, dto.BuildInToolWebSearch: 0, dto.BuildInToolWebSearchPreview: 0}, counts, raw)
}
}
-146
View File
@@ -1,12 +1,7 @@
package ali
import (
"strings"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
type AliMessage struct {
@@ -14,11 +9,6 @@ type AliMessage struct {
Role string `json:"role"`
}
type AliMediaContent struct {
Image string `json:"image,omitempty"`
Text string `json:"text,omitempty"`
}
type AliInput struct {
Prompt string `json:"prompt,omitempty"`
//History []AliMessage `json:"history,omitempty"`
@@ -72,142 +62,6 @@ type AliUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
TotalTokens int `json:"total_tokens"`
ImageCount int `json:"image_count,omitempty"`
}
type TaskResult struct {
B64Image string `json:"b64_image,omitempty"`
Url string `json:"url,omitempty"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
type AliOutput struct {
TaskId string `json:"task_id,omitempty"`
TaskStatus string `json:"task_status,omitempty"`
Text string `json:"text"`
FinishReason string `json:"finish_reason"`
Message string `json:"message,omitempty"`
Code string `json:"code,omitempty"`
Results []TaskResult `json:"results,omitempty"`
Choices []struct {
FinishReason string `json:"finish_reason,omitempty"`
Message struct {
Role string `json:"role,omitempty"`
Content []AliMediaContent `json:"content,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
} `json:"message"`
} `json:"choices,omitempty"`
}
func (o *AliOutput) ChoicesToOpenAIImageDate(c *gin.Context, responseFormat string) []dto.ImageData {
var imageData []dto.ImageData
if len(o.Choices) > 0 {
for _, choice := range o.Choices {
var data dto.ImageData
for _, content := range choice.Message.Content {
if content.Image != "" {
if strings.HasPrefix(content.Image, "http") {
var b64Json string
if responseFormat == "b64_json" {
_, b64, err := service.GetImageFromUrl(content.Image)
if err != nil {
logger.LogError(c, "get_image_data_failed: "+err.Error())
continue
}
b64Json = b64
}
data.Url = content.Image
data.B64Json = b64Json
} else {
data.B64Json = content.Image
}
} else if content.Text != "" {
data.RevisedPrompt = content.Text
}
}
imageData = append(imageData, data)
}
}
return imageData
}
func (o *AliOutput) ResultToOpenAIImageDate(c *gin.Context, responseFormat string) []dto.ImageData {
var imageData []dto.ImageData
for _, data := range o.Results {
var b64Json string
if responseFormat == "b64_json" {
_, b64, err := service.GetImageFromUrl(data.Url)
if err != nil {
logger.LogError(c, "get_image_data_failed: "+err.Error())
continue
}
b64Json = b64
} else {
b64Json = data.B64Image
}
imageData = append(imageData, dto.ImageData{
Url: data.Url,
B64Json: b64Json,
RevisedPrompt: "",
})
}
return imageData
}
type AliResponse struct {
Output AliOutput `json:"output"`
Usage AliUsage `json:"usage"`
AliError
}
type AliImageRequest struct {
Model string `json:"model"`
Input any `json:"input"`
Parameters AliImageParameters `json:"parameters"`
ResponseFormat string `json:"response_format,omitempty"`
}
type AliImageParameters struct {
Size string `json:"size,omitempty"`
N *uint `json:"n,omitempty"`
Steps string `json:"steps,omitempty"`
Scale string `json:"scale,omitempty"`
Watermark *bool `json:"watermark,omitempty"`
PromptExtend *bool `json:"prompt_extend,omitempty"`
ThinkingMode *bool `json:"thinking_mode,omitempty"`
EnableSequential *bool `json:"enable_sequential,omitempty"`
BboxList any `json:"bbox_list,omitempty"`
ColorPalette any `json:"color_palette,omitempty"`
Seed *int `json:"seed,omitempty"`
}
func (p *AliImageParameters) PromptExtendValue() bool {
if p != nil && p.PromptExtend != nil {
return *p.PromptExtend
}
return false
}
type AliImageInput struct {
Prompt string `json:"prompt,omitempty"`
NegativePrompt string `json:"negative_prompt,omitempty"`
Messages []AliMessage `json:"messages,omitempty"`
}
type WanImageInput struct {
Prompt string `json:"prompt"` // 必需:文本提示词,描述生成图像中期望包含的元素和视觉特点
Images []string `json:"images"` // 必需:图像URL数组,长度不超过2,支持HTTP/HTTPS URL或Base64编码
NegativePrompt string `json:"negative_prompt,omitempty"` // 可选:反向提示词,描述不希望在画面中看到的内容
}
type WanImageParameters struct {
N int `json:"n,omitempty"` // 生成图片数量,取值范围1-4,默认4
Watermark *bool `json:"watermark,omitempty"` // 是否添加水印标识,默认false
Seed int `json:"seed,omitempty"` // 随机数种子,取值范围[0, 2147483647]
Strength float64 `json:"strength,omitempty"` // 修改幅度 0.0-1.0,默认0.5(部分模型支持)
}
type AliRerankParameters struct {
-368
View File
@@ -1,368 +0,0 @@
package ali
import (
"encoding/base64"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
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/service"
"github.com/gin-gonic/gin"
)
func oaiImage2AliImageRequest(info *relaycommon.RelayInfo, request dto.ImageRequest, isSync bool) (*AliImageRequest, error) {
var imageRequest AliImageRequest
imageRequest.Model = request.Model
imageRequest.ResponseFormat = request.ResponseFormat
imageRequest.Parameters = AliImageParameters{
Size: strings.ReplaceAll(request.Size, "x", "*"),
Watermark: request.Watermark,
}
if request.Extra != nil {
if val, ok := request.Extra["parameters"]; ok {
imageRequest.Parameters = AliImageParameters{}
err := common.Unmarshal(val, &imageRequest.Parameters)
if err != nil {
return nil, fmt.Errorf("invalid parameters field: %w", err)
}
// Direct adaptor callers can bypass ingress validation. Reuse the
// decoded provider scalars and the same quantity validation below.
if request.BillingParameters == nil {
request.BillingParameters = &dto.ImageBillingParameters{
N: imageRequest.Parameters.N, PromptExtend: imageRequest.Parameters.PromptExtend,
}
}
}
if val, ok := request.Extra["input"]; ok {
err := common.Unmarshal(val, &imageRequest.Input)
if err != nil {
return nil, fmt.Errorf("invalid input field: %w", err)
}
}
}
count, err := request.ImageCount(true)
if err != nil {
return nil, err
}
imageRequest.Parameters.N = common.GetPointer(uint(count))
if request.BillingParameters != nil {
imageRequest.Parameters.PromptExtend = request.BillingParameters.PromptExtend
}
if info.TieredBillingSnapshot == nil {
info.PriceData.AddOtherRatio("n", float64(count))
if strings.Contains(request.Model, "z-image") && imageRequest.Parameters.PromptExtendValue() {
info.PriceData.AddOtherRatio("prompt_extend", common.ZImagePromptExtendMultiplier)
}
}
// 同步图片模型和异步图片模型请求格式不一样
if isSync {
if imageRequest.Input == nil {
imageRequest.Input = AliImageInput{
Messages: []AliMessage{
{
Role: "user",
Content: []AliMediaContent{
{
Text: request.Prompt,
},
},
},
},
}
}
} else {
if imageRequest.Input == nil {
imageRequest.Input = AliImageInput{
Prompt: request.Prompt,
}
}
}
return &imageRequest, nil
}
func getImageBase64sFromForm(c *gin.Context, fieldName string) ([]string, error) {
mf := c.Request.MultipartForm
if mf == nil {
if _, err := c.MultipartForm(); err != nil {
return nil, fmt.Errorf("failed to parse image edit form request: %w", err)
}
mf = c.Request.MultipartForm
}
var imageFiles []*multipart.FileHeader
var exists bool
// First check for standard "image" field
if imageFiles, exists = mf.File["image"]; !exists || len(imageFiles) == 0 {
// If not found, check for "image[]" field
if imageFiles, exists = mf.File["image[]"]; !exists || len(imageFiles) == 0 {
// If still not found, iterate through all fields to find any that start with "image["
foundArrayImages := false
for fieldName, files := range mf.File {
if strings.HasPrefix(fieldName, "image[") && len(files) > 0 {
foundArrayImages = true
imageFiles = append(imageFiles, files...)
}
}
// If no image fields found at all
if !foundArrayImages && (len(imageFiles) == 0) {
return nil, errors.New("image is required")
}
}
}
if len(imageFiles) == 0 {
return nil, errors.New("image is required")
}
//if len(imageFiles) > 1 {
// return nil, errors.New("only one image is supported for qwen edit")
//}
// 获取base64编码的图片
var imageBase64s []string
for _, file := range imageFiles {
image, err := file.Open()
if err != nil {
return nil, errors.New("failed to open image file")
}
// 读取文件内容
imageData, err := io.ReadAll(image)
if err != nil {
return nil, errors.New("failed to read image file")
}
// 获取MIME类型
mimeType := http.DetectContentType(imageData)
// 编码为base64
base64Data := base64.StdEncoding.EncodeToString(imageData)
// 构造data URL格式
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
imageBase64s = append(imageBase64s, dataURL)
image.Close()
}
return imageBase64s, nil
}
func oaiFormEdit2AliImageEdit(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (*AliImageRequest, error) {
count, err := request.ImageCount(true)
if err != nil {
return nil, err
}
var imageRequest AliImageRequest
imageRequest.Model = request.Model
imageRequest.ResponseFormat = request.ResponseFormat
imageBase64s, err := getImageBase64sFromForm(c, "image")
if err != nil {
return nil, fmt.Errorf("get image base64s from form failed: %w", err)
}
//dto.MediaContent{}
mediaContents := make([]AliMediaContent, len(imageBase64s))
for i, b64 := range imageBase64s {
mediaContents[i] = AliMediaContent{
Image: b64,
}
}
mediaContents = append(mediaContents, AliMediaContent{
Text: request.Prompt,
})
imageRequest.Input = AliImageInput{
Messages: []AliMessage{
{
Role: "user",
Content: mediaContents,
},
},
}
imageRequest.Parameters = AliImageParameters{
N: common.GetPointer(uint(count)),
Watermark: request.Watermark,
}
if request.BillingParameters != nil {
imageRequest.Parameters.PromptExtend = request.BillingParameters.PromptExtend
}
return &imageRequest, nil
}
func updateTask(info *relaycommon.RelayInfo, taskID string) (*AliResponse, error, []byte) {
url := fmt.Sprintf("%s/api/v1/tasks/%s", info.ChannelBaseUrl, taskID)
var aliResponse AliResponse
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return &aliResponse, err, nil
}
req.Header.Set("Authorization", "Bearer "+info.ApiKey)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
common.SysLog("updateTask client.Do err: " + err.Error())
return &aliResponse, err, nil
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
var response AliResponse
err = common.Unmarshal(responseBody, &response)
if err != nil {
common.SysLog("updateTask NewDecoder err: " + err.Error())
return &aliResponse, err, nil
}
return &response, nil, responseBody
}
func asyncTaskWait(c *gin.Context, info *relaycommon.RelayInfo, taskID string) (*AliResponse, []byte, error) {
waitSeconds := 10
step := 0
maxStep := 20
var taskResponse AliResponse
var responseBody []byte
time.Sleep(time.Duration(5) * time.Second)
for {
logger.LogDebug(c, "asyncTaskWait step %d/%d, wait %d seconds", step, maxStep, waitSeconds)
step++
rsp, err, body := updateTask(info, taskID)
responseBody = body
if err != nil {
logger.LogWarn(c, "asyncTaskWait UpdateTask err: "+err.Error())
time.Sleep(time.Duration(waitSeconds) * time.Second)
continue
}
if rsp.Output.TaskStatus == "" {
return &taskResponse, responseBody, nil
}
switch rsp.Output.TaskStatus {
case "FAILED":
fallthrough
case "CANCELED":
fallthrough
case "SUCCEEDED":
fallthrough
case "UNKNOWN":
return rsp, responseBody, nil
}
if step >= maxStep {
break
}
time.Sleep(time.Duration(waitSeconds) * time.Second)
}
return nil, nil, fmt.Errorf("aliAsyncTaskWait timeout")
}
func responseAli2OpenAIImage(c *gin.Context, response *AliResponse, originBody []byte, info *relaycommon.RelayInfo, responseFormat string) *dto.ImageResponse {
imageResponse := dto.ImageResponse{
Created: info.StartTime.Unix(),
}
if len(response.Output.Results) > 0 {
imageResponse.Data = response.Output.ResultToOpenAIImageDate(c, responseFormat)
} else if len(response.Output.Choices) > 0 {
imageResponse.Data = response.Output.ChoicesToOpenAIImageDate(c, responseFormat)
}
imageResponse.Metadata = originBody
return &imageResponse
}
func aliImageHandler(a *Adaptor, c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) {
responseFormat := ""
if imageReq, ok := info.Request.(*dto.ImageRequest); ok {
responseFormat = imageReq.ResponseFormat
}
var aliTaskResponse AliResponse
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil
}
service.CloseResponseBodyGracefully(resp)
err = common.Unmarshal(responseBody, &aliTaskResponse)
if err != nil {
return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil
}
if aliTaskResponse.Message != "" {
logger.LogError(c, "ali_async_task_failed: "+aliTaskResponse.Message)
return types.NewError(errors.New(aliTaskResponse.Message), types.ErrorCodeBadResponse), nil
}
var (
aliResponse *AliResponse
originRespBody []byte
)
if a.IsSyncImageModel {
aliResponse = &aliTaskResponse
originRespBody = responseBody
} else {
// 异步图片模型需要轮询任务结果
aliResponse, originRespBody, err = asyncTaskWait(c, info, aliTaskResponse.Output.TaskId)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponse), nil
}
if aliResponse.Output.TaskStatus != "SUCCEEDED" {
return types.WithOpenAIError(types.OpenAIError{
Message: aliResponse.Output.Message,
Type: "ali_error",
Param: "",
Code: aliResponse.Output.Code,
}, resp.StatusCode), nil
}
}
if a.IsSyncImageModel {
logger.LogDebug(c, "ali_sync_image_result: %s", originRespBody)
} else {
logger.LogDebug(c, "ali_async_image_result: %s", originRespBody)
}
imageResponses := responseAli2OpenAIImage(c, aliResponse, originRespBody, info, responseFormat)
count := int64(aliResponse.Usage.ImageCount)
if count < 0 || count > dto.MaxImageN {
logger.LogWarn(c, "invalid Ali image usage count %d; falling back to response images or requested quantity", count)
count = 0
}
if count == 0 {
count = int64(len(imageResponses.Data))
}
if count > dto.MaxImageN {
logger.LogWarn(c, "invalid Ali response image count %d; retaining requested quantity", count)
} else if count > 0 {
info.UpdateImageCount(count)
}
jsonResponse, err := common.Marshal(imageResponses)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
}
service.IOCopyBytesGracefully(c, resp, jsonResponse)
return nil, &dto.Usage{}
}
-51
View File
@@ -1,51 +0,0 @@
package ali
import (
"fmt"
"strings"
"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"
)
func oaiFormEdit2WanxImageEdit(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (*AliImageRequest, error) {
count, err := request.ImageCount(true)
if err != nil {
return nil, err
}
var imageRequest AliImageRequest
imageRequest.Model = request.Model
imageRequest.ResponseFormat = request.ResponseFormat
wanInput := WanImageInput{
Prompt: request.Prompt,
}
if err := common.UnmarshalBodyReusable(c, &wanInput); err != nil {
return nil, err
}
if wanInput.Images, err = getImageBase64sFromForm(c, "image"); err != nil {
return nil, fmt.Errorf("get image base64s from form failed: %w", err)
}
imageRequest.Input = wanInput
imageRequest.Parameters = AliImageParameters{
N: common.GetPointer(uint(count)),
}
if request.BillingParameters != nil {
imageRequest.Parameters.PromptExtend = request.BillingParameters.PromptExtend
}
return &imageRequest, nil
}
func isOldWanModel(modelName string) bool {
return strings.Contains(modelName, "wan") &&
!lo.SomeBy([]string{"wan2.6", "wan2.7"}, func(v string) bool { return strings.Contains(modelName, v) })
}
func isWanModel(modelName string) bool {
return strings.Contains(modelName, "wan")
}
+163
View File
@@ -0,0 +1,163 @@
package ali
import (
"encoding/json"
"strings"
"github.com/QuantumNous/new-api/common"
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/types"
"github.com/tidwall/gjson"
)
// DashScope web-search tool-price keys (search_strategy_* prices in
// setting/operation_setting/tools.go). The compatible-mode Chat encoder never
// writes search_strategy, so DashScope runs turbo; the Responses API "bills
// web search like the agent strategy"; the Anthropic-compatible endpoint
// publishes no search price and is billed like agent too (owner decision N9).
const (
aliSearchTurboKey = "search_strategy_turbo"
aliSearchAgentKey = "search_strategy_agent"
)
// aliMaxAssignedSites is the documented search_options.assigned_site_list
// limit; the list only applies to the default turbo strategy.
const aliMaxAssignedSites = 25
// encodeWebSearch maps hosted web search onto the DashScope OpenAI-compatible
// Chat fields enable_search and search_options
// (https://help.aliyun.com/zh/model-studio/web-search). search_strategy is
// never written, so DashScope runs its default turbo strategy, and
// search_context_size is not mapped to a strategy: a strategy changes the
// price, not the amount of retrieved context.
//
// enable_search only works on the models DashScope lists. Other models answer
// HTTP 400 "This model does not support enable_search.", and models that only
// accept search_strategy=agent (qwen3.8-omni-flash) answer 400 as well. The
// encoder sees no model support matrix and does not gate on model names, so
// the request is sent as is and the upstream 400 reaches the client.
func encodeWebSearch(info *relaycommon.RelayInfo) convmeta.WebSearchEncoder {
return func(call convmeta.WebSearchCall) (*convmeta.WebSearchEncoding, error) {
if call.Chat == nil {
return nil, nil
}
var diagnostics []types.ConversionDiagnostic
options := map[string]any{}
if call.Forced {
options["forced_search"] = true
// Compatible-mode Chat responses report no search usage; a forced
// search is the only proof that one ran.
info.RecordRequestInferredToolCall(aliSearchTurboKey)
}
spec := call.Spec
switch {
case len(spec.AllowedDomains) > aliMaxAssignedSites:
diagnostics = append(diagnostics, call.SemanticLoss("unsupported_domain_filter", "DashScope assigned_site_list accepts at most 25 sites; the domain filter was dropped"))
case len(spec.AllowedDomains) > 0:
options["assigned_site_list"] = spec.AllowedDomains
}
if len(spec.BlockedDomains) > 0 {
diagnostics = append(diagnostics, call.SemanticLoss("unsupported_domain_filter", "DashScope web search cannot exclude domains; blocked_domains was dropped"))
}
if spec.MaxUses != nil {
diagnostics = append(diagnostics, call.SemanticLoss("unsupported_search_limit", "DashScope web search cannot preserve max_uses"))
}
if len(spec.AllowedCallers) > 0 || spec.ExternalWebAccess != nil {
diagnostics = append(diagnostics, call.SemanticLoss("unsupported_search_controls", "DashScope web search cannot preserve caller or external-access constraints"))
}
if spec.SearchContextSize != "" || spec.Location != nil || spec.ResponseInclusion != "" || len(spec.ReturnTokenBudget) > 0 {
diagnostics = append(diagnostics, call.PresentationLoss("unsupported_search_tuning", "DashScope web search cannot preserve search context size, location, or result tuning"))
}
call.Chat.EnableSearch = json.RawMessage(`true`)
if len(options) > 0 {
encoded, err := common.Marshal(options)
if err != nil {
return nil, err
}
call.Chat.SearchOptions = encoded
}
return &convmeta.WebSearchEncoding{Diagnostics: diagnostics}, nil
}
}
// aliResponsesToolUsage bills Responses web search by the count DashScope
// reports in usage.x_tools.web_search.count (terminal frames carry it under
// response.usage) and silences the web_search_call items under the protocol
// keys. gjson truncates fractional values and reads non-numbers as zero;
// SetBillableToolCount ignores negatives and settlement applies
// MaxBillableToolCallCount.
func aliResponsesToolUsage(raw []byte) map[string]int {
count := gjson.GetBytes(raw, "usage.x_tools.web_search.count")
if !count.Exists() {
count = gjson.GetBytes(raw, "response.usage.x_tools.web_search.count")
}
if !count.Exists() {
return nil
}
return map[string]int{
aliSearchAgentKey: int(count.Int()),
dto.BuildInToolWebSearch: 0,
dto.BuildInToolWebSearchPreview: 0,
}
}
// aliAnthropicWebSearchEntrypoint is the client marker DashScope's
// Anthropic-compatible endpoint requires in system before it runs web search:
// "使用 Anthropic SDK 或 HTTP 直接调用时,需在 system 中传入客户端标识
// x-anthropic-billing-header: cc_entrypoint=cli;,否则联网搜索不生效。"
// (https://help.aliyun.com/zh/model-studio/web-search). The marker must be a
// system text block: sent as a plain string system, qwen3.8-flash answered
// without searching (2026-10-05).
const aliAnthropicWebSearchEntrypoint = "x-anthropic-billing-header: cc_entrypoint=cli;"
// ensureAnthropicWebSearchEntrypoint prepends the marker block to system when
// the request carries a web_search_* tool; a string system becomes the block
// after the marker. It is idempotent: a system that already names
// cc_entrypoint=cli, from the client or an earlier call, is left as is.
func ensureAnthropicWebSearchEntrypoint(request *dto.ClaudeRequest) {
hasWebSearch := false
for _, tool := range request.GetTools() {
var toolType string
switch typed := tool.(type) {
case map[string]any:
toolType, _ = typed["type"].(string)
case *dto.ClaudeWebSearchTool:
toolType = typed.Type
case dto.ClaudeWebSearchTool:
toolType = typed.Type
}
if strings.HasPrefix(toolType, "web_search_") {
hasWebSearch = true
break
}
}
if !hasWebSearch {
return
}
const present = "cc_entrypoint=cli"
var blocks []dto.ClaudeMediaMessage
if request.System == nil || request.IsStringSystem() {
system := request.GetStringSystem()
if strings.Contains(system, present) {
return
}
if strings.TrimSpace(system) != "" {
block := dto.ClaudeMediaMessage{Type: dto.ContentTypeText}
block.SetText(system)
blocks = []dto.ClaudeMediaMessage{block}
}
} else {
blocks = request.ParseSystem()
for _, block := range blocks {
if block.Type == dto.ContentTypeText && strings.Contains(block.GetText(), present) {
return
}
}
}
marker := dto.ClaudeMediaMessage{Type: dto.ContentTypeText}
marker.SetText(aliAnthropicWebSearchEntrypoint)
request.System = append([]dto.ClaudeMediaMessage{marker}, blocks...)
}
+27 -10
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"net/http/httptrace"
"regexp"
"strings"
"sync"
@@ -405,6 +406,9 @@ func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
}
dialer.Proxy = http.ProxyURL(proxyURL)
}
if info.ChannelSetting.TLSInsecureSkipVerify {
dialer.TLSClientConfig = common2.InsecureTLSConfig
}
targetConn, resp, err := dialer.DialContext(c.Request.Context(), fullRequestURL, targetHeader)
if err != nil {
statusCode := http.StatusInternalServerError
@@ -499,7 +503,7 @@ func sendPingData(c *gin.Context, mutex *sync.Mutex) error {
helper.ExtendWriteDeadline(c)
err := helper.PingData(c)
if err != nil {
logger.LogError(c, "SSE ping error: "+err.Error())
logger.LogError(c, common2.LogText("SSE ping error: %s", err.Error()))
return err
}
@@ -537,13 +541,11 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
relayClient.CheckRedirect = keepUpstreamRedirectResponse
if common2.DebugEnabled && req != nil && req.URL != nil {
policy := service.NormalizeHTTPTransportPolicy(info.ChannelSetting)
logger.LogDebug(c, fmt.Sprintf(
"http transport select: host=%s protocol=%s shards=%d policy=%s",
logger.LogDebug(c, "http transport select: host=%s protocol=%s shards=%d policy=%s",
req.URL.Host,
policy.Protocol,
policy.Shards,
policy.String(),
))
policy.String())
}
var stopPinger context.CancelFunc
@@ -567,11 +569,21 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
}
info.UpstreamRequests++
info.UpstreamTiming = nil
if info.UsingGroup == common2.GetEnvOrDefaultString("UPSTREAM_TIMING_GROUP", "") && c.Request.URL.Path == "/v1/responses" {
start := c.GetTime("request_received_at")
if start.IsZero() {
start = info.StartTime
}
info.UpstreamTiming = common.NewUpstreamTiming(start, info.UpstreamRequests, info.ChannelId, req.ContentLength)
}
c.Set(common.UpstreamTimingContextKey, info.UpstreamTiming)
var guard *common.ResponseGuardAttempt
var guardUsage *service.GuardUsageCollector
if guardEnabled {
var upstream context.Context
upstream, guard = info.ResponseGuard.Begin(c.Request.Context(), info.UpstreamRequests, info.ChannelId)
guard.Timing = info.UpstreamTiming
if info.DurableAttemptID != 0 {
guardUsage = service.NewGuardUsageCollector(c, info)
guard.OnEvent = guardUsage.Observe
@@ -579,6 +591,9 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
}
req = req.WithContext(upstream)
}
if info.UpstreamTiming != nil {
req = req.WithContext(httptrace.WithClientTrace(req.Context(), info.UpstreamTiming.ClientTrace()))
}
resp, err := relayClient.Do(req)
if err != nil {
if guard != nil {
@@ -594,7 +609,7 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
}
guard.Reason = "transport_error"
}
logger.LogError(c, "do request failed: "+err.Error())
logger.LogError(c, common2.LogText("do request failed: %s", err.Error()))
return nil, types.NewError(err, types.ErrorCodeDoRequestFailed, types.ErrOptionWithHideErrMsg("upstream error: do request failed"))
}
if resp == nil {
@@ -603,6 +618,7 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
}
return nil, errors.New("resp is nil")
}
info.UpstreamTiming.Response(resp.Proto, resp.StatusCode)
if guard != nil {
if err := guard.Confirm(resp, c.Request.Context()); err != nil {
if guardUsage != nil && guardUsage.FinishRejected(info, guard, resp) {
@@ -614,17 +630,18 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
guard.Finish(false)
return nil, guard.APIError()
}
if guard.Reason == "accepted" {
info.UpstreamTiming.Mark("model_confirmed")
}
}
if common2.DebugEnabled {
policy := service.NormalizeHTTPTransportPolicy(info.ChannelSetting)
logger.LogDebug(c, fmt.Sprintf(
"http transport negotiated: host=%s protocol=%s shards=%d policy=%s negotiated=%s",
logger.LogDebug(c, "http transport negotiated: host=%s protocol=%s shards=%d policy=%s negotiated=%s",
req.URL.Host,
policy.Protocol,
policy.Shards,
policy.String(),
resp.Proto,
))
resp.Proto)
}
if upID := resp.Header.Get(common2.RequestIdKey); upID != "" {
+20 -1
View File
@@ -93,6 +93,9 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if info.ChannelOtherSettings.AwsApiType == dto.AwsApiTypeMantle {
return mantleRequestURL(info)
}
if info.ChannelOtherSettings.AwsKeyType == dto.AwsKeyTypeApiKey {
awsModelId := getAwsModelID(info.UpstreamModelName)
a.ClientMode = ClientModeApiKey
@@ -154,6 +157,22 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo
}
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
switch info.ChannelOtherSettings.AwsApiType {
case dto.AwsApiTypeMantle:
req, err := a.newMantleRequest(c, info, requestBody)
if err != nil {
return nil, err
}
resp, err := channel.DoRequest(c, req, info)
if err != nil {
return nil, newAwsInvokeError(c.Request.Context(), err, "Mantle Messages")
}
return resp, nil
case "", dto.AwsApiTypeInvokeModel:
// Unconfigured channels retain the existing InvokeModel behavior.
default:
return nil, errors.New("invalid AWS API type")
}
if a.ClientMode == ClientModeApiKey {
return channel.DoApiRequest(a, c, info, requestBody)
} else {
@@ -162,7 +181,7 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
if a.ClientMode == ClientModeApiKey {
if info.ChannelOtherSettings.AwsApiType == dto.AwsApiTypeMantle || a.ClientMode == ClientModeApiKey {
claudeAdaptor := claude.Adaptor{}
usage, err = claudeAdaptor.DoResponse(c, resp, info)
} else {
+11 -10
View File
@@ -40,18 +40,19 @@ func formatRequest(requestBody io.Reader, requestHeader http.Header) (*AwsClaude
awsClaudeRequest.AnthropicVersion = "bedrock-2023-05-31"
// check header anthropic-beta
anthropicBetaValues := requestHeader.Get("anthropic-beta")
if len(anthropicBetaValues) > 0 {
var tempArray []string
tempArray = strings.Split(anthropicBetaValues, ",")
if len(tempArray) > 0 {
betaJson, err := json.Marshal(tempArray)
if err != nil {
return nil, err
}
awsClaudeRequest.AnthropicBeta = betaJson
var betas []string
for beta := range strings.SplitSeq(requestHeader.Get("anthropic-beta"), ",") {
if beta = strings.TrimSpace(beta); beta != "" {
betas = append(betas, beta)
}
}
if len(betas) > 0 {
betaJson, err := common.Marshal(betas)
if err != nil {
return nil, err
}
awsClaudeRequest.AnthropicBeta = betaJson
}
logger.LogJson(context.Background(), "json", awsClaudeRequest)
return &awsClaudeRequest, nil
}
+144
View File
@@ -0,0 +1,144 @@
package aws
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/relay/channel/claude"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/gin-gonic/gin"
)
func mantleRequestURL(info *relaycommon.RelayInfo) (string, error) {
parts := strings.Split(info.ApiKey, "|")
if len(parts) != 2 && len(parts) != 3 {
return "", fmt.Errorf("invalid AWS credentials: use APIKey|Region or AccessKey|SecretAccessKey|Region")
}
for _, part := range parts {
if strings.TrimSpace(part) == "" {
return "", fmt.Errorf("invalid AWS credentials: empty credential or region")
}
}
region := parts[len(parts)-1]
for _, ch := range region {
if (ch < 'a' || ch > 'z') && (ch < '0' || ch > '9') && ch != '-' {
return "", fmt.Errorf("invalid AWS region")
}
}
baseURL := strings.TrimRight(info.ChannelBaseUrl, "/")
if baseURL == "" {
baseURL = "https://bedrock-mantle." + region + ".api.aws"
}
endpoint, err := url.Parse(baseURL)
if err != nil || endpoint.Scheme != "https" || endpoint.Host == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" {
return "", fmt.Errorf("AWS Mantle base URL must be an HTTPS URL without credentials, query, or fragment")
}
switch {
case strings.HasSuffix(endpoint.Path, "/anthropic/v1/messages"):
case strings.HasSuffix(endpoint.Path, "/anthropic/v1"):
endpoint.Path += "/messages"
case strings.HasSuffix(endpoint.Path, "/anthropic"):
endpoint.Path += "/v1/messages"
default:
endpoint.Path += "/anthropic/v1/messages"
}
return endpoint.String(), nil
}
// Mantle uses native Messages JSON and SSE, not the InvokeModel envelope.
// Sign the final payload and headers, including administrator overrides.
func (a *Adaptor) newMantleRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Request, error) {
endpoint, err := mantleRequestURL(info)
if err != nil {
return nil, err
}
if a.IsNova || isNovaModel(getAwsModelID(info.UpstreamModelName)) {
return nil, fmt.Errorf("AWS Mantle Messages API requires a Claude model; use InvokeModel for Amazon Nova")
}
var payload map[string]json.RawMessage
if err := common.DecodeJson(requestBody, &payload); err != nil {
return nil, fmt.Errorf("decode AWS Mantle request: %w", err)
}
if payload == nil {
return nil, fmt.Errorf("AWS Mantle request must be a JSON object")
}
// Use the channel's resolved model even for passthrough requests. Do not
// apply InvokeModel's version suffixes or automatic cross-region prefixes.
model := info.UpstreamModelName
if model == "claude-haiku-4-5-20251001" {
model = "claude-haiku-4-5"
}
if strings.HasPrefix(model, "claude-") {
model = "anthropic." + model
}
payload["model"], err = common.Marshal(model)
if err != nil {
return nil, err
}
delete(payload, "anthropic_version")
delete(payload, "anthropic_beta")
body, err := common.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal AWS Mantle request: %w", err)
}
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create AWS Mantle request: %w", err)
}
channel.SetupApiRequestHeader(info, c, &req.Header)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("anthropic-version", "2023-06-01")
claude.CommonClaudeHeadersOperation(c, &req.Header, info)
parts := strings.Split(info.ApiKey, "|")
if len(parts) == 2 {
req.Header.Set("x-api-key", parts[0])
}
headerOverride, err := channel.ResolveHeaderOverride(info, c)
if err != nil {
return nil, err
}
for key, value := range headerOverride {
req.Header.Set(key, value)
if strings.EqualFold(key, "Host") {
req.Host = value
}
}
// HTTP/2 and intermediaries can remove hop-by-hop headers. Strip them
// after overrides, including fields named by Connection, so SigV4 covers
// only headers that will reach Mantle. Proxy credentials must stay local.
for _, connection := range req.Header.Values("Connection") {
for name := range strings.SplitSeq(connection, ",") {
req.Header.Del(strings.TrimSpace(name))
}
}
for _, name := range []string{
"Connection", "Proxy-Connection", "Keep-Alive", "Proxy-Authenticate",
"Proxy-Authorization", "Te", "Trailer", "Transfer-Encoding", "Upgrade",
} {
req.Header.Del(name)
}
if len(parts) == 3 {
// Never send an unrelated bearer credential alongside SigV4. The AWS
// signer handles timestamps and signing the exact transmitted payload.
req.Header.Del("Authorization")
req.Header.Del("x-api-key")
credentials := aws.Credentials{AccessKeyID: parts[0], SecretAccessKey: parts[1]}
hash := sha256.Sum256(body)
if err := v4.NewSigner().SignHTTP(req.Context(), credentials, req, hex.EncodeToString(hash[:]), "bedrock-mantle", parts[2], time.Now()); err != nil {
return nil, fmt.Errorf("sign AWS Mantle request: %w", err)
}
}
return req, nil
}
+2 -2
View File
@@ -299,10 +299,10 @@ streamLoop:
return respErr, nil
}
case *bedrockruntimeTypes.UnknownUnionMember:
fmt.Println("unknown tag:", v.Tag)
fmt.Println(common.LogText("unknown tag: %v", v.Tag))
return types.NewError(errors.New("unknown response type"), types.ErrorCodeInvalidRequest), nil
default:
fmt.Println("union is nil or unknown type")
fmt.Println(common.LogText("union is nil or unknown type"))
return types.NewError(errors.New("nil or unknown response type"), types.ErrorCodeInvalidRequest), nil
}
}
+458
View File
@@ -3,21 +3,29 @@ package aws
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"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"
relaytypes "github.com/QuantumNous/new-api/relaykit/types"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream"
"github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream/eventstreamapi"
"github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime"
"github.com/gin-gonic/gin"
@@ -182,6 +190,37 @@ func TestDoAwsClientRequest_AppliesRuntimeHeaderOverrideToAnthropicBeta(t *testi
require.Equal(t, []any{"computer-use-2025-01-24"}, values)
}
func TestDoAwsClientRequest_ForwardsThinkingBlockBindingAndBetaList(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
ctx.Request.Header.Set("anthropic-beta", "fast-mode-2026-02-01, thinking-binding-controls-2026-08-01")
info := &relaycommon.RelayInfo{
OriginModelName: "claude-opus-5-5",
ChannelMeta: &relaycommon.ChannelMeta{
ApiKey: "access-key|secret-key|us-east-1",
UpstreamModelName: "claude-opus-5-5",
},
}
requestBody := bytes.NewBufferString(`{"max_tokens":128,"messages":[{"role":"user","content":"hello"}],` +
`"thinking":{"type":"adaptive","display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"}}}`)
adaptor := &Adaptor{}
_, err := doAwsClientRequest(ctx, info, adaptor, requestBody)
require.NoError(t, err)
awsReq, ok := adaptor.AwsReq.(*bedrockruntime.InvokeModelInput)
require.True(t, ok)
var payload struct {
AnthropicBeta []string `json:"anthropic_beta"`
Thinking json.RawMessage `json:"thinking"`
}
require.NoError(t, common.Unmarshal(awsReq.Body, &payload))
assert.Equal(t, []string{"fast-mode-2026-02-01", "thinking-binding-controls-2026-08-01"}, payload.AnthropicBeta)
assert.JSONEq(t, `{"type":"adaptive","display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"}}`, string(payload.Thinking))
}
func TestNewAwsInvokeContextInheritsParent(t *testing.T) {
originalRelayTimeout := common.RelayTimeout
t.Cleanup(func() {
@@ -450,3 +489,422 @@ func TestAwsStreamHandlerStopsAtClientCancellation(t *testing.T) {
t.Fatal("upstream producer did not observe the closed stream")
}
}
func newMantleTestInfo() *relaycommon.RelayInfo {
info := newAwsTestRelayInfo()
info.IsStream = false
info.RelayFormat = relaytypes.RelayFormatClaude
info.UpstreamModelName = "claude-opus-4-7"
info.ApiKey = "test-api-key|us-east-1"
info.ChannelOtherSettings.AwsApiType = dto.AwsApiTypeMantle
return info
}
func TestMantleRequestPreservesNativeFieldsAndSignsFinalPayload(t *testing.T) {
for _, key := range []string{"test-api-key|us-east-1", "access-key|secret-key|eu-west-1"} {
t.Run(strings.Split(key, "|")[0], func(t *testing.T) {
info := newMantleTestInfo()
info.ApiKey = key
info.UseRuntimeHeadersOverride = true
info.RuntimeHeadersOverride = map[string]any{"anthropic-beta": "test-beta", "X-Test": "override"}
info.ChannelSetting.PassThroughBodyEnabled = true
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
c := newAwsTestContext(httptest.NewRecorder(), ctx)
c.Request.Header.Set("Authorization", "Bearer client-secret")
c.Request.Header.Set("x-api-key", "client-secret")
a := &Adaptor{}
body := `{"model":"client-alias","stream":true,"max_tokens":128,"temperature":0,"cache_control":{"type":"ephemeral","ttl":"1h"},"messages":[{"role":"user","content":[{"type":"text","text":"hello","cache_control":{"type":"ephemeral"}}]}],"future_option":{"enabled":false},"anthropic_version":"bedrock-2023-05-31"}`
req, err := a.newMantleRequest(c, info, strings.NewReader(body))
require.NoError(t, err)
assert.Equal(t, "/anthropic/v1/messages", req.URL.Path)
assert.Equal(t, http.MethodPost, req.Method)
assert.Equal(t, "2023-06-01", req.Header.Get("anthropic-version"))
assert.Equal(t, "test-beta", req.Header.Get("anthropic-beta"))
assert.Equal(t, "override", req.Header.Get("X-Test"))
payload, err := io.ReadAll(req.Body)
require.NoError(t, err)
var data map[string]json.RawMessage
require.NoError(t, common.Unmarshal(payload, &data))
assert.JSONEq(t, `"anthropic.claude-opus-4-7"`, string(data["model"]))
assert.JSONEq(t, `{"type":"ephemeral","ttl":"1h"}`, string(data["cache_control"]))
assert.JSONEq(t, `[{"role":"user","content":[{"type":"text","text":"hello","cache_control":{"type":"ephemeral"}}]}]`, string(data["messages"]))
assert.JSONEq(t, `{"enabled":false}`, string(data["future_option"]))
assert.Equal(t, "0", string(data["temperature"]))
assert.Equal(t, "true", string(data["stream"]))
assert.NotContains(t, data, "anthropic_version")
replay, err := req.GetBody()
require.NoError(t, err)
defer replay.Close()
replayed, err := io.ReadAll(replay)
require.NoError(t, err)
assert.Equal(t, payload, replayed)
if strings.HasPrefix(key, "access-key|") {
assert.Empty(t, req.Header.Get("x-api-key"))
auth := req.Header.Get("Authorization")
assert.Contains(t, auth, "/eu-west-1/bedrock-mantle/aws4_request")
assert.NotContains(t, auth, "secret-key")
signedAt, err := time.Parse("20060102T150405Z", req.Header.Get("X-Amz-Date"))
require.NoError(t, err)
verification := req.Clone(ctx)
verification.Header.Del("Authorization")
hash := sha256.Sum256(payload)
err = v4.NewSigner().SignHTTP(ctx, aws.Credentials{AccessKeyID: "access-key", SecretAccessKey: "secret-key"}, verification, hex.EncodeToString(hash[:]), "bedrock-mantle", "eu-west-1", signedAt)
require.NoError(t, err)
assert.Equal(t, auth, verification.Header.Get("Authorization"))
} else {
assert.Equal(t, "test-api-key", req.Header.Get("x-api-key"))
assert.Empty(t, req.Header.Get("Authorization"))
}
cancel()
assert.ErrorIs(t, req.Context().Err(), context.Canceled)
})
}
}
func TestMantleEndpointAndCredentialValidation(t *testing.T) {
for _, test := range []struct {
name, key, base, want string
invalid bool
}{
{name: "regional default", key: "key|us-west-2", want: "https://bedrock-mantle.us-west-2.api.aws/anthropic/v1/messages"},
{name: "proxy root", key: "ak|sk|us-east-1", base: "https://proxy.example/gateway/", want: "https://proxy.example/gateway/anthropic/v1/messages"},
{name: "anthropic root", key: "key|us-east-1", base: "https://proxy.example/anthropic/", want: "https://proxy.example/anthropic/v1/messages"},
{name: "version root", key: "key|us-east-1", base: "https://proxy.example/anthropic/v1", want: "https://proxy.example/anthropic/v1/messages"},
{name: "full endpoint", key: "key|us-east-1", base: "https://proxy.example/anthropic/v1/messages", want: "https://proxy.example/anthropic/v1/messages"},
{name: "missing region", key: "secret", invalid: true},
{name: "empty key", key: "|us-east-1", invalid: true},
{name: "empty secret", key: "ak||us-east-1", invalid: true},
{name: "injected host", key: "key|us-east-1.evil.example/", invalid: true},
{name: "plaintext URL", key: "key|us-east-1", base: "http://proxy.example", invalid: true},
{name: "URL credentials", key: "key|us-east-1", base: "https://user:secret@proxy.example", invalid: true},
} {
t.Run(test.name, func(t *testing.T) {
info := newMantleTestInfo()
info.ApiKey, info.ChannelBaseUrl = test.key, test.base
endpoint, err := (&Adaptor{}).GetRequestURL(info)
if test.invalid {
require.Error(t, err)
assert.NotContains(t, err.Error(), "secret")
assert.Empty(t, endpoint)
return
}
require.NoError(t, err)
assert.Equal(t, test.want, endpoint)
})
}
}
func TestMantleHTTPResponsesPreserveCacheUsage(t *testing.T) {
originalTimeout := constant.StreamingTimeout
constant.StreamingTimeout = 30
t.Cleanup(func() { constant.StreamingTimeout = originalTimeout })
const usageJSON = `{"input_tokens":100,"output_tokens":23,"cache_read_input_tokens":1000,"cache_creation_input_tokens":50,"cache_creation":{"ephemeral_5m_input_tokens":20,"ephemeral_1h_input_tokens":30}}`
for _, stream := range []bool{false, true} {
for _, format := range []relaytypes.RelayFormat{relaytypes.RelayFormatClaude, relaytypes.RelayFormatOpenAI} {
t.Run(fmt.Sprintf("stream=%t/format=%s", stream, format), func(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if stream {
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprintf(w, "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_mantle\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"anthropic.claude-opus-4-7\",\"content\":[],\"usage\":%s}}\n\n", usageJSON)
fmt.Fprint(w, "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n\n")
fmt.Fprint(w, "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":23}}\n\n")
fmt.Fprint(w, "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"id":"msg_mantle","type":"message","role":"assistant","model":"anthropic.claude-opus-4-7","content":[{"type":"text","text":"hello"}],"stop_reason":"end_turn","usage":%s}`, usageJSON)
}))
defer server.Close()
original := http.DefaultClient
http.DefaultClient = server.Client()
defer func() { http.DefaultClient = original }()
info := newMantleTestInfo()
info.ChannelBaseUrl, info.IsStream, info.RelayFormat = server.URL, stream, format
recorder := httptest.NewRecorder()
c := newAwsTestContext(recorder, context.Background())
a := &Adaptor{}
a.Init(info)
var request dto.ClaudeRequest
require.NoError(t, common.UnmarshalJsonStr(`{"model":"claude-opus-4-7","max_tokens":128,"messages":[{"role":"user","content":"hello"}],"cache_control":{"type":"ephemeral"}}`, &request))
request.Stream = &stream
converted, err := a.ConvertClaudeRequest(c, info, &request)
require.NoError(t, err)
body, err := common.Marshal(converted)
require.NoError(t, err)
response, err := a.DoRequest(c, info, bytes.NewReader(body))
require.NoError(t, err)
httpResponse, ok := response.(*http.Response)
require.True(t, ok)
result, apiErr := a.DoResponse(c, httpResponse, info)
require.Nil(t, apiErr)
usage, ok := result.(*dto.Usage)
require.True(t, ok)
require.NotNil(t, usage.BillingUsage)
require.NotNil(t, usage.BillingUsage.ClaudeUsage)
claudeUsage := usage.BillingUsage.ClaudeUsage
assert.Equal(t, 100, claudeUsage.InputTokens)
assert.Equal(t, 23, claudeUsage.OutputTokens)
assert.Equal(t, 1000, claudeUsage.CacheReadInputTokens)
assert.Equal(t, 50, claudeUsage.CacheCreationInputTokens)
assert.Equal(t, 20, claudeUsage.GetCacheCreation5mTokens())
assert.Equal(t, 30, claudeUsage.GetCacheCreation1hTokens())
assert.Contains(t, recorder.Body.String(), "hello")
if stream && format == relaytypes.RelayFormatOpenAI {
assert.Contains(t, recorder.Body.String(), "[DONE]")
}
})
}
}
}
func TestMantleDoesNotFollowRedirectsOrFallbackOnAuthFailure(t *testing.T) {
for _, status := range []int{http.StatusTemporaryRedirect, http.StatusUnauthorized, http.StatusTooManyRequests} {
t.Run(fmt.Sprint(status), func(t *testing.T) {
calls := 0
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.Header().Set("Location", "/credential-leak")
w.WriteHeader(status)
}))
defer server.Close()
original := http.DefaultClient
http.DefaultClient = server.Client()
defer func() { http.DefaultClient = original }()
info := newMantleTestInfo()
info.ChannelBaseUrl = server.URL
c := newAwsTestContext(httptest.NewRecorder(), context.Background())
response, err := (&Adaptor{}).DoRequest(c, info, strings.NewReader(`{"messages":[]}`))
require.NoError(t, err)
httpResponse := response.(*http.Response)
defer httpResponse.Body.Close()
assert.Equal(t, status, httpResponse.StatusCode)
assert.Equal(t, 1, calls)
})
}
}
func TestAWSLegacyInterfaceRemainsDefault(t *testing.T) {
for _, mode := range []dto.AwsApiType{"", dto.AwsApiTypeInvokeModel} {
for _, stream := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/stream=%t", mode, stream), func(t *testing.T) {
info := newMantleTestInfo()
info.ChannelOtherSettings.AwsApiType = mode
info.IsStream = stream
info.UpstreamModelName = "claude-3-5-sonnet-20240620"
c := newAwsTestContext(httptest.NewRecorder(), context.Background())
a := &Adaptor{}
a.Init(info)
response, err := a.DoRequest(c, info, strings.NewReader(`{"max_tokens":128,"messages":[{"role":"user","content":[{"type":"text","text":"hi","cache_control":{"type":"ephemeral"}}]}],"cache_control":{"type":"ephemeral"}}`))
require.NoError(t, err)
assert.Nil(t, response)
var body []byte
if stream {
input, ok := a.AwsReq.(*bedrockruntime.InvokeModelWithResponseStreamInput)
require.True(t, ok)
body = input.Body
assert.Equal(t, "us."+awsTestModel, *input.ModelId)
} else {
input, ok := a.AwsReq.(*bedrockruntime.InvokeModelInput)
require.True(t, ok)
body = input.Body
assert.Equal(t, "us."+awsTestModel, *input.ModelId)
}
var payload map[string]json.RawMessage
require.NoError(t, common.Unmarshal(body, &payload))
assert.Equal(t, `"bedrock-2023-05-31"`, string(payload["anthropic_version"]))
assert.NotContains(t, payload, "cache_control")
assert.Contains(t, string(payload["messages"]), "cache_control")
})
}
}
}
func TestMantleRejectsInvalidRequestsAndPreservesExplicitModelIDs(t *testing.T) {
for _, test := range []struct {
name, model, body, wantModel string
invalid bool
}{
{name: "legacy Haiku alias", model: "claude-haiku-4-5-20251001", body: `{"messages":[]}`, wantModel: "anthropic.claude-haiku-4-5"},
{name: "short Haiku alias", model: "claude-haiku-4-5", body: `{"messages":[]}`, wantModel: "anthropic.claude-haiku-4-5"},
{name: "explicit Haiku model", model: "anthropic.claude-haiku-4-5", body: `{"messages":[]}`},
{name: "explicit Haiku inference profile", model: "us.anthropic.claude-haiku-4-5", body: `{"messages":[]}`},
{name: "explicit regional model", model: "us.anthropic.claude-opus-4-7", body: `{"messages":[]}`},
{name: "explicit model", model: "anthropic.claude-opus-4-7", body: `{"messages":[]}`},
{name: "Nova requires legacy", model: "nova-pro-v1:0", body: `{"messages":[]}`, invalid: true},
{name: "null body", model: "claude-opus-4-7", body: `null`, invalid: true},
{name: "malformed JSON", model: "claude-opus-4-7", body: `{`, invalid: true},
} {
t.Run(test.name, func(t *testing.T) {
info := newMantleTestInfo()
info.UpstreamModelName = test.model
c := newAwsTestContext(httptest.NewRecorder(), context.Background())
req, err := (&Adaptor{}).newMantleRequest(c, info, strings.NewReader(test.body))
if test.invalid {
require.Error(t, err)
assert.Nil(t, req)
return
}
require.NoError(t, err)
defer req.Body.Close()
var payload map[string]any
require.NoError(t, common.DecodeJson(req.Body, &payload))
wantModel := test.wantModel
if wantModel == "" {
wantModel = test.model
}
assert.Equal(t, wantModel, payload["model"])
assert.NotContains(t, payload, "cache_control")
})
}
}
func TestMantleSignatureMatchesWireHeaders(t *testing.T) {
for _, test := range []struct {
name string
http2 bool
allHopHeaders bool
}{
{name: "HTTP2 keep-alive", http2: true},
{name: "HTTP1 hop-by-hop headers", allHopHeaders: true},
{name: "HTTP2 hop-by-hop headers", http2: true, allHopHeaders: true},
} {
t.Run(test.name, func(t *testing.T) {
type receivedRequest struct {
request *http.Request
body []byte
err error
}
received := make(chan receivedRequest, 1)
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
received <- receivedRequest{request: r.Clone(context.Background()), body: body, err: err}
w.WriteHeader(http.StatusOK)
}))
server.EnableHTTP2 = test.http2
server.StartTLS()
defer server.Close()
originalClient := http.DefaultClient
http.DefaultClient = server.Client()
defer func() { http.DefaultClient = originalClient }()
info := newMantleTestInfo()
info.ChannelBaseUrl = server.URL
info.ApiKey = "access-key|secret-key|us-east-1"
info.UseRuntimeHeadersOverride = true
info.RuntimeHeadersOverride = map[string]any{
"Connection": "keep-alive",
"anthropic-beta": "test-beta",
"X-Test": "end-to-end",
}
if test.allHopHeaders {
maps.Copy(info.RuntimeHeadersOverride, map[string]any{
"Connection": "keep-alive, x-hop-token",
"X-Hop-Token": "hop-only",
"Keep-Alive": "timeout=5",
"Proxy-Connection": "keep-alive",
"Proxy-Authenticate": "Basic",
"Proxy-Authorization": "Basic test-proxy-token",
"Te": "trailers",
"Trailer": "X-Checksum",
"Transfer-Encoding": "chunked",
"Upgrade": "websocket",
})
}
c := newAwsTestContext(httptest.NewRecorder(), context.Background())
response, err := (&Adaptor{}).DoRequest(c, info, strings.NewReader(`{"messages":[],"max_tokens":128,"cache_control":{"type":"ephemeral","ttl":"1h"}}`))
require.NoError(t, err)
httpResponse, ok := response.(*http.Response)
require.True(t, ok)
defer httpResponse.Body.Close()
wire := <-received
require.NoError(t, wire.err)
wantProtocol := 1
if test.http2 {
wantProtocol = 2
}
require.Equal(t, wantProtocol, wire.request.ProtoMajor)
for _, name := range []string{"Connection", "X-Hop-Token", "Keep-Alive", "Proxy-Connection", "Proxy-Authenticate", "Proxy-Authorization", "Te", "Trailer", "Transfer-Encoding", "Upgrade"} {
assert.Empty(t, wire.request.Header.Values(name), "%s must not reach the upstream", name)
}
assert.Equal(t, "test-beta", wire.request.Header.Get("anthropic-beta"))
assert.Equal(t, "end-to-end", wire.request.Header.Get("X-Test"))
// Recompute the signature from only the bytes and signed headers that
// reached the server, not from the pre-transport request object.
authorization := wire.request.Header.Get("Authorization")
_, signedHeaders, found := strings.Cut(authorization, "SignedHeaders=")
require.True(t, found)
signedHeaders, _, found = strings.Cut(signedHeaders, ",")
require.True(t, found)
verification := wire.request.Clone(context.Background())
verification.URL.Scheme = "https"
verification.URL.Host = wire.request.Host
verification.Header = make(http.Header)
for name := range strings.SplitSeq(signedHeaders, ";") {
if values, ok := wire.request.Header[http.CanonicalHeaderKey(name)]; ok {
verification.Header[http.CanonicalHeaderKey(name)] = values
}
}
signedAt, err := time.Parse("20060102T150405Z", wire.request.Header.Get("X-Amz-Date"))
require.NoError(t, err)
hash := sha256.Sum256(wire.body)
err = v4.NewSigner().SignHTTP(context.Background(), aws.Credentials{AccessKeyID: "access-key", SecretAccessKey: "secret-key"}, verification, hex.EncodeToString(hash[:]), "bedrock-mantle", "us-east-1", signedAt)
require.NoError(t, err)
assert.Equal(t, authorization, verification.Header.Get("Authorization"))
})
}
}
func TestAWSLegacyHaikuMappingPreservesVersionAndRegion(t *testing.T) {
for _, test := range []struct {
name string
mode dto.AwsApiType
stream bool
key string
}{
{name: "default API key", key: "test-api-key|us-east-1"},
{name: "default API key stream", stream: true, key: "test-api-key|us-east-1"},
{name: "default AK/SK", key: "access-key|secret-key|us-east-1"},
{name: "default AK/SK stream", stream: true, key: "access-key|secret-key|us-east-1"},
{name: "explicit API key", mode: dto.AwsApiTypeInvokeModel, key: "test-api-key|us-east-1"},
{name: "explicit API key stream", mode: dto.AwsApiTypeInvokeModel, stream: true, key: "test-api-key|us-east-1"},
{name: "explicit AK/SK", mode: dto.AwsApiTypeInvokeModel, key: "access-key|secret-key|us-east-1"},
{name: "explicit AK/SK stream", mode: dto.AwsApiTypeInvokeModel, stream: true, key: "access-key|secret-key|us-east-1"},
} {
t.Run(test.name, func(t *testing.T) {
info := newMantleTestInfo()
info.ChannelOtherSettings.AwsApiType = test.mode
info.ApiKey, info.IsStream = test.key, test.stream
info.UpstreamModelName = "claude-haiku-4-5-20251001"
c := newAwsTestContext(httptest.NewRecorder(), context.Background())
a := &Adaptor{}
a.Init(info)
response, err := a.DoRequest(c, info, strings.NewReader(`{"messages":[{"role":"user","content":"hello"}],"max_tokens":128}`))
require.NoError(t, err)
assert.Nil(t, response)
const wantModel = "us.anthropic.claude-haiku-4-5-20251001-v1:0"
if test.stream {
request, ok := a.AwsReq.(*bedrockruntime.InvokeModelWithResponseStreamInput)
require.True(t, ok)
assert.Equal(t, wantModel, *request.ModelId)
} else {
request, ok := a.AwsReq.(*bedrockruntime.InvokeModelInput)
require.True(t, ok)
assert.Equal(t, wantModel, *request.ModelId)
}
})
}
}
func TestMantleCanceledRequestSkipsRetry(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
c := newAwsTestContext(httptest.NewRecorder(), ctx)
response, err := (&Adaptor{}).DoRequest(c, newMantleTestInfo(), strings.NewReader(`{"messages":[]}`))
require.Error(t, err)
assert.Nil(t, response)
var apiError *relaytypes.NewAPIError
require.ErrorAs(t, err, &apiError)
assert.True(t, relaytypes.IsSkipRetryError(apiError))
}
+2 -2
View File
@@ -119,7 +119,7 @@ func baiduStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
var baiduResponse BaiduChatStreamResponse
if err := common.Unmarshal([]byte(data), &baiduResponse); err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
common.SysLog(common.LogText("error unmarshalling stream response: %s", err.Error()))
sr.Error(err)
return
}
@@ -130,7 +130,7 @@ func baiduStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
}
response := streamResponseBaidu2OpenAI(&baiduResponse)
if err := helper.ObjectData(c, response); err != nil {
common.SysLog("error sending stream response: " + err.Error())
common.SysLog(common.LogText("error sending stream response: %s", err.Error()))
sr.Error(err)
}
})
+11 -1
View File
@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/url"
"strings"
"github.com/QuantumNous/new-api/relay/channel"
relaycommon "github.com/QuantumNous/new-api/relay/common"
@@ -39,6 +40,13 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
if err := relayconvert.ApplyClaudeThinkingModel(request, info); err != nil {
return nil, err
}
// A model reasoning suffix replaces the client's thinking config, but
// block_binding is a conversation control, not a reasoning setting. Keep
// the client's value when the rendered mode is still adaptive.
if original, ok := info.Request.(*dto.ClaudeRequest); ok && original.Thinking != nil &&
request.Thinking != nil && request.Thinking.Type == "adaptive" && len(request.Thinking.BlockBinding) == 0 {
request.Thinking.BlockBinding = original.Thinking.BlockBinding
}
if request.MaxTokens == nil {
defaultMaxTokens := uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(request.Model))
request.MaxTokens = &defaultMaxTokens
@@ -96,7 +104,9 @@ func shouldAppendClaudeBetaQuery(info *relaycommon.RelayInfo) bool {
func CommonClaudeHeadersOperation(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) {
// common headers operation
anthropicBeta := c.Request.Header.Get("anthropic-beta")
// A client may send several anthropic-beta lines; the API reads one
// comma-separated list, so forward all of them.
anthropicBeta := strings.Join(c.Request.Header.Values("anthropic-beta"), ",")
if anthropicBeta != "" {
req.Set("anthropic-beta", anthropicBeta)
}
+111
View File
@@ -1,6 +1,8 @@
package claude
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
@@ -74,6 +76,35 @@ func TestConvertClaudeRequestPreservesNativeClaudeCodeThinking(t *testing.T) {
assert.Empty(t, info.ConversionDiagnostics())
}
func TestConvertClaudeRequestPreservesMessageOutputConfig(t *testing.T) {
body := `{"model":"claude-opus-5-5","max_tokens":64,"output_config":{"effort":"medium"},"messages":[` +
`{"role":"user","content":"summary"},` +
`{"role":"system","content":[],"output_config":{"effort":"high"}},` +
`{"role":"assistant","content":"done"}]}`
var req dto.ClaudeRequest
require.NoError(t, common.UnmarshalJsonStr(body, &req))
info := &relaycommon.RelayInfo{
OriginModelName: req.Model,
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: req.Model,
},
}
out, err := (&Adaptor{}).ConvertClaudeRequest(nil, info, &req)
require.NoError(t, err)
encoded, err := common.Marshal(out)
require.NoError(t, err)
var upstream struct {
Messages []map[string]any `json:"messages"`
}
require.NoError(t, common.Unmarshal(encoded, &upstream))
require.Len(t, upstream.Messages, 3)
assert.Equal(t, map[string]any{"effort": "high"}, upstream.Messages[1]["output_config"])
assert.NotContains(t, upstream.Messages[0], "output_config")
assert.NotContains(t, upstream.Messages[2], "output_config")
}
func TestConvertClaudeRequestZeroMaxTokensStillRaisesThinkingBudget(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
@@ -127,6 +158,86 @@ func TestConvertClaudeRequestDoesNotOverwriteTrimmedUpstreamModelName(t *testing
assert.Equal(t, "claude-3-7-sonnet", info.UpstreamModelName)
}
func TestConvertClaudeRequestForwardsThinkingBlockBinding(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
model string
thinking string
wantThinking string
}{
{
name: "native request keeps every thinking field",
model: "claude-opus-5-5",
thinking: `{"type":"adaptive","display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"},"future_field":{"a":1}}`,
wantThinking: `{"type":"adaptive","display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"},"future_field":{"a":1}}`,
},
{
name: "effort suffix re-renders adaptive and keeps block_binding",
model: "claude-opus-5-5-high",
thinking: `{"type":"adaptive","block_binding":{"prefix_mismatch_behavior":"drop_block"}}`,
wantThinking: `{"type":"adaptive","display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"}}`,
},
{
name: "suffix rendering between_tools does not carry block_binding",
model: "claude-sonnet-5-5-none",
thinking: `{"type":"adaptive","block_binding":{"prefix_mismatch_behavior":"drop_block"}}`,
wantThinking: `{"type":"between_tools"}`,
},
{
name: "request without block_binding gets none",
model: "claude-opus-5-5-high",
thinking: `{"type":"adaptive"}`,
wantThinking: `{"type":"adaptive","display":"summarized"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
body := `{"model":"` + tt.model + `","max_tokens":1024,"thinking":` + tt.thinking +
`,"messages":[{"role":"user","content":"hi"}]}`
var original dto.ClaudeRequest
require.NoError(t, common.UnmarshalJsonStr(body, &original))
info := &relaycommon.RelayInfo{
OriginModelName: tt.model,
Request: &original,
ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: tt.model},
}
// Same steps as the Claude handler before the adaptor converts.
outbound, err := common.DeepCopy(&original)
require.NoError(t, err)
require.NoError(t, helper.ModelMappedHelper(c, info, outbound))
require.NoError(t, helper.ApplyReasoningModelSuffix(nil, info, outbound))
out, err := (&Adaptor{}).ConvertClaudeRequest(c, info, outbound)
require.NoError(t, err)
encoded, err := common.Marshal(out)
require.NoError(t, err)
var upstream struct {
Thinking json.RawMessage `json:"thinking"`
}
require.NoError(t, common.Unmarshal(encoded, &upstream))
assert.JSONEq(t, tt.wantThinking, string(upstream.Thinking))
})
}
}
func TestSetupRequestHeaderForwardsEveryAnthropicBetaValue(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
c.Request.Header.Add("anthropic-beta", "fast-mode-2026-02-01")
c.Request.Header.Add("anthropic-beta", "thinking-binding-controls-2026-08-01")
info := &relaycommon.RelayInfo{
OriginModelName: "claude-opus-5-5",
ChannelMeta: &relaycommon.ChannelMeta{ApiKey: "sk-test"},
}
header := http.Header{}
require.NoError(t, (&Adaptor{}).SetupRequestHeader(c, &header, info))
assert.Equal(t, []string{"fast-mode-2026-02-01,thinking-binding-controls-2026-08-01"}, header.Values("anthropic-beta"))
}
func geminiToClaudeInfo() *relaycommon.RelayInfo {
return &relaycommon.RelayInfo{
OriginModelName: "claude-3-7-sonnet",
+70 -33
View File
@@ -1,6 +1,7 @@
package claude
import (
"cmp"
"fmt"
"io"
"net/http"
@@ -89,7 +90,7 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
var claudeResponse dto.ClaudeResponse
err := common.UnmarshalJsonStr(data, &claudeResponse)
if err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
common.SysLog(common.LogText("error unmarshalling stream response: %s", err.Error()))
return types.NewError(err, types.ErrorCodeBadResponseBody)
}
if claudeError := claudeResponse.GetClaudeError(); claudeError != nil && claudeError.Type != "" {
@@ -122,7 +123,7 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
data = patchClaudeMessageDeltaUsageData(data, buildMessageDeltaPatchUsage(&claudeResponse, claudeInfo))
}
}
countClaudeStreamBillableTools(c, info, &claudeResponse)
countClaudeStreamBillableTools(info, &claudeResponse)
helper.ClaudeChunkData(c, claudeResponse, data)
} else if info.RelayFormat == types.RelayFormatOpenAI {
state, err := claudeToChatStreamState(info)
@@ -138,14 +139,14 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
return nil
}
countClaudeStreamBillableTools(c, info, &claudeResponse)
countClaudeStreamBillableTools(info, &claudeResponse)
if response == nil {
return nil
}
err = helper.ObjectData(c, response)
if err != nil {
logger.LogError(c, "send_stream_response_failed: "+err.Error())
logger.LogError(c, common.LogText("send_stream_response_failed: %s", err.Error()))
}
} else if info.RelayFormat == types.RelayFormatGemini {
state, err := claudeToGeminiStreamState(info)
@@ -159,7 +160,7 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
if !FormatClaudeResponseInfo(&claudeResponse, nil, claudeInfo) {
return nil
}
countClaudeStreamBillableTools(c, info, &claudeResponse)
countClaudeStreamBillableTools(info, &claudeResponse)
if sendErr := sendGeminiStreamResults(c, results); sendErr != nil {
return sendErr
}
@@ -221,38 +222,77 @@ func sendGeminiStreamResults(c *gin.Context, results []relayconvert.ResponseResu
return nil
}
func countClaudeStreamBillableTools(c *gin.Context, info *relaycommon.RelayInfo, claudeResponse *dto.ClaudeResponse) {
func countClaudeStreamBillableTools(info *relaycommon.RelayInfo, claudeResponse *dto.ClaudeResponse) {
if claudeResponse == nil {
return
}
if claudeResponse.Type == "content_block_start" &&
claudeResponse.ContentBlock != nil &&
claudeResponse.ContentBlock.Type == "tool_use" {
info.CountBillableToolCall(dto.BuildInCallToolUse, claudeResponse.ContentBlock.Name)
if claudeResponse.Type == "content_block_start" && claudeResponse.ContentBlock != nil {
countClaudeContentBlockTools(info, claudeResponse.ContentBlock)
}
if claudeResponse.Type == "message_delta" &&
claudeResponse.Usage != nil &&
claudeResponse.Usage.ServerToolUse != nil &&
claudeResponse.Usage.ServerToolUse.WebSearchRequests > 0 {
c.Set("claude_web_search_requests", claudeResponse.Usage.ServerToolUse.WebSearchRequests)
if claudeResponse.Type == "message_delta" && claudeResponse.Usage != nil {
// message_delta usage is cumulative, so the last report wins.
recordClaudeServerToolUse(info, claudeResponse.Usage.ServerToolUse)
}
}
// countClaudeContentBlockTools counts the billable tool calls of one content
// block. On a channel whose upstream prices search itself
// (WebSearchBillingKey), every web_search_tool_result that carries results
// bills one search: usage.server_tool_use replaces that count when reported,
// and it stands when a stream ends before message_delta.
func countClaudeContentBlockTools(info *relaycommon.RelayInfo, block *dto.ClaudeMediaMessage) {
switch block.Type {
case "tool_use":
info.CountBillableToolCall(dto.BuildInCallToolUse, block.Name)
case "web_search_tool_result":
if _, delivered := block.Content.([]any); delivered && info.WebSearchBillingKey != "" {
info.CountBillableToolCall(dto.BuildInCallWebSearchCall, "")
}
}
}
// recordClaudeServerToolUse bills the server tool requests Anthropic reports
// in usage.server_tool_use. Searches and fetches use the channel's own keys
// when its adaptor sets them; otherwise web_fetch, code_execution, and
// tool_search have no built-in price and bill only once an operator prices
// them.
func recordClaudeServerToolUse(info *relaycommon.RelayInfo, serverToolUse *dto.ClaudeServerToolUse) {
if info == nil || serverToolUse == nil {
return
}
for name, count := range map[string]int{
cmp.Or(info.WebSearchBillingKey, dto.BuildInToolWebSearch): serverToolUse.WebSearchRequests,
cmp.Or(info.WebFetchBillingKey, "web_fetch"): serverToolUse.WebFetchRequests,
"code_execution": serverToolUse.CodeExecutionRequests,
"tool_search": serverToolUse.ToolSearchRequests,
} {
if count > 0 {
info.SetBillableToolCount(name, count)
}
}
}
func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo) {
if claudeInfo.Usage.PromptTokens == 0 {
//上游出错
}
if claudeInfo.Usage.CompletionTokens == 0 || !claudeInfo.Done {
// Claude input_tokens excludes cache reads and writes, so a fully cached
// prompt reports zero input; the prompt is uncounted only when the total
// with cache is zero as well. Zero output is uncounted only when content
// was streamed, so a refusal before any output keeps its zeros.
missingInputUsage := buildOpenAIStyleUsageFromClaudeUsage(claudeInfo.Usage).PromptTokens == 0
missingOutputUsage := claudeInfo.Usage.CompletionTokens == 0 && claudeInfo.ResponseText.Len() > 0
if missingInputUsage || missingOutputUsage || !claudeInfo.Done {
if common.DebugEnabled {
common.SysLog("claude response usage is not complete, maybe upstream error")
common.SysLog(common.LogText("claude response usage is not complete, maybe upstream error"))
}
// 只补缺失字段,不整份覆盖——保留 message_start 已拿到的 cache 字段
fallback := service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
if claudeInfo.Usage.CompletionTokens == 0 ||
// Each tool_use block costs about 40 output tokens beyond its name and JSON
// input (measured on Claude Haiku 4.5 and Sonnet 5 agent turns).
fallback.CompletionTokens += claudeInfo.ToolUseCount * 40
if missingOutputUsage ||
(!claudeInfo.Done && fallback.CompletionTokens > claudeInfo.Usage.CompletionTokens) {
claudeInfo.Usage.CompletionTokens = fallback.CompletionTokens
}
if claudeInfo.Usage.PromptTokens == 0 {
if missingInputUsage {
claudeInfo.Usage.PromptTokens = fallback.PromptTokens
}
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
@@ -270,23 +310,23 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
response := helper.GenerateFinalUsageResponse(claudeInfo.ResponseId, claudeInfo.Created, info.UpstreamModelName, openAIUsage)
err := helper.ObjectData(c, response)
if err != nil {
common.SysLog("send final response failed: " + err.Error())
common.SysLog(common.LogText("send final response failed: %s", err.Error()))
}
}
helper.Done(c)
} else if info.RelayFormat == types.RelayFormatGemini {
state, err := claudeToGeminiStreamState(info)
if err != nil {
common.SysLog("error creating Gemini stream state: " + err.Error())
common.SysLog(common.LogText("error creating Gemini stream state: %s", err.Error()))
return
}
results, err := service.FinalizeStreamResponse(c, info, state)
if err != nil {
common.SysLog("error finalizing Gemini stream response: " + err.Error())
common.SysLog(common.LogText("error finalizing Gemini stream response: %s", err.Error()))
return
}
if sendErr := sendGeminiStreamResults(c, results); sendErr != nil {
common.SysLog("send final Gemini stream response failed: " + sendErr.Error())
common.SysLog(common.LogText("send final Gemini stream response failed: %s", sendErr.Error()))
}
}
}
@@ -387,14 +427,11 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
}
}
if claudeResponse.Usage != nil && claudeResponse.Usage.ServerToolUse != nil && claudeResponse.Usage.ServerToolUse.WebSearchRequests > 0 {
c.Set("claude_web_search_requests", claudeResponse.Usage.ServerToolUse.WebSearchRequests)
for i := range claudeResponse.Content {
countClaudeContentBlockTools(info, &claudeResponse.Content[i])
}
for _, block := range claudeResponse.Content {
if block.Type == "tool_use" {
info.CountBillableToolCall(dto.BuildInCallToolUse, block.Name)
}
if claudeResponse.Usage != nil {
recordClaudeServerToolUse(info, claudeResponse.Usage.ServerToolUse)
}
service.IOCopyBytesGracefully(c, httpResp, responseData)
+98
View File
@@ -1,15 +1,20 @@
package claude
import (
"fmt"
"net/http"
"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/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/tokenkit"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -19,6 +24,47 @@ func commonPointer[T any](value T) *T {
return &value
}
func TestClaudeStreamFinalUsageEstimatesOnlyUncountedUsage(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tc := range []struct {
name, startUsage, deltaUsage, stopReason string // no deltaUsage: the stream broke before message_delta
streamText bool
wantInput, wantOutput int
wantLocalCount bool
}{
{name: "cache-only refusal keeps zeros", startUsage: `{"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":20000,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":20000}}`, deltaUsage: `{"output_tokens":0}`, stopReason: "refusal"},
{name: "broken stream keeps cache-only input", startUsage: `{"input_tokens":0,"output_tokens":1,"cache_read_input_tokens":50000}`, streamText: true, wantOutput: 2, wantLocalCount: true},
{name: "zeros after streamed text are estimated", startUsage: `{"input_tokens":0,"output_tokens":0}`, deltaUsage: `{"output_tokens":0}`, stopReason: "end_turn", streamText: true, wantInput: 1200, wantOutput: 2, wantLocalCount: true},
{name: "zero delta input keeps start input", startUsage: `{"input_tokens":1000,"output_tokens":1}`, deltaUsage: `{"input_tokens":0,"output_tokens":50}`, stopReason: "end_turn", streamText: true, wantInput: 1000, wantOutput: 50},
} {
t.Run(tc.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
info := &relaycommon.RelayInfo{RelayFormat: types.RelayFormatClaude, ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "claude-opus-4-8"}}
info.SetEstimatePromptTokens(1200)
state := &ClaudeResponseInfo{Usage: &dto.Usage{}}
start := fmt.Sprintf(`{"type":"message_start","message":{"id":"msg_1","model":"claude-opus-4-8","usage":%s}}`, tc.startUsage)
require.Nil(t, HandleStreamResponseData(c, info, state, start))
if tc.streamText {
require.Nil(t, HandleStreamResponseData(c, info, state, `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}`))
}
if tc.deltaUsage != "" {
delta := fmt.Sprintf(`{"type":"message_delta","delta":{"stop_reason":%q},"usage":%s}`, tc.stopReason, tc.deltaUsage)
require.Nil(t, HandleStreamResponseData(c, info, state, delta))
require.Nil(t, HandleStreamResponseData(c, info, state, `{"type":"message_stop"}`))
}
HandleStreamFinalResponse(c, info, state)
assert.Equal(t, tc.wantInput, state.Usage.PromptTokens)
assert.Equal(t, tc.wantOutput, state.Usage.CompletionTokens)
assert.Equal(t, tc.wantLocalCount, common.GetContextKeyBool(c, constant.ContextKeyLocalCountTokens))
canonical, ok := state.Usage.BillingUsage.CanonicalUsage()
require.True(t, ok)
assert.Equal(t, tc.wantInput, canonical.PromptTokens, "settlement must bill the same input")
assert.Equal(t, tc.wantOutput, canonical.CompletionTokens)
})
}
}
func TestResponseOpenAI2ClaudeToolUseInputIsObject(t *testing.T) {
tests := []struct {
name string
@@ -228,6 +274,33 @@ func TestFormatClaudeResponseInfo_ContentBlockDelta(t *testing.T) {
}
}
// A stream cut off during an agent tool call must still estimate the call's
// output tokens when the final usage never arrives.
func TestClaudeStreamFallbackCountsInterruptedToolUse(t *testing.T) {
claudeInfo := &ClaudeResponseInfo{Usage: &dto.Usage{}}
events := []*dto.ClaudeResponse{
{Type: "content_block_start", ContentBlock: &dto.ClaudeMediaMessage{Type: "tool_use", Id: "toolu_1", Name: "read_file"}},
{Type: "content_block_delta", Delta: &dto.ClaudeMediaMessage{Type: "input_json_delta", PartialJson: commonPointer(`{"path":`)}},
{Type: "content_block_delta", Delta: &dto.ClaudeMediaMessage{Type: "input_json_delta", PartialJson: commonPointer(`"main.go"}`)}},
}
for _, event := range events {
require.True(t, FormatClaudeResponseInfo(event, nil, claudeInfo))
}
require.Equal(t, `read_file{"path":"main.go"}`, claudeInfo.ResponseText.String())
c, _ := gin.CreateTestContext(httptest.NewRecorder())
info := &relaycommon.RelayInfo{
RelayFormat: types.RelayFormatClaude,
ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "claude-sonnet-5"},
}
info.SetEstimatePromptTokens(100)
HandleStreamFinalResponse(c, info, claudeInfo)
// Text estimate plus the per-tool_use framing the upstream bills.
assert.Equal(t, tokenkit.Estimate("claude-sonnet-5", `read_file{"path":"main.go"}`)+40, claudeInfo.Usage.CompletionTokens)
assert.Equal(t, 100, claudeInfo.Usage.PromptTokens)
}
func TestBuildOpenAIStyleUsageFromClaudeUsage(t *testing.T) {
usage := &dto.Usage{
PromptTokens: 100,
@@ -400,3 +473,28 @@ func TestOpenAIChatRequestToClaudeMessages_ClaudeOpus48ThinkingUsesAdaptiveHighE
require.Nil(t, claudeRequest.TopP)
require.Nil(t, claudeRequest.TopK)
}
func TestClaudeResponsePassesInputTransformationsThrough(t *testing.T) {
gin.SetMode(gin.TestMode)
info := &relaycommon.RelayInfo{
RelayFormat: types.RelayFormatClaude,
ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "claude-opus-5-5"},
}
transformations := `"input_transformations":[{"type":"thinking_dropped","path":"messages.1.content.0","reason":"prefix_binding_mismatch"}]`
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/v1/messages", nil)
body := `{"id":"msg_1","type":"message","role":"assistant","model":"claude-opus-5-5","content":[{"type":"text","text":"hi"}],` +
`"stop_reason":"end_turn",` + transformations + `,"usage":{"input_tokens":5,"output_tokens":1}}`
require.Nil(t, HandleClaudeResponseData(c, info, &ClaudeResponseInfo{Usage: &dto.Usage{}}, nil, []byte(body)))
assert.Equal(t, body, w.Body.String())
w = httptest.NewRecorder()
c, _ = gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/v1/messages", nil)
event := `{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-opus-5-5","content":[],` +
transformations + `,"usage":{"input_tokens":5,"output_tokens":1}}}`
require.Nil(t, HandleStreamResponseData(c, info, &ClaudeResponseInfo{Usage: &dto.Usage{}}, event))
assert.Contains(t, w.Body.String(), "data: "+event+"\n")
}
+2 -2
View File
@@ -85,7 +85,7 @@ func ClaudeResponsesStreamHandler(c *gin.Context, resp *http.Response, info *rel
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
var claudeResponse dto.ClaudeResponse
if err := common.UnmarshalJsonStr(data, &claudeResponse); err != nil {
logger.LogError(c, "failed to unmarshal Claude stream event: "+err.Error())
logger.LogError(c, common.LogText("failed to unmarshal Claude stream event: %s", err.Error()))
if failResponsesStream(err) {
// A nil streamErr here is intentional: the protocol-level failure
// event was delivered, so only the scanner needs to stop.
@@ -120,7 +120,7 @@ func ClaudeResponsesStreamHandler(c *gin.Context, resp *http.Response, info *rel
info.UpstreamModelName = claudeResponse.Message.Model
}
FormatClaudeResponseInfo(&claudeResponse, nil, claudeInfo)
countClaudeStreamBillableTools(c, info, &claudeResponse)
countClaudeStreamBillableTools(info, &claudeResponse)
hostedEvents, consumed, err := hostedBridge.Convert(&claudeResponse, state)
if err != nil {
if failResponsesStream(err) {
+107 -6
View File
@@ -1,9 +1,12 @@
package claude
import (
"cmp"
"fmt"
"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/QuantumNous/new-api/relaykit/types"
@@ -47,24 +50,23 @@ func TestHandleClaudeResponseDataCountsToolUse(t *testing.T) {
}
func TestCountClaudeStreamBillableToolsSetsWebSearchRequests(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
info := &relaycommon.RelayInfo{OriginModelName: "claude-3-7-sonnet"}
countClaudeStreamBillableTools(c, info, &dto.ClaudeResponse{
countClaudeStreamBillableTools(info, &dto.ClaudeResponse{
Type: "message_delta",
Usage: &dto.ClaudeUsage{
ServerToolUse: &dto.ClaudeServerToolUse{WebSearchRequests: 3},
},
})
assert.Equal(t, 3, c.GetInt("claude_web_search_requests"))
require.Contains(t, info.ResponsesUsageInfo.BuiltInTools, "web_search")
assert.Equal(t, 3, info.ResponsesUsageInfo.BuiltInTools["web_search"].CallCount)
assert.Len(t, info.ResponsesUsageInfo.BuiltInTools, 1, "server tools reported as zero are not recorded")
operation_setting.SetToolPriceForTest("stream_fn", 2.0)
t.Cleanup(func() {
operation_setting.DeleteToolPriceForTest("stream_fn")
})
countClaudeStreamBillableTools(c, info, &dto.ClaudeResponse{
countClaudeStreamBillableTools(info, &dto.ClaudeResponse{
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "tool_use",
@@ -74,3 +76,102 @@ func TestCountClaudeStreamBillableToolsSetsWebSearchRequests(t *testing.T) {
require.Contains(t, info.ResponsesUsageInfo.BuiltInTools, "stream_fn")
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools["stream_fn"].CallCount)
}
// usage.server_tool_use counts bill under their own keys; a channel that
// bills web search under a vendor key (Ali's Anthropic endpoint) re-keys
// web_search_requests.
func TestCountClaudeStreamBillableToolsServerToolKeys(t *testing.T) {
tests := []struct {
name string
billingKey string
fetchKey string
usage dto.ClaudeServerToolUse
want map[string]int
}{
{
name: "vendor web search key",
billingKey: "search_strategy_agent",
usage: dto.ClaudeServerToolUse{WebSearchRequests: 2},
want: map[string]int{"search_strategy_agent": 2},
},
{
name: "vendor web search and web fetch keys",
billingKey: "deepseek_search",
fetchKey: "deepseek_fetch",
usage: dto.ClaudeServerToolUse{WebSearchRequests: 1, WebFetchRequests: 2},
want: map[string]int{"deepseek_search": 1, "deepseek_fetch": 2},
},
{
name: "every server tool",
usage: dto.ClaudeServerToolUse{WebSearchRequests: 1, WebFetchRequests: 2, CodeExecutionRequests: 3, ToolSearchRequests: 4},
want: map[string]int{"web_search": 1, "web_fetch": 2, "code_execution": 3, "tool_search": 4},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info := &relaycommon.RelayInfo{OriginModelName: "claude-3-7-sonnet", WebSearchBillingKey: tt.billingKey, WebFetchBillingKey: tt.fetchKey}
countClaudeStreamBillableTools(info, &dto.ClaudeResponse{
Type: "message_delta",
Usage: &dto.ClaudeUsage{ServerToolUse: &tt.usage},
})
counts := map[string]int{}
for name, tool := range info.ResponsesUsageInfo.BuiltInTools {
counts[name] = tool.CallCount
}
assert.Equal(t, tt.want, counts)
})
}
}
// On a channel whose upstream prices search itself (DeepSeek), each delivered
// web_search_tool_result bills one search under the vendor key, so a stream cut
// before message_delta still pays for the searches that ran;
// usage.server_tool_use replaces that count when reported. Error results and
// channels without a vendor key count nothing from blocks.
func TestClaudeSearchResultBlocksBillVendorSearch(t *testing.T) {
gin.SetMode(gin.TestMode)
const results = `{"type":"web_search_tool_result","tool_use_id":"call_0","content":[{"type":"web_search_result","url":"https://example.com/"}]}`
const empty = `{"type":"web_search_tool_result","tool_use_id":"call_1","content":[]}`
const failed = `{"type":"web_search_tool_result","tool_use_id":"call_2","content":{"type":"web_search_tool_result_error","error_code":"unavailable"}}`
tests := []struct {
name, billingKey, blocks, usage string
want map[string]int
}{
{name: "stream cut before message_delta", billingKey: "deepseek_search", blocks: results + "," + empty + "," + failed,
want: map[string]int{"deepseek_search": 2}},
{name: "reported count replaces blocks", billingKey: "deepseek_search", blocks: results + "," + results,
usage: `{"input_tokens":1,"output_tokens":1,"server_tool_use":{"web_search_requests":3}}`, want: map[string]int{"deepseek_search": 3}},
{name: "no vendor key", blocks: results, want: map[string]int{}},
}
for _, tt := range tests {
for _, stream := range []bool{true, false} {
t.Run(fmt.Sprintf("%s stream=%t", tt.name, stream), func(t *testing.T) {
info := &relaycommon.RelayInfo{OriginModelName: "claude-sonnet-4-5", RelayFormat: types.RelayFormatClaude, WebSearchBillingKey: tt.billingKey}
var blocks []dto.ClaudeMediaMessage
require.NoError(t, common.UnmarshalJsonStr("["+tt.blocks+"]", &blocks))
if stream {
for i := range blocks {
countClaudeStreamBillableTools(info, &dto.ClaudeResponse{Type: "content_block_start", ContentBlock: &blocks[i]})
}
if tt.usage != "" {
var usage dto.ClaudeUsage
require.NoError(t, common.UnmarshalJsonStr(tt.usage, &usage))
countClaudeStreamBillableTools(info, &dto.ClaudeResponse{Type: "message_delta", Usage: &usage})
}
} else {
usage := cmp.Or(tt.usage, `{"input_tokens":1,"output_tokens":1}`)
data := []byte(`{"type":"message","content":[` + tt.blocks + `],"usage":` + usage + `}`)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
require.Nil(t, HandleClaudeResponseData(c, info, &ClaudeResponseInfo{Usage: &dto.Usage{}}, nil, data))
}
counts := map[string]int{}
if info.ResponsesUsageInfo != nil {
for name, tool := range info.ResponsesUsageInfo.BuiltInTools {
counts[name] = tool.CallCount
}
}
assert.Equal(t, tt.want, counts)
})
}
}
}
+5 -4
View File
@@ -8,6 +8,7 @@ import (
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
@@ -53,7 +54,7 @@ func cfStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Res
var response dto.ChatCompletionsStreamResponse
err := json.Unmarshal([]byte(data), &response)
if err != nil {
logger.LogError(c, "error_unmarshalling_stream_response: "+err.Error())
logger.LogError(c, common.LogText("error_unmarshalling_stream_response: %s", err.Error()))
continue
}
for _, choice := range response.Choices {
@@ -68,19 +69,19 @@ func cfStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Res
info.FirstResponseTime = time.Now()
}
if err != nil {
logger.LogError(c, "error_rendering_stream_response: "+err.Error())
logger.LogError(c, common.LogText("error_rendering_stream_response: %s", err.Error()))
}
}
if err := scanner.Err(); err != nil {
logger.LogError(c, "error_scanning_stream_response: "+err.Error())
logger.LogError(c, common.LogText("error_scanning_stream_response: %s", err.Error()))
}
usage := service.ResponseText2Usage(c, responseText, info.UpstreamModelName, info.GetEstimatePromptTokens())
if info.ShouldIncludeUsage {
response := helper.GenerateFinalUsageResponse(id, info.StartTime.Unix(), info.UpstreamModelName, *usage)
err := helper.ObjectData(c, response)
if err != nil {
logger.LogError(c, "error_rendering_final_usage_response: "+err.Error())
logger.LogError(c, common.LogText("error_rendering_final_usage_response: %s", err.Error()))
}
}
helper.Done(c)
+3 -3
View File
@@ -106,7 +106,7 @@ func cohereStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
dataChan <- data
}
if err := scanner.Err(); err != nil {
common.SysLog("error reading stream: " + err.Error())
common.SysLog(common.LogText("error reading stream: %s", err.Error()))
}
stopChan <- true
}()
@@ -123,7 +123,7 @@ func cohereStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
var cohereResp CohereResponse
err := json.Unmarshal([]byte(data), &cohereResp)
if err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
common.SysLog(common.LogText("error unmarshalling stream response: %s", err.Error()))
return true
}
var openaiResp dto.ChatCompletionsStreamResponse
@@ -158,7 +158,7 @@ func cohereStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
}
jsonStr, err := json.Marshal(openaiResp)
if err != nil {
common.SysLog("error marshalling stream response: " + err.Error())
common.SysLog(common.LogText("error marshalling stream response: %s", err.Error()))
return true
}
c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonStr)})
+5 -5
View File
@@ -156,7 +156,7 @@ func handleCozeEvent(c *gin.Context, event string, data string, responseText *st
var chatData CozeChatResponseData
err := json.Unmarshal([]byte(data), &chatData)
if err != nil {
common.SysLog("error_unmarshalling_stream_response: " + err.Error())
common.SysLog(common.LogText("error_unmarshalling_stream_response: %s", err.Error()))
return
}
@@ -173,14 +173,14 @@ func handleCozeEvent(c *gin.Context, event string, data string, responseText *st
var messageData CozeChatV3MessageDetail
err := json.Unmarshal([]byte(data), &messageData)
if err != nil {
common.SysLog("error_unmarshalling_stream_response: " + err.Error())
common.SysLog(common.LogText("error_unmarshalling_stream_response: %s", err.Error()))
return
}
var content string
err = json.Unmarshal(messageData.Content, &content)
if err != nil {
common.SysLog("error_unmarshalling_stream_response: " + err.Error())
common.SysLog(common.LogText("error_unmarshalling_stream_response: %s", err.Error()))
return
}
@@ -205,11 +205,11 @@ func handleCozeEvent(c *gin.Context, event string, data string, responseText *st
var errorData CozeError
err := json.Unmarshal([]byte(data), &errorData)
if err != nil {
common.SysLog("error_unmarshalling_stream_response: " + err.Error())
common.SysLog(common.LogText("error_unmarshalling_stream_response: %s", err.Error()))
return
}
common.SysLog(fmt.Sprintf("stream event error: %v %v", errorData.Code, errorData.Message))
common.SysLog(common.LogText("stream event error: %v %v", errorData.Code, errorData.Message))
}
}
+101 -30
View File
@@ -14,6 +14,7 @@ import (
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/constant"
"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"
"github.com/QuantumNous/new-api/setting/reasoning"
@@ -30,7 +31,13 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
adaptor := claude.Adaptor{}
// A model modifier is rendered into DeepSeek's own thinking and effort
// fields below; Anthropic's model rules (budgets, adaptive thinking,
// max_tokens and sampling changes) must not see it.
state := info.ReasoningConversion
info.ReasoningConversion = nil
convertedRequest, err := adaptor.ConvertClaudeRequest(c, info, req)
info.ReasoningConversion = state
if err != nil {
return nil, err
}
@@ -55,6 +62,14 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
// DeepSeek runs Claude Code and Codex searches and page fetches
// server-side and bills them through its Search and Fetch APIs, whatever
// model name the client sent.
info.WebSearchBillingKey = SearchToolPriceKey
info.WebFetchBillingKey = FetchToolPriceKey
if info.RelayMode == constant.RelayModeResponses {
info.VendorToolUsage = responsesToolUsage
}
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
@@ -71,6 +86,10 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
return fmt.Sprintf("%s/completions", fimBaseUrl), nil
case constant.RelayModeResponses:
return fmt.Sprintf("%s/responses", info.ChannelBaseUrl), nil
case constant.RelayModeToolsSearch:
return fmt.Sprintf("%s/v1/tools/search", info.ChannelBaseUrl), nil
case constant.RelayModeToolsFetch:
return fmt.Sprintf("%s/v1/tools/fetch", info.ChannelBaseUrl), nil
default:
return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil
}
@@ -99,10 +118,7 @@ func applyDeepSeekV4OpenAIThinkingSuffix(info *relaycommon.RelayInfo, request *d
if info != nil && info.ChannelMeta != nil && info.UpstreamModelName != "" {
modelName = info.UpstreamModelName
}
if model_setting.ShouldPreserveThinkingSuffix(modelName) || info != nil && model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
return nil
}
baseModel, thinkingType, effort, ok := reasoning.ParseDeepSeekV4ThinkingSuffix(modelName)
baseModel, thinkingType, effort, ok := deepSeekThinking(info, modelName)
if !ok {
return nil
}
@@ -115,11 +131,15 @@ func applyDeepSeekV4OpenAIThinkingSuffix(info *relaycommon.RelayInfo, request *d
request.Model = baseModel
request.THINKING = thinking
request.ReasoningEffort = effort
if info.ReasoningState() != nil {
// DeepSeek Chat has no reasoning object; a budget modifier adds one.
request.Reasoning = nil
}
if info != nil {
if info.ChannelMeta != nil {
info.UpstreamModelName = baseModel
}
info.SetReasoningEffort(effort)
info.SetReasoningEffort(deepSeekRunEffort(thinkingType, effort))
}
return nil
}
@@ -129,31 +149,36 @@ func applyDeepSeekV4ClaudeThinkingSuffix(info *relaycommon.RelayInfo, request *d
if info != nil && info.ChannelMeta != nil && info.UpstreamModelName != "" {
modelName = info.UpstreamModelName
}
if model_setting.ShouldPreserveThinkingSuffix(modelName) || info != nil && model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
return nil
}
baseModel, thinkingType, effort, ok := reasoning.ParseDeepSeekV4ThinkingSuffix(modelName)
baseModel, thinkingType, effort, ok := deepSeekThinking(info, modelName)
if !ok {
return nil
}
request.Model = baseModel
request.Thinking = &dto.Thinking{Type: thinkingType}
if effort == "" {
request.OutputConfig = nil
} else {
outputConfig, err := common.Marshal(map[string]string{
"effort": effort,
})
// Only effort is set or removed; other output_config members stay.
outputConfig := make(map[string]any)
if len(request.OutputConfig) > 0 && common.GetJsonType(request.OutputConfig) == "object" {
if err := common.Unmarshal(request.OutputConfig, &outputConfig); err != nil {
return fmt.Errorf("invalid output_config: %w", err)
}
}
delete(outputConfig, "effort")
if effort != "" {
outputConfig["effort"] = effort
}
request.OutputConfig = nil
if len(outputConfig) > 0 {
encoded, err := common.Marshal(outputConfig)
if err != nil {
return fmt.Errorf("error marshalling output_config: %w", err)
}
request.OutputConfig = outputConfig
request.OutputConfig = encoded
}
if info != nil {
if info.ChannelMeta != nil {
info.UpstreamModelName = baseModel
}
info.SetReasoningEffort(effort)
info.SetReasoningEffort(deepSeekRunEffort(thinkingType, effort))
}
return nil
}
@@ -177,25 +202,71 @@ func applyDeepSeekV4ResponsesThinkingSuffix(info *relaycommon.RelayInfo, request
if info != nil && info.ChannelMeta != nil && info.UpstreamModelName != "" {
modelName = info.UpstreamModelName
}
if model_setting.ShouldPreserveThinkingSuffix(modelName) || info != nil && model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
baseModel, thinkingType, effort, ok := deepSeekThinking(info, modelName)
if !ok {
if info != nil && request.Reasoning != nil {
info.SetReasoningEffort(request.Reasoning.Effort)
}
return
}
baseModel, thinkingType, effort, ok := reasoning.ParseDeepSeekV4ThinkingSuffix(modelName)
if ok {
if thinkingType == "disabled" {
effort = "none"
}
request.Model = baseModel
if request.Reasoning == nil {
request.Reasoning = &dto.Reasoning{}
}
if thinkingType == "disabled" {
effort = "none"
}
request.Model = baseModel
if request.Reasoning == nil && effort != "" {
request.Reasoning = &dto.Reasoning{}
}
if request.Reasoning != nil {
request.Reasoning.Effort = effort
if info != nil && info.ChannelMeta != nil {
}
if info != nil {
if info.ChannelMeta != nil {
info.UpstreamModelName = baseModel
}
info.SetReasoningEffort(deepSeekRunEffort(thinkingType, effort))
}
if info != nil && request.Reasoning != nil {
info.SetReasoningEffort(request.Reasoning.Effort)
}
// deepSeekThinking returns the model name to send with DeepSeek's thinking
// switch and effort. An explicit @ modifier (the reasoning state) wins over a
// -none/-low/-max tail, which is still stripped from the name. A budget maps
// to an effort as on other effort-only targets; thinking on or adaptive leaves
// the effort to DeepSeek's default.
func deepSeekThinking(info *relaycommon.RelayInfo, modelName string) (baseModel string, thinkingType string, effort string, ok bool) {
baseModel = modelName
if !model_setting.ShouldPreserveThinkingSuffix(modelName) && (info == nil || !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName)) {
baseModel, thinkingType, effort, ok = reasoning.ParseDeepSeekV4ThinkingSuffix(modelName)
}
state := info.ReasoningState()
if state == nil {
return baseModel, thinkingType, effort, ok
}
intent := kitreasoning.IntentFromState(state)
switch runs := kitreasoning.EffectiveEffort(intent); {
case runs == "":
return baseModel, thinkingType, effort, ok
case runs == kitreasoning.EffortNone:
return baseModel, "disabled", "", true
case intent.Effort == "" && intent.BudgetTokens == nil:
return baseModel, "enabled", "", true
default:
return baseModel, "enabled", string(runs), true
}
}
// deepSeekRunEffort is the effort DeepSeek runs: none with thinking off, its
// default high when no effort is sent, and the levels it does not implement
// mapped as DeepSeek maps them (minimal to low, medium and xhigh to high).
func deepSeekRunEffort(thinkingType string, effort string) string {
switch {
case thinkingType == "disabled":
return string(kitreasoning.EffortNone)
case effort == "" || effort == "medium" || effort == "xhigh":
return "high"
case effort == "minimal":
return "low"
default:
return effort
}
}
+270
View File
@@ -0,0 +1,270 @@
package deepseek
import (
"fmt"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// The -none/-low/-max tails of deepseek-flash and deepseek-v4-* reach DeepSeek
// as its own thinking switch and effort in each upstream format, with the base
// name; a bare name keeps DeepSeek's default, and a blacklisted name is sent
// verbatim.
func TestThinkingSuffixUpstreamFields(t *testing.T) {
settings := model_setting.GetGlobalSettings()
original := append([]string(nil), settings.ThinkingModelBlacklist...)
t.Cleanup(func() { settings.ThinkingModelBlacklist = original })
settings.ThinkingModelBlacklist = append(original, "deepseek-v4-pro-max")
tests := []struct {
model, upstream, thinking, effort string
}{
{model: "deepseek-flash", upstream: "deepseek-flash"},
{model: "deepseek-flash-none", upstream: "deepseek-flash", thinking: "disabled"},
{model: "deepseek-flash-low", upstream: "deepseek-flash", thinking: "enabled", effort: "low"},
{model: "deepseek-flash-max", upstream: "deepseek-flash", thinking: "enabled", effort: "max"},
{model: "deepseek-v4-pro-none", upstream: "deepseek-v4-pro", thinking: "disabled"},
{model: "deepseek-v4-pro-low", upstream: "deepseek-v4-pro", thinking: "enabled", effort: "low"},
{model: "deepseek-v4-pro-max", upstream: "deepseek-v4-pro-max"},
}
newInfo := func(model string) *relaycommon.RelayInfo {
return &relaycommon.RelayInfo{OriginModelName: model, ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: model}}
}
for _, tt := range tests {
t.Run(tt.model, func(t *testing.T) {
chat := &dto.GeneralOpenAIRequest{Model: tt.model}
converted, err := (&Adaptor{}).ConvertOpenAIRequest(nil, newInfo(tt.model), chat)
require.NoError(t, err)
chat = converted.(*dto.GeneralOpenAIRequest)
assert.Equal(t, tt.upstream, chat.Model)
assert.Equal(t, tt.effort, chat.ReasoningEffort)
claude := &dto.ClaudeRequest{Model: tt.model}
require.NoError(t, applyDeepSeekV4ClaudeThinkingSuffix(newInfo(tt.model), claude))
assert.Equal(t, tt.upstream, claude.Model)
converted, err = (&Adaptor{}).ConvertOpenAIResponsesRequest(nil, newInfo(tt.model), dto.OpenAIResponsesRequest{Model: tt.model})
require.NoError(t, err)
responses := converted.(dto.OpenAIResponsesRequest)
assert.Equal(t, tt.upstream, responses.Model)
if tt.thinking == "" {
assert.Empty(t, chat.THINKING)
assert.Nil(t, claude.Thinking)
assert.Nil(t, claude.OutputConfig)
assert.Nil(t, responses.Reasoning)
return
}
assert.JSONEq(t, `{"type":"`+tt.thinking+`"}`, string(chat.THINKING))
require.NotNil(t, claude.Thinking)
assert.Equal(t, tt.thinking, claude.Thinking.Type)
require.NotNil(t, responses.Reasoning)
if tt.thinking == "disabled" {
assert.Nil(t, claude.OutputConfig)
assert.Equal(t, "none", responses.Reasoning.Effort)
return
}
assert.JSONEq(t, `{"effort":"`+tt.effort+`"}`, string(claude.OutputConfig))
assert.Equal(t, tt.effort, responses.Reasoning.Effort)
})
}
}
// DeepSeek runs Claude Code and Codex searches server-side and bills them as
// Search and Fetch API calls, whatever model name the client sent. On the
// Responses API the completed web_search_call items of the whole response
// replace the items counted while streaming: open_page is a Fetch call, a
// failed call is free, and an empty output keeps the streamed count.
func TestServerSideSearchBillingKeys(t *testing.T) {
for _, tc := range []struct {
format types.RelayFormat
mode int
}{
{types.RelayFormatClaude, constant.RelayModeUnknown},
{types.RelayFormatOpenAI, constant.RelayModeChatCompletions},
{types.RelayFormatOpenAIResponses, constant.RelayModeResponses},
} {
info := &relaycommon.RelayInfo{RelayFormat: tc.format, RelayMode: tc.mode, ChannelMeta: &relaycommon.ChannelMeta{}}
(&Adaptor{}).Init(info)
assert.Equal(t, SearchToolPriceKey, info.WebSearchBillingKey, tc.format)
assert.Equal(t, FetchToolPriceKey, info.WebFetchBillingKey, tc.format)
assert.Equal(t, tc.mode == constant.RelayModeResponses, info.VendorToolUsage != nil, tc.format)
}
const output = `[
{"type":"web_search_call","status":"completed","action":{"type":"search","queries":["deepseek","ws_call_id=call_01"]}},
{"type":"web_search_call","status":"completed","action":{"type":"open_page","url":"https://example.com/"}},
{"type":"web_search_call","status":"failed","action":{"type":"open_page","url":"https://api-docs.deepseek.com/"}},
{"type":"message","status":"completed"}]`
for _, raw := range []string{
`{"status":"completed","output":` + output + `}`,
`{"type":"response.completed","response":{"status":"completed","output":` + output + `}}`,
} {
info := &relaycommon.RelayInfo{
OriginModelName: "gpt-5.1-codex",
RelayFormat: types.RelayFormatOpenAIResponses,
RelayMode: constant.RelayModeResponses,
ChannelMeta: &relaycommon.ChannelMeta{},
}
(&Adaptor{}).Init(info)
for range 3 {
info.CountBillableToolCall(dto.BuildInCallWebSearchCall, "")
}
assert.Equal(t, map[string]int{SearchToolPriceKey: 3}, toolCounts(info), "streamed items bill under the Search key")
info.ApplyVendorToolUsage([]byte(`{"type":"response.failed","response":{"status":"failed","output":[]}}`))
assert.Equal(t, map[string]int{SearchToolPriceKey: 3}, toolCounts(info), "an empty output keeps the streamed count")
info.ApplyVendorToolUsage([]byte(raw))
assert.Equal(t, map[string]int{SearchToolPriceKey: 1, FetchToolPriceKey: 1}, toolCounts(info), raw)
}
}
func toolCounts(info *relaycommon.RelayInfo) map[string]int {
counts := map[string]int{}
for name, tool := range info.ResponsesUsageInfo.BuiltInTools {
counts[name] = tool.CallCount
}
return counts
}
// Explicit @ modifiers reach DeepSeek as its own thinking switch and effort in
// every format and override the client's reasoning fields: no budget_tokens,
// adaptive thinking, max_tokens raise or sampling change. An @ effort wins
// over a -none/-low/-max tail, a budget maps to an effort, and a request
// without a modifier passes through unchanged. The usage log records the
// effort DeepSeek runs at.
func TestModelModifiersUpstreamFields(t *testing.T) {
type client struct{ chat, claude, responses string }
tests := []struct {
name, model string
client client
thinking, effort string // thinking "" = untouched
logEffort string
}{
{name: "thinking on", model: "deepseek-flash@thinking:on", thinking: "enabled", logEffort: "high"},
{name: "thinking off", model: "deepseek-flash@thinking:off", thinking: "disabled", logEffort: "none"},
{name: "thinking zero budget", model: "deepseek-flash@thinking:0", thinking: "disabled", logEffort: "none"},
{name: "effort none", model: "deepseek-flash@effort:none", thinking: "disabled", logEffort: "none"},
{name: "effort low", model: "deepseek-flash@effort:low", thinking: "enabled", effort: "low", logEffort: "low"},
{name: "effort medium", model: "deepseek-flash@effort:medium", thinking: "enabled", effort: "medium", logEffort: "high"},
{name: "effort xhigh", model: "deepseek-flash@effort:xhigh", thinking: "enabled", effort: "xhigh", logEffort: "high"},
{name: "effort max on pro", model: "deepseek-v4-pro@effort:max", thinking: "enabled", effort: "max", logEffort: "max"},
{name: "budget maps to an effort", model: "deepseek-flash@thinking:8192", thinking: "enabled", effort: "medium", logEffort: "high"},
{name: "adaptive", model: "deepseek-flash@thinking:adaptive", thinking: "enabled", logEffort: "high"},
{name: "temperature with effort", model: "deepseek-flash@temperature:0.3@effort:low", thinking: "enabled", effort: "low", logEffort: "low"},
{name: "effort overrides client thinking off", model: "deepseek-flash@effort:max", thinking: "enabled", effort: "max", logEffort: "max",
client: client{chat: `"thinking":{"type":"disabled"}`, claude: `"thinking":{"type":"disabled"}`, responses: `"reasoning":{"effort":"none"}`}},
{name: "thinking off overrides client thinking", model: "deepseek-flash@thinking:off", thinking: "disabled", logEffort: "none",
client: client{chat: `"thinking":{"type":"enabled"},"reasoning_effort":"high"`,
claude: `"thinking":{"type":"enabled","budget_tokens":2048},"output_config":{"effort":"high","format":{"type":"json_schema"}}`,
responses: `"reasoning":{"effort":"high"}`}},
{name: "budget overrides client effort none", model: "deepseek-flash@thinking:8192", thinking: "enabled", effort: "medium", logEffort: "high",
client: client{responses: `"reasoning":{"effort":"none"}`}},
{name: "@ effort wins over a tail", model: "deepseek-flash-max@effort:low", thinking: "enabled", effort: "low", logEffort: "low"},
{name: "no modifier", model: "deepseek-flash",
client: client{chat: `"thinking":{"type":"disabled"},"reasoning_effort":"low"`, claude: `"thinking":{"type":"adaptive"},"output_config":{"effort":"low"}`, responses: `"reasoning":{"effort":"low"}`}},
}
extra := func(members string) string {
if members == "" {
return ""
}
return "," + members
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
bodies := map[types.RelayFormat]string{
types.RelayFormatOpenAI: fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"hi"}]%s}`, tt.model, extra(tt.client.chat)),
types.RelayFormatClaude: fmt.Sprintf(`{"model":%q,"max_tokens":64,"temperature":0.7,"messages":[{"role":"user","content":"hi"}]%s}`, tt.model, extra(tt.client.claude)),
types.RelayFormatOpenAIResponses: fmt.Sprintf(`{"model":%q,"input":"hi"%s}`, tt.model, extra(tt.client.responses)),
}
for format, body := range bodies {
info := &relaycommon.RelayInfo{
OriginModelName: tt.model,
RelayFormat: format,
ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: tt.model},
}
var sent []byte
switch format {
case types.RelayFormatOpenAI:
original, outbound := &dto.GeneralOpenAIRequest{}, &dto.GeneralOpenAIRequest{}
require.NoError(t, common.UnmarshalJsonStr(body, original))
require.NoError(t, common.UnmarshalJsonStr(body, outbound))
info.Request = original
require.NoError(t, helper.ApplyReasoningModelSuffix(nil, info, outbound))
converted, err := (&Adaptor{}).ConvertOpenAIRequest(nil, info, outbound)
require.NoError(t, err)
sent, err = common.Marshal(converted)
require.NoError(t, err)
case types.RelayFormatClaude:
original, outbound := &dto.ClaudeRequest{}, &dto.ClaudeRequest{}
require.NoError(t, common.UnmarshalJsonStr(body, original))
require.NoError(t, common.UnmarshalJsonStr(body, outbound))
info.Request = original
require.NoError(t, helper.ApplyReasoningModelSuffix(nil, info, outbound))
converted, err := (&Adaptor{}).ConvertClaudeRequest(nil, info, outbound)
require.NoError(t, err)
sent, err = common.Marshal(converted)
require.NoError(t, err)
default:
original, outbound := &dto.OpenAIResponsesRequest{}, &dto.OpenAIResponsesRequest{}
require.NoError(t, common.UnmarshalJsonStr(body, original))
require.NoError(t, common.UnmarshalJsonStr(body, outbound))
info.Request = original
require.NoError(t, helper.ApplyReasoningModelSuffix(nil, info, outbound))
converted, err := (&Adaptor{}).ConvertOpenAIResponsesRequest(nil, info, *outbound)
require.NoError(t, err)
sent, err = common.Marshal(converted)
require.NoError(t, err)
}
wire := gjson.ParseBytes(sent)
wantModel := "deepseek-flash"
if strings.HasPrefix(tt.model, "deepseek-v4-pro") {
wantModel = "deepseek-v4-pro"
}
assert.Equal(t, wantModel, wire.Get("model").String(), format)
if tt.thinking == "" {
// No modifier: the client's reasoning fields go out as sent.
for _, member := range []string{"thinking", "reasoning_effort", "output_config", "reasoning"} {
assert.Equal(t, gjson.Get(body, member).Raw, wire.Get(member).Raw, "%s %s", format, member)
}
continue
}
switch format {
case types.RelayFormatOpenAI:
assert.JSONEq(t, `{"type":"`+tt.thinking+`"}`, wire.Get("thinking").Raw, format)
assert.Equal(t, tt.effort, wire.Get("reasoning_effort").String(), format)
assert.False(t, wire.Get("reasoning").Exists(), format)
case types.RelayFormatClaude:
assert.JSONEq(t, `{"type":"`+tt.thinking+`"}`, wire.Get("thinking").Raw, format)
assert.Equal(t, tt.effort, wire.Get("output_config.effort").String(), format)
assert.Equal(t, gjson.Get(body, "output_config.format").Raw, wire.Get("output_config.format").Raw, format)
assert.Equal(t, int64(64), wire.Get("max_tokens").Int(), format)
wantTemperature := 0.7
if strings.Contains(tt.model, "@temperature:0.3") {
wantTemperature = 0.3
}
assert.Equal(t, wantTemperature, wire.Get("temperature").Float(), format)
default:
wantEffort := tt.effort
if tt.thinking == "disabled" {
wantEffort = "none"
}
assert.Equal(t, wantEffort, wire.Get("reasoning.effort").String(), format)
}
assert.Equal(t, tt.logEffort, info.ReasoningEffort, format)
}
})
}
}
+13 -3
View File
@@ -1,9 +1,19 @@
package deepseek
import "github.com/QuantumNous/new-api/constant"
var ModelList = []string{
"deepseek-chat", "deepseek-reasoner",
"deepseek-v4-flash", "deepseek-v4-flash-none", "deepseek-v4-flash-max",
"deepseek-v4-pro", "deepseek-v4-pro-none", "deepseek-v4-pro-max",
"deepseek-flash", "deepseek-flash-none", "deepseek-flash-low", "deepseek-flash-max",
"deepseek-v4-pro", "deepseek-v4-pro-none", "deepseek-v4-pro-low", "deepseek-v4-pro-max",
constant.DeepSeekSearchModel, constant.DeepSeekFetchModel,
}
var ChannelName = "deepseek"
// Tool-price keys of the DeepSeek Search and Fetch APIs. They price the
// server-side searches and page fetches DeepSeek runs on its Anthropic and
// Responses endpoints.
const (
SearchToolPriceKey = "deepseek_search"
FetchToolPriceKey = "deepseek_fetch"
)
+37
View File
@@ -0,0 +1,37 @@
package deepseek
import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/tidwall/gjson"
)
// responsesToolUsage bills the Search and Fetch API calls DeepSeek runs for
// the Responses web_search tool from the web_search_call items of the whole
// response (output on non-stream bodies, response.output on terminal stream
// frames); its usage reports tokens only. An open_page action is a Fetch call,
// any other action a Search call, and a failed call is free. The counts
// replace the web_search_call items counted under the Search key while the
// stream was running. An empty output proves nothing, so those counts stay.
func responsesToolUsage(raw []byte) map[string]int {
output := gjson.GetBytes(raw, "output")
if !output.Exists() {
output = gjson.GetBytes(raw, "response.output")
}
items := output.Array()
if !output.IsArray() || len(items) == 0 {
return nil
}
counts := map[string]int{SearchToolPriceKey: 0, FetchToolPriceKey: 0}
for _, item := range items {
if item.Get("type").String() != dto.BuildInCallWebSearchCall || item.Get("status").String() != "completed" {
continue
}
if item.Get("action.type").String() == "open_page" {
counts[FetchToolPriceKey]++
} else {
counts[SearchToolPriceKey]++
}
}
return counts
}
+11 -11
View File
@@ -38,14 +38,14 @@ func uploadDifyFile(c *gin.Context, info *relaycommon.RelayInfo, user string, me
// Decode base64 string
decodedData, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
common.SysLog("failed to decode base64: " + err.Error())
common.SysLog(common.LogText("failed to decode base64: %s", err.Error()))
return nil
}
// Create temporary file
tempFile, err := os.CreateTemp("", "dify-upload-*")
if err != nil {
common.SysLog("failed to create temp file: " + err.Error())
common.SysLog(common.LogText("failed to create temp file: %s", err.Error()))
return nil
}
defer tempFile.Close()
@@ -53,7 +53,7 @@ func uploadDifyFile(c *gin.Context, info *relaycommon.RelayInfo, user string, me
// Write decoded data to temp file
if _, err := tempFile.Write(decodedData); err != nil {
common.SysLog("failed to write to temp file: " + err.Error())
common.SysLog(common.LogText("failed to write to temp file: %s", err.Error()))
return nil
}
@@ -63,7 +63,7 @@ func uploadDifyFile(c *gin.Context, info *relaycommon.RelayInfo, user string, me
// Add user field
if err := writer.WriteField("user", user); err != nil {
common.SysLog("failed to add user field: " + err.Error())
common.SysLog(common.LogText("failed to add user field: %s", err.Error()))
return nil
}
@@ -76,13 +76,13 @@ func uploadDifyFile(c *gin.Context, info *relaycommon.RelayInfo, user string, me
// Create form file
part, err := writer.CreateFormFile("file", fmt.Sprintf("image.%s", strings.TrimPrefix(mimeType, "image/")))
if err != nil {
common.SysLog("failed to create form file: " + err.Error())
common.SysLog(common.LogText("failed to create form file: %s", err.Error()))
return nil
}
// Copy file content to form
if _, err = io.Copy(part, bytes.NewReader(decodedData)); err != nil {
common.SysLog("failed to copy file content: " + err.Error())
common.SysLog(common.LogText("failed to copy file content: %s", err.Error()))
return nil
}
writer.Close()
@@ -90,7 +90,7 @@ func uploadDifyFile(c *gin.Context, info *relaycommon.RelayInfo, user string, me
// Create HTTP request
req, err := http.NewRequest("POST", uploadUrl, body)
if err != nil {
common.SysLog("failed to create request: " + err.Error())
common.SysLog(common.LogText("failed to create request: %s", err.Error()))
return nil
}
@@ -101,7 +101,7 @@ func uploadDifyFile(c *gin.Context, info *relaycommon.RelayInfo, user string, me
client := service.GetHttpClient()
resp, err := client.Do(req)
if err != nil {
common.SysLog("failed to send request: " + err.Error())
common.SysLog(common.LogText("failed to send request: %s", err.Error()))
return nil
}
defer resp.Body.Close()
@@ -111,7 +111,7 @@ func uploadDifyFile(c *gin.Context, info *relaycommon.RelayInfo, user string, me
Id string `json:"id"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
common.SysLog("failed to decode response: " + err.Error())
common.SysLog(common.LogText("failed to decode response: %s", err.Error()))
return nil
}
@@ -137,7 +137,7 @@ func requestOpenAI2Dify(c *gin.Context, info *relaycommon.RelayInfo, request dto
var stringUser string
err := json.Unmarshal(user, &stringUser)
if err != nil {
common.SysLog("failed to unmarshal user: " + err.Error())
common.SysLog(common.LogText("failed to unmarshal user: %s", err.Error()))
stringUser = helper.GetResponseID(c)
}
difyReq.User = stringUser
@@ -231,7 +231,7 @@ func difyStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
var difyResponse DifyChunkChatCompletionResponse
if err := json.Unmarshal([]byte(data), &difyResponse); err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
common.SysLog(common.LogText("error unmarshalling stream response: %s", err.Error()))
sr.Error(err)
return
}
+26 -9
View File
@@ -103,7 +103,7 @@ func TestConvertOpenAIResponsesRequestToGeminiFunctionCallConversation(t *testin
assert.Equal(t, map[string]any{"ok": true}, got.Contents[1].Parts[0].FunctionResponse.Response)
}
func TestConvertOpenAIResponsesRequestToGeminiSkipsCustomToolCalls(t *testing.T) {
func TestConvertOpenAIResponsesRequestToGeminiKeepsCustomToolCalls(t *testing.T) {
got := mustConvertResponsesToGemini(t, dto.OpenAIResponsesRequest{
Model: "gemini-test",
Input: mustGeminiRawMessage(t, []map[string]any{
@@ -140,17 +140,34 @@ func TestConvertOpenAIResponsesRequestToGeminiSkipsCustomToolCalls(t *testing.T)
}),
})
assert.Empty(t, got.GetTools())
require.Len(t, got.Contents, 2)
// The cleaned schema has no additionalProperties, and the unknown tool is dropped.
assert.JSONEq(t, `[{"functionDeclarations":[{
"name":"apply_patch",
"description":"This tool takes freeform text. Put the complete raw text in the \"input\" argument.",
"parameters":{"type":"OBJECT","properties":{"input":{"type":"STRING","description":"Raw input for the tool."}},"required":["input"]}
}]}]`, string(got.Tools))
require.Len(t, got.Contents, 3)
assert.Equal(t, "model", got.Contents[0].Role)
require.Len(t, got.Contents[0].Parts, 1)
assert.Equal(t, "before custom", got.Contents[0].Parts[0].Text)
assert.Nil(t, got.Contents[0].Parts[0].FunctionCall)
require.Len(t, got.Contents[0].Parts, 2)
require.NotNil(t, got.Contents[0].Parts[0].FunctionCall)
assert.Equal(t, "apply_patch", got.Contents[0].Parts[0].FunctionCall.FunctionName)
assert.Equal(t, map[string]any{"input": "patch body"}, got.Contents[0].Parts[0].FunctionCall.Arguments)
assert.Equal(t, "before custom", got.Contents[0].Parts[1].Text)
assert.Equal(t, "user", got.Contents[1].Role)
require.Len(t, got.Contents[1].Parts, 1)
assert.Equal(t, "next turn", got.Contents[1].Parts[0].Text)
assert.Nil(t, got.Contents[1].Parts[0].FunctionResponse)
require.Len(t, got.Contents[1].Parts, 2)
for i, output := range []string{"ok", "legacy custom output"} {
response := got.Contents[1].Parts[i].FunctionResponse
require.NotNil(t, response)
assert.Equal(t, "apply_patch", response.Name)
assert.Equal(t, map[string]any{"content": output}, response.Response)
assert.JSONEq(t, `"call_custom"`, string(response.ID))
}
assert.Equal(t, "user", got.Contents[2].Role)
require.Len(t, got.Contents[2].Parts, 1)
assert.Equal(t, "next turn", got.Contents[2].Parts[0].Text)
}
func mustConvertResponsesToGemini(t *testing.T, req dto.OpenAIResponsesRequest) *dto.GeminiChatRequest {
+4 -1
View File
@@ -36,6 +36,9 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
info.ObserveResponseModel(gjson.GetBytes(responseBody, "modelVersion").Str)
var grounding geminiGroundingCounter
grounding.observe(&geminiResponse)
grounding.commit(info)
countGeminiBillableFunctionCalls(info, &geminiResponse)
if len(geminiResponse.Candidates) == 0 && geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
@@ -88,7 +91,7 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayIn
return geminiStreamHandler(c, info, resp, func(data string, geminiResponse *dto.GeminiChatResponse) bool {
err := helper.StringData(c, data)
if err != nil {
logger.LogError(c, "failed to write stream data: "+err.Error())
logger.LogError(c, common.LogText("failed to write stream data: %s", err.Error()))
return false
}
info.SendResponseCount++
+63 -24
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"slices"
"strings"
"time"
@@ -75,23 +76,58 @@ func geminiResponseUsageText(response *dto.GeminiChatResponse) string {
if part.Text != "" {
text.WriteString(part.Text)
}
// Function calls are most of an agent turn's output.
if part.FunctionCall != nil {
text.WriteString(part.FunctionCall.FunctionName)
args, _ := common.Marshal(part.FunctionCall.Arguments)
text.Write(args)
}
}
}
return text.String()
}
func markGeminiGoogleSearchCall(c *gin.Context, response *dto.GeminiChatResponse) {
if c == nil || response == nil {
return
}
// geminiGroundingCounter accumulates Google Search grounding across one
// upstream response (every stream frame and candidate). Gemini 3 bills each
// unique non-empty search query ("we ignore the empty web search queries when
// counting unique queries", ai.google.dev/gemini-api/docs/google-search);
// Gemini 2.5 and older bill once per grounded prompt, counted here when the
// response carries any search query. Both quantities are recorded and the
// tool price table decides which one costs money for a model. Image-search
// queries count like web-search queries; Google documents no separate rule.
// Google documents no response field holding the billed count, so the unique
// query set is the best available evidence, not a guaranteed match.
type geminiGroundingCounter struct {
queries map[string]struct{}
}
func (g *geminiGroundingCounter) observe(response *dto.GeminiChatResponse) {
for _, candidate := range response.Candidates {
if candidate.GroundingMetadata != nil && len(candidate.GroundingMetadata.WebSearchQueries) > 0 {
c.Set("gemini_google_search_call", true)
return
metadata := candidate.GroundingMetadata
if metadata == nil {
continue
}
for _, query := range slices.Concat(metadata.WebSearchQueries, metadata.ImageSearchQueries) {
query = strings.TrimSpace(query)
if query == "" {
continue
}
if g.queries == nil {
g.queries = make(map[string]struct{})
}
g.queries[query] = struct{}{}
}
}
}
func (g *geminiGroundingCounter) commit(info *relaycommon.RelayInfo) {
if len(g.queries) == 0 {
return
}
info.SetBillableToolCount(dto.BuildInToolGoogleSearch, len(g.queries))
info.SetBillableToolCount(relaycommon.GoogleSearchGroundedPromptTool, 1)
}
func countGeminiBillableFunctionCalls(info *relaycommon.RelayInfo, response *dto.GeminiChatResponse) {
if info == nil || response == nil {
return
@@ -171,6 +207,7 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
var hasBillableUsageMetadata bool
var streamErr error
var accumulatedUsageMetadata *dto.GeminiUsageMetadata
var grounding geminiGroundingCounter
responseText := strings.Builder{}
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
@@ -197,7 +234,9 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
info.StreamStatus.MarkCompleted()
}
markGeminiGoogleSearchCall(c, &geminiResponse)
// Commit on every frame so a client abort keeps the queries seen so far.
grounding.observe(&geminiResponse)
grounding.commit(info)
countGeminiBillableFunctionCalls(info, &geminiResponse)
// 统计图片数量
@@ -206,11 +245,9 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
if part.InlineData != nil && part.InlineData.MimeType != "" {
imageCount++
}
if part.Text != "" {
responseText.WriteString(part.Text)
}
}
}
responseText.WriteString(geminiResponseUsageText(&geminiResponse))
// 更新使用量统计
if metadata := geminiResponse.GetUsageMetadata(); dto.HasGeminiUsageMetadataTokens(metadata) {
@@ -251,7 +288,7 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
return usage, types.NewOpenAIError(streamErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if info.StreamStatus != nil && !info.StreamStatus.IsNormalEnd() {
logger.LogWarn(c, fmt.Sprintf("Gemini stream ended unexpectedly: %s", info.StreamStatus.Summary()))
logger.LogWarn(c, common.LogText("Gemini stream ended unexpectedly: %s", info.StreamStatus.Summary()))
}
return usage, nil
@@ -367,7 +404,7 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
}
handleErr := handleFinalStream(c, info, response)
if handleErr != nil {
common.SysLog("send final response failed: " + handleErr.Error())
common.SysLog(common.LogText("send final response failed: %s", handleErr.Error()))
}
return usage, nil
}
@@ -385,7 +422,9 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
info.ObserveResponseModel(gjson.GetBytes(responseBody, "modelVersion").Str)
markGeminiGoogleSearchCall(c, &geminiResponse)
var grounding geminiGroundingCounter
grounding.observe(&geminiResponse)
grounding.commit(info)
countGeminiBillableFunctionCalls(info, &geminiResponse)
if len(geminiResponse.Candidates) == 0 {
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
@@ -414,11 +453,11 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
case types.RelayFormatClaude:
c.JSON(newAPIError.StatusCode, gin.H{
"type": "error",
"error": newAPIError.ToClaudeError(),
"error": newAPIError.ToClaudeError(common.UpstreamAddresses(c)...),
})
default:
c.JSON(newAPIError.StatusCode, gin.H{
"error": newAPIError.ToOpenAIError(),
"error": newAPIError.ToOpenAIError(common.UpstreamAddresses(c)...),
})
}
return &usage, nil
@@ -558,10 +597,10 @@ type GeminiModelsResponse struct {
NextPageToken string `json:"nextPageToken"`
}
func FetchGeminiModels(baseURL, apiKey, proxyURL string) ([]string, error) {
client, err := service.GetHttpClientWithProxy(proxyURL)
func FetchGeminiModels(baseURL, apiKey string, setting dto.ChannelSettings) ([]string, error) {
client, err := service.GetHttpClientWithProxySettings(setting.Proxy, setting)
if err != nil {
return nil, fmt.Errorf("创建HTTP客户端失败: %v", err)
return nil, fmt.Errorf("failed to create HTTP client: %v", err)
}
allModels := make([]string, 0)
@@ -578,7 +617,7 @@ func FetchGeminiModels(baseURL, apiKey, proxyURL string) ([]string, error) {
request, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
cancel()
return nil, fmt.Errorf("创建请求失败: %v", err)
return nil, fmt.Errorf("failed to create request: %v", err)
}
request.Header.Set("x-goog-api-key", apiKey)
@@ -586,26 +625,26 @@ func FetchGeminiModels(baseURL, apiKey, proxyURL string) ([]string, error) {
response, err := client.Do(request)
if err != nil {
cancel()
return nil, fmt.Errorf("请求失败: %v", err)
return nil, fmt.Errorf("request failed: %v", err)
}
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(response.Body)
response.Body.Close()
cancel()
return nil, fmt.Errorf("服务器返回错误 %d: %s", response.StatusCode, string(body))
return nil, fmt.Errorf("server returned error %d: %s", response.StatusCode, string(body))
}
body, err := io.ReadAll(response.Body)
response.Body.Close()
cancel()
if err != nil {
return nil, fmt.Errorf("读取响应失败: %v", err)
return nil, fmt.Errorf("failed to read response: %v", err)
}
var modelsResponse GeminiModelsResponse
if err = common.Unmarshal(body, &modelsResponse); err != nil {
return nil, fmt.Errorf("解析响应失败: %v", err)
return nil, fmt.Errorf("failed to parse response: %v", err)
}
for _, model := range modelsResponse.Models {
+160 -45
View File
@@ -13,7 +13,9 @@ import (
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/tokenkit"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -494,8 +496,6 @@ func TestGeminiChatHandlerMissingUsageMetadataBuildsEstimatedBillingUsage(t *tes
func TestGeminiStreamHandlerPromptOnlyUsageMetadataEstimatesCompletionTokens(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
oldStreamingTimeout := constant.StreamingTimeout
constant.StreamingTimeout = 300
@@ -503,53 +503,67 @@ func TestGeminiStreamHandlerPromptOnlyUsageMetadataEstimatesCompletionTokens(t *
constant.StreamingTimeout = oldStreamingTimeout
})
info := &relaycommon.RelayInfo{
OriginModelName: "gemini-3-flash-preview",
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "gemini-3-flash-preview",
},
}
info.SetEstimatePromptTokens(20)
for _, tc := range []struct {
name string
part dto.GeminiPart
usageText string
}{
{"text", dto.GeminiPart{Text: "partial streamed answer before disconnect"}, "partial streamed answer before disconnect"},
// An agent turn that only calls a tool still produced output tokens.
{"function call", dto.GeminiPart{FunctionCall: &dto.FunctionCall{FunctionName: "read_file", Arguments: map[string]any{"path": "main.go"}}}, `read_file{"path":"main.go"}`},
} {
t.Run(tc.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
// Simulates a client aborting the stream before the final chunk: text was
// streamed but the last observed usageMetadata only carries prompt tokens.
chunk := dto.GeminiChatResponse{
Candidates: []dto.GeminiChatCandidate{
{
Content: dto.GeminiChatContent{
Role: "model",
Parts: []dto.GeminiPart{
{Text: "partial streamed answer before disconnect"},
info := &relaycommon.RelayInfo{
OriginModelName: "gemini-3-flash-preview",
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "gemini-3-flash-preview",
},
}
info.SetEstimatePromptTokens(20)
// Simulates a client aborting the stream before the final chunk: output was
// streamed but the last observed usageMetadata only carries prompt tokens.
chunk := dto.GeminiChatResponse{
Candidates: []dto.GeminiChatCandidate{
{
Content: dto.GeminiChatContent{
Role: "model",
Parts: []dto.GeminiPart{tc.part},
},
},
},
},
},
UsageMetadata: dto.GeminiUsageMetadata{
PromptTokenCount: 151,
TotalTokenCount: 151,
},
UsageMetadata: dto.GeminiUsageMetadata{
PromptTokenCount: 151,
TotalTokenCount: 151,
},
}
chunkData, err := common.Marshal(chunk)
require.NoError(t, err)
streamBody := []byte("data: " + string(chunkData) + "\n" + "data: [DONE]\n")
resp := &http.Response{
Body: io.NopCloser(bytes.NewReader(streamBody)),
}
usage, newAPIError := geminiStreamHandler(c, info, resp, func(_ string, _ *dto.GeminiChatResponse) bool {
return true
})
require.Nil(t, newAPIError)
require.NotNil(t, usage)
require.Equal(t, 151, usage.PromptTokens)
require.Equal(t, tokenkit.Estimate("gemini-3-flash-preview", tc.usageText), usage.CompletionTokens)
require.Greater(t, usage.CompletionTokens, 0)
require.Equal(t, usage.PromptTokens+usage.CompletionTokens, usage.TotalTokens)
require.NotNil(t, usage.BillingUsage)
require.True(t, usage.BillingUsage.Estimated)
require.NotNil(t, usage.BillingUsage.GeminiUsageMetadata)
require.Equal(t, usage.CompletionTokens, usage.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
})
}
chunkData, err := common.Marshal(chunk)
require.NoError(t, err)
streamBody := []byte("data: " + string(chunkData) + "\n" + "data: [DONE]\n")
resp := &http.Response{
Body: io.NopCloser(bytes.NewReader(streamBody)),
}
usage, newAPIError := geminiStreamHandler(c, info, resp, func(_ string, _ *dto.GeminiChatResponse) bool {
return true
})
require.Nil(t, newAPIError)
require.NotNil(t, usage)
require.Equal(t, 151, usage.PromptTokens)
require.Greater(t, usage.CompletionTokens, 0)
require.Equal(t, usage.PromptTokens+usage.CompletionTokens, usage.TotalTokens)
require.NotNil(t, usage.BillingUsage)
require.True(t, usage.BillingUsage.Estimated)
require.NotNil(t, usage.BillingUsage.GeminiUsageMetadata)
require.Equal(t, usage.CompletionTokens, usage.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
}
func TestGeminiChatHandlerPromptOnlyUsageMetadataEstimatesCompletionTokens(t *testing.T) {
@@ -639,3 +653,104 @@ func TestGeminiStreamHandlerEmptyUsageMetadataBuildsEstimatedBillingUsage(t *tes
require.Equal(t, usage.CompletionTokens, usage.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
require.True(t, common.GetContextKeyBool(c, constant.ContextKeyLocalCountTokens))
}
// Google Search grounding records the unique trimmed non-empty queries
// (webSearchQueries and imageSearchQueries of every candidate, across every
// stream frame) under google_search and one grounded prompt when any query
// exists; the tool price table decides which of the two a model pays for.
func TestGeminiHandlersCountGroundingQueries(t *testing.T) {
gin.SetMode(gin.TestMode)
oldStreamingTimeout := constant.StreamingTimeout
constant.StreamingTimeout = 300
t.Cleanup(func() {
constant.StreamingTimeout = oldStreamingTimeout
})
const text = `"content":{"role":"model","parts":[{"text":"ok"}]}`
grounded := func(queries int) map[string]int {
return map[string]int{dto.BuildInToolGoogleSearch: queries, relaycommon.GoogleSearchGroundedPromptTool: 1}
}
tests := []struct {
name string
stream bool
frames []string
want map[string]int
}{
{
name: "native non-stream",
frames: []string{`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":["Shanghai weather today","current weather in Shanghai"],"groundingChunks":[{"web":{"uri":"https://a.example"}}]}}]}`},
want: grounded(2),
},
{
name: "duplicates, whitespace and empty queries",
frames: []string{`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":["go release",""," go release "," ","tokyo"]}}]}`},
want: grounded(2),
},
{
name: "union across candidates",
frames: []string{`{"candidates":[{"index":0,` + text + `,"groundingMetadata":{"webSearchQueries":["q1","q2"]}},{"index":1,` + text + `,"groundingMetadata":{"webSearchQueries":["q2","q3"]}}]}`},
want: grounded(3),
},
{
name: "image queries count like web queries",
frames: []string{`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":["cat breeds","orange cat"],"imageSearchQueries":["orange tabby cat photo","orange cat"]}}]}`},
want: grounded(3),
},
{
name: "only empty queries",
frames: []string{`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":[""," "],"imageSearchQueries":[""]}}]}`},
want: map[string]int{},
},
{
name: "no grounding metadata",
frames: []string{`{"candidates":[{` + text + `,"finishReason":"STOP"}]}`},
want: map[string]int{},
},
{
name: "stream union across frames",
stream: true,
frames: []string{
`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":["q1"]}}]}`,
`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":["q2","q1 "]}}]}`,
`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":["q3"]}}]}`,
},
want: grounded(3),
},
{
name: "stream repeats are not added up",
stream: true,
frames: []string{
`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":["q1","q2"]}}]}`,
`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":["q1","q2"]}}]}`,
},
want: grounded(2),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-3.7-flash:generateContent", nil)
info := &relaycommon.RelayInfo{
OriginModelName: "gemini-3.7-flash",
ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gemini-3.7-flash"},
}
var apiErr *types.NewAPIError
if tt.stream {
body := "data: " + strings.Join(tt.frames, "\ndata: ") + "\ndata: [DONE]\n"
_, apiErr = geminiStreamHandler(c, info, &http.Response{Body: io.NopCloser(strings.NewReader(body))}, func(string, *dto.GeminiChatResponse) bool {
return true
})
} else {
_, apiErr = GeminiTextGenerationHandler(c, info, &http.Response{Body: io.NopCloser(strings.NewReader(tt.frames[0]))})
}
require.Nil(t, apiErr)
counts := map[string]int{}
if info.ResponsesUsageInfo != nil {
for name, tool := range info.ResponsesUsageInfo.BuiltInTools {
counts[name] = tool.CallCount
}
}
assert.Equal(t, tt.want, counts)
})
}
}
+3 -1
View File
@@ -33,7 +33,9 @@ func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
info.ObserveResponseModel(gjson.GetBytes(responseBody, "modelVersion").Str)
markGeminiGoogleSearchCall(c, &geminiResponse)
var grounding geminiGroundingCounter
grounding.observe(&geminiResponse)
grounding.commit(info)
countGeminiBillableFunctionCalls(info, &geminiResponse)
if len(geminiResponse.Candidates) == 0 {
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
+2 -1
View File
@@ -15,6 +15,7 @@ import (
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/gin-gonic/gin"
)
@@ -45,7 +46,7 @@ func SetPayloadHash(c *gin.Context, req any) error {
if err != nil {
return err
}
logger.LogInfo(c, fmt.Sprintf("SetPayloadHash body: %s", body))
logger.LogInfo(c, common.LogText("SetPayloadHash body: %s", body))
payloadHash := sha256.Sum256(body)
hexPayloadHash := hex.EncodeToString(payloadHash[:])
c.Set(HexPayloadHashKey, hexPayloadHash)
+6 -2
View File
@@ -69,6 +69,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil
} else if info.RelayMode == constant.RelayModeCompletions {
return fmt.Sprintf("%s/v1/completions", info.ChannelBaseUrl), nil
} else if info.RelayMode == constant.RelayModeResponses {
return fmt.Sprintf("%s/v1/responses", info.ChannelBaseUrl), nil
}
return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil
}
@@ -98,9 +100,11 @@ func isTemperatureOneOnlyModel(model string) bool {
return strings.EqualFold(model, "kimi-k2.6")
}
// ConvertOpenAIResponsesRequest forwards Responses requests natively. Kimi's
// /v1/responses currently serves kimi-k3 only; other models are rejected by
// the upstream, not here.
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
// TODO implement me
return nil, errors.New("not implemented")
return request, nil
}
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
+25
View File
@@ -5,7 +5,10 @@ import (
"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -66,3 +69,25 @@ func TestConvertOpenAIRequestOtherMoonshotModelKeepsTemperature(t *testing.T) {
require.NotNil(t, convertedRequest.Temperature)
require.Equal(t, 0.7, *convertedRequest.Temperature)
}
// Kimi serves OpenAI Responses natively (kimi-k3); the body is forwarded
// unchanged and other models are rejected by the upstream.
func TestResponsesRequestPassesThroughToV1Responses(t *testing.T) {
const body = `{"model":"kimi-k3","input":"Latest Go release?","tools":[{"type":"web_search"}],"stream":false}`
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeResponses,
RelayFormat: types.RelayFormatOpenAIResponses,
ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://api.moonshot.invalid", UpstreamModelName: "kimi-k3"},
}
url, err := (&Adaptor{}).GetRequestURL(info)
require.NoError(t, err)
assert.Equal(t, "https://api.moonshot.invalid/v1/responses", url)
var request dto.OpenAIResponsesRequest
require.NoError(t, common.UnmarshalJsonStr(body, &request))
converted, err := (&Adaptor{}).ConvertOpenAIResponsesRequest(nil, info, request)
require.NoError(t, err)
wire, err := common.Marshal(converted)
require.NoError(t, err)
assert.JSONEq(t, body, string(wire))
}
+27 -27
View File
@@ -337,7 +337,7 @@ func FetchOllamaModels(baseURL, apiKey string) ([]OllamaModel, error) {
client := &http.Client{}
request, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %v", err)
return nil, fmt.Errorf("failed to create request: %v", err)
}
// Ollama 通常不需要 Bearer token,但为了兼容性保留
@@ -347,24 +347,24 @@ func FetchOllamaModels(baseURL, apiKey string) ([]OllamaModel, error) {
response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("请求失败: %v", err)
return nil, fmt.Errorf("request failed: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(response.Body)
return nil, fmt.Errorf("服务器返回错误 %d: %s", response.StatusCode, string(body))
return nil, fmt.Errorf("server returned error %d: %s", response.StatusCode, string(body))
}
var tagsResponse OllamaTagsResponse
body, err := io.ReadAll(response.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %v", err)
return nil, fmt.Errorf("failed to read response: %v", err)
}
err = common.Unmarshal(body, &tagsResponse)
if err != nil {
return nil, fmt.Errorf("解析响应失败: %v", err)
return nil, fmt.Errorf("failed to parse response: %v", err)
}
return tagsResponse.Models, nil
@@ -381,7 +381,7 @@ func PullOllamaModel(baseURL, apiKey, modelName string) error {
requestBody, err := common.Marshal(pullRequest)
if err != nil {
return fmt.Errorf("序列化请求失败: %v", err)
return fmt.Errorf("failed to marshal request: %v", err)
}
client := &http.Client{
@@ -389,7 +389,7 @@ func PullOllamaModel(baseURL, apiKey, modelName string) error {
}
request, err := http.NewRequest("POST", url, strings.NewReader(string(requestBody)))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
return fmt.Errorf("failed to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
@@ -399,13 +399,13 @@ func PullOllamaModel(baseURL, apiKey, modelName string) error {
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("请求失败: %v", err)
return fmt.Errorf("request failed: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("拉取模型失败 %d: %s", response.StatusCode, string(body))
return fmt.Errorf("failed to pull model, status %d: %s", response.StatusCode, string(body))
}
return nil
@@ -422,7 +422,7 @@ func PullOllamaModelStream(baseURL, apiKey, modelName string, progressCallback f
requestBody, err := common.Marshal(pullRequest)
if err != nil {
return fmt.Errorf("序列化请求失败: %v", err)
return fmt.Errorf("failed to marshal request: %v", err)
}
client := &http.Client{
@@ -430,7 +430,7 @@ func PullOllamaModelStream(baseURL, apiKey, modelName string, progressCallback f
}
request, err := http.NewRequest("POST", url, strings.NewReader(string(requestBody)))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
return fmt.Errorf("failed to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
@@ -440,13 +440,13 @@ func PullOllamaModelStream(baseURL, apiKey, modelName string, progressCallback f
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("请求失败: %v", err)
return fmt.Errorf("request failed: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("拉取模型失败 %d: %s", response.StatusCode, string(body))
return fmt.Errorf("failed to pull model, status %d: %s", response.StatusCode, string(body))
}
// 读取流式响应
@@ -469,7 +469,7 @@ func PullOllamaModelStream(baseURL, apiKey, modelName string, progressCallback f
// 检查是否出现错误或完成
if strings.EqualFold(pullResponse.Status, "error") {
return fmt.Errorf("拉取模型失败: %s", strings.TrimSpace(line))
return fmt.Errorf("failed to pull model: %s", strings.TrimSpace(line))
}
if strings.EqualFold(pullResponse.Status, "success") {
successful = true
@@ -478,11 +478,11 @@ func PullOllamaModelStream(baseURL, apiKey, modelName string, progressCallback f
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("读取流式响应失败: %v", err)
return fmt.Errorf("failed to read stream response: %v", err)
}
if !successful {
return fmt.Errorf("拉取模型未完成: 未收到成功状态")
return fmt.Errorf("model pull did not complete: no success status received")
}
return nil
@@ -498,13 +498,13 @@ func DeleteOllamaModel(baseURL, apiKey, modelName string) error {
requestBody, err := common.Marshal(deleteRequest)
if err != nil {
return fmt.Errorf("序列化请求失败: %v", err)
return fmt.Errorf("failed to marshal request: %v", err)
}
client := &http.Client{}
request, err := http.NewRequest("DELETE", url, strings.NewReader(string(requestBody)))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
return fmt.Errorf("failed to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
@@ -514,13 +514,13 @@ func DeleteOllamaModel(baseURL, apiKey, modelName string) error {
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("请求失败: %v", err)
return fmt.Errorf("request failed: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("删除模型失败 %d: %s", response.StatusCode, string(body))
return fmt.Errorf("failed to delete model, status %d: %s", response.StatusCode, string(body))
}
return nil
@@ -529,7 +529,7 @@ func DeleteOllamaModel(baseURL, apiKey, modelName string) error {
func FetchOllamaVersion(baseURL, apiKey string) (string, error) {
trimmedBase := strings.TrimRight(baseURL, "/")
if trimmedBase == "" {
return "", fmt.Errorf("baseURL 为空")
return "", fmt.Errorf("baseURL is empty")
}
url := fmt.Sprintf("%s/api/version", trimmedBase)
@@ -537,7 +537,7 @@ func FetchOllamaVersion(baseURL, apiKey string) (string, error) {
client := &http.Client{Timeout: 10 * time.Second}
request, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", fmt.Errorf("创建请求失败: %v", err)
return "", fmt.Errorf("failed to create request: %v", err)
}
if apiKey != "" {
@@ -546,17 +546,17 @@ func FetchOllamaVersion(baseURL, apiKey string) (string, error) {
response, err := client.Do(request)
if err != nil {
return "", fmt.Errorf("请求失败: %v", err)
return "", fmt.Errorf("request failed: %v", err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
return "", fmt.Errorf("读取响应失败: %v", err)
return "", fmt.Errorf("failed to read response: %v", err)
}
if response.StatusCode != http.StatusOK {
return "", fmt.Errorf("查询版本失败 %d: %s", response.StatusCode, string(body))
return "", fmt.Errorf("failed to query version, status %d: %s", response.StatusCode, string(body))
}
var versionResp struct {
@@ -564,11 +564,11 @@ func FetchOllamaVersion(baseURL, apiKey string) (string, error) {
}
if err := common.Unmarshal(body, &versionResp); err != nil {
return "", fmt.Errorf("解析响应失败: %v", err)
return "", fmt.Errorf("failed to parse response: %v", err)
}
if versionResp.Version == "" {
return "", fmt.Errorf("未返回版本信息")
return "", fmt.Errorf("no version information returned")
}
return versionResp.Version, nil
+2 -2
View File
@@ -166,7 +166,7 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
}
var chunk ollamaChatStreamChunk
if err := common.Unmarshal([]byte(line), &chunk); err != nil {
logger.LogError(c, "ollama stream json decode error: "+err.Error()+" line="+line)
logger.LogError(c, common.LogText("ollama stream json decode error: %s line=%s", err.Error(), line))
return usage, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if chunk.Model != "" {
@@ -215,7 +215,7 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
break
}
if err := scanner.Err(); err != nil && err != io.EOF {
logger.LogError(c, "ollama stream scan error: "+err.Error())
logger.LogError(c, common.LogText("ollama stream scan error: %s", err.Error()))
}
return usage, nil
}
+3
View File
@@ -97,6 +97,9 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
a.ChannelType = info.ChannelType
if info.ChannelType == constant.ChannelTypeAzure && info.RelayMode == relayconstant.RelayModeResponses {
info.VendorToolUsage = azureResponsesToolUsage
}
// initialize ThinkingContentInfo when thinking_to_content is enabled
if info.ChannelSetting.ThinkingToContent {
+3 -4
View File
@@ -2,7 +2,6 @@ package openai
import (
"bytes"
"fmt"
"io"
"math"
"net/http"
@@ -59,7 +58,7 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
// 读取响应体到缓冲区
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
logger.LogError(c, fmt.Sprintf("failed to read TTS response body: %v", err))
logger.LogError(c, common.LogText("failed to read TTS response body: %v", err))
c.Writer.WriteHeaderNow()
return usage
}
@@ -68,7 +67,7 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
c.Writer.WriteHeaderNow()
_, err = c.Writer.Write(bodyBytes)
if err != nil {
logger.LogError(c, fmt.Sprintf("failed to write TTS response: %v", err))
logger.LogError(c, common.LogText("failed to write TTS response: %v", err))
}
// 计算音频时长并更新 usage
@@ -96,7 +95,7 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
usage.PromptTokensDetails.TextTokens = usage.PromptTokens
if durationErr != nil {
logger.LogWarn(c, fmt.Sprintf("failed to get audio duration: %v", durationErr))
logger.LogWarn(c, common.LogText("failed to get audio duration: %v", durationErr))
// 如果无法获取时长,则设置保底的 CompletionTokens,根据body大小计算
sizeInKB := float64(len(bodyBytes)) / 1000.0
estimatedTokens := int(math.Ceil(sizeInKB)) // 粗略估算每KB约等于1 token
+78 -18
View File
@@ -85,7 +85,7 @@ func OaiResponsesToChatBufferedStreamHandler(c *gin.Context, info *relaycommon.R
var streamResp dto.ResponsesStreamResponse
if err := common.UnmarshalJsonStr(data, &streamResp); err != nil {
logger.LogError(c, "failed to unmarshal buffered responses stream event: "+err.Error())
logger.LogError(c, common.LogText("failed to unmarshal buffered responses stream event: %s", err.Error()))
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
break
}
@@ -187,11 +187,27 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
streamErr := (*types.NewAPIError)(nil)
// upstreamFailed records a response.failed / response.error event and
// failedUsage the usage it reported, if any.
upstreamFailed := false
var failedUsage *dto.Usage
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo == nil {
info.ClaudeConvertInfo = &relaycommon.ClaudeConvertInfo{LastMessagesType: relaycommon.LastMessageTypeNone}
}
// writeFailed stops the stream after a failed client write. When the client
// went away the upstream already produced output, so the request settles
// normally instead of failing; any other write error stays a server error.
writeFailed := func(err error) bool {
if c.Request.Context().Err() != nil {
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, err)
return false
}
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
return false
}
sendGeminiResponse := func(geminiResponse *dto.GeminiChatResponse) bool {
if geminiResponse == nil {
return true
@@ -213,8 +229,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return true
}
if err := helper.ObjectData(c, &value); err != nil {
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
return false
return writeFailed(err)
}
return true
case *dto.ChatCompletionsStreamResponse:
@@ -222,14 +237,12 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return true
}
if err := helper.ObjectData(c, value); err != nil {
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
return false
return writeFailed(err)
}
return true
case dto.ClaudeResponse:
if err := helper.ClaudeData(c, value); err != nil {
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
return false
return writeFailed(err)
}
return true
case *dto.ClaudeResponse:
@@ -237,8 +250,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return true
}
if err := helper.ClaudeData(c, *value); err != nil {
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
return false
return writeFailed(err)
}
return true
case dto.GeminiChatResponse:
@@ -253,13 +265,13 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
if streamErr != nil {
sr.Stop(streamErr)
stopStream(sr, streamErr)
return
}
var streamResp dto.ResponsesStreamResponse
if err := common.UnmarshalJsonStr(data, &streamResp); err != nil {
logger.LogError(c, "failed to unmarshal responses stream event: "+err.Error())
logger.LogError(c, common.LogText("failed to unmarshal responses stream event: %s", err.Error()))
sr.Error(err)
return
}
@@ -268,34 +280,72 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
info.ObserveResponseModel(streamResp.Response.Model)
}
if streamResp.Type == "response.error" || streamResp.Type == "response.failed" {
upstreamFailed = true
if streamResp.Response != nil {
failedUsage = streamResp.Response.Usage
if oaiErr := streamResp.Response.GetOpenAIError(); oaiErr != nil && oaiErr.Type != "" {
streamErr = types.WithOpenAIError(*oaiErr, http.StatusInternalServerError)
sr.Stop(streamErr)
stopStream(sr, streamErr)
return
}
}
streamErr = types.NewOpenAIError(fmt.Errorf("responses stream error: %s", streamResp.Type), types.ErrorCodeBadResponse, http.StatusInternalServerError)
sr.Stop(streamErr)
stopStream(sr, streamErr)
return
}
results, err := service.ConvertStreamResponseChunk(c, info, state, &streamResp)
if err != nil {
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
sr.Stop(streamErr)
stopStream(sr, streamErr)
return
}
for _, result := range results {
if !sendStreamResult(result) {
sr.Stop(streamErr)
stopStream(sr, streamErr)
return
}
}
})
if streamErr != nil {
return nil, streamErr
if !upstreamFailed || state.UsageText() == "" {
return nil, streamErr
}
// The upstream failed after output reached the client: bill the
// delivered output (upstream usage when reported, otherwise the
// estimate) and send the client the same error the failed request
// path writes. stream_status already records the failure.
usage := relayconvert.UsageFromResponsesUsage(failedUsage)
if usage.TotalTokens == 0 {
usage = state.Usage()
}
if usage == nil || usage.TotalTokens == 0 {
usage = service.ResponseText2Usage(c, state.UsageText(), info.UpstreamModelName, info.GetEstimatePromptTokens())
} else if usage.CompletionTokens == 0 || usage.PromptTokens == 0 {
// As in the native Responses accumulator, reported usage that
// omits the output or the prompt is completed from the estimate of
// the delivered output and the prompt.
estimate := service.ResponseText2Usage(c, state.UsageText(), info.UpstreamModelName, info.GetEstimatePromptTokens())
if usage.CompletionTokens == 0 {
usage.CompletionTokens = estimate.CompletionTokens
}
if usage.PromptTokens == 0 {
usage.PromptTokens = estimate.PromptTokens
}
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
if usage.BillingUsage != nil {
usage.BillingUsage = dto.CloneBillingUsageWithEstimatedCompletion(usage.BillingUsage, usage.CompletionTokens)
}
}
logger.LogError(c, common.LogText("responses stream failed after output was delivered, billing the delivered output: %s", streamErr.Error()))
streamErr.SetMessage(common.MessageWithRequestId(streamErr.Error(), c.GetString(common.RequestIdKey)))
if info.RelayFormat == types.RelayFormatClaude {
c.JSON(streamErr.StatusCode, gin.H{"type": "error", "error": streamErr.ToClaudeError(common.UpstreamAddresses(c)...)})
} else {
c.JSON(streamErr.StatusCode, gin.H{"error": streamErr.ToOpenAIError(common.UpstreamAddresses(c)...)})
}
return usage, nil
}
usage := state.Usage()
@@ -303,6 +353,9 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
usage = service.ResponseText2Usage(c, state.UsageText(), info.UpstreamModelName, info.GetEstimatePromptTokens())
state.SetUsage(usage)
}
if info.StreamStatus.EndReason == relaycommon.StreamEndReasonClientGone {
return usage, nil
}
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil {
info.ClaudeConvertInfo.Usage = usage
@@ -313,12 +366,19 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
}
for _, result := range finalResults {
if !sendStreamResult(result) {
return nil, streamErr
if streamErr != nil {
return nil, streamErr
}
return usage, nil
}
}
if info.RelayFormat == types.RelayFormatOpenAI && info.ShouldIncludeUsage && usage != nil {
if err := helper.ObjectData(c, helper.GenerateFinalUsageResponse(responseId, createAt, info.UpstreamModelName, *usage)); err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
writeFailed(err)
if streamErr != nil {
return nil, streamErr
}
return usage, nil
}
}
@@ -11,7 +11,9 @@ import (
"github.com/QuantumNous/new-api/constant"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -268,6 +270,59 @@ func TestOaiChatToResponsesStreamHandlerConvertsSSEOrderAndUsage(t *testing.T) {
)
}
func TestOaiChatToResponsesStreamHandlerRestoresCodexCustomToolCall(t *testing.T) {
oldMode := gin.Mode()
gin.SetMode(gin.TestMode)
t.Cleanup(func() { gin.SetMode(oldMode) })
oldTimeout := constant.StreamingTimeout
constant.StreamingTimeout = 30
t.Cleanup(func() { constant.StreamingTimeout = oldTimeout })
body := strings.Join([]string{
`data: {"id":"chatcmpl_1","object":"chat.completion.chunk","created":1710000000,"model":"gpt-test","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_exec","type":"function","function":{"name":"exec","arguments":"{\"input\":"}}]},"finish_reason":null}]}`,
`data: {"id":"chatcmpl_1","object":"chat.completion.chunk","created":1710000000,"model":"gpt-test","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"ls -la\"}"}}]},"finish_reason":null}]}`,
`data: {"id":"chatcmpl_1","object":"chat.completion.chunk","created":1710000000,"model":"gpt-test","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
`data: [DONE]`,
``,
}, "\n")
c, recorder, resp, info := newResponsesChatTestContext(t, body, true)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
tools, marshalErr := common.Marshal([]map[string]any{
{"type": "custom", "name": "exec", "description": "Run a shell command", "format": map[string]any{"type": "text"}},
})
require.NoError(t, marshalErr)
converted, convertErr := service.ConvertRequestByID(c, info, relayconvert.ConverterOpenAIResponsesToOpenAIChat, &dto.OpenAIResponsesRequest{
Model: "gpt-test",
Input: []byte(`"list files"`),
Tools: tools,
})
require.NoError(t, convertErr)
chatRequest, ok := converted.Value.(*dto.GeneralOpenAIRequest)
require.True(t, ok)
require.Len(t, chatRequest.Tools, 1)
assert.Equal(t, "function", chatRequest.Tools[0].Type)
assert.Equal(t, "exec", chatRequest.Tools[0].Function.Name)
_, err := OaiChatToResponsesStreamHandler(c, info, resp)
require.Nil(t, err)
got := recorder.Body.String()
assert.NotContains(t, got, `event: response.function_call_arguments`)
assert.Contains(t, got, `"delta":"ls -la"`)
assert.Contains(t, got, `"input":"ls -la"`)
requireOrderedSubstrings(t, got,
`event: response.output_item.added`,
`"type":"custom_tool_call"`,
`event: response.custom_tool_call_input.delta`,
`event: response.custom_tool_call_input.done`,
`event: response.output_item.done`,
`event: response.completed`,
`"type":"custom_tool_call"`,
)
}
func requireOrderedSubstrings(t *testing.T, s string, parts ...string) {
t.Helper()
+45 -11
View File
@@ -62,7 +62,7 @@ func handleClaudeFormat(c *gin.Context, data string, info *relaycommon.RelayInfo
func handleGeminiFormat(c *gin.Context, data string, info *relaycommon.RelayInfo) error {
var streamResponse dto.ChatCompletionsStreamResponse
if err := common.Unmarshal(common.StringToByteSlice(data), &streamResponse); err != nil {
logger.LogError(c, "failed to unmarshal stream response: "+err.Error())
logger.LogError(c, common.LogText("failed to unmarshal stream response: %s", err.Error()))
return err
}
@@ -111,7 +111,7 @@ func sendGeminiStreamResults(c *gin.Context, results []relayconvert.ResponseResu
}
data, err := common.Marshal(geminiResponse)
if err != nil {
logger.LogError(c, "failed to marshal gemini response: "+err.Error())
logger.LogError(c, common.LogText("failed to marshal gemini response: %s", err.Error()))
return err
}
c.Render(-1, common.CustomEvent{Data: "data: " + string(data)})
@@ -206,7 +206,7 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
case types.RelayFormatClaude:
var streamResponse dto.ChatCompletionsStreamResponse
if err := common.Unmarshal(common.StringToByteSlice(lastStreamData), &streamResponse); err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
common.SysLog(common.LogText("error unmarshalling stream response: %s", err.Error()))
return
}
@@ -214,12 +214,12 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
result, err := service.ConvertStreamResponse(c, info, types.RelayFormatClaude, &streamResponse)
if err != nil {
common.SysLog("error converting Claude stream response: " + err.Error())
common.SysLog(common.LogText("error converting Claude stream response: %s", err.Error()))
return
}
claudeResponses, ok := result.Value.([]*dto.ClaudeResponse)
if !ok {
common.SysLog(fmt.Sprintf("expected Claude stream responses, got %T", result.Value))
common.SysLog(common.LogText("expected Claude stream responses, got %T", result.Value))
return
}
for _, resp := range claudeResponses {
@@ -230,34 +230,34 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
case types.RelayFormatGemini:
var streamResponse dto.ChatCompletionsStreamResponse
if err := common.Unmarshal(common.StringToByteSlice(lastStreamData), &streamResponse); err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
common.SysLog(common.LogText("error unmarshalling stream response: %s", err.Error()))
return
}
state, err := chatToGeminiStreamState(info, &streamResponse)
if err != nil {
common.SysLog("error creating Gemini stream state: " + err.Error())
common.SysLog(common.LogText("error creating Gemini stream state: %s", err.Error()))
return
}
state.SetUsage(usage)
results, err := service.ConvertStreamResponseChunk(c, info, state, &streamResponse)
if err != nil {
common.SysLog("error converting final Gemini stream response: " + err.Error())
common.SysLog(common.LogText("error converting final Gemini stream response: %s", err.Error()))
return
}
if err := sendGeminiStreamResults(c, results); err != nil {
common.SysLog("error sending final Gemini stream response: " + err.Error())
common.SysLog(common.LogText("error sending final Gemini stream response: %s", err.Error()))
return
}
results, err = service.FinalizeStreamResponse(c, info, state)
if err != nil {
common.SysLog("error finalizing Gemini stream response: " + err.Error())
common.SysLog(common.LogText("error finalizing Gemini stream response: %s", err.Error()))
return
}
if err := sendGeminiStreamResults(c, results); err != nil {
common.SysLog("error sending finalized Gemini stream response: " + err.Error())
common.SysLog(common.LogText("error sending finalized Gemini stream response: %s", err.Error()))
}
}
}
@@ -266,5 +266,39 @@ func sendResponsesStreamData(c *gin.Context, streamResponse dto.ResponsesStreamR
if data == "" {
return
}
if streamResponse.Type == "error" || streamResponse.Type == "response.error" || streamResponse.Type == "response.failed" {
// Stream events bypass the controller's HTTP error projection. Keep
// diagnostics server-side and emit only protocol fields to the client.
var original map[string]any
if common.UnmarshalJsonStr(data, &original) != nil {
return
}
diagnostic := map[string]any{"type": streamResponse.Type, "code": original["code"], "message": original["message"], "error": original["error"]}
if response, ok := original["response"].(map[string]any); ok {
diagnostic["response_error"] = response["error"]
}
detail, _ := common.Marshal(diagnostic)
logger.LogError(c, "upstream Responses stream failure: "+string(detail))
message := common.MessageWithRequestId("The model service is temporarily unavailable. Please try again later.", c.GetString(common.RequestIdKey))
publicError := map[string]any{"type": "api_error", "code": "model_service_error", "message": message}
safe := map[string]any{"type": streamResponse.Type, "code": "model_service_error", "message": message, "error": publicError}
if sequence, ok := original["sequence_number"]; ok {
safe["sequence_number"] = sequence
}
if response, ok := original["response"].(map[string]any); ok {
publicResponse := map[string]any{"status": "failed", "error": publicError}
for _, field := range []string{"id", "object", "created_at", "model", "usage"} {
if value, exists := response[field]; exists {
publicResponse[field] = value
}
}
safe["response"] = publicResponse
}
encoded, err := common.Marshal(safe)
if err != nil {
return
}
data = string(encoded)
}
_ = helper.ResponseChunkData(c, streamResponse, data)
}
+8 -6
View File
@@ -15,6 +15,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/tokenkit"
"github.com/gin-gonic/gin"
)
@@ -102,7 +103,7 @@ func sendStreamData(c *gin.Context, info *relaycommon.RelayInfo, data string, fo
func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
if resp == nil || resp.Body == nil {
logger.LogError(c, "invalid response or response body")
logger.LogError(c, common.LogText("invalid response or response body"))
return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
@@ -124,7 +125,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
if lastStreamData != "" {
if err := HandleStreamFormat(c, info, lastStreamData, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent); err != nil {
common.SysLog("error handling stream format: " + err.Error())
common.SysLog(common.LogText("error handling stream format: %s", err.Error()))
sr.Error(err)
}
}
@@ -136,7 +137,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
lastStreamData = data
observeStreamChoices(info, data, seenStreamToolCalls, &streamFunctionCallNames)
if err := processTokenData(info, data, &responseTextBuilder, &toolCount); err != nil {
logger.LogError(c, "error processing stream token data: "+err.Error())
logger.LogError(c, common.LogText("error processing stream token data: %s", err.Error()))
sr.Error(err)
}
}
@@ -148,7 +149,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
shouldSendLastResp := true
if err := handleLastResponse(lastStreamData, &responseId, &createAt, &systemFingerprint, &model, &usage,
&containStreamUsage, info, &shouldSendLastResp); err != nil {
logger.LogError(c, fmt.Sprintf("error handling last response: %s, lastStreamData: [%s]", err.Error(), lastStreamData))
logger.LogError(c, common.LogText("error handling last response: %s, lastStreamData: [%s]", err.Error(), lastStreamData))
}
// 部分兼容网关把完整的累计usage附在倒数第二个事件上,随后发送一个空的最后事件。
@@ -268,7 +269,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
if enterpriseResponse.Success {
responseBody = enterpriseResponse.Data
} else {
logger.LogError(c, fmt.Sprintf("openrouter enterprise response success=false, data: %s", enterpriseResponse.Data))
logger.LogError(c, common.LogText("openrouter enterprise response success=false, data: %s", enterpriseResponse.Data))
return nil, types.NewOpenAIError(fmt.Errorf("openrouter response success=false"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
}
@@ -296,6 +297,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
info.CountBillableToolCall(dto.BuildInCallFunctionCall, tc.Function.Name)
}
}
info.ApplyVendorToolUsage(responseBody)
forceFormat := false
if info.ChannelSetting.ForceFormat {
@@ -307,7 +309,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
completionTokens := simpleResponse.Usage.CompletionTokens
if completionTokens == 0 {
for _, choice := range simpleResponse.Choices {
ctkm := service.CountTextToken(choice.Message.StringContent()+choice.Message.GetReasoningContent(), info.UpstreamModelName)
ctkm := tokenkit.Count(info.UpstreamModelName, choice.Message.StringContent()+choice.Message.GetReasoningContent())
completionTokens += ctkm
}
}
+1 -1
View File
@@ -122,7 +122,7 @@ func normalizeOpenAIUsage(usage *dto.Usage) {
func OpenaiImageStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
if resp == nil || resp.Body == nil {
logger.LogError(c, "invalid image stream response")
logger.LogError(c, common.LogText("invalid image stream response"))
return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
+7 -7
View File
@@ -75,7 +75,7 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.
errChan <- fmt.Errorf("error counting text token: %v", err)
return
}
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
logger.LogInfo(c, common.LogText("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
localUsage.TotalTokens += textToken + audioToken
localUsage.InputTokens += textToken + audioToken
localUsage.InputTokenDetails.TextTokens += textToken
@@ -148,7 +148,7 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.
errChan <- fmt.Errorf("error counting text token: %v", err)
return
}
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
logger.LogInfo(c, common.LogText("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
localUsage.TotalTokens += textToken + audioToken
info.IsFirstRequest = false
localUsage.InputTokens += textToken + audioToken
@@ -163,9 +163,9 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.
localUsage = &dto.RealtimeUsage{}
// print now usage
}
logger.LogInfo(c, fmt.Sprintf("realtime streaming sumUsage: %v", sumUsage))
logger.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage))
logger.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage))
logger.LogInfo(c, common.LogText("realtime streaming sumUsage: %v", sumUsage))
logger.LogInfo(c, common.LogText("realtime streaming localUsage: %v", localUsage))
logger.LogInfo(c, common.LogText("realtime streaming localUsage: %v", localUsage))
} else if realtimeEvent.Type == dto.RealtimeEventTypeSessionUpdated || realtimeEvent.Type == dto.RealtimeEventTypeSessionCreated {
realtimeSession := realtimeEvent.Session
@@ -180,7 +180,7 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.
errChan <- fmt.Errorf("error counting text token: %v", err)
return
}
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
logger.LogInfo(c, common.LogText("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
localUsage.TotalTokens += textToken + audioToken
localUsage.OutputTokens += textToken + audioToken
localUsage.OutputTokenDetails.TextTokens += textToken
@@ -206,7 +206,7 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.
case <-targetClosed:
case err := <-errChan:
//return service.OpenAIErrorWrapper(err, "realtime_error", http.StatusInternalServerError), nil
logger.LogError(c, "realtime error: "+err.Error())
logger.LogError(c, common.LogText("realtime error: %s", err.Error()))
case <-c.Done():
}
+6 -8
View File
@@ -73,13 +73,11 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
info.CountBillableToolCall(dto.BuildInCallFunctionCall, output.Name)
}
}
info.ApplyVendorToolUsage(responseBody)
imageCounter := &relaycommon.ImageGenerationCallCounter{}
if !relaycommon.IsNonBillableResponsesStatus(responsesResponse.Status) {
for i := range responsesResponse.Output {
idx := i
imageCounter.Observe(&responsesResponse.Output[i], &idx)
}
for i := range responsesResponse.Output {
imageCounter.Observe(&responsesResponse.Output[i], &i)
}
imageCounter.Commit(info)
@@ -140,7 +138,7 @@ func readBufferedResponsesSSE(body io.Reader) ([]byte, error) {
func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
if resp == nil || resp.Body == nil {
logger.LogError(c, "invalid response or response body")
logger.LogError(c, common.LogText("invalid response or response body"))
return nil, types.NewError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse)
}
@@ -153,7 +151,7 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
// 检查当前数据是否包含 completed 状态和 usage 信息
var streamResponse dto.ResponsesStreamResponse
if err := common.UnmarshalJsonStr(data, &streamResponse); err != nil {
logger.LogError(c, "failed to unmarshal stream response: "+err.Error())
logger.LogError(c, common.LogText("failed to unmarshal stream response: %s", err.Error()))
sr.Error(err)
return
}
@@ -161,7 +159,7 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
data = string(rewriteSGLangResponsesCreatedAt(info, []byte(data), "response.created_at", streamResponse.Response.CreatedAt))
}
sendResponsesStreamData(c, streamResponse, data)
accumulator.Observe(&streamResponse)
accumulator.Observe(&streamResponse, common.StringToByteSlice(data))
})
common.SetContextKey(c, constant.ContextKeyResponseStreamStatus, info.StreamStatus)
@@ -5,13 +5,16 @@ import (
"io"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
@@ -193,7 +196,7 @@ func TestOaiResponsesHandlerCountsCompletedImageGenerationOutputs(t *testing.T)
assert.False(t, c.GetBool("image_generation_call"))
}
func TestOaiResponsesHandlerIncompleteStatusCommitsZeroImageGeneration(t *testing.T) {
func TestOaiResponsesHandlerCountsImageGenerationOnIncompleteStatus(t *testing.T) {
gin.SetMode(gin.TestMode)
body, err := common.Marshal(dto.OpenAIResponsesResponse{
@@ -229,7 +232,7 @@ func TestOaiResponsesHandlerIncompleteStatusCommitsZeroImageGeneration(t *testin
_, apiErr := OaiResponsesHandler(c, info, resp)
require.Nil(t, apiErr)
assert.Equal(t, 0, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
}
func runResponsesImageBillingStream(t *testing.T, events ...string) *relaycommon.RelayInfo {
@@ -284,14 +287,14 @@ func TestOaiResponsesStreamHandlerDeduplicatesCompletedImageOutput(t *testing.T)
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
}
func TestOaiResponsesStreamHandlerDiscardsImageOutputOnIncomplete(t *testing.T) {
func TestOaiResponsesStreamHandlerCountsImageOutputOnIncomplete(t *testing.T) {
info := runResponsesImageBillingStream(
t,
`{"type":"response.output_item.done","output_index":0,"item":{"type":"image_generation_call","id":"img_1","status":"completed","result":"base64-a"}}`,
`{"type":"response.incomplete","response":{"status":"incomplete"}}`,
)
assert.Equal(t, 0, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
}
func TestOaiResponsesStreamHandlerDoesNotCountPartialImageEvent(t *testing.T) {
@@ -392,3 +395,139 @@ func TestOaiResponsesStreamHandlerKeepsNonSGLangCreatedAt(t *testing.T) {
require.Nil(t, apiErr)
assert.Contains(t, w.Body.String(), `1786588600.0`)
}
func TestResponsesStreamFailureHidesRoutingDetails(t *testing.T) {
for _, eventType := range []string{"error", "response.error", "response.failed"} {
t.Run(eventType, func(t *testing.T) {
secret := "智能路由流式响应中途失败。原因:个人池商家在流式响应开始后中断。 request_id: upstream-private-id"
original := map[string]any{"type": eventType, "sequence_number": 3, "code": "vendor_stream_error", "message": secret, "debug": secret, "error": map[string]any{"message": secret}, "response": map[string]any{"id": "resp_test", "status": "failed", "error": map[string]any{"type": "vendor_error", "code": "vendor_stream_error", "message": secret}, "usage": map[string]any{"input_tokens": 10, "output_tokens": 2}, "debug": secret}}
encoded, err := common.Marshal(original)
require.NoError(t, err)
var event dto.ResponsesStreamResponse
require.NoError(t, common.Unmarshal(encoded, &event))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
c.Set(common.RequestIdKey, "local-request-id")
sendResponsesStreamData(c, event, string(encoded))
body := w.Body.String()
assert.Contains(t, body, "event: "+eventType)
assert.Contains(t, body, "model_service_error")
assert.Contains(t, body, "local-request-id")
assert.Contains(t, body, `"status":"failed"`)
assert.Contains(t, body, `"input_tokens":10`)
for _, detail := range []string{"个人池", "智能路由", "upstream-private-id", "vendor_stream_error", "debug"} {
assert.NotContains(t, body, detail)
}
// Projection must not change the event used for outcome and billing.
require.NotNil(t, event.Response)
assert.Equal(t, "vendor_stream_error", event.Response.GetOpenAIError().Code)
})
}
}
func TestResponsesSuccessfulStreamDataUnchanged(t *testing.T) {
body := `{"type":"response.output_text.delta","delta":"个人池 is user content"}`
var event dto.ResponsesStreamResponse
require.NoError(t, common.UnmarshalJsonStr(body, &event))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
sendResponsesStreamData(c, event, body)
assert.Contains(t, w.Body.String(), body)
}
// A converted hosted web search reaches Azure and Xinference Chat Completions
// as web_search_options, like OpenAI; the upstream decides whether it applies.
// Azure Responses bills web search by tool_usage.web_search.num_requests (Bing
// transactions) under bing_web_search, and the explicit zeros silence the
// web_search_call items, which leaves one surcharge row.
func TestAzureHostedWebSearch(t *testing.T) {
gin.SetMode(gin.TestMode)
oldTimeout := constant.StreamingTimeout
constant.StreamingTimeout = 30
t.Cleanup(func() {
constant.StreamingTimeout = oldTimeout
})
t.Run("Claude to Chat", func(t *testing.T) {
const body = `{"model":"gpt-5.1","max_tokens":64,"messages":[{"role":"user","content":"Weather?"}],"tools":[{"type":"web_search_20250305","name":"web_search"}]}`
for _, channelType := range []int{constant.ChannelTypeAzure, constant.ChannelTypeXinference, constant.ChannelTypeOpenAI} {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
info := &relaycommon.RelayInfo{
RelayFormat: types.RelayFormatClaude,
ChannelMeta: &relaycommon.ChannelMeta{ChannelType: channelType, UpstreamModelName: "gpt-5.1"},
}
adaptor := &Adaptor{}
adaptor.Init(info)
var request dto.ClaudeRequest
require.NoError(t, common.UnmarshalJsonStr(body, &request))
converted, err := adaptor.ConvertClaudeRequest(c, info, &request)
require.NoError(t, err)
wire, err := common.Marshal(converted)
require.NoError(t, err)
assert.True(t, strings.Contains(string(wire), `"web_search_options"`), channelType)
var codes []string
for _, diagnostic := range info.ConversionDiagnostics() {
codes = append(codes, diagnostic.Code)
}
assert.False(t, slices.Contains(codes, "hosted_web_search_omitted"), channelType)
}
})
const searchCall = `{"type":"web_search_call","status":"completed","action":{"type":"search","query":"go"}}`
const usage = `"usage":{"input_tokens":10,"output_tokens":3,"total_tokens":13},"tool_usage":{"web_search":{"num_requests":5}}`
output := strings.Repeat(searchCall+",", 3) + `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Go 1.25"}]}`
tests := []struct {
name string
contentType string
body string
}{
{
name: "json",
contentType: "application/json",
body: `{"id":"resp_1","object":"response","model":"gpt-5.1","status":"completed","output":[` + output + `],` + usage + `}`,
},
{
name: "stream",
contentType: "text/event-stream",
body: strings.Repeat(`data: {"type":"response.output_item.done","output_index":0,"item":`+searchCall+"}\n\n", 3) +
`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.1","status":"completed","output":[` + output + `],` + usage + "}}\n\ndata: [DONE]\n\n",
},
}
for _, tt := range tests {
t.Run("Responses "+tt.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeResponses,
RelayFormat: types.RelayFormatOpenAIResponses,
OriginModelName: "gpt-5.1",
IsStream: tt.name == "stream",
DisablePing: true,
ChannelMeta: &relaycommon.ChannelMeta{ChannelType: constant.ChannelTypeAzure, UpstreamModelName: "gpt-5.1"},
}
adaptor := &Adaptor{}
adaptor.Init(info)
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(tt.body)),
Header: http.Header{"Content-Type": []string{tt.contentType}},
}
var apiErr *types.NewAPIError
if info.IsStream {
_, apiErr = OaiResponsesStreamHandler(c, info, resp)
} else {
_, apiErr = OaiResponsesHandler(c, info, resp)
}
require.Nil(t, apiErr)
billed := map[string]int{}
for name, tool := range info.ResponsesUsageInfo.BuiltInTools {
if tool.CallCount > 0 {
billed[name] = tool.CallCount
}
}
assert.Equal(t, map[string]int{"bing_web_search": 5}, billed)
})
}
}
+51 -12
View File
@@ -79,6 +79,9 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
streamErr := (*types.NewAPIError)(nil)
// upstreamFailure is the upstream or conversion failure the client was
// told about with response.failed.
var upstreamFailure *types.NewAPIError
sendEvent := func(event relayconvert.ChatToResponsesStreamEvent) bool {
data, err := common.Marshal(event.Payload)
@@ -87,6 +90,13 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return false
}
if err := helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: event.Type}, string(data)); err != nil {
// When the client went away the upstream already produced output,
// so the stream stops and settles normally instead of failing; any
// other write error stays a server error.
if c.Request.Context().Err() != nil {
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, err)
return false
}
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
return false
}
@@ -112,7 +122,7 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
if streamErr != nil {
sr.Stop(streamErr)
stopStream(sr, streamErr)
return
}
@@ -120,24 +130,28 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
if err := common.UnmarshalJsonStr(data, &errorResp); err == nil {
if oaiError := errorResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
if failResponsesStream(fmt.Errorf("%s", oaiError.Message)) {
sr.Stop(streamErr)
// The client got response.failed, so the request is never
// retried; the recorded error keeps the stream failed.
upstreamFailure = types.WithOpenAIError(*oaiError, http.StatusInternalServerError, types.ErrOptionWithSkipRetry())
sr.Stop(upstreamFailure)
return
}
streamErr = types.WithOpenAIError(*oaiError, resp.StatusCode)
sr.Stop(streamErr)
stopStream(sr, streamErr)
return
}
}
var chunk dto.ChatCompletionsStreamResponse
if err := common.UnmarshalJsonStr(data, &chunk); err != nil {
logger.LogError(c, "failed to unmarshal chat stream response: "+err.Error())
logger.LogError(c, common.LogText("failed to unmarshal chat stream response: %s", err.Error()))
if failResponsesStream(err) {
sr.Stop(streamErr)
upstreamFailure = types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError, types.ErrOptionWithSkipRetry())
sr.Stop(upstreamFailure)
return
}
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
sr.Stop(streamErr)
stopStream(sr, streamErr)
return
}
@@ -145,22 +159,23 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
results, err := service.ConvertStreamResponseChunk(c, info, state, &chunk)
if err != nil {
if failResponsesStream(err) {
sr.Stop(streamErr)
upstreamFailure = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError, types.ErrOptionWithSkipRetry())
sr.Stop(upstreamFailure)
return
}
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
sr.Stop(streamErr)
stopStream(sr, streamErr)
return
}
for _, result := range results {
event, ok := result.Value.(relayconvert.ChatToResponsesStreamEvent)
if !ok {
streamErr = types.NewOpenAIError(fmt.Errorf("expected OAI responses stream event, got %T", result.Value), types.ErrorCodeBadResponse, http.StatusInternalServerError)
sr.Stop(streamErr)
stopStream(sr, streamErr)
return
}
if !sendEvent(event) {
sr.Stop(streamErr)
stopStream(sr, streamErr)
return
}
}
@@ -171,10 +186,19 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
}
usage := state.Usage()
if usage == nil || usage.TotalTokens == 0 {
noUpstreamUsage := usage == nil || usage.TotalTokens == 0
if upstreamFailure != nil && noUpstreamUsage && state.UsageText() == "" {
// The stream failed before any output reached the client, which
// already received response.failed: the request is not charged.
return nil, upstreamFailure
}
if noUpstreamUsage {
usage = service.ResponseText2Usage(c, state.UsageText(), info.UpstreamModelName, info.GetEstimatePromptTokens())
state.SetUsage(usage)
}
if info.StreamStatus.EndReason == relaycommon.StreamEndReasonClientGone {
return usage, nil
}
finalResults, err := service.FinalizeStreamResponse(c, info, state)
if err != nil {
@@ -189,9 +213,24 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return nil, types.NewOpenAIError(fmt.Errorf("expected OAI responses stream event, got %T", result.Value), types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
if !sendEvent(event) {
return nil, streamErr
if streamErr != nil {
return nil, streamErr
}
return usage, nil
}
}
return usage, nil
}
// stopStream ends a converted stream from the scanner callback. A nil
// streamErr (the stream stops because the client went away) must reach
// StreamResult.Stop as an untyped nil; a typed nil *NewAPIError is a non-nil
// error that StreamResult would record as a failure and dereference.
func stopStream(sr *helper.StreamResult, streamErr *types.NewAPIError) {
if streamErr == nil {
sr.Stop(nil)
return
}
sr.Stop(streamErr)
}
+29
View File
@@ -0,0 +1,29 @@
package openai
import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/tidwall/gjson"
)
// azureResponsesToolUsage bills Azure Responses web search by the Bing request
// count the service reports (tool_usage.web_search.num_requests, $14 per 1,000
// transactions) under its own key, and silences the web_search_call items:
// Azure documents "Use tool_usage.web_search.num_requests" rather than
// counting web_search_call. gjson truncates fractional values and reads
// non-numbers as zero; SetBillableToolCount ignores negatives and settlement
// applies MaxBillableToolCallCount.
func azureResponsesToolUsage(raw []byte) map[string]int {
count := gjson.GetBytes(raw, "tool_usage.web_search.num_requests")
if !count.Exists() {
count = gjson.GetBytes(raw, "response.tool_usage.web_search.num_requests")
}
if !count.Exists() {
return nil
}
return map[string]int{
"bing_web_search": int(count.Int()),
dto.BuildInToolWebSearch: 0,
dto.BuildInToolWebSearchPreview: 0,
}
}
+3 -3
View File
@@ -59,7 +59,7 @@ func palmStreamHandler(c *gin.Context, resp *http.Response) (*types.NewAPIError,
go func() {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
common.SysLog("error reading stream response: " + err.Error())
common.SysLog(common.LogText("error reading stream response: %s", err.Error()))
stopChan <- true
return
}
@@ -67,7 +67,7 @@ func palmStreamHandler(c *gin.Context, resp *http.Response) (*types.NewAPIError,
var palmResponse PaLMChatResponse
err = json.Unmarshal(responseBody, &palmResponse)
if err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
common.SysLog(common.LogText("error unmarshalling stream response: %s", err.Error()))
stopChan <- true
return
}
@@ -79,7 +79,7 @@ func palmStreamHandler(c *gin.Context, resp *http.Response) (*types.NewAPIError,
}
jsonResponse, err := json.Marshal(fullTextResponse)
if err != nil {
common.SysLog("error marshalling stream response: " + err.Error())
common.SysLog(common.LogText("error marshalling stream response: %s", err.Error()))
stopChan <- true
return
}
+374 -142
View File
@@ -2,9 +2,10 @@ package jsplugin
import (
"bytes"
"cmp"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
@@ -14,6 +15,7 @@ import (
"net/http"
"net/textproto"
"net/url"
"reflect"
"regexp"
"slices"
"strconv"
@@ -46,6 +48,26 @@ type requestDescriptor struct {
RewriteModel string `json:"rewriteModel"`
BodyType string `json:"bodyType"`
Parts []requestPart `json:"parts"`
// BodyText is the body as JSON.stringify wrote it, for a plugin that
// preserves JSON order: the upstream gets the members in that order.
BodyText json.RawMessage `json:"-"`
}
// orderedRequestDescriptor is requestDescriptor as a plugin that preserves
// JSON order returns it: decoded from the JSON.stringify text of the result,
// with the body kept as that text.
type orderedRequestDescriptor struct {
ResponseType string `json:"responseType"`
URL string `json:"url"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
Body json.RawMessage `json:"body"`
Credentialless bool `json:"credentialless"`
Action string `json:"action"`
Model string `json:"model"`
RewriteModel string `json:"rewriteModel"`
BodyType string `json:"bodyType"`
Parts []requestPart `json:"parts"`
}
type requestPart struct {
@@ -88,6 +110,12 @@ type TaskAdaptor struct {
routeRequest *pluginruntime.RouteRequestContext
requestHeaders map[string]string
files []map[string]any
// hookRequestBody is hookRequestSource as hooks receive it.
hookRequestSource map[string]any
hookRequestBody any
// requestBodyText is the decoded requestBody as JSON text, which hooks
// of a plugin that preserves JSON order receive instead.
requestBodyText json.RawMessage
}
func New(plugin *pluginruntime.LoadedPlugin) *TaskAdaptor { return &TaskAdaptor{plugin: plugin} }
@@ -98,7 +126,10 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
if pinned, ok := pinnedValue.(pluginruntime.PinnedEndpoint); ok && pinned.Plugin == a.plugin {
if protocolValue, present := c.Get(pluginruntime.ContextKeyProtocolRequest); present {
if protocolContext, valid := protocolValue.(pluginruntime.ProtocolRequestContext); valid {
resolvedValue, callErr := a.plugin.Engine.CallPath(context.WithoutCancel(c.Request.Context()), "protocols", []string{pinned.Protocol, "decodeRequest"}, protocolContext.JSValue())
resolvedValue, requestBodyText, callErr := a.plugin.Engine.CallPathWithMemberJSON(
context.WithoutCancel(c.Request.Context()), 0, a.plugin.Meta.JSONTextMember("requestBody"),
"protocols", []string{pinned.Protocol, "decodeRequest"}, protocolContext.JSValueFor(a.plugin.Meta),
)
resolved, resolvedOK := resolvedValue.(map[string]any)
resolvedModel, modelOK := resolved["model"].(string)
if callErr != nil || !resolvedOK || !modelOK || resolvedModel != pinned.Model {
@@ -110,6 +141,9 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
if body, present := resolved["requestBody"]; present {
c.Set("task_request", body)
}
if requestBodyText != nil {
c.Set(pluginruntime.ContextKeyRequestBodyText, requestBodyText)
}
if action, valid := resolved["action"].(string); valid && strings.TrimSpace(action) != "" {
c.Set("task_action", action)
info.Action = action
@@ -126,18 +160,23 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
request, hasRequest := c.Get("task_request")
hasUsageProfiles := len(a.plugin.Meta.UsageProfiles) > 0
if hasRequest && !hasUsageProfiles {
if err := a.validateResolvedUsageRequest(request, ""); err != nil {
if err := a.validateResolvedUsageRequest(request); err != nil {
return service.TaskErrorWrapperLocal(err, "plugin_usage_invalid", http.StatusBadRequest)
}
}
if _, err := a.buildSubmit(c, info); err != nil {
return service.TaskErrorWrapperLocal(err, "plugin_request_invalid", http.StatusBadRequest)
// utils.fetch failures in the build hook name the host fetched: the
// channel base URL or one of the plugin's allowed hosts, which an
// installed plugin may point at private hosts.
taskErr := service.TaskErrorWrapper(err, "plugin_request_invalid", http.StatusBadRequest, append(common.UpstreamAddresses(c), a.plugin.Meta.AllowedHosts...)...)
taskErr.LocalError = true
return taskErr
}
// The descriptor may rewrite the model. Validate profiled requests against
// that final model before any quota calculation or upstream submission.
// that final model before any quota calculation or upstream submission; an
// upstream endpoint ID without a profile keeps the client model's profile.
if hasRequest && hasUsageProfiles {
usageModel := cmp.Or(info.UpstreamModelName, info.OriginModelName)
if err := a.validateResolvedUsageRequest(request, usageModel); err != nil {
if err := a.validateResolvedUsageRequest(request, info.UpstreamModelName, info.OriginModelName); err != nil {
return service.TaskErrorWrapperLocal(err, "plugin_usage_invalid", http.StatusBadRequest)
}
}
@@ -156,7 +195,7 @@ func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInf
func (a *TaskAdaptor) EstimateBillingValidated(c *gin.Context, info *relaycommon.RelayInfo) (map[string]float64, error) {
usageContext := a.submitContext(c, info)
usageContext["usagePurpose"] = "billing_ratios"
return a.usageRatios(c.Request.Context(), cmp.Or(info.UpstreamModelName, info.OriginModelName), "extractUsage", usageContext)
return a.usageRatios(c.Request.Context(), []string{info.UpstreamModelName, info.OriginModelName}, "extractUsage", usageContext)
}
func (a *TaskAdaptor) ExtractUsageFacts(c *gin.Context, info *relaycommon.RelayInfo) map[string]any {
@@ -185,10 +224,11 @@ func (a *TaskAdaptor) ExtractUsageFactsValidated(c *gin.Context, info *relaycomm
if !ok {
return nil, fmt.Errorf("plugin usage hook must return an object")
}
if _, err = a.validatedUsageRatios(facts, cmp.Or(info.UpstreamModelName, info.OriginModelName)); err != nil {
validated, _, err := a.validatedUsageRatios(facts, info.UpstreamModelName, info.OriginModelName)
if err != nil {
return nil, err
}
return facts, nil
return validated, nil
}
func (a *TaskAdaptor) AdjustBillingOnSubmit(info *relaycommon.RelayInfo, taskData []byte) map[string]float64 {
@@ -196,7 +236,7 @@ func (a *TaskAdaptor) AdjustBillingOnSubmit(info *relaycommon.RelayInfo, taskDat
if err := common.Unmarshal(taskData, &data); err != nil {
data = string(taskData)
}
ratios, err := a.usageRatios(context.Background(), cmp.Or(info.UpstreamModelName, info.OriginModelName), "extractUsageOnSubmit", a.submitContext(nil, info), data)
ratios, err := a.usageRatios(context.Background(), []string{info.UpstreamModelName, info.OriginModelName}, "extractUsageOnSubmit", a.submitContext(nil, info), data)
if err != nil {
a.logRejectedUsage("extractUsageOnSubmit", err)
return nil
@@ -212,11 +252,11 @@ func (a *TaskAdaptor) AdjustBillingOnComplete(task *model.Task, result *relaycom
if err != nil {
return 0
}
usageModel := ""
if task != nil {
usageModel = cmp.Or(task.Properties.UpstreamModelName, task.Properties.OriginModelName)
if task == nil {
a.applyCompletionUsageFacts(result, value)
return 0
}
a.applyCompletionUsageFacts(result, value, usageModel)
a.applyCompletionUsageFacts(result, value, task.Properties.UpstreamModelName, task.Properties.OriginModelName)
return 0
}
@@ -227,13 +267,18 @@ func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, erro
return a.submit.URL, pluginruntime.ValidateRequestURL(a.submit.URL, info.ChannelBaseUrl, a.plugin.Meta.AllowedHosts)
}
func (a *TaskAdaptor) BuildRequestHeader(_ *gin.Context, req *http.Request, _ *relaycommon.RelayInfo) error {
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, _ *relaycommon.RelayInfo) error {
if a.submit == nil {
return fmt.Errorf("plugin submit request was not built")
}
for name, value := range a.submit.Headers {
req.Header.Set(name, value)
}
// BuildRequestBody rebuilt the multipart body with a new boundary and put its
// Content-Type on the client request; a plugin's own Content-Type cannot match it.
if a.submit.BodyType == "multipart" {
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
}
return nil
}
@@ -267,18 +312,18 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayIn
}
continue
}
field := strings.TrimPrefix(part.FileRef, "request_file:")
field, index, validRef := pluginruntime.ParseFileReference(part.FileRef)
files := form.File[field]
if len(files) == 0 {
if !validRef || index >= len(files) {
return nil, fmt.Errorf("unknown file reference %q", part.FileRef)
}
file, openErr := files[0].Open()
file, openErr := files[index].Open()
if openErr != nil {
return nil, openErr
}
filename := part.Filename
if filename == "" {
filename = files[0].Filename
filename = files[index].Filename
}
header := make(textproto.MIMEHeader)
disposition := mime.FormatMediaType("form-data", map[string]string{"name": part.Name, "filename": filename})
@@ -287,7 +332,7 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayIn
return nil, fmt.Errorf("invalid multipart name or filename")
}
header.Set("Content-Disposition", disposition)
header.Set("Content-Type", files[0].Header.Get("Content-Type"))
header.Set("Content-Type", files[index].Header.Get("Content-Type"))
destination, copyErr := writer.CreatePart(header)
if copyErr == nil {
_, copyErr = io.Copy(destination, file)
@@ -309,7 +354,18 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayIn
if text, ok := descriptor.Body.(string); ok {
return strings.NewReader(text), nil
}
inlined, err := inlineJSONFilePlaceholders(c, descriptor.Body)
value := descriptor.Body
if descriptor.BodyText != nil {
if !bytes.Contains(descriptor.BodyText, []byte(`"__fileRef":`)) {
return bytes.NewReader(descriptor.BodyText), nil
}
// File placeholders resolve only in the decoded value, which writes
// the members sorted; uploads carry no client JSON order to keep.
if err := common.Unmarshal(descriptor.BodyText, &value); err != nil {
return nil, err
}
}
inlined, err := inlineJSONFilePlaceholders(c, value)
if err != nil {
return nil, err
}
@@ -391,12 +447,12 @@ func encodeFilePlaceholder(placeholder map[string]any, form *multipart.Form, lim
if form == nil {
return "", fmt.Errorf("unknown file reference %q", ref)
}
field := strings.TrimPrefix(ref, "request_file:")
field, index, validRef := pluginruntime.ParseFileReference(ref)
files := form.File[field]
if len(files) == 0 {
if !validRef || index >= len(files) {
return "", fmt.Errorf("unknown file reference %q", ref)
}
header := files[0]
header := files[index]
maxBytes := limit
if raw, exists := placeholder["maxBytes"]; exists {
n, ok := usageNumber(raw, false)
@@ -484,7 +540,7 @@ func (a *TaskAdaptor) ParseResponse(c *gin.Context, resp *http.Response, info *r
}
}
if err != nil {
failure := service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusBadGateway)
failure := service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusBadGateway, common.UpstreamAddresses(c)...)
failure.NoRetry = streaming
return nil, failure
}
@@ -499,7 +555,7 @@ func (a *TaskAdaptor) ParseResponse(c *gin.Context, resp *http.Response, info *r
resp.StatusCode,
time.Since(started).Milliseconds(),
)
return nil, service.TaskErrorWrapper(err, "plugin_submit_response_failed", http.StatusBadGateway)
return nil, service.TaskErrorWrapper(err, "plugin_submit_response_failed", http.StatusBadGateway, common.UpstreamAddresses(c)...)
}
if object, ok := value.(map[string]any); ok {
if _, forbidden := object["clientResponse"]; forbidden {
@@ -518,7 +574,7 @@ func (a *TaskAdaptor) ParseResponse(c *gin.Context, resp *http.Response, info *r
resp.StatusCode,
time.Since(started).Milliseconds(),
)
return nil, service.TaskErrorWrapper(err, "plugin_submit_response_invalid", http.StatusBadGateway)
return nil, service.TaskErrorWrapper(err, "plugin_submit_response_invalid", http.StatusBadGateway, common.UpstreamAddresses(c)...)
}
var taskData []byte
if parsed.TaskData != nil {
@@ -527,12 +583,12 @@ func (a *TaskAdaptor) ParseResponse(c *gin.Context, resp *http.Response, info *r
if err == nil {
err = fmt.Errorf("task data exceeds size limit")
}
return nil, service.TaskErrorWrapper(err, "plugin_submit_response_invalid", http.StatusBadGateway)
return nil, service.TaskErrorWrapper(err, "plugin_submit_response_invalid", http.StatusBadGateway, common.UpstreamAddresses(c)...)
}
}
pluginState, _ := encodeReturnedPluginState(value)
if len(pluginState) > maxTaskPluginPersistedJSONBytes {
logger.LogWarn(c, fmt.Sprintf("task plugin %s rejected oversized submit state (%d bytes)", a.plugin.Meta.Key, len(pluginState)))
logger.LogWarn(c, common.LogText("task plugin %s rejected oversized submit state (%d bytes)", a.plugin.Meta.Key, len(pluginState)))
pluginState = nil
}
response := &channel.TaskSubmitResponse{
@@ -562,16 +618,16 @@ func (a *TaskAdaptor) ParseResponse(c *gin.Context, resp *http.Response, info *r
task.PrivateData.PluginState = pluginState
ctx, err := a.queryContext(task, info.ApiKey, info.ChannelBaseUrl, info.ChannelSetting.Proxy)
if err != nil {
logger.LogWarn(c, fmt.Sprintf("task plugin %s completion context failed; retaining reserved quota: %v", a.plugin.Meta.Key, err))
logger.LogWarn(c, common.LogText("task plugin %s completion context failed; retaining reserved quota: %v", a.plugin.Meta.Key, err))
return response, nil
}
facts, err := a.plugin.Engine.Call(c.Request.Context(), "extractUsageOnComplete", ctx, jsonValue(immediate), parsed.TaskData)
if err != nil {
logger.LogWarn(c, fmt.Sprintf("task plugin %s completion usage failed; retaining reserved quota: %v", a.plugin.Meta.Key, err))
logger.LogWarn(c, common.LogText("task plugin %s completion usage failed; retaining reserved quota: %v", a.plugin.Meta.Key, err))
return response, nil
}
if err := a.applyCompletionUsageFacts(immediate, facts, cmp.Or(info.UpstreamModelName, info.OriginModelName)); err != nil {
logger.LogWarn(c, fmt.Sprintf("task plugin %s completion usage rejected; retaining reserved quota: %v", a.plugin.Meta.Key, err))
if err := a.applyCompletionUsageFacts(immediate, facts, info.UpstreamModelName, info.OriginModelName); err != nil {
logger.LogWarn(c, common.LogText("task plugin %s completion usage rejected; retaining reserved quota: %v", a.plugin.Meta.Key, err))
}
return response, nil
}
@@ -593,11 +649,11 @@ func (a *TaskAdaptor) FetchBatchTasks(baseURL, key string, tasks []*model.Task,
if err != nil {
return nil, err
}
value, err := a.plugin.Engine.Call(context.Background(), "buildBatchQueryRequest", ctx, taskContexts)
descriptor, err := a.requestDescriptor(pluginruntime.WithFetcher(context.Background(), a.hookFetcher(baseURL, proxy)), "buildBatchQueryRequest", ctx, taskContexts)
if err != nil {
return nil, err
}
return a.doFetchDescriptor(baseURL, proxy, value)
return a.doFetchDescriptor(baseURL, proxy, descriptor)
}
func (a *TaskAdaptor) FetchTask(baseURL, key string, task *model.Task, proxy string) (*http.Response, error) {
@@ -605,18 +661,60 @@ func (a *TaskAdaptor) FetchTask(baseURL, key string, task *model.Task, proxy str
if err != nil {
return nil, err
}
value, err := a.plugin.Engine.Call(context.Background(), "buildQueryRequest", ctx)
descriptor, err := a.requestDescriptor(pluginruntime.WithFetcher(context.Background(), a.hookFetcher(baseURL, proxy)), "buildQueryRequest", ctx)
if err != nil {
return nil, err
}
return a.doFetchDescriptor(baseURL, proxy, value)
return a.doFetchDescriptor(baseURL, proxy, descriptor)
}
func (a *TaskAdaptor) doFetchDescriptor(baseURL, proxy string, value any) (*http.Response, error) {
var descriptor requestDescriptor
if err := convert(value, &descriptor); err != nil {
return nil, err
// hookFetcher sends a driver hook's utils.fetch requests to the hosts its
// request descriptors may use, through the channel's proxy like poll requests.
func (a *TaskAdaptor) hookFetcher(baseURL, proxy string) pluginruntime.Fetcher {
return func(ctx context.Context, request pluginruntime.FetchRequest) (pluginruntime.FetchResponse, error) {
if err := pluginruntime.ValidateRequestURL(request.URL, baseURL, a.plugin.Meta.AllowedHosts); err != nil {
return pluginruntime.FetchResponse{}, err
}
client, err := service.GetHttpClientWithProxySettings(proxy, a.channelSetting())
if err != nil {
return pluginruntime.FetchResponse{}, err
}
// A redirect reaches the plugin as it is; fetching its location is a
// new request, checked and counted again.
noRedirects := *client
noRedirects.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
req, err := http.NewRequestWithContext(ctx, request.Method, request.URL, nil)
if err != nil {
return pluginruntime.FetchResponse{}, err
}
for name, value := range request.Headers {
req.Header.Set(name, value)
}
started := time.Now()
resp, err := noRedirects.Do(req)
if err != nil {
logger.LogDebug(ctx, "task_plugin subsystem=fetch event=failed plugin=%q hook=%q method=%q host=%q reason=transport_error elapsed_ms=%d",
a.plugin.Meta.Key, request.Hook, request.Method, req.URL.Host, time.Since(started).Milliseconds())
return pluginruntime.FetchResponse{}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, pluginruntime.MaxFetchBodyBytes+1))
if err != nil {
return pluginruntime.FetchResponse{}, err
}
headers := make(map[string]string, len(resp.Header))
for name, values := range resp.Header {
if len(values) > 0 {
headers[name] = values[0]
}
}
logger.LogDebug(ctx, "task_plugin subsystem=fetch event=done plugin=%q hook=%q method=%q host=%q status=%d bytes=%d elapsed_ms=%d",
a.plugin.Meta.Key, request.Hook, request.Method, req.URL.Host, resp.StatusCode, len(body), time.Since(started).Milliseconds())
return pluginruntime.FetchResponse{Status: resp.StatusCode, Headers: headers, Body: body}, nil
}
}
func (a *TaskAdaptor) doFetchDescriptor(baseURL, proxy string, descriptor requestDescriptor) (*http.Response, error) {
if err := pluginruntime.ValidateRequestURL(descriptor.URL, baseURL, a.plugin.Meta.AllowedHosts); err != nil {
return nil, err
}
@@ -624,6 +722,8 @@ func (a *TaskAdaptor) doFetchDescriptor(baseURL, proxy string, value any) (*http
if descriptor.Body != nil {
if bodyText, ok := descriptor.Body.(string); ok {
requestBody = strings.NewReader(bodyText)
} else if descriptor.BodyText != nil {
requestBody = bytes.NewReader(descriptor.BodyText)
} else {
encoded, marshalErr := common.Marshal(descriptor.Body)
if marshalErr != nil {
@@ -643,7 +743,7 @@ func (a *TaskAdaptor) doFetchDescriptor(baseURL, proxy string, value any) (*http
for name, value := range descriptor.Headers {
req.Header.Set(name, value)
}
client, err := service.GetHttpClientWithProxy(proxy)
client, err := service.GetHttpClientWithProxySettings(proxy, a.channelSetting())
if err != nil {
return nil, err
}
@@ -690,11 +790,6 @@ func (a *TaskAdaptor) ParseBatchResult(tasks []*model.Task, resp *http.Response,
if err != nil {
return nil, err
}
value, err := a.plugin.Engine.Call(context.Background(), "parseBatchResult", ctx, input, hookHTTPResponse(resp))
if err != nil {
logger.LogDebug(context.Background(), "task_plugin subsystem=adaptor event=parse_batch_failed plugin=%q reason=hook_failed body_bytes=%d elapsed_ms=%d", a.plugin.Meta.Key, len(body), time.Since(started).Milliseconds())
return nil, err
}
var parsed []struct {
TaskID string `json:"taskId"`
Action string `json:"action"`
@@ -708,8 +803,13 @@ func (a *TaskAdaptor) ParseBatchResult(tasks []*model.Task, resp *http.Response,
Data any `json:"data"`
State any `json:"state"`
}
if err = convert(value, &parsed); err != nil {
logger.LogDebug(context.Background(), "task_plugin subsystem=adaptor event=parse_batch_failed plugin=%q reason=invalid_result body_bytes=%d elapsed_ms=%d", a.plugin.Meta.Key, len(body), time.Since(started).Milliseconds())
if err = a.plugin.Engine.CallInto(pluginruntime.WithFetcher(context.Background(), a.hookFetcher(baseURL, proxy)), &parsed, "parseBatchResult", ctx, input, hookHTTPResponse(resp)); err != nil {
reason := "hook_failed"
var invalid *pluginruntime.ResultError
if errors.As(err, &invalid) {
reason = "invalid_result"
}
logger.LogDebug(context.Background(), "task_plugin subsystem=adaptor event=parse_batch_failed plugin=%q reason=%s body_bytes=%d elapsed_ms=%d", a.plugin.Meta.Key, reason, len(body), time.Since(started).Milliseconds())
return nil, err
}
results := make(map[string]*service.BatchTaskResult, len(parsed))
@@ -722,7 +822,7 @@ func (a *TaskAdaptor) ParseBatchResult(tasks []*model.Task, resp *http.Response,
if item.State != nil {
pluginState, marshalErr := common.Marshal(item.State)
if marshalErr != nil || len(pluginState) > maxTaskPluginPersistedJSONBytes {
logger.LogWarn(context.Background(), fmt.Sprintf("task plugin %s rejected invalid or oversized poll state", a.plugin.Meta.Key))
logger.LogWarn(context.Background(), common.LogText("task plugin %s rejected invalid or oversized poll state", a.plugin.Meta.Key))
} else {
info.PluginState = pluginState
}
@@ -741,8 +841,9 @@ func (a *TaskAdaptor) ParseBatchResult(tasks []*model.Task, resp *http.Response,
}
facts, hookErr := a.plugin.Engine.Call(context.Background(), "extractUsageOnComplete", itemCtx, jsonValue(&info), usageBody)
if hookErr == nil {
usageModel, _ := itemCtx["upstreamModel"].(string)
a.applyCompletionUsageFacts(&info, facts, usageModel)
upstreamModel, _ := itemCtx["upstreamModel"].(string)
originModel, _ := itemCtx["model"].(string)
a.applyCompletionUsageFacts(&info, facts, upstreamModel, originModel)
}
}
results[item.TaskID] = &service.BatchTaskResult{TaskInfo: info, Action: item.Action, SubmitTime: item.SubmitTime, StartTime: item.StartTime, FinishTime: item.FinishTime, Data: item.Data}
@@ -774,7 +875,7 @@ func (a *TaskAdaptor) ParseTaskResult(task *model.Task, resp *http.Response, bod
if err != nil {
return nil, err
}
value, err := a.plugin.Engine.Call(context.Background(), "parseTaskResult", ctx, input, hookHTTPResponse(resp))
value, err := a.plugin.Engine.Call(pluginruntime.WithFetcher(context.Background(), a.hookFetcher(baseURL, proxy)), "parseTaskResult", ctx, input, hookHTTPResponse(resp))
if err != nil {
logger.LogDebug(context.Background(), "task_plugin subsystem=adaptor event=parse_task_failed plugin=%q reason=hook_failed body_bytes=%d elapsed_ms=%d", a.plugin.Meta.Key, len(body), time.Since(started).Milliseconds())
return nil, err
@@ -797,7 +898,7 @@ func (a *TaskAdaptor) ParseTaskResult(task *model.Task, resp *http.Response, bod
}
if pluginState, present := encodeReturnedPluginState(value); present {
if len(pluginState) > maxTaskPluginPersistedJSONBytes {
logger.LogWarn(context.Background(), fmt.Sprintf("task plugin %s rejected oversized poll state (%d bytes)", a.plugin.Meta.Key, len(pluginState)))
logger.LogWarn(context.Background(), common.LogText("task plugin %s rejected oversized poll state (%d bytes)", a.plugin.Meta.Key, len(pluginState)))
} else {
result.PluginState = pluginState
}
@@ -807,8 +908,9 @@ func (a *TaskAdaptor) ParseTaskResult(task *model.Task, resp *http.Response, bod
if a.hasHook(context.Background(), "extractUsageOnComplete") {
facts, hookErr := a.plugin.Engine.Call(context.Background(), "extractUsageOnComplete", ctx, jsonValue(result), input)
if hookErr == nil {
usageModel, _ := ctx["upstreamModel"].(string)
a.applyCompletionUsageFacts(result, facts, usageModel)
upstreamModel, _ := ctx["upstreamModel"].(string)
originModel, _ := ctx["model"].(string)
a.applyCompletionUsageFacts(result, facts, upstreamModel, originModel)
}
}
taskStatus := model.TaskStatus(result.Status)
@@ -823,8 +925,8 @@ func (a *TaskAdaptor) ParseTaskResult(task *model.Task, resp *http.Response, bod
return result, nil
}
func (a *TaskAdaptor) applyCompletionUsageFacts(result *relaycommon.TaskInfo, facts any, modelName string) error {
values, err := a.validatedCompletionUsageFacts(facts, modelName)
func (a *TaskAdaptor) applyCompletionUsageFacts(result *relaycommon.TaskInfo, facts any, models ...string) error {
values, err := a.validatedCompletionUsageFacts(facts, models...)
if err != nil {
a.logRejectedUsage("extractUsageOnComplete", err)
return err
@@ -861,16 +963,19 @@ func (a *TaskAdaptor) ConvertToOpenAIVideo(task *model.Task) ([]byte, error) {
if err != nil {
return nil, err
}
value, err := a.plugin.Engine.CallPath(context.Background(), "protocols", []string{"openai_video", "render"}, map[string]any{"protocol": "openai_video", "operation": "retrieve"}, jsonValue(view))
encodedView, err := common.Marshal(view)
if err != nil {
return nil, err
}
encoded, err := common.Marshal(value)
// Decoding into any leaves only encoding failures to CallPathInto, so they
// keep the codec's message.
var value any
err = a.plugin.Engine.CallPathInto(context.Background(), &value, "protocols", []string{"openai_video", "render"}, map[string]any{"protocol": "openai_video", "operation": "retrieve"}, pluginruntime.RawJSON(encodedView))
if err != nil {
return nil, err
}
var rendered map[string]any
if err = common.Unmarshal(encoded, &rendered); err != nil || rendered == nil {
rendered, _ := value.(map[string]any)
if rendered == nil {
return nil, fmt.Errorf("plugin returned an invalid OpenAI video object")
}
// Keep provider extensions intact while the host owns the public task's
@@ -906,7 +1011,7 @@ func (a *TaskAdaptor) ListArtifacts(task *model.Task) ([]channel.TaskArtifact, e
return validateTaskArtifacts(value)
}
func (a *TaskAdaptor) BuildContentRequest(task *model.Task, artifactKey string, clientRequest channel.TaskArtifactClientRequest) (*channel.TaskContentRequest, error) {
func (a *TaskAdaptor) BuildContentRequest(ctx context.Context, task *model.Task, artifactKey string, clientRequest channel.TaskArtifactClientRequest) (*channel.TaskContentRequest, error) {
if !a.hasHook(context.Background(), "buildContentRequest") {
return nil, nil
}
@@ -916,32 +1021,22 @@ func (a *TaskAdaptor) BuildContentRequest(task *model.Task, artifactKey string,
if !taskArtifactKeyPattern.MatchString(artifactKey) {
return nil, fmt.Errorf("invalid artifact key")
}
ctx, err := taskArtifactContext(task)
hookContext, err := taskArtifactContext(task)
if err != nil {
return nil, err
}
ctx["upstreamTaskId"] = task.GetUpstreamTaskID()
ctx["artifactKey"] = artifactKey
ctx["baseUrl"] = a.info.ChannelBaseUrl
ctx["clientRequest"] = jsonValue(clientRequest)
proxy := a.info.ChannelSetting.Proxy
auth, err := resolveAuth(a.plugin.Meta.Auth, a.info.ApiKey, proxy)
if err != nil {
hookContext["upstreamTaskId"] = task.GetUpstreamTaskID()
hookContext["artifactKey"] = artifactKey
hookContext["baseUrl"] = a.info.ChannelBaseUrl
hookContext["clientRequest"] = jsonValue(clientRequest)
if err = a.applyUpstreamCredentials(hookContext, a.info.ChannelType, a.info.ApiKey, a.info.ChannelSetting.Proxy); err != nil {
return nil, err
}
ctx["auth"] = auth
ctx["authHeader"] = auth["authHeader"]
if a.plugin.Meta.Auth.Type == "" || a.plugin.Meta.Auth.Type == "none" || a.plugin.Meta.Auth.Type == "api_key" {
ctx["apiKey"] = a.info.ApiKey
}
value, err := a.plugin.Engine.Call(context.Background(), "buildContentRequest", ctx)
fetchContext := pluginruntime.WithFetcher(ctx, a.hookFetcher(a.info.ChannelBaseUrl, a.info.ChannelSetting.Proxy))
descriptor, err := a.requestDescriptor(fetchContext, "buildContentRequest", hookContext)
if err != nil {
return nil, err
}
var descriptor requestDescriptor
if err = convert(value, &descriptor); err != nil {
return nil, err
}
method := strings.ToUpper(strings.TrimSpace(descriptor.Method))
if method == "" {
method = strings.ToUpper(strings.TrimSpace(clientRequest.Method))
@@ -970,6 +1065,8 @@ func (a *TaskAdaptor) BuildContentRequest(task *model.Task, artifactKey string,
if descriptor.Body != nil {
if text, ok := descriptor.Body.(string); ok {
body = []byte(text)
} else if descriptor.BodyText != nil {
body = descriptor.BodyText
} else {
body, err = common.Marshal(descriptor.Body)
if err != nil {
@@ -1056,29 +1153,17 @@ func (a *TaskAdaptor) queryContext(task *model.Task, key, baseURL, proxy string)
key = task.PrivateData.Key
}
}
auth, err := resolveAuth(a.plugin.Meta.Auth, key, proxy)
if err != nil {
if err := a.applyUpstreamCredentials(ctx, a.channelType(), key, proxy); err != nil {
return nil, err
}
ctx["auth"] = auth
ctx["authHeader"] = auth["authHeader"]
if a.plugin.Meta.Auth.Type == "" || a.plugin.Meta.Auth.Type == "none" || a.plugin.Meta.Auth.Type == "api_key" {
ctx["apiKey"] = key
}
return ctx, nil
}
func (a *TaskAdaptor) batchQueryContext(key, baseURL, proxy string, tasks []map[string]any) (map[string]any, error) {
ctx := map[string]any{"baseUrl": baseURL, "tasks": tasks}
auth, err := resolveAuth(a.plugin.Meta.Auth, key, proxy)
if err != nil {
if err := a.applyUpstreamCredentials(ctx, a.channelType(), key, proxy); err != nil {
return nil, err
}
ctx["auth"] = auth
ctx["authHeader"] = auth["authHeader"]
if a.plugin.Meta.Auth.Type == "" || a.plugin.Meta.Auth.Type == "none" || a.plugin.Meta.Auth.Type == "api_key" {
ctx["apiKey"] = key
}
return ctx, nil
}
@@ -1089,6 +1174,50 @@ func (a *TaskAdaptor) queryCredentials() (key, baseURL, proxy string) {
return a.info.ApiKey, a.info.ChannelBaseUrl, a.info.ChannelSetting.Proxy
}
// channelType is the executing channel's type when polling or content
// fetches run with channel metadata; zero otherwise, which is a vendor upstream.
func (a *TaskAdaptor) channelType() int {
if a.info == nil || !a.info.HasChannelMeta() {
return 0
}
return a.info.ChannelType
}
// channelSetting is the executing channel's settings when the adaptor runs
// with channel metadata; zero otherwise.
func (a *TaskAdaptor) channelSetting() kitdto.ChannelSettings {
if a.info == nil || !a.info.HasChannelMeta() {
return kitdto.ChannelSettings{}
}
return a.info.ChannelSetting
}
// applyUpstreamCredentials sets ctx.upstream together with the credentials
// every driver hook reads. A New API channel (type 60) is another gateway:
// the plugin must address its own prefixed native routes there, and the
// channel key is that gateway's token, so it is sent as a Bearer header for
// every auth type instead of running the plugin's vendor auth scheme.
func (a *TaskAdaptor) applyUpstreamCredentials(ctx map[string]any, channelType int, key, proxy string) error {
if channelType == constant.ChannelTypeNewAPI {
ctx["upstream"] = map[string]any{"kind": pluginruntime.UpstreamKindNewAPI}
ctx["auth"] = map[string]any{"authHeader": "Bearer " + key}
ctx["authHeader"] = "Bearer " + key
ctx["apiKey"] = key
return nil
}
ctx["upstream"] = map[string]any{"kind": pluginruntime.UpstreamKindVendor}
auth, err := resolveAuth(a.plugin.Meta.Auth, key, proxy)
if err != nil {
return err
}
ctx["auth"] = auth
ctx["authHeader"] = auth["authHeader"]
if a.plugin.Meta.Auth.Type == "" || a.plugin.Meta.Auth.Type == "none" || a.plugin.Meta.Auth.Type == "api_key" {
ctx["apiKey"] = key
}
return nil
}
func hookHTTPResponse(resp *http.Response) map[string]any {
headers := map[string]string{}
status := 0
@@ -1176,27 +1305,62 @@ func validateTaskArtifacts(value any) ([]channel.TaskArtifact, error) {
return artifacts, nil
}
// requestDescriptor runs a request-building hook. A plugin that preserves JSON
// order has its descriptor decoded from the JSON.stringify text of the result,
// with the body kept as that text (BodyText) for the upstream.
func (a *TaskAdaptor) requestDescriptor(ctx context.Context, hook string, args ...any) (requestDescriptor, error) {
if !a.plugin.Meta.PreservesJSONOrder() {
var descriptor requestDescriptor
err := a.plugin.Engine.CallInto(ctx, &descriptor, hook, args...)
return descriptor, err
}
var ordered orderedRequestDescriptor
if err := a.plugin.Engine.CallJSONInto(ctx, &ordered, hook, args...); err != nil {
return requestDescriptor{}, err
}
descriptor := requestDescriptor{
ResponseType: ordered.ResponseType, URL: ordered.URL, Method: ordered.Method, Headers: ordered.Headers,
Credentialless: ordered.Credentialless, Action: ordered.Action, Model: ordered.Model,
RewriteModel: ordered.RewriteModel, BodyType: ordered.BodyType, Parts: ordered.Parts,
}
switch {
case len(ordered.Body) == 0 || string(ordered.Body) == "null":
case ordered.Body[0] == '"':
// A string body is the plugin's own text, sent as it is.
var text string
if err := common.Unmarshal(ordered.Body, &text); err != nil {
return requestDescriptor{}, err
}
descriptor.Body = text
default:
descriptor.Body, descriptor.BodyText = ordered.Body, ordered.Body
}
return descriptor, nil
}
func (a *TaskAdaptor) buildSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*requestDescriptor, error) {
if a.submit != nil {
return a.submit, nil
}
started := time.Now()
value, err := a.plugin.Engine.Call(c.Request.Context(), "buildSubmitRequest", a.submitContext(c, info))
if err != nil {
logger.LogDebug(
c,
"task_plugin subsystem=adaptor event=build_submit_failed plugin=%q stage=build_submit_request reason=hook_failed elapsed_ms=%d",
a.plugin.Meta.Key,
time.Since(started).Milliseconds(),
)
return nil, err
// buildSubmitRequest runs before quota is reserved, so a failed fetch
// costs nothing; requests that start vendor work stay the descriptor.
fetchContext := c.Request.Context()
if info.HasChannelMeta() {
fetchContext = pluginruntime.WithFetcher(fetchContext, a.hookFetcher(info.ChannelBaseUrl, info.ChannelSetting.Proxy))
}
var descriptor requestDescriptor
if err = convert(value, &descriptor); err != nil {
descriptor, err := a.requestDescriptor(fetchContext, "buildSubmitRequest", a.submitContext(c, info))
if err != nil {
reason := "hook_failed"
var invalid *pluginruntime.ResultError
if errors.As(err, &invalid) {
reason = "invalid_descriptor"
}
logger.LogDebug(
c,
"task_plugin subsystem=adaptor event=build_submit_failed plugin=%q stage=build_submit_request reason=invalid_descriptor elapsed_ms=%d",
"task_plugin subsystem=adaptor event=build_submit_failed plugin=%q stage=build_submit_request reason=%s elapsed_ms=%d",
a.plugin.Meta.Key,
reason,
time.Since(started).Milliseconds(),
)
return nil, err
@@ -1282,7 +1446,11 @@ func (a *TaskAdaptor) submitContext(c *gin.Context, info *relaycommon.RelayInfo)
}
}
if taskRequest, exists := c.Get("task_request"); exists {
routeRequest.RequestBody = jsonValue(taskRequest)
routeRequest.RequestBody = taskRequest
}
a.requestBodyText = nil
if text, exists := c.Get(pluginruntime.ContextKeyRequestBodyText); exists && a.plugin.Meta.PreservesJSONOrder() {
a.requestBodyText, _ = text.(json.RawMessage)
}
if c.Request != nil {
if routeRequest.Path == "" {
@@ -1309,8 +1477,8 @@ func (a *TaskAdaptor) submitContext(c *gin.Context, info *relaycommon.RelayInfo)
if form, err := common.ParseMultipartFormReusable(c); err == nil {
defer form.RemoveAll()
for field, headers := range form.File {
for _, header := range headers {
files = append(files, map[string]any{"ref": "request_file:" + field, "field": field, "filename": header.Filename, "mimeType": header.Header.Get("Content-Type"), "size": header.Size})
for index, header := range headers {
files = append(files, map[string]any{"ref": pluginruntime.FileReference(field, index), "field": field, "filename": header.Filename, "mimeType": header.Header.Get("Content-Type"), "size": header.Size})
}
}
}
@@ -1323,7 +1491,25 @@ func (a *TaskAdaptor) submitContext(c *gin.Context, info *relaycommon.RelayInfo)
a.files = append(a.files[:0], files...)
}
ctx := routeRequest.JSValue()
ctx["requestBody"] = jsonValue(routeRequest.RequestBody)
// Hooks never write into the request body, so the same object normalizes
// to the same value on every hook call of this request.
requestBody := routeRequest.RequestBody
source, isObject := requestBody.(map[string]any)
if a.requestBodyText != nil {
// Driver hooks read the request body, so the engine parses the text
// in place for each of them, members in order.
requestBody = pluginruntime.RawJSON(a.requestBodyText)
} else if isObject && a.hookRequestSource != nil && reflect.ValueOf(source).UnsafePointer() == reflect.ValueOf(a.hookRequestSource).UnsafePointer() {
requestBody = a.hookRequestBody
} else {
if !isPlainJSONValue(requestBody, 0) {
requestBody = jsonValue(requestBody)
}
if isObject {
a.hookRequestSource, a.hookRequestBody = source, requestBody
}
}
ctx["requestBody"] = requestBody
ctx["requestHeaders"] = requestHeaders
ctx["files"] = files
ctx["action"] = info.Action
@@ -1352,21 +1538,13 @@ func (a *TaskAdaptor) submitContext(c *gin.Context, info *relaycommon.RelayInfo)
ctx["upstreamModel"] = info.UpstreamModelName
ctx["baseUrl"] = info.ChannelBaseUrl
ctx["userSetting"] = info.UserSetting
proxy := ""
proxy = info.ChannelSetting.Proxy
if auth, err := resolveAuth(a.plugin.Meta.Auth, info.ApiKey, proxy); err == nil {
ctx["auth"] = auth
ctx["authHeader"] = auth["authHeader"]
if a.plugin.Meta.Auth.Type == "" || a.plugin.Meta.Auth.Type == "none" || a.plugin.Meta.Auth.Type == "api_key" {
ctx["apiKey"] = info.ApiKey
}
} else {
if err := a.applyUpstreamCredentials(ctx, info.ChannelType, info.ApiKey, info.ChannelSetting.Proxy); err != nil {
ctx["authError"] = err.Error()
}
return ctx
}
func (a *TaskAdaptor) usageRatios(ctx context.Context, modelName, hook string, args ...any) (map[string]float64, error) {
func (a *TaskAdaptor) usageRatios(ctx context.Context, models []string, hook string, args ...any) (map[string]float64, error) {
if !a.hasHook(ctx, hook) {
return nil, nil
}
@@ -1384,7 +1562,7 @@ func (a *TaskAdaptor) usageRatios(ctx context.Context, modelName, hook string, a
logger.LogDebug(ctx, "task_plugin subsystem=adaptor event=usage_hook_failed plugin=%q hook=%q reason=result_not_object elapsed_ms=%d", a.plugin.Meta.Key, hook, time.Since(started).Milliseconds())
return nil, fmt.Errorf("plugin usage hook must return an object")
}
ratios, err := a.validatedUsageRatios(facts, modelName)
_, ratios, err := a.validatedUsageRatios(facts, models...)
if err != nil {
logger.LogDebug(ctx, "task_plugin subsystem=adaptor event=usage_hook_failed plugin=%q hook=%q reason=invalid_usage elapsed_ms=%d", a.plugin.Meta.Key, hook, time.Since(started).Milliseconds())
return nil, err
@@ -1401,8 +1579,8 @@ func (a *TaskAdaptor) usageRatios(ctx context.Context, modelName, hook string, a
return ratios, nil
}
func (a *TaskAdaptor) validateResolvedUsageRequest(request any, modelName string) error {
schema, _ := a.plugin.Meta.UsageForModel(modelName)
func (a *TaskAdaptor) validateResolvedUsageRequest(request any, models ...string) error {
schema, _ := a.plugin.Meta.UsageForModels(models...)
return a.validateResolvedUsageValue(jsonValue(request), schema)
}
@@ -1410,11 +1588,21 @@ func (a *TaskAdaptor) validateResolvedUsageValue(value any, usageSchema map[stri
switch typed := value.(type) {
case map[string]any:
for key, item := range typed {
limit, canonical := canonicalUsageLimit(key)
// A duration of -1 asks the vendor to pick the length. Plugins that
// declare duration-auto@1 reserve the longest duration for it and
// settle on the usage the vendor reports; usage facts stay
// non-negative.
if canonical && limit == relaycommon.MaxTaskDurationSeconds && a.plugin.Meta.AcceptsAutoDuration() {
if number, _ := usageNumber(item, true); number == -1 {
continue
}
}
if schema, declared := usageSchema[key]; declared {
if _, err := validateUsageValue(item, schema, true); err != nil {
return err
}
} else if limit, canonical := canonicalUsageLimit(key); canonical {
} else if canonical {
if err := validateUsageLimit(item, limit, true); err != nil {
return err
}
@@ -1433,17 +1621,22 @@ func (a *TaskAdaptor) validateResolvedUsageValue(value any, usageSchema map[stri
return nil
}
func (a *TaskAdaptor) validatedUsageRatios(facts map[string]any, modelName string) (map[string]float64, error) {
usageSchema, _ := a.plugin.Meta.UsageForModel(modelName)
// validatedUsageRatios returns the normalized facts in a new map because the
// hook result may be a host value, such as the request body, that must stay
// unchanged.
func (a *TaskAdaptor) validatedUsageRatios(facts map[string]any, models ...string) (map[string]any, map[string]float64, error) {
usageSchema, _ := a.plugin.Meta.UsageForModels(models...)
validated := make(map[string]any, len(facts))
ratios := make(map[string]float64)
for key, value := range facts {
validated[key] = value
if schema, declared := usageSchema[key]; declared {
number, err := validateUsageValue(value, schema, false)
if err != nil {
return nil, err
return nil, nil, err
}
if schema.Type == "number" {
facts[key] = number
validated[key] = number
if number > 0 {
ratios[key] = number
}
@@ -1462,20 +1655,20 @@ func (a *TaskAdaptor) validatedUsageRatios(facts map[string]any, modelName strin
limit = relaycommon.MaxTaskDurationSeconds
}
if err := validateUsageNumberLimit(number, limit); err != nil {
return nil, err
return nil, nil, err
}
// Keep numeric facts usable by existing expressions even when the
// selected profile no longer declares that legacy extension field.
facts[key] = number
validated[key] = number
if number > 0 {
ratios[key] = number
}
}
return ratios, nil
return validated, ratios, nil
}
func (a *TaskAdaptor) validatedCompletionUsageFacts(facts any, modelName string) (map[string]any, error) {
usageSchema, _ := a.plugin.Meta.UsageForModel(modelName)
func (a *TaskAdaptor) validatedCompletionUsageFacts(facts any, models ...string) (map[string]any, error) {
usageSchema, _ := a.plugin.Meta.UsageForModels(models...)
if facts == nil {
return nil, nil
}
@@ -1602,8 +1795,10 @@ func usageNumber(value any, allowNumericString bool) (float64, bool) {
}
}
var usageKeySeparators = strings.NewReplacer("_", "", "-", "")
func canonicalUsageLimit(key string) (int, bool) {
normalized := strings.NewReplacer("_", "", "-", "").Replace(strings.ToLower(key))
normalized := usageKeySeparators.Replace(strings.ToLower(key))
switch normalized {
case "duration", "durationseconds", "second", "seconds":
return relaycommon.MaxTaskDurationSeconds, true
@@ -1615,13 +1810,13 @@ func canonicalUsageLimit(key string) (int, bool) {
}
func (a *TaskAdaptor) logRejectedUsage(hook string, _ error) {
common.SysError(fmt.Sprintf("task plugin %s rejected invalid %s billing facts", a.plugin.Meta.Key, hook))
common.SysError(common.LogText("task plugin %s rejected invalid %s billing facts", a.plugin.Meta.Key, hook))
}
func (a *TaskAdaptor) hasHook(ctx context.Context, hook string) bool {
has, err := a.plugin.Engine.HasExport(ctx, hook)
return err == nil && has
func (a *TaskAdaptor) hasHook(_ context.Context, hook string) bool {
return a.plugin.Engine.HasExport(hook)
}
func convert(value any, target any) error {
data, err := common.Marshal(value)
if err != nil {
@@ -1645,8 +1840,45 @@ func jsonValue(value any) any {
return normalized
}
// Already-parsed JSON and Sobek's plain exported values do not need another
// encode/decode pass. Copy containers to isolate hooks, preserving the codec's
// isPlainJSONValue reports whether hooks can take value as is: it reaches
// JavaScript exactly as the codec's decoding of it would, and the engine never
// writes back into it. Integers count, since hooks see the same numbers; a nil
// slice does not, since it would reach hooks as [] instead of null.
func isPlainJSONValue(value any, depth int) bool {
if depth > 64 {
return false
}
switch typed := value.(type) {
case nil, bool, int64, int:
return true
case string:
return utf8.ValidString(typed)
case float64:
return !math.IsNaN(typed) && !math.IsInf(typed, 0)
case map[string]any:
for key, item := range typed {
if !utf8.ValidString(key) || !isPlainJSONValue(item, depth+1) {
return false
}
}
return true
case []any:
if typed == nil {
return false
}
for _, item := range typed {
if !isPlainJSONValue(item, depth+1) {
return false
}
}
return true
default:
return false
}
}
// Already-parsed JSON and the engine's plain exported values do not need another
// encode/decode pass. Copy containers for host-side edits, preserving the codec's
// float64 numbers and nulls. Other Go types still use the configured codec.
func cloneJSONValue(value any, depth int) (any, bool) {
if depth > 64 {
+596 -36
View File
@@ -5,8 +5,10 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"math"
"mime"
"mime/multipart"
"net/http"
"net/http/httptest"
@@ -82,9 +84,10 @@ func TestTaskAdaptorRejectsDeprecatedClientResponse(t *testing.T) {
}
func TestTaskAdaptorBuildsMultipartFromOpaqueFileReference(t *testing.T) {
// The plugin forwards the client's Content-Type, whose boundary is not the one of the rebuilt body.
source := `
export const meta = {apiVersion:1,key:"multipart",name:"Multipart",version:"1.0.0",author:{name:"Test"},models:["m"],fetchMode:"per_task"};
export function buildSubmitRequest(ctx) { return {url:ctx.baseUrl+"/submit",bodyType:"multipart",parts:[{name:"model",value:"m"},{name:"input_reference",fileRef:ctx.files[0].ref}]}; }
export function buildSubmitRequest(ctx) { return {url:ctx.baseUrl+"/submit",headers:{"Content-Type":ctx.requestHeaders["Content-Type"]},bodyType:"multipart",parts:[{name:"model",value:"m"},{name:"input_reference",fileRef:ctx.files[0].ref}]}; }
export function parseSubmitResponse(ctx,r){return {taskId:"1"}} export function buildQueryRequest(){return {url:"https://example.com"}} export function parseTaskResult(){return {status:"SUCCESS"}}
`
plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
@@ -107,7 +110,13 @@ export function parseSubmitResponse(ctx,r){return {taskId:"1"}} export function
require.NoError(t, err)
requestBytes, err := io.ReadAll(body)
require.NoError(t, err)
reader := multipart.NewReader(bytes.NewReader(requestBytes), strings.TrimPrefix(c.GetHeader("Content-Type"), "multipart/form-data; boundary="))
upstreamRequest := httptest.NewRequest(http.MethodPost, "https://provider.example/submit", bytes.NewReader(requestBytes))
require.NoError(t, adaptor.BuildRequestHeader(c, upstreamRequest, info))
mediaType, params, err := mime.ParseMediaType(upstreamRequest.Header.Get("Content-Type"))
require.NoError(t, err)
assert.Equal(t, "multipart/form-data", mediaType)
require.NotEmpty(t, params["boundary"])
reader := multipart.NewReader(bytes.NewReader(requestBytes), params["boundary"])
form, err := reader.ReadForm(1024)
require.NoError(t, err)
assert.Equal(t, []string{"m"}, form.Value["model"])
@@ -129,7 +138,8 @@ export function buildSubmitRequest(ctx) {
prompt:"p",
image:{__fileRef:ctx.files[0].ref,encoding:"base64"},
nested:{items:[{__fileRef:ctx.files[0].ref,encoding:"dataUrl",mimeType:"image/png"}]},
dataUrl:{__fileRef:ctx.files[0].ref,encoding:"dataUrl"}
dataUrl:{__fileRef:ctx.files[0].ref,encoding:"dataUrl"},
refs:ctx.files.map(function(file){return file.ref;})
}};
}
export function parseSubmitResponse(){return {taskId:"1"}} export function buildQueryRequest(){return {url:"https://example.com"}} export function parseTaskResult(){return {status:"SUCCESS"}}
@@ -154,6 +164,57 @@ export function parseSubmitResponse(){return {taskId:"1"}} export function build
require.Len(t, items, 1)
assert.Equal(t, "data:image/png;base64,"+encoded, items[0])
assert.Equal(t, "data:image/jpeg;base64,"+encoded, decoded["dataUrl"])
assert.Equal(t, []any{"request_file:input_reference"}, decoded["refs"])
t.Run("repeated field files are addressed by index", func(t *testing.T) {
second := base64.StdEncoding.EncodeToString([]byte("second-bytes"))
source := `
export const meta = {apiVersion:1,key:"json-inline-repeat",name:"JSON Inline Repeat",version:"1.0.0",author:{name:"Test"},models:["m"],fetchMode:"per_task"};
export function buildSubmitRequest(ctx) {
return {url:ctx.baseUrl+"/submit",body:{
refs:ctx.files.map(function(file){return file.ref;}),
images:ctx.files.map(function(file){return {__fileRef:file.ref,encoding:"base64"};}),
missing:{__fileRef:"request_file:image[]#2",encoding:"base64"}
}};
}
export function parseSubmitResponse(){return {taskId:"1"}} export function buildQueryRequest(){return {url:"https://example.com"}} export function parseTaskResult(){return {status:"SUCCESS"}}
`
plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
require.NoError(t, err)
adaptor := New(plugin)
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example"}, TaskRelayInfo: &relaycommon.TaskRelayInfo{}}
adaptor.Init(info)
var input bytes.Buffer
writer := multipart.NewWriter(&input)
for _, content := range []string{fileBytes, "second-bytes"} {
file, createErr := writer.CreateFormFile("image[]", "ref.png")
require.NoError(t, createErr)
_, err = file.Write([]byte(content))
require.NoError(t, err)
}
require.NoError(t, writer.Close())
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/edits", bytes.NewReader(input.Bytes()))
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
c.Set("task_request", map[string]any{"prompt": "p"})
_, err = adaptor.BuildRequestBody(c, info)
require.ErrorContains(t, err, `unknown file reference "request_file:image[]#2"`, "an index beyond the uploaded files is rejected")
source = strings.Replace(source, `,
missing:{__fileRef:"request_file:image[]#2",encoding:"base64"}`, "", 1)
plugin, err = pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
require.NoError(t, err)
adaptor = New(plugin)
adaptor.Init(info)
body, err := adaptor.BuildRequestBody(c, info)
require.NoError(t, err)
requestBytes, err := io.ReadAll(body)
require.NoError(t, err)
var decoded map[string]any
require.NoError(t, common.Unmarshal(requestBytes, &decoded))
assert.Equal(t, []any{"request_file:image[]", "request_file:image[]#1"}, decoded["refs"])
assert.Equal(t, []any{encoded, second}, decoded["images"])
})
}
func TestTaskAdaptorJSONFilePlaceholderErrors(t *testing.T) {
@@ -351,6 +412,95 @@ export function buildSubmitRequest(ctx){return {url:ctx.baseUrl+"/submit"}} expo
assert.Contains(t, taskErr.Message, "must not return renderer")
}
func TestTaskAdaptorMasksChannelAddressInBuildFailure(t *testing.T) {
source := `
export const meta = {apiVersion:1,key:"build-fail",name:"Build Fail",version:"1.0.0",author:{name:"Test"},models:["m"],fetchMode:"per_task",allowedHosts:["assets.corp.internal"]};
export function buildSubmitRequest(ctx){throw new Error("utils.fetch to private.example.com failed: dial tcp: lookup private.example.com: no such host; utils.fetch to assets.corp.internal failed: dial tcp: lookup assets.corp.internal: no such host")}
export function parseSubmitResponse(){return {taskId:"one"}} export function buildQueryRequest(){return {}} export function parseTaskResult(){return {status:"SUCCESS"}}
`
plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
require.NoError(t, err)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/video/generations", nil)
c.Set("task_request", map[string]any{"model": "m"})
common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, "https://private.example.com")
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://private.example.com"}, TaskRelayInfo: &relaycommon.TaskRelayInfo{}, OriginModelName: "m"}
adaptor := New(plugin)
adaptor.Init(info)
taskErr := adaptor.ValidateRequestAndSetAction(c, info)
require.NotNil(t, taskErr)
assert.Equal(t, "plugin_request_invalid", taskErr.Code)
assert.True(t, taskErr.LocalError)
assert.Contains(t, taskErr.Message, "no such host")
assert.NotContains(t, taskErr.Message, "private.example.com")
assert.NotContains(t, taskErr.Message, "assets.corp.internal")
}
const orderedProtocolPlugin = `
export const meta = {apiVersion:1,key:"ordered",name:"Ordered",version:"1.0.0",author:{name:"Test"},models:["m"],fetchMode:"per_task",requiredCapabilities:["json-order@1"],protocols:[{name:"openai_responses",supports:["sync"]}]};
export const protocols = {openai_responses:{decodeRequest:function(ctx){const b = ctx.body.value; return {kind:"submit",model:ctx.model,requestBody:{model:b.model,state:b.state,questions:b.questions}};},renderFinal:function(){return {};}}};
export function buildSubmitRequest(ctx){const b = ctx.requestBody; return {url:ctx.baseUrl+"/submit",body:{model:b.model,state:b.state,questions:b.questions}}}
export function parseSubmitResponse(){return {taskId:"one"}} export function buildQueryRequest(){return {}} export function parseTaskResult(){return {status:"SUCCESS"}}
`
// A plugin declaring json-order@1 forwards the members in the order the client
// sent them, through the final decode, the request body hooks receive and the
// upstream body, while billing bounds still read the decoded value; other
// plugins keep the host codec's sorted encoding.
func TestTaskAdaptorJSONOrderCapability(t *testing.T) {
client := `{"model":"m","state":{"zeta":"1","alpha":"2","mid":"&<>"},"questions":{"q2":{"type":"noul"},"q1":{"type":"noul"}}}`
undeclared := strings.Replace(orderedProtocolPlugin, `requiredCapabilities:["json-order@1"],`, "", 1)
cases := []struct {
name string
source string
client string
want string
status int
}{
{name: "declared keeps the client's order", source: orderedProtocolPlugin, client: client,
want: `{"model":"m","state":{"zeta":"1","alpha":"2","mid":"&<>"},"questions":{"q2":{"type":"noul"},"q1":{"type":"noul"}}}`},
{name: "undeclared stays sorted", source: undeclared, client: client,
want: `{"model":"m","questions":{"q1":{"type":"noul"},"q2":{"type":"noul"}},"state":{"alpha":"2","mid":"\u0026\u003c\u003e","zeta":"1"}}`},
{name: "declared still bounds billing quantities", source: orderedProtocolPlugin,
client: `{"model":"m","state":{"duration":3601},"questions":{}}`, status: http.StatusBadRequest},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
plugin, err := pluginruntime.NewRegistry().Register(tc.source, pluginruntime.Options{})
require.NoError(t, err)
var value any
require.NoError(t, common.Unmarshal([]byte(tc.client), &value))
protocolContext := pluginruntime.ProtocolRequestContext{
RouteRequestContext: pluginruntime.RouteRequestContext{Body: map[string]any{"kind": "json", "value": value}, BodyText: json.RawMessage(tc.client)},
Protocol: "openai_responses", Model: "m",
}
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
c.Set(pluginruntime.ContextKeyPinnedEndpoint, pluginruntime.PinnedEndpoint{Plugin: plugin, Protocol: "openai_responses", Model: "m"})
c.Set(pluginruntime.ContextKeyProtocolRequest, protocolContext)
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example"}, TaskRelayInfo: &relaycommon.TaskRelayInfo{}, OriginModelName: "m"}
adaptor := New(plugin)
adaptor.Init(info)
taskErr := adaptor.ValidateRequestAndSetAction(c, info)
if tc.status != 0 {
require.NotNil(t, taskErr)
assert.Equal(t, tc.status, taskErr.StatusCode)
assert.Equal(t, "plugin_usage_invalid", taskErr.Code)
return
}
require.Nil(t, taskErr)
body, err := adaptor.BuildRequestBody(c, info)
require.NoError(t, err)
raw, err := io.ReadAll(body)
require.NoError(t, err)
assert.Equal(t, tc.want, string(raw))
})
}
}
func TestTaskAdaptorBuildContentRequestHookAndMissingFallback(t *testing.T) {
source := strings.Replace(mockPlugin, `export function listArtifacts() { return []; }
export function buildContentRequest() { throw new Error("artifact_not_found"); }`, `export function listArtifacts(task) { return [{key: "video", type: "video", mimeType: "video/mp4"}]; }
@@ -376,7 +526,7 @@ export function buildContentRequest(ctx) {
artifacts, err := adaptor.ListArtifacts(task)
require.NoError(t, err)
require.Equal(t, []channel.TaskArtifact{{Key: "video", Type: "video", MimeType: "video/mp4"}}, artifacts)
descriptor, err := adaptor.BuildContentRequest(task, "video", channel.TaskArtifactClientRequest{Method: http.MethodHead})
descriptor, err := adaptor.BuildContentRequest(t.Context(), task, "video", channel.TaskArtifactClientRequest{Method: http.MethodHead})
require.NoError(t, err)
require.NotNil(t, descriptor)
assert.Equal(t, "https://provider.example/content/video", descriptor.URL)
@@ -396,7 +546,7 @@ export function parseTaskResult(){return {status:"SUCCESS"};}
artifacts, err = fallback.ListArtifacts(&model.Task{})
require.NoError(t, err)
assert.Nil(t, artifacts)
descriptor, err = fallback.BuildContentRequest(&model.Task{}, "video", channel.TaskArtifactClientRequest{Method: http.MethodGet})
descriptor, err = fallback.BuildContentRequest(t.Context(), &model.Task{}, "video", channel.TaskArtifactClientRequest{Method: http.MethodGet})
require.NoError(t, err)
assert.Nil(t, descriptor)
}
@@ -437,6 +587,7 @@ export function buildContentRequest(ctx) { return {url:"https://cdn.example/vide
adaptor := New(plugin)
adaptor.Init(&relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example"}})
descriptor, err := adaptor.BuildContentRequest(
t.Context(),
&model.Task{TaskID: "task", Data: []byte(`{}`)},
"video",
channel.TaskArtifactClientRequest{Method: http.MethodGet},
@@ -447,6 +598,59 @@ export function buildContentRequest(ctx) { return {url:"https://cdn.example/vide
assert.Equal(t, "https://cdn.example/video.mp4", descriptor.URL)
}
func TestTaskAdaptorHookFetchReachesRequestHostsOnly(t *testing.T) {
service.InitHttpClient()
extra := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"from":"extra"}`))
}))
defer extra.Close()
base := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/info":
assert.Equal(t, "Bearer key", r.Header.Get("Authorization"))
_, _ = w.Write([]byte(`{"from":"base"}`))
case "/moved":
http.Redirect(w, r, extra.URL+"/elsewhere", http.StatusFound)
default:
t.Errorf("unexpected request %s", r.URL.Path)
}
}))
defer base.Close()
source := strings.Replace(mockPlugin, `fetchMode: "per_task",`, `fetchMode: "per_task", allowedHosts: ["`+strings.TrimPrefix(extra.URL, "http://")+`"],`, 1)
source = strings.Replace(source, `export function listArtifacts() { return []; }
export function buildContentRequest() { throw new Error("artifact_not_found"); }`, `export function listArtifacts() { return [{key: "video", type: "video"}]; }
export function buildContentRequest(ctx) {
const base = utils.fetch({url: ctx.baseUrl + "/info", headers: {Authorization: "Bearer " + ctx.apiKey}});
const extra = utils.fetch({url: "http://" + meta.allowedHosts[0] + "/info"});
const moved = utils.fetch({url: ctx.baseUrl + "/moved"});
let denied = "";
try { utils.fetch({url: "https://other.example/info"}); } catch (e) { denied = e.message; }
return {url: ctx.baseUrl + "/content", method: "GET", headers: {
"X-Base": base.body.from, "X-Extra": extra.body.from, "X-Moved": moved.status + " " + moved.headers.Location, "X-Denied": denied,
}};
}`, 1)
source = strings.Replace(source, `render: function(ctx, task) { return {id: task.task_id, status: "completed"}; }`,
`render: function(ctx, task) { return utils.fetch({url: "https://provider.example/info"}).body; }`, 1)
plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
require.NoError(t, err)
adaptor := New(plugin)
adaptor.Init(&relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: base.URL, ApiKey: "key"}})
descriptor, err := adaptor.BuildContentRequest(t.Context(), &model.Task{TaskID: "task", Data: []byte(`{}`)}, "video", channel.TaskArtifactClientRequest{Method: http.MethodGet})
require.NoError(t, err)
require.NotNil(t, descriptor)
assert.Equal(t, map[string]string{
"X-Base": "base",
"X-Extra": "extra",
"X-Moved": "302 " + extra.URL + "/elsewhere",
"X-Denied": `utils.fetch to other.example failed: plugin request host "other.example" is not allowed`,
}, descriptor.Headers)
// Renderers run on every client read and may not fetch.
_, err = adaptor.ConvertToOpenAIVideo(&model.Task{TaskID: "task_public", Status: model.TaskStatusSuccess})
assert.ErrorContains(t, err, "utils.fetch is not available in protocols.openai_video.render")
}
func TestTaskAdaptorMapsJSContract(t *testing.T) {
gin.SetMode(gin.TestMode)
service.InitHttpClient()
@@ -610,13 +814,22 @@ func TestTaskAdaptorPreservesSoraVideoResponseFields(t *testing.T) {
}
func TestTaskAdaptorRejectsNonObjectOpenAIVideoRendererOutput(t *testing.T) {
for _, value := range []string{"null", "[]", `"video"`, "42", "false"} {
for value, message := range map[string]string{
"null": "plugin returned an invalid OpenAI video object",
"undefined": "plugin returned an invalid OpenAI video object",
"[]": "plugin returned an invalid OpenAI video object",
`"video"`: "plugin returned an invalid OpenAI video object",
"42": "plugin returned an invalid OpenAI video object",
"false": "plugin returned an invalid OpenAI video object",
"{seconds: NaN}": "json: unsupported value: NaN",
"[Infinity]": "json: unsupported value: +Inf",
} {
t.Run(value, func(t *testing.T) {
source := strings.Replace(mockPlugin, `return {id: task.task_id, status: "completed"};`, "return "+value+";", 1)
plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
require.NoError(t, err)
_, err = New(plugin).ConvertToOpenAIVideo(&model.Task{TaskID: "task_public"})
require.ErrorContains(t, err, "invalid OpenAI video object")
require.EqualError(t, err, message)
})
}
}
@@ -808,6 +1021,53 @@ export function extractUsageOnComplete(task, result, body) { return (body || {})
assert.Nil(t, ratios)
})
t.Run("duration -1 passes only for plugins that declare duration-auto@1", func(t *testing.T) {
declaring, err := pluginruntime.NewRegistry().Register(strings.Replace(source, `fetchMode: "per_task",`, `fetchMode: "per_task", requiredCapabilities: ["duration-auto@1"],`, 1), pluginruntime.Options{})
require.NoError(t, err)
for _, tc := range []struct {
name string
declaring bool
body map[string]any
accepted bool
}{
{"undeclared plugins keep rejecting it", false, map[string]any{"metadata": map[string]any{"duration": -1}}, false},
{"vendor parameters and declared usage fields", true, map[string]any{
"duration": -1,
"metadata": map[string]any{"seconds": "-1", "parameters": map[string]any{"durationSeconds": -1}},
}, true},
{"other negative durations", true, map[string]any{"metadata": map[string]any{"duration": -2}}, false},
{"durations above the host limit", true, map[string]any{"metadata": map[string]any{"duration": relaycommon.MaxTaskDurationSeconds + 1}}, false},
{"negative counts", true, map[string]any{"metadata": map[string]any{"count": -1}}, false},
} {
t.Run(tc.name, func(t *testing.T) {
adaptor, context, info := newRequest(t, tc.body)
if tc.declaring {
adaptor = New(declaring)
adaptor.Init(info)
}
taskErr := adaptor.ValidateRequestAndSetAction(context, info)
if tc.accepted {
assert.Nil(t, taskErr)
return
}
require.NotNil(t, taskErr)
assert.Equal(t, "plugin_usage_invalid", taskErr.Code)
})
}
adaptor, context, info := newRequest(t, map[string]any{
"metadata": map[string]any{"duration": -1},
"hookUsageEntries": []any{map[string]any{"name": "duration", "value": -1}},
})
adaptor = New(declaring)
adaptor.Init(info)
require.Nil(t, adaptor.ValidateRequestAndSetAction(context, info))
_, err = adaptor.ExtractUsageFactsValidated(context, info)
assert.Error(t, err, "usage facts stay non-negative")
_, err = adaptor.EstimateBillingValidated(context, info)
assert.Error(t, err, "billing ratios stay non-negative")
})
t.Run("declared token facts use int32 saturation instead of duration cap", func(t *testing.T) {
adaptor, context, info := newRequest(t, map[string]any{
"hookUsageEntries": []any{
@@ -1100,6 +1360,26 @@ func TestSubmitContextOriginTasksNilDataOnInvalidJSON(t *testing.T) {
assert.Nil(t, originTasks[0]["data"])
}
func TestSubmitContextNormalizesRequestBodyOnEveryCall(t *testing.T) {
plugin, err := pluginruntime.NewRegistry().Register(mockPlugin, pluginruntime.Options{})
require.NoError(t, err)
adaptor := New(plugin)
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example"}, TaskRelayInfo: &relaycommon.TaskRelayInfo{}}
adaptor.Init(info)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
c.Set("task_request", map[string]any{"prompt": string([]byte{0xff}), "count": int64(math.MaxInt64)})
normalized := map[string]any{"prompt": "�", "count": float64(math.MaxInt64)}
assert.Equal(t, normalized, adaptor.submitContext(c, info)["requestBody"])
assert.Equal(t, normalized, adaptor.submitContext(c, info)["requestBody"])
assert.Equal(t, normalized, adaptor.submitContext(nil, info)["requestBody"])
replaced := map[string]any{"prompt": "second", "count": int64(2)}
c.Set("task_request", replaced)
assert.Equal(t, replaced, adaptor.submitContext(c, info)["requestBody"])
}
func TestTaskAdaptorRejectsRequestHostOverride(t *testing.T) {
source := strings.Replace(mockPlugin, `ctx.baseUrl + "/submit"`, `"https://attacker.example/steal"`, 1)
plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{Key: "mock-task", Version: "1.0.0"})
@@ -1464,10 +1744,10 @@ export function extractUsageOnComplete(ctx,result,body){return body.usage;}
`
plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
require.NoError(t, err)
newRequest := func(t *testing.T, upstream string, body map[string]any) (*TaskAdaptor, *gin.Context, *relaycommon.RelayInfo) {
newRequest := func(t *testing.T, origin, upstream string, body map[string]any) (*TaskAdaptor, *gin.Context, *relaycommon.RelayInfo) {
t.Helper()
info := &relaycommon.RelayInfo{
OriginModelName: "public-alias",
OriginModelName: origin,
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: upstream, ChannelBaseUrl: "https://provider.example",
},
@@ -1482,18 +1762,20 @@ export function extractUsageOnComplete(ctx,result,body){return body.usage;}
}
for _, tc := range []struct {
name, upstream, rewrite, mode string
units float64
wantError bool
name, origin, upstream, rewrite, mode string
units float64
wantError bool
}{
{"mapped image", "image", "", "image", 2, false},
{"mapped video token units", "video", "", "video", 500000, false},
{"rewritten model", "image", "video", "video", 500000, false},
{"image count ceiling", "image", "", "image", float64(dto.MaxImageN + 1), true},
{"profile enum", "image", "", "video", 2, true},
{"mapped image", "public-alias", "image", "", "image", 2, false},
{"mapped video token units", "public-alias", "video", "", "video", 500000, false},
{"rewritten model", "public-alias", "image", "video", "video", 500000, false},
{"image count ceiling", "public-alias", "image", "", "image", float64(dto.MaxImageN + 1), true},
{"profile enum", "public-alias", "image", "", "video", 2, true},
{"endpoint upstream keeps the declared origin profile", "image", "ep-endpoint", "", "image", 2, false},
{"endpoint upstream enforces the origin count ceiling", "image", "ep-endpoint", "", "image", float64(dto.MaxImageN + 1), true},
} {
t.Run(tc.name, func(t *testing.T) {
adaptor, c, info := newRequest(t, tc.upstream, map[string]any{
adaptor, c, info := newRequest(t, tc.origin, tc.upstream, map[string]any{
"hookUnits": tc.units, "hookMode": tc.mode, "rewriteTo": tc.rewrite,
})
require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))
@@ -1514,7 +1796,7 @@ export function extractUsageOnComplete(ctx,result,body){return body.usage;}
}
t.Run("request revalidated after model rewrite", func(t *testing.T) {
adaptor, c, info := newRequest(t, "video", map[string]any{
adaptor, c, info := newRequest(t, "public-alias", "video", map[string]any{
"rewriteTo": "image", "metadata": map[string]any{"units": dto.MaxImageN + 1},
})
taskErr := adaptor.ValidateRequestAndSetAction(c, info)
@@ -1523,7 +1805,7 @@ export function extractUsageOnComplete(ctx,result,body){return body.usage;}
})
t.Run("request enum belongs to the rewritten model", func(t *testing.T) {
adaptor, c, info := newRequest(t, "video", map[string]any{
adaptor, c, info := newRequest(t, "public-alias", "video", map[string]any{
"rewriteTo": "image", "mode": "image", "units": 2,
})
require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))
@@ -1531,7 +1813,7 @@ export function extractUsageOnComplete(ctx,result,body){return body.usage;}
})
t.Run("completion selects each persisted model including legacy origin fallback", func(t *testing.T) {
adaptor, _, _ := newRequest(t, "image", map[string]any{})
adaptor, _, _ := newRequest(t, "public-alias", "image", map[string]any{})
for _, tc := range []struct {
upstream, origin, mode string
units float64
@@ -1542,6 +1824,8 @@ export function extractUsageOnComplete(ctx,result,body){return body.usage;}
{"", "video", "video", 500000, true},
{"image", "public-alias", "image", float64(dto.MaxImageN + 1), false},
{"video", "public-alias", "image", 1, false},
{"ep-endpoint", "image", "image", 2, true},
{"ep-endpoint", "image", "image", float64(dto.MaxImageN + 1), false},
} {
task := &model.Task{Properties: model.Properties{OriginModelName: tc.origin, UpstreamModelName: tc.upstream}}
body, err := common.Marshal(map[string]any{"usage": map[string]any{"units": tc.units, "mode": tc.mode}})
@@ -1561,7 +1845,7 @@ export function extractUsageOnComplete(ctx,result,body){return body.usage;}
`export function extractUsageOnComplete(ctx,result,body){return body.usage;}`,
`export function extractUsageOnComplete(){return {legacyRatio:3};}`, 1), pluginruntime.Options{})
require.NoError(t, err)
_, _, info := newRequest(t, "image", map[string]any{})
_, _, info := newRequest(t, "public-alias", "image", map[string]any{})
adaptor := New(updated)
adaptor.Init(info)
result, err := adaptor.ParseTaskResult(&model.Task{Properties: model.Properties{UpstreamModelName: "image"}},
@@ -1574,20 +1858,23 @@ export function extractUsageOnComplete(ctx,result,body){return body.usage;}
})
t.Run("mixed batch selects schema by task rather than adaptor", func(t *testing.T) {
adaptor, _, _ := newRequest(t, "image", map[string]any{})
adaptor, _, _ := newRequest(t, "public-alias", "image", map[string]any{})
tasks := []*model.Task{
{TaskID: "video-task", Properties: model.Properties{UpstreamModelName: "video"}},
{TaskID: "image-task", Properties: model.Properties{UpstreamModelName: "image"}},
{TaskID: "invalid-image", Properties: model.Properties{UpstreamModelName: "image"}},
{TaskID: "endpoint-image", Properties: model.Properties{OriginModelName: "image", UpstreamModelName: "ep-endpoint"}},
}
body := []byte(`{"items":[
{"taskId":"image-task","status":"SUCCESS","data":{"usage":{"units":2,"mode":"image"}}},
{"taskId":"video-task","status":"SUCCESS","data":{"usage":{"units":500000,"mode":"video"}}},
{"taskId":"invalid-image","status":"SUCCESS","data":{"usage":{"units":129,"mode":"image"}}}
{"taskId":"invalid-image","status":"SUCCESS","data":{"usage":{"units":129,"mode":"image"}}},
{"taskId":"endpoint-image","status":"SUCCESS","data":{"usage":{"units":129,"mode":"image"}}}
]}`)
results, err := adaptor.ParseBatchResult(tasks, &http.Response{StatusCode: http.StatusOK}, body)
require.NoError(t, err)
require.Len(t, results, 3)
require.Len(t, results, 4)
assert.Nil(t, results["endpoint-image"].TaskInfo.UsageFacts, "endpoint upstream must keep the origin profile's count ceiling")
assert.Equal(t, map[string]any{"units": 500000.0, "mode": "video"}, results["video-task"].TaskInfo.UsageFacts)
assert.Equal(t, map[string]any{"units": 2.0, "mode": "image"}, results["image-task"].TaskInfo.UsageFacts)
assert.Nil(t, results["invalid-image"].TaskInfo.UsageFacts)
@@ -1698,22 +1985,27 @@ func TestTaskSubmitStreamIdleTimeout(t *testing.T) {
}
func TestPluginJSONValuesPreserveCodecNormalizationAndIsolation(t *testing.T) {
for _, value := range []any{
map[string]any{"units": int64(3), "nested": []any{true, "<image> / 图像", math.Copysign(0, -1), nil}},
map[string]any{"empty": []any{}, "null": []any(nil), "object": map[string]any(nil)},
map[string]any{"large": int64(math.MaxInt64), "invalid UTF-8": string([]byte{0xff, 0xfe})},
map[string]any{string([]byte{0xff}): "invalid key"},
map[string]any{"bytes": []byte{0, 1, 255}, "number": json.Number("9007199254740993")},
json.RawMessage(`{"units":2,"enabled":false}`),
struct {
for _, tc := range []struct {
value any
plain bool
}{
{map[string]any{"units": int64(3), "nested": []any{true, "<image> / 图像", math.Copysign(0, -1), nil}}, true},
{map[string]any{"empty": []any{}, "object": map[string]any(nil)}, true},
{map[string]any{"null": []any(nil)}, false},
{map[string]any{"large": int64(math.MaxInt64), "invalid UTF-8": string([]byte{0xff, 0xfe})}, false},
{map[string]any{string([]byte{0xff}): "invalid key"}, false},
{map[string]any{"bytes": []byte{0, 1, 255}, "number": json.Number("9007199254740993")}, false},
{json.RawMessage(`{"units":2,"enabled":false}`), false},
{struct {
Units int `json:"units"`
}{Units: 0},
}{Units: 0}, false},
} {
encoded, err := common.Marshal(value)
encoded, err := common.Marshal(tc.value)
require.NoError(t, err)
var expected any
require.NoError(t, common.Unmarshal(encoded, &expected))
assert.Equal(t, expected, jsonValue(value))
assert.Equal(t, expected, jsonValue(tc.value))
assert.Equal(t, tc.plain, isPlainJSONValue(tc.value, 0))
}
source := map[string]any{"items": []any{map[string]any{"label": "original"}}}
copy := jsonValue(source).(map[string]any)
@@ -1721,6 +2013,86 @@ func TestPluginJSONValuesPreserveCodecNormalizationAndIsolation(t *testing.T) {
assert.Equal(t, "original", source["items"].([]any)[0].(map[string]any)["label"])
}
func TestRequestDescriptorDecodingMatchesCodec(t *testing.T) {
engine, err := pluginruntime.Compile(`
class Descriptor { constructor() { this.url = "u"; this.method = "PUT"; } }
let deep = "leaf";
for (let i = 0; i < 70; i++) deep = [deep];
const results = {
"plain body": () => ({url: "https://provider.example/submit", method: "POST", headers: {"X-Plugin": "submit"}, body: {
units: 3, large: 2 ** 60, zero: -0, text: "<image> & 图像", missing: undefined,
items: [null, true, 1.5, {}, [], undefined],
}}),
"argument body": (arg) => ({url: "u", body: arg}),
"argument headers": (arg) => ({url: "u", headers: arg.headers}),
"changed argument body": (arg) => { arg.units = 4; return {url: "u", body: arg}; },
"string body": () => ({url: "u", body: '{"raw":true}'}),
"null body": () => ({url: "u", body: null}),
"undefined body": () => ({url: "u", body: undefined}),
"no body": () => ({url: "u", credentialless: true}),
"class instance": () => new Descriptor(),
"null prototype": () => Object.assign(Object.create(null), {url: "u", body: {a: 1}}),
"getter body": () => ({url: "u", get body() { return {from: "getter"}; }}),
"date body": () => ({url: "u", body: {at: new Date(0)}}),
"typed array body": () => ({url: "u", body: new Uint8Array([0, 1, 255])}),
"deep body": () => ({url: "u", body: deep}),
"capitalized body key": () => ({url: "u", Body: {from: "Body"}}),
"body and capitalized body": () => ({url: "u", body: {from: "body"}, Body: {from: "Body"}, BODY: "upper"}),
"case-insensitive other field": () => ({URL: "u", Method: "PUT", body: "text"}),
"NaN body": () => ({url: "u", body: {ratio: NaN}}),
"infinite method": () => ({url: "u", method: Infinity, body: {}}),
"cyclic body": () => { const body = {}; body.self = body; return {url: "u", body}; },
"invalid header": () => ({url: "u", headers: {x: 1}, body: {}}),
"invalid parts": () => ({url: "u", parts: "none", body: {}}),
"not an object": () => "descriptor",
"throwing getter": () => ({url: "u", get body() { throw new Error("getter failed"); }}),
};
export function build(name, arg) { return results[name](arg); }
`, pluginruntime.Options{})
require.NoError(t, err)
argument := func() map[string]any {
return map[string]any{
"units": int64(3), "large": int64(math.MaxInt64), "zero": math.Copysign(0, -1),
"headers": map[string]any{"X-Plugin": "submit"},
"items": []any{nil, true, 1.5, map[string]any{"label": "original"}, []any{}},
}
}
for _, name := range []string{
"plain body", "argument body", "argument headers", "changed argument body", "string body", "null body",
"undefined body", "no body", "class instance", "null prototype", "getter body", "date body",
"typed array body", "deep body", "capitalized body key", "body and capitalized body",
"case-insensitive other field", "NaN body", "infinite method", "cyclic body", "invalid header",
"invalid parts", "not an object", "throwing getter",
} {
var expected requestDescriptor
value, expectedErr := engine.Call(t.Context(), "build", name, argument())
hookFailed := expectedErr != nil
if !hookFailed {
expectedErr = convert(value, &expected)
}
var decoded requestDescriptor
err := engine.CallInto(t.Context(), &decoded, "build", name, argument())
if expectedErr != nil {
require.EqualError(t, err, expectedErr.Error(), name)
var invalid *pluginruntime.ResultError
assert.Equal(t, !hookFailed, errors.As(err, &invalid), name)
continue
}
require.NoError(t, err, name)
assert.Equal(t, expected, decoded, name)
expectedJSON, err := common.Marshal(expected)
require.NoError(t, err, name)
decodedJSON, err := common.Marshal(decoded)
require.NoError(t, err, name)
assert.Equal(t, string(expectedJSON), string(decodedJSON), name)
}
source := argument()
var decoded requestDescriptor
require.NoError(t, engine.CallInto(t.Context(), &decoded, "build", "argument body", source))
decoded.Body.(map[string]any)["items"].([]any)[3].(map[string]any)["label"] = "changed"
assert.Equal(t, "original", source["items"].([]any)[3].(map[string]any)["label"])
}
func TestTaskSubmitHooksReceiveIndependentRequestSnapshots(t *testing.T) {
source := `
export const meta={apiVersion:1,key:"request-copy",name:"Request copy",version:"1.0.0",author:{name:"Test"},models:["copy"],fetchMode:"per_task",usageSchema:{units:{type:"number",unit:"count"}}};
@@ -1752,6 +2124,39 @@ export function parseTaskResult(){return {status:"SUCCESS"};}
assert.Equal(t, "original", request["nested"].(map[string]any)["label"])
}
func TestTaskUsageValidationLeavesReturnedHostRequestUnchanged(t *testing.T) {
// Returning ctx.requestBody hands back the host request map itself, so
// normalized facts must be built in a new map.
source := `
export const meta={apiVersion:1,key:"request-usage",name:"Request usage",version:"1.0.0",author:{name:"Test"},models:["usage"],fetchMode:"per_task",usageSchema:{units:{type:"number",unit:"count"}}};
export function buildSubmitRequest(ctx){return {url:ctx.baseUrl+"/submit",body:ctx.requestBody};}
export function extractUsage(ctx){return ctx.requestBody;}
export function parseSubmitResponse(){return {taskId:"usage"};}
export function buildQueryRequest(ctx){return {url:ctx.baseUrl+"/query"};}
export function parseTaskResult(){return {status:"SUCCESS"};}
`
plugin, err := pluginruntime.CompilePlugin(source, pluginruntime.Options{})
require.NoError(t, err)
request := map[string]any{"units": int64(2)}
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/submit", nil)
c.Set("task_request", request)
info := &relaycommon.RelayInfo{OriginModelName: "usage", ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "usage", ChannelBaseUrl: "https://provider.example"}, TaskRelayInfo: &relaycommon.TaskRelayInfo{}}
adaptor := New(plugin)
adaptor.Init(info)
require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))
facts, err := adaptor.ExtractUsageFactsValidated(c, info)
require.NoError(t, err)
assert.Equal(t, map[string]any{"units": float64(2)}, facts)
assert.Equal(t, int64(2), request["units"])
estimateRequest := map[string]any{"units": int64(2)}
c.Set("task_request", estimateRequest)
ratios, err := adaptor.EstimateBillingValidated(c, info)
require.NoError(t, err)
assert.Equal(t, map[string]float64{"units": 2}, ratios)
assert.Equal(t, int64(2), estimateRequest["units"])
}
func TestTaskSubmitDeltaStreamContract(t *testing.T) {
source := strings.Replace(documentStreamPlugin, `submitResponseTypes:["json","sse"],`, `submitResponseTypes:["json","sse"],requiredCapabilities:["submit-sse-delta@1"],`, 1) + `
export function parseSubmitEventDelta(ctx,event,previous) {
@@ -1808,6 +2213,26 @@ export function parseSubmitEventDelta(ctx,event,previous) {
assert.Equal(t, map[string]any{"units": float64(0)}, parsed.Immediate.UsageFacts)
})
}
t.Run("delta hook exported without the declaration", func(t *testing.T) {
undeclared := strings.Replace(source, `requiredCapabilities:["submit-sse-delta@1"],`, "", 1)
undeclared = strings.Replace(undeclared, "export function parseSubmitEvent(", "function unusedSnapshotHook(", 1)
plugin, err := pluginruntime.CompilePlugin(undeclared, pluginruntime.Options{})
require.NoError(t, err)
require.Empty(t, plugin.Meta.RequiredCapabilities)
info := &relaycommon.RelayInfo{OriginModelName: "alias", ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "document", ChannelBaseUrl: "https://provider.example"}, TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "public-document"}}
adaptor := New(plugin)
adaptor.Init(info)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/compile", nil)
c.Set("task_request", map[string]any{})
require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))
stream := "data: " + first + "\n\ndata: " + last + "\n\n"
response := &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(stream))}
parsed, taskErr := adaptor.ParseResponse(c, response, info)
require.Nil(t, taskErr)
assert.JSONEq(t, `{"document":"helloworld","units":0}`, string(parsed.TaskData))
})
}
func TestAlibabaSubmitDeltaDoesNotMutateControlState(t *testing.T) {
@@ -1841,3 +2266,138 @@ func TestAlibabaSubmitDeltaDoesNotMutateControlState(t *testing.T) {
require.NoError(t, err)
assert.JSONEq(t, `{"choices":[{"count":2,"lastText":false,"finishReason":"stop"}],"hasUsage":true}`, string(encoded))
}
// newAPIGateway emulates the upstream New API instance a type-60 channel
// points at: it answers only on the plugin's prefixed native routes, requires
// the gateway token as a Bearer header, and renders what its own presenters
// render (the public task id already substituted into the id fields).
func newAPIGateway(t *testing.T, routes map[string]string, seen *[]string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
*seen = append(*seen, r.Method+" "+r.URL.Path)
if r.Header.Get("Authorization") != "Bearer sk-gateway" {
w.WriteHeader(http.StatusUnauthorized)
return
}
body, ok := routes[r.Method+" "+r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
}))
}
func TestTaskAdaptorChainsDoubaoThroughNewAPIUpstream(t *testing.T) {
gin.SetMode(gin.TestMode)
service.InitHttpClient()
var seen []string
gateway := newAPIGateway(t, map[string]string{
"POST /doubao/api/v3/contents/generations/tasks": `{"id":"task_up_public"}`,
"GET /doubao/api/v3/contents/generations/tasks/task_up_public": `{"id":"task_up_public","status":"succeeded","content":{"video_url":"https://cdn.example/v.mp4"},"usage":{"completion_tokens":1200,"total_tokens":1200}}`,
}, &seen)
defer gateway.Close()
source, err := plugins.Source("doubao")
require.NoError(t, err)
plugin, err := pluginruntime.NewRegistry().RegisterFactory(source, pluginruntime.Options{Key: "doubao"})
require.NoError(t, err)
adaptor := New(plugin)
const modelName = "doubao-seedance-1-0-pro-250528"
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{ChannelType: constant.ChannelTypeNewAPI, ChannelBaseUrl: gateway.URL, ApiKey: "sk-gateway", UpstreamModelName: modelName},
OriginModelName: modelName,
TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_local"},
}
adaptor.Init(info)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
c.Set("task_request", relaycommon.TaskSubmitReq{Prompt: "a cat", Model: modelName})
require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))
requestBody, err := adaptor.BuildRequestBody(c, info)
require.NoError(t, err)
submitURL, err := adaptor.BuildRequestURL(info)
require.NoError(t, err)
assert.Equal(t, gateway.URL+"/doubao/api/v3/contents/generations/tasks", submitURL)
resp, err := adaptor.DoRequest(c, info, requestBody)
require.NoError(t, err)
parsed, taskErr := adaptor.ParseResponse(c, resp, info)
require.Nil(t, taskErr)
assert.Equal(t, "task_up_public", parsed.UpstreamTaskID, "the gateway's presenter emits its public id in the id field doubao reads")
task := &model.Task{
Action: info.Action,
Properties: model.Properties{OriginModelName: modelName, UpstreamModelName: modelName},
PrivateData: model.TaskPrivateData{UpstreamTaskID: parsed.UpstreamTaskID},
}
queryResp, err := adaptor.FetchTask(gateway.URL, "sk-gateway", task, "")
require.NoError(t, err)
queryBody, err := io.ReadAll(queryResp.Body)
require.NoError(t, err)
require.NoError(t, queryResp.Body.Close())
result, err := adaptor.ParseTaskResult(task, queryResp, queryBody)
require.NoError(t, err)
assert.Equal(t, "SUCCESS", result.Status)
assert.Equal(t, "https://cdn.example/v.mp4", result.Url)
assert.Equal(t, 1200, result.TotalTokens)
assert.Equal(t, []string{
"POST /doubao/api/v3/contents/generations/tasks",
"GET /doubao/api/v3/contents/generations/tasks/task_up_public",
}, seen)
}
func TestTaskAdaptorChainsSunoBatchFetchThroughNewAPIUpstream(t *testing.T) {
gin.SetMode(gin.TestMode)
service.InitHttpClient()
var seen []string
gateway := newAPIGateway(t, map[string]string{
"POST /suno/submit/MUSIC": `{"code":"success","message":"","data":"task_up_public"}`,
"POST /suno/fetch": `{"code":"success","message":"","data":[{"task_id":"task_up_public","status":"SUCCESS","progress":"100%","data":[{"id":"clip-1","status":"complete","audio_url":"https://cdn.example/a.mp3"}]}]}`,
}, &seen)
defer gateway.Close()
source, err := plugins.Source("sunoapi")
require.NoError(t, err)
plugin, err := pluginruntime.NewRegistry().RegisterFactory(source, pluginruntime.Options{Key: "sunoapi"})
require.NoError(t, err)
adaptor := New(plugin)
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{ChannelType: constant.ChannelTypeNewAPI, ChannelBaseUrl: gateway.URL, ApiKey: "sk-gateway", UpstreamModelName: "suno_music"},
OriginModelName: "suno_music",
TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_local", Action: "MUSIC"},
}
adaptor.Init(info)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/suno/submit/music", nil)
c.Set("task_request", relaycommon.TaskSubmitReq{Prompt: "a song about cats"})
require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))
requestBody, err := adaptor.BuildRequestBody(c, info)
require.NoError(t, err)
submitURL, err := adaptor.BuildRequestURL(info)
require.NoError(t, err)
assert.Equal(t, gateway.URL+"/suno/submit/MUSIC", submitURL)
resp, err := adaptor.DoRequest(c, info, requestBody)
require.NoError(t, err)
parsed, taskErr := adaptor.ParseResponse(c, resp, info)
require.Nil(t, taskErr)
assert.Equal(t, "task_up_public", parsed.UpstreamTaskID)
task := &model.Task{
Action: "MUSIC",
Properties: model.Properties{OriginModelName: "suno_music", UpstreamModelName: "suno_music"},
PrivateData: model.TaskPrivateData{UpstreamTaskID: parsed.UpstreamTaskID},
}
batchResp, err := adaptor.FetchBatchTasks(gateway.URL, "sk-gateway", []*model.Task{task}, "")
require.NoError(t, err)
batchBody, err := io.ReadAll(batchResp.Body)
require.NoError(t, err)
require.NoError(t, batchResp.Body.Close())
results, err := adaptor.ParseBatchResult([]*model.Task{task}, batchResp, batchBody)
require.NoError(t, err)
require.Contains(t, results, "task_up_public")
assert.Equal(t, "SUCCESS", results["task_up_public"].TaskInfo.Status)
assert.Equal(t, []string{"POST /suno/submit/MUSIC", "POST /suno/fetch"}, seen)
}
+78
View File
@@ -9,7 +9,10 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relay/channel"
vertexcore "github.com/QuantumNous/new-api/relay/channel/vertex"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/gin-gonic/gin"
@@ -75,3 +78,78 @@ export function parseTaskResult(){return {status:"SUCCESS"}}
require.NoError(t, adaptor.BuildRequestHeader(c, req, info))
assert.Equal(t, "Bearer access-token", req.Header.Get("Authorization"))
}
func TestUpstreamContextsUseGatewayBearerOnNewAPIChannels(t *testing.T) {
pluginAuthCache = sync.Map{}
original := acquireAccessToken
exchanges := 0
acquireAccessToken = func(_ vertexcore.Credentials, _ string) (string, error) {
exchanges++
return "access-token", nil
}
t.Cleanup(func() { acquireAccessToken = original; pluginAuthCache = sync.Map{} })
credentials, err := common.Marshal(vertexcore.Credentials{ProjectID: "project", ClientEmail: "a@example.com", PrivateKey: "secret"})
require.NoError(t, err)
const base = "https://upstream.example"
testCases := []struct {
name string
auth string
channelType int
key string
wantKind string
wantAuthHeader string
wantAPIKey any // nil when the raw key must stay hidden from the plugin
}{
{"api key plugin on a New API channel", "", constant.ChannelTypeNewAPI, "sk-gateway", pluginruntime.UpstreamKindNewAPI, "Bearer sk-gateway", "sk-gateway"},
{"api key plugin on a Task Plugin channel", "", constant.ChannelTypeTaskPlugin, "vendor-key", pluginruntime.UpstreamKindVendor, "vendor-key", "vendor-key"},
{"api key plugin on a legacy channel", "", 1002, "vendor-key", pluginruntime.UpstreamKindVendor, "vendor-key", "vendor-key"},
{"oauth2_jwt plugin on a New API channel", `,auth:{type:"oauth2_jwt"}`, constant.ChannelTypeNewAPI, "sk-gateway", pluginruntime.UpstreamKindNewAPI, "Bearer sk-gateway", "sk-gateway"},
{"oauth2_jwt plugin on a Task Plugin channel", `,auth:{type:"oauth2_jwt"}`, constant.ChannelTypeTaskPlugin, string(credentials), pluginruntime.UpstreamKindVendor, "Bearer access-token", nil},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
source := `
export const meta = {apiVersion:1,key:"upstream-probe",name:"Upstream Probe",version:"1.0.0",author:{name:"Test"},channelTypes:[1002],models:["probe"],fetchMode:"per_task",upstreams:["vendor","new_api"]` + tc.auth + `};
export function buildSubmitRequest(ctx){return {url:ctx.baseUrl+"/submit"}}
export function parseSubmitResponse(){return {taskId:"1"}}
export function buildQueryRequest(ctx){return {url:ctx.baseUrl+"/tasks/"+ctx.taskId}}
export function parseTaskResult(){return {status:"SUCCESS"}}
export function listArtifacts(){return [{key:"video",type:"video"}]}
export function buildContentRequest(ctx){return {url:ctx.baseUrl+"/content/"+ctx.upstream.kind,headers:{Authorization:ctx.authHeader,"X-Api-Key":String(ctx.apiKey)}}}
`
plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
require.NoError(t, err)
adaptor := New(plugin)
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelType: tc.channelType, ChannelBaseUrl: base, ApiKey: tc.key}, TaskRelayInfo: &relaycommon.TaskRelayInfo{}}
adaptor.Init(info)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
task := &model.Task{PrivateData: model.TaskPrivateData{UpstreamTaskID: "t1"}}
submit := adaptor.submitContext(c, info)
query, err := adaptor.queryContext(task, tc.key, base, "")
require.NoError(t, err)
batch, err := adaptor.batchQueryContext(tc.key, base, "", nil)
require.NoError(t, err)
assert.NotContains(t, submit, "authError")
for name, ctx := range map[string]map[string]any{"submit": submit, "query": query, "batch": batch} {
assert.Equal(t, map[string]any{"kind": tc.wantKind}, ctx["upstream"], name)
assert.Equal(t, tc.wantAuthHeader, ctx["authHeader"], name)
assert.Equal(t, tc.wantAPIKey, ctx["apiKey"], name)
assert.Equal(t, tc.wantAuthHeader, ctx["auth"].(map[string]any)["authHeader"], name)
}
content, err := adaptor.BuildContentRequest(t.Context(), task, "video", channel.TaskArtifactClientRequest{Method: http.MethodGet})
require.NoError(t, err)
assert.Equal(t, base+"/content/"+tc.wantKind, content.URL)
assert.Equal(t, tc.wantAuthHeader, content.Headers["Authorization"])
if tc.wantAPIKey == nil {
assert.Equal(t, "undefined", content.Headers["X-Api-Key"])
} else {
assert.Equal(t, tc.wantAPIKey, content.Headers["X-Api-Key"])
}
})
}
assert.Equal(t, 1, exchanges, "the vendor token exchange runs only for the oauth2_jwt vendor channel, never for a New API channel")
}
@@ -60,9 +60,13 @@ func BenchmarkTaskPluginRuntime(b *testing.B) {
var err error
switch name {
case "HasExport":
_, err = plugin.Engine.HasExport(ctx, "extractUsage")
if !plugin.Engine.HasExport("extractUsage") {
b.Fatal("extractUsage not exported")
}
case "MissingExport":
_, err = plugin.Engine.HasExport(ctx, "absentHook")
if plugin.Engine.HasExport("absentHook") {
b.Fatal("absentHook exported")
}
default:
_, err = plugin.Engine.Call(ctx, "extractUsage", input)
}
+10 -9
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"mime"
"net/http"
"slices"
"strings"
"time"
@@ -37,7 +36,7 @@ func (a *TaskAdaptor) readSubmitEvents(parent context.Context, resp *http.Respon
scanner := helper.NewStreamScanner(resp.Body, maxTaskPluginPersistedJSONBytes+1)
hook := "parseSubmitEvent"
var accumulated *pluginruntime.JSONState
if slices.Contains(a.plugin.Meta.RequiredCapabilities, pluginruntime.CapabilitySubmitSSEDelta) {
if pluginruntime.UsesSubmitEventDelta(a.plugin.Meta, a.plugin.Engine) {
hook = "parseSubmitEventDelta"
accumulated = pluginruntime.NewJSONState(maxTaskPluginPersistedJSONBytes)
}
@@ -117,15 +116,17 @@ func (a *TaskAdaptor) readSubmitEvents(parent context.Context, resp *http.Respon
return nil, fmt.Errorf("invalid submit stream changes: %w", err)
}
}
// Keep the exact encoded-byte limit above. Plain JSON state can be
// isolated without parsing large strings again; codec-specific values
// (for example exported typed arrays) retain the old normalization.
var plainJSON bool
state, plainJSON = cloneJSONValue(nextState, 0)
if !plainJSON {
if err = common.Unmarshal(encoded, &state); err != nil {
// Keep the exact encoded-byte limit above. Plain JSON state goes back
// to the next event as is; codec-specific values (for example exported
// typed arrays) retain the old normalization.
if isPlainJSONValue(nextState, 0) {
state = nextState
} else {
var decoded any
if err = common.Unmarshal(encoded, &decoded); err != nil {
return nil, err
}
state = decoded
}
if done {
if accumulated != nil {
+2 -2
View File
@@ -107,7 +107,7 @@ func tencentStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *htt
var tencentResponse TencentChatResponse
err := common.Unmarshal([]byte(data), &tencentResponse)
if err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
common.SysLog(common.LogText("error unmarshalling stream response: %s", err.Error()))
continue
}
@@ -123,7 +123,7 @@ func tencentStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *htt
}
if err := scanner.Err(); err != nil {
common.SysLog("error reading stream: " + err.Error())
common.SysLog(common.LogText("error reading stream: %s", err.Error()))
}
helper.Done(c)
+5 -2
View File
@@ -152,17 +152,20 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
} else {
keyPrefix = "?"
}
// An API key carries no project. Without a configured project ID the URL
// keeps the express-mode form, which has no region in its path.
projectID := strings.TrimSpace(info.ChannelOtherSettings.VertexProjectID)
if a.RequestMode == RequestModeGemini {
return fmt.Sprintf(
"%s%skey=%s",
BuildGoogleModelURL(info.ChannelBaseUrl, DefaultAPIVersion, "", region, modelName, suffix),
BuildGoogleModelURL(info.ChannelBaseUrl, DefaultAPIVersion, projectID, region, modelName, suffix),
keyPrefix,
info.ApiKey,
), nil
} else if a.RequestMode == RequestModeClaude {
return fmt.Sprintf(
"%s%skey=%s",
BuildAnthropicModelURL(info.ChannelBaseUrl, DefaultAPIVersion, "", region, modelName, suffix),
BuildAnthropicModelURL(info.ChannelBaseUrl, DefaultAPIVersion, projectID, region, modelName, suffix),
keyPrefix,
info.ApiKey,
), nil
@@ -0,0 +1,62 @@
package vertex
import (
"testing"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetRequestURLWithAPIKey(t *testing.T) {
tests := []struct {
name string
projectID string
region string
stream bool
want string
}{
{
name: "project ID keeps the global region",
projectID: "my-project",
region: `{"default":"global"}`,
want: "https://aiplatform.googleapis.com/v1/projects/my-project/locations/global/publishers/google/models/gemini-4-argon:generateContent?key=test-key",
},
{
name: "project ID keeps a regional endpoint when streaming",
projectID: "my-project",
region: `{"default":"us-central1"}`,
stream: true,
want: "https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-4-argon:streamGenerateContent?alt=sse&key=test-key",
},
{
name: "no project ID keeps the express-mode URL",
region: `{"default":"global"}`,
want: "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-4-argon:generateContent?key=test-key",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info := &relaycommon.RelayInfo{
IsStream: tt.stream,
OriginModelName: "gemini-4-argon",
ChannelMeta: &relaycommon.ChannelMeta{
ApiVersion: tt.region,
ApiKey: "test-key",
UpstreamModelName: "gemini-4-argon",
ChannelOtherSettings: dto.ChannelOtherSettings{
VertexKeyType: dto.VertexKeyTypeAPIKey,
VertexProjectID: tt.projectID,
},
},
}
adaptor := &Adaptor{}
adaptor.Init(info)
got, err := adaptor.GetRequestURL(info)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
+14 -48
View File
@@ -6,7 +6,6 @@ import (
"encoding/json"
"encoding/pem"
"errors"
"net/http"
"net/url"
"strings"
@@ -53,7 +52,7 @@ func getAccessToken(a *Adaptor, info *relaycommon.RelayInfo) (string, error) {
if err != nil {
return "", fmt.Errorf("failed to create signed JWT: %w", err)
}
newToken, err := exchangeJwtForAccessToken(signedJWT, info)
newToken, err := exchangeJwtForAccessToken(signedJWT, info.ChannelSetting.Proxy)
if err != nil {
return "", fmt.Errorf("failed to exchange JWT for access token: %w", err)
}
@@ -104,14 +103,24 @@ func createSignedJWT(email, privateKeyPEM string) (string, error) {
return signedToken, nil
}
func exchangeJwtForAccessToken(signedJWT string, info *relaycommon.RelayInfo) (string, error) {
func AcquireAccessToken(creds Credentials, proxy string) (string, error) {
signedJWT, err := createSignedJWT(creds.ClientEmail, creds.PrivateKey)
if err != nil {
return "", fmt.Errorf("failed to create signed JWT: %w", err)
}
return exchangeJwtForAccessToken(signedJWT, proxy)
}
// exchangeJwtForAccessToken trades a signed service-account JWT for an access
// token at Google's OAuth endpoint. It goes through the channel proxy only and
// always verifies the certificate; the channel's TLS setting does not apply.
func exchangeJwtForAccessToken(signedJWT string, proxy string) (string, error) {
authURL := "https://www.googleapis.com/oauth2/v4/token"
data := url.Values{}
data.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
data.Set("assertion", signedJWT)
client, err := service.GetHttpClientWithProxySettings(info.ChannelSetting.Proxy, info.ChannelSetting)
client, err := service.GetHttpClientWithProxy(proxy)
if err != nil {
return "", fmt.Errorf("new proxy http client failed: %w", err)
}
@@ -122,50 +131,7 @@ func exchangeJwtForAccessToken(signedJWT string, info *relaycommon.RelayInfo) (s
}
defer resp.Body.Close()
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if accessToken, ok := result["access_token"].(string); ok {
return accessToken, nil
}
return "", fmt.Errorf("failed to get access token: %v", result)
}
func AcquireAccessToken(creds Credentials, proxy string) (string, error) {
signedJWT, err := createSignedJWT(creds.ClientEmail, creds.PrivateKey)
if err != nil {
return "", fmt.Errorf("failed to create signed JWT: %w", err)
}
return exchangeJwtForAccessTokenWithProxy(signedJWT, proxy)
}
func exchangeJwtForAccessTokenWithProxy(signedJWT string, proxy string) (string, error) {
authURL := "https://www.googleapis.com/oauth2/v4/token"
data := url.Values{}
data.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
data.Set("assertion", signedJWT)
var client *http.Client
var err error
if proxy != "" {
client, err = service.GetHttpClientWithProxy(proxy)
if err != nil {
return "", fmt.Errorf("new proxy http client failed: %w", err)
}
} else {
client = service.GetHttpClient()
}
resp, err := client.PostForm(authURL, data)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result map[string]interface{}
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
+10 -9
View File
@@ -47,17 +47,18 @@ func BuildAPIBaseURL(baseURL, version, projectID, region string) string {
}
region = normalizeVertexRegion(region)
host := region + "-aiplatform.googleapis.com"
switch region {
case "global":
host = "aiplatform.googleapis.com"
case "us", "eu":
// The US and EU multi-regions are served from their own hostnames.
host = "aiplatform." + region + ".rep.googleapis.com"
}
if strings.TrimSpace(projectID) == "" {
if region == "global" {
return fmt.Sprintf("https://aiplatform.googleapis.com/%s", version)
}
return fmt.Sprintf("https://%s-aiplatform.googleapis.com/%s", region, version)
return fmt.Sprintf("https://%s/%s", host, version)
}
if region == "global" {
return fmt.Sprintf("https://aiplatform.googleapis.com/%s/projects/%s/locations/global", version, projectID)
}
return fmt.Sprintf("https://%s-aiplatform.googleapis.com/%s/projects/%s/locations/%s", region, version, projectID, region)
return fmt.Sprintf("https://%s/%s/projects/%s/locations/%s", host, version, projectID, region)
}
func BuildPublisherModelURL(baseURL, version, projectID, region, publisher, modelName, action string) string {
+25
View File
@@ -0,0 +1,25 @@
package vertex
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestBuildGoogleModelURLUsesTheLocationHost(t *testing.T) {
tests := []struct {
region string
want string
}{
{"global", "https://aiplatform.googleapis.com/v1/projects/my-project/locations/global/publishers/google/models/gemini-4-argon:generateContent"},
{"us-central1", "https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-4-argon:generateContent"},
{"us", "https://aiplatform.us.rep.googleapis.com/v1/projects/my-project/locations/us/publishers/google/models/gemini-4-argon:generateContent"},
{"eu", "https://aiplatform.eu.rep.googleapis.com/v1/projects/my-project/locations/eu/publishers/google/models/gemini-4-argon:generateContent"},
}
for _, tt := range tests {
t.Run(tt.region, func(t *testing.T) {
got := BuildGoogleModelURL("", DefaultAPIVersion, "my-project", tt.region, "gemini-4-argon", "generateContent")
assert.Equal(t, tt.want, got)
})
}
}
+3
View File
@@ -49,6 +49,9 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
if info.RelayMode == constant.RelayModeResponses {
info.VendorToolUsage = xaiResponsesToolUsage
}
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
+1 -1
View File
@@ -46,7 +46,7 @@ func xAIStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
var xAIResp *dto.ChatCompletionsStreamResponse
if err := common.UnmarshalJsonStr(data, &xAIResp); err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
common.SysLog(common.LogText("error unmarshalling stream response: %s", err.Error()))
sr.Error(err)
return
}
+37
View File
@@ -0,0 +1,37 @@
package xai
import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/tidwall/gjson"
)
// xaiResponsesToolUsage reads xAI's billed server-side tool counts from
// usage.server_side_tool_usage_details (terminal stream frames carry the
// cumulative usage under response.usage; num_sources_used is not a billed
// count). web_search_calls replaces the web_search_call items and is priced by
// the web_search:grok* override; x_search is billed per fetched post and per
// fetched profile. gjson truncates fractional values and reads non-numbers as
// zero; SetBillableToolCount ignores negatives and settlement applies
// MaxBillableToolCallCount.
func xaiResponsesToolUsage(raw []byte) map[string]int {
details := gjson.GetBytes(raw, "usage.server_side_tool_usage_details")
if !details.Exists() {
details = gjson.GetBytes(raw, "response.usage.server_side_tool_usage_details")
}
if !details.IsObject() {
return nil
}
counts := make(map[string]int, 4)
if calls := details.Get("web_search_calls"); calls.Exists() {
counts[dto.BuildInToolWebSearch] = int(calls.Int())
counts[dto.BuildInToolWebSearchPreview] = 0
}
if posts := details.Get("x_posts_fetched"); posts.Exists() {
counts["x_search_posts"] = int(posts.Int())
}
if users := details.Get("x_users_fetched"); users.Exists() {
counts["x_search_profiles"] = int(users.Int())
}
return counts
}
+5 -5
View File
@@ -145,7 +145,7 @@ func xunfeiStreamHandler(c *gin.Context, textRequest dto.GeneralOpenAIRequest, a
response := streamResponseXunfei2OpenAI(&xunfeiResponse)
jsonResponse, err := json.Marshal(response)
if err != nil {
common.SysLog("error marshalling stream response: " + err.Error())
common.SysLog(common.LogText("error marshalling stream response: %s", err.Error()))
return true
}
c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonResponse)})
@@ -224,19 +224,19 @@ func xunfeiMakeRequest(textRequest dto.GeneralOpenAIRequest, domain, authUrl, ap
for {
_, msg, err := conn.ReadMessage()
if err != nil {
common.SysLog("error reading stream response: " + err.Error())
common.SysLog(common.LogText("error reading stream response: %s", err.Error()))
break
}
var response XunfeiChatResponse
err = json.Unmarshal(msg, &response)
if err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
common.SysLog(common.LogText("error unmarshalling stream response: %s", err.Error()))
break
}
dataChan <- response
if response.Payload.Choices.Status == 2 {
if err != nil {
common.SysLog("error closing websocket connection: " + err.Error())
common.SysLog(common.LogText("error closing websocket connection: %s", err.Error()))
}
break
}
@@ -287,6 +287,6 @@ func getAPIVersion(c *gin.Context, modelName string) string {
return apiVersion
}
apiVersion = "v1.1"
common.SysLog("api_version not found, using default: " + apiVersion)
common.SysLog(common.LogText("api_version not found, using default: %s", apiVersion))
return apiVersion
}
+5 -5
View File
@@ -41,7 +41,7 @@ func getZhipuToken(apikey string) string {
split := strings.Split(apikey, ".")
if len(split) != 2 {
common.SysLog("invalid zhipu key: " + apikey)
common.SysLog(common.LogText("invalid zhipu key: %s", apikey))
return ""
}
@@ -181,7 +181,7 @@ func zhipuStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
}
}
if err := scanner.Err(); err != nil {
common.SysLog("error reading stream: " + err.Error())
common.SysLog(common.LogText("error reading stream: %s", err.Error()))
}
stopChan <- true
}()
@@ -192,7 +192,7 @@ func zhipuStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
response := streamResponseZhipu2OpenAI(data)
jsonResponse, err := json.Marshal(response)
if err != nil {
common.SysLog("error marshalling stream response: " + err.Error())
common.SysLog(common.LogText("error marshalling stream response: %s", err.Error()))
return true
}
c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonResponse)})
@@ -201,13 +201,13 @@ func zhipuStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
var zhipuResponse ZhipuStreamMetaResponse
err := json.Unmarshal([]byte(data), &zhipuResponse)
if err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
common.SysLog(common.LogText("error unmarshalling stream response: %s", err.Error()))
return true
}
response, zhipuUsage := streamMetaResponseZhipu2OpenAI(&zhipuResponse)
jsonResponse, err := json.Marshal(response)
if err != nil {
common.SysLog("error marshalling stream response: " + err.Error())
common.SysLog(common.LogText("error marshalling stream response: %s", err.Error()))
return true
}
usage = zhipuUsage
+12 -3
View File
@@ -14,6 +14,7 @@ import (
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/samber/lo"
"github.com/gin-gonic/gin"
@@ -22,9 +23,16 @@ import (
type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
result, err := service.ConvertRequest(c, info, types.RelayFormatOpenAI, request)
if err != nil {
return nil, err
}
openaiRequest, ok := result.Value.(*dto.GeneralOpenAIRequest)
if !ok {
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", result.Value)
}
return a.ConvertOpenAIRequest(c, info, openaiRequest)
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
@@ -42,6 +50,7 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
info.ConvOptions().WebSearch = encodeWebSearch(info)
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
+3 -3
View File
@@ -86,7 +86,7 @@ func zhipu4vImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.
url = data.ImageUrl
}
if url == "" {
logger.LogWarn(c, "zhipu_image_missing_url")
logger.LogWarn(c, common.LogText("zhipu_image_missing_url"))
continue
}
@@ -99,14 +99,14 @@ func zhipu4vImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.
default:
_, downloaded, err := service.GetImageFromUrl(url)
if err != nil {
logger.LogError(c, "zhipu_image_get_b64_failed: "+err.Error())
logger.LogError(c, common.LogText("zhipu_image_get_b64_failed: %s", err.Error()))
continue
}
b64 = downloaded
}
if b64 == "" {
logger.LogWarn(c, "zhipu_image_empty_b64")
logger.LogWarn(c, common.LogText("zhipu_image_empty_b64"))
continue
}
+10 -9
View File
@@ -42,15 +42,16 @@ func requestOpenAI2Zhipu(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIReq
Stop, _ = request.Stop.([]string)
}
out := &dto.GeneralOpenAIRequest{
Model: request.Model,
Stream: request.Stream,
Messages: messages,
Temperature: request.Temperature,
TopP: request.TopP,
Stop: Stop,
Tools: request.Tools,
ToolChoice: request.ToolChoice,
THINKING: request.THINKING,
Model: request.Model,
Stream: request.Stream,
Messages: messages,
Temperature: request.Temperature,
TopP: request.TopP,
Stop: Stop,
Tools: request.Tools,
ToolChoice: request.ToolChoice,
THINKING: request.THINKING,
ReasoningEffort: request.ReasoningEffort,
}
if request.MaxTokens != nil || request.MaxCompletionTokens != nil {
maxTokens := request.GetMaxTokens()
+85
View File
@@ -0,0 +1,85 @@
package zhipu_4v
import (
"github.com/QuantumNous/new-api/common"
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/types"
"github.com/tidwall/gjson"
)
// zhipuSearchEngine is the documented default search_engine. It is also the
// tool-price key of the searches billed on this path.
const zhipuSearchEngine = "search_std"
// encodeWebSearch maps hosted web search onto the GLM Chat web_search tool
// (https://docs.bigmodel.cn/api-reference/模型-api/对话补全). Claude and
// Responses clients reach Zhipu natively, so only the Chat target is handled.
// search_result makes non-stream responses carry the top-level web_search
// array; search_intent:false skips intent detection when the client forced
// the search.
func encodeWebSearch(info *relaycommon.RelayInfo) convmeta.WebSearchEncoder {
return func(call convmeta.WebSearchCall) (*convmeta.WebSearchEncoding, error) {
if call.Chat == nil {
return nil, nil
}
var diagnostics []types.ConversionDiagnostic
webSearch := map[string]any{"enable": true, "search_engine": zhipuSearchEngine, "search_result": true}
if call.Forced {
webSearch["search_intent"] = false
// Stream responses carry no search evidence; a forced search is
// the only proof that one ran. A non-stream web_search array
// confirms the same single call through zhipuChatToolUsage.
info.RecordRequestInferredToolCall(zhipuSearchEngine)
}
// Only searches this encoder requested are known to use search_std.
info.VendorToolUsage = zhipuChatToolUsage
spec := call.Spec
switch {
case len(spec.AllowedDomains) == 1:
webSearch["search_domain_filter"] = spec.AllowedDomains[0]
case len(spec.AllowedDomains) > 1:
diagnostics = append(diagnostics, call.SemanticLoss("unsupported_domain_filter", "GLM web_search accepts one search_domain_filter; the domain filter was dropped"))
}
if len(spec.BlockedDomains) > 0 {
diagnostics = append(diagnostics, call.SemanticLoss("unsupported_domain_filter", "GLM web_search cannot exclude domains; blocked_domains was dropped"))
}
if spec.MaxUses != nil {
diagnostics = append(diagnostics, call.SemanticLoss("unsupported_search_limit", "GLM web_search cannot preserve max_uses"))
}
if len(spec.AllowedCallers) > 0 || spec.ExternalWebAccess != nil {
diagnostics = append(diagnostics, call.SemanticLoss("unsupported_search_controls", "GLM web_search cannot preserve caller or external-access constraints"))
}
switch spec.SearchContextSize {
case "high":
webSearch["content_size"] = "high"
case "", "medium":
default:
diagnostics = append(diagnostics, call.PresentationLoss("unsupported_search_tuning", "GLM web_search content_size has no low setting"))
}
if spec.Location != nil || spec.ResponseInclusion != "" || len(spec.ReturnTokenBudget) > 0 {
diagnostics = append(diagnostics, call.PresentationLoss("unsupported_search_tuning", "GLM web_search cannot preserve location or result tuning"))
}
native, err := common.Marshal(map[string]any{"type": "web_search", "web_search": webSearch})
if err != nil {
return nil, err
}
return &convmeta.WebSearchEncoding{
Tool: dto.ToolCallRequest{Type: "web_search", Native: native},
Diagnostics: diagnostics,
}, nil
}
}
// zhipuChatToolUsage bills one search_std call when a non-stream GLM Chat
// response carries the top-level web_search result array. The array lists
// results, not searches, so its length is not a count.
func zhipuChatToolUsage(raw []byte) map[string]int {
results := gjson.GetBytes(raw, "web_search")
if !results.IsArray() || len(results.Array()) == 0 {
return nil
}
return map[string]int{zhipuSearchEngine: 1}
}
+3 -3
View File
@@ -2,8 +2,8 @@ package common
import (
"context"
"fmt"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
@@ -46,13 +46,13 @@ func (info *RelayInfo) RecordConversionDiagnostics(ctx context.Context, diagnost
if len(info.conversionDiagnostics) >= maxConversionDiagnostics {
if !info.conversionDiagnosticsTruncated {
info.conversionDiagnosticsTruncated = true
logger.LogWarn(ctx, fmt.Sprintf("conversion diagnostics truncated after %d distinct entries", maxConversionDiagnostics))
logger.LogWarn(ctx, common.LogText("conversion diagnostics truncated after %d distinct entries", maxConversionDiagnostics))
}
continue
}
info.conversionDiagnosticKeys[key] = struct{}{}
info.conversionDiagnostics = append(info.conversionDiagnostics, diagnostic)
logger.LogWarn(ctx, fmt.Sprintf(
logger.LogWarn(ctx, common.LogText(
"conversion diagnostic: code=%q severity=%q from=%q to=%q path=%q message=%q",
diagnostic.Code, diagnostic.Severity, diagnostic.From, diagnostic.To, diagnostic.Path, diagnostic.Message,
))
+23 -6
View File
@@ -56,10 +56,14 @@ type paramOverrideAuditRecorder struct {
type ConditionOperation struct {
Path string `json:"path"` // JSON路径
Mode string `json:"mode"` // full, prefix, suffix, contains, gt, gte, lt, lte
Mode string `json:"mode"` // full, prefix, suffix, contains, regex, gt, gte, lt, lte
Value any `json:"value"` // 匹配的值
Invert bool `json:"invert"` // 反选功能,true表示取反结果
PassMissingKey bool `json:"pass_missing_key"` // 未获取到json key时的行为
// Conditions are parsed per request. Reuse the lazily compiled pattern when
// pruning multiple objects; never compile before a matching key exists.
regex *regexp.Regexp
}
type ParamOperation struct {
@@ -633,8 +637,8 @@ func checkConditions(data []byte, contextJSON string, conditions []ConditionOper
return true, nil // 没有条件,直接通过
}
results := make([]bool, len(conditions))
for i, condition := range conditions {
result, err := checkSingleCondition(data, contextJSON, condition)
for i := range conditions {
result, err := checkSingleCondition(data, contextJSON, &conditions[i])
if err != nil {
return false, err
}
@@ -647,7 +651,7 @@ func checkConditions(data []byte, contextJSON string, conditions []ConditionOper
return lo.SomeBy(results, func(item bool) bool { return item }), nil
}
func checkSingleCondition(data []byte, contextJSON string, condition ConditionOperation) (bool, error) {
func checkSingleCondition(data []byte, contextJSON string, condition *ConditionOperation) (bool, error) {
// 处理负数索引
path := processNegativeIndex(data, condition.Path)
value := gjson.GetBytes(data, path)
@@ -668,7 +672,7 @@ func checkSingleCondition(data []byte, contextJSON string, condition ConditionOp
}
targetValue := gjson.ParseBytes(targetBytes)
result, err := compareGjsonValues(value, targetValue, strings.ToLower(condition.Mode))
result, err := condition.compareGjsonValues(value, targetValue)
if err != nil {
return false, fmt.Errorf("comparison failed for path %s: %v", condition.Path, err)
}
@@ -710,7 +714,8 @@ func processNegativeIndex(data []byte, path string) string {
}
// compareGjsonValues 直接比较两个gjson.Result,支持所有比较模式
func compareGjsonValues(jsonValue, targetValue gjson.Result, mode string) (bool, error) {
func (condition *ConditionOperation) compareGjsonValues(jsonValue, targetValue gjson.Result) (bool, error) {
mode := strings.ToLower(condition.Mode)
switch mode {
case "full":
return compareEqual(jsonValue, targetValue)
@@ -720,6 +725,18 @@ func compareGjsonValues(jsonValue, targetValue gjson.Result, mode string) (bool,
return strings.HasSuffix(jsonValue.String(), targetValue.String()), nil
case "contains":
return strings.Contains(jsonValue.String(), targetValue.String()), nil
case "regex":
if targetValue.Type != gjson.String {
return false, fmt.Errorf("regex pattern must be a string")
}
if condition.regex == nil {
re, err := regexp.Compile(targetValue.String())
if err != nil {
return false, fmt.Errorf("invalid regex pattern: %w", err)
}
condition.regex = re
}
return condition.regex.MatchString(jsonValue.String()), nil
case "gt":
return compareNumeric(jsonValue, targetValue, "gt")
case "gte":
+143
View File
@@ -670,6 +670,149 @@ func TestApplyParamOverrideAppendObjectMergeOverride(t *testing.T) {
assertJSONEqual(t, `{"obj":{"a":2,"b":3}}`, string(out))
}
func TestApplyParamOverrideConditionRegex(t *testing.T) {
tests := []struct {
name string
input string
path string
pattern any
context map[string]any
invert bool
passMissing bool
wantMatch bool
wantError string
}{
{name: "anchors and alternation match", input: `{"model":"gpt-4o"}`, pattern: `^(gpt|claude)-`, wantMatch: true},
{name: "anchors reject other models", input: `{"model":"vendor/gpt-4o"}`, pattern: `^gpt-`},
{name: "unanchored pattern matches substring", input: `{"model":"vendor/gpt-4o"}`, pattern: `gpt-`, wantMatch: true},
{name: "Go inline flags", input: `{"model":"GPT-4o"}`, pattern: `(?i)^gpt-`, wantMatch: true},
{name: "escaped pattern", input: `{"model":"gpt-4.1"}`, pattern: `^gpt-\d\.\d$`, wantMatch: true},
{name: "invert match", input: `{"model":"gpt-4o"}`, pattern: `^gpt-`, invert: true},
{name: "invert nonmatch", input: `{"model":"claude-sonnet"}`, pattern: `^gpt-`, invert: true, wantMatch: true},
{name: "missing key does not match even inverted", input: `{}`, pattern: `.*`, invert: true},
{name: "missing key can pass", input: `{}`, pattern: `^gpt-`, passMissing: true, wantMatch: true},
{name: "missing key skips invalid pattern", input: `{}`, pattern: `[`},
{name: "missing key can pass with invalid pattern", input: `{}`, pattern: `[`, passMissing: true, wantMatch: true},
{name: "context fallback", input: `{}`, pattern: `^gpt-`, context: map[string]any{"model": "gpt-4o"}, wantMatch: true},
{name: "body takes priority over context", input: `{"model":"claude-sonnet"}`, pattern: `^gpt-`, context: map[string]any{"model": "gpt-4o"}},
{name: "negative array index", input: `{"messages":[{"role":"user"}]}`, path: "messages.-1.role", pattern: `^(user|system)$`, wantMatch: true},
{name: "string conversion matches other text comparisons", input: `{"model":123}`, pattern: `^\d+$`, wantMatch: true},
{name: "empty pattern matches empty string", input: `{"model":""}`, pattern: "", wantMatch: true},
{name: "invalid pattern returns error", input: `{"model":"gpt-4o"}`, pattern: "[", wantError: "invalid regex pattern"},
{name: "nonstring pattern returns error", input: `{"model":"123"}`, pattern: 123, wantError: "regex pattern must be a string"},
{name: "missing pattern returns error", input: `{"model":"gpt-4o"}`, wantError: "regex pattern must be a string"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := tt.path
if path == "" {
path = "model"
}
override := map[string]any{
"operations": []any{map[string]any{
"mode": "set", "path": "matched", "value": true,
"conditions": []any{map[string]any{
"path": path, "mode": "regex", "value": tt.pattern,
"invert": tt.invert, "pass_missing_key": tt.passMissing,
}},
}},
}
out, err := ApplyParamOverride([]byte(tt.input), override, tt.context)
if tt.wantError != "" {
require.ErrorContains(t, err, tt.wantError)
assert.ErrorContains(t, err, "comparison failed for path "+path)
return
}
require.NoError(t, err)
var result map[string]any
require.NoError(t, common2.Unmarshal(out, &result))
assert.Equal(t, tt.wantMatch, result["matched"] == true)
if !tt.wantMatch {
assert.JSONEq(t, tt.input, string(out))
}
})
}
}
func TestApplyParamOverrideRegexConditionLogic(t *testing.T) {
for _, tt := range []struct {
logic string
wantMatch bool
}{
{logic: "OR", wantMatch: true},
{logic: "AND", wantMatch: false},
} {
t.Run(tt.logic, func(t *testing.T) {
override := map[string]any{
"operations": []any{map[string]any{
"mode": "set", "path": "matched", "value": true, "logic": tt.logic,
"conditions": []any{
map[string]any{"path": "model", "mode": "regex", "value": `^gpt-`},
map[string]any{"path": "stream", "mode": "full", "value": true},
},
}},
}
out, err := ApplyParamOverride([]byte(`{"model":"gpt-4o","stream":false}`), override, nil)
require.NoError(t, err)
var result map[string]any
require.NoError(t, common2.Unmarshal(out, &result))
assert.Equal(t, tt.wantMatch, result["matched"] == true)
})
}
}
func TestApplyParamOverridePruneObjectsRegex(t *testing.T) {
condition := map[string]any{"path": "role", "mode": "regex"}
override := map[string]any{
"operations": []any{map[string]any{
"mode": "prune_objects", "path": "messages",
"value": map[string]any{
"conditions": []any{condition},
},
}},
}
// Reusing a channel configuration must not reuse compiled patterns across requests.
for _, tt := range []struct {
name string
input string
pattern string
want string
wantError string
}{
{
name: "one condition matches several objects",
input: `{"messages":[{"role":"system"},{"role":"user"},{"role":"developer"}]}`,
pattern: `^(system|developer)$`, want: `{"messages":[{"role":"user"}]}`,
},
{
name: "next request uses updated pattern",
input: `{"messages":[{"role":"system"},{"role":"user"},{"role":"developer"}]}`,
pattern: `^user$`, want: `{"messages":[{"role":"system"},{"role":"developer"}]}`,
},
{
name: "missing keys skip invalid pattern",
input: `{"messages":[{"content":"hello"},{"content":"world"}]}`,
pattern: `[`, want: `{"messages":[{"content":"hello"},{"content":"world"}]}`,
},
{
name: "invalid pattern errors on first present key after missing keys",
input: `{"messages":[{"content":"hello"},{"role":"user"},{"role":"system"}]}`,
pattern: `[`, wantError: "comparison failed for path role: invalid regex pattern",
},
} {
t.Run(tt.name, func(t *testing.T) {
condition["value"] = tt.pattern
out, err := ApplyParamOverride([]byte(tt.input), override, nil)
if tt.wantError != "" {
require.ErrorContains(t, err, tt.wantError)
return
}
require.NoError(t, err)
assert.JSONEq(t, tt.want, string(out))
})
}
}
func TestApplyParamOverrideConditionORDefault(t *testing.T) {
input := []byte(`{"model":"gpt-4","temperature":0.7}`)
override := map[string]any{
+61 -15
View File
@@ -47,9 +47,8 @@ type RerankerInfo struct {
}
type BuildInToolInfo struct {
ToolName string
CallCount int
SearchContextSize string
ToolName string
CallCount int
}
type ResponsesUsageInfo struct {
@@ -91,6 +90,7 @@ type RelayInfo struct {
TokenUnlimited bool
StartTime time.Time
FirstResponseTime time.Time
UpstreamTiming *UpstreamTiming
isFirstResponse bool
//SendLastReasoningResponse bool
IsStream bool
@@ -201,6 +201,27 @@ type RelayInfo struct {
FinalRequestRelayFormat types.RelayFormat
StreamStatus *StreamStatus
// VendorToolUsage reads the upstream's own billed tool counts from a raw
// response body (non-stream) or the raw terminal stream frame. The channel
// adaptor's Init sets it for upstreams whose usage extension names a billed
// count; it stays nil for everyone else. Keys are tool-price keys. A zero
// value suppresses the protocol items counted under that key, so a vendor
// count can supersede web_search_call items billed under a different key.
VendorToolUsage func(raw []byte) map[string]int
// WebSearchBillingKey overrides the tool-price key of hosted web search:
// usage.server_tool_use.web_search_requests and web_search_tool_result
// blocks in the Claude handler, and web_search_call items counted by
// CountBillableToolCall. Set by a channel adaptor whose upstream bills
// search under its own price list; empty keeps the protocol defaults.
WebSearchBillingKey string
// WebFetchBillingKey overrides the tool-price key of
// usage.server_tool_use.web_fetch_requests in the Claude handler, for an
// upstream that bills page fetches under its own price list; empty keeps
// the protocol default web_fetch.
WebFetchBillingKey string
// requestInferredToolKey is the tool-price key RecordRequestInferredToolCall
// billed for the current channel attempt.
requestInferredToolKey string
// PerformanceOutputTokens is captured by settlement and sampled once at
// the request boundary, independently of billing success or failure.
PerformanceOutputTokens int64
@@ -208,6 +229,9 @@ type RelayInfo struct {
// convOptions caches the converter settings snapshot (see ConvOptions).
convOptions *convmeta.Options
// responsesToolState is written by request conversion and read by the
// matching response conversion (see ResponsesToolState).
responsesToolState *convmeta.ResponsesToolState
conversionDiagnostics []types.ConversionDiagnostic
conversionDiagnosticKeys map[conversionDiagnosticKey]struct{}
@@ -317,6 +341,14 @@ func (info *RelayInfo) InitChannelMeta(c *gin.Context) {
// Channel identity feeds the converter options snapshot (e.g.
// OpenRouterDialect); drop the cache so a cross-channel retry rebuilds it.
info.convOptions = nil
// The adaptor Init of this attempt sets the vendor billing hooks again.
info.VendorToolUsage = nil
info.WebSearchBillingKey = ""
info.WebFetchBillingKey = ""
if info.requestInferredToolKey != "" && info.ResponsesUsageInfo != nil {
delete(info.ResponsesUsageInfo.BuiltInTools, info.requestInferredToolKey)
}
info.requestInferredToolKey = ""
if model_setting.GetGlobalSettings().PassThroughRequestEnabled || channelMeta.ChannelSetting.PassThroughBodyEnabled {
info.ReasoningEffort = ""
info.ReasoningConversion = nil
@@ -497,14 +529,6 @@ func GenRelayInfoResponses(c *gin.Context, request *dto.OpenAIResponsesRequest)
ToolName: toolType,
CallCount: 0,
}
switch toolType {
case dto.BuildInToolWebSearchPreview:
searchContextSize := common.Interface2String(tool["search_context_size"])
if searchContextSize == "" {
searchContextSize = "medium"
}
info.ResponsesUsageInfo.BuiltInTools[toolType].SearchContextSize = searchContextSize
}
}
}
return info
@@ -716,6 +740,9 @@ func GenRelayInfo(c *gin.Context, relayFormat types.RelayFormat, request dto.Req
return GenRelayInfoAlphaSearch(c, request), nil
}
return nil, errors.New("request is not a AlphaSearchRequest")
case types.RelayFormatDeepSeekTools:
info = genBaseRelayInfo(c, request)
info.RelayFormat = types.RelayFormatDeepSeekTools
case types.RelayFormatTask:
info = genBaseRelayInfo(c, nil)
info.TaskRelayInfo = &TaskRelayInfo{}
@@ -913,6 +940,19 @@ func (info *RelayInfo) EnsureClaudeConvertInfo() *convmeta.ClaudeConvertInfo {
return info.ClaudeConvertInfo
}
func (info *RelayInfo) ResponsesToolState() *convmeta.ResponsesToolState {
if info == nil {
return nil
}
return info.responsesToolState
}
func (info *RelayInfo) SetResponsesToolState(state *convmeta.ResponsesToolState) {
if info != nil {
info.responsesToolState = state
}
}
func (info *RelayInfo) GetSendResponseCount() int {
if info == nil {
return 0
@@ -936,6 +976,7 @@ func (info *RelayInfo) ConvOptions() *convmeta.Options {
claudeSettings := model_setting.GetClaudeSettings()
geminiSettings := model_setting.GetGeminiSettings()
channelType := info.GetChannelType()
options := &convmeta.Options{
Claude: convmeta.ClaudeOptions{
ThinkingAdapterEnabled: claudeSettings.ThinkingAdapterEnabled,
@@ -948,6 +989,11 @@ func (info *RelayInfo) ConvOptions() *convmeta.Options {
FunctionCallThoughtSignatureEnabled: geminiSettings.FunctionCallThoughtSignatureEnabled,
SupportsImagine: model_setting.IsGeminiModelSupportImagine,
SafetySetting: model_setting.GetGeminiSafetySetting,
// Gemini API v1 has no parametersJsonSchema; v1beta and Vertex AI
// accept it.
SupportsJSONSchema: func(modelName string) bool {
return channelType == constant.ChannelTypeVertexAi || model_setting.GetGeminiVersionSetting(modelName) != "v1"
},
},
OpenRouterDialect: info != nil && info.GetChannelType() == constant.ChannelTypeOpenRouter,
PreserveThinkingSuffix: model_setting.ShouldPreserveThinkingSuffix,
@@ -1120,7 +1166,7 @@ func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOther
var data map[string]any
if err := common.Unmarshal(jsonData, &data); err != nil {
common.SysError("RemoveDisabledFields Unmarshal error :" + err.Error())
common.SysError(common.LogText("RemoveDisabledFields Unmarshal error :%s", err.Error()))
return jsonData, nil
}
@@ -1177,7 +1223,7 @@ func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOther
jsonDataAfter, err := common.Marshal(data)
if err != nil {
common.SysError("RemoveDisabledFields Marshal error :" + err.Error())
common.SysError(common.LogText("RemoveDisabledFields Marshal error :%s", err.Error()))
return jsonData, nil
}
return jsonDataAfter, nil
@@ -1211,7 +1257,7 @@ func RemoveGeminiDisabledFields(jsonData []byte) ([]byte, error) {
var data map[string]any
if err := common.Unmarshal(jsonData, &data); err != nil {
common.SysError("RemoveGeminiDisabledFields Unmarshal error: " + err.Error())
common.SysError(common.LogText("RemoveGeminiDisabledFields Unmarshal error: %s", err.Error()))
return jsonData, nil
}
@@ -1240,7 +1286,7 @@ func RemoveGeminiDisabledFields(jsonData []byte) ([]byte, error) {
jsonDataAfter, err := common.Marshal(data)
if err != nil {
common.SysError("RemoveGeminiDisabledFields Marshal error: " + err.Error())
common.SysError(common.LogText("RemoveGeminiDisabledFields Marshal error: %s", err.Error()))
return jsonData, nil
}
return jsonDataAfter, nil
@@ -3,6 +3,7 @@ package common
import (
"testing"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/stretchr/testify/assert"
)
@@ -23,3 +24,35 @@ func TestRelayInfoConvOptionsUsesNormalizedGeminiSafetySettings(t *testing.T) {
assert.Equal(t, "OFF", options.Gemini.SafetySetting("HARM_CATEGORY_HATE_SPEECH"))
assert.Equal(t, "BLOCK_ONLY_HIGH", options.Gemini.SafetySetting("HARM_CATEGORY_DANGEROUS_CONTENT"))
}
// TestRelayInfoConvOptionsGeminiJSONSchemaSupport covers which upstreams get
// parametersJsonSchema and responseJsonSchema: every channel except one whose
// Gemini API version for the model is v1, and Vertex AI regardless of it.
func TestRelayInfoConvOptionsGeminiJSONSchemaSupport(t *testing.T) {
settings := model_setting.GetGeminiSettings()
original := settings.VersionSettings
t.Cleanup(func() {
settings.VersionSettings = original
})
settings.VersionSettings = map[string]string{"default": "v1beta", "gemini-v1-model": "v1"}
tests := []struct {
name string
channelType int
model string
want bool
}{
{name: "Gemini on the default version", channelType: constant.ChannelTypeGemini, model: "gemini-3.7-flash", want: true},
{name: "Gemini on v1", channelType: constant.ChannelTypeGemini, model: "gemini-v1-model", want: false},
{name: "Vertex AI", channelType: constant.ChannelTypeVertexAi, model: "gemini-v1-model", want: true},
{name: "other channel on v1", channelType: constant.ChannelTypeAdvancedCustom, model: "gemini-v1-model", want: false},
{name: "other channel on the default version", channelType: constant.ChannelTypeAdvancedCustom, model: "gemini-3.7-flash", want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info := &RelayInfo{ChannelMeta: &ChannelMeta{ChannelType: tt.channelType}}
assert.Equal(t, tt.want, info.ConvOptions().Gemini.SupportsJSONSchemaModel(tt.model))
})
}
}
+15
View File
@@ -68,7 +68,9 @@ func TestRelayInfoMetaTypedNilReceiver(t *testing.T) {
meta.SetReasoningEffort("high")
meta.IncrSendResponseCount()
meta.AppendRequestConversion(types.RelayFormatClaude)
meta.SetResponsesToolState(&convmeta.ResponsesToolState{})
})
assert.Nil(t, meta.ResponsesToolState())
firstState := meta.EnsureClaudeConvertInfo()
secondState := meta.EnsureClaudeConvertInfo()
@@ -89,6 +91,19 @@ func TestRelayInfoMetaTypedNilReceiver(t *testing.T) {
assert.NotNil(t, firstOptions.PreserveEffortTail)
}
func TestRelayInfoResponsesToolStateRoundTrip(t *testing.T) {
info := &RelayInfo{}
state := &convmeta.ResponsesToolState{CustomToolNames: map[string]struct{}{"exec": {}}}
info.SetResponsesToolState(state)
assert.Same(t, state, info.ResponsesToolState())
assert.True(t, convmeta.ResponsesToolStateOf(info).IsCustomTool("exec"))
info.SetResponsesToolState(nil)
assert.Nil(t, info.ResponsesToolState())
assert.False(t, convmeta.ResponsesToolStateOf(info).IsCustomTool("exec"))
}
func TestGenRelayInfoCapturesRequestReasoningEffort(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
+29 -17
View File
@@ -4,17 +4,40 @@ import "strings"
// ResponseModel records upstream declarations before response conversion. It is
// diagnostic only: it must never change routing, pricing, or downstream output.
// Only the three names are stored; whether they disagree is computed on demand
// so every consumer applies the current comparison rule to old rows as well.
type ResponseModel struct {
RequestedModel string `json:"requested_model"`
UpstreamModel string `json:"upstream_model"`
ReturnedModel string `json:"returned_model"`
Mismatch bool `json:"mismatch"`
}
// matches reports whether an upstream declaration is compatible with the
// requested or upstream model: equal ignoring case, a dated or variant name
// that extends it, or the same name behind a provider path such as
// "deepseek/deepseek-v4.1-flash".
func (r *ResponseModel) matches(model string) bool {
if r.RequestedModel == "gpt-6-astra" { return strings.TrimSpace(model) == "gpt-6-astra" }
returned := strings.ToLower(model)
for _, expected := range []string{r.RequestedModel, r.UpstreamModel} {
expected = strings.ToLower(expected)
if expected != "" && (strings.HasPrefix(returned, expected) || strings.HasSuffix(returned, expected)) {
return true
}
}
return false
}
// Mismatch reports whether the retained upstream declaration disagrees with
// both the requested and upstream models.
func (r *ResponseModel) Mismatch() bool {
return r != nil && r.ReturnedModel != "" && !r.matches(r.ReturnedModel)
}
// ObserveResponseModel retains the first differing model for inspection, with
// mismatches taking priority over prefix or case-only differences. A later
// matching or empty event cannot erase it. Only observe upstream declarations,
// never models synthesized by a response converter.
// mismatches taking priority over provider-path, prefix, or case-only
// differences. A later matching or empty event cannot erase it. Only observe
// upstream declarations, never models synthesized by a response converter.
func (info *RelayInfo) ObserveResponseModel(model string) {
if info == nil || strings.TrimSpace(model) == "" || (info.OriginModelName == "gpt-6-astra" && info.AstraDetectionDisabled) {
return
@@ -26,23 +49,12 @@ func (info *RelayInfo) ObserveResponseModel(model string) {
}
}
observation := info.ResponseModel
if observation.Mismatch {
if observation.Mismatch() {
return
}
mismatch := true
for _, expected := range []string{observation.RequestedModel, observation.UpstreamModel} {
if expected != "" && (strings.HasPrefix(model, expected) || strings.EqualFold(model, expected)) {
mismatch = false
break
}
}
if observation.RequestedModel == "gpt-6-astra" {
mismatch = strings.TrimSpace(model) != "gpt-6-astra"
}
if !mismatch && observation.ReturnedModel != "" &&
if observation.matches(model) && observation.ReturnedModel != "" &&
observation.ReturnedModel != observation.RequestedModel && observation.ReturnedModel != observation.UpstreamModel {
return
}
observation.ReturnedModel = model
observation.Mismatch = mismatch
}
+4
View File
@@ -32,6 +32,7 @@ type ResponseGuardUsage struct {
}
type ResponseGuardAttempt struct {
Timing *UpstreamTiming `json:"-"`
OnEvent func([]byte, bool) `json:"-"`
OnClose func() `json:"-"`
closeOnce sync.Once
@@ -141,6 +142,9 @@ func (g *ResponseModelGuard) APIError() *types.NewAPIError {
// Only read the provider's declaration and numeric usage, never generated text.
func (a *ResponseGuardAttempt) observe(payload []byte, stream bool) error {
if stream {
a.Timing.Mark("first_sse_data")
}
if a.OnEvent != nil {
a.OnEvent(payload, stream)
}
+75 -50
View File
@@ -1,6 +1,7 @@
package common
import (
"cmp"
"crypto/sha256"
"encoding/hex"
"fmt"
@@ -11,39 +12,44 @@ import (
"github.com/QuantumNous/new-api/setting/operation_setting"
)
// GoogleSearchGroundedPromptTool bills Gemini 2.5-and-older Google Search
// grounding, charged once per grounded prompt.
const GoogleSearchGroundedPromptTool = "google_search_grounded_prompt"
// reservedBillableToolNames are hosted-tool keys without a built-in price
// (Anthropic server tools reported in usage.server_tool_use). They and every
// tool name the built-in price seed prices
// (operation_setting.IsBuiltInToolPriceKey) bill hosted tools; a client
// function or tool_use with the same name is never counted under them.
var reservedBillableToolNames = map[string]struct{}{
dto.BuildInToolWebSearchPreview: {},
dto.BuildInToolWebSearch: {},
dto.BuildInToolFileSearch: {},
dto.BuildInToolGoogleSearch: {},
dto.BuildInToolImageGeneration: {},
"web_fetch": {},
"code_execution": {},
"tool_search": {},
}
// MaxBillableToolCallCount bounds every tool count at settlement, whatever its
// source, before it becomes a surcharge multiplier (see
// .agents/rules/billing.md).
const MaxBillableToolCallCount = 10_000
// CountBillableToolCall is the single entry point for per-call tool billing counts.
// Built-in call types always count; custom function/tool_use names only count when priced.
func (info *RelayInfo) CountBillableToolCall(itemType string, functionName string) {
if info == nil {
return
}
if info.ResponsesUsageInfo == nil {
info.ResponsesUsageInfo = &ResponsesUsageInfo{
BuiltInTools: make(map[string]*BuildInToolInfo),
}
}
if info.ResponsesUsageInfo.BuiltInTools == nil {
info.ResponsesUsageInfo.BuiltInTools = make(map[string]*BuildInToolInfo)
}
info.ensureBuiltInTools()
switch itemType {
case dto.BuildInCallWebSearchCall:
info.incrementBillableToolCall(resolveWebSearchToolName(info.ResponsesUsageInfo.BuiltInTools))
info.incrementBillableToolCall(cmp.Or(info.WebSearchBillingKey, resolveWebSearchToolName(info.ResponsesUsageInfo.BuiltInTools)))
case dto.BuildInCallFileSearchCall:
info.incrementBillableToolCall(dto.BuildInToolFileSearch)
case dto.BuildInCallFunctionCall, dto.BuildInCallToolUse:
if functionName == "" {
return
}
if _, reserved := reservedBillableToolNames[functionName]; reserved {
if _, reserved := reservedBillableToolNames[functionName]; reserved || operation_setting.IsBuiltInToolPriceKey(functionName) {
return
}
if operation_setting.GetToolPriceForModel(functionName, info.GetBillingModelName()) <= 0 {
@@ -53,6 +59,59 @@ func (info *RelayInfo) CountBillableToolCall(itemType string, functionName strin
}
}
// SetBillableToolCount records an upstream-reported count for one tool-price
// key, replacing whatever per-item counting produced for the same key. Zero
// is a valid value: a vendor billing field that reports zero suppresses the
// protocol items counted under that key. Negative counts are ignored.
func (info *RelayInfo) SetBillableToolCount(name string, count int) {
if info == nil || name == "" {
return
}
if count < 0 {
common.SysError(common.LogText("billable tool count ignored: tool=%s count=%d", name, count))
return
}
info.ensureBuiltInTools()
if existing, ok := info.ResponsesUsageInfo.BuiltInTools[name]; ok && existing != nil {
existing.CallCount = count
return
}
info.ResponsesUsageInfo.BuiltInTools[name] = &BuildInToolInfo{ToolName: name, CallCount: count}
}
// ApplyVendorToolUsage writes the vendor-billed counts of one upstream
// response into BuiltInTools. Protocol items counted earlier for the same key
// are replaced; keys the vendor does not mention are kept.
func (info *RelayInfo) ApplyVendorToolUsage(raw []byte) {
if info == nil || info.VendorToolUsage == nil || len(raw) == 0 {
return
}
for name, count := range info.VendorToolUsage(raw) {
info.SetBillableToolCount(name, count)
}
}
// RecordRequestInferredToolCall bills one call of key that the request itself
// proves: a forced hosted search on an upstream whose response reports no
// search usage. The record belongs to the current channel attempt;
// InitChannelMeta drops it when the request moves to another channel.
func (info *RelayInfo) RecordRequestInferredToolCall(key string) {
if info == nil || key == "" {
return
}
info.SetBillableToolCount(key, 1)
info.requestInferredToolKey = key
}
func (info *RelayInfo) ensureBuiltInTools() {
if info.ResponsesUsageInfo == nil {
info.ResponsesUsageInfo = &ResponsesUsageInfo{}
}
if info.ResponsesUsageInfo.BuiltInTools == nil {
info.ResponsesUsageInfo.BuiltInTools = make(map[string]*BuildInToolInfo)
}
}
func resolveWebSearchToolName(tools map[string]*BuildInToolInfo) string {
if _, ok := tools[dto.BuildInToolWebSearchPreview]; ok {
return dto.BuildInToolWebSearchPreview
@@ -125,15 +184,6 @@ func (c *ImageGenerationCallCounter) Observe(item *dto.ResponsesOutput, outputIn
c.count++
}
// Reset clears pending observations (used when a terminal response fails).
func (c *ImageGenerationCallCounter) Reset() {
if c == nil {
return
}
c.seen = nil
c.count = 0
}
// Count returns the deduplicated completed image output count before commit capping.
func (c *ImageGenerationCallCounter) Count() int {
if c == nil {
@@ -148,14 +198,7 @@ func (c *ImageGenerationCallCounter) Commit(info *RelayInfo) {
if info == nil {
return
}
if info.ResponsesUsageInfo == nil {
info.ResponsesUsageInfo = &ResponsesUsageInfo{
BuiltInTools: make(map[string]*BuildInToolInfo),
}
}
if info.ResponsesUsageInfo.BuiltInTools == nil {
info.ResponsesUsageInfo.BuiltInTools = make(map[string]*BuildInToolInfo)
}
info.ensureBuiltInTools()
count := 0
if c != nil {
@@ -174,21 +217,3 @@ func (c *ImageGenerationCallCounter) Commit(info *RelayInfo) {
CallCount: count,
}
}
// IsNonBillableResponsesStatus reports terminal response statuses that must not
// bill pending image_generation observations.
func IsNonBillableResponsesStatus(status []byte) bool {
if len(status) == 0 {
return false
}
var s string
if err := common.Unmarshal(status, &s); err != nil {
return false
}
switch strings.ToLower(strings.TrimSpace(s)) {
case "failed", "cancelled", "canceled", "incomplete":
return true
default:
return false
}
}
+117 -35
View File
@@ -1,11 +1,13 @@
package common
import (
"net/http/httptest"
"strings"
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -35,6 +37,25 @@ func TestCountBillableToolCallWebSearchDefaultsToPreview(t *testing.T) {
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview].CallCount)
}
// A channel whose upstream bills search itself keys web_search_call items to
// its own price, so a mapped claude-* or gpt-* name never bills web_search.
func TestCountBillableToolCallWebSearchUsesChannelBillingKey(t *testing.T) {
info := &RelayInfo{
OriginModelName: "gpt-5.1-codex",
WebSearchBillingKey: "deepseek_search",
ResponsesUsageInfo: &ResponsesUsageInfo{
BuiltInTools: map[string]*BuildInToolInfo{
dto.BuildInToolWebSearch: {ToolName: dto.BuildInToolWebSearch, CallCount: 0},
},
},
}
info.CountBillableToolCall(dto.BuildInCallWebSearchCall, "")
require.Contains(t, info.ResponsesUsageInfo.BuiltInTools, "deepseek_search")
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools["deepseek_search"].CallCount)
assert.Equal(t, 0, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearch].CallCount)
}
func TestCountBillableToolCallFunctionCallRequiresPrice(t *testing.T) {
operation_setting.SetToolPriceForTest("my_priced_fn", 5.0)
t.Cleanup(func() {
@@ -50,19 +71,105 @@ func TestCountBillableToolCallFunctionCallRequiresPrice(t *testing.T) {
assert.NotContains(t, info.ResponsesUsageInfo.BuiltInTools, "unpriced_fn")
}
// Hosted-tool price keys bill hosted tools only: a client function or
// tool_use with the same name is never counted, even when the operator prices
// that name. The reserved set is the built-in price seed plus the hosted
// tools without a built-in price; an operator price alone reserves nothing.
func TestCountBillableToolCallFunctionCallSkipsReservedNames(t *testing.T) {
info := &RelayInfo{OriginModelName: "gpt-5.1"}
wantBilled := map[string]bool{
dto.BuildInToolWebSearchPreview: false,
dto.BuildInToolFileSearch: false,
dto.BuildInToolGoogleSearch: false,
dto.BuildInToolImageGeneration: false,
"bing_web_search": false, // built-in vendor price
GoogleSearchGroundedPromptTool: false, // built-in only for gemini-2.5* and older
"web_fetch": false, // Anthropic server tool, no built-in price
"lookup_order": true, // operator-priced client function
}
for name := range wantBilled {
operation_setting.SetToolPriceForTest(name, 7)
}
t.Cleanup(func() {
for name := range wantBilled {
operation_setting.DeleteToolPriceForTest(name)
}
})
info.CountBillableToolCall(dto.BuildInCallFunctionCall, dto.BuildInToolWebSearchPreview)
info.CountBillableToolCall(dto.BuildInCallFunctionCall, dto.BuildInToolFileSearch)
info.CountBillableToolCall(dto.BuildInCallFunctionCall, dto.BuildInToolGoogleSearch)
info.CountBillableToolCall(dto.BuildInCallFunctionCall, dto.BuildInToolImageGeneration)
for name, billed := range wantBilled {
info := &RelayInfo{OriginModelName: "gpt-5.1"}
info.CountBillableToolCall(dto.BuildInCallFunctionCall, name)
info.CountBillableToolCall(dto.BuildInCallToolUse, name)
if billed {
require.Contains(t, info.ResponsesUsageInfo.BuiltInTools, name)
assert.Equal(t, 2, info.ResponsesUsageInfo.BuiltInTools[name].CallCount, name)
continue
}
assert.NotContains(t, info.ResponsesUsageInfo.BuiltInTools, name)
}
}
if info.ResponsesUsageInfo != nil {
assert.NotContains(t, info.ResponsesUsageInfo.BuiltInTools, dto.BuildInToolWebSearchPreview)
assert.NotContains(t, info.ResponsesUsageInfo.BuiltInTools, dto.BuildInToolFileSearch)
assert.NotContains(t, info.ResponsesUsageInfo.BuiltInTools, dto.BuildInToolGoogleSearch)
assert.NotContains(t, info.ResponsesUsageInfo.BuiltInTools, dto.BuildInToolImageGeneration)
// Upstream-reported counts replace per-item counts for the same key (an
// explicit zero silences them) and ignore negatives; a count the request
// itself proved belongs to one channel attempt, and a retry on another
// channel drops it together with the vendor billing hooks.
func TestBillableToolCountSources(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
record func(c *gin.Context, info *RelayInfo)
want map[string]int
wantVendorHook bool
wantBillingKey string
}{
{
name: "negative upstream count is ignored",
record: func(_ *gin.Context, info *RelayInfo) {
info.SetBillableToolCount(dto.BuildInToolWebSearch, 3)
info.SetBillableToolCount(dto.BuildInToolWebSearch, -5)
},
want: map[string]int{dto.BuildInToolWebSearch: 3},
},
{
name: "vendor zero replaces protocol items",
record: func(_ *gin.Context, info *RelayInfo) {
info.CountBillableToolCall(dto.BuildInCallWebSearchCall, "")
info.CountBillableToolCall(dto.BuildInCallWebSearchCall, "")
info.VendorToolUsage = func([]byte) map[string]int {
return map[string]int{"bing_web_search": 5, dto.BuildInToolWebSearchPreview: 0}
}
info.ApplyVendorToolUsage([]byte(`{}`))
},
want: map[string]int{"bing_web_search": 5, dto.BuildInToolWebSearchPreview: 0},
wantVendorHook: true,
},
{
name: "channel retry drops the request-inferred call and vendor hooks",
record: func(c *gin.Context, info *RelayInfo) {
info.SetBillableToolCount(dto.BuildInToolWebSearch, 2)
info.RecordRequestInferredToolCall("search_strategy_turbo")
info.VendorToolUsage = func([]byte) map[string]int { return nil }
info.WebSearchBillingKey = "search_strategy_agent"
info.WebFetchBillingKey = "deepseek_fetch"
info.InitChannelMeta(c)
},
want: map[string]int{dto.BuildInToolWebSearch: 2},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil)
info := &RelayInfo{OriginModelName: "gpt-5.1"}
tt.record(c, info)
counts := map[string]int{}
for name, tool := range info.ResponsesUsageInfo.BuiltInTools {
counts[name] = tool.CallCount
}
assert.Equal(t, tt.want, counts)
assert.Equal(t, tt.wantVendorHook, info.VendorToolUsage != nil)
assert.Equal(t, tt.wantBillingKey, info.WebSearchBillingKey)
assert.Empty(t, info.WebFetchBillingKey)
})
}
}
@@ -248,20 +355,6 @@ func TestImageGenerationCallCounterCompletedOutputs(t *testing.T) {
},
wantCount: 1,
},
{
name: "output_item.done plus incomplete equals zero",
observe: func(c *ImageGenerationCallCounter) {
idx := 0
c.Observe(&dto.ResponsesOutput{
Type: dto.ResponsesOutputTypeImageGenerationCall,
ID: "img_1",
Status: "completed",
Result: "base64-a",
}, &idx)
c.Reset()
},
wantCount: 0,
},
{
name: "partial event equals zero",
observe: func(c *ImageGenerationCallCounter) {
@@ -335,14 +428,3 @@ func TestImageGenerationCallCounterCommitDoesNotBillDeclarationsAlone(t *testing
(&ImageGenerationCallCounter{}).Commit(info)
assert.Equal(t, 0, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
}
func TestIsNonBillableResponsesStatus(t *testing.T) {
t.Parallel()
assert.True(t, IsNonBillableResponsesStatus([]byte(`"failed"`)))
assert.True(t, IsNonBillableResponsesStatus([]byte(`"incomplete"`)))
assert.True(t, IsNonBillableResponsesStatus([]byte(`"cancelled"`)))
assert.True(t, IsNonBillableResponsesStatus([]byte(`"canceled"`)))
assert.False(t, IsNonBillableResponsesStatus([]byte(`"completed"`)))
assert.False(t, IsNonBillableResponsesStatus(nil))
}
+113
View File
@@ -0,0 +1,113 @@
package common
import (
"crypto/tls"
"maps"
"net/http/httptrace"
"sync"
"time"
)
const UpstreamTimingContextKey = "responses_upstream_timing"
// UpstreamTiming contains only transport metadata and request-relative times.
// HTTP callbacks can run concurrently with streaming and log checkpoints.
type UpstreamTiming struct {
mu sync.Mutex
start time.Time
events map[string]float64
attempt int
channel int
bodyBytes int64
reused bool
gotConn bool
getConnCount int
writeCount int
writeFailed bool
protocol string
status int
}
func NewUpstreamTiming(start time.Time, attempt, channel int, bodyBytes int64) *UpstreamTiming {
if start.IsZero() {
start = time.Now()
}
t := &UpstreamTiming{start: start, events: make(map[string]float64), attempt: attempt, channel: channel, bodyBytes: bodyBytes}
t.Mark("upstream_start")
return t
}
func (t *UpstreamTiming) Mark(event string) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
if _, exists := t.events[event]; !exists {
t.events[event] = float64(time.Since(t.start).Microseconds()) / 1000
}
}
func (t *UpstreamTiming) ClientTrace() *httptrace.ClientTrace {
return &httptrace.ClientTrace{
GetConn: func(string) {
t.Mark("get_connection")
t.mu.Lock()
t.getConnCount++
t.mu.Unlock()
},
DNSStart: func(httptrace.DNSStartInfo) { t.Mark("dns_start") },
DNSDone: func(httptrace.DNSDoneInfo) { t.Mark("dns_done") },
ConnectStart: func(string, string) { t.Mark("tcp_start") },
ConnectDone: func(string, string, error) { t.Mark("tcp_done") },
TLSHandshakeStart: func() { t.Mark("tls_start") },
TLSHandshakeDone: func(tls.ConnectionState, error) { t.Mark("tls_done") },
GotConn: func(info httptrace.GotConnInfo) {
t.Mark("connection_ready")
t.mu.Lock()
t.reused, t.gotConn = info.Reused, true
t.mu.Unlock()
},
WroteRequest: func(info httptrace.WroteRequestInfo) {
t.mu.Lock()
defer t.mu.Unlock()
t.writeCount++
t.writeFailed = t.writeFailed || info.Err != nil
if info.Err == nil {
t.events["request_sent"] = float64(time.Since(t.start).Microseconds()) / 1000
}
},
GotFirstResponseByte: func() { t.Mark("response_first_byte") },
}
}
func (t *UpstreamTiming) Response(protocol string, status int) {
if t == nil {
return
}
t.Mark("response_headers")
t.mu.Lock()
defer t.mu.Unlock()
t.protocol, t.status = protocol, status
}
func (t *UpstreamTiming) Snapshot() map[string]any {
if t == nil {
return nil
}
t.mu.Lock()
defer t.mu.Unlock()
events := maps.Clone(t.events)
result := map[string]any{
"events_ms": events, "attempt": t.attempt, "channel_id": t.channel,
"request_body_bytes": t.bodyBytes, "get_connection_count": t.getConnCount,
"write_count": t.writeCount, "write_failed": t.writeFailed,
}
if t.gotConn {
result["connection_reused"] = t.reused
}
if t.protocol != "" {
result["protocol"], result["status"] = t.protocol, t.status
}
return result
}
+2 -2
View File
@@ -88,7 +88,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
var containsAudioRatios = ratio_setting.ContainsAudioRatio(info.OriginModelName) || ratio_setting.ContainsAudioCompletionRatio(info.OriginModelName)
if containAudioTokens && containsAudioRatios {
service.PostAudioConsumeQuota(c, info, usage, "")
service.PostAudioConsumeQuota(c, info, usage, nil)
} else {
service.PostTextConsumeQuota(c, info, usage, nil)
}
@@ -179,7 +179,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
var containsAudioRatios = ratio_setting.ContainsAudioRatio(info.OriginModelName) || ratio_setting.ContainsAudioCompletionRatio(info.OriginModelName)
if containAudioTokens && containsAudioRatios {
service.PostAudioConsumeQuota(c, info, usage.(*dto.Usage), "")
service.PostAudioConsumeQuota(c, info, usage.(*dto.Usage), nil)
} else {
service.PostTextConsumeQuota(c, info, usage.(*dto.Usage), nil)
}
+7
View File
@@ -49,6 +49,9 @@ const (
RelayModeResponsesCompact
RelayModeAlphaSearch
RelayModeToolsSearch
RelayModeToolsFetch
)
func Path2RelayMode(path string) int {
@@ -75,6 +78,10 @@ func Path2RelayMode(path string) int {
relayMode = RelayModeResponses
} else if strings.HasPrefix(path, "/v1/alpha/search") {
relayMode = RelayModeAlphaSearch
} else if strings.HasPrefix(path, "/v1/tools/search") {
relayMode = RelayModeToolsSearch
} else if strings.HasPrefix(path, "/v1/tools/fetch") {
relayMode = RelayModeToolsFetch
} else if strings.HasPrefix(path, "/v1/audio/speech") {
relayMode = RelayModeAudioSpeech
} else if strings.HasPrefix(path, "/v1/audio/transcriptions") {
+2
View File
@@ -13,6 +13,8 @@ func TestPath2RelayMode(t *testing.T) {
}{
{path: "/v1/alpha/search", want: RelayModeAlphaSearch},
{path: "/v1/alpha/search?foo=1", want: RelayModeAlphaSearch},
{path: "/v1/tools/search", want: RelayModeToolsSearch},
{path: "/v1/tools/fetch", want: RelayModeToolsFetch},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
+43 -28
View File
@@ -45,20 +45,18 @@ func (*imageReservation) NeedsRefund() bool { return false }
func TestImageRequestReservesFinalQuantityBeforeUpstream(t *testing.T) {
service.InitHttpClient()
for _, tc := range []struct {
name, body string
tiered, passThrough, insufficient, retryToOpenAI bool
override any
count, status int
name, body string
tiered, passThrough, insufficient, retryReset bool
override any
count, status int
}{
{name: "Ali nested count", body: `{"model":"z-image","n":1,"parameters":{"n":4}}`, count: 4},
{name: "Ali empty parameters", body: `{"model":"z-image","n":2,"parameters":{}}`, count: 2},
{name: "legacy channel quantity override", body: `{"model":"z-image","n":1}`, override: 4, count: 4},
{name: "expression channel quantity override", body: `{"model":"z-image","n":1}`, override: 4, count: 4, tiered: true},
{name: "pass-through quantity", body: `{"model":"z-image","n":2,"parameters":{}}`, count: 2, passThrough: true},
{name: "zero override rejected", body: `{"model":"z-image","n":1}`, override: 0, status: http.StatusBadRequest},
{name: "oversized override rejected", body: `{"model":"z-image","n":1}`, override: 129, status: http.StatusBadRequest},
{name: "insufficient reservation blocks upstream", body: `{"model":"z-image","n":1}`, override: 4, count: 4, insufficient: true, status: http.StatusForbidden},
{name: "retry drops Ali quantity and surcharge", body: `{"model":"z-image","n":1,"parameters":{"n":4,"prompt_extend":true}}`, count: 1, retryToOpenAI: true},
{name: "top-level count", body: `{"model":"gpt-image-2","n":2,"parameters":{}}`, count: 2},
{name: "legacy channel quantity override", body: `{"model":"gpt-image-2","n":1}`, override: 4, count: 4},
{name: "expression channel quantity override", body: `{"model":"gpt-image-2","n":1}`, override: 4, count: 4, tiered: true},
{name: "pass-through quantity", body: `{"model":"gpt-image-2","n":2,"parameters":{}}`, count: 2, passThrough: true},
{name: "oversized override rejected", body: `{"model":"gpt-image-2","n":1}`, override: 129, status: http.StatusBadRequest},
{name: "insufficient reservation blocks upstream", body: `{"model":"gpt-image-2","n":1}`, override: 4, count: 4, insufficient: true, status: http.StatusForbidden},
{name: "retry drops the previous attempt's quantity", body: `{"model":"gpt-image-2","n":1}`, count: 1, retryReset: true},
} {
t.Run(tc.name, func(t *testing.T) {
received := make(chan []byte, 1)
@@ -75,15 +73,11 @@ func TestImageRequestReservesFinalQuantityBeforeUpstream(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", strings.NewReader(tc.body))
c.Request.Header.Set("Content-Type", "application/json")
channel := constant.ChannelTypeAli
if tc.retryToOpenAI {
channel = constant.ChannelTypeOpenAI
}
common.SetContextKey(c, constant.ContextKeyChannelType, channel)
common.SetContextKey(c, constant.ContextKeyChannelType, constant.ChannelTypeOpenAI)
common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, upstream.URL)
common.SetContextKey(c, constant.ContextKeyChannelSetting, dto.ChannelSettings{PassThroughBodyEnabled: tc.passThrough})
if tc.override != nil {
common.SetContextKey(c, constant.ContextKeyChannelParamOverride, map[string]any{"operations": []any{map[string]any{"path": "parameters.n", "mode": "set", "value": tc.override}}})
common.SetContextKey(c, constant.ContextKeyChannelParamOverride, map[string]any{"operations": []any{map[string]any{"path": "n", "mode": "set", "value": tc.override}}})
}
request, err := helper.GetAndValidOpenAIImageRequest(c, relayconstant.RelayModeImagesGenerations)
require.NoError(t, err)
@@ -91,7 +85,7 @@ func TestImageRequestReservesFinalQuantityBeforeUpstream(t *testing.T) {
if tc.insufficient {
reservation.limit = reservation.held
}
info := &relaycommon.RelayInfo{Request: request, OriginModelName: "z-image", RelayMode: relayconstant.RelayModeImagesGenerations,
info := &relaycommon.RelayInfo{Request: request, OriginModelName: "gpt-image-2", RelayMode: relayconstant.RelayModeImagesGenerations,
RequestURLPath: c.Request.URL.Path, Billing: reservation,
PriceData: hosttypes.PriceData{UsePrice: true, ModelPrice: 0.04, GroupRatioInfo: hosttypes.GroupRatioInfo{GroupRatio: 1}},
}
@@ -101,10 +95,9 @@ func TestImageRequestReservesFinalQuantityBeforeUpstream(t *testing.T) {
info.BillingRequestInput = &billingexpr.RequestInput{Body: []byte(tc.body), ImageCount: common.GetPointer(1)}
info.PriceData.UsePrice = false
}
if tc.retryToOpenAI {
reservation.held = 160000
if tc.retryReset {
reservation.held = 80000
info.PriceData.AddOtherRatio("n", 4)
info.PriceData.AddOtherRatio("prompt_extend", 2)
info.BillingImageCount = common.GetPointer(3)
}
apiErr := ImageHelper(c, info)
@@ -117,11 +110,7 @@ func TestImageRequestReservesFinalQuantityBeforeUpstream(t *testing.T) {
assert.Equal(t, http.StatusBadGateway, apiErr.StatusCode)
require.Len(t, received, 1)
body := <-received
path := "parameters.n"
if tc.retryToOpenAI {
path = "n"
}
assert.Equal(t, int64(tc.count), gjson.GetBytes(body, path).Int())
assert.Equal(t, int64(tc.count), gjson.GetBytes(body, "n").Int())
assert.Equal(t, tc.count*20000, info.PriceData.QuotaToPreConsume)
assert.GreaterOrEqual(t, reservation.held, info.PriceData.QuotaToPreConsume)
assert.Nil(t, info.BillingImageCount)
@@ -299,3 +288,29 @@ func TestGeminiThinkingControlsConvertBestEffort(t *testing.T) {
assert.True(t, hasHostDiagnosticCode(info.ConversionDiagnostics(), "gemini_budget_to_level"))
})
}
// An Ali image model the alibaba task plugin does not claim is rejected by the
// adaptor with a classified 400 that skips retries. ImageHelper must surface
// that classification unchanged instead of wrapping it into a retryable
// conversion failure that other channels would then be asked to serve.
func TestImageHelperKeepsAdaptorClassifiedConvertError(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", strings.NewReader(`{"model":"wanx-style-repaint-v1","prompt":"a cat"}`))
c.Request.Header.Set("Content-Type", "application/json")
common.SetContextKey(c, constant.ContextKeyChannelType, constant.ChannelTypeAli)
common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, "https://dashscope.invalid")
request, err := helper.GetAndValidOpenAIImageRequest(c, relayconstant.RelayModeImagesGenerations)
require.NoError(t, err)
info := &relaycommon.RelayInfo{Request: request, OriginModelName: "wanx-style-repaint-v1", RelayMode: relayconstant.RelayModeImagesGenerations,
RequestURLPath: c.Request.URL.Path, Billing: &imageReservation{limit: 500000},
PriceData: hosttypes.PriceData{UsePrice: true, ModelPrice: 0.04, GroupRatioInfo: hosttypes.GroupRatioInfo{GroupRatio: 1}},
}
apiErr := ImageHelper(c, info)
require.NotNil(t, apiErr)
assert.Equal(t, http.StatusBadRequest, apiErr.StatusCode)
assert.Equal(t, types.ErrorCodeInvalidRequest, apiErr.GetErrorCode())
assert.True(t, types.IsSkipRetryError(apiErr), "other channels must not be asked to serve the same name")
assert.Contains(t, apiErr.Error(), "not served by the alibaba task plugin")
}
+127
View File
@@ -0,0 +1,127 @@
package relay
import (
"errors"
"fmt"
"io"
"net/http"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
)
// DeepSeekToolsHelper relays the DeepSeek Search and Fetch APIs like
// /v1/alpha/search: the raw body is forwarded, the response is returned
// unchanged, and the call is billed at the routing name's request price with
// no tokens.
func DeepSeekToolsHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) {
info.InitChannelMeta(c)
switch info.ChannelType {
case constant.ChannelTypeDeepSeek,
constant.ChannelTypeNewAPI,
constant.ChannelTypeSub2API,
constant.ChannelTypeAdvancedCustom:
default:
// Allow retry onto another channel that may support this endpoint.
return types.NewError(
fmt.Errorf("channel does not support %s", c.Request.URL.Path),
types.ErrorCodeInvalidRequest,
)
}
request, ok := info.Request.(*dto.DeepSeekToolsRequest)
if !ok {
return types.NewErrorWithStatusCode(
fmt.Errorf("invalid request type, expected *dto.DeepSeekToolsRequest, got %T", info.Request),
types.ErrorCodeInvalidRequest,
http.StatusBadRequest,
types.ErrOptionWithSkipRetry(),
)
}
err := helper.ModelMappedHelper(c, info, request)
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
jsonData, err := buildAlphaSearchRequestBody(request.RawBody, info.OriginModelName, info.UpstreamModelName)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info)
if err != nil {
return newAPIErrorFromParamOverride(err)
}
}
logger.LogDebug(c, "requestBody: %s", jsonData)
body, closer, err := relaycommon.NewOutboundJSONBody(jsonData)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
defer closer.Close()
adaptor := GetAdaptor(info.ApiType)
if adaptor == nil {
return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry())
}
adaptor.Init(info)
resp, err := adaptor.DoRequest(c, info, body)
if err != nil {
return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError)
}
statusCodeMappingStr := c.GetString("status_code_mapping")
httpResp, ok := resp.(*http.Response)
if !ok || httpResp == nil {
return types.NewOpenAIError(errors.New("invalid http response"), types.ErrorCodeDoRequestFailed, http.StatusInternalServerError)
}
defer httpResp.Body.Close()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError
}
responseBody, err := io.ReadAll(httpResp.Body)
if err != nil {
return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError, types.ErrOptionWithSkipRetry())
}
service.IOCopyBytesGracefully(c, httpResp, responseBody)
// DeepSeek bills a successful request only when it returns something: a
// non-empty results array for Search, page content for Fetch. A billable
// call settles at the routing name's request price; any other response
// releases the reservation.
var billable bool
switch info.RelayMode {
case relayconstant.RelayModeToolsFetch:
billable = gjson.GetBytes(responseBody, "content").String() != ""
default:
results := gjson.GetBytes(responseBody, "results")
billable = results.IsArray() && len(results.Array()) > 0
}
if !billable {
if info.Billing != nil {
info.Billing.Refund(c)
}
return nil
}
service.PostTextConsumeQuota(c, info, &dto.Usage{}, nil)
return nil
}
+2 -2
View File
@@ -128,7 +128,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
resp, err := adaptor.DoRequest(c, info, requestBody)
if err != nil {
logger.LogError(c, "Do gemini request failed: "+err.Error())
logger.LogError(c, common.LogText("Do gemini request failed: %s", err.Error()))
return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError)
}
@@ -234,7 +234,7 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo) (newAPI
resp, err := adaptor.DoRequest(c, info, requestBody)
if err != nil {
logger.LogError(c, "Do gemini request failed: "+err.Error())
logger.LogError(c, common.LogText("Do gemini request failed: %s", err.Error()))
return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError)
}
+2 -11
View File
@@ -5,7 +5,6 @@ import (
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
@@ -42,19 +41,11 @@ func ResolveImageBillingRequestInput(c *gin.Context, info *relaycommon.RelayInfo
if !ok {
return input, nil
}
channelType := common.GetContextKeyInt(c, constant.ContextKeyChannelType)
if info.ChannelMeta != nil {
channelType = info.ChannelType
}
count, err := request.ImageCount(channelType == constant.ChannelTypeAli)
count, err := request.ImageCount(false)
if err != nil {
return input, err
}
topLevelCount, err := request.ImageCount(false)
if err != nil {
return input, err
}
body := map[string]any{"model": request.Model, "n": topLevelCount, "size": request.Size, "quality": request.Quality}
body := map[string]any{"model": request.Model, "n": count, "size": request.Size, "quality": request.Quality}
if request.BillingParameters != nil {
body["parameters"] = request.BillingParameters
}
+13 -4
View File
@@ -7,6 +7,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
@@ -65,7 +66,7 @@ func ClaudeData(c *gin.Context, resp dto.ClaudeResponse) error {
jsonData, err := common.Marshal(resp)
if err != nil {
common.SysError("error marshalling stream response: " + err.Error())
common.SysError(common.LogText("error marshalling stream response: %s", 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)})
@@ -91,7 +92,15 @@ func ResponseChunkData(c *gin.Context, resp dto.ResponsesStreamResponse, data st
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)
err := FlushWriter(c)
if err == nil {
if value, exists := c.Get(relaycommon.UpstreamTimingContextKey); exists {
if timing, ok := value.(*relaycommon.UpstreamTiming); ok {
timing.Mark("first_downstream_flush")
}
}
}
return err
}
func StringData(c *gin.Context, str string) error {
@@ -139,7 +148,7 @@ func Done(c *gin.Context) {
func WssString(c *gin.Context, ws *websocket.Conn, str string) error {
if ws == nil {
logger.LogError(c, "websocket connection is nil")
logger.LogError(c, common.LogText("websocket connection is nil"))
return errors.New("websocket connection is nil")
}
//common.LogInfo(c, fmt.Sprintf("sending message: %s", str))
@@ -152,7 +161,7 @@ func WssObject(c *gin.Context, ws *websocket.Conn, object any) error {
return fmt.Errorf("error marshalling object: %w", err)
}
if ws == nil {
logger.LogError(c, "websocket connection is nil")
logger.LogError(c, common.LogText("websocket connection is nil"))
return errors.New("websocket connection is nil")
}
//common.LogInfo(c, fmt.Sprintf("sending message: %s", jsonData))
+4 -4
View File
@@ -19,16 +19,16 @@ func TestGroupModelPricingPreservesOtherGroupsAndSnapshot(t *testing.T) {
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}`,
"billing_setting.group_billing_expr": `{"gpt-v-pro":{"gpt-5.6-sol":"tier(\"standard\", p * 5 + c * 30 + cr * 0.5)"}}`,
"group_ratio_setting.group_ratio": `{"gpt-v-pro":0.2052,"gpt-v-mix":0.18}`,
}))
for _, tc := range []struct {
group string
expected int
cost float64
}{
{"example-premium", 1000, 8500},
{"example-standard", 1500, 6400},
{"gpt-v-pro", 513, 8500},
{"gpt-v-mix", 360, 6400},
} {
t.Run(tc.group, func(t *testing.T) {
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
@@ -12,9 +12,9 @@ func TestImageGenerationResolutionMapping(t *testing.T) {
cfg := model_setting.GetGlobalSettings()
oldGroup, oldModels := cfg.ImageGenerationGroup, cfg.ImageGenerationModels
t.Cleanup(func() { cfg.ImageGenerationGroup, cfg.ImageGenerationModels = oldGroup, oldModels })
cfg.ImageGenerationGroup, cfg.ImageGenerationModels = "example-images", []string{"gpt-image-2", "gpt-image-2-4k"}
cfg.ImageGenerationGroup, cfg.ImageGenerationModels = "gpt-images", []string{"gpt-image-2", "gpt-image-2-4k"}
for _, tc := range []struct{ size, tier string }{{"", "1k"}, {"auto", "1k"}, {"1024x1536", "1k"}, {"1536x1024", "1k"}, {"2048x1152", "2k"}, {"2048x2048", "2k"}, {"3840x2160", "4k"}, {"2160x3840", "4k"}, {"4k", "4k"}} {
info := &relaycommon.RelayInfo{OriginModelName: "gpt-image-2", UsingGroup: "example-images", ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gpt-image-2"}}
info := &relaycommon.RelayInfo{OriginModelName: "gpt-image-2", UsingGroup: "gpt-images", ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gpt-image-2"}}
image := &dto.ImageRequest{Model: "gpt-image-2", Size: tc.size}
require.NoError(t, mapImageGenerationResolution(info, image))
require.Equal(t, "gpt-image-2-"+tc.tier, info.UpstreamModelName)
+24 -20
View File
@@ -115,38 +115,42 @@ func TestGetAndValidOpenAIImageRequestMultipartStream(t *testing.T) {
})
}
func TestImageBillingRequestUsesValidatedProviderCount(t *testing.T) {
// Provider `parameters.n` is still bounded at ingress so a malformed multiplier
// is a client error, but the billing quantity always follows the top-level n:
// Ali image requests are served by the alibaba task plugin, which owns the
// DashScope parameters.n semantics.
func TestImageBillingRequestValidatesProviderCountWithoutOverriding(t *testing.T) {
for _, tc := range []struct {
body string
channel, count int
invalid bool
body string
count int
invalid bool
}{
{`{"model":"z-image","n":2,"parameters":{"n":3,"prompt_extend":true}}`, constant.ChannelTypeAli, 3, false},
{`{"model":"gpt-image-2","n":2,"parameters":{"n":3}}`, constant.ChannelTypeOpenAI, 2, false},
{`{"model":"z-image","n":2,"parameters":{"n":129}}`, constant.ChannelTypeAli, 0, true},
{`{"model":"z-image","parameters":{"n":-1}}`, constant.ChannelTypeAli, 0, true},
{`{"model":"z-image","n":2,"parameters":{}}`, constant.ChannelTypeAli, 2, false},
{`{"model":"z-image","n":2,"parameters":{"n":null}}`, constant.ChannelTypeAli, 2, false},
{`{"model":"z-image","n":2,"parameters":{"n":0}}`, constant.ChannelTypeAli, 0, true},
{`{"model":"z-image","parameters":{"n":1.5}}`, constant.ChannelTypeAli, 0, true},
{`{"model":"z-image","parameters":{"n":18446744073686646784}}`, constant.ChannelTypeAli, 0, true},
{`{"model":"z-image","parameters":{"n":128}}`, constant.ChannelTypeAli, 128, false},
{`{"model":"z-image","n":0}`, constant.ChannelTypeAli, 1, false},
{`{"model":"gpt-image-2","n":2,"parameters":{"n":0}}`, constant.ChannelTypeOpenAI, 2, false},
{`{"model":"z-image","n":2,"parameters":{"n":3,"prompt_extend":true}}`, 2, false},
{`{"model":"gpt-image-2","n":2,"parameters":{"n":3}}`, 2, false},
{`{"model":"z-image","n":2,"parameters":{"n":129}}`, 0, true},
{`{"model":"z-image","parameters":{"n":-1}}`, 0, true},
{`{"model":"z-image","n":2,"parameters":{}}`, 2, false},
{`{"model":"z-image","n":2,"parameters":{"n":null}}`, 2, false},
{`{"model":"z-image","n":2,"parameters":{"n":0}}`, 2, false},
{`{"model":"z-image","parameters":{"n":1.5}}`, 0, true},
{`{"model":"z-image","parameters":{"n":18446744073686646784}}`, 0, true},
{`{"model":"z-image","parameters":{"n":128}}`, 1, false},
{`{"model":"z-image","n":0}`, 1, false},
{`{"model":"gpt-image-2","n":2,"parameters":{"n":0}}`, 2, false},
} {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewBufferString(tc.body))
c.Request.Header.Set("Content-Type", "application/json")
common.SetContextKey(c, constant.ContextKeyChannelType, tc.channel)
common.SetContextKey(c, constant.ContextKeyChannelType, constant.ChannelTypeOpenAI)
request, err := GetAndValidOpenAIImageRequest(c, relayconstant.RelayModeImagesGenerations)
if tc.invalid {
require.Error(t, err)
require.Error(t, err, tc.body)
continue
}
require.NoError(t, err)
require.NoError(t, err, tc.body)
input, err := ResolveImageBillingRequestInput(c, &relaycommon.RelayInfo{Request: request}, billingexpr.RequestInput{})
require.NoError(t, err)
require.Equal(t, tc.count, *input.ImageCount)
require.Equal(t, tc.count, *input.ImageCount, tc.body)
cost, _, err := billingexpr.RunExprWithRequest(`tier("image", fixed(0.04)) * image_count`, billingexpr.TokenParams{}, input)
require.NoError(t, err)
require.Equal(t, float64(tc.count)*40000, cost)
+55 -45
View File
@@ -1,11 +1,12 @@
package helper
import (
"errors"
"fmt"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
@@ -14,6 +15,7 @@ import (
"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/model_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"
@@ -22,29 +24,17 @@ import (
"github.com/gin-gonic/gin"
)
func modelPriceNotConfiguredError(modelName string, userId int) error {
func modelPriceNotConfiguredError(c *gin.Context, modelName string, userId int) error {
key := i18n.MsgRelayModelPriceNotConfigured
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,
)
key = i18n.MsgRelayModelPriceNotConfiguredAdmin
}
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,
)
return errors.New(i18n.T(c, key, map[string]any{"Model": 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 {
if autoGroup, exists := ctx.Get("auto_group"); exists {
@@ -54,6 +44,16 @@ func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) hostty
return model.EffectiveGroupRatio(ctx, relayInfo.UserGroup, relayInfo.UsingGroup)
}
func deepSeekOfficialGroupRatio(info *relaycommon.RelayInfo, ratio hosttypes.GroupRatioInfo) hosttypes.GroupRatioInfo {
if info == nil || info.UsingGroup != billing_setting.DeepSeekOfficialGroup {
return ratio
}
return hosttypes.GroupRatioInfo{
GroupRatio: billing_setting.DeepSeekOfficialGroupRatio,
GroupSpecialRatio: -1,
}
}
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 {
@@ -65,10 +65,16 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
groupRatioInfo := HandleGroupRatio(c, info)
if info.UsingGroup == billing_setting.DeepSeekOfficialGroup {
if _, ok := billing_setting.GetGroupBillingExpr(info.UsingGroup, billingModelName); ok {
groupRatioInfo = deepSeekOfficialGroupRatio(info, groupRatioInfo)
}
}
// 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)
return modelPriceHelperTiered(c, info, billingModelName, promptTokens, groupRatioInfo)
}
var preConsumedQuota int
@@ -83,10 +89,11 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
var audioCompletionRatio float64
var freeModel bool
if !usePrice {
preConsumedTokens := common.Max(promptTokens, common.PreConsumedQuota)
if meta.MaxTokens != 0 {
preConsumedTokens += meta.MaxTokens
preConsumeMultiplier, err := operation_setting.InputPreConsumeMultiplier()
if err != nil {
return hosttypes.PriceData{}, err
}
preConsumedTokens := float64(promptTokens) * preConsumeMultiplier
var success bool
var matchName string
modelRatio, success, matchName = ratio_setting.GetModelRatio(billingModelName)
@@ -96,7 +103,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
acceptUnsetRatio = true
}
if !acceptUnsetRatio {
return hosttypes.PriceData{}, modelPriceNotConfiguredError(matchName, info.UserId)
return hosttypes.PriceData{}, modelPriceNotConfiguredError(c, matchName, info.UserId)
}
}
completionRatio = ratio_setting.GetCompletionRatio(billingModelName)
@@ -109,13 +116,13 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
audioRatio = ratio_setting.GetAudioRatio(billingModelName)
audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(billingModelName)
ratio := modelRatio * groupRatioInfo.GroupRatio
quota, err := common.QuotaFromFloatStrict(float64(preConsumedTokens) * ratio)
quota, err := common.QuotaFromFloatStrict(preConsumedTokens * ratio)
if err != nil {
return hosttypes.PriceData{}, err
}
preConsumedQuota = quota
if _, image := info.Request.(*dto.ImageRequest); image {
info.ImageQuotaBeforeGroup = float64(preConsumedTokens) * modelRatio
info.ImageQuotaBeforeGroup = preConsumedTokens * modelRatio
}
} else {
if meta.ImagePriceRatio != 0 {
@@ -167,25 +174,13 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
}
}
if request, image := info.Request.(*dto.ImageRequest); image {
channelType := common.GetContextKeyInt(c, constant.ContextKeyChannelType)
count, err := request.ImageCount(channelType == constant.ChannelTypeAli)
count, err := request.ImageCount(false)
if err != nil {
return hosttypes.PriceData{}, err
}
if usePrice || channelType == constant.ChannelTypeAli {
if usePrice {
priceData.AddOtherRatio("n", float64(count))
}
if channelType == constant.ChannelTypeAli && request.BillingParameters != nil && request.BillingParameters.PromptExtend != nil && *request.BillingParameters.PromptExtend {
// Resolve only routing identity; do not initialize ChannelMeta on the
// real request, which also distinguishes the first channel attempt.
mapped := &relaycommon.RelayInfo{OriginModelName: info.OriginModelName, ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: info.OriginModelName}}
if err := ModelMappedHelper(c, mapped, nil); err != nil {
return hosttypes.PriceData{}, err
}
if strings.Contains(mapped.UpstreamModelName, "z-image") {
priceData.AddOtherRatio("prompt_extend", common.ZImagePromptExtendMultiplier)
}
}
if !usePrice {
quota, err := common.QuotaFromFloatStrict(priceData.ApplyOtherRatiosToFloat(info.ImageQuotaBeforeGroup * groupRatioInfo.GroupRatio))
if err != nil {
@@ -232,7 +227,7 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (hostt
acceptUnsetRatio = true
}
if !ratioSuccess && !acceptUnsetRatio {
return hosttypes.PriceData{}, modelPriceNotConfiguredError(matchName, info.UserId)
return hosttypes.PriceData{}, modelPriceNotConfiguredError(c, matchName, info.UserId)
}
}
}
@@ -314,6 +309,11 @@ func resolveBillingModelName(origin string) string {
}
candidates = append(candidates, hostreasoning.CanonicalBillingModelNames(origin)...)
base := hostreasoning.BaseModelName(origin)
// DeepSeek thinking tails (-none/-low/-max) bill at the base model's price,
// with or without @ modifiers after them.
if deepSeekBase, _, _, ok := reasoning.ParseDeepSeekV4ThinkingSuffix(base); ok && !model_setting.ShouldPreserveThinkingSuffix(base) {
candidates = append(candidates, deepSeekBase)
}
candidates = append(candidates, base)
seen := make(map[string]struct{}, len(candidates))
@@ -337,19 +337,23 @@ func resolveBillingModelName(origin string) string {
return matched
}
func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, billingModelName string, promptTokens int, meta *types.TokenCountMeta, groupRatioInfo hosttypes.GroupRatioInfo) (hosttypes.PriceData, error) {
func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, billingModelName string, promptTokens int, 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)
}
return modelPriceHelperTieredWithExpr(c, info, billingModelName, promptTokens, groupRatioInfo, exprStr)
}
func modelPriceHelperTieredWithExpr(c *gin.Context, info *relaycommon.RelayInfo, billingModelName string, promptTokens int, groupRatioInfo hosttypes.GroupRatioInfo, exprStr string) (hosttypes.PriceData, error) {
exprHash := billingexpr.ExprHashString(exprStr)
if info.RelayFormat == types.RelayFormatOpenAIRealtime && billingexpr.UsesFixedPricingByHash(exprStr, exprHash) {
return hosttypes.PriceData{}, fmt.Errorf("fixed pricing is not supported for Realtime requests")
}
estimatedCompletionTokens := meta.MaxTokens
if estimatedCompletionTokens == 0 && groupRatioInfo.GroupRatio != 0 {
estimatedCompletionTokens = defaultTieredPreConsumeMaxTokens
preConsumeMultiplier, err := operation_setting.InputPreConsumeMultiplier()
if err != nil {
return hosttypes.PriceData{}, err
}
requestInput, err := ResolveIncomingBillingExprRequestInput(c, info)
@@ -365,7 +369,7 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, billing
rawCost, trace, err := billingexpr.RunExprByHashWithRequest(exprStr, exprHash, billingexpr.TokenParams{
P: float64(promptTokens),
C: float64(estimatedCompletionTokens),
C: 0,
Len: float64(promptTokens),
}, requestInput)
if err != nil {
@@ -374,6 +378,11 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, billing
// Expression coefficients are $/1M tokens prices; convert to quota the same way per-call billing does.
quotaBeforeGroup := rawCost / 1_000_000 * common.QuotaPerUnit
// Scale the reservation, preserving the expression's context-length tier
// and leaving actual settlement and fixed request prices unchanged.
if trace.BillingUnit != billingexpr.BillingUnitRequest {
quotaBeforeGroup *= preConsumeMultiplier
}
preConsumedQuota, err := billingexpr.QuotaRoundStrict(quotaBeforeGroup * groupRatioInfo.GroupRatio)
if err != nil {
return hosttypes.PriceData{}, err
@@ -395,7 +404,8 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, billing
ExprHash: exprHash,
GroupRatio: groupRatioInfo.GroupRatio,
EstimatedPromptTokens: promptTokens,
EstimatedCompletionTokens: estimatedCompletionTokens,
EstimatedCompletionTokens: 0,
PreConsumeMultiplier: preConsumeMultiplier,
EstimatedQuotaBeforeGroup: quotaBeforeGroup,
EstimatedQuotaAfterGroup: preConsumedQuota,
EstimatedTier: trace.MatchedTier,
+172 -33
View File
@@ -1,15 +1,19 @@
package helper
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"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/service"
"github.com/QuantumNous/new-api/setting/billing_setting"
"github.com/QuantumNous/new-api/setting/config"
"github.com/QuantumNous/new-api/setting/model_setting"
@@ -20,6 +24,39 @@ import (
"github.com/stretchr/testify/require"
)
func TestDeepSeekOfficialGroupUsesFixed045Ratio(t *testing.T) {
savedRatios := ratio_setting.GroupRatio2JSONString()
t.Cleanup(func() { require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(savedRatios)) })
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"Deepseek官Key":0.3,"default":1}`))
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
info := &relaycommon.RelayInfo{
OriginModelName: "deepseek-v4.1-flash",
UserGroup: "default",
UsingGroup: billing_setting.DeepSeekOfficialGroup,
ChannelMeta: &relaycommon.ChannelMeta{ChannelType: constant.ChannelTypeOpenAI},
BillingRequestInput: &billingexpr.RequestInput{
Body: []byte(`{}`),
},
}
price, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
require.NoError(t, err)
assert.Equal(t, billing_setting.DeepSeekOfficialGroupRatio, price.GroupRatioInfo.GroupRatio)
assert.Equal(t, billing_setting.DeepSeekOfficialGroupRatio, info.TieredBillingSnapshot.GroupRatio)
assert.Equal(t, billing_setting.BillingModeTieredExpr, info.TieredBillingSnapshot.BillingMode)
expression, ok := billing_setting.GetGroupBillingExpr(info.UsingGroup, info.GetBillingModelName())
require.True(t, ok)
assert.Equal(t, expression, info.TieredBillingSnapshot.ExprString)
info.ChannelMeta.ChannelType = constant.ChannelTypeDeepSeek
_, err = ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
require.NoError(t, err)
assert.Equal(t, expression, info.TieredBillingSnapshot.ExprString)
_, hasDomesticOverride := billing_setting.GetGroupBillingExpr("国模", info.GetBillingModelName())
assert.False(t, hasDomesticOverride)
}
func TestModelPriceHelperTieredUsesPreloadedRequestInput(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -109,7 +146,7 @@ func TestFixedPricePreConsumeAndRealtimeRejection(t *testing.T) {
}
}
func TestModelPriceHelperTieredPreConsumeMaxTokensFallback(t *testing.T) {
func TestModelPriceHelperTieredInputPreConsumeMultiplier(t *testing.T) {
gin.SetMode(gin.TestMode)
saved := map[string]string{}
@@ -123,41 +160,25 @@ func TestModelPriceHelperTieredPreConsumeMaxTokensFallback(t *testing.T) {
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)"}`,
"billing_setting.billing_expr": `{"tiered-fallback-model":"len <= 1200 ? tier(\"base\", p * 3 + c * 15) : tier(\"long\", p * 30 + c * 150)"}`,
"group_ratio_setting.group_ratio": `{"default":1,"free":0}`,
}))
const promptTokens = 1000
cases := []struct {
name string
group string
maxTokens int
expected int
name string
group string
prompt int
maxTokens int
multiplier float64
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,
},
{"default uses only input", "default", 1000, 0, 1, 1500},
{"explicit output limit does not increase reservation", "default", 1000, 100, 1, 1500},
{"fraction below one", "default", 1000, 0, 0.5, 750},
{"fraction above one preserves context tier", "default", 1000, 0, 2.5, 3750},
{"small input has no token floor", "default", 100, 0, 1, 150},
{"zero input", "default", 0, 100, 1, 0},
{"free group", "free", 1000, 0, 2.5, 0},
}
for _, tc := range cases {
@@ -180,9 +201,18 @@ func TestModelPriceHelperTieredPreConsumeMaxTokensFallback(t *testing.T) {
},
}
priceData, err := ModelPriceHelper(ctx, info, promptTokens, &types.TokenCountMeta{MaxTokens: tc.maxTokens})
operation_setting.GetQuotaSetting().PreConsumeMultiplier = tc.multiplier
priceData, err := ModelPriceHelper(ctx, info, tc.prompt, &types.TokenCountMeta{MaxTokens: tc.maxTokens})
require.NoError(t, err)
require.Equal(t, tc.expected, priceData.QuotaToPreConsume)
assert.Equal(t, tc.expected, priceData.QuotaToPreConsume)
require.NotNil(t, info.TieredBillingSnapshot)
actual, err := billingexpr.ComputeTieredQuotaWithRequest(info.TieredBillingSnapshot, billingexpr.TokenParams{P: 1000, C: 100, Len: 1000}, *info.BillingRequestInput)
require.NoError(t, err)
wantActual := 2250
if tc.group == "free" {
wantActual = 0
}
assert.Equal(t, wantActual, actual.ActualQuotaAfterGroup, "reservation multiplier must not change settlement")
})
}
}
@@ -650,6 +680,60 @@ func TestModelPriceHelperPreservesGpt51CodexMaxIdentity(t *testing.T) {
assert.Equal(t, 1.75, priceData.ModelRatio)
}
// DeepSeek thinking tails and @ modifiers bill at the base model's built-in
// expression; a name on the thinking-suffix blacklist stays opaque and needs
// its own price.
func TestModelPriceHelperDeepSeekThinkingTailsBillAtBase(t *testing.T) {
gin.SetMode(gin.TestMode)
require.NoError(t, i18n.Init())
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": `{}`,
"billing_setting.billing_expr": `{}`,
}))
oldSelfUse := operation_setting.SelfUseModeEnabled
operation_setting.SelfUseModeEnabled = false
t.Cleanup(func() { operation_setting.SelfUseModeEnabled = oldSelfUse })
settings := model_setting.GetGlobalSettings()
originalBlacklist := append([]string(nil), settings.ThinkingModelBlacklist...)
t.Cleanup(func() { settings.ThinkingModelBlacklist = originalBlacklist })
settings.ThinkingModelBlacklist = append(originalBlacklist, "deepseek-v4-pro-max")
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
ctx.Set("group", "default")
for origin, base := range map[string]string{
"deepseek-flash-none": "deepseek-flash",
"deepseek-flash-low": "deepseek-flash",
"deepseek-flash-max": "deepseek-flash",
"deepseek-v4-pro-low": "deepseek-v4-pro",
"deepseek-flash@effort:max": "deepseek-flash",
"deepseek-flash-max@effort:low": "deepseek-flash",
"deepseek-v4-pro@thinking:off": "deepseek-v4-pro",
} {
info := &relaycommon.RelayInfo{
OriginModelName: origin, UserGroup: "default", UsingGroup: "default",
BillingRequestInput: &billingexpr.RequestInput{Body: []byte(`{}`)},
}
_, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
require.NoError(t, err, origin)
assert.Equal(t, base, info.BillingModelName, origin)
expression, _ := billing_setting.GetBuiltinBillingExpr(base)
require.NotNil(t, info.TieredBillingSnapshot, origin)
assert.Equal(t, expression, info.TieredBillingSnapshot.ExprString, origin)
}
info := &relaycommon.RelayInfo{OriginModelName: "deepseek-v4-pro-max", UserGroup: "default", UsingGroup: "default"}
_, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
require.Error(t, err)
assert.Equal(t, "deepseek-v4-pro-max", info.GetBillingModelName())
}
func TestModelPriceHelperNativeGeminiNoThinkingDoesNotAliasBillingModel(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -695,3 +779,58 @@ func TestModelPriceHelperNativeGeminiNoThinkingDoesNotAliasBillingModel(t *testi
assert.Equal(t, 1.25, priceData.ModelRatio)
assert.NotEqual(t, 37.5, priceData.ModelRatio)
}
func TestInputPreConsumeMultiplierLegacyAndRequestPrices(t *testing.T) {
previous := config.GlobalConfig.ExportAllConfigs()
previousRatios, previousPrices := ratio_setting.ModelRatio2JSONString(), ratio_setting.ModelPrice2JSONString()
t.Cleanup(func() {
require.NoError(t, config.GlobalConfig.LoadFromDB(previous))
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(previousRatios))
require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(previousPrices))
})
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"legacy-input-policy":1.5}`))
require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{"request-input-policy":0.01}`))
require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{
"billing_setting.billing_mode": `{"fixed-input-policy":"tiered_expr","image-input-policy":"tiered_expr"}`,
"billing_setting.billing_expr": `{"fixed-input-policy":"tier(\"request\", fixed(0.01))","image-input-policy":"tier(\"image\", p * 3) * image_count"}`,
"group_ratio_setting.group_ratio": `{"default":1}`,
}))
for _, tc := range []struct {
model string
multiplier float64
prompt, want int
}{
{"legacy-input-policy", 0.5, 100, 75},
{"legacy-input-policy", 2.5, 100, 375},
{"legacy-input-policy", 1, 0, 0},
{"request-input-policy", 2.5, 100, 5000},
{"fixed-input-policy", 2.5, 100, 5000},
{"image-input-policy", 2.5, 100, 375},
} {
t.Run(tc.model+"/"+fmt.Sprint(tc.multiplier, "/", tc.prompt), func(t *testing.T) {
operation_setting.GetQuotaSetting().PreConsumeMultiplier = tc.multiplier
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
info := &relaycommon.RelayInfo{OriginModelName: tc.model, UserGroup: "default", UsingGroup: "default", BillingRequestInput: &billingexpr.RequestInput{}}
price, err := ModelPriceHelper(ctx, info, tc.prompt, &types.TokenCountMeta{MaxTokens: 10000})
require.NoError(t, err)
assert.Equal(t, tc.want, price.QuotaToPreConsume)
if tc.model == "image-input-policy" {
reservation := &priceTestReservation{held: price.QuotaToPreConsume}
info.Billing = reservation
operation_setting.GetQuotaSetting().PreConsumeMultiplier = 10
require.Nil(t, service.PrepareImageBillingForRequest(ctx, info, 2))
assert.Equal(t, 750, reservation.held, "retry must reuse the original fractional multiplier")
}
})
}
}
// priceTestReservation observes the reservation requested before image submission.
type priceTestReservation struct{ held int }
func (s *priceTestReservation) Settle(int) error { return nil }
func (s *priceTestReservation) Refund(*gin.Context) {}
func (s *priceTestReservation) NeedsRefund() bool { return false }
func (s *priceTestReservation) GetPreConsumedQuota() int { return s.held }
func (s *priceTestReservation) Reserve(quota int) error { s.held = max(s.held, quota); return nil }
Loaded 100 of 1022 files, more files were not shown because too many files have changed in this diff. Show more