Files

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...)
}