fix(monitor): reject unusable quota data sources and invalid mode combos at write time

P2-2/P2-4/P2-5 from review:

- validateLinkedAccount/revalidateLinkedAccount now check
  monitorAccountQuotaCapability after the platform match, blocking combos
  that would permanently error at runtime: deepseek coding and
  custom-domain kimi coding (no quota endpoint), zhipu payg (no balance
  endpoint), anthropic/openai API-Key accounts (usage query requires
  oauth; anthropic setup-token still allowed via local estimation).
  gemini/grok/antigravity stay permissive. Quota mode surfaces
  CHANNEL_MONITOR_ACCOUNT_NOT_SUPPORTABLE on edit; probe mode silently
  unbinds (same as platform mismatch).
- applyMonitorUpdate re-runs validateCheckMode on the effective
  provider+check_mode whenever either field is patched, so a
  provider-only update can no longer persist antigravity+probe (the
  check is conditioned on provider/check_mode presence so legacy illegal
  rows can still be renamed/disabled).
- normalizeMonitorPrimaryModel only substitutes the "quota" placeholder
  for pure quota mode; quota_probe with an empty model now fails with
  MissingPrimaryModel instead of probing model="quota". The quota branch
  also moves ahead of the grok default, so grok+quota now gets "quota"
  rather than "grok-4.5" (review L-item).

