dsh-hduhelp-ai 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 +21 -0
- package/README.md +166 -2
- package/cordis.patch.yml +20 -0
- package/docs/icon.png +0 -0
- package/docs/screenshot-quota.png +0 -0
- package/docs/screenshot.png +0 -0
- package/lib/adapter.js +420 -0
- package/lib/cas-login.mjs +214 -0
- package/lib/client.js +666 -0
- package/lib/index.js +467 -0
- package/lib/proxy.js +404 -0
- package/lib/responses.js +321 -0
- package/package.json +61 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AlaIchhe
|
|
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,167 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="docs/icon.png" width="96" alt="dsh-hduhelp-ai">
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
<h1 align="center">dsh-hduhelp-ai</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
把杭电助手的 AI 网关接进 DeepSeek Harness。面板里用学号密码登录,选几个模型,剩下的交给 agent。
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="https://www.npmjs.com/package/dsh-hduhelp-ai">
|
|
13
|
+
<img src="https://img.shields.io/badge/npm-dsh--hduhelp--ai-CB3837?style=for-the-badge&logo=npm&logoColor=white" alt="npm">
|
|
14
|
+
</a>
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
<p align="center">
|
|
18
|
+
<a href="https://github.com/AlaIchhe/dsh-hduhelp-ai/releases/latest"><img src="https://img.shields.io/github/v/release/AlaIchhe/dsh-hduhelp-ai?label=release&style=flat-square" alt="Release"></a>
|
|
19
|
+
<a href="https://www.npmjs.com/package/dsh-hduhelp-ai"><img src="https://img.shields.io/npm/v/dsh-hduhelp-ai?style=flat-square" alt="npm"></a>
|
|
20
|
+
<a href="https://github.com/AlaIchhe/dsh-hduhelp-ai/actions"><img src="https://img.shields.io/github/actions/workflow/status/AlaIchhe/dsh-hduhelp-ai/publish.yml?label=CI&style=flat-square" alt="CI"></a>
|
|
21
|
+
<a href="LICENSE"><img src="https://img.shields.io/github/license/AlaIchhe/dsh-hduhelp-ai?style=flat-square" alt="License"></a>
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
<p align="center">
|
|
25
|
+
<sub>一条命令装进 DSH:<code>dsh plugin --profile web add dsh-hduhelp-ai</code></sub>
|
|
26
|
+
</p>
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
<p align="center">
|
|
31
|
+
<img src="docs/screenshot.png" width="720" alt="设置面板">
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
## 能做什么
|
|
35
|
+
|
|
36
|
+
<table>
|
|
37
|
+
<tr>
|
|
38
|
+
<td width="50%" valign="top">
|
|
39
|
+
<strong>面板登录</strong><br>
|
|
40
|
+
设置里填学号和统一身份认证密码,走杭电 CAS 换 token,纯 HTTP 不需要浏览器,通常不到 1 秒。
|
|
41
|
+
</td>
|
|
42
|
+
<td width="50%" valign="top">
|
|
43
|
+
<strong>只注册你选的模型</strong><br>
|
|
44
|
+
上游目录有 77 个模型,插件只把勾选的那几个注册进 DSH 模型列表,选择器保持干净。
|
|
45
|
+
</td>
|
|
46
|
+
</tr>
|
|
47
|
+
<tr>
|
|
48
|
+
<td width="50%" valign="top">
|
|
49
|
+
<strong>原生多轮工具调用</strong><br>
|
|
50
|
+
走 Responses 协议的 <code>function_call</code> / <code>function_call_output</code>,思考—调用—观察的 ReAct 循环不降级。
|
|
51
|
+
</td>
|
|
52
|
+
<td width="50%" valign="top">
|
|
53
|
+
<strong>额度就在手边</strong><br>
|
|
54
|
+
输入框工具行、权限按钮右侧显示 5 小时 / 7 天两个窗口的用量;额度用尽时显示恢复倒计时。
|
|
55
|
+
</td>
|
|
56
|
+
</tr>
|
|
57
|
+
<tr>
|
|
58
|
+
<td width="50%" valign="top">
|
|
59
|
+
<strong>OpenAI 兼容反代</strong><br>
|
|
60
|
+
额外提供 <code>http://<host>:8787/v1</code>,chat/completions 自动翻译成 Responses,供 Cherry Studio / LobeChat / SDK 使用。
|
|
61
|
+
</td>
|
|
62
|
+
<td width="50%" valign="top">
|
|
63
|
+
<strong>登录态自维护</strong><br>
|
|
64
|
+
token 过期用 refresh_token 自动续;刷新失败会在面板里留下可读的错误。
|
|
65
|
+
</td>
|
|
66
|
+
</tr>
|
|
67
|
+
</table>
|
|
68
|
+
|
|
69
|
+
<p align="center">
|
|
70
|
+
<img src="docs/screenshot-quota.png" width="420" alt="额度胶囊">
|
|
71
|
+
</p>
|
|
72
|
+
|
|
73
|
+
## 开始使用
|
|
74
|
+
|
|
75
|
+
1. 装插件:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
dsh plugin --profile web add dsh-hduhelp-ai
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
2. 在 `~/.dsh/profiles/<profile>/cordis.patch.yml` 末尾加一条 loader 入口:
|
|
82
|
+
|
|
83
|
+
```yaml
|
|
84
|
+
- insert:
|
|
85
|
+
- id: hduhelp-ai
|
|
86
|
+
name: 'dsh-hduhelp-ai'
|
|
87
|
+
config:
|
|
88
|
+
port: 8787
|
|
89
|
+
apiKey: sk-hduhelp
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
3. 打开 DSH 设置 →「杭电助手 AI」,用学号 + 数字杭电密码登录。
|
|
93
|
+
|
|
94
|
+
4. 在「模型注册」里勾选要用的模型并保存;回到聊天,在输入框上方的模型选择器里选「杭电助手」。
|
|
95
|
+
|
|
96
|
+
> [!NOTE]
|
|
97
|
+
> 插件的文本用途走 **Responses** 协议(`/openai/v1/responses`),走 chat/completions 会被上游拒绝;
|
|
98
|
+
> 反代那边已经做了翻译,所以普通 OpenAI 客户端不用关心这件事。
|
|
99
|
+
|
|
100
|
+
> [!IMPORTANT]
|
|
101
|
+
> 账号密码只用于换取 token,不落盘:`~/.dsh/hduhelp-ai/token.json` 里只有 access/refresh token(0600)。
|
|
102
|
+
> 面板接口要求 `X-DSH-HDUHelp: 1` 头并校验 Host,跨站页面无法静默调用。
|
|
103
|
+
> 反代默认监听 `0.0.0.0:8787`,只建议在内网使用;公网部署请改成 `127.0.0.1` 或加前置鉴权。
|
|
104
|
+
> 本项目只做协议转换与本地反代,请在你的账号额度内使用,并遵守学校与杭电助手的使用规则。
|
|
105
|
+
|
|
106
|
+
## 反代
|
|
107
|
+
|
|
108
|
+
| 路由 | 说明 |
|
|
109
|
+
|---|---|
|
|
110
|
+
| `GET /v1/models` | 上游模型目录 |
|
|
111
|
+
| `POST /v1/chat/completions` | 翻译成 Responses,流式 / 非流式都支持 |
|
|
112
|
+
| `POST /v1/responses` | 原样透传 |
|
|
113
|
+
| `POST /v1/embeddings`、`/v1/images/*`、`/v1/rerank` | 透传 |
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
curl http://127.0.0.1:8787/v1/chat/completions \
|
|
117
|
+
-H 'Authorization: Bearer sk-hduhelp' \
|
|
118
|
+
-H 'Content-Type: application/json' \
|
|
119
|
+
-d '{"model":"deepseek-flash","messages":[{"role":"user","content":"你好"}],"stream":true}'
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
API Key 默认 `sk-hduhelp`,也可以直接把 `access_token` 当 key 传。
|
|
123
|
+
|
|
124
|
+
## 配置
|
|
125
|
+
|
|
126
|
+
| 键 | 默认 | 说明 |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `port` | `8787` | 反代端口,`0` 表示不自动启动 |
|
|
129
|
+
| `host` | `0.0.0.0` | 反代监听地址 |
|
|
130
|
+
| `purpose` | `app.mate.chat` | 固定用途,插件只暴露 chat 这一条入口 |
|
|
131
|
+
| `apiKey` | `sk-hduhelp` | 客户端用的 key |
|
|
132
|
+
| `defaultModel` | 空 | 请求没带 model 时补的值 |
|
|
133
|
+
| `dataDir` | `~/.dsh/hduhelp-ai` | token / 勾选集 / 模型缓存目录 |
|
|
134
|
+
|
|
135
|
+
## 工作原理
|
|
136
|
+
|
|
137
|
+
```text
|
|
138
|
+
学号 + 密码
|
|
139
|
+
→ GET /identity/login/url?grant-key=cas&client_id=poplar
|
|
140
|
+
→ 解析 CAS 登录页的 login-croypto(AES key) 与 flowkey
|
|
141
|
+
→ POST /login 表单(AES-128-ECB + PKCS#7 加密,captcha_payload=AES("{}"))
|
|
142
|
+
→ 302 → /identity/login/sso?state&ticket → /auth/callback?code=
|
|
143
|
+
→ POST /identity/login/exchange {code} → access_token / refresh_token
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
额度来自响应头:`x-hduhelp-quota-5h-utilization` / `-5h-reset`、`x-hduhelp-quota-7d-*`,429 时附 `retry-after`。
|
|
147
|
+
|
|
148
|
+
## 卸载
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
dsh plugin --profile web remove dsh-hduhelp-ai
|
|
152
|
+
rm -rf ~/.dsh/hduhelp-ai
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
再删掉 `cordis.patch.yml` 里的 `hduhelp-ai` 条目。
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
<p align="center">
|
|
160
|
+
<a href="https://github.com/AlaIchhe/dsh-hduhelp-ai/issues">反馈</a> ·
|
|
161
|
+
<a href="LICENSE">License</a> ·
|
|
162
|
+
<a href="https://www.npmjs.com/package/dsh-hduhelp-ai">npm</a>
|
|
163
|
+
</p>
|
|
164
|
+
|
|
165
|
+
<p align="center">
|
|
166
|
+
<sub>把学校的 AI 额度,接到自己的 agent 上。<br>省下来的时间,拿去写点更有意思的东西。</sub>
|
|
167
|
+
</p>
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# dsh-hduhelp-ai profile patch — insert one host entry. The browser half
|
|
2
|
+
# (lib/client.js) is picked up by dsh-client-modules through this package's
|
|
3
|
+
# `dsh.client` declaration, so the Settings panel appears without extra wiring.
|
|
4
|
+
- insert:
|
|
5
|
+
- id: hduhelp-ai
|
|
6
|
+
name: 'dsh-hduhelp-ai'
|
|
7
|
+
config:
|
|
8
|
+
# OpenAI 兼容反代监听端口(0 表示不自动启动,可在面板里手动开)
|
|
9
|
+
port: 8787
|
|
10
|
+
host: 0.0.0.0
|
|
11
|
+
# 默认用途:app.mate.chat / app.mate.summary / app.mate.title / app.mate.image
|
|
12
|
+
purpose: app.mate.chat
|
|
13
|
+
# 客户端拿这个 key 连反代
|
|
14
|
+
apiKey: sk-hduhelp
|
|
15
|
+
# 没有 model 时补的默认模型(留空交给上游报错)
|
|
16
|
+
defaultModel: ''
|
|
17
|
+
# 允许把 hduhelp access_token 直接当 key 用
|
|
18
|
+
allowPassthrough: true
|
|
19
|
+
# token / 配置存放目录,留空 = ~/.dsh/hduhelp-ai
|
|
20
|
+
dataDir: ''
|
package/docs/icon.png
ADDED
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/adapter.js
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH LLM adapter:把杭电助手的 AI 网关注册成 DSH 的 provider(模型选择器里的「杭电助手」)。
|
|
3
|
+
*
|
|
4
|
+
* 契约(@deepseek-ai/dsh-llm 的 LlmAdapter):
|
|
5
|
+
* providerInfo / providerRetryPolicy / imageRequestPricing
|
|
6
|
+
* listModels / resolveModel / prepareCall / stream
|
|
7
|
+
*
|
|
8
|
+
* 上游实测:文本用途的入口协议是 **Responses**(/openai/v1/responses),
|
|
9
|
+
* 见 lib/responses.js。模型目录走 /openai/v1/models,自带 context_length。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { toResponsesInput, postResponses, readResponsesStream } from './responses.js';
|
|
15
|
+
|
|
16
|
+
const DEFAULT_CONTEXT = 128000;
|
|
17
|
+
const DEFAULT_MAX_TOKENS = 32000;
|
|
18
|
+
const CATALOG_TTL = 5 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
const VISION_HINTS = /(gpt-4o|gpt-4\.1|gpt-5|o3|o4|claude|gemini|qwen-?vl|qwen2\.5-vl|qwen3-vl|glm-4v|glm-4\.5v|doubao.*vision|step-1v|step-1o|kimi.*vision|minimax.*vl|grok.*vision|vision)/i;
|
|
21
|
+
|
|
22
|
+
const TRANSIENT = /(stream ended without a terminal event|no_available_connectors|模型服务暂时不可用|稍后再试|temporarily|timeout|ETIMEDOUT|ECONNRESET|socket hang up|overloaded|rate limit|429)/i;
|
|
23
|
+
|
|
24
|
+
function llmFailure(message, code, extra = {}) {
|
|
25
|
+
const err = new Error(message);
|
|
26
|
+
Object.defineProperty(err, 'failure', {
|
|
27
|
+
value: Object.freeze({ message, code, ...extra }),
|
|
28
|
+
enumerable: true,
|
|
29
|
+
writable: false,
|
|
30
|
+
});
|
|
31
|
+
return err;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function classifyFailure(error, status) {
|
|
35
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
36
|
+
if (error?.name === 'AbortError') return llmFailure(message || 'aborted', 'ABORTED');
|
|
37
|
+
if (status === 401 || status === 403) return llmFailure(message, 'INVALID_CREDENTIAL', { status });
|
|
38
|
+
if (status === 429) return llmFailure(message, 'RATE_LIMIT', { status });
|
|
39
|
+
if (status && status >= 500) return llmFailure(message, 'SERVER', { status });
|
|
40
|
+
if (TRANSIENT.test(message)) return llmFailure(message, 'SERVER', { status: status || 502 });
|
|
41
|
+
return llmFailure(message, 'UNKNOWN', status ? { status } : {});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function pickFinish(status, incomplete, sawToolCall) {
|
|
45
|
+
if (sawToolCall) return { kind: 'tool-calls' };
|
|
46
|
+
if (status === 'incomplete') {
|
|
47
|
+
if (incomplete === 'max_output_tokens' || incomplete === 'max_tokens') return { kind: 'max-tokens' };
|
|
48
|
+
if (incomplete === 'content_filter') return { kind: 'error', failure: { message: '内容被上游过滤', code: 'CONTENT_FILTERED' } };
|
|
49
|
+
}
|
|
50
|
+
return { kind: 'stop' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class HduhelpAdapter {
|
|
54
|
+
constructor(opts) {
|
|
55
|
+
this.providerId = opts.providerId || 'hduhelp';
|
|
56
|
+
this.displayName = opts.displayName || '杭电助手';
|
|
57
|
+
this.apiBase = (opts.apiBase || 'https://api.hduhelp.com/hduhelp-neo/ai/v1').replace(/\/+$/, '');
|
|
58
|
+
this.purpose = opts.purpose || 'app.mate.chat';
|
|
59
|
+
this.getToken = opts.getToken;
|
|
60
|
+
this.refreshToken = opts.refreshToken;
|
|
61
|
+
this.dataDir = opts.dataDir;
|
|
62
|
+
this.defaultContext = Number(opts.contextWindow || DEFAULT_CONTEXT);
|
|
63
|
+
this.attachments = opts.attachments;
|
|
64
|
+
this.onQuota = opts.onQuota;
|
|
65
|
+
this.getSelected = opts.getSelected;
|
|
66
|
+
this.log = opts.log || (() => {});
|
|
67
|
+
this.catalogFile = path.join(this.dataDir, 'models.json');
|
|
68
|
+
this.catalog = [];
|
|
69
|
+
this.catalogAt = 0;
|
|
70
|
+
this.refreshing = null;
|
|
71
|
+
try {
|
|
72
|
+
const saved = JSON.parse(fs.readFileSync(this.catalogFile, 'utf8'));
|
|
73
|
+
if (Array.isArray(saved?.models)) {
|
|
74
|
+
this.catalog = saved.models;
|
|
75
|
+
}
|
|
76
|
+
} catch { /* 首次运行没有缓存 */ }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---- 契约 --------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
providerInfo(provider) {
|
|
82
|
+
return { id: provider, name: this.displayName };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
providerRetryPolicy() {
|
|
86
|
+
return {
|
|
87
|
+
mode: 'normal',
|
|
88
|
+
maxRetries: 3,
|
|
89
|
+
retryableCodes: ['RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE'],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
imageRequestPricing() {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async listModels(provider) {
|
|
98
|
+
if (this.getToken()) await this.refreshModels().catch(() => {});
|
|
99
|
+
var selected = typeof this.getSelected === 'function' ? this.getSelected() : null;
|
|
100
|
+
var pool = selected && selected.size ? this.catalog.filter((m) => selected.has(m.id)) : [];
|
|
101
|
+
return pool.map((m) => ({
|
|
102
|
+
provider,
|
|
103
|
+
id: m.id,
|
|
104
|
+
name: m.name || m.id,
|
|
105
|
+
inputModalities: m.vision ? ['text', 'image'] : ['text'],
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async resolveModel(provider, model) {
|
|
110
|
+
const entry = this.catalog.find((m) => m.id === model);
|
|
111
|
+
const contextWindow = Number(entry?.contextWindow) || this.defaultContext;
|
|
112
|
+
return {
|
|
113
|
+
provider,
|
|
114
|
+
id: model,
|
|
115
|
+
name: entry?.name || model,
|
|
116
|
+
inputModalities: entry?.vision ? ['text', 'image'] : ['text'],
|
|
117
|
+
context: { contextWindow },
|
|
118
|
+
defaultMaxTokens: Math.min(DEFAULT_MAX_TOKENS, contextWindow),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async prepareCall(provider, model) {
|
|
123
|
+
const info = await this.resolveModel(provider, model);
|
|
124
|
+
return {
|
|
125
|
+
model: info,
|
|
126
|
+
stream: (options) => this.stream({ ...options, model, provider }),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---- 模型目录 ----------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
async refreshModels(force = false) {
|
|
133
|
+
const purpose = this.purpose;
|
|
134
|
+
const fresh = Date.now() - this.catalogAt < CATALOG_TTL && this.catalog.length > 0;
|
|
135
|
+
if (fresh && !force) return;
|
|
136
|
+
if (this.refreshing) return this.refreshing;
|
|
137
|
+
this.refreshing = (async () => {
|
|
138
|
+
const token = this.getToken();
|
|
139
|
+
if (!token) return;
|
|
140
|
+
try {
|
|
141
|
+
const res = await fetch(`${this.apiBase}/purposes/${encodeURIComponent(purpose)}/openai/v1/models`, {
|
|
142
|
+
headers: {
|
|
143
|
+
authorization: `Bearer ${token}`,
|
|
144
|
+
accept: 'application/json',
|
|
145
|
+
'x-device-id': 'dsh-hduhelp-ai',
|
|
146
|
+
'x-device-name': 'DSH',
|
|
147
|
+
'x-hduhelp-source': 'app',
|
|
148
|
+
},
|
|
149
|
+
signal: AbortSignal.timeout(20000),
|
|
150
|
+
});
|
|
151
|
+
if (res.status === 401 && this.refreshToken) {
|
|
152
|
+
const t = await this.refreshToken();
|
|
153
|
+
if (t) {
|
|
154
|
+
this.refreshing = null;
|
|
155
|
+
return this.refreshModels(true);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (!res.ok) return;
|
|
159
|
+
const json = await res.json();
|
|
160
|
+
const list = Array.isArray(json?.data) ? json.data : Array.isArray(json?.models) ? json.models : Array.isArray(json) ? json : [];
|
|
161
|
+
const models = list
|
|
162
|
+
.map((m) => (typeof m === 'string' ? { id: m } : m || {}))
|
|
163
|
+
.map((m) => ({
|
|
164
|
+
id: String(m.id || m.model || '').trim(),
|
|
165
|
+
name: String(m.name || m.id || '').trim(),
|
|
166
|
+
contextWindow: Number(m.context_window || m.context_length || m.contextWindow || 0) || 0,
|
|
167
|
+
vision: VISION_HINTS.test(String(m.id || '')),
|
|
168
|
+
}))
|
|
169
|
+
.filter((m) => m.id);
|
|
170
|
+
if (!models.length) return;
|
|
171
|
+
this.catalog = models;
|
|
172
|
+
this.catalogAt = Date.now();
|
|
173
|
+
try {
|
|
174
|
+
fs.writeFileSync(this.catalogFile, JSON.stringify({ purpose, at: this.catalogAt, models }, null, 2), { mode: 0o600 });
|
|
175
|
+
} catch { /* 缓存写失败不影响使用 */ }
|
|
176
|
+
this.log(`模型目录 ${models.length} 项`);
|
|
177
|
+
} catch (e) {
|
|
178
|
+
this.log('拉取模型目录失败:', e.message);
|
|
179
|
+
} finally {
|
|
180
|
+
this.refreshing = null;
|
|
181
|
+
}
|
|
182
|
+
})();
|
|
183
|
+
return this.refreshing;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---- 流式请求 ----------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
async *stream(options) {
|
|
189
|
+
let attempts = 0;
|
|
190
|
+
let emitted = false;
|
|
191
|
+
for (;;) {
|
|
192
|
+
try {
|
|
193
|
+
for await (const chunk of this.#streamOnce(options)) {
|
|
194
|
+
emitted = true;
|
|
195
|
+
yield chunk;
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
} catch (error) {
|
|
199
|
+
const failure = classifyFailure(error, error?.status);
|
|
200
|
+
const retryable = failure.failure.code === 'SERVER' || failure.failure.code === 'RATE_LIMIT';
|
|
201
|
+
if (!emitted && retryable && attempts < 2) {
|
|
202
|
+
attempts += 1;
|
|
203
|
+
this.log(`上游瞬时失败,重试 ${attempts}/2:`, failure.message);
|
|
204
|
+
await new Promise((r) => setTimeout(r, 800 * attempts));
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
throw failure;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 从响应头里读额度窗口(x-hduhelp-quota-5h/7d-*)。 */
|
|
213
|
+
#reportQuota(res, provider) {
|
|
214
|
+
if (typeof this.onQuota !== 'function') return;
|
|
215
|
+
try {
|
|
216
|
+
const h = res.headers;
|
|
217
|
+
const util5 = h.get('x-hduhelp-quota-5h-utilization');
|
|
218
|
+
const reset5 = h.get('x-hduhelp-quota-5h-reset');
|
|
219
|
+
const util7 = h.get('x-hduhelp-quota-7d-utilization');
|
|
220
|
+
const reset7 = h.get('x-hduhelp-quota-7d-reset');
|
|
221
|
+
const retryAfter = h.get('retry-after');
|
|
222
|
+
if (util5 === null && util7 === null && retryAfter === null) return;
|
|
223
|
+
this.onQuota({
|
|
224
|
+
provider,
|
|
225
|
+
status: res.status,
|
|
226
|
+
exhausted: res.status === 429,
|
|
227
|
+
retryAfter: retryAfter ? Number(retryAfter) : null,
|
|
228
|
+
windows: {
|
|
229
|
+
'5h': util5 !== null ? { utilization: Number(util5), resetAt: reset5 ? Number(reset5) : null } : null,
|
|
230
|
+
'7d': util7 !== null ? { utilization: Number(util7), resetAt: reset7 ? Number(reset7) : null } : null,
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
} catch { /* 头部不规范就忽略 */ }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 把 DSH 的图片附件读成 data URL,供 vision 模型使用。 */
|
|
237
|
+
async #readImages(messages, signal) {
|
|
238
|
+
const attachments = this.attachments;
|
|
239
|
+
if (!attachments || typeof attachments.readImageRequest !== 'function') return undefined;
|
|
240
|
+
const refs = new Map();
|
|
241
|
+
for (const m of messages || []) {
|
|
242
|
+
if (!m || !Array.isArray(m.content)) continue;
|
|
243
|
+
for (const b of m.content) {
|
|
244
|
+
if (!b || b.type !== 'image' || !b.attachment?.attachmentId) continue;
|
|
245
|
+
refs.set(b.attachment.attachmentId, b.attachment);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (!refs.size) return undefined;
|
|
249
|
+
const out = new Map();
|
|
250
|
+
await Promise.all([...refs.entries()].map(async ([id, ref]) => {
|
|
251
|
+
try {
|
|
252
|
+
const read = await attachments.readImageRequest(ref, undefined, signal);
|
|
253
|
+
if (!read || read.bytes <= 0 || !read.data?.length) return;
|
|
254
|
+
const mediaType = read.mediaType || ref.mediaType || 'image/png';
|
|
255
|
+
const base64 = Buffer.from(read.data.buffer, read.data.byteOffset, read.data.byteLength).toString('base64');
|
|
256
|
+
out.set(id, `data:${mediaType};base64,${base64}`);
|
|
257
|
+
} catch (e) {
|
|
258
|
+
this.log('读取图片失败:', e.message);
|
|
259
|
+
}
|
|
260
|
+
}));
|
|
261
|
+
return out.size ? out : undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async *#streamOnce(options) {
|
|
265
|
+
const purpose = this.purpose;
|
|
266
|
+
const token = this.getToken();
|
|
267
|
+
if (!token) throw new Error('杭电助手还没有登录:请到 设置 → 杭电助手 AI 用学号密码登录');
|
|
268
|
+
|
|
269
|
+
const entry = this.catalog.find((m) => m.id === options.model);
|
|
270
|
+
const vision = entry?.vision === true;
|
|
271
|
+
const images = vision ? await this.#readImages(options.messages, options.signal) : undefined;
|
|
272
|
+
const hasTools = Array.isArray(options.tools) && options.tools.length > 0;
|
|
273
|
+
|
|
274
|
+
const buildBody = (structuredTools) => {
|
|
275
|
+
const { instructions, input } = toResponsesInput(options.messages, {
|
|
276
|
+
system: options.system,
|
|
277
|
+
vision,
|
|
278
|
+
images,
|
|
279
|
+
structuredTools,
|
|
280
|
+
toolProtocol: hasTools,
|
|
281
|
+
});
|
|
282
|
+
const body = { model: options.model, input, stream: true };
|
|
283
|
+
if (instructions) body.instructions = instructions;
|
|
284
|
+
if (typeof options.temperature === 'number') body.temperature = options.temperature;
|
|
285
|
+
if (typeof options.maxTokens === 'number' && options.maxTokens > 0) body.max_output_tokens = options.maxTokens;
|
|
286
|
+
if (hasTools) {
|
|
287
|
+
body.tools = options.tools.map((t) => ({
|
|
288
|
+
type: 'function',
|
|
289
|
+
name: t.name,
|
|
290
|
+
description: t.description || '',
|
|
291
|
+
parameters: t.parameters || { type: 'object', properties: {} },
|
|
292
|
+
}));
|
|
293
|
+
body.tool_choice = 'auto';
|
|
294
|
+
}
|
|
295
|
+
return body;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
let structuredTools = true;
|
|
299
|
+
let body = buildBody(true);
|
|
300
|
+
const send = (tk) => postResponses({ purpose, token: tk, body, signal: options.signal });
|
|
301
|
+
let res = await send(token);
|
|
302
|
+
this.#reportQuota(res, 'hduhelp');
|
|
303
|
+
if (res.status === 401 && this.refreshToken) {
|
|
304
|
+
const fresh = await this.refreshToken();
|
|
305
|
+
if (fresh) { res = await send(fresh); this.#reportQuota(res, 'hduhelp'); }
|
|
306
|
+
}
|
|
307
|
+
if (res.status === 400 && structuredTools && hasTools) {
|
|
308
|
+
// 结构化工具被拒是协议问题,不代表额度异常
|
|
309
|
+
// 结构化工具 item 被拒(例如历史里的 call_id 太旧、上游不再认),
|
|
310
|
+
// 退回文本工具协议再试一次。
|
|
311
|
+
const raw400 = await res.text().catch(() => '');
|
|
312
|
+
this.log('结构化工具 item 被拒,退回文本协议:', raw400.slice(0, 120));
|
|
313
|
+
structuredTools = false;
|
|
314
|
+
body = buildBody(false);
|
|
315
|
+
res = await send(token);
|
|
316
|
+
this.#reportQuota(res, 'hduhelp');
|
|
317
|
+
}
|
|
318
|
+
if (!res.ok) {
|
|
319
|
+
const raw = await res.text().catch(() => '');
|
|
320
|
+
let msg = raw.trim().slice(0, 500);
|
|
321
|
+
try {
|
|
322
|
+
const j = JSON.parse(raw);
|
|
323
|
+
msg = j?.error?.message || j?.message || msg;
|
|
324
|
+
} catch { /* 保留原文 */ }
|
|
325
|
+
const err = new Error(`杭电助手上游 ${res.status}: ${msg || '无响应内容'}`);
|
|
326
|
+
err.status = res.status;
|
|
327
|
+
throw err;
|
|
328
|
+
}
|
|
329
|
+
if (!res.body) throw new Error('杭电助手上游没有返回 body');
|
|
330
|
+
|
|
331
|
+
// ---- 归一化事件 -> DSH StreamChunk ----
|
|
332
|
+
let nextIndex = 0;
|
|
333
|
+
let textIndex = -1;
|
|
334
|
+
let reasoningIndex = -1;
|
|
335
|
+
let textOpen = false;
|
|
336
|
+
let reasoningOpen = false;
|
|
337
|
+
const textAcc = new Map();
|
|
338
|
+
const reasoningAcc = new Map();
|
|
339
|
+
const toolIndex = new Map();
|
|
340
|
+
const toolName = new Map();
|
|
341
|
+
const toolArgs = new Map();
|
|
342
|
+
const toolClosed = new Set();
|
|
343
|
+
let sawToolCall = false;
|
|
344
|
+
|
|
345
|
+
for await (const evt of readResponsesStream(res.body)) {
|
|
346
|
+
if (evt.type === 'reasoning') {
|
|
347
|
+
if (!reasoningOpen) {
|
|
348
|
+
reasoningIndex = nextIndex++;
|
|
349
|
+
reasoningOpen = true;
|
|
350
|
+
yield { type: 'block-start', index: reasoningIndex, blockType: 'reasoning' };
|
|
351
|
+
}
|
|
352
|
+
reasoningAcc.set(reasoningIndex, (reasoningAcc.get(reasoningIndex) || '') + evt.text);
|
|
353
|
+
yield { type: 'reasoning-delta', index: reasoningIndex, text: evt.text };
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (evt.type === 'text') {
|
|
357
|
+
if (reasoningOpen) {
|
|
358
|
+
reasoningOpen = false;
|
|
359
|
+
yield { type: 'block-end', index: reasoningIndex, block: { type: 'reasoning', text: reasoningAcc.get(reasoningIndex) || '' } };
|
|
360
|
+
}
|
|
361
|
+
if (!textOpen) {
|
|
362
|
+
textIndex = nextIndex++;
|
|
363
|
+
textOpen = true;
|
|
364
|
+
yield { type: 'block-start', index: textIndex, blockType: 'text' };
|
|
365
|
+
}
|
|
366
|
+
textAcc.set(textIndex, (textAcc.get(textIndex) || '') + evt.text);
|
|
367
|
+
yield { type: 'text-delta', index: textIndex, text: evt.text };
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (evt.type === 'tool-start') {
|
|
371
|
+
sawToolCall = true;
|
|
372
|
+
const index = nextIndex++;
|
|
373
|
+
toolIndex.set(evt.id, index);
|
|
374
|
+
toolName.set(evt.id, evt.name);
|
|
375
|
+
toolArgs.set(evt.id, '');
|
|
376
|
+
yield { type: 'block-start', index, blockType: 'tool-call' };
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (evt.type === 'tool-args') {
|
|
380
|
+
const index = toolIndex.get(evt.id);
|
|
381
|
+
if (index === undefined) continue;
|
|
382
|
+
toolArgs.set(evt.id, (toolArgs.get(evt.id) || '') + evt.delta);
|
|
383
|
+
yield { type: 'tool-call-delta', index, id: evt.id, argumentsDelta: evt.delta };
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (evt.type === 'tool-end') {
|
|
387
|
+
const index = toolIndex.get(evt.id);
|
|
388
|
+
if (index === undefined || toolClosed.has(evt.id)) continue;
|
|
389
|
+
toolClosed.add(evt.id);
|
|
390
|
+
const args = evt.args || toolArgs.get(evt.id) || '{}';
|
|
391
|
+
yield { type: 'block-end', index, block: { type: 'tool-call', id: evt.id, name: toolName.get(evt.id) || '', arguments: args } };
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (evt.type === 'usage') {
|
|
395
|
+
yield { type: 'usage', usage: { inputTokens: evt.inputTokens || 0, outputTokens: evt.outputTokens || 0 } };
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (evt.type === 'finish') {
|
|
399
|
+
if (reasoningOpen) {
|
|
400
|
+
yield { type: 'block-end', index: reasoningIndex, block: { type: 'reasoning', text: reasoningAcc.get(reasoningIndex) || '' } };
|
|
401
|
+
}
|
|
402
|
+
if (textOpen) {
|
|
403
|
+
yield { type: 'block-end', index: textIndex, block: { type: 'text', text: textAcc.get(textIndex) || '' } };
|
|
404
|
+
}
|
|
405
|
+
yield { type: 'finish', reason: pickFinish(evt.status, evt.incompleteReason, sawToolCall) };
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// 流提前结束的兜底
|
|
411
|
+
if (reasoningOpen) {
|
|
412
|
+
yield { type: 'block-end', index: reasoningIndex, block: { type: 'reasoning', text: reasoningAcc.get(reasoningIndex) || '' } };
|
|
413
|
+
}
|
|
414
|
+
if (textOpen) {
|
|
415
|
+
yield { type: 'block-end', index: textIndex, block: { type: 'text', text: textAcc.get(textIndex) || '' } };
|
|
416
|
+
}
|
|
417
|
+
yield { type: 'usage', usage: { inputTokens: 0, outputTokens: 0 } };
|
|
418
|
+
yield { type: 'finish', reason: pickFinish('completed', '', sawToolCall) };
|
|
419
|
+
}
|
|
420
|
+
}
|