Merge pull request #2041 from pikasTech/feat/sub2api-profit-report
feat: 增加 Sub2API 24 小时利润统计 CLI
This commit is contained in:
@@ -86,6 +86,8 @@ bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --target PK01
|
||||
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>
|
||||
bun scripts/cli.ts platform-infra sub2api codex-pool profit --target PK01 --since 24h
|
||||
bun scripts/cli.ts platform-infra sub2api codex-pool profit --target PK01 --since 24h --json
|
||||
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
|
||||
@@ -94,6 +96,13 @@ bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-report --target D6
|
||||
bun scripts/cli.ts platform-infra sub2api codex-pool cleanup-probes --target D601 --confirm
|
||||
```
|
||||
|
||||
- `profit` 只读聚合 Sub2API 原生 `/api/v1/admin/usage`:
|
||||
- 默认窗口、盈利分组、自用分组、排除分组、净收入系数和账号采购成本来源由 `config/platform-infra/sub2api-codex-pool.yaml#profit` 声明;
|
||||
- 默认 Kubernetes 列表按完整账号名称显示请求数、Token、原生 `actual_cost`、采购成本系数、收入、上游成本和利润贡献;
|
||||
- 汇总同时给出“不扣自用成本”和“扣除自用成本”两种口径,自用分组不计收入;
|
||||
- 账号名称末尾缺少采购成本系数,或存在未分类流量时,相关口径必须显示 `partial`,禁止把缺失值当作零;
|
||||
- `--json` 保留精确十进制字符串、原生来源、时间窗、分页数和完整性明细;命令不修改 runtime。
|
||||
|
||||
`config/platform-infra/sub2api-codex-pool.yaml` 控制:
|
||||
|
||||
- `pool.groupName`: Sub2API group 名称。
|
||||
|
||||
@@ -45,6 +45,16 @@ runtime:
|
||||
durationMinutes: 1
|
||||
description: 明确命中 524 上游不可用、过载或并发限制时短时冷却当前账号。
|
||||
|
||||
profit:
|
||||
defaultWindow: 24h
|
||||
usageCostField: actual_cost
|
||||
accountCostSource: account-name-trailing-coefficient
|
||||
netRevenueCoefficient: 0.10
|
||||
groups:
|
||||
profitable: [unidesk-codex-pool]
|
||||
selfUse: [自用]
|
||||
excluded: [grok]
|
||||
|
||||
pool:
|
||||
groupName: unidesk-codex-pool
|
||||
groupDescription: UniDesk-managed Codex API-key pool for YAML-selected Sub2API clients.
|
||||
|
||||
@@ -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, CodexPoolPublicExposureConfig, CodexProfile, CodexRuntimeConfig, CodexRuntimeTemplate, CodexSentinelProtectPolicy, CodexTempUnschedulablePolicy, CodexTempUnschedulableRule, OpenAIResponsesWebSocketsV2Mode } from "./types";
|
||||
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 { 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";
|
||||
@@ -179,6 +179,7 @@ export function readCodexPoolConfig(): CodexPoolConfig {
|
||||
defaultSentinelProtect,
|
||||
profiles,
|
||||
runtime,
|
||||
profit: readProfitConfig(parsed.profit),
|
||||
manualAccounts,
|
||||
publicExposure: readPublicExposureConfig(parsed.publicExposure),
|
||||
localCodex: readLocalCodexConfig(parsed.localCodex),
|
||||
@@ -199,6 +200,51 @@ export function readCodexPoolConfig(): CodexPoolConfig {
|
||||
return config;
|
||||
}
|
||||
|
||||
function readProfitConfig(value: unknown): CodexPoolProfitConfig {
|
||||
if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.profit must be a YAML object`);
|
||||
const groups = requiredRecordConfigField(value, "groups", "profit");
|
||||
const profitable = readUniqueStringArray(groups.profitable, "profit.groups.profitable", false);
|
||||
const selfUse = readUniqueStringArray(groups.selfUse, "profit.groups.selfUse", false);
|
||||
const excluded = readUniqueStringArray(groups.excluded, "profit.groups.excluded", true);
|
||||
const allGroups = [...profitable, ...selfUse, ...excluded];
|
||||
if (new Set(allGroups).size !== allGroups.length) {
|
||||
throw new Error(`${codexPoolConfigPath}.profit.groups entries must be disjoint`);
|
||||
}
|
||||
const netRevenueCoefficient = numberValue(value.netRevenueCoefficient);
|
||||
if (netRevenueCoefficient === null || netRevenueCoefficient < 0) {
|
||||
throw new Error(`${codexPoolConfigPath}.profit.netRevenueCoefficient must be >= 0`);
|
||||
}
|
||||
const defaultWindow = requiredStringConfigField(value, "defaultWindow", "profit");
|
||||
if (!/^[1-9][0-9]*(?:s|m|h|d)$/u.test(defaultWindow)) {
|
||||
throw new Error(`${codexPoolConfigPath}.profit.defaultWindow must be a duration such as 24h`);
|
||||
}
|
||||
const usageCostField = requiredStringConfigField(value, "usageCostField", "profit");
|
||||
if (usageCostField !== "actual_cost") {
|
||||
throw new Error(`${codexPoolConfigPath}.profit.usageCostField must be actual_cost`);
|
||||
}
|
||||
const accountCostSource = requiredStringConfigField(value, "accountCostSource", "profit");
|
||||
if (accountCostSource !== "account-name-trailing-coefficient") {
|
||||
throw new Error(`${codexPoolConfigPath}.profit.accountCostSource must be account-name-trailing-coefficient`);
|
||||
}
|
||||
return {
|
||||
defaultWindow,
|
||||
usageCostField,
|
||||
accountCostSource,
|
||||
netRevenueCoefficient,
|
||||
groups: { profitable, selfUse, excluded },
|
||||
};
|
||||
}
|
||||
|
||||
function readUniqueStringArray(value: unknown, path: string, allowEmpty: boolean): string[] {
|
||||
if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && item.trim().length > 0)) {
|
||||
throw new Error(`${codexPoolConfigPath}.${path} must be an array of non-empty strings`);
|
||||
}
|
||||
const normalized = value.map((item) => item.trim());
|
||||
if (!allowEmpty && normalized.length === 0) throw new Error(`${codexPoolConfigPath}.${path} must not be empty`);
|
||||
if (new Set(normalized).size !== normalized.length) throw new Error(`${codexPoolConfigPath}.${path} must not contain duplicates`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function readRuntimeConfig(value: unknown): CodexRuntimeConfig {
|
||||
if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.runtime must be a YAML object`);
|
||||
if (!Array.isArray(value.templates)) throw new Error(`${codexPoolConfigPath}.runtime.templates must be a YAML array`);
|
||||
|
||||
@@ -14,3 +14,4 @@ export * from "./accounts";
|
||||
export * from "./remote-python-sync";
|
||||
export * from "./remote";
|
||||
export * from "./feedback";
|
||||
export * from "./profit";
|
||||
|
||||
@@ -27,6 +27,7 @@ import { codexPoolFeedback } from "./feedback";
|
||||
import { renderCodexPoolPlan } from "./render";
|
||||
import { codexPoolRuntime } from "./runtime";
|
||||
import { codexPoolFaults } from "./faults";
|
||||
import { codexPoolProfit } from "./profit";
|
||||
import { defaultCodexPoolRuntimeTargetId } from "./runtime-target";
|
||||
import { codexPoolHelp } from "./types";
|
||||
|
||||
@@ -41,6 +42,7 @@ export async function runCodexPoolCommand(config: UniDeskConfig, args: string[])
|
||||
if (action === "validate") return await codexPoolValidate(config, parseDisclosureOptions(args.slice(1)));
|
||||
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 === "trace") return await codexPoolTrace(config, parseTraceOptions(args.slice(1)));
|
||||
if (action === "feedback") return await codexPoolFeedback(config, parseFeedbackOptions(args.slice(1)));
|
||||
if (action === "sentinel-image") return await codexPoolSentinelImage(config, parseSentinelImageOptions(args.slice(1)));
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { UniDeskConfig } from "../config";
|
||||
import type { RenderedCliResult } from "../output";
|
||||
|
||||
import { readCodexPoolConfig } from "./config";
|
||||
import { boolField, compactCapture, parseJsonOutput, runRemoteCodexPoolScript } from "./remote";
|
||||
import { profitScript } from "./remote-scripts";
|
||||
import { codexPoolRuntimeTarget, defaultCodexPoolRuntimeTargetId } from "./runtime-target";
|
||||
|
||||
interface ProfitOptions {
|
||||
since: string;
|
||||
targetId: string;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
export async function codexPoolProfit(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
|
||||
const pool = readCodexPoolConfig();
|
||||
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 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 response = {
|
||||
ok,
|
||||
action: "platform-infra-sub2api-codex-pool-profit",
|
||||
target: { id: target.id, route: target.route, runtimeMode: target.runtimeMode, endpoint: target.serviceDns },
|
||||
source: {
|
||||
kind: "sub2api-native-admin-usage",
|
||||
mutation: false,
|
||||
configPath: "config/platform-infra/sub2api-codex-pool.yaml#profit",
|
||||
},
|
||||
report,
|
||||
remote: compactCapture(remote, { full: !ok || report === null }),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
return options.json ? renderProfitJson(response) : renderProfit(response);
|
||||
}
|
||||
|
||||
function parseProfitOptions(args: string[], defaultWindow: string): ProfitOptions {
|
||||
let since = defaultWindow;
|
||||
let targetId = defaultCodexPoolRuntimeTargetId();
|
||||
let json = false;
|
||||
const readValue = (index: number, option: string): [string, number] => {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error(`${option} requires a value`);
|
||||
return [value.trim(), index + 1];
|
||||
};
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index]!;
|
||||
if (arg === "--json") json = true;
|
||||
else if (arg === "--since") {
|
||||
[since, index] = readValue(index, "--since");
|
||||
} else if (arg.startsWith("--since=")) since = arg.slice("--since=".length).trim();
|
||||
else if (arg === "--target") {
|
||||
[targetId, index] = readValue(index, "--target");
|
||||
} else if (arg.startsWith("--target=")) targetId = arg.slice("--target=".length).trim();
|
||||
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 (!targetId || /[\r\n\0]/u.test(targetId)) throw new Error("--target has an unsupported format");
|
||||
return { since, targetId, json };
|
||||
}
|
||||
|
||||
function renderProfitHelp(defaultWindow: string): RenderedCliResult {
|
||||
return {
|
||||
ok: true,
|
||||
command: "platform-infra sub2api codex-pool profit --help",
|
||||
renderedText: [
|
||||
"SUB2API CODEX-POOL PROFIT",
|
||||
"Usage:",
|
||||
" bun scripts/cli.ts platform-infra sub2api codex-pool profit [--since 24h] [--target PK01] [--json]",
|
||||
"Options:",
|
||||
` --since <duration> Native usage window; YAML default is ${defaultWindow}.`,
|
||||
" --target <id>",
|
||||
" --json Emit exact structured values.",
|
||||
"Notes:",
|
||||
" Default output is a Kubernetes-style account list plus both profit scopes.",
|
||||
" This command is read-only and does not change Sub2API runtime state.",
|
||||
].join("\n"),
|
||||
contentType: "text/plain",
|
||||
};
|
||||
}
|
||||
|
||||
function renderProfit(response: Record<string, unknown>): RenderedCliResult {
|
||||
const report = record(response.report);
|
||||
const window = record(report.window);
|
||||
const policy = record(report.policy);
|
||||
const summary = record(report.summary);
|
||||
const completeness = record(report.completeness);
|
||||
const lines = [
|
||||
`SUB2API PROFIT status=${text(report.status)} target=${text(record(response.target).id)} window=${text(window.since)}`,
|
||||
`WINDOW ${text(window.startAt)} -> ${text(window.endAt)} SOURCE native-admin-usage/${text(record(report.source).usageCostField)}`,
|
||||
`POLICY net-revenue=${text(policy.netRevenueCoefficient)} account-cost=${text(policy.accountCostSource)}`,
|
||||
"",
|
||||
"ACCOUNTS",
|
||||
];
|
||||
const accounts = arrayOfRecords(report.accounts);
|
||||
lines.push(table(
|
||||
["GROUP", "CLASS", "ACCOUNT", "ID", "REQ", "TOKENS", "API_AMOUNT", "COST_RATE", "REVENUE", "UPSTREAM_COST", "CONTRIBUTION", "STATUS"],
|
||||
accounts.map((row) => [
|
||||
text(row.groupName), text(row.class), text(row.accountName), text(row.accountId), text(row.requestCount), compactTokens(row.tokenCount),
|
||||
usd(row.apiAmountUsd), text(row.costCoefficient), usd(row.revenueUsd), usd(row.upstreamCostUsd), usd(row.profitContributionUsd), text(row.completeness),
|
||||
]),
|
||||
));
|
||||
lines.push("", "PROFIT");
|
||||
lines.push(table(
|
||||
["SCOPE", "STATUS", "REVENUE", "PROFIT_POOL_COST", "SELF_USE_COST", "PROFIT"],
|
||||
[
|
||||
["不扣自用", text(record(summary.withoutSelfUse).status), usd(summary.revenueUsd), usd(summary.profitPoolCostUsd), "-", usd(record(summary.withoutSelfUse).profitUsd)],
|
||||
["扣除自用", text(record(summary.withSelfUse).status), usd(summary.revenueUsd), usd(summary.profitPoolCostUsd), usd(summary.selfUseCostUsd), usd(record(summary.withSelfUse).profitUsd)],
|
||||
],
|
||||
));
|
||||
const missingCosts = arrayOfRecords(completeness.missingCostAccounts);
|
||||
const unclassified = arrayOfRecords(completeness.unclassifiedTraffic);
|
||||
const missingGroups = Array.isArray(completeness.configuredGroupsMissingAtRuntime) ? completeness.configuredGroupsMissingAtRuntime : [];
|
||||
if (missingCosts.length || unclassified.length || missingGroups.length) {
|
||||
lines.push("", "COMPLETENESS");
|
||||
for (const row of missingCosts) lines.push(`- missing-cost group=${text(row.groupName)} account=${text(row.accountName)} id=${text(row.accountId)}`);
|
||||
for (const row of unclassified) lines.push(`- unclassified-traffic group=${text(row.groupName)} account=${text(row.accountName)} requests=${text(row.requestCount)}`);
|
||||
for (const group of missingGroups) lines.push(`- configured-group-missing ${text(group)}`);
|
||||
}
|
||||
return {
|
||||
ok: response.ok === true,
|
||||
command: "platform-infra sub2api codex-pool profit",
|
||||
renderedText: lines.join("\n"),
|
||||
contentType: "text/plain",
|
||||
projection: response,
|
||||
};
|
||||
}
|
||||
|
||||
function renderProfitJson(response: Record<string, unknown>): RenderedCliResult {
|
||||
return {
|
||||
ok: response.ok === true,
|
||||
command: "platform-infra sub2api codex-pool profit --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 {
|
||||
if (value === null || value === undefined || value === "") return "-";
|
||||
return String(value).replace(/[\r\n\t]+/gu, " ");
|
||||
}
|
||||
|
||||
function compactTokens(value: unknown): string {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return text(value);
|
||||
if (Math.abs(numeric) >= 1_000_000_000) return `${(numeric / 1_000_000_000).toFixed(3)}B`;
|
||||
if (Math.abs(numeric) >= 1_000_000) return `${(numeric / 1_000_000).toFixed(3)}M`;
|
||||
if (Math.abs(numeric) >= 1_000) return `${(numeric / 1_000).toFixed(3)}K`;
|
||||
return String(numeric);
|
||||
}
|
||||
|
||||
function usd(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") return "-";
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? `$${numeric.toFixed(6)}` : text(value);
|
||||
}
|
||||
|
||||
function table(headers: string[], rows: string[][]): string {
|
||||
if (rows.length === 0) return `${headers.join(" ")}\n-`;
|
||||
const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => (row[index] ?? "").length)));
|
||||
return [headers, ...rows].map((row) => row.map((cell, index) => cell.padEnd(widths[index]!)).join(" ").trimEnd()).join("\n");
|
||||
}
|
||||
@@ -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", encodedPayload: string, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string {
|
||||
export function remotePythonCoreScript(mode: "sync" | "validate" | "trace" | "cleanup-probes" | "sentinel-probe" | "profit", encodedPayload: string, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string {
|
||||
const hostDockerEnvPath = target.runtimeMode === "host-docker" ? target.hostDockerEnvPath : null;
|
||||
return `
|
||||
set -u
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
export const remotePythonProfitScript = String.raw`
|
||||
|
||||
def profit_decimal(value):
|
||||
from decimal import Decimal, InvalidOperation
|
||||
if value is None or isinstance(value, bool):
|
||||
return Decimal("0")
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
except (InvalidOperation, ValueError):
|
||||
return Decimal("0")
|
||||
|
||||
def profit_decimal_text(value):
|
||||
text_value = format(value, "f")
|
||||
if "." in text_value:
|
||||
text_value = text_value.rstrip("0").rstrip(".")
|
||||
return text_value or "0"
|
||||
|
||||
def profit_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 profit_window_start(window, end_at):
|
||||
matched = re.fullmatch(r"([1-9][0-9]*)([smhd])", str(window or ""))
|
||||
if matched is None:
|
||||
raise RuntimeError("profit window must be a duration such as 24h")
|
||||
amount = int(matched.group(1))
|
||||
unit = matched.group(2)
|
||||
seconds = amount * {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit]
|
||||
return end_at - timedelta(seconds=seconds)
|
||||
|
||||
def profit_group_class(name, config):
|
||||
groups = config.get("groups") if isinstance(config.get("groups"), dict) else {}
|
||||
if name in (groups.get("profitable") or []):
|
||||
return "profit"
|
||||
if name in (groups.get("selfUse") or []):
|
||||
return "self-use"
|
||||
if name in (groups.get("excluded") or []):
|
||||
return "excluded"
|
||||
return "unclassified"
|
||||
|
||||
def profit_account_rate(name):
|
||||
matched = re.search(r"(?:^|\s)([0-9]+(?:\.[0-9]+)?)$", str(name or "").strip())
|
||||
return profit_decimal(matched.group(1)) if matched is not None else None
|
||||
|
||||
def profit_usage_rows(token, group_id, start_at, end_at):
|
||||
rows = []
|
||||
page = 1
|
||||
page_size = 100
|
||||
while True:
|
||||
query = (
|
||||
f"/api/v1/admin/usage?page={page}&page_size={page_size}&group_id={group_id}"
|
||||
f"&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", query, bearer=token), f"list usage for group {group_id}")
|
||||
batch = extract_items(data)
|
||||
reached_start = False
|
||||
for row in batch:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
created_at = profit_parse_time(row.get("created_at"))
|
||||
if created_at is not None and created_at < start_at:
|
||||
reached_start = True
|
||||
continue
|
||||
if created_at is not None and created_at <= end_at:
|
||||
rows.append(row)
|
||||
total = int(data.get("total", len(rows))) if isinstance(data, dict) else len(rows)
|
||||
if not batch or len(batch) < page_size or reached_start or page * page_size >= total:
|
||||
return rows, page
|
||||
page += 1
|
||||
|
||||
def profit_row_account_id(row):
|
||||
for key in ("account_id", "accountId"):
|
||||
if row.get(key) is not None:
|
||||
return row.get(key)
|
||||
account = row.get("account")
|
||||
return account.get("id") if isinstance(account, dict) else None
|
||||
|
||||
def profit_row_account_name(row):
|
||||
for key in ("account_name", "accountName"):
|
||||
if isinstance(row.get(key), str) and row.get(key):
|
||||
return row.get(key)
|
||||
account = row.get("account")
|
||||
return account.get("name") if isinstance(account, dict) else None
|
||||
|
||||
def profit_add_usage(target, row, cost_field):
|
||||
target["requestCount"] += 1
|
||||
target["inputTokens"] += int(row.get("input_tokens") or 0)
|
||||
target["outputTokens"] += int(row.get("output_tokens") or 0)
|
||||
target["apiAmount"] += profit_decimal(row.get(cost_field))
|
||||
|
||||
def profit_empty_account(group, group_class, account_id, account_name):
|
||||
return {
|
||||
"groupId": group.get("id"),
|
||||
"groupName": group.get("name"),
|
||||
"class": group_class,
|
||||
"accountId": account_id,
|
||||
"accountName": account_name,
|
||||
"requestCount": 0,
|
||||
"inputTokens": 0,
|
||||
"outputTokens": 0,
|
||||
"apiAmount": profit_decimal(0),
|
||||
}
|
||||
|
||||
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")
|
||||
cost_field = config.get("usageCostField")
|
||||
net_rate = profit_decimal(config.get("netRevenueCoefficient"))
|
||||
end_at = datetime.now(timezone.utc)
|
||||
start_at = profit_window_start(window, end_at)
|
||||
_, token, _ = login()
|
||||
groups = [item for item in list_groups(token) if isinstance(item, dict)]
|
||||
account_rows = []
|
||||
group_rows = []
|
||||
total_pages = 0
|
||||
configured_names = set()
|
||||
configured_groups = config.get("groups") if isinstance(config.get("groups"), dict) else {}
|
||||
for names in configured_groups.values():
|
||||
if isinstance(names, list):
|
||||
configured_names.update(str(name) for name in names)
|
||||
runtime_names = {str(group.get("name")) for group in groups}
|
||||
for group in sorted(groups, key=lambda item: int(item.get("id") or 0)):
|
||||
group_id = group.get("id")
|
||||
group_name = str(group.get("name") or "")
|
||||
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)
|
||||
group_amount = profit_decimal(0)
|
||||
group_tokens = 0
|
||||
group_requests = 0
|
||||
for aggregate in aggregates.values():
|
||||
rate = profit_account_rate(aggregate["accountName"])
|
||||
amount = aggregate.pop("apiAmount")
|
||||
revenue = amount * net_rate if group_class == "profit" else profit_decimal(0)
|
||||
upstream_cost = amount * rate if rate is not None and group_class in ("profit", "self-use") else profit_decimal(0)
|
||||
aggregate["tokenCount"] = aggregate.pop("inputTokens") + aggregate.pop("outputTokens")
|
||||
aggregate["apiAmountUsd"] = profit_decimal_text(amount)
|
||||
aggregate["costCoefficient"] = profit_decimal_text(rate) if rate is not None else None
|
||||
aggregate["revenueUsd"] = profit_decimal_text(revenue)
|
||||
aggregate["upstreamCostUsd"] = profit_decimal_text(upstream_cost) if rate is not None or group_class not in ("profit", "self-use") else None
|
||||
aggregate["profitContributionUsd"] = profit_decimal_text(revenue - upstream_cost) if rate is not None or group_class not in ("profit", "self-use") else None
|
||||
aggregate["completeness"] = "complete" if rate is not None or group_class not in ("profit", "self-use") else "partial"
|
||||
account_rows.append(aggregate)
|
||||
group_amount += amount
|
||||
group_tokens += aggregate["tokenCount"]
|
||||
group_requests += aggregate["requestCount"]
|
||||
group_rows.append({
|
||||
"groupId": group_id,
|
||||
"groupName": group_name,
|
||||
"class": group_class,
|
||||
"requestCount": group_requests,
|
||||
"tokenCount": group_tokens,
|
||||
"apiAmountUsd": profit_decimal_text(group_amount),
|
||||
})
|
||||
profit_accounts = [row for row in account_rows if row["class"] == "profit"]
|
||||
self_accounts = [row for row in account_rows if row["class"] == "self-use"]
|
||||
missing_profit = [row for row in profit_accounts if row["completeness"] != "complete"]
|
||||
missing_self = [row for row in self_accounts if row["completeness"] != "complete"]
|
||||
unclassified = [row for row in account_rows if row["class"] == "unclassified" and row["requestCount"] > 0]
|
||||
revenue = sum((profit_decimal(row["revenueUsd"]) for row in profit_accounts), profit_decimal(0))
|
||||
profit_cost = sum((profit_decimal(row["upstreamCostUsd"]) for row in profit_accounts if row["upstreamCostUsd"] is not None), profit_decimal(0))
|
||||
self_cost = sum((profit_decimal(row["upstreamCostUsd"]) for row in self_accounts if row["upstreamCostUsd"] is not None), profit_decimal(0))
|
||||
without_self = revenue - profit_cost
|
||||
with_self = without_self - self_cost
|
||||
without_complete = not missing_profit and not unclassified
|
||||
with_complete = without_complete and not missing_self
|
||||
account_rows.sort(key=lambda row: (row["class"], row["groupName"], -profit_decimal(row["apiAmountUsd"]), 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},
|
||||
"policy": config,
|
||||
"groups": group_rows,
|
||||
"accounts": account_rows,
|
||||
"summary": {
|
||||
"revenueUsd": profit_decimal_text(revenue),
|
||||
"profitPoolCostUsd": profit_decimal_text(profit_cost),
|
||||
"selfUseCostUsd": profit_decimal_text(self_cost),
|
||||
"withoutSelfUse": {"status": "complete" if without_complete else "partial", "profitUsd": profit_decimal_text(without_self) if without_complete else None},
|
||||
"withSelfUse": {"status": "complete" if with_complete else "partial", "profitUsd": profit_decimal_text(with_self) if with_complete else None},
|
||||
},
|
||||
"completeness": {
|
||||
"missingCostAccounts": [{"groupName": row["groupName"], "accountId": row["accountId"], "accountName": row["accountName"]} for row in missing_profit + missing_self],
|
||||
"unclassifiedTraffic": [{"groupName": row["groupName"], "accountId": row["accountId"], "accountName": row["accountName"], "requestCount": row["requestCount"]} for row in unclassified],
|
||||
"configuredGroupsMissingAtRuntime": sorted(configured_names - runtime_names),
|
||||
},
|
||||
}
|
||||
`;
|
||||
@@ -170,6 +170,8 @@ try:
|
||||
result = run_cleanup_probes()
|
||||
elif MODE == "sentinel-probe":
|
||||
result = run_sentinel_probe()
|
||||
elif MODE == "profit":
|
||||
result = run_profit_report()
|
||||
else:
|
||||
result = run_validate()
|
||||
except Exception as exc:
|
||||
|
||||
@@ -7,14 +7,16 @@ import { remotePythonValidationScript } from "./remote-python-validation";
|
||||
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";
|
||||
|
||||
export function remotePythonScript(mode: "sync" | "validate" | "trace" | "cleanup-probes" | "sentinel-probe", encodedPayload: string, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string {
|
||||
export function remotePythonScript(mode: "sync" | "validate" | "trace" | "cleanup-probes" | "sentinel-probe" | "profit", encodedPayload: string, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string {
|
||||
return [
|
||||
remotePythonCoreScript(mode, encodedPayload, pool, target),
|
||||
remotePythonSentinelScript,
|
||||
remotePythonValidationScript,
|
||||
remotePythonSyncValidateScript,
|
||||
remotePythonTraceScript,
|
||||
remotePythonProfitScript,
|
||||
remotePythonSentinelProbeScript,
|
||||
].join("");
|
||||
}
|
||||
|
||||
@@ -51,6 +51,11 @@ export function sentinelProbeScript(payload: unknown, pool: CodexPoolConfig, tar
|
||||
return remotePythonScript("sentinel-probe", encoded, pool, target);
|
||||
}
|
||||
|
||||
export function profitScript(payload: unknown, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string {
|
||||
const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
|
||||
return remotePythonScript("profit", 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" | "sentinel-probe" | "sentinel-image-status" | "sentinel-image-build";
|
||||
export type RemoteCodexPoolMode = "sync" | "validate" | "runtime" | "faults" | "profit" | "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);
|
||||
@@ -84,6 +84,7 @@ export async function runRemoteCodexPoolScript(config: UniDeskConfig, mode: Remo
|
||||
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 === "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";
|
||||
if (mode === "sentinel-image-build") return "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image build --confirm";
|
||||
|
||||
@@ -181,6 +181,18 @@ export interface CodexRuntimeConfig {
|
||||
templatesById: Record<string, CodexRuntimeTemplate>;
|
||||
}
|
||||
|
||||
export interface CodexPoolProfitConfig {
|
||||
defaultWindow: string;
|
||||
usageCostField: "actual_cost";
|
||||
accountCostSource: "account-name-trailing-coefficient";
|
||||
netRevenueCoefficient: number;
|
||||
groups: {
|
||||
profitable: string[];
|
||||
selfUse: string[];
|
||||
excluded: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface CodexPoolConfig {
|
||||
version: number;
|
||||
kind: string;
|
||||
@@ -206,6 +218,7 @@ export interface CodexPoolConfig {
|
||||
defaultSentinelProtect: CodexSentinelProtectPolicy;
|
||||
profiles: CodexPoolProfileConfig[];
|
||||
runtime: CodexRuntimeConfig;
|
||||
profit: CodexPoolProfitConfig;
|
||||
manualAccounts: CodexPoolManualAccountsConfig;
|
||||
publicExposure: CodexPoolPublicExposureConfig;
|
||||
localCodex: CodexPoolLocalCodexConfig;
|
||||
@@ -350,7 +363,7 @@ export function codexPoolHelp(): unknown {
|
||||
const pool = readCodexPoolConfig();
|
||||
const runtimeTarget = codexPoolRuntimeTarget();
|
||||
return {
|
||||
command: "platform-infra sub2api codex-pool plan|sync|validate|runtime|faults|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|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",
|
||||
@@ -361,6 +374,7 @@ 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] [--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 delete --account <name-or-id> --kind temp-unschedulable [--target PK01] [--confirm]",
|
||||
|
||||
@@ -424,6 +424,7 @@ export function platformInfraHelp(): unknown {
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool validate",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool trace --request-id <requestId>",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool feedback --user <email-or-id> [--window 30m] [--page-token <token>|--request-id <client-or-internal-id>] [--json]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool profit [--since 24h] [--target PK01] [--json]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-probe --account unidesk-codex-hy --confirm",
|
||||
"bun scripts/cli.ts platform-infra sub2rank plan [--target NC01]",
|
||||
@@ -507,6 +508,7 @@ export function platformInfraHelp(): unknown {
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool validate",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool trace --request-id <requestId>",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool feedback --user <email-or-id> [--window 30m] [--page-token <token>|--request-id <client-or-internal-id>] [--json]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool profit [--since 24h] [--target PK01] [--json]",
|
||||
"bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status",
|
||||
],
|
||||
module: "scripts/src/platform-infra-sub2api-codex.ts",
|
||||
|
||||
Reference in New Issue
Block a user