mslxdff 0.1.232 → 0.1.233

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.
@@ -41,38 +41,54 @@ export function raccoonFallbackModels() {
41
41
  return RACCOON_FALLBACK_MODELS.map((m) => ({ ...m }));
42
42
  }
43
43
 
44
- /** 单条目录条目:字段名容错(缺字段即省略,不猜、不填假值)。 */
44
+ /**
45
+ * 单条目录条目:字段名容错(缺字段即省略,不猜、不填假值)。
46
+ *
47
+ * 上游真实字段(2026-10-22 `GET /api/web/llm/v2/model_catalog` 实测):
48
+ * `name`=模型 id(请求体 `model` 用的就是它)、`description`=展示名(如 `DeepSeek-V4.1-Flash`)、
49
+ * `visible: false`=上游对自己隐藏的内部模型、`params.context_window` / `params.max_tokens`、
50
+ * `billing_multiplier` / `billing_effective_multiplier`。
51
+ * ⚠ **id 绝不能取 `description`**:旧`pickString(["modelName","name","displayName","description"])`
52
+ * 把 id 与展示名混在一串里,一旦上游条目的 `name` 缺失就会拿展示名当 id 去发请求(必 404/400)。
53
+ */
45
54
  function parseRaccoonModelEntry(raw) {
46
55
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
56
+ // 上游用 visible:false 表示「我这边不展示」,这类内部模型不进目录
57
+ if (raw.visible === false) return null;
47
58
  const id = pickString(raw, ["id", "modelId", "model_id", "modelName", "name"]);
48
59
  if (!id) return null;
49
60
  const model = { id };
50
- const name = pickString(raw, ["modelName", "name", "displayName", "description"]);
51
- if (name) model.name = name;
52
- const ctx = pickNumber(raw, ["contextWindow", "context_window", "contextLength", "context_length"]);
61
+ // 展示名单独取,且排除与 id 同值的情况(否则列表会出现「xxx · xxx」)
62
+ const name = pickString(raw, ["description", "displayName", "modelName", "name"]);
63
+ if (name && name !== id) model.name = name;
64
+ const params = raw.params && typeof raw.params === "object" ? raw.params : {};
65
+ const ctx = pickNumber(raw, ["contextWindow", "context_window", "contextLength", "context_length"]) ?? pickNumber(params, ["context_window", "contextWindow"]);
53
66
  if (ctx !== undefined && ctx > 0) model.contextWindow = ctx;
54
- const maxTokens = pickNumber(raw, ["maxTokens", "max_tokens", "maxCompletionTokens"]);
67
+ const maxTokens = pickNumber(raw, ["maxTokens", "max_tokens", "maxCompletionTokens"]) ?? pickNumber(params, ["max_tokens", "maxTokens"]);
55
68
  if (maxTokens !== undefined && maxTokens > 0) model.maxTokens = maxTokens;
56
69
  const img = pickBool(raw, ["supportsImage", "supports_image", "supportsVision"]);
57
70
  if (img !== undefined) model.supportsImage = img;
58
- const eff = pickNumber(raw, ["effectiveMultiplier", "effective_multiplier", "multiplier", "costMultiplier"]);
71
+ const eff = pickNumber(raw, ["effectiveMultiplier", "effective_multiplier", "billing_effective_multiplier", "multiplier", "costMultiplier"]);
59
72
  if (eff !== undefined && eff >= 0) model.multiplier = eff;
60
- const base = pickNumber(raw, ["baseMultiplier", "base_multiplier"]);
73
+ const base = pickNumber(raw, ["baseMultiplier", "base_multiplier", "billing_multiplier"]);
61
74
  if (base !== undefined && base > 0) model.baseMultiplier = base;
62
75
  return model;
63
76
  }
64
77
 
