解决「插上 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>
119 lines
4.4 KiB
JavaScript
119 lines
4.4 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`;
|
||
}
|
||
|
||
// 首启静默开户:把设备指纹送到虾盘云后台,自动创建一个 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 };
|
||
}
|
||
}
|