diff --git a/backend/docs/GROK_INTEGRATION_PROGRESS.md b/backend/docs/GROK_INTEGRATION_PROGRESS.md new file mode 100644 index 0000000000..aa73c87a49 --- /dev/null +++ b/backend/docs/GROK_INTEGRATION_PROGRESS.md @@ -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)。 diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index e99ba7b755..62960fdd83 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -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, diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index a9d8b0d88e..7ff2f87cc2 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -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, diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 5619641c53..34c084d82b 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -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, diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index c6f6cf25e2..0ac92284a7 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -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, diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 6eccb92c8e..75e7972a4d 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -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"` diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 37927c2b48..871f0ab13b 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -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"` diff --git a/backend/internal/pkg/xai/models.go b/backend/internal/pkg/xai/models.go index 671655d2cf..25584f0843 100644 --- a/backend/internal/pkg/xai/models.go +++ b/backend/internal/pkg/xai/models.go @@ -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 { diff --git a/backend/internal/pkg/xai/models_test.go b/backend/internal/pkg/xai/models_test.go index 82c04a6bb0..6ed61f3e7b 100644 --- a/backend/internal/pkg/xai/models_test.go +++ b/backend/internal/pkg/xai/models_test.go @@ -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"]) diff --git a/backend/internal/pkg/xai/oauth_test.go b/backend/internal/pkg/xai/oauth_test.go index 6b72c58409..7c179a5ec1 100644 --- a/backend/internal/pkg/xai/oauth_test.go +++ b/backend/internal/pkg/xai/oauth_test.go @@ -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") } diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index 31724f7c55..be008f4912 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -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) } diff --git a/backend/internal/repository/group_video_model_prices.go b/backend/internal/repository/group_video_model_prices.go new file mode 100644 index 0000000000..af6b05ff86 --- /dev/null +++ b/backend/internal/repository/group_video_model_prices.go @@ -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 +} diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index a43f26a8ad..cf9b25fbd3 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -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 diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index 2fea013aff..7ae43c3f37 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -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 { diff --git a/backend/internal/service/video_billing.go b/backend/internal/service/video_billing.go new file mode 100644 index 0000000000..97950cad37 --- /dev/null +++ b/backend/internal/service/video_billing.go @@ -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 +} diff --git a/backend/internal/service/video_billing_test.go b/backend/internal/service/video_billing_test.go new file mode 100644 index 0000000000..0130c9cca9 --- /dev/null +++ b/backend/internal/service/video_billing_test.go @@ -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")) +} diff --git a/backend/migrations/217_group_video_model_prices.sql b/backend/migrations/217_group_video_model_prices.sql new file mode 100644 index 0000000000..61080015d4 --- /dev/null +++ b/backend/migrations/217_group_video_model_prices.sql @@ -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_* 列或官方默认';