diff --git a/model/domain/schema_mapping.go b/model/domain/schema_mapping.go index e787af4..4f32fc5 100644 --- a/model/domain/schema_mapping.go +++ b/model/domain/schema_mapping.go @@ -44,3 +44,9 @@ type VideoFields struct { NegativePrompt string `json:"negative_prompt" dc:"反向提示词,描述不希望出现的内容"` CfgScale string `json:"cfg_scale" dc:"CFG 引导比例,控制对 prompt 的遵从程度"` } + +// VideoFieldsRes 视频模型业务字段映射 +// 适用于 视频模型(600) 及其子类型 +type VideoFieldsRes struct { + Duration int64 `json:"duration" dc:"视频时长(秒)"` +} diff --git a/model/dto/model_call_dto.go b/model/dto/model_call_dto.go index f33a2b8..55cb970 100644 --- a/model/dto/model_call_dto.go +++ b/model/dto/model_call_dto.go @@ -66,6 +66,7 @@ type ModelMsg struct { CompletionTokens int64 `json:"completionTokens" dc:"输出token"` Content map[string]any `json:"content" dc:"内容"` Cost float64 `json:"cost" dc:"费用(元)"` + Duration int64 `json:"duration" dc:"时长(秒)"` ErrorMsg string `json:"errorMsg" dc:"错误消息"` } diff --git a/service/model_call_service.go b/service/model_call_service.go index de45342..c4a9d02 100644 --- a/service/model_call_service.go +++ b/service/model_call_service.go @@ -34,7 +34,7 @@ func (s *modelCallService) ModelCall(ctx context.Context, req *dto.ModelCallReq) return nil, fmt.Errorf("模型不存在") } // 引用行 → 解析为系统模型配置+本人 apiKey;系统模型已删除等解析失败 → 阻塞调用 - modelInfo, err = resolveModelConfig(ctx, modelInfo) + modelInfo, err = modelUtils.ResolveModelConfig(ctx, modelInfo) if err != nil { return nil, err } @@ -47,9 +47,11 @@ func (s *modelCallService) ModelCall(ctx context.Context, req *dto.ModelCallReq) if err != nil { return } - // 调用前检查模型计价配置(shop-user-trade):未配置/未启用 → 阻塞调用(subject=解析后的系统模型 id) - if err = modelBillable(ctx, modelInfo.Id); err != nil { - return nil, err + if !g.IsEmpty(modelInfo.RefSystemModelId) { + // 调用前检查模型计价配置(shop-user-trade):未配置/未启用 → 阻塞调用(subject=解析后的系统模型 id) + if err = modelBillable(ctx, modelInfo.Id); err != nil { + return nil, err + } } err = queue(ctx, modelInfo.ModelName, userInfo.TenantId, gconv.Int64(modelInfo.MaxConcurrency), func(ctx context.Context) (err error) { if *modelInfo.ResponseType == *model.ResponseTypeSync.Code() || *modelInfo.ResponseType == *model.ResponseTypeStream.Code() { @@ -113,7 +115,7 @@ func (s *modelCallService) ModelCallStream(ctx context.Context, w http.ResponseW return fmt.Errorf("模型不存在") } // 引用行 → 解析为系统模型配置+本人 apiKey;系统模型已删除等解析失败 → 阻塞调用 - modelInfo, err = resolveModelConfig(ctx, modelInfo) + modelInfo, err = modelUtils.ResolveModelConfig(ctx, modelInfo) if err != nil { return err } @@ -126,9 +128,11 @@ func (s *modelCallService) ModelCallStream(ctx context.Context, w http.ResponseW if err != nil { return err } - // 调用前检查模型计价配置(shop-user-trade):未配置/未启用 → 阻塞调用(subject=解析后的系统模型 id) - if err = modelBillable(ctx, modelInfo.Id); err != nil { - return err + if !g.IsEmpty(modelInfo.RefSystemModelId) { + // 调用前检查模型计价配置(shop-user-trade):未配置/未启用 → 阻塞调用(subject=解析后的系统模型 id) + if err = modelBillable(ctx, modelInfo.Id); err != nil { + return err + } } err = queue(ctx, modelInfo.ModelName, userInfo.TenantId, gconv.Int64(modelInfo.MaxConcurrency), func(ctx context.Context) (err error) { var newRequestParams map[string]any @@ -201,7 +205,7 @@ func (s *modelCallService) saveModelRequestParams(ctx context.Context, now time. MsgTopic: req.MsgTopic, RequestPath: uploadNewReq.FileURL, OriginalRequestPath: uploadOriginalReq.FileURL, - MediaType: DetectMediaType(modelInfo.RequestBusinessFieldMapping, out), + MediaType: modelUtils.DetectMediaType(modelInfo.RequestBusinessFieldMapping, out), }) if err != nil { return 0, nil, fmt.Errorf("保存模型请求信息失败: %v", err) diff --git a/service/model_http_service.go b/service/model_http_service.go index 5afd78e..e5093bb 100644 --- a/service/model_http_service.go +++ b/service/model_http_service.go @@ -7,13 +7,11 @@ import ( commonHttp "gitea.redpowerfuture.com/red-future/common/http" "gitea.redpowerfuture.com/red-future/common/oss" "gitea.redpowerfuture.com/red-future/common/utils" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/util/gconv" ) // IsSuperAdmin 调用admin-go服务检查是否是管理员 func IsSuperAdmin(ctx context.Context) (res bool, err error) { - headers := setCtxHeader(ctx) + headers := utils.HeadersFromCtx(ctx, utils.HeadersOptions{ResolveToken: true}) var r = make(map[string]bool) if err = commonHttp.Get(ctx, "admin-go/api/v1/system/user/checkIsSuperAdmin", headers, &r); err != nil { return false, err @@ -35,29 +33,3 @@ func Upload(ctx context.Context, req *dto.UploadFileBytesReq) (*dto.UploadFileBy FileAddressPrefix: res.FileAddressPrefix, }, nil } - -// setCtxHeader 构造调用方请求头透传(X-User-Info 三态注入): -// 1. 透传 HTTP 请求头(含 Authorization/X-User-Info) -// 2. ctx 无请求头时,用任务体注入的 user(异步任务 Creator/TenantId)生成 X-User-Info -// 3. 仍为空时,解析调用方 token 得到用户生成 X-User-Info(直连场景归属校验) -func setCtxHeader(ctx context.Context) map[string]string { - headers := make(map[string]string) - if r := g.RequestFromCtx(ctx); r != nil { - for k, v := range r.Request.Header { - if len(v) > 0 { - headers[k] = v[0] - } - } - } - if headers["X-User-Info"] == "" { - if user := ctx.Value("user"); !g.IsNil(user) { - headers["X-User-Info"] = gconv.String(user) - } - } - if headers["X-User-Info"] == "" { - if user, err := utils.GetUserInfo(ctx); err == nil && user != nil { - headers["X-User-Info"] = gconv.String(user) - } - } - return headers -} diff --git a/service/model_manage_service.go b/service/model_manage_service.go index 7a245bc..06469f7 100644 --- a/service/model_manage_service.go +++ b/service/model_manage_service.go @@ -8,6 +8,7 @@ import ( "model-gateway/dao" "model-gateway/model/dto" "model-gateway/model/entity" + modelUtils "model-gateway/service/utils" "gitea.redpowerfuture.com/red-future/common/beans" "gitea.redpowerfuture.com/red-future/common/db/gfdb" @@ -268,7 +269,7 @@ func (s *modelManageService) Get(ctx context.Context, req *dto.GetModelManageReq return nil, e } if sys != nil { - get = mergeReferenceConfigForQuery(get, sys) + get = modelUtils.MergeReferenceConfigForQuery(get, sys) } } // 系统模型 apiKey 对非创建者脱敏(引用行/自有行仅本人可见) @@ -324,6 +325,25 @@ func (s *modelManageService) List(ctx context.Context, req *dto.ListModelManageR if err != nil { return } + // 引用行 → 合入系统模型配置返回(与 Get 展示一致;保留引用行自身 Id/SystemModel 供更新/脱敏判断) + sysCache := make(map[int64]*entity.ModelManage) + for _, row := range list { + if row.RefSystemModelId <= 0 { + continue + } + sys, ok := sysCache[row.RefSystemModelId] + if !ok { + sys, err = dao.ModelManage.GetNotTenantId(ctx, &dto.GetModelManageReq{Id: row.RefSystemModelId}) + if err != nil { + return + } + if sys == nil { + continue + } + sysCache[row.RefSystemModelId] = sys + } + *row = *modelUtils.MergeReferenceConfigForQuery(row, sys) + } // 系统模型 apiKey 对非创建者脱敏(引用行/自有行仅本人可见) for _, row := range list { if row.SystemModel != nil && *row.SystemModel && row.Creator != user.UserName { diff --git a/service/model_task_end_service.go b/service/model_task_end_service.go index 554387f..e4cbb9b 100644 --- a/service/model_task_end_service.go +++ b/service/model_task_end_service.go @@ -6,6 +6,7 @@ import ( "fmt" "model-gateway/consts/public" "model-gateway/dao" + "model-gateway/model/domain" "model-gateway/model/dto" "model-gateway/model/entity" "model-gateway/service/httpclient" @@ -199,7 +200,7 @@ func (s *modelTaskEndService) processClaimedTask(asyncCtx context.Context, item } // 引用行 → 解析为系统模型配置+本人 apiKey(轮询/计价均用系统模型) - modelInfo, err = resolveModelConfig(asyncCtx, modelInfo) + modelInfo, err = modelUtils.ResolveModelConfig(asyncCtx, modelInfo) if err != nil { g.Log().Errorf(asyncCtx, "模型配置解析失败: modelId=%d err=%v", item.ModelId, err) docMsg.ErrorMsg = fmt.Sprintf("模型配置解析失败: %v", err) @@ -281,6 +282,20 @@ LOOP: } docMsg.Content = content + // 解析 ResponseBusinessFieldMapping 字段 + businessField := make(map[string]any, len(modelInfo.ResponseBusinessFieldMapping)) + for key, value := range modelInfo.ResponseBusinessFieldMapping { + businessField[key] = modelUtils.GetByPathValue(respObj, modelUtils.CleanFieldPath(value)) + } + businessFieldRes := new(domain.VideoFieldsRes) + err = gconv.Struct(businessField, businessFieldRes) + if err != nil { + docMsg.ErrorMsg = fmt.Sprintf("解析 ResponseBusinessFieldMapping 字段失败: %v", err) + finalize() + return nil + } + docMsg.Duration = businessFieldRes.Duration + // 解析Token totalTokPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.TotalTokens) promptTokPath := modelUtils.CleanFieldPath(modelInfo.TokenMapping.PromptTokens) @@ -290,10 +305,6 @@ LOOP: docMsg.PromptTokens = gconv.Int64(modelUtils.GetByPathValue(respObj, promptTokPath)) docMsg.CompletionTokens = gconv.Int64(modelUtils.GetByPathValue(respObj, compTokPath)) - // 调 shop-user-trade 按用量算费(媒体类型取任务创建时的快照;subject=解析后的系统模型 id) - docMsg.Cost = calcModelCost(asyncCtx, modelInfo.Id, - buildModelUsage(docMsg.PromptTokens, docMsg.CompletionTokens, 0, item.MediaType, time.Since(startTime).Seconds())) - // 判断任务状态,轮询等待 statusPath := modelUtils.CleanFieldPath(modelInfo.AsyncTaskMapping.TaskStatus) status := gconv.String(modelUtils.GetByPathValue(respObj, statusPath)) @@ -301,6 +312,10 @@ LOOP: time.Sleep(10 * time.Second) goto LOOP } + + // 调 shop-user-trade 按用量算费(媒体类型取任务创建时的快照;subject=解析后的系统模型 id) + docMsg.Cost = calcModelCost(asyncCtx, modelInfo.Id, + buildModelUsage(docMsg.PromptTokens, docMsg.CompletionTokens, 0, item.MediaType, docMsg.Duration)) } // 成功或已识别出错误的终态统一落库+发布(内容组装完成/错误消息已写入 docMsg) finalize() diff --git a/service/parse_error_test.go b/service/parse_error_test.go deleted file mode 100644 index ee33c48..0000000 --- a/service/parse_error_test.go +++ /dev/null @@ -1,146 +0,0 @@ -package service - -import "testing" - -// flatMapping 扁平形态:code 配置 defaultValue=20000000 为成功码 -func flatMapping() map[string]any { - return map[string]any{ - "code": map[string]any{ - "type": "number", "value": 0, "label": "", "fieldType": "number", "isForm": false, "required": false, - "defaultValue": float64(20000000), - }, - "message": map[string]any{ - "type": "string", "value": "", "label": "", "fieldType": "string", "isForm": false, "required": false, - }, - } -} - -func TestParseModelErrorFlatDefaultValue(t *testing.T) { - mapping := flatMapping() - // 成功:code == defaultValue - code, msg, err := parseModelError([]byte(`{"code":20000000,"message":"ok"}`), mapping) - if err != nil || code != "" || msg != "" { - t.Fatalf("success(default): code=%q msg=%q err=%v", code, msg, err) - } - // 成功:code 数字零值(视为空) - code, msg, err = parseModelError([]byte(`{"code":0}`), mapping) - if err != nil || code != "" || msg != "" { - t.Fatalf("success(zero): code=%q msg=%q err=%v", code, msg, err) - } - // 错误:code != defaultValue - code, msg, err = parseModelError([]byte(`{"code":1234,"message":"boom"}`), mapping) - if err != nil || code != "1234" || msg != "boom" { - t.Fatalf("error: code=%q msg=%q err=%v", code, msg, err) - } -} - -func TestParseModelErrorNested(t *testing.T) { - mapping := map[string]any{ - "error": map[string]any{ - "type": "object", - "attrs": map[string]any{ - "code": map[string]any{"type": "string", "label": "", "value": "", "fieldType": "string", "isForm": false, "required": false}, - "message": map[string]any{"type": "string", "label": "", "value": "", "fieldType": "string", "isForm": false, "required": false}, - }, - "label": "", "isForm": false, "required": false, "fieldType": "string", - }, - } - // 错误响应 - code, msg, err := parseModelError([]byte(`{"error":{"code":"rate_limit_exceeded","message":"Too fast"}}`), mapping) - if err != nil || code != "rate_limit_exceeded" || msg != "Too fast" { - t.Fatalf("error: code=%q msg=%q err=%v", code, msg, err) - } - // 成功响应(无 error 字段 → code 提取为空) - code, msg, err = parseModelError([]byte(`{"id":"x","choices":[]}`), mapping) - if err != nil || code != "" || msg != "" { - t.Fatalf("success: code=%q msg=%q err=%v", code, msg, err) - } -} - -func TestParseModelErrorArray(t *testing.T) { - mapping := map[string]any{ - "errors": map[string]any{ - "type": "array", - "attrs": []any{map[string]any{ - "code": map[string]any{"type": "number"}, - "message": map[string]any{"type": "string"}, - }}, - }, - } - code, msg, err := parseModelError([]byte(`{"errors":[{"code":1001,"message":"err-a"}]}`), mapping) - if err != nil || code != "1001" || msg != "err-a" { - t.Fatalf("array error: code=%q msg=%q err=%v", code, msg, err) - } - code, msg, err = parseModelError([]byte(`{"errors":[]}`), mapping) - if err != nil || code != "" || msg != "" { - t.Fatalf("array success: code=%q msg=%q err=%v", code, msg, err) - } -} - -func TestParseModelErrorPlainStringMapping(t *testing.T) { - // 纯字符串路径形态:字段值直接是字段路径 - mapping := map[string]any{"code": "error.code", "message": "error.message"} - code, msg, err := parseModelError([]byte(`{"error":{"code":100,"message":"nope"}}`), mapping) - if err != nil || code != "100" || msg != "nope" { - t.Fatalf("plain mapping: code=%q msg=%q err=%v", code, msg, err) - } - code, msg, err = parseModelError([]byte(`{"data":"ok"}`), mapping) - if err != nil || code != "" || msg != "" { - t.Fatalf("plain success: code=%q msg=%q err=%v", code, msg, err) - } -} - -func TestParseModelErrorNoMapping(t *testing.T) { - // 未配置 mapping:不识别错误,一律成功(纯配置驱动) - code, msg, err := parseModelError([]byte(`{"error":{"code":"x"}}`), nil) - if err != nil || code != "" || msg != "" { - t.Fatalf("no mapping: code=%q msg=%q err=%v", code, msg, err) - } -} - -func TestParseModelErrorBadJSON(t *testing.T) { - mapping := map[string]any{"code": map[string]any{"type": "string"}} - if _, _, err := parseModelError([]byte(`{invalid`), mapping); err == nil { - t.Fatalf("bad json should err") - } -} - -func TestParseModelErrorOnlyMessage(t *testing.T) { - // 只配置 message:以 message 是否为空判定错误 - mapping := map[string]any{"message": map[string]any{"type": "string"}} - code, msg, err := parseModelError([]byte(`{"message":"something went wrong"}`), mapping) - if err != nil || code != "" || msg != "something went wrong" { - t.Fatalf("only-message error: code=%q msg=%q err=%v", code, msg, err) - } - code, msg, err = parseModelError([]byte(`{"data":"ok"}`), mapping) - if err != nil || code != "" || msg != "" { - t.Fatalf("only-message success: code=%q msg=%q err=%v", code, msg, err) - } -} - -func TestCollectErrorMapping(t *testing.T) { - // 扁平 + defaultValue - codePath, msgPath, hasDef, def := collectErrorMapping(flatMapping()) - if codePath != "code" || msgPath != "message" || !hasDef || def != float64(20000000) { - t.Fatalf("flat: code=%q msg=%q hasDef=%v def=%v", codePath, msgPath, hasDef, def) - } - // 嵌套 - codePath, msgPath, hasDef, def = collectErrorMapping(map[string]any{ - "error": map[string]any{"type": "object", "attrs": map[string]any{ - "code": map[string]any{"type": "string"}, - "message": map[string]any{"type": "string"}, - }}, - }) - if codePath != "error.code" || msgPath != "error.message" || hasDef { - t.Fatalf("nested: code=%q msg=%q hasDef=%v", codePath, msgPath, hasDef) - } - // 数组 → [*] - codePath, _, _, _ = collectErrorMapping(map[string]any{ - "errors": map[string]any{"type": "array", "attrs": []any{ - map[string]any{"code": map[string]any{"type": "number"}}, - }}, - }) - if codePath != "errors[*].code" { - t.Fatalf("array: code=%q", codePath) - } -} diff --git a/service/pricing_client.go b/service/pricing_client.go index ffd6663..12c0f60 100644 --- a/service/pricing_client.go +++ b/service/pricing_client.go @@ -5,14 +5,25 @@ import ( "fmt" commonHttp "gitea.redpowerfuture.com/red-future/common/http" + "gitea.redpowerfuture.com/red-future/common/utils" "github.com/gogf/gf/v2/frame/g" ) // ====================== shop-user-trade 计价对接(独立 module,本地 JSON 对齐) ====================== -// shopPricingConfig shop-user-trade config/get 响应(仅取启用标记;规则在 shop-user-trade 侧,model-gateway 不消费) +// shopPricingConfig shop-user-trade config/get 响应(仅取启用标记与最低余额门限;费率规则在 shop-user-trade 侧,model-gateway 不消费) type shopPricingConfig struct { - Enabled int `json:"enabled"` + Enabled int `json:"enabled"` + MinBalance float64 `json:"minBalance" dc:"门禁:调用前可用余额须>=该值(元),0=不校验"` +} + +// shopWalletAccount shop-user-trade wallet/account/get 响应 +type shopWalletAccount struct { + ID int64 `json:"id"` + UserID int64 `json:"userId"` + Balance float64 `json:"balance" dc:"可用余额(元),负值=欠费"` + Currency string `json:"currency"` + Status int `json:"status" dc:"状态:1启用 0禁用 -1冻结"` } // shopCalcFeeRes shop-user-trade /calc 响应 @@ -37,11 +48,17 @@ func mgMediaTypeToShop(mg string) string { } } +// walletURL 组装 shop-user-trade 钱包接口地址(accountController → account/controller,与 pricing 同 RouteRegister 推导规则) +func walletURL(sub string) string { + return "shop-user-trade/account/controller/" + sub +} + // modelBillable 调用前门禁:模型须在 shop-user-trade 已配置且启用计价,否则阻塞调用。 // 替换原 CheckTenantBalance(admin-go 租户余额门禁);未配置→config/get 返回错误,未启用→enabled!=1。 +// minBalance>0 时追加最低余额门禁:钱包须存在且可用余额 >= 门限(与 shop-user-trade open_order 同语义,0=不校验)。 func modelBillable(ctx context.Context, modelId int64) error { var cfg shopPricingConfig - err := commonHttp.Get(ctx, pricingURL("config/get"), setCtxHeader(ctx), &cfg, + err := commonHttp.Get(ctx, pricingURL("config/get"), utils.HeadersFromCtx(ctx, utils.HeadersOptions{ResolveToken: true}), &cfg, "subjectType", "model", "subjectId", fmt.Sprintf("%d", modelId)) if err != nil { return fmt.Errorf("模型未配置计价,无法调用: %w", err) @@ -49,11 +66,29 @@ func modelBillable(ctx context.Context, modelId int64) error { if cfg.Enabled != 1 { return fmt.Errorf("模型未启用计价,无法调用") } + if cfg.MinBalance <= 0 { + return nil + } + user, e := utils.GetUserInfo(ctx) + if e != nil || user == nil || user.Id == 0 { + return fmt.Errorf("取不到用户,无法校验最低余额") + } + var acc shopWalletAccount + if e = commonHttp.Get(ctx, walletURL("get"), utils.HeadersFromCtx(ctx, utils.HeadersOptions{ResolveToken: true}), &acc, + "userId", fmt.Sprintf("%d", user.Id)); e != nil { + return fmt.Errorf("获取钱包失败,无法校验最低余额: %w", e) + } + if acc.Status != 1 { + return fmt.Errorf("钱包不可用,无法调用") + } + if acc.Balance < cfg.MinBalance { + return fmt.Errorf("余额不足:可用余额须不低于 %.2f 元才能发起", cfg.MinBalance) + } return nil } // buildModelUsage 组装算费用量 JSON 对象(ChargeUsage 形状)。mediaType 为 model-gateway 词汇(DetectMediaType/异步快照)。 -func buildModelUsage(prompt, completion, cached int64, mediaType string, durationSec float64) map[string]any { +func buildModelUsage(prompt, completion, cached int64, mediaType string, durationSec int64) map[string]any { if durationSec < 0 { durationSec = 0 } @@ -70,7 +105,7 @@ func buildModelUsage(prompt, completion, cached int64, mediaType string, duratio // 调用前门禁已保证配置存在;此处失败(配置中途删除/网络抖动)→ 记日志返回 0,不拖垮已完成的模型调用。 func calcModelCost(ctx context.Context, modelId int64, usage map[string]any) float64 { var res shopCalcFeeRes - err := commonHttp.Post(ctx, pricingURL("calc"), setCtxHeader(ctx), &res, &struct { + err := commonHttp.Post(ctx, pricingURL("calc"), utils.HeadersFromCtx(ctx, utils.HeadersOptions{ResolveToken: true}), &res, &struct { SubjectType string `json:"subjectType"` SubjectID string `json:"subjectId"` Usage map[string]any `json:"usage"` diff --git a/service/session_stream.go b/service/session_stream.go index 750b16f..3be7528 100644 --- a/service/session_stream.go +++ b/service/session_stream.go @@ -127,9 +127,9 @@ LOOP: } updateModelSessionReq.DurationSeconds = int64(time.Since(startTime).Seconds()) // 调 shop-user-trade 按用量算费(不本地换算;调用前门禁已保证配置存在,失败→0 不阻塞) - mediaType := DetectMediaType(modelInfo.RequestBusinessFieldMapping, newRequestParams) + mediaType := modelUtils.DetectMediaType(modelInfo.RequestBusinessFieldMapping, newRequestParams) docMsg.Cost = calcModelCost(ctx, modelInfo.Id, - buildModelUsage(docMsg.PromptTokens, docMsg.CompletionTokens, 0, mediaType, time.Since(startTime).Seconds())) + buildModelUsage(docMsg.PromptTokens, docMsg.CompletionTokens, 0, mediaType, 0)) updateModelSessionReq.TotalCost = docMsg.Cost if _, updateErr := dao.ModelSession.Update(ctx, &updateModelSessionReq); updateErr != nil { g.Log().Errorf(ctx, "更新流式会话信息失败: %v", updateErr) @@ -238,9 +238,9 @@ func (s *modelSessionService) CreateSessionStream(ctx context.Context, w http.Re }) // 流结束:调 shop-user-trade 按用量算费(不本地换算;调用前门禁已保证配置存在,失败→0 不阻塞) - mediaType := DetectMediaType(modelInfo.RequestBusinessFieldMapping, newRequestParams) + mediaType := modelUtils.DetectMediaType(modelInfo.RequestBusinessFieldMapping, newRequestParams) docMsg.Cost = calcModelCost(ctx, modelInfo.Id, - buildModelUsage(docMsg.PromptTokens, docMsg.CompletionTokens, 0, mediaType, time.Since(startTime).Seconds())) + buildModelUsage(docMsg.PromptTokens, docMsg.CompletionTokens, 0, mediaType, 0)) // 流末 done 事件:携带该步最终 token 与费用。工具调用时附带完整 tool_calls, // 纯文本流同样补发,使调用方拿到最终费用与 token;不识别 type=done 的消费方忽略该事件。 diff --git a/service/session_sync.go b/service/session_sync.go index 4769821..eabb093 100644 --- a/service/session_sync.go +++ b/service/session_sync.go @@ -121,9 +121,9 @@ LOOP: } updateModelSessionReq.DurationSeconds = int64(time.Since(startTime).Seconds()) // 9.5) 调 shop-user-trade 按用量算费(不本地换算;调用前门禁已保证配置存在,失败→0 不阻塞) - mediaType := DetectMediaType(modelInfo.RequestBusinessFieldMapping, newRequestParams) + mediaType := modelUtils.DetectMediaType(modelInfo.RequestBusinessFieldMapping, newRequestParams) docMsg.Cost = calcModelCost(ctx, modelInfo.Id, - buildModelUsage(docMsg.PromptTokens, docMsg.CompletionTokens, 0, mediaType, time.Since(startTime).Seconds())) + buildModelUsage(docMsg.PromptTokens, docMsg.CompletionTokens, 0, mediaType, 0)) updateModelSessionReq.TotalCost = docMsg.Cost // 10) 更新模型会话信息 _, err = dao.ModelSession.Update(ctx, &updateModelSessionReq) diff --git a/service/price.go b/service/utils/media_type.go similarity index 82% rename from service/price.go rename to service/utils/media_type.go index 8d75bc4..57ab5bb 100644 --- a/service/price.go +++ b/service/utils/media_type.go @@ -1,8 +1,4 @@ -package service - -import ( - modelUtils "model-gateway/service/utils" -) +package utils // DetectMediaType 按模型业务字段映射从请求体推导输入媒体类型(替代硬编码的 media.type 路径): // - reference_audio 映射路径在请求体中有值 → "audio" @@ -11,7 +7,7 @@ import ( // // 判定完全由模型配置(RequestBusinessFieldMapping,业务字段名见 ChatFieldsReq/VideoFields)驱动, // 无请求结构硬编码;映射路径值即 GetByPathAll 路径(如 input.media?type=audio&url=#)。 -// 媒体类型仅供 shop-user-trade 算费用量(见 pricing_client.go mgMediaTypeToShop)。 +// 媒体类型仅供 shop-user-trade 算费用量(见 service/pricing_client.go mgMediaTypeToShop)。 func DetectMediaType(reqBizMapping map[string]string, reqParams map[string]any) string { if hasMediaValue(reqBizMapping, reqParams, "reference_audio") { return "audio" @@ -28,5 +24,5 @@ func hasMediaValue(reqBizMapping map[string]string, reqParams map[string]any, bi if path == "" { return false } - return len(modelUtils.GetByPathAll(reqParams, path)) > 0 + return len(GetByPathAll(reqParams, path)) > 0 } diff --git a/service/model_resolve.go b/service/utils/model_resolve.go similarity index 92% rename from service/model_resolve.go rename to service/utils/model_resolve.go index 1a6b2ec..2f3f1d1 100644 --- a/service/model_resolve.go +++ b/service/utils/model_resolve.go @@ -1,4 +1,4 @@ -package service +package utils import ( "context" @@ -83,6 +83,7 @@ func substituteAPIPlaceholder(m *entity.ModelManage, key string) { // 配置字段取系统行;个人字段(apiKey/enabled/chatModel)取引用行;enabled 取 AND(系统停用=引用失效)。 func mergeReferenceConfig(stub, sys *entity.ModelManage) *entity.ModelManage { out := *sys + out.RefSystemModelId = stub.RefSystemModelId // 保留引用标记,调用侧据此判断引用行门禁(Id 已覆盖为系统模型 id) out.ApiKey = stub.ApiKey if stub.Enabled != nil { out.Enabled = stub.Enabled @@ -96,11 +97,11 @@ func mergeReferenceConfig(stub, sys *entity.ModelManage) *entity.ModelManage { return &out } -// mergeReferenceConfigForQuery 管理端 Get 查询展示用:以引用行为基底,把系统行的配置列合入, +// MergeReferenceConfigForQuery 管理端 Get 查询展示用:以引用行为基底,把系统行的配置列合入, // 保留引用行自身 Id/RefSystemModelId/SystemModel/Creator/时间戳与个人字段(apiKey/enabled/chatModel)。 // 与 mergeReferenceConfig 的区别:不替换 {apiKey}(Get 非引用行也不替换,展示模板), // enabled 不做 AND(展示引用行个人开关,调用时才按系统行生效状态门禁)。 -func mergeReferenceConfigForQuery(stub, sys *entity.ModelManage) *entity.ModelManage { +func MergeReferenceConfigForQuery(stub, sys *entity.ModelManage) *entity.ModelManage { out := *stub out.BaseURL = sys.BaseURL out.HttpMethod = sys.HttpMethod @@ -124,10 +125,10 @@ func mergeReferenceConfigForQuery(stub, sys *entity.ModelManage) *entity.ModelMa return &out } -// resolveModelConfig 把请求命中的模型行解析为可执行配置: +// ResolveModelConfig 把请求命中的模型行解析为可执行配置: // 引用行 → 系统行配置 + 引用行 apiKey(Id 覆盖为系统模型 id);非引用行 → 原配置 + 自身 apiKey 替换占位。 // 引用系统模型已删除 → 报错(调用方阻塞)。 -func resolveModelConfig(ctx context.Context, m *entity.ModelManage) (*entity.ModelManage, error) { +func ResolveModelConfig(ctx context.Context, m *entity.ModelManage) (*entity.ModelManage, error) { if m == nil { return nil, nil }