mslxdff 0.1.99 → 0.1.101

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.99",
3
+ "version": "0.1.101",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -84,7 +84,7 @@ export async function handleSetto(args) {
84
84
  if (!norm || norm === "auto") continue;
85
85
  // 首次循环也同步 preferred(保持 daemon 热重载语义)
86
86
  if (rawId === list[0]) savePreferredModel(norm);
87
- const r = await syncToOpencode({ id: norm, token, port, file, keep: pruneKeep() });
87
+ const r = await syncToOpencode({ id: norm, token, port, file, keep: pruneKeep(), ensureAll: pruneKeep() });
88
88
  if (r.action === "inserted") inserted++; else updated++;
89
89
  prunedTotal += r.pruned || 0;
90
90
  console.log(` ${r.action} "${r.id}" -> ${r.internal} @ ${file}`);
@@ -156,8 +156,9 @@ export async function handleSetto(args) {
156
156
  const envPort = Number(process.env.MSLXDFF_PORT);
157
157
  const port = persisted !== null ? persisted : (Number.isInteger(envPort) && envPort > 0 ? envPort : 8989);
158
158
  const file = opencodeConfigPath();
159
- const r = await syncToOpencode({ id, token, port, file, keep: pruneKeep() });
159
+ const r = await syncToOpencode({ id, token, port, file, keep: pruneKeep(), ensureAll: pruneKeep() });
160
160
  console.log(`synced to opencode: ${r.action} "${r.id}" @ ${file}`);
161
+ if (r.backfilled) console.log(` backfilled ${r.backfilled} 个 picks 模型(此前 pick 了但未同步过,现已补齐)`);
161
162
  if (r.pruned) console.log(` pruned ${r.pruned} 个失效模型(未在 picks,不再于 opencode 显示)`);
162
163
  console.log(` url: http://127.0.0.1:${port}/v1`);
163
164
  console.log(` opencode 选 mslxdff/${r.id} 直达本地 ${r.internal}${r.storageKey !== r.internal ? ` (dash→${r.internal} 自动映射)` : ""}`);
@@ -1,6 +1,8 @@
1
1
  // DeepSeek 账号池 + 登录(Android 协议)
2
2
  // 上游协议参考 iidamie/deepseek2api(GPL-3.0,协议事实)
3
+ // 并发闸门参考 Chat2API-WXS per-account guard:每 token 在途限 1,全忙 FIFO 排队,流结束释放
3
4
  import { compatFetch } from "../../compat.js";
5
+ import { envInt } from "../base.js";
4
6
  import { androidHeaders } from "./pow.js";
5
7
  import { DEEPSEEK_DEFAULT_BASE, DEEPSEEK_API_PREFIX } from "./pow.js";
6
8
  import { dsDebug } from "./debug.js";
@@ -56,7 +58,7 @@ export async function loginDeepseek({ loginValue, password, areaCode = "+86", fe
56
58
  }
57
59
 
58
60
  // 多账号轮换池:空闲最久优先 + 冷却 + onError;轮换语义对齐 NIyueeE/ds-free-api(选空闲最久者,最大化每次使用间隔)
59
- export function createAuthPool({ tokens = [], cooldownMs = DEFAULT_COOLDOWN_MS, clock = Date.now } = {}) {
61
+ export function createAuthPool({ tokens = [], cooldownMs = DEFAULT_COOLDOWN_MS, clock = Date.now, maxConcurrentPerToken: maxConcParam } = {}) {
60
62
  const list = [...new Set((tokens || []).map((t) => String(t).trim()).filter(Boolean))];
61
63
  const until = new Map(); // token → 冷却到期时刻
62
64
  const lastUsedAt = new Map(); // token → 最近一次被取用时刻(空闲最久排序依据)
@@ -107,5 +109,86 @@ export function createAuthPool({ tokens = [], cooldownMs = DEFAULT_COOLDOWN_MS,
107
109
  return token;
108
110
  }
109
111
 
110
- return { next, onError, available, requireToken, size: list.length, cooldownMs, keys: [...list] };
112
+ // ---------------------------------------------------------------------------
113
+ // 并发闸门:每 token 最多 maxConcurrentPerToken 个在途请求(默认 1 = 禁止并发)
114
+ // acquireSlot → { token, release } | null(超时);release 幂等,流式在 cleanup 链释放
115
+ // ---------------------------------------------------------------------------
116
+ const maxConcurrentPerToken = Math.max(1, Number(maxConcParam) > 0 ? Number(maxConcParam) : envInt("MSLXDFF_DEEPSEEK_MAX_CONCURRENT", 1));
117
+ const inFlight = new Map(); // token → 在途数
118
+ const waiters = []; // FIFO 等待队列:{ resolve, timer }
119
+
120
+ function inflightOf(token) {
121
+ return inFlight.get(token) || 0;
122
+ }
123
+
124
+ // 空闲(未冷却且在途未满)的号里选空闲最久者;无 → null
125
+ function nextIdle() {
126
+ let best = null;
127
+ let bestIdle = -1;
128
+ for (const token of list) {
129
+ if (isCooling(token) || inflightOf(token) >= maxConcurrentPerToken) continue;
130
+ const idle = clock() - (lastUsedAt.get(token) || 0);
131
+ if (idle > bestIdle) {
132
+ bestIdle = idle;
133
+ best = token;
134
+ }
135
+ }
136
+ return best;
137
+ }
138
+
139
+ function makeRelease(token) {
140
+ let released = false;
141
+ return () => {
142
+ if (released) return;
143
+ released = true;
144
+ inFlight.set(token, Math.max(0, inflightOf(token) - 1));
145
+ dsDebug("gate", { event: "release", tokenTail: String(token).slice(-6), inFlight: inflightOf(token) });
146
+ wakeNext();
147
+ };
148
+ }
149
+
150
+ // 有槽位释放 → 唤醒队首等待者重新竞争(重新走 nextIdle,天然跳过冷却/满载号)
151
+ function wakeNext() {
152
+ while (waiters.length) {
153
+ const w = waiters.shift();
154
+ clearTimeout(w.timer);
155
+ const token = nextIdle();
156
+ if (!token) { waiters.unshift(w); break; } // 没有可用槽位,塞回队首等下次 release
157
+ lastUsedAt.set(token, clock());
158
+ inFlight.set(token, inflightOf(token) + 1);
159
+ dsDebug("gate", { event: "wakeup", tokenTail: String(token).slice(-6), queue: waiters.length });
160
+ w.resolve({ token, release: makeRelease(token) });
161
+ }
162
+ }
163
+
164
+ async function acquireSlot({ timeoutMs } = {}) {
165
+ const timeout = Number(timeoutMs) > 0 ? Number(timeoutMs) : envInt("MSLXDFF_DEEPSEEK_QUEUE_TIMEOUT_MS", 30_000);
166
+ const token = nextIdle();
167
+ if (token) {
168
+ lastUsedAt.set(token, clock());
169
+ inFlight.set(token, inflightOf(token) + 1);
170
+ dsDebug("gate", { event: "acquire-direct", tokenTail: String(token).slice(-6), inFlight: inflightOf(token) });
171
+ return { token, release: makeRelease(token) };
172
+ }
173
+ if (!list.length) {
174
+ const err = new Error("缺少 DeepSeek 凭据,请先 -provider deepseek login 或设置 providerConfigs.deepseek.keys");
175
+ err._deepseekNoAuth = true;
176
+ throw err;
177
+ }
178
+ // 全忙:入 FIFO 队列等待 release 唤醒;超时返回 null。
179
+ // 定时器不 unref:有请求在排队 = 有未完成工作,事件循环必须等它(unref 会让进程提前退出)
180
+ return new Promise((resolve) => {
181
+ const timer = setTimeout(() => {
182
+ const i = waiters.indexOf(w);
183
+ if (i >= 0) waiters.splice(i, 1);
184
+ dsDebug("gate", { event: "acquire-timeout", queue: waiters.length });
185
+ resolve(null);
186
+ }, timeout);
187
+ const w = { resolve, timer };
188
+ waiters.push(w);
189
+ dsDebug("gate", { event: "enqueue", queue: waiters.length, timeoutMs: timeout });
190
+ });
191
+ }
192
+
193
+ return { next, onError, available, requireToken, acquireSlot, maxConcurrentPerToken, size: list.length, cooldownMs, keys: [...list] };
111
194
  }
@@ -183,17 +183,26 @@ export async function runDeepseekChat({ body, authPool, fetchImpl, dispatcher, b
183
183
 
184
184
  const maxAttempts = maxAuthRetries ?? Math.max(1, authPool.size);
185
185
  for (let attempt = 0; attempt <= maxAttempts; attempt++) {
186
- const token = authPool.requireToken();
186
+ // 并发闸门:每 token 在途限 1(MSLXDFF_DEEPSEEK_MAX_CONCURRENT),全忙排队,超时人话 429
187
+ const slot = await authPool.acquireSlot({});
188
+ if (!slot) {
189
+ throw upstreamError("DeepSeek: 所有账号都在忙(并发闸门排队超时),稍后重试或调大 MSLXDFF_DEEPSEEK_QUEUE_TIMEOUT_MS / MSLXDFF_DEEPSEEK_MAX_CONCURRENT", { status: 429 });
190
+ }
191
+ const { token, release } = slot;
187
192
  try {
188
193
  const out = prompt.length > threshold
189
194
  ? await runChunked({ token, flags, prompt, isStream, fetchImpl, dispatcher, baseUrl, connectTimeoutMs, threshold })
190
195
  : await runOnce({ token, flags, prompt, isStream, fetchImpl, dispatcher, baseUrl, connectTimeoutMs });
191
196
  if (out.kind === "stream") {
197
+ // 流式:槽位由 cleanup 链释放(buildOpenAiSseStream 的 done/error/cancel),此处不放
198
+ const prevCleanup = out.cleanup;
192
199
  return {
193
200
  kind: "stream",
194
201
  res: out.res,
195
202
  token,
196
- cleanup: () => deleteChatSession({ token: out.token, sessionId: out.sessionId, fetchImpl, dispatcher, baseUrl }),
203
+ cleanup: async () => {
204
+ try { await prevCleanup?.(); } finally { release(); }
205
+ },
197
206
  };
198
207
  }
199
208
  const sseText = await readBodyText(out.res);
@@ -209,11 +218,15 @@ export async function runDeepseekChat({ body, authPool, fetchImpl, dispatcher, b
209
218
  dsDump("aggregate", "EMPTY aggregate content! full sseText", sseText, 8000);
210
219
  }
211
220
  await deleteChatSession({ token: out.token, sessionId: out.sessionId, fetchImpl, dispatcher, baseUrl });
221
+ release(); // 聚合成功:槽位随会话删除一并释放
212
222
  return { kind: "json", data };
213
223
  } catch (err) {
224
+ release(); // 失败(含 rotateAuth 重试):立即释放,避免槽位泄漏
214
225
  dsError(`chat attempt=${attempt}`, err);
215
226
  if (err?._rotateAuth) authPool.onError(token, { cooldownMs: err._cooldownMs });
216
- if (!err?._rotateAuth || attempt >= maxAttempts - 1) throw err;
227
+ // 会话创建/删除阶段的 401/403 也属凭据被拒(session.js status),与 completion 阶段对齐
228
+ else if (err?.status === 401 || err?.status === 403) authPool.onError(token, { cooldownMs: COOLDOWN_PRESETS.default });
229
+ if (!(err?._rotateAuth || err?.status === 401 || err?.status === 403) || attempt >= maxAttempts - 1) throw err;
217
230
  }
218
231
  }
219
232
  throw upstreamError("DeepSeek: 所有账号均不可用", {});
@@ -30,7 +30,10 @@ export async function createChatSession({ token, fetchImpl, dispatcher, baseUrl
30
30
  const id = data?.data?.biz_data?.chat_session?.id ?? data?.data?.biz_data?.id;
31
31
  if (!res.ok || data?.data?.biz_code !== 0 || !id) {
32
32
  const msg = data?.data?.biz_msg || data?.msg || "响应缺少会话 id";
33
- throw new Error(`DeepSeek 会话创建失败: ${msg}${res.ok ? "" : ` (http ${res.status})`}`);
33
+ const err = new Error(`DeepSeek 会话创建失败: ${msg}${res.ok ? "" : ` (http ${res.status})`}`);
34
+ // 401/403 = 凭据被拒:带 status 供上层 rotateAuth(与 completion 阶段 classifyFailure 对齐)
35
+ if (res.status === 401 || res.status === 403) err.status = res.status;
36
+ throw err;
34
37
  }
35
38
  return id;
36
39
  }
@@ -78,7 +78,43 @@ export function pruneOpencodeModels(models, keep, currentKey) {
78
78
  return pruned;
79
79
  }
80
80
 
81
- export async function syncToOpencode({ id, token, port, file, keep } = {}) {
81
+ // 补齐:把 ensureAll(picks 口径)里缺失的键写入 models(slash dash + alias 注册)。
82
+ // 返回 { nextModels, backfilled };ensureAll 非数组或为空时原样返回(backfilled=0)。
83
+ // 注意:需在剪枝之前调用——先补 picks 缺失,再剪 picks 外旧键,结果集恰为 picks ∪ currentKey。
84
+ export async function ensureAllOpencodeModels(models, ensureAll) {
85
+ if (!Array.isArray(ensureAll) || !ensureAll.length || !models || typeof models !== "object") {
86
+ return { nextModels: models, backfilled: 0 };
87
+ }
88
+ const existing = new Set(Object.keys(models).map(normalizeOpencodeKey));
89
+ let backfilled = 0;
90
+ let aliasDirty = false;
91
+ for (const raw of ensureAll) {
92
+ const internal = toInternalId(String(raw || "").trim());
93
+ if (!internal || internal === "auto") continue;
94
+ const storageKey = internal.includes("/") ? internal.replace(/\//g, "-") : internal;
95
+ if (!storageKey || existing.has(storageKey)) continue;
96
+ models[storageKey] = { name: storageKey };
97
+ existing.add(storageKey);
98
+ backfilled++;
99
+ if (internal.includes("/") && storageKey !== internal) {
100
+ try {
101
+ const { loadModelAliases, registerModelAlias } = await import("./providers/model-id.js");
102
+ loadModelAliases();
103
+ registerModelAlias(storageKey, internal);
104
+ aliasDirty = true;
105
+ } catch {}
106
+ }
107
+ }
108
+ if (aliasDirty) {
109
+ try {
110
+ const { persistModelAliases } = await import("./providers/model-id.js");
111
+ persistModelAliases();
112
+ } catch {}
113
+ }
114
+ return { nextModels: models, backfilled };
115
+ }
116
+
117
+ export async function syncToOpencode({ id, token, port, file, keep, ensureAll } = {}) {
82
118
  const targetFile = file || opencodeConfigPath();
83
119
  const normalizedRaw = String(id || "").trim();
84
120
  if (!normalizedRaw) throw new Error("model id required");
@@ -119,6 +155,7 @@ export async function syncToOpencode({ id, token, port, file, keep } = {}) {
119
155
  let action;
120
156
  let effectiveId = storageKey;
121
157
  let pruned = 0;
158
+ let backfilled = 0;
122
159
  if (oldProvider) {
123
160
  const oldModels = oldProvider.models && typeof oldProvider.models === "object" && !Array.isArray(oldProvider.models)
124
161
  ? oldProvider.models
@@ -158,6 +195,8 @@ export async function syncToOpencode({ id, token, port, file, keep } = {}) {
158
195
  effectiveId = storageKey;
159
196
  action = "inserted";
160
197
  }
198
+ const ensured = await ensureAllOpencodeModels(nextModels, ensureAll);
199
+ backfilled = ensured.backfilled;
161
200
  pruned = pruneOpencodeModels(nextModels, keep, storageKey);
162
201
  const nextProvider = {
163
202
  ...oldProvider,
@@ -177,6 +216,8 @@ export async function syncToOpencode({ id, token, port, file, keep } = {}) {
177
216
  if (!data.provider.mslxdff.models[storageKey]) {
178
217
  data.provider.mslxdff.models = { [storageKey]: { name: storageKey } };
179
218
  }
219
+ const ensured = await ensureAllOpencodeModels(data.provider.mslxdff.models, ensureAll);
220
+ backfilled = ensured.backfilled;
180
221
  action = "inserted";
181
222
  }
182
223
 
@@ -208,5 +249,5 @@ export async function syncToOpencode({ id, token, port, file, keep } = {}) {
208
249
  }
209
250
  } catch {}
210
251
 
211
- return { action, file: targetFile, id: effectiveId, alias: storageKey, internal, corrupted, storageKey, pruned };
252
+ return { action, file: targetFile, id: effectiveId, alias: storageKey, internal, corrupted, storageKey, pruned, backfilled };
212
253
  }