feat(channel): 通配符定价匹配 + OpenAI BillingModelSource + 按次价格校验 + 用户端计费模式展示

- 定价查找支持通配符(suffix *),最长前缀优先匹配
- 模型限制(restrict_models)同样支持通配符匹配
- OpenAI 网关接入渠道映射/BillingModelSource/模型限制
- 按次/图片计费模式创建时强制要求价格或层级(前后端)
- 用户使用记录列表增加计费模式 badge 列
This commit is contained in:
erio
2026-03-31 00:23:45 +08:00
parent 0fbc9a44d3
commit 8d03c52e15
11 changed files with 255 additions and 22 deletions

View File

@@ -276,11 +276,21 @@ func (h *ChannelHandler) Create(c *gin.Context) {
return
}
pricing := pricingRequestToService(req.ModelPricing)
for _, p := range pricing {
if p.BillingMode == service.BillingModePerRequest || p.BillingMode == service.BillingModeImage {
if p.PerRequestPrice == nil && len(p.Intervals) == 0 {
response.BadRequest(c, "Per-request price or intervals required for per_request/image billing mode")
return
}
}
}
channel, err := h.channelService.Create(c.Request.Context(), &service.CreateChannelInput{
Name: req.Name,
Description: req.Description,
GroupIDs: req.GroupIDs,
ModelPricing: pricingRequestToService(req.ModelPricing),
ModelPricing: pricing,
ModelMapping: req.ModelMapping,
BillingModelSource: req.BillingModelSource,
RestrictModels: req.RestrictModels,
@@ -319,6 +329,14 @@ func (h *ChannelHandler) Update(c *gin.Context) {
}
if req.ModelPricing != nil {
pricing := pricingRequestToService(*req.ModelPricing)
for _, p := range pricing {
if p.BillingMode == service.BillingModePerRequest || p.BillingMode == service.BillingModeImage {
if p.PerRequestPrice == nil && len(p.Intervals) == 0 {
response.BadRequest(c, "Per-request price or intervals required for per_request/image billing mode")
return
}
}
}
input.ModelPricing = &pricing
}

View File

@@ -185,6 +185,20 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
setOpsRequestContext(c, reqModel, reqStream, body)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
// 解析渠道级模型映射
var channelMapping service.ChannelMappingResult
if apiKey.GroupID != nil {
channelMapping = h.gatewayService.ResolveChannelMapping(c.Request.Context(), *apiKey.GroupID, reqModel)
}
// 渠道模型限制检查
if apiKey.GroupID != nil {
if h.gatewayService.IsModelRestricted(c.Request.Context(), *apiKey.GroupID, reqModel) {
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts")
return
}
}
// 提前校验 function_call_output 是否具备可关联上下文,避免上游 400。
if !h.validateFunctionCallOutputRequest(c, body, reqLog) {
return
@@ -379,6 +393,21 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
ChannelID: channelMapping.ChannelID,
OriginalModel: reqModel,
BillingModelSource: channelMapping.BillingModelSource,
ModelMappingChain: func() string {
if !channelMapping.Mapped {
if result.UpstreamModel != "" && result.UpstreamModel != result.Model {
return reqModel + "→" + result.UpstreamModel
}
return ""
}
if result.UpstreamModel != "" && result.UpstreamModel != channelMapping.MappedModel {
return reqModel + "→" + channelMapping.MappedModel + "→" + result.UpstreamModel
}
return reqModel + "→" + channelMapping.MappedModel
}(),
}); err != nil {
logger.L().With(
zap.String("component", "handler.openai_gateway.responses"),
@@ -549,6 +578,20 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
setOpsRequestContext(c, reqModel, reqStream, body)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
// 解析渠道级模型映射
var channelMappingMsg service.ChannelMappingResult
if apiKey.GroupID != nil {
channelMappingMsg = h.gatewayService.ResolveChannelMapping(c.Request.Context(), *apiKey.GroupID, reqModel)
}
// 渠道模型限制检查
if apiKey.GroupID != nil {
if h.gatewayService.IsModelRestricted(c.Request.Context(), *apiKey.GroupID, reqModel) {
h.anthropicErrorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts")
return
}
}
// 绑定错误透传服务,允许 service 层在非 failover 错误场景复用规则。
if h.errorPassthroughService != nil {
service.BindErrorPassthroughService(c, h.errorPassthroughService)
@@ -759,6 +802,21 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
ChannelID: channelMappingMsg.ChannelID,
OriginalModel: reqModel,
BillingModelSource: channelMappingMsg.BillingModelSource,
ModelMappingChain: func() string {
if !channelMappingMsg.Mapped {
if result.UpstreamModel != "" && result.UpstreamModel != result.Model {
return reqModel + "→" + result.UpstreamModel
}
return ""
}
if result.UpstreamModel != "" && result.UpstreamModel != channelMappingMsg.MappedModel {
return reqModel + "→" + channelMappingMsg.MappedModel + "→" + result.UpstreamModel
}
return reqModel + "→" + channelMappingMsg.MappedModel
}(),
}); err != nil {
logger.L().With(
zap.String("component", "handler.openai_gateway.messages"),
@@ -1101,6 +1159,20 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
setOpsRequestContext(c, reqModel, true, firstMessage)
setOpsEndpointContext(c, "", int16(service.RequestTypeWSV2))
// 解析渠道级模型映射
var channelMappingWS service.ChannelMappingResult
if apiKey.GroupID != nil {
channelMappingWS = h.gatewayService.ResolveChannelMapping(ctx, *apiKey.GroupID, reqModel)
}
// 渠道模型限制检查
if apiKey.GroupID != nil {
if h.gatewayService.IsModelRestricted(ctx, *apiKey.GroupID, reqModel) {
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "model not allowed")
return
}
}
var currentUserRelease func()
var currentAccountRelease func()
releaseTurnSlots := func() {
@@ -1259,6 +1331,21 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
IPAddress: clientIP,
RequestPayloadHash: service.HashUsageRequestPayload(firstMessage),
APIKeyService: h.apiKeyService,
ChannelID: channelMappingWS.ChannelID,
OriginalModel: reqModel,
BillingModelSource: channelMappingWS.BillingModelSource,
ModelMappingChain: func() string {
if !channelMappingWS.Mapped {
if result.UpstreamModel != "" && result.UpstreamModel != result.Model {
return reqModel + "→" + result.UpstreamModel
}
return ""
}
if result.UpstreamModel != "" && result.UpstreamModel != channelMappingWS.MappedModel {
return reqModel + "→" + channelMappingWS.MappedModel + "→" + result.UpstreamModel
}
return reqModel + "→" + channelMappingWS.MappedModel
}(),
}); err != nil {
reqLog.Error("openai.websocket_record_usage_failed",
zap.Int64("account_id", account.ID),