dsh-scnet-connect 1.1.0 → 1.2.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.
@@ -22,9 +22,26 @@ import { DEFAULT_BASE_URL, DEFAULT_MODEL_ID, FREE_PRICE_LABEL } from './scnet-mo
22
22
 
23
23
  /** 目录缓存有效期。 */
24
24
  export const CATALOG_CACHE_TTL_MS = 60_000;
25
+ /** 目录 mtime 复查间隔(DSH 会高频调用 `getModels()`,不做节流就是每请求一次 stat)。 */
26
+ const STAT_THROTTLE_MS = 5_000;
25
27
  /** 网关校验超时。 */
26
28
  const GATEWAY_TIMEOUT_MS = 8_000;
27
29
 
30
+ /**
31
+ * 目录不可用时的兜底模型。
32
+ *
33
+ * 必须带上与平台一致的输入能力:只写 `{ id, priceLabel }` 会让描述符退化成
34
+ * 纯文本(`buildModel` 对「有元数据但没写 input」按纯文本处理),
35
+ * 于是没装客户端的用户会平白丢掉图片能力。
36
+ */
37
+ export const BUILTIN_MODEL = Object.freeze({
38
+ id: DEFAULT_MODEL_ID,
39
+ priceLabel: FREE_PRICE_LABEL,
40
+ input: ['text', 'image'],
41
+ reasoning: false,
42
+ thinkingMode: 'levels',
43
+ });
44
+
28
45
  /**
29
46
  * 从客户端目录里挑出免费模型。
30
47
  *
@@ -104,10 +121,16 @@ export function createModelCatalog(options = {}) {
104
121
  /** @type {{ models: any[], source: string, path?: string, modifiedAt?: number, updatedAt: string, gateway?: any, error?: string } | undefined} */
105
122
  let snapshot;
106
123
  let readAt = 0;
124
+ /** 最近一次 stat 得到的目录 mtime(用于发现客户端刷新了目录)。 */
125
+ let lastMtime;
126
+ let lastStatAt = 0;
107
127
 
108
128
  function readFromDisk() {
109
129
  const catalog = readClientCatalog({ modelsPath });
110
130
  const updatedAt = new Date().toISOString();
131
+ // 目录不存在时也要有 mtime 基线,否则「后来才出现的目录」不会被察觉
132
+ // (`modifiedAt === undefined` 会让 fileChanged 永远为 false)。
133
+ const modifiedAt = catalog?.modifiedAt ?? fileMtime();
111
134
  if (explicitIds.length > 0) {
112
135
  const byId = new Map((catalog?.models ?? []).map((model) => [model.id.toLowerCase(), model]));
113
136
  const models = explicitIds.map((id) => byId.get(id.toLowerCase()) ?? { id, priceLabel: 'unknown' });
@@ -115,7 +138,7 @@ export function createModelCatalog(options = {}) {
115
138
  models,
116
139
  source: 'config',
117
140
  path: catalog?.path,
118
- modifiedAt: catalog?.modifiedAt,
141
+ modifiedAt,
119
142
  updatedAt,
120
143
  };
121
144
  }
@@ -129,15 +152,15 @@ export function createModelCatalog(options = {}) {
129
152
  models: [...preferred, ...rest],
130
153
  source: 'client-catalog-all',
131
154
  path: catalog.path,
132
- modifiedAt: catalog.modifiedAt,
155
+ modifiedAt,
133
156
  updatedAt,
134
157
  };
135
158
  }
136
159
  return {
137
- models: [{ id: DEFAULT_MODEL_ID, priceLabel: FREE_PRICE_LABEL }],
160
+ models: [{ ...BUILTIN_MODEL }],
138
161
  source: 'builtin',
139
162
  path: catalog?.path,
140
- modifiedAt: catalog?.modifiedAt,
163
+ modifiedAt,
141
164
  updatedAt,
142
165
  error: '未找到客户端模型目录(~/.sclaw/agent/models.json),已退回内置 SCNet-Max',
143
166
  };
@@ -148,15 +171,15 @@ export function createModelCatalog(options = {}) {
148
171
  models: free,
149
172
  source: 'client-catalog',
150
173
  path: catalog.path,
151
- modifiedAt: catalog.modifiedAt,
174
+ modifiedAt,
152
175
  updatedAt,
153
176
  };
154
177
  }
