From 30dc651170ce864e52e65d0a6790816887d89de9 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 15:54:11 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20Sub2API=20?= =?UTF-8?q?=E5=88=A9=E6=B6=A6=E7=BB=9F=E8=AE=A1=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unidesk-sub2api/references/codex-pool.md | 9 + config/platform-infra/sub2api-codex-pool.yaml | 10 + .../platform-infra-sub2api-codex/config.ts | 48 +++- .../src/platform-infra-sub2api-codex/index.ts | 1 + .../platform-infra-sub2api-codex/options.ts | 2 + .../platform-infra-sub2api-codex/profit.ts | 174 +++++++++++++++ .../remote-python-core.ts | 2 +- .../remote-python-profit.ts | 208 ++++++++++++++++++ .../remote-python-sentinel-probe.ts | 2 + .../remote-python-sync.ts | 4 +- .../remote-scripts.ts | 5 + .../platform-infra-sub2api-codex/remote.ts | 3 +- .../src/platform-infra-sub2api-codex/types.ts | 16 +- scripts/src/platform-infra/entry.ts | 2 + 14 files changed, 481 insertions(+), 5 deletions(-) create mode 100644 scripts/src/platform-infra-sub2api-codex/profit.ts create mode 100644 scripts/src/platform-infra-sub2api-codex/remote-python-profit.ts diff --git a/.agents/skills/unidesk-sub2api/references/codex-pool.md b/.agents/skills/unidesk-sub2api/references/codex-pool.md index 2514dbc6..d4f96504 100644 --- a/.agents/skills/unidesk-sub2api/references/codex-pool.md +++ b/.agents/skills/unidesk-sub2api/references/codex-pool.md @@ -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 --kind temp-unschedulable bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --target PK01 --account --kind model-mapping --model --upstream-model bun scripts/cli.ts platform-infra sub2api codex-pool runtime delete --target PK01 --account --kind model-mapping --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 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 名称。 diff --git a/config/platform-infra/sub2api-codex-pool.yaml b/config/platform-infra/sub2api-codex-pool.yaml index d1c1f343..1ff2b71b 100644 --- a/config/platform-infra/sub2api-codex-pool.yaml +++ b/config/platform-infra/sub2api-codex-pool.yaml @@ -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. diff --git a/scripts/src/platform-infra-sub2api-codex/config.ts b/scripts/src/platform-infra-sub2api-codex/config.ts index eec363e3..e169d034 100644 --- a/scripts/src/platform-infra-sub2api-codex/config.ts +++ b/scripts/src/platform-infra-sub2api-codex/config.ts @@ -21,7 +21,7 @@ import { import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets"; import { runSshCommandCapture, type SshCaptureResult } from "../ssh"; -import type { CodexPoolCaddyEdgeRetryConfig, CodexPoolConfig, CodexPoolLocalCodexConfig, CodexPoolManualAccountGroupBinding, CodexPoolManualAccountProtection, CodexPoolManualAccountProxyBinding, CodexPoolManualAccountsConfig, CodexPoolManualBindingKind, CodexPoolManualBindingProvider, CodexPoolManualBindingSource, CodexPoolManualBindingSourcesConfig, CodexPoolProfileConfig, 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`); diff --git a/scripts/src/platform-infra-sub2api-codex/index.ts b/scripts/src/platform-infra-sub2api-codex/index.ts index 9ec51ffa..903b1c0f 100644 --- a/scripts/src/platform-infra-sub2api-codex/index.ts +++ b/scripts/src/platform-infra-sub2api-codex/index.ts @@ -14,3 +14,4 @@ export * from "./accounts"; export * from "./remote-python-sync"; export * from "./remote"; export * from "./feedback"; +export * from "./profit"; diff --git a/scripts/src/platform-infra-sub2api-codex/options.ts b/scripts/src/platform-infra-sub2api-codex/options.ts index 3b8a351e..fdb01884 100644 --- a/scripts/src/platform-infra-sub2api-codex/options.ts +++ b/scripts/src/platform-infra-sub2api-codex/options.ts @@ -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))); diff --git a/scripts/src/platform-infra-sub2api-codex/profit.ts b/scripts/src/platform-infra-sub2api-codex/profit.ts new file mode 100644 index 00000000..3ade35f8 --- /dev/null +++ b/scripts/src/platform-infra-sub2api-codex/profit.ts @@ -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 | 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 Native usage window; YAML default is ${defaultWindow}.`, + " --target ", + " --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): 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): 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 { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function arrayOfRecords(value: unknown): Record[] { + return Array.isArray(value) ? value.filter((item): item is Record => 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"); +} diff --git a/scripts/src/platform-infra-sub2api-codex/remote-python-core.ts b/scripts/src/platform-infra-sub2api-codex/remote-python-core.ts index fd7de0fa..b2134b0b 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-python-core.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-python-core.ts @@ -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 diff --git a/scripts/src/platform-infra-sub2api-codex/remote-python-profit.ts b/scripts/src/platform-infra-sub2api-codex/remote-python-profit.ts new file mode 100644 index 00000000..87e6d1a9 --- /dev/null +++ b/scripts/src/platform-infra-sub2api-codex/remote-python-profit.ts @@ -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), + }, + } +`; diff --git a/scripts/src/platform-infra-sub2api-codex/remote-python-sentinel-probe.ts b/scripts/src/platform-infra-sub2api-codex/remote-python-sentinel-probe.ts index a5428ab8..a04a1d60 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-python-sentinel-probe.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-python-sentinel-probe.ts @@ -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: diff --git a/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts b/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts index 5f8766ec..dca12034 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts @@ -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(""); } diff --git a/scripts/src/platform-infra-sub2api-codex/remote-scripts.ts b/scripts/src/platform-infra-sub2api-codex/remote-scripts.ts index 68e3630c..d47c39c8 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-scripts.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-scripts.ts @@ -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; diff --git a/scripts/src/platform-infra-sub2api-codex/remote.ts b/scripts/src/platform-infra-sub2api-codex/remote.ts index 3f2d525a..90507e26 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote.ts @@ -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 { 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 --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"; diff --git a/scripts/src/platform-infra-sub2api-codex/types.ts b/scripts/src/platform-infra-sub2api-codex/types.ts index 687bf4fa..f027e3c4 100644 --- a/scripts/src/platform-infra-sub2api-codex/types.ts +++ b/scripts/src/platform-infra-sub2api-codex/types.ts @@ -181,6 +181,18 @@ export interface CodexRuntimeConfig { templatesById: Record; } +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 [--target PK01] [--json|--full|--raw]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime errors [--group |--all-groups] [--platform ] [--page-token ] [--account ] [--since 24h] [--tail 50000] [--target PK01] [--json|--full|--raw]", "bun scripts/cli.ts platform-infra sub2api codex-pool faults [--level P0|P1|P2] [--group ] [--account ] [--model ] [--stream sync|stream] [--endpoint ] [--request-id ] [--page-token ] [--target PK01] [--json]", + "bun scripts/cli.ts platform-infra sub2api codex-pool profit [--since 24h] [--target PK01] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --account --template [--target PK01] [--confirm]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --accounts --template [--target PK01] [--confirm] [--full|--raw]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime delete --account --kind temp-unschedulable [--target PK01] [--confirm]", diff --git a/scripts/src/platform-infra/entry.ts b/scripts/src/platform-infra/entry.ts index be0979fe..d8f39c4a 100644 --- a/scripts/src/platform-infra/entry.ts +++ b/scripts/src/platform-infra/entry.ts @@ -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 ", "bun scripts/cli.ts platform-infra sub2api codex-pool feedback --user [--window 30m] [--page-token |--request-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 ", "bun scripts/cli.ts platform-infra sub2api codex-pool feedback --user [--window 30m] [--page-token |--request-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",