Files
model-gateway/service/model_session_service.go
T
19904408334 76c55fbb73 feat: 新增业务字段路径读写工具
新增 TakeBusinessFields、WriteBusinessFields、SetByPath 与 GetByPath 等工具,支持按映射路径写入请求体与解析响应,并更新相关依赖。
2026-08-18 10:11:09 +08:00

406 lines
16 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 service
import (
"context"
"encoding/json"
"fmt"
"model-gateway/consts/model"
"model-gateway/dao"
"model-gateway/model/domain"
"model-gateway/model/dto"
modelUtils "model-gateway/service/utils"
"net/http"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
var ModelSession = &modelSessionService{}
type modelSessionService struct{}
// modelCallMaxRetries 上游调用最大重试次数
const modelCallMaxRetries = 3
// CreateSession 创建会话
func (s *modelSessionService) CreateSession(ctx context.Context, req *dto.CallModelSessionReq) (res *dto.ModelCallRes, err error) {
startTime := time.Now()
attempt := 0
id := req.Id
modelInfo := req.ModelInfo
newRequestParams := req.RequestParams
LOOP:
// 6) 模型请求
modelRespBody, err := ModelHttpNormalRequest(ctx, modelInfo.BaseURL, modelInfo.RequestHeadMapping, modelInfo.HttpMethod, newRequestParams)
if err != nil {
return nil, err
}
if modelRespBody == nil {
return nil, fmt.Errorf("模型返回参数是空")
}
// 7) 上传模型返回参数文件
uploadOriginalResp, err := Upload(ctx, &dto.UploadFileBytesReq{
FileBytes: modelRespBody,
FileName: fmt.Sprintf("modelRespParams:%v.json", time.Now().UnixMilli()),
})
if err != nil {
return nil, fmt.Errorf("上传模型返回参数文件失败:%v", err)
}
// 8) 更新模型会话信息
updateModelSessionReq := dto.UpdateModelSessionReq{
Id: id,
OriginalResponsePath: uploadOriginalResp.FileURL,
}
errMsg := new(dto.ModelErrorResp)
err = gconv.Struct(modelRespBody, errMsg)
if err != nil {
return nil, fmt.Errorf("模型返回参数解析失败:%v", err)
}
docMsg := new(dto.ModelCallRes)
docMsg.TaskId = id
if errMsg.Error.Code != "" {
if attempt < modelCallMaxRetries && isRetryableErrorCode(errMsg.Error.Code) {
attempt++
wait := time.Duration(1<<attempt) * time.Second
g.Log().Warningf(ctx, "模型上游调用异常,第 %d 次重试(等待 %v): code=%s err=%v", attempt+1, wait, errMsg.Error.Code, errMsg.Error.Message)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
goto LOOP
}
docMsg.ErrorMsg = errMsg.Error.Message
updateModelSessionReq.ErrorMsg = docMsg.ErrorMsg
} else {
if *model.ResponseTypeSync.Code() == *modelInfo.ResponseType {
respBodyMap := make(map[string]string, len(modelInfo.ResponseBodyMapping))
for k, _ := range modelInfo.ResponseBodyMapping {
respBodyMap[modelUtils.CleanFieldPath(k)] = modelUtils.CleanFieldPath(k)
}
// 基于统一字段路径(GetByPath)按映射取值组装结果
var respObj map[string]any
if err = json.Unmarshal(modelRespBody, &respObj); err != nil {
return nil, fmt.Errorf("模型返回参数解析失败:%v", err)
}
content := make(map[string]any, len(respBodyMap))
for bizKey, jsonPath := range respBodyMap {
content[bizKey] = modelUtils.GetByPathValue(respObj, jsonPath)
}
businessField := make(map[string]any, len(modelInfo.ResponseBusinessFieldMapping))
for key, value := range modelInfo.ResponseBusinessFieldMapping {
businessField[key] = modelUtils.GetByPathValue(respObj, modelUtils.CleanFieldPath(value))
}
err = gconv.Struct(businessField, docMsg)
if err != nil {
return nil, fmt.Errorf("模型返回参数解析失败:%v", err)
}
docMsg.Content = content
docMsg.TotalTokens = gconv.Int64(modelUtils.GetByPathValue(respObj, modelUtils.CleanFieldPath(modelInfo.TokenMapping.TotalTokens)))
docMsg.PromptTokens = gconv.Int64(modelUtils.GetByPathValue(respObj, modelUtils.CleanFieldPath(modelInfo.TokenMapping.PromptTokens)))
docMsg.CompletionTokens = gconv.Int64(modelUtils.GetByPathValue(respObj, modelUtils.CleanFieldPath(modelInfo.TokenMapping.CompletionTokens)))
updateModelSessionReq.PromptTokens = docMsg.PromptTokens
updateModelSessionReq.CompletionTokens = docMsg.CompletionTokens
updateModelSessionReq.TotalTokens = docMsg.TotalTokens
} else {
docMsg.Content = map[string]any{
"respBody": modelRespBody,
}
}
}
if !g.IsEmpty(docMsg.Content) {
// 9) 上传模型返回参数文件
uploadNewResp, err := Upload(ctx, &dto.UploadFileBytesReq{
FileBytes: gconv.Bytes(gconv.String(docMsg.Content)),
FileName: fmt.Sprintf("modelNewRespParams:%v.json", time.Now().UnixMilli()),
})
if err != nil {
return nil, fmt.Errorf("上传模型返回参数文件失败:%v", err)
}
updateModelSessionReq.ResponsePath = uploadNewResp.FileURL
}
updateModelSessionReq.DurationSeconds = int64(time.Since(startTime).Seconds())
// 9.5) 按模型计费规则换算本次调用费用(未配置返回 0)
docMsg.Cost = CalcModelCallCost(modelInfo.PriceConfig, modelInfo.RequestBusinessFieldMapping, newRequestParams, docMsg.PromptTokens, docMsg.CompletionTokens, 0)
updateModelSessionReq.TotalCost = docMsg.Cost
// 10) 更新模型会话信息
_, err = dao.ModelSession.Update(ctx, &updateModelSessionReq)
if err != nil {
return nil, fmt.Errorf("更新模型会话信息失败: %v", err)
}
return docMsg, nil
}
// CreateSessionStreamOnce 流式调用上游模型 → 缓冲全量后一次返回(走 gf 框架正常返回)
func (s *modelSessionService) CreateSessionStreamOnce(ctx context.Context, req *dto.CallModelSessionReq) (docMsg *dto.ModelCallRes, err error) {
startTime := time.Now()
id := req.Id
modelInfo := req.ModelInfo
newRequestParams := req.RequestParams
// 获取上游流式 readerstream=false → w 不会被使用,传 nil)
streamReader, err := ModelHttpStreamRequest(ctx, nil, modelInfo.BaseURL, modelInfo.RequestHeadMapping, modelInfo.HttpMethod, newRequestParams)
if err != nil {
return nil, err
}
docMsg = new(dto.ModelCallRes)
docMsg.TaskId = id
var contentBuf strings.Builder
// 路径预处理
respMapping := make(map[string]string, len(modelInfo.ResponseBodyMapping))
for k, _ := range modelInfo.ResponseBodyMapping {
respMapping[modelUtils.CleanFieldPath(k)] = modelUtils.CleanFieldPath(k)
}
totalTokenPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.TotalTokens)
promptTokenPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.PromptTokens)
completionTokenPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.CompletionTokens)
ParseSSEStream(ctx, streamReader, func(ctx context.Context, chunk map[string]any) error {
// 基于统一字段路径(GetByPath)在分片对象上取值,取首个数组元素文本累加
for _, jsonPath := range respMapping {
v := modelUtils.GetByPathValue(chunk, jsonPath)
if v == nil || g.IsEmpty(v) {
continue
}
var realText string
if arr, ok := v.([]any); ok && len(arr) > 0 {
realText = gconv.String(arr[0])
} else {
realText = gconv.String(v)
}
realText = strings.TrimSpace(realText)
if realText == "" {
continue
}
contentBuf.WriteString(realText)
}
// Token 累加
docMsg.TotalTokens += gconv.Int64(modelUtils.GetByPathValue(chunk, totalTokenPath))
docMsg.PromptTokens += gconv.Int64(modelUtils.GetByPathValue(chunk, promptTokenPath))
docMsg.CompletionTokens += gconv.Int64(modelUtils.GetByPathValue(chunk, completionTokenPath))
return nil
})
// 流结束后组装
docMsg.Content = map[string]any{"respBody": contentBuf.String()}
// 补充更新会话记录
updateModelSessionReq := dto.UpdateModelSessionReq{
Id: id,
DurationSeconds: int64(time.Since(startTime).Seconds()),
TotalTokens: docMsg.TotalTokens,
PromptTokens: docMsg.PromptTokens,
CompletionTokens: docMsg.CompletionTokens,
}
if !g.IsEmpty(docMsg.Content) {
uploadNewResp, uploadErr := Upload(ctx, &dto.UploadFileBytesReq{
FileBytes: gconv.Bytes(gconv.String(docMsg.Content)),
FileName: fmt.Sprintf("modelNewRespParams:%v.json", time.Now().UnixMilli()),
})
if uploadErr != nil {
return nil, fmt.Errorf("上传模型返回参数文件失败:%v", uploadErr)
}
updateModelSessionReq.ResponsePath = uploadNewResp.FileURL
}
updateModelSessionReq.DurationSeconds = int64(time.Since(startTime).Seconds())
// 按模型计费规则换算本次调用费用(未配置返回 0)
docMsg.Cost = CalcModelCallCost(modelInfo.PriceConfig, modelInfo.RequestBusinessFieldMapping, newRequestParams, docMsg.PromptTokens, docMsg.CompletionTokens, 0)
updateModelSessionReq.TotalCost = docMsg.Cost
if _, updateErr := dao.ModelSession.Update(ctx, &updateModelSessionReq); updateErr != nil {
g.Log().Errorf(ctx, "更新流式会话信息失败: %v", updateErr)
}
return docMsg, nil
}
// CreateSessionStream 流式调用上游模型 → SSE 逐分片推送给前端;流结束返回本次调用的 token/费用(docMsg.Cost)供调用方扣减。
func (s *modelSessionService) CreateSessionStream(ctx context.Context, w http.ResponseWriter, req *dto.CallModelSessionReq) (*dto.ModelCallRes, error) {
startTime := time.Now()
id := req.Id
modelInfo := req.ModelInfo
newRequestParams := req.RequestParams
// 获取上游流式 reader 并设置 SSE 响应头
streamReader, err := ModelHttpStreamRequest(ctx, w, modelInfo.BaseURL, modelInfo.RequestHeadMapping, modelInfo.HttpMethod, newRequestParams)
if err != nil {
return nil, err
}
flusher := w.(http.Flusher)
docMsg := new(dto.ModelCallRes)
var contentBuf strings.Builder
// tool_calls 按 index 累加(OpenAI 兼容 delta),流末随 done 事件一次性返回
toolAcc := make(map[int]*streamToolCallAcc)
// 路径预处理
respMapping := make(map[string]string, len(modelInfo.ResponseBodyMapping))
for k, _ := range modelInfo.ResponseBodyMapping {
respMapping[modelUtils.CleanFieldPath(k)] = modelUtils.CleanFieldPath(k)
}
totalTokenPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.TotalTokens)
promptTokenPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.PromptTokens)
completionTokenPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.CompletionTokens)
// 解析 ResponseBusinessFieldMapping 字段
businessFieldRes := new(domain.ChatFieldsRes)
err = gconv.Struct(modelInfo.ResponseBusinessFieldMapping, businessFieldRes)
if err != nil {
g.Log().Errorf(ctx, "解析 ResponseBusinessFieldMapping 失败: %v", err)
}
// tool_calls 读取路径:优先走 ResponseBusinessFieldMapping 的 tools 配置,缺省兜底 OpenAI 兼容路径
//toolsPath := "choices[0].delta.tool_calls"
toolsPath := businessFieldRes.Tools
// reasoning_content 读取路径:走 ResponseBusinessFieldMapping 的 reasoning_content 配置,未配置则不返回思考内容
reasoningPath := modelUtils.CleanFieldPath(businessFieldRes.ReasoningContent)
ParseSSEStream(ctx, streamReader, func(ctx context.Context, chunk map[string]any) error {
// 基于统一字段路径(GetByPath)在分片对象上取值,取首个数组元素文本
content := make(map[string]any, len(respMapping))
for bizKey, jsonPath := range respMapping {
v := modelUtils.GetByPathValue(chunk, jsonPath)
if v == nil || g.IsEmpty(v) {
continue
}
var realText string
if arr, ok := v.([]any); ok && len(arr) > 0 {
realText = gconv.String(arr[0])
} else {
realText = gconv.String(v)
}
realText = strings.TrimSpace(realText)
if realText == "" {
continue
}
content[bizKey] = realText
contentBuf.WriteString(realText)
}
// Token 累加(记录增量:usage 常在无文本/思考的末分片出现,需据此放行推送)
prevTotal, prevPrompt, prevCompletion := docMsg.TotalTokens, docMsg.PromptTokens, docMsg.CompletionTokens
docMsg.TotalTokens += gconv.Int64(modelUtils.GetByPathValue(chunk, totalTokenPath))
docMsg.PromptTokens += gconv.Int64(modelUtils.GetByPathValue(chunk, promptTokenPath))
docMsg.CompletionTokens += gconv.Int64(modelUtils.GetByPathValue(chunk, completionTokenPath))
tokenDelta := docMsg.TotalTokens != prevTotal || docMsg.PromptTokens != prevPrompt || docMsg.CompletionTokens != prevCompletion
accumulateStreamToolCallsByPath(chunk, toolsPath, toolAcc)
// 思考内容提取(独立业务字段,不进回答全文)
var reasoningContent string
if reasoningPath != "" {
if v := modelUtils.GetByPathValue(chunk, reasoningPath); v != nil && !g.IsEmpty(v) {
if arr, ok := v.([]any); ok && len(arr) > 0 {
reasoningContent = gconv.String(arr[0])
} else {
reasoningContent = gconv.String(v)
}
}
}
// 纯 token 分片(无文本/思考)也放行:否则末分片 usage 被过滤,调用方拿不到 token 值
if len(content) == 0 && reasoningContent == "" && !tokenDelta {
return nil
}
// 逐 chunk SSE 推送给前端(字段名由 ModelCallStreamEvent 统一管理)
event := &dto.ModelCallStreamEvent{
Content: content,
ReasoningContent: reasoningContent,
TotalTokens: docMsg.TotalTokens,
PromptTokens: docMsg.PromptTokens,
CompletionTokens: docMsg.CompletionTokens,
}
outBytes, err := json.Marshal(event)
if err != nil {
g.Log().Errorf(ctx, "[SSE] marshal response failed: %v", err)
return nil
}
_, err = fmt.Fprintf(w, "data: %s\n\n", outBytes)
if err != nil {
g.Log().Errorf(ctx, "[SSE] write client failed: %v", err)
return err
}
flusher.Flush()
return nil
})
// 流结束:按模型计费规则换算本次调用费用(未配置返回 0)
docMsg.Cost = CalcModelCallCost(modelInfo.PriceConfig, modelInfo.RequestBusinessFieldMapping, newRequestParams, docMsg.PromptTokens, docMsg.CompletionTokens, 0)
// 流末 done 事件:携带该步最终 token 与费用。工具调用时附带完整 tool_calls,
// 纯文本流同样补发,使调用方拿到最终费用与 token;不识别 type=done 的消费方忽略该事件。
event := &dto.ModelCallStreamEvent{
Type: "done",
TotalTokens: docMsg.TotalTokens,
PromptTokens: docMsg.PromptTokens,
CompletionTokens: docMsg.CompletionTokens,
Cost: docMsg.Cost,
}
if tools := finalizeStreamToolCalls(toolAcc); len(tools) > 0 {
var toolModels []dto.ModelTool
if err := gconv.Structs(tools, &toolModels); err != nil {
g.Log().Errorf(ctx, "[SSE] convert tools failed: %v", err)
} else {
event.Tools = toolModels
}
}
outBytes, err := json.Marshal(event)
if err == nil {
_, _ = fmt.Fprintf(w, "data: %s\n\n", outBytes)
flusher.Flush()
}
// 流结束后补充更新会话记录
updateModelSessionReq := dto.UpdateModelSessionReq{
Id: id,
DurationSeconds: int64(time.Since(startTime).Seconds()),
TotalTokens: docMsg.TotalTokens,
PromptTokens: docMsg.PromptTokens,
CompletionTokens: docMsg.CompletionTokens,
TotalCost: docMsg.Cost,
}
if !g.IsEmpty(contentBuf.String()) {
uploadNewResp, uploadErr := Upload(ctx, &dto.UploadFileBytesReq{
FileBytes: gconv.Bytes(gconv.String(map[string]any{"respBody": contentBuf.String()})),
FileName: fmt.Sprintf("modelNewRespParams:%v.json", time.Now().UnixMilli()),
})
if uploadErr != nil {
return nil, fmt.Errorf("上传模型返回参数文件失败:%v", uploadErr)
}
updateModelSessionReq.ResponsePath = uploadNewResp.FileURL
}
updateModelSessionReq.DurationSeconds = int64(time.Since(startTime).Seconds())
if _, updateErr := dao.ModelSession.Update(ctx, &updateModelSessionReq); updateErr != nil {
return nil, fmt.Errorf("更新会话信息失败: %v", updateErr)
}
return docMsg, nil
}
// isRetryableErrorCode 判定上游返回的错误码是否可重试:限流(429/limit_requests/limit_tokens/rate_limit_exceeded)与 5xx(500-503)。
// ModelHttpNormalRequest 不返回 HTTP status,只能按响应体 error.code 字符串判定。
func isRetryableErrorCode(code string) bool {
switch code {
case "429", "500", "501", "502", "503", "limit_requests", "limit_tokens", "rate_limit_exceeded":
return true
}
return false
}