155
178
  return {
156
- models: [{ id: DEFAULT_MODEL_ID, priceLabel: FREE_PRICE_LABEL }],
179
+ models: [{ ...BUILTIN_MODEL }],
157
180
  source: catalog ? 'client-catalog-empty' : 'builtin',
158
181
  path: catalog?.path,
159
- modifiedAt: catalog?.modifiedAt,
182
+ modifiedAt,
160
183
  updatedAt,
161
184
  error: catalog
162
185
  ? '客户端模型目录里没有标记为 free 的模型,已退回内置 SCNet-Max'
@@ -164,14 +187,27 @@ export function createModelCatalog(options = {}) {
164
187
  };
165
188
  }
166
189
 
167
- /** 同步读取(带 TTL 缓存)。 */
190
+ /**
191
+ * 同步读取(带 TTL 缓存 + stat 节流)。
192
+ *
193
+ * DSH 会频繁调用 `getModels()`;`stat` 本身不贵,但每次请求都打一次系统调用
194
+ * 没有意义,因此目录 mtime 最多每 {@link STAT_THROTTLE_MS} 复查一次。
195
+ */
168
196
  function current() {
169
197
  const now = Date.now();
170
198
  const expired = now - readAt > CATALOG_CACHE_TTL_MS;
171
- const fileChanged = snapshot?.modifiedAt !== undefined && fileMtime() !== snapshot.modifiedAt;
199
+ let fileChanged = false;
200
+ if (snapshot !== undefined && now - lastStatAt > STAT_THROTTLE_MS) {
201
+ lastStatAt = now;
202
+ const mtime = fileMtime();
203
+ fileChanged = mtime !== lastMtime;
204
+ lastMtime = mtime;
205
+ }
172
206
  if (!snapshot || expired || fileChanged) {
173
207
  snapshot = readFromDisk();
174
208
  readAt = now;
209
+ lastStatAt = now;
210
+ lastMtime = fileMtime();
175
211
  }
176
212
  return snapshot;
177
213
  }
@@ -184,13 +220,18 @@ export function createModelCatalog(options = {}) {
184
220
  }
185
221
  }
186
222
 
187
- /** 强制重新读取,并(可选)用网关校验可用性。 */
223
+ /**
224
+ * 强制重新读取,并(可选)用网关校验可用性。
225
+ *
226
+ * `resolveKey` 允许返回 Promise:免客户端模式下缓存的 key 可能已过期,
227
+ * 刷新时现换一个再校验,否则会拿过期 key 去请求网关、误报「校验失败」。
228
+ */
188
229
  async function refresh() {
189
230
  const fresh = readFromDisk();
190
231
  if (verifyWithGateway) {
191
232
  let key = '';
192
233
  try {
193
- key = resolveKey() || '';
234
+ key = (await resolveKey()) || '';
194
235
  } catch {
195
236
  key = '';
196
237
  }
@@ -212,6 +253,8 @@ export function createModelCatalog(options = {}) {
212
253
  }
213
254
  snapshot = fresh;
214
255
  readAt = Date.now();
256
+ lastStatAt = readAt;
257
+ lastMtime = fileMtime();
215
258
  return snapshot;
216
259
  }
217
260
 
@@ -147,7 +147,11 @@ function normalizeModelEntry(found) {
147
147
  input: input && input.length > 0 ? input : undefined,
148
148
  contextWindow: typeof found.contextWindow === 'number' ? found.contextWindow : undefined,
149
149
  maxTokens: typeof found.maxTokens === 'number' ? found.maxTokens : undefined,
150
- toolCalling: found.toolCalling === true ? true : undefined,
150
+ // `toolCalling` / `thinkingMode` 以前读了不用(描述符里没有对应字段),
151
+ // 现在它们参与两件事:设置页标注「不支持工具」,以及判断是否真的能展示思考等级。
152
+ toolCalling: typeof found.toolCalling === 'boolean' ? found.toolCalling : undefined,
153
+ thinkingMode: typeof found.thinkingMode === 'string' ? found.thinkingMode : undefined,
154
+ reasoning: typeof found.reasoning === 'boolean' ? found.reasoning : undefined,
151
155
  priceLabel: typeof found.priceLabel === 'string' ? found.priceLabel : undefined,
152
156
  };
153
157
  }
@@ -194,20 +198,6 @@ export function readClientCatalog(options = {}) {
194
198
  return { path: modelsPath, modifiedAt, models, visibleModelIds };
195
199
  }
196
200
 
