Build composite subscription bucket two

This commit is contained in:
Heatherm Huang
2026-07-23 09:20:52 +08:00
parent 3a683fff55
commit ce3272c41b
6 changed files with 357 additions and 13 deletions
@@ -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.
@@ -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)
}
}
@@ -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)
})
}
}
+40
View File
@@ -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.
@@ -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'
@@ -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<string, unknown> | null) {
const BaseDialogStub = defineComponent({
name: 'BaseDialog',
props: {
show: Boolean,
title: String,
width: String,
},
template: '<div v-if="show"><slot /><slot name="footer" /></div>',
})
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: `
<select
:value="modelValue ?? ''"
@change="onChange"
>
<option value="">{{ placeholder }}</option>
<option
v-for="option in options"
:key="option.value"
:value="option.value"
:data-platform="option.platform"
>
{{ option.label }}
</option>
</select>
`,
})
const groupFixture = (overrides: Partial<AdminGroup>): 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<string, unknown> | null
} = {}) {
return mount(PlanEditDialog, {
props: {
show: true,
plan: null,
groups: [],
groups,
paymentConfig,
},
global: {
stubs: {
BaseDialog: {
props: ['show'],
template: '<div v-if="show"><slot /><slot name="footer" /></div>',
},
Select: true,
BaseDialog: BaseDialogStub,
Select: SelectStub,
Icon: true,
GroupBadge: true,
},
@@ -48,11 +138,13 @@ function mountDialog(paymentConfig: Record<string, unknown> | 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)')
})
})