diff --git a/.agents/skills/unidesk-sub2api/references/codex-pool.md b/.agents/skills/unidesk-sub2api/references/codex-pool.md index 36a6221d..ea9997e6 100644 --- a/.agents/skills/unidesk-sub2api/references/codex-pool.md +++ b/.agents/skills/unidesk-sub2api/references/codex-pool.md @@ -111,6 +111,7 @@ bun scripts/cli.ts platform-infra sub2api codex-pool profit --target PK01 --mont bun scripts/cli.ts platform-infra sub2api codex-pool profit --target PK01 --month 2026-07 --forecast bun scripts/cli.ts platform-infra sub2api codex-pool profit --target PK01 --month --forecast --sale-rate --scenario-without-account '' bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --target PK01 --active-since 24h --amount-usd 20 --reason +bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --target PK01 --active-start 2026-07-14T17:00:00 --active-end 2026-07-14T18:00:00 --amount-usd 10 --reason bun scripts/cli.ts platform-infra sub2api codex-pool announcements list --target PK01 bun scripts/cli.ts platform-infra sub2api codex-pool announcements get --target PK01 --id bun scripts/cli.ts platform-infra sub2api codex-pool announcements create --target PK01 --title --content --targeting-json '{"any_of":[]}' @@ -151,7 +152,9 @@ bun scripts/cli.ts platform-infra sub2api codex-pool cleanup-probes --target D60 - `--json` 保留精确十进制字符串、原生来源、时间窗、分页数和完整性明细;命令不修改 runtime。 - `credits grant` 管理客户余额补偿: - - `--active-since ` 从原生 usage 选取窗口内有请求的非管理员活跃用户;`--users ` 使用精确 selector;两者必须且只能选择一个; + - `--active-since ` 从原生 usage 选取相对窗口内有请求的非管理员活跃用户; + - `--active-start --active-end ` 使用精确半开区间 `[start,end)`;未携带 UTC 偏移的输入按 `config/platform-infra/sub2api-codex-pool.yaml#credits.timezone` 解释,并同时披露本地与 UTC 边界; + - `--users ` 使用精确 selector;相对窗口、固定窗口和精确 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 b66e3468..31939b6c 100644 --- a/config/platform-infra/sub2api-codex-pool.yaml +++ b/config/platform-infra/sub2api-codex-pool.yaml @@ -76,6 +76,9 @@ profit: selfUse: [自用] excluded: [grok] +credits: + timezone: Asia/Shanghai + pool: groupName: unidesk-codex-pool groupDescription: UniDesk-managed Codex API-key pool for YAML-selected Sub2API clients. diff --git a/scripts/src/platform-infra-sub2api-codex/config.ts b/scripts/src/platform-infra-sub2api-codex/config.ts index 69633e59..955b840c 100644 --- a/scripts/src/platform-infra-sub2api-codex/config.ts +++ b/scripts/src/platform-infra-sub2api-codex/config.ts @@ -21,7 +21,7 @@ import { import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets"; import { runSshCommandCapture, type SshCaptureResult } from "../ssh"; -import type { CodexPoolCaddyEdgeRetryConfig, CodexPoolConfig, CodexPoolLocalCodexConfig, CodexPoolManualAccountGroupBinding, CodexPoolManualAccountProtection, CodexPoolManualAccountProxyBinding, CodexPoolManualAccountsConfig, CodexPoolManualBindingKind, CodexPoolManualBindingProvider, CodexPoolManualBindingSource, CodexPoolManualBindingSourcesConfig, CodexPoolProfileConfig, CodexPoolProfitConfig, CodexPoolPublicExposureConfig, CodexProfile, CodexRuntimeConfig, CodexRuntimeTemplate, CodexSentinelProtectPolicy, CodexTempUnschedulablePolicy, CodexTempUnschedulableRule, OpenAIResponsesWebSocketsV2Mode } from "./types"; +import type { CodexPoolCaddyEdgeRetryConfig, CodexPoolConfig, CodexPoolCreditsConfig, CodexPoolLocalCodexConfig, CodexPoolManualAccountGroupBinding, CodexPoolManualAccountProtection, CodexPoolManualAccountProxyBinding, CodexPoolManualAccountsConfig, CodexPoolManualBindingKind, CodexPoolManualBindingProvider, CodexPoolManualBindingSource, CodexPoolManualBindingSourcesConfig, CodexPoolProfileConfig, CodexPoolProfitConfig, CodexPoolPublicExposureConfig, CodexProfile, CodexRuntimeConfig, CodexRuntimeTemplate, CodexSentinelProtectPolicy, CodexTempUnschedulablePolicy, CodexTempUnschedulableRule, OpenAIResponsesWebSocketsV2Mode } from "./types"; import { booleanValue, integerArrayConfigField, integerConfigField, isRecord, normalizeBaseUrl, numberValue, readRequiredBaseUrl, readRequiredDescription, readRequiredEmail, readRequiredPort, readRequiredPositiveNumber, requiredRecordConfigField, requiredStringConfigField, stringValue, validateKubernetesName } from "./config-utils"; import { readAuthAPIKey, uniqueAccountName } from "./redaction"; import { codexPoolConfigPath } from "./types"; @@ -180,6 +180,7 @@ export function readCodexPoolConfig(): CodexPoolConfig { profiles, runtime, profit: readProfitConfig(parsed.profit), + credits: readCreditsConfig(parsed.credits), manualAccounts, publicExposure: readPublicExposureConfig(parsed.publicExposure), localCodex: readLocalCodexConfig(parsed.localCodex), @@ -200,6 +201,17 @@ export function readCodexPoolConfig(): CodexPoolConfig { return config; } +function readCreditsConfig(value: unknown): CodexPoolCreditsConfig { + if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.credits must be a YAML object`); + const timezone = requiredStringConfigField(value, "timezone", "credits"); + 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 }; +} + function readProfitConfig(value: unknown): CodexPoolProfitConfig { if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.profit must be a YAML object`); const groups = requiredRecordConfigField(value, "groups", "profit"); diff --git a/scripts/src/platform-infra-sub2api-codex/credits.ts b/scripts/src/platform-infra-sub2api-codex/credits.ts index 30a61635..664118f7 100644 --- a/scripts/src/platform-infra-sub2api-codex/credits.ts +++ b/scripts/src/platform-infra-sub2api-codex/credits.ts @@ -4,9 +4,15 @@ import { renderTable } from "./render"; import { boolField, compactCapture, parseJsonOutput, runRemoteCodexPoolScript } from "./remote"; import { creditsScript } from "./remote-scripts"; import { codexPoolRuntimeTarget, defaultCodexPoolRuntimeTargetId } from "./runtime-target"; +import { readCodexPoolConfig } from "./config"; interface CreditsOptions { activeSince: string | null; + activeStart: string | null; + activeEnd: string | null; + activeStartUtc: string | null; + activeEndUtc: string | null; + timezone: string; users: string[]; amountUsd: number; reason: string; @@ -17,9 +23,10 @@ interface CreditsOptions { export async function codexPoolCredits(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { if (args.includes("--help")) return renderCreditsHelp(); - const options = parseCreditsOptions(args); + const pool = readCodexPoolConfig(); + const options = parseCreditsOptions(args, pool.credits.timezone); const target = codexPoolRuntimeTarget(options.targetId); - const remote = await runRemoteCodexPoolScript(config, "credits", creditsScript(options, target), target); + const remote = await runRemoteCodexPoolScript(config, "credits", creditsScript(options, pool, target), target); const report = parseJsonOutput(remote.stdout); const ok = remote.exitCode === 0 && boolField(report, "ok", false); const response = { @@ -27,8 +34,13 @@ export async function codexPoolCredits(config: UniDeskConfig, args: string[]): P action: "platform-infra-sub2api-codex-pool-credits", target: { id: target.id, route: target.route, runtimeMode: target.runtimeMode, endpoint: target.serviceDns }, request: { - selection: options.activeSince === null ? "exact-users" : "active-users", + selection: options.users.length > 0 ? "exact-users" : options.activeSince === null ? "active-window" : "active-users", activeSince: options.activeSince, + activeStart: options.activeStart, + activeEnd: options.activeEnd, + activeStartUtc: options.activeStartUtc, + activeEndUtc: options.activeEndUtc, + timezone: options.timezone, users: options.users, amountUsd: options.amountUsd, reason: options.reason, @@ -41,10 +53,12 @@ export async function codexPoolCredits(config: UniDeskConfig, args: string[]): P return options.json ? renderCreditsJson(response) : renderCredits(response); } -function parseCreditsOptions(args: string[]): CreditsOptions { +function parseCreditsOptions(args: string[], timezone: string): CreditsOptions { const [action = "grant", ...rest] = args; - if (action !== "grant") throw new Error("credits usage: grant (--active-since 24h|--users ) --amount-usd --reason [--confirm] [--target ] [--json]"); + 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; + let activeStart: string | null = null; + let activeEnd: string | null = null; let users: string[] = []; let amountUsd: number | null = null; let reason: string | null = null; @@ -62,6 +76,10 @@ function parseCreditsOptions(args: string[]): CreditsOptions { else if (arg === "--json") json = true; else if (arg === "--active-since") [activeSince, index] = readValue(index, arg); else if (arg.startsWith("--active-since=")) activeSince = arg.slice("--active-since=".length).trim(); + else if (arg === "--active-start") [activeStart, index] = readValue(index, arg); + else if (arg.startsWith("--active-start=")) activeStart = arg.slice("--active-start=".length).trim(); + else if (arg === "--active-end") [activeEnd, index] = readValue(index, arg); + else if (arg.startsWith("--active-end=")) activeEnd = arg.slice("--active-end=".length).trim(); else if (arg === "--users") { let value: string; [value, index] = readValue(index, arg); @@ -78,12 +96,64 @@ function parseCreditsOptions(args: string[]): CreditsOptions { else if (arg.startsWith("--target=")) targetId = arg.slice("--target=".length).trim(); else throw new Error(`unsupported credits option: ${arg}`); } - if ((activeSince === null) === (users.length === 0)) throw new Error("use exactly one of --active-since or --users"); + if ((activeStart === null) !== (activeEnd === null)) throw new Error("--active-start and --active-end must be provided together"); + const selectionCount = Number(activeSince !== null) + Number(activeStart !== null) + Number(users.length > 0); + if (selectionCount !== 1) throw new Error("use exactly one of --active-since, --active-start/--active-end, or --users"); if (activeSince !== null && !/^[1-9][0-9]*(?:m|h|d)$/u.test(activeSince)) throw new Error("--active-since must use a duration such as 24h"); - if (confirm && activeSince !== null) throw new Error("confirmed credits require the exact --users selectors returned by an active-user dry-run"); + if (activeStart !== null && !isIsoDateTime(activeStart)) throw new Error("--active-start must use ISO date-time format"); + if (activeEnd !== null && !isIsoDateTime(activeEnd)) throw new Error("--active-end must use ISO date-time format"); + const activeStartUtc = activeStart === null ? null : isoInTimezoneToUtc(activeStart, timezone); + const activeEndUtc = activeEnd === null ? null : isoInTimezoneToUtc(activeEnd, timezone); + if (activeStartUtc !== null && activeEndUtc !== null && activeEndUtc <= activeStartUtc) { + throw new Error("credits active window end must be after start"); + } + 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, users, amountUsd, reason, confirm, targetId, json }; + return { activeSince, activeStart, activeEnd, activeStartUtc, activeEndUtc, timezone, users, amountUsd, reason, confirm, targetId, json }; +} + +function isIsoDateTime(value: string): boolean { + return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?(?:Z|[+-]\d{2}:\d{2})?$/u.test(value); +} + +function isoInTimezoneToUtc(value: string, timezone: string): string { + if (/(?:Z|[+-]\d{2}:\d{2})$/u.test(value)) { + const instant = new Date(value); + if (!Number.isFinite(instant.getTime())) throw new Error("fixed-window date-time is invalid"); + return instant.toISOString(); + } + const matched = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,6}))?)?$/u.exec(value); + if (matched === null) throw new Error("fixed-window date-time is invalid"); + const components = matched.slice(1, 7).map((item) => Number(item ?? 0)); + const milliseconds = Number((matched[7] ?? "").padEnd(3, "0").slice(0, 3)); + const localEpoch = Date.UTC(components[0]!, components[1]! - 1, components[2]!, components[3]!, components[4]!, components[5]!, milliseconds); + let instant = localEpoch; + for (let attempt = 0; attempt < 3; attempt += 1) { + const actual = timezoneComponents(instant, timezone); + const represented = Date.UTC(actual[0], actual[1] - 1, actual[2], actual[3], actual[4], actual[5], milliseconds); + instant = localEpoch - (represented - instant); + } + const actual = timezoneComponents(instant, timezone); + if (actual.some((item, index) => item !== components[index])) { + throw new Error(`fixed-window date-time does not exist in timezone ${timezone}: ${value}`); + } + return new Date(instant).toISOString(); +} + +function timezoneComponents(epochMs: number, timezone: string): number[] { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }).formatToParts(new Date(epochMs)); + const values = new Map(parts.map((part) => [part.type, Number(part.value)])); + return ["year", "month", "day", "hour", "minute", "second"].map((key) => values.get(key) ?? Number.NaN); } function parseSelectors(value: string): string[] { @@ -108,9 +178,11 @@ function renderCreditsHelp(): RenderedCliResult { "SUB2API USER CREDITS", "Usage:", " bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --active-since 24h --amount-usd 20 --reason [--confirm]", + " bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --active-start --active-end --amount-usd 20 --reason ", " bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --users --amount-usd 20 --reason [--confirm]", "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).", " --confirm performs additive balance writes and then reads every selected user back.", ].join("\n"), contentType: "text/plain", @@ -125,6 +197,10 @@ function renderCredits(response: Record): RenderedCliResult { `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)}`, `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); + if (selection.kind === "active-users" || selection.kind === "active-window") { + lines.push(`WINDOW timezone=${text(selection.timezone)} start=${text(selection.startAt)} end=${text(selection.endAt)} start-utc=${text(selection.startAtUtc)} end-utc=${text(selection.endAtUtc)}`); + } if (typeof report.error === "string") lines.push(`ERROR ${text(report.error)}`); if (rows.length > 0) { lines.push("", renderTable([ 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 b6dc0066..fe4595ad 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-python-credits.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-python-credits.ts @@ -28,9 +28,17 @@ def credits_all_users(token): return users page += 1 -def credits_active_usage(token, since): - end_at = datetime.now(timezone.utc) - start_at = credits_window_start(since, end_at) +def credits_active_usage(token, since, active_start, active_end, active_start_utc, active_end_utc, timezone_name): + if since: + end_at = datetime.now(timezone.utc) + start_at = credits_window_start(since, end_at) + selection_kind = "active-users" + else: + start_at = credits_parse_time(active_start_utc) + end_at = credits_parse_time(active_end_utc) + selection_kind = "active-window" + if start_at is None or end_at is None or end_at <= start_at: + raise RuntimeError("credits active window end must be after start") user_requests = {} page = 1 while True: @@ -46,11 +54,19 @@ def credits_active_usage(token, since): reached_start = True continue user_id = row.get("user_id") if row.get("user_id") is not None else row.get("userId") - if user_id is not None and (created_at is None or created_at <= end_at): + if user_id is not None and created_at is not None and start_at <= created_at < end_at: user_requests[str(user_id)] = user_requests.get(str(user_id), 0) + 1 total = int(data.get("total", 0)) if isinstance(data, dict) else 0 if not batch or len(batch) < 100 or reached_start or page * 100 >= total: - return user_requests, start_at, end_at + return user_requests, start_at, end_at, { + "kind": selection_kind, + "since": since, + "timezone": timezone_name if selection_kind == "active-window" else "UTC", + "startAt": active_start if selection_kind == "active-window" else start_at.isoformat(), + "endAt": active_end if selection_kind == "active-window" else end_at.isoformat(), + "startAtUtc": start_at.isoformat().replace("+00:00", "Z"), + "endAtUtc": end_at.isoformat().replace("+00:00", "Z"), + } page += 1 def credits_selected_users(token, payload): @@ -58,8 +74,13 @@ def credits_selected_users(token, payload): users_by_id = {str(item.get("id")): item for item in users if item.get("id") is not None} users_by_email = {str(item.get("email") or "").strip().lower(): item for item in users if item.get("email")} active_since = payload.get("activeSince") - if active_since: - requests, start_at, end_at = credits_active_usage(token, active_since) + active_start = payload.get("activeStart") + active_end = payload.get("activeEnd") + if active_since or (active_start and active_end): + requests, _, _, selection = credits_active_usage( + token, active_since, active_start, active_end, + payload.get("activeStartUtc"), payload.get("activeEndUtc"), payload.get("timezone"), + ) selected = [] excluded = [] for user_id, request_count in requests.items(): @@ -72,7 +93,7 @@ def credits_selected_users(token, payload): excluded.append({"userId": user.get("id"), "email": user.get("email"), "reason": "inactive-or-deleted"}) else: selected.append((user, request_count)) - return selected, excluded, {"kind": "active-users", "since": active_since, "startAt": start_at.isoformat(), "endAt": end_at.isoformat()} + return selected, excluded, selection selected = [] seen = set() for selector in payload.get("users") or []: diff --git a/scripts/src/platform-infra-sub2api-codex/remote-scripts.ts b/scripts/src/platform-infra-sub2api-codex/remote-scripts.ts index cf72e990..b724a46b 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-scripts.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-scripts.ts @@ -57,8 +57,7 @@ export function profitScript(payload: unknown, pool: CodexPoolConfig, target: Co return remotePythonScript("profit", encoded, pool, target); } -export function creditsScript(payload: unknown, target: CodexPoolRuntimeTarget): string { - const pool = readCodexPoolConfig(); +export function creditsScript(payload: unknown, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string { const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64"); return remotePythonScript("credits", encoded, pool, target); } diff --git a/scripts/src/platform-infra-sub2api-codex/types.ts b/scripts/src/platform-infra-sub2api-codex/types.ts index a0463a8a..4b9b2080 100644 --- a/scripts/src/platform-infra-sub2api-codex/types.ts +++ b/scripts/src/platform-infra-sub2api-codex/types.ts @@ -219,6 +219,10 @@ export interface CodexPoolProfitConfig { }; } +export interface CodexPoolCreditsConfig { + timezone: string; +} + export interface CodexPoolConfig { version: number; kind: string; @@ -245,6 +249,7 @@ export interface CodexPoolConfig { profiles: CodexPoolProfileConfig[]; runtime: CodexRuntimeConfig; profit: CodexPoolProfitConfig; + credits: CodexPoolCreditsConfig; manualAccounts: CodexPoolManualAccountsConfig; publicExposure: CodexPoolPublicExposureConfig; localCodex: CodexPoolLocalCodexConfig; @@ -401,7 +406,7 @@ export function codexPoolHelp(): unknown { "bun scripts/cli.ts platform-infra sub2api codex-pool runtime errors [--group |--all-groups] [--platform ] [--page-token ] [--account ] [--since 24h] [--tail 50000] [--target PK01] [--json|--full|--raw]", "bun scripts/cli.ts platform-infra sub2api codex-pool faults [--level P0|P1|P2] [--group ] [--account ] [--model ] [--stream sync|stream] [--endpoint ] [--request-id ] [--page-token ] [--target PK01] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool profit [--since 24h|--month YYYY-MM [--forecast]] [--target PK01] [--sale-rate ] [--scenario-without-account ...] [--accounts [--page-token ]|--missing-costs] [--json]", - "bun scripts/cli.ts platform-infra sub2api codex-pool credits grant (--active-since 24h|--users ) --amount-usd --reason [--target PK01] [--confirm] [--json]", + "bun scripts/cli.ts platform-infra sub2api codex-pool credits grant (--active-since 24h|--active-start --active-end |--users ) --amount-usd --reason [--target PK01] [--confirm] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool announcements list|get|create [--target PK01] [--confirm for create] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool error-passthrough list|apply|delete [--template ] [--target PK01] [--confirm] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --account --template [--target PK01] [--confirm]",