Tests: capability matrix (15 cases incl. anti-over-blocking rows),
linked-account wiring, revalidate quota/probe split, provider-only
update bypass + legacy-row rename, quota_probe missing-model create,
normalize matrix.
This commit is contained in:
Randark
2026-08-18 10:28:28 +00:00
parent 1128df2592
commit c41ae19e52
4 changed files with 232 additions and 13 deletions
@@ -162,6 +162,9 @@ var (
ErrChannelMonitorProviderIncompatible = infraerrors.BadRequest(
"CHANNEL_MONITOR_PROVIDER_INCOMPATIBLE", "monitor provider must match the linked account platform",
)
ErrChannelMonitorAccountNotSupportable = infraerrors.BadRequest(
"CHANNEL_MONITOR_ACCOUNT_NOT_SUPPORTABLE", "linked account cannot serve as a quota data source (cn coding plan must be kimi/zhipu, cn payg must be kimi/deepseek, openai requires an oauth account, anthropic requires oauth or setup-token)",
)
ErrChannelMonitorInvalidAPIMode = infraerrors.BadRequest(
"CHANNEL_MONITOR_INVALID_API_MODE", "api_mode must be chat_completions or responses; responses is only supported for openai",
)
@@ -325,6 +325,16 @@ func TestValidateCreateParams_CheckModeMatrix(t *testing.T) {
},
wantErr: ErrChannelMonitorInvalidCheckMode,
},
{
// quota_probe 仍要打真实探活请求:空模型必须报错,不再用 "quota" 占位。
name: "quota_probe requires primary model",
params: ChannelMonitorCreateParams{
Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuotaProbe,
Endpoint: "https://api.kimi.com", APIKey: "sk",
IntervalSeconds: 60, AccountID: &accountID,
},
wantErr: ErrChannelMonitorMissingPrimaryModel,
},
}
for _, tc := range cases {
@@ -342,8 +352,15 @@ func TestValidateCreateParams_CheckModeMatrix(t *testing.T) {
func TestNormalizeMonitorPrimaryModel_QuotaDefault(t *testing.T) {
require.Equal(t, "quota", normalizeMonitorPrimaryModel(MonitorProviderKimi, MonitorCheckModeQuota, ""))
require.Equal(t, "quota", normalizeMonitorPrimaryModel(MonitorProviderAntigravity, MonitorCheckModeQuota, " "))
// 探活模式沿用原语义:grok 默认模型,其余必填(空串报错在 validateCreateParams)。
// quota_probe 仍要打真实探活请求:空模型返回 ""(由上层报 MissingPrimaryModel),
// 不再用 "quota" 占位打 model="quota" 的请求。
require.Equal(t, "", normalizeMonitorPrimaryModel(MonitorProviderKimi, MonitorCheckModeQuotaProbe, ""))
// grok 分支在纯 quota 占位之后:grok+quota 占位 "quota",
// grok 探活(probe/quota_probe)默认轻量测活模型。
require.Equal(t, "quota", normalizeMonitorPrimaryModel(MonitorProviderGrok, MonitorCheckModeQuota, ""))
require.Equal(t, MonitorDefaultGrokModel, normalizeMonitorPrimaryModel(MonitorProviderGrok, MonitorCheckModeProbe, ""))
require.Equal(t, MonitorDefaultGrokModel, normalizeMonitorPrimaryModel(MonitorProviderGrok, MonitorCheckModeQuotaProbe, ""))
// 探活模式沿用原语义:其余必填(空串报错在 validateCreateParams)。
require.Equal(t, "kimi-k2", normalizeMonitorPrimaryModel(MonitorProviderKimi, MonitorCheckModeQuotaProbe, "kimi-k2"))
}
@@ -414,6 +431,150 @@ func TestRevalidateLinkedAccount_PlatformMismatch(t *testing.T) {
require.Nil(t, probe.AccountID)
}
// 能力矩阵:与 fetchUncached 路由一一对应,创建期拦截注定运行期永久 error 的组合。
func TestMonitorAccountQuotaCapability_Matrix(t *testing.T) {
cases := []struct {
name string
account *Account
wantErr error
}{
{
name: "deepseek coding has no quota endpoint",
account: &Account{ID: 1, Platform: domain.PlatformDeepseek, Credentials: map[string]any{"account_mode": AccountModeCoding}},
wantErr: ErrChannelMonitorAccountNotSupportable,
},
{
// 自定义域名 kimi coding:GetCodingPlanProvider 识别不到 → 无额度端点。
name: "custom-domain kimi coding unsupported",
account: &Account{ID: 2, Platform: domain.PlatformKimi, Type: AccountTypeAPIKey,
Credentials: map[string]any{"account_mode": AccountModeCoding, "base_url": "https://cw.example.com"}},
wantErr: ErrChannelMonitorAccountNotSupportable,
},
{
name: "kimi coding default endpoint ok",
account: &Account{ID: 3, Platform: domain.PlatformKimi, Credentials: map[string]any{"account_mode": AccountModeCoding}},
},
{
name: "zhipu coding default endpoint ok",
account: &Account{ID: 4, Platform: domain.PlatformZhipu, Credentials: map[string]any{"account_mode": AccountModeCoding}},
},
{
name: "zhipu payg has no balance endpoint",
account: &Account{ID: 5, Platform: domain.PlatformZhipu},
wantErr: ErrChannelMonitorAccountNotSupportable,
},
{
name: "kimi payg ok",
account: &Account{ID: 6, Platform: domain.PlatformKimi},
},
{
name: "deepseek payg ok",
account: &Account{ID: 7, Platform: domain.PlatformDeepseek},
},
{
name: "anthropic api key cannot query usage",
account: &Account{ID: 8, Platform: domain.PlatformAnthropic, Type: AccountTypeAPIKey},
wantErr: ErrChannelMonitorAccountNotSupportable,
},
{
name: "anthropic oauth ok",
account: &Account{ID: 9, Platform: domain.PlatformAnthropic, Type: AccountTypeOAuth},
},
{
name: "anthropic setup token ok (local estimation)",
account: &Account{ID: 10, Platform: domain.PlatformAnthropic, Type: AccountTypeSetupToken},
},
{
name: "openai api key cannot query usage",
account: &Account{ID: 11, Platform: domain.PlatformOpenAI, Type: AccountTypeAPIKey},
wantErr: ErrChannelMonitorAccountNotSupportable,
},
{
name: "openai oauth ok",
account: &Account{ID: 12, Platform: domain.PlatformOpenAI, Type: AccountTypeOAuth},
},
{
// 防过度拦截:gemini/grok/antigravity 走本地统计/值通道降级,不会永久 error。
name: "gemini api key ok",
account: &Account{ID: 13, Platform: domain.PlatformGemini, Type: AccountTypeAPIKey},
},
{
name: "grok ok",
account: &Account{ID: 14, Platform: domain.PlatformGrok},
},
{
name: "antigravity ok",
account: &Account{ID: 15, Platform: domain.PlatformAntigravity},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := monitorAccountQuotaCapability(tc.account)
if tc.wantErr == nil {
require.NoError(t, err)
} else {
require.ErrorIs(t, err, tc.wantErr)
}
})
}
}
func TestValidateLinkedAccount_CapabilityRejected(t *testing.T) {
svc := NewChannelMonitorService(nil, nil)
svc.SetQuotaFetcher(newQuotaModeFetcher(map[int64]*Account{
1: {ID: 1, Platform: domain.PlatformDeepseek, Credentials: map[string]any{"account_mode": AccountModeCoding}},
}, nil))
err := svc.validateLinkedAccount(context.Background(), MonitorProviderDeepseek, int64Ptr(1))
require.ErrorIs(t, err, ErrChannelMonitorAccountNotSupportable)
}
func TestRevalidateLinkedAccount_Capability(t *testing.T) {
svc := NewChannelMonitorService(nil, nil)
svc.SetQuotaFetcher(newQuotaModeFetcher(map[int64]*Account{
2: {ID: 2, Platform: domain.PlatformDeepseek, Credentials: map[string]any{"account_mode": AccountModeCoding}},
}, nil))
quota := &ChannelMonitor{Provider: MonitorProviderDeepseek, CheckMode: MonitorCheckModeQuota, AccountID: int64Ptr(2)}
require.ErrorIs(t, svc.revalidateLinkedAccount(context.Background(), quota), ErrChannelMonitorAccountNotSupportable)
require.NotNil(t, quota.AccountID, "quota mode keeps the binding for the admin to fix")
probe := &ChannelMonitor{Provider: MonitorProviderDeepseek, CheckMode: MonitorCheckModeProbe, AccountID: int64Ptr(2)}
require.NoError(t, svc.revalidateLinkedAccount(context.Background(), probe))
require.Nil(t, probe.AccountID, "probe mode should silently unbind unusable account")
}
// provider-only 更新不得绕过 provider × check_mode 组合校验。
func TestApplyMonitorUpdate_ProviderOnlyRevalidatesCheckMode(t *testing.T) {
probeKimi := func() *ChannelMonitor {
return &ChannelMonitor{
Provider: MonitorProviderKimi, APIMode: MonitorAPIModeChatCompletions,
Endpoint: "https://api.kimi.com", PrimaryModel: "kimi-k2",
CheckMode: MonitorCheckModeProbe,
}
}
provider := MonitorProviderAntigravity
err := applyMonitorUpdate(probeKimi(), ChannelMonitorUpdateParams{Provider: &provider})
require.ErrorIs(t, err, ErrChannelMonitorInvalidCheckMode)
// 带上 check_mode/account_id 的完整切换合法。
accountID := int64(3)
err = applyMonitorUpdate(probeKimi(), ChannelMonitorUpdateParams{
Provider: &provider, CheckMode: strPtr(MonitorCheckModeQuota), AccountID: &accountID,
})
require.NoError(t, err)
// 存量非法行(antigravity+probe)仅改名/停用不被砖化。
legacy := &ChannelMonitor{
Provider: MonitorProviderAntigravity, APIMode: MonitorAPIModeChatCompletions,
Endpoint: "https://example.com", PrimaryModel: "gemini-3-pro",
CheckMode: MonitorCheckModeProbe,
}
newName := "renamed"
require.NoError(t, applyMonitorUpdate(legacy, ChannelMonitorUpdateParams{Name: &newName}))
}
// --- quota → probe 切换的 key 管控(validateProbeAPIKey) ---
func TestValidateProbeAPIKey_QuotaToProbeRequiresFreshKey(t *testing.T) {
@@ -406,7 +406,8 @@ func validateCreateParams(p ChannelMonitorCreateParams) error {
return nil
}
// validateLinkedAccount 校验关联账号存在且平台与监控 provider 一致。
// validateLinkedAccount 校验关联账号存在、平台与监控 provider 一致、且能充当
// 配额数据源(能力拦截,见 monitorAccountQuotaCapability)。
// fetcher 未注入时 fail-closed(拒绝创建配额监控,而不是创建后静默坏)。
func (s *ChannelMonitorService) validateLinkedAccount(ctx context.Context, provider string, accountID *int64) error {
if accountID == nil || *accountID <= 0 {
@@ -422,7 +423,7 @@ func (s *ChannelMonitorService) validateLinkedAccount(ctx context.Context, provi
if account.Platform != provider {
return ErrChannelMonitorProviderIncompatible
}
return nil
return monitorAccountQuotaCapability(account)
}
// Update 更新监控。APIKey 字段:nil 或空字符串 = 不修改;非空 = 加密后覆盖。
@@ -533,6 +534,15 @@ func (s *ChannelMonitorService) revalidateLinkedAccount(ctx context.Context, m *
m.AccountID = nil
return nil
}
// 能力失配(如 deepseek coding / zhipu payg / API-Key 型海外账号):
// quota 模式显式报错(有该类存量监控时编辑会被拦,出路是换账号或切 probe),
// probe 模式账号无用途,静默解绑。
if err := monitorAccountQuotaCapability(account); err != nil {
if usesQuota {
return err
}
m.AccountID = nil
}
return nil
}
@@ -886,11 +896,16 @@ func applyMonitorUpdate(existing *ChannelMonitor, p ChannelMonitorUpdateParams)
existing.Provider = *p.Provider
}
if p.CheckMode != nil {
mode := defaultCheckMode(*p.CheckMode)
if err := validateCheckMode(existing.Provider, mode); err != nil {
existing.CheckMode = defaultCheckMode(*p.CheckMode)
}
// provider 与 check_mode 任一变化后统一复核组合矩阵:provider-only 更新
// (如把 probe 监控的 provider 改成 antigravity)也不得落库非法组合,否则
// 运行期恒 error。条件限定避免把存量非法行的 name/enabled-only 更新也判死
// (否则连改名/停用都无法操作)。
if p.Provider != nil || p.CheckMode != nil {
if err := validateCheckMode(existing.Provider, defaultCheckMode(existing.CheckMode)); err != nil {
return err
}
existing.CheckMode = mode
}
if p.AccountID != nil {
if *p.AccountID > 0 {
@@ -190,21 +190,61 @@ func normalizeModels(in []string) []string {
return out
}
// normalizeMonitorPrimaryModel applies provider/check_mode defaults while
// preserving the existing required-model behavior:
// - Grok 探活默认轻量测活模型
// - quota 模式占位 "quota"(primary_model 列 NotEmpty;历史行/时间线机制无需特判)
// normalizeMonitorPrimaryModel applies provider/check_mode defaults:
// - pure quota mode never sends requests: placeholder "quota" keeps
// primary_model NOT NULL (history rows / timeline need no special-casing)
// - quota_probe still sends a real probe request: empty model returns ""
// so validateCreateParams / applyMonitorUpdate report
// ErrChannelMonitorMissingPrimaryModel instead of probing model="quota"
// - Grok probing (probe/quota_probe) defaults to the lightweight check model
func normalizeMonitorPrimaryModel(provider, checkMode, model string) string {
model = strings.TrimSpace(model)
if model == "" && defaultCheckMode(checkMode) == MonitorCheckModeQuota {
return MonitorDefaultQuotaModel
}
if model == "" && provider == MonitorProviderGrok {
return MonitorDefaultGrokModel
}
if model == "" && monitorCheckModeUsesQuota(defaultCheckMode(checkMode)) {
return MonitorDefaultQuotaModel
}
return model
}
// monitorAccountQuotaCapability 校验关联账号能否充当配额数据源,与
// fetchUncached 的路由一一对应(coding→CN 额度端点 / payg→CN 余额端点 /
// 其余→AccountUsageService)。在创建/更新期拦截注定运行期永久 error 的组合:
// - kimi/zhipu/deepseek coding:GetCodingPlanProvider 须识别为 kimi/zhipu
// (deepseek coding、自定义域名 kimi coding 无法路由额度端点)
// - kimi/zhipu/deepseek payg:仅 kimi/deepseek 有公开余额端点(zhipu payg 无)
// - anthropic:OAuth / Setup Token(API-Key 型无 usage 通道,永久 error)
// - openai:OAuth(API-Key 型无 usage 通道)
// - gemini/grok/antigravity:本地统计/值通道降级,不会永久 error,放行
func monitorAccountQuotaCapability(account *Account) error {
switch account.Platform {
case PlatformKimi, PlatformZhipu, PlatformDeepseek:
if account.IsCodingPlan() {
if p := account.GetCodingPlanProvider(); p != PlatformKimi && p != PlatformZhipu {
return ErrChannelMonitorAccountNotSupportable
}
return nil
}
if account.Platform == PlatformZhipu {
return ErrChannelMonitorAccountNotSupportable
}
return nil
case PlatformAnthropic:
if account.Type == AccountTypeOAuth || account.Type == AccountTypeSetupToken {
return nil
}
return ErrChannelMonitorAccountNotSupportable
case PlatformOpenAI:
if account.Type == AccountTypeOAuth {
return nil
}
return ErrChannelMonitorAccountNotSupportable
default:
return nil
}
}
// defaultAPIMode 空串归一为 chat_completions,保证历史数据与旧客户端兼容。
func defaultAPIMode(apiMode string) string {
if strings.TrimSpace(apiMode) == "" {