refactor: 模型解析/价格工具拆分至 service/utils,调用方适配(WIP)
- price.go -> service/utils/media_type.go(媒体类型检测) - model_resolve.go -> service/utils/model_resolve.go(模型解析) - 删除 service/parse_error_test.go(逻辑迁往 utils 后无对应测试) - session_sync/session_stream/model_*_service/pricing_client 等调用方适配 - schema_mapping/model_call_dto 补字段路径
This commit is contained in:
@@ -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:"视频时长(秒)"`
|
||||
}
|
||||
|
||||
@@ -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:"错误消息"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 的消费方忽略该事件。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user