diff --git a/backend/internal/handler/admin/payment_handler.go b/backend/internal/handler/admin/payment_handler.go index 1200b47f8..f01917740 100644 --- a/backend/internal/handler/admin/payment_handler.go +++ b/backend/internal/handler/admin/payment_handler.go @@ -283,7 +283,62 @@ func (h *PaymentHandler) ListPlans(c *gin.Context) { response.ErrorFrom(c, err) return } - response.Success(c, plans) + groupInfo := h.configService.GetGroupInfoMap(c.Request.Context(), plans) + response.Success(c, adminSubscriptionPlansForResponse(plans, groupInfo)) +} + +type AdminSubscriptionPlanResult struct { + ID int64 `json:"id"` + GroupID int64 `json:"group_id"` + GroupPlatform string `json:"group_platform,omitempty"` + GroupName string `json:"group_name,omitempty"` + RateMultiplier float64 `json:"rate_multiplier,omitempty"` + DailyLimitUSD *float64 `json:"daily_limit_usd,omitempty"` + WeeklyLimitUSD *float64 `json:"weekly_limit_usd,omitempty"` + MonthlyLimitUSD *float64 `json:"monthly_limit_usd,omitempty"` + ModelScopes []string `json:"supported_model_scopes,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + Price float64 `json:"price"` + OriginalPrice *float64 `json:"original_price,omitempty"` + ValidityDays int `json:"validity_days"` + ValidityUnit string `json:"validity_unit"` + Features string `json:"features"` + ProductName string `json:"product_name"` + ForSale bool `json:"for_sale"` + SortOrder int `json:"sort_order"` +} + +func adminSubscriptionPlansForResponse(plans []*dbent.SubscriptionPlan, groupInfo map[int64]service.PlanGroupInfo) []AdminSubscriptionPlanResult { + result := make([]AdminSubscriptionPlanResult, 0, len(plans)) + for _, p := range plans { + if p == nil { + continue + } + gi := groupInfo[p.GroupID] + result = append(result, AdminSubscriptionPlanResult{ + ID: int64(p.ID), + GroupID: p.GroupID, + GroupPlatform: gi.Platform, + GroupName: gi.Name, + RateMultiplier: gi.RateMultiplier, + DailyLimitUSD: gi.DailyLimitUSD, + WeeklyLimitUSD: gi.WeeklyLimitUSD, + MonthlyLimitUSD: gi.MonthlyLimitUSD, + ModelScopes: gi.ModelScopes, + Name: p.Name, + Description: p.Description, + Price: p.Price, + OriginalPrice: p.OriginalPrice, + ValidityDays: p.ValidityDays, + ValidityUnit: p.ValidityUnit, + Features: p.Features, + ProductName: p.ProductName, + ForSale: p.ForSale, + SortOrder: p.SortOrder, + }) + } + return result } // CreatePlan creates a new subscription plan. diff --git a/backend/internal/handler/admin/payment_handler_test.go b/backend/internal/handler/admin/payment_handler_test.go index 42974b907..dc77e93f3 100644 --- a/backend/internal/handler/admin/payment_handler_test.go +++ b/backend/internal/handler/admin/payment_handler_test.go @@ -7,6 +7,7 @@ import ( "time" dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/service" ) func TestSanitizeAdminPaymentOrderForResponseAddsCurrency(t *testing.T) { @@ -46,3 +47,49 @@ func TestSanitizeAdminPaymentOrderForResponseAddsCurrency(t *testing.T) { t.Fatalf("expected provider_snapshot to be omitted, got %s", string(body)) } } + +func TestAdminSubscriptionPlansForResponseIncludesCompositeGroupInfo(t *testing.T) { + weekly := 25.0 + plans := []*dbent.SubscriptionPlan{ + { + ID: 11, + GroupID: 7, + Name: "All models", + Description: "Composite access", + Price: 19.99, + ValidityDays: 30, + ValidityUnit: "days", + Features: "OpenAI\nClaude\nGemini\nGrok", + ProductName: "Sub2API", + ForSale: true, + SortOrder: 1, + }, + } + groupInfo := map[int64]service.PlanGroupInfo{ + 7: { + Platform: service.PlatformComposite, + Name: "Bucket 2 composite", + RateMultiplier: 1.5, + WeeklyLimitUSD: &weekly, + ModelScopes: []string{"openai", "claude", "gemini", "grok"}, + }, + } + + got := adminSubscriptionPlansForResponse(plans, groupInfo) + + if len(got) != 1 { + t.Fatalf("expected one plan, got %d", len(got)) + } + if got[0].GroupPlatform != service.PlatformComposite { + t.Fatalf("expected composite group platform, got %q", got[0].GroupPlatform) + } + if got[0].GroupName != "Bucket 2 composite" { + t.Fatalf("expected group name to be included, got %q", got[0].GroupName) + } + if got[0].WeeklyLimitUSD == nil || *got[0].WeeklyLimitUSD != weekly { + t.Fatalf("expected weekly limit to be included, got %#v", got[0].WeeklyLimitUSD) + } + if strings.Join(got[0].ModelScopes, ",") != "openai,claude,gemini,grok" { + t.Fatalf("expected model scopes to be preserved, got %#v", got[0].ModelScopes) + } +} diff --git a/backend/internal/service/composite_route_resolver_test.go b/backend/internal/service/composite_route_resolver_test.go index 4364c4ddc..794e7b2ea 100644 --- a/backend/internal/service/composite_route_resolver_test.go +++ b/backend/internal/service/composite_route_resolver_test.go @@ -133,3 +133,78 @@ func TestCompositeRouteResolverIgnoresDisabledRoutesAndFallsBackToDetector(t *te require.Equal(t, "gpt-5", decision.UpstreamModel) require.Nil(t, decision.Route) } + +func TestCompositeRouteResolverExplicitRoutesCoverBucketTwoProviders(t *testing.T) { + resolver := NewCompositeRouteResolver(compositeRouteRepoStub{ + routes: []CompositeModelRoute{ + { + ID: 1, + GroupID: 7, + PublicModel: "all/gpt-5", + MatchType: CompositeRouteMatchExact, + TargetPlatform: PlatformOpenAI, + UpstreamModel: "gpt-5", + Endpoint: CompositeRouteEndpointResponses, + Priority: 100, + Enabled: true, + }, + { + ID: 2, + GroupID: 7, + PublicModel: "all/claude-sonnet", + MatchType: CompositeRouteMatchExact, + TargetPlatform: PlatformAnthropic, + UpstreamModel: "claude-sonnet-4-6", + Endpoint: CompositeRouteEndpointMessages, + Priority: 100, + Enabled: true, + }, + { + ID: 3, + GroupID: 7, + PublicModel: "all/gemini-pro", + MatchType: CompositeRouteMatchExact, + TargetPlatform: PlatformGemini, + UpstreamModel: "gemini-2.5-pro", + Endpoint: CompositeRouteEndpointGemini, + Priority: 100, + Enabled: true, + }, + { + ID: 4, + GroupID: 7, + PublicModel: "all/grok", + MatchType: CompositeRouteMatchExact, + TargetPlatform: PlatformGrok, + UpstreamModel: "grok-4", + Endpoint: CompositeRouteEndpointResponses, + Priority: 100, + Enabled: true, + }, + }, + }) + + tests := []struct { + model string + endpoint string + wantPlatform string + wantUpstream string + }{ + {"all/gpt-5", CompositeRouteEndpointResponses, PlatformOpenAI, "gpt-5"}, + {"all/claude-sonnet", CompositeRouteEndpointMessages, PlatformAnthropic, "claude-sonnet-4-6"}, + {"all/gemini-pro", CompositeRouteEndpointGemini, PlatformGemini, "gemini-2.5-pro"}, + {"all/grok", CompositeRouteEndpointResponses, PlatformGrok, "grok-4"}, + } + + for _, tt := range tests { + t.Run(tt.model, func(t *testing.T) { + decision, err := resolver.Resolve(context.Background(), 7, tt.model, tt.endpoint) + + require.NoError(t, err) + require.True(t, decision.Matched) + require.Equal(t, CompositeRouteSourceExplicit, decision.Source) + require.Equal(t, tt.wantPlatform, decision.TargetPlatform) + require.Equal(t, tt.wantUpstream, decision.UpstreamModel) + }) + } +} diff --git a/docs/COMPOSITE_GROUPS.md b/docs/COMPOSITE_GROUPS.md index 4798ae001..9b27d9767 100644 --- a/docs/COMPOSITE_GROUPS.md +++ b/docs/COMPOSITE_GROUPS.md @@ -73,13 +73,53 @@ guessing a provider. - Composite groups can copy accounts from concrete provider groups. - Concrete provider accounts can be assigned directly to composite groups from account create/edit and bulk account workflows. +- Subscription payment plans can bind to a composite group when that group's + `subscription_type` is `subscription`. The plan grants access to the + composite group; each request is still billed and quota-checked against the + resolved concrete provider platform. - Channel configuration exposes composite groups in concrete provider sections. The channel `group_ids` payload is still flat; provider-specific model mapping and pricing remain keyed by concrete platform. +## Bucket 2 Setup: OpenAI + Claude + Gemini + Grok + +Use one composite subscription group when one customer-facing plan should expose +model aliases across OpenAI, Claude, Gemini, and Grok without issuing separate +keys per provider. + +1. Create concrete provider groups for the upstream account pools, for example + `OpenAI Paid`, `Claude Paid`, `Gemini Paid`, and `Grok Paid`. +2. Create a `composite` group with `subscription_type` set to `subscription`. +3. Assign provider accounts directly to the composite group, or copy accounts + from the concrete provider groups during group creation. +4. Add explicit routes for public aliases that should not rely on built-in + model detection: + + | Public model | Endpoint | Target platform | Upstream model | + | --- | --- | --- | --- | + | `all/gpt-5` | `responses` | `openai` | `gpt-5` | + | `all/claude-sonnet` | `messages` | `anthropic` | `claude-sonnet-4-6` | + | `all/gemini-pro` | `gemini` | `gemini` | `gemini-2.5-pro` | + | `all/grok` | `responses` | `grok` | `grok-4` | + +5. Configure channel pricing and model mapping under the concrete platforms + named in each route. Composite routing does not create pricing records. +6. Create a subscription payment plan for the composite group. + +The same composite group can also rely on built-in detection for standard model +names such as `gpt-*`, `claude-*`, `gemini-*`, and `grok-*`. Explicit routes are +recommended for bundled plan aliases because they make endpoint, provider, and +upstream model attribution reviewable in the admin UI. + ## Limits Composite routes choose a concrete provider and upstream model; they do not create synthetic model metadata, pricing, or upstream capability records by themselves. Keep channel pricing/model mapping configured for the concrete provider platforms that the routes target. + +This PR intentionally does not implement: + +- AUTO smart-routing among multiple providers for the same abstract task. +- Direct API-key binding to several existing groups without a composite group. +- Protocol-agnostic provider decoupling or a LiteLLM-style adapter rewrite. diff --git a/frontend/src/components/common/GroupBadge.vue b/frontend/src/components/common/GroupBadge.vue index 358997401..ee4b4b915 100644 --- a/frontend/src/components/common/GroupBadge.vue +++ b/frontend/src/components/common/GroupBadge.vue @@ -162,6 +162,9 @@ const labelClass = computed(() => { if (props.platform === 'grok') { return `${base} bg-zinc-300/70 text-zinc-800 dark:bg-zinc-700/60 dark:text-zinc-200` } + if (props.platform === 'composite') { + return `${base} bg-cyan-200/70 text-cyan-900 dark:bg-cyan-900/50 dark:text-cyan-300` + } return `${base} bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300` }) @@ -197,6 +200,11 @@ const badgeClass = computed(() => { ? 'bg-zinc-200 text-zinc-800 dark:bg-zinc-700 dark:text-zinc-100' : 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200' } + if (props.platform === 'composite') { + return isSubscription.value + ? 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-300' + : 'bg-cyan-50 text-cyan-800 dark:bg-cyan-900/20 dark:text-cyan-300' + } // Fallback: original colors return isSubscription.value ? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400' diff --git a/frontend/src/views/admin/orders/__tests__/PlanEditDialog.spec.ts b/frontend/src/views/admin/orders/__tests__/PlanEditDialog.spec.ts index 9c31e7c17..7b21ef507 100644 --- a/frontend/src/views/admin/orders/__tests__/PlanEditDialog.spec.ts +++ b/frontend/src/views/admin/orders/__tests__/PlanEditDialog.spec.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest' +import { defineComponent } from 'vue' import { mount } from '@vue/test-utils' + import PlanEditDialog from '../PlanEditDialog.vue' +import type { AdminGroup } from '@/types' vi.mock('vue-i18n', () => ({ useI18n: () => ({ @@ -26,21 +29,108 @@ vi.mock('@/api/admin/payment', () => ({ }, })) -function mountDialog(paymentConfig: Record | null) { +const BaseDialogStub = defineComponent({ + name: 'BaseDialog', + props: { + show: Boolean, + title: String, + width: String, + }, + template: '
', +}) + +const SelectStub = defineComponent({ + name: 'SelectStub', + props: { + modelValue: [String, Number], + options: { + type: Array, + default: () => [], + }, + placeholder: String, + }, + emits: ['update:modelValue'], + setup(_props, { emit }) { + const onChange = (event: Event) => { + const value = (event.target as HTMLSelectElement).value + emit('update:modelValue', value === '' ? null : Number(value)) + } + return { onChange } + }, + template: ` + + `, +}) + +const groupFixture = (overrides: Partial): AdminGroup => ({ + id: 1, + name: 'OpenAI', + description: null, + platform: 'openai', + rate_multiplier: 1, + rpm_limit: 0, + is_exclusive: false, + status: 'active', + subscription_type: 'subscription', + daily_limit_usd: null, + weekly_limit_usd: null, + monthly_limit_usd: null, + allow_image_generation: false, + image_rate_independent: false, + image_rate_multiplier: 1, + image_price_1k: null, + image_price_2k: null, + image_price_4k: null, + peak_rate_enabled: false, + peak_start: '', + peak_end: '', + peak_rate_multiplier: 1, + claude_code_only: false, + fallback_group_id: null, + fallback_group_id_on_invalid_request: null, + allow_messages_dispatch: false, + require_oauth_only: false, + require_privacy_set: false, + created_at: '2026-07-01T00:00:00Z', + updated_at: '2026-07-01T00:00:00Z', + model_routing: null, + model_routing_enabled: false, + mcp_xml_inject: false, + sort_order: 0, + ...overrides, +}) + +function mountDialog({ + groups = [], + paymentConfig = null, +}: { + groups?: AdminGroup[] + paymentConfig?: Record | null +} = {}) { return mount(PlanEditDialog, { props: { show: true, plan: null, - groups: [], + groups, paymentConfig, }, global: { stubs: { - BaseDialog: { - props: ['show'], - template: '
', - }, - Select: true, + BaseDialog: BaseDialogStub, + Select: SelectStub, Icon: true, GroupBadge: true, }, @@ -48,11 +138,13 @@ function mountDialog(paymentConfig: Record | null) { }) } -describe('PlanEditDialog subscription CNY payment preview', () => { +describe('PlanEditDialog', () => { it('shows CNY channel charge using the configured subscription rate and fee', async () => { const wrapper = mountDialog({ - subscription_usd_to_cny_rate: 7.15, - recharge_fee_rate: 2.5, + paymentConfig: { + subscription_usd_to_cny_rate: 7.15, + recharge_fee_rate: 2.5, + }, }) await wrapper.find('input[type="number"]').setValue('9.99') @@ -65,8 +157,10 @@ describe('PlanEditDialog subscription CNY payment preview', () => { it('hides the preview when the subscription rate is not configured', async () => { const wrapper = mountDialog({ - subscription_usd_to_cny_rate: 0, - recharge_fee_rate: 2.5, + paymentConfig: { + subscription_usd_to_cny_rate: 0, + recharge_fee_rate: 2.5, + }, }) await wrapper.find('input[type="number"]').setValue('9.99') @@ -74,4 +168,29 @@ describe('PlanEditDialog subscription CNY payment preview', () => { expect(wrapper.text()).not.toContain('preview') expect(wrapper.text()).not.toContain('¥71.43') }) + + it('allows composite subscription groups for payment plans', () => { + const wrapper = mountDialog({ + groups: [ + groupFixture({ + id: 10, + name: 'OpenAI + Claude + Gemini + Grok', + platform: 'composite', + rate_multiplier: 1.2, + subscription_type: 'subscription', + }), + groupFixture({ + id: 11, + name: 'Standard OpenAI', + platform: 'openai', + subscription_type: 'standard', + }), + ], + }) + + const options = wrapper.findAll('option').map(option => option.text()) + + expect(options).toContain('OpenAI + Claude + Gemini + Grok — composite (1.2x)') + expect(options).not.toContain('Standard OpenAI — openai (1x)') + }) })