package service import ( "context" "encoding/json" "fmt" "model-gateway/dao" "model-gateway/model/domain" "model-gateway/model/dto" "model-gateway/service/httpclient" modelUtils "model-gateway/service/utils" "net/http" "strings" "time" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/util/gconv" ) // CreateSessionStreamOnce 流式调用上游模型 → 缓冲全量后一次返回(走 gf 框架正常返回)。 // 与同步请求一致:上游返回错误时按 shouldRetryWithMemory 判定是否指数退避重试(最多 modelCallMaxRetries 次)。 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 attempt := 0 LOOP: // 获取上游流式 reader(stream=false → w 不会被使用,传 nil)。 // 非 2xx 状态/网络错误在此返回;按 shouldRetryWithMemory 判定是否指数退避重试,与同步请求一致。 streamReader, err := httpclient.ModelHttpStreamRequest(ctx, nil, modelInfo.BaseURL, modelInfo.RequestHeadMapping, modelInfo.HttpMethod, newRequestParams) if err != nil { if attempt < modelCallMaxRetries { if code, msg := streamErrorInfoOfError(err); shouldRetryWithMemory(ctx, modelInfo, code, msg, "") { attempt++ wait := time.Duration(1< 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 } // recordSessionError 请求建立前失败(上游不可达/非 2xx 且非重试/重试耗尽/调用取消)时, // 把错误与耗时写入模型会话记录,避免流式调用留半截无错误信息记录。 // 仅写 ErrorMsg/DurationSeconds(OmitEmpty 不会影响已落库字段);ctx 已取消时须传 WithoutCancel(ctx)。 func recordSessionError(ctx context.Context, id int64, startTime time.Time, errMsg string) { if _, updateErr := dao.ModelSession.Update(ctx, &dto.UpdateModelSessionReq{ Id: id, DurationSeconds: int64(time.Since(startTime).Seconds()), ErrorMsg: errMsg, }); updateErr != nil { g.Log().Errorf(ctx, "更新模型会话错误信息失败: %v", updateErr) } } // streamErrorOfChunk 从流式分片提取错误码与消息:优先 OpenAI 兼容 error 事件,顶层 code 兜底。 func streamErrorOfChunk(chunk map[string]any) (code, msg string) { if errObj := gconv.Map(chunk["error"]); errObj != nil { code = gconv.String(errObj["code"]) msg = gconv.String(errObj["message"]) } if code == "" { code = gconv.String(chunk["code"]) } return } // streamErrorInfoOfError 从流式请求错误中提取错误码与消息(不做固定清单过滤,交由 shouldRetryWithMemory 判定): // 优先解析错误体 error.code/顶层 code 与 message,其次取非 2xx 的 HTTP 状态码。 // 纯网络错误等无错误码场景返回空串。 func streamErrorInfoOfError(err error) (code, msg string) { if err == nil { return "", "" } e := err.Error() // 非 2xx 时 httpclient.ModelHttpStreamRequest 返回 "[HTTP][Stream] 状态码异常: %d, body={...}" if idx := strings.Index(e, "body="); idx >= 0 { body := e[idx+len("body="):] var errResp struct { Error struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` Code string `json:"code"` Message string `json:"message"` } if json.Unmarshal([]byte(body), &errResp) == nil { if errResp.Error.Code != "" { return errResp.Error.Code, errResp.Error.Message } if errResp.Code != "" { return errResp.Code, errResp.Message } } } if idx := strings.Index(e, "状态码异常: "); idx >= 0 { codeStr := strings.TrimSpace(e[idx+len("状态码异常: "):]) if comma := strings.IndexByte(codeStr, ','); comma >= 0 { codeStr = codeStr[:comma] } return codeStr, "" } return "", "" }