Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"github.com/432539/gpt2api/internal/scheduler"
"github.com/432539/gpt2api/internal/server"
"github.com/432539/gpt2api/internal/settings"
minimaxpkg "github.com/432539/gpt2api/internal/upstream/minimax"
"github.com/432539/gpt2api/internal/usage"
"github.com/432539/gpt2api/internal/user"
"github.com/432539/gpt2api/pkg/crypto"
Expand Down Expand Up @@ -141,6 +142,23 @@ func main() {
AccSvc: accSvc,
}

// MiniMax 官方 API 客户端(可选:配置文件里 minimax.api_key 为空则跳过初始化)。
if cfg.MiniMax.APIKey != "" {
mmCli, err := minimaxpkg.New(minimaxpkg.Options{
APIKey: cfg.MiniMax.APIKey,
BaseURL: cfg.MiniMax.BaseURL,
Timeout: time.Duration(cfg.Upstream.RequestTimeoutSec) * time.Second,
})
if err != nil {
log.Warn("minimax client init failed, MiniMax models will be unavailable", zap.Error(err))
} else {
gwH.MiniMax = mmCli
log.Info("minimax client initialized")
}
} else {
log.Info("minimax.api_key not set; MiniMax models will return 503")
}

imageDAO := image.NewDAO(sqldb)
imageRunner := image.NewRunner(sched, imageDAO)
imagesH := &gateway.ImagesHandler{
Expand Down
7 changes: 7 additions & 0 deletions configs/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ upstream:
request_timeout_sec: 60
sse_read_timeout_sec: 300

# MiniMax 官方 API(可选)
# 获取 API Key:https://platform.minimaxi.com/
# 环境变量覆盖:GPT2API_MINIMAX_API_KEY
minimax:
api_key: "" # 填入后,provider=minimax 的模型将通过官方 API 调用
base_url: "" # 留空使用默认 https://api.minimax.chat/v1

# 支付(易支付)
epay:
gateway_url: ""
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ require (
github.com/google/uuid v1.6.0
github.com/jmoiron/sqlx v1.4.0
github.com/redis/go-redis/v9 v9.18.0
github.com/refraction-networking/utls v1.8.2
github.com/spf13/viper v1.21.0
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.50.0
golang.org/x/net v0.52.0
)

require (
Expand Down Expand Up @@ -42,7 +44,6 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/refraction-networking/utls v1.8.2 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
Expand All @@ -56,7 +57,6 @@ require (
go.uber.org/multierr v1.10.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
Expand Down
12 changes: 12 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type Config struct {
Security SecurityConfig `mapstructure:"security"`
Scheduler SchedulerConfig `mapstructure:"scheduler"`
Upstream UpstreamConfig `mapstructure:"upstream"`
MiniMax MiniMaxConfig `mapstructure:"minimax"`
EPay EPayConfig `mapstructure:"epay"`
Backup BackupConfig `mapstructure:"backup"`
SMTP SMTPConfig `mapstructure:"smtp"`
Expand Down Expand Up @@ -80,6 +81,17 @@ type UpstreamConfig struct {
SSEReadTimeoutSec int `mapstructure:"sse_read_timeout_sec"`
}

// MiniMaxConfig MiniMax 官方 API 配置。
// APIKey 为空时 MiniMax 模型路由会返回 503。
type MiniMaxConfig struct {
// APIKey MiniMax 平台 API Key(Bearer Token)。
// 环境变量:GPT2API_MINIMAX_API_KEY
APIKey string `mapstructure:"api_key"`
// BaseURL 覆盖默认 API 地址,留空使用 https://api.minimax.chat/v1
// 可用于私有化部署或代理测试。
BaseURL string `mapstructure:"base_url"`
}

// BackupConfig 数据库备份配置。
type BackupConfig struct {
Dir string `mapstructure:"dir"` // 备份落盘目录,默认 /app/data/backups
Expand Down
256 changes: 255 additions & 1 deletion internal/gateway/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"github.com/432539/gpt2api/internal/ratelimit"
"github.com/432539/gpt2api/internal/scheduler"
"github.com/432539/gpt2api/internal/upstream/chatgpt"
"github.com/432539/gpt2api/internal/upstream/minimax"
"github.com/432539/gpt2api/internal/usage"
"github.com/432539/gpt2api/internal/user"
"github.com/432539/gpt2api/pkg/logger"
Expand All @@ -49,6 +50,9 @@ type Handler struct {
// Images 可选:若挂载,chat/completions 里指定图像模型会自动转派。
Images *ImagesHandler

// MiniMax 可选:若注入则 provider=minimax 的模型走 MiniMax 官方 API。
MiniMax *minimax.Client

// Settings 可选:若注入则在构造上游 client 时应用动态超时。
Settings interface {
GatewayUpstreamTimeoutSec() int
Expand Down Expand Up @@ -161,6 +165,11 @@ func (h *Handler) ChatCompletions(c *gin.Context) {
h.Images.handleChatAsImage(c, rec, ak, m, &req, startAt)
return
}
// MiniMax provider:直接调官方 API,不经过 chatgpt.com 账号调度器。
if m.Provider == modelpkg.ProviderMiniMax {
h.handleMiniMaxChat(c, rec, ak, m, &req, startAt)
return
}
rec.ModelID = m.ID

// 2) 分组倍率 + RPM/TPM
Expand Down Expand Up @@ -642,12 +651,257 @@ func (h *Handler) ListModels(c *gin.Context) {
}
data := make([]gin.H, 0, len(list))
for _, m := range list {
ownedBy := "chatgpt"
if m.Provider != "" {
ownedBy = m.Provider
}
data = append(data, gin.H{
"id": m.Slug,
"object": "model",
"created": m.CreatedAt.Unix(),
"owned_by": "chatgpt",
"owned_by": ownedBy,
})
}
c.JSON(http.StatusOK, gin.H{"object": "list", "data": data})
}

// handleMiniMaxChat 处理 provider=minimax 的文字对话请求。
//
// 与 chatgpt 路径的主要区别:
// - 不需要账号调度器,直接用 MiniMax 官方 API Key;
// - SSE 格式是标准 OpenAI delta chunk,而非 chatgpt.com 的 JSON-Patch 格式;
// - 计费按实际 tokens(从响应体拿到)结算;
// - 没有"免费账号降级"等逻辑。
func (h *Handler) handleMiniMaxChat(c *gin.Context, rec *usage.Log, ak *apikey.APIKey,
m *modelpkg.Model, req *ChatCompletionsRequest, startAt time.Time) {

rec.ModelID = m.ID

if h.MiniMax == nil {
rec.Status = usage.StatusFailed
rec.ErrorCode = "minimax_not_configured"
openAIError(c, http.StatusServiceUnavailable, "minimax_not_configured",
"MiniMax API 未配置,请在 config.yaml 的 minimax.api_key 中填入 API Key")
return
}

refID := uuid.NewString()

// 倍率 + RPM
ratio := 1.0
rpmCap, tpmCap := ak.RPM, ak.TPM
if h.Groups != nil {
if g, err := h.Groups.OfUser(c.Request.Context(), ak.UserID); err == nil && g != nil {
ratio = g.Ratio
if rpmCap == 0 {
rpmCap = g.RPMLimit
}
if tpmCap == 0 {
tpmCap = g.TPMLimit
}
}
}
if h.Limiter != nil {
if ok, _, err := h.Limiter.AllowRPM(c.Request.Context(), ak.ID, rpmCap); err == nil && !ok {
rec.Status = usage.StatusFailed
rec.ErrorCode = "rate_limit_rpm"
openAIError(c, http.StatusTooManyRequests, "rate_limit_rpm",
"触发每分钟请求数限制 (RPM),请稍后再试")
return
}
}

// 预扣
promptTokens := roughEstimateTokens(req.Messages)
estTokens := req.MaxTokens
if estTokens <= 0 {
estTokens = 2048
}
estCost := billing.EstimateChat(m, promptTokens, estTokens, ratio)

if h.Limiter != nil {
if ok, _, err := h.Limiter.AllowTPM(c.Request.Context(), ak.ID, tpmCap,
int64(promptTokens+estTokens)); err == nil && !ok {
rec.Status = usage.StatusFailed
rec.ErrorCode = "rate_limit_tpm"
openAIError(c, http.StatusTooManyRequests, "rate_limit_tpm",
"触发每分钟 Token 限制 (TPM),请稍后再试")
return
}
}

if err := h.Billing.PreDeduct(c.Request.Context(), ak.UserID, ak.ID, estCost, refID, "minimax chat prepay"); err != nil {
rec.Status = usage.StatusFailed
if errors.Is(err, billing.ErrInsufficient) {
rec.ErrorCode = "insufficient_balance"
openAIError(c, http.StatusPaymentRequired, "insufficient_balance",
"积分不足,请前往「账单与充值」充值后再试")
return
}
rec.ErrorCode = "billing_error"
openAIError(c, http.StatusInternalServerError, "billing_error", "计费异常:"+err.Error())
return
}

refunded := false
refund := func(code string) {
rec.Status = usage.StatusFailed
rec.ErrorCode = code
if refunded {
return
}
refunded = true
_ = h.Billing.Refund(context.Background(), ak.UserID, ak.ID, estCost, refID, "minimax chat refund")
}

upstreamModel := m.UpstreamModelSlug
if upstreamModel == "" {
upstreamModel = m.Slug
}

id := "chatcmpl-" + uuid.NewString()

if req.Stream {
stream, err := h.MiniMax.StreamChat(c.Request.Context(), upstreamModel, req.Messages, req.MaxTokens)
if err != nil {
refund("upstream_error")
openAIError(c, http.StatusBadGateway, "upstream_error", "MiniMax 请求失败:"+err.Error())
return
}
h.streamMiniMax(c, id, req.Model, stream)
} else {
content, promptTok, completionTok, err := h.MiniMax.Chat(c.Request.Context(), upstreamModel, req.Messages, req.MaxTokens)
if err != nil {
refund("upstream_error")
openAIError(c, http.StatusBadGateway, "upstream_error", "MiniMax 请求失败:"+err.Error())
return
}
if promptTok > 0 {
promptTokens = promptTok
}
completionTokens := completionTok
if completionTokens == 0 {
completionTokens = (len(content) + 3) / 4
}

actual := billing.ComputeChatCost(m, promptTokens, completionTokens, ratio)
if err := h.Billing.Settle(context.Background(), ak.UserID, ak.ID, estCost, actual, refID, "minimax chat settle"); err != nil {
logger.L().Error("minimax billing settle", zap.Error(err), zap.String("ref", refID))
}
_ = h.Keys.DAO().TouchUsage(context.Background(), ak.ID, c.ClientIP(), actual)

rec.Status = usage.StatusSuccess
rec.InputTokens = promptTokens
rec.OutputTokens = completionTokens
rec.CreditCost = actual
rec.DurationMs = int(time.Since(startAt).Milliseconds())

resp := ChatCompletionResponse{
ID: id,
Object: "chat.completion",
Created: time.Now().Unix(),
Model: req.Model,
Choices: []ChatCompletionChoice{{
Index: 0,
Message: chatgpt.ChatMessage{Role: "assistant", Content: content},
FinishReason: "stop",
}},
Usage: ChatCompletionUsage{
PromptTokens: promptTokens,
CompletionTokens: completionTokens,
TotalTokens: promptTokens + completionTokens,
},
}
c.JSON(http.StatusOK, resp)
return
}

// 流式时结算(从 ctx 里读 completion_tokens)
completionTokens := h.lastCompletionTokens(c)
actual := billing.ComputeChatCost(m, promptTokens, completionTokens, ratio)
if err := h.Billing.Settle(context.Background(), ak.UserID, ak.ID, estCost, actual, refID, "minimax chat settle"); err != nil {
logger.L().Error("minimax billing settle", zap.Error(err), zap.String("ref", refID))
}
_ = h.Keys.DAO().TouchUsage(context.Background(), ak.ID, c.ClientIP(), actual)
if h.Limiter != nil {
real := int64(promptTokens + completionTokens)
est := int64(promptTokens + estTokens)
if diff := real - est; diff > 0 {
h.Limiter.AdjustTPM(context.Background(), ak.ID, tpmCap, diff)
}
}

rec.Status = usage.StatusSuccess
rec.InputTokens = promptTokens
rec.OutputTokens = completionTokens
rec.CreditCost = actual
}

// streamMiniMax 将 MiniMax 标准 OpenAI-format SSE 转为 OpenAI 流式响应。
//
// MiniMax 使用标准格式:
//
// data: {"choices":[{"delta":{"content":"..."},"finish_reason":null}]}
// data: {"choices":[{"delta":{},"finish_reason":"stop"}]}
// data: [DONE]
func (h *Handler) streamMiniMax(c *gin.Context, id, model string, stream <-chan chatgpt.SSEEvent) {
w := c.Writer
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)

writeChunk(w, flusher, id, model, DeltaMsg{Role: "assistant"}, nil)

var total strings.Builder

for ev := range stream {
if ev.Err != nil {
logger.L().Warn("minimax stream err", zap.Error(ev.Err))
break
}
if len(ev.Data) == 0 {
continue
}
delta, done := extractMiniMaxDelta(ev.Data)
if delta != "" {
total.WriteString(delta)
writeChunk(w, flusher, id, model, DeltaMsg{Content: delta}, nil)
}
if done {
break
}
}

stop := "stop"
writeChunk(w, flusher, id, model, DeltaMsg{}, &stop)
fmt.Fprintf(w, "data: [DONE]\n\n")
if flusher != nil {
flusher.Flush()
}
c.Set("completion_tokens", (total.Len()+3)/4)
}

// extractMiniMaxDelta 从标准 OpenAI delta chunk 中提取增量文本。
// 返回 (增量内容, 是否结束)。
func extractMiniMaxDelta(data []byte) (string, bool) {
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
}
if err := json.Unmarshal(data, &chunk); err != nil {
return "", false
}
if len(chunk.Choices) == 0 {
return "", false
}
choice := chunk.Choices[0]
done := choice.FinishReason != nil && *choice.FinishReason == "stop"
return choice.Delta.Content, done
}
Loading