From da98972ddabcd7a1476e81a3bb511a23169c14f2 Mon Sep 17 00:00:00 2001
From: wzxjohn
Date: Fri, 16 May 2025 16:44:47 +0800
Subject: [PATCH 001/165] feat: support UMAMI analytics
---
main.go | 18 ++++++++++++++++++
web/index.html | 1 +
2 files changed, 19 insertions(+)
diff --git a/main.go b/main.go
index 95c6820d7..e9f55b093 100644
--- a/main.go
+++ b/main.go
@@ -1,6 +1,7 @@
package main
import (
+ "bytes"
"embed"
"fmt"
"log"
@@ -15,6 +16,7 @@ import (
"one-api/setting/operation_setting"
"os"
"strconv"
+ "strings"
"github.com/bytedance/gopkg/util/gopool"
"github.com/gin-contrib/sessions"
@@ -161,6 +163,22 @@ func main() {
})
server.Use(sessions.Sessions("session", store))
+ analyticsInjectBuilder := &strings.Builder{}
+ if os.Getenv("UMAMI_WEBSITE_ID") != "" {
+ umamiSiteID := os.Getenv("UMAMI_WEBSITE_ID")
+ umamiScriptURL := os.Getenv("UMAMI_SCRIPT_URL")
+ if umamiScriptURL == "" {
+ umamiScriptURL = "https://analytics.umami.is/script.js"
+ }
+ analyticsInjectBuilder.WriteString("")
+ }
+ analyticsInject := analyticsInjectBuilder.String()
+ indexPage = bytes.ReplaceAll(indexPage, []byte("\n"), []byte(analyticsInject))
+
router.SetRouter(server, buildFS, indexPage)
var port = os.Getenv("PORT")
if port == "" {
diff --git a/web/index.html b/web/index.html
index 1e75f3d74..c6ce7b841 100644
--- a/web/index.html
+++ b/web/index.html
@@ -10,6 +10,7 @@
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用"
/>
New API
+
From ef0780c0968d78fa8971f3864e886edd3a53bc94 Mon Sep 17 00:00:00 2001
From: feitianbubu
Date: Sun, 10 Aug 2025 16:34:53 +0800
Subject: [PATCH 002/165] feat: if video cannot play open in a new tab
---
.../table/task-logs/modals/ContentModal.jsx | 114 +++++++++++++++++-
1 file changed, 110 insertions(+), 4 deletions(-)
diff --git a/web/src/components/table/task-logs/modals/ContentModal.jsx b/web/src/components/table/task-logs/modals/ContentModal.jsx
index a6f16c98c..fd17c206f 100644
--- a/web/src/components/table/task-logs/modals/ContentModal.jsx
+++ b/web/src/components/table/task-logs/modals/ContentModal.jsx
@@ -17,8 +17,11 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import React from 'react';
-import { Modal } from '@douyinfe/semi-ui';
+import React, { useState, useEffect } from 'react';
+import { Modal, Button, Typography, Spin } from '@douyinfe/semi-ui';
+import { IconExternalOpen, IconCopy } from '@douyinfe/semi-icons';
+
+const { Text } = Typography;
const ContentModal = ({
isModalOpen,
@@ -26,17 +29,120 @@ const ContentModal = ({
modalContent,
isVideo,
}) => {
+ const [videoError, setVideoError] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+
+ useEffect(() => {
+ if (isModalOpen && isVideo) {
+ setVideoError(false);
+ setIsLoading(true);
+ }
+ }, [isModalOpen, isVideo]);
+
+ const handleVideoError = () => {
+ setVideoError(true);
+ setIsLoading(false);
+ };
+
+ const handleVideoLoaded = () => {
+ setIsLoading(false);
+ };
+
+ const handleCopyUrl = () => {
+ navigator.clipboard.writeText(modalContent);
+ };
+
+ const handleOpenInNewTab = () => {
+ window.open(modalContent, '_blank');
+ };
+
+ const renderVideoContent = () => {
+ if (videoError) {
+ return (
+
+
+ 视频无法在当前浏览器中播放,这可能是由于:
+
+
+ • 视频服务商的跨域限制
+
+
+ • 需要特定的请求头或认证
+
+
+ • 防盗链保护机制
+
+
+
+ }
+ onClick={handleOpenInNewTab}
+ style={{ marginRight: '8px' }}
+ >
+ 在新标签页中打开
+
+ }
+ onClick={handleCopyUrl}
+ >
+ 复制链接
+
+
+
+
+
+ {modalContent}
+
+
+
+ );
+ }
+
+ return (
+
+ {isLoading && (
+
+
+
+ )}
+
+ );
+ };
+
return (
setIsModalOpen(false)}
onCancel={() => setIsModalOpen(false)}
closable={null}
- bodyStyle={{ height: '400px', overflow: 'auto' }}
+ bodyStyle={{
+ height: isVideo ? '450px' : '400px',
+ overflow: 'auto',
+ padding: isVideo && videoError ? '0' : '24px'
+ }}
width={800}
>
{isVideo ? (
-
+ renderVideoContent()
) : (
{modalContent}
)}
From 81e29aaa3db696a180077f3960d04a23ecde0157 Mon Sep 17 00:00:00 2001
From: Sh1n3zZ
Date: Tue, 26 Aug 2025 08:29:26 +0800
Subject: [PATCH 003/165] feat: vertex veo (#1450)
---
common/database.go | 2 +-
controller/setup.go | 2 +-
controller/task_video.go | 42 ++-
main.go | 2 +-
middleware/distributor.go | 2 +-
relay/channel/task/vertex/adaptor.go | 344 ++++++++++++++++++++++++
relay/channel/vertex/adaptor.go | 1 +
relay/channel/vertex/relay-vertex.go | 5 +-
relay/channel/vertex/service_account.go | 47 +++-
relay/relay_adaptor.go | 6 +-
relay/relay_task.go | 96 ++++++-
11 files changed, 534 insertions(+), 15 deletions(-)
create mode 100644 relay/channel/task/vertex/adaptor.go
diff --git a/common/database.go b/common/database.go
index 71dbd94d5..38a54d5e6 100644
--- a/common/database.go
+++ b/common/database.go
@@ -12,4 +12,4 @@ var LogSqlType = DatabaseTypeSQLite // Default to SQLite for logging SQL queries
var UsingMySQL = false
var UsingClickHouse = false
-var SQLitePath = "one-api.db?_busy_timeout=30000"
+var SQLitePath = "one-api.db?_busy_timeout=30000"
\ No newline at end of file
diff --git a/controller/setup.go b/controller/setup.go
index 8943a1a02..44a7b3a73 100644
--- a/controller/setup.go
+++ b/controller/setup.go
@@ -178,4 +178,4 @@ func boolToString(b bool) string {
return "true"
}
return "false"
-}
+}
\ No newline at end of file
diff --git a/controller/task_video.go b/controller/task_video.go
index ffb6728ba..73d5c39b1 100644
--- a/controller/task_video.go
+++ b/controller/task_video.go
@@ -94,7 +94,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
} else if taskResult, err = adaptor.ParseTaskResult(responseBody); err != nil {
return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err)
} else {
- task.Data = responseBody
+ task.Data = redactVideoResponseBody(responseBody)
}
now := time.Now().Unix()
@@ -113,11 +113,13 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
task.StartTime = now
}
case model.TaskStatusSuccess:
- task.Progress = "100%"
+ task.Progress = "100%"
if task.FinishTime == 0 {
task.FinishTime = now
}
- task.FailReason = taskResult.Url
+ if !(len(taskResult.Url) > 5 && taskResult.Url[:5] == "data:") {
+ task.FailReason = taskResult.Url
+ }
case model.TaskStatusFailure:
task.Status = model.TaskStatusFailure
task.Progress = "100%"
@@ -146,3 +148,37 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
return nil
}
+
+func redactVideoResponseBody(body []byte) []byte {
+ var m map[string]any
+ if err := json.Unmarshal(body, &m); err != nil {
+ return body
+ }
+ resp, _ := m["response"].(map[string]any)
+ if resp != nil {
+ delete(resp, "bytesBase64Encoded")
+ if v, ok := resp["video"].(string); ok {
+ resp["video"] = truncateBase64(v)
+ }
+ if vs, ok := resp["videos"].([]any); ok {
+ for i := range vs {
+ if vm, ok := vs[i].(map[string]any); ok {
+ delete(vm, "bytesBase64Encoded")
+ }
+ }
+ }
+ }
+ b, err := json.Marshal(m)
+ if err != nil {
+ return body
+ }
+ return b
+}
+
+func truncateBase64(s string) string {
+ const maxKeep = 256
+ if len(s) <= maxKeep {
+ return s
+ }
+ return s[:maxKeep] + "..."
+}
diff --git a/main.go b/main.go
index 2dfddaccf..91311b867 100644
--- a/main.go
+++ b/main.go
@@ -208,4 +208,4 @@ func InitResources() error {
return err
}
return nil
-}
+}
\ No newline at end of file
diff --git a/middleware/distributor.go b/middleware/distributor.go
index 1e6df872d..7fefeda49 100644
--- a/middleware/distributor.go
+++ b/middleware/distributor.go
@@ -166,9 +166,9 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
c.Set("platform", string(constant.TaskPlatformSuno))
c.Set("relay_mode", relayMode)
} else if strings.Contains(c.Request.URL.Path, "/v1/video/generations") {
- err = common.UnmarshalBodyReusable(c, &modelRequest)
relayMode := relayconstant.RelayModeUnknown
if c.Request.Method == http.MethodPost {
+ err = common.UnmarshalBodyReusable(c, &modelRequest)
relayMode = relayconstant.RelayModeVideoSubmit
} else if c.Request.Method == http.MethodGet {
relayMode = relayconstant.RelayModeVideoFetchByID
diff --git a/relay/channel/task/vertex/adaptor.go b/relay/channel/task/vertex/adaptor.go
new file mode 100644
index 000000000..d2ab826d0
--- /dev/null
+++ b/relay/channel/task/vertex/adaptor.go
@@ -0,0 +1,344 @@
+package vertex
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "regexp"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+
+ "one-api/common"
+ "one-api/constant"
+ "one-api/dto"
+ "one-api/relay/channel"
+ vertexcore "one-api/relay/channel/vertex"
+ relaycommon "one-api/relay/common"
+ "one-api/service"
+)
+
+type requestPayload struct {
+ Instances []map[string]any `json:"instances"`
+ Parameters map[string]any `json:"parameters,omitempty"`
+}
+
+type submitResponse struct {
+ Name string `json:"name"`
+}
+
+type operationVideo struct {
+ MimeType string `json:"mimeType"`
+ BytesBase64Encoded string `json:"bytesBase64Encoded"`
+ Encoding string `json:"encoding"`
+}
+
+type operationResponse struct {
+ Name string `json:"name"`
+ Done bool `json:"done"`
+ Response struct {
+ Type string `json:"@type"`
+ RaiMediaFilteredCount int `json:"raiMediaFilteredCount"`
+ Videos []operationVideo `json:"videos"`
+ BytesBase64Encoded string `json:"bytesBase64Encoded"`
+ Encoding string `json:"encoding"`
+ Video string `json:"video"`
+ } `json:"response"`
+ Error struct {
+ Message string `json:"message"`
+ } `json:"error"`
+}
+
+type TaskAdaptor struct{}
+
+func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {}
+
+func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) (taskErr *dto.TaskError) {
+ info.Action = constant.TaskActionTextGenerate
+
+ req := relaycommon.TaskSubmitReq{}
+ if err := common.UnmarshalBodyReusable(c, &req); err != nil {
+ return service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest)
+ }
+ if strings.TrimSpace(req.Prompt) == "" {
+ return service.TaskErrorWrapperLocal(fmt.Errorf("prompt is required"), "invalid_request", http.StatusBadRequest)
+ }
+ c.Set("task_request", req)
+ return nil
+}
+
+func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) {
+ adc := &vertexcore.Credentials{}
+ if err := json.Unmarshal([]byte(info.ApiKey), adc); err != nil {
+ return "", fmt.Errorf("failed to decode credentials: %w", err)
+ }
+ modelName := info.OriginModelName
+ if v, ok := getRequestModelFromContext(info); ok {
+ modelName = v
+ }
+ if modelName == "" {
+ modelName = "veo-3.0-generate-001"
+ }
+
+ region := vertexcore.GetModelRegion(info.ApiVersion, modelName)
+ if strings.TrimSpace(region) == "" {
+ region = "global"
+ }
+ if region == "global" {
+ return fmt.Sprintf(
+ "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:predictLongRunning",
+ adc.ProjectID,
+ modelName,
+ ), nil
+ }
+ return fmt.Sprintf(
+ "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predictLongRunning",
+ region,
+ adc.ProjectID,
+ region,
+ modelName,
+ ), nil
+}
+
+func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error {
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+
+ adc := &vertexcore.Credentials{}
+ if err := json.Unmarshal([]byte(info.ApiKey), adc); err != nil {
+ return fmt.Errorf("failed to decode credentials: %w", err)
+ }
+
+ token, err := vertexcore.AcquireAccessToken(*adc, info.ChannelSetting.Proxy)
+ if err != nil {
+ return fmt.Errorf("failed to acquire access token: %w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("x-goog-user-project", adc.ProjectID)
+ return nil
+}
+
+func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.TaskRelayInfo) (io.Reader, error) {
+ v, ok := c.Get("task_request")
+ if !ok {
+ return nil, fmt.Errorf("request not found in context")
+ }
+ req := v.(relaycommon.TaskSubmitReq)
+
+ body := requestPayload{
+ Instances: []map[string]any{{"prompt": req.Prompt}},
+ Parameters: map[string]any{},
+ }
+ if req.Metadata != nil {
+ if v, ok := req.Metadata["storageUri"]; ok {
+ body.Parameters["storageUri"] = v
+ }
+ if v, ok := req.Metadata["sampleCount"]; ok {
+ body.Parameters["sampleCount"] = v
+ }
+ }
+ if _, ok := body.Parameters["sampleCount"]; !ok {
+ body.Parameters["sampleCount"] = 1
+ }
+
+ data, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ return bytes.NewReader(data), nil
+}
+
+func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) {
+ return channel.DoTaskApiRequest(a, c, info, requestBody)
+}
+
+func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, _ *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
+ responseBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", nil, service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
+ }
+ _ = resp.Body.Close()
+
+ var s submitResponse
+ if err := json.Unmarshal(responseBody, &s); err != nil {
+ return "", nil, service.TaskErrorWrapper(err, "unmarshal_response_failed", http.StatusInternalServerError)
+ }
+ if strings.TrimSpace(s.Name) == "" {
+ return "", nil, service.TaskErrorWrapper(fmt.Errorf("missing operation name"), "invalid_response", http.StatusInternalServerError)
+ }
+ localID := encodeLocalTaskID(s.Name)
+ c.JSON(http.StatusOK, gin.H{"task_id": localID})
+ return localID, responseBody, nil
+}
+
+func (a *TaskAdaptor) GetModelList() []string { return []string{"veo-3.0-generate-001"} }
+func (a *TaskAdaptor) GetChannelName() string { return "vertex" }
+
+func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) {
+ taskID, ok := body["task_id"].(string)
+ if !ok {
+ return nil, fmt.Errorf("invalid task_id")
+ }
+ upstreamName, err := decodeLocalTaskID(taskID)
+ if err != nil {
+ return nil, fmt.Errorf("decode task_id failed: %w", err)
+ }
+ region := extractRegionFromOperationName(upstreamName)
+ if region == "" {
+ region = "us-central1"
+ }
+ project := extractProjectFromOperationName(upstreamName)
+ model := extractModelFromOperationName(upstreamName)
+ if project == "" || model == "" {
+ return nil, fmt.Errorf("cannot extract project/model from operation name")
+ }
+ var url string
+ if region == "global" {
+ url = fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:fetchPredictOperation", project, model)
+ } else {
+ url = fmt.Sprintf("https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:fetchPredictOperation", region, project, region, model)
+ }
+ payload := map[string]string{"operationName": upstreamName}
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return nil, err
+ }
+ adc := &vertexcore.Credentials{}
+ if err := json.Unmarshal([]byte(key), adc); err != nil {
+ return nil, fmt.Errorf("failed to decode credentials: %w", err)
+ }
+ token, err := vertexcore.AcquireAccessToken(*adc, "")
+ if err != nil {
+ return nil, fmt.Errorf("failed to acquire access token: %w", err)
+ }
+ req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("x-goog-user-project", adc.ProjectID)
+ return service.GetHttpClient().Do(req)
+}
+
+func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
+ var op operationResponse
+ if err := json.Unmarshal(respBody, &op); err != nil {
+ return nil, fmt.Errorf("unmarshal operation response failed: %w", err)
+ }
+ ti := &relaycommon.TaskInfo{}
+ if op.Error.Message != "" {
+ ti.Status = "FAILURE"
+ ti.Reason = op.Error.Message
+ ti.Progress = "100%"
+ return ti, nil
+ }
+ if !op.Done {
+ ti.Status = "IN_PROGRESS"
+ ti.Progress = "50%"
+ return ti, nil
+ }
+ ti.Status = "SUCCESS"
+ ti.Progress = "100%"
+ if len(op.Response.Videos) > 0 {
+ v0 := op.Response.Videos[0]
+ if v0.BytesBase64Encoded != "" {
+ mime := strings.TrimSpace(v0.MimeType)
+ if mime == "" {
+ enc := strings.TrimSpace(v0.Encoding)
+ if enc == "" {
+ enc = "mp4"
+ }
+ if strings.Contains(enc, "/") {
+ mime = enc
+ } else {
+ mime = "video/" + enc
+ }
+ }
+ ti.Url = "data:" + mime + ";base64," + v0.BytesBase64Encoded
+ return ti, nil
+ }
+ }
+ if op.Response.BytesBase64Encoded != "" {
+ enc := strings.TrimSpace(op.Response.Encoding)
+ if enc == "" {
+ enc = "mp4"
+ }
+ mime := enc
+ if !strings.Contains(enc, "/") {
+ mime = "video/" + enc
+ }
+ ti.Url = "data:" + mime + ";base64," + op.Response.BytesBase64Encoded
+ return ti, nil
+ }
+ if op.Response.Video != "" { // some variants use `video` as base64
+ enc := strings.TrimSpace(op.Response.Encoding)
+ if enc == "" {
+ enc = "mp4"
+ }
+ mime := enc
+ if !strings.Contains(enc, "/") {
+ mime = "video/" + enc
+ }
+ ti.Url = "data:" + mime + ";base64," + op.Response.Video
+ return ti, nil
+ }
+ return ti, nil
+}
+
+func getRequestModelFromContext(info *relaycommon.TaskRelayInfo) (string, bool) {
+ return info.OriginModelName, info.OriginModelName != ""
+}
+
+func encodeLocalTaskID(name string) string {
+ return base64.RawURLEncoding.EncodeToString([]byte(name))
+}
+
+func decodeLocalTaskID(local string) (string, error) {
+ b, err := base64.RawURLEncoding.DecodeString(local)
+ if err != nil {
+ return "", err
+ }
+ return string(b), nil
+}
+
+var regionRe = regexp.MustCompile(`locations/([a-z0-9-]+)/`)
+
+func extractRegionFromOperationName(name string) string {
+ m := regionRe.FindStringSubmatch(name)
+ if len(m) == 2 {
+ return m[1]
+ }
+ return ""
+}
+
+var modelRe = regexp.MustCompile(`models/([^/]+)/operations/`)
+
+func extractModelFromOperationName(name string) string {
+ m := modelRe.FindStringSubmatch(name)
+ if len(m) == 2 {
+ return m[1]
+ }
+ idx := strings.Index(name, "models/")
+ if idx >= 0 {
+ s := name[idx+len("models/"):]
+ if p := strings.Index(s, "/operations/"); p > 0 {
+ return s[:p]
+ }
+ }
+ return ""
+}
+
+var projectRe = regexp.MustCompile(`projects/([^/]+)/locations/`)
+
+func extractProjectFromOperationName(name string) string {
+ m := projectRe.FindStringSubmatch(name)
+ if len(m) == 2 {
+ return m[1]
+ }
+ return ""
+}
diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go
index 0b6b26743..d15592bf8 100644
--- a/relay/channel/vertex/adaptor.go
+++ b/relay/channel/vertex/adaptor.go
@@ -174,6 +174,7 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
return err
}
req.Set("Authorization", "Bearer "+accessToken)
+ req.Set("x-goog-user-project", a.AccountCredentials.ProjectID)
return nil
}
diff --git a/relay/channel/vertex/relay-vertex.go b/relay/channel/vertex/relay-vertex.go
index 5ed876654..f0b84906a 100644
--- a/relay/channel/vertex/relay-vertex.go
+++ b/relay/channel/vertex/relay-vertex.go
@@ -12,7 +12,10 @@ func GetModelRegion(other string, localModelName string) string {
if m[localModelName] != nil {
return m[localModelName].(string)
} else {
- return m["default"].(string)
+ if v, ok := m["default"]; ok {
+ return v.(string)
+ }
+ return "global"
}
}
return other
diff --git a/relay/channel/vertex/service_account.go b/relay/channel/vertex/service_account.go
index 9a4650d98..f90d5454d 100644
--- a/relay/channel/vertex/service_account.go
+++ b/relay/channel/vertex/service_account.go
@@ -6,14 +6,15 @@ import (
"encoding/json"
"encoding/pem"
"errors"
- "github.com/bytedance/gopkg/cache/asynccache"
- "github.com/golang-jwt/jwt"
"net/http"
"net/url"
relaycommon "one-api/relay/common"
"one-api/service"
"strings"
+ "github.com/bytedance/gopkg/cache/asynccache"
+ "github.com/golang-jwt/jwt"
+
"fmt"
"time"
)
@@ -137,3 +138,45 @@ func exchangeJwtForAccessToken(signedJWT string, info *relaycommon.RelayInfo) (s
return "", fmt.Errorf("failed to get access token: %v", result)
}
+
+func AcquireAccessToken(creds Credentials, proxy string) (string, error) {
+ signedJWT, err := createSignedJWT(creds.ClientEmail, creds.PrivateKey)
+ if err != nil {
+ return "", fmt.Errorf("failed to create signed JWT: %w", err)
+ }
+ return exchangeJwtForAccessTokenWithProxy(signedJWT, proxy)
+}
+
+func exchangeJwtForAccessTokenWithProxy(signedJWT string, proxy string) (string, error) {
+ authURL := "https://www.googleapis.com/oauth2/v4/token"
+ data := url.Values{}
+ data.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
+ data.Set("assertion", signedJWT)
+
+ var client *http.Client
+ var err error
+ if proxy != "" {
+ client, err = service.NewProxyHttpClient(proxy)
+ if err != nil {
+ return "", fmt.Errorf("new proxy http client failed: %w", err)
+ }
+ } else {
+ client = service.GetHttpClient()
+ }
+
+ resp, err := client.PostForm(authURL, data)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ var result map[string]interface{}
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return "", err
+ }
+
+ if accessToken, ok := result["access_token"].(string); ok {
+ return accessToken, nil
+ }
+ return "", fmt.Errorf("failed to get access token: %v", result)
+}
diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go
index 1ee85986c..0c271210b 100644
--- a/relay/relay_adaptor.go
+++ b/relay/relay_adaptor.go
@@ -1,7 +1,6 @@
package relay
import (
- "github.com/gin-gonic/gin"
"one-api/constant"
"one-api/relay/channel"
"one-api/relay/channel/ali"
@@ -28,6 +27,7 @@ import (
taskjimeng "one-api/relay/channel/task/jimeng"
"one-api/relay/channel/task/kling"
"one-api/relay/channel/task/suno"
+ taskvertex "one-api/relay/channel/task/vertex"
taskVidu "one-api/relay/channel/task/vidu"
"one-api/relay/channel/tencent"
"one-api/relay/channel/vertex"
@@ -37,6 +37,8 @@ import (
"one-api/relay/channel/zhipu"
"one-api/relay/channel/zhipu_4v"
"strconv"
+
+ "github.com/gin-gonic/gin"
)
func GetAdaptor(apiType int) channel.Adaptor {
@@ -126,6 +128,8 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor {
return &kling.TaskAdaptor{}
case constant.ChannelTypeJimeng:
return &taskjimeng.TaskAdaptor{}
+ case constant.ChannelTypeVertexAi:
+ return &taskvertex.TaskAdaptor{}
case constant.ChannelTypeVidu:
return &taskVidu.TaskAdaptor{}
}
diff --git a/relay/relay_task.go b/relay/relay_task.go
index 95b8083b3..6faec176d 100644
--- a/relay/relay_task.go
+++ b/relay/relay_task.go
@@ -15,6 +15,8 @@ import (
relayconstant "one-api/relay/constant"
"one-api/service"
"one-api/setting/ratio_setting"
+ "strconv"
+ "strings"
"github.com/gin-gonic/gin"
)
@@ -32,6 +34,7 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
if err != nil {
return service.TaskErrorWrapper(err, "gen_relay_info_failed", http.StatusInternalServerError)
}
+ relayInfo.InitChannelMeta(c)
adaptor := GetTaskAdaptor(platform)
if adaptor == nil {
@@ -197,6 +200,9 @@ func RelayTaskFetch(c *gin.Context, relayMode int) (taskResp *dto.TaskError) {
if taskErr != nil {
return taskErr
}
+ if len(respBody) == 0 {
+ respBody = []byte("{\"code\":\"success\",\"data\":null}")
+ }
c.Writer.Header().Set("Content-Type", "application/json")
_, err := io.Copy(c.Writer, bytes.NewBuffer(respBody))
@@ -276,10 +282,92 @@ func videoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *d
return
}
- respBody, err = json.Marshal(dto.TaskResponse[any]{
- Code: "success",
- Data: TaskModel2Dto(originTask),
- })
+ func() {
+ channelModel, err2 := model.GetChannelById(originTask.ChannelId, true)
+ if err2 != nil {
+ return
+ }
+ if channelModel.Type != constant.ChannelTypeVertexAi {
+ return
+ }
+ baseURL := constant.ChannelBaseURLs[channelModel.Type]
+ if channelModel.GetBaseURL() != "" {
+ baseURL = channelModel.GetBaseURL()
+ }
+ adaptor := GetTaskAdaptor(constant.TaskPlatform(strconv.Itoa(channelModel.Type)))
+ if adaptor == nil {
+ return
+ }
+ resp, err2 := adaptor.FetchTask(baseURL, channelModel.Key, map[string]any{
+ "task_id": originTask.TaskID,
+ "action": originTask.Action,
+ })
+ if err2 != nil || resp == nil {
+ return
+ }
+ defer resp.Body.Close()
+ body, err2 := io.ReadAll(resp.Body)
+ if err2 != nil {
+ return
+ }
+ ti, err2 := adaptor.ParseTaskResult(body)
+ if err2 == nil && ti != nil {
+ if ti.Status != "" {
+ originTask.Status = model.TaskStatus(ti.Status)
+ }
+ if ti.Progress != "" {
+ originTask.Progress = ti.Progress
+ }
+ if ti.Url != "" {
+ originTask.FailReason = ti.Url
+ }
+ _ = originTask.Update()
+ var raw map[string]any
+ _ = json.Unmarshal(body, &raw)
+ format := "mp4"
+ if respObj, ok := raw["response"].(map[string]any); ok {
+ if vids, ok := respObj["videos"].([]any); ok && len(vids) > 0 {
+ if v0, ok := vids[0].(map[string]any); ok {
+ if mt, ok := v0["mimeType"].(string); ok && mt != "" {
+ if strings.Contains(mt, "mp4") {
+ format = "mp4"
+ } else {
+ format = mt
+ }
+ }
+ }
+ }
+ }
+ status := "processing"
+ switch originTask.Status {
+ case model.TaskStatusSuccess:
+ status = "succeeded"
+ case model.TaskStatusFailure:
+ status = "failed"
+ case model.TaskStatusQueued, model.TaskStatusSubmitted:
+ status = "queued"
+ }
+ out := map[string]any{
+ "error": nil,
+ "format": format,
+ "metadata": nil,
+ "status": status,
+ "task_id": originTask.TaskID,
+ "url": originTask.FailReason,
+ }
+ respBody, _ = json.Marshal(dto.TaskResponse[any]{
+ Code: "success",
+ Data: out,
+ })
+ }
+ }()
+
+ if len(respBody) == 0 {
+ respBody, err = json.Marshal(dto.TaskResponse[any]{
+ Code: "success",
+ Data: TaskModel2Dto(originTask),
+ })
+ }
return
}
From da5aace109691923797ff43b347a20aaaa733979 Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Sat, 30 Aug 2025 23:28:09 +0800
Subject: [PATCH 004/165] =?UTF-8?q?feat:=20=E5=9B=BE=E5=83=8F=E5=80=8D?=
=?UTF-8?q?=E7=8E=87=EF=BC=8C=E9=9F=B3=E9=A2=91=E5=80=8D=E7=8E=87=E5=92=8C?=
=?UTF-8?q?=E9=9F=B3=E9=A2=91=E8=A1=A5=E5=85=A8=E5=80=8D=E7=8E=87=E9=85=8D?=
=?UTF-8?q?=E7=BD=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
controller/option.go | 27 ++++
model/option.go | 9 ++
relay/helper/price.go | 6 +
setting/ratio_setting/model_ratio.go | 133 +++++++++++++++---
types/price_data.go | 4 +-
web/src/components/settings/RatioSetting.jsx | 8 +-
web/src/i18n/locales/en.json | 12 +-
.../Setting/Ratio/ModelRatioSettings.jsx | 69 +++++++++
8 files changed, 244 insertions(+), 24 deletions(-)
diff --git a/controller/option.go b/controller/option.go
index decdb0d40..fb54d20a0 100644
--- a/controller/option.go
+++ b/controller/option.go
@@ -112,6 +112,33 @@ func UpdateOption(c *gin.Context) {
})
return
}
+ case "ImageRatio":
+ err = ratio_setting.UpdateImageRatioByJSONString(option.Value)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "图片倍率设置失败: " + err.Error(),
+ })
+ return
+ }
+ case "AudioRatio":
+ err = ratio_setting.UpdateAudioRatioByJSONString(option.Value)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "音频倍率设置失败: " + err.Error(),
+ })
+ return
+ }
+ case "AudioCompletionRatio":
+ err = ratio_setting.UpdateAudioCompletionRatioByJSONString(option.Value)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "音频补全倍率设置失败: " + err.Error(),
+ })
+ return
+ }
case "ModelRequestRateLimitGroup":
err = setting.CheckModelRequestRateLimitGroup(option.Value)
if err != nil {
diff --git a/model/option.go b/model/option.go
index 2121710ce..e589b46ed 100644
--- a/model/option.go
+++ b/model/option.go
@@ -111,6 +111,9 @@ func InitOptionMap() {
common.OptionMap["GroupGroupRatio"] = ratio_setting.GroupGroupRatio2JSONString()
common.OptionMap["UserUsableGroups"] = setting.UserUsableGroups2JSONString()
common.OptionMap["CompletionRatio"] = ratio_setting.CompletionRatio2JSONString()
+ common.OptionMap["ImageRatio"] = ratio_setting.ImageRatio2JSONString()
+ common.OptionMap["AudioRatio"] = ratio_setting.AudioRatio2JSONString()
+ common.OptionMap["AudioCompletionRatio"] = ratio_setting.AudioCompletionRatio2JSONString()
common.OptionMap["TopUpLink"] = common.TopUpLink
//common.OptionMap["ChatLink"] = common.ChatLink
//common.OptionMap["ChatLink2"] = common.ChatLink2
@@ -396,6 +399,12 @@ func updateOptionMap(key string, value string) (err error) {
err = ratio_setting.UpdateModelPriceByJSONString(value)
case "CacheRatio":
err = ratio_setting.UpdateCacheRatioByJSONString(value)
+ case "ImageRatio":
+ err = ratio_setting.UpdateImageRatioByJSONString(value)
+ case "AudioRatio":
+ err = ratio_setting.UpdateAudioRatioByJSONString(value)
+ case "AudioCompletionRatio":
+ err = ratio_setting.UpdateAudioCompletionRatioByJSONString(value)
case "TopUpLink":
common.TopUpLink = value
//case "ChatLink":
diff --git a/relay/helper/price.go b/relay/helper/price.go
index fdc5b66d8..c23c068b3 100644
--- a/relay/helper/price.go
+++ b/relay/helper/price.go
@@ -52,6 +52,8 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
var cacheRatio float64
var imageRatio float64
var cacheCreationRatio float64
+ var audioRatio float64
+ var audioCompletionRatio float64
if !usePrice {
preConsumedTokens := common.Max(promptTokens, common.PreConsumedQuota)
if meta.MaxTokens != 0 {
@@ -73,6 +75,8 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
cacheRatio, _ = ratio_setting.GetCacheRatio(info.OriginModelName)
cacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(info.OriginModelName)
imageRatio, _ = ratio_setting.GetImageRatio(info.OriginModelName)
+ audioRatio = ratio_setting.GetAudioRatio(info.OriginModelName)
+ audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(info.OriginModelName)
ratio := modelRatio * groupRatioInfo.GroupRatio
preConsumedQuota = int(float64(preConsumedTokens) * ratio)
} else {
@@ -90,6 +94,8 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
UsePrice: usePrice,
CacheRatio: cacheRatio,
ImageRatio: imageRatio,
+ AudioRatio: audioRatio,
+ AudioCompletionRatio: audioCompletionRatio,
CacheCreationRatio: cacheCreationRatio,
ShouldPreConsumedQuota: preConsumedQuota,
}
diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go
index f06cd71ef..99952daf1 100644
--- a/setting/ratio_setting/model_ratio.go
+++ b/setting/ratio_setting/model_ratio.go
@@ -278,6 +278,18 @@ var defaultModelPrice = map[string]float64{
"mj_upload": 0.05,
}
+var defaultAudioRatio = map[string]float64{
+ "gpt-4o-audio-preview": 16,
+ "gpt-4o-mini-audio-preview": 66.67,
+ "gpt-4o-realtime-preview": 8,
+ "gpt-4o-mini-realtime-preview": 16.67,
+}
+
+var defaultAudioCompletionRatio = map[string]float64{
+ "gpt-4o-realtime": 2,
+ "gpt-4o-mini-realtime": 2,
+}
+
var (
modelPriceMap map[string]float64 = nil
modelPriceMapMutex = sync.RWMutex{}
@@ -326,6 +338,15 @@ func InitRatioSettings() {
imageRatioMap = defaultImageRatio
imageRatioMapMutex.Unlock()
+ // initialize audioRatioMap
+ audioRatioMapMutex.Lock()
+ audioRatioMap = defaultAudioRatio
+ audioRatioMapMutex.Unlock()
+
+ // initialize audioCompletionRatioMap
+ audioCompletionRatioMapMutex.Lock()
+ audioCompletionRatioMap = defaultAudioCompletionRatio
+ audioCompletionRatioMapMutex.Unlock()
}
func GetModelPriceMap() map[string]float64 {
@@ -417,6 +438,18 @@ func GetDefaultModelRatioMap() map[string]float64 {
return defaultModelRatio
}
+func GetDefaultImageRatioMap() map[string]float64 {
+ return defaultImageRatio
+}
+
+func GetDefaultAudioRatioMap() map[string]float64 {
+ return defaultAudioRatio
+}
+
+func GetDefaultAudioCompletionRatioMap() map[string]float64 {
+ return defaultAudioCompletionRatio
+}
+
func GetCompletionRatioMap() map[string]float64 {
CompletionRatioMutex.RLock()
defer CompletionRatioMutex.RUnlock()
@@ -584,32 +617,20 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
}
func GetAudioRatio(name string) float64 {
- if strings.Contains(name, "-realtime") {
- if strings.HasSuffix(name, "gpt-4o-realtime-preview") {
- return 8
- } else if strings.Contains(name, "gpt-4o-mini-realtime-preview") {
- return 10 / 0.6
- } else {
- return 20
- }
- }
- if strings.Contains(name, "-audio") {
- if strings.HasPrefix(name, "gpt-4o-audio-preview") {
- return 40 / 2.5
- } else if strings.HasPrefix(name, "gpt-4o-mini-audio-preview") {
- return 10 / 0.15
- } else {
- return 40
- }
+ audioRatioMapMutex.RLock()
+ defer audioRatioMapMutex.RUnlock()
+ if ratio, ok := audioRatioMap[name]; ok {
+ return ratio
}
return 20
}
func GetAudioCompletionRatio(name string) float64 {
- if strings.HasPrefix(name, "gpt-4o-realtime") {
- return 2
- } else if strings.HasPrefix(name, "gpt-4o-mini-realtime") {
- return 2
+ audioCompletionRatioMapMutex.RLock()
+ defer audioCompletionRatioMapMutex.RUnlock()
+ if ratio, ok := audioCompletionRatioMap[name]; ok {
+
+ return ratio
}
return 2
}
@@ -630,6 +651,14 @@ var defaultImageRatio = map[string]float64{
}
var imageRatioMap map[string]float64
var imageRatioMapMutex sync.RWMutex
+var (
+ audioRatioMap map[string]float64 = nil
+ audioRatioMapMutex = sync.RWMutex{}
+)
+var (
+ audioCompletionRatioMap map[string]float64 = nil
+ audioCompletionRatioMapMutex = sync.RWMutex{}
+)
func ImageRatio2JSONString() string {
imageRatioMapMutex.RLock()
@@ -658,6 +687,68 @@ func GetImageRatio(name string) (float64, bool) {
return ratio, true
}
+func AudioRatio2JSONString() string {
+ audioRatioMapMutex.RLock()
+ defer audioRatioMapMutex.RUnlock()
+ jsonBytes, err := common.Marshal(audioRatioMap)
+ if err != nil {
+ common.SysError("error marshalling audio ratio: " + err.Error())
+ }
+ return string(jsonBytes)
+}
+
+func UpdateAudioRatioByJSONString(jsonStr string) error {
+ audioRatioMapMutex.Lock()
+ defer audioRatioMapMutex.Unlock()
+ audioRatioMap = make(map[string]float64)
+ err := common.Unmarshal([]byte(jsonStr), &audioRatioMap)
+ if err == nil {
+ InvalidateExposedDataCache()
+ }
+ return err
+}
+
+func GetAudioRatioCopy() map[string]float64 {
+ audioRatioMapMutex.RLock()
+ defer audioRatioMapMutex.RUnlock()
+ copyMap := make(map[string]float64, len(audioRatioMap))
+ for k, v := range audioRatioMap {
+ copyMap[k] = v
+ }
+ return copyMap
+}
+
+func AudioCompletionRatio2JSONString() string {
+ audioCompletionRatioMapMutex.RLock()
+ defer audioCompletionRatioMapMutex.RUnlock()
+ jsonBytes, err := common.Marshal(audioCompletionRatioMap)
+ if err != nil {
+ common.SysError("error marshalling audio completion ratio: " + err.Error())
+ }
+ return string(jsonBytes)
+}
+
+func UpdateAudioCompletionRatioByJSONString(jsonStr string) error {
+ audioCompletionRatioMapMutex.Lock()
+ defer audioCompletionRatioMapMutex.Unlock()
+ audioCompletionRatioMap = make(map[string]float64)
+ err := common.Unmarshal([]byte(jsonStr), &audioCompletionRatioMap)
+ if err == nil {
+ InvalidateExposedDataCache()
+ }
+ return err
+}
+
+func GetAudioCompletionRatioCopy() map[string]float64 {
+ audioCompletionRatioMapMutex.RLock()
+ defer audioCompletionRatioMapMutex.RUnlock()
+ copyMap := make(map[string]float64, len(audioCompletionRatioMap))
+ for k, v := range audioCompletionRatioMap {
+ copyMap[k] = v
+ }
+ return copyMap
+}
+
func GetModelRatioCopy() map[string]float64 {
modelRatioMapMutex.RLock()
defer modelRatioMapMutex.RUnlock()
diff --git a/types/price_data.go b/types/price_data.go
index f6a92d7e3..ec7fcdfe9 100644
--- a/types/price_data.go
+++ b/types/price_data.go
@@ -15,6 +15,8 @@ type PriceData struct {
CacheRatio float64
CacheCreationRatio float64
ImageRatio float64
+ AudioRatio float64
+ AudioCompletionRatio float64
UsePrice bool
ShouldPreConsumedQuota int
GroupRatioInfo GroupRatioInfo
@@ -27,5 +29,5 @@ type PerCallPriceData struct {
}
func (p PriceData) ToSetting() string {
- return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, ShouldPreConsumedQuota: %d, ImageRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.ShouldPreConsumedQuota, p.ImageRatio)
+ return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, ShouldPreConsumedQuota: %d, ImageRatio: %f, AudioRatio: %f, AudioCompletionRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.ShouldPreConsumedQuota, p.ImageRatio, p.AudioRatio, p.AudioCompletionRatio)
}
diff --git a/web/src/components/settings/RatioSetting.jsx b/web/src/components/settings/RatioSetting.jsx
index 096722bba..f5d8ef99d 100644
--- a/web/src/components/settings/RatioSetting.jsx
+++ b/web/src/components/settings/RatioSetting.jsx
@@ -39,6 +39,9 @@ const RatioSetting = () => {
CompletionRatio: '',
GroupRatio: '',
GroupGroupRatio: '',
+ ImageRatio: '',
+ AudioRatio: '',
+ AudioCompletionRatio: '',
AutoGroups: '',
DefaultUseAutoGroup: false,
ExposeRatioEnabled: false,
@@ -61,7 +64,10 @@ const RatioSetting = () => {
item.key === 'UserUsableGroups' ||
item.key === 'CompletionRatio' ||
item.key === 'ModelPrice' ||
- item.key === 'CacheRatio'
+ item.key === 'CacheRatio' ||
+ item.key === 'ImageRatio' ||
+ item.key === 'AudioRatio' ||
+ item.key === 'AudioCompletionRatio'
) {
try {
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index 877fa44fe..62c8ebb79 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -2017,5 +2017,15 @@
"查看密钥": "View key",
"查看渠道密钥": "View channel key",
"渠道密钥信息": "Channel key information",
- "密钥获取成功": "Key acquisition successful"
+ "密钥获取成功": "Key acquisition successful",
+ "模型补全倍率(仅对自定义模型有效)": "Model completion ratio (only effective for custom models)",
+ "图片倍率": "Image ratio",
+ "音频倍率": "Audio ratio",
+ "音频补全倍率": "Audio completion ratio",
+ "图片输入相关的倍率设置,键为模型名称,值为倍率": "Image input related ratio settings, key is model name, value is ratio",
+ "音频输入相关的倍率设置,键为模型名称,值为倍率": "Audio input related ratio settings, key is model name, value is ratio",
+ "音频输出补全相关的倍率设置,键为模型名称,值为倍率": "Audio output completion related ratio settings, key is model name, value is ratio",
+ "为一个 JSON 文本,键为模型名称,值为倍率,例如:{\"gpt-image-1\": 2}": "A JSON text with model name as key and ratio as value, e.g.: {\"gpt-image-1\": 2}",
+ "为一个 JSON 文本,键为模型名称,值为倍率,例如:{\"gpt-4o-audio-preview\": 16}": "A JSON text with model name as key and ratio as value, e.g.: {\"gpt-4o-audio-preview\": 16}",
+ "为一个 JSON 文本,键为模型名称,值为倍率,例如:{\"gpt-4o-realtime\": 2}": "A JSON text with model name as key and ratio as value, e.g.: {\"gpt-4o-realtime\": 2}"
}
diff --git a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx
index 2462a35ad..b40951261 100644
--- a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx
+++ b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx
@@ -44,6 +44,9 @@ export default function ModelRatioSettings(props) {
ModelRatio: '',
CacheRatio: '',
CompletionRatio: '',
+ ImageRatio: '',
+ AudioRatio: '',
+ AudioCompletionRatio: '',
ExposeRatioEnabled: false,
});
const refForm = useRef();
@@ -219,6 +222,72 @@ export default function ModelRatioSettings(props) {
/>
+
+
+ verifyJSON(value),
+ message: '不是合法的 JSON 字符串',
+ },
+ ]}
+ onChange={(value) =>
+ setInputs({ ...inputs, ImageRatio: value })
+ }
+ />
+
+
+
+
+ verifyJSON(value),
+ message: '不是合法的 JSON 字符串',
+ },
+ ]}
+ onChange={(value) =>
+ setInputs({ ...inputs, AudioRatio: value })
+ }
+ />
+
+
+
+
+ verifyJSON(value),
+ message: '不是合法的 JSON 字符串',
+ },
+ ]}
+ onChange={(value) =>
+ setInputs({ ...inputs, AudioCompletionRatio: value })
+ }
+ />
+
+
Date: Sat, 30 Aug 2025 23:53:46 +0800
Subject: [PATCH 005/165] feat: improve ratio update
---
setting/ratio_setting/model_ratio.go | 33 ++++++++++++++++------------
1 file changed, 19 insertions(+), 14 deletions(-)
diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go
index 99952daf1..5b47c875f 100644
--- a/setting/ratio_setting/model_ratio.go
+++ b/setting/ratio_setting/model_ratio.go
@@ -619,6 +619,7 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
func GetAudioRatio(name string) float64 {
audioRatioMapMutex.RLock()
defer audioRatioMapMutex.RUnlock()
+ name = FormatMatchingModelName(name)
if ratio, ok := audioRatioMap[name]; ok {
return ratio
}
@@ -628,6 +629,7 @@ func GetAudioRatio(name string) float64 {
func GetAudioCompletionRatio(name string) float64 {
audioCompletionRatioMapMutex.RLock()
defer audioCompletionRatioMapMutex.RUnlock()
+ name = FormatMatchingModelName(name)
if ratio, ok := audioCompletionRatioMap[name]; ok {
return ratio
@@ -698,14 +700,16 @@ func AudioRatio2JSONString() string {
}
func UpdateAudioRatioByJSONString(jsonStr string) error {
- audioRatioMapMutex.Lock()
- defer audioRatioMapMutex.Unlock()
- audioRatioMap = make(map[string]float64)
- err := common.Unmarshal([]byte(jsonStr), &audioRatioMap)
- if err == nil {
- InvalidateExposedDataCache()
+
+ tmp := make(map[string]float64)
+ if err := common.Unmarshal([]byte(jsonStr), &tmp); err != nil {
+ return err
}
- return err
+ audioRatioMapMutex.Lock()
+ audioRatioMap = tmp
+ audioRatioMapMutex.Unlock()
+ InvalidateExposedDataCache()
+ return nil
}
func GetAudioRatioCopy() map[string]float64 {
@@ -729,14 +733,15 @@ func AudioCompletionRatio2JSONString() string {
}
func UpdateAudioCompletionRatioByJSONString(jsonStr string) error {
- audioCompletionRatioMapMutex.Lock()
- defer audioCompletionRatioMapMutex.Unlock()
- audioCompletionRatioMap = make(map[string]float64)
- err := common.Unmarshal([]byte(jsonStr), &audioCompletionRatioMap)
- if err == nil {
- InvalidateExposedDataCache()
+ tmp := make(map[string]float64)
+ if err := common.Unmarshal([]byte(jsonStr), &tmp); err != nil {
+ return err
}
- return err
+ audioCompletionRatioMapMutex.Lock()
+ audioCompletionRatioMap = tmp
+ audioCompletionRatioMapMutex.Unlock()
+ InvalidateExposedDataCache()
+ return nil
}
func GetAudioCompletionRatioCopy() map[string]float64 {
From 511489db0950744bad18a2d2a26b2f972d366844 Mon Sep 17 00:00:00 2001
From: DD <1083962986@qq.com>
Date: Mon, 8 Sep 2025 16:21:21 +0800
Subject: [PATCH 006/165] add submodel.ai
---
common/api_type.go | 2 +
constant/api_type.go | 1 +
constant/channel.go | 2 +
relay/channel/submodel/adaptor.go | 82 ++++++++++++++++++++++++++
relay/channel/submodel/constants.go | 16 +++++
relay/relay_adaptor.go | 3 +
setting/ratio_setting/model_ratio.go | 13 ++++
web/src/constants/channel.constants.js | 5 ++
web/src/helpers/render.js | 2 +
web/src/pages/Channel/EditTagModal.js | 3 +
10 files changed, 129 insertions(+)
create mode 100644 relay/channel/submodel/adaptor.go
create mode 100644 relay/channel/submodel/constants.go
diff --git a/common/api_type.go b/common/api_type.go
index f045866ac..6204451dc 100644
--- a/common/api_type.go
+++ b/common/api_type.go
@@ -65,6 +65,8 @@ func ChannelType2APIType(channelType int) (int, bool) {
apiType = constant.APITypeCoze
case constant.ChannelTypeJimeng:
apiType = constant.APITypeJimeng
+ case constant.ChannelTypeSubmodel:
+ apiType = constant.APITypeSubmodel
}
if apiType == -1 {
return constant.APITypeOpenAI, false
diff --git a/constant/api_type.go b/constant/api_type.go
index 6ba5f2574..0c7b1fdde 100644
--- a/constant/api_type.go
+++ b/constant/api_type.go
@@ -31,5 +31,6 @@ const (
APITypeXai
APITypeCoze
APITypeJimeng
+ APITypeSubmodel
APITypeDummy // this one is only for count, do not add any channel after this
)
diff --git a/constant/channel.go b/constant/channel.go
index 224121e70..3d7158b11 100644
--- a/constant/channel.go
+++ b/constant/channel.go
@@ -49,6 +49,7 @@ const (
ChannelTypeCoze = 49
ChannelTypeKling = 50
ChannelTypeJimeng = 51
+ ChannelTypeSubmodel = 52
ChannelTypeDummy // this one is only for count, do not add any channel after this
)
@@ -106,4 +107,5 @@ var ChannelBaseURLs = []string{
"https://api.coze.cn", //49
"https://api.klingai.com", //50
"https://visual.volcengineapi.com", //51
+ "https://llm.submodel.ai", //52
}
diff --git a/relay/channel/submodel/adaptor.go b/relay/channel/submodel/adaptor.go
new file mode 100644
index 000000000..371fb055c
--- /dev/null
+++ b/relay/channel/submodel/adaptor.go
@@ -0,0 +1,82 @@
+package submodel
+
+import (
+ "errors"
+ "io"
+ "net/http"
+ "one-api/dto"
+ "one-api/relay/channel"
+ "one-api/relay/channel/openai"
+ relaycommon "one-api/relay/common"
+ "one-api/types"
+
+ "github.com/gin-gonic/gin"
+)
+
+type Adaptor struct {
+}
+
+func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
+ return nil, errors.New("not implemented")
+}
+
+func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
+ return nil, errors.New("not implemented")
+}
+
+func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
+ return nil, errors.New("not implemented")
+}
+
+func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
+}
+
+func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
+ return relaycommon.GetFullRequestURL(info.BaseUrl, info.RequestURLPath, info.ChannelType), nil
+}
+
+func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
+ channel.SetupApiRequestHeader(info, c, req)
+ req.Set("Authorization", "Bearer "+info.ApiKey)
+ return nil
+}
+
+func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
+ if request == nil {
+ return nil, errors.New("request is nil")
+ }
+ return request, nil
+}
+
+func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
+ return nil, errors.New("not implemented")
+}
+
+func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
+ return nil, errors.New("not implemented")
+}
+
+func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
+ return nil, errors.New("not implemented")
+}
+
+func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
+ return channel.DoApiRequest(a, c, info, requestBody)
+}
+
+func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
+ if info.IsStream {
+ usage, err = openai.OaiStreamHandler(c, info, resp)
+ } else {
+ usage, err = openai.OpenaiHandler(c, info, resp)
+ }
+ return
+}
+
+func (a *Adaptor) GetModelList() []string {
+ return ModelList
+}
+
+func (a *Adaptor) GetChannelName() string {
+ return ChannelName
+}
\ No newline at end of file
diff --git a/relay/channel/submodel/constants.go b/relay/channel/submodel/constants.go
new file mode 100644
index 000000000..962682bb9
--- /dev/null
+++ b/relay/channel/submodel/constants.go
@@ -0,0 +1,16 @@
+package submodel
+
+var ModelList = []string{
+ "NousResearch/Hermes-4-405B-FP8",
+ "Qwen/Qwen3-235B-A22B-Thinking-2507",
+ "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8",
+ "Qwen/Qwen3-235B-A22B-Instruct-2507",
+ "zai-org/GLM-4.5-FP8",
+ "openai/gpt-oss-120b",
+ "deepseek-ai/DeepSeek-R1-0528",
+ "deepseek-ai/DeepSeek-R1",
+ "deepseek-ai/DeepSeek-V3-0324",
+ "deepseek-ai/DeepSeek-V3.1",
+}
+
+var ChannelName = "submodel"
\ No newline at end of file
diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go
index 2ce12a872..946053a28 100644
--- a/relay/relay_adaptor.go
+++ b/relay/relay_adaptor.go
@@ -34,6 +34,7 @@ import (
"one-api/relay/channel/xunfei"
"one-api/relay/channel/zhipu"
"one-api/relay/channel/zhipu_4v"
+ "one-api/relay/channel/submodel"
)
func GetAdaptor(apiType int) channel.Adaptor {
@@ -96,6 +97,8 @@ func GetAdaptor(apiType int) channel.Adaptor {
return &coze.Adaptor{}
case constant.APITypeJimeng:
return &jimeng.Adaptor{}
+ case constant.APITypeSubmodel:
+ return &submodel.Adaptor{}
}
return nil
}
diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go
index 8a1d6aaed..0bcb6ff58 100644
--- a/setting/ratio_setting/model_ratio.go
+++ b/setting/ratio_setting/model_ratio.go
@@ -223,6 +223,19 @@ var defaultModelRatio = map[string]float64{
"grok-vision-beta": 2.5,
"grok-3-fast-beta": 2.5,
"grok-3-mini-fast-beta": 0.3,
+
+ // submodel
+ "NousResearch/Hermes-4-405B-FP8": 0.8,
+ "Qwen/Qwen3-235B-A22B-Thinking-2507": 0.6,
+ "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": 0.8,
+ "Qwen/Qwen3-235B-A22B-Instruct-2507": 0.3,
+ "zai-org/GLM-4.5-FP8": 0.8,
+ "openai/gpt-oss-120b": 0.5,
+ "deepseek-ai/DeepSeek-R1-0528": 0.8,
+ "deepseek-ai/DeepSeek-R1": 0.8,
+ "deepseek-ai/DeepSeek-V3-0324": 0.8,
+ "deepseek-ai/DeepSeek-V3.1": 0.8
+
}
var defaultModelPrice = map[string]float64{
diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js
index b145ea11f..6a4566a7d 100644
--- a/web/src/constants/channel.constants.js
+++ b/web/src/constants/channel.constants.js
@@ -135,6 +135,11 @@ export const CHANNEL_OPTIONS = [
color: 'blue',
label: '即梦',
},
+ {
+ value: 52,
+ color: 'blue',
+ label: 'SubModel',
+ },
];
export const MODEL_TABLE_PAGE_SIZE = 10;
diff --git a/web/src/helpers/render.js b/web/src/helpers/render.js
index 34ba78d7a..abf246b8c 100644
--- a/web/src/helpers/render.js
+++ b/web/src/helpers/render.js
@@ -398,6 +398,8 @@ export function getChannelIcon(channelType) {
return ;
case 21: // 知识库:AI Proxy
case 44: // 嵌入模型:MokaAI M3E
+ case 52: // SubModel
+ return null;
default:
return null; // 未知类型或自定义渠道不显示图标
}
diff --git a/web/src/pages/Channel/EditTagModal.js b/web/src/pages/Channel/EditTagModal.js
index 433d4f092..35fc1646f 100644
--- a/web/src/pages/Channel/EditTagModal.js
+++ b/web/src/pages/Channel/EditTagModal.js
@@ -98,6 +98,9 @@ const EditTagModal = (props) => {
case 36:
localModels = ['suno_music', 'suno_lyrics'];
break;
+ case 52:
+ localModels = ['NousResearch/Hermes-4-405B-FP8', 'Qwen/Qwen3-235B-A22B-Thinking-2507', 'Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8', 'zai-org/GLM-4.5-FP8', 'openai/gpt-oss-120b', 'deepseek-ai/DeepSeek-R1-0528', 'deepseek-ai/DeepSeek-R1', 'deepseek-ai/DeepSeek-V3-0324', 'deepseek-ai/DeepSeek-V3.1'];
+ break;
default:
localModels = getChannelModels(value);
break;
From 23e4249ebeaf49abd1847cf19d3b3b2535981161 Mon Sep 17 00:00:00 2001
From: DD <1083962986@qq.com>
Date: Mon, 8 Sep 2025 17:33:15 +0800
Subject: [PATCH 007/165] merge
---
web/src/pages/Channel/EditTagModal.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/src/pages/Channel/EditTagModal.js b/web/src/pages/Channel/EditTagModal.js
index 35fc1646f..aedf991c3 100644
--- a/web/src/pages/Channel/EditTagModal.js
+++ b/web/src/pages/Channel/EditTagModal.js
@@ -99,7 +99,7 @@ const EditTagModal = (props) => {
localModels = ['suno_music', 'suno_lyrics'];
break;
case 52:
- localModels = ['NousResearch/Hermes-4-405B-FP8', 'Qwen/Qwen3-235B-A22B-Thinking-2507', 'Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8', 'zai-org/GLM-4.5-FP8', 'openai/gpt-oss-120b', 'deepseek-ai/DeepSeek-R1-0528', 'deepseek-ai/DeepSeek-R1', 'deepseek-ai/DeepSeek-V3-0324', 'deepseek-ai/DeepSeek-V3.1'];
+ localModels = ['NousResearch/Hermes-4-405B-FP8', 'Qwen/Qwen3-235B-A22B-Thinking-2507', 'Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8','Qwen/Qwen3-235B-A22B-Instruct-2507', 'zai-org/GLM-4.5-FP8', 'openai/gpt-oss-120b', 'deepseek-ai/DeepSeek-R1-0528', 'deepseek-ai/DeepSeek-R1', 'deepseek-ai/DeepSeek-V3-0324', 'deepseek-ai/DeepSeek-V3.1'];
break;
default:
localModels = getChannelModels(value);
From 99a8b5eef052e7f732b433a6eacda21356964e1e Mon Sep 17 00:00:00 2001
From: undefinedcodezhong
<40236765+undefinedcodezhong@users.noreply.github.com>
Date: Wed, 10 Sep 2025 10:41:44 +0800
Subject: [PATCH 008/165] =?UTF-8?q?fix=EF=BC=9AAccount=20Management=20Stat?=
=?UTF-8?q?us?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../settings/personal/cards/AccountManagement.jsx | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/web/src/components/settings/personal/cards/AccountManagement.jsx b/web/src/components/settings/personal/cards/AccountManagement.jsx
index 515a5c191..235b4f3ff 100644
--- a/web/src/components/settings/personal/cards/AccountManagement.jsx
+++ b/web/src/components/settings/personal/cards/AccountManagement.jsx
@@ -165,9 +165,10 @@ const AccountManagement = ({
{t('微信')}
- {userState.user && userState.user.wechat_id !== ''
- ? t('已绑定')
- : t('未绑定')}
+ {renderAccountInfo(
+ userState.user?.wechat_id,
+ t('微信 ID'),
+ )}
@@ -179,7 +180,7 @@ const AccountManagement = ({
disabled={!status.wechat_login}
onClick={() => setShowWeChatBindModal(true)}
>
- {userState.user && userState.user.wechat_id !== ''
+ {userState.user && userState.user?.wechat_id
? t('修改绑定')
: status.wechat_login
? t('绑定')
@@ -298,7 +299,7 @@ const AccountManagement = ({
{status.telegram_oauth ? (
- userState.user.telegram_id !== '' ? (
+ userState.user?.telegram_id ? (
From 3f9698bb470a8a6b6499c79a5f98c9ba3cfafab4 Mon Sep 17 00:00:00 2001
From: Xyfacai
Date: Wed, 10 Sep 2025 15:29:07 +0800
Subject: [PATCH 009/165] =?UTF-8?q?feat:=20dalle=20=E8=87=AA=E5=AE=9A?=
=?UTF-8?q?=E4=B9=89=E5=AD=97=E6=AE=B5=E9=80=8F=E4=BC=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
dto/openai_image.go | 23 +++++++++++++++++++++++
1 file changed, 23 insertions(+)
diff --git a/dto/openai_image.go b/dto/openai_image.go
index 9e838688e..bc888dc71 100644
--- a/dto/openai_image.go
+++ b/dto/openai_image.go
@@ -59,6 +59,29 @@ func (i *ImageRequest) UnmarshalJSON(data []byte) error {
return nil
}
+// 序列化时需要重新把字段平铺
+func (r ImageRequest) MarshalJSON() ([]byte, error) {
+ // 将已定义字段转为 map
+ type Alias ImageRequest
+ alias := Alias(r)
+ base, err := json.Marshal(alias)
+ if err != nil {
+ return nil, err
+ }
+
+ var baseMap map[string]json.RawMessage
+ if err := json.Unmarshal(base, &baseMap); err != nil {
+ return nil, err
+ }
+
+ // 合并 ExtraFields
+ for k, v := range r.Extra {
+ baseMap[k] = v
+ }
+
+ return json.Marshal(baseMap)
+}
+
func GetJSONFieldNames(t reflect.Type) map[string]struct{} {
fields := make(map[string]struct{})
for i := 0; i < t.NumField(); i++ {
From fcdfd027cd0140c98861cdc8e05050846344a75e Mon Sep 17 00:00:00 2001
From: Xyfacai
Date: Wed, 10 Sep 2025 15:30:23 +0800
Subject: [PATCH 010/165] =?UTF-8?q?fix:=20openai=20=E6=A0=BC=E5=BC=8F?=
=?UTF-8?q?=E8=AF=B7=E6=B1=82=20claude=20=E6=B2=A1=E8=AE=A1=E8=B4=B9=20cre?=
=?UTF-8?q?ate=20cache=20token?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
controller/channel-test.go | 2 +-
relay/audio_handler.go | 2 +-
relay/claude_handler.go | 2 +-
relay/compatible_handler.go | 20 ++++++++++++++++++--
relay/embedding_handler.go | 2 +-
relay/gemini_handler.go | 4 ++--
relay/image_handler.go | 2 +-
relay/rerank_handler.go | 2 +-
relay/responses_handler.go | 2 +-
service/error.go | 6 ++++--
10 files changed, 31 insertions(+), 13 deletions(-)
diff --git a/controller/channel-test.go b/controller/channel-test.go
index 5fc6d749c..5a668c488 100644
--- a/controller/channel-test.go
+++ b/controller/channel-test.go
@@ -235,7 +235,7 @@ func testChannel(channel *model.Channel, testModel string) testResult {
if resp != nil {
httpResp = resp.(*http.Response)
if httpResp.StatusCode != http.StatusOK {
- err := service.RelayErrorHandler(httpResp, true)
+ err := service.RelayErrorHandler(c.Request.Context(), httpResp, true)
return testResult{
context: c,
localErr: err,
diff --git a/relay/audio_handler.go b/relay/audio_handler.go
index 711cc7a9b..1357e3816 100644
--- a/relay/audio_handler.go
+++ b/relay/audio_handler.go
@@ -53,7 +53,7 @@ func AudioHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
if resp != nil {
httpResp = resp.(*http.Response)
if httpResp.StatusCode != http.StatusOK {
- newAPIError = service.RelayErrorHandler(httpResp, false)
+ newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
// reset status code 重置状态码
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError
diff --git a/relay/claude_handler.go b/relay/claude_handler.go
index 59c052f62..dbdc6ee1c 100644
--- a/relay/claude_handler.go
+++ b/relay/claude_handler.go
@@ -111,7 +111,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
httpResp = resp.(*http.Response)
info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream")
if httpResp.StatusCode != http.StatusOK {
- newAPIError = service.RelayErrorHandler(httpResp, false)
+ newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
// reset status code 重置状态码
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError
diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go
index a3c6ace6e..8f27fd60b 100644
--- a/relay/compatible_handler.go
+++ b/relay/compatible_handler.go
@@ -158,7 +158,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
httpResp = resp.(*http.Response)
info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream")
if httpResp.StatusCode != http.StatusOK {
- newApiErr := service.RelayErrorHandler(httpResp, false)
+ newApiErr := service.RelayErrorHandler(c.Request.Context(), httpResp, false)
// reset status code 重置状态码
service.ResetStatusCode(newApiErr, statusCodeMappingStr)
return newApiErr
@@ -195,6 +195,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
imageTokens := usage.PromptTokensDetails.ImageTokens
audioTokens := usage.PromptTokensDetails.AudioTokens
completionTokens := usage.CompletionTokens
+ cachedCreationTokens := usage.PromptTokensDetails.CachedCreationTokens
+
modelName := relayInfo.OriginModelName
tokenName := ctx.GetString("token_name")
@@ -204,6 +206,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
modelRatio := relayInfo.PriceData.ModelRatio
groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
modelPrice := relayInfo.PriceData.ModelPrice
+ cachedCreationRatio := relayInfo.PriceData.CacheCreationRatio
// Convert values to decimal for precise calculation
dPromptTokens := decimal.NewFromInt(int64(promptTokens))
@@ -211,12 +214,14 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
dImageTokens := decimal.NewFromInt(int64(imageTokens))
dAudioTokens := decimal.NewFromInt(int64(audioTokens))
dCompletionTokens := decimal.NewFromInt(int64(completionTokens))
+ dCachedCreationTokens := decimal.NewFromInt(int64(cachedCreationTokens))
dCompletionRatio := decimal.NewFromFloat(completionRatio)
dCacheRatio := decimal.NewFromFloat(cacheRatio)
dImageRatio := decimal.NewFromFloat(imageRatio)
dModelRatio := decimal.NewFromFloat(modelRatio)
dGroupRatio := decimal.NewFromFloat(groupRatio)
dModelPrice := decimal.NewFromFloat(modelPrice)
+ dCachedCreationRatio := decimal.NewFromFloat(cachedCreationRatio)
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
ratio := dModelRatio.Mul(dGroupRatio)
@@ -284,6 +289,11 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
baseTokens = baseTokens.Sub(dCacheTokens)
cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio)
}
+ var dCachedCreationTokensWithRatio decimal.Decimal
+ if !dCachedCreationTokens.IsZero() {
+ baseTokens = baseTokens.Sub(dCachedCreationTokens)
+ dCachedCreationTokensWithRatio = dCachedCreationTokens.Mul(dCachedCreationRatio)
+ }
// 减去 image tokens
var imageTokensWithRatio decimal.Decimal
@@ -302,7 +312,9 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
extraContent += fmt.Sprintf("Audio Input 花费 %s", audioInputQuota.String())
}
}
- promptQuota := baseTokens.Add(cachedTokensWithRatio).Add(imageTokensWithRatio)
+ promptQuota := baseTokens.Add(cachedTokensWithRatio).
+ Add(imageTokensWithRatio).
+ Add(dCachedCreationTokensWithRatio)
completionQuota := dCompletionTokens.Mul(dCompletionRatio)
@@ -395,6 +407,10 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
other["image_ratio"] = imageRatio
other["image_output"] = imageTokens
}
+ if cachedCreationTokens != 0 {
+ other["cache_creation_tokens"] = cachedCreationTokens
+ other["cache_creation_ratio"] = cachedCreationRatio
+ }
if !dWebSearchQuota.IsZero() {
if relayInfo.ResponsesUsageInfo != nil {
if webSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists {
diff --git a/relay/embedding_handler.go b/relay/embedding_handler.go
index 26dcf9719..3d8962bb4 100644
--- a/relay/embedding_handler.go
+++ b/relay/embedding_handler.go
@@ -58,7 +58,7 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
if resp != nil {
httpResp = resp.(*http.Response)
if httpResp.StatusCode != http.StatusOK {
- newAPIError = service.RelayErrorHandler(httpResp, false)
+ newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
// reset status code 重置状态码
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError
diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go
index 460fd2f58..0252d6578 100644
--- a/relay/gemini_handler.go
+++ b/relay/gemini_handler.go
@@ -152,7 +152,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
httpResp = resp.(*http.Response)
info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream")
if httpResp.StatusCode != http.StatusOK {
- newAPIError = service.RelayErrorHandler(httpResp, false)
+ newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
// reset status code 重置状态码
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError
@@ -249,7 +249,7 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo) (newAPI
if resp != nil {
httpResp = resp.(*http.Response)
if httpResp.StatusCode != http.StatusOK {
- newAPIError = service.RelayErrorHandler(httpResp, false)
+ newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError
}
diff --git a/relay/image_handler.go b/relay/image_handler.go
index 14a7103c3..e2789ae5e 100644
--- a/relay/image_handler.go
+++ b/relay/image_handler.go
@@ -91,7 +91,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
httpResp = resp.(*http.Response)
info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream")
if httpResp.StatusCode != http.StatusOK {
- newAPIError = service.RelayErrorHandler(httpResp, false)
+ newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
// reset status code 重置状态码
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError
diff --git a/relay/rerank_handler.go b/relay/rerank_handler.go
index fa3c7bbb4..46d2e25f6 100644
--- a/relay/rerank_handler.go
+++ b/relay/rerank_handler.go
@@ -81,7 +81,7 @@ func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
if resp != nil {
httpResp = resp.(*http.Response)
if httpResp.StatusCode != http.StatusOK {
- newAPIError = service.RelayErrorHandler(httpResp, false)
+ newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
// reset status code 重置状态码
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError
diff --git a/relay/responses_handler.go b/relay/responses_handler.go
index f5f624c92..d1c5d2158 100644
--- a/relay/responses_handler.go
+++ b/relay/responses_handler.go
@@ -82,7 +82,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
httpResp = resp.(*http.Response)
if httpResp.StatusCode != http.StatusOK {
- newAPIError = service.RelayErrorHandler(httpResp, false)
+ newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
// reset status code 重置状态码
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError
diff --git a/service/error.go b/service/error.go
index ef5cbbde6..5c3bddd6e 100644
--- a/service/error.go
+++ b/service/error.go
@@ -1,12 +1,14 @@
package service
import (
+ "context"
"errors"
"fmt"
"io"
"net/http"
"one-api/common"
"one-api/dto"
+ "one-api/logger"
"one-api/types"
"strconv"
"strings"
@@ -78,7 +80,7 @@ func ClaudeErrorWrapperLocal(err error, code string, statusCode int) *dto.Claude
return claudeErr
}
-func RelayErrorHandler(resp *http.Response, showBodyWhenFail bool) (newApiErr *types.NewAPIError) {
+func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFail bool) (newApiErr *types.NewAPIError) {
newApiErr = types.InitOpenAIError(types.ErrorCodeBadResponseStatusCode, resp.StatusCode)
responseBody, err := io.ReadAll(resp.Body)
@@ -94,7 +96,7 @@ func RelayErrorHandler(resp *http.Response, showBodyWhenFail bool) (newApiErr *t
newApiErr.Err = fmt.Errorf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody))
} else {
if common.DebugEnabled {
- println(fmt.Sprintf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody)))
+ logger.LogInfo(ctx, fmt.Sprintf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody)))
}
newApiErr.Err = fmt.Errorf("bad response status code %d", resp.StatusCode)
}
From 27a0a447d0cf12c3b527f3797f4140dacd6498bc Mon Sep 17 00:00:00 2001
From: Xyfacai
Date: Wed, 10 Sep 2025 15:31:35 +0800
Subject: [PATCH 011/165] =?UTF-8?q?fix:=20err=20=E5=A6=82=E6=9E=9C?=
=?UTF-8?q?=E6=98=AF=20newApiErr=20=E5=88=99=E4=BF=9D=E7=95=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/channel/api_request.go | 3 +--
types/error.go | 34 ++++++++++++++++++++++++++++++++--
2 files changed, 33 insertions(+), 4 deletions(-)
diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go
index a50d5bdb5..a065caff7 100644
--- a/relay/channel/api_request.go
+++ b/relay/channel/api_request.go
@@ -264,9 +264,8 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
}
resp, err := client.Do(req)
-
if err != nil {
- return nil, err
+ return nil, types.NewError(err, types.ErrorCodeDoRequestFailed, types.ErrOptionWithHideErrMsg("upstream error: do request failed"))
}
if resp == nil {
return nil, errors.New("resp is nil")
diff --git a/types/error.go b/types/error.go
index f653e9a28..883ee0641 100644
--- a/types/error.go
+++ b/types/error.go
@@ -185,6 +185,14 @@ func (e *NewAPIError) ToClaudeError() ClaudeError {
type NewAPIErrorOptions func(*NewAPIError)
func NewError(err error, errorCode ErrorCode, ops ...NewAPIErrorOptions) *NewAPIError {
+ var newErr *NewAPIError
+ // 保留深层传递的 new err
+ if errors.As(err, &newErr) {
+ for _, op := range ops {
+ op(newErr)
+ }
+ return newErr
+ }
e := &NewAPIError{
Err: err,
RelayError: nil,
@@ -199,8 +207,21 @@ func NewError(err error, errorCode ErrorCode, ops ...NewAPIErrorOptions) *NewAPI
}
func NewOpenAIError(err error, errorCode ErrorCode, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError {
- if errorCode == ErrorCodeDoRequestFailed {
- err = errors.New("upstream error: do request failed")
+ var newErr *NewAPIError
+ // 保留深层传递的 new err
+ if errors.As(err, &newErr) {
+ if newErr.RelayError == nil {
+ openaiError := OpenAIError{
+ Message: newErr.Error(),
+ Type: string(errorCode),
+ Code: errorCode,
+ }
+ newErr.RelayError = openaiError
+ }
+ for _, op := range ops {
+ op(newErr)
+ }
+ return newErr
}
openaiError := OpenAIError{
Message: err.Error(),
@@ -305,6 +326,15 @@ func ErrOptionWithNoRecordErrorLog() NewAPIErrorOptions {
}
}
+func ErrOptionWithHideErrMsg(replaceStr string) NewAPIErrorOptions {
+ return func(e *NewAPIError) {
+ if common.DebugEnabled {
+ fmt.Printf("ErrOptionWithHideErrMsg: %s, origin error: %s", replaceStr, e.Err)
+ }
+ e.Err = errors.New(replaceStr)
+ }
+}
+
func IsRecordErrorLog(e *NewAPIError) bool {
if e == nil {
return false
From 78b0f8905bc515de3181ffa2eb40961cc421d732 Mon Sep 17 00:00:00 2001
From: DD <1083962986@qq.com>
Date: Wed, 10 Sep 2025 18:37:55 +0800
Subject: [PATCH 012/165] merge
---
constant/channel.go | 6 ++----
web/src/helpers/render.jsx | 2 +-
2 files changed, 3 insertions(+), 5 deletions(-)
diff --git a/constant/channel.go b/constant/channel.go
index 0cfd90cd9..34fb20f46 100644
--- a/constant/channel.go
+++ b/constant/channel.go
@@ -49,13 +49,11 @@ const (
ChannelTypeCoze = 49
ChannelTypeKling = 50
ChannelTypeJimeng = 51
-<<<<<<< HEAD
- ChannelTypeSubmodel = 52
-=======
ChannelTypeVidu = 52
->>>>>>> 041782c49e0289b9d2e64a318e81e4f75754dabf
+ ChannelTypeSubmodel = 53
ChannelTypeDummy // this one is only for count, do not add any channel after this
+
)
var ChannelBaseURLs = []string{
diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx
index 49e87f668..676a582bc 100644
--- a/web/src/helpers/render.jsx
+++ b/web/src/helpers/render.jsx
@@ -342,7 +342,7 @@ export function getChannelIcon(channelType) {
return ;
case 21: // 知识库:AI Proxy
case 44: // 嵌入模型:MokaAI M3E
- case 52: // SubModel
+ case 53: // SubModel
return null;
default:
return null; // 未知类型或自定义渠道不显示图标
From a12ed5709e46e9aa18331a2000c06e0fcbc4269e Mon Sep 17 00:00:00 2001
From: DD <1083962986@qq.com>
Date: Wed, 10 Sep 2025 19:11:58 +0800
Subject: [PATCH 013/165] merge
---
relay/channel/submodel/constants.go | 2 +-
setting/ratio_setting/model_ratio.go | 4 +---
web/src/components/table/channels/modals/EditTagModal.jsx | 2 +-
web/src/helpers/render.jsx | 2 --
4 files changed, 3 insertions(+), 7 deletions(-)
diff --git a/relay/channel/submodel/constants.go b/relay/channel/submodel/constants.go
index 962682bb9..f5e1feb84 100644
--- a/relay/channel/submodel/constants.go
+++ b/relay/channel/submodel/constants.go
@@ -13,4 +13,4 @@ var ModelList = []string{
"deepseek-ai/DeepSeek-V3.1",
}
-var ChannelName = "submodel"
\ No newline at end of file
+const ChannelName = "submodel"
\ No newline at end of file
diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go
index 26db81684..c427cfe25 100644
--- a/setting/ratio_setting/model_ratio.go
+++ b/setting/ratio_setting/model_ratio.go
@@ -251,7 +251,6 @@ var defaultModelRatio = map[string]float64{
"grok-vision-beta": 2.5,
"grok-3-fast-beta": 2.5,
"grok-3-mini-fast-beta": 0.3,
-
// submodel
"NousResearch/Hermes-4-405B-FP8": 0.8,
"Qwen/Qwen3-235B-A22B-Thinking-2507": 0.6,
@@ -262,8 +261,7 @@ var defaultModelRatio = map[string]float64{
"deepseek-ai/DeepSeek-R1-0528": 0.8,
"deepseek-ai/DeepSeek-R1": 0.8,
"deepseek-ai/DeepSeek-V3-0324": 0.8,
- "deepseek-ai/DeepSeek-V3.1": 0.8
-
+ "deepseek-ai/DeepSeek-V3.1": 0.8,
}
var defaultModelPrice = map[string]float64{
diff --git a/web/src/components/table/channels/modals/EditTagModal.jsx b/web/src/components/table/channels/modals/EditTagModal.jsx
index 727b19094..752ff3dc5 100644
--- a/web/src/components/table/channels/modals/EditTagModal.jsx
+++ b/web/src/components/table/channels/modals/EditTagModal.jsx
@@ -118,7 +118,7 @@ const EditTagModal = (props) => {
case 36:
localModels = ['suno_music', 'suno_lyrics'];
break;
- case 52:
+ case 53:
localModels = ['NousResearch/Hermes-4-405B-FP8', 'Qwen/Qwen3-235B-A22B-Thinking-2507', 'Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8','Qwen/Qwen3-235B-A22B-Instruct-2507', 'zai-org/GLM-4.5-FP8', 'openai/gpt-oss-120b', 'deepseek-ai/DeepSeek-R1-0528', 'deepseek-ai/DeepSeek-R1', 'deepseek-ai/DeepSeek-V3-0324', 'deepseek-ai/DeepSeek-V3.1'];
break;
default:
diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx
index 676a582bc..3d9d8d710 100644
--- a/web/src/helpers/render.jsx
+++ b/web/src/helpers/render.jsx
@@ -342,8 +342,6 @@ export function getChannelIcon(channelType) {
return ;
case 21: // 知识库:AI Proxy
case 44: // 嵌入模型:MokaAI M3E
- case 53: // SubModel
- return null;
default:
return null; // 未知类型或自定义渠道不显示图标
}
From cda73a2ec5be50c8b6723b8a84440845a4b30f45 Mon Sep 17 00:00:00 2001
From: Xyfacai
Date: Wed, 10 Sep 2025 19:53:32 +0800
Subject: [PATCH 014/165] =?UTF-8?q?fix:=20dalle=20log=20=E6=98=BE=E7=A4=BA?=
=?UTF-8?q?=E5=BC=A0=E6=95=B0=20N?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
controller/relay.go | 13 ++++++-------
relay/image_handler.go | 2 +-
2 files changed, 7 insertions(+), 8 deletions(-)
diff --git a/controller/relay.go b/controller/relay.go
index d3d93192e..07c3aeaac 100644
--- a/controller/relay.go
+++ b/controller/relay.go
@@ -277,14 +277,13 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) {
logger.LogError(c, fmt.Sprintf("relay error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error()))
-
- gopool.Go(func() {
- // 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况
- // do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
- if service.ShouldDisableChannel(channelError.ChannelId, err) && channelError.AutoBan {
+ // 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况
+ // do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
+ if service.ShouldDisableChannel(channelError.ChannelId, err) && channelError.AutoBan {
+ gopool.Go(func() {
service.DisableChannel(channelError, err.Error())
- }
- })
+ })
+ }
if constant.ErrorLogEnabled && types.IsRecordErrorLog(err) {
// 保存错误日志到mysql中
diff --git a/relay/image_handler.go b/relay/image_handler.go
index e2789ae5e..9c873d47f 100644
--- a/relay/image_handler.go
+++ b/relay/image_handler.go
@@ -120,7 +120,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
var logContent string
if len(request.Size) > 0 {
- logContent = fmt.Sprintf("大小 %s, 品质 %s", request.Size, quality)
+ logContent = fmt.Sprintf("大小 %s, 品质 %s, 张数 %d", request.Size, quality, request.N)
}
postConsumeQuota(c, info, usage.(*dto.Usage), logContent)
From 47aaa695b2c90b0a169a3010b0e91ab4d5fe9640 Mon Sep 17 00:00:00 2001
From: huanghejian
Date: Wed, 10 Sep 2025 20:30:00 +0800
Subject: [PATCH 015/165] feat: support amazon nova
---
relay/channel/aws/adaptor.go | 10 +++++
relay/channel/aws/constants.go | 11 +++++
relay/channel/aws/dto.go | 53 +++++++++++++++++++++++
relay/channel/aws/relay-aws.go | 78 ++++++++++++++++++++++++++++++++++
4 files changed, 152 insertions(+)
diff --git a/relay/channel/aws/adaptor.go b/relay/channel/aws/adaptor.go
index 1526a7f75..9d5e5891e 100644
--- a/relay/channel/aws/adaptor.go
+++ b/relay/channel/aws/adaptor.go
@@ -60,7 +60,16 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if request == nil {
return nil, errors.New("request is nil")
}
+ // 检查是否为Nova模型
+ if isNovaModel(request.Model) {
+ novaReq := convertToNovaRequest(request)
+ c.Set("request_model", request.Model)
+ c.Set("converted_request", novaReq)
+ c.Set("is_nova_model", true)
+ return novaReq, nil
+ }
+ // 原有的Claude模型处理逻辑
var claudeReq *dto.ClaudeRequest
var err error
claudeReq, err = claude.RequestOpenAI2ClaudeMessage(c, *request)
@@ -69,6 +78,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
}
c.Set("request_model", claudeReq.Model)
c.Set("converted_request", claudeReq)
+ c.Set("is_nova_model", false)
return claudeReq, err
}
diff --git a/relay/channel/aws/constants.go b/relay/channel/aws/constants.go
index 3f8800b1e..8ed8f0318 100644
--- a/relay/channel/aws/constants.go
+++ b/relay/channel/aws/constants.go
@@ -1,5 +1,7 @@
package aws
+import "strings"
+
var awsModelIDMap = map[string]string{
"claude-instant-1.2": "anthropic.claude-instant-v1",
"claude-2.0": "anthropic.claude-v2",
@@ -14,6 +16,10 @@ var awsModelIDMap = map[string]string{
"claude-sonnet-4-20250514": "anthropic.claude-sonnet-4-20250514-v1:0",
"claude-opus-4-20250514": "anthropic.claude-opus-4-20250514-v1:0",
"claude-opus-4-1-20250805": "anthropic.claude-opus-4-1-20250805-v1:0",
+ // Nova models
+ "amazon.nova-micro-v1:0": "us.amazon.nova-micro-v1:0",
+ "amazon.nova-lite-v1:0": "us.amazon.nova-lite-v1:0",
+ "amazon.nova-pro-v1:0": "us.amazon.nova-pro-v1:0",
}
var awsModelCanCrossRegionMap = map[string]map[string]bool{
@@ -67,3 +73,8 @@ var awsRegionCrossModelPrefixMap = map[string]string{
}
var ChannelName = "aws"
+
+// 判断是否为Nova模型
+func isNovaModel(modelId string) bool {
+ return strings.HasPrefix(modelId, "amazon.nova-")
+}
diff --git a/relay/channel/aws/dto.go b/relay/channel/aws/dto.go
index 0188c30a9..25851ff6f 100644
--- a/relay/channel/aws/dto.go
+++ b/relay/channel/aws/dto.go
@@ -34,3 +34,56 @@ func copyRequest(req *dto.ClaudeRequest) *AwsClaudeRequest {
Thinking: req.Thinking,
}
}
+
+// Nova模型使用messages-v1格式
+type NovaMessage struct {
+ Role string `json:"role"`
+ Content []NovaContent `json:"content"`
+}
+
+type NovaContent struct {
+ Text string `json:"text"`
+}
+
+type NovaRequest struct {
+ SchemaVersion string `json:"schemaVersion"`
+ Messages []NovaMessage `json:"messages"`
+ InferenceConfig NovaInferenceConfig `json:"inferenceConfig,omitempty"`
+}
+
+type NovaInferenceConfig struct {
+ MaxTokens int `json:"maxTokens,omitempty"`
+ Temperature float64 `json:"temperature,omitempty"`
+ TopP float64 `json:"topP,omitempty"`
+}
+
+// 转换OpenAI请求为Nova格式
+func convertToNovaRequest(req *dto.GeneralOpenAIRequest) *NovaRequest {
+ novaMessages := make([]NovaMessage, len(req.Messages))
+ for i, msg := range req.Messages {
+ novaMessages[i] = NovaMessage{
+ Role: msg.Role,
+ Content: []NovaContent{{Text: msg.StringContent()}},
+ }
+ }
+
+ novaReq := &NovaRequest{
+ SchemaVersion: "messages-v1",
+ Messages: novaMessages,
+ }
+
+ // 设置推理配置
+ if req.MaxTokens != 0 || (req.Temperature != nil && *req.Temperature != 0) || req.TopP != 0 {
+ if req.MaxTokens != 0 {
+ novaReq.InferenceConfig.MaxTokens = int(req.MaxTokens)
+ }
+ if req.Temperature != nil && *req.Temperature != 0 {
+ novaReq.InferenceConfig.Temperature = *req.Temperature
+ }
+ if req.TopP != 0 {
+ novaReq.InferenceConfig.TopP = req.TopP
+ }
+ }
+
+ return novaReq
+}
diff --git a/relay/channel/aws/relay-aws.go b/relay/channel/aws/relay-aws.go
index 26e234fa3..3df6b33dd 100644
--- a/relay/channel/aws/relay-aws.go
+++ b/relay/channel/aws/relay-aws.go
@@ -1,6 +1,7 @@
package aws
import (
+ "encoding/json"
"fmt"
"net/http"
"one-api/common"
@@ -93,7 +94,13 @@ func awsHandler(c *gin.Context, info *relaycommon.RelayInfo, requestMode int) (*
}
awsModelId := awsModelID(c.GetString("request_model"))
+ // 检查是否为Nova模型
+ isNova, _ := c.Get("is_nova_model")
+ if isNova == true {
+ return handleNovaRequest(c, awsCli, info, awsModelId)
+ }
+ // 原有的Claude处理逻辑
awsRegionPrefix := awsRegionPrefix(awsCli.Options().Region)
canCrossRegion := awsModelCanCrossRegion(awsModelId, awsRegionPrefix)
if canCrossRegion {
@@ -209,3 +216,74 @@ func awsStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
claude.HandleStreamFinalResponse(c, info, claudeInfo, RequestModeMessage)
return nil, claudeInfo.Usage
}
+
+// Nova模型处理函数
+func handleNovaRequest(c *gin.Context, awsCli *bedrockruntime.Client, info *relaycommon.RelayInfo, awsModelId string) (*types.NewAPIError, *dto.Usage) {
+ novaReq_, ok := c.Get("converted_request")
+ if !ok {
+ return types.NewError(errors.New("nova request not found"), types.ErrorCodeInvalidRequest), nil
+ }
+ novaReq := novaReq_.(*NovaRequest)
+
+ // 使用InvokeModel API,但使用Nova格式的请求体
+ awsReq := &bedrockruntime.InvokeModelInput{
+ ModelId: aws.String(awsModelId),
+ Accept: aws.String("application/json"),
+ ContentType: aws.String("application/json"),
+ }
+
+ reqBody, err := json.Marshal(novaReq)
+ if err != nil {
+ return types.NewError(errors.Wrap(err, "marshal nova request"), types.ErrorCodeBadResponseBody), nil
+ }
+ awsReq.Body = reqBody
+
+ awsResp, err := awsCli.InvokeModel(c.Request.Context(), awsReq)
+ if err != nil {
+ return types.NewError(errors.Wrap(err, "InvokeModel"), types.ErrorCodeChannelAwsClientError), nil
+ }
+
+ // 解析Nova响应
+ var novaResp struct {
+ Output struct {
+ Message struct {
+ Content []struct {
+ Text string `json:"text"`
+ } `json:"content"`
+ } `json:"message"`
+ } `json:"output"`
+ Usage struct {
+ InputTokens int `json:"inputTokens"`
+ OutputTokens int `json:"outputTokens"`
+ TotalTokens int `json:"totalTokens"`
+ } `json:"usage"`
+ }
+
+ if err := json.Unmarshal(awsResp.Body, &novaResp); err != nil {
+ return types.NewError(errors.Wrap(err, "unmarshal nova response"), types.ErrorCodeBadResponseBody), nil
+ }
+
+ // 构造OpenAI格式响应
+ response := dto.OpenAITextResponse{
+ Id: helper.GetResponseID(c),
+ Object: "chat.completion",
+ Created: common.GetTimestamp(),
+ Model: info.UpstreamModelName,
+ Choices: []dto.OpenAITextResponseChoice{{
+ Index: 0,
+ Message: dto.Message{
+ Role: "assistant",
+ Content: novaResp.Output.Message.Content[0].Text,
+ },
+ FinishReason: "stop",
+ }},
+ Usage: dto.Usage{
+ PromptTokens: novaResp.Usage.InputTokens,
+ CompletionTokens: novaResp.Usage.OutputTokens,
+ TotalTokens: novaResp.Usage.TotalTokens,
+ },
+ }
+
+ c.JSON(http.StatusOK, response)
+ return nil, &response.Usage
+}
From 684caa36731ea63ab19a630a29debfbb26d435ec Mon Sep 17 00:00:00 2001
From: huanghejian
Date: Thu, 11 Sep 2025 10:01:54 +0800
Subject: [PATCH 016/165] feat: amazon.nova-premier-v1:0
---
relay/channel/aws/constants.go | 7 ++++---
relay/channel/aws/dto.go | 16 +++++++++-------
2 files changed, 13 insertions(+), 10 deletions(-)
diff --git a/relay/channel/aws/constants.go b/relay/channel/aws/constants.go
index 8ed8f0318..7f18d57a1 100644
--- a/relay/channel/aws/constants.go
+++ b/relay/channel/aws/constants.go
@@ -17,9 +17,10 @@ var awsModelIDMap = map[string]string{
"claude-opus-4-20250514": "anthropic.claude-opus-4-20250514-v1:0",
"claude-opus-4-1-20250805": "anthropic.claude-opus-4-1-20250805-v1:0",
// Nova models
- "amazon.nova-micro-v1:0": "us.amazon.nova-micro-v1:0",
- "amazon.nova-lite-v1:0": "us.amazon.nova-lite-v1:0",
- "amazon.nova-pro-v1:0": "us.amazon.nova-pro-v1:0",
+ "amazon.nova-micro-v1:0": "us.amazon.nova-micro-v1:0",
+ "amazon.nova-lite-v1:0": "us.amazon.nova-lite-v1:0",
+ "amazon.nova-pro-v1:0": "us.amazon.nova-pro-v1:0",
+ "amazon.nova-premier-v1:0": "us.amazon.nova-premier-v1:0",
}
var awsModelCanCrossRegionMap = map[string]map[string]bool{
diff --git a/relay/channel/aws/dto.go b/relay/channel/aws/dto.go
index 25851ff6f..cef16c11f 100644
--- a/relay/channel/aws/dto.go
+++ b/relay/channel/aws/dto.go
@@ -35,7 +35,7 @@ func copyRequest(req *dto.ClaudeRequest) *AwsClaudeRequest {
}
}
-// Nova模型使用messages-v1格式
+// NovaMessage Nova模型使用messages-v1格式
type NovaMessage struct {
Role string `json:"role"`
Content []NovaContent `json:"content"`
@@ -46,15 +46,17 @@ type NovaContent struct {
}
type NovaRequest struct {
- SchemaVersion string `json:"schemaVersion"`
- Messages []NovaMessage `json:"messages"`
- InferenceConfig NovaInferenceConfig `json:"inferenceConfig,omitempty"`
+ SchemaVersion string `json:"schemaVersion"` // 请求版本,例如 "1.0"
+ Messages []NovaMessage `json:"messages"` // 对话消息列表
+ InferenceConfig *NovaInferenceConfig `json:"inferenceConfig,omitempty"` // 推理配置,可选
}
type NovaInferenceConfig struct {
- MaxTokens int `json:"maxTokens,omitempty"`
- Temperature float64 `json:"temperature,omitempty"`
- TopP float64 `json:"topP,omitempty"`
+ MaxTokens int `json:"maxTokens,omitempty"` // 最大生成的 token 数
+ Temperature float64 `json:"temperature,omitempty"` // 随机性 (默认 0.7, 范围 0-1)
+ TopP float64 `json:"topP,omitempty"` // nucleus sampling (默认 0.9, 范围 0-1)
+ TopK int `json:"topK,omitempty"` // 限制候选 token 数 (默认 50, 范围 0-128)
+ StopSequences []string `json:"stopSequences,omitempty"` // 停止生成的序列
}
// 转换OpenAI请求为Nova格式
From 3e9be07db47300dc144990c18bb2d8f4488d6427 Mon Sep 17 00:00:00 2001
From: heimoshuiyu
Date: Thu, 11 Sep 2025 10:34:51 +0800
Subject: [PATCH 017/165] feat: add thousand separators to token display in
dashboard
---
web/src/hooks/dashboard/useDashboardStats.jsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/src/hooks/dashboard/useDashboardStats.jsx b/web/src/hooks/dashboard/useDashboardStats.jsx
index aa9677a50..dbf3b67e7 100644
--- a/web/src/hooks/dashboard/useDashboardStats.jsx
+++ b/web/src/hooks/dashboard/useDashboardStats.jsx
@@ -102,7 +102,7 @@ export const useDashboardStats = (
},
{
title: t('统计Tokens'),
- value: isNaN(consumeTokens) ? 0 : consumeTokens,
+ value: isNaN(consumeTokens) ? 0 : consumeTokens.toLocaleString(),
icon: ,
avatarColor: 'pink',
trendData: trendData.tokens,
From e3bc40f11b8bd3c57ca3435ba09af0b5b65a1c56 Mon Sep 17 00:00:00 2001
From: huanghejian
Date: Thu, 11 Sep 2025 12:17:16 +0800
Subject: [PATCH 018/165] pref: support amazon nova
---
relay/channel/aws/constants.go | 32 ++++++++++++++++++++++++++------
relay/channel/aws/dto.go | 1 +
relay/channel/aws/relay-aws.go | 6 ++++++
3 files changed, 33 insertions(+), 6 deletions(-)
diff --git a/relay/channel/aws/constants.go b/relay/channel/aws/constants.go
index 7f18d57a1..72d0f9890 100644
--- a/relay/channel/aws/constants.go
+++ b/relay/channel/aws/constants.go
@@ -17,10 +17,10 @@ var awsModelIDMap = map[string]string{
"claude-opus-4-20250514": "anthropic.claude-opus-4-20250514-v1:0",
"claude-opus-4-1-20250805": "anthropic.claude-opus-4-1-20250805-v1:0",
// Nova models
- "amazon.nova-micro-v1:0": "us.amazon.nova-micro-v1:0",
- "amazon.nova-lite-v1:0": "us.amazon.nova-lite-v1:0",
- "amazon.nova-pro-v1:0": "us.amazon.nova-pro-v1:0",
- "amazon.nova-premier-v1:0": "us.amazon.nova-premier-v1:0",
+ "nova-micro-v1:0": "amazon.nova-micro-v1:0",
+ "nova-lite-v1:0": "amazon.nova-lite-v1:0",
+ "nova-pro-v1:0": "amazon.nova-pro-v1:0",
+ "nova-premier-v1:0": "amazon.nova-premier-v1:0",
}
var awsModelCanCrossRegionMap = map[string]map[string]bool{
@@ -65,7 +65,27 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{
"anthropic.claude-opus-4-1-20250805-v1:0": {
"us": true,
},
-}
+ // Nova models - all support three major regions
+ "amazon.nova-micro-v1:0": {
+ "us": true,
+ "eu": true,
+ "apac": true,
+ },
+ "amazon.nova-lite-v1:0": {
+ "us": true,
+ "eu": true,
+ "apac": true,
+ },
+ "amazon.nova-pro-v1:0": {
+ "us": true,
+ "eu": true,
+ "apac": true,
+ },
+ "amazon.nova-premier-v1:0": {
+ "us": true,
+ "eu": true,
+ "apac": true,
+ }}
var awsRegionCrossModelPrefixMap = map[string]string{
"us": "us",
@@ -77,5 +97,5 @@ var ChannelName = "aws"
// 判断是否为Nova模型
func isNovaModel(modelId string) bool {
- return strings.HasPrefix(modelId, "amazon.nova-")
+ return strings.HasPrefix(modelId, "nova-")
}
diff --git a/relay/channel/aws/dto.go b/relay/channel/aws/dto.go
index cef16c11f..53daef288 100644
--- a/relay/channel/aws/dto.go
+++ b/relay/channel/aws/dto.go
@@ -76,6 +76,7 @@ func convertToNovaRequest(req *dto.GeneralOpenAIRequest) *NovaRequest {
// 设置推理配置
if req.MaxTokens != 0 || (req.Temperature != nil && *req.Temperature != 0) || req.TopP != 0 {
+ novaReq.InferenceConfig = &NovaInferenceConfig{}
if req.MaxTokens != 0 {
novaReq.InferenceConfig.MaxTokens = int(req.MaxTokens)
}
diff --git a/relay/channel/aws/relay-aws.go b/relay/channel/aws/relay-aws.go
index 3df6b33dd..eef26855a 100644
--- a/relay/channel/aws/relay-aws.go
+++ b/relay/channel/aws/relay-aws.go
@@ -97,6 +97,12 @@ func awsHandler(c *gin.Context, info *relaycommon.RelayInfo, requestMode int) (*
// 检查是否为Nova模型
isNova, _ := c.Get("is_nova_model")
if isNova == true {
+ // Nova模型也支持跨区域
+ awsRegionPrefix := awsRegionPrefix(awsCli.Options().Region)
+ canCrossRegion := awsModelCanCrossRegion(awsModelId, awsRegionPrefix)
+ if canCrossRegion {
+ awsModelId = awsModelCrossRegion(awsModelId, awsRegionPrefix)
+ }
return handleNovaRequest(c, awsCli, info, awsModelId)
}
From db6a788e0d4798c62922714a8e33d3f4780f095e Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Thu, 11 Sep 2025 12:28:57 +0800
Subject: [PATCH 019/165] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=20ImageReques?=
=?UTF-8?q?t=20=E7=9A=84=20JSON=20=E5=BA=8F=E5=88=97=E5=8C=96=EF=BC=8C?=
=?UTF-8?q?=E9=81=BF=E5=85=8D=E8=A6=86=E7=9B=96=E5=90=88=E5=B9=B6=20ExtraF?=
=?UTF-8?q?ields?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
dto/openai_image.go | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/dto/openai_image.go b/dto/openai_image.go
index bc888dc71..5aece25f2 100644
--- a/dto/openai_image.go
+++ b/dto/openai_image.go
@@ -64,19 +64,21 @@ func (r ImageRequest) MarshalJSON() ([]byte, error) {
// 将已定义字段转为 map
type Alias ImageRequest
alias := Alias(r)
- base, err := json.Marshal(alias)
+ base, err := common.Marshal(alias)
if err != nil {
return nil, err
}
var baseMap map[string]json.RawMessage
- if err := json.Unmarshal(base, &baseMap); err != nil {
+ if err := common.Unmarshal(base, &baseMap); err != nil {
return nil, err
}
// 合并 ExtraFields
for k, v := range r.Extra {
- baseMap[k] = v
+ if _, exists := baseMap[k]; !exists {
+ baseMap[k] = v
+ }
}
return json.Marshal(baseMap)
From 70c27bc662fd4edb6487261538208bc0a2e802a9 Mon Sep 17 00:00:00 2001
From: huanghejian
Date: Thu, 11 Sep 2025 12:31:43 +0800
Subject: [PATCH 020/165] feat: improve nova config
---
relay/channel/aws/dto.go | 35 ++++++++++++++++++++++++++++++++++-
1 file changed, 34 insertions(+), 1 deletion(-)
diff --git a/relay/channel/aws/dto.go b/relay/channel/aws/dto.go
index 53daef288..9c9fe946f 100644
--- a/relay/channel/aws/dto.go
+++ b/relay/channel/aws/dto.go
@@ -75,7 +75,7 @@ func convertToNovaRequest(req *dto.GeneralOpenAIRequest) *NovaRequest {
}
// 设置推理配置
- if req.MaxTokens != 0 || (req.Temperature != nil && *req.Temperature != 0) || req.TopP != 0 {
+ if req.MaxTokens != 0 || (req.Temperature != nil && *req.Temperature != 0) || req.TopP != 0 || req.TopK != 0 || req.Stop != nil {
novaReq.InferenceConfig = &NovaInferenceConfig{}
if req.MaxTokens != 0 {
novaReq.InferenceConfig.MaxTokens = int(req.MaxTokens)
@@ -86,7 +86,40 @@ func convertToNovaRequest(req *dto.GeneralOpenAIRequest) *NovaRequest {
if req.TopP != 0 {
novaReq.InferenceConfig.TopP = req.TopP
}
+ if req.TopK != 0 {
+ novaReq.InferenceConfig.TopK = req.TopK
+ }
+ if req.Stop != nil {
+ if stopSequences := parseStopSequences(req.Stop); len(stopSequences) > 0 {
+ novaReq.InferenceConfig.StopSequences = stopSequences
+ }
+ }
}
return novaReq
}
+
+// parseStopSequences 解析停止序列,支持字符串或字符串数组
+func parseStopSequences(stop any) []string {
+ if stop == nil {
+ return nil
+ }
+
+ switch v := stop.(type) {
+ case string:
+ if v != "" {
+ return []string{v}
+ }
+ case []string:
+ return v
+ case []interface{}:
+ var sequences []string
+ for _, item := range v {
+ if str, ok := item.(string); ok && str != "" {
+ sequences = append(sequences, str)
+ }
+ }
+ return sequences
+ }
+ return nil
+}
From b25ac0bfb69ba6a5f1bd3f352567c7c8ad9a8f9e Mon Sep 17 00:00:00 2001
From: Xyfacai
Date: Thu, 11 Sep 2025 16:04:32 +0800
Subject: [PATCH 021/165] =?UTF-8?q?fix:=20=E9=A2=84=E6=89=A3=E9=A2=9D?=
=?UTF-8?q?=E5=BA=A6=E4=BD=BF=E7=94=A8=20relay=20info=20=E4=BC=A0=E9=80=92?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
controller/relay.go | 6 +++---
service/pre_consume_quota.go | 22 +++++++++++-----------
2 files changed, 14 insertions(+), 14 deletions(-)
diff --git a/controller/relay.go b/controller/relay.go
index 07c3aeaac..23d725153 100644
--- a/controller/relay.go
+++ b/controller/relay.go
@@ -139,15 +139,15 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
// common.SetContextKey(c, constant.ContextKeyTokenCountMeta, meta)
- preConsumedQuota, newAPIError := service.PreConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo)
+ newAPIError = service.PreConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo)
if newAPIError != nil {
return
}
defer func() {
// Only return quota if downstream failed and quota was actually pre-consumed
- if newAPIError != nil && preConsumedQuota != 0 {
- service.ReturnPreConsumedQuota(c, relayInfo, preConsumedQuota)
+ if newAPIError != nil && relayInfo.FinalPreConsumedQuota != 0 {
+ service.ReturnPreConsumedQuota(c, relayInfo)
}
}()
diff --git a/service/pre_consume_quota.go b/service/pre_consume_quota.go
index 86b04e526..3cfabc1a4 100644
--- a/service/pre_consume_quota.go
+++ b/service/pre_consume_quota.go
@@ -13,13 +13,13 @@ import (
"github.com/gin-gonic/gin"
)
-func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo, preConsumedQuota int) {
- if preConsumedQuota != 0 {
- logger.LogInfo(c, fmt.Sprintf("用户 %d 请求失败, 返还预扣费额度 %s", relayInfo.UserId, logger.FormatQuota(preConsumedQuota)))
+func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo) {
+ if relayInfo.FinalPreConsumedQuota != 0 {
+ logger.LogInfo(c, fmt.Sprintf("用户 %d 请求失败, 返还预扣费额度 %s", relayInfo.UserId, logger.FormatQuota(relayInfo.FinalPreConsumedQuota)))
gopool.Go(func() {
relayInfoCopy := *relayInfo
- err := PostConsumeQuota(&relayInfoCopy, -preConsumedQuota, 0, false)
+ err := PostConsumeQuota(&relayInfoCopy, -relayInfo.FinalPreConsumedQuota, 0, false)
if err != nil {
common.SysLog("error return pre-consumed quota: " + err.Error())
}
@@ -29,16 +29,16 @@ func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo, pr
// PreConsumeQuota checks if the user has enough quota to pre-consume.
// It returns the pre-consumed quota if successful, or an error if not.
-func PreConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) (int, *types.NewAPIError) {
+func PreConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError {
userQuota, err := model.GetUserQuota(relayInfo.UserId, false)
if err != nil {
- return 0, types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
+ return types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
}
if userQuota <= 0 {
- return 0, types.NewErrorWithStatusCode(fmt.Errorf("用户额度不足, 剩余额度: %s", logger.FormatQuota(userQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
+ return types.NewErrorWithStatusCode(fmt.Errorf("用户额度不足, 剩余额度: %s", logger.FormatQuota(userQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
}
if userQuota-preConsumedQuota < 0 {
- return 0, types.NewErrorWithStatusCode(fmt.Errorf("预扣费额度失败, 用户剩余额度: %s, 需要预扣费额度: %s", logger.FormatQuota(userQuota), logger.FormatQuota(preConsumedQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
+ return types.NewErrorWithStatusCode(fmt.Errorf("预扣费额度失败, 用户剩余额度: %s, 需要预扣费额度: %s", logger.FormatQuota(userQuota), logger.FormatQuota(preConsumedQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
}
trustQuota := common.GetTrustQuota()
@@ -65,14 +65,14 @@ func PreConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommo
if preConsumedQuota > 0 {
err := PreConsumeTokenQuota(relayInfo, preConsumedQuota)
if err != nil {
- return 0, types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
+ return types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
}
err = model.DecreaseUserQuota(relayInfo.UserId, preConsumedQuota)
if err != nil {
- return 0, types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry())
+ return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry())
}
logger.LogInfo(c, fmt.Sprintf("用户 %d 预扣费 %s, 预扣费后剩余额度: %s", relayInfo.UserId, logger.FormatQuota(preConsumedQuota), logger.FormatQuota(userQuota-preConsumedQuota)))
}
relayInfo.FinalPreConsumedQuota = preConsumedQuota
- return preConsumedQuota, nil
+ return nil
}
From 93adcd57d7d851d90ee051e1daf8db7ea6b52655 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Thu, 11 Sep 2025 21:02:12 +0800
Subject: [PATCH 022/165] fix(responses): allow pass-through body for specific
channel settings. (close #1762)
---
relay/responses_handler.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/relay/responses_handler.go b/relay/responses_handler.go
index d1c5d2158..0c57a303f 100644
--- a/relay/responses_handler.go
+++ b/relay/responses_handler.go
@@ -41,7 +41,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
}
adaptor.Init(info)
var requestBody io.Reader
- if model_setting.GetGlobalSettings().PassThroughRequestEnabled {
+ if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled {
body, err := common.GetRequestBody(c)
if err != nil {
return types.NewError(err, types.ErrorCodeReadRequestBodyFailed, types.ErrOptionWithSkipRetry())
From b6c547ae982e83e34a1182578d68e3a8a9e86cf6 Mon Sep 17 00:00:00 2001
From: Zhaokun Zhang
Date: Thu, 11 Sep 2025 21:34:49 +0800
Subject: [PATCH 023/165] =?UTF-8?q?fix:=20UI=20=E6=9C=AA=E5=AF=B9=E9=BD=90?=
=?UTF-8?q?=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
web/src/pages/Setting/Operation/SettingsGeneral.jsx | 2 +-
web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/web/src/pages/Setting/Operation/SettingsGeneral.jsx b/web/src/pages/Setting/Operation/SettingsGeneral.jsx
index c94c0dd5a..37b3dd984 100644
--- a/web/src/pages/Setting/Operation/SettingsGeneral.jsx
+++ b/web/src/pages/Setting/Operation/SettingsGeneral.jsx
@@ -194,7 +194,7 @@ export default function GeneralSettings(props) {
/>
-
+
From e68eed3d400785401c74f5bb3db21fd8b2f27b6a Mon Sep 17 00:00:00 2001
From: CaIon
Date: Fri, 12 Sep 2025 14:06:09 +0800
Subject: [PATCH 024/165] feat(channel): add support for Vertex AI key type
configuration in settings
---
controller/channel.go | 7 +-
dto/channel_settings.go | 10 +-
model/channel.go | 3 +-
relay/channel/vertex/adaptor.go | 116 ++++++++-------
.../channels/modals/EditChannelModal.jsx | 133 +++++++++++-------
5 files changed, 166 insertions(+), 103 deletions(-)
diff --git a/controller/channel.go b/controller/channel.go
index 70be91d42..403eb04cc 100644
--- a/controller/channel.go
+++ b/controller/channel.go
@@ -6,6 +6,7 @@ import (
"net/http"
"one-api/common"
"one-api/constant"
+ "one-api/dto"
"one-api/model"
"strconv"
"strings"
@@ -560,7 +561,7 @@ func AddChannel(c *gin.Context) {
case "multi_to_single":
addChannelRequest.Channel.ChannelInfo.IsMultiKey = true
addChannelRequest.Channel.ChannelInfo.MultiKeyMode = addChannelRequest.MultiKeyMode
- if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi {
+ if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
array, err := getVertexArrayKeys(addChannelRequest.Channel.Key)
if err != nil {
c.JSON(http.StatusOK, gin.H{
@@ -585,7 +586,7 @@ func AddChannel(c *gin.Context) {
}
keys = []string{addChannelRequest.Channel.Key}
case "batch":
- if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi {
+ if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
// multi json
keys, err = getVertexArrayKeys(addChannelRequest.Channel.Key)
if err != nil {
@@ -840,7 +841,7 @@ func UpdateChannel(c *gin.Context) {
}
// 处理 Vertex AI 的特殊情况
- if channel.Type == constant.ChannelTypeVertexAi {
+ if channel.Type == constant.ChannelTypeVertexAi && channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
// 尝试解析新密钥为JSON数组
if strings.HasPrefix(strings.TrimSpace(channel.Key), "[") {
array, err := getVertexArrayKeys(channel.Key)
diff --git a/dto/channel_settings.go b/dto/channel_settings.go
index 2c58795cb..8791f516e 100644
--- a/dto/channel_settings.go
+++ b/dto/channel_settings.go
@@ -9,6 +9,14 @@ type ChannelSettings struct {
SystemPromptOverride bool `json:"system_prompt_override,omitempty"`
}
+type VertexKeyType string
+
+const (
+ VertexKeyTypeJSON VertexKeyType = "json"
+ VertexKeyTypeAPIKey VertexKeyType = "api_key"
+)
+
type ChannelOtherSettings struct {
- AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
+ AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
+ VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
}
diff --git a/model/channel.go b/model/channel.go
index a61b3eccf..534e2f3f2 100644
--- a/model/channel.go
+++ b/model/channel.go
@@ -42,7 +42,6 @@ type Channel struct {
Priority *int64 `json:"priority" gorm:"bigint;default:0"`
AutoBan *int `json:"auto_ban" gorm:"default:1"`
OtherInfo string `json:"other_info"`
- OtherSettings string `json:"settings" gorm:"column:settings"` // 其他设置
Tag *string `json:"tag" gorm:"index"`
Setting *string `json:"setting" gorm:"type:text"` // 渠道额外设置
ParamOverride *string `json:"param_override" gorm:"type:text"`
@@ -51,6 +50,8 @@ type Channel struct {
// add after v0.8.5
ChannelInfo ChannelInfo `json:"channel_info" gorm:"type:json"`
+ OtherSettings string `json:"settings" gorm:"column:settings"` // 其他设置,存储azure版本等不需要检索的信息,详见dto.ChannelOtherSettings
+
// cache info
Keys []string `json:"-" gorm:"-"`
}
diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go
index 0b6b26743..b6a78b7aa 100644
--- a/relay/channel/vertex/adaptor.go
+++ b/relay/channel/vertex/adaptor.go
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
+ "one-api/common"
"one-api/dto"
"one-api/relay/channel"
"one-api/relay/channel/claude"
@@ -80,16 +81,64 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
}
}
-func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
- adc := &Credentials{}
- if err := json.Unmarshal([]byte(info.ApiKey), adc); err != nil {
- return "", fmt.Errorf("failed to decode credentials file: %w", err)
- }
+func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix string) (string, error) {
region := GetModelRegion(info.ApiVersion, info.OriginModelName)
- a.AccountCredentials = *adc
+ if info.ChannelOtherSettings.VertexKeyType != dto.VertexKeyTypeAPIKey {
+ adc := &Credentials{}
+ if err := common.Unmarshal([]byte(info.ApiKey), adc); err != nil {
+ return "", fmt.Errorf("failed to decode credentials file: %w", err)
+ }
+ a.AccountCredentials = *adc
+
+ if a.RequestMode == RequestModeLlama {
+ return fmt.Sprintf(
+ "https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions",
+ region,
+ adc.ProjectID,
+ region,
+ ), nil
+ }
+
+ if region == "global" {
+ return fmt.Sprintf(
+ "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s",
+ adc.ProjectID,
+ modelName,
+ suffix,
+ ), nil
+ } else {
+ return fmt.Sprintf(
+ "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s",
+ region,
+ adc.ProjectID,
+ region,
+ modelName,
+ suffix,
+ ), nil
+ }
+ } else {
+ if region == "global" {
+ return fmt.Sprintf(
+ "https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s",
+ modelName,
+ suffix,
+ info.ApiKey,
+ ), nil
+ } else {
+ return fmt.Sprintf(
+ "https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s",
+ region,
+ modelName,
+ suffix,
+ info.ApiKey,
+ ), nil
+ }
+ }
+}
+
+func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
suffix := ""
if a.RequestMode == RequestModeGemini {
-
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
// 新增逻辑:处理 -thinking- 格式
if strings.Contains(info.UpstreamModelName, "-thinking-") {
@@ -112,23 +161,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
suffix = "predict"
}
- if region == "global" {
- return fmt.Sprintf(
- "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s",
- adc.ProjectID,
- info.UpstreamModelName,
- suffix,
- ), nil
- } else {
- return fmt.Sprintf(
- "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s",
- region,
- adc.ProjectID,
- region,
- info.UpstreamModelName,
- suffix,
- ), nil
- }
+ return a.getRequestUrl(info, info.UpstreamModelName, suffix)
} else if a.RequestMode == RequestModeClaude {
if info.IsStream {
suffix = "streamRawPredict?alt=sse"
@@ -139,41 +172,22 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if v, ok := claudeModelMap[info.UpstreamModelName]; ok {
model = v
}
- if region == "global" {
- return fmt.Sprintf(
- "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/anthropic/models/%s:%s",
- adc.ProjectID,
- model,
- suffix,
- ), nil
- } else {
- return fmt.Sprintf(
- "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:%s",
- region,
- adc.ProjectID,
- region,
- model,
- suffix,
- ), nil
- }
+ return a.getRequestUrl(info, model, suffix)
} else if a.RequestMode == RequestModeLlama {
- return fmt.Sprintf(
- "https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions",
- region,
- adc.ProjectID,
- region,
- ), nil
+ return a.getRequestUrl(info, "", "")
}
return "", errors.New("unsupported request mode")
}
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
channel.SetupApiRequestHeader(info, c, req)
- accessToken, err := getAccessToken(a, info)
- if err != nil {
- return err
+ if info.ChannelOtherSettings.VertexKeyType == "json" {
+ accessToken, err := getAccessToken(a, info)
+ if err != nil {
+ return err
+ }
+ req.Set("Authorization", "Bearer "+accessToken)
}
- req.Set("Authorization", "Bearer "+accessToken)
return nil
}
diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx
index 7a86fa114..c0a216246 100644
--- a/web/src/components/table/channels/modals/EditChannelModal.jsx
+++ b/web/src/components/table/channels/modals/EditChannelModal.jsx
@@ -142,6 +142,8 @@ const EditChannelModal = (props) => {
system_prompt: '',
system_prompt_override: false,
settings: '',
+ // 仅 Vertex: 密钥格式(存入 settings.vertex_key_type)
+ vertex_key_type: 'json',
};
const [batch, setBatch] = useState(false);
const [multiToSingle, setMultiToSingle] = useState(false);
@@ -409,11 +411,17 @@ const EditChannelModal = (props) => {
const parsedSettings = JSON.parse(data.settings);
data.azure_responses_version =
parsedSettings.azure_responses_version || '';
+ // 读取 Vertex 密钥格式
+ data.vertex_key_type = parsedSettings.vertex_key_type || 'json';
} catch (error) {
console.error('解析其他设置失败:', error);
data.azure_responses_version = '';
data.region = '';
+ data.vertex_key_type = 'json';
}
+ } else {
+ // 兼容历史数据:老渠道没有 settings 时,默认按 json 展示
+ data.vertex_key_type = 'json';
}
setInputs(data);
@@ -745,59 +753,56 @@ const EditChannelModal = (props) => {
let localInputs = { ...formValues };
if (localInputs.type === 41) {
- if (useManualInput) {
- // 手动输入模式
- if (localInputs.key && localInputs.key.trim() !== '') {
- try {
- // 验证 JSON 格式
- const parsedKey = JSON.parse(localInputs.key);
- // 确保是有效的密钥格式
- localInputs.key = JSON.stringify(parsedKey);
- } catch (err) {
- showError(t('密钥格式无效,请输入有效的 JSON 格式密钥'));
- return;
- }
- } else if (!isEdit) {
+ const keyType = localInputs.vertex_key_type || 'json';
+ if (keyType === 'api_key') {
+ // 直接作为普通字符串密钥处理
+ if (!isEdit && (!localInputs.key || localInputs.key.trim() === '')) {
showInfo(t('请输入密钥!'));
return;
}
} else {
- // 文件上传模式
- let keys = vertexKeys;
-
- // 若当前未选择文件,尝试从已上传文件列表解析(异步读取)
- if (keys.length === 0 && vertexFileList.length > 0) {
- try {
- const parsed = await Promise.all(
- vertexFileList.map(async (item) => {
- const fileObj = item.fileInstance;
- if (!fileObj) return null;
- const txt = await fileObj.text();
- return JSON.parse(txt);
- }),
- );
- keys = parsed.filter(Boolean);
- } catch (err) {
- showError(t('解析密钥文件失败: {{msg}}', { msg: err.message }));
+ // JSON 服务账号密钥
+ if (useManualInput) {
+ if (localInputs.key && localInputs.key.trim() !== '') {
+ try {
+ const parsedKey = JSON.parse(localInputs.key);
+ localInputs.key = JSON.stringify(parsedKey);
+ } catch (err) {
+ showError(t('密钥格式无效,请输入有效的 JSON 格式密钥'));
+ return;
+ }
+ } else if (!isEdit) {
+ showInfo(t('请输入密钥!'));
return;
}
- }
-
- // 创建模式必须上传密钥;编辑模式可选
- if (keys.length === 0) {
- if (!isEdit) {
- showInfo(t('请上传密钥文件!'));
- return;
- } else {
- // 编辑模式且未上传新密钥,不修改 key
- delete localInputs.key;
- }
} else {
- // 有新密钥,则覆盖
- if (batch) {
- localInputs.key = JSON.stringify(keys);
+ // 文件上传模式
+ let keys = vertexKeys;
+ if (keys.length === 0 && vertexFileList.length > 0) {
+ try {
+ const parsed = await Promise.all(
+ vertexFileList.map(async (item) => {
+ const fileObj = item.fileInstance;
+ if (!fileObj) return null;
+ const txt = await fileObj.text();
+ return JSON.parse(txt);
+ }),
+ );
+ keys = parsed.filter(Boolean);
+ } catch (err) {
+ showError(t('解析密钥文件失败: {{msg}}', { msg: err.message }));
+ return;
+ }
+ }
+ if (keys.length === 0) {
+ if (!isEdit) {
+ showInfo(t('请上传密钥文件!'));
+ return;
+ } else {
+ delete localInputs.key;
+ }
} else {
- localInputs.key = JSON.stringify(keys[0]);
+ localInputs.key = batch ? JSON.stringify(keys) : JSON.stringify(keys[0]);
}
}
}
@@ -853,6 +858,8 @@ const EditChannelModal = (props) => {
delete localInputs.pass_through_body_enabled;
delete localInputs.system_prompt;
delete localInputs.system_prompt_override;
+ // 顶层的 vertex_key_type 不应发送给后端
+ delete localInputs.vertex_key_type;
let res;
localInputs.auto_ban = localInputs.auto_ban ? 1 : 0;
@@ -1178,8 +1185,40 @@ const EditChannelModal = (props) => {
autoComplete='new-password'
/>
+ {inputs.type === 41 && (
+ {
+ // 更新设置中的 vertex_key_type
+ handleChannelOtherSettingsChange('vertex_key_type', value);
+ // 切换为 api_key 时,关闭批量与手动/文件切换,并清理已选文件
+ if (value === 'api_key') {
+ setBatch(false);
+ setUseManualInput(false);
+ setVertexKeys([]);
+ setVertexFileList([]);
+ if (formApiRef.current) {
+ formApiRef.current.setValue('vertex_files', []);
+ }
+ }
+ }}
+ extraText={
+ inputs.vertex_key_type === 'api_key'
+ ? t('API Key 模式下不支持批量创建')
+ : t('JSON 模式支持手动输入或上传服务账号 JSON')
+ }
+ />
+ )}
{batch ? (
- inputs.type === 41 ? (
+ inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? (
{
)
) : (
<>
- {inputs.type === 41 ? (
+ {inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? (
<>
{!batch && (
From d8410d2f11fdce79376531b1d752552efd17283f Mon Sep 17 00:00:00 2001
From: CaIon
Date: Fri, 12 Sep 2025 19:11:17 +0800
Subject: [PATCH 025/165] feat(payment): add payment settings configuration and
update payment methods handling
---
controller/channel-billing.go | 4 +-
controller/channel.go | 7 +-
controller/misc.go | 12 +-
controller/topup.go | 63 +++++-
controller/topup_stripe.go | 11 +-
dto/channel_settings.go | 10 +-
model/channel.go | 3 +-
model/option.go | 24 +--
relay/channel/vertex/adaptor.go | 116 ++++++-----
router/api-router.go | 1 +
service/epay.go | 5 +-
setting/operation_setting/payment_setting.go | 23 +++
.../payment_setting_old.go} | 21 +-
.../components/settings/PaymentSetting.jsx | 26 +++
.../channels/modals/EditChannelModal.jsx | 133 ++++++++-----
web/src/components/topup/RechargeCard.jsx | 182 +++++++++++-------
web/src/components/topup/index.jsx | 173 +++++++++++------
.../topup/modals/PaymentConfirmModal.jsx | 39 +++-
web/src/helpers/data.js | 1 -
.../Payment/SettingsPaymentGateway.jsx | 76 ++++++++
20 files changed, 655 insertions(+), 275 deletions(-)
create mode 100644 setting/operation_setting/payment_setting.go
rename setting/{payment.go => operation_setting/payment_setting_old.go} (57%)
diff --git a/controller/channel-billing.go b/controller/channel-billing.go
index 18acf2319..1082b9e73 100644
--- a/controller/channel-billing.go
+++ b/controller/channel-billing.go
@@ -10,7 +10,7 @@ import (
"one-api/constant"
"one-api/model"
"one-api/service"
- "one-api/setting"
+ "one-api/setting/operation_setting"
"one-api/types"
"strconv"
"time"
@@ -342,7 +342,7 @@ func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) {
return 0, fmt.Errorf("failed to update moonshot balance, status: %v, code: %d, scode: %s", response.Status, response.Code, response.Scode)
}
availableBalanceCny := response.Data.AvailableBalance
- availableBalanceUsd := decimal.NewFromFloat(availableBalanceCny).Div(decimal.NewFromFloat(setting.Price)).InexactFloat64()
+ availableBalanceUsd := decimal.NewFromFloat(availableBalanceCny).Div(decimal.NewFromFloat(operation_setting.Price)).InexactFloat64()
channel.UpdateBalance(availableBalanceUsd)
return availableBalanceUsd, nil
}
diff --git a/controller/channel.go b/controller/channel.go
index 70be91d42..403eb04cc 100644
--- a/controller/channel.go
+++ b/controller/channel.go
@@ -6,6 +6,7 @@ import (
"net/http"
"one-api/common"
"one-api/constant"
+ "one-api/dto"
"one-api/model"
"strconv"
"strings"
@@ -560,7 +561,7 @@ func AddChannel(c *gin.Context) {
case "multi_to_single":
addChannelRequest.Channel.ChannelInfo.IsMultiKey = true
addChannelRequest.Channel.ChannelInfo.MultiKeyMode = addChannelRequest.MultiKeyMode
- if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi {
+ if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
array, err := getVertexArrayKeys(addChannelRequest.Channel.Key)
if err != nil {
c.JSON(http.StatusOK, gin.H{
@@ -585,7 +586,7 @@ func AddChannel(c *gin.Context) {
}
keys = []string{addChannelRequest.Channel.Key}
case "batch":
- if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi {
+ if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
// multi json
keys, err = getVertexArrayKeys(addChannelRequest.Channel.Key)
if err != nil {
@@ -840,7 +841,7 @@ func UpdateChannel(c *gin.Context) {
}
// 处理 Vertex AI 的特殊情况
- if channel.Type == constant.ChannelTypeVertexAi {
+ if channel.Type == constant.ChannelTypeVertexAi && channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
// 尝试解析新密钥为JSON数组
if strings.HasPrefix(strings.TrimSpace(channel.Key), "[") {
array, err := getVertexArrayKeys(channel.Key)
diff --git a/controller/misc.go b/controller/misc.go
index 897dad254..085829302 100644
--- a/controller/misc.go
+++ b/controller/misc.go
@@ -59,10 +59,6 @@ func GetStatus(c *gin.Context) {
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"wechat_login": common.WeChatAuthEnabled,
"server_address": setting.ServerAddress,
- "price": setting.Price,
- "stripe_unit_price": setting.StripeUnitPrice,
- "min_topup": setting.MinTopUp,
- "stripe_min_topup": setting.StripeMinTopUp,
"turnstile_check": common.TurnstileCheckEnabled,
"turnstile_site_key": common.TurnstileSiteKey,
"top_up_link": common.TopUpLink,
@@ -75,15 +71,15 @@ func GetStatus(c *gin.Context) {
"enable_data_export": common.DataExportEnabled,
"data_export_default_time": common.DataExportDefaultTime,
"default_collapse_sidebar": common.DefaultCollapseSidebar,
- "enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "",
- "enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
"mj_notify_enabled": setting.MjNotifyEnabled,
"chats": setting.Chats,
"demo_site_enabled": operation_setting.DemoSiteEnabled,
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
"default_use_auto_group": setting.DefaultUseAutoGroup,
- "pay_methods": setting.PayMethods,
- "usd_exchange_rate": setting.USDExchangeRate,
+
+ "usd_exchange_rate": operation_setting.USDExchangeRate,
+ "price": operation_setting.Price,
+ "stripe_unit_price": setting.StripeUnitPrice,
// 面板启用开关
"api_info_enabled": cs.ApiInfoEnabled,
diff --git a/controller/topup.go b/controller/topup.go
index 3f3c86231..93f3e58e0 100644
--- a/controller/topup.go
+++ b/controller/topup.go
@@ -9,6 +9,7 @@ import (
"one-api/model"
"one-api/service"
"one-api/setting"
+ "one-api/setting/operation_setting"
"strconv"
"sync"
"time"
@@ -19,6 +20,44 @@ import (
"github.com/shopspring/decimal"
)
+func GetTopUpInfo(c *gin.Context) {
+ // 获取支付方式
+ payMethods := operation_setting.PayMethods
+
+ // 如果启用了 Stripe 支付,添加到支付方法列表
+ if setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "" {
+ // 检查是否已经包含 Stripe
+ hasStripe := false
+ for _, method := range payMethods {
+ if method["type"] == "stripe" {
+ hasStripe = true
+ break
+ }
+ }
+
+ if !hasStripe {
+ stripeMethod := map[string]string{
+ "name": "Stripe",
+ "type": "stripe",
+ "color": "rgba(var(--semi-purple-5), 1)",
+ "min_topup": strconv.Itoa(setting.StripeMinTopUp),
+ }
+ payMethods = append(payMethods, stripeMethod)
+ }
+ }
+
+ data := gin.H{
+ "enable_online_topup": operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "",
+ "enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
+ "pay_methods": payMethods,
+ "min_topup": operation_setting.MinTopUp,
+ "stripe_min_topup": setting.StripeMinTopUp,
+ "amount_options": operation_setting.GetPaymentSetting().AmountOptions,
+ "discount": operation_setting.GetPaymentSetting().AmountDiscount,
+ }
+ common.ApiSuccess(c, data)
+}
+
type EpayRequest struct {
Amount int64 `json:"amount"`
PaymentMethod string `json:"payment_method"`
@@ -31,13 +70,13 @@ type AmountRequest struct {
}
func GetEpayClient() *epay.Client {
- if setting.PayAddress == "" || setting.EpayId == "" || setting.EpayKey == "" {
+ if operation_setting.PayAddress == "" || operation_setting.EpayId == "" || operation_setting.EpayKey == "" {
return nil
}
withUrl, err := epay.NewClient(&epay.Config{
- PartnerID: setting.EpayId,
- Key: setting.EpayKey,
- }, setting.PayAddress)
+ PartnerID: operation_setting.EpayId,
+ Key: operation_setting.EpayKey,
+ }, operation_setting.PayAddress)
if err != nil {
return nil
}
@@ -58,15 +97,23 @@ func getPayMoney(amount int64, group string) float64 {
}
dTopupGroupRatio := decimal.NewFromFloat(topupGroupRatio)
- dPrice := decimal.NewFromFloat(setting.Price)
+ dPrice := decimal.NewFromFloat(operation_setting.Price)
+ // apply optional preset discount by the original request amount (if configured), default 1.0
+ discount := 1.0
+ if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(amount)]; ok {
+ if ds > 0 {
+ discount = ds
+ }
+ }
+ dDiscount := decimal.NewFromFloat(discount)
- payMoney := dAmount.Mul(dPrice).Mul(dTopupGroupRatio)
+ payMoney := dAmount.Mul(dPrice).Mul(dTopupGroupRatio).Mul(dDiscount)
return payMoney.InexactFloat64()
}
func getMinTopup() int64 {
- minTopup := setting.MinTopUp
+ minTopup := operation_setting.MinTopUp
if !common.DisplayInCurrencyEnabled {
dMinTopup := decimal.NewFromInt(int64(minTopup))
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
@@ -99,7 +146,7 @@ func RequestEpay(c *gin.Context) {
return
}
- if !setting.ContainsPayMethod(req.PaymentMethod) {
+ if !operation_setting.ContainsPayMethod(req.PaymentMethod) {
c.JSON(200, gin.H{"message": "error", "data": "支付方式不存在"})
return
}
diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go
index eb3208092..bf0d7bf36 100644
--- a/controller/topup_stripe.go
+++ b/controller/topup_stripe.go
@@ -8,6 +8,7 @@ import (
"one-api/common"
"one-api/model"
"one-api/setting"
+ "one-api/setting/operation_setting"
"strconv"
"strings"
"time"
@@ -254,6 +255,7 @@ func GetChargedAmount(count float64, user model.User) float64 {
}
func getStripePayMoney(amount float64, group string) float64 {
+ originalAmount := amount
if !common.DisplayInCurrencyEnabled {
amount = amount / common.QuotaPerUnit
}
@@ -262,7 +264,14 @@ func getStripePayMoney(amount float64, group string) float64 {
if topupGroupRatio == 0 {
topupGroupRatio = 1
}
- payMoney := amount * setting.StripeUnitPrice * topupGroupRatio
+ // apply optional preset discount by the original request amount (if configured), default 1.0
+ discount := 1.0
+ if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(originalAmount)]; ok {
+ if ds > 0 {
+ discount = ds
+ }
+ }
+ payMoney := amount * setting.StripeUnitPrice * topupGroupRatio * discount
return payMoney
}
diff --git a/dto/channel_settings.go b/dto/channel_settings.go
index 2c58795cb..8791f516e 100644
--- a/dto/channel_settings.go
+++ b/dto/channel_settings.go
@@ -9,6 +9,14 @@ type ChannelSettings struct {
SystemPromptOverride bool `json:"system_prompt_override,omitempty"`
}
+type VertexKeyType string
+
+const (
+ VertexKeyTypeJSON VertexKeyType = "json"
+ VertexKeyTypeAPIKey VertexKeyType = "api_key"
+)
+
type ChannelOtherSettings struct {
- AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
+ AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
+ VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
}
diff --git a/model/channel.go b/model/channel.go
index a61b3eccf..534e2f3f2 100644
--- a/model/channel.go
+++ b/model/channel.go
@@ -42,7 +42,6 @@ type Channel struct {
Priority *int64 `json:"priority" gorm:"bigint;default:0"`
AutoBan *int `json:"auto_ban" gorm:"default:1"`
OtherInfo string `json:"other_info"`
- OtherSettings string `json:"settings" gorm:"column:settings"` // 其他设置
Tag *string `json:"tag" gorm:"index"`
Setting *string `json:"setting" gorm:"type:text"` // 渠道额外设置
ParamOverride *string `json:"param_override" gorm:"type:text"`
@@ -51,6 +50,8 @@ type Channel struct {
// add after v0.8.5
ChannelInfo ChannelInfo `json:"channel_info" gorm:"type:json"`
+ OtherSettings string `json:"settings" gorm:"column:settings"` // 其他设置,存储azure版本等不需要检索的信息,详见dto.ChannelOtherSettings
+
// cache info
Keys []string `json:"-" gorm:"-"`
}
diff --git a/model/option.go b/model/option.go
index 2121710ce..73fe92ad1 100644
--- a/model/option.go
+++ b/model/option.go
@@ -73,9 +73,9 @@ func InitOptionMap() {
common.OptionMap["CustomCallbackAddress"] = ""
common.OptionMap["EpayId"] = ""
common.OptionMap["EpayKey"] = ""
- common.OptionMap["Price"] = strconv.FormatFloat(setting.Price, 'f', -1, 64)
- common.OptionMap["USDExchangeRate"] = strconv.FormatFloat(setting.USDExchangeRate, 'f', -1, 64)
- common.OptionMap["MinTopUp"] = strconv.Itoa(setting.MinTopUp)
+ common.OptionMap["Price"] = strconv.FormatFloat(operation_setting.Price, 'f', -1, 64)
+ common.OptionMap["USDExchangeRate"] = strconv.FormatFloat(operation_setting.USDExchangeRate, 'f', -1, 64)
+ common.OptionMap["MinTopUp"] = strconv.Itoa(operation_setting.MinTopUp)
common.OptionMap["StripeMinTopUp"] = strconv.Itoa(setting.StripeMinTopUp)
common.OptionMap["StripeApiSecret"] = setting.StripeApiSecret
common.OptionMap["StripeWebhookSecret"] = setting.StripeWebhookSecret
@@ -85,7 +85,7 @@ func InitOptionMap() {
common.OptionMap["Chats"] = setting.Chats2JsonString()
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
common.OptionMap["DefaultUseAutoGroup"] = strconv.FormatBool(setting.DefaultUseAutoGroup)
- common.OptionMap["PayMethods"] = setting.PayMethods2JsonString()
+ common.OptionMap["PayMethods"] = operation_setting.PayMethods2JsonString()
common.OptionMap["GitHubClientId"] = ""
common.OptionMap["GitHubClientSecret"] = ""
common.OptionMap["TelegramBotToken"] = ""
@@ -299,23 +299,23 @@ func updateOptionMap(key string, value string) (err error) {
case "WorkerValidKey":
setting.WorkerValidKey = value
case "PayAddress":
- setting.PayAddress = value
+ operation_setting.PayAddress = value
case "Chats":
err = setting.UpdateChatsByJsonString(value)
case "AutoGroups":
err = setting.UpdateAutoGroupsByJsonString(value)
case "CustomCallbackAddress":
- setting.CustomCallbackAddress = value
+ operation_setting.CustomCallbackAddress = value
case "EpayId":
- setting.EpayId = value
+ operation_setting.EpayId = value
case "EpayKey":
- setting.EpayKey = value
+ operation_setting.EpayKey = value
case "Price":
- setting.Price, _ = strconv.ParseFloat(value, 64)
+ operation_setting.Price, _ = strconv.ParseFloat(value, 64)
case "USDExchangeRate":
- setting.USDExchangeRate, _ = strconv.ParseFloat(value, 64)
+ operation_setting.USDExchangeRate, _ = strconv.ParseFloat(value, 64)
case "MinTopUp":
- setting.MinTopUp, _ = strconv.Atoi(value)
+ operation_setting.MinTopUp, _ = strconv.Atoi(value)
case "StripeApiSecret":
setting.StripeApiSecret = value
case "StripeWebhookSecret":
@@ -413,7 +413,7 @@ func updateOptionMap(key string, value string) (err error) {
case "StreamCacheQueueLength":
setting.StreamCacheQueueLength, _ = strconv.Atoi(value)
case "PayMethods":
- err = setting.UpdatePayMethodsByJsonString(value)
+ err = operation_setting.UpdatePayMethodsByJsonString(value)
}
return err
}
diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go
index 0b6b26743..b6a78b7aa 100644
--- a/relay/channel/vertex/adaptor.go
+++ b/relay/channel/vertex/adaptor.go
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
+ "one-api/common"
"one-api/dto"
"one-api/relay/channel"
"one-api/relay/channel/claude"
@@ -80,16 +81,64 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
}
}
-func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
- adc := &Credentials{}
- if err := json.Unmarshal([]byte(info.ApiKey), adc); err != nil {
- return "", fmt.Errorf("failed to decode credentials file: %w", err)
- }
+func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix string) (string, error) {
region := GetModelRegion(info.ApiVersion, info.OriginModelName)
- a.AccountCredentials = *adc
+ if info.ChannelOtherSettings.VertexKeyType != dto.VertexKeyTypeAPIKey {
+ adc := &Credentials{}
+ if err := common.Unmarshal([]byte(info.ApiKey), adc); err != nil {
+ return "", fmt.Errorf("failed to decode credentials file: %w", err)
+ }
+ a.AccountCredentials = *adc
+
+ if a.RequestMode == RequestModeLlama {
+ return fmt.Sprintf(
+ "https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions",
+ region,
+ adc.ProjectID,
+ region,
+ ), nil
+ }
+
+ if region == "global" {
+ return fmt.Sprintf(
+ "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s",
+ adc.ProjectID,
+ modelName,
+ suffix,
+ ), nil
+ } else {
+ return fmt.Sprintf(
+ "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s",
+ region,
+ adc.ProjectID,
+ region,
+ modelName,
+ suffix,
+ ), nil
+ }
+ } else {
+ if region == "global" {
+ return fmt.Sprintf(
+ "https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s",
+ modelName,
+ suffix,
+ info.ApiKey,
+ ), nil
+ } else {
+ return fmt.Sprintf(
+ "https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s",
+ region,
+ modelName,
+ suffix,
+ info.ApiKey,
+ ), nil
+ }
+ }
+}
+
+func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
suffix := ""
if a.RequestMode == RequestModeGemini {
-
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
// 新增逻辑:处理 -thinking- 格式
if strings.Contains(info.UpstreamModelName, "-thinking-") {
@@ -112,23 +161,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
suffix = "predict"
}
- if region == "global" {
- return fmt.Sprintf(
- "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s",
- adc.ProjectID,
- info.UpstreamModelName,
- suffix,
- ), nil
- } else {
- return fmt.Sprintf(
- "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s",
- region,
- adc.ProjectID,
- region,
- info.UpstreamModelName,
- suffix,
- ), nil
- }
+ return a.getRequestUrl(info, info.UpstreamModelName, suffix)
} else if a.RequestMode == RequestModeClaude {
if info.IsStream {
suffix = "streamRawPredict?alt=sse"
@@ -139,41 +172,22 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if v, ok := claudeModelMap[info.UpstreamModelName]; ok {
model = v
}
- if region == "global" {
- return fmt.Sprintf(
- "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/anthropic/models/%s:%s",
- adc.ProjectID,
- model,
- suffix,
- ), nil
- } else {
- return fmt.Sprintf(
- "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:%s",
- region,
- adc.ProjectID,
- region,
- model,
- suffix,
- ), nil
- }
+ return a.getRequestUrl(info, model, suffix)
} else if a.RequestMode == RequestModeLlama {
- return fmt.Sprintf(
- "https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions",
- region,
- adc.ProjectID,
- region,
- ), nil
+ return a.getRequestUrl(info, "", "")
}
return "", errors.New("unsupported request mode")
}
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
channel.SetupApiRequestHeader(info, c, req)
- accessToken, err := getAccessToken(a, info)
- if err != nil {
- return err
+ if info.ChannelOtherSettings.VertexKeyType == "json" {
+ accessToken, err := getAccessToken(a, info)
+ if err != nil {
+ return err
+ }
+ req.Set("Authorization", "Bearer "+accessToken)
}
- req.Set("Authorization", "Bearer "+accessToken)
return nil
}
diff --git a/router/api-router.go b/router/api-router.go
index 773857385..e16d06628 100644
--- a/router/api-router.go
+++ b/router/api-router.go
@@ -60,6 +60,7 @@ func SetApiRouter(router *gin.Engine) {
selfRoute.DELETE("/self", controller.DeleteSelf)
selfRoute.GET("/token", controller.GenerateAccessToken)
selfRoute.GET("/aff", controller.GetAffCode)
+ selfRoute.GET("/topup/info", controller.GetTopUpInfo)
selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp)
selfRoute.POST("/pay", middleware.CriticalRateLimit(), controller.RequestEpay)
selfRoute.POST("/amount", controller.RequestAmount)
diff --git a/service/epay.go b/service/epay.go
index a8259d21d..a1ff484e6 100644
--- a/service/epay.go
+++ b/service/epay.go
@@ -2,11 +2,12 @@ package service
import (
"one-api/setting"
+ "one-api/setting/operation_setting"
)
func GetCallbackAddress() string {
- if setting.CustomCallbackAddress == "" {
+ if operation_setting.CustomCallbackAddress == "" {
return setting.ServerAddress
}
- return setting.CustomCallbackAddress
+ return operation_setting.CustomCallbackAddress
}
diff --git a/setting/operation_setting/payment_setting.go b/setting/operation_setting/payment_setting.go
new file mode 100644
index 000000000..c8df039cf
--- /dev/null
+++ b/setting/operation_setting/payment_setting.go
@@ -0,0 +1,23 @@
+package operation_setting
+
+import "one-api/setting/config"
+
+type PaymentSetting struct {
+ AmountOptions []int `json:"amount_options"`
+ AmountDiscount map[int]float64 `json:"amount_discount"` // 充值金额对应的折扣,例如 100 元 0.9 表示 100 元充值享受 9 折优惠
+}
+
+// 默认配置
+var paymentSetting = PaymentSetting{
+ AmountOptions: []int{10, 20, 50, 100, 200, 500},
+ AmountDiscount: map[int]float64{},
+}
+
+func init() {
+ // 注册到全局配置管理器
+ config.GlobalConfig.Register("payment_setting", &paymentSetting)
+}
+
+func GetPaymentSetting() *PaymentSetting {
+ return &paymentSetting
+}
diff --git a/setting/payment.go b/setting/operation_setting/payment_setting_old.go
similarity index 57%
rename from setting/payment.go
rename to setting/operation_setting/payment_setting_old.go
index 7fc5ad3fd..a6313179e 100644
--- a/setting/payment.go
+++ b/setting/operation_setting/payment_setting_old.go
@@ -1,6 +1,13 @@
-package setting
+/**
+此文件为旧版支付设置文件,如需增加新的参数、变量等,请在 payment_setting.go 中添加
+This file is the old version of the payment settings file. If you need to add new parameters, variables, etc., please add them in payment_setting.go
+*/
-import "encoding/json"
+package operation_setting
+
+import (
+ "one-api/common"
+)
var PayAddress = ""
var CustomCallbackAddress = ""
@@ -21,15 +28,21 @@ var PayMethods = []map[string]string{
"color": "rgba(var(--semi-green-5), 1)",
"type": "wxpay",
},
+ {
+ "name": "自定义1",
+ "color": "black",
+ "type": "custom1",
+ "min_topup": "50",
+ },
}
func UpdatePayMethodsByJsonString(jsonString string) error {
PayMethods = make([]map[string]string, 0)
- return json.Unmarshal([]byte(jsonString), &PayMethods)
+ return common.Unmarshal([]byte(jsonString), &PayMethods)
}
func PayMethods2JsonString() string {
- jsonBytes, err := json.Marshal(PayMethods)
+ jsonBytes, err := common.Marshal(PayMethods)
if err != nil {
return "[]"
}
diff --git a/web/src/components/settings/PaymentSetting.jsx b/web/src/components/settings/PaymentSetting.jsx
index a632760aa..faaa9561b 100644
--- a/web/src/components/settings/PaymentSetting.jsx
+++ b/web/src/components/settings/PaymentSetting.jsx
@@ -37,6 +37,8 @@ const PaymentSetting = () => {
TopupGroupRatio: '',
CustomCallbackAddress: '',
PayMethods: '',
+ AmountOptions: '',
+ AmountDiscount: '',
StripeApiSecret: '',
StripeWebhookSecret: '',
@@ -66,6 +68,30 @@ const PaymentSetting = () => {
newInputs[item.key] = item.value;
}
break;
+ case 'payment_setting.amount_options':
+ try {
+ newInputs['AmountOptions'] = JSON.stringify(
+ JSON.parse(item.value),
+ null,
+ 2,
+ );
+ } catch (error) {
+ console.error('解析AmountOptions出错:', error);
+ newInputs['AmountOptions'] = item.value;
+ }
+ break;
+ case 'payment_setting.amount_discount':
+ try {
+ newInputs['AmountDiscount'] = JSON.stringify(
+ JSON.parse(item.value),
+ null,
+ 2,
+ );
+ } catch (error) {
+ console.error('解析AmountDiscount出错:', error);
+ newInputs['AmountDiscount'] = item.value;
+ }
+ break;
case 'Price':
case 'MinTopUp':
case 'StripeUnitPrice':
diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx
index 7a86fa114..c0a216246 100644
--- a/web/src/components/table/channels/modals/EditChannelModal.jsx
+++ b/web/src/components/table/channels/modals/EditChannelModal.jsx
@@ -142,6 +142,8 @@ const EditChannelModal = (props) => {
system_prompt: '',
system_prompt_override: false,
settings: '',
+ // 仅 Vertex: 密钥格式(存入 settings.vertex_key_type)
+ vertex_key_type: 'json',
};
const [batch, setBatch] = useState(false);
const [multiToSingle, setMultiToSingle] = useState(false);
@@ -409,11 +411,17 @@ const EditChannelModal = (props) => {
const parsedSettings = JSON.parse(data.settings);
data.azure_responses_version =
parsedSettings.azure_responses_version || '';
+ // 读取 Vertex 密钥格式
+ data.vertex_key_type = parsedSettings.vertex_key_type || 'json';
} catch (error) {
console.error('解析其他设置失败:', error);
data.azure_responses_version = '';
data.region = '';
+ data.vertex_key_type = 'json';
}
+ } else {
+ // 兼容历史数据:老渠道没有 settings 时,默认按 json 展示
+ data.vertex_key_type = 'json';
}
setInputs(data);
@@ -745,59 +753,56 @@ const EditChannelModal = (props) => {
let localInputs = { ...formValues };
if (localInputs.type === 41) {
- if (useManualInput) {
- // 手动输入模式
- if (localInputs.key && localInputs.key.trim() !== '') {
- try {
- // 验证 JSON 格式
- const parsedKey = JSON.parse(localInputs.key);
- // 确保是有效的密钥格式
- localInputs.key = JSON.stringify(parsedKey);
- } catch (err) {
- showError(t('密钥格式无效,请输入有效的 JSON 格式密钥'));
- return;
- }
- } else if (!isEdit) {
+ const keyType = localInputs.vertex_key_type || 'json';
+ if (keyType === 'api_key') {
+ // 直接作为普通字符串密钥处理
+ if (!isEdit && (!localInputs.key || localInputs.key.trim() === '')) {
showInfo(t('请输入密钥!'));
return;
}
} else {
- // 文件上传模式
- let keys = vertexKeys;
-
- // 若当前未选择文件,尝试从已上传文件列表解析(异步读取)
- if (keys.length === 0 && vertexFileList.length > 0) {
- try {
- const parsed = await Promise.all(
- vertexFileList.map(async (item) => {
- const fileObj = item.fileInstance;
- if (!fileObj) return null;
- const txt = await fileObj.text();
- return JSON.parse(txt);
- }),
- );
- keys = parsed.filter(Boolean);
- } catch (err) {
- showError(t('解析密钥文件失败: {{msg}}', { msg: err.message }));
+ // JSON 服务账号密钥
+ if (useManualInput) {
+ if (localInputs.key && localInputs.key.trim() !== '') {
+ try {
+ const parsedKey = JSON.parse(localInputs.key);
+ localInputs.key = JSON.stringify(parsedKey);
+ } catch (err) {
+ showError(t('密钥格式无效,请输入有效的 JSON 格式密钥'));
+ return;
+ }
+ } else if (!isEdit) {
+ showInfo(t('请输入密钥!'));
return;
}
- }
-
- // 创建模式必须上传密钥;编辑模式可选
- if (keys.length === 0) {
- if (!isEdit) {
- showInfo(t('请上传密钥文件!'));
- return;
- } else {
- // 编辑模式且未上传新密钥,不修改 key
- delete localInputs.key;
- }
} else {
- // 有新密钥,则覆盖
- if (batch) {
- localInputs.key = JSON.stringify(keys);
+ // 文件上传模式
+ let keys = vertexKeys;
+ if (keys.length === 0 && vertexFileList.length > 0) {
+ try {
+ const parsed = await Promise.all(
+ vertexFileList.map(async (item) => {
+ const fileObj = item.fileInstance;
+ if (!fileObj) return null;
+ const txt = await fileObj.text();
+ return JSON.parse(txt);
+ }),
+ );
+ keys = parsed.filter(Boolean);
+ } catch (err) {
+ showError(t('解析密钥文件失败: {{msg}}', { msg: err.message }));
+ return;
+ }
+ }
+ if (keys.length === 0) {
+ if (!isEdit) {
+ showInfo(t('请上传密钥文件!'));
+ return;
+ } else {
+ delete localInputs.key;
+ }
} else {
- localInputs.key = JSON.stringify(keys[0]);
+ localInputs.key = batch ? JSON.stringify(keys) : JSON.stringify(keys[0]);
}
}
}
@@ -853,6 +858,8 @@ const EditChannelModal = (props) => {
delete localInputs.pass_through_body_enabled;
delete localInputs.system_prompt;
delete localInputs.system_prompt_override;
+ // 顶层的 vertex_key_type 不应发送给后端
+ delete localInputs.vertex_key_type;
let res;
localInputs.auto_ban = localInputs.auto_ban ? 1 : 0;
@@ -1178,8 +1185,40 @@ const EditChannelModal = (props) => {
autoComplete='new-password'
/>
+ {inputs.type === 41 && (
+ {
+ // 更新设置中的 vertex_key_type
+ handleChannelOtherSettingsChange('vertex_key_type', value);
+ // 切换为 api_key 时,关闭批量与手动/文件切换,并清理已选文件
+ if (value === 'api_key') {
+ setBatch(false);
+ setUseManualInput(false);
+ setVertexKeys([]);
+ setVertexFileList([]);
+ if (formApiRef.current) {
+ formApiRef.current.setValue('vertex_files', []);
+ }
+ }
+ }}
+ extraText={
+ inputs.vertex_key_type === 'api_key'
+ ? t('API Key 模式下不支持批量创建')
+ : t('JSON 模式支持手动输入或上传服务账号 JSON')
+ }
+ />
+ )}
{batch ? (
- inputs.type === 41 ? (
+ inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? (
{
)
) : (
<>
- {inputs.type === 41 ? (
+ {inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? (
<>
{!batch && (
diff --git a/web/src/components/topup/RechargeCard.jsx b/web/src/components/topup/RechargeCard.jsx
index 7fb06b0ca..f23381f40 100644
--- a/web/src/components/topup/RechargeCard.jsx
+++ b/web/src/components/topup/RechargeCard.jsx
@@ -21,6 +21,7 @@ import React, { useRef } from 'react';
import {
Avatar,
Typography,
+ Tag,
Card,
Button,
Banner,
@@ -29,7 +30,7 @@ import {
Space,
Row,
Col,
- Spin,
+ Spin, Tooltip
} from '@douyinfe/semi-ui';
import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si';
import { CreditCard, Coins, Wallet, BarChart2, TrendingUp } from 'lucide-react';
@@ -68,6 +69,7 @@ const RechargeCard = ({
userState,
renderQuota,
statusLoading,
+ topupInfo,
}) => {
const onlineFormApiRef = useRef(null);
const redeemFormApiRef = useRef(null);
@@ -261,44 +263,58 @@ const RechargeCard = ({
-
- {payMethods.map((payMethod) => (
-
- ))}
-
+ {payMethods && payMethods.length > 0 ? (
+
+ {payMethods.map((payMethod) => {
+ const minTopupVal = Number(payMethod.min_topup) || 0;
+ const isStripe = payMethod.type === 'stripe';
+ const disabled =
+ (!enableOnlineTopUp && !isStripe) ||
+ (!enableStripeTopUp && isStripe) ||
+ minTopupVal > Number(topUpCount || 0);
+
+ const buttonEl = (
+
+ );
+
+ return disabled && minTopupVal > Number(topUpCount || 0) ? (
+
+ {buttonEl}
+
+ ) : (
+ {buttonEl}
+ );
+ })}
+
+ ) : (
+
+ {t('暂无可用的支付方式,请联系管理员配置')}
+
+ )}
@@ -306,41 +322,59 @@ const RechargeCard = ({
{(enableOnlineTopUp || enableStripeTopUp) && (
-
- {presetAmounts.map((preset, index) => (
-
- ))}
-
+
+ {presetAmounts.map((preset, index) => {
+ const discount = preset.discount || topupInfo?.discount?.[preset.value] || 1.0;
+ const originalPrice = preset.value * priceRatio;
+ const discountedPrice = originalPrice * discount;
+ const hasDiscount = discount < 1.0;
+ const actualPay = discountedPrice;
+ const save = originalPrice - discountedPrice;
+
+ return (
+
{
+ selectPresetAmount(preset);
+ onlineFormApiRef.current?.setValue(
+ 'topUpCount',
+ preset.value,
+ );
+ }}
+ >
+
+
+ {formatLargeNumber(preset.value)} {t('美元额度')}
+ {hasDiscount && (
+
+ {t('折').includes('off') ?
+ ((1 - discount) * 100).toFixed(1) :
+ (discount * 10).toFixed(1)}{t('折')}
+
+ )}
+
+
+ {t('实付')} {actualPay.toFixed(2)},
+ {hasDiscount ? `${t('节省')} ${save.toFixed(2)}` : `${t('节省')} 0.00`}
+
+
+
+ );
+ })}
+
)}
diff --git a/web/src/components/topup/index.jsx b/web/src/components/topup/index.jsx
index a09244488..929a47e39 100644
--- a/web/src/components/topup/index.jsx
+++ b/web/src/components/topup/index.jsx
@@ -80,6 +80,12 @@ const TopUp = () => {
// 预设充值额度选项
const [presetAmounts, setPresetAmounts] = useState([]);
const [selectedPreset, setSelectedPreset] = useState(null);
+
+ // 充值配置信息
+ const [topupInfo, setTopupInfo] = useState({
+ amount_options: [],
+ discount: {}
+ });
const topUp = async () => {
if (redemptionCode === '') {
@@ -248,6 +254,99 @@ const TopUp = () => {
}
};
+ // 获取充值配置信息
+ const getTopupInfo = async () => {
+ try {
+ const res = await API.get('/api/user/topup/info');
+ const { message, data, success } = res.data;
+ if (success) {
+ setTopupInfo({
+ amount_options: data.amount_options || [],
+ discount: data.discount || {}
+ });
+
+ // 处理支付方式
+ let payMethods = data.pay_methods || [];
+ try {
+ if (typeof payMethods === 'string') {
+ payMethods = JSON.parse(payMethods);
+ }
+ if (payMethods && payMethods.length > 0) {
+ // 检查name和type是否为空
+ payMethods = payMethods.filter((method) => {
+ return method.name && method.type;
+ });
+ // 如果没有color,则设置默认颜色
+ payMethods = payMethods.map((method) => {
+ // 规范化最小充值数
+ const normalizedMinTopup = Number(method.min_topup);
+ method.min_topup = Number.isFinite(normalizedMinTopup) ? normalizedMinTopup : 0;
+
+ // Stripe 的最小充值从后端字段回填
+ if (method.type === 'stripe' && (!method.min_topup || method.min_topup <= 0)) {
+ const stripeMin = Number(data.stripe_min_topup);
+ if (Number.isFinite(stripeMin)) {
+ method.min_topup = stripeMin;
+ }
+ }
+
+ if (!method.color) {
+ if (method.type === 'alipay') {
+ method.color = 'rgba(var(--semi-blue-5), 1)';
+ } else if (method.type === 'wxpay') {
+ method.color = 'rgba(var(--semi-green-5), 1)';
+ } else if (method.type === 'stripe') {
+ method.color = 'rgba(var(--semi-purple-5), 1)';
+ } else {
+ method.color = 'rgba(var(--semi-primary-5), 1)';
+ }
+ }
+ return method;
+ });
+ } else {
+ payMethods = [];
+ }
+
+ // 如果启用了 Stripe 支付,添加到支付方法列表
+ // 这个逻辑现在由后端处理,如果 Stripe 启用,后端会在 pay_methods 中包含它
+
+ setPayMethods(payMethods);
+ const enableStripeTopUp = data.enable_stripe_topup || false;
+ const enableOnlineTopUp = data.enable_online_topup || false;
+ const minTopUpValue = enableOnlineTopUp? data.min_topup : enableStripeTopUp? data.stripe_min_topup : 1;
+ setEnableOnlineTopUp(enableOnlineTopUp);
+ setEnableStripeTopUp(enableStripeTopUp);
+ setMinTopUp(minTopUpValue);
+ setTopUpCount(minTopUpValue);
+
+ // 如果没有自定义充值数量选项,根据最小充值金额生成预设充值额度选项
+ if (topupInfo.amount_options.length === 0) {
+ setPresetAmounts(generatePresetAmounts(minTopUpValue));
+ }
+
+ // 初始化显示实付金额
+ getAmount(minTopUpValue);
+ } catch (e) {
+ console.log('解析支付方式失败:', e);
+ setPayMethods([]);
+ }
+
+ // 如果有自定义充值数量选项,使用它们替换默认的预设选项
+ if (data.amount_options && data.amount_options.length > 0) {
+ const customPresets = data.amount_options.map(amount => ({
+ value: amount,
+ discount: data.discount[amount] || 1.0
+ }));
+ setPresetAmounts(customPresets);
+ }
+ } else {
+ console.error('获取充值配置失败:', data);
+ }
+ } catch (error) {
+ console.error('获取充值配置异常:', error);
+ }
+ };
+
// 获取邀请链接
const getAffLink = async () => {
const res = await API.get('/api/user/aff');
@@ -290,52 +389,7 @@ const TopUp = () => {
getUserQuota().then();
}
setTransferAmount(getQuotaPerUnit());
-
- let payMethods = localStorage.getItem('pay_methods');
- try {
- payMethods = JSON.parse(payMethods);
- if (payMethods && payMethods.length > 0) {
- // 检查name和type是否为空
- payMethods = payMethods.filter((method) => {
- return method.name && method.type;
- });
- // 如果没有color,则设置默认颜色
- payMethods = payMethods.map((method) => {
- if (!method.color) {
- if (method.type === 'alipay') {
- method.color = 'rgba(var(--semi-blue-5), 1)';
- } else if (method.type === 'wxpay') {
- method.color = 'rgba(var(--semi-green-5), 1)';
- } else if (method.type === 'stripe') {
- method.color = 'rgba(var(--semi-purple-5), 1)';
- } else {
- method.color = 'rgba(var(--semi-primary-5), 1)';
- }
- }
- return method;
- });
- } else {
- payMethods = [];
- }
-
- // 如果启用了 Stripe 支付,添加到支付方法列表
- if (statusState?.status?.enable_stripe_topup) {
- const hasStripe = payMethods.some((method) => method.type === 'stripe');
- if (!hasStripe) {
- payMethods.push({
- name: 'Stripe',
- type: 'stripe',
- color: 'rgba(var(--semi-purple-5), 1)',
- });
- }
- }
-
- setPayMethods(payMethods);
- } catch (e) {
- console.log(e);
- showError(t('支付方式配置错误, 请联系管理员'));
- }
- }, [statusState?.status?.enable_stripe_topup]);
+ }, []);
useEffect(() => {
if (affFetchedRef.current) return;
@@ -343,20 +397,18 @@ const TopUp = () => {
getAffLink().then();
}, []);
+ // 在 statusState 可用时获取充值信息
+ useEffect(() => {
+ getTopupInfo().then();
+ }, []);
+
useEffect(() => {
if (statusState?.status) {
- const minTopUpValue = statusState.status.min_topup || 1;
- setMinTopUp(minTopUpValue);
- setTopUpCount(minTopUpValue);
+ // const minTopUpValue = statusState.status.min_topup || 1;
+ // setMinTopUp(minTopUpValue);
+ // setTopUpCount(minTopUpValue);
setTopUpLink(statusState.status.top_up_link || '');
- setEnableOnlineTopUp(statusState.status.enable_online_topup || false);
setPriceRatio(statusState.status.price || 1);
- setEnableStripeTopUp(statusState.status.enable_stripe_topup || false);
-
- // 根据最小充值金额生成预设充值额度选项
- setPresetAmounts(generatePresetAmounts(minTopUpValue));
- // 初始化显示实付金额
- getAmount(minTopUpValue);
setStatusLoading(false);
}
@@ -431,7 +483,11 @@ const TopUp = () => {
const selectPresetAmount = (preset) => {
setTopUpCount(preset.value);
setSelectedPreset(preset.value);
- setAmount(preset.value * priceRatio);
+
+ // 计算实际支付金额,考虑折扣
+ const discount = preset.discount || topupInfo.discount[preset.value] || 1.0;
+ const discountedAmount = preset.value * priceRatio * discount;
+ setAmount(discountedAmount);
};
// 格式化大数字显示
@@ -475,6 +531,8 @@ const TopUp = () => {
renderAmount={renderAmount}
payWay={payWay}
payMethods={payMethods}
+ amountNumber={amount}
+ discountRate={topupInfo?.discount?.[topUpCount] || 1.0}
/>
{/* 用户信息头部 */}
@@ -512,6 +570,7 @@ const TopUp = () => {
userState={userState}
renderQuota={renderQuota}
statusLoading={statusLoading}
+ topupInfo={topupInfo}
/>
diff --git a/web/src/components/topup/modals/PaymentConfirmModal.jsx b/web/src/components/topup/modals/PaymentConfirmModal.jsx
index 76ea5eb22..1bffbfed1 100644
--- a/web/src/components/topup/modals/PaymentConfirmModal.jsx
+++ b/web/src/components/topup/modals/PaymentConfirmModal.jsx
@@ -36,7 +36,13 @@ const PaymentConfirmModal = ({
renderAmount,
payWay,
payMethods,
+ // 新增:用于显示折扣明细
+ amountNumber,
+ discountRate,
}) => {
+ const hasDiscount = discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0;
+ const originalAmount = hasDiscount ? (amountNumber / discountRate) : 0;
+ const discountAmount = hasDiscount ? (originalAmount - amountNumber) : 0;
return (
) : (
-
- {renderAmount()}
-
+
+
+ {renderAmount()}
+
+ {hasDiscount && (
+
+ {Math.round(discountRate * 100)}%
+
+ )}
+
)}
+ {hasDiscount && !amountLoading && (
+ <>
+
+
+ {t('原价')}:
+
+
+ {`${originalAmount.toFixed(2)} ${t('元')}`}
+
+
+
+
+ {t('优惠')}:
+
+
+ {`- ${discountAmount.toFixed(2)} ${t('元')}`}
+
+
+ >
+ )}
{t('支付方式')}:
diff --git a/web/src/helpers/data.js b/web/src/helpers/data.js
index 62353327c..b894a953c 100644
--- a/web/src/helpers/data.js
+++ b/web/src/helpers/data.js
@@ -28,7 +28,6 @@ export function setStatusData(data) {
localStorage.setItem('enable_task', data.enable_task);
localStorage.setItem('enable_data_export', data.enable_data_export);
localStorage.setItem('chats', JSON.stringify(data.chats));
- localStorage.setItem('pay_methods', JSON.stringify(data.pay_methods));
localStorage.setItem(
'data_export_default_time',
data.data_export_default_time,
diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx
index ce8958dca..d681b6a27 100644
--- a/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx
+++ b/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx
@@ -41,6 +41,8 @@ export default function SettingsPaymentGateway(props) {
TopupGroupRatio: '',
CustomCallbackAddress: '',
PayMethods: '',
+ AmountOptions: '',
+ AmountDiscount: '',
});
const [originInputs, setOriginInputs] = useState({});
const formApiRef = useRef(null);
@@ -62,7 +64,30 @@ export default function SettingsPaymentGateway(props) {
TopupGroupRatio: props.options.TopupGroupRatio || '',
CustomCallbackAddress: props.options.CustomCallbackAddress || '',
PayMethods: props.options.PayMethods || '',
+ AmountOptions: props.options.AmountOptions || '',
+ AmountDiscount: props.options.AmountDiscount || '',
};
+
+ // 美化 JSON 展示
+ try {
+ if (currentInputs.AmountOptions) {
+ currentInputs.AmountOptions = JSON.stringify(
+ JSON.parse(currentInputs.AmountOptions),
+ null,
+ 2,
+ );
+ }
+ } catch {}
+ try {
+ if (currentInputs.AmountDiscount) {
+ currentInputs.AmountDiscount = JSON.stringify(
+ JSON.parse(currentInputs.AmountDiscount),
+ null,
+ 2,
+ );
+ }
+ } catch {}
+
setInputs(currentInputs);
setOriginInputs({ ...currentInputs });
formApiRef.current.setValues(currentInputs);
@@ -93,6 +118,20 @@ export default function SettingsPaymentGateway(props) {
}
}
+ if (originInputs['AmountOptions'] !== inputs.AmountOptions && inputs.AmountOptions.trim() !== '') {
+ if (!verifyJSON(inputs.AmountOptions)) {
+ showError(t('自定义充值数量选项不是合法的 JSON 数组'));
+ return;
+ }
+ }
+
+ if (originInputs['AmountDiscount'] !== inputs.AmountDiscount && inputs.AmountDiscount.trim() !== '') {
+ if (!verifyJSON(inputs.AmountDiscount)) {
+ showError(t('充值金额折扣配置不是合法的 JSON 对象'));
+ return;
+ }
+ }
+
setLoading(true);
try {
const options = [
@@ -123,6 +162,12 @@ export default function SettingsPaymentGateway(props) {
if (originInputs['PayMethods'] !== inputs.PayMethods) {
options.push({ key: 'PayMethods', value: inputs.PayMethods });
}
+ if (originInputs['AmountOptions'] !== inputs.AmountOptions) {
+ options.push({ key: 'payment_setting.amount_options', value: inputs.AmountOptions });
+ }
+ if (originInputs['AmountDiscount'] !== inputs.AmountDiscount) {
+ options.push({ key: 'payment_setting.amount_discount', value: inputs.AmountDiscount });
+ }
// 发送请求
const requestQueue = options.map((opt) =>
@@ -228,6 +273,37 @@ export default function SettingsPaymentGateway(props) {
placeholder={t('为一个 JSON 文本')}
autosize
/>
+
+
+
+
+
+
+
+
+
+
+
+
+
From 1bffe3081dde8b6c9c35d4bced59bb23f3b7d396 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Fri, 12 Sep 2025 21:14:10 +0800
Subject: [PATCH 026/165] =?UTF-8?q?feat(settings):=20=E7=A7=BB=E9=99=A4?=
=?UTF-8?q?=E5=8D=95=E4=BD=8D=E7=BE=8E=E5=85=83=E9=A2=9D=E5=BA=A6=E8=AE=BE?=
=?UTF-8?q?=E7=BD=AE=E9=A1=B9=EF=BC=8C=E4=B8=BA=E5=90=8E=E7=BB=AD=E4=BF=AE?=
=?UTF-8?q?=E6=94=B9=E4=BD=9C=E5=87=86=E5=A4=87?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Setting/Operation/SettingsGeneral.jsx | 24 ++++++++++---------
1 file changed, 13 insertions(+), 11 deletions(-)
diff --git a/web/src/pages/Setting/Operation/SettingsGeneral.jsx b/web/src/pages/Setting/Operation/SettingsGeneral.jsx
index 37b3dd984..5af750ec3 100644
--- a/web/src/pages/Setting/Operation/SettingsGeneral.jsx
+++ b/web/src/pages/Setting/Operation/SettingsGeneral.jsx
@@ -130,17 +130,19 @@ export default function GeneralSettings(props) {
showClear
/>
-
- setShowQuotaWarning(true)}
- />
-
+ {inputs.QuotaPerUnit !== '500000' && inputs.QuotaPerUnit !== 500000 && (
+
+ setShowQuotaWarning(true)}
+ />
+
+ )}
Date: Fri, 12 Sep 2025 21:53:21 +0800
Subject: [PATCH 027/165] feat(i18n): update TOTP verification message with
configuration details
---
web/src/components/common/modals/TwoFactorAuthModal.jsx | 2 +-
web/src/i18n/locales/en.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/web/src/components/common/modals/TwoFactorAuthModal.jsx b/web/src/components/common/modals/TwoFactorAuthModal.jsx
index b0fc28e2a..2a9a8b25b 100644
--- a/web/src/components/common/modals/TwoFactorAuthModal.jsx
+++ b/web/src/components/common/modals/TwoFactorAuthModal.jsx
@@ -135,7 +135,7 @@ const TwoFactorAuthModal = ({
autoFocus
/>
- {t('支持6位TOTP验证码或8位备用码')}
+ {t('支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。')}
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index f47839f2e..73dfbebe7 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -1993,7 +1993,7 @@
"安全验证": "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",
+ "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "Supports 6-digit TOTP verification code or 8-digit backup code, can be configured or viewed in `Personal Settings - Security Settings - Two-Factor Authentication Settings`.",
"获取密钥失败": "Failed to get key",
"查看密钥": "View key",
"查看渠道密钥": "View channel key",
From 6ed775be8f55787f0af6eb98b60634c73be2d94d Mon Sep 17 00:00:00 2001
From: feitianbubu
Date: Fri, 12 Sep 2025 21:52:32 +0800
Subject: [PATCH 028/165] refactor: use common taskSubmitReq
---
relay/channel/task/jimeng/adaptor.go | 18 +------
relay/channel/task/kling/adaptor.go | 34 ++-----------
relay/channel/task/vidu/adaptor.go | 33 ++----------
relay/common/relay_info.go | 8 +++
relay/common/relay_utils.go | 75 ++++++++++++++++++++++++++++
5 files changed, 92 insertions(+), 76 deletions(-)
diff --git a/relay/channel/task/jimeng/adaptor.go b/relay/channel/task/jimeng/adaptor.go
index 955e592a2..f838bdb16 100644
--- a/relay/channel/task/jimeng/adaptor.go
+++ b/relay/channel/task/jimeng/adaptor.go
@@ -18,7 +18,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
- "one-api/common"
"one-api/constant"
"one-api/dto"
"one-api/relay/channel"
@@ -89,22 +88,7 @@ func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
// ValidateRequestAndSetAction parses body, validates fields and sets default action.
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
// Accept only POST /v1/video/generations as "generate" action.
- action := constant.TaskActionGenerate
- info.Action = action
-
- req := relaycommon.TaskSubmitReq{}
- if err := common.UnmarshalBodyReusable(c, &req); err != nil {
- taskErr = service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest)
- return
- }
- if strings.TrimSpace(req.Prompt) == "" {
- taskErr = service.TaskErrorWrapperLocal(fmt.Errorf("prompt is required"), "invalid_request", http.StatusBadRequest)
- return
- }
-
- // Store into context for later usage
- c.Set("task_request", req)
- return nil
+ return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate)
}
// BuildRequestURL constructs the upstream URL.
diff --git a/relay/channel/task/kling/adaptor.go b/relay/channel/task/kling/adaptor.go
index 3d6da253b..13f2af972 100644
--- a/relay/channel/task/kling/adaptor.go
+++ b/relay/channel/task/kling/adaptor.go
@@ -16,7 +16,6 @@ import (
"github.com/golang-jwt/jwt"
"github.com/pkg/errors"
- "one-api/common"
"one-api/constant"
"one-api/dto"
"one-api/relay/channel"
@@ -28,16 +27,6 @@ import (
// Request / Response structures
// ============================
-type SubmitReq struct {
- Prompt string `json:"prompt"`
- Model string `json:"model,omitempty"`
- Mode string `json:"mode,omitempty"`
- Image string `json:"image,omitempty"`
- Size string `json:"size,omitempty"`
- Duration int `json:"duration,omitempty"`
- Metadata map[string]interface{} `json:"metadata,omitempty"`
-}
-
type TrajectoryPoint struct {
X int `json:"x"`
Y int `json:"y"`
@@ -121,23 +110,8 @@ func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
// ValidateRequestAndSetAction parses body, validates fields and sets default action.
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
- // Accept only POST /v1/video/generations as "generate" action.
- action := constant.TaskActionGenerate
- info.Action = action
-
- var req SubmitReq
- if err := common.UnmarshalBodyReusable(c, &req); err != nil {
- taskErr = service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest)
- return
- }
- if strings.TrimSpace(req.Prompt) == "" {
- taskErr = service.TaskErrorWrapperLocal(fmt.Errorf("prompt is required"), "invalid_request", http.StatusBadRequest)
- return
- }
-
- // Store into context for later usage
- c.Set("task_request", req)
- return nil
+ // Use the standard validation method for TaskSubmitReq
+ return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate)
}
// BuildRequestURL constructs the upstream URL.
@@ -166,7 +140,7 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayIn
if !exists {
return nil, fmt.Errorf("request not found in context")
}
- req := v.(SubmitReq)
+ req := v.(relaycommon.TaskSubmitReq)
body, err := a.convertToRequestPayload(&req)
if err != nil {
@@ -255,7 +229,7 @@ func (a *TaskAdaptor) GetChannelName() string {
// helpers
// ============================
-func (a *TaskAdaptor) convertToRequestPayload(req *SubmitReq) (*requestPayload, error) {
+func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) {
r := requestPayload{
Prompt: req.Prompt,
Image: req.Image,
diff --git a/relay/channel/task/vidu/adaptor.go b/relay/channel/task/vidu/adaptor.go
index c82c1c0e8..a1140d1e7 100644
--- a/relay/channel/task/vidu/adaptor.go
+++ b/relay/channel/task/vidu/adaptor.go
@@ -23,16 +23,6 @@ import (
// Request / Response structures
// ============================
-type SubmitReq struct {
- Prompt string `json:"prompt"`
- Model string `json:"model,omitempty"`
- Mode string `json:"mode,omitempty"`
- Image string `json:"image,omitempty"`
- Size string `json:"size,omitempty"`
- Duration int `json:"duration,omitempty"`
- Metadata map[string]interface{} `json:"metadata,omitempty"`
-}
-
type requestPayload struct {
Model string `json:"model"`
Images []string `json:"images"`
@@ -90,23 +80,8 @@ func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
}
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError {
- var req SubmitReq
- if err := c.ShouldBindJSON(&req); err != nil {
- return service.TaskErrorWrapper(err, "invalid_request_body", http.StatusBadRequest)
- }
-
- if req.Prompt == "" {
- return service.TaskErrorWrapperLocal(fmt.Errorf("prompt is required"), "missing_prompt", http.StatusBadRequest)
- }
-
- if req.Image != "" {
- info.Action = constant.TaskActionGenerate
- } else {
- info.Action = constant.TaskActionTextGenerate
- }
-
- c.Set("task_request", req)
- return nil
+ // Use the unified validation method for TaskSubmitReq with image-based action determination
+ return relaycommon.ValidateTaskRequestWithImageBinding(c, info)
}
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.RelayInfo) (io.Reader, error) {
@@ -114,7 +89,7 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.RelayInfo)
if !exists {
return nil, fmt.Errorf("request not found in context")
}
- req := v.(SubmitReq)
+ req := v.(relaycommon.TaskSubmitReq)
body, err := a.convertToRequestPayload(&req)
if err != nil {
@@ -211,7 +186,7 @@ func (a *TaskAdaptor) GetChannelName() string {
// helpers
// ============================
-func (a *TaskAdaptor) convertToRequestPayload(req *SubmitReq) (*requestPayload, error) {
+func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) {
var images []string
if req.Image != "" {
images = []string{req.Image}
diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go
index da572c070..eb292de23 100644
--- a/relay/common/relay_info.go
+++ b/relay/common/relay_info.go
@@ -486,6 +486,14 @@ type TaskSubmitReq struct {
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
+func (t TaskSubmitReq) GetPrompt() string {
+ return t.Prompt
+}
+
+func (t TaskSubmitReq) GetImage() string {
+ return t.Image
+}
+
type TaskInfo struct {
Code int `json:"code"`
TaskID string `json:"task_id"`
diff --git a/relay/common/relay_utils.go b/relay/common/relay_utils.go
index 3d5efcb6d..108395613 100644
--- a/relay/common/relay_utils.go
+++ b/relay/common/relay_utils.go
@@ -2,12 +2,23 @@ package common
import (
"fmt"
+ "net/http"
+ "one-api/common"
"one-api/constant"
+ "one-api/dto"
"strings"
"github.com/gin-gonic/gin"
)
+type HasPrompt interface {
+ GetPrompt() string
+}
+
+type HasImage interface {
+ GetImage() string
+}
+
func GetFullRequestURL(baseURL string, requestURL string, channelType int) string {
fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL)
@@ -30,3 +41,67 @@ func GetAPIVersion(c *gin.Context) string {
}
return apiVersion
}
+
+func createTaskError(err error, code string, statusCode int, localError bool) *dto.TaskError {
+ return &dto.TaskError{
+ Code: code,
+ Message: err.Error(),
+ StatusCode: statusCode,
+ LocalError: localError,
+ Error: err,
+ }
+}
+
+func storeTaskRequest(c *gin.Context, info *RelayInfo, action string, requestObj interface{}) {
+ info.Action = action
+ c.Set("task_request", requestObj)
+}
+
+func validatePrompt(prompt string) *dto.TaskError {
+ if strings.TrimSpace(prompt) == "" {
+ return createTaskError(fmt.Errorf("prompt is required"), "invalid_request", http.StatusBadRequest, true)
+ }
+ return nil
+}
+
+func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *dto.TaskError {
+ var req TaskSubmitReq
+ if err := common.UnmarshalBodyReusable(c, &req); err != nil {
+ return createTaskError(err, "invalid_request", http.StatusBadRequest, true)
+ }
+
+ if taskErr := validatePrompt(req.Prompt); taskErr != nil {
+ return taskErr
+ }
+
+ storeTaskRequest(c, info, action, req)
+ return nil
+}
+
+func ValidateTaskRequestWithImage(c *gin.Context, info *RelayInfo, requestObj interface{}) *dto.TaskError {
+ hasPrompt, ok := requestObj.(HasPrompt)
+ if !ok {
+ return createTaskError(fmt.Errorf("request must have prompt"), "invalid_request", http.StatusBadRequest, true)
+ }
+
+ if taskErr := validatePrompt(hasPrompt.GetPrompt()); taskErr != nil {
+ return taskErr
+ }
+
+ action := constant.TaskActionTextGenerate
+ if hasImage, ok := requestObj.(HasImage); ok && strings.TrimSpace(hasImage.GetImage()) != "" {
+ action = constant.TaskActionGenerate
+ }
+
+ storeTaskRequest(c, info, action, requestObj)
+ return nil
+}
+
+func ValidateTaskRequestWithImageBinding(c *gin.Context, info *RelayInfo) *dto.TaskError {
+ var req TaskSubmitReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ return createTaskError(err, "invalid_request_body", http.StatusBadRequest, false)
+ }
+
+ return ValidateTaskRequestWithImage(c, info, req)
+}
From f14b06ec3a88023b3f4ef17f90e6e815bd4a75d2 Mon Sep 17 00:00:00 2001
From: feitianbubu
Date: Fri, 12 Sep 2025 22:19:45 +0800
Subject: [PATCH 029/165] feat: jimeng video add images
---
relay/channel/task/jimeng/adaptor.go | 8 ++++----
relay/common/relay_info.go | 5 +++--
relay/common/relay_utils.go | 9 +++++++--
3 files changed, 14 insertions(+), 8 deletions(-)
diff --git a/relay/channel/task/jimeng/adaptor.go b/relay/channel/task/jimeng/adaptor.go
index f838bdb16..2bc45c547 100644
--- a/relay/channel/task/jimeng/adaptor.go
+++ b/relay/channel/task/jimeng/adaptor.go
@@ -318,11 +318,11 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
}
// Handle one-of image_urls or binary_data_base64
- if req.Image != "" {
- if strings.HasPrefix(req.Image, "http") {
- r.ImageUrls = []string{req.Image}
+ if req.HasImage() {
+ if strings.HasPrefix(req.Images[0], "http") {
+ r.ImageUrls = req.Images
} else {
- r.BinaryDataBase64 = []string{req.Image}
+ r.BinaryDataBase64 = req.Images
}
}
metadata := req.Metadata
diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go
index eb292de23..99925dc5d 100644
--- a/relay/common/relay_info.go
+++ b/relay/common/relay_info.go
@@ -481,6 +481,7 @@ type TaskSubmitReq struct {
Model string `json:"model,omitempty"`
Mode string `json:"mode,omitempty"`
Image string `json:"image,omitempty"`
+ Images []string `json:"images,omitempty"`
Size string `json:"size,omitempty"`
Duration int `json:"duration,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
@@ -490,8 +491,8 @@ func (t TaskSubmitReq) GetPrompt() string {
return t.Prompt
}
-func (t TaskSubmitReq) GetImage() string {
- return t.Image
+func (t TaskSubmitReq) HasImage() bool {
+ return len(t.Images) > 0
}
type TaskInfo struct {
diff --git a/relay/common/relay_utils.go b/relay/common/relay_utils.go
index 108395613..cf6d08dda 100644
--- a/relay/common/relay_utils.go
+++ b/relay/common/relay_utils.go
@@ -16,7 +16,7 @@ type HasPrompt interface {
}
type HasImage interface {
- GetImage() string
+ HasImage() bool
}
func GetFullRequestURL(baseURL string, requestURL string, channelType int) string {
@@ -74,6 +74,11 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d
return taskErr
}
+ if len(req.Images) == 0 && strings.TrimSpace(req.Image) != "" {
+ // 兼容单图上传
+ req.Images = []string{req.Image}
+ }
+
storeTaskRequest(c, info, action, req)
return nil
}
@@ -89,7 +94,7 @@ func ValidateTaskRequestWithImage(c *gin.Context, info *RelayInfo, requestObj in
}
action := constant.TaskActionTextGenerate
- if hasImage, ok := requestObj.(HasImage); ok && strings.TrimSpace(hasImage.GetImage()) != "" {
+ if hasImage, ok := requestObj.(HasImage); ok && hasImage.HasImage() {
action = constant.TaskActionGenerate
}
From 6451158680ac671e65f7691f1197b0f9f51c4637 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Sat, 13 Sep 2025 12:53:28 +0800
Subject: [PATCH 030/165] =?UTF-8?q?Revert=20"feat:=20gemini-2.5-flash-imag?=
=?UTF-8?q?e-preview=20=E6=96=87=E6=9C=AC=E5=92=8C=E5=9B=BE=E7=89=87?=
=?UTF-8?q?=E8=BE=93=E5=87=BA=E8=AE=A1=E8=B4=B9"?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This reverts commit e732c5842675d2aeeb3faa2af633341fb9d9c1ac.
---
dto/gemini.go | 16 ++++----
relay/channel/gemini/relay-gemini-native.go | 36 ------------------
relay/compatible_handler.go | 15 --------
service/token_counter.go | 2 +-
setting/model_setting/gemini.go | 1 -
setting/operation_setting/tools.go | 11 ------
setting/ratio_setting/model_ratio.go | 10 ++---
web/src/helpers/render.jsx | 38 ++++---------------
web/src/hooks/usage-logs/useUsageLogsData.jsx | 2 -
9 files changed, 20 insertions(+), 111 deletions(-)
diff --git a/dto/gemini.go b/dto/gemini.go
index cd5d74cdd..5df67ba0b 100644
--- a/dto/gemini.go
+++ b/dto/gemini.go
@@ -2,12 +2,11 @@ package dto
import (
"encoding/json"
+ "github.com/gin-gonic/gin"
"one-api/common"
"one-api/logger"
"one-api/types"
"strings"
-
- "github.com/gin-gonic/gin"
)
type GeminiChatRequest struct {
@@ -269,15 +268,14 @@ type GeminiChatResponse struct {
}
type GeminiUsageMetadata struct {
- PromptTokenCount int `json:"promptTokenCount"`
- CandidatesTokenCount int `json:"candidatesTokenCount"`
- TotalTokenCount int `json:"totalTokenCount"`
- ThoughtsTokenCount int `json:"thoughtsTokenCount"`
- PromptTokensDetails []GeminiModalityTokenCount `json:"promptTokensDetails"`
- CandidatesTokensDetails []GeminiModalityTokenCount `json:"candidatesTokensDetails"`
+ PromptTokenCount int `json:"promptTokenCount"`
+ CandidatesTokenCount int `json:"candidatesTokenCount"`
+ TotalTokenCount int `json:"totalTokenCount"`
+ ThoughtsTokenCount int `json:"thoughtsTokenCount"`
+ PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"`
}
-type GeminiModalityTokenCount struct {
+type GeminiPromptTokensDetails struct {
Modality string `json:"modality"`
TokenCount int `json:"tokenCount"`
}
diff --git a/relay/channel/gemini/relay-gemini-native.go b/relay/channel/gemini/relay-gemini-native.go
index 564b86908..974a22f50 100644
--- a/relay/channel/gemini/relay-gemini-native.go
+++ b/relay/channel/gemini/relay-gemini-native.go
@@ -46,32 +46,6 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
- if strings.HasPrefix(info.UpstreamModelName, "gemini-2.5-flash-image-preview") {
- imageOutputCounts := 0
- for _, candidate := range geminiResponse.Candidates {
- for _, part := range candidate.Content.Parts {
- if part.InlineData != nil && strings.HasPrefix(part.InlineData.MimeType, "image/") {
- imageOutputCounts++
- }
- }
- }
- if imageOutputCounts != 0 {
- usage.CompletionTokens = usage.CompletionTokens - imageOutputCounts*1290
- usage.TotalTokens = usage.TotalTokens - imageOutputCounts*1290
- c.Set("gemini_image_tokens", imageOutputCounts*1290)
- }
- }
-
- // if strings.HasPrefix(info.UpstreamModelName, "gemini-2.5-flash-image-preview") {
- // for _, detail := range geminiResponse.UsageMetadata.CandidatesTokensDetails {
- // if detail.Modality == "IMAGE" {
- // usage.CompletionTokens = usage.CompletionTokens - detail.TokenCount
- // usage.TotalTokens = usage.TotalTokens - detail.TokenCount
- // c.Set("gemini_image_tokens", detail.TokenCount)
- // }
- // }
- // }
-
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
if detail.Modality == "AUDIO" {
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
@@ -162,16 +136,6 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayIn
usage.PromptTokensDetails.TextTokens = detail.TokenCount
}
}
-
- if strings.HasPrefix(info.UpstreamModelName, "gemini-2.5-flash-image-preview") {
- for _, detail := range geminiResponse.UsageMetadata.CandidatesTokensDetails {
- if detail.Modality == "IMAGE" {
- usage.CompletionTokens = usage.CompletionTokens - detail.TokenCount
- usage.TotalTokens = usage.TotalTokens - detail.TokenCount
- c.Set("gemini_image_tokens", detail.TokenCount)
- }
- }
- }
}
// 直接发送 GeminiChatResponse 响应
diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go
index 8f27fd60b..01ab1fff4 100644
--- a/relay/compatible_handler.go
+++ b/relay/compatible_handler.go
@@ -326,22 +326,11 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
} else {
quotaCalculateDecimal = dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio)
}
- var dGeminiImageOutputQuota decimal.Decimal
- var imageOutputPrice float64
- if strings.HasPrefix(modelName, "gemini-2.5-flash-image-preview") {
- imageOutputPrice = operation_setting.GetGeminiImageOutputPricePerMillionTokens(modelName)
- if imageOutputPrice > 0 {
- dImageOutputTokens := decimal.NewFromInt(int64(ctx.GetInt("gemini_image_tokens")))
- dGeminiImageOutputQuota = decimal.NewFromFloat(imageOutputPrice).Div(decimal.NewFromInt(1000000)).Mul(dImageOutputTokens).Mul(dGroupRatio).Mul(dQuotaPerUnit)
- }
- }
// 添加 responses tools call 调用的配额
quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota)
quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
// 添加 audio input 独立计费
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
- // 添加 Gemini image output 计费
- quotaCalculateDecimal = quotaCalculateDecimal.Add(dGeminiImageOutputQuota)
quota := int(quotaCalculateDecimal.Round(0).IntPart())
totalTokens := promptTokens + completionTokens
@@ -440,10 +429,6 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
other["audio_input_token_count"] = audioTokens
other["audio_input_price"] = audioInputPrice
}
- if !dGeminiImageOutputQuota.IsZero() {
- other["image_output_token_count"] = ctx.GetInt("gemini_image_tokens")
- other["image_output_price"] = imageOutputPrice
- }
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: promptTokens,
diff --git a/service/token_counter.go b/service/token_counter.go
index da56523fe..be5c2e80c 100644
--- a/service/token_counter.go
+++ b/service/token_counter.go
@@ -336,7 +336,7 @@ func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relayco
for i, file := range meta.Files {
switch file.FileType {
case types.FileTypeImage:
- if info.RelayFormat == types.RelayFormatGemini && !strings.HasPrefix(model, "gemini-2.5-flash-image-preview") {
+ if info.RelayFormat == types.RelayFormatGemini {
tkm += 256
} else {
token, err := getImageToken(file, model, info.IsStream)
diff --git a/setting/model_setting/gemini.go b/setting/model_setting/gemini.go
index 5412155f1..f132fec88 100644
--- a/setting/model_setting/gemini.go
+++ b/setting/model_setting/gemini.go
@@ -26,7 +26,6 @@ var defaultGeminiSettings = GeminiSettings{
SupportedImagineModels: []string{
"gemini-2.0-flash-exp-image-generation",
"gemini-2.0-flash-exp",
- "gemini-2.5-flash-image-preview",
},
ThinkingAdapterEnabled: false,
ThinkingAdapterBudgetTokensPercentage: 0.6,
diff --git a/setting/operation_setting/tools.go b/setting/operation_setting/tools.go
index b87265ee1..549a1862e 100644
--- a/setting/operation_setting/tools.go
+++ b/setting/operation_setting/tools.go
@@ -24,10 +24,6 @@ const (
ClaudeWebSearchPrice = 10.00
)
-const (
- Gemini25FlashImagePreviewImageOutputPrice = 30.00
-)
-
func GetClaudeWebSearchPricePerThousand() float64 {
return ClaudeWebSearchPrice
}
@@ -69,10 +65,3 @@ func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
}
return 0
}
-
-func GetGeminiImageOutputPricePerMillionTokens(modelName string) float64 {
- if strings.HasPrefix(modelName, "gemini-2.5-flash-image-preview") {
- return Gemini25FlashImagePreviewImageOutputPrice
- }
- return 0
-}
diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go
index 1a1b0afa8..f06cd71ef 100644
--- a/setting/ratio_setting/model_ratio.go
+++ b/setting/ratio_setting/model_ratio.go
@@ -178,7 +178,6 @@ var defaultModelRatio = map[string]float64{
"gemini-2.5-flash-lite-preview-thinking-*": 0.05,
"gemini-2.5-flash-lite-preview-06-17": 0.05,
"gemini-2.5-flash": 0.15,
- "gemini-2.5-flash-image-preview": 0.15, // $0.30(text/image) / 1M tokens
"text-embedding-004": 0.001,
"chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens
"chatglm_pro": 0.7143, // ¥0.01 / 1k tokens
@@ -294,11 +293,10 @@ var (
)
var defaultCompletionRatio = map[string]float64{
- "gpt-4-gizmo-*": 2,
- "gpt-4o-gizmo-*": 3,
- "gpt-4-all": 2,
- "gpt-image-1": 8,
- "gemini-2.5-flash-image-preview": 8.3333333333,
+ "gpt-4-gizmo-*": 2,
+ "gpt-4o-gizmo-*": 3,
+ "gpt-4-all": 2,
+ "gpt-image-1": 8,
}
// InitRatioSettings initializes all model related settings maps
diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx
index 3d9d8d710..65332701b 100644
--- a/web/src/helpers/render.jsx
+++ b/web/src/helpers/render.jsx
@@ -1017,7 +1017,7 @@ export function renderModelPrice(
cacheRatio = 1.0,
image = false,
imageRatio = 1.0,
- imageInputTokens = 0,
+ imageOutputTokens = 0,
webSearch = false,
webSearchCallCount = 0,
webSearchPrice = 0,
@@ -1027,8 +1027,6 @@ export function renderModelPrice(
audioInputSeperatePrice = false,
audioInputTokens = 0,
audioInputPrice = 0,
- imageOutputTokens = 0,
- imageOutputPrice = 0,
) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
groupRatio,
@@ -1059,9 +1057,9 @@ export function renderModelPrice(
let effectiveInputTokens =
inputTokens - cacheTokens + cacheTokens * cacheRatio;
// Handle image tokens if present
- if (image && imageInputTokens > 0) {
+ if (image && imageOutputTokens > 0) {
effectiveInputTokens =
- inputTokens - imageInputTokens + imageInputTokens * imageRatio;
+ inputTokens - imageOutputTokens + imageOutputTokens * imageRatio;
}
if (audioInputTokens > 0) {
effectiveInputTokens -= audioInputTokens;
@@ -1071,8 +1069,7 @@ export function renderModelPrice(
(audioInputTokens / 1000000) * audioInputPrice * groupRatio +
(completionTokens / 1000000) * completionRatioPrice * groupRatio +
(webSearchCallCount / 1000) * webSearchPrice * groupRatio +
- (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
- (imageOutputTokens / 1000000) * imageOutputPrice * groupRatio;
+ (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio;
return (
<>
@@ -1107,7 +1104,7 @@ export function renderModelPrice(
)}
)}
- {image && imageInputTokens > 0 && (
+ {image && imageOutputTokens > 0 && (
{i18next.t(
'图片输入价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (图片倍率: {{imageRatio}})',
@@ -1134,26 +1131,17 @@ export function renderModelPrice(
})}
)}
- {imageOutputPrice > 0 && imageOutputTokens > 0 && (
-
- {i18next.t('图片输出价格:${{price}} * 分组倍率{{ratio}} = ${{total}} / 1M tokens', {
- price: imageOutputPrice,
- ratio: groupRatio,
- total: imageOutputPrice * groupRatio,
- })}
-
- )}
{(() => {
// 构建输入部分描述
let inputDesc = '';
- if (image && imageInputTokens > 0) {
+ if (image && imageOutputTokens > 0) {
inputDesc = i18next.t(
'(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}}',
{
- nonImageInput: inputTokens - imageInputTokens,
- imageInput: imageInputTokens,
+ nonImageInput: inputTokens - imageOutputTokens,
+ imageInput: imageOutputTokens,
imageRatio: imageRatio,
price: inputRatioPrice,
},
@@ -1223,16 +1211,6 @@ export function renderModelPrice(
},
)
: '',
- imageOutputPrice > 0 && imageOutputTokens > 0
- ? i18next.t(
- ' + 图片输出 {{tokenCounts}} tokens * ${{price}} / 1M tokens * 分组倍率{{ratio}}',
- {
- tokenCounts: imageOutputTokens,
- price: imageOutputPrice,
- ratio: groupRatio,
- },
- )
- : '',
].join('');
return i18next.t(
diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx
index 3584f1d9b..81f3f539a 100644
--- a/web/src/hooks/usage-logs/useUsageLogsData.jsx
+++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx
@@ -447,8 +447,6 @@ export const useLogsData = () => {
other?.audio_input_seperate_price || false,
other?.audio_input_token_count || 0,
other?.audio_input_price || 0,
- other?.image_output_token_count || 0,
- other?.image_output_price || 0,
);
}
expandDataLocal.push({
From c1d7ecdeec73ad5eaaad0626ee0262930ce67142 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Sat, 13 Sep 2025 12:53:41 +0800
Subject: [PATCH 031/165] fix(adaptor): correct VertexKeyType condition in
SetupRequestHeader
---
relay/channel/vertex/adaptor.go | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go
index b6a78b7aa..7e2fdcad3 100644
--- a/relay/channel/vertex/adaptor.go
+++ b/relay/channel/vertex/adaptor.go
@@ -160,7 +160,6 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if strings.HasPrefix(info.UpstreamModelName, "imagen") {
suffix = "predict"
}
-
return a.getRequestUrl(info, info.UpstreamModelName, suffix)
} else if a.RequestMode == RequestModeClaude {
if info.IsStream {
@@ -181,7 +180,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
channel.SetupApiRequestHeader(info, c, req)
- if info.ChannelOtherSettings.VertexKeyType == "json" {
+ if info.ChannelOtherSettings.VertexKeyType != dto.VertexKeyTypeAPIKey {
accessToken, err := getAccessToken(a, info)
if err != nil {
return err
From 28ed42130c9e6397580be3172a12ebd5dc2da096 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Sat, 13 Sep 2025 15:24:48 +0800
Subject: [PATCH 032/165] fix: update references from setting to system_setting
for ServerAddress
---
controller/midjourney.go | 5 +++--
controller/misc.go | 4 ++--
controller/oidc.go | 3 +--
controller/topup.go | 3 ++-
controller/topup_stripe.go | 5 +++--
model/option.go | 15 ++++++++-------
relay/mjproxy_handler.go | 3 ++-
service/epay.go | 4 ++--
service/quota.go | 4 ++--
.../system_setting_old.go} | 2 +-
10 files changed, 26 insertions(+), 22 deletions(-)
rename setting/{system_setting.go => system_setting/system_setting_old.go} (89%)
diff --git a/controller/midjourney.go b/controller/midjourney.go
index a67d39c23..3a7304419 100644
--- a/controller/midjourney.go
+++ b/controller/midjourney.go
@@ -13,6 +13,7 @@ import (
"one-api/model"
"one-api/service"
"one-api/setting"
+ "one-api/setting/system_setting"
"time"
"github.com/gin-gonic/gin"
@@ -259,7 +260,7 @@ func GetAllMidjourney(c *gin.Context) {
if setting.MjForwardUrlEnabled {
for i, midjourney := range items {
- midjourney.ImageUrl = setting.ServerAddress + "/mj/image/" + midjourney.MjId
+ midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId
items[i] = midjourney
}
}
@@ -284,7 +285,7 @@ func GetUserMidjourney(c *gin.Context) {
if setting.MjForwardUrlEnabled {
for i, midjourney := range items {
- midjourney.ImageUrl = setting.ServerAddress + "/mj/image/" + midjourney.MjId
+ midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId
items[i] = midjourney
}
}
diff --git a/controller/misc.go b/controller/misc.go
index 085829302..875142ffb 100644
--- a/controller/misc.go
+++ b/controller/misc.go
@@ -58,7 +58,7 @@ func GetStatus(c *gin.Context) {
"footer_html": common.Footer,
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"wechat_login": common.WeChatAuthEnabled,
- "server_address": setting.ServerAddress,
+ "server_address": system_setting.ServerAddress,
"turnstile_check": common.TurnstileCheckEnabled,
"turnstile_site_key": common.TurnstileSiteKey,
"top_up_link": common.TopUpLink,
@@ -249,7 +249,7 @@ func SendPasswordResetEmail(c *gin.Context) {
}
code := common.GenerateVerificationCode(0)
common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose)
- link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", setting.ServerAddress, email, code)
+ link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", system_setting.ServerAddress, email, code)
subject := fmt.Sprintf("%s密码重置", common.SystemName)
content := fmt.Sprintf("
您好,你正在进行%s密码重置。
"+
"点击 此处 进行密码重置。
"+
diff --git a/controller/oidc.go b/controller/oidc.go
index f3def0e34..8e254d38f 100644
--- a/controller/oidc.go
+++ b/controller/oidc.go
@@ -8,7 +8,6 @@ import (
"net/url"
"one-api/common"
"one-api/model"
- "one-api/setting"
"one-api/setting/system_setting"
"strconv"
"strings"
@@ -45,7 +44,7 @@ func getOidcUserInfoByCode(code string) (*OidcUser, error) {
values.Set("client_secret", system_setting.GetOIDCSettings().ClientSecret)
values.Set("code", code)
values.Set("grant_type", "authorization_code")
- values.Set("redirect_uri", fmt.Sprintf("%s/oauth/oidc", setting.ServerAddress))
+ values.Set("redirect_uri", fmt.Sprintf("%s/oauth/oidc", system_setting.ServerAddress))
formData := values.Encode()
req, err := http.NewRequest("POST", system_setting.GetOIDCSettings().TokenEndpoint, strings.NewReader(formData))
if err != nil {
diff --git a/controller/topup.go b/controller/topup.go
index 93f3e58e0..243e67940 100644
--- a/controller/topup.go
+++ b/controller/topup.go
@@ -10,6 +10,7 @@ import (
"one-api/service"
"one-api/setting"
"one-api/setting/operation_setting"
+ "one-api/setting/system_setting"
"strconv"
"sync"
"time"
@@ -152,7 +153,7 @@ func RequestEpay(c *gin.Context) {
}
callBackAddress := service.GetCallbackAddress()
- returnUrl, _ := url.Parse(setting.ServerAddress + "/console/log")
+ returnUrl, _ := url.Parse(system_setting.ServerAddress + "/console/log")
notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify")
tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo)
diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go
index bf0d7bf36..d462acb4b 100644
--- a/controller/topup_stripe.go
+++ b/controller/topup_stripe.go
@@ -9,6 +9,7 @@ import (
"one-api/model"
"one-api/setting"
"one-api/setting/operation_setting"
+ "one-api/setting/system_setting"
"strconv"
"strings"
"time"
@@ -216,8 +217,8 @@ func genStripeLink(referenceId string, customerId string, email string, amount i
params := &stripe.CheckoutSessionParams{
ClientReferenceID: stripe.String(referenceId),
- SuccessURL: stripe.String(setting.ServerAddress + "/log"),
- CancelURL: stripe.String(setting.ServerAddress + "/topup"),
+ SuccessURL: stripe.String(system_setting.ServerAddress + "/log"),
+ CancelURL: stripe.String(system_setting.ServerAddress + "/topup"),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(setting.StripePriceId),
diff --git a/model/option.go b/model/option.go
index 73fe92ad1..fefee4e7d 100644
--- a/model/option.go
+++ b/model/option.go
@@ -6,6 +6,7 @@ import (
"one-api/setting/config"
"one-api/setting/operation_setting"
"one-api/setting/ratio_setting"
+ "one-api/setting/system_setting"
"strconv"
"strings"
"time"
@@ -66,9 +67,9 @@ func InitOptionMap() {
common.OptionMap["SystemName"] = common.SystemName
common.OptionMap["Logo"] = common.Logo
common.OptionMap["ServerAddress"] = ""
- common.OptionMap["WorkerUrl"] = setting.WorkerUrl
- common.OptionMap["WorkerValidKey"] = setting.WorkerValidKey
- common.OptionMap["WorkerAllowHttpImageRequestEnabled"] = strconv.FormatBool(setting.WorkerAllowHttpImageRequestEnabled)
+ common.OptionMap["WorkerUrl"] = system_setting.WorkerUrl
+ common.OptionMap["WorkerValidKey"] = system_setting.WorkerValidKey
+ common.OptionMap["WorkerAllowHttpImageRequestEnabled"] = strconv.FormatBool(system_setting.WorkerAllowHttpImageRequestEnabled)
common.OptionMap["PayAddress"] = ""
common.OptionMap["CustomCallbackAddress"] = ""
common.OptionMap["EpayId"] = ""
@@ -271,7 +272,7 @@ func updateOptionMap(key string, value string) (err error) {
case "SMTPSSLEnabled":
common.SMTPSSLEnabled = boolValue
case "WorkerAllowHttpImageRequestEnabled":
- setting.WorkerAllowHttpImageRequestEnabled = boolValue
+ system_setting.WorkerAllowHttpImageRequestEnabled = boolValue
case "DefaultUseAutoGroup":
setting.DefaultUseAutoGroup = boolValue
case "ExposeRatioEnabled":
@@ -293,11 +294,11 @@ func updateOptionMap(key string, value string) (err error) {
case "SMTPToken":
common.SMTPToken = value
case "ServerAddress":
- setting.ServerAddress = value
+ system_setting.ServerAddress = value
case "WorkerUrl":
- setting.WorkerUrl = value
+ system_setting.WorkerUrl = value
case "WorkerValidKey":
- setting.WorkerValidKey = value
+ system_setting.WorkerValidKey = value
case "PayAddress":
operation_setting.PayAddress = value
case "Chats":
diff --git a/relay/mjproxy_handler.go b/relay/mjproxy_handler.go
index 7c52cb6be..ec8dfc6b2 100644
--- a/relay/mjproxy_handler.go
+++ b/relay/mjproxy_handler.go
@@ -16,6 +16,7 @@ import (
"one-api/relay/helper"
"one-api/service"
"one-api/setting"
+ "one-api/setting/system_setting"
"strconv"
"strings"
"time"
@@ -131,7 +132,7 @@ func coverMidjourneyTaskDto(c *gin.Context, originTask *model.Midjourney) (midjo
midjourneyTask.FinishTime = originTask.FinishTime
midjourneyTask.ImageUrl = ""
if originTask.ImageUrl != "" && setting.MjForwardUrlEnabled {
- midjourneyTask.ImageUrl = setting.ServerAddress + "/mj/image/" + originTask.MjId
+ midjourneyTask.ImageUrl = system_setting.ServerAddress + "/mj/image/" + originTask.MjId
if originTask.Status != "SUCCESS" {
midjourneyTask.ImageUrl += "?rand=" + strconv.FormatInt(time.Now().UnixNano(), 10)
}
diff --git a/service/epay.go b/service/epay.go
index a1ff484e6..48b84dd58 100644
--- a/service/epay.go
+++ b/service/epay.go
@@ -1,13 +1,13 @@
package service
import (
- "one-api/setting"
"one-api/setting/operation_setting"
+ "one-api/setting/system_setting"
)
func GetCallbackAddress() string {
if operation_setting.CustomCallbackAddress == "" {
- return setting.ServerAddress
+ return system_setting.ServerAddress
}
return operation_setting.CustomCallbackAddress
}
diff --git a/service/quota.go b/service/quota.go
index e078a1ad1..12017e11e 100644
--- a/service/quota.go
+++ b/service/quota.go
@@ -11,8 +11,8 @@ import (
"one-api/logger"
"one-api/model"
relaycommon "one-api/relay/common"
- "one-api/setting"
"one-api/setting/ratio_setting"
+ "one-api/setting/system_setting"
"one-api/types"
"strings"
"time"
@@ -534,7 +534,7 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon
}
if quotaTooLow {
prompt := "您的额度即将用尽"
- topUpLink := fmt.Sprintf("%s/topup", setting.ServerAddress)
+ topUpLink := fmt.Sprintf("%s/topup", system_setting.ServerAddress)
// 根据通知方式生成不同的内容格式
var content string
diff --git a/setting/system_setting.go b/setting/system_setting/system_setting_old.go
similarity index 89%
rename from setting/system_setting.go
rename to setting/system_setting/system_setting_old.go
index c37a61235..4e0f1a502 100644
--- a/setting/system_setting.go
+++ b/setting/system_setting/system_setting_old.go
@@ -1,4 +1,4 @@
-package setting
+package system_setting
var ServerAddress = "http://localhost:3000"
var WorkerUrl = ""
From da6f24a3d48c286e4509a4f0befcb263133ec41b Mon Sep 17 00:00:00 2001
From: Seefs
Date: Sat, 13 Sep 2025 16:26:14 +0800
Subject: [PATCH 033/165] fix veo3 adapter
---
relay/channel/task/vertex/adaptor.go | 85 ++++++++++++++++------------
1 file changed, 48 insertions(+), 37 deletions(-)
diff --git a/relay/channel/task/vertex/adaptor.go b/relay/channel/task/vertex/adaptor.go
index d2ab826d0..4a236b2f0 100644
--- a/relay/channel/task/vertex/adaptor.go
+++ b/relay/channel/task/vertex/adaptor.go
@@ -7,12 +7,12 @@ import (
"fmt"
"io"
"net/http"
+ "one-api/model"
"regexp"
"strings"
"github.com/gin-gonic/gin"
- "one-api/common"
"one-api/constant"
"one-api/dto"
"one-api/relay/channel"
@@ -21,6 +21,10 @@ import (
"one-api/service"
)
+// ============================
+// Request / Response structures
+// ============================
+
type requestPayload struct {
Instances []map[string]any `json:"instances"`
Parameters map[string]any `json:"parameters,omitempty"`
@@ -52,33 +56,35 @@ type operationResponse struct {
} `json:"error"`
}
-type TaskAdaptor struct{}
+// ============================
+// Adaptor implementation
+// ============================
-func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {}
-
-func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) (taskErr *dto.TaskError) {
- info.Action = constant.TaskActionTextGenerate
-
- req := relaycommon.TaskSubmitReq{}
- if err := common.UnmarshalBodyReusable(c, &req); err != nil {
- return service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest)
- }
- if strings.TrimSpace(req.Prompt) == "" {
- return service.TaskErrorWrapperLocal(fmt.Errorf("prompt is required"), "invalid_request", http.StatusBadRequest)
- }
- c.Set("task_request", req)
- return nil
+type TaskAdaptor struct {
+ ChannelType int
+ apiKey string
+ baseURL string
}
-func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) {
+func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
+ a.ChannelType = info.ChannelType
+ a.baseURL = info.ChannelBaseUrl
+ a.apiKey = info.ApiKey
+}
+
+// ValidateRequestAndSetAction parses body, validates fields and sets default action.
+func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
+ // Use the standard validation method for TaskSubmitReq
+ return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionTextGenerate)
+}
+
+// BuildRequestURL constructs the upstream URL.
+func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
adc := &vertexcore.Credentials{}
- if err := json.Unmarshal([]byte(info.ApiKey), adc); err != nil {
+ if err := json.Unmarshal([]byte(a.apiKey), adc); err != nil {
return "", fmt.Errorf("failed to decode credentials: %w", err)
}
modelName := info.OriginModelName
- if v, ok := getRequestModelFromContext(info); ok {
- modelName = v
- }
if modelName == "" {
modelName = "veo-3.0-generate-001"
}
@@ -103,16 +109,17 @@ func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string,
), nil
}
-func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error {
+// BuildRequestHeader sets required headers.
+func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
adc := &vertexcore.Credentials{}
- if err := json.Unmarshal([]byte(info.ApiKey), adc); err != nil {
+ if err := json.Unmarshal([]byte(a.apiKey), adc); err != nil {
return fmt.Errorf("failed to decode credentials: %w", err)
}
- token, err := vertexcore.AcquireAccessToken(*adc, info.ChannelSetting.Proxy)
+ token, err := vertexcore.AcquireAccessToken(*adc, "")
if err != nil {
return fmt.Errorf("failed to acquire access token: %w", err)
}
@@ -121,7 +128,8 @@ func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info
return nil
}
-func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.TaskRelayInfo) (io.Reader, error) {
+// BuildRequestBody converts request into Vertex specific format.
+func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
v, ok := c.Get("task_request")
if !ok {
return nil, fmt.Errorf("request not found in context")
@@ -151,11 +159,13 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.TaskRelayI
return bytes.NewReader(data), nil
}
-func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) {
+// DoRequest delegates to common helper.
+func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
return channel.DoTaskApiRequest(a, c, info, requestBody)
}
-func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, _ *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
+// DoResponse handles upstream response, returns taskID etc.
+func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", nil, service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
@@ -177,6 +187,7 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, _ *relayco
func (a *TaskAdaptor) GetModelList() []string { return []string{"veo-3.0-generate-001"} }
func (a *TaskAdaptor) GetChannelName() string { return "vertex" }
+// FetchTask fetch task status
func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) {
taskID, ok := body["task_id"].(string)
if !ok {
@@ -191,15 +202,15 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http
region = "us-central1"
}
project := extractProjectFromOperationName(upstreamName)
- model := extractModelFromOperationName(upstreamName)
- if project == "" || model == "" {
+ modelName := extractModelFromOperationName(upstreamName)
+ if project == "" || modelName == "" {
return nil, fmt.Errorf("cannot extract project/model from operation name")
}
var url string
if region == "global" {
- url = fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:fetchPredictOperation", project, model)
+ url = fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:fetchPredictOperation", project, modelName)
} else {
- url = fmt.Sprintf("https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:fetchPredictOperation", region, project, region, model)
+ url = fmt.Sprintf("https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:fetchPredictOperation", region, project, region, modelName)
}
payload := map[string]string{"operationName": upstreamName}
data, err := json.Marshal(payload)
@@ -232,17 +243,17 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
}
ti := &relaycommon.TaskInfo{}
if op.Error.Message != "" {
- ti.Status = "FAILURE"
+ ti.Status = model.TaskStatusFailure
ti.Reason = op.Error.Message
ti.Progress = "100%"
return ti, nil
}
if !op.Done {
- ti.Status = "IN_PROGRESS"
+ ti.Status = model.TaskStatusInProgress
ti.Progress = "50%"
return ti, nil
}
- ti.Status = "SUCCESS"
+ ti.Status = model.TaskStatusSuccess
ti.Progress = "100%"
if len(op.Response.Videos) > 0 {
v0 := op.Response.Videos[0]
@@ -290,9 +301,9 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
return ti, nil
}
-func getRequestModelFromContext(info *relaycommon.TaskRelayInfo) (string, bool) {
- return info.OriginModelName, info.OriginModelName != ""
-}
+// ============================
+// helpers
+// ============================
func encodeLocalTaskID(name string) string {
return base64.RawURLEncoding.EncodeToString([]byte(name))
From 72d5b35d3f51d630b6f6156d903c0c0017f9eec8 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Sat, 13 Sep 2025 17:34:22 +0800
Subject: [PATCH 034/165] feat: implement SSRF protection settings and update
related references
---
common/ip.go | 22 +
common/ssrf_protection.go | 384 ++++++++++++++++++
service/{cf_worker.go => download.go} | 26 +-
service/user_notify.go | 12 +-
service/webhook.go | 13 +-
setting/system_setting/fetch_setting.go | 28 ++
types/error.go | 12 +-
web/src/components/settings/SystemSetting.jsx | 200 +++++++++
web/src/i18n/locales/en.json | 24 +-
web/src/i18n/locales/zh.json | 24 +-
10 files changed, 727 insertions(+), 18 deletions(-)
create mode 100644 common/ip.go
create mode 100644 common/ssrf_protection.go
rename service/{cf_worker.go => download.go} (52%)
create mode 100644 setting/system_setting/fetch_setting.go
diff --git a/common/ip.go b/common/ip.go
new file mode 100644
index 000000000..bfb64ee7f
--- /dev/null
+++ b/common/ip.go
@@ -0,0 +1,22 @@
+package common
+
+import "net"
+
+func IsPrivateIP(ip net.IP) bool {
+ if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
+ return true
+ }
+
+ private := []net.IPNet{
+ {IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)},
+ {IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)},
+ {IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)},
+ }
+
+ for _, privateNet := range private {
+ if privateNet.Contains(ip) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/common/ssrf_protection.go b/common/ssrf_protection.go
new file mode 100644
index 000000000..b0988d907
--- /dev/null
+++ b/common/ssrf_protection.go
@@ -0,0 +1,384 @@
+package common
+
+import (
+ "fmt"
+ "net"
+ "net/url"
+ "strconv"
+ "strings"
+)
+
+// SSRFProtection SSRF防护配置
+type SSRFProtection struct {
+ AllowPrivateIp bool
+ WhitelistDomains []string // domain format, e.g. example.com, *.example.com
+ WhitelistIps []string // CIDR format
+ AllowedPorts []int // 允许的端口范围
+}
+
+// DefaultSSRFProtection 默认SSRF防护配置
+var DefaultSSRFProtection = &SSRFProtection{
+ AllowPrivateIp: false,
+ WhitelistDomains: []string{},
+ WhitelistIps: []string{},
+ AllowedPorts: []int{},
+}
+
+// isPrivateIP 检查IP是否为私有地址
+func isPrivateIP(ip net.IP) bool {
+ if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
+ return true
+ }
+
+ // 检查私有网段
+ private := []net.IPNet{
+ {IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 10.0.0.0/8
+ {IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, // 172.16.0.0/12
+ {IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, // 192.168.0.0/16
+ {IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 127.0.0.0/8
+ {IP: net.IPv4(169, 254, 0, 0), Mask: net.CIDRMask(16, 32)}, // 169.254.0.0/16 (链路本地)
+ {IP: net.IPv4(224, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 224.0.0.0/4 (组播)
+ {IP: net.IPv4(240, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 240.0.0.0/4 (保留)
+ }
+
+ for _, privateNet := range private {
+ if privateNet.Contains(ip) {
+ return true
+ }
+ }
+
+ // 检查IPv6私有地址
+ if ip.To4() == nil {
+ // IPv6 loopback
+ if ip.Equal(net.IPv6loopback) {
+ return true
+ }
+ // IPv6 link-local
+ if strings.HasPrefix(ip.String(), "fe80:") {
+ return true
+ }
+ // IPv6 unique local
+ if strings.HasPrefix(ip.String(), "fc") || strings.HasPrefix(ip.String(), "fd") {
+ return true
+ }
+ }
+
+ return false
+}
+
+// parsePortRanges 解析端口范围配置
+// 支持格式: "80", "443", "8000-9000"
+func parsePortRanges(portConfigs []string) ([]int, error) {
+ var ports []int
+
+ for _, config := range portConfigs {
+ config = strings.TrimSpace(config)
+ if config == "" {
+ continue
+ }
+
+ if strings.Contains(config, "-") {
+ // 处理端口范围 "8000-9000"
+ parts := strings.Split(config, "-")
+ if len(parts) != 2 {
+ return nil, fmt.Errorf("invalid port range format: %s", config)
+ }
+
+ startPort, err := strconv.Atoi(strings.TrimSpace(parts[0]))
+ if err != nil {
+ return nil, fmt.Errorf("invalid start port in range %s: %v", config, err)
+ }
+
+ endPort, err := strconv.Atoi(strings.TrimSpace(parts[1]))
+ if err != nil {
+ return nil, fmt.Errorf("invalid end port in range %s: %v", config, err)
+ }
+
+ if startPort > endPort {
+ return nil, fmt.Errorf("invalid port range %s: start port cannot be greater than end port", config)
+ }
+
+ if startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535 {
+ return nil, fmt.Errorf("port range %s contains invalid port numbers (must be 1-65535)", config)
+ }
+
+ // 添加范围内的所有端口
+ for port := startPort; port <= endPort; port++ {
+ ports = append(ports, port)
+ }
+ } else {
+ // 处理单个端口 "80"
+ port, err := strconv.Atoi(config)
+ if err != nil {
+ return nil, fmt.Errorf("invalid port number: %s", config)
+ }
+
+ if port < 1 || port > 65535 {
+ return nil, fmt.Errorf("invalid port number %d (must be 1-65535)", port)
+ }
+
+ ports = append(ports, port)
+ }
+ }
+
+ return ports, nil
+}
+
+// isAllowedPort 检查端口是否被允许
+func (p *SSRFProtection) isAllowedPort(port int) bool {
+ if len(p.AllowedPorts) == 0 {
+ return true // 如果没有配置端口限制,则允许所有端口
+ }
+
+ for _, allowedPort := range p.AllowedPorts {
+ if port == allowedPort {
+ return true
+ }
+ }
+ return false
+}
+
+// isAllowedPortFromRanges 从端口范围字符串检查端口是否被允许
+func isAllowedPortFromRanges(port int, portRanges []string) bool {
+ if len(portRanges) == 0 {
+ return true // 如果没有配置端口限制,则允许所有端口
+ }
+
+ allowedPorts, err := parsePortRanges(portRanges)
+ if err != nil {
+ // 如果解析失败,为安全起见拒绝访问
+ return false
+ }
+
+ for _, allowedPort := range allowedPorts {
+ if port == allowedPort {
+ return true
+ }
+ }
+ return false
+}
+
+// isDomainWhitelisted 检查域名是否在白名单中
+func (p *SSRFProtection) isDomainWhitelisted(domain string) bool {
+ if len(p.WhitelistDomains) == 0 {
+ return false
+ }
+
+ domain = strings.ToLower(domain)
+ for _, whitelistDomain := range p.WhitelistDomains {
+ whitelistDomain = strings.ToLower(whitelistDomain)
+
+ // 精确匹配
+ if domain == whitelistDomain {
+ return true
+ }
+
+ // 通配符匹配 (*.example.com)
+ if strings.HasPrefix(whitelistDomain, "*.") {
+ suffix := strings.TrimPrefix(whitelistDomain, "*.")
+ if strings.HasSuffix(domain, "."+suffix) || domain == suffix {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// isIPWhitelisted 检查IP是否在白名单中
+func (p *SSRFProtection) isIPWhitelisted(ip net.IP) bool {
+ if len(p.WhitelistIps) == 0 {
+ return false
+ }
+
+ for _, whitelistCIDR := range p.WhitelistIps {
+ _, network, err := net.ParseCIDR(whitelistCIDR)
+ if err != nil {
+ // 尝试作为单个IP处理
+ if whitelistIP := net.ParseIP(whitelistCIDR); whitelistIP != nil {
+ if ip.Equal(whitelistIP) {
+ return true
+ }
+ }
+ continue
+ }
+
+ if network.Contains(ip) {
+ return true
+ }
+ }
+ return false
+}
+
+// IsIPAccessAllowed 检查IP是否允许访问
+func (p *SSRFProtection) IsIPAccessAllowed(ip net.IP) bool {
+ // 如果IP在白名单中,直接允许访问(绕过私有IP检查)
+ if p.isIPWhitelisted(ip) {
+ return true
+ }
+
+ // 如果IP白名单为空,允许所有IP(但仍需通过私有IP检查)
+ if len(p.WhitelistIps) == 0 {
+ // 检查私有IP限制
+ if isPrivateIP(ip) && !p.AllowPrivateIp {
+ return false
+ }
+ return true
+ }
+
+ // 如果IP白名单不为空且IP不在白名单中,拒绝访问
+ return false
+}
+
+// ValidateURL 验证URL是否安全
+func (p *SSRFProtection) ValidateURL(urlStr string) error {
+ // 解析URL
+ u, err := url.Parse(urlStr)
+ if err != nil {
+ return fmt.Errorf("invalid URL format: %v", err)
+ }
+
+ // 只允许HTTP/HTTPS协议
+ if u.Scheme != "http" && u.Scheme != "https" {
+ return fmt.Errorf("unsupported protocol: %s (only http/https allowed)", u.Scheme)
+ }
+
+ // 解析主机和端口
+ host, portStr, err := net.SplitHostPort(u.Host)
+ if err != nil {
+ // 没有端口,使用默认端口
+ host = u.Host
+ if u.Scheme == "https" {
+ portStr = "443"
+ } else {
+ portStr = "80"
+ }
+ }
+
+ // 验证端口
+ port, err := strconv.Atoi(portStr)
+ if err != nil {
+ return fmt.Errorf("invalid port: %s", portStr)
+ }
+
+ if !p.isAllowedPort(port) {
+ return fmt.Errorf("port %d is not allowed", port)
+ }
+
+ // 检查域名白名单
+ if p.isDomainWhitelisted(host) {
+ return nil // 白名单域名直接通过
+ }
+
+ // DNS解析获取IP地址
+ ips, err := net.LookupIP(host)
+ if err != nil {
+ return fmt.Errorf("DNS resolution failed for %s: %v", host, err)
+ }
+
+ // 检查所有解析的IP地址
+ for _, ip := range ips {
+ if !p.IsIPAccessAllowed(ip) {
+ if isPrivateIP(ip) {
+ return fmt.Errorf("private IP address not allowed: %s resolves to %s", host, ip.String())
+ } else {
+ return fmt.Errorf("IP address not in whitelist: %s resolves to %s", host, ip.String())
+ }
+ }
+ }
+
+ return nil
+}
+
+// ValidateURLWithDefaults 使用默认配置验证URL
+func ValidateURLWithDefaults(urlStr string) error {
+ return DefaultSSRFProtection.ValidateURL(urlStr)
+}
+
+// ValidateURLWithFetchSetting 使用FetchSetting配置验证URL
+func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, whitelistDomains, whitelistIps, allowedPorts []string) error {
+ // 如果SSRF防护被禁用,直接返回成功
+ if !enableSSRFProtection {
+ return nil
+ }
+
+ // 解析端口范围配置
+ allowedPortInts, err := parsePortRanges(allowedPorts)
+ if err != nil {
+ return fmt.Errorf("request reject - invalid port configuration: %v", err)
+ }
+
+ protection := &SSRFProtection{
+ AllowPrivateIp: allowPrivateIp,
+ WhitelistDomains: whitelistDomains,
+ WhitelistIps: whitelistIps,
+ AllowedPorts: allowedPortInts,
+ }
+ return protection.ValidateURL(urlStr)
+}
+
+// ValidateURLWithPortRanges 直接使用端口范围字符串验证URL(更高效的版本)
+func ValidateURLWithPortRanges(urlStr string, allowPrivateIp bool, whitelistDomains, whitelistIps, allowedPorts []string) error {
+ // 解析URL
+ u, err := url.Parse(urlStr)
+ if err != nil {
+ return fmt.Errorf("invalid URL format: %v", err)
+ }
+
+ // 只允许HTTP/HTTPS协议
+ if u.Scheme != "http" && u.Scheme != "https" {
+ return fmt.Errorf("unsupported protocol: %s (only http/https allowed)", u.Scheme)
+ }
+
+ // 解析主机和端口
+ host, portStr, err := net.SplitHostPort(u.Host)
+ if err != nil {
+ // 没有端口,使用默认端口
+ host = u.Host
+ if u.Scheme == "https" {
+ portStr = "443"
+ } else {
+ portStr = "80"
+ }
+ }
+
+ // 验证端口
+ port, err := strconv.Atoi(portStr)
+ if err != nil {
+ return fmt.Errorf("invalid port: %s", portStr)
+ }
+
+ if !isAllowedPortFromRanges(port, allowedPorts) {
+ return fmt.Errorf("port %d is not allowed", port)
+ }
+
+ // 创建临时的SSRFProtection来复用域名和IP检查逻辑
+ protection := &SSRFProtection{
+ AllowPrivateIp: allowPrivateIp,
+ WhitelistDomains: whitelistDomains,
+ WhitelistIps: whitelistIps,
+ }
+
+ // 检查域名白名单
+ if protection.isDomainWhitelisted(host) {
+ return nil // 白名单域名直接通过
+ }
+
+ // DNS解析获取IP地址
+ ips, err := net.LookupIP(host)
+ if err != nil {
+ return fmt.Errorf("DNS resolution failed for %s: %v", host, err)
+ }
+
+ // 检查所有解析的IP地址
+ for _, ip := range ips {
+ if !protection.IsIPAccessAllowed(ip) {
+ if isPrivateIP(ip) {
+ return fmt.Errorf("private IP address not allowed: %s resolves to %s", host, ip.String())
+ } else {
+ return fmt.Errorf("IP address not in whitelist: %s resolves to %s", host, ip.String())
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/service/cf_worker.go b/service/download.go
similarity index 52%
rename from service/cf_worker.go
rename to service/download.go
index 4a7b43760..2f30870d4 100644
--- a/service/cf_worker.go
+++ b/service/download.go
@@ -6,7 +6,7 @@ import (
"fmt"
"net/http"
"one-api/common"
- "one-api/setting"
+ "one-api/setting/system_setting"
"strings"
)
@@ -21,14 +21,20 @@ type WorkerRequest struct {
// DoWorkerRequest 通过Worker发送请求
func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) {
- if !setting.EnableWorker() {
+ if !system_setting.EnableWorker() {
return nil, fmt.Errorf("worker not enabled")
}
- if !setting.WorkerAllowHttpImageRequestEnabled && !strings.HasPrefix(req.URL, "https") {
+ if !system_setting.WorkerAllowHttpImageRequestEnabled && !strings.HasPrefix(req.URL, "https") {
return nil, fmt.Errorf("only support https url")
}
- workerUrl := setting.WorkerUrl
+ // SSRF防护:验证请求URL
+ fetchSetting := system_setting.GetFetchSetting()
+ if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil {
+ return nil, fmt.Errorf("request reject: %v", err)
+ }
+
+ workerUrl := system_setting.WorkerUrl
if !strings.HasSuffix(workerUrl, "/") {
workerUrl += "/"
}
@@ -43,15 +49,21 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) {
}
func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response, err error) {
- if setting.EnableWorker() {
+ if system_setting.EnableWorker() {
common.SysLog(fmt.Sprintf("downloading file from worker: %s, reason: %s", originUrl, strings.Join(reason, ", ")))
req := &WorkerRequest{
URL: originUrl,
- Key: setting.WorkerValidKey,
+ Key: system_setting.WorkerValidKey,
}
return DoWorkerRequest(req)
} else {
- common.SysLog(fmt.Sprintf("downloading from origin with worker: %s, reason: %s", originUrl, strings.Join(reason, ", ")))
+ // SSRF防护:验证请求URL(非Worker模式)
+ fetchSetting := system_setting.GetFetchSetting()
+ if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil {
+ return nil, fmt.Errorf("request reject: %v", err)
+ }
+
+ common.SysLog(fmt.Sprintf("downloading from origin: %s, reason: %s", common.MaskSensitiveInfo(originUrl), strings.Join(reason, ", ")))
return http.Get(originUrl)
}
}
diff --git a/service/user_notify.go b/service/user_notify.go
index c4a3ea91f..f9d7b6691 100644
--- a/service/user_notify.go
+++ b/service/user_notify.go
@@ -7,7 +7,7 @@ import (
"one-api/common"
"one-api/dto"
"one-api/model"
- "one-api/setting"
+ "one-api/setting/system_setting"
"strings"
)
@@ -91,11 +91,11 @@ func sendBarkNotify(barkURL string, data dto.Notify) error {
var resp *http.Response
var err error
- if setting.EnableWorker() {
+ if system_setting.EnableWorker() {
// 使用worker发送请求
workerReq := &WorkerRequest{
URL: finalURL,
- Key: setting.WorkerValidKey,
+ Key: system_setting.WorkerValidKey,
Method: http.MethodGet,
Headers: map[string]string{
"User-Agent": "OneAPI-Bark-Notify/1.0",
@@ -113,6 +113,12 @@ func sendBarkNotify(barkURL string, data dto.Notify) error {
return fmt.Errorf("bark request failed with status code: %d", resp.StatusCode)
}
} else {
+ // SSRF防护:验证Bark URL(非Worker模式)
+ fetchSetting := system_setting.GetFetchSetting()
+ if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil {
+ return fmt.Errorf("request reject: %v", err)
+ }
+
// 直接发送请求
req, err = http.NewRequest(http.MethodGet, finalURL, nil)
if err != nil {
diff --git a/service/webhook.go b/service/webhook.go
index 8faccda30..1f159eb4b 100644
--- a/service/webhook.go
+++ b/service/webhook.go
@@ -8,8 +8,9 @@ import (
"encoding/json"
"fmt"
"net/http"
+ "one-api/common"
"one-api/dto"
- "one-api/setting"
+ "one-api/setting/system_setting"
"time"
)
@@ -56,11 +57,11 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error
var req *http.Request
var resp *http.Response
- if setting.EnableWorker() {
+ if system_setting.EnableWorker() {
// 构建worker请求数据
workerReq := &WorkerRequest{
URL: webhookURL,
- Key: setting.WorkerValidKey,
+ Key: system_setting.WorkerValidKey,
Method: http.MethodPost,
Headers: map[string]string{
"Content-Type": "application/json",
@@ -86,6 +87,12 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error
return fmt.Errorf("webhook request failed with status code: %d", resp.StatusCode)
}
} else {
+ // SSRF防护:验证Webhook URL(非Worker模式)
+ fetchSetting := system_setting.GetFetchSetting()
+ if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil {
+ return fmt.Errorf("request reject: %v", err)
+ }
+
req, err = http.NewRequest(http.MethodPost, webhookURL, bytes.NewBuffer(payloadBytes))
if err != nil {
return fmt.Errorf("failed to create webhook request: %v", err)
diff --git a/setting/system_setting/fetch_setting.go b/setting/system_setting/fetch_setting.go
new file mode 100644
index 000000000..6e47c3f06
--- /dev/null
+++ b/setting/system_setting/fetch_setting.go
@@ -0,0 +1,28 @@
+package system_setting
+
+import "one-api/setting/config"
+
+type FetchSetting struct {
+ EnableSSRFProtection bool `json:"enable_ssrf_protection"` // 是否启用SSRF防护
+ AllowPrivateIp bool `json:"allow_private_ip"`
+ WhitelistDomains []string `json:"whitelist_domains"` // domain format, e.g. example.com, *.example.com
+ WhitelistIps []string `json:"whitelist_ips"` // CIDR format
+ AllowedPorts []string `json:"allowed_ports"` // port range format, e.g. 80, 443, 8000-9000
+}
+
+var defaultFetchSetting = FetchSetting{
+ EnableSSRFProtection: true, // 默认开启SSRF防护
+ AllowPrivateIp: false,
+ WhitelistDomains: []string{},
+ WhitelistIps: []string{},
+ AllowedPorts: []string{"80", "443", "8080", "8443"},
+}
+
+func init() {
+ // 注册到全局配置管理器
+ config.GlobalConfig.Register("fetch_setting", &defaultFetchSetting)
+}
+
+func GetFetchSetting() *FetchSetting {
+ return &defaultFetchSetting
+}
diff --git a/types/error.go b/types/error.go
index 883ee0641..a42e84385 100644
--- a/types/error.go
+++ b/types/error.go
@@ -122,6 +122,9 @@ func (e *NewAPIError) MaskSensitiveError() string {
return string(e.errorCode)
}
errStr := e.Err.Error()
+ if e.errorCode == ErrorCodeCountTokenFailed {
+ return errStr
+ }
return common.MaskSensitiveInfo(errStr)
}
@@ -153,8 +156,9 @@ func (e *NewAPIError) ToOpenAIError() OpenAIError {
Code: e.errorCode,
}
}
-
- result.Message = common.MaskSensitiveInfo(result.Message)
+ if e.errorCode != ErrorCodeCountTokenFailed {
+ result.Message = common.MaskSensitiveInfo(result.Message)
+ }
return result
}
@@ -178,7 +182,9 @@ func (e *NewAPIError) ToClaudeError() ClaudeError {
Type: string(e.errorType),
}
}
- result.Message = common.MaskSensitiveInfo(result.Message)
+ if e.errorCode != ErrorCodeCountTokenFailed {
+ result.Message = common.MaskSensitiveInfo(result.Message)
+ }
return result
}
diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx
index 9c7eeaadc..71dfaac8d 100644
--- a/web/src/components/settings/SystemSetting.jsx
+++ b/web/src/components/settings/SystemSetting.jsx
@@ -44,6 +44,7 @@ import { useTranslation } from 'react-i18next';
const SystemSetting = () => {
const { t } = useTranslation();
let [inputs, setInputs] = useState({
+
PasswordLoginEnabled: '',
PasswordRegisterEnabled: '',
EmailVerificationEnabled: '',
@@ -87,6 +88,12 @@ const SystemSetting = () => {
LinuxDOClientSecret: '',
LinuxDOMinimumTrustLevel: '',
ServerAddress: '',
+ // SSRF防护配置
+ 'fetch_setting.enable_ssrf_protection': true,
+ 'fetch_setting.allow_private_ip': '',
+ 'fetch_setting.whitelist_domains': [],
+ 'fetch_setting.whitelist_ips': [],
+ 'fetch_setting.allowed_ports': [],
});
const [originInputs, setOriginInputs] = useState({});
@@ -98,6 +105,9 @@ const SystemSetting = () => {
useState(false);
const [linuxDOOAuthEnabled, setLinuxDOOAuthEnabled] = useState(false);
const [emailToAdd, setEmailToAdd] = useState('');
+ const [whitelistDomains, setWhitelistDomains] = useState([]);
+ const [whitelistIps, setWhitelistIps] = useState([]);
+ const [allowedPorts, setAllowedPorts] = useState([]);
const getOptions = async () => {
setLoading(true);
@@ -113,6 +123,34 @@ const SystemSetting = () => {
case 'EmailDomainWhitelist':
setEmailDomainWhitelist(item.value ? item.value.split(',') : []);
break;
+ case 'fetch_setting.allow_private_ip':
+ case 'fetch_setting.enable_ssrf_protection':
+ item.value = toBoolean(item.value);
+ break;
+ case 'fetch_setting.whitelist_domains':
+ try {
+ const domains = item.value ? JSON.parse(item.value) : [];
+ setWhitelistDomains(Array.isArray(domains) ? domains : []);
+ } catch (e) {
+ setWhitelistDomains([]);
+ }
+ break;
+ case 'fetch_setting.whitelist_ips':
+ try {
+ const ips = item.value ? JSON.parse(item.value) : [];
+ setWhitelistIps(Array.isArray(ips) ? ips : []);
+ } catch (e) {
+ setWhitelistIps([]);
+ }
+ break;
+ case 'fetch_setting.allowed_ports':
+ try {
+ const ports = item.value ? JSON.parse(item.value) : [];
+ setAllowedPorts(Array.isArray(ports) ? ports : []);
+ } catch (e) {
+ setAllowedPorts(['80', '443', '8080', '8443']);
+ }
+ break;
case 'PasswordLoginEnabled':
case 'PasswordRegisterEnabled':
case 'EmailVerificationEnabled':
@@ -276,6 +314,38 @@ const SystemSetting = () => {
}
};
+ const submitSSRF = async () => {
+ const options = [];
+
+ // 处理域名白名单
+ if (Array.isArray(whitelistDomains)) {
+ options.push({
+ key: 'fetch_setting.whitelist_domains',
+ value: JSON.stringify(whitelistDomains),
+ });
+ }
+
+ // 处理IP白名单
+ if (Array.isArray(whitelistIps)) {
+ options.push({
+ key: 'fetch_setting.whitelist_ips',
+ value: JSON.stringify(whitelistIps),
+ });
+ }
+
+ // 处理端口配置
+ if (Array.isArray(allowedPorts)) {
+ options.push({
+ key: 'fetch_setting.allowed_ports',
+ value: JSON.stringify(allowedPorts),
+ });
+ }
+
+ if (options.length > 0) {
+ await updateOptions(options);
+ }
+ };
+
const handleAddEmail = () => {
if (emailToAdd && emailToAdd.trim() !== '') {
const domain = emailToAdd.trim();
@@ -587,6 +657,136 @@ const SystemSetting = () => {
+
+
+
+ {t('配置服务器端请求伪造(SSRF)防护,用于保护内网资源安全')}
+
+
+
+
+ handleCheckboxChange('fetch_setting.enable_ssrf_protection', e)
+ }
+ >
+ {t('启用SSRF防护(推荐开启以保护服务器安全)')}
+
+
+
+
+
+
+
+ handleCheckboxChange('fetch_setting.allow_private_ip', e)
+ }
+ >
+ {t('允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)')}
+
+
+
+
+
+
+ {t('域名白名单')}
+
+ {t('支持通配符格式,如:example.com, *.api.example.com')}
+
+ {
+ setWhitelistDomains(value);
+ // 触发Form的onChange事件
+ setInputs(prev => ({
+ ...prev,
+ 'fetch_setting.whitelist_domains': value
+ }));
+ }}
+ placeholder={t('输入域名后回车,如:example.com')}
+ style={{ width: '100%' }}
+ />
+
+ {t('域名白名单详细说明')}
+
+
+
+
+
+
+ {t('IP白名单')}
+
+ {t('支持CIDR格式,如:8.8.8.8, 192.168.1.0/24')}
+
+ {
+ setWhitelistIps(value);
+ // 触发Form的onChange事件
+ setInputs(prev => ({
+ ...prev,
+ 'fetch_setting.whitelist_ips': value
+ }));
+ }}
+ placeholder={t('输入IP地址后回车,如:8.8.8.8')}
+ style={{ width: '100%' }}
+ />
+
+ {t('IP白名单详细说明')}
+
+
+
+
+
+
+ {t('允许的端口')}
+
+ {t('支持单个端口和端口范围,如:80, 443, 8000-8999')}
+
+ {
+ setAllowedPorts(value);
+ // 触发Form的onChange事件
+ setInputs(prev => ({
+ ...prev,
+ 'fetch_setting.allowed_ports': value
+ }));
+ }}
+ placeholder={t('输入端口后回车,如:80 或 8000-8999')}
+ style={{ width: '100%' }}
+ />
+
+ {t('端口配置详细说明')}
+
+
+
+
+
+
+
+
Date: Sun, 14 Sep 2025 12:59:44 +0800
Subject: [PATCH 035/165] fix: settings
---
service/cf_worker.go | 12 ++++++------
service/user_notify.go | 6 +++---
service/webhook.go | 6 +++---
3 files changed, 12 insertions(+), 12 deletions(-)
diff --git a/service/cf_worker.go b/service/cf_worker.go
index 4a7b43760..d60b6fad5 100644
--- a/service/cf_worker.go
+++ b/service/cf_worker.go
@@ -6,7 +6,7 @@ import (
"fmt"
"net/http"
"one-api/common"
- "one-api/setting"
+ "one-api/setting/system_setting"
"strings"
)
@@ -21,14 +21,14 @@ type WorkerRequest struct {
// DoWorkerRequest 通过Worker发送请求
func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) {
- if !setting.EnableWorker() {
+ if !system_setting.EnableWorker() {
return nil, fmt.Errorf("worker not enabled")
}
- if !setting.WorkerAllowHttpImageRequestEnabled && !strings.HasPrefix(req.URL, "https") {
+ if !system_setting.WorkerAllowHttpImageRequestEnabled && !strings.HasPrefix(req.URL, "https") {
return nil, fmt.Errorf("only support https url")
}
- workerUrl := setting.WorkerUrl
+ workerUrl := system_setting.WorkerUrl
if !strings.HasSuffix(workerUrl, "/") {
workerUrl += "/"
}
@@ -43,11 +43,11 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) {
}
func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response, err error) {
- if setting.EnableWorker() {
+ if system_setting.EnableWorker() {
common.SysLog(fmt.Sprintf("downloading file from worker: %s, reason: %s", originUrl, strings.Join(reason, ", ")))
req := &WorkerRequest{
URL: originUrl,
- Key: setting.WorkerValidKey,
+ Key: system_setting.WorkerValidKey,
}
return DoWorkerRequest(req)
} else {
diff --git a/service/user_notify.go b/service/user_notify.go
index c4a3ea91f..972ca655c 100644
--- a/service/user_notify.go
+++ b/service/user_notify.go
@@ -7,7 +7,7 @@ import (
"one-api/common"
"one-api/dto"
"one-api/model"
- "one-api/setting"
+ "one-api/setting/system_setting"
"strings"
)
@@ -91,11 +91,11 @@ func sendBarkNotify(barkURL string, data dto.Notify) error {
var resp *http.Response
var err error
- if setting.EnableWorker() {
+ if system_setting.EnableWorker() {
// 使用worker发送请求
workerReq := &WorkerRequest{
URL: finalURL,
- Key: setting.WorkerValidKey,
+ Key: system_setting.WorkerValidKey,
Method: http.MethodGet,
Headers: map[string]string{
"User-Agent": "OneAPI-Bark-Notify/1.0",
diff --git a/service/webhook.go b/service/webhook.go
index 8faccda30..9c6ec8102 100644
--- a/service/webhook.go
+++ b/service/webhook.go
@@ -9,7 +9,7 @@ import (
"fmt"
"net/http"
"one-api/dto"
- "one-api/setting"
+ "one-api/setting/system_setting"
"time"
)
@@ -56,11 +56,11 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error
var req *http.Request
var resp *http.Response
- if setting.EnableWorker() {
+ if system_setting.EnableWorker() {
// 构建worker请求数据
workerReq := &WorkerRequest{
URL: webhookURL,
- Key: setting.WorkerValidKey,
+ Key: system_setting.WorkerValidKey,
Method: http.MethodPost,
Headers: map[string]string{
"Content-Type": "application/json",
From 9790e2c4f687053c4a10ab6c224819f3e8cc3080 Mon Sep 17 00:00:00 2001
From: Xyfacai
Date: Mon, 15 Sep 2025 01:01:48 +0800
Subject: [PATCH 036/165] fix: gemini support webp file
---
relay/channel/gemini/relay-gemini.go | 2 ++
service/pre_consume_quota.go | 2 +-
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go
index eb4afbae1..199c84664 100644
--- a/relay/channel/gemini/relay-gemini.go
+++ b/relay/channel/gemini/relay-gemini.go
@@ -23,6 +23,7 @@ import (
"github.com/gin-gonic/gin"
)
+// https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference?hl=zh-cn#blob
var geminiSupportedMimeTypes = map[string]bool{
"application/pdf": true,
"audio/mpeg": true,
@@ -30,6 +31,7 @@ var geminiSupportedMimeTypes = map[string]bool{
"audio/wav": true,
"image/png": true,
"image/jpeg": true,
+ "image/webp": true,
"text/plain": true,
"video/mov": true,
"video/mpeg": true,
diff --git a/service/pre_consume_quota.go b/service/pre_consume_quota.go
index 3cfabc1a4..0cf53513b 100644
--- a/service/pre_consume_quota.go
+++ b/service/pre_consume_quota.go
@@ -19,7 +19,7 @@ func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo) {
gopool.Go(func() {
relayInfoCopy := *relayInfo
- err := PostConsumeQuota(&relayInfoCopy, -relayInfo.FinalPreConsumedQuota, 0, false)
+ err := PostConsumeQuota(&relayInfoCopy, -relayInfoCopy.FinalPreConsumedQuota, 0, false)
if err != nil {
common.SysLog("error return pre-consumed quota: " + err.Error())
}
From 274872b8e5dee8334f7658be14a6df5048f056fd Mon Sep 17 00:00:00 2001
From: DD <1083962986@qq.com>
Date: Mon, 15 Sep 2025 14:31:31 +0800
Subject: [PATCH 037/165] add submodel icon
---
web/src/helpers/render.jsx | 139 +++++++++++++++++++------------------
1 file changed, 71 insertions(+), 68 deletions(-)
diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx
index 65332701b..c69262582 100644
--- a/web/src/helpers/render.jsx
+++ b/web/src/helpers/render.jsx
@@ -54,6 +54,7 @@ import {
FastGPT,
Kling,
Jimeng,
+ SubModel,
} from '@lobehub/icons';
import {
@@ -342,6 +343,8 @@ export function getChannelIcon(channelType) {
return ;
case 21: // 知识库:AI Proxy
case 44: // 嵌入模型:MokaAI M3E
+ case 53: // 嵌入模型:SubModel
+ return ;
default:
return null; // 未知类型或自定义渠道不显示图标
}
@@ -1191,25 +1194,25 @@ export function renderModelPrice(
const extraServices = [
webSearch && webSearchCallCount > 0
? i18next.t(
- ' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
- {
- count: webSearchCallCount,
- price: webSearchPrice,
- ratio: groupRatio,
- ratioType: ratioLabel,
- },
- )
+ ' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
+ {
+ count: webSearchCallCount,
+ price: webSearchPrice,
+ ratio: groupRatio,
+ ratioType: ratioLabel,
+ },
+ )
: '',
fileSearch && fileSearchCallCount > 0
? i18next.t(
- ' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
- {
- count: fileSearchCallCount,
- price: fileSearchPrice,
- ratio: groupRatio,
- ratioType: ratioLabel,
- },
- )
+ ' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
+ {
+ count: fileSearchCallCount,
+ price: fileSearchPrice,
+ ratio: groupRatio,
+ ratioType: ratioLabel,
+ },
+ )
: '',
].join('');
@@ -1379,10 +1382,10 @@ export function renderAudioModelPrice(
let audioPrice =
(audioInputTokens / 1000000) * inputRatioPrice * audioRatio * groupRatio +
(audioCompletionTokens / 1000000) *
- inputRatioPrice *
- audioRatio *
- audioCompletionRatio *
- groupRatio;
+ inputRatioPrice *
+ audioRatio *
+ audioCompletionRatio *
+ groupRatio;
let price = textPrice + audioPrice;
return (
<>
@@ -1438,27 +1441,27 @@ export function renderAudioModelPrice(
{cacheTokens > 0
? i18next.t(
- '文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
- {
- nonCacheInput: inputTokens - cacheTokens,
- cacheInput: cacheTokens,
- cachePrice: inputRatioPrice * cacheRatio,
- price: inputRatioPrice,
- completion: completionTokens,
- compPrice: completionRatioPrice,
- total: textPrice.toFixed(6),
- },
- )
+ '文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
+ {
+ nonCacheInput: inputTokens - cacheTokens,
+ cacheInput: cacheTokens,
+ cachePrice: inputRatioPrice * cacheRatio,
+ price: inputRatioPrice,
+ completion: completionTokens,
+ compPrice: completionRatioPrice,
+ total: textPrice.toFixed(6),
+ },
+ )
: i18next.t(
- '文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
- {
- input: inputTokens,
- price: inputRatioPrice,
- completion: completionTokens,
- compPrice: completionRatioPrice,
- total: textPrice.toFixed(6),
- },
- )}
+ '文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
+ {
+ input: inputTokens,
+ price: inputRatioPrice,
+ completion: completionTokens,
+ compPrice: completionRatioPrice,
+ total: textPrice.toFixed(6),
+ },
+ )}
{i18next.t(
@@ -1598,35 +1601,35 @@ export function renderClaudeModelPrice(
{cacheTokens > 0 || cacheCreationTokens > 0
? i18next.t(
- '提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
- {
- nonCacheInput: nonCachedTokens,
- cacheInput: cacheTokens,
- cacheRatio: cacheRatio,
- cacheCreationInput: cacheCreationTokens,
- cacheCreationRatio: cacheCreationRatio,
- cachePrice: cacheRatioPrice,
- cacheCreationPrice: cacheCreationRatioPrice,
- price: inputRatioPrice,
- completion: completionTokens,
- compPrice: completionRatioPrice,
- ratio: groupRatio,
- ratioType: ratioLabel,
- total: price.toFixed(6),
- },
- )
+ '提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
+ {
+ nonCacheInput: nonCachedTokens,
+ cacheInput: cacheTokens,
+ cacheRatio: cacheRatio,
+ cacheCreationInput: cacheCreationTokens,
+ cacheCreationRatio: cacheCreationRatio,
+ cachePrice: cacheRatioPrice,
+ cacheCreationPrice: cacheCreationRatioPrice,
+ price: inputRatioPrice,
+ completion: completionTokens,
+ compPrice: completionRatioPrice,
+ ratio: groupRatio,
+ ratioType: ratioLabel,
+ total: price.toFixed(6),
+ },
+ )
: i18next.t(
- '提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
- {
- input: inputTokens,
- price: inputRatioPrice,
- completion: completionTokens,
- compPrice: completionRatioPrice,
- ratio: groupRatio,
- ratioType: ratioLabel,
- total: price.toFixed(6),
- },
- )}
+ '提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
+ {
+ input: inputTokens,
+ price: inputRatioPrice,
+ completion: completionTokens,
+ compPrice: completionRatioPrice,
+ ratio: groupRatio,
+ ratioType: ratioLabel,
+ total: price.toFixed(6),
+ },
+ )}
{i18next.t('仅供参考,以实际扣费为准')}
From 33bf267ce82b82d5a43eeadff9d7b74424ddc2e0 Mon Sep 17 00:00:00 2001
From: feitianbubu
Date: Mon, 15 Sep 2025 14:31:55 +0800
Subject: [PATCH 038/165] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=8D=B3?=
=?UTF-8?q?=E6=A2=A6=E8=A7=86=E9=A2=913.0,=E6=96=B0=E5=A2=9E10s(frames=3D2?=
=?UTF-8?q?41)=E6=94=AF=E6=8C=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/channel/task/jimeng/adaptor.go | 14 ++++++++++----
1 file changed, 10 insertions(+), 4 deletions(-)
diff --git a/relay/channel/task/jimeng/adaptor.go b/relay/channel/task/jimeng/adaptor.go
index 2bc45c547..e870a6590 100644
--- a/relay/channel/task/jimeng/adaptor.go
+++ b/relay/channel/task/jimeng/adaptor.go
@@ -36,6 +36,7 @@ type requestPayload struct {
Prompt string `json:"prompt,omitempty"`
Seed int64 `json:"seed"`
AspectRatio string `json:"aspect_ratio"`
+ Frames int `json:"frames,omitempty"`
}
type responsePayload struct {
@@ -311,10 +312,15 @@ func hmacSHA256(key []byte, data []byte) []byte {
func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) {
r := requestPayload{
- ReqKey: "jimeng_vgfm_i2v_l20",
- Prompt: req.Prompt,
- AspectRatio: "16:9", // Default aspect ratio
- Seed: -1, // Default to random
+ ReqKey: req.Model,
+ Prompt: req.Prompt,
+ }
+
+ switch req.Duration {
+ case 10:
+ r.Frames = 241 // 24*10+1 = 241
+ default:
+ r.Frames = 121 // 24*5+1 = 121
}
// Handle one-of image_urls or binary_data_base64
From f3e220b196028d29ddc2947daa7b3b8da21267a0 Mon Sep 17 00:00:00 2001
From: feitianbubu
Date: Mon, 15 Sep 2025 15:53:41 +0800
Subject: [PATCH 039/165] feat: jimeng video 3.0 req_key convert
---
relay/channel/task/jimeng/adaptor.go | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/relay/channel/task/jimeng/adaptor.go b/relay/channel/task/jimeng/adaptor.go
index e870a6590..b954d7b88 100644
--- a/relay/channel/task/jimeng/adaptor.go
+++ b/relay/channel/task/jimeng/adaptor.go
@@ -340,6 +340,22 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
if err != nil {
return nil, errors.Wrap(err, "unmarshal metadata failed")
}
+
+ // 即梦视频3.0 ReqKey转换
+ // https://www.volcengine.com/docs/85621/1792707
+ if strings.Contains(r.ReqKey, "jimeng_v30") {
+ if len(r.ImageUrls) > 1 {
+ // 多张图片:首尾帧生成
+ r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_tail_v30", 1)
+ } else if len(r.ImageUrls) == 1 {
+ // 单张图片:图生视频
+ r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_v30", 1)
+ } else {
+ // 无图片:文生视频
+ r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_t2v_v30", 1)
+ }
+ }
+
return &r, nil
}
From f236785ed5594c3229ba5ab56d915424436a5281 Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Mon, 15 Sep 2025 16:22:37 +0800
Subject: [PATCH 040/165] =?UTF-8?q?fix:=20stripe=E6=94=AF=E4=BB=98?=
=?UTF-8?q?=E6=88=90=E5=8A=9F=E6=9C=AA=E6=AD=A3=E7=A1=AE=E8=B7=B3=E8=BD=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
controller/topup_stripe.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go
index d462acb4b..ccde91dbe 100644
--- a/controller/topup_stripe.go
+++ b/controller/topup_stripe.go
@@ -217,7 +217,7 @@ func genStripeLink(referenceId string, customerId string, email string, amount i
params := &stripe.CheckoutSessionParams{
ClientReferenceID: stripe.String(referenceId),
- SuccessURL: stripe.String(system_setting.ServerAddress + "/log"),
+ SuccessURL: stripe.String(system_setting.ServerAddress + "/console/log"),
CancelURL: stripe.String(system_setting.ServerAddress + "/topup"),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
From 63f94e76699318134e281c3736fec288695b58e3 Mon Sep 17 00:00:00 2001
From: Xyfacai
Date: Mon, 15 Sep 2025 19:38:31 +0800
Subject: [PATCH 041/165] =?UTF-8?q?fix:=20=E9=9D=9Eopenai=20=E6=B8=A0?=
=?UTF-8?q?=E9=81=93=E4=BD=BF=E7=94=A8=20SystemPrompt=20=E8=AE=BE=E7=BD=AE?=
=?UTF-8?q?=E4=BC=9Apanic?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/compatible_handler.go | 64 +++++++++++++++++++------------------
1 file changed, 33 insertions(+), 31 deletions(-)
diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go
index 01ab1fff4..c2d6b6fa1 100644
--- a/relay/compatible_handler.go
+++ b/relay/compatible_handler.go
@@ -90,41 +90,43 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
if info.ChannelSetting.SystemPrompt != "" {
// 如果有系统提示,则将其添加到请求中
- request := convertedRequest.(*dto.GeneralOpenAIRequest)
- containSystemPrompt := false
- for _, message := range request.Messages {
- if message.Role == request.GetSystemRoleName() {
- containSystemPrompt = true
- break
- }
- }
- if !containSystemPrompt {
- // 如果没有系统提示,则添加系统提示
- systemMessage := dto.Message{
- Role: request.GetSystemRoleName(),
- Content: info.ChannelSetting.SystemPrompt,
- }
- request.Messages = append([]dto.Message{systemMessage}, request.Messages...)
- } else if info.ChannelSetting.SystemPromptOverride {
- common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true)
- // 如果有系统提示,且允许覆盖,则拼接到前面
- for i, message := range request.Messages {
+ request, ok := convertedRequest.(*dto.GeneralOpenAIRequest)
+ if ok {
+ containSystemPrompt := false
+ for _, message := range request.Messages {
if message.Role == request.GetSystemRoleName() {
- if message.IsStringContent() {
- request.Messages[i].SetStringContent(info.ChannelSetting.SystemPrompt + "\n" + message.StringContent())
- } else {
- contents := message.ParseContent()
- contents = append([]dto.MediaContent{
- {
- Type: dto.ContentTypeText,
- Text: info.ChannelSetting.SystemPrompt,
- },
- }, contents...)
- request.Messages[i].Content = contents
- }
+ containSystemPrompt = true
break
}
}
+ if !containSystemPrompt {
+ // 如果没有系统提示,则添加系统提示
+ systemMessage := dto.Message{
+ Role: request.GetSystemRoleName(),
+ Content: info.ChannelSetting.SystemPrompt,
+ }
+ request.Messages = append([]dto.Message{systemMessage}, request.Messages...)
+ } else if info.ChannelSetting.SystemPromptOverride {
+ common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true)
+ // 如果有系统提示,且允许覆盖,则拼接到前面
+ for i, message := range request.Messages {
+ if message.Role == request.GetSystemRoleName() {
+ if message.IsStringContent() {
+ request.Messages[i].SetStringContent(info.ChannelSetting.SystemPrompt + "\n" + message.StringContent())
+ } else {
+ contents := message.ParseContent()
+ contents = append([]dto.MediaContent{
+ {
+ Type: dto.ContentTypeText,
+ Text: info.ChannelSetting.SystemPrompt,
+ },
+ }, contents...)
+ request.Messages[i].Content = contents
+ }
+ break
+ }
+ }
+ }
}
}
From e34b5def602cd29098029f9436bd5c9e4bf97e8c Mon Sep 17 00:00:00 2001
From: QuentinHsu
Date: Mon, 15 Sep 2025 21:45:00 +0800
Subject: [PATCH 042/165] feat: add date range preset constants and use them in
the log filter
---
.../table/mj-logs/MjLogsFilters.jsx | 7 +++
.../table/task-logs/TaskLogsFilters.jsx | 7 +++
.../table/usage-logs/UsageLogsFilters.jsx | 7 +++
web/src/constants/console.constants.js | 49 +++++++++++++++++++
web/src/i18n/locales/en.json | 7 ++-
5 files changed, 76 insertions(+), 1 deletion(-)
create mode 100644 web/src/constants/console.constants.js
diff --git a/web/src/components/table/mj-logs/MjLogsFilters.jsx b/web/src/components/table/mj-logs/MjLogsFilters.jsx
index 44c6bcfcd..6db96e791 100644
--- a/web/src/components/table/mj-logs/MjLogsFilters.jsx
+++ b/web/src/components/table/mj-logs/MjLogsFilters.jsx
@@ -21,6 +21,8 @@ import React from 'react';
import { Button, Form } from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
+import { DATE_RANGE_PRESETS } from '../../../constants/console.constants';
+
const MjLogsFilters = ({
formInitValues,
setFormApi,
@@ -54,6 +56,11 @@ const MjLogsFilters = ({
showClear
pure
size='small'
+ presets={DATE_RANGE_PRESETS.map(preset => ({
+ text: t(preset.text),
+ start: preset.start(),
+ end: preset.end()
+ }))}
/>
diff --git a/web/src/components/table/task-logs/TaskLogsFilters.jsx b/web/src/components/table/task-logs/TaskLogsFilters.jsx
index d5e081ab7..e27cea867 100644
--- a/web/src/components/table/task-logs/TaskLogsFilters.jsx
+++ b/web/src/components/table/task-logs/TaskLogsFilters.jsx
@@ -21,6 +21,8 @@ import React from 'react';
import { Button, Form } from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
+import { DATE_RANGE_PRESETS } from '../../../constants/console.constants';
+
const TaskLogsFilters = ({
formInitValues,
setFormApi,
@@ -54,6 +56,11 @@ const TaskLogsFilters = ({
showClear
pure
size='small'
+ presets={DATE_RANGE_PRESETS.map(preset => ({
+ text: t(preset.text),
+ start: preset.start(),
+ end: preset.end()
+ }))}
/>
diff --git a/web/src/components/table/usage-logs/UsageLogsFilters.jsx b/web/src/components/table/usage-logs/UsageLogsFilters.jsx
index f76ec823e..58e5a4692 100644
--- a/web/src/components/table/usage-logs/UsageLogsFilters.jsx
+++ b/web/src/components/table/usage-logs/UsageLogsFilters.jsx
@@ -21,6 +21,8 @@ import React from 'react';
import { Button, Form } from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
+import { DATE_RANGE_PRESETS } from '../../../constants/console.constants';
+
const LogsFilters = ({
formInitValues,
setFormApi,
@@ -55,6 +57,11 @@ const LogsFilters = ({
showClear
pure
size='small'
+ presets={DATE_RANGE_PRESETS.map(preset => ({
+ text: t(preset.text),
+ start: preset.start(),
+ end: preset.end()
+ }))}
/>
diff --git a/web/src/constants/console.constants.js b/web/src/constants/console.constants.js
new file mode 100644
index 000000000..23ee1e17f
--- /dev/null
+++ b/web/src/constants/console.constants.js
@@ -0,0 +1,49 @@
+/*
+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 .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import dayjs from 'dayjs';
+
+// ========== 日期预设常量 ==========
+export const DATE_RANGE_PRESETS = [
+ {
+ text: '今天',
+ start: () => dayjs().startOf('day').toDate(),
+ end: () => dayjs().endOf('day').toDate()
+ },
+ {
+ text: '近 7 天',
+ start: () => dayjs().subtract(6, 'day').startOf('day').toDate(),
+ end: () => dayjs().endOf('day').toDate()
+ },
+ {
+ text: '本周',
+ start: () => dayjs().startOf('week').toDate(),
+ end: () => dayjs().endOf('week').toDate()
+ },
+ {
+ text: '近 30 天',
+ start: () => dayjs().subtract(29, 'day').startOf('day').toDate(),
+ end: () => dayjs().endOf('day').toDate()
+ },
+ {
+ text: '本月',
+ start: () => dayjs().startOf('month').toDate(),
+ end: () => dayjs().endOf('month').toDate()
+ },
+];
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index 73dfbebe7..a527b91c3 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -2084,5 +2084,10 @@
"原价": "Original price",
"优惠": "Discount",
"折": "% off",
- "节省": "Save"
+ "节省": "Save",
+ "今天": "Today",
+ "近 7 天": "Last 7 Days",
+ "本周": "This Week",
+ "本月": "This Month",
+ "近 30 天": "Last 30 Days"
}
From dfa27f3412e5192936db448cf45f281a9a29ab13 Mon Sep 17 00:00:00 2001
From: QuentinHsu
Date: Mon, 15 Sep 2025 22:30:41 +0800
Subject: [PATCH 043/165] feat: add jsconfig.json and configure path aliases
---
web/jsconfig.json | 9 +++++++++
web/vite.config.js | 6 ++++++
2 files changed, 15 insertions(+)
create mode 100644 web/jsconfig.json
diff --git a/web/jsconfig.json b/web/jsconfig.json
new file mode 100644
index 000000000..ced4d0543
--- /dev/null
+++ b/web/jsconfig.json
@@ -0,0 +1,9 @@
+{
+ "compilerOptions": {
+ "baseUrl": "./",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src/**/*"]
+}
\ No newline at end of file
diff --git a/web/vite.config.js b/web/vite.config.js
index 3515dce7b..d57fd9d9b 100644
--- a/web/vite.config.js
+++ b/web/vite.config.js
@@ -20,10 +20,16 @@ For commercial licensing, please contact support@quantumnous.com
import react from '@vitejs/plugin-react';
import { defineConfig, transformWithEsbuild } from 'vite';
import pkg from '@douyinfe/vite-plugin-semi';
+import path from 'path';
const { vitePluginSemi } = pkg;
// https://vitejs.dev/config/
export default defineConfig({
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
plugins: [
{
name: 'treat-js-files-as-jsx',
From 51c4cd9ab52a5b9e7263b3e3ed483594a0900cd3 Mon Sep 17 00:00:00 2001
From: somnifex <98788152+somnifex@users.noreply.github.com>
Date: Mon, 15 Sep 2025 23:01:14 +0800
Subject: [PATCH 044/165] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84ollama?=
=?UTF-8?q?=E6=B8=A0=E9=81=93=E8=AF=B7=E6=B1=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/channel/ollama/adaptor.go | 31 ++--
relay/channel/ollama/dto.go | 91 +++++----
relay/channel/ollama/relay-ollama.go | 263 ++++++++++++++++-----------
relay/channel/ollama/stream.go | 165 +++++++++++++++++
4 files changed, 400 insertions(+), 150 deletions(-)
create mode 100644 relay/channel/ollama/stream.go
diff --git a/relay/channel/ollama/adaptor.go b/relay/channel/ollama/adaptor.go
index d6b5b697e..3732be91b 100644
--- a/relay/channel/ollama/adaptor.go
+++ b/relay/channel/ollama/adaptor.go
@@ -10,6 +10,7 @@ import (
relaycommon "one-api/relay/common"
relayconstant "one-api/relay/constant"
"one-api/types"
+ "strings"
"github.com/gin-gonic/gin"
)
@@ -48,15 +49,15 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
- if info.RelayFormat == types.RelayFormatClaude {
- return info.ChannelBaseUrl + "/v1/chat/completions", nil
- }
- switch info.RelayMode {
- case relayconstant.RelayModeEmbeddings:
+ // embeddings fixed endpoint
+ if info.RelayMode == relayconstant.RelayModeEmbeddings {
return info.ChannelBaseUrl + "/api/embed", nil
- default:
- return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, info.RequestURLPath, info.ChannelType), nil
}
+ // For chat vs generate: if original path contains "/v1/completions" map to generate; otherwise chat
+ if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions {
+ return info.ChannelBaseUrl + "/api/generate", nil
+ }
+ return info.ChannelBaseUrl + "/api/chat", nil
}
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
@@ -66,10 +67,12 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
}
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
- if request == nil {
- return nil, errors.New("request is nil")
+ if request == nil { return nil, errors.New("request is nil") }
+ // decide generate or chat
+ if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions {
+ return openAIToGenerate(c, request)
}
- return requestOpenAI2Ollama(c, request)
+ return openAIChatToOllamaChat(c, request)
}
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
@@ -92,15 +95,13 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
switch info.RelayMode {
case relayconstant.RelayModeEmbeddings:
- usage, err = ollamaEmbeddingHandler(c, info, resp)
+ return ollamaEmbeddingHandler(c, info, resp)
default:
if info.IsStream {
- usage, err = openai.OaiStreamHandler(c, info, resp)
- } else {
- usage, err = openai.OpenaiHandler(c, info, resp)
+ return ollamaStreamHandler(c, info, resp)
}
+ return ollamaChatHandler(c, info, resp)
}
- return
}
func (a *Adaptor) GetModelList() []string {
diff --git a/relay/channel/ollama/dto.go b/relay/channel/ollama/dto.go
index 317c2a4a1..b3d083dce 100644
--- a/relay/channel/ollama/dto.go
+++ b/relay/channel/ollama/dto.go
@@ -5,45 +5,70 @@ import (
"one-api/dto"
)
-type OllamaRequest struct {
- Model string `json:"model,omitempty"`
- Messages []dto.Message `json:"messages,omitempty"`
- Stream bool `json:"stream,omitempty"`
- Temperature *float64 `json:"temperature,omitempty"`
- Seed float64 `json:"seed,omitempty"`
- Topp float64 `json:"top_p,omitempty"`
- TopK int `json:"top_k,omitempty"`
- Stop any `json:"stop,omitempty"`
- MaxTokens uint `json:"max_tokens,omitempty"`
- Tools []dto.ToolCallRequest `json:"tools,omitempty"`
- ResponseFormat any `json:"response_format,omitempty"`
- FrequencyPenalty float64 `json:"frequency_penalty,omitempty"`
- PresencePenalty float64 `json:"presence_penalty,omitempty"`
- Suffix any `json:"suffix,omitempty"`
- StreamOptions *dto.StreamOptions `json:"stream_options,omitempty"`
- Prompt any `json:"prompt,omitempty"`
- Think json.RawMessage `json:"think,omitempty"`
+// OllamaChatMessage represents a single chat message
+type OllamaChatMessage struct {
+ Role string `json:"role"`
+ Content string `json:"content,omitempty"`
+ Images []string `json:"images,omitempty"`
+ ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"`
+ ToolName string `json:"tool_name,omitempty"`
+ Thinking json.RawMessage `json:"thinking,omitempty"`
}
-type Options struct {
- Seed int `json:"seed,omitempty"`
- Temperature *float64 `json:"temperature,omitempty"`
- TopK int `json:"top_k,omitempty"`
- TopP float64 `json:"top_p,omitempty"`
- FrequencyPenalty float64 `json:"frequency_penalty,omitempty"`
- PresencePenalty float64 `json:"presence_penalty,omitempty"`
- NumPredict int `json:"num_predict,omitempty"`
- NumCtx int `json:"num_ctx,omitempty"`
+type OllamaToolFunction struct {
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Parameters interface{} `json:"parameters,omitempty"`
+}
+
+type OllamaTool struct {
+ Type string `json:"type"`
+ Function OllamaToolFunction `json:"function"`
+}
+
+type OllamaToolCall struct {
+ Function struct {
+ Name string `json:"name"`
+ Arguments interface{} `json:"arguments"`
+ } `json:"function"`
+}
+
+// OllamaChatRequest -> /api/chat
+type OllamaChatRequest struct {
+ Model string `json:"model"`
+ Messages []OllamaChatMessage `json:"messages"`
+ Tools interface{} `json:"tools,omitempty"`
+ Format interface{} `json:"format,omitempty"`
+ Stream bool `json:"stream,omitempty"`
+ Options map[string]any `json:"options,omitempty"`
+ KeepAlive interface{} `json:"keep_alive,omitempty"`
+ Think json.RawMessage `json:"think,omitempty"`
+}
+
+// OllamaGenerateRequest -> /api/generate
+type OllamaGenerateRequest struct {
+ Model string `json:"model"`
+ Prompt string `json:"prompt,omitempty"`
+ Suffix string `json:"suffix,omitempty"`
+ Images []string `json:"images,omitempty"`
+ Format interface{} `json:"format,omitempty"`
+ Stream bool `json:"stream,omitempty"`
+ Options map[string]any `json:"options,omitempty"`
+ KeepAlive interface{} `json:"keep_alive,omitempty"`
+ Think json.RawMessage `json:"think,omitempty"`
}
type OllamaEmbeddingRequest struct {
- Model string `json:"model,omitempty"`
- Input []string `json:"input"`
- Options *Options `json:"options,omitempty"`
+ Model string `json:"model"`
+ Input interface{} `json:"input"`
+ Options map[string]any `json:"options,omitempty"`
+ Dimensions int `json:"dimensions,omitempty"`
}
type OllamaEmbeddingResponse struct {
- Error string `json:"error,omitempty"`
- Model string `json:"model"`
- Embedding [][]float64 `json:"embeddings,omitempty"`
+ Error string `json:"error,omitempty"`
+ Model string `json:"model"`
+ Embeddings [][]float64 `json:"embeddings"`
+ PromptEvalCount int `json:"prompt_eval_count,omitempty"`
}
+
diff --git a/relay/channel/ollama/relay-ollama.go b/relay/channel/ollama/relay-ollama.go
index 27c67b4ec..897e22cbd 100644
--- a/relay/channel/ollama/relay-ollama.go
+++ b/relay/channel/ollama/relay-ollama.go
@@ -1,6 +1,7 @@
package ollama
import (
+ "encoding/json"
"fmt"
"io"
"net/http"
@@ -14,121 +15,179 @@ import (
"github.com/gin-gonic/gin"
)
-func requestOpenAI2Ollama(c *gin.Context, request *dto.GeneralOpenAIRequest) (*OllamaRequest, error) {
- messages := make([]dto.Message, 0, len(request.Messages))
- for _, message := range request.Messages {
- if !message.IsStringContent() {
- mediaMessages := message.ParseContent()
- for j, mediaMessage := range mediaMessages {
- if mediaMessage.Type == dto.ContentTypeImageURL {
- imageUrl := mediaMessage.GetImageMedia()
- // check if not base64
- if strings.HasPrefix(imageUrl.Url, "http") {
- fileData, err := service.GetFileBase64FromUrl(c, imageUrl.Url, "formatting image for Ollama")
- if err != nil {
- return nil, err
+// openAIChatToOllamaChat converts OpenAI-style chat request to Ollama chat
+func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaChatRequest, error) {
+ chatReq := &OllamaChatRequest{
+ Model: r.Model,
+ Stream: r.Stream,
+ Options: map[string]any{},
+ Think: r.Think,
+ }
+ // format mapping
+ if r.ResponseFormat != nil {
+ if r.ResponseFormat.Type == "json" {
+ chatReq.Format = "json"
+ } else if r.ResponseFormat.Type == "json_schema" {
+ // supply schema object directly
+ if len(r.ResponseFormat.JsonSchema) > 0 {
+ var schema any
+ _ = json.Unmarshal(r.ResponseFormat.JsonSchema, &schema)
+ chatReq.Format = schema
+ }
+ }
+ }
+
+ // options mapping
+ if r.Temperature != nil { chatReq.Options["temperature"] = r.Temperature }
+ if r.TopP != 0 { chatReq.Options["top_p"] = r.TopP }
+ if r.TopK != 0 { chatReq.Options["top_k"] = r.TopK }
+ if r.FrequencyPenalty != 0 { chatReq.Options["frequency_penalty"] = r.FrequencyPenalty }
+ if r.PresencePenalty != 0 { chatReq.Options["presence_penalty"] = r.PresencePenalty }
+ if r.Seed != 0 { chatReq.Options["seed"] = int(r.Seed) }
+ if mt := r.GetMaxTokens(); mt != 0 { chatReq.Options["num_predict"] = int(mt) }
+
+ // Stop -> options.stop (array)
+ if r.Stop != nil {
+ switch v := r.Stop.(type) {
+ case string:
+ chatReq.Options["stop"] = []string{v}
+ case []string:
+ chatReq.Options["stop"] = v
+ case []any:
+ arr := make([]string,0,len(v))
+ for _, i := range v { if s,ok:=i.(string); ok { arr = append(arr,s) } }
+ if len(arr)>0 { chatReq.Options["stop"] = arr }
+ }
+ }
+
+ // tools
+ if len(r.Tools) > 0 {
+ tools := make([]OllamaTool,0,len(r.Tools))
+ for _, t := range r.Tools {
+ tools = append(tools, OllamaTool{Type: "function", Function: OllamaToolFunction{Name: t.Function.Name, Description: t.Function.Description, Parameters: t.Function.Parameters}})
+ }
+ chatReq.Tools = tools
+ }
+
+ // messages
+ chatReq.Messages = make([]OllamaChatMessage,0,len(r.Messages))
+ for _, m := range r.Messages {
+ // gather text parts & images
+ var textBuilder strings.Builder
+ var images []string
+ if m.IsStringContent() {
+ textBuilder.WriteString(m.StringContent())
+ } else {
+ parts := m.ParseContent()
+ for _, part := range parts {
+ if part.Type == dto.ContentTypeImageURL {
+ img := part.GetImageMedia()
+ if img != nil && img.Url != "" {
+ // ensure base64 dataURL
+ if strings.HasPrefix(img.Url, "http") {
+ fileData, err := service.GetFileBase64FromUrl(c, img.Url, "fetch image for ollama chat")
+ if err != nil { return nil, err }
+ img.Url = fmt.Sprintf("data:%s;base64,%s", fileData.MimeType, fileData.Base64Data)
}
- imageUrl.Url = fmt.Sprintf("data:%s;base64,%s", fileData.MimeType, fileData.Base64Data)
+ images = append(images, img.Url)
}
- mediaMessage.ImageUrl = imageUrl
- mediaMessages[j] = mediaMessage
+ } else if part.Type == dto.ContentTypeText {
+ textBuilder.WriteString(part.Text)
}
}
- message.SetMediaContent(mediaMessages)
}
- messages = append(messages, dto.Message{
- Role: message.Role,
- Content: message.Content,
- ToolCalls: message.ToolCalls,
- ToolCallId: message.ToolCallId,
- })
+ cm := OllamaChatMessage{Role: m.Role, Content: textBuilder.String()}
+ if len(images)>0 { cm.Images = images }
+ // history tool call result message
+ if m.Role == "tool" && m.Name != nil { cm.ToolName = *m.Name }
+ // tool calls from assistant previous message
+ if len(m.ToolCalls)>0 {
+ calls := make([]OllamaToolCall,0,len(m.ToolCalls))
+ for _, tc := range m.ToolCalls {
+ var args interface{}
+ if tc.Function.Arguments != "" { _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) }
+ oc := OllamaToolCall{}
+ oc.Function.Name = tc.Function.Name
+ if args==nil { args = map[string]any{} }
+ oc.Function.Arguments = args
+ calls = append(calls, oc)
+ }
+ cm.ToolCalls = calls
+ }
+ chatReq.Messages = append(chatReq.Messages, cm)
}
- str, ok := request.Stop.(string)
- var Stop []string
- if ok {
- Stop = []string{str}
- } else {
- Stop, _ = request.Stop.([]string)
- }
- ollamaRequest := &OllamaRequest{
- Model: request.Model,
- Messages: messages,
- Stream: request.Stream,
- Temperature: request.Temperature,
- Seed: request.Seed,
- Topp: request.TopP,
- TopK: request.TopK,
- Stop: Stop,
- Tools: request.Tools,
- MaxTokens: request.GetMaxTokens(),
- ResponseFormat: request.ResponseFormat,
- FrequencyPenalty: request.FrequencyPenalty,
- PresencePenalty: request.PresencePenalty,
- Prompt: request.Prompt,
- StreamOptions: request.StreamOptions,
- Suffix: request.Suffix,
- }
- ollamaRequest.Think = request.Think
- return ollamaRequest, nil
+ return chatReq, nil
}
-func requestOpenAI2Embeddings(request dto.EmbeddingRequest) *OllamaEmbeddingRequest {
- return &OllamaEmbeddingRequest{
- Model: request.Model,
- Input: request.ParseInput(),
- Options: &Options{
- Seed: int(request.Seed),
- Temperature: request.Temperature,
- TopP: request.TopP,
- FrequencyPenalty: request.FrequencyPenalty,
- PresencePenalty: request.PresencePenalty,
- },
+// openAIToGenerate converts OpenAI completions request to Ollama generate
+func openAIToGenerate(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaGenerateRequest, error) {
+ gen := &OllamaGenerateRequest{
+ Model: r.Model,
+ Stream: r.Stream,
+ Options: map[string]any{},
+ Think: r.Think,
}
+ // Prompt may be in r.Prompt (string or []any)
+ if r.Prompt != nil {
+ switch v := r.Prompt.(type) {
+ case string:
+ gen.Prompt = v
+ case []any:
+ var sb strings.Builder
+ for _, it := range v { if s,ok:=it.(string); ok { sb.WriteString(s) } }
+ gen.Prompt = sb.String()
+ default:
+ gen.Prompt = fmt.Sprintf("%v", r.Prompt)
+ }
+ }
+ if r.Suffix != nil { if s,ok:=r.Suffix.(string); ok { gen.Suffix = s } }
+ if r.ResponseFormat != nil {
+ if r.ResponseFormat.Type == "json" { gen.Format = "json" } else if r.ResponseFormat.Type == "json_schema" { var schema any; _ = json.Unmarshal(r.ResponseFormat.JsonSchema,&schema); gen.Format=schema }
+ }
+ if r.Temperature != nil { gen.Options["temperature"] = r.Temperature }
+ if r.TopP != 0 { gen.Options["top_p"] = r.TopP }
+ if r.TopK != 0 { gen.Options["top_k"] = r.TopK }
+ if r.FrequencyPenalty != 0 { gen.Options["frequency_penalty"] = r.FrequencyPenalty }
+ if r.PresencePenalty != 0 { gen.Options["presence_penalty"] = r.PresencePenalty }
+ if r.Seed != 0 { gen.Options["seed"] = int(r.Seed) }
+ if mt := r.GetMaxTokens(); mt != 0 { gen.Options["num_predict"] = int(mt) }
+ if r.Stop != nil {
+ switch v := r.Stop.(type) {
+ case string: gen.Options["stop"] = []string{v}
+ case []string: gen.Options["stop"] = v
+ case []any: arr:=make([]string,0,len(v)); for _,i:= range v { if s,ok:=i.(string); ok { arr=append(arr,s) } }; if len(arr)>0 { gen.Options["stop"]=arr }
+ }
+ }
+ return gen, nil
+}
+
+func requestOpenAI2Embeddings(r dto.EmbeddingRequest) *OllamaEmbeddingRequest {
+ opts := map[string]any{}
+ if r.Temperature != nil { opts["temperature"] = r.Temperature }
+ if r.TopP != 0 { opts["top_p"] = r.TopP }
+ if r.TopK != 0 { opts["top_k"] = r.TopK }
+ if r.FrequencyPenalty != 0 { opts["frequency_penalty"] = r.FrequencyPenalty }
+ if r.PresencePenalty != 0 { opts["presence_penalty"] = r.PresencePenalty }
+ if r.Seed != 0 { opts["seed"] = int(r.Seed) }
+ if r.Dimensions != 0 { opts["dimensions"] = r.Dimensions }
+ input := r.ParseInput()
+ if len(input)==1 { return &OllamaEmbeddingRequest{Model:r.Model, Input: input[0], Options: opts, Dimensions:r.Dimensions} }
+ return &OllamaEmbeddingRequest{Model:r.Model, Input: input, Options: opts, Dimensions:r.Dimensions}
}
func ollamaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
- var ollamaEmbeddingResponse OllamaEmbeddingResponse
- responseBody, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
- }
+ var oResp OllamaEmbeddingResponse
+ body, err := io.ReadAll(resp.Body)
+ if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
service.CloseResponseBodyGracefully(resp)
- err = common.Unmarshal(responseBody, &ollamaEmbeddingResponse)
- if err != nil {
- return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
- }
- if ollamaEmbeddingResponse.Error != "" {
- return nil, types.NewOpenAIError(fmt.Errorf("ollama error: %s", ollamaEmbeddingResponse.Error), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
- }
- flattenedEmbeddings := flattenEmbeddings(ollamaEmbeddingResponse.Embedding)
- data := make([]dto.OpenAIEmbeddingResponseItem, 0, 1)
- data = append(data, dto.OpenAIEmbeddingResponseItem{
- Embedding: flattenedEmbeddings,
- Object: "embedding",
- })
- usage := &dto.Usage{
- TotalTokens: info.PromptTokens,
- CompletionTokens: 0,
- PromptTokens: info.PromptTokens,
- }
- embeddingResponse := &dto.OpenAIEmbeddingResponse{
- Object: "list",
- Data: data,
- Model: info.UpstreamModelName,
- Usage: *usage,
- }
- doResponseBody, err := common.Marshal(embeddingResponse)
- if err != nil {
- return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
- }
- service.IOCopyBytesGracefully(c, resp, doResponseBody)
+ if err = common.Unmarshal(body, &oResp); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
+ if oResp.Error != "" { return nil, types.NewOpenAIError(fmt.Errorf("ollama error: %s", oResp.Error), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
+ data := make([]dto.OpenAIEmbeddingResponseItem,0,len(oResp.Embeddings))
+ for i, emb := range oResp.Embeddings { data = append(data, dto.OpenAIEmbeddingResponseItem{Index:i,Object:"embedding",Embedding:emb}) }
+ usage := &dto.Usage{PromptTokens: oResp.PromptEvalCount, CompletionTokens:0, TotalTokens: oResp.PromptEvalCount}
+ embResp := &dto.OpenAIEmbeddingResponse{Object:"list", Data:data, Model: info.UpstreamModelName, Usage:*usage}
+ out, _ := common.Marshal(embResp)
+ service.IOCopyBytesGracefully(c, resp, out)
return usage, nil
}
-func flattenEmbeddings(embeddings [][]float64) []float64 {
- flattened := []float64{}
- for _, row := range embeddings {
- flattened = append(flattened, row...)
- }
- return flattened
-}
diff --git a/relay/channel/ollama/stream.go b/relay/channel/ollama/stream.go
new file mode 100644
index 000000000..3ae9c6d04
--- /dev/null
+++ b/relay/channel/ollama/stream.go
@@ -0,0 +1,165 @@
+package ollama
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "one-api/common"
+ "one-api/dto"
+ "one-api/logger"
+ relaycommon "one-api/relay/common"
+ "one-api/relay/helper"
+ "one-api/service"
+ "one-api/types"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+)
+
+// Ollama streaming chunk (chat or generate)
+type ollamaChatStreamChunk struct {
+ Model string `json:"model"`
+ CreatedAt string `json:"created_at"`
+ // chat
+ Message *struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ ToolCalls []struct { `json:"tool_calls"`
+ Function struct {
+ Name string `json:"name"`
+ Arguments interface{} `json:"arguments"`
+ } `json:"function"`
+ } `json:"tool_calls"`
+ } `json:"message"`
+ // generate
+ Response string `json:"response"`
+ Done bool `json:"done"`
+ DoneReason string `json:"done_reason"`
+ TotalDuration int64 `json:"total_duration"`
+ LoadDuration int64 `json:"load_duration"`
+ PromptEvalCount int `json:"prompt_eval_count"`
+ EvalCount int `json:"eval_count"`
+ // generate mode may use these
+ PromptEvalDuration int64 `json:"prompt_eval_duration"`
+ EvalDuration int64 `json:"eval_duration"`
+}
+
+func toUnix(ts string) int64 { // parse RFC3339 / variant; fallback time.Now
+ if ts == "" { return time.Now().Unix() }
+ // try time.RFC3339 or with nanoseconds
+ t, err := time.Parse(time.RFC3339Nano, ts)
+ if err != nil { t2, err2 := time.Parse(time.RFC3339, ts); if err2==nil { return t2.Unix() }; return time.Now().Unix() }
+ return t.Unix()
+}
+
+// streaming handler: convert Ollama stream -> OpenAI SSE
+func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
+ if resp == nil || resp.Body == nil { return nil, types.NewOpenAIError(fmt.Errorf("empty response"), types.ErrorCodeBadResponse, http.StatusBadRequest) }
+ defer service.CloseResponseBodyGracefully(resp)
+
+ helper.SetEventStreamHeaders(c)
+ scanner := bufio.NewScanner(resp.Body)
+ usage := &dto.Usage{}
+ var model = info.UpstreamModelName
+ var responseId = common.GetUUID()
+ var created = time.Now().Unix()
+ var aggregatedText strings.Builder
+ var toolCallIndex int
+ // send start event
+ start := helper.GenerateStartEmptyResponse(responseId, created, model, nil)
+ if data, err := common.Marshal(start); err == nil { _ = helper.StringData(c, string(data)) }
+
+ for scanner.Scan() {
+ line := scanner.Text()
+ line = strings.TrimSpace(line)
+ if line == "" { continue }
+ var chunk ollamaChatStreamChunk
+ if err := json.Unmarshal([]byte(line), &chunk); err != nil {
+ logger.LogError(c, "ollama stream json decode error: "+err.Error()+" line="+line)
+ return usage, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
+ }
+ if chunk.Model != "" { model = chunk.Model }
+ created = toUnix(chunk.CreatedAt)
+
+ if !chunk.Done {
+ // delta content
+ var content string
+ if chunk.Message != nil { content = chunk.Message.Content } else { content = chunk.Response }
+ if content != "" { aggregatedText.WriteString(content) }
+ delta := dto.ChatCompletionsStreamResponse{
+ Id: responseId,
+ Object: "chat.completion.chunk",
+ Created: created,
+ Model: model,
+ Choices: []dto.ChatCompletionsStreamResponseChoice{ {
+ Index: 0,
+ Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ Role: "assistant" },
+ } },
+ }
+ if content != "" { delta.Choices[0].Delta.SetContentString(content) }
+ // tool calls
+ if chunk.Message != nil && len(chunk.Message.ToolCalls) > 0 {
+ delta.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse,0,len(chunk.Message.ToolCalls))
+ for _, tc := range chunk.Message.ToolCalls {
+ // arguments -> string
+ argBytes, _ := json.Marshal(tc.Function.Arguments)
+ tr := dto.ToolCallResponse{ID:"", Type:nil, Function: dto.FunctionResponse{Name: tc.Function.Name, Arguments: string(argBytes)}}
+ tr.SetIndex(toolCallIndex)
+ toolCallIndex++
+ delta.Choices[0].Delta.ToolCalls = append(delta.Choices[0].Delta.ToolCalls, tr)
+ }
+ }
+ if data, err := common.Marshal(delta); err == nil { _ = helper.StringData(c, string(data)) }
+ continue
+ }
+ // done frame
+ usage.PromptTokens = chunk.PromptEvalCount
+ usage.CompletionTokens = chunk.EvalCount
+ usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
+ finishReason := chunk.DoneReason
+ if finishReason == "" { finishReason = "stop" }
+ stop := helper.GenerateStopResponse(responseId, created, model, finishReason)
+ if data, err := common.Marshal(stop); err == nil { _ = helper.StringData(c, string(data)) }
+ final := helper.GenerateFinalUsageResponse(responseId, created, model, *usage)
+ if data, err := common.Marshal(final); err == nil { _ = helper.StringData(c, string(data)) }
+ }
+ if err := scanner.Err(); err != nil && err != io.EOF { logger.LogError(c, "ollama stream scan error: "+err.Error()) }
+ return usage, nil
+}
+
+// non-stream handler for chat/generate
+func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) }
+ service.CloseResponseBodyGracefully(resp)
+ if common.DebugEnabled { println("ollama non-stream resp:", string(body)) }
+ var chunk ollamaChatStreamChunk
+ if err = json.Unmarshal(body, &chunk); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
+ model := chunk.Model
+ if model == "" { model = info.UpstreamModelName }
+ created := toUnix(chunk.CreatedAt)
+ content := ""
+ if chunk.Message != nil { content = chunk.Message.Content } else { content = chunk.Response }
+ usage := &dto.Usage{PromptTokens: chunk.PromptEvalCount, CompletionTokens: chunk.EvalCount, TotalTokens: chunk.PromptEvalCount + chunk.EvalCount}
+ // Build OpenAI style response
+ full := dto.OpenAITextResponse{
+ Id: common.GetUUID(),
+ Model: model,
+ Object: "chat.completion",
+ Created: created,
+ Choices: []dto.OpenAITextResponseChoice{ {
+ Index: 0,
+ Message: dto.Message{Role: "assistant", Content: contentPtr(content)},
+ FinishReason: func() string { if chunk.DoneReason == "" { return "stop" } ; return chunk.DoneReason }(),
+ } },
+ Usage: *usage,
+ }
+ out, _ := common.Marshal(full)
+ service.IOCopyBytesGracefully(c, resp, out)
+ return usage, nil
+}
+
+func contentPtr(s string) *string { if s=="" { return nil }; return &s }
From fc38c480a1fd764bc5b9d0d7c3fd07acd7bf0694 Mon Sep 17 00:00:00 2001
From: somnifex <98788152+somnifex@users.noreply.github.com>
Date: Mon, 15 Sep 2025 23:09:10 +0800
Subject: [PATCH 045/165] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96ollamaChatStre?=
=?UTF-8?q?amChunk=E7=BB=93=E6=9E=84=E4=BD=93=E5=AD=97=E6=AE=B5=E6=A0=BC?=
=?UTF-8?q?=E5=BC=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/channel/ollama/stream.go | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/relay/channel/ollama/stream.go b/relay/channel/ollama/stream.go
index 3ae9c6d04..db615e8bd 100644
--- a/relay/channel/ollama/stream.go
+++ b/relay/channel/ollama/stream.go
@@ -25,9 +25,9 @@ type ollamaChatStreamChunk struct {
CreatedAt string `json:"created_at"`
// chat
Message *struct {
- Role string `json:"role"`
- Content string `json:"content"`
- ToolCalls []struct { `json:"tool_calls"`
+ Role string `json:"role"`
+ Content string `json:"content"`
+ ToolCalls []struct {
Function struct {
Name string `json:"name"`
Arguments interface{} `json:"arguments"`
@@ -66,7 +66,6 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
var model = info.UpstreamModelName
var responseId = common.GetUUID()
var created = time.Now().Unix()
- var aggregatedText strings.Builder
var toolCallIndex int
// send start event
start := helper.GenerateStartEmptyResponse(responseId, created, model, nil)
From 7d6ba52d85fbe88244b1e8e5c4866b63da3cae6a Mon Sep 17 00:00:00 2001
From: somnifex <98788152+somnifex@users.noreply.github.com>
Date: Mon, 15 Sep 2025 23:15:46 +0800
Subject: [PATCH 046/165] =?UTF-8?q?refactor:=20=E6=9B=B4=E6=96=B0=E8=AF=B7?=
=?UTF-8?q?=E6=B1=82=E8=BD=AC=E6=8D=A2=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BC=98?=
=?UTF-8?q?=E5=8C=96=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8=E8=A7=A3=E6=9E=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/channel/ollama/adaptor.go | 3 ++-
relay/channel/ollama/dto.go | 1 -
relay/channel/ollama/relay-ollama.go | 26 ++++++++++++++------------
relay/channel/ollama/stream.go | 1 -
4 files changed, 16 insertions(+), 15 deletions(-)
diff --git a/relay/channel/ollama/adaptor.go b/relay/channel/ollama/adaptor.go
index 3732be91b..d66839f7b 100644
--- a/relay/channel/ollama/adaptor.go
+++ b/relay/channel/ollama/adaptor.go
@@ -32,7 +32,8 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
openaiRequest.(*dto.GeneralOpenAIRequest).StreamOptions = &dto.StreamOptions{
IncludeUsage: true,
}
- return requestOpenAI2Ollama(c, openaiRequest.(*dto.GeneralOpenAIRequest))
+ // map to ollama chat request (Claude -> OpenAI -> Ollama chat)
+ return openAIChatToOllamaChat(c, openaiRequest.(*dto.GeneralOpenAIRequest))
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
diff --git a/relay/channel/ollama/dto.go b/relay/channel/ollama/dto.go
index b3d083dce..a3e325e2f 100644
--- a/relay/channel/ollama/dto.go
+++ b/relay/channel/ollama/dto.go
@@ -2,7 +2,6 @@ package ollama
import (
"encoding/json"
- "one-api/dto"
)
// OllamaChatMessage represents a single chat message
diff --git a/relay/channel/ollama/relay-ollama.go b/relay/channel/ollama/relay-ollama.go
index 897e22cbd..45424633c 100644
--- a/relay/channel/ollama/relay-ollama.go
+++ b/relay/channel/ollama/relay-ollama.go
@@ -101,18 +101,21 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
// history tool call result message
if m.Role == "tool" && m.Name != nil { cm.ToolName = *m.Name }
// tool calls from assistant previous message
- if len(m.ToolCalls)>0 {
- calls := make([]OllamaToolCall,0,len(m.ToolCalls))
- for _, tc := range m.ToolCalls {
- var args interface{}
- if tc.Function.Arguments != "" { _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) }
- oc := OllamaToolCall{}
- oc.Function.Name = tc.Function.Name
- if args==nil { args = map[string]any{} }
- oc.Function.Arguments = args
- calls = append(calls, oc)
+ if m.ToolCalls != nil && len(m.ToolCalls) > 0 {
+ parsed := m.ParseToolCalls()
+ if len(parsed) > 0 {
+ calls := make([]OllamaToolCall,0,len(parsed))
+ for _, tc := range parsed {
+ var args interface{}
+ if tc.Function.Arguments != "" { _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) }
+ if args==nil { args = map[string]any{} }
+ oc := OllamaToolCall{}
+ oc.Function.Name = tc.Function.Name
+ oc.Function.Arguments = args
+ calls = append(calls, oc)
+ }
+ cm.ToolCalls = calls
}
- cm.ToolCalls = calls
}
chatReq.Messages = append(chatReq.Messages, cm)
}
@@ -165,7 +168,6 @@ func requestOpenAI2Embeddings(r dto.EmbeddingRequest) *OllamaEmbeddingRequest {
opts := map[string]any{}
if r.Temperature != nil { opts["temperature"] = r.Temperature }
if r.TopP != 0 { opts["top_p"] = r.TopP }
- if r.TopK != 0 { opts["top_k"] = r.TopK }
if r.FrequencyPenalty != 0 { opts["frequency_penalty"] = r.FrequencyPenalty }
if r.PresencePenalty != 0 { opts["presence_penalty"] = r.PresencePenalty }
if r.Seed != 0 { opts["seed"] = int(r.Seed) }
diff --git a/relay/channel/ollama/stream.go b/relay/channel/ollama/stream.go
index db615e8bd..d5b104d6f 100644
--- a/relay/channel/ollama/stream.go
+++ b/relay/channel/ollama/stream.go
@@ -87,7 +87,6 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
// delta content
var content string
if chunk.Message != nil { content = chunk.Message.Content } else { content = chunk.Response }
- if content != "" { aggregatedText.WriteString(content) }
delta := dto.ChatCompletionsStreamResponse{
Id: responseId,
Object: "chat.completion.chunk",
From 176fd6eda13537b57e6e1dc4c0f718a5ad523498 Mon Sep 17 00:00:00 2001
From: somnifex <98788152+somnifex@users.noreply.github.com>
Date: Mon, 15 Sep 2025 23:23:53 +0800
Subject: [PATCH 047/165] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96ollamaStreamHa?=
=?UTF-8?q?ndler=E4=B8=AD=E7=9A=84=E5=81=9C=E6=AD=A2=E5=92=8C=E6=9C=80?=
=?UTF-8?q?=E7=BB=88=E4=BD=BF=E7=94=A8=E5=93=8D=E5=BA=94=E9=80=BB=E8=BE=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/channel/ollama/stream.go | 16 ++++++++++++----
1 file changed, 12 insertions(+), 4 deletions(-)
diff --git a/relay/channel/ollama/stream.go b/relay/channel/ollama/stream.go
index d5b104d6f..4e17f12d7 100644
--- a/relay/channel/ollama/stream.go
+++ b/relay/channel/ollama/stream.go
@@ -114,15 +114,23 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
continue
}
// done frame
+ // finalize once and break loop
usage.PromptTokens = chunk.PromptEvalCount
usage.CompletionTokens = chunk.EvalCount
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
finishReason := chunk.DoneReason
if finishReason == "" { finishReason = "stop" }
- stop := helper.GenerateStopResponse(responseId, created, model, finishReason)
- if data, err := common.Marshal(stop); err == nil { _ = helper.StringData(c, string(data)) }
- final := helper.GenerateFinalUsageResponse(responseId, created, model, *usage)
- if data, err := common.Marshal(final); err == nil { _ = helper.StringData(c, string(data)) }
+ // emit stop delta
+ if stop := helper.GenerateStopResponse(responseId, created, model, finishReason); stop != nil {
+ if data, err := common.Marshal(stop); err == nil { _ = helper.StringData(c, string(data)) }
+ }
+ // emit usage frame
+ if final := helper.GenerateFinalUsageResponse(responseId, created, model, *usage); final != nil {
+ if data, err := common.Marshal(final); err == nil { _ = helper.StringData(c, string(data)) }
+ }
+ // send [DONE]
+ helper.Done(c)
+ break
}
if err := scanner.Err(); err != nil && err != io.EOF { logger.LogError(c, "ollama stream scan error: "+err.Error()) }
return usage, nil
From f7d393fc721278774469b3e25eca2df35f25127f Mon Sep 17 00:00:00 2001
From: somnifex <98788152+somnifex@users.noreply.github.com>
Date: Mon, 15 Sep 2025 23:41:09 +0800
Subject: [PATCH 048/165] =?UTF-8?q?refactor:=20=E7=AE=80=E5=8C=96=E8=AF=B7?=
=?UTF-8?q?=E6=B1=82=E8=BD=AC=E6=8D=A2=E5=87=BD=E6=95=B0=E5=92=8C=E6=B5=81?=
=?UTF-8?q?=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/channel/ollama/adaptor.go | 32 ++++------------
relay/channel/ollama/dto.go | 3 --
relay/channel/ollama/relay-ollama.go | 9 -----
relay/channel/ollama/stream.go | 56 +++++++++++++++++++++-------
4 files changed, 49 insertions(+), 51 deletions(-)
diff --git a/relay/channel/ollama/adaptor.go b/relay/channel/ollama/adaptor.go
index d66839f7b..bafe73b92 100644
--- a/relay/channel/ollama/adaptor.go
+++ b/relay/channel/ollama/adaptor.go
@@ -18,10 +18,7 @@ import (
type Adaptor struct {
}
-func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
- //TODO implement me
- return nil, errors.New("not implemented")
-}
+func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { return nil, errors.New("not implemented") }
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
openaiAdaptor := openai.Adaptor{}
@@ -36,29 +33,17 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
return openAIChatToOllamaChat(c, openaiRequest.(*dto.GeneralOpenAIRequest))
}
-func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
- //TODO implement me
- return nil, errors.New("not implemented")
-}
+func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { return nil, errors.New("not implemented") }
-func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
- //TODO implement me
- return nil, errors.New("not implemented")
-}
+func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { return nil, errors.New("not implemented") }
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
- // embeddings fixed endpoint
- if info.RelayMode == relayconstant.RelayModeEmbeddings {
- return info.ChannelBaseUrl + "/api/embed", nil
- }
- // For chat vs generate: if original path contains "/v1/completions" map to generate; otherwise chat
- if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions {
- return info.ChannelBaseUrl + "/api/generate", nil
- }
- return info.ChannelBaseUrl + "/api/chat", nil
+ if info.RelayMode == relayconstant.RelayModeEmbeddings { return info.ChannelBaseUrl + "/api/embed", nil }
+ if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions { return info.ChannelBaseUrl + "/api/generate", nil }
+ return info.ChannelBaseUrl + "/api/chat", nil
}
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
@@ -84,10 +69,7 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
return requestOpenAI2Embeddings(request), nil
}
-func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
- // TODO implement me
- return nil, errors.New("not implemented")
-}
+func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { return nil, errors.New("not implemented") }
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
return channel.DoApiRequest(a, c, info, requestBody)
diff --git a/relay/channel/ollama/dto.go b/relay/channel/ollama/dto.go
index a3e325e2f..45e49ab43 100644
--- a/relay/channel/ollama/dto.go
+++ b/relay/channel/ollama/dto.go
@@ -4,7 +4,6 @@ import (
"encoding/json"
)
-// OllamaChatMessage represents a single chat message
type OllamaChatMessage struct {
Role string `json:"role"`
Content string `json:"content,omitempty"`
@@ -32,7 +31,6 @@ type OllamaToolCall struct {
} `json:"function"`
}
-// OllamaChatRequest -> /api/chat
type OllamaChatRequest struct {
Model string `json:"model"`
Messages []OllamaChatMessage `json:"messages"`
@@ -44,7 +42,6 @@ type OllamaChatRequest struct {
Think json.RawMessage `json:"think,omitempty"`
}
-// OllamaGenerateRequest -> /api/generate
type OllamaGenerateRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt,omitempty"`
diff --git a/relay/channel/ollama/relay-ollama.go b/relay/channel/ollama/relay-ollama.go
index 45424633c..c79f98760 100644
--- a/relay/channel/ollama/relay-ollama.go
+++ b/relay/channel/ollama/relay-ollama.go
@@ -15,7 +15,6 @@ import (
"github.com/gin-gonic/gin"
)
-// openAIChatToOllamaChat converts OpenAI-style chat request to Ollama chat
func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaChatRequest, error) {
chatReq := &OllamaChatRequest{
Model: r.Model,
@@ -23,12 +22,10 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
Options: map[string]any{},
Think: r.Think,
}
- // format mapping
if r.ResponseFormat != nil {
if r.ResponseFormat.Type == "json" {
chatReq.Format = "json"
} else if r.ResponseFormat.Type == "json_schema" {
- // supply schema object directly
if len(r.ResponseFormat.JsonSchema) > 0 {
var schema any
_ = json.Unmarshal(r.ResponseFormat.JsonSchema, &schema)
@@ -46,7 +43,6 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
if r.Seed != 0 { chatReq.Options["seed"] = int(r.Seed) }
if mt := r.GetMaxTokens(); mt != 0 { chatReq.Options["num_predict"] = int(mt) }
- // Stop -> options.stop (array)
if r.Stop != nil {
switch v := r.Stop.(type) {
case string:
@@ -60,7 +56,6 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
}
}
- // tools
if len(r.Tools) > 0 {
tools := make([]OllamaTool,0,len(r.Tools))
for _, t := range r.Tools {
@@ -69,10 +64,8 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
chatReq.Tools = tools
}
- // messages
chatReq.Messages = make([]OllamaChatMessage,0,len(r.Messages))
for _, m := range r.Messages {
- // gather text parts & images
var textBuilder strings.Builder
var images []string
if m.IsStringContent() {
@@ -98,9 +91,7 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
}
cm := OllamaChatMessage{Role: m.Role, Content: textBuilder.String()}
if len(images)>0 { cm.Images = images }
- // history tool call result message
if m.Role == "tool" && m.Name != nil { cm.ToolName = *m.Name }
- // tool calls from assistant previous message
if m.ToolCalls != nil && len(m.ToolCalls) > 0 {
parsed := m.ParseToolCalls()
if len(parsed) > 0 {
diff --git a/relay/channel/ollama/stream.go b/relay/channel/ollama/stream.go
index 4e17f12d7..167c676d6 100644
--- a/relay/channel/ollama/stream.go
+++ b/relay/channel/ollama/stream.go
@@ -19,7 +19,6 @@ import (
"github.com/gin-gonic/gin"
)
-// Ollama streaming chunk (chat or generate)
type ollamaChatStreamChunk struct {
Model string `json:"model"`
CreatedAt string `json:"created_at"`
@@ -47,7 +46,7 @@ type ollamaChatStreamChunk struct {
EvalDuration int64 `json:"eval_duration"`
}
-func toUnix(ts string) int64 { // parse RFC3339 / variant; fallback time.Now
+func toUnix(ts string) int64 {
if ts == "" { return time.Now().Unix() }
// try time.RFC3339 or with nanoseconds
t, err := time.Parse(time.RFC3339Nano, ts)
@@ -55,7 +54,6 @@ func toUnix(ts string) int64 { // parse RFC3339 / variant; fallback time.Now
return t.Unix()
}
-// streaming handler: convert Ollama stream -> OpenAI SSE
func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
if resp == nil || resp.Body == nil { return nil, types.NewOpenAIError(fmt.Errorf("empty response"), types.ErrorCodeBadResponse, http.StatusBadRequest) }
defer service.CloseResponseBodyGracefully(resp)
@@ -67,7 +65,6 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
var responseId = common.GetUUID()
var created = time.Now().Unix()
var toolCallIndex int
- // send start event
start := helper.GenerateStartEmptyResponse(responseId, created, model, nil)
if data, err := common.Marshal(start); err == nil { _ = helper.StringData(c, string(data)) }
@@ -141,16 +138,47 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
body, err := io.ReadAll(resp.Body)
if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) }
service.CloseResponseBodyGracefully(resp)
- if common.DebugEnabled { println("ollama non-stream resp:", string(body)) }
- var chunk ollamaChatStreamChunk
- if err = json.Unmarshal(body, &chunk); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
- model := chunk.Model
+ raw := string(body)
+ if common.DebugEnabled { println("ollama non-stream raw resp:", raw) }
+
+ lines := strings.Split(raw, "\n")
+ var (
+ aggContent strings.Builder
+ lastChunk ollamaChatStreamChunk
+ parsedAny bool
+ )
+ for _, ln := range lines {
+ ln = strings.TrimSpace(ln)
+ if ln == "" { continue }
+ var ck ollamaChatStreamChunk
+ if err := json.Unmarshal([]byte(ln), &ck); err != nil {
+ if len(lines) == 1 { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
+ continue
+ }
+ parsedAny = true
+ lastChunk = ck
+ if !ck.Done {
+ if ck.Message != nil && ck.Message.Content != "" { aggContent.WriteString(ck.Message.Content) } else if ck.Response != "" { aggContent.WriteString(ck.Response) }
+ } else {
+ if ck.Message != nil && ck.Message.Content != "" { aggContent.WriteString(ck.Message.Content) } else if ck.Response != "" { aggContent.WriteString(ck.Response) }
+ }
+ }
+
+ if !parsedAny {
+ var single ollamaChatStreamChunk
+ if err := json.Unmarshal(body, &single); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
+ lastChunk = single
+ if single.Message != nil { aggContent.WriteString(single.Message.Content) } else { aggContent.WriteString(single.Response) }
+ }
+
+ model := lastChunk.Model
if model == "" { model = info.UpstreamModelName }
- created := toUnix(chunk.CreatedAt)
- content := ""
- if chunk.Message != nil { content = chunk.Message.Content } else { content = chunk.Response }
- usage := &dto.Usage{PromptTokens: chunk.PromptEvalCount, CompletionTokens: chunk.EvalCount, TotalTokens: chunk.PromptEvalCount + chunk.EvalCount}
- // Build OpenAI style response
+ created := toUnix(lastChunk.CreatedAt)
+ usage := &dto.Usage{PromptTokens: lastChunk.PromptEvalCount, CompletionTokens: lastChunk.EvalCount, TotalTokens: lastChunk.PromptEvalCount + lastChunk.EvalCount}
+ content := aggContent.String()
+ finishReason := lastChunk.DoneReason
+ if finishReason == "" { finishReason = "stop" }
+
full := dto.OpenAITextResponse{
Id: common.GetUUID(),
Model: model,
@@ -159,7 +187,7 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
Choices: []dto.OpenAITextResponseChoice{ {
Index: 0,
Message: dto.Message{Role: "assistant", Content: contentPtr(content)},
- FinishReason: func() string { if chunk.DoneReason == "" { return "stop" } ; return chunk.DoneReason }(),
+ FinishReason: &finishReason,
} },
Usage: *usage,
}
From 9d952e0d78569bee80446c4b6ff2881d04159eb6 Mon Sep 17 00:00:00 2001
From: somnifex <98788152+somnifex@users.noreply.github.com>
Date: Mon, 15 Sep 2025 23:43:39 +0800
Subject: [PATCH 049/165] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DollamaChatHand?=
=?UTF-8?q?ler=E4=B8=AD=E7=9A=84FinishReason=E5=AD=97=E6=AE=B5=E8=B5=8B?=
=?UTF-8?q?=E5=80=BC=E9=80=BB=E8=BE=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/channel/ollama/stream.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/relay/channel/ollama/stream.go b/relay/channel/ollama/stream.go
index 167c676d6..ad12e7f83 100644
--- a/relay/channel/ollama/stream.go
+++ b/relay/channel/ollama/stream.go
@@ -187,7 +187,7 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
Choices: []dto.OpenAITextResponseChoice{ {
Index: 0,
Message: dto.Message{Role: "assistant", Content: contentPtr(content)},
- FinishReason: &finishReason,
+ FinishReason: finishReason,
} },
Usage: *usage,
}
From 4eeca081fee46faaeb5551c7af355c1ef03de47b Mon Sep 17 00:00:00 2001
From: somnifex <98788152+somnifex@users.noreply.github.com>
Date: Tue, 16 Sep 2025 08:13:28 +0800
Subject: [PATCH 050/165] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=9B=BE?=
=?UTF-8?q?=E5=83=8FURL=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91=EF=BC=8C?=
=?UTF-8?q?=E7=A1=AE=E4=BF=9D=E6=AD=A3=E7=A1=AE=E7=94=9F=E6=88=90base64?=
=?UTF-8?q?=E6=95=B0=E6=8D=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/channel/ollama/relay-ollama.go | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/relay/channel/ollama/relay-ollama.go b/relay/channel/ollama/relay-ollama.go
index c79f98760..3b67f9525 100644
--- a/relay/channel/ollama/relay-ollama.go
+++ b/relay/channel/ollama/relay-ollama.go
@@ -76,13 +76,17 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
if part.Type == dto.ContentTypeImageURL {
img := part.GetImageMedia()
if img != nil && img.Url != "" {
- // ensure base64 dataURL
+ var base64Data string
if strings.HasPrefix(img.Url, "http") {
fileData, err := service.GetFileBase64FromUrl(c, img.Url, "fetch image for ollama chat")
if err != nil { return nil, err }
- img.Url = fmt.Sprintf("data:%s;base64,%s", fileData.MimeType, fileData.Base64Data)
+ base64Data = fileData.Base64Data
+ } else if strings.HasPrefix(img.Url, "data:") {
+ if idx := strings.Index(img.Url, ","); idx != -1 && idx+1 < len(img.Url) { base64Data = img.Url[idx+1:] }
+ } else {
+ base64Data = img.Url
}
- images = append(images, img.Url)
+ if base64Data != "" { images = append(images, base64Data) }
}
} else if part.Type == dto.ContentTypeText {
textBuilder.WriteString(part.Text)
From 62549717e0652a9914c83ad5e025360e63c0f9c3 Mon Sep 17 00:00:00 2001
From: somnifex <98788152+somnifex@users.noreply.github.com>
Date: Tue, 16 Sep 2025 08:51:29 +0800
Subject: [PATCH 051/165] =?UTF-8?q?fix:=20=E6=B7=BB=E5=8A=A0=E5=AF=B9Think?=
=?UTF-8?q?ing=E5=AD=97=E6=AE=B5=E7=9A=84=E5=A4=84=E7=90=86=E9=80=BB?=
=?UTF-8?q?=E8=BE=91=EF=BC=8C=E7=A1=AE=E4=BF=9D=E6=8E=A8=E7=90=86=E5=86=85?=
=?UTF-8?q?=E5=AE=B9=E6=AD=A3=E7=A1=AE=E4=BC=A0=E9=80=92?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/channel/ollama/stream.go | 31 +++++++++++++++++++++----------
1 file changed, 21 insertions(+), 10 deletions(-)
diff --git a/relay/channel/ollama/stream.go b/relay/channel/ollama/stream.go
index ad12e7f83..cea458444 100644
--- a/relay/channel/ollama/stream.go
+++ b/relay/channel/ollama/stream.go
@@ -26,6 +26,7 @@ type ollamaChatStreamChunk struct {
Message *struct {
Role string `json:"role"`
Content string `json:"content"`
+ Thinking json.RawMessage `json:"thinking"`
ToolCalls []struct {
Function struct {
Name string `json:"name"`
@@ -41,7 +42,6 @@ type ollamaChatStreamChunk struct {
LoadDuration int64 `json:"load_duration"`
PromptEvalCount int `json:"prompt_eval_count"`
EvalCount int `json:"eval_count"`
- // generate mode may use these
PromptEvalDuration int64 `json:"prompt_eval_duration"`
EvalDuration int64 `json:"eval_duration"`
}
@@ -95,13 +95,18 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
} },
}
if content != "" { delta.Choices[0].Delta.SetContentString(content) }
+ if chunk.Message != nil && len(chunk.Message.Thinking) > 0 {
+ raw := strings.TrimSpace(string(chunk.Message.Thinking))
+ if raw != "" && raw != "null" { delta.Choices[0].Delta.SetReasoningContent(raw) }
+ }
// tool calls
if chunk.Message != nil && len(chunk.Message.ToolCalls) > 0 {
delta.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse,0,len(chunk.Message.ToolCalls))
for _, tc := range chunk.Message.ToolCalls {
// arguments -> string
argBytes, _ := json.Marshal(tc.Function.Arguments)
- tr := dto.ToolCallResponse{ID:"", Type:nil, Function: dto.FunctionResponse{Name: tc.Function.Name, Arguments: string(argBytes)}}
+ toolId := fmt.Sprintf("call_%d", toolCallIndex)
+ tr := dto.ToolCallResponse{ID:toolId, Type:"function", Function: dto.FunctionResponse{Name: tc.Function.Name, Arguments: string(argBytes)}}
tr.SetIndex(toolCallIndex)
toolCallIndex++
delta.Choices[0].Delta.ToolCalls = append(delta.Choices[0].Delta.ToolCalls, tr)
@@ -115,8 +120,8 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
usage.PromptTokens = chunk.PromptEvalCount
usage.CompletionTokens = chunk.EvalCount
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
- finishReason := chunk.DoneReason
- if finishReason == "" { finishReason = "stop" }
+ finishReason := chunk.DoneReason
+ if finishReason == "" { finishReason = "stop" }
// emit stop delta
if stop := helper.GenerateStopResponse(responseId, created, model, finishReason); stop != nil {
if data, err := common.Marshal(stop); err == nil { _ = helper.StringData(c, string(data)) }
@@ -144,6 +149,7 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
lines := strings.Split(raw, "\n")
var (
aggContent strings.Builder
+ reasoningBuilder strings.Builder
lastChunk ollamaChatStreamChunk
parsedAny bool
)
@@ -157,18 +163,21 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
}
parsedAny = true
lastChunk = ck
- if !ck.Done {
- if ck.Message != nil && ck.Message.Content != "" { aggContent.WriteString(ck.Message.Content) } else if ck.Response != "" { aggContent.WriteString(ck.Response) }
- } else {
- if ck.Message != nil && ck.Message.Content != "" { aggContent.WriteString(ck.Message.Content) } else if ck.Response != "" { aggContent.WriteString(ck.Response) }
+ if ck.Message != nil && len(ck.Message.Thinking) > 0 {
+ raw := strings.TrimSpace(string(ck.Message.Thinking))
+ if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) }
}
+ if ck.Message != nil && ck.Message.Content != "" { aggContent.WriteString(ck.Message.Content) } else if ck.Response != "" { aggContent.WriteString(ck.Response) }
}
if !parsedAny {
var single ollamaChatStreamChunk
if err := json.Unmarshal(body, &single); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
lastChunk = single
- if single.Message != nil { aggContent.WriteString(single.Message.Content) } else { aggContent.WriteString(single.Response) }
+ if single.Message != nil {
+ if len(single.Message.Thinking) > 0 { raw := strings.TrimSpace(string(single.Message.Thinking)); if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) } }
+ aggContent.WriteString(single.Message.Content)
+ } else { aggContent.WriteString(single.Response) }
}
model := lastChunk.Model
@@ -179,6 +188,8 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
finishReason := lastChunk.DoneReason
if finishReason == "" { finishReason = "stop" }
+ msg := dto.Message{Role: "assistant", Content: contentPtr(content)}
+ if rc := reasoningBuilder.String(); rc != "" { msg.ReasoningContent = &rc }
full := dto.OpenAITextResponse{
Id: common.GetUUID(),
Model: model,
@@ -186,7 +197,7 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
Created: created,
Choices: []dto.OpenAITextResponseChoice{ {
Index: 0,
- Message: dto.Message{Role: "assistant", Content: contentPtr(content)},
+ Message: msg,
FinishReason: finishReason,
} },
Usage: *usage,
From 1dd78b83b7d80e408062f9b18b3db8f860c0a0c9 Mon Sep 17 00:00:00 2001
From: somnifex <98788152+somnifex@users.noreply.github.com>
Date: Tue, 16 Sep 2025 08:54:34 +0800
Subject: [PATCH 052/165] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DollamaChatHand?=
=?UTF-8?q?ler=E4=B8=ADReasoningContent=E5=AD=97=E6=AE=B5=E7=9A=84?=
=?UTF-8?q?=E8=B5=8B=E5=80=BC=E9=80=BB=E8=BE=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/channel/ollama/stream.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/relay/channel/ollama/stream.go b/relay/channel/ollama/stream.go
index cea458444..964f11d90 100644
--- a/relay/channel/ollama/stream.go
+++ b/relay/channel/ollama/stream.go
@@ -189,7 +189,7 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
if finishReason == "" { finishReason = "stop" }
msg := dto.Message{Role: "assistant", Content: contentPtr(content)}
- if rc := reasoningBuilder.String(); rc != "" { msg.ReasoningContent = &rc }
+ if rc := reasoningBuilder.String(); rc != "" { msg.ReasoningContent = rc }
full := dto.OpenAITextResponse{
Id: common.GetUUID(),
Model: model,
From 69a88a0563932d62b1d9bb8f9c310ab119909d51 Mon Sep 17 00:00:00 2001
From: somnifex <98788152+somnifex@users.noreply.github.com>
Date: Tue, 16 Sep 2025 08:58:06 +0800
Subject: [PATCH 053/165] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0ollama?=
=?UTF-8?q?=E8=81=8A=E5=A4=A9=E6=B5=81=E5=A4=84=E7=90=86=E5=92=8C=E9=9D=9E?=
=?UTF-8?q?=E6=B5=81=E5=A4=84=E7=90=86=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../channel/ollama/stream_20250916085416.go | 210 ++++++++++++++++++
.../channel/ollama/stream_20250916085435.go | 210 ++++++++++++++++++
2 files changed, 420 insertions(+)
create mode 100644 .history/relay/channel/ollama/stream_20250916085416.go
create mode 100644 .history/relay/channel/ollama/stream_20250916085435.go
diff --git a/.history/relay/channel/ollama/stream_20250916085416.go b/.history/relay/channel/ollama/stream_20250916085416.go
new file mode 100644
index 000000000..964f11d90
--- /dev/null
+++ b/.history/relay/channel/ollama/stream_20250916085416.go
@@ -0,0 +1,210 @@
+package ollama
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "one-api/common"
+ "one-api/dto"
+ "one-api/logger"
+ relaycommon "one-api/relay/common"
+ "one-api/relay/helper"
+ "one-api/service"
+ "one-api/types"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+)
+
+type ollamaChatStreamChunk struct {
+ Model string `json:"model"`
+ CreatedAt string `json:"created_at"`
+ // chat
+ Message *struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ Thinking json.RawMessage `json:"thinking"`
+ ToolCalls []struct {
+ Function struct {
+ Name string `json:"name"`
+ Arguments interface{} `json:"arguments"`
+ } `json:"function"`
+ } `json:"tool_calls"`
+ } `json:"message"`
+ // generate
+ Response string `json:"response"`
+ Done bool `json:"done"`
+ DoneReason string `json:"done_reason"`
+ TotalDuration int64 `json:"total_duration"`
+ LoadDuration int64 `json:"load_duration"`
+ PromptEvalCount int `json:"prompt_eval_count"`
+ EvalCount int `json:"eval_count"`
+ PromptEvalDuration int64 `json:"prompt_eval_duration"`
+ EvalDuration int64 `json:"eval_duration"`
+}
+
+func toUnix(ts string) int64 {
+ if ts == "" { return time.Now().Unix() }
+ // try time.RFC3339 or with nanoseconds
+ t, err := time.Parse(time.RFC3339Nano, ts)
+ if err != nil { t2, err2 := time.Parse(time.RFC3339, ts); if err2==nil { return t2.Unix() }; return time.Now().Unix() }
+ return t.Unix()
+}
+
+func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
+ if resp == nil || resp.Body == nil { return nil, types.NewOpenAIError(fmt.Errorf("empty response"), types.ErrorCodeBadResponse, http.StatusBadRequest) }
+ defer service.CloseResponseBodyGracefully(resp)
+
+ helper.SetEventStreamHeaders(c)
+ scanner := bufio.NewScanner(resp.Body)
+ usage := &dto.Usage{}
+ var model = info.UpstreamModelName
+ var responseId = common.GetUUID()
+ var created = time.Now().Unix()
+ var toolCallIndex int
+ start := helper.GenerateStartEmptyResponse(responseId, created, model, nil)
+ if data, err := common.Marshal(start); err == nil { _ = helper.StringData(c, string(data)) }
+
+ for scanner.Scan() {
+ line := scanner.Text()
+ line = strings.TrimSpace(line)
+ if line == "" { continue }
+ var chunk ollamaChatStreamChunk
+ if err := json.Unmarshal([]byte(line), &chunk); err != nil {
+ logger.LogError(c, "ollama stream json decode error: "+err.Error()+" line="+line)
+ return usage, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
+ }
+ if chunk.Model != "" { model = chunk.Model }
+ created = toUnix(chunk.CreatedAt)
+
+ if !chunk.Done {
+ // delta content
+ var content string
+ if chunk.Message != nil { content = chunk.Message.Content } else { content = chunk.Response }
+ delta := dto.ChatCompletionsStreamResponse{
+ Id: responseId,
+ Object: "chat.completion.chunk",
+ Created: created,
+ Model: model,
+ Choices: []dto.ChatCompletionsStreamResponseChoice{ {
+ Index: 0,
+ Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ Role: "assistant" },
+ } },
+ }
+ if content != "" { delta.Choices[0].Delta.SetContentString(content) }
+ if chunk.Message != nil && len(chunk.Message.Thinking) > 0 {
+ raw := strings.TrimSpace(string(chunk.Message.Thinking))
+ if raw != "" && raw != "null" { delta.Choices[0].Delta.SetReasoningContent(raw) }
+ }
+ // tool calls
+ if chunk.Message != nil && len(chunk.Message.ToolCalls) > 0 {
+ delta.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse,0,len(chunk.Message.ToolCalls))
+ for _, tc := range chunk.Message.ToolCalls {
+ // arguments -> string
+ argBytes, _ := json.Marshal(tc.Function.Arguments)
+ toolId := fmt.Sprintf("call_%d", toolCallIndex)
+ tr := dto.ToolCallResponse{ID:toolId, Type:"function", Function: dto.FunctionResponse{Name: tc.Function.Name, Arguments: string(argBytes)}}
+ tr.SetIndex(toolCallIndex)
+ toolCallIndex++
+ delta.Choices[0].Delta.ToolCalls = append(delta.Choices[0].Delta.ToolCalls, tr)
+ }
+ }
+ if data, err := common.Marshal(delta); err == nil { _ = helper.StringData(c, string(data)) }
+ continue
+ }
+ // done frame
+ // finalize once and break loop
+ usage.PromptTokens = chunk.PromptEvalCount
+ usage.CompletionTokens = chunk.EvalCount
+ usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
+ finishReason := chunk.DoneReason
+ if finishReason == "" { finishReason = "stop" }
+ // emit stop delta
+ if stop := helper.GenerateStopResponse(responseId, created, model, finishReason); stop != nil {
+ if data, err := common.Marshal(stop); err == nil { _ = helper.StringData(c, string(data)) }
+ }
+ // emit usage frame
+ if final := helper.GenerateFinalUsageResponse(responseId, created, model, *usage); final != nil {
+ if data, err := common.Marshal(final); err == nil { _ = helper.StringData(c, string(data)) }
+ }
+ // send [DONE]
+ helper.Done(c)
+ break
+ }
+ if err := scanner.Err(); err != nil && err != io.EOF { logger.LogError(c, "ollama stream scan error: "+err.Error()) }
+ return usage, nil
+}
+
+// non-stream handler for chat/generate
+func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) }
+ service.CloseResponseBodyGracefully(resp)
+ raw := string(body)
+ if common.DebugEnabled { println("ollama non-stream raw resp:", raw) }
+
+ lines := strings.Split(raw, "\n")
+ var (
+ aggContent strings.Builder
+ reasoningBuilder strings.Builder
+ lastChunk ollamaChatStreamChunk
+ parsedAny bool
+ )
+ for _, ln := range lines {
+ ln = strings.TrimSpace(ln)
+ if ln == "" { continue }
+ var ck ollamaChatStreamChunk
+ if err := json.Unmarshal([]byte(ln), &ck); err != nil {
+ if len(lines) == 1 { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
+ continue
+ }
+ parsedAny = true
+ lastChunk = ck
+ if ck.Message != nil && len(ck.Message.Thinking) > 0 {
+ raw := strings.TrimSpace(string(ck.Message.Thinking))
+ if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) }
+ }
+ if ck.Message != nil && ck.Message.Content != "" { aggContent.WriteString(ck.Message.Content) } else if ck.Response != "" { aggContent.WriteString(ck.Response) }
+ }
+
+ if !parsedAny {
+ var single ollamaChatStreamChunk
+ if err := json.Unmarshal(body, &single); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
+ lastChunk = single
+ if single.Message != nil {
+ if len(single.Message.Thinking) > 0 { raw := strings.TrimSpace(string(single.Message.Thinking)); if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) } }
+ aggContent.WriteString(single.Message.Content)
+ } else { aggContent.WriteString(single.Response) }
+ }
+
+ model := lastChunk.Model
+ if model == "" { model = info.UpstreamModelName }
+ created := toUnix(lastChunk.CreatedAt)
+ usage := &dto.Usage{PromptTokens: lastChunk.PromptEvalCount, CompletionTokens: lastChunk.EvalCount, TotalTokens: lastChunk.PromptEvalCount + lastChunk.EvalCount}
+ content := aggContent.String()
+ finishReason := lastChunk.DoneReason
+ if finishReason == "" { finishReason = "stop" }
+
+ msg := dto.Message{Role: "assistant", Content: contentPtr(content)}
+ if rc := reasoningBuilder.String(); rc != "" { msg.ReasoningContent = rc }
+ full := dto.OpenAITextResponse{
+ Id: common.GetUUID(),
+ Model: model,
+ Object: "chat.completion",
+ Created: created,
+ Choices: []dto.OpenAITextResponseChoice{ {
+ Index: 0,
+ Message: msg,
+ FinishReason: finishReason,
+ } },
+ Usage: *usage,
+ }
+ out, _ := common.Marshal(full)
+ service.IOCopyBytesGracefully(c, resp, out)
+ return usage, nil
+}
+
+func contentPtr(s string) *string { if s=="" { return nil }; return &s }
diff --git a/.history/relay/channel/ollama/stream_20250916085435.go b/.history/relay/channel/ollama/stream_20250916085435.go
new file mode 100644
index 000000000..964f11d90
--- /dev/null
+++ b/.history/relay/channel/ollama/stream_20250916085435.go
@@ -0,0 +1,210 @@
+package ollama
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "one-api/common"
+ "one-api/dto"
+ "one-api/logger"
+ relaycommon "one-api/relay/common"
+ "one-api/relay/helper"
+ "one-api/service"
+ "one-api/types"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+)
+
+type ollamaChatStreamChunk struct {
+ Model string `json:"model"`
+ CreatedAt string `json:"created_at"`
+ // chat
+ Message *struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ Thinking json.RawMessage `json:"thinking"`
+ ToolCalls []struct {
+ Function struct {
+ Name string `json:"name"`
+ Arguments interface{} `json:"arguments"`
+ } `json:"function"`
+ } `json:"tool_calls"`
+ } `json:"message"`
+ // generate
+ Response string `json:"response"`
+ Done bool `json:"done"`
+ DoneReason string `json:"done_reason"`
+ TotalDuration int64 `json:"total_duration"`
+ LoadDuration int64 `json:"load_duration"`
+ PromptEvalCount int `json:"prompt_eval_count"`
+ EvalCount int `json:"eval_count"`
+ PromptEvalDuration int64 `json:"prompt_eval_duration"`
+ EvalDuration int64 `json:"eval_duration"`
+}
+
+func toUnix(ts string) int64 {
+ if ts == "" { return time.Now().Unix() }
+ // try time.RFC3339 or with nanoseconds
+ t, err := time.Parse(time.RFC3339Nano, ts)
+ if err != nil { t2, err2 := time.Parse(time.RFC3339, ts); if err2==nil { return t2.Unix() }; return time.Now().Unix() }
+ return t.Unix()
+}
+
+func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
+ if resp == nil || resp.Body == nil { return nil, types.NewOpenAIError(fmt.Errorf("empty response"), types.ErrorCodeBadResponse, http.StatusBadRequest) }
+ defer service.CloseResponseBodyGracefully(resp)
+
+ helper.SetEventStreamHeaders(c)
+ scanner := bufio.NewScanner(resp.Body)
+ usage := &dto.Usage{}
+ var model = info.UpstreamModelName
+ var responseId = common.GetUUID()
+ var created = time.Now().Unix()
+ var toolCallIndex int
+ start := helper.GenerateStartEmptyResponse(responseId, created, model, nil)
+ if data, err := common.Marshal(start); err == nil { _ = helper.StringData(c, string(data)) }
+
+ for scanner.Scan() {
+ line := scanner.Text()
+ line = strings.TrimSpace(line)
+ if line == "" { continue }
+ var chunk ollamaChatStreamChunk
+ if err := json.Unmarshal([]byte(line), &chunk); err != nil {
+ logger.LogError(c, "ollama stream json decode error: "+err.Error()+" line="+line)
+ return usage, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
+ }
+ if chunk.Model != "" { model = chunk.Model }
+ created = toUnix(chunk.CreatedAt)
+
+ if !chunk.Done {
+ // delta content
+ var content string
+ if chunk.Message != nil { content = chunk.Message.Content } else { content = chunk.Response }
+ delta := dto.ChatCompletionsStreamResponse{
+ Id: responseId,
+ Object: "chat.completion.chunk",
+ Created: created,
+ Model: model,
+ Choices: []dto.ChatCompletionsStreamResponseChoice{ {
+ Index: 0,
+ Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ Role: "assistant" },
+ } },
+ }
+ if content != "" { delta.Choices[0].Delta.SetContentString(content) }
+ if chunk.Message != nil && len(chunk.Message.Thinking) > 0 {
+ raw := strings.TrimSpace(string(chunk.Message.Thinking))
+ if raw != "" && raw != "null" { delta.Choices[0].Delta.SetReasoningContent(raw) }
+ }
+ // tool calls
+ if chunk.Message != nil && len(chunk.Message.ToolCalls) > 0 {
+ delta.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse,0,len(chunk.Message.ToolCalls))
+ for _, tc := range chunk.Message.ToolCalls {
+ // arguments -> string
+ argBytes, _ := json.Marshal(tc.Function.Arguments)
+ toolId := fmt.Sprintf("call_%d", toolCallIndex)
+ tr := dto.ToolCallResponse{ID:toolId, Type:"function", Function: dto.FunctionResponse{Name: tc.Function.Name, Arguments: string(argBytes)}}
+ tr.SetIndex(toolCallIndex)
+ toolCallIndex++
+ delta.Choices[0].Delta.ToolCalls = append(delta.Choices[0].Delta.ToolCalls, tr)
+ }
+ }
+ if data, err := common.Marshal(delta); err == nil { _ = helper.StringData(c, string(data)) }
+ continue
+ }
+ // done frame
+ // finalize once and break loop
+ usage.PromptTokens = chunk.PromptEvalCount
+ usage.CompletionTokens = chunk.EvalCount
+ usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
+ finishReason := chunk.DoneReason
+ if finishReason == "" { finishReason = "stop" }
+ // emit stop delta
+ if stop := helper.GenerateStopResponse(responseId, created, model, finishReason); stop != nil {
+ if data, err := common.Marshal(stop); err == nil { _ = helper.StringData(c, string(data)) }
+ }
+ // emit usage frame
+ if final := helper.GenerateFinalUsageResponse(responseId, created, model, *usage); final != nil {
+ if data, err := common.Marshal(final); err == nil { _ = helper.StringData(c, string(data)) }
+ }
+ // send [DONE]
+ helper.Done(c)
+ break
+ }
+ if err := scanner.Err(); err != nil && err != io.EOF { logger.LogError(c, "ollama stream scan error: "+err.Error()) }
+ return usage, nil
+}
+
+// non-stream handler for chat/generate
+func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) }
+ service.CloseResponseBodyGracefully(resp)
+ raw := string(body)
+ if common.DebugEnabled { println("ollama non-stream raw resp:", raw) }
+
+ lines := strings.Split(raw, "\n")
+ var (
+ aggContent strings.Builder
+ reasoningBuilder strings.Builder
+ lastChunk ollamaChatStreamChunk
+ parsedAny bool
+ )
+ for _, ln := range lines {
+ ln = strings.TrimSpace(ln)
+ if ln == "" { continue }
+ var ck ollamaChatStreamChunk
+ if err := json.Unmarshal([]byte(ln), &ck); err != nil {
+ if len(lines) == 1 { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
+ continue
+ }
+ parsedAny = true
+ lastChunk = ck
+ if ck.Message != nil && len(ck.Message.Thinking) > 0 {
+ raw := strings.TrimSpace(string(ck.Message.Thinking))
+ if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) }
+ }
+ if ck.Message != nil && ck.Message.Content != "" { aggContent.WriteString(ck.Message.Content) } else if ck.Response != "" { aggContent.WriteString(ck.Response) }
+ }
+
+ if !parsedAny {
+ var single ollamaChatStreamChunk
+ if err := json.Unmarshal(body, &single); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
+ lastChunk = single
+ if single.Message != nil {
+ if len(single.Message.Thinking) > 0 { raw := strings.TrimSpace(string(single.Message.Thinking)); if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) } }
+ aggContent.WriteString(single.Message.Content)
+ } else { aggContent.WriteString(single.Response) }
+ }
+
+ model := lastChunk.Model
+ if model == "" { model = info.UpstreamModelName }
+ created := toUnix(lastChunk.CreatedAt)
+ usage := &dto.Usage{PromptTokens: lastChunk.PromptEvalCount, CompletionTokens: lastChunk.EvalCount, TotalTokens: lastChunk.PromptEvalCount + lastChunk.EvalCount}
+ content := aggContent.String()
+ finishReason := lastChunk.DoneReason
+ if finishReason == "" { finishReason = "stop" }
+
+ msg := dto.Message{Role: "assistant", Content: contentPtr(content)}
+ if rc := reasoningBuilder.String(); rc != "" { msg.ReasoningContent = rc }
+ full := dto.OpenAITextResponse{
+ Id: common.GetUUID(),
+ Model: model,
+ Object: "chat.completion",
+ Created: created,
+ Choices: []dto.OpenAITextResponseChoice{ {
+ Index: 0,
+ Message: msg,
+ FinishReason: finishReason,
+ } },
+ Usage: *usage,
+ }
+ out, _ := common.Marshal(full)
+ service.IOCopyBytesGracefully(c, resp, out)
+ return usage, nil
+}
+
+func contentPtr(s string) *string { if s=="" { return nil }; return &s }
From f19b5b8680f5078e2516e856670ecba463c010b5 Mon Sep 17 00:00:00 2001
From: somnifex <98788152+somnifex@users.noreply.github.com>
Date: Tue, 16 Sep 2025 08:58:19 +0800
Subject: [PATCH 054/165] =?UTF-8?q?chore:=20=E5=88=A0=E9=99=A4=E5=8E=86?=
=?UTF-8?q?=E5=8F=B2=E6=96=87=E4=BB=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../channel/ollama/stream_20250916085416.go | 210 ------------------
.../channel/ollama/stream_20250916085435.go | 210 ------------------
2 files changed, 420 deletions(-)
delete mode 100644 .history/relay/channel/ollama/stream_20250916085416.go
delete mode 100644 .history/relay/channel/ollama/stream_20250916085435.go
diff --git a/.history/relay/channel/ollama/stream_20250916085416.go b/.history/relay/channel/ollama/stream_20250916085416.go
deleted file mode 100644
index 964f11d90..000000000
--- a/.history/relay/channel/ollama/stream_20250916085416.go
+++ /dev/null
@@ -1,210 +0,0 @@
-package ollama
-
-import (
- "bufio"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "one-api/common"
- "one-api/dto"
- "one-api/logger"
- relaycommon "one-api/relay/common"
- "one-api/relay/helper"
- "one-api/service"
- "one-api/types"
- "strings"
- "time"
-
- "github.com/gin-gonic/gin"
-)
-
-type ollamaChatStreamChunk struct {
- Model string `json:"model"`
- CreatedAt string `json:"created_at"`
- // chat
- Message *struct {
- Role string `json:"role"`
- Content string `json:"content"`
- Thinking json.RawMessage `json:"thinking"`
- ToolCalls []struct {
- Function struct {
- Name string `json:"name"`
- Arguments interface{} `json:"arguments"`
- } `json:"function"`
- } `json:"tool_calls"`
- } `json:"message"`
- // generate
- Response string `json:"response"`
- Done bool `json:"done"`
- DoneReason string `json:"done_reason"`
- TotalDuration int64 `json:"total_duration"`
- LoadDuration int64 `json:"load_duration"`
- PromptEvalCount int `json:"prompt_eval_count"`
- EvalCount int `json:"eval_count"`
- PromptEvalDuration int64 `json:"prompt_eval_duration"`
- EvalDuration int64 `json:"eval_duration"`
-}
-
-func toUnix(ts string) int64 {
- if ts == "" { return time.Now().Unix() }
- // try time.RFC3339 or with nanoseconds
- t, err := time.Parse(time.RFC3339Nano, ts)
- if err != nil { t2, err2 := time.Parse(time.RFC3339, ts); if err2==nil { return t2.Unix() }; return time.Now().Unix() }
- return t.Unix()
-}
-
-func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
- if resp == nil || resp.Body == nil { return nil, types.NewOpenAIError(fmt.Errorf("empty response"), types.ErrorCodeBadResponse, http.StatusBadRequest) }
- defer service.CloseResponseBodyGracefully(resp)
-
- helper.SetEventStreamHeaders(c)
- scanner := bufio.NewScanner(resp.Body)
- usage := &dto.Usage{}
- var model = info.UpstreamModelName
- var responseId = common.GetUUID()
- var created = time.Now().Unix()
- var toolCallIndex int
- start := helper.GenerateStartEmptyResponse(responseId, created, model, nil)
- if data, err := common.Marshal(start); err == nil { _ = helper.StringData(c, string(data)) }
-
- for scanner.Scan() {
- line := scanner.Text()
- line = strings.TrimSpace(line)
- if line == "" { continue }
- var chunk ollamaChatStreamChunk
- if err := json.Unmarshal([]byte(line), &chunk); err != nil {
- logger.LogError(c, "ollama stream json decode error: "+err.Error()+" line="+line)
- return usage, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
- }
- if chunk.Model != "" { model = chunk.Model }
- created = toUnix(chunk.CreatedAt)
-
- if !chunk.Done {
- // delta content
- var content string
- if chunk.Message != nil { content = chunk.Message.Content } else { content = chunk.Response }
- delta := dto.ChatCompletionsStreamResponse{
- Id: responseId,
- Object: "chat.completion.chunk",
- Created: created,
- Model: model,
- Choices: []dto.ChatCompletionsStreamResponseChoice{ {
- Index: 0,
- Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ Role: "assistant" },
- } },
- }
- if content != "" { delta.Choices[0].Delta.SetContentString(content) }
- if chunk.Message != nil && len(chunk.Message.Thinking) > 0 {
- raw := strings.TrimSpace(string(chunk.Message.Thinking))
- if raw != "" && raw != "null" { delta.Choices[0].Delta.SetReasoningContent(raw) }
- }
- // tool calls
- if chunk.Message != nil && len(chunk.Message.ToolCalls) > 0 {
- delta.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse,0,len(chunk.Message.ToolCalls))
- for _, tc := range chunk.Message.ToolCalls {
- // arguments -> string
- argBytes, _ := json.Marshal(tc.Function.Arguments)
- toolId := fmt.Sprintf("call_%d", toolCallIndex)
- tr := dto.ToolCallResponse{ID:toolId, Type:"function", Function: dto.FunctionResponse{Name: tc.Function.Name, Arguments: string(argBytes)}}
- tr.SetIndex(toolCallIndex)
- toolCallIndex++
- delta.Choices[0].Delta.ToolCalls = append(delta.Choices[0].Delta.ToolCalls, tr)
- }
- }
- if data, err := common.Marshal(delta); err == nil { _ = helper.StringData(c, string(data)) }
- continue
- }
- // done frame
- // finalize once and break loop
- usage.PromptTokens = chunk.PromptEvalCount
- usage.CompletionTokens = chunk.EvalCount
- usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
- finishReason := chunk.DoneReason
- if finishReason == "" { finishReason = "stop" }
- // emit stop delta
- if stop := helper.GenerateStopResponse(responseId, created, model, finishReason); stop != nil {
- if data, err := common.Marshal(stop); err == nil { _ = helper.StringData(c, string(data)) }
- }
- // emit usage frame
- if final := helper.GenerateFinalUsageResponse(responseId, created, model, *usage); final != nil {
- if data, err := common.Marshal(final); err == nil { _ = helper.StringData(c, string(data)) }
- }
- // send [DONE]
- helper.Done(c)
- break
- }
- if err := scanner.Err(); err != nil && err != io.EOF { logger.LogError(c, "ollama stream scan error: "+err.Error()) }
- return usage, nil
-}
-
-// non-stream handler for chat/generate
-func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
- body, err := io.ReadAll(resp.Body)
- if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) }
- service.CloseResponseBodyGracefully(resp)
- raw := string(body)
- if common.DebugEnabled { println("ollama non-stream raw resp:", raw) }
-
- lines := strings.Split(raw, "\n")
- var (
- aggContent strings.Builder
- reasoningBuilder strings.Builder
- lastChunk ollamaChatStreamChunk
- parsedAny bool
- )
- for _, ln := range lines {
- ln = strings.TrimSpace(ln)
- if ln == "" { continue }
- var ck ollamaChatStreamChunk
- if err := json.Unmarshal([]byte(ln), &ck); err != nil {
- if len(lines) == 1 { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
- continue
- }
- parsedAny = true
- lastChunk = ck
- if ck.Message != nil && len(ck.Message.Thinking) > 0 {
- raw := strings.TrimSpace(string(ck.Message.Thinking))
- if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) }
- }
- if ck.Message != nil && ck.Message.Content != "" { aggContent.WriteString(ck.Message.Content) } else if ck.Response != "" { aggContent.WriteString(ck.Response) }
- }
-
- if !parsedAny {
- var single ollamaChatStreamChunk
- if err := json.Unmarshal(body, &single); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
- lastChunk = single
- if single.Message != nil {
- if len(single.Message.Thinking) > 0 { raw := strings.TrimSpace(string(single.Message.Thinking)); if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) } }
- aggContent.WriteString(single.Message.Content)
- } else { aggContent.WriteString(single.Response) }
- }
-
- model := lastChunk.Model
- if model == "" { model = info.UpstreamModelName }
- created := toUnix(lastChunk.CreatedAt)
- usage := &dto.Usage{PromptTokens: lastChunk.PromptEvalCount, CompletionTokens: lastChunk.EvalCount, TotalTokens: lastChunk.PromptEvalCount + lastChunk.EvalCount}
- content := aggContent.String()
- finishReason := lastChunk.DoneReason
- if finishReason == "" { finishReason = "stop" }
-
- msg := dto.Message{Role: "assistant", Content: contentPtr(content)}
- if rc := reasoningBuilder.String(); rc != "" { msg.ReasoningContent = rc }
- full := dto.OpenAITextResponse{
- Id: common.GetUUID(),
- Model: model,
- Object: "chat.completion",
- Created: created,
- Choices: []dto.OpenAITextResponseChoice{ {
- Index: 0,
- Message: msg,
- FinishReason: finishReason,
- } },
- Usage: *usage,
- }
- out, _ := common.Marshal(full)
- service.IOCopyBytesGracefully(c, resp, out)
- return usage, nil
-}
-
-func contentPtr(s string) *string { if s=="" { return nil }; return &s }
diff --git a/.history/relay/channel/ollama/stream_20250916085435.go b/.history/relay/channel/ollama/stream_20250916085435.go
deleted file mode 100644
index 964f11d90..000000000
--- a/.history/relay/channel/ollama/stream_20250916085435.go
+++ /dev/null
@@ -1,210 +0,0 @@
-package ollama
-
-import (
- "bufio"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "one-api/common"
- "one-api/dto"
- "one-api/logger"
- relaycommon "one-api/relay/common"
- "one-api/relay/helper"
- "one-api/service"
- "one-api/types"
- "strings"
- "time"
-
- "github.com/gin-gonic/gin"
-)
-
-type ollamaChatStreamChunk struct {
- Model string `json:"model"`
- CreatedAt string `json:"created_at"`
- // chat
- Message *struct {
- Role string `json:"role"`
- Content string `json:"content"`
- Thinking json.RawMessage `json:"thinking"`
- ToolCalls []struct {
- Function struct {
- Name string `json:"name"`
- Arguments interface{} `json:"arguments"`
- } `json:"function"`
- } `json:"tool_calls"`
- } `json:"message"`
- // generate
- Response string `json:"response"`
- Done bool `json:"done"`
- DoneReason string `json:"done_reason"`
- TotalDuration int64 `json:"total_duration"`
- LoadDuration int64 `json:"load_duration"`
- PromptEvalCount int `json:"prompt_eval_count"`
- EvalCount int `json:"eval_count"`
- PromptEvalDuration int64 `json:"prompt_eval_duration"`
- EvalDuration int64 `json:"eval_duration"`
-}
-
-func toUnix(ts string) int64 {
- if ts == "" { return time.Now().Unix() }
- // try time.RFC3339 or with nanoseconds
- t, err := time.Parse(time.RFC3339Nano, ts)
- if err != nil { t2, err2 := time.Parse(time.RFC3339, ts); if err2==nil { return t2.Unix() }; return time.Now().Unix() }
- return t.Unix()
-}
-
-func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
- if resp == nil || resp.Body == nil { return nil, types.NewOpenAIError(fmt.Errorf("empty response"), types.ErrorCodeBadResponse, http.StatusBadRequest) }
- defer service.CloseResponseBodyGracefully(resp)
-
- helper.SetEventStreamHeaders(c)
- scanner := bufio.NewScanner(resp.Body)
- usage := &dto.Usage{}
- var model = info.UpstreamModelName
- var responseId = common.GetUUID()
- var created = time.Now().Unix()
- var toolCallIndex int
- start := helper.GenerateStartEmptyResponse(responseId, created, model, nil)
- if data, err := common.Marshal(start); err == nil { _ = helper.StringData(c, string(data)) }
-
- for scanner.Scan() {
- line := scanner.Text()
- line = strings.TrimSpace(line)
- if line == "" { continue }
- var chunk ollamaChatStreamChunk
- if err := json.Unmarshal([]byte(line), &chunk); err != nil {
- logger.LogError(c, "ollama stream json decode error: "+err.Error()+" line="+line)
- return usage, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
- }
- if chunk.Model != "" { model = chunk.Model }
- created = toUnix(chunk.CreatedAt)
-
- if !chunk.Done {
- // delta content
- var content string
- if chunk.Message != nil { content = chunk.Message.Content } else { content = chunk.Response }
- delta := dto.ChatCompletionsStreamResponse{
- Id: responseId,
- Object: "chat.completion.chunk",
- Created: created,
- Model: model,
- Choices: []dto.ChatCompletionsStreamResponseChoice{ {
- Index: 0,
- Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ Role: "assistant" },
- } },
- }
- if content != "" { delta.Choices[0].Delta.SetContentString(content) }
- if chunk.Message != nil && len(chunk.Message.Thinking) > 0 {
- raw := strings.TrimSpace(string(chunk.Message.Thinking))
- if raw != "" && raw != "null" { delta.Choices[0].Delta.SetReasoningContent(raw) }
- }
- // tool calls
- if chunk.Message != nil && len(chunk.Message.ToolCalls) > 0 {
- delta.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse,0,len(chunk.Message.ToolCalls))
- for _, tc := range chunk.Message.ToolCalls {
- // arguments -> string
- argBytes, _ := json.Marshal(tc.Function.Arguments)
- toolId := fmt.Sprintf("call_%d", toolCallIndex)
- tr := dto.ToolCallResponse{ID:toolId, Type:"function", Function: dto.FunctionResponse{Name: tc.Function.Name, Arguments: string(argBytes)}}
- tr.SetIndex(toolCallIndex)
- toolCallIndex++
- delta.Choices[0].Delta.ToolCalls = append(delta.Choices[0].Delta.ToolCalls, tr)
- }
- }
- if data, err := common.Marshal(delta); err == nil { _ = helper.StringData(c, string(data)) }
- continue
- }
- // done frame
- // finalize once and break loop
- usage.PromptTokens = chunk.PromptEvalCount
- usage.CompletionTokens = chunk.EvalCount
- usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
- finishReason := chunk.DoneReason
- if finishReason == "" { finishReason = "stop" }
- // emit stop delta
- if stop := helper.GenerateStopResponse(responseId, created, model, finishReason); stop != nil {
- if data, err := common.Marshal(stop); err == nil { _ = helper.StringData(c, string(data)) }
- }
- // emit usage frame
- if final := helper.GenerateFinalUsageResponse(responseId, created, model, *usage); final != nil {
- if data, err := common.Marshal(final); err == nil { _ = helper.StringData(c, string(data)) }
- }
- // send [DONE]
- helper.Done(c)
- break
- }
- if err := scanner.Err(); err != nil && err != io.EOF { logger.LogError(c, "ollama stream scan error: "+err.Error()) }
- return usage, nil
-}
-
-// non-stream handler for chat/generate
-func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
- body, err := io.ReadAll(resp.Body)
- if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) }
- service.CloseResponseBodyGracefully(resp)
- raw := string(body)
- if common.DebugEnabled { println("ollama non-stream raw resp:", raw) }
-
- lines := strings.Split(raw, "\n")
- var (
- aggContent strings.Builder
- reasoningBuilder strings.Builder
- lastChunk ollamaChatStreamChunk
- parsedAny bool
- )
- for _, ln := range lines {
- ln = strings.TrimSpace(ln)
- if ln == "" { continue }
- var ck ollamaChatStreamChunk
- if err := json.Unmarshal([]byte(ln), &ck); err != nil {
- if len(lines) == 1 { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
- continue
- }
- parsedAny = true
- lastChunk = ck
- if ck.Message != nil && len(ck.Message.Thinking) > 0 {
- raw := strings.TrimSpace(string(ck.Message.Thinking))
- if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) }
- }
- if ck.Message != nil && ck.Message.Content != "" { aggContent.WriteString(ck.Message.Content) } else if ck.Response != "" { aggContent.WriteString(ck.Response) }
- }
-
- if !parsedAny {
- var single ollamaChatStreamChunk
- if err := json.Unmarshal(body, &single); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
- lastChunk = single
- if single.Message != nil {
- if len(single.Message.Thinking) > 0 { raw := strings.TrimSpace(string(single.Message.Thinking)); if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) } }
- aggContent.WriteString(single.Message.Content)
- } else { aggContent.WriteString(single.Response) }
- }
-
- model := lastChunk.Model
- if model == "" { model = info.UpstreamModelName }
- created := toUnix(lastChunk.CreatedAt)
- usage := &dto.Usage{PromptTokens: lastChunk.PromptEvalCount, CompletionTokens: lastChunk.EvalCount, TotalTokens: lastChunk.PromptEvalCount + lastChunk.EvalCount}
- content := aggContent.String()
- finishReason := lastChunk.DoneReason
- if finishReason == "" { finishReason = "stop" }
-
- msg := dto.Message{Role: "assistant", Content: contentPtr(content)}
- if rc := reasoningBuilder.String(); rc != "" { msg.ReasoningContent = rc }
- full := dto.OpenAITextResponse{
- Id: common.GetUUID(),
- Model: model,
- Object: "chat.completion",
- Created: created,
- Choices: []dto.OpenAITextResponseChoice{ {
- Index: 0,
- Message: msg,
- FinishReason: finishReason,
- } },
- Usage: *usage,
- }
- out, _ := common.Marshal(full)
- service.IOCopyBytesGracefully(c, resp, out)
- return usage, nil
-}
-
-func contentPtr(s string) *string { if s=="" { return nil }; return &s }
From 11cf70e60d559953764c30cc4ff4fd47dad207e5 Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Tue, 16 Sep 2025 12:47:59 +0800
Subject: [PATCH 055/165] =?UTF-8?q?fix:=20openai=20responses=20api=20?=
=?UTF-8?q?=E6=9C=AA=E7=BB=9F=E8=AE=A1=E5=9B=BE=E5=83=8F=E7=94=9F=E6=88=90?=
=?UTF-8?q?=E8=B0=83=E7=94=A8=E8=AE=A1=E8=B4=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
dto/openai_response.go | 42 +++++++++++++++++++
relay/channel/openai/relay_responses.go | 35 +++++++++++-----
relay/compatible_handler.go | 13 ++++++
setting/operation_setting/tools.go | 40 ++++++++++++++++++
web/src/helpers/render.jsx | 23 +++++++++-
web/src/hooks/usage-logs/useUsageLogsData.jsx | 2 +
6 files changed, 142 insertions(+), 13 deletions(-)
diff --git a/dto/openai_response.go b/dto/openai_response.go
index 966748cb5..6353c15ff 100644
--- a/dto/openai_response.go
+++ b/dto/openai_response.go
@@ -6,6 +6,10 @@ import (
"one-api/types"
)
+const (
+ ResponsesOutputTypeImageGenerationCall = "image_generation_call"
+)
+
type SimpleResponse struct {
Usage `json:"usage"`
Error any `json:"error"`
@@ -273,6 +277,42 @@ func (o *OpenAIResponsesResponse) GetOpenAIError() *types.OpenAIError {
return GetOpenAIError(o.Error)
}
+func (o *OpenAIResponsesResponse) HasImageGenerationCall() bool {
+ if len(o.Output) == 0 {
+ return false
+ }
+ for _, output := range o.Output {
+ if output.Type == ResponsesOutputTypeImageGenerationCall {
+ return true
+ }
+ }
+ return false
+}
+
+func (o *OpenAIResponsesResponse) GetQuality() string {
+ if len(o.Output) == 0 {
+ return ""
+ }
+ for _, output := range o.Output {
+ if output.Type == ResponsesOutputTypeImageGenerationCall {
+ return output.Quality
+ }
+ }
+ return ""
+}
+
+func (o *OpenAIResponsesResponse) GetSize() string {
+ if len(o.Output) == 0 {
+ return ""
+ }
+ for _, output := range o.Output {
+ if output.Type == ResponsesOutputTypeImageGenerationCall {
+ return output.Size
+ }
+ }
+ return ""
+}
+
type IncompleteDetails struct {
Reasoning string `json:"reasoning"`
}
@@ -283,6 +323,8 @@ type ResponsesOutput struct {
Status string `json:"status"`
Role string `json:"role"`
Content []ResponsesOutputContent `json:"content"`
+ Quality string `json:"quality"`
+ Size string `json:"size"`
}
type ResponsesOutputContent struct {
diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go
index e188889e4..85938a771 100644
--- a/relay/channel/openai/relay_responses.go
+++ b/relay/channel/openai/relay_responses.go
@@ -33,6 +33,12 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
}
+ if responsesResponse.HasImageGenerationCall() {
+ c.Set("image_generation_call", true)
+ c.Set("image_generation_call_quality", responsesResponse.GetQuality())
+ c.Set("image_generation_call_size", responsesResponse.GetSize())
+ }
+
// 写入新的 response body
service.IOCopyBytesGracefully(c, resp, responseBody)
@@ -80,18 +86,25 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
sendResponsesStreamData(c, streamResponse, data)
switch streamResponse.Type {
case "response.completed":
- if streamResponse.Response != nil && streamResponse.Response.Usage != nil {
- if streamResponse.Response.Usage.InputTokens != 0 {
- usage.PromptTokens = streamResponse.Response.Usage.InputTokens
+ if streamResponse.Response != nil {
+ if streamResponse.Response.Usage != nil {
+ if streamResponse.Response.Usage.InputTokens != 0 {
+ usage.PromptTokens = streamResponse.Response.Usage.InputTokens
+ }
+ if streamResponse.Response.Usage.OutputTokens != 0 {
+ usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens
+ }
+ if streamResponse.Response.Usage.TotalTokens != 0 {
+ usage.TotalTokens = streamResponse.Response.Usage.TotalTokens
+ }
+ if streamResponse.Response.Usage.InputTokensDetails != nil {
+ usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
+ }
}
- if streamResponse.Response.Usage.OutputTokens != 0 {
- usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens
- }
- if streamResponse.Response.Usage.TotalTokens != 0 {
- usage.TotalTokens = streamResponse.Response.Usage.TotalTokens
- }
- if streamResponse.Response.Usage.InputTokensDetails != nil {
- usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
+ if streamResponse.Response.HasImageGenerationCall() {
+ c.Set("image_generation_call", true)
+ c.Set("image_generation_call_quality", streamResponse.Response.GetQuality())
+ c.Set("image_generation_call_size", streamResponse.Response.GetSize())
}
}
case "response.output_text.delta":
diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go
index 01ab1fff4..c931fe2a0 100644
--- a/relay/compatible_handler.go
+++ b/relay/compatible_handler.go
@@ -276,6 +276,13 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
fileSearchTool.CallCount, dFileSearchQuota.String())
}
}
+ var dImageGenerationCallQuota decimal.Decimal
+ var imageGenerationCallPrice float64
+ if ctx.GetBool("image_generation_call") {
+ imageGenerationCallPrice = operation_setting.GetGPTImage1PriceOnceCall(ctx.GetString("image_generation_call_quality"), ctx.GetString("image_generation_call_size"))
+ dImageGenerationCallQuota = decimal.NewFromFloat(imageGenerationCallPrice).Mul(dGroupRatio).Mul(dQuotaPerUnit)
+ extraContent += fmt.Sprintf("Image Generation Call 花费 %s", dImageGenerationCallQuota.String())
+ }
var quotaCalculateDecimal decimal.Decimal
@@ -331,6 +338,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
// 添加 audio input 独立计费
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
+ // 添加 image generation call 计费
+ quotaCalculateDecimal = quotaCalculateDecimal.Add(dImageGenerationCallQuota)
quota := int(quotaCalculateDecimal.Round(0).IntPart())
totalTokens := promptTokens + completionTokens
@@ -429,6 +438,10 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
other["audio_input_token_count"] = audioTokens
other["audio_input_price"] = audioInputPrice
}
+ if !dImageGenerationCallQuota.IsZero() {
+ other["image_generation_call"] = true
+ other["image_generation_call_price"] = imageGenerationCallPrice
+ }
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: promptTokens,
diff --git a/setting/operation_setting/tools.go b/setting/operation_setting/tools.go
index 549a1862e..5b89d6fec 100644
--- a/setting/operation_setting/tools.go
+++ b/setting/operation_setting/tools.go
@@ -10,6 +10,18 @@ const (
FileSearchPrice = 2.5
)
+const (
+ GPTImage1Low1024x1024 = 0.011
+ GPTImage1Low1024x1536 = 0.016
+ GPTImage1Low1536x1024 = 0.016
+ GPTImage1Medium1024x1024 = 0.042
+ GPTImage1Medium1024x1536 = 0.063
+ GPTImage1Medium1536x1024 = 0.063
+ GPTImage1High1024x1024 = 0.167
+ GPTImage1High1024x1536 = 0.25
+ GPTImage1High1536x1024 = 0.25
+)
+
const (
// Gemini Audio Input Price
Gemini25FlashPreviewInputAudioPrice = 1.00
@@ -65,3 +77,31 @@ func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
}
return 0
}
+
+func GetGPTImage1PriceOnceCall(quality string, size string) float64 {
+ prices := map[string]map[string]float64{
+ "low": {
+ "1024x1024": GPTImage1Low1024x1024,
+ "1024x1536": GPTImage1Low1024x1536,
+ "1536x1024": GPTImage1Low1536x1024,
+ },
+ "medium": {
+ "1024x1024": GPTImage1Medium1024x1024,
+ "1024x1536": GPTImage1Medium1024x1536,
+ "1536x1024": GPTImage1Medium1536x1024,
+ },
+ "high": {
+ "1024x1024": GPTImage1High1024x1024,
+ "1024x1536": GPTImage1High1024x1536,
+ "1536x1024": GPTImage1High1536x1024,
+ },
+ }
+
+ if qualityMap, exists := prices[quality]; exists {
+ if price, exists := qualityMap[size]; exists {
+ return price
+ }
+ }
+
+ return GPTImage1High1024x1024
+}
diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx
index 65332701b..c331d7fe8 100644
--- a/web/src/helpers/render.jsx
+++ b/web/src/helpers/render.jsx
@@ -1027,6 +1027,8 @@ export function renderModelPrice(
audioInputSeperatePrice = false,
audioInputTokens = 0,
audioInputPrice = 0,
+ imageGenerationCall = false,
+ imageGenerationCallPrice = 0,
) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
groupRatio,
@@ -1069,7 +1071,8 @@ export function renderModelPrice(
(audioInputTokens / 1000000) * audioInputPrice * groupRatio +
(completionTokens / 1000000) * completionRatioPrice * groupRatio +
(webSearchCallCount / 1000) * webSearchPrice * groupRatio +
- (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio;
+ (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
+ (imageGenerationCall * imageGenerationCallPrice * groupRatio);
return (
<>
@@ -1131,7 +1134,13 @@ export function renderModelPrice(
})}
)}
-
+ {imageGenerationCall && imageGenerationCallPrice > 0 && (
+
+ {i18next.t('图片生成调用:${{price}} / 1次', {
+ price: imageGenerationCallPrice,
+ })}
+
+ )}
{(() => {
// 构建输入部分描述
@@ -1211,6 +1220,16 @@ export function renderModelPrice(
},
)
: '',
+ imageGenerationCall && imageGenerationCallPrice > 0
+ ? i18next.t(
+ ' + 图片生成调用 ${{price}} / 1次 * {{ratioType}} {{ratio}}',
+ {
+ price: imageGenerationCallPrice,
+ ratio: groupRatio,
+ ratioType: ratioLabel,
+ },
+ )
+ : '',
].join('');
return i18next.t(
diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx
index 81f3f539a..d434e7333 100644
--- a/web/src/hooks/usage-logs/useUsageLogsData.jsx
+++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx
@@ -447,6 +447,8 @@ export const useLogsData = () => {
other?.audio_input_seperate_price || false,
other?.audio_input_token_count || 0,
other?.audio_input_price || 0,
+ other?.image_generation_call || false,
+ other?.image_generation_call_price || 0,
);
}
expandDataLocal.push({
From 17be7c3b451622680a5f8c34b27f8436b19afa9d Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Tue, 16 Sep 2025 13:02:15 +0800
Subject: [PATCH 056/165] fix: imageGenerationCall involves billing
---
web/src/helpers/render.jsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx
index c331d7fe8..c19e2849d 100644
--- a/web/src/helpers/render.jsx
+++ b/web/src/helpers/render.jsx
@@ -1072,7 +1072,7 @@ export function renderModelPrice(
(completionTokens / 1000000) * completionRatioPrice * groupRatio +
(webSearchCallCount / 1000) * webSearchPrice * groupRatio +
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
- (imageGenerationCall * imageGenerationCallPrice * groupRatio);
+ (imageGenerationCallPrice * groupRatio);
return (
<>
From 956244c742842f8f096dd8e47a97404107a8f777 Mon Sep 17 00:00:00 2001
From: huanghejian
Date: Tue, 16 Sep 2025 14:30:12 +0800
Subject: [PATCH 057/165] fix: VolcEngine doubao-seedream-4-0-250828
---
controller/channel-test.go | 39 +++++++++++++++++++++++++++
relay/channel/volcengine/adaptor.go | 2 ++
relay/channel/volcengine/constants.go | 1 +
3 files changed, 42 insertions(+)
diff --git a/controller/channel-test.go b/controller/channel-test.go
index 5a668c488..9ea6eed75 100644
--- a/controller/channel-test.go
+++ b/controller/channel-test.go
@@ -90,6 +90,11 @@ func testChannel(channel *model.Channel, testModel string) testResult {
requestPath = "/v1/embeddings" // 修改请求路径
}
+ // VolcEngine 图像生成模型
+ if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
+ requestPath = "/v1/images/generations"
+ }
+
c.Request = &http.Request{
Method: "POST",
URL: &url.URL{Path: requestPath}, // 使用动态路径
@@ -109,6 +114,21 @@ func testChannel(channel *model.Channel, testModel string) testResult {
}
}
+ // 重新检查模型类型并更新请求路径
+ if strings.Contains(strings.ToLower(testModel), "embedding") ||
+ strings.HasPrefix(testModel, "m3e") ||
+ strings.Contains(testModel, "bge-") ||
+ strings.Contains(testModel, "embed") ||
+ channel.Type == constant.ChannelTypeMokaAI {
+ requestPath = "/v1/embeddings"
+ c.Request.URL.Path = requestPath
+ }
+
+ if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
+ requestPath = "/v1/images/generations"
+ c.Request.URL.Path = requestPath
+ }
+
cache, err := model.GetUserCache(1)
if err != nil {
return testResult{
@@ -140,6 +160,9 @@ func testChannel(channel *model.Channel, testModel string) testResult {
if c.Request.URL.Path == "/v1/embeddings" {
relayFormat = types.RelayFormatEmbedding
}
+ if c.Request.URL.Path == "/v1/images/generations" {
+ relayFormat = types.RelayFormatOpenAIImage
+ }
info, err := relaycommon.GenRelayInfo(c, relayFormat, request, nil)
@@ -201,6 +224,22 @@ func testChannel(channel *model.Channel, testModel string) testResult {
}
// 调用专门用于 Embedding 的转换函数
convertedRequest, err = adaptor.ConvertEmbeddingRequest(c, info, embeddingRequest)
+ } else if info.RelayMode == relayconstant.RelayModeImagesGenerations {
+ // 创建一个 ImageRequest
+ prompt := "cat"
+ if request.Prompt != nil {
+ if promptStr, ok := request.Prompt.(string); ok && promptStr != "" {
+ prompt = promptStr
+ }
+ }
+ imageRequest := dto.ImageRequest{
+ Prompt: prompt,
+ Model: request.Model,
+ N: uint(request.N),
+ Size: request.Size,
+ }
+ // 调用专门用于图像生成的转换函数
+ convertedRequest, err = adaptor.ConvertImageRequest(c, info, imageRequest)
} else {
// 对其他所有请求类型(如 Chat),保持原有逻辑
convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, request)
diff --git a/relay/channel/volcengine/adaptor.go b/relay/channel/volcengine/adaptor.go
index 0af019da4..eb88412af 100644
--- a/relay/channel/volcengine/adaptor.go
+++ b/relay/channel/volcengine/adaptor.go
@@ -41,6 +41,8 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
switch info.RelayMode {
+ case constant.RelayModeImagesGenerations:
+ return request, nil
case constant.RelayModeImagesEdits:
var requestBody bytes.Buffer
diff --git a/relay/channel/volcengine/constants.go b/relay/channel/volcengine/constants.go
index 30cc902e7..fca10e7c1 100644
--- a/relay/channel/volcengine/constants.go
+++ b/relay/channel/volcengine/constants.go
@@ -8,6 +8,7 @@ var ModelList = []string{
"Doubao-lite-32k",
"Doubao-lite-4k",
"Doubao-embedding",
+ "doubao-seedream-4-0-250828",
}
var ChannelName = "volcengine"
From 91e57a4c694cd425fd60ff494a202212bc749f64 Mon Sep 17 00:00:00 2001
From: Xyfacai
Date: Tue, 16 Sep 2025 16:28:27 +0800
Subject: [PATCH 058/165] =?UTF-8?q?feat:=20jimeng=20kling=20=E6=94=AF?=
=?UTF-8?q?=E6=8C=81new=20api=20=E5=B5=8C=E5=A5=97=E4=B8=AD=E8=BD=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
controller/channel.go | 14 +++++++---
relay/channel/task/jimeng/adaptor.go | 38 ++++++++++++++++++++--------
relay/channel/task/kling/adaptor.go | 19 +++++++++++++-
3 files changed, 57 insertions(+), 14 deletions(-)
diff --git a/controller/channel.go b/controller/channel.go
index 403eb04cc..17154ab0f 100644
--- a/controller/channel.go
+++ b/controller/channel.go
@@ -501,9 +501,10 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
}
type AddChannelRequest struct {
- Mode string `json:"mode"`
- MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
- Channel *model.Channel `json:"channel"`
+ Mode string `json:"mode"`
+ MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
+ BatchAddSetKeyPrefix2Name bool `json:"batch_add_set_key_prefix_2_name"`
+ Channel *model.Channel `json:"channel"`
}
func getVertexArrayKeys(keys string) ([]string, error) {
@@ -616,6 +617,13 @@ func AddChannel(c *gin.Context) {
}
localChannel := addChannelRequest.Channel
localChannel.Key = key
+ if addChannelRequest.BatchAddSetKeyPrefix2Name && len(keys) > 1 {
+ keyPrefix := localChannel.Key
+ if len(localChannel.Key) > 8 {
+ keyPrefix = localChannel.Key[:8]
+ }
+ localChannel.Name = fmt.Sprintf("%s %s", localChannel.Name, keyPrefix)
+ }
channels = append(channels, *localChannel)
}
err = model.BatchInsertChannels(channels)
diff --git a/relay/channel/task/jimeng/adaptor.go b/relay/channel/task/jimeng/adaptor.go
index 2bc45c547..95f3cb269 100644
--- a/relay/channel/task/jimeng/adaptor.go
+++ b/relay/channel/task/jimeng/adaptor.go
@@ -93,6 +93,9 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
// BuildRequestURL constructs the upstream URL.
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
+ if isNewAPIRelay(info.ApiKey) {
+ return fmt.Sprintf("%s/jimeng/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31", a.baseURL), nil
+ }
return fmt.Sprintf("%s/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31", a.baseURL), nil
}
@@ -100,7 +103,12 @@ func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, erro
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
- return a.signRequest(req, a.accessKey, a.secretKey)
+ if isNewAPIRelay(info.ApiKey) {
+ req.Header.Set("Authorization", "Bearer "+info.ApiKey)
+ } else {
+ return a.signRequest(req, a.accessKey, a.secretKey)
+ }
+ return nil
}
// BuildRequestBody converts request into Jimeng specific format.
@@ -160,6 +168,9 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http
}
uri := fmt.Sprintf("%s/?Action=CVSync2AsyncGetResult&Version=2022-08-31", baseUrl)
+ if isNewAPIRelay(key) {
+ uri = fmt.Sprintf("%s/jimeng/?Action=CVSync2AsyncGetResult&Version=2022-08-31", a.baseURL)
+ }
payload := map[string]string{
"req_key": "jimeng_vgfm_t2v_l20", // This is fixed value from doc: https://www.volcengine.com/docs/85621/1544774
"task_id": taskID,
@@ -177,17 +188,20 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
- keyParts := strings.Split(key, "|")
- if len(keyParts) != 2 {
- return nil, fmt.Errorf("invalid api key format for jimeng: expected 'ak|sk'")
- }
- accessKey := strings.TrimSpace(keyParts[0])
- secretKey := strings.TrimSpace(keyParts[1])
+ if isNewAPIRelay(key) {
+ req.Header.Set("Authorization", "Bearer "+key)
+ } else {
+ keyParts := strings.Split(key, "|")
+ if len(keyParts) != 2 {
+ return nil, fmt.Errorf("invalid api key format for jimeng: expected 'ak|sk'")
+ }
+ accessKey := strings.TrimSpace(keyParts[0])
+ secretKey := strings.TrimSpace(keyParts[1])
- if err := a.signRequest(req, accessKey, secretKey); err != nil {
- return nil, errors.Wrap(err, "sign request failed")
+ if err := a.signRequest(req, accessKey, secretKey); err != nil {
+ return nil, errors.Wrap(err, "sign request failed")
+ }
}
-
return service.GetHttpClient().Do(req)
}
@@ -362,3 +376,7 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
taskResult.Url = resTask.Data.VideoUrl
return &taskResult, nil
}
+
+func isNewAPIRelay(apiKey string) bool {
+ return strings.HasPrefix(apiKey, "sk-")
+}
diff --git a/relay/channel/task/kling/adaptor.go b/relay/channel/task/kling/adaptor.go
index 13f2af972..fec3396ae 100644
--- a/relay/channel/task/kling/adaptor.go
+++ b/relay/channel/task/kling/adaptor.go
@@ -117,6 +117,11 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
// BuildRequestURL constructs the upstream URL.
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
path := lo.Ternary(info.Action == constant.TaskActionGenerate, "/v1/videos/image2video", "/v1/videos/text2video")
+
+ if isNewAPIRelay(info.ApiKey) {
+ return fmt.Sprintf("%s/kling%s", a.baseURL, path), nil
+ }
+
return fmt.Sprintf("%s%s", a.baseURL, path), nil
}
@@ -199,6 +204,9 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http
}
path := lo.Ternary(action == constant.TaskActionGenerate, "/v1/videos/image2video", "/v1/videos/text2video")
url := fmt.Sprintf("%s%s/%s", baseUrl, path, taskID)
+ if isNewAPIRelay(key) {
+ url = fmt.Sprintf("%s/kling%s/%s", baseUrl, path, taskID)
+ }
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
@@ -304,8 +312,13 @@ func (a *TaskAdaptor) createJWTToken() (string, error) {
//}
func (a *TaskAdaptor) createJWTTokenWithKey(apiKey string) (string, error) {
-
+ if isNewAPIRelay(apiKey) {
+ return apiKey, nil // new api relay
+ }
keyParts := strings.Split(apiKey, "|")
+ if len(keyParts) != 2 {
+ return "", errors.New("invalid api_key, required format is accessKey|secretKey")
+ }
accessKey := strings.TrimSpace(keyParts[0])
if len(keyParts) == 1 {
return accessKey, nil
@@ -352,3 +365,7 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
}
return taskInfo, nil
}
+
+func isNewAPIRelay(apiKey string) bool {
+ return strings.HasPrefix(apiKey, "sk-")
+}
From 9af71caf739e026485dab5453bbcc2e64a6d22ad Mon Sep 17 00:00:00 2001
From: t0ng7u
Date: Tue, 16 Sep 2025 16:55:35 +0800
Subject: [PATCH 059/165] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20fix:=20Align=20?=
=?UTF-8?q?setup=20API=20errors=20to=20HTTP=20200=20with=20{success:false,?=
=?UTF-8?q?=20message}?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Unify the setup initialization endpoint’s error contract to match the rest
of the project and keep the frontend unchanged.
Changes
- controller/setup.go: Return HTTP 200 with {success:false, message} for all
predictable errors in POST /api/setup, including:
- already initialized
- invalid payload
- username too long
- password mismatch
- password too short
- password hashing failure
- root user creation failure
- option persistence failures (SelfUseModeEnabled, DemoSiteEnabled)
- setup record creation failure
- web/src/components/setup/SetupWizard.jsx: Restore catch handler to the
previous generic toast (frontend logic unchanged).
- web/src/helpers/utils.jsx: Restore the original showError implementation
(no Axios response.data parsing required).
Why
- Keep API behavior consistent across endpoints so the UI can rely on the
success flag and message in the normal .then() flow instead of falling
into Axios 4xx errors that only show a generic "400".
Impact
- UI now displays specific server messages during initialization without
frontend adaptations.
- Note: clients relying solely on HTTP status codes for error handling
should inspect the JSON body (success/message) instead.
No changes to the happy path; initialization success responses are unchanged.
---
controller/setup.go | 20 ++++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/controller/setup.go b/controller/setup.go
index 44a7b3a73..3ae255e94 100644
--- a/controller/setup.go
+++ b/controller/setup.go
@@ -53,7 +53,7 @@ func GetSetup(c *gin.Context) {
func PostSetup(c *gin.Context) {
// Check if setup is already completed
if constant.Setup {
- c.JSON(400, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "系统已经初始化完成",
})
@@ -66,7 +66,7 @@ func PostSetup(c *gin.Context) {
var req SetupRequest
err := c.ShouldBindJSON(&req)
if err != nil {
- c.JSON(400, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "请求参数有误",
})
@@ -77,7 +77,7 @@ func PostSetup(c *gin.Context) {
if !rootExists {
// Validate username length: max 12 characters to align with model.User validation
if len(req.Username) > 12 {
- c.JSON(400, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "用户名长度不能超过12个字符",
})
@@ -85,7 +85,7 @@ func PostSetup(c *gin.Context) {
}
// Validate password
if req.Password != req.ConfirmPassword {
- c.JSON(400, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "两次输入的密码不一致",
})
@@ -93,7 +93,7 @@ func PostSetup(c *gin.Context) {
}
if len(req.Password) < 8 {
- c.JSON(400, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "密码长度至少为8个字符",
})
@@ -103,7 +103,7 @@ func PostSetup(c *gin.Context) {
// Create root user
hashedPassword, err := common.Password2Hash(req.Password)
if err != nil {
- c.JSON(500, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "系统错误: " + err.Error(),
})
@@ -120,7 +120,7 @@ func PostSetup(c *gin.Context) {
}
err = model.DB.Create(&rootUser).Error
if err != nil {
- c.JSON(500, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "创建管理员账号失败: " + err.Error(),
})
@@ -135,7 +135,7 @@ func PostSetup(c *gin.Context) {
// Save operation modes to database for persistence
err = model.UpdateOption("SelfUseModeEnabled", boolToString(req.SelfUseModeEnabled))
if err != nil {
- c.JSON(500, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "保存自用模式设置失败: " + err.Error(),
})
@@ -144,7 +144,7 @@ func PostSetup(c *gin.Context) {
err = model.UpdateOption("DemoSiteEnabled", boolToString(req.DemoSiteEnabled))
if err != nil {
- c.JSON(500, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "保存演示站点模式设置失败: " + err.Error(),
})
@@ -160,7 +160,7 @@ func PostSetup(c *gin.Context) {
}
err = model.DB.Create(&setup).Error
if err != nil {
- c.JSON(500, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "系统初始化失败: " + err.Error(),
})
From f2e9fd7afb91a5ab7c988401927b5f36e7a960c5 Mon Sep 17 00:00:00 2001
From: wzxjohn
Date: Tue, 16 Sep 2025 17:18:32 +0800
Subject: [PATCH 060/165] fix(relay): wrong URL for claude model in GCP Vertex
AI
---
relay/channel/vertex/adaptor.go | 59 ++++++++++++++++++++++-----------
1 file changed, 39 insertions(+), 20 deletions(-)
diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go
index a424cb1a4..6398b8f62 100644
--- a/relay/channel/vertex/adaptor.go
+++ b/relay/channel/vertex/adaptor.go
@@ -90,7 +90,43 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
}
a.AccountCredentials = *adc
- if a.RequestMode == RequestModeLlama {
+ if a.RequestMode == RequestModeGemini {
+ if region == "global" {
+ return fmt.Sprintf(
+ "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s",
+ adc.ProjectID,
+ modelName,
+ suffix,
+ ), nil
+ } else {
+ return fmt.Sprintf(
+ "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s",
+ region,
+ adc.ProjectID,
+ region,
+ modelName,
+ suffix,
+ ), nil
+ }
+ } else if a.RequestMode == RequestModeClaude {
+ if region == "global" {
+ return fmt.Sprintf(
+ "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/anthropic/models/%s:%s",
+ adc.ProjectID,
+ modelName,
+ suffix,
+ ), nil
+ } else {
+ return fmt.Sprintf(
+ "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:%s",
+ region,
+ adc.ProjectID,
+ region,
+ modelName,
+ suffix,
+ ), nil
+ }
+ } else if a.RequestMode == RequestModeLlama {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions",
region,
@@ -98,24 +134,6 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
region,
), nil
}
-
- if region == "global" {
- return fmt.Sprintf(
- "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s",
- adc.ProjectID,
- modelName,
- suffix,
- ), nil
- } else {
- return fmt.Sprintf(
- "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s",
- region,
- adc.ProjectID,
- region,
- modelName,
- suffix,
- ), nil
- }
} else {
if region == "global" {
return fmt.Sprintf(
@@ -134,6 +152,7 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
), nil
}
}
+ return "", errors.New("unsupported request mode")
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
@@ -187,7 +206,7 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
}
req.Set("Authorization", "Bearer "+accessToken)
}
- if a.AccountCredentials.ProjectID != "" {
+ if a.AccountCredentials.ProjectID != "" {
req.Set("x-goog-user-project", a.AccountCredentials.ProjectID)
}
return nil
From 4be61d00e4cf111457d96e9beed03b39944e12cb Mon Sep 17 00:00:00 2001
From: t0ng7u
Date: Tue, 16 Sep 2025 17:21:22 +0800
Subject: [PATCH 061/165] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20fix:=20Align=20?=
=?UTF-8?q?setup=20API=20errors=20to=20HTTP=20200=20with=20{success:false,?=
=?UTF-8?q?=20message}?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Unify the setup initialization endpoint’s error contract to match the rest
of the project and keep the frontend unchanged.
Changes
- controller/setup.go: Return HTTP 200 with {success:false, message} for all
predictable errors in POST /api/setup, including:
- already initialized
- invalid payload
- username too long
- password mismatch
- password too short
- password hashing failure
- root user creation failure
- option persistence failures (SelfUseModeEnabled, DemoSiteEnabled)
- setup record creation failure
- web/src/components/setup/SetupWizard.jsx: Restore catch handler to the
previous generic toast (frontend logic unchanged).
- web/src/helpers/utils.jsx: Restore the original showError implementation
(no Axios response.data parsing required).
Why
- Keep API behavior consistent across endpoints so the UI can rely on the
success flag and message in the normal .then() flow instead of falling
into Axios 4xx errors that only show a generic "400".
Impact
- UI now displays specific server messages during initialization without
frontend adaptations.
- Note: clients relying solely on HTTP status codes for error handling
should inspect the JSON body (success/message) instead.
No changes to the happy path; initialization success responses are unchanged.
---
controller/setup.go | 20 ++++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/controller/setup.go b/controller/setup.go
index 44a7b3a73..3ae255e94 100644
--- a/controller/setup.go
+++ b/controller/setup.go
@@ -53,7 +53,7 @@ func GetSetup(c *gin.Context) {
func PostSetup(c *gin.Context) {
// Check if setup is already completed
if constant.Setup {
- c.JSON(400, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "系统已经初始化完成",
})
@@ -66,7 +66,7 @@ func PostSetup(c *gin.Context) {
var req SetupRequest
err := c.ShouldBindJSON(&req)
if err != nil {
- c.JSON(400, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "请求参数有误",
})
@@ -77,7 +77,7 @@ func PostSetup(c *gin.Context) {
if !rootExists {
// Validate username length: max 12 characters to align with model.User validation
if len(req.Username) > 12 {
- c.JSON(400, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "用户名长度不能超过12个字符",
})
@@ -85,7 +85,7 @@ func PostSetup(c *gin.Context) {
}
// Validate password
if req.Password != req.ConfirmPassword {
- c.JSON(400, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "两次输入的密码不一致",
})
@@ -93,7 +93,7 @@ func PostSetup(c *gin.Context) {
}
if len(req.Password) < 8 {
- c.JSON(400, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "密码长度至少为8个字符",
})
@@ -103,7 +103,7 @@ func PostSetup(c *gin.Context) {
// Create root user
hashedPassword, err := common.Password2Hash(req.Password)
if err != nil {
- c.JSON(500, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "系统错误: " + err.Error(),
})
@@ -120,7 +120,7 @@ func PostSetup(c *gin.Context) {
}
err = model.DB.Create(&rootUser).Error
if err != nil {
- c.JSON(500, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "创建管理员账号失败: " + err.Error(),
})
@@ -135,7 +135,7 @@ func PostSetup(c *gin.Context) {
// Save operation modes to database for persistence
err = model.UpdateOption("SelfUseModeEnabled", boolToString(req.SelfUseModeEnabled))
if err != nil {
- c.JSON(500, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "保存自用模式设置失败: " + err.Error(),
})
@@ -144,7 +144,7 @@ func PostSetup(c *gin.Context) {
err = model.UpdateOption("DemoSiteEnabled", boolToString(req.DemoSiteEnabled))
if err != nil {
- c.JSON(500, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "保存演示站点模式设置失败: " + err.Error(),
})
@@ -160,7 +160,7 @@ func PostSetup(c *gin.Context) {
}
err = model.DB.Create(&setup).Error
if err != nil {
- c.JSON(500, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "系统初始化失败: " + err.Error(),
})
From 4b98773e9a97fbf6af1799c1287fb6dfc6cd6574 Mon Sep 17 00:00:00 2001
From: RixAPI
Date: Tue, 16 Sep 2025 20:03:10 +0800
Subject: [PATCH 062/165] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=B8=A0=E9=81=93?=
=?UTF-8?q?=E6=B5=8B=E8=AF=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
增加并发支持
---
web/src/hooks/channels/useChannelsData.jsx | 363 +++++++++++----------
1 file changed, 198 insertions(+), 165 deletions(-)
diff --git a/web/src/hooks/channels/useChannelsData.jsx b/web/src/hooks/channels/useChannelsData.jsx
index 65460a06b..7d09d4dfd 100644
--- a/web/src/hooks/channels/useChannelsData.jsx
+++ b/web/src/hooks/channels/useChannelsData.jsx
@@ -25,13 +25,9 @@ import {
showInfo,
showSuccess,
loadChannelModels,
- copy,
+ copy
} from '../../helpers';
-import {
- CHANNEL_OPTIONS,
- ITEMS_PER_PAGE,
- MODEL_TABLE_PAGE_SIZE,
-} from '../../constants';
+import { CHANNEL_OPTIONS, ITEMS_PER_PAGE, MODEL_TABLE_PAGE_SIZE } from '../../constants';
import { useIsMobile } from '../common/useIsMobile';
import { useTableCompactMode } from '../common/useTableCompactMode';
import { Modal } from '@douyinfe/semi-ui';
@@ -68,7 +64,7 @@ export const useChannelsData = () => {
// Status filter
const [statusFilter, setStatusFilter] = useState(
- localStorage.getItem('channel-status-filter') || 'all',
+ localStorage.getItem('channel-status-filter') || 'all'
);
// Type tabs states
@@ -83,9 +79,10 @@ export const useChannelsData = () => {
const [testingModels, setTestingModels] = useState(new Set());
const [selectedModelKeys, setSelectedModelKeys] = useState([]);
const [isBatchTesting, setIsBatchTesting] = useState(false);
- const [testQueue, setTestQueue] = useState([]);
- const [isProcessingQueue, setIsProcessingQueue] = useState(false);
const [modelTablePage, setModelTablePage] = useState(1);
+
+ // 使用 ref 来避免闭包问题,类似旧版实现
+ const shouldStopBatchTestingRef = useRef(false);
// Multi-key management states
const [showMultiKeyManageModal, setShowMultiKeyManageModal] = useState(false);
@@ -119,12 +116,9 @@ export const useChannelsData = () => {
// Initialize from localStorage
useEffect(() => {
const localIdSort = localStorage.getItem('id-sort') === 'true';
- const localPageSize =
- parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
- const localEnableTagMode =
- localStorage.getItem('enable-tag-mode') === 'true';
- const localEnableBatchDelete =
- localStorage.getItem('enable-batch-delete') === 'true';
+ const localPageSize = parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
+ const localEnableTagMode = localStorage.getItem('enable-tag-mode') === 'true';
+ const localEnableBatchDelete = localStorage.getItem('enable-batch-delete') === 'true';
setIdSort(localIdSort);
setPageSize(localPageSize);
@@ -182,10 +176,7 @@ export const useChannelsData = () => {
// Save column preferences
useEffect(() => {
if (Object.keys(visibleColumns).length > 0) {
- localStorage.setItem(
- 'channels-table-columns',
- JSON.stringify(visibleColumns),
- );
+ localStorage.setItem('channels-table-columns', JSON.stringify(visibleColumns));
}
}, [visibleColumns]);
@@ -299,21 +290,14 @@ export const useChannelsData = () => {
const { searchKeyword, searchGroup, searchModel } = getFormValues();
if (searchKeyword !== '' || searchGroup !== '' || searchModel !== '') {
setLoading(true);
- await searchChannels(
- enableTagMode,
- typeKey,
- statusF,
- page,
- pageSize,
- idSort,
- );
+ await searchChannels(enableTagMode, typeKey, statusF, page, pageSize, idSort);
setLoading(false);
return;
}
const reqId = ++requestCounter.current;
setLoading(true);
- const typeParam = typeKey !== 'all' ? `&type=${typeKey}` : '';
+ const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
const res = await API.get(
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}${statusParam}`,
@@ -327,10 +311,7 @@ export const useChannelsData = () => {
if (success) {
const { items, total, type_counts } = data;
if (type_counts) {
- const sumAll = Object.values(type_counts).reduce(
- (acc, v) => acc + v,
- 0,
- );
+ const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
setTypeCounts({ ...type_counts, all: sumAll });
}
setChannelFormat(items, enableTagMode);
@@ -354,18 +335,11 @@ export const useChannelsData = () => {
setSearching(true);
try {
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
- await loadChannels(
- page,
- pageSz,
- sortFlag,
- enableTagMode,
- typeKey,
- statusF,
- );
+ await loadChannels(page, pageSz, sortFlag, enableTagMode, typeKey, statusF);
return;
}
- const typeParam = typeKey !== 'all' ? `&type=${typeKey}` : '';
+ const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
const res = await API.get(
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${sortFlag}&tag_mode=${enableTagMode}&p=${page}&page_size=${pageSz}${typeParam}${statusParam}`,
@@ -373,10 +347,7 @@ export const useChannelsData = () => {
const { success, message, data } = res.data;
if (success) {
const { items = [], total = 0, type_counts = {} } = data;
- const sumAll = Object.values(type_counts).reduce(
- (acc, v) => acc + v,
- 0,
- );
+ const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
setTypeCounts({ ...type_counts, all: sumAll });
setChannelFormat(items, enableTagMode);
setChannelCount(total);
@@ -395,14 +366,7 @@ export const useChannelsData = () => {
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
await loadChannels(page, pageSize, idSort, enableTagMode);
} else {
- await searchChannels(
- enableTagMode,
- activeTypeKey,
- statusFilter,
- page,
- pageSize,
- idSort,
- );
+ await searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
}
};
@@ -488,16 +452,9 @@ export const useChannelsData = () => {
const { searchKeyword, searchGroup, searchModel } = getFormValues();
setActivePage(page);
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
- loadChannels(page, pageSize, idSort, enableTagMode).then(() => {});
+ loadChannels(page, pageSize, idSort, enableTagMode).then(() => { });
} else {
- searchChannels(
- enableTagMode,
- activeTypeKey,
- statusFilter,
- page,
- pageSize,
- idSort,
- );
+ searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
}
};
@@ -513,14 +470,7 @@ export const useChannelsData = () => {
showError(reason);
});
} else {
- searchChannels(
- enableTagMode,
- activeTypeKey,
- statusFilter,
- 1,
- size,
- idSort,
- );
+ searchChannels(enableTagMode, activeTypeKey, statusFilter, 1, size, idSort);
}
};
@@ -551,10 +501,7 @@ export const useChannelsData = () => {
showError(res?.data?.message || t('渠道复制失败'));
}
} catch (error) {
- showError(
- t('渠道复制失败: ') +
- (error?.response?.data?.message || error?.message || error),
- );
+ showError(t('渠道复制失败: ') + (error?.response?.data?.message || error?.message || error));
}
};
@@ -593,11 +540,7 @@ export const useChannelsData = () => {
data.priority = parseInt(data.priority);
break;
case 'weight':
- if (
- data.weight === undefined ||
- data.weight < 0 ||
- data.weight === ''
- ) {
+ if (data.weight === undefined || data.weight < 0 || data.weight === '') {
showInfo('权重必须是非负整数!');
return;
}
@@ -740,136 +683,226 @@ export const useChannelsData = () => {
const res = await API.post(`/api/channel/fix`);
const { success, message, data } = res.data;
if (success) {
- showSuccess(
- t('已修复 ${success} 个通道,失败 ${fails} 个通道。')
- .replace('${success}', data.success)
- .replace('${fails}', data.fails),
- );
+ showSuccess(t('已修复 ${success} 个通道,失败 ${fails} 个通道。').replace('${success}', data.success).replace('${fails}', data.fails));
await refresh();
} else {
showError(message);
}
};
- // Test channel
+ // Test channel - 单个模型测试,参考旧版实现
const testChannel = async (record, model) => {
- setTestQueue((prev) => [...prev, { channel: record, model }]);
- if (!isProcessingQueue) {
- setIsProcessingQueue(true);
+ const testKey = `${record.id}-${model}`;
+
+ // 检查是否应该停止批量测试
+ if (shouldStopBatchTestingRef.current && isBatchTesting) {
+ return Promise.resolve();
}
- };
- // Process test queue
- const processTestQueue = async () => {
- if (!isProcessingQueue || testQueue.length === 0) return;
-
- const { channel, model, indexInFiltered } = testQueue[0];
-
- if (currentTestChannel && currentTestChannel.id === channel.id) {
- let pageNo;
- if (indexInFiltered !== undefined) {
- pageNo = Math.floor(indexInFiltered / MODEL_TABLE_PAGE_SIZE) + 1;
- } else {
- const filteredModelsList = currentTestChannel.models
- .split(',')
- .filter((m) =>
- m.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
- );
- const modelIdx = filteredModelsList.indexOf(model);
- pageNo =
- modelIdx !== -1
- ? Math.floor(modelIdx / MODEL_TABLE_PAGE_SIZE) + 1
- : 1;
- }
- setModelTablePage(pageNo);
- }
+ // 添加到正在测试的模型集合
+ setTestingModels(prev => new Set([...prev, model]));
try {
- setTestingModels((prev) => new Set([...prev, model]));
- const res = await API.get(
- `/api/channel/test/${channel.id}?model=${model}`,
- );
+ const res = await API.get(`/api/channel/test/${record.id}?model=${model}`);
+
+ // 检查是否在请求期间被停止
+ if (shouldStopBatchTestingRef.current && isBatchTesting) {
+ return Promise.resolve();
+ }
+
const { success, message, time } = res.data;
- setModelTestResults((prev) => ({
+ // 更新测试结果
+ setModelTestResults(prev => ({
...prev,
- [`${channel.id}-${model}`]: { success, time },
+ [testKey]: {
+ success,
+ message,
+ time: time || 0,
+ timestamp: Date.now()
+ }
}));
if (success) {
- updateChannelProperty(channel.id, (ch) => {
- ch.response_time = time * 1000;
- ch.test_time = Date.now() / 1000;
+ // 更新渠道响应时间
+ updateChannelProperty(record.id, (channel) => {
+ channel.response_time = time * 1000;
+ channel.test_time = Date.now() / 1000;
});
- if (!model) {
+
+ if (!model || model === '') {
showInfo(
t('通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。')
- .replace('${name}', channel.name)
+ .replace('${name}', record.name)
+ .replace('${time.toFixed(2)}', time.toFixed(2)),
+ );
+ } else {
+ showInfo(
+ t('通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。')
+ .replace('${name}', record.name)
+ .replace('${model}', model)
.replace('${time.toFixed(2)}', time.toFixed(2)),
);
}
} else {
- showError(message);
+ showError(`${t('模型')} ${model}: ${message}`);
}
} catch (error) {
- showError(error.message);
+ // 处理网络错误
+ const testKey = `${record.id}-${model}`;
+ setModelTestResults(prev => ({
+ ...prev,
+ [testKey]: {
+ success: false,
+ message: error.message || t('网络错误'),
+ time: 0,
+ timestamp: Date.now()
+ }
+ }));
+ showError(`${t('模型')} ${model}: ${error.message || t('测试失败')}`);
} finally {
- setTestingModels((prev) => {
+ // 从正在测试的模型集合中移除
+ setTestingModels(prev => {
const newSet = new Set(prev);
newSet.delete(model);
return newSet;
});
}
-
- setTestQueue((prev) => prev.slice(1));
};
- // Monitor queue changes
- useEffect(() => {
- if (testQueue.length > 0 && isProcessingQueue) {
- processTestQueue();
- } else if (testQueue.length === 0 && isProcessingQueue) {
- setIsProcessingQueue(false);
- setIsBatchTesting(false);
- }
- }, [testQueue, isProcessingQueue]);
-
- // Batch test models
+ // 批量测试单个渠道的所有模型,参考旧版实现
const batchTestModels = async () => {
- if (!currentTestChannel) return;
+ if (!currentTestChannel || !currentTestChannel.models) {
+ showError(t('渠道模型信息不完整'));
+ return;
+ }
+
+ const models = currentTestChannel.models.split(',').filter(model =>
+ model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
+ );
+
+ if (models.length === 0) {
+ showError(t('没有找到匹配的模型'));
+ return;
+ }
setIsBatchTesting(true);
- setModelTablePage(1);
+ shouldStopBatchTestingRef.current = false; // 重置停止标志
- const filteredModels = currentTestChannel.models
- .split(',')
- .filter((model) =>
- model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
- );
+ // 清空该渠道之前的测试结果
+ setModelTestResults(prev => {
+ const newResults = { ...prev };
+ models.forEach(model => {
+ const testKey = `${currentTestChannel.id}-${model}`;
+ delete newResults[testKey];
+ });
+ return newResults;
+ });
- setTestQueue(
- filteredModels.map((model, idx) => ({
- channel: currentTestChannel,
- model,
- indexInFiltered: idx,
- })),
- );
- setIsProcessingQueue(true);
+ try {
+ showInfo(t('开始批量测试 ${count} 个模型,已清空上次结果...').replace('${count}', models.length));
+
+ // 提高并发数量以加快测试速度,参考旧版的并发限制
+ const concurrencyLimit = 5;
+ const results = [];
+
+ for (let i = 0; i < models.length; i += concurrencyLimit) {
+ // 检查是否应该停止
+ if (shouldStopBatchTestingRef.current) {
+ showInfo(t('批量测试已停止'));
+ break;
+ }
+
+ const batch = models.slice(i, i + concurrencyLimit);
+ showInfo(t('正在测试第 ${current} - ${end} 个模型 (共 ${total} 个)')
+ .replace('${current}', i + 1)
+ .replace('${end}', Math.min(i + concurrencyLimit, models.length))
+ .replace('${total}', models.length)
+ );
+
+ const batchPromises = batch.map(model => testChannel(currentTestChannel, model));
+ const batchResults = await Promise.allSettled(batchPromises);
+ results.push(...batchResults);
+
+ // 再次检查是否应该停止
+ if (shouldStopBatchTestingRef.current) {
+ showInfo(t('批量测试已停止'));
+ break;
+ }
+
+ // 短暂延迟避免过于频繁的请求
+ if (i + concurrencyLimit < models.length) {
+ await new Promise(resolve => setTimeout(resolve, 100));
+ }
+ }
+
+ if (!shouldStopBatchTestingRef.current) {
+ // 等待一小段时间确保所有结果都已更新
+ await new Promise(resolve => setTimeout(resolve, 300));
+
+ // 使用当前状态重新计算结果统计
+ setModelTestResults(currentResults => {
+ let successCount = 0;
+ let failCount = 0;
+
+ models.forEach(model => {
+ const testKey = `${currentTestChannel.id}-${model}`;
+ const result = currentResults[testKey];
+ if (result && result.success) {
+ successCount++;
+ } else {
+ failCount++;
+ }
+ });
+
+ // 显示完成消息
+ setTimeout(() => {
+ showSuccess(t('批量测试完成!成功: ${success}, 失败: ${fail}, 总计: ${total}')
+ .replace('${success}', successCount)
+ .replace('${fail}', failCount)
+ .replace('${total}', models.length)
+ );
+ }, 100);
+
+ return currentResults; // 不修改状态,只是为了获取最新值
+ });
+ }
+ } catch (error) {
+ showError(t('批量测试过程中发生错误: ') + error.message);
+ } finally {
+ setIsBatchTesting(false);
+ }
+ };
+
+ // 停止批量测试
+ const stopBatchTesting = () => {
+ shouldStopBatchTestingRef.current = true;
+ setIsBatchTesting(false);
+ setTestingModels(new Set());
+ showInfo(t('已停止批量测试'));
+ };
+
+ // 清空测试结果
+ const clearTestResults = () => {
+ setModelTestResults({});
+ showInfo(t('已清空测试结果'));
};
// Handle close modal
const handleCloseModal = () => {
+ // 如果正在批量测试,先停止测试
if (isBatchTesting) {
- setTestQueue([]);
- setIsProcessingQueue(false);
- setIsBatchTesting(false);
- showSuccess(t('已停止测试'));
- } else {
- setShowModelTestModal(false);
- setModelSearchKeyword('');
- setSelectedModelKeys([]);
- setModelTablePage(1);
+ shouldStopBatchTestingRef.current = true;
+ showInfo(t('关闭弹窗,已停止批量测试'));
}
+
+ setShowModelTestModal(false);
+ setModelSearchKeyword('');
+ setIsBatchTesting(false);
+ setTestingModels(new Set());
+ setSelectedModelKeys([]);
+ setModelTablePage(1);
+ // 可选择性保留测试结果,这里不清空以便用户查看
};
// Type counts
@@ -1012,4 +1045,4 @@ export const useChannelsData = () => {
setCompactMode,
setActivePage,
};
-};
+};
\ No newline at end of file
From b7bc609a7a837bd7902a6d014b90e398f066659a Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Tue, 16 Sep 2025 22:40:40 +0800
Subject: [PATCH 063/165] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=9F=9F?=
=?UTF-8?q?=E5=90=8D=E5=92=8Cip=E8=BF=87=E6=BB=A4=E6=A8=A1=E5=BC=8F?=
=?UTF-8?q?=E8=AE=BE=E7=BD=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
service/download.go | 4 +-
service/user_notify.go | 2 +-
service/webhook.go | 2 +-
setting/system_setting/fetch_setting.go | 14 ++-
web/src/components/settings/SystemSetting.jsx | 114 +++++++++++++-----
5 files changed, 99 insertions(+), 37 deletions(-)
diff --git a/service/download.go b/service/download.go
index 2f30870d4..43b6fe7df 100644
--- a/service/download.go
+++ b/service/download.go
@@ -30,7 +30,7 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) {
// SSRF防护:验证请求URL
fetchSetting := system_setting.GetFetchSetting()
- if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil {
+ if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
return nil, fmt.Errorf("request reject: %v", err)
}
@@ -59,7 +59,7 @@ func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response,
} else {
// SSRF防护:验证请求URL(非Worker模式)
fetchSetting := system_setting.GetFetchSetting()
- if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil {
+ if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
return nil, fmt.Errorf("request reject: %v", err)
}
diff --git a/service/user_notify.go b/service/user_notify.go
index f9d7b6691..1e9e8947c 100644
--- a/service/user_notify.go
+++ b/service/user_notify.go
@@ -115,7 +115,7 @@ func sendBarkNotify(barkURL string, data dto.Notify) error {
} else {
// SSRF防护:验证Bark URL(非Worker模式)
fetchSetting := system_setting.GetFetchSetting()
- if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil {
+ if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
return fmt.Errorf("request reject: %v", err)
}
diff --git a/service/webhook.go b/service/webhook.go
index 1f159eb4b..5d9ce400a 100644
--- a/service/webhook.go
+++ b/service/webhook.go
@@ -89,7 +89,7 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error
} else {
// SSRF防护:验证Webhook URL(非Worker模式)
fetchSetting := system_setting.GetFetchSetting()
- if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil {
+ if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
return fmt.Errorf("request reject: %v", err)
}
diff --git a/setting/system_setting/fetch_setting.go b/setting/system_setting/fetch_setting.go
index 6e47c3f06..5277e1033 100644
--- a/setting/system_setting/fetch_setting.go
+++ b/setting/system_setting/fetch_setting.go
@@ -5,16 +5,20 @@ import "one-api/setting/config"
type FetchSetting struct {
EnableSSRFProtection bool `json:"enable_ssrf_protection"` // 是否启用SSRF防护
AllowPrivateIp bool `json:"allow_private_ip"`
- WhitelistDomains []string `json:"whitelist_domains"` // domain format, e.g. example.com, *.example.com
- WhitelistIps []string `json:"whitelist_ips"` // CIDR format
- AllowedPorts []string `json:"allowed_ports"` // port range format, e.g. 80, 443, 8000-9000
+ DomainFilterMode bool `json:"domain_filter_mode"` // 域名过滤模式,true: 白名单模式,false: 黑名单模式
+ IpFilterMode bool `json:"ip_filter_mode"` // IP过滤模式,true: 白名单模式,false: 黑名单模式
+ DomainList []string `json:"domain_list"` // domain format, e.g. example.com, *.example.com
+ IpList []string `json:"ip_list"` // CIDR format
+ AllowedPorts []string `json:"allowed_ports"` // port range format, e.g. 80, 443, 8000-9000
}
var defaultFetchSetting = FetchSetting{
EnableSSRFProtection: true, // 默认开启SSRF防护
AllowPrivateIp: false,
- WhitelistDomains: []string{},
- WhitelistIps: []string{},
+ DomainFilterMode: true,
+ IpFilterMode: true,
+ DomainList: []string{},
+ IpList: []string{},
AllowedPorts: []string{"80", "443", "8080", "8443"},
}
diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx
index 71dfaac8d..ebe4084be 100644
--- a/web/src/components/settings/SystemSetting.jsx
+++ b/web/src/components/settings/SystemSetting.jsx
@@ -29,6 +29,7 @@ import {
TagInput,
Spin,
Card,
+ Radio,
} from '@douyinfe/semi-ui';
const { Text } = Typography;
import {
@@ -91,8 +92,10 @@ const SystemSetting = () => {
// SSRF防护配置
'fetch_setting.enable_ssrf_protection': true,
'fetch_setting.allow_private_ip': '',
- 'fetch_setting.whitelist_domains': [],
- 'fetch_setting.whitelist_ips': [],
+ 'fetch_setting.domain_filter_mode': true, // true 白名单,false 黑名单
+ 'fetch_setting.ip_filter_mode': true, // true 白名单,false 黑名单
+ 'fetch_setting.domain_list': [],
+ 'fetch_setting.ip_list': [],
'fetch_setting.allowed_ports': [],
});
@@ -105,8 +108,10 @@ const SystemSetting = () => {
useState(false);
const [linuxDOOAuthEnabled, setLinuxDOOAuthEnabled] = useState(false);
const [emailToAdd, setEmailToAdd] = useState('');
- const [whitelistDomains, setWhitelistDomains] = useState([]);
- const [whitelistIps, setWhitelistIps] = useState([]);
+ const [domainFilterMode, setDomainFilterMode] = useState(true);
+ const [ipFilterMode, setIpFilterMode] = useState(true);
+ const [domainList, setDomainList] = useState([]);
+ const [ipList, setIpList] = useState([]);
const [allowedPorts, setAllowedPorts] = useState([]);
const getOptions = async () => {
@@ -125,22 +130,24 @@ const SystemSetting = () => {
break;
case 'fetch_setting.allow_private_ip':
case 'fetch_setting.enable_ssrf_protection':
+ case 'fetch_setting.domain_filter_mode':
+ case 'fetch_setting.ip_filter_mode':
item.value = toBoolean(item.value);
break;
- case 'fetch_setting.whitelist_domains':
+ case 'fetch_setting.domain_list':
try {
const domains = item.value ? JSON.parse(item.value) : [];
- setWhitelistDomains(Array.isArray(domains) ? domains : []);
+ setDomainList(Array.isArray(domains) ? domains : []);
} catch (e) {
- setWhitelistDomains([]);
+ setDomainList([]);
}
break;
- case 'fetch_setting.whitelist_ips':
+ case 'fetch_setting.ip_list':
try {
const ips = item.value ? JSON.parse(item.value) : [];
- setWhitelistIps(Array.isArray(ips) ? ips : []);
+ setIpList(Array.isArray(ips) ? ips : []);
} catch (e) {
- setWhitelistIps([]);
+ setIpList([]);
}
break;
case 'fetch_setting.allowed_ports':
@@ -178,6 +185,13 @@ const SystemSetting = () => {
});
setInputs(newInputs);
setOriginInputs(newInputs);
+ // 同步模式布尔到本地状态
+ if (typeof newInputs['fetch_setting.domain_filter_mode'] !== 'undefined') {
+ setDomainFilterMode(!!newInputs['fetch_setting.domain_filter_mode']);
+ }
+ if (typeof newInputs['fetch_setting.ip_filter_mode'] !== 'undefined') {
+ setIpFilterMode(!!newInputs['fetch_setting.ip_filter_mode']);
+ }
if (formApiRef.current) {
formApiRef.current.setValues(newInputs);
}
@@ -317,19 +331,27 @@ const SystemSetting = () => {
const submitSSRF = async () => {
const options = [];
- // 处理域名白名单
- if (Array.isArray(whitelistDomains)) {
+ // 处理域名过滤模式与列表
+ options.push({
+ key: 'fetch_setting.domain_filter_mode',
+ value: domainFilterMode,
+ });
+ if (Array.isArray(domainList)) {
options.push({
- key: 'fetch_setting.whitelist_domains',
- value: JSON.stringify(whitelistDomains),
+ key: 'fetch_setting.domain_list',
+ value: JSON.stringify(domainList),
});
}
- // 处理IP白名单
- if (Array.isArray(whitelistIps)) {
+ // 处理IP过滤模式与列表
+ options.push({
+ key: 'fetch_setting.ip_filter_mode',
+ value: ipFilterMode,
+ });
+ if (Array.isArray(ipList)) {
options.push({
- key: 'fetch_setting.whitelist_ips',
- value: JSON.stringify(whitelistIps),
+ key: 'fetch_setting.ip_list',
+ value: JSON.stringify(ipList),
});
}
@@ -702,25 +724,43 @@ const SystemSetting = () => {
style={{ marginTop: 16 }}
>
- {t('域名白名单')}
+
+ {t(domainFilterMode ? '域名白名单' : '域名黑名单')}
+
{t('支持通配符格式,如:example.com, *.api.example.com')}
+ {
+ const isWhitelist = val === 'whitelist';
+ setDomainFilterMode(isWhitelist);
+ setInputs(prev => ({
+ ...prev,
+ 'fetch_setting.domain_filter_mode': isWhitelist,
+ }));
+ }}
+ style={{ marginBottom: 8 }}
+ >
+ {t('白名单')}
+ {t('黑名单')}
+
{
- setWhitelistDomains(value);
+ setDomainList(value);
// 触发Form的onChange事件
setInputs(prev => ({
...prev,
- 'fetch_setting.whitelist_domains': value
+ 'fetch_setting.domain_list': value
}));
}}
placeholder={t('输入域名后回车,如:example.com')}
style={{ width: '100%' }}
/>
- {t('域名白名单详细说明')}
+ {t('域名过滤详细说明')}
@@ -730,25 +770,43 @@ const SystemSetting = () => {
style={{ marginTop: 16 }}
>
- {t('IP白名单')}
+
+ {t(ipFilterMode ? 'IP白名单' : 'IP黑名单')}
+
{t('支持CIDR格式,如:8.8.8.8, 192.168.1.0/24')}
+ {
+ const isWhitelist = val === 'whitelist';
+ setIpFilterMode(isWhitelist);
+ setInputs(prev => ({
+ ...prev,
+ 'fetch_setting.ip_filter_mode': isWhitelist,
+ }));
+ }}
+ style={{ marginBottom: 8 }}
+ >
+ {t('白名单')}
+ {t('黑名单')}
+
{
- setWhitelistIps(value);
+ setIpList(value);
// 触发Form的onChange事件
setInputs(prev => ({
...prev,
- 'fetch_setting.whitelist_ips': value
+ 'fetch_setting.ip_list': value
}));
}}
placeholder={t('输入IP地址后回车,如:8.8.8.8')}
style={{ width: '100%' }}
/>
- {t('IP白名单详细说明')}
+ {t('IP过滤详细说明')}
From 168ebb1cd42841b403e301c9cb7ff510dae83908 Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Wed, 17 Sep 2025 15:41:21 +0800
Subject: [PATCH 064/165] =?UTF-8?q?feat:=20ssrf=E6=94=AF=E6=8C=81=E5=9F=9F?=
=?UTF-8?q?=E5=90=8D=E5=92=8Cip=E9=BB=91=E7=99=BD=E5=90=8D=E5=8D=95?=
=?UTF-8?q?=E8=BF=87=E6=BB=A4=E6=A8=A1=E5=BC=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
common/ssrf_protection.go | 199 ++++++++++++++------------------------
service/download.go | 4 +-
service/user_notify.go | 2 +-
service/webhook.go | 2 +-
4 files changed, 74 insertions(+), 133 deletions(-)
diff --git a/common/ssrf_protection.go b/common/ssrf_protection.go
index b0988d907..52b839525 100644
--- a/common/ssrf_protection.go
+++ b/common/ssrf_protection.go
@@ -11,16 +11,20 @@ import (
// SSRFProtection SSRF防护配置
type SSRFProtection struct {
AllowPrivateIp bool
- WhitelistDomains []string // domain format, e.g. example.com, *.example.com
- WhitelistIps []string // CIDR format
+ DomainFilterMode bool // true: 白名单, false: 黑名单
+ DomainList []string // domain format, e.g. example.com, *.example.com
+ IpFilterMode bool // true: 白名单, false: 黑名单
+ IpList []string // CIDR or single IP
AllowedPorts []int // 允许的端口范围
}
// DefaultSSRFProtection 默认SSRF防护配置
var DefaultSSRFProtection = &SSRFProtection{
AllowPrivateIp: false,
- WhitelistDomains: []string{},
- WhitelistIps: []string{},
+ DomainFilterMode: true,
+ DomainList: []string{},
+ IpFilterMode: true,
+ IpList: []string{},
AllowedPorts: []int{},
}
@@ -138,44 +142,25 @@ func (p *SSRFProtection) isAllowedPort(port int) bool {
return false
}
-// isAllowedPortFromRanges 从端口范围字符串检查端口是否被允许
-func isAllowedPortFromRanges(port int, portRanges []string) bool {
- if len(portRanges) == 0 {
- return true // 如果没有配置端口限制,则允许所有端口
- }
-
- allowedPorts, err := parsePortRanges(portRanges)
- if err != nil {
- // 如果解析失败,为安全起见拒绝访问
- return false
- }
-
- for _, allowedPort := range allowedPorts {
- if port == allowedPort {
- return true
- }
- }
- return false
-}
-
// isDomainWhitelisted 检查域名是否在白名单中
-func (p *SSRFProtection) isDomainWhitelisted(domain string) bool {
- if len(p.WhitelistDomains) == 0 {
+func isDomainListed(domain string, list []string) bool {
+ if len(list) == 0 {
return false
}
domain = strings.ToLower(domain)
- for _, whitelistDomain := range p.WhitelistDomains {
- whitelistDomain = strings.ToLower(whitelistDomain)
-
+ for _, item := range list {
+ item = strings.ToLower(strings.TrimSpace(item))
+ if item == "" {
+ continue
+ }
// 精确匹配
- if domain == whitelistDomain {
+ if domain == item {
return true
}
-
// 通配符匹配 (*.example.com)
- if strings.HasPrefix(whitelistDomain, "*.") {
- suffix := strings.TrimPrefix(whitelistDomain, "*.")
+ if strings.HasPrefix(item, "*.") {
+ suffix := strings.TrimPrefix(item, "*.")
if strings.HasSuffix(domain, "."+suffix) || domain == suffix {
return true
}
@@ -184,13 +169,23 @@ func (p *SSRFProtection) isDomainWhitelisted(domain string) bool {
return false
}
+func (p *SSRFProtection) isDomainAllowed(domain string) bool {
+ listed := isDomainListed(domain, p.DomainList)
+ if p.DomainFilterMode { // 白名单
+ return listed
+ }
+ // 黑名单
+ return !listed
+}
+
// isIPWhitelisted 检查IP是否在白名单中
-func (p *SSRFProtection) isIPWhitelisted(ip net.IP) bool {
- if len(p.WhitelistIps) == 0 {
+
+func isIPListed(ip net.IP, list []string) bool {
+ if len(list) == 0 {
return false
}
- for _, whitelistCIDR := range p.WhitelistIps {
+ for _, whitelistCIDR := range list {
_, network, err := net.ParseCIDR(whitelistCIDR)
if err != nil {
// 尝试作为单个IP处理
@@ -211,22 +206,17 @@ func (p *SSRFProtection) isIPWhitelisted(ip net.IP) bool {
// IsIPAccessAllowed 检查IP是否允许访问
func (p *SSRFProtection) IsIPAccessAllowed(ip net.IP) bool {
- // 如果IP在白名单中,直接允许访问(绕过私有IP检查)
- if p.isIPWhitelisted(ip) {
- return true
+ // 私有IP限制
+ if isPrivateIP(ip) && !p.AllowPrivateIp {
+ return false
}
- // 如果IP白名单为空,允许所有IP(但仍需通过私有IP检查)
- if len(p.WhitelistIps) == 0 {
- // 检查私有IP限制
- if isPrivateIP(ip) && !p.AllowPrivateIp {
- return false
- }
- return true
+ listed := isIPListed(ip, p.IpList)
+ if p.IpFilterMode { // 白名单
+ return listed
}
-
- // 如果IP白名单不为空且IP不在白名单中,拒绝访问
- return false
+ // 黑名单
+ return !listed
}
// ValidateURL 验证URL是否安全
@@ -264,28 +254,44 @@ func (p *SSRFProtection) ValidateURL(urlStr string) error {
return fmt.Errorf("port %d is not allowed", port)
}
- // 检查域名白名单
- if p.isDomainWhitelisted(host) {
- return nil // 白名单域名直接通过
+ // 如果 host 是 IP,则跳过域名检查
+ if ip := net.ParseIP(host); ip != nil {
+ if !p.IsIPAccessAllowed(ip) {
+ if isPrivateIP(ip) {
+ return fmt.Errorf("private IP address not allowed: %s", ip.String())
+ }
+ if p.IpFilterMode {
+ return fmt.Errorf("ip not in whitelist: %s", ip.String())
+ }
+ return fmt.Errorf("ip in blacklist: %s", ip.String())
+ }
+ return nil
}
- // DNS解析获取IP地址
+ // 先进行域名过滤
+ if !p.isDomainAllowed(host) {
+ if p.DomainFilterMode {
+ return fmt.Errorf("domain not in whitelist: %s", host)
+ }
+ return fmt.Errorf("domain in blacklist: %s", host)
+ }
+
+ // 解析域名对应IP并检查
ips, err := net.LookupIP(host)
if err != nil {
return fmt.Errorf("DNS resolution failed for %s: %v", host, err)
}
-
- // 检查所有解析的IP地址
for _, ip := range ips {
if !p.IsIPAccessAllowed(ip) {
- if isPrivateIP(ip) {
+ if isPrivateIP(ip) && !p.AllowPrivateIp {
return fmt.Errorf("private IP address not allowed: %s resolves to %s", host, ip.String())
- } else {
- return fmt.Errorf("IP address not in whitelist: %s resolves to %s", host, ip.String())
}
+ if p.IpFilterMode {
+ return fmt.Errorf("ip not in whitelist: %s resolves to %s", host, ip.String())
+ }
+ return fmt.Errorf("ip in blacklist: %s resolves to %s", host, ip.String())
}
}
-
return nil
}
@@ -295,7 +301,7 @@ func ValidateURLWithDefaults(urlStr string) error {
}
// ValidateURLWithFetchSetting 使用FetchSetting配置验证URL
-func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, whitelistDomains, whitelistIps, allowedPorts []string) error {
+func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string) error {
// 如果SSRF防护被禁用,直接返回成功
if !enableSSRFProtection {
return nil
@@ -309,76 +315,11 @@ func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPriva
protection := &SSRFProtection{
AllowPrivateIp: allowPrivateIp,
- WhitelistDomains: whitelistDomains,
- WhitelistIps: whitelistIps,
+ DomainFilterMode: domainFilterMode,
+ DomainList: domainList,
+ IpFilterMode: ipFilterMode,
+ IpList: ipList,
AllowedPorts: allowedPortInts,
}
return protection.ValidateURL(urlStr)
}
-
-// ValidateURLWithPortRanges 直接使用端口范围字符串验证URL(更高效的版本)
-func ValidateURLWithPortRanges(urlStr string, allowPrivateIp bool, whitelistDomains, whitelistIps, allowedPorts []string) error {
- // 解析URL
- u, err := url.Parse(urlStr)
- if err != nil {
- return fmt.Errorf("invalid URL format: %v", err)
- }
-
- // 只允许HTTP/HTTPS协议
- if u.Scheme != "http" && u.Scheme != "https" {
- return fmt.Errorf("unsupported protocol: %s (only http/https allowed)", u.Scheme)
- }
-
- // 解析主机和端口
- host, portStr, err := net.SplitHostPort(u.Host)
- if err != nil {
- // 没有端口,使用默认端口
- host = u.Host
- if u.Scheme == "https" {
- portStr = "443"
- } else {
- portStr = "80"
- }
- }
-
- // 验证端口
- port, err := strconv.Atoi(portStr)
- if err != nil {
- return fmt.Errorf("invalid port: %s", portStr)
- }
-
- if !isAllowedPortFromRanges(port, allowedPorts) {
- return fmt.Errorf("port %d is not allowed", port)
- }
-
- // 创建临时的SSRFProtection来复用域名和IP检查逻辑
- protection := &SSRFProtection{
- AllowPrivateIp: allowPrivateIp,
- WhitelistDomains: whitelistDomains,
- WhitelistIps: whitelistIps,
- }
-
- // 检查域名白名单
- if protection.isDomainWhitelisted(host) {
- return nil // 白名单域名直接通过
- }
-
- // DNS解析获取IP地址
- ips, err := net.LookupIP(host)
- if err != nil {
- return fmt.Errorf("DNS resolution failed for %s: %v", host, err)
- }
-
- // 检查所有解析的IP地址
- for _, ip := range ips {
- if !protection.IsIPAccessAllowed(ip) {
- if isPrivateIP(ip) {
- return fmt.Errorf("private IP address not allowed: %s resolves to %s", host, ip.String())
- } else {
- return fmt.Errorf("IP address not in whitelist: %s resolves to %s", host, ip.String())
- }
- }
- }
-
- return nil
-}
diff --git a/service/download.go b/service/download.go
index 43b6fe7df..c07c9e1cd 100644
--- a/service/download.go
+++ b/service/download.go
@@ -30,7 +30,7 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) {
// SSRF防护:验证请求URL
fetchSetting := system_setting.GetFetchSetting()
- if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
+ if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
return nil, fmt.Errorf("request reject: %v", err)
}
@@ -59,7 +59,7 @@ func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response,
} else {
// SSRF防护:验证请求URL(非Worker模式)
fetchSetting := system_setting.GetFetchSetting()
- if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
+ if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
return nil, fmt.Errorf("request reject: %v", err)
}
diff --git a/service/user_notify.go b/service/user_notify.go
index 1e9e8947c..76d15903d 100644
--- a/service/user_notify.go
+++ b/service/user_notify.go
@@ -115,7 +115,7 @@ func sendBarkNotify(barkURL string, data dto.Notify) error {
} else {
// SSRF防护:验证Bark URL(非Worker模式)
fetchSetting := system_setting.GetFetchSetting()
- if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
+ if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
return fmt.Errorf("request reject: %v", err)
}
diff --git a/service/webhook.go b/service/webhook.go
index 5d9ce400a..b7fd13df6 100644
--- a/service/webhook.go
+++ b/service/webhook.go
@@ -89,7 +89,7 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error
} else {
// SSRF防护:验证Webhook URL(非Worker模式)
fetchSetting := system_setting.GetFetchSetting()
- if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
+ if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
return fmt.Errorf("request reject: %v", err)
}
From f635fc3ae6dfb7ad06effaf69309645f7c45650a Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Wed, 17 Sep 2025 23:29:18 +0800
Subject: [PATCH 065/165] feat: remove ValidateURLWithDefaults
---
common/ssrf_protection.go | 5 -----
1 file changed, 5 deletions(-)
diff --git a/common/ssrf_protection.go b/common/ssrf_protection.go
index 52b839525..e48ca0e08 100644
--- a/common/ssrf_protection.go
+++ b/common/ssrf_protection.go
@@ -295,11 +295,6 @@ func (p *SSRFProtection) ValidateURL(urlStr string) error {
return nil
}
-// ValidateURLWithDefaults 使用默认配置验证URL
-func ValidateURLWithDefaults(urlStr string) error {
- return DefaultSSRFProtection.ValidateURL(urlStr)
-}
-
// ValidateURLWithFetchSetting 使用FetchSetting配置验证URL
func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string) error {
// 如果SSRF防护被禁用,直接返回成功
From 467e58435959a812dd5132c5cafebfa89b55c9f3 Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Wed, 17 Sep 2025 23:46:04 +0800
Subject: [PATCH 066/165] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=9F=9F?=
=?UTF-8?q?=E5=90=8D=E5=90=AF=E7=94=A8ip=E8=BF=87=E6=BB=A4=E5=BC=80?=
=?UTF-8?q?=E5=85=B3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
common/ssrf_protection.go | 33 +++++++++++--------
service/download.go | 4 +--
service/user_notify.go | 2 +-
service/webhook.go | 2 +-
setting/system_setting/fetch_setting.go | 30 +++++++++--------
web/src/components/settings/SystemSetting.jsx | 19 +++++++++--
6 files changed, 57 insertions(+), 33 deletions(-)
diff --git a/common/ssrf_protection.go b/common/ssrf_protection.go
index e48ca0e08..40d3b10b8 100644
--- a/common/ssrf_protection.go
+++ b/common/ssrf_protection.go
@@ -10,12 +10,13 @@ import (
// SSRFProtection SSRF防护配置
type SSRFProtection struct {
- AllowPrivateIp bool
- DomainFilterMode bool // true: 白名单, false: 黑名单
- DomainList []string // domain format, e.g. example.com, *.example.com
- IpFilterMode bool // true: 白名单, false: 黑名单
- IpList []string // CIDR or single IP
- AllowedPorts []int // 允许的端口范围
+ AllowPrivateIp bool
+ DomainFilterMode bool // true: 白名单, false: 黑名单
+ DomainList []string // domain format, e.g. example.com, *.example.com
+ IpFilterMode bool // true: 白名单, false: 黑名单
+ IpList []string // CIDR or single IP
+ AllowedPorts []int // 允许的端口范围
+ ApplyIPFilterForDomain bool // 对域名启用IP过滤
}
// DefaultSSRFProtection 默认SSRF防护配置
@@ -276,6 +277,11 @@ func (p *SSRFProtection) ValidateURL(urlStr string) error {
return fmt.Errorf("domain in blacklist: %s", host)
}
+ // 若未启用对域名应用IP过滤,则到此通过
+ if !p.ApplyIPFilterForDomain {
+ return nil
+ }
+
// 解析域名对应IP并检查
ips, err := net.LookupIP(host)
if err != nil {
@@ -296,7 +302,7 @@ func (p *SSRFProtection) ValidateURL(urlStr string) error {
}
// ValidateURLWithFetchSetting 使用FetchSetting配置验证URL
-func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string) error {
+func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string, applyIPFilterForDomain bool) error {
// 如果SSRF防护被禁用,直接返回成功
if !enableSSRFProtection {
return nil
@@ -309,12 +315,13 @@ func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPriva
}
protection := &SSRFProtection{
- AllowPrivateIp: allowPrivateIp,
- DomainFilterMode: domainFilterMode,
- DomainList: domainList,
- IpFilterMode: ipFilterMode,
- IpList: ipList,
- AllowedPorts: allowedPortInts,
+ AllowPrivateIp: allowPrivateIp,
+ DomainFilterMode: domainFilterMode,
+ DomainList: domainList,
+ IpFilterMode: ipFilterMode,
+ IpList: ipList,
+ AllowedPorts: allowedPortInts,
+ ApplyIPFilterForDomain: applyIPFilterForDomain,
}
return protection.ValidateURL(urlStr)
}
diff --git a/service/download.go b/service/download.go
index c07c9e1cd..036c43af8 100644
--- a/service/download.go
+++ b/service/download.go
@@ -30,7 +30,7 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) {
// SSRF防护:验证请求URL
fetchSetting := system_setting.GetFetchSetting()
- if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
+ if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
return nil, fmt.Errorf("request reject: %v", err)
}
@@ -59,7 +59,7 @@ func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response,
} else {
// SSRF防护:验证请求URL(非Worker模式)
fetchSetting := system_setting.GetFetchSetting()
- if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
+ if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
return nil, fmt.Errorf("request reject: %v", err)
}
diff --git a/service/user_notify.go b/service/user_notify.go
index 76d15903d..fba12d9db 100644
--- a/service/user_notify.go
+++ b/service/user_notify.go
@@ -115,7 +115,7 @@ func sendBarkNotify(barkURL string, data dto.Notify) error {
} else {
// SSRF防护:验证Bark URL(非Worker模式)
fetchSetting := system_setting.GetFetchSetting()
- if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
+ if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
return fmt.Errorf("request reject: %v", err)
}
diff --git a/service/webhook.go b/service/webhook.go
index b7fd13df6..c678b8634 100644
--- a/service/webhook.go
+++ b/service/webhook.go
@@ -89,7 +89,7 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error
} else {
// SSRF防护:验证Webhook URL(非Worker模式)
fetchSetting := system_setting.GetFetchSetting()
- if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
+ if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
return fmt.Errorf("request reject: %v", err)
}
diff --git a/setting/system_setting/fetch_setting.go b/setting/system_setting/fetch_setting.go
index 5277e1033..3c7f1e059 100644
--- a/setting/system_setting/fetch_setting.go
+++ b/setting/system_setting/fetch_setting.go
@@ -3,23 +3,25 @@ package system_setting
import "one-api/setting/config"
type FetchSetting struct {
- EnableSSRFProtection bool `json:"enable_ssrf_protection"` // 是否启用SSRF防护
- AllowPrivateIp bool `json:"allow_private_ip"`
- DomainFilterMode bool `json:"domain_filter_mode"` // 域名过滤模式,true: 白名单模式,false: 黑名单模式
- IpFilterMode bool `json:"ip_filter_mode"` // IP过滤模式,true: 白名单模式,false: 黑名单模式
- DomainList []string `json:"domain_list"` // domain format, e.g. example.com, *.example.com
- IpList []string `json:"ip_list"` // CIDR format
- AllowedPorts []string `json:"allowed_ports"` // port range format, e.g. 80, 443, 8000-9000
+ EnableSSRFProtection bool `json:"enable_ssrf_protection"` // 是否启用SSRF防护
+ AllowPrivateIp bool `json:"allow_private_ip"`
+ DomainFilterMode bool `json:"domain_filter_mode"` // 域名过滤模式,true: 白名单模式,false: 黑名单模式
+ IpFilterMode bool `json:"ip_filter_mode"` // IP过滤模式,true: 白名单模式,false: 黑名单模式
+ DomainList []string `json:"domain_list"` // domain format, e.g. example.com, *.example.com
+ IpList []string `json:"ip_list"` // CIDR format
+ AllowedPorts []string `json:"allowed_ports"` // port range format, e.g. 80, 443, 8000-9000
+ ApplyIPFilterForDomain bool `json:"apply_ip_filter_for_domain"` // 对域名启用IP过滤(实验性)
}
var defaultFetchSetting = FetchSetting{
- EnableSSRFProtection: true, // 默认开启SSRF防护
- AllowPrivateIp: false,
- DomainFilterMode: true,
- IpFilterMode: true,
- DomainList: []string{},
- IpList: []string{},
- AllowedPorts: []string{"80", "443", "8080", "8443"},
+ EnableSSRFProtection: true, // 默认开启SSRF防护
+ AllowPrivateIp: false,
+ DomainFilterMode: true,
+ IpFilterMode: true,
+ DomainList: []string{},
+ IpList: []string{},
+ AllowedPorts: []string{"80", "443", "8080", "8443"},
+ ApplyIPFilterForDomain: false,
}
func init() {
diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx
index ebe4084be..a1d26a4ad 100644
--- a/web/src/components/settings/SystemSetting.jsx
+++ b/web/src/components/settings/SystemSetting.jsx
@@ -97,6 +97,7 @@ const SystemSetting = () => {
'fetch_setting.domain_list': [],
'fetch_setting.ip_list': [],
'fetch_setting.allowed_ports': [],
+ 'fetch_setting.apply_ip_filter_for_domain': false,
});
const [originInputs, setOriginInputs] = useState({});
@@ -132,6 +133,7 @@ const SystemSetting = () => {
case 'fetch_setting.enable_ssrf_protection':
case 'fetch_setting.domain_filter_mode':
case 'fetch_setting.ip_filter_mode':
+ case 'fetch_setting.apply_ip_filter_for_domain':
item.value = toBoolean(item.value);
break;
case 'fetch_setting.domain_list':
@@ -724,6 +726,17 @@ const SystemSetting = () => {
style={{ marginTop: 16 }}
>
+
+
+ handleCheckboxChange('fetch_setting.apply_ip_filter_for_domain', e)
+ }
+ style={{ marginBottom: 8 }}
+ >
+ {t('对域名启用 IP 过滤(实验性)')}
+
{t(domainFilterMode ? '域名白名单' : '域名黑名单')}
@@ -734,7 +747,8 @@ const SystemSetting = () => {
type='button'
value={domainFilterMode ? 'whitelist' : 'blacklist'}
onChange={(val) => {
- const isWhitelist = val === 'whitelist';
+ const selected = val && val.target ? val.target.value : val;
+ const isWhitelist = selected === 'whitelist';
setDomainFilterMode(isWhitelist);
setInputs(prev => ({
...prev,
@@ -780,7 +794,8 @@ const SystemSetting = () => {
type='button'
value={ipFilterMode ? 'whitelist' : 'blacklist'}
onChange={(val) => {
- const isWhitelist = val === 'whitelist';
+ const selected = val && val.target ? val.target.value : val;
+ const isWhitelist = selected === 'whitelist';
setIpFilterMode(isWhitelist);
setInputs(prev => ({
...prev,
From 00f45940620ae0b25e7b74618aa2fafb2a2b6f85 Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Wed, 17 Sep 2025 23:47:59 +0800
Subject: [PATCH 067/165] fix: use u.Hostname() instead of u.Host to avoid ipv6
host parse failed
---
common/ssrf_protection.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/common/ssrf_protection.go b/common/ssrf_protection.go
index 40d3b10b8..6f7d289f1 100644
--- a/common/ssrf_protection.go
+++ b/common/ssrf_protection.go
@@ -237,7 +237,7 @@ func (p *SSRFProtection) ValidateURL(urlStr string) error {
host, portStr, err := net.SplitHostPort(u.Host)
if err != nil {
// 没有端口,使用默认端口
- host = u.Host
+ host = u.Hostname()
if u.Scheme == "https" {
portStr = "443"
} else {
From 31c8ead1d45c2b3aa0cd28731be3d73eaf122cac Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Wed, 17 Sep 2025 23:54:34 +0800
Subject: [PATCH 068/165] =?UTF-8?q?feat:=20=E7=A7=BB=E9=99=A4=E5=A4=9A?=
=?UTF-8?q?=E4=BD=99=E7=9A=84=E8=AF=B4=E6=98=8E=E6=96=87=E6=9C=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
web/src/components/settings/SystemSetting.jsx | 6 ------
1 file changed, 6 deletions(-)
diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx
index a1d26a4ad..3218cdf07 100644
--- a/web/src/components/settings/SystemSetting.jsx
+++ b/web/src/components/settings/SystemSetting.jsx
@@ -773,9 +773,6 @@ const SystemSetting = () => {
placeholder={t('输入域名后回车,如:example.com')}
style={{ width: '100%' }}
/>
-
- {t('域名过滤详细说明')}
-
@@ -820,9 +817,6 @@ const SystemSetting = () => {
placeholder={t('输入IP地址后回车,如:8.8.8.8')}
style={{ width: '100%' }}
/>
-
- {t('IP过滤详细说明')}
-
From 10da082412532682be054f9a1921a6d55284152b Mon Sep 17 00:00:00 2001
From: MyPrototypeWhat
Date: Thu, 18 Sep 2025 12:01:35 +0800
Subject: [PATCH 069/165] refactor: Enhance UserArea dropdown positioning with
useRef
- Added useRef to manage dropdown positioning in UserArea component.
- Wrapped Dropdown in a div with a ref to ensure correct popup container.
- Minor adjustments to maintain existing functionality and styling.
---
.../components/layout/headerbar/UserArea.jsx | 170 +++++++++---------
1 file changed, 87 insertions(+), 83 deletions(-)
diff --git a/web/src/components/layout/headerbar/UserArea.jsx b/web/src/components/layout/headerbar/UserArea.jsx
index 8ea70f47f..9fc011da1 100644
--- a/web/src/components/layout/headerbar/UserArea.jsx
+++ b/web/src/components/layout/headerbar/UserArea.jsx
@@ -17,7 +17,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import React from 'react';
+import React, { useRef } from 'react';
import { Link } from 'react-router-dom';
import { Avatar, Button, Dropdown, Typography } from '@douyinfe/semi-ui';
import { ChevronDown } from 'lucide-react';
@@ -39,6 +39,7 @@ const UserArea = ({
navigate,
t,
}) => {
+ const dropdownRef = useRef(null);
if (isLoading) {
return (
- {
- navigate('/console/personal');
- }}
- className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
- >
-
-
- {t('个人设置')}
-
-
- {
- navigate('/console/token');
- }}
- className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
- >
-
-
- {t('令牌管理')}
-
-
- {
- navigate('/console/topup');
- }}
- className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
- >
-
-
- {t('钱包管理')}
-
-
-
-
-
- {t('退出')}
-
-
-
- }
- >
-
-
+
+ {userState.user.username[0].toUpperCase()}
+
+
+
+ {userState.user.username}
+
+
+
+
+
+
);
} else {
const showRegisterButton = !isSelfUseMode;
From 50a432180dbe805c19664aaf7c4da7f646af1cd4 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Thu, 18 Sep 2025 13:40:52 +0800
Subject: [PATCH 070/165] feat: add experimental IP filtering for domains and
update related settings
---
setting/system_setting/fetch_setting.go | 4 ++--
web/src/components/settings/SystemSetting.jsx | 6 +++---
web/src/i18n/locales/en.json | 8 ++++++--
web/src/i18n/locales/zh.json | 3 ++-
4 files changed, 13 insertions(+), 8 deletions(-)
diff --git a/setting/system_setting/fetch_setting.go b/setting/system_setting/fetch_setting.go
index 3c7f1e059..c41b930af 100644
--- a/setting/system_setting/fetch_setting.go
+++ b/setting/system_setting/fetch_setting.go
@@ -16,8 +16,8 @@ type FetchSetting struct {
var defaultFetchSetting = FetchSetting{
EnableSSRFProtection: true, // 默认开启SSRF防护
AllowPrivateIp: false,
- DomainFilterMode: true,
- IpFilterMode: true,
+ DomainFilterMode: false,
+ IpFilterMode: false,
DomainList: []string{},
IpList: []string{},
AllowedPorts: []string{"80", "443", "8080", "8443"},
diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx
index 3218cdf07..f9a2c019d 100644
--- a/web/src/components/settings/SystemSetting.jsx
+++ b/web/src/components/settings/SystemSetting.jsx
@@ -92,8 +92,8 @@ const SystemSetting = () => {
// SSRF防护配置
'fetch_setting.enable_ssrf_protection': true,
'fetch_setting.allow_private_ip': '',
- 'fetch_setting.domain_filter_mode': true, // true 白名单,false 黑名单
- 'fetch_setting.ip_filter_mode': true, // true 白名单,false 黑名单
+ 'fetch_setting.domain_filter_mode': false, // true 白名单,false 黑名单
+ 'fetch_setting.ip_filter_mode': false, // true 白名单,false 黑名单
'fetch_setting.domain_list': [],
'fetch_setting.ip_list': [],
'fetch_setting.allowed_ports': [],
@@ -726,10 +726,10 @@ const SystemSetting = () => {
style={{ marginTop: 16 }}
>
-
handleCheckboxChange('fetch_setting.apply_ip_filter_for_domain', e)
}
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index 6759f53e8..0af06477a 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -2098,7 +2098,6 @@
"支持通配符格式,如:example.com, *.api.example.com": "Supports wildcard format, e.g.: example.com, *.api.example.com",
"域名白名单详细说明": "Whitelisted domains bypass all SSRF checks and are allowed direct access. Supports exact domains (example.com) or wildcards (*.api.example.com) for subdomains. When whitelist is empty, all domains go through SSRF validation.",
"输入域名后回车,如:example.com": "Enter domain and press Enter, e.g.: example.com",
- "IP白名单": "IP Whitelist",
"支持CIDR格式,如:8.8.8.8, 192.168.1.0/24": "Supports CIDR format, e.g.: 8.8.8.8, 192.168.1.0/24",
"IP白名单详细说明": "Controls which IP addresses are allowed access. Use single IPs (8.8.8.8) or CIDR notation (192.168.1.0/24). Empty whitelist allows all IPs (subject to private IP settings), non-empty whitelist only allows listed IPs.",
"输入IP地址后回车,如:8.8.8.8": "Enter IP address and press Enter, e.g.: 8.8.8.8",
@@ -2106,5 +2105,10 @@
"支持单个端口和端口范围,如:80, 443, 8000-8999": "Supports single ports and port ranges, e.g.: 80, 443, 8000-8999",
"端口配置详细说明": "Restrict external requests to specific ports. Use single ports (80, 443) or ranges (8000-8999). Empty list allows all ports. Default includes common web ports.",
"输入端口后回车,如:80 或 8000-8999": "Enter port and press Enter, e.g.: 80 or 8000-8999",
- "更新SSRF防护设置": "Update SSRF Protection Settings"
+ "更新SSRF防护设置": "Update SSRF Protection Settings",
+ "对域名启用 IP 过滤(实验性)": "Enable IP filtering for domains (experimental)",
+ "域名IP过滤详细说明": "⚠️ This is an experimental option. A domain may resolve to multiple IPv4/IPv6 addresses. If enabled, ensure the IP filter list covers these addresses, otherwise access may fail.",
+ "域名黑名单": "Domain Blacklist",
+ "白名单": "Whitelist",
+ "黑名单": "Blacklist"
}
diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json
index 717770449..95fa06414 100644
--- a/web/src/i18n/locales/zh.json
+++ b/web/src/i18n/locales/zh.json
@@ -31,5 +31,6 @@
"支持单个端口和端口范围,如:80, 443, 8000-8999": "支持单个端口和端口范围,如:80, 443, 8000-8999",
"端口配置详细说明": "限制外部请求只能访问指定端口。支持单个端口(80, 443)或端口范围(8000-8999)。空列表允许所有端口。默认包含常用Web端口。",
"输入端口后回车,如:80 或 8000-8999": "输入端口后回车,如:80 或 8000-8999",
- "更新SSRF防护设置": "更新SSRF防护设置"
+ "更新SSRF防护设置": "更新SSRF防护设置",
+ "域名IP过滤详细说明": "⚠️此功能为实验性选项,域名可能解析到多个 IPv4/IPv6 地址,若开启,请确保 IP 过滤列表覆盖这些地址,否则可能导致访问失败。"
}
From 4b98fceb6e07abbb537297450c12dec993518708 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Thu, 18 Sep 2025 13:53:58 +0800
Subject: [PATCH 071/165] CI
---
.github/workflows/docker-image-arm64.yml | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/docker-image-arm64.yml b/.github/workflows/docker-image-arm64.yml
index 8e4656aa7..cabf3cec4 100644
--- a/.github/workflows/docker-image-arm64.yml
+++ b/.github/workflows/docker-image-arm64.yml
@@ -53,4 +53,5 @@ jobs:
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
- labels: ${{ steps.meta.outputs.labels }}
\ No newline at end of file
+ labels: ${{ steps.meta.outputs.labels }}
+ provenance: false
\ No newline at end of file
From d331f0fb2af5006227a970d9ba0e9d0ab6b157b7 Mon Sep 17 00:00:00 2001
From: Seefs
Date: Thu, 18 Sep 2025 16:14:25 +0800
Subject: [PATCH 072/165] fix: kimi claude code
---
relay/channel/moonshot/adaptor.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/relay/channel/moonshot/adaptor.go b/relay/channel/moonshot/adaptor.go
index e290c239d..f24976bb3 100644
--- a/relay/channel/moonshot/adaptor.go
+++ b/relay/channel/moonshot/adaptor.go
@@ -25,7 +25,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
- adaptor := openai.Adaptor{}
+ adaptor := claude.Adaptor{}
return adaptor.ConvertClaudeRequest(c, info, req)
}
From 23ee0fc3b47b5606ca7f1ea5bb13f451b833ca9b Mon Sep 17 00:00:00 2001
From: Seefs
Date: Thu, 18 Sep 2025 16:19:44 +0800
Subject: [PATCH 073/165] feat: deepseek claude endpoint
---
relay/channel/deepseek/adaptor.go | 25 +++++++++++++++----------
1 file changed, 15 insertions(+), 10 deletions(-)
diff --git a/relay/channel/deepseek/adaptor.go b/relay/channel/deepseek/adaptor.go
index 17d732ab0..292c1e4bd 100644
--- a/relay/channel/deepseek/adaptor.go
+++ b/relay/channel/deepseek/adaptor.go
@@ -3,17 +3,17 @@ package deepseek
import (
"errors"
"fmt"
+ "github.com/gin-gonic/gin"
"io"
"net/http"
"one-api/dto"
"one-api/relay/channel"
+ "one-api/relay/channel/claude"
"one-api/relay/channel/openai"
relaycommon "one-api/relay/common"
"one-api/relay/constant"
"one-api/types"
"strings"
-
- "github.com/gin-gonic/gin"
)
type Adaptor struct {
@@ -25,7 +25,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
- adaptor := openai.Adaptor{}
+ adaptor := claude.Adaptor{}
return adaptor.ConvertClaudeRequest(c, info, req)
}
@@ -44,14 +44,19 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
fimBaseUrl := info.ChannelBaseUrl
- if !strings.HasSuffix(info.ChannelBaseUrl, "/beta") {
- fimBaseUrl += "/beta"
- }
- switch info.RelayMode {
- case constant.RelayModeCompletions:
- return fmt.Sprintf("%s/completions", fimBaseUrl), nil
+ switch info.RelayFormat {
+ case types.RelayFormatClaude:
+ return fmt.Sprintf("%s/anthropic/v1/messages", info.ChannelBaseUrl), nil
default:
- return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil
+ if !strings.HasSuffix(info.ChannelBaseUrl, "/beta") {
+ fimBaseUrl += "/beta"
+ }
+ switch info.RelayMode {
+ case constant.RelayModeCompletions:
+ return fmt.Sprintf("%s/completions", fimBaseUrl), nil
+ default:
+ return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil
+ }
}
}
From dd374cdd9b1fc59ac80a19ba83002d1711714e82 Mon Sep 17 00:00:00 2001
From: Seefs
Date: Thu, 18 Sep 2025 16:32:29 +0800
Subject: [PATCH 074/165] feat: deepseek claude endpoint
---
relay/channel/deepseek/adaptor.go | 15 ++++++++++-----
1 file changed, 10 insertions(+), 5 deletions(-)
diff --git a/relay/channel/deepseek/adaptor.go b/relay/channel/deepseek/adaptor.go
index 292c1e4bd..962f8794a 100644
--- a/relay/channel/deepseek/adaptor.go
+++ b/relay/channel/deepseek/adaptor.go
@@ -92,12 +92,17 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
- if info.IsStream {
- usage, err = openai.OaiStreamHandler(c, info, resp)
- } else {
- usage, err = openai.OpenaiHandler(c, info, resp)
+ switch info.RelayFormat {
+ case types.RelayFormatClaude:
+ if info.IsStream {
+ return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage)
+ } else {
+ return claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage)
+ }
+ default:
+ adaptor := openai.Adaptor{}
+ return adaptor.DoResponse(c, resp, info)
}
- return
}
func (a *Adaptor) GetModelList() []string {
From 9f1ab16aa5bd1e434da4571782d91dc940dc935c Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Fri, 19 Sep 2025 00:24:01 +0800
Subject: [PATCH 075/165] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20gemini-emb?=
=?UTF-8?q?edding-001?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/channel/gemini/adaptor.go | 4 ++--
relay/helper/valid_request.go | 16 ++++++++++++++--
setting/ratio_setting/model_ratio.go | 1 +
3 files changed, 17 insertions(+), 4 deletions(-)
diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go
index 4968f78fe..57542aa5a 100644
--- a/relay/channel/gemini/adaptor.go
+++ b/relay/channel/gemini/adaptor.go
@@ -215,8 +215,8 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
if info.RelayMode == constant.RelayModeGemini {
- if strings.HasSuffix(info.RequestURLPath, ":embedContent") ||
- strings.HasSuffix(info.RequestURLPath, ":batchEmbedContents") {
+ if strings.Contains(info.RequestURLPath, ":embedContent") ||
+ strings.Contains(info.RequestURLPath, ":batchEmbedContents") {
return NativeGeminiEmbeddingHandler(c, resp, info)
}
if info.IsStream {
diff --git a/relay/helper/valid_request.go b/relay/helper/valid_request.go
index 4d1c1f9bb..f4a290ec6 100644
--- a/relay/helper/valid_request.go
+++ b/relay/helper/valid_request.go
@@ -21,7 +21,11 @@ func GetAndValidateRequest(c *gin.Context, format types.RelayFormat) (request dt
case types.RelayFormatOpenAI:
request, err = GetAndValidateTextRequest(c, relayMode)
case types.RelayFormatGemini:
- request, err = GetAndValidateGeminiRequest(c)
+ if strings.Contains(c.Request.URL.Path, ":embedContent") || strings.Contains(c.Request.URL.Path, ":batchEmbedContents") {
+ request, err = GetAndValidateGeminiEmbeddingRequest(c)
+ } else {
+ request, err = GetAndValidateGeminiRequest(c)
+ }
case types.RelayFormatClaude:
request, err = GetAndValidateClaudeRequest(c)
case types.RelayFormatOpenAIResponses:
@@ -288,7 +292,6 @@ func GetAndValidateTextRequest(c *gin.Context, relayMode int) (*dto.GeneralOpenA
}
func GetAndValidateGeminiRequest(c *gin.Context) (*dto.GeminiChatRequest, error) {
-
request := &dto.GeminiChatRequest{}
err := common.UnmarshalBodyReusable(c, request)
if err != nil {
@@ -304,3 +307,12 @@ func GetAndValidateGeminiRequest(c *gin.Context) (*dto.GeminiChatRequest, error)
return request, nil
}
+
+func GetAndValidateGeminiEmbeddingRequest(c *gin.Context) (*dto.GeminiEmbeddingRequest, error) {
+ request := &dto.GeminiEmbeddingRequest{}
+ err := common.UnmarshalBodyReusable(c, request)
+ if err != nil {
+ return nil, err
+ }
+ return request, nil
+}
diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go
index f06cd71ef..9f11a3b74 100644
--- a/setting/ratio_setting/model_ratio.go
+++ b/setting/ratio_setting/model_ratio.go
@@ -178,6 +178,7 @@ var defaultModelRatio = map[string]float64{
"gemini-2.5-flash-lite-preview-thinking-*": 0.05,
"gemini-2.5-flash-lite-preview-06-17": 0.05,
"gemini-2.5-flash": 0.15,
+ "gemini-embedding-001": 0.075,
"text-embedding-004": 0.001,
"chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens
"chatglm_pro": 0.7143, // ¥0.01 / 1k tokens
From 6c0b1681f9d64140a3682586cc9d53d9d06cada7 Mon Sep 17 00:00:00 2001
From: joesonshaw
Date: Fri, 19 Sep 2025 10:49:47 +0800
Subject: [PATCH 076/165] =?UTF-8?q?fix(relay-xunfei):=20=E4=BF=AE=E5=A4=8D?=
=?UTF-8?q?=E8=AE=AF=E9=A3=9E=E6=B8=A0=E9=81=93=E6=97=A0=E6=B3=95=E4=BD=BF?=
=?UTF-8?q?=E7=94=A8=E9=97=AE=E9=A2=98=20#1740?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
将连接延迟关闭逻辑调整到协程中执行,防止在完全接收到所有数据前提前关闭
---
relay/channel/xunfei/relay-xunfei.go | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/relay/channel/xunfei/relay-xunfei.go b/relay/channel/xunfei/relay-xunfei.go
index 9d5c190fe..9503d5d39 100644
--- a/relay/channel/xunfei/relay-xunfei.go
+++ b/relay/channel/xunfei/relay-xunfei.go
@@ -207,10 +207,6 @@ func xunfeiMakeRequest(textRequest dto.GeneralOpenAIRequest, domain, authUrl, ap
return nil, nil, err
}
- defer func() {
- conn.Close()
- }()
-
data := requestOpenAI2Xunfei(textRequest, appId, domain)
err = conn.WriteJSON(data)
if err != nil {
@@ -220,6 +216,9 @@ func xunfeiMakeRequest(textRequest dto.GeneralOpenAIRequest, domain, authUrl, ap
dataChan := make(chan XunfeiChatResponse)
stopChan := make(chan bool)
go func() {
+ defer func() {
+ conn.Close()
+ }()
for {
_, msg, err := conn.ReadMessage()
if err != nil {
From 8d92ce38ed2be32295a69926f843023dabdc7ef0 Mon Sep 17 00:00:00 2001
From: wzxjohn
Date: Fri, 19 Sep 2025 11:22:03 +0800
Subject: [PATCH 077/165] fix(relay): wrong key param while enable sse
---
relay/channel/vertex/adaptor.go | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go
index 6398b8f62..742366b13 100644
--- a/relay/channel/vertex/adaptor.go
+++ b/relay/channel/vertex/adaptor.go
@@ -135,19 +135,27 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
), nil
}
} else {
+ var keyPrefix string
+ if strings.HasSuffix(suffix, "?alt=sse") {
+ keyPrefix = "&"
+ } else {
+ keyPrefix = "?"
+ }
if region == "global" {
return fmt.Sprintf(
- "https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s",
+ "https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s%skey=%s",
modelName,
suffix,
+ keyPrefix,
info.ApiKey,
), nil
} else {
return fmt.Sprintf(
- "https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s",
+ "https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s%skey=%s",
region,
modelName,
suffix,
+ keyPrefix,
info.ApiKey,
), nil
}
From ba632d0b4d43fc170716328dab586ef39980074b Mon Sep 17 00:00:00 2001
From: CaIon
Date: Fri, 19 Sep 2025 14:20:35 +0800
Subject: [PATCH 078/165] CI
---
.github/workflows/docker-image-arm64.yml | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/.github/workflows/docker-image-arm64.yml b/.github/workflows/docker-image-arm64.yml
index cabf3cec4..8e4656aa7 100644
--- a/.github/workflows/docker-image-arm64.yml
+++ b/.github/workflows/docker-image-arm64.yml
@@ -53,5 +53,4 @@ jobs:
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
- labels: ${{ steps.meta.outputs.labels }}
- provenance: false
\ No newline at end of file
+ labels: ${{ steps.meta.outputs.labels }}
\ No newline at end of file
From 334ba555fcbd86877ab098d4a66c4526565ff613 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Fri, 19 Sep 2025 14:21:32 +0800
Subject: [PATCH 079/165] fix: cast option.Value to string for ratio updates
---
controller/option.go | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/controller/option.go b/controller/option.go
index 3e59c68e0..7d1c676f5 100644
--- a/controller/option.go
+++ b/controller/option.go
@@ -129,7 +129,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "ImageRatio":
- err = ratio_setting.UpdateImageRatioByJSONString(option.Value)
+ err = ratio_setting.UpdateImageRatioByJSONString(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -138,7 +138,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "AudioRatio":
- err = ratio_setting.UpdateAudioRatioByJSONString(option.Value)
+ err = ratio_setting.UpdateAudioRatioByJSONString(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -147,7 +147,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "AudioCompletionRatio":
- err = ratio_setting.UpdateAudioCompletionRatioByJSONString(option.Value)
+ err = ratio_setting.UpdateAudioCompletionRatioByJSONString(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
From d491cbd3d23c7becaedad489c1ba76a2b0fc5bdd Mon Sep 17 00:00:00 2001
From: CaIon
Date: Fri, 19 Sep 2025 14:23:08 +0800
Subject: [PATCH 080/165] feat: update labels for ratio settings to clarify
model support
---
web/src/pages/Setting/Ratio/ModelRatioSettings.jsx | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx
index b40951261..ed982edcf 100644
--- a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx
+++ b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx
@@ -225,8 +225,8 @@ export default function ModelRatioSettings(props) {
Date: Mon, 1 Sep 2025 09:52:52 +0800
Subject: [PATCH 081/165] =?UTF-8?q?=E4=BF=AE=E5=A4=8D"=E8=BE=B9=E6=A0=8F"?=
=?UTF-8?q?=E9=9A=90=E8=97=8F=E5=90=8E=E6=97=A0=E6=B3=95=E5=8D=B3=E6=97=B6?=
=?UTF-8?q?=E7=94=9F=E6=95=88=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../personal/cards/NotificationSettings.jsx | 9 ++++++-
web/src/hooks/common/useSidebar.js | 27 ++++++++++++++++---
2 files changed, 32 insertions(+), 4 deletions(-)
diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx
index 0b097eaff..aad612d2c 100644
--- a/web/src/components/settings/personal/cards/NotificationSettings.jsx
+++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx
@@ -44,6 +44,7 @@ import CodeViewer from '../../../playground/CodeViewer';
import { StatusContext } from '../../../../context/Status';
import { UserContext } from '../../../../context/User';
import { useUserPermissions } from '../../../../hooks/common/useUserPermissions';
+import { useSidebar } from '../../../../hooks/common/useSidebar';
const NotificationSettings = ({
t,
@@ -97,6 +98,9 @@ const NotificationSettings = ({
isSidebarModuleAllowed,
} = useUserPermissions();
+ // 使用useSidebar钩子获取刷新方法
+ const { refreshUserConfig } = useSidebar();
+
// 左侧边栏设置处理函数
const handleSectionChange = (sectionKey) => {
return (checked) => {
@@ -132,6 +136,9 @@ const NotificationSettings = ({
});
if (res.data.success) {
showSuccess(t('侧边栏设置保存成功'));
+
+ // 刷新useSidebar钩子中的用户配置,实现实时更新
+ await refreshUserConfig();
} else {
showError(res.data.message);
}
@@ -334,7 +341,7 @@ const NotificationSettings = ({
loading={sidebarLoading}
className='!rounded-lg'
>
- {t('保存边栏设置')}
+ {t('保存设置')}
>
) : (
diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js
index 5dce44f9e..e964855e3 100644
--- a/web/src/hooks/common/useSidebar.js
+++ b/web/src/hooks/common/useSidebar.js
@@ -21,6 +21,10 @@ import { useState, useEffect, useMemo, useContext } from 'react';
import { StatusContext } from '../../context/Status';
import { API } from '../../helpers';
+// 创建一个全局事件系统来同步所有useSidebar实例
+const sidebarEventTarget = new EventTarget();
+const SIDEBAR_REFRESH_EVENT = 'sidebar-refresh';
+
export const useSidebar = () => {
const [statusState] = useContext(StatusContext);
const [userConfig, setUserConfig] = useState(null);
@@ -124,9 +128,11 @@ export const useSidebar = () => {
// 刷新用户配置的方法(供外部调用)
const refreshUserConfig = async () => {
- if (Object.keys(adminConfig).length > 0) {
- await loadUserConfig();
- }
+ // 移除adminConfig的条件限制,直接刷新用户配置
+ await loadUserConfig();
+
+ // 触发全局刷新事件,通知所有useSidebar实例更新
+ sidebarEventTarget.dispatchEvent(new CustomEvent(SIDEBAR_REFRESH_EVENT));
};
// 加载用户配置
@@ -137,6 +143,21 @@ export const useSidebar = () => {
}
}, [adminConfig]);
+ // 监听全局刷新事件
+ useEffect(() => {
+ const handleRefresh = () => {
+ if (Object.keys(adminConfig).length > 0) {
+ loadUserConfig();
+ }
+ };
+
+ sidebarEventTarget.addEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh);
+
+ return () => {
+ sidebarEventTarget.removeEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh);
+ };
+ }, [adminConfig]);
+
// 计算最终的显示配置
const finalConfig = useMemo(() => {
const result = {};
From f23be16e981c5940de59243d0155d8a80a28227e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?F=E3=80=82?=
Date: Mon, 1 Sep 2025 10:20:15 +0800
Subject: [PATCH 082/165] =?UTF-8?q?=E4=BF=AE=E5=A4=8D"=E8=BE=B9=E6=A0=8F"?=
=?UTF-8?q?=E6=9D=83=E9=99=90=E6=8E=A7=E5=88=B6=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
web/src/hooks/common/useSidebar.js | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js
index e964855e3..13d76fd86 100644
--- a/web/src/hooks/common/useSidebar.js
+++ b/web/src/hooks/common/useSidebar.js
@@ -128,8 +128,9 @@ export const useSidebar = () => {
// 刷新用户配置的方法(供外部调用)
const refreshUserConfig = async () => {
- // 移除adminConfig的条件限制,直接刷新用户配置
- await loadUserConfig();
+ if (Object.keys(adminConfig).length > 0) {
+ await loadUserConfig();
+ }
// 触发全局刷新事件,通知所有useSidebar实例更新
sidebarEventTarget.dispatchEvent(new CustomEvent(SIDEBAR_REFRESH_EVENT));
From 1894ddc786cd0bde7a2affee3a88fb81e9567841 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?F=E3=80=82?=
Date: Tue, 2 Sep 2025 18:10:08 +0800
Subject: [PATCH 083/165] =?UTF-8?q?=E6=94=B9=E8=BF=9B"=E4=BE=A7=E8=BE=B9?=
=?UTF-8?q?=E6=A0=8F"=E6=9D=83=E9=99=90=E6=8E=A7=E5=88=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
web/src/components/auth/ModuleRoute.jsx | 200 ++++++++++++++++++++++++
1 file changed, 200 insertions(+)
create mode 100644 web/src/components/auth/ModuleRoute.jsx
diff --git a/web/src/components/auth/ModuleRoute.jsx b/web/src/components/auth/ModuleRoute.jsx
new file mode 100644
index 000000000..3f208c7fa
--- /dev/null
+++ b/web/src/components/auth/ModuleRoute.jsx
@@ -0,0 +1,200 @@
+/*
+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 .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useState, useEffect, useContext } from 'react';
+import { Navigate } from 'react-router-dom';
+import { StatusContext } from '../../context/Status';
+import Loading from '../common/ui/Loading';
+import { API } from '../../helpers';
+
+/**
+ * ModuleRoute - 基于功能模块权限的路由保护组件
+ *
+ * @param {Object} props
+ * @param {React.ReactNode} props.children - 要保护的子组件
+ * @param {string} props.modulePath - 模块权限路径,如 "admin.channel", "console.token"
+ * @param {React.ReactNode} props.fallback - 无权限时显示的组件,默认跳转到 /forbidden
+ * @returns {React.ReactNode}
+ */
+const ModuleRoute = ({ children, modulePath, fallback = }) => {
+ const [hasPermission, setHasPermission] = useState(null);
+ const [statusState] = useContext(StatusContext);
+
+ useEffect(() => {
+ checkModulePermission();
+ }, [modulePath, statusState?.status]); // 只在status数据变化时重新检查
+
+ const checkModulePermission = async () => {
+ try {
+ // 检查用户是否已登录
+ const user = localStorage.getItem('user');
+ if (!user) {
+ setHasPermission(false);
+ return;
+ }
+
+ const userData = JSON.parse(user);
+ const userRole = userData.role;
+
+ // 超级管理员始终有权限
+ if (userRole >= 100) {
+ setHasPermission(true);
+ return;
+ }
+
+ // 检查模块权限
+ const permission = await checkModulePermissionAPI(modulePath);
+
+ // 如果返回null,表示status数据还未加载完成,保持loading状态
+ if (permission === null) {
+ setHasPermission(null);
+ return;
+ }
+
+ setHasPermission(permission);
+ } catch (error) {
+ console.error('检查模块权限失败:', error);
+ // 出错时采用安全优先策略,拒绝访问
+ setHasPermission(false);
+ }
+ };
+
+ const checkModulePermissionAPI = async (modulePath) => {
+ try {
+ // 数据看板始终允许访问,不受控制台区域开关影响
+ if (modulePath === 'console.detail') {
+ return true;
+ }
+
+ // 从StatusContext中获取配置信息
+ // 如果status数据还未加载完成,返回null表示需要等待
+ if (!statusState?.status) {
+ return null;
+ }
+
+ const user = JSON.parse(localStorage.getItem('user'));
+ const userRole = user.role;
+
+ // 解析模块路径
+ const pathParts = modulePath.split('.');
+ if (pathParts.length < 2) {
+ return false;
+ }
+
+ // 普通用户权限检查
+ if (userRole < 10) {
+ return await isUserModuleAllowed(modulePath);
+ }
+
+ // 超级管理员权限检查 - 不受系统配置限制
+ if (userRole >= 100) {
+ return true;
+ }
+
+ // 管理员权限检查 - 受系统配置限制
+ if (userRole >= 10 && userRole < 100) {
+ // 从/api/user/self获取系统权限配置
+ try {
+ const userRes = await API.get('/api/user/self');
+ if (userRes.data.success && userRes.data.data.sidebar_config) {
+ const sidebarConfigData = userRes.data.data.sidebar_config;
+ // 管理员权限检查基于系统配置,不受用户偏好影响
+ const systemConfig = sidebarConfigData.system || sidebarConfigData;
+ return checkModulePermissionInConfig(systemConfig, modulePath);
+ } else {
+ // 没有配置时,除了系统设置外都允许访问
+ return modulePath !== 'admin.setting';
+ }
+ } catch (error) {
+ console.error('获取侧边栏配置失败:', error);
+ return false;
+ }
+ }
+
+ return false;
+ } catch (error) {
+ console.error('API权限检查失败:', error);
+ return false;
+ }
+ };
+
+ const isUserModuleAllowed = async (modulePath) => {
+ // 数据看板始终允许访问,不受控制台区域开关影响
+ if (modulePath === 'console.detail') {
+ return true;
+ }
+
+ // 普通用户的权限基于最终计算的配置
+ try {
+ const userRes = await API.get('/api/user/self');
+ if (userRes.data.success && userRes.data.data.sidebar_config) {
+ const sidebarConfigData = userRes.data.data.sidebar_config;
+ // 使用最终计算的配置进行权限检查
+ const finalConfig = sidebarConfigData.final || sidebarConfigData;
+ return checkModulePermissionInConfig(finalConfig, modulePath);
+ }
+ return false;
+ } catch (error) {
+ console.error('获取用户权限配置失败:', error);
+ return false;
+ }
+ };
+
+ // 检查新的sidebar_config结构中的模块权限
+ const checkModulePermissionInConfig = (sidebarConfig, modulePath) => {
+ const parts = modulePath.split('.');
+ if (parts.length !== 2) {
+ return false;
+ }
+
+ const [sectionKey, moduleKey] = parts;
+ const section = sidebarConfig[sectionKey];
+
+ // 检查区域是否存在且启用
+ if (!section || !section.enabled) {
+ return false;
+ }
+
+ // 检查模块是否启用
+ const moduleValue = section[moduleKey];
+ // 处理布尔值和嵌套对象两种情况
+ if (typeof moduleValue === 'boolean') {
+ return moduleValue === true;
+ } else if (typeof moduleValue === 'object' && moduleValue !== null) {
+ // 对于嵌套对象,检查其enabled状态
+ return moduleValue.enabled === true;
+ }
+ return false;
+ };
+
+ // 权限检查中
+ if (hasPermission === null) {
+ return ;
+ }
+
+ // 无权限
+ if (!hasPermission) {
+ return fallback;
+ }
+
+ // 有权限,渲染子组件
+ return children;
+};
+
+export default ModuleRoute;
From 3a98ae3f70c3b131f39909de25f20139441ba6ba Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?F=E3=80=82?=
Date: Tue, 2 Sep 2025 19:26:30 +0800
Subject: [PATCH 084/165] =?UTF-8?q?=E6=94=B9=E8=BF=9B"=E4=BE=A7=E8=BE=B9?=
=?UTF-8?q?=E6=A0=8F"=E6=9D=83=E9=99=90=E6=8E=A7=E5=88=B6-1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This reverts commit d798db5953906aa5ff76cf6f2b641eb204d279b0.
---
web/src/components/auth/ModuleRoute.jsx | 200 ------------------------
1 file changed, 200 deletions(-)
delete mode 100644 web/src/components/auth/ModuleRoute.jsx
diff --git a/web/src/components/auth/ModuleRoute.jsx b/web/src/components/auth/ModuleRoute.jsx
deleted file mode 100644
index 3f208c7fa..000000000
--- a/web/src/components/auth/ModuleRoute.jsx
+++ /dev/null
@@ -1,200 +0,0 @@
-/*
-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 .
-
-For commercial licensing, please contact support@quantumnous.com
-*/
-
-import React, { useState, useEffect, useContext } from 'react';
-import { Navigate } from 'react-router-dom';
-import { StatusContext } from '../../context/Status';
-import Loading from '../common/ui/Loading';
-import { API } from '../../helpers';
-
-/**
- * ModuleRoute - 基于功能模块权限的路由保护组件
- *
- * @param {Object} props
- * @param {React.ReactNode} props.children - 要保护的子组件
- * @param {string} props.modulePath - 模块权限路径,如 "admin.channel", "console.token"
- * @param {React.ReactNode} props.fallback - 无权限时显示的组件,默认跳转到 /forbidden
- * @returns {React.ReactNode}
- */
-const ModuleRoute = ({ children, modulePath, fallback = }) => {
- const [hasPermission, setHasPermission] = useState(null);
- const [statusState] = useContext(StatusContext);
-
- useEffect(() => {
- checkModulePermission();
- }, [modulePath, statusState?.status]); // 只在status数据变化时重新检查
-
- const checkModulePermission = async () => {
- try {
- // 检查用户是否已登录
- const user = localStorage.getItem('user');
- if (!user) {
- setHasPermission(false);
- return;
- }
-
- const userData = JSON.parse(user);
- const userRole = userData.role;
-
- // 超级管理员始终有权限
- if (userRole >= 100) {
- setHasPermission(true);
- return;
- }
-
- // 检查模块权限
- const permission = await checkModulePermissionAPI(modulePath);
-
- // 如果返回null,表示status数据还未加载完成,保持loading状态
- if (permission === null) {
- setHasPermission(null);
- return;
- }
-
- setHasPermission(permission);
- } catch (error) {
- console.error('检查模块权限失败:', error);
- // 出错时采用安全优先策略,拒绝访问
- setHasPermission(false);
- }
- };
-
- const checkModulePermissionAPI = async (modulePath) => {
- try {
- // 数据看板始终允许访问,不受控制台区域开关影响
- if (modulePath === 'console.detail') {
- return true;
- }
-
- // 从StatusContext中获取配置信息
- // 如果status数据还未加载完成,返回null表示需要等待
- if (!statusState?.status) {
- return null;
- }
-
- const user = JSON.parse(localStorage.getItem('user'));
- const userRole = user.role;
-
- // 解析模块路径
- const pathParts = modulePath.split('.');
- if (pathParts.length < 2) {
- return false;
- }
-
- // 普通用户权限检查
- if (userRole < 10) {
- return await isUserModuleAllowed(modulePath);
- }
-
- // 超级管理员权限检查 - 不受系统配置限制
- if (userRole >= 100) {
- return true;
- }
-
- // 管理员权限检查 - 受系统配置限制
- if (userRole >= 10 && userRole < 100) {
- // 从/api/user/self获取系统权限配置
- try {
- const userRes = await API.get('/api/user/self');
- if (userRes.data.success && userRes.data.data.sidebar_config) {
- const sidebarConfigData = userRes.data.data.sidebar_config;
- // 管理员权限检查基于系统配置,不受用户偏好影响
- const systemConfig = sidebarConfigData.system || sidebarConfigData;
- return checkModulePermissionInConfig(systemConfig, modulePath);
- } else {
- // 没有配置时,除了系统设置外都允许访问
- return modulePath !== 'admin.setting';
- }
- } catch (error) {
- console.error('获取侧边栏配置失败:', error);
- return false;
- }
- }
-
- return false;
- } catch (error) {
- console.error('API权限检查失败:', error);
- return false;
- }
- };
-
- const isUserModuleAllowed = async (modulePath) => {
- // 数据看板始终允许访问,不受控制台区域开关影响
- if (modulePath === 'console.detail') {
- return true;
- }
-
- // 普通用户的权限基于最终计算的配置
- try {
- const userRes = await API.get('/api/user/self');
- if (userRes.data.success && userRes.data.data.sidebar_config) {
- const sidebarConfigData = userRes.data.data.sidebar_config;
- // 使用最终计算的配置进行权限检查
- const finalConfig = sidebarConfigData.final || sidebarConfigData;
- return checkModulePermissionInConfig(finalConfig, modulePath);
- }
- return false;
- } catch (error) {
- console.error('获取用户权限配置失败:', error);
- return false;
- }
- };
-
- // 检查新的sidebar_config结构中的模块权限
- const checkModulePermissionInConfig = (sidebarConfig, modulePath) => {
- const parts = modulePath.split('.');
- if (parts.length !== 2) {
- return false;
- }
-
- const [sectionKey, moduleKey] = parts;
- const section = sidebarConfig[sectionKey];
-
- // 检查区域是否存在且启用
- if (!section || !section.enabled) {
- return false;
- }
-
- // 检查模块是否启用
- const moduleValue = section[moduleKey];
- // 处理布尔值和嵌套对象两种情况
- if (typeof moduleValue === 'boolean') {
- return moduleValue === true;
- } else if (typeof moduleValue === 'object' && moduleValue !== null) {
- // 对于嵌套对象,检查其enabled状态
- return moduleValue.enabled === true;
- }
- return false;
- };
-
- // 权限检查中
- if (hasPermission === null) {
- return ;
- }
-
- // 无权限
- if (!hasPermission) {
- return fallback;
- }
-
- // 有权限,渲染子组件
- return children;
-};
-
-export default ModuleRoute;
From ec9903e6404ee6dfed5d3ae9e236802cd0445c3c Mon Sep 17 00:00:00 2001
From: JoeyLearnsToCode
Date: Fri, 19 Sep 2025 18:09:26 +0800
Subject: [PATCH 085/165] feat: jump between section on channel edit page
---
.../channels/modals/EditChannelModal.jsx | 138 +++++++++++++++---
1 file changed, 116 insertions(+), 22 deletions(-)
diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx
index c0a216246..07d4f3925 100644
--- a/web/src/components/table/channels/modals/EditChannelModal.jsx
+++ b/web/src/components/table/channels/modals/EditChannelModal.jsx
@@ -66,6 +66,8 @@ import {
IconCode,
IconGlobe,
IconBolt,
+ IconChevronUp,
+ IconChevronDown,
} from '@douyinfe/semi-icons';
const { Text, Title } = Typography;
@@ -184,6 +186,18 @@ const EditChannelModal = (props) => {
const [verifyCode, setVerifyCode] = useState('');
const [verifyLoading, setVerifyLoading] = useState(false);
+ // 表单块导航相关状态
+ const formSectionRefs = useRef({
+ basicInfo: null,
+ apiConfig: null,
+ modelConfig: null,
+ advancedSettings: null,
+ channelExtraSettings: null,
+ });
+ const [currentSectionIndex, setCurrentSectionIndex] = useState(0);
+ const formSections = ['basicInfo', 'apiConfig', 'modelConfig', 'advancedSettings', 'channelExtraSettings'];
+ const formContainerRef = useRef(null);
+
// 2FA状态更新辅助函数
const updateTwoFAState = (updates) => {
setTwoFAState((prev) => ({ ...prev, ...updates }));
@@ -207,6 +221,37 @@ const EditChannelModal = (props) => {
setVerifyLoading(false);
};
+ // 表单导航功能
+ const scrollToSection = (sectionKey) => {
+ const sectionElement = formSectionRefs.current[sectionKey];
+ if (sectionElement) {
+ sectionElement.scrollIntoView({
+ behavior: 'smooth',
+ block: 'start',
+ inline: 'nearest'
+ });
+ }
+ };
+
+ const navigateToSection = (direction) => {
+ const availableSections = formSections.filter(section => {
+ if (section === 'apiConfig') {
+ return showApiConfigCard;
+ }
+ return true;
+ });
+
+ let newIndex;
+ if (direction === 'up') {
+ newIndex = currentSectionIndex > 0 ? currentSectionIndex - 1 : availableSections.length - 1;
+ } else {
+ newIndex = currentSectionIndex < availableSections.length - 1 ? currentSectionIndex + 1 : 0;
+ }
+
+ setCurrentSectionIndex(newIndex);
+ scrollToSection(availableSections[newIndex]);
+ };
+
// 渠道额外设置状态
const [channelSettings, setChannelSettings] = useState({
force_format: false,
@@ -672,6 +717,8 @@ const EditChannelModal = (props) => {
fetchModelGroups();
// 重置手动输入模式状态
setUseManualInput(false);
+ // 重置导航状态
+ setCurrentSectionIndex(0);
} else {
// 统一的模态框关闭重置逻辑
resetModalState();
@@ -1108,7 +1155,41 @@ const EditChannelModal = (props) => {
visible={props.visible}
width={isMobile ? '100%' : 600}
footer={
-
+
+
+ }
+ onClick={() => navigateToSection('up')}
+ style={{
+ borderRadius: '50%',
+ width: '32px',
+ height: '32px',
+ padding: 0,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center'
+ }}
+ title={t('上一个表单块')}
+ />
+ }
+ onClick={() => navigateToSection('down')}
+ style={{
+ borderRadius: '50%',
+ width: '32px',
+ height: '32px',
+ padding: 0,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center'
+ }}
+ title={t('下一个表单块')}
+ />
+