@tokenaut/opentoken 1.3.5 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.js CHANGED
@@ -1,8 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const fs = require('fs');
4
- const os = require('os');
5
4
  const path = require('path');
5
+ const os = require('os');
6
6
 
7
7
  const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
8
8
 
@@ -25,21 +25,15 @@ function readClaudeSettings() {
25
25
  if (!fs.existsSync(SETTINGS_PATH)) {
26
26
  return {};
27
27
  }
28
-
29
28
  const raw = fs.readFileSync(SETTINGS_PATH, 'utf-8');
30
29
  const parsed = JSON.parse(raw);
31
-
32
30
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
33
31
  throw new Error('settings.json 内容格式不合法');
34
32
  }
35
-
36
33
  return parsed;
37
- } catch (error) {
38
- if (error.code === 'ENOENT') {
39
- return {};
40
- }
41
-
42
- throw error;
34
+ } catch (e) {
35
+ if (e.code === 'ENOENT') return {};
36
+ throw e;
43
37
  }
44
38
  }
45
39
 
@@ -48,43 +42,50 @@ function writeClaudeSettings(settings) {
48
42
  if (!fs.existsSync(dir)) {
49
43
  fs.mkdirSync(dir, { recursive: true });
50
44
  }
51
-
52
45
  fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf-8');
53
46
  }
54
47
 
