164 lines
6.6 KiB
Go
164 lines
6.6 KiB
Go
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...)
|
|
}
|