credentials.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  1. const crypto = require('crypto');
  2. const fs = require('fs');
  3. const path = require('path');
  4. const os = require('os');
  5. const { AsyncLocalStorage } = require('async_hooks');
  6. const DEFAULT_API_BASE = 'https://server.fmode.cn/api/qiwei';
  7. const CREDENTIALS_FILE = path.join(os.homedir(), '.claude', 'qiwei-credentials.json');
  8. let activeQiweiContext = {};
  9. const qiweiContextStorage = new AsyncLocalStorage();
  10. function safeAccountKey(value, fallback = 'legacy-default') {
  11. return String(value || fallback).trim().replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 100) || fallback;
  12. }
  13. function setActiveQiweiContext(input = {}) {
  14. activeQiweiContext = {
  15. uid: String(input.uid || input.qiweiUid || '').trim(),
  16. guid: String(input.guid || input.qiweiGuid || input.deviceGuid || '').trim(),
  17. userId: String(input.userId || input.wecomUserId || '').trim(),
  18. nickname: String(input.nickname || '').trim(),
  19. corpName: String(input.corpName || '').trim(),
  20. apiBase: String(input.apiBase || input.baseUrl || '').trim().replace(/\/$/, ''),
  21. };
  22. return { ...activeQiweiContext };
  23. }
  24. function getActiveQiweiContext() {
  25. return { ...activeQiweiContext, ...(qiweiContextStorage.getStore() || {}) };
  26. }
  27. function runWithQiweiContext(input = {}, callback) {
  28. if (typeof callback !== 'function') throw new TypeError('callback must be a function');
  29. const parent = getActiveQiweiContext();
  30. const context = {
  31. ...parent,
  32. uid: String(input.uid || input.qiweiUid || parent.uid || '').trim(),
  33. guid: String(input.guid || input.qiweiGuid || input.deviceGuid || parent.guid || '').trim(),
  34. userId: String(input.userId || input.wecomUserId || parent.userId || '').trim(),
  35. nickname: String(input.nickname || parent.nickname || '').trim(),
  36. corpName: String(input.corpName || parent.corpName || '').trim(),
  37. apiBase: String(input.apiBase || input.baseUrl || parent.apiBase || '').trim().replace(/\/$/, ''),
  38. };
  39. return qiweiContextStorage.run(context, callback);
  40. }
  41. function readJsonMaybe(filePath) {
  42. try {
  43. if (!filePath || !fs.existsSync(filePath)) return {};
  44. return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
  45. } catch {
  46. return {};
  47. }
  48. }
  49. function firstNonEmpty(values) {
  50. return values.find(value => typeof value === 'string' && value.trim()) || '';
  51. }
  52. function readEnvFileMaybe(filePath) {
  53. try {
  54. if (!filePath || !fs.existsSync(filePath)) return {};
  55. const env = {};
  56. const content = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
  57. for (const rawLine of content.split(/\r?\n/)) {
  58. const line = rawLine.trim();
  59. if (!line || line.startsWith('#')) continue;
  60. const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
  61. if (!match) continue;
  62. let value = match[2].trim();
  63. if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
  64. value = value.slice(1, -1);
  65. }
  66. env[match[1]] = value;
  67. }
  68. return env;
  69. } catch {
  70. return {};
  71. }
  72. }
  73. function envCandidates() {
  74. const candidates = [];
  75. let current = process.cwd();
  76. while (current) {
  77. candidates.push(path.join(current, '.env.local'));
  78. candidates.push(path.join(current, '.env'));
  79. const parent = path.dirname(current);
  80. if (parent === current) break;
  81. current = parent;
  82. }
  83. candidates.push(path.resolve(__dirname, '..', '..', '..', '.env.local'));
  84. candidates.push(path.resolve(__dirname, '..', '..', '..', '.env'));
  85. return [...new Set(candidates)];
  86. }
  87. function readEnvFiles() {
  88. return envCandidates().reduce((merged, filePath) => {
  89. const next = readEnvFileMaybe(filePath);
  90. for (const [key, value] of Object.entries(next)) {
  91. if (!Object.prototype.hasOwnProperty.call(merged, key)) merged[key] = value;
  92. }
  93. return merged;
  94. }, {});
  95. }
  96. function readClaudeSettingsEnv() {
  97. const files = [
  98. path.join(os.homedir(), '.claude', 'settings.json'),
  99. path.join(os.homedir(), '.claude', 'settings.local.json'),
  100. path.join(process.cwd(), '.claude', 'settings.json'),
  101. path.join(process.cwd(), '.claude', 'settings.local.json')
  102. ];
  103. const merged = {};
  104. for (const filePath of files) {
  105. const json = readJsonMaybe(filePath);
  106. const env = json && typeof json.env === 'object' && json.env ? json.env : null;
  107. if (!env) continue;
  108. for (const [key, value] of Object.entries(env)) {
  109. if (!merged[key] && typeof value === 'string' && value.trim()) merged[key] = value;
  110. }
  111. }
  112. return merged;
  113. }
  114. function readFmodeConfig() {
  115. const files = [
  116. path.join(os.homedir(), '.fmode', 'config.json'),
  117. path.join(process.cwd(), '.fmode', 'config.json')
  118. ];
  119. const merged = {};
  120. for (const filePath of files) {
  121. const json = readJsonMaybe(filePath);
  122. for (const [key, value] of Object.entries(json || {})) {
  123. if (merged[key] === undefined) merged[key] = value;
  124. }
  125. }
  126. return merged;
  127. }
  128. function pickFmodeAnthropicToken(env) {
  129. const token = env && typeof env.ANTHROPIC_AUTH_TOKEN === 'string' ? env.ANTHROPIC_AUTH_TOKEN.trim() : '';
  130. const base = String((env && (env.ANTHROPIC_BASE_URL || env.ANTHROPIC_API_BASE)) || '').toLowerCase();
  131. return pickFmodeApiToken(token, base);
  132. }
  133. function normalizeToken(value) {
  134. return String(value || '').trim().replace(/^Bearer\s+/i, '');
  135. }
  136. function pickFmodeApiToken(value, apiBase = '') {
  137. const token = normalizeToken(value);
  138. if (!/^sk-/i.test(token) || /^sk-ant-/i.test(token)) return '';
  139. const base = String(apiBase || '').trim().toLowerCase();
  140. if (base && !base.includes('fmode')) return '';
  141. return token;
  142. }
  143. function readFmodeVoiceToken(input = {}, sourceOverrides = {}) {
  144. const processEnv = sourceOverrides.processEnv || process.env;
  145. const fileEnv = sourceOverrides.fileEnv || readEnvFiles();
  146. const claudeEnv = sourceOverrides.claudeEnv || readClaudeSettingsEnv();
  147. const fmodeConfig = sourceOverrides.fmodeConfig || readFmodeConfig();
  148. const voiceBase = firstNonEmpty([
  149. input.voiceApiBase,
  150. input.endpoint,
  151. processEnv.QIWEI_VOICE_ENDPOINT,
  152. fileEnv.QIWEI_VOICE_ENDPOINT,
  153. 'https://server.fmode.cn/api/voice/indextts2'
  154. ]);
  155. const fmodeBase = firstNonEmpty([
  156. input.fmodeApiBase,
  157. processEnv.FMODE_API_BASE,
  158. fileEnv.FMODE_API_BASE,
  159. fmodeConfig.apiBase,
  160. fmodeConfig.llmBaseUrl,
  161. 'https://api.fmode.cn'
  162. ]);
  163. return firstNonEmpty([
  164. pickFmodeApiToken(input.voiceAuthToken || input.authToken, voiceBase),
  165. pickFmodeApiToken(processEnv.QIWEI_VOICE_AUTH_TOKEN, voiceBase),
  166. pickFmodeApiToken(fileEnv.QIWEI_VOICE_AUTH_TOKEN, voiceBase),
  167. pickFmodeApiToken(input.fmodeApiToken || input.fmodeApiKey || input.newapiToken, fmodeBase),
  168. pickFmodeApiToken(processEnv.FMODE_API_TOKEN, fmodeBase),
  169. pickFmodeApiToken(processEnv.FMODE_API_KEY, fmodeBase),
  170. pickFmodeApiToken(processEnv.NEWAPI_TOKEN, fmodeBase),
  171. pickFmodeApiToken(fileEnv.FMODE_API_TOKEN, fmodeBase),
  172. pickFmodeApiToken(fileEnv.FMODE_API_KEY, fmodeBase),
  173. pickFmodeApiToken(fileEnv.NEWAPI_TOKEN, fmodeBase),
  174. pickFmodeApiToken(fmodeConfig.fmodeApiToken, fmodeBase),
  175. pickFmodeApiToken(fmodeConfig.fmodeApiKey, fmodeBase),
  176. pickFmodeApiToken(fmodeConfig.newapiToken, fmodeBase),
  177. pickFmodeApiToken(fmodeConfig.newApiToken, fmodeBase),
  178. pickFmodeApiToken(claudeEnv.FMODE_API_TOKEN, fmodeBase),
  179. pickFmodeApiToken(claudeEnv.FMODE_API_KEY, fmodeBase),
  180. pickFmodeApiToken(claudeEnv.NEWAPI_TOKEN, fmodeBase),
  181. pickFmodeAnthropicToken(processEnv),
  182. pickFmodeAnthropicToken(claudeEnv)
  183. ]);
  184. }
  185. function readQiweiAuthToken(input = {}) {
  186. const fileEnv = readEnvFiles();
  187. const claudeEnv = readClaudeSettingsEnv();
  188. const fmodeConfig = readFmodeConfig();
  189. const token = firstNonEmpty([
  190. input.authToken,
  191. input.fmodeApiKey,
  192. input.fmodeApiToken,
  193. input.sessionToken,
  194. input.apiToken,
  195. input.token,
  196. process.env.QIWEI_AUTH_TOKEN,
  197. process.env.QIWE_AUTH_TOKEN,
  198. process.env.FMODE_API_KEY,
  199. process.env.FMODE_API_TOKEN,
  200. process.env.NEWAPI_TOKEN,
  201. pickFmodeAnthropicToken(process.env),
  202. fileEnv.QIWEI_AUTH_TOKEN,
  203. fileEnv.QIWE_AUTH_TOKEN,
  204. fileEnv.FMODE_API_KEY,
  205. fileEnv.FMODE_API_TOKEN,
  206. fileEnv.NEWAPI_TOKEN,
  207. fmodeConfig.newapiToken,
  208. fmodeConfig.newApiToken,
  209. fmodeConfig.fmodeApiKey,
  210. fmodeConfig.fmodeApiToken,
  211. claudeEnv.FMODE_API_KEY,
  212. claudeEnv.NEWAPI_TOKEN,
  213. pickFmodeAnthropicToken(claudeEnv),
  214. fileEnv.VOC_TOKEN,
  215. fileEnv.VOC_SOCIAL_TOKEN,
  216. process.env.VOC_TOKEN,
  217. process.env.VOC_SOCIAL_TOKEN
  218. ]);
  219. return normalizeToken(token);
  220. }
  221. function readQiweiUpstreamToken(input = {}) {
  222. const fileEnv = readEnvFiles();
  223. return normalizeToken(firstNonEmpty([
  224. input.upstreamToken,
  225. input.qiweiToken,
  226. process.env.QIWEI_UPSTREAM_TOKEN,
  227. process.env.QIWEI_TOKEN,
  228. fileEnv.QIWEI_UPSTREAM_TOKEN,
  229. fileEnv.QIWEI_TOKEN
  230. ]));
  231. }
  232. function readQiweiUpstreamApiBase(input = {}) {
  233. const fileEnv = readEnvFiles();
  234. return String(firstNonEmpty([
  235. input.upstreamApiBase,
  236. input.qiweiApiBase,
  237. process.env.QIWEI_UPSTREAM_API_BASE,
  238. fileEnv.QIWEI_UPSTREAM_API_BASE,
  239. 'https://manager.qiweapi.com/qiwe'
  240. ])).replace(/\/$/, '');
  241. }
  242. function readQiweiTransportMode(input = {}) {
  243. return String(firstNonEmpty([
  244. input.transportMode,
  245. process.env.QIWEI_TRANSPORT_MODE,
  246. readEnvFiles().QIWEI_TRANSPORT_MODE,
  247. 'fmode'
  248. ])).trim().toLowerCase();
  249. }
  250. function readCredentialsFile() {
  251. return readJsonMaybe(CREDENTIALS_FILE);
  252. }
  253. function readQiweiUid(input = {}) {
  254. const fileEnv = readEnvFiles();
  255. const creds = readCredentialsFile();
  256. return firstNonEmpty([
  257. input.uid,
  258. input.qiweiUid,
  259. activeQiweiContext.uid,
  260. process.env.QIWEI_UID,
  261. process.env.QIWE_UID,
  262. fileEnv.QIWEI_UID,
  263. fileEnv.QIWE_UID,
  264. creds.uid
  265. ]);
  266. }
  267. function readQiweiApiBase(input = {}) {
  268. const fileEnv = readEnvFiles();
  269. const creds = readCredentialsFile();
  270. return String(firstNonEmpty([
  271. input.apiBase,
  272. input.baseUrl,
  273. activeQiweiContext.apiBase,
  274. process.env.QIWEI_API_BASE,
  275. process.env.QIWEI_RELAY_BASE_URL,
  276. process.env.QIWE_API_BASE,
  277. process.env.QIWE_RELAY_BASE_URL,
  278. fileEnv.QIWEI_API_BASE,
  279. fileEnv.QIWEI_RELAY_BASE_URL,
  280. fileEnv.QIWE_API_BASE,
  281. fileEnv.QIWE_RELAY_BASE_URL,
  282. creds.apiBase,
  283. DEFAULT_API_BASE
  284. ]) || DEFAULT_API_BASE).replace(/\/$/, '');
  285. }
  286. function resolveQiweiEnvPath(envRoot) {
  287. return path.join(envRoot ? path.resolve(envRoot) : process.cwd(), '.env.local');
  288. }
  289. function saveQiweiClientConfig(input = {}) {
  290. const { uid, apiBase, guid, authToken, envRoot, userId, nickname, corpName } = input;
  291. const hasGuid = Object.prototype.hasOwnProperty.call(input, 'guid');
  292. const saved = [];
  293. try {
  294. const dir = path.dirname(CREDENTIALS_FILE);
  295. if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
  296. const current = readCredentialsFile();
  297. const next = { ...current };
  298. if (uid) next.uid = uid;
  299. if (apiBase) next.apiBase = apiBase;
  300. if (hasGuid) {
  301. if (guid) next.guid = guid;
  302. else delete next.guid;
  303. }
  304. for (const [key, value] of Object.entries({ userId, nickname, corpName })) {
  305. if (Object.prototype.hasOwnProperty.call(input, key)) next[key] = String(value || '').trim();
  306. }
  307. next.updatedAt = new Date().toISOString();
  308. fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(next, null, 2), 'utf8');
  309. saved.push(CREDENTIALS_FILE);
  310. } catch {
  311. // ignore, fall through to env file
  312. }
  313. try {
  314. const envPath = resolveQiweiEnvPath(envRoot);
  315. const pairs = [];
  316. if (uid) pairs.push(['QIWEI_UID', uid]);
  317. if (apiBase) pairs.push(['QIWEI_API_BASE', apiBase]);
  318. if (hasGuid) pairs.push(['QIWEI_GUID', String(guid || '').trim()]);
  319. if (authToken) pairs.push(['QIWEI_AUTH_TOKEN', normalizeToken(authToken)]);
  320. if (pairs.length) {
  321. let content = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : '';
  322. for (const [key, value] of pairs) {
  323. const line = `${key}=${value}`;
  324. const re = new RegExp(`^${key}\\s*=.*$`, 'm');
  325. content = re.test(content)
  326. ? content.replace(re, line)
  327. : `${content.replace(/\n*$/, '')}${content ? '\n' : ''}${line}\n`;
  328. }
  329. fs.writeFileSync(envPath, content, 'utf8');
  330. saved.push(envPath);
  331. }
  332. } catch {
  333. // ignore
  334. }
  335. if (uid) process.env.QIWEI_UID = String(uid).trim();
  336. if (apiBase) process.env.QIWEI_API_BASE = String(apiBase).trim().replace(/\/$/, '');
  337. if (hasGuid) {
  338. if (guid) process.env.QIWEI_GUID = String(guid).trim();
  339. else process.env.QIWEI_GUID = '';
  340. }
  341. return saved;
  342. }
  343. function readQiweiAccountMetadata(input = {}) {
  344. const creds = readCredentialsFile();
  345. const fileEnv = readEnvFiles();
  346. return {
  347. userId: firstNonEmpty([input.userId, input.wecomUserId, activeQiweiContext.userId, creds.userId]),
  348. nickname: firstNonEmpty([input.nickname, activeQiweiContext.nickname, creds.nickname]),
  349. corpName: firstNonEmpty([input.corpName, activeQiweiContext.corpName, creds.corpName]),
  350. legacyAccountKey: safeAccountKey(firstNonEmpty([
  351. input.legacyAccountKey,
  352. process.env.QIWEI_LEGACY_ACCOUNT_KEY,
  353. fileEnv.QIWEI_LEGACY_ACCOUNT_KEY,
  354. 'legacy-default'
  355. ]))
  356. };
  357. }
  358. function qiweiAccountKey(input = {}) {
  359. const metadata = readQiweiAccountMetadata(input);
  360. if (!metadata.userId) return metadata.legacyAccountKey;
  361. return crypto.createHash('sha256').update(metadata.userId).digest('hex').slice(0, 16);
  362. }
  363. function readQiweiGuid(input = {}) {
  364. const fileEnv = readEnvFiles();
  365. const creds = readCredentialsFile();
  366. const processGuid = Object.prototype.hasOwnProperty.call(process.env, 'QIWEI_GUID')
  367. ? process.env.QIWEI_GUID
  368. : process.env.QIWE_GUID;
  369. const fileGuid = Object.prototype.hasOwnProperty.call(fileEnv, 'QIWEI_GUID')
  370. ? fileEnv.QIWEI_GUID
  371. : fileEnv.QIWE_GUID;
  372. const requestedUid = firstNonEmpty([input.uid, input.qiweiUid]);
  373. const storedUid = firstNonEmpty([
  374. process.env.QIWEI_UID,
  375. process.env.QIWE_UID,
  376. fileEnv.QIWEI_UID,
  377. fileEnv.QIWE_UID,
  378. creds.uid,
  379. activeQiweiContext.uid
  380. ]);
  381. const contextMatchesUid = !requestedUid || !storedUid || requestedUid === storedUid;
  382. return firstNonEmpty([
  383. input.guid,
  384. input.qiweiGuid,
  385. input.deviceGuid,
  386. contextMatchesUid ? activeQiweiContext.guid : '',
  387. contextMatchesUid ? processGuid : '',
  388. contextMatchesUid ? fileGuid : '',
  389. contextMatchesUid ? creds.guid : ''
  390. ]);
  391. }
  392. function readFmodeApiKey(input = {}) {
  393. const fmodeConfig = readFmodeConfig();
  394. return firstNonEmpty([
  395. input.fmodeApiKey,
  396. input.newapiToken,
  397. input.fmodeApiToken,
  398. process.env.FMODE_API_KEY,
  399. process.env.FMODE_API_TOKEN,
  400. process.env.NEWAPI_TOKEN,
  401. fmodeConfig.newapiToken,
  402. fmodeConfig.fmodeApiToken,
  403. fmodeConfig.newApiToken
  404. ]);
  405. }
  406. function readFmodeLlmBase(input = {}) {
  407. const fmodeConfig = readFmodeConfig();
  408. return String(firstNonEmpty([
  409. input.fmodeLlmBase,
  410. input.llmBaseUrl,
  411. process.env.FMODE_LLM_BASE_URL,
  412. process.env.FMODE_API_BASE,
  413. fmodeConfig.llmBaseUrl,
  414. fmodeConfig.apiBase,
  415. 'https://api.fmode.cn'
  416. ]) || 'https://api.fmode.cn').replace(/\/$/, '');
  417. }
  418. function ensureQiweiUid(input = {}) {
  419. const existing = readQiweiUid(input);
  420. if (existing) return existing;
  421. const uid = `qiwei-${crypto.randomUUID()}`;
  422. saveQiweiClientConfig({ uid, apiBase: readQiweiApiBase(input) });
  423. return uid;
  424. }
  425. function isConfigured(input = {}) {
  426. return Boolean(readQiweiAuthToken(input) && readQiweiUid(input));
  427. }
  428. module.exports = {
  429. DEFAULT_API_BASE,
  430. CREDENTIALS_FILE,
  431. readQiweiAuthToken,
  432. readQiweiUpstreamToken,
  433. readQiweiUpstreamApiBase,
  434. readQiweiTransportMode,
  435. readFmodeVoiceToken,
  436. readQiweiUid,
  437. readQiweiGuid,
  438. readFmodeApiKey,
  439. readFmodeLlmBase,
  440. ensureQiweiUid,
  441. readQiweiApiBase,
  442. resolveQiweiEnvPath,
  443. saveQiweiClientConfig,
  444. readQiweiAccountMetadata,
  445. qiweiAccountKey,
  446. safeAccountKey,
  447. setActiveQiweiContext,
  448. getActiveQiweiContext,
  449. runWithQiweiContext,
  450. isConfigured,
  451. readEnvFiles,
  452. readClaudeSettingsEnv,
  453. readFmodeConfig,
  454. pickFmodeAnthropicToken,
  455. pickFmodeApiToken
  456. };