Compare commits
31
Commits
4a9ae2d412
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8a0b0c2f4 | ||
|
|
e5bba51b50 | ||
|
|
8598b63aa8 | ||
|
|
0dde11dbdf | ||
|
|
31bb973bc0 | ||
|
|
2d93b0f5ad | ||
|
|
94d408074a | ||
|
|
ea949a4b66 | ||
|
|
21447b67db | ||
|
|
a68b7b1017 | ||
|
|
bd649cb527 | ||
|
|
809c07ba3b | ||
|
|
a454826433 | ||
|
|
a356803809 | ||
|
|
e78b914b0b | ||
|
|
1ca25abca0 | ||
|
|
b663149977 | ||
|
|
e5f205e6e1 | ||
|
|
49c1cb1f42 | ||
|
|
db91d098bf | ||
|
|
923644db2b | ||
|
|
7dcfb6744d | ||
|
|
b165ef4f3e | ||
|
|
58d9891205 | ||
|
|
6cff7a934b | ||
|
|
c2758b3010 | ||
|
|
66f1d1020f | ||
|
|
b99f43b31f | ||
|
|
bc78ad2b2f | ||
|
|
4afb920456 | ||
|
|
928682f121 |
+3
-1
@@ -1 +1,3 @@
|
||||
/.idea/*
|
||||
/.idea/*
|
||||
/.superpowers/
|
||||
/docs/superpowers/
|
||||
|
||||
+10
-4
@@ -9,8 +9,8 @@ database:
|
||||
- type: "pgsql"
|
||||
host: "192.168.0.83"
|
||||
port: "15432"
|
||||
user: "postgres"
|
||||
pass: "Bjang09@686^*^"
|
||||
user: "sql9f15b63fd203b36e"
|
||||
pass: "1ec94b1acdaf57b66030242d418fad5a"
|
||||
name: "model-gateway"
|
||||
prefix: "" # (可选)表名前缀
|
||||
role: "master" # (可选)数据库主从角色(master/slave),默认为master。如果不使用应用主从机制请不配置或留空即可。
|
||||
@@ -30,8 +30,8 @@ database:
|
||||
- type: "pgsql"
|
||||
host: "192.168.0.83"
|
||||
port: "15432"
|
||||
user: "postgres"
|
||||
pass: "Bjang09@686^*^"
|
||||
user: "sql9f15b63fd203b36e"
|
||||
pass: "1ec94b1acdaf57b66030242d418fad5a"
|
||||
name: "model-gateway"
|
||||
prefix: "model_gateway_"
|
||||
role: "master"
|
||||
@@ -63,6 +63,12 @@ nats:
|
||||
addr: 192.168.0.83
|
||||
port: 4222
|
||||
|
||||
# schema_mapping 自动构建专用 LLM(OpenAI 兼容;密钥不要写死进代码)
|
||||
schemaMapping:
|
||||
baseUrl: "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
|
||||
modelName: "doubao-seed-2-0-lite-260428"
|
||||
apiKey: "ark-9df744e8-a0de-4c54-9db3-18379bccd523-e6733"
|
||||
|
||||
# 本地调试用:可选自动执行 worker/cleaner(默认关闭)
|
||||
asynch:
|
||||
queryPending:
|
||||
|
||||
@@ -5,8 +5,9 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
TableNameModelManage = "model_manage"
|
||||
TableNameModelSession = "model_session"
|
||||
TableNameModelTaskStart = "model_task_start"
|
||||
TableNameModelTaskEnd = "model_task_end"
|
||||
TableNameModelManage = "model_manage"
|
||||
TableNameModelSession = "model_session"
|
||||
TableNameModelTaskStart = "model_task_start"
|
||||
TableNameModelTaskEnd = "model_task_end"
|
||||
TableNameModelErrorMemory = "model_error_memory"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/service"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
// ModelErrorMemory 错误重试记忆控制器
|
||||
var ModelErrorMemory = new(modelErrorMemory)
|
||||
|
||||
type modelErrorMemory struct{}
|
||||
|
||||
// List 错误重试记忆列表
|
||||
func (c *modelErrorMemory) List(ctx context.Context, req *dto.GetErrorMemoryListReq) (res *dto.GetErrorMemoryListRes, err error) {
|
||||
return service.ModelErrorMemory.List(ctx, req)
|
||||
}
|
||||
|
||||
// Delete 删除错误重试记忆
|
||||
func (c *modelErrorMemory) Delete(ctx context.Context, req *dto.DeleteErrorMemoryReq) (res *beans.ResponseEmpty, err error) {
|
||||
err = service.ModelErrorMemory.Delete(ctx, req)
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"model-gateway/consts/public"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
|
||||
)
|
||||
|
||||
var ModelErrorMemory = &modelErrorMemoryDao{}
|
||||
|
||||
type modelErrorMemoryDao struct{}
|
||||
|
||||
// GetByKey 按记忆键查询(未命中返回 (nil, nil))
|
||||
// 错误记忆为全局表:NoTenantId 绕过租户过滤,跨租户共享;r.IsEmpty() 兜底 miss 契约,
|
||||
// 避免对空记录 r.Struct(&res) 上浮 sql.ErrNoRows 导致调用方 fail-closed。
|
||||
func (d *modelErrorMemoryDao) GetByKey(ctx context.Context, key string) (res *entity.ModelErrorMemory, err error) {
|
||||
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelErrorMemory).
|
||||
NoTenantId(ctx).
|
||||
Where(entity.ModelErrorMemoryCol.MemoryKey, key).
|
||||
One()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if r.IsEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
err = r.Struct(&res)
|
||||
return
|
||||
}
|
||||
|
||||
// Upsert 存在则更新 retryable/reason/analyzed_by,不存在则插入
|
||||
func (d *modelErrorMemoryDao) Upsert(ctx context.Context, m *entity.ModelErrorMemory) (err error) {
|
||||
model := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelErrorMemory)
|
||||
// Count 同样全局化:跨租户已存在的记忆键需命中更新分支,而非重复插入
|
||||
n, err := model.NoTenantId(ctx).Where(entity.ModelErrorMemoryCol.MemoryKey, m.MemoryKey).Count()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
_, err = model.Where(entity.ModelErrorMemoryCol.MemoryKey, m.MemoryKey).Data(map[string]any{
|
||||
entity.ModelErrorMemoryCol.Retryable: m.Retryable,
|
||||
entity.ModelErrorMemoryCol.Reason: m.Reason,
|
||||
entity.ModelErrorMemoryCol.AnalyzedBy: m.AnalyzedBy,
|
||||
}).Update()
|
||||
return
|
||||
}
|
||||
_, err = model.Insert(m)
|
||||
return
|
||||
}
|
||||
|
||||
// List 分页查询(按 id 倒序);全局表,管理端列表展示所有租户记忆
|
||||
func (d *modelErrorMemoryDao) List(ctx context.Context, page, pageSize int) (list []entity.ModelErrorMemory, total int64, err error) {
|
||||
model := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelErrorMemory).NoTenantId(ctx)
|
||||
n, err := model.Count()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
total = int64(n)
|
||||
err = model.Page(page, pageSize).OrderDesc(entity.ModelErrorMemoryCol.Id).Scan(&list)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete 按 id 删除(软删除)
|
||||
func (d *modelErrorMemoryDao) Delete(ctx context.Context, id int64) (err error) {
|
||||
_, err = gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelErrorMemory).
|
||||
Where(entity.ModelErrorMemoryCol.Id, id).Delete()
|
||||
return
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
@@ -77,6 +78,26 @@ func (d *modelManageDao) GetByCreatorAndName(ctx context.Context, creator, model
|
||||
return
|
||||
}
|
||||
|
||||
// CountReferences 统计引用某系统模型的引用行数(Model 链自动过滤软删)
|
||||
func (d *modelManageDao) CountReferences(ctx context.Context, systemModelId int64) (count int, err error) {
|
||||
count, err = gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelManage).
|
||||
Where(entity.ModelManageCol.RefSystemModelId, systemModelId).
|
||||
Count()
|
||||
return
|
||||
}
|
||||
|
||||
// UpdateReferencesName 系统模型改名时同步引用行的 model_name(保列表 DISTINCT ON 去重正确)
|
||||
func (d *modelManageDao) UpdateReferencesName(ctx context.Context, systemModelId int64, newName string) (rows int64, err error) {
|
||||
r, err := gfdb.DB(ctx, public.DbNameModelGateway).Model(ctx, public.TableNameModelManage).
|
||||
Data(gdb.Map{entity.ModelManageCol.ModelName: newName}).
|
||||
Where(entity.ModelManageCol.RefSystemModelId, systemModelId).
|
||||
Update()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return r.RowsAffected()
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -3,13 +3,12 @@ module model-gateway
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
gitea.redpowerfuture.com/red-future/common v0.0.32
|
||||
gitea.redpowerfuture.com/red-future/common v0.0.33
|
||||
github.com/bjang03/gmq v0.0.3
|
||||
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
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/tidwall/gjson v1.19.0
|
||||
golang.org/x/sync v0.19.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -42,6 +41,7 @@ require (
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/flatbuffers v1.12.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
|
||||
github.com/grokify/html-strip-tags-go v0.1.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
|
||||
@@ -75,6 +75,7 @@ require (
|
||||
github.com/redis/go-redis/v9 v9.18.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
gitea.redpowerfuture.com/red-future/common v0.0.31 h1:9H8nL5Drazcv7Hs9d4j+cXhaB+7uOllIUqEOyZy1Eao=
|
||||
gitea.redpowerfuture.com/red-future/common v0.0.31/go.mod h1:xPU7aaMxn8rtNnWc2LDUXZL+IkaUkpQeLgflqw9FvdU=
|
||||
gitea.redpowerfuture.com/red-future/common v0.0.32/go.mod h1:xPU7aaMxn8rtNnWc2LDUXZL+IkaUkpQeLgflqw9FvdU=
|
||||
gitea.redpowerfuture.com/red-future/common v0.0.33 h1:AhWJ6l9zrjc1U0UEfyIZu8wkkVFxNe0hfuA51vOnOIo=
|
||||
gitea.redpowerfuture.com/red-future/common v0.0.33/go.mod h1:FtI9KJJSKo4/K0emjVkbL8yoSIPHJdZXr27vnScQpmM=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
@@ -20,10 +19,7 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||
github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow=
|
||||
github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q=
|
||||
github.com/bjang03/gmq v0.0.2 h1:3CcVorDXYoRIN65bbzwRuUxzkBCkEpHWmKHOkfXzUo0=
|
||||
github.com/bjang03/gmq v0.0.2/go.mod h1:Y7TwWGuV4Cw97WUDaM7x+NC4kyFx1z44WAvNwJV3HV8=
|
||||
github.com/bjang03/gmq v0.0.3 h1:Yn9GZP1okOc8uh0f/1FFTooV5/mbO4pKrkcK9mTMjok=
|
||||
github.com/bjang03/gmq v0.0.3/go.mod h1:Y7TwWGuV4Cw97WUDaM7x+NC4kyFx1z44WAvNwJV3HV8=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
@@ -95,8 +91,6 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2 h1:u8EpP24GkprogROnJ7htMov9Fc66pTP1eVYrWxiCYOs=
|
||||
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2/go.mod h1:GmvM3r8GVByVMi4RD2+MCs5+CfxVXPMeT8mVDkAaAXE=
|
||||
github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.2 h1:iTQegT+lEg/wDKvj2mi3W1wrdrwFarjokf88EXVVgu4=
|
||||
@@ -323,11 +317,10 @@ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1F
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
|
||||
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
|
||||
@@ -36,6 +36,7 @@ func main() {
|
||||
http.RouteRegister([]interface{}{
|
||||
controller.ModelCall,
|
||||
controller.ModelManage,
|
||||
controller.ModelErrorMemory,
|
||||
})
|
||||
|
||||
gmq.GmqRegister(public.GmqMsgPluginsName, &mq.NatsConn{
|
||||
|
||||
@@ -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:"视频时长(秒)"`
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ type ModelCallReq struct {
|
||||
|
||||
type ModelCallRes struct {
|
||||
TaskId int64 `json:"id" dc:"任务ID"`
|
||||
ModelId int64 `json:"modelId" dc:"生效模型ID(引用行=解析后的系统模型ID,计价按此)"`
|
||||
MediaType string `json:"mediaType" dc:"输入媒体类型(shop词汇: text/audio/video)"`
|
||||
TotalTokens int64 `json:"totalTokens" dc:"总token"`
|
||||
PromptTokens int64 `json:"promptTokens" dc:"输入token"`
|
||||
CompletionTokens int64 `json:"completionTokens" dc:"输出token"`
|
||||
@@ -59,25 +61,16 @@ type ModelCallStreamReq struct {
|
||||
BusinessParams map[string]any `json:"businessParams" dc:"业务参数(按业务字段名传,按 RequestBusinessFieldMapping 写入请求体)"`
|
||||
}
|
||||
|
||||
type ModelErrorResp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
type ModelError1Resp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type ModelMsg struct {
|
||||
TaskID int64 `json:"id" dc:"任务ID"`
|
||||
ModelId int64 `json:"modelId" dc:"生效模型ID(引用行=解析后的系统模型ID,计价按此)"`
|
||||
MediaType string `json:"mediaType" dc:"输入媒体类型(shop词汇: text/audio/video)"`
|
||||
TotalTokens int64 `json:"totalTokens" dc:"总token"`
|
||||
PromptTokens int64 `json:"promptTokens" dc:"输入token"`
|
||||
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:"错误消息"`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// GetErrorMemoryListReq 错误重试记忆列表
|
||||
type GetErrorMemoryListReq struct {
|
||||
g.Meta `path:"/errorMemory/list" method:"get" tags:"错误记忆" summary:"错误重试记忆列表" dc:"查看错误→可重试结论记忆"`
|
||||
*beans.Page `json:"page"`
|
||||
}
|
||||
|
||||
type GetErrorMemoryListRes struct {
|
||||
List []ErrorMemoryItem `json:"list" dc:"记忆条目"`
|
||||
Total int64 `json:"total" dc:"总数"`
|
||||
}
|
||||
|
||||
type ErrorMemoryItem struct {
|
||||
Id int64 `json:"id"`
|
||||
MemoryKey string `json:"memoryKey"`
|
||||
Upstream string `json:"upstream"`
|
||||
ErrorCode string `json:"errorCode"`
|
||||
MsgFingerprint string `json:"msgFingerprint"`
|
||||
Retryable bool `json:"retryable"`
|
||||
Reason string `json:"reason"`
|
||||
AnalyzedBy string `json:"analyzedBy"`
|
||||
}
|
||||
|
||||
// DeleteErrorMemoryReq 删除错误重试记忆
|
||||
type DeleteErrorMemoryReq struct {
|
||||
g.Meta `path:"/errorMemory/delete" method:"post" tags:"错误记忆" summary:"删除错误重试记忆" dc:"手动清理永久记忆条目"`
|
||||
Id int64 `json:"id" v:"required#id不能为空" dc:"记忆ID"`
|
||||
}
|
||||
@@ -17,6 +17,7 @@ type CreateModelManageReq struct {
|
||||
ModelType model.ModelType `json:"modelType" v:"required#模型类型不能为空" dc:"模型类型"`
|
||||
BaseURL string `json:"baseUrl" v:"required#模型服务地址不能为空" dc:"模型服务地址"`
|
||||
SystemModel *bool `json:"systemModel" dc:"系统模型"`
|
||||
RefSystemModelId int64 `json:"refSystemModelId" dc:"引用的系统模型ID(引用创建时填,普通创建留空)"`
|
||||
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-流式"`
|
||||
@@ -33,11 +34,11 @@ type CreateModelManageReq struct {
|
||||
AsyncTaskMapping *entity.AsyncTaskMapping `json:"asyncTaskMapping" dc:"异步任务映射"`
|
||||
TokenPredictPrice float64 `json:"tokenPredictPrice" dc:"模型Token预估价格"`
|
||||
TokenPredictPriceUnit string `json:"tokenPredictPriceUnit" dc:"模型Token预估价格单位"`
|
||||
PriceConfig *entity.PriceConfig `json:"priceConfig" dc:"计费规则"`
|
||||
MaxTokens int `json:"maxTokens" dc:"最大token数"`
|
||||
MinDuration int `json:"minDuration" dc:"最小时长"`
|
||||
MaxDuration int `json:"maxDuration" dc:"最大时长"`
|
||||
LastFrame string `json:"lastFrame" dc:"视频的尾帧图像"`
|
||||
ErrorMessageMapping map[string]any `json:"errorMessageMapping" dc:"错误消息映射(schema 树,解析模型错误用)"`
|
||||
}
|
||||
|
||||
type CreateModelManageRes struct {
|
||||
@@ -52,6 +53,7 @@ type UpdateModelManageReq struct {
|
||||
ModelType model.ModelType `json:"modelType" dc:"模型类型"`
|
||||
BaseURL string `json:"baseUrl" dc:"模型服务地址"`
|
||||
SystemModel *bool `json:"systemModel" dc:"系统模型"`
|
||||
RefSystemModelId int64 `json:"refSystemModelId" dc:"引用的系统模型ID(引用行只改个人字段,本字段无效)"`
|
||||
HttpMethod string `json:"httpMethod" dc:"请求方式:GET/POST"`
|
||||
ChatModel *bool `json:"chatModel" dc:"对话模型"`
|
||||
ResponseType model.ResponseType `json:"responseType" dc:"调用模式:0-同步 1-异步 2-流式"`
|
||||
@@ -68,11 +70,11 @@ type UpdateModelManageReq struct {
|
||||
AsyncTaskMapping *entity.AsyncTaskMapping `json:"asyncTaskMapping" dc:"异步任务映射"`
|
||||
TokenPredictPrice float64 `json:"tokenPredictPrice" dc:"模型Token预估价格"`
|
||||
TokenPredictPriceUnit string `json:"tokenPredictPriceUnit" dc:"模型Token预估价格单位"`
|
||||
PriceConfig *entity.PriceConfig `json:"priceConfig" dc:"计费规则"`
|
||||
MaxTokens int `json:"maxTokens" dc:"最大token数"`
|
||||
MinDuration int `json:"minDuration" dc:"最小时长"`
|
||||
MaxDuration int `json:"maxDuration" dc:"最大时长"`
|
||||
LastFrame string `json:"lastFrame" dc:"视频的尾帧图像"`
|
||||
ErrorMessageMapping map[string]any `json:"errorMessageMapping" dc:"错误消息映射(schema 树,解析模型错误用)"`
|
||||
}
|
||||
|
||||
type DeleteModelManageReq struct {
|
||||
@@ -154,3 +156,4 @@ type BuildSchemaMappingReq struct {
|
||||
type BuildSchemaMappingRes struct {
|
||||
SchemaMapping map[string]any `json:"schemaMapping" dc:"生成的 Schema 映射 JSON"`
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ type CreateModelTaskStartReq struct {
|
||||
MsgTopic string `json:"msgTopic" dc:"消息主题(可选,用于后续业务通知)"`
|
||||
RequestPath string `json:"requestPath" dc:"请求参数保存路径"`
|
||||
OriginalRequestPath string `json:"originalRequestPath" dc:"原始请求参数保存路径"`
|
||||
MediaType string `json:"mediaType" dc:"输入媒体类型快照(audio/no_video/has_video,创建任务时按请求体推导)"`
|
||||
MediaType string `json:"mediaType" dc:"输入媒体类型快照(audio/video,空=无媒体引用;shop 计费词汇,创建任务时按请求体推导)"`
|
||||
}
|
||||
|
||||
type CreateModelTaskStartRes struct {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package entity
|
||||
|
||||
import "gitea.redpowerfuture.com/red-future/common/beans"
|
||||
|
||||
type modelErrorMemoryCol struct {
|
||||
beans.SQLBaseCol
|
||||
MemoryKey string
|
||||
Upstream string
|
||||
ErrorCode string
|
||||
MsgFingerprint string
|
||||
Retryable string
|
||||
Reason string
|
||||
AnalyzedBy string
|
||||
}
|
||||
|
||||
var ModelErrorMemoryCol = modelErrorMemoryCol{
|
||||
SQLBaseCol: beans.DefSQLBaseCol,
|
||||
MemoryKey: "memory_key",
|
||||
Upstream: "upstream",
|
||||
ErrorCode: "error_code",
|
||||
MsgFingerprint: "msg_fingerprint",
|
||||
Retryable: "retryable",
|
||||
Reason: "reason",
|
||||
AnalyzedBy: "analyzed_by",
|
||||
}
|
||||
|
||||
// ModelErrorMemory 错误重试记忆(LLM 分析结论持久化,永久有效)
|
||||
type ModelErrorMemory struct {
|
||||
beans.SQLBaseDO `orm:",inline"`
|
||||
MemoryKey string `orm:"memory_key" json:"memoryKey" dc:"记忆键=SHA-256(upstream|code|归一化消息)"`
|
||||
Upstream string `orm:"upstream" json:"upstream" dc:"失败上游BaseURL"`
|
||||
ErrorCode string `orm:"error_code" json:"errorCode" dc:"错误码"`
|
||||
MsgFingerprint string `orm:"msg_fingerprint" json:"msgFingerprint" dc:"归一化消息md5"`
|
||||
Retryable bool `orm:"retryable" json:"retryable" dc:"是否可重试"`
|
||||
Reason string `orm:"reason" json:"reason" dc:"分析原因"`
|
||||
AnalyzedBy string `orm:"analyzed_by" json:"analyzedBy" dc:"分析模型名"`
|
||||
}
|
||||
@@ -13,6 +13,7 @@ type modelManageCol struct {
|
||||
ModelType string
|
||||
BaseURL string
|
||||
SystemModel string
|
||||
RefSystemModelId string
|
||||
HttpMethod string
|
||||
ChatModel string
|
||||
ResponseType string
|
||||
@@ -27,7 +28,6 @@ type modelManageCol struct {
|
||||
MaxConcurrency string
|
||||
TokenPredictPrice string
|
||||
TokenPredictPriceUnit string
|
||||
PriceConfig string
|
||||
MaxTokens string
|
||||
MinDuration string
|
||||
MaxDuration string
|
||||
@@ -41,6 +41,7 @@ var ModelManageCol = modelManageCol{
|
||||
ModelType: "model_type",
|
||||
BaseURL: "base_url",
|
||||
SystemModel: "system_model",
|
||||
RefSystemModelId: "ref_system_model_id",
|
||||
HttpMethod: "http_method",
|
||||
ChatModel: "chat_model",
|
||||
ResponseType: "response_type",
|
||||
@@ -55,7 +56,6 @@ var ModelManageCol = modelManageCol{
|
||||
MaxConcurrency: "max_concurrency",
|
||||
TokenPredictPrice: "token_predict_price",
|
||||
TokenPredictPriceUnit: "token_predict_price_unit",
|
||||
PriceConfig: "price_config",
|
||||
MaxTokens: "max_tokens",
|
||||
MinDuration: "min_duration",
|
||||
MaxDuration: "max_duration",
|
||||
@@ -69,6 +69,7 @@ type ModelManage struct {
|
||||
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:"系统模型"`
|
||||
RefSystemModelId int64 `orm:"ref_system_model_id" json:"refSystemModelId" description:"引用的系统模型ID(NULL=非引用行)"`
|
||||
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流"`
|
||||
@@ -85,7 +86,6 @@ type ModelManage struct {
|
||||
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,字数)"`
|
||||
PriceConfig *PriceConfig `orm:"price_config" json:"priceConfig" description:"计费规则"`
|
||||
MaxTokens int `orm:"max_tokens" json:"maxTokens" description:"最大token数"`
|
||||
MinDuration int `orm:"min_duration" json:"minDuration" description:"最小时长(秒)"`
|
||||
MaxDuration int `orm:"max_duration" json:"maxDuration" description:"最大时长(秒)"`
|
||||
@@ -114,53 +114,3 @@ type AsyncTaskMapping struct {
|
||||
TaskStatusCancel string `json:"taskStatusCancel" dc:"任务状态-取消"`
|
||||
TaskStatusUnknown string `json:"taskStatusUnknown" dc:"任务状态-未知"`
|
||||
}
|
||||
|
||||
// PriceConfig 模型计费规则(price_config 列,JSONB)。
|
||||
// 命中条件为固定字段结构(PriceMatch):token 档位从调用用量读取,媒体类型由请求体参考媒体字段推导,
|
||||
// 全部字段缺省表示无条件命中。
|
||||
type PriceConfig struct {
|
||||
Currency string `json:"currency" dc:"币种,默认CNY"`
|
||||
Unit string `json:"unit" dc:"单价基准:per_1K-千token/per_1M-百万token/per_1-单个"`
|
||||
Rules []PriceRule `json:"rules" dc:"定价规则数组,按序首条命中生效"`
|
||||
Discount *PriceDiscount `json:"discount" dc:"模型级限时折扣,规则级可覆盖"`
|
||||
|
||||
// 单规则便捷字段:Rules 为空时的兜底价(等价于一条空 match 规则),全部为 0 时不参与计费
|
||||
InputPrice float64 `json:"inputPrice,omitempty" dc:"输入单价"`
|
||||
OutputPrice float64 `json:"outputPrice,omitempty" dc:"输出单价"`
|
||||
CacheHitPrice float64 `json:"cacheHitPrice,omitempty" dc:"缓存命中单价"`
|
||||
CacheStorageHourPrice float64 `json:"cacheStorageHourPrice,omitempty" dc:"缓存存储单价(元/小时)"`
|
||||
}
|
||||
|
||||
// PriceRule 定价规则:match 条件命中后按价格项计价,缺省项视为 0
|
||||
type PriceRule struct {
|
||||
Name string `json:"name"`
|
||||
Match *PriceMatch `json:"match,omitempty" dc:"命中条件(固定字段,见 PriceMatch);空表示任意调用"`
|
||||
Input float64 `json:"input" dc:"输入单价(非音频)"`
|
||||
InputAudio float64 `json:"inputAudio,omitempty" dc:"输入单价(音频),请求体 reference_audio 参考媒体字段命中时生效(见 DetectMediaType)"`
|
||||
Output float64 `json:"output" dc:"输出单价"`
|
||||
CacheHit float64 `json:"cacheHit" dc:"缓存命中单价(非音频)"`
|
||||
CacheHitAudio float64 `json:"cacheHitAudio,omitempty" dc:"缓存命中单价(音频),请求体 reference_audio 参考媒体字段命中时生效(见 DetectMediaType)"`
|
||||
CacheStorageHour float64 `json:"cacheStorageHour" dc:"缓存存储单价(元/小时)"`
|
||||
Discount *PriceDiscount `json:"discount" dc:"规则级折扣,覆盖模型级折扣"`
|
||||
}
|
||||
|
||||
// PriceMatch 命中条件:字段对应调用上下文固定路径,全部缺省(空值)表示无条件命中任意调用。
|
||||
// token 档位(InputLength/OutputLength/TotalLength/CachedTokens)从调用用量读取,
|
||||
// MediaType 由请求体参考媒体字段推导(见 DetectMediaType)。
|
||||
type PriceMatch struct {
|
||||
MediaType string `json:"mediaType,omitempty" dc:"输入媒体类型精确值(audio/no_video/has_video,由请求体参考媒体字段推导)"`
|
||||
InputLengthMax int64 `json:"inputLengthMax,omitempty" dc:"输入token上限(<=,usage.prompt_tokens)"`
|
||||
InputLengthMin int64 `json:"inputLengthMin,omitempty" dc:"输入token下限(>=,usage.prompt_tokens)"`
|
||||
OutputLengthMax int64 `json:"outputLengthMax,omitempty" dc:"输出token上限(<=,usage.completion_tokens)"`
|
||||
OutputLengthMin int64 `json:"outputLengthMin,omitempty" dc:"输出token下限(>=,usage.completion_tokens)"`
|
||||
TotalLengthMax int64 `json:"totalLengthMax,omitempty" dc:"总token上限(<=,usage.total_tokens)"`
|
||||
TotalLengthMin int64 `json:"totalLengthMin,omitempty" dc:"总token下限(>=,usage.total_tokens)"`
|
||||
CachedTokensMax int64 `json:"cachedTokensMax,omitempty" dc:"缓存命中token上限(<=,usage.cached_tokens)"`
|
||||
CachedTokensMin int64 `json:"cachedTokensMin,omitempty" dc:"缓存命中token下限(>=,usage.cached_tokens)"`
|
||||
}
|
||||
|
||||
// PriceDiscount 限时折扣:rate 为折扣率(0.4=4折),effective 为空数组表示长期有效
|
||||
type PriceDiscount struct {
|
||||
Rate float64 `json:"rate" dc:"折扣率"`
|
||||
Effective [2]string `json:"effective" dc:"有效期[起始,截止],格式YYYY-MM-DD,缺省长期有效"`
|
||||
}
|
||||
|
||||
@@ -49,6 +49,6 @@ type ModelTaskStart struct {
|
||||
OriginalResponseParams map[string]any `orm:"original_response_params" json:"originalResponseParams" dc:"原始响应结果"`
|
||||
DurationSeconds int64 `orm:"duration_seconds" json:"durationSeconds" dc:"耗时(秒)"`
|
||||
TaskId string `orm:"task_id" json:"taskId" dc:"任务ID"`
|
||||
MediaType string `orm:"media_type" json:"mediaType" dc:"输入媒体类型快照(audio/no_video/has_video,创建任务时按请求体推导)"`
|
||||
MediaType string `orm:"media_type" json:"mediaType" dc:"输入媒体类型快照(audio/video,空=无媒体引用;shop 计费词汇,创建任务时按请求体推导)"`
|
||||
ErrorMsg string `orm:"error_msg" json:"errorMsg" dc:"错误消息"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// parseAnalysisResponse 解析分析模型输出的判定 JSON。
|
||||
// 容错:剥 ```json 代码块/首尾空白/多余文字,取首个 {...}。
|
||||
func parseAnalysisResponse(content string) (retryable bool, reason string, err error) {
|
||||
s := strings.TrimSpace(content)
|
||||
s = strings.TrimPrefix(s, "```json")
|
||||
s = strings.TrimSuffix(s, "```")
|
||||
s = strings.TrimSpace(s)
|
||||
start, end := strings.IndexByte(s, '{'), strings.LastIndexByte(s, '}')
|
||||
if start < 0 || end <= start {
|
||||
return false, "", fmt.Errorf("分析响应中未找到JSON对象: %q", content)
|
||||
}
|
||||
var obj struct {
|
||||
Retryable bool `json:"retryable"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err = json.Unmarshal([]byte(s[start:end+1]), &obj); err != nil {
|
||||
return false, "", fmt.Errorf("分析响应JSON解析失败: %v", err)
|
||||
}
|
||||
return obj.Retryable, obj.Reason, nil
|
||||
}
|
||||
|
||||
const (
|
||||
analysisTimeout = 15 * time.Second
|
||||
analysisMaxBody = 2000
|
||||
analysisMaxTokens = 256
|
||||
)
|
||||
|
||||
const analysisSystemPrompt = `你是 AI 模型网关的错误分析器。上游 AI 模型调用返回了一个错误,你需要判断该错误是否"值得指数退避后重试"。
|
||||
|
||||
## 值得重试(retryable: true)
|
||||
- 限流:429、rate limit、请求过密、并发超限
|
||||
- 服务端瞬时故障:5xx、InternalServiceError、服务过载、上游临时不可用
|
||||
- 超时/取消/连接:Timeout、RequestCanceled、Error while connecting、连接抖动
|
||||
- 媒体源暂不可用(视频/音频生成类上游常见):Invalid video_url、Invalid audio track、Error while downloading、download failed —— 通常是源尚未就绪或下载瞬断,重试可成功,不要误判为永久参数错误
|
||||
- 资源暂时不足:quota 暂时受限
|
||||
|
||||
## 不值得重试(retryable: false)
|
||||
- 请求/参数错误:400、invalid_argument、格式错误(注意 Invalid video_url / Invalid audio track 属上类的媒体源错误,不归此类)
|
||||
- 鉴权失败:401、403、invalid_api_key、签名错误
|
||||
- 模型不存在:404、model_not_found
|
||||
- 余额不足:insufficient_quota
|
||||
- 内容违规:内容安全拦截
|
||||
- 明确的永久性配置错误
|
||||
|
||||
## 输出
|
||||
只输出一个 JSON 对象,不要任何多余文字、解释或代码块标记:
|
||||
{"retryable": true 或 false, "reason": "不超过20字的简要原因"}`
|
||||
|
||||
// truncateStr 按字节截断到 max(中文可能截半个字符,仅用于分析输入,可接受)
|
||||
func truncateStr(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max]
|
||||
}
|
||||
|
||||
// buildAnalysisBody 构造分析请求体(OpenAI 兼容 messages 格式),纯函数便于单测。
|
||||
func buildAnalysisBody(ctx context.Context, modelName, code, msg, body string) map[string]any {
|
||||
user := fmt.Sprintf("错误码: %s\n错误消息: %s\n错误响应体: %s", code, msg, truncateStr(body, analysisMaxBody))
|
||||
// 打印错误信息
|
||||
g.Log().Debugf(ctx, "分析请求体: %s", user)
|
||||
|
||||
return map[string]any{
|
||||
"model": modelName,
|
||||
"messages": []map[string]string{
|
||||
{"role": "system", "content": analysisSystemPrompt},
|
||||
{"role": "user", "content": user},
|
||||
},
|
||||
"max_tokens": analysisMaxTokens,
|
||||
"temperature": 0,
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAnalysisModel 选择分析模型:失败模型自身是对话模型则复用,否则取当前用户对话模型。
|
||||
// 取不到返回 ok=false,调用方 fail-closed 不重试。
|
||||
func resolveAnalysisModel(ctx context.Context, modelInfo *entity.ModelManage) (model *entity.ModelManage, ok bool) {
|
||||
if modelInfo != nil && modelInfo.ChatModel != nil && *modelInfo.ChatModel {
|
||||
return modelInfo, true
|
||||
}
|
||||
chat, err := ModelManage.GetChatModel(ctx, &dto.GetChatModelReq{})
|
||||
if err == nil && chat != nil && chat.ModelManage != nil {
|
||||
return chat.ModelManage, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// callAnalysisLLM 调分析模型(对话模型)判定错误是否可重试。
|
||||
// 独立短超时 http.Client;非 200 / 解析失败 / 超时 → 返回 err,调用方 fail-closed。
|
||||
func callAnalysisLLM(ctx context.Context, model *entity.ModelManage, code, msg, body string) (retryable bool, reason string, err error) {
|
||||
reqBody, err := json.Marshal(buildAnalysisBody(ctx, model.ModelName, code, msg, body))
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("marshal分析请求失败: %w", err)
|
||||
}
|
||||
httpMethod := model.HttpMethod
|
||||
if httpMethod == "" {
|
||||
httpMethod = http.MethodPost
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, httpMethod, strings.TrimRight(model.BaseURL, "/"), bytes.NewBuffer(reqBody))
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("创建分析请求失败: %w", err)
|
||||
}
|
||||
for k, v := range model.RequestHeadMapping {
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+model.ApiKey)
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: analysisTimeout}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("分析请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("读取分析响应失败: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, "", fmt.Errorf("分析接口非200: status=%d body=%s", resp.StatusCode, truncateStr(string(respBody), 500))
|
||||
}
|
||||
var apiResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err = json.Unmarshal(respBody, &apiResp); err != nil {
|
||||
return false, "", fmt.Errorf("解析分析响应失败: %w", err)
|
||||
}
|
||||
if len(apiResp.Choices) == 0 {
|
||||
return false, "", fmt.Errorf("分析响应无choices")
|
||||
}
|
||||
return parseAnalysisResponse(apiResp.Choices[0].Message.Content)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
reUUID = regexp.MustCompile(`[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`)
|
||||
reISOTime = regexp.MustCompile(`\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?`)
|
||||
reUnixMs = regexp.MustCompile(`\b1[4-9]\d{12}\b`) // unix 毫秒级时间戳
|
||||
reRequestID = regexp.MustCompile(`\b(req[-_]?|request[-_]?|rid[-_:]?)[-_:]?[0-9a-zA-Z-]{4,}\b`)
|
||||
reLongNum = regexp.MustCompile(`\b\d{4,}\b`) // 连续≥4位数字
|
||||
)
|
||||
|
||||
// normalizeErrorMsg 归一化错误消息:剔除易变片段(UUID/时间戳/请求ID/连续数字),
|
||||
// 使同因不同实例的错误命中同一记忆键。
|
||||
func normalizeErrorMsg(msg string) string {
|
||||
m := msg
|
||||
m = reUUID.ReplaceAllString(m, "{uuid}")
|
||||
m = reISOTime.ReplaceAllString(m, "{time}")
|
||||
m = reUnixMs.ReplaceAllString(m, "{ts}")
|
||||
m = reRequestID.ReplaceAllString(m, "{reqid}")
|
||||
m = reLongNum.ReplaceAllString(m, "{num}")
|
||||
return strings.TrimSpace(m)
|
||||
}
|
||||
|
||||
// buildMemoryKey 构造记忆键 = SHA-256(upstream|error_code|归一化消息)。
|
||||
// 含失败上游维度:不同上游的同类错误互不串扰。
|
||||
func buildMemoryKey(upstream, code, msg string) string {
|
||||
raw := strings.Join([]string{upstream, code, normalizeErrorMsg(msg)}, "|")
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// msgFingerprint 归一化消息的 md5(观测/展示用)。
|
||||
func msgFingerprint(msg string) string {
|
||||
sum := md5.Sum([]byte(normalizeErrorMsg(msg)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -21,22 +21,6 @@ var ModelCall = &modelCallService{}
|
||||
|
||||
type modelCallService struct{}
|
||||
|
||||
// minTenantSurplusForUse 调用模型前租户余额最低门槛(元),余额须大于该值才允许调用
|
||||
const minTenantSurplusForUse = 200.0
|
||||
|
||||
// CheckTenantBalance 调用模型前检查租户余额:余额须大于 minTenantSurplusForUse 才允许调用。
|
||||
// 返回当前余额;余额不足或获取失败返回错误。
|
||||
func CheckTenantBalance(ctx context.Context, tenantId uint64) (surplus float64, err error) {
|
||||
surplus, err = GetTenantSurplus(ctx, tenantId)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("获取租户余额失败: %w", err)
|
||||
}
|
||||
if surplus <= minTenantSurplusForUse {
|
||||
return surplus, fmt.Errorf("租户余额不足,无法调用模型(需余额大于%.0f元,当前余额%.2f元)", minTenantSurplusForUse, surplus)
|
||||
}
|
||||
return surplus, nil
|
||||
}
|
||||
|
||||
func (s *modelCallService) ModelCall(ctx context.Context, req *dto.ModelCallReq) (res *dto.ModelCallRes, err error) {
|
||||
// 1) 检查模型配置
|
||||
var modelInfo *entity.ModelManage
|
||||
@@ -46,7 +30,15 @@ func (s *modelCallService) ModelCall(ctx context.Context, req *dto.ModelCallReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取模型配置失败: %v", err)
|
||||
}
|
||||
if modelInfo == nil || (modelInfo.Enabled != nil && !*modelInfo.Enabled) {
|
||||
if modelInfo == nil {
|
||||
return nil, fmt.Errorf("模型不存在")
|
||||
}
|
||||
// 引用行 → 解析为系统模型配置+本人 apiKey;系统模型已删除等解析失败 → 阻塞调用
|
||||
modelInfo, err = modelUtils.ResolveModelConfig(ctx, modelInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if modelInfo.Enabled != nil && !*modelInfo.Enabled {
|
||||
return nil, fmt.Errorf("模型不存在或未启用")
|
||||
}
|
||||
|
||||
@@ -55,9 +47,11 @@ func (s *modelCallService) ModelCall(ctx context.Context, req *dto.ModelCallReq)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// 调用前检查租户余额:余额须大于 minTenantSurplusForUse 才允许调用
|
||||
if _, err = CheckTenantBalance(ctx, userInfo.TenantId); 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() {
|
||||
@@ -103,12 +97,6 @@ func (s *modelCallService) ModelCall(ctx context.Context, req *dto.ModelCallReq)
|
||||
RequestParams: newRequestParams,
|
||||
})
|
||||
}
|
||||
// 扣减本次调用费用:同步/一次性流式返回实际费用;异步提交费用为 0 跳过,由任务完成时(handleSingleTask)扣减
|
||||
if err == nil && res != nil && res.Cost > 0 {
|
||||
if dedErr := DeductBalance(ctx, userInfo.TenantId, res.Cost); dedErr != nil {
|
||||
g.Log().Errorf(ctx, "[扣减余额] 模型调用扣费失败 modelId=%d cost=%.6f err=%v", modelInfo.Id, res.Cost, dedErr)
|
||||
}
|
||||
}
|
||||
return
|
||||
})
|
||||
return
|
||||
@@ -123,7 +111,15 @@ func (s *modelCallService) ModelCallStream(ctx context.Context, w http.ResponseW
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取模型配置失败: %v", err)
|
||||
}
|
||||
if modelInfo == nil || (modelInfo.Enabled != nil && !*modelInfo.Enabled) {
|
||||
if modelInfo == nil {
|
||||
return fmt.Errorf("模型不存在")
|
||||
}
|
||||
// 引用行 → 解析为系统模型配置+本人 apiKey;系统模型已删除等解析失败 → 阻塞调用
|
||||
modelInfo, err = modelUtils.ResolveModelConfig(ctx, modelInfo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if modelInfo.Enabled != nil && !*modelInfo.Enabled {
|
||||
return fmt.Errorf("模型不存在或未启用")
|
||||
}
|
||||
if *modelInfo.ResponseType == *model.ResponseTypeStream.Code() {
|
||||
@@ -132,9 +128,11 @@ func (s *modelCallService) ModelCallStream(ctx context.Context, w http.ResponseW
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 调用前检查租户余额:余额须大于 minTenantSurplusForUse 才允许调用
|
||||
if _, err = CheckTenantBalance(ctx, userInfo.TenantId); 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
|
||||
@@ -152,18 +150,11 @@ func (s *modelCallService) ModelCallStream(ctx context.Context, w http.ResponseW
|
||||
if g.IsEmpty(id) || g.IsEmpty(newRequestParams) {
|
||||
return fmt.Errorf("保存模型请求参数失败")
|
||||
}
|
||||
var streamRes *dto.ModelCallRes
|
||||
streamRes, err = ModelSession.CreateSessionStream(ctx, w, &dto.CallModelSessionReq{
|
||||
_, err = ModelSession.CreateSessionStream(ctx, w, &dto.CallModelSessionReq{
|
||||
Id: id,
|
||||
ModelInfo: modelInfo,
|
||||
RequestParams: newRequestParams,
|
||||
})
|
||||
// 扣减本次流式调用费用(流结束返回实际费用)
|
||||
if err == nil && streamRes != nil && streamRes.Cost > 0 {
|
||||
if dedErr := DeductBalance(ctx, userInfo.TenantId, streamRes.Cost); dedErr != nil {
|
||||
g.Log().Errorf(ctx, "[扣减余额] 流式调用扣费失败 modelId=%d cost=%.6f err=%v", modelInfo.Id, streamRes.Cost, dedErr)
|
||||
}
|
||||
}
|
||||
return
|
||||
})
|
||||
} else {
|
||||
@@ -206,7 +197,7 @@ func (s *modelCallService) saveModelRequestParams(ctx context.Context, now time.
|
||||
return 0, nil, fmt.Errorf("上传模型解析请求参数文件失败:%v", err)
|
||||
}
|
||||
|
||||
// 3) 保存模型请求信息(快照媒体类型供任务完成时换算费用;模型计费配置任务完成时按 modelId 现查)
|
||||
// 3) 保存模型请求信息(快照媒体类型=shop 计费词汇 audio/video,空=无媒体引用,任务完成时直接用于算费;模型计费配置任务完成时按 modelId 现查)
|
||||
if *modelInfo.ResponseType == *model.ResponseTypeAsync.Code() {
|
||||
id, err = dao.ModelTaskStart.Insert(ctx, &dto.CreateModelTaskStartReq{
|
||||
ModelId: req.ModelId,
|
||||
@@ -214,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)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"model-gateway/dao"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
var analysisGroup singleflight.Group
|
||||
|
||||
// shouldRetryWithMemory 统一重试判定:查持久记忆,未命中则调分析模型并落库。
|
||||
// 记忆/分析/DB 任一环节失败均 fail-closed 不重试。
|
||||
func shouldRetryWithMemory(ctx context.Context, modelInfo *entity.ModelManage, code, msg, rawBody string) (retry bool) {
|
||||
if modelInfo == nil || (code == "" && msg == "") {
|
||||
return false
|
||||
}
|
||||
key := buildMemoryKey(modelInfo.BaseURL, code, msg)
|
||||
if row, err := dao.ModelErrorMemory.GetByKey(ctx, key); err != nil {
|
||||
g.Log().Errorf(ctx, "查询错误重试记忆失败: %v", err)
|
||||
return false
|
||||
} else if row != nil {
|
||||
return row.Retryable
|
||||
}
|
||||
retry, _ = analyzeOnce(key, func() (bool, string) {
|
||||
model, ok := resolveAnalysisModel(ctx, modelInfo)
|
||||
if !ok {
|
||||
g.Log().Warningf(ctx, "无可用分析模型(对话模型),错误不重试: code=%s", code)
|
||||
return false, ""
|
||||
}
|
||||
r, reason, err := callAnalysisLLM(ctx, model, code, msg, rawBody)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "错误分析失败,fail-closed不重试: %v", err)
|
||||
return false, ""
|
||||
}
|
||||
row := &entity.ModelErrorMemory{
|
||||
MemoryKey: key,
|
||||
Upstream: modelInfo.BaseURL,
|
||||
ErrorCode: code,
|
||||
MsgFingerprint: msgFingerprint(msg),
|
||||
Retryable: r,
|
||||
Reason: reason,
|
||||
AnalyzedBy: model.ModelName,
|
||||
}
|
||||
if err := dao.ModelErrorMemory.Upsert(ctx, row); err != nil {
|
||||
g.Log().Errorf(ctx, "错误重试记忆落库失败: %v", err)
|
||||
}
|
||||
return r, reason
|
||||
})
|
||||
return retry
|
||||
}
|
||||
|
||||
// analyzeOnce 按记忆键合并并发分析请求(singleflight)。
|
||||
// 注:fn 失败时结果在本次突发内共享(后续新错误会重新分析)。
|
||||
func analyzeOnce(key string, fn func() (bool, string)) (bool, string) {
|
||||
v, err, _ := analysisGroup.Do(key, func() (any, error) {
|
||||
retry, reason := fn()
|
||||
return []any{retry, reason}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
vals := v.([]any)
|
||||
return vals[0].(bool), vals[1].(string)
|
||||
}
|
||||
|
||||
var ModelErrorMemory = &modelErrorMemoryService{}
|
||||
|
||||
type modelErrorMemoryService struct{}
|
||||
|
||||
// List 错误重试记忆列表
|
||||
func (s *modelErrorMemoryService) List(ctx context.Context, req *dto.GetErrorMemoryListReq) (res *dto.GetErrorMemoryListRes, err error) {
|
||||
page, size := 1, 20
|
||||
if req.Page != nil && req.Page.PageNum > 0 {
|
||||
page = int(req.Page.PageNum)
|
||||
}
|
||||
if req.Page != nil && req.Page.PageSize > 0 {
|
||||
size = int(req.Page.PageSize)
|
||||
}
|
||||
list, total, err := dao.ModelErrorMemory.List(ctx, page, size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res = &dto.GetErrorMemoryListRes{Total: total, List: make([]dto.ErrorMemoryItem, 0, len(list))}
|
||||
for _, m := range list {
|
||||
res.List = append(res.List, dto.ErrorMemoryItem{
|
||||
Id: m.Id,
|
||||
MemoryKey: m.MemoryKey,
|
||||
Upstream: m.Upstream,
|
||||
ErrorCode: m.ErrorCode,
|
||||
MsgFingerprint: m.MsgFingerprint,
|
||||
Retryable: m.Retryable,
|
||||
Reason: m.Reason,
|
||||
AnalyzedBy: m.AnalyzedBy,
|
||||
})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Delete 删除错误重试记忆(手动纠错永久记忆)
|
||||
func (s *modelErrorMemoryService) Delete(ctx context.Context, req *dto.DeleteErrorMemoryReq) (err error) {
|
||||
return dao.ModelErrorMemory.Delete(ctx, req.Id)
|
||||
}
|
||||
@@ -2,69 +2,16 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"model-gateway/model/dto"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// DeductBalanceReq 扣减余额请求
|
||||
type DeductBalanceReq struct {
|
||||
Id uint64 `json:"id"`
|
||||
Surplus float64 `json:"surplus"`
|
||||
}
|
||||
|
||||
// DeductBalance 扣减租户余额。走 admin-go 内部接口 /pub/tenant/deduct(无 gftoken/Auth,供 model-gateway 内部调用)。
|
||||
// admin-go 的 tenant/edit 对 surplus 走 gdb.Counter 增量(正加负减),故扣减须传负值;调用方在本次未产生费用(cost<=0)时应跳过。
|
||||
func DeductBalance(ctx context.Context, tenantId uint64, amount float64) error {
|
||||
apiURL := "admin-go/api/v1/pub/tenant/deduct"
|
||||
headers := setCtxHeader(ctx)
|
||||
|
||||
body := DeductBalanceReq{
|
||||
Id: tenantId,
|
||||
Surplus: -amount,
|
||||
}
|
||||
jsonData, _ := json.Marshal(body)
|
||||
|
||||
var resp struct{}
|
||||
err := commonHttp.Post(ctx, apiURL, headers, &resp, jsonData)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[扣减余额] 失败 tenantId=%d amount=%.6f err=%v", tenantId, amount, err)
|
||||
return err
|
||||
}
|
||||
g.Log().Infof(ctx, "[扣减余额] 成功 tenantId=%d amount=%.6f", tenantId, amount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TenantSurplusResp 租户余额返回
|
||||
type TenantSurplusResp struct {
|
||||
Tenant struct {
|
||||
Surplus float64 `json:"surplus"`
|
||||
} `json:"tenant"`
|
||||
}
|
||||
|
||||
// GetTenantSurplus 获取租户余额(走 admin-go 内部接口 /pub/tenant/balance,无 gftoken/Auth)
|
||||
func GetTenantSurplus(ctx context.Context, tenantId uint64) (float64, error) {
|
||||
apiURL := fmt.Sprintf("admin-go/api/v1/pub/tenant/balance?tenantId=%d", tenantId)
|
||||
headers := setCtxHeader(ctx)
|
||||
|
||||
var resp TenantSurplusResp
|
||||
err := commonHttp.Get(ctx, apiURL, headers, &resp, nil)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[获取余额] 失败 tenantId=%d err=%v", tenantId, err)
|
||||
return 0, err
|
||||
}
|
||||
return resp.Tenant.Surplus, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -86,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
|
||||
}
|
||||
|
||||
+161
-54
@@ -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"
|
||||
@@ -64,6 +65,9 @@ func (s *modelManageService) Create(ctx context.Context, req *dto.CreateModelMan
|
||||
}
|
||||
|
||||
// Update 更新模型配置
|
||||
// 引用行:只改 apiKey/enabled/chatModel,配置锁定跟随系统模型(其余字段忽略);
|
||||
// 系统模型:仅创建者(超管)可改配置,改名时同步引用行 model_name;
|
||||
// 非超管编辑系统模型 → 建引用行(不拷贝配置,apiKey 必填);用户自有模型:创建者可改全配置。非创建者操作他人行 → 无权限。
|
||||
func (s *modelManageService) Update(ctx context.Context, req *dto.UpdateModelManageReq) (res *dto.GetModelManageRes, err error) {
|
||||
err = gfdb.DB(ctx, public.DbNameModelGateway).Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) {
|
||||
var get *entity.ModelManage
|
||||
@@ -73,49 +77,44 @@ func (s *modelManageService) Update(ctx context.Context, req *dto.UpdateModelMan
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if get == nil {
|
||||
return fmt.Errorf("模型不存在")
|
||||
}
|
||||
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 {
|
||||
// 拷贝前先查同名:用户已存在同名模型则直接返回用户自己的模型,避免重复拷贝
|
||||
copyName := req.ModelName
|
||||
if g.IsEmpty(copyName) {
|
||||
copyName = get.ModelName
|
||||
}
|
||||
if !g.IsEmpty(copyName) {
|
||||
var exist *entity.ModelManage
|
||||
exist, err = dao.ModelManage.GetByCreatorAndName(ctx, user.UserName, copyName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if exist != nil {
|
||||
res, err = s.Get(ctx, &dto.GetModelManageReq{Id: exist.Id})
|
||||
return
|
||||
}
|
||||
}
|
||||
if g.IsEmpty(req.ApiKey) {
|
||||
return fmt.Errorf("模型apiKey不能为空")
|
||||
}
|
||||
d := new(dto.CreateModelManageReq)
|
||||
err = gconv.Struct(req, d)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var r *dto.CreateModelManageRes
|
||||
r, err = s.Create(ctx, d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err = s.Get(ctx, &dto.GetModelManageReq{
|
||||
Id: r.Id,
|
||||
})
|
||||
|
||||
// 引用行:只改 apiKey/enabled/chatModel,配置锁定跟随系统模型(其余字段忽略;isSuperAdmin=false=个人会话模型开关)
|
||||
if get.RefSystemModelId > 0 {
|
||||
if get.Creator != user.UserName {
|
||||
return fmt.Errorf("无权限操作")
|
||||
}
|
||||
if err = s.CancelChatModel(ctx, get.ModelType, req.ChatModel, false); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = dao.ModelManage.Update(ctx, &dto.UpdateModelManageReq{
|
||||
Id: req.Id,
|
||||
ApiKey: req.ApiKey,
|
||||
Enabled: req.Enabled,
|
||||
ChatModel: req.ChatModel,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 同名唯一性:同一用户下不允许同名模型(排除自身,允许不改名编辑)
|
||||
if !g.IsEmpty(req.ModelName) {
|
||||
var exist *entity.ModelManage
|
||||
exist, err = dao.ModelManage.GetByCreatorAndName(ctx, user.UserName, req.ModelName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return fmt.Errorf("无权限操作")
|
||||
if exist != nil && exist.Id != req.Id {
|
||||
res, err = s.Get(ctx, &dto.GetModelManageReq{Id: exist.Id})
|
||||
return
|
||||
//return fmt.Errorf("模型名称 [%s] 已存在,同一用户下不能重复添加同名模型", req.ModelName)
|
||||
}
|
||||
}
|
||||
|
||||
// 1)检查是否是超管
|
||||
@@ -124,27 +123,62 @@ func (s *modelManageService) Update(ctx context.Context, req *dto.UpdateModelMan
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
modelType := req.ModelType
|
||||
if g.IsEmpty(modelType) {
|
||||
modelType = get.ModelType
|
||||
if err = s.CancelChatModel(ctx, get.ModelType, req.ChatModel, isSuperAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
// 1)如果设为会话模型,先把该用户旧会话模型取消
|
||||
err = s.CancelChatModel(ctx, modelType, req.ChatModel, isSuperAdmin)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// 2)模型名称唯一性:同一用户下不允许同名模型(排除自身)
|
||||
if !g.IsEmpty(req.ModelName) {
|
||||
var exist *entity.ModelManage
|
||||
exist, err = dao.ModelManage.GetByCreatorAndName(ctx, user.UserName, req.ModelName)
|
||||
if err != nil {
|
||||
if isSuperAdmin {
|
||||
if get.Creator != user.UserName {
|
||||
return fmt.Errorf("无权限操作")
|
||||
}
|
||||
// 3)系统模型改名 → 同步引用行 model_name(保列表 DISTINCT ON 去重正确)
|
||||
if get.SystemModel != nil && *get.SystemModel && req.ModelName != "" && req.ModelName != get.ModelName {
|
||||
if _, err = dao.ModelManage.UpdateReferencesName(ctx, get.Id, req.ModelName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if get.SystemModel != nil && *get.SystemModel {
|
||||
// 用户引用系统模型:apiKey 必填(与旧拷贝分支一致,空 key 引用行无意义)
|
||||
if g.IsEmpty(req.ApiKey) {
|
||||
return fmt.Errorf("模型apiKey不能为空")
|
||||
}
|
||||
var exist *entity.ModelManage
|
||||
exist, err = dao.ModelManage.GetByCreatorAndName(ctx, user.UserName, get.ModelName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if exist != nil {
|
||||
return fmt.Errorf("模型名称 [%s] 已存在,同一用户下不能重复添加同名模型", get.ModelName)
|
||||
}
|
||||
// 4)插引用行(配置列零值即可,解析层只读系统行配置)
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
var id int64
|
||||
id, err = dao.ModelManage.Insert(ctx, &dto.CreateModelManageReq{
|
||||
ModelSupplier: get.ModelSupplier,
|
||||
ModelName: get.ModelName,
|
||||
ModelType: get.ModelType,
|
||||
SystemModel: gconv.PtrBool(false),
|
||||
ChatModel: req.ChatModel,
|
||||
ApiKey: req.ApiKey,
|
||||
Enabled: gconv.PtrBool(enabled),
|
||||
RefSystemModelId: get.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err = s.Get(ctx, &dto.GetModelManageReq{
|
||||
Id: id,
|
||||
})
|
||||
return
|
||||
}
|
||||
if exist != nil && exist.Id != req.Id {
|
||||
return fmt.Errorf("模型名称 [%s] 已存在,同一用户下不能重复添加同名模型", req.ModelName)
|
||||
if get.Creator != user.UserName {
|
||||
return fmt.Errorf("无权限操作")
|
||||
}
|
||||
}
|
||||
// 3)更新数据
|
||||
// 4)更新数据
|
||||
_, err = dao.ModelManage.Update(ctx, req)
|
||||
return
|
||||
})
|
||||
@@ -189,9 +223,36 @@ func (s *modelManageService) CancelChatModel(ctx context.Context, modelType mode
|
||||
return
|
||||
}
|
||||
|
||||
// Delete 删除模型
|
||||
// Delete 删除模型:系统模型被引用 → 拒绝;引用行/自有行仅创建者可删
|
||||
func (s *modelManageService) Delete(ctx context.Context, req *dto.DeleteModelManageReq) error {
|
||||
_, err := dao.ModelManage.Delete(ctx, req)
|
||||
user, err := utils.GetUserInfo(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
get, err := dao.ModelManage.GetNotTenantId(ctx, &dto.GetModelManageReq{Id: req.Id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if get == nil {
|
||||
return nil
|
||||
}
|
||||
// 系统模型被引用 → 拒绝删除(防悬挂)
|
||||
if get.SystemModel != nil && *get.SystemModel {
|
||||
n, err := dao.ModelManage.CountReferences(ctx, get.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return fmt.Errorf("系统模型已被 %d 个用户引用,不能删除", n)
|
||||
}
|
||||
_, err = dao.ModelManage.Delete(ctx, req)
|
||||
return err
|
||||
}
|
||||
// 引用行/自有行:仅创建者可删(删引用行即解除引用)
|
||||
if get.Creator != user.UserName {
|
||||
return fmt.Errorf("无权限操作")
|
||||
}
|
||||
_, err = dao.ModelManage.Delete(ctx, req)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -200,6 +261,27 @@ func (s *modelManageService) Get(ctx context.Context, req *dto.GetModelManageReq
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if get == nil {
|
||||
return new(dto.GetModelManageRes), nil
|
||||
}
|
||||
// 引用行 → 合入系统模型配置返回(前端展示/编辑需要完整配置;保留引用行自身 id 供更新/删除)
|
||||
if get.RefSystemModelId > 0 {
|
||||
sys, e := dao.ModelManage.GetNotTenantId(ctx, &dto.GetModelManageReq{Id: get.RefSystemModelId})
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if sys != nil {
|
||||
get = modelUtils.MergeReferenceConfigForQuery(get, sys)
|
||||
}
|
||||
}
|
||||
// 系统模型 apiKey 对非创建者脱敏(引用行/自有行仅本人可见)
|
||||
user, err := utils.GetUserInfo(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if get.SystemModel != nil && *get.SystemModel && get.Creator != user.UserName {
|
||||
get.ApiKey = ""
|
||||
}
|
||||
res = new(dto.GetModelManageRes)
|
||||
err = gconv.Struct(get, &res.ModelManage)
|
||||
return
|
||||
@@ -245,6 +327,31 @@ 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 {
|
||||
row.ApiKey = ""
|
||||
}
|
||||
}
|
||||
res = &dto.ListModelManageRes{
|
||||
Total: total,
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -143,14 +144,8 @@ func (s *modelTaskEndService) processClaimedTask(asyncCtx context.Context, item
|
||||
docMsg.TaskID = item.Id
|
||||
var respObj map[string]any
|
||||
|
||||
// 终态处理:扣费(若产生)→ 删任务行 → 插结果行(含 ErrorMsg)→ NATS 发布结果给调用方
|
||||
// 终态处理:删任务行 → 插结果行(含 ErrorMsg)→ NATS 发布结果给调用方
|
||||
finalize := func() {
|
||||
// 按本次实际费用扣减租户余额(未产生费用不扣;异步任务无请求头,admin-go 租户接口无需鉴权可直接调用)
|
||||
if docMsg.Cost > 0 {
|
||||
if err := DeductBalance(asyncCtx, item.TenantId, docMsg.Cost); err != nil {
|
||||
g.Log().Errorf(asyncCtx, "[扣减余额] 异步任务扣费失败 taskId=%d cost=%.6f err=%v", item.Id, docMsg.Cost, err)
|
||||
}
|
||||
}
|
||||
err := gfdb.DB(asyncCtx, public.DbNameModelGateway).Transaction(asyncCtx, func(asyncCtx context.Context, tx gdb.TX) (err error) {
|
||||
// 删除视频任务
|
||||
_, err = dao.ModelTaskStart.Delete(asyncCtx, &dto.DeleteModelTaskStartReq{
|
||||
@@ -204,6 +199,15 @@ func (s *modelTaskEndService) processClaimedTask(asyncCtx context.Context, item
|
||||
return nil
|
||||
}
|
||||
|
||||
// 引用行 → 解析为系统模型配置+本人 apiKey(轮询/计价均用系统模型)
|
||||
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)
|
||||
finalize()
|
||||
return nil
|
||||
}
|
||||
|
||||
// 连续轮询失败上限:瞬时抖动(HTTP 错/空响应/解析失败)先有限重试,超限按终态错误落库
|
||||
const maxPollErrRetries = 3
|
||||
pollErrCnt := 0
|
||||
@@ -243,8 +247,8 @@ LOOP:
|
||||
}
|
||||
pollErrCnt = 0 // 请求成功一次即重置连续失败计数
|
||||
|
||||
// 异常响应识别:兼容 OpenAI 嵌套 error / 扁平 code 两种形态(与任务创建端一致),无错误返回空串
|
||||
if _, docMsg.ErrorMsg, err = parseModelError(modelRespBody); err != nil {
|
||||
// 异常响应识别:按模型 ErrorMessageMapping 解析,无错误返回空串
|
||||
if _, docMsg.ErrorMsg, err = parseModelError(modelRespBody, modelInfo.ErrorMessageMapping); err != nil {
|
||||
g.Log().Errorf(asyncCtx, "模型返回参数解析失败:%v", err)
|
||||
if pollErrCnt < maxPollErrRetries {
|
||||
pollErrCnt++
|
||||
@@ -278,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)
|
||||
@@ -287,9 +305,6 @@ LOOP:
|
||||
docMsg.PromptTokens = gconv.Int64(modelUtils.GetByPathValue(respObj, promptTokPath))
|
||||
docMsg.CompletionTokens = gconv.Int64(modelUtils.GetByPathValue(respObj, compTokPath))
|
||||
|
||||
// 按模型计费规则换算本次调用费用(未配置返回 0);媒体类型取任务创建时的快照
|
||||
docMsg.Cost = calcCostWithMediaType(docMsg.PromptTokens, docMsg.CompletionTokens, 0, item.MediaType, modelInfo.PriceConfig)
|
||||
|
||||
// 判断任务状态,轮询等待
|
||||
statusPath := modelUtils.CleanFieldPath(modelInfo.AsyncTaskMapping.TaskStatus)
|
||||
status := gconv.String(modelUtils.GetByPathValue(respObj, statusPath))
|
||||
@@ -297,6 +312,12 @@ LOOP:
|
||||
time.Sleep(10 * time.Second)
|
||||
goto LOOP
|
||||
}
|
||||
|
||||
// 调 shop-user-trade 按用量算费(媒体类型取任务创建时的快照;subject=解析后的系统模型 id)
|
||||
docMsg.ModelId = modelInfo.Id // 引用行=系统模型 id,供 per_token 结算按系统模型计价
|
||||
docMsg.MediaType = item.MediaType
|
||||
docMsg.Cost = calcModelCost(asyncCtx, modelInfo.Id,
|
||||
buildModelUsage(docMsg.PromptTokens, docMsg.CompletionTokens, 0, item.MediaType, docMsg.Duration))
|
||||
}
|
||||
// 成功或已识别出错误的终态统一落库+发布(内容组装完成/错误消息已写入 docMsg)
|
||||
finalize()
|
||||
|
||||
@@ -21,14 +21,23 @@ type modelTaskStartService struct{}
|
||||
// CreateTask 创建任务
|
||||
func (s *modelTaskStartService) CreateTask(ctx context.Context, req *dto.CallModelTaskStartReq) (res *dto.ModelCallRes, err error) {
|
||||
startTime := time.Now()
|
||||
attempt := 0
|
||||
|
||||
id := req.Id
|
||||
modelInfo := req.ModelInfo
|
||||
newRequestParams := req.RequestParams
|
||||
|
||||
LOOP:
|
||||
// 6) 模型请求
|
||||
modelRespBody, err := httpclient.ModelHttpNormalRequest(ctx, modelInfo.BaseURL, modelInfo.RequestHeadMapping, modelInfo.HttpMethod, newRequestParams)
|
||||
if err != nil {
|
||||
if attempt < modelCallMaxRetries && shouldRetryWithMemory(ctx, modelInfo, "", err.Error(), "") {
|
||||
attempt++
|
||||
if waitErr := retryWait(ctx, attempt); waitErr != nil {
|
||||
return nil, waitErr
|
||||
}
|
||||
goto LOOP
|
||||
}
|
||||
return nil, fmt.Errorf("模型请求失败: %v", err)
|
||||
}
|
||||
if modelRespBody == nil {
|
||||
@@ -45,11 +54,19 @@ func (s *modelTaskStartService) CreateTask(ctx context.Context, req *dto.CallMod
|
||||
}
|
||||
docMsg := new(dto.ModelCallRes)
|
||||
docMsg.TaskId = id
|
||||
// 统一解析模型错误(兼容 OpenAI 嵌套 error / 扁平 code 两种形态),无错误返回空串
|
||||
if _, docMsg.ErrorMsg, err = parseModelError(modelRespBody); err != nil {
|
||||
// 按模型 ErrorMessageMapping 解析错误响应,无错误返回空串
|
||||
var errCode string
|
||||
if errCode, docMsg.ErrorMsg, err = parseModelError(modelRespBody, modelInfo.ErrorMessageMapping); err != nil {
|
||||
return nil, fmt.Errorf("模型返回参数解析失败:%v", err)
|
||||
}
|
||||
if docMsg.ErrorMsg != "" {
|
||||
if attempt < modelCallMaxRetries && shouldRetryWithMemory(ctx, modelInfo, errCode, docMsg.ErrorMsg, string(modelRespBody)) {
|
||||
attempt++
|
||||
if waitErr := retryWait(ctx, attempt); waitErr != nil {
|
||||
return nil, waitErr
|
||||
}
|
||||
goto LOOP
|
||||
}
|
||||
updateModelReq.ErrorMsg = docMsg.ErrorMsg
|
||||
}
|
||||
if docMsg.ErrorMsg == "" {
|
||||
|
||||
+179
-17
@@ -1,32 +1,194 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"model-gateway/model/dto"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
|
||||
modelUtils "model-gateway/service/utils"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// parseModelError 解析模型错误响应,返回错误码与错误消息(无错误均返回空串)。
|
||||
// 兼容两种形态(与任务创建端 model_task_start_service.go 一致):
|
||||
// - OpenAI 嵌套 {"error":{"code","message"}} → 取 error.code / error.message
|
||||
// - 扁平 {"code","message"} → code=20000000 视为成功码,不当作错误
|
||||
// parseModelError 按模型配置的 ErrorMessageMapping 解析错误响应,返回错误码与错误消息(无错误均返回空串)。
|
||||
// ErrorMessageMapping 为 schema 树(与请求模板同格式:type/value/label/isForm/required/fieldType/defaultValue/attrs),
|
||||
// 解析时剔除包装字段取 code/message 的字段路径(复用 normalizeSchemaPath 归一 attrs/数组段),
|
||||
// 经 GetByPath 从响应体提取。成功判定:
|
||||
// - code 提取值为空(nil/空串/数字零)→ 成功
|
||||
// - code 节点配置了 defaultValue 且提取值等于它 → 成功
|
||||
// - 否则 → 错误(返回 code + message)
|
||||
//
|
||||
// 未配置 ErrorMessageMapping → 不识别错误,一律返回成功(纯配置驱动)。
|
||||
// 解析失败返回 err,由调用方决定重试/终态。
|
||||
func parseModelError(body []byte) (code, msg string, err error) {
|
||||
errMsg := new(dto.ModelErrorResp)
|
||||
if err = gconv.Struct(body, errMsg); err != nil {
|
||||
func parseModelError(body []byte, mapping map[string]any) (code, msg string, err error) {
|
||||
if len(mapping) == 0 {
|
||||
return "", "", nil
|
||||
}
|
||||
var respObj map[string]any
|
||||
if err = json.Unmarshal(body, &respObj); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if !g.IsEmpty(errMsg.Error.Code) {
|
||||
return errMsg.Error.Code, errMsg.Error.Message, nil
|
||||
codePath, msgPath, hasCodeDefault, codeDefault := collectErrorMapping(mapping)
|
||||
codeVal := modelUtils.GetByPathValue(respObj, codePath)
|
||||
msgVal := modelUtils.GetByPathValue(respObj, msgPath)
|
||||
if codePath != "" {
|
||||
if isEmptyErrorCode(codeVal) {
|
||||
return "", "", nil
|
||||
}
|
||||
if hasCodeDefault && sameErrorValue(codeVal, codeDefault) {
|
||||
return "", "", nil
|
||||
}
|
||||
return gconv.String(codeVal), gconv.String(msgVal), nil
|
||||
}
|
||||
flat := new(dto.ModelError1Resp)
|
||||
if err = gconv.Struct(body, flat); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if !g.IsEmpty(flat.Code) && flat.Code != 20000000 {
|
||||
return gconv.String(flat.Code), flat.Message, nil
|
||||
// 未配置 code 路径:以 message 是否为空判定错误
|
||||
if !isEmptyErrorCode(msgVal) {
|
||||
return "", gconv.String(msgVal), nil
|
||||
}
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
// collectErrorMapping 遍历 ErrorMessageMapping schema 树,提取 code/message 的字段路径与 code 节点的 defaultValue。
|
||||
// 支持 schema 节点(含 type/attrs 等包装)与纯字符串路径两种形态;数组段经 normalizeSchemaPath 归一到 [*]。
|
||||
// 返回路径为已归一字段路径;未配置返回空串。
|
||||
func collectErrorMapping(mapping map[string]any) (codePath, msgPath string, hasCodeDefault bool, codeDefault any) {
|
||||
var walk func(node map[string]any, prefix string)
|
||||
walk = func(node map[string]any, prefix string) {
|
||||
for key, val := range node {
|
||||
if isErrorMetaKey(key) {
|
||||
continue
|
||||
}
|
||||
m, ok := val.(map[string]any)
|
||||
if !ok {
|
||||
// 纯值形态:字段值直接是字段路径字符串
|
||||
if s, ok := val.(string); ok {
|
||||
path := normalizeSchemaPath(joinErrorFieldPath(prefix, s))
|
||||
if key == "code" && codePath == "" {
|
||||
codePath = path
|
||||
} else if key == "message" && msgPath == "" {
|
||||
msgPath = path
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
nodeType, _ := m["type"].(string)
|
||||
hasDef := false
|
||||
var def any
|
||||
if d, has := m["defaultValue"]; has && d != nil {
|
||||
hasDef, def = true, d
|
||||
}
|
||||
switch {
|
||||
case nodeType == "object":
|
||||
if attrs, ok := m["attrs"].(map[string]any); ok {
|
||||
walk(attrs, joinErrorFieldPath(prefix, key))
|
||||
} else if attrs, ok := m["attrs"].([]any); ok && len(attrs) > 0 {
|
||||
if elm, ok := attrs[0].(map[string]any); ok {
|
||||
walkErrorArrayElement(elm, joinErrorArrayPath(prefix, key), walk)
|
||||
}
|
||||
} else {
|
||||
walk(m, joinErrorFieldPath(prefix, key))
|
||||
}
|
||||
case nodeType == "array":
|
||||
walkErrorArrayContainer(m, joinErrorArrayPath(prefix, key), walk)
|
||||
case nodeType == "":
|
||||
// 无 type 键:纯容器(字段直接作为键),递归下钻
|
||||
walk(m, joinErrorFieldPath(prefix, key))
|
||||
default:
|
||||
// 标量叶子字段
|
||||
path := normalizeSchemaPath(joinErrorFieldPath(prefix, key))
|
||||
if key == "code" && codePath == "" {
|
||||
codePath, hasCodeDefault, codeDefault = path, hasDef, def
|
||||
} else if key == "message" && msgPath == "" {
|
||||
msgPath = path
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(mapping, "")
|
||||
return
|
||||
}
|
||||
|
||||
// walkErrorArrayContainer 数组节点:子字段容器依次尝试 attrs([]any)/enumValues/value([]any)
|
||||
func walkErrorArrayContainer(node map[string]any, prefix string, walk func(map[string]any, string)) {
|
||||
for _, container := range []string{"attrs", "enumValues", "value"} {
|
||||
items, ok := node[container].([]any)
|
||||
if !ok || len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
if elm, ok := items[0].(map[string]any); ok {
|
||||
walkErrorArrayElement(elm, prefix, walk)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// walkErrorArrayElement 数组元素:字段在其 attrs 下(对象元素)或直接作为键(纯元素)
|
||||
func walkErrorArrayElement(elm map[string]any, prefix string, walk func(map[string]any, string)) {
|
||||
if attrs, ok := elm["attrs"].(map[string]any); ok {
|
||||
walk(attrs, prefix)
|
||||
return
|
||||
}
|
||||
walk(elm, prefix)
|
||||
}
|
||||
|
||||
// isErrorMetaKey 判断是否为 schema 节点元数据字段(非业务字段,剔除)
|
||||
func isErrorMetaKey(key string) bool {
|
||||
switch key {
|
||||
case "type", "value", "label", "isForm", "required", "fieldType", "defaultValue", "description":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// joinErrorFieldPath 拼接字段路径(数组段 [0] 由 normalizeSchemaPath 归一为 [*])
|
||||
func joinErrorFieldPath(prefix, key string) string {
|
||||
if prefix == "" {
|
||||
return key
|
||||
}
|
||||
return prefix + "." + key
|
||||
}
|
||||
|
||||
// joinErrorArrayPath 数组字段路径:元素下标 [0] 由 normalizeSchemaPath 归一为 [*]
|
||||
func joinErrorArrayPath(prefix, key string) string {
|
||||
if prefix == "" {
|
||||
return key + "[0]"
|
||||
}
|
||||
return prefix + "." + key + "[0]"
|
||||
}
|
||||
|
||||
// isEmptyErrorCode 判断错误码是否为空(空=无错误):nil / 空串 / 布尔 false / 数字零值
|
||||
func isEmptyErrorCode(v any) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t == ""
|
||||
case bool:
|
||||
return !t
|
||||
}
|
||||
if isNumericType(v) {
|
||||
return gconv.Float64(v) == 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sameErrorValue 判断提取值是否等于配置的 defaultValue(数值/字符串跨类型兼容)
|
||||
func sameErrorValue(a, b any) bool {
|
||||
if isNumericType(a) && isNumericType(b) {
|
||||
return gconv.Float64(a) == gconv.Float64(b)
|
||||
}
|
||||
return gconv.String(a) == gconv.String(b)
|
||||
}
|
||||
|
||||
// isNumericType 判断是否为数值类型
|
||||
func isNumericType(v any) bool {
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
switch reflect.TypeOf(v).Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
||||
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
|
||||
reflect.Float32, reflect.Float64:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"model-gateway/model/entity"
|
||||
modelUtils "model-gateway/service/utils"
|
||||
)
|
||||
|
||||
// DetectMediaType 按模型业务字段映射从请求体推导输入媒体类型(替代硬编码的 media.type 路径):
|
||||
// - reference_audio 映射路径在请求体中有值 → "audio"
|
||||
// - reference_video 映射路径在请求体中有值 → "has_video"
|
||||
// - 否则 → "no_video"
|
||||
//
|
||||
// 判定完全由模型配置(RequestBusinessFieldMapping,业务字段名见 ChatFieldsReq/VideoFields)驱动,
|
||||
// 无请求结构硬编码;映射路径值即 GetByPathAll 路径(如 input.media?type=audio&url=#)。
|
||||
func DetectMediaType(reqBizMapping map[string]string, reqParams map[string]any) string {
|
||||
if hasMediaValue(reqBizMapping, reqParams, "reference_audio") {
|
||||
return "audio"
|
||||
}
|
||||
if hasMediaValue(reqBizMapping, reqParams, "reference_video") {
|
||||
return "has_video"
|
||||
}
|
||||
return "no_video"
|
||||
}
|
||||
|
||||
// hasMediaValue 业务字段映射路径在请求体中是否命中值
|
||||
func hasMediaValue(reqBizMapping map[string]string, reqParams map[string]any, bizField string) bool {
|
||||
path := reqBizMapping[bizField]
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
return len(modelUtils.GetByPathAll(reqParams, path)) > 0
|
||||
}
|
||||
|
||||
// unitBase 单价基准换算基数:per_1K=1000、per_1M=1000000、其余(per_1/空)按 1
|
||||
func unitBase(unit string) float64 {
|
||||
switch unit {
|
||||
case "per_1K":
|
||||
return 1000
|
||||
case "per_1M":
|
||||
return 1000000
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// sumPrices 各价格项按 unit 基准换算求和(cost = tokens/unit * 单价)
|
||||
func sumPrices(promptTokens, completionTokens, cachedTokens int64, base, inputPrice, outputPrice, cacheHitPrice float64) float64 {
|
||||
return float64(promptTokens)/base*inputPrice +
|
||||
float64(completionTokens)/base*outputPrice +
|
||||
float64(cachedTokens)/base*cacheHitPrice
|
||||
}
|
||||
|
||||
// priceFor 按输入是否音频选择价格:音频变体存在时优先,否则回退默认价
|
||||
func priceFor(basePrice, audioPrice float64, isAudio bool) float64 {
|
||||
if isAudio && audioPrice > 0 {
|
||||
return audioPrice
|
||||
}
|
||||
return basePrice
|
||||
}
|
||||
|
||||
// CalcModelCallCost 按模型计费规则换算本次调用费用;未配置计费规则返回 0。
|
||||
// 供三处调用路径(同步/流式缓冲/流式逐推)在 token 累加后使用。
|
||||
func CalcModelCallCost(c *entity.PriceConfig, reqBizMapping map[string]string, reqParams map[string]any, promptTokens, completionTokens, cachedTokens int64) float64 {
|
||||
return CalcCost(promptTokens, completionTokens, cachedTokens, reqBizMapping, reqParams, c)
|
||||
}
|
||||
|
||||
// CalcCost 计算本次调用费用(纯函数)。返回 0 表示未配置计费规则或无任何价格项。
|
||||
//
|
||||
// 流程:媒体类型由请求体参考媒体字段推导(DetectMediaType)→ matchRule 按 match 条件首条命中定价规则 →
|
||||
// 各价格项按 unit 换算到基准求和 → 折扣(规则级覆盖模型级,命中有效期才生效)→ 收敛 6 位小数。
|
||||
// match 条件:token 档位直接取本次调用用量(promptTokens/completionTokens/totalTokens/cachedTokens)+
|
||||
// 媒体类型(mediaType=audio/no_video/has_video)。
|
||||
func CalcCost(promptTokens, completionTokens, cachedTokens int64, reqBizMapping map[string]string, reqParams map[string]any, c *entity.PriceConfig) float64 {
|
||||
return calcCostWithMediaType(promptTokens, completionTokens, cachedTokens, DetectMediaType(reqBizMapping, reqParams), c)
|
||||
}
|
||||
|
||||
// calcCostWithMediaType 按已推导的媒体类型计算本次调用费用。
|
||||
// 供无法在落库时拿到请求体的调用方使用(如异步任务,媒体类型在任务创建时快照)。
|
||||
func calcCostWithMediaType(promptTokens, completionTokens, cachedTokens int64, mediaType string, c *entity.PriceConfig) float64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
audio := mediaType == "audio"
|
||||
|
||||
rule := matchRule(mediaType, promptTokens, completionTokens, cachedTokens, c.Rules)
|
||||
base := unitBase(c.Unit)
|
||||
var cost float64
|
||||
if rule != nil {
|
||||
cost = sumPrices(promptTokens, completionTokens, cachedTokens, base,
|
||||
priceFor(rule.Input, rule.InputAudio, audio), rule.Output,
|
||||
priceFor(rule.CacheHit, rule.CacheHitAudio, audio))
|
||||
} else if hasFallbackPrices(c) {
|
||||
// 兜底:Rules 为空时的便捷单规则字段(无音频变体,按默认价计)
|
||||
cost = sumPrices(promptTokens, completionTokens, cachedTokens, base, c.InputPrice, c.OutputPrice, c.CacheHitPrice)
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
|
||||
if d := effectiveDiscount(rule, c); d != nil {
|
||||
cost *= d.Rate
|
||||
}
|
||||
return math.Round(cost*1e6) / 1e6
|
||||
}
|
||||
|
||||
// hasFallbackPrices 便捷兜底字段是否配置了任一价格项
|
||||
func hasFallbackPrices(c *entity.PriceConfig) bool {
|
||||
return c.InputPrice > 0 || c.OutputPrice > 0 || c.CacheHitPrice > 0 || c.CacheStorageHourPrice > 0
|
||||
}
|
||||
|
||||
// matchRule 按序取第一条所有 match 条件命中的规则;无命中返回 nil
|
||||
func matchRule(mediaType string, promptTokens, completionTokens, cachedTokens int64, rules []entity.PriceRule) *entity.PriceRule {
|
||||
totalTokens := promptTokens + completionTokens
|
||||
for i := range rules {
|
||||
if matchConditions(mediaType, promptTokens, completionTokens, totalTokens, cachedTokens, rules[i].Match) {
|
||||
return &rules[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// matchConditions 判定 rule.Match 的全部非零字段条件是否满足;Match 为空表示无条件命中。
|
||||
func matchConditions(mediaType string, promptTokens, completionTokens, totalTokens, cachedTokens int64, m *entity.PriceMatch) bool {
|
||||
if m == nil {
|
||||
return true
|
||||
}
|
||||
if !within(promptTokens, m.InputLengthMin, m.InputLengthMax) {
|
||||
return false
|
||||
}
|
||||
if !within(completionTokens, m.OutputLengthMin, m.OutputLengthMax) {
|
||||
return false
|
||||
}
|
||||
if !within(totalTokens, m.TotalLengthMin, m.TotalLengthMax) {
|
||||
return false
|
||||
}
|
||||
if !within(cachedTokens, m.CachedTokensMin, m.CachedTokensMax) {
|
||||
return false
|
||||
}
|
||||
if m.MediaType != "" && mediaType != m.MediaType {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// within 数值是否落在 [min,max];min/max 为 0 表示该侧不设限
|
||||
func within(v int64, min, max int64) bool {
|
||||
if min > 0 && v < min {
|
||||
return false
|
||||
}
|
||||
if max > 0 && v > max {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// effectiveDiscount 判定当前时间是否在折扣有效期内,返回应生效的折扣。
|
||||
// 规则级 Discount 覆盖模型级;effective 为空数组视为长期有效;不在有效期返回 nil。
|
||||
func effectiveDiscount(rule *entity.PriceRule, c *entity.PriceConfig) *entity.PriceDiscount {
|
||||
var d *entity.PriceDiscount
|
||||
if rule != nil && rule.Discount != nil {
|
||||
d = rule.Discount
|
||||
} else {
|
||||
d = c.Discount
|
||||
}
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
if d.Effective[0] != "" && d.Effective[1] != "" {
|
||||
now := time.Now().Format(time.DateOnly)
|
||||
if now < d.Effective[0] || now > d.Effective[1] {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 不消费)
|
||||
type shopPricingConfig struct {
|
||||
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 响应
|
||||
type shopCalcFeeRes struct {
|
||||
Cost float64 `json:"cost"`
|
||||
}
|
||||
|
||||
// pricingURL 组装 shop-user-trade 计价接口地址(common http.RouteRegister 推导前缀,同 ai-agent billing.go)
|
||||
func pricingURL(sub string) string {
|
||||
return "shop-user-trade/pricing/controller/" + sub
|
||||
}
|
||||
|
||||
// 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"), utils.HeadersFromCtx(ctx, utils.HeadersOptions{ResolveToken: true}), &cfg,
|
||||
"subjectType", "model", "subjectId", fmt.Sprintf("%d", modelId))
|
||||
if err != nil {
|
||||
return fmt.Errorf("模型未配置计价,无法调用: %w", err)
|
||||
}
|
||||
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 为 shop 计费词汇
|
||||
// (audio/video,空=无媒体引用走默认价;DetectMediaType/异步快照已直接为该词汇,不再二次转换)。
|
||||
// per_char 模型把输出字数映射到 completionTokens(TokenMapping),随该字段传给 shop /calc 计价。
|
||||
func buildModelUsage(prompt, completion, cached int64, mediaType string, durationSec int64) map[string]any {
|
||||
if durationSec < 0 {
|
||||
durationSec = 0
|
||||
}
|
||||
return map[string]any{
|
||||
"promptTokens": prompt,
|
||||
"completionTokens": completion,
|
||||
"cachedTokens": cached,
|
||||
"mediaType": mediaType,
|
||||
"durationSec": durationSec,
|
||||
}
|
||||
}
|
||||
|
||||
// calcModelCost 调 shop-user-trade /calc 按用量算费(不建单不扣费)。
|
||||
// 调用前门禁已保证配置存在;此处失败(配置中途删除/网络抖动)→ 记日志返回 0,不拖垮已完成的模型调用。
|
||||
func calcModelCost(ctx context.Context, modelId int64, usage map[string]any) float64 {
|
||||
var res shopCalcFeeRes
|
||||
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"`
|
||||
}{
|
||||
SubjectType: "model", SubjectID: fmt.Sprintf("%d", modelId), Usage: usage,
|
||||
})
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "[算费] 调用 shop-user-trade 失败 modelId=%d: %v", modelId, err)
|
||||
return 0
|
||||
}
|
||||
return res.Cost
|
||||
}
|
||||
@@ -25,16 +25,6 @@ func retryWait(ctx context.Context, attempt int) error {
|
||||
}
|
||||
}
|
||||
|
||||
// isRetryableErrorCode 判定上游返回的错误码是否可重试:限流(429/limit_requests/limit_tokens/rate_limit_exceeded)与 5xx(500-503)。
|
||||
// httpclient.ModelHttpNormalRequest 不返回 HTTP status,只能按响应体 error.code 字符串判定。
|
||||
func isRetryableErrorCode(code string) bool {
|
||||
switch code {
|
||||
case "429", "500", "501", "502", "503", "InvalidParameter", "limit_requests", "limit_tokens", "rate_limit_exceeded":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// firstText 取任意值首位文本:数组取首个元素,其余原样转字符串
|
||||
func firstText(v any) string {
|
||||
if arr, ok := v.([]any); ok && len(arr) > 0 {
|
||||
|
||||
+47
-36
@@ -18,7 +18,7 @@ import (
|
||||
)
|
||||
|
||||
// CreateSessionStreamOnce 流式调用上游模型 → 缓冲全量后一次返回(走 gf 框架正常返回)。
|
||||
// 与同步请求一致:上游返回可重试错误码(限流/5xx)时按指数退避重试(最多 modelCallMaxRetries 次)。
|
||||
// 与同步请求一致:上游返回错误时按 shouldRetryWithMemory 判定是否指数退避重试(最多 modelCallMaxRetries 次)。
|
||||
func (s *modelSessionService) CreateSessionStreamOnce(ctx context.Context, req *dto.CallModelSessionReq) (docMsg *dto.ModelCallRes, err error) {
|
||||
startTime := time.Now()
|
||||
|
||||
@@ -29,20 +29,22 @@ func (s *modelSessionService) CreateSessionStreamOnce(ctx context.Context, req *
|
||||
attempt := 0
|
||||
LOOP:
|
||||
// 获取上游流式 reader(stream=false → w 不会被使用,传 nil)。
|
||||
// 非 2xx 状态/网络错误在此返回;错误含可重试错误码(限流/5xx)时按指数退避重试,与同步请求一致。
|
||||
// 非 2xx 状态/网络错误在此返回;按 shouldRetryWithMemory 判定是否指数退避重试,与同步请求一致。
|
||||
streamReader, err := httpclient.ModelHttpStreamRequest(ctx, nil, modelInfo.BaseURL, modelInfo.RequestHeadMapping, modelInfo.HttpMethod, newRequestParams)
|
||||
if err != nil {
|
||||
if retryCode := streamRetryCodeOfError(err); retryCode != "" && attempt < modelCallMaxRetries {
|
||||
attempt++
|
||||
wait := time.Duration(1<<attempt) * time.Second
|
||||
g.Log().Warningf(ctx, "模型流式请求异常,第 %d 次重试(等待 %v): code=%s err=%v", attempt+1, wait, retryCode, err)
|
||||
if waitErr := retryWait(ctx, attempt); waitErr != nil {
|
||||
recordSessionError(context.WithoutCancel(ctx), id, startTime, "调用取消: "+waitErr.Error())
|
||||
return nil, waitErr
|
||||
if attempt < modelCallMaxRetries {
|
||||
if code, msg := streamErrorInfoOfError(err); shouldRetryWithMemory(ctx, modelInfo, code, msg, "") {
|
||||
attempt++
|
||||
wait := time.Duration(1<<attempt) * time.Second
|
||||
g.Log().Warningf(ctx, "模型流式请求异常,第 %d 次重试(等待 %v): code=%s err=%v", attempt+1, wait, code, err)
|
||||
if waitErr := retryWait(ctx, attempt); waitErr != nil {
|
||||
recordSessionError(context.WithoutCancel(ctx), id, startTime, "调用取消: "+waitErr.Error())
|
||||
return nil, waitErr
|
||||
}
|
||||
goto LOOP
|
||||
}
|
||||
goto LOOP
|
||||
}
|
||||
// 非重试错误/重试耗尽:请求失败即返回,需把失败信息写入模型会话记录,避免留半截无错误信息记录
|
||||
// 非重试错误/重试耗尽:请求失败即返回,把失败信息写入模型会话记录
|
||||
recordSessionError(ctx, id, startTime, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
@@ -52,7 +54,7 @@ LOOP:
|
||||
var contentBuf strings.Builder
|
||||
|
||||
// 记录流内 error 事件(OpenAI 兼容 error 分片),供流结束后统一判定重试/报错
|
||||
var streamErrCode, streamErrMsg string
|
||||
var streamErrCode, streamErrMsg, streamErrBody string
|
||||
|
||||
// 路径预处理
|
||||
respMapping := make(map[string]string, len(modelInfo.ResponseBodyMapping))
|
||||
@@ -66,7 +68,7 @@ LOOP:
|
||||
httpclient.ParseSSEStream(ctx, streamReader, func(ctx context.Context, chunk map[string]any) error {
|
||||
// 流内错误事件(OpenAI 兼容 error 分片):暂存错误码/消息,不做内容累加,由流结束后统一判定
|
||||
if code, msg := streamErrorOfChunk(chunk); code != "" {
|
||||
streamErrCode, streamErrMsg = code, msg
|
||||
streamErrCode, streamErrMsg, streamErrBody = code, msg, gconv.String(chunk["error"])
|
||||
return nil
|
||||
}
|
||||
// 基于统一字段路径(GetByPath)在分片对象上取值,取首个数组元素文本累加
|
||||
@@ -83,9 +85,9 @@ LOOP:
|
||||
return nil
|
||||
})
|
||||
|
||||
// 流内返回可重试错误码:丢弃本次部分内容,指数退避后重新请求
|
||||
// 流内返回错误:丢弃本次部分内容,指数退避后重新请求(判定交给 shouldRetryWithMemory)
|
||||
if streamErrCode != "" {
|
||||
if attempt < modelCallMaxRetries && isRetryableErrorCode(streamErrCode) {
|
||||
if attempt < modelCallMaxRetries && shouldRetryWithMemory(ctx, modelInfo, streamErrCode, streamErrMsg, streamErrBody) {
|
||||
attempt++
|
||||
wait := time.Duration(1<<attempt) * time.Second
|
||||
g.Log().Warningf(ctx, "模型流式调用异常,第 %d 次重试(等待 %v): code=%s msg=%s", attempt+1, wait, streamErrCode, streamErrMsg)
|
||||
@@ -126,8 +128,12 @@ LOOP:
|
||||
updateModelSessionReq.ResponsePath = uploadNewResp.FileURL
|
||||
}
|
||||
updateModelSessionReq.DurationSeconds = int64(time.Since(startTime).Seconds())
|
||||
// 按模型计费规则换算本次调用费用(未配置返回 0)
|
||||
docMsg.Cost = CalcModelCallCost(modelInfo.PriceConfig, modelInfo.RequestBusinessFieldMapping, newRequestParams, docMsg.PromptTokens, docMsg.CompletionTokens, 0)
|
||||
// 调 shop-user-trade 按用量算费(不本地换算;调用前门禁已保证配置存在,失败→0 不阻塞)
|
||||
mediaType := modelUtils.DetectMediaType(modelInfo.RequestBusinessFieldMapping, newRequestParams)
|
||||
docMsg.ModelId = modelInfo.Id // 引用行=系统模型 id,供 per_token 结算按系统模型计价
|
||||
docMsg.MediaType = mediaType
|
||||
docMsg.Cost = calcModelCost(ctx, modelInfo.Id,
|
||||
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)
|
||||
@@ -235,8 +241,12 @@ func (s *modelSessionService) CreateSessionStream(ctx context.Context, w http.Re
|
||||
return nil
|
||||
})
|
||||
|
||||
// 流结束:按模型计费规则换算本次调用费用(未配置返回 0)
|
||||
docMsg.Cost = CalcModelCallCost(modelInfo.PriceConfig, modelInfo.RequestBusinessFieldMapping, newRequestParams, docMsg.PromptTokens, docMsg.CompletionTokens, 0)
|
||||
// 流结束:调 shop-user-trade 按用量算费(不本地换算;调用前门禁已保证配置存在,失败→0 不阻塞)
|
||||
mediaType := modelUtils.DetectMediaType(modelInfo.RequestBusinessFieldMapping, newRequestParams)
|
||||
docMsg.ModelId = modelInfo.Id // 引用行=系统模型 id,供 per_token 结算按系统模型计价
|
||||
docMsg.MediaType = mediaType
|
||||
docMsg.Cost = calcModelCost(ctx, modelInfo.Id,
|
||||
buildModelUsage(docMsg.PromptTokens, docMsg.CompletionTokens, 0, mediaType, 0))
|
||||
|
||||
// 流末 done 事件:携带该步最终 token 与费用。工具调用时附带完整 tool_calls,
|
||||
// 纯文本流同样补发,使调用方拿到最终费用与 token;不识别 type=done 的消费方忽略该事件。
|
||||
@@ -313,39 +323,40 @@ func streamErrorOfChunk(chunk map[string]any) (code, msg string) {
|
||||
return
|
||||
}
|
||||
|
||||
// streamRetryCodeOfError 从流式请求错误中提取可重试错误码:优先解析错误体 error.code/顶层 code,
|
||||
// 其次取非 2xx 的 HTTP 状态码;纯网络错误等无错误码场景返回空串(与同步请求一致,不重试)。
|
||||
func streamRetryCodeOfError(err error) string {
|
||||
// streamErrorInfoOfError 从流式请求错误中提取错误码与消息(不做固定清单过滤,交由 shouldRetryWithMemory 判定):
|
||||
// 优先解析错误体 error.code/顶层 code 与 message,其次取非 2xx 的 HTTP 状态码。
|
||||
// 纯网络错误等无错误码场景返回空串。
|
||||
func streamErrorInfoOfError(err error) (code, msg string) {
|
||||
if err == nil {
|
||||
return ""
|
||||
return "", ""
|
||||
}
|
||||
msg := err.Error()
|
||||
e := err.Error()
|
||||
// 非 2xx 时 httpclient.ModelHttpStreamRequest 返回 "[HTTP][Stream] 状态码异常: %d, body={...}"
|
||||
if idx := strings.Index(msg, "body="); idx >= 0 {
|
||||
body := msg[idx+len("body="):]
|
||||
if idx := strings.Index(e, "body="); idx >= 0 {
|
||||
body := e[idx+len("body="):]
|
||||
var errResp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
Code string `json:"code"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if json.Unmarshal([]byte(body), &errResp) == nil {
|
||||
if errResp.Error.Code != "" {
|
||||
return errResp.Error.Code
|
||||
return errResp.Error.Code, errResp.Error.Message
|
||||
}
|
||||
if errResp.Code != "" {
|
||||
return errResp.Code
|
||||
return errResp.Code, errResp.Message
|
||||
}
|
||||
}
|
||||
}
|
||||
if idx := strings.Index(msg, "状态码异常: "); idx >= 0 {
|
||||
codeStr := strings.TrimSpace(msg[idx+len("状态码异常: "):])
|
||||
if idx := strings.Index(e, "状态码异常: "); idx >= 0 {
|
||||
codeStr := strings.TrimSpace(e[idx+len("状态码异常: "):])
|
||||
if comma := strings.IndexByte(codeStr, ','); comma >= 0 {
|
||||
codeStr = codeStr[:comma]
|
||||
}
|
||||
if isRetryableErrorCode(codeStr) {
|
||||
return codeStr
|
||||
}
|
||||
return codeStr, ""
|
||||
}
|
||||
return ""
|
||||
return "", ""
|
||||
}
|
||||
|
||||
@@ -51,13 +51,12 @@ LOOP:
|
||||
}
|
||||
docMsg := new(dto.ModelCallRes)
|
||||
docMsg.TaskId = id
|
||||
errCode, errMsg, err := parseModelError(modelRespBody)
|
||||
errCode, errMsg, err := parseModelError(modelRespBody, modelInfo.ErrorMessageMapping)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("模型返回参数解析失败:%v", err)
|
||||
}
|
||||
if errCode != "" {
|
||||
|
||||
if attempt < modelCallMaxRetries && isRetryableErrorCode(errCode) {
|
||||
if attempt < modelCallMaxRetries && shouldRetryWithMemory(ctx, modelInfo, errCode, errMsg, string(modelRespBody)) {
|
||||
attempt++
|
||||
wait := time.Duration(1<<attempt) * time.Second
|
||||
g.Log().Warningf(ctx, "模型上游调用异常,第 %d 次重试(等待 %v): code=%s err=%v", attempt+1, wait, errCode, errMsg)
|
||||
@@ -66,7 +65,6 @@ LOOP:
|
||||
}
|
||||
goto LOOP
|
||||
}
|
||||
|
||||
docMsg.ErrorMsg = errMsg
|
||||
updateModelSessionReq.ErrorMsg = docMsg.ErrorMsg
|
||||
} else {
|
||||
@@ -120,8 +118,12 @@ LOOP:
|
||||
updateModelSessionReq.ResponsePath = uploadNewResp.FileURL
|
||||
}
|
||||
updateModelSessionReq.DurationSeconds = int64(time.Since(startTime).Seconds())
|
||||
// 9.5) 按模型计费规则换算本次调用费用(未配置返回 0)
|
||||
docMsg.Cost = CalcModelCallCost(modelInfo.PriceConfig, modelInfo.RequestBusinessFieldMapping, newRequestParams, docMsg.PromptTokens, docMsg.CompletionTokens, 0)
|
||||
// 9.5) 调 shop-user-trade 按用量算费(不本地换算;调用前门禁已保证配置存在,失败→0 不阻塞)
|
||||
mediaType := modelUtils.DetectMediaType(modelInfo.RequestBusinessFieldMapping, newRequestParams)
|
||||
docMsg.ModelId = modelInfo.Id // 引用行=系统模型 id,供 per_token 结算按系统模型计价
|
||||
docMsg.MediaType = mediaType
|
||||
docMsg.Cost = calcModelCost(ctx, modelInfo.Id,
|
||||
buildModelUsage(docMsg.PromptTokens, docMsg.CompletionTokens, 0, mediaType, 0))
|
||||
updateModelSessionReq.TotalCost = docMsg.Cost
|
||||
// 10) 更新模型会话信息
|
||||
_, err = dao.ModelSession.Update(ctx, &updateModelSessionReq)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package utils
|
||||
|
||||
// DetectMediaType 按模型业务字段映射从请求体推导输入媒体类型(替代硬编码的 media.type 路径)。
|
||||
// 直接返回 shop-user-trade 计费词汇(audio/video,对齐 ChargeUsage.MediaType),
|
||||
// 无需二次转换(原 mgMediaTypeToShop 已删除):
|
||||
// - reference_audio 映射路径在请求体中有值 → "audio"
|
||||
// - reference_video 映射路径在请求体中有值 → "video"
|
||||
// - 否则 → ""(无媒体引用,shop 侧 pickModelPrice 查不到 mediaPrices 键 → 落默认价)
|
||||
//
|
||||
// 判定完全由模型配置(RequestBusinessFieldMapping,业务字段名见 ChatFieldsReq/VideoFields)驱动,
|
||||
// 无请求结构硬编码;映射路径值即 GetByPathAll 路径(如 input.media?type=audio&url=#)。
|
||||
// 媒体类型仅供 shop-user-trade 算费用量(见 service/pricing_client.go buildModelUsage)。
|
||||
func DetectMediaType(reqBizMapping map[string]string, reqParams map[string]any) string {
|
||||
if hasMediaValue(reqBizMapping, reqParams, "reference_audio") {
|
||||
return "audio"
|
||||
}
|
||||
if hasMediaValue(reqBizMapping, reqParams, "reference_video") {
|
||||
return "video"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// hasMediaValue 业务字段映射路径在请求体中是否命中值
|
||||
func hasMediaValue(reqBizMapping map[string]string, reqParams map[string]any, bizField string) bool {
|
||||
path := reqBizMapping[bizField]
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
return len(GetByPathAll(reqParams, path)) > 0
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"model-gateway/dao"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// ====================== 模型引用解析层 ======================
|
||||
// 引用行(ref_system_model_id>0) 调用时实时取系统模型配置 + 本人 apiKey 合成可执行配置;
|
||||
// 系统模型调整零同步。Id 被覆盖为系统模型 id → 计价/并发键按系统模型走(会话/任务落库仍用引用行 id)。
|
||||
|
||||
const apiKeyPlaceholder = "{apiKey}"
|
||||
|
||||
// replacePlaceholder 递归替换 map/slice/string 中的占位符(泛化自 task_end 的 replaceTaskPlaceholder,非破坏式)。
|
||||
func replacePlaceholder(v any, from, to string) any {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return strings.ReplaceAll(val, from, to)
|
||||
case map[string]any:
|
||||
m := make(map[string]any, len(val))
|
||||
for k, x := range val {
|
||||
m[k] = replacePlaceholder(x, from, to)
|
||||
}
|
||||
return m
|
||||
case map[string]string:
|
||||
m := make(map[string]string, len(val))
|
||||
for k, x := range val {
|
||||
m[k] = strings.ReplaceAll(x, from, to)
|
||||
}
|
||||
return m
|
||||
case []any:
|
||||
arr := make([]any, len(val))
|
||||
for i, x := range val {
|
||||
arr[i] = replacePlaceholder(x, from, to)
|
||||
}
|
||||
return arr
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// copyAndReplaceStringMap 非破坏式替换 map[string]string 值(新建 map,不污染入参)
|
||||
func copyAndReplaceStringMap(src map[string]string, from, to string) map[string]string {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
m := make(map[string]string, len(src))
|
||||
for k, v := range src {
|
||||
m[k] = strings.ReplaceAll(v, from, to)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// substituteAPIPlaceholder 把输入侧配置中的 {apiKey} 替换为生效 key(非破坏式:新建 map/struct,不污染入参)。
|
||||
// 覆盖 BaseURL / RequestHeadMapping / RequestBodyMapping / RequestBusinessFieldMapping /
|
||||
// AsyncTaskMapping(Url/RequestHeadMapping/RequestBodyMapping)。
|
||||
func substituteAPIPlaceholder(m *entity.ModelManage, key string) {
|
||||
m.BaseURL = strings.ReplaceAll(m.BaseURL, apiKeyPlaceholder, key)
|
||||
m.RequestHeadMapping = copyAndReplaceStringMap(m.RequestHeadMapping, apiKeyPlaceholder, key)
|
||||
m.RequestBusinessFieldMapping = copyAndReplaceStringMap(m.RequestBusinessFieldMapping, apiKeyPlaceholder, key)
|
||||
if v, ok := replacePlaceholder(m.RequestBodyMapping, apiKeyPlaceholder, key).(map[string]any); ok {
|
||||
m.RequestBodyMapping = v
|
||||
}
|
||||
if a := m.AsyncTaskMapping; a != nil {
|
||||
ac := *a
|
||||
ac.Url = strings.ReplaceAll(a.Url, apiKeyPlaceholder, key)
|
||||
ac.RequestHeadMapping = copyAndReplaceStringMap(a.RequestHeadMapping, apiKeyPlaceholder, key)
|
||||
if v, ok := replacePlaceholder(a.RequestBodyMapping, apiKeyPlaceholder, key).(map[string]any); ok {
|
||||
ac.RequestBodyMapping = v
|
||||
}
|
||||
m.AsyncTaskMapping = &ac
|
||||
}
|
||||
}
|
||||
|
||||
// mergeReferenceConfig 引用行 + 系统行 → 有效配置(纯函数,便于单测)。
|
||||
// 配置字段取系统行;个人字段(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
|
||||
}
|
||||
if stub.ChatModel != nil {
|
||||
out.ChatModel = stub.ChatModel
|
||||
}
|
||||
if sys.Enabled != nil && !*sys.Enabled {
|
||||
out.Enabled = gconv.PtrBool(false)
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// MergeReferenceConfigForQuery 管理端 Get 查询展示用:以引用行为基底,把系统行的配置列合入,
|
||||
// 保留引用行自身 Id/RefSystemModelId/SystemModel/Creator/时间戳与个人字段(apiKey/enabled/chatModel)。
|
||||
// 与 mergeReferenceConfig 的区别:不替换 {apiKey}(Get 非引用行也不替换,展示模板),
|
||||
// enabled 不做 AND(展示引用行个人开关,调用时才按系统行生效状态门禁)。
|
||||
func MergeReferenceConfigForQuery(stub, sys *entity.ModelManage) *entity.ModelManage {
|
||||
out := *stub
|
||||
out.BaseURL = sys.BaseURL
|
||||
out.HttpMethod = sys.HttpMethod
|
||||
out.ResponseType = sys.ResponseType
|
||||
out.RequestHeadMapping = sys.RequestHeadMapping
|
||||
out.RequestBodyMapping = sys.RequestBodyMapping
|
||||
out.RequestBusinessFieldMapping = sys.RequestBusinessFieldMapping
|
||||
out.ResponseMapping = sys.ResponseMapping
|
||||
out.ResponseBodyMapping = sys.ResponseBodyMapping
|
||||
out.ResponseBusinessFieldMapping = sys.ResponseBusinessFieldMapping
|
||||
out.MaxConcurrency = sys.MaxConcurrency
|
||||
out.TokenMapping = sys.TokenMapping
|
||||
out.AsyncTaskMapping = sys.AsyncTaskMapping
|
||||
out.TokenPredictPrice = sys.TokenPredictPrice
|
||||
out.TokenPredictPriceUnit = sys.TokenPredictPriceUnit
|
||||
out.MaxTokens = sys.MaxTokens
|
||||
out.MinDuration = sys.MinDuration
|
||||
out.MaxDuration = sys.MaxDuration
|
||||
out.LastFrame = sys.LastFrame
|
||||
out.ErrorMessageMapping = sys.ErrorMessageMapping
|
||||
return &out
|
||||
}
|
||||
|
||||
// ResolveModelConfig 把请求命中的模型行解析为可执行配置:
|
||||
// 引用行 → 系统行配置 + 引用行 apiKey(Id 覆盖为系统模型 id);非引用行 → 原配置 + 自身 apiKey 替换占位。
|
||||
// 引用系统模型已删除 → 报错(调用方阻塞)。
|
||||
func ResolveModelConfig(ctx context.Context, m *entity.ModelManage) (*entity.ModelManage, error) {
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if m.RefSystemModelId > 0 {
|
||||
sys, err := dao.ModelManage.GetNotTenantId(ctx, &dto.GetModelManageReq{Id: m.RefSystemModelId})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sys == nil {
|
||||
return nil, fmt.Errorf("引用的系统模型已删除")
|
||||
}
|
||||
out := mergeReferenceConfig(m, sys)
|
||||
substituteAPIPlaceholder(out, out.ApiKey)
|
||||
return out, nil
|
||||
}
|
||||
out := *m
|
||||
substituteAPIPlaceholder(&out, out.ApiKey)
|
||||
return &out, nil
|
||||
}
|
||||
+71
-2
@@ -312,9 +312,78 @@ COMMENT ON COLUMN model_gateway_session.total_cost
|
||||
ALTER TABLE model_gateway_model_task_start
|
||||
ADD COLUMN IF NOT EXISTS media_type VARCHAR(32) DEFAULT NULL;
|
||||
COMMENT ON COLUMN model_gateway_model_task_start.media_type
|
||||
IS '输入媒体类型快照(audio/no_video/has_video,创建任务时按请求体参考媒体字段推导)';
|
||||
IS '输入媒体类型快照(audio/video,空=无媒体引用;shop 计费词汇,创建任务时按请求体参考媒体字段推导)';
|
||||
|
||||
ALTER TABLE model_gateway_model_task_end
|
||||
ADD COLUMN IF NOT EXISTS total_cost NUMERIC DEFAULT 0;
|
||||
COMMENT ON COLUMN model_gateway_model_task_end.total_cost
|
||||
IS '本次调用总费用(元),未配置计费规则为0';
|
||||
IS '本次调用总费用(元),未配置计费规则为0';
|
||||
|
||||
-- =========================
|
||||
-- 模型引用化:model_manage 新增 ref_system_model_id(引用行指向系统模型)
|
||||
-- 系统模型 = 配置唯一来源;引用行只存 apiKey/enabled/chatModel,配置列留空
|
||||
-- =========================
|
||||
ALTER TABLE model_gateway_model_manage
|
||||
ADD COLUMN IF NOT EXISTS ref_system_model_id BIGINT DEFAULT NULL;
|
||||
COMMENT ON COLUMN model_gateway_model_manage.ref_system_model_id
|
||||
IS '引用的系统模型ID(NULL=非引用行;system_model=false 且该列非空=引用行)';
|
||||
CREATE INDEX IF NOT EXISTS idx_model_manage_ref_system_model_id
|
||||
ON model_gateway_model_manage(ref_system_model_id);
|
||||
|
||||
-- =========================
|
||||
-- 存量迁移:拷贝行 → 引用行(按同名系统模型匹配,一次性,幂等)
|
||||
-- 前提:存量拷贝均原封不动、无自定义(用户已确认)。若存在用户独立创建的同名自有模型会被误转,执行前复核一次:
|
||||
-- SELECT r.id, r.creator, r.model_name, r.ref_system_model_id, s.id AS sys_id
|
||||
-- FROM model_gateway_model_manage r
|
||||
-- JOIN model_gateway_model_manage s ON s.model_name = r.model_name AND s.system_model = true
|
||||
-- WHERE r.system_model = false AND r.ref_system_model_id IS NOT NULL;
|
||||
-- 列名以 entity orm 为准(update.sql 顶部旧 DDL 已过时)。
|
||||
-- =========================
|
||||
UPDATE model_gateway_model_manage r SET
|
||||
ref_system_model_id = s.id,
|
||||
base_url = NULL, http_method = NULL,
|
||||
request_head_mapping = NULL, request_body_mapping = NULL,
|
||||
request_business_field_mapping = NULL,
|
||||
response_mapping = NULL, response_body_mapping = NULL,
|
||||
response_business_field_mapping = NULL,
|
||||
max_concurrency = 0, token_mapping = NULL, async_task_mapping = NULL,
|
||||
token_predict_price = 0, token_predict_price_unit = NULL,
|
||||
max_tokens = 0, min_duration = 0, max_duration = 0,
|
||||
last_frame = NULL, error_message_mapping = NULL
|
||||
FROM model_gateway_model_manage s
|
||||
WHERE r.system_model = false AND s.system_model = true
|
||||
AND r.model_name = s.model_name
|
||||
AND r.ref_system_model_id IS NULL;
|
||||
|
||||
-- =========================
|
||||
-- 错误消息映射:model_manage 新增 error_message_mapping(JSONB,schema 树形态,解析模型错误响应用)
|
||||
-- =========================
|
||||
ALTER TABLE model_gateway_model_manage
|
||||
ADD COLUMN IF NOT EXISTS error_message_mapping JSONB DEFAULT NULL;
|
||||
COMMENT ON COLUMN model_gateway_model_manage.error_message_mapping
|
||||
IS '错误消息映射:{code,message} 的 schema 树(type/attrs/value/defaultValue),解析模型错误响应,defaultValue 为成功码';
|
||||
|
||||
|
||||
-- =========================
|
||||
-- 错误重试记忆:LLM 分析上游模型错误是否可重试的持久知识库
|
||||
-- memory_key = SHA-256(upstream|error_code|归一化消息),唯一;命中直接复用,永久有效
|
||||
-- =========================
|
||||
CREATE TABLE IF NOT EXISTS model_gateway_model_error_memory (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT DEFAULT 0,
|
||||
creator VARCHAR(64) DEFAULT '',
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updater VARCHAR(64) DEFAULT '',
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ DEFAULT NULL,
|
||||
memory_key CHAR(64) NOT NULL,
|
||||
upstream VARCHAR(512) NOT NULL DEFAULT '',
|
||||
error_code VARCHAR(128) NOT NULL DEFAULT '',
|
||||
msg_fingerprint CHAR(32) NOT NULL DEFAULT '',
|
||||
retryable BOOLEAN NOT NULL DEFAULT false,
|
||||
reason VARCHAR(512) NOT NULL DEFAULT '',
|
||||
analyzed_by VARCHAR(128) NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_error_memory_memory_key
|
||||
ON model_gateway_model_error_memory (memory_key)
|
||||
WHERE deleted_at IS NULL;
|
||||
Reference in New Issue
Block a user