dsh-hduhelp-ai 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +142 -2
- package/cordis.patch.yml +20 -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 +60 -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,143 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-hduhelp-ai
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
把 **杭电助手(HDUHelp)的 AI 网关**接进 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness),在 DSH 里直接当模型用。
|
|
4
|
+
|
|
5
|
+
- **面板登录**:设置 → 杭电助手 AI,填学号 + 统一身份认证密码;纯 HTTP 走杭电 CAS(crypto 加密表单,不需要浏览器,<1s)
|
|
6
|
+
- **模型注册**:登录后 `杭电助手` 作为 provider 出现;上游目录 77 个模型,**只注册你勾选的那几个**,选择器不臃肿
|
|
7
|
+
- **原生多轮工具**:走 Responses 协议的 `function_call` / `function_call_output`,ReAct 多轮不降级
|
|
8
|
+
- **额度显示**:输入框工具行、权限按钮右边显示 5h / 7d 两个窗口的用量,429 时显示恢复倒计时
|
|
9
|
+
- **OpenAI 兼容反代**:额外提供 `http://<host>:8787/v1`,给 Cherry Studio / LobeChat / SDK 用
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
输入框工具行、「完全权限」右侧的额度胶囊:
|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
> 接口从 **杭电助手 Beta 0.5.8** 的 Flutter/Dart AOT 包里逆向得到,仅用于个人接入自己账号的模型额度。
|
|
18
|
+
|
|
19
|
+
## 安装
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# 在 DSH profile 里装(推荐)
|
|
23
|
+
dsh plugin --profile web add dsh-hduhelp-ai
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
装完在 `~/.dsh/profiles/<profile>/cordis.patch.yml` 末尾加一条 loader 入口:
|
|
27
|
+
|
|
28
|
+
```yaml
|
|
29
|
+
- insert:
|
|
30
|
+
- id: hduhelp-ai
|
|
31
|
+
name: 'dsh-hduhelp-ai'
|
|
32
|
+
config:
|
|
33
|
+
port: 8787
|
|
34
|
+
apiKey: sk-hduhelp
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
然后刷新 DSH 页面(或等 HMR 重载),设置里会出现「杭电助手 AI」。
|
|
38
|
+
|
|
39
|
+
本地开发可以 `file:` 安装:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
dsh plugin --profile web add file:/path/to/dsh-hduhelp-ai
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## 使用
|
|
46
|
+
|
|
47
|
+
### 1. 登录
|
|
48
|
+
|
|
49
|
+
设置 → 杭电助手 AI → 填学号 / 密码 → 登录。
|
|
50
|
+
|
|
51
|
+
token 存在 `~/.dsh/hduhelp-ai/token.json`(0600),过期自动用 refresh_token 续;
|
|
52
|
+
不想让插件碰密码,也可以手动把 `access_token` 写进这个文件。
|
|
53
|
+
|
|
54
|
+
### 2. 勾选模型
|
|
55
|
+
|
|
56
|
+
面板里「模型注册」列出上游全部 77 个模型(带上下文窗口),搜索 / 勾选 / 保存。
|
|
57
|
+
没勾任何模型时,模型选择器里不会出现「杭电助手」。
|
|
58
|
+
|
|
59
|
+
### 3. 用起来
|
|
60
|
+
|
|
61
|
+
聊天输入框上方的模型选择器里选 **杭电助手 → 任意已勾选模型**,agent 直接跑。
|
|
62
|
+
|
|
63
|
+
> 注意:文本用途的入口协议是 **Responses**(`/openai/v1/responses`),插件内部已处理;
|
|
64
|
+
> 走 `chat/completions` 会被上游拒绝。
|
|
65
|
+
|
|
66
|
+
## OpenAI 兼容反代
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
http://<host>:8787/v1
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
| 路由 | 说明 |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `GET /v1/models` | 上游模型目录 |
|
|
75
|
+
| `POST /v1/chat/completions` | 翻译成 Responses,流式/非流式都支持 |
|
|
76
|
+
| `POST /v1/responses` | 原样透传 |
|
|
77
|
+
| `POST /v1/embeddings`、`/v1/images/*`、`/v1/rerank` | 透传 |
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
curl http://127.0.0.1:8787/v1/chat/completions \
|
|
81
|
+
-H 'Authorization: Bearer sk-hduhelp' \
|
|
82
|
+
-H 'Content-Type: application/json' \
|
|
83
|
+
-d '{"model":"deepseek-flash","messages":[{"role":"user","content":"你好"}],"stream":true}'
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
API Key 默认 `sk-hduhelp`,也可以直接把 `access_token` 当 key 传。
|
|
87
|
+
|
|
88
|
+
## 配置
|
|
89
|
+
|
|
90
|
+
`cordis.patch.yml` 的 `config`:
|
|
91
|
+
|
|
92
|
+
| 键 | 默认 | 说明 |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| `port` | `8787` | 反代端口,`0` 表示不自动启动 |
|
|
95
|
+
| `host` | `0.0.0.0` | 反代监听地址(内网用;想锁死改 `127.0.0.1`) |
|
|
96
|
+
| `purpose` | `app.mate.chat` | 固定用途,插件只暴露 chat 这一条入口 |
|
|
97
|
+
| `apiKey` | `sk-hduhelp` | 客户端用的 key |
|
|
98
|
+
| `defaultModel` | 空 | 请求没带 model 时补的值 |
|
|
99
|
+
| `dataDir` | `~/.dsh/hduhelp-ai` | token / 勾选集 / 模型缓存目录 |
|
|
100
|
+
|
|
101
|
+
## 工作原理(简述)
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
学号+密码
|
|
105
|
+
→ GET /identity/login/url?grant-key=cas&client_id=poplar
|
|
106
|
+
→ CAS 登录页解析 login-croypto(AES key) + flowkey
|
|
107
|
+
→ POST /login 表单(AES-128-ECB + PKCS#7 加密密码,captcha_payload=AES("{}"))
|
|
108
|
+
→ 302 → /identity/login/sso?state&ticket → /auth/callback?code=
|
|
109
|
+
→ POST /identity/login/exchange {code} → access_token / refresh_token
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
模型调用:
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
{api}/purposes/app.mate.chat/openai/v1/responses
|
|
116
|
+
Authorization: Bearer <access_token>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
额度是响应头带回来的:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
x-hduhelp-quota-5h-utilization / x-hduhelp-quota-5h-reset
|
|
123
|
+
x-hduhelp-quota-7d-utilization / x-hduhelp-quota-7d-reset
|
|
124
|
+
retry-after (仅 429)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## 安全
|
|
128
|
+
|
|
129
|
+
- 面板接口要求 `X-DSH-HDUHelp: 1` 头 + Host 白名单,且不发 CORS 头,跨站页面无法静默调用;
|
|
130
|
+
- 密码只在登录子进程的环境变量里出现一次,不落盘;token/勾选文件 0600;
|
|
131
|
+
- 反代默认监听 `0.0.0.0`,只在内网使用;公网部署请改 `127.0.0.1` 或加前置鉴权。
|
|
132
|
+
|
|
133
|
+
## 卸载
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
dsh plugin --profile web remove dsh-hduhelp-ai
|
|
137
|
+
rm -rf ~/.dsh/hduhelp-ai
|
|
138
|
+
# 再删掉 cordis.patch.yml 里的 hduhelp-ai 条目
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT
|
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: ''
|
|
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
|
+
}
|