Build composite subscription bucket two
This commit is contained in:
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user