├── internal ├── utils │ ├── base64.go │ ├── string.go │ └── req_client.go ├── service │ ├── model_service.go │ ├── image_service.go │ ├── chat_service.go │ └── custom_bot_service.go ├── monica │ ├── client.go │ ├── draw.go │ └── sse.go ├── middleware │ ├── logging.go │ ├── auth.go │ ├── error_handler.go │ └── rate_limit.go ├── logger │ └── logger.go ├── types │ ├── openai.go │ ├── monica_test.go │ ├── image.go │ └── monica.go ├── errors │ └── errors.go ├── apiserver │ └── router.go └── config │ └── config.go ├── Dockerfile ├── Makefile ├── docker-compose.yml ├── .gitignore ├── nginx.conf ├── LICENSE ├── config.example.yaml ├── go.mod ├── main.go ├── go.sum └── README.md /internal/utils/base64.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/base64" 5 | ) 6 | 7 | // Base64Decode 解码base64字符串为字节数组 8 | func Base64Decode(data string) ([]byte, error) { 9 | return base64.StdEncoding.DecodeString(data) 10 | } 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine AS deps 2 | WORKDIR /app 3 | COPY go.mod go.sum ./ 4 | RUN --mount=type=cache,target=/go/pkg/mod go mod download 5 | 6 | FROM golang:alpine AS builder 7 | ARG TARGETOS=linux 8 | ARG TARGETARCH=amd64 9 | WORKDIR /app 10 | RUN apk add --no-cache make 11 | COPY --from=deps /app/go.mod /app/go.sum ./ 12 | COPY . . 13 | RUN --mount=type=cache,target=/root/.cache/go-build \ 14 | --mount=type=cache,target=/go/pkg/mod \ 15 | make build GOOS=${TARGETOS} GOARCH=${TARGETARCH} 16 | 17 | FROM gcr.io/distroless/static:nonroot AS final 18 | WORKDIR /data 19 | COPY --from=builder /app/build/monica /data/monica 20 | 21 | EXPOSE 8080 22 | USER nonroot:nonroot 23 | CMD ["./monica"] -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build build-all docker-build clean 2 | 3 | GOOS ?= $(shell go env GOOS) 4 | GOARCH ?= $(shell go env GOARCH) 5 | 6 | BIN_NAME = monica 7 | BUILD_DIR = build 8 | 9 | build: 10 | @rm -rf $(BUILD_DIR) || true 11 | @mkdir -p $(BUILD_DIR) || true 12 | @go mod tidy 13 | @CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) go build -ldflags "-s -w" -o $(BUILD_DIR)/$(BIN_NAME) . 14 | 15 | build-all: 16 | @$(MAKE) build GOOS=linux GOARCH=amd64 17 | @$(MAKE) build GOOS=linux GOARCH=arm64 18 | @$(MAKE) build GOOS=darwin GOARCH=arm64 19 | 20 | docker-build: 21 | docker buildx build --platform linux/amd64,linux/arm64 -t yourrepo/monica-proxy:latest --push . 22 | 23 | clean: 24 | rm -rf $(BUILD_DIR) -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | monica-proxy: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | image: monica-proxy 7 | container_name: monica-proxy 8 | restart: unless-stopped 9 | command: ["./monica"] 10 | environment: 11 | - MONICA_COOKIE=${MONICA_COOKIE} 12 | - BEARER_TOKEN=${BEARER_TOKEN} 13 | # 限流配置(可选) 14 | - RATE_LIMIT_RPS=${RATE_LIMIT_RPS:-0} # 默认0=禁用限流 15 | # 其他可选配置 16 | - TLS_SKIP_VERIFY=${TLS_SKIP_VERIFY:-true} 17 | - LOG_LEVEL=${LOG_LEVEL:-info} 18 | 19 | nginx: 20 | image: nginx:latest 21 | container_name: monica-nginx 22 | ports: 23 | - "8080:80" 24 | volumes: 25 | - ./nginx.conf:/etc/nginx/nginx.conf:ro 26 | depends_on: 27 | - monica-proxy -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Go template 2 | # If you prefer the allow list template instead of the deny list, see community template: 3 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 4 | # 5 | # Binaries for programs and plugins 6 | *.exe 7 | *.exe~ 8 | *.dll 9 | *.so 10 | *.dylib 11 | 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | # vendor/ 20 | launch.json 21 | # Go workspace file 22 | go.work 23 | go.work.sum 24 | 25 | # Environment Variables 26 | .env 27 | .env.local 28 | .env.* 29 | 30 | # env file 31 | .idea 32 | /build 33 | 34 | # Allow specific test files 35 | !monica_test.go 36 | !*_test.go 37 | 38 | 39 | # windsurf rules 40 | .windsurfrules 41 | .codanna -------------------------------------------------------------------------------- /internal/service/model_service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "monica-proxy/internal/config" 5 | "monica-proxy/internal/logger" 6 | "monica-proxy/internal/types" 7 | 8 | "go.uber.org/zap" 9 | ) 10 | 11 | // ModelService 模型服务接口 12 | type ModelService interface { 13 | // GetSupportedModels 获取支持的模型列表 14 | GetSupportedModels() []string 15 | } 16 | 17 | // modelService 模型服务实现 18 | type modelService struct { 19 | config *config.Config 20 | } 21 | 22 | // NewModelService 创建模型服务实例 23 | func NewModelService(cfg *config.Config) ModelService { 24 | return &modelService{ 25 | config: cfg, 26 | } 27 | } 28 | 29 | // GetSupportedModels 获取支持的模型列表 30 | func (s *modelService) GetSupportedModels() []string { 31 | models := types.GetSupportedModels() 32 | 33 | logger.Info("获取支持的模型列表", 34 | zap.Int("model_count", len(models)), 35 | ) 36 | 37 | return models 38 | } 39 | -------------------------------------------------------------------------------- /internal/utils/string.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "math/rand" 5 | "strings" 6 | "sync" 7 | "time" 8 | ) 9 | 10 | var ( 11 | randSource = rand.New(rand.NewSource(time.Now().UnixNano())) 12 | letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 13 | 14 | // 字符串构建器池,用于生成随机字符串 15 | randStringBuilderPool = sync.Pool{ 16 | New: func() any { 17 | return &strings.Builder{} 18 | }, 19 | } 20 | ) 21 | 22 | // RandStringUsingMathRand 生成指定长度的随机字符串 23 | func RandStringUsingMathRand(n int) string { 24 | if n <= 0 { 25 | return "" 26 | } 27 | 28 | // 从池中获取字符串构建器 29 | sb := randStringBuilderPool.Get().(*strings.Builder) 30 | defer func() { 31 | sb.Reset() 32 | randStringBuilderPool.Put(sb) 33 | }() 34 | 35 | // 预分配容量 36 | sb.Grow(n) 37 | 38 | // 生成随机字符 39 | for range n { 40 | sb.WriteRune(letters[randSource.Intn(len(letters))]) 41 | } 42 | 43 | return sb.String() 44 | } 45 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | events { 2 | worker_connections 1024; 3 | } 4 | 5 | http { 6 | upstream monica-proxy { 7 | server monica-proxy:8080; 8 | } 9 | 10 | server { 11 | listen 80; 12 | 13 | location / { 14 | # 必须使用 HTTP/1.1,才能支持 chunked 传输 15 | proxy_http_version 1.1; 16 | # 去掉 Connection: close,避免长连接被关闭 17 | proxy_set_header Connection ''; 18 | 19 | # 指定后端地址 20 | proxy_pass http://monica-proxy; 21 | 22 | # 关闭 Nginx 的各种缓存与缓冲 23 | proxy_buffering off; 24 | proxy_cache off; 25 | # 这一行可以确保 Nginx 不再做加速层的缓冲 26 | proxy_set_header X-Accel-Buffering off; 27 | 28 | # 打开分块传输 29 | chunked_transfer_encoding on; 30 | 31 | proxy_read_timeout 3600s; 32 | proxy_send_timeout 3600s; 33 | } 34 | 35 | gzip on; 36 | # 不包含 text/event-stream 37 | gzip_types text/plain application/json; 38 | } 39 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Monica Proxy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /config.example.yaml: -------------------------------------------------------------------------------- 1 | # Monica Proxy 配置文件示例 2 | # 将此文件复制为 config.yaml 并填入实际值 3 | 4 | # 服务器配置 5 | server: 6 | host: "0.0.0.0" 7 | port: 8080 8 | read_timeout: "30s" 9 | write_timeout: "30s" 10 | idle_timeout: "60s" 11 | 12 | # Monica API 配置 13 | monica: 14 | # Monica 登录后的 Cookie (必填) 15 | cookie: "YOUR_MONICA_COOKIE_HERE" 16 | 17 | # 安全配置 18 | security: 19 | # API访问令牌 (必填) 20 | bearer_token: "YOUR_BEARER_TOKEN_HERE" 21 | # 是否跳过TLS验证 (生产环境建议设为 false) 22 | tls_skip_verify: true 23 | # 是否启用限流 (基于客户端IP) 24 | rate_limit_enabled: false # 默认禁用,需要明确启用 25 | # 每秒请求数限制 (每个IP独立计算,0=禁用限流) 26 | rate_limit_rps: 0 27 | # 请求超时时间 28 | request_timeout: "30s" 29 | 30 | # HTTP客户端配置 31 | http_client: 32 | # 请求超时时间 33 | timeout: "3m" 34 | # 最大空闲连接数 35 | max_idle_conns: 100 36 | # 每个主机最大空闲连接数 37 | max_idle_conns_per_host: 10 38 | # 每个主机最大连接数 39 | max_conns_per_host: 50 40 | # 重试次数 41 | retry_count: 3 42 | # 重试等待时间 43 | retry_wait_time: "1s" 44 | # 最大重试等待时间 45 | retry_max_wait_time: "10s" 46 | 47 | # 日志配置 48 | logging: 49 | # 日志级别: debug, info, warn, error 50 | level: "info" 51 | # 日志格式: json, console 52 | format: "json" 53 | # 日志输出: stdout, stderr, file 54 | output: "stdout" 55 | # 是否启用请求日志 56 | enable_request_log: true 57 | # 是否掩盖敏感信息 58 | mask_sensitive: true -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module monica-proxy 2 | 3 | go 1.25 4 | 5 | require ( 6 | github.com/bytedance/sonic v1.14.2 7 | github.com/go-resty/resty/v2 v2.16.5 8 | github.com/google/uuid v1.6.0 9 | github.com/labstack/echo/v4 v4.13.4 10 | github.com/sashabaranov/go-openai v1.41.2 11 | ) 12 | 13 | require ( 14 | github.com/cespare/xxhash/v2 v2.3.0 15 | github.com/samber/lo v1.52.0 16 | go.uber.org/zap v1.27.1 17 | gopkg.in/yaml.v3 v3.0.1 18 | ) 19 | 20 | require ( 21 | github.com/bytedance/gopkg v0.1.3 // indirect 22 | go.uber.org/multierr v1.11.0 // indirect 23 | ) 24 | 25 | require ( 26 | github.com/bytedance/sonic/loader v0.4.0 // indirect 27 | github.com/cloudwego/base64x v0.1.6 // indirect 28 | github.com/joho/godotenv v1.5.1 29 | github.com/klauspost/cpuid/v2 v2.3.0 // indirect 30 | github.com/labstack/gommon v0.4.2 // indirect 31 | github.com/mattn/go-colorable v0.1.14 // indirect 32 | github.com/mattn/go-isatty v0.0.20 // indirect 33 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 34 | github.com/valyala/bytebufferpool v1.0.0 // indirect 35 | github.com/valyala/fasttemplate v1.2.2 // indirect 36 | golang.org/x/arch v0.23.0 // indirect 37 | golang.org/x/crypto v0.45.0 // indirect 38 | golang.org/x/net v0.47.0 // indirect 39 | golang.org/x/sys v0.38.0 // indirect 40 | golang.org/x/text v0.31.0 // indirect 41 | golang.org/x/time v0.14.0 42 | ) 43 | -------------------------------------------------------------------------------- /internal/monica/client.go: -------------------------------------------------------------------------------- 1 | package monica 2 | 3 | import ( 4 | "context" 5 | "monica-proxy/internal/config" 6 | "monica-proxy/internal/errors" 7 | "monica-proxy/internal/logger" 8 | "monica-proxy/internal/types" 9 | "monica-proxy/internal/utils" 10 | 11 | "github.com/go-resty/resty/v2" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | // SendMonicaRequest 发起对 Monica AI 的请求(使用 resty) 16 | func SendMonicaRequest(ctx context.Context, cfg *config.Config, mReq *types.MonicaRequest) (*resty.Response, error) { 17 | // 发起请求 18 | resp, err := utils.RestySSEClient.R(). 19 | SetContext(ctx). 20 | SetHeader("cookie", cfg.Monica.Cookie). 21 | SetBody(mReq). 22 | Post(types.BotChatURL) 23 | 24 | if err != nil { 25 | logger.Error("Monica API请求失败", zap.Error(err)) 26 | return nil, errors.NewRequestFailedError("Monica API调用失败", err) 27 | } 28 | 29 | // 如果需要在这里做更多判断,可自行补充 30 | return resp, nil 31 | } 32 | 33 | // SendCustomBotRequest 发送custom bot请求 34 | func SendCustomBotRequest(ctx context.Context, cfg *config.Config, customBotReq *types.CustomBotRequest) (*resty.Response, error) { 35 | // 发起请求 36 | resp, err := utils.RestySSEClient.R(). 37 | SetContext(ctx). 38 | SetHeader("cookie", cfg.Monica.Cookie). 39 | SetBody(customBotReq). 40 | Post(types.CustomBotChatURL) 41 | 42 | if err != nil { 43 | logger.Error("Custom Bot API请求失败", zap.Error(err)) 44 | return nil, errors.NewRequestFailedError("Custom Bot API调用失败", err) 45 | } 46 | 47 | return resp, nil 48 | } 49 | -------------------------------------------------------------------------------- /internal/service/image_service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "monica-proxy/internal/config" 6 | "monica-proxy/internal/errors" 7 | "monica-proxy/internal/logger" 8 | "monica-proxy/internal/monica" 9 | "monica-proxy/internal/types" 10 | 11 | "go.uber.org/zap" 12 | ) 13 | 14 | // ImageService 图像服务接口 15 | type ImageService interface { 16 | // GenerateImage 生成图像 17 | GenerateImage(ctx context.Context, req *types.ImageGenerationRequest) (*types.ImageGenerationResponse, error) 18 | } 19 | 20 | // imageService 图像服务实现 21 | type imageService struct { 22 | config *config.Config 23 | } 24 | 25 | // NewImageService 创建图像服务实例 26 | func NewImageService(cfg *config.Config) ImageService { 27 | return &imageService{ 28 | config: cfg, 29 | } 30 | } 31 | 32 | // GenerateImage 生成图像 33 | func (s *imageService) GenerateImage(ctx context.Context, req *types.ImageGenerationRequest) (*types.ImageGenerationResponse, error) { 34 | // 验证请求 35 | if req.Prompt == "" { 36 | return nil, errors.NewInvalidInputError("提示词不能为空", nil) 37 | } 38 | 39 | // 设置默认值 40 | if req.Model == "" { 41 | req.Model = "dall-e-3" 42 | } 43 | if req.N <= 0 { 44 | req.N = 1 45 | } 46 | if req.Size == "" { 47 | req.Size = "1024x1024" 48 | } 49 | 50 | // 日志记录请求 51 | logger.Info("处理图像生成请求", 52 | zap.String("model", req.Model), 53 | zap.String("size", req.Size), 54 | zap.Int("count", req.N), 55 | ) 56 | 57 | // 调用Monica API生成图像 58 | response, err := monica.GenerateImage(ctx, s.config, req) 59 | if err != nil { 60 | logger.Error("生成图像失败", zap.Error(err)) 61 | return nil, errors.NewImageGenerationError(err) 62 | } 63 | 64 | return response, nil 65 | } 66 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "monica-proxy/internal/apiserver" 7 | "monica-proxy/internal/config" 8 | "monica-proxy/internal/logger" 9 | "monica-proxy/internal/utils" 10 | customMiddleware "monica-proxy/internal/middleware" 11 | 12 | "github.com/labstack/echo/v4" 13 | "github.com/labstack/echo/v4/middleware" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | func main() { 18 | // 加载配置 19 | cfg, err := config.Load() 20 | if err != nil { 21 | panic(fmt.Sprintf("Failed to load config: %v", err)) 22 | } 23 | 24 | // 设置日志级别 25 | logger.SetLevel(cfg.Logging.Level) 26 | 27 | // 创建应用实例 28 | app := newApp(cfg) 29 | 30 | // 启动服务器 31 | logger.Info("启动服务器", zap.String("address", cfg.GetAddress())) 32 | 33 | if err := app.Start(); err != nil { 34 | logger.Fatal("启动服务器失败", zap.Error(err)) 35 | } 36 | } 37 | 38 | // App 应用实例 39 | type App struct { 40 | config *config.Config 41 | server *echo.Echo 42 | } 43 | 44 | // newApp 创建应用实例 45 | func newApp(cfg *config.Config) *App { 46 | // 初始化HTTP客户端 47 | utils.InitHTTPClients(cfg) 48 | 49 | // 设置 Echo Server 50 | e := echo.New() 51 | e.Logger.SetOutput(io.Discard) 52 | e.HideBanner = true 53 | 54 | // 配置服务器 55 | e.Server.ReadTimeout = cfg.Server.ReadTimeout 56 | e.Server.WriteTimeout = cfg.Server.WriteTimeout 57 | e.Server.IdleTimeout = cfg.Server.IdleTimeout 58 | 59 | // 添加基础中间件 60 | e.Use(middleware.Recover()) 61 | e.Use(middleware.CORS()) 62 | e.Use(middleware.RequestID()) 63 | 64 | // 添加限流中间件 65 | e.Use(customMiddleware.RateLimit(cfg)) 66 | 67 | // 注册路由 68 | apiserver.RegisterRoutes(e, cfg) 69 | 70 | return &App{ 71 | config: cfg, 72 | server: e, 73 | } 74 | } 75 | 76 | // Start 启动应用 77 | func (a *App) Start() error { 78 | return a.server.Start(a.config.GetAddress()) 79 | } 80 | -------------------------------------------------------------------------------- /internal/middleware/logging.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "monica-proxy/internal/config" 5 | "monica-proxy/internal/logger" 6 | "time" 7 | 8 | "github.com/labstack/echo/v4" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | // RequestLogger 创建一个请求日志记录中间件 13 | func RequestLogger(cfg *config.Config) echo.MiddlewareFunc { 14 | return func(next echo.HandlerFunc) echo.HandlerFunc { 15 | return func(c echo.Context) error { 16 | // 如果禁用了请求日志,直接处理请求 17 | if !cfg.Logging.EnableRequestLog { 18 | return next(c) 19 | } 20 | 21 | start := time.Now() 22 | req := c.Request() 23 | res := c.Response() 24 | 25 | // 从 Echo 的 RequestID 中间件读取请求ID(不再自行生成) 26 | requestID := req.Header.Get(echo.HeaderXRequestID) 27 | if requestID == "" { 28 | requestID = res.Header().Get(echo.HeaderXRequestID) 29 | } 30 | 31 | // 处理请求 32 | err := next(c) 33 | 34 | // 计算耗时 35 | duration := time.Since(start) 36 | 37 | // 构建日志字段 38 | fields := []zap.Field{ 39 | zap.String("method", req.Method), 40 | zap.String("uri", req.RequestURI), 41 | zap.Int("status", res.Status), 42 | zap.Duration("latency", duration), 43 | zap.String("remote_addr", c.RealIP()), 44 | zap.String("request_id", requestID), 45 | zap.String("user_agent", req.UserAgent()), 46 | } 47 | 48 | // 添加响应大小信息 49 | if res.Size > 0 { 50 | fields = append(fields, zap.Int64("response_size", res.Size)) 51 | } 52 | 53 | // 根据错误情况记录不同级别的日志 54 | if err != nil { 55 | fields = append(fields, zap.Error(err)) 56 | logger.Error("请求失败", fields...) 57 | } else { 58 | // 根据状态码决定日志级别 59 | switch { 60 | case res.Status >= 500: 61 | logger.Error("请求完成但服务器错误", fields...) 62 | case res.Status >= 400: 63 | logger.Warn("请求完成但客户端错误", fields...) 64 | default: 65 | logger.Info("请求完成", fields...) 66 | } 67 | } 68 | 69 | return err 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /internal/middleware/auth.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "monica-proxy/internal/config" 5 | "monica-proxy/internal/logger" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/labstack/echo/v4" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | // BearerAuth 创建一个Bearer Token认证中间件 14 | func BearerAuth(cfg *config.Config) echo.MiddlewareFunc { 15 | return func(next echo.HandlerFunc) echo.HandlerFunc { 16 | return func(c echo.Context) error { 17 | // 获取Authorization header 18 | auth := c.Request().Header.Get("Authorization") 19 | 20 | // 检查header格式 21 | if auth == "" || !strings.HasPrefix(auth, "Bearer ") { 22 | if cfg.Logging.MaskSensitive { 23 | logger.Warn("无效的授权头", 24 | zap.String("method", c.Request().Method), 25 | zap.String("uri", c.Request().RequestURI), 26 | zap.String("remote_addr", c.RealIP()), 27 | ) 28 | } else { 29 | logger.Warn("无效的授权头", 30 | zap.String("method", c.Request().Method), 31 | zap.String("uri", c.Request().RequestURI), 32 | zap.String("remote_addr", c.RealIP()), 33 | zap.String("auth_header", auth), 34 | ) 35 | } 36 | return echo.NewHTTPError(http.StatusUnauthorized, "invalid authorization header") 37 | } 38 | 39 | // 提取token 40 | token := strings.TrimPrefix(auth, "Bearer ") 41 | 42 | // 验证token 43 | if token != cfg.Security.BearerToken || token == "" { 44 | if cfg.Logging.MaskSensitive { 45 | logger.Warn("无效的Token", 46 | zap.String("method", c.Request().Method), 47 | zap.String("uri", c.Request().RequestURI), 48 | zap.String("remote_addr", c.RealIP()), 49 | ) 50 | } else { 51 | logger.Warn("无效的Token", 52 | zap.String("method", c.Request().Method), 53 | zap.String("uri", c.Request().RequestURI), 54 | zap.String("remote_addr", c.RealIP()), 55 | zap.String("token", token), 56 | ) 57 | } 58 | return echo.NewHTTPError(http.StatusUnauthorized, "invalid token") 59 | } 60 | 61 | return next(c) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /internal/middleware/error_handler.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "monica-proxy/internal/errors" 5 | "monica-proxy/internal/logger" 6 | "net/http" 7 | 8 | "github.com/labstack/echo/v4" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | // buildErrorResponse 构建统一的错误响应格式 13 | func buildErrorResponse(code any, message, requestID string) map[string]any { 14 | return map[string]any{ 15 | "error": map[string]any{ 16 | "code": code, 17 | "message": message, 18 | "request_id": requestID, 19 | }, 20 | } 21 | } 22 | 23 | // ErrorHandler 创建统一的错误处理中间件 24 | func ErrorHandler() echo.HTTPErrorHandler { 25 | return func(err error, c echo.Context) { 26 | // 获取请求ID 27 | requestID := c.Request().Header.Get(echo.HeaderXRequestID) 28 | 29 | // 处理应用错误 30 | if appErr, ok := err.(*errors.AppError); ok { 31 | status, _ := appErr.HTTPResponse() 32 | response := buildErrorResponse(appErr.Code, appErr.Message, requestID) 33 | 34 | // 记录错误日志 35 | logger.Error("应用错误", 36 | zap.Int("status", status), 37 | zap.Int("error_code", int(appErr.Code)), 38 | zap.String("error_msg", appErr.Message), 39 | zap.Error(appErr.Err), 40 | zap.String("request_id", requestID), 41 | ) 42 | 43 | c.JSON(status, response) 44 | return 45 | } 46 | 47 | // 处理Echo框架错误 48 | if echoErr, ok := err.(*echo.HTTPError); ok { 49 | status := echoErr.Code 50 | message := "服务器错误" 51 | if m, ok := echoErr.Message.(string); ok { 52 | message = m 53 | } 54 | 55 | // 构建响应 56 | response := buildErrorResponse(echoErr.Code, message, requestID) 57 | 58 | // 记录错误日志 59 | logger.Error("框架错误", 60 | zap.Int("status", status), 61 | zap.String("error_msg", message), 62 | zap.Error(err), 63 | zap.String("request_id", requestID), 64 | ) 65 | 66 | c.JSON(status, response) 67 | return 68 | } 69 | 70 | // 处理其他错误 71 | status := http.StatusInternalServerError 72 | response := buildErrorResponse(status, "服务器内部错误", requestID) 73 | 74 | // 记录错误日志 75 | logger.Error("未分类错误", 76 | zap.Int("status", status), 77 | zap.Error(err), 78 | zap.String("request_id", requestID), 79 | ) 80 | 81 | c.JSON(status, response) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /internal/service/chat_service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "monica-proxy/internal/config" 6 | "monica-proxy/internal/errors" 7 | "monica-proxy/internal/logger" 8 | "monica-proxy/internal/monica" 9 | "monica-proxy/internal/types" 10 | 11 | "github.com/sashabaranov/go-openai" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | // ChatService 聊天服务接口 16 | type ChatService interface { 17 | // HandleChatCompletion 处理聊天完成请求 18 | HandleChatCompletion(ctx context.Context, req *openai.ChatCompletionRequest) (interface{}, error) 19 | } 20 | 21 | // chatService 聊天服务实现 22 | type chatService struct { 23 | config *config.Config 24 | } 25 | 26 | // NewChatService 创建聊天服务实例 27 | func NewChatService(cfg *config.Config) ChatService { 28 | return &chatService{ 29 | config: cfg, 30 | } 31 | } 32 | 33 | // HandleChatCompletion 处理聊天完成请求 34 | func (s *chatService) HandleChatCompletion(ctx context.Context, req *openai.ChatCompletionRequest) (interface{}, error) { 35 | // 验证请求 36 | if len(req.Messages) == 0 { 37 | return nil, errors.NewEmptyMessageError() 38 | } 39 | 40 | // 日志记录请求 41 | // logger.Info("处理聊天请求", 42 | // zap.String("model", req.Model), 43 | // zap.Int("message_count", len(req.Messages)), 44 | // zap.Bool("stream", req.Stream), 45 | // ) 46 | 47 | // 转换请求格式 48 | monicaReq, err := types.ChatGPTToMonica(s.config, *req) 49 | if err != nil { 50 | logger.Error("转换请求失败", zap.Error(err)) 51 | return nil, errors.NewInternalError(err) 52 | } 53 | 54 | // 调用Monica API 55 | stream, err := monica.SendMonicaRequest(ctx, s.config, monicaReq) 56 | if err != nil { 57 | logger.Error("调用Monica API失败", zap.Error(err)) 58 | // 如果已经是AppError,直接返回,否则包装为内部错误 59 | if appErr, ok := err.(*errors.AppError); ok { 60 | return nil, appErr 61 | } 62 | return nil, errors.NewInternalError(err) 63 | } 64 | // 根据是否使用流式响应处理结果 65 | if req.Stream { 66 | // 这里只返回stream,实际的流处理在handler层 67 | // 流式响应时不关闭响应体,让handler层负责关闭 68 | return stream.RawBody(), nil 69 | } 70 | 71 | // 非流式响应,确保在此函数结束时关闭响应体 72 | defer stream.RawBody().Close() 73 | 74 | // 处理非流式响应 75 | response, err := monica.CollectMonicaSSEToCompletion(req.Model, stream.RawBody()) 76 | if err != nil { 77 | logger.Error("处理Monica响应失败", zap.Error(err)) 78 | return nil, errors.NewInternalError(err) 79 | } 80 | 81 | return response, nil 82 | } 83 | -------------------------------------------------------------------------------- /internal/service/custom_bot_service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "monica-proxy/internal/config" 6 | "monica-proxy/internal/errors" 7 | "monica-proxy/internal/logger" 8 | "monica-proxy/internal/monica" 9 | "monica-proxy/internal/types" 10 | 11 | "github.com/sashabaranov/go-openai" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | // CustomBotService 定义自定义Bot服务接口 16 | type CustomBotService interface { 17 | HandleCustomBotChat(ctx context.Context, req *openai.ChatCompletionRequest, botUID string) (interface{}, error) 18 | } 19 | 20 | type customBotService struct { 21 | config *config.Config 22 | } 23 | 24 | // NewCustomBotService 创建自定义Bot服务实例 25 | func NewCustomBotService(cfg *config.Config) CustomBotService { 26 | return &customBotService{ 27 | config: cfg, 28 | } 29 | } 30 | 31 | // HandleCustomBotChat 处理自定义Bot对话请求 32 | func (s *customBotService) HandleCustomBotChat(ctx context.Context, req *openai.ChatCompletionRequest, botUID string) (interface{}, error) { 33 | // 验证请求 34 | if len(req.Messages) == 0 { 35 | return nil, errors.NewEmptyMessageError() 36 | } 37 | 38 | // 日志记录请求 39 | logger.Info("处理Custom Bot聊天请求", 40 | zap.String("model", req.Model), 41 | zap.String("bot_uid", botUID), 42 | zap.Int("message_count", len(req.Messages)), 43 | zap.Bool("stream", req.Stream), 44 | ) 45 | 46 | // 转换请求格式 47 | customBotReq, err := types.ChatGPTToCustomBot(s.config, *req, botUID) 48 | if err != nil { 49 | logger.Error("转换Custom Bot请求失败", zap.Error(err)) 50 | return nil, errors.NewInternalError(err) 51 | } 52 | 53 | // 调用Monica Custom Bot API 54 | stream, err := monica.SendCustomBotRequest(ctx, s.config, customBotReq) 55 | if err != nil { 56 | logger.Error("调用Custom Bot API失败", zap.Error(err)) 57 | // 如果已经是AppError,直接返回,否则包装为内部错误 58 | if appErr, ok := err.(*errors.AppError); ok { 59 | return nil, appErr 60 | } 61 | return nil, errors.NewInternalError(err) 62 | } 63 | 64 | // 根据是否使用流式响应处理结果 65 | if req.Stream { 66 | // 流式响应时不关闭响应体,让handler层负责关闭 67 | return stream.RawBody(), nil 68 | } 69 | 70 | // 非流式响应,确保在此函数结束时关闭响应体 71 | defer stream.RawBody().Close() 72 | 73 | // 处理非流式响应 74 | response, err := monica.CollectMonicaSSEToCompletion(req.Model, stream.RawBody()) 75 | if err != nil { 76 | logger.Error("处理Custom Bot响应失败", zap.Error(err)) 77 | return nil, errors.NewInternalError(err) 78 | } 79 | 80 | return response, nil 81 | } 82 | -------------------------------------------------------------------------------- /internal/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "os" 5 | "sync" 6 | 7 | "go.uber.org/zap" 8 | "go.uber.org/zap/zapcore" 9 | ) 10 | 11 | var ( 12 | // 全局日志实例 13 | logger *zap.Logger 14 | atomicLevel zap.AtomicLevel 15 | once sync.Once 16 | ) 17 | 18 | // 初始化日志 19 | func init() { 20 | once.Do(func() { 21 | logger = newLogger() 22 | }) 23 | } 24 | 25 | // newLogger 创建一个新的日志实例 26 | func newLogger() *zap.Logger { 27 | // 创建基础的encoder配置 28 | encoderConfig := zapcore.EncoderConfig{ 29 | TimeKey: "time", 30 | LevelKey: "level", 31 | NameKey: "logger", 32 | CallerKey: "caller", 33 | MessageKey: "msg", 34 | StacktraceKey: "stacktrace", 35 | LineEnding: zapcore.DefaultLineEnding, 36 | EncodeLevel: zapcore.LowercaseLevelEncoder, 37 | EncodeTime: zapcore.ISO8601TimeEncoder, 38 | EncodeDuration: zapcore.SecondsDurationEncoder, 39 | EncodeCaller: zapcore.ShortCallerEncoder, 40 | } 41 | 42 | // 创建AtomicLevel 43 | atomicLevel = zap.NewAtomicLevelAt(zap.InfoLevel) 44 | 45 | // 创建Core 46 | core := zapcore.NewCore( 47 | zapcore.NewJSONEncoder(encoderConfig), 48 | zapcore.AddSync(os.Stdout), 49 | atomicLevel, 50 | ) 51 | 52 | // 创建Logger 53 | return zap.New(core, 54 | zap.AddCaller(), 55 | zap.AddCallerSkip(1), 56 | zap.AddStacktrace(zapcore.ErrorLevel), 57 | ) 58 | } 59 | 60 | // Info 记录INFO级别的日志 61 | func Info(msg string, fields ...zap.Field) { 62 | logger.Info(msg, fields...) 63 | } 64 | 65 | // Debug 记录DEBUG级别的日志 66 | func Debug(msg string, fields ...zap.Field) { 67 | logger.Debug(msg, fields...) 68 | } 69 | 70 | // Warn 记录WARN级别的日志 71 | func Warn(msg string, fields ...zap.Field) { 72 | logger.Warn(msg, fields...) 73 | } 74 | 75 | // Error 记录ERROR级别的日志 76 | func Error(msg string, fields ...zap.Field) { 77 | logger.Error(msg, fields...) 78 | } 79 | 80 | // Fatal 记录FATAL级别的日志,然后退出程序 81 | func Fatal(msg string, fields ...zap.Field) { 82 | logger.Fatal(msg, fields...) 83 | } 84 | 85 | // With 返回带有指定字段的Logger 86 | func With(fields ...zap.Field) *zap.Logger { 87 | return logger.With(fields...) 88 | } 89 | 90 | // SetLevel 设置日志级别 91 | func SetLevel(level string) { 92 | var zapLevel zapcore.Level 93 | switch level { 94 | case "debug": 95 | zapLevel = zap.DebugLevel 96 | case "info": 97 | zapLevel = zap.InfoLevel 98 | case "warn": 99 | zapLevel = zap.WarnLevel 100 | case "error": 101 | zapLevel = zap.ErrorLevel 102 | default: 103 | zapLevel = zap.InfoLevel 104 | } 105 | atomicLevel.SetLevel(zapLevel) 106 | } 107 | -------------------------------------------------------------------------------- /internal/types/openai.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "github.com/sashabaranov/go-openai" 4 | 5 | // ImageGenerationRequest represents a request to create an image using DALL-E 6 | type ImageGenerationRequest struct { 7 | Model string `json:"model"` // Required. Currently supports: dall-e-3 8 | Prompt string `json:"prompt"` // Required. A text description of the desired image(s) 9 | N int `json:"n,omitempty"` // Optional. The number of images to generate. Default is 1 10 | Quality string `json:"quality,omitempty"` // Optional. The quality of the image that will be generated 11 | ResponseFormat string `json:"response_format,omitempty"` // Optional. The format in which the generated images are returned 12 | Size string `json:"size,omitempty"` // Optional. The size of the generated images 13 | Style string `json:"style,omitempty"` // Optional. The style of the generated images 14 | User string `json:"user,omitempty"` // Optional. A unique identifier representing your end-user 15 | } 16 | 17 | // ImageGenerationResponse represents the response from the DALL-E image generation API 18 | type ImageGenerationResponse struct { 19 | Created int64 `json:"created"` 20 | Data []ImageGenerationData `json:"data"` 21 | } 22 | 23 | // ImageGenerationData represents a single image in the response 24 | type ImageGenerationData struct { 25 | URL string `json:"url,omitempty"` // The URL of the generated image 26 | B64JSON string `json:"b64_json,omitempty"` // Base64 encoded JSON of the generated image 27 | RevisedPrompt string `json:"revised_prompt,omitempty"` // The prompt that was used to generate the image 28 | } 29 | 30 | type ChatCompletionStreamResponse struct { 31 | ID string `json:"id"` 32 | Object string `json:"object"` 33 | Created int64 `json:"created"` 34 | Model string `json:"model"` 35 | Choices []ChatCompletionStreamChoice `json:"choices"` 36 | SystemFingerprint string `json:"system_fingerprint"` 37 | PromptAnnotations []openai.PromptAnnotation `json:"prompt_annotations,omitempty"` 38 | PromptFilterResults []openai.PromptFilterResult `json:"prompt_filter_results,omitempty"` 39 | Usage *openai.Usage `json:"usage,omitempty"` 40 | } 41 | 42 | type ChatCompletionStreamChoice struct { 43 | Index int `json:"index"` 44 | Delta openai.ChatCompletionStreamChoiceDelta `json:"delta"` 45 | Logprobs *openai.ChatCompletionStreamChoiceLogprobs `json:"logprobs,omitempty"` 46 | FinishReason openai.FinishReason `json:"finish_reason"` 47 | } -------------------------------------------------------------------------------- /internal/types/monica_test.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | // TestGetSupportedModels 测试获取支持的模型列表 8 | func TestGetSupportedModels(t *testing.T) { 9 | models := GetSupportedModels() 10 | 11 | // 验证模型列表不为空 12 | if len(models) == 0 { 13 | t.Fatal("支持的模型列表不应为空") 14 | } 15 | 16 | // 验证模型列表长度应该等于 modelToBotMap 的长度 17 | if len(models) != len(modelToBotMap) { 18 | t.Errorf("模型列表长度 (%d) 不等于 modelToBotMap 长度 (%d)", len(models), len(modelToBotMap)) 19 | } 20 | 21 | // 验证每个返回的模型都在 modelToBotMap 中 22 | for _, model := range models { 23 | if _, exists := modelToBotMap[model]; !exists { 24 | t.Errorf("模型 %s 在 GetSupportedModels 中返回但不在 modelToBotMap 中", model) 25 | } 26 | } 27 | 28 | // 验证 modelToBotMap 中的每个模型都能被 GetSupportedModels 返回 29 | modelSet := make(map[string]bool) 30 | for _, model := range models { 31 | modelSet[model] = true 32 | } 33 | 34 | for model := range modelToBotMap { 35 | if !modelSet[model] { 36 | t.Errorf("模型 %s 在 modelToBotMap 中但未被 GetSupportedModels 返回", model) 37 | } 38 | } 39 | } 40 | 41 | // TestModelToBot 测试模型到 Bot UID 的映射 42 | func TestModelToBot(t *testing.T) { 43 | testCases := []struct { 44 | model string 45 | expected string 46 | }{ 47 | {"gpt-4o", "gpt_4_o_chat"}, 48 | {"claude-sonnet-4-5", "claude_4_5_sonnet"}, 49 | {"claude-3-5-sonnet", "claude_3.5_sonnet"}, 50 | {"gemini-2.5-pro", "gemini_2_5_pro"}, 51 | {"o1-preview", "o1_preview"}, 52 | {"deepseek-v3.1", "deepseek_v3_1"}, 53 | {"grok-4", "grok_4"}, 54 | } 55 | 56 | for _, tc := range testCases { 57 | t.Run(tc.model, func(t *testing.T) { 58 | result := modelToBot(tc.model) 59 | if result != tc.expected { 60 | t.Errorf("modelToBot(%s) = %s; 期望 %s", tc.model, result, tc.expected) 61 | } 62 | }) 63 | } 64 | } 65 | 66 | // TestModelToBotFallback 测试未知模型的 fallback 行为 67 | func TestModelToBotFallback(t *testing.T) { 68 | unknownModel := "unknown-model-xyz" 69 | result := modelToBot(unknownModel) 70 | 71 | // fallback 应该返回原始模型名称 72 | if result != unknownModel { 73 | t.Errorf("modelToBot(%s) = %s; 期望 fallback 到原始名称 %s", unknownModel, result, unknownModel) 74 | } 75 | } 76 | 77 | // TestAllSupportedModelsHaveMapping 测试所有支持的模型都有有效的映射 78 | func TestAllSupportedModelsHaveMapping(t *testing.T) { 79 | models := GetSupportedModels() 80 | 81 | for _, model := range models { 82 | botUID := modelToBot(model) 83 | 84 | // 验证 botUID 不为空 85 | if botUID == "" { 86 | t.Errorf("模型 %s 映射到空的 botUID", model) 87 | } 88 | 89 | // 验证映射结果与 modelToBotMap 中的值一致 90 | expectedBotUID, exists := modelToBotMap[model] 91 | if !exists { 92 | t.Errorf("模型 %s 不在 modelToBotMap 中", model) 93 | continue 94 | } 95 | 96 | if botUID != expectedBotUID { 97 | t.Errorf("模型 %s: modelToBot 返回 %s,但 modelToBotMap 中是 %s", model, botUID, expectedBotUID) 98 | } 99 | } 100 | } 101 | 102 | // TestNoHardcodedModels 测试确保没有硬编码的模型列表与 map 不一致 103 | func TestNoHardcodedModels(t *testing.T) { 104 | // 这个测试确保 GetSupportedModels 完全依赖 modelToBotMap 105 | // 通过比较两者的长度来验证 106 | models := GetSupportedModels() 107 | 108 | if len(models) != len(modelToBotMap) { 109 | t.Errorf("检测到硬编码问题:GetSupportedModels 返回 %d 个模型,但 modelToBotMap 有 %d 个条目", 110 | len(models), len(modelToBotMap)) 111 | } 112 | 113 | // 额外验证:确保所有模型都来自 modelToBotMap 114 | for _, model := range models { 115 | if _, exists := modelToBotMap[model]; !exists { 116 | t.Errorf("发现硬编码模型 %s,它不在 modelToBotMap 中", model) 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /internal/errors/errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | // ErrorCode 定义错误码 9 | type ErrorCode int 10 | 11 | const ( 12 | // 系统错误码 (1000-1999) 13 | ErrInternal ErrorCode = 1000 + iota 14 | ErrBadRequest 15 | ErrUnauthorized 16 | ErrForbidden 17 | ErrNotFound 18 | ErrTimeout 19 | ErrRequestFailed 20 | 21 | // 业务错误码 (2000-2999) 22 | ErrInvalidInput ErrorCode = 2000 + iota 23 | ErrInvalidModel 24 | ErrEmptyMessage 25 | ErrImageGeneration 26 | ErrModelMapping 27 | ErrFileUpload 28 | ) 29 | 30 | // AppError 应用错误 31 | type AppError struct { 32 | Code ErrorCode // 错误码 33 | Message string // 错误消息 34 | Err error // 原始错误 35 | Status int // HTTP状态码 36 | } 37 | 38 | // Error 实现error接口 39 | func (e *AppError) Error() string { 40 | if e.Err != nil { 41 | return fmt.Sprintf("[%d] %s: %v", e.Code, e.Message, e.Err) 42 | } 43 | return fmt.Sprintf("[%d] %s", e.Code, e.Message) 44 | } 45 | 46 | // Unwrap 实现errors.Unwrap接口 47 | func (e *AppError) Unwrap() error { 48 | return e.Err 49 | } 50 | 51 | // HTTPResponse 生成HTTP响应 52 | func (e *AppError) HTTPResponse() (int, map[string]interface{}) { 53 | return e.Status, map[string]interface{}{ 54 | "error": map[string]interface{}{ 55 | "code": e.Code, 56 | "message": e.Message, 57 | }, 58 | } 59 | } 60 | 61 | // NewInternalError 创建内部错误 62 | func NewInternalError(err error) *AppError { 63 | return &AppError{ 64 | Code: ErrInternal, 65 | Message: "服务器内部错误", 66 | Err: err, 67 | Status: http.StatusInternalServerError, 68 | } 69 | } 70 | 71 | // NewBadRequestError 创建请求错误 72 | func NewBadRequestError(message string, err error) *AppError { 73 | return &AppError{ 74 | Code: ErrBadRequest, 75 | Message: message, 76 | Err: err, 77 | Status: http.StatusBadRequest, 78 | } 79 | } 80 | 81 | // NewUnauthorizedError 创建未授权错误 82 | func NewUnauthorizedError(message string) *AppError { 83 | return &AppError{ 84 | Code: ErrUnauthorized, 85 | Message: message, 86 | Status: http.StatusUnauthorized, 87 | } 88 | } 89 | 90 | // NewInvalidInputError 创建无效输入错误 91 | func NewInvalidInputError(message string, err error) *AppError { 92 | return &AppError{ 93 | Code: ErrInvalidInput, 94 | Message: message, 95 | Err: err, 96 | Status: http.StatusBadRequest, 97 | } 98 | } 99 | 100 | // NewEmptyMessageError 创建空消息错误 101 | func NewEmptyMessageError() *AppError { 102 | return &AppError{ 103 | Code: ErrEmptyMessage, 104 | Message: "消息内容不能为空", 105 | Status: http.StatusBadRequest, 106 | } 107 | } 108 | 109 | // NewImageGenerationError 创建图片生成错误 110 | func NewImageGenerationError(err error) *AppError { 111 | return &AppError{ 112 | Code: ErrImageGeneration, 113 | Message: "图片生成失败", 114 | Err: err, 115 | Status: http.StatusInternalServerError, 116 | } 117 | } 118 | 119 | // NewRequestFailedError 创建请求失败错误 120 | func NewRequestFailedError(message string, err error) *AppError { 121 | return &AppError{ 122 | Code: ErrRequestFailed, 123 | Message: fmt.Sprintf("请求失败: %s", message), 124 | Err: err, 125 | Status: http.StatusBadGateway, 126 | } 127 | } 128 | 129 | // NewModelMappingError 创建模型映射错误 130 | func NewModelMappingError(model string) *AppError { 131 | return &AppError{ 132 | Code: ErrModelMapping, 133 | Message: fmt.Sprintf("不支持的模型: %s", model), 134 | Status: http.StatusBadRequest, 135 | } 136 | } 137 | 138 | // NewFileUploadError 创建文件上传错误 139 | func NewFileUploadError(err error) *AppError { 140 | return &AppError{ 141 | Code: ErrFileUpload, 142 | Message: "文件上传失败", 143 | Err: err, 144 | Status: http.StatusInternalServerError, 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /internal/utils/req_client.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "monica-proxy/internal/config" 7 | "net" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/go-resty/resty/v2" 12 | ) 13 | 14 | var ( 15 | // 全局客户端实例,将在初始化时设置 16 | RestySSEClient *resty.Client 17 | RestyDefaultClient *resty.Client 18 | ) 19 | 20 | // InitHTTPClients 初始化HTTP客户端 21 | func InitHTTPClients(cfg *config.Config) { 22 | RestySSEClient = createSSEClient(cfg) 23 | RestyDefaultClient = createDefaultClient(cfg) 24 | } 25 | 26 | // createSSEClient 创建SSE专用客户端 27 | func createSSEClient(cfg *config.Config) *resty.Client { 28 | // 创建自定义的Transport 29 | transport := &http.Transport{ 30 | DialContext: (&net.Dialer{ 31 | Timeout: 30 * time.Second, 32 | KeepAlive: 30 * time.Second, 33 | }).DialContext, 34 | MaxIdleConns: cfg.HTTPClient.MaxIdleConns, 35 | MaxIdleConnsPerHost: cfg.HTTPClient.MaxIdleConnsPerHost, 36 | MaxConnsPerHost: cfg.HTTPClient.MaxConnsPerHost, 37 | IdleConnTimeout: 90 * time.Second, 38 | TLSHandshakeTimeout: 10 * time.Second, 39 | TLSClientConfig: &tls.Config{ 40 | InsecureSkipVerify: cfg.Security.TLSSkipVerify, 41 | MinVersion: tls.VersionTLS12, // 强制使用TLS 1.2+ 42 | }, 43 | } 44 | 45 | client := resty.NewWithClient(&http.Client{ 46 | Transport: transport, 47 | Timeout: cfg.HTTPClient.Timeout, 48 | }). 49 | SetRetryCount(cfg.HTTPClient.RetryCount). 50 | SetRetryWaitTime(cfg.HTTPClient.RetryWaitTime). 51 | SetRetryMaxWaitTime(cfg.HTTPClient.RetryMaxWaitTime). 52 | SetDoNotParseResponse(true). // SSE需要流式处理 53 | SetHeaders(map[string]string{ 54 | "Content-Type": "application/json", 55 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", 56 | "x-client-locale": "zh_CN", 57 | "Accept": "text/event-stream,application/json", 58 | }). 59 | OnAfterResponse(func(c *resty.Client, resp *resty.Response) error { 60 | if resp.StatusCode() >= 400 { 61 | return fmt.Errorf("monica API error: status %d, body: %s", 62 | resp.StatusCode(), resp.String()) 63 | } 64 | return nil 65 | }) 66 | 67 | // 添加重试条件 68 | client.AddRetryCondition(func(r *resty.Response, err error) bool { 69 | // 网络错误或5xx错误时重试 70 | return err != nil || r.StatusCode() >= 500 71 | }) 72 | 73 | return client 74 | } 75 | 76 | // createDefaultClient 创建默认客户端 77 | func createDefaultClient(cfg *config.Config) *resty.Client { 78 | // 创建自定义的Transport 79 | transport := &http.Transport{ 80 | DialContext: (&net.Dialer{ 81 | Timeout: 30 * time.Second, 82 | KeepAlive: 30 * time.Second, 83 | }).DialContext, 84 | MaxIdleConns: cfg.HTTPClient.MaxIdleConns, 85 | MaxIdleConnsPerHost: cfg.HTTPClient.MaxIdleConnsPerHost, 86 | MaxConnsPerHost: cfg.HTTPClient.MaxConnsPerHost, 87 | IdleConnTimeout: 90 * time.Second, 88 | TLSHandshakeTimeout: 10 * time.Second, 89 | TLSClientConfig: &tls.Config{ 90 | InsecureSkipVerify: cfg.Security.TLSSkipVerify, 91 | MinVersion: tls.VersionTLS12, // 强制使用TLS 1.2+ 92 | }, 93 | } 94 | 95 | client := resty.NewWithClient(&http.Client{ 96 | Transport: transport, 97 | Timeout: cfg.Security.RequestTimeout, 98 | }). 99 | SetRetryCount(cfg.HTTPClient.RetryCount). 100 | SetRetryWaitTime(cfg.HTTPClient.RetryWaitTime). 101 | SetRetryMaxWaitTime(cfg.HTTPClient.RetryMaxWaitTime). 102 | SetHeaders(map[string]string{ 103 | "Content-Type": "application/json", 104 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", 105 | }). 106 | OnAfterResponse(func(c *resty.Client, resp *resty.Response) error { 107 | if resp.StatusCode() >= 400 { 108 | return fmt.Errorf("monica API error: status %d, body: %s", 109 | resp.StatusCode(), resp.String()) 110 | } 111 | return nil 112 | }) 113 | 114 | // 添加重试条件 115 | client.AddRetryCondition(func(r *resty.Response, err error) bool { 116 | // 网络错误或5xx错误时重试 117 | return err != nil || r.StatusCode() >= 500 118 | }) 119 | 120 | return client 121 | } 122 | -------------------------------------------------------------------------------- /internal/monica/draw.go: -------------------------------------------------------------------------------- 1 | package monica 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "monica-proxy/internal/config" 7 | "monica-proxy/internal/types" 8 | "monica-proxy/internal/utils" 9 | "time" 10 | 11 | "github.com/bytedance/sonic" 12 | "github.com/google/uuid" 13 | ) 14 | 15 | // GenerateImage 使用 Monica 的文生图 API 生成图片 16 | func GenerateImage(ctx context.Context, cfg *config.Config, req *types.ImageGenerationRequest) (*types.ImageGenerationResponse, error) { 17 | // 1. 参数验证和默认值设置 18 | if req.Model == "" { 19 | req.Model = "dall-e-3" // 默认使用 dall-e-3 20 | } 21 | if req.N <= 0 { 22 | req.N = 1 // 默认生成1张图片 23 | } 24 | if req.Size == "" { 25 | req.Size = "1024x1024" // 默认尺寸 26 | } 27 | 28 | // 2. 转换尺寸为 Monica 的格式 29 | aspectRatio := sizeToAspectRatio(req.Size) 30 | 31 | // 3. 构建请求体 32 | monicaReq := &types.MonicaImageRequest{ 33 | TaskUID: uuid.New().String(), 34 | ImageCount: req.N, 35 | Prompt: req.Prompt, 36 | ModelType: "sdxl", // Monica 目前只支持 sdxl 37 | AspectRatio: aspectRatio, 38 | TaskType: "text_to_image", 39 | } 40 | 41 | // 4. 发送请求生成图片 42 | resp, err := utils.RestyDefaultClient.R(). 43 | SetContext(ctx). 44 | SetBody(monicaReq). 45 | SetHeader("cookie", cfg.Monica.Cookie). 46 | Post(types.ImageGenerateURL) 47 | 48 | if err != nil { 49 | return nil, fmt.Errorf("failed to send image generation request: %v", err) 50 | } 51 | 52 | // 5. 解析响应 53 | var monicaResp struct { 54 | Code int `json:"code"` 55 | Msg string `json:"msg"` 56 | Data struct { 57 | ImageToolsID int `json:"image_tools_id"` 58 | ExpectedTime int `json:"expected_time"` 59 | } `json:"data"` 60 | } 61 | 62 | if err := sonic.Unmarshal(resp.Body(), &monicaResp); err != nil { 63 | return nil, fmt.Errorf("failed to parse image generation response: %v", err) 64 | } 65 | 66 | if monicaResp.Code != 0 { 67 | return nil, fmt.Errorf("image generation failed: %s", monicaResp.Msg) 68 | } 69 | 70 | // 6. 轮询获取生成结果 71 | imageToolsID := monicaResp.Data.ImageToolsID 72 | expectedTime := monicaResp.Data.ExpectedTime 73 | 74 | // 设置轮询超时时间为预期时间的2倍 75 | timeoutCtx, cancel := context.WithTimeout(ctx, time.Duration(expectedTime*2)*time.Second) 76 | defer cancel() 77 | 78 | var generatedImages []types.ImageGenerationData 79 | for { 80 | select { 81 | case <-timeoutCtx.Done(): 82 | return nil, fmt.Errorf("timeout waiting for image generation") 83 | default: 84 | var resultData struct { 85 | Code int `json:"code"` 86 | Msg string `json:"msg"` 87 | Data struct { 88 | Record struct { 89 | Result struct { 90 | CDNURLList []string `json:"cdn_url_list"` 91 | } `json:"result"` 92 | } `json:"record"` 93 | } `json:"data"` 94 | } 95 | 96 | // 查询生成结果 97 | _, err := utils.RestyDefaultClient.R(). 98 | SetContext(ctx). 99 | SetBody(map[string]any{ 100 | "image_tools_id": imageToolsID, 101 | }). 102 | SetHeader("cookie", cfg.Monica.Cookie). 103 | SetResult(&resultData). 104 | Post(types.ImageResultURL) 105 | 106 | if err != nil { 107 | return nil, fmt.Errorf("failed to get image generation result: %v", err) 108 | } 109 | 110 | if resultData.Code != 0 { 111 | return nil, fmt.Errorf("failed to get image result: %s", resultData.Msg) 112 | } 113 | 114 | // 检查是否有图片生成完成 115 | if len(resultData.Data.Record.Result.CDNURLList) > 0 { 116 | // 构建返回数据 117 | for _, url := range resultData.Data.Record.Result.CDNURLList { 118 | generatedImages = append(generatedImages, types.ImageGenerationData{ 119 | URL: url, 120 | RevisedPrompt: req.Prompt, // Monica 不提供修改后的提示词 121 | }) 122 | } 123 | 124 | // 返回结果 125 | return &types.ImageGenerationResponse{ 126 | Created: time.Now().Unix(), 127 | Data: generatedImages, 128 | }, nil 129 | } 130 | 131 | // 等待一段时间后继续轮询 132 | time.Sleep(time.Second) 133 | } 134 | } 135 | } 136 | 137 | // sizeToAspectRatio 将 OpenAI 的尺寸格式转换为 Monica 的宽高比格式 138 | func sizeToAspectRatio(size string) string { 139 | switch size { 140 | case "1024x1024": 141 | return "1:1" 142 | case "1792x1024": 143 | return "16:9" 144 | case "1024x1792": 145 | return "9:16" 146 | default: 147 | return "1:1" // 默认使用1:1 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /internal/middleware/rate_limit.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "context" 5 | "monica-proxy/internal/config" 6 | "net" 7 | "net/http" 8 | "sync" 9 | "time" 10 | 11 | "github.com/labstack/echo/v4" 12 | "golang.org/x/time/rate" 13 | ) 14 | 15 | // clientEntry 客户端限流器条目 16 | type clientEntry struct { 17 | limiter *rate.Limiter 18 | lastSeen time.Time 19 | } 20 | 21 | // RateLimiter 限流器结构 22 | type RateLimiter struct { 23 | mu sync.RWMutex 24 | clients map[string]*clientEntry 25 | rate rate.Limit 26 | burst int 27 | ctx context.Context 28 | cancel context.CancelFunc 29 | } 30 | 31 | // NewRateLimiter 创建新的限流器 32 | func NewRateLimiter(rps int) *RateLimiter { 33 | ctx, cancel := context.WithCancel(context.Background()) 34 | rl := &RateLimiter{ 35 | clients: make(map[string]*clientEntry), 36 | rate: rate.Limit(rps), 37 | burst: rps, // 突发请求等于RPS,更保守的策略 38 | ctx: ctx, 39 | cancel: cancel, 40 | } 41 | 42 | // 启动清理协程 43 | go rl.cleanupClients() 44 | 45 | return rl 46 | } 47 | 48 | // GetLimiter 获取特定客户端的限流器 49 | func (rl *RateLimiter) GetLimiter(clientIP string) *rate.Limiter { 50 | // 先尝试读锁 51 | rl.mu.RLock() 52 | entry, exists := rl.clients[clientIP] 53 | if exists { 54 | entry.lastSeen = time.Now() // 更新最后访问时间 55 | rl.mu.RUnlock() 56 | return entry.limiter 57 | } 58 | rl.mu.RUnlock() 59 | 60 | // 需要创建新的限流器,使用写锁 61 | rl.mu.Lock() 62 | defer rl.mu.Unlock() 63 | 64 | // 双重检查,避免并发创建 65 | if entry, exists := rl.clients[clientIP]; exists { 66 | entry.lastSeen = time.Now() 67 | return entry.limiter 68 | } 69 | 70 | // 创建新的限流器 71 | limiter := rate.NewLimiter(rl.rate, rl.burst) 72 | rl.clients[clientIP] = &clientEntry{ 73 | limiter: limiter, 74 | lastSeen: time.Now(), 75 | } 76 | 77 | return limiter 78 | } 79 | 80 | // cleanupClients 定期清理不活跃的客户端限流器 81 | func (rl *RateLimiter) cleanupClients() { 82 | ticker := time.NewTicker(2 * time.Minute) // 更频繁的清理 83 | defer ticker.Stop() 84 | 85 | for { 86 | select { 87 | case <-rl.ctx.Done(): 88 | return // 优雅停止 89 | case <-ticker.C: 90 | rl.cleanupInactiveClients() 91 | } 92 | } 93 | } 94 | 95 | // cleanupInactiveClients 清理不活跃的客户端 96 | func (rl *RateLimiter) cleanupInactiveClients() { 97 | now := time.Now() 98 | threshold := 10 * time.Minute // 10分钟不活跃就清理 99 | 100 | rl.mu.Lock() 101 | defer rl.mu.Unlock() 102 | 103 | for ip, entry := range rl.clients { 104 | if now.Sub(entry.lastSeen) > threshold { 105 | delete(rl.clients, ip) 106 | } 107 | } 108 | } 109 | 110 | // Close 关闭限流器,停止清理协程 111 | func (rl *RateLimiter) Close() { 112 | rl.cancel() 113 | } 114 | 115 | // getClientIP 安全地获取客户端IP 116 | func getClientIP(c echo.Context) string { 117 | // 优先级:X-Real-IP > X-Forwarded-For > RemoteAddr 118 | if ip := c.Request().Header.Get("X-Real-IP"); ip != "" { 119 | if parsedIP := net.ParseIP(ip); parsedIP != nil { 120 | return ip 121 | } 122 | } 123 | 124 | if ip := c.Request().Header.Get("X-Forwarded-For"); ip != "" { 125 | // X-Forwarded-For 可能包含多个IP,取第一个 126 | if firstIP := getFirstIP(ip); firstIP != "" { 127 | if parsedIP := net.ParseIP(firstIP); parsedIP != nil { 128 | return firstIP 129 | } 130 | } 131 | } 132 | 133 | // 回退到 RemoteAddr 134 | if ip, _, err := net.SplitHostPort(c.Request().RemoteAddr); err == nil { 135 | return ip 136 | } 137 | 138 | return c.Request().RemoteAddr 139 | } 140 | 141 | // getFirstIP 从逗号分隔的IP列表中获取第一个IP 142 | func getFirstIP(ips string) string { 143 | for i, char := range ips { 144 | if char == ',' { 145 | return ips[:i] 146 | } 147 | } 148 | return ips 149 | } 150 | 151 | // 全局限流器实例,避免重复创建 152 | var globalRateLimiter *RateLimiter 153 | var rateLimiterOnce sync.Once 154 | 155 | // RateLimit 创建限流中间件 156 | func RateLimit(cfg *config.Config) echo.MiddlewareFunc { 157 | // 如果禁用限流,返回空中间件 158 | if !cfg.Security.RateLimitEnabled { 159 | return func(next echo.HandlerFunc) echo.HandlerFunc { 160 | return next 161 | } 162 | } 163 | 164 | // 确保只创建一次限流器 165 | rateLimiterOnce.Do(func() { 166 | globalRateLimiter = NewRateLimiter(cfg.Security.RateLimitRPS) 167 | }) 168 | 169 | return func(next echo.HandlerFunc) echo.HandlerFunc { 170 | return func(c echo.Context) error { 171 | // 安全地获取客户端IP 172 | clientIP := getClientIP(c) 173 | 174 | // 获取该客户端的限流器 175 | limiter := globalRateLimiter.GetLimiter(clientIP) 176 | 177 | // 检查是否允许请求 178 | if !limiter.Allow() { 179 | return echo.NewHTTPError(http.StatusTooManyRequests, map[string]any{ 180 | "error": map[string]any{ 181 | "code": "rate_limit_exceeded", 182 | "message": "请求过于频繁,请稍后再试", 183 | "limit": cfg.Security.RateLimitRPS, 184 | "retry_after": "1s", 185 | }, 186 | }) 187 | } 188 | 189 | return next(c) 190 | } 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /internal/apiserver/router.go: -------------------------------------------------------------------------------- 1 | package apiserver 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "monica-proxy/internal/config" 7 | "monica-proxy/internal/errors" 8 | "monica-proxy/internal/logger" 9 | "monica-proxy/internal/middleware" 10 | "monica-proxy/internal/monica" 11 | "monica-proxy/internal/service" 12 | "monica-proxy/internal/types" 13 | "net/http" 14 | 15 | "github.com/labstack/echo/v4" 16 | "github.com/sashabaranov/go-openai" 17 | "go.uber.org/zap" 18 | ) 19 | 20 | // RegisterRoutes 注册 Echo 路由 21 | func RegisterRoutes(e *echo.Echo, cfg *config.Config) { 22 | // 设置自定义错误处理器 23 | e.HTTPErrorHandler = middleware.ErrorHandler() 24 | 25 | // 添加中间件 26 | e.Use(middleware.BearerAuth(cfg)) 27 | e.Use(middleware.RequestLogger(cfg)) 28 | 29 | // 初始化服务实例 30 | chatService := service.NewChatService(cfg) 31 | modelService := service.NewModelService(cfg) 32 | imageService := service.NewImageService(cfg) 33 | customBotService := service.NewCustomBotService(cfg) 34 | 35 | // ChatGPT 风格的请求转发到 /v1/chat/completions 36 | e.POST("/v1/chat/completions", createChatCompletionHandler(chatService, customBotService, cfg)) 37 | // 获取支持的模型列表 38 | e.GET("/v1/models", createListModelsHandler(modelService)) 39 | // DALL-E 风格的图片生成请求 40 | e.POST("/v1/images/generations", createImageGenerationHandler(imageService)) 41 | // Custom Bot 测试接口 42 | e.POST("/v1/chat/custom-bot/:bot_uid", createCustomBotHandler(customBotService, cfg)) 43 | // 新增不带bot_uid的路由,使用环境变量中的BOT_UID 44 | e.POST("/v1/chat/custom-bot", createCustomBotHandler(customBotService, cfg)) 45 | } 46 | 47 | // createChatCompletionHandler 创建聊天完成处理器 48 | func createChatCompletionHandler(chatService service.ChatService, customBotService service.CustomBotService, cfg *config.Config) echo.HandlerFunc { 49 | return func(c echo.Context) error { 50 | var req openai.ChatCompletionRequest 51 | if err := c.Bind(&req); err != nil { 52 | return errors.NewBadRequestError("无效的请求数据", err) 53 | } 54 | 55 | ctx := c.Request().Context() 56 | var result interface{} 57 | var err error 58 | 59 | // 检查是否启用了 Custom Bot 模式 60 | if cfg.Monica.EnableCustomBotMode { 61 | // 使用 Custom Bot Service 处理请求 62 | result, err = customBotService.HandleCustomBotChat(ctx, &req, cfg.Monica.BotUID) 63 | } else { 64 | // 使用普通的 Chat Service 处理请求 65 | result, err = chatService.HandleChatCompletion(ctx, &req) 66 | } 67 | 68 | if err != nil { 69 | return err 70 | } 71 | 72 | // 根据请求参数决定响应方式 73 | if req.Stream { 74 | // 对于流式请求,result是一个io.ReadCloser 75 | rawBody, ok := result.(io.Reader) 76 | if !ok { 77 | return errors.NewInternalError(nil) 78 | } 79 | 80 | // 确保关闭响应体 81 | closer, isCloser := rawBody.(io.Closer) 82 | if isCloser { 83 | defer closer.Close() 84 | } 85 | 86 | // 设置响应头 87 | c.Response().Header().Set(echo.HeaderContentType, "text/event-stream") 88 | c.Response().Header().Set("Cache-Control", "no-cache") 89 | c.Response().Header().Set("Transfer-Encoding", "chunked") 90 | c.Response().WriteHeader(http.StatusOK) 91 | 92 | // 流式处理响应 93 | if err := monica.StreamMonicaSSEToClient(req.Model, c.Response().Writer, rawBody); err != nil { 94 | return errors.NewInternalError(err) 95 | } 96 | return nil 97 | } else { 98 | // 对于非流式请求,直接返回JSON响应 99 | return c.JSON(http.StatusOK, result) 100 | } 101 | } 102 | } 103 | 104 | // createListModelsHandler 创建模型列表处理器 105 | func createListModelsHandler(modelService service.ModelService) echo.HandlerFunc { 106 | return func(c echo.Context) error { 107 | // 调用服务获取模型列表 108 | models := modelService.GetSupportedModels() 109 | 110 | // 构造响应格式 111 | result := make(map[string][]struct { 112 | Id string `json:"id"` 113 | }) 114 | 115 | result["data"] = make([]struct { 116 | Id string `json:"id"` 117 | }, 0) 118 | 119 | for _, model := range models { 120 | result["data"] = append(result["data"], struct { 121 | Id string `json:"id"` 122 | }{ 123 | Id: model, 124 | }) 125 | } 126 | return c.JSON(http.StatusOK, result) 127 | } 128 | } 129 | 130 | // createImageGenerationHandler 创建图片生成处理器 131 | func createImageGenerationHandler(imageService service.ImageService) echo.HandlerFunc { 132 | return func(c echo.Context) error { 133 | // 解析请求 134 | var req types.ImageGenerationRequest 135 | if err := c.Bind(&req); err != nil { 136 | return errors.NewBadRequestError("无效的请求数据", err) 137 | } 138 | 139 | // 调用服务生成图片 140 | resp, err := imageService.GenerateImage(c.Request().Context(), &req) 141 | if err != nil { 142 | return err 143 | } 144 | 145 | // 返回结果 146 | return c.JSON(http.StatusOK, resp) 147 | } 148 | } 149 | 150 | // createCustomBotHandler 创建Custom Bot处理器 151 | func createCustomBotHandler(service service.CustomBotService, cfg *config.Config) echo.HandlerFunc { 152 | return func(c echo.Context) error { 153 | // 获取bot UID,优先从路由参数获取,如果没有则从环境变量获取 154 | botUID := c.Param("bot_uid") 155 | if botUID == "" { 156 | // 从配置(环境变量)中获取 157 | botUID = cfg.Monica.BotUID 158 | if botUID == "" { 159 | return errors.NewBadRequestError("bot_uid参数不能为空,请在URL中指定或设置BOT_UID环境变量", nil) 160 | } 161 | } 162 | 163 | var req openai.ChatCompletionRequest 164 | if err := c.Bind(&req); err != nil { 165 | return errors.NewBadRequestError("请求体解析失败", err) 166 | } 167 | 168 | ctx := c.Request().Context() 169 | result, err := service.HandleCustomBotChat(ctx, &req, botUID) 170 | if err != nil { 171 | return err 172 | } 173 | 174 | // 如果是流式响应 175 | if req.Stream { 176 | // 设置响应头 177 | c.Response().Header().Set("Content-Type", "text/event-stream") 178 | c.Response().Header().Set("Cache-Control", "no-cache") 179 | c.Response().Header().Set("Connection", "keep-alive") 180 | c.Response().Header().Set("Transfer-Encoding", "chunked") 181 | 182 | // 获取响应体(io.ReadCloser) 183 | stream, ok := result.(io.ReadCloser) 184 | if !ok { 185 | return errors.NewInternalError(fmt.Errorf("流式响应类型错误")) 186 | } 187 | defer stream.Close() 188 | 189 | // 转换并写入响应 190 | err := monica.StreamMonicaSSEToClient(req.Model, c.Response().Writer, stream) 191 | if err != nil { 192 | logger.Error("流式响应写入失败", zap.Error(err)) 193 | return err 194 | } 195 | 196 | c.Response().Flush() 197 | return nil 198 | } 199 | 200 | // 非流式响应 201 | return c.JSON(http.StatusOK, result) 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /internal/types/image.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "monica-proxy/internal/config" 7 | "monica-proxy/internal/utils" 8 | "net/http" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "github.com/cespare/xxhash/v2" 14 | "github.com/google/uuid" 15 | ) 16 | 17 | const MaxFileSize = 10 * 1024 * 1024 // 10MB 18 | 19 | var imageCache sync.Map 20 | 21 | // sampleAndHash 对base64字符串进行采样并计算xxHash 22 | func sampleAndHash(data string) string { 23 | // 如果数据长度小于1024,直接计算整个字符串的哈希 24 | if len(data) <= 1024 { 25 | return fmt.Sprintf("%x", xxhash.Sum64String(data)) 26 | } 27 | 28 | // 采样策略: 29 | // 1. 取前256字节 30 | // 2. 取中间256字节 31 | // 3. 取最后256字节 32 | var samples []string 33 | samples = append(samples, data[:256]) 34 | mid := len(data) / 2 35 | samples = append(samples, data[mid-128:mid+128]) 36 | samples = append(samples, data[len(data)-256:]) 37 | 38 | // 将采样数据拼接后计算哈希 39 | return fmt.Sprintf("%x", xxhash.Sum64String(strings.Join(samples, ""))) 40 | } 41 | 42 | // UploadBase64Image 上传base64编码的图片到Monica 43 | func UploadBase64Image(ctx context.Context, cfg *config.Config, base64Data string) (*FileInfo, error) { 44 | // 1. 生成缓存key 45 | cacheKey := sampleAndHash(base64Data) 46 | 47 | // 2. 检查缓存 48 | if value, exists := imageCache.Load(cacheKey); exists { 49 | return value.(*FileInfo), nil 50 | } 51 | 52 | // 3. 解析base64数据 53 | // 移除 "data:image/png;base64," 这样的前缀 54 | parts := strings.Split(base64Data, ",") 55 | if len(parts) != 2 { 56 | return nil, fmt.Errorf("invalid base64 image format") 57 | } 58 | 59 | // 获取图片类型 60 | mimeType := strings.TrimSuffix(strings.TrimPrefix(parts[0], "data:"), ";base64") 61 | if !strings.HasPrefix(mimeType, "image/") { 62 | return nil, fmt.Errorf("invalid image mime type: %s", mimeType) 63 | } 64 | 65 | // 解码base64数据 66 | imageData, err := utils.Base64Decode(parts[1]) 67 | if err != nil { 68 | return nil, fmt.Errorf("decode base64 failed: %v", err) 69 | } 70 | 71 | // 4. 验证图片格式和大小 72 | fileInfo, err := validateImageBytes(imageData, mimeType) 73 | if err != nil { 74 | return nil, fmt.Errorf("validate image failed: %v", err) 75 | } 76 | // log.Printf("file info: %+v", fileInfo) 77 | 78 | // 5. 获取预签名URL 79 | preSignReq := &PreSignRequest{ 80 | FilenameList: []string{fileInfo.FileName}, 81 | Module: ImageModule, 82 | Location: ImageLocation, 83 | ObjID: uuid.New().String(), 84 | } 85 | 86 | var preSignResp PreSignResponse 87 | _, err = utils.RestyDefaultClient.R(). 88 | SetContext(ctx). 89 | SetHeader("cookie", cfg.Monica.Cookie). 90 | SetBody(preSignReq). 91 | SetResult(&preSignResp). 92 | Post(PreSignURL) 93 | 94 | if err != nil { 95 | return nil, fmt.Errorf("get pre-sign url failed: %v", err) 96 | } 97 | 98 | if len(preSignResp.Data.PreSignURLList) == 0 || len(preSignResp.Data.ObjectURLList) == 0 { 99 | return nil, fmt.Errorf("no pre-sign url or object url returned") 100 | } 101 | // log.Printf("preSign info: %+v", preSignResp) 102 | 103 | // 6. 上传图片数据 104 | _, err = utils.RestyDefaultClient.R(). 105 | SetContext(ctx). 106 | SetHeader("Content-Type", fileInfo.FileType). 107 | SetBody(imageData). 108 | Put(preSignResp.Data.PreSignURLList[0]) 109 | 110 | if err != nil { 111 | return nil, fmt.Errorf("upload file failed: %v", err) 112 | } 113 | 114 | // 7. 创建文件对象 115 | fileInfo.ObjectURL = preSignResp.Data.ObjectURLList[0] 116 | uploadReq := &FileUploadRequest{ 117 | Data: []FileInfo{*fileInfo}, 118 | } 119 | 120 | var uploadResp FileUploadResponse 121 | _, err = utils.RestyDefaultClient.R(). 122 | SetContext(ctx). 123 | SetHeader("cookie", cfg.Monica.Cookie). 124 | SetBody(uploadReq). 125 | SetResult(&uploadResp). 126 | Post(FileUploadURL) 127 | 128 | if err != nil { 129 | return nil, fmt.Errorf("create file object failed: %v", err) 130 | } 131 | // log.Printf("uploadResp: %+v", uploadResp) 132 | if len(uploadResp.Data.Items) > 0 { 133 | fileInfo.FileName = uploadResp.Data.Items[0].FileName 134 | fileInfo.FileType = uploadResp.Data.Items[0].FileType 135 | fileInfo.FileSize = uploadResp.Data.Items[0].FileSize 136 | fileInfo.FileUID = uploadResp.Data.Items[0].FileUID 137 | fileInfo.FileExt = uploadResp.Data.Items[0].FileType 138 | fileInfo.FileTokens = uploadResp.Data.Items[0].FileTokens 139 | fileInfo.FileChunks = uploadResp.Data.Items[0].FileChunks 140 | } 141 | 142 | fileInfo.UseFullText = true 143 | fileInfo.FileURL = preSignResp.Data.CDNURLList[0] 144 | 145 | // 8. 获取文件llm读取结果知道有返回 146 | var batchResp FileBatchGetResponse 147 | reqMap := make(map[string][]string) 148 | reqMap["file_uids"] = []string{fileInfo.FileUID} 149 | var retryCount = 1 150 | for { 151 | if retryCount > 5 { 152 | return nil, fmt.Errorf("retry limit exceeded") 153 | } 154 | _, err = utils.RestyDefaultClient.R(). 155 | SetContext(ctx). 156 | SetHeader("cookie", cfg.Monica.Cookie). 157 | SetBody(reqMap). 158 | SetResult(&batchResp). 159 | Post(FileGetURL) 160 | if err != nil { 161 | return nil, fmt.Errorf("batch get file failed: %v", err) 162 | } 163 | if len(batchResp.Data.Items) > 0 && batchResp.Data.Items[0].FileChunks > 0 { 164 | break 165 | } else { 166 | retryCount++ 167 | } 168 | time.Sleep(1 * time.Second) 169 | } 170 | fileInfo.FileChunks = batchResp.Data.Items[0].FileChunks 171 | fileInfo.FileTokens = batchResp.Data.Items[0].FileTokens 172 | fileInfo.URL = "" 173 | fileInfo.ObjectURL = "" 174 | 175 | // 9. 保存到缓存 176 | imageCache.Store(cacheKey, fileInfo) 177 | 178 | return fileInfo, nil 179 | } 180 | 181 | // validateImageBytes 验证图片字节数据的格式和大小 182 | func validateImageBytes(imageData []byte, mimeType string) (*FileInfo, error) { 183 | if len(imageData) > MaxFileSize { 184 | return nil, fmt.Errorf("file size exceeds limit: %d > %d", len(imageData), MaxFileSize) 185 | } 186 | 187 | contentType := http.DetectContentType(imageData) 188 | if !SupportedImageTypes[contentType] { 189 | return nil, fmt.Errorf("unsupported image type: %s", contentType) 190 | } 191 | 192 | // 根据MIME类型生成文件扩展名 193 | ext := ".png" 194 | switch mimeType { 195 | case "image/jpeg": 196 | ext = ".jpg" 197 | case "image/gif": 198 | ext = ".gif" 199 | case "image/webp": 200 | ext = ".webp" 201 | } 202 | 203 | fileName := fmt.Sprintf("%s%s", uuid.New().String(), ext) 204 | 205 | return &FileInfo{ 206 | FileName: fileName, 207 | FileSize: int64(len(imageData)), 208 | FileType: contentType, 209 | }, nil 210 | } 211 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= 2 | github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= 3 | github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE= 4 | github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980= 5 | github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o= 6 | github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= 7 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 8 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 9 | github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= 10 | github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= 15 | github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= 16 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 17 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 18 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 19 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 20 | github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 21 | github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 22 | github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= 23 | github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= 24 | github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 25 | github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 26 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 27 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 28 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 29 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 30 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 31 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 32 | github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= 33 | github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= 34 | github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM= 35 | github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= 36 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 37 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 38 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 39 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 40 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 41 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 42 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 43 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 44 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 45 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 46 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 47 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 48 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 49 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 50 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 51 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 52 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 53 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 54 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 55 | go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= 56 | go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 57 | golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= 58 | golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= 59 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= 60 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 61 | golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= 62 | golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 63 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 64 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 65 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 66 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 67 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 68 | golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= 69 | golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= 70 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 71 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 72 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 73 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 74 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Monica Proxy 2 | 3 |
4 | 5 | ![Go](https://img.shields.io/badge/go-1.24-00ADD8) 6 | ![License](https://img.shields.io/badge/license-MIT-green) 7 | ![Docker](https://img.shields.io/badge/docker-ready-2496ED) 8 | 9 | **Monica AI 代理服务** 10 | 11 | 将 Monica AI 转换为 ChatGPT 兼容的 API,支持完整的 OpenAI 接口兼容性 12 | 13 | [快速开始](#-快速开始) • [功能特性](#-功能特性) • [部署指南](#-部署指南) • [配置参考](#-配置参考) 14 | 15 |
16 | 17 | --- 18 | 19 | ## 🚀 **快速开始** 20 | 21 | ### 一键启动 22 | 23 | ```bash 24 | docker run -d \ 25 | --name monica-proxy \ 26 | -p 8080:8080 \ 27 | -e MONICA_COOKIE="your_monica_cookie" \ 28 | -e BEARER_TOKEN="your_bearer_token" \ 29 | neccen/monica-proxy:latest 30 | ``` 31 | 32 | ### 测试API 33 | 34 | ```bash 35 | curl -H "Authorization: Bearer your_bearer_token" \ 36 | http://localhost:8080/v1/models 37 | ``` 38 | 39 | ## ✨ **功能特性** 40 | 41 | ### 🔗 **API兼容性** 42 | 43 | - ✅ **完整的System Prompt支持** - 通过Custom Bot Mode实现真正的系统提示词 44 | - ✅ **ChatGPT API完全兼容** - 无缝替换OpenAI接口,支持所有标准参数 45 | - ✅ **流式响应** - 完整的SSE流式对话体验,支持实时输出 46 | - ✅ **Monica模型支持** - GPT-4o、Claude-4、Gemini等主流模型完整映射 47 | 48 | ## 🏗️ **部署指南** 49 | 50 | ### 🐳 **Docker Compose部署(推荐)** 51 | 52 | #### 部署配置 53 | 54 | ```yaml 55 | # docker-compose.yml 56 | services: 57 | monica-proxy: 58 | build: . 59 | container_name: monica-proxy 60 | restart: unless-stopped 61 | ports: 62 | - "8080:8080" 63 | environment: 64 | - MONICA_COOKIE=${MONICA_COOKIE} 65 | - BEARER_TOKEN=${BEARER_TOKEN} 66 | - RATE_LIMIT_RPS=100 # 启用限流:每秒100请求 67 | # Custom Bot模式配置(可选) 68 | # - ENABLE_CUSTOM_BOT_MODE=true 69 | # - BOT_UID=${BOT_UID} 70 | ``` 71 | 72 | ### 🔧 **源码编译** 73 | 74 | ```bash 75 | # 克隆项目 76 | git clone https://github.com/ycvk/monica-proxy.git 77 | cd monica-proxy 78 | 79 | # 编译 80 | go build -o monica-proxy main.go 81 | 82 | # 运行 83 | export MONICA_COOKIE="your_cookie" 84 | export BEARER_TOKEN="your_token" 85 | # export BOT_UID="your_bot_uid" # 可选,用于Custom Bot模式 86 | ./monica-proxy 87 | ``` 88 | 89 | ## ⚙️ **配置参考** 90 | 91 | ### 🌍 **环境变量配置** 92 | 93 | | 变量名 | 必需 | 默认值 | 说明 | 94 | |--------------------------|----|-----------|--------------------------------------------------| 95 | | `MONICA_COOKIE` | ✅ | - | Monica登录Cookie | 96 | | `BEARER_TOKEN` | ✅ | - | API访问令牌 | 97 | | `ENABLE_CUSTOM_BOT_MODE` | ❌ | `false` | 启用Custom Bot模式,支持系统提示词 | 98 | | `BOT_UID` | ❌* | - | Custom Bot的UID(*当ENABLE_CUSTOM_BOT_MODE=true时必需) | 99 | | `RATE_LIMIT_RPS` | ❌ | `0` | 限流配置:0=禁用,>0=每秒请求数限制 | 100 | | `TLS_SKIP_VERIFY` | ❌ | `true` | 是否跳过TLS证书验证 | 101 | | `LOG_LEVEL` | ❌ | `info` | 日志级别:debug/info/warn/error | 102 | | `SERVER_PORT` | ❌ | `8080` | HTTP服务监听端口 | 103 | | `SERVER_HOST` | ❌ | `0.0.0.0` | HTTP服务监听地址 | 104 | 105 | ### 📄 **配置文件示例** 106 | 107 | ```yaml 108 | # config.yaml 109 | server: 110 | host: "0.0.0.0" 111 | port: 8080 112 | read_timeout: "30s" 113 | write_timeout: "30s" 114 | 115 | monica: 116 | cookie: "your_monica_cookie" 117 | enable_custom_bot_mode: false # 启用后支持系统提示词 118 | bot_uid: "your_custom_bot_uid" # Custom Bot模式必需 119 | 120 | security: 121 | bearer_token: "your_bearer_token" 122 | rate_limit_enabled: true 123 | rate_limit_rps: 100 124 | tls_skip_verify: false 125 | 126 | http_client: 127 | timeout: "3m" 128 | max_idle_conns: 100 129 | max_idle_conns_per_host: 20 130 | retry_count: 3 131 | 132 | logging: 133 | level: "info" 134 | format: "json" 135 | mask_sensitive: true 136 | ``` 137 | 138 | ## 🔌 **API使用** 139 | 140 | ### 支持的端点 141 | 142 | - `POST /v1/chat/completions` - 聊天对话(兼容ChatGPT) 143 | - `GET /v1/models` - 获取模型列表 144 | - `POST /v1/images/generations` - 图片生成(兼容DALL-E) 145 | 146 | ### 认证方式 147 | 148 | ```http 149 | Authorization: Bearer YOUR_BEARER_TOKEN 150 | ``` 151 | 152 | ### 聊天API示例 153 | 154 | ```bash 155 | curl -X POST http://localhost:8080/v1/chat/completions \ 156 | -H "Authorization: Bearer your_token" \ 157 | -H "Content-Type: application/json" \ 158 | -d '{ 159 | "model": "gpt-4o", 160 | "messages": [ 161 | {"role": "system", "content": "你是一个有帮助的助手"}, 162 | {"role": "user", "content": "你好"} 163 | ], 164 | "stream": true 165 | }' 166 | ``` 167 | 168 | ### 支持的模型 169 | 170 | | 模型系列 | 模型名称 | 说明 | 171 | |--------------|--------------------------------------------------------------------------------------------------|--------------------| 172 | | **GPT系列** | `gpt-5`, `gpt-4o`, `gpt-4o-mini`, `gpt-4.1`, `gpt-4.1-mini`, `gpt-4.1-nano`, `gpt-4-5` | OpenAI GPT模型 | 173 | | **Claude系列** | `claude-4-sonnet`, `claude-4-opus`, `claude-3-7-sonnet`, `claude-3-5-sonnet`, `claude-3-5-haiku` | Anthropic Claude模型 | 174 | | **Gemini系列** | `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.0-flash`, `gemini-1` | Google Gemini模型 | 175 | | **O系列** | `o1-preview`, `o3`, `o3-mini`, `o4-mini` | OpenAI O系列模型 | 176 | | **其他** | `deepseek-reasoner`, `deepseek-chat`, `grok-3-beta`, `grok-4`, `sonar`, `sonar-reasoning-pro` | 专业模型 | 177 | 178 | ## 🛠️ **高级功能** 179 | 180 | ### Custom Bot Mode(系统提示词支持) 181 | 182 | 通过启用 Custom Bot Mode,可以让所有的聊天请求都支持系统提示词(system prompt)功能: 183 | 184 | ```bash 185 | # 启用 Custom Bot Mode 186 | export ENABLE_CUSTOM_BOT_MODE=true 187 | export BOT_UID="your-bot-uid" # 必需 188 | 189 | ⬇️ 启动项目后 ⬇️ 190 | 191 | # 现在所有 /v1/chat/completions 请求都支持 system prompt 192 | curl -X POST http://localhost:8080/v1/chat/completions \ 193 | -H "Authorization: Bearer your_token" \ 194 | -H "Content-Type: application/json" \ 195 | -d '{ 196 | "model": "gpt-4o", 197 | "messages": [ 198 | { 199 | "role": "system", 200 | "content": "你是一个海盗船长,用海盗的口吻说话" 201 | }, 202 | { 203 | "role": "user", 204 | "content": "介绍一下你自己" 205 | } 206 | ] 207 | }' 208 | ``` 209 | 210 | **优势:** 211 | 212 | - 无需修改客户端代码,保持完全兼容 213 | - 所有请求都可以动态设置不同的 prompt 214 | - 支持流式和非流式响应 215 | 216 | ### 限流配置 217 | 218 | ```bash 219 | # 启用限流(每秒50请求) 220 | export RATE_LIMIT_RPS=50 221 | docker-compose restart monica-proxy 222 | 223 | # 测试限流效果 224 | for i in {1..100}; do curl -H "Authorization: Bearer token" http://localhost:8080/v1/models & done 225 | ``` 226 | 227 | ## 📈 **监控和运维** 228 | 229 | ### 日志查看 230 | 231 | ```bash 232 | # 查看实时日志 233 | docker-compose logs -f monica-proxy 234 | 235 | # 查看错误日志 236 | docker-compose logs monica-proxy | grep -i error 237 | 238 | # 查看JSON格式结构化日志 239 | docker-compose logs monica-proxy | jq . 240 | ``` 241 | 242 | ### 服务状态检查 243 | 244 | ```bash 245 | # 测试API可用性 246 | curl -H "Authorization: Bearer your_token" \ 247 | http://localhost:8080/v1/models 248 | 249 | # 测试限流状态(查看HTTP响应头) 250 | curl -I -H "Authorization: Bearer your_token" \ 251 | http://localhost:8080/v1/models 252 | ``` 253 | 254 | ### 基础监控 255 | 256 | ```bash 257 | # 查看容器资源使用情况 258 | docker stats monica-proxy 259 | 260 | # 简单的API压力测试 261 | for i in {1..10}; do 262 | curl -s -H "Authorization: Bearer your_token" \ 263 | http://localhost:8080/v1/models > /dev/null && echo "OK" || echo "FAIL" 264 | done 265 | ``` 266 | 267 | ## 🔧 **故障排查** 268 | 269 | ### 常见问题 270 | 271 | 1. **认证失败** 272 | ```bash 273 | # 检查Token配置 274 | docker-compose exec monica-proxy env | grep BEARER_TOKEN 275 | ``` 276 | 277 | 2. **限流过于严格** 278 | ```bash 279 | # 调整限流参数 280 | export RATE_LIMIT_RPS=200 281 | docker-compose restart monica-proxy 282 | ``` 283 | 284 | ## 🤝 **贡献指南** 285 | 286 | 欢迎提交Issue和Pull Request! 287 | 288 | 1. Fork本项目 289 | 2. 创建特性分支:`git checkout -b feature/amazing-feature` 290 | 3. 提交更改:`git commit -m 'Add amazing feature'` 291 | 4. 推送分支:`git push origin feature/amazing-feature` 292 | 5. 提交Pull Request 293 | 294 | ## 📄 **许可证** 295 | 296 | 本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情 297 | 298 | --- 299 | 300 |
301 | 302 | **如果这个项目对你有帮助,请给个 ⭐️ Star!** 303 | 304 |
-------------------------------------------------------------------------------- /internal/monica/sse.go: -------------------------------------------------------------------------------- 1 | package monica 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "sync" 11 | "time" 12 | 13 | "monica-proxy/internal/types" 14 | "monica-proxy/internal/utils" 15 | "net/http" 16 | "strings" 17 | 18 | "github.com/bytedance/sonic" 19 | "github.com/sashabaranov/go-openai" 20 | ) 21 | 22 | const ( 23 | sseObject = "chat.completion.chunk" 24 | sseFinish = "[DONE]" 25 | flushInterval = 100 * time.Millisecond // 刷新间隔 26 | bufferSize = 4096 // 缓冲区大小 27 | 28 | dataPrefix = "data: " 29 | dataPrefixLen = len(dataPrefix) 30 | lineEnd = "\n\n" 31 | ) 32 | 33 | // SSEData 用于解析 Monica SSE json 34 | type SSEData struct { 35 | Text string `json:"text"` 36 | Finished bool `json:"finished"` 37 | AgentStatus AgentStatus `json:"agent_status,omitempty"` 38 | } 39 | 40 | type AgentStatus struct { 41 | UID string `json:"uid"` 42 | Type string `json:"type"` 43 | Text string `json:"text"` 44 | Metadata struct { 45 | Title string `json:"title"` 46 | ReasoningDetail string `json:"reasoning_detail"` 47 | } `json:"metadata"` 48 | } 49 | 50 | var ( 51 | sseDataPool = sync.Pool{ 52 | New: func() any { 53 | return &SSEData{} 54 | }, 55 | } 56 | 57 | // 字符串构建器池,复用strings.Builder 58 | stringBuilderPool = sync.Pool{ 59 | New: func() any { 60 | return &strings.Builder{} 61 | }, 62 | } 63 | 64 | // 缓冲区池,复用字节缓冲区 65 | bufferPool = sync.Pool{ 66 | New: func() any { 67 | buf := make([]byte, bufferSize) 68 | return &buf 69 | }, 70 | } 71 | ) 72 | 73 | // processMonicaSSE 处理Monica的SSE数据 74 | type processMonicaSSE struct { 75 | reader *bufio.Reader 76 | model string 77 | ctx context.Context 78 | } 79 | 80 | // handleSSEData 处理单条SSE数据 81 | type handleSSEData func(*SSEData) error 82 | 83 | // processSSEStream 处理SSE流 84 | func (p *processMonicaSSE) processSSEStream(handler handleSSEData) error { 85 | var line []byte 86 | var err error 87 | for { 88 | // 检查上下文是否已取消 89 | select { 90 | case <-p.ctx.Done(): 91 | return p.ctx.Err() 92 | default: 93 | } 94 | 95 | line, err = p.reader.ReadBytes('\n') 96 | if err != nil { 97 | // EOF 和 上下文取消 都是正常结束,不应视为错误 98 | if err == io.EOF || errors.Is(err, context.Canceled) { 99 | return nil 100 | } 101 | return fmt.Errorf("read error: %w", err) 102 | } 103 | 104 | // Monica SSE 的行前缀一般是 "data: " 105 | if len(line) < dataPrefixLen || !bytes.HasPrefix(line, []byte(dataPrefix)) { 106 | continue 107 | } 108 | 109 | jsonStr := line[dataPrefixLen : len(line)-1] // 去掉\n 110 | if len(jsonStr) == 0 { 111 | continue 112 | } 113 | 114 | // 如果是 [DONE] 则结束 115 | if bytes.Equal(jsonStr, []byte(sseFinish)) { 116 | return nil 117 | } 118 | 119 | // 从对象池获取一个对象 120 | sseData := sseDataPool.Get().(*SSEData) 121 | 122 | // 解析 JSON 123 | if err := sonic.Unmarshal(jsonStr, sseData); err != nil { 124 | // 立即归还对象到池中 125 | *sseData = SSEData{} 126 | sseDataPool.Put(sseData) 127 | return fmt.Errorf("unmarshal error: %w", err) 128 | } 129 | 130 | // 调用处理函数 131 | if err := handler(sseData); err != nil { 132 | // 立即归还对象到池中 133 | *sseData = SSEData{} 134 | sseDataPool.Put(sseData) 135 | return err 136 | } 137 | 138 | // 使用完后立即归还对象到池中 139 | *sseData = SSEData{} 140 | sseDataPool.Put(sseData) 141 | } 142 | } 143 | 144 | // CollectMonicaSSEToCompletion 将 Monica SSE 转换为完整的 ChatCompletion 响应 145 | func CollectMonicaSSEToCompletion(model string, r io.Reader) (*openai.ChatCompletionResponse, error) { 146 | ctx := context.Background() 147 | 148 | // 从池中获取字符串构建器 149 | fullContentBuilder := stringBuilderPool.Get().(*strings.Builder) 150 | defer func() { 151 | fullContentBuilder.Reset() 152 | stringBuilderPool.Put(fullContentBuilder) 153 | }() 154 | 155 | processor := &processMonicaSSE{ 156 | reader: bufio.NewReaderSize(r, bufferSize), 157 | model: model, 158 | ctx: ctx, 159 | } 160 | 161 | // 处理SSE数据 162 | err := processor.processSSEStream(func(sseData *SSEData) error { 163 | // 如果是 agent_status,跳过 164 | if sseData.AgentStatus.Type != "" { 165 | return nil 166 | } 167 | // 累积内容 168 | fullContentBuilder.WriteString(sseData.Text) 169 | return nil 170 | }) 171 | 172 | if err != nil { 173 | return nil, err 174 | } 175 | 176 | // 构造完整的响应 177 | response := &openai.ChatCompletionResponse{ 178 | ID: fmt.Sprintf("chatcmpl-%s", utils.RandStringUsingMathRand(29)), 179 | Object: "chat.completion", 180 | Created: time.Now().Unix(), 181 | Model: model, 182 | Choices: []openai.ChatCompletionChoice{ 183 | { 184 | Index: 0, 185 | Message: openai.ChatCompletionMessage{ 186 | Role: "assistant", 187 | Content: fullContentBuilder.String(), 188 | }, 189 | FinishReason: "stop", 190 | }, 191 | }, 192 | Usage: openai.Usage{ 193 | // Monica API 不提供 token 使用信息,这里暂时填 0 194 | PromptTokens: 0, 195 | CompletionTokens: 0, 196 | TotalTokens: 0, 197 | }, 198 | } 199 | 200 | return response, nil 201 | } 202 | 203 | // StreamMonicaSSEToClient 将 Monica SSE 转成前端可用的流 204 | func StreamMonicaSSEToClient(model string, w io.Writer, r io.Reader) error { 205 | ctx := context.Background() 206 | writer := bufio.NewWriterSize(w, bufferSize) 207 | defer writer.Flush() 208 | 209 | chatId := utils.RandStringUsingMathRand(29) 210 | now := time.Now().Unix() 211 | fingerprint := utils.RandStringUsingMathRand(10) 212 | 213 | // 创建一个定时刷新的 ticker 214 | ticker := time.NewTicker(flushInterval) 215 | defer ticker.Stop() 216 | 217 | // 创建一个 done channel 用于清理 218 | done := make(chan struct{}) 219 | defer close(done) 220 | 221 | // 启动一个 goroutine 定期刷新缓冲区 222 | go func() { 223 | for { 224 | select { 225 | case <-ticker.C: 226 | if f, ok := w.(http.Flusher); ok { 227 | writer.Flush() 228 | f.Flush() 229 | } 230 | case <-done: 231 | return 232 | } 233 | } 234 | }() 235 | 236 | processor := &processMonicaSSE{ 237 | reader: bufio.NewReaderSize(r, bufferSize), 238 | model: model, 239 | ctx: ctx, 240 | } 241 | 242 | var thinkFlag bool 243 | return processor.processSSEStream(func(sseData *SSEData) error { 244 | var sseMsg types.ChatCompletionStreamResponse 245 | switch { 246 | case sseData.Finished: 247 | sseMsg = types.ChatCompletionStreamResponse{ 248 | ID: "chatcmpl-" + chatId, 249 | Object: sseObject, 250 | Created: now, 251 | Model: model, 252 | Choices: []types.ChatCompletionStreamChoice{ 253 | { 254 | Index: 0, 255 | Delta: openai.ChatCompletionStreamChoiceDelta{ 256 | Role: openai.ChatMessageRoleAssistant, 257 | }, 258 | FinishReason: openai.FinishReasonStop, 259 | }, 260 | }, 261 | } 262 | case sseData.AgentStatus.Type == "thinking": 263 | thinkFlag = true 264 | sseMsg = types.ChatCompletionStreamResponse{ 265 | ID: "chatcmpl-" + chatId, 266 | Object: sseObject, 267 | SystemFingerprint: fingerprint, 268 | Created: now, 269 | Model: model, 270 | Choices: []types.ChatCompletionStreamChoice{ 271 | { 272 | Index: 0, 273 | Delta: openai.ChatCompletionStreamChoiceDelta{ 274 | Role: openai.ChatMessageRoleAssistant, 275 | Content: ``, 276 | }, 277 | FinishReason: openai.FinishReasonNull, 278 | }, 279 | }, 280 | } 281 | case sseData.AgentStatus.Type == "thinking_detail_stream": 282 | sseMsg = types.ChatCompletionStreamResponse{ 283 | ID: "chatcmpl-" + chatId, 284 | Object: sseObject, 285 | SystemFingerprint: fingerprint, 286 | Created: now, 287 | Model: model, 288 | Choices: []types.ChatCompletionStreamChoice{ 289 | { 290 | Index: 0, 291 | Delta: openai.ChatCompletionStreamChoiceDelta{ 292 | Role: openai.ChatMessageRoleAssistant, 293 | Content: sseData.AgentStatus.Metadata.ReasoningDetail, 294 | }, 295 | FinishReason: openai.FinishReasonNull, 296 | }, 297 | }, 298 | } 299 | default: 300 | if thinkFlag { 301 | sseData.Text = "" + sseData.Text 302 | thinkFlag = false 303 | } 304 | sseMsg = types.ChatCompletionStreamResponse{ 305 | ID: "chatcmpl-" + chatId, 306 | Object: sseObject, 307 | SystemFingerprint: fingerprint, 308 | Created: now, 309 | Model: model, 310 | Choices: []types.ChatCompletionStreamChoice{ 311 | { 312 | Index: 0, 313 | Delta: openai.ChatCompletionStreamChoiceDelta{ 314 | Role: openai.ChatMessageRoleAssistant, 315 | Content: sseData.Text, 316 | }, 317 | FinishReason: openai.FinishReasonNull, 318 | }, 319 | }, 320 | } 321 | } 322 | 323 | // 从池中获取字符串构建器 324 | sb := stringBuilderPool.Get().(*strings.Builder) 325 | sb.WriteString("data: ") 326 | sendLine, _ := sonic.MarshalString(sseMsg) 327 | sb.WriteString(sendLine) 328 | sb.WriteString("\n\n") 329 | 330 | // 写入缓冲区 331 | if _, err := writer.WriteString(sb.String()); err != nil { 332 | // 归还字符串构建器到池中 333 | sb.Reset() 334 | stringBuilderPool.Put(sb) 335 | return fmt.Errorf("write error: %w", err) 336 | } 337 | 338 | // 使用完毕,归还字符串构建器到池中 339 | sb.Reset() 340 | stringBuilderPool.Put(sb) 341 | 342 | // 如果发现 finished=true,就可以结束 343 | if sseData.Finished { 344 | writer.WriteString(dataPrefix) 345 | writer.WriteString(sseFinish) 346 | writer.WriteString(lineEnd) 347 | writer.Flush() 348 | if f, ok := w.(http.Flusher); ok { 349 | f.Flush() 350 | } 351 | return nil 352 | } 353 | 354 | sseData.AgentStatus.Type = "" 355 | sseData.Finished = false 356 | return nil 357 | }) 358 | } 359 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/joho/godotenv" 14 | "gopkg.in/yaml.v3" 15 | ) 16 | 17 | // Config 应用配置结构 18 | type Config struct { 19 | // 服务器配置 20 | Server ServerConfig `yaml:"server" json:"server"` 21 | 22 | // Monica API 配置 23 | Monica MonicaConfig `yaml:"monica" json:"monica"` 24 | 25 | // 安全配置 26 | Security SecurityConfig `yaml:"security" json:"security"` 27 | 28 | // HTTP 客户端配置 29 | HTTPClient HTTPClientConfig `yaml:"http_client" json:"http_client"` 30 | 31 | // 日志配置 32 | Logging LoggingConfig `yaml:"logging" json:"logging"` 33 | } 34 | 35 | // ServerConfig 服务器配置 36 | type ServerConfig struct { 37 | Host string `yaml:"host" json:"host"` 38 | Port int `yaml:"port" json:"port"` 39 | ReadTimeout time.Duration `yaml:"read_timeout" json:"read_timeout"` 40 | WriteTimeout time.Duration `yaml:"write_timeout" json:"write_timeout"` 41 | IdleTimeout time.Duration `yaml:"idle_timeout" json:"idle_timeout"` 42 | } 43 | 44 | // MonicaConfig Monica API 配置 45 | type MonicaConfig struct { 46 | Cookie string `yaml:"cookie" json:"cookie"` 47 | BotUID string `yaml:"bot_uid" json:"bot_uid"` 48 | EnableCustomBotMode bool `yaml:"enable_custom_bot_mode" json:"enable_custom_bot_mode"` 49 | } 50 | 51 | // SecurityConfig 安全配置 52 | type SecurityConfig struct { 53 | BearerToken string `yaml:"bearer_token" json:"bearer_token"` 54 | TLSSkipVerify bool `yaml:"tls_skip_verify" json:"tls_skip_verify"` 55 | RateLimitEnabled bool `yaml:"rate_limit_enabled" json:"rate_limit_enabled"` 56 | RateLimitRPS int `yaml:"rate_limit_rps" json:"rate_limit_rps"` 57 | RequestTimeout time.Duration `yaml:"request_timeout" json:"request_timeout"` 58 | } 59 | 60 | // HTTPClientConfig HTTP 客户端配置 61 | type HTTPClientConfig struct { 62 | Timeout time.Duration `yaml:"timeout" json:"timeout"` 63 | MaxIdleConns int `yaml:"max_idle_conns" json:"max_idle_conns"` 64 | MaxIdleConnsPerHost int `yaml:"max_idle_conns_per_host" json:"max_idle_conns_per_host"` 65 | MaxConnsPerHost int `yaml:"max_conns_per_host" json:"max_conns_per_host"` 66 | RetryCount int `yaml:"retry_count" json:"retry_count"` 67 | RetryWaitTime time.Duration `yaml:"retry_wait_time" json:"retry_wait_time"` 68 | RetryMaxWaitTime time.Duration `yaml:"retry_max_wait_time" json:"retry_max_wait_time"` 69 | } 70 | 71 | // LoggingConfig 日志配置 72 | type LoggingConfig struct { 73 | Level string `yaml:"level" json:"level"` 74 | Format string `yaml:"format" json:"format"` 75 | Output string `yaml:"output" json:"output"` 76 | EnableRequestLog bool `yaml:"enable_request_log" json:"enable_request_log"` 77 | MaskSensitive bool `yaml:"mask_sensitive" json:"mask_sensitive"` 78 | } 79 | 80 | // Load 加载配置,优先级:配置文件 > 环境变量 > 默认值 81 | func Load() (*Config, error) { 82 | // 1. 设置默认配置 83 | config := getDefaultConfig() 84 | 85 | // 2. 尝试加载 .env 文件 86 | _ = godotenv.Load() 87 | 88 | // 3. 尝试加载配置文件 89 | if err := loadConfigFile(config); err != nil { 90 | // 配置文件加载失败不是致命错误,继续使用环境变量和默认值 91 | fmt.Printf("Warning: Failed to load config file: %v\n", err) 92 | } 93 | 94 | // 4. 环境变量覆盖 95 | overrideWithEnv(config) 96 | 97 | // 5. 验证配置 98 | if err := config.Validate(); err != nil { 99 | return nil, fmt.Errorf("配置验证失败: %w", err) 100 | } 101 | 102 | return config, nil 103 | } 104 | 105 | // getDefaultConfig 获取默认配置 106 | func getDefaultConfig() *Config { 107 | return &Config{ 108 | Server: ServerConfig{ 109 | Host: "0.0.0.0", 110 | Port: 8080, 111 | ReadTimeout: 5 * time.Minute, 112 | WriteTimeout: 5 * time.Minute, 113 | IdleTimeout: 60 * time.Second, 114 | }, 115 | Monica: MonicaConfig{ 116 | Cookie: "", 117 | BotUID: "", 118 | EnableCustomBotMode: false, 119 | }, 120 | Security: SecurityConfig{ 121 | TLSSkipVerify: true, 122 | RateLimitEnabled: false, // 默认禁用,需要明确配置 123 | RateLimitRPS: 0, // 默认0,禁用限流 124 | RequestTimeout: 30 * time.Second, 125 | }, 126 | HTTPClient: HTTPClientConfig{ 127 | Timeout: 3 * time.Minute, 128 | MaxIdleConns: 100, 129 | MaxIdleConnsPerHost: 10, 130 | MaxConnsPerHost: 50, 131 | RetryCount: 3, 132 | RetryWaitTime: 1 * time.Second, 133 | RetryMaxWaitTime: 10 * time.Second, 134 | }, 135 | Logging: LoggingConfig{ 136 | Level: "info", 137 | Format: "json", 138 | Output: "stdout", 139 | EnableRequestLog: true, 140 | MaskSensitive: true, 141 | }, 142 | } 143 | } 144 | 145 | // loadConfigFile 加载配置文件 146 | func loadConfigFile(config *Config) error { 147 | configPaths := []string{ 148 | "config.yaml", 149 | "config.yml", 150 | "config.json", 151 | "./configs/config.yaml", 152 | "./configs/config.yml", 153 | "./configs/config.json", 154 | } 155 | 156 | for _, path := range configPaths { 157 | if _, err := os.Stat(path); err == nil { 158 | return loadFromFile(path, config) 159 | } 160 | } 161 | 162 | // 检查环境变量指定的配置文件 163 | if configPath := os.Getenv("CONFIG_FILE"); configPath != "" { 164 | return loadFromFile(configPath, config) 165 | } 166 | 167 | return fmt.Errorf("no config file found") 168 | } 169 | 170 | // loadFromFile 从文件加载配置 171 | func loadFromFile(path string, config *Config) error { 172 | file, err := os.Open(path) 173 | if err != nil { 174 | return err 175 | } 176 | defer file.Close() 177 | 178 | data, err := io.ReadAll(file) 179 | if err != nil { 180 | return err 181 | } 182 | 183 | ext := strings.ToLower(filepath.Ext(path)) 184 | switch ext { 185 | case ".yaml", ".yml": 186 | return yaml.Unmarshal(data, config) 187 | case ".json": 188 | return json.Unmarshal(data, config) 189 | default: 190 | return fmt.Errorf("unsupported config file format: %s", ext) 191 | } 192 | } 193 | 194 | // overrideWithEnv 用环境变量覆盖配置 195 | func overrideWithEnv(config *Config) { 196 | // 服务器配置 197 | if host := os.Getenv("SERVER_HOST"); host != "" { 198 | config.Server.Host = host 199 | } 200 | // 保持向后兼容,支持原来的PORT环境变量 201 | if port := os.Getenv("PORT"); port != "" { 202 | if p, err := strconv.Atoi(port); err == nil { 203 | config.Server.Port = p 204 | } 205 | } 206 | if port := os.Getenv("SERVER_PORT"); port != "" { 207 | if p, err := strconv.Atoi(port); err == nil { 208 | config.Server.Port = p 209 | } 210 | } 211 | if timeout := os.Getenv("SERVER_READ_TIMEOUT"); timeout != "" { 212 | if t, err := time.ParseDuration(timeout); err == nil { 213 | config.Server.ReadTimeout = t 214 | } 215 | } 216 | 217 | // Monica 配置 218 | if cookie := os.Getenv("MONICA_COOKIE"); cookie != "" { 219 | config.Monica.Cookie = cookie 220 | } 221 | if botUID := os.Getenv("BOT_UID"); botUID != "" { 222 | config.Monica.BotUID = botUID 223 | } 224 | if enableCustomBot := os.Getenv("ENABLE_CUSTOM_BOT_MODE"); enableCustomBot != "" { 225 | if enabled, err := strconv.ParseBool(enableCustomBot); err == nil { 226 | config.Monica.EnableCustomBotMode = enabled 227 | } 228 | } 229 | 230 | // 安全配置 231 | if token := os.Getenv("BEARER_TOKEN"); token != "" { 232 | config.Security.BearerToken = token 233 | } 234 | if skipVerify := os.Getenv("TLS_SKIP_VERIFY"); skipVerify != "" { 235 | if skip, err := strconv.ParseBool(skipVerify); err == nil { 236 | config.Security.TLSSkipVerify = skip 237 | } 238 | } 239 | if rateLimitEnabled := os.Getenv("RATE_LIMIT_ENABLED"); rateLimitEnabled != "" { 240 | if enabled, err := strconv.ParseBool(rateLimitEnabled); err == nil { 241 | config.Security.RateLimitEnabled = enabled 242 | } 243 | } 244 | if rateLimitRPS := os.Getenv("RATE_LIMIT_RPS"); rateLimitRPS != "" { 245 | if rps, err := strconv.Atoi(rateLimitRPS); err == nil { 246 | config.Security.RateLimitRPS = rps 247 | } 248 | } 249 | 250 | // 日志配置 251 | if level := os.Getenv("LOG_LEVEL"); level != "" { 252 | config.Logging.Level = level 253 | } 254 | if format := os.Getenv("LOG_FORMAT"); format != "" { 255 | config.Logging.Format = format 256 | } 257 | } 258 | 259 | // Validate 验证配置 260 | func (c *Config) Validate() error { 261 | var errors []string 262 | 263 | // 验证必要配置 264 | if c.Monica.Cookie == "" { 265 | errors = append(errors, "MONICA_COOKIE is required") 266 | } 267 | if c.Security.BearerToken == "" { 268 | errors = append(errors, "BEARER_TOKEN is required") 269 | } 270 | 271 | // 如果启用了 Custom Bot 模式,必须设置 BOT_UID 272 | if c.Monica.EnableCustomBotMode && c.Monica.BotUID == "" { 273 | errors = append(errors, "BOT_UID is required when ENABLE_CUSTOM_BOT_MODE is true") 274 | } 275 | 276 | // 验证端口范围 277 | if c.Server.Port < 1 || c.Server.Port > 65535 { 278 | errors = append(errors, "SERVER_PORT must be between 1 and 65535") 279 | } 280 | 281 | // 验证超时配置 282 | if c.Server.ReadTimeout < 0 { 283 | errors = append(errors, "SERVER_READ_TIMEOUT must be positive") 284 | } 285 | if c.HTTPClient.Timeout < 0 { 286 | errors = append(errors, "HTTP_CLIENT_TIMEOUT must be positive") 287 | } 288 | 289 | // 验证限流配置 290 | if c.Security.RateLimitRPS <= 0 { 291 | // 如果RPS<=0,自动禁用限流 292 | c.Security.RateLimitEnabled = false 293 | } 294 | if c.Security.RateLimitEnabled && c.Security.RateLimitRPS <= 0 { 295 | errors = append(errors, "RATE_LIMIT_RPS must be positive when rate limiting is enabled") 296 | } 297 | if c.Security.RateLimitRPS > 10000 { 298 | errors = append(errors, "RATE_LIMIT_RPS should not exceed 10000 for performance reasons") 299 | } 300 | 301 | // 验证日志级别 302 | validLevels := []string{"debug", "info", "warn", "error", "dpanic", "panic", "fatal"} 303 | if !contains(validLevels, c.Logging.Level) { 304 | errors = append(errors, fmt.Sprintf("LOG_LEVEL must be one of: %s", strings.Join(validLevels, ", "))) 305 | } 306 | 307 | if len(errors) > 0 { 308 | return fmt.Errorf("%s", strings.Join(errors, "; ")) 309 | } 310 | 311 | return nil 312 | } 313 | 314 | // GetAddress 获取服务器监听地址 315 | func (c *Config) GetAddress() string { 316 | return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port) 317 | } 318 | 319 | // contains 检查字符串是否在切片中 320 | func contains(slice []string, item string) bool { 321 | for _, s := range slice { 322 | if s == item { 323 | return true 324 | } 325 | } 326 | return false 327 | } 328 | -------------------------------------------------------------------------------- /internal/types/monica.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "monica-proxy/internal/config" 7 | "monica-proxy/internal/logger" 8 | "sync/atomic" 9 | "time" 10 | 11 | lop "github.com/samber/lo/parallel" 12 | 13 | "github.com/google/uuid" 14 | "github.com/sashabaranov/go-openai" 15 | "go.uber.org/zap" 16 | ) 17 | 18 | const ( 19 | BotChatURL = "https://api.monica.im/api/custom_bot/chat" 20 | PreSignURL = "https://api.monica.im/api/file_object/pre_sign_list_by_module" 21 | FileUploadURL = "https://api.monica.im/api/files/batch_create_llm_file" 22 | FileGetURL = "https://api.monica.im/api/files/batch_get_file" 23 | 24 | // 图片生成相关 API 25 | ImageGenerateURL = "https://api.monica.im/api/image_tools/text_to_image" 26 | ImageResultURL = "https://api.monica.im/api/image_tools/loop_result" 27 | ) 28 | 29 | // 图片相关常量 30 | const ( 31 | MaxImageSize = 10 * 1024 * 1024 // 10MB 32 | ImageModule = "chat_bot" 33 | ImageLocation = "files" 34 | ImageUploadTimeout = 30 * time.Second // 图片上传超时时间 35 | MaxConcurrentUploads = 5 // 最大并发上传数 36 | ) 37 | 38 | // 支持的图片格式 39 | var SupportedImageTypes = map[string]bool{ 40 | "image/jpeg": true, 41 | "image/png": true, 42 | "image/gif": true, 43 | "image/webp": true, 44 | } 45 | 46 | type ChatGPTRequest struct { 47 | Model string `json:"model"` // gpt-3.5-turbo, gpt-4, ... 48 | Messages []ChatMessage `json:"messages"` // 对话数组 49 | Stream bool `json:"stream"` // 是否流式返回 50 | } 51 | 52 | type ChatMessage struct { 53 | Role string `json:"role"` // "system", "user", "assistant" 54 | Content any `json:"content"` // 可以是字符串或MessageContent数组 55 | } 56 | 57 | // MessageContent 消息内容 58 | type MessageContent struct { 59 | Type string `json:"type"` // "text" 或 "image_url" 60 | Text string `json:"text,omitempty"` // 文本内容 61 | ImageURL string `json:"image_url,omitempty"` // 图片URL 62 | } 63 | 64 | // MonicaRequest 为 Monica 自定义 AI 的请求格式 65 | type MonicaRequest struct { 66 | TaskUID string `json:"task_uid"` 67 | BotUID string `json:"bot_uid"` 68 | Data DataField `json:"data"` 69 | Language string `json:"language"` 70 | TaskType string `json:"task_type"` 71 | ToolData ToolData `json:"tool_data"` 72 | } 73 | 74 | // DataField 在 Monica 的 body 中 75 | type DataField struct { 76 | ConversationID string `json:"conversation_id"` 77 | PreParentItemID string `json:"pre_parent_item_id"` 78 | Items []Item `json:"items"` 79 | TriggerBy string `json:"trigger_by"` 80 | UseModel string `json:"use_model,omitempty"` 81 | IsIncognito bool `json:"is_incognito"` 82 | UseNewMemory bool `json:"use_new_memory"` 83 | } 84 | 85 | type Item struct { 86 | ConversationID string `json:"conversation_id"` 87 | ParentItemID string `json:"parent_item_id,omitempty"` 88 | ItemID string `json:"item_id"` 89 | ItemType string `json:"item_type"` 90 | Data ItemContent `json:"data"` 91 | } 92 | 93 | type ItemContent struct { 94 | Type string `json:"type"` 95 | Content string `json:"content"` 96 | MaxToken int `json:"max_token,omitempty"` 97 | IsIncognito bool `json:"is_incognito,omitempty"` // 是否无痕模式 98 | FromTaskType string `json:"from_task_type,omitempty"` 99 | ManualWebSearchEnabled bool `json:"manual_web_search_enabled,omitempty"` // 网页搜索 100 | UseModel string `json:"use_model,omitempty"` 101 | FileInfos []FileInfo `json:"file_infos,omitempty"` 102 | } 103 | 104 | // ToolData 这里演示放空 105 | type ToolData struct { 106 | SysSkillList []string `json:"sys_skill_list"` 107 | } 108 | 109 | // PreSignRequest 预签名请求 110 | type PreSignRequest struct { 111 | FilenameList []string `json:"filename_list"` 112 | Module string `json:"module"` 113 | Location string `json:"location"` 114 | ObjID string `json:"obj_id"` 115 | } 116 | 117 | // PreSignResponse 预签名响应 118 | type PreSignResponse struct { 119 | Code int `json:"code"` 120 | Msg string `json:"msg"` 121 | Data struct { 122 | PreSignURLList []string `json:"pre_sign_url_list"` 123 | ObjectURLList []string `json:"object_url_list"` 124 | CDNURLList []string `json:"cdn_url_list"` 125 | } `json:"data"` 126 | } 127 | 128 | // MonicaImageRequest 文生图请求结构 129 | type MonicaImageRequest struct { 130 | TaskUID string `json:"task_uid"` // 任务ID 131 | ImageCount int `json:"image_count"` // 生成图片数量 132 | Prompt string `json:"prompt"` // 提示词 133 | ModelType string `json:"model_type"` // 模型类型,目前只支持 sdxl 134 | AspectRatio string `json:"aspect_ratio"` // 宽高比,如 1:1, 16:9, 9:16 135 | TaskType string `json:"task_type"` // 任务类型,固定为 text_to_image 136 | } 137 | 138 | // FileInfo 文件信息 139 | type FileInfo struct { 140 | URL string `json:"url,omitempty"` 141 | FileURL string `json:"file_url"` 142 | FileUID string `json:"file_uid"` 143 | Parse bool `json:"parse"` 144 | FileName string `json:"file_name"` 145 | FileSize int64 `json:"file_size"` 146 | FileType string `json:"file_type"` 147 | FileExt string `json:"file_ext"` 148 | FileTokens int64 `json:"file_tokens"` 149 | FileChunks int64 `json:"file_chunks"` 150 | ObjectURL string `json:"object_url,omitempty"` 151 | //Embedding bool `json:"embedding"` 152 | FileMetaInfo map[string]any `json:"file_meta_info,omitempty"` 153 | UseFullText bool `json:"use_full_text"` 154 | } 155 | 156 | // FileUploadRequest 文件上传请求 157 | type FileUploadRequest struct { 158 | Data []FileInfo `json:"data"` 159 | } 160 | 161 | // FileUploadResponse 文件上传响应 162 | type FileUploadResponse struct { 163 | Code int `json:"code"` 164 | Msg string `json:"msg"` 165 | Data struct { 166 | Items []struct { 167 | FileName string `json:"file_name"` 168 | FileType string `json:"file_type"` 169 | FileSize int64 `json:"file_size"` 170 | FileUID string `json:"file_uid"` 171 | FileTokens int64 `json:"file_tokens"` 172 | FileChunks int64 `json:"file_chunks"` 173 | // 其他字段暂时不需要 174 | } `json:"items"` 175 | } `json:"data"` 176 | } 177 | 178 | // FileBatchGetResponse 获取文件llm处理是否完成 179 | type FileBatchGetResponse struct { 180 | Data struct { 181 | Items []struct { 182 | FileName string `json:"file_name"` 183 | FileType string `json:"file_type"` 184 | FileSize int `json:"file_size"` 185 | ObjectUrl string `json:"object_url"` 186 | Url string `json:"url"` 187 | FileMetaInfo struct { 188 | } `json:"file_meta_info"` 189 | DriveFileUid string `json:"drive_file_uid"` 190 | FileUid string `json:"file_uid"` 191 | IndexState int `json:"index_state"` 192 | IndexDesc string `json:"index_desc"` 193 | ErrorMessage string `json:"error_message"` 194 | FileTokens int64 `json:"file_tokens"` 195 | FileChunks int64 `json:"file_chunks"` 196 | IndexProgress int `json:"index_progress"` 197 | } `json:"items"` 198 | } `json:"data"` 199 | } 200 | 201 | // OpenAIModel represents a model in the OpenAI API format 202 | type OpenAIModel struct { 203 | ID string `json:"id"` 204 | Object string `json:"object"` 205 | OwnedBy string `json:"owned_by"` 206 | } 207 | 208 | // OpenAIModelList represents the response format for the /v1/models endpoint 209 | type OpenAIModelList struct { 210 | Object string `json:"object"` 211 | Data []OpenAIModel `json:"data"` 212 | } 213 | 214 | var modelToBotMap = map[string]string{ 215 | // OpenAI 系列 216 | "gpt-5": "gpt_5", 217 | "gpt-4o": "gpt_4_o_chat", 218 | "gpt-4o-mini": "gpt_4_o_mini_chat", 219 | "gpt-4.1": "gpt_4_1", 220 | "gpt-4.1-mini": "gpt_4_1_mini", 221 | "gpt-4.1-nano": "gpt_4_1_nano", 222 | "gpt-4-5": "gpt_4_5_chat", 223 | "o3": "o3", 224 | "o3-mini": "openai_o_3_mini", 225 | "o4-mini": "o4_mini", 226 | 227 | // Claude 系列 228 | "claude-haiku-4-5": "claude_4_5_haiku", 229 | "claude-sonnet-4-5": "claude_4_5_sonnet", 230 | "claude-4-sonnet": "claude_4_sonnet", 231 | "claude-4-sonnet-thinking": "claude_4_sonnet_think", 232 | "claude-4-opus": "claude_4_opus", 233 | "claude-4-opus-thinking": "claude_4_opus_think", 234 | "claude-opus-4-1-20250805-thinking": "claude_4_1_opus_think", 235 | "claude-3-7-sonnet-thinking": "claude_3_7_sonnet_think", 236 | "claude-3-7-sonnet": "claude_3_7_sonnet", 237 | "claude-3-5-haiku": "claude_3.5_haiku", 238 | 239 | // Gemini 系列 240 | "gemini-3-pro-preview-thinking": "gemini_3_pro_preview_think", 241 | "gemini-2.5-pro": "gemini_2_5_pro", 242 | "gemini-2.5-flash": "gemini_2_5_flash", 243 | "gemini-2.0-flash": "gemini_2_0", 244 | 245 | // DeepSeek 系列 246 | "deepseek-v3.1": "deepseek_v3_1", 247 | "deepseek-reasoner": "deepseek_reasoner", 248 | "deepseek-chat": "deepseek_chat", 249 | "deepclaude": "deepclaude", 250 | 251 | // Perplexity 系列 252 | "sonar": "sonar", 253 | "sonar-reasoning-pro": "sonar_reasoning_pro", 254 | 255 | // Grok 系列 256 | "grok-3-beta": "grok_3_beta", 257 | "grok-4": "grok_4", 258 | "grok-code-fast-1": "grok_code_fast_1", 259 | } 260 | 261 | func modelToBot(model string) string { 262 | if botUID, ok := modelToBotMap[model]; ok { 263 | return botUID 264 | } 265 | // 如果未找到映射,则返回原始模型名称 266 | logger.Warn("未找到模型映射,使用原始名称", zap.String("model", model)) 267 | return model 268 | } 269 | 270 | // CustomBotRequest 定义custom bot的请求结构 271 | type CustomBotRequest struct { 272 | TaskUID string `json:"task_uid"` 273 | BotUID string `json:"bot_uid"` 274 | Data CustomBotData `json:"data"` 275 | Language string `json:"language"` 276 | Locale string `json:"locale"` 277 | TaskType string `json:"task_type"` 278 | BotData BotData `json:"bot_data"` 279 | AIRespLanguage string `json:"ai_resp_language,omitempty"` 280 | } 281 | 282 | // CustomBotData custom bot的数据字段 283 | type CustomBotData struct { 284 | ConversationID string `json:"conversation_id"` 285 | Items []Item `json:"items"` 286 | PreGeneratedReplyID string `json:"pre_generated_reply_id"` 287 | PreParentItemID string `json:"pre_parent_item_id"` 288 | Origin string `json:"origin"` 289 | OriginPageTitle string `json:"origin_page_title"` 290 | TriggerBy string `json:"trigger_by"` 291 | UseModel string `json:"use_model"` 292 | IsIncognito bool `json:"is_incognito"` 293 | UseNewMemory bool `json:"use_new_memory"` 294 | UseMemorySuggestion bool `json:"use_memory_suggestion"` 295 | } 296 | 297 | // BotData bot配置数据 298 | type BotData struct { 299 | Description string `json:"description"` 300 | LogoURL string `json:"logo_url"` 301 | Name string `json:"name"` 302 | Classification string `json:"classification"` 303 | Prompt string `json:"prompt"` 304 | Type string `json:"type"` 305 | UID string `json:"uid"` 306 | ExampleList []interface{} `json:"example_list"` 307 | ToolData BotToolData `json:"tool_data"` 308 | } 309 | 310 | // BotToolData bot工具数据 311 | type BotToolData struct { 312 | KnowledgeList []interface{} `json:"knowledge_list"` 313 | UserSkillList []interface{} `json:"user_skill_list"` 314 | SysSkillList []interface{} `json:"sys_skill_list"` 315 | UseModel string `json:"use_model"` 316 | ScheduleTaskList []interface{} `json:"schedule_task_list"` 317 | } 318 | 319 | // Custom Bot相关的URL 320 | const ( 321 | CustomBotSaveURL = "https://api.monica.im/api/custom_bot/save_bot" 322 | CustomBotPublishURL = "https://api.monica.im/api/custom_bot/publish_bot" 323 | CustomBotPinURL = "https://api.monica.im/api/custom_bot/pin_bot" 324 | CustomBotChatURL = "https://api.monica.im/api/custom_bot/preview_chat" 325 | ) 326 | 327 | // GetSupportedModels 获取支持的模型列表 328 | // 从 modelToBotMap 自动生成,确保映射表和支持列表始终一致 329 | func GetSupportedModels() []string { 330 | models := make([]string, 0, len(modelToBotMap)) 331 | for model := range modelToBotMap { 332 | models = append(models, model) 333 | } 334 | return models 335 | } 336 | 337 | // ChatGPTToMonica 将 ChatGPTRequest 转换为 MonicaRequest 338 | func ChatGPTToMonica(cfg *config.Config, chatReq openai.ChatCompletionRequest) (*MonicaRequest, error) { 339 | if len(chatReq.Messages) == 0 { 340 | return nil, fmt.Errorf("empty messages") 341 | } 342 | 343 | // 生成会话ID 344 | conversationID := fmt.Sprintf("conv:%s", uuid.New().String()) 345 | 346 | // 转换消息 347 | 348 | // 设置默认欢迎消息头,不加上就有几率去掉问题最后的十几个token,不清楚是不是bug 349 | defaultItem := Item{ 350 | ItemID: fmt.Sprintf("msg:%s", uuid.New().String()), 351 | ConversationID: conversationID, 352 | ItemType: "reply", 353 | Data: ItemContent{Type: "text", Content: "__RENDER_BOT_WELCOME_MSG__"}, 354 | } 355 | var items = make([]Item, 1, len(chatReq.Messages)) 356 | items[0] = defaultItem 357 | preItemID := defaultItem.ItemID 358 | 359 | for _, msg := range chatReq.Messages { 360 | if msg.Role == "system" { 361 | // monica不支持设置prompt,所以直接跳过 362 | continue 363 | } 364 | var msgContext string 365 | var imgUrl []*openai.ChatMessageImageURL 366 | if len(msg.MultiContent) > 0 { // 说明应该是多内容,可能是图片内容 367 | for _, content := range msg.MultiContent { 368 | switch content.Type { 369 | case "text": 370 | msgContext = content.Text 371 | case "image_url": 372 | imgUrl = append(imgUrl, content.ImageURL) 373 | } 374 | } 375 | } 376 | itemID := fmt.Sprintf("msg:%s", uuid.New().String()) 377 | itemType := "question" 378 | if msg.Role == "assistant" { 379 | itemType = "reply" 380 | } 381 | 382 | var content ItemContent 383 | if len(imgUrl) > 0 { 384 | // 为图片上传创建带超时的上下文 385 | uploadCtx, cancel := context.WithTimeout(context.Background(), ImageUploadTimeout) 386 | defer cancel() 387 | 388 | // 统计上传成功和失败数量 389 | var successCount, failureCount int64 390 | 391 | // 并发上传图片并收集结果 392 | uploadResults := lop.Map(imgUrl, func(item *openai.ChatMessageImageURL, _ int) *FileInfo { 393 | f, err := UploadBase64Image(uploadCtx, cfg, item.URL) 394 | if err != nil { 395 | atomic.AddInt64(&failureCount, 1) 396 | logger.Error("上传图片失败", 397 | zap.Error(err), 398 | zap.String("image_url", item.URL), 399 | zap.Int("total_images", len(imgUrl)), 400 | ) 401 | return nil // 返回 nil 表示失败 402 | } 403 | 404 | if f == nil { 405 | atomic.AddInt64(&failureCount, 1) 406 | logger.Warn("图片上传返回空结果", zap.String("image_url", item.URL)) 407 | return nil 408 | } 409 | 410 | atomic.AddInt64(&successCount, 1) 411 | return f 412 | }) 413 | 414 | // 过滤掉失败的上传,只保留成功的 415 | fileIfoList := make([]FileInfo, 0, len(uploadResults)) 416 | for _, result := range uploadResults { 417 | if result != nil { 418 | fileIfoList = append(fileIfoList, *result) 419 | } 420 | } 421 | 422 | // 记录上传统计信息 423 | if failureCount > 0 { 424 | logger.Warn("图片上传完成", 425 | zap.Int64("success_count", successCount), 426 | zap.Int64("failure_count", failureCount), 427 | zap.Int("total_images", len(imgUrl)), 428 | ) 429 | } else { 430 | logger.Info("所有图片上传成功", 431 | zap.Int64("success_count", successCount), 432 | zap.Int("total_images", len(imgUrl)), 433 | ) 434 | } 435 | 436 | content = ItemContent{ 437 | Type: "file_with_text", 438 | Content: msgContext, 439 | FileInfos: fileIfoList, 440 | IsIncognito: true, 441 | } 442 | } else { 443 | content = ItemContent{ 444 | Type: "text", 445 | Content: msg.Content, 446 | IsIncognito: true, 447 | } 448 | } 449 | 450 | item := Item{ 451 | ConversationID: conversationID, 452 | ItemID: itemID, 453 | ParentItemID: preItemID, 454 | ItemType: itemType, 455 | Data: content, 456 | } 457 | items = append(items, item) 458 | preItemID = itemID 459 | } 460 | 461 | // 构建请求 462 | mReq := &MonicaRequest{ 463 | TaskUID: fmt.Sprintf("task:%s", uuid.New().String()), 464 | BotUID: modelToBot(chatReq.Model), 465 | Data: DataField{ 466 | ConversationID: conversationID, 467 | Items: items, 468 | PreParentItemID: preItemID, 469 | TriggerBy: "auto", 470 | IsIncognito: true, 471 | UseModel: chatReq.Model, //TODO 好像写啥都没影响 472 | UseNewMemory: false, 473 | }, 474 | Language: "auto", 475 | TaskType: "chat", 476 | } 477 | 478 | // indent, err := json.MarshalIndent(mReq, "", " ") 479 | // if err != nil { 480 | // return nil, err 481 | // } 482 | // log.Printf("send: \n%s\n", indent) 483 | 484 | return mReq, nil 485 | } 486 | 487 | // ChatGPTToCustomBot 转换ChatGPT请求到Custom Bot请求 488 | func ChatGPTToCustomBot(cfg *config.Config, chatReq openai.ChatCompletionRequest, botUID string) (*CustomBotRequest, error) { 489 | if len(chatReq.Messages) == 0 { 490 | return nil, fmt.Errorf("empty messages") 491 | } 492 | 493 | // 修改customBot请求的模型ID 494 | chatReq.Model = changeModelToCustomBotModel(chatReq.Model) 495 | 496 | // 生成会话ID 497 | conversationID := fmt.Sprintf("conv:%s", uuid.New().String()) 498 | 499 | // 设置默认欢迎消息 500 | defaultItem := Item{ 501 | ItemID: fmt.Sprintf("msg:%s", uuid.New().String()), 502 | ConversationID: conversationID, 503 | ItemType: "reply", 504 | Data: ItemContent{Type: "text", Content: "__RENDER_BOT_WELCOME_MSG__"}, 505 | } 506 | var items = make([]Item, 1, len(chatReq.Messages)) 507 | items[0] = defaultItem 508 | preItemID := defaultItem.ItemID 509 | 510 | // 提取system消息作为prompt 511 | var systemPrompt string 512 | // 转换消息 513 | for _, msg := range chatReq.Messages { 514 | if msg.Role == "system" { 515 | // 将system消息作为prompt 516 | systemPrompt = msg.Content 517 | continue 518 | } 519 | 520 | var msgContext string 521 | var imgUrl []*openai.ChatMessageImageURL 522 | if len(msg.MultiContent) > 0 { 523 | for _, content := range msg.MultiContent { 524 | switch content.Type { 525 | case "text": 526 | msgContext = content.Text 527 | case "image_url": 528 | imgUrl = append(imgUrl, content.ImageURL) 529 | } 530 | } 531 | } 532 | 533 | itemID := fmt.Sprintf("msg:%s", uuid.New().String()) 534 | itemType := "question" 535 | if msg.Role == "assistant" { 536 | itemType = "reply" 537 | } 538 | 539 | var content ItemContent 540 | if len(imgUrl) > 0 { 541 | // 处理图片上传 542 | uploadCtx, cancel := context.WithTimeout(context.Background(), ImageUploadTimeout) 543 | defer cancel() 544 | 545 | var successCount, failureCount int64 546 | uploadResults := lop.Map(imgUrl, func(item *openai.ChatMessageImageURL, _ int) *FileInfo { 547 | f, err := UploadBase64Image(uploadCtx, cfg, item.URL) 548 | if err != nil { 549 | atomic.AddInt64(&failureCount, 1) 550 | logger.Error("上传图片失败", 551 | zap.Error(err), 552 | zap.String("image_url", item.URL), 553 | ) 554 | return nil 555 | } 556 | atomic.AddInt64(&successCount, 1) 557 | return f 558 | }) 559 | 560 | fileIfoList := make([]FileInfo, 0, len(uploadResults)) 561 | for _, result := range uploadResults { 562 | if result != nil { 563 | fileIfoList = append(fileIfoList, *result) 564 | } 565 | } 566 | 567 | content = ItemContent{ 568 | Type: "file_with_text", 569 | Content: msgContext, 570 | FileInfos: fileIfoList, 571 | IsIncognito: false, 572 | } 573 | } else { 574 | content = ItemContent{ 575 | Type: "text", 576 | Content: msg.Content, 577 | IsIncognito: false, 578 | } 579 | } 580 | 581 | item := Item{ 582 | ConversationID: conversationID, 583 | ItemID: itemID, 584 | ParentItemID: preItemID, 585 | ItemType: itemType, 586 | Data: content, 587 | } 588 | items = append(items, item) 589 | preItemID = itemID 590 | } 591 | 592 | // 生成reply ID 593 | preGeneratedReplyID := fmt.Sprintf("msg:%s", uuid.New().String()) 594 | 595 | // 构建请求 596 | customBotReq := &CustomBotRequest{ 597 | TaskUID: fmt.Sprintf("task:%s", uuid.New().String()), 598 | BotUID: botUID, 599 | Data: CustomBotData{ 600 | ConversationID: conversationID, 601 | Items: items, 602 | PreGeneratedReplyID: preGeneratedReplyID, 603 | PreParentItemID: preItemID, 604 | Origin: fmt.Sprintf("https://monica.im/bots/%s", botUID), 605 | OriginPageTitle: "Monica Bot Test", 606 | TriggerBy: "auto", 607 | UseModel: chatReq.Model, // 使用请求中的模型 608 | IsIncognito: false, 609 | UseNewMemory: true, 610 | UseMemorySuggestion: true, 611 | }, 612 | Language: "auto", 613 | Locale: "zh_CN", 614 | TaskType: "chat", 615 | BotData: BotData{ 616 | Description: "Test Bot", 617 | LogoURL: "https://assets.monica.im/assets/img/default_bot_icon.jpg", 618 | Name: "Test Bot", 619 | Classification: "custom", 620 | Prompt: systemPrompt, 621 | Type: "custom_bot", 622 | UID: botUID, 623 | ExampleList: []interface{}{}, 624 | ToolData: BotToolData{ 625 | KnowledgeList: []interface{}{}, 626 | UserSkillList: []interface{}{}, 627 | SysSkillList: []interface{}{}, 628 | UseModel: chatReq.Model, 629 | ScheduleTaskList: []interface{}{}, 630 | }, 631 | }, 632 | AIRespLanguage: "Chinese (Simplified)", 633 | } 634 | 635 | return customBotReq, nil 636 | } 637 | 638 | func changeModelToCustomBotModel(model string) string { 639 | switch model { 640 | case "grok-4": 641 | return "grok-4-0709" 642 | case "gemini-2.5-pro": 643 | return "gemini-2.5-pro-thinking" 644 | default: 645 | return model 646 | } 647 | } --------------------------------------------------------------------------------