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 = 15 // 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 }) // 流内返回可重试错误码:丢弃本次部分内容,指数退避后重新请求 if streamErrCode != "" { if attempt < modelCallMaxRetries && isRetryableErrorCode(streamErrCode) { attempt++ wait := time.Duration(1< 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 } // 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) } } // 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", "InvalidParameter", "limit_requests", "limit_tokens", "rate_limit_exceeded": return true } return false } // 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 时 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 "" }