feat: publish sanitized custom.97 source
This commit is contained in:
1022 files changed
+69903
-19207
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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():
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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,
|
||||
))
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
Reference in new issue
Block a user