65
- /** 目录信封 → 条目数组。兼容 `data` 为数组、`data.models`、`data.list`、`data.items` 四种形状。 */
78
+ /**
79
+ * 目录信封 → 条目数组。
80
+ *
81
+ * ⚠ **主形状是 `data.categories[]`,只有 `type: "chat"` 那组才是可路由模型**(2026-10-22 上游实测)。
82
+ * 旧实现只认 `data` 为数组 / `data.models` / `data.list` / `data.items` 四种,而真实报文是
83
+ * `{data:{categories:[...]}}` —— 结果每次都解析出 0 条、静默回落静态兜底表,症状是
84
+ * 「上游新上架的模型(如 `sn-deepseek-v4-1-flash-1017`)在网关里永远看不见」。
85
+ * 另外两条判据同样来自实测:`name` 才是模型 id(`description` 是展示名)、`visible: false`
86
+ * 是上游对自己隐藏的内部模型(`raccoon-*`),不该进目录。
87
+ *
88
+ * 四种旧形状继续兼容(上游改版/其他信封时兜底),按顺序取第一个非空的。
89
+ */
66
90
  export function parseRaccoonCatalog(data) {
67
- const list = Array.isArray(data)
68
- ? data
69
- : Array.isArray(data?.models)
70
- ? data.models
71
- : Array.isArray(data?.list)
72
- ? data.list
73
- : Array.isArray(data?.items)
74
- ? data.items
75
- : [];
91
+ const list = pickCatalogList(data);
76
92
  const out = [];
77
93
  const seen = new Set();
78
94
  for (const raw of list) {
@@ -84,6 +100,19 @@ export function parseRaccoonCatalog(data) {
84
100
  return out;
85
101
  }
86
102
 
103
+ function pickCatalogList(data) {
104
+ const arr = (v) => (Array.isArray(v) ? v : null);
105
+ // 真实形状:{ data: { categories: [{ type: "chat", models: [...] }] } }
106
+ const root = data && typeof data === "object" && !Array.isArray(data) ? data : null;
107
+ const categories = arr(root?.categories) ?? arr(root?.data?.categories);
108
+ if (categories) {
109
+ const chat = categories.find((c) => c && typeof c === "object" && c.type === "chat");
110
+ const chatModels = arr(chat?.models);
111
+ if (chatModels) return chatModels;
112
+ }
113
+ return arr(data) ?? arr(root?.models) ?? arr(root?.list) ?? arr(root?.items) ?? [];
114
+ }
115
+
87
116
  /** 倍率文案:0 → 免费;促销 → `x基→x实`;其余 → `x实`(含 x1)。 */
88
117
  export function raccoonMultiplierLabel(model) {
89
118
  const eff = typeof model?.multiplier === "number" ? model.multiplier : undefined;
@@ -143,6 +172,28 @@ export function seedRaccoonAllowlist({ ids, file } = {}) {
143
172
  return { allowed: [...cur, ...added], added };
144
173
  }
145
174
 
175
+ /**
176
+ * 把**上游目录**的模型 id 只增不减并入 allowlist。
177
+ *
178
+ * 为什么需要:白名单此前只在登录时按**静态兜底表**播种一次,上游后来上架的模型
179
+ * (如 2026-10 的 `sn-deepseek-v4-1-flash-1017`)就永远进不来 —— dispatcher 拿白名单
180
+ * 过滤 `listModels()`,模型看得见列表却选不到、调用直接被挡。上游目录才是这家唯一真相
181
+ * (积分制,模型 id 全由上游定义),故每次成功拉到目录就同步一次。
182
+ *
183
+ * **为什么只增不减**:目录是**按账号**下发的(套餐不同可见模型数不同),某次拉到一份
184
+ * 较短的目录不代表模型下架;双向收敛会在换号/降级套餐时把白名单越缩越小、把能用的模型删掉。
185
+ * 真要下架的模型留着只会 404,代价远小于误删。对齐 cline 的 `planMerge` 保守口径(ADR-0067)。
186
+ */
187
+ export function mergeRaccoonCatalogIntoAllowlist(models, { file } = {}) {
188
+ const ids = (Array.isArray(models) ? models : []).map((m) => String(m?.id || "").trim()).filter(Boolean);
189
+ if (!ids.length) return { added: [] };
190
+ const opts = file ? { file } : {};
191
+ const cur = loadProviderAllowedModels("raccoon", opts);
192
+ const added = ids.filter((m) => !cur.includes(m));
193
+ if (added.length) saveProviderAllowedModels("raccoon", [...cur, ...added], opts);
194
+ return { added };
195
+ }
196
+
146
197
  /** 对外目录条目(`/v1/models` 与 CLI models 共用形状)。 */
147
198
  export function buildRaccoonModelList({ id = "raccoon", models } = {}) {
148
199
  const list = models || raccoonFallbackModels();
@@ -45,6 +45,30 @@ export const MAX_STREAM_MS = (() => {
45
45
  return Number.isInteger(n) && n > 0 ? n : 0;
46
46
  })();
47
47
 
48
+ // 上游判决头白名单透传:provider 层(qoder no-capacity/login/quota)把结论钉在响应头上,
49
+ // relay 这一层若只回 status 不回这些头,客户端就拿不到"该重试还是该换号"的判决。
50
+ // 只放白名单,绝不整包 upRes.headers(避免把上游内部头/日期等杂讯透给客户端)。
51
+ // 取证教训:真机 429 响应没带 Retry-After,就是因为这里丢头(见 qoder chat.js withNoCapacity)。
52
+ const DECISION_HEADERS = [
53
+ "retry-after",
54
+ "x-mslxdff-qoder-verdict",
55
+ "x-mslxdff-qoder-nocapacity",
56
+ "x-mslxdff-qoder-retry-after",
57
+ "x-mslxdff-qoder-login",
58
+ "x-mslxdff-workbuddy-uid",
59
+ "x-mslxdff-workbuddy-reason",
60
+ "x-mslxdff-allowlist",
61
+ "x-mslxdff-upstream-engine",
62
+ ];
63
+ function propagateDecisionHeaders(res, upRes) {
64
+ try {
65
+ for (const name of DECISION_HEADERS) {
66
+ const v = upRes.headers.get(name);
67
+ if (v != null && v !== "") res.setHeader(name, v);
68
+ }
69
+ } catch {}
70
+ }
71
+
48
72
  export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort, streamTimeoutMs = STREAM_TIMEOUT_MS, keepaliveMs = KEEPALIVE_MS, fallback, attemptStartMs } = {}) {
49
73
  const t0 = performance.now();
50
74
  // 上报锚点偏移:只加给「上报用时长」(usage 行),闸门计时仍从上面的 t0 起算,两者不得混用
@@ -56,17 +80,8 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
56
80
  // 此时 statusCode 与自定义头都不可再设,否则 setHeader 抛 ERR_HTTP_HEADERS_SENT,重拉直接炸在半路
57
81
  if (!res.headersSent) {
58
82
  res.statusCode = upRes.status;
59
- // propagate workbuddy uid / allowlist headers
60
- try {
61
- const uid = upRes.headers.get("x-mslxdff-workbuddy-uid");
62
- if (uid) res.setHeader("x-mslxdff-workbuddy-uid", uid);
63
- const reason = upRes.headers.get("x-mslxdff-workbuddy-reason");
64
- if (reason) res.setHeader("x-mslxdff-workbuddy-reason", reason);
65
- const allow = upRes.headers.get("x-mslxdff-allowlist");
66
- if (allow) res.setHeader("x-mslxdff-allowlist", allow);
67
- const engine = upRes.headers.get("x-mslxdff-upstream-engine");
68
- if (engine) res.setHeader("x-mslxdff-upstream-engine", engine);
69
- } catch {}
83
+ // propagate decision headers(workbuddy uid/allowlist + qoder 判决 + Retry-After)
84
+ propagateDecisionHeaders(res, upRes);
70
85
  if (fallback) applyFallbackHeaders(res, fallback);
71
86
  }
72
87
 
@@ -335,6 +350,8 @@ export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort,
335
350
  }
336
351
  detail.wrotePayload = true; // 真写下去了:从此撤不回,也不再判空轮
337
352
  const nsTotalMs = Math.round(performance.now() - t0);
353
+ // 非流路径同样透判决头(429+Retry-After 此前只在这条路上丢过——真机 curl 实测)
354
+ if (!res.headersSent) propagateDecisionHeaders(res, upRes);
338
355
  if (enriched !== null) {
339
356
  json(res, upRes.status, enriched);
340
357
  } else {