diff --git a/.agents/skills/unidesk-sub2api/references/codex-pool.md b/.agents/skills/unidesk-sub2api/references/codex-pool.md index ea9997e6..7c96d50c 100644 --- a/.agents/skills/unidesk-sub2api/references/codex-pool.md +++ b/.agents/skills/unidesk-sub2api/references/codex-pool.md @@ -155,6 +155,7 @@ bun scripts/cli.ts platform-infra sub2api codex-pool cleanup-probes --target D60 - `--active-since ` 从原生 usage 选取相对窗口内有请求的非管理员活跃用户; - `--active-start --active-end ` 使用精确半开区间 `[start,end)`;未携带 UTC 偏移的输入按 `config/platform-infra/sub2api-codex-pool.yaml#credits.timezone` 解释,并同时披露本地与 UTC 边界; - `--users ` 使用精确 selector;相对窗口、固定窗口和精确 selector 三者必须且只能选择一个; + - `config/platform-infra/sub2api-codex-pool.yaml#credits.excludedUsers` 声明不得获得客户补偿的精确用户 ID 或邮箱;活跃窗口和显式用户模式都必须排除,并在默认表格中披露匹配 selector 与原因; - 默认 dry-run,逐用户披露完整邮箱、用户 ID、请求数、当前余额、增加额和目标余额; - 活跃窗口只允许生成计划;实际写入必须把 dry-run 返回的用户 ID 固定为精确 `--users` selectors,再显式 `--confirm`,防止移动窗口在确认时扩大或缩小名单; - 确认写入为每个用户生成由精确名单、金额和原因确定的 `Idempotency-Key`,调用原生 `POST /api/v1/admin/users/:id/balance` 的 `add` 操作,随后逐用户回读余额并对账; diff --git a/config/platform-infra/sub2api-codex-pool.yaml b/config/platform-infra/sub2api-codex-pool.yaml index 31939b6c..fd73e785 100644 --- a/config/platform-infra/sub2api-codex-pool.yaml +++ b/config/platform-infra/sub2api-codex-pool.yaml @@ -78,6 +78,9 @@ profit: credits: timezone: Asia/Shanghai + excludedUsers: + - zhuo@test.com + - huiguo@test.com pool: groupName: unidesk-codex-pool diff --git a/scripts/src/platform-infra-sub2api-codex/config.ts b/scripts/src/platform-infra-sub2api-codex/config.ts index 955b840c..8b1bb558 100644 --- a/scripts/src/platform-infra-sub2api-codex/config.ts +++ b/scripts/src/platform-infra-sub2api-codex/config.ts @@ -204,12 +204,19 @@ export function readCodexPoolConfig(): CodexPoolConfig { function readCreditsConfig(value: unknown): CodexPoolCreditsConfig { if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.credits must be a YAML object`); const timezone = requiredStringConfigField(value, "timezone", "credits"); + const excludedUsers = readUniqueStringArray(value.excludedUsers, "credits.excludedUsers", true); + if (excludedUsers.some((selector) => selector.length > 256 || /[\r\n\0]/u.test(selector))) { + throw new Error(`${codexPoolConfigPath}.credits.excludedUsers contains an unsupported selector`); + } + if (new Set(excludedUsers.map((selector) => selector.toLowerCase())).size !== excludedUsers.length) { + throw new Error(`${codexPoolConfigPath}.credits.excludedUsers must not contain case-insensitive duplicates`); + } try { new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(new Date(0)); } catch { throw new Error(`${codexPoolConfigPath}.credits.timezone must be a valid IANA timezone`); } - return { timezone }; + return { timezone, excludedUsers }; } function readProfitConfig(value: unknown): CodexPoolProfitConfig { diff --git a/scripts/src/platform-infra-sub2api-codex/credits.ts b/scripts/src/platform-infra-sub2api-codex/credits.ts index 664118f7..9f721b14 100644 --- a/scripts/src/platform-infra-sub2api-codex/credits.ts +++ b/scripts/src/platform-infra-sub2api-codex/credits.ts @@ -13,6 +13,7 @@ interface CreditsOptions { activeStartUtc: string | null; activeEndUtc: string | null; timezone: string; + excludedUsers: string[]; users: string[]; amountUsd: number; reason: string; @@ -24,7 +25,7 @@ interface CreditsOptions { export async function codexPoolCredits(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { if (args.includes("--help")) return renderCreditsHelp(); const pool = readCodexPoolConfig(); - const options = parseCreditsOptions(args, pool.credits.timezone); + const options = parseCreditsOptions(args, pool.credits); const target = codexPoolRuntimeTarget(options.targetId); const remote = await runRemoteCodexPoolScript(config, "credits", creditsScript(options, pool, target), target); const report = parseJsonOutput(remote.stdout); @@ -41,6 +42,7 @@ export async function codexPoolCredits(config: UniDeskConfig, args: string[]): P activeStartUtc: options.activeStartUtc, activeEndUtc: options.activeEndUtc, timezone: options.timezone, + excludedUsers: options.excludedUsers, users: options.users, amountUsd: options.amountUsd, reason: options.reason, @@ -53,7 +55,8 @@ export async function codexPoolCredits(config: UniDeskConfig, args: string[]): P return options.json ? renderCreditsJson(response) : renderCredits(response); } -function parseCreditsOptions(args: string[], timezone: string): CreditsOptions { +function parseCreditsOptions(args: string[], creditsConfig: { timezone: string; excludedUsers: string[] }): CreditsOptions { + const { timezone, excludedUsers } = creditsConfig; const [action = "grant", ...rest] = args; if (action !== "grant") throw new Error("credits usage: grant (--active-since 24h|--active-start --active-end |--users ) --amount-usd --reason [--confirm] [--target ] [--json]"); let activeSince: string | null = null; @@ -110,7 +113,7 @@ function parseCreditsOptions(args: string[], timezone: string): CreditsOptions { if (confirm && users.length === 0) throw new Error("confirmed credits require the exact --users selectors returned by an active-user dry-run"); if (amountUsd === null) throw new Error("credits grant requires --amount-usd"); if (reason === null || reason.length === 0 || reason.length > 500 || /[\r\n\0]/u.test(reason)) throw new Error("credits grant requires a single-line --reason up to 500 characters"); - return { activeSince, activeStart, activeEnd, activeStartUtc, activeEndUtc, timezone, users, amountUsd, reason, confirm, targetId, json }; + return { activeSince, activeStart, activeEnd, activeStartUtc, activeEndUtc, timezone, excludedUsers, users, amountUsd, reason, confirm, targetId, json }; } function isIsoDateTime(value: string): boolean { @@ -183,6 +186,7 @@ function renderCreditsHelp(): RenderedCliResult { "Notes:", " Default mode is dry-run. --active-since selects distinct active non-admin users from native usage.", " Offset-free fixed-window values use config/platform-infra/sub2api-codex-pool.yaml#credits.timezone and the interval is [start,end).", + " YAML credits.excludedUsers selectors are excluded from both active-window and exact-user grants.", " --confirm performs additive balance writes and then reads every selected user back.", ].join("\n"), contentType: "text/plain", @@ -193,8 +197,9 @@ function renderCredits(response: Record): RenderedCliResult { const report = record(response.report); const summary = record(report.summary); const rows = arrayOfRecords(report.items); + const excludedRows = arrayOfRecords(report.excluded); const lines = [ - `SUB2API USER CREDITS ok=${response.ok === true} mode=${text(report.mode)} selection=${text(record(report.selection).kind)} selected=${text(summary.selected)} amount-usd=${text(summary.amountUsd)} total-usd=${text(summary.totalAmountUsd)}`, + `SUB2API USER CREDITS ok=${response.ok === true} mode=${text(report.mode)} selection=${text(record(report.selection).kind)} selected=${text(summary.selected)} excluded=${text(summary.excluded)} amount-usd=${text(summary.amountUsd)} total-usd=${text(summary.totalAmountUsd)}`, `WRITE attempted=${text(summary.writeAttempted)} succeeded=${text(summary.writeSucceeded)} failed=${text(summary.writeFailed)} reconciled=${text(summary.reconciled)} mutation=${text(report.mutation)}`, ]; const selection = record(report.selection); @@ -211,10 +216,21 @@ function renderCredits(response: Record): RenderedCliResult { ]), ])); } + if (excludedRows.length > 0) { + lines.push("", "EXCLUDED", renderTable([ + ["EMAIL", "USER_ID", "REQUESTS", "REASON", "MATCHED_SELECTOR"], + ...excludedRows.map((row) => [ + text(row.email), text(row.userId), text(row.requestCount), text(row.reason), text(row.matchedSelector), + ]), + ])); + } if (report.mode === "dry-run") { - const selectors = Array.isArray(report.confirmationUserIds) ? report.confirmationUserIds.join(",") : ""; + const confirmationUserIds = Array.isArray(report.confirmationUserIds) ? report.confirmationUserIds : []; + const selectors = confirmationUserIds.length > 0 ? confirmationUserIds.join(",") : ""; lines.push("", `CONFIRM SELECTORS --users ${selectors}`); - lines.push("NEXT: use these exact selectors with --confirm only after explicit user authorization."); + lines.push(confirmationUserIds.length > 0 + ? "NEXT: use these exact selectors with --confirm only after explicit user authorization." + : "NEXT: no users remain eligible for confirmation."); } return { ok: response.ok === true, command: "platform-infra sub2api codex-pool credits", renderedText: lines.join("\n"), contentType: "text/plain", projection: response }; } diff --git a/scripts/src/platform-infra-sub2api-codex/remote-python-credits.ts b/scripts/src/platform-infra-sub2api-codex/remote-python-credits.ts index fe4595ad..eff90d8b 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-python-credits.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-python-credits.ts @@ -69,6 +69,15 @@ def credits_active_usage(token, since, active_start, active_end, active_start_ut } page += 1 +def credits_excluded_selector(user, payload): + user_id = str(user.get("id")) if user.get("id") is not None else "" + email = str(user.get("email") or "").strip().lower() + for selector in payload.get("excludedUsers") or []: + normalized = str(selector).strip().lower() + if normalized == user_id or normalized == email: + return str(selector) + return None + def credits_selected_users(token, payload): users = credits_all_users(token) users_by_id = {str(item.get("id")): item for item in users if item.get("id") is not None} @@ -85,8 +94,14 @@ def credits_selected_users(token, payload): excluded = [] for user_id, request_count in requests.items(): user = users_by_id.get(user_id) + excluded_selector = credits_excluded_selector(user, payload) if isinstance(user, dict) else None if not isinstance(user, dict): excluded.append({"userId": user_id, "reason": "user-not-found"}) + elif excluded_selector is not None: + excluded.append({ + "userId": user.get("id"), "email": user.get("email"), "requestCount": request_count, + "reason": "yaml-excluded-user", "matchedSelector": excluded_selector, + }) elif user.get("role") == "admin": excluded.append({"userId": user.get("id"), "email": user.get("email"), "reason": "admin-excluded"}) elif user.get("status") != "active" or user.get("deleted_at") is not None: @@ -95,6 +110,7 @@ def credits_selected_users(token, payload): selected.append((user, request_count)) return selected, excluded, selection selected = [] + excluded = [] seen = set() for selector in payload.get("users") or []: user = users_by_id.get(str(selector)) or users_by_email.get(str(selector).strip().lower()) @@ -104,8 +120,15 @@ def credits_selected_users(token, payload): if user_id in seen: raise RuntimeError("duplicate user selector: " + str(selector)) seen.add(user_id) - selected.append((user, None)) - return selected, [], {"kind": "exact-users", "selectors": payload.get("users") or []} + excluded_selector = credits_excluded_selector(user, payload) + if excluded_selector is not None: + excluded.append({ + "userId": user.get("id"), "email": user.get("email"), "requestCount": None, + "reason": "yaml-excluded-user", "matchedSelector": excluded_selector, + }) + else: + selected.append((user, None)) + return selected, excluded, {"kind": "exact-users", "selectors": payload.get("users") or []} def run_credits_grant(): payload = json.loads(base64.b64decode(PAYLOAD_B64).decode("utf-8")) if PAYLOAD_B64 else {} diff --git a/scripts/src/platform-infra-sub2api-codex/types.ts b/scripts/src/platform-infra-sub2api-codex/types.ts index 4b9b2080..742b388a 100644 --- a/scripts/src/platform-infra-sub2api-codex/types.ts +++ b/scripts/src/platform-infra-sub2api-codex/types.ts @@ -221,6 +221,7 @@ export interface CodexPoolProfitConfig { export interface CodexPoolCreditsConfig { timezone: string; + excludedUsers: string[]; } export interface CodexPoolConfig {