Files
u-claw/portable/lib/bootstrap-xiapan.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

189 lines
6.7 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.

// Bootstrap: ensure data/.openclaw/openclaw.json contains the uclaw-cloud provider
// pointing to the device-bound apiKey derived from the local fingerprint.
//
// Idempotent: if the provider already exists with the correct apiKey, do nothing.
// If it exists but the apiKey differs (USB swapped, machine changed), leave the
// existing entry alone and log a hint — never overwrite user data silently.
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { getFingerprint } from './fingerprint.mjs';
import { buildApiKey, provisionApiKey } from './xiapan-client.mjs';
const PROVIDER_ID = 'uclaw-cloud';
// Mirror ClawX commercial schema: keep models[] empty and steer model selection
// via agents.defaults.model.primary + fallbacks. OpenClaw 2026.4.x accepts both
// shapes, but the empty-models form lets the runtime auto-discover model ids
// from /v1/models without us hard-coding a list that drifts from the backend.
const DEFAULT_PROVIDER_TEMPLATE = {
baseUrl: 'https://api.u-claw.org/v1',
api: 'openai-completions',
models: [],
};
const DEFAULT_PRIMARY_MODEL = `${PROVIDER_ID}/deepseek-v4-flash`;
const DEFAULT_FALLBACK_MODELS = [
`${PROVIDER_ID}/deepseek-chat`,
`${PROVIDER_ID}/qwen-plus`,
`${PROVIDER_ID}/qwen-turbo`,
];
function readJsonSafe(filePath) {
if (!existsSync(filePath)) return null;
try {
const raw = readFileSync(filePath, 'utf8');
return JSON.parse(raw);
} catch (err) {
process.stderr.write(`[bootstrap-xiapan] Cannot parse ${filePath}: ${err.message}\n`);
return null;
}
}
function writeJson(filePath, data) {
writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
}
function ensureModelsContainer(config) {
if (!config.models || typeof config.models !== 'object') {
config.models = { mode: 'merge', providers: {} };
}
if (!config.models.mode) config.models.mode = 'merge';
if (!config.models.providers || typeof config.models.providers !== 'object') {
config.models.providers = {};
}
return config.models.providers;
}
// Set agents.defaults.model.primary to uclaw-cloud only when the user has not
// configured a primary model already. If they've picked a different provider
// (e.g. DeepSeek BYOK), leave their choice untouched.
function ensureAgentsDefaults(config) {
if (!config.agents || typeof config.agents !== 'object') {
config.agents = {};
}
if (!config.agents.defaults || typeof config.agents.defaults !== 'object') {
config.agents.defaults = {};
}
const defaults = config.agents.defaults;
if (!defaults.model || typeof defaults.model !== 'object') {
defaults.model = {};
}
let changed = false;
if (!defaults.model.primary) {
defaults.model.primary = DEFAULT_PRIMARY_MODEL;
changed = true;
}
if (!Array.isArray(defaults.model.fallbacks) || defaults.model.fallbacks.length === 0) {
defaults.model.fallbacks = [...DEFAULT_FALLBACK_MODELS];
changed = true;
}
return changed;
}
export async function bootstrapXiapan({ configPath, appRoot, log = console } = {}) {
if (!configPath) {
throw new Error('bootstrapXiapan: configPath is required.');
}
const root = appRoot || process.cwd();
let fingerprintInfo;
try {
fingerprintInfo = await getFingerprint(root);
} catch (err) {
log.warn?.(`[bootstrap-xiapan] Fingerprint detection failed: ${err.message}`);
return { ok: false, reason: 'fingerprint-failed' };
}
const config = readJsonSafe(configPath) || { gateway: { mode: 'local', auth: { token: 'uclaw' } } };
const providers = ensureModelsContainer(config);
const existing = providers[PROVIDER_ID];
// 幂等:配置里已有 uclaw-cloud apiKey → 保持不动,不再打网络 provision。
// (后端 provision 本身也按 deviceId 幂等,但客户端先短路可避免每次启动都请求。)
if (existing && typeof existing === 'object' && existing.apiKey) {
const changedDefaults = ensureAgentsDefaults(config);
if (changedDefaults) {
writeJson(configPath, config);
log.info?.('[bootstrap-xiapan] Added agents.defaults to existing config');
}
return {
ok: true,
action: changedDefaults ? 'agents-defaults-added' : 'noop',
source: fingerprintInfo.source,
apiKey: existing.apiKey,
};
}
// 首启(配置里还没有 cloud key静默开户。把指纹送到虾盘云后台自动开一个带
// 试用额度的 token。成功 → 用后端真正注册过的 key可直接聊天解决 Invalid token
// 失败(离线/后端不可达)→ 回退到指纹派生 key至少配置不空、不阻塞启动。
let apiKey = buildApiKey(fingerprintInfo.fingerprint);
let provisioned = false;
try {
const prov = await provisionApiKey(fingerprintInfo.fingerprint, { source: 'uclaw-portable' });
if (prov.ok && prov.apiKey) {
apiKey = prov.apiKey;
provisioned = true;
log.info?.(`[bootstrap-xiapan] Provisioned cloud token (reused=${prov.reused})`);
} else {
log.info?.(`[bootstrap-xiapan] Provision skipped (${prov.reason}); using fingerprint key`);
}
} catch (err) {
log.info?.(`[bootstrap-xiapan] Provision failed (${err.message}); using fingerprint key`);
}
providers[PROVIDER_ID] = {
...DEFAULT_PROVIDER_TEMPLATE,
...(existing && typeof existing === 'object' ? existing : {}),
baseUrl: existing?.baseUrl || DEFAULT_PROVIDER_TEMPLATE.baseUrl,
api: existing?.api || DEFAULT_PROVIDER_TEMPLATE.api,
apiKey,
models: existing?.models ?? DEFAULT_PROVIDER_TEMPLATE.models,
};
ensureAgentsDefaults(config);
writeJson(configPath, config);
log.info?.(
`[bootstrap-xiapan] Wrote uclaw-cloud provider (source=${fingerprintInfo.source}, `
+ `provisioned=${provisioned}, key=${apiKey.slice(0, 12)}…)`,
);
return {
ok: true,
action: 'created',
provisioned,
source: fingerprintInfo.source,
apiKey,
};
}
// CLI:
// node bootstrap-xiapan.mjs <config-path>
// env UCLAW_CONFIG_PATH=... node bootstrap-xiapan.mjs
import { pathToFileURL } from 'node:url';
const isMain = (() => {
try {
if (!process.argv[1]) return false;
return import.meta.url === pathToFileURL(process.argv[1]).href;
} catch {
return false;
}
})();
if (isMain) {
const configPath = process.argv[2] || process.env.UCLAW_CONFIG_PATH;
if (!configPath) {
process.stderr.write('Usage: node bootstrap-xiapan.mjs <openclaw.json path>\n');
process.exit(2);
}
const appRoot = process.env.UCLAW_APP_ROOT || resolve(configPath, '../../..');
bootstrapXiapan({ configPath, appRoot })
.then((res) => {
process.stdout.write(`${JSON.stringify(res)}\n`);
})
.catch((err) => {
process.stderr.write(`bootstrap-xiapan error: ${err.message}\n`);
process.exit(1);
});
}