diff --git a/Dockerfile b/Dockerfile index 3b881c1..012969b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,8 @@ # 阶段1: 构建 FROM golang:alpine AS builder -RUN apk add --no-cache git ca-certificates tzdata +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \ + apk add --no-cache git ca-certificates tzdata ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone diff --git a/common/util/billing.go b/common/util/billing.go index 1e8eb71..4d56344 100644 --- a/common/util/billing.go +++ b/common/util/billing.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "math" - "model-gateway/service/gateway" "strings" "github.com/gogf/gf/v2/encoding/gjson" @@ -22,6 +21,8 @@ func CalculateBilling(config map[string]any, billingData map[string]any) map[str return calculateInferenceTierBilling(config, billingData) case "video_resolution": //视频模型计费 return calculateVideoResolutionBilling(config, billingData) + case "tts": + return calculateTTSBilling(config, billingData) } return nil } @@ -33,7 +34,6 @@ func calculateInferenceTierBilling(config map[string]any, data map[string]any) m completionTokens := gconv.Int64(data["completion_tokens"]) hasAudio := gconv.Bool(data["has_audio"]) inputK := promptTokens / 1000 - tiers := config["pricing"].(map[string]any)["tiers"].([]any) var matched map[string]any for _, t := range tiers { @@ -54,7 +54,6 @@ func calculateInferenceTierBilling(config map[string]any, data map[string]any) m inputPrice = gconv.Float64(matched["input_price"]) } outputPrice := gconv.Float64(matched["output_price"]) - inputCost := float64(promptTokens) * inputPrice / 1000000 outputCost := float64(completionTokens) * outputPrice / 1000000 @@ -116,6 +115,22 @@ func calculateVideoResolutionBilling(config map[string]any, data map[string]any) } } +func calculateTTSBilling(config map[string]any, data map[string]any) map[string]any { + usage := gconv.Float64(data["synthesize_text_length"]) + unitPrice := gconv.Float64(config["pricing"]) + totalFee := usage * unitPrice + + return map[string]any{ + "model_name": data["model_name"], + "total_tokens": int64(usage), + "total_fee": totalFee, + // 明细 + "prompt_tokens": 0, + "completion_tokens": int64(usage), + "unit_price": unitPrice, + } +} + // ======================== 数据提取 ======================== func ExtractRequestBilling(ctx context.Context, config map[string]any, requestPayload map[string]any) map[string]any { @@ -124,7 +139,15 @@ func ExtractRequestBilling(ctx context.Context, config map[string]any, requestPa data := make(map[string]any) for key, path := range fields { - data[key] = extractValue(requestPayload, gconv.String(path)) + val := extractValue(requestPayload, gconv.String(path)) + // TTS 模型:input_chars 自动计算字符数 + if key == "input_chars" { + if s, ok := val.(string); ok { + data[key] = len([]rune(s)) + continue + } + } + data[key] = val } if defaults, ok := config["defaults"].(map[string]any); ok { @@ -135,29 +158,28 @@ func ExtractRequestBilling(ctx context.Context, config map[string]any, requestPa } } - // 处理 compute 字段 - if compute, ok := config["compute"].(map[string]any); ok { - for targetField, rule := range compute { - r := rule.(map[string]any) - dependsOn := gconv.String(r["depends_on"]) - dependsValue := gconv.Bool(r["depends_value"]) - - if gconv.Bool(data[dependsOn]) != dependsValue { - continue - } - - switch r["service"] { - case "video_duration": - urls := extractVideoUrls(requestPayload) - if len(urls) > 0 { - resp, err := gateway.GetVideoDuration(ctx, urls) - if err == nil { - data[targetField] = resp.TotalDuration - } - } - } - } - } + //if compute, ok := config["compute"].(map[string]any); ok { + // for targetField, rule := range compute { + // r := rule.(map[string]any) + // dependsOn := gconv.String(r["depends_on"]) + // dependsValue := gconv.Bool(r["depends_value"]) + // + // if gconv.Bool(data[dependsOn]) != dependsValue { + // continue + // } + // + // switch r["service"] { + // case "video_duration": + // urls := extractVideoUrls(requestPayload) + // if len(urls) > 0 { + // resp, err := gateway.GetVideoDuration(ctx, urls) + // if err == nil { + // data[targetField] = resp.TotalDuration + // } + // } + // } + // } + //} return data } diff --git a/common/util/mapping.go b/common/util/mapping.go index ee61f21..00e2856 100644 --- a/common/util/mapping.go +++ b/common/util/mapping.go @@ -33,24 +33,45 @@ func ParseAndValidate(raw map[string]any, requiredFields []string) (map[string]a return r }, contentStr) - var arr []any - if err := json.Unmarshal([]byte(contentStr), &arr); err != nil { + // 第一步:先解析为通用 interface{},判断是对象还是数组 + var data any + if err := json.Unmarshal([]byte(contentStr), &data); err != nil { return raw, fmt.Errorf("JSON解析失败: %w", err) } - if len(arr) == 0 { - return raw, fmt.Errorf("解析后数组为空") + + var arr []any + switch val := data.(type) { + case []any: + // 本身就是数组,直接赋值 + arr = val + case map[string]any: + // 单个对象,包装成单元素数组,统一后续逻辑 + arr = []any{val} + default: + return raw, fmt.Errorf("不支持的JSON类型,仅允许对象/数组") } + if len(arr) == 0 { + return raw, fmt.Errorf("解析后数据数组为空") + } + + // 校验每一项的必填字段 for _, field := range requiredFields { - for i, r := range arr { - round, _ := r.(map[string]any) - if round != nil && gjson.New(round).Get(field).IsNil() { + for i, item := range arr { + itemMap, ok := item.(map[string]any) + if !ok { + return raw, fmt.Errorf("rounds[%d] 不是合法JSON对象", i) + } + if gjson.New(itemMap).Get(field).IsNil() { return raw, fmt.Errorf("rounds[%d] 缺少必填字段: %s", i, field) } } } - return map[string]any{"total_rounds": len(arr), "rounds": arr}, nil + return map[string]any{ + "total_rounds": len(arr), + "rounds": arr, + }, nil } // ParseStructResult 解析结构结果 @@ -263,10 +284,17 @@ func PullTaskResult(ctx context.Context, body map[string]any, queryConfig map[st } if matchStatus(statusStr, statusValues["failed"]) { - g.Log().Errorf(ctx, "[PullTaskResult] 任务失败 taskID=%s", taskID) - return result, fmt.Errorf("任务失败") + errMsg := gconv.String(gjson.New(result).Get("error.message").Val()) + if errMsg == "" { + errMsg = gconv.String(gjson.New(result).Get("error").Val()) + } + if errMsg == "" { + rawBytes, _ := json.Marshal(result) + errMsg = string(rawBytes) + } + g.Log().Errorf(ctx, "[PullTaskResult] 任务失败 taskID=%s err=%s", taskID, errMsg) + return result, fmt.Errorf("任务失败: %s", errMsg) } - time.Sleep(time.Duration(interval) * time.Second) } } diff --git a/consts/model/model_supplier.go b/consts/model/model_supplier.go new file mode 100644 index 0000000..5dd2545 --- /dev/null +++ b/consts/model/model_supplier.go @@ -0,0 +1,108 @@ +package model + +import ( + "model-gateway/consts/public" + + "github.com/gogf/gf/v2/util/gconv" +) + +// 供应商编码常量 +const ( + SupplierAliyun = 1 + SupplierVolcengine = 2 + SupplierTencent = 3 + SupplierHuawei = 4 + SupplierBaidu = 5 + SupplierOpenAI = 6 + SupplierAzure = 7 + SupplierAWS = 8 + SupplierGoogle = 9 + SupplierDeepSeek = 10 + SupplierMoonshot = 11 + SupplierZhipu = 12 + SupplierBaichuan = 13 + SupplierMinimax = 14 + SupplierXunfei = 15 + SupplierOthers = 16 +) + +// SupplierType 供应商编码类型 +type SupplierType *int8 + +// SupplierItem 供应商项 +type SupplierItem struct { + Code SupplierType `json:"code"` + Desc string `json:"desc"` +} + +// 名称映射【唯一文案维护】 +var supplierNameMap = map[int]string{ + SupplierAliyun: "阿里云百炼", + SupplierVolcengine: "火山引擎", + SupplierTencent: "腾讯云", + SupplierHuawei: "华为云", + SupplierBaidu: "百度智能云", + SupplierOpenAI: "OpenAI", + SupplierAzure: "Azure OpenAI", + SupplierAWS: "AWS Bedrock", + SupplierGoogle: "Google Cloud", + SupplierDeepSeek: "DeepSeek", + SupplierMoonshot: "Moonshot", + SupplierZhipu: "智谱AI", + SupplierBaichuan: "百川智能", + SupplierMinimax: "MiniMax", + SupplierXunfei: "科大讯飞", + SupplierOthers: "其他", +} + +// 供应商展示顺序 +var supplierOrder = []int{ + SupplierAliyun, SupplierVolcengine, SupplierTencent, SupplierHuawei, SupplierBaidu, + SupplierOpenAI, SupplierAzure, SupplierAWS, SupplierGoogle, SupplierDeepSeek, + SupplierMoonshot, SupplierZhipu, SupplierBaichuan, SupplierMinimax, SupplierXunfei, SupplierOthers, +} + +// 全局供应商实例 +var ( + SupplierItemAliyun = newSupplierItem(gconv.PtrInt8(SupplierAliyun)) + SupplierItemVolcengine = newSupplierItem(gconv.PtrInt8(SupplierVolcengine)) + SupplierItemTencent = newSupplierItem(gconv.PtrInt8(SupplierTencent)) + SupplierItemHuawei = newSupplierItem(gconv.PtrInt8(SupplierHuawei)) + SupplierItemBaidu = newSupplierItem(gconv.PtrInt8(SupplierBaidu)) + SupplierItemOpenAI = newSupplierItem(gconv.PtrInt8(SupplierOpenAI)) + SupplierItemAzure = newSupplierItem(gconv.PtrInt8(SupplierAzure)) + SupplierItemAWS = newSupplierItem(gconv.PtrInt8(SupplierAWS)) + SupplierItemGoogle = newSupplierItem(gconv.PtrInt8(SupplierGoogle)) + SupplierItemDeepSeek = newSupplierItem(gconv.PtrInt8(SupplierDeepSeek)) + SupplierItemMoonshot = newSupplierItem(gconv.PtrInt8(SupplierMoonshot)) + SupplierItemZhipu = newSupplierItem(gconv.PtrInt8(SupplierZhipu)) + SupplierItemBaichuan = newSupplierItem(gconv.PtrInt8(SupplierBaichuan)) + SupplierItemMinimax = newSupplierItem(gconv.PtrInt8(SupplierMinimax)) + SupplierItemXunfei = newSupplierItem(gconv.PtrInt8(SupplierXunfei)) + SupplierItemOthers = newSupplierItem(gconv.PtrInt8(SupplierOthers)) +) + +func newSupplierItem(code SupplierType) SupplierItem { + val := int(*code) + return SupplierItem{ + Code: code, + Desc: supplierNameMap[val], + } +} + +// GetSupplierDescByCode 根据编码获取供应商名称 +func GetSupplierDescByCode(code int) string { + return supplierNameMap[code] +} + +// GetSupplierOptionList 获取供应商下拉列表 +func GetSupplierOptionList() []*public.Option { + var list []*public.Option + for _, code := range supplierOrder { + list = append(list, &public.Option{ + Value: code, + Label: supplierNameMap[code], + }) + } + return list +} diff --git a/consts/model/model_type.go b/consts/model/model_type.go new file mode 100644 index 0000000..8f0cfe9 --- /dev/null +++ b/consts/model/model_type.go @@ -0,0 +1,186 @@ +package model + +import ( + "model-gateway/consts/public" + + "github.com/gogf/gf/v2/util/gconv" +) + +// 模型类型编码常量 +const ( + TypeInference = 100 // 推理模型 + TypeImage = 200 // 图片模型 + TypeAudio = 300 // 音频模型 + TypeVector = 400 // 向量化模型 + TypeOmni = 500 // 全模态模型 + TypeVideo = 600 // 视频模型 + + // 图片子类型 + ImageSubTextToImage = 201 + ImageSubImageToImage = 202 + ImageSubImageEdit = 203 + ImageSubImageVariation = 204 + ImageSubImageTextToImage = 205 + + // 音频子类型 + AudioSubTextToSpeech = 301 + AudioSubSpeechToText = 302 + AudioSubSpeechToSpeech = 303 + + // 向量化子类型 + VectorSubEmbedding = 401 + VectorSubRerank = 402 + + // 全模态子类型 + OmniSubTextImageAudio = 501 + OmniSubVision = 502 + + // 视频子类型 + VideoSubTextToVideo = 601 + VideoSubImageToVideo = 602 + VideoSubImageTextToVideo = 603 + VideoSubVideoToVideo = 604 +) + +// ModelType 编码类型 +type ModelType *int + +// ModelTypeItem 模型类型项 +type ModelTypeItem struct { + Code ModelType `json:"code"` + Desc string `json:"desc"` +} + +// TypeTree 树形结构 +type TypeTree struct { + Value int `json:"value"` + Label string `json:"label"` + Children []*public.Option `json:"children"` +} + +// 名称映射表【唯一文案维护入口】 +var typeNameMap = map[int]string{ + TypeInference: "推理模型", + TypeImage: "图片模型", + TypeAudio: "音频模型", + TypeVector: "向量化模型", + TypeOmni: "全模态模型", + TypeVideo: "视频模型", + + ImageSubTextToImage: "文生图", + ImageSubImageToImage: "图生图", + ImageSubImageEdit: "图片编辑", + ImageSubImageVariation: "图片变体", + ImageSubImageTextToImage: "图文生图", + + AudioSubTextToSpeech: "文生音", + AudioSubSpeechToText: "音生文", + AudioSubSpeechToSpeech: "音生音", + + VectorSubEmbedding: "文本嵌入", + VectorSubRerank: "重排序", + + OmniSubTextImageAudio: "文图音", + OmniSubVision: "视觉理解", + + VideoSubTextToVideo: "文生视频", + VideoSubImageToVideo: "图生视频", + VideoSubImageTextToVideo: "图文生视频", + VideoSubVideoToVideo: "视频生视频", +} + +// 父子级映射(仅存有子项的分类) +var parentChildMap = map[int][]int{ + TypeImage: {ImageSubTextToImage, ImageSubImageToImage, ImageSubImageEdit, ImageSubImageVariation, ImageSubImageTextToImage}, + TypeAudio: {AudioSubTextToSpeech, AudioSubSpeechToText, AudioSubSpeechToSpeech}, + TypeVector: {VectorSubEmbedding, VectorSubRerank}, + TypeOmni: {OmniSubTextImageAudio, OmniSubVision}, + TypeVideo: {VideoSubTextToVideo, VideoSubImageToVideo, VideoSubImageTextToVideo, VideoSubVideoToVideo}, +} + +// 一级分类展示顺序 +var parentTypeOrder = []int{ + TypeInference, TypeImage, TypeAudio, TypeVector, TypeOmni, TypeVideo, +} + +// 全局实例:一级 + 全部二级子类型,统一通过 newItem 构造,文案仅维护在 typeNameMap +var ( + // 一级类型 + ModelTypeInference = newItem(gconv.PtrInt(TypeInference)) + ModelTypeImage = newItem(gconv.PtrInt(TypeImage)) + ModelTypeAudio = newItem(gconv.PtrInt(TypeAudio)) + ModelTypeVector = newItem(gconv.PtrInt(TypeVector)) + ModelTypeOmni = newItem(gconv.PtrInt(TypeOmni)) + ModelTypeVideo = newItem(gconv.PtrInt(TypeVideo)) + + // 图片二级子类型 + ModelImageSubTextToImage = newItem(gconv.PtrInt(ImageSubTextToImage)) + ModelImageSubImageToImage = newItem(gconv.PtrInt(ImageSubImageToImage)) + ModelImageSubImageEdit = newItem(gconv.PtrInt(ImageSubImageEdit)) + ModelImageSubImageVariation = newItem(gconv.PtrInt(ImageSubImageVariation)) + ModelImageSubImageTextToImage = newItem(gconv.PtrInt(ImageSubImageTextToImage)) + + // 音频二级子类型 + ModelAudioSubTextToSpeech = newItem(gconv.PtrInt(AudioSubTextToSpeech)) + ModelAudioSubSpeechToText = newItem(gconv.PtrInt(AudioSubSpeechToText)) + ModelAudioSubSpeechToSpeech = newItem(gconv.PtrInt(AudioSubSpeechToSpeech)) + + // 向量化二级子类型 + ModelVectorSubEmbedding = newItem(gconv.PtrInt(VectorSubEmbedding)) + ModelVectorSubRerank = newItem(gconv.PtrInt(VectorSubRerank)) + + // 全模态二级子类型 + ModelOmniSubTextImageAudio = newItem(gconv.PtrInt(OmniSubTextImageAudio)) + ModelOmniSubVision = newItem(gconv.PtrInt(OmniSubVision)) + + // 视频二级子类型 + ModelVideoSubTextToVideo = newItem(gconv.PtrInt(VideoSubTextToVideo)) + ModelVideoSubImageToVideo = newItem(gconv.PtrInt(VideoSubImageToVideo)) + ModelVideoSubImageTextToVideo = newItem(gconv.PtrInt(VideoSubImageTextToVideo)) + ModelVideoSubVideoToVideo = newItem(gconv.PtrInt(VideoSubVideoToVideo)) +) + +// newItem 构造方法:自动从 typeNameMap 读取描述 +func newItem(code ModelType) ModelTypeItem { + val := int(*code) + return ModelTypeItem{ + Code: code, + Desc: typeNameMap[val], + } +} + +// GetDescByCode 根据编码获取名称 +func GetDescByCode(code int) string { + return typeNameMap[code] +} + +// GetTypeTreeList 生成树形数据 +func GetTypeTreeList() []*TypeTree { + var list []*TypeTree + for _, parentCode := range parentTypeOrder { + tree := &TypeTree{ + Value: parentCode, + Label: typeNameMap[parentCode], + Children: make([]*public.Option, 0), + } + if childCodes, ok := parentChildMap[parentCode]; ok { + for _, c := range childCodes { + tree.Children = append(tree.Children, &public.Option{ + Value: c, + Label: typeNameMap[c], + }) + } + } + list = append(list, tree) + } + return list +} + +// GetAllTypeOption 全量平铺选项 +func GetAllTypeOption() []*public.Option { + var list []*public.Option + for code, label := range typeNameMap { + list = append(list, &public.Option{Value: code, Label: label}) + } + return list +} diff --git a/consts/model/response_type.go b/consts/model/response_type.go new file mode 100644 index 0000000..3e14c82 --- /dev/null +++ b/consts/model/response_type.go @@ -0,0 +1,27 @@ +package model + +import "github.com/gogf/gf/v2/util/gconv" + +var ( + ResponseTypeSync = newResponseType(gconv.PtrInt8(1), "sync") // 同步 + ResponseTypeAsync = newResponseType(gconv.PtrInt8(2), "async") // 异步 + ResponseTypeStream = newResponseType(gconv.PtrInt8(3), "stream") // 流 +) + +type ResponseType *int8 + +type responseType struct { + code ResponseType + desc string +} + +func (s responseType) Code() ResponseType { + return s.code +} +func (s responseType) Desc() string { + return s.desc +} + +func newResponseType(code ResponseType, desc string) responseType { + return responseType{code: code, desc: desc} +} diff --git a/consts/public/public.go b/consts/public/public.go index f20e1dc..08ef959 100644 --- a/consts/public/public.go +++ b/consts/public/public.go @@ -1,5 +1,11 @@ package public +// Option 通用下拉选项 +type Option struct { + Value int `json:"value"` + Label string `json:"label"` +} + const ( CallModeSync = 0 // 同步调用 CallModeAsync = 1 // 异步调用 diff --git a/consts/public/table_name.go b/consts/public/table_name.go index a7f89cb..2042ad2 100644 --- a/consts/public/table_name.go +++ b/consts/public/table_name.go @@ -5,8 +5,9 @@ const ( ) const ( - TableNameModel = "model_gateway_models" // 模型表 - TableNameTask = "model_gateway_task" // 任务表 - TableNameOpLog = "model_gateway_logs_op" // 操作日志表 - TableNameStat = "model_gateway_logs_stat" // 按天统计表 + TableNameModel = "model_gateway_models" // 模型表 + TableNameTask = "model_gateway_task" // 任务表 + TableNameOpLog = "model_gateway_logs_op" // 操作日志表 + TableNameStat = "model_gateway_logs_stat" // 按天统计表 + TableNameModelManage = "model_gateway_model_manage" ) diff --git a/controller/model_manage_controller.go b/controller/model_manage_controller.go new file mode 100644 index 0000000..5a5e9ee --- /dev/null +++ b/controller/model_manage_controller.go @@ -0,0 +1,56 @@ +package controller + +import ( + "context" + "model-gateway/model/dto" + "model-gateway/service" + + "gitea.redpowerfuture.com/red-future/common/beans" +) + +// ModelManage 模型配置控制器 +var ModelManage = new(modelManage) + +type modelManage struct{} + +// CreateModel 添加配置 +func (c *modelManage) CreateModel(ctx context.Context, req *dto.CreateModelManageReq) (res *dto.CreateModelManageRes, err error) { + return service.ModelManage.Create(ctx, req) +} + +// UpdateModel 更改配置 +func (c *modelManage) UpdateModel(ctx context.Context, req *dto.UpdateModelManageReq) (res *beans.ResponseEmpty, err error) { + err = service.ModelManage.Update(ctx, req) + return +} + +// DeleteModel 删除配置 +func (c *modelManage) DeleteModel(ctx context.Context, req *dto.DeleteModelManageReq) (res *beans.ResponseEmpty, err error) { + err = service.ModelManage.Delete(ctx, req) + return +} + +// GetModel 获取配置 +func (c *modelManage) GetModel(ctx context.Context, req *dto.GetModelManageReq) (res *dto.GetModelManageRes, err error) { + return service.ModelManage.Get(ctx, req) +} + +// ListModel 配置列表 +func (c *modelManage) ListModel(ctx context.Context, req *dto.ListModelManageReq) (res *dto.ListModelManageRes, err error) { + return service.ModelManage.List(ctx, req) +} + +// CheckChatModel 检查是否为聊天模型 +func (c *modelManage) CheckChatModel(ctx context.Context, req *dto.CheckChatModelReq) (res *dto.CheckChatModelRes, err error) { + return service.ModelManage.CheckChatModel(ctx, req) +} + +// ListType 模型类型列表 +func (c *modelManage) ListType(ctx context.Context, req *dto.ModelTypeReq) (res *dto.ModelTypeRes, err error) { + return service.ModelManage.GetModelType(ctx, req) +} + +// ListOperator 运营商列表 +func (c *modelManage) ListOperator(ctx context.Context, req *dto.ModelSupplierReq) (res *dto.ModelSupplierRes, err error) { + return service.ModelManage.GetModelSupplier(ctx, req) +} diff --git a/dao/model_manage_dao.go b/dao/model_manage_dao.go new file mode 100644 index 0000000..0052582 --- /dev/null +++ b/dao/model_manage_dao.go @@ -0,0 +1,193 @@ +package dao + +import ( + "context" + "fmt" + "model-gateway/consts/public" + "model-gateway/model/dto" + "model-gateway/model/entity" + + "gitea.redpowerfuture.com/red-future/common/db/gfdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +var ModelManage = &modelManageDao{} + +type modelManageDao struct{} + +// Insert 插入 +func (d *modelManageDao) Insert(ctx context.Context, req *dto.CreateModelManageReq) (id int64, err error) { + var e = new(entity.ModelManage) + err = gconv.Struct(req, &e) + if err != nil { + return + } + r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelManage).Insert(e) + if err != nil { + return + } + return r.LastInsertId() +} + +// Update 更新 +func (d *modelManageDao) Update(ctx context.Context, req *dto.UpdateModelManageReq) (rows int64, err error) { + r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelManage).OmitEmpty().Data(req).Where(entity.ModelManageCol.Id, req.Id).Update() + if err != nil { + return + } + return r.RowsAffected() +} + +// Delete 删除 +func (d *modelManageDao) Delete(ctx context.Context, req *dto.DeleteModelManageReq) (rows int64, err error) { + r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelManage).Where(entity.ModelManageCol.Id, req.Id).Delete() + if err != nil { + return + } + return r.RowsAffected() +} + +func (d *modelManageDao) Get(ctx context.Context, req *dto.GetModelManage, fields ...string) (res *entity.ModelManage, err error) { + r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelManage).Cache(ctx). + OmitEmpty(). + Where(entity.ModelManageCol.ModelName, req.ModelName). + Where(entity.ModelManageCol.ChatModel, req.ChatModel). + Where(entity.ModelManageCol.Creator, req.Creator). + Fields(fields).One() + if err != nil { + return + } + err = r.Struct(&res) + return +} + +func (d *modelManageDao) GetNotTenantId(ctx context.Context, req *dto.GetModelManageReq, fields ...string) (res *entity.ModelManage, err error) { + // 获取表前缀 + prefix := g.Cfg().MustGet(ctx, fmt.Sprintf("database.%s.0.prefix", public.DbNameModelGateway)).String() + table := prefix + public.TableNameModelManage + // 动态拼接 SELECT 列 + var field string + if !g.IsEmpty(fields) { + for k, v := range fields { + if k == len(fields)-1 { + field = field + v + } else { + field = field + v + "," + } + } + } else { + field = "*" + } + // 动态拼接 WHERE 条件 + var whereCondition string + var queryParams []interface{} + if !g.IsEmpty(req.Id) { + whereCondition = fmt.Sprintf(" AND %s=(?) ", entity.ModelManageCol.Id) + queryParams = append(queryParams, req.Id) + } + whereCondition = whereCondition + " AND " + entity.ModelManageCol.DeletedAt + " IS NULL " + + sql := `SELECT ` + field + ` FROM ` + table + ` WHERE 1=1 ` + whereCondition + `` + // 执行查询 + result, err := gfdb.DB(ctx, public.DbNameModelGateway).GetOne(ctx, sql, queryParams...) + if err != nil { + return nil, err + } + err = result.Struct(&res) + return +} +func (d *modelManageDao) ListNotTenantId(ctx context.Context, req *dto.ListModelManageReq, fields ...string) (res []*entity.ModelManage, total int, err error) { + // 获取表前缀 + prefix := g.Cfg().MustGet(ctx, fmt.Sprintf("database.%s.0.prefix", public.DbNameModelGateway)).String() + table := prefix + public.TableNameModelManage + + // 动态拼接 SELECT 列 + var field string + if !g.IsEmpty(fields) { + for k, v := range fields { + if k == len(fields)-1 { + field = field + v + } else { + field = field + v + "," + } + } + } else { + field = "*" + } + + // 动态拼接 WHERE 条件 + var whereCondition string + var queryParams []interface{} + + if !g.IsEmpty(req.ModelName) { + whereCondition += fmt.Sprintf(" AND %s=(?) ", entity.ModelManageCol.ModelName) + queryParams = append(queryParams, req.ModelName) + } + if !g.IsEmpty(req.ModelType) { + whereCondition += fmt.Sprintf(" AND %s=(?) ", entity.ModelManageCol.ModelType) + queryParams = append(queryParams, req.ModelType) + } + if !g.IsEmpty(req.Creator) { + whereCondition += fmt.Sprintf(" AND (%s=(?) OR %s=true) ", entity.ModelManageCol.Creator, entity.ModelManageCol.SystemModel) + queryParams = append(queryParams, req.Creator) + } + whereCondition = whereCondition + " AND " + entity.ModelManageCol.DeletedAt + " IS NULL " + + // 1. 统计去重后总条数 + countSql := fmt.Sprintf( + `SELECT COUNT(DISTINCT %s) FROM %s WHERE 1=1 %s`, + entity.ModelManageCol.ModelName, + table, + whereCondition, + ) + countResult, err := gfdb.DB(ctx, public.DbNameModelGateway).GetOne(ctx, countSql, queryParams...) + if err != nil { + return nil, 0, err + } + type crr struct { + Count int64 `db:"count"` + } + var cr crr + if err = countResult.Struct(&cr); err != nil { + return nil, 0, err + } + total = int(cr.Count) + // 2. 分页处理 + limitSql := "" + if req.Page != nil { + pageNum := int(req.Page.PageNum) + pageSize := int(req.Page.PageSize) + offset := (pageNum - 1) * pageSize + limitSql = fmt.Sprintf(" LIMIT ? OFFSET ? ") + // PG 语法 LIMIT 条数 OFFSET 偏移量 + queryParams = append(queryParams, pageSize, offset) + } + + // 排序优先级:1.分组字段ModelName 2.SystemModel升序(false在前,保留用户数据) 3.创建时间倒序 + orderSql := fmt.Sprintf( + " ORDER BY %s, %s ASC, %s DESC ", + entity.ModelManageCol.ModelName, + entity.ModelManageCol.SystemModel, + entity.ModelManageCol.CreatedAt, + ) + + // PG DISTINCT ON 按模型名去重,同名只取第一条(用户数据) + sql := fmt.Sprintf( + `SELECT DISTINCT ON (%s) %s FROM %s WHERE 1=1 %s %s %s`, + entity.ModelManageCol.ModelName, + field, + table, + whereCondition, + orderSql, + limitSql, + ) + + // 执行查询 + result, err := gfdb.DB(ctx, public.DbNameModelGateway).GetAll(ctx, sql, queryParams...) + if err != nil { + return nil, 0, err + } + err = result.Structs(&res) + return +} diff --git a/go.mod b/go.mod index ceebe44..8a7dc17 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module model-gateway go 1.26.1 require ( - gitea.redpowerfuture.com/red-future/common v0.0.29 + gitea.redpowerfuture.com/red-future/common v0.0.30 github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2 github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.2 github.com/gogf/gf/v2 v2.10.2 diff --git a/go.sum b/go.sum index 9e4e87b..06dae1c 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= gitea.redpowerfuture.com/red-future/common v0.0.29 h1:5McaN5pSewvrLUHQzWMX6EaUvD+B5I5bMYoU+clHJk4= gitea.redpowerfuture.com/red-future/common v0.0.29/go.mod h1:50U1Xi+Ie56z09S5LQbZvaken0Mxv3OeS9LgR7U/ZRY= +gitea.redpowerfuture.com/red-future/common v0.0.30 h1:UkWYubUsLPJQUhEhc9Ca2UPg5iLC6jzURo3ngztINYg= +gitea.redpowerfuture.com/red-future/common v0.0.30/go.mod h1:zuhqbWHd/YICalYJnmecY8Vqo5j4dtZOD63P96fiFeU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= diff --git a/main.go b/main.go index d00e8fe..7f55c07 100644 --- a/main.go +++ b/main.go @@ -26,6 +26,7 @@ func main() { // 注册路由 http.RouteRegister([]interface{}{ + controller.ModelManage, controller.ModelGatewayModels, controller.ModelGatewayTask, controller.ModelGatewayLogsStat, @@ -40,9 +41,10 @@ func main() { <-quit g.Log().Infof(ctx, "[main] 收到退出信号,开始优雅退出...") - cancel() - // 关闭 gateway server(RouteRegister 内部是 go Httpserver.Run() 启动的) + // 先关闭 gateway server,等待 in-flight 请求处理完成 _ = http.Httpserver.Shutdown() + // 再取消上下文,避免活跃请求被中断 + cancel() } func startAutoRunner(ctx context.Context) { diff --git a/model/dto/model_gateway_task_dto.go b/model/dto/model_gateway_task_dto.go index 710e39f..863aa03 100644 --- a/model/dto/model_gateway_task_dto.go +++ b/model/dto/model_gateway_task_dto.go @@ -14,6 +14,7 @@ type CreateTaskReq struct { EpicycleId int64 `json:"epicycleId" dc:"轮次ID"` BuildType int64 `json:"buildType" dc:"构建类型:1-提示词构建 2-节点构建"` BuildModelName string `json:"buildModelName" json:"buildModelName" dc:"构建模型名称"` + TaskId string `json:"taskId" dc:"任务ID"` } type CreateTaskRes struct { diff --git a/model/dto/model_manage_dto.go b/model/dto/model_manage_dto.go new file mode 100644 index 0000000..b66ae70 --- /dev/null +++ b/model/dto/model_manage_dto.go @@ -0,0 +1,127 @@ +package dto + +import ( + "model-gateway/consts/model" + "model-gateway/consts/public" + "model-gateway/model/entity" + + "gitea.redpowerfuture.com/red-future/common/beans" + "github.com/gogf/gf/v2/frame/g" +) + +// CreateModelManageReq 添加模型配置 +type CreateModelManageReq struct { + g.Meta `path:"/createModelManage" method:"post" tags:"new模型管理" summary:"new创建模型配置" dc:"new添加新的模型配置"` + ModelSupplier model.SupplierType `json:"modelSupplier" v:"required#模型供应商不能为空" dc:"模型供应商"` + ModelName string `json:"modelName" v:"required#模型名称不能为空" dc:"模型名称"` + ModelType model.ModelType `json:"modelType" v:"required#模型类型不能为空" dc:"模型类型"` + BaseURL string `json:"baseUrl" v:"required#模型服务地址不能为空" dc:"模型服务地址"` + SystemModel *bool `json:"systemModel" dc:"系统模型"` + HttpMethod string `json:"httpMethod" dc:"请求方式:GET/POST" d:"POST"` + ChatModel *bool `json:"chatModel" dc:"对话模型"` + ResponseType model.ResponseType `json:"responseType" v:"required#调用模式不能为空" dc:"调用模式:0-同步 1-异步 2-流式"` + ApiKey string `json:"apiKey" dc:"调用凭证/密钥"` + Enabled *bool `json:"enabled" dc:"启用"` + RequestHeadMapping map[string]string `json:"requestHeadMapping" dc:"请求头映射"` + RequestBodyMapping map[string]any `json:"requestBodyMapping" dc:"请求体映射"` + ResponseMapping map[string]any `json:"responseMapping" dc:"返回映射"` + ResponseBodyMapping map[string]string `json:"responseBodyMapping" dc:"返回体映射"` + MaxConcurrency int `json:"maxConcurrency" dc:"最大并发数(默认10)"` + TokenMapping *entity.TokenMapping `json:"tokenMapping" dc:"token映射"` + AsyncTaskMapping *entity.AsyncTaskMapping `json:"asyncTaskMapping" dc:"异步任务映射"` + TokenPredictPrice float64 `json:"tokenPredictPrice" dc:"模型Token预估价格"` + TokenPredictPriceUnit string `json:"tokenPredictPriceUnit" dc:"模型Token预估价格单位"` + MaxTokens int `json:"maxTokens" dc:"最大token数"` + MaxDuration int `json:"maxDuration" dc:"最大时长"` + LastFrame string `json:"lastFrame" dc:"视频的尾帧图像"` +} + +type CreateModelManageRes struct { + Id int64 `json:"id,string" dc:"配置ID"` +} + +type UpdateModelManageReq struct { + g.Meta `path:"/updateModelManage" method:"put" tags:"new模型管理" summary:"new更新模型配置" dc:"new更新指定ID的模型配置"` + Id int64 `json:"id" v:"required#id不能为空" dc:"配置ID"` + ModelSupplier model.SupplierType `json:"modelSupplier" dc:"模型供应商"` + ModelName string `json:"modelName" dc:"模型名称"` + ModelType model.ModelType `json:"modelType" dc:"模型类型"` + BaseURL string `json:"baseUrl" dc:"模型服务地址"` + SystemModel *bool `json:"systemModel" dc:"系统模型"` + HttpMethod string `json:"httpMethod" dc:"请求方式:GET/POST"` + ChatModel *bool `json:"chatModel" dc:"对话模型"` + ResponseType model.ResponseType `json:"responseType" dc:"调用模式:0-同步 1-异步 2-流式"` + ApiKey string `json:"apiKey" dc:"调用凭证/密钥"` + Enabled *bool `json:"enabled" dc:"启用"` + RequestHeadMapping map[string]string `json:"requestHeadMapping" dc:"请求头映射"` + RequestBodyMapping map[string]any `json:"requestBodyMapping" dc:"请求体映射"` + ResponseMapping map[string]any `json:"responseMapping" dc:"返回映射"` + ResponseBodyMapping map[string]string `json:"responseBodyMapping" dc:"返回主体映射"` + MaxConcurrency int `json:"maxConcurrency" dc:"最大并发数(默认10)"` + TokenMapping *entity.TokenMapping `json:"tokenMapping" dc:"token映射"` + AsyncTaskMapping *entity.AsyncTaskMapping `json:"asyncTaskMapping" dc:"异步任务映射"` + TokenPredictPrice float64 `json:"tokenPredictPrice" dc:"模型Token预估价格"` + TokenPredictPriceUnit string `json:"tokenPredictPriceUnit" dc:"模型Token预估价格单位"` + MaxTokens int `json:"maxTokens" dc:"最大token数"` + MaxDuration int `json:"maxDuration" dc:"最大时长"` + LastFrame string `json:"lastFrame" dc:"视频的尾帧图像"` +} + +type DeleteModelManageReq struct { + g.Meta `path:"/deleteModelManage" method:"delete" tags:"new模型管理" summary:"new删除模型配置" dc:"new删除指定ID的模型配置"` + Id int64 `p:"id" json:"id,string" v:"required#id不能为空" dc:"配置ID"` +} + +type GetModelManage struct { + ChatModel *bool `json:"chatModel" dc:"对话模型"` + Creator string `json:"creator" dc:"创建人"` + ModelName string `json:"modelName" dc:"模型名称"` +} + +type GetModelManageReq struct { + g.Meta `path:"/getModelManage" method:"get" tags:"new模型管理" summary:"new获取模型配置" dc:"new获取指定ID的模型配置"` + Id int64 `p:"id" json:"id,string" v:"required#id不能为空" dc:"配置ID"` +} + +type GetModelManageRes struct { + *entity.ModelManage `json:"modelManage"` +} + +// ListModelManageReq 配置列表 +type ListModelManageReq struct { + g.Meta `path:"/listModelManage" method:"get" tags:"new模型管理" summary:"new模型配置列表" dc:"new分页获取模型配置列表"` + *beans.Page `json:"page"` + ModelName string `p:"modelName" json:"modelName" dc:"模型名称(模糊查询,可选)"` + ModelType int `p:"modelType" json:"modelType" dc:"模型类型"` + Creator string `json:"creator" dc:"创建人"` +} + +type ListModelManageRes struct { + List []*entity.ModelManage `json:"list" dc:"列表数据"` + Total int `json:"total" dc:"总数"` +} + +type CheckChatModelReq struct { + g.Meta `path:"/checkChatModel" method:"get" tags:"new模型管理" summary:"new检查是否为聊天模型" dc:"new检查是否为聊天模型"` +} + +type CheckChatModelRes struct { + IsChatModel bool `json:"isChatModel" dc:"是否为聊天模型"` +} + +// ModelTypeReq 模型类型列表(分页) +type ModelTypeReq struct { + g.Meta `path:"/modelType" method:"get" tags:"new模型管理" summary:"new模型类型列表" dc:"new分页获取模型类型列表"` +} + +type ModelTypeRes struct { + List []*model.TypeTree `json:"list" dc:"模型类型ID到名称的映射"` +} + +type ModelSupplierReq struct { + g.Meta `path:"/modelSupplier" method:"get" tags:"new模型管理" summary:"new获取运营商列表" dc:"new获取运营商列表"` +} + +type ModelSupplierRes struct { + List []*public.Option `json:"list" dc:"运营商名称到ID的映射"` +} diff --git a/model/entity/model_manage.go b/model/entity/model_manage.go new file mode 100644 index 0000000..3004310 --- /dev/null +++ b/model/entity/model_manage.go @@ -0,0 +1,102 @@ +package entity + +import ( + "model-gateway/consts/model" + + "gitea.redpowerfuture.com/red-future/common/beans" +) + +type modelManageCol struct { + beans.SQLBaseCol + ModelSupplier string + ModelName string + ModelType string + BaseURL string + SystemModel string + HttpMethod string + ChatModel string + ResponseType string + ApiKey string + Enabled string + RequestHeadMapping string + RequestBodyMapping string + ResponseMapping string + ResponseBodyMapping string + MaxConcurrency string + TokenPredictPrice string + TokenPredictPriceUnit string + MaxTokens string + MaxDuration string + LastFrame string +} + +var ModelManageCol = modelManageCol{ + SQLBaseCol: beans.DefSQLBaseCol, + ModelSupplier: "model_supplier", + ModelName: "model_name", + ModelType: "model_type", + BaseURL: "base_url", + SystemModel: "system_model", + HttpMethod: "http_method", + ChatModel: "chat_model", + ResponseType: "response_type", + ApiKey: "api_key", + Enabled: "enabled", + RequestHeadMapping: "request_head_mapping", + RequestBodyMapping: "request_body_mapping", + ResponseMapping: "response_mapping", + ResponseBodyMapping: "response_body_mapping", + MaxConcurrency: "max_concurrency", + TokenPredictPrice: "token_predict_price", + TokenPredictPriceUnit: "token_predict_price_unit", + MaxTokens: "max_tokens", + MaxDuration: "max_duration", + LastFrame: "last_frame", +} + +type ModelManage struct { + beans.SQLBaseDO `orm:",inline"` + ModelSupplier model.SupplierType `orm:"model_supplier" json:"modelSupplier" description:"模型供应商"` + ModelName string `orm:"model_name" json:"modelName" description:"模型名称"` + ModelType model.ModelType `orm:"model_type" json:"modelType" description:"模型类型"` + BaseURL string `orm:"base_url" json:"baseUrl" description:"模型地址"` + SystemModel *bool `orm:"system_model" json:"systemModel" description:"系统模型"` + HttpMethod string `orm:"http_method" json:"httpMethod" description:"http方法"` + ChatModel *bool `orm:"chat_model" json:"ChatModel" description:"是否聊天模型"` + ResponseType model.ResponseType `orm:"response_type" json:"responseType" description:"返回类型:1同步,2异步,3流"` + ApiKey string `orm:"api_key" json:"apiKey" description:"api key"` + Enabled *bool `orm:"enabled" json:"enabled" description:"是否启用"` + RequestHeadMapping map[string]string `orm:"request_head_mapping" json:"requestHeadMapping" description:"请求头映射"` + RequestBodyMapping map[string]any `orm:"request_body_mapping" json:"requestBodyMapping" description:"请求体映射"` + ResponseMapping map[string]any `orm:"response_mapping" json:"responseMapping" description:"响应映射"` + ResponseBodyMapping map[string]string `orm:"response_body_mapping" json:"responseBodyMapping" description:"响应主体映射"` + MaxConcurrency int `orm:"max_concurrency" json:"maxConcurrency" description:"最大并发数"` + TokenMapping *TokenMapping `orm:"token_mapping" json:"tokenMapping" description:"token映射"` + AsyncTaskMapping *AsyncTaskMapping `orm:"async_task_mapping" json:"asyncTaskMapping" description:"异步任务映射"` + TokenPredictPrice float64 `orm:"token_predict_price" json:"tokenPredictPrice" description:"模型Token预估价格"` + TokenPredictPriceUnit string `orm:"token_predict_price_unit" json:"tokenPredictPriceUnit" description:"模型token预估价格单位(秒,百万Token,千Token,字数)"` + MaxTokens int `orm:"max_tokens" json:"maxTokens" description:"最大token数"` + MaxDuration int `orm:"max_duration" json:"maxDuration" description:"最大时长(秒)"` + LastFrame string `orm:"last_frame" json:"lastFrame" description:"视频的尾帧图像"` +} + +type TokenMapping struct { + PromptTokens string `json:"promptTokens" dc:"输入token"` + CompletionTokens string `json:"completionTokens" dc:"输出token"` + TotalTokens string `json:"totalTokens" dc:"总token"` +} + +type AsyncTaskMapping struct { + Url string `json:"url" dc:"url"` + HttpMethod string `json:"httpMethod" dc:"http方法" d:"POST"` + RequestHeadMapping map[string]string `json:"requestHeadMapping" description:"请求头映射"` + ResponseMapping map[string]any `json:"responseMapping" description:"响应映射"` + TaskId string `json:"taskId" dc:"任务id"` + TaskStatus string `json:"taskStatus" dc:"任务状态"` + TaskStatusPending string `json:"taskStatusPending" dc:"任务状态-待处理"` + TaskStatusRunning string `json:"taskStatusRunning" dc:"任务状态-运行中"` + TaskStatusSuccess string `json:"taskStatusSuccess" dc:"任务状态-成功"` + TaskStatusFailed string `json:"taskStatusFailed" dc:"任务状态-失败"` + TaskStatusCancel string `json:"taskStatusCancel" dc:"任务状态-取消"` + TaskStatusUnknown string `json:"taskStatusUnknown" dc:"任务状态-未知"` +} diff --git a/service/gateway/gateway_http_service.go b/service/gateway/gateway_http_service.go index 0ad2635..9df0638 100644 --- a/service/gateway/gateway_http_service.go +++ b/service/gateway/gateway_http_service.go @@ -257,7 +257,9 @@ func DeductBalance(ctx context.Context, tenantId uint64, amount float64) error { // TenantSurplusResp 租户余额返回 type TenantSurplusResp struct { - Surplus float64 `json:"surplus"` + Tenant struct { + Surplus float64 `json:"surplus"` + } `json:"tenant"` } // GetTenantSurplus 获取租户余额 @@ -278,7 +280,7 @@ func GetTenantSurplus(ctx context.Context, tenantId uint64) (float64, error) { g.Log().Warningf(ctx, "[获取余额] 失败 tenantId=%d err=%v", tenantId, err) return 0, err } - return resp.Surplus, nil + return resp.Tenant.Surplus, nil } //// callback 向回调地址 POST 任务结果(与查询接口 GetTaskRes 出参一致) diff --git a/service/model_manage_service.go b/service/model_manage_service.go new file mode 100644 index 0000000..57483b3 --- /dev/null +++ b/service/model_manage_service.go @@ -0,0 +1,204 @@ +package service + +import ( + "context" + "fmt" + "model-gateway/consts/model" + "model-gateway/consts/public" + "model-gateway/dao" + "model-gateway/model/dto" + "model-gateway/model/entity" + "model-gateway/service/gateway" + + "gitea.redpowerfuture.com/red-future/common/beans" + "gitea.redpowerfuture.com/red-future/common/db/gfdb" + "gitea.redpowerfuture.com/red-future/common/utils" + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +var ModelManage = &modelManageService{} + +type modelManageService struct{} + +// Create 创建模型 +func (s *modelManageService) Create(ctx context.Context, req *dto.CreateModelManageReq) (res *dto.CreateModelManageRes, err error) { + err = gfdb.DB(ctx, public.DbNameModelGateway).Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) { + // 1)检查是否是超管 + var isSuperAdmin bool + isSuperAdmin, err = gateway.IsSuperAdmin(ctx) + if err != nil { + return + } + req.SystemModel = &isSuperAdmin + // 1)如果设为会话模型,先把该用户旧会话模型取消 + err = s.CancelChatModel(ctx, req.ModelType, req.ChatModel, isSuperAdmin) + if err != nil { + return + } + // 2)插入数据 + id, err := dao.ModelManage.Insert(ctx, req) + if err != nil { + return + } + res = &dto.CreateModelManageRes{Id: id} + return + }) + return +} + +// Update 更新模型配置 +func (s *modelManageService) Update(ctx context.Context, req *dto.UpdateModelManageReq) (err error) { + err = gfdb.DB(ctx, public.DbNameModelGateway).Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) { + var get *entity.ModelManage + get, err = dao.ModelManage.GetNotTenantId(ctx, &dto.GetModelManageReq{ + Id: req.Id, + }) + if err != nil { + return + } + var user *beans.User + user, err = utils.GetUserInfo(ctx) + if err != nil { + return + } + // 1)如果不是创建者,且是系统模型,则需要拷贝 + if get.Creator != user.UserName { + if get.SystemModel != nil && *get.SystemModel { + if g.IsEmpty(req.ApiKey) { + return fmt.Errorf("模型apiKey不能为空") + } + d := new(dto.CreateModelManageReq) + err = gconv.Struct(req, d) + if err != nil { + return + } + _, err = s.Create(ctx, d) + if err != nil { + return err + } + return + } + return fmt.Errorf("无权限操作") + } + + // 1)检查是否是超管 + var isSuperAdmin bool + isSuperAdmin, err = gateway.IsSuperAdmin(ctx) + if err != nil { + return + } + // 1)如果设为会话模型,先把该用户旧会话模型取消 + err = s.CancelChatModel(ctx, req.ModelType, req.ChatModel, isSuperAdmin) + if err != nil { + return + } + // 2)更新数据 + _, err = dao.ModelManage.Update(ctx, req) + return + }) + return +} + +func (s *modelManageService) CancelChatModel(ctx context.Context, modelType model.ModelType, chatModel *bool, isSuperAdmin bool) (err error) { + if !g.IsEmpty(chatModel) && *chatModel { + if *modelType == *model.ModelTypeInference.Code { + if isSuperAdmin { + return fmt.Errorf("超级管理员不能设置会话模型") + } + // 2)获取该用户信息 + var user *beans.User + user, err = utils.GetUserInfo(ctx) + if err != nil { + return + } + // 3)取消该用户之前的会话模型 + var get *entity.ModelManage + get, err = dao.ModelManage.Get(ctx, &dto.GetModelManage{ + Creator: user.UserName, + ChatModel: chatModel, + }) + if err != nil { + return + } + _, err = dao.ModelManage.Update(ctx, &dto.UpdateModelManageReq{ + Id: get.Id, + ChatModel: gconv.PtrBool(false), + }) + if err != nil { + return + } + } else { + return fmt.Errorf("只有推理模型可以设置成会话模型") + } + } + return +} + +// Delete 删除模型 +func (s *modelManageService) Delete(ctx context.Context, req *dto.DeleteModelManageReq) error { + _, err := dao.ModelManage.Delete(ctx, req) + return err +} + +func (s *modelManageService) Get(ctx context.Context, req *dto.GetModelManageReq) (res *dto.GetModelManageRes, err error) { + get, err := dao.ModelManage.GetNotTenantId(ctx, req) + if err != nil { + return + } + err = gconv.Struct(get, &res) + return +} + +// List 获取模型列表 +func (s *modelManageService) List(ctx context.Context, req *dto.ListModelManageReq) (res *dto.ListModelManageRes, err error) { + var user *beans.User + user, err = utils.GetUserInfo(ctx) + if err != nil { + return + } + req.Creator = user.UserName + list, total, err := dao.ModelManage.ListNotTenantId(ctx, req) + if err != nil { + return + } + res = &dto.ListModelManageRes{ + Total: total, + } + err = gconv.Struct(list, &res.List) + return +} + +func (s *modelManageService) CheckChatModel(ctx context.Context, req *dto.CheckChatModelReq) (res *dto.CheckChatModelRes, err error) { + user, err := utils.GetUserInfo(ctx) + if err != nil { + return + } + get, err := dao.ModelManage.Get(ctx, &dto.GetModelManage{ + Creator: user.UserName, + ChatModel: gconv.PtrBool(true), + }) + if err != nil { + return + } + res = &dto.CheckChatModelRes{ + IsChatModel: !g.IsEmpty(get), + } + return +} + +// GetModelType 获取模型类型 +func (s *modelManageService) GetModelType(ctx context.Context, req *dto.ModelTypeReq) (res *dto.ModelTypeRes, err error) { + res = &dto.ModelTypeRes{ + List: model.GetTypeTreeList(), + } + return res, nil +} + +// GetModelSupplier 获取运营商列表 +func (s *modelManageService) GetModelSupplier(ctx context.Context, req *dto.ModelSupplierReq) (res *dto.ModelSupplierRes, err error) { + return &dto.ModelSupplierRes{ + List: model.GetSupplierOptionList(), + }, nil +} diff --git a/service/task/task_service.go b/service/task/task_service.go index 9b89032..a30b0e9 100644 --- a/service/task/task_service.go +++ b/service/task/task_service.go @@ -15,7 +15,10 @@ import ( "gitea.redpowerfuture.com/red-future/common/beans" "gitea.redpowerfuture.com/red-future/common/utils" "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/database/gredis" + "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/glog" "github.com/gogf/gf/v2/util/gconv" "github.com/google/uuid" ) @@ -26,7 +29,10 @@ type taskService struct{} // Create 创建任务 func (s *taskService) Create(ctx context.Context, req *dto.CreateTaskReq) (res *dto.CreateTaskRes, err error) { - taskID := uuid.NewString() + taskID := req.TaskId + if taskID == "" { + taskID = uuid.NewString() + } startAt := time.Now() // 1) 获取用户信息 @@ -49,81 +55,196 @@ func (s *taskService) Create(ctx context.Context, req *dto.CreateTaskReq) (res * if model == nil || (model.Enabled != nil && *model.Enabled != 1) { return nil, errors.New("模型不存在或未启用") } + lockKey := fmt.Sprintf("lock:tenantId-%s:model-%s", gconv.String(userInfo.TenantId), req.ModelName) + success, e := Lock(ctx, lockKey, -1, int64(time.Minute.Seconds()*5), func(ctx context.Context) error { + const ( + keyExpireSec = 600 // 计数Key兜底过期时间 10min + waitInterval = 10 * time.Second // 轮询等待间隔 + ) + // Redis 操作统一使用独立上下文,避免外部 ctx canceled + redisCtx := context.WithoutCancel(ctx) + // 模型并发计数Key + concurrencyKey := fmt.Sprintf("model:concurrency:%s", req.ModelName) + maxCon := gconv.Int64(model.MaxConcurrency) - // TODO: 排队控制暂时关闭,后续需要时取消注释 - // limit := queue.GetRuntimeQueueLimit(ctx, req.ModelName, model.MaxConcurrency*2) - // if limit > 0 { - // ok, err := queue.AcquireQueueSlot(ctx, req.ModelName, taskID, limit, model.TimeoutSeconds) - // if err != nil { - // return nil, err - // } - // if !ok { - // return nil, errors.New("任务排队已满,请稍后再试") - // } - // } + // 循环尝试获取并发名额,超限则等待重试 + var held bool // 标记当前是否持有未释放的计数 + for { + // 检测全局上下文取消 + if ctx.Err() != nil { + if held { + g.Redis().Decr(redisCtx, concurrencyKey) + } + return ctx.Err() + } + // 计数自增 + currentCon, e := g.Redis().Incr(redisCtx, concurrencyKey) + if e != nil { + if held { + g.Redis().Decr(redisCtx, concurrencyKey) + } + glog.Errorf(ctx, "redis incr concurrency key err: %v", e) + return e + } + held = true + // 首次创建Key时设置过期时间(避免重复执行EXPIRE) + exists, errr := g.Redis().Exists(redisCtx, concurrencyKey) + if errr == nil && exists == 1 { + g.Redis().Expire(redisCtx, concurrencyKey, keyExpireSec) + } - // 3) 构建任务实体 - task := &entity.ModelGatewayTask{ - ModelName: model.ModelName, - TaskID: taskID, - State: public.TaskStatusRunning, - BizName: req.BizName, - CallbackURL: req.CallbackUrl, - RequestPayload: &entity.RequestPayload{ - Body: req.RequestPayload, - Headers: util.ParseHeadMsgHeaders(model.HeadMsg), - }, - EpicycleId: req.EpicycleId, - BuildModelName: req.BuildModelName, - } + // 未超限:跳出循环,执行业务 + if currentCon <= maxCon { + glog.Infof(ctx, "并发数: %s %d/%d", concurrencyKey, currentCon, maxCon) + break + } + // 超限立刻回减,撤销本次计数 + g.Redis().Decr(redisCtx, concurrencyKey) + held = false + glog.Infof(ctx, "并发超限等待: %s %d/%d", concurrencyKey, currentCon, maxCon) + time.Sleep(waitInterval) + } - // 4) 插入任务记录 - id, err := dao.ModelGatewayTask.Insert(ctx, task) - if err != nil { - // TODO: 恢复排队逻辑后,此处需要回滚排队占位 - // queue.ReleaseQueueSlot(ctx, req.ModelName, taskID) - return nil, err - } - task.Id = id + // 3) 构建任务实体 + task := &entity.ModelGatewayTask{ + ModelName: model.ModelName, + TaskID: taskID, + State: public.TaskStatusRunning, + BizName: req.BizName, + CallbackURL: req.CallbackUrl, + RequestPayload: &entity.RequestPayload{ + Body: req.RequestPayload, + Headers: util.ParseHeadMsgHeaders(model.HeadMsg), + }, + EpicycleId: req.EpicycleId, + BuildModelName: req.BuildModelName, + } - // 5) 记录操作日志(非关键路径,失败不影响主流程) - ip, ua := "", "" - if r := g.RequestFromCtx(ctx); r != nil { - ip = utils.GetLocalIP() - ua = r.UserAgent() - } - _, _ = dao.ModelGatewayLogsOp.Insert(ctx, &entity.ModelGatewayLogsOp{ - IP: ip, - UserAgent: ua, - APIPath: "/task/createTask", - HttpMethod: "POST", - BizName: req.BizName, - ModelName: req.ModelName, - TaskID: taskID, - OpType: "createTask", - Success: 1, - CostMs: time.Since(startAt).Milliseconds(), - RequestPayload: task.RequestPayload, - ResponsePayload: gdb.Map{"taskId": taskID}, - }) + // 4) 插入任务记录 + id, errr := dao.ModelGatewayTask.Insert(ctx, task) + if errr != nil { + g.Redis().Decr(redisCtx, concurrencyKey) + // TODO: 恢复排队逻辑后,此处需要回滚排队占位 + //queue.ReleaseQueueSlot(ctx, req.ModelName, taskID) + return errr + } + task.Id = id - // 6) 模型计费 - if len(model.BillingConfig) > 0 { - requestData := util.ExtractRequestBilling(ctx, model.BillingConfig, req.RequestPayload) - // 请求数据作为计费记录的基础字段,先存入数组 - task.BillingData = append(task.BillingData, requestData) - _, _ = dao.ModelGatewayTask.Update(ctx, &entity.ModelGatewayTask{ - SQLBaseDO: beans.SQLBaseDO{Id: task.Id}, - BillingData: task.BillingData, + // 5) 记录操作日志(非关键路径,失败不影响主流程) + ip, ua := "", "" + if r := g.RequestFromCtx(ctx); r != nil { + ip = utils.GetLocalIP() + ua = r.UserAgent() + } + _, _ = dao.ModelGatewayLogsOp.Insert(ctx, &entity.ModelGatewayLogsOp{ + IP: ip, + UserAgent: ua, + APIPath: "/task/createTask", + HttpMethod: "POST", + BizName: req.BizName, + ModelName: req.ModelName, + TaskID: taskID, + OpType: "createTask", + Success: 1, + CostMs: time.Since(startAt).Milliseconds(), + RequestPayload: task.RequestPayload, + ResponsePayload: gdb.Map{"taskId": taskID}, }) - } - // 7) 异步执行任务 - go AsyncWorker.handleOne(util.AsyncCtx(ctx), task, model, req) + // 6) 模型计费 + if len(model.BillingConfig) > 0 { + requestData := util.ExtractRequestBilling(ctx, model.BillingConfig, req.RequestPayload) + // 请求数据作为计费记录的基础字段,先存入数组 + task.BillingData = append(task.BillingData, requestData) + _, _ = dao.ModelGatewayTask.Update(ctx, &entity.ModelGatewayTask{ + SQLBaseDO: beans.SQLBaseDO{Id: task.Id}, + BillingData: task.BillingData, + }) + } + + // 7) 异步执行任务 + go AsyncWorker.handleOne(util.AsyncCtx(ctx), task, model, req) + + return nil + }) + if e != nil { + err = e + return + } + if !success { + err = gerror.New("任务排队已满,请稍后再试") + return + } return &dto.CreateTaskRes{TaskID: taskID}, nil } +// Lock 分布式锁 纯原生命令、无Lua、隔离上下文防 context canceled +func Lock(ctx context.Context, key string, limit, expireSeconds int64, fn func(ctx context.Context) error) (success bool, err error) { + if limit <= 0 { + limit = -1 + } + + // 过期时间合法校验(单位:秒) + const maxExpireSec = 86400 * 7 + if expireSeconds < 1 || expireSeconds > maxExpireSec { + glog.Warningf(ctx, "锁过期时间非法,原值:%d,兜底为60秒", expireSeconds) + expireSeconds = 60 + } + + lockVal := "1" + +LOOP: + // 检测父级上下文取消,防止无限重试阻塞 goroutine + if ctx.Err() != nil { + return false, ctx.Err() + } + if limit != -1 { + if limit < 0 { + return false, errors.New("锁重试次数耗尽,获取锁失败") + } + limit-- + } + + // 核心:创建独立上下文,不受外部 ctx 取消影响 + redisCtx := context.WithoutCancel(ctx) + + // 加锁 + val, err := g.Redis().Set(redisCtx, key, lockVal, gredis.SetOption{ + TTLOption: gredis.TTLOption{ + EX: &expireSeconds, + }, + NX: true, + }) + if err != nil { + glog.Errorf(ctx, "redis set lock failed: %v", err) + time.Sleep(time.Second) + goto LOOP + } + + if val.Bool() { + // 执行业务逻辑(使用原上下文) + runErr := fn(ctx) + + // 释放锁:同样使用独立上下文 + 先GET再DEL防误删 + getRes, err := g.Redis().Get(redisCtx, key) + if err != nil { + glog.Errorf(ctx, "redis get lock value failed: %v", err) + } else if getRes.String() == lockVal { + _, delErr := g.Redis().Del(redisCtx, key) + if delErr != nil { + glog.Errorf(ctx, "redis del lock failed: %v", delErr) + } + } + + return true, runErr + } + + // 抢锁失败,休眠重试 + time.Sleep(time.Second) + goto LOOP +} + // GetResult 获取任务结果 func (s *taskService) GetResult(ctx context.Context, taskID string) (res *dto.GetTaskResultRes, err error) { t, err := dao.ModelGatewayTask.Get(ctx, &entity.ModelGatewayTask{ @@ -260,7 +381,10 @@ func (s *taskService) QueryPendingTasks(ctx context.Context, req *dto.QueryPendi if err != nil || model == nil || model.QueryConfig == nil { continue } - result, err := util.PullTaskResult(ctx, nil, model.QueryConfig, model.HeadMsg) + // 每个任务使用独立的超时上下文,防止单个任务阻塞整个轮询 + pullCtx, pullCancel := context.WithTimeout(ctx, 30*time.Second) + result, err := util.PullTaskResult(pullCtx, nil, model.QueryConfig, model.HeadMsg) + pullCancel() if err != nil { g.Log().Warningf(ctx, "[轮询] 查询失败 taskID=%s err=%v", t.TaskID, err) continue diff --git a/service/task/worker.go b/service/task/worker.go index 79d7b2e..fa0b1ce 100644 --- a/service/task/worker.go +++ b/service/task/worker.go @@ -46,11 +46,11 @@ func (w *asyncWorker) handleOne(ctx context.Context, task *entity.ModelGatewayTa // 1) 查询余额 // ============================================ surplus, _ = gateway.GetTenantSurplus(ctx, model.TenantId) - if surplus <= 0 { + if surplus <= 200 { w.failTask(ctx, task, startTime, "租户余额不足") return } - g.Log().Infof(ctx, "[handleOne] 当前余额 tenantId=%d surplus=%.2f", task.TenantId, surplus) + g.Log().Infof(ctx, "[handleOne] 当前余额 tenantId=%d surplus=%.2f", model.TenantId, surplus) // ============================================ // 2) 调用模型 @@ -83,6 +83,7 @@ func (w *asyncWorker) handleOne(ctx context.Context, task *entity.ModelGatewayTa } // 模型调用失败 if !strings.Contains(err.Error(), "Timeout") && + !strings.Contains(err.Error(), "RequestCanceled") && !strings.Contains(err.Error(), "InternalServiceError") && !strings.Contains(err.Error(), "Invalid video_url") && !strings.Contains(err.Error(), "Invalid audio track") && @@ -122,12 +123,17 @@ func (w *asyncWorker) handleOne(ctx context.Context, task *entity.ModelGatewayTa if billingResult != nil { task.BillingData[0] = billingResult } - if billingResult != nil { task.BillingData[0] = billingResult totalFee := gconv.Float64(billingResult["total_fee"]) if totalFee > 0 { - _ = gateway.DeductBalance(util.AsyncCtx(ctx), task.TenantId, -totalFee) + for attempt := 0; attempt <= maxRetry; attempt++ { + err = gateway.DeductBalance(util.AsyncCtx(ctx), model.TenantId, -totalFee) + if err == nil { + break + } + g.Log().Warningf(ctx, "[handleOne] 扣除余额失败 taskId=%s attempt=%d/%d err=%v", task.TaskID, attempt, maxRetry, err) + } } } } @@ -184,10 +190,11 @@ func (w *asyncWorker) handleOne(ctx context.Context, task *entity.ModelGatewayTa g.Log().Errorf(ctx, "[handleOne] 更新DB失败 taskId=%s err=%v", task.TaskID, err) return } - - go gateway.TriggerCallback(util.AsyncCtx(ctx), task) + concurrencyKey := fmt.Sprintf("model:concurrency:%s", req.ModelName) + g.Redis().Decr(ctx, concurrencyKey) + gateway.TriggerCallback(ctx, task) if req.EpicycleId != 0 { - go gateway.TriggerPromptsCallback(util.AsyncCtx(ctx), task, req.EpicycleId) + gateway.TriggerPromptsCallback(ctx, task, req.EpicycleId) } g.Log().Infof(ctx, "[handleOne] 成功 taskId=%s duration=%ds fileType=%s", @@ -338,7 +345,13 @@ func (w *asyncWorker) parseAndRetry(ctx context.Context, body map[string]any, mo task.BillingData = append(task.BillingData, billingResult) totalFee := gconv.Float64(billingResult["total_fee"]) if totalFee > 0 { - _ = gateway.DeductBalance(util.AsyncCtx(ctx), task.TenantId, -totalFee) + for a := 0; a <= maxRetry; a++ { + errr := gateway.DeductBalance(util.AsyncCtx(ctx), task.TenantId, -totalFee) + if errr == nil { + break + } + g.Log().Warningf(ctx, "[handleOne] 扣除余额失败 taskId=%s attempt=%d/%d err=%v", task.TaskID, a, maxRetry, errr) + } } } @@ -539,6 +552,8 @@ func (w *asyncWorker) failTask(ctx context.Context, t *entity.ModelGatewayTask, t.State = 3 t.ErrorMsg = errMsg t.DurationSeconds = int64(time.Since(startTime).Seconds()) + concurrencyKey := fmt.Sprintf("model:concurrency:%s", t.ModelName) + g.Redis().Decr(ctx, concurrencyKey) _, _ = dao.ModelGatewayTask.Update(ctx, t) // 更新任务状态 go gateway.TriggerCallback(util.AsyncCtx(ctx), t) // 触发回调 } diff --git a/update.sql b/update.sql index 248f060..df93383 100644 --- a/update.sql +++ b/update.sql @@ -230,4 +230,122 @@ COMMENT ON COLUMN model_gateway_logs_op.success IS '是否成功:1成功/0失 COMMENT ON COLUMN model_gateway_logs_op.error_msg IS '错误信息(失败时)'; COMMENT ON COLUMN model_gateway_logs_op.cost_ms IS '耗时(毫秒)'; COMMENT ON COLUMN model_gateway_logs_op.request_payload IS '请求 JSON'; -COMMENT ON COLUMN model_gateway_logs_op.response_payload IS '响应 JSON'; \ No newline at end of file +COMMENT ON COLUMN model_gateway_logs_op.response_payload IS '响应 JSON'; + +-- ========================================================================================================================= +CREATE TABLE "public"."model_gateway_model_manage" ( + "id" int8 NOT NULL, + "tenant_id" int8 NOT NULL DEFAULT 0, + "creator" varchar(64) COLLATE "pg_catalog"."default" NOT NULL, + "created_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) COLLATE "pg_catalog"."default" NOT NULL, + "updated_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" timestamp(6), + "model_supplier" varchar(32) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying, + "model_name" varchar(128) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying, + "model_type" varchar(32) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying, + "base_url" varchar(512) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying, + "system_model" bool, + "http_method" varchar(32) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying, + "chat_model" bool, + "response_type" int2 NOT NULL DEFAULT 0, + "api_key" varchar(255) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying, + "enabled" bool, + "request_head_mapping" jsonb DEFAULT '{}'::jsonb, + "request_body_mapping" jsonb DEFAULT '{}'::jsonb, + "response_mapping" jsonb DEFAULT '{}'::jsonb, + "max_concurrency" int4 NOT NULL DEFAULT 0, + "token_mapping" jsonb, + "async_task_mapping" jsonb, + "token_predict_price" numeric(12,6) NOT NULL DEFAULT 0.000000, + "max_tokens" int4 NOT NULL DEFAULT 0, + "last_frame" varchar(512) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying, + "response_body_mapping" jsonb DEFAULT '{}'::jsonb, + "token_predict_price_unit" varchar(32) COLLATE "pg_catalog"."default", + "max_duration" int4, + CONSTRAINT "model_gateway_model_manage_pkey" PRIMARY KEY ("id") +) +; + +ALTER TABLE "public"."model_gateway_model_manage" + OWNER TO "postgres"; + +CREATE INDEX "idx_model_manage_deleted_at" ON "public"."model_gateway_model_manage" USING btree ( + "deleted_at" "pg_catalog"."timestamp_ops" ASC NULLS LAST + ); + +CREATE INDEX "idx_model_manage_model_type" ON "public"."model_gateway_model_manage" USING btree ( + "model_type" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST + ); + +CREATE INDEX "idx_model_manage_response_type" ON "public"."model_gateway_model_manage" USING btree ( + "response_type" "pg_catalog"."int2_ops" ASC NULLS LAST + ); + +CREATE INDEX "idx_model_manage_supplier" ON "public"."model_gateway_model_manage" USING btree ( + "model_supplier" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST + ); + +CREATE INDEX "idx_model_manage_tenant_id" ON "public"."model_gateway_model_manage" USING btree ( + "tenant_id" "pg_catalog"."int8_ops" ASC NULLS LAST + ); + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."id" IS '主键ID'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."tenant_id" IS '租户ID'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."creator" IS '创建人'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."created_at" IS '创建时间'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."updater" IS '更新人'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."updated_at" IS '更新时间'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."deleted_at" IS '删除时间(软删)'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."model_supplier" IS '模型供应商'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."model_name" IS '模型名称'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."model_type" IS '模型类型'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."base_url" IS '模型地址'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."system_model" IS '是否系统模型'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."http_method" IS 'http请求方法'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."chat_model" IS '是否聊天模型'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."response_type" IS '返回类型:1同步,2异步,3流'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."api_key" IS '接口密钥'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."enabled" IS '是否启用'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."request_head_mapping" IS '请求头映射'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."request_body_mapping" IS '请求体映射'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."response_mapping" IS '响应映射'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."max_concurrency" IS '最大并发数'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."token_mapping" IS 'token映射'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."async_task_mapping" IS '异步任务映射'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."token_predict_price" IS '模型Token预估价格'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."max_tokens" IS '最大token数'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."last_frame" IS '视频尾帧图像地址'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."response_body_mapping" IS '响应主体映射'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."token_predict_price_unit" IS '模型token预估价格单位(秒,百万Token,千Token,字数)'; + +COMMENT ON COLUMN "public"."model_gateway_model_manage"."max_duration" IS '最大时长(秒)'; + +COMMENT ON TABLE "public"."model_gateway_model_manage" IS '模型管理表'; \ No newline at end of file