feat(grok): 视频按模型族配置每秒单价,并补齐管理端映射设置

分组新增 video_model_prices(JSONB);计费优先模型×分辨率覆盖,
其次旧分辨率三列,最后官方按模型族默认价。同步补齐 admin 设置
中的 grok_default_text_model / 跨客户端映射开关,并保持
grok-imagine-video-1.5 请求模型 identity 不被静默改写。
This commit is contained in:
IanShaw027
2026-08-07 12:44:08 +08:00
parent 370bdcf695
commit eb6c9663e7
17 changed files with 444 additions and 55 deletions
+21
View File
@@ -0,0 +1,21 @@
# Grok 完整整合进度(单 PR)
分支:`feat/grok-complete-integration`(基准 `upstream/main`)
## 已完成阶段
1. **模型目录与可配置映射** — 默认禁止 gpt/claude→grok-4.5;设置项 `grok_default_text_model` / `grok_cross_client_model_map_enabled`
2. **密码登录 + SSO 校验** — `POST .../oauth/password`、`.../oauth/sso-token`;不落库密码/raw SSO
3. **视频按模型族定价** — `groups.video_model_prices` JSONB;计费顺序:模型×分辨率 → 旧三列 → 官方默认
## 待续阶段
- free-tier / cooldown / payment-required 调度
- media/voice 增量与错误语义
- 网关 tool_choice / active-delta(默认关)/ web_search
- 前端 CreateAccount SSO/密码入口
- 详见子代理审计结论(对照 personal-dev vs main)
## 原则
以 main 为底重写,不整文件 pick personal-dev;migration 使用新序号(如 217)。
+54 -50
View File
@@ -106,31 +106,32 @@ type CreateGroupRequest struct {
WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"`
MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"`
// 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置)
AllowImageGeneration bool `json:"allow_image_generation"`
AllowBatchImageGeneration bool `json:"allow_batch_image_generation"`
ImageRateIndependent bool `json:"image_rate_independent"`
ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"`
BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"`
VideoRateIndependent bool `json:"video_rate_independent"`
VideoRateMultiplier *float64 `json:"video_rate_multiplier"`
PeakRateEnabled bool `json:"peak_rate_enabled"`
PeakStart string `json:"peak_start"`
PeakEnd string `json:"peak_end"`
PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
ProfitControlEnabled bool `json:"profit_control_enabled"`
ProfitMinMargin *float64 `json:"profit_min_margin"`
ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"`
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
VideoPrice480P *float64 `json:"video_price_480p"`
VideoPrice720P *float64 `json:"video_price_720p"`
VideoPrice1080P *float64 `json:"video_price_1080p"`
WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
ClaudeCodeOnly bool `json:"claude_code_only"`
FallbackGroupID *int64 `json:"fallback_group_id"`
FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"`
AllowImageGeneration bool `json:"allow_image_generation"`
AllowBatchImageGeneration bool `json:"allow_batch_image_generation"`
ImageRateIndependent bool `json:"image_rate_independent"`
ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"`
BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"`
VideoRateIndependent bool `json:"video_rate_independent"`
VideoRateMultiplier *float64 `json:"video_rate_multiplier"`
PeakRateEnabled bool `json:"peak_rate_enabled"`
PeakStart string `json:"peak_start"`
PeakEnd string `json:"peak_end"`
PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
ProfitControlEnabled bool `json:"profit_control_enabled"`
ProfitMinMargin *float64 `json:"profit_min_margin"`
ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"`
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
VideoPrice480P *float64 `json:"video_price_480p"`
VideoPrice720P *float64 `json:"video_price_720p"`
VideoPrice1080P *float64 `json:"video_price_1080p"`
VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"`
WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
ClaudeCodeOnly bool `json:"claude_code_only"`
FallbackGroupID *int64 `json:"fallback_group_id"`
FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"`
// 模型路由配置(仅 anthropic 平台使用)
ModelRouting map[string][]int64 `json:"model_routing"`
ModelRoutingEnabled bool `json:"model_routing_enabled"`
@@ -168,31 +169,32 @@ type UpdateGroupRequest struct {
WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"`
MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"`
// 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置)
AllowImageGeneration *bool `json:"allow_image_generation"`
AllowBatchImageGeneration *bool `json:"allow_batch_image_generation"`
ImageRateIndependent *bool `json:"image_rate_independent"`
ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"`
BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"`
VideoRateIndependent *bool `json:"video_rate_independent"`
VideoRateMultiplier *float64 `json:"video_rate_multiplier"`
PeakRateEnabled *bool `json:"peak_rate_enabled"`
PeakStart *string `json:"peak_start"`
PeakEnd *string `json:"peak_end"`
PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
ProfitControlEnabled *bool `json:"profit_control_enabled"`
ProfitMinMargin *float64 `json:"profit_min_margin"`
ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"`
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
VideoPrice480P *float64 `json:"video_price_480p"`
VideoPrice720P *float64 `json:"video_price_720p"`
VideoPrice1080P *float64 `json:"video_price_1080p"`
WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
ClaudeCodeOnly *bool `json:"claude_code_only"`
FallbackGroupID *int64 `json:"fallback_group_id"`
FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"`
AllowImageGeneration *bool `json:"allow_image_generation"`
AllowBatchImageGeneration *bool `json:"allow_batch_image_generation"`
ImageRateIndependent *bool `json:"image_rate_independent"`
ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"`
BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"`
VideoRateIndependent *bool `json:"video_rate_independent"`
VideoRateMultiplier *float64 `json:"video_rate_multiplier"`
PeakRateEnabled *bool `json:"peak_rate_enabled"`
PeakStart *string `json:"peak_start"`
PeakEnd *string `json:"peak_end"`
PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
ProfitControlEnabled *bool `json:"profit_control_enabled"`
ProfitMinMargin *float64 `json:"profit_min_margin"`
ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"`
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
VideoPrice480P *float64 `json:"video_price_480p"`
VideoPrice720P *float64 `json:"video_price_720p"`
VideoPrice1080P *float64 `json:"video_price_1080p"`
VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"`
WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
ClaudeCodeOnly *bool `json:"claude_code_only"`
FallbackGroupID *int64 `json:"fallback_group_id"`
FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"`
// 模型路由配置(仅 anthropic 平台使用)
ModelRouting map[string][]int64 `json:"model_routing"`
ModelRoutingEnabled *bool `json:"model_routing_enabled"`
@@ -519,6 +521,7 @@ func (h *GroupHandler) Create(c *gin.Context) {
VideoPrice480P: req.VideoPrice480P,
VideoPrice720P: req.VideoPrice720P,
VideoPrice1080P: req.VideoPrice1080P,
VideoModelPrices: req.VideoModelPrices,
WebSearchPricePerCall: req.WebSearchPricePerCall,
ClaudeCodeOnly: req.ClaudeCodeOnly,
FallbackGroupID: req.FallbackGroupID,
@@ -641,6 +644,7 @@ func (h *GroupHandler) Update(c *gin.Context) {
VideoPrice480P: req.VideoPrice480P,
VideoPrice720P: req.VideoPrice720P,
VideoPrice1080P: req.VideoPrice1080P,
VideoModelPrices: req.VideoModelPrices,
WebSearchPricePerCall: req.WebSearchPricePerCall,
ClaudeCodeOnly: req.ClaudeCodeOnly,
FallbackGroupID: req.FallbackGroupID,
@@ -372,6 +372,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
GrokDefaultTextModel: settings.GrokDefaultTextModel,
GrokCrossClientModelMapEnabled: settings.GrokCrossClientModelMapEnabled,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
ModelPlazaEnabled: settings.ModelPlazaEnabled,
@@ -330,6 +330,10 @@ type UpdateSettingsRequest struct {
ChannelMonitorEnabled *bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"`
// Grok model mapping policy
GrokDefaultTextModel *string `json:"grok_default_text_model"`
GrokCrossClientModelMapEnabled *bool `json:"grok_cross_client_model_map_enabled"`
// Available Channels feature switch (user-facing)
AvailableChannelsEnabled *bool `json:"available_channels_enabled"`
@@ -1860,6 +1864,18 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.ChannelMonitorDefaultIntervalSeconds
}(),
GrokDefaultTextModel: func() string {
if req.GrokDefaultTextModel != nil {
return *req.GrokDefaultTextModel
}
return previousSettings.GrokDefaultTextModel
}(),
GrokCrossClientModelMapEnabled: func() bool {
if req.GrokCrossClientModelMapEnabled != nil {
return *req.GrokCrossClientModelMapEnabled
}
return previousSettings.GrokCrossClientModelMapEnabled
}(),
AvailableChannelsEnabled: func() bool {
if req.AvailableChannelsEnabled != nil {
return *req.AvailableChannelsEnabled
@@ -2293,6 +2309,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
ChannelMonitorEnabled: updatedSettings.ChannelMonitorEnabled,
ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds,
GrokDefaultTextModel: updatedSettings.GrokDefaultTextModel,
GrokCrossClientModelMapEnabled: updatedSettings.GrokCrossClientModelMapEnabled,
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled,
+1
View File
@@ -202,6 +202,7 @@ func groupFromServiceBase(g *service.Group) Group {
VideoPrice480P: g.VideoPrice480P,
VideoPrice720P: g.VideoPrice720P,
VideoPrice1080P: g.VideoPrice1080P,
VideoModelPrices: g.VideoModelPrices,
WebSearchPricePerCall: g.WebSearchPricePerCall,
ClaudeCodeOnly: g.ClaudeCodeOnly,
FallbackGroupID: g.FallbackGroupID,
+4
View File
@@ -303,6 +303,10 @@ type SystemSettings struct {
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
// Grok model mapping policy (admin settings; empty account mapping falls back to these).
GrokDefaultTextModel string `json:"grok_default_text_model"`
GrokCrossClientModelMapEnabled bool `json:"grok_cross_client_model_map_enabled"`
// Available Channels feature switch (user-facing aggregate view)
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
+2
View File
@@ -121,6 +121,8 @@ type Group struct {
VideoPrice480P *float64 `json:"video_price_480p"`
VideoPrice720P *float64 `json:"video_price_720p"`
VideoPrice1080P *float64 `json:"video_price_1080p"`
// VideoModelPrices 可选按模型族×分辨率覆盖视频每秒单价 (USD/s)。
VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"`
// Codex alpha/search 网页搜索单次价格(USD/次);null 表示使用默认价 0.01
WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
+4 -1
View File
@@ -169,9 +169,12 @@ func ModelMappingWithOptions(opts ModelMappingOptions) map[string]string {
mapping["grok-imagine-edit"] = "grok-imagine-edit"
mapping["grok-imagine-image"] = DefaultImagineImageFastModel
mapping["grok-imagine-image-quality"] = DefaultImagineImageQualityModel
// Keep official IDs as identity so client-requested model strings are not
// rewritten on the wire (pricing still canonicalizes 1.5* via CanonicalImagineVideoModel).
mapping["grok-imagine-video"] = DefaultImagineVideoModel
mapping["grok-imagine-video-1.5"] = DefaultImagineVideo15Model
mapping["grok-imagine-video-1.5"] = DefaultImagineVideo15LegacyModel
mapping["grok-imagine-video-1.5-preview"] = DefaultImagineVideo15Model
// Informal alias only:
mapping["grok-video-1.5"] = DefaultImagineVideo15Model
if opts.EnableCrossClientMap {
+1 -1
View File
@@ -15,7 +15,7 @@ func TestDefaultModelMappingExcludesCrossClientWildcards(t *testing.T) {
require.Equal(t, "grok-4.5", mapping["grok-latest"])
require.Equal(t, "grok-build-0.1", mapping["grok-build"])
require.Equal(t, "grok-build-0.1", mapping["grok-build-latest"])
require.Equal(t, DefaultImagineVideo15Model, mapping["grok-imagine-video-1.5"])
require.Equal(t, DefaultImagineVideo15LegacyModel, mapping["grok-imagine-video-1.5"])
require.Equal(t, DefaultImagineVideo15Model, mapping["grok-imagine-video-1.5-preview"])
require.Equal(t, "grok-4.5", mapping["xai/grok"])
+2 -1
View File
@@ -358,7 +358,8 @@ func TestDefaultModelMappingIncludesGrokAliases(t *testing.T) {
require.Equal(t, DefaultImagineImageQualityModel, mapping["grok-imagine-image-quality"])
require.Equal(t, "grok-imagine-edit", mapping["grok-imagine-edit"])
require.Equal(t, DefaultImagineVideoModel, mapping["grok-imagine-video"])
require.Equal(t, DefaultImagineVideo15Model, mapping["grok-imagine-video-1.5"])
require.Equal(t, DefaultImagineVideo15LegacyModel, mapping["grok-imagine-video-1.5"])
require.Equal(t, DefaultImagineVideo15Model, mapping["grok-imagine-video-1.5-preview"])
_, hasGPT := mapping["gpt-*"]
require.False(t, hasGPT, "cross-client wildcards must be opt-in")
}
+11 -1
View File
@@ -46,6 +46,9 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er
if err := createGroupRecord(ctx, r.client, groupIn); err != nil {
return err
}
if saveErr := saveGroupVideoModelPrices(ctx, r.sql, groupIn.ID, groupIn.VideoModelPrices); saveErr != nil {
return fmt.Errorf("save group video_model_prices: %w", saveErr)
}
if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventGroupChanged, nil, &groupIn.ID, nil); err != nil {
logger.LegacyPrintf("repository.group", "[SchedulerOutbox] enqueue group create failed: group=%d err=%v", groupIn.ID, err)
}
@@ -225,7 +228,11 @@ func (r *groupRepository) GetByIDLite(ctx context.Context, id int64) (*service.G
if err != nil {
return nil, translatePersistenceError(err, service.ErrGroupNotFound, nil)
}
return groupEntityToService(m), nil
out := groupEntityToService(m)
if prices, loadErr := loadGroupVideoModelPrices(ctx, r.sql, []int64{id}); loadErr == nil {
applyVideoModelPricesToGroup(out, prices)
}
return out, nil
}
func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) error {
@@ -356,6 +363,9 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
return translatePersistenceError(err, service.ErrGroupNotFound, service.ErrGroupExists)
}
groupIn.UpdatedAt = updated.UpdatedAt
if err := saveGroupVideoModelPrices(ctx, r.sql, groupIn.ID, groupIn.VideoModelPrices); err != nil {
return fmt.Errorf("save group video_model_prices: %w", err)
}
if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventGroupChanged, nil, &groupIn.ID, nil); err != nil {
logger.LegacyPrintf("repository.group", "[SchedulerOutbox] enqueue group update failed: group=%d err=%v", groupIn.ID, err)
}
@@ -0,0 +1,114 @@
package repository
import (
"context"
"database/sql"
"encoding/json"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/lib/pq"
)
func loadGroupVideoModelPrices(ctx context.Context, sqlq sqlExecutor, groupIDs []int64) (map[int64]map[string]map[string]float64, error) {
out := make(map[int64]map[string]map[string]float64, len(groupIDs))
if sqlq == nil || len(groupIDs) == 0 {
return out, nil
}
rows, err := sqlq.QueryContext(ctx, `
SELECT id, video_model_prices
FROM groups
WHERE id = ANY($1) AND deleted_at IS NULL
`, pq.Array(groupIDs))
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
groupID int64
raw []byte
)
if err := rows.Scan(&groupID, &raw); err != nil {
return nil, err
}
prices, err := decodeVideoModelPrices(raw)
if err != nil {
return nil, err
}
if prices != nil {
out[groupID] = prices
}
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func saveGroupVideoModelPrices(ctx context.Context, sqlq sqlExecutor, groupID int64, prices map[string]map[string]float64) error {
if sqlq == nil || groupID <= 0 {
return nil
}
normalized := service.NormalizeVideoModelPrices(prices)
if len(normalized) == 0 {
_, err := sqlq.ExecContext(ctx, `
UPDATE groups
SET video_model_prices = NULL
WHERE id = $1
`, groupID)
return err
}
payload, err := json.Marshal(normalized)
if err != nil {
return err
}
_, err = sqlq.ExecContext(ctx, `
UPDATE groups
SET video_model_prices = $1::jsonb
WHERE id = $2
`, string(payload), groupID)
return err
}
func applyVideoModelPricesToGroups(groups []service.Group, pricesByID map[int64]map[string]map[string]float64) {
for i := range groups {
if prices, ok := pricesByID[groups[i].ID]; ok {
groups[i].VideoModelPrices = service.NormalizeVideoModelPrices(prices)
continue
}
groups[i].VideoModelPrices = service.NormalizeVideoModelPrices(groups[i].VideoModelPrices)
}
}
func applyVideoModelPricesToGroup(group *service.Group, pricesByID map[int64]map[string]map[string]float64) {
if group == nil {
return
}
if prices, ok := pricesByID[group.ID]; ok {
group.VideoModelPrices = service.NormalizeVideoModelPrices(prices)
return
}
group.VideoModelPrices = service.NormalizeVideoModelPrices(group.VideoModelPrices)
}
func decodeVideoModelPrices(raw []byte) (map[string]map[string]float64, error) {
if len(raw) == 0 {
return nil, nil
}
// Driver may return NULL as nil slice; treat empty JSON as nil.
trimmed := string(raw)
if trimmed == "" || trimmed == "null" {
return nil, nil
}
var parsed map[string]map[string]float64
if err := json.Unmarshal(raw, &parsed); err != nil {
// Some drivers surface NULL via sql.NullString paths; tolerate empty object.
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return service.NormalizeVideoModelPrices(parsed), nil
}
+8 -1
View File
@@ -1415,6 +1415,9 @@ type VideoPriceConfig struct {
Price480P *float64 // 480p 每秒价格(nil 表示使用默认值)
Price720P *float64 // 720p 每秒价格(nil 表示使用默认值)
Price1080P *float64 // 1080p 每秒价格(nil 表示使用默认值)
// ModelPrices is optional per-model-family override: family → resolution → USD/s.
// When set for a model, it wins over Price* flat columns for that model only.
ModelPrices map[string]map[string]float64
}
const (
@@ -1546,8 +1549,12 @@ func (s *BillingService) getImageUnitPrice(model string, imageSize string, group
}
func (s *BillingService) getVideoUnitPrice(model string, resolution string, groupConfig *VideoPriceConfig) float64 {
// Order: (a) per-model map (b) flat group video_price_* (c) model-aware code defaults.
if groupConfig != nil {
switch resolution {
if price := LookupVideoModelPrice(groupConfig.ModelPrices, model, resolution); price != nil {
return *price
}
switch NormalizeVideoBillingResolutionOrDefault(resolution) {
case VideoBillingResolution480P:
if groupConfig.Price480P != nil {
return *groupConfig.Price480P
+28
View File
@@ -55,6 +55,10 @@ type Group struct {
VideoPrice480P *float64
VideoPrice720P *float64
VideoPrice1080P *float64
// VideoModelPrices is optional per-model-family per-second pricing
// (groups.video_model_prices JSONB). Shape: family → resolution → USD/s.
// When set for a model, overrides VideoPrice* for that model only.
VideoModelPrices map[string]map[string]float64
// Codex alpha/search 网页搜索单次价格(USD/次,仅 openai 平台使用);
// nil 表示使用默认价 defaultWebSearchPricePerCall(官方 $10/1000 次)。
WebSearchPricePerCall *float64
@@ -168,6 +172,30 @@ func (g *Group) GetVideoPrice(resolution string) *float64 {
}
}
// GetVideoPriceForModel prefers VideoModelPrices for the model family, then flat columns.
func (g *Group) GetVideoPriceForModel(model, resolution string) *float64 {
if g == nil {
return nil
}
if price := LookupVideoModelPrice(g.VideoModelPrices, model, resolution); price != nil {
return price
}
return g.GetVideoPrice(resolution)
}
// VideoPriceConfig builds billing config including optional per-model map.
func (g *Group) VideoPriceConfig() *VideoPriceConfig {
if g == nil {
return nil
}
return &VideoPriceConfig{
Price480P: g.VideoPrice480P,
Price720P: g.VideoPrice720P,
Price1080P: g.VideoPrice1080P,
ModelPrices: NormalizeVideoModelPrices(g.VideoModelPrices),
}
}
// IsGroupContextValid reports whether a group from context has the fields required for routing decisions.
func IsGroupContextValid(group *Group) bool {
if group == nil {
+126
View File
@@ -0,0 +1,126 @@
package service
import (
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
)
// Canonical video price family keys used in groups.video_model_prices JSONB.
const (
VideoPriceFamilyGrokImagineVideo = "grok-imagine-video"
VideoPriceFamilyGrokImagineVideo15 = "grok-imagine-video-1.5"
)
// CanonicalGrokImagineVideoPriceFamily normalizes model aliases / preview / legacy
// IDs onto the price-family keys stored in video_model_prices.
func CanonicalGrokImagineVideoPriceFamily(model string) string {
if model == "" {
return ""
}
// Prefer shared xAI helper when available.
if c := xai.CanonicalImagineVideoModel(model); c != "" {
switch {
case strings.HasPrefix(c, "grok-imagine-video-1.5"):
return VideoPriceFamilyGrokImagineVideo15
case strings.HasPrefix(c, "grok-imagine-video"):
return VideoPriceFamilyGrokImagineVideo
}
}
m := strings.ToLower(strings.TrimSpace(model))
for _, prefix := range []string{"xai/", "x-ai/", "grok/"} {
if strings.HasPrefix(m, prefix) {
m = strings.TrimPrefix(m, prefix)
break
}
}
switch {
case m == "grok-imagine-video-1.5" || m == "grok-imagine-video-1.5-preview" ||
m == "grok-video-1.5" || strings.Contains(m, "video-1.5"):
return VideoPriceFamilyGrokImagineVideo15
case m == "grok-imagine-video" || m == "grok-video" || m == "grok-video-latest" ||
strings.HasPrefix(m, "grok-imagine-video") || strings.HasPrefix(m, "grok-video"):
return VideoPriceFamilyGrokImagineVideo
default:
return ""
}
}
// NormalizeVideoModelPrices cleans and canonicalizes a per-model resolution map.
// Keys become price families; tiers use 480p/720p/1080p. Negative prices dropped.
func NormalizeVideoModelPrices(in map[string]map[string]float64) map[string]map[string]float64 {
if len(in) == 0 {
return nil
}
out := make(map[string]map[string]float64)
for modelKey, tierPrices := range in {
if len(tierPrices) == 0 {
continue
}
family := CanonicalGrokImagineVideoPriceFamily(modelKey)
if family == "" {
key := strings.ToLower(strings.TrimSpace(modelKey))
switch key {
case VideoPriceFamilyGrokImagineVideo, VideoPriceFamilyGrokImagineVideo15:
family = key
default:
if key == "" {
continue
}
family = key
}
}
normalizedTiers := out[family]
if normalizedTiers == nil {
normalizedTiers = make(map[string]float64)
}
for tierKey, price := range tierPrices {
if price < 0 {
continue
}
tier := NormalizeVideoBillingResolutionOrDefault(tierKey)
normalizedTiers[tier] = price
}
if len(normalizedTiers) > 0 {
out[family] = normalizedTiers
}
}
if len(out) == 0 {
return nil
}
return out
}
// LookupVideoModelPrice returns a per-second price from a model×resolution map, or nil.
func LookupVideoModelPrice(prices map[string]map[string]float64, model, resolution string) *float64 {
if len(prices) == 0 {
return nil
}
family := CanonicalGrokImagineVideoPriceFamily(model)
if family == "" {
family = strings.ToLower(strings.TrimSpace(model))
}
if family == "" {
return nil
}
tierPrices, ok := prices[family]
if !ok || len(tierPrices) == 0 {
return nil
}
tier := NormalizeVideoBillingResolutionOrDefault(resolution)
if price, ok := tierPrices[tier]; ok {
p := price
return &p
}
for _, candidate := range []string{
VideoBillingResolution1080P,
VideoBillingResolution720P,
VideoBillingResolution480P,
} {
if price, ok := tierPrices[candidate]; ok {
p := price
return &p
}
}
return nil
}
@@ -0,0 +1,38 @@
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestCanonicalGrokImagineVideoPriceFamily(t *testing.T) {
t.Parallel()
require.Equal(t, VideoPriceFamilyGrokImagineVideo, CanonicalGrokImagineVideoPriceFamily("grok-imagine-video"))
require.Equal(t, VideoPriceFamilyGrokImagineVideo15, CanonicalGrokImagineVideoPriceFamily("grok-imagine-video-1.5"))
require.Equal(t, VideoPriceFamilyGrokImagineVideo15, CanonicalGrokImagineVideoPriceFamily("grok-imagine-video-1.5-preview"))
require.Equal(t, VideoPriceFamilyGrokImagineVideo15, CanonicalGrokImagineVideoPriceFamily("xai/grok-video-1.5"))
}
func TestNormalizeAndLookupVideoModelPrices(t *testing.T) {
t.Parallel()
raw := map[string]map[string]float64{
"grok-imagine-video-1.5-preview": {"480p": 0.08, "720p": 0.14},
"grok-imagine-video": {"480p": 0.05},
}
norm := NormalizeVideoModelPrices(raw)
require.NotNil(t, norm)
require.Contains(t, norm, VideoPriceFamilyGrokImagineVideo15)
require.Contains(t, norm, VideoPriceFamilyGrokImagineVideo)
p15 := LookupVideoModelPrice(norm, "grok-imagine-video-1.5", "480p")
require.NotNil(t, p15)
require.InDelta(t, 0.08, *p15, 1e-9)
pBase := LookupVideoModelPrice(norm, "grok-imagine-video", "480p")
require.NotNil(t, pBase)
require.InDelta(t, 0.05, *pBase, 1e-9)
// Unmatched model → nil (caller falls back to flat columns / defaults).
require.Nil(t, LookupVideoModelPrice(norm, "unknown-model", "480p"))
}
@@ -0,0 +1,8 @@
-- Per-model-family video per-second prices for Grok Imagine.
-- Shape: {"grok-imagine-video":{"480p":0.05,"720p":0.07},"grok-imagine-video-1.5":{"480p":0.08,"720p":0.14,"1080p":0.25}}
-- Resolution order in billing: per-model map → legacy video_price_* columns → code defaults (model-aware).
ALTER TABLE groups
ADD COLUMN IF NOT EXISTS video_model_prices JSONB;
COMMENT ON COLUMN groups.video_model_prices IS
'可选:按模型族×分辨率覆盖视频每秒单价 (USD/s)。key 为规范模型族 (grok-imagine-video / grok-imagine-video-1.5),value 为分辨率→单价映射;NULL/空表示不覆盖,回退到 video_price_* 列或官方默认';