Files
u-claw/portable/lib/xiapan-client.mjs
hfshfg b1468d455c feat(portable): 虾盘云插上即用 — 自动开户送试用额度 + Config 首选卡片
解决「插上 U 盘选虾盘云模型却 Invalid token」:之前客户端只生成
sk-uc-指纹 key 写进配置,但后台从没注册该 token。

- xiapan-client.mjs 加 provisionApiKey():把设备指纹送到虾盘云后台
  (POST api.u-claw.org/internal/token/provision) 自动开一个带试用额度
  的 token,返回真正可用的 key。
- bootstrap-xiapan.mjs 改:首启(配置无 cloud key 时)调 provision 拿真
  key 写配置;已有 key 则幂等跳过;provision 失败回退指纹 key 不阻塞启动。
  后端按 deviceId 幂等,刷不出额度。
- Config.html:模型网格加「虾盘云」首选卡片(一个 key 用全部模型,无需
  申请,选中直接启动跳过填 Key);更新过时型号(MiniMax-M2/kimi-k2/
  qwen-plus/doubao-seed-1.6/gpt-5.4/claude-opus-4-6/deepseek-v4);
  banner 文案改为「已含试用额度」。

注:开户/送额度的商业逻辑在闭源服务端(token-key-service),开源仓只放
调用机制。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 16:01:35 +08:00

119 lines
4.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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`;
}
// 首启静默开户:把设备指纹送到虾盘云后台,自动创建一个 token 并附带试用额度。
// 后端按 deviceId 幂等(同一台机器永远拿同一个 token刷不出额度不重启容器。
// 返回 { ok, apiKey, reused } —— apiKey 是后端真正注册过的 key可直接聊天
// 失败时返回 { ok:false, reason },调用方应回退到指纹派生 key至少配置不空
export async function provisionApiKey(fingerprint, { source = 'uclaw-portable' } = {}) {
if (!fingerprint || !/^[0-9a-f]{64}$/i.test(fingerprint)) {
return { ok: false, reason: 'bad-fingerprint' };
}
const base = getApiBase(); // https://api.u-claw.org/v1
const root = base.replace(/\/v1$/, ''); // https://api.u-claw.org
const url = `${root}/internal/token/provision`;
try {
const res = await fetchWithTimeout(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceId: fingerprint.toLowerCase(), source }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.success || !data.data || !data.data.apiKey) {
return { ok: false, reason: data.message || `http-${res.status}` };
}
return { ok: true, apiKey: data.data.apiKey, reused: !!data.reused };
} catch (err) {
return { ok: false, reason: err.message };
}
}