feat: publish sanitized custom.97 source
This commit is contained in:
1022 files changed
+69903
-19207
No files matched your search
@@ -16,26 +16,17 @@ import (
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type Adaptor struct {
|
||||
IsSyncImageModel bool
|
||||
}
|
||||
|
||||
const aliAnthropicMessagesModelsEnv = "ALI_ANTHROPIC_MESSAGES_MODELS"
|
||||
const defaultAliAnthropicMessagesModels = "qwen,deepseek-v4,kimi,glm,minimax-m"
|
||||
|
||||
/*
|
||||
var syncModels = []string{
|
||||
"z-image",
|
||||
"qwen-image",
|
||||
"wan2.6",
|
||||
}
|
||||
*/
|
||||
func supportsAliAnthropicMessages(modelName string) bool {
|
||||
normalizedModelName := strings.ToLower(strings.TrimSpace(modelName))
|
||||
if normalizedModelName == "" {
|
||||
@@ -55,16 +46,6 @@ func aliAnthropicMessagesModelPatterns() []string {
|
||||
})
|
||||
}
|
||||
|
||||
var syncModels = []string{
|
||||
"z-image",
|
||||
"qwen-image",
|
||||
"wan2.6",
|
||||
}
|
||||
|
||||
func isSyncImageModel(modelName string) bool {
|
||||
return model_setting.IsSyncImageModel(modelName)
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
|
||||
//TODO implement me
|
||||
return nil, errors.New("not implemented")
|
||||
@@ -72,6 +53,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
|
||||
|
||||
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
|
||||
if supportsAliAnthropicMessages(info.UpstreamModelName) {
|
||||
ensureAnthropicWebSearchEntrypoint(req)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
@@ -90,6 +72,18 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
}
|
||||
|
||||
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||
if info.RelayMode == constant.RelayModeResponses {
|
||||
info.VendorToolUsage = aliResponsesToolUsage
|
||||
}
|
||||
if info.RelayFormat == types.RelayFormatClaude {
|
||||
if supportsAliAnthropicMessages(info.UpstreamModelName) {
|
||||
info.WebSearchBillingKey = aliSearchAgentKey
|
||||
} else {
|
||||
// Claude clients on models outside the Anthropic-compatible list
|
||||
// are converted to compatible-mode Chat.
|
||||
info.ConvOptions().WebSearch = encodeWebSearch(info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
@@ -108,21 +102,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
case constant.RelayModeRerank:
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.ChannelBaseUrl)
|
||||
case constant.RelayModeResponses:
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v2/apps/protocols/compatible-mode/v1/responses", info.ChannelBaseUrl)
|
||||
case constant.RelayModeImagesGenerations:
|
||||
if isSyncImageModel(info.UpstreamModelName) {
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl)
|
||||
} else {
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.ChannelBaseUrl)
|
||||
}
|
||||
case constant.RelayModeImagesEdits:
|
||||
if isOldWanModel(info.UpstreamModelName) {
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/image2image/image-synthesis", info.ChannelBaseUrl)
|
||||
} else if isWanModel(info.UpstreamModelName) {
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/image-generation/generation", info.ChannelBaseUrl)
|
||||
} else {
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl)
|
||||
}
|
||||
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/responses", info.ChannelBaseUrl)
|
||||
case constant.RelayModeCompletions:
|
||||
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/completions", info.ChannelBaseUrl)
|
||||
default:
|
||||
@@ -142,19 +122,6 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
|
||||
if c.GetString("plugin") != "" {
|
||||
req.Set("X-DashScope-Plugin", c.GetString("plugin"))
|
||||
}
|
||||
if info.RelayMode == constant.RelayModeImagesGenerations {
|
||||
if isSyncImageModel(info.UpstreamModelName) {
|
||||
|
||||
} else {
|
||||
req.Set("X-DashScope-Async", "enable")
|
||||
}
|
||||
}
|
||||
if info.RelayMode == constant.RelayModeImagesEdits {
|
||||
if isWanModel(info.UpstreamModelName) {
|
||||
req.Set("X-DashScope-Async", "enable")
|
||||
}
|
||||
req.Set("Content-Type", "application/json")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -181,44 +148,21 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertImageRequest is not implemented: Ali image generation and editing
|
||||
// are served by the alibaba task plugin through the openai_image host
|
||||
// protocol, which claims every declared image model on /v1/images/*. A model
|
||||
// reaching this adaptor is not declared by the plugin (or is named
|
||||
// differently from the Bailian model list), so the request cannot be served.
|
||||
// The rejection is a 400 that skips channel retries: every channel of this
|
||||
// type refuses the same name, and a retryable 500 would only hide the
|
||||
// misconfiguration behind unrelated channels.
|
||||
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
|
||||
if info.RelayMode == constant.RelayModeImagesGenerations {
|
||||
if isSyncImageModel(info.UpstreamModelName) {
|
||||
a.IsSyncImageModel = true
|
||||
}
|
||||
aliRequest, err := oaiImage2AliImageRequest(info, request, a.IsSyncImageModel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("convert image request to async ali image request failed: %w", err)
|
||||
}
|
||||
return aliRequest, nil
|
||||
} else if info.RelayMode == constant.RelayModeImagesEdits {
|
||||
if isOldWanModel(info.UpstreamModelName) {
|
||||
return oaiFormEdit2WanxImageEdit(c, info, request)
|
||||
}
|
||||
if isSyncImageModel(info.UpstreamModelName) {
|
||||
if isWanModel(info.UpstreamModelName) {
|
||||
a.IsSyncImageModel = false
|
||||
} else {
|
||||
a.IsSyncImageModel = true
|
||||
}
|
||||
}
|
||||
// ali image edit https://bailian.console.aliyun.com/?tab=api#/api/?type=model&url=2976416
|
||||
// 如果用户使用表单,则需要解析表单数据
|
||||
if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
|
||||
aliRequest, err := oaiFormEdit2AliImageEdit(c, info, request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("convert image edit form request failed: %w", err)
|
||||
}
|
||||
return aliRequest, nil
|
||||
} else {
|
||||
aliRequest, err := oaiImage2AliImageRequest(info, request, a.IsSyncImageModel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("convert image request to async ali image request failed: %w", err)
|
||||
}
|
||||
return aliRequest, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported image relay mode: %d", info.RelayMode)
|
||||
return nil, types.NewErrorWithStatusCode(
|
||||
fmt.Errorf("image model %q is not served by the alibaba task plugin; use a Bailian image model name declared by the plugin or map it with the channel model mapping", info.UpstreamModelName),
|
||||
types.ErrorCodeInvalidRequest,
|
||||
http.StatusBadRequest,
|
||||
types.ErrOptionWithSkipRetry(),
|
||||
)
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
|
||||
@@ -254,10 +198,6 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
|
||||
return adaptor.DoResponse(c, resp, info)
|
||||
default:
|
||||
switch info.RelayMode {
|
||||
case constant.RelayModeImagesGenerations:
|
||||
err, usage = aliImageHandler(a, c, resp, info)
|
||||
case constant.RelayModeImagesEdits:
|
||||
err, usage = aliImageHandler(a, c, resp, info)
|
||||
case constant.RelayModeRerank:
|
||||
err, usage = RerankHandler(c, resp, info)
|
||||
default:
|
||||
|
||||
+185
-171
@@ -1,71 +1,24 @@
|
||||
package ali
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
rootconstant "github.com/QuantumNous/new-api/constant"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/constant"
|
||||
relayhelper "github.com/QuantumNous/new-api/relay/helper"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestAliMultipartEditsUseValidatedProviderQuantity(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
convert func(*gin.Context, *relaycommon.RelayInfo, dto.ImageRequest) (*AliImageRequest, error)
|
||||
}{
|
||||
{"multimodal edit", oaiFormEdit2AliImageEdit},
|
||||
{"legacy Wan edit", oaiFormEdit2WanxImageEdit},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
require.NoError(t, writer.WriteField("model", "fixture-image"))
|
||||
require.NoError(t, writer.WriteField("n", "2"))
|
||||
require.NoError(t, writer.WriteField("parameters", `{"n":3,"prompt_extend":false}`))
|
||||
part, err := writer.CreateFormFile("image", "input.png")
|
||||
require.NoError(t, err)
|
||||
_, err = part.Write([]byte("fixture image"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body)
|
||||
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
common.SetContextKey(c, rootconstant.ContextKeyChannelType, rootconstant.ChannelTypeAli)
|
||||
request, err := relayhelper.GetAndValidOpenAIImageRequest(c, constant.RelayModeImagesEdits)
|
||||
require.NoError(t, err)
|
||||
info := &relaycommon.RelayInfo{Request: request}
|
||||
converted, err := tc.convert(c, info, *request)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, converted.Parameters.N)
|
||||
assert.Equal(t, uint(3), *converted.Parameters.N)
|
||||
require.NotNil(t, converted.Parameters.PromptExtend)
|
||||
assert.False(t, *converted.Parameters.PromptExtend)
|
||||
assert.Equal(t, uint(2), *request.N, "conversion must preserve the incoming request")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestFiltersThinkingBudgetByUpstreamModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -178,147 +131,208 @@ func TestConvertOpenAIRequestPreservesExplicitZeroForMappedQwenModel(t *testing.
|
||||
assert.Equal(t, int64(0), value.Int())
|
||||
}
|
||||
|
||||
func TestMappedAliImageModelUsesUpstreamProtocol(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayMode: constant.RelayModeImagesGenerations,
|
||||
OriginModelName: "customer-image-model",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
ChannelBaseUrl: "https://dashscope.aliyuncs.com",
|
||||
UpstreamModelName: "qwen-image-3.0-pro",
|
||||
},
|
||||
}
|
||||
|
||||
adaptor := &Adaptor{}
|
||||
url, err := adaptor.GetRequestURL(info)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", url)
|
||||
|
||||
header := http.Header{}
|
||||
require.NoError(t, adaptor.SetupRequestHeader(c, &header, info))
|
||||
assert.Empty(t, header.Get("X-DashScope-Async"))
|
||||
|
||||
converted, err := adaptor.ConvertImageRequest(c, info, dto.ImageRequest{
|
||||
Model: info.UpstreamModelName,
|
||||
Prompt: "poster",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, adaptor.IsSyncImageModel)
|
||||
assert.IsType(t, &AliImageRequest{}, converted)
|
||||
}
|
||||
|
||||
func TestAliImageCountMatchesLegacyReservation(t *testing.T) {
|
||||
saved := ratio_setting.ModelPrice2JSONString()
|
||||
require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{"z-image":0.04}`))
|
||||
t.Cleanup(func() { require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(saved)) })
|
||||
for _, tc := range []struct {
|
||||
name, body string
|
||||
count int
|
||||
multiplier float64
|
||||
}{
|
||||
{"provider override", `{"model":"z-image","n":2,"parameters":{"n":4}}`, 4, 1},
|
||||
{"empty parameters inherit top-level", `{"model":"z-image","n":2,"parameters":{}}`, 2, 1},
|
||||
{"null count inherits top-level", `{"model":"z-image","n":2,"parameters":{"n":null}}`, 2, 1},
|
||||
{"default count", `{"model":"z-image"}`, 1, 1},
|
||||
{"prompt extension", `{"model":"z-image","parameters":{"n":3,"prompt_extend":true}}`, 3, 2},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", strings.NewReader(tc.body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
common.SetContextKey(c, rootconstant.ContextKeyChannelType, rootconstant.ChannelTypeAli)
|
||||
request, err := relayhelper.GetAndValidOpenAIImageRequest(c, constant.RelayModeImagesGenerations)
|
||||
require.NoError(t, err)
|
||||
info := &relaycommon.RelayInfo{Request: request, OriginModelName: request.Model, UserGroup: "default", UsingGroup: "default"}
|
||||
price, err := relayhelper.ModelPriceHelper(c, info, 0, request.GetTokenCountMeta())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, common.QuotaFromFloat(0.04*float64(tc.count)*tc.multiplier*common.QuotaPerUnit), price.QuotaToPreConsume)
|
||||
info.ChannelMeta = &relaycommon.ChannelMeta{ChannelType: rootconstant.ChannelTypeAli, UpstreamModelName: request.Model}
|
||||
converted, err := oaiImage2AliImageRequest(info, *request, true)
|
||||
require.NoError(t, err)
|
||||
encoded, err := common.Marshal(converted)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(tc.count), gjson.GetBytes(encoded, "parameters.n").Int())
|
||||
})
|
||||
// Image models the alibaba task plugin does not claim reach this adaptor only
|
||||
// through a channel misconfiguration. The rejection is a client error that
|
||||
// skips channel retries; a retryable 500 would be re-attempted on unrelated
|
||||
// channels and still end as a 500 for the client.
|
||||
func TestConvertImageRequestRejectsUnclaimedModelWithoutRetry(t *testing.T) {
|
||||
for _, name := range []string{"wanx-style-repaint-v1", "custom-image-model"} {
|
||||
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: name}}
|
||||
_, err := (&Adaptor{}).ConvertImageRequest(nil, info, dto.ImageRequest{Model: name, Prompt: "a cat"})
|
||||
var apiErr *types.NewAPIError
|
||||
require.ErrorAs(t, err, &apiErr, name)
|
||||
assert.Equal(t, http.StatusBadRequest, apiErr.StatusCode, name)
|
||||
assert.True(t, types.IsSkipRetryError(apiErr), name)
|
||||
assert.Contains(t, apiErr.Error(), name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliImageHandlerHonorsRequestResponseFormat(t *testing.T) {
|
||||
imageBytes := []byte("ali-image")
|
||||
var downloads atomic.Int32
|
||||
imageServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
downloads.Add(1)
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write(imageBytes)
|
||||
}))
|
||||
t.Cleanup(imageServer.Close)
|
||||
|
||||
fetchSetting := system_setting.GetFetchSetting()
|
||||
require.NotNil(t, fetchSetting)
|
||||
originalFetchSetting := *fetchSetting
|
||||
fetchSetting.EnableSSRFProtection = false
|
||||
t.Cleanup(func() {
|
||||
*fetchSetting = originalFetchSetting
|
||||
})
|
||||
originalMaxFileDownloadMB := rootconstant.MaxFileDownloadMB
|
||||
rootconstant.MaxFileDownloadMB = 1
|
||||
t.Cleanup(func() {
|
||||
rootconstant.MaxFileDownloadMB = originalMaxFileDownloadMB
|
||||
})
|
||||
service.InitHttpClient()
|
||||
// Claude clients reach DashScope two ways: models on the Anthropic-compatible
|
||||
// list keep Claude Messages and get the cc_entrypoint marker block (block
|
||||
// form verified live 2026-10-05; a plain string system did not search) and
|
||||
// the agent billing key; other models are converted to compatible-mode Chat,
|
||||
// where hosted web search becomes enable_search / search_options and a forced
|
||||
// search is billed as one turbo call.
|
||||
func TestConvertClaudeRequestWebSearch(t *testing.T) {
|
||||
t.Setenv(aliAnthropicMessagesModelsEnv, defaultAliAnthropicMessagesModels)
|
||||
const (
|
||||
chatModel = "llama-4-scout"
|
||||
anthropicModel = "qwen3.8-flash"
|
||||
marker = `{"type":"text","text":"x-anthropic-billing-header: cc_entrypoint=cli;"}`
|
||||
messages = `"messages":[{"role":"user","content":"Weather?"}]`
|
||||
searchTool = `{"type":"web_search_20250305","name":"web_search"}`
|
||||
forcedChoice = `"tool_choice":{"type":"tool","name":"web_search"},`
|
||||
)
|
||||
domains := make([]string, 26)
|
||||
for i := range domains {
|
||||
domains[i] = fmt.Sprintf("d%d.example", i)
|
||||
}
|
||||
tooManyDomains, err := common.Marshal(domains)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
responseFormat string
|
||||
wantBase64 string
|
||||
wantDownloads int32
|
||||
name string
|
||||
model string
|
||||
body string // Claude request fields after model and max_tokens
|
||||
want map[string]string // gjson path -> raw JSON; "" means absent
|
||||
wantCodes []string
|
||||
noCodes []string
|
||||
wantTurbo int
|
||||
}{
|
||||
{
|
||||
name: "base64",
|
||||
responseFormat: "b64_json",
|
||||
wantBase64: base64.StdEncoding.EncodeToString(imageBytes),
|
||||
wantDownloads: 1,
|
||||
name: "auto search on Chat",
|
||||
model: chatModel,
|
||||
body: messages + `,"tools":[` + searchTool + `]`,
|
||||
want: map[string]string{"enable_search": "true", "search_options": "", "tool_choice": "", "web_search_options": ""},
|
||||
},
|
||||
{
|
||||
name: "url",
|
||||
responseFormat: "url",
|
||||
name: "forced search on Chat",
|
||||
model: chatModel,
|
||||
body: forcedChoice + messages + `,"tools":[` + searchTool + `]`,
|
||||
want: map[string]string{"enable_search": "true", "search_options": `{"forced_search":true}`, "tool_choice": "", "web_search_options": ""},
|
||||
wantTurbo: 1,
|
||||
},
|
||||
{
|
||||
name: "default",
|
||||
name: "allowed domains become assigned_site_list",
|
||||
model: chatModel,
|
||||
body: messages + `,"tools":[{"type":"web_search_20250305","name":"web_search","allowed_domains":["a.example","b.example"]}]`,
|
||||
want: map[string]string{"search_options": `{"assigned_site_list":["a.example","b.example"]}`},
|
||||
noCodes: []string{"unsupported_domain_filter"},
|
||||
},
|
||||
{
|
||||
name: "more than 25 allowed domains are dropped",
|
||||
model: chatModel,
|
||||
body: messages + `,"tools":[{"type":"web_search_20250305","name":"web_search","allowed_domains":` + string(tooManyDomains) + `}]`,
|
||||
want: map[string]string{"enable_search": "true", "search_options": ""},
|
||||
wantCodes: []string{"unsupported_domain_filter"},
|
||||
},
|
||||
{
|
||||
name: "search limits and excluded domains are semantic loss",
|
||||
model: chatModel,
|
||||
body: messages + `,"tools":[{"type":"web_search_20250305","name":"web_search","max_uses":2,"blocked_domains":["a.example"]}]`,
|
||||
want: map[string]string{"enable_search": "true"},
|
||||
wantCodes: []string{"unsupported_search_limit", "unsupported_domain_filter"},
|
||||
},
|
||||
{
|
||||
name: "user location is presentation loss",
|
||||
model: chatModel,
|
||||
body: messages + `,"tools":[{"type":"web_search_20250305","name":"web_search","user_location":{"type":"approximate","city":"Paris"}}]`,
|
||||
want: map[string]string{"enable_search": "true", "search_options": ""},
|
||||
wantCodes: []string{"unsupported_search_tuning"},
|
||||
},
|
||||
{
|
||||
name: "marker without system",
|
||||
model: anthropicModel,
|
||||
body: messages + `,"tools":[` + searchTool + `]`,
|
||||
want: map[string]string{"system": `[` + marker + `]`},
|
||||
},
|
||||
{
|
||||
name: "marker before string system",
|
||||
model: anthropicModel,
|
||||
body: `"system":"Be brief.",` + messages + `,"tools":[` + searchTool + `]`,
|
||||
want: map[string]string{"system": `[` + marker + `,{"type":"text","text":"Be brief."}]`},
|
||||
},
|
||||
{
|
||||
name: "marker before array system",
|
||||
model: anthropicModel,
|
||||
body: `"system":[{"type":"text","text":"Be brief."}],` + messages + `,"tools":[` + searchTool + `]`,
|
||||
want: map[string]string{"system": `[` + marker + `,{"type":"text","text":"Be brief."}]`},
|
||||
},
|
||||
{
|
||||
name: "marker for empty array system",
|
||||
model: anthropicModel,
|
||||
body: `"system":[],` + messages + `,"tools":[` + searchTool + `]`,
|
||||
want: map[string]string{"system": `[` + marker + `]`},
|
||||
},
|
||||
{
|
||||
name: "marker already sent by the client",
|
||||
model: anthropicModel,
|
||||
body: `"system":"x-anthropic-billing-header: cc_version=2.1; cc_entrypoint=cli;",` + messages + `,"tools":[` + searchTool + `]`,
|
||||
want: map[string]string{"system": `"x-anthropic-billing-header: cc_version=2.1; cc_entrypoint=cli;"`},
|
||||
},
|
||||
{
|
||||
name: "no marker without a search tool",
|
||||
model: anthropicModel,
|
||||
body: `"system":"Be brief.",` + messages + `,"tools":[{"name":"f","input_schema":{"type":"object"}}]`,
|
||||
want: map[string]string{"system": `"Be brief."`},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
downloads.Store(0)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayMode: constant.RelayModeImagesGenerations,
|
||||
StartTime: time.Unix(1, 0),
|
||||
Request: &dto.ImageRequest{
|
||||
ResponseFormat: tt.responseFormat,
|
||||
},
|
||||
RelayFormat: types.RelayFormatClaude,
|
||||
OriginModelName: tt.model,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: tt.model},
|
||||
}
|
||||
responseBody := fmt.Sprintf(`{"output":{"results":[{"url":%q}]}}`, imageServer.URL)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(strings.NewReader(responseBody)),
|
||||
adaptor := &Adaptor{}
|
||||
adaptor.Init(info)
|
||||
var request dto.ClaudeRequest
|
||||
require.NoError(t, common.UnmarshalJsonStr(`{"model":"`+tt.model+`","max_tokens":64,`+tt.body+`}`, &request))
|
||||
var converted any
|
||||
for range 2 { // converting the same request twice adds one marker
|
||||
converted, err = adaptor.ConvertClaudeRequest(c, info, &request)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
newAPIError, usage := aliImageHandler(&Adaptor{IsSyncImageModel: true}, c, resp, info)
|
||||
require.Nil(t, newAPIError)
|
||||
require.NotNil(t, usage)
|
||||
|
||||
var imageResponse dto.ImageResponse
|
||||
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &imageResponse))
|
||||
require.Len(t, imageResponse.Data, 1)
|
||||
assert.Equal(t, imageServer.URL, imageResponse.Data[0].Url)
|
||||
assert.Equal(t, tt.wantBase64, imageResponse.Data[0].B64Json)
|
||||
assert.Equal(t, tt.wantDownloads, downloads.Load())
|
||||
wire, err := common.Marshal(converted)
|
||||
require.NoError(t, err)
|
||||
for path, want := range tt.want {
|
||||
value := gjson.GetBytes(wire, path)
|
||||
if want == "" {
|
||||
assert.False(t, value.Exists(), "%s in %s", path, wire)
|
||||
continue
|
||||
}
|
||||
assert.JSONEq(t, want, value.Raw, path)
|
||||
}
|
||||
var codes []string
|
||||
for _, diagnostic := range info.ConversionDiagnostics() {
|
||||
codes = append(codes, diagnostic.Code)
|
||||
}
|
||||
for _, code := range tt.wantCodes {
|
||||
assert.Contains(t, codes, code)
|
||||
}
|
||||
for _, code := range tt.noCodes {
|
||||
assert.NotContains(t, codes, code)
|
||||
}
|
||||
turbo := 0
|
||||
if info.ResponsesUsageInfo != nil && info.ResponsesUsageInfo.BuiltInTools[aliSearchTurboKey] != nil {
|
||||
turbo = info.ResponsesUsageInfo.BuiltInTools[aliSearchTurboKey].CallCount
|
||||
}
|
||||
assert.Equal(t, tt.wantTurbo, turbo)
|
||||
wantKey := ""
|
||||
if tt.model == anthropicModel {
|
||||
wantKey = aliSearchAgentKey
|
||||
}
|
||||
assert.Equal(t, wantKey, info.WebSearchBillingKey)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// DashScope Responses bills web search like the agent strategy and reports
|
||||
// the count in usage.x_tools; it replaces the web_search_call items counted
|
||||
// under the protocol keys.
|
||||
func TestAliResponsesWebSearchUsage(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
`{"status":"completed","usage":{"x_tools":{"web_search":{"count":2}}}}`,
|
||||
`{"type":"response.completed","response":{"usage":{"x_tools":{"web_search":{"count":2}}}}}`,
|
||||
} {
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayMode: constant.RelayModeResponses,
|
||||
RelayFormat: types.RelayFormatOpenAIResponses,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://dashscope.invalid"},
|
||||
}
|
||||
adaptor := &Adaptor{}
|
||||
adaptor.Init(info)
|
||||
url, err := adaptor.GetRequestURL(info)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://dashscope.invalid/compatible-mode/v1/responses", url)
|
||||
|
||||
info.CountBillableToolCall(dto.BuildInCallWebSearchCall, "")
|
||||
info.ApplyVendorToolUsage([]byte(raw))
|
||||
counts := map[string]int{}
|
||||
for name, tool := range info.ResponsesUsageInfo.BuiltInTools {
|
||||
counts[name] = tool.CallCount
|
||||
}
|
||||
assert.Equal(t, map[string]int{aliSearchAgentKey: 2, dto.BuildInToolWebSearch: 0, dto.BuildInToolWebSearchPreview: 0}, counts, raw)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
package ali
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AliMessage struct {
|
||||
@@ -14,11 +9,6 @@ type AliMessage struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type AliMediaContent struct {
|
||||
Image string `json:"image,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
type AliInput struct {
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
//History []AliMessage `json:"history,omitempty"`
|
||||
@@ -72,142 +62,6 @@ type AliUsage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
ImageCount int `json:"image_count,omitempty"`
|
||||
}
|
||||
|
||||
type TaskResult struct {
|
||||
B64Image string `json:"b64_image,omitempty"`
|
||||
Url string `json:"url,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type AliOutput struct {
|
||||
TaskId string `json:"task_id,omitempty"`
|
||||
TaskStatus string `json:"task_status,omitempty"`
|
||||
Text string `json:"text"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Results []TaskResult `json:"results,omitempty"`
|
||||
Choices []struct {
|
||||
FinishReason string `json:"finish_reason,omitempty"`
|
||||
Message struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Content []AliMediaContent `json:"content,omitempty"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
} `json:"message"`
|
||||
} `json:"choices,omitempty"`
|
||||
}
|
||||
|
||||
func (o *AliOutput) ChoicesToOpenAIImageDate(c *gin.Context, responseFormat string) []dto.ImageData {
|
||||
var imageData []dto.ImageData
|
||||
if len(o.Choices) > 0 {
|
||||
for _, choice := range o.Choices {
|
||||
var data dto.ImageData
|
||||
for _, content := range choice.Message.Content {
|
||||
if content.Image != "" {
|
||||
if strings.HasPrefix(content.Image, "http") {
|
||||
var b64Json string
|
||||
if responseFormat == "b64_json" {
|
||||
_, b64, err := service.GetImageFromUrl(content.Image)
|
||||
if err != nil {
|
||||
logger.LogError(c, "get_image_data_failed: "+err.Error())
|
||||
continue
|
||||
}
|
||||
b64Json = b64
|
||||
}
|
||||
data.Url = content.Image
|
||||
data.B64Json = b64Json
|
||||
} else {
|
||||
data.B64Json = content.Image
|
||||
}
|
||||
} else if content.Text != "" {
|
||||
data.RevisedPrompt = content.Text
|
||||
}
|
||||
}
|
||||
imageData = append(imageData, data)
|
||||
}
|
||||
}
|
||||
|
||||
return imageData
|
||||
}
|
||||
|
||||
func (o *AliOutput) ResultToOpenAIImageDate(c *gin.Context, responseFormat string) []dto.ImageData {
|
||||
var imageData []dto.ImageData
|
||||
for _, data := range o.Results {
|
||||
var b64Json string
|
||||
if responseFormat == "b64_json" {
|
||||
_, b64, err := service.GetImageFromUrl(data.Url)
|
||||
if err != nil {
|
||||
logger.LogError(c, "get_image_data_failed: "+err.Error())
|
||||
continue
|
||||
}
|
||||
b64Json = b64
|
||||
} else {
|
||||
b64Json = data.B64Image
|
||||
}
|
||||
|
||||
imageData = append(imageData, dto.ImageData{
|
||||
Url: data.Url,
|
||||
B64Json: b64Json,
|
||||
RevisedPrompt: "",
|
||||
})
|
||||
}
|
||||
return imageData
|
||||
}
|
||||
|
||||
type AliResponse struct {
|
||||
Output AliOutput `json:"output"`
|
||||
Usage AliUsage `json:"usage"`
|
||||
AliError
|
||||
}
|
||||
|
||||
type AliImageRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input any `json:"input"`
|
||||
Parameters AliImageParameters `json:"parameters"`
|
||||
ResponseFormat string `json:"response_format,omitempty"`
|
||||
}
|
||||
|
||||
type AliImageParameters struct {
|
||||
Size string `json:"size,omitempty"`
|
||||
N *uint `json:"n,omitempty"`
|
||||
Steps string `json:"steps,omitempty"`
|
||||
Scale string `json:"scale,omitempty"`
|
||||
Watermark *bool `json:"watermark,omitempty"`
|
||||
PromptExtend *bool `json:"prompt_extend,omitempty"`
|
||||
ThinkingMode *bool `json:"thinking_mode,omitempty"`
|
||||
EnableSequential *bool `json:"enable_sequential,omitempty"`
|
||||
BboxList any `json:"bbox_list,omitempty"`
|
||||
ColorPalette any `json:"color_palette,omitempty"`
|
||||
Seed *int `json:"seed,omitempty"`
|
||||
}
|
||||
|
||||
func (p *AliImageParameters) PromptExtendValue() bool {
|
||||
if p != nil && p.PromptExtend != nil {
|
||||
return *p.PromptExtend
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type AliImageInput struct {
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
NegativePrompt string `json:"negative_prompt,omitempty"`
|
||||
Messages []AliMessage `json:"messages,omitempty"`
|
||||
}
|
||||
|
||||
type WanImageInput struct {
|
||||
Prompt string `json:"prompt"` // 必需:文本提示词,描述生成图像中期望包含的元素和视觉特点
|
||||
Images []string `json:"images"` // 必需:图像URL数组,长度不超过2,支持HTTP/HTTPS URL或Base64编码
|
||||
NegativePrompt string `json:"negative_prompt,omitempty"` // 可选:反向提示词,描述不希望在画面中看到的内容
|
||||
}
|
||||
|
||||
type WanImageParameters struct {
|
||||
N int `json:"n,omitempty"` // 生成图片数量,取值范围1-4,默认4
|
||||
Watermark *bool `json:"watermark,omitempty"` // 是否添加水印标识,默认false
|
||||
Seed int `json:"seed,omitempty"` // 随机数种子,取值范围[0, 2147483647]
|
||||
Strength float64 `json:"strength,omitempty"` // 修改幅度 0.0-1.0,默认0.5(部分模型支持)
|
||||
}
|
||||
|
||||
type AliRerankParameters struct {
|
||||
|
||||
@@ -1,368 +0,0 @@
|
||||
package ali
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func oaiImage2AliImageRequest(info *relaycommon.RelayInfo, request dto.ImageRequest, isSync bool) (*AliImageRequest, error) {
|
||||
var imageRequest AliImageRequest
|
||||
imageRequest.Model = request.Model
|
||||
imageRequest.ResponseFormat = request.ResponseFormat
|
||||
imageRequest.Parameters = AliImageParameters{
|
||||
Size: strings.ReplaceAll(request.Size, "x", "*"),
|
||||
Watermark: request.Watermark,
|
||||
}
|
||||
if request.Extra != nil {
|
||||
if val, ok := request.Extra["parameters"]; ok {
|
||||
imageRequest.Parameters = AliImageParameters{}
|
||||
err := common.Unmarshal(val, &imageRequest.Parameters)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid parameters field: %w", err)
|
||||
}
|
||||
// Direct adaptor callers can bypass ingress validation. Reuse the
|
||||
// decoded provider scalars and the same quantity validation below.
|
||||
if request.BillingParameters == nil {
|
||||
request.BillingParameters = &dto.ImageBillingParameters{
|
||||
N: imageRequest.Parameters.N, PromptExtend: imageRequest.Parameters.PromptExtend,
|
||||
}
|
||||
}
|
||||
}
|
||||
if val, ok := request.Extra["input"]; ok {
|
||||
err := common.Unmarshal(val, &imageRequest.Input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid input field: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
count, err := request.ImageCount(true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
imageRequest.Parameters.N = common.GetPointer(uint(count))
|
||||
if request.BillingParameters != nil {
|
||||
imageRequest.Parameters.PromptExtend = request.BillingParameters.PromptExtend
|
||||
}
|
||||
if info.TieredBillingSnapshot == nil {
|
||||
info.PriceData.AddOtherRatio("n", float64(count))
|
||||
if strings.Contains(request.Model, "z-image") && imageRequest.Parameters.PromptExtendValue() {
|
||||
info.PriceData.AddOtherRatio("prompt_extend", common.ZImagePromptExtendMultiplier)
|
||||
}
|
||||
}
|
||||
|
||||
// 同步图片模型和异步图片模型请求格式不一样
|
||||
if isSync {
|
||||
if imageRequest.Input == nil {
|
||||
imageRequest.Input = AliImageInput{
|
||||
Messages: []AliMessage{
|
||||
{
|
||||
Role: "user",
|
||||
Content: []AliMediaContent{
|
||||
{
|
||||
Text: request.Prompt,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if imageRequest.Input == nil {
|
||||
imageRequest.Input = AliImageInput{
|
||||
Prompt: request.Prompt,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &imageRequest, nil
|
||||
}
|
||||
func getImageBase64sFromForm(c *gin.Context, fieldName string) ([]string, error) {
|
||||
mf := c.Request.MultipartForm
|
||||
if mf == nil {
|
||||
if _, err := c.MultipartForm(); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse image edit form request: %w", err)
|
||||
}
|
||||
mf = c.Request.MultipartForm
|
||||
}
|
||||
|
||||
var imageFiles []*multipart.FileHeader
|
||||
var exists bool
|
||||
|
||||
// First check for standard "image" field
|
||||
if imageFiles, exists = mf.File["image"]; !exists || len(imageFiles) == 0 {
|
||||
// If not found, check for "image[]" field
|
||||
if imageFiles, exists = mf.File["image[]"]; !exists || len(imageFiles) == 0 {
|
||||
// If still not found, iterate through all fields to find any that start with "image["
|
||||
foundArrayImages := false
|
||||
for fieldName, files := range mf.File {
|
||||
if strings.HasPrefix(fieldName, "image[") && len(files) > 0 {
|
||||
foundArrayImages = true
|
||||
imageFiles = append(imageFiles, files...)
|
||||
}
|
||||
}
|
||||
|
||||
// If no image fields found at all
|
||||
if !foundArrayImages && (len(imageFiles) == 0) {
|
||||
return nil, errors.New("image is required")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(imageFiles) == 0 {
|
||||
return nil, errors.New("image is required")
|
||||
}
|
||||
|
||||
//if len(imageFiles) > 1 {
|
||||
// return nil, errors.New("only one image is supported for qwen edit")
|
||||
//}
|
||||
|
||||
// 获取base64编码的图片
|
||||
var imageBase64s []string
|
||||
for _, file := range imageFiles {
|
||||
image, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to open image file")
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
imageData, err := io.ReadAll(image)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to read image file")
|
||||
}
|
||||
|
||||
// 获取MIME类型
|
||||
mimeType := http.DetectContentType(imageData)
|
||||
|
||||
// 编码为base64
|
||||
base64Data := base64.StdEncoding.EncodeToString(imageData)
|
||||
|
||||
// 构造data URL格式
|
||||
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
|
||||
imageBase64s = append(imageBase64s, dataURL)
|
||||
image.Close()
|
||||
}
|
||||
return imageBase64s, nil
|
||||
}
|
||||
|
||||
func oaiFormEdit2AliImageEdit(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (*AliImageRequest, error) {
|
||||
count, err := request.ImageCount(true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var imageRequest AliImageRequest
|
||||
imageRequest.Model = request.Model
|
||||
imageRequest.ResponseFormat = request.ResponseFormat
|
||||
|
||||
imageBase64s, err := getImageBase64sFromForm(c, "image")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get image base64s from form failed: %w", err)
|
||||
}
|
||||
//dto.MediaContent{}
|
||||
mediaContents := make([]AliMediaContent, len(imageBase64s))
|
||||
for i, b64 := range imageBase64s {
|
||||
mediaContents[i] = AliMediaContent{
|
||||
Image: b64,
|
||||
}
|
||||
}
|
||||
mediaContents = append(mediaContents, AliMediaContent{
|
||||
Text: request.Prompt,
|
||||
})
|
||||
imageRequest.Input = AliImageInput{
|
||||
Messages: []AliMessage{
|
||||
{
|
||||
Role: "user",
|
||||
Content: mediaContents,
|
||||
},
|
||||
},
|
||||
}
|
||||
imageRequest.Parameters = AliImageParameters{
|
||||
N: common.GetPointer(uint(count)),
|
||||
Watermark: request.Watermark,
|
||||
}
|
||||
if request.BillingParameters != nil {
|
||||
imageRequest.Parameters.PromptExtend = request.BillingParameters.PromptExtend
|
||||
}
|
||||
return &imageRequest, nil
|
||||
}
|
||||
|
||||
func updateTask(info *relaycommon.RelayInfo, taskID string) (*AliResponse, error, []byte) {
|
||||
url := fmt.Sprintf("%s/api/v1/tasks/%s", info.ChannelBaseUrl, taskID)
|
||||
|
||||
var aliResponse AliResponse
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return &aliResponse, err, nil
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+info.ApiKey)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
common.SysLog("updateTask client.Do err: " + err.Error())
|
||||
return &aliResponse, err, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
|
||||
var response AliResponse
|
||||
err = common.Unmarshal(responseBody, &response)
|
||||
if err != nil {
|
||||
common.SysLog("updateTask NewDecoder err: " + err.Error())
|
||||
return &aliResponse, err, nil
|
||||
}
|
||||
|
||||
return &response, nil, responseBody
|
||||
}
|
||||
|
||||
func asyncTaskWait(c *gin.Context, info *relaycommon.RelayInfo, taskID string) (*AliResponse, []byte, error) {
|
||||
waitSeconds := 10
|
||||
step := 0
|
||||
maxStep := 20
|
||||
|
||||
var taskResponse AliResponse
|
||||
var responseBody []byte
|
||||
|
||||
time.Sleep(time.Duration(5) * time.Second)
|
||||
|
||||
for {
|
||||
logger.LogDebug(c, "asyncTaskWait step %d/%d, wait %d seconds", step, maxStep, waitSeconds)
|
||||
step++
|
||||
rsp, err, body := updateTask(info, taskID)
|
||||
responseBody = body
|
||||
if err != nil {
|
||||
logger.LogWarn(c, "asyncTaskWait UpdateTask err: "+err.Error())
|
||||
time.Sleep(time.Duration(waitSeconds) * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
if rsp.Output.TaskStatus == "" {
|
||||
return &taskResponse, responseBody, nil
|
||||
}
|
||||
|
||||
switch rsp.Output.TaskStatus {
|
||||
case "FAILED":
|
||||
fallthrough
|
||||
case "CANCELED":
|
||||
fallthrough
|
||||
case "SUCCEEDED":
|
||||
fallthrough
|
||||
case "UNKNOWN":
|
||||
return rsp, responseBody, nil
|
||||
}
|
||||
if step >= maxStep {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Duration(waitSeconds) * time.Second)
|
||||
}
|
||||
|
||||
return nil, nil, fmt.Errorf("aliAsyncTaskWait timeout")
|
||||
}
|
||||
|
||||
func responseAli2OpenAIImage(c *gin.Context, response *AliResponse, originBody []byte, info *relaycommon.RelayInfo, responseFormat string) *dto.ImageResponse {
|
||||
imageResponse := dto.ImageResponse{
|
||||
Created: info.StartTime.Unix(),
|
||||
}
|
||||
|
||||
if len(response.Output.Results) > 0 {
|
||||
imageResponse.Data = response.Output.ResultToOpenAIImageDate(c, responseFormat)
|
||||
} else if len(response.Output.Choices) > 0 {
|
||||
imageResponse.Data = response.Output.ChoicesToOpenAIImageDate(c, responseFormat)
|
||||
}
|
||||
|
||||
imageResponse.Metadata = originBody
|
||||
return &imageResponse
|
||||
}
|
||||
|
||||
func aliImageHandler(a *Adaptor, c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) {
|
||||
responseFormat := ""
|
||||
if imageReq, ok := info.Request.(*dto.ImageRequest); ok {
|
||||
responseFormat = imageReq.ResponseFormat
|
||||
}
|
||||
|
||||
var aliTaskResponse AliResponse
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil
|
||||
}
|
||||
service.CloseResponseBodyGracefully(resp)
|
||||
err = common.Unmarshal(responseBody, &aliTaskResponse)
|
||||
if err != nil {
|
||||
return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil
|
||||
}
|
||||
|
||||
if aliTaskResponse.Message != "" {
|
||||
logger.LogError(c, "ali_async_task_failed: "+aliTaskResponse.Message)
|
||||
return types.NewError(errors.New(aliTaskResponse.Message), types.ErrorCodeBadResponse), nil
|
||||
}
|
||||
|
||||
var (
|
||||
aliResponse *AliResponse
|
||||
originRespBody []byte
|
||||
)
|
||||
|
||||
if a.IsSyncImageModel {
|
||||
aliResponse = &aliTaskResponse
|
||||
originRespBody = responseBody
|
||||
} else {
|
||||
// 异步图片模型需要轮询任务结果
|
||||
aliResponse, originRespBody, err = asyncTaskWait(c, info, aliTaskResponse.Output.TaskId)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeBadResponse), nil
|
||||
}
|
||||
if aliResponse.Output.TaskStatus != "SUCCEEDED" {
|
||||
return types.WithOpenAIError(types.OpenAIError{
|
||||
Message: aliResponse.Output.Message,
|
||||
Type: "ali_error",
|
||||
Param: "",
|
||||
Code: aliResponse.Output.Code,
|
||||
}, resp.StatusCode), nil
|
||||
}
|
||||
}
|
||||
|
||||
if a.IsSyncImageModel {
|
||||
logger.LogDebug(c, "ali_sync_image_result: %s", originRespBody)
|
||||
} else {
|
||||
logger.LogDebug(c, "ali_async_image_result: %s", originRespBody)
|
||||
}
|
||||
|
||||
imageResponses := responseAli2OpenAIImage(c, aliResponse, originRespBody, info, responseFormat)
|
||||
count := int64(aliResponse.Usage.ImageCount)
|
||||
if count < 0 || count > dto.MaxImageN {
|
||||
logger.LogWarn(c, "invalid Ali image usage count %d; falling back to response images or requested quantity", count)
|
||||
count = 0
|
||||
}
|
||||
if count == 0 {
|
||||
count = int64(len(imageResponses.Data))
|
||||
}
|
||||
if count > dto.MaxImageN {
|
||||
logger.LogWarn(c, "invalid Ali response image count %d; retaining requested quantity", count)
|
||||
} else if count > 0 {
|
||||
info.UpdateImageCount(count)
|
||||
}
|
||||
jsonResponse, err := common.Marshal(imageResponses)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
|
||||
}
|
||||
service.IOCopyBytesGracefully(c, resp, jsonResponse)
|
||||
|
||||
return nil, &dto.Usage{}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package ali
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
func oaiFormEdit2WanxImageEdit(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (*AliImageRequest, error) {
|
||||
count, err := request.ImageCount(true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var imageRequest AliImageRequest
|
||||
imageRequest.Model = request.Model
|
||||
imageRequest.ResponseFormat = request.ResponseFormat
|
||||
wanInput := WanImageInput{
|
||||
Prompt: request.Prompt,
|
||||
}
|
||||
|
||||
if err := common.UnmarshalBodyReusable(c, &wanInput); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wanInput.Images, err = getImageBase64sFromForm(c, "image"); err != nil {
|
||||
return nil, fmt.Errorf("get image base64s from form failed: %w", err)
|
||||
}
|
||||
imageRequest.Input = wanInput
|
||||
imageRequest.Parameters = AliImageParameters{
|
||||
N: common.GetPointer(uint(count)),
|
||||
}
|
||||
if request.BillingParameters != nil {
|
||||
imageRequest.Parameters.PromptExtend = request.BillingParameters.PromptExtend
|
||||
}
|
||||
|
||||
return &imageRequest, nil
|
||||
}
|
||||
|
||||
func isOldWanModel(modelName string) bool {
|
||||
return strings.Contains(modelName, "wan") &&
|
||||
!lo.SomeBy([]string{"wan2.6", "wan2.7"}, func(v string) bool { return strings.Contains(modelName, v) })
|
||||
}
|
||||
|
||||
func isWanModel(modelName string) bool {
|
||||
return strings.Contains(modelName, "wan")
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package ali
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// DashScope web-search tool-price keys (search_strategy_* prices in
|
||||
// setting/operation_setting/tools.go). The compatible-mode Chat encoder never
|
||||
// writes search_strategy, so DashScope runs turbo; the Responses API "bills
|
||||
// web search like the agent strategy"; the Anthropic-compatible endpoint
|
||||
// publishes no search price and is billed like agent too (owner decision N9).
|
||||
const (
|
||||
aliSearchTurboKey = "search_strategy_turbo"
|
||||
aliSearchAgentKey = "search_strategy_agent"
|
||||
)
|
||||
|
||||
// aliMaxAssignedSites is the documented search_options.assigned_site_list
|
||||
// limit; the list only applies to the default turbo strategy.
|
||||
const aliMaxAssignedSites = 25
|
||||
|
||||
// encodeWebSearch maps hosted web search onto the DashScope OpenAI-compatible
|
||||
// Chat fields enable_search and search_options
|
||||
// (https://help.aliyun.com/zh/model-studio/web-search). search_strategy is
|
||||
// never written, so DashScope runs its default turbo strategy, and
|
||||
// search_context_size is not mapped to a strategy: a strategy changes the
|
||||
// price, not the amount of retrieved context.
|
||||
//
|
||||
// enable_search only works on the models DashScope lists. Other models answer
|
||||
// HTTP 400 "This model does not support enable_search.", and models that only
|
||||
// accept search_strategy=agent (qwen3.8-omni-flash) answer 400 as well. The
|
||||
// encoder sees no model support matrix and does not gate on model names, so
|
||||
// the request is sent as is and the upstream 400 reaches the client.
|
||||
func encodeWebSearch(info *relaycommon.RelayInfo) convmeta.WebSearchEncoder {
|
||||
return func(call convmeta.WebSearchCall) (*convmeta.WebSearchEncoding, error) {
|
||||
if call.Chat == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var diagnostics []types.ConversionDiagnostic
|
||||
options := map[string]any{}
|
||||
if call.Forced {
|
||||
options["forced_search"] = true
|
||||
// Compatible-mode Chat responses report no search usage; a forced
|
||||
// search is the only proof that one ran.
|
||||
info.RecordRequestInferredToolCall(aliSearchTurboKey)
|
||||
}
|
||||
spec := call.Spec
|
||||
switch {
|
||||
case len(spec.AllowedDomains) > aliMaxAssignedSites:
|
||||
diagnostics = append(diagnostics, call.SemanticLoss("unsupported_domain_filter", "DashScope assigned_site_list accepts at most 25 sites; the domain filter was dropped"))
|
||||
case len(spec.AllowedDomains) > 0:
|
||||
options["assigned_site_list"] = spec.AllowedDomains
|
||||
}
|
||||
if len(spec.BlockedDomains) > 0 {
|
||||
diagnostics = append(diagnostics, call.SemanticLoss("unsupported_domain_filter", "DashScope web search cannot exclude domains; blocked_domains was dropped"))
|
||||
}
|
||||
if spec.MaxUses != nil {
|
||||
diagnostics = append(diagnostics, call.SemanticLoss("unsupported_search_limit", "DashScope web search cannot preserve max_uses"))
|
||||
}
|
||||
if len(spec.AllowedCallers) > 0 || spec.ExternalWebAccess != nil {
|
||||
diagnostics = append(diagnostics, call.SemanticLoss("unsupported_search_controls", "DashScope web search cannot preserve caller or external-access constraints"))
|
||||
}
|
||||
if spec.SearchContextSize != "" || spec.Location != nil || spec.ResponseInclusion != "" || len(spec.ReturnTokenBudget) > 0 {
|
||||
diagnostics = append(diagnostics, call.PresentationLoss("unsupported_search_tuning", "DashScope web search cannot preserve search context size, location, or result tuning"))
|
||||
}
|
||||
call.Chat.EnableSearch = json.RawMessage(`true`)
|
||||
if len(options) > 0 {
|
||||
encoded, err := common.Marshal(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
call.Chat.SearchOptions = encoded
|
||||
}
|
||||
return &convmeta.WebSearchEncoding{Diagnostics: diagnostics}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// aliResponsesToolUsage bills Responses web search by the count DashScope
|
||||
// reports in usage.x_tools.web_search.count (terminal frames carry it under
|
||||
// response.usage) and silences the web_search_call items under the protocol
|
||||
// keys. gjson truncates fractional values and reads non-numbers as zero;
|
||||
// SetBillableToolCount ignores negatives and settlement applies
|
||||
// MaxBillableToolCallCount.
|
||||
func aliResponsesToolUsage(raw []byte) map[string]int {
|
||||
count := gjson.GetBytes(raw, "usage.x_tools.web_search.count")
|
||||
if !count.Exists() {
|
||||
count = gjson.GetBytes(raw, "response.usage.x_tools.web_search.count")
|
||||
}
|
||||
if !count.Exists() {
|
||||
return nil
|
||||
}
|
||||
return map[string]int{
|
||||
aliSearchAgentKey: int(count.Int()),
|
||||
dto.BuildInToolWebSearch: 0,
|
||||
dto.BuildInToolWebSearchPreview: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// aliAnthropicWebSearchEntrypoint is the client marker DashScope's
|
||||
// Anthropic-compatible endpoint requires in system before it runs web search:
|
||||
// "使用 Anthropic SDK 或 HTTP 直接调用时,需在 system 中传入客户端标识
|
||||
// x-anthropic-billing-header: cc_entrypoint=cli;,否则联网搜索不生效。"
|
||||
// (https://help.aliyun.com/zh/model-studio/web-search). The marker must be a
|
||||
// system text block: sent as a plain string system, qwen3.8-flash answered
|
||||
// without searching (2026-10-05).
|
||||
const aliAnthropicWebSearchEntrypoint = "x-anthropic-billing-header: cc_entrypoint=cli;"
|
||||
|
||||
// ensureAnthropicWebSearchEntrypoint prepends the marker block to system when
|
||||
// the request carries a web_search_* tool; a string system becomes the block
|
||||
// after the marker. It is idempotent: a system that already names
|
||||
// cc_entrypoint=cli, from the client or an earlier call, is left as is.
|
||||
func ensureAnthropicWebSearchEntrypoint(request *dto.ClaudeRequest) {
|
||||
hasWebSearch := false
|
||||
for _, tool := range request.GetTools() {
|
||||
var toolType string
|
||||
switch typed := tool.(type) {
|
||||
case map[string]any:
|
||||
toolType, _ = typed["type"].(string)
|
||||
case *dto.ClaudeWebSearchTool:
|
||||
toolType = typed.Type
|
||||
case dto.ClaudeWebSearchTool:
|
||||
toolType = typed.Type
|
||||
}
|
||||
if strings.HasPrefix(toolType, "web_search_") {
|
||||
hasWebSearch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasWebSearch {
|
||||
return
|
||||
}
|
||||
const present = "cc_entrypoint=cli"
|
||||
var blocks []dto.ClaudeMediaMessage
|
||||
if request.System == nil || request.IsStringSystem() {
|
||||
system := request.GetStringSystem()
|
||||
if strings.Contains(system, present) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(system) != "" {
|
||||
block := dto.ClaudeMediaMessage{Type: dto.ContentTypeText}
|
||||
block.SetText(system)
|
||||
blocks = []dto.ClaudeMediaMessage{block}
|
||||
}
|
||||
} else {
|
||||
blocks = request.ParseSystem()
|
||||
for _, block := range blocks {
|
||||
if block.Type == dto.ContentTypeText && strings.Contains(block.GetText(), present) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
marker := dto.ClaudeMediaMessage{Type: dto.ContentTypeText}
|
||||
marker.SetText(aliAnthropicWebSearchEntrypoint)
|
||||
request.System = append([]dto.ClaudeMediaMessage{marker}, blocks...)
|
||||
}
|
||||
Reference in new issue
Block a user