55
- function applyConfig(patch) {
48
+ function getCurrentConfig() {
56
49
  const settings = readClaudeSettings();
50
+ const env = settings.env || {};
51
+ const apiKey =
52
+ env['ANTHROPIC_AUTH_TOKEN'] ||
53
+ env['ANTHROPIC_API_KEY'] ||
54
+ '';
55
+ return {
56
+ apiKey,
57
+ baseUrl: env['ANTHROPIC_BASE_URL'] || '',
58
+ model: env['ANTHROPIC_MODEL'] || '',
59
+ };
60
+ }
57
61
 
58
- if (!settings.env || typeof settings.env !== 'object' || Array.isArray(settings.env)) {
59
- settings.env = {};
60
- }
61
-
62
- if (patch.apiKey !== undefined) {
63
- settings.env.ANTHROPIC_AUTH_TOKEN = patch.apiKey;
64
- settings.env.ANTHROPIC_API_KEY = '';
65
- }
62
+ function applyConfig({ apiKey, baseUrl, model }) {
63
+ const settings = readClaudeSettings();
66
64
 
67
- if (patch.baseUrl !== undefined) {
68
- settings.env.ANTHROPIC_BASE_URL = patch.baseUrl;
65
+ if (!settings.env) {
66
+ settings.env = {};
69
67
  }
70
68
 
71
- if (patch.model !== undefined) {
72
- settings.env.ANTHROPIC_MODEL = patch.model;
69
+ if (apiKey !== undefined) {
70
+ settings.env['ANTHROPIC_AUTH_TOKEN'] = apiKey;
71
+ settings.env['ANTHROPIC_API_KEY'] = '';
73
72
  }
74
73
 
75
- if (patch.sonnetModel !== undefined) {
76
- settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = patch.sonnetModel;
74
+ if (baseUrl !== undefined) {
75
+ settings.env['ANTHROPIC_BASE_URL'] = baseUrl;
77
76
  }
78
77
 
79
- if (patch.haikuModel !== undefined) {
80
- settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = patch.haikuModel;
78
+ if (model !== undefined) {
79
+ settings.env['ANTHROPIC_MODEL'] = model;
81
80
  }
82
81
 
83
82
  mergeDefaultClaudeAuxEnv(settings.env);
83
+
84
84
  writeClaudeSettings(settings);
85
85
  }
86
86
 
87
87
  module.exports = {
88
88
  SETTINGS_PATH,
89
+ getCurrentConfig,
89
90
  applyConfig,
90
91
  };
@@ -0,0 +1,228 @@
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+ const https = require('https');
5
+ const { URL } = require('url');
6
+
7
+ const DEFAULT_TIMEOUT_MS = 20000;
8
+ const MAX_REDIRECTS = 5;
9
+
10
+ const MODEL_LIST_PATHS = ['/v1/models', '/models'];
11
+
12
+ function joinUrl(baseUrl, path) {
13
+ const base = String(baseUrl || '').replace(/\/+$/, '');
14
+ const p = path.startsWith('/') ? path : '/' + path;
15
+ return base + p;
16
+ }
17
+
18
+ function buildHeaders(token, baseUrl) {
19
+ const headers = {
20
+ Accept: 'application/json',
21
+ 'Accept-Language': 'zh-CN,zh;q=0.9',
22
+ 'Cache-Control': 'no-cache',
23
+ Pragma: 'no-cache',
24
+ 'User-Agent': 'opentoken-cli/1.0',
25
+ };
26
+
27
+ if (baseUrl) {
28
+ try {
29
+ const u = new URL(baseUrl);
30
+ headers.Origin = `${u.protocol}//${u.host}`;
31
+ } catch (_) {
32
+ /* ignore */
33
+ }
34
+ }
35
+
36
+ if (token && String(token).trim()) {
37
+ headers.Authorization = `Bearer ${String(token).trim()}`;
38
+ }
39
+
40
+ return headers;
41
+ }
42
+
43
+ function requestOnce(urlStr, headers, timeoutMs) {
44
+ return new Promise((resolve, reject) => {
45
+ let u;
46
+ try {
47
+ u = new URL(urlStr);
48
+ } catch (e) {
49
+ return reject(new Error('无效的 Base URL'));
50
+ }
51
+
52
+ const lib = u.protocol === 'https:' ? https : http;
53
+ const port = u.port || (u.protocol === 'https:' ? 443 : 80);
54
+
55
+ const req = lib.request(
56
+ {
57
+ hostname: u.hostname,
58
+ port,
59
+ path: u.pathname + u.search,
60
+ method: 'GET',
61
+ headers,
62
+ timeout: timeoutMs || DEFAULT_TIMEOUT_MS,
63
+ },
64
+ (res) => {
65
+ const chunks = [];
66
+ res.on('data', (c) => chunks.push(c));
67
+ res.on('end', () => {
68
+ const body = Buffer.concat(chunks).toString('utf8');
69
+ resolve({ statusCode: res.statusCode || 0, headers: res.headers, body });
70
+ });
71
+ }
72
+ );
73
+
74
+ req.on('timeout', () => {
75
+ req.destroy();
76
+ reject(new Error('请求超时'));
77
+ });
78
+ req.on('error', reject);
79
+ req.end();
80
+ });
81
+ }
82
+
83
+ async function requestWithRedirects(urlStr, headers, redirectCount) {
84
+ const { statusCode, headers: resHeaders, body } = await requestOnce(
85
+ urlStr,
86
+ headers,
87
+ DEFAULT_TIMEOUT_MS
88
+ );
89
+
90
+ if (statusCode >= 300 && statusCode < 400 && resHeaders.location) {
91
+ if (redirectCount >= MAX_REDIRECTS) {
92
+ throw new Error('重定向次数过多');
93
+ }
94
+ const next = new URL(resHeaders.location, urlStr).href;
95
+ return requestWithRedirects(next, headers, redirectCount + 1);
96
+ }
97
+
98
+ return { statusCode, body };
99
+ }
100
+
101
+ function extractRawList(json) {
102
+ if (json == null) return [];
103
+ if (Array.isArray(json)) return json;
104
+ if (Array.isArray(json.data)) return json.data;
105
+ if (Array.isArray(json.models)) return json.models;
106
+ if (Array.isArray(json.result)) return json.result;
107
+ if (Array.isArray(json.list)) return json.list;
108
+ if (Array.isArray(json.items)) return json.items;
109
+ return [];
110
+ }
111
+
112
+ function normalizeModelId(entry) {
113
+ if (typeof entry === 'string') {
114
+ const s = entry.trim();
115
+ return s || null;
116
+ }
117
+ if (entry && typeof entry === 'object') {
118
+ const id =
119
+ entry.id ||
120
+ entry.model ||
121
+ entry.name ||
122
+ entry.model_id ||
123
+ entry.modelId ||
124
+ entry.value;
125
+ if (typeof id === 'string' && id.trim()) return id.trim();
126
+ }
127
+ return null;
128
+ }
129
+
130
+ function normalizeEndpointTypes(types) {
131
+ if (Array.isArray(types)) {
132
+ return types.map((t) => String(t).trim().toLowerCase()).filter(Boolean);
133
+ }
134
+ if (typeof types === 'string' && types.trim()) {
135
+ const s = types.trim();
136
+ if (s.startsWith('[') && s.endsWith(']')) {
137
+ try {
138
+ return normalizeEndpointTypes(JSON.parse(s));
139
+ } catch (_) {
140
+ // fall through to comma-separated parsing
141
+ }
142
+ }
143
+ return s.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean);
144
+ }
145
+ return [];
146
+ }
147
+
148
+ function getSupportedEndpointTypes(entry) {
149
+ if (!entry || typeof entry !== 'object') return [];
150
+ return normalizeEndpointTypes(
151
+ entry.supported_endpoint_types ||
152
+ entry.supportedEndpointTypes ||
153
+ entry.endpoint_types ||
154
+ entry.endpointTypes,
155
+ );
156
+ }
157
+
158
+ function isEndpointCompatible(entry, endpointType, allowMissingTypes) {
159
+ if (typeof entry === 'string') return allowMissingTypes;
160
+ if (!entry || typeof entry !== 'object') return false;
161
+
162
+ const types = getSupportedEndpointTypes(entry);
163
+ if (types.length === 0) return allowMissingTypes;
164
+ return types.includes(String(endpointType || '').trim().toLowerCase());
165
+ }
166
+
167
+ function parseModelsFromBody(body, options = {}) {
168
+ const {
169
+ filterAnthropicCompatible = false,
170
+ requiredEndpointType = '',
171
+ allowMissingEndpointTypes,
172
+ } = options;
173
+ const trimmed = String(body || '').trim();
174
+ if (!trimmed) return [];
175
+ let json;
176
+ try {
177
+ json = JSON.parse(trimmed);
178
+ } catch {
179
+ return [];
180
+ }
181
+ const raw = extractRawList(json);
182
+ let list = raw;
183
+ if (filterAnthropicCompatible) {
184
+ list = list.filter((entry) => isEndpointCompatible(entry, 'anthropic', true));
185
+ } else if (requiredEndpointType) {
186
+ const allowMissing =
187
+ allowMissingEndpointTypes === undefined ? false : Boolean(allowMissingEndpointTypes);
188
+ list = list.filter((entry) => isEndpointCompatible(entry, requiredEndpointType, allowMissing));
189
+ }
190
+ const ids = list.map(normalizeModelId).filter(Boolean);
191
+ return [...new Set(ids)];
192
+ }
193
+
194
+ async function fetchModelsForBaseUrl(baseUrl, token, options = {}) {
195
+ const base = String(baseUrl || '').trim();
196
+ if (!base) {
197
+ return { ok: false, models: [], error: '未配置 Base URL' };
198
+ }
199
+
200
+ const headers = buildHeaders(token, base);
201
+ let lastErr = '';
202
+
203
+ for (const p of MODEL_LIST_PATHS) {
204
+ const url = joinUrl(base, p);
205
+ try {
206
+ const { statusCode, body } = await requestWithRedirects(url, headers, 0);
207
+ if (statusCode !== 200) {
208
+ lastErr = `HTTP ${statusCode} (${url})`;
209
+ continue;
210
+ }
211
+ const models = parseModelsFromBody(body, options);
212
+ if (models.length > 0) {
213
+ return { ok: true, models, endpoint: url };
214
+ }
215
+ lastErr = `响应中无模型列表 (${url})`;
216
+ } catch (e) {
217
+ lastErr = e.message || String(e);
218
+ }
219
+ }
220
+
221
+ return { ok: false, models: [], error: lastErr || '无法获取模型列表' };
222
+ }
223
+
224
+ module.exports = {
225
+ fetchModelsForBaseUrl,
226
+ joinUrl,
227
+ MODEL_LIST_PATHS,
228
+ };
@@ -0,0 +1,157 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const yaml = require('js-yaml');
7
+ const {
8
+ HERMES_OPENTOKEN_BASE_URL,
9
+ HERMES_DEFAULT_MODEL,
10
+ } = require('./models');
11
+
12
+ const HERMES_DIR = path.join(os.homedir(), '.hermes');
13
+ const CONFIG_YAML_PATH = path.join(HERMES_DIR, 'config.yaml');
14
+ const ENV_PATH = path.join(HERMES_DIR, '.env');
15
+
16
+ function readEnvFile(envPath) {
17
+ if (!fs.existsSync(envPath)) {
18
+ return {};
19
+ }
20
+ const raw = fs.readFileSync(envPath, 'utf-8');
21
+ const out = {};
22
+ for (const line of raw.split(/\r?\n/)) {
23
+ const trimmed = line.trim();
24
+ if (!trimmed || trimmed.startsWith('#')) continue;
25
+ const eq = trimmed.indexOf('=');
26
+ if (eq <= 0) continue;
27
+ const k = trimmed.slice(0, eq).trim();
28
+ let v = trimmed.slice(eq + 1).trim();
29
+ if (
30
+ (v.startsWith('"') && v.endsWith('"')) ||
31
+ (v.startsWith("'") && v.endsWith("'"))
32
+ ) {
33
+ v = v.slice(1, -1);
34
+ }
35
+ out[k] = v;
36
+ }
37
+ return out;
38
+ }
39
+
40
+ function mergeEnvKey(raw, key, value) {
41
+ const lines = String(raw || '').split(/\r?\n/);
42
+ const assignment = `${key}=${value}`;
43
+ let found = false;
44
+ const next = lines.map((line) => {
45
+ const t = line.trim();
46
+ if (!t || t.startsWith('#')) return line;
47
+ const eq = t.indexOf('=');
48
+ if (eq <= 0) return line;
49
+ const k = t.slice(0, eq).trim();
50
+ if (k === key) {
51
+ found = true;
52
+ return assignment;
53
+ }
54
+ return line;
55
+ });
56
+ if (!found) {
57
+ if (!String(raw || '').trim()) {
58
+ return assignment + '\n';
59
+ }
60
+ if (next.length && next[next.length - 1] !== '') {
61
+ next.push('');
62
+ }
63
+ next.push(assignment);
64
+ }
65
+ const body = next.join('\n');
66
+ return body.endsWith('\n') ? body : body + '\n';
67
+ }
68
+
69
+ function readYamlRoot() {
70
+ if (!fs.existsSync(CONFIG_YAML_PATH)) {
71
+ return {};
72
+ }
73
+ const raw = fs.readFileSync(CONFIG_YAML_PATH, 'utf-8');
74
+ if (!String(raw).trim()) {
75
+ return {};
76
+ }
77
+ const data = yaml.load(raw);
78
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
79
+ throw new Error('config.yaml 根节点必须是映射(YAML mapping)');
80
+ }
81
+ return data;
82
+ }
83
+
84
+ function getCurrentHermesConfig() {
85
+ let modelId = '';
86
+ let baseUrl = '';
87
+ let provider = '';
88
+ try {
89
+ const data = readYamlRoot();
90
+ const m = data.model;
91
+ if (m && typeof m === 'object' && !Array.isArray(m)) {
92
+ if (m.default != null) modelId = String(m.default);
93
+ if (m.base_url != null) baseUrl = String(m.base_url);
94
+ if (m.provider != null) provider = String(m.provider);
95
+ }
96
+ } catch (_) {
97
+ /* 损坏时由 getCurrent 返回空,apply 时再报错 */
98
+ }
99
+ const envMap = readEnvFile(ENV_PATH);
100
+ const apiKey = envMap['OPENTOKEN_API_KEY'] || '';
101
+ return { apiKey, model: modelId, baseUrl, provider };
102
+ }
103
+
104
+ function applyHermesConfig(patch) {
105
+ const { apiKey, model } = patch || {};
106
+
107
+ let data;
108
+ try {
109
+ data = readYamlRoot();
110
+ } catch (e) {
111
+ throw new Error(
112
+ '现有 config.yaml 无法解析,请先备份后手动修复:' + e.message,
113
+ );
114
+ }
115
+
116
+ if (!data.model || typeof data.model !== 'object' || Array.isArray(data.model)) {
117
+ data.model = {};
118
+ }
119
+
120
+ data.model.provider = 'custom';
121
+ data.model.base_url = HERMES_OPENTOKEN_BASE_URL;
122
+
123
+ if (model !== undefined) {
124
+ data.model.default = model;
125
+ } else if (data.model.default == null || data.model.default === '') {
126
+ data.model.default = HERMES_DEFAULT_MODEL;
127
+ }
128
+
129
+ if (!fs.existsSync(HERMES_DIR)) {
130
+ fs.mkdirSync(HERMES_DIR, { recursive: true });
131
+ }
132
+
133
+ const yamlOut = yaml.dump(data, {
134
+ indent: 2,
135
+ lineWidth: -1,
136
+ noRefs: true,
137
+ sortKeys: false,
138
+ });
139
+ fs.writeFileSync(CONFIG_YAML_PATH, yamlOut, 'utf-8');
140
+
141
+ if (apiKey !== undefined) {
142
+ let prev = '';
143
+ if (fs.existsSync(ENV_PATH)) {
144
+ prev = fs.readFileSync(ENV_PATH, 'utf-8');
145
+ }
146
+ const merged = mergeEnvKey(prev, 'OPENTOKEN_API_KEY', apiKey);
147
+ fs.writeFileSync(ENV_PATH, merged, 'utf-8');
148
+ }
149
+ }
150
+
151
+ module.exports = {
152
+ HERMES_DIR,
153
+ CONFIG_YAML_PATH,
154
+ ENV_PATH,
155
+ getCurrentHermesConfig,
156
+ applyHermesConfig,
157
+ };
package/src/logo.js ADDED
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+
3
+ const chalk = require('chalk');
4
+ const stringWidth = require('string-width');
5
+
6
+ const LOGO_LINES = [
7
+ ' ████████╗ ██████╗ ██╗ ██╗███████╗███╗ ██╗ ',
8
+ ' ██║ ██╔═══██╗██║ ██╔╝██╔════╝████╗ ██║ ',
9
+ ' ██║ ██║ ██║█████╔╝ █████╗ ██╔██╗██║ ',
10
+ ' ██║ ██║ ██║██╔═██╗ ██╔══╝ ██║╚████║ ',
11
+ ' ██║ ╚██████╔╝██║ ██╗███████╗██║ ╚███║ ',
12
+ ' ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚══╝ ',
13
+ ];
14
+
15
+ function padToDisplayWidth(str, targetCols) {
16
+ let s = str;
17
+ while (stringWidth(s) < targetCols) {
18
+ s += ' ';
19
+ }
20
+ return s;
21
+ }
22
+
23
+ function splitAtDisplayHalf(str, totalDisplayWidth) {
24
+ const half = totalDisplayWidth / 2;
25
+ let acc = 0;
26
+ let i = 0;
27
+ while (i < str.length) {
28
+ const cp = str.codePointAt(i);
29
+ const ch = String.fromCodePoint(cp);
30
+ const w = stringWidth(ch);
31
+ if (acc + w > half) break;
32
+ acc += w;
33
+ i += ch.length;
34
+ }
35
+ return { left: str.slice(0, i), right: str.slice(i) };
36
+ }
37
+
38
+ function printLogo() {
39
+ const logoDisplayWidths = LOGO_LINES.map((l) => stringWidth(l));
40
+ const innerWidth = Math.max(...logoDisplayWidths);
41
+
42
+ const border = '═'.repeat(innerWidth);
43
+
44
+ console.log('');
45
+ console.log(chalk.cyan('╔' + border + '╗'));
46
+
47
+ for (const line of LOGO_LINES) {
48
+ const padded = padToDisplayWidth(line, innerWidth);
49
+ const { left, right } = splitAtDisplayHalf(padded, innerWidth);
50
+ console.log(
51
+ chalk.cyan('║') +
52
+ chalk.bold(chalk.hex('#c084fc')(left)) +
53
+ chalk.bold(chalk.white(right)) +
54
+ chalk.cyan('║')
55
+ );
56
+ }
57
+
58
+ console.log(chalk.cyan('╚' + border + '╝'));
59
+ console.log('');
60
+ }
61
+
62
+ module.exports = { printLogo };
package/src/models.js ADDED
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ const DEFAULT_BASE_URL = 'https://gw.opentoken.io';
4
+
5
+ const ATOMCODE_OPENTOKEN_BASE_URL = 'https://gw.opentoken.io/v1';
6
+
7
+ const ATOMCODE_CONTEXT_WINDOW = 200000;
8
+
9
+ const HERMES_OPENTOKEN_BASE_URL = 'https://gw.opentoken.io/v1';
10
+
11
+ const HERMES_DEFAULT_MODEL = 'claude-sonnet-4-5-20250929';
12
+
13
+ const OPENCLAW_OPENTOKEN_BASE_URL = 'https://gw.opentoken.io/v1';
14
+
15
+ const OPENCLAW_PROVIDER_KEY = 'opentoken';
16
+
17
+ const OPENCLAW_DEFAULT_MODEL = HERMES_DEFAULT_MODEL;
18
+
19
+ const OPENCODE_OPENTOKEN_BASE_URL = 'https://gw.opentoken.io/v1';
20
+
21
+ const OPENCODE_PROVIDER_KEY = 'opentoken';
22
+
23
+ const OPENCODE_DEFAULT_MODEL = HERMES_DEFAULT_MODEL;
24
+
25
+ const CODEX_OPENTOKEN_BASE_URL = 'https://gw.opentoken.io/v1';
26
+
27
+ const CODEX_PROVIDER_KEY = 'opentoken';
28
+
29
+ const CODEX_AUTH_KEY = 'OPENAI_API_KEY';
30
+
31
+ const CODEX_DEFAULT_MODEL = 'gpt-5.4';
32
+
33
+ const API_NODES = [
34
+ {
35
+ name: 'opentoken 默认节点 - https://gw.opentoken.io',
36
+ value: 'https://gw.opentoken.io',
37
+ },
38
+ {
39
+ name: '自定义 Base URL...',
40
+ value: '__custom__',
41
+ },
42
+ ];
43
+
44
+ module.exports = {
45
+ API_NODES,
46
+ DEFAULT_BASE_URL,
47
+ ATOMCODE_OPENTOKEN_BASE_URL,
48
+ ATOMCODE_CONTEXT_WINDOW,
49
+ HERMES_OPENTOKEN_BASE_URL,
50
+ HERMES_DEFAULT_MODEL,
51
+ OPENCLAW_OPENTOKEN_BASE_URL,
52
+ OPENCLAW_PROVIDER_KEY,
53
+ OPENCLAW_DEFAULT_MODEL,
54
+ OPENCODE_OPENTOKEN_BASE_URL,
55
+ OPENCODE_PROVIDER_KEY,
56
+ OPENCODE_DEFAULT_MODEL,
57
+ CODEX_OPENTOKEN_BASE_URL,
58
+ CODEX_PROVIDER_KEY,
59
+ CODEX_AUTH_KEY,
60
+ CODEX_DEFAULT_MODEL,
61
+ };