Files
new-api/dto/channel_settings.go
T
Calcium-Ion c36418c863 feat: enhance text protocol conversion and advanced custom routing (#5825)
* refactor: consolidate relay protocol converters

* refactor relayconvert text converters

* feat: refine relay converters and advanced custom routing

* refactor: enhance logging and add thought signature handling for Gemini requests

* refactor: enhance channel cache and pricing endpoint handling for advanced custom models

* feat: preserve billing usage semantics

* feat: add protocol-aware billing usage

* Delete useless files

* chore: update action versions in workflow files

* chore: update Docker action versions in workflow files

* fix: harden billing usage settlement and hot-path route matching

- estimate Gemini completion tokens locally when billable usageMetadata is
  prompt-only but output content was received (e.g. client aborts the stream
  before the final chunk), and rebuild the attached billing_usage as estimated
  so settlement does not bill zero output tokens
- guard NewClaudeMessagesBillingUsage against all-zero ClaudeUsage, matching
  the OpenAI/Gemini constructors, so a zero billing_usage cannot override a
  non-zero top-level usage during settlement
- cache compiled advanced-custom route model regexes; they run on the request
  hot path and were recompiled per request
- move the effectiveBillingUsage remap to PostTextConsumeQuota only, and
  document that calculateTextQuotaSummary expects remapped usage
- document the updatePricingLock -> channelSyncLock lock ordering that
  InitChannelCache/CacheUpdateChannel rely on, and the aux-struct pitfall in
  GeminiChatResponse.UnmarshalJSON
2026-07-11 20:44:12 +08:00

494 lines
19 KiB
Go

package dto
import (
"fmt"
"net/url"
"regexp"
"strings"
"sync"
"github.com/QuantumNous/new-api/constant"
)
type ChannelSettings struct {
ForceFormat bool `json:"force_format,omitempty"`
ThinkingToContent bool `json:"thinking_to_content,omitempty"`
Proxy string `json:"proxy"`
PassThroughBodyEnabled bool `json:"pass_through_body_enabled,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
SystemPromptOverride bool `json:"system_prompt_override,omitempty"`
}
type VertexKeyType string
const (
VertexKeyTypeJSON VertexKeyType = "json"
VertexKeyTypeAPIKey VertexKeyType = "api_key"
)
type AwsKeyType string
const (
AwsKeyTypeAKSK AwsKeyType = "ak_sk" // 默认
AwsKeyTypeApiKey AwsKeyType = "api_key"
)
type ChannelOtherSettings struct {
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"`
ClaudeBetaQuery bool `json:"claude_beta_query,omitempty"` // Claude 渠道是否强制追加 ?beta=true
AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费)
AllowInferenceGeo bool `json:"allow_inference_geo,omitempty"` // 是否允许 inference_geo 透传(仅 Claude,默认过滤以满足数据驻留合规
AllowSpeed bool `json:"allow_speed,omitempty"` // 是否允许 speed 透传(仅 Claude,默认过滤以避免意外切换推理速度模式)
AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私)
DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用)
AllowIncludeObfuscation bool `json:"allow_include_obfuscation,omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护)
DisableTaskPollingSleep bool `json:"disable_task_polling_sleep,omitempty"` // 是否跳过异步任务轮询间隔
AwsKeyType AwsKeyType `json:"aws_key_type,omitempty"`
UpstreamModelUpdateCheckEnabled bool `json:"upstream_model_update_check_enabled,omitempty"` // 是否检测上游模型更新
UpstreamModelUpdateAutoSyncEnabled bool `json:"upstream_model_update_auto_sync_enabled,omitempty"` // 是否自动同步上游模型更新
UpstreamModelUpdateLastCheckTime int64 `json:"upstream_model_update_last_check_time,omitempty"` // 上次检测时间
UpstreamModelUpdateLastDetectedModels []string `json:"upstream_model_update_last_detected_models,omitempty"` // 上次检测到的可加入模型
UpstreamModelUpdateLastRemovedModels []string `json:"upstream_model_update_last_removed_models,omitempty"` // 上次检测到的可删除模型
UpstreamModelUpdateIgnoredModels []string `json:"upstream_model_update_ignored_models,omitempty"` // 手动忽略的模型
AdvancedCustom *AdvancedCustomConfig `json:"advanced_custom,omitempty"`
}
func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool {
if s == nil || s.OpenRouterEnterprise == nil {
return false
}
return *s.OpenRouterEnterprise
}
const (
advancedCustomConverterNone = "none"
advancedCustomConverterClaudeMessagesToOpenAIChat = "anthropic_messages_to_openai_chat_completions"
advancedCustomConverterOpenAIChatToClaudeMessages = "openai_chat_completions_to_anthropic_messages"
advancedCustomConverterOpenAIChatToOpenAIResponses = "openai_chat_completions_to_openai_responses"
advancedCustomConverterOpenAIResponsesToOpenAIChat = "openai_responses_to_openai_chat_completions"
advancedCustomConverterOpenAIResponsesToGemini = "openai_responses_to_gemini_generate_content"
advancedCustomConverterGeminiContentToOpenAIChat = "gemini_generate_content_to_openai_chat_completions"
advancedCustomConverterOpenAIChatToGeminiContent = "openai_chat_completions_to_gemini_generate_content"
)
const (
AdvancedCustomAuthTypeNone = "none"
AdvancedCustomAuthTypeHeader = "header"
AdvancedCustomAuthTypeQuery = "query"
)
type AdvancedCustomConfig struct {
Routes []AdvancedCustomRoute `json:"advanced_routes,omitempty"`
}
type AdvancedCustomRoute struct {
IncomingPath string `json:"incoming_path,omitempty"`
UpstreamPath string `json:"upstream_path,omitempty"`
Converter string `json:"converter,omitempty"`
Models []string `json:"models,omitempty"`
Auth *AdvancedCustomRouteAuth `json:"auth,omitempty"`
}
type AdvancedCustomRouteAuth struct {
Type string `json:"type,omitempty"`
Name string `json:"name,omitempty"`
Value string `json:"value,omitempty"`
}
const (
advancedCustomModelPlaceholder = "{model}"
advancedCustomModelRegexPrefix = "re:"
)
const (
advancedCustomEndpointPathOpenAIChat = "/v1/chat/completions"
advancedCustomEndpointPathOpenAIResponses = "/v1/responses"
advancedCustomEndpointPathOpenAIResponsesCompact = "/v1/responses/compact"
advancedCustomEndpointPathClaudeMessages = "/v1/messages"
advancedCustomEndpointPathJinaRerank = "/v1/rerank"
advancedCustomEndpointPathImageGeneration = "/v1/images/generations"
advancedCustomEndpointPathEmbeddings = "/v1/embeddings"
)
// MatchPath returns the first route whose IncomingPath matches requestPath.
// Matching mirrors the relay adaptor: exact match, {model} placeholder, and
// :generateContent <-> :streamGenerateContent equivalence.
func (c *AdvancedCustomConfig) MatchPath(requestPath string) (AdvancedCustomRoute, bool) {
if c == nil {
return AdvancedCustomRoute{}, false
}
for _, route := range c.Routes {
if matchAdvancedCustomIncomingPath(strings.TrimSpace(route.IncomingPath), requestPath) {
return route, true
}
}
return AdvancedCustomRoute{}, false
}
// MatchPathForModel returns the first route whose IncomingPath and Models match.
// An empty Models list is a catch-all fallback for that incoming path.
func (c *AdvancedCustomConfig) MatchPathForModel(requestPath string, model string) (AdvancedCustomRoute, bool) {
if c == nil {
return AdvancedCustomRoute{}, false
}
model = strings.TrimSpace(model)
for _, route := range c.Routes {
if matchAdvancedCustomIncomingPath(strings.TrimSpace(route.IncomingPath), requestPath) &&
matchAdvancedCustomRouteModel(route.Models, model) {
return route, true
}
}
return AdvancedCustomRoute{}, false
}
// SupportsPath reports whether any route matches requestPath.
func (c *AdvancedCustomConfig) SupportsPath(requestPath string) bool {
_, ok := c.MatchPath(requestPath)
return ok
}
// SupportsPathForModel reports whether any route matches requestPath and model.
func (c *AdvancedCustomConfig) SupportsPathForModel(requestPath string, model string) bool {
_, ok := c.MatchPathForModel(requestPath, model)
return ok
}
func (c *AdvancedCustomConfig) SupportedEndpointTypesForModel(model string) []constant.EndpointType {
if c == nil {
return nil
}
model = strings.TrimSpace(model)
endpoints := make([]constant.EndpointType, 0, len(c.Routes))
seen := make(map[constant.EndpointType]struct{}, len(c.Routes))
for _, route := range c.Routes {
if !matchAdvancedCustomRouteModel(route.Models, model) {
continue
}
endpointType, ok := advancedCustomEndpointTypeFromIncomingPath(strings.TrimSpace(route.IncomingPath))
if !ok {
continue
}
if _, exists := seen[endpointType]; exists {
continue
}
seen[endpointType] = struct{}{}
endpoints = append(endpoints, endpointType)
}
return endpoints
}
func advancedCustomEndpointTypeFromIncomingPath(incomingPath string) (constant.EndpointType, bool) {
switch incomingPath {
case advancedCustomEndpointPathOpenAIChat:
return constant.EndpointTypeOpenAI, true
case advancedCustomEndpointPathOpenAIResponses:
return constant.EndpointTypeOpenAIResponse, true
case advancedCustomEndpointPathOpenAIResponsesCompact:
return constant.EndpointTypeOpenAIResponseCompact, true
case advancedCustomEndpointPathClaudeMessages:
return constant.EndpointTypeAnthropic, true
case advancedCustomEndpointPathJinaRerank:
return constant.EndpointTypeJinaRerank, true
case advancedCustomEndpointPathImageGeneration:
return constant.EndpointTypeImageGeneration, true
case advancedCustomEndpointPathEmbeddings:
return constant.EndpointTypeEmbeddings, true
default:
if isAdvancedCustomGeminiIncomingPath(incomingPath) {
return constant.EndpointTypeGemini, true
}
return "", false
}
}
func isAdvancedCustomGeminiIncomingPath(incomingPath string) bool {
if !strings.HasPrefix(incomingPath, "/v1beta/models/") {
return false
}
return strings.Contains(incomingPath, ":generateContent") || strings.Contains(incomingPath, ":streamGenerateContent")
}
func matchAdvancedCustomRouteModel(models []string, model string) bool {
normalizedModels := normalizeAdvancedCustomRouteModels(models)
if len(normalizedModels) == 0 {
return true
}
for _, allowedModel := range normalizedModels {
if matchAdvancedCustomRouteModelRule(allowedModel, model) {
return true
}
}
return false
}
// advancedCustomModelRegexCache caches compiled route model patterns. Route model
// matching runs on the request hot path (distributor affinity, ability filtering,
// channel cache filtering, adaptor resolve), so patterns must not be recompiled per
// request. Invalid patterns are cached as nil to avoid recompiling them as well.
var advancedCustomModelRegexCache sync.Map // pattern string -> *regexp.Regexp (nil when invalid)
func compileAdvancedCustomModelRegex(pattern string) *regexp.Regexp {
if cached, ok := advancedCustomModelRegexCache.Load(pattern); ok {
re, _ := cached.(*regexp.Regexp)
return re
}
re, err := regexp.Compile(pattern)
if err != nil {
re = nil
}
advancedCustomModelRegexCache.Store(pattern, re)
return re
}
func matchAdvancedCustomRouteModelRule(rule string, model string) bool {
if !strings.HasPrefix(rule, advancedCustomModelRegexPrefix) {
return rule == model
}
pattern := strings.TrimPrefix(rule, advancedCustomModelRegexPrefix)
if pattern == "" {
return false
}
re := compileAdvancedCustomModelRegex(pattern)
return re != nil && re.MatchString(model)
}
func matchAdvancedCustomIncomingPath(configuredPath string, requestPath string) bool {
if matchAdvancedCustomIncomingPathTemplate(configuredPath, requestPath) {
return true
}
if strings.Contains(configuredPath, ":generateContent") {
streamPath := strings.Replace(configuredPath, ":generateContent", ":streamGenerateContent", 1)
return matchAdvancedCustomIncomingPathTemplate(streamPath, requestPath)
}
return false
}
func matchAdvancedCustomIncomingPathTemplate(configuredPath string, requestPath string) bool {
if !strings.Contains(configuredPath, advancedCustomModelPlaceholder) {
return configuredPath == requestPath
}
parts := strings.Split(configuredPath, advancedCustomModelPlaceholder)
if len(parts) != 2 {
return false
}
if !strings.HasPrefix(requestPath, parts[0]) || !strings.HasSuffix(requestPath, parts[1]) {
return false
}
model := strings.TrimSuffix(strings.TrimPrefix(requestPath, parts[0]), parts[1])
return model != "" && !strings.Contains(model, "/")
}
func IsAdvancedCustomConverterAllowed(converter string) bool {
switch converter {
case advancedCustomConverterNone,
advancedCustomConverterClaudeMessagesToOpenAIChat,
advancedCustomConverterOpenAIChatToClaudeMessages,
advancedCustomConverterOpenAIChatToOpenAIResponses,
advancedCustomConverterOpenAIResponsesToOpenAIChat,
advancedCustomConverterOpenAIResponsesToGemini,
advancedCustomConverterGeminiContentToOpenAIChat,
advancedCustomConverterOpenAIChatToGeminiContent:
return true
default:
return false
}
}
func (c *AdvancedCustomConfig) Validate() error {
if c == nil {
return fmt.Errorf("advanced_custom is required")
}
if len(c.Routes) == 0 {
return fmt.Errorf("advanced_custom requires at least one route")
}
paths := make(map[string]*advancedCustomPathModelState, len(c.Routes))
for i := range c.Routes {
route := c.Routes[i]
route.IncomingPath = strings.TrimSpace(route.IncomingPath)
upstreamPath := strings.TrimSpace(route.UpstreamPath)
route.Converter = strings.TrimSpace(route.Converter)
if route.Converter == "" {
route.Converter = advancedCustomConverterNone
}
if route.IncomingPath == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path is required", i)
}
if !strings.HasPrefix(route.IncomingPath, "/") {
return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must start with /", i)
}
if strings.Contains(route.IncomingPath, "?") {
return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must not include query", i)
}
if err := validateAdvancedCustomRouteModels(i, route.IncomingPath, route.Models, paths); err != nil {
return err
}
if upstreamPath == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path is required", i)
}
if err := validateAdvancedCustomUpstreamTarget(i, upstreamPath); err != nil {
return err
}
if !IsAdvancedCustomConverterAllowed(route.Converter) {
return fmt.Errorf("advanced_custom.advanced_routes[%d].converter is not registered: %s", i, route.Converter)
}
if err := validateAdvancedCustomConverterPath(i, route.IncomingPath, route.Converter); err != nil {
return err
}
if err := validateAdvancedCustomRouteAuth(i, route.Auth); err != nil {
return err
}
}
return nil
}
type advancedCustomPathModelState struct {
catchAllIndex int
modelIndexes map[string]int
}
func validateAdvancedCustomRouteModels(index int, incomingPath string, models []string, paths map[string]*advancedCustomPathModelState) error {
state := paths[incomingPath]
if state == nil {
state = &advancedCustomPathModelState{
catchAllIndex: -1,
modelIndexes: make(map[string]int),
}
paths[incomingPath] = state
}
normalizedModels := normalizeAdvancedCustomRouteModels(models)
if len(normalizedModels) == 0 {
if state.catchAllIndex >= 0 {
return fmt.Errorf("advanced_custom.advanced_routes[%d].models catch-all already exists for incoming_path: %s", index, incomingPath)
}
state.catchAllIndex = index
return nil
}
if state.catchAllIndex >= 0 {
return fmt.Errorf("advanced_custom.advanced_routes[%d].models catch-all route must be last for incoming_path: %s", index, incomingPath)
}
seenInRoute := make(map[string]struct{}, len(normalizedModels))
for _, model := range normalizedModels {
if err := validateAdvancedCustomRouteModelRule(index, incomingPath, model); err != nil {
return err
}
if _, exists := seenInRoute[model]; exists {
return fmt.Errorf("advanced_custom.advanced_routes[%d].models contains duplicate model for incoming_path %s: %s", index, incomingPath, model)
}
seenInRoute[model] = struct{}{}
if existingIndex, exists := state.modelIndexes[model]; exists {
return fmt.Errorf("advanced_custom.advanced_routes[%d].models overlaps with advanced_routes[%d] for incoming_path %s: %s", index, existingIndex, incomingPath, model)
}
state.modelIndexes[model] = index
}
return nil
}
func validateAdvancedCustomRouteModelRule(index int, incomingPath string, model string) error {
if !strings.HasPrefix(model, advancedCustomModelRegexPrefix) {
return nil
}
pattern := strings.TrimPrefix(model, advancedCustomModelRegexPrefix)
if pattern == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].models regex is empty for incoming_path %s: %s", index, incomingPath, model)
}
if _, err := regexp.Compile(pattern); err != nil {
return fmt.Errorf("advanced_custom.advanced_routes[%d].models regex is invalid for incoming_path %s: %s", index, incomingPath, model)
}
return nil
}
func normalizeAdvancedCustomRouteModels(models []string) []string {
if len(models) == 0 {
return nil
}
normalized := make([]string, 0, len(models))
for _, model := range models {
model = strings.TrimSpace(model)
if model != "" {
normalized = append(normalized, model)
}
}
return normalized
}
func validateAdvancedCustomUpstreamTarget(index int, upstreamPath string) error {
if strings.HasPrefix(upstreamPath, "/") {
if strings.HasPrefix(upstreamPath, "//") {
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must be a full URL or a path starting with /", index)
}
return nil
}
parsedURL, err := url.Parse(upstreamPath)
if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must be a full URL or a path starting with /", index)
}
if !strings.EqualFold(parsedURL.Scheme, "http") && !strings.EqualFold(parsedURL.Scheme, "https") {
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must use http or https", index)
}
return nil
}
func validateAdvancedCustomConverterPath(index int, incomingPath string, converter string) error {
switch converter {
case advancedCustomConverterNone:
return nil
case advancedCustomConverterClaudeMessagesToOpenAIChat:
if incomingPath == "/v1/messages" {
return nil
}
case advancedCustomConverterOpenAIChatToClaudeMessages,
advancedCustomConverterOpenAIChatToOpenAIResponses,
advancedCustomConverterOpenAIChatToGeminiContent:
if incomingPath == "/v1/chat/completions" {
return nil
}
case advancedCustomConverterOpenAIResponsesToOpenAIChat:
if incomingPath == "/v1/responses" {
return nil
}
case advancedCustomConverterOpenAIResponsesToGemini:
if incomingPath == "/v1/responses" {
return nil
}
case advancedCustomConverterGeminiContentToOpenAIChat:
if strings.Contains(incomingPath, ":generateContent") || strings.Contains(incomingPath, ":streamGenerateContent") {
return nil
}
}
return fmt.Errorf("advanced_custom.advanced_routes[%d].converter does not match incoming_path: %s", index, converter)
}
func validateAdvancedCustomRouteAuth(index int, auth *AdvancedCustomRouteAuth) error {
if auth == nil {
return nil
}
authType := strings.TrimSpace(auth.Type)
switch authType {
case AdvancedCustomAuthTypeNone:
return nil
case AdvancedCustomAuthTypeHeader, AdvancedCustomAuthTypeQuery:
if strings.TrimSpace(auth.Name) == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].auth.name is required", index)
}
if strings.TrimSpace(auth.Value) == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].auth.value is required", index)
}
return nil
default:
return fmt.Errorf("advanced_custom.advanced_routes[%d].auth.type is invalid: %s", index, auth.Type)
}
}