feat: 支持按时区查询活跃用户时间窗

This commit is contained in:
Codex
2026-07-15 06:09:58 +02:00
parent 1b0a7d7bb3
commit aab251173e
7 changed files with 140 additions and 21 deletions
@@ -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");
@@ -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<Record<string, unknown> | 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 <id-or-email,...>) --amount-usd <amount> --reason <text> [--confirm] [--target <id>] [--json]");
if (action !== "grant") throw new Error("credits usage: grant (--active-since 24h|--active-start <ISO> --active-end <ISO>|--users <id-or-email,...>) --amount-usd <amount> --reason <text> [--confirm] [--target <id>] [--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 <text> [--confirm]",
" bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --active-start <ISO> --active-end <ISO> --amount-usd 20 --reason <text>",
" bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --users <id-or-email,...> --amount-usd 20 --reason <text> [--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<string, unknown>): 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([
@@ -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 []:
@@ -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);
}
@@ -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 <name-or-id>|--all-groups] [--platform <name>] [--page-token <group-id>] [--account <name-or-id>] [--since 24h] [--tail 50000] [--target PK01] [--json|--full|--raw]",
"bun scripts/cli.ts platform-infra sub2api codex-pool faults [--level P0|P1|P2] [--group <name-or-id>] [--account <name-or-id>] [--model <model>] [--stream sync|stream] [--endpoint <path>] [--request-id <id>] [--page-token <token>] [--target PK01] [--json]",
"bun scripts/cli.ts platform-infra sub2api codex-pool profit [--since 24h|--month YYYY-MM [--forecast]] [--target PK01] [--sale-rate <CNY/API_USD>] [--scenario-without-account <id-or-exact-name> ...] [--accounts [--page-token <token>]|--missing-costs] [--json]",
"bun scripts/cli.ts platform-infra sub2api codex-pool credits grant (--active-since 24h|--users <id-or-email,...>) --amount-usd <amount> --reason <text> [--target PK01] [--confirm] [--json]",
"bun scripts/cli.ts platform-infra sub2api codex-pool credits grant (--active-since 24h|--active-start <ISO> --active-end <ISO>|--users <id-or-email,...>) --amount-usd <amount> --reason <text> [--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 <id>] [--target PK01] [--confirm] [--json]",
"bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --account <name-or-id> --template <id> [--target PK01] [--confirm]",