mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
feat(grok): 视频按模型族配置每秒单价,并补齐管理端映射设置
分组新增 video_model_prices(JSONB);计费优先模型×分辨率覆盖, 其次旧分辨率三列,最后官方按模型族默认价。同步补齐 admin 设置 中的 grok_default_text_model / 跨客户端映射开关,并保持 grok-imagine-video-1.5 请求模型 identity 不被静默改写。
This commit is contained in:
@@ -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)。
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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_* 列或官方默认';
|
||||
Reference in New Issue
Block a user