feat: 支持按时区查询活跃用户时间窗
This commit is contained in:
@@ -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]",
|
||||
|
||||
Reference in New Issue
Block a user