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< 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 }