Browse Source

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

t0ng7u 4 months ago
parent
commit
034094c2d2

+ 3 - 5
common/copy.go

@@ -2,7 +2,8 @@ package common
 
 import (
 	"fmt"
-	"github.com/antlabs/pcopy"
+
+	"github.com/jinzhu/copier"
 )
 
 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")
 	}
 	var dst T
-	err := pcopy.Copy(&dst, src)
+	err := copier.CopyWithOption(&dst, src, copier.Option{DeepCopy: true, IgnoreEmpty: true})
 	if err != nil {
 		return nil, err
 	}
-	if &dst == nil {
-		return nil, fmt.Errorf("copy result cannot be nil")
-	}
 	return &dst, nil
 }

+ 22 - 0
common/json.go

@@ -20,3 +20,25 @@ func DecodeJson(reader *bytes.Reader, v any) error {
 func Marshal(v any) ([]byte, error) {
 	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"
+	}
+}

+ 79 - 0
controller/channel.go

@@ -380,6 +380,85 @@ func GetChannel(c *gin.Context) {
 	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 通用的渠道校验函数
 func validateChannel(channel *model.Channel, isAdd bool) error {
 	// 校验 channel settings

+ 3 - 3
dto/claude.go

@@ -488,14 +488,14 @@ func (c *ClaudeResponse) GetClaudeError() *types.ClaudeError {
 	case string:
 		// 处理简单字符串错误
 		return &types.ClaudeError{
-			Type:    "error",
+			Type:    "upstream_error",
 			Message: err,
 		}
 	default:
 		// 未知类型,尝试转换为字符串
 		return &types.ClaudeError{
-			Type:    "unknown_error",
-			Message: fmt.Sprintf("%v", err),
+			Type:    "unknown_upstream_error",
+			Message: fmt.Sprintf("unknown_error: %v", err),
 		}
 	}
 }

+ 64 - 0
dto/openai_image.go

@@ -2,7 +2,9 @@ package dto
 
 import (
 	"encoding/json"
+	"one-api/common"
 	"one-api/types"
+	"reflect"
 	"strings"
 
 	"github.com/gin-gonic/gin"
@@ -29,6 +31,68 @@ type ImageRequest struct {
 	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 {
 	var sizeRatio = 1.0
 	var qualityRatio = 1.0

+ 53 - 32
dto/openai_request.go

@@ -57,18 +57,24 @@ type GeneralOpenAIRequest struct {
 	Dimensions          int               `json:"dimensions,omitempty"`
 	Modalities          json.RawMessage   `json:"modalities,omitempty"`
 	Audio               json.RawMessage   `json:"audio,omitempty"`
-	EnableThinking      any               `json:"enable_thinking,omitempty"` // ali
-	THINKING            json.RawMessage   `json:"thinking,omitempty"`        // doubao,zhipu_v4
-	ExtraBody           json.RawMessage   `json:"extra_body,omitempty"`
-	SearchParameters    any               `json:"search_parameters,omitempty"` //xai
-	WebSearchOptions    *WebSearchOptions `json:"web_search_options,omitempty"`
+	// gemini
+	ExtraBody json.RawMessage `json:"extra_body,omitempty"`
+	//xai
+	SearchParameters json.RawMessage `json:"search_parameters,omitempty"`
+	// claude
+	WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"`
 	// OpenRouter Params
 	Usage     json.RawMessage `json:"usage,omitempty"`
 	Reasoning json.RawMessage `json:"reasoning,omitempty"`
 	// Ali Qwen Params
 	VlHighResolutionImages json.RawMessage `json:"vl_high_resolution_images,omitempty"`
-	// 用匿名参数接收额外参数,例如ollama的think参数在此接收
-	Extra map[string]json.RawMessage `json:"-"`
+	EnableThinking         any             `json:"enable_thinking,omitempty"`
+	// 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 {
@@ -760,27 +766,27 @@ type WebSearchOptions struct {
 
 // https://platform.openai.com/docs/api-reference/responses/create
 type OpenAIResponsesRequest struct {
-	Model              string           `json:"model"`
-	Input              any              `json:"input,omitempty"`
-	Include            json.RawMessage  `json:"include,omitempty"`
-	Instructions       json.RawMessage  `json:"instructions,omitempty"`
-	MaxOutputTokens    uint             `json:"max_output_tokens,omitempty"`
-	Metadata           json.RawMessage  `json:"metadata,omitempty"`
-	ParallelToolCalls  bool             `json:"parallel_tool_calls,omitempty"`
-	PreviousResponseID string           `json:"previous_response_id,omitempty"`
-	Reasoning          *Reasoning       `json:"reasoning,omitempty"`
-	ServiceTier        string           `json:"service_tier,omitempty"`
-	Store              bool             `json:"store,omitempty"`
-	Stream             bool             `json:"stream,omitempty"`
-	Temperature        float64          `json:"temperature,omitempty"`
-	Text               json.RawMessage  `json:"text,omitempty"`
-	ToolChoice         json.RawMessage  `json:"tool_choice,omitempty"`
-	Tools              []map[string]any `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map
-	TopP               float64          `json:"top_p,omitempty"`
-	Truncation         string           `json:"truncation,omitempty"`
-	User               string           `json:"user,omitempty"`
-	MaxToolCalls       uint             `json:"max_tool_calls,omitempty"`
-	Prompt             json.RawMessage  `json:"prompt,omitempty"`
+	Model              string          `json:"model"`
+	Input              json.RawMessage `json:"input,omitempty"`
+	Include            json.RawMessage `json:"include,omitempty"`
+	Instructions       json.RawMessage `json:"instructions,omitempty"`
+	MaxOutputTokens    uint            `json:"max_output_tokens,omitempty"`
+	Metadata           json.RawMessage `json:"metadata,omitempty"`
+	ParallelToolCalls  bool            `json:"parallel_tool_calls,omitempty"`
+	PreviousResponseID string          `json:"previous_response_id,omitempty"`
+	Reasoning          *Reasoning      `json:"reasoning,omitempty"`
+	ServiceTier        string          `json:"service_tier,omitempty"`
+	Store              bool            `json:"store,omitempty"`
+	Stream             bool            `json:"stream,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"`
+	Truncation         string          `json:"truncation,omitempty"`
+	User               string          `json:"user,omitempty"`
+	MaxToolCalls       uint            `json:"max_tool_calls,omitempty"`
+	Prompt             json.RawMessage `json:"prompt,omitempty"`
 }
 
 func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
@@ -832,8 +838,7 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
 	}
 
 	if len(r.Tools) > 0 {
-		toolStr, _ := common.Marshal(r.Tools)
-		texts = append(texts, string(toolStr))
+		texts = append(texts, string(r.Tools))
 	}
 
 	return &types.TokenCountMeta{
@@ -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 {
 	Effort  string `json:"effort,omitempty"`
 	Summary string `json:"summary,omitempty"`
@@ -879,13 +892,21 @@ func (r *OpenAIResponsesRequest) ParseInput() []MediaInput {
 	var inputs []MediaInput
 
 	// 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})
 		return inputs
 	}
 
 	// 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 {
 			// Already parsed MediaInput
 			if media, ok := itemAny.(MediaInput); ok {

+ 1 - 9
go.mod

@@ -23,6 +23,7 @@ require (
 	github.com/golang-jwt/jwt v3.2.2+incompatible
 	github.com/google/uuid v1.6.0
 	github.com/gorilla/websocket v1.5.0
+	github.com/jinzhu/copier v0.4.0
 	github.com/joho/godotenv v1.5.1
 	github.com/pkg/errors v0.9.1
 	github.com/pquerna/otp v1.5.0
@@ -44,11 +45,7 @@ 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/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/internal/configsources v1.4.2 // indirect
 	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 // indirect
@@ -73,8 +70,6 @@ require (
 	github.com/gorilla/context v1.1.1 // indirect
 	github.com/gorilla/securecookie v1.1.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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
 	github.com/jackc/pgx/v5 v5.7.1 // indirect
@@ -85,14 +80,11 @@ require (
 	github.com/klauspost/cpuid/v2 v2.2.9 // indirect
 	github.com/leodido/go-urn v1.4.0 // 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/reflectwalk v1.0.0 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
 	github.com/pelletier/go-toml/v2 v2.2.1 // 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/pretty v1.2.0 // indirect
 	github.com/tklauser/go-sysconf v0.3.12 // indirect

+ 2 - 45
go.sum

@@ -1,19 +1,11 @@
 github.com/Calcium-Ion/go-epay v0.0.4 h1:C96M7WfRLadcIVscWzwLiYs8etI1wrDmtFMuK2zP22A=
 github.com/Calcium-Ion/go-epay v0.0.4/go.mod h1:cxo/ZOg8ClvE3VAnCmEzbuyAZINSq7kFEN9oHj5WQ2U=
-github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
-github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
-github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g=
-github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
-github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA=
-github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM=
 github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
 github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
 github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+KcxaMk1lfrRnwCd1UUuOjJM/lri5eM1qMs=
 github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0/go.mod h1:4yg+jNTYlDEzBjhGS96v+zjyA3lfXlFd5CiTLIkPBLI=
 github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 h1:HblK3eJHq54yET63qPCTJnks3loDse5xRmmqHgHzwoI=
 github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6/go.mod h1:pbiaLIeYLUbgMY1kwEAdwO6UKD5ZNwdPGQlwokS9fe8=
-github.com/antlabs/pcopy v0.1.5 h1:5Fa1ExY9T6ar3ysAi4rzB5jiYg72Innm+/ESEIOSHvQ=
-github.com/antlabs/pcopy v0.1.5/go.mod h1:2FvdkPD3cFiM1CjGuXFCDQZqhKVcLI7IzeSJ2xUIOOI=
 github.com/aws/aws-sdk-go-v2 v1.37.2 h1:xkW1iMYawzcmYFYEV0UCMxc8gSsjCGEhBXQkdQywVbo=
 github.com/aws/aws-sdk-go-v2 v1.37.2/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg=
 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg=
@@ -110,7 +102,6 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
 github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
-github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
@@ -121,10 +112,6 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
 github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
 github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
 github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4=
-github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
-github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA=
-github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
 github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
 github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
 github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -133,6 +120,8 @@ github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
 github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
 github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
 github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
+github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
 github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
 github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
 github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
@@ -163,12 +152,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky
 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ=
-github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
-github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY=
-github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -201,19 +186,14 @@ github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA=
 github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
 github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
 github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
-github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
 github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
 github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
-github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
-github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
-github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -251,36 +231,25 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
 github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
-github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
 github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
 golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
 golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
 golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
 golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
 golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
 golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8=
 golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
 golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
 golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
 golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
 golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
 golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -289,29 +258,18 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
 golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
 golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
@@ -326,7 +284,6 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkep
 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

+ 12 - 0
middleware/disable-cache.go

@@ -0,0 +1,12 @@
+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()
+	}
+}

+ 5 - 2
middleware/distributor.go

@@ -185,7 +185,7 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
 			modelRequest.Model = modelName
 		}
 		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)
 	}
 	if err != nil {
@@ -208,7 +208,10 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
 	if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") {
 		modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "dall-e")
 	} 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") {
 		relayMode := relayconstant.RelayModeAudioSpeech

+ 32 - 5
relay/channel/ali/adaptor.go

@@ -3,7 +3,6 @@ package ali
 import (
 	"errors"
 	"fmt"
-	"github.com/gin-gonic/gin"
 	"io"
 	"net/http"
 	"one-api/dto"
@@ -14,6 +13,8 @@ import (
 	"one-api/relay/constant"
 	"one-api/types"
 	"strings"
+
+	"github.com/gin-gonic/gin"
 )
 
 type Adaptor struct {
@@ -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)
 		case constant.RelayModeImagesGenerations:
 			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:
 			fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/completions", info.ChannelBaseUrl)
 		default:
@@ -66,6 +69,9 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
 	if info.RelayMode == constant.RelayModeImagesGenerations {
 		req.Set("X-DashScope-Async", "enable")
 	}
+	if info.RelayMode == constant.RelayModeImagesEdits {
+		req.Set("Content-Type", "application/json")
+	}
 	return nil
 }
 
@@ -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) {
-	aliRequest, err := oaiImage2Ali(request)
-	if err != nil {
-		return nil, fmt.Errorf("convert image request failed: %w", err)
+	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)
+			if err != nil {
+				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) {
@@ -134,6 +159,8 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
 		switch info.RelayMode {
 		case constant.RelayModeImagesGenerations:
 			err, usage = aliImageHandler(c, resp, info)
+		case constant.RelayModeImagesEdits:
+			err, usage = aliImageEditHandler(c, resp, info)
 		case constant.RelayModeRerank:
 			err, usage = RerankHandler(c, resp, info)
 		default:

+ 17 - 10
relay/channel/ali/dto.go

@@ -3,10 +3,15 @@ package ali
 import "one-api/dto"
 
 type AliMessage struct {
-	Content string `json:"content"`
+	Content any    `json:"content"`
 	Role    string `json:"role"`
 }
 
+type AliMediaContent struct {
+	Image string `json:"image,omitempty"`
+	Text  string `json:"text,omitempty"`
+}
+
 type AliInput struct {
 	Prompt string `json:"prompt,omitempty"`
 	//History []AliMessage `json:"history,omitempty"`
@@ -70,13 +75,14 @@ type TaskResult struct {
 }
 
 type AliOutput struct {
-	TaskId       string       `json:"task_id,omitempty"`
-	TaskStatus   string       `json:"task_status,omitempty"`
-	Text         string       `json:"text"`
-	FinishReason string       `json:"finish_reason"`
-	Message      string       `json:"message,omitempty"`
-	Code         string       `json:"code,omitempty"`
-	Results      []TaskResult `json:"results,omitempty"`
+	TaskId       string           `json:"task_id,omitempty"`
+	TaskStatus   string           `json:"task_status,omitempty"`
+	Text         string           `json:"text"`
+	FinishReason string           `json:"finish_reason"`
+	Message      string           `json:"message,omitempty"`
+	Code         string           `json:"code,omitempty"`
+	Results      []TaskResult     `json:"results,omitempty"`
+	Choices      []map[string]any `json:"choices,omitempty"`
 }
 
 type AliResponse struct {
@@ -101,8 +107,9 @@ type AliImageParameters struct {
 }
 
 type AliImageInput struct {
-	Prompt         string `json:"prompt"`
-	NegativePrompt string `json:"negative_prompt,omitempty"`
+	Prompt         string       `json:"prompt,omitempty"`
+	NegativePrompt string       `json:"negative_prompt,omitempty"`
+	Messages       []AliMessage `json:"messages,omitempty"`
 }
 
 type AliRerankParameters struct {

+ 140 - 4
relay/channel/ali/image.go

@@ -1,9 +1,12 @@
 package ali
 
 import (
+	"context"
+	"encoding/base64"
 	"errors"
 	"fmt"
 	"io"
+	"mime/multipart"
 	"net/http"
 	"one-api/common"
 	"one-api/dto"
@@ -21,7 +24,7 @@ func oaiImage2Ali(request dto.ImageRequest) (*AliImageRequest, error) {
 	var imageRequest AliImageRequest
 	imageRequest.Model = request.Model
 	imageRequest.ResponseFormat = request.ResponseFormat
-
+	logger.LogJson(context.Background(), "oaiImage2Ali request extra", request.Extra)
 	if request.Extra != nil {
 		if val, ok := request.Extra["parameters"]; ok {
 			err := common.Unmarshal(val, &imageRequest.Parameters)
@@ -54,6 +57,100 @@ func oaiImage2Ali(request dto.ImageRequest) (*AliImageRequest, error) {
 	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) {
 	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
 	if err != nil {
 		return types.NewError(err, types.ErrorCodeBadResponseBody), nil
 	}
-	c.Writer.Header().Set("Content-Type", "application/json")
-	c.Writer.WriteHeader(resp.StatusCode)
-	c.Writer.Write(jsonResponse)
+	service.IOCopyBytesGracefully(c, resp, jsonResponse)
+	return nil, &dto.Usage{}
+}
+
+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{}
 }

+ 11 - 8
relay/channel/baidu_v2/adaptor.go

@@ -81,20 +81,23 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
 	if strings.HasSuffix(info.UpstreamModelName, "-search") {
 		info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-search")
 		request.Model = info.UpstreamModelName
-		toMap := request.ToMap()
-		toMap["web_search"] = map[string]any{
-			"enable":          true,
-			"enable_citation": true,
-			"enable_trace":    true,
-			"enable_status":   false,
+		if len(request.WebSearch) == 0 {
+			toMap := request.ToMap()
+			toMap["web_search"] = map[string]any{
+				"enable":          true,
+				"enable_citation": true,
+				"enable_trace":    true,
+				"enable_status":   false,
+			}
+			return toMap, nil
 		}
-		return toMap, nil
+		return request, nil
 	}
 	return request, nil
 }
 
 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) {

+ 1 - 3
relay/channel/ollama/relay-ollama.go

@@ -68,9 +68,7 @@ func requestOpenAI2Ollama(c *gin.Context, request *dto.GeneralOpenAIRequest) (*O
 		StreamOptions:    request.StreamOptions,
 		Suffix:           request.Suffix,
 	}
-	if think, ok := request.Extra["think"]; ok {
-		ollamaRequest.Think = think
-	}
+	ollamaRequest.Think = request.Think
 	return ollamaRequest, nil
 }
 

+ 7 - 1
relay/channel/openai/adaptor.go

@@ -538,7 +538,13 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo
 	//  转换模型推理力度后缀
 	effort, originModel := parseReasoningEffortFromModelSuffix(request.Model)
 	if effort != "" {
-		request.Reasoning.Effort = effort
+		if request.Reasoning == nil {
+			request.Reasoning = &dto.Reasoning{
+				Effort: effort,
+			}
+		} else {
+			request.Reasoning.Effort = effort
+		}
 		request.Model = originModel
 	}
 	return request, nil

+ 3 - 1
relay/channel/openai/relay_responses.go

@@ -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
 	})
@@ -107,7 +109,7 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
 	}
 
 	if usage.PromptTokens == 0 && usage.CompletionTokens != 0 {
-		usage.PromptTokens = usage.CompletionTokens
+		usage.PromptTokens = info.PromptTokens
 	} else {
 		usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
 	}

+ 7 - 0
relay/channel/volcengine/adaptor.go

@@ -2,6 +2,7 @@ package volcengine
 
 import (
 	"bytes"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
@@ -214,6 +215,12 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
 	if request == 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
 }
 

+ 1 - 1
relay/common/relay_info.go

@@ -313,7 +313,7 @@ func GenRelayInfoResponses(c *gin.Context, request *dto.OpenAIResponsesRequest)
 		BuiltInTools: make(map[string]*BuildInToolInfo),
 	}
 	if len(request.Tools) > 0 {
-		for _, tool := range request.Tools {
+		for _, tool := range request.GetToolsMap() {
 			toolType := common.Interface2String(tool["type"])
 			info.ResponsesUsageInfo.BuiltInTools[toolType] = &BuildInToolInfo{
 				ToolName:  toolType,

+ 1 - 1
relay/compatible_handler.go

@@ -130,7 +130,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
 
 		jsonData, err := common.Marshal(convertedRequest)
 		if err != nil {
-			return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
+			return types.NewError(err, types.ErrorCodeJsonMarshalFailed, types.ErrOptionWithSkipRetry())
 		}
 
 		// apply param override

+ 30 - 25
relay/helper/valid_request.go

@@ -132,30 +132,34 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq
 
 	switch relayMode {
 	case relayconstant.RelayModeImagesEdits:
-		_, err := c.MultipartForm()
-		if err != nil {
-			return nil, fmt.Errorf("failed to parse image edit form request: %w", err)
-		}
-		formData := c.Request.PostForm
-		imageRequest.Prompt = formData.Get("prompt")
-		imageRequest.Model = formData.Get("model")
-		imageRequest.N = uint(common.String2Int(formData.Get("n")))
-		imageRequest.Quality = formData.Get("quality")
-		imageRequest.Size = formData.Get("size")
-
-		if imageRequest.Model == "gpt-image-1" {
-			if imageRequest.Quality == "" {
-				imageRequest.Quality = "standard"
+		if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
+			_, err := c.MultipartForm()
+			if err != nil {
+				return nil, fmt.Errorf("failed to parse image edit form request: %w", err)
+			}
+			formData := c.Request.PostForm
+			imageRequest.Prompt = formData.Get("prompt")
+			imageRequest.Model = formData.Get("model")
+			imageRequest.N = uint(common.String2Int(formData.Get("n")))
+			imageRequest.Quality = formData.Get("quality")
+			imageRequest.Size = formData.Get("size")
+
+			if imageRequest.Model == "gpt-image-1" {
+				if imageRequest.Quality == "" {
+					imageRequest.Quality = "standard"
+				}
+			}
+			if imageRequest.N == 0 {
+				imageRequest.N = 1
 			}
-		}
-		if imageRequest.N == 0 {
-			imageRequest.N = 1
-		}
 
-		watermark := formData.Has("watermark")
-		if watermark {
-			imageRequest.Watermark = &watermark
+			watermark := formData.Has("watermark")
+			if watermark {
+				imageRequest.Watermark = &watermark
+			}
+			break
 		}
+		fallthrough
 	default:
 		err := common.UnmarshalBodyReusable(c, imageRequest)
 		if err != nil {
@@ -163,7 +167,8 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq
 		}
 
 		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, "×") {
@@ -194,9 +199,9 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq
 			}
 		}
 
-		if imageRequest.Prompt == "" {
-			return nil, errors.New("prompt is required")
-		}
+		//if imageRequest.Prompt == "" {
+		//	return nil, errors.New("prompt is required")
+		//}
 
 		if imageRequest.N == 0 {
 			imageRequest.N = 1

+ 7 - 6
relay/image_handler.go

@@ -2,14 +2,13 @@ package relay
 
 import (
 	"bytes"
-	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
 	"one-api/common"
 	"one-api/dto"
+	"one-api/logger"
 	relaycommon "one-api/relay/common"
-	relayconstant "one-api/relay/constant"
 	"one-api/relay/helper"
 	"one-api/service"
 	"one-api/setting/model_setting"
@@ -56,10 +55,12 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
 		if err != nil {
 			return types.NewError(err, types.ErrorCodeConvertRequestFailed)
 		}
-		if info.RelayMode == relayconstant.RelayModeImagesEdits {
+
+		switch convertedRequest.(type) {
+		case *bytes.Buffer:
 			requestBody = convertedRequest.(io.Reader)
-		} else {
-			jsonData, err := json.Marshal(convertedRequest)
+		default:
+			jsonData, err := common.Marshal(convertedRequest)
 			if err != nil {
 				return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
 			}
@@ -73,7 +74,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
 			}
 
 			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)
 		}

+ 1 - 0
router/api-router.go

@@ -114,6 +114,7 @@ func SetApiRouter(router *gin.Engine) {
 			channelRoute.GET("/models", controller.ChannelListModels)
 			channelRoute.GET("/models_enabled", controller.EnabledListModels)
 			channelRoute.GET("/:id", controller.GetChannel)
+			channelRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetChannelKey)
 			channelRoute.GET("/test", controller.TestAllChannels)
 			channelRoute.GET("/test/:id", controller.TestChannel)
 			channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance)

+ 3 - 2
service/convert.go

@@ -248,9 +248,10 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
 				},
 			})
 			claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
-				Type: "content_block_delta",
+				Index: &info.ClaudeConvertInfo.Index,
+				Type:  "content_block_delta",
 				Delta: &dto.ClaudeMediaMessage{
-					Type: "text",
+					Type: "text_delta",
 					Text: common.GetPointer[string](openAIResponse.Choices[0].Delta.GetContentString()),
 				},
 			})

+ 3 - 2
setting/operation_setting/tools.go

@@ -32,11 +32,12 @@ func GetWebSearchPricePerThousand(modelName string, contextSize string) float64
 	// 确定模型类型
 	// https://platform.openai.com/docs/pricing Web search 价格按模型类型收费
 	// 新版计费规则不再关联 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 :=
 		strings.HasPrefix(modelName, "o3") ||
 			strings.HasPrefix(modelName, "o4") ||
-			strings.Contains(modelName, "deep-research")
+			strings.HasPrefix(modelName, "gpt-5")
 	var priceWebSearchPerThousandCalls float64
 	if isNormalPriceModel {
 		priceWebSearchPerThousandCalls = WebSearchPrice

+ 54 - 48
setting/ratio_setting/model_ratio.go

@@ -52,52 +52,52 @@ var defaultModelRatio = map[string]float64{
 	"gpt-4o-realtime-preview-2024-12-17":      2.5,
 	"gpt-4o-mini-realtime-preview":            0.3,
 	"gpt-4o-mini-realtime-preview-2024-12-17": 0.3,
-	"gpt-4.1":                                 1.0,  // $2 / 1M tokens
-	"gpt-4.1-2025-04-14":                      1.0,  // $2 / 1M tokens
-	"gpt-4.1-mini":                            0.2,  // $0.4 / 1M tokens
-	"gpt-4.1-mini-2025-04-14":                 0.2,  // $0.4 / 1M tokens
-	"gpt-4.1-nano":                            0.05, // $0.1 / 1M tokens
-	"gpt-4.1-nano-2025-04-14":                 0.05, // $0.1 / 1M tokens
-	"gpt-image-1":                             2.5,  // $5 / 1M tokens
-	"o1":                                      7.5,  // $15 / 1M tokens
-	"o1-2024-12-17":                           7.5,  // $15 / 1M tokens
-	"o1-preview":                              7.5,  // $15 / 1M tokens
-	"o1-preview-2024-09-12":                   7.5,  // $15 / 1M tokens
-	"o1-mini":                                 0.55, // $1.1 / 1M tokens
-	"o1-mini-2024-09-12":                      0.55, // $1.1 / 1M tokens
-	"o1-pro":                                  75.0, // $150 / 1M tokens
-	"o1-pro-2025-03-19":                       75.0, // $150 / 1M tokens
-	"o3-mini":                                 0.55,
-	"o3-mini-2025-01-31":                      0.55,
-	"o3-mini-high":                            0.55,
-	"o3-mini-2025-01-31-high":                 0.55,
-	"o3-mini-low":                             0.55,
-	"o3-mini-2025-01-31-low":                  0.55,
-	"o3-mini-medium":                          0.55,
-	"o3-mini-2025-01-31-medium":               0.55,
-	"o3":                                      1.0,  // $2 / 1M tokens
-	"o3-2025-04-16":                           1.0,  // $2 / 1M tokens
-	"o3-pro":                                  10.0, // $20 / 1M tokens
-	"o3-pro-2025-06-10":                       10.0, // $20 / 1M tokens
-	"o3-deep-research":                        5.0,  // $10 / 1M tokens
-	"o3-deep-research-2025-06-26":             5.0,  // $10 / 1M tokens
-	"o4-mini":                                 0.55, // $1.1 / 1M tokens
-	"o4-mini-2025-04-16":                      0.55, // $1.1 / 1M tokens
-	"o4-mini-deep-research":                   1.0,  // $2 / 1M tokens
-	"o4-mini-deep-research-2025-06-26":        1.0,  // $2 / 1M tokens
-	"gpt-4o-mini":                             0.075,
-	"gpt-4o-mini-2024-07-18":                  0.075,
-	"gpt-4-turbo":                             5, // $0.01 / 1K tokens
-	"gpt-4-turbo-2024-04-09":                  5, // $0.01 / 1K tokens
-	"gpt-4.5-preview":                         37.5,
-	"gpt-4.5-preview-2025-02-27":              37.5,
-	"gpt-5":                                   0.625,
-	"gpt-5-2025-08-07":                        0.625,
-	"gpt-5-chat-latest":                       0.625,
-	"gpt-5-mini":                              0.125,
-	"gpt-5-mini-2025-08-07":                   0.125,
-	"gpt-5-nano":                              0.025,
-	"gpt-5-nano-2025-08-07":                   0.025,
+	"gpt-4.1":                          1.0,  // $2 / 1M tokens
+	"gpt-4.1-2025-04-14":               1.0,  // $2 / 1M tokens
+	"gpt-4.1-mini":                     0.2,  // $0.4 / 1M tokens
+	"gpt-4.1-mini-2025-04-14":          0.2,  // $0.4 / 1M tokens
+	"gpt-4.1-nano":                     0.05, // $0.1 / 1M tokens
+	"gpt-4.1-nano-2025-04-14":          0.05, // $0.1 / 1M tokens
+	"gpt-image-1":                      2.5,  // $5 / 1M tokens
+	"o1":                               7.5,  // $15 / 1M tokens
+	"o1-2024-12-17":                    7.5,  // $15 / 1M tokens
+	"o1-preview":                       7.5,  // $15 / 1M tokens
+	"o1-preview-2024-09-12":            7.5,  // $15 / 1M tokens
+	"o1-mini":                          0.55, // $1.1 / 1M tokens
+	"o1-mini-2024-09-12":               0.55, // $1.1 / 1M tokens
+	"o1-pro":                           75.0, // $150 / 1M tokens
+	"o1-pro-2025-03-19":                75.0, // $150 / 1M tokens
+	"o3-mini":                          0.55,
+	"o3-mini-2025-01-31":               0.55,
+	"o3-mini-high":                     0.55,
+	"o3-mini-2025-01-31-high":          0.55,
+	"o3-mini-low":                      0.55,
+	"o3-mini-2025-01-31-low":           0.55,
+	"o3-mini-medium":                   0.55,
+	"o3-mini-2025-01-31-medium":        0.55,
+	"o3":                               1.0,  // $2 / 1M tokens
+	"o3-2025-04-16":                    1.0,  // $2 / 1M tokens
+	"o3-pro":                           10.0, // $20 / 1M tokens
+	"o3-pro-2025-06-10":                10.0, // $20 / 1M tokens
+	"o3-deep-research":                 5.0,  // $10 / 1M tokens
+	"o3-deep-research-2025-06-26":      5.0,  // $10 / 1M tokens
+	"o4-mini":                          0.55, // $1.1 / 1M tokens
+	"o4-mini-2025-04-16":               0.55, // $1.1 / 1M tokens
+	"o4-mini-deep-research":            1.0,  // $2 / 1M tokens
+	"o4-mini-deep-research-2025-06-26": 1.0,  // $2 / 1M tokens
+	"gpt-4o-mini":                      0.075,
+	"gpt-4o-mini-2024-07-18":           0.075,
+	"gpt-4-turbo":                      5, // $0.01 / 1K tokens
+	"gpt-4-turbo-2024-04-09":           5, // $0.01 / 1K tokens
+	"gpt-4.5-preview":                  37.5,
+	"gpt-4.5-preview-2025-02-27":       37.5,
+	"gpt-5":                            0.625,
+	"gpt-5-2025-08-07":                 0.625,
+	"gpt-5-chat-latest":                0.625,
+	"gpt-5-mini":                       0.125,
+	"gpt-5-mini-2025-08-07":            0.125,
+	"gpt-5-nano":                       0.025,
+	"gpt-5-nano-2025-08-07":            0.025,
 	//"gpt-3.5-turbo-0301":           0.75, //deprecated
 	"gpt-3.5-turbo":          0.25,
 	"gpt-3.5-turbo-0613":     0.75,
@@ -468,7 +468,13 @@ func GetCompletionRatio(name string) float64 {
 
 func getHardcodedCompletionModelRatio(name string) (float64, bool) {
 	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 name == "gpt-4o-2024-05-13" {
 				return 3, true
@@ -535,7 +541,7 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
 			if strings.HasPrefix(name, "gemini-2.5-flash-lite") {
 				return 4, false
 			}
-			return 2.5 / 0.3, true
+			return 2.5 / 0.3, false
 		}
 		return 4, false
 	}

+ 129 - 0
web/src/components/common/modals/TwoFactorAuthModal.jsx

@@ -0,0 +1,129 @@
+/*
+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 [email protected]
+*/
+
+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;

+ 224 - 0
web/src/components/common/ui/ChannelKeyDisplay.jsx

@@ -0,0 +1,224 @@
+/*
+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 [email protected]
+*/
+
+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;

+ 182 - 18
web/src/components/table/channels/modals/EditChannelModal.jsx

@@ -45,10 +45,13 @@ import {
   Row,
   Col,
   Highlight,
+  Input,
 } from '@douyinfe/semi-ui';
 import { getChannelModels, copy, getChannelIcon, getModelCategories, selectFilter } from '../../../../helpers';
 import ModelSelectModal from './ModelSelectModal';
 import JSONEditor from '../../../common/ui/JSONEditor';
+import TwoFactorAuthModal from '../../../common/modals/TwoFactorAuthModal';
+import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
 import {
   IconSave,
   IconClose,
@@ -158,6 +161,44 @@ const EditChannelModal = (props) => {
   const [channelSearchValue, setChannelSearchValue] = useState('');
   const [useManualInput, setUseManualInput] = useState(false); // 是否使用手动输入模式
   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({
     force_format: false,
@@ -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(() => {
     const modelMap = new Map();
 
@@ -576,27 +653,37 @@ const EditChannelModal = (props) => {
       // 重置手动输入模式状态
       setUseManualInput(false);
     } else {
-      formApiRef.current?.reset();
-      // 重置渠道设置状态
-      setChannelSettings({
-        force_format: false,
-        thinking_to_content: false,
-        proxy: '',
-        pass_through_body_enabled: false,
-        system_prompt: '',
-        system_prompt_override: false,
-      });
-      // 重置密钥模式状态
-      setKeyMode('append');
-      // 清空表单中的key_mode字段
-      if (formApiRef.current) {
-        formApiRef.current.setValue('key_mode', undefined);
-      }
-      // 重置本地输入,避免下次打开残留上一次的 JSON 字段值
-      setInputs(getInitValues());
+      // 统一的模态框关闭重置逻辑
+      resetModalState();
     }
   }, [props.visible, channelId]);
 
+  // 统一的模态框重置函数
+  const resetModalState = () => {
+    formApiRef.current?.reset();
+    // 重置渠道设置状态
+    setChannelSettings({
+      force_format: false,
+      thinking_to_content: false,
+      proxy: '',
+      pass_through_body_enabled: false,
+      system_prompt: '',
+      system_prompt_override: false,
+    });
+    // 重置密钥模式状态
+    setKeyMode('append');
+    // 清空表单中的key_mode字段
+    if (formApiRef.current) {
+      formApiRef.current.setValue('key_mode', undefined);
+    }
+    // 重置本地输入,避免下次打开残留上一次的 JSON 字段值
+    setInputs(getInitValues());
+    // 重置2FA状态
+    resetTwoFAState();
+    // 重置2FA验证状态
+    reset2FAVerifyState();
+  };
+
   const handleVertexUploadChange = ({ fileList }) => {
     vertexErroredNames.current.clear();
     (async () => {
@@ -1080,6 +1167,16 @@ const EditChannelModal = (props) => {
                                 {t('追加模式:新密钥将添加到现有密钥列表的末尾')}
                               </Text>
                             )}
+                            {isEdit && (
+                              <Button
+                                size="small"
+                                type="primary"
+                                theme="outline"
+                                onClick={handleShow2FAModal}
+                              >
+                                {t('查看密钥')}
+                              </Button>
+                            )}
                             {batchExtra}
                           </div>
                         }
@@ -1154,6 +1251,16 @@ const EditChannelModal = (props) => {
                                       {t('追加模式:新密钥将添加到现有密钥列表的末尾')}
                                     </Text>
                                   )}
+                                  {isEdit && (
+                                    <Button
+                                      size="small"
+                                      type="primary"
+                                      theme="outline"
+                                      onClick={handleShow2FAModal}
+                                    >
+                                      {t('查看密钥')}
+                                    </Button>
+                                  )}
                                   {batchExtra}
                                 </div>
                               }
@@ -1194,6 +1301,16 @@ const EditChannelModal = (props) => {
                                   {t('追加模式:新密钥将添加到现有密钥列表的末尾')}
                                 </Text>
                               )}
+                              {isEdit && (
+                                <Button
+                                  size="small"
+                                  type="primary"
+                                  theme="outline"
+                                  onClick={handleShow2FAModal}
+                                >
+                                  {t('查看密钥')}
+                                </Button>
+                              )}
                               {batchExtra}
                             </div>
                           }
@@ -1846,6 +1963,53 @@ const EditChannelModal = (props) => {
           onVisibleChange={(visible) => setIsModalOpenurl(visible)}
         />
       </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
         visible={modelModalVisible}
         models={fetchedModels}

+ 21 - 1
web/src/i18n/locales/en.json

@@ -1997,5 +1997,25 @@
   "深色": "Dark",
   "浅色": "Light",
   "点击复制模型名称": "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"
 }