diff --git a/.agents/skills/unidesk-sub2api/references/codex-pool.md b/.agents/skills/unidesk-sub2api/references/codex-pool.md index eb09b7b0..5074acee 100644 --- a/.agents/skills/unidesk-sub2api/references/codex-pool.md +++ b/.agents/skills/unidesk-sub2api/references/codex-pool.md @@ -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 --template bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --target PK01 --accounts --template --confirm bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --target PK01 --accounts --kind priority --priority <0-1000> --confirm +bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --target PK01 --account --kind account-name --name --confirm 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 @@ -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 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 @@ -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 ` 从原生 usage 选取窗口内有请求的非管理员活跃用户;`--users ` 使用精确 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 `: + - 只接受单个精确账号 selector,先 dry-run,确认后自动回读名称; + - 只更新名称,不携带 credentials、priority、分组、容量、代理、状态或可调度字段。 - 模型映射先用 `runtime models --account ...` 获取原生配置与实时上游能力: - 只有实时模型列表或真实模型请求证明支持时才确认写入。 - 禁止把名称相近的模型猜测为别名。 diff --git a/.agents/skills/unidesk-sub2api/references/guardrails.md b/.agents/skills/unidesk-sub2api/references/guardrails.md index a3a620f7..81b855fb 100644 --- a/.agents/skills/unidesk-sub2api/references/guardrails.md +++ b/.agents/skills/unidesk-sub2api/references/guardrails.md @@ -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 代替上游实现。 diff --git a/config/platform-db/postgres-pk01.yaml b/config/platform-db/postgres-pk01.yaml index 8795f1c6..276cf0bd 100644 --- a/config/platform-db/postgres-pk01.yaml +++ b/config/platform-db/postgres-pk01.yaml @@ -10,6 +10,7 @@ metadata: - 281 - 297 - 300 + - 2078 cluster: role: primary @@ -374,6 +375,16 @@ postgres: user: pikaoa address: 152.53.229.148/32 method: scram-sha-256 + - type: hostssl + database: pikaoa_test + user: pikaoa_test + address: 10.0.8.0/22 + method: scram-sha-256 + - type: hostssl + database: pikaoa_test + user: pikaoa_test + address: 152.53.229.148/32 + method: scram-sha-256 secrets: source: master-local @@ -491,6 +502,20 @@ secrets: PIKAOA_DB_NAME: pikaoa randomHex: PIKAOA_DB_PASSWORD: 32 + - name: pikaoa-test-db-credentials + sourceRef: platform-db/pikaoa-test-db.env + type: env + requiredKeys: + - PIKAOA_TEST_DB_USER + - PIKAOA_TEST_DB_PASSWORD + - PIKAOA_TEST_DB_NAME + createIfMissing: + enabled: true + values: + PIKAOA_TEST_DB_USER: pikaoa_test + PIKAOA_TEST_DB_NAME: pikaoa_test + randomHex: + PIKAOA_TEST_DB_PASSWORD: 32 objects: roles: - name: sub2api @@ -565,6 +590,15 @@ objects: createdb: false createrole: false superuser: false + - name: pikaoa_test + passwordRef: + sourceRef: platform-db/pikaoa-test-db.env + key: PIKAOA_TEST_DB_PASSWORD + login: true + attributes: + createdb: false + createrole: false + superuser: false databases: - name: sub2api owner: sub2api @@ -606,6 +640,11 @@ objects: encoding: UTF8 locale: C.UTF-8 extensions: [] + - name: pikaoa_test + owner: pikaoa_test + encoding: UTF8 + locale: C.UTF-8 + extensions: [] exports: connectionStrings: @@ -764,6 +803,22 @@ exports: - scope: pikaoa secret: pikaoa-runtime key: DATABASE_URL + - name: pikaoa-test-database-url + sourceSecretRef: platform-db/pikaoa-test-db.env + render: + envKey: DATABASE_URL + format: postgresql://$(PIKAOA_TEST_DB_USER):$(PIKAOA_TEST_DB_PASSWORD)@$(PGHOST):$(PGPORT)/$(PIKAOA_TEST_DB_NAME)?sslmode=require + variables: + PGHOST: 82.156.23.220 + PGPORT: "5432" + writeToSecretSource: + sourceRef: platform-infra/pikaoa-test.env + key: DATABASE_URL + mode: update-or-insert + consumers: + - scope: pikaoa-test + secret: pikaoa-test-runtime + key: DATABASE_URL backup: phase: minimum-restoreable @@ -812,6 +867,9 @@ observability: - kind: psql-app-role database: pikaoa user: pikaoa + - kind: psql-app-role + database: pikaoa_test + user: pikaoa_test - kind: disk-free path: /var/lib/postgresql/16/main minFreeGiB: 10 diff --git a/config/platform-infra/sub2api-codex-pool.yaml b/config/platform-infra/sub2api-codex-pool.yaml index bde5b7e8..b66e3468 100644 --- a/config/platform-infra/sub2api-codex-pool.yaml +++ b/config/platform-infra/sub2api-codex-pool.yaml @@ -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: [自用] diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.4_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.4_Task_Report.md new file mode 100644 index 00000000..725722cf --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.4_Task_Report.md @@ -0,0 +1,36 @@ +# R1.5.9.4 任务报告 + +## 任务结果 + +- 在 `config/platform-db/postgres-pk01.yaml` 声明独立 `pikaoa_test` role、database、凭据 `sourceRef`、连接串 export、NC01 来源 HBA 和有界状态检查。 +- 测试凭据来源为 `platform-db/pikaoa-test-db.env`,连接串写入 `platform-infra/pikaoa-test.env`,consumer 为 `pikaoa-test-runtime/DATABASE_URL`。 +- 保持既有生产 `pikaoa` role、database、Secret export 和 consumer 声明不变。 +- 收紧 `platform-db postgres plan` 的 role、database 和 Secret 默认摘要,确保新增对象后仍保持有界可见。 + +## 交付证据 + +- 执行记录:[pikasTech/unidesk#2078](https://github.com/pikasTech/unidesk/issues/2078)。 +- 提交:[d281b560](https://github.com/pikasTech/unidesk/commit/d281b560746b0cd61b018a5d628490685b62d210)。 +- PR:[pikasTech/unidesk#2083](https://github.com/pikasTech/unidesk/pull/2083)。 + +## 验证摘要 + +- `bun --check scripts/src/platform-db.ts`:通过。 +- `git diff --check`:通过。 +- `bun scripts/cli.ts platform-db postgres plan --config config/platform-db/postgres-pk01.yaml`:显示 `pikaoa_test` role、database、Secret sourceRef 和连接串 export;输出包含 `mutation=false`、`valuesPrinted=false`。 +- `bun scripts/cli.ts platform-db postgres status --config config/platform-db/postgres-pk01.yaml`:对尚未落地的 `pikaoa_test` role、database 和 export 返回有界 actionable 摘要;未执行 mutation。 +- `bun scripts/cli.ts platform-db postgres export-secrets --config config/platform-db/postgres-pk01.yaml --dry-run`:显示独立测试凭据与连接串 export 的 create 计划;输出包含 `mutation=false`、`valuesPrinted=false`。 +- PR preflight:`MERGEABLE/CLEAN`,无 pending 或失败检查。 + +## 执行边界 + +- 未执行 live `platform-db postgres apply`。 +- 未写入任何 Secret source 文件。 +- 未触碰 NC01 k3s 运行对象。 +- 未扩展 PikaOA 产品源码或其他配置范围。 + +## 已知风险 + +- 当前 PK01 状态同时显示既有生产 `pikaoa` role、database 和 export 尚未落地。 +- owning YAML 的 live `apply` 是 shared apply;主代理后续执行时,production `pikaoa` 与新增 `pikaoa_test` 将被同批收敛。 +- 该 shared apply 风险不由本任务提前消除或绕过;执行 live apply 前必须由主代理明确确认影响范围,并继续保持 Secret 脱敏和非阻塞漂移 warning 边界。 diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.6_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.6_Task_Report.md new file mode 100644 index 00000000..84327f54 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.6_Task_Report.md @@ -0,0 +1,15 @@ +# R1.5.9.6 任务报告 + +## 结果 + +- 产品仓库新增 API、Worker、Web 三个职责独立的多阶段 OCI 构建输入。 +- API 镜像同时包含服务二进制和迁移 CLI;API、Worker 使用 nonroot 运行。 +- Web 使用 nginx-unprivileged,运行时由 `PIKAOA_API_UPSTREAM` 注入同源 API 上游,保留 `/healthz` 和 SPA fallback。 +- PR:pikainc/pikaoa#15;合并提交:`0184d200db3f1e5fb1ce6aec1a10cf7d3843849d`。 + +## 验证 + +- NC01 从干净提交构建 API、Worker、Web 三个镜像成功。 +- `go test ./...` 通过。 +- Web 测试与构建通过,代理、health、SPA 深链 smoke 通过。 +- 构建验证发现既有事件版本一致性校验阻塞 API 启动,已登记 pikainc/pikaoa#16 并作为独立最小修复处理。 diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index 71698319..9d6945d3 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -102,15 +102,18 @@ ##### R1.5.9.3 [completed] 按用户确认方案将测试架构定锚为现有 NC01 k3s 的 pikaoa-test namespace、PK01 host PostgreSQL 独立测试库与角色、YAML hostIP 端口,并更新 SPEC 与 [pikasTech/unidesk#2058](https://github.com/pikasTech/unidesk/issues/2058),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.9.3_Task_Report.md)。 -##### R1.5.9.4 [in_progress] +##### R1.5.9.4 [completed] 在 PK01 host PostgreSQL 声明独立 pikaoa_test database/role、Secret export 与 NC01 HBA,执行记录见 [pikasTech/unidesk#2078](https://github.com/pikasTech/unidesk/issues/2078),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.9.4_Task_Report.md)。 ##### R1.5.9.5 [in_progress] 在 NC01 pikaoa-test namespace 实现外部 PK01 测试 DSN、API/Worker/Web/迁移/附件 PVC、OTel/Prometheus、YAML hostIP 端口和受控单步 CLI,执行记录见 [pikasTech/unidesk#2079](https://github.com/pikasTech/unidesk/issues/2079),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.9.5_Task_Report.md)。 -##### R1.5.9.6 [in_progress] +##### R1.5.9.6 [completed] 为产品仓库补齐 API、Worker、Web 的 commit-pinned OCI 构建输入,使 NC01 测试运行面和后续 PaC 可构建镜像,执行记录见 [pikainc/pikaoa#14](https://github.com/pikainc/pikaoa/issues/14),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.9.6_Task_Report.md)。 +##### R1.5.9.7 [in_progress] + +降级事件版本一致性校验并解除 MVP 启动阻塞(pikainc/pikaoa#16),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.9.7_Task_Report.md)。 ### R1.6 审核并合并双仓 PR,执行首次受控 PaC bootstrap,由正常 source PR merge 自动发布,依赖 R1.3-R1.5,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6_Task_Report.md)。 diff --git a/scripts/src/platform-db.ts b/scripts/src/platform-db.ts index c5da285a..377618f7 100644 --- a/scripts/src/platform-db.ts +++ b/scripts/src/platform-db.ts @@ -1113,27 +1113,28 @@ function compactPlan( transport: pg.postgres.network.transport, sslmode: pg.postgres.network.sslmode, }, - roles: pg.objects.roles.map((role) => ({ + roles: compactActionableSummary(pg.objects.roles.map((role) => ({ name: role.name, exists: observedRoles.find((item) => item.name === role.name)?.exists ?? null, action: observedRoles.some((item) => item.name === role.name && item.exists) ? "none" : "create-or-update", - })), - databases: pg.objects.databases.map((database) => ({ + })), { + passed: (item) => item.action === "none", + missing: (item) => item.exists !== true, + actionable: (item) => item.action !== "none", + project: (item) => item, + }), + databases: compactActionableSummary(pg.objects.databases.map((database) => ({ name: database.name, owner: database.owner, exists: observedDatabases.find((item) => item.name === database.name)?.exists ?? null, action: observedDatabases.some((item) => item.name === database.name && item.exists) ? "none" : "create", - })), - secrets: { - ok: secrets.ok, - root: secrets.root, - entries: secrets.entries.map((entry) => ({ - sourceRef: entry.sourceRef, - missingKeys: entry.missingKeys, - action: entry.action, - })), - valuesPrinted: false, - }, + })), { + passed: (item) => item.action === "none", + missing: (item) => item.exists !== true, + actionable: (item) => item.action !== "none", + project: (item) => item, + }), + secrets: compactSecretInspection(secrets), exports: pg.exports.connectionStrings.map((item) => ({ name: item.name, sourceRef: item.sourceSecretRef, diff --git a/scripts/src/platform-infra-sub2api-codex/config.ts b/scripts/src/platform-infra-sub2api-codex/config.ts index c314f0a0..69633e59 100644 --- a/scripts/src/platform-infra-sub2api-codex/config.ts +++ b/scripts/src/platform-infra-sub2api-codex/config.ts @@ -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(); + 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 }, }; } diff --git a/scripts/src/platform-infra-sub2api-codex/credits.ts b/scripts/src/platform-infra-sub2api-codex/credits.ts new file mode 100644 index 00000000..30a61635 --- /dev/null +++ b/scripts/src/platform-infra-sub2api-codex/credits.ts @@ -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 | 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 ) --amount-usd --reason [--confirm] [--target ] [--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 [--confirm]", + " bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --users --amount-usd 20 --reason [--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): 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(",") : ""; + 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): 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 { 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 { 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); } diff --git a/scripts/src/platform-infra-sub2api-codex/index.ts b/scripts/src/platform-infra-sub2api-codex/index.ts index c57d2b5d..13dd55f3 100644 --- a/scripts/src/platform-infra-sub2api-codex/index.ts +++ b/scripts/src/platform-infra-sub2api-codex/index.ts @@ -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"; diff --git a/scripts/src/platform-infra-sub2api-codex/options.ts b/scripts/src/platform-infra-sub2api-codex/options.ts index b7a0df1f..99cc4904 100644 --- a/scripts/src/platform-infra-sub2api-codex/options.ts +++ b/scripts/src/platform-infra-sub2api-codex/options.ts @@ -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))); diff --git a/scripts/src/platform-infra-sub2api-codex/profit.ts b/scripts/src/platform-infra-sub2api-codex/profit.ts index 10ad3a89..28ccbd8b 100644 --- a/scripts/src/platform-infra-sub2api-codex/profit.ts +++ b/scripts/src/platform-infra-sub2api-codex/profit.ts @@ -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 ]|--missing-costs] [--json]", + " bun scripts/cli.ts platform-infra sub2api codex-pool profit [--since 24h|--month YYYY-MM [--forecast]] [--target PK01] [--accounts [--page-token ]|--missing-costs] [--json]", "Options:", ` --since Native usage window; YAML default is ${defaultWindow}.`, + " --month 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 ", " --accounts Include one fixed-size account page with complete names and IDs.", " --page-token Continue from the opaque token returned by the previous account page.", @@ -125,19 +139,40 @@ function renderProfit(response: Record): 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)}`); 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 b2134b0b..7dbdd257 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" | "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) diff --git a/scripts/src/platform-infra-sub2api-codex/remote-python-credits.ts b/scripts/src/platform-infra-sub2api-codex/remote-python-credits.ts new file mode 100644 index 00000000..b6dc0066 --- /dev/null +++ b/scripts/src/platform-infra-sub2api-codex/remote-python-credits.ts @@ -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, + } +`; diff --git a/scripts/src/platform-infra-sub2api-codex/remote-python-profit.ts b/scripts/src/platform-infra-sub2api-codex/remote-python-profit.ts index 6f2762d8..9f0a1587 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-python-profit.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-python-profit.ts @@ -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], 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 a04a1d60..6cc3907c 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 @@ -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: 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 dca12034..4c64ad7d 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts @@ -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(""); } diff --git a/scripts/src/platform-infra-sub2api-codex/remote-scripts.ts b/scripts/src/platform-infra-sub2api-codex/remote-scripts.ts index d47c39c8..a455ddf2 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-scripts.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-scripts.ts @@ -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; diff --git a/scripts/src/platform-infra-sub2api-codex/remote.ts b/scripts/src/platform-infra-sub2api-codex/remote.ts index a6e0aca3..fabf6cbf 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" | "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 { 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 "; 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 --confirm"; if (mode === "sentinel-image-status") return "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status"; diff --git a/scripts/src/platform-infra-sub2api-codex/runtime-mutation-render.ts b/scripts/src/platform-infra-sub2api-codex/runtime-mutation-render.ts index 7f35cafd..164993fa 100644 --- a/scripts/src/platform-infra-sub2api-codex/runtime-mutation-render.ts +++ b/scripts/src/platform-infra-sub2api-codex/runtime-mutation-render.ts @@ -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 }) => { diff --git a/scripts/src/platform-infra-sub2api-codex/runtime-options.ts b/scripts/src/platform-infra-sub2api-codex/runtime-options.ts index 72178073..d6b2df15 100644 --- a/scripts/src/platform-infra-sub2api-codex/runtime-options.ts +++ b/scripts/src/platform-infra-sub2api-codex/runtime-options.ts @@ -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 |--accounts ] [--group |--all-groups] [--platform ] [--page-token ] [--since 24h] [--tail 50000] [--template ] [--kind temp-unschedulable|priority] [--priority <0-1000>] [--confirm] [--target ] [--json|--full|--raw]"); + throw new Error("runtime usage: list|get|errors|infrastructure|apply|delete [--account |--accounts ] [--group |--all-groups] [--platform ] [--page-token ] [--since 24h] [--tail 50000] [--template ] [--kind temp-unschedulable|priority|account-name] [--priority <0-1000>|--name ] [--confirm] [--target ] [--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 m, h, or 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; } diff --git a/scripts/src/platform-infra-sub2api-codex/runtime-remote-script-tail.ts b/scripts/src/platform-infra-sub2api-codex/runtime-remote-script-tail.ts index 7f7b6674..94cb3f39 100644 --- a/scripts/src/platform-infra-sub2api-codex/runtime-remote-script-tail.ts +++ b/scripts/src/platform-infra-sub2api-codex/runtime-remote-script-tail.ts @@ -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) diff --git a/scripts/src/platform-infra-sub2api-codex/runtime.ts b/scripts/src/platform-infra-sub2api-codex/runtime.ts index b04e4d96..10676f32 100644 --- a/scripts/src/platform-infra-sub2api-codex/runtime.ts +++ b/scripts/src/platform-infra-sub2api-codex/runtime.ts @@ -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, diff --git a/scripts/src/platform-infra-sub2api-codex/types.ts b/scripts/src/platform-infra-sub2api-codex/types.ts index c8ce134b..f650c342 100644 --- a/scripts/src/platform-infra-sub2api-codex/types.ts +++ b/scripts/src/platform-infra-sub2api-codex/types.ts @@ -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 [--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] [--accounts [--page-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 ]|--missing-costs] [--json]", + "bun scripts/cli.ts platform-infra sub2api codex-pool credits grant (--active-since 24h|--users ) --amount-usd --reason [--target PK01] [--confirm] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool error-passthrough list|apply|delete [--template ] [--target PK01] [--confirm] [--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 apply --accounts --kind priority --priority <0-1000> [--target PK01] [--confirm]", + "bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --account --kind account-name --name [--target PK01] [--confirm]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime delete --account --kind temp-unschedulable [--target PK01] [--confirm]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime delete --accounts --kind temp-unschedulable [--target PK01] [--confirm] [--full|--raw]", "bun scripts/cli.ts platform-infra sub2api codex-pool trace [--target D601] --request-id [--since 24h|--tail 20000|--context-seconds 300|--show-lines|--raw]",