套餐余量 SKILL
本文用于约束 OpenClaw 自动创建「ClawPilot 套餐余量查询 Skill」时的实现方式。
目标只有一个:创建一个只读 Skill,复用当前 openclaw.json 中 models.providers.clawpilot-ai.apiKey 的值,请求 ClawPilot 套餐余量接口,并把结果稳定地展示给用户。
如果你正在自动创建这个 Skill,请按本文执行,不要自行改成别的接口或额外登录链路。
一、这个 Skill 必须完成什么
- 查询当前
clawpilot-aiprovider 对应账号的套餐余量、今日已用额度、今日剩余额度、请求数与 token 用量。 - 复用当前 OpenClaw 已配置的
clawpilot-aiprovider API key。 - 不要求用户额外登录 App,也不要要求用户再粘贴一份 API key。
- 不修改
openclaw.json、不写入新 secret、不过度缓存旧结果。 - 当用户表达下面这些意图时,应优先使用这个 Skill:
- 查询套餐余量
- 查询套餐用量
- 查询今日还剩多少额度
- 查询今天用了多少额度
- 查询套餐是否还有效
二、凭证来源规则
1. 只使用当前 clawpilot-ai provider 的 key
必须直接读取本地配置文件并解析:
bash
models.providers.clawpilot-ai.apiKey不要使用下面这条命令来读取:
bash
openclaw config get models.providers.clawpilot-ai.apiKey原因:
- 这条命令返回的值会被脱敏。
- 脱敏后的值不能拿来请求套餐余量接口。
- 因此,创建 Skill 时必须直接读取本地配置文件,再解析
models.providers.clawpilot-ai.apiKey的原始值。
推荐查找顺序:
- 如果存在
OPENCLAW_CONFIG_PATH,优先读取该路径指向的配置文件。 - 否则,如果存在
OPENCLAW_STATE_DIR,读取OPENCLAW_STATE_DIR/openclaw.json。 - 否则,读取默认路径
~/.openclaw/openclaw.json。
跨平台与部署约束:
- 不要假设用户的 OpenClaw 一定运行在 macOS 或 Linux;也可能运行在 Windows、WSL 或 Docker 容器里。
- 因此不要硬编码
/Users/...、/home/...、C:\\Users\\...这类路径。 - 读取配置时,优先依赖
OPENCLAW_CONFIG_PATH或OPENCLAW_STATE_DIR;只有它们都不存在时,才回退到运行时用户目录下的默认配置文件。 - 如果 OpenClaw 运行在 Docker 中,应读取容器内运行时可见的配置文件路径,而不是宿主机路径。
- Skill 的实现应以“当前 OpenClaw 进程所在运行环境”作为文件系统基准,而不是以用户口头描述的宿主机操作系统作为基准。
处理规则:
- 读取配置文件原始内容,并解析
models.providers.clawpilot-ai.apiKey。 - 对解析出的结果做
trim()。 - 如果为空,直接返回“当前 OpenClaw 未配置 clawpilot-ai provider API key”,不要继续请求接口。
- 如果值形如
${ENV_NAME},取process.env.ENV_NAME的实际值。 - 如果值本身像环境变量名(例如
CLAWPILOT_AI_API_KEY)且对应环境变量存在,优先取环境变量值。 - 其他情况按字面量 API key 使用。
额外约束:
- 不要把这个 key 复制到
skills.entries.*.apiKey。 - 不要在回复中输出完整 key。
- 不要把 key 写入日志、Markdown 或错误详情中。
- 如果配置文件读取失败,应明确告诉用户是“本地配置读取失败”,而不是伪装成接口鉴权失败。
2. 推荐超时
- HTTP 请求超时建议使用
12秒。 - 如果超时,直接告诉用户“查询超时,请稍后重试”,不要伪造旧结果。
三、接口契约
1. 请求
- Method:
GET - URL:
https://api.clawpilot.me/api/v1/ai-api/me/summary - Headers:
Authorization: Bearer <clawpilot-ai provider apiKey>Accept: application/json
- Query:无
- Body:无
2. 成功响应
成功时返回 200,结构如下:
json
{
"account": {
"id": 123,
"email": "[email protected]",
"status": "active",
"created_at": "2026-03-01T09:00:00.000Z",
"updated_at": "2026-03-14T06:21:00.000Z",
"last_login_at": "2026-03-14T05:10:00.000Z"
},
"summary": {
"has_any_entitlement": true,
"has_active_entitlement": true,
"current_total_daily_allowance_usd_micros": 100000000,
"today_used_amount_usd_micros": 12345678,
"current_daily_remaining_usd_micros": 87654322,
"today_used_input_tokens": 123456,
"today_used_output_tokens": 78901,
"today_used_total_tokens": 202357,
"today_request_count": 42,
"last_request_at": "2026-03-14T06:20:15.000Z",
"last_entitlement_ends_at": "2026-04-10T16:00:00.000Z",
"quota_date": "2026-03-14",
"quota_reset_timezone": "Asia/Shanghai"
},
"server_time": "2026-03-14T06:21:02.000Z"
}兼容性要求:
- 可以忽略未知字段。
- 不要依赖未在上面列出的字段。
3. 字段解释
| 字段 | 含义 | 展示建议 |
|---|---|---|
has_any_entitlement | 是否有过套餐记录 | 用于区分“从未开通过”和“当前暂无生效套餐” |
has_active_entitlement | 当前是否有生效套餐 | 作为主状态判断 |
current_total_daily_allowance_usd_micros | 当前每日总额度(美元 micros) | 除以 1_000_000 后显示 |
today_used_amount_usd_micros | 今日已用额度(美元 micros) | 除以 1_000_000 后显示 |
current_daily_remaining_usd_micros | 当前今日剩余额度(美元 micros) | 除以 1_000_000 后显示 |
today_used_input_tokens | 今日输入 token | 展示时建议使用紧凑单位,如 1.2K、3.4M |
today_used_output_tokens | 今日输出 token | 展示时建议使用紧凑单位,如 1.2K、3.4M |
today_used_total_tokens | 今日总 token | 展示时建议使用紧凑单位,如 1.2K、3.4M |
today_request_count | 今日请求数 | 直接显示整数 |
last_request_at | 最近一次请求时间 | 若为空则显示“暂无”;若展示则转换为用户本地时区的绝对时间 |
last_entitlement_ends_at | 当前或最近套餐的最晚结束时间 | 若为空则显示“暂无”;若展示则转换为用户本地时区的绝对时间 |
quota_date | 当前统计口径所属日期 | 与时区一起显示 |
quota_reset_timezone | 每日额度重置时区 | 必须保留原值展示 |
server_time | 接口返回时间 | 建议作为“查询时间”显示,并转换为用户本地时区的绝对时间 |
4. 金额换算规则
所有 *_usd_micros 字段都按下面公式换算:
text
usd = micros / 1_000_000展示规则:
- 大于等于
0.01美元时,默认保留最多2位小数。 - 大于
0且小于0.01美元时,保留最多4位小数。 - 去掉无意义的末尾
0。 - 输出前缀统一使用
$。
5. Token 展示规则
today_used_input_tokens、today_used_output_tokens、today_used_total_tokens这三个字段,展示时应优先使用紧凑单位。- 推荐单位:
K= 千M= 百万B= 十亿
- 推荐格式:
950显示为9501_200显示为1.2K15_300显示为15.3K2_450_000显示为2.45M
- 默认保留最多
2位小数,并去掉无意义的末尾0。 today_request_count不需要使用K/M,保持普通整数显示即可。
6. 时间展示规则
server_time、last_request_at、last_entitlement_ends_at这三个字段,展示时都要转换为用户本地时区。- 展示时使用绝对时间,不要只说“今天”“刚刚”“昨天”。
- 如果可以获取用户本地时区名称,建议同时显示时区缩写或时区名。
quota_reset_timezone是套餐每日额度的重置时区,必须保留原值,不要替换成用户本地时区。- 如果某个时间字段为空,明确显示“暂无”。
四、失败响应与处理方式
非 2xx 时,接口会返回统一错误结构:
json
{
"code": 401,
"status": 401,
"error_code": "UNAUTHORIZED",
"message": "AI API Key 无效",
"request_id": "req_xxx"
}处理规则:
| HTTP 状态 | 处理方式 |
|---|---|
401 | 告诉用户当前 clawpilot-ai provider API key 无效、缺失或不可用于查询套餐余量;附带 request_id(如果有) |
403 | 告诉用户当前 AI API 账号不可用;附带 request_id(如果有) |
429 | 告诉用户请求过于频繁,请稍后再试;若响应里有 retry_after_seconds,可以一起展示 |
5xx | 告诉用户服务端暂时异常,请稍后重试;附带 request_id(如果有) |
额外约束:
- 失败时不要伪造套餐信息。
- 失败时不要继续调用其他 ClawPilot 私有接口做猜测。
- 失败时可以建议用户检查
clawpilot-aiprovider 配置是否仍有效。
五、给用户的展示格式约束
这个 Skill 返回成功后,推荐按下面顺序给用户展示,保持稳定、简洁、可读:
text
ClawPilot 套餐用量
- 查询时间:<server_time 转换为用户本地时区后的绝对时间>
- 账号:<account.email>
- 当前状态:<状态文案>
- 当前每日额度:<$ 金额>
- 今日已用:<$ 金额>
- 当前剩余:<$ 金额>
- 今日请求数:<整数>
- 今日 Token:输入 <紧凑单位> / 输出 <紧凑单位> / 合计 <紧凑单位>
- 最近一次请求:<last_request_at 转换为用户本地时区后的绝对时间,或 暂无>
- 当前套餐最晚到期:<last_entitlement_ends_at 转换为用户本地时区后的绝对时间,或 暂无>
- 统计口径:<quota_date>(<quota_reset_timezone>)状态文案约束:
has_active_entitlement === true显示:套餐生效中has_active_entitlement === false && has_any_entitlement === true显示:暂无生效套餐has_active_entitlement === false && has_any_entitlement === false显示:未检测到套餐
展示时必须遵守:
- 对
server_time、last_request_at、last_entitlement_ends_at使用用户本地时区的绝对时间,不要只说“今天”“刚刚”“昨天”。 - 不要猜测套餐名称,因为接口没有返回套餐名称。
- 不要猜测剩余天数,因为接口没有直接返回剩余天数。
- 不要输出原始 micros 数值,除非用户明确要求查看原始字段。
- 若某些时间字段为空,明确显示“暂无”。
六、推荐创建的 Skill 目录
推荐目录:
bash
mkdir -p ~/.openclaw/workspace/skills/clawpilot-plan-summary/scripts目录结构:
text
clawpilot-plan-summary/
├── SKILL.md
└── scripts/
└── fetch_plan_summary.mjs七、参考 SKILL.md
如果你要自动创建这个 Skill,推荐写成下面这样:
markdown
---
name: clawpilot-plan-summary
description: Query ClawPilot plan usage and remaining daily credits with the existing clawpilot-ai provider API key.
metadata:
{
"openclaw":
{
"emoji": "📊",
"requires": { "bins": ["node", "openclaw"], "config": ["models.providers.clawpilot-ai.apiKey"] },
},
}
---
# ClawPilot plan summary
Use this skill when the user asks about ClawPilot plan balance, plan usage, daily credits, remaining quota, or whether the current plan is still active.
## Steps
1. Run `node {baseDir}/scripts/fetch_plan_summary.mjs`.
2. Parse the returned JSON.
3. If `ok !== true`, explain the failure briefly and include `request_id` when present.
4. If `ok === true`, present the result in this exact order:
- fetched_at
- account_email
- status_label
- current_daily_allowance
- used_today
- remaining_today
- today_request_count
- today token usage(compact units like K / M)
- last_request_at(show in the user's local timezone)
- last_entitlement_ends_at(show in the user's local timezone)
- quota_date + quota_reset_timezone
## Important rules
- Reuse the existing `clawpilot-ai` provider API key.
- Read the provider API key directly from the local OpenClaw config file. Do not use `openclaw config get ...apiKey` because that output is masked.
- Do not ask the user to paste a second API key if this skill is eligible.
- Do not call `/api/v1/client/ai-api/runtime-status`.
- Do not infer a plan name.
- Do not show raw micros unless the user explicitly asks for raw fields.
- Render displayed timestamps in the user's local timezone.
- Render token usage with compact units such as `K` and `M`.八、参考脚本 scripts/fetch_plan_summary.mjs
下面这份脚本是一个可直接生成的参考实现。它会:
- 读取当前
clawpilot-aiprovider 的 API key - 调用套餐余量接口
- 输出一个更适合 Skill 消费的标准化 JSON
注意:
- 这个脚本保留原始 ISO 时间字符串。
- 最终给用户展示时,应在 Skill 展示层把这些时间转换成用户本地时区。
- 这个脚本使用
os.homedir()和path.join(),就是为了兼容 Windows、macOS、Linux 以及容器内路径。
js
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const API_URL = "https://api.clawpilot.me/api/v1/ai-api/me/summary";
const TIMEOUT_MS = 12_000;
function trimOrEmpty(value) {
return typeof value === "string" ? value.trim() : "";
}
function resolveConfigFilePath() {
const explicitConfigPath = trimOrEmpty(process.env.OPENCLAW_CONFIG_PATH);
if (explicitConfigPath) {
return explicitConfigPath;
}
const stateDir = trimOrEmpty(process.env.OPENCLAW_STATE_DIR);
if (stateDir) {
return path.join(stateDir, "openclaw.json");
}
return path.join(os.homedir(), ".openclaw", "openclaw.json");
}
function readOpenClawConfig() {
const configPath = resolveConfigFilePath();
let rawText = "";
try {
rawText = fs.readFileSync(configPath, "utf8");
} catch {
throw new Error(`读取 OpenClaw 配置文件失败:${configPath}`);
}
try {
return JSON.parse(rawText);
} catch {
throw new Error(`OpenClaw 配置文件不是合法 JSON:${configPath}`);
}
}
function readProviderApiKeyRaw() {
const config = readOpenClawConfig();
return trimOrEmpty(config?.models?.providers?.["clawpilot-ai"]?.apiKey);
}
function resolveApiKey(rawValue) {
const raw = trimOrEmpty(rawValue);
if (!raw) {
throw new Error("当前 OpenClaw 未配置 clawpilot-ai provider API key。");
}
const bracketEnvMatch = raw.match(/^\$\{([A-Z][A-Z0-9_]*)\}$/u);
if (bracketEnvMatch) {
const resolved = trimOrEmpty(process.env[bracketEnvMatch[1]]);
if (!resolved) {
throw new Error(`环境变量 ${bracketEnvMatch[1]} 未设置,无法继续查询套餐余量。`);
}
return resolved;
}
if (/^[A-Z][A-Z0-9_]*$/u.test(raw)) {
const resolved = trimOrEmpty(process.env[raw]);
if (resolved) {
return resolved;
}
}
return raw;
}
function toNumber(value) {
return typeof value === "number" && Number.isFinite(value) ? value : Number(value || 0);
}
function microsToUsd(value) {
return toNumber(value) / 1_000_000;
}
function formatUsd(value) {
const amount = microsToUsd(value);
if (!Number.isFinite(amount) || amount <= 0) {
return "$0";
}
const digits = amount < 0.01 ? 4 : 2;
return `$${amount.toFixed(digits).replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "")}`;
}
function formatInteger(value) {
return Math.max(0, Math.trunc(toNumber(value))).toLocaleString("en-US");
}
function formatCompactTokenCount(value) {
const safeValue = Math.max(0, Math.trunc(toNumber(value)));
if (safeValue < 1000) {
return String(safeValue);
}
const units = [
{ threshold: 1_000_000_000, suffix: "B" },
{ threshold: 1_000_000, suffix: "M" },
{ threshold: 1_000, suffix: "K" },
];
for (const unit of units) {
if (safeValue >= unit.threshold) {
const amount = safeValue / unit.threshold;
return `${amount.toFixed(2).replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "")}${unit.suffix}`;
}
}
return String(safeValue);
}
function resolveStatusLabel(summary) {
if (summary?.has_active_entitlement === true) {
return "套餐生效中";
}
if (summary?.has_any_entitlement === true) {
return "暂无生效套餐";
}
return "未检测到套餐";
}
async function requestSummary(apiKey) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(API_URL, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
signal: controller.signal,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = null;
}
if (!response.ok) {
return {
ok: false,
status: response.status,
error_code: payload?.error_code ?? "HTTP_ERROR",
message: payload?.message ?? "请求失败",
request_id: payload?.request_id ?? null,
};
}
return {
ok: true,
payload,
};
} finally {
clearTimeout(timer);
}
}
async function main() {
try {
const apiKey = resolveApiKey(readProviderApiKeyRaw());
const result = await requestSummary(apiKey);
if (!result.ok) {
console.log(JSON.stringify(result, null, 2));
process.exit(result.status >= 500 ? 1 : 2);
}
const payload = result.payload ?? {};
const account = payload.account ?? {};
const summary = payload.summary ?? {};
const normalized = {
ok: true,
fetched_at: payload.server_time ?? new Date().toISOString(),
account_email: account.email ?? null,
account_status: account.status ?? null,
last_login_at: account.last_login_at ?? null,
status_label: resolveStatusLabel(summary),
has_any_entitlement: summary.has_any_entitlement === true,
has_active_entitlement: summary.has_active_entitlement === true,
current_daily_allowance: formatUsd(summary.current_total_daily_allowance_usd_micros),
used_today: formatUsd(summary.today_used_amount_usd_micros),
remaining_today: formatUsd(summary.current_daily_remaining_usd_micros),
today_request_count: formatInteger(summary.today_request_count),
today_used_input_tokens: formatCompactTokenCount(summary.today_used_input_tokens),
today_used_output_tokens: formatCompactTokenCount(summary.today_used_output_tokens),
today_used_total_tokens: formatCompactTokenCount(summary.today_used_total_tokens),
last_request_at: summary.last_request_at ?? null,
last_entitlement_ends_at: summary.last_entitlement_ends_at ?? null,
quota_date: summary.quota_date ?? null,
quota_reset_timezone: summary.quota_reset_timezone ?? null,
raw: payload,
};
console.log(JSON.stringify(normalized, null, 2));
} catch (error) {
console.log(
JSON.stringify(
{
ok: false,
status: 0,
error_code: "LOCAL_RUNTIME_ERROR",
message: error instanceof Error ? error.message : String(error),
request_id: null,
},
null,
2,
),
);
process.exit(1);
}
}
await main();九、创建完成后的自检清单
自动创建这个 Skill 后,至少自检下面几项:
- 当
clawpilot-aiprovider API key 已配置时,Skill 能返回套餐余量。 - 当 provider key 缺失时,Skill 会明确提示配置缺失,而不是请求错误接口。
- 当账号暂无生效套餐时,Skill 会显示“暂无生效套餐”或“未检测到套餐”,而不是伪造套餐名。
- 当接口失败时,Skill 会把
request_id带给用户(如果响应中存在)。 - Skill 不会在回复里泄露完整 API key。