197
- /**
198
- * 读取客户端的模型目录,返回 scnet provider 下某个模型的元数据。
199
- * 目录不存在或没有该模型时返回 undefined(调用方使用内置默认值)。
200
- *
201
- * @param {string} modelId
202
- * @param {{ modelsPath?: string }} [options]
203
- * @returns {{ id: string, name?: string, input?: string[], contextWindow?: number, maxTokens?: number, toolCalling?: boolean, priceLabel?: string } | undefined}
204
- */
205
- export function readClientModelMeta(modelId, options = {}) {
206
- const catalog = readClientCatalog(options);
207
- const target = modelId.trim().toLowerCase();
208
- return catalog?.models.find((m) => m.id.toLowerCase() === target);
209
- }
210
-
211
201
  /** 把手机号抹成 138****1234(客户端本身也可能已脱敏)。 */
212
202
  function maskPhone(value) {
213
203
  if (typeof value !== 'string') return undefined;
@@ -23,8 +23,6 @@ import path from 'node:path';
23
23
 
24
24
  import {
25
25
  PENDING_TTL_MS,
26
- PLATFORM_BASE_URL,
27
- cookieHeader,
28
26
  ensureApiKey,
29
27
  logout,
30
28
  normalizeCookies,
@@ -32,7 +30,7 @@ import {
32
30
  sessionFilePath,
33
31
  webLoginUrl,
34
32
  } from './scnet-platform.js';
35
- import { createWebLogin, mapLoginError } from './scnet-web-login.js';
33
+ import { createWebLogin, mapLoginError, translatePlatformMessage } from './scnet-web-login.js';
36
34
  import { maskKey } from './scnet-credential.js';
37
35
 
38
36
  /** 平台会话换来的 key 缓存 6 天;过期或被清空时自动重换。 */
@@ -101,6 +99,13 @@ export function createLoginManager(options = {}) {
101
99
  let pending;
102
100
  let phase = session ? 'authorized' : 'idle';
103
101
  let lastError;
102
+ /**
103
+ * 最近一次登录失败的原始码与原文(诊断用)。
104
+ *
105
+ * 界面只显示中文提示;但「平台到底回了什么」是排查这类
106
+ * 「明明是短信登录却收到密码错误」问题的唯一线索,必须留下来。
107
+ */
108
+ let lastFailure;
104
109
  let inflight;
105
110
  let lastValidatedAt = 0;
106
111
  /**
@@ -125,6 +130,8 @@ export function createLoginManager(options = {}) {
125
130
  lastValidatedAt: lastValidatedAt ? new Date(lastValidatedAt).toISOString() : undefined,
126
131
  pending: pending ? { startedAt: pending.startedAt, expiresAt: pending.expiresAt } : undefined,
127
132
  error: lastError,
133
+ /** 原始失败信息(码 + 平台原文),供诊断;不含任何凭据。 */
134
+ lastFailure,
128
135
  loginPageUrl: webLoginUrl(),
129
136
  };
130
137
  }
@@ -156,10 +163,17 @@ export function createLoginManager(options = {}) {
156
163
  return { ok: false, error: reason, ...snapshot() };
157
164
  }
158
165
 
159
- /** 会话到手后立刻换 API Key。失败时保留会话(用户不必重新登录)。 */
160
- async function finishAuthorization(raw, signal) {
166
+ /**
167
+ * 会话到手后立刻换 API Key。失败时保留会话(用户不必重新登录)。
168
+ *
169
+ * @param {'register' | 'login'} [via] 这条会话是「自动注册」还是「登录」拿到的
170
+ */
171
+ async function finishAuthorization(raw, signal, via) {
161
172
  const startedAt = epoch;
162
- const keyed = await ensureApiKey({ cookies: raw.cookies, signal });
173
+ // 带上 accountId:新账号首次换 key 会回「模型服务尚未开通」,
174
+ // ensureApiKey 需要用它在平台侧开通服务后重试。
175
+ const accountId = raw.account?.id ?? raw.account?.accountId ?? raw.account?.userId;
176
+ const keyed = await ensureApiKey({ cookies: raw.cookies, accountId, signal });
163
177
  // 换 key 期间用户又点了退出/重新登录:这次结果不再代表当前意图。
164
178
  if (startedAt !== epoch) return { ok: false, error: '登录已被取消', ...snapshot() };
165
179
  if (!keyed.ok) {
@@ -178,15 +192,15 @@ export function createLoginManager(options = {}) {
178
192
  });
179
193
  lastValidatedAt = now.getTime();
180
194
  logger.info(
181
- `免客户端登录完成:${raw.account?.userName || raw.account?.userId || '未知账号'}(来源:${
182
- '短信登录'
195
+ `免客户端登录完成:${raw.account?.userName || raw.account?.userId || '未知账号'}(${
196
+ via === 'register' ? '短信登录 · 已自动注册新账号' : '短信登录'
183
197
  },key=${maskKey(keyed.key)})`,
184
198
  );
185
199
  return { ok: true, ...snapshot() };
186
200
  }
187
201
 
188
202
  /** 把任意会话来源收敛成「验证身份 → 换 key」。 */
189
- async function authorizeFromCookies(cookies, source, signal) {
203
+ async function authorizeFromCookies(cookies, source, signal, via) {
190
204
  const verified = await resolveSession({ cookies, signal });
191
205
  if (!verified.ok) {
192
206
  return fail(
@@ -203,6 +217,7 @@ export function createLoginManager(options = {}) {
203
217
  source,
204
218
  },
205
219
  signal,
220
+ via,
206
221
  );
207
222
  }
208
223
 
@@ -236,7 +251,9 @@ export function createLoginManager(options = {}) {
236
251
  return undefined;
237
252
  }
238
253
  lastValidatedAt = Date.now();
239
- const keyed = await ensureApiKey({ cookies: verified.cookies, signal });
254
+ const renewId =
255
+ verified.user?.id ?? verified.user?.accountId ?? session.account?.id ?? session.account?.accountId;
256
+ const keyed = await ensureApiKey({ cookies: verified.cookies, accountId: renewId, signal });
240
257
  if (startedAt !== epoch) return undefined;
241
258
  if (!keyed.ok) {
242
259
  phase = 'failed';
@@ -265,15 +282,6 @@ export function createLoginManager(options = {}) {
265
282
  return inflight;
266
283
  },
267
284
 
268
- /** 开一张新的网页登录表单会话(重置验证码/风控状态)。 */
269
- beginWebLogin() {
270
- webLogin = createWebLogin();
271
- pending = { startedAt: Date.now(), expiresAt: new Date(Date.now() + PENDING_TTL_MS).toISOString() };
272
- if (phase !== 'authorized') phase = 'pending';
273
- lastError = undefined;
274
- return { ok: true, ...snapshot() };
275
- },
276
-
277
285
  /** 图形验证码(短信登录/风控时用)。返回 data URL,不落盘。 */
278
286
  async captcha() {
279
287
  if (!webLogin) webLogin = createWebLogin();
@@ -307,17 +315,29 @@ export function createLoginManager(options = {}) {
307
315
  mapped === 'captcha-invalid'
308
316
  ? '短信验证码不正确或已失效,请重新获取'
309
317
  : mapped === 'auth-failed'
310
- ? '手机号或验证码不正确'
311
- : mapped === 'invalid-params'
312
- ? '参数不完整,请检查手机号/验证码'
313
- : mapped === 'too-many-attempts'
314
- ? '尝试次数过多,请稍后再试'
315
- : result.message || '登录失败';
318
+ ? translatePlatformMessage(result.message) || '手机号或验证码不正确'
319
+ : mapped === 'user-not-exist'
320
+ ? '该手机号尚未注册 SCNet 账号,请先在平台注册'
321
+ : mapped === 'invalid-params'
322
+ ? '参数不完整,请检查手机号/验证码'
323
+ : mapped === 'too-many-attempts'
324
+ ? '尝试次数过多,请稍后再试'
325
+ : mapped === 'server-busy'
326
+ ? '平台繁忙,请稍后重试'
327
+ // 兜底也要说人话:平台原文多是英文,不能直接甩给用户。
328
+ : translatePlatformMessage(result.message) || '登录失败';
329
+ // 记下原始码与原文,供状态文件/诊断用(界面只显示上面的中文)。
330
+ lastFailure = {
331
+ code: result.code !== undefined ? String(result.code) : undefined,
332
+ rawMessage: result.message,
333
+ reason: result.reason,
334
+ at: new Date().toISOString(),
335
+ };
316
336
  // 验证码/风控状态可能需要重开表单。
317
337
  if (mapped === 'too-many-attempts' || mapped === 'captcha-invalid') webLogin = undefined;
318
338
  return fail(message);
319
339
  }
320
- return authorizeFromCookies(result.cookies, 'web', form.signal);
340
+ return authorizeFromCookies(result.cookies, 'web', form.signal, result.via);
321
341
  },
322
342
 
323
343
  /** 平台会话是否还活着。 */
@@ -363,18 +383,6 @@ export function createLoginManager(options = {}) {
363
383
  logger.info('已退出免客户端登录(本地会话已清除)');
364
384
  return snapshot();
365
385
  },
366
-
367
- /** 给「测试连接」用:只读当前 key 与账号,不暴露密文。 */
368
- describe() {
369
- return {
370
- available: Boolean(session?.apiKey),
371
- source: session?.source,
372
- account: session?.account,
373
- cookieHeaderPresent: Boolean(cookieHeader(session?.cookies)),
374
- };
375
- },
376
386
  };
377
387
  }
378
388
 
379
- export { PLATFORM_BASE_URL, sessionFilePath };
380
-
@@ -63,6 +63,23 @@ export const THINKING_LEVEL_MAP = Object.freeze({
63
63
  max: 'max',
64
64
  });
65
65
 
66
+ /**
67
+ * 判断客户端目录里的模型是否真的支持思考。
68
+ *
69
+ * 目录里 `reasoning: false` 但 `thinkingMode: "levels"` 的模型(当前 SCNet-Max
70
+ * 就是这种)实际会返回 `reasoning_content`,客户端 UI 也按「分等级」展示;
71
+ * 所以 `thinkingMode` 是比 `reasoning` 更诚实的信号。两个都没有时保持旧行为
72
+ * (声明支持),因为网关不认 `reasoning_effort` 时也只会忽略它。
73
+ *
74
+ * @param {{ reasoning?: boolean, thinkingMode?: string } | undefined} meta
75
+ */
76
+ function catalogSupportsReasoning(meta) {
77
+ if (!meta) return true;
78
+ if (meta.thinkingMode === 'levels') return true;
79
+ if (typeof meta.reasoning === 'boolean') return meta.reasoning;
80
+ return true;
81
+ }
82
+
66
83
  /**
67
84
  * 构造 pi-ai 的模型描述符。
68
85
  *
@@ -70,7 +87,8 @@ export const THINKING_LEVEL_MAP = Object.freeze({
70
87
  * modelId?: string,
71
88
  * modelName?: string,
72
89
  * baseUrl: string,
73
- * meta?: { id?: string, name?: string, input?: string[], contextWindow?: number, maxTokens?: number, toolCalling?: boolean, priceLabel?: string },
90
+ * provider?: string,
91
+ * meta?: { id?: string, name?: string, input?: string[], contextWindow?: number, maxTokens?: number, toolCalling?: boolean, thinkingMode?: string, reasoning?: boolean, priceLabel?: string },
74
92
  * contextWindow?: number,
75
93
  * maxTokens?: number,
76
94
  * inputImages?: boolean,
@@ -84,17 +102,27 @@ export function buildModel(options) {
84
102
  const signedMaxTokens = options.maxTokens && options.maxTokens > 0 ? options.maxTokens : undefined;
85
103
  const contextWindow = signedContextWindow ?? meta?.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
86
104
  const maxTokens = signedMaxTokens ?? meta?.maxTokens ?? DEFAULT_MAX_TOKENS;
87
- const input = options.inputImages === false ? ['text'] : meta?.input?.length ? [...meta.input] : ['text', 'image'];
105
+ // 目录里有 `input` 就以目录为准(没写就是纯文本);连元数据都没有时才按
106
+ // 「SCNet 网关至少支持文本+图片」给个乐观默认值,避免凭空给陌生模型加图片能力。
107
+ const input =
108
+ options.inputImages === false
109
+ ? ['text']
110
+ : meta?.input?.length
111
+ ? [...meta.input]
112
+ : meta
113
+ ? ['text']
114
+ : ['text', 'image'];
115
+ const supportsReasoning = options.reasoning !== false && catalogSupportsReasoning(meta);
88
116
 
89
117
  return {
90
118
  id: modelId,
91
119
  name: options.modelName?.trim() || meta?.name || modelId,
92
120
  api: 'openai-completions',
93
- provider: 'scnet',
121
+ provider: options.provider || 'scnet',
94
122
  baseUrl: options.baseUrl,
95
123
  input,
96
- reasoning: options.reasoning !== false,
97
- ...(options.reasoning === false ? {} : { thinkingLevelMap: { ...THINKING_LEVEL_MAP } }),
124
+ reasoning: supportsReasoning,
125
+ ...(supportsReasoning ? { thinkingLevelMap: { ...THINKING_LEVEL_MAP } } : {}),
98
126
  cost: { ...NO_COST },
99
127
  contextWindow,
100
128
  // 只保证「输出不超过上下文」这一条一致性;不再按模型 id 打 262144/32768 的补丁