scnet-magpie 0.0.0-stage → 1.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SCNet 超算互联网
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,77 @@
1
- # Temporary Holding Version
1
+ # SCNet × Magpie 插件
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 让 [Magpie](https://usemagpie.ai) 网关出现 **SCNet 超算互联网** 模型服务:
4
+ 各 Agent(Claude Code、Codex、Gemini CLI、OpenCode…)用你的 SCNet 账号
5
+ 直接使用超算互联网的模型(DeepSeek、Kimi、GLM、Qwen、MiniMax、SCNet-Max 等)。
6
+
7
+ 协议实现完全对照 SCNet 客户端主进程,与官方客户端同一套接入方式。
8
+
9
+ ## 安装
10
+
11
+ ```sh
12
+ magpie plugin add scnet-magpie
13
+ ```
14
+
15
+ 或从 GitHub 安装:
16
+
17
+ ```sh
18
+ magpie plugin add github:zhang3776813/scnet-magpie
19
+ ```
20
+
21
+ 或 Magpie → Plugins → 添加本地目录(开发模式)。
22
+
23
+ ## 登录
24
+
25
+ ```sh
26
+ magpie plugin login scnet
27
+ ```
28
+
29
+ 会要求输入 SCNet 账号密码与图形验证码(与 SCNet 客户端登录一致)。
30
+ 登录成功后插件自动向平台换取 LLM API Key(`sk-...`),存入本机
31
+ `~/.config/magpie/plugin-auth.json`。
32
+
33
+ 也可以直接粘贴 API Key(在 SCNet 控制台或客户端创建)。
34
+
35
+ ## 给 Agent 选模型
36
+
37
+ Magpie → Agents → 点某个 Agent 的模型 → 从「超算互联网 SCNet」分组里选。
38
+ 开新会话生效。
39
+
40
+ ```sh
41
+ magpie plugin # 查看登录状态
42
+ magpie provider test scnet # 测试连通
43
+ ```
44
+
45
+ ## 机制
46
+
47
+ | 项 | 实现 |
48
+ |---|---|
49
+ | 平台登录 | `POST https://www.scnet.cn/ac/api/auth/loginDesktopClient.action`(账号密码 + 图形验证码) |
50
+ | 平台会话 | 登录返回的 `Token` cookie + `myCookie`,存于 magpie 凭证库 |
51
+ | API Key | `POST https://www.scnet.cn/acx/llm/api-key/createActivityApiKey`(平台会话认证,优先);被拒(API010010)时走客户端同款团队 Key 流程(query/detail/create) |
52
+ | 聊天端点 | `POST https://api.scnet.cn/api/llm/v1/chat/completions`(OpenAI 兼容;Magpie 网关自动做 Anthropic 等协议互转) |
53
+ | 平台会话认证 | `/acx/...` 平台接口靠**全量 Cookie** 认证(登录 set-cookie,含 `Token=...`);单独 `Token` 头会 401 |
54
+ | 客户端身份头 | 所有请求带 `User-Agent: SCNet-Client`、`source: Electron`、`version: 4.1.0`(与客户端 axios 默认头和网络层 UA 一致,取自客户端 app.asar) |
55
+ | 请求头 | `Authorization: Bearer <sk-key>` |
56
+ | Key 轮换 | 请求 401 → 用平台会话向 `www.scnet.cn` 换新 Key 并重试一次(与客户端 `createScnetModelStream` 一致);`auth.refresh` 供 Magpie 定期续期;换 Key 失败 → 重登信号 |
57
+ | 模型列表 | 登录后 `GET https://www.scnet.cn/acx/llm/api/console/model` 实时拉取;只保留 `modelType==="LLM"`(剔除文档/OCR 服务条目);未登录用内置兜底表 |
58
+
59
+ ## 故障排查
60
+
61
+ | 现象 | 处理 |
62
+ |---|---|
63
+ | 401 / 提示重新登录 | Key 或平台会话失效 → `magpie plugin login scnet` |
64
+ | 验证码错误 | 登录时验证码过期/输错 → 重试登录 |
65
+ | 模型列表为空 | 登录后自动拉取真实列表;未登录只显示兜底表 |
66
+ | 余额不足 | SCNet 平台账号计费,按平台规则 |
67
+
68
+ ## 开发
69
+
70
+ ```sh
71
+ node test/smoke.mjs # 协议自检(不登录,只验证端点可达性)
72
+ ```
73
+
74
+ ## 凭证安全
75
+
76
+ 每个用户登录**自己的** SCNet 账号;凭证只保存在本机 magpie 的
77
+ `plugin-auth.json`,不共享、不导出、不上传。
Binary file
package/index.mjs ADDED
@@ -0,0 +1,463 @@
1
+ // SCNet x magpie 供应商插件
2
+ // 让 Magpie 网关出现 SCNet(超算互联网)模型服务,各 Agent(Claude Code、
3
+ // Codex、Gemini CLI、OpenCode…)直接用超算互联网账号使用模型。
4
+ //
5
+ // 协议实现完全对照 SCNet 客户端主进程(D:\install_path\scnet-client):
6
+ // - 平台登录:POST /ac/api/auth/loginDesktopClient.action(账号密码 + 图形验证码)
7
+ // - 平台会话:Cookie 里的 Token(acToken)+ myCookie
8
+ // - API key:POST /acx/llm/api-key/createActivityApiKey(优先),失败走
9
+ // 团队 key 流程(/llm/api-key/query + /llm/api-key/create),与客户端一致
10
+ // - 聊天:OpenAI 兼容 chat/completions → https://api.scnet.cn/api/llm/v1
11
+ // (Magpie 网关自动完成 Anthropic 等协议互转),Authorization: Bearer <sk-key>
12
+ // - 401 = key 失效:自动换新 key 重试一次(与客户端 createScnetModelStream 一致)
13
+ // - auth.refresh:key 到期/失效时由 magpie 定期调起换新
14
+ // - 模型列表:登录后 GET /acx/llm/api/console/model 实时拉取
15
+ //
16
+ // 凭证只存本机 magpie 的 plugin-auth.json,每个用户登录自己的账号,不共享。
17
+
18
+ import {
19
+ PLATFORM_BASE,
20
+ LLM_BASE,
21
+ SCNET_CLIENT_UA,
22
+ fetchCaptcha,
23
+ loginWithPassword,
24
+ fetchCurrentUser,
25
+ obtainApiKey,
26
+ fetchModels,
27
+ ScnetError,
28
+ } from "./src/api.mjs";
29
+ import { staticModelList, STATIC_MODELS } from "./src/models.mjs";
30
+
31
+ const PROVIDER = "scnet";
32
+ const PROVIDER_NAME = "超算互联网 SCNet";
33
+ const NPM = "@ai-sdk/openai-compatible";
34
+ const DEFAULT_MODEL_ID = "SCNet-Max";
35
+
36
+ // —— 登录态形状 ——
37
+ // token 平台会话 Token(acToken,Cookie 的 Token= 值)
38
+ // myCookie 其余平台 Cookie
39
+ // api_key LLM API key(sk- 开头,发给网关)
40
+ // accountId 账号 id(卡片显示名)
41
+
42
+ function sessionOf(auth) {
43
+ if (!auth || typeof auth !== "object") return null;
44
+ // type:"api" 由 host 的 save() 写成 { type, key, metadata };
45
+ // type:"oauth" 是密码登录写的形状(key 在 refresh/api_key)
46
+ const apiKey =
47
+ typeof auth.key === "string" && auth.key.trim()
48
+ ? auth.key.trim()
49
+ : typeof auth.api_key === "string" && auth.api_key.trim()
50
+ ? auth.api_key.trim()
51
+ : typeof auth.refresh === "string" && auth.refresh.startsWith("sk-")
52
+ ? auth.refresh
53
+ : undefined;
54
+ return {
55
+ token: typeof auth.token === "string" ? auth.token : undefined,
56
+ myCookie: typeof auth.myCookie === "string" ? auth.myCookie : undefined,
57
+ cookie: typeof auth.cookie === "string" ? auth.cookie : undefined,
58
+ apiKey,
59
+ accountId: typeof auth.accountId === "string" ? auth.accountId : undefined,
60
+ metadata: auth.metadata ?? undefined,
61
+ userInfo: auth.userInfo ?? undefined,
62
+ type: typeof auth.type === "string" ? auth.type : undefined,
63
+ };
64
+ }
65
+
66
+ function accountLabel(auth) {
67
+ const s = sessionOf(auth);
68
+ if (!s) return "";
69
+ // api key 登录:备注就是显示名(host 存在 metadata.label)
70
+ const note = s.metadata?.label;
71
+ if (typeof note === "string" && note.trim()) return note.trim();
72
+ const u = s.userInfo;
73
+ if (u && typeof u === "object") {
74
+ return (
75
+ u.fullName ||
76
+ u.userName ||
77
+ u.email ||
78
+ u.mobilephoneNum ||
79
+ s.accountId ||
80
+ "SCNet 账号"
81
+ );
82
+ }
83
+ return s.accountId || "SCNet 账号";
84
+ }
85
+
86
+ // —— 插件主体 ——
87
+
88
+ export const ScnetPlugin = async () => ({
89
+ config: async (cfg) => {
90
+ cfg.provider ??= {};
91
+ cfg.provider[PROVIDER] ??= {
92
+ name: PROVIDER_NAME,
93
+ npm: NPM,
94
+ api: LLM_BASE,
95
+ models: STATIC_MODELS,
96
+ };
97
+ },
98
+
99
+ auth: {
100
+ provider: PROVIDER,
101
+
102
+ methods: [
103
+ // ① 账号密码登录(含图形验证码),与 SCNet 客户端登录一致
104
+ // 流程(对照 OpenCode/magpie host):
105
+ // prompts 收集 用户名/密码 → authorize() 取验证码、内嵌图片并
106
+ // 返回 method:"code" → callback(验证码) 完成登录。
107
+ {
108
+ type: "oauth",
109
+ label: "通过 SCNet 账号密码登录",
110
+ prompts: [
111
+ {
112
+ type: "text",
113
+ key: "username",
114
+ message: "SCNet 账号",
115
+ placeholder: "手机号 / 用户名",
116
+ validate: (v) =>
117
+ typeof v !== "string" || !v.trim() ? "请输入 SCNet 账号" : undefined,
118
+ },
119
+ {
120
+ type: "password",
121
+ key: "password",
122
+ message: "密码",
123
+ placeholder: "登录密码",
124
+ validate: (v) =>
125
+ typeof v !== "string" || !v.trim() ? "请输入密码" : undefined,
126
+ },
127
+ ],
128
+ async authorize(inputs = {}) {
129
+ const username = String(inputs?.username ?? "").trim();
130
+ const password = String(inputs?.password ?? "");
131
+ if (!username || !password) {
132
+ return { type: "failed", error: "缺少用户名或密码" };
133
+ }
134
+ // 取验证码(含 captchaKey cookie,callback 时回传)
135
+ let captcha;
136
+ try {
137
+ captcha = await fetchCaptcha();
138
+ } catch {
139
+ return { type: "failed", error: "连不上 SCNet 服务器,请检查网络后重试" };
140
+ }
141
+ const captchaStep = { image: captcha.imageBase64, cookie: captcha.cookie };
142
+ // 验证码图片内嵌在 instructions 里,让用户在终端/页面里直接读
143
+ return {
144
+ url: `${PLATFORM_BASE}/login`,
145
+ instructions:
146
+ `请输入下图验证码(共 4 位):\n\n` +
147
+ `![验证码](${captchaStep.image})\n\n` +
148
+ `验证码图片已内嵌(若无法显示请打开 ${PLATFORM_BASE} 后重试)。`,
149
+ method: "code",
150
+ async callback(code, { signal } = {}) {
151
+ const captchaText = String(code ?? "").trim();
152
+ if (!captchaText) {
153
+ return { type: "failed", error: "请输入验证码" };
154
+ }
155
+ try {
156
+ return await doPasswordLogin(
157
+ username,
158
+ password,
159
+ captchaText,
160
+ captchaStep.cookie
161
+ );
162
+ } catch (err) {
163
+ const msg = err instanceof Error ? err.message : "登录失败";
164
+ const captchaHint = /验证码|validate|captha|captcha/i.test(msg)
165
+ ? "\n(验证码错误或过期,请重试)"
166
+ : "";
167
+ return { type: "failed", error: msg + captchaHint };
168
+ }
169
+ },
170
+ };
171
+ },
172
+ },
173
+
174
+ // ② API key 直接粘贴(在 SCNet 控制台 / 客户端创建)
175
+ // 契约(对照 OpenCode/magpie host 的 apiKey handler):
176
+ // - magpie 用 type:"api" + placeholder 自己显示 key 输入框;
177
+ // - prompts 是它接着问的问题,authorize(inputs) 里做校验;
178
+ // - key 由 host 收走并写进 save(),authorize 不返回 key,
179
+ // 存的形状是 { type:"api", key, metadata }。
180
+ {
181
+ type: "api",
182
+ label: "SCNet API Key",
183
+ placeholder: "sk-...",
184
+ prompts: [
185
+ {
186
+ type: "text",
187
+ key: "label",
188
+ message: "账号备注 · 每个账号请使用不同名称(同名登录会更新原账号)",
189
+ placeholder: "例如:主账号 / 团队 key / 活动套餐",
190
+ validate: (value) =>
191
+ typeof value !== "string" ||
192
+ !value.trim() ||
193
+ value.trim().length > 60 ||
194
+ /[\x00-\x1f\x7f]/.test(value)
195
+ ? "请输入 1–60 字的账号备注,不要填写 API Key"
196
+ : undefined,
197
+ },
198
+ {
199
+ type: "select",
200
+ key: "scope",
201
+ message: "可用模型范围 · 以该 Key 在控制台的授权为准",
202
+ options: [
203
+ { label: "全部模型(跟随控制台授权)", value: "all" },
204
+ { label: "只暴露默认模型 SCNet-Max", value: "default", hint: "SCNet-Max" },
205
+ ],
206
+ },
207
+ ],
208
+ async authorize(inputs = {}) {
209
+ // 兼容:host 有时会把 key 放进 inputs(旧版 CLI / 测试脚本)
210
+ const fromInputs =
211
+ typeof inputs === "string"
212
+ ? inputs.trim()
213
+ : String(inputs?.key ?? inputs?.apiKey ?? inputs?.api_key ?? "").trim();
214
+ const label = String(inputs?.label ?? "").trim();
215
+ if (inputs?.label !== undefined && (!label || label.length > 60)) {
216
+ return { type: "failed", error: "请输入 1–60 字的账号备注,不要填写 API Key" };
217
+ }
218
+ const scope = inputs?.scope === "default" ? "default" : "all";
219
+ if (inputs?.scope !== undefined && !["all", "default"].includes(inputs.scope)) {
220
+ return { type: "failed", error: "请选择模型范围" };
221
+ }
222
+ const result = {
223
+ type: "success",
224
+ metadata: {
225
+ label: label || "SCNet API Key",
226
+ scope,
227
+ },
228
+ };
229
+ // 旧 CLI 把 key 传进来时才代写,正常由 host 的 save() 落库
230
+ if (fromInputs) result.key = fromInputs;
231
+ return result;
232
+ },
233
+ },
234
+ ],
235
+
236
+ // 每个请求:注入 Bearer <sk-key>;401 时自动换新 key 重试一次
237
+ async loader(getAuth) {
238
+ const auth = await getAuth();
239
+ if (!auth) return {};
240
+ const s = sessionOf(auth);
241
+ if (!s?.apiKey) return {};
242
+ return {
243
+ baseURL: LLM_BASE,
244
+ async fetch(input, init) {
245
+ const headers = new Headers(init?.headers);
246
+ const authNow = await getAuth();
247
+ const sNow = sessionOf(authNow);
248
+ const key = sNow?.apiKey ?? s.apiKey;
249
+ if (!key) return fetch(input, init);
250
+ if (!headers.has("authorization")) {
251
+ headers.set("Authorization", `Bearer ${key}`);
252
+ }
253
+ // 与 SCNet 客户端一致:网关请求也带客户端 UA
254
+ if (!headers.has("user-agent")) {
255
+ headers.set("User-Agent", SCNET_CLIENT_UA);
256
+ }
257
+ let res = await fetch(input, { ...init, headers });
258
+ if (res.status !== 401) return res;
259
+ // key 失效 → 轮换刷新(与客户端 createScnetModelStream 一致)
260
+ res.body?.cancel().catch(() => {});
261
+ try {
262
+ // 用保存的平台会话向平台换新 key(新 key 会经 auth.refresh 写回存储)
263
+ const freshAuth = await getAuth();
264
+ const s2 = sessionOf(freshAuth);
265
+ const session = {
266
+ token: s2?.token ?? s.token,
267
+ myCookie: s2?.myCookie ?? s.myCookie,
268
+ cookie: s2?.cookie ?? s.cookie,
269
+ accountId: s2?.accountId ?? s.accountId,
270
+ };
271
+ if (!session.token && !session.myCookie && !session.cookie) {
272
+ throw new ScnetError("缺少平台会话");
273
+ }
274
+ const newKey = await obtainApiKey(session, { rejectedKey: key });
275
+ if (newKey && newKey !== key) {
276
+ _bumpKey(newKey); // 模块级最新 key,供 refresh 返回
277
+ }
278
+ } catch {
279
+ // 换 key 失败:重登信号
280
+ const out = new Headers(res.headers);
281
+ out.set("X-Magpie-Sign-In", "expired");
282
+ out.set("content-type", "application/json");
283
+ return new Response(JSON.stringify({ error: { message: "SCNet 登录已失效,请重新登录" } }), {
284
+ status: 401,
285
+ statusText: res.statusText,
286
+ headers: out,
287
+ });
288
+ }
289
+ const retryKey = _latestKey() || key;
290
+ const retryHeaders = new Headers(headers);
291
+ retryHeaders.set("Authorization", `Bearer ${retryKey}`);
292
+ if (!retryHeaders.has("user-agent")) {
293
+ retryHeaders.set("User-Agent", SCNET_CLIENT_UA);
294
+ }
295
+ const retry = await fetch(input, { ...init, headers: retryHeaders });
296
+ if (retry.status === 401) {
297
+ const out = new Headers(retry.headers);
298
+ out.set("X-Magpie-Sign-In", "expired");
299
+ out.set("content-type", "application/json");
300
+ return new Response(JSON.stringify({ error: { message: "SCNet 登录已失效,请重新登录" } }), {
301
+ status: 401,
302
+ statusText: retry.statusText,
303
+ headers: out,
304
+ });
305
+ }
306
+ return retry;
307
+ },
308
+ };
309
+ },
310
+
311
+ // 定期刷新:用平台会话换新 key(key 轮换机制)
312
+ async refresh(auth) {
313
+ const s = sessionOf(auth);
314
+ if (!s?.token && !s?.myCookie && !s?.cookie) return undefined;
315
+ try {
316
+ const newKey = await obtainApiKey({
317
+ token: s.token,
318
+ myCookie: s.myCookie,
319
+ cookie: s.cookie,
320
+ accountId: s.accountId,
321
+ });
322
+ if (!newKey) return undefined;
323
+ const label = accountLabel(auth);
324
+ return {
325
+ type: "success",
326
+ access: s.token,
327
+ refresh: newKey,
328
+ token: s.token,
329
+ myCookie: s.myCookie,
330
+ cookie: s.cookie,
331
+ api_key: newKey,
332
+ accountId: s.accountId,
333
+ accountName: label,
334
+ userInfo: s.userInfo,
335
+ };
336
+ } catch (err) {
337
+ if (err?.statusCode === 401 || err?.code === "auth.session_expired") {
338
+ return undefined; // 平台会话也失效 → magpie 标记需重登
339
+ }
340
+ return undefined; // 网络等暂时错误:保留旧 key
341
+ }
342
+ },
343
+ },
344
+
345
+ // 登录后拉取该账号真实模型列表;API key 登录时按 metadata.scope 收敛
346
+ provider: {
347
+ id: PROVIDER,
348
+ async models(provider, { auth } = {}) {
349
+ const modelBase = provider.models ?? {};
350
+ const s = sessionOf(auth);
351
+ // 仅 API key 登录(无平台会话)时按备注里选的范围收敛
352
+ if (s && !s.token && !s.myCookie && !s.cookie) {
353
+ if (s.metadata?.scope === "default" && DEFAULT_MODEL_ID in modelBase) {
354
+ return { ...modelBase, [DEFAULT_MODEL_ID]: modelBase[DEFAULT_MODEL_ID] };
355
+ }
356
+ }
357
+ if (!auth) return provider.models;
358
+ if (!s?.token && !s?.myCookie && !s?.cookie) return provider.models;
359
+ const session = {
360
+ token: s.token,
361
+ myCookie: s.myCookie,
362
+ cookie: s.cookie,
363
+ accountId: s.accountId,
364
+ };
365
+ let list;
366
+ try {
367
+ list = await fetchModels(session);
368
+ } catch (err) {
369
+ if (err?.statusCode === 401 || err?.code === "auth.session_expired") {
370
+ throw Object.assign(new Error("SCNet 登录已失效,请重新登录"), { signIn: "expired" });
371
+ }
372
+ return provider.models;
373
+ }
374
+ if (!list.length) return provider.models;
375
+ const base = modelBase;
376
+ const out = {};
377
+ for (const m of list) {
378
+ const prev = base[m.id] ?? {};
379
+ const vision =
380
+ m.modalities?.input?.includes("image") ||
381
+ m.inputModalities?.includes("image") ||
382
+ m.vision === true;
383
+ out[m.id] = {
384
+ ...prev,
385
+ id: m.id,
386
+ name: m.name ?? prev.name ?? m.id,
387
+ limit: prev.limit ?? (m.contextWindow
388
+ ? { context: m.contextWindow, output: m.maxTokens ?? 64000 }
389
+ : { context: 1048576, output: 64000 }),
390
+ reasoning: prev.reasoning ?? (m.reasoning ?? true),
391
+ tool_call: prev.tool_call ?? true,
392
+ modalities: vision
393
+ ? { input: ["text", "image"] }
394
+ : (prev.modalities ?? { input: ["text"] }),
395
+ cost: prev.cost,
396
+ };
397
+ }
398
+ return Object.keys(out).length ? out : provider.models;
399
+ },
400
+ },
401
+ });
402
+
403
+ // —— 密码登录流程 ——
404
+
405
+ async function doPasswordLogin(username, password, captchaText, captchaCookie) {
406
+ const first = await loginWithPassword(username, password, captchaText, {
407
+ captchaCookie,
408
+ });
409
+ const session = {
410
+ token: first.token,
411
+ myCookie: first.myCookie,
412
+ cookie: first.cookie,
413
+ accountId: first.accountId,
414
+ userInfo: first.userInfo,
415
+ };
416
+ let userInfo = first.userInfo;
417
+ try {
418
+ userInfo = await fetchCurrentUser(session);
419
+ session.accountId = userInfo.accountId ?? first.accountId;
420
+ } catch {
421
+ /* 拿不到用户信息也不阻塞 */
422
+ }
423
+ // 获取 API key
424
+ let apiKey;
425
+ try {
426
+ apiKey = await obtainApiKey(session);
427
+ } catch (err) {
428
+ // key 拿不到,但登录本身成功:允许只保存会话,模型请求时再补 key
429
+ if (err?.statusCode === 401) throw err;
430
+ }
431
+ const accountId = session.accountId || String(userInfo?.id ?? "");
432
+ const display = String(
433
+ userInfo?.fullName ?? userInfo?.userName ?? userInfo?.email ?? accountId
434
+ );
435
+ return {
436
+ type: "success",
437
+ access: session.token,
438
+ refresh: apiKey,
439
+ token: session.token,
440
+ myCookie: session.myCookie,
441
+ cookie: session.cookie,
442
+ api_key: apiKey,
443
+ accountId,
444
+ accountName: display,
445
+ userInfo,
446
+ };
447
+ }
448
+
449
+ // —— 模块级最新 key(loader 换 key 后,refresh 与后续请求用) ——
450
+
451
+ let latestKeyValue = "";
452
+
453
+ function _latestKey() {
454
+ return latestKeyValue;
455
+ }
456
+
457
+ function _bumpKey(key) {
458
+ if (typeof key === "string" && key) latestKeyValue = key;
459
+ }
460
+
461
+ export { _bumpKey, _latestKey };
462
+
463
+ export default ScnetPlugin;
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "scnet-magpie",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "SCNet 超算互联网官方 Magpie 插件:登录 SCNet 账号,让 Magpie 网关里的各 Agent 使用超算互联网的模型服务(DeepSeek、Kimi、GLM、Qwen、SCNet-Max 等)。",
5
+ "type": "module",
6
+ "main": "./index.mjs",
7
+ "exports": "./index.mjs",
8
+ "files": [
9
+ "index.mjs",
10
+ "src",
11
+ "assets/icon.png",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "keywords": [
16
+ "magpie",
17
+ "provider",
18
+ "auth",
19
+ "magpie-plugin",
20
+ "opencode",
21
+ "opencode-plugin",
22
+ "opencode-auth",
23
+ "scnet",
24
+ "超算互联网",
25
+ "sugon",
26
+ "llm"
27
+ ],
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/zhang3776813/scnet-magpie.git"
31
+ },
32
+ "homepage": "https://github.com/zhang3776813/scnet-magpie#readme",
33
+ "bugs": "https://github.com/zhang3776813/scnet-magpie/issues",
34
+ "engines": {
35
+ "node": ">=18"
36
+ },
37
+ "license": "MIT",
38
+ "magpie": {
39
+ "icon": "data:image/png;base64,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"
40
+ }
41
+ }
package/src/api.mjs ADDED
@@ -0,0 +1,455 @@
1
+ // SCNet 平台 API 客户端
2
+ // 实现与 SCNet 客户端主进程完全一致的协议:
3
+ // - 登录:POST /ac/api/auth/loginDesktopClient.action(账号密码 + 图形验证码)
4
+ // - 平台会话:返回的 Cookie 中的 Token(acToken)+ myCookie
5
+ // - API key:POST /acx/llm/api-key/createActivityApiKey(平台会话认证,返回 sk- 开头的 key)
6
+ // - 模型列表:GET /acx/llm/api/console/model(平台会话认证)
7
+ // - LLM 网关:https://api.scnet.cn/api/llm/v1(OpenAI 兼容,Bearer <sk-key>)
8
+
9
+ export const PLATFORM_BASE = "https://www.scnet.cn";
10
+ export const API_BASE = "https://www.scnet.cn";
11
+ export const LLM_BASE = "https://api.scnet.cn/api/llm/v1";
12
+
13
+ const CAPTCHA_URL = "/sso/captcha.action";
14
+
15
+ // —— 客户端身份头(与 SCNet 客户端保持一致,服务端据此识别客户端流量) ——
16
+ // 对照客户端主进程(app.asar dist-electron/main/index.js):
17
+ // - axios 实例默认头 {"Content-Type": "...", source: "Electron", version: app.getVersion()}
18
+ // 拦截器还会在每个请求上重设 version 头
19
+ // - 客户端网络层包装对 SCNet 请求显式设 User-Agent: "SCNet-Client"
20
+ // version 取自客户端 resources/app.asar 内 package.json(4.1.0)
21
+ export const SCNET_CLIENT_UA = "SCNet-Client";
22
+ export const SCNET_CLIENT_VERSION = "4.1.0";
23
+ const CLIENT_HEADERS = {
24
+ "User-Agent": SCNET_CLIENT_UA,
25
+ source: "Electron",
26
+ version: SCNET_CLIENT_VERSION,
27
+ };
28
+
29
+ // 密码 RSA 加密(与 SCNet 客户端同款:1024 位 PKCS1,base64 输出)
30
+ import { createPublicKey, publicEncrypt, constants } from "node:crypto";
31
+
32
+ export const SCNET_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
33
+ MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALaXEnbjI6fjy+t9W9AiO/KS0q+b/OZFS
34
+ +7ykinLbiriUx9P8BcuuHnVbXNiZp5jW70eVGBtX4DhGUPzJa1YT/8CAwEAAQ==
35
+ -----END PUBLIC KEY-----`;
36
+
37
+ export function encryptPassword(password) {
38
+ const key = createPublicKey(SCNET_PUBLIC_KEY);
39
+ return publicEncrypt(
40
+ { key, padding: constants.RSA_PKCS1_PADDING },
41
+ Buffer.from(String(password), "utf8")
42
+ ).toString("base64");
43
+ }
44
+
45
+ export class ScnetError extends Error {
46
+ constructor(message, { statusCode, code, retryable = false } = {}) {
47
+ super(message);
48
+ this.name = "ScnetError";
49
+ this.statusCode = statusCode;
50
+ this.code = code;
51
+ this.retryable = retryable;
52
+ }
53
+ }
54
+
55
+ // 解析平台接口统一响应 { code, msg, data, error, request_id, trace_id }
56
+ function parsePayload(json) {
57
+ if (!json || typeof json !== "object" || Array.isArray(json)) {
58
+ throw new ScnetError("SCNet 平台返回了无效的响应");
59
+ }
60
+ const err = json.error && typeof json.error === "object" ? json.error : null;
61
+ const code = String(err?.code ?? json.code ?? "");
62
+ const msg = String(err?.message ?? json.msg ?? json.message ?? "").trim();
63
+ if (code && code !== "0" && code !== "200") {
64
+ const providerCode = err?.code ?? json.code;
65
+ const scnet = new ScnetError(msg || `SCNet 平台请求失败(code=${providerCode})`, {
66
+ statusCode: json.status ?? null,
67
+ code: providerCode,
68
+ retryable: /(timeout|网络|try|later)/i.test(msg),
69
+ });
70
+ scnet.providerCode = providerCode;
71
+ throw scnet;
72
+ }
73
+ return json.data ?? json;
74
+ }
75
+
76
+ async function requestJson(url, { method = "GET", body, headers = {}, timeoutMs = 15000, signal } = {}) {
77
+ const res = await fetch(url, {
78
+ method,
79
+ headers: {
80
+ Accept: "application/json",
81
+ ...(body ? { "Content-Type": "application/json" } : {}),
82
+ ...headers,
83
+ },
84
+ ...(body ? { body: JSON.stringify(body) } : {}),
85
+ redirect: "manual",
86
+ signal: AbortSignal.any([AbortSignal.timeout(timeoutMs), ...(signal ? [signal] : [])]),
87
+ });
88
+ if (!res.ok && res.status >= 500) {
89
+ throw new ScnetError(`SCNet 服务暂不可用(HTTP ${res.status})`, {
90
+ statusCode: res.status,
91
+ retryable: true,
92
+ });
93
+ }
94
+ let text = "";
95
+ try {
96
+ text = await res.text();
97
+ } catch {
98
+ text = "";
99
+ }
100
+ let json;
101
+ try {
102
+ json = text ? JSON.parse(text) : {};
103
+ } catch {
104
+ throw new ScnetError(`SCNet 平台返回了无法解析的响应(HTTP ${res.status})`, {
105
+ statusCode: res.status,
106
+ });
107
+ }
108
+ if (!res.ok && res.status === 401) {
109
+ throw new ScnetError("SCNet 登录已失效,请重新登录", { statusCode: 401, code: "auth.session_expired" });
110
+ }
111
+ return { json, headers: res.headers, status: res.status };
112
+ }
113
+
114
+ // —— 图形验证码 ——
115
+
116
+ // 获取验证码(与 SCNet 客户端一致):
117
+ // GET /sso/captcha.action → { code, data: "data:image/jpeg;base64,..." }
118
+ // 响应 Set-Cookie 里的 captchaKey 要回传给登录接口。
119
+ // 返回 { image, cookie, imageBase64 }(imageBase64 兼容旧引用)
120
+ export async function fetchCaptcha({ signal } = {}) {
121
+ const res = await fetch(`${PLATFORM_BASE}${CAPTCHA_URL}`, {
122
+ headers: { Accept: "application/json", ...CLIENT_HEADERS },
123
+ redirect: "manual",
124
+ signal: signal ?? AbortSignal.timeout(15000),
125
+ });
126
+ let json;
127
+ try {
128
+ json = await res.json();
129
+ } catch {
130
+ throw new ScnetError(`获取 SCNet 验证码失败(HTTP ${res.status})`, {
131
+ statusCode: res.status,
132
+ });
133
+ }
134
+ const img = String(json?.data ?? json?.image ?? "");
135
+ if (!img) {
136
+ throw new ScnetError("获取 SCNet 验证码失败:接口未返回图片");
137
+ }
138
+ const allCookies = res.headers.getSetCookie?.() ?? [];
139
+ const cookieHeader = res.headers.get("set-cookie");
140
+ if (cookieHeader && !allCookies.includes(cookieHeader)) allCookies.push(cookieHeader);
141
+ const cookie = allCookies.map((c) => String(c).split(";")[0]).join("; ");
142
+ return {
143
+ image: img,
144
+ cookie,
145
+ imageBase64: img.startsWith("data:") ? img : `data:image/png;base64,${img}`,
146
+ };
147
+ }
148
+
149
+ // —— 密码登录 ——
150
+ // 与 SCNet 客户端主进程同一套协议:
151
+ // POST /ac/api/auth/loginDesktopClient.action
152
+ // Content-Type: application/x-www-form-urlencoded(客户端 axios 的默认,
153
+ // 拦截器会把 JSON data 用 qs.stringify 转表单)
154
+ // { username, password: RSA(base64), captcha, encrypted:true,
155
+ // isSevenDaysValidity:true, sensors_anon_id:"", captchaKey:"" }
156
+ // + 取验证码时拿到的 Cookie
157
+ // captchaCookie 来自 fetchCaptcha().cookie
158
+ // 返回 { token, myCookie, userInfo, accountId }
159
+ export async function loginWithPassword(
160
+ username,
161
+ password,
162
+ captchaText,
163
+ { captchaCookie, signal } = {}
164
+ ) {
165
+ const form = new URLSearchParams({
166
+ username,
167
+ password: encryptPassword(password),
168
+ captcha: captchaText ?? "",
169
+ encrypted: "true",
170
+ isSevenDaysValidity: "true",
171
+ sensors_anon_id: "",
172
+ captchaKey: "",
173
+ });
174
+ const res = await fetch(`${PLATFORM_BASE}/ac/api/auth/loginDesktopClient.action`, {
175
+ method: "POST",
176
+ headers: {
177
+ Accept: "application/json",
178
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
179
+ ...CLIENT_HEADERS,
180
+ ...(captchaCookie ? { Cookie: captchaCookie } : {}),
181
+ },
182
+ redirect: "manual",
183
+ body: form.toString(),
184
+ signal: signal ?? AbortSignal.timeout(20000),
185
+ });
186
+ let json;
187
+ try {
188
+ json = await res.json();
189
+ } catch {
190
+ throw new ScnetError(`SCNet 登录响应无法解析(HTTP ${res.status})`, {
191
+ statusCode: res.status,
192
+ });
193
+ }
194
+ const payload = parsePayload(json);
195
+ const allCookies = res.headers.getSetCookie?.() ?? [];
196
+ const cookieHeader = res.headers.get("set-cookie");
197
+ if (cookieHeader && !allCookies.includes(cookieHeader)) allCookies.push(cookieHeader);
198
+ const map = new Map();
199
+ for (const c of allCookies) {
200
+ const [pair] = String(c).split(";");
201
+ const eq = pair.indexOf("=");
202
+ if (eq <= 0) continue;
203
+ map.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
204
+ }
205
+ const token = map.get("Token") ?? "";
206
+ if (!token) {
207
+ throw new ScnetError("SCNet 登录失败:平台未返回会话(请检查账号或验证码)");
208
+ }
209
+ // 平台接口靠全量 Cookie 认证(含 Token=xxx),实测单独 Token 头会 401。
210
+ // cookie 字段就是整个 set-cookie 拼成的字符串,请求时原样回传。
211
+ const myCookie = [...map.entries()]
212
+ .filter(([k]) => k !== "Token")
213
+ .map(([, v]) => v)
214
+ .join("; ");
215
+ const cookie = [...map.entries()].map(([k, v]) => `${k}=${v}`).join("; ");
216
+ const userInfo = payload && typeof payload === "object" ? payload : {};
217
+ const accountId = String(
218
+ userInfo.id ?? userInfo.userId ?? userInfo.accountId ?? userInfo.user?.id ?? ""
219
+ );
220
+ return { token, myCookie, cookie, userInfo, accountId, cookies: map };
221
+ }
222
+
223
+ // —— 平台认证请求(带 Cookie 头) ——
224
+ // 实测:/acx/... 与 /llm/... 平台接口靠 Cookie 认证(登录 set-cookie 全量,
225
+ // 其中含 Token=xxx),Token 头会 401,X-Jwt-Access-Token 头会 1001。
226
+
227
+ function platformHeaders(session) {
228
+ const h = { Accept: "application/json", ...CLIENT_HEADERS };
229
+ const parts = [];
230
+ if (session.cookie) {
231
+ parts.push(session.cookie);
232
+ } else {
233
+ if (session.myCookie) parts.push(session.myCookie);
234
+ if (session.token) parts.push(`Token=${session.token}`);
235
+ }
236
+ if (parts.length) h.Cookie = parts.join("; ");
237
+ // 客户端 w() 也会设置 Token 头(accessToken),实测不影响,保留以便服务端兼容
238
+ if (session.token) h.Token = session.token;
239
+ return h;
240
+ }
241
+
242
+ async function platformRequest(session, path, { method = "GET", body, params, signal } = {}) {
243
+ let url = `${API_BASE}${path.startsWith("/") ? path : `/${path}`}`;
244
+ if (params) {
245
+ const q = new URLSearchParams();
246
+ for (const [k, v] of Object.entries(params)) {
247
+ if (v !== undefined && v !== null) q.set(k, String(v));
248
+ }
249
+ const qs = q.toString();
250
+ if (qs) url += `${url.includes("?") ? "&" : "?"}${qs}`;
251
+ }
252
+ const { json, status } = await requestJson(url, {
253
+ method,
254
+ body,
255
+ headers: platformHeaders(session),
256
+ signal,
257
+ });
258
+ return parsePayload(json, status);
259
+ }
260
+
261
+ // —— 当前用户信息(确认登录态) ——
262
+
263
+ export async function fetchCurrentUser(session, { signal } = {}) {
264
+ const data = await platformRequest(
265
+ session,
266
+ "/acx/user/users/current-user-info?includeToken=true&refresh=true",
267
+ { signal }
268
+ );
269
+ const accountId = String(
270
+ data?.id ?? data?.userId ?? data?.accountId ?? data?.user?.id ?? session.accountId ?? ""
271
+ );
272
+ return {
273
+ ...(data && typeof data === "object" ? data : {}),
274
+ accountId,
275
+ };
276
+ }
277
+
278
+ // —— API key(客户端同款流程) ——
279
+
280
+ // 优先走 createActivityApiKey(一键取活动 key);
281
+ // 服务端拒绝(API010010)时,模拟客户端:查成员 key 列表 → 复用或新建。
282
+ export async function obtainApiKey(session, { rejectedKey, signal } = {}) {
283
+ try {
284
+ const data = await platformRequest(session, "/acx/llm/api-key/createActivityApiKey", {
285
+ method: "POST",
286
+ body: {},
287
+ signal,
288
+ });
289
+ const key = apiKeyOf(data);
290
+ if (key) return key;
291
+ throw new ScnetError("SCNet 未返回服务凭证");
292
+ } catch (err) {
293
+ if (err?.providerCode && String(err.providerCode) !== "API010010") throw err;
294
+ if (err?.statusCode === 401) throw err;
295
+ return obtainMemberApiKey(session, { rejectedKey, signal });
296
+ }
297
+ }
298
+
299
+ function apiKeyOf(data) {
300
+ if (!data) return "";
301
+ if (typeof data === "string") return data.trim();
302
+ for (const k of ["apiKey", "apikey", "key", "secret", "token"]) {
303
+ const v = data[k];
304
+ if (typeof v === "string" && v.trim()) return v.trim();
305
+ }
306
+ if (data.data && typeof data.data === "object") return apiKeyOf(data.data);
307
+ return "";
308
+ }
309
+
310
+ // 团队成员 API key 流程(客户端同款):
311
+ // GET /user/users/cluster-user-detail → 账号/团队信息
312
+ // POST /llm/api-key/query 分页查 key → 找到 scnet-client-member-<createUser> 且 keyStatus=100 的复用
313
+ // 没有则 POST /llm/api-key/create 新建
314
+ async function obtainMemberApiKey(session, { rejectedKey, signal } = {}) {
315
+ const users = await platformRequest(session, "/acx/user/users/cluster-user-detail", { signal });
316
+ const list = Array.isArray(users) ? users : [];
317
+ if (!list.length || list.some((u) => String(u?.memberType ?? "").toLowerCase() !== "member")) {
318
+ throw new ScnetError("SCNet 用户账号异常,请联系团队管理员");
319
+ }
320
+ const ids = new Set(list.map((u) => String(u?.accountId ?? "")));
321
+ if (ids.size !== 1) {
322
+ throw new ScnetError("当前账号关联多个团队账号,请联系团队管理员确认");
323
+ }
324
+ const first = list[0];
325
+ const accountId = String(first?.accountId ?? "");
326
+ const groupId = String(first?.groupId ?? "");
327
+ const createUser = String(first?.userId ?? "");
328
+ const name = `scnet-client-member-${createUser}`;
329
+ if (!accountId || !groupId || !createUser) {
330
+ throw new ScnetError("SCNet 团队账号信息异常,请联系团队管理员");
331
+ }
332
+ const records = [];
333
+ for (let page = 1; page <= 100; page += 1) {
334
+ const resp = await platformRequest(session, "/acx/llm/api-key/query", {
335
+ method: "POST",
336
+ body: { createUser, accountId, groupId, serviceType: "model", page, size: 100 },
337
+ signal,
338
+ });
339
+ const recs = Array.isArray(resp)
340
+ ? resp
341
+ : Array.isArray(resp?.records)
342
+ ? resp.records
343
+ : [];
344
+ records.push(...recs);
345
+ if (Array.isArray(resp)) break;
346
+ const pages = Number(resp?.pages || 1);
347
+ if (page >= pages) break;
348
+ }
349
+ const mine = records.find(
350
+ (r) =>
351
+ String(r?.apiKeyName ?? "") === name &&
352
+ String(r?.accountId ?? "") === accountId &&
353
+ String(r?.createUser ?? "") === createUser &&
354
+ String(r?.keyStatus ?? "") === "100"
355
+ );
356
+ if (mine && mine.id) {
357
+ const detail = await platformRequest(session, "/acx/llm/api-key/detail", {
358
+ params: { id: String(mine.id) },
359
+ signal,
360
+ });
361
+ const key = apiKeyOf(detail);
362
+ if (key && !key.includes("*")) {
363
+ if (rejectedKey && key === rejectedKey) {
364
+ throw new ScnetError("SCNet API Key 已失效,请重新登录", { code: "auth.session_expired" });
365
+ }
366
+ return key;
367
+ }
368
+ throw new ScnetError("SCNet 服务凭证不可用");
369
+ }
370
+ if (records.some((r) => String(r?.apiKeyName ?? "") === name && String(r?.accountId ?? "") === accountId)) {
371
+ throw new ScnetError("SCNet API Key 已失效,请重新登录", { code: "auth.session_expired" });
372
+ }
373
+ const created = await platformRequest(session, "/acx/llm/api-key/create", {
374
+ method: "POST",
375
+ body: {
376
+ createUser,
377
+ apiKeyName: name,
378
+ serviceTypes: ["model"],
379
+ accessibleModelScope: "ALL",
380
+ groupId,
381
+ accountId,
382
+ },
383
+ signal,
384
+ });
385
+ const key = apiKeyOf(created);
386
+ if (!key) throw new ScnetError("SCNet 服务凭证不可用");
387
+ return key;
388
+ }
389
+
390
+ // —— 模型列表 ——
391
+ // 返回 [{ id, name, contextWindow, maxTokens, reasoning, modalities, description, ... }]
392
+
393
+ export async function fetchModels(session, { signal } = {}) {
394
+ const data = await platformRequest(session, "/acx/llm/api/console/model", { signal });
395
+ const arr = Array.isArray(data)
396
+ ? data
397
+ : Array.isArray(data?.records)
398
+ ? data.records
399
+ : Array.isArray(data?.data)
400
+ ? data.data
401
+ : Array.isArray(data?.list)
402
+ ? data.list
403
+ : Array.isArray(data?.models)
404
+ ? data.models
405
+ : [];
406
+ const out = [];
407
+ for (const m of arr) {
408
+ if (!m || typeof m !== "object") continue;
409
+ // 真实字段(2026-11 实测 console/model):
410
+ // modelName = 模型 id
411
+ // displayName= 显示名
412
+ // modelType = "LLM" | "DOC_CONVERSION" | "DOC_MIND" | "OCR" …
413
+ // maxToken = 上下文窗口(KiB),乘 1024 得 token 数
414
+ const id = String(m.modelName ?? m.modelId ?? m.id ?? "").trim();
415
+ const type = String(m.modelType ?? "").trim().toUpperCase();
416
+ if (!id) continue;
417
+ // 只要语言模型;文档/OCR 等服务型条目 Magpie 用不了(它们走别的接口)
418
+ if (type && type !== "LLM") continue;
419
+ const kib = Number(m.maxToken ?? m.maxTokenKib);
420
+ const ctx = Number.isFinite(kib) && kib > 0 ? Math.round(kib * 1024) : undefined;
421
+ // 价格:SCNet 按元/百万 token 计,magpie cost 也是元/百万 token
422
+ const price = (v) => (Number.isFinite(Number(v)) ? Number(v) : undefined);
423
+ const cost =
424
+ price(m.inputPrice) !== undefined || price(m.outputPrice) !== undefined
425
+ ? {
426
+ input: price(m.inputPrice) ?? 0,
427
+ output: price(m.outputPrice) ?? 0,
428
+ ...(price(m.cachedPrice) !== undefined ? { cache_read: price(m.cachedPrice) } : {}),
429
+ }
430
+ : undefined;
431
+ const inputKinds = Array.isArray(m.modalities?.input)
432
+ ? m.modalities.input.map((k) => String(k).toLowerCase())
433
+ : [];
434
+ const vision = inputKinds.includes("image") || inputKinds.includes("video");
435
+ out.push({
436
+ id,
437
+ name: String(m.displayName ?? m.modelName ?? id),
438
+ ...(m.description ? { description: String(m.description) } : {}),
439
+ ...(ctx ? { contextWindow: ctx } : {}),
440
+ ...(cost ? { cost } : {}),
441
+ ...(vision ? { modalities: { input: ["text", "image"] } } : {}),
442
+ });
443
+ }
444
+ return out;
445
+ }
446
+
447
+ // —— 登录态保存(与客户端 scnet-credentials 相同形状) ——
448
+
449
+ export function saveApiKeyToClientStore(apiKey) {
450
+ // 只做可选的落盘:写入 ~/.sclaw/agent/scnet-credentials 需客户端同意,
451
+ // magpie 插件不主动写客户端目录;这里返回 void,由 auth 存储管理 key。
452
+ void apiKey;
453
+ }
454
+
455
+ export { requestJson, platformRequest };
package/src/models.mjs ADDED
@@ -0,0 +1,50 @@
1
+ // SCNet 模型静态兜底表
2
+ // 数据来源:SCNet 客户端 ~/.sclaw/agent/models.json(2026-11-06,32 个模型)。
3
+ // 登录后由 GET /acx/llm/api/console/model 实时替换。
4
+ // cost 为 null 表示按平台计费(与客户端一致,不显示价格)。
5
+
6
+ export const STATIC_MODELS = {
7
+ "DeepSeek-V4.1-Flash-1017": { name: "DeepSeek-V4.1-Flash-1017", limit: { context: 1048576, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
8
+ "Kimi-K3.1-Pro": { name: "Kimi-K3.1-Pro", limit: { context: 1048576, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
9
+ "Kimi-K3.1-Pro-Highspeed": { name: "Kimi-K3.1-Pro-Highspeed", limit: { context: 1048576, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
10
+ "GLM-5.3-Flash": { name: "GLM-5.3-Flash", limit: { context: 1048576, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
11
+ "MiniMax-M3.1-Flash": { name: "MiniMax-M3.1-Flash", limit: { context: 1048576, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
12
+ "Qwen3.8-Flash": { name: "Qwen3.8-Flash", limit: { context: 1048576, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
13
+ "GLM-5.3": { name: "GLM-5.3", limit: { context: 1048576, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
14
+ "MiniMax-M3": { name: "MiniMax-M3", limit: { context: 1024000, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
15
+ "DeepSeek-V4.1-Flash": { name: "DeepSeek-V4.1-Flash", limit: { context: 1048576, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
16
+ "Kimi-K3": { name: "Kimi-K3", limit: { context: 1024000, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
17
+ "DeepSeek-V4-Pro-0813": { name: "DeepSeek-V4-Pro-0813", limit: { context: 1048576, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
18
+ "Qwen3.8-Max": { name: "Qwen3.8-Max", limit: { context: 1048576, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
19
+ "DeepSeek-V4-Flash-0731": { name: "DeepSeek-V4-Flash-0731", limit: { context: 1048576, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
20
+ "GLM-5.2": { name: "GLM-5.2", limit: { context: 1048576, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
21
+ "Spark-X2.5": { name: "Spark-X2.5", limit: { context: 262144, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
22
+ "GLM-5.1": { name: "GLM-5.1", limit: { context: 206848, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
23
+ "Qwen3.7-Max": { name: "Qwen3.7-Max", limit: { context: 1014784, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
24
+ "Kimi-K2.7-Code": { name: "Kimi-K2.7-Code", limit: { context: 262144, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
25
+ "Kimi-K2.6": { name: "Kimi-K2.6", limit: { context: 262144, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
26
+ "Kimi-K2.5": { name: "Kimi-K2.5", limit: { context: 262144, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
27
+ "GLM-5": { name: "GLM-5", limit: { context: 204800, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
28
+ "MiniMax-M2.7": { name: "MiniMax-M2.7", limit: { context: 204800, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
29
+ "MiniMax-M2.5": { name: "MiniMax-M2.5", limit: { context: 204800, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
30
+ "Qwen3.7-Plus": { name: "Qwen3.7-Plus", limit: { context: 1014784, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
31
+ "Qwen3.6-Plus": { name: "Qwen3.6-Plus", limit: { context: 512000, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
32
+ "Qwen3.6-Flash": { name: "Qwen3.6-Flash", limit: { context: 1024000, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
33
+ "Qwen3.6-27B": { name: "Qwen3.6-27B", limit: { context: 262144, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
34
+ "Qwen3.5-122B-A10B": { name: "Qwen3.5-122B-A10B", limit: { context: 102400, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
35
+ "MiMo-V2.5-Pro": { name: "MiMo-V2.5-Pro", limit: { context: 1024000, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
36
+ "Qwen3-235B-A22B": { name: "Qwen3-235B-A22B", limit: { context: 32768, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
37
+ "SCNet-Max": { name: "SCNet-Max", limit: { context: 1048576, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
38
+ "GLM-5.2-Base": { name: "GLM-5.2-Base", limit: { context: 131072, output: 131072 }, reasoning: true, tool_call: true, modalities: { input: ["text"] } },
39
+ };
40
+
41
+ export function staticModelList() {
42
+ return Object.entries(STATIC_MODELS).map(([id, m]) => ({
43
+ id,
44
+ name: m.name,
45
+ limit: m.limit,
46
+ reasoning: m.reasoning,
47
+ tool_call: m.tool_call,
48
+ modalities: m.modalities,
49
+ }));
50
+ }