diff --git a/README.md b/README.md index 37e384c..04cebe9 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ - 平均恒定模型保留透明的累计比例对照;时变额度模型使用混合粒子滤波估计连续容量、整数显示规则和参与者归属,并同时给出概率区间与确定性边界。 - 在参与者用户余额耗尽、建议余额变化、测算率变化或采集异常时发送邮件通知。 - 支持 SMTP 和 Resend 邮件服务。 -- 提供额度总览、账号状态、观测记录、粒子轨迹、额度统计、登录审计和使用教程;CPA 统计按本地哈希后的 API Key 分组,并显示未知模型的未计价请求数,不创建参与者、余额或额度建议。 +- 提供额度总览、账号状态、观测记录、粒子轨迹、额度统计、登录审计和使用教程;CPA 支持参与者绑定多个 Key、独立或多账号额度池、个人权益与同车成员用量汇总,以及本人请求明细;历史请求可预览后认领,未计价模型和采集缺口显式保留。配置见 [CPA 拼车](docs/cpa-carpool.md)。 - 管理员可为普通系统用户逐页配置只读访问权限,并精确选择其可见账号与参与者;账号范围同时约束额度总览、账号状态、观测、粒子轨迹和统计,所有页面写操作仍仅限管理员。系统设置页无需授权且不可关闭,普通用户在其中只会看到自己的 API Key 卡片。 - 提供内置 OpenAPI 3.1 文档的业务 API;管理员全局 Bearer Key 拥有全部已开放权限并可一键设置建议余额,普通系统用户可在系统设置页生成绑定自身的 Key,其端点发现、OpenAPI 文档和业务响应实时继承可配置页面、账号与参与者权限,且不能执行管理写操作。 - 记录服务端来源 IP,并可选记录浏览器通过 WebRTC 上报的辅助地址。 diff --git a/backend/monitor/access.py b/backend/monitor/access.py index 720eb98..d68f784 100644 --- a/backend/monitor/access.py +++ b/backend/monitor/access.py @@ -2,7 +2,7 @@ from collections.abc import Iterable -from django.db.models import QuerySet +from django.db.models import Q, QuerySet from rest_framework.permissions import BasePermission from .models import ( @@ -86,13 +86,15 @@ def visible_accounts_for(user, queryset: QuerySet | None = None) -> QuerySet: accounts = queryset if queryset is not None else MonitoredAccount.objects.all() if user.is_staff: return accounts - return accounts.filter(authorized_users=user) + return accounts.filter(authorized_users=user).filter( + Q(provider="sub2api") | Q(pool__allocations__participant__authorized_users=user) + ).distinct() def visible_account_ids(user) -> set[int] | None: if user.is_staff: return None - return set(user.visible_monitored_accounts.values_list("id", flat=True)) + return set(visible_accounts_for(user).values_list("id", flat=True)) def scope_participant_data( diff --git a/backend/monitor/accounting/adaptive_range.py b/backend/monitor/accounting/adaptive_range.py index bd30626..2b39d7c 100644 --- a/backend/monitor/accounting/adaptive_range.py +++ b/backend/monitor/accounting/adaptive_range.py @@ -50,14 +50,19 @@ class AdaptiveRangeOutput: direction: ExpansionDirection | None promotions: tuple[RangePromotion, ...] filter_config: ParticleFilterConfig + initial_range_usd: tuple[float, float] def _display_residuals( particle: ParticleFilterOutput, displayed_percent: np.ndarray, ) -> tuple[np.ndarray, np.ndarray]: - # 与研究实现一致:各主体后验中位数之和用于检查显示单元矛盾。 - progress = particle.attributed_percent_hat.sum(axis=1) + # Compare absolute progress with the provider's absolute display. Baseline + # uncertainty is inferred by the filter, not forced to the displayed integer. + # Exact-zero baselines retain the validated sum-of-subject-medians behavior. + progress = ( + particle.baseline_percent_hat + particle.attributed_percent_hat.sum(axis=1) + ) upper = np.maximum( progress - np.minimum(displayed_percent + 1.0, 100.0), 0.0, @@ -193,6 +198,20 @@ def run_adaptive_range_filter( capacity_max_usd=capacity_profile.capacity_max_usd, initial_capacity_sd_usd=capacity_profile.initial_capacity_sd_usd, ) + # A prior learned outside the base plan range must not be clipped back to + # its boundary on reconnect. Reuse the smallest supported range containing + # that prior; this is historical input, not a new dual-evidence promotion. + prior = base_config.initial_capacity_usd + if prior is not None and np.isfinite(prior) and prior > 0: + for target in capacity_profile.lower_stages_usd: + if prior >= base_config.capacity_min_usd: + break + base_config = replace(base_config, capacity_min_usd=target) + for target in capacity_profile.upper_stages_usd: + if prior <= base_config.capacity_max_usd: + break + base_config = replace(base_config, capacity_max_usd=target) + initial_range = (base_config.capacity_min_usd, base_config.capacity_max_usd) base_particle = run_particle_filter( model_input, seed=seed, @@ -200,20 +219,20 @@ def run_adaptive_range_filter( ) base_bounds = run_deterministic_bounds( model_input, - capacity_min_usd=capacity_profile.capacity_min_usd, - capacity_max_usd=capacity_profile.capacity_max_usd, + capacity_min_usd=base_config.capacity_min_usd, + capacity_max_usd=base_config.capacity_max_usd, ) particle = _copy_particle(base_particle) bounds = _copy_bounds(base_bounds) row_count = len(model_input.times_hours) active_min = np.full( row_count, - capacity_profile.capacity_min_usd, + base_config.capacity_min_usd, dtype=float, ) active_max = np.full( row_count, - capacity_profile.capacity_max_usd, + base_config.capacity_max_usd, dtype=float, ) active_stage = np.zeros(row_count, dtype=int) @@ -232,6 +251,7 @@ def run_adaptive_range_filter( direction=None, promotions=(), filter_config=base_config, + initial_range_usd=initial_range, ) targets = ( @@ -240,8 +260,12 @@ def run_adaptive_range_filter( else capacity_profile.lower_stages_usd ) current_particle = base_particle - current_min = capacity_profile.capacity_min_usd - current_max = capacity_profile.capacity_max_usd + current_min = base_config.capacity_min_usd + current_max = base_config.capacity_max_usd + targets = tuple( + t for t in targets + if (t > current_max if direction == "upper" else t < current_min) + ) promotions: list[RangePromotion] = [] for stage_number, target in enumerate(targets, start=1): @@ -311,4 +335,5 @@ def run_adaptive_range_filter( direction=direction, promotions=tuple(promotions), filter_config=base_config, + initial_range_usd=initial_range, ) diff --git a/backend/monitor/accounting/contracts.py b/backend/monitor/accounting/contracts.py index 285da89..52a23f8 100644 --- a/backend/monitor/accounting/contracts.py +++ b/backend/monitor/accounting/contracts.py @@ -6,7 +6,7 @@ from ..models import Observation -ALGORITHM_VERSION = "particle_filter_v9" +ALGORITHM_VERSION = "particle_filter_v10" @dataclass diff --git a/backend/monitor/accounting/dynamic_attribution.py b/backend/monitor/accounting/dynamic_attribution.py index cf63059..2df3209 100644 --- a/backend/monitor/accounting/dynamic_attribution.py +++ b/backend/monitor/accounting/dynamic_attribution.py @@ -139,6 +139,7 @@ def _diagnostics( float(adaptive.capacity_min_usd[row]), float(adaptive.capacity_max_usd[row]), ], + "capacity_initial_range_usd": list(adaptive.initial_range_usd), "capacity_range_stage": int(adaptive.stage[row]), "capacity_range_direction": ( adaptive.direction if adaptive.stage[row] > 0 else None @@ -543,6 +544,17 @@ def replay_dynamic_segment( snapshot.recommendation_applied = False snapshot.needs_manual_update = needs_update snapshot.reason = reason + if account_id < 0: + snapshot.current_balance_usd = None + snapshot.recommended_balance_usd = None + snapshot.recommended_balance_min_usd = None + snapshot.recommended_balance_max_usd = None + snapshot.deterministic_balance_min_usd = None + snapshot.deterministic_balance_max_usd = None + snapshot.balance_difference_usd = None + snapshot.needs_manual_update = False + snapshot.recommendation_applied = False + snapshot.reason = "CPA 本地估算权益" if snapshot.cpa_contract_known else "缺少历史份额,剩余权益未知" if snapshots: ParticipantSnapshot.objects.bulk_update( snapshots, diff --git a/backend/monitor/accounting/dynamic_contracts.py b/backend/monitor/accounting/dynamic_contracts.py index 81185a6..4cf91f1 100644 --- a/backend/monitor/accounting/dynamic_contracts.py +++ b/backend/monitor/accounting/dynamic_contracts.py @@ -55,6 +55,7 @@ class ParticleFilterOutput: capacity_hat_usd: np.ndarray capacity_lower_usd: np.ndarray capacity_upper_usd: np.ndarray + baseline_percent_hat: np.ndarray total_percent_hat: np.ndarray total_percent_lower: np.ndarray total_percent_upper: np.ndarray diff --git a/backend/monitor/accounting/model_inputs.py b/backend/monitor/accounting/model_inputs.py index 263eda4..d4e4ba8 100644 --- a/backend/monitor/accounting/model_inputs.py +++ b/backend/monitor/accounting/model_inputs.py @@ -74,23 +74,25 @@ def build_dynamic_replay_input( user_ids.add(row.sub2api_user_id) participant_by_id = {} + source_by_participant = {} share_by_participant_id = {} snapshots_by_observation: list[dict[int, object]] = [] for observation in observations: by_user = {} for snapshot in observation.participant_snapshots.all(): participant = snapshot.participant + source_by_participant[participant.id] = participant.id if account_id < 0 else (participant.sub2api_user_id if participant.sub2api_user_id is not None else snapshot.source_sub2api_user_id) participant_by_id[participant.id] = participant share_by_participant_id[participant.id] = snapshot.share_percent - user_ids.add(participant.sub2api_user_id) - by_user[participant.sub2api_user_id] = snapshot + user_ids.add(source_by_participant[participant.id]) + by_user[source_by_participant[participant.id]] = snapshot snapshots_by_observation.append(by_user) ordered_users = tuple(sorted(user_ids)) subject_user_ids: tuple[int | None, ...] = (*ordered_users, RESIDUAL_SUBJECT) user_index = {user_id: index for index, user_id in enumerate(ordered_users)} participant_subject_indices = { - participant_id: user_index[participant.sub2api_user_id] + participant_id: user_index[source_by_participant[participant.id]] for participant_id, participant in participant_by_id.items() } rights = np.zeros(len(subject_user_ids), dtype=float) @@ -114,7 +116,7 @@ def build_dynamic_replay_input( participant = participant_by_id.get(participant_id) if participant is not None: baseline_by_user.setdefault( - participant.sub2api_user_id, + source_by_participant[participant.id], baseline, ) diff --git a/backend/monitor/accounting/particle_filter.py b/backend/monitor/accounting/particle_filter.py index 0038028..4dd3c31 100644 --- a/backend/monitor/accounting/particle_filter.py +++ b/backend/monitor/accounting/particle_filter.py @@ -155,6 +155,7 @@ def run_particle_filter( capacity_hat = np.zeros(observation_count) capacity_lower = np.zeros(observation_count) capacity_upper = np.zeros(observation_count) + baseline_hat = np.zeros(observation_count) total_hat = np.zeros(observation_count) total_lower = np.zeros(observation_count) total_upper = np.zeros(observation_count) @@ -200,6 +201,9 @@ def record( upper_boundary_mass[index] = weights[ capacity_particles >= cfg.capacity_max_usd - boundary_band ].sum() + baseline_hat[index] = _weighted_quantile( + baseline_hidden, weights, (0.5,), + )[0] total_particles = attributed_particles.sum(axis=1) remaining_percent = np.maximum( model_input.rights_percent[None, :] - attributed_particles, @@ -384,6 +388,7 @@ def record( capacity_hat_usd=capacity_hat, capacity_lower_usd=capacity_lower, capacity_upper_usd=capacity_upper, + baseline_percent_hat=baseline_hat, total_percent_hat=total_hat, total_percent_lower=total_lower, total_percent_upper=total_upper, diff --git a/backend/monitor/accounting/replay.py b/backend/monitor/accounting/replay.py index 870915f..7f3a166 100644 --- a/backend/monitor/accounting/replay.py +++ b/backend/monitor/accounting/replay.py @@ -88,7 +88,9 @@ def _replay_usage_samples( observed_at__in=[sample.observed_at for sample in samples], ) } + historical_sources = {row.participant_id: row.source_sub2api_user_id for row in ParticipantSnapshot.objects.filter(observation__account_id=account_id).order_by("observation__observed_at", "id")} for sample in samples: + source_id = sample.participant_id if account_id < 0 else (sample.participant.sub2api_user_id if sample.participant.sub2api_user_id is not None else historical_sources.get(sample.participant_id)) segment = None for candidate in segments: if candidate.first_observed_at <= sample.observed_at: @@ -101,7 +103,7 @@ def _replay_usage_samples( continue sample.attribution_started_at = segment.started_at raw_cost = normalized_by_key.get( - (sample.participant.sub2api_user_id, sample.observed_at), + (source_id, sample.observed_at), sample.raw_selected_cost, ) baseline = segment.participant_baselines.get( @@ -115,7 +117,7 @@ def _replay_usage_samples( }: baseline = normalized_by_key.get( ( - sample.participant.sub2api_user_id, + source_id, segment.first_observed_at, ), baseline, @@ -125,7 +127,7 @@ def _replay_usage_samples( raw_cost - baseline + correction_prefix.user_between( - sample.participant.sub2api_user_id, + source_id, segment.started_at, sample.observed_at, ), @@ -151,7 +153,7 @@ def _update_participant_latest(account_id: int) -> None: return snapshots = list(latest.participant_snapshots.all()) participant_ids = [snapshot.participant_id for snapshot in snapshots] - account = MonitoredAccount.objects.filter(external_account_id=account_id).first() + account = MonitoredAccount.for_fact_key(account_id) memberships = ( { item.participant_id: item @@ -177,10 +179,14 @@ def _update_participant_latest(account_id: int) -> None: ["latest_selected_cost", "last_checked_at"], ) + if account_id < 0: + return + latest_balance_sample = ParticipantBalanceSample.objects.filter( participant_id=OuterRef("pk"), ).order_by("-captured_at", "-id") latest_snapshot = ParticipantSnapshot.objects.filter( + observation__account_id__gt=0, participant_id=OuterRef("pk"), observation__excluded_at__isnull=True, ).order_by("-observation__observed_at", "-id") @@ -343,6 +349,9 @@ def rebuild_account( CPAAccountCollectionInterval.objects.filter(account=monitored_account) .order_by("connected_at", "id") ) + if monitored_account is not None and monitored_account.provider == "cpa": + from ..cpa.participants import materialize_participants + materialize_participants(monitored_account, config) all_observations = list( Observation.objects.select_for_update() .select_related("sample_point", "manual_start_end") diff --git a/backend/monitor/api_usage.py b/backend/monitor/api_usage.py index e8362fd..d821ab5 100644 --- a/backend/monitor/api_usage.py +++ b/backend/monitor/api_usage.py @@ -208,7 +208,7 @@ def refresh_due_api_usage_snapshots(config: AppSettings) -> dict[str, int]: observation = latest_cycle_observation(account) if observation is None: continue - participants = Participant.objects.filter(enabled=True) + participants = Participant.objects.filter(enabled=True, sub2api_user_id__isnull=False) for participant in participants: if fresh_snapshot( participant=participant, diff --git a/backend/monitor/cpa/account_owner.py b/backend/monitor/cpa/account_owner.py new file mode 100644 index 0000000..c85e3a8 --- /dev/null +++ b/backend/monitor/cpa/account_owner.py @@ -0,0 +1,196 @@ +"""Account-scoped fallback ownership and explicit claims of collected requests.""" + +from datetime import timedelta +from decimal import Decimal + +from django.utils import timezone +from rest_framework.exceptions import ValidationError + +from ..fact_utils import canonical_digest +from ..history_state import fenced_fact_write +from ..models import ( + AppSettings, + CPAAccountOwnerBinding, + CPAClaimEvent, + CPAClaimPlan, + CPAKeyBinding, + CPAQuotaContract, + CPAUsageEvent, + Observation, + Participant, +) +from .participants import cpa_accounts, coverage_data, ownership_filter +from .usage import cpa_event_cost + + +def sync_account_owner(account, effective_at=None): + """Called under a fact-write fence when roles or pool membership change.""" + owners = list( + account.pool.allocations.filter( + participant__is_owner=True, participant__enabled=True + ).values_list("participant_id", flat=True) + ) + participant_id = owners[0] if len(owners) == 1 else None + previous = account.cpa_owner_bindings.filter(ended_at__isnull=True).first() + if previous and previous.participant_id == participant_id: + return previous + now = effective_at or timezone.now() + if previous: + now = max(now, previous.started_at + timedelta(microseconds=1)) + previous.ended_at = now + previous.save(update_fields=["ended_at"]) + if participant_id is not None: + return CPAAccountOwnerBinding.objects.create( + account=account, participant_id=participant_id, started_at=now + ) + return None + + +def account_owner_data(account): + binding = ( + account.cpa_owner_bindings.filter(ended_at__isnull=True) + .select_related("participant") + .first() + ) + if binding: + return { + "participant_id": binding.participant_id, + "participant_name": binding.participant.name, + "started_at": binding.started_at.isoformat(), + "status": "active", + } + count = account.pool.allocations.filter( + participant__is_owner=True, participant__enabled=True + ).count() + return { + "participant_id": None, + "participant_name": None, + "started_at": None, + "status": "ambiguous" if count > 1 else "missing", + } + + +def unassigned_events(account, start, end): + return ( + CPAUsageEvent.objects.filter( + account=account, occurred_at__gte=start, occurred_at__lt=end + ) + .exclude(ownership_filter(Participant.objects.values_list("pk", flat=True))) + .order_by("id") + ) + + +def source_digest(account, start, end, config): + # Include raw facts and all ownership evidence, even if a concurrent change + # leaves the aggregate request count unchanged. + return canonical_digest( + { + "events": list( + CPAUsageEvent.objects.filter( + account=account, occurred_at__gte=start, occurred_at__lt=end + ) + .order_by("id") + .values() + ), + "keys": list(CPAKeyBinding.objects.order_by("id").values()), + "owners": list(CPAAccountOwnerBinding.objects.order_by("id").values()), + "claims": list(CPAClaimEvent.objects.order_by("id").values()), + "settings": AppSettings.objects.filter(pk=config.pk).values().get(), + "contracts": list( + CPAQuotaContract.objects.filter(account=account).order_by("id").values() + ), + "pool": [account.pool_id, account.pool.contract_revision], + "observations": list( + Observation.objects.filter(account_id=account.fact_key) + .order_by("id") + .values() + ), + "coverage": list(account.cpa_collection_intervals.order_by("id").values()), + } + ) + + +def preview_unassigned_claim( + account, participant, user, started_at=None, ended_at=None +): + with fenced_fact_write([a.fact_key for a in cpa_accounts()]): + now = timezone.now() + earliest = ( + CPAUsageEvent.objects.filter(account=account) + .order_by("occurred_at") + .values_list("occurred_at", flat=True) + .first() + ) + start = started_at or earliest or account.created_at + end = ended_at or now + if start >= end or end > now: + raise ValidationError("历史范围必须为过去的有效起止时间") + current = account.cpa_owner_bindings.filter(ended_at__isnull=True).first() + if ( + current is None + or current.participant_id != participant.pk + or not participant.enabled + or not account.pool.allocations.filter(participant=participant).exists() + ): + raise ValidationError("请选择此 CPA 池内已分配份额的参与者") + config = AppSettings.load() + events = list(unassigned_events(account, start, end)) + if not events: + raise ValidationError("此范围没有未归属的已采集请求") + total = Decimal("0") + unpriced = 0 + for event in events: + cost, unknown = cpa_event_cost(event, config) + total += cost + unpriced += int(unknown) + return CPAClaimPlan.objects.create( + account=account, + participant=participant, + started_at=start, + ended_at=end, + created_by=user, + expires_at=now + timedelta(minutes=15), + source_digest=source_digest(account, start, end, config), + preview={ + "accounts": [ + { + "account_id": account.pk, + "account_name": account.name, + "request_count": len(events), + "token_count": sum(e.total_tokens for e in events), + "usage_usd": float(total), + "unpriced_request_count": unpriced, + "coverage": coverage_data(account, start, end), + } + ], + "historical_contract_policy": "仅认领已采集且尚无归属的请求,不补造断线数据或历史份额", + }, + ) + + +def apply_unassigned_claim(plan_id): + from ..replay import rebuild_account + + with fenced_fact_write([a.fact_key for a in cpa_accounts()]) as guards: + plan = ( + CPAClaimPlan.objects.select_for_update() + .select_related("account__pool") + .get(pk=plan_id) + ) + if plan.applied_at is not None: + return plan + config = AppSettings.load() + if plan.expires_at <= timezone.now() or plan.source_digest != source_digest( + plan.account, plan.started_at, plan.ended_at, config + ): + raise ValidationError("认领预览已过期或数据已变化,请重新预览") + events = list(unassigned_events(plan.account, plan.started_at, plan.ended_at)) + CPAClaimEvent.objects.bulk_create( + [CPAClaimEvent(plan=plan, event=e) for e in events], batch_size=500 + ) + rebuild_account( + plan.account.fact_key, config, guard=guards[plan.account.fact_key] + ) + plan.applied_at = timezone.now() + plan.save(update_fields=["applied_at"]) + return plan diff --git a/backend/monitor/cpa/billing.py b/backend/monitor/cpa/billing.py new file mode 100644 index 0000000..2a7819b --- /dev/null +++ b/backend/monitor/cpa/billing.py @@ -0,0 +1,572 @@ +"""Read-time, replayable billing projections. Never writes source facts or balances.""" + +from bisect import bisect_left, bisect_right +from calendar import monthrange +from collections import defaultdict +from datetime import datetime, timedelta, timezone as dt_timezone +from decimal import Decimal +from statistics import median +from types import SimpleNamespace +from zoneinfo import ZoneInfo + +from django.db.models import Q + +from ..access import visible_accounts_for +from ..accounting.boundaries import same_official_reset +from ..models import CPAUsageEvent, Observation +from ..reporting.recommendations import _capacity_values +from .capacity_estimate import particle_capacity_estimate +from .participants import coverage_data, event_owner +from .usage import cpa_event_cost + +ZERO = Decimal(0) + + +def billing_bounds(anchor, zone, now): + """Retain the original anchor day after short months; compare UTC durations.""" + tz = ZoneInfo(zone) + local = now.astimezone(tz) + + def boundary(y, m): + return datetime( + y, m, min(anchor.day, monthrange(y, m)[1]), tzinfo=tz + ).astimezone(dt_timezone.utc) + + anchor_start = datetime.combine(anchor, datetime.min.time(), tz).astimezone( + dt_timezone.utc + ) + if now < anchor_start: + next_month = anchor.year * 12 + anchor.month + return anchor_start, boundary(next_month // 12, next_month % 12 + 1) + + index = local.year * 12 + local.month - 1 + start = boundary(index // 12, index % 12 + 1) + if now < start: + index -= 1 + start = boundary(index // 12, index % 12 + 1) + return max( + start, + datetime.combine(anchor, datetime.min.time(), tz).astimezone(dt_timezone.utc), + ), boundary((index + 1) // 12, (index + 1) % 12 + 1) + + +def fraction(start, end, left, right): + return Decimal( + str(max(0, (min(end, right) - max(start, left)).total_seconds())) + ) / Decimal(str((end - start).total_seconds())) + + +def cycle_rows(account, config, now): + groups = [] + for obs in Observation.objects.filter( + account_id=account.fact_key, excluded_at__isnull=True, observed_at__lte=now + ).order_by("observed_at", "id"): + if not obs.upstream_resets_at or not obs.window_seconds: + continue + if not groups or not same_official_reset( + groups[-1][-1].upstream_resets_at, obs.upstream_resets_at + ): + groups.append([]) + groups[-1].append(obs) + rows = [] + for group in groups: + latest = group[-1] + start = latest.upstream_resets_at - timedelta(seconds=latest.window_seconds) + reliable = None + # Prefix sums price each request once per cycle, even when many stale + # observations are skipped after a late event or a catalog adjustment. + times, amounts, unknowns = [], [ZERO], [0] + if any(o.valid_sample or particle_capacity_estimate(o) for o in group): + for event in ( + CPAUsageEvent.objects.filter( + account=account, + occurred_at__gte=start, + occurred_at__lte=latest.observed_at, + ) + .order_by("occurred_at", "id") + .iterator(chunk_size=1000) + ): + cost, unknown = cpa_event_cost(event, config) + times.append(event.occurred_at) + amounts.append(amounts[-1] + cost) + unknowns.append(unknowns[-1] + int(unknown)) + details = None + for obs in reversed(group): + posterior = particle_capacity_estimate(obs) + if not posterior and not ( + obs.valid_sample + and obs.interval_used_percent == obs.upstream_used_percent + ): + continue + index = bisect_right(times, obs.observed_at) + base = 0 + if posterior and obs.attribution_started_at: + reason = obs.raw_window.get("replay_segment_reason") + observed_baseline = ( + reason + in { + "manual_override", + "official_zero_observation", + "provider_collection_baseline", + } + or obs.interval_used_percent != obs.upstream_used_percent + ) + base = (bisect_right if observed_baseline else bisect_left)( + times, obs.attribution_started_at + ) + if unknowns[index] - unknowns[base] or abs( + amounts[index] - amounts[base] - obs.selected_total_cost + ) > Decimal("0.00001"): + continue + if posterior and config.weekly_quota_model != "constant_average": + # Already computed by the same deterministic filter as the trajectory + # page. Do not reject it because the official week has collection gaps. + capacity = Decimal(str(posterior["capacity_usd"])) + details = posterior + else: + if posterior: + # The average model also uses only the valid connected segment. + if not obs.valid_sample or obs.interval_used_percent <= 0: + continue + elif not coverage_data(account, start, obs.observed_at)["complete"]: + continue + snap = SimpleNamespace( + observation=obs, + selected_cost=ZERO, + charged_cycle_percent=ZERO, + charged_percent_lower=None, + charged_percent_upper=None, + ) + capacity = _capacity_values(snap, config)[0] + details = dict( + source="quota_model", + capacity_usd=float(capacity), + lower_usd=None, + upper_usd=None, + prior_only=False, + as_of=obs.observed_at.isoformat(), + ) + if capacity > 0: + reliable = (obs, capacity) + break + rows.append( + dict( + start=start, + end=latest.upstream_resets_at, + natural_end=latest.upstream_resets_at, + window=latest.window_seconds, + observation=latest, + reliable=reliable, + capacity_estimate=details if reliable else None, + ) + ) + for i, row in enumerate(rows[:-1]): + row["end"] = min(row["end"], rows[i + 1]["start"]) + return [r for r in rows if r["end"] > r["start"]] + + +def contract_at(contracts, at): + return next((c for c in reversed(contracts) if c.effective_at <= at), None) + + +def billing_summary(user, account, config, now, bindings, members): + pool = account.pool + result = dict( + configured=bool(pool.cpa_billing_anchor), + anchor_date=str(pool.cpa_billing_anchor) if pool.cpa_billing_anchor else None, + timezone=pool.cpa_billing_timezone, + started_at=None, + ended_at=None, + generated_at=now.isoformat(), + capacity_usd=None, + actual_capacity_usd=None, + future_capacity_usd=None, + expired_usd=None, + available_usd=None, + usage_usd=0.0, + unattributed_usd=0.0, + other_members_usd=0.0, + unallocated_usd=None, + reasons=[], + cycles=[], + members=[], + ) + if not pool.cpa_billing_anchor: + return result + start, end = billing_bounds(pool.cpa_billing_anchor, pool.cpa_billing_timezone, now) + result.update(started_at=start.isoformat(), ended_at=end.isoformat()) + if start >= end or start > now: + result["reasons"] = ["账期尚未开始"] + return result + accounts = ( + visible_accounts_for(user) + .filter(provider="cpa") + .filter(Q(pool=pool) | Q(cpa_contracts__pool_id_at_capture=pool.id)) + .distinct() + ) + usage, entitlement, completed_usage, completed_entitlement = ( + defaultdict(lambda: ZERO) for _ in range(4) + ) + actual = future = expired = available = reserve = ZERO + future_known = True + capacity_known = True + contracts_known = True + reasons = set() + for selected in accounts: + contracts = list(selected.cpa_contracts.order_by("effective_at", "id")) + cycles = cycle_rows(selected, config, now) + event_start = min([start, *[r["start"] for r in cycles if r["end"] > start]]) + events = list( + CPAUsageEvent.objects.filter( + account=selected, + occurred_at__gte=event_start, + occurred_at__lt=min(now, end), + ).order_by("occurred_at", "id") + ) + costs = [(e, *cpa_event_cost(e, config)) for e in events] + current = next( + (r for r in reversed(cycles) if r["start"] <= now < r["end"]), None + ) + reliable_past = [ + r["reliable"][1] for r in cycles if r["end"] <= now and r["reliable"] + ][-3:] + prediction = ( + current["reliable"][1] + if current and current["reliable"] + else (median(reliable_past) if reliable_past else None) + ) + prediction_as_of = ( + current["reliable"][0].observed_at + if current and current["reliable"] + else next( + ( + r["reliable"][0].observed_at + for r in reversed(cycles) + if r["end"] <= now and r["reliable"] + ), + None, + ) + ) + # A stale observation cannot establish a new reset boundary. + if current: + cursor = current["end"] + while cursor < end: + cycles.append( + dict( + start=cursor, + end=cursor + timedelta(seconds=current["window"]), + window=current["window"], + reliable=None, + prediction=prediction, + prediction_as_of=prediction_as_of, + observation=None, + ) + ) + cursor = cycles[-1]["end"] + relevant_intervals = [] + for row in cycles: + left, right = max(start, row["start"]), min(end, row["end"]) + if left >= right: + continue + predicted = "prediction" in row + cuts = sorted( + { + left, + right, + *[ + c.effective_at + for c in contracts + if left < c.effective_at < right + ], + *([now] if left < now < right else []), + } + ) + segments = [] + for a, b in zip(cuts, cuts[1:]): + contract = contract_at(contracts, min(a, now)) + if contract and contract.pool_id_at_capture != pool.id: + continue + if contract is None: + # Only current-pool accounts can contribute unknown prehistory. + if selected.pool_id != pool.id: + continue + reasons.add("缺少历史份额或账号所属池记录") + contracts_known = False + segments.append((a, b, contract)) + relevant_intervals.append((a, b)) + if not segments: + continue + reliable = row["reliable"] + capacity = ( + row.get("prediction") + if predicted + else (reliable[1] if reliable else None) + ) + observed = reliable[0] if reliable else None + row_reasons = [] + if predicted and capacity is None: + future_known = False + if capacity is None: + capacity_known = False + row_reasons.append("缺少可靠周期容量") + cycle_costs = [ + (e, cost, unknown) + for e, cost, unknown in costs + if row["start"] <= e.occurred_at < min(row["end"], now) + ] + if not predicted: + if row["observation"].observed_at > row["end"]: + capacity_known = False + row_reasons.append("周期边界与观测时间冲突") + if not coverage_data(selected, row["start"], min(row["end"], now))[ + "complete" + ]: + row_reasons.append("采集覆盖不完整") + if any(unknown for _, _, unknown in cycle_costs): + row_reasons.append("请求缺少模型价格") + weight = sum( + (fraction(row["start"], row["end"], a, b) for a, b, _ in segments), ZERO + ) + amount = capacity * weight if capacity is not None else None + closed = row["end"] <= now + full_used = sum((cost for _, cost, _ in cycle_costs), ZERO) + loss = ( + max(ZERO, capacity - full_used) * weight + if closed and capacity is not None and not row_reasons + else None + ) + spendable = ( + max(ZERO, capacity - full_used) * weight + if not closed + and not predicted + and capacity is not None + and not row_reasons + else None + ) + result["cycles"].append( + dict( + account_id=selected.id, + account_name=selected.name, + started_at=row["start"].isoformat(), + ended_at=row["end"].isoformat(), + kind="future" + if predicted + else ("historical" if closed else "current"), + capacity_usd=float(amount) if amount is not None else None, + full_capacity_usd=float(capacity) if capacity is not None else None, + expired_usd=float(loss) if loss is not None else None, + quota_as_of=observed.observed_at.isoformat() + if observed + else ( + row["prediction_as_of"].isoformat() + if predicted and row["prediction_as_of"] + else None + ), + capacity_estimate=row.get("capacity_estimate"), + reasons=row_reasons, + ) + ) + reasons.update(row_reasons) + if amount is not None: + if predicted: + future += amount + available += amount + else: + actual += amount + expired += loss or ZERO + available += spendable or ZERO + for a, b, contract in segments: + if contract is None: + continue + piece = capacity * fraction(row["start"], row["end"], a, b) + allocated = ZERO + for allocation in contract.allocations: + pk = int(allocation["participant_id"]) + value = piece * Decimal(str(allocation["share_percent"])) / 100 + entitlement[pk] += value + if closed: + completed_entitlement[pk] += value + allocated += value + reserve += max(ZERO, piece - allocated) + # Detect missing actual cycle boundaries, including unobserved reset periods. + cursor = start + scope_segments = [] + cuts = sorted( + { + start, + min(now, end), + *[ + c.effective_at + for c in contracts + if start < c.effective_at < min(now, end) + ], + } + ) + for a, b in zip(cuts, cuts[1:]): + c = contract_at(contracts, a) + if (c and c.pool_id_at_capture == pool.id) or ( + c is None and selected.pool_id == pool.id + ): + scope_segments.append((a, b)) + for a, b in scope_segments: + cursor = a + for x, y in sorted(relevant_intervals): + if y <= cursor or x >= b: + continue + if x > cursor: + reasons.add("周期边界不完整") + capacity_known = False + cursor = max(cursor, y) + if cursor < b: + reasons.add("周期边界不完整") + capacity_known = False + if selected.pool_id == pool.id and not current: + reasons.add("等待当前周期观测,无法预测后续自然周期") + future_known = False + capacity_known = False + for e, cost, unknown in costs: + if not start <= e.occurred_at < min(end, now): + continue + contract = contract_at(contracts, e.occurred_at) + if contract and contract.pool_id_at_capture != pool.id: + continue + if contract is None: + if selected.pool_id != pool.id: + continue + reasons.add("缺少历史份额或账号所属池记录") + contracts_known = False + if unknown: + reasons.add("请求缺少模型价格") + owner = event_owner(e, bindings) + usage[owner] += cost + if any(r["start"] <= e.occurred_at < r["end"] <= now for r in cycles): + completed_usage[owner] += cost + known = not reasons + total = actual + future + result.update( + capacity_usd=float(total) if capacity_known and contracts_known else None, + actual_capacity_usd=float(actual), + future_capacity_usd=float(future) if future_known else None, + expired_usd=float(expired) if known else None, + available_usd=float(available) if known else None, + usage_usd=float(sum(usage.values(), ZERO)), + unattributed_usd=float(usage[None]), + other_members_usd=float( + sum( + (v for k, v in usage.items() if k is not None and k not in members), + ZERO, + ) + ), + unallocated_usd=float(reserve) if known else None, + reasons=sorted(reasons), + ) + remaining = {pk: max(ZERO, entitlement[pk] - usage[pk]) for pk in members} + denominator = ( + sum(remaining.values(), ZERO) + + reserve + + sum( + ( + max(ZERO, value - usage[pk]) + for pk, value in entitlement.items() + if pk not in members + ), + ZERO, + ) + ) + for pk in members: + rest = entitlement[pk] - usage[pk] + recommended = ( + min(available, denominator) * remaining[pk] / denominator + if denominator + else ZERO + ) + result["members"].append( + dict( + participant_id=pk, + usage_usd=float(usage[pk]), + usage_percent=float(usage[pk] / total * 100) + if capacity_known and contracts_known and total + else None, + entitlement_usd=float(entitlement[pk]) + if capacity_known and contracts_known + else None, + remaining_usd=float(rest) if known else None, + recommended_usd=float(recommended) if known else None, + recommended_percent=float(recommended / available * 100) + if known and available + else None, + completed_overuse_usd=float( + max(ZERO, completed_usage[pk] - completed_entitlement[pk]) + ) + if known + else None, + projected_overuse=rest < 0 if known else None, + ) + ) + return result + + +def weekly_distribution(accounts, members, config, now, bindings): + from .reporting import account_summary + + output = [] + for account in accounts: + obs, start, usage, total, coverage, _, latest_request = account_summary( + account, config, now, bindings + ) + cycles = cycle_rows(account, config, now) + current = next( + (r for r in reversed(cycles) if r["start"] <= now < r["end"]), None + ) + reliable = current["reliable"] if current else None + capacity = reliable[1] if reliable else None + complete = bool( + current + and coverage_data(account, current["start"], now)["complete"] + and not total["unpriced_request_count"] + ) + output.append( + dict( + account_id=account.id, + account_name=account.name, + started_at=start.isoformat(), + resets_at=obs.upstream_resets_at.isoformat() if obs else None, + quota_as_of=reliable[0].observed_at.isoformat() if reliable else None, + requests_as_of=latest_request.isoformat() if latest_request else None, + capacity_usd=float(capacity) if capacity is not None else None, + capacity_estimate=current.get("capacity_estimate") if current else None, + coverage_complete=complete, + remaining_usd=float(max(ZERO, capacity - total["usage_usd"])) + if capacity is not None and complete + else None, + upstream_remaining_percent=float( + max(ZERO, 100 - obs.upstream_used_percent) + ) + if obs and obs.upstream_resets_at > now + else None, + usage_usd=float(total["usage_usd"]), + unpriced_request_count=total["unpriced_request_count"], + unattributed_usd=float(usage[None]["usage_usd"]), + other_members_usd=float( + sum( + ( + v["usage_usd"] + for k, v in usage.items() + if k is not None and k not in members + ), + ZERO, + ) + ), + members=[ + dict( + participant_id=pk, + usage_usd=float(usage[pk]["usage_usd"]), + usage_percent=float(usage[pk]["usage_usd"] / capacity * 100) + if capacity + else None, + ) + for pk in members + ], + ) + ) + return output diff --git a/backend/monitor/cpa/capacity_estimate.py b/backend/monitor/cpa/capacity_estimate.py new file mode 100644 index 0000000..a4154f1 --- /dev/null +++ b/backend/monitor/cpa/capacity_estimate.py @@ -0,0 +1,29 @@ +"""Display the same persisted posterior used by particle trajectory replay. + +Collection completeness concerns settlement, not the existence of a capacity +posterior. A model prior is explicitly labelled and never presented as measured. +""" + +from decimal import Decimal + + +def particle_capacity_estimate(observation): + if ( + observation is None + or observation.attribution_started_at is None + or not observation.model_diagnostics.get("algorithm") + or observation.capacity_lower_usd is None + or observation.capacity_upper_usd is None + or observation.effective_usd_per_percent <= 0 + ): + return None + return { + "source": "particle_filter", + "capacity_usd": float( + (observation.effective_usd_per_percent * 100).quantize(Decimal("0.01")) + ), + "lower_usd": float(observation.capacity_lower_usd.quantize(Decimal("0.01"))), + "upper_usd": float(observation.capacity_upper_usd.quantize(Decimal("0.01"))), + "prior_only": not observation.valid_sample, + "as_of": observation.observed_at.isoformat(), + } diff --git a/backend/monitor/cpa/monitoring.py b/backend/monitor/cpa/monitoring.py index f70505e..8f88bde 100644 --- a/backend/monitor/cpa/monitoring.py +++ b/backend/monitor/cpa/monitoring.py @@ -64,6 +64,8 @@ def _persist_cpa_capture( interval_started_at = cost_started_at interval_cost = total_cost + from .participants import record_contract + record_contract(account, timezone.now()) point = UsageSamplePoint.objects.create( account_id=account.fact_key, observed_at=observed_at, diff --git a/backend/monitor/cpa/participants.py b/backend/monitor/cpa/participants.py new file mode 100644 index 0000000..2f04245 --- /dev/null +++ b/backend/monitor/cpa/participants.py @@ -0,0 +1,505 @@ +"""Time-scoped CPA ownership, explicit claims, and account projections.""" + +from bisect import bisect_left, bisect_right +from collections import defaultdict +from datetime import timedelta +from decimal import Decimal + +from django.db.models import Q +from django.utils import timezone +from rest_framework.exceptions import ValidationError + +from ..fact_utils import canonical_digest +from ..history_state import fenced_fact_write +from ..models import ( + AccountParticipant, + AppSettings, + CPAAPIKey, + CPAAccountOwnerBinding, + CPAClaimPlan, + CPAClaimEvent, + CPAKeyBinding, + CPAQuotaContract, + CPAUsageEvent, + MonitoredAccount, + Observation, + ParticipantSnapshot, + ParticipantUsageSample, +) +from .usage import _api_key_identity, cpa_event_cost + +ZERO = Decimal("0") + + +def _key_ownership_filter(participant_ids=None): + result = Q(pk__in=[]) + bindings = CPAKeyBinding.objects.filter(claim__isnull=True).select_related("key") + if participant_ids is not None: + bindings = bindings.filter(participant_id__in=participant_ids) + for binding in bindings: + condition = Q( + api_key_hash=binding.key.key_hash, occurred_at__gte=binding.started_at + ) + if binding.ended_at is not None: + condition &= Q(occurred_at__lt=binding.ended_at) + result |= condition + return result + + +def ownership_filter(participant_ids): + """Claims and explicit keys precede account fallback, identically to event_owner.""" + participant_ids = list(participant_ids) + claimed = Q(ownership_claim__plan__participant_id__in=participant_ids) + keyed = _key_ownership_filter(participant_ids) + fallback = Q(pk__in=[]) + for binding in CPAAccountOwnerBinding.objects.filter( + participant_id__in=participant_ids + ): + condition = Q( + account_id=binding.account_id, occurred_at__gte=binding.started_at + ) + if binding.ended_at is not None: + condition &= Q(occurred_at__lt=binding.ended_at) + fallback |= condition + return claimed | ( + Q(ownership_claim__isnull=True) + & (keyed | (fallback & ~_key_ownership_filter())) + ) + + +def owner_index(): + result = defaultdict(list) + result["claims"] = dict( + CPAClaimEvent.objects.values_list("event_id", "plan__participant_id") + ) + for binding in CPAAccountOwnerBinding.objects.order_by("started_at", "id"): + result[("account", binding.account_id)].append(binding) + claimed = defaultdict(set) + for plan_id, event_id in CPAClaimEvent.objects.values_list("plan_id", "event_id"): + claimed[plan_id].add(event_id) + for binding in CPAKeyBinding.objects.select_related("key").order_by( + "started_at", "id" + ): + binding.claimed_event_ids = ( + claimed[binding.claim_id] if binding.claim_id else None + ) + result[binding.key.key_hash].append(binding) + return result + + +def event_owner(event, bindings): + if event.pk in bindings["claims"]: + return bindings["claims"][event.pk] + for binding in reversed(bindings.get(event.api_key_hash, ())): + if ( + binding.claimed_event_ids is not None + and event.pk not in binding.claimed_event_ids + ): + continue + if binding.started_at <= event.occurred_at and ( + binding.ended_at is None or event.occurred_at < binding.ended_at + ): + return binding.participant_id + for binding in reversed(bindings.get(("account", event.account_id), ())): + if binding.started_at <= event.occurred_at and ( + binding.ended_at is None or event.occurred_at < binding.ended_at + ): + return binding.participant_id + return None + + +def cpa_accounts(): + return MonitoredAccount.objects.filter(provider="cpa").order_by("id") + + +def register_key(*, raw_key="", observed_hash="", name=""): + if raw_key: + digest, hint = _api_key_identity(raw_key) + else: + existing = CPAAPIKey.objects.filter(key_hash=observed_hash).first() + if existing is not None: + if name and existing.name != name: + existing.name = name + existing.save(update_fields=["name"]) + return existing + event = ( + CPAUsageEvent.objects.filter(api_key_hash=observed_hash) + .exclude(api_key_hash="") + .first() + ) + if event is None: + raise ValidationError("该 Key 尚未采集,请输入完整 Key 预先绑定") + digest, hint = event.api_key_hash, event.api_key_hint + if not digest: + raise ValidationError("Key 不能为空") + key, _ = CPAAPIKey.objects.get_or_create( + key_hash=digest, defaults={"hint": hint, "name": name} + ) + if name and key.name != name: + key.name = name + key.save(update_fields=["name"]) + return key + + +def assert_available(key, started_at, ended_at=None, claiming_participant_id=None): + rows = key.bindings.filter(Q(ended_at__isnull=True) | Q(ended_at__gt=started_at)) + if ended_at is not None: + rows = rows.filter(started_at__lt=ended_at) + if claiming_participant_id is not None: + rows = rows.exclude(claim__isnull=False, participant_id=claiming_participant_id) + if rows.exists(): + raise ValidationError("此时间范围已有 Key 归属,请调整范围或先解绑当前归属") + + +def bind_key(*, participant, user, raw_key="", observed_hash="", name=""): + with fenced_fact_write([a.fact_key for a in cpa_accounts()]): + key = register_key(raw_key=raw_key, observed_hash=observed_hash, name=name) + started_at = timezone.now() + assert_available(key, started_at) + return CPAKeyBinding.objects.create( + key=key, participant=participant, started_at=started_at, created_by=user + ) + + +def unbind_key(binding_id): + with fenced_fact_write([a.fact_key for a in cpa_accounts()]): + binding = CPAKeyBinding.objects.select_for_update().get(pk=binding_id) + if binding.ended_at is None: + binding.ended_at = max( + timezone.now(), binding.started_at + timedelta(microseconds=1) + ) + binding.save(update_fields=["ended_at"]) + return binding + + +def coverage_data(account, started_at, ended_at): + intervals = list( + account.cpa_collection_intervals.filter(connected_at__lte=ended_at) + .filter(Q(disconnected_at__isnull=True) | Q(disconnected_at__gte=started_at)) + .order_by("connected_at", "id") + ) + cursor = started_at + gaps = [] + for interval in intervals: + if interval.connected_at > cursor: + gaps.append( + { + "started_at": cursor.isoformat(), + "ended_at": min(interval.connected_at, ended_at).isoformat(), + } + ) + cursor = max(cursor, min(interval.disconnected_at or ended_at, ended_at)) + if cursor < ended_at: + gaps.append( + {"started_at": cursor.isoformat(), "ended_at": ended_at.isoformat()} + ) + uncertain_end = any( + row.disconnected_at is not None + and not row.end_reliable + and started_at < row.disconnected_at <= ended_at + for row in intervals + ) + return { + "complete": not gaps and not uncertain_end, + "gaps": gaps, + "uncertain_end": uncertain_end, + } + + +def _claim_events(key, start, end): + return ( + CPAUsageEvent.objects.filter( + api_key_hash=key.key_hash, + occurred_at__gte=start, + occurred_at__lt=end, + ownership_claim__isnull=True, + ) + .exclude( + ownership_filter( + CPAAccountOwnerBinding.objects.values_list("participant_id", flat=True) + ) + ) + .order_by("id") + ) + + +def claim_digest(key, start, end, config): + accounts = list(cpa_accounts()) + return canonical_digest( + { + "events": list(_claim_events(key, start, end).values()), + "bindings": list(key.bindings.order_by("id").values()), + "owners": list(CPAAccountOwnerBinding.objects.order_by("id").values()), + "claims": list(CPAClaimEvent.objects.order_by("id").values()), + "settings": AppSettings.objects.filter(pk=config.pk).values().get(), + "accounts": [(a.id, a.pool_id, a.pool.contract_revision) for a in accounts], + "contracts": list(CPAQuotaContract.objects.order_by("id").values()), + "observations": list( + Observation.objects.filter( + account_id__in=[a.fact_key for a in accounts] + ) + .order_by("id") + .values( + "id", + "observed_at", + "upstream_used_percent", + "upstream_resets_at", + "excluded_at", + "is_manual_start", + "manual_start_end_id", + ) + ), + "coverage": [ + list(a.cpa_collection_intervals.order_by("id").values()) + for a in accounts + ], + } + ) + + +def preview_claim(*, key, participant, started_at, ended_at, user): + if started_at >= ended_at or ended_at > timezone.now(): + raise ValidationError("历史范围必须为过去的有效起止时间") + with fenced_fact_write([a.fact_key for a in cpa_accounts()]): + assert_available(key, started_at, ended_at, participant.pk) + config = AppSettings.load() + groups = {} + for event in _claim_events(key, started_at, ended_at).select_related("account"): + row = groups.setdefault( + event.account_id, + { + "account_id": event.account_id, + "account_name": event.account.name, + "request_count": 0, + "token_count": 0, + "usage_usd": ZERO, + "unpriced_request_count": 0, + "coverage": coverage_data(event.account, started_at, ended_at), + }, + ) + cost, unknown = cpa_event_cost(event, config) + row["request_count"] += 1 + row["token_count"] += event.total_tokens + row["usage_usd"] += cost + row["unpriced_request_count"] += int(unknown) + if not groups: + raise ValidationError("此范围没有已采集请求") + for row in groups.values(): + row["usage_usd"] = float(row["usage_usd"]) + return CPAClaimPlan.objects.create( + key=key, + participant=participant, + started_at=started_at, + ended_at=ended_at, + created_by=user, + expires_at=timezone.now() + timedelta(minutes=15), + source_digest=claim_digest(key, started_at, ended_at, config), + preview={ + "accounts": list(groups.values()), + "historical_contract_policy": "缺少历史份额时只认领请求与消耗,剩余权益保持未知", + }, + ) + + +def apply_claim(plan_id): + plan = CPAClaimPlan.objects.get(pk=plan_id) + if plan.account_id is not None: + from .account_owner import apply_unassigned_claim + + return apply_unassigned_claim(plan_id) + from ..replay import rebuild_account + + with fenced_fact_write([a.fact_key for a in cpa_accounts()]) as guards: + plan = CPAClaimPlan.objects.select_for_update().get(pk=plan_id) + if plan.applied_at is not None: + return plan + config = AppSettings.load() + if plan.expires_at <= timezone.now() or plan.source_digest != claim_digest( + plan.key, plan.started_at, plan.ended_at, config + ): + raise ValidationError("认领预览已过期或数据已变化,请重新预览") + assert_available(plan.key, plan.started_at, plan.ended_at, plan.participant_id) + events = list( + _claim_events(plan.key, plan.started_at, plan.ended_at).values_list( + "id", "account_id" + ) + ) + CPAKeyBinding.objects.create( + key=plan.key, + participant=plan.participant, + started_at=plan.started_at, + ended_at=plan.ended_at, + created_by=plan.created_by, + claim=plan, + ) + CPAClaimEvent.objects.bulk_create( + [CPAClaimEvent(plan=plan, event_id=event_id) for event_id, _ in events], + batch_size=500, + ) + account_ids = {account_id for _, account_id in events} + for account in cpa_accounts().filter(pk__in=account_ids): + rebuild_account(account.fact_key, config, guard=guards[account.fact_key]) + plan.applied_at = timezone.now() + plan.save(update_fields=["applied_at"]) + return plan + + +def record_contract(account, effective_at): + from .account_owner import sync_account_owner + + sync_account_owner(account, effective_at) + allocations = [ + {"participant_id": row.participant_id, "share_percent": str(row.share_percent)} + for row in account.pool.allocations.order_by("participant_id") + ] + previous = account.cpa_contracts.order_by("-effective_at", "-id").first() + if ( + previous + and previous.pool_id_at_capture == account.pool_id + and previous.revision == account.pool.contract_revision + and previous.allocations == allocations + ): + return previous + return CPAQuotaContract.objects.create( + account=account, + effective_at=effective_at, + pool_id_at_capture=account.pool_id, + pool_name=account.pool.name, + revision=account.pool.contract_revision, + allocations=allocations, + ) + + +def materialize_participants(account, config): + """Rebuild participant inputs from events and frozen policy before model replay.""" + bindings = owner_index() + prefixes = defaultdict(lambda: [[], [ZERO]]) + for event in ( + CPAUsageEvent.objects.filter(account=account) + .order_by("occurred_at", "id") + .iterator(chunk_size=1000) + ): + owner = event_owner(event, bindings) + if owner is None: + continue + times, sums = prefixes[owner] + times.append(event.occurred_at) + sums.append(sums[-1] + cpa_event_cost(event, config)[0]) + contracts = list(account.cpa_contracts.order_by("effective_at", "id")) + contract_times = [row.effective_at for row in contracts] + observations = list( + Observation.objects.filter(account_id=account.fact_key).order_by( + "observed_at", "id" + ) + ) + snapshots = { + (row.observation_id, row.participant_id): row + for row in ParticipantSnapshot.objects.filter( + observation__account_id=account.fact_key + ) + } + samples = { + (row.observed_at, row.participant_id): row + for row in ParticipantUsageSample.objects.filter(account_id=account.fact_key) + } + new_snapshots, changed_snapshots, new_samples, changed_samples = [], [], [], [] + snapshot_fields = [ + "raw_selected_cost", + "share_percent", + "cpa_contract_known", + "quota_pool_id", + "quota_pool_name", + "pool_contract_revision", + ] + member_ids = set() + for observation in observations: + index = bisect_right(contract_times, observation.observed_at) - 1 + contract = contracts[index] if index >= 0 else None + shares = ( + { + row["participant_id"]: Decimal(row["share_percent"]) + for row in contract.allocations + } + if contract + else {} + ) + start = observation.cost_window_started_at or ( + observation.upstream_resets_at + - timedelta(seconds=observation.window_seconds) + ) + for participant_id in set(prefixes) | set(shares): + times, sums = prefixes[participant_id] + if participant_id not in shares and ( + not times or times[0] > observation.observed_at + ): + continue + member_ids.add(participant_id) + cost = ( + sums[bisect_right(times, observation.observed_at)] + - sums[bisect_left(times, start)] + ) + values = { + "raw_selected_cost": cost, + "share_percent": shares.get(participant_id, ZERO), + "cpa_contract_known": participant_id in shares, + "quota_pool_id": contract.pool_id_at_capture if contract else None, + "quota_pool_name": contract.pool_name if contract else "", + "pool_contract_revision": contract.revision if contract else None, + } + snapshot = snapshots.get((observation.id, participant_id)) + if snapshot is None: + new_snapshots.append( + ParticipantSnapshot( + observation=observation, + participant_id=participant_id, + selected_cost=cost, + **values, + ) + ) + elif any( + getattr(snapshot, field) != value for field, value in values.items() + ): + for field, value in values.items(): + setattr(snapshot, field, value) + changed_snapshots.append(snapshot) + sample = samples.get((observation.observed_at, participant_id)) + if sample is None: + new_samples.append( + ParticipantUsageSample( + participant_id=participant_id, + account_id=account.fact_key, + observed_at=observation.observed_at, + sample_point_id=observation.sample_point_id, + raw_selected_cost=cost, + selected_cost=cost, + balance_usd=None, + ) + ) + elif sample.raw_selected_cost != cost: + sample.raw_selected_cost = cost + changed_samples.append(sample) + ParticipantSnapshot.objects.bulk_create(new_snapshots, batch_size=500) + ParticipantSnapshot.objects.bulk_update( + changed_snapshots, snapshot_fields, batch_size=500 + ) + ParticipantUsageSample.objects.bulk_create(new_samples, batch_size=500) + ParticipantUsageSample.objects.bulk_update( + changed_samples, ["raw_selected_cost"], batch_size=500 + ) + existing = set(account.memberships.values_list("participant_id", flat=True)) + AccountParticipant.objects.bulk_create( + [ + AccountParticipant(account=account, participant_id=pk) + for pk in member_ids - existing + ], + ignore_conflicts=True, + ) + + +def has_cpa_contract_history(participant_id): + return any( + any(row["participant_id"] == participant_id for row in allocations) + for allocations in CPAQuotaContract.objects.values_list( + "allocations", flat=True + ).iterator() + ) diff --git a/backend/monitor/cpa/price_sync.py b/backend/monitor/cpa/price_sync.py new file mode 100644 index 0000000..3328c71 --- /dev/null +++ b/backend/monitor/cpa/price_sync.py @@ -0,0 +1,169 @@ +"""Fill missing CPA base prices from the public models.dev OpenAI catalog.""" + +from datetime import timedelta +from decimal import Decimal, InvalidOperation + +import httpx +from django.db.models import Count, Sum +from django.utils import timezone + +from ..history_state import fenced_fact_write +from ..models import AppSettings, CPAUsageEvent, MonitoredAccount +from ..replay import rebuild_account +from .pricing import resolve_model_price +from .usage import refresh_cpa_history + +CATALOG_URL = "https://models.dev/api.json" +MAX_CATALOG_BYTES = 16 * 1024 * 1024 + + +class PriceCatalogError(ValueError): + pass + + +def fetch_catalog(): + # Model names, keys and usage never leave this service. + try: + with httpx.stream( + "GET", + CATALOG_URL, + timeout=20, + headers={"User-Agent": "Sub2Pool/1.0", "Accept": "application/json"}, + ) as response: + response.raise_for_status() + chunks, size = [], 0 + for chunk in response.iter_bytes(): + size += len(chunk) + if size > MAX_CATALOG_BYTES: + raise PriceCatalogError("价格目录过大,请稍后重试") + chunks.append(chunk) + import json + + catalog = json.loads(b"".join(chunks), parse_float=Decimal) + models = catalog["openai"]["models"] + if not isinstance(models, dict) or not models: + raise ValueError("empty catalog") + return models + except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc: + raise PriceCatalogError( + "无法读取 models.dev 价格目录,请稍后重试或手动填写价格;已有价格未更改" + ) from exc + + +def observed_models(account_id=None): + events = CPAUsageEvent.objects.all() + if account_id is not None: + events = events.filter(account_id=account_id) + return ( + events.order_by() + .values("model") + .annotate(request_count=Count("id"), token_count=Sum("total_tokens")) + .order_by("model") + ) + + +def pricing_inventory(config, account_id=None): + models = [] + for row in observed_models(account_id): + resolved = resolve_model_price(config.cpa_model_pricing, row["model"]) + models.append( + { + **row, + "missing": resolved is None, + "pricing_model": resolved[0] if resolved else None, + } + ) + return { + "pricing": config.cpa_model_pricing, + "models": models, + "missing_model_count": sum(row["missing"] for row in models), + "unpriced_request_count": sum( + row["request_count"] for row in models if row["missing"] + ), + "source": "models.dev", + "source_url": CATALOG_URL, + "generated_at": timezone.now().isoformat(), + } + + +def catalog_price(models, model): + # Match official OpenAI IDs only; don't guess prices from another provider, + # dates, reasoning suffixes, or an arbitrary custom alias. + candidates = [model] + if model.endswith("-latest"): + candidates.append(model[:-7]) + if model.startswith("openai/"): + candidates.append(model[len("openai/") :]) + for name in candidates: + entry = models.get(name) + if not isinstance(entry, dict): + continue + cost = entry.get("cost") + if not isinstance(cost, dict): + continue + result = {} + for local, remote in ( + ("input", "input"), + ("cached_input", "cache_read"), + ("output", "output"), + ): + try: + value = Decimal(str(cost[remote])) + if isinstance(cost[remote], bool) or not value.is_finite() or value < 0: + return None + result[local] = str(value) + except (KeyError, ValueError, InvalidOperation, TypeError): + return None + return name, result + return None + + +def sync_missing_prices(account_id=None): + config = AppSettings.load() + inventory = pricing_inventory(config, account_id) + if not inventory["missing_model_count"]: + return {**inventory, "added": [], "unresolved": []} + models = fetch_catalog() + accounts = list(MonitoredAccount.objects.filter(provider="cpa").order_by("id")) + with fenced_fact_write( + [account.fact_key for account in accounts], ttl=timedelta(minutes=30) + ) as guards: + config = AppSettings.objects.select_for_update().get(pk=config.pk) + pricing = dict(config.cpa_model_pricing) + added, unresolved = [], [] + for row in observed_models(account_id): + model = row["model"] + # Recheck after taking the lock so concurrent manual edits win. + if resolve_model_price(pricing, model) is not None: + continue + match = catalog_price(models, model) + if match is None: + unresolved.append( + { + "model": model, + "reason": "目录未收录此名称或缺少完整的三项价格,请手动填写", + } + ) + continue + source_model, price = match + pricing[model] = price + added.append( + { + "model": model, + "source_model": f"openai/{source_model}", + "price": price, + } + ) + if added: + config.cpa_model_pricing = pricing + config.save(update_fields=["cpa_model_pricing", "updated_at"]) + refresh_cpa_history(config, rebuild=False) + for account in accounts: + rebuild_account( + account.fact_key, config, guard=guards[account.fact_key] + ) + return { + **pricing_inventory(config, account_id), + "added": added, + "unresolved": unresolved, + } diff --git a/backend/monitor/cpa/quota_status.py b/backend/monitor/cpa/quota_status.py new file mode 100644 index 0000000..17f219c --- /dev/null +++ b/backend/monitor/cpa/quota_status.py @@ -0,0 +1,229 @@ +"""Read-only quota cards with distinct capacity estimates and usage projections.""" + +from datetime import UTC, datetime, timedelta +from math import isfinite + + +from ..models import CPAUsageEvent, Observation +from .participants import coverage_data +from .request_summary import request_summary + + +def number(value): + try: + result = float(value) + return result if isfinite(result) and not isinstance(value, bool) else None + except (ValueError, TypeError, OverflowError): + return None + + +def reset_credits(body): + credit = body.get("rate_limit_reset_credits") if isinstance(body, dict) else None + value = number(credit.get("available_count")) if isinstance(credit, dict) else None + return ( + int(value) if value is not None and value >= 0 and value.is_integer() else None + ) + + +def metrics(account, config, start, end): + result = request_summary( + CPAUsageEvent.objects.filter( + account=account, occurred_at__gte=start, occurred_at__lt=end + ), + config, + ) + count = result["request_count"] + return { + "request_count": count, + "token_count": result["total_tokens"], + "usage_usd": result["usage_usd"], + "success_rate": (count - result["failed_count"]) / count * 100 + if count + else None, + "unpriced_request_count": result["unpriced_request_count"], + } + + +def period(account, config, start, end): + return { + "started_at": start.isoformat(), + "ended_at": end.isoformat(), + "metrics": metrics(account, config, start, end), + "coverage_complete": coverage_data(account, start, end)["complete"], + } + + +def quota_detail(account, config, now, body=None, weekly=None): + body = body if isinstance(body, dict) else {} + earliest = ( + CPAUsageEvent.objects.filter(account=account, occurred_at__lte=now) + .order_by("occurred_at") + .values_list("occurred_at", flat=True) + .first() + ) + start = max(now - timedelta(days=30), earliest or now - timedelta(days=30)) + result = { + "totals": period(account, config, start, now), + "windows": [], + "reset": { + "available_count": reset_credits(body), + "can_reset": False, + "history": [], + }, + } + groups = [ + ("standard", "", body.get("rate_limit"), True), + ("review", "代码审查", body.get("code_review_rate_limit"), False), + ] + additional = body.get("additional_rate_limits") + separate_model_limits = bool(additional) or bool(body.get("code_review_rate_limit")) + if isinstance(additional, list): + groups.extend( + ( + f"additional-{index}", + str( + item.get("limit_name") or item.get("metered_feature") or "附加限额" + ), + item.get("rate_limit"), + False, + ) + for index, item in enumerate(additional) + if isinstance(item, dict) + ) + if not isinstance(body.get("rate_limit"), dict) and weekly is not None: + groups[0] = ( + "standard", + "", + { + weekly.slot: { + "used_percent": float(weekly.used_percent), + "limit_window_seconds": weekly.window_seconds, + "reset_at": weekly.reset_at, + } + }, + True, + ) + for group_id, label, limits, standard in groups: + if not isinstance(limits, dict): + continue + for slot in ("primary_window", "secondary_window"): + raw = limits.get(slot) + if not isinstance(raw, dict): + continue + seconds = number(raw.get("limit_window_seconds")) + used = number(raw.get("used_percent")) + used = used if used is not None and 0 <= used <= 100 else None + reset = number(raw.get("reset_at")) + try: + end = ( + datetime.fromtimestamp(reset, UTC) if reset and reset > 0 else None + ) + except (ValueError, OverflowError, OSError): + end = None + duration = ( + "周限额" + if seconds == 604800 + else "5 小时限额" + if seconds == 18000 + else f"{seconds / 3600:g} 小时限额" + if seconds and seconds > 0 + else "额度窗口" + ) + row = { + "id": f"{group_id}-{slot}", + "label": f"{label} {duration}".strip(), + "used_percent": used, + "reset_at": end.isoformat() if end else None, + "updated_at": weekly.sampled_at if weekly else now.isoformat(), + "source": "主动 API 查询", + "boundary": "unknown", + "current": None, + "previous": None, + "prediction": None, + "capacity_estimate": None, + "notice": None, + } + window_start = ( + end - timedelta(seconds=seconds) + if end and seconds and 0 < seconds <= 366 * 86400 + else None + ) + if window_start is not None: + row["boundary"] = "provider" + if not standard: + row["notice"] = "上游未提供完整模型范围,未计算该窗口用量。" + elif window_start is None or end <= now or window_start > now: + row["notice"] = "缺少有效窗口边界,等待新的额度观测。" + else: + row["current"] = period(account, config, window_start, now) + row["current"]["ended_at"] = end.isoformat() + current = row["current"] + if seconds == 604800: + previous = ( + Observation.objects.filter( + account_id=account.fact_key, + excluded_at__isnull=True, + observed_at__lt=window_start, + ) + .order_by("-observed_at", "-id") + .first() + ) + if previous: + prev_start = previous.upstream_resets_at - timedelta( + seconds=previous.window_seconds + ) + prev_end = min(previous.upstream_resets_at, window_start) + if prev_start < prev_end: + row["previous"] = period( + account, config, prev_start, prev_end + ) + if previous.upstream_resets_at > window_start: + row["previous"]["notice"] = ( + "上个窗口提前重置,仅统计已观测区间。" + ) + if separate_model_limits: + row["notice"] = ( + "用量为该时间段的账号合计;附加限额的模型范围不明确,暂不预测。" + ) + elif not current["coverage_complete"]: + row["notice"] = "当前窗口采集不完整,仅展示已采集用量,暂不预测。" + elif current["metrics"]["unpriced_request_count"]: + row["notice"] = "当前窗口存在未计价请求,暂不预测。" + elif ( + used is None + or used <= 0 + or current["metrics"]["request_count"] == 0 + ): + row["notice"] = "用量观测不足,暂不预测。" + else: + row["prediction"] = { + key: current["metrics"][key] * 100 / used + for key in ("request_count", "token_count", "usage_usd") + } + if ( + standard + and seconds == 604800 + and window_start + and window_start <= now < end + ): + from ..accounting.boundaries import same_official_reset + from .billing import cycle_rows + + cycle = next( + ( + c + for c in reversed(cycle_rows(account, config, now)) + if same_official_reset(c["natural_end"], end) + ), + None, + ) + if cycle and cycle.get("capacity_estimate"): + row["capacity_estimate"] = cycle["capacity_estimate"] + if row["prediction"] is None: + row["notice"] = ( + "容量采用当前额度模型估计;请求数与 Token 总量暂不预测,采集缺口仍影响历史结算。" + ) + result["windows"].append(row) + # Put the weekly account quota first, then other standard/model limits. + result["windows"].sort(key=lambda row: 0 if row["label"] == "周限额" else 1) + return result diff --git a/backend/monitor/cpa/reporting.py b/backend/monitor/cpa/reporting.py new file mode 100644 index 0000000..33625e3 --- /dev/null +++ b/backend/monitor/cpa/reporting.py @@ -0,0 +1,265 @@ +"""CPA pool summaries expose peers' totals, never their keys or requests.""" + +from collections import defaultdict +from datetime import timedelta +from decimal import Decimal + +from django.utils import timezone + +from ..access import visible_accounts_for, visible_participant_ids +from ..models import ( + AppSettings, + CPAUsageEvent, + Observation, + Participant, + PoolParticipant, +) +from ..reporting.recommendations import _capacity_values +from .account_owner import account_owner_data +from .collector_state import get_collector_status +from .participants import coverage_data, event_owner, owner_index +from .usage import cpa_event_cost + +ZERO = Decimal("0") + + +def _totals(): + return { + "usage_usd": ZERO, + "request_count": 0, + "token_count": 0, + "unpriced_request_count": 0, + } + + +def account_summary(account, config, now, bindings): + observation = ( + Observation.objects.filter( + account_id=account.fact_key, + excluded_at__isnull=True, + attribution_started_at__isnull=False, + ) + .prefetch_related("participant_snapshots") + .order_by("-observed_at", "-id") + .first() + ) + start = ( + observation.upstream_resets_at - timedelta(seconds=observation.window_seconds) + if observation + else account.created_at + ) + summaries = defaultdict(_totals) + total = _totals() + latest_request_at = None + for event in ( + CPAUsageEvent.objects.filter( + account=account, occurred_at__gte=start, occurred_at__lte=now + ) + .order_by("occurred_at", "id") + .iterator(chunk_size=1000) + ): + latest_request_at = event.occurred_at + cost, unknown = cpa_event_cost(event, config) + for row in (summaries[event_owner(event, bindings)], total): + row["usage_usd"] += cost + row["request_count"] += 1 + row["token_count"] += event.total_tokens + row["unpriced_request_count"] += int(unknown) + coverage = coverage_data( + account, start, observation.observed_at if observation else now + ) + snapshots = ( + {row.participant_id: row for row in observation.participant_snapshots.all()} + if observation + else {} + ) + return observation, start, summaries, total, coverage, snapshots, latest_request_at + + +def pool_summary(user, account, config=None): + config = config or AppSettings.load() + now = timezone.now() + mine = visible_participant_ids(user) + accounts = list( + visible_accounts_for(user) + .filter(provider="cpa", pool_id=account.pool_id, enabled=True) + .order_by("id") + ) + allocations = list( + PoolParticipant.objects.filter(pool=account.pool) + .select_related("participant") + .order_by("participant_id") + ) + shares = {row.participant_id: row.share_percent for row in allocations} + members = {row.participant_id: row.participant for row in allocations} + if mine is not None: + for participant in Participant.objects.filter( + pk__in=mine, cpa_bindings__isnull=False + ).distinct(): + members.setdefault(participant.id, participant) + bindings = owner_index() + by_account = [] + rows = { + pk: { + "participant_id": pk, + "participant_name": member.name, + "is_owner": member.is_owner, + "is_self": mine is not None and pk in mine, + "share_percent": float(shares[pk]) if pk in shares else None, + **_totals(), + "expected_entitlement_usd": ZERO, + "consumed_entitlement_usd": ZERO, + "remaining_entitlement_usd": ZERO, + "quota_available": bool(accounts), + "is_overused": False, + "_remaining_upper": ZERO, + "account_breakdowns": [], + } + for pk, member in members.items() + } + unassigned = _totals() + for selected in accounts: + observation, start, totals, total, coverage, snapshots, latest_request_at = ( + account_summary(selected, config, now, bindings) + ) + observed_at = observation.observed_at if observation else None + unavailable_reasons = [] + if not observation: + unavailable_reasons.append("尚无额度观测") + else: + if not observation.valid_sample: + unavailable_reasons.append("额度观测尚不足以估算") + if observation.upstream_resets_at <= now: + unavailable_reasons.append("等待新周期的额度观测") + if observation.interval_used_percent != observation.upstream_used_percent: + unavailable_reasons.append("本周期用量覆盖不完整") + if not coverage["complete"]: + unavailable_reasons.append( + f"本周期有 {len(coverage['gaps'])} 段采集缺口" + if coverage["gaps"] + else "本周期采集覆盖不完整" + ) + if total["unpriced_request_count"]: + unavailable_reasons.append( + f"{total['unpriced_request_count']} 次请求缺少模型价格" + ) + valid = not unavailable_reasons + by_account.append( + { + "account_id": selected.id, + "account_name": selected.name, + "owner": account_owner_data(selected), + "selected": selected.id == account.id, + "quota_as_of": observed_at.isoformat() if observed_at else None, + "requests_as_of": latest_request_at.isoformat() + if latest_request_at + else None, + "cycle_started_at": start.isoformat(), + "resets_at": observation.upstream_resets_at.isoformat() + if observation + else None, + "coverage": coverage, + "upstream_used_percent": float(observation.upstream_used_percent) if observation else None, + "quota_available": valid, + "quota_unavailable_reasons": unavailable_reasons, + **{ + k: float(v) if isinstance(v, Decimal) else v + for k, v in total.items() + }, + } + ) + for owner, values in totals.items(): + if owner not in rows: + for key in unassigned: + unassigned[key] += values[key] + for pk, row in rows.items(): + values = totals[pk] + for key in values: + row[key] += values[key] + snapshot = snapshots.get(pk) + member_reasons = list(unavailable_reasons) + if pk not in shares: + member_reasons.append("尚未分配 CPA 份额") + if not snapshot or not snapshot.cpa_contract_known: + member_reasons.append("缺少历史份额依据或对应额度观测") + elif ( + snapshot.quota_pool_id != selected.pool_id + or snapshot.pool_contract_revision != selected.pool.contract_revision + ): + member_reasons.append("份额调整后尚未生成匹配的额度结果") + available = valid and not member_reasons + breakdown = { + "account_id": selected.id, + "quota_available": available, + "quota_unavailable_reasons": member_reasons, + "quota_as_of": observed_at.isoformat() if observed_at else None, + "charged_percent": None, + "remaining_share_percent": None, + "usage_usd": float(values["usage_usd"]), + "estimated_capacity_usd": None, + "expected_entitlement_usd": None, + "consumed_entitlement_usd": None, + "remaining_entitlement_usd": None, + } + if available: + capacity, capacity_lo, capacity_hi, charged, charged_lo, charged_hi = ( + _capacity_values(snapshot, config) + ) + expected = shares[pk] * capacity / 100 + consumed = charged * capacity / 100 + remaining = expected - consumed + breakdown.update( + charged_percent=float(charged), + remaining_share_percent=float(max(ZERO, shares[pk] - charged)), + estimated_capacity_usd=float(capacity), + expected_entitlement_usd=float(expected), + consumed_entitlement_usd=float(consumed), + remaining_entitlement_usd=float(remaining), + ) + row["expected_entitlement_usd"] += expected + row["consumed_entitlement_usd"] += consumed + row["remaining_entitlement_usd"] += remaining + row["_remaining_upper"] += max( + (shares[pk] - spent) * cap / 100 + for spent in (charged_lo, charged_hi) + for cap in (capacity_lo, capacity_hi) + ) + else: + row["quota_available"] = False + row["account_breakdowns"].append(breakdown) + for row in rows.values(): + if not row["quota_available"]: + for key in ( + "expected_entitlement_usd", + "consumed_entitlement_usd", + "remaining_entitlement_usd", + ): + row[key] = None + row["is_overused"] = False + else: + # Mixed-pool credits offset deficits, as in existing pool accounting. + row["is_overused"] = row["_remaining_upper"] < ZERO + row.pop("_remaining_upper") + for key, value in list(row.items()): + if isinstance(value, Decimal): + row[key] = float(value) + from .billing import billing_summary, weekly_distribution + + return { + "weekly_distribution": weekly_distribution(accounts, members, config, now, bindings), + "billing_summary": billing_summary(user, account, config, now, bindings, members), + "pool_id": account.pool_id, + "pool_name": account.pool.name, + "selected_account_id": account.id, + "partial_scope": len(accounts) + != account.pool.accounts.filter(enabled=True).count(), + "accounts": by_account, + "members": list(rows.values()), + "unattributed": { + k: float(v) if isinstance(v, Decimal) else v for k, v in unassigned.items() + }, + "collector": get_collector_status(include_error=user.is_staff), + "cost_estimate": True, + "enforcement_enabled": False, + "generated_at": now.isoformat(), + } diff --git a/backend/monitor/cpa/request_summary.py b/backend/monitor/cpa/request_summary.py new file mode 100644 index 0000000..7e78201 --- /dev/null +++ b/backend/monitor/cpa/request_summary.py @@ -0,0 +1,56 @@ +"""Request-list totals computed from the already authorized, filtered queryset.""" + +from decimal import Decimal + +from .usage import cpa_event_cost + + +def request_summary(events, config): + result = dict.fromkeys( + [ + "request_count", + "failed_count", + "input_tokens", + "cached_input_tokens", + "output_tokens", + "reasoning_tokens", + "total_tokens", + "unpriced_request_count", + ], + 0, + ) + cost = Decimal(0) + latency_sum = ttft_sum = latency_count = ttft_count = 0 + fields = [ + "model", + "requested_service_tier", + "response_service_tier", + "failed", + "latency_ms", + "ttft_ms", + "input_tokens", + "cached_input_tokens", + "output_tokens", + "reasoning_tokens", + "total_tokens", + ] + for event in events.order_by().only(*fields).iterator(chunk_size=2000): + amount, unknown = cpa_event_cost(event, config) + cost += amount + result["request_count"] += 1 + result["failed_count"] += int(event.failed) + result["unpriced_request_count"] += int(unknown) + for field in fields[-5:]: + result[field] += getattr(event, field) + if event.latency_ms > 0: + latency_sum += event.latency_ms + latency_count += 1 + if event.ttft_ms > 0: + ttft_sum += event.ttft_ms + ttft_count += 1 + result.update( + usage_usd=float(cost), + average_latency_ms=latency_sum / latency_count if latency_count else None, + average_ttft_ms=ttft_sum / ttft_count if ttft_count else None, + ) + return result diff --git a/backend/monitor/engine.py b/backend/monitor/engine.py index b476feb..6b4c6fb 100644 --- a/backend/monitor/engine.py +++ b/backend/monitor/engine.py @@ -156,6 +156,7 @@ def _run_monitor_locked( .filter( pool_id=account.pool_id, participant__enabled=True, + participant__sub2api_user_id__isnull=False, share_percent__gt=ZERO, ) .order_by("-participant__is_owner", "participant_id") diff --git a/backend/monitor/integrations/cpa/client.py b/backend/monitor/integrations/cpa/client.py index b5bf2a3..6cc81c4 100644 --- a/backend/monitor/integrations/cpa/client.py +++ b/backend/monitor/integrations/cpa/client.py @@ -119,7 +119,9 @@ def list_codex_accounts(self) -> list[dict[str, Any]]: accounts.append( { "auth_index": auth_index, - "name": str(item.get("label") or item.get("name") or email or auth_index), + "name": str( + item.get("label") or item.get("name") or email or auth_index + ), "email": email, "chatgpt_account_id": str( claims.get("chatgpt_account_id") @@ -146,7 +148,7 @@ def get_codex_account(self, auth_index: str) -> dict[str, Any]: return account raise CPAError("CPA 中未找到该 Codex 账号") - def query_weekly_window(self, auth_index: str) -> WeeklyWindow: + def query_usage_payload(self, auth_index: str) -> tuple[dict, dict]: account = self.get_codex_account(auth_index) chatgpt_account_id = account["chatgpt_account_id"] if not chatgpt_account_id: @@ -177,6 +179,12 @@ def query_weekly_window(self, auth_index: str) -> WeeklyWindow: raise CPAError("CPA 上游额度接口返回的不是 JSON") from exc if not isinstance(body, dict): raise CPAError("CPA 上游额度响应结构错误") + return account, body + + def query_weekly_window(self, auth_index: str) -> WeeklyWindow: + self.last_usage_body = None + account, body = self.query_usage_payload(auth_index) + self.last_usage_body = body rate_limit = body.get("rate_limit") if not isinstance(rate_limit, dict): raise CPAError("CPA Codex 账号没有可用的 rate_limit 数据") @@ -188,7 +196,9 @@ def query_weekly_window(self, auth_index: str) -> WeeklyWindow: seconds = int(item.get("limit_window_seconds") or 0) candidates.append( WeeklyWindow( - used_percent=_decimal(item.get("used_percent"), f"{slot}.used_percent"), + used_percent=_decimal( + item.get("used_percent"), f"{slot}.used_percent" + ), window_seconds=seconds, reset_after_seconds=int(item.get("reset_after_seconds") or 0), reset_at=int(item.get("reset_at") or 0), @@ -206,6 +216,32 @@ def query_weekly_window(self, auth_index: str) -> WeeklyWindow: raise CPAError("CPA 七天窗口缺少 reset_at") return weekly + def consume_reset_credit(self, auth_index: str, request_id: str) -> None: + account = self.get_codex_account(auth_index) + if not account["chatgpt_account_id"]: + raise CPAError("CPA Codex 账号缺少 ChatGPT Account ID") + result = self._request( + "POST", + "api-call", + json_body={ + "auth_index": auth_index, + "method": "POST", + "url": "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", + "header": { + "Authorization": "Bearer $TOKEN$", + "Chatgpt-Account-Id": account["chatgpt_account_id"], + "Content-Type": "application/json", + }, + "data": json.dumps({"redeem_request_id": request_id}), + }, + ) + status = result.get("status_code", 0) if isinstance(result, dict) else 0 + if not isinstance(status, int) or not 200 <= status < 300: + error = CPAError( + "上游未确认重置成功,请刷新额度核对;重试将复用同一重置标识" + ) + error.definitive_rejection = status in (400, 401, 403, 404, 405, 422) + raise error def test_connection(self) -> dict[str, Any]: accounts = self.list_codex_accounts() diff --git a/backend/monitor/migrations/0052_cpa_participants.py b/backend/monitor/migrations/0052_cpa_participants.py new file mode 100644 index 0000000..3026204 --- /dev/null +++ b/backend/monitor/migrations/0052_cpa_participants.py @@ -0,0 +1,87 @@ +# Generated by Django 5.2.17 on 2026-09-08 01:15 + +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('monitor', '0051_pooled_research_raw_only'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CPAAPIKey', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key_hash', models.CharField(max_length=64, unique=True)), + ('hint', models.CharField(max_length=4)), + ('name', models.CharField(blank=True, max_length=80)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ], + ), + migrations.AddField( + model_name='participantsnapshot', + name='cpa_contract_known', + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name='participant', + name='sub2api_user_id', + field=models.BigIntegerField(blank=True, null=True, unique=True), + ), + migrations.CreateModel( + name='CPAClaimPlan', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('started_at', models.DateTimeField()), + ('ended_at', models.DateTimeField()), + ('source_digest', models.CharField(max_length=64)), + ('preview', models.JSONField(default=dict)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('expires_at', models.DateTimeField()), + ('applied_at', models.DateTimeField(null=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('key', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='monitor.cpaapikey')), + ('participant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='cpa_claim_plans', to='monitor.participant')), + ], + ), + migrations.CreateModel( + name='CPAKeyBinding', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('started_at', models.DateTimeField()), + ('ended_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('key', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='bindings', to='monitor.cpaapikey')), + ('participant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='cpa_bindings', to='monitor.participant')), + ], + options={ + 'ordering': ['started_at', 'id'], + 'indexes': [models.Index(fields=['key', 'started_at', 'ended_at'], name='monitor_cpa_key_id_d216c1_idx')], + 'constraints': [models.UniqueConstraint(condition=models.Q(('ended_at__isnull', True)), fields=('key',), name='one_open_cpa_key_binding'), models.CheckConstraint(condition=models.Q(('ended_at__isnull', True), ('ended_at__gt', models.F('started_at')), _connector='OR'), name='cpa_binding_positive_interval')], + }, + ), + migrations.CreateModel( + name='CPAQuotaContract', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('effective_at', models.DateTimeField()), + ('pool_id_at_capture', models.BigIntegerField()), + ('pool_name', models.CharField(max_length=160)), + ('revision', models.PositiveBigIntegerField()), + ('allocations', models.JSONField(default=list)), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='cpa_contracts', to='monitor.monitoredaccount')), + ], + options={ + 'ordering': ['effective_at', 'id'], + 'indexes': [models.Index(fields=['account', 'effective_at'], name='monitor_cpa_account_71a032_idx')], + }, + ), + ] diff --git a/backend/monitor/migrations/0053_cpa_claim_events.py b/backend/monitor/migrations/0053_cpa_claim_events.py new file mode 100644 index 0000000..9f114ac --- /dev/null +++ b/backend/monitor/migrations/0053_cpa_claim_events.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.17 on 2026-09-08 01:55 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('monitor', '0052_cpa_participants'), + ] + + operations = [ + migrations.AddField( + model_name='cpakeybinding', + name='claim', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='monitor.cpaclaimplan'), + ), + migrations.CreateModel( + name='CPAClaimEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='ownership_claim', to='monitor.cpausageevent')), + ('plan', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='events', to='monitor.cpaclaimplan')), + ], + ), + ] diff --git a/backend/monitor/migrations/0054_cpaclaimplan_account_alter_cpaclaimplan_key_and_more.py b/backend/monitor/migrations/0054_cpaclaimplan_account_alter_cpaclaimplan_key_and_more.py new file mode 100644 index 0000000..a262c17 --- /dev/null +++ b/backend/monitor/migrations/0054_cpaclaimplan_account_alter_cpaclaimplan_key_and_more.py @@ -0,0 +1,121 @@ +# Generated by Django 5.2.17 on 2026-09-08 06:10 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models +from django.utils import timezone + + +def initialize_owner_roles(apps, schema_editor): + Account = apps.get_model("monitor", "MonitoredAccount") + Allocation = apps.get_model("monitor", "PoolParticipant") + Binding = apps.get_model("monitor", "CPAAccountOwnerBinding") + now = timezone.now() + for account in Account.objects.filter(provider="cpa"): + owners = list( + Allocation.objects.filter( + pool_id=account.pool_id, + participant__is_owner=True, + participant__enabled=True, + ).values_list("participant_id", flat=True) + ) + if len(owners) == 1: + Binding.objects.create( + account_id=account.pk, participant_id=owners[0], started_at=now + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("monitor", "0053_cpa_claim_events"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="cpaclaimplan", + name="account", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="monitor.monitoredaccount", + ), + ), + migrations.AlterField( + model_name="cpaclaimplan", + name="key", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="monitor.cpaapikey", + ), + ), + migrations.CreateModel( + name="CPAAccountOwnerBinding", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("started_at", models.DateTimeField()), + ("ended_at", models.DateTimeField(blank=True, null=True)), + ( + "account", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="cpa_owner_bindings", + to="monitor.monitoredaccount", + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "participant", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="cpa_owner_bindings", + to="monitor.participant", + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + condition=models.Q(("ended_at__isnull", True)), + fields=("account",), + name="one_open_cpa_account_owner", + ), + models.CheckConstraint( + condition=models.Q( + ("ended_at__isnull", True), + ("ended_at__gt", models.F("started_at")), + _connector="OR", + ), + name="cpa_owner_positive_interval", + ), + ], + }, + ), + migrations.AddConstraint( + model_name="cpaclaimplan", + constraint=models.CheckConstraint( + condition=( + models.Q(key__isnull=False, account__isnull=True) + | models.Q(key__isnull=True, account__isnull=False) + ), + name="cpa_claim_single_source", + ), + ), + migrations.RunPython(initialize_owner_roles, migrations.RunPython.noop), + ] diff --git a/backend/monitor/migrations/0055_cpaquotaresetrequest.py b/backend/monitor/migrations/0055_cpaquotaresetrequest.py new file mode 100644 index 0000000..68e7719 --- /dev/null +++ b/backend/monitor/migrations/0055_cpaquotaresetrequest.py @@ -0,0 +1,53 @@ +# Generated by Django 5.2.17 on 2026-09-08 06:36 + +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("monitor", "0054_cpaclaimplan_account_alter_cpaclaimplan_key_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="CPAQuotaResetRequest", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("source_account_id", models.CharField(max_length=255)), + ("available_count", models.PositiveIntegerField()), + ("status", models.CharField(default="pending", max_length=16)), + ("created_at", models.DateTimeField(default=django.utils.timezone.now)), + ("expires_at", models.DateTimeField()), + ("finished_at", models.DateTimeField(null=True)), + ( + "account", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="cpa_reset_requests", + to="monitor.monitoredaccount", + ), + ), + ( + "requested_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/backend/monitor/migrations/0056_quotapool_cpa_billing_anchor_and_more.py b/backend/monitor/migrations/0056_quotapool_cpa_billing_anchor_and_more.py new file mode 100644 index 0000000..4fea86b --- /dev/null +++ b/backend/monitor/migrations/0056_quotapool_cpa_billing_anchor_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.17 on 2026-09-08 08:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('monitor', '0055_cpaquotaresetrequest'), + ] + + operations = [ + migrations.AddField( + model_name='quotapool', + name='cpa_billing_anchor', + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name='quotapool', + name='cpa_billing_timezone', + field=models.CharField(default='Asia/Shanghai', max_length=64), + ), + ] diff --git a/backend/monitor/models/__init__.py b/backend/monitor/models/__init__.py index e79dcfb..5100f0b 100644 --- a/backend/monitor/models/__init__.py +++ b/backend/monitor/models/__init__.py @@ -45,6 +45,13 @@ "CPAUsageEvent", "CPAAccountCollectionInterval", "CPACollectorState", + "CPAQuotaResetRequest", + "CPAAccountOwnerBinding", + "CPAAPIKey", + "CPAKeyBinding", + "CPAClaimPlan", + "CPAClaimEvent", + "CPAQuotaContract", "HistoricalRebuildRun", "HistoryMaintenanceState", "MonitoredAccount", @@ -76,3 +83,5 @@ ] from .research import ResearchSettings, ResearchRequestComponents, ResearchEvidenceBatch + +from .cpa_participants import CPAQuotaResetRequest, CPAAccountOwnerBinding, CPAAPIKey, CPAKeyBinding, CPAClaimPlan, CPAClaimEvent, CPAQuotaContract diff --git a/backend/monitor/models/cpa_participants.py b/backend/monitor/models/cpa_participants.py new file mode 100644 index 0000000..5d094cd --- /dev/null +++ b/backend/monitor/models/cpa_participants.py @@ -0,0 +1,153 @@ +"""CPA identities and immutable, time-scoped ownership/contract evidence.""" + +import uuid + +from django.conf import settings +from django.db import models +from django.db.models import F, Q +from django.utils import timezone + + +class CPAAPIKey(models.Model): + key_hash = models.CharField(max_length=64, unique=True) + hint = models.CharField(max_length=4) + name = models.CharField(max_length=80, blank=True) + created_at = models.DateTimeField(default=timezone.now) + + +class CPAKeyBinding(models.Model): + key = models.ForeignKey( + CPAAPIKey, on_delete=models.PROTECT, related_name="bindings" + ) + participant = models.ForeignKey( + "Participant", on_delete=models.PROTECT, related_name="cpa_bindings" + ) + started_at = models.DateTimeField() + ended_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(default=timezone.now) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL + ) + claim = models.OneToOneField( + "CPAClaimPlan", null=True, blank=True, on_delete=models.PROTECT + ) + + class Meta: + ordering = ["started_at", "id"] + constraints = [ + models.UniqueConstraint( + fields=["key"], + condition=Q(ended_at__isnull=True), + name="one_open_cpa_key_binding", + ), + models.CheckConstraint( + condition=Q(ended_at__isnull=True) | Q(ended_at__gt=F("started_at")), + name="cpa_binding_positive_interval", + ), + ] + indexes = [models.Index(fields=["key", "started_at", "ended_at"])] + + +class CPAClaimPlan(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + key = models.ForeignKey(CPAAPIKey, null=True, on_delete=models.PROTECT) + account = models.ForeignKey("MonitoredAccount", null=True, on_delete=models.PROTECT) + participant = models.ForeignKey( + "Participant", on_delete=models.PROTECT, related_name="cpa_claim_plans" + ) + started_at = models.DateTimeField() + ended_at = models.DateTimeField() + source_digest = models.CharField(max_length=64) + preview = models.JSONField(default=dict) + created_at = models.DateTimeField(default=timezone.now) + expires_at = models.DateTimeField() + applied_at = models.DateTimeField(null=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL + ) + + class Meta: + constraints = [ + models.CheckConstraint( + condition=( + Q(key__isnull=False, account__isnull=True) + | Q(key__isnull=True, account__isnull=False) + ), + name="cpa_claim_single_source", + ) + ] + + +class CPAQuotaContract(models.Model): + """Frozen per-account pool policy; never backdate today's allocations.""" + + account = models.ForeignKey( + "MonitoredAccount", on_delete=models.PROTECT, related_name="cpa_contracts" + ) + effective_at = models.DateTimeField() + pool_id_at_capture = models.BigIntegerField() + pool_name = models.CharField(max_length=160) + revision = models.PositiveBigIntegerField() + allocations = models.JSONField(default=list) + + class Meta: + ordering = ["effective_at", "id"] + indexes = [models.Index(fields=["account", "effective_at"])] + + +class CPAClaimEvent(models.Model): + """A historical claim owns only the immutable events confirmed in its preview.""" + + plan = models.ForeignKey( + CPAClaimPlan, on_delete=models.PROTECT, related_name="events" + ) + event = models.OneToOneField( + "CPAUsageEvent", on_delete=models.PROTECT, related_name="ownership_claim" + ) + + +class CPAAccountOwnerBinding(models.Model): + """Prospective fallback ownership. Explicit key and historical claims win.""" + + account = models.ForeignKey( + "MonitoredAccount", on_delete=models.PROTECT, related_name="cpa_owner_bindings" + ) + participant = models.ForeignKey( + "Participant", on_delete=models.PROTECT, related_name="cpa_owner_bindings" + ) + started_at = models.DateTimeField() + ended_at = models.DateTimeField(null=True, blank=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL + ) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["account"], + condition=Q(ended_at__isnull=True), + name="one_open_cpa_account_owner", + ), + models.CheckConstraint( + condition=Q(ended_at__isnull=True) | Q(ended_at__gt=F("started_at")), + name="cpa_owner_positive_interval", + ), + ] + + +class CPAQuotaResetRequest(models.Model): + """One confirmed upstream redemption, retried with the same idempotency key.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + account = models.ForeignKey( + "MonitoredAccount", on_delete=models.PROTECT, related_name="cpa_reset_requests" + ) + requested_by = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL + ) + source_account_id = models.CharField(max_length=255) + available_count = models.PositiveIntegerField() + status = models.CharField(max_length=16, default="pending") + created_at = models.DateTimeField(default=timezone.now) + expires_at = models.DateTimeField() + finished_at = models.DateTimeField(null=True) diff --git a/backend/monitor/models/observations.py b/backend/monitor/models/observations.py index 3becfa6..281eefd 100644 --- a/backend/monitor/models/observations.py +++ b/backend/monitor/models/observations.py @@ -202,6 +202,7 @@ class ParticipantSnapshot(models.Model): observation = models.ForeignKey(Observation, on_delete=models.CASCADE, related_name="participant_snapshots") participant = models.ForeignKey(Participant, on_delete=models.CASCADE, related_name="snapshots") + cpa_contract_known = models.BooleanField(default=True) # Identity at collection time; participant bindings may change later. source_sub2api_user_id = models.BigIntegerField(null=True, blank=True) share_percent = models.DecimalField( diff --git a/backend/monitor/models/participants.py b/backend/monitor/models/participants.py index 8463f11..199b649 100644 --- a/backend/monitor/models/participants.py +++ b/backend/monitor/models/participants.py @@ -12,6 +12,8 @@ class QuotaPool(models.Model): name = models.CharField(max_length=160) contract_revision = models.PositiveBigIntegerField(default=1) + cpa_billing_anchor = models.DateField(null=True, blank=True) + cpa_billing_timezone = models.CharField(max_length=64, default="Asia/Shanghai") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -51,11 +53,11 @@ def __str__(self) -> str: class Participant(models.Model): - """一个 Sub2API 用户;余额与身份属于整个 Sub2API 渠道。""" + """一个拼车参与者,可绑定 Sub2API 用户和多个 CPA Key。""" name = models.CharField(max_length=80) email = models.EmailField(blank=True) - sub2api_user_id = models.BigIntegerField(unique=True) + sub2api_user_id = models.BigIntegerField(unique=True, null=True, blank=True) # Sub2API 用户名随参与者关系一起缓存,避免首页为了显示名称额外访问 Admin API。 sub2api_username = models.CharField(max_length=150, blank=True) # 邮箱与用户名来自同一次 Admin 用户列表读取;用户名为空时用邮箱展示账号身份。 diff --git a/backend/monitor/openapi/accounts.py b/backend/monitor/openapi/accounts.py index a934d5d..41f58be 100644 --- a/backend/monitor/openapi/accounts.py +++ b/backend/monitor/openapi/accounts.py @@ -3,6 +3,7 @@ from __future__ import annotations from .common import _nullable +from .cpa_status import cpa_status_schema def account_schemas(nullable_number: dict, nullable_string: dict) -> dict: @@ -94,6 +95,7 @@ def account_schemas(nullable_number: dict, nullable_string: dict) -> dict: "needs_manual_update_count", ], "properties": { + "cpa_summary": {"oneOf": [{"$ref": "#/components/schemas/CPAPoolSummary"}, {"type": "null"}]}, "configured": {"type": "boolean"}, "monitoring_enabled": {"type": "boolean"}, "accounts": { @@ -229,6 +231,7 @@ def account_schemas(nullable_number: dict, nullable_string: dict) -> dict: "warnings", ], "properties": { + "cpa_quota": cpa_status_schema(), "id": {"type": "integer"}, "provider": { "type": "string", diff --git a/backend/monitor/openapi/cpa.py b/backend/monitor/openapi/cpa.py new file mode 100644 index 0000000..5c06fb5 --- /dev/null +++ b/backend/monitor/openapi/cpa.py @@ -0,0 +1,392 @@ +"""CPA carpool read API schemas and query contract.""" + +from .common import _account_id_parameter, _success_response + + +def _object(properties): + return {"type": "object", "properties": properties, "required": list(properties)} + + +def _array(schema): + return {"type": "array", "items": schema} + + +def capacity_estimate_schema(): + number = {"type": "number"} + nullable_number = {"type": ["number", "null"]} + boolean = {"type": "boolean"} + time = {"type": "string", "format": "date-time"} + return { + "oneOf": [ + {"type": "null"}, + _object( + { + "source": { + "type": "string", + "enum": ["particle_filter", "quota_model"], + }, + "capacity_usd": number, + "lower_usd": nullable_number, + "upper_usd": nullable_number, + "prior_only": boolean, + "as_of": time, + } + ), + ] + } + + +def cpa_schemas(): + number = {"type": "number"} + integer = {"type": "integer"} + text = {"type": "string"} + boolean = {"type": "boolean"} + nullable_number = {"type": ["number", "null"]} + nullable_time = {"type": ["string", "null"], "format": "date-time"} + time = {"type": "string", "format": "date-time"} + totals = { + "usage_usd": number, + "request_count": integer, + "token_count": integer, + "unpriced_request_count": integer, + } + coverage = _object( + { + "complete": boolean, + "uncertain_end": boolean, + "gaps": _array(_object({"started_at": time, "ended_at": time})), + } + ) + breakdown = _object( + { + "account_id": integer, + "quota_available": boolean, + "quota_unavailable_reasons": _array(text), + "quota_as_of": nullable_time, + "charged_percent": nullable_number, + "remaining_share_percent": nullable_number, + "usage_usd": number, + "estimated_capacity_usd": nullable_number, + "expected_entitlement_usd": nullable_number, + "consumed_entitlement_usd": nullable_number, + "remaining_entitlement_usd": nullable_number, + } + ) + capacity_estimate = capacity_estimate_schema() + member_billing = _object( + { + "participant_id": integer, + "usage_usd": number, + **{ + key: nullable_number + for key in ( + "usage_percent", + "entitlement_usd", + "remaining_usd", + "recommended_usd", + "recommended_percent", + "completed_overuse_usd", + ) + }, + "projected_overuse": {"type": ["boolean", "null"]}, + } + ) + cycle = _object( + { + "account_id": integer, + "account_name": text, + "started_at": time, + "ended_at": time, + "kind": {"type": "string", "enum": ["historical", "current", "future"]}, + "capacity_usd": nullable_number, + "full_capacity_usd": nullable_number, + "capacity_estimate": capacity_estimate, + "expired_usd": nullable_number, + "quota_as_of": nullable_time, + "reasons": _array(text), + } + ) + billing = _object( + { + "configured": boolean, + "anchor_date": {"type": ["string", "null"], "format": "date"}, + "timezone": text, + "started_at": nullable_time, + "ended_at": nullable_time, + "generated_at": time, + **{ + key: nullable_number + for key in ( + "capacity_usd", + "actual_capacity_usd", + "future_capacity_usd", + "expired_usd", + "available_usd", + "unallocated_usd", + ) + }, + **{ + key: number + for key in ("usage_usd", "unattributed_usd", "other_members_usd") + }, + "reasons": _array(text), + "cycles": _array(cycle), + "members": _array(member_billing), + } + ) + weekly = _object( + { + "capacity_estimate": capacity_estimate, + "coverage_complete": boolean, + "account_id": integer, + "account_name": text, + "started_at": time, + "resets_at": nullable_time, + "quota_as_of": nullable_time, + "requests_as_of": nullable_time, + **{ + key: nullable_number + for key in ( + "capacity_usd", + "remaining_usd", + "upstream_remaining_percent", + ) + }, + **{ + key: number + for key in ("usage_usd", "unattributed_usd", "other_members_usd") + }, + "unpriced_request_count": integer, + "members": _array( + _object( + { + "participant_id": integer, + "usage_usd": number, + "usage_percent": nullable_number, + } + ) + ), + } + ) + return { + "CPAPoolSummary": _object( + { + "billing_summary": billing, + "weekly_distribution": _array(weekly), + "pool_id": integer, + "pool_name": text, + "selected_account_id": integer, + "partial_scope": boolean, + "cost_estimate": boolean, + "enforcement_enabled": boolean, + "generated_at": time, + "accounts": _array( + _object( + { + **totals, + "account_id": integer, + "account_name": text, + "owner": _object( + { + "participant_id": {"type": ["integer", "null"]}, + "participant_name": {"type": ["string", "null"]}, + "started_at": nullable_time, + "status": { + "type": "string", + "enum": ["active", "missing", "ambiguous"], + }, + } + ), + "selected": boolean, + "upstream_used_percent": nullable_number, + "quota_as_of": nullable_time, + "requests_as_of": nullable_time, + "cycle_started_at": time, + "resets_at": nullable_time, + "coverage": coverage, + "quota_available": boolean, + "quota_unavailable_reasons": _array(text), + } + ) + ), + "members": _array( + _object( + { + **totals, + "participant_id": integer, + "participant_name": text, + "is_self": boolean, + "is_owner": boolean, + "share_percent": nullable_number, + "quota_available": boolean, + "is_overused": boolean, + "expected_entitlement_usd": nullable_number, + "consumed_entitlement_usd": nullable_number, + "remaining_entitlement_usd": nullable_number, + "account_breakdowns": _array(breakdown), + } + ) + ), + "unattributed": _object(totals), + "collector": { + "type": "object", + "description": "采集器状态;普通用户不返回 last_error。", + }, + } + ), + "CPARequest": _object( + { + "id": integer, + "occurred_at": time, + "request_id": text, + "api_key_hint": text, + "api_key_alias": { + "type": "string", + "description": "请求携带的 CPA Key alias;缺失时使用已绑定 Key 的本地备注,无别名则为空。仅随授权请求返回。", + }, + "model": text, + "endpoint": text, + "input_tokens": integer, + "cached_input_tokens": integer, + "output_tokens": integer, + "reasoning_tokens": integer, + "total_tokens": integer, + "failed": boolean, + "latency_ms": integer, + "ttft_ms": integer, + "usage_usd": number, + "unpriced": boolean, + "requested_service_tier": text, + "response_service_tier": text, + } + ), + "CPARequests": _object( + { + "account_id": integer, + "items": _array({"$ref": "#/components/schemas/CPARequest"}), + "total": integer, + "started_at": time, + "ended_at": time, + "summary": { + "oneOf": [ + _object( + { + **{ + name: integer + for name in ( + "request_count", + "failed_count", + "input_tokens", + "cached_input_tokens", + "output_tokens", + "reasoning_tokens", + "total_tokens", + "unpriced_request_count", + ) + }, + "usage_usd": number, + "average_latency_ms": {"type": ["number", "null"]}, + "average_ttft_ms": {"type": ["number", "null"]}, + } + ), + {"type": "null"}, + ], + "description": "include_summary=true 时返回整个授权筛选范围的汇总,不受分页限制;缺失耗时不计入均值。", + }, + "page": integer, + "page_size": integer, + "cost_estimate": boolean, + "generated_at": time, + "keys": _array(_object({"id": integer, "name": text, "hint": text})), + "models": _array(text), + } + ), + } + + +def cpa_paths(): + def parameter(name, schema): + return {"name": name, "in": "query", "required": False, "schema": schema} + + result = {} + for suffix, schema, label in ( + ("summary", "CPAPoolSummary", "读取 CPA 同车成员汇总"), + ("requests", "CPARequests", "分页读取本人 CPA 请求"), + ): + params = [_account_id_parameter()] + params[0]["required"] = True + if suffix == "requests": + params += [ + parameter(name, spec) + for name, spec in ( + ("participant_id", {"type": "integer", "minimum": 1}), + ("key_id", {"type": "integer", "minimum": 1}), + ("model", {"type": "string"}), + ("include_summary", {"type": "boolean", "default": False}), + ( + "days", + {"type": "integer", "minimum": 1, "maximum": 90, "default": 7}, + ), + ("failed", {"type": "boolean"}), + ("started_at", {"type": "string", "format": "date-time"}), + ("ended_at", {"type": "string", "format": "date-time"}), + ("page", {"type": "integer", "minimum": 1, "default": 1}), + ( + "page_size", + { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50, + }, + ), + ) + ] + result[f"/v1/cpa/{suffix}"] = { + "get": { + "summary": label, + "operationId": f"get{schema}", + "description": "按账号授权与 CPA 池成员关系限制访问。请求和 Key 分项仅本人授权参与者及管理员可读;时间范围左闭右开,默认最近 7 天,最多 90 天。", + "parameters": params, + "responses": { + "200": _success_response( + label, {"$ref": f"#/components/schemas/{schema}"} + ), + **{ + str(code): {"$ref": f"#/components/responses/{name}"} + for code, name in ((400, "BadRequest"), (401, "Unauthorized")) + }, + }, + } + } + result["/cpa/billing-config"] = { + "put": { + "summary": "管理员配置 CPA 池订阅账期", + "operationId": "configureCPABilling", + "description": "仅系统管理员。按起始日和 IANA 时区逐月计算;空日期取消配置,不改写请求、合同或余额。只读 API 不提供写入。", + "parameters": [{**_account_id_parameter(), "required": True}], + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": _object( + { + "anchor_date": { + "type": ["string", "null"], + "format": "date", + }, + "timezone": {"type": "string"}, + } + ) + } + }, + }, + "responses": { + "200": _success_response( + "配置后的汇总", {"$ref": "#/components/schemas/CPAPoolSummary"} + ), + "403": {"description": "仅管理员可修改"}, + }, + } + } + return result diff --git a/backend/monitor/openapi/cpa_status.py b/backend/monitor/openapi/cpa_status.py new file mode 100644 index 0000000..218836a --- /dev/null +++ b/backend/monitor/openapi/cpa_status.py @@ -0,0 +1,69 @@ +"""CPA account-window presentation, scoped summary metadata, and reset history.""" + +from .cpa import _object, _array, capacity_estimate_schema + + +def cpa_status_schema(): + number = {"type": "number"} + nullable_number = {"type": ["number", "null"]} + text = {"type": "string"} + nullable_text = {"type": ["string", "null"]} + boolean = {"type": "boolean"} + metrics = { + "type": "object", + "properties": { + "request_count": number, + "token_count": number, + "usage_usd": number, + "success_rate": nullable_number, + "unpriced_request_count": {"type": "integer"}, + }, + } + period = _object( + { + "started_at": text, + "ended_at": text, + "metrics": metrics, + "coverage_complete": boolean, + } + ) + optional = lambda schema: {"oneOf": [schema, {"type": "null"}]} + return _object( + { + "totals": period, + "windows": _array( + _object( + { + "id": text, + "label": text, + "used_percent": nullable_number, + "reset_at": nullable_text, + "updated_at": nullable_text, + "source": text, + "boundary": {"type": "string", "enum": ["unknown", "provider"]}, + "current": optional(period), + "previous": optional(period), + "prediction": optional(metrics), + "capacity_estimate": capacity_estimate_schema(), + "notice": nullable_text, + } + ) + ), + "reset": _object( + { + "available_count": {"type": ["integer", "null"]}, + "can_reset": boolean, + "history": _array( + _object( + { + "id": text, + "status": text, + "created_at": text, + "finished_at": nullable_text, + } + ) + ), + } + ), + } + ) diff --git a/backend/monitor/openapi/document.py b/backend/monitor/openapi/document.py index b7e1efc..dc02a99 100644 --- a/backend/monitor/openapi/document.py +++ b/backend/monitor/openapi/document.py @@ -2,6 +2,7 @@ from __future__ import annotations +from .cpa import cpa_paths, cpa_schemas from .corrections import with_correction_schemas from .accounts import account_schemas from .common import _nullable, common_schemas, error_responses, security_schemes @@ -18,7 +19,7 @@ def openapi_document( ) -> dict: nullable_number = _nullable("number") nullable_string = _nullable("string") - paths = openapi_paths() + paths = {**openapi_paths(), **cpa_paths()} if endpoint_paths is not None: visible_paths = { "/v1", @@ -54,6 +55,7 @@ def openapi_document( "responses": error_responses(), "schemas": with_correction_schemas({ **common_schemas(), + **cpa_schemas(), **account_schemas(nullable_number, nullable_string), **participant_schemas(nullable_number, nullable_string), **observation_schemas(nullable_number, nullable_string), diff --git a/backend/monitor/openapi/observations.py b/backend/monitor/openapi/observations.py index 22b273d..ac62aae 100644 --- a/backend/monitor/openapi/observations.py +++ b/backend/monitor/openapi/observations.py @@ -381,6 +381,7 @@ def observation_schemas(nullable_number: dict, nullable_string: dict) -> dict: "range_min_usd": {"type": "number"}, "range_max_usd": {"type": "number"}, "range_stage": {"type": "integer"}, + "range_inherited": {"type": "boolean"}, "range_direction": { "type": ["string", "null"], "enum": ["upper", "lower", None], diff --git a/backend/monitor/openapi/participants.py b/backend/monitor/openapi/participants.py index 5626b4b..41e7761 100644 --- a/backend/monitor/openapi/participants.py +++ b/backend/monitor/openapi/participants.py @@ -46,7 +46,7 @@ def participant_schemas(nullable_number: dict, nullable_string: dict) -> dict: "id": {"type": "integer"}, "name": {"type": "string"}, "email": {"type": "string"}, - "sub2api_user_id": {"type": "integer"}, + "sub2api_user_id": {"type": ["integer", "null"]}, "sub2api_username": {"type": "string"}, "sub2api_email": {"type": "string"}, "sub2api_identity": {"type": "string"}, @@ -80,6 +80,7 @@ def participant_schemas(nullable_number: dict, nullable_string: dict) -> dict: "ParticipantSnapshot": { "type": "object", "required": [ + "cpa_contract_known", "participant_id", "participant_name", "quota_pool_id", @@ -110,6 +111,7 @@ def participant_schemas(nullable_number: dict, nullable_string: dict) -> dict: "allocation_model", ], "properties": { + "cpa_contract_known": {"type": "boolean", "description": "CPA 观测是否存在当时生效的份额依据;无依据时剩余份额未知。"}, "participant_id": {"type": "integer"}, "participant_name": {"type": "string"}, "quota_pool_id": { @@ -126,7 +128,7 @@ def participant_schemas(nullable_number: dict, nullable_string: dict) -> dict: "charged_cycle_percent": {"type": "number"}, "charged_percent_lower": nullable_number, "charged_percent_upper": nullable_number, - "remaining_share_percent": {"type": "number"}, + "remaining_share_percent": nullable_number, "current_balance_usd": nullable_number, "recommended_balance_usd": nullable_number, "recommended_balance_min_usd": nullable_number, @@ -320,7 +322,7 @@ def participant_schemas(nullable_number: dict, nullable_string: dict) -> dict: "properties": { "operation_id": {"type": "string", "format": "uuid"}, "participant_id": {"type": "integer"}, - "sub2api_user_id": {"type": "integer"}, + "sub2api_user_id": {"type": ["integer", "null"]}, "applied_balance_usd": {"type": "number"}, "account_count": {"type": "integer"}, }, diff --git a/backend/monitor/openapi/statistics.py b/backend/monitor/openapi/statistics.py index 2d0caa5..7a4a5fc 100644 --- a/backend/monitor/openapi/statistics.py +++ b/backend/monitor/openapi/statistics.py @@ -130,6 +130,7 @@ def statistics_schemas(nullable_number: dict, nullable_string: dict) -> dict: "cpa_api_key_series", ], "properties": { + "cpa_summary": {"oneOf": [{"$ref": "#/components/schemas/CPAPoolSummary"}, {"type": "null"}]}, "account": { "$ref": "#/components/schemas/MonitoredAccountSummary" }, diff --git a/backend/monitor/particle_trajectory.py b/backend/monitor/particle_trajectory.py index c35f3ee..5382339 100644 --- a/backend/monitor/particle_trajectory.py +++ b/backend/monitor/particle_trajectory.py @@ -323,6 +323,9 @@ def particle_trajectory_data( 2, ), "range_stage": int(adaptive.stage[row]), + "range_inherited": adaptive.initial_range_usd != ( + range_profile.capacity_min_usd, range_profile.capacity_max_usd, + ), "range_direction": ( adaptive.direction if adaptive.stage[row] > 0 else None ), diff --git a/backend/monitor/reporting/recommendations.py b/backend/monitor/reporting/recommendations.py index d54cb85..29b16ca 100644 --- a/backend/monitor/reporting/recommendations.py +++ b/backend/monitor/reporting/recommendations.py @@ -84,7 +84,11 @@ def snapshot_data(snapshot: ParticipantSnapshot) -> dict: charged_lower=snapshot.charged_percent_lower, charged_upper=snapshot.charged_percent_upper, ) + cpa_unknown = snapshot.observation.account_id < 0 and not snapshot.cpa_contract_known + if cpa_unknown: + overuse = {"is_overused": False, "overused_percent": ZERO, "overused_percent_min": ZERO, "overused_percent_max": ZERO} return { + "cpa_contract_known": snapshot.cpa_contract_known, "participant_id": snapshot.participant_id, "participant_name": ( snapshot.participant.name if hasattr(snapshot, "participant") else "" @@ -99,7 +103,7 @@ def snapshot_data(snapshot: ParticipantSnapshot) -> dict: ), "charged_delta_percent": float(snapshot.charged_delta_percent), "charged_cycle_percent": float(snapshot.charged_cycle_percent), - "remaining_share_percent": float(snapshot.remaining_share_percent), + "remaining_share_percent": None if cpa_unknown else float(snapshot.remaining_share_percent), "current_balance_usd": ( float(snapshot.current_balance_usd) if snapshot.current_balance_usd is not None @@ -166,11 +170,11 @@ def latest_snapshot( "participant", ).filter( observation__excluded_at__isnull=True, - source_sub2api_user_id=participant.sub2api_user_id, + **({} if account is not None and account.provider == "cpa" else {"source_sub2api_user_id": participant.sub2api_user_id, "observation__account_id__gt": 0}), ) if account is not None: snapshots = snapshots.filter( - observation__account_id=account.external_account_id + observation__account_id=account.fact_key ) return snapshots.order_by("-observation__observed_at", "-id").first() @@ -357,11 +361,12 @@ def _display_snapshot_data( snapshot: ParticipantSnapshot, config: AppSettings, ) -> dict: - if config.weekly_quota_model != "constant_average": + if snapshot.observation.account_id < 0 or config.weekly_quota_model != "constant_average": return snapshot_data(snapshot) values = _constant_average_values(snapshot, config) return { + "cpa_contract_known": snapshot.cpa_contract_known, "participant_id": snapshot.participant_id, "participant_name": snapshot.participant.name, "selected_cost": float(values["selected_cost"]), @@ -570,6 +575,7 @@ def _pooled_safety_factor( enabled=True, pool_allocations__share_percent__gt=ZERO, pool_allocations__pool__accounts__enabled=True, + pool_allocations__pool__accounts__provider="sub2api", ) .distinct() .order_by("id") @@ -582,13 +588,14 @@ def _pooled_safety_factor( candidate.pool_allocations.filter( share_percent__gt=ZERO, pool__accounts__enabled=True, + pool__accounts__provider="sub2api", ) .distinct() .values_list("pool_id", "share_percent") ) candidate_accounts = list( MonitoredAccount.objects.select_related("pool") - .filter(enabled=True, pool_id__in=candidate_allocations) + .filter(enabled=True, provider="sub2api", pool_id__in=candidate_allocations) .order_by("id") ) net = ZERO @@ -702,12 +709,15 @@ def aggregate_recommendation( config: AppSettings, ) -> tuple[dict | None, list[ParticipantSnapshot]]: """Sum the participant's current pool contracts into one global balance.""" + if participant.sub2api_user_id is None: + return None, [] allocations = list( PoolParticipant.objects.select_related("pool") .filter( participant=participant, share_percent__gt=ZERO, pool__accounts__enabled=True, + pool__accounts__provider="sub2api", ) .distinct() .order_by("pool__name", "pool_id") @@ -720,6 +730,7 @@ def aggregate_recommendation( .filter( enabled=True, pool_id__in=allocation_by_pool_id, + provider="sub2api", ) .order_by("pool__name", "pool_id", "name", "external_account_id") ) @@ -995,7 +1006,7 @@ def participant_data( allocation.pool_id: allocation for allocation in allocations } accounts = list( - MonitoredAccount.objects.select_related("pool").order_by( + MonitoredAccount.objects.filter(provider="sub2api").select_related("pool").order_by( "pool__name", "pool_id", "name", @@ -1022,7 +1033,7 @@ def participant_data( "sub2api_identity": ( participant.sub2api_username or participant.sub2api_email - or f"账号 {participant.sub2api_user_id}" + or (f"账号 {participant.sub2api_user_id}" if participant.sub2api_user_id is not None else "仅 CPA") ), "pool_allocations": [ { diff --git a/backend/monitor/reporting/usage.py b/backend/monitor/reporting/usage.py index 3778333..7982936 100644 --- a/backend/monitor/reporting/usage.py +++ b/backend/monitor/reporting/usage.py @@ -26,7 +26,7 @@ def participant_usage_series( usage_precision: str, ) -> list[dict]: """按调用者可见范围和请求粒度生成参与者用量序列。""" - participants = Participant.objects.all() + participants = Participant.objects.filter(sub2api_user_id__isnull=False) if not user.is_staff: participants = participants.filter(authorized_users=user) @@ -87,12 +87,16 @@ def cpa_api_key_usage_series( now: datetime, usage_days: int, usage_precision: str, + user=None, ) -> list[dict]: events = CPAUsageEvent.objects.filter( account=account, occurred_at__gte=now - timedelta(days=usage_days), occurred_at__lte=now, ).order_by("occurred_at", "id") + if user is not None and not user.is_staff: + from ..cpa.participants import ownership_filter + events = events.filter(ownership_filter(user.quota_participants.values_list("id", flat=True))) series: dict[str, dict] = {} for event in events: key = event.api_key_hash or "unattributed" diff --git a/backend/monitor/serializers/participants.py b/backend/monitor/serializers/participants.py index 3450309..2d9908c 100644 --- a/backend/monitor/serializers/participants.py +++ b/backend/monitor/serializers/participants.py @@ -37,9 +37,7 @@ class Meta: def _account_external_ids() -> list[int]: return [ account.fact_key - for account in MonitoredAccount.objects.filter( - provider="sub2api" - ).order_by("external_account_id") + for account in MonitoredAccount.objects.all().order_by("id") ] @staticmethod @@ -48,6 +46,8 @@ def _validate_user_identity( sub2api_user_id: int, instance_id: int | None, ) -> None: + if sub2api_user_id is None: + return duplicate = Participant.objects.select_for_update().filter( sub2api_user_id=sub2api_user_id, ) @@ -60,6 +60,8 @@ def _validate_user_identity( @staticmethod def _ensure_account_usage_rows(participant: Participant) -> None: + if participant.sub2api_user_id is None: + return existing = set( participant.account_memberships.values_list("account_id", flat=True) ) @@ -79,7 +81,7 @@ def _ensure_account_usage_rows(participant: Participant) -> None: def create(self, validated_data): with fenced_fact_write(self._account_external_ids()): self._validate_user_identity( - sub2api_user_id=validated_data["sub2api_user_id"], + sub2api_user_id=validated_data.get("sub2api_user_id"), instance_id=None, ) participant = Participant.objects.create(**validated_data) @@ -101,6 +103,10 @@ def update(self, instance, validated_data): setattr(current, field, value) current.save() self._ensure_account_usage_rows(current) + if "is_owner" in validated_data or "enabled" in validated_data: + from ..cpa.account_owner import sync_account_owner + for account in MonitoredAccount.objects.filter(provider="cpa"): + sync_account_owner(account) return current @@ -132,21 +138,23 @@ class QuotaPoolWriteSerializer(serializers.Serializer): class QuotaAllocationWriteSerializer(serializers.Serializer): """Atomically replace the complete account partition and pool contracts.""" + provider = serializers.ChoiceField(choices=("sub2api", "cpa"), default="sub2api") pools = QuotaPoolWriteSerializer(many=True) def validate(self, attrs): pools = attrs["pools"] configured_account_ids = set( - MonitoredAccount.objects.filter(provider="sub2api").values_list( + MonitoredAccount.objects.filter(provider=attrs["provider"]).values_list( "id", flat=True, ) ) - configured_participant_ids = set( - Participant.objects.values_list("id", flat=True) - ) + participants = Participant.objects.all() + if attrs["provider"] == "sub2api": + participants = participants.filter(sub2api_user_id__isnull=False) + configured_participant_ids = set(participants.values_list("id", flat=True)) existing_pool_ids = set( - QuotaPool.objects.filter(accounts__provider="sub2api") + QuotaPool.objects.filter(accounts__provider=attrs["provider"]) .distinct() .values_list("id", flat=True) ) @@ -254,7 +262,7 @@ def _generated_name( def apply(self) -> list[QuotaPool]: accounts = list( MonitoredAccount.objects.select_for_update() - .filter(provider="sub2api") + .filter(provider=self.validated_data["provider"]) .select_related("pool") .order_by("id") ) @@ -262,7 +270,7 @@ def apply(self) -> list[QuotaPool]: existing_pools = { pool.id: pool for pool in QuotaPool.objects.select_for_update() - .filter(accounts__provider="sub2api") + .filter(accounts__provider=self.validated_data["provider"]) .prefetch_related("allocations") .distinct() .order_by("id") @@ -368,4 +376,10 @@ def apply(self) -> list[QuotaPool]: QuotaPool.objects.exclude(id__in=retained_pool_ids).filter( accounts__isnull=True ).delete() + if self.validated_data["provider"] == "cpa": + from django.utils import timezone + from ..cpa.participants import record_contract + now = timezone.now() + for account in MonitoredAccount.objects.filter(pk__in=accounts_by_id).select_related("pool"): + record_contract(account, now) return [pool for pool, _spec, _changed in applied] diff --git a/backend/monitor/tests/accounting/test_contracts.py b/backend/monitor/tests/accounting/test_contracts.py index 44ea7a4..61eb6be 100644 --- a/backend/monitor/tests/accounting/test_contracts.py +++ b/backend/monitor/tests/accounting/test_contracts.py @@ -113,8 +113,8 @@ def derived_state(): for observation in (first, second): observation.refresh_from_db() snapshots = list(observation.participant_snapshots.all()) - assert observation.raw_window["rate_method"] == "particle_filter_v9" - assert observation.model_diagnostics["algorithm"] == "particle_filter_v9" + assert observation.raw_window["rate_method"] == "particle_filter_v10" + assert observation.model_diagnostics["algorithm"] == "particle_filter_v10" assert observation.capacity_lower_usd <= ( observation.effective_usd_per_percent * Decimal("100") ) diff --git a/backend/monitor/tests/accounting/test_reconnect_capacity.py b/backend/monitor/tests/accounting/test_reconnect_capacity.py new file mode 100644 index 0000000..93d12df --- /dev/null +++ b/backend/monitor/tests/accounting/test_reconnect_capacity.py @@ -0,0 +1,65 @@ +"""Reconnect baselines must not act as new capacity evidence.""" + +from dataclasses import replace + +import numpy as np +import pytest + +from monitor.accounting.adaptive_range import run_adaptive_range_filter +from monitor.accounting.dynamic_contracts import DynamicModelInput +from monitor.accounting.particle_filter import ParticleFilterConfig + + +def sample(baseline, costs=(0., 0.), progress=(0., 0.)): + return DynamicModelInput( + times_hours=np.arange(len(costs), dtype=float) / 60, + costs_usd=np.asarray(costs)[:, None], + displayed_percent=np.asarray(progress) + baseline, + rights_percent=np.asarray([100.]), + baseline_display_percent=baseline, + baseline_exact_zero=baseline == 0, + ) + + +@pytest.mark.parametrize("baseline", [0., 18., 75.]) +@pytest.mark.parametrize("prior", [828.41, 1800., 8000.]) +def test_no_spend_does_not_create_expansion_evidence_or_clip_prior(baseline, prior): + output = run_adaptive_range_filter( + sample(baseline), + seed=42, + config=ParticleFilterConfig(initial_capacity_usd=prior), + ) + assert not output.promotions + assert output.particle.capacity_hat_usd[-1] == pytest.approx(prior, abs=45) + assert output.capacity_min_usd[-1] < prior < output.capacity_max_usd[-1] + + +@pytest.mark.parametrize("baseline", [18., 60.]) +def test_nonzero_baseline_does_not_invent_lower_expansion_for_in_range_capacity(baseline): + output = run_adaptive_range_filter( + sample(baseline, (0., 180., 360.), (0., 10., 20.)), + seed=17, + config=ParticleFilterConfig(initial_capacity_usd=1800.), + ) + assert not output.promotions + assert output.particle.capacity_hat_usd[-1] == pytest.approx(1800., abs=200) + + +@pytest.mark.parametrize("baseline", [0., 18.]) +@pytest.mark.parametrize( + "costs,direction", + [((0., 20., 40., 60.), "lower"), ((0., 800., 1600., 2400.), "upper")], +) +def test_true_range_mismatch_still_expands_after_reconnect(baseline, costs, direction): + model = replace( + sample(baseline, costs, (0., 10., 20., 30.)), + times_hours=np.asarray([0., 12., 24., 36.]), + ) + output = run_adaptive_range_filter(model, seed=91 if direction == "lower" else 81) + assert output.direction == direction + assert output.promotions + assert all(p.row > 0 for p in output.promotions) + if direction == "lower": + assert output.capacity_min_usd[-1] < 1400 + else: + assert output.capacity_max_usd[-1] > 4000 diff --git a/backend/monitor/tests/api/test_cpa_account_owner.py b/backend/monitor/tests/api/test_cpa_account_owner.py new file mode 100644 index 0000000..7d69b82 --- /dev/null +++ b/backend/monitor/tests/api/test_cpa_account_owner.py @@ -0,0 +1,231 @@ +from datetime import timedelta +from unittest.mock import patch + +import pytest +from django.test import Client +from django.utils import timezone +from rest_framework.exceptions import ValidationError + +from monitor.api_auth import hash_api_key +from monitor.cpa.account_owner import ( + sync_account_owner, + preview_unassigned_claim, + account_owner_data, +) +from monitor.cpa.participants import ( + apply_claim, + event_owner, + owner_index, + ownership_filter, +) +from monitor.cpa.reporting import pool_summary +from monitor.models import ( + CPAAccountOwnerBinding, + CPAAPIKey, + CPAClaimEvent, + CPAUsageEvent, + SystemUserAPIKey, +) +from monitor.serializers import ParticipantWriteSerializer +from monitor.tests.helpers import create_cpa_account, jwt_login +from monitor.tests.api.test_cpa_participants import ( + setup, + event, + member_client, + observation, +) # noqa: F401 +from monitor.replay import rebuild_account + +pytestmark = pytest.mark.django_db + + +def designate(person, account, when): + person.is_owner = True + person.save(update_fields=["is_owner"]) + return sync_account_owner(account, when) + + +def change_role(person, is_owner): + serializer = ParticipantWriteSerializer( + person, data={"is_owner": is_owner}, partial=True + ) + assert serializer.is_valid() + return serializer.save() + + +def test_role_is_account_scoped_key_priority_and_read_permissions(setup): + config, admin, account, alice, bob, keys, start = setup + designate(alice, account, start) + other = create_cpa_account("other-owner-scope") + keyless = CPAAPIKey(key_hash="", hint="") + unknown = CPAAPIKey(key_hash="f" * 64, hint="9999") + a = event(account, keyless, start + timedelta(minutes=10)) + u = event(account, unknown, start + timedelta(minutes=11)) + b = event(account, keys[1], start + timedelta(minutes=12)) + excluded = event(other, keyless, start + timedelta(minutes=13)) + bindings = owner_index() + assert [event_owner(e, bindings) for e in [a, u, b, excluded]] == [ + alice.pk, + alice.pk, + bob.pk, + None, + ] + assert set( + CPAUsageEvent.objects.filter(ownership_filter([alice.pk])).values_list( + "pk", flat=True + ) + ) == {a.pk, u.pk} + user, client, headers = member_client(account, alice) + SystemUserAPIKey.objects.create( + user=user, key_hash=hash_api_key("owner-read-key"), hint="-key" + ) + for route, auth in [ + ("cpa/requests", headers), + ("v1/cpa/requests", {"HTTP_AUTHORIZATION": "Bearer owner-read-key"}), + ]: + result = client.get(f"/api/{route}?account_id={account.pk}", **auth) + assert result.status_code == 200 + assert {r["id"] for r in result.json()["data"]["items"]} == {a.pk, u.pk} + assert ( + client.get( + f"/api/{route}?account_id={account.pk}&participant_id={bob.pk}", **auth + ).status_code + == 400 + ) + _, peer, auth = member_client(account, bob, "peer") + assert [ + r["id"] + for r in peer.get(f"/api/cpa/requests?account_id={account.pk}", **auth).json()[ + "data" + ]["items"] + ] == [b.pk] + summary = pool_summary(user, account, config) + assert summary["accounts"][0]["owner"]["participant_id"] == alice.pk + assert {r["participant_name"] for r in summary["members"]} == {"Alice", "Bob"} + assert ( + client.post( + f"/api/cpa/unassigned/preview?account_id={account.pk}", **headers + ).status_code + == 403 + ) + + +@pytest.mark.parametrize("model", ["constant_average", "time_varying"]) +def test_claim_is_frozen_idempotent_and_replay_stable(setup, model): + config, admin, account, alice, bob, keys, start = setup + config.weekly_quota_model = model + config.save() + keyless = CPAAPIKey(key_hash="", hint="") + a = event(account, keyless, start + timedelta(minutes=10)) + b = event(account, keys[1], start + timedelta(minutes=20)) + designate(alice, account, timezone.now()) + raw = list(CPAUsageEvent.objects.order_by("pk").values()) + observation(account, start, start + timedelta(hours=1), 10, 20) + plan = preview_unassigned_claim(account, alice, admin) + assert plan.preview["accounts"][0]["request_count"] == 1 + assert not plan.preview["accounts"][0]["coverage"]["complete"] + apply_claim(plan.pk) + apply_claim(plan.pk) + assert CPAClaimEvent.objects.filter(plan=plan).count() == 1 + assert list(CPAUsageEvent.objects.order_by("pk").values()) == raw + assert event_owner(a, owner_index()) == alice.pk + assert event_owner(b, owner_index()) == bob.pk + first = pool_summary(admin, account, config) + rebuild_account(account.fact_key, config) + second = pool_summary(admin, account, config) + assert first["members"] == second["members"] + assert first["members"][0]["usage_usd"] == 10 + assert first["members"][0]["remaining_entitlement_usd"] is None + late = event(account, keyless, start + timedelta(minutes=15)) + assert event_owner(late, owner_index()) is None + assert ( + preview_unassigned_claim(account, alice, admin).preview["accounts"][0][ + "request_count" + ] + == 1 + ) + + +@pytest.mark.parametrize("change", ["event", "price", "role", "binding"]) +def test_preview_invalidates_changed_evidence(setup, change): + config, admin, account, alice, bob, keys, start = setup + keyless = CPAAPIKey(key_hash="", hint="") + event(account, keyless, start + timedelta(minutes=10)) + designate(alice, account, timezone.now()) + plan = preview_unassigned_claim(account, alice, admin) + if change == "event": + event(account, keyless, start + timedelta(minutes=11)) + elif change == "price": + config.cpa_fast_multiplier = 4 + config.save() + elif change == "binding": + binding = keys[0].bindings.get() + binding.ended_at = timezone.now() + binding.save() + else: + change_role(alice, False) + with pytest.raises(ValidationError, match="重新预览"): + apply_claim(plan.pk) + assert not CPAClaimEvent.objects.filter(plan=plan).exists() + + +def test_role_changes_keep_history_and_ambiguous_owner_has_no_fallback(setup): + config, admin, account, alice, bob, keys, start = setup + designate(alice, account, start) + keyless = CPAAPIKey(key_hash="", hint="") + old = event(account, keyless, start + timedelta(minutes=10)) + switch = timezone.now() + with patch("django.utils.timezone.now", return_value=switch): + change_role(alice, False) + change_role(bob, True) + new = event(account, keyless, switch) + assert event_owner(old, owner_index()) == alice.pk + assert event_owner(new, owner_index()) == bob.pk + assert set( + CPAUsageEvent.objects.filter(ownership_filter([bob.pk])).values_list( + "pk", flat=True + ) + ) == {new.pk} + designate(alice, account, switch + timedelta(seconds=1)) + assert account_owner_data(account)["status"] == "ambiguous" + unmatched = event(account, keyless, switch + timedelta(seconds=2)) + assert event_owner(unmatched, owner_index()) is None + assert CPAAccountOwnerBinding.objects.filter(account=account).count() == 2 + + +def test_preview_api_derives_owner_from_role(setup): + config, admin, account, alice, bob, keys, start = setup + designate(alice, account, timezone.now()) + event(account, CPAAPIKey(key_hash="", hint=""), start + timedelta(minutes=10)) + client = Client() + headers, _ = jwt_login(client) + result = client.post( + f"/api/cpa/unassigned/preview?account_id={account.pk}", + {"participant_id": bob.pk}, + content_type="application/json", + **headers, + ) + assert result.status_code == 201 + assert result.json()["data"]["participant_id"] == alice.pk + + +def test_ended_key_binding_yields_to_owner_without_reassigning_old_requests(setup): + config, admin, account, alice, bob, keys, start = setup + designate(alice, account, start) + binding = keys[1].bindings.get() + binding.ended_at = start + timedelta(minutes=15) + binding.save() + before = event(account, keys[1], start + timedelta(minutes=14)) + after = event(account, keys[1], start + timedelta(minutes=15)) + assert event_owner(before, owner_index()) == bob.pk + assert event_owner(after, owner_index()) == alice.pk + assert set( + CPAUsageEvent.objects.filter(ownership_filter([alice.pk])).values_list( + "pk", flat=True + ) + ) == {after.pk} + assert set( + CPAUsageEvent.objects.filter(ownership_filter([bob.pk])).values_list( + "pk", flat=True + ) + ) == {before.pk} diff --git a/backend/monitor/tests/api/test_cpa_billing.py b/backend/monitor/tests/api/test_cpa_billing.py new file mode 100644 index 0000000..6ba407d --- /dev/null +++ b/backend/monitor/tests/api/test_cpa_billing.py @@ -0,0 +1,443 @@ +from datetime import date, datetime, timedelta, timezone +from decimal import Decimal + +import pytest +from monitor.cpa.billing import billing_bounds, billing_summary, cycle_rows +from monitor.cpa.participants import owner_index +from monitor.models import ( + CPAAccountCollectionInterval, + CPAQuotaContract, +) +from .test_cpa_participants import event, observation, member_client +from . import test_cpa_participants as fixtures + +setup = fixtures.setup + +pytestmark = pytest.mark.django_db +UTC = timezone.utc + + +def test_anchor_short_month_and_leap(): + anchor = date(2024, 1, 31) + assert billing_bounds(anchor, "UTC", datetime(2024, 2, 29, tzinfo=UTC)) == ( + datetime(2024, 2, 29, tzinfo=UTC), + datetime(2024, 3, 31, tzinfo=UTC), + ) + assert billing_bounds(anchor, "Asia/Shanghai", datetime(2025, 2, 28, tzinfo=UTC))[ + 1 + ] == datetime(2025, 3, 30, 16, tzinfo=UTC) + start, end = billing_bounds( + date(2024, 1, 10), "America/New_York", datetime(2024, 3, 11, tzinfo=UTC) + ) + assert end - start == timedelta(days=31, hours=-1) + + +def scenario(setup, spend=(500, 500), manual=False): + config, admin, account, alice, bob, keys, _ = setup + start = datetime(2025, 2, 1, tzinfo=UTC) # exactly four natural weeks + now = start + timedelta(days=7, hours=1) + account.created_at = start + account.save() + pool = account.pool + pool.cpa_billing_anchor = start.date() + pool.cpa_billing_timezone = "UTC" + pool.save() + config.weekly_quota_model = "time_varying" + config.save() + account.cpa_contracts.update(effective_at=start) + for key in keys: + key.bindings.update(started_at=start) + CPAAccountCollectionInterval.objects.create( + account=account, + connected_at=start, + disconnected_at=now, + end_reliable=True, + session_key="billing-test", + ) + for key, value in zip(keys, spend): + event(account, key, start + timedelta(hours=1), tokens=value * 100000) + reset = start + timedelta(days=3 if manual else 7) + old = observation( + account, + start, + reset - timedelta(seconds=1), + Decimal(sum(spend)) / 10, + Decimal(sum(spend)), + ) + old.valid_sample = True + old.interval_used_percent = old.upstream_used_percent + old.save() + current = observation(account, reset, now, Decimal(0), Decimal(0)) + current.valid_sample = True + current.save() + return config, admin, account, alice, bob, keys, start, now + + +def report(s): + config, admin, account, a, b, _, _, now = s + return billing_summary( + admin, account, config, now, owner_index(), {a.id: a, b.id: b} + ) + + +def test_four_cycles_compensation_and_no_balance_write(setup): + s = scenario(setup, spend=(500, 250)) + config, admin, account, a, b, keys, start, now = s + # Two members own 1/3 each; reserve the last third explicitly. + account.cpa_contracts.update( + allocations=[ + {"participant_id": a.id, "share_percent": "33.33333333333333"}, + {"participant_id": b.id, "share_percent": "33.33333333333333"}, + ] + ) + r = report(s) + assert r["reasons"] == [] + assert r["capacity_usd"] == 4000 + assert r["expired_usd"] == 250 + assert r["available_usd"] == 3000 + assert r["members"][0]["remaining_usd"] == pytest.approx(833.333333) + assert r["members"][0]["completed_overuse_usd"] == pytest.approx(166.666667) + assert r["members"][0]["projected_overuse"] is False + a.refresh_from_db() + assert a.latest_balance_usd == Decimal("123") + + +def test_manual_reset_counts_full_capacity_without_overlap(setup): + s = scenario(setup, spend=(100, 100), manual=True) + r = report(s) + assert r["reasons"] == [] + assert r["cycles"][0]["capacity_usd"] == 1000 + assert r["expired_usd"] == 800 + assert r["capacity_usd"] == pytest.approx(1000 + 25000 / 7) + assert r["cycles"][0]["ended_at"] == r["cycles"][1]["started_at"] + + +def test_reconnect_does_not_duplicate_cycle(setup): + s = scenario(setup) + config, _, account, _, _, _, start, now = s + observation(account, start + timedelta(days=7), now - timedelta(minutes=1), 0, 0) + assert len(cycle_rows(account, config, now)) == 2 + + +def test_price_change_and_gap_fail_closed_but_keep_usage(setup): + s = scenario(setup) + config, _, account, *_ = s + config.cpa_model_pricing["gpt-test"]["input"] = "20" + r = report(s) + assert r["capacity_usd"] is None + assert r["usage_usd"] == 2000 + assert r["members"][0]["recommended_usd"] is None + CPAAccountCollectionInterval.objects.filter(account=account).delete() + assert "采集覆盖不完整" in report(s)["reasons"] + + +def test_unknown_contract_and_no_observation(setup): + s = scenario(setup) + s[2].cpa_contracts.all().delete() + assert report(s)["members"][0]["entitlement_usd"] is None + from monitor.models import Observation + + Observation.objects.all().delete() + assert report(s)["capacity_usd"] is None + + +def test_admin_config_and_readonly_scope(setup): + from django.test import Client + from monitor.tests.helpers import jwt_login + + config, admin, account, alice, bob, keys, start = setup + user, client, headers = member_client(account, alice) + url = f"/api/cpa/billing-config?account_id={account.id}" + payload = {"anchor_date": "2025-01-31", "timezone": "Asia/Shanghai"} + assert ( + client.put(url, payload, content_type="application/json", **headers).status_code + == 403 + ) + client = Client() + headers, _ = jwt_login(client, "owner") + assert ( + client.put(url, payload, content_type="application/json", **headers).status_code + == 200 + ) + assert ( + client.put( + url, + {**payload, "timezone": "../invalid"}, + content_type="application/json", + **headers, + ).status_code + == 400 + ) + + +def test_three_equal_members_exact_example(setup): + from monitor.models import Participant, CPAAPIKey, CPAKeyBinding + + s = scenario(setup, spend=(500, 250)) + config, admin, account, a, b, _, start, now = s + c = Participant.objects.create(name="C") + key = CPAAPIKey.objects.create(key_hash="c" * 64, hint="cccc") + CPAKeyBinding.objects.create(key=key, participant=c, started_at=start) + event(account, key, start + timedelta(hours=2), tokens=25000000) + account.cpa_contracts.update( + allocations=[ + {"participant_id": p.id, "share_percent": "33.3333333333333333"} + for p in (a, b, c) + ] + ) + from monitor.models import Observation + + Observation.objects.filter( + account_id=account.fact_key, observed_at__lt=start + timedelta(days=7) + ).update( + selected_total_cost=1000, upstream_used_percent=100, interval_used_percent=100 + ) + r = billing_summary( + admin, account, config, now, owner_index(), {p.id: p for p in (a, b, c)} + ) + assert r["reasons"] == [] + assert r["members"][0]["recommended_usd"] / 3 == pytest.approx(277.777777) + assert r["members"][0]["recommended_percent"] == pytest.approx(27.777777) + assert sum(m["recommended_usd"] for m in r["members"]) <= r["available_usd"] + + +@pytest.mark.parametrize("model", ["constant_average", "time_varying"]) +def test_weighted_capacity_and_share_change(setup, model): + s = scenario(setup) + config, _, account, a, b, _, start, now = s + config.weekly_quota_model = model + from monitor.models import Observation + + Observation.objects.filter(account_id=account.fact_key, observed_at=now).update( + effective_usd_per_percent=20 + ) + CPAQuotaContract.objects.create( + account=account, + effective_at=start + timedelta(days=7), + pool_id_at_capture=account.pool_id, + pool_name=account.pool.name, + revision=2, + allocations=[ + {"participant_id": a.id, "share_percent": "25"}, + {"participant_id": b.id, "share_percent": "75"}, + ], + ) + r = report(s) + assert r["capacity_usd"] == 7000 + assert r["members"][0]["entitlement_usd"] == 2000 # 1000*50% + 6000*25% + assert r["members"][0]["remaining_usd"] == 1500 + + +def test_period_overuse_and_unallocated_reserve(setup): + s = scenario(setup) + account, a, b = s[2:5] + account.cpa_contracts.update( + allocations=[ + {"participant_id": a.id, "share_percent": "10"}, + {"participant_id": b.id, "share_percent": "50"}, + ] + ) + r = report(s) + assert r["members"][0]["projected_overuse"] is True + assert r["members"][0]["remaining_usd"] == -100 + assert r["members"][0]["recommended_usd"] == 0 + assert r["unallocated_usd"] == 1600 + assert sum(m["recommended_usd"] for m in r["members"]) < r["available_usd"] + + +def test_cross_billing_boundary_capacity_prorated_requests_not_prorated(setup): + s = scenario(setup) + account = s[2] + account.pool.cpa_billing_anchor = date(2025, 2, 4) + account.pool.save() + r = report(s) + assert r["cycles"][0]["capacity_usd"] == pytest.approx(4000 / 7) + assert r["usage_usd"] == 0 # all requests occurred before February 4 + assert r["capacity_usd"] == 4000 + + +def test_zero_consumption_and_missing_price(setup): + s = scenario(setup, spend=(0, 0)) + assert report(s)["usage_usd"] == 0 + assert report(s)["expired_usd"] == 1000 + event(s[2], s[5][0], s[6] + timedelta(hours=3), model="missing") + r = report(s) + assert r["capacity_usd"] is None + assert "请求缺少模型价格" in r["reasons"] + + +def test_historical_pool_move_filters_usage_and_entitlement(setup): + from monitor.models import QuotaPool + + s = scenario(setup) + account, a, b = s[2:5] + old = QuotaPool.objects.create(name="former") + account.cpa_contracts.update(pool_id_at_capture=old.id) + CPAQuotaContract.objects.create( + account=account, + effective_at=s[6] + timedelta(days=7), + pool_id_at_capture=account.pool_id, + pool_name=account.pool.name, + revision=2, + allocations=[ + {"participant_id": a.id, "share_percent": "50"}, + {"participant_id": b.id, "share_percent": "50"}, + ], + ) + r = report(s) + assert r["usage_usd"] == 0 + assert r["capacity_usd"] == 3000 + assert r["expired_usd"] == 0 + + +def test_former_member_and_unattributed_are_separate(setup): + s = scenario(setup) + from monitor.models import CPAUsageEvent + + CPAUsageEvent.objects.filter(account=s[2], api_key_hash=s[5][1].key_hash).update( + api_key_hash="unbound" + ) + r = billing_summary(s[1], s[2], s[0], s[7], owner_index(), {}) + assert r["unattributed_usd"] == 500 + assert r["other_members_usd"] == 500 + + +def test_authorized_multi_account_and_readonly_identical(setup, monkeypatch): + from monitor.tests.helpers import create_cpa_account + from monitor.models import SystemUserAPIKey + from monitor.api_auth import hash_api_key + from monitor.cpa.reporting import pool_summary + + s = scenario(setup) + config, admin, account, a, b, _, start, now = s + other = create_cpa_account(auth_index="other") + other.pool = account.pool + other.save() + # No observations for unauthorized account must not poison or reveal its summary. + user, client, headers = member_client(account, a) + monkeypatch.setattr("monitor.cpa.reporting.timezone.now", lambda: now) + data = pool_summary(user, account, config) + assert data["partial_scope"] is True + assert data["billing_summary"]["capacity_usd"] == 4000 + assert {r["account_id"] for r in data["billing_summary"]["cycles"]} == {account.id} + SystemUserAPIKey.objects.create( + user=user, key_hash=hash_api_key("billing-test-token"), hint="test" + ) + response = client.get( + f"/api/v1/cpa/summary?account_id={account.id}", + HTTP_AUTHORIZATION="Bearer billing-test-token", + ) + assert response.status_code == 200 + assert response.json()["data"]["billing_summary"] == data["billing_summary"] + assert client.get( + f"/api/v1/cpa/summary?account_id={other.id}", + HTTP_AUTHORIZATION="Bearer billing-test-token", + ).status_code in (400, 403, 404) + + +def test_future_fallback_uses_median_of_three_reliable_completed_cycles(setup): + s = scenario(setup) + from monitor.models import Observation + + config, _, account, _, _, _, start, now = s + CPAAccountCollectionInterval.objects.filter(account=account).update( + connected_at=start - timedelta(days=21) + ) + for weeks, rate in ((3, 1), (2, 3), (1, 2)): + begin = start - timedelta(days=7 * weeks) + row = observation(account, begin, begin + timedelta(days=6), 0, 0) + row.valid_sample = True + row.effective_usd_per_percent = rate + row.save() + Observation.objects.filter(account_id=account.fact_key, observed_at=now).update( + valid_sample=False + ) + r = report(s) + assert r["capacity_usd"] is None + assert r["future_capacity_usd"] == 600 # median(300, 200, 1000) * two future weeks + assert all( + c["full_capacity_usd"] == 300 for c in r["cycles"] if c["kind"] == "future" + ) + assert r["members"][0]["recommended_usd"] is None + + +def test_late_request_invalidates_capacity_without_duplicate_accounting(setup): + s = scenario(setup) + initial = report(s) + assert report(s) == initial + event(s[2], s[5][0], s[6] + timedelta(days=2)) + changed = report(s) + assert changed["usage_usd"] == initial["usage_usd"] + 10 + assert changed["capacity_usd"] is None + assert report(s) == changed + + +def test_future_anchor_has_a_valid_first_period(): + assert billing_bounds( + date(2027, 1, 31), "UTC", datetime(2026, 9, 8, tzinfo=UTC) + ) == (datetime(2027, 1, 31, tzinfo=UTC), datetime(2027, 2, 28, tzinfo=UTC)) + + +def test_authorized_accounts_sum_dollars_not_percentages(setup): + from monitor.tests.helpers import create_cpa_account + from monitor.cpa.participants import record_contract + + s = scenario(setup) + config, admin, account, a, b, _, start, now = s + other = create_cpa_account(auth_index="second-authorized") + other.pool = account.pool + other.created_at = start + other.save() + record_contract(other, start) + CPAAccountCollectionInterval.objects.create( + account=other, + session_key="second", + connected_at=start, + disconnected_at=now, + end_reliable=True, + ) + for begin, at in ( + (start, start + timedelta(days=6)), + (start + timedelta(days=7), now), + ): + row = observation(other, begin, at, 0, 0) + row.valid_sample = True + row.effective_usd_per_percent = 20 + row.save() + user, _, _ = member_client(account, a) + other.authorized_users.add(user) + result = billing_summary( + user, account, config, now, owner_index(), {a.id: a, b.id: b} + ) + assert result["capacity_usd"] == 12000 + assert result["members"][0]["usage_percent"] == pytest.approx(500 / 12000 * 100) + assert result["expired_usd"] == 2000 + + +def test_history_claim_is_visible_on_next_billing_read(setup): + from monitor.cpa.participants import preview_claim, apply_claim + from monitor.models import CPAUsageEvent + + s = scenario(setup) + config, admin, account, a, b, keys, start, now = s + keys[0].bindings.update(started_at=start + timedelta(days=7)) + initial = report(s) + assert initial["unattributed_usd"] == 500 + raw = list(CPAUsageEvent.objects.filter(account=account).order_by("id").values()) + plan = preview_claim( + key=keys[0], + participant=a, + started_at=start, + ended_at=start + timedelta(days=7), + user=admin, + ) + apply_claim(plan.id) + apply_claim(plan.id) + result = report(s) + assert result["unattributed_usd"] == 0 + assert result["members"][0]["usage_usd"] == 500 + assert result["usage_usd"] == initial["usage_usd"] + assert ( + list(CPAUsageEvent.objects.filter(account=account).order_by("id").values()) + == raw + ) diff --git a/backend/monitor/tests/api/test_cpa_capacity_display.py b/backend/monitor/tests/api/test_cpa_capacity_display.py new file mode 100644 index 0000000..9d8c8a8 --- /dev/null +++ b/backend/monitor/tests/api/test_cpa_capacity_display.py @@ -0,0 +1,234 @@ +from datetime import timedelta +from decimal import Decimal +from types import SimpleNamespace + +import pytest +from monitor.cpa.billing import weekly_distribution +from monitor.cpa.participants import owner_index +from monitor.cpa.quota_status import quota_detail +from monitor.cpa.usage import refresh_cpa_history +from monitor.models import CPAAccountCollectionInterval, Observation +from monitor.particle_trajectory import particle_trajectory_data +from monitor.replay import rebuild_account +from monitor.reporting.recommendations import _capacity_values +from .test_cpa_participants import event, observation +from . import test_cpa_participants as fixtures +from .test_cpa_billing import scenario, report + +setup = fixtures.setup +pytestmark = pytest.mark.django_db + + +def partial_cycle(setup): + config, admin, account, a, b, keys, start = setup + config.weekly_quota_model = "time_varying" + config.save() + official = start - timedelta(hours=2) + connected = start + timedelta(minutes=30) + now = start + timedelta(hours=1) + event(account, keys[0], start + timedelta(minutes=10), tokens=2_000_000) + event(account, keys[0], start + timedelta(minutes=40)) + CPAAccountCollectionInterval.objects.create( + account=account, session_key="reconnected", connected_at=connected + ) + observation(account, official, connected, 15, 20) + observation(account, official, now, 16, 30) + rebuild_account(account.fact_key, config) + return config, account, a, b, now + + +def test_reconnect_capacity_matches_particle_trajectory_without_fake_balance(setup): + config, account, a, b, now = partial_cycle(setup) + trajectory = particle_trajectory_data(config, account) + week = weekly_distribution( + [account], {a.id: a, b.id: b}, config, now, owner_index() + )[0] + assert trajectory["available"] + assert week["capacity_usd"] == trajectory["latest"]["capacity_usd"] + assert ( + week["capacity_estimate"]["lower_usd"] + == trajectory["latest"]["capacity_lower_usd"] + ) + assert ( + week["capacity_estimate"]["upper_usd"] + == trajectory["latest"]["capacity_upper_usd"] + ) + assert week["coverage_complete"] is False + assert week["remaining_usd"] is None + assert week["upstream_remaining_percent"] == 84 + assert week["members"][0]["usage_percent"] > 0 + assert week["usage_usd"] == 30 + a.refresh_from_db() + assert a.latest_balance_usd == Decimal("123") + + +def test_missing_history_does_not_hide_capacity_but_settlement_stays_unknown(setup): + s = scenario(setup) + account = s[2] + CPAAccountCollectionInterval.objects.filter(account=account).delete() + for obs in Observation.objects.filter(account_id=account.fact_key): + obs.attribution_started_at = obs.upstream_resets_at - timedelta(days=7) + obs.model_diagnostics = {"algorithm": "posterior"} + obs.capacity_lower_usd = 900 + obs.capacity_upper_usd = 1100 + obs.save() + result = report(s) + assert result["capacity_usd"] == 4000 + assert result["members"][0]["entitlement_usd"] == 2000 + assert result["members"][0]["usage_percent"] == 12.5 + assert result["members"][0]["remaining_usd"] is None + assert result["expired_usd"] is None + assert result["available_usd"] is None + assert result["members"][0]["recommended_usd"] is None + + +def test_quota_status_displays_same_capacity_even_with_extra_model_limits(setup): + config, account, a, b, now = partial_cycle(setup) + latest = Observation.objects.filter(account_id=account.fact_key).latest( + "observed_at" + ) + body = { + "rate_limit": { + "primary_window": { + "limit_window_seconds": 604800, + "used_percent": 16, + "reset_at": latest.upstream_resets_at.timestamp(), + } + }, + "additional_rate_limits": [ + { + "limit_name": "Spark", + "rate_limit": { + "primary_window": { + "limit_window_seconds": 18000, + "used_percent": 0, + "reset_at": (now + timedelta(hours=5)).timestamp(), + } + }, + } + ], + } + detail = quota_detail(account, config, now, body=body) + week = detail["windows"][0] + assert ( + week["capacity_estimate"]["capacity_usd"] + == particle_trajectory_data(config, account)["latest"]["capacity_usd"] + ) + assert week["prediction"] is None # No invented request/token totals. + assert detail["windows"][1]["capacity_estimate"] is None + + +@pytest.mark.parametrize("change", ["late_event", "pricing"]) +def test_repricing_or_late_segment_events_require_replay_before_display(setup, change): + config, account, a, b, now = partial_cycle(setup) + if change == "pricing": + config.cpa_model_pricing["gpt-test"]["input"] = "30" + config.save() + else: + event(account, setup[5][0], now - timedelta(minutes=10)) + # Earlier prior may still be displayed, but never the stale latest posterior. + latest = Observation.objects.filter(account_id=account.fact_key).latest( + "observed_at" + ) + week = weekly_distribution([account], {a.id: a}, config, now, owner_index())[0] + assert week["quota_as_of"] != latest.observed_at.isoformat() + refresh_cpa_history(config) + week = weekly_distribution([account], {a.id: a}, config, now, owner_index())[0] + assert ( + week["capacity_usd"] + == particle_trajectory_data(config, account)["latest"]["capacity_usd"] + ) + + +def test_unobserved_account_has_no_invented_initial_capacity(setup): + config, _, account, a, _, _, start = setup + week = weekly_distribution([account], {a.id: a}, config, start, owner_index())[0] + assert week["capacity_usd"] is None + assert week["capacity_estimate"] is None + + +def test_first_reconnect_observation_is_explicit_prior(setup): + config, account, a, b, now = partial_cycle(setup) + first = Observation.objects.filter(account_id=account.fact_key).earliest( + "observed_at" + ) + week = weekly_distribution( + [account], {a.id: a}, config, first.observed_at, owner_index() + )[0] + assert week["capacity_usd"] > 0 + assert week["capacity_estimate"]["prior_only"] is True + assert week["capacity_estimate"]["as_of"] == first.observed_at.isoformat() + assert week["remaining_usd"] is None + + +def test_average_model_keeps_its_capacity_after_reconnect(setup): + config, account, a, b, now = partial_cycle(setup) + config.weekly_quota_model = "constant_average" + config.save() + latest = Observation.objects.filter(account_id=account.fact_key).latest( + "observed_at" + ) + week = weekly_distribution([account], {a.id: a}, config, now, owner_index())[0] + expected = _capacity_values( + SimpleNamespace(observation=latest, selected_cost=Decimal(0)), config + )[0] + assert week["capacity_usd"] == float(expected) + assert week["capacity_estimate"]["source"] == "quota_model" + assert week["remaining_usd"] is None + + +def test_v9_upgrade_replays_reconnects_and_preserves_source_facts(setup): + from io import StringIO + from django.core.management import call_command + from monitor.models import CPAUsageEvent + from monitor.replay import rebuild_observation_suffix + + config, _, account, a, b, keys, start = setup + config.weekly_quota_model = "time_varying" + config.save() + CPAAccountCollectionInterval.objects.create( + account=account, session_key="first", connected_at=start, + disconnected_at=start + timedelta(hours=36), end_reliable=True, + ) + for i in range(4): + when = start + timedelta(hours=12 * i) + if i: + event(account, keys[0], when - timedelta(minutes=1), tokens=2_000_000) + observation(account, start, when, 18 + 10 * i, 20 * i) + connected = start + timedelta(hours=37) + CPAAccountCollectionInterval.objects.create( + account=account, session_key="next", connected_at=connected, + ) + observation(account, start, connected, 48, 60) + latest = observation(account, start, connected + timedelta(seconds=1), 48, 60) + rebuild_account(account.fact_key, config) + latest.refresh_from_db() + expected = latest.effective_usd_per_percent + rebuild_observation_suffix(latest, config) + latest.refresh_from_db() + assert latest.effective_usd_per_percent == expected + assert expected < Decimal("7") + assert not latest.model_diagnostics["capacity_range_promotions"] + assert latest.model_diagnostics["capacity_initial_range_usd"][0] < 1400 + source_fields = ( + "id", "observed_at", "upstream_used_percent", "raw_selected_total_cost", + ) + facts = list(Observation.objects.values(*source_fields)) + events = list(CPAUsageEvent.objects.values()) + for obs in Observation.objects.all(): + obs.raw_window["rate_method"] = "particle_filter_v9" + obs.save(update_fields=["raw_window"]) + call_command("replayobservations", stdout=StringIO()) + latest.refresh_from_db() + assert latest.raw_window["rate_method"] == "particle_filter_v10" + assert latest.effective_usd_per_percent == expected + assert list(Observation.objects.values(*source_fields)) == facts + assert list(CPAUsageEvent.objects.values()) == events + trajectory = particle_trajectory_data(config, account) + week = weekly_distribution( + [account], {a.id: a, b.id: b}, config, latest.observed_at, owner_index(), + )[0] + assert trajectory["points"][-1]["range_inherited"] + assert week["capacity_usd"] == trajectory["latest"]["capacity_usd"] + a.refresh_from_db() + assert a.latest_balance_usd == Decimal("123") diff --git a/backend/monitor/tests/api/test_cpa_participants.py b/backend/monitor/tests/api/test_cpa_participants.py new file mode 100644 index 0000000..5234dea --- /dev/null +++ b/backend/monitor/tests/api/test_cpa_participants.py @@ -0,0 +1,708 @@ +from datetime import timedelta +from decimal import Decimal +import uuid + +import pytest +from django.contrib.auth import get_user_model +from django.test import Client +from django.utils import timezone +from rest_framework.exceptions import ValidationError + +from monitor.api_auth import hash_api_key +from monitor.cpa.participants import ( + apply_claim, + bind_key, + preview_claim, + record_contract, + unbind_key, + ownership_filter, +) +from monitor.cpa.reporting import pool_summary +from monitor.cpa.usage import _api_key_identity, persist_usage_event +from monitor.models import ( + AppSettings, + CPAAPIKey, + CPAKeyBinding, + CPAUsageEvent, + CPAAccountCollectionInterval, + Participant, + ParticipantSnapshot, + PoolParticipant, + Observation, + SystemUserAPIKey, + SystemUserPageAccess, +) +from monitor.replay import rebuild_account +from monitor.serializers import ( + ParticipantWriteSerializer, + QuotaAllocationWriteSerializer, +) +from monitor.tests.helpers import ( + create_cpa_account, + create_monitored_account, + jwt_login, +) + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def setup(): + config = AppSettings.load() + config.cpa_model_pricing = { + "gpt-test": {"input": "10", "cached_input": "1", "output": "30"} + } + config.save() + admin = get_user_model().objects.create_superuser( + "owner", "owner@example.com", "very-strong-password" + ) + account = create_cpa_account() + alice = Participant.objects.create( + name="Alice", sub2api_user_id=51, latest_balance_usd=Decimal("123") + ) + bob = Participant.objects.create(name="Bob") + for person in (alice, bob): + PoolParticipant.objects.create( + pool=account.pool, participant=person, share_percent=50 + ) + start = timezone.now() - timedelta(hours=2) + account.created_at = start + account.save(update_fields=["created_at"]) + record_contract(account, start) + keys = [] + for person, raw in ((alice, "alice-cpa-key"), (bob, "bob-cpa-key")): + digest, hint = _api_key_identity(raw) + key = CPAAPIKey.objects.create(key_hash=digest, hint=hint) + CPAKeyBinding.objects.create(key=key, participant=person, started_at=start) + keys.append(key) + return config, admin, account, alice, bob, keys, start + + +def event(account, key, when, *, tokens=1_000_000, model="gpt-test", failed=False): + return CPAUsageEvent.objects.create( + account=account, + event_fingerprint=uuid.uuid4().hex, + request_id=uuid.uuid4().hex, + occurred_at=when, + api_key_hash=key.key_hash, + api_key_hint=key.hint, + model=model, + input_tokens=tokens, + total_tokens=tokens, + failed=failed, + ) + + +def observation(account, start, when, percent, cost): + return Observation.objects.create( + account_id=account.fact_key, + observed_at=when, + upstream_resets_at=start + timedelta(days=7), + window_seconds=604800, + upstream_used_percent=percent, + effective_usd_per_percent=Decimal("10"), + raw_selected_total_cost=cost, + selected_total_cost=cost, + total_standard_cost=cost, + total_actual_cost=cost, + cost_window_started_at=start, + cost_window_ended_at=when, + raw_window={"provider": "cpa"}, + ) + + +def member_client(account, participant, name="rider"): + user = get_user_model().objects.create_user(name, password="very-strong-password") + participant.authorized_users.add(user) + account.authorized_users.add(user) + for page in ("dashboard", "participants", "statistics", "observations"): + SystemUserPageAccess.objects.create(user=user, page_code=page) + client = Client() + headers, _ = jwt_login(client, name) + return user, client, headers + + +def test_nullable_identity_and_channel_partition(setup): + config, admin, account, alice, bob, keys, start = setup + serializer = ParticipantWriteSerializer(data={"name": "CPA only"}) + assert serializer.is_valid(), serializer.errors + person = serializer.save() + assert person.sub2api_user_id is None + assert not person.account_memberships.exists() + sub = create_monitored_account(7) + payload = { + "provider": "cpa", + "pools": [{"account_ids": [account.id, sub.id], "allocations": []}], + } + serializer = QuotaAllocationWriteSerializer(data=payload) + assert not serializer.is_valid() + serializer = QuotaAllocationWriteSerializer( + data={ + "provider": "cpa", + "pools": [ + { + "id": account.pool_id, + "account_ids": [account.id], + "allocations": [{"participant_id": bob.id, "share_percent": 100}], + } + ], + } + ) + assert serializer.is_valid(), serializer.errors + serializer.apply() + sub.refresh_from_db() + assert sub.pool_id != account.pool_id + assert account.cpa_contracts.count() == 2 + + +def test_multi_key_binding_no_plaintext_or_overlap_and_unbind(setup): + config, admin, account, alice, bob, keys, start = setup + binding = bind_key( + participant=alice, user=admin, raw_key="new-private-cpa-key", name="Laptop" + ) + assert binding.key.key_hash != "new-private-cpa-key" + assert binding.key.hint == "-key" + with pytest.raises(ValidationError): + bind_key(participant=bob, user=admin, raw_key="new-private-cpa-key") + unbind_key(binding.id) + replacement = bind_key(participant=bob, user=admin, raw_key="new-private-cpa-key") + assert replacement.started_at >= CPAKeyBinding.objects.get(pk=binding.id).ended_at + assert alice.cpa_bindings.count() == 2 + + +@pytest.mark.parametrize("model", ["constant_average", "time_varying"]) +def test_model_replay_and_pool_summary_preserve_sub2api_balance(setup, model): + config, admin, account, alice, bob, keys, start = setup + config.weekly_quota_model = model + config.save() + CPAAccountCollectionInterval.objects.create( + account=account, session_key="live", connected_at=start + ) + observation(account, start, start, 0, 0) + event(account, keys[0], start + timedelta(minutes=10)) + event(account, keys[1], start + timedelta(minutes=20), tokens=2_000_000) + end = observation(account, start, start + timedelta(hours=1), 10, 30) + rebuild_account(account.fact_key, config) + alice.refresh_from_db() + assert alice.latest_balance_usd == Decimal("123") + first = ParticipantSnapshot.objects.get(observation=end, participant=alice) + second = ParticipantSnapshot.objects.get(observation=end, participant=bob) + assert first.raw_selected_cost == 10 + assert second.raw_selected_cost == 20 + assert first.charged_cycle_percent > 0 + assert first.charged_cycle_percent < second.charged_cycle_percent + assert first.recommended_balance_usd is None + assert not first.needs_manual_update + summary = pool_summary(admin, account, config) + assert all(row["quota_available"] for row in summary["members"]) + assert summary["accounts"][0]["quota_unavailable_reasons"] == [] + assert all(not row["is_self"] for row in summary["members"]) + assert all( + row["account_breakdowns"][0]["quota_unavailable_reasons"] == [] + for row in summary["members"] + ) + assert sum(row["usage_usd"] for row in summary["members"]) == 30 + before = list( + end.participant_snapshots.order_by("participant_id").values( + "selected_cost", "charged_cycle_percent" + ) + ) + rebuild_account(account.fact_key, config) + assert ( + list( + end.participant_snapshots.order_by("participant_id").values( + "selected_cost", "charged_cycle_percent" + ) + ) + == before + ) + + +def test_peer_totals_own_requests_and_readonly_scope(setup): + config, admin, account, alice, bob, keys, start = setup + a = event(account, keys[0], start + timedelta(minutes=10)) + b = event(account, keys[1], start + timedelta(minutes=20), tokens=2_000_000) + user, client, headers = member_client(account, alice) + response = client.get(f"/api/cpa/summary?account_id={account.id}", **headers) + assert response.status_code == 200, response.content + rows = response.json()["data"]["members"] + assert {row["participant_name"] for row in rows} == {"Alice", "Bob"} + assert "key_hash" not in str(rows) and "request_id" not in str(rows) + for route in ("cpa/requests", "v1/cpa/requests"): + chosen_headers = headers + if route.startswith("v1/"): + SystemUserAPIKey.objects.create( + user=user, key_hash=hash_api_key("user-read-key"), hint="-key" + ) + chosen_headers = {"HTTP_AUTHORIZATION": "Bearer user-read-key"} + own = client.get(f"/api/{route}?account_id={account.id}", **chosen_headers) + assert own.status_code == 200, own.content + assert [row["id"] for row in own.json()["data"]["items"]] == [a.id] + assert ( + client.get( + f"/api/{route}?account_id={account.id}&participant_id={bob.id}", + **chosen_headers, + ).status_code + == 400 + ) + assert ( + client.get( + f"/api/{route}?account_id={account.id}&key_id={keys[1].id}", + **chosen_headers, + ).status_code + == 404 + ) + stats = client.get( + f"/api/statistics?account_id={account.id}&usage_precision=raw", **headers + ) + assert len(stats.json()["data"]["cpa_api_key_series"]) == 1 + other = create_cpa_account("private") + other.authorized_users.add(user) + assert ( + client.get(f"/api/cpa/summary?account_id={other.id}", **headers).status_code + == 400 + ) + allocation = client.get("/api/quota-allocation?provider=cpa", **headers).json()[ + "data" + ] + assert [row["id"] for row in allocation["accounts"]] == [account.id] + assert all(row["sub2api_email"] == "" for row in allocation["participants"]) + assert b.request_id not in str( + client.get(f"/api/cpa/requests?account_id={account.id}", **headers).json() + ) + + +def test_admin_readonly_requests_principal(setup): + config, admin, account, alice, bob, keys, start = setup + config.readonly_api_key_hash = hash_api_key("admin-read-key") + config.save() + event(account, keys[1], start + timedelta(minutes=20)) + client = Client() + response = client.get( + f"/api/v1/cpa/requests?account_id={account.id}", + HTTP_AUTHORIZATION="Bearer admin-read-key", + ) + assert response.status_code == 200, response.content + assert response.json()["data"]["total"] == 1 + + +def test_claim_is_explicit_idempotent_and_does_not_invent_contract(setup): + config, admin, account, alice, bob, keys, start = setup + earlier = start - timedelta(hours=2) + raw = event(account, keys[0], earlier + timedelta(minutes=5)) + old = observation(account, earlier, earlier + timedelta(hours=1), 10, 10) + before = CPAUsageEvent.objects.get(pk=raw.id).__dict__.copy() + plan = preview_claim( + key=keys[0], participant=alice, started_at=earlier, ended_at=start, user=admin + ) + assert plan.preview["accounts"][0]["request_count"] == 1 + assert not plan.preview["accounts"][0]["coverage"]["complete"] + assert CPAKeyBinding.objects.count() == 2 + apply_claim(plan.id) + apply_claim(plan.id) + assert CPAKeyBinding.objects.count() == 3 + snapshot = ParticipantSnapshot.objects.get(observation=old, participant=alice) + assert not snapshot.cpa_contract_known + assert snapshot.quota_pool_id is None + fresh = CPAUsageEvent.objects.get(pk=raw.id).__dict__ + assert {k: v for k, v in fresh.items() if k != "_state"} == { + k: v for k, v in before.items() if k != "_state" + } + + +def test_claim_does_not_automatically_own_late_historical_events(setup): + config, admin, account, alice, bob, keys, start = setup + earlier = start - timedelta(hours=2) + original = event(account, keys[0], earlier + timedelta(minutes=5)) + old = observation(account, earlier, earlier + timedelta(hours=1), 10, 20) + plan = preview_claim( + key=keys[0], participant=alice, started_at=earlier, ended_at=start, user=admin + ) + apply_claim(plan.id) + late = event(account, keys[0], earlier + timedelta(minutes=6)) + rebuild_account(account.fact_key, config) + assert ( + ParticipantSnapshot.objects.get( + observation=old, participant=alice + ).raw_selected_cost + == 10 + ) + assert set( + CPAUsageEvent.objects.filter(ownership_filter([alice.id])).values_list( + "id", flat=True + ) + ) == {original.id} + with pytest.raises(ValidationError): + preview_claim( + key=keys[0], participant=bob, started_at=earlier, ended_at=start, user=admin + ) + new_plan = preview_claim( + key=keys[0], participant=alice, started_at=earlier, ended_at=start, user=admin + ) + assert new_plan.preview["accounts"][0]["request_count"] == 1 + apply_claim(new_plan.id) + apply_claim(new_plan.id) + assert ( + ParticipantSnapshot.objects.get( + observation=old, participant=alice + ).raw_selected_cost + == 20 + ) + assert set( + CPAUsageEvent.objects.filter(ownership_filter([alice.id])).values_list( + "id", flat=True + ) + ) == {original.id, late.id} + + +@pytest.mark.parametrize("change", ["events", "pricing", "binding"]) +def test_claim_rejects_changed_preview(setup, change): + config, admin, account, alice, bob, keys, start = setup + earlier = start - timedelta(hours=2) + event(account, keys[0], earlier + timedelta(minutes=5)) + plan = preview_claim( + key=keys[0], participant=alice, started_at=earlier, ended_at=start, user=admin + ) + if change == "events": + event(account, keys[0], earlier + timedelta(minutes=6)) + elif change == "pricing": + config.cpa_fast_multiplier = 4 + config.save() + else: + unbind_key(keys[0].bindings.get().id) + with pytest.raises(ValidationError, match="重新预览"): + apply_claim(plan.id) + + +def test_unknown_models_and_collection_gap_do_not_show_remaining(setup): + config, admin, account, alice, bob, keys, start = setup + event(account, keys[0], start + timedelta(minutes=10), model="unpriced") + observation(account, start, start + timedelta(hours=1), 10, 0) + rebuild_account(account.fact_key, config) + summary = pool_summary(admin, account, config) + assert summary["members"][0]["remaining_entitlement_usd"] is None + assert summary["members"][0]["unpriced_request_count"] == 1 + assert not summary["accounts"][0]["coverage"]["complete"] + reasons = summary["accounts"][0]["quota_unavailable_reasons"] + assert "1 次请求缺少模型价格" in reasons + assert any("采集缺口" in reason for reason in reasons) + assert set(reasons) <= set( + summary["members"][0]["account_breakdowns"][0]["quota_unavailable_reasons"] + ) + + +def test_request_filters_and_participant_with_history_cannot_be_deleted(setup): + config, admin, account, alice, bob, keys, start = setup + event(account, keys[0], start + timedelta(minutes=10)) + failed = event(account, keys[0], start + timedelta(minutes=20), failed=True) + client = Client() + headers, _ = jwt_login(client) + assert ( + client.get(f"/api/cpa/requests?account_id={account.id}", **headers).json()[ + "data" + ]["total"] + == 2 + ) + response = client.get( + f"/api/cpa/requests?account_id={account.id}&failed=true&page_size=1", **headers + ) + assert response.json()["data"]["total"] == 1 + assert response.json()["data"]["items"][0]["id"] == failed.id + assert ( + client.get( + f"/api/cpa/requests?account_id={account.id}&page_size=101", **headers + ).status_code + == 400 + ) + assert client.delete(f"/api/participants/{bob.id}", **headers).status_code == 409 + + +def test_late_usage_duplicate_and_repricing_refresh_participant_inputs(setup): + from monitor.cpa.usage import refresh_cpa_history + + config, admin, account, alice, bob, keys, start = setup + CPAAccountCollectionInterval.objects.create( + account=account, session_key="live", connected_at=start + ) + observation(account, start, start, 0, 0) + end = observation(account, start, start + timedelta(hours=1), 10, 0) + payload = { + "auth_index": account.cpa_auth_index, + "timestamp": (start + timedelta(minutes=20)).isoformat(), + "request_id": "late-cpa-event", + "model": "gpt-test", + "api_key": "alice-cpa-key", + "tokens": {"input_tokens": 1_000_000, "total_tokens": 1_000_000}, + } + assert persist_usage_event(payload) == "created" + assert persist_usage_event(payload) == "duplicate" + assert CPAUsageEvent.objects.filter(request_id="late-cpa-event").count() == 1 + assert ( + ParticipantSnapshot.objects.get( + observation=end, participant=alice + ).raw_selected_cost + == 10 + ) + config.cpa_model_pricing["gpt-test"]["input"] = "20" + config.save() + refresh_cpa_history(config) + assert ( + ParticipantSnapshot.objects.get( + observation=end, participant=alice + ).raw_selected_cost + == 20 + ) + alice.refresh_from_db() + assert alice.latest_balance_usd == 123 + + +def test_pool_partial_scope_does_not_expose_hidden_account_cost(setup): + config, admin, account, alice, bob, keys, start = setup + hidden = create_cpa_account("same-pool-hidden") + hidden.pool = account.pool + hidden.save(update_fields=["pool"]) + event(account, keys[0], start + timedelta(minutes=10)) + event(hidden, keys[0], start + timedelta(minutes=20), tokens=50_000_000) + user, client, headers = member_client(account, alice) + summary = client.get(f"/api/cpa/summary?account_id={account.id}", **headers).json()[ + "data" + ] + assert summary["partial_scope"] + assert [a["account_id"] for a in summary["accounts"]] == [account.id] + assert ( + next(row for row in summary["members"] if row["participant_id"] == alice.id)[ + "usage_usd" + ] + == 10 + ) + + +def test_positive_baseline_does_not_claim_complete_personal_quota(setup): + config, admin, account, alice, bob, keys, start = setup + CPAAccountCollectionInterval.objects.create( + account=account, session_key="live", connected_at=start + ) + observation(account, start, start + timedelta(minutes=10), 10, 10) + event(account, keys[0], start + timedelta(minutes=5)) + event(account, keys[1], start + timedelta(minutes=20)) + observation(account, start, start + timedelta(hours=1), 20, 20) + rebuild_account(account.fact_key, config) + assert all( + not row["quota_available"] + for row in pool_summary(admin, account, config)["members"] + ) + + +def test_cpa_membership_does_not_enter_sub2api_balance_guards(setup, monkeypatch): + config, admin, account, alice, bob, keys, start = setup + sub = create_monitored_account(7) + PoolParticipant.objects.create(pool=sub.pool, participant=alice, share_percent=50) + captured = [] + from monitor.history_state import LeaseBusyError + + def stop_after_scope(account_ids): + captured.append(account_ids) + raise LeaseBusyError("test scope") + + monkeypatch.setattr("monitor.views.dashboard._acquire_guards", stop_after_scope) + client = Client() + headers, _ = jwt_login(client) + assert ( + client.post( + f"/api/dashboard/participants/{alice.id}/apply-recommendation", **headers + ).status_code + == 409 + ) + assert captured == [{7}] + assert ( + client.post( + f"/api/dashboard/participants/{bob.id}/apply-recommendation", **headers + ).status_code + == 400 + ) + + +def test_rebind_registered_key_without_events_and_keep_contract_identity(setup): + config, admin, account, alice, bob, keys, start = setup + binding = bind_key(participant=alice, user=admin, raw_key="never-used-key") + unbind_key(binding.id) + new = bind_key(participant=bob, user=admin, observed_hash=binding.key.key_hash) + assert new.key_id == binding.key_id + person = Participant.objects.create(name="Contract only") + PoolParticipant.objects.create( + pool=account.pool, participant=person, share_percent=0 + ) + record_contract(account, timezone.now()) + client = Client() + headers, _ = jwt_login(client) + assert client.delete(f"/api/participants/{person.id}", **headers).status_code == 409 + + +@pytest.mark.parametrize("model", ["constant_average", "time_varying"]) +def test_pool_combines_account_credits_and_keeps_unassigned_cost(setup, model): + config, admin, account, alice, bob, keys, start = setup + config.weekly_quota_model = model + config.save() + second = create_cpa_account("second-pool-account") + second.pool = account.pool + second.created_at = start + second.save(update_fields=["pool", "created_at"]) + record_contract(second, start) + unknown = CPAAPIKey.objects.create(key_hash="f" * 64, hint="0000") + for selected in (account, second): + CPAAccountCollectionInterval.objects.create( + account=selected, session_key="live", connected_at=start + ) + observation(selected, start, start, 0, 0) + event(account, keys[0], start + timedelta(minutes=10), tokens=6_000_000) + observation(account, start, start + timedelta(hours=1), 60, 60) + event(second, unknown, start + timedelta(minutes=10), tokens=5_000_000) + observation(second, start, start + timedelta(hours=1), 10, 50) + for selected in (account, second): + rebuild_account(selected.fact_key, config) + summary = pool_summary(admin, account, config) + member = next( + row for row in summary["members"] if row["participant_id"] == alice.id + ) + assert member["quota_available"] + assert len(member["account_breakdowns"]) == 2 + assert member["usage_usd"] == 60 + assert member["remaining_entitlement_usd"] == pytest.approx( + sum(row["remaining_entitlement_usd"] for row in member["account_breakdowns"]) + ) + assert not member["is_overused"] + assert summary["unattributed"]["usage_usd"] == 50 + second.enabled = False + second.save(update_fields=["enabled"]) + single = pool_summary(admin, account, config) + assert next(row for row in single["members"] if row["participant_id"] == alice.id)[ + "is_overused" + ] + + +@pytest.mark.parametrize("model", ["constant_average", "time_varying"]) +def test_cpa_participant_cost_resets_with_official_cycle(setup, model): + config, admin, account, alice, bob, keys, start = setup + config.weekly_quota_model = model + config.save() + previous_start = start - timedelta(days=7) + record_contract(account, previous_start) + keys[0].bindings.update(started_at=previous_start) + CPAAccountCollectionInterval.objects.create( + account=account, session_key="live", connected_at=previous_start + ) + observation(account, previous_start, previous_start, 0, 0) + event(account, keys[0], previous_start + timedelta(hours=1), tokens=9_000_000) + old = observation(account, previous_start, start - timedelta(minutes=5), 90, 90) + observation(account, start, start, 0, 0) + event(account, keys[0], start + timedelta(minutes=10)) + current = observation(account, start, start + timedelta(hours=1), 10, 10) + rebuild_account(account.fact_key, config) + assert ( + ParticipantSnapshot.objects.get( + observation=old, participant=alice + ).raw_selected_cost + == 90 + ) + snapshot = ParticipantSnapshot.objects.get(observation=current, participant=alice) + assert snapshot.raw_selected_cost == 10 + assert snapshot.charged_cycle_percent < 50 + member = next( + row + for row in pool_summary(admin, account, config)["members"] + if row["participant_id"] == alice.id + ) + assert member["usage_usd"] == 10 + assert not member["is_overused"] + + +def test_detached_sub2api_identity_keeps_historical_replay_subject(setup): + from monitor.models import AccountParticipant + from monitor.tests.helpers import create_participant_snapshot + + config, admin, account, alice, bob, keys, start = setup + sub = create_monitored_account(7) + AccountParticipant.objects.create(account=sub, participant=alice) + PoolParticipant.objects.create(pool=sub.pool, participant=alice, share_percent=50) + for when, percent, cost in ((start, 0, 0), (start + timedelta(hours=1), 10, 10)): + row = observation(sub, start, when, percent, cost) + create_participant_snapshot( + observation=row, + participant=alice, + raw_selected_cost=cost, + selected_cost=cost, + ) + rebuild_account(sub.fact_key, config) + before = list( + ParticipantSnapshot.objects.filter(observation__account_id=7) + .order_by("id") + .values( + "raw_selected_cost", + "selected_cost", + "charged_cycle_percent", + "source_sub2api_user_id", + ) + ) + serializer = ParticipantWriteSerializer( + alice, data={"sub2api_user_id": None}, partial=True + ) + assert serializer.is_valid(), serializer.errors + serializer.save() + rebuild_account(sub.fact_key, config) + assert ( + list( + ParticipantSnapshot.objects.filter(observation__account_id=7) + .order_by("id") + .values( + "raw_selected_cost", + "selected_cost", + "charged_cycle_percent", + "source_sub2api_user_id", + ) + ) + == before + ) + + +def test_no_observation_explains_unknown_without_hiding_collected_usage(setup): + config, admin, account, alice, bob, keys, start = setup + event(account, keys[0], start + timedelta(minutes=10)) + summary = pool_summary(admin, account, config) + assert "尚无额度观测" in summary["accounts"][0]["quota_unavailable_reasons"] + member = next( + row for row in summary["members"] if row["participant_id"] == alice.id + ) + assert member["usage_usd"] == 10 + assert member["remaining_entitlement_usd"] is None + assert ( + "缺少历史份额依据或对应额度观测" + in member["account_breakdowns"][0]["quota_unavailable_reasons"] + ) + + +@pytest.mark.parametrize("readonly", [False, True]) +def test_request_key_alias_and_local_note_fallback_are_scoped(setup, readonly): + config, admin, account, alice, bob, keys, start = setup + first = event(account, keys[0], start + timedelta(minutes=1)) + first.alias = 'Le' + first.save(update_fields=['alias']) + second = event(account, keys[0], start + timedelta(minutes=2)) + keys[0].name = 'Laptop' + keys[0].save(update_fields=['name']) + private = event(account, keys[1], start + timedelta(minutes=3)) + private.alias = 'Other private alias' + private.save(update_fields=['alias']) + user, client, headers = member_client(account, alice) + path = 'cpa/requests' + if readonly: + SystemUserAPIKey.objects.create(user=user, key_hash=hash_api_key('alias-read-key'), hint='-key') + headers = {'HTTP_AUTHORIZATION': 'Bearer alias-read-key'} + path = 'v1/cpa/requests' + response = client.get(f'/api/{path}?account_id={account.id}', **headers) + assert response.status_code == 200 + rows = {row['id']: row for row in response.json()['data']['items']} + assert rows[first.id]['api_key_alias'] == 'Le' + assert rows[second.id]['api_key_alias'] == 'Laptop' + assert private.id not in rows + assert 'Other private alias' not in response.content.decode() + assert keys[0].key_hash not in response.content.decode() diff --git a/backend/monitor/tests/api/test_cpa_price_sync.py b/backend/monitor/tests/api/test_cpa_price_sync.py new file mode 100644 index 0000000..242b633 --- /dev/null +++ b/backend/monitor/tests/api/test_cpa_price_sync.py @@ -0,0 +1,170 @@ +from datetime import timedelta + +import httpx +import pytest +from django.test import Client + +from monitor.cpa.price_sync import ( + PriceCatalogError, + catalog_price, + fetch_catalog, + sync_missing_prices, +) +from monitor.models import AppSettings, CPAUsageEvent, ParticipantSnapshot +from monitor.replay import rebuild_account +from monitor.tests.api.test_cpa_participants import ( + event, + member_client, + observation, +) +from monitor.tests.api.test_cpa_participants import ( + setup as participant_setup, +) +from monitor.tests.helpers import create_cpa_account, jwt_login + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def setup(): + return participant_setup.__wrapped__() + + +def catalog(): + return { + "new-model": {"cost": {"input": 10, "cache_read": 1, "output": 50}}, + "gpt-test": {"cost": {"input": 99, "cache_read": 99, "output": 99}}, + } + + +def test_sync_missing_only_and_replay_existing_owned_events(setup, monkeypatch): + config, _admin, account, alice, _bob, keys, start = setup + observation(account, start, start, 0, 0) + raw = event(account, keys[0], start + timedelta(minutes=10), model="new-model") + last = observation(account, start, start + timedelta(hours=1), 10, 0) + event(account, keys[1], start + timedelta(minutes=10), model="unknown-alias") + rebuild_account(account.fact_key, config) + original = dict(CPAUsageEvent.objects.filter(pk=raw.pk).values().get()) + monkeypatch.setattr("monitor.cpa.price_sync.fetch_catalog", catalog) + client = Client() + headers, _ = jwt_login(client) + inventory = client.get( + f"/api/settings/cpa-pricing?account_id={account.id}", **headers + ).json()["data"] + assert inventory["missing_model_count"] == 2 + response = client.post( + f"/api/settings/cpa-pricing?account_id={account.id}", **headers + ) + assert response.status_code == 200, response.content + data = response.json()["data"] + assert [row["model"] for row in data["added"]] == ["new-model"] + assert data["missing_model_count"] == 1 + assert data["unresolved"][0]["model"] == "unknown-alias" + config.refresh_from_db() + assert config.cpa_model_pricing["gpt-test"]["input"] == "10" + assert config.cpa_model_pricing["new-model"] == { + "input": "10", + "cached_input": "1", + "output": "50", + } + assert ( + ParticipantSnapshot.objects.get( + observation=last, participant=alice + ).raw_selected_cost + == 10 + ) + alice.refresh_from_db() + assert alice.latest_balance_usd == 123 + assert CPAUsageEvent.objects.filter(pk=raw.pk).values().get() == original + assert not client.post( + f"/api/settings/cpa-pricing?account_id={account.id}", **headers + ).json()["data"]["added"] + + +def test_sync_respects_account_scope_and_manual_edit_while_fetching(setup, monkeypatch): + _config, _admin, account, _alice, _bob, keys, start = setup + hidden = create_cpa_account("other") + event(account, keys[0], start, model="new-model") + event(hidden, keys[0], start, model="other-model") + + def fetch_after_manual_save(): + latest = AppSettings.load() + latest.cpa_model_pricing["new-model"] = { + "input": "7", + "cached_input": "0.7", + "output": "8", + } + latest.save() + return {**catalog(), "other-model": catalog()["new-model"]} + + monkeypatch.setattr("monitor.cpa.price_sync.fetch_catalog", fetch_after_manual_save) + result = sync_missing_prices(account.id) + assert not result["added"] + assert result["pricing"]["new-model"]["input"] == "7" + assert "other-model" not in result["pricing"] + + +def test_pricing_admin_only_and_failures_do_not_change_settings(setup, monkeypatch): + config, _admin, account, alice, _bob, keys, start = setup + event(account, keys[0], start, model="new-model") + _user, client, headers = member_client(account, alice) + for method in (client.get, client.post): + assert method("/api/settings/cpa-pricing", **headers).status_code == 403 + headers, _ = jwt_login(client) + before = dict(config.cpa_model_pricing) + + def fail(): + raise PriceCatalogError("同步失败") + + monkeypatch.setattr("monitor.cpa.price_sync.fetch_catalog", fail) + assert client.post("/api/settings/cpa-pricing", **headers).status_code == 502 + config.refresh_from_db() + assert config.cpa_model_pricing == before + monkeypatch.setattr("monitor.cpa.price_sync.fetch_catalog", catalog) + + def fail_replay(*args, **kwargs): + raise ValueError("bad replay") + + monkeypatch.setattr("monitor.cpa.price_sync.rebuild_account", fail_replay) + assert client.post("/api/settings/cpa-pricing", **headers).status_code == 409 + config.refresh_from_db() + assert config.cpa_model_pricing == before + + +@pytest.mark.parametrize("invalid", [None, -1, "NaN", "Infinity", True]) +def test_catalog_rejects_incomplete_invalid_prices(invalid): + models = {"new-model": {"cost": {"input": 10, "cache_read": invalid, "output": 50}}} + assert catalog_price(models, "new-model") is None + assert catalog_price(catalog(), "my-new-model") is None + assert catalog_price(catalog(), "new-model-2026-09-08") is None + assert catalog_price(catalog(), "new-model-latest")[0] == "new-model" + assert catalog_price(catalog(), "openai/new-model")[1]["cached_input"] == "1" + + +def test_no_missing_prices_does_not_fetch_catalog(setup, monkeypatch): + _config, _admin, account, _alice, _bob, keys, start = setup + event(account, keys[0], start) + + def fail(): + raise AssertionError("unnecessary network request") + + monkeypatch.setattr("monitor.cpa.price_sync.fetch_catalog", fail) + assert sync_missing_prices()["added"] == [] + + +def test_catalog_fetch_validates_shape_and_does_not_send_usage(monkeypatch): + calls = [] + + def transport(request): + calls.append(request) + return httpx.Response( + 200, + json={"openai": {"models": catalog()}, "other-provider": {"models": {}}}, + ) + + client = httpx.Client(transport=httpx.MockTransport(transport)) + monkeypatch.setattr("monitor.cpa.price_sync.httpx.stream", client.stream) + assert fetch_catalog() == catalog() + assert str(calls[0].url) == "https://models.dev/api.json" + assert calls[0].content == b"" + client.close() diff --git a/backend/monitor/tests/api/test_cpa_quota_status.py b/backend/monitor/tests/api/test_cpa_quota_status.py new file mode 100644 index 0000000..afd7a13 --- /dev/null +++ b/backend/monitor/tests/api/test_cpa_quota_status.py @@ -0,0 +1,338 @@ +from datetime import timedelta + +import pytest +from django.test import Client +from django.utils import timezone + +from monitor.cpa.quota_status import quota_detail, reset_credits +from monitor.integrations.cpa import CPAError +from monitor.models import ( + CPAAccountCollectionInterval, + CPAQuotaResetRequest, + SystemUserPageAccess, + PagePermission, +) +from monitor.tests.api.test_cpa_participants import setup, event, member_client # noqa: F401 +from monitor.tests.helpers import jwt_login, create_cpa_account + +pytestmark = pytest.mark.django_db + + +def body(start, used=10): + return { + "rate_limit": { + "primary_window": { + "used_percent": 20, + "limit_window_seconds": 18000, + "reset_at": int((start + timedelta(hours=5)).timestamp()), + }, + "secondary_window": { + "used_percent": used, + "limit_window_seconds": 604800, + "reset_at": int((start + timedelta(days=7)).timestamp()), + }, + }, + "additional_rate_limits": [ + { + "limit_name": "Spark", + "rate_limit": { + "secondary_window": { + "used_percent": 0, + "limit_window_seconds": 604800, + "reset_at": int((start + timedelta(days=7)).timestamp()), + } + }, + } + ], + "rate_limit_reset_credits": {"available_count": 1}, + } + + +def test_quota_cards_and_prediction_require_complete_priced_coverage(setup): + config, admin, account, alice, bob, keys, start = setup + start = start.replace(microsecond=0) + event(account, keys[0], start + timedelta(minutes=10)) + detail = quota_detail(account, config, timezone.now(), body(start)) + assert len(detail["windows"]) == 3 + assert detail["windows"][0]["label"] == "周限额" + assert detail["windows"][0]["prediction"] is None + assert detail["windows"][0]["current"]["metrics"]["usage_usd"] == 10 + assert detail["windows"][2]["current"] is None + assert detail["reset"]["available_count"] == 1 + CPAAccountCollectionInterval.objects.create( + account=account, session_key="test", connected_at=start + ) + detail = quota_detail(account, config, timezone.now(), body(start)) + assert detail["windows"][0]["prediction"] is None + assert "模型范围" in detail["windows"][0]["notice"] + standard_body = body(start) + standard_body.pop("additional_rate_limits") + detail = quota_detail(account, config, timezone.now(), standard_body) + assert detail["windows"][0]["prediction"]["usage_usd"] == 100 + event(account, keys[0], start + timedelta(minutes=11), model="unknown") + detail = quota_detail(account, config, timezone.now(), standard_body) + assert detail["windows"][0]["prediction"] is None + assert detail["windows"][0]["current"]["metrics"]["unpriced_request_count"] == 1 + + +@pytest.mark.parametrize("value", [None, -1, "NaN", "Infinity", True, 1.5]) +def test_invalid_credit_counts_stay_unknown(value): + assert ( + reset_credits({"rate_limit_reset_credits": {"available_count": value}}) is None + ) + + +def test_expired_and_malformed_windows_do_not_invent_statistics(setup): + config, admin, account, alice, bob, keys, start = setup + result = quota_detail( + account, config, timezone.now(), body(start - timedelta(days=8)) + ) + assert all(w["prediction"] is None for w in result["windows"]) + malformed = { + "rate_limit": { + "primary_window": { + "used_percent": "NaN", + "reset_at": 1e300, + "limit_window_seconds": -1, + } + } + } + row = quota_detail(account, config, timezone.now(), malformed)["windows"][0] + assert ( + row["reset_at"] is None + and row["used_percent"] is None + and row["current"] is None + ) + + +@pytest.fixture +def reset_client(setup, monkeypatch): + config, admin, account, alice, bob, keys, start = setup + calls = [] + + class FakeClient: + fail = False + + def __init__(self, config): + pass + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def query_usage_payload(self, index): + return {}, body(start) + + def consume_reset_credit(self, index, request_id): + calls.append((index, request_id)) + if self.fail: + raise CPAError("lost response") + + monkeypatch.setattr("monitor.views.cpa_quota_reset.CPAClient", FakeClient) + return FakeClient, calls + + +def test_reset_requires_owner_and_confirmation_and_is_idempotent(setup, reset_client): + config, admin, account, alice, bob, keys, start = setup + fake, calls = reset_client + alice.is_owner = True + alice.save() + user, client, headers = member_client(account, alice) + SystemUserPageAccess.objects.create( + user=user, page_code=PagePermission.ACCOUNT_STATUS + ) + preview = client.post( + f"/api/account-status/cpa/{account.pk}/reset-preview", **headers + ) + assert preview.status_code == 201, preview.content + plan = preview.json()["data"] + url = f"/api/account-status/cpa/{account.pk}/resets/{plan['id']}/confirm" + assert ( + client.post(url, {}, content_type="application/json", **headers).status_code + == 400 + ) + assert not calls + assert ( + client.post( + url, {"confirmed": True}, content_type="application/json", **headers + ).status_code + == 200 + ) + assert ( + client.post( + url, {"confirmed": True}, content_type="application/json", **headers + ).status_code + == 200 + ) + assert calls == [(account.cpa_auth_index, plan["id"])] + peer_user, peer, auth = member_client(account, bob, "peer") + SystemUserPageAccess.objects.create( + user=peer_user, page_code=PagePermission.ACCOUNT_STATUS + ) + assert ( + peer.post( + f"/api/account-status/cpa/{account.pk}/reset-preview", **auth + ).status_code + == 403 + ) + assert peer.post( + url, {"confirmed": True}, content_type="application/json", **auth + ).status_code == 403 + other = create_cpa_account("other-reset") + assert ( + client.post( + f"/api/account-status/cpa/{other.pk}/reset-preview", **headers + ).status_code + == 404 + ) + + +def test_admin_reset_retries_uncertain_result_with_same_id(setup, reset_client): + config, admin, account, alice, bob, keys, start = setup + fake, calls = reset_client + client = Client() + headers, _ = jwt_login(client) + plan = client.post( + f"/api/account-status/cpa/{account.pk}/reset-preview", **headers + ).json()["data"] + url = f"/api/account-status/cpa/{account.pk}/resets/{plan['id']}/confirm" + fake.fail = True + assert ( + client.post( + url, {"confirmed": True}, content_type="application/json", **headers + ).status_code + == 502 + ) + assert CPAQuotaResetRequest.objects.get(pk=plan["id"]).status == "unknown" + retry = client.post( + f"/api/account-status/cpa/{account.pk}/reset-preview", **headers + ).json()["data"] + assert retry["id"] == plan["id"] + fake.fail = False + assert ( + client.post( + url, {"confirmed": True}, content_type="application/json", **headers + ).status_code + == 200 + ) + assert calls[0] == calls[1] + + +def test_role_revoked_after_preview_cannot_confirm(setup, reset_client): + config, admin, account, alice, bob, keys, start = setup + from monitor.models import PagePermission + + alice.is_owner = True + alice.save() + user, client, headers = member_client(account, alice) + SystemUserPageAccess.objects.create( + user=user, page_code=PagePermission.ACCOUNT_STATUS + ) + plan = client.post( + f"/api/account-status/cpa/{account.pk}/reset-preview", **headers + ).json()["data"] + alice.is_owner = False + alice.save() + url = f"/api/account-status/cpa/{account.pk}/resets/{plan['id']}/confirm" + assert ( + client.post( + url, {"confirmed": True}, content_type="application/json", **headers + ).status_code + == 403 + ) + assert not reset_client[1] + + +def test_expired_preview_cannot_consume_credit(setup, reset_client): + account = setup[2] + client = Client() + headers, _ = jwt_login(client) + plan = client.post( + f"/api/account-status/cpa/{account.pk}/reset-preview", **headers + ).json()["data"] + CPAQuotaResetRequest.objects.filter(pk=plan["id"]).update( + expires_at=timezone.now() - timedelta(seconds=1) + ) + response = client.post( + f"/api/account-status/cpa/{account.pk}/resets/{plan['id']}/confirm", + {"confirmed": True}, content_type="application/json", **headers, + ) + assert response.status_code == 400 + assert not reset_client[1] + + +def test_revoked_account_authorization_cannot_confirm(setup, reset_client): + _, _, account, alice, *_ = setup + alice.is_owner = True + alice.save() + user, client, headers = member_client(account, alice) + SystemUserPageAccess.objects.create(user=user, page_code=PagePermission.ACCOUNT_STATUS) + plan = client.post( + f"/api/account-status/cpa/{account.pk}/reset-preview", **headers + ).json()["data"] + account.authorized_users.remove(user) + response = client.post( + f"/api/account-status/cpa/{account.pk}/resets/{plan['id']}/confirm", + {"confirmed": True}, content_type="application/json", **headers, + ) + assert response.status_code == 404 + assert not reset_client[1] + + +def test_admin_can_resolve_owner_uncertain_request(setup, reset_client): + _, _, account, alice, *_ = setup + alice.is_owner = True + alice.save() + user, client, headers = member_client(account, alice) + SystemUserPageAccess.objects.create(user=user, page_code=PagePermission.ACCOUNT_STATUS) + plan = client.post( + f"/api/account-status/cpa/{account.pk}/reset-preview", **headers + ).json()["data"] + CPAQuotaResetRequest.objects.filter(pk=plan["id"]).update(status="unknown") + admin_client = Client() + admin_headers, _ = jwt_login(admin_client) + preview = admin_client.post( + f"/api/account-status/cpa/{account.pk}/reset-preview", **admin_headers + ).json()["data"] + assert preview["id"] == plan["id"] + response = admin_client.post( + f"/api/account-status/cpa/{account.pk}/resets/{plan['id']}/confirm", + {"confirmed": True}, content_type="application/json", **admin_headers, + ) + assert response.status_code == 200 + assert reset_client[1] == [(account.cpa_auth_index, plan["id"])] + + +def test_redemption_client_uses_verified_endpoint_and_stable_request_id(monkeypatch): + import json + from monitor.integrations.cpa import CPAClient + from monitor.models import AppSettings + + client = CPAClient(AppSettings.load(), management_key="synthetic-key") + sent = [] + monkeypatch.setattr( + client, + "get_codex_account", + lambda key: {"chatgpt_account_id": "synthetic-account"}, + ) + + def request(method, path, **kwargs): + sent.append((method, path, kwargs["json_body"])) + return {"status_code": 200} + + monkeypatch.setattr(client, "_request", request) + try: + client.consume_reset_credit("auth-test", "stable-redemption-id") + finally: + client.client.close() + method, path, body = sent[0] + assert method == "POST" and path == "api-call" + assert ( + body["url"] + == "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume" + ) + assert json.loads(body["data"]) == {"redeem_request_id": "stable-redemption-id"} + assert body["header"]["Authorization"] == "Bearer $TOKEN$" + assert body["auth_index"] == "auth-test" diff --git a/backend/monitor/tests/api/test_cpa_request_summary.py b/backend/monitor/tests/api/test_cpa_request_summary.py new file mode 100644 index 0000000..45aab27 --- /dev/null +++ b/backend/monitor/tests/api/test_cpa_request_summary.py @@ -0,0 +1,93 @@ +from datetime import timedelta + +import pytest + +from monitor.api_auth import hash_api_key +from monitor.models import SystemUserAPIKey +from monitor.tests.api.test_cpa_participants import event, member_client +from monitor.tests.api.test_cpa_participants import setup as participant_setup + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def setup(): + return participant_setup.__wrapped__() + + +def test_request_summary_full_scope_price_rules_and_pagination(setup): + config, _admin, account, alice, _bob, keys, start = setup + config.cpa_fast_multiplier = 3 + config.cpa_double_billing_enabled = True + config.cpa_double_billing_threshold_tokens = 500_000 + config.cpa_double_billing_multiplier = 2 + config.save() + first = event(account, keys[0], start) + first.cached_input_tokens = 100_000 + first.output_tokens = 100_000 + first.reasoning_tokens = 20_000 + first.total_tokens = 1_100_000 + first.requested_service_tier = "priority" + first.response_service_tier = "auto" + first.latency_ms = 10_000 + first.ttft_ms = 1000 + first.save() + event(account, keys[0], start + timedelta(minutes=1), model="unknown", failed=True) + keys[0].bindings.update(started_at=start - timedelta(days=9)) + event(account, keys[0], start - timedelta(days=8)) + event(account, keys[1], start, tokens=99_000_000) + _user, client, headers = member_client(account, alice) + path = f"/api/cpa/requests?account_id={account.id}&include_summary=true&page_size=1" + result = client.get(path, **headers).json()["data"] + assert result["total"] == 2 + assert len(result["items"]) == 1 + summary = result["summary"] + assert summary["request_count"] == 2 + assert summary["failed_count"] == 1 + assert summary["total_tokens"] == 2_100_000 + assert summary["cached_input_tokens"] == 100_000 + assert summary["reasoning_tokens"] == 20_000 + assert summary["unpriced_request_count"] == 1 + assert summary["usage_usd"] == pytest.approx((9 + 0.1 + 3) * 3 * 2) + assert summary["average_latency_ms"] == 10_000 + assert summary["average_ttft_ms"] == 1000 + assert client.get(path + "&page=2", **headers).json()["data"]["summary"] == summary + failed = client.get(path + "&failed=true", **headers).json()["data"]["summary"] + assert failed["request_count"] == 1 + assert failed["average_latency_ms"] is None + assert ( + client.get(path + "&model=does-not-exist", **headers).json()["data"]["summary"][ + "request_count" + ] + == 0 + ) + assert client.get(path + "&days=30", **headers).json()["data"]["total"] == 3 + assert client.get(path + "&days=91", **headers).status_code == 400 + + +def test_summary_readonly_key_is_scoped_and_opt_in(setup): + _config, _admin, account, alice, bob, keys, start = setup + event(account, keys[0], start) + event(account, keys[1], start, tokens=99_000_000) + user, client, _headers = member_client(account, alice) + SystemUserAPIKey.objects.create( + user=user, key_hash=hash_api_key("summary-test-key"), hint="test" + ) + headers = {"HTTP_AUTHORIZATION": "Bearer summary-test-key"} + path = f"/api/v1/cpa/requests?account_id={account.id}" + assert client.get(path, **headers).json()["data"]["summary"] is None + response = client.get(path + "&include_summary=true", **headers) + assert response.status_code == 200 + assert response.json()["data"]["summary"]["input_tokens"] == 1_000_000 + assert ( + client.get( + path + f"&include_summary=true&key_id={keys[1].id}", **headers + ).status_code + == 404 + ) + assert ( + client.get( + path + f"&include_summary=true&participant_id={bob.id}", **headers + ).status_code + == 400 + ) diff --git a/backend/monitor/tests/api/test_particle_trajectory.py b/backend/monitor/tests/api/test_particle_trajectory.py index 10ae77f..fa0a1a3 100644 --- a/backend/monitor/tests/api/test_particle_trajectory.py +++ b/backend/monitor/tests/api/test_particle_trajectory.py @@ -108,7 +108,7 @@ def test_particle_trajectory_reruns_current_segment_without_writes(): assert response.status_code == 200 data = response.json()["data"] assert data["available"] is True - assert data["algorithm"] == "particle_filter_v9" + assert data["algorithm"] == "particle_filter_v10" assert data["particle_count"] == 480 assert data["representative_particle_count"] == 96 assert data["segment"]["observation_count"] == 2 diff --git a/backend/monitor/tests/api/test_readonly_api.py b/backend/monitor/tests/api/test_readonly_api.py index 7d1e58a..925c24a 100644 --- a/backend/monitor/tests/api/test_readonly_api.py +++ b/backend/monitor/tests/api/test_readonly_api.py @@ -147,6 +147,8 @@ def test_api_key_lifecycle_and_scope(): assert index_data["openapi"] == "/api/v1/openapi.json" assert index_data["authentication"]["scheme"] == "bearer" expected_endpoint_paths = { + "/api/v1/cpa/summary", + "/api/v1/cpa/requests", "/api/v1/accounts", "/api/v1/dashboard", "/api/v1/recommendations", @@ -515,6 +517,7 @@ def test_system_user_api_key_follows_live_page_and_data_permissions(): index_data = index.json()["data"] assert index_data["authentication"]["permissions"] == "page_scoped" expected_paths = { + "/api/v1/cpa/summary", "/api/v1/accounts", "/api/v1/account-status", "/api/v1/participants", diff --git a/backend/monitor/tests/migrations/test_0052_cpa_participants.py b/backend/monitor/tests/migrations/test_0052_cpa_participants.py new file mode 100644 index 0000000..ad17d80 --- /dev/null +++ b/backend/monitor/tests/migrations/test_0052_cpa_participants.py @@ -0,0 +1,64 @@ +import pytest +from django.db import connection +from django.db.migrations.executor import MigrationExecutor +from django.utils import timezone + + +@pytest.mark.django_db(transaction=True) +def test_cpa_migration_preserves_users_grants_and_unclaimed_events(): + executor = MigrationExecutor(connection) + latest = executor.loader.graph.leaf_nodes() + before = [("monitor", "0051_pooled_research_raw_only")] + after = [("monitor", "0052_cpa_participants")] + try: + executor.migrate(before) + apps = executor.loader.project_state(before).apps + Participant = apps.get_model("monitor", "Participant") + Pool = apps.get_model("monitor", "QuotaPool") + Account = apps.get_model("monitor", "MonitoredAccount") + Event = apps.get_model("monitor", "CPAUsageEvent") + User = apps.get_model("auth", "User") + user = User.objects.create(username="legacy-rider") + person = Participant.objects.create(name="原成员", sub2api_user_id=99) + person.authorized_users.add(user) + pool = Pool.objects.create(name="原池") + account = Account.objects.create( + provider="cpa", cpa_auth_index="legacy-cpa", pool=pool, name="原 CPA" + ) + event = Event.objects.create( + account=account, + occurred_at=timezone.now(), + model="gpt-test", + event_fingerprint="legacy-event", + api_key_hash="a" * 64, + api_key_hint="9999", + total_tokens=100, + ) + executor = MigrationExecutor(connection) + executor.migrate(after) + apps = executor.loader.project_state(after).apps + assert ( + apps.get_model("monitor", "Participant") + .objects.get(pk=person.pk) + .sub2api_user_id + == 99 + ) + assert ( + apps.get_model("monitor", "Participant") + .objects.get(pk=person.pk) + .authorized_users.filter(pk=user.pk) + .exists() + ) + assert ( + apps.get_model("monitor", "CPAUsageEvent") + .objects.get(pk=event.pk) + .api_key_hash + == "a" * 64 + ) + assert not apps.get_model("monitor", "CPAKeyBinding").objects.exists() + assert not apps.get_model("monitor", "CPAQuotaContract").objects.exists() + apps.get_model("monitor", "Participant").objects.create( + name="CPA 新成员", sub2api_user_id=None + ) + finally: + MigrationExecutor(connection).migrate(latest) diff --git a/backend/monitor/tests/migrations/test_0054_cpa_owner.py b/backend/monitor/tests/migrations/test_0054_cpa_owner.py new file mode 100644 index 0000000..8829665 --- /dev/null +++ b/backend/monitor/tests/migrations/test_0054_cpa_owner.py @@ -0,0 +1,49 @@ +import pytest +from django.db import connection +from django.db.migrations.executor import MigrationExecutor +from django.utils import timezone + + +@pytest.mark.django_db(transaction=True) +def test_upgrade_uses_owner_role_prospectively_and_preserves_events(): + executor = MigrationExecutor(connection) + latest = executor.loader.graph.leaf_nodes() + before = [("monitor", "0053_cpa_claim_events")] + try: + executor.migrate(before) + apps = executor.loader.project_state(before).apps + Pool = apps.get_model("monitor", "QuotaPool") + Person = apps.get_model("monitor", "Participant") + Account = apps.get_model("monitor", "MonitoredAccount") + Allocation = apps.get_model("monitor", "PoolParticipant") + Event = apps.get_model("monitor", "CPAUsageEvent") + pool = Pool.objects.create(name="existing") + owner = Person.objects.create(name="any owner name", is_owner=True) + Allocation.objects.create(pool=pool, participant=owner, share_percent=50) + account = Account.objects.create( + provider="cpa", cpa_auth_index="existing", pool=pool, name="CPA" + ) + old = Event.objects.create( + account=account, + occurred_at=timezone.now(), + event_fingerprint="old", + api_key_hash="", + model="unknown", + ) + executor = MigrationExecutor(connection) + executor.migrate(latest) + apps = executor.loader.project_state(latest).apps + rule = apps.get_model("monitor", "CPAAccountOwnerBinding").objects.get( + account_id=account.pk + ) + assert rule.participant_id == owner.pk + assert rule.started_at > old.occurred_at + assert not apps.get_model("monitor", "CPAClaimEvent").objects.exists() + assert ( + apps.get_model("monitor", "CPAUsageEvent") + .objects.get(pk=old.pk) + .api_key_hash + == "" + ) + finally: + MigrationExecutor(connection).migrate(latest) diff --git a/backend/monitor/tests/migrations/test_0056_cpa_billing.py b/backend/monitor/tests/migrations/test_0056_cpa_billing.py new file mode 100644 index 0000000..7aabf94 --- /dev/null +++ b/backend/monitor/tests/migrations/test_0056_cpa_billing.py @@ -0,0 +1,59 @@ +import pytest +from django.db import connection +from django.db.migrations.executor import MigrationExecutor +from django.utils import timezone + + +@pytest.mark.django_db(transaction=True) +def test_billing_upgrade_preserves_identity_ownership_and_requests(): + executor = MigrationExecutor(connection) + latest = executor.loader.graph.leaf_nodes() + before = [("monitor", "0055_cpaquotaresetrequest")] + try: + executor.migrate(before) + apps = executor.loader.project_state(before).apps + pool = apps.get_model("monitor", "QuotaPool").objects.create( + name="Preserved pool" + ) + member = apps.get_model("monitor", "Participant").objects.create( + name="Owner", is_owner=True, sub2api_user_id=912, latest_balance_usd=123 + ) + account = apps.get_model("monitor", "MonitoredAccount").objects.create( + provider="cpa", cpa_auth_index="billing-upgrade", pool=pool, name="CPA" + ) + apps.get_model("monitor", "PoolParticipant").objects.create( + pool=pool, participant=member, share_percent=50 + ) + event = apps.get_model("monitor", "CPAUsageEvent").objects.create( + account=account, + occurred_at=timezone.now(), + event_fingerprint="preserved-event", + api_key_hash="preserved-hash", + model="unknown", + ) + executor = MigrationExecutor(connection) + executor.migrate(latest) + apps = executor.loader.project_state(latest).apps + assert ( + apps.get_model("monitor", "QuotaPool") + .objects.get(pk=pool.pk) + .cpa_billing_anchor + is None + ) + saved = apps.get_model("monitor", "Participant").objects.get(pk=member.pk) + assert saved.sub2api_user_id == 912 and saved.latest_balance_usd == 123 + assert ( + apps.get_model("monitor", "CPAUsageEvent") + .objects.get(pk=event.pk) + .api_key_hash + == "preserved-hash" + ) + assert not apps.get_model("monitor", "CPAClaimEvent").objects.exists() + assert ( + apps.get_model("monitor", "PoolParticipant") + .objects.get(pool_id=pool.pk) + .participant_id + == member.pk + ) + finally: + MigrationExecutor(connection).migrate(latest) diff --git a/backend/monitor/urls.py b/backend/monitor/urls.py index db86f87..9e16a2d 100644 --- a/backend/monitor/urls.py +++ b/backend/monitor/urls.py @@ -9,6 +9,7 @@ PasswordView, RefreshView, ) +from .views.cpa_quota_reset import CPAQuotaResetPreviewView, CPAQuotaResetConfirmView from .views.account_status import AccountStatusView, ReadOnlyAccountStatusView from .views.dashboard import ( APIApplyParticipantRecommendationView, @@ -81,8 +82,10 @@ ) from .views.research import ResearchSettingsView, ResearchRunView +from .views.cpa_pricing import CPAPricingView urlpatterns = [ + path("settings/cpa-pricing", CPAPricingView.as_view()), path("settings/research", ResearchSettingsView.as_view()), path("settings/research/run", ResearchRunView.as_view()), path("health", HealthView.as_view()), @@ -102,6 +105,8 @@ path("ip-blocks", BlockedIPAddressListView.as_view()), path("ip-blocks/", BlockedIPAddressDetailView.as_view()), path("dashboard", DashboardView.as_view()), + path("account-status/cpa//reset-preview", CPAQuotaResetPreviewView.as_view()), + path("account-status/cpa//resets//confirm", CPAQuotaResetConfirmView.as_view()), path("account-status", AccountStatusView.as_view()), path( "dashboard/participants//apply-recommendation", @@ -202,3 +207,29 @@ ), path("monitor/run", RunMonitorView.as_view()), ] + +from .views.cpa_participants import ( + CPABillingConfigView, + CPAUnassignedClaimPreviewView, + CPAKeyListView, + CPABindingDetailView, + CPAClaimPreviewView, + CPAClaimApplyView, + CPASummaryView, + CPARequestsView, + ReadOnlyCPASummaryView, + ReadOnlyCPARequestsView, +) + +urlpatterns += [ + path("cpa/unassigned/preview", CPAUnassignedClaimPreviewView.as_view()), + path("cpa/keys", CPAKeyListView.as_view()), + path("cpa/bindings/", CPABindingDetailView.as_view()), + path("cpa/claims/preview", CPAClaimPreviewView.as_view()), + path("cpa/claims//apply", CPAClaimApplyView.as_view()), + path("cpa/billing-config", CPABillingConfigView.as_view()), + path("cpa/summary", CPASummaryView.as_view()), + path("cpa/requests", CPARequestsView.as_view()), + path("v1/cpa/summary", ReadOnlyCPASummaryView.as_view()), + path("v1/cpa/requests", ReadOnlyCPARequestsView.as_view()), +] diff --git a/backend/monitor/views/account_status.py b/backend/monitor/views/account_status.py index 69d9acf..f415b69 100644 --- a/backend/monitor/views/account_status.py +++ b/backend/monitor/views/account_status.py @@ -11,6 +11,7 @@ from ..billing_correction.domain import BillingCorrectionRules, CorrectionAmounts from ..billing_correction.observations import interval_corrections from ..api_auth import APIKeyAuthentication +from ..cpa.quota_status import quota_detail from ..cpa.usage import cpa_events_cost from ..integrations.cpa import CPAClient, CPAError from ..integrations.sub2api import Sub2APIClient, Sub2APIError, WeeklyWindow @@ -174,6 +175,8 @@ def _cpa_status_rows( "temp_unschedulable_reason": None, "error_message": upstream["status_message"] or None, } + window = None + client.last_usage_body = None try: window = client.query_weekly_window(account.cpa_auth_index or "") reset_at = datetime.fromtimestamp( @@ -210,6 +213,9 @@ def _cpa_status_rows( except CPAError as exc: row["warnings"].append(f"额度状态:{exc}") + row["cpa_quota"] = quota_detail( + account, config, sampled_at, client.last_usage_body, window + ) started_at = sampled_at - timedelta(days=STATS_DAYS) events = CPAUsageEvent.objects.filter( account=account, @@ -383,6 +389,26 @@ def get(self, request): rows_by_id, sampled_at, ) + from .cpa_quota_reset import can_reset_quota + + for account in cpa_accounts: + row = rows_by_id[account.pk] + detail = row.get("cpa_quota") + if detail is None: + detail = row["cpa_quota"] = quota_detail(account, config, sampled_at) + detail["reset"]["can_reset"] = can_reset_quota(request.user, account) + detail["reset"]["history"] = [ + { + "id": str(record.pk), + "status": record.status, + "created_at": record.created_at.isoformat(), + "finished_at": record.finished_at.isoformat() + if record.finished_at else None, + } + for record in account.cpa_reset_requests.exclude( + status__in=["pending", "cancelled"] + ).order_by("-created_at")[:10] + ] if cpa_error: errors.append(cpa_error) data["connection_error"] = ";".join(dict.fromkeys(errors)) or None diff --git a/backend/monitor/views/cpa_participants.py b/backend/monitor/views/cpa_participants.py new file mode 100644 index 0000000..6a99892 --- /dev/null +++ b/backend/monitor/views/cpa_participants.py @@ -0,0 +1,371 @@ +"""CPA carpool API. Administrative identity writes and scoped reads stay separate.""" + +from datetime import timedelta + +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework import serializers + +from ..api_auth import APIKeyAuthentication +from ..cpa.participants import ( + apply_claim, + bind_key, + ownership_filter, + preview_claim, + unbind_key, +) +from ..cpa.reporting import pool_summary +from ..cpa.request_summary import request_summary +from ..cpa.usage import cpa_event_cost +from ..models import ( + AppSettings, + CPAAPIKey, + CPAClaimPlan, + CPAKeyBinding, + CPAUsageEvent, + PagePermission, + Participant, +) +from .base import AdminAPIView, PageAccessAPIView, error, ok +from .query_params import monitored_account_query + + +class CPAErrorEnvelope: + def handle_exception(self, exc): + from ..history_state import LeaseBusyError + + if isinstance(exc, LeaseBusyError): + return error("账号正在采集或重算,请稍后重试", 409) + response = super().handle_exception(exc) + + def first_message(value): + if isinstance(value, dict): + return first_message(next(iter(value.values()), "请求失败")) + if isinstance(value, (list, tuple)): + return first_message(value[0]) if value else "请求失败" + return str(value) + + result = error( + first_message(response.data), response.status_code, response.data + ) + result.exception = True + for name, value in response.items(): + result[name] = value + return result + + +class CPAAdminView(CPAErrorEnvelope, AdminAPIView): + pass + + +class CPAReadView(CPAErrorEnvelope, PageAccessAPIView): + pass + + +def cpa_account(request, *, enabled_only=True): + if not request.query_params.get("account_id"): + raise serializers.ValidationError("必须指定 account_id") + try: + account = monitored_account_query(request, enabled_only=enabled_only) + except ValueError as exc: + raise serializers.ValidationError(str(exc)) from exc + if account is None or account.provider != "cpa": + raise serializers.ValidationError("请选择已授权的 CPA 账号") + return account + + +def binding_data(binding): + return { + "id": binding.id, + "key_id": binding.key_id, + "hint": binding.key.hint, + "name": binding.key.name, + "participant_id": binding.participant_id, + "participant_name": binding.participant.name, + "started_at": binding.started_at.isoformat(), + "ended_at": binding.ended_at.isoformat() if binding.ended_at else None, + } + + +def claim_data(plan): + return { + "id": str(plan.id), + "key_id": plan.key_id, + "account_id": plan.account_id, + "participant_id": plan.participant_id, + "started_at": plan.started_at.isoformat(), + "ended_at": plan.ended_at.isoformat(), + "expires_at": plan.expires_at.isoformat(), + "applied_at": plan.applied_at.isoformat() if plan.applied_at else None, + **plan.preview, + } + + +class BindingWrite(serializers.Serializer): + participant_id = serializers.PrimaryKeyRelatedField( + queryset=Participant.objects.filter(enabled=True), source="participant" + ) + raw_key = serializers.CharField( + required=False, allow_blank=True, max_length=4096, write_only=True + ) + observed_hash = serializers.RegexField(r"^[a-f0-9]{64}$", required=False) + name = serializers.CharField(required=False, allow_blank=True, max_length=80) + + def validate(self, attrs): + if bool(attrs.get("raw_key")) == bool(attrs.get("observed_hash")): + raise serializers.ValidationError("请选择已采集 Key 或输入完整 Key,二选一") + return attrs + + +class CPAKeyListView(CPAAdminView): + def get(self, request): + keys = list( + CPAAPIKey.objects.prefetch_related("bindings__participant").order_by("id") + ) + known = {key.key_hash for key in keys} + observed = ( + CPAUsageEvent.objects.exclude(api_key_hash="") + .exclude(api_key_hash__in=known) + .order_by() + .values("api_key_hash", "api_key_hint") + .distinct() + ) + return ok( + { + "keys": [ + { + "id": key.id, + "hint": key.hint, + "name": key.name, + "observed_hash": key.key_hash, + "bindings": [binding_data(row) for row in key.bindings.all()], + } + for key in keys + ], + "unregistered": [ + {"observed_hash": row["api_key_hash"], "hint": row["api_key_hint"]} + for row in observed + ], + } + ) + + def post(self, request): + serializer = BindingWrite(data=request.data) + serializer.is_valid(raise_exception=True) + return ok( + binding_data(bind_key(user=request.user, **serializer.validated_data)), 201 + ) + + +class CPABindingDetailView(CPAAdminView): + def delete(self, request, binding_id): + get_object_or_404(CPAKeyBinding, pk=binding_id) + return ok(binding_data(unbind_key(binding_id))) + + def patch(self, request, binding_id): + binding = get_object_or_404(CPAKeyBinding, pk=binding_id) + name = serializers.CharField(max_length=80, allow_blank=True).run_validation( + request.data.get("name", "") + ) + binding.key.name = name + binding.key.save(update_fields=["name"]) + return ok(binding_data(binding)) + + +class ClaimWrite(serializers.Serializer): + key_id = serializers.PrimaryKeyRelatedField( + queryset=CPAAPIKey.objects.all(), source="key" + ) + participant_id = serializers.PrimaryKeyRelatedField( + queryset=Participant.objects.filter(enabled=True), source="participant" + ) + started_at = serializers.DateTimeField() + ended_at = serializers.DateTimeField() + + +class CPAClaimPreviewView(CPAAdminView): + def post(self, request): + serializer = ClaimWrite(data=request.data) + serializer.is_valid(raise_exception=True) + return ok( + claim_data(preview_claim(user=request.user, **serializer.validated_data)), + 201, + ) + + +class CPAUnassignedClaimPreviewView(CPAAdminView): + def post(self, request): + from ..cpa.account_owner import preview_unassigned_claim + account = cpa_account(request) + binding = account.cpa_owner_bindings.filter(ended_at__isnull=True).select_related("participant").first() + if binding is None: + raise serializers.ValidationError("请先在参与者管理中将池内唯一参与者设为车主") + # Owner is derived from the existing role; client cannot select a different recipient. + return ok(claim_data(preview_unassigned_claim(account=account, participant=binding.participant, user=request.user)), 201) + + +class CPAClaimApplyView(CPAAdminView): + def post(self, request, plan_id): + get_object_or_404(CPAClaimPlan, pk=plan_id) + return ok(claim_data(apply_claim(plan_id))) + + +class CPASummaryView(CPAReadView): + required_page_permissions = ( + PagePermission.DASHBOARD, + PagePermission.PARTICIPANTS, + PagePermission.STATISTICS, + ) + + def get(self, request): + return ok(pool_summary(request.user, cpa_account(request))) + + +class CPARequestQuery(serializers.Serializer): + participant_id = serializers.IntegerField(required=False, min_value=1) + key_id = serializers.IntegerField(required=False, min_value=1) + model = serializers.CharField(required=False, max_length=255) + failed = serializers.BooleanField(required=False) + started_at = serializers.DateTimeField(required=False) + ended_at = serializers.DateTimeField(required=False) + page = serializers.IntegerField(default=1, min_value=1) + page_size = serializers.IntegerField(default=50, min_value=1, max_value=100) + include_summary = serializers.BooleanField(default=False) + days = serializers.IntegerField(default=7, min_value=1, max_value=90) + + def validate(self, attrs): + end = attrs.get("ended_at", timezone.now()) + start = attrs.get("started_at", end - timedelta(days=attrs["days"])) + if start >= end or end - start > timedelta(days=90): + raise serializers.ValidationError("请求查询时间范围须大于零且不超过 90 天") + attrs.update(started_at=start, ended_at=end) + return attrs + + +class CPARequestsView(CPAReadView): + required_page_permissions = (PagePermission.STATISTICS,) + + def get(self, request): + account = cpa_account(request, enabled_only=False) + # QueryDict makes DRF treat omitted booleans like unchecked HTML fields. + # An absent status filter must include both successful and failed requests. + serializer = CPARequestQuery(data=request.query_params.dict()) + serializer.is_valid(raise_exception=True) + params = serializer.validated_data + events = CPAUsageEvent.objects.filter(account=account) + owned = ( + list(request.user.quota_participants.values_list("id", flat=True)) + if not request.user.is_staff + else [] + ) + if not request.user.is_staff: + events = events.filter(ownership_filter(owned)) + if "participant_id" in params: + if not request.user.is_staff and params["participant_id"] not in owned: + raise serializers.ValidationError("只能查看本人授权参与者的请求") + events = events.filter(ownership_filter([params["participant_id"]])) + # Options are scoped before accepting client-supplied key/model filters. + key_hashes = events.order_by().values_list("api_key_hash", flat=True).distinct() + keys = CPAAPIKey.objects.filter(key_hash__in=key_hashes).order_by("id") + key_options = [ + {"id": key.id, "name": key.name, "hint": key.hint} for key in keys + ] + key_names = {key.key_hash: key.name for key in keys} + model_options = list( + events.order_by("model").values_list("model", flat=True).distinct() + ) + if "key_id" in params: + key = get_object_or_404(keys, pk=params["key_id"]) + events = events.filter(api_key_hash=key.key_hash) + events = events.filter( + occurred_at__gte=params["started_at"], occurred_at__lt=params["ended_at"] + ) + if params.get("model"): + events = events.filter(model=params["model"]) + if "failed" in params: + events = events.filter(failed=params["failed"]) + count = events.count() + offset = (params["page"] - 1) * params["page_size"] + config = AppSettings.load() + rows = [] + for event in events.order_by("-occurred_at", "-id")[ + offset : offset + params["page_size"] + ]: + cost, unknown = cpa_event_cost(event, config) + rows.append( + { + "id": event.id, + "occurred_at": event.occurred_at.isoformat(), + "request_id": event.request_id, + "api_key_hint": event.api_key_hint, + "api_key_alias": event.alias.strip() or key_names.get(event.api_key_hash, ""), + "model": event.model, + "endpoint": event.endpoint, + "input_tokens": event.input_tokens, + "cached_input_tokens": event.cached_input_tokens, + "output_tokens": event.output_tokens, + "reasoning_tokens": event.reasoning_tokens, + "total_tokens": event.total_tokens, + "failed": event.failed, + "latency_ms": event.latency_ms, + "ttft_ms": event.ttft_ms, + "usage_usd": float(cost), + "unpriced": unknown, + "requested_service_tier": event.requested_service_tier, + "response_service_tier": event.response_service_tier, + } + ) + return ok( + { + "account_id": account.id, + "items": rows, + "total": count, + "summary": request_summary(events, config) if params["include_summary"] else None, + "started_at": params["started_at"].isoformat(), + "ended_at": params["ended_at"].isoformat(), + "page": params["page"], + "page_size": params["page_size"], + "keys": key_options, + "models": model_options, + "cost_estimate": True, + "generated_at": timezone.now().isoformat(), + } + ) + + +class ReadOnlyCPASummaryView(CPASummaryView): + authentication_classes = [APIKeyAuthentication] + http_method_names = ["get", "head", "options"] + + +class ReadOnlyCPARequestsView(CPARequestsView): + authentication_classes = [APIKeyAuthentication] + http_method_names = ["get", "head", "options"] + + +class CPABillingConfigWrite(serializers.Serializer): + anchor_date = serializers.DateField(allow_null=True) + timezone = serializers.CharField(max_length=64) + + def validate_timezone(self, value): + from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + + try: + ZoneInfo(value) + except (ZoneInfoNotFoundError, ValueError): + raise serializers.ValidationError("请选择有效的 IANA 时区") + return value + + +class CPABillingConfigView(CPAAdminView): + def put(self, request): + account = cpa_account(request) + serializer = CPABillingConfigWrite(data=request.data) + serializer.is_valid(raise_exception=True) + pool = account.pool + pool.cpa_billing_anchor = serializer.validated_data["anchor_date"] + pool.cpa_billing_timezone = serializer.validated_data["timezone"] + pool.save( + update_fields=["cpa_billing_anchor", "cpa_billing_timezone", "updated_at"] + ) + return ok(pool_summary(request.user, account)) diff --git a/backend/monitor/views/cpa_pricing.py b/backend/monitor/views/cpa_pricing.py new file mode 100644 index 0000000..7e36cdc --- /dev/null +++ b/backend/monitor/views/cpa_pricing.py @@ -0,0 +1,32 @@ +"""Administrator-only pricing inventory and non-destructive catalog sync.""" + +from ..cpa.price_sync import PriceCatalogError, pricing_inventory, sync_missing_prices +from ..models import AppSettings +from .base import error, ok +from .cpa_participants import CPAAdminView, cpa_account + + +class CPAPricingView(CPAAdminView): + def get(self, request): + account = ( + cpa_account(request, enabled_only=False) + if request.query_params.get("account_id") + else None + ) + return ok( + pricing_inventory(AppSettings.load(), account.id if account else None) + ) + + def post(self, request): + account = ( + cpa_account(request, enabled_only=False) + if request.query_params.get("account_id") + else None + ) + try: + result = sync_missing_prices(account.id if account else None) + except PriceCatalogError as exc: + return error(str(exc), 502) + except ValueError: + return error("价格未保存:历史额度重算失败,请稍后重试", 409) + return ok(result) diff --git a/backend/monitor/views/cpa_quota_reset.py b/backend/monitor/views/cpa_quota_reset.py new file mode 100644 index 0000000..494e936 --- /dev/null +++ b/backend/monitor/views/cpa_quota_reset.py @@ -0,0 +1,156 @@ +"""Explicit confirmation and server-side owner authorization for quota resets.""" + +from datetime import timedelta + +from django.db import transaction +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.permissions import IsAuthenticated + +from ..access import HasPageAccess, visible_accounts_for +from ..cpa.quota_status import reset_credits +from ..history_state import fenced_fact_write, LeaseGuard +from ..integrations.cpa import CPAClient, CPAError +from ..models import AppSettings, CPAQuotaResetRequest, PagePermission +from .base import AuthenticatedAPIView, ok, error +from .cpa_participants import CPAErrorEnvelope + + +def can_reset_quota(user, account): + if not account.enabled: + return False + if user.is_staff: + return True + owner_ids = list( + account.pool.allocations.filter( + participant__enabled=True, participant__is_owner=True + ).values_list("participant_id", flat=True) + ) + return ( + len(owner_ids) == 1 and user.quota_participants.filter(pk=owner_ids[0]).exists() + ) + + +class ResetView(CPAErrorEnvelope, AuthenticatedAPIView): + permission_classes = [IsAuthenticated, HasPageAccess] + required_page_permissions = (PagePermission.ACCOUNT_STATUS,) + + def account(self, request, account_id): + account = get_object_or_404( + visible_accounts_for(request.user), + pk=account_id, + provider="cpa", + enabled=True, + ) + if not can_reset_quota(request.user, account): + raise PermissionDenied("仅管理员或该 CPA 池的车主可重置额度") + return account + + +def plan_data(plan, account): + return { + "id": str(plan.pk), + "account_id": account.pk, + "account_name": account.name, + "available_count": plan.available_count, + "status": plan.status, + "expires_at": plan.expires_at.isoformat(), + } + + +class CPAQuotaResetPreviewView(ResetView): + def post(self, request, account_id): + account = self.account(request, account_id) + with fenced_fact_write([account.fact_key]): + now = timezone.now() + account.cpa_reset_requests.filter( + status="pending", expires_at__lte=now + ).update(status="cancelled") + existing = ( + account.cpa_reset_requests.filter(status__in=["pending", "unknown"]) + .order_by("created_at") + .first() + ) + if existing: + if ( + existing.requested_by_id != request.user.pk + and not request.user.is_staff + ): + raise ValidationError( + "该账号已有待确认或待核对的重置请求,请先处理原请求" + ) + return ok(plan_data(existing, account)) + try: + with CPAClient(AppSettings.load()) as client: + _, body = client.query_usage_payload(account.cpa_auth_index) + except CPAError as exc: + return error(str(exc), 502) + count = reset_credits(body) + if count is None or count <= 0: + raise ValidationError("没有可靠的可用重置次数,请先刷新额度") + plan = CPAQuotaResetRequest.objects.create( + account=account, + requested_by=request.user, + source_account_id=account.cpa_auth_index, + available_count=count, + expires_at=now + timedelta(minutes=5), + ) + return ok(plan_data(plan, account), 201) + + +class CPAQuotaResetConfirmView(ResetView): + def post(self, request, account_id, plan_id): + account = self.account(request, account_id) + if request.data.get("confirmed") is not True: + raise ValidationError("请先完成重置额度的二次确认") + guard = LeaseGuard.acquire(account.fact_key) + try: + with transaction.atomic(): + guard.assert_owned() + account.refresh_from_db() + if ( + not visible_accounts_for(request.user).filter(pk=account.pk).exists() + or not can_reset_quota(request.user, account) + ): + raise PermissionDenied("当前已无重置权限") + plans = CPAQuotaResetRequest.objects.select_for_update() + if not request.user.is_staff: + plans = plans.filter(requested_by=request.user) + plan = get_object_or_404(plans, pk=plan_id, account=account) + if plan.status == "succeeded": + return ok(plan_data(plan, account)) + if plan.source_account_id != account.cpa_auth_index: + raise ValidationError("账号凭据已变化,不能重试此重置请求") + if plan.status not in ("pending", "unknown") or ( + plan.status == "pending" and plan.expires_at <= timezone.now() + ): + raise ValidationError("确认已过期,请重新预览") + # Commit the uncertainty marker before any upstream mutation. + # A crash or lost response must reuse this same redemption ID. + plan.status = "unknown" + plan.save(update_fields=["status"]) + try: + with CPAClient(AppSettings.load()) as client: + client.consume_reset_credit(account.cpa_auth_index, str(plan.pk)) + except CPAError as exc: + if getattr(exc, "definitive_rejection", False): + with transaction.atomic(): + guard.assert_owned() + plan.status = "failed" + plan.finished_at = timezone.now() + plan.save(update_fields=["status", "finished_at"]) + return error( + "上游拒绝重置,请检查账号状态和 CPA 版本后重新预览", 502 + ) + return error( + "上游重置结果未确认,请刷新额度核对;重试会复用同一请求标识", 502 + ) + with transaction.atomic(): + guard.assert_owned() + plan.status = "succeeded" + plan.finished_at = timezone.now() + plan.save(update_fields=["status", "finished_at"]) + return ok(plan_data(plan, account)) + finally: + guard.release() diff --git a/backend/monitor/views/dashboard.py b/backend/monitor/views/dashboard.py index a6a54a4..b4568ac 100644 --- a/backend/monitor/views/dashboard.py +++ b/backend/monitor/views/dashboard.py @@ -9,6 +9,7 @@ from django.shortcuts import get_object_or_404 from django.utils import timezone +from ..cpa.reporting import pool_summary from .base import AdminAPIView, PageAccessAPIView, error, ok from ..api_auth import APIKeyAuthentication from ..access import ( @@ -100,7 +101,7 @@ def _participant_rows( ) -> tuple[list[dict], list[dict]]: all_rows = [ participant_data(item, config) - for item in Participant.objects.filter(enabled=True).prefetch_related( + for item in Participant.objects.filter(enabled=True, sub2api_user_id__isnull=False).prefetch_related( "account_memberships__account" ) ] @@ -229,6 +230,7 @@ def get(self, request): "accounts": [_account_data(item) for item in accounts], "selected_account_id": account.id if account else None, "selected_provider": account.provider if account else None, + "cpa_summary": pool_summary(request.user, account, config) if account is not None and account.provider == "cpa" else None, "last_local_check_at": iso( account.last_local_check_at if account else config.last_local_check_at ), @@ -592,6 +594,8 @@ class ApplyParticipantRecommendationView(AdminAPIView): def post(self, _request, participant_id: int): participant = get_object_or_404(Participant, pk=participant_id, enabled=True) + if participant.sub2api_user_id is None: + return error("CPA 参与者没有可写入的 Sub2API 余额", 400) pending = ( ParticipantBalanceOperation.objects.exclude(state="committed") .filter(participant=participant) @@ -602,6 +606,7 @@ def post(self, _request, participant_id: int): account_ids = set( MonitoredAccount.objects.filter( enabled=True, + provider="sub2api", pool__allocations__participant=participant, pool__allocations__share_percent__gt=0, ) diff --git a/backend/monitor/views/participants.py b/backend/monitor/views/participants.py index fbbc68d..324cf36 100644 --- a/backend/monitor/views/participants.py +++ b/backend/monitor/views/participants.py @@ -31,9 +31,9 @@ class _ParticipantHasSnapshots(RuntimeError): pass -def quota_allocation_data(user) -> dict: +def quota_allocation_data(user, provider="sub2api") -> dict: accounts = list( - MonitoredAccount.objects.filter(provider="sub2api") + MonitoredAccount.objects.filter(provider=provider) .select_related("pool") .order_by( "pool__name", @@ -42,8 +42,12 @@ def quota_allocation_data(user) -> dict: "external_account_id", ) ) + if provider == "cpa" and not user.is_staff: + from ..access import visible_accounts_for + allowed = set(visible_accounts_for(user).values_list("id", flat=True)) + accounts = [account for account in accounts if account.id in allowed] pools = list( - QuotaPool.objects.filter(accounts__provider="sub2api") + QuotaPool.objects.filter(accounts__id__in=[account.id for account in accounts]) .prefetch_related( "accounts", "allocations__participant", @@ -57,20 +61,25 @@ def quota_allocation_data(user) -> dict: Participant.objects.order_by("-is_owner", "id"), ) ) + if provider == "sub2api": + participants = [p for p in participants if p.sub2api_user_id is not None] + if provider == "cpa" and not user.is_staff: + participants = list(Participant.objects.filter(pool_allocations__pool__in=pools).distinct()) visible_participant_ids = { participant.id for participant in participants } return { + "provider": provider, "accounts": MonitoredAccountSerializer(accounts, many=True).data, "participants": [ { "id": participant.id, "name": participant.name, - "sub2api_user_id": participant.sub2api_user_id, - "sub2api_username": participant.sub2api_username, - "sub2api_email": participant.sub2api_email, + "sub2api_user_id": participant.sub2api_user_id if provider == "sub2api" else None, + "sub2api_username": participant.sub2api_username if provider == "sub2api" else "", + "sub2api_email": participant.sub2api_email if provider == "sub2api" else "", "sub2api_identity": ( - participant.sub2api_username + "CPA" if provider == "cpa" else participant.sub2api_username or participant.sub2api_email or f"账号 {participant.sub2api_user_id}" ), @@ -90,7 +99,7 @@ def quota_allocation_data(user) -> dict: [ item for item in pool.accounts.all() - if item.provider == "sub2api" + if item.provider == provider and item.id in {account.id for account in accounts} ], key=lambda item: ( item.name, @@ -127,7 +136,10 @@ class QuotaAllocationView(PageAccessAPIView): required_page_permissions = (PagePermission.PARTICIPANTS,) def get(self, request): - return ok(quota_allocation_data(request.user)) + provider = request.query_params.get("provider", "sub2api") + if provider not in {"sub2api", "cpa"}: + return error("渠道参数无效") + return ok(quota_allocation_data(request.user, provider)) def put(self, request): serializer = QuotaAllocationWriteSerializer(data=request.data) @@ -136,7 +148,7 @@ def put(self, request): external_account_ids = [ account.fact_key for account in MonitoredAccount.objects.filter( - provider="sub2api" + provider=serializer.validated_data["provider"] ).order_by("external_account_id") ] settings_id = AppSettings.load().pk @@ -146,7 +158,7 @@ def put(self, request): serializer.apply() except serializers.ValidationError as exc: return error("分配方案已过期", details=exc.detail) - return ok(quota_allocation_data(request.user)) + return ok(quota_allocation_data(request.user, serializer.validated_data["provider"])) class Sub2APIUserListView(AdminAPIView): @@ -262,7 +274,8 @@ def delete(self, _request, participant_id: int): participant = Participant.objects.select_for_update().get( pk=participant_id ) - if participant.snapshots.exists(): + from ..cpa.participants import has_cpa_contract_history + if participant.snapshots.exists() or participant.cpa_bindings.exists() or participant.cpa_claim_plans.exists() or participant.cpa_owner_bindings.exists() or has_cpa_contract_history(participant.id): raise _ParticipantHasSnapshots pool_ids = list( participant.pool_allocations.order_by().values_list( diff --git a/backend/monitor/views/readonly_api.py b/backend/monitor/views/readonly_api.py index 1dfb64c..ea6bcd6 100644 --- a/backend/monitor/views/readonly_api.py +++ b/backend/monitor/views/readonly_api.py @@ -14,6 +14,8 @@ API_ENDPOINTS = [ + {"method": "GET", "path": "/api/v1/cpa/summary", "description": "读取 CPA 同车成员汇总,按池成员与账号授权限制。", "page_permission": (PagePermission.DASHBOARD, PagePermission.PARTICIPANTS, PagePermission.STATISTICS)}, + {"method": "GET", "path": "/api/v1/cpa/requests", "description": "分页读取本人授权参与者的 CPA 请求与 Key 分项。", "page_permission": PagePermission.STATISTICS}, { "method": "GET", "path": "/api/v1/accounts", diff --git a/backend/monitor/views/settings.py b/backend/monitor/views/settings.py index fbadd83..5a78cc3 100644 --- a/backend/monitor/views/settings.py +++ b/backend/monitor/views/settings.py @@ -323,7 +323,7 @@ def post(self, request): AccountParticipant.objects.bulk_create( [ AccountParticipant(account=account, participant=participant) - for participant in Participant.objects.order_by("id") + for participant in Participant.objects.filter(sub2api_user_id__isnull=False).order_by("id") ] ) else: diff --git a/backend/monitor/views/statistics.py b/backend/monitor/views/statistics.py index f6d71e0..dd1fcb6 100644 --- a/backend/monitor/views/statistics.py +++ b/backend/monitor/views/statistics.py @@ -9,6 +9,7 @@ from rest_framework.permissions import IsAuthenticated +from ..cpa.reporting import pool_summary from .base import PageAccessAPIView, error, ok from .query_params import bounded_query_int, monitored_account_query from ..access import visible_participants_for @@ -93,6 +94,7 @@ def get(self, request): now, cost_breakdowns, ), + "cpa_summary": pool_summary(request.user, account, config) if account.provider == "cpa" else None, "usage_days": usage_days, "usage_precision": usage_precision, "sample_interval_minutes": config.local_poll_minutes, @@ -110,6 +112,7 @@ def get(self, request): ), "cpa_api_key_series": ( cpa_api_key_usage_series( + user=request.user, config=config, account=account, location=location, diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 0000000..77d5f1d --- /dev/null +++ b/design-qa.md @@ -0,0 +1,39 @@ +# CPA 账号状态与重置设计验收 + +final result: passed + +## 设计目标与证据 + +以用户提供的 CPA Manager Plus 两张截图为信息层级参考,在现有账号状态菜单中实现 CPA 独立卡片。沿用 Sub2Pool 的导航、系统字体、daisyUI 主题和 Heroicons,不引入参考产品的抽屉、禁用凭证或配置菜单。 + +- Source visual truth:`/var/folders/s5/fcvcqjmn2dn4jjr2m4qqq79r0000gn/T/codex-clipboard-8a8ce6a5-b366-43ee-8e2c-055e6f856da9.png`(1312 × 1404 px)及 `codex-clipboard-68187fe7-1c5b-4465-b482-92cade5a55be.png`(1258 × 720 px)。来源截图的 CSS viewport / DPR 未知。 +- Implementation:`http://127.0.0.1:5180/#/account-status`,本地合成数据管理员;周限额剩余 89%,Spark 剩余 100%,初始重置次数 1。 +- 桌面 CSS viewport / 图片:1536 × 1100,DPR 1;`/tmp/sub2pool-status-design/desktop.png`、`desktop-bottom.png`。 +- 手机 CSS viewport / 图片:390 × 844,DPR 1;`mobile.png`、`mobile-windows.png`、`confirm-mobile.png`、`reset-zero-mobile.png`,均在同一验收目录。 +- 确认与重置结果:`confirm-desktop.png`、`reset-zero-desktop.png`;只操作本地演示数据,未请求线上重置。 +- 并排比较:`comparison.png` 对齐总体统计与标准窗口,`comparison-bottom.png` 对齐附加限额与零次数重置区域;两侧按内容宽度等比归一到 760 px,无拉伸。排除来源抽屉和实现导航的差异。 +- 两个局部并排比较覆盖本次全部 CPA 内容区域,数字、对齐、说明和重置控件均可辨认;原始桌面截图用于查看完整页面关系。 + +## 视觉检查与修正 + +1. [P2,已修复] 剩余比例与标题挤在左侧:修正弹性布局下的数值对齐,最终桌面图显示比例位于右侧,手机允许换行。 +2. [P2,已修复] 浅色主题的语义色文字偏浅:剩余数值、缺价和范围提示改用正文色,保留图标与进度条的语义色。最终截图和并排比较确认关键数字及说明可读。 +3. 字体:沿用系统字体,标题与数字加重,数值等宽;主体 14–16 px,说明 12 px,关键指标 24–30 px。参考图片的字体尺寸与密度不直接推定为 CSS 大小。 +4. 布局:四个总体指标;标准窗口三列用量卡;附加限额和重置独立成卡。手机指标两列、窗口单列,确认框完整显示,页面 scrollWidth 与 innerWidth 均为 390。 +5. 颜色:使用现有主题的背景、边框和状态色,绿色进度表示剩余比例;图标只作辅助,状态和空值都有文字。保留原产品主题与参考蓝白配色的差异。 +6. 资产:参考只有标准 UI 图标,无照片或插图;使用现有 Heroicons,不新增自绘图标或位图。 +7. 内容:金额明确为估算,范围不明时不生成模型用量或预测。合成数据包含附加模型限额,因此标准窗口预测留空并解释原因;这是数据可靠性要求产生的差异。重置历史仅代表本系统发起的操作。 +8. 最终并排比较未发现剩余 P0/P1/P2 问题。 + +## 交互、权限与回归 + +- 首次点击只打开确认框,显示目标账号、可用次数、将消耗 1 次和不可撤销说明。 +- 桌面取消后仍为 1 次;手机再次确认后变为 0 次,按钮禁用并出现“已重置”记录。 +- 后端 71 项测试通过:账号状态、CPA 采集集成、只读 API、车主/管理员权限、普通成员拒绝、跨账号拒绝、权限撤销、过期、二次确认、重复提交及未知结果复用标识。 +- `makemigrations --check --dry-run` 无待生成迁移;前端 `pnpm check`、`pnpm build` 通过,演示构建也已验证。 +- Sub2API 状态页保留原有周期与统计布局,截图 `sub2api-regression.png`。回归发现原历史图表缺少 ECharts Toolbox 注册,已补注册并隐藏工具栏,保留原拖选行为;修复后重新加载没有新增 console error。 +- 重置真实上游交互以测试替身验证请求协议和幂等标识,线上额度未消耗。 + +## 后续优化 + +P3:上游提供可信模型归属范围后,可增加附加限额的窗口用量和预测。 diff --git a/docs/architecture.md b/docs/architecture.md index 97a4e3e..b134565 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,7 @@ CPA usage 订阅 ───┼─> 采样证据 ─> 区间识别 ─> 账本重 - `monitor.integrations.sub2api`:访问 Sub2API;普通采样读取请求日志时严格校验 exact_total、分页游标、返回行数和重复 ID。 - `monitor.integrations.cpa` 与 `monitor.cpa`:读取 CPA Codex 账号和七天额度,并在同端口 RESP usage 订阅成功后,将活动 session 与每账号连接事件原子写入独立 SQLite spool;业务库以 `CPAAccountCollectionInterval` 独立保存连接覆盖,不从百分比观测推断连接状态。opening/closing 是可选整数百分比样本,人工排除不改变采集区间;正常结束由最终 RESP barrier 确认可靠截止点,异常退出按最后心跳恢复不可靠截止点且不伪造观测。只保存订阅成功后的直播事件,不读取 CPA FIFO。原子批量持久化不可删除的 Token/服务档位事实;延迟事件会刷新并重放其后的百分比观测,读取与重放时按当前本地价格计算成本,不保存原始 API Key。 -- `monitor.sampling`:按 `UsageSamplePoint` 原子采集并保存来源事实,不决定参与者归属;普通 participant CRUD 不追溯改写历史。 +- `monitor.sampling`:按 `UsageSamplePoint` 原子采集并保存来源事实,不决定参与者归属;普通 participant CRUD 不追溯改写历史;CPA 历史认领通过单独预览与确认流程保存归属区间,冻结的 CPA 合同决定历史份额是否已知。 - `monitor.historical_rebuild`:冻结本地全点审计计划,按 plan digest fail closed,并在 lease/fencing 保护下零联网重放派生结果。 - `monitor.history_state`:账号级 fact revision、lease 与 fencing token。 - `monitor.accounting`:识别归属区间,重放时变账本,保存兼容现有读取路径的 legacy live projection。 @@ -40,7 +40,7 @@ CPA usage 订阅 ───┼─> 采样证据 ─> 区间识别 ─> 账本重 3. 当前仍可查询的请求日志不能证明历史 retention 完整;缺失的历史 FAST 或请求数事实保持 unknown。 4. 维护 apply 必须只消费持久化 plan,零联网,并在同一事务内完成全点审计和 legacy projection 重放;不得改写来源成本。 5. 所有来源事实写入由 fact revision、lease 和 fencing token 协调;旧 owner 不得在租约失效后提交。 -6. 算法不能调用 Sub2API 写接口;写余额只允许管理员显式操作。CPA 账号不创建参与者、余额、建议或人工额度调整。 +6. 算法不能调用 Sub2API 写接口;写余额只允许管理员显式操作。CPA Key 通过带生效时间的独立归属记录接入共同参与者模型;CPA 只展示估算权益,不产生余额写入或人工余额建议。 7. 合同权益不参与消费事实推断,不得用当前参与者策略发明历史用户或 policy。 8. 未解释成本必须显式保留,不能静默分给已绑定参与者。 9. 相同原始数据、算法/构建版本和配置必须产生相同 legacy read projection。 diff --git a/docs/cpa-billing-qa.md b/docs/cpa-billing-qa.md new file mode 100644 index 0000000..2ec52af --- /dev/null +++ b/docs/cpa-billing-qa.md @@ -0,0 +1,32 @@ +# CPA 周与账期权益验收 + +## 页面 + +本地合成演示通过界面登录、进入 CPA 参与者页、打开账期配置并保存。演示中的四周场景明确标为合成示例,线上日期由真实账期配置计算。 + +- 1440 × 1000 深色:周分布、账期容量来源与累计消费同时显示;三张成员卡同一行,宽 341 px、x 坐标 321 / 678 / 1035,姓名颜色一致。 +- 390 × 844 浅色:成员卡转单列(宽 310 px),图例与操作按钮换行,页面 `scrollWidth` 和视口均为 390 px,无横向页面溢出。 +- 未配置账期显示管理员配置入口;配置后出现容量、失效、预计可用、周期来源及个人建议。未知周容量保留已采集费用和上游剩余百分比,不画成可用余额。 +- 零值的未归属/历史成员图例隐藏,存在消费时单列。颜色之外始终保留姓名、金额和百分比;超额进度显示实际百分比。 +- 页面无浏览器控制台 error。额度总览与统计页选择 CPA 账号后,均确认显示相同的周分布、账期分布和成员累计卡;原有 Sub2API 统计仍正常展示。 + +## 后端验证 + +覆盖四周期三人示例(A 后续每周期约 $277.78)、容量加权、份额变化、不同阶段多用、整个账期超额、未分配权益、提前手动重置的完整容量及失效量、跨账期分摊、月末/闰年/夏令时、未来起始日、重连不重复计量、零消费、缺价、采集缺口、迟到事件、重复读取一致性、缺历史合同、历史池迁移、其他历史成员与未归属分离、未来容量中位数回退,以及只读 API 和页面接口的授权范围一致。 + +迁移测试核对已有参与者、Sub2API 身份及余额、份额和请求保留,账期默认未配置、不自动认领事件。既有 CPA 历史认领、补价、请求权限与 Sub2API 额度回归一并运行。 + +## 自动检查结果 + +- API、accounting 与 CPA integration 回归:225 项通过。 +- reporting 与 sampling 回归:46 项通过。 +- 最终账期专项与迁移验证:22 项通过(包含后续补充的未来起始日、多账号加权、历史认领重读)。 +- Django `check` 与 `makemigrations --check --dry-run` 通过。 +- 前端 `pnpm check`、`pnpm build` 通过;构建仅保留现有图表依赖包超过 500 kB 的提示。 + + +## 粒子容量展示回归 + +`test_cpa_capacity_display.py` 验证重连后容量及 90% 区间与粒子轨迹一致、仅初始观测时标注先验、无观测不生成容量、平均恒定模型保持其算法、缺历史采集时容量与结算分开展示、补价和延迟事件刷新重放后结果一致,以及附加模型限额不阻止标准周容量展示。 + +本地合成演示已检查 1440px 桌面、390px 手机布局与深浅主题;缺价时周容量、个人份额预算仍显示,周与账期同时呈现。账号状态显示同源容量、估计区间及估算更新时间;浏览器无错误日志。`pnpm check` 和 `pnpm build` 通过。 diff --git a/docs/cpa-carpool.md b/docs/cpa-carpool.md new file mode 100644 index 0000000..8152d27 --- /dev/null +++ b/docs/cpa-carpool.md @@ -0,0 +1,197 @@ +# CPA 拼车 + +CPA 复用 Sub2Pool 的系统用户、参与者和额度池。一个参与者可以只绑定 CPA Key,也可以同时绑定一个 Sub2API 用户;一个人可持有多个 CPA Key。两个渠道的额度池、权益和余额分别计算。 + +## 配置流程 + +1. 在系统设置中配置 CPA Management API 和 usage 订阅连接,添加监控账号。 +2. 在参与者页面添加成员;只用 CPA 时,Sub2API 用户选择“仅使用 CPA”。 +3. 切换到 CPA,选择成员后绑定已采集的 Key,或输入完整 Key 预先绑定;可以填写、修改 Key 备注。 +4. 在额度分配中切换到 CPA,设置独立池或多个 CPA 账号组成的混池,以及各成员份额。池内份额合计不能超过 100%,不得跨渠道混池。 +5. 在系统用户页面创建登录账号,授权对应参与者、CPA 账号及总览/参与者/统计等页面。普通用户同时满足账号授权和池成员关系才能查看该池。 + +绑定从提交时刻生效;解除绑定只关闭归属时间区间,过去的请求仍归原成员。原始 Key 仅用于计算现有 HMAC 摘要,不写入数据库、spool 或返回值。末四位相同的 Key 仍由完整摘要区分。 + +## 车主身份与未匹配 Key 的请求 + +复用参与者编辑中的“设为车主”,不按姓名识别,也无需另外指定车主。每个 CPA 池中只有一位启用的车主时,该池各账号从身份或份额配置生效时刻起,将没有匹配到成员 Key 的请求(包括没有 Key 的已采集请求)归给车主。已有 Key 绑定和已确认的历史认领优先;同池没有车主或存在多位车主时暂停默认归属,并在账号卡片提示。 + +身份取消、停用或池成员调整会关闭旧归属区间;新车主只接收其生效后的请求。旧归属记录保留,迟到请求按发生时间匹配。升级会根据现有车主身份建立从升级时刻开始的归属区间,不自动追溯修改历史。 + +要把升级前的未归属请求计入车主,在额度总览选择 CPA 账号,在账号卡片点击“认领历史未归属请求”。预览目标由该池车主身份确定,包含该账号所有已采集且未归属的历史请求、Token、估算费用、缺价与采集缺口;确认后写入认领记录并重算。仅认领预览中的已有事实,重复确认不会重复计数;迟到的更早请求需要重新认领。其他成员的已归属请求不会被认领。完全绕过 CPA、没有被采集的调用无法生成逐次请求明细。 + +## 历史请求认领 + +绑定后可打开“历史认领”,选择参与者、开始时间和结束时间,先预览请求数、Token、费用、未计价请求和采集缺口,再确认认领。范围采用 `[开始, 结束)`;默认结束时间精确衔接当前绑定的生效时间。 + +预览有效期为 15 分钟,冻结请求事实、Key 归属、价格配置、观测和额度池合同。确认时若这些数据变化,需要重新预览。重复确认已完成的预览不会重复归属。时间范围不能与常规绑定或其他参与者的认领重叠;同一参与者可在原范围再次预览,只认领新收到且尚未归属的请求。 + +认领仅新增归属记录和确认时的事件清单并重算派生额度,不修改 `CPAUsageEvent`。确认后迟到的历史请求不会自动加入认领,需再次预览确认。断线时未采集的请求不会补造。当前份额不会被追溯到历史;历史合同缺失时可以查看请求与消耗,历史剩余权益保持未知。有 Key 归属、认领计划或 CPA 合同历史的参与者只能停用。 + +## 展示口径 + +- 总览、参与者和统计页面为所有 CPA 池成员展示额度卡片,普通用户自己的卡片排在前面,管理员也能看到所有成员卡片。卡片显示合同份额、已采集请求费用与用量,以及可计算时的权益总额、已用权益、剩余权益和超额状态。多账号池可在卡片内切换账号;进度条以该成员的合同份额为 100%。汇总表可展开查看。 +- “数据不足”卡片保留已采集用量,并列出采集缺口、缺价、无额度观测或历史份额依据不足等原因,不将未知剩余额度画成零。认领和补价不能恢复漏采请求,需要后续完整周期具备有效观测与合同依据后才可估算剩余。 +- 个人 Key 的费用轨迹接入现有平均恒定/时变模型;未绑定 Key 的消耗保留在未归属主体中,不分摊给成员。成员在 CPA 的重放不会改动其 Sub2API 全局余额。 +- 多账号池分别依据各账号的上游重置周期计算,再汇总等效美元权益。仅授权部分账号时明确标注部分范围,不暴露其他账号用量。 +- 请求费用按当前本地模型价格重算,不代表 CPA 可扣减余额。未知模型保留请求数和 Token,同时标明未计价;它及采集缺口、有效观测不足会阻止展示可靠剩余额度。 +- 请求更新时间和额度观测时间分别显示。usage 尚在 spool 或采集器未连接时会提示数据延迟。 +- 普通用户能看同车成员汇总,只能看本人授权参与者的 Key 分项和逐次请求。管理员可看全部。逐次请求包含模型、Token、费用、成功/失败、请求标识、耗时和首 Token 延迟,不包含对话正文。 +- 当前仅展示额度和超额提示,不会停用 Key 或拦截调用。 + +## 请求明细与一键补价 + +请求明细页的“成员额度”入口携带当前账号返回总览;卡片区提供“我的请求明细/查看请求明细”“分配 CPA 份额”和 Key 管理入口。 + +侧边栏“CPA 请求明细”提供独立请求页面,沿用系统用户的“额度统计”页面授权。选择 CPA 账号后,可按时间、模型、本人 Key、成功/失败筛选;摘要统计整个筛选范围,列表支持每页 25/50/100 条。移动端使用卡片列表,“详情”展示完整请求标识、Token 和耗时。统计页顶部的“请求明细”会携带当前账号跳转到独立页面;“模型价格”对管理员开放。缺价提示中的“一键同步缺失价格”会打开价格窗口并开始同步,也可以先打开“模型价格”查看缺价模型及其请求数,再决定同步或手动填写。 + +价格窗口统计所选账号的全部已采集请求(从系统设置打开时统计全部账号),不受统计页日期筛选限制。价格配置全局共享,保存后所有 CPA 账号的历史请求费用和派生额度都会重算;原始请求保持不变。同步和重算在同一事务内完成,失败则保留原价格。 + +同步从 `https://models.dev/api.json` 下载公共目录,在本地匹配 OpenAI 模型名称,不发送 Key、请求内容或用量。只补齐已有请求缺失的输入、缓存输入、输出三项基础价格,保留已有手动价格;FAST 和长上下文计费继续使用当前 CPA 配置。目录未收录的名称、自定义别名或缺少完整三项价格的模型仍显示缺价,可手动输入。未知价格不会自动设为零。 + +升级后请刷新已打开的浏览器标签页,加载新版前端。 + +## API + +所有路径以 `/api` 为前缀。管理接口使用管理员 JWT;只读接口还提供 `/v1/cpa/summary` 和 `/v1/cpa/requests`,使用 Sub2Pool 个人或管理员 API Key,执行相同的数据范围限制。CPA 调用 Key 不作为面板登录凭据。 + +| 方法与路径 | 用途 | +| --- | --- | +| `GET /settings/cpa-pricing?account_id=...` | 管理员查看价格和已采集缺价模型;省略账号时统计全部 CPA 账号 | +| `POST /settings/cpa-pricing?account_id=...` | 管理员一键补齐缺价模型并重算,返回已添加和无法匹配的模型 | +| `GET /cpa/keys` | 管理员读取已登记 Key、绑定区间和未登记的已采集 Key | +| `POST /cpa/keys` | 提交 `participant_id`、可选 `name`,以及二选一的 `raw_key`/`observed_hash` | +| `PATCH /cpa/bindings/{id}` | 修改 Key 的 `name` 备注 | +| `DELETE /cpa/bindings/{id}` | 幂等结束当前绑定,保留历史 | +| `POST /cpa/unassigned/preview?account_id=...` | 管理员预览该账号未归属历史请求,认领目标由当前车主身份确定;确认复用 claims/{id}/apply | +| `POST /cpa/claims/preview` | 提交 `key_id`、`participant_id`、`started_at`、`ended_at`,返回预览和计划 ID | +| `POST /cpa/claims/{id}/apply` | 确认认领预览,重算受影响账号 | +| `GET /cpa/summary?account_id=...` | 返回该账号所属池在调用者授权范围内的成员汇总 | +| `GET /cpa/requests?account_id=...` | 查询本人/管理员可查看的请求 | + +请求查询支持 `include_summary=true`(返回整个授权筛选范围的 Token、费用、成功/失败数及有效耗时均值)、`days`(1–90,省略开始时间时回看天数),以及 `participant_id`、`key_id`、`model`、`failed`、`started_at`、`ended_at`、`page`、`page_size`。默认最近 7 天,最多 90 天;默认每页 50 条,最多 100 条,按请求时间和 ID 倒序。返回的 Key 和模型选项也按调用者权限过滤。 + +CPA 汇总的成员新增 `is_owner`;账号新增 `owner`(参与者 ID、名称、生效时间和 active/missing/ambiguous 状态)。账号和成员账号分项均返回 `quota_unavailable_reasons`。车主历史认领返回 `key_id: null` 和对应的 `account_id`。 + +`GET /quota-allocation?provider=cpa` 读取 CPA 分配;`PUT /quota-allocation` 的请求体增加 `provider: "cpa"`。省略渠道时仍处理 Sub2API,保存一个渠道不会删除另一个渠道的分配。参与者的 `sub2api_user_id` 现在允许为 `null`。 + +## CPA 账号状态与额度重置 + +账号状态页中,CPA 账号展示请求数、Token、估算费用、成功率,以及上游提供的周限额、短时限额、代码审查和附加模型限额。进度条表示**剩余比例**;窗口卡片同时标注重置时间、观测时间和边界依据。总体统计最多回看 30 天,以页面标出的时间范围为准;窗口用量只计入本系统已采集的请求。 + +标准窗口展示上个窗口、当前窗口和预测。当前窗口的结束时间是上游重置时间,已用请求统计截至本次查询时间。只有采集完整、请求均已计价、已用比例有效且大于零、模型范围可确定时,才按已用比例线性估算满额总量。附加限额范围不明确时,标准窗口展示该时间段的账号合计并停止预测;附加窗口仅显示上游比例和重置时间。提前重置的历史窗口只展示截断区间,不作为预测依据。金额为估算值,不等于成员合同余额。 + +**只有管理员或对应 CPA 池的车主可以重置额度。** 车主由参与者的“设为车主”身份确定,必须是池中唯一启用的车主,并且登录用户已获该参与者、账号和账号状态页面的授权。普通成员可以查看汇总,不能调用重置接口;修改账号或预览 ID 也不能绕过授权。权限在提交时再次检查。 + +点击“重置额度”只读取可用次数并打开确认弹窗。弹窗标明账号、当前可用次数和将消耗 1 次、成功后不可撤销,用户必须再点“确认消耗并重置”。预览有效期为 5 分钟。没有次数时按钮禁用;上游未返回可靠次数时显示“未知”,不能发起新重置。 + +同一账号的重置串行执行;确认前持久化重置标识,重复提交已成功请求不会再次调用上游。响应丢失或结果未知时保留原请求,使用“核对重置结果”重试同一标识,管理员也可处理车主留下的待核对请求。上游明确拒绝时显示失败记录,可检查账号和 CPA 版本后重新预览。重置记录只包含从本系统发起的操作,最近展示 10 条,不代表上游全部历史。 + +页面使用 JWT 接口: + +| 方法与路径(`/api` 前缀) | 用途 | +| --- | --- | +| `GET /account-status` | CPA 账号增加 `cpa_quota`,包含 totals、windows 和 reset | +| `POST /account-status/cpa/{account_id}/reset-preview` | 获取待确认计划及可用次数,不消耗额度 | +| `POST /account-status/cpa/{account_id}/resets/{id}/confirm` | 请求体必须为 `{ "confirmed": true }`,确认或核对原计划 | + +外部只读 API 同步提供 `cpa_quota` 读取结构,不提供重置写接口。迁移 `0055` 增加重置审计记录,不修改原始请求、份额或 Sub2API 余额。 + +## 升级与后续 + +迁移保留参与者 ID、已有授权和所有原始 CPA 请求,不自动认领历史。现有 Sub2API 参与者无需重新配置。第一次使用 CPA 拼车时按上述流程添加绑定与份额。 + +### 部署当前 CPA 分支 + +本次功能位于 `https://github.com/ld0574/Sub2Pool` 的 `cpa` 分支。该分支的推送不会自动发布镜像;README 中的上游 `ghcr.io/lingyenbird/sub2pool:latest` 不保证包含这次改造。当前可从源码构建。 + +全新安装: + +```bash +git clone --branch cpa https://github.com/ld0574/Sub2Pool.git sub2pool +cd sub2pool +cp .env.example .env +# 编辑 .env:设置随机 DJANGO_SECRET_KEY、管理员密码和域名/IP。 +docker compose up -d --build +docker compose logs --tail=100 app +``` + +已有源码部署:在原部署目录中更新到本仓库的 `cpa` 分支,再执行 `docker compose up -d --build`。保留原 `.env`、Compose 项目名和数据卷挂载;不要重新生成已有实例的 `DJANGO_SECRET_KEY`,加密配置和 CPA Key 摘要依赖它。 + +已有 GHCR 部署:先在另一个目录构建本地镜像,然后在原部署目录增加覆盖文件,以沿用原实例的数据卷和环境变量: + +```bash +git clone --branch cpa https://github.com/ld0574/Sub2Pool.git sub2pool-cpa-src +docker build -t sub2pool-cpa:local ./sub2pool-cpa-src + +# 以下命令在原来存放 compose.yaml 和 .env 的部署目录执行。 +cat > compose.cpa.yaml <<'YAML' +services: + app: + image: sub2pool-cpa:local + pull_policy: never +YAML + +# 停止写入后备份整个数据目录,包含主数据库和 CPA spool。 +backup_dir="./backup-cpa-$(date +%Y%m%d-%H%M%S)" +mkdir -p "$backup_dir" +docker compose stop app +docker compose cp app:/app/data/. "$backup_dir/" +docker compose -f compose.yaml -f compose.cpa.yaml up -d --no-build +docker compose -f compose.yaml -f compose.cpa.yaml logs --tail=100 app +``` + +源码升级也应在重建前使用上述停止与备份命令。若原部署使用额外的 `-p`、`--env-file` 或其他 Compose 文件,升级命令继续使用相同参数。后续使用本地镜像部署时,继续带上 `-f compose.cpa.yaml`。 + +容器启动脚本会自动执行 `migrate`(包括 `0052`、`0053`、`0054`、`0055`)、历史重放及管理员初始化,无需手工进入容器迁移。首次升级的历史重放可能延长启动时间,可通过日志确认完成,再访问 `http://服务器IP:8088`(或原访问地址)。升级后按本文配置流程设置 CPA 成员、Key、份额和系统用户授权。 + +第二阶段再验证目标 CPA 版本的 Key 控制能力,选择直接控制或请求网关,实现超额自动限制。 + +## 周额度与订阅账期累计权益 + +CPA 拼车汇总在额度总览、参与者和统计页同时展示本周分布、订阅账期分布与成员卡,不需要切换周/月。多个账号分别展示周条形图,账期只累加当前用户获授权账号的美元值。姓名和颜色在图表、图例和成员卡之间保持一致。 + +管理员通过「订阅账期累计分布 → 配置订阅账期」设置订阅起始日期和 IANA 时区(例如 `Asia/Shanghai`)。不填写月预算;31 日续期的订阅在短月取月末,下个月恢复 31 日。尚未配置时不猜测续费日,也不自动认领历史请求。设置可随时修改或清空,成员没有写权限。 + +### 计算口径 + +- 账期预计容量按真实上游周期累计。时变模型直接复用粒子轨迹的持久化容量估计及 90% 区间;平均恒定模型沿用其容量计算。历史取最后可靠估计,当前取最新可靠估计;未来自然周期优先用当前估计,否则用最近三个可靠已结束周期的中位数。 +- 同一官方重置时间内的断线、重连或归属分段不会重复产生容量。提前手动重置截断旧周期,旧、新周期都计入完整容量;跨账期按周期实际区间的重叠秒数分摊。 +- 请求费用按实际发生时间计算,使用现有 CPA 模型价格和历史 Key/车主/认领归属。历史账号所属池与个人份额来自 `CPAQuotaContract`,份额变化按生效时刻分段,不用当前份额补造历史合同。 +- 完整采集的已结束周期,失效额度为估算容量减该周期全部已计价消费的正值,再按账期重叠比例分摊。失效额度不进入后续可用量。 +- 个人预计剩余权益为账期预计权益减累计消费,可为负。分别显示本周超份额、已结束周期累计多用、整个账期预计超额。 +- 后续建议总量受当前未失效余额与未来预测容量约束,按正的剩余权益比例分配,同时为未分配权益和其他历史成员保留空间。建议不超过个人正的剩余权益,也不写入份额、不限制请求、不修改 Sub2API 余额。 +- 三人各占三分之一、四周容量均为 $1,000、第一周 A/B/C 消费 $500/$250/$250 时,A 后续建议总量约 $833.33,即后三个周期平均 $277.78、27.8%。 + +容量时间分摊与请求发生时间统计是两种口径。跨账期时,消费与容量不强行平账;图表保留原始百分比,超出 100% 会标注,不能缩小比例掩盖超额。累计消耗条的留白表示未展示为消费的理论容量,实际可用量以「当前及后续预计可用」为准。 + +粒子容量估计不要求从官方周起点连续采集。重连后的有效模型区间也可提供容量、估计区间和成员使用百分比;仅有模型初始值时标注“先验估计”,完全没有模型观测时保持未知。容量估算时间与请求统计时间分别显示。补价或延迟事件改变区间成本后,旧结果须通过成本一致性校验才能继续使用;刷新成本并重放后显示更新后的估计。 + +采集缺口或缺价不隐藏已有容量估计,但完整个人剩余、失效量与后续建议仍返回 `null`。成员卡集中展示本周已用、按当前份额计算的预算、预算减已采集消耗得到的估算剩余及进度;差额可为负,超预算另行标注。账期预计权益已知时也展示权益减累计消耗的参考剩余;权益本身未知时只保留累计已用,统一提示预算依据不足。不据此补造历史权益,也不生成缺乏完整数据的后续建议。账期周期边界与历史合同明确时,可继续显示预计权益;缺少这些依据时相应总量或权益仍未知。周分布的“估算剩余”显示整车估算容量减已采集费用(最低为零),并显示占整车额度的百分比;总消耗包含未归属及其他历史成员。缺口或缺价存在时说明这些请求尚未扣除,此参考差额不作为个人合同余额或调用限制依据。容量本身未知时差额仍未知,超额消费另外标注。保留已采集金额及明确的上游剩余比例。未归属、其他历史成员消费和未分配权益单独列示。 + +### 接口与派生结果 + +`GET /api/cpa/summary?account_id=…` 和只读 `GET /api/v1/cpa/summary?account_id=…` 保留原字段,新增: + +- `weekly_distribution`:各账号周分布、请求更新时间、容量估算时间、上游明确剩余比例、缺价数;`capacity_estimate` 提供来源、容量、区间、是否仅先验与更新时间,`coverage_complete` 表示消耗是否足以完整结算。账号状态的标准周窗口复用相同容量信息,附加模型限额不会禁用该容量展示,也不会据此虚构请求数和 Token 总量预测。 +- `billing_summary`:账期配置与边界、已知实际容量、未来容量、失效量、预计可用量、质量原因、按账号拆分的周期以及各成员累计指标。 +- `billing_summary.cycles[].kind` 区分 `historical`、`current`、`future`;`quota_as_of` 为实际估计或预测依据时间,`generated_at` 为本次汇总生成时间。 + +`PUT /api/cpa/billing-config?account_id=…` 仅管理员可用,请求为 `{"anchor_date":"2026-08-31","timezone":"Asia/Shanghai"}`;日期为 `null` 时取消配置。只读 API 不提供写入入口。 + +周期汇总是由已持久化 Observation、采集区间、合同与原始请求在读取时重建的派生结果,不另存可能过期的余额缓存。认领完成、价格同步和重放后,下次读取立即重新聚合;会核对可靠观测对应的请求费用,拒绝沿用补价或迟到事件导致失配的旧容量。请求定价使用每周期前缀和,避免逐个历史观测重复扫描全部请求。 + +### 升级 + +迁移 `0056` 只为额度池增加可空的账期起始日期和时区,保留参与者 ID、现有授权、归属、原始请求及 Sub2API 数据。拉取 `cpa` 分支后重新构建启动: + +```sh +git pull --ff-only origin cpa +docker compose up -d --build +``` + +容器启动按现有流程执行迁移。首次进入 CPA 汇总后由管理员设置实际订阅账期;已有采集缺口不会因设置账期或历史认领消失。 + +请求列表的「模型 / 服务」、手机卡片和详情同时显示 Key 别名与末四位,例如 `Le...9x0-`(列表不再加服务等级或 Key 前缀,服务等级保留在详情中)。`CPARequest.api_key_alias` 优先返回请求中保存的 CPA `alias`,缺失时回退到本地 Key 备注;没有两者时只显示脱敏尾号 `...9x0-`。历史请求无需重新采集。此字段遵循现有本人请求权限,只读 API 同样限制。 + +分布图的百分比紧跟成员名称与金额,周图标注「占本周额度」,账期图标注「占账期总额度」。容量缺少可靠估计时显示「额度占比待估算」,而非省略百分比或用占已采集消费的比例替代。个人卡片的份额进度以个人份额为分母,与整车占比区分。 diff --git a/docs/cpa-member-qa.md b/docs/cpa-member-qa.md new file mode 100644 index 0000000..e793424 --- /dev/null +++ b/docs/cpa-member-qa.md @@ -0,0 +1,30 @@ +# CPA 成员额度与车主归属验收 + +2026-09-08,分支 `cpa`。首轮使用独立合成数据库与浏览器内演示数据;首轮没有改动线上归属或价格。 + +## 页面 + +- 总览展示全部成员卡片;管理员看所有卡片,普通用户本人排在前面并标记“我”,车主身份单独标记。 +- 已采集消耗与按观测计算的权益分开展示。缺价时显示“数据不足”和具体原因,保留请求数、Token 与已知费用;可靠额度显示已用/剩余进度条。 +- 桌面三列卡片、390 × 844 手机单列卡片通过目视检查;手机文档宽度与视口宽度均为 390,无横向溢出。 +- 请求页“成员额度”回到当前 CPA 账号;“分配 CPA 份额”直接选择 CPA 渠道。 +- 真实测试后端认领一条 $10 请求后,车主已采集消耗由 $10 变为 $20,另一成员仍为 $10;剩余权益同步重算,未归属行消失。 +- 普通成员看到车友汇总;没有历史认领和份额管理按钮。本人请求包含已认领请求,车友请求仍不可见。 + +## 自动验证 + +- 后端 CPA、只读 API、参与者历史策略、额度模型/重放和迁移回归:116 项通过。 +- 补充平均恒定模型认领与 Key 解绑边界后,车主专项 10 项通过(已包含于前述回归的 8 项,加上新增 2 项)。 +- 覆盖无 Key/未知 Key、账号隔离、Key 优先、历史认领幂等、迟到请求、事实/价格/身份/绑定变化时使预览失效、车主切换、多车主暂停归属。 +- 迁移保留原始请求,现有车主按升级时刻开始接收新请求,不自动认领旧请求。 +- 前端 `pnpm format`、`pnpm check`、`pnpm build` 通过;构建仅有已有的大分包提示。 + +旧周期的采集缺口仍会阻止可靠剩余额度估算;认领与补价不会补造遗漏请求。 + +## 紧凑布局复验 + +- 桌面 1440 × 1000 三位成员在同一行,每张卡片 341 × 238 px;主信息为姓名、已采集消耗、请求 / Token、份额和剩余额度。 +- 单账号不再在每张成员卡内重复账号名称、额度更新时间及公共数据缺口;账号状态与归属集中展示一次。多账号保留分项切换,成员自身的历史合同等异常仍在成员卡内显示。 +- 缺少可靠额度时,剩余显示“待估算”,已采集消耗仍正常显示;保留按观测计算的权益与请求消耗的区别。 +- 深色、浅色与 390 px 手机排版通过目视检查,手机文档无横向溢出。截图:`/tmp/sub2pool-member-layout/desktop.png`、`mobile.png`。 +- 前端 `pnpm check`、`pnpm build` 通过。后端归属逻辑未改动;按用户明确授权,通过线上历史预览与确认完成车主认领,验证当前周期未归属提示消失、车主消耗增加;真实账号及金额不写入本验收文档。 diff --git a/docs/particle-filter.md b/docs/particle-filter.md index 0ab50ac..321f619 100644 --- a/docs/particle-filter.md +++ b/docs/particle-filter.md @@ -73,7 +73,13 @@ CPA 账号若在官方周期中途开始监控,只从本地连接后的第一 6. 有效粒子不足时执行系统重采样; 7. 输出加权中位数和 90% 区间。 -固定随机种子保证重放幂等。`particle_filter_v9` 只增加账号基础范围覆盖并调整 Pro 5X 默认范围,继续使用已验证的 v4 随机流,避免现有 Pro 20X 区间仅因元数据版本升级而改变估计。 +固定随机种子保证重放幂等。当前 `particle_filter_v10` 修正非零起点的范围扩张判断:主体归属是区间新增进度,必须加上模型推断的起点进度后验中位数,才能与上游全周整数百分比比较。不能把重连时已用的 18% 当成连接后新增消耗。官方零起点仍沿用原有主体中位数之和与 v4 随机流,避免无关轨迹发生变化。 + +跨区间沿用的容量先验若超出套餐基础范围,使用能够容纳它的最小预设扩张范围初始化,避免重连时被硬截到默认上下限。这是历史先验,不是本区间新触发的“双证据”;页面标注“沿用历史先验范围”,新增扩张仍需边界质量和绝对进度残差同时成立。没有消费证据时先验不等于实测容量。 + +升级后容器的 `replayobservations` 会根据 v10 标记重算旧派生结果,无需修改原始请求、价格、绑定或上游百分比。非容器部署执行 `python manage.py replayobservations`。本修复消除已复现的起点错误,不声称估计必然提高,也不把区间解释为实际额度保证。 + +工程回归新增非零起点零消费、历史低/高容量先验、真实向上/向下扩张,以及 v9 升级、局部与全量重放一致性测试。相关后端共 128 项通过,前端 `pnpm check` 与 `pnpm build` 通过;本地粒子轨迹页正常展示且无浏览器错误。 ## 分级扩张 @@ -157,6 +163,6 @@ Pro 20X 的扩张范围经两轮独立实验验证。确认集包含 800 个常 补充生产实现消融使用 300 个开发案例、600 个独立确认案例和 30 个多种子案例,直接调用生产滤波与投影代码。确定性投影对确认集 MAE 的影响为 +0.036 美元,95% Bootstrap 区间为 -0.015~+0.090 美元;它不提升平均精度,但以近乎零误差代价维持确定性可行性,因此保留。 -当前 480 粒子和固定 1.3 余额区间膨胀仍是 `particle_filter_v9` 的实际参数。补充实验给出的下一版候选是 960 粒子,以及扩张状态相关区间:第一级上扩用确定性外包络、第一级下扩用 1.3、第二级以上用 1.0。候选区间在独立确认集覆盖 95.04%,按案例 Bootstrap 95% 区间为 94.20%~95.81%;在版本升级和完整重放落地前,不得把该候选描述成当前行为。 +当前 480 粒子和固定 1.3 余额区间膨胀仍是 `particle_filter_v10` 的实际参数。补充实验给出的下一版候选是 960 粒子,以及扩张状态相关区间:第一级上扩用确定性外包络、第一级下扩用 1.3、第二级以上用 1.0。候选区间在独立确认集覆盖 95.04%,按案例 Bootstrap 95% 区间为 94.20%~95.81%;在版本升级和完整重放落地前,不得把该候选描述成当前行为。 完整研究顺序、实验入口和冻结结果见 [study/README.md](study/README.md)。 diff --git a/docs/statistics.md b/docs/statistics.md index 1426a1a..84fbcb7 100644 --- a/docs/statistics.md +++ b/docs/statistics.md @@ -38,7 +38,7 @@ $$ ## 账号用量明细 -Sub2API 账号按参与者与上游 API Key 展示已保存的用户用量事实。CPA 账号没有参与者、余额或建议,改为按本地 HMAC 后的 API Key 分组展示请求数、Token 和按当前价格配置动态计算的估算成本;页面只显示末尾四位提示,不能恢复原始 Key。未知模型按 0 美元汇总,并单独显示未计价请求数。 +Sub2API 账号按参与者与上游 API Key 展示已保存的用户用量事实。CPA 账号按参与者及其本地 HMAC 后的 API Key 展示请求数、Token 和按当前价格配置动态计算的估算成本;同车成员只能互看汇总,Key 分项与逐次请求仅本人及管理员可见。CPA 权益由独立渠道额度池分配,不产生上游余额建议;页面只显示末尾四位提示,不能恢复原始 Key。未知模型按 0 美元汇总,并单独显示未计价请求数。 CPA 统计只覆盖监控账号在 RESP 订阅成功后收到并持久化的 usage 事件。订阅期间原有定时百分比请求照常执行;连接建立前与断线期间的请求不推测、不回填。账号连接覆盖由独立采集区间保存,不从百分比观测推断。订阅后的 opening 百分比与取消订阅前的 closing 百分比只是可选数值样本;最终 RESP `PING` 确认截止点前的直播事件已进入持久队列后,独立结束事件关闭采集区间。数据库 busy 时持续重试;异常退出按最后持久心跳记录不可靠截止点且不伪造 closing 观测。断线期间产生的普通观测只保留审计事实,不把缺口当成零成本连续用量。CPA 成本也不会应用 Sub2API FAST 事后修正。 diff --git a/frontend/src/components/common/CPABillingOverview.vue b/frontend/src/components/common/CPABillingOverview.vue new file mode 100644 index 0000000..7d6f006 --- /dev/null +++ b/frontend/src/components/common/CPABillingOverview.vue @@ -0,0 +1,335 @@ + + diff --git a/frontend/src/components/common/CPADistributionBar.vue b/frontend/src/components/common/CPADistributionBar.vue new file mode 100644 index 0000000..aeccc3d --- /dev/null +++ b/frontend/src/components/common/CPADistributionBar.vue @@ -0,0 +1,86 @@ + + diff --git a/frontend/src/components/common/CPAMemberQuotaCard.vue b/frontend/src/components/common/CPAMemberQuotaCard.vue new file mode 100644 index 0000000..1df2c9b --- /dev/null +++ b/frontend/src/components/common/CPAMemberQuotaCard.vue @@ -0,0 +1,215 @@ + + + diff --git a/frontend/src/components/common/CPAModelPricingDialog.vue b/frontend/src/components/common/CPAModelPricingDialog.vue new file mode 100644 index 0000000..029a1b2 --- /dev/null +++ b/frontend/src/components/common/CPAModelPricingDialog.vue @@ -0,0 +1,383 @@ + + + diff --git a/frontend/src/components/common/CPAOwnerClaim.vue b/frontend/src/components/common/CPAOwnerClaim.vue new file mode 100644 index 0000000..b3b9a04 --- /dev/null +++ b/frontend/src/components/common/CPAOwnerClaim.vue @@ -0,0 +1,116 @@ + + diff --git a/frontend/src/components/common/CPAPoolCard.vue b/frontend/src/components/common/CPAPoolCard.vue new file mode 100644 index 0000000..74d2e9f --- /dev/null +++ b/frontend/src/components/common/CPAPoolCard.vue @@ -0,0 +1,300 @@ + + + diff --git a/frontend/src/components/common/cpaColors.ts b/frontend/src/components/common/cpaColors.ts new file mode 100644 index 0000000..cb15ab7 --- /dev/null +++ b/frontend/src/components/common/cpaColors.ts @@ -0,0 +1,10 @@ +// Participant identity, rather than sorted display position, owns the color. +const colors = [ + "#3b82f6", + "#d97706", + "#8b5cf6", + "#059669", + "#db2777", + "#0891b2", +]; +export const cpaColor = (id: number) => colors[Math.abs(id) % colors.length]!; diff --git a/frontend/src/components/common/heroicons.ts b/frontend/src/components/common/heroicons.ts index 033bbce..7e2b62f 100644 --- a/frontend/src/components/common/heroicons.ts +++ b/frontend/src/components/common/heroicons.ts @@ -35,6 +35,7 @@ import { Cog6ToothIcon, CpuChipIcon, CreditCardIcon, + CurrencyDollarIcon, CubeIcon, CursorArrowRaysIcon, DevicePhoneMobileIcon, @@ -156,6 +157,7 @@ export const heroicons: Record = { "cog-6-tooth": Cog6ToothIcon, "cpu-chip": CpuChipIcon, "credit-card": CreditCardIcon, + "currency-dollar": CurrencyDollarIcon, cube: CubeIcon, "cursor-arrow-rays": CursorArrowRaysIcon, "device-phone-mobile": DevicePhoneMobileIcon, diff --git a/frontend/src/config/navigation.ts b/frontend/src/config/navigation.ts index b6ee409..0a5ed72 100644 --- a/frontend/src/config/navigation.ts +++ b/frontend/src/config/navigation.ts @@ -69,6 +69,12 @@ export const navigation: NavigationGroup[] = [ to: "/statistics", permission: "statistics", }, + { + label: "CPA 请求明细", + icon: "queue-list", + to: "/cpa-requests", + permission: "statistics", + }, { label: "通知记录", icon: "bell", diff --git a/frontend/src/config/pagePermissions.ts b/frontend/src/config/pagePermissions.ts index 5d79d07..27b5892 100644 --- a/frontend/src/config/pagePermissions.ts +++ b/frontend/src/config/pagePermissions.ts @@ -56,7 +56,7 @@ export const pagePermissionGroups: PagePermissionGroup[] = [ { code: "statistics", label: "额度统计", - description: "查看容量和参与者用量统计", + description: "查看容量、参与者用量及本人 CPA 请求明细", }, ], }, diff --git a/frontend/src/demo/backend.ts b/frontend/src/demo/backend.ts index 90ad73c..56c9b02 100644 --- a/frontend/src/demo/backend.ts +++ b/frontend/src/demo/backend.ts @@ -1,3 +1,5 @@ +import { handleCPAStatus } from "./cpaStatus"; +import { handleCPA } from "./cpa"; import type { DemoState } from "./state"; import { demoIdentity, loadDemoState } from "./state"; import { handleProtectedAuth, handlePublicAuth } from "./handlers/auth"; @@ -40,6 +42,8 @@ export interface DemoRequestContext { type DemoRequestHandler = (context: DemoRequestContext) => Response | null; const protectedHandlers: DemoRequestHandler[] = [ + handleCPAStatus, + handleCPA, handleProtectedAuth, handleSecurity, handleDashboard, diff --git a/frontend/src/demo/cpa.ts b/frontend/src/demo/cpa.ts new file mode 100644 index 0000000..a156635 --- /dev/null +++ b/frontend/src/demo/cpa.ts @@ -0,0 +1,890 @@ +import type { CPAQuotaDetail, CPAResetPreview } from "@/types/accounts"; +import type { CPAPricingInventory, CPAPricingSync } from "@/types/cpaPricing"; +import type { + CPABillingSummary, + CPAKeys, + CPARequest, + CPAPoolSummary, + CPAClaim, +} from "@/types/cpa"; +import type { DemoRequestContext } from "./backend"; +import type { DemoState } from "./state"; +import type { CPAAPIKeyUsageSeries } from "@/types/statistics"; +import { demoIdentity, saveDemoState } from "./state"; + +export interface DemoCPAState { + billingConfigs?: Record< + number, + { anchor_date: string | null; timezone: string } + >; + quotaStatuses?: Record; + resetPlans?: CPAResetPreview[]; + keys: CPAKeys; + events: (CPARequest & { + participant_id: number | null; + key_id: number; + account_id: number; + })[]; + claims: (CPAClaim & { revision: number })[]; +} + +export function initializeCPADemo(state: DemoState) { + if (state.cpa) return; + const account = { + ...state.monitoredAccounts[0]!, + id: 3, + provider: "cpa" as const, + pool_id: 2, + external_account_id: null, + cpa_auth_index: "demo-codex", + source_account_id: "demo-codex", + name: "CPA 拼车账号", + quota_query_mode: "direct" as const, + }; + state.monitoredAccounts.push(account); + state.quotaPools.push({ + id: 2, + name: "CPA 演示池", + contract_revision: 1, + account_ids: [3], + total_share_percent: 100, + allocations: state.participants.map((p, i) => ({ + participant_id: p.id, + share_percent: [40, 35, 25][i] ?? 0, + })), + }); + state.nextPoolId = Math.max(3, state.nextPoolId); + for (const user of state.systemUsers) { + user.account_ids.push(3); + user.account_names.push(account.name); + } + const anchor = Date.parse(state.clock); + const start = new Date(anchor - 3 * 86400000).toISOString(); + const keys: CPAKeys = { + keys: state.participants.map((p, i) => ({ + id: i + 1, + name: ["工作电脑", "笔记本", "开发测试"][i] ?? "演示 Key", + hint: String(1001 + i), + observed_hash: String(i + 1).padStart(64, "0"), + bindings: [ + { + id: i + 1, + key_id: i + 1, + name: ["工作电脑", "笔记本", "开发测试"][i] ?? "演示 Key", + hint: String(1001 + i), + participant_id: p.id, + participant_name: p.name, + started_at: start, + ended_at: null, + }, + ], + })), + unregistered: [{ observed_hash: "4".padStart(64, "0"), hint: "1004" }], + }; + const events: DemoCPAState["events"] = Array.from({ length: 84 }, (_, i) => { + const owner = i % 4; + return { + id: i + 1, + account_id: 3, + participant_id: state.participants[owner]?.id ?? null, + key_id: owner + 1, + occurred_at: new Date(anchor - (84 - i) * 1800000).toISOString(), + request_id: `demo-cpa-request-${i + 1}`, + api_key_hint: String(1001 + owner), + model: owner === 3 ? "gpt-6-astra" : "gpt-5.4", + endpoint: "/v1/responses", + input_tokens: 9000 + i * 70, + cached_input_tokens: 3000, + output_tokens: 1100 + i * 13, + reasoning_tokens: 350, + total_tokens: 10100 + i * 83, + failed: i % 17 === 0, + latency_ms: 1800 + i * 15, + ttft_ms: 210 + i, + usage_usd: 0, + unpriced: false, + requested_service_tier: "", + response_service_tier: "default", + }; + }); + state.cpa = { keys, events, claims: [] }; + repriceCPADemo(state); +} + +function demoPrice(state: DemoState, model: string) { + const pricing = state.settings.cpa_model_pricing; + const name = model in pricing ? model : model.replace(/-latest$/i, ""); + return pricing[name] ? { name, price: pricing[name] } : null; +} + +export function repriceCPADemo(state: DemoState) { + for (const event of state.cpa?.events ?? []) { + const match = demoPrice(state, event.model); + event.unpriced = !match; + const cached = Math.min(event.input_tokens, event.cached_input_tokens); + const fast = ["fast", "priority"].includes( + event.response_service_tier || event.requested_service_tier, + ) + ? Number(state.settings.cpa_fast_multiplier) + : 1; + const context = + state.settings.cpa_double_billing_enabled && + event.input_tokens > + Number(state.settings.cpa_double_billing_threshold_tokens) + ? Number(state.settings.cpa_double_billing_multiplier) + : 1; + event.usage_usd = match + ? (((event.input_tokens - cached) * Number(match.price.input) + + cached * Number(match.price.cached_input) + + event.output_tokens * Number(match.price.output)) / + 1_000_000) * + fast * + context + : 0; + } +} + +function demoPricingInventory( + state: DemoState, + accountId: number | null, +): CPAPricingInventory { + const groups = new Map(); + for (const event of state.cpa?.events ?? []) { + if (accountId != null && event.account_id !== accountId) continue; + const match = demoPrice(state, event.model); + const row = groups.get(event.model) ?? { + model: event.model, + request_count: 0, + token_count: 0, + missing: !match, + pricing_model: match?.name ?? null, + }; + row.request_count++; + row.token_count += event.total_tokens; + groups.set(event.model, row); + } + const models = [...groups.values()]; + return { + pricing: state.settings.cpa_model_pricing, + models, + missing_model_count: models.filter((row) => row.missing).length, + unpriced_request_count: models + .filter((row) => row.missing) + .reduce((sum, row) => sum + row.request_count, 0), + source: "models.dev(演示快照)", + source_url: "https://models.dev/api.json", + generated_at: state.clock, + }; +} + +function ownIds(state: DemoState) { + const identity = demoIdentity(); + if (identity?.is_staff) return null; + return ( + state.systemUsers.find((user) => user.username === identity?.username) + ?.participant_ids ?? [] + ); +} + +function canRead(state: DemoState, accountId: number) { + const identity = demoIdentity(); + if (identity?.is_staff) return true; + const user = state.systemUsers.find((u) => u.username === identity?.username); + const account = state.monitoredAccounts.find((a) => a.id === accountId); + const pool = state.quotaPools.find((p) => p.id === account?.pool_id); + return Boolean( + user?.account_ids.includes(accountId) && + pool?.allocations.some((a) => + user.participant_ids.includes(a.participant_id), + ), + ); +} + +export function demoCPASummary( + state: DemoState, + accountId: number, +): CPAPoolSummary | null { + const account = state.monitoredAccounts.find( + (a) => a.id === accountId && a.provider === "cpa", + ); + const pool = state.quotaPools.find((p) => p.id === account?.pool_id); + if (!account || !pool || !canRead(state, accountId)) return null; + const own = ownIds(state); + const owners = state.participants.filter( + (p) => + p.is_owner && + p.enabled && + pool.allocations.some((a) => a.participant_id === p.id), + ); + const owner: CPAPoolSummary["accounts"][number]["owner"] = + owners.length === 1 + ? { + participant_id: owners[0]!.id, + participant_name: owners[0]!.name, + started_at: state.clock, + status: "active", + } + : { + participant_id: null, + participant_name: null, + started_at: null, + status: owners.length > 1 ? "ambiguous" : "missing", + }; + const sum = (events: DemoCPAState["events"]) => ({ + usage_usd: events.reduce((total, e) => total + e.usage_usd, 0), + request_count: events.length, + token_count: events.reduce((total, e) => total + e.total_tokens, 0), + unpriced_request_count: events.filter((e) => e.unpriced).length, + }); + const events = state.cpa!.events.filter((e) => e.account_id === accountId); + const quotaAvailable = !events.some((e) => e.unpriced); + const members = pool.allocations.flatMap((allocation) => { + const person = state.participants.find( + (p) => p.id === allocation.participant_id, + ); + if (!person) return []; + const totals = sum(events.filter((e) => e.participant_id === person.id)); + const expected = allocation.share_percent * 20; + const remaining = expected - totals.usage_usd; + return [ + { + ...totals, + participant_id: person.id, + participant_name: person.name, + is_owner: person.is_owner, + is_self: own != null && own.includes(person.id), + share_percent: allocation.share_percent, + quota_available: quotaAvailable, + is_overused: quotaAvailable && remaining < 0, + expected_entitlement_usd: quotaAvailable ? expected : null, + consumed_entitlement_usd: quotaAvailable ? totals.usage_usd : null, + remaining_entitlement_usd: quotaAvailable ? remaining : null, + account_breakdowns: [ + { + account_id: accountId, + quota_available: quotaAvailable, + quota_unavailable_reasons: quotaAvailable + ? [] + : ["本周期存在未定价请求"], + quota_as_of: state.clock, + charged_percent: quotaAvailable ? totals.usage_usd / 20 : null, + remaining_share_percent: quotaAvailable + ? Math.max(0, remaining / 20) + : null, + usage_usd: totals.usage_usd, + estimated_capacity_usd: quotaAvailable ? 2000 : null, + expected_entitlement_usd: quotaAvailable ? expected : null, + consumed_entitlement_usd: quotaAvailable ? totals.usage_usd : null, + remaining_entitlement_usd: quotaAvailable ? remaining : null, + }, + ], + }, + ]; + }); + return { + billing_summary: demoBilling(state, pool.id, members, accountId), + weekly_distribution: [ + { + account_id: accountId, + account_name: account.name, + started_at: new Date( + Date.parse(state.clock) - 3 * 86400000, + ).toISOString(), + resets_at: new Date( + Date.parse(state.clock) + 4 * 86400000, + ).toISOString(), + quota_as_of: state.clock, + requests_as_of: events.at(-1)?.occurred_at ?? null, + capacity_usd: 2000, + capacity_estimate: { + source: "particle_filter", + capacity_usd: 2000, + lower_usd: 1650, + upper_usd: 2400, + prior_only: false, + as_of: state.clock, + }, + coverage_complete: quotaAvailable, + remaining_usd: quotaAvailable + ? Math.max(0, 2000 - sum(events).usage_usd) + : null, + upstream_remaining_percent: 72, + usage_usd: sum(events).usage_usd, + unpriced_request_count: sum(events).unpriced_request_count, + unattributed_usd: sum(events.filter((e) => e.participant_id == null)) + .usage_usd, + other_members_usd: 0, + members: members.map((m) => ({ + participant_id: m.participant_id, + usage_usd: m.usage_usd, + usage_percent: m.usage_usd / 20, + })), + }, + ], + pool_id: pool.id, + pool_name: pool.name, + selected_account_id: accountId, + partial_scope: false, + members, + accounts: [ + { + ...sum(events), + account_id: accountId, + account_name: account.name, + owner, + selected: true, + quota_as_of: state.clock, + requests_as_of: events.at(-1)?.occurred_at ?? null, + cycle_started_at: new Date( + Date.parse(state.clock) - 3 * 86400000, + ).toISOString(), + resets_at: new Date( + Date.parse(state.clock) + 4 * 86400000, + ).toISOString(), + coverage: { complete: true, uncertain_end: false, gaps: [] }, + quota_available: quotaAvailable, + quota_unavailable_reasons: quotaAvailable + ? [] + : ["本周期存在未定价请求"], + }, + ], + unattributed: sum(events.filter((e) => e.participant_id == null)), + cost_estimate: true, + enforcement_enabled: false, + generated_at: state.clock, + collector: { + state: "connected", + connected: true, + stale: false, + connected_at: state.clock, + heartbeat_at: state.clock, + last_message_at: state.clock, + last_persisted_at: state.clock, + pending_count: 0, + last_error: "", + last_error_at: null, + }, + }; +} + +function demoBilling( + state: DemoState, + poolId: number, + members: CPAPoolSummary["members"], + accountId: number, +): CPABillingSummary { + const config = state.cpa?.billingConfigs?.[poolId]; + const result: CPABillingSummary = { + configured: !!config?.anchor_date, + anchor_date: config?.anchor_date ?? null, + timezone: config?.timezone ?? "Asia/Shanghai", + started_at: null, + ended_at: null, + generated_at: state.clock, + capacity_usd: null, + actual_capacity_usd: null, + future_capacity_usd: null, + expired_usd: null, + available_usd: null, + usage_usd: 0, + unattributed_usd: 0, + other_members_usd: 0, + unallocated_usd: null, + reasons: [], + cycles: [], + members: [], + }; + if (!result.configured) return result; + // Synthetic four-week scenario for UI review; never presented as production facts. + const start = Date.parse(state.clock) - 7 * 86400000; + const end = start + 28 * 86400000; + result.started_at = new Date(start).toISOString(); + result.ended_at = new Date(end).toISOString(); + result.capacity_usd = 4000; + result.actual_capacity_usd = 2000; + result.future_capacity_usd = 2000; + result.available_usd = 3000; + result.expired_usd = 0; + result.usage_usd = 1000; + result.unallocated_usd = 0; + result.cycles = Array.from({ length: 4 }, (_, i) => ({ + account_id: accountId, + account_name: "CPA 演示账号", + started_at: new Date(start + i * 7 * 86400000).toISOString(), + ended_at: new Date(start + (i + 1) * 7 * 86400000).toISOString(), + kind: i === 0 ? "historical" : i === 1 ? "current" : "future", + capacity_usd: 1000, + full_capacity_usd: 1000, + expired_usd: i === 0 ? 0 : null, + quota_as_of: i < 2 ? state.clock : null, + reasons: [], + })); + result.members = members.map((m, i) => { + const spend = i === 0 ? 500 : 250; + const entitlement = (4000 * (m.share_percent ?? 0)) / 100; + const remaining = entitlement - spend; + return { + participant_id: m.participant_id, + usage_usd: spend, + usage_percent: spend / 40, + entitlement_usd: entitlement, + remaining_usd: remaining, + recommended_usd: Math.max(0, remaining), + recommended_percent: Math.max(0, remaining) / 30, + completed_overuse_usd: Math.max(0, spend - entitlement / 4), + projected_overuse: remaining < 0, + }; + }); + return result; +} + +export function demoCPAKeySeries( + state: DemoState, + accountId: number, + days: number, + precision: "raw" | "hour" | "day", +): CPAAPIKeyUsageSeries[] { + if (!canRead(state, accountId)) return []; + const own = ownIds(state); + const cutoff = Date.parse(state.clock) - days * 86400000; + const groups = new Map(); + const buckets = new Map(); + const format = new Intl.DateTimeFormat("sv-SE", { + timeZone: String(state.settings.timezone), + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }); + for (const event of state.cpa!.events) { + if ( + event.account_id !== accountId || + Date.parse(event.occurred_at) < cutoff || + (own && + (event.participant_id == null || !own.includes(event.participant_id))) + ) + continue; + let series = groups.get(event.key_id); + if (!series) { + const key = state.cpa!.keys.keys.find((k) => k.id === event.key_id); + series = { + api_key_id: `demo-key-${event.key_id}`, + api_key_name: `${key?.name || "API Key"} ····${event.api_key_hint}`, + total_usage_usd: 0, + request_count: 0, + token_count: 0, + unpriced_request_count: 0, + points: [], + }; + groups.set(event.key_id, series); + } + series.total_usage_usd += event.usage_usd; + series.request_count++; + series.token_count += event.total_tokens; + series.unpriced_request_count += Number(event.unpriced); + const local = format.format(new Date(event.occurred_at)); + const label = + precision === "day" + ? local.slice(0, 10) + : precision === "hour" + ? `${local.slice(0, 13)}:00` + : local; + const bucketId = `${event.key_id}:${precision === "raw" ? event.id : label}`; + let point = buckets.get(bucketId); + if (!point) { + point = { + observed_at: event.occurred_at, + label, + usage_usd: 0, + request_count: 0, + token_count: 0, + }; + buckets.set(bucketId, point); + series.points.push(point); + } + point.usage_usd += event.usage_usd; + point.request_count++; + point.token_count += event.total_tokens; + } + return [...groups.values()]; +} + +export function handleCPA({ + state, + pathname, + method, + payload, + url, + ok, + fail, +}: DemoRequestContext): Response | null { + if (pathname === "settings/cpa-pricing") { + if (!demoIdentity()?.is_staff) return fail("仅管理员可管理模型价格", 403); + const accountId = url.searchParams.has("account_id") + ? Number(url.searchParams.get("account_id")) + : null; + if ( + accountId != null && + !state.monitoredAccounts.some( + (a) => a.id === accountId && a.provider === "cpa", + ) + ) + return fail("CPA 账号不存在", 404); + const inventory = demoPricingInventory(state, accountId); + if (method === "GET") return ok(inventory); + if (method !== "POST") return fail("不支持此操作", 405); + const added: CPAPricingSync["added"] = []; + const unresolved: CPAPricingSync["unresolved"] = []; + // Deterministic public-catalog snapshot; demo mode never contacts a service. + const catalog = { + "gpt-6-astra": { input: "10", cached_input: "1", output: "50" }, + "gpt-5.4": { input: "2.5", cached_input: "0.25", output: "15" }, + }; + for (const row of inventory.models.filter((row) => row.missing)) { + const price = catalog[row.model as keyof typeof catalog]; + if (price) { + state.settings.cpa_model_pricing[row.model] = { ...price }; + added.push({ + model: row.model, + source_model: `openai/${row.model}`, + price, + }); + } else + unresolved.push({ + model: row.model, + reason: "演示目录未收录此模型,请手动填写", + }); + } + repriceCPADemo(state); + saveDemoState(state); + return ok({ ...demoPricingInventory(state, accountId), added, unresolved }); + } + if (!pathname.startsWith("cpa/")) return null; + const cpa = state.cpa!; + const admin = demoIdentity()?.is_staff; + const own = ownIds(state); + if (pathname === "cpa/billing-config") { + if (!admin) return fail("仅管理员可修改", 403); + if (method !== "PUT") return fail("不支持此操作", 405); + const accountId = Number(url.searchParams.get("account_id")); + const account = state.monitoredAccounts.find((a) => a.id === accountId); + if (!account || !canRead(state, accountId)) + return fail("CPA 账号未授权", 403); + const body = { + anchor_date: payload.anchor_date ? String(payload.anchor_date) : null, + timezone: String(payload.timezone), + }; + cpa.billingConfigs ??= {}; + cpa.billingConfigs[account.pool_id] = body; + saveDemoState(state); + return ok(demoCPASummary(state, accountId)); + } + if (pathname === "cpa/summary" || pathname === "cpa/requests") { + const accountId = Number(url.searchParams.get("account_id")); + if (!canRead(state, accountId)) return fail("CPA 账号未授权", 403); + if (pathname === "cpa/summary") return ok(demoCPASummary(state, accountId)); + let events = cpa.events.filter( + (e) => + e.account_id === accountId && + (own == null || + (e.participant_id != null && own.includes(e.participant_id))), + ); + const hashes = new Set(events.map((e) => e.key_id)); + const keys = cpa.keys.keys + .filter((k) => hashes.has(k.id)) + .map((k) => ({ id: k.id, name: k.name, hint: k.hint })); + const models = [...new Set(events.map((e) => e.model))]; + const params = url.searchParams; + if (params.get("key_id")) + events = events.filter((e) => e.key_id === Number(params.get("key_id"))); + if (params.get("model")) + events = events.filter((e) => e.model === params.get("model")); + if (params.has("failed")) + events = events.filter( + (e) => e.failed === (params.get("failed") === "true"), + ); + const endedAt = params.get("ended_at") || state.clock; + const startedAt = + params.get("started_at") || + new Date( + Date.parse(endedAt) - Number(params.get("days") || 7) * 86400000, + ).toISOString(); + if ( + !Number.isFinite(Date.parse(startedAt)) || + !Number.isFinite(Date.parse(endedAt)) || + Date.parse(startedAt) >= Date.parse(endedAt) || + Date.parse(endedAt) - Date.parse(startedAt) > 90 * 86400000 + ) + return fail("请求查询时间范围须大于零且不超过 90 天"); + events = events.filter( + (e) => e.occurred_at >= startedAt && e.occurred_at < endedAt, + ); + const sum = ( + field: + | "input_tokens" + | "cached_input_tokens" + | "output_tokens" + | "reasoning_tokens" + | "total_tokens" + | "usage_usd", + ) => events.reduce((total, event) => total + event[field], 0); + const average = (field: "latency_ms" | "ttft_ms") => { + const observed = events.filter((e) => e[field] > 0); + return observed.length + ? observed.reduce((total, e) => total + e[field], 0) / observed.length + : null; + }; + const summary = + params.get("include_summary") === "true" + ? { + request_count: events.length, + failed_count: events.filter((e) => e.failed).length, + input_tokens: sum("input_tokens"), + cached_input_tokens: sum("cached_input_tokens"), + output_tokens: sum("output_tokens"), + reasoning_tokens: sum("reasoning_tokens"), + total_tokens: sum("total_tokens"), + usage_usd: sum("usage_usd"), + unpriced_request_count: events.filter((e) => e.unpriced).length, + average_latency_ms: average("latency_ms"), + average_ttft_ms: average("ttft_ms"), + } + : null; + events.sort((a, b) => b.occurred_at.localeCompare(a.occurred_at)); + const page = Math.max(1, Number(params.get("page") ?? 1)); + const size = Math.min( + 100, + Math.max(1, Number(params.get("page_size") ?? 50)), + ); + return ok({ + account_id: accountId, + items: events.slice((page - 1) * size, page * size).map((event) => ({ + ...event, + api_key_alias: + event.api_key_alias || + cpa.keys.keys.find((key) => key.id === event.key_id)?.name || + "", + })), + total: events.length, + summary, + started_at: startedAt, + ended_at: endedAt, + page, + page_size: size, + keys, + models, + generated_at: state.clock, + cost_estimate: true, + }); + } + if (!admin) return fail("仅管理员可管理 CPA Key", 403); + if (pathname === "cpa/unassigned/preview" && method === "POST") { + const accountId = Number(url.searchParams.get("account_id")); + const summary = demoCPASummary(state, accountId); + const owner = summary?.accounts[0]?.owner; + if (owner?.status !== "active" || owner.participant_id == null) + return fail("请先在参与者管理中设置唯一车主"); + const events = cpa.events.filter( + (e) => e.account_id === accountId && e.participant_id == null, + ); + if (!events.length) return fail("此范围没有未归属的已采集请求"); + const plan: DemoCPAState["claims"][number] = { + id: `demo-${cpa.claims.length + 1}`, + key_id: null, + account_id: accountId, + participant_id: owner.participant_id, + started_at: summary!.accounts[0]!.cycle_started_at, + ended_at: state.clock, + expires_at: new Date(Date.now() + 900000).toISOString(), + applied_at: null, + revision: state.revision, + historical_contract_policy: + "仅认领已采集且尚无归属的请求,不补造断线数据或历史份额", + accounts: [ + { + account_id: accountId, + account_name: summary!.accounts[0]!.account_name, + request_count: events.length, + token_count: events.reduce((sum, e) => sum + e.total_tokens, 0), + usage_usd: events.reduce((sum, e) => sum + e.usage_usd, 0), + unpriced_request_count: events.filter((e) => e.unpriced).length, + coverage: summary!.accounts[0]!.coverage, + }, + ], + }; + cpa.claims.push(plan); + saveDemoState(state); + return ok(plan, 201); + } + if (pathname === "cpa/keys" && method === "GET") return ok(cpa.keys); + if (pathname === "cpa/keys" && method === "POST") { + const person = state.participants.find( + (p) => p.id === Number(payload.participant_id), + ); + if (!person) return fail("请选择参与者"); + const hash = String(payload.observed_hash ?? ""); + let key = cpa.keys.keys.find((k) => k.observed_hash === hash); + if (key?.bindings.some((b) => !b.ended_at)) return fail("该 Key 已绑定"); + if (!key) { + const observed = cpa.keys.unregistered.find( + (k) => k.observed_hash === hash, + ); + const id = Math.max(0, ...cpa.keys.keys.map((k) => k.id)) + 1; + key = { + id, + name: String(payload.name ?? ""), + hint: observed?.hint ?? String(payload.raw_key ?? "").slice(-4), + observed_hash: hash || String(id).padStart(64, "0"), + bindings: [], + }; + cpa.keys.keys.push(key); + cpa.keys.unregistered = cpa.keys.unregistered.filter( + (k) => k.observed_hash !== hash, + ); + } + const binding = { + id: + Math.max( + 0, + ...cpa.keys.keys.flatMap((k) => k.bindings.map((b) => b.id)), + ) + 1, + key_id: key.id, + hint: key.hint, + name: key.name, + participant_id: person.id, + participant_name: person.name, + started_at: state.clock, + ended_at: null, + }; + key.bindings.push(binding); + state.revision++; + saveDemoState(state); + return ok(binding, 201); + } + const bindingMatch = /^cpa\/bindings\/(\d+)$/.exec(pathname); + if (bindingMatch && method === "PATCH") { + const key = cpa.keys.keys.find((k) => + k.bindings.some((b) => b.id === Number(bindingMatch[1])), + ); + if (!key) return fail("绑定不存在", 404); + key.name = String(payload.name ?? ""); + for (const binding of key.bindings) binding.name = key.name; + saveDemoState(state); + return ok(key.bindings.find((b) => b.id === Number(bindingMatch[1]))); + } + if (bindingMatch && method === "DELETE") { + const binding = cpa.keys.keys + .flatMap((k) => k.bindings) + .find((b) => b.id === Number(bindingMatch[1])); + if (!binding) return fail("绑定不存在", 404); + binding.ended_at ??= state.clock; + state.revision++; + saveDemoState(state); + return ok(binding); + } + if (pathname === "cpa/claims/preview" && method === "POST") { + const start = String(payload.started_at); + const end = String(payload.ended_at); + const key = cpa.keys.keys.find((k) => k.id === Number(payload.key_id)); + if (!key || !(Date.parse(start) < Date.parse(end))) + return fail("请选择有效时间范围"); + if ( + key.bindings.some( + (b) => + Date.parse(b.started_at) < Date.parse(end) && + (!b.ended_at || Date.parse(b.ended_at) > Date.parse(start)), + ) + ) + return fail("此范围已有归属"); + const events = cpa.events.filter( + (e) => + e.key_id === key.id && + Date.parse(e.occurred_at) >= Date.parse(start) && + Date.parse(e.occurred_at) < Date.parse(end), + ); + if (!events.length) return fail("此范围没有已采集请求"); + const plan = { + id: `demo-${cpa.claims.length + 1}`, + key_id: key.id, + participant_id: Number(payload.participant_id), + started_at: start, + ended_at: end, + expires_at: new Date(Date.now() + 900000).toISOString(), + applied_at: null, + revision: state.revision, + historical_contract_policy: "演示:缺少历史份额时不补造历史权益。", + accounts: [ + { + account_id: 3, + account_name: "CPA 拼车账号", + usage_usd: events.reduce((v, e) => v + e.usage_usd, 0), + request_count: events.length, + token_count: events.reduce((v, e) => v + e.total_tokens, 0), + unpriced_request_count: 0, + coverage: { complete: true, uncertain_end: false, gaps: [] }, + }, + ], + }; + cpa.claims.push(plan); + saveDemoState(state); + return ok(plan, 201); + } + const claimMatch = /^cpa\/claims\/([^/]+)\/apply$/.exec(pathname); + if (claimMatch && method === "POST") { + const plan = cpa.claims.find((p) => p.id === claimMatch[1]); + if (!plan) return fail("预览不存在", 404); + if (plan.applied_at) return ok(plan); + if ( + plan.revision !== state.revision || + Date.parse(plan.expires_at) <= Date.now() + ) + return fail("预览已变化,请重新预览"); + if (plan.account_id != null) { + for (const item of cpa.events) { + if ( + item.account_id === plan.account_id && + item.participant_id == null && + item.occurred_at >= plan.started_at && + item.occurred_at < plan.ended_at + ) + item.participant_id = plan.participant_id; + } + plan.applied_at = state.clock; + state.revision++; + saveDemoState(state); + return ok(plan); + } + for (const item of cpa.events) + if ( + item.key_id === plan.key_id && + Date.parse(item.occurred_at) >= Date.parse(plan.started_at) && + Date.parse(item.occurred_at) < Date.parse(plan.ended_at) + ) + item.participant_id = plan.participant_id; + const key = cpa.keys.keys.find((k) => k.id === plan.key_id)!; + key.bindings.push({ + id: + Math.max( + 0, + ...cpa.keys.keys.flatMap((k) => k.bindings.map((b) => b.id)), + ) + 1, + key_id: key.id, + hint: key.hint, + name: key.name, + participant_id: plan.participant_id, + participant_name: + state.participants.find((p) => p.id === plan.participant_id)?.name ?? + "", + started_at: plan.started_at, + ended_at: plan.ended_at, + }); + plan.applied_at = state.clock; + state.revision++; + saveDemoState(state); + return ok(plan); + } + return fail("CPA 演示接口不存在", 404); +} diff --git a/frontend/src/demo/cpaStatus.ts b/frontend/src/demo/cpaStatus.ts new file mode 100644 index 0000000..db0bb7e --- /dev/null +++ b/frontend/src/demo/cpaStatus.ts @@ -0,0 +1,172 @@ +import type { CPAQuotaDetail, CPAResetPreview } from "@/types/accounts"; +import type { DemoState } from "./state"; +import { demoIdentity, saveDemoState } from "./state"; +import type { DemoRequestContext } from "./backend"; + +export function demoCPAStatus( + state: DemoState, + accountId: number, +): CPAQuotaDetail { + const cpa = state.cpa!; + cpa.quotaStatuses ??= {}; + const now = Date.parse(state.clock), + start = now - 5 * 3600000, + end = start + 7 * 86400000; + const iso = (value: number) => new Date(value).toISOString(); + const current = { + request_count: 947, + token_count: 127_700_000, + usage_usd: 120.18, + success_rate: 99.89, + unpriced_request_count: 0, + }; + const previous = { + request_count: 1893, + token_count: 228_700_000, + usage_usd: 336.35, + success_rate: 99.95, + unpriced_request_count: 0, + }; + const result = (cpa.quotaStatuses[accountId] ??= { + totals: { + started_at: iso(start - 86400000), + ended_at: state.clock, + coverage_complete: true, + metrics: { + request_count: 2840, + token_count: 356_400_000, + usage_usd: 456.53, + success_rate: 99.93, + unpriced_request_count: 0, + }, + }, + windows: [ + { + id: "standard-week", + label: "周限额", + used_percent: 11, + reset_at: iso(end), + updated_at: state.clock, + source: "主动 API 查询", + boundary: "provider", + current: { + started_at: iso(start), + ended_at: iso(end), + coverage_complete: true, + metrics: current, + }, + previous: { + started_at: iso(start - 7 * 86400000), + ended_at: iso(start), + coverage_complete: true, + metrics: previous, + }, + prediction: null, + capacity_estimate: { + source: "particle_filter", + capacity_usd: 2000, + lower_usd: 1650, + upper_usd: 2400, + prior_only: false, + as_of: state.clock, + }, + notice: "容量复用粒子轨迹估计;请求数与 Token 总量暂不预测。", + }, + { + id: "spark-week", + label: "GPT-5.3-Codex-Spark 周限额", + used_percent: 0, + reset_at: iso(end + 6000000), + updated_at: state.clock, + source: "主动 API 查询", + boundary: "provider", + current: null, + previous: null, + prediction: null, + notice: "上游未提供完整模型范围,未计算该窗口用量。", + }, + ], + reset: { available_count: 1, can_reset: false, history: [] }, + }); + const identity = demoIdentity(); + const account = state.monitoredAccounts.find((a) => a.id === accountId); + const pool = state.quotaPools.find((p) => p.id === account?.pool_id); + const owners = state.participants.filter( + (p) => + p.enabled && + p.is_owner && + pool?.allocations.some((a) => a.participant_id === p.id), + ); + const user = state.systemUsers.find((u) => u.username === identity?.username); + result.reset.can_reset = Boolean( + identity?.is_staff || + (owners.length === 1 && + user?.participant_ids.includes(owners[0]!.id) && + user.account_ids.includes(accountId)), + ); + return result; +} +export function handleCPAStatus(context: DemoRequestContext): Response | null { + const { pathname, state, method, payload, ok, fail } = context; + const match = + /^account-status\/cpa\/(\d+)\/(reset-preview|resets\/([^/]+)\/confirm)$/.exec( + pathname, + ); + if (!match || method !== "POST") return null; + const accountId = Number(match[1]); + const account = state.monitoredAccounts.find( + (a) => a.id === accountId && a.provider === "cpa", + ); + if (!account) return fail("账号不存在", 404); + const detail = demoCPAStatus(state, accountId); + if (!detail.reset.can_reset) return fail("仅管理员或车主可以重置额度", 403); + const cpa = state.cpa!; + cpa.resetPlans ??= []; + if (match[2] === "reset-preview") { + if (!detail.reset.available_count) return fail("没有可用重置次数"); + const plan: CPAResetPreview = { + id: `demo-reset-${cpa.resetPlans.length + 1}`, + account_id: accountId, + account_name: account.name, + available_count: detail.reset.available_count, + status: "pending", + expires_at: new Date(Date.now() + 300000).toISOString(), + }; + cpa.resetPlans.push(plan); + saveDemoState(state); + return ok(plan, 201); + } + const plan = cpa.resetPlans.find( + (p) => p.id === match[3] && p.account_id === accountId, + ); + if (!plan || payload.confirmed !== true) return fail("请完成二次确认"); + if (plan.status === "succeeded") return ok(plan); + if ( + Date.parse(plan.expires_at) <= Date.now() || + !detail.reset.available_count + ) + return fail("重置确认已过期"); + detail.reset.available_count--; + detail.reset.history.unshift({ + id: plan.id, + status: "succeeded", + created_at: state.clock, + finished_at: state.clock, + }); + for (const window of detail.windows) { + window.used_percent = 0; + window.prediction = null; + window.capacity_estimate = null; + if (window.current) + window.current.metrics = { + request_count: 0, + token_count: 0, + usage_usd: 0, + success_rate: null, + unpriced_request_count: 0, + }; + } + plan.status = "succeeded"; + saveDemoState(state); + return ok(plan); +} diff --git a/frontend/src/demo/fixtures.ts b/frontend/src/demo/fixtures.ts index dc0ce3d..deab3b2 100644 --- a/frontend/src/demo/fixtures.ts +++ b/frontend/src/demo/fixtures.ts @@ -225,6 +225,7 @@ function baseSettings(): AppSettingsData { cpa_double_billing_threshold_tokens: 272000, cpa_double_billing_multiplier: 2, cpa_model_pricing: { + "gpt-5.4": { input: "2.5", cached_input: "0.25", output: "15" }, "gpt-5-codex": { input: "1.25", cached_input: "0.125", @@ -369,7 +370,7 @@ function initializeState(): DemoState { aggregateParticipant(participant); } return { - version: 15, + version: 17, clock: iso(DEMO_ANCHOR), nextParticipantId: 4, nextPoolId: 2, @@ -380,13 +381,20 @@ function initializeState(): DemoState { participants, monitoredAccounts, quotaPools, - sub2apiUsers: participants.map((participant) => ({ - id: participant.sub2api_user_id, - email: participant.sub2api_email, - username: participant.sub2api_username, - status: "active", - role: participant.is_owner ? "admin" : "user", - })), + sub2apiUsers: participants + .filter( + ( + participant, + ): participant is Participant & { sub2api_user_id: number } => + participant.sub2api_user_id != null, + ) + .map((participant) => ({ + id: participant.sub2api_user_id, + email: participant.sub2api_email, + username: participant.sub2api_username, + status: "active", + role: participant.is_owner ? "admin" : "user", + })), systemUsers: [ { id: 1, diff --git a/frontend/src/demo/handlers/dashboard.ts b/frontend/src/demo/handlers/dashboard.ts index 6222119..b8fd171 100644 --- a/frontend/src/demo/handlers/dashboard.ts +++ b/frontend/src/demo/handlers/dashboard.ts @@ -1,3 +1,4 @@ +import { demoCPASummary } from "../cpa"; import type { QuotaAllocationData, QuotaAllocationWritePool, @@ -13,9 +14,12 @@ import { } from "../state"; import { participantBreakdowns } from "./participants"; -function quotaAllocationData(state: DemoState): QuotaAllocationData { +function quotaAllocationData( + state: DemoState, + provider = "sub2api", +): QuotaAllocationData { return { - accounts: state.monitoredAccounts, + accounts: state.monitoredAccounts.filter((a) => a.provider === provider), participants: state.participants.map((participant) => ({ id: participant.id, name: participant.name, @@ -26,7 +30,13 @@ function quotaAllocationData(state: DemoState): QuotaAllocationData { is_owner: participant.is_owner, enabled: participant.enabled, })), - pools: state.quotaPools, + pools: state.quotaPools.filter((p) => + p.account_ids.some((id) => + state.monitoredAccounts.some( + (a) => a.id === id && a.provider === provider, + ), + ), + ), }; } @@ -42,13 +52,23 @@ function quotaPoolSignature(pool: { return JSON.stringify([pool.name, accountIds, allocations]); } -function applyQuotaAllocation(state: DemoState, value: unknown): string | null { +function applyQuotaAllocation( + state: DemoState, + value: unknown, + provider = "sub2api", +): string | null { if (!Array.isArray(value)) return "额度池列表格式无效"; - const accountIds = new Set(state.monitoredAccounts.map((item) => item.id)); + const accountIds = new Set( + state.monitoredAccounts + .filter((a) => a.provider === provider) + .map((item) => item.id), + ); const participantIds = new Set(state.participants.map((item) => item.id)); const existingPools = new Map( - state.quotaPools.map((pool) => [pool.id, pool]), + state.quotaPools + .filter((p) => p.account_ids.some((id) => accountIds.has(id))) + .map((pool) => [pool.id, pool]), ); const seenAccountIds = new Set(); const seenPoolIds = new Set(); @@ -174,7 +194,12 @@ function applyQuotaAllocation(state: DemoState, value: unknown): string | null { }; }); - state.quotaPools = nextPools; + state.quotaPools = [ + ...state.quotaPools.filter( + (p) => !p.account_ids.some((id) => accountIds.has(id)), + ), + ...nextPools, + ]; for (const pool of nextPools) { for (const accountId of pool.account_ids) { const account = state.monitoredAccounts.find( @@ -184,22 +209,30 @@ function applyQuotaAllocation(state: DemoState, value: unknown): string | null { } } for (const participant of state.participants) { - participant.pool_allocations = nextPools.flatMap((pool) => { - const allocation = pool.allocations.find( - (item) => item.participant_id === participant.id, - ); - return allocation - ? [ - { - pool_id: pool.id, - pool_name: pool.name, - share_percent: allocation.share_percent, - account_ids: [...pool.account_ids], - account_count: pool.account_ids.length, - }, - ] - : []; - }); + participant.pool_allocations = state.quotaPools + .filter((pool) => + pool.account_ids.some((id) => + state.monitoredAccounts.some( + (a) => a.id === id && a.provider === "sub2api", + ), + ), + ) + .flatMap((pool) => { + const allocation = pool.allocations.find( + (item) => item.participant_id === participant.id, + ); + return allocation + ? [ + { + pool_id: pool.id, + pool_name: pool.name, + share_percent: allocation.share_percent, + account_ids: [...pool.account_ids], + account_count: pool.account_ids.length, + }, + ] + : []; + }); participant.account_breakdowns = participantBreakdowns( state, participant.id, @@ -221,7 +254,10 @@ export function handleDashboard({ }: DemoRequestContext): Response | null { if (method === "GET" && pathname === "dashboard") { const accountId = Number(url.searchParams.get("account_id")); - return ok(dashboardData(state, accountId)); + return ok({ + ...dashboardData(state, accountId), + cpa_summary: demoCPASummary(state, accountId), + }); } if (pathname === "monitor/run" && method === "GET") { const enabledAccounts = state.monitoredAccounts.filter( @@ -293,13 +329,21 @@ export function handleDashboard({ return ok({ applied: true }); } if (method === "GET" && pathname === "quota-allocation") { - return ok(quotaAllocationData(state)); + return ok( + quotaAllocationData(state, url.searchParams.get("provider") ?? "sub2api"), + ); } if (method === "PUT" && pathname === "quota-allocation") { - const validationError = applyQuotaAllocation(state, payload.pools); + const validationError = applyQuotaAllocation( + state, + payload.pools, + String(payload.provider ?? "sub2api"), + ); if (validationError) return fail(validationError); saveDemoState(state); - return ok(quotaAllocationData(state)); + return ok( + quotaAllocationData(state, String(payload.provider ?? "sub2api")), + ); } return null; } diff --git a/frontend/src/demo/handlers/observations.ts b/frontend/src/demo/handlers/observations.ts index 1970a42..b0aa66c 100644 --- a/frontend/src/demo/handlers/observations.ts +++ b/frontend/src/demo/handlers/observations.ts @@ -90,29 +90,31 @@ function fastCorrectionData( observation.legacy_fast_only ?? observation.fast_correction_calculated, corrected_fast_cost_usd: fastCost * 3, collection_error: "", - users: state.participants.map((participant, index) => { - const requestCount = Math.round( - totalRequests * [0.39, 0.35, 0.26][index], - ); - const participantFast = Math.round(requestCount * 0.34); - const billed = fastCost * [0.39, 0.35, 0.26][index] * 2; - return { - sub2api_user_id: participant.sub2api_user_id, - username: participant.sub2api_username, - email: participant.sub2api_email, - display_name: participant.name, - request_count: requestCount, - fast_request_count: participantFast, - non_fast_request_count: requestCount - participantFast, - fast_billed_cost_usd: billed, - correction_usd: billed * 0.5, - fast_correction_usd: billed * 0.5, - correction_total_usd: billed * 0.5, - long_context_correction_usd: 0, - model_correction_usd: 0, - corrected_fast_cost_usd: billed * 1.5, - }; - }), + users: state.participants + .filter((p) => p.sub2api_user_id != null) + .map((participant, index) => { + const requestCount = Math.round( + totalRequests * [0.39, 0.35, 0.26][index], + ); + const participantFast = Math.round(requestCount * 0.34); + const billed = fastCost * [0.39, 0.35, 0.26][index] * 2; + return { + sub2api_user_id: participant.sub2api_user_id!, + username: participant.sub2api_username, + email: participant.sub2api_email, + display_name: participant.name, + request_count: requestCount, + fast_request_count: participantFast, + non_fast_request_count: requestCount - participantFast, + fast_billed_cost_usd: billed, + correction_usd: billed * 0.5, + fast_correction_usd: billed * 0.5, + correction_total_usd: billed * 0.5, + long_context_correction_usd: 0, + model_correction_usd: 0, + corrected_fast_cost_usd: billed * 1.5, + }; + }), }; } diff --git a/frontend/src/demo/handlers/participants.ts b/frontend/src/demo/handlers/participants.ts index b971a62..5db8cdc 100644 --- a/frontend/src/demo/handlers/participants.ts +++ b/frontend/src/demo/handlers/participants.ts @@ -66,7 +66,10 @@ export function handleParticipants({ id, name: String(payload.name ?? `演示参与者 ${id}`), email: String(payload.email ?? `participant-${id}@example.test`), - sub2api_user_id: Number(payload.sub2api_user_id ?? 100 + id), + sub2api_user_id: + payload.sub2api_user_id == null + ? null + : Number(payload.sub2api_user_id), sub2api_username: String(payload.sub2api_username ?? `demo-user-${id}`), sub2api_email: String( payload.sub2api_email ?? `participant-${id}@example.test`, @@ -96,9 +99,10 @@ export function handleParticipants({ Object.assign(participant, { name: String(payload.name ?? participant.name), email: String(payload.email ?? participant.email), - sub2api_user_id: Number( - payload.sub2api_user_id ?? participant.sub2api_user_id, - ), + sub2api_user_id: + payload.sub2api_user_id === null + ? null + : Number(payload.sub2api_user_id ?? participant.sub2api_user_id), sub2api_username: String( payload.sub2api_username ?? participant.sub2api_username, ), diff --git a/frontend/src/demo/handlers/reporting.ts b/frontend/src/demo/handlers/reporting.ts index d169b7e..2212e41 100644 --- a/frontend/src/demo/handlers/reporting.ts +++ b/frontend/src/demo/handlers/reporting.ts @@ -1,3 +1,5 @@ +import { demoCPAStatus } from "../cpaStatus"; +import { demoCPAKeySeries, demoCPASummary } from "../cpa"; import type { AccountStatusData } from "@/types/accounts"; import type { Observation } from "@/types/observations"; import type { ParticleTrajectoryData } from "@/types/particleTrajectory"; @@ -87,7 +89,10 @@ function statisticsData(state: DemoState, url: URL): StatisticsData { state.monitoredAccounts.find((item) => item.enabled) ?? state.monitoredAccounts[0]!; if (account.provider === "cpa") { + const summary = demoCPASummary(state, account.id); + const cycle = summary?.accounts[0]; return { + cpa_summary: summary, account: { id: account.id, provider: account.provider, @@ -99,7 +104,35 @@ function statisticsData(state: DemoState, url: URL): StatisticsData { capacity_series: [], fast_correction_enabled: false, capacity_summary: { - cycle: null, + cycle: cycle + ? { + estimate_usd: 2000, + raw_estimate_usd: 2000, + start_cost_usd: 0, + start_cost_breakdown: { + sub2api_cost_usd: 0, + fast_correction_usd: 0, + total_cost_usd: 0, + }, + start_percent: 0, + end_cost_usd: cycle.usage_usd, + end_cost_breakdown: { + sub2api_cost_usd: cycle.usage_usd, + fast_correction_usd: 0, + total_cost_usd: cycle.usage_usd, + }, + end_percent: cycle.usage_usd / 20, + cost_usd: cycle.usage_usd, + used_percent: cycle.usage_usd / 20, + effective_usd_per_percent: 20, + calculation_model: "endpoint_ratio", + rate_calculated: true, + confidence: "中", + observed_at: state.clock, + starts_at: cycle.cycle_started_at, + resets_at: cycle.resets_at!, + } + : null, today: { estimate_usd: null, minimum_usd: null, @@ -117,14 +150,19 @@ function statisticsData(state: DemoState, url: URL): StatisticsData { observed_to: null, min_percent_span: 3, sufficient: false, - reason: "连接后尚无 CPA 观测记录", + reason: "演示数据的日内百分比跨度不足", }, }, usage_days: usageDays, usage_precision: usagePrecision, sample_interval_minutes: Number(state.settings.local_poll_minutes), participant_series: [], - cpa_api_key_series: [], + cpa_api_key_series: demoCPAKeySeries( + state, + account.id, + usageDays, + usagePrecision, + ), }; } const now = Date.parse(state.clock); @@ -307,13 +345,13 @@ function statisticsData(state: DemoState, url: URL): StatisticsData { participant_series: account.provider === "sub2api" ? state.participants - .filter((item) => item.enabled) + .filter((item) => item.enabled && item.sub2api_user_id != null) .map((participant) => ({ participant_id: participant.id, participant_name: participant.name, account_id: account.id, external_account_id: account.external_account_id!, - sub2api_user_id: participant.sub2api_user_id, + sub2api_user_id: participant.sub2api_user_id!, points: participantUsagePoints( state, participant.id, @@ -396,6 +434,7 @@ function accountStatusData(state: DemoState): AccountStatusData { enabled: account.enabled, quota_query_mode: account.quota_query_mode, cycles, + cpa_quota: isCPA ? demoCPAStatus(state, account.id) : undefined, runtime: { name: account.name, account_type: isCPA ? "pro" : "oauth", diff --git a/frontend/src/demo/handlers/settings.ts b/frontend/src/demo/handlers/settings.ts index df29cc6..ac7b79a 100644 --- a/frontend/src/demo/handlers/settings.ts +++ b/frontend/src/demo/handlers/settings.ts @@ -1,3 +1,4 @@ +import { repriceCPADemo } from "../cpa"; import type { MonitoredAccount } from "@/types/accounts"; import type { AppSettingsData, @@ -224,6 +225,7 @@ export function handleSettings({ | FastCorrectionRule[]; } } + repriceCPADemo(state); saveDemoState(state); return ok(state.settings); } diff --git a/frontend/src/demo/selectors.ts b/frontend/src/demo/selectors.ts index 6204c34..d0737c8 100644 --- a/frontend/src/demo/selectors.ts +++ b/frontend/src/demo/selectors.ts @@ -217,7 +217,7 @@ export function apiUsageData( return { participant_id: participant.id, participant_name: participant.name, - sub2api_user_id: participant.sub2api_user_id, + sub2api_user_id: participant.sub2api_user_id!, starts_at: period.startedAt, observed_to: latest.observed_at, cost_basis: "actual", diff --git a/frontend/src/demo/state.ts b/frontend/src/demo/state.ts index cefa0e7..b47027e 100644 --- a/frontend/src/demo/state.ts +++ b/frontend/src/demo/state.ts @@ -1,3 +1,4 @@ +import { initializeCPADemo, type DemoCPAState } from "./cpa"; import type { PagePermission } from "@/config/pagePermissions"; import type { MonitoredAccount } from "@/types/accounts"; import type { Observation } from "@/types/observations"; @@ -53,7 +54,8 @@ export interface DemoPeriod { } export interface DemoState { - version: 15; + cpa?: DemoCPAState; + version: 17; clock: string; nextParticipantId: number; nextPoolId: number; @@ -81,12 +83,16 @@ export function loadDemoState(): DemoState { if (stored) { try { const parsed = JSON.parse(stored) as DemoState; - if (parsed.version === 15) return parsed; + if (parsed.version === 17) { + initializeCPADemo(parsed); + return parsed; + } } catch { sessionStorage.removeItem(DEMO_STATE_KEY); } } const initial = initializeState(); + initializeCPADemo(initial); saveDemoState(initial); return initial; } @@ -98,6 +104,7 @@ export function saveDemoState(state: DemoState): void { export function resetDemoState(): DemoState { sessionStorage.removeItem(DEMO_STATE_KEY); const state = initializeState(); + initializeCPADemo(state); saveDemoState(state); window.dispatchEvent(new CustomEvent("sub2pool:demo-reset")); return state; diff --git a/frontend/src/demo/trajectoryFixtures.ts b/frontend/src/demo/trajectoryFixtures.ts index 5138fd9..76acb16 100644 --- a/frontend/src/demo/trajectoryFixtures.ts +++ b/frontend/src/demo/trajectoryFixtures.ts @@ -98,7 +98,8 @@ function buildPeriods(participants: Participant[]): { } { const observations: Observation[] = []; const periods: DemoPeriod[] = []; - const counts = [188, 216, 203, 224, 207]; + // Leave room in sessionStorage for CPA requests and interactive demo edits. + const counts = [94, 108, 102, 112, 104]; let observationId = 1; for (let periodIndex = 0; periodIndex < counts.length; periodIndex += 1) { diff --git a/frontend/src/router/routes/index.ts b/frontend/src/router/routes/index.ts index 234ec54..0a49a2a 100644 --- a/frontend/src/router/routes/index.ts +++ b/frontend/src/router/routes/index.ts @@ -55,6 +55,12 @@ export const appRoutes: RouteRecordRaw[] = [ component: () => import("@/views/statistics/StatisticsView.vue"), meta: pageMeta("额度统计", "statistics"), }, + { + path: "cpa-requests", + name: "cpa-requests", + component: () => import("@/views/cpa-requests/CPARequestsView.vue"), + meta: pageMeta("CPA 请求明细", "statistics"), + }, { path: "notifications", name: "notifications", diff --git a/frontend/src/types/accounts.ts b/frontend/src/types/accounts.ts index d2e4af0..3de52e2 100644 --- a/frontend/src/types/accounts.ts +++ b/frontend/src/types/accounts.ts @@ -1,3 +1,4 @@ +import type { CPACapacityEstimate } from "./cpa"; import type { CorrectionBreakdown } from "./common"; export type QuotaProfile = "auto" | "plus" | "pro_5x" | "pro_20x"; export type EffectiveQuotaProfile = Exclude; @@ -109,6 +110,7 @@ export interface AccountCycleUsage { is_current: boolean; } export interface AccountStatusAccount { + cpa_quota?: CPAQuotaDetail; id: number; provider: AccountProvider; source_account_id: string; @@ -136,3 +138,53 @@ export interface OpenAIAccountOption { status: string; schedulable: boolean; } + +export interface CPAQuotaMetrics { + request_count: number; + token_count: number; + usage_usd: number; + success_rate?: number | null; + unpriced_request_count?: number; +} +export interface CPAQuotaPeriod { + started_at: string; + ended_at: string; + metrics: CPAQuotaMetrics; + coverage_complete: boolean; + notice?: string; +} +export interface CPAQuotaDetail { + totals: CPAQuotaPeriod; + windows: { + id: string; + label: string; + used_percent: number | null; + reset_at: string | null; + updated_at: string | null; + source: string; + boundary: "unknown" | "provider"; + current: CPAQuotaPeriod | null; + previous: CPAQuotaPeriod | null; + prediction: CPAQuotaMetrics | null; + capacity_estimate?: CPACapacityEstimate | null; + notice: string | null; + }[]; + reset: { + available_count: number | null; + can_reset: boolean; + history: { + id: string; + status: string; + created_at: string; + finished_at: string | null; + }[]; + }; +} +export interface CPAResetPreview { + id: string; + account_id: number; + account_name: string; + available_count: number; + status: string; + expires_at: string; +} diff --git a/frontend/src/types/cpa.ts b/frontend/src/types/cpa.ts new file mode 100644 index 0000000..662d96a --- /dev/null +++ b/frontend/src/types/cpa.ts @@ -0,0 +1,227 @@ +export interface CPACapacityEstimate { + source: "particle_filter" | "quota_model"; + capacity_usd: number; + lower_usd: number | null; + upper_usd: number | null; + prior_only: boolean; + as_of: string; +} +import type { CPACollectorStatus } from "./settings"; + +export interface CPATotals { + usage_usd: number; + request_count: number; + token_count: number; + unpriced_request_count: number; +} +export interface CPACoverage { + complete: boolean; + uncertain_end: boolean; + gaps: { started_at: string; ended_at: string }[]; +} +export interface CPAMember extends CPATotals { + participant_id: number; + participant_name: string; + is_self: boolean; + is_owner: boolean; + share_percent: number | null; + quota_available: boolean; + is_overused: boolean; + expected_entitlement_usd: number | null; + consumed_entitlement_usd: number | null; + remaining_entitlement_usd: number | null; + account_breakdowns: { + account_id: number; + quota_available: boolean; + quota_unavailable_reasons: string[]; + quota_as_of: string | null; + charged_percent: number | null; + remaining_share_percent: number | null; + usage_usd: number; + estimated_capacity_usd: number | null; + expected_entitlement_usd: number | null; + consumed_entitlement_usd: number | null; + remaining_entitlement_usd: number | null; + }[]; +} +export interface CPABillingMember { + participant_id: number; + usage_usd: number; + usage_percent: number | null; + entitlement_usd: number | null; + remaining_usd: number | null; + recommended_usd: number | null; + recommended_percent: number | null; + completed_overuse_usd: number | null; + projected_overuse: boolean | null; +} +export interface CPAWeeklyDistribution { + capacity_estimate?: CPACapacityEstimate | null; + coverage_complete?: boolean; + account_id: number; + account_name: string; + started_at: string; + resets_at: string | null; + quota_as_of: string | null; + requests_as_of: string | null; + capacity_usd: number | null; + remaining_usd: number | null; + upstream_remaining_percent: number | null; + usage_usd: number; + unpriced_request_count: number; + unattributed_usd: number; + other_members_usd: number; + members: { + participant_id: number; + usage_usd: number; + usage_percent: number | null; + }[]; +} +export interface CPABillingSummary { + configured: boolean; + anchor_date: string | null; + timezone: string; + started_at: string | null; + ended_at: string | null; + generated_at: string; + capacity_usd: number | null; + actual_capacity_usd: number | null; + future_capacity_usd: number | null; + expired_usd: number | null; + available_usd: number | null; + usage_usd: number; + unattributed_usd: number; + other_members_usd: number; + unallocated_usd: number | null; + reasons: string[]; + cycles: { + account_id: number; + account_name: string; + started_at: string; + ended_at: string; + kind: "historical" | "current" | "future"; + capacity_usd: number | null; + full_capacity_usd: number | null; + capacity_estimate?: CPACapacityEstimate | null; + expired_usd: number | null; + quota_as_of: string | null; + reasons: string[]; + }[]; + members: CPABillingMember[]; +} +export interface CPAPoolSummary { + weekly_distribution?: CPAWeeklyDistribution[]; + billing_summary?: CPABillingSummary; + pool_id: number; + pool_name: string; + selected_account_id: number; + partial_scope: boolean; + accounts: (CPATotals & { + account_id: number; + account_name: string; + owner: { + participant_id: number | null; + participant_name: string | null; + started_at: string | null; + status: "active" | "missing" | "ambiguous"; + }; + selected: boolean; + upstream_used_percent?: number | null; + quota_as_of: string | null; + requests_as_of: string | null; + cycle_started_at: string; + resets_at: string | null; + coverage: CPACoverage; + quota_available: boolean; + quota_unavailable_reasons: string[]; + })[]; + members: CPAMember[]; + unattributed: CPATotals; + collector: CPACollectorStatus; + cost_estimate: boolean; + enforcement_enabled: boolean; + generated_at: string; +} +export interface CPABinding { + id: number; + key_id: number; + hint: string; + name: string; + participant_id: number; + participant_name: string; + started_at: string; + ended_at: string | null; +} +export interface CPAKeys { + keys: { + id: number; + name: string; + hint: string; + observed_hash: string; + bindings: CPABinding[]; + }[]; + unregistered: { observed_hash: string; hint: string }[]; +} +export interface CPAClaim { + id: string; + key_id: number | null; + account_id?: number | null; + participant_id: number; + started_at: string; + ended_at: string; + expires_at: string; + applied_at: string | null; + historical_contract_policy: string; + accounts: (CPATotals & { + account_id: number; + account_name: string; + coverage: CPACoverage; + })[]; +} +export interface CPARequest { + id: number; + occurred_at: string; + request_id: string; + api_key_hint: string; + api_key_alias?: string; + model: string; + endpoint: string; + input_tokens: number; + cached_input_tokens: number; + output_tokens: number; + reasoning_tokens: number; + total_tokens: number; + failed: boolean; + latency_ms: number; + ttft_ms: number; + usage_usd: number; + unpriced: boolean; + requested_service_tier: string; + response_service_tier: string; +} +export interface CPARequestSummary { + request_count: number; + failed_count: number; + input_tokens: number; + cached_input_tokens: number; + output_tokens: number; + reasoning_tokens: number; + total_tokens: number; + unpriced_request_count: number; + usage_usd: number; + average_latency_ms: number | null; + average_ttft_ms: number | null; +} +export interface CPARequests { + summary: CPARequestSummary | null; + started_at: string; + ended_at: string; + account_id: number; + items: CPARequest[]; + total: number; + page: number; + page_size: number; + keys: { id: number; name: string; hint: string }[]; + models: string[]; + generated_at: string; +} diff --git a/frontend/src/types/cpaPricing.ts b/frontend/src/types/cpaPricing.ts new file mode 100644 index 0000000..d792bcf --- /dev/null +++ b/frontend/src/types/cpaPricing.ts @@ -0,0 +1,25 @@ +import type { CPAModelPricing } from "./settings"; + +export interface CPAPricingInventory { + pricing: CPAModelPricing; + models: { + model: string; + request_count: number; + token_count: number; + missing: boolean; + pricing_model: string | null; + }[]; + missing_model_count: number; + unpriced_request_count: number; + source: string; + source_url: string; + generated_at: string; +} +export interface CPAPricingSync extends CPAPricingInventory { + added: { + model: string; + source_model: string; + price: CPAModelPricing[string]; + }[]; + unresolved: { model: string; reason: string }[]; +} diff --git a/frontend/src/types/dashboard.ts b/frontend/src/types/dashboard.ts index e5a2721..af6e672 100644 --- a/frontend/src/types/dashboard.ts +++ b/frontend/src/types/dashboard.ts @@ -1,3 +1,4 @@ +import type { CPAPoolSummary } from "./cpa"; import type { MonitoredAccount } from "./accounts"; import type { CostBreakdown } from "./common"; import type { Participant } from "./participants"; @@ -35,6 +36,7 @@ export interface ModelDiagnostics { }; } export interface DashboardData { + cpa_summary?: CPAPoolSummary | null; configured: boolean; monitoring_enabled: boolean; accounts: MonitoredAccount[]; diff --git a/frontend/src/types/participants.ts b/frontend/src/types/participants.ts index 94f2507..902c6f9 100644 --- a/frontend/src/types/participants.ts +++ b/frontend/src/types/participants.ts @@ -1,9 +1,10 @@ import type { MonitoredAccount } from "./accounts"; export interface Snapshot { + cpa_contract_known?: boolean; participant_id: number; participant_name: string; - source_sub2api_user_id?: number; + source_sub2api_user_id?: number | null; quota_pool_id?: number | null; quota_pool_name?: string; pool_contract_revision?: number | null; @@ -14,7 +15,7 @@ export interface Snapshot { charged_cycle_percent: number; charged_percent_lower: number | null; charged_percent_upper: number | null; - remaining_share_percent: number; + remaining_share_percent: number | null; current_balance_usd: number | null; recommended_balance_usd: number | null; recommended_balance_min_usd: number | null; @@ -102,7 +103,7 @@ export interface Participant { id: number; name: string; email: string; - sub2api_user_id: number; + sub2api_user_id: number | null; sub2api_username: string; sub2api_email: string; sub2api_identity: string; @@ -118,7 +119,7 @@ export interface Participant { export interface QuotaAllocationParticipant { id: number; name: string; - sub2api_user_id: number; + sub2api_user_id: number | null; sub2api_username: string; sub2api_email: string; sub2api_identity: string; @@ -138,6 +139,7 @@ export interface QuotaPoolAllocation { total_share_percent: number; } export interface QuotaAllocationData { + provider?: "sub2api" | "cpa"; accounts: MonitoredAccount[]; participants: QuotaAllocationParticipant[]; pools: QuotaPoolAllocation[]; @@ -149,6 +151,7 @@ export interface QuotaAllocationWritePool { allocations: QuotaPoolAllocationEntry[]; } export interface QuotaAllocationWrite { + provider?: "sub2api" | "cpa"; pools: QuotaAllocationWritePool[]; } export interface Sub2APIUserOption { diff --git a/frontend/src/types/particleTrajectory.ts b/frontend/src/types/particleTrajectory.ts index b425659..a95bb9b 100644 --- a/frontend/src/types/particleTrajectory.ts +++ b/frontend/src/types/particleTrajectory.ts @@ -14,6 +14,7 @@ export interface ParticleTrajectoryPoint { range_min_usd: number; range_max_usd: number; range_stage: number; + range_inherited?: boolean; range_direction: "upper" | "lower" | null; ess_fraction: number; resampled: boolean; diff --git a/frontend/src/types/statistics.ts b/frontend/src/types/statistics.ts index 24616e6..0d47fb1 100644 --- a/frontend/src/types/statistics.ts +++ b/frontend/src/types/statistics.ts @@ -1,3 +1,4 @@ +import type { CPAPoolSummary } from "./cpa"; import type { CorrectionBreakdown } from "./common"; import type { MonitoredAccount } from "./accounts"; import type { CostBreakdown } from "./common"; @@ -140,6 +141,7 @@ export interface APIUsageBreakdown extends CorrectionBreakdown { api_keys: APIKeyUsageItem[]; } export interface StatisticsData { + cpa_summary?: CPAPoolSummary | null; account: Pick< MonitoredAccount, "id" | "provider" | "source_account_id" | "external_account_id" | "name" diff --git a/frontend/src/views/account-status/AccountStatusView.vue b/frontend/src/views/account-status/AccountStatusView.vue index 239db9a..6616eff 100644 --- a/frontend/src/views/account-status/AccountStatusView.vue +++ b/frontend/src/views/account-status/AccountStatusView.vue @@ -1,4 +1,5 @@ + diff --git a/frontend/src/views/account-status/components/CPAQuotaMetricList.vue b/frontend/src/views/account-status/components/CPAQuotaMetricList.vue new file mode 100644 index 0000000..b4e835f --- /dev/null +++ b/frontend/src/views/account-status/components/CPAQuotaMetricList.vue @@ -0,0 +1,85 @@ + + diff --git a/frontend/src/views/cpa-requests/CPARequestsView.vue b/frontend/src/views/cpa-requests/CPARequestsView.vue new file mode 100644 index 0000000..c393d02 --- /dev/null +++ b/frontend/src/views/cpa-requests/CPARequestsView.vue @@ -0,0 +1,774 @@ + + + diff --git a/frontend/src/views/index/Index.vue b/frontend/src/views/index/Index.vue index ef40484..e8cabbf 100644 --- a/frontend/src/views/index/Index.vue +++ b/frontend/src/views/index/Index.vue @@ -1,6 +1,8 @@ - - diff --git a/frontend/src/views/statistics/StatisticsView.vue b/frontend/src/views/statistics/StatisticsView.vue index 109f984..7580009 100644 --- a/frontend/src/views/statistics/StatisticsView.vue +++ b/frontend/src/views/statistics/StatisticsView.vue @@ -1,6 +1,9 @@