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.
- package/README.md +70 -7
- package/cordis.patch.yml +3 -2
- package/lib/client.js +45 -11
- package/lib/index.js +159 -38
- package/lib/scnet-catalog.js +54 -11
- package/lib/scnet-credential.js +5 -15
- package/lib/scnet-login.js +46 -38
- package/lib/scnet-models.js +33 -5
- package/lib/scnet-platform.js +315 -329
- package/lib/scnet-web-login.js +479 -404
- package/package.json +4 -2
- package/screenshots.json +4 -0
- package/tools/check-classify.mjs +63 -0
- package/tools/check-fetch.mjs +91 -0
- package/tools/check.mjs +156 -0
- package/tools/fake-gateway.mjs +73 -0
- package/tools/repro/asar-extract.mjs +84 -0
- package/tools/repro/direct-capabilities.mjs +77 -0
- package/tools/repro/direct-effort.mjs +59 -0
- package/tools/repro/direct-probe.mjs +82 -0
- package/tools/repro/direct-verify.mjs +229 -0
- package/tools/repro/effort-burst.mjs +25 -0
- package/tools/repro/effort-effect.mjs +29 -0
- package/tools/repro/image-tools-burst.mjs +28 -0
- package/tools/repro/pixel.png +0 -0
- package/tools/repro/probe-scnet.mjs +61 -0
- package/tools/repro/scnet-chat.mjs +77 -0
- package/tools/repro/scnet-llm-call.mjs +186 -0
- package/tools/repro/search-asar.mjs +58 -0
- package/tools/repro/slice-asar.mjs +13 -0
package/lib/scnet-catalog.js
CHANGED
|
@@ -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
|
|
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
|
|
155
|
+
modifiedAt,
|
|
133
156
|
updatedAt,
|
|
134
157
|
};
|
|
135
158
|
}
|
|
136
159
|
return {
|
|
137
|
-
models: [{
|
|
160
|
+
models: [{ ...BUILTIN_MODEL }],
|
|
138
161
|
source: 'builtin',
|
|
139
162
|
path: catalog?.path,
|
|
140
|
-
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
|
|
174
|
+
modifiedAt,
|
|
152
175
|
updatedAt,
|
|
153
176
|
};
|
|
154
177
|
}
|
|
155
178
|
return {
|
|
156
|
-
models: [{
|
|
179
|
+
models: [{ ...BUILTIN_MODEL }],
|
|
157
180
|
source: catalog ? 'client-catalog-empty' : 'builtin',
|
|
158
181
|
path: catalog?.path,
|
|
159
|
-
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
package/lib/scnet-credential.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/lib/scnet-login.js
CHANGED
|
@@ -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
|
-
/**
|
|
160
|
-
|
|
166
|
+
/**
|
|
167
|
+
* 会话到手后立刻换 API Key。失败时保留会话(用户不必重新登录)。
|
|
168
|
+
*
|
|
169
|
+
* @param {'register' | 'login'} [via] 这条会话是「自动注册」还是「登录」拿到的
|
|
170
|
+
*/
|
|
171
|
+
async function finishAuthorization(raw, signal, via) {
|
|
161
172
|
const startedAt = epoch;
|
|
162
|
-
|
|
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
|
|
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 === '
|
|
312
|
-
? '
|
|
313
|
-
: mapped === '
|
|
314
|
-
? '
|
|
315
|
-
:
|
|
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
|
-
|
package/lib/scnet-models.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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:
|
|
97
|
-
...(
|
|
124
|
+
reasoning: supportsReasoning,
|
|
125
|
+
...(supportsReasoning ? { thinkingLevelMap: { ...THINKING_LEVEL_MAP } } : {}),
|
|
98
126
|
cost: { ...NO_COST },
|
|
99
127
|
contextWindow,
|
|
100
128
|
// 只保证「输出不超过上下文」这一条一致性;不再按模型 id 打 262144/32768 的补丁
|