Commit 61f2aa54 by t0ng7u

Merge remote-tracking branch 'origin/alpha' into alpha

parents 17cf9c2d 80a7d7eb
...@@ -2,7 +2,8 @@ package common ...@@ -2,7 +2,8 @@ package common
import ( import (
"fmt" "fmt"
"github.com/antlabs/pcopy"
"github.com/jinzhu/copier"
) )
func DeepCopy[T any](src *T) (*T, error) { func DeepCopy[T any](src *T) (*T, error) {
...@@ -10,12 +11,9 @@ func DeepCopy[T any](src *T) (*T, error) { ...@@ -10,12 +11,9 @@ func DeepCopy[T any](src *T) (*T, error) {
return nil, fmt.Errorf("copy source cannot be nil") return nil, fmt.Errorf("copy source cannot be nil")
} }
var dst T var dst T
err := pcopy.Copy(&dst, src) err := copier.CopyWithOption(&dst, src, copier.Option{DeepCopy: true, IgnoreEmpty: true})
if err != nil { if err != nil {
return nil, err return nil, err
} }
if &dst == nil {
return nil, fmt.Errorf("copy result cannot be nil")
}
return &dst, nil return &dst, nil
} }
...@@ -20,3 +20,25 @@ func DecodeJson(reader *bytes.Reader, v any) error { ...@@ -20,3 +20,25 @@ func DecodeJson(reader *bytes.Reader, v any) error {
func Marshal(v any) ([]byte, error) { func Marshal(v any) ([]byte, error) {
return json.Marshal(v) return json.Marshal(v)
} }
func GetJsonType(data json.RawMessage) string {
data = bytes.TrimSpace(data)
if len(data) == 0 {
return "unknown"
}
firstChar := bytes.TrimSpace(data)[0]
switch firstChar {
case '{':
return "object"
case '[':
return "array"
case '"':
return "string"
case 't', 'f':
return "boolean"
case 'n':
return "null"
default:
return "number"
}
}
...@@ -380,6 +380,85 @@ func GetChannel(c *gin.Context) { ...@@ -380,6 +380,85 @@ func GetChannel(c *gin.Context) {
return return
} }
// GetChannelKey 验证2FA后获取渠道密钥
func GetChannelKey(c *gin.Context) {
type GetChannelKeyRequest struct {
Code string `json:"code" binding:"required"`
}
var req GetChannelKeyRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, fmt.Errorf("参数错误: %v", err))
return
}
userId := c.GetInt("id")
channelId, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiError(c, fmt.Errorf("渠道ID格式错误: %v", err))
return
}
// 获取2FA记录并验证
twoFA, err := model.GetTwoFAByUserId(userId)
if err != nil {
common.ApiError(c, fmt.Errorf("获取2FA信息失败: %v", err))
return
}
if twoFA == nil || !twoFA.IsEnabled {
common.ApiError(c, fmt.Errorf("用户未启用2FA,无法查看密钥"))
return
}
// 统一的2FA验证逻辑
if !validateTwoFactorAuth(twoFA, req.Code) {
common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试"))
return
}
// 获取渠道信息(包含密钥)
channel, err := model.GetChannelById(channelId, true)
if err != nil {
common.ApiError(c, fmt.Errorf("获取渠道信息失败: %v", err))
return
}
if channel == nil {
common.ApiError(c, fmt.Errorf("渠道不存在"))
return
}
// 记录操作日志
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d)", channelId))
// 统一的成功响应格式
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "验证成功",
"data": map[string]interface{}{
"key": channel.Key,
},
})
}
// validateTwoFactorAuth 统一的2FA验证函数
func validateTwoFactorAuth(twoFA *model.TwoFA, code string) bool {
// 尝试验证TOTP
if cleanCode, err := common.ValidateNumericCode(code); err == nil {
if isValid, _ := twoFA.ValidateTOTPAndUpdateUsage(cleanCode); isValid {
return true
}
}
// 尝试验证备用码
if isValid, err := twoFA.ValidateBackupCodeAndUpdateUsage(code); err == nil && isValid {
return true
}
return false
}
// validateChannel 通用的渠道校验函数 // validateChannel 通用的渠道校验函数
func validateChannel(channel *model.Channel, isAdd bool) error { func validateChannel(channel *model.Channel, isAdd bool) error {
// 校验 channel settings // 校验 channel settings
......
...@@ -488,14 +488,14 @@ func (c *ClaudeResponse) GetClaudeError() *types.ClaudeError { ...@@ -488,14 +488,14 @@ func (c *ClaudeResponse) GetClaudeError() *types.ClaudeError {
case string: case string:
// 处理简单字符串错误 // 处理简单字符串错误
return &types.ClaudeError{ return &types.ClaudeError{
Type: "error", Type: "upstream_error",
Message: err, Message: err,
} }
default: default:
// 未知类型,尝试转换为字符串 // 未知类型,尝试转换为字符串
return &types.ClaudeError{ return &types.ClaudeError{
Type: "unknown_error", Type: "unknown_upstream_error",
Message: fmt.Sprintf("%v", err), Message: fmt.Sprintf("unknown_error: %v", err),
} }
} }
} }
......
...@@ -2,7 +2,9 @@ package dto ...@@ -2,7 +2,9 @@ package dto
import ( import (
"encoding/json" "encoding/json"
"one-api/common"
"one-api/types" "one-api/types"
"reflect"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
...@@ -29,6 +31,68 @@ type ImageRequest struct { ...@@ -29,6 +31,68 @@ type ImageRequest struct {
Extra map[string]json.RawMessage `json:"-"` Extra map[string]json.RawMessage `json:"-"`
} }
func (i *ImageRequest) UnmarshalJSON(data []byte) error {
// 先解析成 map[string]interface{}
var rawMap map[string]json.RawMessage
if err := common.Unmarshal(data, &rawMap); err != nil {
return err
}
// 用 struct tag 获取所有已定义字段名
knownFields := GetJSONFieldNames(reflect.TypeOf(*i))
// 再正常解析已定义字段
type Alias ImageRequest
var known Alias
if err := common.Unmarshal(data, &known); err != nil {
return err
}
*i = ImageRequest(known)
// 提取多余字段
i.Extra = make(map[string]json.RawMessage)
for k, v := range rawMap {
if _, ok := knownFields[k]; !ok {
i.Extra[k] = v
}
}
return nil
}
func GetJSONFieldNames(t reflect.Type) map[string]struct{} {
fields := make(map[string]struct{})
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
// 跳过匿名字段(例如 ExtraFields)
if field.Anonymous {
continue
}
tag := field.Tag.Get("json")
if tag == "-" || tag == "" {
continue
}
// 取逗号前字段名(排除 omitempty 等)
name := tag
if commaIdx := indexComma(tag); commaIdx != -1 {
name = tag[:commaIdx]
}
fields[name] = struct{}{}
}
return fields
}
func indexComma(s string) int {
for i := 0; i < len(s); i++ {
if s[i] == ',' {
return i
}
}
return -1
}
func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta { func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta {
var sizeRatio = 1.0 var sizeRatio = 1.0
var qualityRatio = 1.0 var qualityRatio = 1.0
......
...@@ -57,18 +57,24 @@ type GeneralOpenAIRequest struct { ...@@ -57,18 +57,24 @@ type GeneralOpenAIRequest struct {
Dimensions int `json:"dimensions,omitempty"` Dimensions int `json:"dimensions,omitempty"`
Modalities json.RawMessage `json:"modalities,omitempty"` Modalities json.RawMessage `json:"modalities,omitempty"`
Audio json.RawMessage `json:"audio,omitempty"` Audio json.RawMessage `json:"audio,omitempty"`
EnableThinking any `json:"enable_thinking,omitempty"` // ali // gemini
THINKING json.RawMessage `json:"thinking,omitempty"` // doubao,zhipu_v4
ExtraBody json.RawMessage `json:"extra_body,omitempty"` ExtraBody json.RawMessage `json:"extra_body,omitempty"`
SearchParameters any `json:"search_parameters,omitempty"` //xai //xai
SearchParameters json.RawMessage `json:"search_parameters,omitempty"`
// claude
WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"` WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"`
// OpenRouter Params // OpenRouter Params
Usage json.RawMessage `json:"usage,omitempty"` Usage json.RawMessage `json:"usage,omitempty"`
Reasoning json.RawMessage `json:"reasoning,omitempty"` Reasoning json.RawMessage `json:"reasoning,omitempty"`
// Ali Qwen Params // Ali Qwen Params
VlHighResolutionImages json.RawMessage `json:"vl_high_resolution_images,omitempty"` VlHighResolutionImages json.RawMessage `json:"vl_high_resolution_images,omitempty"`
// 用匿名参数接收额外参数,例如ollama的think参数在此接收 EnableThinking any `json:"enable_thinking,omitempty"`
Extra map[string]json.RawMessage `json:"-"` // ollama Params
Think json.RawMessage `json:"think,omitempty"`
// baidu v2
WebSearch json.RawMessage `json:"web_search,omitempty"`
// doubao,zhipu_v4
THINKING json.RawMessage `json:"thinking,omitempty"`
} }
func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta { func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
...@@ -761,7 +767,7 @@ type WebSearchOptions struct { ...@@ -761,7 +767,7 @@ type WebSearchOptions struct {
// https://platform.openai.com/docs/api-reference/responses/create // https://platform.openai.com/docs/api-reference/responses/create
type OpenAIResponsesRequest struct { type OpenAIResponsesRequest struct {
Model string `json:"model"` Model string `json:"model"`
Input any `json:"input,omitempty"` Input json.RawMessage `json:"input,omitempty"`
Include json.RawMessage `json:"include,omitempty"` Include json.RawMessage `json:"include,omitempty"`
Instructions json.RawMessage `json:"instructions,omitempty"` Instructions json.RawMessage `json:"instructions,omitempty"`
MaxOutputTokens uint `json:"max_output_tokens,omitempty"` MaxOutputTokens uint `json:"max_output_tokens,omitempty"`
...@@ -775,7 +781,7 @@ type OpenAIResponsesRequest struct { ...@@ -775,7 +781,7 @@ type OpenAIResponsesRequest struct {
Temperature float64 `json:"temperature,omitempty"` Temperature float64 `json:"temperature,omitempty"`
Text json.RawMessage `json:"text,omitempty"` Text json.RawMessage `json:"text,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"` ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
Tools []map[string]any `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map 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"` Truncation string `json:"truncation,omitempty"`
User string `json:"user,omitempty"` User string `json:"user,omitempty"`
...@@ -832,8 +838,7 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta { ...@@ -832,8 +838,7 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
} }
if len(r.Tools) > 0 { if len(r.Tools) > 0 {
toolStr, _ := common.Marshal(r.Tools) texts = append(texts, string(r.Tools))
texts = append(texts, string(toolStr))
} }
return &types.TokenCountMeta{ return &types.TokenCountMeta{
...@@ -853,6 +858,14 @@ func (r *OpenAIResponsesRequest) SetModelName(modelName string) { ...@@ -853,6 +858,14 @@ func (r *OpenAIResponsesRequest) SetModelName(modelName string) {
} }
} }
func (r *OpenAIResponsesRequest) GetToolsMap() []map[string]any {
var toolsMap []map[string]any
if len(r.Tools) > 0 {
_ = common.Unmarshal(r.Tools, &toolsMap)
}
return toolsMap
}
type Reasoning struct { type Reasoning struct {
Effort string `json:"effort,omitempty"` Effort string `json:"effort,omitempty"`
Summary string `json:"summary,omitempty"` Summary string `json:"summary,omitempty"`
...@@ -879,13 +892,21 @@ func (r *OpenAIResponsesRequest) ParseInput() []MediaInput { ...@@ -879,13 +892,21 @@ func (r *OpenAIResponsesRequest) ParseInput() []MediaInput {
var inputs []MediaInput var inputs []MediaInput
// Try string first // Try string first
if str, ok := r.Input.(string); ok { // if str, ok := common.GetJsonType(r.Input); ok {
// inputs = append(inputs, MediaInput{Type: "input_text", Text: str})
// return inputs
// }
if common.GetJsonType(r.Input) == "string" {
var str string
_ = common.Unmarshal(r.Input, &str)
inputs = append(inputs, MediaInput{Type: "input_text", Text: str}) inputs = append(inputs, MediaInput{Type: "input_text", Text: str})
return inputs return inputs
} }
// Try array of parts // Try array of parts
if array, ok := r.Input.([]any); ok { if common.GetJsonType(r.Input) == "array" {
var array []any
_ = common.Unmarshal(r.Input, &array)
for _, itemAny := range array { for _, itemAny := range array {
// Already parsed MediaInput // Already parsed MediaInput
if media, ok := itemAny.(MediaInput); ok { if media, ok := itemAny.(MediaInput); ok {
......
...@@ -23,6 +23,7 @@ require ( ...@@ -23,6 +23,7 @@ require (
github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.0 github.com/gorilla/websocket v1.5.0
github.com/jinzhu/copier v0.4.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
github.com/pquerna/otp v1.5.0 github.com/pquerna/otp v1.5.0
...@@ -44,11 +45,7 @@ require ( ...@@ -44,11 +45,7 @@ require (
) )
require ( require (
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.2.0 // indirect
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect
github.com/antlabs/pcopy v0.1.5 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 // indirect
...@@ -73,8 +70,6 @@ require ( ...@@ -73,8 +70,6 @@ require (
github.com/gorilla/context v1.1.1 // indirect github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect github.com/gorilla/securecookie v1.1.1 // indirect
github.com/gorilla/sessions v1.2.1 // indirect github.com/gorilla/sessions v1.2.1 // indirect
github.com/huandu/xstrings v1.3.3 // indirect
github.com/imdario/mergo v0.3.11 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.1 // indirect github.com/jackc/pgx/v5 v5.7.1 // indirect
...@@ -85,14 +80,11 @@ require ( ...@@ -85,14 +80,11 @@ require (
github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/copystructure v1.0.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.1 // indirect github.com/pelletier/go-toml/v2 v2.2.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/cast v1.3.1 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect github.com/tidwall/pretty v1.2.0 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect
......
package middleware
import "github.com/gin-gonic/gin"
func DisableCache() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Cache-Control", "no-store, no-cache, must-revalidate, private, max-age=0")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")
c.Next()
}
}
...@@ -185,7 +185,7 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { ...@@ -185,7 +185,7 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
modelRequest.Model = modelName modelRequest.Model = modelName
} }
c.Set("relay_mode", relayMode) c.Set("relay_mode", relayMode)
} else if !strings.HasPrefix(c.Request.URL.Path, "/v1/audio/transcriptions") && !strings.HasPrefix(c.Request.URL.Path, "/v1/images/edits") { } else if !strings.HasPrefix(c.Request.URL.Path, "/v1/audio/transcriptions") && !strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
err = common.UnmarshalBodyReusable(c, &modelRequest) err = common.UnmarshalBodyReusable(c, &modelRequest)
} }
if err != nil { if err != nil {
...@@ -208,7 +208,10 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { ...@@ -208,7 +208,10 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") { if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") {
modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "dall-e") modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "dall-e")
} else if strings.HasPrefix(c.Request.URL.Path, "/v1/images/edits") { } else if strings.HasPrefix(c.Request.URL.Path, "/v1/images/edits") {
modelRequest.Model = common.GetStringIfEmpty(c.PostForm("model"), "gpt-image-1") //modelRequest.Model = common.GetStringIfEmpty(c.PostForm("model"), "gpt-image-1")
if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
modelRequest.Model = c.PostForm("model")
}
} }
if strings.HasPrefix(c.Request.URL.Path, "/v1/audio") { if strings.HasPrefix(c.Request.URL.Path, "/v1/audio") {
relayMode := relayconstant.RelayModeAudioSpeech relayMode := relayconstant.RelayModeAudioSpeech
......
...@@ -3,7 +3,6 @@ package ali ...@@ -3,7 +3,6 @@ package ali
import ( import (
"errors" "errors"
"fmt" "fmt"
"github.com/gin-gonic/gin"
"io" "io"
"net/http" "net/http"
"one-api/dto" "one-api/dto"
...@@ -14,6 +13,8 @@ import ( ...@@ -14,6 +13,8 @@ import (
"one-api/relay/constant" "one-api/relay/constant"
"one-api/types" "one-api/types"
"strings" "strings"
"github.com/gin-gonic/gin"
) )
type Adaptor struct { type Adaptor struct {
...@@ -44,6 +45,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { ...@@ -44,6 +45,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.ChannelBaseUrl) fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.ChannelBaseUrl)
case constant.RelayModeImagesGenerations: case constant.RelayModeImagesGenerations:
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.ChannelBaseUrl) fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.ChannelBaseUrl)
case constant.RelayModeImagesEdits:
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl)
case constant.RelayModeCompletions: case constant.RelayModeCompletions:
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/completions", info.ChannelBaseUrl) fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/completions", info.ChannelBaseUrl)
default: default:
...@@ -66,6 +69,9 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel ...@@ -66,6 +69,9 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
if info.RelayMode == constant.RelayModeImagesGenerations { if info.RelayMode == constant.RelayModeImagesGenerations {
req.Set("X-DashScope-Async", "enable") req.Set("X-DashScope-Async", "enable")
} }
if info.RelayMode == constant.RelayModeImagesEdits {
req.Set("Content-Type", "application/json")
}
return nil return nil
} }
...@@ -93,11 +99,30 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn ...@@ -93,11 +99,30 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
} }
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
if info.RelayMode == constant.RelayModeImagesGenerations {
aliRequest, err := oaiImage2Ali(request)
if err != nil {
return nil, fmt.Errorf("convert image request failed: %w", err)
}
return aliRequest, nil
} else if info.RelayMode == constant.RelayModeImagesEdits {
// ali image edit https://bailian.console.aliyun.com/?tab=api#/api/?type=model&url=2976416
// 如果用户使用表单,则需要解析表单数据
if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
aliRequest, err := oaiFormEdit2AliImageEdit(c, info, request)
if err != nil {
return nil, fmt.Errorf("convert image edit form request failed: %w", err)
}
return aliRequest, nil
} else {
aliRequest, err := oaiImage2Ali(request) aliRequest, err := oaiImage2Ali(request)
if err != nil { if err != nil {
return nil, fmt.Errorf("convert image request failed: %w", err) return nil, fmt.Errorf("convert image request failed: %w", err)
} }
return aliRequest, nil return aliRequest, nil
}
}
return nil, fmt.Errorf("unsupported image relay mode: %d", info.RelayMode)
} }
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
...@@ -134,6 +159,8 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom ...@@ -134,6 +159,8 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
switch info.RelayMode { switch info.RelayMode {
case constant.RelayModeImagesGenerations: case constant.RelayModeImagesGenerations:
err, usage = aliImageHandler(c, resp, info) err, usage = aliImageHandler(c, resp, info)
case constant.RelayModeImagesEdits:
err, usage = aliImageEditHandler(c, resp, info)
case constant.RelayModeRerank: case constant.RelayModeRerank:
err, usage = RerankHandler(c, resp, info) err, usage = RerankHandler(c, resp, info)
default: default:
......
...@@ -3,10 +3,15 @@ package ali ...@@ -3,10 +3,15 @@ package ali
import "one-api/dto" import "one-api/dto"
type AliMessage struct { type AliMessage struct {
Content string `json:"content"` Content any `json:"content"`
Role string `json:"role"` Role string `json:"role"`
} }
type AliMediaContent struct {
Image string `json:"image,omitempty"`
Text string `json:"text,omitempty"`
}
type AliInput struct { type AliInput struct {
Prompt string `json:"prompt,omitempty"` Prompt string `json:"prompt,omitempty"`
//History []AliMessage `json:"history,omitempty"` //History []AliMessage `json:"history,omitempty"`
...@@ -77,6 +82,7 @@ type AliOutput struct { ...@@ -77,6 +82,7 @@ type AliOutput struct {
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
Code string `json:"code,omitempty"` Code string `json:"code,omitempty"`
Results []TaskResult `json:"results,omitempty"` Results []TaskResult `json:"results,omitempty"`
Choices []map[string]any `json:"choices,omitempty"`
} }
type AliResponse struct { type AliResponse struct {
...@@ -101,8 +107,9 @@ type AliImageParameters struct { ...@@ -101,8 +107,9 @@ type AliImageParameters struct {
} }
type AliImageInput struct { type AliImageInput struct {
Prompt string `json:"prompt"` Prompt string `json:"prompt,omitempty"`
NegativePrompt string `json:"negative_prompt,omitempty"` NegativePrompt string `json:"negative_prompt,omitempty"`
Messages []AliMessage `json:"messages,omitempty"`
} }
type AliRerankParameters struct { type AliRerankParameters struct {
......
package ali package ali
import ( import (
"context"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"mime/multipart"
"net/http" "net/http"
"one-api/common" "one-api/common"
"one-api/dto" "one-api/dto"
...@@ -21,7 +24,7 @@ func oaiImage2Ali(request dto.ImageRequest) (*AliImageRequest, error) { ...@@ -21,7 +24,7 @@ func oaiImage2Ali(request dto.ImageRequest) (*AliImageRequest, error) {
var imageRequest AliImageRequest var imageRequest AliImageRequest
imageRequest.Model = request.Model imageRequest.Model = request.Model
imageRequest.ResponseFormat = request.ResponseFormat imageRequest.ResponseFormat = request.ResponseFormat
logger.LogJson(context.Background(), "oaiImage2Ali request extra", request.Extra)
if request.Extra != nil { if request.Extra != nil {
if val, ok := request.Extra["parameters"]; ok { if val, ok := request.Extra["parameters"]; ok {
err := common.Unmarshal(val, &imageRequest.Parameters) err := common.Unmarshal(val, &imageRequest.Parameters)
...@@ -54,6 +57,100 @@ func oaiImage2Ali(request dto.ImageRequest) (*AliImageRequest, error) { ...@@ -54,6 +57,100 @@ func oaiImage2Ali(request dto.ImageRequest) (*AliImageRequest, error) {
return &imageRequest, nil return &imageRequest, nil
} }
func oaiFormEdit2AliImageEdit(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (*AliImageRequest, error) {
var imageRequest AliImageRequest
imageRequest.Model = request.Model
imageRequest.ResponseFormat = request.ResponseFormat
mf := c.Request.MultipartForm
if mf == nil {
if _, err := c.MultipartForm(); err != nil {
return nil, fmt.Errorf("failed to parse image edit form request: %w", err)
}
mf = c.Request.MultipartForm
}
var imageFiles []*multipart.FileHeader
var exists bool
// First check for standard "image" field
if imageFiles, exists = mf.File["image"]; !exists || len(imageFiles) == 0 {
// If not found, check for "image[]" field
if imageFiles, exists = mf.File["image[]"]; !exists || len(imageFiles) == 0 {
// If still not found, iterate through all fields to find any that start with "image["
foundArrayImages := false
for fieldName, files := range mf.File {
if strings.HasPrefix(fieldName, "image[") && len(files) > 0 {
foundArrayImages = true
imageFiles = append(imageFiles, files...)
}
}
// If no image fields found at all
if !foundArrayImages && (len(imageFiles) == 0) {
return nil, errors.New("image is required")
}
}
}
if len(imageFiles) == 0 {
return nil, errors.New("image is required")
}
if len(imageFiles) > 1 {
return nil, errors.New("only one image is supported for qwen edit")
}
// 获取base64编码的图片
var imageBase64s []string
for _, file := range imageFiles {
image, err := file.Open()
if err != nil {
return nil, errors.New("failed to open image file")
}
// 读取文件内容
imageData, err := io.ReadAll(image)
if err != nil {
return nil, errors.New("failed to read image file")
}
// 获取MIME类型
mimeType := http.DetectContentType(imageData)
// 编码为base64
base64Data := base64.StdEncoding.EncodeToString(imageData)
// 构造data URL格式
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
imageBase64s = append(imageBase64s, dataURL)
image.Close()
}
//dto.MediaContent{}
mediaContents := make([]AliMediaContent, len(imageBase64s))
for i, b64 := range imageBase64s {
mediaContents[i] = AliMediaContent{
Image: b64,
}
}
mediaContents = append(mediaContents, AliMediaContent{
Text: request.Prompt,
})
imageRequest.Input = AliImageInput{
Messages: []AliMessage{
{
Role: "user",
Content: mediaContents,
},
},
}
imageRequest.Parameters = AliImageParameters{
Watermark: request.Watermark,
}
return &imageRequest, nil
}
func updateTask(info *relaycommon.RelayInfo, taskID string) (*AliResponse, error, []byte) { func updateTask(info *relaycommon.RelayInfo, taskID string) (*AliResponse, error, []byte) {
url := fmt.Sprintf("%s/api/v1/tasks/%s", info.ChannelBaseUrl, taskID) url := fmt.Sprintf("%s/api/v1/tasks/%s", info.ChannelBaseUrl, taskID)
...@@ -196,8 +293,47 @@ func aliImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rela ...@@ -196,8 +293,47 @@ func aliImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rela
if err != nil { if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody), nil return types.NewError(err, types.ErrorCodeBadResponseBody), nil
} }
c.Writer.Header().Set("Content-Type", "application/json") service.IOCopyBytesGracefully(c, resp, jsonResponse)
c.Writer.WriteHeader(resp.StatusCode) return nil, &dto.Usage{}
c.Writer.Write(jsonResponse) }
func aliImageEditHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) {
var aliResponse AliResponse
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil
}
service.CloseResponseBodyGracefully(resp)
err = common.Unmarshal(responseBody, &aliResponse)
if err != nil {
return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil
}
if aliResponse.Message != "" {
logger.LogError(c, "ali_task_failed: "+aliResponse.Message)
return types.NewError(errors.New(aliResponse.Message), types.ErrorCodeBadResponse), nil
}
var fullTextResponse dto.ImageResponse
if len(aliResponse.Output.Choices) > 0 {
fullTextResponse = dto.ImageResponse{
Created: info.StartTime.Unix(),
Data: []dto.ImageData{
{
Url: aliResponse.Output.Choices[0]["message"].(map[string]any)["content"].([]any)[0].(map[string]any)["image"].(string),
B64Json: "",
},
},
}
}
var mapResponse map[string]any
_ = common.Unmarshal(responseBody, &mapResponse)
fullTextResponse.Extra = mapResponse
jsonResponse, err := common.Marshal(fullTextResponse)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
}
service.IOCopyBytesGracefully(c, resp, jsonResponse)
return nil, &dto.Usage{} return nil, &dto.Usage{}
} }
...@@ -81,6 +81,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn ...@@ -81,6 +81,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if strings.HasSuffix(info.UpstreamModelName, "-search") { if strings.HasSuffix(info.UpstreamModelName, "-search") {
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-search") info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-search")
request.Model = info.UpstreamModelName request.Model = info.UpstreamModelName
if len(request.WebSearch) == 0 {
toMap := request.ToMap() toMap := request.ToMap()
toMap["web_search"] = map[string]any{ toMap["web_search"] = map[string]any{
"enable": true, "enable": true,
...@@ -91,10 +92,12 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn ...@@ -91,10 +92,12 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
return toMap, nil return toMap, nil
} }
return request, nil return request, nil
}
return request, nil
} }
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
return nil, nil return nil, errors.New("not implemented")
} }
func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
......
...@@ -68,9 +68,7 @@ func requestOpenAI2Ollama(c *gin.Context, request *dto.GeneralOpenAIRequest) (*O ...@@ -68,9 +68,7 @@ func requestOpenAI2Ollama(c *gin.Context, request *dto.GeneralOpenAIRequest) (*O
StreamOptions: request.StreamOptions, StreamOptions: request.StreamOptions,
Suffix: request.Suffix, Suffix: request.Suffix,
} }
if think, ok := request.Extra["think"]; ok { ollamaRequest.Think = request.Think
ollamaRequest.Think = think
}
return ollamaRequest, nil return ollamaRequest, nil
} }
......
...@@ -538,7 +538,13 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo ...@@ -538,7 +538,13 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo
// 转换模型推理力度后缀 // 转换模型推理力度后缀
effort, originModel := parseReasoningEffortFromModelSuffix(request.Model) effort, originModel := parseReasoningEffortFromModelSuffix(request.Model)
if effort != "" { if effort != "" {
if request.Reasoning == nil {
request.Reasoning = &dto.Reasoning{
Effort: effort,
}
} else {
request.Reasoning.Effort = effort request.Reasoning.Effort = effort
}
request.Model = originModel request.Model = originModel
} }
return request, nil return request, nil
......
...@@ -92,6 +92,8 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp ...@@ -92,6 +92,8 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
} }
} }
} }
} else {
logger.LogError(c, "failed to unmarshal stream response: "+err.Error())
} }
return true return true
}) })
...@@ -107,7 +109,7 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp ...@@ -107,7 +109,7 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
} }
if usage.PromptTokens == 0 && usage.CompletionTokens != 0 { if usage.PromptTokens == 0 && usage.CompletionTokens != 0 {
usage.PromptTokens = usage.CompletionTokens usage.PromptTokens = info.PromptTokens
} else { } else {
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
} }
......
...@@ -2,6 +2,7 @@ package volcengine ...@@ -2,6 +2,7 @@ package volcengine
import ( import (
"bytes" "bytes"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
...@@ -214,6 +215,12 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn ...@@ -214,6 +215,12 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if request == nil { if request == nil {
return nil, errors.New("request is nil") return nil, errors.New("request is nil")
} }
// 适配 方舟deepseek混合模型 的 thinking 后缀
if strings.HasSuffix(info.UpstreamModelName, "-thinking") && strings.HasPrefix(info.UpstreamModelName, "deepseek") {
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
request.Model = info.UpstreamModelName
request.THINKING = json.RawMessage(`{"type": "enabled"}`)
}
return request, nil return request, nil
} }
......
...@@ -313,7 +313,7 @@ func GenRelayInfoResponses(c *gin.Context, request *dto.OpenAIResponsesRequest) ...@@ -313,7 +313,7 @@ func GenRelayInfoResponses(c *gin.Context, request *dto.OpenAIResponsesRequest)
BuiltInTools: make(map[string]*BuildInToolInfo), BuiltInTools: make(map[string]*BuildInToolInfo),
} }
if len(request.Tools) > 0 { if len(request.Tools) > 0 {
for _, tool := range request.Tools { for _, tool := range request.GetToolsMap() {
toolType := common.Interface2String(tool["type"]) toolType := common.Interface2String(tool["type"])
info.ResponsesUsageInfo.BuiltInTools[toolType] = &BuildInToolInfo{ info.ResponsesUsageInfo.BuiltInTools[toolType] = &BuildInToolInfo{
ToolName: toolType, ToolName: toolType,
......
...@@ -130,7 +130,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types ...@@ -130,7 +130,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
jsonData, err := common.Marshal(convertedRequest) jsonData, err := common.Marshal(convertedRequest)
if err != nil { if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) return types.NewError(err, types.ErrorCodeJsonMarshalFailed, types.ErrOptionWithSkipRetry())
} }
// apply param override // apply param override
......
...@@ -132,6 +132,7 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq ...@@ -132,6 +132,7 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq
switch relayMode { switch relayMode {
case relayconstant.RelayModeImagesEdits: case relayconstant.RelayModeImagesEdits:
if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
_, err := c.MultipartForm() _, err := c.MultipartForm()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse image edit form request: %w", err) return nil, fmt.Errorf("failed to parse image edit form request: %w", err)
...@@ -156,6 +157,9 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq ...@@ -156,6 +157,9 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq
if watermark { if watermark {
imageRequest.Watermark = &watermark imageRequest.Watermark = &watermark
} }
break
}
fallthrough
default: default:
err := common.UnmarshalBodyReusable(c, imageRequest) err := common.UnmarshalBodyReusable(c, imageRequest)
if err != nil { if err != nil {
...@@ -163,7 +167,8 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq ...@@ -163,7 +167,8 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq
} }
if imageRequest.Model == "" { if imageRequest.Model == "" {
imageRequest.Model = "dall-e-3" //imageRequest.Model = "dall-e-3"
return nil, errors.New("model is required")
} }
if strings.Contains(imageRequest.Size, "×") { if strings.Contains(imageRequest.Size, "×") {
...@@ -194,9 +199,9 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq ...@@ -194,9 +199,9 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq
} }
} }
if imageRequest.Prompt == "" { //if imageRequest.Prompt == "" {
return nil, errors.New("prompt is required") // return nil, errors.New("prompt is required")
} //}
if imageRequest.N == 0 { if imageRequest.N == 0 {
imageRequest.N = 1 imageRequest.N = 1
......
...@@ -2,14 +2,13 @@ package relay ...@@ -2,14 +2,13 @@ package relay
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"one-api/common" "one-api/common"
"one-api/dto" "one-api/dto"
"one-api/logger"
relaycommon "one-api/relay/common" relaycommon "one-api/relay/common"
relayconstant "one-api/relay/constant"
"one-api/relay/helper" "one-api/relay/helper"
"one-api/service" "one-api/service"
"one-api/setting/model_setting" "one-api/setting/model_setting"
...@@ -56,10 +55,12 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type ...@@ -56,10 +55,12 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
if err != nil { if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed) return types.NewError(err, types.ErrorCodeConvertRequestFailed)
} }
if info.RelayMode == relayconstant.RelayModeImagesEdits {
switch convertedRequest.(type) {
case *bytes.Buffer:
requestBody = convertedRequest.(io.Reader) requestBody = convertedRequest.(io.Reader)
} else { default:
jsonData, err := json.Marshal(convertedRequest) jsonData, err := common.Marshal(convertedRequest)
if err != nil { if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
} }
...@@ -73,7 +74,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type ...@@ -73,7 +74,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
} }
if common.DebugEnabled { if common.DebugEnabled {
println(fmt.Sprintf("image request body: %s", string(jsonData))) logger.LogDebug(c, fmt.Sprintf("image request body: %s", string(jsonData)))
} }
requestBody = bytes.NewBuffer(jsonData) requestBody = bytes.NewBuffer(jsonData)
} }
......
...@@ -114,6 +114,7 @@ func SetApiRouter(router *gin.Engine) { ...@@ -114,6 +114,7 @@ func SetApiRouter(router *gin.Engine) {
channelRoute.GET("/models", controller.ChannelListModels) channelRoute.GET("/models", controller.ChannelListModels)
channelRoute.GET("/models_enabled", controller.EnabledListModels) channelRoute.GET("/models_enabled", controller.EnabledListModels)
channelRoute.GET("/:id", controller.GetChannel) channelRoute.GET("/:id", controller.GetChannel)
channelRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetChannelKey)
channelRoute.GET("/test", controller.TestAllChannels) channelRoute.GET("/test", controller.TestAllChannels)
channelRoute.GET("/test/:id", controller.TestChannel) channelRoute.GET("/test/:id", controller.TestChannel)
channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance) channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance)
......
...@@ -248,9 +248,10 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon ...@@ -248,9 +248,10 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
}, },
}) })
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &info.ClaudeConvertInfo.Index,
Type: "content_block_delta", Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{ Delta: &dto.ClaudeMediaMessage{
Type: "text", Type: "text_delta",
Text: common.GetPointer[string](openAIResponse.Choices[0].Delta.GetContentString()), Text: common.GetPointer[string](openAIResponse.Choices[0].Delta.GetContentString()),
}, },
}) })
......
...@@ -32,11 +32,12 @@ func GetWebSearchPricePerThousand(modelName string, contextSize string) float64 ...@@ -32,11 +32,12 @@ func GetWebSearchPricePerThousand(modelName string, contextSize string) float64
// 确定模型类型 // 确定模型类型
// https://platform.openai.com/docs/pricing Web search 价格按模型类型收费 // https://platform.openai.com/docs/pricing Web search 价格按模型类型收费
// 新版计费规则不再关联 search context size,故在const区域将各size的价格设为一致。 // 新版计费规则不再关联 search context size,故在const区域将各size的价格设为一致。
// gpt-4o and gpt-4.1 models (including mini models) 等模型更贵,o3, o4-mini, o3-pro, and deep research models 等模型更便宜 // gpt-5, gpt-5-mini, gpt-5-nano 和 o 系列模型价格为 10.00 美元/千次调用,产生额外 token 计入 input_tokens
// gpt-4o, gpt-4.1, gpt-4o-mini 和 gpt-4.1-mini 价格为 25.00 美元/千次调用,不产生额外 token
isNormalPriceModel := isNormalPriceModel :=
strings.HasPrefix(modelName, "o3") || strings.HasPrefix(modelName, "o3") ||
strings.HasPrefix(modelName, "o4") || strings.HasPrefix(modelName, "o4") ||
strings.Contains(modelName, "deep-research") strings.HasPrefix(modelName, "gpt-5")
var priceWebSearchPerThousandCalls float64 var priceWebSearchPerThousandCalls float64
if isNormalPriceModel { if isNormalPriceModel {
priceWebSearchPerThousandCalls = WebSearchPrice priceWebSearchPerThousandCalls = WebSearchPrice
......
...@@ -468,7 +468,13 @@ func GetCompletionRatio(name string) float64 { ...@@ -468,7 +468,13 @@ func GetCompletionRatio(name string) float64 {
func getHardcodedCompletionModelRatio(name string) (float64, bool) { func getHardcodedCompletionModelRatio(name string) (float64, bool) {
lowercaseName := strings.ToLower(name) lowercaseName := strings.ToLower(name)
if strings.HasPrefix(name, "gpt-4") && !strings.HasSuffix(name, "-all") && !strings.HasSuffix(name, "-gizmo-*") {
isReservedModel := strings.HasSuffix(name, "-all") || strings.HasSuffix(name, "-gizmo-*")
if isReservedModel {
return 2, false
}
if strings.HasPrefix(name, "gpt-") {
if strings.HasPrefix(name, "gpt-4o") { if strings.HasPrefix(name, "gpt-4o") {
if name == "gpt-4o-2024-05-13" { if name == "gpt-4o-2024-05-13" {
return 3, true return 3, true
...@@ -535,7 +541,7 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) { ...@@ -535,7 +541,7 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
if strings.HasPrefix(name, "gemini-2.5-flash-lite") { if strings.HasPrefix(name, "gemini-2.5-flash-lite") {
return 4, false return 4, false
} }
return 2.5 / 0.3, true return 2.5 / 0.3, false
} }
return 4, false return 4, false
} }
......
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Modal, Button, Input, Typography } from '@douyinfe/semi-ui';
/**
* 可复用的两步验证模态框组件
* @param {Object} props
* @param {boolean} props.visible - 是否显示模态框
* @param {string} props.code - 验证码值
* @param {boolean} props.loading - 是否正在验证
* @param {Function} props.onCodeChange - 验证码变化回调
* @param {Function} props.onVerify - 验证回调
* @param {Function} props.onCancel - 取消回调
* @param {string} props.title - 模态框标题
* @param {string} props.description - 验证描述文本
* @param {string} props.placeholder - 输入框占位文本
*/
const TwoFactorAuthModal = ({
visible,
code,
loading,
onCodeChange,
onVerify,
onCancel,
title,
description,
placeholder
}) => {
const { t } = useTranslation();
const handleKeyDown = (e) => {
if (e.key === 'Enter' && code && !loading) {
onVerify();
}
};
return (
<Modal
title={
<div className="flex items-center">
<div className="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center mr-3">
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
</svg>
</div>
{title || t('安全验证')}
</div>
}
visible={visible}
onCancel={onCancel}
footer={
<>
<Button onClick={onCancel}>
{t('取消')}
</Button>
<Button
type="primary"
loading={loading}
disabled={!code || loading}
onClick={onVerify}
>
{t('验证')}
</Button>
</>
}
width={500}
style={{ maxWidth: '90vw' }}
>
<div className="space-y-6">
{/* 安全提示 */}
<div className="bg-blue-50 dark:bg-blue-900 rounded-lg p-4">
<div className="flex items-start">
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
<div>
<Typography.Text strong className="text-blue-800 dark:text-blue-200">
{t('安全验证')}
</Typography.Text>
<Typography.Text className="block text-blue-700 dark:text-blue-300 text-sm mt-1">
{description || t('为了保护账户安全,请验证您的两步验证码。')}
</Typography.Text>
</div>
</div>
</div>
{/* 验证码输入 */}
<div>
<Typography.Text strong className="block mb-2">
{t('验证身份')}
</Typography.Text>
<Input
placeholder={placeholder || t('请输入认证器验证码或备用码')}
value={code}
onChange={onCodeChange}
size="large"
maxLength={8}
onKeyDown={handleKeyDown}
autoFocus
/>
<Typography.Text type="tertiary" size="small" className="mt-2 block">
{t('支持6位TOTP验证码或8位备用码')}
</Typography.Text>
</div>
</div>
</Modal>
);
};
export default TwoFactorAuthModal;
\ No newline at end of file
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Card, Button, Typography, Tag } from '@douyinfe/semi-ui';
import { copy, showSuccess } from '../../../helpers';
/**
* 解析密钥数据,支持多种格式
* @param {string} keyData - 密钥数据
* @param {Function} t - 翻译函数
* @returns {Array} 解析后的密钥数组
*/
const parseChannelKeys = (keyData, t) => {
if (!keyData) return [];
const trimmed = keyData.trim();
// 检查是否是JSON数组格式(如Vertex AI)
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed.map((item, index) => ({
id: index,
content: typeof item === 'string' ? item : JSON.stringify(item, null, 2),
type: typeof item === 'string' ? 'text' : 'json',
label: `${t('密钥')} ${index + 1}`
}));
}
} catch (e) {
// 如果解析失败,按普通文本处理
console.warn('Failed to parse JSON keys:', e);
}
}
// 检查是否是多行密钥(按换行符分割)
const lines = trimmed.split('\n').filter(line => line.trim());
if (lines.length > 1) {
return lines.map((line, index) => ({
id: index,
content: line.trim(),
type: 'text',
label: `${t('密钥')} ${index + 1}`
}));
}
// 单个密钥
return [{
id: 0,
content: trimmed,
type: trimmed.startsWith('{') ? 'json' : 'text',
label: t('密钥')
}];
};
/**
* 可复用的密钥显示组件
* @param {Object} props
* @param {string} props.keyData - 密钥数据
* @param {boolean} props.showSuccessIcon - 是否显示成功图标
* @param {string} props.successText - 成功文本
* @param {boolean} props.showWarning - 是否显示安全警告
* @param {string} props.warningText - 警告文本
*/
const ChannelKeyDisplay = ({
keyData,
showSuccessIcon = true,
successText,
showWarning = true,
warningText
}) => {
const { t } = useTranslation();
const parsedKeys = parseChannelKeys(keyData, t);
const isMultipleKeys = parsedKeys.length > 1;
const handleCopyAll = () => {
copy(keyData);
showSuccess(t('所有密钥已复制到剪贴板'));
};
const handleCopyKey = (content) => {
copy(content);
showSuccess(t('密钥已复制到剪贴板'));
};
return (
<div className="space-y-4">
{/* 成功状态 */}
{showSuccessIcon && (
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<Typography.Text strong className="text-green-700">
{successText || t('验证成功')}
</Typography.Text>
</div>
)}
{/* 密钥内容 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Typography.Text strong>
{isMultipleKeys ? t('渠道密钥列表') : t('渠道密钥')}
</Typography.Text>
{isMultipleKeys && (
<div className="flex items-center gap-2">
<Typography.Text type="tertiary" size="small">
{t('共 {{count}} 个密钥', { count: parsedKeys.length })}
</Typography.Text>
<Button
size="small"
type="primary"
theme="outline"
onClick={handleCopyAll}
>
{t('复制全部')}
</Button>
</div>
)}
</div>
<div className="space-y-3 max-h-80 overflow-auto">
{parsedKeys.map((keyItem) => (
<Card key={keyItem.id} className="!rounded-lg !border !border-gray-200 dark:!border-gray-700">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Typography.Text strong size="small" className="text-gray-700 dark:text-gray-300">
{keyItem.label}
</Typography.Text>
<div className="flex items-center gap-2">
{keyItem.type === 'json' && (
<Tag size="small" color="blue">{t('JSON')}</Tag>
)}
<Button
size="small"
type="primary"
theme="outline"
icon={
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" />
<path d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z" />
</svg>
}
onClick={() => handleCopyKey(keyItem.content)}
>
{t('复制')}
</Button>
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 max-h-40 overflow-auto">
<Typography.Text
code
className="text-xs font-mono break-all whitespace-pre-wrap text-gray-800 dark:text-gray-200"
>
{keyItem.content}
</Typography.Text>
</div>
{keyItem.type === 'json' && (
<Typography.Text type="tertiary" size="small" className="block">
{t('JSON格式密钥,请确保格式正确')}
</Typography.Text>
)}
</div>
</Card>
))}
</div>
{isMultipleKeys && (
<div className="bg-blue-50 dark:bg-blue-900 rounded-lg p-3">
<Typography.Text type="tertiary" size="small" className="text-blue-700 dark:text-blue-300">
<svg className="w-4 h-4 inline mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
{t('检测到多个密钥,您可以单独复制每个密钥,或点击复制全部获取完整内容。')}
</Typography.Text>
</div>
)}
</div>
{/* 安全警告 */}
{showWarning && (
<div className="bg-yellow-50 dark:bg-yellow-900 rounded-lg p-4">
<div className="flex items-start">
<svg className="w-5 h-5 text-yellow-600 dark:text-yellow-400 mt-0.5 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<div>
<Typography.Text strong className="text-yellow-800 dark:text-yellow-200">
{t('安全提醒')}
</Typography.Text>
<Typography.Text className="block text-yellow-700 dark:text-yellow-300 text-sm mt-1">
{warningText || t('请妥善保管密钥信息,不要泄露给他人。如有安全疑虑,请及时更换密钥。')}
</Typography.Text>
</div>
</div>
</div>
)}
</div>
);
};
export default ChannelKeyDisplay;
\ No newline at end of file
...@@ -45,10 +45,13 @@ import { ...@@ -45,10 +45,13 @@ import {
Row, Row,
Col, Col,
Highlight, Highlight,
Input,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { getChannelModels, copy, getChannelIcon, getModelCategories, selectFilter } from '../../../../helpers'; import { getChannelModels, copy, getChannelIcon, getModelCategories, selectFilter } from '../../../../helpers';
import ModelSelectModal from './ModelSelectModal'; import ModelSelectModal from './ModelSelectModal';
import JSONEditor from '../../../common/ui/JSONEditor'; import JSONEditor from '../../../common/ui/JSONEditor';
import TwoFactorAuthModal from '../../../common/modals/TwoFactorAuthModal';
import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
import { import {
IconSave, IconSave,
IconClose, IconClose,
...@@ -158,6 +161,44 @@ const EditChannelModal = (props) => { ...@@ -158,6 +161,44 @@ const EditChannelModal = (props) => {
const [channelSearchValue, setChannelSearchValue] = useState(''); const [channelSearchValue, setChannelSearchValue] = useState('');
const [useManualInput, setUseManualInput] = useState(false); // 是否使用手动输入模式 const [useManualInput, setUseManualInput] = useState(false); // 是否使用手动输入模式
const [keyMode, setKeyMode] = useState('append'); // 密钥模式:replace(覆盖)或 append(追加) const [keyMode, setKeyMode] = useState('append'); // 密钥模式:replace(覆盖)或 append(追加)
// 2FA验证查看密钥相关状态
const [twoFAState, setTwoFAState] = useState({
showModal: false,
code: '',
loading: false,
showKey: false,
keyData: ''
});
// 专门的2FA验证状态(用于TwoFactorAuthModal)
const [show2FAVerifyModal, setShow2FAVerifyModal] = useState(false);
const [verifyCode, setVerifyCode] = useState('');
const [verifyLoading, setVerifyLoading] = useState(false);
// 2FA状态更新辅助函数
const updateTwoFAState = (updates) => {
setTwoFAState(prev => ({ ...prev, ...updates }));
};
// 重置2FA状态
const resetTwoFAState = () => {
setTwoFAState({
showModal: false,
code: '',
loading: false,
showKey: false,
keyData: ''
});
};
// 重置2FA验证状态
const reset2FAVerifyState = () => {
setShow2FAVerifyModal(false);
setVerifyCode('');
setVerifyLoading(false);
};
// 渠道额外设置状态 // 渠道额外设置状态
const [channelSettings, setChannelSettings] = useState({ const [channelSettings, setChannelSettings] = useState({
force_format: false, force_format: false,
...@@ -500,6 +541,42 @@ const EditChannelModal = (props) => { ...@@ -500,6 +541,42 @@ const EditChannelModal = (props) => {
} }
}; };
// 使用TwoFactorAuthModal的验证函数
const handleVerify2FA = async () => {
if (!verifyCode) {
showError(t('请输入验证码或备用码'));
return;
}
setVerifyLoading(true);
try {
const res = await API.post(`/api/channel/${channelId}/key`, {
code: verifyCode
});
if (res.data.success) {
// 验证成功,显示密钥
updateTwoFAState({
showModal: true,
showKey: true,
keyData: res.data.data.key
});
reset2FAVerifyState();
showSuccess(t('验证成功'));
} else {
showError(res.data.message);
}
} catch (error) {
showError(t('获取密钥失败'));
} finally {
setVerifyLoading(false);
}
};
// 显示2FA验证模态框 - 使用TwoFactorAuthModal
const handleShow2FAModal = () => {
setShow2FAVerifyModal(true);
};
useEffect(() => { useEffect(() => {
const modelMap = new Map(); const modelMap = new Map();
...@@ -576,6 +653,13 @@ const EditChannelModal = (props) => { ...@@ -576,6 +653,13 @@ const EditChannelModal = (props) => {
// 重置手动输入模式状态 // 重置手动输入模式状态
setUseManualInput(false); setUseManualInput(false);
} else { } else {
// 统一的模态框关闭重置逻辑
resetModalState();
}
}, [props.visible, channelId]);
// 统一的模态框重置函数
const resetModalState = () => {
formApiRef.current?.reset(); formApiRef.current?.reset();
// 重置渠道设置状态 // 重置渠道设置状态
setChannelSettings({ setChannelSettings({
...@@ -594,8 +678,11 @@ const EditChannelModal = (props) => { ...@@ -594,8 +678,11 @@ const EditChannelModal = (props) => {
} }
// 重置本地输入,避免下次打开残留上一次的 JSON 字段值 // 重置本地输入,避免下次打开残留上一次的 JSON 字段值
setInputs(getInitValues()); setInputs(getInitValues());
} // 重置2FA状态
}, [props.visible, channelId]); resetTwoFAState();
// 重置2FA验证状态
reset2FAVerifyState();
};
const handleVertexUploadChange = ({ fileList }) => { const handleVertexUploadChange = ({ fileList }) => {
vertexErroredNames.current.clear(); vertexErroredNames.current.clear();
...@@ -1080,6 +1167,16 @@ const EditChannelModal = (props) => { ...@@ -1080,6 +1167,16 @@ const EditChannelModal = (props) => {
{t('追加模式:新密钥将添加到现有密钥列表的末尾')} {t('追加模式:新密钥将添加到现有密钥列表的末尾')}
</Text> </Text>
)} )}
{isEdit && (
<Button
size="small"
type="primary"
theme="outline"
onClick={handleShow2FAModal}
>
{t('查看密钥')}
</Button>
)}
{batchExtra} {batchExtra}
</div> </div>
} }
...@@ -1154,6 +1251,16 @@ const EditChannelModal = (props) => { ...@@ -1154,6 +1251,16 @@ const EditChannelModal = (props) => {
{t('追加模式:新密钥将添加到现有密钥列表的末尾')} {t('追加模式:新密钥将添加到现有密钥列表的末尾')}
</Text> </Text>
)} )}
{isEdit && (
<Button
size="small"
type="primary"
theme="outline"
onClick={handleShow2FAModal}
>
{t('查看密钥')}
</Button>
)}
{batchExtra} {batchExtra}
</div> </div>
} }
...@@ -1194,6 +1301,16 @@ const EditChannelModal = (props) => { ...@@ -1194,6 +1301,16 @@ const EditChannelModal = (props) => {
{t('追加模式:新密钥将添加到现有密钥列表的末尾')} {t('追加模式:新密钥将添加到现有密钥列表的末尾')}
</Text> </Text>
)} )}
{isEdit && (
<Button
size="small"
type="primary"
theme="outline"
onClick={handleShow2FAModal}
>
{t('查看密钥')}
</Button>
)}
{batchExtra} {batchExtra}
</div> </div>
} }
...@@ -1846,6 +1963,53 @@ const EditChannelModal = (props) => { ...@@ -1846,6 +1963,53 @@ const EditChannelModal = (props) => {
onVisibleChange={(visible) => setIsModalOpenurl(visible)} onVisibleChange={(visible) => setIsModalOpenurl(visible)}
/> />
</SideSheet> </SideSheet>
{/* 使用TwoFactorAuthModal组件进行2FA验证 */}
<TwoFactorAuthModal
visible={show2FAVerifyModal}
code={verifyCode}
loading={verifyLoading}
onCodeChange={setVerifyCode}
onVerify={handleVerify2FA}
onCancel={reset2FAVerifyState}
title={t('查看渠道密钥')}
description={t('为了保护账户安全,请验证您的两步验证码。')}
placeholder={t('请输入验证码或备用码')}
/>
{/* 使用ChannelKeyDisplay组件显示密钥 */}
<Modal
title={
<div className="flex items-center">
<div className="w-8 h-8 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mr-3">
<svg className="w-4 h-4 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
</svg>
</div>
{t('渠道密钥信息')}
</div>
}
visible={twoFAState.showModal && twoFAState.showKey}
onCancel={resetTwoFAState}
footer={
<Button
type="primary"
onClick={resetTwoFAState}
>
{t('完成')}
</Button>
}
width={700}
style={{ maxWidth: '90vw' }}
>
<ChannelKeyDisplay
keyData={twoFAState.keyData}
showSuccessIcon={true}
successText={t('密钥获取成功')}
showWarning={true}
warningText={t('请妥善保管密钥信息,不要泄露给他人。如有安全疑虑,请及时更换密钥。')}
/>
</Modal>
<ModelSelectModal <ModelSelectModal
visible={modelModalVisible} visible={modelModalVisible}
models={fetchedModels} models={fetchedModels}
......
...@@ -1997,5 +1997,25 @@ ...@@ -1997,5 +1997,25 @@
"深色": "Dark", "深色": "Dark",
"浅色": "Light", "浅色": "Light",
"点击复制模型名称": "Click to copy model name", "点击复制模型名称": "Click to copy model name",
"已复制:{{name}}": "Copied: {{name}}" "已复制:{{name}}": "Copied: {{name}}",
"所有密钥已复制到剪贴板": "All keys have been copied to the clipboard",
"密钥已复制到剪贴板": "Key copied to clipboard",
"验证成功": "Verification successful",
"渠道密钥列表": "Channel key list",
"渠道密钥": "Channel key",
"共 {{count}} 个密钥": "{{count}} keys in total",
"复制全部": "Copy all",
"JSON格式密钥,请确保格式正确": "JSON format key, please ensure the format is correct",
"检测到多个密钥,您可以单独复制每个密钥,或点击复制全部获取完整内容。": "Detected multiple keys, you can copy each key individually or click Copy All to get the complete content.",
"安全提醒": "Security reminder",
"请妥善保管密钥信息,不要泄露给他人。如有安全疑虑,请及时更换密钥。": "Keep key information secure, do not disclose to others. If there are security concerns, please change the key immediately.",
"安全验证": "Security verification",
"验证": "Verify",
"为了保护账户安全,请验证您的两步验证码。": "To protect account security, please verify your two-factor authentication code.",
"支持6位TOTP验证码或8位备用码": "Supports 6-digit TOTP verification code or 8-digit backup code",
"获取密钥失败": "Failed to get key",
"查看密钥": "View key",
"查看渠道密钥": "View channel key",
"渠道密钥信息": "Channel key information",
"密钥获取成功": "Key acquisition successful"
} }
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment