feat: publish sanitized custom.97 source

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

No files matched your search

+2 -1
View File
@@ -1,6 +1,7 @@
package channel
import (
"context"
"io"
"net/http"
@@ -114,7 +115,7 @@ type TaskContentRequest struct {
}
type TaskContentRequestProvider interface {
BuildContentRequest(task *model.Task, artifactKey string, clientRequest TaskArtifactClientRequest) (*TaskContentRequest, error)
BuildContentRequest(ctx context.Context, task *model.Task, artifactKey string, clientRequest TaskArtifactClientRequest) (*TaskContentRequest, error)
}
type TaskUsageFactsProvider interface {
+22 -1
View File
@@ -78,6 +78,8 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
return a.claudeAdaptor.ConvertClaudeRequest(c, info, request)
case relayconvert.ConverterClaudeMessagesToOpenAIChat:
return a.convertCrossProtocolChatRequest(c, info, converter, request)
case relayconvert.ConverterClaudeMessagesToOpenAIResponses:
return convertCrossProtocolResponsesRequest(c, info, converter, request)
default:
return nil, fmt.Errorf("converter %q does not support Anthropic Messages requests", converter)
}
@@ -94,6 +96,8 @@ func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayIn
return a.geminiAdaptor.ConvertGeminiRequest(c, info, request)
case relayconvert.ConverterGeminiContentToOpenAIChat:
return a.convertCrossProtocolChatRequest(c, info, converter, request)
case relayconvert.ConverterGeminiContentToOpenAIResponses:
return convertCrossProtocolResponsesRequest(c, info, converter, request)
default:
return nil, fmt.Errorf("converter %q does not support Gemini generateContent requests", converter)
}
@@ -296,7 +300,10 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
return a.geminiAdaptor.DoResponse(c, resp, info)
case relayconvert.ConverterOpenAIResponsesToGemini:
return a.geminiAdaptor.DoResponse(c, resp, info)
case relayconvert.ConverterOpenAIChatToOpenAIResponses:
case relayconvert.ConverterOpenAIChatToOpenAIResponses,
relayconvert.ConverterClaudeMessagesToOpenAIResponses,
relayconvert.ConverterGeminiContentToOpenAIResponses:
// These handlers convert the Responses upstream into info.RelayFormat.
if info.IsStream {
return openai.OaiResponsesToChatStreamHandler(c, info, resp)
}
@@ -531,6 +538,20 @@ func (a *Adaptor) convertCrossProtocolChatRequest(c *gin.Context, info *relaycom
return a.convertOpenAICompatibleRequest(c, info, chatRequest)
}
// convertCrossProtocolResponsesRequest converts a Claude or Gemini client
// request for an OpenAI Responses upstream.
func convertCrossProtocolResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, converter string, request any) (any, error) {
result, err := service.ConvertRequestByID(c, info, converter, request)
if err != nil {
return nil, err
}
responsesRequest, ok := result.Value.(*dto.OpenAIResponsesRequest)
if !ok {
return nil, fmt.Errorf("expected OpenAI Responses request, got %T", result.Value)
}
return responsesRequest, nil
}
func (a *Adaptor) convertOpenAICompatibleRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
old := info.ChannelType
info.ChannelType = constant.ChannelTypeOpenAI
@@ -2,10 +2,12 @@ package advancedcustom
import (
"bytes"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
@@ -19,6 +21,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestAdaptorUsesExactRouteAndQueryAuth(t *testing.T) {
@@ -51,6 +54,47 @@ func TestAdaptorUsesExactRouteAndQueryAuth(t *testing.T) {
assert.Equal(t, "sk-test", parsedURL.Query().Get("api_key"))
}
// An absolute route sends the request to a host other than the channel base
// URL, and a channel proxy is dialed before the upstream; a failed handshake
// must hide both.
func TestAdaptorMasksDialedHostsInDialFailure(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer upstream.Close()
upstreamURL, err := url.Parse(upstream.URL)
require.NoError(t, err)
for _, tc := range []struct {
name string
proxy string
hidden string
}{
{name: "absolute route host", hidden: "localhost"},
{name: "channel proxy host", proxy: "http://egress.private.invalid:8080", hidden: "egress.private.invalid"},
} {
t.Run(tc.name, func(t *testing.T) {
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
Routes: []dto.AdvancedCustomRoute{{IncomingPath: "/v1/realtime", UpstreamPath: "http://localhost:" + upstreamURL.Port() + "/v1/realtime", Converter: relayconvert.ConverterNone}},
})
info.RelayMode = relayconstant.RelayModeRealtime
info.RequestURLPath = "/v1/realtime"
info.ChannelSetting.Proxy = tc.proxy
c := advancedCustomGinContext("/v1/realtime")
common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, info.ChannelBaseUrl)
common.SetContextKey(c, constant.ContextKeyChannelSetting, info.ChannelSetting)
_, err := (&Adaptor{}).DoRequest(c, info, nil)
var apiErr *types.NewAPIError
require.ErrorAs(t, err, &apiErr)
require.Contains(t, apiErr.Error(), tc.hidden)
message := apiErr.ToOpenAIError(common.UpstreamAddresses(c)...).Message
assert.Contains(t, message, "dial failed to ws://***:"+upstreamURL.Port()+"/***/***")
assert.NotContains(t, message, tc.hidden)
})
}
}
func TestAdaptorJoinsUpstreamPathWithChannelBaseURL(t *testing.T) {
adaptor := &Adaptor{}
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
@@ -966,6 +1010,98 @@ func TestAdaptorCrossProtocolChatUpstreamRequestsStreamUsage(t *testing.T) {
}
}
// Claude Messages and Gemini Generate Content clients on an OpenAI Responses
// upstream: the request carries the hosted web search as a Responses tool and
// the Responses answer, JSON or stream, is converted back to the client format.
func TestAdaptorConvertsClaudeAndGeminiRequestsToResponsesUpstream(t *testing.T) {
oldTimeout := constant.StreamingTimeout
constant.StreamingTimeout = 30
t.Cleanup(func() {
constant.StreamingTimeout = oldTimeout
})
const responsesJSON = `{"id":"resp_1","object":"response","model":"gpt-test","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"Go 1.25","annotations":[]}]}],"usage":{"input_tokens":12,"output_tokens":4,"total_tokens":16}}`
responsesStream := "data: " + `{"type":"response.created","sequence_number":0,"response":{"id":"resp_1","object":"response","model":"gpt-test","status":"in_progress","output":[]}}` + "\n\n" +
"data: " + `{"type":"response.output_item.added","sequence_number":1,"output_index":0,"item":{"type":"message","id":"msg_1","role":"assistant","status":"in_progress","content":[]}}` + "\n\n" +
"data: " + `{"type":"response.output_text.delta","sequence_number":2,"output_index":0,"item_id":"msg_1","content_index":0,"delta":"Go 1.25"}` + "\n\n" +
"data: " + `{"type":"response.completed","sequence_number":3,"response":` + responsesJSON + `}` + "\n\n"
tests := []struct {
name string
route dto.AdvancedCustomRoute
relayFormat types.RelayFormat
requestPath string
convert func(*Adaptor, *gin.Context, *relaycommon.RelayInfo) (any, error)
wantResponse map[bool]string // stream -> marker in the client body
}{
{
name: "claude",
route: dto.AdvancedCustomRoute{IncomingPath: "/v1/messages", UpstreamPath: "/v1/responses", Converter: relayconvert.ConverterClaudeMessagesToOpenAIResponses},
relayFormat: types.RelayFormatClaude,
requestPath: "/v1/messages",
convert: func(a *Adaptor, c *gin.Context, info *relaycommon.RelayInfo) (any, error) {
return a.ConvertClaudeRequest(c, info, &dto.ClaudeRequest{
Model: "gpt-test",
Messages: []dto.ClaudeMessage{{Role: "user", Content: "Latest Go?"}},
Tools: []any{map[string]any{"type": "web_search_20250305", "name": "web_search"}},
})
},
wantResponse: map[bool]string{false: `"type":"message"`, true: "event: message_stop"},
},
{
name: "gemini",
route: dto.AdvancedCustomRoute{IncomingPath: "/v1beta/models/{model}:generateContent", UpstreamPath: "/v1/responses", Converter: relayconvert.ConverterGeminiContentToOpenAIResponses},
relayFormat: types.RelayFormatGemini,
requestPath: "/v1beta/models/gpt-test:generateContent",
convert: func(a *Adaptor, c *gin.Context, info *relaycommon.RelayInfo) (any, error) {
return a.ConvertGeminiRequest(c, info, &dto.GeminiChatRequest{
Contents: []dto.GeminiChatContent{{Role: "user", Parts: []dto.GeminiPart{{Text: "Latest Go?"}}}},
Tools: mustAdvancedCustomRawMessage(t, []map[string]any{{"googleSearch": map[string]any{}}}),
})
},
wantResponse: map[bool]string{false: `"candidates"`, true: `"candidates"`},
},
}
for _, tt := range tests {
for _, stream := range []bool{false, true} {
t.Run(fmt.Sprintf("%s stream=%v", tt.name, stream), func(t *testing.T) {
adaptor := &Adaptor{}
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{Routes: []dto.AdvancedCustomRoute{tt.route}})
info.RelayFormat = tt.relayFormat
info.RequestURLPath = tt.requestPath
info.IsStream = stream
info.DisablePing = true
info.ClaudeConvertInfo = &relaycommon.ClaudeConvertInfo{LastMessagesType: relaycommon.LastMessageTypeNone}
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, tt.requestPath, nil)
converted, err := tt.convert(adaptor, c, info)
require.NoError(t, err)
wire, err := common.Marshal(converted)
require.NoError(t, err)
assert.JSONEq(t, `[{"type":"web_search"}]`, gjson.GetBytes(wire, "tools").Raw)
url, err := adaptor.GetRequestURL(info)
require.NoError(t, err)
assert.Equal(t, "https://fallback.example/v1/responses", url)
upstream, contentType := responsesJSON, "application/json"
if stream {
upstream, contentType = responsesStream, "text/event-stream"
}
usage, apiErr := adaptor.DoResponse(c, &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{contentType}},
Body: io.NopCloser(strings.NewReader(upstream)),
}, info)
require.Nil(t, apiErr)
assert.Equal(t, 12, usage.(*dto.Usage).PromptTokens)
assert.Contains(t, recorder.Body.String(), "Go 1.25")
assert.Contains(t, recorder.Body.String(), tt.wantResponse[stream])
})
}
}
}
func advancedCustomRelayInfo(config *dto.AdvancedCustomConfig) *relaycommon.RelayInfo {
return &relaycommon.RelayInfo{
RelayFormat: types.RelayFormatOpenAI,
+28 -88
View File
@@ -16,26 +16,17 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
type Adaptor struct {
IsSyncImageModel bool
}
const aliAnthropicMessagesModelsEnv = "ALI_ANTHROPIC_MESSAGES_MODELS"
const defaultAliAnthropicMessagesModels = "qwen,deepseek-v4,kimi,glm,minimax-m"
/*
var syncModels = []string{
"z-image",
"qwen-image",
"wan2.6",
}
*/
func supportsAliAnthropicMessages(modelName string) bool {
normalizedModelName := strings.ToLower(strings.TrimSpace(modelName))
if normalizedModelName == "" {
@@ -55,16 +46,6 @@ func aliAnthropicMessagesModelPatterns() []string {
})
}
var syncModels = []string{
"z-image",
"qwen-image",
"wan2.6",
}
func isSyncImageModel(modelName string) bool {
return model_setting.IsSyncImageModel(modelName)
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
@@ -72,6 +53,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
if supportsAliAnthropicMessages(info.UpstreamModelName) {
ensureAnthropicWebSearchEntrypoint(req)
return req, nil
}
@@ -90,6 +72,18 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
if info.RelayMode == constant.RelayModeResponses {
info.VendorToolUsage = aliResponsesToolUsage
}
if info.RelayFormat == types.RelayFormatClaude {
if supportsAliAnthropicMessages(info.UpstreamModelName) {
info.WebSearchBillingKey = aliSearchAgentKey
} else {
// Claude clients on models outside the Anthropic-compatible list
// are converted to compatible-mode Chat.
info.ConvOptions().WebSearch = encodeWebSearch(info)
}
}
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
@@ -108,21 +102,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
case constant.RelayModeRerank:
fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.ChannelBaseUrl)
case constant.RelayModeResponses:
fullRequestURL = fmt.Sprintf("%s/api/v2/apps/protocols/compatible-mode/v1/responses", info.ChannelBaseUrl)
case constant.RelayModeImagesGenerations:
if isSyncImageModel(info.UpstreamModelName) {
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl)
} else {
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.ChannelBaseUrl)
}
case constant.RelayModeImagesEdits:
if isOldWanModel(info.UpstreamModelName) {
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/image2image/image-synthesis", info.ChannelBaseUrl)
} else if isWanModel(info.UpstreamModelName) {
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/image-generation/generation", info.ChannelBaseUrl)
} else {
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl)
}
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/responses", info.ChannelBaseUrl)
case constant.RelayModeCompletions:
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/completions", info.ChannelBaseUrl)
default:
@@ -142,19 +122,6 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
if c.GetString("plugin") != "" {
req.Set("X-DashScope-Plugin", c.GetString("plugin"))
}
if info.RelayMode == constant.RelayModeImagesGenerations {
if isSyncImageModel(info.UpstreamModelName) {
} else {
req.Set("X-DashScope-Async", "enable")
}
}
if info.RelayMode == constant.RelayModeImagesEdits {
if isWanModel(info.UpstreamModelName) {
req.Set("X-DashScope-Async", "enable")
}
req.Set("Content-Type", "application/json")
}
return nil
}
@@ -181,44 +148,21 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
}
}
// ConvertImageRequest is not implemented: Ali image generation and editing
// are served by the alibaba task plugin through the openai_image host
// protocol, which claims every declared image model on /v1/images/*. A model
// reaching this adaptor is not declared by the plugin (or is named
// differently from the Bailian model list), so the request cannot be served.
// The rejection is a 400 that skips channel retries: every channel of this
// type refuses the same name, and a retryable 500 would only hide the
// misconfiguration behind unrelated channels.
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
if info.RelayMode == constant.RelayModeImagesGenerations {
if isSyncImageModel(info.UpstreamModelName) {
a.IsSyncImageModel = true
}
aliRequest, err := oaiImage2AliImageRequest(info, request, a.IsSyncImageModel)
if err != nil {
return nil, fmt.Errorf("convert image request to async ali image request failed: %w", err)
}
return aliRequest, nil
} else if info.RelayMode == constant.RelayModeImagesEdits {
if isOldWanModel(info.UpstreamModelName) {
return oaiFormEdit2WanxImageEdit(c, info, request)
}
if isSyncImageModel(info.UpstreamModelName) {
if isWanModel(info.UpstreamModelName) {
a.IsSyncImageModel = false
} else {
a.IsSyncImageModel = true
}
}
// ali image edit https://bailian.console.aliyun.com/?tab=api#/api/?type=model&url=2976416
// 如果用户使用表单,则需要解析表单数据
if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
aliRequest, err := oaiFormEdit2AliImageEdit(c, info, request)
if err != nil {
return nil, fmt.Errorf("convert image edit form request failed: %w", err)
}
return aliRequest, nil
} else {
aliRequest, err := oaiImage2AliImageRequest(info, request, a.IsSyncImageModel)
if err != nil {
return nil, fmt.Errorf("convert image request to async ali image request failed: %w", err)
}
return aliRequest, nil
}
}
return nil, fmt.Errorf("unsupported image relay mode: %d", info.RelayMode)
return nil, types.NewErrorWithStatusCode(
fmt.Errorf("image model %q is not served by the alibaba task plugin; use a Bailian image model name declared by the plugin or map it with the channel model mapping", info.UpstreamModelName),
types.ErrorCodeInvalidRequest,
http.StatusBadRequest,
types.ErrOptionWithSkipRetry(),
)
}
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
@@ -254,10 +198,6 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
return adaptor.DoResponse(c, resp, info)
default:
switch info.RelayMode {
case constant.RelayModeImagesGenerations:
err, usage = aliImageHandler(a, c, resp, info)
case constant.RelayModeImagesEdits:
err, usage = aliImageHandler(a, c, resp, info)
case constant.RelayModeRerank:
err, usage = RerankHandler(c, resp, info)
default:
+185 -171
View File
@@ -1,71 +1,24 @@
package ali
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
rootconstant "github.com/QuantumNous/new-api/constant"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/constant"
relayhelper "github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestAliMultipartEditsUseValidatedProviderQuantity(t *testing.T) {
for _, tc := range []struct {
name string
convert func(*gin.Context, *relaycommon.RelayInfo, dto.ImageRequest) (*AliImageRequest, error)
}{
{"multimodal edit", oaiFormEdit2AliImageEdit},
{"legacy Wan edit", oaiFormEdit2WanxImageEdit},
} {
t.Run(tc.name, func(t *testing.T) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
require.NoError(t, writer.WriteField("model", "fixture-image"))
require.NoError(t, writer.WriteField("n", "2"))
require.NoError(t, writer.WriteField("parameters", `{"n":3,"prompt_extend":false}`))
part, err := writer.CreateFormFile("image", "input.png")
require.NoError(t, err)
_, err = part.Write([]byte("fixture image"))
require.NoError(t, err)
require.NoError(t, writer.Close())
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body)
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
common.SetContextKey(c, rootconstant.ContextKeyChannelType, rootconstant.ChannelTypeAli)
request, err := relayhelper.GetAndValidOpenAIImageRequest(c, constant.RelayModeImagesEdits)
require.NoError(t, err)
info := &relaycommon.RelayInfo{Request: request}
converted, err := tc.convert(c, info, *request)
require.NoError(t, err)
require.NotNil(t, converted.Parameters.N)
assert.Equal(t, uint(3), *converted.Parameters.N)
require.NotNil(t, converted.Parameters.PromptExtend)
assert.False(t, *converted.Parameters.PromptExtend)
assert.Equal(t, uint(2), *request.N, "conversion must preserve the incoming request")
})
}
}
func TestConvertOpenAIRequestFiltersThinkingBudgetByUpstreamModel(t *testing.T) {
tests := []struct {
name string
@@ -178,147 +131,208 @@ func TestConvertOpenAIRequestPreservesExplicitZeroForMappedQwenModel(t *testing.
assert.Equal(t, int64(0), value.Int())
}
func TestMappedAliImageModelUsesUpstreamProtocol(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
info := &relaycommon.RelayInfo{
RelayMode: constant.RelayModeImagesGenerations,
OriginModelName: "customer-image-model",
ChannelMeta: &relaycommon.ChannelMeta{
ChannelBaseUrl: "https://dashscope.aliyuncs.com",
UpstreamModelName: "qwen-image-3.0-pro",
},
}
adaptor := &Adaptor{}
url, err := adaptor.GetRequestURL(info)
require.NoError(t, err)
assert.Equal(t, "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", url)
header := http.Header{}
require.NoError(t, adaptor.SetupRequestHeader(c, &header, info))
assert.Empty(t, header.Get("X-DashScope-Async"))
converted, err := adaptor.ConvertImageRequest(c, info, dto.ImageRequest{
Model: info.UpstreamModelName,
Prompt: "poster",
})
require.NoError(t, err)
assert.True(t, adaptor.IsSyncImageModel)
assert.IsType(t, &AliImageRequest{}, converted)
}
func TestAliImageCountMatchesLegacyReservation(t *testing.T) {
saved := ratio_setting.ModelPrice2JSONString()
require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{"z-image":0.04}`))
t.Cleanup(func() { require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(saved)) })
for _, tc := range []struct {
name, body string
count int
multiplier float64
}{
{"provider override", `{"model":"z-image","n":2,"parameters":{"n":4}}`, 4, 1},
{"empty parameters inherit top-level", `{"model":"z-image","n":2,"parameters":{}}`, 2, 1},
{"null count inherits top-level", `{"model":"z-image","n":2,"parameters":{"n":null}}`, 2, 1},
{"default count", `{"model":"z-image"}`, 1, 1},
{"prompt extension", `{"model":"z-image","parameters":{"n":3,"prompt_extend":true}}`, 3, 2},
} {
t.Run(tc.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", strings.NewReader(tc.body))
c.Request.Header.Set("Content-Type", "application/json")
common.SetContextKey(c, rootconstant.ContextKeyChannelType, rootconstant.ChannelTypeAli)
request, err := relayhelper.GetAndValidOpenAIImageRequest(c, constant.RelayModeImagesGenerations)
require.NoError(t, err)
info := &relaycommon.RelayInfo{Request: request, OriginModelName: request.Model, UserGroup: "default", UsingGroup: "default"}
price, err := relayhelper.ModelPriceHelper(c, info, 0, request.GetTokenCountMeta())
require.NoError(t, err)
assert.Equal(t, common.QuotaFromFloat(0.04*float64(tc.count)*tc.multiplier*common.QuotaPerUnit), price.QuotaToPreConsume)
info.ChannelMeta = &relaycommon.ChannelMeta{ChannelType: rootconstant.ChannelTypeAli, UpstreamModelName: request.Model}
converted, err := oaiImage2AliImageRequest(info, *request, true)
require.NoError(t, err)
encoded, err := common.Marshal(converted)
require.NoError(t, err)
assert.Equal(t, int64(tc.count), gjson.GetBytes(encoded, "parameters.n").Int())
})
// Image models the alibaba task plugin does not claim reach this adaptor only
// through a channel misconfiguration. The rejection is a client error that
// skips channel retries; a retryable 500 would be re-attempted on unrelated
// channels and still end as a 500 for the client.
func TestConvertImageRequestRejectsUnclaimedModelWithoutRetry(t *testing.T) {
for _, name := range []string{"wanx-style-repaint-v1", "custom-image-model"} {
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: name}}
_, err := (&Adaptor{}).ConvertImageRequest(nil, info, dto.ImageRequest{Model: name, Prompt: "a cat"})
var apiErr *types.NewAPIError
require.ErrorAs(t, err, &apiErr, name)
assert.Equal(t, http.StatusBadRequest, apiErr.StatusCode, name)
assert.True(t, types.IsSkipRetryError(apiErr), name)
assert.Contains(t, apiErr.Error(), name)
}
}
func TestAliImageHandlerHonorsRequestResponseFormat(t *testing.T) {
imageBytes := []byte("ali-image")
var downloads atomic.Int32
imageServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
downloads.Add(1)
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(imageBytes)
}))
t.Cleanup(imageServer.Close)
fetchSetting := system_setting.GetFetchSetting()
require.NotNil(t, fetchSetting)
originalFetchSetting := *fetchSetting
fetchSetting.EnableSSRFProtection = false
t.Cleanup(func() {
*fetchSetting = originalFetchSetting
})
originalMaxFileDownloadMB := rootconstant.MaxFileDownloadMB
rootconstant.MaxFileDownloadMB = 1
t.Cleanup(func() {
rootconstant.MaxFileDownloadMB = originalMaxFileDownloadMB
})
service.InitHttpClient()
// Claude clients reach DashScope two ways: models on the Anthropic-compatible
// list keep Claude Messages and get the cc_entrypoint marker block (block
// form verified live 2026-10-05; a plain string system did not search) and
// the agent billing key; other models are converted to compatible-mode Chat,
// where hosted web search becomes enable_search / search_options and a forced
// search is billed as one turbo call.
func TestConvertClaudeRequestWebSearch(t *testing.T) {
t.Setenv(aliAnthropicMessagesModelsEnv, defaultAliAnthropicMessagesModels)
const (
chatModel = "llama-4-scout"
anthropicModel = "qwen3.8-flash"
marker = `{"type":"text","text":"x-anthropic-billing-header: cc_entrypoint=cli;"}`
messages = `"messages":[{"role":"user","content":"Weather?"}]`
searchTool = `{"type":"web_search_20250305","name":"web_search"}`
forcedChoice = `"tool_choice":{"type":"tool","name":"web_search"},`
)
domains := make([]string, 26)
for i := range domains {
domains[i] = fmt.Sprintf("d%d.example", i)
}
tooManyDomains, err := common.Marshal(domains)
require.NoError(t, err)
tests := []struct {
name string
responseFormat string
wantBase64 string
wantDownloads int32
name string
model string
body string // Claude request fields after model and max_tokens
want map[string]string // gjson path -> raw JSON; "" means absent
wantCodes []string
noCodes []string
wantTurbo int
}{
{
name: "base64",
responseFormat: "b64_json",
wantBase64: base64.StdEncoding.EncodeToString(imageBytes),
wantDownloads: 1,
name: "auto search on Chat",
model: chatModel,
body: messages + `,"tools":[` + searchTool + `]`,
want: map[string]string{"enable_search": "true", "search_options": "", "tool_choice": "", "web_search_options": ""},
},
{
name: "url",
responseFormat: "url",
name: "forced search on Chat",
model: chatModel,
body: forcedChoice + messages + `,"tools":[` + searchTool + `]`,
want: map[string]string{"enable_search": "true", "search_options": `{"forced_search":true}`, "tool_choice": "", "web_search_options": ""},
wantTurbo: 1,
},
{
name: "default",
name: "allowed domains become assigned_site_list",
model: chatModel,
body: messages + `,"tools":[{"type":"web_search_20250305","name":"web_search","allowed_domains":["a.example","b.example"]}]`,
want: map[string]string{"search_options": `{"assigned_site_list":["a.example","b.example"]}`},
noCodes: []string{"unsupported_domain_filter"},
},
{
name: "more than 25 allowed domains are dropped",
model: chatModel,
body: messages + `,"tools":[{"type":"web_search_20250305","name":"web_search","allowed_domains":` + string(tooManyDomains) + `}]`,
want: map[string]string{"enable_search": "true", "search_options": ""},
wantCodes: []string{"unsupported_domain_filter"},
},
{
name: "search limits and excluded domains are semantic loss",
model: chatModel,
body: messages + `,"tools":[{"type":"web_search_20250305","name":"web_search","max_uses":2,"blocked_domains":["a.example"]}]`,
want: map[string]string{"enable_search": "true"},
wantCodes: []string{"unsupported_search_limit", "unsupported_domain_filter"},
},
{
name: "user location is presentation loss",
model: chatModel,
body: messages + `,"tools":[{"type":"web_search_20250305","name":"web_search","user_location":{"type":"approximate","city":"Paris"}}]`,
want: map[string]string{"enable_search": "true", "search_options": ""},
wantCodes: []string{"unsupported_search_tuning"},
},
{
name: "marker without system",
model: anthropicModel,
body: messages + `,"tools":[` + searchTool + `]`,
want: map[string]string{"system": `[` + marker + `]`},
},
{
name: "marker before string system",
model: anthropicModel,
body: `"system":"Be brief.",` + messages + `,"tools":[` + searchTool + `]`,
want: map[string]string{"system": `[` + marker + `,{"type":"text","text":"Be brief."}]`},
},
{
name: "marker before array system",
model: anthropicModel,
body: `"system":[{"type":"text","text":"Be brief."}],` + messages + `,"tools":[` + searchTool + `]`,
want: map[string]string{"system": `[` + marker + `,{"type":"text","text":"Be brief."}]`},
},
{
name: "marker for empty array system",
model: anthropicModel,
body: `"system":[],` + messages + `,"tools":[` + searchTool + `]`,
want: map[string]string{"system": `[` + marker + `]`},
},
{
name: "marker already sent by the client",
model: anthropicModel,
body: `"system":"x-anthropic-billing-header: cc_version=2.1; cc_entrypoint=cli;",` + messages + `,"tools":[` + searchTool + `]`,
want: map[string]string{"system": `"x-anthropic-billing-header: cc_version=2.1; cc_entrypoint=cli;"`},
},
{
name: "no marker without a search tool",
model: anthropicModel,
body: `"system":"Be brief.",` + messages + `,"tools":[{"name":"f","input_schema":{"type":"object"}}]`,
want: map[string]string{"system": `"Be brief."`},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
downloads.Store(0)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
info := &relaycommon.RelayInfo{
RelayMode: constant.RelayModeImagesGenerations,
StartTime: time.Unix(1, 0),
Request: &dto.ImageRequest{
ResponseFormat: tt.responseFormat,
},
RelayFormat: types.RelayFormatClaude,
OriginModelName: tt.model,
ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: tt.model},
}
responseBody := fmt.Sprintf(`{"output":{"results":[{"url":%q}]}}`, imageServer.URL)
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{},
Body: io.NopCloser(strings.NewReader(responseBody)),
adaptor := &Adaptor{}
adaptor.Init(info)
var request dto.ClaudeRequest
require.NoError(t, common.UnmarshalJsonStr(`{"model":"`+tt.model+`","max_tokens":64,`+tt.body+`}`, &request))
var converted any
for range 2 { // converting the same request twice adds one marker
converted, err = adaptor.ConvertClaudeRequest(c, info, &request)
require.NoError(t, err)
}
newAPIError, usage := aliImageHandler(&Adaptor{IsSyncImageModel: true}, c, resp, info)
require.Nil(t, newAPIError)
require.NotNil(t, usage)
var imageResponse dto.ImageResponse
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &imageResponse))
require.Len(t, imageResponse.Data, 1)
assert.Equal(t, imageServer.URL, imageResponse.Data[0].Url)
assert.Equal(t, tt.wantBase64, imageResponse.Data[0].B64Json)
assert.Equal(t, tt.wantDownloads, downloads.Load())
wire, err := common.Marshal(converted)
require.NoError(t, err)
for path, want := range tt.want {
value := gjson.GetBytes(wire, path)
if want == "" {
assert.False(t, value.Exists(), "%s in %s", path, wire)
continue
}
assert.JSONEq(t, want, value.Raw, path)
}
var codes []string
for _, diagnostic := range info.ConversionDiagnostics() {
codes = append(codes, diagnostic.Code)
}
for _, code := range tt.wantCodes {
assert.Contains(t, codes, code)
}
for _, code := range tt.noCodes {
assert.NotContains(t, codes, code)
}
turbo := 0
if info.ResponsesUsageInfo != nil && info.ResponsesUsageInfo.BuiltInTools[aliSearchTurboKey] != nil {
turbo = info.ResponsesUsageInfo.BuiltInTools[aliSearchTurboKey].CallCount
}
assert.Equal(t, tt.wantTurbo, turbo)
wantKey := ""
if tt.model == anthropicModel {
wantKey = aliSearchAgentKey
}
assert.Equal(t, wantKey, info.WebSearchBillingKey)
})
}
}
// DashScope Responses bills web search like the agent strategy and reports
// the count in usage.x_tools; it replaces the web_search_call items counted
// under the protocol keys.
func TestAliResponsesWebSearchUsage(t *testing.T) {
for _, raw := range []string{
`{"status":"completed","usage":{"x_tools":{"web_search":{"count":2}}}}`,
`{"type":"response.completed","response":{"usage":{"x_tools":{"web_search":{"count":2}}}}}`,
} {
info := &relaycommon.RelayInfo{
RelayMode: constant.RelayModeResponses,
RelayFormat: types.RelayFormatOpenAIResponses,
ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://dashscope.invalid"},
}
adaptor := &Adaptor{}
adaptor.Init(info)
url, err := adaptor.GetRequestURL(info)
require.NoError(t, err)
assert.Equal(t, "https://dashscope.invalid/compatible-mode/v1/responses", url)
info.CountBillableToolCall(dto.BuildInCallWebSearchCall, "")
info.ApplyVendorToolUsage([]byte(raw))
counts := map[string]int{}
for name, tool := range info.ResponsesUsageInfo.BuiltInTools {
counts[name] = tool.CallCount
}
assert.Equal(t, map[string]int{aliSearchAgentKey: 2, dto.BuildInToolWebSearch: 0, dto.BuildInToolWebSearchPreview: 0}, counts, raw)
}
}
-146
View File
@@ -1,12 +1,7 @@
package ali
import (
"strings"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
type AliMessage struct {
@@ -14,11 +9,6 @@ type AliMessage struct {
Role string `json:"role"`
}
type AliMediaContent struct {
Image string `json:"image,omitempty"`
Text string `json:"text,omitempty"`
}
type AliInput struct {
Prompt string `json:"prompt,omitempty"`
//History []AliMessage `json:"history,omitempty"`
@@ -72,142 +62,6 @@ type AliUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
TotalTokens int `json:"total_tokens"`
ImageCount int `json:"image_count,omitempty"`
}
type TaskResult struct {
B64Image string `json:"b64_image,omitempty"`
Url string `json:"url,omitempty"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
type AliOutput struct {
TaskId string `json:"task_id,omitempty"`
TaskStatus string `json:"task_status,omitempty"`
Text string `json:"text"`
FinishReason string `json:"finish_reason"`
Message string `json:"message,omitempty"`
Code string `json:"code,omitempty"`
Results []TaskResult `json:"results,omitempty"`
Choices []struct {
FinishReason string `json:"finish_reason,omitempty"`
Message struct {
Role string `json:"role,omitempty"`
Content []AliMediaContent `json:"content,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
} `json:"message"`
} `json:"choices,omitempty"`
}
func (o *AliOutput) ChoicesToOpenAIImageDate(c *gin.Context, responseFormat string) []dto.ImageData {
var imageData []dto.ImageData
if len(o.Choices) > 0 {
for _, choice := range o.Choices {
var data dto.ImageData
for _, content := range choice.Message.Content {
if content.Image != "" {
if strings.HasPrefix(content.Image, "http") {
var b64Json string
if responseFormat == "b64_json" {
_, b64, err := service.GetImageFromUrl(content.Image)
if err != nil {
logger.LogError(c, "get_image_data_failed: "+err.Error())
continue
}
b64Json = b64
}
data.Url = content.Image
data.B64Json = b64Json
} else {
data.B64Json = content.Image
}
} else if content.Text != "" {
data.RevisedPrompt = content.Text
}
}
imageData = append(imageData, data)
}
}
return imageData
}
func (o *AliOutput) ResultToOpenAIImageDate(c *gin.Context, responseFormat string) []dto.ImageData {
var imageData []dto.ImageData
for _, data := range o.Results {
var b64Json string
if responseFormat == "b64_json" {
_, b64, err := service.GetImageFromUrl(data.Url)
if err != nil {
logger.LogError(c, "get_image_data_failed: "+err.Error())
continue
}
b64Json = b64
} else {
b64Json = data.B64Image
}
imageData = append(imageData, dto.ImageData{
Url: data.Url,
B64Json: b64Json,
RevisedPrompt: "",
})
}
return imageData
}
type AliResponse struct {
Output AliOutput `json:"output"`
Usage AliUsage `json:"usage"`
AliError
}
type AliImageRequest struct {
Model string `json:"model"`
Input any `json:"input"`
Parameters AliImageParameters `json:"parameters"`
ResponseFormat string `json:"response_format,omitempty"`
}
type AliImageParameters struct {
Size string `json:"size,omitempty"`
N *uint `json:"n,omitempty"`
Steps string `json:"steps,omitempty"`
Scale string `json:"scale,omitempty"`
Watermark *bool `json:"watermark,omitempty"`
PromptExtend *bool `json:"prompt_extend,omitempty"`
ThinkingMode *bool `json:"thinking_mode,omitempty"`
EnableSequential *bool `json:"enable_sequential,omitempty"`
BboxList any `json:"bbox_list,omitempty"`
ColorPalette any `json:"color_palette,omitempty"`
Seed *int `json:"seed,omitempty"`
}
func (p *AliImageParameters) PromptExtendValue() bool {
if p != nil && p.PromptExtend != nil {
return *p.PromptExtend
}
return false
}
type AliImageInput struct {
Prompt string `json:"prompt,omitempty"`
NegativePrompt string `json:"negative_prompt,omitempty"`
Messages []AliMessage `json:"messages,omitempty"`
}
type WanImageInput struct {
Prompt string `json:"prompt"` // 必需:文本提示词,描述生成图像中期望包含的元素和视觉特点
Images []string `json:"images"` // 必需:图像URL数组,长度不超过2,支持HTTP/HTTPS URL或Base64编码
NegativePrompt string `json:"negative_prompt,omitempty"` // 可选:反向提示词,描述不希望在画面中看到的内容
}
type WanImageParameters struct {
N int `json:"n,omitempty"` // 生成图片数量,取值范围1-4,默认4
Watermark *bool `json:"watermark,omitempty"` // 是否添加水印标识,默认false
Seed int `json:"seed,omitempty"` // 随机数种子,取值范围[0, 2147483647]
Strength float64 `json:"strength,omitempty"` // 修改幅度 0.0-1.0,默认0.5(部分模型支持)
}
type AliRerankParameters struct {
-368
View File
@@ -1,368 +0,0 @@
package ali
import (
"encoding/base64"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
func oaiImage2AliImageRequest(info *relaycommon.RelayInfo, request dto.ImageRequest, isSync bool) (*AliImageRequest, error) {
var imageRequest AliImageRequest
imageRequest.Model = request.Model
imageRequest.ResponseFormat = request.ResponseFormat
imageRequest.Parameters = AliImageParameters{
Size: strings.ReplaceAll(request.Size, "x", "*"),
Watermark: request.Watermark,
}
if request.Extra != nil {
if val, ok := request.Extra["parameters"]; ok {
imageRequest.Parameters = AliImageParameters{}
err := common.Unmarshal(val, &imageRequest.Parameters)
if err != nil {
return nil, fmt.Errorf("invalid parameters field: %w", err)
}
// Direct adaptor callers can bypass ingress validation. Reuse the
// decoded provider scalars and the same quantity validation below.
if request.BillingParameters == nil {
request.BillingParameters = &dto.ImageBillingParameters{
N: imageRequest.Parameters.N, PromptExtend: imageRequest.Parameters.PromptExtend,
}
}
}
if val, ok := request.Extra["input"]; ok {
err := common.Unmarshal(val, &imageRequest.Input)
if err != nil {
return nil, fmt.Errorf("invalid input field: %w", err)
}
}
}
count, err := request.ImageCount(true)
if err != nil {
return nil, err
}
imageRequest.Parameters.N = common.GetPointer(uint(count))
if request.BillingParameters != nil {
imageRequest.Parameters.PromptExtend = request.BillingParameters.PromptExtend
}
if info.TieredBillingSnapshot == nil {
info.PriceData.AddOtherRatio("n", float64(count))
if strings.Contains(request.Model, "z-image") && imageRequest.Parameters.PromptExtendValue() {
info.PriceData.AddOtherRatio("prompt_extend", common.ZImagePromptExtendMultiplier)
}
}
// 同步图片模型和异步图片模型请求格式不一样
if isSync {
if imageRequest.Input == nil {
imageRequest.Input = AliImageInput{
Messages: []AliMessage{
{
Role: "user",
Content: []AliMediaContent{
{
Text: request.Prompt,
},
},
},
},
}
}
} else {
if imageRequest.Input == nil {
imageRequest.Input = AliImageInput{
Prompt: request.Prompt,
}
}
}
return &imageRequest, nil
}
func getImageBase64sFromForm(c *gin.Context, fieldName string) ([]string, error) {
mf := c.Request.MultipartForm
if mf == nil {
if _, err := c.MultipartForm(); err != nil {
return nil, fmt.Errorf("failed to parse image edit form request: %w", err)
}
mf = c.Request.MultipartForm
}
var imageFiles []*multipart.FileHeader
var exists bool
// First check for standard "image" field
if imageFiles, exists = mf.File["image"]; !exists || len(imageFiles) == 0 {
// If not found, check for "image[]" field
if imageFiles, exists = mf.File["image[]"]; !exists || len(imageFiles) == 0 {
// If still not found, iterate through all fields to find any that start with "image["
foundArrayImages := false
for fieldName, files := range mf.File {
if strings.HasPrefix(fieldName, "image[") && len(files) > 0 {
foundArrayImages = true
imageFiles = append(imageFiles, files...)
}
}
// If no image fields found at all
if !foundArrayImages && (len(imageFiles) == 0) {
return nil, errors.New("image is required")
}
}
}
if len(imageFiles) == 0 {
return nil, errors.New("image is required")
}
//if len(imageFiles) > 1 {
// return nil, errors.New("only one image is supported for qwen edit")
//}
// 获取base64编码的图片
var imageBase64s []string
for _, file := range imageFiles {
image, err := file.Open()
if err != nil {
return nil, errors.New("failed to open image file")
}
// 读取文件内容
imageData, err := io.ReadAll(image)
if err != nil {
return nil, errors.New("failed to read image file")
}
// 获取MIME类型
mimeType := http.DetectContentType(imageData)
// 编码为base64
base64Data := base64.StdEncoding.EncodeToString(imageData)
// 构造data URL格式
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
imageBase64s = append(imageBase64s, dataURL)
image.Close()
}
return imageBase64s, nil
}
func oaiFormEdit2AliImageEdit(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (*AliImageRequest, error) {
count, err := request.ImageCount(true)
if err != nil {
return nil, err
}
var imageRequest AliImageRequest
imageRequest.Model = request.Model
imageRequest.ResponseFormat = request.ResponseFormat
imageBase64s, err := getImageBase64sFromForm(c, "image")
if err != nil {
return nil, fmt.Errorf("get image base64s from form failed: %w", err)
}
//dto.MediaContent{}
mediaContents := make([]AliMediaContent, len(imageBase64s))
for i, b64 := range imageBase64s {
mediaContents[i] = AliMediaContent{
Image: b64,
}
}
mediaContents = append(mediaContents, AliMediaContent{
Text: request.Prompt,
})
imageRequest.Input = AliImageInput{
Messages: []AliMessage{
{
Role: "user",
Content: mediaContents,
},
},
}
imageRequest.Parameters = AliImageParameters{
N: common.GetPointer(uint(count)),
Watermark: request.Watermark,
}
if request.BillingParameters != nil {
imageRequest.Parameters.PromptExtend = request.BillingParameters.PromptExtend
}
return &imageRequest, nil
}
func updateTask(info *relaycommon.RelayInfo, taskID string) (*AliResponse, error, []byte) {
url := fmt.Sprintf("%s/api/v1/tasks/%s", info.ChannelBaseUrl, taskID)
var aliResponse AliResponse
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return &aliResponse, err, nil
}
req.Header.Set("Authorization", "Bearer "+info.ApiKey)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
common.SysLog("updateTask client.Do err: " + err.Error())
return &aliResponse, err, nil
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
var response AliResponse
err = common.Unmarshal(responseBody, &response)
if err != nil {
common.SysLog("updateTask NewDecoder err: " + err.Error())
return &aliResponse, err, nil
}
return &response, nil, responseBody
}
func asyncTaskWait(c *gin.Context, info *relaycommon.RelayInfo, taskID string) (*AliResponse, []byte, error) {
waitSeconds := 10
step := 0
maxStep := 20
var taskResponse AliResponse
var responseBody []byte
time.Sleep(time.Duration(5) * time.Second)
for {
logger.LogDebug(c, "asyncTaskWait step %d/%d, wait %d seconds", step, maxStep, waitSeconds)
step++
rsp, err, body := updateTask(info, taskID)
responseBody = body
if err != nil {
logger.LogWarn(c, "asyncTaskWait UpdateTask err: "+err.Error())
time.Sleep(time.Duration(waitSeconds) * time.Second)
continue
}
if rsp.Output.TaskStatus == "" {
return &taskResponse, responseBody, nil
}
switch rsp.Output.TaskStatus {
case "FAILED":
fallthrough
case "CANCELED":
fallthrough
case "SUCCEEDED":
fallthrough
case "UNKNOWN":
return rsp, responseBody, nil
}
if step >= maxStep {
break
}
time.Sleep(time.Duration(waitSeconds) * time.Second)
}
return nil, nil, fmt.Errorf("aliAsyncTaskWait timeout")
}
func responseAli2OpenAIImage(c *gin.Context, response *AliResponse, originBody []byte, info *relaycommon.RelayInfo, responseFormat string) *dto.ImageResponse {
imageResponse := dto.ImageResponse{
Created: info.StartTime.Unix(),
}
if len(response.Output.Results) > 0 {
imageResponse.Data = response.Output.ResultToOpenAIImageDate(c, responseFormat)
} else if len(response.Output.Choices) > 0 {
imageResponse.Data = response.Output.ChoicesToOpenAIImageDate(c, responseFormat)
}
imageResponse.Metadata = originBody
return &imageResponse
}
func aliImageHandler(a *Adaptor, c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) {
responseFormat := ""
if imageReq, ok := info.Request.(*dto.ImageRequest); ok {
responseFormat = imageReq.ResponseFormat
}
var aliTaskResponse AliResponse
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil
}
service.CloseResponseBodyGracefully(resp)
err = common.Unmarshal(responseBody, &aliTaskResponse)
if err != nil {
return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil
}
if aliTaskResponse.Message != "" {
logger.LogError(c, "ali_async_task_failed: "+aliTaskResponse.Message)
return types.NewError(errors.New(aliTaskResponse.Message), types.ErrorCodeBadResponse), nil
}
var (
aliResponse *AliResponse
originRespBody []byte
)
if a.IsSyncImageModel {
aliResponse = &aliTaskResponse
originRespBody = responseBody
} else {
// 异步图片模型需要轮询任务结果
aliResponse, originRespBody, err = asyncTaskWait(c, info, aliTaskResponse.Output.TaskId)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponse), nil
}
if aliResponse.Output.TaskStatus != "SUCCEEDED" {
return types.WithOpenAIError(types.OpenAIError{
Message: aliResponse.Output.Message,
Type: "ali_error",
Param: "",
Code: aliResponse.Output.Code,
}, resp.StatusCode), nil
}
}
if a.IsSyncImageModel {
logger.LogDebug(c, "ali_sync_image_result: %s", originRespBody)
} else {
logger.LogDebug(c, "ali_async_image_result: %s", originRespBody)
}
imageResponses := responseAli2OpenAIImage(c, aliResponse, originRespBody, info, responseFormat)
count := int64(aliResponse.Usage.ImageCount)
if count < 0 || count > dto.MaxImageN {
logger.LogWarn(c, "invalid Ali image usage count %d; falling back to response images or requested quantity", count)
count = 0
}
if count == 0 {
count = int64(len(imageResponses.Data))
}
if count > dto.MaxImageN {
logger.LogWarn(c, "invalid Ali response image count %d; retaining requested quantity", count)
} else if count > 0 {
info.UpdateImageCount(count)
}
jsonResponse, err := common.Marshal(imageResponses)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
}
service.IOCopyBytesGracefully(c, resp, jsonResponse)
return nil, &dto.Usage{}
}
-51
View File
@@ -1,51 +0,0 @@
package ali
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
func oaiFormEdit2WanxImageEdit(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (*AliImageRequest, error) {
count, err := request.ImageCount(true)
if err != nil {
return nil, err
}
var imageRequest AliImageRequest
imageRequest.Model = request.Model
imageRequest.ResponseFormat = request.ResponseFormat
wanInput := WanImageInput{
Prompt: request.Prompt,
}
if err := common.UnmarshalBodyReusable(c, &wanInput); err != nil {
return nil, err
}
if wanInput.Images, err = getImageBase64sFromForm(c, "image"); err != nil {
return nil, fmt.Errorf("get image base64s from form failed: %w", err)
}
imageRequest.Input = wanInput
imageRequest.Parameters = AliImageParameters{
N: common.GetPointer(uint(count)),
}
if request.BillingParameters != nil {
imageRequest.Parameters.PromptExtend = request.BillingParameters.PromptExtend
}
return &imageRequest, nil
}
func isOldWanModel(modelName string) bool {
return strings.Contains(modelName, "wan") &&
!lo.SomeBy([]string{"wan2.6", "wan2.7"}, func(v string) bool { return strings.Contains(modelName, v) })
}
func isWanModel(modelName string) bool {
return strings.Contains(modelName, "wan")
}
+163
View File
@@ -0,0 +1,163 @@
package ali
import (
"encoding/json"
"strings"
"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/tidwall/gjson"
)
// DashScope web-search tool-price keys (search_strategy_* prices in
// setting/operation_setting/tools.go). The compatible-mode Chat encoder never
// writes search_strategy, so DashScope runs turbo; the Responses API "bills
// web search like the agent strategy"; the Anthropic-compatible endpoint
// publishes no search price and is billed like agent too (owner decision N9).
const (
aliSearchTurboKey = "search_strategy_turbo"
aliSearchAgentKey = "search_strategy_agent"
)
// aliMaxAssignedSites is the documented search_options.assigned_site_list
// limit; the list only applies to the default turbo strategy.
const aliMaxAssignedSites = 25
// encodeWebSearch maps hosted web search onto the DashScope OpenAI-compatible
// Chat fields enable_search and search_options
// (https://help.aliyun.com/zh/model-studio/web-search). search_strategy is
// never written, so DashScope runs its default turbo strategy, and
// search_context_size is not mapped to a strategy: a strategy changes the
// price, not the amount of retrieved context.
//
// enable_search only works on the models DashScope lists. Other models answer
// HTTP 400 "This model does not support enable_search.", and models that only
// accept search_strategy=agent (qwen3.8-omni-flash) answer 400 as well. The
// encoder sees no model support matrix and does not gate on model names, so
// the request is sent as is and the upstream 400 reaches the client.
func encodeWebSearch(info *relaycommon.RelayInfo) convmeta.WebSearchEncoder {
return func(call convmeta.WebSearchCall) (*convmeta.WebSearchEncoding, error) {
if call.Chat == nil {
return nil, nil
}
var diagnostics []types.ConversionDiagnostic
options := map[string]any{}
if call.Forced {
options["forced_search"] = true
// Compatible-mode Chat responses report no search usage; a forced
// search is the only proof that one ran.
info.RecordRequestInferredToolCall(aliSearchTurboKey)
}
spec := call.Spec
switch {
case len(spec.AllowedDomains) > aliMaxAssignedSites:
diagnostics = append(diagnostics, call.SemanticLoss("unsupported_domain_filter", "DashScope assigned_site_list accepts at most 25 sites; the domain filter was dropped"))
case len(spec.AllowedDomains) > 0:
options["assigned_site_list"] = spec.AllowedDomains
}
if len(spec.BlockedDomains) > 0 {
diagnostics = append(diagnostics, call.SemanticLoss("unsupported_domain_filter", "DashScope web search cannot exclude domains; blocked_domains was dropped"))
}
if spec.MaxUses != nil {
diagnostics = append(diagnostics, call.SemanticLoss("unsupported_search_limit", "DashScope web search cannot preserve max_uses"))
}
if len(spec.AllowedCallers) > 0 || spec.ExternalWebAccess != nil {
diagnostics = append(diagnostics, call.SemanticLoss("unsupported_search_controls", "DashScope web search cannot preserve caller or external-access constraints"))
}
if spec.SearchContextSize != "" || spec.Location != nil || spec.ResponseInclusion != "" || len(spec.ReturnTokenBudget) > 0 {
diagnostics = append(diagnostics, call.PresentationLoss("unsupported_search_tuning", "DashScope web search cannot preserve search context size, location, or result tuning"))
}
call.Chat.EnableSearch = json.RawMessage(`true`)
if len(options) > 0 {
encoded, err := common.Marshal(options)
if err != nil {
return nil, err
}
call.Chat.SearchOptions = encoded
}
return &convmeta.WebSearchEncoding{Diagnostics: diagnostics}, nil
}
}
// aliResponsesToolUsage bills Responses web search by the count DashScope
// reports in usage.x_tools.web_search.count (terminal frames carry it under
// response.usage) and silences the web_search_call items under the protocol
// keys. gjson truncates fractional values and reads non-numbers as zero;
// SetBillableToolCount ignores negatives and settlement applies
// MaxBillableToolCallCount.
func aliResponsesToolUsage(raw []byte) map[string]int {
count := gjson.GetBytes(raw, "usage.x_tools.web_search.count")
if !count.Exists() {
count = gjson.GetBytes(raw, "response.usage.x_tools.web_search.count")
}
if !count.Exists() {
return nil
}
return map[string]int{
aliSearchAgentKey: int(count.Int()),
dto.BuildInToolWebSearch: 0,
dto.BuildInToolWebSearchPreview: 0,
}
}
// aliAnthropicWebSearchEntrypoint is the client marker DashScope's
// Anthropic-compatible endpoint requires in system before it runs web search:
// "使用 Anthropic SDK 或 HTTP 直接调用时,需在 system 中传入客户端标识
// x-anthropic-billing-header: cc_entrypoint=cli;,否则联网搜索不生效。"
// (https://help.aliyun.com/zh/model-studio/web-search). The marker must be a
// system text block: sent as a plain string system, qwen3.8-flash answered
// without searching (2026-10-05).
const aliAnthropicWebSearchEntrypoint = "x-anthropic-billing-header: cc_entrypoint=cli;"
// ensureAnthropicWebSearchEntrypoint prepends the marker block to system when
// the request carries a web_search_* tool; a string system becomes the block
// after the marker. It is idempotent: a system that already names
// cc_entrypoint=cli, from the client or an earlier call, is left as is.
func ensureAnthropicWebSearchEntrypoint(request *dto.ClaudeRequest) {
hasWebSearch := false
for _, tool := range request.GetTools() {
var toolType string
switch typed := tool.(type) {
case map[string]any:
toolType, _ = typed["type"].(string)
case *dto.ClaudeWebSearchTool:
toolType = typed.Type
case dto.ClaudeWebSearchTool:
toolType = typed.Type
}
if strings.HasPrefix(toolType, "web_search_") {
hasWebSearch = true
break
}
}
if !hasWebSearch {
return
}
const present = "cc_entrypoint=cli"
var blocks []dto.ClaudeMediaMessage
if request.System == nil || request.IsStringSystem() {
system := request.GetStringSystem()
if strings.Contains(system, present) {
return
}
if strings.TrimSpace(system) != "" {
block := dto.ClaudeMediaMessage{Type: dto.ContentTypeText}
block.SetText(system)
blocks = []dto.ClaudeMediaMessage{block}
}
} else {
blocks = request.ParseSystem()
for _, block := range blocks {
if block.Type == dto.ContentTypeText && strings.Contains(block.GetText(), present) {
return
}
}
}
marker := dto.ClaudeMediaMessage{Type: dto.ContentTypeText}
marker.SetText(aliAnthropicWebSearchEntrypoint)
request.System = append([]dto.ClaudeMediaMessage{marker}, blocks...)
}
+27 -10
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"net/http/httptrace"
"regexp"
"strings"
"sync"
@@ -405,6 +406,9 @@ func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
}
dialer.Proxy = http.ProxyURL(proxyURL)
}
if info.ChannelSetting.TLSInsecureSkipVerify {
dialer.TLSClientConfig = common2.InsecureTLSConfig
}
targetConn, resp, err := dialer.DialContext(c.Request.Context(), fullRequestURL, targetHeader)
if err != nil {
statusCode := http.StatusInternalServerError
@@ -499,7 +503,7 @@ func sendPingData(c *gin.Context, mutex *sync.Mutex) error {
helper.ExtendWriteDeadline(c)
err := helper.PingData(c)
if err != nil {
logger.LogError(c, "SSE ping error: "+err.Error())
logger.LogError(c, common2.LogText("SSE ping error: %s", err.Error()))
return err
}
@@ -537,13 +541,11 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
relayClient.CheckRedirect = keepUpstreamRedirectResponse
if common2.DebugEnabled && req != nil && req.URL != nil {
policy := service.NormalizeHTTPTransportPolicy(info.ChannelSetting)
logger.LogDebug(c, fmt.Sprintf(
"http transport select: host=%s protocol=%s shards=%d policy=%s",
logger.LogDebug(c, "http transport select: host=%s protocol=%s shards=%d policy=%s",
req.URL.Host,
policy.Protocol,
policy.Shards,
policy.String(),
))
policy.String())
}
var stopPinger context.CancelFunc
@@ -567,11 +569,21 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
}
info.UpstreamRequests++
info.UpstreamTiming = nil
if info.UsingGroup == common2.GetEnvOrDefaultString("UPSTREAM_TIMING_GROUP", "") && c.Request.URL.Path == "/v1/responses" {
start := c.GetTime("request_received_at")
if start.IsZero() {
start = info.StartTime
}
info.UpstreamTiming = common.NewUpstreamTiming(start, info.UpstreamRequests, info.ChannelId, req.ContentLength)
}
c.Set(common.UpstreamTimingContextKey, info.UpstreamTiming)
var guard *common.ResponseGuardAttempt
var guardUsage *service.GuardUsageCollector
if guardEnabled {
var upstream context.Context
upstream, guard = info.ResponseGuard.Begin(c.Request.Context(), info.UpstreamRequests, info.ChannelId)
guard.Timing = info.UpstreamTiming
if info.DurableAttemptID != 0 {
guardUsage = service.NewGuardUsageCollector(c, info)
guard.OnEvent = guardUsage.Observe
@@ -579,6 +591,9 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
}
req = req.WithContext(upstream)
}
if info.UpstreamTiming != nil {
req = req.WithContext(httptrace.WithClientTrace(req.Context(), info.UpstreamTiming.ClientTrace()))
}
resp, err := relayClient.Do(req)
if err != nil {
if guard != nil {
@@ -594,7 +609,7 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
}
guard.Reason = "transport_error"
}
logger.LogError(c, "do request failed: "+err.Error())
logger.LogError(c, common2.LogText("do request failed: %s", err.Error()))
return nil, types.NewError(err, types.ErrorCodeDoRequestFailed, types.ErrOptionWithHideErrMsg("upstream error: do request failed"))
}
if resp == nil {
@@ -603,6 +618,7 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
}
return nil, errors.New("resp is nil")
}
info.UpstreamTiming.Response(resp.Proto, resp.StatusCode)
if guard != nil {
if err := guard.Confirm(resp, c.Request.Context()); err != nil {
if guardUsage != nil && guardUsage.FinishRejected(info, guard, resp) {
@@ -614,17 +630,18 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
guard.Finish(false)
return nil, guard.APIError()
}
if guard.Reason == "accepted" {
info.UpstreamTiming.Mark("model_confirmed")
}
}
if common2.DebugEnabled {
policy := service.NormalizeHTTPTransportPolicy(info.ChannelSetting)
logger.LogDebug(c, fmt.Sprintf(
"http transport negotiated: host=%s protocol=%s shards=%d policy=%s negotiated=%s",
logger.LogDebug(c, "http transport negotiated: host=%s protocol=%s shards=%d policy=%s negotiated=%s",
req.URL.Host,
policy.Protocol,
policy.Shards,
policy.String(),
resp.Proto,
))
resp.Proto)
}
if upID := resp.Header.Get(common2.RequestIdKey); upID != "" {
+20 -1
View File
@@ -93,6 +93,9 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if info.ChannelOtherSettings.AwsApiType == dto.AwsApiTypeMantle {
return mantleRequestURL(info)
}
if info.ChannelOtherSettings.AwsKeyType == dto.AwsKeyTypeApiKey {
awsModelId := getAwsModelID(info.UpstreamModelName)
a.ClientMode = ClientModeApiKey
@@ -154,6 +157,22 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo
}
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
switch info.ChannelOtherSettings.AwsApiType {
case dto.AwsApiTypeMantle:
req, err := a.newMantleRequest(c, info, requestBody)
if err != nil {
return nil, err
}
resp, err := channel.DoRequest(c, req, info)
if err != nil {
return nil, newAwsInvokeError(c.Request.Context(), err, "Mantle Messages")
}
return resp, nil
case "", dto.AwsApiTypeInvokeModel:
// Unconfigured channels retain the existing InvokeModel behavior.
default:
return nil, errors.New("invalid AWS API type")
}
if a.ClientMode == ClientModeApiKey {
return channel.DoApiRequest(a, c, info, requestBody)
} else {
@@ -162,7 +181,7 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
if a.ClientMode == ClientModeApiKey {
if info.ChannelOtherSettings.AwsApiType == dto.AwsApiTypeMantle || a.ClientMode == ClientModeApiKey {
claudeAdaptor := claude.Adaptor{}
usage, err = claudeAdaptor.DoResponse(c, resp, info)
} else {
+11 -10
View File
@@ -40,18 +40,19 @@ func formatRequest(requestBody io.Reader, requestHeader http.Header) (*AwsClaude
awsClaudeRequest.AnthropicVersion = "bedrock-2023-05-31"
// check header anthropic-beta
anthropicBetaValues := requestHeader.Get("anthropic-beta")
if len(anthropicBetaValues) > 0 {
var tempArray []string
tempArray = strings.Split(anthropicBetaValues, ",")
if len(tempArray) > 0 {
betaJson, err := json.Marshal(tempArray)
if err != nil {
return nil, err
}
awsClaudeRequest.AnthropicBeta = betaJson
var betas []string
for beta := range strings.SplitSeq(requestHeader.Get("anthropic-beta"), ",") {
if beta = strings.TrimSpace(beta); beta != "" {
betas = append(betas, beta)
}
}
if len(betas) > 0 {
betaJson, err := common.Marshal(betas)
if err != nil {
return nil, err
}
awsClaudeRequest.AnthropicBeta = betaJson
}
logger.LogJson(context.Background(), "json", awsClaudeRequest)
return &awsClaudeRequest, nil
}
+144
View File
@@ -0,0 +1,144 @@
package aws
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/relay/channel/claude"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/gin-gonic/gin"
)
func mantleRequestURL(info *relaycommon.RelayInfo) (string, error) {
parts := strings.Split(info.ApiKey, "|")
if len(parts) != 2 && len(parts) != 3 {
return "", fmt.Errorf("invalid AWS credentials: use APIKey|Region or AccessKey|SecretAccessKey|Region")
}
for _, part := range parts {
if strings.TrimSpace(part) == "" {
return "", fmt.Errorf("invalid AWS credentials: empty credential or region")
}
}
region := parts[len(parts)-1]
for _, ch := range region {
if (ch < 'a' || ch > 'z') && (ch < '0' || ch > '9') && ch != '-' {
return "", fmt.Errorf("invalid AWS region")
}
}
baseURL := strings.TrimRight(info.ChannelBaseUrl, "/")
if baseURL == "" {
baseURL = "https://bedrock-mantle." + region + ".api.aws"
}
endpoint, err := url.Parse(baseURL)
if err != nil || endpoint.Scheme != "https" || endpoint.Host == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" {
return "", fmt.Errorf("AWS Mantle base URL must be an HTTPS URL without credentials, query, or fragment")
}
switch {
case strings.HasSuffix(endpoint.Path, "/anthropic/v1/messages"):
case strings.HasSuffix(endpoint.Path, "/anthropic/v1"):
endpoint.Path += "/messages"
case strings.HasSuffix(endpoint.Path, "/anthropic"):
endpoint.Path += "/v1/messages"
default:
endpoint.Path += "/anthropic/v1/messages"
}
return endpoint.String(), nil
}
// Mantle uses native Messages JSON and SSE, not the InvokeModel envelope.
// Sign the final payload and headers, including administrator overrides.
func (a *Adaptor) newMantleRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Request, error) {
endpoint, err := mantleRequestURL(info)
if err != nil {
return nil, err
}
if a.IsNova || isNovaModel(getAwsModelID(info.UpstreamModelName)) {
return nil, fmt.Errorf("AWS Mantle Messages API requires a Claude model; use InvokeModel for Amazon Nova")
}
var payload map[string]json.RawMessage
if err := common.DecodeJson(requestBody, &payload); err != nil {
return nil, fmt.Errorf("decode AWS Mantle request: %w", err)
}
if payload == nil {
return nil, fmt.Errorf("AWS Mantle request must be a JSON object")
}
// Use the channel's resolved model even for passthrough requests. Do not
// apply InvokeModel's version suffixes or automatic cross-region prefixes.
model := info.UpstreamModelName
if model == "claude-haiku-4-5-20251001" {
model = "claude-haiku-4-5"
}
if strings.HasPrefix(model, "claude-") {
model = "anthropic." + model
}
payload["model"], err = common.Marshal(model)
if err != nil {
return nil, err
}
delete(payload, "anthropic_version")
delete(payload, "anthropic_beta")
body, err := common.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal AWS Mantle request: %w", err)
}
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create AWS Mantle request: %w", err)
}
channel.SetupApiRequestHeader(info, c, &req.Header)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("anthropic-version", "2023-06-01")
claude.CommonClaudeHeadersOperation(c, &req.Header, info)
parts := strings.Split(info.ApiKey, "|")
if len(parts) == 2 {
req.Header.Set("x-api-key", parts[0])
}
headerOverride, err := channel.ResolveHeaderOverride(info, c)
if err != nil {
return nil, err
}
for key, value := range headerOverride {
req.Header.Set(key, value)
if strings.EqualFold(key, "Host") {
req.Host = value
}
}
// HTTP/2 and intermediaries can remove hop-by-hop headers. Strip them
// after overrides, including fields named by Connection, so SigV4 covers
// only headers that will reach Mantle. Proxy credentials must stay local.
for _, connection := range req.Header.Values("Connection") {
for name := range strings.SplitSeq(connection, ",") {
req.Header.Del(strings.TrimSpace(name))
}
}
for _, name := range []string{
"Connection", "Proxy-Connection", "Keep-Alive", "Proxy-Authenticate",
"Proxy-Authorization", "Te", "Trailer", "Transfer-Encoding", "Upgrade",
} {
req.Header.Del(name)
}
if len(parts) == 3 {
// Never send an unrelated bearer credential alongside SigV4. The AWS
// signer handles timestamps and signing the exact transmitted payload.
req.Header.Del("Authorization")
req.Header.Del("x-api-key")
credentials := aws.Credentials{AccessKeyID: parts[0], SecretAccessKey: parts[1]}
hash := sha256.Sum256(body)
if err := v4.NewSigner().SignHTTP(req.Context(), credentials, req, hex.EncodeToString(hash[:]), "bedrock-mantle", parts[2], time.Now()); err != nil {
return nil, fmt.Errorf("sign AWS Mantle request: %w", err)
}
}
return req, nil
}
+2 -2
View File
@@ -299,10 +299,10 @@ streamLoop:
return respErr, nil
}
case *bedrockruntimeTypes.UnknownUnionMember:
fmt.Println("unknown tag:", v.Tag)
fmt.Println(common.LogText("unknown tag: %v", v.Tag))
return types.NewError(errors.New("unknown response type"), types.ErrorCodeInvalidRequest), nil
default:
fmt.Println("union is nil or unknown type")
fmt.Println(common.LogText("union is nil or unknown type"))
return types.NewError(errors.New("nil or unknown response type"), types.ErrorCodeInvalidRequest), nil
}
}
+458
View File
@@ -3,21 +3,29 @@ package aws
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
relaytypes "github.com/QuantumNous/new-api/relaykit/types"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream"
"github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream/eventstreamapi"
"github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime"
"github.com/gin-gonic/gin"
@@ -182,6 +190,37 @@ func TestDoAwsClientRequest_AppliesRuntimeHeaderOverrideToAnthropicBeta(t *testi
require.Equal(t, []any{"computer-use-2025-01-24"}, values)
}
func TestDoAwsClientRequest_ForwardsThinkingBlockBindingAndBetaList(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
ctx.Request.Header.Set("anthropic-beta", "fast-mode-2026-02-01, thinking-binding-controls-2026-08-01")
info := &relaycommon.RelayInfo{
OriginModelName: "claude-opus-5-5",
ChannelMeta: &relaycommon.ChannelMeta{
ApiKey: "access-key|secret-key|us-east-1",
UpstreamModelName: "claude-opus-5-5",
},
}
requestBody := bytes.NewBufferString(`{"max_tokens":128,"messages":[{"role":"user","content":"hello"}],` +
`"thinking":{"type":"adaptive","display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"}}}`)
adaptor := &Adaptor{}
_, err := doAwsClientRequest(ctx, info, adaptor, requestBody)
require.NoError(t, err)
awsReq, ok := adaptor.AwsReq.(*bedrockruntime.InvokeModelInput)
require.True(t, ok)
var payload struct {
AnthropicBeta []string `json:"anthropic_beta"`
Thinking json.RawMessage `json:"thinking"`
}
require.NoError(t, common.Unmarshal(awsReq.Body, &payload))
assert.Equal(t, []string{"fast-mode-2026-02-01", "thinking-binding-controls-2026-08-01"}, payload.AnthropicBeta)
assert.JSONEq(t, `{"type":"adaptive","display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"}}`, string(payload.Thinking))
}
func TestNewAwsInvokeContextInheritsParent(t *testing.T) {
originalRelayTimeout := common.RelayTimeout
t.Cleanup(func() {
@@ -450,3 +489,422 @@ func TestAwsStreamHandlerStopsAtClientCancellation(t *testing.T) {
t.Fatal("upstream producer did not observe the closed stream")
}
}
func newMantleTestInfo() *relaycommon.RelayInfo {
info := newAwsTestRelayInfo()
info.IsStream = false
info.RelayFormat = relaytypes.RelayFormatClaude
info.UpstreamModelName = "claude-opus-4-7"
info.ApiKey = "test-api-key|us-east-1"
info.ChannelOtherSettings.AwsApiType = dto.AwsApiTypeMantle
return info
}
func TestMantleRequestPreservesNativeFieldsAndSignsFinalPayload(t *testing.T) {
for _, key := range []string{"test-api-key|us-east-1", "access-key|secret-key|eu-west-1"} {
t.Run(strings.Split(key, "|")[0], func(t *testing.T) {
info := newMantleTestInfo()
info.ApiKey = key
info.UseRuntimeHeadersOverride = true
info.RuntimeHeadersOverride = map[string]any{"anthropic-beta": "test-beta", "X-Test": "override"}
info.ChannelSetting.PassThroughBodyEnabled = true
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
c := newAwsTestContext(httptest.NewRecorder(), ctx)
c.Request.Header.Set("Authorization", "Bearer client-secret")
c.Request.Header.Set("x-api-key", "client-secret")
a := &Adaptor{}
body := `{"model":"client-alias","stream":true,"max_tokens":128,"temperature":0,"cache_control":{"type":"ephemeral","ttl":"1h"},"messages":[{"role":"user","content":[{"type":"text","text":"hello","cache_control":{"type":"ephemeral"}}]}],"future_option":{"enabled":false},"anthropic_version":"bedrock-2023-05-31"}`
req, err := a.newMantleRequest(c, info, strings.NewReader(body))
require.NoError(t, err)
assert.Equal(t, "/anthropic/v1/messages", req.URL.Path)
assert.Equal(t, http.MethodPost, req.Method)
assert.Equal(t, "2023-06-01", req.Header.Get("anthropic-version"))
assert.Equal(t, "test-beta", req.Header.Get("anthropic-beta"))
assert.Equal(t, "override", req.Header.Get("X-Test"))
payload, err := io.ReadAll(req.Body)
require.NoError(t, err)
var data map[string]json.RawMessage
require.NoError(t, common.Unmarshal(payload, &data))
assert.JSONEq(t, `"anthropic.claude-opus-4-7"`, string(data["model"]))
assert.JSONEq(t, `{"type":"ephemeral","ttl":"1h"}`, string(data["cache_control"]))
assert.JSONEq(t, `[{"role":"user","content":[{"type":"text","text":"hello","cache_control":{"type":"ephemeral"}}]}]`, string(data["messages"]))
assert.JSONEq(t, `{"enabled":false}`, string(data["future_option"]))
assert.Equal(t, "0", string(data["temperature"]))
assert.Equal(t, "true", string(data["stream"]))
assert.NotContains(t, data, "anthropic_version")
replay, err := req.GetBody()
require.NoError(t, err)
defer replay.Close()
replayed, err := io.ReadAll(replay)
require.NoError(t, err)
assert.Equal(t, payload, replayed)
if strings.HasPrefix(key, "access-key|") {
assert.Empty(t, req.Header.Get("x-api-key"))
auth := req.Header.Get("Authorization")
assert.Contains(t, auth, "/eu-west-1/bedrock-mantle/aws4_request")
assert.NotContains(t, auth, "secret-key")
signedAt, err := time.Parse("20060102T150405Z", req.Header.Get("X-Amz-Date"))
require.NoError(t, err)
verification := req.Clone(ctx)
verification.Header.Del("Authorization")
hash := sha256.Sum256(payload)
err = v4.NewSigner().SignHTTP(ctx, aws.Credentials{AccessKeyID: "access-key", SecretAccessKey: "secret-key"}, verification, hex.EncodeToString(hash[:]), "bedrock-mantle", "eu-west-1", signedAt)
require.NoError(t, err)
assert.Equal(t, auth, verification.Header.Get("Authorization"))
} else {
assert.Equal(t, "test-api-key", req.Header.Get("x-api-key"))
assert.Empty(t, req.Header.Get("Authorization"))
}
cancel()
assert.ErrorIs(t, req.Context().Err(), context.Canceled)
})
}
}
func TestMantleEndpointAndCredentialValidation(t *testing.T) {
for _, test := range []struct {
name, key, base, want string
invalid bool
}{
{name: "regional default", key: "key|us-west-2", want: "https://bedrock-mantle.us-west-2.api.aws/anthropic/v1/messages"},
{name: "proxy root", key: "ak|sk|us-east-1", base: "https://proxy.example/gateway/", want: "https://proxy.example/gateway/anthropic/v1/messages"},
{name: "anthropic root", key: "key|us-east-1", base: "https://proxy.example/anthropic/", want: "https://proxy.example/anthropic/v1/messages"},
{name: "version root", key: "key|us-east-1", base: "https://proxy.example/anthropic/v1", want: "https://proxy.example/anthropic/v1/messages"},
{name: "full endpoint", key: "key|us-east-1", base: "https://proxy.example/anthropic/v1/messages", want: "https://proxy.example/anthropic/v1/messages"},
{name: "missing region", key: "secret", invalid: true},
{name: "empty key", key: "|us-east-1", invalid: true},
{name: "empty secret", key: "ak||us-east-1", invalid: true},
{name: "injected host", key: "key|us-east-1.evil.example/", invalid: true},
{name: "plaintext URL", key: "key|us-east-1", base: "http://proxy.example", invalid: true},
{name: "URL credentials", key: "key|us-east-1", base: "https://user:secret@proxy.example", invalid: true},
} {
t.Run(test.name, func(t *testing.T) {
info := newMantleTestInfo()
info.ApiKey, info.ChannelBaseUrl = test.key, test.base
endpoint, err := (&Adaptor{}).GetRequestURL(info)
if test.invalid {
require.Error(t, err)
assert.NotContains(t, err.Error(), "secret")
assert.Empty(t, endpoint)
return
}
require.NoError(t, err)
assert.Equal(t, test.want, endpoint)
})
}
}
func TestMantleHTTPResponsesPreserveCacheUsage(t *testing.T) {
originalTimeout := constant.StreamingTimeout
constant.StreamingTimeout = 30
t.Cleanup(func() { constant.StreamingTimeout = originalTimeout })
const usageJSON = `{"input_tokens":100,"output_tokens":23,"cache_read_input_tokens":1000,"cache_creation_input_tokens":50,"cache_creation":{"ephemeral_5m_input_tokens":20,"ephemeral_1h_input_tokens":30}}`
for _, stream := range []bool{false, true} {
for _, format := range []relaytypes.RelayFormat{relaytypes.RelayFormatClaude, relaytypes.RelayFormatOpenAI} {
t.Run(fmt.Sprintf("stream=%t/format=%s", stream, format), func(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if stream {
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprintf(w, "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_mantle\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"anthropic.claude-opus-4-7\",\"content\":[],\"usage\":%s}}\n\n", usageJSON)
fmt.Fprint(w, "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n\n")
fmt.Fprint(w, "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":23}}\n\n")
fmt.Fprint(w, "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"id":"msg_mantle","type":"message","role":"assistant","model":"anthropic.claude-opus-4-7","content":[{"type":"text","text":"hello"}],"stop_reason":"end_turn","usage":%s}`, usageJSON)
}))
defer server.Close()
original := http.DefaultClient
http.DefaultClient = server.Client()
defer func() { http.DefaultClient = original }()
info := newMantleTestInfo()
info.ChannelBaseUrl, info.IsStream, info.RelayFormat = server.URL, stream, format
recorder := httptest.NewRecorder()
c := newAwsTestContext(recorder, context.Background())
a := &Adaptor{}
a.Init(info)
var request dto.ClaudeRequest
require.NoError(t, common.UnmarshalJsonStr(`{"model":"claude-opus-4-7","max_tokens":128,"messages":[{"role":"user","content":"hello"}],"cache_control":{"type":"ephemeral"}}`, &request))
request.Stream = &stream
converted, err := a.ConvertClaudeRequest(c, info, &request)
require.NoError(t, err)
body, err := common.Marshal(converted)
require.NoError(t, err)
response, err := a.DoRequest(c, info, bytes.NewReader(body))
require.NoError(t, err)
httpResponse, ok := response.(*http.Response)
require.True(t, ok)
result, apiErr := a.DoResponse(c, httpResponse, info)
require.Nil(t, apiErr)
usage, ok := result.(*dto.Usage)
require.True(t, ok)
require.NotNil(t, usage.BillingUsage)
require.NotNil(t, usage.BillingUsage.ClaudeUsage)
claudeUsage := usage.BillingUsage.ClaudeUsage
assert.Equal(t, 100, claudeUsage.InputTokens)
assert.Equal(t, 23, claudeUsage.OutputTokens)
assert.Equal(t, 1000, claudeUsage.CacheReadInputTokens)
assert.Equal(t, 50, claudeUsage.CacheCreationInputTokens)
assert.Equal(t, 20, claudeUsage.GetCacheCreation5mTokens())
assert.Equal(t, 30, claudeUsage.GetCacheCreation1hTokens())
assert.Contains(t, recorder.Body.String(), "hello")
if stream && format == relaytypes.RelayFormatOpenAI {
assert.Contains(t, recorder.Body.String(), "[DONE]")
}
})
}
}
}
func TestMantleDoesNotFollowRedirectsOrFallbackOnAuthFailure(t *testing.T) {
for _, status := range []int{http.StatusTemporaryRedirect, http.StatusUnauthorized, http.StatusTooManyRequests} {
t.Run(fmt.Sprint(status), func(t *testing.T) {
calls := 0
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.Header().Set("Location", "/credential-leak")
w.WriteHeader(status)
}))
defer server.Close()
original := http.DefaultClient
http.DefaultClient = server.Client()
defer func() { http.DefaultClient = original }()
info := newMantleTestInfo()
info.ChannelBaseUrl = server.URL
c := newAwsTestContext(httptest.NewRecorder(), context.Background())
response, err := (&Adaptor{}).DoRequest(c, info, strings.NewReader(`{"messages":[]}`))
require.NoError(t, err)
httpResponse := response.(*http.Response)
defer httpResponse.Body.Close()
assert.Equal(t, status, httpResponse.StatusCode)
assert.Equal(t, 1, calls)
})
}
}
func TestAWSLegacyInterfaceRemainsDefault(t *testing.T) {
for _, mode := range []dto.AwsApiType{"", dto.AwsApiTypeInvokeModel} {
for _, stream := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/stream=%t", mode, stream), func(t *testing.T) {
info := newMantleTestInfo()
info.ChannelOtherSettings.AwsApiType = mode
info.IsStream = stream
info.UpstreamModelName = "claude-3-5-sonnet-20240620"
c := newAwsTestContext(httptest.NewRecorder(), context.Background())
a := &Adaptor{}
a.Init(info)
response, err := a.DoRequest(c, info, strings.NewReader(`{"max_tokens":128,"messages":[{"role":"user","content":[{"type":"text","text":"hi","cache_control":{"type":"ephemeral"}}]}],"cache_control":{"type":"ephemeral"}}`))
require.NoError(t, err)
assert.Nil(t, response)
var body []byte
if stream {
input, ok := a.AwsReq.(*bedrockruntime.InvokeModelWithResponseStreamInput)
require.True(t, ok)
body = input.Body
assert.Equal(t, "us."+awsTestModel, *input.ModelId)
} else {
input, ok := a.AwsReq.(*bedrockruntime.InvokeModelInput)
require.True(t, ok)
body = input.Body
assert.Equal(t, "us."+awsTestModel, *input.ModelId)
}
var payload map[string]json.RawMessage
require.NoError(t, common.Unmarshal(body, &payload))
assert.Equal(t, `"bedrock-2023-05-31"`, string(payload["anthropic_version"]))
assert.NotContains(t, payload, "cache_control")
assert.Contains(t, string(payload["messages"]), "cache_control")
})
}
}
}
func TestMantleRejectsInvalidRequestsAndPreservesExplicitModelIDs(t *testing.T) {
for _, test := range []struct {
name, model, body, wantModel string
invalid bool
}{
{name: "legacy Haiku alias", model: "claude-haiku-4-5-20251001", body: `{"messages":[]}`, wantModel: "anthropic.claude-haiku-4-5"},
{name: "short Haiku alias", model: "claude-haiku-4-5", body: `{"messages":[]}`, wantModel: "anthropic.claude-haiku-4-5"},
{name: "explicit Haiku model", model: "anthropic.claude-haiku-4-5", body: `{"messages":[]}`},
{name: "explicit Haiku inference profile", model: "us.anthropic.claude-haiku-4-5", body: `{"messages":[]}`},
{name: "explicit regional model", model: "us.anthropic.claude-opus-4-7", body: `{"messages":[]}`},
{name: "explicit model", model: "anthropic.claude-opus-4-7", body: `{"messages":[]}`},
{name: "Nova requires legacy", model: "nova-pro-v1:0", body: `{"messages":[]}`, invalid: true},
{name: "null body", model: "claude-opus-4-7", body: `null`, invalid: true},
{name: "malformed JSON", model: "claude-opus-4-7", body: `{`, invalid: true},
} {
t.Run(test.name, func(t *testing.T) {
info := newMantleTestInfo()
info.UpstreamModelName = test.model
c := newAwsTestContext(httptest.NewRecorder(), context.Background())
req, err := (&Adaptor{}).newMantleRequest(c, info, strings.NewReader(test.body))
if test.invalid {
require.Error(t, err)
assert.Nil(t, req)
return
}
require.NoError(t, err)
defer req.Body.Close()
var payload map[string]any
require.NoError(t, common.DecodeJson(req.Body, &payload))
wantModel := test.wantModel
if wantModel == "" {
wantModel = test.model
}
assert.Equal(t, wantModel, payload["model"])
assert.NotContains(t, payload, "cache_control")
})
}
}
func TestMantleSignatureMatchesWireHeaders(t *testing.T) {
for _, test := range []struct {
name string
http2 bool
allHopHeaders bool
}{
{name: "HTTP2 keep-alive", http2: true},
{name: "HTTP1 hop-by-hop headers", allHopHeaders: true},
{name: "HTTP2 hop-by-hop headers", http2: true, allHopHeaders: true},
} {
t.Run(test.name, func(t *testing.T) {
type receivedRequest struct {
request *http.Request
body []byte
err error
}
received := make(chan receivedRequest, 1)
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
received <- receivedRequest{request: r.Clone(context.Background()), body: body, err: err}
w.WriteHeader(http.StatusOK)
}))
server.EnableHTTP2 = test.http2
server.StartTLS()
defer server.Close()
originalClient := http.DefaultClient
http.DefaultClient = server.Client()
defer func() { http.DefaultClient = originalClient }()
info := newMantleTestInfo()
info.ChannelBaseUrl = server.URL
info.ApiKey = "access-key|secret-key|us-east-1"
info.UseRuntimeHeadersOverride = true
info.RuntimeHeadersOverride = map[string]any{
"Connection": "keep-alive",
"anthropic-beta": "test-beta",
"X-Test": "end-to-end",
}
if test.allHopHeaders {
maps.Copy(info.RuntimeHeadersOverride, map[string]any{
"Connection": "keep-alive, x-hop-token",
"X-Hop-Token": "hop-only",
"Keep-Alive": "timeout=5",
"Proxy-Connection": "keep-alive",
"Proxy-Authenticate": "Basic",
"Proxy-Authorization": "Basic test-proxy-token",
"Te": "trailers",
"Trailer": "X-Checksum",
"Transfer-Encoding": "chunked",
"Upgrade": "websocket",
})
}
c := newAwsTestContext(httptest.NewRecorder(), context.Background())
response, err := (&Adaptor{}).DoRequest(c, info, strings.NewReader(`{"messages":[],"max_tokens":128,"cache_control":{"type":"ephemeral","ttl":"1h"}}`))
require.NoError(t, err)
httpResponse, ok := response.(*http.Response)
require.True(t, ok)
defer httpResponse.Body.Close()
wire := <-received
require.NoError(t, wire.err)
wantProtocol := 1
if test.http2 {
wantProtocol = 2
}
require.Equal(t, wantProtocol, wire.request.ProtoMajor)
for _, name := range []string{"Connection", "X-Hop-Token", "Keep-Alive", "Proxy-Connection", "Proxy-Authenticate", "Proxy-Authorization", "Te", "Trailer", "Transfer-Encoding", "Upgrade"} {
assert.Empty(t, wire.request.Header.Values(name), "%s must not reach the upstream", name)
}
assert.Equal(t, "test-beta", wire.request.Header.Get("anthropic-beta"))
assert.Equal(t, "end-to-end", wire.request.Header.Get("X-Test"))
// Recompute the signature from only the bytes and signed headers that
// reached the server, not from the pre-transport request object.
authorization := wire.request.Header.Get("Authorization")
_, signedHeaders, found := strings.Cut(authorization, "SignedHeaders=")
require.True(t, found)
signedHeaders, _, found = strings.Cut(signedHeaders, ",")
require.True(t, found)
verification := wire.request.Clone(context.Background())
verification.URL.Scheme = "https"
verification.URL.Host = wire.request.Host
verification.Header = make(http.Header)
for name := range strings.SplitSeq(signedHeaders, ";") {
if values, ok := wire.request.Header[http.CanonicalHeaderKey(name)]; ok {
verification.Header[http.CanonicalHeaderKey(name)] = values
}
}
signedAt, err := time.Parse("20060102T150405Z", wire.request.Header.Get("X-Amz-Date"))
require.NoError(t, err)
hash := sha256.Sum256(wire.body)
err = v4.NewSigner().SignHTTP(context.Background(), aws.Credentials{AccessKeyID: "access-key", SecretAccessKey: "secret-key"}, verification, hex.EncodeToString(hash[:]), "bedrock-mantle", "us-east-1", signedAt)
require.NoError(t, err)
assert.Equal(t, authorization, verification.Header.Get("Authorization"))
})
}
}
func TestAWSLegacyHaikuMappingPreservesVersionAndRegion(t *testing.T) {
for _, test := range []struct {
name string
mode dto.AwsApiType
stream bool
key string
}{
{name: "default API key", key: "test-api-key|us-east-1"},
{name: "default API key stream", stream: true, key: "test-api-key|us-east-1"},
{name: "default AK/SK", key: "access-key|secret-key|us-east-1"},
{name: "default AK/SK stream", stream: true, key: "access-key|secret-key|us-east-1"},
{name: "explicit API key", mode: dto.AwsApiTypeInvokeModel, key: "test-api-key|us-east-1"},
{name: "explicit API key stream", mode: dto.AwsApiTypeInvokeModel, stream: true, key: "test-api-key|us-east-1"},
{name: "explicit AK/SK", mode: dto.AwsApiTypeInvokeModel, key: "access-key|secret-key|us-east-1"},
{name: "explicit AK/SK stream", mode: dto.AwsApiTypeInvokeModel, stream: true, key: "access-key|secret-key|us-east-1"},
} {
t.Run(test.name, func(t *testing.T) {
info := newMantleTestInfo()
info.ChannelOtherSettings.AwsApiType = test.mode
info.ApiKey, info.IsStream = test.key, test.stream
info.UpstreamModelName = "claude-haiku-4-5-20251001"
c := newAwsTestContext(httptest.NewRecorder(), context.Background())
a := &Adaptor{}
a.Init(info)
response, err := a.DoRequest(c, info, strings.NewReader(`{"messages":[{"role":"user","content":"hello"}],"max_tokens":128}`))
require.NoError(t, err)
assert.Nil(t, response)
const wantModel = "us.anthropic.claude-haiku-4-5-20251001-v1:0"
if test.stream {
request, ok := a.AwsReq.(*bedrockruntime.InvokeModelWithResponseStreamInput)
require.True(t, ok)
assert.Equal(t, wantModel, *request.ModelId)
} else {
request, ok := a.AwsReq.(*bedrockruntime.InvokeModelInput)
require.True(t, ok)
assert.Equal(t, wantModel, *request.ModelId)
}
})
}
}
func TestMantleCanceledRequestSkipsRetry(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
c := newAwsTestContext(httptest.NewRecorder(), ctx)
response, err := (&Adaptor{}).DoRequest(c, newMantleTestInfo(), strings.NewReader(`{"messages":[]}`))
require.Error(t, err)
assert.Nil(t, response)
var apiError *relaytypes.NewAPIError
require.ErrorAs(t, err, &apiError)
assert.True(t, relaytypes.IsSkipRetryError(apiError))
}
+2 -2
View File
@@ -119,7 +119,7 @@ func baiduStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
var baiduResponse BaiduChatStreamResponse
if err := common.Unmarshal([]byte(data), &baiduResponse); err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
common.SysLog(common.LogText("error unmarshalling stream response: %s", err.Error()))
sr.Error(err)
return
}
@@ -130,7 +130,7 @@ func baiduStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
}
response := streamResponseBaidu2OpenAI(&baiduResponse)
if err := helper.ObjectData(c, response); err != nil {
common.SysLog("error sending stream response: " + err.Error())
common.SysLog(common.LogText("error sending stream response: %s", err.Error()))
sr.Error(err)
}
})
+11 -1
View File
@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/url"
"strings"
"github.com/QuantumNous/new-api/relay/channel"
relaycommon "github.com/QuantumNous/new-api/relay/common"
@@ -39,6 +40,13 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
if err := relayconvert.ApplyClaudeThinkingModel(request, info); err != nil {
return nil, err
}
// A model reasoning suffix replaces the client's thinking config, but
// block_binding is a conversation control, not a reasoning setting. Keep
// the client's value when the rendered mode is still adaptive.
if original, ok := info.Request.(*dto.ClaudeRequest); ok && original.Thinking != nil &&
request.Thinking != nil && request.Thinking.Type == "adaptive" && len(request.Thinking.BlockBinding) == 0 {
request.Thinking.BlockBinding = original.Thinking.BlockBinding
}
if request.MaxTokens == nil {
defaultMaxTokens := uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(request.Model))
request.MaxTokens = &defaultMaxTokens
@@ -96,7 +104,9 @@ func shouldAppendClaudeBetaQuery(info *relaycommon.RelayInfo) bool {
func CommonClaudeHeadersOperation(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) {
// common headers operation
anthropicBeta := c.Request.Header.Get("anthropic-beta")
// A client may send several anthropic-beta lines; the API reads one
// comma-separated list, so forward all of them.
anthropicBeta := strings.Join(c.Request.Header.Values("anthropic-beta"), ",")
if anthropicBeta != "" {
req.Set("anthropic-beta", anthropicBeta)
}
+111
View File
@@ -1,6 +1,8 @@
package claude
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
@@ -74,6 +76,35 @@ func TestConvertClaudeRequestPreservesNativeClaudeCodeThinking(t *testing.T) {
assert.Empty(t, info.ConversionDiagnostics())
}
func TestConvertClaudeRequestPreservesMessageOutputConfig(t *testing.T) {
body := `{"model":"claude-opus-5-5","max_tokens":64,"output_config":{"effort":"medium"},"messages":[` +
`{"role":"user","content":"summary"},` +
`{"role":"system","content":[],"output_config":{"effort":"high"}},` +
`{"role":"assistant","content":"done"}]}`
var req dto.ClaudeRequest
require.NoError(t, common.UnmarshalJsonStr(body, &req))
info := &relaycommon.RelayInfo{
OriginModelName: req.Model,
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: req.Model,
},
}
out, err := (&Adaptor{}).ConvertClaudeRequest(nil, info, &req)
require.NoError(t, err)
encoded, err := common.Marshal(out)
require.NoError(t, err)
var upstream struct {
Messages []map[string]any `json:"messages"`
}
require.NoError(t, common.Unmarshal(encoded, &upstream))
require.Len(t, upstream.Messages, 3)
assert.Equal(t, map[string]any{"effort": "high"}, upstream.Messages[1]["output_config"])
assert.NotContains(t, upstream.Messages[0], "output_config")
assert.NotContains(t, upstream.Messages[2], "output_config")
}
func TestConvertClaudeRequestZeroMaxTokensStillRaisesThinkingBudget(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
@@ -127,6 +158,86 @@ func TestConvertClaudeRequestDoesNotOverwriteTrimmedUpstreamModelName(t *testing
assert.Equal(t, "claude-3-7-sonnet", info.UpstreamModelName)
}
func TestConvertClaudeRequestForwardsThinkingBlockBinding(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
model string
thinking string
wantThinking string
}{
{
name: "native request keeps every thinking field",
model: "claude-opus-5-5",
thinking: `{"type":"adaptive","display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"},"future_field":{"a":1}}`,
wantThinking: `{"type":"adaptive","display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"},"future_field":{"a":1}}`,
},
{
name: "effort suffix re-renders adaptive and keeps block_binding",
model: "claude-opus-5-5-high",
thinking: `{"type":"adaptive","block_binding":{"prefix_mismatch_behavior":"drop_block"}}`,
wantThinking: `{"type":"adaptive","display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"}}`,
},
{
name: "suffix rendering between_tools does not carry block_binding",
model: "claude-sonnet-5-5-none",
thinking: `{"type":"adaptive","block_binding":{"prefix_mismatch_behavior":"drop_block"}}`,
wantThinking: `{"type":"between_tools"}`,
},
{
name: "request without block_binding gets none",
model: "claude-opus-5-5-high",
thinking: `{"type":"adaptive"}`,
wantThinking: `{"type":"adaptive","display":"summarized"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
body := `{"model":"` + tt.model + `","max_tokens":1024,"thinking":` + tt.thinking +
`,"messages":[{"role":"user","content":"hi"}]}`
var original dto.ClaudeRequest
require.NoError(t, common.UnmarshalJsonStr(body, &original))
info := &relaycommon.RelayInfo{
OriginModelName: tt.model,
Request: &original,
ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: tt.model},
}
// Same steps as the Claude handler before the adaptor converts.
outbound, err := common.DeepCopy(&original)
require.NoError(t, err)
require.NoError(t, helper.ModelMappedHelper(c, info, outbound))
require.NoError(t, helper.ApplyReasoningModelSuffix(nil, info, outbound))
out, err := (&Adaptor{}).ConvertClaudeRequest(c, info, outbound)
require.NoError(t, err)
encoded, err := common.Marshal(out)
require.NoError(t, err)
var upstream struct {
Thinking json.RawMessage `json:"thinking"`
}
require.NoError(t, common.Unmarshal(encoded, &upstream))
assert.JSONEq(t, tt.wantThinking, string(upstream.Thinking))
})
}
}
func TestSetupRequestHeaderForwardsEveryAnthropicBetaValue(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
c.Request.Header.Add("anthropic-beta", "fast-mode-2026-02-01")
c.Request.Header.Add("anthropic-beta", "thinking-binding-controls-2026-08-01")
info := &relaycommon.RelayInfo{
OriginModelName: "claude-opus-5-5",
ChannelMeta: &relaycommon.ChannelMeta{ApiKey: "sk-test"},
}
header := http.Header{}
require.NoError(t, (&Adaptor{}).SetupRequestHeader(c, &header, info))
assert.Equal(t, []string{"fast-mode-2026-02-01,thinking-binding-controls-2026-08-01"}, header.Values("anthropic-beta"))
}
func geminiToClaudeInfo() *relaycommon.RelayInfo {
return &relaycommon.RelayInfo{
OriginModelName: "claude-3-7-sonnet",
+70 -33
View File
@@ -1,6 +1,7 @@
package claude
import (
"cmp"
"fmt"
"io"
"net/http"
@@ -89,7 +90,7 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
var claudeResponse dto.ClaudeResponse
err := common.UnmarshalJsonStr(data, &claudeResponse)
if err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
common.SysLog(common.LogText("error unmarshalling stream response: %s", err.Error()))
return types.NewError(err, types.ErrorCodeBadResponseBody)
}
if claudeError := claudeResponse.GetClaudeError(); claudeError != nil && claudeError.Type != "" {
@@ -122,7 +123,7 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
data = patchClaudeMessageDeltaUsageData(data, buildMessageDeltaPatchUsage(&claudeResponse, claudeInfo))
}
}
countClaudeStreamBillableTools(c, info, &claudeResponse)
countClaudeStreamBillableTools(info, &claudeResponse)
helper.ClaudeChunkData(c, claudeResponse, data)
} else if info.RelayFormat == types.RelayFormatOpenAI {
state, err := claudeToChatStreamState(info)
@@ -138,14 +139,14 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
return nil
}
countClaudeStreamBillableTools(c, info, &claudeResponse)
countClaudeStreamBillableTools(info, &claudeResponse)
if response == nil {
return nil
}
err = helper.ObjectData(c, response)
if err != nil {
logger.LogError(c, "send_stream_response_failed: "+err.Error())
logger.LogError(c, common.LogText("send_stream_response_failed: %s", err.Error()))
}
} else if info.RelayFormat == types.RelayFormatGemini {
state, err := claudeToGeminiStreamState(info)
@@ -159,7 +160,7 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
if !FormatClaudeResponseInfo(&claudeResponse, nil, claudeInfo) {
return nil
}
countClaudeStreamBillableTools(c, info, &claudeResponse)
countClaudeStreamBillableTools(info, &claudeResponse)
if sendErr := sendGeminiStreamResults(c, results); sendErr != nil {
return sendErr
}
@@ -221,38 +222,77 @@ func sendGeminiStreamResults(c *gin.Context, results []relayconvert.ResponseResu
return nil
}
func countClaudeStreamBillableTools(c *gin.Context, info *relaycommon.RelayInfo, claudeResponse *dto.ClaudeResponse) {
func countClaudeStreamBillableTools(info *relaycommon.RelayInfo, claudeResponse *dto.ClaudeResponse) {
if claudeResponse == nil {
return
}
if claudeResponse.Type == "content_block_start" &&
claudeResponse.ContentBlock != nil &&
claudeResponse.ContentBlock.Type == "tool_use" {
info.CountBillableToolCall(dto.BuildInCallToolUse, claudeResponse.ContentBlock.Name)
if claudeResponse.Type == "content_block_start" && claudeResponse.ContentBlock != nil {
countClaudeContentBlockTools(info, claudeResponse.ContentBlock)
}
if claudeResponse.Type == "message_delta" &&
claudeResponse.Usage != nil &&
claudeResponse.Usage.ServerToolUse != nil &&
claudeResponse.Usage.ServerToolUse.WebSearchRequests > 0 {
c.Set("claude_web_search_requests", claudeResponse.Usage.ServerToolUse.WebSearchRequests)
if claudeResponse.Type == "message_delta" && claudeResponse.Usage != nil {
// message_delta usage is cumulative, so the last report wins.
recordClaudeServerToolUse(info, claudeResponse.Usage.ServerToolUse)
}
}
// countClaudeContentBlockTools counts the billable tool calls of one content
// block. On a channel whose upstream prices search itself
// (WebSearchBillingKey), every web_search_tool_result that carries results
// bills one search: usage.server_tool_use replaces that count when reported,
// and it stands when a stream ends before message_delta.
func countClaudeContentBlockTools(info *relaycommon.RelayInfo, block *dto.ClaudeMediaMessage) {
switch block.Type {
case "tool_use":
info.CountBillableToolCall(dto.BuildInCallToolUse, block.Name)
case "web_search_tool_result":
if _, delivered := block.Content.([]any); delivered && info.WebSearchBillingKey != "" {
info.CountBillableToolCall(dto.BuildInCallWebSearchCall, "")
}
}
}
// recordClaudeServerToolUse bills the server tool requests Anthropic reports
// in usage.server_tool_use. Searches and fetches use the channel's own keys
// when its adaptor sets them; otherwise web_fetch, code_execution, and
// tool_search have no built-in price and bill only once an operator prices
// them.
func recordClaudeServerToolUse(info *relaycommon.RelayInfo, serverToolUse *dto.ClaudeServerToolUse) {
if info == nil || serverToolUse == nil {
return
}
for name, count := range map[string]int{
cmp.Or(info.WebSearchBillingKey, dto.BuildInToolWebSearch): serverToolUse.WebSearchRequests,
cmp.Or(info.WebFetchBillingKey, "web_fetch"): serverToolUse.WebFetchRequests,
"code_execution": serverToolUse.CodeExecutionRequests,
"tool_search": serverToolUse.ToolSearchRequests,
} {
if count > 0 {
info.SetBillableToolCount(name, count)
}
}
}
func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo) {
if claudeInfo.Usage.PromptTokens == 0 {
//上游出错
}
if claudeInfo.Usage.CompletionTokens == 0 || !claudeInfo.Done {
// Claude input_tokens excludes cache reads and writes, so a fully cached
// prompt reports zero input; the prompt is uncounted only when the total
// with cache is zero as well. Zero output is uncounted only when content
// was streamed, so a refusal before any output keeps its zeros.
missingInputUsage := buildOpenAIStyleUsageFromClaudeUsage(claudeInfo.Usage).PromptTokens == 0
missingOutputUsage := claudeInfo.Usage.CompletionTokens == 0 && claudeInfo.ResponseText.Len() > 0
if missingInputUsage || missingOutputUsage || !claudeInfo.Done {
if common.DebugEnabled {
common.SysLog("claude response usage is not complete, maybe upstream error")
common.SysLog(common.LogText("claude response usage is not complete, maybe upstream error"))
}
// 只补缺失字段,不整份覆盖——保留 message_start 已拿到的 cache 字段
fallback := service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
if claudeInfo.Usage.CompletionTokens == 0 ||
// Each tool_use block costs about 40 output tokens beyond its name and JSON
// input (measured on Claude Haiku 4.5 and Sonnet 5 agent turns).
fallback.CompletionTokens += claudeInfo.ToolUseCount * 40
if missingOutputUsage ||
(!claudeInfo.Done && fallback.CompletionTokens > claudeInfo.Usage.CompletionTokens) {
claudeInfo.Usage.CompletionTokens = fallback.CompletionTokens
}
if claudeInfo.Usage.PromptTokens == 0 {
if missingInputUsage {
claudeInfo.Usage.PromptTokens = fallback.PromptTokens
}
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
@@ -270,23 +310,23 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
response := helper.GenerateFinalUsageResponse(claudeInfo.ResponseId, claudeInfo.Created, info.UpstreamModelName, openAIUsage)
err := helper.ObjectData(c, response)
if err != nil {
common.SysLog("send final response failed: " + err.Error())
common.SysLog(common.LogText("send final response failed: %s", err.Error()))
}
}
helper.Done(c)
} else if info.RelayFormat == types.RelayFormatGemini {
state, err := claudeToGeminiStreamState(info)
if err != nil {
common.SysLog("error creating Gemini stream state: " + err.Error())
common.SysLog(common.LogText("error creating Gemini stream state: %s", err.Error()))
return
}
results, err := service.FinalizeStreamResponse(c, info, state)
if err != nil {
common.SysLog("error finalizing Gemini stream response: " + err.Error())
common.SysLog(common.LogText("error finalizing Gemini stream response: %s", err.Error()))
return
}
if sendErr := sendGeminiStreamResults(c, results); sendErr != nil {
common.SysLog("send final Gemini stream response failed: " + sendErr.Error())
common.SysLog(common.LogText("send final Gemini stream response failed: %s", sendErr.Error()))
}
}
}
@@ -387,14 +427,11 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
}
}
if claudeResponse.Usage != nil && claudeResponse.Usage.ServerToolUse != nil && claudeResponse.Usage.ServerToolUse.WebSearchRequests > 0 {
c.Set("claude_web_search_requests", claudeResponse.Usage.ServerToolUse.WebSearchRequests)
for i := range claudeResponse.Content {
countClaudeContentBlockTools(info, &claudeResponse.Content[i])
}
for _, block := range claudeResponse.Content {
if block.Type == "tool_use" {
info.CountBillableToolCall(dto.BuildInCallToolUse, block.Name)
}
if claudeResponse.Usage != nil {
recordClaudeServerToolUse(info, claudeResponse.Usage.ServerToolUse)
}
service.IOCopyBytesGracefully(c, httpResp, responseData)
+98
View File
@@ -1,15 +1,20 @@
package claude
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/tokenkit"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -19,6 +24,47 @@ func commonPointer[T any](value T) *T {
return &value
}
func TestClaudeStreamFinalUsageEstimatesOnlyUncountedUsage(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tc := range []struct {
name, startUsage, deltaUsage, stopReason string // no deltaUsage: the stream broke before message_delta
streamText bool
wantInput, wantOutput int
wantLocalCount bool
}{
{name: "cache-only refusal keeps zeros", startUsage: `{"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":20000,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":20000}}`, deltaUsage: `{"output_tokens":0}`, stopReason: "refusal"},
{name: "broken stream keeps cache-only input", startUsage: `{"input_tokens":0,"output_tokens":1,"cache_read_input_tokens":50000}`, streamText: true, wantOutput: 2, wantLocalCount: true},
{name: "zeros after streamed text are estimated", startUsage: `{"input_tokens":0,"output_tokens":0}`, deltaUsage: `{"output_tokens":0}`, stopReason: "end_turn", streamText: true, wantInput: 1200, wantOutput: 2, wantLocalCount: true},
{name: "zero delta input keeps start input", startUsage: `{"input_tokens":1000,"output_tokens":1}`, deltaUsage: `{"input_tokens":0,"output_tokens":50}`, stopReason: "end_turn", streamText: true, wantInput: 1000, wantOutput: 50},
} {
t.Run(tc.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
info := &relaycommon.RelayInfo{RelayFormat: types.RelayFormatClaude, ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "claude-opus-4-8"}}
info.SetEstimatePromptTokens(1200)
state := &ClaudeResponseInfo{Usage: &dto.Usage{}}
start := fmt.Sprintf(`{"type":"message_start","message":{"id":"msg_1","model":"claude-opus-4-8","usage":%s}}`, tc.startUsage)
require.Nil(t, HandleStreamResponseData(c, info, state, start))
if tc.streamText {
require.Nil(t, HandleStreamResponseData(c, info, state, `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}`))
}
if tc.deltaUsage != "" {
delta := fmt.Sprintf(`{"type":"message_delta","delta":{"stop_reason":%q},"usage":%s}`, tc.stopReason, tc.deltaUsage)
require.Nil(t, HandleStreamResponseData(c, info, state, delta))
require.Nil(t, HandleStreamResponseData(c, info, state, `{"type":"message_stop"}`))
}
HandleStreamFinalResponse(c, info, state)
assert.Equal(t, tc.wantInput, state.Usage.PromptTokens)
assert.Equal(t, tc.wantOutput, state.Usage.CompletionTokens)
assert.Equal(t, tc.wantLocalCount, common.GetContextKeyBool(c, constant.ContextKeyLocalCountTokens))
canonical, ok := state.Usage.BillingUsage.CanonicalUsage()
require.True(t, ok)
assert.Equal(t, tc.wantInput, canonical.PromptTokens, "settlement must bill the same input")
assert.Equal(t, tc.wantOutput, canonical.CompletionTokens)
})
}
}
func TestResponseOpenAI2ClaudeToolUseInputIsObject(t *testing.T) {
tests := []struct {
name string
@@ -228,6 +274,33 @@ func TestFormatClaudeResponseInfo_ContentBlockDelta(t *testing.T) {
}
}
// A stream cut off during an agent tool call must still estimate the call's
// output tokens when the final usage never arrives.
func TestClaudeStreamFallbackCountsInterruptedToolUse(t *testing.T) {
claudeInfo := &ClaudeResponseInfo{Usage: &dto.Usage{}}
events := []*dto.ClaudeResponse{
{Type: "content_block_start", ContentBlock: &dto.ClaudeMediaMessage{Type: "tool_use", Id: "toolu_1", Name: "read_file"}},
{Type: "content_block_delta", Delta: &dto.ClaudeMediaMessage{Type: "input_json_delta", PartialJson: commonPointer(`{"path":`)}},
{Type: "content_block_delta", Delta: &dto.ClaudeMediaMessage{Type: "input_json_delta", PartialJson: commonPointer(`"main.go"}`)}},
}
for _, event := range events {
require.True(t, FormatClaudeResponseInfo(event, nil, claudeInfo))
}
require.Equal(t, `read_file{"path":"main.go"}`, claudeInfo.ResponseText.String())
c, _ := gin.CreateTestContext(httptest.NewRecorder())
info := &relaycommon.RelayInfo{
RelayFormat: types.RelayFormatClaude,
ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "claude-sonnet-5"},
}
info.SetEstimatePromptTokens(100)
HandleStreamFinalResponse(c, info, claudeInfo)
// Text estimate plus the per-tool_use framing the upstream bills.
assert.Equal(t, tokenkit.Estimate("claude-sonnet-5", `read_file{"path":"main.go"}`)+40, claudeInfo.Usage.CompletionTokens)
assert.Equal(t, 100, claudeInfo.Usage.PromptTokens)
}
func TestBuildOpenAIStyleUsageFromClaudeUsage(t *testing.T) {
usage := &dto.Usage{
PromptTokens: 100,
@@ -400,3 +473,28 @@ func TestOpenAIChatRequestToClaudeMessages_ClaudeOpus48ThinkingUsesAdaptiveHighE
require.Nil(t, claudeRequest.TopP)
require.Nil(t, claudeRequest.TopK)
}
func TestClaudeResponsePassesInputTransformationsThrough(t *testing.T) {
gin.SetMode(gin.TestMode)
info := &relaycommon.RelayInfo{
RelayFormat: types.RelayFormatClaude,
ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "claude-opus-5-5"},
}
transformations := `"input_transformations":[{"type":"thinking_dropped","path":"messages.1.content.0","reason":"prefix_binding_mismatch"}]`
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/v1/messages", nil)
body := `{"id":"msg_1","type":"message","role":"assistant","model":"claude-opus-5-5","content":[{"type":"text","text":"hi"}],` +
`"stop_reason":"end_turn",` + transformations + `,"usage":{"input_tokens":5,"output_tokens":1}}`
require.Nil(t, HandleClaudeResponseData(c, info, &ClaudeResponseInfo{Usage: &dto.Usage{}}, nil, []byte(body)))
assert.Equal(t, body, w.Body.String())
w = httptest.NewRecorder()
c, _ = gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/v1/messages", nil)
event := `{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-opus-5-5","content":[],` +
transformations + `,"usage":{"input_tokens":5,"output_tokens":1}}}`
require.Nil(t, HandleStreamResponseData(c, info, &ClaudeResponseInfo{Usage: &dto.Usage{}}, event))
assert.Contains(t, w.Body.String(), "data: "+event+"\n")
}
+2 -2
View File
@@ -85,7 +85,7 @@ func ClaudeResponsesStreamHandler(c *gin.Context, resp *http.Response, info *rel
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
var claudeResponse dto.ClaudeResponse
if err := common.UnmarshalJsonStr(data, &claudeResponse); err != nil {
logger.LogError(c, "failed to unmarshal Claude stream event: "+err.Error())
logger.LogError(c, common.LogText("failed to unmarshal Claude stream event: %s", err.Error()))
if failResponsesStream(err) {
// A nil streamErr here is intentional: the protocol-level failure
// event was delivered, so only the scanner needs to stop.
@@ -120,7 +120,7 @@ func ClaudeResponsesStreamHandler(c *gin.Context, resp *http.Response, info *rel
info.UpstreamModelName = claudeResponse.Message.Model
}
FormatClaudeResponseInfo(&claudeResponse, nil, claudeInfo)
countClaudeStreamBillableTools(c, info, &claudeResponse)
countClaudeStreamBillableTools(info, &claudeResponse)
hostedEvents, consumed, err := hostedBridge.Convert(&claudeResponse, state)
if err != nil {
if failResponsesStream(err) {
+107 -6
View File
@@ -1,9 +1,12 @@
package claude
import (
"cmp"
"fmt"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
@@ -47,24 +50,23 @@ func TestHandleClaudeResponseDataCountsToolUse(t *testing.T) {
}
func TestCountClaudeStreamBillableToolsSetsWebSearchRequests(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
info := &relaycommon.RelayInfo{OriginModelName: "claude-3-7-sonnet"}
countClaudeStreamBillableTools(c, info, &dto.ClaudeResponse{
countClaudeStreamBillableTools(info, &dto.ClaudeResponse{
Type: "message_delta",
Usage: &dto.ClaudeUsage{
ServerToolUse: &dto.ClaudeServerToolUse{WebSearchRequests: 3},
},
})
assert.Equal(t, 3, c.GetInt("claude_web_search_requests"))
require.Contains(t, info.ResponsesUsageInfo.BuiltInTools, "web_search")
assert.Equal(t, 3, info.ResponsesUsageInfo.BuiltInTools["web_search"].CallCount)
assert.Len(t, info.ResponsesUsageInfo.BuiltInTools, 1, "server tools reported as zero are not recorded")
operation_setting.SetToolPriceForTest("stream_fn", 2.0)
t.Cleanup(func() {
operation_setting.DeleteToolPriceForTest("stream_fn")
})
countClaudeStreamBillableTools(c, info, &dto.ClaudeResponse{
countClaudeStreamBillableTools(info, &dto.ClaudeResponse{
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "tool_use",
@@ -74,3 +76,102 @@ func TestCountClaudeStreamBillableToolsSetsWebSearchRequests(t *testing.T) {
require.Contains(t, info.ResponsesUsageInfo.BuiltInTools, "stream_fn")
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools["stream_fn"].CallCount)
}
// usage.server_tool_use counts bill under their own keys; a channel that
// bills web search under a vendor key (Ali's Anthropic endpoint) re-keys
// web_search_requests.
func TestCountClaudeStreamBillableToolsServerToolKeys(t *testing.T) {
tests := []struct {
name string
billingKey string
fetchKey string
usage dto.ClaudeServerToolUse
want map[string]int
}{
{
name: "vendor web search key",
billingKey: "search_strategy_agent",
usage: dto.ClaudeServerToolUse{WebSearchRequests: 2},
want: map[string]int{"search_strategy_agent": 2},
},
{
name: "vendor web search and web fetch keys",
billingKey: "deepseek_search",
fetchKey: "deepseek_fetch",
usage: dto.ClaudeServerToolUse{WebSearchRequests: 1, WebFetchRequests: 2},
want: map[string]int{"deepseek_search": 1, "deepseek_fetch": 2},
},
{
name: "every server tool",
usage: dto.ClaudeServerToolUse{WebSearchRequests: 1, WebFetchRequests: 2, CodeExecutionRequests: 3, ToolSearchRequests: 4},
want: map[string]int{"web_search": 1, "web_fetch": 2, "code_execution": 3, "tool_search": 4},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info := &relaycommon.RelayInfo{OriginModelName: "claude-3-7-sonnet", WebSearchBillingKey: tt.billingKey, WebFetchBillingKey: tt.fetchKey}
countClaudeStreamBillableTools(info, &dto.ClaudeResponse{
Type: "message_delta",
Usage: &dto.ClaudeUsage{ServerToolUse: &tt.usage},
})
counts := map[string]int{}
for name, tool := range info.ResponsesUsageInfo.BuiltInTools {
counts[name] = tool.CallCount
}
assert.Equal(t, tt.want, counts)
})
}
}
// On a channel whose upstream prices search itself (DeepSeek), each delivered
// web_search_tool_result bills one search under the vendor key, so a stream cut
// before message_delta still pays for the searches that ran;
// usage.server_tool_use replaces that count when reported. Error results and
// channels without a vendor key count nothing from blocks.
func TestClaudeSearchResultBlocksBillVendorSearch(t *testing.T) {
gin.SetMode(gin.TestMode)
const results = `{"type":"web_search_tool_result","tool_use_id":"call_0","content":[{"type":"web_search_result","url":"https://example.com/"}]}`
const empty = `{"type":"web_search_tool_result","tool_use_id":"call_1","content":[]}`
const failed = `{"type":"web_search_tool_result","tool_use_id":"call_2","content":{"type":"web_search_tool_result_error","error_code":"unavailable"}}`
tests := []struct {
name, billingKey, blocks, usage string
want map[string]int
}{
{name: "stream cut before message_delta", billingKey: "deepseek_search", blocks: results + "," + empty + "," + failed,
want: map[string]int{"deepseek_search": 2}},
{name: "reported count replaces blocks", billingKey: "deepseek_search", blocks: results + "," + results,
usage: `{"input_tokens":1,"output_tokens":1,"server_tool_use":{"web_search_requests":3}}`, want: map[string]int{"deepseek_search": 3}},
{name: "no vendor key", blocks: results, want: map[string]int{}},
}
for _, tt := range tests {
for _, stream := range []bool{true, false} {
t.Run(fmt.Sprintf("%s stream=%t", tt.name, stream), func(t *testing.T) {
info := &relaycommon.RelayInfo{OriginModelName: "claude-sonnet-4-5", RelayFormat: types.RelayFormatClaude, WebSearchBillingKey: tt.billingKey}
var blocks []dto.ClaudeMediaMessage
require.NoError(t, common.UnmarshalJsonStr("["+tt.blocks+"]", &blocks))
if stream {
for i := range blocks {
countClaudeStreamBillableTools(info, &dto.ClaudeResponse{Type: "content_block_start", ContentBlock: &blocks[i]})
}
if tt.usage != "" {
var usage dto.ClaudeUsage
require.NoError(t, common.UnmarshalJsonStr(tt.usage, &usage))
countClaudeStreamBillableTools(info, &dto.ClaudeResponse{Type: "message_delta", Usage: &usage})
}
} else {
usage := cmp.Or(tt.usage, `{"input_tokens":1,"output_tokens":1}`)
data := []byte(`{"type":"message","content":[` + tt.blocks + `],"usage":` + usage + `}`)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
require.Nil(t, HandleClaudeResponseData(c, info, &ClaudeResponseInfo{Usage: &dto.Usage{}}, nil, data))
}
counts := map[string]int{}
if info.ResponsesUsageInfo != nil {
for name, tool := range info.ResponsesUsageInfo.BuiltInTools {
counts[name] = tool.CallCount
}
}
assert.Equal(t, tt.want, counts)
})
}
}
}
+5 -4
View File
@@ -8,6 +8,7 @@ import (
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
@@ -53,7 +54,7 @@ func cfStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Res
var response dto.ChatCompletionsStreamResponse
err := json.Unmarshal([]byte(data), &response)
if err != nil {
logger.LogError(c, "error_unmarshalling_stream_response: "+err.Error())
logger.LogError(c, common.LogText("error_unmarshalling_stream_response: %s", err.Error()))
continue
}
for _, choice := range response.Choices {
@@ -68,19 +69,19 @@ func cfStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Res
info.FirstResponseTime = time.Now()
}
if err != nil {
logger.LogError(c, "error_rendering_stream_response: "+err.Error())
logger.LogError(c, common.LogText("error_rendering_stream_response: %s", err.Error()))
}
}
if err := scanner.Err(); err != nil {
logger.LogError(c, "error_scanning_stream_response: "+err.Error())
logger.LogError(c, common.LogText("error_scanning_stream_response: %s", err.Error()))
}
usage := service.ResponseText2Usage(c, responseText, info.UpstreamModelName, info.GetEstimatePromptTokens())
if info.ShouldIncludeUsage {
response := helper.GenerateFinalUsageResponse(id, info.StartTime.Unix(), info.UpstreamModelName, *usage)
err := helper.ObjectData(c, response)
if err != nil {
logger.LogError(c, "error_rendering_final_usage_response: "+err.Error())
logger.LogError(c, common.LogText("error_rendering_final_usage_response: %s", err.Error()))
}
}
helper.Done(c)
+3 -3
View File
@@ -106,7 +106,7 @@ func cohereStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
dataChan <- data
}
if err := scanner.Err(); err != nil {
common.SysLog("error reading stream: " + err.Error())
common.SysLog(common.LogText("error reading stream: %s", err.Error()))
}
stopChan <- true
}()
@@ -123,7 +123,7 @@ func cohereStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
var cohereResp CohereResponse
err := json.Unmarshal([]byte(data), &cohereResp)
if err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
common.SysLog(common.LogText("error unmarshalling stream response: %s", err.Error()))
return true
}
var openaiResp dto.ChatCompletionsStreamResponse
@@ -158,7 +158,7 @@ func cohereStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
}
jsonStr, err := json.Marshal(openaiResp)
if err != nil {
common.SysLog("error marshalling stream response: " + err.Error())
common.SysLog(common.LogText("error marshalling stream response: %s", err.Error()))
return true
}
c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonStr)})
+5 -5
View File
@@ -156,7 +156,7 @@ func handleCozeEvent(c *gin.Context, event string, data string, responseText *st
var chatData CozeChatResponseData
err := json.Unmarshal([]byte(data), &chatData)
if err != nil {
common.SysLog("error_unmarshalling_stream_response: " + err.Error())
common.SysLog(common.LogText("error_unmarshalling_stream_response: %s", err.Error()))
return
}
@@ -173,14 +173,14 @@ func handleCozeEvent(c *gin.Context, event string, data string, responseText *st
var messageData CozeChatV3MessageDetail
err := json.Unmarshal([]byte(data), &messageData)
if err != nil {
common.SysLog("error_unmarshalling_stream_response: " + err.Error())
common.SysLog(common.LogText("error_unmarshalling_stream_response: %s", err.Error()))
return
}
var content string
err = json.Unmarshal(messageData.Content, &content)
if err != nil {
common.SysLog("error_unmarshalling_stream_response: " + err.Error())
common.SysLog(common.LogText("error_unmarshalling_stream_response: %s", err.Error()))
return
}
@@ -205,11 +205,11 @@ func handleCozeEvent(c *gin.Context, event string, data string, responseText *st
var errorData CozeError
err := json.Unmarshal([]byte(data), &errorData)
if err != nil {
common.SysLog("error_unmarshalling_stream_response: " + err.Error())
common.SysLog(common.LogText("error_unmarshalling_stream_response: %s", err.Error()))
return
}
common.SysLog(fmt.Sprintf("stream event error: %v %v", errorData.Code, errorData.Message))
common.SysLog(common.LogText("stream event error: %v %v", errorData.Code, errorData.Message))
}
}
+101 -30
View File
@@ -14,6 +14,7 @@ import (
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/setting/reasoning"
@@ -30,7 +31,13 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
adaptor := claude.Adaptor{}
// A model modifier is rendered into DeepSeek's own thinking and effort
// fields below; Anthropic's model rules (budgets, adaptive thinking,
// max_tokens and sampling changes) must not see it.
state := info.ReasoningConversion
info.ReasoningConversion = nil
convertedRequest, err := adaptor.ConvertClaudeRequest(c, info, req)
info.ReasoningConversion = state
if err != nil {
return nil, err
}
@@ -55,6 +62,14 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
// DeepSeek runs Claude Code and Codex searches and page fetches
// server-side and bills them through its Search and Fetch APIs, whatever
// model name the client sent.
info.WebSearchBillingKey = SearchToolPriceKey
info.WebFetchBillingKey = FetchToolPriceKey
if info.RelayMode == constant.RelayModeResponses {
info.VendorToolUsage = responsesToolUsage
}
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
@@ -71,6 +86,10 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
return fmt.Sprintf("%s/completions", fimBaseUrl), nil
case constant.RelayModeResponses:
return fmt.Sprintf("%s/responses", info.ChannelBaseUrl), nil
case constant.RelayModeToolsSearch:
return fmt.Sprintf("%s/v1/tools/search", info.ChannelBaseUrl), nil
case constant.RelayModeToolsFetch:
return fmt.Sprintf("%s/v1/tools/fetch", info.ChannelBaseUrl), nil
default:
return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil
}
@@ -99,10 +118,7 @@ func applyDeepSeekV4OpenAIThinkingSuffix(info *relaycommon.RelayInfo, request *d
if info != nil && info.ChannelMeta != nil && info.UpstreamModelName != "" {
modelName = info.UpstreamModelName
}
if model_setting.ShouldPreserveThinkingSuffix(modelName) || info != nil && model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
return nil
}
baseModel, thinkingType, effort, ok := reasoning.ParseDeepSeekV4ThinkingSuffix(modelName)
baseModel, thinkingType, effort, ok := deepSeekThinking(info, modelName)
if !ok {
return nil
}
@@ -115,11 +131,15 @@ func applyDeepSeekV4OpenAIThinkingSuffix(info *relaycommon.RelayInfo, request *d
request.Model = baseModel
request.THINKING = thinking
request.ReasoningEffort = effort
if info.ReasoningState() != nil {
// DeepSeek Chat has no reasoning object; a budget modifier adds one.
request.Reasoning = nil
}
if info != nil {
if info.ChannelMeta != nil {
info.UpstreamModelName = baseModel
}
info.SetReasoningEffort(effort)
info.SetReasoningEffort(deepSeekRunEffort(thinkingType, effort))
}
return nil
}
@@ -129,31 +149,36 @@ func applyDeepSeekV4ClaudeThinkingSuffix(info *relaycommon.RelayInfo, request *d
if info != nil && info.ChannelMeta != nil && info.UpstreamModelName != "" {
modelName = info.UpstreamModelName
}
if model_setting.ShouldPreserveThinkingSuffix(modelName) || info != nil && model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
return nil
}
baseModel, thinkingType, effort, ok := reasoning.ParseDeepSeekV4ThinkingSuffix(modelName)
baseModel, thinkingType, effort, ok := deepSeekThinking(info, modelName)
if !ok {
return nil
}
request.Model = baseModel
request.Thinking = &dto.Thinking{Type: thinkingType}
if effort == "" {
request.OutputConfig = nil
} else {
outputConfig, err := common.Marshal(map[string]string{
"effort": effort,
})
// Only effort is set or removed; other output_config members stay.
outputConfig := make(map[string]any)
if len(request.OutputConfig) > 0 && common.GetJsonType(request.OutputConfig) == "object" {
if err := common.Unmarshal(request.OutputConfig, &outputConfig); err != nil {
return fmt.Errorf("invalid output_config: %w", err)
}
}
delete(outputConfig, "effort")
if effort != "" {
outputConfig["effort"] = effort
}
request.OutputConfig = nil
if len(outputConfig) > 0 {
encoded, err := common.Marshal(outputConfig)
if err != nil {
return fmt.Errorf("error marshalling output_config: %w", err)
}
request.OutputConfig = outputConfig
request.OutputConfig = encoded
}
if info != nil {
if info.ChannelMeta != nil {
info.UpstreamModelName = baseModel
}
info.SetReasoningEffort(effort)
info.SetReasoningEffort(deepSeekRunEffort(thinkingType, effort))
}
return nil
}
@@ -177,25 +202,71 @@ func applyDeepSeekV4ResponsesThinkingSuffix(info *relaycommon.RelayInfo, request
if info != nil && info.ChannelMeta != nil && info.UpstreamModelName != "" {
modelName = info.UpstreamModelName
}
if model_setting.ShouldPreserveThinkingSuffix(modelName) || info != nil && model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
baseModel, thinkingType, effort, ok := deepSeekThinking(info, modelName)
if !ok {
if info != nil && request.Reasoning != nil {
info.SetReasoningEffort(request.Reasoning.Effort)
}
return
}
baseModel, thinkingType, effort, ok := reasoning.ParseDeepSeekV4ThinkingSuffix(modelName)
if ok {
if thinkingType == "disabled" {
effort = "none"
}
request.Model = baseModel
if request.Reasoning == nil {
request.Reasoning = &dto.Reasoning{}
}
if thinkingType == "disabled" {
effort = "none"
}
request.Model = baseModel
if request.Reasoning == nil && effort != "" {
request.Reasoning = &dto.Reasoning{}
}
if request.Reasoning != nil {
request.Reasoning.Effort = effort
if info != nil && info.ChannelMeta != nil {
}
if info != nil {
if info.ChannelMeta != nil {
info.UpstreamModelName = baseModel
}
info.SetReasoningEffort(deepSeekRunEffort(thinkingType, effort))
}
if info != nil && request.Reasoning != nil {
info.SetReasoningEffort(request.Reasoning.Effort)
}
// deepSeekThinking returns the model name to send with DeepSeek's thinking
// switch and effort. An explicit @ modifier (the reasoning state) wins over a
// -none/-low/-max tail, which is still stripped from the name. A budget maps
// to an effort as on other effort-only targets; thinking on or adaptive leaves
// the effort to DeepSeek's default.
func deepSeekThinking(info *relaycommon.RelayInfo, modelName string) (baseModel string, thinkingType string, effort string, ok bool) {
baseModel = modelName
if !model_setting.ShouldPreserveThinkingSuffix(modelName) && (info == nil || !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName)) {
baseModel, thinkingType, effort, ok = reasoning.ParseDeepSeekV4ThinkingSuffix(modelName)
}
state := info.ReasoningState()
if state == nil {
return baseModel, thinkingType, effort, ok
}
intent := kitreasoning.IntentFromState(state)
switch runs := kitreasoning.EffectiveEffort(intent); {
case runs == "":
return baseModel, thinkingType, effort, ok
case runs == kitreasoning.EffortNone:
return baseModel, "disabled", "", true
case intent.Effort == "" && intent.BudgetTokens == nil:
return baseModel, "enabled", "", true
default:
return baseModel, "enabled", string(runs), true
}
}
// deepSeekRunEffort is the effort DeepSeek runs: none with thinking off, its
// default high when no effort is sent, and the levels it does not implement
// mapped as DeepSeek maps them (minimal to low, medium and xhigh to high).
func deepSeekRunEffort(thinkingType string, effort string) string {
switch {
case thinkingType == "disabled":
return string(kitreasoning.EffortNone)
case effort == "" || effort == "medium" || effort == "xhigh":
return "high"
case effort == "minimal":
return "low"
default:
return effort
}
}
+270
View File
@@ -0,0 +1,270 @@
package deepseek
import (
"fmt"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// The -none/-low/-max tails of deepseek-flash and deepseek-v4-* reach DeepSeek
// as its own thinking switch and effort in each upstream format, with the base
// name; a bare name keeps DeepSeek's default, and a blacklisted name is sent
// verbatim.
func TestThinkingSuffixUpstreamFields(t *testing.T) {
settings := model_setting.GetGlobalSettings()
original := append([]string(nil), settings.ThinkingModelBlacklist...)
t.Cleanup(func() { settings.ThinkingModelBlacklist = original })
settings.ThinkingModelBlacklist = append(original, "deepseek-v4-pro-max")
tests := []struct {
model, upstream, thinking, effort string
}{
{model: "deepseek-flash", upstream: "deepseek-flash"},
{model: "deepseek-flash-none", upstream: "deepseek-flash", thinking: "disabled"},
{model: "deepseek-flash-low", upstream: "deepseek-flash", thinking: "enabled", effort: "low"},
{model: "deepseek-flash-max", upstream: "deepseek-flash", thinking: "enabled", effort: "max"},
{model: "deepseek-v4-pro-none", upstream: "deepseek-v4-pro", thinking: "disabled"},
{model: "deepseek-v4-pro-low", upstream: "deepseek-v4-pro", thinking: "enabled", effort: "low"},
{model: "deepseek-v4-pro-max", upstream: "deepseek-v4-pro-max"},
}
newInfo := func(model string) *relaycommon.RelayInfo {
return &relaycommon.RelayInfo{OriginModelName: model, ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: model}}
}
for _, tt := range tests {
t.Run(tt.model, func(t *testing.T) {
chat := &dto.GeneralOpenAIRequest{Model: tt.model}
converted, err := (&Adaptor{}).ConvertOpenAIRequest(nil, newInfo(tt.model), chat)
require.NoError(t, err)
chat = converted.(*dto.GeneralOpenAIRequest)
assert.Equal(t, tt.upstream, chat.Model)
assert.Equal(t, tt.effort, chat.ReasoningEffort)
claude := &dto.ClaudeRequest{Model: tt.model}
require.NoError(t, applyDeepSeekV4ClaudeThinkingSuffix(newInfo(tt.model), claude))
assert.Equal(t, tt.upstream, claude.Model)
converted, err = (&Adaptor{}).ConvertOpenAIResponsesRequest(nil, newInfo(tt.model), dto.OpenAIResponsesRequest{Model: tt.model})
require.NoError(t, err)
responses := converted.(dto.OpenAIResponsesRequest)
assert.Equal(t, tt.upstream, responses.Model)
if tt.thinking == "" {
assert.Empty(t, chat.THINKING)
assert.Nil(t, claude.Thinking)
assert.Nil(t, claude.OutputConfig)
assert.Nil(t, responses.Reasoning)
return
}
assert.JSONEq(t, `{"type":"`+tt.thinking+`"}`, string(chat.THINKING))
require.NotNil(t, claude.Thinking)
assert.Equal(t, tt.thinking, claude.Thinking.Type)
require.NotNil(t, responses.Reasoning)
if tt.thinking == "disabled" {
assert.Nil(t, claude.OutputConfig)
assert.Equal(t, "none", responses.Reasoning.Effort)
return
}
assert.JSONEq(t, `{"effort":"`+tt.effort+`"}`, string(claude.OutputConfig))
assert.Equal(t, tt.effort, responses.Reasoning.Effort)
})
}
}
// DeepSeek runs Claude Code and Codex searches server-side and bills them as
// Search and Fetch API calls, whatever model name the client sent. On the
// Responses API the completed web_search_call items of the whole response
// replace the items counted while streaming: open_page is a Fetch call, a
// failed call is free, and an empty output keeps the streamed count.
func TestServerSideSearchBillingKeys(t *testing.T) {
for _, tc := range []struct {
format types.RelayFormat
mode int
}{
{types.RelayFormatClaude, constant.RelayModeUnknown},
{types.RelayFormatOpenAI, constant.RelayModeChatCompletions},
{types.RelayFormatOpenAIResponses, constant.RelayModeResponses},
} {
info := &relaycommon.RelayInfo{RelayFormat: tc.format, RelayMode: tc.mode, ChannelMeta: &relaycommon.ChannelMeta{}}
(&Adaptor{}).Init(info)
assert.Equal(t, SearchToolPriceKey, info.WebSearchBillingKey, tc.format)
assert.Equal(t, FetchToolPriceKey, info.WebFetchBillingKey, tc.format)
assert.Equal(t, tc.mode == constant.RelayModeResponses, info.VendorToolUsage != nil, tc.format)
}
const output = `[
{"type":"web_search_call","status":"completed","action":{"type":"search","queries":["deepseek","ws_call_id=call_01"]}},
{"type":"web_search_call","status":"completed","action":{"type":"open_page","url":"https://example.com/"}},
{"type":"web_search_call","status":"failed","action":{"type":"open_page","url":"https://api-docs.deepseek.com/"}},
{"type":"message","status":"completed"}]`
for _, raw := range []string{
`{"status":"completed","output":` + output + `}`,
`{"type":"response.completed","response":{"status":"completed","output":` + output + `}}`,
} {
info := &relaycommon.RelayInfo{
OriginModelName: "gpt-5.1-codex",
RelayFormat: types.RelayFormatOpenAIResponses,
RelayMode: constant.RelayModeResponses,
ChannelMeta: &relaycommon.ChannelMeta{},
}
(&Adaptor{}).Init(info)
for range 3 {
info.CountBillableToolCall(dto.BuildInCallWebSearchCall, "")
}
assert.Equal(t, map[string]int{SearchToolPriceKey: 3}, toolCounts(info), "streamed items bill under the Search key")
info.ApplyVendorToolUsage([]byte(`{"type":"response.failed","response":{"status":"failed","output":[]}}`))
assert.Equal(t, map[string]int{SearchToolPriceKey: 3}, toolCounts(info), "an empty output keeps the streamed count")
info.ApplyVendorToolUsage([]byte(raw))
assert.Equal(t, map[string]int{SearchToolPriceKey: 1, FetchToolPriceKey: 1}, toolCounts(info), raw)
}
}
func toolCounts(info *relaycommon.RelayInfo) map[string]int {
counts := map[string]int{}
for name, tool := range info.ResponsesUsageInfo.BuiltInTools {
counts[name] = tool.CallCount
}
return counts
}
// Explicit @ modifiers reach DeepSeek as its own thinking switch and effort in
// every format and override the client's reasoning fields: no budget_tokens,
// adaptive thinking, max_tokens raise or sampling change. An @ effort wins
// over a -none/-low/-max tail, a budget maps to an effort, and a request
// without a modifier passes through unchanged. The usage log records the
// effort DeepSeek runs at.
func TestModelModifiersUpstreamFields(t *testing.T) {
type client struct{ chat, claude, responses string }
tests := []struct {
name, model string
client client
thinking, effort string // thinking "" = untouched
logEffort string
}{
{name: "thinking on", model: "deepseek-flash@thinking:on", thinking: "enabled", logEffort: "high"},
{name: "thinking off", model: "deepseek-flash@thinking:off", thinking: "disabled", logEffort: "none"},
{name: "thinking zero budget", model: "deepseek-flash@thinking:0", thinking: "disabled", logEffort: "none"},
{name: "effort none", model: "deepseek-flash@effort:none", thinking: "disabled", logEffort: "none"},
{name: "effort low", model: "deepseek-flash@effort:low", thinking: "enabled", effort: "low", logEffort: "low"},
{name: "effort medium", model: "deepseek-flash@effort:medium", thinking: "enabled", effort: "medium", logEffort: "high"},
{name: "effort xhigh", model: "deepseek-flash@effort:xhigh", thinking: "enabled", effort: "xhigh", logEffort: "high"},
{name: "effort max on pro", model: "deepseek-v4-pro@effort:max", thinking: "enabled", effort: "max", logEffort: "max"},
{name: "budget maps to an effort", model: "deepseek-flash@thinking:8192", thinking: "enabled", effort: "medium", logEffort: "high"},
{name: "adaptive", model: "deepseek-flash@thinking:adaptive", thinking: "enabled", logEffort: "high"},
{name: "temperature with effort", model: "deepseek-flash@temperature:0.3@effort:low", thinking: "enabled", effort: "low", logEffort: "low"},
{name: "effort overrides client thinking off", model: "deepseek-flash@effort:max", thinking: "enabled", effort: "max", logEffort: "max",
client: client{chat: `"thinking":{"type":"disabled"}`, claude: `"thinking":{"type":"disabled"}`, responses: `"reasoning":{"effort":"none"}`}},
{name: "thinking off overrides client thinking", model: "deepseek-flash@thinking:off", thinking: "disabled", logEffort: "none",
client: client{chat: `"thinking":{"type":"enabled"},"reasoning_effort":"high"`,
claude: `"thinking":{"type":"enabled","budget_tokens":2048},"output_config":{"effort":"high","format":{"type":"json_schema"}}`,
responses: `"reasoning":{"effort":"high"}`}},
{name: "budget overrides client effort none", model: "deepseek-flash@thinking:8192", thinking: "enabled", effort: "medium", logEffort: "high",
client: client{responses: `"reasoning":{"effort":"none"}`}},
{name: "@ effort wins over a tail", model: "deepseek-flash-max@effort:low", thinking: "enabled", effort: "low", logEffort: "low"},
{name: "no modifier", model: "deepseek-flash",
client: client{chat: `"thinking":{"type":"disabled"},"reasoning_effort":"low"`, claude: `"thinking":{"type":"adaptive"},"output_config":{"effort":"low"}`, responses: `"reasoning":{"effort":"low"}`}},
}
extra := func(members string) string {
if members == "" {
return ""
}
return "," + members
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
bodies := map[types.RelayFormat]string{
types.RelayFormatOpenAI: fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"hi"}]%s}`, tt.model, extra(tt.client.chat)),
types.RelayFormatClaude: fmt.Sprintf(`{"model":%q,"max_tokens":64,"temperature":0.7,"messages":[{"role":"user","content":"hi"}]%s}`, tt.model, extra(tt.client.claude)),
types.RelayFormatOpenAIResponses: fmt.Sprintf(`{"model":%q,"input":"hi"%s}`, tt.model, extra(tt.client.responses)),
}
for format, body := range bodies {
info := &relaycommon.RelayInfo{
OriginModelName: tt.model,
RelayFormat: format,
ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: tt.model},
}
var sent []byte
switch format {
case types.RelayFormatOpenAI:
original, outbound := &dto.GeneralOpenAIRequest{}, &dto.GeneralOpenAIRequest{}
require.NoError(t, common.UnmarshalJsonStr(body, original))
require.NoError(t, common.UnmarshalJsonStr(body, outbound))
info.Request = original
require.NoError(t, helper.ApplyReasoningModelSuffix(nil, info, outbound))
converted, err := (&Adaptor{}).ConvertOpenAIRequest(nil, info, outbound)
require.NoError(t, err)
sent, err = common.Marshal(converted)
require.NoError(t, err)
case types.RelayFormatClaude:
original, outbound := &dto.ClaudeRequest{}, &dto.ClaudeRequest{}
require.NoError(t, common.UnmarshalJsonStr(body, original))
require.NoError(t, common.UnmarshalJsonStr(body, outbound))
info.Request = original
require.NoError(t, helper.ApplyReasoningModelSuffix(nil, info, outbound))
converted, err := (&Adaptor{}).ConvertClaudeRequest(nil, info, outbound)
require.NoError(t, err)
sent, err = common.Marshal(converted)
require.NoError(t, err)
default:
original, outbound := &dto.OpenAIResponsesRequest{}, &dto.OpenAIResponsesRequest{}
require.NoError(t, common.UnmarshalJsonStr(body, original))
require.NoError(t, common.UnmarshalJsonStr(body, outbound))
info.Request = original
require.NoError(t, helper.ApplyReasoningModelSuffix(nil, info, outbound))
converted, err := (&Adaptor{}).ConvertOpenAIResponsesRequest(nil, info, *outbound)
require.NoError(t, err)
sent, err = common.Marshal(converted)
require.NoError(t, err)
}
wire := gjson.ParseBytes(sent)
wantModel := "deepseek-flash"
if strings.HasPrefix(tt.model, "deepseek-v4-pro") {
wantModel = "deepseek-v4-pro"
}
assert.Equal(t, wantModel, wire.Get("model").String(), format)
if tt.thinking == "" {
// No modifier: the client's reasoning fields go out as sent.
for _, member := range []string{"thinking", "reasoning_effort", "output_config", "reasoning"} {
assert.Equal(t, gjson.Get(body, member).Raw, wire.Get(member).Raw, "%s %s", format, member)
}
continue
}
switch format {
case types.RelayFormatOpenAI:
assert.JSONEq(t, `{"type":"`+tt.thinking+`"}`, wire.Get("thinking").Raw, format)
assert.Equal(t, tt.effort, wire.Get("reasoning_effort").String(), format)
assert.False(t, wire.Get("reasoning").Exists(), format)
case types.RelayFormatClaude:
assert.JSONEq(t, `{"type":"`+tt.thinking+`"}`, wire.Get("thinking").Raw, format)
assert.Equal(t, tt.effort, wire.Get("output_config.effort").String(), format)
assert.Equal(t, gjson.Get(body, "output_config.format").Raw, wire.Get("output_config.format").Raw, format)
assert.Equal(t, int64(64), wire.Get("max_tokens").Int(), format)
wantTemperature := 0.7
if strings.Contains(tt.model, "@temperature:0.3") {
wantTemperature = 0.3
}
assert.Equal(t, wantTemperature, wire.Get("temperature").Float(), format)
default:
wantEffort := tt.effort
if tt.thinking == "disabled" {
wantEffort = "none"
}
assert.Equal(t, wantEffort, wire.Get("reasoning.effort").String(), format)
}
assert.Equal(t, tt.logEffort, info.ReasoningEffort, format)
}
})
}
}
+13 -3
View File
@@ -1,9 +1,19 @@
package deepseek
import "github.com/QuantumNous/new-api/constant"
var ModelList = []string{
"deepseek-chat", "deepseek-reasoner",
"deepseek-v4-flash", "deepseek-v4-flash-none", "deepseek-v4-flash-max",
"deepseek-v4-pro", "deepseek-v4-pro-none", "deepseek-v4-pro-max",
"deepseek-flash", "deepseek-flash-none", "deepseek-flash-low", "deepseek-flash-max",
"deepseek-v4-pro", "deepseek-v4-pro-none", "deepseek-v4-pro-low", "deepseek-v4-pro-max",
constant.DeepSeekSearchModel, constant.DeepSeekFetchModel,
}
var ChannelName = "deepseek"
// Tool-price keys of the DeepSeek Search and Fetch APIs. They price the
// server-side searches and page fetches DeepSeek runs on its Anthropic and
// Responses endpoints.
const (
SearchToolPriceKey = "deepseek_search"
FetchToolPriceKey = "deepseek_fetch"
)
+37
View File
@@ -0,0 +1,37 @@
package deepseek
import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/tidwall/gjson"
)
// responsesToolUsage bills the Search and Fetch API calls DeepSeek runs for
// the Responses web_search tool from the web_search_call items of the whole
// response (output on non-stream bodies, response.output on terminal stream
// frames); its usage reports tokens only. An open_page action is a Fetch call,
// any other action a Search call, and a failed call is free. The counts
// replace the web_search_call items counted under the Search key while the
// stream was running. An empty output proves nothing, so those counts stay.
func responsesToolUsage(raw []byte) map[string]int {
output := gjson.GetBytes(raw, "output")
if !output.Exists() {
output = gjson.GetBytes(raw, "response.output")
}
items := output.Array()
if !output.IsArray() || len(items) == 0 {
return nil
}
counts := map[string]int{SearchToolPriceKey: 0, FetchToolPriceKey: 0}
for _, item := range items {
if item.Get("type").String() != dto.BuildInCallWebSearchCall || item.Get("status").String() != "completed" {
continue
}
if item.Get("action.type").String() == "open_page" {
counts[FetchToolPriceKey]++
} else {
counts[SearchToolPriceKey]++
}
}
return counts
}
+11 -11
View File
@@ -38,14 +38,14 @@ func uploadDifyFile(c *gin.Context, info *relaycommon.RelayInfo, user string, me
// Decode base64 string
decodedData, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
common.SysLog("failed to decode base64: " + err.Error())
common.SysLog(common.LogText("failed to decode base64: %s", err.Error()))
return nil
}
// Create temporary file
tempFile, err := os.CreateTemp("", "dify-upload-*")
if err != nil {
common.SysLog("failed to create temp file: " + err.Error())
common.SysLog(common.LogText("failed to create temp file: %s", err.Error()))
return nil
}
defer tempFile.Close()
@@ -53,7 +53,7 @@ func uploadDifyFile(c *gin.Context, info *relaycommon.RelayInfo, user string, me
// Write decoded data to temp file
if _, err := tempFile.Write(decodedData); err != nil {
common.SysLog("failed to write to temp file: " + err.Error())
common.SysLog(common.LogText("failed to write to temp file: %s", err.Error()))
return nil
}
@@ -63,7 +63,7 @@ func uploadDifyFile(c *gin.Context, info *relaycommon.RelayInfo, user string, me
// Add user field
if err := writer.WriteField("user", user); err != nil {
common.SysLog("failed to add user field: " + err.Error())
common.SysLog(common.LogText("failed to add user field: %s", err.Error()))
return nil
}
@@ -76,13 +76,13 @@ func uploadDifyFile(c *gin.Context, info *relaycommon.RelayInfo, user string, me
// Create form file
part, err := writer.CreateFormFile("file", fmt.Sprintf("image.%s", strings.TrimPrefix(mimeType, "image/")))
if err != nil {
common.SysLog("failed to create form file: " + err.Error())
common.SysLog(common.LogText("failed to create form file: %s", err.Error()))
return nil
}
// Copy file content to form
if _, err = io.Copy(part, bytes.NewReader(decodedData)); err != nil {
common.SysLog("failed to copy file content: " + err.Error())
common.SysLog(common.LogText("failed to copy file content: %s", err.Error()))
return nil
}
writer.Close()
@@ -90,7 +90,7 @@ func uploadDifyFile(c *gin.Context, info *relaycommon.RelayInfo, user string, me
// Create HTTP request
req, err := http.NewRequest("POST", uploadUrl, body)
if err != nil {
common.SysLog("failed to create request: " + err.Error())
common.SysLog(common.LogText("failed to create request: %s", err.Error()))
return nil
}
@@ -101,7 +101,7 @@ func uploadDifyFile(c *gin.Context, info *relaycommon.RelayInfo, user string, me
client := service.GetHttpClient()
resp, err := client.Do(req)
if err != nil {
common.SysLog("failed to send request: " + err.Error())
common.SysLog(common.LogText("failed to send request: %s", err.Error()))
return nil
}
defer resp.Body.Close()
@@ -111,7 +111,7 @@ func uploadDifyFile(c *gin.Context, info *relaycommon.RelayInfo, user string, me
Id string `json:"id"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
common.SysLog("failed to decode response: " + err.Error())
common.SysLog(common.LogText("failed to decode response: %s", err.Error()))
return nil
}
@@ -137,7 +137,7 @@ func requestOpenAI2Dify(c *gin.Context, info *relaycommon.RelayInfo, request dto
var stringUser string
err := json.Unmarshal(user, &stringUser)
if err != nil {
common.SysLog("failed to unmarshal user: " + err.Error())
common.SysLog(common.LogText("failed to unmarshal user: %s", err.Error()))
stringUser = helper.GetResponseID(c)
}
difyReq.User = stringUser
@@ -231,7 +231,7 @@ func difyStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
var difyResponse DifyChunkChatCompletionResponse
if err := json.Unmarshal([]byte(data), &difyResponse); err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
common.SysLog(common.LogText("error unmarshalling stream response: %s", err.Error()))
sr.Error(err)
return
}
+26 -9
View File
@@ -103,7 +103,7 @@ func TestConvertOpenAIResponsesRequestToGeminiFunctionCallConversation(t *testin
assert.Equal(t, map[string]any{"ok": true}, got.Contents[1].Parts[0].FunctionResponse.Response)
}
func TestConvertOpenAIResponsesRequestToGeminiSkipsCustomToolCalls(t *testing.T) {
func TestConvertOpenAIResponsesRequestToGeminiKeepsCustomToolCalls(t *testing.T) {
got := mustConvertResponsesToGemini(t, dto.OpenAIResponsesRequest{
Model: "gemini-test",
Input: mustGeminiRawMessage(t, []map[string]any{
@@ -140,17 +140,34 @@ func TestConvertOpenAIResponsesRequestToGeminiSkipsCustomToolCalls(t *testing.T)
}),
})
assert.Empty(t, got.GetTools())
require.Len(t, got.Contents, 2)
// The cleaned schema has no additionalProperties, and the unknown tool is dropped.
assert.JSONEq(t, `[{"functionDeclarations":[{
"name":"apply_patch",
"description":"This tool takes freeform text. Put the complete raw text in the \"input\" argument.",
"parameters":{"type":"OBJECT","properties":{"input":{"type":"STRING","description":"Raw input for the tool."}},"required":["input"]}
}]}]`, string(got.Tools))
require.Len(t, got.Contents, 3)
assert.Equal(t, "model", got.Contents[0].Role)
require.Len(t, got.Contents[0].Parts, 1)
assert.Equal(t, "before custom", got.Contents[0].Parts[0].Text)
assert.Nil(t, got.Contents[0].Parts[0].FunctionCall)
require.Len(t, got.Contents[0].Parts, 2)
require.NotNil(t, got.Contents[0].Parts[0].FunctionCall)
assert.Equal(t, "apply_patch", got.Contents[0].Parts[0].FunctionCall.FunctionName)
assert.Equal(t, map[string]any{"input": "patch body"}, got.Contents[0].Parts[0].FunctionCall.Arguments)
assert.Equal(t, "before custom", got.Contents[0].Parts[1].Text)
assert.Equal(t, "user", got.Contents[1].Role)
require.Len(t, got.Contents[1].Parts, 1)
assert.Equal(t, "next turn", got.Contents[1].Parts[0].Text)
assert.Nil(t, got.Contents[1].Parts[0].FunctionResponse)
require.Len(t, got.Contents[1].Parts, 2)
for i, output := range []string{"ok", "legacy custom output"} {
response := got.Contents[1].Parts[i].FunctionResponse
require.NotNil(t, response)
assert.Equal(t, "apply_patch", response.Name)
assert.Equal(t, map[string]any{"content": output}, response.Response)
assert.JSONEq(t, `"call_custom"`, string(response.ID))
}
assert.Equal(t, "user", got.Contents[2].Role)
require.Len(t, got.Contents[2].Parts, 1)
assert.Equal(t, "next turn", got.Contents[2].Parts[0].Text)
}
func mustConvertResponsesToGemini(t *testing.T, req dto.OpenAIResponsesRequest) *dto.GeminiChatRequest {
+4 -1
View File
@@ -36,6 +36,9 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
info.ObserveResponseModel(gjson.GetBytes(responseBody, "modelVersion").Str)
var grounding geminiGroundingCounter
grounding.observe(&geminiResponse)
grounding.commit(info)
countGeminiBillableFunctionCalls(info, &geminiResponse)
if len(geminiResponse.Candidates) == 0 && geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
@@ -88,7 +91,7 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayIn
return geminiStreamHandler(c, info, resp, func(data string, geminiResponse *dto.GeminiChatResponse) bool {
err := helper.StringData(c, data)
if err != nil {
logger.LogError(c, "failed to write stream data: "+err.Error())
logger.LogError(c, common.LogText("failed to write stream data: %s", err.Error()))
return false
}
info.SendResponseCount++
+63 -24
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"slices"
"strings"
"time"
@@ -75,23 +76,58 @@ func geminiResponseUsageText(response *dto.GeminiChatResponse) string {
if part.Text != "" {
text.WriteString(part.Text)
}
// Function calls are most of an agent turn's output.
if part.FunctionCall != nil {
text.WriteString(part.FunctionCall.FunctionName)
args, _ := common.Marshal(part.FunctionCall.Arguments)
text.Write(args)
}
}
}
return text.String()
}
func markGeminiGoogleSearchCall(c *gin.Context, response *dto.GeminiChatResponse) {
if c == nil || response == nil {
return
}
// geminiGroundingCounter accumulates Google Search grounding across one
// upstream response (every stream frame and candidate). Gemini 3 bills each
// unique non-empty search query ("we ignore the empty web search queries when
// counting unique queries", ai.google.dev/gemini-api/docs/google-search);
// Gemini 2.5 and older bill once per grounded prompt, counted here when the
// response carries any search query. Both quantities are recorded and the
// tool price table decides which one costs money for a model. Image-search
// queries count like web-search queries; Google documents no separate rule.
// Google documents no response field holding the billed count, so the unique
// query set is the best available evidence, not a guaranteed match.
type geminiGroundingCounter struct {
queries map[string]struct{}
}
func (g *geminiGroundingCounter) observe(response *dto.GeminiChatResponse) {
for _, candidate := range response.Candidates {
if candidate.GroundingMetadata != nil && len(candidate.GroundingMetadata.WebSearchQueries) > 0 {
c.Set("gemini_google_search_call", true)
return
metadata := candidate.GroundingMetadata
if metadata == nil {
continue
}
for _, query := range slices.Concat(metadata.WebSearchQueries, metadata.ImageSearchQueries) {
query = strings.TrimSpace(query)
if query == "" {
continue
}
if g.queries == nil {
g.queries = make(map[string]struct{})
}
g.queries[query] = struct{}{}
}
}
}
func (g *geminiGroundingCounter) commit(info *relaycommon.RelayInfo) {
if len(g.queries) == 0 {
return
}
info.SetBillableToolCount(dto.BuildInToolGoogleSearch, len(g.queries))
info.SetBillableToolCount(relaycommon.GoogleSearchGroundedPromptTool, 1)
}
func countGeminiBillableFunctionCalls(info *relaycommon.RelayInfo, response *dto.GeminiChatResponse) {
if info == nil || response == nil {
return
@@ -171,6 +207,7 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
var hasBillableUsageMetadata bool
var streamErr error
var accumulatedUsageMetadata *dto.GeminiUsageMetadata
var grounding geminiGroundingCounter
responseText := strings.Builder{}
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
@@ -197,7 +234,9 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
info.StreamStatus.MarkCompleted()
}
markGeminiGoogleSearchCall(c, &geminiResponse)
// Commit on every frame so a client abort keeps the queries seen so far.
grounding.observe(&geminiResponse)
grounding.commit(info)
countGeminiBillableFunctionCalls(info, &geminiResponse)
// 统计图片数量
@@ -206,11 +245,9 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
if part.InlineData != nil && part.InlineData.MimeType != "" {
imageCount++
}
if part.Text != "" {
responseText.WriteString(part.Text)
}
}
}
responseText.WriteString(geminiResponseUsageText(&geminiResponse))
// 更新使用量统计
if metadata := geminiResponse.GetUsageMetadata(); dto.HasGeminiUsageMetadataTokens(metadata) {
@@ -251,7 +288,7 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
return usage, types.NewOpenAIError(streamErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if info.StreamStatus != nil && !info.StreamStatus.IsNormalEnd() {
logger.LogWarn(c, fmt.Sprintf("Gemini stream ended unexpectedly: %s", info.StreamStatus.Summary()))
logger.LogWarn(c, common.LogText("Gemini stream ended unexpectedly: %s", info.StreamStatus.Summary()))
}
return usage, nil
@@ -367,7 +404,7 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
}
handleErr := handleFinalStream(c, info, response)
if handleErr != nil {
common.SysLog("send final response failed: " + handleErr.Error())
common.SysLog(common.LogText("send final response failed: %s", handleErr.Error()))
}
return usage, nil
}
@@ -385,7 +422,9 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
info.ObserveResponseModel(gjson.GetBytes(responseBody, "modelVersion").Str)
markGeminiGoogleSearchCall(c, &geminiResponse)
var grounding geminiGroundingCounter
grounding.observe(&geminiResponse)
grounding.commit(info)
countGeminiBillableFunctionCalls(info, &geminiResponse)
if len(geminiResponse.Candidates) == 0 {
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
@@ -414,11 +453,11 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
case types.RelayFormatClaude:
c.JSON(newAPIError.StatusCode, gin.H{
"type": "error",
"error": newAPIError.ToClaudeError(),
"error": newAPIError.ToClaudeError(common.UpstreamAddresses(c)...),
})
default:
c.JSON(newAPIError.StatusCode, gin.H{
"error": newAPIError.ToOpenAIError(),
"error": newAPIError.ToOpenAIError(common.UpstreamAddresses(c)...),
})
}
return &usage, nil
@@ -558,10 +597,10 @@ type GeminiModelsResponse struct {
NextPageToken string `json:"nextPageToken"`
}
func FetchGeminiModels(baseURL, apiKey, proxyURL string) ([]string, error) {
client, err := service.GetHttpClientWithProxy(proxyURL)
func FetchGeminiModels(baseURL, apiKey string, setting dto.ChannelSettings) ([]string, error) {
client, err := service.GetHttpClientWithProxySettings(setting.Proxy, setting)
if err != nil {
return nil, fmt.Errorf("创建HTTP客户端失败: %v", err)
return nil, fmt.Errorf("failed to create HTTP client: %v", err)
}
allModels := make([]string, 0)
@@ -578,7 +617,7 @@ func FetchGeminiModels(baseURL, apiKey, proxyURL string) ([]string, error) {
request, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
cancel()
return nil, fmt.Errorf("创建请求失败: %v", err)
return nil, fmt.Errorf("failed to create request: %v", err)
}
request.Header.Set("x-goog-api-key", apiKey)
@@ -586,26 +625,26 @@ func FetchGeminiModels(baseURL, apiKey, proxyURL string) ([]string, error) {
response, err := client.Do(request)
if err != nil {
cancel()
return nil, fmt.Errorf("请求失败: %v", err)
return nil, fmt.Errorf("request failed: %v", err)
}
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(response.Body)
response.Body.Close()
cancel()
return nil, fmt.Errorf("服务器返回错误 %d: %s", response.StatusCode, string(body))
return nil, fmt.Errorf("server returned error %d: %s", response.StatusCode, string(body))
}
body, err := io.ReadAll(response.Body)
response.Body.Close()
cancel()
if err != nil {
return nil, fmt.Errorf("读取响应失败: %v", err)
return nil, fmt.Errorf("failed to read response: %v", err)
}
var modelsResponse GeminiModelsResponse
if err = common.Unmarshal(body, &modelsResponse); err != nil {
return nil, fmt.Errorf("解析响应失败: %v", err)
return nil, fmt.Errorf("failed to parse response: %v", err)
}
for _, model := range modelsResponse.Models {
+160 -45
View File
@@ -13,7 +13,9 @@ import (
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/tokenkit"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -494,8 +496,6 @@ func TestGeminiChatHandlerMissingUsageMetadataBuildsEstimatedBillingUsage(t *tes
func TestGeminiStreamHandlerPromptOnlyUsageMetadataEstimatesCompletionTokens(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
oldStreamingTimeout := constant.StreamingTimeout
constant.StreamingTimeout = 300
@@ -503,53 +503,67 @@ func TestGeminiStreamHandlerPromptOnlyUsageMetadataEstimatesCompletionTokens(t *
constant.StreamingTimeout = oldStreamingTimeout
})
info := &relaycommon.RelayInfo{
OriginModelName: "gemini-3-flash-preview",
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "gemini-3-flash-preview",
},
}
info.SetEstimatePromptTokens(20)
for _, tc := range []struct {
name string
part dto.GeminiPart
usageText string
}{
{"text", dto.GeminiPart{Text: "partial streamed answer before disconnect"}, "partial streamed answer before disconnect"},
// An agent turn that only calls a tool still produced output tokens.
{"function call", dto.GeminiPart{FunctionCall: &dto.FunctionCall{FunctionName: "read_file", Arguments: map[string]any{"path": "main.go"}}}, `read_file{"path":"main.go"}`},
} {
t.Run(tc.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
// Simulates a client aborting the stream before the final chunk: text was
// streamed but the last observed usageMetadata only carries prompt tokens.
chunk := dto.GeminiChatResponse{
Candidates: []dto.GeminiChatCandidate{
{
Content: dto.GeminiChatContent{
Role: "model",
Parts: []dto.GeminiPart{
{Text: "partial streamed answer before disconnect"},
info := &relaycommon.RelayInfo{
OriginModelName: "gemini-3-flash-preview",
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "gemini-3-flash-preview",
},
}
info.SetEstimatePromptTokens(20)
// Simulates a client aborting the stream before the final chunk: output was
// streamed but the last observed usageMetadata only carries prompt tokens.
chunk := dto.GeminiChatResponse{
Candidates: []dto.GeminiChatCandidate{
{
Content: dto.GeminiChatContent{
Role: "model",
Parts: []dto.GeminiPart{tc.part},
},
},
},
},
},
UsageMetadata: dto.GeminiUsageMetadata{
PromptTokenCount: 151,
TotalTokenCount: 151,
},
UsageMetadata: dto.GeminiUsageMetadata{
PromptTokenCount: 151,
TotalTokenCount: 151,
},
}
chunkData, err := common.Marshal(chunk)
require.NoError(t, err)
streamBody := []byte("data: " + string(chunkData) + "\n" + "data: [DONE]\n")
resp := &http.Response{
Body: io.NopCloser(bytes.NewReader(streamBody)),
}
usage, newAPIError := geminiStreamHandler(c, info, resp, func(_ string, _ *dto.GeminiChatResponse) bool {
return true
})
require.Nil(t, newAPIError)
require.NotNil(t, usage)
require.Equal(t, 151, usage.PromptTokens)
require.Equal(t, tokenkit.Estimate("gemini-3-flash-preview", tc.usageText), usage.CompletionTokens)
require.Greater(t, usage.CompletionTokens, 0)
require.Equal(t, usage.PromptTokens+usage.CompletionTokens, usage.TotalTokens)
require.NotNil(t, usage.BillingUsage)
require.True(t, usage.BillingUsage.Estimated)
require.NotNil(t, usage.BillingUsage.GeminiUsageMetadata)
require.Equal(t, usage.CompletionTokens, usage.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
})
}
chunkData, err := common.Marshal(chunk)
require.NoError(t, err)
streamBody := []byte("data: " + string(chunkData) + "\n" + "data: [DONE]\n")
resp := &http.Response{
Body: io.NopCloser(bytes.NewReader(streamBody)),
}
usage, newAPIError := geminiStreamHandler(c, info, resp, func(_ string, _ *dto.GeminiChatResponse) bool {
return true
})
require.Nil(t, newAPIError)
require.NotNil(t, usage)
require.Equal(t, 151, usage.PromptTokens)
require.Greater(t, usage.CompletionTokens, 0)
require.Equal(t, usage.PromptTokens+usage.CompletionTokens, usage.TotalTokens)
require.NotNil(t, usage.BillingUsage)
require.True(t, usage.BillingUsage.Estimated)
require.NotNil(t, usage.BillingUsage.GeminiUsageMetadata)
require.Equal(t, usage.CompletionTokens, usage.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
}
func TestGeminiChatHandlerPromptOnlyUsageMetadataEstimatesCompletionTokens(t *testing.T) {
@@ -639,3 +653,104 @@ func TestGeminiStreamHandlerEmptyUsageMetadataBuildsEstimatedBillingUsage(t *tes
require.Equal(t, usage.CompletionTokens, usage.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
require.True(t, common.GetContextKeyBool(c, constant.ContextKeyLocalCountTokens))
}
// Google Search grounding records the unique trimmed non-empty queries
// (webSearchQueries and imageSearchQueries of every candidate, across every
// stream frame) under google_search and one grounded prompt when any query
// exists; the tool price table decides which of the two a model pays for.
func TestGeminiHandlersCountGroundingQueries(t *testing.T) {
gin.SetMode(gin.TestMode)
oldStreamingTimeout := constant.StreamingTimeout
constant.StreamingTimeout = 300
t.Cleanup(func() {
constant.StreamingTimeout = oldStreamingTimeout
})
const text = `"content":{"role":"model","parts":[{"text":"ok"}]}`
grounded := func(queries int) map[string]int {
return map[string]int{dto.BuildInToolGoogleSearch: queries, relaycommon.GoogleSearchGroundedPromptTool: 1}
}
tests := []struct {
name string
stream bool
frames []string
want map[string]int
}{
{
name: "native non-stream",
frames: []string{`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":["Shanghai weather today","current weather in Shanghai"],"groundingChunks":[{"web":{"uri":"https://a.example"}}]}}]}`},
want: grounded(2),
},
{
name: "duplicates, whitespace and empty queries",
frames: []string{`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":["go release",""," go release "," ","tokyo"]}}]}`},
want: grounded(2),
},
{
name: "union across candidates",
frames: []string{`{"candidates":[{"index":0,` + text + `,"groundingMetadata":{"webSearchQueries":["q1","q2"]}},{"index":1,` + text + `,"groundingMetadata":{"webSearchQueries":["q2","q3"]}}]}`},
want: grounded(3),
},
{
name: "image queries count like web queries",
frames: []string{`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":["cat breeds","orange cat"],"imageSearchQueries":["orange tabby cat photo","orange cat"]}}]}`},
want: grounded(3),
},
{
name: "only empty queries",
frames: []string{`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":[""," "],"imageSearchQueries":[""]}}]}`},
want: map[string]int{},
},
{
name: "no grounding metadata",
frames: []string{`{"candidates":[{` + text + `,"finishReason":"STOP"}]}`},
want: map[string]int{},
},
{
name: "stream union across frames",
stream: true,
frames: []string{
`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":["q1"]}}]}`,
`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":["q2","q1 "]}}]}`,
`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":["q3"]}}]}`,
},
want: grounded(3),
},
{
name: "stream repeats are not added up",
stream: true,
frames: []string{
`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":["q1","q2"]}}]}`,
`{"candidates":[{` + text + `,"groundingMetadata":{"webSearchQueries":["q1","q2"]}}]}`,
},
want: grounded(2),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-3.7-flash:generateContent", nil)
info := &relaycommon.RelayInfo{
OriginModelName: "gemini-3.7-flash",
ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gemini-3.7-flash"},
}
var apiErr *types.NewAPIError
if tt.stream {
body := "data: " + strings.Join(tt.frames, "\ndata: ") + "\ndata: [DONE]\n"
_, apiErr = geminiStreamHandler(c, info, &http.Response{Body: io.NopCloser(strings.NewReader(body))}, func(string, *dto.GeminiChatResponse) bool {
return true
})
} else {
_, apiErr = GeminiTextGenerationHandler(c, info, &http.Response{Body: io.NopCloser(strings.NewReader(tt.frames[0]))})
}
require.Nil(t, apiErr)
counts := map[string]int{}
if info.ResponsesUsageInfo != nil {
for name, tool := range info.ResponsesUsageInfo.BuiltInTools {
counts[name] = tool.CallCount
}
}
assert.Equal(t, tt.want, counts)
})
}
}
+3 -1
View File
@@ -33,7 +33,9 @@ func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
info.ObserveResponseModel(gjson.GetBytes(responseBody, "modelVersion").Str)
markGeminiGoogleSearchCall(c, &geminiResponse)
var grounding geminiGroundingCounter
grounding.observe(&geminiResponse)
grounding.commit(info)
countGeminiBillableFunctionCalls(info, &geminiResponse)
if len(geminiResponse.Candidates) == 0 {
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
+2 -1
View File
@@ -15,6 +15,7 @@ import (
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/gin-gonic/gin"
)
@@ -45,7 +46,7 @@ func SetPayloadHash(c *gin.Context, req any) error {
if err != nil {
return err
}
logger.LogInfo(c, fmt.Sprintf("SetPayloadHash body: %s", body))
logger.LogInfo(c, common.LogText("SetPayloadHash body: %s", body))
payloadHash := sha256.Sum256(body)
hexPayloadHash := hex.EncodeToString(payloadHash[:])
c.Set(HexPayloadHashKey, hexPayloadHash)
+6 -2
View File
@@ -69,6 +69,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil
} else if info.RelayMode == constant.RelayModeCompletions {
return fmt.Sprintf("%s/v1/completions", info.ChannelBaseUrl), nil
} else if info.RelayMode == constant.RelayModeResponses {
return fmt.Sprintf("%s/v1/responses", info.ChannelBaseUrl), nil
}
return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil
}
@@ -98,9 +100,11 @@ func isTemperatureOneOnlyModel(model string) bool {
return strings.EqualFold(model, "kimi-k2.6")
}
// ConvertOpenAIResponsesRequest forwards Responses requests natively. Kimi's
// /v1/responses currently serves kimi-k3 only; other models are rejected by
// the upstream, not here.
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
// TODO implement me
return nil, errors.New("not implemented")
return request, nil
}
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
+25
View File
@@ -5,7 +5,10 @@ import (
"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -66,3 +69,25 @@ func TestConvertOpenAIRequestOtherMoonshotModelKeepsTemperature(t *testing.T) {
require.NotNil(t, convertedRequest.Temperature)
require.Equal(t, 0.7, *convertedRequest.Temperature)
}
// Kimi serves OpenAI Responses natively (kimi-k3); the body is forwarded
// unchanged and other models are rejected by the upstream.
func TestResponsesRequestPassesThroughToV1Responses(t *testing.T) {
const body = `{"model":"kimi-k3","input":"Latest Go release?","tools":[{"type":"web_search"}],"stream":false}`
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeResponses,
RelayFormat: types.RelayFormatOpenAIResponses,
ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://api.moonshot.invalid", UpstreamModelName: "kimi-k3"},
}
url, err := (&Adaptor{}).GetRequestURL(info)
require.NoError(t, err)
assert.Equal(t, "https://api.moonshot.invalid/v1/responses", url)
var request dto.OpenAIResponsesRequest
require.NoError(t, common.UnmarshalJsonStr(body, &request))
converted, err := (&Adaptor{}).ConvertOpenAIResponsesRequest(nil, info, request)
require.NoError(t, err)
wire, err := common.Marshal(converted)
require.NoError(t, err)
assert.JSONEq(t, body, string(wire))
}
+27 -27
View File
@@ -337,7 +337,7 @@ func FetchOllamaModels(baseURL, apiKey string) ([]OllamaModel, error) {
client := &http.Client{}
request, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %v", err)
return nil, fmt.Errorf("failed to create request: %v", err)
}
// Ollama 通常不需要 Bearer token,但为了兼容性保留
@@ -347,24 +347,24 @@ func FetchOllamaModels(baseURL, apiKey string) ([]OllamaModel, error) {
response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("请求失败: %v", err)
return nil, fmt.Errorf("request failed: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(response.Body)
return nil, fmt.Errorf("服务器返回错误 %d: %s", response.StatusCode, string(body))
return nil, fmt.Errorf("server returned error %d: %s", response.StatusCode, string(body))
}
var tagsResponse OllamaTagsResponse
body, err := io.ReadAll(response.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %v", err)
return nil, fmt.Errorf("failed to read response: %v", err)
}
err = common.Unmarshal(body, &tagsResponse)
if err != nil {
return nil, fmt.Errorf("解析响应失败: %v", err)
return nil, fmt.Errorf("failed to parse response: %v", err)
}
return tagsResponse.Models, nil
@@ -381,7 +381,7 @@ func PullOllamaModel(baseURL, apiKey, modelName string) error {
requestBody, err := common.Marshal(pullRequest)
if err != nil {
return fmt.Errorf("序列化请求失败: %v", err)
return fmt.Errorf("failed to marshal request: %v", err)
}
client := &http.Client{
@@ -389,7 +389,7 @@ func PullOllamaModel(baseURL, apiKey, modelName string) error {
}
request, err := http.NewRequest("POST", url, strings.NewReader(string(requestBody)))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
return fmt.Errorf("failed to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
@@ -399,13 +399,13 @@ func PullOllamaModel(baseURL, apiKey, modelName string) error {
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("请求失败: %v", err)
return fmt.Errorf("request failed: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("拉取模型失败 %d: %s", response.StatusCode, string(body))
return fmt.Errorf("failed to pull model, status %d: %s", response.StatusCode, string(body))
}
return nil
@@ -422,7 +422,7 @@ func PullOllamaModelStream(baseURL, apiKey, modelName string, progressCallback f
requestBody, err := common.Marshal(pullRequest)
if err != nil {
return fmt.Errorf("序列化请求失败: %v", err)
return fmt.Errorf("failed to marshal request: %v", err)
}
client := &http.Client{
@@ -430,7 +430,7 @@ func PullOllamaModelStream(baseURL, apiKey, modelName string, progressCallback f
}
request, err := http.NewRequest("POST", url, strings.NewReader(string(requestBody)))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
return fmt.Errorf("failed to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
@@ -440,13 +440,13 @@ func PullOllamaModelStream(baseURL, apiKey, modelName string, progressCallback f
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("请求失败: %v", err)
return fmt.Errorf("request failed: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("拉取模型失败 %d: %s", response.StatusCode, string(body))
return fmt.Errorf("failed to pull model, status %d: %s", response.StatusCode, string(body))
}
// 读取流式响应
@@ -469,7 +469,7 @@ func PullOllamaModelStream(baseURL, apiKey, modelName string, progressCallback f
// 检查是否出现错误或完成
if strings.EqualFold(pullResponse.Status, "error") {
return fmt.Errorf("拉取模型失败: %s", strings.TrimSpace(line))
return fmt.Errorf("failed to pull model: %s", strings.TrimSpace(line))
}
if strings.EqualFold(pullResponse.Status, "success") {
successful = true
@@ -478,11 +478,11 @@ func PullOllamaModelStream(baseURL, apiKey, modelName string, progressCallback f
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("读取流式响应失败: %v", err)
return fmt.Errorf("failed to read stream response: %v", err)
}
if !successful {
return fmt.Errorf("拉取模型未完成: 未收到成功状态")
return fmt.Errorf("model pull did not complete: no success status received")
}
return nil
@@ -498,13 +498,13 @@ func DeleteOllamaModel(baseURL, apiKey, modelName string) error {
requestBody, err := common.Marshal(deleteRequest)
if err != nil {
return fmt.Errorf("序列化请求失败: %v", err)
return fmt.Errorf("failed to marshal request: %v", err)
}
client := &http.Client{}
request, err := http.NewRequest("DELETE", url, strings.NewReader(string(requestBody)))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
return fmt.Errorf("failed to create request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
@@ -514,13 +514,13 @@ func DeleteOllamaModel(baseURL, apiKey, modelName string) error {
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("请求失败: %v", err)
return fmt.Errorf("request failed: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("删除模型失败 %d: %s", response.StatusCode, string(body))
return fmt.Errorf("failed to delete model, status %d: %s", response.StatusCode, string(body))
}
return nil
@@ -529,7 +529,7 @@ func DeleteOllamaModel(baseURL, apiKey, modelName string) error {
func FetchOllamaVersion(baseURL, apiKey string) (string, error) {
trimmedBase := strings.TrimRight(baseURL, "/")
if trimmedBase == "" {
return "", fmt.Errorf("baseURL 为空")
return "", fmt.Errorf("baseURL is empty")
}
url := fmt.Sprintf("%s/api/version", trimmedBase)
@@ -537,7 +537,7 @@ func FetchOllamaVersion(baseURL, apiKey string) (string, error) {
client := &http.Client{Timeout: 10 * time.Second}
request, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", fmt.Errorf("创建请求失败: %v", err)
return "", fmt.Errorf("failed to create request: %v", err)
}
if apiKey != "" {
@@ -546,17 +546,17 @@ func FetchOllamaVersion(baseURL, apiKey string) (string, error) {
response, err := client.Do(request)
if err != nil {
return "", fmt.Errorf("请求失败: %v", err)
return "", fmt.Errorf("request failed: %v", err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
return "", fmt.Errorf("读取响应失败: %v", err)
return "", fmt.Errorf("failed to read response: %v", err)
}
if response.StatusCode != http.StatusOK {
return "", fmt.Errorf("查询版本失败 %d: %s", response.StatusCode, string(body))
return "", fmt.Errorf("failed to query version, status %d: %s", response.StatusCode, string(body))
}
var versionResp struct {
@@ -564,11 +564,11 @@ func FetchOllamaVersion(baseURL, apiKey string) (string, error) {
}
if err := common.Unmarshal(body, &versionResp); err != nil {
return "", fmt.Errorf("解析响应失败: %v", err)
return "", fmt.Errorf("failed to parse response: %v", err)
}
if versionResp.Version == "" {
return "", fmt.Errorf("未返回版本信息")
return "", fmt.Errorf("no version information returned")
}
return versionResp.Version, nil
+2 -2
View File
@@ -166,7 +166,7 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
}
var chunk ollamaChatStreamChunk
if err := common.Unmarshal([]byte(line), &chunk); err != nil {
logger.LogError(c, "ollama stream json decode error: "+err.Error()+" line="+line)
logger.LogError(c, common.LogText("ollama stream json decode error: %s line=%s", err.Error(), line))
return usage, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if chunk.Model != "" {
@@ -215,7 +215,7 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
break
}
if err := scanner.Err(); err != nil && err != io.EOF {
logger.LogError(c, "ollama stream scan error: "+err.Error())
logger.LogError(c, common.LogText("ollama stream scan error: %s", err.Error()))
}
return usage, nil
}
+3
View File
@@ -97,6 +97,9 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
a.ChannelType = info.ChannelType
if info.ChannelType == constant.ChannelTypeAzure && info.RelayMode == relayconstant.RelayModeResponses {
info.VendorToolUsage = azureResponsesToolUsage
}
// initialize ThinkingContentInfo when thinking_to_content is enabled
if info.ChannelSetting.ThinkingToContent {
+3 -4
View File
@@ -2,7 +2,6 @@ package openai
import (
"bytes"
"fmt"
"io"
"math"
"net/http"
@@ -59,7 +58,7 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
// 读取响应体到缓冲区
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
logger.LogError(c, fmt.Sprintf("failed to read TTS response body: %v", err))
logger.LogError(c, common.LogText("failed to read TTS response body: %v", err))
c.Writer.WriteHeaderNow()
return usage
}
@@ -68,7 +67,7 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
c.Writer.WriteHeaderNow()
_, err = c.Writer.Write(bodyBytes)
if err != nil {
logger.LogError(c, fmt.Sprintf("failed to write TTS response: %v", err))
logger.LogError(c, common.LogText("failed to write TTS response: %v", err))
}
// 计算音频时长并更新 usage
@@ -96,7 +95,7 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
usage.PromptTokensDetails.TextTokens = usage.PromptTokens
if durationErr != nil {
logger.LogWarn(c, fmt.Sprintf("failed to get audio duration: %v", durationErr))
logger.LogWarn(c, common.LogText("failed to get audio duration: %v", durationErr))
// 如果无法获取时长,则设置保底的 CompletionTokens,根据body大小计算
sizeInKB := float64(len(bodyBytes)) / 1000.0
estimatedTokens := int(math.Ceil(sizeInKB)) // 粗略估算每KB约等于1 token
+78 -18
View File
@@ -85,7 +85,7 @@ func OaiResponsesToChatBufferedStreamHandler(c *gin.Context, info *relaycommon.R
var streamResp dto.ResponsesStreamResponse
if err := common.UnmarshalJsonStr(data, &streamResp); err != nil {
logger.LogError(c, "failed to unmarshal buffered responses stream event: "+err.Error())
logger.LogError(c, common.LogText("failed to unmarshal buffered responses stream event: %s", err.Error()))
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
break
}
@@ -187,11 +187,27 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
streamErr := (*types.NewAPIError)(nil)
// upstreamFailed records a response.failed / response.error event and
// failedUsage the usage it reported, if any.
upstreamFailed := false
var failedUsage *dto.Usage
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo == nil {
info.ClaudeConvertInfo = &relaycommon.ClaudeConvertInfo{LastMessagesType: relaycommon.LastMessageTypeNone}
}
// writeFailed stops the stream after a failed client write. When the client
// went away the upstream already produced output, so the request settles
// normally instead of failing; any other write error stays a server error.
writeFailed := func(err error) bool {
if c.Request.Context().Err() != nil {
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, err)
return false
}
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
return false
}
sendGeminiResponse := func(geminiResponse *dto.GeminiChatResponse) bool {
if geminiResponse == nil {
return true
@@ -213,8 +229,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return true
}
if err := helper.ObjectData(c, &value); err != nil {
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
return false
return writeFailed(err)
}
return true
case *dto.ChatCompletionsStreamResponse:
@@ -222,14 +237,12 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return true
}
if err := helper.ObjectData(c, value); err != nil {
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
return false
return writeFailed(err)
}
return true
case dto.ClaudeResponse:
if err := helper.ClaudeData(c, value); err != nil {
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
return false
return writeFailed(err)
}
return true
case *dto.ClaudeResponse:
@@ -237,8 +250,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return true
}
if err := helper.ClaudeData(c, *value); err != nil {
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
return false
return writeFailed(err)
}
return true
case dto.GeminiChatResponse:
@@ -253,13 +265,13 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
if streamErr != nil {
sr.Stop(streamErr)
stopStream(sr, streamErr)
return
}
var streamResp dto.ResponsesStreamResponse
if err := common.UnmarshalJsonStr(data, &streamResp); err != nil {
logger.LogError(c, "failed to unmarshal responses stream event: "+err.Error())
logger.LogError(c, common.LogText("failed to unmarshal responses stream event: %s", err.Error()))
sr.Error(err)
return
}
@@ -268,34 +280,72 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
info.ObserveResponseModel(streamResp.Response.Model)
}
if streamResp.Type == "response.error" || streamResp.Type == "response.failed" {
upstreamFailed = true
if streamResp.Response != nil {
failedUsage = streamResp.Response.Usage
if oaiErr := streamResp.Response.GetOpenAIError(); oaiErr != nil && oaiErr.Type != "" {
streamErr = types.WithOpenAIError(*oaiErr, http.StatusInternalServerError)
sr.Stop(streamErr)
stopStream(sr, streamErr)
return
}
}
streamErr = types.NewOpenAIError(fmt.Errorf("responses stream error: %s", streamResp.Type), types.ErrorCodeBadResponse, http.StatusInternalServerError)
sr.Stop(streamErr)
stopStream(sr, streamErr)
return
}
results, err := service.ConvertStreamResponseChunk(c, info, state, &streamResp)
if err != nil {
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
sr.Stop(streamErr)
stopStream(sr, streamErr)
return
}
for _, result := range results {
if !sendStreamResult(result) {
sr.Stop(streamErr)
stopStream(sr, streamErr)
return
}
}
})
if streamErr != nil {
return nil, streamErr
if !upstreamFailed || state.UsageText() == "" {
return nil, streamErr
}
// The upstream failed after output reached the client: bill the
// delivered output (upstream usage when reported, otherwise the
// estimate) and send the client the same error the failed request
// path writes. stream_status already records the failure.
usage := relayconvert.UsageFromResponsesUsage(failedUsage)
if usage.TotalTokens == 0 {
usage = state.Usage()
}
if usage == nil || usage.TotalTokens == 0 {
usage = service.ResponseText2Usage(c, state.UsageText(), info.UpstreamModelName, info.GetEstimatePromptTokens())
} else if usage.CompletionTokens == 0 || usage.PromptTokens == 0 {
// As in the native Responses accumulator, reported usage that
// omits the output or the prompt is completed from the estimate of
// the delivered output and the prompt.
estimate := service.ResponseText2Usage(c, state.UsageText(), info.UpstreamModelName, info.GetEstimatePromptTokens())
if usage.CompletionTokens == 0 {
usage.CompletionTokens = estimate.CompletionTokens
}
if usage.PromptTokens == 0 {
usage.PromptTokens = estimate.PromptTokens
}
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
if usage.BillingUsage != nil {
usage.BillingUsage = dto.CloneBillingUsageWithEstimatedCompletion(usage.BillingUsage, usage.CompletionTokens)
}
}
logger.LogError(c, common.LogText("responses stream failed after output was delivered, billing the delivered output: %s", streamErr.Error()))
streamErr.SetMessage(common.MessageWithRequestId(streamErr.Error(), c.GetString(common.RequestIdKey)))
if info.RelayFormat == types.RelayFormatClaude {
c.JSON(streamErr.StatusCode, gin.H{"type": "error", "error": streamErr.ToClaudeError(common.UpstreamAddresses(c)...)})
} else {
c.JSON(streamErr.StatusCode, gin.H{"error": streamErr.ToOpenAIError(common.UpstreamAddresses(c)...)})
}
return usage, nil
}
usage := state.Usage()
@@ -303,6 +353,9 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
usage = service.ResponseText2Usage(c, state.UsageText(), info.UpstreamModelName, info.GetEstimatePromptTokens())
state.SetUsage(usage)
}
if info.StreamStatus.EndReason == relaycommon.StreamEndReasonClientGone {
return usage, nil
}
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil {
info.ClaudeConvertInfo.Usage = usage
@@ -313,12 +366,19 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
}
for _, result := range finalResults {
if !sendStreamResult(result) {
return nil, streamErr
if streamErr != nil {
return nil, streamErr
}
return usage, nil
}
}
if info.RelayFormat == types.RelayFormatOpenAI && info.ShouldIncludeUsage && usage != nil {
if err := helper.ObjectData(c, helper.GenerateFinalUsageResponse(responseId, createAt, info.UpstreamModelName, *usage)); err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
writeFailed(err)
if streamErr != nil {
return nil, streamErr
}
return usage, nil
}
}
@@ -11,7 +11,9 @@ import (
"github.com/QuantumNous/new-api/constant"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -268,6 +270,59 @@ func TestOaiChatToResponsesStreamHandlerConvertsSSEOrderAndUsage(t *testing.T) {
)
}
func TestOaiChatToResponsesStreamHandlerRestoresCodexCustomToolCall(t *testing.T) {
oldMode := gin.Mode()
gin.SetMode(gin.TestMode)
t.Cleanup(func() { gin.SetMode(oldMode) })
oldTimeout := constant.StreamingTimeout
constant.StreamingTimeout = 30
t.Cleanup(func() { constant.StreamingTimeout = oldTimeout })
body := strings.Join([]string{
`data: {"id":"chatcmpl_1","object":"chat.completion.chunk","created":1710000000,"model":"gpt-test","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_exec","type":"function","function":{"name":"exec","arguments":"{\"input\":"}}]},"finish_reason":null}]}`,
`data: {"id":"chatcmpl_1","object":"chat.completion.chunk","created":1710000000,"model":"gpt-test","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"ls -la\"}"}}]},"finish_reason":null}]}`,
`data: {"id":"chatcmpl_1","object":"chat.completion.chunk","created":1710000000,"model":"gpt-test","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
`data: [DONE]`,
``,
}, "\n")
c, recorder, resp, info := newResponsesChatTestContext(t, body, true)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
tools, marshalErr := common.Marshal([]map[string]any{
{"type": "custom", "name": "exec", "description": "Run a shell command", "format": map[string]any{"type": "text"}},
})
require.NoError(t, marshalErr)
converted, convertErr := service.ConvertRequestByID(c, info, relayconvert.ConverterOpenAIResponsesToOpenAIChat, &dto.OpenAIResponsesRequest{
Model: "gpt-test",
Input: []byte(`"list files"`),
Tools: tools,
})
require.NoError(t, convertErr)
chatRequest, ok := converted.Value.(*dto.GeneralOpenAIRequest)
require.True(t, ok)
require.Len(t, chatRequest.Tools, 1)
assert.Equal(t, "function", chatRequest.Tools[0].Type)
assert.Equal(t, "exec", chatRequest.Tools[0].Function.Name)
_, err := OaiChatToResponsesStreamHandler(c, info, resp)
require.Nil(t, err)
got := recorder.Body.String()
assert.NotContains(t, got, `event: response.function_call_arguments`)
assert.Contains(t, got, `"delta":"ls -la"`)
assert.Contains(t, got, `"input":"ls -la"`)
requireOrderedSubstrings(t, got,
`event: response.output_item.added`,
`"type":"custom_tool_call"`,
`event: response.custom_tool_call_input.delta`,
`event: response.custom_tool_call_input.done`,
`event: response.output_item.done`,
`event: response.completed`,
`"type":"custom_tool_call"`,
)
}
func requireOrderedSubstrings(t *testing.T, s string, parts ...string) {
t.Helper()
+45 -11
View File
@@ -62,7 +62,7 @@ func handleClaudeFormat(c *gin.Context, data string, info *relaycommon.RelayInfo
func handleGeminiFormat(c *gin.Context, data string, info *relaycommon.RelayInfo) error {
var streamResponse dto.ChatCompletionsStreamResponse
if err := common.Unmarshal(common.StringToByteSlice(data), &streamResponse); err != nil {
logger.LogError(c, "failed to unmarshal stream response: "+err.Error())
logger.LogError(c, common.LogText("failed to unmarshal stream response: %s", err.Error()))
return err
}
@@ -111,7 +111,7 @@ func sendGeminiStreamResults(c *gin.Context, results []relayconvert.ResponseResu
}
data, err := common.Marshal(geminiResponse)
if err != nil {
logger.LogError(c, "failed to marshal gemini response: "+err.Error())
logger.LogError(c, common.LogText("failed to marshal gemini response: %s", err.Error()))
return err
}
c.Render(-1, common.CustomEvent{Data: "data: " + string(data)})
@@ -206,7 +206,7 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
case types.RelayFormatClaude:
var streamResponse dto.ChatCompletionsStreamResponse
if err := common.Unmarshal(common.StringToByteSlice(lastStreamData), &streamResponse); err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
common.SysLog(common.LogText("error unmarshalling stream response: %s", err.Error()))
return
}
@@ -214,12 +214,12 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
result, err := service.ConvertStreamResponse(c, info, types.RelayFormatClaude, &streamResponse)
if err != nil {
common.SysLog("error converting Claude stream response: " + err.Error())
common.SysLog(common.LogText("error converting Claude stream response: %s", err.Error()))
return
}
claudeResponses, ok := result.Value.([]*dto.ClaudeResponse)
if !ok {
common.SysLog(fmt.Sprintf("expected Claude stream responses, got %T", result.Value))
common.SysLog(common.LogText("expected Claude stream responses, got %T", result.Value))
return
}
for _, resp := range claudeResponses {
@@ -230,34 +230,34 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
case types.RelayFormatGemini:
var streamResponse dto.ChatCompletionsStreamResponse
if err := common.Unmarshal(common.StringToByteSlice(lastStreamData), &streamResponse); err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
common.SysLog(common.LogText("error unmarshalling stream response: %s", err.Error()))
return
}
state, err := chatToGeminiStreamState(info, &streamResponse)
if err != nil {
common.SysLog("error creating Gemini stream state: " + err.Error())
common.SysLog(common.LogText("error creating Gemini stream state: %s", err.Error()))
return
}
state.SetUsage(usage)
results, err := service.ConvertStreamResponseChunk(c, info, state, &streamResponse)
if err != nil {
common.SysLog("error converting final Gemini stream response: " + err.Error())
common.SysLog(common.LogText("error converting final Gemini stream response: %s", err.Error()))
return
}
if err := sendGeminiStreamResults(c, results); err != nil {
common.SysLog("error sending final Gemini stream response: " + err.Error())
common.SysLog(common.LogText("error sending final Gemini stream response: %s", err.Error()))
return
}
results, err = service.FinalizeStreamResponse(c, info, state)
if err != nil {
common.SysLog("error finalizing Gemini stream response: " + err.Error())
common.SysLog(common.LogText("error finalizing Gemini stream response: %s", err.Error()))
return
}
if err := sendGeminiStreamResults(c, results); err != nil {
common.SysLog("error sending finalized Gemini stream response: " + err.Error())
common.SysLog(common.LogText("error sending finalized Gemini stream response: %s", err.Error()))
}
}
}
@@ -266,5 +266,39 @@ func sendResponsesStreamData(c *gin.Context, streamResponse dto.ResponsesStreamR
if data == "" {
return
}
if streamResponse.Type == "error" || streamResponse.Type == "response.error" || streamResponse.Type == "response.failed" {
// Stream events bypass the controller's HTTP error projection. Keep
// diagnostics server-side and emit only protocol fields to the client.
var original map[string]any
if common.UnmarshalJsonStr(data, &original) != nil {
return
}
diagnostic := map[string]any{"type": streamResponse.Type, "code": original["code"], "message": original["message"], "error": original["error"]}
if response, ok := original["response"].(map[string]any); ok {
diagnostic["response_error"] = response["error"]
}
detail, _ := common.Marshal(diagnostic)
logger.LogError(c, "upstream Responses stream failure: "+string(detail))
message := common.MessageWithRequestId("The model service is temporarily unavailable. Please try again later.", c.GetString(common.RequestIdKey))
publicError := map[string]any{"type": "api_error", "code": "model_service_error", "message": message}
safe := map[string]any{"type": streamResponse.Type, "code": "model_service_error", "message": message, "error": publicError}
if sequence, ok := original["sequence_number"]; ok {
safe["sequence_number"] = sequence
}
if response, ok := original["response"].(map[string]any); ok {
publicResponse := map[string]any{"status": "failed", "error": publicError}
for _, field := range []string{"id", "object", "created_at", "model", "usage"} {
if value, exists := response[field]; exists {
publicResponse[field] = value
}
}
safe["response"] = publicResponse
}
encoded, err := common.Marshal(safe)
if err != nil {
return
}
data = string(encoded)
}
_ = helper.ResponseChunkData(c, streamResponse, data)
}
+8 -6
View File
@@ -15,6 +15,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/tokenkit"
"github.com/gin-gonic/gin"
)
@@ -102,7 +103,7 @@ func sendStreamData(c *gin.Context, info *relaycommon.RelayInfo, data string, fo
func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
if resp == nil || resp.Body == nil {
logger.LogError(c, "invalid response or response body")
logger.LogError(c, common.LogText("invalid response or response body"))
return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
@@ -124,7 +125,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
if lastStreamData != "" {
if err := HandleStreamFormat(c, info, lastStreamData, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent); err != nil {
common.SysLog("error handling stream format: " + err.Error())
common.SysLog(common.LogText("error handling stream format: %s", err.Error()))
sr.Error(err)
}
}
@@ -136,7 +137,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
lastStreamData = data
observeStreamChoices(info, data, seenStreamToolCalls, &streamFunctionCallNames)
if err := processTokenData(info, data, &responseTextBuilder, &toolCount); err != nil {
logger.LogError(c, "error processing stream token data: "+err.Error())
logger.LogError(c, common.LogText("error processing stream token data: %s", err.Error()))
sr.Error(err)
}
}
@@ -148,7 +149,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
shouldSendLastResp := true
if err := handleLastResponse(lastStreamData, &responseId, &createAt, &systemFingerprint, &model, &usage,
&containStreamUsage, info, &shouldSendLastResp); err != nil {
logger.LogError(c, fmt.Sprintf("error handling last response: %s, lastStreamData: [%s]", err.Error(), lastStreamData))
logger.LogError(c, common.LogText("error handling last response: %s, lastStreamData: [%s]", err.Error(), lastStreamData))
}
// 部分兼容网关把完整的累计usage附在倒数第二个事件上,随后发送一个空的最后事件。
@@ -268,7 +269,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
if enterpriseResponse.Success {
responseBody = enterpriseResponse.Data
} else {
logger.LogError(c, fmt.Sprintf("openrouter enterprise response success=false, data: %s", enterpriseResponse.Data))
logger.LogError(c, common.LogText("openrouter enterprise response success=false, data: %s", enterpriseResponse.Data))
return nil, types.NewOpenAIError(fmt.Errorf("openrouter response success=false"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
}
@@ -296,6 +297,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
info.CountBillableToolCall(dto.BuildInCallFunctionCall, tc.Function.Name)
}
}
info.ApplyVendorToolUsage(responseBody)
forceFormat := false
if info.ChannelSetting.ForceFormat {
@@ -307,7 +309,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
completionTokens := simpleResponse.Usage.CompletionTokens
if completionTokens == 0 {
for _, choice := range simpleResponse.Choices {
ctkm := service.CountTextToken(choice.Message.StringContent()+choice.Message.GetReasoningContent(), info.UpstreamModelName)
ctkm := tokenkit.Count(info.UpstreamModelName, choice.Message.StringContent()+choice.Message.GetReasoningContent())
completionTokens += ctkm
}
}
+1 -1
View File
@@ -122,7 +122,7 @@ func normalizeOpenAIUsage(usage *dto.Usage) {
func OpenaiImageStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
if resp == nil || resp.Body == nil {
logger.LogError(c, "invalid image stream response")
logger.LogError(c, common.LogText("invalid image stream response"))
return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
+7 -7
View File
@@ -75,7 +75,7 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.
errChan <- fmt.Errorf("error counting text token: %v", err)
return
}
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
logger.LogInfo(c, common.LogText("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
localUsage.TotalTokens += textToken + audioToken
localUsage.InputTokens += textToken + audioToken
localUsage.InputTokenDetails.TextTokens += textToken
@@ -148,7 +148,7 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.
errChan <- fmt.Errorf("error counting text token: %v", err)
return
}
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
logger.LogInfo(c, common.LogText("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
localUsage.TotalTokens += textToken + audioToken
info.IsFirstRequest = false
localUsage.InputTokens += textToken + audioToken
@@ -163,9 +163,9 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.
localUsage = &dto.RealtimeUsage{}
// print now usage
}
logger.LogInfo(c, fmt.Sprintf("realtime streaming sumUsage: %v", sumUsage))
logger.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage))
logger.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage))
logger.LogInfo(c, common.LogText("realtime streaming sumUsage: %v", sumUsage))
logger.LogInfo(c, common.LogText("realtime streaming localUsage: %v", localUsage))
logger.LogInfo(c, common.LogText("realtime streaming localUsage: %v", localUsage))
} else if realtimeEvent.Type == dto.RealtimeEventTypeSessionUpdated || realtimeEvent.Type == dto.RealtimeEventTypeSessionCreated {
realtimeSession := realtimeEvent.Session
@@ -180,7 +180,7 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.
errChan <- fmt.Errorf("error counting text token: %v", err)
return
}
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
logger.LogInfo(c, common.LogText("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
localUsage.TotalTokens += textToken + audioToken
localUsage.OutputTokens += textToken + audioToken
localUsage.OutputTokenDetails.TextTokens += textToken
@@ -206,7 +206,7 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.
case <-targetClosed:
case err := <-errChan:
//return service.OpenAIErrorWrapper(err, "realtime_error", http.StatusInternalServerError), nil
logger.LogError(c, "realtime error: "+err.Error())
logger.LogError(c, common.LogText("realtime error: %s", err.Error()))
case <-c.Done():
}
+6 -8
View File
@@ -73,13 +73,11 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
info.CountBillableToolCall(dto.BuildInCallFunctionCall, output.Name)
}
}
info.ApplyVendorToolUsage(responseBody)
imageCounter := &relaycommon.ImageGenerationCallCounter{}
if !relaycommon.IsNonBillableResponsesStatus(responsesResponse.Status) {
for i := range responsesResponse.Output {
idx := i
imageCounter.Observe(&responsesResponse.Output[i], &idx)
}
for i := range responsesResponse.Output {
imageCounter.Observe(&responsesResponse.Output[i], &i)
}
imageCounter.Commit(info)
@@ -140,7 +138,7 @@ func readBufferedResponsesSSE(body io.Reader) ([]byte, error) {
func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
if resp == nil || resp.Body == nil {
logger.LogError(c, "invalid response or response body")
logger.LogError(c, common.LogText("invalid response or response body"))
return nil, types.NewError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse)
}
@@ -153,7 +151,7 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
// 检查当前数据是否包含 completed 状态和 usage 信息
var streamResponse dto.ResponsesStreamResponse
if err := common.UnmarshalJsonStr(data, &streamResponse); err != nil {
logger.LogError(c, "failed to unmarshal stream response: "+err.Error())
logger.LogError(c, common.LogText("failed to unmarshal stream response: %s", err.Error()))
sr.Error(err)
return
}
@@ -161,7 +159,7 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
data = string(rewriteSGLangResponsesCreatedAt(info, []byte(data), "response.created_at", streamResponse.Response.CreatedAt))
}
sendResponsesStreamData(c, streamResponse, data)
accumulator.Observe(&streamResponse)
accumulator.Observe(&streamResponse, common.StringToByteSlice(data))
})
common.SetContextKey(c, constant.ContextKeyResponseStreamStatus, info.StreamStatus)
@@ -5,13 +5,16 @@ import (
"io"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
@@ -193,7 +196,7 @@ func TestOaiResponsesHandlerCountsCompletedImageGenerationOutputs(t *testing.T)
assert.False(t, c.GetBool("image_generation_call"))
}
func TestOaiResponsesHandlerIncompleteStatusCommitsZeroImageGeneration(t *testing.T) {
func TestOaiResponsesHandlerCountsImageGenerationOnIncompleteStatus(t *testing.T) {
gin.SetMode(gin.TestMode)
body, err := common.Marshal(dto.OpenAIResponsesResponse{
@@ -229,7 +232,7 @@ func TestOaiResponsesHandlerIncompleteStatusCommitsZeroImageGeneration(t *testin
_, apiErr := OaiResponsesHandler(c, info, resp)
require.Nil(t, apiErr)
assert.Equal(t, 0, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
}
func runResponsesImageBillingStream(t *testing.T, events ...string) *relaycommon.RelayInfo {
@@ -284,14 +287,14 @@ func TestOaiResponsesStreamHandlerDeduplicatesCompletedImageOutput(t *testing.T)
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
}
func TestOaiResponsesStreamHandlerDiscardsImageOutputOnIncomplete(t *testing.T) {
func TestOaiResponsesStreamHandlerCountsImageOutputOnIncomplete(t *testing.T) {
info := runResponsesImageBillingStream(
t,
`{"type":"response.output_item.done","output_index":0,"item":{"type":"image_generation_call","id":"img_1","status":"completed","result":"base64-a"}}`,
`{"type":"response.incomplete","response":{"status":"incomplete"}}`,
)
assert.Equal(t, 0, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
}
func TestOaiResponsesStreamHandlerDoesNotCountPartialImageEvent(t *testing.T) {
@@ -392,3 +395,139 @@ func TestOaiResponsesStreamHandlerKeepsNonSGLangCreatedAt(t *testing.T) {
require.Nil(t, apiErr)
assert.Contains(t, w.Body.String(), `1786588600.0`)
}
func TestResponsesStreamFailureHidesRoutingDetails(t *testing.T) {
for _, eventType := range []string{"error", "response.error", "response.failed"} {
t.Run(eventType, func(t *testing.T) {
secret := "智能路由流式响应中途失败。原因:个人池商家在流式响应开始后中断。 request_id: upstream-private-id"
original := map[string]any{"type": eventType, "sequence_number": 3, "code": "vendor_stream_error", "message": secret, "debug": secret, "error": map[string]any{"message": secret}, "response": map[string]any{"id": "resp_test", "status": "failed", "error": map[string]any{"type": "vendor_error", "code": "vendor_stream_error", "message": secret}, "usage": map[string]any{"input_tokens": 10, "output_tokens": 2}, "debug": secret}}
encoded, err := common.Marshal(original)
require.NoError(t, err)
var event dto.ResponsesStreamResponse
require.NoError(t, common.Unmarshal(encoded, &event))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
c.Set(common.RequestIdKey, "local-request-id")
sendResponsesStreamData(c, event, string(encoded))
body := w.Body.String()
assert.Contains(t, body, "event: "+eventType)
assert.Contains(t, body, "model_service_error")
assert.Contains(t, body, "local-request-id")
assert.Contains(t, body, `"status":"failed"`)
assert.Contains(t, body, `"input_tokens":10`)
for _, detail := range []string{"个人池", "智能路由", "upstream-private-id", "vendor_stream_error", "debug"} {
assert.NotContains(t, body, detail)
}
// Projection must not change the event used for outcome and billing.
require.NotNil(t, event.Response)
assert.Equal(t, "vendor_stream_error", event.Response.GetOpenAIError().Code)
})
}
}
func TestResponsesSuccessfulStreamDataUnchanged(t *testing.T) {
body := `{"type":"response.output_text.delta","delta":"个人池 is user content"}`
var event dto.ResponsesStreamResponse
require.NoError(t, common.UnmarshalJsonStr(body, &event))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
sendResponsesStreamData(c, event, body)
assert.Contains(t, w.Body.String(), body)
}
// A converted hosted web search reaches Azure and Xinference Chat Completions
// as web_search_options, like OpenAI; the upstream decides whether it applies.
// Azure Responses bills web search by tool_usage.web_search.num_requests (Bing
// transactions) under bing_web_search, and the explicit zeros silence the
// web_search_call items, which leaves one surcharge row.
func TestAzureHostedWebSearch(t *testing.T) {
gin.SetMode(gin.TestMode)
oldTimeout := constant.StreamingTimeout
constant.StreamingTimeout = 30
t.Cleanup(func() {
constant.StreamingTimeout = oldTimeout
})
t.Run("Claude to Chat", func(t *testing.T) {
const body = `{"model":"gpt-5.1","max_tokens":64,"messages":[{"role":"user","content":"Weather?"}],"tools":[{"type":"web_search_20250305","name":"web_search"}]}`
for _, channelType := range []int{constant.ChannelTypeAzure, constant.ChannelTypeXinference, constant.ChannelTypeOpenAI} {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
info := &relaycommon.RelayInfo{
RelayFormat: types.RelayFormatClaude,
ChannelMeta: &relaycommon.ChannelMeta{ChannelType: channelType, UpstreamModelName: "gpt-5.1"},
}
adaptor := &Adaptor{}
adaptor.Init(info)
var request dto.ClaudeRequest
require.NoError(t, common.UnmarshalJsonStr(body, &request))
converted, err := adaptor.ConvertClaudeRequest(c, info, &request)
require.NoError(t, err)
wire, err := common.Marshal(converted)
require.NoError(t, err)
assert.True(t, strings.Contains(string(wire), `"web_search_options"`), channelType)
var codes []string
for _, diagnostic := range info.ConversionDiagnostics() {
codes = append(codes, diagnostic.Code)
}
assert.False(t, slices.Contains(codes, "hosted_web_search_omitted"), channelType)
}
})
const searchCall = `{"type":"web_search_call","status":"completed","action":{"type":"search","query":"go"}}`
const usage = `"usage":{"input_tokens":10,"output_tokens":3,"total_tokens":13},"tool_usage":{"web_search":{"num_requests":5}}`
output := strings.Repeat(searchCall+",", 3) + `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Go 1.25"}]}`
tests := []struct {
name string
contentType string
body string
}{
{
name: "json",
contentType: "application/json",
body: `{"id":"resp_1","object":"response","model":"gpt-5.1","status":"completed","output":[` + output + `],` + usage + `}`,
},
{
name: "stream",
contentType: "text/event-stream",
body: strings.Repeat(`data: {"type":"response.output_item.done","output_index":0,"item":`+searchCall+"}\n\n", 3) +
`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.1","status":"completed","output":[` + output + `],` + usage + "}}\n\ndata: [DONE]\n\n",
},
}
for _, tt := range tests {
t.Run("Responses "+tt.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeResponses,
RelayFormat: types.RelayFormatOpenAIResponses,
OriginModelName: "gpt-5.1",
IsStream: tt.name == "stream",
DisablePing: true,
ChannelMeta: &relaycommon.ChannelMeta{ChannelType: constant.ChannelTypeAzure, UpstreamModelName: "gpt-5.1"},
}
adaptor := &Adaptor{}
adaptor.Init(info)
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(tt.body)),
Header: http.Header{"Content-Type": []string{tt.contentType}},
}
var apiErr *types.NewAPIError
if info.IsStream {
_, apiErr = OaiResponsesStreamHandler(c, info, resp)
} else {
_, apiErr = OaiResponsesHandler(c, info, resp)
}
require.Nil(t, apiErr)
billed := map[string]int{}
for name, tool := range info.ResponsesUsageInfo.BuiltInTools {
if tool.CallCount > 0 {
billed[name] = tool.CallCount
}
}
assert.Equal(t, map[string]int{"bing_web_search": 5}, billed)
})
}
}
+51 -12
View File
@@ -79,6 +79,9 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
streamErr := (*types.NewAPIError)(nil)
// upstreamFailure is the upstream or conversion failure the client was
// told about with response.failed.
var upstreamFailure *types.NewAPIError
sendEvent := func(event relayconvert.ChatToResponsesStreamEvent) bool {
data, err := common.Marshal(event.Payload)
@@ -87,6 +90,13 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return false
}
if err := helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: event.Type}, string(data)); err != nil {
// When the client went away the upstream already produced output,
// so the stream stops and settles normally instead of failing; any
// other write error stays a server error.
if c.Request.Context().Err() != nil {
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, err)
return false
}
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
return false
}
@@ -112,7 +122,7 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
if streamErr != nil {
sr.Stop(streamErr)
stopStream(sr, streamErr)
return
}
@@ -120,24 +130,28 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
if err := common.UnmarshalJsonStr(data, &errorResp); err == nil {
if oaiError := errorResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
if failResponsesStream(fmt.Errorf("%s", oaiError.Message)) {
sr.Stop(streamErr)
// The client got response.failed, so the request is never
// retried; the recorded error keeps the stream failed.
upstreamFailure = types.WithOpenAIError(*oaiError, http.StatusInternalServerError, types.ErrOptionWithSkipRetry())
sr.Stop(upstreamFailure)
return
}
streamErr = types.WithOpenAIError(*oaiError, resp.StatusCode)
sr.Stop(streamErr)
stopStream(sr, streamErr)
return
}
}
var chunk dto.ChatCompletionsStreamResponse
if err := common.UnmarshalJsonStr(data, &chunk); err != nil {
logger.LogError(c, "failed to unmarshal chat stream response: "+err.Error())
logger.LogError(c, common.LogText("failed to unmarshal chat stream response: %s", err.Error()))
if failResponsesStream(err) {
sr.Stop(streamErr)
upstreamFailure = types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError, types.ErrOptionWithSkipRetry())
sr.Stop(upstreamFailure)
return
}
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
sr.Stop(streamErr)
stopStream(sr, streamErr)
return
}
@@ -145,22 +159,23 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
results, err := service.ConvertStreamResponseChunk(c, info, state, &chunk)
if err != nil {
if failResponsesStream(err) {
sr.Stop(streamErr)
upstreamFailure = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError, types.ErrOptionWithSkipRetry())
sr.Stop(upstreamFailure)
return
}
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
sr.Stop(streamErr)
stopStream(sr, streamErr)
return
}
for _, result := range results {
event, ok := result.Value.(relayconvert.ChatToResponsesStreamEvent)
if !ok {
streamErr = types.NewOpenAIError(fmt.Errorf("expected OAI responses stream event, got %T", result.Value), types.ErrorCodeBadResponse, http.StatusInternalServerError)
sr.Stop(streamErr)
stopStream(sr, streamErr)
return
}
if !sendEvent(event) {
sr.Stop(streamErr)
stopStream(sr, streamErr)
return
}
}
@@ -171,10 +186,19 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
}
usage := state.Usage()
if usage == nil || usage.TotalTokens == 0 {
noUpstreamUsage := usage == nil || usage.TotalTokens == 0
if upstreamFailure != nil && noUpstreamUsage && state.UsageText() == "" {
// The stream failed before any output reached the client, which
// already received response.failed: the request is not charged.
return nil, upstreamFailure
}
if noUpstreamUsage {
usage = service.ResponseText2Usage(c, state.UsageText(), info.UpstreamModelName, info.GetEstimatePromptTokens())
state.SetUsage(usage)
}
if info.StreamStatus.EndReason == relaycommon.StreamEndReasonClientGone {
return usage, nil
}
finalResults, err := service.FinalizeStreamResponse(c, info, state)
if err != nil {
@@ -189,9 +213,24 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return nil, types.NewOpenAIError(fmt.Errorf("expected OAI responses stream event, got %T", result.Value), types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
if !sendEvent(event) {
return nil, streamErr
if streamErr != nil {
return nil, streamErr
}
return usage, nil
}
}
return usage, nil
}
// stopStream ends a converted stream from the scanner callback. A nil
// streamErr (the stream stops because the client went away) must reach
// StreamResult.Stop as an untyped nil; a typed nil *NewAPIError is a non-nil
// error that StreamResult would record as a failure and dereference.
func stopStream(sr *helper.StreamResult, streamErr *types.NewAPIError) {
if streamErr == nil {
sr.Stop(nil)
return
}
sr.Stop(streamErr)
}
+29
View File
@@ -0,0 +1,29 @@
package openai
import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/tidwall/gjson"
)
// azureResponsesToolUsage bills Azure Responses web search by the Bing request
// count the service reports (tool_usage.web_search.num_requests, $14 per 1,000
// transactions) under its own key, and silences the web_search_call items:
// Azure documents "Use tool_usage.web_search.num_requests" rather than
// counting web_search_call. gjson truncates fractional values and reads
// non-numbers as zero; SetBillableToolCount ignores negatives and settlement
// applies MaxBillableToolCallCount.
func azureResponsesToolUsage(raw []byte) map[string]int {
count := gjson.GetBytes(raw, "tool_usage.web_search.num_requests")
if !count.Exists() {
count = gjson.GetBytes(raw, "response.tool_usage.web_search.num_requests")
}
if !count.Exists() {
return nil
}
return map[string]int{
"bing_web_search": int(count.Int()),
dto.BuildInToolWebSearch: 0,
dto.BuildInToolWebSearchPreview: 0,
}
}
+3 -3
View File
@@ -59,7 +59,7 @@ func palmStreamHandler(c *gin.Context, resp *http.Response) (*types.NewAPIError,
go func() {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
common.SysLog("error reading stream response: " + err.Error())
common.SysLog(common.LogText("error reading stream response: %s", err.Error()))
stopChan <- true
return
}
@@ -67,7 +67,7 @@ func palmStreamHandler(c *gin.Context, resp *http.Response) (*types.NewAPIError,
var palmResponse PaLMChatResponse
err = json.Unmarshal(responseBody, &palmResponse)
if err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
common.SysLog(common.LogText("error unmarshalling stream response: %s", err.Error()))
stopChan <- true
return
}
@@ -79,7 +79,7 @@ func palmStreamHandler(c *gin.Context, resp *http.Response) (*types.NewAPIError,
}
jsonResponse, err := json.Marshal(fullTextResponse)
if err != nil {
common.SysLog("error marshalling stream response: " + err.Error())
common.SysLog(common.LogText("error marshalling stream response: %s", err.Error()))
stopChan <- true
return
}
+374 -142
View File
@@ -2,9 +2,10 @@ package jsplugin
import (
"bytes"
"cmp"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
@@ -14,6 +15,7 @@ import (
"net/http"
"net/textproto"
"net/url"
"reflect"
"regexp"
"slices"
"strconv"
@@ -46,6 +48,26 @@ type requestDescriptor struct {
RewriteModel string `json:"rewriteModel"`
BodyType string `json:"bodyType"`
Parts []requestPart `json:"parts"`
// BodyText is the body as JSON.stringify wrote it, for a plugin that
// preserves JSON order: the upstream gets the members in that order.
BodyText json.RawMessage `json:"-"`
}
// orderedRequestDescriptor is requestDescriptor as a plugin that preserves
// JSON order returns it: decoded from the JSON.stringify text of the result,
// with the body kept as that text.
type orderedRequestDescriptor struct {
ResponseType string `json:"responseType"`
URL string `json:"url"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
Body json.RawMessage `json:"body"`
Credentialless bool `json:"credentialless"`
Action string `json:"action"`
Model string `json:"model"`
RewriteModel string `json:"rewriteModel"`
BodyType string `json:"bodyType"`
Parts []requestPart `json:"parts"`
}
type requestPart struct {
@@ -88,6 +110,12 @@ type TaskAdaptor struct {
routeRequest *pluginruntime.RouteRequestContext
requestHeaders map[string]string
files []map[string]any
// hookRequestBody is hookRequestSource as hooks receive it.
hookRequestSource map[string]any
hookRequestBody any
// requestBodyText is the decoded requestBody as JSON text, which hooks
// of a plugin that preserves JSON order receive instead.
requestBodyText json.RawMessage
}
func New(plugin *pluginruntime.LoadedPlugin) *TaskAdaptor { return &TaskAdaptor{plugin: plugin} }
@@ -98,7 +126,10 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
if pinned, ok := pinnedValue.(pluginruntime.PinnedEndpoint); ok && pinned.Plugin == a.plugin {
if protocolValue, present := c.Get(pluginruntime.ContextKeyProtocolRequest); present {
if protocolContext, valid := protocolValue.(pluginruntime.ProtocolRequestContext); valid {
resolvedValue, callErr := a.plugin.Engine.CallPath(context.WithoutCancel(c.Request.Context()), "protocols", []string{pinned.Protocol, "decodeRequest"}, protocolContext.JSValue())
resolvedValue, requestBodyText, callErr := a.plugin.Engine.CallPathWithMemberJSON(
context.WithoutCancel(c.Request.Context()), 0, a.plugin.Meta.JSONTextMember("requestBody"),
"protocols", []string{pinned.Protocol, "decodeRequest"}, protocolContext.JSValueFor(a.plugin.Meta),
)
resolved, resolvedOK := resolvedValue.(map[string]any)
resolvedModel, modelOK := resolved["model"].(string)
if callErr != nil || !resolvedOK || !modelOK || resolvedModel != pinned.Model {
@@ -110,6 +141,9 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
if body, present := resolved["requestBody"]; present {
c.Set("task_request", body)
}
if requestBodyText != nil {
c.Set(pluginruntime.ContextKeyRequestBodyText, requestBodyText)
}
if action, valid := resolved["action"].(string); valid && strings.TrimSpace(action) != "" {
c.Set("task_action", action)
info.Action = action
@@ -126,18 +160,23 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
request, hasRequest := c.Get("task_request")
hasUsageProfiles := len(a.plugin.Meta.UsageProfiles) > 0
if hasRequest && !hasUsageProfiles {
if err := a.validateResolvedUsageRequest(request, ""); err != nil {
if err := a.validateResolvedUsageRequest(request); err != nil {
return service.TaskErrorWrapperLocal(err, "plugin_usage_invalid", http.StatusBadRequest)
}
}
if _, err := a.buildSubmit(c, info); err != nil {
return service.TaskErrorWrapperLocal(err, "plugin_request_invalid", http.StatusBadRequest)
// utils.fetch failures in the build hook name the host fetched: the
// channel base URL or one of the plugin's allowed hosts, which an
// installed plugin may point at private hosts.
taskErr := service.TaskErrorWrapper(err, "plugin_request_invalid", http.StatusBadRequest, append(common.UpstreamAddresses(c), a.plugin.Meta.AllowedHosts...)...)
taskErr.LocalError = true
return taskErr
}
// The descriptor may rewrite the model. Validate profiled requests against
// that final model before any quota calculation or upstream submission.
// that final model before any quota calculation or upstream submission; an
// upstream endpoint ID without a profile keeps the client model's profile.
if hasRequest && hasUsageProfiles {
usageModel := cmp.Or(info.UpstreamModelName, info.OriginModelName)
if err := a.validateResolvedUsageRequest(request, usageModel); err != nil {
if err := a.validateResolvedUsageRequest(request, info.UpstreamModelName, info.OriginModelName); err != nil {
return service.TaskErrorWrapperLocal(err, "plugin_usage_invalid", http.StatusBadRequest)
}
}
@@ -156,7 +195,7 @@ func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInf
func (a *TaskAdaptor) EstimateBillingValidated(c *gin.Context, info *relaycommon.RelayInfo) (map[string]float64, error) {
usageContext := a.submitContext(c, info)
usageContext["usagePurpose"] = "billing_ratios"
return a.usageRatios(c.Request.Context(), cmp.Or(info.UpstreamModelName, info.OriginModelName), "extractUsage", usageContext)
return a.usageRatios(c.Request.Context(), []string{info.UpstreamModelName, info.OriginModelName}, "extractUsage", usageContext)
}
func (a *TaskAdaptor) ExtractUsageFacts(c *gin.Context, info *relaycommon.RelayInfo) map[string]any {
@@ -185,10 +224,11 @@ func (a *TaskAdaptor) ExtractUsageFactsValidated(c *gin.Context, info *relaycomm
if !ok {
return nil, fmt.Errorf("plugin usage hook must return an object")
}
if _, err = a.validatedUsageRatios(facts, cmp.Or(info.UpstreamModelName, info.OriginModelName)); err != nil {
validated, _, err := a.validatedUsageRatios(facts, info.UpstreamModelName, info.OriginModelName)
if err != nil {
return nil, err
}
return facts, nil
return validated, nil
}
func (a *TaskAdaptor) AdjustBillingOnSubmit(info *relaycommon.RelayInfo, taskData []byte) map[string]float64 {
@@ -196,7 +236,7 @@ func (a *TaskAdaptor) AdjustBillingOnSubmit(info *relaycommon.RelayInfo, taskDat
if err := common.Unmarshal(taskData, &data); err != nil {
data = string(taskData)
}
ratios, err := a.usageRatios(context.Background(), cmp.Or(info.UpstreamModelName, info.OriginModelName), "extractUsageOnSubmit", a.submitContext(nil, info), data)
ratios, err := a.usageRatios(context.Background(), []string{info.UpstreamModelName, info.OriginModelName}, "extractUsageOnSubmit", a.submitContext(nil, info), data)
if err != nil {
a.logRejectedUsage("extractUsageOnSubmit", err)
return nil
@@ -212,11 +252,11 @@ func (a *TaskAdaptor) AdjustBillingOnComplete(task *model.Task, result *relaycom
if err != nil {
return 0
}
usageModel := ""
if task != nil {
usageModel = cmp.Or(task.Properties.UpstreamModelName, task.Properties.OriginModelName)
if task == nil {
a.applyCompletionUsageFacts(result, value)
return 0
}
a.applyCompletionUsageFacts(result, value, usageModel)
a.applyCompletionUsageFacts(result, value, task.Properties.UpstreamModelName, task.Properties.OriginModelName)
return 0
}
@@ -227,13 +267,18 @@ func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, erro
return a.submit.URL, pluginruntime.ValidateRequestURL(a.submit.URL, info.ChannelBaseUrl, a.plugin.Meta.AllowedHosts)
}
func (a *TaskAdaptor) BuildRequestHeader(_ *gin.Context, req *http.Request, _ *relaycommon.RelayInfo) error {
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, _ *relaycommon.RelayInfo) error {
if a.submit == nil {
return fmt.Errorf("plugin submit request was not built")
}
for name, value := range a.submit.Headers {
req.Header.Set(name, value)
}
// BuildRequestBody rebuilt the multipart body with a new boundary and put its
// Content-Type on the client request; a plugin's own Content-Type cannot match it.
if a.submit.BodyType == "multipart" {
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
}
return nil
}
@@ -267,18 +312,18 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayIn
}
continue
}
field := strings.TrimPrefix(part.FileRef, "request_file:")
field, index, validRef := pluginruntime.ParseFileReference(part.FileRef)
files := form.File[field]
if len(files) == 0 {
if !validRef || index >= len(files) {
return nil, fmt.Errorf("unknown file reference %q", part.FileRef)
}
file, openErr := files[0].Open()
file, openErr := files[index].Open()
if openErr != nil {
return nil, openErr
}
filename := part.Filename
if filename == "" {
filename = files[0].Filename
filename = files[index].Filename
}
header := make(textproto.MIMEHeader)
disposition := mime.FormatMediaType("form-data", map[string]string{"name": part.Name, "filename": filename})
@@ -287,7 +332,7 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayIn
return nil, fmt.Errorf("invalid multipart name or filename")
}
header.Set("Content-Disposition", disposition)
header.Set("Content-Type", files[0].Header.Get("Content-Type"))
header.Set("Content-Type", files[index].Header.Get("Content-Type"))
destination, copyErr := writer.CreatePart(header)
if copyErr == nil {
_, copyErr = io.Copy(destination, file)
@@ -309,7 +354,18 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayIn
if text, ok := descriptor.Body.(string); ok {
return strings.NewReader(text), nil
}
inlined, err := inlineJSONFilePlaceholders(c, descriptor.Body)
value := descriptor.Body
if descriptor.BodyText != nil {
if !bytes.Contains(descriptor.BodyText, []byte(`"__fileRef":`)) {
return bytes.NewReader(descriptor.BodyText), nil
}
// File placeholders resolve only in the decoded value, which writes
// the members sorted; uploads carry no client JSON order to keep.
if err := common.Unmarshal(descriptor.BodyText, &value); err != nil {
return nil, err
}
}
inlined, err := inlineJSONFilePlaceholders(c, value)
if err != nil {
return nil, err
}
@@ -391,12 +447,12 @@ func encodeFilePlaceholder(placeholder map[string]any, form *multipart.Form, lim
if form == nil {
return "", fmt.Errorf("unknown file reference %q", ref)
}
field := strings.TrimPrefix(ref, "request_file:")
field, index, validRef := pluginruntime.ParseFileReference(ref)
files := form.File[field]
if len(files) == 0 {
if !validRef || index >= len(files) {
return "", fmt.Errorf("unknown file reference %q", ref)
}
header := files[0]
header := files[index]
maxBytes := limit
if raw, exists := placeholder["maxBytes"]; exists {
n, ok := usageNumber(raw, false)
@@ -484,7 +540,7 @@ func (a *TaskAdaptor) ParseResponse(c *gin.Context, resp *http.Response, info *r
}
}
if err != nil {
failure := service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusBadGateway)
failure := service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusBadGateway, common.UpstreamAddresses(c)...)
failure.NoRetry = streaming
return nil, failure
}
@@ -499,7 +555,7 @@ func (a *TaskAdaptor) ParseResponse(c *gin.Context, resp *http.Response, info *r
resp.StatusCode,
time.Since(started).Milliseconds(),
)
return nil, service.TaskErrorWrapper(err, "plugin_submit_response_failed", http.StatusBadGateway)
return nil, service.TaskErrorWrapper(err, "plugin_submit_response_failed", http.StatusBadGateway, common.UpstreamAddresses(c)...)
}
if object, ok := value.(map[string]any); ok {
if _, forbidden := object["clientResponse"]; forbidden {
@@ -518,7 +574,7 @@ func (a *TaskAdaptor) ParseResponse(c *gin.Context, resp *http.Response, info *r
resp.StatusCode,
time.Since(started).Milliseconds(),
)
return nil, service.TaskErrorWrapper(err, "plugin_submit_response_invalid", http.StatusBadGateway)
return nil, service.TaskErrorWrapper(err, "plugin_submit_response_invalid", http.StatusBadGateway, common.UpstreamAddresses(c)...)
}
var taskData []byte
if parsed.TaskData != nil {
@@ -527,12 +583,12 @@ func (a *TaskAdaptor) ParseResponse(c *gin.Context, resp *http.Response, info *r
if err == nil {
err = fmt.Errorf("task data exceeds size limit")
}
return nil, service.TaskErrorWrapper(err, "plugin_submit_response_invalid", http.StatusBadGateway)
return nil, service.TaskErrorWrapper(err, "plugin_submit_response_invalid", http.StatusBadGateway, common.UpstreamAddresses(c)...)
}
}
pluginState, _ := encodeReturnedPluginState(value)
if len(pluginState) > maxTaskPluginPersistedJSONBytes {
logger.LogWarn(c, fmt.Sprintf("task plugin %s rejected oversized submit state (%d bytes)", a.plugin.Meta.Key, len(pluginState)))
logger.LogWarn(c, common.LogText("task plugin %s rejected oversized submit state (%d bytes)", a.plugin.Meta.Key, len(pluginState)))
pluginState = nil
}
response := &channel.TaskSubmitResponse{
@@ -562,16 +618,16 @@ func (a *TaskAdaptor) ParseResponse(c *gin.Context, resp *http.Response, info *r
task.PrivateData.PluginState = pluginState
ctx, err := a.queryContext(task, info.ApiKey, info.ChannelBaseUrl, info.ChannelSetting.Proxy)
if err != nil {
logger.LogWarn(c, fmt.Sprintf("task plugin %s completion context failed; retaining reserved quota: %v", a.plugin.Meta.Key, err))
logger.LogWarn(c, common.LogText("task plugin %s completion context failed; retaining reserved quota: %v", a.plugin.Meta.Key, err))
return response, nil
}
facts, err := a.plugin.Engine.Call(c.Request.Context(), "extractUsageOnComplete", ctx, jsonValue(immediate), parsed.TaskData)
if err != nil {
logger.LogWarn(c, fmt.Sprintf("task plugin %s completion usage failed; retaining reserved quota: %v", a.plugin.Meta.Key, err))
logger.LogWarn(c, common.LogText("task plugin %s completion usage failed; retaining reserved quota: %v", a.plugin.Meta.Key, err))
return response, nil
}
if err := a.applyCompletionUsageFacts(immediate, facts, cmp.Or(info.UpstreamModelName, info.OriginModelName)); err != nil {
logger.LogWarn(c, fmt.Sprintf("task plugin %s completion usage rejected; retaining reserved quota: %v", a.plugin.Meta.Key, err))
if err := a.applyCompletionUsageFacts(immediate, facts, info.UpstreamModelName, info.OriginModelName); err != nil {
logger.LogWarn(c, common.LogText("task plugin %s completion usage rejected; retaining reserved quota: %v", a.plugin.Meta.Key, err))
}
return response, nil
}
@@ -593,11 +649,11 @@ func (a *TaskAdaptor) FetchBatchTasks(baseURL, key string, tasks []*model.Task,
if err != nil {
return nil, err
}
value, err := a.plugin.Engine.Call(context.Background(), "buildBatchQueryRequest", ctx, taskContexts)
descriptor, err := a.requestDescriptor(pluginruntime.WithFetcher(context.Background(), a.hookFetcher(baseURL, proxy)), "buildBatchQueryRequest", ctx, taskContexts)
if err != nil {
return nil, err
}
return a.doFetchDescriptor(baseURL, proxy, value)
return a.doFetchDescriptor(baseURL, proxy, descriptor)
}
func (a *TaskAdaptor) FetchTask(baseURL, key string, task *model.Task, proxy string) (*http.Response, error) {
@@ -605,18 +661,60 @@ func (a *TaskAdaptor) FetchTask(baseURL, key string, task *model.Task, proxy str
if err != nil {
return nil, err
}
value, err := a.plugin.Engine.Call(context.Background(), "buildQueryRequest", ctx)
descriptor, err := a.requestDescriptor(pluginruntime.WithFetcher(context.Background(), a.hookFetcher(baseURL, proxy)), "buildQueryRequest", ctx)
if err != nil {
return nil, err
}
return a.doFetchDescriptor(baseURL, proxy, value)
return a.doFetchDescriptor(baseURL, proxy, descriptor)
}
func (a *TaskAdaptor) doFetchDescriptor(baseURL, proxy string, value any) (*http.Response, error) {
var descriptor requestDescriptor
if err := convert(value, &descriptor); err != nil {
return nil, err
// hookFetcher sends a driver hook's utils.fetch requests to the hosts its
// request descriptors may use, through the channel's proxy like poll requests.
func (a *TaskAdaptor) hookFetcher(baseURL, proxy string) pluginruntime.Fetcher {
return func(ctx context.Context, request pluginruntime.FetchRequest) (pluginruntime.FetchResponse, error) {
if err := pluginruntime.ValidateRequestURL(request.URL, baseURL, a.plugin.Meta.AllowedHosts); err != nil {
return pluginruntime.FetchResponse{}, err
}
client, err := service.GetHttpClientWithProxySettings(proxy, a.channelSetting())
if err != nil {
return pluginruntime.FetchResponse{}, err
}
// A redirect reaches the plugin as it is; fetching its location is a
// new request, checked and counted again.
noRedirects := *client
noRedirects.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
req, err := http.NewRequestWithContext(ctx, request.Method, request.URL, nil)
if err != nil {
return pluginruntime.FetchResponse{}, err
}
for name, value := range request.Headers {
req.Header.Set(name, value)
}
started := time.Now()
resp, err := noRedirects.Do(req)
if err != nil {
logger.LogDebug(ctx, "task_plugin subsystem=fetch event=failed plugin=%q hook=%q method=%q host=%q reason=transport_error elapsed_ms=%d",
a.plugin.Meta.Key, request.Hook, request.Method, req.URL.Host, time.Since(started).Milliseconds())
return pluginruntime.FetchResponse{}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, pluginruntime.MaxFetchBodyBytes+1))
if err != nil {
return pluginruntime.FetchResponse{}, err
}
headers := make(map[string]string, len(resp.Header))
for name, values := range resp.Header {
if len(values) > 0 {
headers[name] = values[0]
}
}
logger.LogDebug(ctx, "task_plugin subsystem=fetch event=done plugin=%q hook=%q method=%q host=%q status=%d bytes=%d elapsed_ms=%d",
a.plugin.Meta.Key, request.Hook, request.Method, req.URL.Host, resp.StatusCode, len(body), time.Since(started).Milliseconds())
return pluginruntime.FetchResponse{Status: resp.StatusCode, Headers: headers, Body: body}, nil
}
}
func (a *TaskAdaptor) doFetchDescriptor(baseURL, proxy string, descriptor requestDescriptor) (*http.Response, error) {
if err := pluginruntime.ValidateRequestURL(descriptor.URL, baseURL, a.plugin.Meta.AllowedHosts); err != nil {
return nil, err
}
@@ -624,6 +722,8 @@ func (a *TaskAdaptor) doFetchDescriptor(baseURL, proxy string, value any) (*http
if descriptor.Body != nil {
if bodyText, ok := descriptor.Body.(string); ok {
requestBody = strings.NewReader(bodyText)
} else if descriptor.BodyText != nil {
requestBody = bytes.NewReader(descriptor.BodyText)
} else {
encoded, marshalErr := common.Marshal(descriptor.Body)
if marshalErr != nil {
@@ -643,7 +743,7 @@ func (a *TaskAdaptor) doFetchDescriptor(baseURL, proxy string, value any) (*http
for name, value := range descriptor.Headers {
req.Header.Set(name, value)
}
client, err := service.GetHttpClientWithProxy(proxy)
client, err := service.GetHttpClientWithProxySettings(proxy, a.channelSetting())
if err != nil {
return nil, err
}
@@ -690,11 +790,6 @@ func (a *TaskAdaptor) ParseBatchResult(tasks []*model.Task, resp *http.Response,
if err != nil {
return nil, err
}
value, err := a.plugin.Engine.Call(context.Background(), "parseBatchResult", ctx, input, hookHTTPResponse(resp))
if err != nil {
logger.LogDebug(context.Background(), "task_plugin subsystem=adaptor event=parse_batch_failed plugin=%q reason=hook_failed body_bytes=%d elapsed_ms=%d", a.plugin.Meta.Key, len(body), time.Since(started).Milliseconds())
return nil, err
}
var parsed []struct {
TaskID string `json:"taskId"`
Action string `json:"action"`
@@ -708,8 +803,13 @@ func (a *TaskAdaptor) ParseBatchResult(tasks []*model.Task, resp *http.Response,
Data any `json:"data"`
State any `json:"state"`
}
if err = convert(value, &parsed); err != nil {
logger.LogDebug(context.Background(), "task_plugin subsystem=adaptor event=parse_batch_failed plugin=%q reason=invalid_result body_bytes=%d elapsed_ms=%d", a.plugin.Meta.Key, len(body), time.Since(started).Milliseconds())
if err = a.plugin.Engine.CallInto(pluginruntime.WithFetcher(context.Background(), a.hookFetcher(baseURL, proxy)), &parsed, "parseBatchResult", ctx, input, hookHTTPResponse(resp)); err != nil {
reason := "hook_failed"
var invalid *pluginruntime.ResultError
if errors.As(err, &invalid) {
reason = "invalid_result"
}
logger.LogDebug(context.Background(), "task_plugin subsystem=adaptor event=parse_batch_failed plugin=%q reason=%s body_bytes=%d elapsed_ms=%d", a.plugin.Meta.Key, reason, len(body), time.Since(started).Milliseconds())
return nil, err
}
results := make(map[string]*service.BatchTaskResult, len(parsed))
@@ -722,7 +822,7 @@ func (a *TaskAdaptor) ParseBatchResult(tasks []*model.Task, resp *http.Response,
if item.State != nil {
pluginState, marshalErr := common.Marshal(item.State)
if marshalErr != nil || len(pluginState) > maxTaskPluginPersistedJSONBytes {
logger.LogWarn(context.Background(), fmt.Sprintf("task plugin %s rejected invalid or oversized poll state", a.plugin.Meta.Key))
logger.LogWarn(context.Background(), common.LogText("task plugin %s rejected invalid or oversized poll state", a.plugin.Meta.Key))
} else {
info.PluginState = pluginState
}
@@ -741,8 +841,9 @@ func (a *TaskAdaptor) ParseBatchResult(tasks []*model.Task, resp *http.Response,
}
facts, hookErr := a.plugin.Engine.Call(context.Background(), "extractUsageOnComplete", itemCtx, jsonValue(&info), usageBody)
if hookErr == nil {
usageModel, _ := itemCtx["upstreamModel"].(string)
a.applyCompletionUsageFacts(&info, facts, usageModel)
upstreamModel, _ := itemCtx["upstreamModel"].(string)
originModel, _ := itemCtx["model"].(string)
a.applyCompletionUsageFacts(&info, facts, upstreamModel, originModel)
}
}
results[item.TaskID] = &service.BatchTaskResult{TaskInfo: info, Action: item.Action, SubmitTime: item.SubmitTime, StartTime: item.StartTime, FinishTime: item.FinishTime, Data: item.Data}
@@ -774,7 +875,7 @@ func (a *TaskAdaptor) ParseTaskResult(task *model.Task, resp *http.Response, bod
if err != nil {
return nil, err
}
value, err := a.plugin.Engine.Call(context.Background(), "parseTaskResult", ctx, input, hookHTTPResponse(resp))
value, err := a.plugin.Engine.Call(pluginruntime.WithFetcher(context.Background(), a.hookFetcher(baseURL, proxy)), "parseTaskResult", ctx, input, hookHTTPResponse(resp))
if err != nil {
logger.LogDebug(context.Background(), "task_plugin subsystem=adaptor event=parse_task_failed plugin=%q reason=hook_failed body_bytes=%d elapsed_ms=%d", a.plugin.Meta.Key, len(body), time.Since(started).Milliseconds())
return nil, err
@@ -797,7 +898,7 @@ func (a *TaskAdaptor) ParseTaskResult(task *model.Task, resp *http.Response, bod
}
if pluginState, present := encodeReturnedPluginState(value); present {
if len(pluginState) > maxTaskPluginPersistedJSONBytes {
logger.LogWarn(context.Background(), fmt.Sprintf("task plugin %s rejected oversized poll state (%d bytes)", a.plugin.Meta.Key, len(pluginState)))
logger.LogWarn(context.Background(), common.LogText("task plugin %s rejected oversized poll state (%d bytes)", a.plugin.Meta.Key, len(pluginState)))
} else {
result.PluginState = pluginState
}
@@ -807,8 +908,9 @@ func (a *TaskAdaptor) ParseTaskResult(task *model.Task, resp *http.Response, bod
if a.hasHook(context.Background(), "extractUsageOnComplete") {
facts, hookErr := a.plugin.Engine.Call(context.Background(), "extractUsageOnComplete", ctx, jsonValue(result), input)
if hookErr == nil {
usageModel, _ := ctx["upstreamModel"].(string)
a.applyCompletionUsageFacts(result, facts, usageModel)
upstreamModel, _ := ctx["upstreamModel"].(string)
originModel, _ := ctx["model"].(string)
a.applyCompletionUsageFacts(result, facts, upstreamModel, originModel)
}
}
taskStatus := model.TaskStatus(result.Status)
@@ -823,8 +925,8 @@ func (a *TaskAdaptor) ParseTaskResult(task *model.Task, resp *http.Response, bod
return result, nil
}
func (a *TaskAdaptor) applyCompletionUsageFacts(result *relaycommon.TaskInfo, facts any, modelName string) error {
values, err := a.validatedCompletionUsageFacts(facts, modelName)
func (a *TaskAdaptor) applyCompletionUsageFacts(result *relaycommon.TaskInfo, facts any, models ...string) error {
values, err := a.validatedCompletionUsageFacts(facts, models...)
if err != nil {
a.logRejectedUsage("extractUsageOnComplete", err)
return err
@@ -861,16 +963,19 @@ func (a *TaskAdaptor) ConvertToOpenAIVideo(task *model.Task) ([]byte, error) {
if err != nil {
return nil, err
}
value, err := a.plugin.Engine.CallPath(context.Background(), "protocols", []string{"openai_video", "render"}, map[string]any{"protocol": "openai_video", "operation": "retrieve"}, jsonValue(view))
encodedView, err := common.Marshal(view)
if err != nil {
return nil, err
}
encoded, err := common.Marshal(value)
// Decoding into any leaves only encoding failures to CallPathInto, so they
// keep the codec's message.
var value any
err = a.plugin.Engine.CallPathInto(context.Background(), &value, "protocols", []string{"openai_video", "render"}, map[string]any{"protocol": "openai_video", "operation": "retrieve"}, pluginruntime.RawJSON(encodedView))
if err != nil {
return nil, err
}
var rendered map[string]any
if err = common.Unmarshal(encoded, &rendered); err != nil || rendered == nil {
rendered, _ := value.(map[string]any)
if rendered == nil {
return nil, fmt.Errorf("plugin returned an invalid OpenAI video object")
}
// Keep provider extensions intact while the host owns the public task's
@@ -906,7 +1011,7 @@ func (a *TaskAdaptor) ListArtifacts(task *model.Task) ([]channel.TaskArtifact, e
return validateTaskArtifacts(value)
}
func (a *TaskAdaptor) BuildContentRequest(task *model.Task, artifactKey string, clientRequest channel.TaskArtifactClientRequest) (*channel.TaskContentRequest, error) {
func (a *TaskAdaptor) BuildContentRequest(ctx context.Context, task *model.Task, artifactKey string, clientRequest channel.TaskArtifactClientRequest) (*channel.TaskContentRequest, error) {
if !a.hasHook(context.Background(), "buildContentRequest") {
return nil, nil
}
@@ -916,32 +1021,22 @@ func (a *TaskAdaptor) BuildContentRequest(task *model.Task, artifactKey string,
if !taskArtifactKeyPattern.MatchString(artifactKey) {
return nil, fmt.Errorf("invalid artifact key")
}
ctx, err := taskArtifactContext(task)
hookContext, err := taskArtifactContext(task)
if err != nil {
return nil, err
}
ctx["upstreamTaskId"] = task.GetUpstreamTaskID()
ctx["artifactKey"] = artifactKey
ctx["baseUrl"] = a.info.ChannelBaseUrl
ctx["clientRequest"] = jsonValue(clientRequest)
proxy := a.info.ChannelSetting.Proxy
auth, err := resolveAuth(a.plugin.Meta.Auth, a.info.ApiKey, proxy)
if err != nil {
hookContext["upstreamTaskId"] = task.GetUpstreamTaskID()
hookContext["artifactKey"] = artifactKey
hookContext["baseUrl"] = a.info.ChannelBaseUrl
hookContext["clientRequest"] = jsonValue(clientRequest)
if err = a.applyUpstreamCredentials(hookContext, a.info.ChannelType, a.info.ApiKey, a.info.ChannelSetting.Proxy); err != nil {
return nil, err
}
ctx["auth"] = auth
ctx["authHeader"] = auth["authHeader"]
if a.plugin.Meta.Auth.Type == "" || a.plugin.Meta.Auth.Type == "none" || a.plugin.Meta.Auth.Type == "api_key" {
ctx["apiKey"] = a.info.ApiKey
}
value, err := a.plugin.Engine.Call(context.Background(), "buildContentRequest", ctx)
fetchContext := pluginruntime.WithFetcher(ctx, a.hookFetcher(a.info.ChannelBaseUrl, a.info.ChannelSetting.Proxy))
descriptor, err := a.requestDescriptor(fetchContext, "buildContentRequest", hookContext)
if err != nil {
return nil, err
}
var descriptor requestDescriptor
if err = convert(value, &descriptor); err != nil {
return nil, err
}
method := strings.ToUpper(strings.TrimSpace(descriptor.Method))
if method == "" {
method = strings.ToUpper(strings.TrimSpace(clientRequest.Method))
@@ -970,6 +1065,8 @@ func (a *TaskAdaptor) BuildContentRequest(task *model.Task, artifactKey string,
if descriptor.Body != nil {
if text, ok := descriptor.Body.(string); ok {
body = []byte(text)
} else if descriptor.BodyText != nil {
body = descriptor.BodyText
} else {
body, err = common.Marshal(descriptor.Body)
if err != nil {
@@ -1056,29 +1153,17 @@ func (a *TaskAdaptor) queryContext(task *model.Task, key, baseURL, proxy string)
key = task.PrivateData.Key
}
}
auth, err := resolveAuth(a.plugin.Meta.Auth, key, proxy)
if err != nil {
if err := a.applyUpstreamCredentials(ctx, a.channelType(), key, proxy); err != nil {
return nil, err
}
ctx["auth"] = auth
ctx["authHeader"] = auth["authHeader"]
if a.plugin.Meta.Auth.Type == "" || a.plugin.Meta.Auth.Type == "none" || a.plugin.Meta.Auth.Type == "api_key" {
ctx["apiKey"] = key
}
return ctx, nil
}
func (a *TaskAdaptor) batchQueryContext(key, baseURL, proxy string, tasks []map[string]any) (map[string]any, error) {
ctx := map[string]any{"baseUrl": baseURL, "tasks": tasks}
auth, err := resolveAuth(a.plugin.Meta.Auth, key, proxy)
if err != nil {
if err := a.applyUpstreamCredentials(ctx, a.channelType(), key, proxy); err != nil {
return nil, err
}
ctx["auth"] = auth
ctx["authHeader"] = auth["authHeader"]
if a.plugin.Meta.Auth.Type == "" || a.plugin.Meta.Auth.Type == "none" || a.plugin.Meta.Auth.Type == "api_key" {
ctx["apiKey"] = key
}
return ctx, nil
}
@@ -1089,6 +1174,50 @@ func (a *TaskAdaptor) queryCredentials() (key, baseURL, proxy string) {
return a.info.ApiKey, a.info.ChannelBaseUrl, a.info.ChannelSetting.Proxy
}
// channelType is the executing channel's type when polling or content
// fetches run with channel metadata; zero otherwise, which is a vendor upstream.
func (a *TaskAdaptor) channelType() int {
if a.info == nil || !a.info.HasChannelMeta() {
return 0
}
return a.info.ChannelType
}
// channelSetting is the executing channel's settings when the adaptor runs
// with channel metadata; zero otherwise.
func (a *TaskAdaptor) channelSetting() kitdto.ChannelSettings {
if a.info == nil || !a.info.HasChannelMeta() {
return kitdto.ChannelSettings{}
}
return a.info.ChannelSetting
}
// applyUpstreamCredentials sets ctx.upstream together with the credentials
// every driver hook reads. A New API channel (type 60) is another gateway:
// the plugin must address its own prefixed native routes there, and the
// channel key is that gateway's token, so it is sent as a Bearer header for
// every auth type instead of running the plugin's vendor auth scheme.
func (a *TaskAdaptor) applyUpstreamCredentials(ctx map[string]any, channelType int, key, proxy string) error {
if channelType == constant.ChannelTypeNewAPI {
ctx["upstream"] = map[string]any{"kind": pluginruntime.UpstreamKindNewAPI}
ctx["auth"] = map[string]any{"authHeader": "Bearer " + key}
ctx["authHeader"] = "Bearer " + key
ctx["apiKey"] = key
return nil
}
ctx["upstream"] = map[string]any{"kind": pluginruntime.UpstreamKindVendor}
auth, err := resolveAuth(a.plugin.Meta.Auth, key, proxy)
if err != nil {
return err
}
ctx["auth"] = auth
ctx["authHeader"] = auth["authHeader"]
if a.plugin.Meta.Auth.Type == "" || a.plugin.Meta.Auth.Type == "none" || a.plugin.Meta.Auth.Type == "api_key" {
ctx["apiKey"] = key
}
return nil
}
func hookHTTPResponse(resp *http.Response) map[string]any {
headers := map[string]string{}
status := 0
@@ -1176,27 +1305,62 @@ func validateTaskArtifacts(value any) ([]channel.TaskArtifact, error) {
return artifacts, nil
}
// requestDescriptor runs a request-building hook. A plugin that preserves JSON
// order has its descriptor decoded from the JSON.stringify text of the result,
// with the body kept as that text (BodyText) for the upstream.
func (a *TaskAdaptor) requestDescriptor(ctx context.Context, hook string, args ...any) (requestDescriptor, error) {
if !a.plugin.Meta.PreservesJSONOrder() {
var descriptor requestDescriptor
err := a.plugin.Engine.CallInto(ctx, &descriptor, hook, args...)
return descriptor, err
}
var ordered orderedRequestDescriptor
if err := a.plugin.Engine.CallJSONInto(ctx, &ordered, hook, args...); err != nil {
return requestDescriptor{}, err
}
descriptor := requestDescriptor{
ResponseType: ordered.ResponseType, URL: ordered.URL, Method: ordered.Method, Headers: ordered.Headers,
Credentialless: ordered.Credentialless, Action: ordered.Action, Model: ordered.Model,
RewriteModel: ordered.RewriteModel, BodyType: ordered.BodyType, Parts: ordered.Parts,
}
switch {
case len(ordered.Body) == 0 || string(ordered.Body) == "null":
case ordered.Body[0] == '"':
// A string body is the plugin's own text, sent as it is.
var text string
if err := common.Unmarshal(ordered.Body, &text); err != nil {
return requestDescriptor{}, err
}
descriptor.Body = text
default:
descriptor.Body, descriptor.BodyText = ordered.Body, ordered.Body
}
return descriptor, nil
}
func (a *TaskAdaptor) buildSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*requestDescriptor, error) {
if a.submit != nil {
return a.submit, nil
}
started := time.Now()
value, err := a.plugin.Engine.Call(c.Request.Context(), "buildSubmitRequest", a.submitContext(c, info))
if err != nil {
logger.LogDebug(
c,
"task_plugin subsystem=adaptor event=build_submit_failed plugin=%q stage=build_submit_request reason=hook_failed elapsed_ms=%d",
a.plugin.Meta.Key,
time.Since(started).Milliseconds(),
)
return nil, err
// buildSubmitRequest runs before quota is reserved, so a failed fetch
// costs nothing; requests that start vendor work stay the descriptor.
fetchContext := c.Request.Context()
if info.HasChannelMeta() {
fetchContext = pluginruntime.WithFetcher(fetchContext, a.hookFetcher(info.ChannelBaseUrl, info.ChannelSetting.Proxy))
}
var descriptor requestDescriptor
if err = convert(value, &descriptor); err != nil {
descriptor, err := a.requestDescriptor(fetchContext, "buildSubmitRequest", a.submitContext(c, info))
if err != nil {
reason := "hook_failed"
var invalid *pluginruntime.ResultError
if errors.As(err, &invalid) {
reason = "invalid_descriptor"
}
logger.LogDebug(
c,
"task_plugin subsystem=adaptor event=build_submit_failed plugin=%q stage=build_submit_request reason=invalid_descriptor elapsed_ms=%d",
"task_plugin subsystem=adaptor event=build_submit_failed plugin=%q stage=build_submit_request reason=%s elapsed_ms=%d",
a.plugin.Meta.Key,
reason,
time.Since(started).Milliseconds(),
)
return nil, err
@@ -1282,7 +1446,11 @@ func (a *TaskAdaptor) submitContext(c *gin.Context, info *relaycommon.RelayInfo)
}
}
if taskRequest, exists := c.Get("task_request"); exists {
routeRequest.RequestBody = jsonValue(taskRequest)
routeRequest.RequestBody = taskRequest
}
a.requestBodyText = nil
if text, exists := c.Get(pluginruntime.ContextKeyRequestBodyText); exists && a.plugin.Meta.PreservesJSONOrder() {
a.requestBodyText, _ = text.(json.RawMessage)
}
if c.Request != nil {
if routeRequest.Path == "" {
@@ -1309,8 +1477,8 @@ func (a *TaskAdaptor) submitContext(c *gin.Context, info *relaycommon.RelayInfo)
if form, err := common.ParseMultipartFormReusable(c); err == nil {
defer form.RemoveAll()
for field, headers := range form.File {
for _, header := range headers {
files = append(files, map[string]any{"ref": "request_file:" + field, "field": field, "filename": header.Filename, "mimeType": header.Header.Get("Content-Type"), "size": header.Size})
for index, header := range headers {
files = append(files, map[string]any{"ref": pluginruntime.FileReference(field, index), "field": field, "filename": header.Filename, "mimeType": header.Header.Get("Content-Type"), "size": header.Size})
}
}
}
@@ -1323,7 +1491,25 @@ func (a *TaskAdaptor) submitContext(c *gin.Context, info *relaycommon.RelayInfo)
a.files = append(a.files[:0], files...)
}
ctx := routeRequest.JSValue()
ctx["requestBody"] = jsonValue(routeRequest.RequestBody)
// Hooks never write into the request body, so the same object normalizes
// to the same value on every hook call of this request.
requestBody := routeRequest.RequestBody
source, isObject := requestBody.(map[string]any)
if a.requestBodyText != nil {
// Driver hooks read the request body, so the engine parses the text
// in place for each of them, members in order.
requestBody = pluginruntime.RawJSON(a.requestBodyText)
} else if isObject && a.hookRequestSource != nil && reflect.ValueOf(source).UnsafePointer() == reflect.ValueOf(a.hookRequestSource).UnsafePointer() {
requestBody = a.hookRequestBody
} else {
if !isPlainJSONValue(requestBody, 0) {
requestBody = jsonValue(requestBody)
}
if isObject {
a.hookRequestSource, a.hookRequestBody = source, requestBody
}
}
ctx["requestBody"] = requestBody
ctx["requestHeaders"] = requestHeaders
ctx["files"] = files
ctx["action"] = info.Action
@@ -1352,21 +1538,13 @@ func (a *TaskAdaptor) submitContext(c *gin.Context, info *relaycommon.RelayInfo)
ctx["upstreamModel"] = info.UpstreamModelName
ctx["baseUrl"] = info.ChannelBaseUrl
ctx["userSetting"] = info.UserSetting
proxy := ""
proxy = info.ChannelSetting.Proxy
if auth, err := resolveAuth(a.plugin.Meta.Auth, info.ApiKey, proxy); err == nil {
ctx["auth"] = auth
ctx["authHeader"] = auth["authHeader"]
if a.plugin.Meta.Auth.Type == "" || a.plugin.Meta.Auth.Type == "none" || a.plugin.Meta.Auth.Type == "api_key" {
ctx["apiKey"] = info.ApiKey
}
} else {
if err := a.applyUpstreamCredentials(ctx, info.ChannelType, info.ApiKey, info.ChannelSetting.Proxy); err != nil {
ctx["authError"] = err.Error()
}
return ctx
}
func (a *TaskAdaptor) usageRatios(ctx context.Context, modelName, hook string, args ...any) (map[string]float64, error) {
func (a *TaskAdaptor) usageRatios(ctx context.Context, models []string, hook string, args ...any) (map[string]float64, error) {
if !a.hasHook(ctx, hook) {
return nil, nil
}
@@ -1384,7 +1562,7 @@ func (a *TaskAdaptor) usageRatios(ctx context.Context, modelName, hook string, a
logger.LogDebug(ctx, "task_plugin subsystem=adaptor event=usage_hook_failed plugin=%q hook=%q reason=result_not_object elapsed_ms=%d", a.plugin.Meta.Key, hook, time.Since(started).Milliseconds())
return nil, fmt.Errorf("plugin usage hook must return an object")
}
ratios, err := a.validatedUsageRatios(facts, modelName)
_, ratios, err := a.validatedUsageRatios(facts, models...)
if err != nil {
logger.LogDebug(ctx, "task_plugin subsystem=adaptor event=usage_hook_failed plugin=%q hook=%q reason=invalid_usage elapsed_ms=%d", a.plugin.Meta.Key, hook, time.Since(started).Milliseconds())
return nil, err
@@ -1401,8 +1579,8 @@ func (a *TaskAdaptor) usageRatios(ctx context.Context, modelName, hook string, a
return ratios, nil
}
func (a *TaskAdaptor) validateResolvedUsageRequest(request any, modelName string) error {
schema, _ := a.plugin.Meta.UsageForModel(modelName)
func (a *TaskAdaptor) validateResolvedUsageRequest(request any, models ...string) error {
schema, _ := a.plugin.Meta.UsageForModels(models...)
return a.validateResolvedUsageValue(jsonValue(request), schema)
}
@@ -1410,11 +1588,21 @@ func (a *TaskAdaptor) validateResolvedUsageValue(value any, usageSchema map[stri
switch typed := value.(type) {
case map[string]any:
for key, item := range typed {
limit, canonical := canonicalUsageLimit(key)
// A duration of -1 asks the vendor to pick the length. Plugins that
// declare duration-auto@1 reserve the longest duration for it and
// settle on the usage the vendor reports; usage facts stay
// non-negative.
if canonical && limit == relaycommon.MaxTaskDurationSeconds && a.plugin.Meta.AcceptsAutoDuration() {
if number, _ := usageNumber(item, true); number == -1 {
continue
}
}
if schema, declared := usageSchema[key]; declared {
if _, err := validateUsageValue(item, schema, true); err != nil {
return err
}
} else if limit, canonical := canonicalUsageLimit(key); canonical {
} else if canonical {
if err := validateUsageLimit(item, limit, true); err != nil {
return err
}
@@ -1433,17 +1621,22 @@ func (a *TaskAdaptor) validateResolvedUsageValue(value any, usageSchema map[stri
return nil
}
func (a *TaskAdaptor) validatedUsageRatios(facts map[string]any, modelName string) (map[string]float64, error) {
usageSchema, _ := a.plugin.Meta.UsageForModel(modelName)
// validatedUsageRatios returns the normalized facts in a new map because the
// hook result may be a host value, such as the request body, that must stay
// unchanged.
func (a *TaskAdaptor) validatedUsageRatios(facts map[string]any, models ...string) (map[string]any, map[string]float64, error) {
usageSchema, _ := a.plugin.Meta.UsageForModels(models...)
validated := make(map[string]any, len(facts))
ratios := make(map[string]float64)
for key, value := range facts {
validated[key] = value
if schema, declared := usageSchema[key]; declared {
number, err := validateUsageValue(value, schema, false)
if err != nil {
return nil, err
return nil, nil, err
}
if schema.Type == "number" {
facts[key] = number
validated[key] = number
if number > 0 {
ratios[key] = number
}
@@ -1462,20 +1655,20 @@ func (a *TaskAdaptor) validatedUsageRatios(facts map[string]any, modelName strin
limit = relaycommon.MaxTaskDurationSeconds
}
if err := validateUsageNumberLimit(number, limit); err != nil {
return nil, err
return nil, nil, err
}
// Keep numeric facts usable by existing expressions even when the
// selected profile no longer declares that legacy extension field.
facts[key] = number
validated[key] = number
if number > 0 {
ratios[key] = number
}
}
return ratios, nil
return validated, ratios, nil
}
func (a *TaskAdaptor) validatedCompletionUsageFacts(facts any, modelName string) (map[string]any, error) {
usageSchema, _ := a.plugin.Meta.UsageForModel(modelName)
func (a *TaskAdaptor) validatedCompletionUsageFacts(facts any, models ...string) (map[string]any, error) {
usageSchema, _ := a.plugin.Meta.UsageForModels(models...)
if facts == nil {
return nil, nil
}
@@ -1602,8 +1795,10 @@ func usageNumber(value any, allowNumericString bool) (float64, bool) {
}
}
var usageKeySeparators = strings.NewReplacer("_", "", "-", "")
func canonicalUsageLimit(key string) (int, bool) {
normalized := strings.NewReplacer("_", "", "-", "").Replace(strings.ToLower(key))
normalized := usageKeySeparators.Replace(strings.ToLower(key))
switch normalized {
case "duration", "durationseconds", "second", "seconds":
return relaycommon.MaxTaskDurationSeconds, true
@@ -1615,13 +1810,13 @@ func canonicalUsageLimit(key string) (int, bool) {
}
func (a *TaskAdaptor) logRejectedUsage(hook string, _ error) {
common.SysError(fmt.Sprintf("task plugin %s rejected invalid %s billing facts", a.plugin.Meta.Key, hook))
common.SysError(common.LogText("task plugin %s rejected invalid %s billing facts", a.plugin.Meta.Key, hook))
}
func (a *TaskAdaptor) hasHook(ctx context.Context, hook string) bool {
has, err := a.plugin.Engine.HasExport(ctx, hook)
return err == nil && has
func (a *TaskAdaptor) hasHook(_ context.Context, hook string) bool {
return a.plugin.Engine.HasExport(hook)
}
func convert(value any, target any) error {
data, err := common.Marshal(value)
if err != nil {
@@ -1645,8 +1840,45 @@ func jsonValue(value any) any {
return normalized
}
// Already-parsed JSON and Sobek's plain exported values do not need another
// encode/decode pass. Copy containers to isolate hooks, preserving the codec's
// isPlainJSONValue reports whether hooks can take value as is: it reaches
// JavaScript exactly as the codec's decoding of it would, and the engine never
// writes back into it. Integers count, since hooks see the same numbers; a nil
// slice does not, since it would reach hooks as [] instead of null.
func isPlainJSONValue(value any, depth int) bool {
if depth > 64 {
return false
}
switch typed := value.(type) {
case nil, bool, int64, int:
return true
case string:
return utf8.ValidString(typed)
case float64:
return !math.IsNaN(typed) && !math.IsInf(typed, 0)
case map[string]any:
for key, item := range typed {
if !utf8.ValidString(key) || !isPlainJSONValue(item, depth+1) {
return false
}
}
return true
case []any:
if typed == nil {
return false
}
for _, item := range typed {
if !isPlainJSONValue(item, depth+1) {
return false
}
}
return true
default:
return false
}
}
// Already-parsed JSON and the engine's plain exported values do not need another
// encode/decode pass. Copy containers for host-side edits, preserving the codec's
// float64 numbers and nulls. Other Go types still use the configured codec.
func cloneJSONValue(value any, depth int) (any, bool) {
if depth > 64 {
+596 -36
View File
@@ -5,8 +5,10 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"math"
"mime"
"mime/multipart"
"net/http"
"net/http/httptest"
@@ -82,9 +84,10 @@ func TestTaskAdaptorRejectsDeprecatedClientResponse(t *testing.T) {
}
func TestTaskAdaptorBuildsMultipartFromOpaqueFileReference(t *testing.T) {
// The plugin forwards the client's Content-Type, whose boundary is not the one of the rebuilt body.
source := `
export const meta = {apiVersion:1,key:"multipart",name:"Multipart",version:"1.0.0",author:{name:"Test"},models:["m"],fetchMode:"per_task"};
export function buildSubmitRequest(ctx) { return {url:ctx.baseUrl+"/submit",bodyType:"multipart",parts:[{name:"model",value:"m"},{name:"input_reference",fileRef:ctx.files[0].ref}]}; }
export function buildSubmitRequest(ctx) { return {url:ctx.baseUrl+"/submit",headers:{"Content-Type":ctx.requestHeaders["Content-Type"]},bodyType:"multipart",parts:[{name:"model",value:"m"},{name:"input_reference",fileRef:ctx.files[0].ref}]}; }
export function parseSubmitResponse(ctx,r){return {taskId:"1"}} export function buildQueryRequest(){return {url:"https://example.com"}} export function parseTaskResult(){return {status:"SUCCESS"}}
`
plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
@@ -107,7 +110,13 @@ export function parseSubmitResponse(ctx,r){return {taskId:"1"}} export function
require.NoError(t, err)
requestBytes, err := io.ReadAll(body)
require.NoError(t, err)
reader := multipart.NewReader(bytes.NewReader(requestBytes), strings.TrimPrefix(c.GetHeader("Content-Type"), "multipart/form-data; boundary="))
upstreamRequest := httptest.NewRequest(http.MethodPost, "https://provider.example/submit", bytes.NewReader(requestBytes))
require.NoError(t, adaptor.BuildRequestHeader(c, upstreamRequest, info))
mediaType, params, err := mime.ParseMediaType(upstreamRequest.Header.Get("Content-Type"))
require.NoError(t, err)
assert.Equal(t, "multipart/form-data", mediaType)
require.NotEmpty(t, params["boundary"])
reader := multipart.NewReader(bytes.NewReader(requestBytes), params["boundary"])
form, err := reader.ReadForm(1024)
require.NoError(t, err)
assert.Equal(t, []string{"m"}, form.Value["model"])
@@ -129,7 +138,8 @@ export function buildSubmitRequest(ctx) {
prompt:"p",
image:{__fileRef:ctx.files[0].ref,encoding:"base64"},
nested:{items:[{__fileRef:ctx.files[0].ref,encoding:"dataUrl",mimeType:"image/png"}]},
dataUrl:{__fileRef:ctx.files[0].ref,encoding:"dataUrl"}
dataUrl:{__fileRef:ctx.files[0].ref,encoding:"dataUrl"},
refs:ctx.files.map(function(file){return file.ref;})
}};
}
export function parseSubmitResponse(){return {taskId:"1"}} export function buildQueryRequest(){return {url:"https://example.com"}} export function parseTaskResult(){return {status:"SUCCESS"}}
@@ -154,6 +164,57 @@ export function parseSubmitResponse(){return {taskId:"1"}} export function build
require.Len(t, items, 1)
assert.Equal(t, "data:image/png;base64,"+encoded, items[0])
assert.Equal(t, "data:image/jpeg;base64,"+encoded, decoded["dataUrl"])
assert.Equal(t, []any{"request_file:input_reference"}, decoded["refs"])
t.Run("repeated field files are addressed by index", func(t *testing.T) {
second := base64.StdEncoding.EncodeToString([]byte("second-bytes"))
source := `
export const meta = {apiVersion:1,key:"json-inline-repeat",name:"JSON Inline Repeat",version:"1.0.0",author:{name:"Test"},models:["m"],fetchMode:"per_task"};
export function buildSubmitRequest(ctx) {
return {url:ctx.baseUrl+"/submit",body:{
refs:ctx.files.map(function(file){return file.ref;}),
images:ctx.files.map(function(file){return {__fileRef:file.ref,encoding:"base64"};}),
missing:{__fileRef:"request_file:image[]#2",encoding:"base64"}
}};
}
export function parseSubmitResponse(){return {taskId:"1"}} export function buildQueryRequest(){return {url:"https://example.com"}} export function parseTaskResult(){return {status:"SUCCESS"}}
`
plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
require.NoError(t, err)
adaptor := New(plugin)
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example"}, TaskRelayInfo: &relaycommon.TaskRelayInfo{}}
adaptor.Init(info)
var input bytes.Buffer
writer := multipart.NewWriter(&input)
for _, content := range []string{fileBytes, "second-bytes"} {
file, createErr := writer.CreateFormFile("image[]", "ref.png")
require.NoError(t, createErr)
_, err = file.Write([]byte(content))
require.NoError(t, err)
}
require.NoError(t, writer.Close())
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/edits", bytes.NewReader(input.Bytes()))
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
c.Set("task_request", map[string]any{"prompt": "p"})
_, err = adaptor.BuildRequestBody(c, info)
require.ErrorContains(t, err, `unknown file reference "request_file:image[]#2"`, "an index beyond the uploaded files is rejected")
source = strings.Replace(source, `,
missing:{__fileRef:"request_file:image[]#2",encoding:"base64"}`, "", 1)
plugin, err = pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
require.NoError(t, err)
adaptor = New(plugin)
adaptor.Init(info)
body, err := adaptor.BuildRequestBody(c, info)
require.NoError(t, err)
requestBytes, err := io.ReadAll(body)
require.NoError(t, err)
var decoded map[string]any
require.NoError(t, common.Unmarshal(requestBytes, &decoded))
assert.Equal(t, []any{"request_file:image[]", "request_file:image[]#1"}, decoded["refs"])
assert.Equal(t, []any{encoded, second}, decoded["images"])
})
}
func TestTaskAdaptorJSONFilePlaceholderErrors(t *testing.T) {
@@ -351,6 +412,95 @@ export function buildSubmitRequest(ctx){return {url:ctx.baseUrl+"/submit"}} expo
assert.Contains(t, taskErr.Message, "must not return renderer")
}
func TestTaskAdaptorMasksChannelAddressInBuildFailure(t *testing.T) {
source := `
export const meta = {apiVersion:1,key:"build-fail",name:"Build Fail",version:"1.0.0",author:{name:"Test"},models:["m"],fetchMode:"per_task",allowedHosts:["assets.corp.internal"]};
export function buildSubmitRequest(ctx){throw new Error("utils.fetch to private.example.com failed: dial tcp: lookup private.example.com: no such host; utils.fetch to assets.corp.internal failed: dial tcp: lookup assets.corp.internal: no such host")}
export function parseSubmitResponse(){return {taskId:"one"}} export function buildQueryRequest(){return {}} export function parseTaskResult(){return {status:"SUCCESS"}}
`
plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
require.NoError(t, err)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/video/generations", nil)
c.Set("task_request", map[string]any{"model": "m"})
common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, "https://private.example.com")
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://private.example.com"}, TaskRelayInfo: &relaycommon.TaskRelayInfo{}, OriginModelName: "m"}
adaptor := New(plugin)
adaptor.Init(info)
taskErr := adaptor.ValidateRequestAndSetAction(c, info)
require.NotNil(t, taskErr)
assert.Equal(t, "plugin_request_invalid", taskErr.Code)
assert.True(t, taskErr.LocalError)
assert.Contains(t, taskErr.Message, "no such host")
assert.NotContains(t, taskErr.Message, "private.example.com")
assert.NotContains(t, taskErr.Message, "assets.corp.internal")
}
const orderedProtocolPlugin = `
export const meta = {apiVersion:1,key:"ordered",name:"Ordered",version:"1.0.0",author:{name:"Test"},models:["m"],fetchMode:"per_task",requiredCapabilities:["json-order@1"],protocols:[{name:"openai_responses",supports:["sync"]}]};
export const protocols = {openai_responses:{decodeRequest:function(ctx){const b = ctx.body.value; return {kind:"submit",model:ctx.model,requestBody:{model:b.model,state:b.state,questions:b.questions}};},renderFinal:function(){return {};}}};
export function buildSubmitRequest(ctx){const b = ctx.requestBody; return {url:ctx.baseUrl+"/submit",body:{model:b.model,state:b.state,questions:b.questions}}}
export function parseSubmitResponse(){return {taskId:"one"}} export function buildQueryRequest(){return {}} export function parseTaskResult(){return {status:"SUCCESS"}}
`
// A plugin declaring json-order@1 forwards the members in the order the client
// sent them, through the final decode, the request body hooks receive and the
// upstream body, while billing bounds still read the decoded value; other
// plugins keep the host codec's sorted encoding.
func TestTaskAdaptorJSONOrderCapability(t *testing.T) {
client := `{"model":"m","state":{"zeta":"1","alpha":"2","mid":"&<>"},"questions":{"q2":{"type":"noul"},"q1":{"type":"noul"}}}`
undeclared := strings.Replace(orderedProtocolPlugin, `requiredCapabilities:["json-order@1"],`, "", 1)
cases := []struct {
name string
source string
client string
want string
status int
}{
{name: "declared keeps the client's order", source: orderedProtocolPlugin, client: client,
want: `{"model":"m","state":{"zeta":"1","alpha":"2","mid":"&<>"},"questions":{"q2":{"type":"noul"},"q1":{"type":"noul"}}}`},
{name: "undeclared stays sorted", source: undeclared, client: client,
want: `{"model":"m","questions":{"q1":{"type":"noul"},"q2":{"type":"noul"}},"state":{"alpha":"2","mid":"\u0026\u003c\u003e","zeta":"1"}}`},
{name: "declared still bounds billing quantities", source: orderedProtocolPlugin,
client: `{"model":"m","state":{"duration":3601},"questions":{}}`, status: http.StatusBadRequest},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
plugin, err := pluginruntime.NewRegistry().Register(tc.source, pluginruntime.Options{})
require.NoError(t, err)
var value any
require.NoError(t, common.Unmarshal([]byte(tc.client), &value))
protocolContext := pluginruntime.ProtocolRequestContext{
RouteRequestContext: pluginruntime.RouteRequestContext{Body: map[string]any{"kind": "json", "value": value}, BodyText: json.RawMessage(tc.client)},
Protocol: "openai_responses", Model: "m",
}
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
c.Set(pluginruntime.ContextKeyPinnedEndpoint, pluginruntime.PinnedEndpoint{Plugin: plugin, Protocol: "openai_responses", Model: "m"})
c.Set(pluginruntime.ContextKeyProtocolRequest, protocolContext)
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example"}, TaskRelayInfo: &relaycommon.TaskRelayInfo{}, OriginModelName: "m"}
adaptor := New(plugin)
adaptor.Init(info)
taskErr := adaptor.ValidateRequestAndSetAction(c, info)
if tc.status != 0 {
require.NotNil(t, taskErr)
assert.Equal(t, tc.status, taskErr.StatusCode)
assert.Equal(t, "plugin_usage_invalid", taskErr.Code)
return
}
require.Nil(t, taskErr)
body, err := adaptor.BuildRequestBody(c, info)
require.NoError(t, err)
raw, err := io.ReadAll(body)
require.NoError(t, err)
assert.Equal(t, tc.want, string(raw))
})
}
}
func TestTaskAdaptorBuildContentRequestHookAndMissingFallback(t *testing.T) {
source := strings.Replace(mockPlugin, `export function listArtifacts() { return []; }
export function buildContentRequest() { throw new Error("artifact_not_found"); }`, `export function listArtifacts(task) { return [{key: "video", type: "video", mimeType: "video/mp4"}]; }
@@ -376,7 +526,7 @@ export function buildContentRequest(ctx) {
artifacts, err := adaptor.ListArtifacts(task)
require.NoError(t, err)
require.Equal(t, []channel.TaskArtifact{{Key: "video", Type: "video", MimeType: "video/mp4"}}, artifacts)
descriptor, err := adaptor.BuildContentRequest(task, "video", channel.TaskArtifactClientRequest{Method: http.MethodHead})
descriptor, err := adaptor.BuildContentRequest(t.Context(), task, "video", channel.TaskArtifactClientRequest{Method: http.MethodHead})
require.NoError(t, err)
require.NotNil(t, descriptor)
assert.Equal(t, "https://provider.example/content/video", descriptor.URL)
@@ -396,7 +546,7 @@ export function parseTaskResult(){return {status:"SUCCESS"};}
artifacts, err = fallback.ListArtifacts(&model.Task{})
require.NoError(t, err)
assert.Nil(t, artifacts)
descriptor, err = fallback.BuildContentRequest(&model.Task{}, "video", channel.TaskArtifactClientRequest{Method: http.MethodGet})
descriptor, err = fallback.BuildContentRequest(t.Context(), &model.Task{}, "video", channel.TaskArtifactClientRequest{Method: http.MethodGet})
require.NoError(t, err)
assert.Nil(t, descriptor)
}
@@ -437,6 +587,7 @@ export function buildContentRequest(ctx) { return {url:"https://cdn.example/vide
adaptor := New(plugin)
adaptor.Init(&relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example"}})
descriptor, err := adaptor.BuildContentRequest(
t.Context(),
&model.Task{TaskID: "task", Data: []byte(`{}`)},
"video",
channel.TaskArtifactClientRequest{Method: http.MethodGet},
@@ -447,6 +598,59 @@ export function buildContentRequest(ctx) { return {url:"https://cdn.example/vide
assert.Equal(t, "https://cdn.example/video.mp4", descriptor.URL)
}
func TestTaskAdaptorHookFetchReachesRequestHostsOnly(t *testing.T) {
service.InitHttpClient()
extra := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"from":"extra"}`))
}))
defer extra.Close()
base := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/info":
assert.Equal(t, "Bearer key", r.Header.Get("Authorization"))
_, _ = w.Write([]byte(`{"from":"base"}`))
case "/moved":
http.Redirect(w, r, extra.URL+"/elsewhere", http.StatusFound)
default:
t.Errorf("unexpected request %s", r.URL.Path)
}
}))
defer base.Close()
source := strings.Replace(mockPlugin, `fetchMode: "per_task",`, `fetchMode: "per_task", allowedHosts: ["`+strings.TrimPrefix(extra.URL, "http://")+`"],`, 1)
source = strings.Replace(source, `export function listArtifacts() { return []; }
export function buildContentRequest() { throw new Error("artifact_not_found"); }`, `export function listArtifacts() { return [{key: "video", type: "video"}]; }
export function buildContentRequest(ctx) {
const base = utils.fetch({url: ctx.baseUrl + "/info", headers: {Authorization: "Bearer " + ctx.apiKey}});
const extra = utils.fetch({url: "http://" + meta.allowedHosts[0] + "/info"});
const moved = utils.fetch({url: ctx.baseUrl + "/moved"});
let denied = "";
try { utils.fetch({url: "https://other.example/info"}); } catch (e) { denied = e.message; }
return {url: ctx.baseUrl + "/content", method: "GET", headers: {
"X-Base": base.body.from, "X-Extra": extra.body.from, "X-Moved": moved.status + " " + moved.headers.Location, "X-Denied": denied,
}};
}`, 1)
source = strings.Replace(source, `render: function(ctx, task) { return {id: task.task_id, status: "completed"}; }`,
`render: function(ctx, task) { return utils.fetch({url: "https://provider.example/info"}).body; }`, 1)
plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
require.NoError(t, err)
adaptor := New(plugin)
adaptor.Init(&relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: base.URL, ApiKey: "key"}})
descriptor, err := adaptor.BuildContentRequest(t.Context(), &model.Task{TaskID: "task", Data: []byte(`{}`)}, "video", channel.TaskArtifactClientRequest{Method: http.MethodGet})
require.NoError(t, err)
require.NotNil(t, descriptor)
assert.Equal(t, map[string]string{
"X-Base": "base",
"X-Extra": "extra",
"X-Moved": "302 " + extra.URL + "/elsewhere",
"X-Denied": `utils.fetch to other.example failed: plugin request host "other.example" is not allowed`,
}, descriptor.Headers)
// Renderers run on every client read and may not fetch.
_, err = adaptor.ConvertToOpenAIVideo(&model.Task{TaskID: "task_public", Status: model.TaskStatusSuccess})
assert.ErrorContains(t, err, "utils.fetch is not available in protocols.openai_video.render")
}
func TestTaskAdaptorMapsJSContract(t *testing.T) {
gin.SetMode(gin.TestMode)
service.InitHttpClient()
@@ -610,13 +814,22 @@ func TestTaskAdaptorPreservesSoraVideoResponseFields(t *testing.T) {
}
func TestTaskAdaptorRejectsNonObjectOpenAIVideoRendererOutput(t *testing.T) {
for _, value := range []string{"null", "[]", `"video"`, "42", "false"} {
for value, message := range map[string]string{
"null": "plugin returned an invalid OpenAI video object",
"undefined": "plugin returned an invalid OpenAI video object",
"[]": "plugin returned an invalid OpenAI video object",
`"video"`: "plugin returned an invalid OpenAI video object",
"42": "plugin returned an invalid OpenAI video object",
"false": "plugin returned an invalid OpenAI video object",
"{seconds: NaN}": "json: unsupported value: NaN",
"[Infinity]": "json: unsupported value: +Inf",
} {
t.Run(value, func(t *testing.T) {
source := strings.Replace(mockPlugin, `return {id: task.task_id, status: "completed"};`, "return "+value+";", 1)
plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
require.NoError(t, err)
_, err = New(plugin).ConvertToOpenAIVideo(&model.Task{TaskID: "task_public"})
require.ErrorContains(t, err, "invalid OpenAI video object")
require.EqualError(t, err, message)
})
}
}
@@ -808,6 +1021,53 @@ export function extractUsageOnComplete(task, result, body) { return (body || {})
assert.Nil(t, ratios)
})
t.Run("duration -1 passes only for plugins that declare duration-auto@1", func(t *testing.T) {
declaring, err := pluginruntime.NewRegistry().Register(strings.Replace(source, `fetchMode: "per_task",`, `fetchMode: "per_task", requiredCapabilities: ["duration-auto@1"],`, 1), pluginruntime.Options{})
require.NoError(t, err)
for _, tc := range []struct {
name string
declaring bool
body map[string]any
accepted bool
}{
{"undeclared plugins keep rejecting it", false, map[string]any{"metadata": map[string]any{"duration": -1}}, false},
{"vendor parameters and declared usage fields", true, map[string]any{
"duration": -1,
"metadata": map[string]any{"seconds": "-1", "parameters": map[string]any{"durationSeconds": -1}},
}, true},
{"other negative durations", true, map[string]any{"metadata": map[string]any{"duration": -2}}, false},
{"durations above the host limit", true, map[string]any{"metadata": map[string]any{"duration": relaycommon.MaxTaskDurationSeconds + 1}}, false},
{"negative counts", true, map[string]any{"metadata": map[string]any{"count": -1}}, false},
} {
t.Run(tc.name, func(t *testing.T) {
adaptor, context, info := newRequest(t, tc.body)
if tc.declaring {
adaptor = New(declaring)
adaptor.Init(info)
}
taskErr := adaptor.ValidateRequestAndSetAction(context, info)
if tc.accepted {
assert.Nil(t, taskErr)
return
}
require.NotNil(t, taskErr)
assert.Equal(t, "plugin_usage_invalid", taskErr.Code)
})
}
adaptor, context, info := newRequest(t, map[string]any{
"metadata": map[string]any{"duration": -1},
"hookUsageEntries": []any{map[string]any{"name": "duration", "value": -1}},
})
adaptor = New(declaring)
adaptor.Init(info)
require.Nil(t, adaptor.ValidateRequestAndSetAction(context, info))
_, err = adaptor.ExtractUsageFactsValidated(context, info)
assert.Error(t, err, "usage facts stay non-negative")
_, err = adaptor.EstimateBillingValidated(context, info)
assert.Error(t, err, "billing ratios stay non-negative")
})
t.Run("declared token facts use int32 saturation instead of duration cap", func(t *testing.T) {
adaptor, context, info := newRequest(t, map[string]any{
"hookUsageEntries": []any{
@@ -1100,6 +1360,26 @@ func TestSubmitContextOriginTasksNilDataOnInvalidJSON(t *testing.T) {
assert.Nil(t, originTasks[0]["data"])
}
func TestSubmitContextNormalizesRequestBodyOnEveryCall(t *testing.T) {
plugin, err := pluginruntime.NewRegistry().Register(mockPlugin, pluginruntime.Options{})
require.NoError(t, err)
adaptor := New(plugin)
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example"}, TaskRelayInfo: &relaycommon.TaskRelayInfo{}}
adaptor.Init(info)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
c.Set("task_request", map[string]any{"prompt": string([]byte{0xff}), "count": int64(math.MaxInt64)})
normalized := map[string]any{"prompt": "�", "count": float64(math.MaxInt64)}
assert.Equal(t, normalized, adaptor.submitContext(c, info)["requestBody"])
assert.Equal(t, normalized, adaptor.submitContext(c, info)["requestBody"])
assert.Equal(t, normalized, adaptor.submitContext(nil, info)["requestBody"])
replaced := map[string]any{"prompt": "second", "count": int64(2)}
c.Set("task_request", replaced)
assert.Equal(t, replaced, adaptor.submitContext(c, info)["requestBody"])
}
func TestTaskAdaptorRejectsRequestHostOverride(t *testing.T) {
source := strings.Replace(mockPlugin, `ctx.baseUrl + "/submit"`, `"https://attacker.example/steal"`, 1)
plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{Key: "mock-task", Version: "1.0.0"})
@@ -1464,10 +1744,10 @@ export function extractUsageOnComplete(ctx,result,body){return body.usage;}
`
plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
require.NoError(t, err)
newRequest := func(t *testing.T, upstream string, body map[string]any) (*TaskAdaptor, *gin.Context, *relaycommon.RelayInfo) {
newRequest := func(t *testing.T, origin, upstream string, body map[string]any) (*TaskAdaptor, *gin.Context, *relaycommon.RelayInfo) {
t.Helper()
info := &relaycommon.RelayInfo{
OriginModelName: "public-alias",
OriginModelName: origin,
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: upstream, ChannelBaseUrl: "https://provider.example",
},
@@ -1482,18 +1762,20 @@ export function extractUsageOnComplete(ctx,result,body){return body.usage;}
}
for _, tc := range []struct {
name, upstream, rewrite, mode string
units float64
wantError bool
name, origin, upstream, rewrite, mode string
units float64
wantError bool
}{
{"mapped image", "image", "", "image", 2, false},
{"mapped video token units", "video", "", "video", 500000, false},
{"rewritten model", "image", "video", "video", 500000, false},
{"image count ceiling", "image", "", "image", float64(dto.MaxImageN + 1), true},
{"profile enum", "image", "", "video", 2, true},
{"mapped image", "public-alias", "image", "", "image", 2, false},
{"mapped video token units", "public-alias", "video", "", "video", 500000, false},
{"rewritten model", "public-alias", "image", "video", "video", 500000, false},
{"image count ceiling", "public-alias", "image", "", "image", float64(dto.MaxImageN + 1), true},
{"profile enum", "public-alias", "image", "", "video", 2, true},
{"endpoint upstream keeps the declared origin profile", "image", "ep-endpoint", "", "image", 2, false},
{"endpoint upstream enforces the origin count ceiling", "image", "ep-endpoint", "", "image", float64(dto.MaxImageN + 1), true},
} {
t.Run(tc.name, func(t *testing.T) {
adaptor, c, info := newRequest(t, tc.upstream, map[string]any{
adaptor, c, info := newRequest(t, tc.origin, tc.upstream, map[string]any{
"hookUnits": tc.units, "hookMode": tc.mode, "rewriteTo": tc.rewrite,
})
require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))
@@ -1514,7 +1796,7 @@ export function extractUsageOnComplete(ctx,result,body){return body.usage;}
}
t.Run("request revalidated after model rewrite", func(t *testing.T) {
adaptor, c, info := newRequest(t, "video", map[string]any{
adaptor, c, info := newRequest(t, "public-alias", "video", map[string]any{
"rewriteTo": "image", "metadata": map[string]any{"units": dto.MaxImageN + 1},
})
taskErr := adaptor.ValidateRequestAndSetAction(c, info)
@@ -1523,7 +1805,7 @@ export function extractUsageOnComplete(ctx,result,body){return body.usage;}
})
t.Run("request enum belongs to the rewritten model", func(t *testing.T) {
adaptor, c, info := newRequest(t, "video", map[string]any{
adaptor, c, info := newRequest(t, "public-alias", "video", map[string]any{
"rewriteTo": "image", "mode": "image", "units": 2,
})
require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))
@@ -1531,7 +1813,7 @@ export function extractUsageOnComplete(ctx,result,body){return body.usage;}
})
t.Run("completion selects each persisted model including legacy origin fallback", func(t *testing.T) {
adaptor, _, _ := newRequest(t, "image", map[string]any{})
adaptor, _, _ := newRequest(t, "public-alias", "image", map[string]any{})
for _, tc := range []struct {
upstream, origin, mode string
units float64
@@ -1542,6 +1824,8 @@ export function extractUsageOnComplete(ctx,result,body){return body.usage;}
{"", "video", "video", 500000, true},
{"image", "public-alias", "image", float64(dto.MaxImageN + 1), false},
{"video", "public-alias", "image", 1, false},
{"ep-endpoint", "image", "image", 2, true},
{"ep-endpoint", "image", "image", float64(dto.MaxImageN + 1), false},
} {
task := &model.Task{Properties: model.Properties{OriginModelName: tc.origin, UpstreamModelName: tc.upstream}}
body, err := common.Marshal(map[string]any{"usage": map[string]any{"units": tc.units, "mode": tc.mode}})
@@ -1561,7 +1845,7 @@ export function extractUsageOnComplete(ctx,result,body){return body.usage;}
`export function extractUsageOnComplete(ctx,result,body){return body.usage;}`,
`export function extractUsageOnComplete(){return {legacyRatio:3};}`, 1), pluginruntime.Options{})
require.NoError(t, err)
_, _, info := newRequest(t, "image", map[string]any{})
_, _, info := newRequest(t, "public-alias", "image", map[string]any{})
adaptor := New(updated)
adaptor.Init(info)
result, err := adaptor.ParseTaskResult(&model.Task{Properties: model.Properties{UpstreamModelName: "image"}},
@@ -1574,20 +1858,23 @@ export function extractUsageOnComplete(ctx,result,body){return body.usage;}
})
t.Run("mixed batch selects schema by task rather than adaptor", func(t *testing.T) {
adaptor, _, _ := newRequest(t, "image", map[string]any{})
adaptor, _, _ := newRequest(t, "public-alias", "image", map[string]any{})
tasks := []*model.Task{
{TaskID: "video-task", Properties: model.Properties{UpstreamModelName: "video"}},
{TaskID: "image-task", Properties: model.Properties{UpstreamModelName: "image"}},
{TaskID: "invalid-image", Properties: model.Properties{UpstreamModelName: "image"}},
{TaskID: "endpoint-image", Properties: model.Properties{OriginModelName: "image", UpstreamModelName: "ep-endpoint"}},
}
body := []byte(`{"items":[
{"taskId":"image-task","status":"SUCCESS","data":{"usage":{"units":2,"mode":"image"}}},
{"taskId":"video-task","status":"SUCCESS","data":{"usage":{"units":500000,"mode":"video"}}},
{"taskId":"invalid-image","status":"SUCCESS","data":{"usage":{"units":129,"mode":"image"}}}
{"taskId":"invalid-image","status":"SUCCESS","data":{"usage":{"units":129,"mode":"image"}}},
{"taskId":"endpoint-image","status":"SUCCESS","data":{"usage":{"units":129,"mode":"image"}}}
]}`)
results, err := adaptor.ParseBatchResult(tasks, &http.Response{StatusCode: http.StatusOK}, body)
require.NoError(t, err)
require.Len(t, results, 3)
require.Len(t, results, 4)
assert.Nil(t, results["endpoint-image"].TaskInfo.UsageFacts, "endpoint upstream must keep the origin profile's count ceiling")
assert.Equal(t, map[string]any{"units": 500000.0, "mode": "video"}, results["video-task"].TaskInfo.UsageFacts)
assert.Equal(t, map[string]any{"units": 2.0, "mode": "image"}, results["image-task"].TaskInfo.UsageFacts)
assert.Nil(t, results["invalid-image"].TaskInfo.UsageFacts)
@@ -1698,22 +1985,27 @@ func TestTaskSubmitStreamIdleTimeout(t *testing.T) {
}
func TestPluginJSONValuesPreserveCodecNormalizationAndIsolation(t *testing.T) {
for _, value := range []any{
map[string]any{"units": int64(3), "nested": []any{true, "<image> / 图像", math.Copysign(0, -1), nil}},
map[string]any{"empty": []any{}, "null": []any(nil), "object": map[string]any(nil)},
map[string]any{"large": int64(math.MaxInt64), "invalid UTF-8": string([]byte{0xff, 0xfe})},
map[string]any{string([]byte{0xff}): "invalid key"},
map[string]any{"bytes": []byte{0, 1, 255}, "number": json.Number("9007199254740993")},
json.RawMessage(`{"units":2,"enabled":false}`),
struct {
for _, tc := range []struct {
value any
plain bool
}{
{map[string]any{"units": int64(3), "nested": []any{true, "<image> / 图像", math.Copysign(0, -1), nil}}, true},
{map[string]any{"empty": []any{}, "object": map[string]any(nil)}, true},
{map[string]any{"null": []any(nil)}, false},
{map[string]any{"large": int64(math.MaxInt64), "invalid UTF-8": string([]byte{0xff, 0xfe})}, false},
{map[string]any{string([]byte{0xff}): "invalid key"}, false},
{map[string]any{"bytes": []byte{0, 1, 255}, "number": json.Number("9007199254740993")}, false},
{json.RawMessage(`{"units":2,"enabled":false}`), false},
{struct {
Units int `json:"units"`
}{Units: 0},
}{Units: 0}, false},
} {
encoded, err := common.Marshal(value)
encoded, err := common.Marshal(tc.value)
require.NoError(t, err)
var expected any
require.NoError(t, common.Unmarshal(encoded, &expected))
assert.Equal(t, expected, jsonValue(value))
assert.Equal(t, expected, jsonValue(tc.value))
assert.Equal(t, tc.plain, isPlainJSONValue(tc.value, 0))
}
source := map[string]any{"items": []any{map[string]any{"label": "original"}}}
copy := jsonValue(source).(map[string]any)
@@ -1721,6 +2013,86 @@ func TestPluginJSONValuesPreserveCodecNormalizationAndIsolation(t *testing.T) {
assert.Equal(t, "original", source["items"].([]any)[0].(map[string]any)["label"])
}
func TestRequestDescriptorDecodingMatchesCodec(t *testing.T) {
engine, err := pluginruntime.Compile(`
class Descriptor { constructor() { this.url = "u"; this.method = "PUT"; } }
let deep = "leaf";
for (let i = 0; i < 70; i++) deep = [deep];
const results = {
"plain body": () => ({url: "https://provider.example/submit", method: "POST", headers: {"X-Plugin": "submit"}, body: {
units: 3, large: 2 ** 60, zero: -0, text: "<image> & 图像", missing: undefined,
items: [null, true, 1.5, {}, [], undefined],
}}),
"argument body": (arg) => ({url: "u", body: arg}),
"argument headers": (arg) => ({url: "u", headers: arg.headers}),
"changed argument body": (arg) => { arg.units = 4; return {url: "u", body: arg}; },
"string body": () => ({url: "u", body: '{"raw":true}'}),
"null body": () => ({url: "u", body: null}),
"undefined body": () => ({url: "u", body: undefined}),
"no body": () => ({url: "u", credentialless: true}),
"class instance": () => new Descriptor(),
"null prototype": () => Object.assign(Object.create(null), {url: "u", body: {a: 1}}),
"getter body": () => ({url: "u", get body() { return {from: "getter"}; }}),
"date body": () => ({url: "u", body: {at: new Date(0)}}),
"typed array body": () => ({url: "u", body: new Uint8Array([0, 1, 255])}),
"deep body": () => ({url: "u", body: deep}),
"capitalized body key": () => ({url: "u", Body: {from: "Body"}}),
"body and capitalized body": () => ({url: "u", body: {from: "body"}, Body: {from: "Body"}, BODY: "upper"}),
"case-insensitive other field": () => ({URL: "u", Method: "PUT", body: "text"}),
"NaN body": () => ({url: "u", body: {ratio: NaN}}),
"infinite method": () => ({url: "u", method: Infinity, body: {}}),
"cyclic body": () => { const body = {}; body.self = body; return {url: "u", body}; },
"invalid header": () => ({url: "u", headers: {x: 1}, body: {}}),
"invalid parts": () => ({url: "u", parts: "none", body: {}}),
"not an object": () => "descriptor",
"throwing getter": () => ({url: "u", get body() { throw new Error("getter failed"); }}),
};
export function build(name, arg) { return results[name](arg); }
`, pluginruntime.Options{})
require.NoError(t, err)
argument := func() map[string]any {
return map[string]any{
"units": int64(3), "large": int64(math.MaxInt64), "zero": math.Copysign(0, -1),
"headers": map[string]any{"X-Plugin": "submit"},
"items": []any{nil, true, 1.5, map[string]any{"label": "original"}, []any{}},
}
}
for _, name := range []string{
"plain body", "argument body", "argument headers", "changed argument body", "string body", "null body",
"undefined body", "no body", "class instance", "null prototype", "getter body", "date body",
"typed array body", "deep body", "capitalized body key", "body and capitalized body",
"case-insensitive other field", "NaN body", "infinite method", "cyclic body", "invalid header",
"invalid parts", "not an object", "throwing getter",
} {
var expected requestDescriptor
value, expectedErr := engine.Call(t.Context(), "build", name, argument())
hookFailed := expectedErr != nil
if !hookFailed {
expectedErr = convert(value, &expected)
}
var decoded requestDescriptor
err := engine.CallInto(t.Context(), &decoded, "build", name, argument())
if expectedErr != nil {
require.EqualError(t, err, expectedErr.Error(), name)
var invalid *pluginruntime.ResultError
assert.Equal(t, !hookFailed, errors.As(err, &invalid), name)
continue
}
require.NoError(t, err, name)
assert.Equal(t, expected, decoded, name)
expectedJSON, err := common.Marshal(expected)
require.NoError(t, err, name)
decodedJSON, err := common.Marshal(decoded)
require.NoError(t, err, name)
assert.Equal(t, string(expectedJSON), string(decodedJSON), name)
}
source := argument()
var decoded requestDescriptor
require.NoError(t, engine.CallInto(t.Context(), &decoded, "build", "argument body", source))
decoded.Body.(map[string]any)["items"].([]any)[3].(map[string]any)["label"] = "changed"
assert.Equal(t, "original", source["items"].([]any)[3].(map[string]any)["label"])
}
func TestTaskSubmitHooksReceiveIndependentRequestSnapshots(t *testing.T) {
source := `
export const meta={apiVersion:1,key:"request-copy",name:"Request copy",version:"1.0.0",author:{name:"Test"},models:["copy"],fetchMode:"per_task",usageSchema:{units:{type:"number",unit:"count"}}};
@@ -1752,6 +2124,39 @@ export function parseTaskResult(){return {status:"SUCCESS"};}
assert.Equal(t, "original", request["nested"].(map[string]any)["label"])
}
func TestTaskUsageValidationLeavesReturnedHostRequestUnchanged(t *testing.T) {
// Returning ctx.requestBody hands back the host request map itself, so
// normalized facts must be built in a new map.
source := `
export const meta={apiVersion:1,key:"request-usage",name:"Request usage",version:"1.0.0",author:{name:"Test"},models:["usage"],fetchMode:"per_task",usageSchema:{units:{type:"number",unit:"count"}}};
export function buildSubmitRequest(ctx){return {url:ctx.baseUrl+"/submit",body:ctx.requestBody};}
export function extractUsage(ctx){return ctx.requestBody;}
export function parseSubmitResponse(){return {taskId:"usage"};}
export function buildQueryRequest(ctx){return {url:ctx.baseUrl+"/query"};}
export function parseTaskResult(){return {status:"SUCCESS"};}
`
plugin, err := pluginruntime.CompilePlugin(source, pluginruntime.Options{})
require.NoError(t, err)
request := map[string]any{"units": int64(2)}
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/submit", nil)
c.Set("task_request", request)
info := &relaycommon.RelayInfo{OriginModelName: "usage", ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "usage", ChannelBaseUrl: "https://provider.example"}, TaskRelayInfo: &relaycommon.TaskRelayInfo{}}
adaptor := New(plugin)
adaptor.Init(info)
require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))
facts, err := adaptor.ExtractUsageFactsValidated(c, info)
require.NoError(t, err)
assert.Equal(t, map[string]any{"units": float64(2)}, facts)
assert.Equal(t, int64(2), request["units"])
estimateRequest := map[string]any{"units": int64(2)}
c.Set("task_request", estimateRequest)
ratios, err := adaptor.EstimateBillingValidated(c, info)
require.NoError(t, err)
assert.Equal(t, map[string]float64{"units": 2}, ratios)
assert.Equal(t, int64(2), estimateRequest["units"])
}
func TestTaskSubmitDeltaStreamContract(t *testing.T) {
source := strings.Replace(documentStreamPlugin, `submitResponseTypes:["json","sse"],`, `submitResponseTypes:["json","sse"],requiredCapabilities:["submit-sse-delta@1"],`, 1) + `
export function parseSubmitEventDelta(ctx,event,previous) {
@@ -1808,6 +2213,26 @@ export function parseSubmitEventDelta(ctx,event,previous) {
assert.Equal(t, map[string]any{"units": float64(0)}, parsed.Immediate.UsageFacts)
})
}
t.Run("delta hook exported without the declaration", func(t *testing.T) {
undeclared := strings.Replace(source, `requiredCapabilities:["submit-sse-delta@1"],`, "", 1)
undeclared = strings.Replace(undeclared, "export function parseSubmitEvent(", "function unusedSnapshotHook(", 1)
plugin, err := pluginruntime.CompilePlugin(undeclared, pluginruntime.Options{})
require.NoError(t, err)
require.Empty(t, plugin.Meta.RequiredCapabilities)
info := &relaycommon.RelayInfo{OriginModelName: "alias", ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "document", ChannelBaseUrl: "https://provider.example"}, TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "public-document"}}
adaptor := New(plugin)
adaptor.Init(info)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/compile", nil)
c.Set("task_request", map[string]any{})
require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))
stream := "data: " + first + "\n\ndata: " + last + "\n\n"
response := &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(stream))}
parsed, taskErr := adaptor.ParseResponse(c, response, info)
require.Nil(t, taskErr)
assert.JSONEq(t, `{"document":"helloworld","units":0}`, string(parsed.TaskData))
})
}
func TestAlibabaSubmitDeltaDoesNotMutateControlState(t *testing.T) {
@@ -1841,3 +2266,138 @@ func TestAlibabaSubmitDeltaDoesNotMutateControlState(t *testing.T) {
require.NoError(t, err)
assert.JSONEq(t, `{"choices":[{"count":2,"lastText":false,"finishReason":"stop"}],"hasUsage":true}`, string(encoded))
}
// newAPIGateway emulates the upstream New API instance a type-60 channel
// points at: it answers only on the plugin's prefixed native routes, requires
// the gateway token as a Bearer header, and renders what its own presenters
// render (the public task id already substituted into the id fields).
func newAPIGateway(t *testing.T, routes map[string]string, seen *[]string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
*seen = append(*seen, r.Method+" "+r.URL.Path)
if r.Header.Get("Authorization") != "Bearer sk-gateway" {
w.WriteHeader(http.StatusUnauthorized)
return
}
body, ok := routes[r.Method+" "+r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
}))
}
func TestTaskAdaptorChainsDoubaoThroughNewAPIUpstream(t *testing.T) {
gin.SetMode(gin.TestMode)
service.InitHttpClient()
var seen []string
gateway := newAPIGateway(t, map[string]string{
"POST /doubao/api/v3/contents/generations/tasks": `{"id":"task_up_public"}`,
"GET /doubao/api/v3/contents/generations/tasks/task_up_public": `{"id":"task_up_public","status":"succeeded","content":{"video_url":"https://cdn.example/v.mp4"},"usage":{"completion_tokens":1200,"total_tokens":1200}}`,
}, &seen)
defer gateway.Close()
source, err := plugins.Source("doubao")
require.NoError(t, err)
plugin, err := pluginruntime.NewRegistry().RegisterFactory(source, pluginruntime.Options{Key: "doubao"})
require.NoError(t, err)
adaptor := New(plugin)
const modelName = "doubao-seedance-1-0-pro-250528"
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{ChannelType: constant.ChannelTypeNewAPI, ChannelBaseUrl: gateway.URL, ApiKey: "sk-gateway", UpstreamModelName: modelName},
OriginModelName: modelName,
TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_local"},
}
adaptor.Init(info)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
c.Set("task_request", relaycommon.TaskSubmitReq{Prompt: "a cat", Model: modelName})
require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))
requestBody, err := adaptor.BuildRequestBody(c, info)
require.NoError(t, err)
submitURL, err := adaptor.BuildRequestURL(info)
require.NoError(t, err)
assert.Equal(t, gateway.URL+"/doubao/api/v3/contents/generations/tasks", submitURL)
resp, err := adaptor.DoRequest(c, info, requestBody)
require.NoError(t, err)
parsed, taskErr := adaptor.ParseResponse(c, resp, info)
require.Nil(t, taskErr)
assert.Equal(t, "task_up_public", parsed.UpstreamTaskID, "the gateway's presenter emits its public id in the id field doubao reads")
task := &model.Task{
Action: info.Action,
Properties: model.Properties{OriginModelName: modelName, UpstreamModelName: modelName},
PrivateData: model.TaskPrivateData{UpstreamTaskID: parsed.UpstreamTaskID},
}
queryResp, err := adaptor.FetchTask(gateway.URL, "sk-gateway", task, "")
require.NoError(t, err)
queryBody, err := io.ReadAll(queryResp.Body)
require.NoError(t, err)
require.NoError(t, queryResp.Body.Close())
result, err := adaptor.ParseTaskResult(task, queryResp, queryBody)
require.NoError(t, err)
assert.Equal(t, "SUCCESS", result.Status)
assert.Equal(t, "https://cdn.example/v.mp4", result.Url)
assert.Equal(t, 1200, result.TotalTokens)
assert.Equal(t, []string{
"POST /doubao/api/v3/contents/generations/tasks",
"GET /doubao/api/v3/contents/generations/tasks/task_up_public",
}, seen)
}
func TestTaskAdaptorChainsSunoBatchFetchThroughNewAPIUpstream(t *testing.T) {
gin.SetMode(gin.TestMode)
service.InitHttpClient()
var seen []string
gateway := newAPIGateway(t, map[string]string{
"POST /suno/submit/MUSIC": `{"code":"success","message":"","data":"task_up_public"}`,
"POST /suno/fetch": `{"code":"success","message":"","data":[{"task_id":"task_up_public","status":"SUCCESS","progress":"100%","data":[{"id":"clip-1","status":"complete","audio_url":"https://cdn.example/a.mp3"}]}]}`,
}, &seen)
defer gateway.Close()
source, err := plugins.Source("sunoapi")
require.NoError(t, err)
plugin, err := pluginruntime.NewRegistry().RegisterFactory(source, pluginruntime.Options{Key: "sunoapi"})
require.NoError(t, err)
adaptor := New(plugin)
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{ChannelType: constant.ChannelTypeNewAPI, ChannelBaseUrl: gateway.URL, ApiKey: "sk-gateway", UpstreamModelName: "suno_music"},
OriginModelName: "suno_music",
TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_local", Action: "MUSIC"},
}
adaptor.Init(info)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/suno/submit/music", nil)
c.Set("task_request", relaycommon.TaskSubmitReq{Prompt: "a song about cats"})
require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))
requestBody, err := adaptor.BuildRequestBody(c, info)
require.NoError(t, err)
submitURL, err := adaptor.BuildRequestURL(info)
require.NoError(t, err)
assert.Equal(t, gateway.URL+"/suno/submit/MUSIC", submitURL)
resp, err := adaptor.DoRequest(c, info, requestBody)
require.NoError(t, err)
parsed, taskErr := adaptor.ParseResponse(c, resp, info)
require.Nil(t, taskErr)
assert.Equal(t, "task_up_public", parsed.UpstreamTaskID)
task := &model.Task{
Action: "MUSIC",
Properties: model.Properties{OriginModelName: "suno_music", UpstreamModelName: "suno_music"},
PrivateData: model.TaskPrivateData{UpstreamTaskID: parsed.UpstreamTaskID},
}
batchResp, err := adaptor.FetchBatchTasks(gateway.URL, "sk-gateway", []*model.Task{task}, "")
require.NoError(t, err)
batchBody, err := io.ReadAll(batchResp.Body)
require.NoError(t, err)
require.NoError(t, batchResp.Body.Close())
results, err := adaptor.ParseBatchResult([]*model.Task{task}, batchResp, batchBody)
require.NoError(t, err)
require.Contains(t, results, "task_up_public")
assert.Equal(t, "SUCCESS", results["task_up_public"].TaskInfo.Status)
assert.Equal(t, []string{"POST /suno/submit/MUSIC", "POST /suno/fetch"}, seen)
}
+78
View File
@@ -9,7 +9,10 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relay/channel"
vertexcore "github.com/QuantumNous/new-api/relay/channel/vertex"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/gin-gonic/gin"
@@ -75,3 +78,78 @@ export function parseTaskResult(){return {status:"SUCCESS"}}
require.NoError(t, adaptor.BuildRequestHeader(c, req, info))
assert.Equal(t, "Bearer access-token", req.Header.Get("Authorization"))
}
func TestUpstreamContextsUseGatewayBearerOnNewAPIChannels(t *testing.T) {
pluginAuthCache = sync.Map{}
original := acquireAccessToken
exchanges := 0
acquireAccessToken = func(_ vertexcore.Credentials, _ string) (string, error) {
exchanges++
return "access-token", nil
}
t.Cleanup(func() { acquireAccessToken = original; pluginAuthCache = sync.Map{} })
credentials, err := common.Marshal(vertexcore.Credentials{ProjectID: "project", ClientEmail: "a@example.com", PrivateKey: "secret"})
require.NoError(t, err)
const base = "https://upstream.example"
testCases := []struct {
name string
auth string
channelType int
key string
wantKind string
wantAuthHeader string
wantAPIKey any // nil when the raw key must stay hidden from the plugin
}{
{"api key plugin on a New API channel", "", constant.ChannelTypeNewAPI, "sk-gateway", pluginruntime.UpstreamKindNewAPI, "Bearer sk-gateway", "sk-gateway"},
{"api key plugin on a Task Plugin channel", "", constant.ChannelTypeTaskPlugin, "vendor-key", pluginruntime.UpstreamKindVendor, "vendor-key", "vendor-key"},
{"api key plugin on a legacy channel", "", 1002, "vendor-key", pluginruntime.UpstreamKindVendor, "vendor-key", "vendor-key"},
{"oauth2_jwt plugin on a New API channel", `,auth:{type:"oauth2_jwt"}`, constant.ChannelTypeNewAPI, "sk-gateway", pluginruntime.UpstreamKindNewAPI, "Bearer sk-gateway", "sk-gateway"},
{"oauth2_jwt plugin on a Task Plugin channel", `,auth:{type:"oauth2_jwt"}`, constant.ChannelTypeTaskPlugin, string(credentials), pluginruntime.UpstreamKindVendor, "Bearer access-token", nil},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
source := `
export const meta = {apiVersion:1,key:"upstream-probe",name:"Upstream Probe",version:"1.0.0",author:{name:"Test"},channelTypes:[1002],models:["probe"],fetchMode:"per_task",upstreams:["vendor","new_api"]` + tc.auth + `};
export function buildSubmitRequest(ctx){return {url:ctx.baseUrl+"/submit"}}
export function parseSubmitResponse(){return {taskId:"1"}}
export function buildQueryRequest(ctx){return {url:ctx.baseUrl+"/tasks/"+ctx.taskId}}
export function parseTaskResult(){return {status:"SUCCESS"}}
export function listArtifacts(){return [{key:"video",type:"video"}]}
export function buildContentRequest(ctx){return {url:ctx.baseUrl+"/content/"+ctx.upstream.kind,headers:{Authorization:ctx.authHeader,"X-Api-Key":String(ctx.apiKey)}}}
`
plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
require.NoError(t, err)
adaptor := New(plugin)
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelType: tc.channelType, ChannelBaseUrl: base, ApiKey: tc.key}, TaskRelayInfo: &relaycommon.TaskRelayInfo{}}
adaptor.Init(info)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
task := &model.Task{PrivateData: model.TaskPrivateData{UpstreamTaskID: "t1"}}
submit := adaptor.submitContext(c, info)
query, err := adaptor.queryContext(task, tc.key, base, "")
require.NoError(t, err)
batch, err := adaptor.batchQueryContext(tc.key, base, "", nil)
require.NoError(t, err)
assert.NotContains(t, submit, "authError")
for name, ctx := range map[string]map[string]any{"submit": submit, "query": query, "batch": batch} {
assert.Equal(t, map[string]any{"kind": tc.wantKind}, ctx["upstream"], name)
assert.Equal(t, tc.wantAuthHeader, ctx["authHeader"], name)
assert.Equal(t, tc.wantAPIKey, ctx["apiKey"], name)
assert.Equal(t, tc.wantAuthHeader, ctx["auth"].(map[string]any)["authHeader"], name)
}
content, err := adaptor.BuildContentRequest(t.Context(), task, "video", channel.TaskArtifactClientRequest{Method: http.MethodGet})
require.NoError(t, err)
assert.Equal(t, base+"/content/"+tc.wantKind, content.URL)
assert.Equal(t, tc.wantAuthHeader, content.Headers["Authorization"])
if tc.wantAPIKey == nil {
assert.Equal(t, "undefined", content.Headers["X-Api-Key"])
} else {
assert.Equal(t, tc.wantAPIKey, content.Headers["X-Api-Key"])
}
})
}
assert.Equal(t, 1, exchanges, "the vendor token exchange runs only for the oauth2_jwt vendor channel, never for a New API channel")
}
@@ -60,9 +60,13 @@ func BenchmarkTaskPluginRuntime(b *testing.B) {
var err error
switch name {
case "HasExport":
_, err = plugin.Engine.HasExport(ctx, "extractUsage")
if !plugin.Engine.HasExport("extractUsage") {
b.Fatal("extractUsage not exported")
}
case "MissingExport":
_, err = plugin.Engine.HasExport(ctx, "absentHook")
if plugin.Engine.HasExport("absentHook") {
b.Fatal("absentHook exported")
}
default:
_, err = plugin.Engine.Call(ctx, "extractUsage", input)
}
+10 -9
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"mime"
"net/http"
"slices"
"strings"
"time"
@@ -37,7 +36,7 @@ func (a *TaskAdaptor) readSubmitEvents(parent context.Context, resp *http.Respon
scanner := helper.NewStreamScanner(resp.Body, maxTaskPluginPersistedJSONBytes+1)
hook := "parseSubmitEvent"
var accumulated *pluginruntime.JSONState
if slices.Contains(a.plugin.Meta.RequiredCapabilities, pluginruntime.CapabilitySubmitSSEDelta) {
if pluginruntime.UsesSubmitEventDelta(a.plugin.Meta, a.plugin.Engine) {
hook = "parseSubmitEventDelta"
accumulated = pluginruntime.NewJSONState(maxTaskPluginPersistedJSONBytes)
}
@@ -117,15 +116,17 @@ func (a *TaskAdaptor) readSubmitEvents(parent context.Context, resp *http.Respon
return nil, fmt.Errorf("invalid submit stream changes: %w", err)
}
}
// Keep the exact encoded-byte limit above. Plain JSON state can be
// isolated without parsing large strings again; codec-specific values
// (for example exported typed arrays) retain the old normalization.
var plainJSON bool
state, plainJSON = cloneJSONValue(nextState, 0)
if !plainJSON {
if err = common.Unmarshal(encoded, &state); err != nil {
// Keep the exact encoded-byte limit above. Plain JSON state goes back
// to the next event as is; codec-specific values (for example exported
// typed arrays) retain the old normalization.
if isPlainJSONValue(nextState, 0) {
state = nextState
} else {
var decoded any
if err = common.Unmarshal(encoded, &decoded); err != nil {
return nil, err
}
state = decoded
}
if done {
if accumulated != nil {
+2 -2
View File
@@ -107,7 +107,7 @@ func tencentStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *htt
var tencentResponse TencentChatResponse
err := common.Unmarshal([]byte(data), &tencentResponse)
if err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
common.SysLog(common.LogText("error unmarshalling stream response: %s", err.Error()))
continue
}
@@ -123,7 +123,7 @@ func tencentStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *htt
}
if err := scanner.Err(); err != nil {
common.SysLog("error reading stream: " + err.Error())
common.SysLog(common.LogText("error reading stream: %s", err.Error()))
}
helper.Done(c)
+5 -2
View File
@@ -152,17 +152,20 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
} else {
keyPrefix = "?"
}
// An API key carries no project. Without a configured project ID the URL
// keeps the express-mode form, which has no region in its path.
projectID := strings.TrimSpace(info.ChannelOtherSettings.VertexProjectID)
if a.RequestMode == RequestModeGemini {
return fmt.Sprintf(
"%s%skey=%s",
BuildGoogleModelURL(info.ChannelBaseUrl, DefaultAPIVersion, "", region, modelName, suffix),
BuildGoogleModelURL(info.ChannelBaseUrl, DefaultAPIVersion, projectID, region, modelName, suffix),
keyPrefix,
info.ApiKey,
), nil
} else if a.RequestMode == RequestModeClaude {
return fmt.Sprintf(
"%s%skey=%s",
BuildAnthropicModelURL(info.ChannelBaseUrl, DefaultAPIVersion, "", region, modelName, suffix),
BuildAnthropicModelURL(info.ChannelBaseUrl, DefaultAPIVersion, projectID, region, modelName, suffix),
keyPrefix,
info.ApiKey,
), nil
@@ -0,0 +1,62 @@
package vertex
import (
"testing"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetRequestURLWithAPIKey(t *testing.T) {
tests := []struct {
name string
projectID string
region string
stream bool
want string
}{
{
name: "project ID keeps the global region",
projectID: "my-project",
region: `{"default":"global"}`,
want: "https://aiplatform.googleapis.com/v1/projects/my-project/locations/global/publishers/google/models/gemini-4-argon:generateContent?key=test-key",
},
{
name: "project ID keeps a regional endpoint when streaming",
projectID: "my-project",
region: `{"default":"us-central1"}`,
stream: true,
want: "https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-4-argon:streamGenerateContent?alt=sse&key=test-key",
},
{
name: "no project ID keeps the express-mode URL",
region: `{"default":"global"}`,
want: "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-4-argon:generateContent?key=test-key",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info := &relaycommon.RelayInfo{
IsStream: tt.stream,
OriginModelName: "gemini-4-argon",
ChannelMeta: &relaycommon.ChannelMeta{
ApiVersion: tt.region,
ApiKey: "test-key",
UpstreamModelName: "gemini-4-argon",
ChannelOtherSettings: dto.ChannelOtherSettings{
VertexKeyType: dto.VertexKeyTypeAPIKey,
VertexProjectID: tt.projectID,
},
},
}
adaptor := &Adaptor{}
adaptor.Init(info)
got, err := adaptor.GetRequestURL(info)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
+14 -48
View File
@@ -6,7 +6,6 @@ import (
"encoding/json"
"encoding/pem"
"errors"
"net/http"
"net/url"
"strings"
@@ -53,7 +52,7 @@ func getAccessToken(a *Adaptor, info *relaycommon.RelayInfo) (string, error) {
if err != nil {
return "", fmt.Errorf("failed to create signed JWT: %w", err)
}
newToken, err := exchangeJwtForAccessToken(signedJWT, info)
newToken, err := exchangeJwtForAccessToken(signedJWT, info.ChannelSetting.Proxy)
if err != nil {
return "", fmt.Errorf("failed to exchange JWT for access token: %w", err)
}
@@ -104,14 +103,24 @@ func createSignedJWT(email, privateKeyPEM string) (string, error) {
return signedToken, nil
}
func exchangeJwtForAccessToken(signedJWT string, info *relaycommon.RelayInfo) (string, error) {
func AcquireAccessToken(creds Credentials, proxy string) (string, error) {
signedJWT, err := createSignedJWT(creds.ClientEmail, creds.PrivateKey)
if err != nil {
return "", fmt.Errorf("failed to create signed JWT: %w", err)
}
return exchangeJwtForAccessToken(signedJWT, proxy)
}
// exchangeJwtForAccessToken trades a signed service-account JWT for an access
// token at Google's OAuth endpoint. It goes through the channel proxy only and
// always verifies the certificate; the channel's TLS setting does not apply.
func exchangeJwtForAccessToken(signedJWT string, proxy string) (string, error) {
authURL := "https://www.googleapis.com/oauth2/v4/token"
data := url.Values{}
data.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
data.Set("assertion", signedJWT)
client, err := service.GetHttpClientWithProxySettings(info.ChannelSetting.Proxy, info.ChannelSetting)
client, err := service.GetHttpClientWithProxy(proxy)
if err != nil {
return "", fmt.Errorf("new proxy http client failed: %w", err)
}
@@ -122,50 +131,7 @@ func exchangeJwtForAccessToken(signedJWT string, info *relaycommon.RelayInfo) (s
}
defer resp.Body.Close()
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if accessToken, ok := result["access_token"].(string); ok {
return accessToken, nil
}
return "", fmt.Errorf("failed to get access token: %v", result)
}
func AcquireAccessToken(creds Credentials, proxy string) (string, error) {
signedJWT, err := createSignedJWT(creds.ClientEmail, creds.PrivateKey)
if err != nil {
return "", fmt.Errorf("failed to create signed JWT: %w", err)
}
return exchangeJwtForAccessTokenWithProxy(signedJWT, proxy)
}
func exchangeJwtForAccessTokenWithProxy(signedJWT string, proxy string) (string, error) {
authURL := "https://www.googleapis.com/oauth2/v4/token"
data := url.Values{}
data.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
data.Set("assertion", signedJWT)
var client *http.Client
var err error
if proxy != "" {
client, err = service.GetHttpClientWithProxy(proxy)
if err != nil {
return "", fmt.Errorf("new proxy http client failed: %w", err)
}
} else {
client = service.GetHttpClient()
}
resp, err := client.PostForm(authURL, data)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result map[string]interface{}
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
+10 -9
View File
@@ -47,17 +47,18 @@ func BuildAPIBaseURL(baseURL, version, projectID, region string) string {
}
region = normalizeVertexRegion(region)
host := region + "-aiplatform.googleapis.com"
switch region {
case "global":
host = "aiplatform.googleapis.com"
case "us", "eu":
// The US and EU multi-regions are served from their own hostnames.
host = "aiplatform." + region + ".rep.googleapis.com"
}
if strings.TrimSpace(projectID) == "" {
if region == "global" {
return fmt.Sprintf("https://aiplatform.googleapis.com/%s", version)
}
return fmt.Sprintf("https://%s-aiplatform.googleapis.com/%s", region, version)
return fmt.Sprintf("https://%s/%s", host, version)
}
if region == "global" {
return fmt.Sprintf("https://aiplatform.googleapis.com/%s/projects/%s/locations/global", version, projectID)
}
return fmt.Sprintf("https://%s-aiplatform.googleapis.com/%s/projects/%s/locations/%s", region, version, projectID, region)
return fmt.Sprintf("https://%s/%s/projects/%s/locations/%s", host, version, projectID, region)
}
func BuildPublisherModelURL(baseURL, version, projectID, region, publisher, modelName, action string) string {
+25
View File
@@ -0,0 +1,25 @@
package vertex
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestBuildGoogleModelURLUsesTheLocationHost(t *testing.T) {
tests := []struct {
region string
want string
}{
{"global", "https://aiplatform.googleapis.com/v1/projects/my-project/locations/global/publishers/google/models/gemini-4-argon:generateContent"},
{"us-central1", "https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-4-argon:generateContent"},
{"us", "https://aiplatform.us.rep.googleapis.com/v1/projects/my-project/locations/us/publishers/google/models/gemini-4-argon:generateContent"},
{"eu", "https://aiplatform.eu.rep.googleapis.com/v1/projects/my-project/locations/eu/publishers/google/models/gemini-4-argon:generateContent"},
}
for _, tt := range tests {
t.Run(tt.region, func(t *testing.T) {
got := BuildGoogleModelURL("", DefaultAPIVersion, "my-project", tt.region, "gemini-4-argon", "generateContent")
assert.Equal(t, tt.want, got)
})
}
}
+3
View File
@@ -49,6 +49,9 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
if info.RelayMode == constant.RelayModeResponses {
info.VendorToolUsage = xaiResponsesToolUsage
}
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
+1 -1
View File
@@ -46,7 +46,7 @@ func xAIStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
var xAIResp *dto.ChatCompletionsStreamResponse
if err := common.UnmarshalJsonStr(data, &xAIResp); err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
common.SysLog(common.LogText("error unmarshalling stream response: %s", err.Error()))
sr.Error(err)
return
}
+37
View File
@@ -0,0 +1,37 @@
package xai
import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/tidwall/gjson"
)
// xaiResponsesToolUsage reads xAI's billed server-side tool counts from
// usage.server_side_tool_usage_details (terminal stream frames carry the
// cumulative usage under response.usage; num_sources_used is not a billed
// count). web_search_calls replaces the web_search_call items and is priced by
// the web_search:grok* override; x_search is billed per fetched post and per
// fetched profile. gjson truncates fractional values and reads non-numbers as
// zero; SetBillableToolCount ignores negatives and settlement applies
// MaxBillableToolCallCount.
func xaiResponsesToolUsage(raw []byte) map[string]int {
details := gjson.GetBytes(raw, "usage.server_side_tool_usage_details")
if !details.Exists() {
details = gjson.GetBytes(raw, "response.usage.server_side_tool_usage_details")
}
if !details.IsObject() {
return nil
}
counts := make(map[string]int, 4)
if calls := details.Get("web_search_calls"); calls.Exists() {
counts[dto.BuildInToolWebSearch] = int(calls.Int())
counts[dto.BuildInToolWebSearchPreview] = 0
}
if posts := details.Get("x_posts_fetched"); posts.Exists() {
counts["x_search_posts"] = int(posts.Int())
}
if users := details.Get("x_users_fetched"); users.Exists() {
counts["x_search_profiles"] = int(users.Int())
}
return counts
}
+5 -5
View File
@@ -145,7 +145,7 @@ func xunfeiStreamHandler(c *gin.Context, textRequest dto.GeneralOpenAIRequest, a
response := streamResponseXunfei2OpenAI(&xunfeiResponse)
jsonResponse, err := json.Marshal(response)
if err != nil {
common.SysLog("error marshalling stream response: " + err.Error())
common.SysLog(common.LogText("error marshalling stream response: %s", err.Error()))
return true
}
c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonResponse)})
@@ -224,19 +224,19 @@ func xunfeiMakeRequest(textRequest dto.GeneralOpenAIRequest, domain, authUrl, ap
for {
_, msg, err := conn.ReadMessage()
if err != nil {
common.SysLog("error reading stream response: " + err.Error())
common.SysLog(common.LogText("error reading stream response: %s", err.Error()))
break
}
var response XunfeiChatResponse
err = json.Unmarshal(msg, &response)
if err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
common.SysLog(common.LogText("error unmarshalling stream response: %s", err.Error()))
break
}
dataChan <- response
if response.Payload.Choices.Status == 2 {
if err != nil {
common.SysLog("error closing websocket connection: " + err.Error())
common.SysLog(common.LogText("error closing websocket connection: %s", err.Error()))
}
break
}
@@ -287,6 +287,6 @@ func getAPIVersion(c *gin.Context, modelName string) string {
return apiVersion
}
apiVersion = "v1.1"
common.SysLog("api_version not found, using default: " + apiVersion)
common.SysLog(common.LogText("api_version not found, using default: %s", apiVersion))
return apiVersion
}
+5 -5
View File
@@ -41,7 +41,7 @@ func getZhipuToken(apikey string) string {
split := strings.Split(apikey, ".")
if len(split) != 2 {
common.SysLog("invalid zhipu key: " + apikey)
common.SysLog(common.LogText("invalid zhipu key: %s", apikey))
return ""
}
@@ -181,7 +181,7 @@ func zhipuStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
}
}
if err := scanner.Err(); err != nil {
common.SysLog("error reading stream: " + err.Error())
common.SysLog(common.LogText("error reading stream: %s", err.Error()))
}
stopChan <- true
}()
@@ -192,7 +192,7 @@ func zhipuStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
response := streamResponseZhipu2OpenAI(data)
jsonResponse, err := json.Marshal(response)
if err != nil {
common.SysLog("error marshalling stream response: " + err.Error())
common.SysLog(common.LogText("error marshalling stream response: %s", err.Error()))
return true
}
c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonResponse)})
@@ -201,13 +201,13 @@ func zhipuStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
var zhipuResponse ZhipuStreamMetaResponse
err := json.Unmarshal([]byte(data), &zhipuResponse)
if err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
common.SysLog(common.LogText("error unmarshalling stream response: %s", err.Error()))
return true
}
response, zhipuUsage := streamMetaResponseZhipu2OpenAI(&zhipuResponse)
jsonResponse, err := json.Marshal(response)
if err != nil {
common.SysLog("error marshalling stream response: " + err.Error())
common.SysLog(common.LogText("error marshalling stream response: %s", err.Error()))
return true
}
usage = zhipuUsage
+12 -3
View File
@@ -14,6 +14,7 @@ import (
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/samber/lo"
"github.com/gin-gonic/gin"
@@ -22,9 +23,16 @@ import (
type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
result, err := service.ConvertRequest(c, info, types.RelayFormatOpenAI, request)
if err != nil {
return nil, err
}
openaiRequest, ok := result.Value.(*dto.GeneralOpenAIRequest)
if !ok {
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", result.Value)
}
return a.ConvertOpenAIRequest(c, info, openaiRequest)
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
@@ -42,6 +50,7 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
info.ConvOptions().WebSearch = encodeWebSearch(info)
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
+3 -3
View File
@@ -86,7 +86,7 @@ func zhipu4vImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.
url = data.ImageUrl
}
if url == "" {
logger.LogWarn(c, "zhipu_image_missing_url")
logger.LogWarn(c, common.LogText("zhipu_image_missing_url"))
continue
}
@@ -99,14 +99,14 @@ func zhipu4vImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.
default:
_, downloaded, err := service.GetImageFromUrl(url)
if err != nil {
logger.LogError(c, "zhipu_image_get_b64_failed: "+err.Error())
logger.LogError(c, common.LogText("zhipu_image_get_b64_failed: %s", err.Error()))
continue
}
b64 = downloaded
}
if b64 == "" {
logger.LogWarn(c, "zhipu_image_empty_b64")
logger.LogWarn(c, common.LogText("zhipu_image_empty_b64"))
continue
}
+10 -9
View File
@@ -42,15 +42,16 @@ func requestOpenAI2Zhipu(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIReq
Stop, _ = request.Stop.([]string)
}
out := &dto.GeneralOpenAIRequest{
Model: request.Model,
Stream: request.Stream,
Messages: messages,
Temperature: request.Temperature,
TopP: request.TopP,
Stop: Stop,
Tools: request.Tools,
ToolChoice: request.ToolChoice,
THINKING: request.THINKING,
Model: request.Model,
Stream: request.Stream,
Messages: messages,
Temperature: request.Temperature,
TopP: request.TopP,
Stop: Stop,
Tools: request.Tools,
ToolChoice: request.ToolChoice,
THINKING: request.THINKING,
ReasoningEffort: request.ReasoningEffort,
}
if request.MaxTokens != nil || request.MaxCompletionTokens != nil {
maxTokens := request.GetMaxTokens()
+85
View File
@@ -0,0 +1,85 @@
package zhipu_4v
import (
"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/tidwall/gjson"
)
// zhipuSearchEngine is the documented default search_engine. It is also the
// tool-price key of the searches billed on this path.
const zhipuSearchEngine = "search_std"
// encodeWebSearch maps hosted web search onto the GLM Chat web_search tool
// (https://docs.bigmodel.cn/api-reference/模型-api/对话补全). Claude and
// Responses clients reach Zhipu natively, so only the Chat target is handled.
// search_result makes non-stream responses carry the top-level web_search
// array; search_intent:false skips intent detection when the client forced
// the search.
func encodeWebSearch(info *relaycommon.RelayInfo) convmeta.WebSearchEncoder {
return func(call convmeta.WebSearchCall) (*convmeta.WebSearchEncoding, error) {
if call.Chat == nil {
return nil, nil
}
var diagnostics []types.ConversionDiagnostic
webSearch := map[string]any{"enable": true, "search_engine": zhipuSearchEngine, "search_result": true}
if call.Forced {
webSearch["search_intent"] = false
// Stream responses carry no search evidence; a forced search is
// the only proof that one ran. A non-stream web_search array
// confirms the same single call through zhipuChatToolUsage.
info.RecordRequestInferredToolCall(zhipuSearchEngine)
}
// Only searches this encoder requested are known to use search_std.
info.VendorToolUsage = zhipuChatToolUsage
spec := call.Spec
switch {
case len(spec.AllowedDomains) == 1:
webSearch["search_domain_filter"] = spec.AllowedDomains[0]
case len(spec.AllowedDomains) > 1:
diagnostics = append(diagnostics, call.SemanticLoss("unsupported_domain_filter", "GLM web_search accepts one search_domain_filter; the domain filter was dropped"))
}
if len(spec.BlockedDomains) > 0 {
diagnostics = append(diagnostics, call.SemanticLoss("unsupported_domain_filter", "GLM web_search cannot exclude domains; blocked_domains was dropped"))
}
if spec.MaxUses != nil {
diagnostics = append(diagnostics, call.SemanticLoss("unsupported_search_limit", "GLM web_search cannot preserve max_uses"))
}
if len(spec.AllowedCallers) > 0 || spec.ExternalWebAccess != nil {
diagnostics = append(diagnostics, call.SemanticLoss("unsupported_search_controls", "GLM web_search cannot preserve caller or external-access constraints"))
}
switch spec.SearchContextSize {
case "high":
webSearch["content_size"] = "high"
case "", "medium":
default:
diagnostics = append(diagnostics, call.PresentationLoss("unsupported_search_tuning", "GLM web_search content_size has no low setting"))
}
if spec.Location != nil || spec.ResponseInclusion != "" || len(spec.ReturnTokenBudget) > 0 {
diagnostics = append(diagnostics, call.PresentationLoss("unsupported_search_tuning", "GLM web_search cannot preserve location or result tuning"))
}
native, err := common.Marshal(map[string]any{"type": "web_search", "web_search": webSearch})
if err != nil {
return nil, err
}
return &convmeta.WebSearchEncoding{
Tool: dto.ToolCallRequest{Type: "web_search", Native: native},
Diagnostics: diagnostics,
}, nil
}
}
// zhipuChatToolUsage bills one search_std call when a non-stream GLM Chat
// response carries the top-level web_search result array. The array lists
// results, not searches, so its length is not a count.
func zhipuChatToolUsage(raw []byte) map[string]int {
results := gjson.GetBytes(raw, "web_search")
if !results.IsArray() || len(results.Array()) == 0 {
return nil
}
return map[string]int{zhipuSearchEngine: 1}
}