Files
team-api/relay/handler/chat_handler.go
T
fa053529af 图片请求统一异步处理 (#11)
* fix:未导入unicode包

* feat:图片异步转换

* feat:添加R2支持

* feat:新增文件管理

* fix:修复错误信息双写导致响应异常

* chore:渠道列表中添加快捷启用停止按钮

* fix:修复模型异常终端导致报错的问题

* improve:优化无可用渠道的日志打印,减少日志污染

* fix:在线体验出错缺少提示信息

* fix:修复日志库问题,只有请求日志保存到独立的库中

* feat:新增阿里图片模型说明文档

* fix:阿里千问图片生成

* fix:修复图片异步控制无效问题

* improve:优化文件展示,支持缩略图查看

* feat:实时监控中添加图片异步转换池状态信息

* improve:优化在线体验页面布局

* fix:修复异步任务状态显示异常问题

* feat:图片异步任务停止时等待任务完成后再退出程序

* chore:调整存储路径

---------

Co-authored-by: qian <wuchengqian5@163.com>
Co-authored-by: chengqian <wuchengqian@kkidc.com>
2026-07-18 00:26:13 +08:00

200 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/gogf/gf/v2/frame/g"
"github.com/qianfree/team-api/relay/common"
"github.com/qianfree/team-api/relay/constant"
"github.com/qianfree/team-api/relay/dto"
)
// HandleChatCompletions 处理 /v1/chat/completions 请求
func HandleChatCompletions(ctx context.Context, body []byte, path string, headers http.Header, rc *RelayContext, provider common.DataProvider, billing common.BillingProvider) (*common.Usage, *BillingResult, error) {
return RelayHandler(ctx, body, path, headers, rc, provider, billing)
}
// HandleEmbeddings 处理 /v1/embeddings 请求
func HandleEmbeddings(ctx context.Context, body []byte, path string, headers http.Header, rc *RelayContext, provider common.DataProvider, billing common.BillingProvider) (*common.Usage, *BillingResult, error) {
return RelayHandler(ctx, body, path, headers, rc, provider, billing)
}
// HandleImagesGenerations 处理 /v1/images/generations 请求
func HandleImagesGenerations(ctx context.Context, body []byte, path string, headers http.Header, rc *RelayContext, provider common.DataProvider, billing common.BillingProvider) (*common.Usage, *BillingResult, error) {
return RelayHandler(ctx, body, path, headers, rc, provider, billing)
}
// HandleCompletions 处理 /v1/completions 请求
func HandleCompletions(ctx context.Context, body []byte, path string, headers http.Header, rc *RelayContext, provider common.DataProvider, billing common.BillingProvider) (*common.Usage, *BillingResult, error) {
return RelayHandler(ctx, body, path, headers, rc, provider, billing)
}
// HandleResponses 处理 /v1/responses 请求(OpenAI Responses API)
func HandleResponses(ctx context.Context, body []byte, path string, headers http.Header, rc *RelayContext, provider common.DataProvider, billing common.BillingProvider) (*common.Usage, *BillingResult, error) {
return RelayHandler(ctx, body, path, headers, rc, provider, billing)
}
// HandleModels 处理 /v1/models 请求(列出可用模型)
// tenantID 用于过滤该租户有权使用的模型,apiKeyID 用于进一步按 Key 的模型范围过滤
// userID 用于进一步按成员的模型范围过滤
func HandleModels(ctx context.Context, tenantID, apiKeyID, userID int64, provider common.DataProvider) (*dto.ModelsResponse, error) {
models, err := provider.GetAvailableModels(ctx, tenantID, apiKeyID, userID)
if err != nil {
return nil, err
}
data := make([]dto.ModelDTO, 0, len(models))
for _, m := range models {
item := dto.ModelDTO{
ID: m.ModelId,
Object: "model",
Created: 0,
OwnedBy: "platform",
ModelName: m.ModelName,
Category: m.Category,
ContextWindow: m.MaxContextTokens,
MaxOutputTokens: m.MaxOutputTokens,
Capabilities: m.Capabilities,
Modalities: buildModalities(m.Category, m.Capabilities),
}
data = append(data, item)
}
return &dto.ModelsResponse{
Object: "list",
Data: data,
}, nil
}
// HandleModelDetail 处理 /v1/models/{model_id} 请求(获取单个模型详情)
func HandleModelDetail(ctx context.Context, tenantID int64, modelName string, provider common.DataProvider) (*dto.ModelDetailResponse, error) {
detail, err := provider.GetModelDetail(ctx, tenantID, modelName)
if err != nil {
return nil, err
}
return &dto.ModelDetailResponse{
ID: detail.ID,
Object: detail.Object,
Created: detail.Created,
OwnedBy: detail.OwnedBy,
ModelName: detail.ModelName,
Description: detail.Description,
Category: detail.Category,
Status: detail.Status,
ContextWindow: detail.MaxContextTokens,
MaxOutputTokens: detail.MaxOutputTokens,
Capabilities: detail.Capabilities,
Modalities: buildModalities(detail.Category, detail.Capabilities),
Deprecated: detail.Status == "deprecated",
}, nil
}
func buildModalities(category string, capabilities map[string]bool) *dto.ModelModalities {
input := []string{"text"}
output := []string{"text"}
switch category {
case "embedding":
output = []string{"embedding"}
case "image":
input = []string{"text"}
output = []string{"image"}
case "audio":
input = []string{"audio"}
output = []string{"text"}
}
if capabilities != nil {
if capabilities["vision"] {
input = appendUnique(input, "image")
}
if capabilities["audio_input"] {
input = appendUnique(input, "audio")
}
if capabilities["audio_output"] {
output = appendUnique(output, "audio")
}
if capabilities["pdf_input"] {
input = appendUnique(input, "pdf")
}
}
return &dto.ModelModalities{Input: input, Output: output}
}
func appendUnique(slice []string, item string) []string {
for _, s := range slice {
if s == item {
return slice
}
}
return append(slice, item)
}
// WriteRelayError 写入 relay 错误响应(导出,供 GoFrame handler 调用)
func WriteRelayError(w http.ResponseWriter, err error) {
// 流式中断(客户端已断开):降级为 INFO,跳过写响应
if errors.Is(err, common.ErrStreamInterrupted) {
g.Log().Infof(context.Background(), "[RelayError] Client disconnected during stream")
return
}
// adaptor 已直接写入响应体(如 Gemini 原生格式透传),跳过二次写入
var prewritten *constant.RelayError
if errors.As(err, &prewritten) && prewritten.ResponseWritten {
return
}
var relayErr *constant.RelayError
var rateLimitErr *RelayErrorWithRateLimit
statusCode := http.StatusInternalServerError
errMsg := err.Error()
errType := "internal_error"
if errors.As(err, &rateLimitErr) {
statusCode = rateLimitErr.StatusCode
errMsg = rateLimitErr.Message
errType = "rate_limit_error"
w.Header().Set("X-RateLimit-Limit", "0")
w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", rateLimitErr.Remaining))
w.Header().Set("X-RateLimit-Reset", fmt.Sprintf("%d", rateLimitErr.ResetAt))
} else if errors.As(err, &relayErr) {
statusCode = relayErr.StatusCode
errMsg = relayErr.Message
if relayErr.Cause != nil {
errMsg = relayErr.Message + ": " + relayErr.Cause.Error()
}
errType = relayErr.Type
}
if statusCode < 100 || statusCode > 599 {
statusCode = http.StatusInternalServerError
}
// 无可用渠道是正常业务条件,已在 handleChannelUnavailable 中以 Warning 记录,此处跳过避免重复日志;
// 其余 5xx 为真实错误,保留 ERROR 但禁用堆栈打印(此处调用栈固定,无调试价值)
if statusCode >= 500 && !errors.Is(err, common.ErrChannelUnavailable) {
g.Log().Stack(false).Errorf(context.Background(), "[RelayError] statusCode=%d type=%s message=%s originalError=%v",
statusCode, errType, errMsg, err)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
errBody, _ := json.Marshal(map[string]any{
"error": map[string]any{
"message": errMsg,
"type": errType,
"param": nil,
"code": nil,
},
})
_, _ = w.Write(errBody)
}