mslxdff 0.1.199 → 0.1.200

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.199",
3
+ "version": "0.1.200",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,8 +46,10 @@ export function createUpstreamEngine(opts = {}) {
46
46
  const via = useResponses ? "@ai-sdk/openai · responses" : "@ai-sdk/openai-compatible · chat";
47
47
  try { console.error(`[upstream-engine] sdk 引擎生效(${via})`); } catch {}
48
48
  }
49
- // 保留 legacy 的匿名重试语义(仅 !anonFirst 配置下存在)
50
- if (res.status === 429 && !anonFirst && isFreeModel(body?.model)) return legacy.chat(body);
49
+ // 保留 legacy 的匿名重试语义(仅 !anonFirst 配置下存在);G1a 起 free-429 的**请求内 v6 换出口**
50
+ // 也走这条委派——重试环在 legacy.chat 里,SDK 通道撞 429 不委派就等于把换出口的额度白白留给下一发。
51
+ // 无 rotateFor 的 dispatcher(直连/WARP)且 anonFirst 时维持旧行为(不重复打流量)。
52
+ if (res.status === 429 && isFreeModel(body?.model) && (!anonFirst || typeof dispatcher?.rotateFor === "function")) return legacy.chat(body);
51
53
  return res;
52
54
  } catch (e) {
53
55
  if (e && e._sdkLoadFailed) {
package/src/upstream.js CHANGED
@@ -33,6 +33,11 @@ function envInt(name, fallback) {
33
33
  const v = Number(process.env[name]);
34
34
  return Number.isInteger(v) && v > 0 ? v : fallback;
35
35
  }
36
+ /** 允许 0 的数值 env(G1a 的逃生阀要认 0=关;envInt 的 `v>0` 会把 0 吞回默认值,那就是个假开关)。 */
37
+ function envIntOrZero(name, fallback) {
38
+ const v = Number(process.env[name]);
39
+ return Number.isInteger(v) && v >= 0 ? v : fallback;
40
+ }
36
41
  function isPreheatDisabled() {
37
42
  const raw = process.env.MSLXDFF_PREHEAT;
38
43
  if (raw === undefined || raw === null || raw === "") return false;
@@ -164,11 +169,12 @@ export function createUpstreamClient({
164
169
 
165
170
  // 首发请求(transport 已处理 network/429 等重试)
166
171
  let res;
172
+ const firstHeaders = buildHeaders(reqBody, { sessionId });
167
173
  try {
168
174
  res = await transport.request({
169
175
  url,
170
176
  method: "POST",
171
- headers: buildHeaders(reqBody, { sessionId }),
177
+ headers: firstHeaders,
172
178
  body: reqBody,
173
179
  stream: reqBody.stream !== false,
174
180
  timeoutMs: connectTimeoutMs,
@@ -182,6 +188,52 @@ export function createUpstreamClient({
182
188
  try { console.error(`[free-lane] resp model=${body?.model} status=${res.status} ct=${res.headers?.get?.("content-type") || ""}`); } catch {}
183
189
  }
184
190
 
191
+ // G1a(ADR-0061)**请求内**榨干 v6 出口池:free 模型撞出口池级 429 时,await dispatcher 换一个新
192
+ // 源地址、在**同一请求内**重发——额度按出口 IP 计,本节点 v6 池里的其余地址这一发就该用上,
193
+ // 而不是把 429 退回客户端/组长、留给"下一个请求"。
194
+ // 边界(探测实证过的硬约束):① 仅头部即 429 才换(不碰半开的 200 流);② 429 错误体已被
195
+ // transport 非流分支读尽,无连接泄漏;③ 每轮换的是 session 粘的那个槽(swap 原地换 addr/e.agent,
196
+ // 粘性映射不失效);④ 次数上限防 429 风暴空烧认领地址;⑤ 无 rotateFor 的 dispatcher(直连/WARP)
197
+ // 条件短路——WARP 换出口=重注册设备 6s~600s,在请求内做只会白等,**设计上排除**。
198
+ if (freeLane && res.status === 429 && typeof transport.dispatcher?.rotateFor === "function") {
199
+ const maxRot = envIntOrZero("MSLXDFF_V6_INREQUEST_RETRIES", 3); // 0 = 关掉请求内换出口
200
+ let rotated = 0;
201
+ while (res.status === 429 && rotated < maxRot) {
202
+ let next = null;
203
+ try {
204
+ // 用**线上传的那个** session 头定位粘性槽(无 messages 时 buildHeaders 兜底成进程级
205
+ // FALLBACK_SESSION;传 null 会让 selector 轮转到别的槽,换掉一个没准真没耗尽的健康出口)
206
+ next = await transport.dispatcher.rotateFor(firstHeaders["x-opencode-session"] || "");
207
+ } catch { break; }
208
+ if (!next?.agent) break; // 换不到新出口(空池/全在换址/认领失败)⇒ 正常收场,不黑洞
209
+ rotated += 1;
210
+ if (laneDebug) {
211
+ try { console.error(`[free-lane] v6 in-request rotate #${rotated} ${next.from} -> ${next.addr} model=${body?.model}`); } catch {}
212
+ }
213
+ try {
214
+ res = await transport.request({
215
+ url,
216
+ method: "POST",
217
+ headers: buildHeaders(reqBody, { sessionId }),
218
+ body: reqBody,
219
+ stream: reqBody.stream !== false,
220
+ timeoutMs: connectTimeoutMs,
221
+ retry,
222
+ dispatcher: next.agent, // per-call 出口:绕开 extDispatcher 的旧槽选择
223
+ });
224
+ } catch (e) {
225
+ e._t = e._t || { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - t0) };
226
+ throw e;
227
+ }
228
+ }
229
+ if (rotated) {
230
+ res._t = { ...(res._t || {}), egressRotations: rotated, egressRecovered: res.status < 400 };
231
+ try {
232
+ appendFile(freeAnonLogFile(), `${fmtShanghaiYMDHMS(new Date())} model=${body?.model} v6-in-request-rotations=${rotated} finalStatus=${res.status}\n`).catch(() => {});
233
+ } catch {}
234
+ }
235
+ }
236
+
185
237
  // 匿名兜底:仅非 anonFirst 时,public 429 + free 模型才走 hermes 空头重试
186
238
  if (!anonFirst && res.status === 429 && isFreeModel(body?.model) && shouldTryAnonFree()) {
187
239
  const anonRetries = envInt("MSLXDFF_FREE_ANON_RETRIES", 3);
@@ -195,6 +195,30 @@ export function createV6Dispatcher({
195
195
  /** 手动换某个槽的源地址(供运维/测试;正常路径由配额信号触发)。 */
196
196
  swap,
197
197
 
198
+ /**
199
+ * G1a(ADR-0061):**请求内**换出口——撞出口池级 429 后由 upstream.js await 这一发,
200
+ * 把该会话粘的槽换新源地址,返回**换址完成后**的新 Agent 供同一请求立刻重发。
201
+ * 与 onTap 的 fire-and-forget 换址(只救下一个请求)不同,这里救的是**在飞请求**。
202
+ * 仅 v6 池提供本方法:换源地址成本≈0(bind/ip addr add);WARP 的换出口=重注册设备
203
+ * (6s~600s,会被 CF 限流),绝不允许在请求内做——白等 N 分钟换不来更快收场。
204
+ * 空池/全槽换址中/该槽被并发占走 ⇒ 返回 null(调用方正常收场,绝不黑洞)。
205
+ * 粘性安全:swap 是**原地**换 e.addr/e.agent(index 不变),session→slot 映射不失效。
206
+ * @param {string} sessionKey x-opencode-session 值(无 key 时由轮转游标挑槽)
207
+ * @returns {Promise<null | {agent: object, addr: string, from: string}>}
208
+ */
209
+ async rotateFor(sessionKey = "") {
210
+ if (!list.length) return null;
211
+ const avail = availableIndexes();
212
+ if (!avail.length) return null;
213
+ const index = selector.pick(avail, strategy === "session" ? sessionKey : "");
214
+ const e = list[index];
215
+ if (!e) return null;
216
+ const r = await swap(index, { kind: "in-request-rotate", status: 429, forced: true });
217
+ if (r.skipped) return null;
218
+ const slot = list[index];
219
+ return { agent: slot.agent, addr: slot.addr, from: r.from };
220
+ },
221
+
198
222
  /** 只读快照(含已退休出口的最终计数——"换址到底有没有换来新额度"看这里)。 */
199
223
  stats() {
200
224
  const t = now();