magpie-scnet-auth 0.0.0-stage → 0.1.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,42 @@
1
+ MIT License
2
+
3
+
4
+
5
+ Copyright (c) 2026 SCNet provider plugin contributors
6
+
7
+
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+
11
+ of this software and associated documentation files (the "Software"), to deal
12
+
13
+ in the Software without restriction, including without limitation the rights
14
+
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+
17
+ copies of the Software, and to permit persons to whom the Software is
18
+
19
+ furnished to do so, subject to the following conditions:
20
+
21
+
22
+
23
+ The above copyright notice and this permission notice shall be included in all
24
+
25
+ copies or substantial portions of the Software.
26
+
27
+
28
+
29
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
30
+
31
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
32
+
33
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
34
+
35
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
36
+
37
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
38
+
39
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
40
+
41
+ SOFTWARE.
42
+
package/README.md CHANGED
@@ -1,3 +1,145 @@
1
- # Temporary Holding Version
1
+ # magpie-scnet-auth
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 provider plugin for SCNet (scnet.cn) LLM models.**
4
+
5
+ English | [简体中文](README.zh-CN.md)
6
+
7
+ Once installed, the models served by the SCNet platform appear in magpie as a `scnet` provider and become available to every agent that magpie drives (Claude Code, Codex, dsh, and others).
8
+
9
+ - Sign in with an SCNet username and password — no desktop client, no packet capture
10
+ - Speaks standard OpenAI Chat Completions
11
+ - Passes through tool calls and reasoning content
12
+
13
+ ---
14
+
15
+ ## Supported models
16
+
17
+ The platform `GET /models` endpoint returns 32 models, including:
18
+
19
+ | Family | Models |
20
+ |---|---|
21
+ | Zhipu | GLM-5.3-Flash, GLM-5.3, GLM-5.2, GLM-5.1, GLM-5 |
22
+ | Qwen | Qwen3.8-Max, Qwen3.8-Flash, Qwen3.7-Max, Qwen3.7-Plus, Qwen3.6-Plus, Qwen3-235B-A22B |
23
+ | DeepSeek | DeepSeek-V4-Pro-0813, DeepSeek-V4.1-Flash, DeepSeek-V4.1-Flash-1017, DeepSeek-V4-Flash-0731 |
24
+ | Kimi | Kimi-K3.1-Pro, Kimi-K3, Kimi-K2.7-Code, Kimi-K2.6 |
25
+ | MiniMax | MiniMax-M3.1-Flash, MiniMax-M3, MiniMax-M2.7, MiniMax-M2.5 |
26
+ | Others | SCNet-Max, Spark-X2.5, MiMo-V2.5-Pro |
27
+
28
+ **Quota is granted per model.** An account does not necessarily have balance for every model; calls to an unprovisioned model return `402 Insufficient Balance`. The plugin lists all models by default and offers filtering (see Configuration).
29
+
30
+ ---
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ # From a local folder
36
+ magpie plugin add /path/to/magpie-scnet-auth
37
+
38
+ # From GitHub
39
+ magpie plugin add github:RrOrange/magpie-scnet-auth
40
+
41
+ # From npm, once published
42
+ magpie plugin add magpie-scnet-auth
43
+ ```
44
+
45
+ After installation, the provider **SCNet** appears on the Providers page in magpie.
46
+
47
+ ---
48
+
49
+ ## Sign in
50
+
51
+ ### Username and password (recommended)
52
+
53
+ In the magpie window:
54
+
55
+ 1. Providers → **SCNet** → click **+**
56
+ 2. Choose **Sign in with SCNet username and password**
57
+ 3. Enter the username / phone number → Next → enter the password → Sign in
58
+
59
+ The password is used only to exchange for an API key and is not stored; only the returned `sk-…` key is saved.
60
+
61
+ Command-line equivalent:
62
+
63
+ ```bash
64
+ node login.mjs # prompts for username and password
65
+ ```
66
+
67
+ ### API key
68
+
69
+ If an API key already exists on the platform, choose **API Key** and paste `sk-…`.
70
+
71
+ ### Local client credentials
72
+
73
+ If the SCNet desktop client is installed and signed in (`~/.sclaw/agent/scnet-credentials/`), enter `local` and the plugin reads and validates the available key automatically.
74
+
75
+ ### Any other compatible service
76
+
77
+ Paste a JSON credential pointing at any OpenAI-compatible endpoint:
78
+
79
+ ```json
80
+ {"baseUrl":"https://example.com/v1","apiKey":"sk-…","models":["model-a","model-b"]}
81
+ ```
82
+
83
+ ---
84
+
85
+ ## Configuration
86
+
87
+ Environment variables, applied after `magpie plugin off scnet-plugin` / `on scnet-plugin`:
88
+
89
+ | Variable | Description |
90
+ |---|---|
91
+ | `SCNET_MODELS` | `all` (default) lists everything; `known` lists only models verified on this machine; or a comma-separated list of model IDs |
92
+ | `SCNET_PROBE` | Set to `1` to probe each model's balance (`max_tokens:1`) and keep only usable ones |
93
+ | `SCNET_BASE` | Overrides the API base URL (default `https://api.scnet.cn/api/llm/v1`) |
94
+ | `SCNET_AGENT_DIR` | Overrides the client config directory (default `~/.sclaw/agent`) |
95
+
96
+ ---
97
+
98
+ ## Authentication and multiple accounts
99
+
100
+ The plugin contains no authentication layer of its own. A credential is simply the user's API key, stored locally:
101
+
102
+ ```
103
+ ~/.config/magpie/plugin-auth.json # mode 0600, readable only by the current user
104
+ ```
105
+
106
+ Identity is verified by the SCNet platform (Bearer token). The same plugin can be installed by several people, each using their own credential.
107
+
108
+ magpie supports multiple accounts for one provider: click **+** again to add another, mark one as preferred, and the next account is used automatically when the current one runs out of quota.
109
+
110
+ To share quota with someone else, share the magpie gateway rather than the plugin:
111
+
112
+ ```bash
113
+ magpie gateway-key add friend --models 'scnet-plugin/*'
114
+ magpie gateway-key limit friend day --tokens 5000000
115
+ ```
116
+
117
+ ---
118
+
119
+ ## Known limitations
120
+
121
+ - Quota is counted per model; unprovisioned models return `402`.
122
+ - Context and output limits come from the client metadata (e.g. GLM-5.3-Flash: 1048576 / 131072).
123
+ - The server occasionally returns `500 internal_error`; retrying resolves it.
124
+ - The plugin proxies chat only; it does not expose the platform's image or video generation APIs.
125
+ - Concurrency is limited by `magpie.maxConcurrency` (default 4) to avoid triggering rate limits.
126
+
127
+ ---
128
+
129
+ ## Layout
130
+
131
+ ```
132
+ magpie-scnet-auth/
133
+ ├── index.mjs provider declaration, credential resolution, model discovery
134
+ ├── login.mjs username/password login helper (exchanges for a key and writes it into magpie)
135
+ ├── package.json package manifest
136
+ ├── README.md this document
137
+ ├── README.zh-CN.md Chinese translation
138
+ └── LICENSE
139
+ ```
140
+
141
+ ---
142
+
143
+ ## License
144
+
145
+ MIT
@@ -0,0 +1,145 @@
1
+ # magpie-scnet-auth
2
+
3
+ **Magpie provider 插件:接入 SCNet 计算互联网(scnet.cn)的大模型。**
4
+
5
+ [English](README.md) | 简体中文
6
+
7
+ 安装后,SCNet 平台提供的模型会作为 `scnet` 供应商出现在 magpie 中,magpie 驱动的所有 agent(Claude Code、Codex、dsh 等)均可直接选用。
8
+
9
+ - 支持账号密码登录,无需安装桌面客户端,无需抓包
10
+ - 使用标准 OpenAI Chat Completions 协议
11
+ - 透传工具调用与推理过程
12
+
13
+ ---
14
+
15
+ ## 支持的模型
16
+
17
+ 平台 `GET /models` 返回 32 个模型,包括:
18
+
19
+ | 系列 | 模型 |
20
+ |---|---|
21
+ | 智谱 | GLM-5.3-Flash、GLM-5.3、GLM-5.2、GLM-5.1、GLM-5 |
22
+ | 通义 | Qwen3.8-Max、Qwen3.8-Flash、Qwen3.7-Max、Qwen3.7-Plus、Qwen3.6-Plus、Qwen3-235B-A22B |
23
+ | DeepSeek | DeepSeek-V4-Pro-0813、DeepSeek-V4.1-Flash、DeepSeek-V4.1-Flash-1017、DeepSeek-V4-Flash-0731 |
24
+ | Kimi | Kimi-K3.1-Pro、Kimi-K3、Kimi-K2.7-Code、Kimi-K2.6 |
25
+ | MiniMax | MiniMax-M3.1-Flash、MiniMax-M3、MiniMax-M2.7、MiniMax-M2.5 |
26
+ | 其他 | SCNet-Max、Spark-X2.5、MiMo-V2.5-Pro |
27
+
28
+ **额度按模型分配。** 账号并非对所有模型都有余额,未开通的模型调用会返回 `402 Insufficient Balance`。插件默认列出全部模型,并支持筛选(见「配置」)。
29
+
30
+ ---
31
+
32
+ ## 安装
33
+
34
+ ```bash
35
+ # 从本地目录安装
36
+ magpie plugin add /path/to/magpie-scnet-auth
37
+
38
+ # 从 GitHub 安装
39
+ magpie plugin add github:RrOrange/magpie-scnet-auth
40
+
41
+ # 发布到 npm 后按包名安装
42
+ magpie plugin add magpie-scnet-auth
43
+ ```
44
+
45
+ 安装后,magpie 的「供应商」页面会出现 **SCNet**。
46
+
47
+ ---
48
+
49
+ ## 登录
50
+
51
+ ### 账号密码(推荐)
52
+
53
+ 在 magpie 窗口中:
54
+
55
+ 1. 供应商 → **SCNet** → 点击 **+**
56
+ 2. 选择 **使用 SCNet 账号密码登录**
57
+ 3. 输入用户名/手机号 → 下一步 → 输入密码 → 登录
58
+
59
+ 密码仅用于当次换取 API Key,不会被保存;保存的是平台返回的 `sk-…` 密钥。
60
+
61
+ 命令行等价方式:
62
+
63
+ ```bash
64
+ node login.mjs # 交互输入账号与密码
65
+ ```
66
+
67
+ ### API Key
68
+
69
+ 若平台已创建密钥,登录时选择 **API Key**,粘贴 `sk-…`。
70
+
71
+ ### 本机客户端凭据
72
+
73
+ 若本机已安装并登录 SCNet 桌面客户端(`~/.sclaw/agent/scnet-credentials/`),登录时输入 `local`,插件会自动读取并校验可用的密钥。
74
+
75
+ ### 其他兼容服务
76
+
77
+ 登录时粘贴 JSON,可指向任意 OpenAI 兼容端点:
78
+
79
+ ```json
80
+ {"baseUrl":"https://example.com/v1","apiKey":"sk-…","models":["model-a","model-b"]}
81
+ ```
82
+
83
+ ---
84
+
85
+ ## 配置
86
+
87
+ 通过环境变量控制,修改后执行 `magpie plugin off scnet-plugin` / `on scnet-plugin` 生效:
88
+
89
+ | 变量 | 说明 |
90
+ |---|---|
91
+ | `SCNET_MODELS` | `all`(默认)列出全部;`known` 仅列本机已验证有余额的模型;或逗号分隔的模型 ID |
92
+ | `SCNET_PROBE` | 设为 `1` 时逐个探测模型余额(`max_tokens:1`),仅保留可用模型 |
93
+ | `SCNET_BASE` | 覆盖 API 基址,默认 `https://api.scnet.cn/api/llm/v1` |
94
+ | `SCNET_AGENT_DIR` | 覆盖客户端配置目录,默认 `~/.sclaw/agent` |
95
+
96
+ ---
97
+
98
+ ## 鉴权与多账号
99
+
100
+ 插件本身不包含鉴权层。凭据即用户的 API Key,保存在本机:
101
+
102
+ ```
103
+ ~/.config/magpie/plugin-auth.json # 权限 0600,仅当前用户可读
104
+ ```
105
+
106
+ 身份校验由 SCNet 平台(Bearer Token)完成。同一份插件可被多人安装,各自使用自己的凭据。
107
+
108
+ magpie 支持同一供应商多账号:重复点击 **+** 即可添加,可设置首选账号,当前账号额度耗尽时自动切换到下一个。
109
+
110
+ 如需与他人共享额度,应共享 magpie 网关而非插件:
111
+
112
+ ```bash
113
+ magpie gateway-key add friend --models 'scnet-plugin/*'
114
+ magpie gateway-key limit friend day --tokens 5000000
115
+ ```
116
+
117
+ ---
118
+
119
+ ## 已知限制
120
+
121
+ - 额度按模型计算,未开通的模型返回 `402`。
122
+ - 上下文与输出上限取自客户端元数据(如 GLM-5.3-Flash:1048576 / 131072)。
123
+ - 服务端偶发 `500 internal_error`,重试即可。
124
+ - 插件仅代理对话能力,不包含平台的图片/视频生成接口。
125
+ - 并发受 `magpie.maxConcurrency` 限制(默认 4),以避免触发风控。
126
+
127
+ ---
128
+
129
+ ## 目录结构
130
+
131
+ ```
132
+ magpie-scnet-auth/
133
+ ├── index.mjs provider 声明、凭据解析、模型发现
134
+ ├── login.mjs 账号密码登录工具(换取 key 并写入 magpie)
135
+ ├── package.json 包声明
136
+ ├── README.md 英文文档
137
+ ├── README.zh-CN.md 中文文档
138
+ └── LICENSE
139
+ ```
140
+
141
+ ---
142
+
143
+ ## 许可
144
+
145
+ MIT
package/index.mjs ADDED
@@ -0,0 +1,354 @@
1
+ // SCNet 计算互联网 → magpie provider 插件
2
+ //
3
+ // 事实依据(本机实测):
4
+ // - 平台是标准 OpenAI 兼容:POST https://api.scnet.cn/api/llm/v1/chat/completions
5
+ // Authorization: Bearer <key>,流式带 choices[].delta.reasoning_content
6
+ // - GET /api/llm/v1/models 用这把 key 能列出 32 个模型(GLM-5.3-Flash / Qwen3.8 / Kimi-K3 …)
7
+ // - SCNet 客户端把凭据和模型元数据存在本机:
8
+ // %USERPROFILE%\.sclaw\agent\scnet-credentials\*.json → {scnet:{type:"api-key",key}}
9
+ // %USERPROFILE%\.sclaw\agent\models.json → providers.scnet.models[](含 contextWindow 等)
10
+ //
11
+ // 所以插件不需要任何抓包或注入:登录时填 local,它自己去客户端目录里取 key。
12
+
13
+ import { readFileSync, readdirSync, statSync } from "node:fs"
14
+ import { homedir } from "node:os"
15
+ import { join } from "node:path"
16
+
17
+ const PROVIDER = "scnet"
18
+ const BASE = process.env.SCNET_BASE || "https://api.scnet.cn/api/llm/v1"
19
+ const AGENT_DIR = process.env.SCNET_AGENT_DIR || join(homedir(), ".sclaw", "agent")
20
+ const CRED_DIR = join(AGENT_DIR, "scnet-credentials")
21
+ const MODELS_FILE = join(AGENT_DIR, "models.json")
22
+
23
+ // 兜底:客户端读不到时至少可用
24
+ const FALLBACK = { "GLM-5.3-Flash": { name: "GLM-5.3-Flash", context: 1000000, output: 131072, reasoning: true } }
25
+
26
+ // 平台给了 32 个模型,但额度是按模型隔离的:没额度的调用会 402 Insufficient Balance。
27
+ // 默认全部列出(账号不同,能用哪些只有自己知道);想收窄用 SCNET_MODELS。
28
+ // SCNET_MODELS=… 逗号分隔的模型 id
29
+ // SCNET_MODELS=known 只列本机实测有额度的(GLM-5.3-Flash / SCNet-Max)
30
+ // SCNET_PROBE=1 逐个实测余额(max_tokens=1),只留有额度的
31
+ const KNOWN_IDS = ["GLM-5.3-Flash", "SCNet-Max"]
32
+
33
+ function selectedIds(allIds) {
34
+ const spec = (process.env.SCNET_MODELS || "").trim().toLowerCase()
35
+ if (!spec || spec === "all" || spec === "*") return allIds
36
+ if (spec === "known" || spec === "mine") {
37
+ const lower = new Set(KNOWN_IDS.map((x) => x.toLowerCase()))
38
+ const picked = allIds.filter((id) => lower.has(id.toLowerCase()))
39
+ return picked.length ? picked : KNOWN_IDS.filter((id) => allIds.some((a) => a.toLowerCase() === id.toLowerCase()))
40
+ }
41
+ const want = spec.split(",").map((x) => x.trim()).filter(Boolean)
42
+ return allIds.filter((id) => want.includes(id.toLowerCase()))
43
+ }
44
+
45
+ // 可选:SCNET_PROBE=1 时实测每个模型是否还有余额(max_tokens=1,几乎不耗额度)
46
+ async function probeIds(key, ids) {
47
+ const ok = []
48
+ const queue = [...ids]
49
+ const worker = async () => {
50
+ while (queue.length) {
51
+ const id = queue.shift()
52
+ try {
53
+ const r = await fetch(`${BASE}/chat/completions`, {
54
+ method: "POST",
55
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
56
+ body: JSON.stringify({ model: id, messages: [{ role: "user", content: "hi" }], max_tokens: 1, stream: false }),
57
+ signal: AbortSignal.timeout(20000),
58
+ })
59
+ if (r.ok) ok.push(id)
60
+ } catch {}
61
+ }
62
+ }
63
+ await Promise.all(Array.from({ length: 8 }, worker))
64
+ return ids.filter((id) => ok.includes(id))
65
+ }
66
+
67
+ let cachedKey = null
68
+ let cachedKeyAt = 0
69
+ let cachedCatalog = null
70
+ let cachedCatalogAt = 0
71
+
72
+ const enc = new TextEncoder()
73
+
74
+ // ---------- 账号密码登录(不用客户端、不用抓包) ----------
75
+ // 链路(来自 scnet-client 源码):POST /ac/api/auth/loginDesktopClient.action
76
+ // username + RSA(password) + encrypted=true + ph=1 → Set-Cookie: Token=…
77
+ // 再用 cookie 调 POST /acx/llm/api-key/createActivityApiKey → {data:{apiKey:"sk-…", baseUrl:…}}
78
+ const WEB_ORIGIN = "https://www.scnet.cn"
79
+ const LOGIN_URL = `${WEB_ORIGIN}/ac/api/auth/loginDesktopClient.action`
80
+ const CAPTCHA_URL = `${WEB_ORIGIN}/ac/api/auth/isNeedCpatcha.action`
81
+ const ACTIVITY_KEY_URL = `${WEB_ORIGIN}/acx/llm/api-key/createActivityApiKey`
82
+ const RSA_PUB = "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALaXEnbjI6fjy+t9W9AiO/KS0q+b/OZFS+7ykinLbiriUx9P8BcuuHnVbXNiZp5jW70eVGBtX4DhGUPzJa1YT/8CAwEAAQ=="
83
+ const UA = "scnet-client/4.1.2"
84
+
85
+ async function rsaEncrypt(plain) {
86
+ const { publicEncrypt, constants } = await import("node:crypto")
87
+ const pem = "-----BEGIN PUBLIC KEY-----\n" + RSA_PUB.match(/.{1,64}/g).join("\n") + "\n-----END PUBLIC KEY-----"
88
+ return publicEncrypt({ key: pem, padding: constants.RSA_PKCS1_PADDING }, Buffer.from(plain, "utf8")).toString("base64")
89
+ }
90
+
91
+ async function needCaptcha() {
92
+ try {
93
+ const r = await fetch(CAPTCHA_URL, { headers: { "user-agent": UA }, signal: AbortSignal.timeout(15000) })
94
+ const j = await r.json()
95
+ return j?.data === true
96
+ } catch {
97
+ return false
98
+ }
99
+ }
100
+
101
+ function cookiesOf(res) {
102
+ const list = typeof res.headers.getSetCookie === "function" ? res.headers.getSetCookie() : []
103
+ const raw = list.length ? list : (res.headers.get("set-cookie") ?? "").split(/,(?=[^;,]+=)/)
104
+ return raw.map((c) => String(c).split(";")[0].trim()).filter(Boolean).join("; ")
105
+ }
106
+
107
+ async function loginWithPassword(username, password) {
108
+ if (await needCaptcha()) {
109
+ throw new Error("该账号当前需要图形验证码。请先用 API Key 登录,或在 scnet.cn 网页登录一次后再试。")
110
+ }
111
+ const body = new URLSearchParams({
112
+ username,
113
+ password: await rsaEncrypt(password),
114
+ captcha: "",
115
+ captchaKey: "",
116
+ encrypted: "true",
117
+ ph: "1",
118
+ })
119
+ const r = await fetch(LOGIN_URL, {
120
+ method: "POST",
121
+ headers: { "content-type": "application/x-www-form-urlencoded", "user-agent": UA },
122
+ body,
123
+ signal: AbortSignal.timeout(20000),
124
+ })
125
+ const j = await r.json()
126
+ if (String(j?.code) !== "0") throw new Error(`SCNet 登录失败:${j?.msg || j?.code || r.status}`)
127
+ const cookie = cookiesOf(r)
128
+ if (!cookie) throw new Error("SCNet 登录成功但没有拿到会话 cookie")
129
+
130
+ const r2 = await fetch(ACTIVITY_KEY_URL, {
131
+ method: "POST",
132
+ headers: { "content-type": "application/json", cookie, "user-agent": UA },
133
+ body: "{}",
134
+ signal: AbortSignal.timeout(20000),
135
+ })
136
+ const j2 = await r2.json()
137
+ const key = j2?.data?.apiKey
138
+ if (!key) throw new Error(`取 API Key 失败:${j2?.msg || j2?.code || r2.status}(账号可能未开通模型服务)`)
139
+ return { key, baseUrl: j2.data.baseUrl, model: j2.data.model }
140
+ }
141
+
142
+ // ---------- 凭据:优先客户端目录,其次显式 key ----------
143
+
144
+ function keysFromClient() {
145
+ const out = []
146
+ try {
147
+ for (const f of readdirSync(CRED_DIR)) {
148
+ if (!f.endsWith(".json")) continue
149
+ const p = join(CRED_DIR, f)
150
+ try {
151
+ const d = JSON.parse(readFileSync(p, "utf8"))
152
+ const k = d?.scnet?.key
153
+ if (typeof k === "string" && k.length > 10) out.push({ key: k, file: f, mtime: statSync(p).mtimeMs })
154
+ } catch {}
155
+ }
156
+ } catch {}
157
+ return out.sort((a, b) => b.mtime - a.mtime)
158
+ }
159
+
160
+ async function keyWorks(key) {
161
+ try {
162
+ const r = await fetch(`${BASE}/models`, {
163
+ headers: { authorization: `Bearer ${key}` },
164
+ signal: AbortSignal.timeout(8000),
165
+ })
166
+ return r.ok
167
+ } catch {
168
+ return false
169
+ }
170
+ }
171
+
172
+ async function clientKey() {
173
+ if (cachedKey && Date.now() - cachedKeyAt < 300_000) return cachedKey
174
+ const candidates = keysFromClient()
175
+ for (const c of candidates) {
176
+ if (await keyWorks(c.key)) {
177
+ cachedKey = c.key
178
+ cachedKeyAt = Date.now()
179
+ return cachedKey
180
+ }
181
+ }
182
+ if (candidates[0]) {
183
+ cachedKey = candidates[0].key
184
+ cachedKeyAt = Date.now()
185
+ return cachedKey
186
+ }
187
+ throw new Error(
188
+ `读不到 SCNet 凭据:${CRED_DIR} 为空或不存在。请先登录 SCNet 客户端(计算服务),或直接粘贴 API Key。`,
189
+ )
190
+ }
191
+
192
+ // 登录凭据支持三种形态:
193
+ // local → 从本机 SCNet 客户端目录读 key
194
+ // sk-xxx → 直接把这个 key 用在 SCNet 平台
195
+ // {"baseUrl":…,"apiKey":…,"models":[…]} → 接任意 OpenAI 兼容服务(分发/自建用)
196
+ async function resolveCred(raw) {
197
+ const s = String(raw ?? "").trim()
198
+ if (!s || /^(local|auto|本机|自动)$/i.test(s)) {
199
+ return { baseUrl: BASE, apiKey: await clientKey(), models: null }
200
+ }
201
+ if (s.startsWith("{")) {
202
+ let j
203
+ try {
204
+ j = JSON.parse(s)
205
+ } catch {
206
+ throw new Error('凭据 JSON 解析失败。形如 {"baseUrl":"https://…/v1","apiKey":"…","models":["m1","m2"]}')
207
+ }
208
+ const apiKey = j.apiKey || j.key
209
+ if (!apiKey) throw new Error("凭据 JSON 缺少 apiKey")
210
+ return {
211
+ baseUrl: String(j.baseUrl || j.baseURL || BASE).replace(/\/+$/, ""),
212
+ apiKey,
213
+ models: Array.isArray(j.models) && j.models.length ? j.models : null,
214
+ }
215
+ }
216
+ return { baseUrl: BASE, apiKey: s, models: null }
217
+ }
218
+
219
+ // ---------- 模型目录:客户端元数据 + 平台 /models 交叉 ----------
220
+
221
+ function clientCatalog() {
222
+ if (cachedCatalog && Date.now() - cachedCatalogAt < 300_000) return cachedCatalog
223
+ try {
224
+ const d = JSON.parse(readFileSync(MODELS_FILE, "utf8"))
225
+ const list = d?.providers?.scnet?.models
226
+ if (Array.isArray(list)) {
227
+ const map = {}
228
+ for (const m of list) {
229
+ if (!m?.id) continue
230
+ map[m.id] = {
231
+ name: m.name || m.id,
232
+ context: m.contextWindow,
233
+ output: m.maxTokens,
234
+ reasoning: !!m.reasoning,
235
+ tool_call: m.toolCalling !== false,
236
+ input: Array.isArray(m.input) ? m.input : ["text"],
237
+ }
238
+ }
239
+ cachedCatalog = map
240
+ cachedCatalogAt = Date.now()
241
+ return map
242
+ }
243
+ } catch {}
244
+ return {}
245
+ }
246
+
247
+ async function platformModels(baseUrl, key) {
248
+ const r = await fetch(`${baseUrl}/models`, { headers: { authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(20000) })
249
+ if (!r.ok) throw new Error(`/models 返回 ${r.status}`)
250
+ const d = await r.json()
251
+ return (d?.data ?? []).map((m) => m.id).filter(Boolean)
252
+ }
253
+
254
+ function toModel(id, meta) {
255
+ const m = meta?.[id] ?? {}
256
+ return {
257
+ name: m.name || id,
258
+ limit: { context: m.context ?? 131072, output: m.output ?? 32768 },
259
+ reasoning: m.reasoning ?? false,
260
+ tool_call: m.tool_call ?? true,
261
+ modalities: { input: m.input ?? ["text"] },
262
+ }
263
+ }
264
+
265
+ // ---------- 插件 ----------
266
+
267
+ export const SCNetAuthPlugin = async ({ client }) => ({
268
+ config: async (cfg) => {
269
+ cfg.provider ??= {}
270
+ cfg.provider[PROVIDER] ??= {
271
+ name: "SCNet 计算互联网",
272
+ npm: "@ai-sdk/openai-compatible",
273
+ api: BASE,
274
+ models: Object.fromEntries(
275
+ Object.entries(FALLBACK).map(([id, m]) => [
276
+ id,
277
+ { name: m.name, limit: { context: m.context, output: m.output }, reasoning: m.reasoning, tool_call: true },
278
+ ]),
279
+ ),
280
+ }
281
+ },
282
+
283
+ auth: {
284
+ provider: PROVIDER,
285
+ methods: [
286
+ {
287
+ type: "api",
288
+ label: "SCNet 账号密码登录",
289
+ prompts: [
290
+ { type: "text", key: "username", message: "SCNet 用户名 / 手机号", placeholder: "手机号或用户名" },
291
+ { type: "password", key: "password", message: "密码" },
292
+ ],
293
+ async authorize(inputs) {
294
+ try {
295
+ const out = await loginWithPassword(String(inputs?.username ?? "").trim(), String(inputs?.password ?? ""))
296
+ return { type: "success", key: out.key, metadata: { username: inputs.username, baseUrl: out.baseUrl } }
297
+ } catch (e) {
298
+ return { type: "failed", error: String(e?.message || e).slice(0, 500) }
299
+ }
300
+ },
301
+ },
302
+ {
303
+ type: "api",
304
+ label: "API Key(local = 读本机客户端;或粘贴 sk-… / JSON)",
305
+ placeholder: "local",
306
+ },
307
+ ],
308
+
309
+ async loader(getAuth) {
310
+ const auth = await getAuth()
311
+ if (auth?.type !== "api") return {}
312
+ const cred = await resolveCred(auth.key)
313
+ const metaBase = typeof auth.metadata?.baseUrl === "string" ? auth.metadata.baseUrl.replace(/\/+$/, "") : ""
314
+ return { baseURL: metaBase || cred.baseUrl, apiKey: cred.apiKey }
315
+ },
316
+ },
317
+
318
+ provider: {
319
+ id: PROVIDER,
320
+ async models(provider, { auth } = {}) {
321
+ let cred
322
+ try {
323
+ cred = await resolveCred(auth?.key)
324
+ } catch {
325
+ return provider.models
326
+ }
327
+ const meta = clientCatalog()
328
+ // 自定义服务:凭据里直接给了模型列表,不用再问
329
+ if (cred.models) {
330
+ const out = {}
331
+ for (const id of cred.models) out[id] = toModel(id, meta)
332
+ return out
333
+ }
334
+ try {
335
+ let ids = await platformModels(cred.baseUrl, cred.apiKey)
336
+ if (!ids.length) return provider.models
337
+ ids = selectedIds(ids)
338
+ if (process.env.SCNET_PROBE === "1") ids = await probeIds(cred.apiKey, ids)
339
+ const out = {}
340
+ for (const id of ids) out[id] = toModel(id, meta)
341
+ return out
342
+ } catch {
343
+ // 平台拉不到就退到客户端目录
344
+ const ids = selectedIds(Object.keys(meta))
345
+ if (!ids.length) return provider.models
346
+ const out = {}
347
+ for (const id of ids) out[id] = toModel(id, meta)
348
+ return out
349
+ }
350
+ },
351
+ },
352
+ })
353
+
354
+ export const _internal = { PROVIDER, BASE, keysFromClient, clientKey, clientCatalog, toModel, loginWithPassword, rsaEncrypt }
package/login.mjs ADDED
@@ -0,0 +1,114 @@
1
+ // SCNet 账号密码登录 → 拿到 API Key → 写进 magpie 的 plugin-auth.json
2
+ //
3
+ // node login.mjs 交互输入用户名/密码
4
+ // node login.mjs <用户名> <密码> 直接传参(脚本用)
5
+ //
6
+ // 不需要 SCNet 客户端,也不需要抓包。
7
+
8
+ import { readFileSync, writeFileSync, copyFileSync, existsSync } from "node:fs"
9
+ import { join } from "node:path"
10
+ import { homedir } from "node:os"
11
+ import { execFileSync } from "node:child_process"
12
+ import { createInterface } from "node:readline"
13
+
14
+ const { _internal } = await import(new URL("./index.mjs", import.meta.url))
15
+ const { loginWithPassword } = _internal
16
+
17
+ function configDir() {
18
+ const xdg = process.env.XDG_CONFIG_HOME
19
+ return xdg ? join(xdg, "magpie") : join(homedir(), ".config", "magpie")
20
+ }
21
+
22
+ function ask(question, hidden = false) {
23
+ return new Promise((resolve) => {
24
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: !!process.stdin.isTTY })
25
+ if (hidden && process.stdin.isTTY) {
26
+ process.stdout.write(question)
27
+ const stdin = process.stdin
28
+ stdin.setRawMode?.(true)
29
+ let buf = ""
30
+ const onData = (c) => {
31
+ const s = c.toString()
32
+ if (s === "\r" || s === "\n") {
33
+ stdin.setRawMode?.(false)
34
+ stdin.removeListener("data", onData)
35
+ process.stdout.write("\n")
36
+ rl.close()
37
+ resolve(buf)
38
+ } else if (s === "\u0003") {
39
+ process.exit(130)
40
+ } else if (s === "\u007f") {
41
+ buf = buf.slice(0, -1)
42
+ } else {
43
+ buf += s
44
+ }
45
+ }
46
+ stdin.on("data", onData)
47
+ return
48
+ }
49
+ rl.question(question, (a) => {
50
+ rl.close()
51
+ resolve(a)
52
+ })
53
+ })
54
+ }
55
+
56
+ // 找到本插件在 magpie 里真实的 provider id(scnet 或 scnet-plugin)
57
+ function providerId() {
58
+ try {
59
+ const out = execFileSync("magpie", ["plugin", "--json"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
60
+ const data = JSON.parse(out)
61
+ const hit = (data.providers ?? []).find((p) => String(p.spec ?? "").includes("scnet-plugin"))
62
+ if (hit?.id) return hit.id
63
+ } catch {}
64
+ return "scnet"
65
+ }
66
+
67
+ let [username, password] = process.argv.slice(2)
68
+ if (!username) username = (await ask("SCNet 用户名 / 手机号: ")).trim()
69
+ if (!password) password = await ask("密码: ", true)
70
+
71
+ if (!username || !password) {
72
+ console.error("用户名和密码都要填。")
73
+ process.exit(1)
74
+ }
75
+
76
+ console.log("正在登录 SCNet …")
77
+ let cred
78
+ try {
79
+ cred = await loginWithPassword(username, password)
80
+ } catch (e) {
81
+ console.error("登录失败:" + (e?.message || e))
82
+ process.exit(1)
83
+ }
84
+ console.log("✓ 登录成功,已取得 API Key:" + cred.key.slice(0, 6) + "…(长度 " + cred.key.length + ")")
85
+
86
+ const dir = configDir()
87
+ const file = join(dir, "plugin-auth.json")
88
+ if (!existsSync(dir)) {
89
+ console.error(`找不到 magpie 配置目录 ${dir},请先启动一次 magpie。`)
90
+ process.exit(1)
91
+ }
92
+
93
+ let db = {}
94
+ if (existsSync(file)) {
95
+ try {
96
+ db = JSON.parse(readFileSync(file, "utf8"))
97
+ } catch {
98
+ db = {}
99
+ }
100
+ copyFileSync(file, file + ".bak-scnet-login")
101
+ }
102
+
103
+ const id = providerId()
104
+ db[id] = {
105
+ type: "api",
106
+ key: cred.key,
107
+ metadata: { username, baseUrl: cred.baseUrl, via: "password-login" },
108
+ }
109
+ writeFileSync(file, JSON.stringify(db, null, 2) + "\n", { mode: 0o600 })
110
+
111
+ console.log(`✓ 已写入 ${file}(provider: ${id})`)
112
+ console.log("")
113
+ console.log("下一步:重启 magpie(或 magpie plugin off/on " + id + "),然后")
114
+ console.log(" magpie provider test " + id + " GLM-5.3-Flash")
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
- {
2
- "name": "magpie-scnet-auth",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
1
+ {
2
+ "name": "magpie-scnet-auth",
3
+ "version": "0.1.1",
4
+ "description": "Magpie / OpenCode provider plugin for SCNet (scnet.cn) LLM models - password login, OpenAI-compatible, tool calls and reasoning passthrough.",
5
+ "type": "module",
6
+ "main": "./index.mjs",
7
+ "files": [
8
+ "index.mjs",
9
+ "login.mjs",
10
+ "README.md",
11
+ "README.zh-CN.md",
12
+ "LICENSE"
13
+ ],
14
+ "keywords": [
15
+ "opencode",
16
+ "opencode-plugin",
17
+ "magpie",
18
+ "scnet",
19
+ "sugon",
20
+ "glm",
21
+ "qwen",
22
+ "deepseek",
23
+ "kimi",
24
+ "minimax"
25
+ ],
26
+ "license": "MIT",
27
+ "magpie": {
28
+ "maxConcurrency": 4,
29
+ "name": "SCNet 计算互联网",
30
+ "summary": {
31
+ "en": "All models on the SCNet platform (GLM-5.3-Flash, Qwen3.8, Kimi-K3 …), reusing the SCNet client's credentials.",
32
+ "zh": "把 SCNet 计算互联网平台的全部模型(GLM-5.3-Flash、Qwen3.8、Kimi-K3…)接进 magpie,直接复用客户端的凭据。"
33
+ },
34
+ "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij48cmVjdCB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgcng9IjI4IiBmaWxsPSIjMjU2M0VCIi8+PHRleHQgeD0iNjQiIHk9Ijg2IiBmb250LWZhbWlseT0iU2Vnb2UgVUksSGVsdmV0aWNhLEFyaWFsLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNjIiIGZvbnQtd2VpZ2h0PSI3MDAiIGZpbGw9IiNmZmYiIHRleHQtYW5jaG9yPSJtaWRkbGUiPlNDPC90ZXh0Pjwvc3ZnPg=="
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/RrOrange/magpie-scnet-auth.git"
39
+ },
40
+ "homepage": "https://github.com/RrOrange/magpie-scnet-auth#readme",
41
+ "bugs": {
42
+ "url": "https://github.com/RrOrange/magpie-scnet-auth/issues"
43
+ }
6
44
  }