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 框架正常返回)。 // 与同步请求一致:上游返回可重试错误码(限流/5xx)时按指数退避重试(最多 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 状态/网络错误在此返回;错误含可重试错误码(限流/5xx)时按指数退避重试,与同步请求一致。 streamReader, err := httpclient.ModelHttpStreamRequest(ctx, nil, modelInfo.BaseURL, modelInfo.RequestHeadMapping, modelInfo.HttpMethod, newRequestParams) if err != nil { if retryCode := streamRetryCodeOfError(err); retryCode != "" && attempt < modelCallMaxRetries { 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 } // streamRetryCodeOfError 从流式请求错误中提取可重试错误码:优先解析错误体 error.code/顶层 code, // 其次取非 2xx 的 HTTP 状态码;纯网络错误等无错误码场景返回空串(与同步请求一致,不重试)。 func streamRetryCodeOfError(err error) string { if err == nil { return "" } msg := err.Error() // 非 2xx 时 httpclient.ModelHttpStreamRequest 返回 "[HTTP][Stream] 状态码异常: %d, body={...}" if idx := strings.Index(msg, "body="); idx >= 0 { body := msg[idx+len("body="):] var errResp struct { Error struct { Code string `json:"code"` } `json:"error"` Code string `json:"code"` } if json.Unmarshal([]byte(body), &errResp) == nil { if errResp.Error.Code != "" { return errResp.Error.Code } if errResp.Code != "" { return errResp.Code } } } if idx := strings.Index(msg, "状态码异常: "); idx >= 0 { codeStr := strings.TrimSpace(msg[idx+len("状态码异常: "):]) if comma := strings.IndexByte(codeStr, ','); comma >= 0 { codeStr = codeStr[:comma] } if isRetryableErrorCode(codeStr) { return codeStr } } return "" }