feat: 扩展 Sub2API 成本与余额运维
This commit is contained in:
@@ -95,6 +95,7 @@ bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --target PK01
|
||||
bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --target PK01 --accounts <id-or-exact-name,...> --template <template-id>
|
||||
bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --target PK01 --accounts <id-or-exact-name,...> --template <template-id> --confirm
|
||||
bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --target PK01 --accounts <id-or-exact-name,...> --kind priority --priority <0-1000> --confirm
|
||||
bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --target PK01 --account <id-or-exact-name> --kind account-name --name <new-name> --confirm
|
||||
bun scripts/cli.ts platform-infra sub2api codex-pool runtime delete --target PK01 --account <name-or-id> --kind temp-unschedulable
|
||||
bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --target PK01 --account <name-or-id> --kind model-mapping --model <client-model> --upstream-model <upstream-model>
|
||||
bun scripts/cli.ts platform-infra sub2api codex-pool runtime delete --target PK01 --account <name-or-id> --kind model-mapping --model <client-model>
|
||||
@@ -106,6 +107,9 @@ bun scripts/cli.ts platform-infra sub2api codex-pool profit --target PK01 --sinc
|
||||
bun scripts/cli.ts platform-infra sub2api codex-pool profit --target PK01 --since 24h --json
|
||||
bun scripts/cli.ts platform-infra sub2api codex-pool profit --target PK01 --since 24h --accounts
|
||||
bun scripts/cli.ts platform-infra sub2api codex-pool profit --target PK01 --since 24h --missing-costs
|
||||
bun scripts/cli.ts platform-infra sub2api codex-pool profit --target PK01 --month 2026-07
|
||||
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 credits grant --target PK01 --active-since 24h --amount-usd 20 --reason <text>
|
||||
bun scripts/cli.ts platform-infra sub2api codex-pool trace --request-id <requestId>
|
||||
bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status --target D601
|
||||
bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image build --target D601 --confirm
|
||||
@@ -125,6 +129,9 @@ bun scripts/cli.ts platform-infra sub2api codex-pool cleanup-probes --target D60
|
||||
- `profit` 只读聚合 Sub2API 原生 `/api/v1/admin/usage`:
|
||||
- 默认窗口、盈利分组、自用分组、排除分组、人民币售价和账号采购成本来源由 `config/platform-infra/sub2api-codex-pool.yaml#profit` 声明;
|
||||
- 原生 `actual_cost` 是标准 API 美元计费基数;`saleRateCnyPerApiUsd` 和账号名称末尾成本都表示每 1 美元标准 API 计费对应的人民币单价,不做汇率换算;
|
||||
- `periodCosts` 声明按 `YYYY-MM` 入账的一次性人民币固定成本;`--month YYYY-MM` 使用原生 `/api/v1/admin/usage/stats` 按分组和账号聚合该月流水并一次扣除匹配期间成本,任意 `--since` 窗口继续读取逐条 usage 以保持滚动窗口精度且不分摊固定成本;
|
||||
- 账号调度经济性使用边际采购倍率,不把已经发生的一次性期间成本摊回请求成本或用于压低账号优先级;月度净利润必须单列期间成本后再计算;
|
||||
- `--month YYYY-MM --forecast` 以月初至当前的实际秒数计算线性 run-rate:收入、盈利池流量成本和自用流量成本按完整月份秒数外推,一次性 `periodCosts` 只扣一次;同时保留 MTD 实际汇总和两种整月预计利润,不能把预测值表述为已实现收益;
|
||||
- 默认 Kubernetes 风格输出优先给出总收入、已确认成本、扣除已知成本后的待定利润基数、未知成本对应的 API USD 基数、完整性和参数化利润公式;
|
||||
- 显式 `--json` 返回同一份稳定紧凑汇总,不携带完整账号数组,避免大窗口触发全局输出保护;
|
||||
- `--accounts` 固定返回一页完整账号名称、账号 ID、请求数、Token、标准 API 美元计费基数、人民币采购单价、人民币收入、人民币上游成本和人民币利润贡献;存在后续页时返回由分类、分组、账号 ID 和名称生成的 opaque `--page-token` 命令,不提供手工 `limit`;
|
||||
@@ -133,6 +140,13 @@ bun scripts/cli.ts platform-infra sub2api codex-pool cleanup-probes --target D60
|
||||
- 账号名称末尾缺少采购成本系数,或存在未分类流量时,相关口径必须显示 `partial`,禁止把缺失值当作零;
|
||||
- `--json` 保留精确十进制字符串、原生来源、时间窗、分页数和完整性明细;命令不修改 runtime。
|
||||
|
||||
- `credits grant` 管理客户余额补偿:
|
||||
- `--active-since <duration>` 从原生 usage 选取窗口内有请求的非管理员活跃用户;`--users <id-or-email,...>` 使用精确 selector;两者必须且只能选择一个;
|
||||
- 默认 dry-run,逐用户披露完整邮箱、用户 ID、请求数、当前余额、增加额和目标余额;
|
||||
- 活跃窗口只允许生成计划;实际写入必须把 dry-run 返回的用户 ID 固定为精确 `--users` selectors,再显式 `--confirm`,防止移动窗口在确认时扩大或缩小名单;
|
||||
- 确认写入为每个用户生成由精确名单、金额和原因确定的 `Idempotency-Key`,调用原生 `POST /api/v1/admin/users/:id/balance` 的 `add` 操作,随后逐用户回读余额并对账;
|
||||
- 实际批量充值属于外部财务影响操作,执行前必须取得用户对本次具体名单、金额和原因的明确授权;仅提出设计目标或要求 dry-run 不构成授权。
|
||||
|
||||
`config/platform-infra/sub2api-codex-pool.yaml` 控制:
|
||||
|
||||
- `pool.groupName`: Sub2API group 名称。
|
||||
@@ -151,6 +165,9 @@ bun scripts/cli.ts platform-infra sub2api codex-pool cleanup-probes --target D60
|
||||
- 数值越小优先级越高,不存在分组级 priority;
|
||||
- 单账号和精准批量都必须先 dry-run,确认写入后逐账号回读并显示 before、desired、actual 与 reconciled;
|
||||
- 只更新账号 priority,不携带 credentials、分组、容量、代理、状态、可调度或临时不可调度字段。
|
||||
- 账号人类可读名称使用 `runtime apply --kind account-name --name <new-name>`:
|
||||
- 只接受单个精确账号 selector,先 dry-run,确认后自动回读名称;
|
||||
- 只更新名称,不携带 credentials、priority、分组、容量、代理、状态或可调度字段。
|
||||
- 模型映射先用 `runtime models --account ...` 获取原生配置与实时上游能力:
|
||||
- 只有实时模型列表或真实模型请求证明支持时才确认写入。
|
||||
- 禁止把名称相近的模型猜测为别名。
|
||||
|
||||
@@ -7,5 +7,7 @@
|
||||
- 不给 Sub2API manifest 添加 CPU/memory limits,除非有新的 YAML 化明确决策。
|
||||
- 不打印完整 API key、admin password 或 Secret 明文。
|
||||
- 不把普通上游增删做成代码变更、CI/CD、feature flag 或兼容双路径。
|
||||
- 除非用户明确指定,不修改 Sub2API 源码或版本;优先使用官方配置、原生调度能力和 owning YAML,官方不支持时明确报告能力边界。
|
||||
- 除非用户明确授权,不启用、扩展或调整 Sub2API 外部哨兵;外部哨兵不是配置优化、换号或错误缓解的默认手段。
|
||||
- 不把手动禁用账号、删除账号、移除 YAML entry、降低 membership、临时改 priority/capacity/loadFactor、provider pinning 或给某个账号特权当作通用 failover / 哨兵隔离恢复问题的修复。
|
||||
- 不魔改 Sub2API:Sub2API 本身不支持的能力就不做,不通过 UniDesk 脚本、k8s 原地热补、本地 fork、YAML 伪声明或隐藏 fallback 代替上游实现。
|
||||
|
||||
@@ -65,6 +65,12 @@ profit:
|
||||
usageCostField: actual_cost
|
||||
accountCostSource: account-name-trailing-cny-per-api-usd
|
||||
saleRateCnyPerApiUsd: 0.10
|
||||
periodCosts:
|
||||
- id: lyon9801-acquisition
|
||||
period: "2026-07"
|
||||
accountName: lyon9801 0.0
|
||||
amountCny: 1300
|
||||
description: lyon9801 的一次性采购成本;边际流量采购倍率为 0.0。
|
||||
groups:
|
||||
profitable: [unidesk-codex-pool]
|
||||
selfUse: [自用]
|
||||
|
||||
@@ -226,11 +226,32 @@ function readProfitConfig(value: unknown): CodexPoolProfitConfig {
|
||||
if (accountCostSource !== "account-name-trailing-cny-per-api-usd") {
|
||||
throw new Error(`${codexPoolConfigPath}.profit.accountCostSource must be account-name-trailing-cny-per-api-usd`);
|
||||
}
|
||||
if (!Array.isArray(value.periodCosts)) {
|
||||
throw new Error(`${codexPoolConfigPath}.profit.periodCosts must be an array`);
|
||||
}
|
||||
const periodCostIds = new Set<string>();
|
||||
const periodCosts = value.periodCosts.map((item, index) => {
|
||||
const path = `profit.periodCosts[${index}]`;
|
||||
if (!isRecord(item)) throw new Error(`${codexPoolConfigPath}.${path} must be an object`);
|
||||
const id = requiredStringConfigField(item, "id", path);
|
||||
if (periodCostIds.has(id)) throw new Error(`${codexPoolConfigPath}.profit.periodCosts contains duplicate id ${id}`);
|
||||
periodCostIds.add(id);
|
||||
const period = requiredStringConfigField(item, "period", path);
|
||||
if (!/^\d{4}-(?:0[1-9]|1[0-2])$/u.test(period)) {
|
||||
throw new Error(`${codexPoolConfigPath}.${path}.period must use YYYY-MM`);
|
||||
}
|
||||
const accountName = requiredStringConfigField(item, "accountName", path);
|
||||
const amountCny = numberValue(item.amountCny);
|
||||
if (amountCny === null || amountCny < 0) throw new Error(`${codexPoolConfigPath}.${path}.amountCny must be >= 0`);
|
||||
const description = requiredStringConfigField(item, "description", path);
|
||||
return { id, period, accountName, amountCny, description };
|
||||
});
|
||||
return {
|
||||
defaultWindow,
|
||||
usageCostField,
|
||||
accountCostSource,
|
||||
saleRateCnyPerApiUsd,
|
||||
periodCosts,
|
||||
groups: { profitable, selfUse, excluded },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { UniDeskConfig } from "../config";
|
||||
import type { RenderedCliResult } from "../output";
|
||||
import { renderTable } from "./render";
|
||||
import { boolField, compactCapture, parseJsonOutput, runRemoteCodexPoolScript } from "./remote";
|
||||
import { creditsScript } from "./remote-scripts";
|
||||
import { codexPoolRuntimeTarget, defaultCodexPoolRuntimeTargetId } from "./runtime-target";
|
||||
|
||||
interface CreditsOptions {
|
||||
activeSince: string | null;
|
||||
users: string[];
|
||||
amountUsd: number;
|
||||
reason: string;
|
||||
confirm: boolean;
|
||||
targetId: string;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
export async function codexPoolCredits(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
|
||||
if (args.includes("--help")) return renderCreditsHelp();
|
||||
const options = parseCreditsOptions(args);
|
||||
const target = codexPoolRuntimeTarget(options.targetId);
|
||||
const remote = await runRemoteCodexPoolScript(config, "credits", creditsScript(options, target), target);
|
||||
const report = parseJsonOutput(remote.stdout);
|
||||
const ok = remote.exitCode === 0 && boolField(report, "ok", false);
|
||||
const response = {
|
||||
ok,
|
||||
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",
|
||||
activeSince: options.activeSince,
|
||||
users: options.users,
|
||||
amountUsd: options.amountUsd,
|
||||
reason: options.reason,
|
||||
confirm: options.confirm,
|
||||
},
|
||||
report,
|
||||
remote: compactCapture(remote, { full: !ok || report === null }),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
return options.json ? renderCreditsJson(response) : renderCredits(response);
|
||||
}
|
||||
|
||||
function parseCreditsOptions(args: 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]");
|
||||
let activeSince: string | null = null;
|
||||
let users: string[] = [];
|
||||
let amountUsd: number | null = null;
|
||||
let reason: string | null = null;
|
||||
let confirm = false;
|
||||
let targetId = defaultCodexPoolRuntimeTargetId();
|
||||
let json = false;
|
||||
const readValue = (index: number, option: string): [string, number] => {
|
||||
const value = rest[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error(`${option} requires a value`);
|
||||
return [value.trim(), index + 1];
|
||||
};
|
||||
for (let index = 0; index < rest.length; index += 1) {
|
||||
const arg = rest[index]!;
|
||||
if (arg === "--confirm") confirm = true;
|
||||
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 === "--users") {
|
||||
let value: string;
|
||||
[value, index] = readValue(index, arg);
|
||||
users = parseSelectors(value);
|
||||
} else if (arg.startsWith("--users=")) users = parseSelectors(arg.slice("--users=".length));
|
||||
else if (arg === "--amount-usd") {
|
||||
let value: string;
|
||||
[value, index] = readValue(index, arg);
|
||||
amountUsd = parseAmount(value);
|
||||
} else if (arg.startsWith("--amount-usd=")) amountUsd = parseAmount(arg.slice("--amount-usd=".length));
|
||||
else if (arg === "--reason") [reason, index] = readValue(index, arg);
|
||||
else if (arg.startsWith("--reason=")) reason = arg.slice("--reason=".length).trim();
|
||||
else if (arg === "--target") [targetId, index] = readValue(index, arg);
|
||||
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 (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 (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 };
|
||||
}
|
||||
|
||||
function parseSelectors(value: string): string[] {
|
||||
const selectors = value.split(",").map((item) => item.trim()).filter(Boolean);
|
||||
if (selectors.length === 0 || selectors.length > 200) throw new Error("--users requires 1 to 200 comma-separated exact IDs or emails");
|
||||
if (selectors.some((item) => item.length > 256 || /[\r\n\0]/u.test(item))) throw new Error("--users contains an unsupported selector");
|
||||
if (new Set(selectors).size !== selectors.length) throw new Error("--users contains duplicate selectors");
|
||||
return selectors;
|
||||
}
|
||||
|
||||
function parseAmount(value: string): number {
|
||||
const amount = Number(value);
|
||||
if (!Number.isFinite(amount) || amount <= 0) throw new Error("--amount-usd must be greater than zero");
|
||||
return amount;
|
||||
}
|
||||
|
||||
function renderCreditsHelp(): RenderedCliResult {
|
||||
return {
|
||||
ok: true,
|
||||
command: "platform-infra sub2api codex-pool credits --help",
|
||||
renderedText: [
|
||||
"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 --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.",
|
||||
" --confirm performs additive balance writes and then reads every selected user back.",
|
||||
].join("\n"),
|
||||
contentType: "text/plain",
|
||||
};
|
||||
}
|
||||
|
||||
function renderCredits(response: Record<string, unknown>): RenderedCliResult {
|
||||
const report = record(response.report);
|
||||
const summary = record(report.summary);
|
||||
const rows = arrayOfRecords(report.items);
|
||||
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)}`,
|
||||
`WRITE attempted=${text(summary.writeAttempted)} succeeded=${text(summary.writeSucceeded)} failed=${text(summary.writeFailed)} reconciled=${text(summary.reconciled)} mutation=${text(report.mutation)}`,
|
||||
];
|
||||
if (typeof report.error === "string") lines.push(`ERROR ${text(report.error)}`);
|
||||
if (rows.length > 0) {
|
||||
lines.push("", renderTable([
|
||||
["EMAIL", "USER_ID", "ROLE", "STATUS", "REQUESTS", "BALANCE_BEFORE", "ADD_USD", "BALANCE_AFTER", "WRITE", "RECONCILED"],
|
||||
...rows.map((row) => [
|
||||
text(row.email), text(row.userId), text(row.role), text(row.status), text(row.requestCount), usd(row.balanceBefore), usd(row.amountUsd),
|
||||
usd(row.balanceAfter), text(record(row.write).status), text(row.reconciled),
|
||||
]),
|
||||
]));
|
||||
}
|
||||
if (report.mode === "dry-run") {
|
||||
const selectors = Array.isArray(report.confirmationUserIds) ? report.confirmationUserIds.join(",") : "<exact-user-ids>";
|
||||
lines.push("", `CONFIRM SELECTORS --users ${selectors}`);
|
||||
lines.push("NEXT: use these exact selectors with --confirm only after explicit user authorization.");
|
||||
}
|
||||
return { ok: response.ok === true, command: "platform-infra sub2api codex-pool credits", renderedText: lines.join("\n"), contentType: "text/plain", projection: response };
|
||||
}
|
||||
|
||||
function renderCreditsJson(response: Record<string, unknown>): RenderedCliResult {
|
||||
return { ok: response.ok === true, command: "platform-infra sub2api codex-pool credits --json", renderedText: JSON.stringify(response), contentType: "application/json", projection: response };
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
|
||||
function arrayOfRecords(value: unknown): Record<string, unknown>[] { return Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null && !Array.isArray(item)) : []; }
|
||||
function text(value: unknown): string { return value === null || value === undefined || value === "" ? "-" : String(value).replace(/[\r\n\t]+/gu, " "); }
|
||||
function usd(value: unknown): string { const numeric = Number(value); return Number.isFinite(numeric) ? `$${numeric.toFixed(8)}` : text(value); }
|
||||
@@ -15,4 +15,5 @@ export * from "./remote-python-sync";
|
||||
export * from "./remote";
|
||||
export * from "./feedback";
|
||||
export * from "./profit";
|
||||
export * from "./credits";
|
||||
export * from "./error-passthrough";
|
||||
|
||||
@@ -28,6 +28,7 @@ import { renderCodexPoolPlan } from "./render";
|
||||
import { codexPoolRuntime } from "./runtime";
|
||||
import { codexPoolFaults } from "./faults";
|
||||
import { codexPoolProfit } from "./profit";
|
||||
import { codexPoolCredits } from "./credits";
|
||||
import { codexPoolErrorPassthrough } from "./error-passthrough";
|
||||
import { defaultCodexPoolRuntimeTargetId } from "./runtime-target";
|
||||
import { codexPoolHelp } from "./types";
|
||||
@@ -44,6 +45,7 @@ export async function runCodexPoolCommand(config: UniDeskConfig, args: string[])
|
||||
if (action === "runtime") return await codexPoolRuntime(config, args.slice(1));
|
||||
if (action === "faults") return await codexPoolFaults(config, args.slice(1));
|
||||
if (action === "profit") return await codexPoolProfit(config, args.slice(1));
|
||||
if (action === "credits") return await codexPoolCredits(config, args.slice(1));
|
||||
if (action === "error-passthrough") return await codexPoolErrorPassthrough(config, args.slice(1));
|
||||
if (action === "trace") return await codexPoolTrace(config, parseTraceOptions(args.slice(1)));
|
||||
if (action === "feedback") return await codexPoolFeedback(config, parseFeedbackOptions(args.slice(1)));
|
||||
|
||||
@@ -8,6 +8,8 @@ import { codexPoolRuntimeTarget, defaultCodexPoolRuntimeTargetId } from "./runti
|
||||
|
||||
interface ProfitOptions {
|
||||
since: string;
|
||||
month: string | null;
|
||||
forecast: boolean;
|
||||
targetId: string;
|
||||
json: boolean;
|
||||
accounts: boolean;
|
||||
@@ -22,12 +24,13 @@ export async function codexPoolProfit(config: UniDeskConfig, args: string[]): Pr
|
||||
if (args.includes("--help")) return renderProfitHelp(pool.profit.defaultWindow);
|
||||
const options = parseProfitOptions(args, pool.profit.defaultWindow);
|
||||
const target = codexPoolRuntimeTarget(options.targetId);
|
||||
const payload = { since: options.since, config: pool.profit };
|
||||
const payload = { since: options.since, month: options.month, forecast: options.forecast, config: pool.profit };
|
||||
const remote = await runRemoteCodexPoolScript(config, "profit", profitScript(payload, pool, target), target);
|
||||
const report = parseJsonOutput(remote.stdout);
|
||||
const ok = remote.exitCode === 0 && boolField(report, "ok", false);
|
||||
const projectedReport = projectProfitReport(report, options);
|
||||
const baseCommand = `bun scripts/cli.ts platform-infra sub2api codex-pool profit --target ${target.id} --since ${options.since}`;
|
||||
const windowOption = options.month === null ? `--since ${options.since}` : `--month ${options.month}${options.forecast ? " --forecast" : ""}`;
|
||||
const baseCommand = `bun scripts/cli.ts platform-infra sub2api codex-pool profit --target ${target.id} ${windowOption}`;
|
||||
const accountPage = record(record(projectedReport).accountPage);
|
||||
const response = {
|
||||
ok,
|
||||
@@ -56,6 +59,8 @@ export async function codexPoolProfit(config: UniDeskConfig, args: string[]): Pr
|
||||
|
||||
function parseProfitOptions(args: string[], defaultWindow: string): ProfitOptions {
|
||||
let since = defaultWindow;
|
||||
let month: string | null = null;
|
||||
let forecast = false;
|
||||
let targetId = defaultCodexPoolRuntimeTargetId();
|
||||
let json = false;
|
||||
let accounts = false;
|
||||
@@ -69,8 +74,12 @@ function parseProfitOptions(args: string[], defaultWindow: string): ProfitOption
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index]!;
|
||||
if (arg === "--json") json = true;
|
||||
else if (arg === "--forecast") forecast = true;
|
||||
else if (arg === "--accounts") accounts = true;
|
||||
else if (arg === "--missing-costs") missingCosts = true;
|
||||
else if (arg === "--month") {
|
||||
[month, index] = readValue(index, "--month");
|
||||
} else if (arg.startsWith("--month=")) month = arg.slice("--month=".length).trim();
|
||||
else if (arg === "--page-token") {
|
||||
[pageToken, index] = readValue(index, "--page-token");
|
||||
} else if (arg.startsWith("--page-token=")) pageToken = arg.slice("--page-token=".length).trim();
|
||||
@@ -83,11 +92,14 @@ function parseProfitOptions(args: string[], defaultWindow: string): ProfitOption
|
||||
else throw new Error(`unsupported profit option: ${arg}`);
|
||||
}
|
||||
if (!/^[1-9][0-9]*(?:s|m|h|d)$/u.test(since)) throw new Error("--since must be a duration such as 24h, 90m, or 7d");
|
||||
if (month !== null && !/^\d{4}-(?:0[1-9]|1[0-2])$/u.test(month)) throw new Error("--month must use YYYY-MM");
|
||||
if (month !== null && args.some((arg) => arg === "--since" || arg.startsWith("--since="))) throw new Error("use only one of --since or --month");
|
||||
if (forecast && month === null) throw new Error("--forecast requires --month YYYY-MM");
|
||||
if (!targetId || /[\r\n\0]/u.test(targetId)) throw new Error("--target has an unsupported format");
|
||||
if (accounts && missingCosts) throw new Error("--accounts and --missing-costs are separate semantic disclosures");
|
||||
if (pageToken !== null && !accounts) throw new Error("--page-token requires --accounts");
|
||||
if (pageToken !== null && (!pageToken || /[\r\n\0\s]/u.test(pageToken))) throw new Error("--page-token has an unsupported format");
|
||||
return { since, targetId, json, accounts, missingCosts, pageToken };
|
||||
return { since, month, forecast, targetId, json, accounts, missingCosts, pageToken };
|
||||
}
|
||||
|
||||
function renderProfitHelp(defaultWindow: string): RenderedCliResult {
|
||||
@@ -97,9 +109,11 @@ function renderProfitHelp(defaultWindow: string): RenderedCliResult {
|
||||
renderedText: [
|
||||
"SUB2API CODEX-POOL PROFIT",
|
||||
"Usage:",
|
||||
" bun scripts/cli.ts platform-infra sub2api codex-pool profit [--since 24h] [--target PK01] [--accounts [--page-token <token>]|--missing-costs] [--json]",
|
||||
" bun scripts/cli.ts platform-infra sub2api codex-pool profit [--since 24h|--month YYYY-MM [--forecast]] [--target PK01] [--accounts [--page-token <token>]|--missing-costs] [--json]",
|
||||
"Options:",
|
||||
` --since <duration> Native usage window; YAML default is ${defaultWindow}.`,
|
||||
" --month <YYYY-MM> Calendar-month usage plus YAML-declared one-time period costs.",
|
||||
" --forecast Extrapolate month-to-date variable revenue and costs to month end; period costs remain one-time.",
|
||||
" --target <id>",
|
||||
" --accounts Include one fixed-size account page with complete names and IDs.",
|
||||
" --page-token <token> Continue from the opaque token returned by the previous account page.",
|
||||
@@ -125,19 +139,40 @@ function renderProfit(response: Record<string, unknown>): RenderedCliResult {
|
||||
const formulas = record(summary.profitFormula);
|
||||
const disclosure = record(response.disclosure);
|
||||
const lines = [
|
||||
`SUB2API PROFIT status=${text(report.status)} target=${text(record(response.target).id)} window=${text(window.since)}`,
|
||||
`SUB2API PROFIT status=${text(report.status)} target=${text(record(response.target).id)} window=${text(window.month ?? window.since)}`,
|
||||
`WINDOW ${text(window.startAt)} -> ${text(window.endAt)} SOURCE native-admin-usage/${text(record(report.source).usageCostField)}`,
|
||||
`POLICY sale-rate=${text(policy.saleRateCnyPerApiUsd)}CNY/API_USD account-cost=${text(policy.accountCostSource)}`,
|
||||
"",
|
||||
"SUMMARY",
|
||||
];
|
||||
lines.push(table(
|
||||
["SCOPE", "STATUS", "REVENUE_CNY", "PROFIT_POOL_COST_CNY", "SELF_USE_COST_CNY", "PROFIT_BEFORE_UNKNOWN_COSTS_CNY", "UNKNOWN_API_USD"],
|
||||
[
|
||||
["不扣自用", text(record(summary.withoutSelfUse).status), cny(summary.revenueCny), cny(confirmedCosts.profitPool), "-", cny(profitBeforeUnknownCosts.withoutSelfUse), apiUsd(unknownBasis.profitPool)],
|
||||
["扣除自用", text(record(summary.withSelfUse).status), cny(summary.revenueCny), cny(confirmedCosts.profitPool), cny(confirmedCosts.selfUse), cny(profitBeforeUnknownCosts.withSelfUse), apiUsd(unknownBasis.total)],
|
||||
["SCOPE", "STATUS", "REVENUE_CNY", "TRAFFIC_COST_CNY", "SELF_USE_COST_CNY", "PERIOD_COST_CNY", "PROFIT_BEFORE_UNKNOWN_COSTS_CNY", "UNKNOWN_API_USD"],
|
||||
[
|
||||
["不扣自用", text(record(summary.withoutSelfUse).status), cny(summary.revenueCny), cny(confirmedCosts.profitPool), "-", cny(confirmedCosts.period), cny(profitBeforeUnknownCosts.withoutSelfUse), apiUsd(unknownBasis.profitPool)],
|
||||
["扣除自用", text(record(summary.withSelfUse).status), cny(summary.revenueCny), cny(confirmedCosts.profitPool), cny(confirmedCosts.selfUse), cny(confirmedCosts.period), cny(profitBeforeUnknownCosts.withSelfUse), apiUsd(unknownBasis.total)],
|
||||
],
|
||||
));
|
||||
const forecast = record(summary.forecast);
|
||||
if (Object.keys(forecast).length > 0) {
|
||||
const forecastCosts = record(forecast.costsCny);
|
||||
const forecastProfit = record(forecast.profitCny);
|
||||
lines.push("", `FULL-MONTH FORECAST method=${text(forecast.method)} scale=${text(forecast.scale)} observed=${text(forecast.observedSeconds)}s full-period=${text(forecast.fullPeriodSeconds)}s`);
|
||||
lines.push(table(
|
||||
["SCOPE", "STATUS", "REVENUE_CNY", "TRAFFIC_COST_CNY", "SELF_USE_COST_CNY", "PERIOD_COST_CNY", "FORECAST_PROFIT_CNY"],
|
||||
[
|
||||
["不扣自用", text(record(forecast.withoutSelfUse).status), cny(forecast.revenueCny), cny(forecastCosts.profitPool), "-", cny(forecastCosts.period), cny(forecastProfit.withoutSelfUse)],
|
||||
["扣除自用", text(record(forecast.withSelfUse).status), cny(forecast.revenueCny), cny(forecastCosts.profitPool), cny(forecastCosts.selfUse), cny(forecastCosts.period), cny(forecastProfit.withSelfUse)],
|
||||
],
|
||||
));
|
||||
}
|
||||
const periodCosts = arrayOfRecords(report.periodCosts);
|
||||
if (periodCosts.length > 0) {
|
||||
lines.push("", "PERIOD COSTS");
|
||||
lines.push(table(
|
||||
["ID", "PERIOD", "ACCOUNT", "AMOUNT_CNY", "DESCRIPTION"],
|
||||
periodCosts.map((row) => [text(row.id), text(row.period), text(row.accountName), cny(row.amountCny), text(row.description)]),
|
||||
));
|
||||
}
|
||||
lines.push("", "FORMULA");
|
||||
lines.push(`- 不扣自用: ${text(formulas.withoutSelfUse)}`);
|
||||
lines.push(`- 扣除自用: ${text(formulas.withSelfUse)}`);
|
||||
|
||||
@@ -30,7 +30,7 @@ function pyJson(value: unknown): string {
|
||||
return `json.loads(${JSON.stringify(JSON.stringify(value))})`;
|
||||
}
|
||||
|
||||
export function remotePythonCoreScript(mode: "sync" | "validate" | "trace" | "cleanup-probes" | "sentinel-probe" | "profit", encodedPayload: string, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string {
|
||||
export function remotePythonCoreScript(mode: "sync" | "validate" | "trace" | "cleanup-probes" | "sentinel-probe" | "profit" | "credits", encodedPayload: string, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string {
|
||||
const hostDockerEnvPath = target.runtimeMode === "host-docker" ? target.hostDockerEnvPath : null;
|
||||
return `
|
||||
set -u
|
||||
@@ -335,13 +335,14 @@ def sentinel_probe_change_reasons(current, profile):
|
||||
reasons.append("sentinel-protect")
|
||||
return reasons
|
||||
|
||||
def curl_api(method, path, bearer=None, payload=None):
|
||||
def curl_api(method, path, bearer=None, payload=None, idempotency_key=None):
|
||||
body = b"" if payload is None else json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
script = r'''
|
||||
set -eu
|
||||
method="$1"
|
||||
url="$2"
|
||||
token="\${3:-}"
|
||||
idempotency_key="\${4:-}"
|
||||
tmp="$(mktemp)"
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
cat > "$tmp"
|
||||
@@ -349,7 +350,11 @@ if [ -n "$token" ]; then
|
||||
if [ "$method" = "GET" ] && [ ! -s "$tmp" ]; then
|
||||
curl -sS -w '\\n__HTTP_CODE__:%{http_code}' -X "$method" -H "Authorization: Bearer $token" "$url"
|
||||
else
|
||||
curl -sS -w '\\n__HTTP_CODE__:%{http_code}' -X "$method" -H 'Content-Type: application/json' -H "Authorization: Bearer $token" --data-binary @"$tmp" "$url"
|
||||
if [ -n "$idempotency_key" ]; then
|
||||
curl -sS -w '\\n__HTTP_CODE__:%{http_code}' -X "$method" -H 'Content-Type: application/json' -H "Authorization: Bearer $token" -H "Idempotency-Key: $idempotency_key" --data-binary @"$tmp" "$url"
|
||||
else
|
||||
curl -sS -w '\\n__HTTP_CODE__:%{http_code}' -X "$method" -H 'Content-Type: application/json' -H "Authorization: Bearer $token" --data-binary @"$tmp" "$url"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
if [ "$method" = "GET" ] && [ ! -s "$tmp" ]; then
|
||||
@@ -362,11 +367,11 @@ fi
|
||||
if RUNTIME_MODE == "host-docker":
|
||||
if not isinstance(HOST_DOCKER_APP_PORT, int):
|
||||
raise RuntimeError("host-docker app port missing")
|
||||
proc = run(["sh", "-c", script, "sh", method, f"http://127.0.0.1:{HOST_DOCKER_APP_PORT}{path}", bearer or ""], body)
|
||||
proc = run(["sh", "-c", script, "sh", method, f"http://127.0.0.1:{HOST_DOCKER_APP_PORT}{path}", bearer or "", idempotency_key or ""], body)
|
||||
else:
|
||||
proc = run([
|
||||
"kubectl", "-n", NAMESPACE, "exec", "-i", APP_POD,
|
||||
"--", "sh", "-c", script, "sh", method, f"http://127.0.0.1:8080{path}", bearer or "",
|
||||
"--", "sh", "-c", script, "sh", method, f"http://127.0.0.1:8080{path}", bearer or "", idempotency_key or "",
|
||||
], body)
|
||||
return parse_curl_output(proc)
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
export const remotePythonCreditsScript = String.raw`
|
||||
|
||||
def credits_parse_time(value):
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
return parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed.astimezone(timezone.utc)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def credits_window_start(value, end_at):
|
||||
matched = re.fullmatch(r"([1-9][0-9]*)([mhd])", str(value or ""))
|
||||
if matched is None:
|
||||
raise RuntimeError("credits active window must use a duration such as 24h")
|
||||
seconds = int(matched.group(1)) * {"m": 60, "h": 3600, "d": 86400}[matched.group(2)]
|
||||
return end_at - timedelta(seconds=seconds)
|
||||
|
||||
def credits_all_users(token):
|
||||
users = []
|
||||
page = 1
|
||||
while True:
|
||||
data = ensure_success(curl_api("GET", f"/api/v1/admin/users?page={page}&page_size=100", bearer=token), "list users")
|
||||
batch = extract_items(data)
|
||||
users.extend(item for item in batch if isinstance(item, dict))
|
||||
total = int(data.get("total", len(users))) if isinstance(data, dict) else len(users)
|
||||
if not batch or len(batch) < 100 or page * 100 >= total:
|
||||
return users
|
||||
page += 1
|
||||
|
||||
def credits_active_usage(token, since):
|
||||
end_at = datetime.now(timezone.utc)
|
||||
start_at = credits_window_start(since, end_at)
|
||||
user_requests = {}
|
||||
page = 1
|
||||
while True:
|
||||
path = f"/api/v1/admin/usage?page={page}&page_size=100&start_date={start_at.date().isoformat()}&end_date={end_at.date().isoformat()}&timezone=UTC&sort_by=created_at&sort_order=desc"
|
||||
data = ensure_success(curl_api("GET", path, bearer=token), "list active user usage")
|
||||
batch = extract_items(data)
|
||||
reached_start = False
|
||||
for row in batch:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
created_at = credits_parse_time(row.get("created_at"))
|
||||
if created_at is not None and created_at < start_at:
|
||||
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):
|
||||
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
|
||||
page += 1
|
||||
|
||||
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}
|
||||
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)
|
||||
selected = []
|
||||
excluded = []
|
||||
for user_id, request_count in requests.items():
|
||||
user = users_by_id.get(user_id)
|
||||
if not isinstance(user, dict):
|
||||
excluded.append({"userId": user_id, "reason": "user-not-found"})
|
||||
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:
|
||||
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()}
|
||||
selected = []
|
||||
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())
|
||||
if not isinstance(user, dict):
|
||||
raise RuntimeError("exact user selector not found: " + str(selector))
|
||||
user_id = str(user.get("id"))
|
||||
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 []}
|
||||
|
||||
def run_credits_grant():
|
||||
payload = json.loads(base64.b64decode(PAYLOAD_B64).decode("utf-8")) if PAYLOAD_B64 else {}
|
||||
_, token, _ = login()
|
||||
selected, excluded, selection = credits_selected_users(token, payload)
|
||||
amount = profit_decimal(payload.get("amountUsd"))
|
||||
confirm = payload.get("confirm") is True
|
||||
batch_identity = json.dumps({
|
||||
"users": sorted(str(value) for value in (payload.get("users") or [])),
|
||||
"amountUsd": profit_decimal_text(amount),
|
||||
"reason": payload.get("reason"),
|
||||
}, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
batch_key = hashlib.sha256(batch_identity.encode("utf-8")).hexdigest()[:24]
|
||||
items = []
|
||||
write_succeeded = 0
|
||||
write_failed = 0
|
||||
reconciled = 0
|
||||
for user, request_count in sorted(selected, key=lambda item: (str(item[0].get("email") or ""), int(item[0].get("id") or 0))):
|
||||
before = profit_decimal(user.get("balance"))
|
||||
after = before + amount
|
||||
item = {
|
||||
"userId": user.get("id"), "email": user.get("email"), "role": user.get("role"), "status": user.get("status"),
|
||||
"requestCount": request_count, "balanceBefore": profit_decimal_text(before), "amountUsd": profit_decimal_text(amount),
|
||||
"balanceAfter": profit_decimal_text(after), "write": {"attempted": False, "status": "dry-run", "error": None}, "reconciled": None,
|
||||
}
|
||||
if confirm:
|
||||
try:
|
||||
ensure_success(curl_api("POST", f"/api/v1/admin/users/{user.get('id')}/balance", bearer=token, payload={
|
||||
"balance": float(amount), "operation": "add", "notes": payload.get("reason"),
|
||||
}, idempotency_key=f"unidesk-credit-{batch_key}-{user.get('id')}"), "add user compensation balance")
|
||||
item["write"] = {"attempted": True, "status": "succeeded", "error": None}
|
||||
write_succeeded += 1
|
||||
actual = ensure_success(curl_api("GET", f"/api/v1/admin/users/{user.get('id')}", bearer=token), "read user balance after grant")
|
||||
actual_balance = profit_decimal(actual.get("balance") if isinstance(actual, dict) else None)
|
||||
item["actualBalance"] = profit_decimal_text(actual_balance)
|
||||
item["reconciled"] = actual_balance == after
|
||||
reconciled += 1 if item["reconciled"] else 0
|
||||
except Exception as exc:
|
||||
item["write"] = {"attempted": True, "status": "failed", "error": str(exc)[:500]}
|
||||
item["reconciled"] = False
|
||||
write_failed += 1
|
||||
items.append(item)
|
||||
selected_count = len(items)
|
||||
return {
|
||||
"ok": not confirm or (write_failed == 0 and reconciled == selected_count),
|
||||
"mode": "confirmed" if confirm else "dry-run", "mutation": confirm and write_succeeded > 0,
|
||||
"selection": selection, "reason": payload.get("reason"), "items": items, "excluded": excluded,
|
||||
"confirmationUserIds": [item.get("userId") for item in items],
|
||||
"summary": {
|
||||
"selected": selected_count, "excluded": len(excluded), "amountUsd": profit_decimal_text(amount),
|
||||
"totalAmountUsd": profit_decimal_text(amount * selected_count), "writeAttempted": selected_count if confirm else 0,
|
||||
"writeSucceeded": write_succeeded, "writeFailed": write_failed, "reconciled": reconciled,
|
||||
},
|
||||
"valuesPrinted": False,
|
||||
}
|
||||
`;
|
||||
@@ -33,6 +33,20 @@ def profit_window_start(window, end_at):
|
||||
seconds = amount * {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit]
|
||||
return end_at - timedelta(seconds=seconds)
|
||||
|
||||
def profit_period_bounds(window, month, end_at):
|
||||
if month is None:
|
||||
return profit_window_start(window, end_at), end_at, None, end_at
|
||||
matched = re.fullmatch(r"(\d{4})-(0[1-9]|1[0-2])", str(month))
|
||||
if matched is None:
|
||||
raise RuntimeError("profit month must use YYYY-MM")
|
||||
year = int(matched.group(1))
|
||||
month_number = int(matched.group(2))
|
||||
start_at = datetime(year, month_number, 1, tzinfo=timezone.utc)
|
||||
next_at = datetime(year + 1, 1, 1, tzinfo=timezone.utc) if month_number == 12 else datetime(year, month_number + 1, 1, tzinfo=timezone.utc)
|
||||
if start_at > end_at:
|
||||
raise RuntimeError("profit month must not be in the future")
|
||||
return start_at, min(next_at, end_at), str(month), next_at
|
||||
|
||||
def profit_group_class(name, config):
|
||||
groups = config.get("groups") if isinstance(config.get("groups"), dict) else {}
|
||||
if name in (groups.get("profitable") or []):
|
||||
@@ -74,6 +88,19 @@ def profit_usage_rows(token, group_id, start_at, end_at):
|
||||
return rows, page
|
||||
page += 1
|
||||
|
||||
def profit_usage_stats(token, group_id, account_id, start_at, end_at):
|
||||
query = (
|
||||
f"/api/v1/admin/usage/stats?group_id={group_id}&account_id={account_id}"
|
||||
f"&start_date={start_at.date().isoformat()}&end_date={end_at.date().isoformat()}&timezone=UTC&nocache=1"
|
||||
)
|
||||
data = ensure_success(curl_api("GET", query, bearer=token), f"get usage stats for group {group_id} account {account_id}")
|
||||
return {
|
||||
"request_count": int(data.get("total_requests") or 0) if isinstance(data, dict) else 0,
|
||||
"input_tokens": int(data.get("total_input_tokens") or 0) if isinstance(data, dict) else 0,
|
||||
"output_tokens": int(data.get("total_output_tokens") or 0) if isinstance(data, dict) else 0,
|
||||
"actual_cost": profit_decimal(data.get("total_actual_cost") if isinstance(data, dict) else None),
|
||||
}
|
||||
|
||||
def profit_row_account_id(row):
|
||||
for key in ("account_id", "accountId"):
|
||||
if row.get(key) is not None:
|
||||
@@ -94,6 +121,12 @@ def profit_add_usage(target, row, cost_field):
|
||||
target["outputTokens"] += int(row.get("output_tokens") or 0)
|
||||
target["apiAmount"] += profit_decimal(row.get(cost_field))
|
||||
|
||||
def profit_add_stats(target, stats, cost_field):
|
||||
target["requestCount"] += int(stats.get("request_count") or 0)
|
||||
target["inputTokens"] += int(stats.get("input_tokens") or 0)
|
||||
target["outputTokens"] += int(stats.get("output_tokens") or 0)
|
||||
target["apiAmount"] += profit_decimal(stats.get(cost_field))
|
||||
|
||||
def profit_empty_account(group, group_class, account_id, account_name):
|
||||
return {
|
||||
"groupId": group.get("id"),
|
||||
@@ -111,10 +144,13 @@ def run_profit_report():
|
||||
payload = json.loads(base64.b64decode(PAYLOAD_B64).decode("utf-8")) if PAYLOAD_B64 else {}
|
||||
config = payload.get("config") if isinstance(payload.get("config"), dict) else {}
|
||||
window = payload.get("since")
|
||||
requested_month = payload.get("month")
|
||||
cost_field = config.get("usageCostField")
|
||||
sale_rate = profit_decimal(config.get("saleRateCnyPerApiUsd"))
|
||||
end_at = datetime.now(timezone.utc)
|
||||
start_at = profit_window_start(window, end_at)
|
||||
start_at, end_at, month, period_end_at = profit_period_bounds(window, requested_month, end_at)
|
||||
period_costs = [item for item in (config.get("periodCosts") or []) if isinstance(item, dict) and item.get("period") == month]
|
||||
period_cost = sum((profit_decimal(item.get("amountCny")) for item in period_costs), profit_decimal(0))
|
||||
_, token, _ = login()
|
||||
groups = [item for item in list_groups(token) if isinstance(item, dict)]
|
||||
account_rows = []
|
||||
@@ -132,17 +168,30 @@ def run_profit_report():
|
||||
group_class = profit_group_class(group_name, config)
|
||||
members = list_accounts_for_group(token, group_id)
|
||||
accounts_by_id = {str(item.get("id")): item for item in members if isinstance(item, dict) and item.get("id") is not None}
|
||||
usage, pages = profit_usage_rows(token, group_id, start_at, end_at)
|
||||
total_pages += pages
|
||||
aggregates = {}
|
||||
for row in usage:
|
||||
account_id = profit_row_account_id(row)
|
||||
account = accounts_by_id.get(str(account_id))
|
||||
account_name = profit_row_account_name(row) or (account.get("name") if isinstance(account, dict) else None) or f"account-{account_id}"
|
||||
key = str(account_id) if account_id is not None else "name:" + str(account_name)
|
||||
if key not in aggregates:
|
||||
aggregates[key] = profit_empty_account(group, group_class, account_id, account_name)
|
||||
profit_add_usage(aggregates[key], row, cost_field)
|
||||
if month is not None:
|
||||
for account in members:
|
||||
if not isinstance(account, dict) or account.get("id") is None:
|
||||
continue
|
||||
account_id = account.get("id")
|
||||
account_name = account.get("name") or f"account-{account_id}"
|
||||
stats = profit_usage_stats(token, group_id, account_id, start_at, end_at)
|
||||
total_pages += 1
|
||||
if int(stats.get("request_count") or 0) <= 0:
|
||||
continue
|
||||
aggregates[str(account_id)] = profit_empty_account(group, group_class, account_id, account_name)
|
||||
profit_add_stats(aggregates[str(account_id)], stats, cost_field)
|
||||
else:
|
||||
usage, pages = profit_usage_rows(token, group_id, start_at, end_at)
|
||||
total_pages += pages
|
||||
for row in usage:
|
||||
account_id = profit_row_account_id(row)
|
||||
account = accounts_by_id.get(str(account_id))
|
||||
account_name = profit_row_account_name(row) or (account.get("name") if isinstance(account, dict) else None) or f"account-{account_id}"
|
||||
key = str(account_id) if account_id is not None else "name:" + str(account_name)
|
||||
if key not in aggregates:
|
||||
aggregates[key] = profit_empty_account(group, group_class, account_id, account_name)
|
||||
profit_add_usage(aggregates[key], row, cost_field)
|
||||
group_amount = profit_decimal(0)
|
||||
group_tokens = 0
|
||||
group_requests = 0
|
||||
@@ -180,26 +229,66 @@ def run_profit_report():
|
||||
self_cost = sum((profit_decimal(row["upstreamCostCny"]) for row in self_accounts if row["upstreamCostCny"] is not None), profit_decimal(0))
|
||||
unknown_profit_api_usd = sum((profit_decimal(row["apiAmountUsd"]) for row in missing_profit), profit_decimal(0))
|
||||
unknown_self_api_usd = sum((profit_decimal(row["apiAmountUsd"]) for row in missing_self), profit_decimal(0))
|
||||
profit_before_unknown_without_self = revenue - profit_cost
|
||||
profit_before_unknown_with_self = profit_before_unknown_without_self - self_cost
|
||||
operating_profit_without_self = revenue - profit_cost
|
||||
operating_profit_with_self = operating_profit_without_self - self_cost
|
||||
profit_before_unknown_without_self = operating_profit_without_self - period_cost
|
||||
profit_before_unknown_with_self = operating_profit_with_self - period_cost
|
||||
without_complete = not missing_profit and not unclassified
|
||||
with_complete = without_complete and not missing_self
|
||||
forecast = None
|
||||
if payload.get("forecast") is True:
|
||||
if month is None:
|
||||
raise RuntimeError("profit forecast requires a calendar month")
|
||||
observed_seconds = profit_decimal((end_at - start_at).total_seconds())
|
||||
full_period_seconds = profit_decimal((period_end_at - start_at).total_seconds())
|
||||
if observed_seconds <= 0:
|
||||
raise RuntimeError("profit forecast has no observed period")
|
||||
scale = full_period_seconds / observed_seconds
|
||||
forecast_revenue = revenue * scale
|
||||
forecast_profit_cost = profit_cost * scale
|
||||
forecast_self_cost = self_cost * scale
|
||||
forecast_without_self = forecast_revenue - forecast_profit_cost - period_cost
|
||||
forecast_with_self = forecast_without_self - forecast_self_cost
|
||||
forecast = {
|
||||
"method": "month-to-date-linear-run-rate",
|
||||
"scale": profit_decimal_text(scale),
|
||||
"observedSeconds": profit_decimal_text(observed_seconds),
|
||||
"fullPeriodSeconds": profit_decimal_text(full_period_seconds),
|
||||
"revenueCny": profit_decimal_text(forecast_revenue),
|
||||
"costsCny": {
|
||||
"profitPool": profit_decimal_text(forecast_profit_cost),
|
||||
"selfUse": profit_decimal_text(forecast_self_cost),
|
||||
"period": profit_decimal_text(period_cost),
|
||||
},
|
||||
"profitCny": {
|
||||
"withoutSelfUse": profit_decimal_text(forecast_without_self) if without_complete else None,
|
||||
"withSelfUse": profit_decimal_text(forecast_with_self) if with_complete else None,
|
||||
},
|
||||
"withoutSelfUse": {"status": "complete" if without_complete else "partial"},
|
||||
"withSelfUse": {"status": "complete" if with_complete else "partial"},
|
||||
}
|
||||
account_rows.sort(key=lambda row: (row["class"], row["groupName"], str(row["accountId"]), str(row["accountName"])))
|
||||
return {
|
||||
"ok": True,
|
||||
"mode": "profit",
|
||||
"status": "complete" if with_complete else "partial",
|
||||
"window": {"since": window, "startAt": start_at.isoformat(), "endAt": end_at.isoformat(), "timezone": "UTC"},
|
||||
"source": {"kind": "sub2api-native-admin-usage", "endpoint": "/api/v1/admin/usage", "usageCostField": cost_field, "pagesRead": total_pages},
|
||||
"window": {"since": window if month is None else None, "month": month, "startAt": start_at.isoformat(), "endAt": end_at.isoformat(), "timezone": "UTC"},
|
||||
"source": {"kind": "sub2api-native-admin-usage", "endpoint": "/api/v1/admin/usage/stats" if month is not None else "/api/v1/admin/usage", "usageCostField": cost_field, "queriesRead": total_pages},
|
||||
"policy": config,
|
||||
"groups": group_rows,
|
||||
"accounts": account_rows,
|
||||
"periodCosts": [{"id": item.get("id"), "period": item.get("period"), "accountName": item.get("accountName"), "amountCny": profit_decimal_text(profit_decimal(item.get("amountCny"))), "description": item.get("description")} for item in period_costs],
|
||||
"summary": {
|
||||
"revenueCny": profit_decimal_text(revenue),
|
||||
"confirmedCostsCny": {
|
||||
"profitPool": profit_decimal_text(profit_cost),
|
||||
"selfUse": profit_decimal_text(self_cost),
|
||||
"total": profit_decimal_text(profit_cost + self_cost),
|
||||
"period": profit_decimal_text(period_cost),
|
||||
"total": profit_decimal_text(profit_cost + self_cost + period_cost),
|
||||
},
|
||||
"operatingProfitBeforeUnknownCostsCny": {
|
||||
"withoutSelfUse": profit_decimal_text(operating_profit_without_self),
|
||||
"withSelfUse": profit_decimal_text(operating_profit_with_self),
|
||||
},
|
||||
"profitBeforeUnknownCostsCny": {
|
||||
"withoutSelfUse": profit_decimal_text(profit_before_unknown_without_self),
|
||||
@@ -211,11 +300,12 @@ def run_profit_report():
|
||||
"total": profit_decimal_text(unknown_profit_api_usd + unknown_self_api_usd),
|
||||
},
|
||||
"profitFormula": {
|
||||
"withoutSelfUse": "profitBeforeUnknownCostsCny.withoutSelfUse - sum(missing profit account apiAmountUsd * unknown costRateCnyPerApiUsd)",
|
||||
"withSelfUse": "profitBeforeUnknownCostsCny.withSelfUse - sum(all missing account apiAmountUsd * unknown costRateCnyPerApiUsd)",
|
||||
"withoutSelfUse": "revenueCny - confirmedCostsCny.profitPool - confirmedCostsCny.period - sum(missing profit account apiAmountUsd * unknown costRateCnyPerApiUsd)",
|
||||
"withSelfUse": "revenueCny - confirmedCostsCny.profitPool - confirmedCostsCny.selfUse - confirmedCostsCny.period - sum(all missing account apiAmountUsd * unknown costRateCnyPerApiUsd)",
|
||||
},
|
||||
"withoutSelfUse": {"status": "complete" if without_complete else "partial", "profitCny": profit_decimal_text(profit_before_unknown_without_self) if without_complete else None},
|
||||
"withSelfUse": {"status": "complete" if with_complete else "partial", "profitCny": profit_decimal_text(profit_before_unknown_with_self) if with_complete else None},
|
||||
"forecast": forecast,
|
||||
},
|
||||
"completeness": {
|
||||
"missingCostAccounts": [{"class": row["class"], "groupName": row["groupName"], "accountId": row["accountId"], "accountName": row["accountName"], "apiAmountUsd": row["apiAmountUsd"]} for row in missing_profit + missing_self],
|
||||
|
||||
@@ -172,6 +172,8 @@ try:
|
||||
result = run_sentinel_probe()
|
||||
elif MODE == "profit":
|
||||
result = run_profit_report()
|
||||
elif MODE == "credits":
|
||||
result = run_credits_grant()
|
||||
else:
|
||||
result = run_validate()
|
||||
except Exception as exc:
|
||||
|
||||
@@ -8,8 +8,9 @@ import { remotePythonSyncValidateScript } from "./remote-python-sync-validate";
|
||||
import { remotePythonTraceScript } from "./remote-python-trace";
|
||||
import { remotePythonSentinelProbeScript } from "./remote-python-sentinel-probe";
|
||||
import { remotePythonProfitScript } from "./remote-python-profit";
|
||||
import { remotePythonCreditsScript } from "./remote-python-credits";
|
||||
|
||||
export function remotePythonScript(mode: "sync" | "validate" | "trace" | "cleanup-probes" | "sentinel-probe" | "profit", encodedPayload: string, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string {
|
||||
export function remotePythonScript(mode: "sync" | "validate" | "trace" | "cleanup-probes" | "sentinel-probe" | "profit" | "credits", encodedPayload: string, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string {
|
||||
return [
|
||||
remotePythonCoreScript(mode, encodedPayload, pool, target),
|
||||
remotePythonSentinelScript,
|
||||
@@ -17,6 +18,7 @@ export function remotePythonScript(mode: "sync" | "validate" | "trace" | "cleanu
|
||||
remotePythonSyncValidateScript,
|
||||
remotePythonTraceScript,
|
||||
remotePythonProfitScript,
|
||||
remotePythonCreditsScript,
|
||||
remotePythonSentinelProbeScript,
|
||||
].join("");
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "..
|
||||
import { runSshCommandCapture, type SshCaptureResult } from "../ssh";
|
||||
|
||||
import type { CodexPoolConfig, CodexPoolRuntimeTarget, TraceOptions } from "./types";
|
||||
import { readCodexPoolConfig } from "./config";
|
||||
import { shQuote } from "./remote";
|
||||
import { remotePythonScript } from "./remote-python-sync";
|
||||
import { sentinelImageDockerfilePath } from "./types";
|
||||
@@ -56,6 +57,12 @@ 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();
|
||||
const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
|
||||
return remotePythonScript("credits", encoded, pool, target);
|
||||
}
|
||||
|
||||
export function sentinelReportScript(pool: CodexPoolConfig, events: number, target: CodexPoolRuntimeTarget): string {
|
||||
const stateName = pool.sentinel.stateConfigMapName;
|
||||
const cronJobName = pool.sentinel.cronJobName;
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function capture(config: UniDeskConfig, target: string, args: strin
|
||||
return await runSshCommandCapture(config, target, args, input);
|
||||
}
|
||||
|
||||
export type RemoteCodexPoolMode = "sync" | "validate" | "runtime" | "faults" | "profit" | "error-passthrough" | "sentinel-probe" | "sentinel-image-status" | "sentinel-image-build";
|
||||
export type RemoteCodexPoolMode = "sync" | "validate" | "runtime" | "faults" | "profit" | "credits" | "error-passthrough" | "sentinel-probe" | "sentinel-image-status" | "sentinel-image-build";
|
||||
|
||||
export async function runRemoteCodexPoolScript(config: UniDeskConfig, mode: RemoteCodexPoolMode, script: string, target = codexPoolRuntimeTarget()): Promise<SshCaptureResult> {
|
||||
const jobName = `codex-pool-${mode}-${Date.now().toString(36)}`.slice(0, 63);
|
||||
@@ -85,6 +85,7 @@ export function codexPoolModeCommand(mode: RemoteCodexPoolMode): string {
|
||||
if (mode === "sync") return "bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm";
|
||||
if (mode === "runtime") return "bun scripts/cli.ts platform-infra sub2api codex-pool runtime list";
|
||||
if (mode === "profit") return "bun scripts/cli.ts platform-infra sub2api codex-pool profit --since 24h";
|
||||
if (mode === "credits") return "bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --active-since 24h --amount-usd 20 --reason <text>";
|
||||
if (mode === "error-passthrough") return "bun scripts/cli.ts platform-infra sub2api codex-pool error-passthrough list";
|
||||
if (mode === "sentinel-probe") return "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-probe --account <accountName> --confirm";
|
||||
if (mode === "sentinel-image-status") return "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status";
|
||||
|
||||
@@ -29,6 +29,15 @@ export function renderRuntimeMutationResult(ok: boolean, options: RuntimeOptions
|
||||
stringValue(write.error) ?? stringValue(item.reconcileError) ?? "-",
|
||||
]),
|
||||
]));
|
||||
else if (options.kind === "account-name") lines.push(renderTable([
|
||||
["ACCOUNT", "ID", "CHANGE", "WRITE", "RECONCILED", "BEFORE_NAME", "DESIRED_NAME", "ACTUAL_NAME", "FAILURE"],
|
||||
...fields.map(({ item, write, before, desired, actual }) => [
|
||||
stringValue(item.accountName) ?? "-", String(item.accountId ?? "-"), stringValue(item.change) ?? "-",
|
||||
stringValue(write.status) ?? "-", item.reconciled === true ? "true" : item.reconciled === false ? "false" : "-",
|
||||
stringValue(before.name) ?? "-", stringValue(desired.name) ?? "-", stringValue(actual.name) ?? "-",
|
||||
stringValue(write.error) ?? stringValue(item.reconcileError) ?? "-",
|
||||
]),
|
||||
]));
|
||||
else lines.push(renderTable([
|
||||
["ACCOUNT", "ID", "CHANGE", "WRITE", "RECONCILED", "BEFORE", "DESIRED", "ACTUAL", "BEFORE_CODES", "DESIRED_CODES", "ACTUAL_CODES", "FAILURE"],
|
||||
...fields.map(({ item, write, before, desired, actual }) => {
|
||||
|
||||
@@ -11,8 +11,9 @@ export interface RuntimeOptions {
|
||||
platform: string | null;
|
||||
pageToken: string | null;
|
||||
template: string | null;
|
||||
kind: "temp-unschedulable" | "priority";
|
||||
kind: "temp-unschedulable" | "priority" | "account-name";
|
||||
priority: number | null;
|
||||
name: string | null;
|
||||
confirm: boolean;
|
||||
full: boolean;
|
||||
raw: boolean;
|
||||
@@ -25,7 +26,7 @@ export interface RuntimeOptions {
|
||||
export function parseRuntimeOptions(args: string[]): RuntimeOptions {
|
||||
const [actionRaw = "list", ...rest] = args;
|
||||
if (!(["list", "get", "errors", "infrastructure", "apply", "delete"] as string[]).includes(actionRaw)) {
|
||||
throw new Error("runtime usage: list|get|errors|infrastructure|apply|delete [--account <name-or-id>|--accounts <selectors>] [--group <name-or-id>|--all-groups] [--platform <name>] [--page-token <token>] [--since 24h] [--tail 50000] [--template <id>] [--kind temp-unschedulable|priority] [--priority <0-1000>] [--confirm] [--target <id>] [--json|--full|--raw]");
|
||||
throw new Error("runtime usage: list|get|errors|infrastructure|apply|delete [--account <name-or-id>|--accounts <selectors>] [--group <name-or-id>|--all-groups] [--platform <name>] [--page-token <token>] [--since 24h] [--tail 50000] [--template <id>] [--kind temp-unschedulable|priority|account-name] [--priority <0-1000>|--name <account-name>] [--confirm] [--target <id>] [--json|--full|--raw]");
|
||||
}
|
||||
let account: string | null = null;
|
||||
let accounts: string[] = [];
|
||||
@@ -34,8 +35,9 @@ export function parseRuntimeOptions(args: string[]): RuntimeOptions {
|
||||
let platform: string | null = null;
|
||||
let pageToken: string | null = null;
|
||||
let template: string | null = null;
|
||||
let kind: "temp-unschedulable" | "priority" = "temp-unschedulable";
|
||||
let kind: "temp-unschedulable" | "priority" | "account-name" = "temp-unschedulable";
|
||||
let priority: number | null = null;
|
||||
let name: string | null = null;
|
||||
let confirm = false;
|
||||
let full = false;
|
||||
let raw = false;
|
||||
@@ -73,6 +75,8 @@ export function parseRuntimeOptions(args: string[]): RuntimeOptions {
|
||||
else if (arg.startsWith("--kind=")) kind = parseRuntimeKind(arg.slice("--kind=".length));
|
||||
else if (arg === "--priority") priority = parsePriority(readValue("--priority"));
|
||||
else if (arg.startsWith("--priority=")) priority = parsePriority(arg.slice("--priority=".length));
|
||||
else if (arg === "--name") name = readValue("--name");
|
||||
else if (arg.startsWith("--name=")) name = arg.slice("--name=".length).trim();
|
||||
else if (arg === "--target") targetId = readValue("--target");
|
||||
else if (arg.startsWith("--target=")) targetId = arg.slice("--target=".length).trim();
|
||||
else if (arg === "--since") since = readValue("--since");
|
||||
@@ -100,12 +104,19 @@ export function parseRuntimeOptions(args: string[]): RuntimeOptions {
|
||||
if (kind === "priority" && priority === null) throw new Error("runtime apply --kind priority requires --priority <0-1000>");
|
||||
if (kind === "priority" && template !== null) throw new Error("runtime apply --kind priority does not accept --template");
|
||||
if (kind !== "priority" && priority !== null) throw new Error("--priority requires --kind priority");
|
||||
if (kind === "account-name" && action !== "apply") throw new Error("runtime --kind account-name only supports apply");
|
||||
if (kind === "account-name" && (name === null || name.length === 0 || name.length > 256 || /[\r\n]/u.test(name))) {
|
||||
throw new Error("runtime apply --kind account-name requires --name with a supported account name");
|
||||
}
|
||||
if (kind === "account-name" && accounts.length > 0) throw new Error("runtime account-name updates exactly one --account at a time");
|
||||
if (kind === "account-name" && template !== null) throw new Error("runtime apply --kind account-name does not accept --template");
|
||||
if (kind !== "account-name" && name !== null) throw new Error("--name requires --kind account-name");
|
||||
if (action !== "apply" && template !== null) throw new Error(`runtime ${action} does not accept --template`);
|
||||
if (action !== "errors" && action !== "infrastructure" && (since !== "24h" || tail !== 50_000)) throw new Error(`runtime ${action} does not accept --since or --tail`);
|
||||
if (!/^\d+[mhd]$/u.test(since)) throw new Error("--since must use <number>m, <number>h, or <number>d");
|
||||
if (["list", "get", "errors", "infrastructure"].includes(action) && confirm) throw new Error(`runtime ${action} does not accept --confirm`);
|
||||
if ([full, raw, json].filter(Boolean).length > 1) throw new Error("use only one of --json, --full, or --raw");
|
||||
return { action, account, accounts, group, allGroups, platform, pageToken, template, kind, priority, confirm, full, raw, json, since, tail, targetId };
|
||||
return { action, account, accounts, group, allGroups, platform, pageToken, template, kind, priority, name, confirm, full, raw, json, since, tail, targetId };
|
||||
}
|
||||
|
||||
function parseAccountSelectors(value: string): string[] {
|
||||
@@ -116,8 +127,8 @@ function parseAccountSelectors(value: string): string[] {
|
||||
return selectors;
|
||||
}
|
||||
|
||||
function parseRuntimeKind(value: string): "temp-unschedulable" | "priority" {
|
||||
if (value !== "temp-unschedulable" && value !== "priority") throw new Error("--kind must be temp-unschedulable or priority");
|
||||
function parseRuntimeKind(value: string): "temp-unschedulable" | "priority" | "account-name" {
|
||||
if (value !== "temp-unschedulable" && value !== "priority" && value !== "account-name") throw new Error("--kind must be temp-unschedulable, priority, or account-name");
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -1068,6 +1068,19 @@ def selected_account_details(token, accounts):
|
||||
return details
|
||||
|
||||
def mutation_plan(detail, action, include_rules):
|
||||
if PAYLOAD.get("kind") == "account-name":
|
||||
before_name = str(detail.get("name") or "")
|
||||
desired_name = str(PAYLOAD.get("name") or "")
|
||||
return {
|
||||
"accountName": before_name,
|
||||
"accountId": detail.get("id"),
|
||||
"change": "noop" if before_name == desired_name else "update",
|
||||
"before": {"name": before_name},
|
||||
"desired": {"name": desired_name},
|
||||
"template": None,
|
||||
"_kind": "account-name",
|
||||
"_desiredName": desired_name,
|
||||
}
|
||||
if PAYLOAD.get("kind") == "priority":
|
||||
before_priority = int(detail.get("priority") or 0)
|
||||
desired_priority = int(PAYLOAD.get("priority"))
|
||||
@@ -1114,7 +1127,12 @@ def write_mutation_plan(token, plan):
|
||||
if plan["change"] == "noop":
|
||||
return {"attempted": False, "status": "noop", "error": None}
|
||||
try:
|
||||
payload = {"priority": plan["_desiredPriority"]} if plan["_kind"] == "priority" else {"credentials": plan["_desiredCredentials"]}
|
||||
if plan["_kind"] == "priority":
|
||||
payload = {"priority": plan["_desiredPriority"]}
|
||||
elif plan["_kind"] == "account-name":
|
||||
payload = {"name": plan["_desiredName"]}
|
||||
else:
|
||||
payload = {"credentials": plan["_desiredCredentials"]}
|
||||
data_of(curl_api("PUT", f"/api/v1/admin/accounts/{plan['accountId']}", bearer=token, payload=payload), "update account runtime config")
|
||||
return {"attempted": True, "status": "succeeded", "error": None}
|
||||
except Exception as exc:
|
||||
@@ -1129,6 +1147,10 @@ def reconcile_mutation_plan(token, plan, write, include_rules):
|
||||
actual_priority = int(actual_detail.get("priority") or 0)
|
||||
item["actual"] = {"priority": actual_priority}
|
||||
item["reconciled"] = actual_priority == plan["_desiredPriority"]
|
||||
elif plan["_kind"] == "account-name":
|
||||
actual_name = str(actual_detail.get("name") or "")
|
||||
item["actual"] = {"name": actual_name}
|
||||
item["reconciled"] = actual_name == plan["_desiredName"]
|
||||
else:
|
||||
actual_policy = normalize_policy(actual_detail.get("credentials"))
|
||||
item["actual"] = policy_summary(actual_policy, include_rules)
|
||||
|
||||
@@ -14,7 +14,7 @@ export async function codexPoolRuntime(config: UniDeskConfig, args: string[]): P
|
||||
const options = parseRuntimeOptions(args);
|
||||
const pool = readCodexPoolConfig();
|
||||
const target = codexPoolRuntimeTarget(options.targetId);
|
||||
const selectedTemplate = options.kind === "priority" || options.template === null ? null : pool.runtime.templatesById[options.template];
|
||||
const selectedTemplate = options.kind !== "temp-unschedulable" || options.template === null ? null : pool.runtime.templatesById[options.template];
|
||||
if (options.template !== null && selectedTemplate === undefined) {
|
||||
throw new Error(`unknown runtime template ${options.template}; available: ${pool.runtime.templates.map((item) => item.id).join(", ")}`);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export async function codexPoolRuntime(config: UniDeskConfig, args: string[]): P
|
||||
pageToken: options.pageToken,
|
||||
kind: options.kind,
|
||||
priority: options.priority,
|
||||
name: options.name,
|
||||
confirm: options.confirm,
|
||||
full: options.full,
|
||||
since: options.since,
|
||||
@@ -84,6 +85,7 @@ export async function codexPoolRuntime(config: UniDeskConfig, args: string[]): P
|
||||
template: options.template,
|
||||
kind: options.kind,
|
||||
priority: options.priority,
|
||||
name: options.name,
|
||||
confirm: options.confirm,
|
||||
since: options.action === "errors" || options.action === "infrastructure" ? options.since : undefined,
|
||||
tail: options.action === "errors" || options.action === "infrastructure" ? options.tail : undefined,
|
||||
|
||||
@@ -205,6 +205,13 @@ export interface CodexPoolProfitConfig {
|
||||
usageCostField: "actual_cost";
|
||||
accountCostSource: "account-name-trailing-cny-per-api-usd";
|
||||
saleRateCnyPerApiUsd: number;
|
||||
periodCosts: Array<{
|
||||
id: string;
|
||||
period: string;
|
||||
accountName: string;
|
||||
amountCny: number;
|
||||
description: string;
|
||||
}>;
|
||||
groups: {
|
||||
profitable: string[];
|
||||
selfUse: string[];
|
||||
@@ -382,7 +389,7 @@ export function codexPoolHelp(): unknown {
|
||||
const pool = readCodexPoolConfig();
|
||||
const runtimeTarget = codexPoolRuntimeTarget();
|
||||
return {
|
||||
command: "platform-infra sub2api codex-pool plan|sync|validate|runtime|faults|profit|error-passthrough|trace|feedback|sentinel-image|sentinel-probe|sentinel-report|cleanup-probes|expose|configure-local",
|
||||
command: "platform-infra sub2api codex-pool plan|sync|validate|runtime|faults|profit|credits|error-passthrough|trace|feedback|sentinel-image|sentinel-probe|sentinel-report|cleanup-probes|expose|configure-local",
|
||||
output: "json, except trace, feedback, and sentinel-report default to low-noise text tables",
|
||||
usage: [
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool plan",
|
||||
@@ -393,11 +400,13 @@ export function codexPoolHelp(): unknown {
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool runtime get --account <name-or-id> [--target PK01] [--json|--full|--raw]",
|
||||
"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] [--target PK01] [--accounts [--page-token <token>]|--missing-costs] [--json]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool profit [--since 24h|--month YYYY-MM [--forecast]] [--target PK01] [--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 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]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --accounts <name-or-id,...> --template <id> [--target PK01] [--confirm] [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --accounts <name-or-id,...> --kind priority --priority <0-1000> [--target PK01] [--confirm]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --account <name-or-id> --kind account-name --name <new-name> [--target PK01] [--confirm]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool runtime delete --account <name-or-id> --kind temp-unschedulable [--target PK01] [--confirm]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool runtime delete --accounts <name-or-id,...> --kind temp-unschedulable [--target PK01] [--confirm] [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool trace [--target D601] --request-id <requestId> [--since 24h|--tail 20000|--context-seconds 300|--show-lines|--raw]",
|
||||
|
||||
Reference in New Issue
Block a user