dsh-multi2api 0.1.4
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 +23 -0
- package/README.md +69 -0
- package/bin/multi2api-darwin-amd64 +0 -0
- package/bin/multi2api-darwin-arm64 +0 -0
- package/bin/multi2api-linux-amd64 +0 -0
- package/bin/multi2api-linux-arm64 +0 -0
- package/bin/multi2api-win32-x64.exe +0 -0
- package/cordis.patch.yml +3 -0
- package/go-patch/multi2api-plugin-changes.patch +417 -0
- package/icon.svg +6 -0
- package/lib/client.js +960 -0
- package/lib/index.js +1678 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +62 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 moze30
|
|
4
|
+
Copyright (c) 2026 itamzxm (the bundled `multi2api` binaries are built from
|
|
5
|
+
https://github.com/itamzxm/ai-multi-platform-proxy, MIT licensed)
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# dsh-multi2api
|
|
2
|
+
|
|
3
|
+
把**多个 WorkBuddy(腾讯 CodeBuddy)账号**变成 DeepSeek Harness 里一个个**互不干扰的模型**:
|
|
4
|
+
一个账号一个模型,用哪个号就切到哪个模型,不会串号、不会自己换号。
|
|
5
|
+
|
|
6
|
+
## 它做了什么
|
|
7
|
+
|
|
8
|
+
- **自带程序**:包里带好了各个平台编译好的 `multi2api` 程序(Linux / macOS / Windows,x64 与 arm64),
|
|
9
|
+
装上就能跑,机器上**不需要装 Go**。
|
|
10
|
+
- **进程托管**:插件启动时自动拉起 `multi2api`,崩了会自动重启;点"重启服务"手动重启。
|
|
11
|
+
- **一账号一模型**:账号池里每个账号都会变成 DSH 里的一个模型,
|
|
12
|
+
切模型 = 切账号,互不影响。
|
|
13
|
+
- **自动检测上游模型**:直接问上游要可用模型清单(模型名、上下文长度、能力、积分倍率都是上游实时给的),
|
|
14
|
+
不用手动维护。上游有几个模型,面板里就能看到几个。
|
|
15
|
+
- **模型名 + 账号名**:每个账号 × 每个上游模型都会拼成一条,
|
|
16
|
+
名字形如「GLM-5.3 · 13800138000」,一眼就知道用哪个号跑哪个模型。
|
|
17
|
+
- **积分倍率可见**:面板里有一张表,列出每个模型的倍率(比如 `x0.11 credits`),花多少心里有数。
|
|
18
|
+
- **自动模式**:另有一个 `auto` 模型,走"积分最高的健康账号",适合不想管号的时候。
|
|
19
|
+
- **选择启用**:上游模型和账号都能单独开关。没启用的模型不会出现在聊天界面的模型列表里,
|
|
20
|
+
关掉的账号也不会占位置——只留你要用的那几条,清清爽爽。
|
|
21
|
+
- **网页管理面板**:在「设置 → 插件」里多一个页签,可以开关模型、开关账号、看倍率、看余额、加号、删号、导号、看日志、重启。
|
|
22
|
+
|
|
23
|
+
## 安装
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install dsh-multi2api
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
装完后在 DSH 的插件管理里启用(或让 `dsh plugin install` 直接装)。
|
|
30
|
+
|
|
31
|
+
## 使用
|
|
32
|
+
|
|
33
|
+
1. 打开「设置 → 插件 → Multi2API 账号池」。
|
|
34
|
+
2. 点「添加账号」,浏览器会打开登录页,用手机号登录 WorkBuddy;面板会自动把账号存下来。
|
|
35
|
+
3. 也可以点「导入账号文件」,一次选多个别的机器导出的凭证 JSON,批量加号。
|
|
36
|
+
4. 在面板的模型表里给要用的模型打勾、在账号卡片上开关账号。**只有打勾的模型和开着的账号**,
|
|
37
|
+
才会出现在聊天界面的模型列表里;没打勾的一律不显示,列表不会被塞满。
|
|
38
|
+
5. 回到会话,在模型选择里挑「模型名 · 账号名」的那条(模型 ID 形如 `glm-5.3-13800138000`,
|
|
39
|
+
前面是上游模型,后面是账号别名)。
|
|
40
|
+
6. 想不管号的时候,选 `auto`。
|
|
41
|
+
|
|
42
|
+
## 数据放在哪
|
|
43
|
+
|
|
44
|
+
默认 `~/.dsh/multi2api/`:
|
|
45
|
+
|
|
46
|
+
- `auths/` —— 各账号的登录信息(权限 600)
|
|
47
|
+
- `config.json` —— 被托管程序的配置(插件自动生成)
|
|
48
|
+
- `api_key` —— 本地接口密钥(插件自动生成)
|
|
49
|
+
- `selection.json` —— 哪些模型启用、哪些账号关掉
|
|
50
|
+
- `auto_checkin.json` —— 自动签到开关
|
|
51
|
+
- `data/state.json` —— 运行状态
|
|
52
|
+
|
|
53
|
+
可以在插件设置里改「数据目录」。
|
|
54
|
+
|
|
55
|
+
## 注意事项
|
|
56
|
+
|
|
57
|
+
- 账号的**增删需要重启被托管程序**才会生效,面板会自动帮你重启。
|
|
58
|
+
- 默认本地端口 7864,被占用时会自动往后找空闲端口。
|
|
59
|
+
- 如果之前手动跑过 `multi2api`,把它的 `auths/*.json` 复制到本插件的数据目录,
|
|
60
|
+
或者在面板里用「导入账号文件」导入即可。
|
|
61
|
+
|
|
62
|
+
## 许可
|
|
63
|
+
|
|
64
|
+
MIT
|
|
65
|
+
|
|
66
|
+
## 致谢
|
|
67
|
+
|
|
68
|
+
包里自带的 `multi2api` 程序基于 [itamzxm/ai-multi-platform-proxy](https://github.com/itamzxm/ai-multi-platform-proxy)(MIT 许可)修改后编译,
|
|
69
|
+
按其许可要求在此声明。本插件包内**不含任何账号凭证**。
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
--- a/cmd/server/config.go
|
|
2
|
+
+++ b/cmd/server/config.go
|
|
3
|
+
@@ -67,6 +67,9 @@
|
|
4
|
+
type WorkBuddyScheduleConfig struct {
|
|
5
|
+
CheckinHours []int `json:"checkin_hours"` // [9,21]
|
|
6
|
+
KeepaliveHours []int `json:"keepalive_hours"` // [22]
|
|
7
|
+
+ // AutoCheckin 是否自动签到:控制「整点定时签到」与「进程启动补签」两处。
|
|
8
|
+
+ // nil = 视为 true(保持旧行为)。用指针才能区分「未配置」与「显式关闭」。
|
|
9
|
+
+ AutoCheckin *bool `json:"auto_checkin"`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// WorkBuddyProviderConfig WorkBuddy provider 分节。
|
|
13
|
+
@@ -139,6 +142,7 @@
|
|
14
|
+
c.Providers.WorkBuddy.Cooldown.ErrCooldown = "10m"
|
|
15
|
+
c.Providers.WorkBuddy.Schedule.CheckinHours = []int{9, 21}
|
|
16
|
+
c.Providers.WorkBuddy.Schedule.KeepaliveHours = []int{22}
|
|
17
|
+
+ c.Providers.WorkBuddy.Schedule.AutoCheckin = boolPtr(true)
|
|
18
|
+
c.Providers.WorkBuddy.Upstream.TimeoutSeconds = 120
|
|
19
|
+
|
|
20
|
+
c.Providers.QClaw.AuthDir = ""
|
|
21
|
+
@@ -332,6 +336,9 @@
|
|
22
|
+
if len(w.Schedule.KeepaliveHours) == 0 {
|
|
23
|
+
w.Schedule.KeepaliveHours = []int{22}
|
|
24
|
+
}
|
|
25
|
+
+ if w.Schedule.AutoCheckin == nil {
|
|
26
|
+
+ w.Schedule.AutoCheckin = boolPtr(true)
|
|
27
|
+
+ }
|
|
28
|
+
if w.Upstream.TimeoutSeconds <= 0 {
|
|
29
|
+
w.Upstream.TimeoutSeconds = 120
|
|
30
|
+
}
|
|
31
|
+
@@ -399,6 +406,8 @@
|
|
32
|
+
return ""
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
+func boolPtr(v bool) *bool { return &v }
|
|
36
|
+
+
|
|
37
|
+
func parseCSInts(s string) ([]int, error) {
|
|
38
|
+
parts := strings.Split(s, ",")
|
|
39
|
+
out := make([]int, 0, len(parts))
|
|
40
|
+
--- a/cmd/server/main.go
|
|
41
|
+
+++ b/cmd/server/main.go
|
|
42
|
+
@@ -69,6 +69,7 @@
|
|
43
|
+
WorkBuddy: scheduler.WorkBuddySchedule{
|
|
44
|
+
CheckinHours: cfg.Providers.WorkBuddy.Schedule.CheckinHours,
|
|
45
|
+
KeepaliveHours: cfg.Providers.WorkBuddy.Schedule.KeepaliveHours,
|
|
46
|
+
+ AutoCheckin: cfg.Providers.WorkBuddy.Schedule.AutoCheckin,
|
|
47
|
+
},
|
|
48
|
+
QClaw: scheduler.QClawSchedule{
|
|
49
|
+
KeepaliveHours: cfg.Providers.QClaw.Schedule.KeepaliveHours,
|
|
50
|
+
@@ -110,10 +111,13 @@
|
|
51
|
+
defer stop()
|
|
52
|
+
go sch.Run(ctx)
|
|
53
|
+
// 调度器只认整点(9/21),容器重启易错过 9 点场;启动后补一次签到(幂等,已签视为成功)。
|
|
54
|
+
- go func() {
|
|
55
|
+
- time.Sleep(3 * time.Second)
|
|
56
|
+
- sch.RunCheckinNowFor(provider.PlatformWorkBuddy)
|
|
57
|
+
- }()
|
|
58
|
+
+ // 由 providers.workbuddy.schedule.auto_checkin 控制;显式 false 时不补签。
|
|
59
|
+
+ if cfg.Providers.WorkBuddy.Schedule.AutoCheckin == nil || *cfg.Providers.WorkBuddy.Schedule.AutoCheckin {
|
|
60
|
+
+ go func() {
|
|
61
|
+
+ time.Sleep(3 * time.Second)
|
|
62
|
+
+ sch.RunCheckinNowFor(provider.PlatformWorkBuddy)
|
|
63
|
+
+ }()
|
|
64
|
+
+ }
|
|
65
|
+
|
|
66
|
+
srv := &http.Server{
|
|
67
|
+
Addr: cfg.Listen,
|
|
68
|
+
--- a/internal/provider/provider.go
|
|
69
|
+
+++ b/internal/provider/provider.go
|
|
70
|
+
@@ -87,6 +87,12 @@
|
|
71
|
+
Name string
|
|
72
|
+
ContextWindow int64 // = maxInputTokens
|
|
73
|
+
MaxTokens int64 // = maxOutputTokens
|
|
74
|
+
+ // Credits 上游给出的积分倍率原文,形如 "x0.11 credits" / "x0.05";可能为空。
|
|
75
|
+
+ Credits string
|
|
76
|
+
+ // 能力位,用于下游决定是否开启图片/思考/工具。
|
|
77
|
+
+ SupportsImages bool
|
|
78
|
+
+ SupportsReasoning bool
|
|
79
|
+
+ SupportsToolCall bool
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Provider 平台上游能力抽象。ChatStream 接收的是已由 PrepareBody 改写过的请求体。
|
|
83
|
+
--- a/internal/provider/workbuddy/provider.go
|
|
84
|
+
+++ b/internal/provider/workbuddy/provider.go
|
|
85
|
+
@@ -309,11 +309,15 @@
|
|
86
|
+
Code int `json:"code"`
|
|
87
|
+
Data struct {
|
|
88
|
+
Models []struct {
|
|
89
|
+
- ID string `json:"id"`
|
|
90
|
+
- Name string `json:"name"`
|
|
91
|
+
- MaxInputTokens int64 `json:"maxInputTokens"`
|
|
92
|
+
- MaxOutputTokens int64 `json:"maxOutputTokens"`
|
|
93
|
+
- Disabled bool `json:"disabled"`
|
|
94
|
+
+ ID string `json:"id"`
|
|
95
|
+
+ Name string `json:"name"`
|
|
96
|
+
+ MaxInputTokens int64 `json:"maxInputTokens"`
|
|
97
|
+
+ MaxOutputTokens int64 `json:"maxOutputTokens"`
|
|
98
|
+
+ Disabled bool `json:"disabled"`
|
|
99
|
+
+ Credits string `json:"credits"`
|
|
100
|
+
+ SupportsImages bool `json:"supportsImages"`
|
|
101
|
+
+ SupportsReasoning bool `json:"supportsReasoning"`
|
|
102
|
+
+ SupportsToolCall bool `json:"supportsToolCall"`
|
|
103
|
+
} `json:"models"`
|
|
104
|
+
Agents []struct {
|
|
105
|
+
Name string `json:"name"`
|
|
106
|
+
@@ -343,10 +347,14 @@
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
dynMap[m.ID] = provider.ModelInfo{
|
|
110
|
+
- ID: m.ID,
|
|
111
|
+
- Name: m.Name,
|
|
112
|
+
- ContextWindow: m.MaxInputTokens,
|
|
113
|
+
- MaxTokens: m.MaxOutputTokens,
|
|
114
|
+
+ ID: m.ID,
|
|
115
|
+
+ Name: m.Name,
|
|
116
|
+
+ ContextWindow: m.MaxInputTokens,
|
|
117
|
+
+ MaxTokens: m.MaxOutputTokens,
|
|
118
|
+
+ Credits: m.Credits,
|
|
119
|
+
+ SupportsImages: m.SupportsImages,
|
|
120
|
+
+ SupportsReasoning: m.SupportsReasoning,
|
|
121
|
+
+ SupportsToolCall: m.SupportsToolCall,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
out := make([]provider.ModelInfo, 0, len(cliIDs))
|
|
125
|
+
--- a/internal/scheduler/scheduler.go
|
|
126
|
+
+++ b/internal/scheduler/scheduler.go
|
|
127
|
+
@@ -22,6 +22,8 @@
|
|
128
|
+
type WorkBuddySchedule struct {
|
|
129
|
+
CheckinHours []int
|
|
130
|
+
KeepaliveHours []int
|
|
131
|
+
+ // AutoCheckin 是否注册整点自动签到任务;nil = 视为 true(保持旧行为)。
|
|
132
|
+
+ AutoCheckin *bool
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// QClawSchedule qclaw 定时配置(每日 keepalive 探活)。
|
|
136
|
+
@@ -71,14 +73,17 @@
|
|
137
|
+
cfg.QClaw.KeepaliveHours = []int{0}
|
|
138
|
+
}
|
|
139
|
+
s := &Scheduler{cfg: cfg}
|
|
140
|
+
+ autoCheckin := cfg.WorkBuddy.AutoCheckin == nil || *cfg.WorkBuddy.AutoCheckin
|
|
141
|
+
s.tasks = []task{
|
|
142
|
+
{name: "trae-refresh", hours: append([]int{}, cfg.Trae.RefreshHours...), run: func() { s.RunRefreshNowFor(provider.PlatformTrae) }},
|
|
143
|
+
{name: "trae-checkin", hours: []int{cfg.Trae.CheckinHour}, run: func() { s.RunCheckinNowFor(provider.PlatformTrae) }},
|
|
144
|
+
- {name: "workbuddy-checkin", hours: append([]int{}, cfg.WorkBuddy.CheckinHours...), run: func() { s.RunCheckinNowFor(provider.PlatformWorkBuddy) }},
|
|
145
|
+
{name: "workbuddy-keepalive", hours: append([]int{}, cfg.WorkBuddy.KeepaliveHours...), run: func() { s.RunRefreshNowFor(provider.PlatformWorkBuddy) }},
|
|
146
|
+
// QC2A 无签到接口:每日 keepalive 探活冷却账号并刷新积分。
|
|
147
|
+
{name: "qclaw-keepalive", hours: append([]int{}, cfg.QClaw.KeepaliveHours...), run: func() { s.RunQClawKeepaliveNow() }},
|
|
148
|
+
}
|
|
149
|
+
+ if autoCheckin {
|
|
150
|
+
+ s.tasks = append(s.tasks, task{name: "workbuddy-checkin", hours: append([]int{}, cfg.WorkBuddy.CheckinHours...), run: func() { s.RunCheckinNowFor(provider.PlatformWorkBuddy) }})
|
|
151
|
+
+ }
|
|
152
|
+
return s
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
--- a/internal/server/handler.go
|
|
156
|
+
+++ b/internal/server/handler.go
|
|
157
|
+
@@ -10,6 +10,7 @@
|
|
158
|
+
"log"
|
|
159
|
+
"net/http"
|
|
160
|
+
"strings"
|
|
161
|
+
+ "sync"
|
|
162
|
+
"time"
|
|
163
|
+
|
|
164
|
+
"multi2api/internal/auth"
|
|
165
|
+
@@ -54,8 +55,17 @@
|
|
166
|
+
type Handler struct {
|
|
167
|
+
cfg Config
|
|
168
|
+
mux *http.ServeMux
|
|
169
|
+
+
|
|
170
|
+
+ // 动态上游模型表缓存(WorkBuddy /models 接口)。
|
|
171
|
+
+ modelsMu sync.RWMutex
|
|
172
|
+
+ modelsList []provider.ModelInfo
|
|
173
|
+
+ modelsAt time.Time
|
|
174
|
+
+ modelsErr error
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
+// modelsCacheTTL 上游模型表缓存时长。
|
|
178
|
+
+const modelsCacheTTL = 10 * time.Minute
|
|
179
|
+
+
|
|
180
|
+
// NewHandler 构建 handler。
|
|
181
|
+
func NewHandler(cfg Config) *Handler {
|
|
182
|
+
if cfg.MaxRotate <= 0 {
|
|
183
|
+
@@ -146,34 +156,133 @@
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
-// supportedModels 对外支持的模型 → 上游映射。
|
|
188
|
+
+// supportedModels 静态兜底模型表 → 上游映射。
|
|
189
|
+
+// 正常情况下以动态上游模型表(/v1/models 实时拉取)为准,仅在拉取失败时兜底。
|
|
190
|
+
var supportedModels = map[string]string{
|
|
191
|
+
"deepseek-v4.1-flash": "deepseek-v4.1-flash",
|
|
192
|
+
"deepseek-v4-pro": "deepseek-v4-pro",
|
|
193
|
+
"glm-5.3": "glm-5.3",
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
-// mapModel 校验模型名并返回上游模型名。空/auto 默认 deepseek-v4.1-flash。
|
|
197
|
+
-// 支持带账号后缀的别名,如 deepseek-v4.1-flash-13800138000:
|
|
198
|
+
-// 后缀为账号昵称(手机号),用于精确挑号;返回的上游模型名已去掉后缀。
|
|
199
|
+
+// catalogPlatformOrder 探测上游模型表时的平台优先级。
|
|
200
|
+
+var catalogPlatformOrder = []string{
|
|
201
|
+
+ provider.PlatformWorkBuddy,
|
|
202
|
+
+ provider.PlatformTrae,
|
|
203
|
+
+ provider.PlatformQClaw,
|
|
204
|
+
+}
|
|
205
|
+
+
|
|
206
|
+
+// catalog 返回上游动态模型表(带缓存)。force=true 时强制刷新。
|
|
207
|
+
+func (h *Handler) catalog(force bool) ([]provider.ModelInfo, error) {
|
|
208
|
+
+ h.modelsMu.RLock()
|
|
209
|
+
+ if !force && len(h.modelsList) > 0 && time.Since(h.modelsAt) < modelsCacheTTL {
|
|
210
|
+
+ list := h.modelsList
|
|
211
|
+
+ h.modelsMu.RUnlock()
|
|
212
|
+
+ return list, nil
|
|
213
|
+
+ }
|
|
214
|
+
+ h.modelsMu.RUnlock()
|
|
215
|
+
+
|
|
216
|
+
+ h.modelsMu.Lock()
|
|
217
|
+
+ defer h.modelsMu.Unlock()
|
|
218
|
+
+ // 双检:可能已被并发请求刷新。
|
|
219
|
+
+ if !force && len(h.modelsList) > 0 && time.Since(h.modelsAt) < modelsCacheTTL {
|
|
220
|
+
+ return h.modelsList, nil
|
|
221
|
+
+ }
|
|
222
|
+
+
|
|
223
|
+
+ // 按平台分组,每个平台取一个账号,合并其模型表(按 ID 去重,先到先得)。
|
|
224
|
+
+ seen := map[string]bool{}
|
|
225
|
+
+ merged := make([]provider.ModelInfo, 0, 32)
|
|
226
|
+
+ var firstErr error
|
|
227
|
+
+ for _, platform := range catalogPlatformOrder {
|
|
228
|
+
+ p := h.cfg.Providers[platform]
|
|
229
|
+
+ if p == nil {
|
|
230
|
+
+ continue
|
|
231
|
+
+ }
|
|
232
|
+
+ var acct *auth.Auth
|
|
233
|
+
+ for _, st := range h.cfg.Pool.List() {
|
|
234
|
+
+ if st.Platform != platform {
|
|
235
|
+
+ continue
|
|
236
|
+
+ }
|
|
237
|
+
+ if a := h.cfg.Pool.AuthByKey(st.Key); a != nil {
|
|
238
|
+
+ acct = a
|
|
239
|
+
+ break
|
|
240
|
+
+ }
|
|
241
|
+
+ }
|
|
242
|
+
+ if acct == nil {
|
|
243
|
+
+ continue
|
|
244
|
+
+ }
|
|
245
|
+
+ infos, err := p.FetchModels(acct)
|
|
246
|
+
+ if err != nil {
|
|
247
|
+
+ if firstErr == nil {
|
|
248
|
+
+ firstErr = fmt.Errorf("%s: %w", platform, err)
|
|
249
|
+
+ }
|
|
250
|
+
+ continue
|
|
251
|
+
+ }
|
|
252
|
+
+ for _, info := range infos {
|
|
253
|
+
+ if info.ID == "" || seen[info.ID] {
|
|
254
|
+
+ continue
|
|
255
|
+
+ }
|
|
256
|
+
+ seen[info.ID] = true
|
|
257
|
+
+ merged = append(merged, info)
|
|
258
|
+
+ }
|
|
259
|
+
+ }
|
|
260
|
+
+ if len(merged) == 0 {
|
|
261
|
+
+ h.modelsErr = firstErr
|
|
262
|
+
+ return nil, firstErr
|
|
263
|
+
+ }
|
|
264
|
+
+ h.modelsList = merged
|
|
265
|
+
+ h.modelsAt = time.Now()
|
|
266
|
+
+ h.modelsErr = nil
|
|
267
|
+
+ return merged, nil
|
|
268
|
+
+}
|
|
269
|
+
+
|
|
270
|
+
+// modelUpstream 在动态表中查模型 ID(大小写不敏感),返回上游模型名。
|
|
271
|
+
+func (h *Handler) modelUpstream(list []provider.ModelInfo, model string) (string, bool) {
|
|
272
|
+
+ for _, info := range list {
|
|
273
|
+
+ if strings.EqualFold(info.ID, model) {
|
|
274
|
+
+ return info.ID, true
|
|
275
|
+
+ }
|
|
276
|
+
+ }
|
|
277
|
+
+ return "", false
|
|
278
|
+
+}
|
|
279
|
+
+
|
|
280
|
+
+// mapModel 校验模型名并返回上游模型名。空/auto 默认第一个可用模型。
|
|
281
|
+
+// 支持带账号后缀的别名,如 glm-5.3-13800138000:后缀为账号昵称(手机号)用于精确挑号,
|
|
282
|
+
+// 返回的上游模型名已去掉后缀。
|
|
283
|
+
func (h *Handler) mapModel(model string) (string, error) {
|
|
284
|
+
- model = strings.TrimSpace(strings.ToLower(model))
|
|
285
|
+
- if model == "" || model == "auto" {
|
|
286
|
+
- model = provider.UnifiedModel
|
|
287
|
+
+ raw := strings.TrimSpace(model)
|
|
288
|
+
+ lower := strings.ToLower(raw)
|
|
289
|
+
+
|
|
290
|
+
+ list, _ := h.catalog(false)
|
|
291
|
+
+
|
|
292
|
+
+ if lower == "" || lower == "auto" || lower == provider.UnifiedModel {
|
|
293
|
+
+ // 优先用动态表里的 deepseek-v4.1-flash,其次是列表第一个。
|
|
294
|
+
+ if upstream, ok := h.modelUpstream(list, provider.UnifiedModel); ok {
|
|
295
|
+
+ return upstream, nil
|
|
296
|
+
+ }
|
|
297
|
+
+ if len(list) > 0 {
|
|
298
|
+
+ return list[0].ID, nil
|
|
299
|
+
+ }
|
|
300
|
+
+ return provider.UnifiedModel, nil
|
|
301
|
+
}
|
|
302
|
+
- if upstream, ok := supportedModels[model]; ok {
|
|
303
|
+
+ if upstream, ok := h.modelUpstream(list, lower); ok {
|
|
304
|
+
return upstream, nil
|
|
305
|
+
}
|
|
306
|
+
- if base, nick, ok := splitModelNick(model); ok {
|
|
307
|
+
+ // 剥掉账号后缀再查一次。
|
|
308
|
+
+ if base, _, ok := splitModelNick(lower); ok {
|
|
309
|
+
+ if upstream, ok := h.modelUpstream(list, base); ok {
|
|
310
|
+
+ return upstream, nil
|
|
311
|
+
+ }
|
|
312
|
+
if upstream, ok := supportedModels[base]; ok {
|
|
313
|
+
- _ = nick
|
|
314
|
+
return upstream, nil
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
- return "", fmt.Errorf("unsupported model %q, supported: deepseek-v4.1-flash, deepseek-v4-pro, glm-5.3", model)
|
|
318
|
+
+ if upstream, ok := supportedModels[lower]; ok {
|
|
319
|
+
+ return upstream, nil
|
|
320
|
+
+ }
|
|
321
|
+
+ return "", fmt.Errorf("unsupported model %q", raw)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
-// splitModelNick 把 "deepseek-v4.1-flash-13800138000" 拆成 ("deepseek-v4.1-flash", "13800138000", true)。
|
|
325
|
+
+// splitModelNick 把 "deepseek-v4.1-flash-13800138000" 拆成 ("deepseek-v4.1-flash", "13800138000", true)。
|
|
326
|
+
// 最后一段必须全为数字(账号昵称/手机号)才认定为后缀。
|
|
327
|
+
func splitModelNick(model string) (string, string, bool) {
|
|
328
|
+
i := strings.LastIndex(model, "-")
|
|
329
|
+
@@ -189,24 +298,72 @@
|
|
330
|
+
return model[:i], nick, true
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
-// models 返回支持的模型列表。
|
|
334
|
+
+// models 返回上游可用模型列表(动态拉取,失败时回退静态表)。
|
|
335
|
+
+// 支持 ?refresh=1 强制刷新。
|
|
336
|
+
func (h *Handler) models(w http.ResponseWriter, r *http.Request) {
|
|
337
|
+
- data := make([]map[string]any, 0, len(supportedModels))
|
|
338
|
+
- for id, upstream := range supportedModels {
|
|
339
|
+
- data = append(data, map[string]any{
|
|
340
|
+
- "id": id,
|
|
341
|
+
+ force := r.URL.Query().Get("refresh") == "1"
|
|
342
|
+
+ list, err := h.catalog(force)
|
|
343
|
+
+
|
|
344
|
+
+ if len(list) == 0 {
|
|
345
|
+
+ // 回退静态表,保证下游至少能启动。
|
|
346
|
+
+ data := make([]map[string]any, 0, len(supportedModels))
|
|
347
|
+
+ for id, upstream := range supportedModels {
|
|
348
|
+
+ data = append(data, map[string]any{
|
|
349
|
+
+ "id": id,
|
|
350
|
+
+ "object": "model",
|
|
351
|
+
+ "created": 1753600000,
|
|
352
|
+
+ "owned_by": "multi2api",
|
|
353
|
+
+ "upstream_model": upstream,
|
|
354
|
+
+ })
|
|
355
|
+
+ }
|
|
356
|
+
+ writeJSON(w, http.StatusOK, map[string]any{
|
|
357
|
+
+ "object": "list",
|
|
358
|
+
+ "data": data,
|
|
359
|
+
+ })
|
|
360
|
+
+ return
|
|
361
|
+
+ }
|
|
362
|
+
+
|
|
363
|
+
+ data := make([]map[string]any, 0, len(list))
|
|
364
|
+
+ for _, info := range list {
|
|
365
|
+
+ item := map[string]any{
|
|
366
|
+
+ "id": info.ID,
|
|
367
|
+
"object": "model",
|
|
368
|
+
"created": 1753600000,
|
|
369
|
+
"owned_by": "multi2api",
|
|
370
|
+
- "upstream_model": upstream,
|
|
371
|
+
- })
|
|
372
|
+
+ "upstream_model": info.ID,
|
|
373
|
+
+ }
|
|
374
|
+
+ if info.Name != "" {
|
|
375
|
+
+ item["name"] = info.Name
|
|
376
|
+
+ }
|
|
377
|
+
+ if info.ContextWindow > 0 {
|
|
378
|
+
+ item["context_window"] = info.ContextWindow
|
|
379
|
+
+ }
|
|
380
|
+
+ if info.MaxTokens > 0 {
|
|
381
|
+
+ item["max_tokens"] = info.MaxTokens
|
|
382
|
+
+ }
|
|
383
|
+
+ if info.Credits != "" {
|
|
384
|
+
+ item["credits"] = info.Credits
|
|
385
|
+
+ }
|
|
386
|
+
+ item["supports_reasoning"] = info.SupportsReasoning
|
|
387
|
+
+ item["supports_images"] = info.SupportsImages
|
|
388
|
+
+ item["supports_tool_call"] = info.SupportsToolCall
|
|
389
|
+
+ data = append(data, item)
|
|
390
|
+
}
|
|
391
|
+
writeJSON(w, http.StatusOK, map[string]any{
|
|
392
|
+
"object": "list",
|
|
393
|
+
"data": data,
|
|
394
|
+
+ "error": errString(err),
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
+// errString 把可选错误转成字符串(成功返回空串)。
|
|
399
|
+
+func errString(err error) string {
|
|
400
|
+
+ if err == nil {
|
|
401
|
+
+ return ""
|
|
402
|
+
+ }
|
|
403
|
+
+ return err.Error()
|
|
404
|
+
+}
|
|
405
|
+
+
|
|
406
|
+
func (h *Handler) chatCompletions(w http.ResponseWriter, r *http.Request) {
|
|
407
|
+
body, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes+1))
|
|
408
|
+
if err != nil {
|
|
409
|
+
@@ -229,7 +386,7 @@
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
- // 模型名后缀指定账号(如 deepseek-v4.1-flash-13800138000)优先;
|
|
414
|
+
+ // 模型名后缀指定账号(如 deepseek-v4.1-flash-13800138000)优先;
|
|
415
|
+
// 其次支持 X-Account-Uid 头指定账号。
|
|
416
|
+
_, reqNick, _ := splitModelNick(strings.TrimSpace(strings.ToLower(peek.Model)))
|
|
417
|
+
reqUID := r.Header.Get("X-Account-Uid")
|
package/icon.svg
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<rect x="3" y="3" width="7.5" height="7.5" rx="2" />
|
|
3
|
+
<rect x="13.5" y="3" width="7.5" height="7.5" rx="2" />
|
|
4
|
+
<rect x="3" y="13.5" width="7.5" height="7.5" rx="2" />
|
|
5
|
+
<path d="M17.25 13.5v7.5M13.5 17.25h7.5" />
|
|
6
|
+
</svg>
|