├── 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 |