- 新增 portable/lib/{fingerprint,xiapan-client,bootstrap-xiapan}.mjs:
跨平台设备指纹(Win USB/disk + Mac UUID + Linux machine-id + seed 兜底)
生成 sk-uc-{fingerprint} 形式的虾盘云 apiKey,启动时 merge 到 openclaw.json
- Config.html 顶部新增「已绑定虾盘云」横幅:指纹来源 + Key + 余额 + 充值/解绑
- config-server 增加 /api/xiapan/{status,bind,unbind} 三个端点
- Electron app 在 whenReady 调 bootstrap,新增 sync-lib.js 让 build 前自动同步
- 锁 OpenClaw 版本到 2026.4.29(OPENCLAW_VERSION 单一来源)
- 删除死代码 portable/充值.html
- 新增 .github/workflows/release.yml:tag 触发,出 portable zip + Electron exe/dmg
- README 增加内置虾盘云说明 + 直接下载发行版指引
92 lines
3.0 KiB
JavaScript
92 lines
3.0 KiB
JavaScript
// Xiapan Cloud (虾盘云) client for U-Claw open-source edition.
|
|
// Provides only: apiKey derivation, balance lookup, recharge URL.
|
|
// Intentionally does NOT call /recharge/activate — open-source users do not get free quota.
|
|
|
|
const DEFAULT_API_BASE = 'https://api.u-claw.org/v1';
|
|
const DEFAULT_RECHARGE_PAGE = 'https://u-claw.org/cloud.html';
|
|
const QUOTA_PER_USD = 500_000; // 1 USD = 500k tokens (matches new-api convention)
|
|
const REQUEST_TIMEOUT_MS = 10_000;
|
|
|
|
// sk-uc- prefix marks keys generated by the u-claw open-source edition.
|
|
// ClawX commercial keys use plain sk-<hash> and the cloud.html flow uses sk-xp-,
|
|
// so the three namespaces never collide and the backend can audit by prefix.
|
|
export function buildApiKey(fingerprint) {
|
|
if (!fingerprint || !/^[0-9a-f]{64}$/i.test(fingerprint)) {
|
|
throw new Error('Fingerprint must be 64-character hex.');
|
|
}
|
|
return `sk-uc-${fingerprint.toLowerCase()}`;
|
|
}
|
|
|
|
function getApiBase() {
|
|
return (process.env.UCLAW_CLOUD_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
|
}
|
|
|
|
function getRechargePage() {
|
|
return process.env.UCLAW_CLOUD_RECHARGE_PAGE || DEFAULT_RECHARGE_PAGE;
|
|
}
|
|
|
|
async function fetchWithTimeout(url, init) {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
try {
|
|
return await fetch(url, { ...init, signal: controller.signal });
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
export async function getBalance(apiKey) {
|
|
if (!apiKey) throw new Error('apiKey is required.');
|
|
const base = getApiBase();
|
|
const headers = { Authorization: `Bearer ${apiKey}` };
|
|
|
|
const [subRes, usageRes] = await Promise.all([
|
|
fetchWithTimeout(`${base}/dashboard/billing/subscription`, { headers }).catch(() => null),
|
|
fetchWithTimeout(
|
|
`${base}/dashboard/billing/usage?start_date=2020-01-01&end_date=${new Date().toISOString().slice(0, 10)}`,
|
|
{ headers },
|
|
).catch(() => null),
|
|
]);
|
|
|
|
if (!subRes || !subRes.ok) {
|
|
return {
|
|
ok: false,
|
|
reason: subRes ? `subscription HTTP ${subRes.status}` : 'subscription request failed',
|
|
hardLimitUsd: 0,
|
|
usedUsd: 0,
|
|
remainingUsd: 0,
|
|
remainingTokens: 0,
|
|
};
|
|
}
|
|
|
|
const subscription = await subRes.json().catch(() => ({}));
|
|
let usedUsd = 0;
|
|
if (usageRes && usageRes.ok) {
|
|
const usage = await usageRes.json().catch(() => ({}));
|
|
// total_usage is in cents (USD * 100), per new-api convention
|
|
usedUsd = Number(usage.total_usage || 0) / 100;
|
|
}
|
|
|
|
const hardLimitUsd = Number(subscription.hard_limit_usd || 0);
|
|
const remainingUsd = Math.max(0, hardLimitUsd - usedUsd);
|
|
const remainingTokens = Math.round(remainingUsd * QUOTA_PER_USD);
|
|
|
|
return {
|
|
ok: true,
|
|
reason: null,
|
|
hardLimitUsd,
|
|
usedUsd,
|
|
remainingUsd,
|
|
remainingTokens,
|
|
};
|
|
}
|
|
|
|
export function getRechargeUrl(apiKey) {
|
|
if (!apiKey) throw new Error('apiKey is required.');
|
|
const page = getRechargePage();
|
|
const url = new URL(page);
|
|
url.searchParams.set('key', apiKey);
|
|
// Page already has #recharge anchor; preserve it
|
|
return `${url.toString()}#recharge`;
|
|
}
|