mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Group pricing (rate multiplier, peak windows, per-user overrides) and account cost (accounts.rate_multiplier) already live side by side, but nothing stops the scheduler from handing a request to an account whose cost multiplier exceeds what the group's pricing can profitably serve. Add an opt-in per-group profit gate that filters scheduling candidates by a margin rule, while ordering, scoring, stickiness and breakers keep working unchanged among qualified accounts. Admission rule: an account qualifies iff U <= D * (1 - min_margin - safety_buffer) within a small relative epsilon, where U is accounts.rate_multiplier (0 is legal; missing/negative/NaN/Inf are conservatively rejected as invalid) and D is the requester's effective downstream multiplier (user-group override ?? group default, times the group peak factor) frozen at the request's pricing instant. - groups gain profit_control_enabled / profit_min_margin / profit_safety_buffer (migration 191); the durable auth-cache invalidation trigger additionally watches the profit and pricing columns (migration 192) so out-of-band group edits cannot leave stale auth snapshots; GetByKeyForAuth explicitly projects the new columns and the API-key auth snapshot version is bumped to force a refresh of pre-existing snapshots - request-level pricing instant: token entry points install pricingAt into ctx; the profit threshold D and the RecordUsage peak factor read the same instant, so one request never changes price mid-flight across waits/retries/failover (media and unwired paths keep the existing record-time semantics) - the gate covers token requests on openai, anthropic, gemini, grok and antigravity groups: OpenAI-family handlers via WithOpenAIRequestPricingContext (responses incl. WS bridge, chat completions, messages, embeddings, alpha search), the shared gateway via WithGatewayTokenRequestPricing (messages, chat completions, responses, gemini model actions); composite groups cannot enable it directly; image/video/models/usage/count_tokens stay ungated and an explicit image-generation intent suppresses the gate end to end - post-slot recheck: after a slot is acquired the account is re-read via SchedulerSnapshotService.GetAccount (scheduler cache, then DB; only when both fail the check fails open with WARN + metric); a vetoed account releases its slot and joins the request's exclusion set for reselection; sticky bindings are written only after the final check passes, and an over-threshold sticky account is skipped, not deleted, so it comes back once its rate recovers - sticky-session cache contract: GatewayCache.GetSessionAccountID now returns ErrStickySessionNotFound on a miss (mapped from redis.Nil in the repository implementation, mirroring ErrRefreshTokenNotFound) so the profit sticky path can distinguish "no binding yet" from a real read failure without importing the cache driver in service code - cross-group re-entry (composite parent -> member group) resolves the gate against the member group and clears a stale parent gate instead of letting a foreign threshold veto accounts - per-platform/group activity counters (installs, threshold vetoes, invalid-rate vetoes, refresh failures) for observability - admin UI: profit-control section on the five platforms' group forms with percent input, validation and platform-switch reset; group create/update/duplicate normalize and validate the config at a single choke point - cmd/profit-preview: offline what-if tool that replays the production admission semantics over an exported config/account/override/model dump, reports per-model admitted-account counts under the default and the worst-case (lowest user override) D, and surfaces probe-sync staleness as warnings without affecting admission Tests: service unit coverage for gate resolution/veto/threshold epsilon/pricing instant/suppress marker/scheduler filtering and post-slot recheck (incl. -race on the profit surface), unit-tagged handler slot-recheck and capability-mapping regressions, sqlmock and real-PostgreSQL integration regressions for the GetByKeyForAuth projection and the migration-192 trigger watch list, API contract update, and frontend specs for the five-platform form helpers.
147 lines
5.2 KiB
Go
147 lines
5.2 KiB
Go
package service
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestPreviewProfitAdmissionUsesAccountRatesAndPreinitializesModels(t *testing.T) {
|
|
now := time.Now()
|
|
group := profitControlTestGroup(50, 0.2, 0)
|
|
group.Name = "VIP-preview"
|
|
|
|
cheap := profitControlTestAccountWithRate(
|
|
upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 1.0, now.Add(-3*time.Hour), 30*time.Minute),
|
|
0.5,
|
|
)
|
|
cheap.Name = "cheap"
|
|
cheap.Extra[UpstreamBillingRateSyncEnabledExtraKey] = true
|
|
cheap.Credentials = map[string]any{"model_mapping": map[string]any{"gpt-sol": "gpt-sol"}}
|
|
|
|
boundary := profitControlTestAccountWithRate(
|
|
upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute),
|
|
0.8,
|
|
)
|
|
boundary.Name = "boundary"
|
|
boundary.Credentials = map[string]any{"model_mapping": map[string]any{
|
|
"gpt-sol": "gpt-sol",
|
|
"gpt-luna": "gpt-luna",
|
|
}}
|
|
|
|
expensive := profitControlTestAccountWithRate(
|
|
upstreamCostTestAccount(3, UpstreamBillingProbeStatusOK, 0.2, now.Add(-time.Minute), 30*time.Minute),
|
|
1.0,
|
|
)
|
|
expensive.Name = "expensive"
|
|
|
|
invalid := upstreamCostTestOAuthAccount(4)
|
|
invalid.Name = "invalid"
|
|
|
|
reports := PreviewProfitAdmission([]ProfitPreviewGroupInput{{
|
|
Group: group,
|
|
Accounts: []*Account{cheap, boundary, expensive, invalid},
|
|
UserOverrides: map[int64]float64{40: 0.5},
|
|
Models: []string{"gpt-sol", "gpt-luna", "gpt-no-account"},
|
|
}}, now)
|
|
require.Len(t, reports, 1)
|
|
report := reports[0]
|
|
|
|
require.True(t, report.EffectiveGate)
|
|
require.InDelta(t, 1.0, report.DefaultD, 1e-12)
|
|
require.InDelta(t, 0.8, report.ThresholdDefault, 1e-12)
|
|
require.InDelta(t, 0.5, report.MinEffectiveD, 1e-12)
|
|
require.InDelta(t, 0.4, report.ThresholdMinD, 1e-12)
|
|
|
|
byID := map[int64]ProfitPreviewAccountVerdict{}
|
|
for _, verdict := range report.Verdicts {
|
|
byID[verdict.AccountID] = verdict
|
|
}
|
|
require.Equal(t, ProfitPreviewClassAdmitted, byID[cheap.ID].Class)
|
|
require.Equal(t, ProfitPreviewRateSourceUpstreamProbe, byID[cheap.ID].RateSource)
|
|
require.Contains(t, byID[cheap.ID].Warnings, ProfitPreviewWarningProbeStale)
|
|
require.True(t, byID[cheap.ID].RejectedUnderMinD)
|
|
|
|
require.Equal(t, ProfitPreviewClassAdmitted, byID[boundary.ID].Class, "U == 阈值按 epsilon 语义准入")
|
|
require.Equal(t, ProfitPreviewClassRejectedThreshold, byID[expensive.ID].Class)
|
|
require.Contains(t, byID[expensive.ID].Warnings, ProfitPreviewWarningManualRateOne)
|
|
require.Equal(t, ProfitPreviewClassRejectedInvalidRate, byID[invalid.ID].Class)
|
|
|
|
require.Equal(t, 2, report.RemainingByModel["gpt-sol"])
|
|
require.Equal(t, 1, report.RemainingByModel["gpt-luna"])
|
|
require.Equal(t, 0, report.RemainingByModel["gpt-no-account"])
|
|
_, present := report.RemainingByModel["gpt-no-account"]
|
|
require.True(t, present, "全部账号均不支持时也必须显式保留 0,供 CLI 发出警告")
|
|
}
|
|
|
|
func TestPreviewProfitAdmissionAssumeEnabled(t *testing.T) {
|
|
now := time.Now()
|
|
group := profitControlTestGroup(51, 0, 0)
|
|
group.Platform = PlatformOpenAI
|
|
group.RateMultiplier = 0.5
|
|
group.ProfitControlEnabled = false
|
|
|
|
cheapAccount := profitControlTestAccountWithRate(
|
|
upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.9, now.Add(-time.Minute), 30*time.Minute),
|
|
0.2,
|
|
)
|
|
expensiveAccount := profitControlTestAccountWithRate(
|
|
upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0.2, now.Add(-time.Minute), 30*time.Minute),
|
|
0.9,
|
|
)
|
|
|
|
withoutAssume := PreviewProfitAdmission([]ProfitPreviewGroupInput{{
|
|
Group: group,
|
|
Accounts: []*Account{cheapAccount, expensiveAccount},
|
|
Models: []string{"gpt-test"},
|
|
}}, now)[0]
|
|
require.False(t, withoutAssume.EffectiveGate)
|
|
require.Equal(t, ProfitPreviewClassAdmitted, withoutAssume.Verdicts[0].Class)
|
|
require.Equal(t, ProfitPreviewClassAdmitted, withoutAssume.Verdicts[1].Class)
|
|
|
|
withAssume := PreviewProfitAdmission([]ProfitPreviewGroupInput{{
|
|
Group: group,
|
|
Accounts: []*Account{cheapAccount, expensiveAccount},
|
|
Models: []string{"gpt-test"},
|
|
AssumeEnabled: true,
|
|
}}, now)[0]
|
|
require.True(t, withAssume.EffectiveGate)
|
|
require.True(t, withAssume.AssumedEnabled)
|
|
|
|
byID := map[int64]ProfitPreviewAccountVerdict{}
|
|
for _, verdict := range withAssume.Verdicts {
|
|
byID[verdict.AccountID] = verdict
|
|
}
|
|
require.Equal(t, ProfitPreviewClassAdmitted, byID[cheapAccount.ID].Class,
|
|
"账号倍率 0.2 <= 阈值 0.5,探测快照的高倍率不参与准入判断")
|
|
require.Equal(t, ProfitPreviewClassRejectedThreshold, byID[expensiveAccount.ID].Class,
|
|
"账号倍率 0.9 > 阈值 0.5,探测快照的低倍率不能替代账号倍率")
|
|
}
|
|
|
|
func TestPreviewProfitAdmissionSupportsFivePlatforms(t *testing.T) {
|
|
for i, platform := range []string{
|
|
PlatformOpenAI,
|
|
PlatformAnthropic,
|
|
PlatformGemini,
|
|
PlatformGrok,
|
|
PlatformAntigravity,
|
|
} {
|
|
group := profitControlTestGroup(int64(100+i), 0, 0)
|
|
group.Platform = platform
|
|
rate := 0.2
|
|
account := &Account{
|
|
ID: int64(200 + i),
|
|
Platform: platform,
|
|
RateMultiplier: &rate,
|
|
}
|
|
report := PreviewProfitAdmission([]ProfitPreviewGroupInput{{
|
|
Group: group,
|
|
Accounts: []*Account{account},
|
|
Models: []string{"model"},
|
|
}}, time.Now())[0]
|
|
require.True(t, report.EffectiveGate, platform)
|
|
require.Equal(t, ProfitPreviewClassAdmitted, report.Verdicts[0].Class, platform)
|
|
}
|
|
}
|