Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eae53ac398 | |||
| ef572392b7 | |||
| dfda90a425 | |||
| ac04c802a7 | |||
| 5812163faa | |||
| 5568423e5b | |||
| 14b3dac82c | |||
| 71460cba15 | |||
| 2432181ca0 | |||
| ecc10f80c7 | |||
| ffa8a42784 | |||
| 0f0ba4adc4 | |||
| db96248c5b | |||
| e8eea5d3ee | |||
| 4ffe7dc50b | |||
| 389ddfa8a9 | |||
| 283271064c | |||
| d2a91effef | |||
| e71be75067 | |||
| 3c21652e28 |
+5
-3
@@ -213,9 +213,11 @@ docker run --name new-api -d --restart always \
|
||||
- 🚦 User-level model rate limiting
|
||||
|
||||
**Format Conversion:**
|
||||
- 🔄 OpenAI ⇄ Claude Messages
|
||||
- 🔄 OpenAI ⇄ Gemini Chat
|
||||
- 🔄 Thinking-to-content functionality
|
||||
- 🔄 **OpenAI Compatible ⇄ Claude Messages**
|
||||
- 🔄 **OpenAI Compatible → Google Gemini**
|
||||
- 🔄 **Google Gemini → OpenAI Compatible** - Text only, function calling not supported yet
|
||||
- 🚧 **OpenAI Compatible ⇄ OpenAI Responses** - In development
|
||||
- 🔄 **Thinking-to-content functionality**
|
||||
|
||||
**Reasoning Effort Support:**
|
||||
|
||||
|
||||
+5
-3
@@ -212,9 +212,11 @@ docker run --name new-api -d --restart always \
|
||||
- 🚦 Limitation du débit du modèle pour les utilisateurs
|
||||
|
||||
**Conversion de format:**
|
||||
- 🔄 OpenAI ⇄ Claude Messages
|
||||
- 🔄 OpenAI ⇄ Gemini Chat
|
||||
- 🔄 Fonctionnalité de la pensée au contenu
|
||||
- 🔄 **OpenAI Compatible ⇄ Claude Messages**
|
||||
- 🔄 **OpenAI Compatible → Google Gemini**
|
||||
- 🔄 **Google Gemini → OpenAI Compatible** - Texte uniquement, les appels de fonction ne sont pas encore pris en charge
|
||||
- 🚧 **OpenAI Compatible ⇄ OpenAI Responses** - En développement
|
||||
- 🔄 **Fonctionnalité de la pensée au contenu**
|
||||
|
||||
**Prise en charge de l'effort de raisonnement:**
|
||||
|
||||
|
||||
+5
-3
@@ -218,9 +218,11 @@ docker run --name new-api -d --restart always \
|
||||
- 🚦 ユーザーレベルモデルレート制限
|
||||
|
||||
**フォーマット変換:**
|
||||
- 🔄 OpenAI ⇄ Claude Messages
|
||||
- 🔄 OpenAI ⇄ Gemini Chat
|
||||
- 🔄 思考からコンテンツへの機能
|
||||
- 🔄 **OpenAI Compatible ⇄ Claude Messages**
|
||||
- 🔄 **OpenAI Compatible → Google Gemini**
|
||||
- 🔄 **Google Gemini → OpenAI Compatible** - テキストのみ、関数呼び出しはまだサポートされていません
|
||||
- 🚧 **OpenAI Compatible ⇄ OpenAI Responses** - 開発中
|
||||
- 🔄 **思考からコンテンツへの機能**
|
||||
|
||||
**Reasoning Effort サポート:**
|
||||
|
||||
|
||||
@@ -214,9 +214,11 @@ docker run --name new-api -d --restart always \
|
||||
- 🚦 用户级别模型限流
|
||||
|
||||
**格式转换:**
|
||||
- 🔄 OpenAI ⇄ Claude Messages
|
||||
- 🔄 OpenAI ⇄ Gemini Chat
|
||||
- 🔄 思考转内容功能
|
||||
- 🔄 **OpenAI Compatible ⇄ Claude Messages**
|
||||
- 🔄 **OpenAI Compatible → Google Gemini**
|
||||
- 🔄 **Google Gemini → OpenAI Compatible** - 仅支持文本,暂不支持函数调用
|
||||
- 🚧 **OpenAI Compatible ⇄ OpenAI Responses** - 开发中
|
||||
- 🔄 **思考转内容功能**
|
||||
|
||||
**Reasoning Effort 支持:**
|
||||
|
||||
|
||||
+1
-1
@@ -118,7 +118,7 @@ func initConstantEnv() {
|
||||
constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 64)
|
||||
constant.StreamScannerMaxBufferMB = GetEnvOrDefault("STREAM_SCANNER_MAX_BUFFER_MB", 64)
|
||||
// MaxRequestBodyMB 请求体最大大小(解压后),用于防止超大请求/zip bomb导致内存暴涨
|
||||
constant.MaxRequestBodyMB = GetEnvOrDefault("MAX_REQUEST_BODY_MB", 64)
|
||||
constant.MaxRequestBodyMB = GetEnvOrDefault("MAX_REQUEST_BODY_MB", 128)
|
||||
// ForceStreamOption 覆盖请求参数,强制返回usage信息
|
||||
constant.ForceStreamOption = GetEnvOrDefaultBool("FORCE_STREAM_OPTION", true)
|
||||
constant.CountToken = GetEnvOrDefaultBool("CountToken", true)
|
||||
|
||||
@@ -16,6 +16,8 @@ var (
|
||||
maskURLPattern = regexp.MustCompile(`(http|https)://[^\s/$.?#].[^\s]*`)
|
||||
maskDomainPattern = regexp.MustCompile(`\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\b`)
|
||||
maskIPPattern = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
|
||||
// maskApiKeyPattern matches patterns like 'api_key:xxx' or "api_key:xxx" to mask the API key value
|
||||
maskApiKeyPattern = regexp.MustCompile(`(['"]?)api_key:([^\s'"]+)(['"]?)`)
|
||||
)
|
||||
|
||||
func GetStringIfEmpty(str string, defaultValue string) string {
|
||||
@@ -235,5 +237,8 @@ func MaskSensitiveInfo(str string) string {
|
||||
// Mask IP addresses
|
||||
str = maskIPPattern.ReplaceAllString(str, "***.***.***.***")
|
||||
|
||||
// Mask API keys (e.g., "api_key:AIzaSyAAAaUooTUni8AdaOkSRMda30n_Q4vrV70" -> "api_key:***")
|
||||
str = maskApiKeyPattern.ReplaceAllString(str, "${1}api_key:***${3}")
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
@@ -193,6 +193,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string)
|
||||
}
|
||||
}
|
||||
|
||||
info.IsChannelTest = true
|
||||
info.InitChannelMeta(c)
|
||||
|
||||
err = helper.ModelMappedHelper(c, info, request)
|
||||
@@ -309,6 +310,27 @@ func testChannel(channel *model.Channel, testModel string, endpointType string)
|
||||
newAPIError: types.NewError(err, types.ErrorCodeJsonMarshalFailed),
|
||||
}
|
||||
}
|
||||
|
||||
//jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings)
|
||||
//if err != nil {
|
||||
// return testResult{
|
||||
// context: c,
|
||||
// localErr: err,
|
||||
// newAPIError: types.NewError(err, types.ErrorCodeConvertRequestFailed),
|
||||
// }
|
||||
//}
|
||||
|
||||
if len(info.ParamOverride) > 0 {
|
||||
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info))
|
||||
if err != nil {
|
||||
return testResult{
|
||||
context: c,
|
||||
localErr: err,
|
||||
newAPIError: types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requestBody := bytes.NewBuffer(jsonData)
|
||||
c.Request.Body = io.NopCloser(requestBody)
|
||||
resp, err := adaptor.DoRequest(c, info, requestBody)
|
||||
|
||||
+47
-3
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/relay/channel/gemini"
|
||||
"github.com/QuantumNous/new-api/relay/channel/ollama"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
|
||||
@@ -260,11 +261,37 @@ func FetchUpstreamModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 对于 Gemini 渠道,使用特殊处理
|
||||
if channel.Type == constant.ChannelTypeGemini {
|
||||
// 获取用于请求的可用密钥(多密钥渠道优先使用启用状态的密钥)
|
||||
key, _, apiErr := channel.GetNextEnabledKey()
|
||||
if apiErr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("获取渠道密钥失败: %s", apiErr.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
models, err := gemini.FetchGeminiModels(baseURL, key, channel.GetSetting().Proxy)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("获取Gemini模型失败: %s", err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": models,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var url string
|
||||
switch channel.Type {
|
||||
case constant.ChannelTypeGemini:
|
||||
// curl https://example.com/v1beta/models?key=$GEMINI_API_KEY
|
||||
url = fmt.Sprintf("%s/v1beta/openai/models", baseURL) // Remove key in url since we need to use AuthHeader
|
||||
case constant.ChannelTypeAli:
|
||||
url = fmt.Sprintf("%s/compatible-mode/v1/models", baseURL)
|
||||
case constant.ChannelTypeZhipu_v4:
|
||||
@@ -1072,6 +1099,23 @@ func FetchModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Type == constant.ChannelTypeGemini {
|
||||
models, err := gemini.FetchGeminiModels(baseURL, key, "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("获取Gemini模型失败: %s", err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": models,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
url := fmt.Sprintf("%s/v1/models", baseURL)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/console_setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
|
||||
@@ -177,6 +178,15 @@ func UpdateOption(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
case "AutomaticDisableStatusCodes":
|
||||
_, err = operation_setting.ParseHTTPStatusCodeRanges(option.Value.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
case "console_setting.api_info":
|
||||
err = console_setting.ValidateConsoleSettings(option.Value.(string), "ApiInfo")
|
||||
if err != nil {
|
||||
|
||||
+2
-2
@@ -348,7 +348,7 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
|
||||
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
|
||||
if service.ShouldDisableChannel(channelError.ChannelType, err) && channelError.AutoBan {
|
||||
gopool.Go(func() {
|
||||
service.DisableChannel(channelError, err.Error())
|
||||
service.DisableChannel(channelError, err.ErrorWithStatusCode())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -378,7 +378,7 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
|
||||
adminInfo["multi_key_index"] = common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex)
|
||||
}
|
||||
other["admin_info"] = adminInfo
|
||||
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveError(), tokenId, 0, false, userGroup, other)
|
||||
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, 0, false, userGroup, other)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -341,6 +341,88 @@ type GeminiChatGenerationConfig struct {
|
||||
ImageConfig json.RawMessage `json:"imageConfig,omitempty"` // RawMessage to allow flexible image config
|
||||
}
|
||||
|
||||
// UnmarshalJSON allows GeminiChatGenerationConfig to accept both snake_case and camelCase fields.
|
||||
func (c *GeminiChatGenerationConfig) UnmarshalJSON(data []byte) error {
|
||||
type Alias GeminiChatGenerationConfig
|
||||
var aux struct {
|
||||
Alias
|
||||
TopPSnake float64 `json:"top_p,omitempty"`
|
||||
TopKSnake float64 `json:"top_k,omitempty"`
|
||||
MaxOutputTokensSnake uint `json:"max_output_tokens,omitempty"`
|
||||
CandidateCountSnake int `json:"candidate_count,omitempty"`
|
||||
StopSequencesSnake []string `json:"stop_sequences,omitempty"`
|
||||
ResponseMimeTypeSnake string `json:"response_mime_type,omitempty"`
|
||||
ResponseSchemaSnake any `json:"response_schema,omitempty"`
|
||||
ResponseJsonSchemaSnake json.RawMessage `json:"response_json_schema,omitempty"`
|
||||
PresencePenaltySnake *float32 `json:"presence_penalty,omitempty"`
|
||||
FrequencyPenaltySnake *float32 `json:"frequency_penalty,omitempty"`
|
||||
ResponseLogprobsSnake bool `json:"response_logprobs,omitempty"`
|
||||
MediaResolutionSnake MediaResolution `json:"media_resolution,omitempty"`
|
||||
ResponseModalitiesSnake []string `json:"response_modalities,omitempty"`
|
||||
ThinkingConfigSnake *GeminiThinkingConfig `json:"thinking_config,omitempty"`
|
||||
SpeechConfigSnake json.RawMessage `json:"speech_config,omitempty"`
|
||||
ImageConfigSnake json.RawMessage `json:"image_config,omitempty"`
|
||||
}
|
||||
|
||||
if err := common.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*c = GeminiChatGenerationConfig(aux.Alias)
|
||||
|
||||
// Prioritize snake_case if present
|
||||
if aux.TopPSnake != 0 {
|
||||
c.TopP = aux.TopPSnake
|
||||
}
|
||||
if aux.TopKSnake != 0 {
|
||||
c.TopK = aux.TopKSnake
|
||||
}
|
||||
if aux.MaxOutputTokensSnake != 0 {
|
||||
c.MaxOutputTokens = aux.MaxOutputTokensSnake
|
||||
}
|
||||
if aux.CandidateCountSnake != 0 {
|
||||
c.CandidateCount = aux.CandidateCountSnake
|
||||
}
|
||||
if len(aux.StopSequencesSnake) > 0 {
|
||||
c.StopSequences = aux.StopSequencesSnake
|
||||
}
|
||||
if aux.ResponseMimeTypeSnake != "" {
|
||||
c.ResponseMimeType = aux.ResponseMimeTypeSnake
|
||||
}
|
||||
if aux.ResponseSchemaSnake != nil {
|
||||
c.ResponseSchema = aux.ResponseSchemaSnake
|
||||
}
|
||||
if len(aux.ResponseJsonSchemaSnake) > 0 {
|
||||
c.ResponseJsonSchema = aux.ResponseJsonSchemaSnake
|
||||
}
|
||||
if aux.PresencePenaltySnake != nil {
|
||||
c.PresencePenalty = aux.PresencePenaltySnake
|
||||
}
|
||||
if aux.FrequencyPenaltySnake != nil {
|
||||
c.FrequencyPenalty = aux.FrequencyPenaltySnake
|
||||
}
|
||||
if aux.ResponseLogprobsSnake {
|
||||
c.ResponseLogprobs = aux.ResponseLogprobsSnake
|
||||
}
|
||||
if aux.MediaResolutionSnake != "" {
|
||||
c.MediaResolution = aux.MediaResolutionSnake
|
||||
}
|
||||
if len(aux.ResponseModalitiesSnake) > 0 {
|
||||
c.ResponseModalities = aux.ResponseModalitiesSnake
|
||||
}
|
||||
if aux.ThinkingConfigSnake != nil {
|
||||
c.ThinkingConfig = aux.ThinkingConfigSnake
|
||||
}
|
||||
if len(aux.SpeechConfigSnake) > 0 {
|
||||
c.SpeechConfig = aux.SpeechConfigSnake
|
||||
}
|
||||
if len(aux.ImageConfigSnake) > 0 {
|
||||
c.ImageConfig = aux.ImageConfigSnake
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type MediaResolution string
|
||||
|
||||
type GeminiChatCandidate struct {
|
||||
|
||||
@@ -808,11 +808,11 @@ type OpenAIResponsesRequest struct {
|
||||
PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"`
|
||||
PromptCacheRetention json.RawMessage `json:"prompt_cache_retention,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
Text json.RawMessage `json:"text,omitempty"`
|
||||
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
||||
Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map
|
||||
TopP float64 `json:"top_p,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
Truncation string `json:"truncation,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
MaxToolCalls uint `json:"max_tool_calls,omitempty"`
|
||||
|
||||
+10
-7
@@ -334,13 +334,16 @@ type IncompleteDetails struct {
|
||||
}
|
||||
|
||||
type ResponsesOutput struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Role string `json:"role"`
|
||||
Content []ResponsesOutputContent `json:"content"`
|
||||
Quality string `json:"quality"`
|
||||
Size string `json:"size"`
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Role string `json:"role"`
|
||||
Content []ResponsesOutputContent `json:"content"`
|
||||
Quality string `json:"quality"`
|
||||
Size string `json:"size"`
|
||||
CallId string `json:"call_id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Arguments string `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
type ResponsesOutputContent struct {
|
||||
|
||||
@@ -143,6 +143,7 @@ func InitOptionMap() {
|
||||
common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString()
|
||||
common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength)
|
||||
common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString()
|
||||
common.OptionMap["AutomaticDisableStatusCodes"] = operation_setting.AutomaticDisableStatusCodesToString()
|
||||
common.OptionMap["ExposeRatioEnabled"] = strconv.FormatBool(ratio_setting.IsExposeRatioEnabled())
|
||||
|
||||
// 自动添加所有注册的模型配置
|
||||
@@ -444,6 +445,8 @@ func updateOptionMap(key string, value string) (err error) {
|
||||
setting.SensitiveWordsFromString(value)
|
||||
case "AutomaticDisableKeywords":
|
||||
operation_setting.AutomaticDisableKeywordsFromString(value)
|
||||
case "AutomaticDisableStatusCodes":
|
||||
err = operation_setting.AutomaticDisableStatusCodesFromString(value)
|
||||
case "StreamCacheQueueLength":
|
||||
setting.StreamCacheQueueLength, _ = strconv.Atoi(value)
|
||||
case "PayMethods":
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -673,6 +675,7 @@ func cleanFunctionParameters(params interface{}) interface{} {
|
||||
delete(cleanedMap, "exclusiveMinimum")
|
||||
delete(cleanedMap, "$schema")
|
||||
delete(cleanedMap, "additionalProperties")
|
||||
delete(cleanedMap, "propertyNames")
|
||||
|
||||
// Check and clean 'format' for string types
|
||||
if propType, typeExists := cleanedMap["type"].(string); typeExists && propType == "string" {
|
||||
@@ -1363,3 +1366,76 @@ func GeminiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
|
||||
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
type GeminiModelsResponse struct {
|
||||
Models []dto.GeminiModel `json:"models"`
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
}
|
||||
|
||||
func FetchGeminiModels(baseURL, apiKey, proxyURL string) ([]string, error) {
|
||||
client, err := service.GetHttpClientWithProxy(proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建HTTP客户端失败: %v", err)
|
||||
}
|
||||
|
||||
allModels := make([]string, 0)
|
||||
nextPageToken := ""
|
||||
maxPages := 100 // Safety limit to prevent infinite loops
|
||||
|
||||
for page := 0; page < maxPages; page++ {
|
||||
url := fmt.Sprintf("%s/v1beta/models", baseURL)
|
||||
if nextPageToken != "" {
|
||||
url = fmt.Sprintf("%s?pageToken=%s", url, nextPageToken)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
request, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("创建请求失败: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("x-goog-api-key", apiKey)
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("请求失败: %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))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
cancel()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取响应失败: %v", err)
|
||||
}
|
||||
|
||||
var modelsResponse GeminiModelsResponse
|
||||
if err = common.Unmarshal(body, &modelsResponse); err != nil {
|
||||
return nil, fmt.Errorf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
for _, model := range modelsResponse.Models {
|
||||
modelNameValue, ok := model.Name.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
modelName := strings.TrimPrefix(modelNameValue, "models/")
|
||||
allModels = append(allModels, modelName)
|
||||
}
|
||||
|
||||
nextPageToken = modelsResponse.NextPageToken
|
||||
if nextPageToken == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return allModels, nil
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ var ModelList = []string{
|
||||
"speech-02-turbo",
|
||||
"speech-01-hd",
|
||||
"speech-01-turbo",
|
||||
"MiniMax-M2.1",
|
||||
"MiniMax-M2.1-lightning",
|
||||
"MiniMax-M2",
|
||||
}
|
||||
|
||||
var ChannelName = "minimax"
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/helper"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func OaiResponsesToChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
defer service.CloseResponseBodyGracefully(resp)
|
||||
|
||||
var responsesResp dto.OpenAIResponsesResponse
|
||||
const maxResponseBodyBytes = 10 << 20 // 10MB
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes+1))
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
|
||||
}
|
||||
if int64(len(body)) > maxResponseBodyBytes {
|
||||
return nil, types.NewOpenAIError(fmt.Errorf("response body exceeds %d bytes", maxResponseBodyBytes), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if err := common.Unmarshal(body, &responsesResp); err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if oaiError := responsesResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
|
||||
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
|
||||
}
|
||||
|
||||
chatId := helper.GetResponseID(c)
|
||||
chatResp, usage, err := service.ResponsesResponseToChatCompletionsResponse(&responsesResp, chatId)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if usage == nil || usage.TotalTokens == 0 {
|
||||
text := service.ExtractOutputTextFromResponses(&responsesResp)
|
||||
usage = service.ResponseText2Usage(c, text, info.UpstreamModelName, info.GetEstimatePromptTokens())
|
||||
chatResp.Usage = *usage
|
||||
}
|
||||
|
||||
chatBody, err := common.Marshal(chatResp)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
service.IOCopyBytesGracefully(c, resp, chatBody)
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
defer service.CloseResponseBodyGracefully(resp)
|
||||
|
||||
responseId := helper.GetResponseID(c)
|
||||
createAt := time.Now().Unix()
|
||||
model := info.UpstreamModelName
|
||||
|
||||
var (
|
||||
usage = &dto.Usage{}
|
||||
textBuilder strings.Builder
|
||||
sentStart bool
|
||||
sentStop bool
|
||||
streamErr *types.NewAPIError
|
||||
)
|
||||
|
||||
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
|
||||
if streamErr != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var streamResp dto.ResponsesStreamResponse
|
||||
if err := common.UnmarshalJsonStr(data, &streamResp); err != nil {
|
||||
logger.LogError(c, "failed to unmarshal responses stream event: "+err.Error())
|
||||
return true
|
||||
}
|
||||
|
||||
switch streamResp.Type {
|
||||
case "response.created":
|
||||
if streamResp.Response != nil {
|
||||
if streamResp.Response.Model != "" {
|
||||
model = streamResp.Response.Model
|
||||
}
|
||||
if streamResp.Response.CreatedAt != 0 {
|
||||
createAt = int64(streamResp.Response.CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
case "response.output_text.delta":
|
||||
if !sentStart {
|
||||
if err := helper.ObjectData(c, helper.GenerateStartEmptyResponse(responseId, createAt, model, nil)); err != nil {
|
||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
sentStart = true
|
||||
}
|
||||
|
||||
if streamResp.Delta != "" {
|
||||
textBuilder.WriteString(streamResp.Delta)
|
||||
delta := streamResp.Delta
|
||||
chunk := &dto.ChatCompletionsStreamResponse{
|
||||
Id: responseId,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: createAt,
|
||||
Model: model,
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||
{
|
||||
Index: 0,
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
|
||||
Content: &delta,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := helper.ObjectData(c, chunk); err != nil {
|
||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
case "response.completed":
|
||||
if streamResp.Response != nil {
|
||||
if streamResp.Response.Model != "" {
|
||||
model = streamResp.Response.Model
|
||||
}
|
||||
if streamResp.Response.CreatedAt != 0 {
|
||||
createAt = int64(streamResp.Response.CreatedAt)
|
||||
}
|
||||
if streamResp.Response.Usage != nil {
|
||||
if streamResp.Response.Usage.InputTokens != 0 {
|
||||
usage.PromptTokens = streamResp.Response.Usage.InputTokens
|
||||
usage.InputTokens = streamResp.Response.Usage.InputTokens
|
||||
}
|
||||
if streamResp.Response.Usage.OutputTokens != 0 {
|
||||
usage.CompletionTokens = streamResp.Response.Usage.OutputTokens
|
||||
usage.OutputTokens = streamResp.Response.Usage.OutputTokens
|
||||
}
|
||||
if streamResp.Response.Usage.TotalTokens != 0 {
|
||||
usage.TotalTokens = streamResp.Response.Usage.TotalTokens
|
||||
} else {
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
}
|
||||
if streamResp.Response.Usage.InputTokensDetails != nil {
|
||||
usage.PromptTokensDetails.CachedTokens = streamResp.Response.Usage.InputTokensDetails.CachedTokens
|
||||
usage.PromptTokensDetails.ImageTokens = streamResp.Response.Usage.InputTokensDetails.ImageTokens
|
||||
usage.PromptTokensDetails.AudioTokens = streamResp.Response.Usage.InputTokensDetails.AudioTokens
|
||||
}
|
||||
if streamResp.Response.Usage.CompletionTokenDetails.ReasoningTokens != 0 {
|
||||
usage.CompletionTokenDetails.ReasoningTokens = streamResp.Response.Usage.CompletionTokenDetails.ReasoningTokens
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !sentStart {
|
||||
if err := helper.ObjectData(c, helper.GenerateStartEmptyResponse(responseId, createAt, model, nil)); err != nil {
|
||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
sentStart = true
|
||||
}
|
||||
if !sentStop {
|
||||
stop := helper.GenerateStopResponse(responseId, createAt, model, "stop")
|
||||
if err := helper.ObjectData(c, stop); err != nil {
|
||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
sentStop = true
|
||||
}
|
||||
|
||||
case "response.error", "response.failed":
|
||||
if streamResp.Response != nil {
|
||||
if oaiErr := streamResp.Response.GetOpenAIError(); oaiErr != nil && oaiErr.Type != "" {
|
||||
streamErr = types.WithOpenAIError(*oaiErr, http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
}
|
||||
streamErr = types.NewOpenAIError(fmt.Errorf("responses stream error: %s", streamResp.Type), types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
return false
|
||||
|
||||
case "response.output_item.added", "response.output_item.done":
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if streamErr != nil {
|
||||
return nil, streamErr
|
||||
}
|
||||
|
||||
if usage.TotalTokens == 0 {
|
||||
usage = service.ResponseText2Usage(c, textBuilder.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
|
||||
}
|
||||
|
||||
if !sentStart {
|
||||
if err := helper.ObjectData(c, helper.GenerateStartEmptyResponse(responseId, createAt, model, nil)); err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
if !sentStop {
|
||||
stop := helper.GenerateStopResponse(responseId, createAt, model, "stop")
|
||||
if err := helper.ObjectData(c, stop); err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
if info.ShouldIncludeUsage && usage != nil {
|
||||
if err := helper.ObjectData(c, helper.GenerateFinalUsageResponse(responseId, createAt, model, *usage)); err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
helper.Done(c)
|
||||
return usage, nil
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/relay/channel"
|
||||
openaichannel "github.com/QuantumNous/new-api/relay/channel/openai"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func applySystemPromptIfNeeded(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) {
|
||||
if info == nil || request == nil {
|
||||
return
|
||||
}
|
||||
if info.ChannelSetting.SystemPrompt == "" {
|
||||
return
|
||||
}
|
||||
|
||||
systemRole := request.GetSystemRoleName()
|
||||
|
||||
containSystemPrompt := false
|
||||
for _, message := range request.Messages {
|
||||
if message.Role == systemRole {
|
||||
containSystemPrompt = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !containSystemPrompt {
|
||||
systemMessage := dto.Message{
|
||||
Role: systemRole,
|
||||
Content: info.ChannelSetting.SystemPrompt,
|
||||
}
|
||||
request.Messages = append([]dto.Message{systemMessage}, request.Messages...)
|
||||
return
|
||||
}
|
||||
|
||||
if !info.ChannelSetting.SystemPromptOverride {
|
||||
return
|
||||
}
|
||||
|
||||
common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true)
|
||||
for i, message := range request.Messages {
|
||||
if message.Role != systemRole {
|
||||
continue
|
||||
}
|
||||
if message.IsStringContent() {
|
||||
request.Messages[i].SetStringContent(info.ChannelSetting.SystemPrompt + "\n" + message.StringContent())
|
||||
return
|
||||
}
|
||||
contents := message.ParseContent()
|
||||
contents = append([]dto.MediaContent{
|
||||
{
|
||||
Type: dto.ContentTypeText,
|
||||
Text: info.ChannelSetting.SystemPrompt,
|
||||
},
|
||||
}, contents...)
|
||||
request.Messages[i].Content = contents
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.Adaptor, request *dto.GeneralOpenAIRequest) (*dto.Usage, *types.NewAPIError) {
|
||||
overrideCtx := relaycommon.BuildParamOverrideContext(info)
|
||||
chatJSON, err := common.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
chatJSON, err = relaycommon.RemoveDisabledFields(chatJSON, info.ChannelOtherSettings)
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
if len(info.ParamOverride) > 0 {
|
||||
chatJSON, err = relaycommon.ApplyParamOverride(chatJSON, info.ParamOverride, overrideCtx)
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
}
|
||||
|
||||
var overriddenChatReq dto.GeneralOpenAIRequest
|
||||
if err := common.Unmarshal(chatJSON, &overriddenChatReq); err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
responsesReq, err := service.ChatCompletionsRequestToResponsesRequest(&overriddenChatReq)
|
||||
if err != nil {
|
||||
return nil, types.NewErrorWithStatusCode(err, types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
savedRelayMode := info.RelayMode
|
||||
savedRequestURLPath := info.RequestURLPath
|
||||
defer func() {
|
||||
info.RelayMode = savedRelayMode
|
||||
info.RequestURLPath = savedRequestURLPath
|
||||
}()
|
||||
|
||||
info.RelayMode = relayconstant.RelayModeResponses
|
||||
info.RequestURLPath = "/v1/responses"
|
||||
|
||||
convertedRequest, err := adaptor.ConvertOpenAIResponsesRequest(c, info, *responsesReq)
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
jsonData, err := common.Marshal(convertedRequest)
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings)
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
var httpResp *http.Response
|
||||
resp, err := adaptor.DoRequest(c, info, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, types.NewOpenAIError(nil, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
statusCodeMappingStr := c.GetString("status_code_mapping")
|
||||
|
||||
httpResp = resp.(*http.Response)
|
||||
info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream")
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
newApiErr := service.RelayErrorHandler(c.Request.Context(), httpResp, false)
|
||||
service.ResetStatusCode(newApiErr, statusCodeMappingStr)
|
||||
return nil, newApiErr
|
||||
}
|
||||
|
||||
if info.IsStream {
|
||||
usage, newApiErr := openaichannel.OaiResponsesToChatStreamHandler(c, info, httpResp)
|
||||
if newApiErr != nil {
|
||||
service.ResetStatusCode(newApiErr, statusCodeMappingStr)
|
||||
return nil, newApiErr
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
usage, newApiErr := openaichannel.OaiResponsesToChatHandler(c, info, httpResp)
|
||||
if newApiErr != nil {
|
||||
service.ResetStatusCode(newApiErr, statusCodeMappingStr)
|
||||
return nil, newApiErr
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
@@ -570,18 +570,19 @@ func mergeObjects(jsonStr, path string, value interface{}, keepOrigin bool) (str
|
||||
|
||||
// BuildParamOverrideContext 提供 ApplyParamOverride 可用的上下文信息。
|
||||
// 目前内置以下字段:
|
||||
// - model:优先使用上游模型名(UpstreamModelName),若不存在则回落到原始模型名(OriginModelName)。
|
||||
// - upstream_model:始终为通道映射后的上游模型名。
|
||||
// - upstream_model/model:始终为通道映射后的上游模型名。
|
||||
// - original_model:请求最初指定的模型名。
|
||||
// - request_path:请求路径
|
||||
// - is_channel_test:是否为渠道测试请求(同 is_test)。
|
||||
func BuildParamOverrideContext(info *RelayInfo) map[string]interface{} {
|
||||
if info == nil || info.ChannelMeta == nil {
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := make(map[string]interface{})
|
||||
if info.UpstreamModelName != "" {
|
||||
ctx["model"] = info.UpstreamModelName
|
||||
ctx["upstream_model"] = info.UpstreamModelName
|
||||
if info.ChannelMeta != nil && info.ChannelMeta.UpstreamModelName != "" {
|
||||
ctx["model"] = info.ChannelMeta.UpstreamModelName
|
||||
ctx["upstream_model"] = info.ChannelMeta.UpstreamModelName
|
||||
}
|
||||
if info.OriginModelName != "" {
|
||||
ctx["original_model"] = info.OriginModelName
|
||||
@@ -590,8 +591,13 @@ func BuildParamOverrideContext(info *RelayInfo) map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
if len(ctx) == 0 {
|
||||
return nil
|
||||
if info.RequestURLPath != "" {
|
||||
requestPath := info.RequestURLPath
|
||||
if requestPath != "" {
|
||||
ctx["request_path"] = requestPath
|
||||
}
|
||||
}
|
||||
|
||||
ctx["is_channel_test"] = info.IsChannelTest
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ type RelayInfo struct {
|
||||
SendResponseCount int
|
||||
FinalPreConsumedQuota int // 最终预消耗的配额
|
||||
IsClaudeBetaQuery bool // /v1/messages?beta=true
|
||||
IsChannelTest bool // channel test request
|
||||
|
||||
PriceData types.PriceData
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/relay/helper"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
@@ -73,6 +74,28 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
|
||||
return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
adaptor.Init(info)
|
||||
|
||||
if info.RelayMode == relayconstant.RelayModeChatCompletions &&
|
||||
!model_setting.GetGlobalSettings().PassThroughRequestEnabled &&
|
||||
!info.ChannelSetting.PassThroughBodyEnabled &&
|
||||
service.ShouldChatCompletionsUseResponsesGlobal(info.ChannelId, info.OriginModelName) {
|
||||
applySystemPromptIfNeeded(c, info, request)
|
||||
usage, newApiErr := chatCompletionsViaResponses(c, info, adaptor, request)
|
||||
if newApiErr != nil {
|
||||
return newApiErr
|
||||
}
|
||||
|
||||
var containAudioTokens = usage.CompletionTokenDetails.AudioTokens > 0 || usage.PromptTokensDetails.AudioTokens > 0
|
||||
var containsAudioRatios = ratio_setting.ContainsAudioRatio(info.OriginModelName) || ratio_setting.ContainsAudioCompletionRatio(info.OriginModelName)
|
||||
|
||||
if containAudioTokens && containsAudioRatios {
|
||||
service.PostAudioConsumeQuota(c, info, usage, "")
|
||||
} else {
|
||||
postConsumeQuota(c, info, usage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var requestBody io.Reader
|
||||
|
||||
if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled {
|
||||
|
||||
@@ -150,6 +150,14 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 auto 分组:从 context 获取实际选中的分组
|
||||
// 当使用 auto 分组时,Distribute 中间件会将实际选中的分组存储在 ContextKeyAutoGroup 中
|
||||
if autoGroup, exists := common.GetContextKey(c, constant.ContextKeyAutoGroup); exists {
|
||||
if groupStr, ok := autoGroup.(string); ok && groupStr != "" {
|
||||
info.UsingGroup = groupStr
|
||||
}
|
||||
}
|
||||
|
||||
// 预扣
|
||||
groupRatio := ratio_setting.GetGroupRatio(info.UsingGroup)
|
||||
var ratio float64
|
||||
|
||||
+4
-1
@@ -57,9 +57,12 @@ func ShouldDisableChannel(channelType int, err *types.NewAPIError) bool {
|
||||
if types.IsSkipRetryError(err) {
|
||||
return false
|
||||
}
|
||||
if err.StatusCode == http.StatusUnauthorized {
|
||||
if operation_setting.ShouldDisableByStatusCode(err.StatusCode) {
|
||||
return true
|
||||
}
|
||||
//if err.StatusCode == http.StatusUnauthorized {
|
||||
// return true
|
||||
//}
|
||||
if err.StatusCode == http.StatusForbidden {
|
||||
switch channelType {
|
||||
case constant.ChannelTypeGemini:
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/service/openaicompat"
|
||||
)
|
||||
|
||||
func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) {
|
||||
return openaicompat.ChatCompletionsRequestToResponsesRequest(req)
|
||||
}
|
||||
|
||||
func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) {
|
||||
return openaicompat.ResponsesResponseToChatCompletionsResponse(resp, id)
|
||||
}
|
||||
|
||||
func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
|
||||
return openaicompat.ExtractOutputTextFromResponses(resp)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/service/openaicompat"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
)
|
||||
|
||||
func ShouldChatCompletionsUseResponsesPolicy(policy model_setting.ChatCompletionsToResponsesPolicy, channelID int, model string) bool {
|
||||
return openaicompat.ShouldChatCompletionsUseResponsesPolicy(policy, channelID, model)
|
||||
}
|
||||
|
||||
func ShouldChatCompletionsUseResponsesGlobal(channelID int, model string) bool {
|
||||
return openaicompat.ShouldChatCompletionsUseResponsesGlobal(channelID, model)
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package openaicompat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
)
|
||||
|
||||
func normalizeChatImageURLToString(v any) any {
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
return vv
|
||||
case map[string]any:
|
||||
if url := common.Interface2String(vv["url"]); url != "" {
|
||||
return url
|
||||
}
|
||||
return v
|
||||
case dto.MessageImageUrl:
|
||||
if vv.Url != "" {
|
||||
return vv.Url
|
||||
}
|
||||
return v
|
||||
case *dto.MessageImageUrl:
|
||||
if vv != nil && vv.Url != "" {
|
||||
return vv.Url
|
||||
}
|
||||
return v
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) {
|
||||
if req == nil {
|
||||
return nil, errors.New("request is nil")
|
||||
}
|
||||
if req.Model == "" {
|
||||
return nil, errors.New("model is required")
|
||||
}
|
||||
if req.N > 1 {
|
||||
return nil, fmt.Errorf("n>1 is not supported in responses compatibility mode")
|
||||
}
|
||||
|
||||
var instructionsParts []string
|
||||
inputItems := make([]map[string]any, 0, len(req.Messages))
|
||||
|
||||
for _, msg := range req.Messages {
|
||||
role := strings.TrimSpace(msg.Role)
|
||||
if role == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Prefer mapping system/developer messages into `instructions`.
|
||||
if role == "system" || role == "developer" {
|
||||
if msg.Content == nil {
|
||||
continue
|
||||
}
|
||||
if msg.IsStringContent() {
|
||||
if s := strings.TrimSpace(msg.StringContent()); s != "" {
|
||||
instructionsParts = append(instructionsParts, s)
|
||||
}
|
||||
continue
|
||||
}
|
||||
parts := msg.ParseContent()
|
||||
var sb strings.Builder
|
||||
for _, part := range parts {
|
||||
if part.Type == dto.ContentTypeText && strings.TrimSpace(part.Text) != "" {
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(part.Text)
|
||||
}
|
||||
}
|
||||
if s := strings.TrimSpace(sb.String()); s != "" {
|
||||
instructionsParts = append(instructionsParts, s)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
item := map[string]any{
|
||||
"role": role,
|
||||
}
|
||||
|
||||
if msg.Content == nil {
|
||||
item["content"] = ""
|
||||
inputItems = append(inputItems, item)
|
||||
continue
|
||||
}
|
||||
|
||||
if msg.IsStringContent() {
|
||||
item["content"] = msg.StringContent()
|
||||
inputItems = append(inputItems, item)
|
||||
continue
|
||||
}
|
||||
|
||||
parts := msg.ParseContent()
|
||||
contentParts := make([]map[string]any, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
switch part.Type {
|
||||
case dto.ContentTypeText:
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": "input_text",
|
||||
"text": part.Text,
|
||||
})
|
||||
case dto.ContentTypeImageURL:
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": "input_image",
|
||||
"image_url": normalizeChatImageURLToString(part.ImageUrl),
|
||||
})
|
||||
case dto.ContentTypeInputAudio:
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": "input_audio",
|
||||
"input_audio": part.InputAudio,
|
||||
})
|
||||
case dto.ContentTypeFile:
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": "input_file",
|
||||
"file": part.File,
|
||||
})
|
||||
case dto.ContentTypeVideoUrl:
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": "input_video",
|
||||
"video_url": part.VideoUrl,
|
||||
})
|
||||
default:
|
||||
// Best-effort: keep unknown parts as-is to avoid silently dropping context.
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": part.Type,
|
||||
})
|
||||
}
|
||||
}
|
||||
item["content"] = contentParts
|
||||
inputItems = append(inputItems, item)
|
||||
}
|
||||
|
||||
inputRaw, err := common.Marshal(inputItems)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var instructionsRaw json.RawMessage
|
||||
if len(instructionsParts) > 0 {
|
||||
instructions := strings.Join(instructionsParts, "\n\n")
|
||||
instructionsRaw, _ = common.Marshal(instructions)
|
||||
}
|
||||
|
||||
var toolsRaw json.RawMessage
|
||||
if req.Tools != nil {
|
||||
tools := make([]map[string]any, 0, len(req.Tools))
|
||||
for _, tool := range req.Tools {
|
||||
switch tool.Type {
|
||||
case "function":
|
||||
tools = append(tools, map[string]any{
|
||||
"type": "function",
|
||||
"name": tool.Function.Name,
|
||||
"description": tool.Function.Description,
|
||||
"parameters": tool.Function.Parameters,
|
||||
})
|
||||
default:
|
||||
// Best-effort: keep original tool shape for unknown types.
|
||||
var m map[string]any
|
||||
if b, err := common.Marshal(tool); err == nil {
|
||||
_ = common.Unmarshal(b, &m)
|
||||
}
|
||||
if len(m) == 0 {
|
||||
m = map[string]any{"type": tool.Type}
|
||||
}
|
||||
tools = append(tools, m)
|
||||
}
|
||||
}
|
||||
toolsRaw, _ = common.Marshal(tools)
|
||||
}
|
||||
|
||||
var toolChoiceRaw json.RawMessage
|
||||
if req.ToolChoice != nil {
|
||||
switch v := req.ToolChoice.(type) {
|
||||
case string:
|
||||
toolChoiceRaw, _ = common.Marshal(v)
|
||||
default:
|
||||
var m map[string]any
|
||||
if b, err := common.Marshal(v); err == nil {
|
||||
_ = common.Unmarshal(b, &m)
|
||||
}
|
||||
if m == nil {
|
||||
toolChoiceRaw, _ = common.Marshal(v)
|
||||
} else if t, _ := m["type"].(string); t == "function" {
|
||||
// Chat: {"type":"function","function":{"name":"..."}}
|
||||
// Responses: {"type":"function","name":"..."}
|
||||
if name, ok := m["name"].(string); ok && name != "" {
|
||||
toolChoiceRaw, _ = common.Marshal(map[string]any{
|
||||
"type": "function",
|
||||
"name": name,
|
||||
})
|
||||
} else if fn, ok := m["function"].(map[string]any); ok {
|
||||
if name, ok := fn["name"].(string); ok && name != "" {
|
||||
toolChoiceRaw, _ = common.Marshal(map[string]any{
|
||||
"type": "function",
|
||||
"name": name,
|
||||
})
|
||||
} else {
|
||||
toolChoiceRaw, _ = common.Marshal(v)
|
||||
}
|
||||
} else {
|
||||
toolChoiceRaw, _ = common.Marshal(v)
|
||||
}
|
||||
} else {
|
||||
toolChoiceRaw, _ = common.Marshal(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var parallelToolCallsRaw json.RawMessage
|
||||
if req.ParallelTooCalls != nil {
|
||||
parallelToolCallsRaw, _ = common.Marshal(*req.ParallelTooCalls)
|
||||
}
|
||||
|
||||
var textRaw json.RawMessage
|
||||
if req.ResponseFormat != nil && req.ResponseFormat.Type != "" {
|
||||
textRaw, _ = common.Marshal(map[string]any{
|
||||
"format": req.ResponseFormat,
|
||||
})
|
||||
}
|
||||
|
||||
maxOutputTokens := req.MaxTokens
|
||||
if req.MaxCompletionTokens > maxOutputTokens {
|
||||
maxOutputTokens = req.MaxCompletionTokens
|
||||
}
|
||||
|
||||
var topP *float64
|
||||
if req.TopP != 0 {
|
||||
topP = common.GetPointer(req.TopP)
|
||||
}
|
||||
|
||||
out := &dto.OpenAIResponsesRequest{
|
||||
Model: req.Model,
|
||||
Input: inputRaw,
|
||||
Instructions: instructionsRaw,
|
||||
MaxOutputTokens: maxOutputTokens,
|
||||
Stream: req.Stream,
|
||||
Temperature: req.Temperature,
|
||||
Text: textRaw,
|
||||
ToolChoice: toolChoiceRaw,
|
||||
Tools: toolsRaw,
|
||||
TopP: topP,
|
||||
User: req.User,
|
||||
ParallelToolCalls: parallelToolCallsRaw,
|
||||
Store: req.Store,
|
||||
Metadata: req.Metadata,
|
||||
}
|
||||
|
||||
if req.ReasoningEffort != "" && req.ReasoningEffort != "none" {
|
||||
out.Reasoning = &dto.Reasoning{
|
||||
Effort: req.ReasoningEffort,
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package openaicompat
|
||||
|
||||
import "github.com/QuantumNous/new-api/setting/model_setting"
|
||||
|
||||
func ShouldChatCompletionsUseResponsesPolicy(policy model_setting.ChatCompletionsToResponsesPolicy, channelID int, model string) bool {
|
||||
if !policy.IsChannelEnabled(channelID) {
|
||||
return false
|
||||
}
|
||||
return matchAnyRegex(policy.ModelPatterns, model)
|
||||
}
|
||||
|
||||
func ShouldChatCompletionsUseResponsesGlobal(channelID int, model string) bool {
|
||||
return ShouldChatCompletionsUseResponsesPolicy(
|
||||
model_setting.GetGlobalSettings().ChatCompletionsToResponsesPolicy,
|
||||
channelID,
|
||||
model,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package openaicompat
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var compiledRegexCache sync.Map // map[string]*regexp.Regexp
|
||||
|
||||
func matchAnyRegex(patterns []string, s string) bool {
|
||||
if len(patterns) == 0 || s == "" {
|
||||
return false
|
||||
}
|
||||
for _, pattern := range patterns {
|
||||
if pattern == "" {
|
||||
continue
|
||||
}
|
||||
re, ok := compiledRegexCache.Load(pattern)
|
||||
if !ok {
|
||||
compiled, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
// Treat invalid patterns as non-matching to avoid breaking runtime traffic.
|
||||
continue
|
||||
}
|
||||
re = compiled
|
||||
compiledRegexCache.Store(pattern, re)
|
||||
}
|
||||
if re.(*regexp.Regexp).MatchString(s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package openaicompat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
)
|
||||
|
||||
func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) {
|
||||
if resp == nil {
|
||||
return nil, nil, errors.New("response is nil")
|
||||
}
|
||||
|
||||
text := ExtractOutputTextFromResponses(resp)
|
||||
|
||||
usage := &dto.Usage{}
|
||||
if resp.Usage != nil {
|
||||
if resp.Usage.InputTokens != 0 {
|
||||
usage.PromptTokens = resp.Usage.InputTokens
|
||||
usage.InputTokens = resp.Usage.InputTokens
|
||||
}
|
||||
if resp.Usage.OutputTokens != 0 {
|
||||
usage.CompletionTokens = resp.Usage.OutputTokens
|
||||
usage.OutputTokens = resp.Usage.OutputTokens
|
||||
}
|
||||
if resp.Usage.TotalTokens != 0 {
|
||||
usage.TotalTokens = resp.Usage.TotalTokens
|
||||
} else {
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
}
|
||||
if resp.Usage.InputTokensDetails != nil {
|
||||
usage.PromptTokensDetails.CachedTokens = resp.Usage.InputTokensDetails.CachedTokens
|
||||
usage.PromptTokensDetails.ImageTokens = resp.Usage.InputTokensDetails.ImageTokens
|
||||
usage.PromptTokensDetails.AudioTokens = resp.Usage.InputTokensDetails.AudioTokens
|
||||
}
|
||||
if resp.Usage.CompletionTokenDetails.ReasoningTokens != 0 {
|
||||
usage.CompletionTokenDetails.ReasoningTokens = resp.Usage.CompletionTokenDetails.ReasoningTokens
|
||||
}
|
||||
}
|
||||
|
||||
created := resp.CreatedAt
|
||||
|
||||
var toolCalls []dto.ToolCallResponse
|
||||
if text == "" && len(resp.Output) > 0 {
|
||||
for _, out := range resp.Output {
|
||||
if out.Type != "function_call" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(out.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
callId := strings.TrimSpace(out.CallId)
|
||||
if callId == "" {
|
||||
callId = strings.TrimSpace(out.ID)
|
||||
}
|
||||
toolCalls = append(toolCalls, dto.ToolCallResponse{
|
||||
ID: callId,
|
||||
Type: "function",
|
||||
Function: dto.FunctionResponse{
|
||||
Name: name,
|
||||
Arguments: out.Arguments,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
finishReason := "stop"
|
||||
if len(toolCalls) > 0 {
|
||||
finishReason = "tool_calls"
|
||||
}
|
||||
|
||||
msg := dto.Message{
|
||||
Role: "assistant",
|
||||
Content: text,
|
||||
}
|
||||
if len(toolCalls) > 0 {
|
||||
msg.SetToolCalls(toolCalls)
|
||||
msg.Content = ""
|
||||
}
|
||||
|
||||
out := &dto.OpenAITextResponse{
|
||||
Id: id,
|
||||
Object: "chat.completion",
|
||||
Created: created,
|
||||
Model: resp.Model,
|
||||
Choices: []dto.OpenAITextResponseChoice{
|
||||
{
|
||||
Index: 0,
|
||||
Message: msg,
|
||||
FinishReason: finishReason,
|
||||
},
|
||||
},
|
||||
Usage: *usage,
|
||||
}
|
||||
|
||||
return out, usage, nil
|
||||
}
|
||||
|
||||
func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
|
||||
if resp == nil || len(resp.Output) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Prefer assistant message outputs.
|
||||
for _, out := range resp.Output {
|
||||
if out.Type != "message" {
|
||||
continue
|
||||
}
|
||||
if out.Role != "" && out.Role != "assistant" {
|
||||
continue
|
||||
}
|
||||
for _, c := range out.Content {
|
||||
if c.Type == "output_text" && c.Text != "" {
|
||||
sb.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
if sb.Len() > 0 {
|
||||
return sb.String()
|
||||
}
|
||||
for _, out := range resp.Output {
|
||||
for _, c := range out.Content {
|
||||
if c.Text != "" {
|
||||
sb.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
@@ -1,14 +1,36 @@
|
||||
package model_setting
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/setting/config"
|
||||
)
|
||||
|
||||
type ChatCompletionsToResponsesPolicy struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AllChannels bool `json:"all_channels"`
|
||||
ChannelIDs []int `json:"channel_ids,omitempty"`
|
||||
ModelPatterns []string `json:"model_patterns,omitempty"`
|
||||
}
|
||||
|
||||
func (p ChatCompletionsToResponsesPolicy) IsChannelEnabled(channelID int) bool {
|
||||
if !p.Enabled {
|
||||
return false
|
||||
}
|
||||
if p.AllChannels {
|
||||
return true
|
||||
}
|
||||
if channelID == 0 || len(p.ChannelIDs) == 0 {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(p.ChannelIDs, channelID)
|
||||
}
|
||||
|
||||
type GlobalSettings struct {
|
||||
PassThroughRequestEnabled bool `json:"pass_through_request_enabled"`
|
||||
ThinkingModelBlacklist []string `json:"thinking_model_blacklist"`
|
||||
PassThroughRequestEnabled bool `json:"pass_through_request_enabled"`
|
||||
ThinkingModelBlacklist []string `json:"thinking_model_blacklist"`
|
||||
ChatCompletionsToResponsesPolicy ChatCompletionsToResponsesPolicy `json:"chat_completions_to_responses_policy"`
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
@@ -18,6 +40,10 @@ var defaultOpenaiSettings = GlobalSettings{
|
||||
"moonshotai/kimi-k2-thinking",
|
||||
"kimi-k2-thinking",
|
||||
},
|
||||
ChatCompletionsToResponsesPolicy: ChatCompletionsToResponsesPolicy{
|
||||
Enabled: false,
|
||||
AllChannels: true,
|
||||
},
|
||||
}
|
||||
|
||||
// 全局实例
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package operation_setting
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type StatusCodeRange struct {
|
||||
Start int
|
||||
End int
|
||||
}
|
||||
|
||||
var AutomaticDisableStatusCodeRanges = []StatusCodeRange{{Start: 401, End: 401}}
|
||||
|
||||
func AutomaticDisableStatusCodesToString() string {
|
||||
if len(AutomaticDisableStatusCodeRanges) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(AutomaticDisableStatusCodeRanges))
|
||||
for _, r := range AutomaticDisableStatusCodeRanges {
|
||||
if r.Start == r.End {
|
||||
parts = append(parts, strconv.Itoa(r.Start))
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%d-%d", r.Start, r.End))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func AutomaticDisableStatusCodesFromString(s string) error {
|
||||
ranges, err := ParseHTTPStatusCodeRanges(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
AutomaticDisableStatusCodeRanges = ranges
|
||||
return nil
|
||||
}
|
||||
|
||||
func ShouldDisableByStatusCode(code int) bool {
|
||||
if code < 100 || code > 599 {
|
||||
return false
|
||||
}
|
||||
for _, r := range AutomaticDisableStatusCodeRanges {
|
||||
if code < r.Start {
|
||||
return false
|
||||
}
|
||||
if code <= r.End {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ParseHTTPStatusCodeRanges(input string) ([]StatusCodeRange, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
input = strings.NewReplacer(",", ",").Replace(input)
|
||||
segments := strings.Split(input, ",")
|
||||
|
||||
var ranges []StatusCodeRange
|
||||
var invalid []string
|
||||
|
||||
for _, seg := range segments {
|
||||
seg = strings.TrimSpace(seg)
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
r, err := parseHTTPStatusCodeToken(seg)
|
||||
if err != nil {
|
||||
invalid = append(invalid, seg)
|
||||
continue
|
||||
}
|
||||
ranges = append(ranges, r)
|
||||
}
|
||||
|
||||
if len(invalid) > 0 {
|
||||
return nil, fmt.Errorf("invalid http status code rules: %s", strings.Join(invalid, ", "))
|
||||
}
|
||||
if len(ranges) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
sort.Slice(ranges, func(i, j int) bool {
|
||||
if ranges[i].Start == ranges[j].Start {
|
||||
return ranges[i].End < ranges[j].End
|
||||
}
|
||||
return ranges[i].Start < ranges[j].Start
|
||||
})
|
||||
|
||||
merged := []StatusCodeRange{ranges[0]}
|
||||
for _, r := range ranges[1:] {
|
||||
last := &merged[len(merged)-1]
|
||||
if r.Start <= last.End+1 {
|
||||
if r.End > last.End {
|
||||
last.End = r.End
|
||||
}
|
||||
continue
|
||||
}
|
||||
merged = append(merged, r)
|
||||
}
|
||||
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func parseHTTPStatusCodeToken(token string) (StatusCodeRange, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
token = strings.ReplaceAll(token, " ", "")
|
||||
if token == "" {
|
||||
return StatusCodeRange{}, fmt.Errorf("empty token")
|
||||
}
|
||||
|
||||
if strings.Contains(token, "-") {
|
||||
parts := strings.Split(token, "-")
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return StatusCodeRange{}, fmt.Errorf("invalid range token: %s", token)
|
||||
}
|
||||
start, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return StatusCodeRange{}, fmt.Errorf("invalid range start: %s", token)
|
||||
}
|
||||
end, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return StatusCodeRange{}, fmt.Errorf("invalid range end: %s", token)
|
||||
}
|
||||
if start > end {
|
||||
return StatusCodeRange{}, fmt.Errorf("range start > end: %s", token)
|
||||
}
|
||||
if start < 100 || end > 599 {
|
||||
return StatusCodeRange{}, fmt.Errorf("range out of bounds: %s", token)
|
||||
}
|
||||
return StatusCodeRange{Start: start, End: end}, nil
|
||||
}
|
||||
|
||||
code, err := strconv.Atoi(token)
|
||||
if err != nil {
|
||||
return StatusCodeRange{}, fmt.Errorf("invalid status code: %s", token)
|
||||
}
|
||||
if code < 100 || code > 599 {
|
||||
return StatusCodeRange{}, fmt.Errorf("status code out of bounds: %s", token)
|
||||
}
|
||||
return StatusCodeRange{Start: code, End: code}, nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package operation_setting
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseHTTPStatusCodeRanges_CommaSeparated(t *testing.T) {
|
||||
ranges, err := ParseHTTPStatusCodeRanges("401,403,500-599")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []StatusCodeRange{
|
||||
{Start: 401, End: 401},
|
||||
{Start: 403, End: 403},
|
||||
{Start: 500, End: 599},
|
||||
}, ranges)
|
||||
}
|
||||
|
||||
func TestParseHTTPStatusCodeRanges_MergeAndNormalize(t *testing.T) {
|
||||
ranges, err := ParseHTTPStatusCodeRanges("500-505,504,401,403,402")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []StatusCodeRange{
|
||||
{Start: 401, End: 403},
|
||||
{Start: 500, End: 505},
|
||||
}, ranges)
|
||||
}
|
||||
|
||||
func TestParseHTTPStatusCodeRanges_Invalid(t *testing.T) {
|
||||
_, err := ParseHTTPStatusCodeRanges("99,600,foo,500-400,500-")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestParseHTTPStatusCodeRanges_NoComma_IsInvalid(t *testing.T) {
|
||||
_, err := ParseHTTPStatusCodeRanges("401 403")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestShouldDisableByStatusCode(t *testing.T) {
|
||||
orig := AutomaticDisableStatusCodeRanges
|
||||
t.Cleanup(func() { AutomaticDisableStatusCodeRanges = orig })
|
||||
|
||||
AutomaticDisableStatusCodeRanges = []StatusCodeRange{
|
||||
{Start: 401, End: 403},
|
||||
{Start: 500, End: 599},
|
||||
}
|
||||
|
||||
require.True(t, ShouldDisableByStatusCode(401))
|
||||
require.True(t, ShouldDisableByStatusCode(403))
|
||||
require.False(t, ShouldDisableByStatusCode(404))
|
||||
require.True(t, ShouldDisableByStatusCode(500))
|
||||
require.False(t, ShouldDisableByStatusCode(200))
|
||||
}
|
||||
@@ -130,6 +130,20 @@ func (e *NewAPIError) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *NewAPIError) ErrorWithStatusCode() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
msg := e.Error()
|
||||
if e.StatusCode == 0 {
|
||||
return msg
|
||||
}
|
||||
if msg == "" {
|
||||
return fmt.Sprintf("status_code=%d", e.StatusCode)
|
||||
}
|
||||
return fmt.Sprintf("status_code=%d, %s", e.StatusCode, msg)
|
||||
}
|
||||
|
||||
func (e *NewAPIError) MaskSensitiveError() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
@@ -144,6 +158,20 @@ func (e *NewAPIError) MaskSensitiveError() string {
|
||||
return common.MaskSensitiveInfo(errStr)
|
||||
}
|
||||
|
||||
func (e *NewAPIError) MaskSensitiveErrorWithStatusCode() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
msg := e.MaskSensitiveError()
|
||||
if e.StatusCode == 0 {
|
||||
return msg
|
||||
}
|
||||
if msg == "" {
|
||||
return fmt.Sprintf("status_code=%d", e.StatusCode)
|
||||
}
|
||||
return fmt.Sprintf("status_code=%d, %s", e.StatusCode, msg)
|
||||
}
|
||||
|
||||
func (e *NewAPIError) SetMessage(message string) {
|
||||
e.Err = errors.New(message)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ const ModelSetting = () => {
|
||||
'claude.thinking_adapter_budget_tokens_percentage': 0.8,
|
||||
'global.pass_through_request_enabled': false,
|
||||
'global.thinking_model_blacklist': '[]',
|
||||
'global.chat_completions_to_responses_policy': '{}',
|
||||
'general_setting.ping_interval_enabled': false,
|
||||
'general_setting.ping_interval_seconds': 60,
|
||||
'gemini.thinking_adapter_enabled': false,
|
||||
@@ -59,10 +60,16 @@ const ModelSetting = () => {
|
||||
item.key === 'claude.model_headers_settings' ||
|
||||
item.key === 'claude.default_max_tokens' ||
|
||||
item.key === 'gemini.supported_imagine_models' ||
|
||||
item.key === 'global.thinking_model_blacklist'
|
||||
item.key === 'global.thinking_model_blacklist' ||
|
||||
item.key === 'global.chat_completions_to_responses_policy'
|
||||
) {
|
||||
if (item.value !== '') {
|
||||
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
|
||||
try {
|
||||
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
|
||||
} catch (e) {
|
||||
// Keep raw value so user can fix it, and avoid crashing the page.
|
||||
console.error(`Invalid JSON for option ${item.key}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Keep boolean config keys ending with enabled/Enabled so UI parses correctly.
|
||||
|
||||
@@ -70,6 +70,7 @@ const OperationSetting = () => {
|
||||
AutomaticDisableChannelEnabled: false,
|
||||
AutomaticEnableChannelEnabled: false,
|
||||
AutomaticDisableKeywords: '',
|
||||
AutomaticDisableStatusCodes: '401',
|
||||
'monitor_setting.auto_test_channel_enabled': false,
|
||||
'monitor_setting.auto_test_channel_minutes': 10 /* 签到设置 */,
|
||||
'checkin_setting.enabled': false,
|
||||
|
||||
@@ -92,7 +92,7 @@ const REGION_EXAMPLE = {
|
||||
|
||||
// 支持并且已适配通过接口获取模型列表的渠道类型
|
||||
const MODEL_FETCHABLE_TYPES = new Set([
|
||||
1, 4, 14, 34, 17, 26, 27, 24, 47, 25, 20, 23, 31, 35, 40, 42, 48, 43,
|
||||
1, 4, 14, 34, 17, 26, 27, 24, 47, 25, 20, 23, 31, 40, 42, 48, 43,
|
||||
]);
|
||||
|
||||
function type2secretPrompt(type) {
|
||||
@@ -199,17 +199,11 @@ const EditChannelModal = (props) => {
|
||||
if (!trimmed) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
Array.isArray(parsed)
|
||||
) {
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
const values = Object.values(parsed)
|
||||
.map((value) =>
|
||||
typeof value === 'string' ? value.trim() : undefined,
|
||||
)
|
||||
.map((value) => (typeof value === 'string' ? value.trim() : undefined))
|
||||
.filter((value) => value);
|
||||
return Array.from(new Set(values));
|
||||
} catch (error) {
|
||||
@@ -509,6 +503,18 @@ const EditChannelModal = (props) => {
|
||||
//setAutoBan
|
||||
};
|
||||
|
||||
const formatJsonField = (fieldName) => {
|
||||
const rawValue = (inputs?.[fieldName] ?? '').trim();
|
||||
if (!rawValue) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue);
|
||||
handleInputChange(fieldName, JSON.stringify(parsed, null, 2));
|
||||
} catch (error) {
|
||||
showError(`${t('JSON格式错误')}: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const loadChannel = async () => {
|
||||
setLoading(true);
|
||||
let res = await API.get(`/api/channel/${channelId}`);
|
||||
@@ -2812,6 +2818,12 @@ const EditChannelModal = (props) => {
|
||||
>
|
||||
{t('新格式模板')}
|
||||
</Text>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() => formatJsonField('param_override')}
|
||||
>
|
||||
{t('格式化')}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
showClear
|
||||
@@ -2852,6 +2864,12 @@ const EditChannelModal = (props) => {
|
||||
>
|
||||
{t('填入模板')}
|
||||
</Text>
|
||||
<Text
|
||||
className='!text-semi-color-primary cursor-pointer'
|
||||
onClick={() => formatJsonField('header_override')}
|
||||
>
|
||||
{t('格式化')}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text type='tertiary' size='small'>
|
||||
@@ -3181,7 +3199,9 @@ const EditChannelModal = (props) => {
|
||||
? inputs.models.map(String)
|
||||
: [];
|
||||
const incoming = modelIds.map(String);
|
||||
const nextModels = Array.from(new Set([...existingModels, ...incoming]));
|
||||
const nextModels = Array.from(
|
||||
new Set([...existingModels, ...incoming]),
|
||||
);
|
||||
|
||||
handleInputChange('models', nextModels);
|
||||
if (formApiRef.current) {
|
||||
|
||||
@@ -29,3 +29,4 @@ export * from './token';
|
||||
export * from './boolean';
|
||||
export * from './dashboard';
|
||||
export * from './passkey';
|
||||
export * from './statusCodeRules';
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
export function parseHttpStatusCodeRules(input) {
|
||||
const raw = (input ?? '').toString().trim();
|
||||
if (raw.length === 0) {
|
||||
return {
|
||||
ok: true,
|
||||
ranges: [],
|
||||
tokens: [],
|
||||
normalized: '',
|
||||
invalidTokens: [],
|
||||
};
|
||||
}
|
||||
|
||||
const sanitized = raw.replace(/[,]/g, ',');
|
||||
const segments = sanitized.split(/[,]/g);
|
||||
|
||||
const ranges = [];
|
||||
const invalidTokens = [];
|
||||
|
||||
for (const segment of segments) {
|
||||
const trimmed = segment.trim();
|
||||
if (!trimmed) continue;
|
||||
const parsed = parseToken(trimmed);
|
||||
if (!parsed) invalidTokens.push(trimmed);
|
||||
else ranges.push(parsed);
|
||||
}
|
||||
|
||||
if (invalidTokens.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
ranges: [],
|
||||
tokens: [],
|
||||
normalized: raw,
|
||||
invalidTokens,
|
||||
};
|
||||
}
|
||||
|
||||
const merged = mergeRanges(ranges);
|
||||
const tokens = merged.map((r) => (r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`));
|
||||
const normalized = tokens.join(',');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
ranges: merged,
|
||||
tokens,
|
||||
normalized,
|
||||
invalidTokens: [],
|
||||
};
|
||||
}
|
||||
|
||||
function parseToken(token) {
|
||||
const cleaned = (token ?? '').toString().trim().replaceAll(' ', '');
|
||||
if (!cleaned) return null;
|
||||
|
||||
if (cleaned.includes('-')) {
|
||||
const parts = cleaned.split('-');
|
||||
if (parts.length !== 2) return null;
|
||||
const [a, b] = parts;
|
||||
if (!isNumber(a) || !isNumber(b)) return null;
|
||||
const start = Number.parseInt(a, 10);
|
||||
const end = Number.parseInt(b, 10);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
|
||||
if (start > end) return null;
|
||||
if (start < 100 || end > 599) return null;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
if (!isNumber(cleaned)) return null;
|
||||
const code = Number.parseInt(cleaned, 10);
|
||||
if (!Number.isFinite(code)) return null;
|
||||
if (code < 100 || code > 599) return null;
|
||||
return { start: code, end: code };
|
||||
}
|
||||
|
||||
function isNumber(s) {
|
||||
return typeof s === 'string' && /^\d+$/.test(s);
|
||||
}
|
||||
|
||||
function mergeRanges(ranges) {
|
||||
if (!Array.isArray(ranges) || ranges.length === 0) return [];
|
||||
|
||||
const sorted = [...ranges].sort((a, b) => (a.start !== b.start ? a.start - b.start : a.end - b.end));
|
||||
const merged = [sorted[0]];
|
||||
|
||||
for (let i = 1; i < sorted.length; i += 1) {
|
||||
const current = sorted[i];
|
||||
const last = merged[merged.length - 1];
|
||||
|
||||
if (current.start <= last.end + 1) {
|
||||
last.end = Math.max(last.end, current.end);
|
||||
continue;
|
||||
}
|
||||
merged.push({ ...current });
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
@@ -1923,6 +1923,10 @@
|
||||
"自动测试所有通道间隔时间": "Auto test interval for all channels",
|
||||
"自动禁用": "Auto disabled",
|
||||
"自动禁用关键词": "Automatic disable keywords",
|
||||
"自动禁用状态码": "Auto-disable status codes",
|
||||
"自动禁用状态码格式不正确": "Invalid auto-disable status code format",
|
||||
"支持填写单个状态码或范围(含首尾),使用逗号分隔": "Supports single status codes or inclusive ranges; separate with commas",
|
||||
"例如:401, 403, 429, 500-599": "e.g. 401,403,429,500-599",
|
||||
"自动选择": "Auto Select",
|
||||
"自定义充值数量选项": "Custom Recharge Amount Options",
|
||||
"自定义充值数量选项不是合法的 JSON 数组": "Custom recharge amount options is not a valid JSON array",
|
||||
@@ -2583,6 +2587,11 @@
|
||||
"签到最大额度": "Maximum check-in quota",
|
||||
"签到奖励的最大额度": "Maximum quota for check-in rewards",
|
||||
"保存签到设置": "Save check-in settings",
|
||||
"ChatCompletions→Responses 兼容配置(Beta)": "ChatCompletions→Responses Compatibility (Beta)",
|
||||
"提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。": "Notice: This feature is beta. The configuration structure and behavior may change in the future. Do not use in production.",
|
||||
"填充模板(指定渠道)": "Fill template (selected channels)",
|
||||
"填充模板(全渠道)": "Fill template (all channels)",
|
||||
"格式化 JSON": "Format JSON",
|
||||
"提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。": "Notice: This configuration only affects how models are displayed in the Model Marketplace and does not impact actual model invocation or routing. To configure real invocation behavior, please go to Channel Management.",
|
||||
"确认关闭提示": "Confirm close",
|
||||
"关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "After closing, this notice will no longer be shown (only for this browser). Are you sure you want to close it?",
|
||||
|
||||
@@ -2597,6 +2597,11 @@
|
||||
"提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。": "Remarque : cette configuration n'affecte que l'affichage des modèles dans la place de marché des modèles et n'a aucun impact sur l'invocation ou le routage réels. Pour configurer le comportement réel des appels, veuillez aller dans « Gestion des canaux ».",
|
||||
"确认关闭提示": "Confirmer la fermeture",
|
||||
"关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "Après fermeture, cet avertissement ne sera plus affiché (uniquement pour ce navigateur). Voulez-vous vraiment le fermer ?",
|
||||
"ChatCompletions→Responses 兼容配置(Beta)": "Compatibilité ChatCompletions→Responses (bêta)",
|
||||
"提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。": "Remarque : cette fonctionnalité est en version bêta. La structure de configuration et le comportement peuvent changer à l’avenir. Ne l’utilisez pas en production.",
|
||||
"填充模板(指定渠道)": "Remplir le modèle (canaux sélectionnés)",
|
||||
"填充模板(全渠道)": "Remplir le modèle (tous les canaux)",
|
||||
"格式化 JSON": "Formater le JSON",
|
||||
"关闭提示": "Fermer l’avertissement",
|
||||
"说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "Remarque : les tests sur cette page utilisent des requêtes non-streaming. Si un canal ne prend en charge que les réponses en streaming, les tests peuvent échouer. Veuillez vous référer à l’usage réel.",
|
||||
"提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "Remarque : la correspondance des endpoints sert uniquement à l’affichage dans la place de marché des modèles et n’affecte pas l’invocation réelle. Pour configurer l’invocation réelle, veuillez aller dans « Gestion des canaux »."
|
||||
|
||||
@@ -2580,6 +2580,11 @@
|
||||
"提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。": "注意: ここでの設定は「モデル広場」での表示にのみ影響し、実際の呼び出しやルーティングには影響しません。実際の呼び出しを設定する場合は、「チャネル管理」で設定してください。",
|
||||
"确认关闭提示": "閉じる確認",
|
||||
"关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "閉じると、このお知らせは今後表示されません(このブラウザのみ)。閉じてもよろしいですか?",
|
||||
"ChatCompletions→Responses 兼容配置(Beta)": "ChatCompletions→Responses 互換設定(ベータ)",
|
||||
"提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。": "注意: この機能はベータ版です。今後、設定構造や挙動が変更される可能性があります。本番環境では使用しないでください。",
|
||||
"填充模板(指定渠道)": "テンプレートを入力(指定チャネル)",
|
||||
"填充模板(全渠道)": "テンプレートを入力(全チャネル)",
|
||||
"格式化 JSON": "JSON を整形",
|
||||
"关闭提示": "お知らせを閉じる",
|
||||
"说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "注意: このページのテストは非ストリーミングリクエストです。チャネルがストリーミング応答のみ対応の場合、テストが失敗することがあります。実際の利用結果を優先してください。",
|
||||
"提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "注意: エンドポイントマッピングは「モデル広場」での表示専用で、実際の呼び出しには影響しません。実際の呼び出し設定は「チャネル管理」で行ってください。"
|
||||
|
||||
@@ -2610,6 +2610,11 @@
|
||||
"提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。": "Примечание: эта настройка влияет только на отображение моделей в «Маркетплейсе моделей» и не влияет на фактический вызов или маршрутизацию. Чтобы настроить реальное поведение вызовов, перейдите в «Управление каналами».",
|
||||
"确认关闭提示": "Подтвердить закрытие",
|
||||
"关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "После закрытия это уведомление больше не будет показываться (только в этом браузере). Закрыть?",
|
||||
"ChatCompletions→Responses 兼容配置(Beta)": "Совместимость ChatCompletions→Responses (бета)",
|
||||
"提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。": "Примечание: это бета-функция. Структура конфигурации и поведение могут измениться в будущем. Не используйте в продакшене.",
|
||||
"填充模板(指定渠道)": "Заполнить шаблон (выбранные каналы)",
|
||||
"填充模板(全渠道)": "Заполнить шаблон (все каналы)",
|
||||
"格式化 JSON": "Форматировать JSON",
|
||||
"关闭提示": "Закрыть уведомление",
|
||||
"说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "Примечание: тесты на этой странице используют нестриминговые запросы. Если канал поддерживает только стриминговые ответы, тест может завершиться неудачей. Ориентируйтесь на реальное использование.",
|
||||
"提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "Примечание: сопоставление endpoint'ов используется только для отображения в «Маркетплейсе моделей» и не влияет на реальный вызов. Чтобы настроить реальное поведение вызовов, перейдите в «Управление каналами»."
|
||||
|
||||
@@ -3160,6 +3160,11 @@
|
||||
"提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。": "Lưu ý: Cấu hình tại đây chỉ ảnh hưởng đến cách hiển thị trong \"Chợ mô hình\" và không ảnh hưởng đến việc gọi hoặc định tuyến thực tế. Nếu cần cấu hình hành vi gọi thực tế, vui lòng thiết lập trong \"Quản lý kênh\".",
|
||||
"确认关闭提示": "Xác nhận đóng",
|
||||
"关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "Sau khi đóng, thông báo này sẽ không còn hiển thị nữa (chỉ với trình duyệt này). Bạn có chắc muốn đóng không?",
|
||||
"ChatCompletions→Responses 兼容配置(Beta)": "Tương thích ChatCompletions→Responses (Beta)",
|
||||
"提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。": "Lưu ý: Đây là tính năng beta. Cấu trúc cấu hình và hành vi có thể thay đổi trong tương lai. Không dùng trong môi trường production.",
|
||||
"填充模板(指定渠道)": "Điền mẫu (kênh được chọn)",
|
||||
"填充模板(全渠道)": "Điền mẫu (tất cả kênh)",
|
||||
"格式化 JSON": "Định dạng JSON",
|
||||
"关闭提示": "Đóng thông báo",
|
||||
"说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "Lưu ý: Bài kiểm tra trên trang này sử dụng yêu cầu không streaming. Nếu kênh chỉ hỗ trợ phản hồi streaming, bài kiểm tra có thể thất bại. Vui lòng dựa vào sử dụng thực tế.",
|
||||
"提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "Lưu ý: Ánh xạ endpoint chỉ dùng để hiển thị trong \"Chợ mô hình\" và không ảnh hưởng đến việc gọi thực tế. Để cấu hình gọi thực tế, vui lòng vào \"Quản lý kênh\"."
|
||||
|
||||
@@ -1909,6 +1909,10 @@
|
||||
"自动测试所有通道间隔时间": "自动测试所有通道间隔时间",
|
||||
"自动禁用": "自动禁用",
|
||||
"自动禁用关键词": "自动禁用关键词",
|
||||
"自动禁用状态码": "自动禁用状态码",
|
||||
"自动禁用状态码格式不正确": "自动禁用状态码格式不正确",
|
||||
"支持填写单个状态码或范围(含首尾),使用逗号分隔": "支持填写单个状态码或范围(含首尾),使用逗号分隔",
|
||||
"例如:401, 403, 429, 500-599": "例如:401,403,429,500-599",
|
||||
"自动选择": "自动选择",
|
||||
"自定义充值数量选项": "自定义充值数量选项",
|
||||
"自定义充值数量选项不是合法的 JSON 数组": "自定义充值数量选项不是合法的 JSON 数组",
|
||||
@@ -2569,6 +2573,11 @@
|
||||
"签到最大额度": "签到最大额度",
|
||||
"签到奖励的最大额度": "签到奖励的最大额度",
|
||||
"保存签到设置": "保存签到设置",
|
||||
"ChatCompletions→Responses 兼容配置(Beta)": "ChatCompletions→Responses 兼容配置(Beta)",
|
||||
"提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。": "提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。",
|
||||
"填充模板(指定渠道)": "填充模板(指定渠道)",
|
||||
"填充模板(全渠道)": "填充模板(全渠道)",
|
||||
"格式化 JSON": "格式化 JSON",
|
||||
"提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。": "提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。",
|
||||
"确认关闭提示": "确认关闭提示",
|
||||
"关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?",
|
||||
|
||||
@@ -18,7 +18,16 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Button, Col, Form, Row, Spin, Banner } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
Row,
|
||||
Spin,
|
||||
Banner,
|
||||
Tag,
|
||||
Divider,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
@@ -35,9 +44,31 @@ const thinkingExample = JSON.stringify(
|
||||
2,
|
||||
);
|
||||
|
||||
const chatCompletionsToResponsesPolicyExample = JSON.stringify(
|
||||
{
|
||||
enabled: true,
|
||||
all_channels: false,
|
||||
channel_ids: [1, 2],
|
||||
model_patterns: ['^gpt-4o.*$', '^gpt-5.*$'],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
const chatCompletionsToResponsesPolicyAllChannelsExample = JSON.stringify(
|
||||
{
|
||||
enabled: true,
|
||||
all_channels: true,
|
||||
model_patterns: ['^gpt-4o.*$', '^gpt-5.*$'],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
const defaultGlobalSettingInputs = {
|
||||
'global.pass_through_request_enabled': false,
|
||||
'global.thinking_model_blacklist': '[]',
|
||||
'global.chat_completions_to_responses_policy': '{}',
|
||||
'general_setting.ping_interval_enabled': false,
|
||||
'general_setting.ping_interval_seconds': 60,
|
||||
};
|
||||
@@ -49,12 +80,28 @@ export default function SettingGlobalModel(props) {
|
||||
const [inputs, setInputs] = useState(defaultGlobalSettingInputs);
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(defaultGlobalSettingInputs);
|
||||
const chatCompletionsToResponsesPolicyKey =
|
||||
'global.chat_completions_to_responses_policy';
|
||||
|
||||
const setChatCompletionsToResponsesPolicyValue = (value) => {
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
[chatCompletionsToResponsesPolicyKey]: value,
|
||||
}));
|
||||
if (refForm.current) {
|
||||
refForm.current.setValue(chatCompletionsToResponsesPolicyKey, value);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeValueBeforeSave = (key, value) => {
|
||||
if (key === 'global.thinking_model_blacklist') {
|
||||
const text = typeof value === 'string' ? value.trim() : '';
|
||||
return text === '' ? '[]' : value;
|
||||
}
|
||||
if (key === 'global.chat_completions_to_responses_policy') {
|
||||
const text = typeof value === 'string' ? value.trim() : '';
|
||||
return text === '' ? '{}' : value;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
@@ -108,6 +155,16 @@ export default function SettingGlobalModel(props) {
|
||||
value = defaultGlobalSettingInputs[key];
|
||||
}
|
||||
}
|
||||
if (key === 'global.chat_completions_to_responses_policy') {
|
||||
try {
|
||||
value =
|
||||
value && String(value).trim() !== ''
|
||||
? JSON.stringify(JSON.parse(value), null, 2)
|
||||
: defaultGlobalSettingInputs[key];
|
||||
} catch (error) {
|
||||
value = defaultGlobalSettingInputs[key];
|
||||
}
|
||||
}
|
||||
currentInputs[key] = value;
|
||||
} else {
|
||||
currentInputs[key] = defaultGlobalSettingInputs[key];
|
||||
@@ -180,7 +237,134 @@ export default function SettingGlobalModel(props) {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Section text={t('连接保活设置')}>
|
||||
<Form.Section
|
||||
text={
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{t('ChatCompletions→Responses 兼容配置')}
|
||||
<Tag color='orange' size='small'>
|
||||
测试版
|
||||
</Tag>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Row style={{ marginTop: 10 }}>
|
||||
<Col span={24}>
|
||||
<Banner
|
||||
type='warning'
|
||||
description={t(
|
||||
'提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。',
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row style={{ marginTop: 10 }}>
|
||||
<Col span={24}>
|
||||
<Form.TextArea
|
||||
label={t('参数配置')}
|
||||
field={chatCompletionsToResponsesPolicyKey}
|
||||
placeholder={
|
||||
t('例如(指定渠道):') +
|
||||
'\n' +
|
||||
chatCompletionsToResponsesPolicyExample +
|
||||
'\n\n' +
|
||||
t('例如(全渠道):') +
|
||||
'\n' +
|
||||
chatCompletionsToResponsesPolicyAllChannelsExample
|
||||
}
|
||||
rows={8}
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => {
|
||||
if (!value || value.trim() === '') return true;
|
||||
return verifyJSON(value);
|
||||
},
|
||||
message: t('不是合法的 JSON 字符串'),
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
[chatCompletionsToResponsesPolicyKey]: value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row style={{ marginTop: 10, marginBottom: 16 }}>
|
||||
<Col span={24}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type='secondary'
|
||||
size='small'
|
||||
onClick={() =>
|
||||
setChatCompletionsToResponsesPolicyValue(
|
||||
chatCompletionsToResponsesPolicyExample,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('填充模板(指定渠道)')}
|
||||
</Button>
|
||||
<Button
|
||||
type='secondary'
|
||||
size='small'
|
||||
onClick={() =>
|
||||
setChatCompletionsToResponsesPolicyValue(
|
||||
chatCompletionsToResponsesPolicyAllChannelsExample,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('填充模板(全渠道)')}
|
||||
</Button>
|
||||
<Button
|
||||
type='secondary'
|
||||
size='small'
|
||||
onClick={() => {
|
||||
const raw = inputs[chatCompletionsToResponsesPolicyKey];
|
||||
if (!raw || String(raw).trim() === '') return;
|
||||
try {
|
||||
const formatted = JSON.stringify(
|
||||
JSON.parse(raw),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
setChatCompletionsToResponsesPolicyValue(formatted);
|
||||
} catch (error) {
|
||||
showError(t('不是合法的 JSON 字符串'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('格式化 JSON')}
|
||||
</Button>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
|
||||
<Form.Section
|
||||
text={
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>
|
||||
{t('连接保活设置')}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Row style={{ marginTop: 10 }}>
|
||||
<Col span={24}>
|
||||
<Banner
|
||||
|
||||
@@ -18,19 +18,29 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
Row,
|
||||
Spin,
|
||||
Tag,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
parseHttpStatusCodeRules,
|
||||
verifyJSON,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function SettingsMonitoring(props) {
|
||||
const { t } = useTranslation();
|
||||
const { Text } = Typography;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
ChannelDisableThreshold: '',
|
||||
@@ -38,21 +48,37 @@ export default function SettingsMonitoring(props) {
|
||||
AutomaticDisableChannelEnabled: false,
|
||||
AutomaticEnableChannelEnabled: false,
|
||||
AutomaticDisableKeywords: '',
|
||||
AutomaticDisableStatusCodes: '401',
|
||||
'monitor_setting.auto_test_channel_enabled': false,
|
||||
'monitor_setting.auto_test_channel_minutes': 10,
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
const parsedAutoDisableStatusCodes = parseHttpStatusCodeRules(
|
||||
inputs.AutomaticDisableStatusCodes || '',
|
||||
);
|
||||
|
||||
function onSubmit() {
|
||||
const updateArray = compareObjects(inputs, inputsRow);
|
||||
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
|
||||
if (!parsedAutoDisableStatusCodes.ok) {
|
||||
const details =
|
||||
parsedAutoDisableStatusCodes.invalidTokens &&
|
||||
parsedAutoDisableStatusCodes.invalidTokens.length > 0
|
||||
? `: ${parsedAutoDisableStatusCodes.invalidTokens.join(', ')}`
|
||||
: '';
|
||||
return showError(`${t('自动禁用状态码格式不正确')}${details}`);
|
||||
}
|
||||
const requestQueue = updateArray.map((item) => {
|
||||
let value = '';
|
||||
if (typeof inputs[item.key] === 'boolean') {
|
||||
value = String(inputs[item.key]);
|
||||
} else {
|
||||
value = inputs[item.key];
|
||||
if (item.key === 'AutomaticDisableStatusCodes') {
|
||||
value = parsedAutoDisableStatusCodes.normalized;
|
||||
} else {
|
||||
value = inputs[item.key];
|
||||
}
|
||||
}
|
||||
return API.put('/api/option/', {
|
||||
key: item.key,
|
||||
@@ -207,6 +233,45 @@ export default function SettingsMonitoring(props) {
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.Input
|
||||
label={t('自动禁用状态码')}
|
||||
placeholder={t('例如:401, 403, 429, 500-599')}
|
||||
extraText={t(
|
||||
'支持填写单个状态码或范围(含首尾),使用逗号分隔',
|
||||
)}
|
||||
field={'AutomaticDisableStatusCodes'}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, AutomaticDisableStatusCodes: value })
|
||||
}
|
||||
/>
|
||||
{parsedAutoDisableStatusCodes.ok &&
|
||||
parsedAutoDisableStatusCodes.tokens.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
{parsedAutoDisableStatusCodes.tokens.map((token) => (
|
||||
<Tag key={token} size='small'>
|
||||
{token}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!parsedAutoDisableStatusCodes.ok && (
|
||||
<Text type='danger' style={{ display: 'block', marginTop: 8 }}>
|
||||
{t('自动禁用状态码格式不正确')}
|
||||
{parsedAutoDisableStatusCodes.invalidTokens &&
|
||||
parsedAutoDisableStatusCodes.invalidTokens.length > 0
|
||||
? `: ${parsedAutoDisableStatusCodes.invalidTokens.join(
|
||||
', ',
|
||||
)}`
|
||||
: ''}
|
||||
</Text>
|
||||
)}
|
||||
<Form.TextArea
|
||||
label={t('自动禁用关键词')}
|
||||
placeholder={t('一行一个,不区分大小写')}
|
||||
|
||||
Reference in New Issue
Block a user