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.
- package/package.json +1 -1
- package/src/auto.js +10 -0
- package/src/chat-pipeline/index.js +11 -1
- package/src/model-trace.js +10 -2
- package/src/models.js +91 -24
- package/src/providers/qoder/account-store.js +18 -0
- package/src/providers/qoder/chat.js +108 -19
- package/src/providers/qoder/checkin.js +25 -12
- package/src/providers/qoder/index.js +62 -0
- package/src/providers/qoder/models.js +64 -2
- package/src/providers/qoder/oauth.js +42 -1
- package/src/providers/qoder/payload.js +116 -80
- package/src/providers/qoder/request.js +7 -2
- package/src/providers/qoder/session.js +26 -10
- package/src/providers/qoder/sse.js +100 -12
- package/src/providers/qoder/sticky.js +5 -2
- package/src/providers/qoder/wire-messages.js +242 -0
- package/src/providers/raccoon/chat.js +1 -1
- package/src/providers/raccoon/const.js +21 -4
- package/src/providers/raccoon/index.js +15 -2
- package/src/providers/raccoon/models.js +68 -17
- package/src/routes/stream.js +28 -11
package/package.json
CHANGED
package/src/auto.js
CHANGED
|
@@ -176,13 +176,21 @@ export function createAutoSelector({
|
|
|
176
176
|
|
|
177
177
|
async function candidatesFor(requested) {
|
|
178
178
|
if (!requested) return candidates();
|
|
179
|
+
// 分段计时取证(2026-10-22:真机 candidatesFor 烧 20-26s,源码只 await loadList 与 persistPicks
|
|
180
|
+
// 两口——不测还是猜。>500ms 才留痕,正常毫秒级零噪音)。
|
|
181
|
+
const tL = Date.now();
|
|
179
182
|
const list = await loadList();
|
|
183
|
+
const loadMs = Date.now() - tL;
|
|
180
184
|
// 显式指定某模型 = 认可它,自动加入勾选集(仅当它是真实上游 free 模型时,避免垃圾 id 污染)
|
|
181
185
|
const picks = loadPicks();
|
|
186
|
+
const tP = Date.now();
|
|
182
187
|
if (list.includes(requested) && !picks.includes(requested) && persistPicks) {
|
|
183
188
|
await persistPicks([...picks, requested]);
|
|
184
189
|
}
|
|
190
|
+
const persistMs = Date.now() - tP;
|
|
191
|
+
if (loadMs > 500 || persistMs > 500) console.log(`[auto-slow] candidatesFor model=${requested} loadList=${loadMs}ms persistPicks=${persistMs}ms listSize=${list.length}`);
|
|
185
192
|
const all = list.includes(requested) ? list : [requested, ...list];
|
|
193
|
+
const tR = Date.now();
|
|
186
194
|
const others = rankModels(all.filter((id) => id !== requested), lastErrorAt, {
|
|
187
195
|
now: now(),
|
|
188
196
|
cooldownMs,
|
|
@@ -190,6 +198,8 @@ export function createAutoSelector({
|
|
|
190
198
|
latencies,
|
|
191
199
|
preferred: getPreferredModel({ file: file ?? undefined }),
|
|
192
200
|
});
|
|
201
|
+
const rankMs = Date.now() - tR;
|
|
202
|
+
if (rankMs > 500) console.log(`[auto-slow] candidatesFor.rank model=${requested} rank=${rankMs}ms`);
|
|
193
203
|
// 显式指定模型:严格优先,永不因冷却被挤到最后(原设计:A deepseek 失败 → B/D deepseek 并发 → 都失败才 fallback)
|
|
194
204
|
// 冷却仅影响 auto 的择优,不影响指定模型的“很难被更改”语义
|
|
195
205
|
return [requested, ...others];
|
|
@@ -94,8 +94,18 @@ export function createChatPipeline({ upstream, auto, logs, peers, groups, bus, t
|
|
|
94
94
|
// 指定模型 = 死锁单模型:额度耗尽只换组员/组长(同模型换机器),**绝不换模型**。
|
|
95
95
|
// candidatesFor 的持久化副作用(把显式指定的模型加入勾选集,供日后 auto 用)必须保留——
|
|
96
96
|
// 见 auto.js:180「显式指定某模型 = 认可它,自动加入勾选集」,历史上删它会丢勾选记录。
|
|
97
|
+
// 但**不 await**(2026-10-22 日志定罪:loadList 拉全量模型目录烧 24s,写盘 persistPicks 仅 0ms)。
|
|
98
|
+
// 此路径 order 已定死 [requested],candidatesFor 返回值根本不被消费,只为触发"加入勾选集"副作用——
|
|
99
|
+
// 为副作用让主请求干等 24s 是自伤。改后台 fire-and-forget:勾选照样持久化,主请求零阻塞。
|
|
97
100
|
if (auto && requested) {
|
|
98
|
-
|
|
101
|
+
const tCf = performance.now();
|
|
102
|
+
Promise.resolve()
|
|
103
|
+
.then(() => auto.candidatesFor(requested))
|
|
104
|
+
.catch(() => {})
|
|
105
|
+
.finally(() => {
|
|
106
|
+
const ms = Math.round(performance.now() - tCf);
|
|
107
|
+
if (ms > 5000) console.log(`[pipeline-bg] candidatesFor 后台耗时 model=${requested} ms=${ms}(未阻塞主请求)`);
|
|
108
|
+
});
|
|
99
109
|
}
|
|
100
110
|
order = [requested];
|
|
101
111
|
}
|
package/src/model-trace.js
CHANGED
|
@@ -84,6 +84,8 @@ const DECISION_FIELDS = [
|
|
|
84
84
|
"latencyMs", "ttfMs", "hedged", "hops", "hasShare", "elapsedMs", "threshold",
|
|
85
85
|
"stallHits", "maxGapMs", "interrupted", "providers", "via",
|
|
86
86
|
"retry", "step", "max", "delayMs", "upstream", "account", "pick", "cooled", "queued", "nudged", "retries", "waitedMs", "raiseFrom", "raiseTo",
|
|
87
|
+
// qoder 判决定点日志(2026-10-22):login/额度/排队判决各走各的路径,没有这三项,403 盲飞无从分辨
|
|
88
|
+
"login", "nocapacity", "retryAfter",
|
|
87
89
|
"heldOpen", "terminalForm", "requestWaitedMs", "budgetMs", "budgetOut",
|
|
88
90
|
// 终局判决(ADR-0058):取证时第一眼要看的三件——判的哪种、命中哪条判据、是否因此没问组员
|
|
89
91
|
"kind", "marker", "peersSkipped",
|
|
@@ -103,8 +105,10 @@ function decisionKv(data = {}) {
|
|
|
103
105
|
return out.join(" ");
|
|
104
106
|
}
|
|
105
107
|
|
|
106
|
-
// provider 回显头 → 日志字段(谁上的 / 打哪个站 / 为什么选它 / 是否被冷却 /
|
|
108
|
+
// provider 回显头 → 日志字段(谁上的 / 打哪个站 / 为什么选它 / 是否被冷却 / 是否被判排队 / 判决命中哪种):
|
|
107
109
|
// 单一来源,防各处重复写法漂移;取不到时返回空对象(其它供应商零变化)。
|
|
110
|
+
// login/nocapacity/retryAfter(2026-10-22 补):qoder 判决链曾全程盲飞——403 直出日志里只有状态码,
|
|
111
|
+
// 看不出内部是 login 误伤、queue 漏检还是根本没命中,三轮排查全靠猜(用户实证批评:没多打日志)。
|
|
108
112
|
export function upstreamEcho(res) {
|
|
109
113
|
const h = res?.headers;
|
|
110
114
|
if (typeof h?.get !== "function") return {};
|
|
@@ -115,6 +119,10 @@ export function upstreamEcho(res) {
|
|
|
115
119
|
pick: pick("x-mslxdff-qoder-account"),
|
|
116
120
|
cooled: pick("x-mslxdff-qoder-cooldown"),
|
|
117
121
|
queued: pick("x-mslxdff-qoder-queued"),
|
|
122
|
+
login: pick("x-mslxdff-qoder-login"),
|
|
123
|
+
nocapacity: pick("x-mslxdff-qoder-nocapacity"),
|
|
124
|
+
verdict: pick("x-mslxdff-qoder-verdict"),
|
|
125
|
+
qoderRetry: pick("x-mslxdff-qoder-retry-after"),
|
|
118
126
|
};
|
|
119
127
|
}
|
|
120
128
|
|
|
@@ -128,7 +136,7 @@ export function formatModelTrace({ type, reqId, model, data = {}, request = null
|
|
|
128
136
|
else if (type === "alias") detail = `rawModel=${data.rawModel || "-"} requested=${data.requested || model || "-"}`;
|
|
129
137
|
else if (type === "exhausted-local" || type === "exhausted-all") detail = `last=${data.lastModel || model || "-"} status=${data.lastStatus ?? "-"} order=${(data.order || []).join(" | ")}`;
|
|
130
138
|
else if (type === "upstream-try") detail = `target=${data.model || model || "-"} attempt=${data.attempt ?? "-"} payload=${kv(data.payload || {})}`;
|
|
131
|
-
else if (type === "upstream-done" || type === "upstream-error") detail = `upstream status=${data.status ?? "-"} timing=${data.timing?.totalMs ?? "-"}ms${data.upstream ? ` via=${data.upstream}` : ""}${data.account ? ` account=${data.account}` : ""}${data.pick ? ` pick=${data.pick}` : ""}${data.cooled ? ` cooled=${data.cooled}` : ""}${data.queued ? ` queued=1` : ""} ${safeText(data.message || data.error || "")}`;
|
|
139
|
+
else if (type === "upstream-done" || type === "upstream-error") detail = `upstream status=${data.status ?? "-"} timing=${data.timing?.totalMs ?? "-"}ms${data.upstream ? ` via=${data.upstream}` : ""}${data.account ? ` account=${data.account}` : ""}${data.pick ? ` pick=${data.pick}` : ""}${data.cooled ? ` cooled=${data.cooled}` : ""}${data.queued ? ` queued=1` : ""}${data.login ? ` login=1` : ""}${data.nocapacity ? ` nocapacity=1` : ""}${data.verdict ? ` verdict=${data.verdict}` : ""}${data.qoderRetry ? ` retry=${data.qoderRetry}s` : ""} ${safeText(data.message || data.error || "")}`;
|
|
132
140
|
else if (type === "empty-turn-retry") detail = `空转重试:retry ${data.retry ?? "-"}/${data.max ?? "-"}${data.step != null ? ` step=${data.step}` : ""} delay=${data.delayMs ?? "-"}ms${data.waitedMs ? ` waited=${data.waitedMs}ms` : ""}${data.requestWaitedMs != null ? ` 请求级累计=${data.requestWaitedMs}ms/预算${data.budgetMs ?? "-"}ms` : ""}${data.raiseTo ? ` max_tokens ${data.raiseFrom}→${data.raiseTo}` : ""}${data.upstream ? ` after=${data.upstream}` : ""}${data.account ? ` account=${data.account}` : ""}${data.pick ? ` pick=${data.pick}` : ""}${data.nudged ? ` nudged=1` : ""} reason=empty turn${data.reason ? ` :: ${safeText(data.reason, 120)}` : ""}`;
|
|
133
141
|
else if (type === "empty-turn-recovered") detail = `空转重试成功:第 ${data.retries ?? "-"}/${data.max ?? "-"} 次重拉拿到真实输出,本轮共白等 ${data.waitedMs ?? "-"}ms${data.upstream ? ` via=${data.upstream}` : ""}${data.account ? ` account=${data.account}` : ""}${data.pick ? ` pick=${data.pick}` : ""}`;
|
|
134
142
|
else if (type === "empty-turn-exhausted") detail = `空转重试已用尽:重试 ${data.retries ?? 0}/${data.max ?? "-"} 次仍零输出(累计等待 ${data.waitedMs ?? 0}ms${data.budgetOut ? `,请求级等待预算 ${data.budgetMs ?? "-"}ms 用尽` : ""}),交回换候选${data.heldOpen ? "(响应仍开着,换过去的正文还能送达)" : "(响应已封口,重拉只为省额度)"}${data.upstream ? ` after=${data.upstream}` : ""}${data.account ? ` account=${data.account}` : ""}${data.pick ? ` pick=${data.pick}` : ""}${data.reason ? ` :: ${safeText(data.reason, 120)}` : ""}`;
|
package/src/models.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const KNOWN_FREE_OPENCODE_MODELS = ["big-pickle"];
|
|
2
2
|
const CACHE_TTL_MS = 2 * 60 * 60 * 1000;
|
|
3
3
|
const DEFAULT_REFRESH_MS = 2 * 60 * 60 * 1000;
|
|
4
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
5
5
|
import { dirname } from "node:path";
|
|
6
6
|
import { compatFetch } from "./compat.js";
|
|
7
7
|
|
|
@@ -28,7 +28,21 @@ export function createModelsService({ baseUrl, headers, ttlMs = CACHE_TTL_MS, re
|
|
|
28
28
|
if (providers?.length) {
|
|
29
29
|
let aggregate = null;
|
|
30
30
|
let aggFetchedAt = 0;
|
|
31
|
+
let aggInflight = null;
|
|
32
|
+
let aggTimer = null;
|
|
31
33
|
let lastAllowlistKey = "";
|
|
34
|
+
// 冷启动预热:读上一次 persist 的磁盘目录当首值(真机取证:全目录拉取烧 20-26s,
|
|
35
|
+
// 首个 candidatesFor 冷等全程)。aggFetchedAt 用磁盘记录的真实时间;get 对
|
|
36
|
+
// 「有旧值」的情形一律 stale-while-revalidate(立即返回 + 后台刷),主请求零等待。
|
|
37
|
+
if (cacheFile) {
|
|
38
|
+
try {
|
|
39
|
+
const raw = JSON.parse(readFileSync(cacheFile, "utf8"));
|
|
40
|
+
if (Array.isArray(raw?.data)) {
|
|
41
|
+
aggregate = { object: "list", data: raw.data };
|
|
42
|
+
aggFetchedAt = Number(raw.cachedAt) || 0;
|
|
43
|
+
}
|
|
44
|
+
} catch { /* 无缓存/坏档:按冷处理 */ }
|
|
45
|
+
}
|
|
32
46
|
async function currentAllowlistKey() {
|
|
33
47
|
try {
|
|
34
48
|
const { loadProviderAllowedModels, loadProviderAllowAnyModels } = await import("./state.js");
|
|
@@ -42,6 +56,15 @@ export function createModelsService({ baseUrl, headers, ttlMs = CACHE_TTL_MS, re
|
|
|
42
56
|
}
|
|
43
57
|
async function aggLoad() {
|
|
44
58
|
const all = [];
|
|
59
|
+
// 单供应商拉取失败时沿用旧缓存里它的段(取证教训:此前失败=整段消失,
|
|
60
|
+
// 且 2h TTL 内无人再拉(startAutoRefresh 曾是空函数),目录会长期缺一大块)。
|
|
61
|
+
const prevByProvider = new Map();
|
|
62
|
+
for (const m of aggregate?.data || []) {
|
|
63
|
+
const pid = typeof m?.id === "string" ? m.id.split("/")[0] : "";
|
|
64
|
+
if (!pid) continue;
|
|
65
|
+
if (!prevByProvider.has(pid)) prevByProvider.set(pid, []);
|
|
66
|
+
prevByProvider.get(pid).push(m);
|
|
67
|
+
}
|
|
45
68
|
for (const p of providers) {
|
|
46
69
|
try {
|
|
47
70
|
let list = (await p.listModels?.()) ?? [];
|
|
@@ -66,7 +89,9 @@ export function createModelsService({ baseUrl, headers, ttlMs = CACHE_TTL_MS, re
|
|
|
66
89
|
} catch {}
|
|
67
90
|
all.push(...list);
|
|
68
91
|
} catch {
|
|
69
|
-
//
|
|
92
|
+
// 单供应商取数失败不拖垮整体:有旧数据则保留旧数据
|
|
93
|
+
const prev = prevByProvider.get(p.id);
|
|
94
|
+
if (prev?.length) all.push(...prev);
|
|
70
95
|
}
|
|
71
96
|
}
|
|
72
97
|
aggregate = { object: "list", data: all };
|
|
@@ -76,42 +101,84 @@ export function createModelsService({ baseUrl, headers, ttlMs = CACHE_TTL_MS, re
|
|
|
76
101
|
if (cacheFile) persistModels(aggregate, cacheFile);
|
|
77
102
|
return aggregate;
|
|
78
103
|
}
|
|
104
|
+
// 对当前 aggregate(含磁盘 stale 值)做白名单二次过滤后返回;过滤失败回原值。
|
|
105
|
+
async function aggSnapshot() {
|
|
106
|
+
try {
|
|
107
|
+
const { loadProviderAllowedModels, loadProviderAllowAnyModels } = await import("./state.js");
|
|
108
|
+
const { splitModelId } = await import("./providers/model-id.js");
|
|
109
|
+
const filteredData = aggregate.data.filter((m) => {
|
|
110
|
+
if (!m || !m.id) return false;
|
|
111
|
+
const { provider, raw } = splitModelId(m.id, providers.map((x) => x.id));
|
|
112
|
+
const allowed = loadProviderAllowedModels(provider);
|
|
113
|
+
const allowAny = loadProviderAllowAnyModels(provider);
|
|
114
|
+
if (!allowed.length && !allowAny) return false;
|
|
115
|
+
if (!allowed.length) return true;
|
|
116
|
+
return allowed.includes(String(raw || "").trim());
|
|
117
|
+
});
|
|
118
|
+
if (filteredData.length !== aggregate.data.length) return { ...aggregate, data: filteredData };
|
|
119
|
+
return aggregate;
|
|
120
|
+
} catch {
|
|
121
|
+
return aggregate;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function aggRefreshInBackground() {
|
|
125
|
+
void aggLoadShared().then(() => { aggFetchedAt = Date.now(); }).catch(() => {});
|
|
126
|
+
}
|
|
79
127
|
async function get() {
|
|
80
128
|
const now = Date.now();
|
|
81
|
-
|
|
129
|
+
const fresh = aggregate && now - aggFetchedAt < ttlMs;
|
|
130
|
+
if (fresh) {
|
|
82
131
|
// 热更新白名单:allowlist 变化时,即使命中 TTL 也要重载(否则 clear 后仍返回旧过滤结果)
|
|
83
132
|
try {
|
|
84
133
|
const curKey = await currentAllowlistKey();
|
|
85
134
|
if (curKey !== lastAllowlistKey) {
|
|
86
|
-
const out = await
|
|
135
|
+
const out = await aggLoadShared();
|
|
87
136
|
aggFetchedAt = Date.now();
|
|
88
137
|
return out;
|
|
89
138
|
}
|
|
90
139
|
} catch {}
|
|
91
|
-
|
|
92
|
-
try {
|
|
93
|
-
const { loadProviderAllowedModels, loadProviderAllowAnyModels } = await import("./state.js");
|
|
94
|
-
const { splitModelId } = await import("./providers/model-id.js");
|
|
95
|
-
const filteredData = aggregate.data.filter((m) => {
|
|
96
|
-
if (!m || !m.id) return false;
|
|
97
|
-
const { provider, raw } = splitModelId(m.id, providers.map((x) => x.id));
|
|
98
|
-
const allowed = loadProviderAllowedModels(provider);
|
|
99
|
-
const allowAny = loadProviderAllowAnyModels(provider);
|
|
100
|
-
if (!allowed.length && !allowAny) return false;
|
|
101
|
-
if (!allowed.length) return true;
|
|
102
|
-
return allowed.includes(String(raw || "").trim());
|
|
103
|
-
});
|
|
104
|
-
if (filteredData.length !== aggregate.data.length) return { ...aggregate, data: filteredData };
|
|
105
|
-
return aggregate;
|
|
106
|
-
} catch {
|
|
107
|
-
return aggregate;
|
|
108
|
-
}
|
|
140
|
+
return aggSnapshot();
|
|
109
141
|
}
|
|
110
|
-
|
|
142
|
+
// 有旧值(含冷启动磁盘 stale):立即返回 + 后台刷新,主请求零等待。
|
|
143
|
+
// 取证教训:全目录拉取 20-26s,此前冷路径同步阻塞整个首请求。
|
|
144
|
+
if (aggregate) {
|
|
145
|
+
aggRefreshInBackground();
|
|
146
|
+
return aggSnapshot();
|
|
147
|
+
}
|
|
148
|
+
// 完全无值(首次冷跑):只能等这一趟。
|
|
149
|
+
const out = await aggLoadShared();
|
|
111
150
|
aggFetchedAt = Date.now();
|
|
112
151
|
return out;
|
|
113
152
|
}
|
|
114
|
-
|
|
153
|
+
// 并发去重:aggLoad 要遍历全部供应商拉目录(真机实测 20-26s),
|
|
154
|
+
// 无去重时赛马/后台任务并发触发就各烧各的(真机日志实证同窗口重复 3 次)。
|
|
155
|
+
function aggLoadShared() {
|
|
156
|
+
if (aggInflight) return aggInflight;
|
|
157
|
+
aggInflight = (async () => {
|
|
158
|
+
try {
|
|
159
|
+
return await aggLoad();
|
|
160
|
+
} catch (e) {
|
|
161
|
+
// 失败保旧:有旧聚合就回退旧值,绝不让半次刷新把目录清空
|
|
162
|
+
if (aggregate) return aggregate;
|
|
163
|
+
throw e;
|
|
164
|
+
} finally {
|
|
165
|
+
aggInflight = null;
|
|
166
|
+
}
|
|
167
|
+
})();
|
|
168
|
+
return aggInflight;
|
|
169
|
+
}
|
|
170
|
+
function aggStartAutoRefresh(intervalMs = refreshMs) {
|
|
171
|
+
if (aggTimer) return aggStopAutoRefresh;
|
|
172
|
+
aggTimer = setInterval(() => {
|
|
173
|
+
void aggLoadShared().then(() => { aggFetchedAt = Date.now(); }).catch(() => {});
|
|
174
|
+
}, intervalMs);
|
|
175
|
+
aggTimer.unref?.();
|
|
176
|
+
return aggStopAutoRefresh;
|
|
177
|
+
}
|
|
178
|
+
function aggStopAutoRefresh() {
|
|
179
|
+
if (aggTimer) { clearInterval(aggTimer); aggTimer = null; }
|
|
180
|
+
}
|
|
181
|
+
return { get, startAutoRefresh: aggStartAutoRefresh, stopAutoRefresh: aggStopAutoRefresh };
|
|
115
182
|
}
|
|
116
183
|
let cache = null;
|
|
117
184
|
let fetchedAt = 0;
|
|
@@ -96,6 +96,24 @@ export async function saveQoderAccount({ uid, deviceToken, refreshToken = "", re
|
|
|
96
96
|
return { file: fp, accounts: st.keys.length, updated: st.updated > 0 };
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
// 刷新成功后按 uid 原位更新令牌对(deviceToken 时效短,105 过期即刷)。
|
|
100
|
+
// auth 文档保留原 region/name;state 行按 uid 覆盖(persistAccounts 命中既有 uid 行即替换)。
|
|
101
|
+
export async function updateQoderAccountTokens({ uid, deviceToken, refreshToken, file } = {}) {
|
|
102
|
+
if (!uid || !deviceToken) throw new Error("updateQoderAccountTokens: 缺少 uid/deviceToken");
|
|
103
|
+
const docs = listAccountDocs();
|
|
104
|
+
const found = docs.find((d) => String(d.uid) === String(uid));
|
|
105
|
+
const a = found?.doc?.auth || {};
|
|
106
|
+
const acc = found?.doc?.account || {};
|
|
107
|
+
writeAccountFile({
|
|
108
|
+
uid, deviceToken, refreshToken,
|
|
109
|
+
region: a.region || "global",
|
|
110
|
+
name: acc.name || "", email: acc.email || "", userType: acc.userType || "personal_standard",
|
|
111
|
+
organizationId: acc.organizationId || "", organizationName: acc.organizationName || "",
|
|
112
|
+
});
|
|
113
|
+
await persistAccounts([{ uid, deviceToken, refreshToken, region: a.region || "global", name: acc.name || "", domain: "qoder.com" }], file);
|
|
114
|
+
return { updated: Boolean(found) };
|
|
115
|
+
}
|
|
116
|
+
|
|
99
117
|
// 重建 provider 的行级 auth:由 keys[i] blob + auths[i] 还原 deviceToken。
|
|
100
118
|
export function accountFromBlob(blob) {
|
|
101
119
|
try {
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
// false → 聚合回 JSON。装配走 request.js,帧格式走 sse.js,两条管线共享。
|
|
3
3
|
import { compatFetch, timeoutSignal } from "../../compat.js";
|
|
4
4
|
import { mapModel } from "./payload.js";
|
|
5
|
-
import { effortFromBody, isReasoningEnabled } from "../../thinking-effort.js";
|
|
5
|
+
import { effortFromBody, effortLevelFromBody, isReasoningEnabled } from "../../thinking-effort.js";
|
|
6
6
|
import { normalizeRegion } from "./constants.js";
|
|
7
7
|
import { buildUpstreamRequest } from "./request.js";
|
|
8
8
|
import { reshapeQoderStream } from "./stream.js";
|
|
9
9
|
import { aggregateQoderStream, toCompletionJson } from "./aggregate.js";
|
|
10
|
-
import { errorStatus, extractDelta } from "./sse.js";
|
|
10
|
+
import { errorStatus, extractDelta, findQuotaSignal, findLoginSignal, findQueueSignal, retryAfterFrom } from "./sse.js";
|
|
11
11
|
|
|
12
12
|
function hostOf(url) {
|
|
13
13
|
try { return new URL(String(url)).host; } catch { return "-"; }
|
|
@@ -25,21 +25,39 @@ function mapUpstreamError(status, detail) {
|
|
|
25
25
|
// 又不会把首字节延迟拖到不可控。超窗未得判决即放弃(退化为无冷却的流内 error,见 SPEC 已知上限)。
|
|
26
26
|
const PEEK_MAX_FRAMES = 8;
|
|
27
27
|
|
|
28
|
+
// 判决旗标(统一出口):登录过期/额度/排队(含无容量子型)+ retryAfter 秒数。
|
|
29
|
+
// 非 200 直出与 200 信封裹身两条路径共用同一解析口,防两处漂移。
|
|
30
|
+
function verdictFlagsFrom(objOrText) {
|
|
31
|
+
const v = { login: false, quota: false, queued: false, noCapacity: false, retryAfter: 0 };
|
|
32
|
+
if (findLoginSignal(objOrText)) v.login = true;
|
|
33
|
+
else if (findQuotaSignal(objOrText)) v.quota = true;
|
|
34
|
+
else {
|
|
35
|
+
const q = findQueueSignal(objOrText);
|
|
36
|
+
if (q) { v.queued = true; v.noCapacity = q.noCapacity === true; }
|
|
37
|
+
}
|
|
38
|
+
const ra = retryAfterFrom(typeof objOrText === "string" ? objOrText : JSON.stringify(objOrText ?? ""));
|
|
39
|
+
if (ra.retryAfter) v.retryAfter = ra.retryAfter;
|
|
40
|
+
return v;
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
// 预读上游首帧只为取「流内判决」:qoder 把限流/鉴权判决裹在 HTTP 200 的 SSE 信封里
|
|
29
44
|
//(statusCodeValue:403 + serviceAvailable:false),upRes.status 恒 200,判决只有读完帧才知道。
|
|
30
45
|
// 不预读则状态码永远出不来流式路径 → 门面不冷却该号(ADR-0036 的粘号会把重试继续粘在它上面)。
|
|
31
46
|
// 返回 { res, status, frames }:res 是把预读帧回灌后的等价上游响应,frames 交回灌用。
|
|
32
47
|
// 任何异常都降级为「无判决」(status=0):排障手段不得变成故障源。
|
|
33
48
|
async function peekEnvelopeVerdict(upRes, cdbg) {
|
|
34
|
-
if (!upRes?.body) return { res: upRes, status: 0, quota: false, queued: false, frames: [] };
|
|
49
|
+
if (!upRes?.body) return { res: upRes, status: 0, quota: false, queued: false, noCapacity: false, retryAfter: 0, login: false, frames: [] };
|
|
35
50
|
let reader;
|
|
36
|
-
try { reader = upRes.body.getReader(); } catch { return { res: upRes, status: 0, quota: false, queued: false, frames: [] }; }
|
|
51
|
+
try { reader = upRes.body.getReader(); } catch { return { res: upRes, status: 0, quota: false, queued: false, noCapacity: false, retryAfter: 0, login: false, frames: [] }; }
|
|
37
52
|
const dec = new TextDecoder();
|
|
38
53
|
const frames = [];
|
|
39
54
|
let text = "";
|
|
40
55
|
let status = 0;
|
|
41
56
|
let quota = false;
|
|
42
57
|
let queued = false;
|
|
58
|
+
let noCapacity = false;
|
|
59
|
+
let retryAfter = 0;
|
|
60
|
+
let login = false;
|
|
43
61
|
let decided = false;
|
|
44
62
|
try {
|
|
45
63
|
for (let i = 0; i < PEEK_MAX_FRAMES && !decided; i++) {
|
|
@@ -53,14 +71,22 @@ async function peekEnvelopeVerdict(upRes, cdbg) {
|
|
|
53
71
|
const p = line.slice(5).trim();
|
|
54
72
|
if (!p || p === "[DONE]") continue;
|
|
55
73
|
const d = extractDelta(p);
|
|
56
|
-
if (d.err) {
|
|
74
|
+
if (d.err) {
|
|
75
|
+
status = Number(d.err.status) || 0;
|
|
76
|
+
quota = d.err.kind === "quota";
|
|
77
|
+
queued = d.err.queued === true;
|
|
78
|
+
noCapacity = d.err.noCapacity === true;
|
|
79
|
+
retryAfter = Number(d.err.retryAfter) || 0;
|
|
80
|
+
login = d.err.loginExpired === true;
|
|
81
|
+
decided = true; break;
|
|
82
|
+
}
|
|
57
83
|
if (d.content || d.reasoning || d.toolCalls || d.usageIn || d.usageOut) { decided = true; break; }
|
|
58
84
|
}
|
|
59
85
|
}
|
|
60
86
|
} catch (e) {
|
|
61
87
|
cdbg(`[peek-throw] ${String(e?.message || e).slice(0, 160)}`);
|
|
62
88
|
}
|
|
63
|
-
cdbg(`[peek] frames=${frames.length} verdict=${status || "none(正常流)"} quota=${quota} queued=${queued}`);
|
|
89
|
+
cdbg(`[peek] frames=${frames.length} verdict=${status || "none(正常流)"} quota=${quota} queued=${queued} noCapacity=${noCapacity} ra=${retryAfter} login=${login}`);
|
|
64
90
|
// 回灌:已读帧先按原样吐出,再接续同一个 reader 的剩余部分
|
|
65
91
|
const body = new ReadableStream({
|
|
66
92
|
start(ctrl) {
|
|
@@ -77,7 +103,7 @@ async function peekEnvelopeVerdict(upRes, cdbg) {
|
|
|
77
103
|
})();
|
|
78
104
|
},
|
|
79
105
|
});
|
|
80
|
-
return { res: new Response(body, { status: upRes.status, headers: upRes.headers }), status, quota, queued, frames };
|
|
106
|
+
return { res: new Response(body, { status: upRes.status, headers: upRes.headers }), status, quota, queued, noCapacity, retryAfter, login, frames };
|
|
81
107
|
}
|
|
82
108
|
export function createChatService({ id = "qoder", fetchImpl, timeoutMs, getModelMeta } = {}) {
|
|
83
109
|
if (!fetchImpl) fetchImpl = compatFetch;
|
|
@@ -114,6 +140,31 @@ export function createChatService({ id = "qoder", fetchImpl, timeoutMs, getModel
|
|
|
114
140
|
try { res.headers.set("x-mslxdff-qoder-queued", "1"); } catch {}
|
|
115
141
|
return res;
|
|
116
142
|
};
|
|
143
|
+
// 无容量旗标:10605 且 queueCount:0 + serviceAvailable:false = 整池共享同一 p3 队列、
|
|
144
|
+
// 换号救不了,只能等 Retry-After。门面据此**不换号轮询**,带 429+Retry-After 如实透给客户端。
|
|
145
|
+
const withNoCapacity = (res, retryAfter) => {
|
|
146
|
+
try {
|
|
147
|
+
res.headers.set("x-mslxdff-qoder-nocapacity", "1");
|
|
148
|
+
if (retryAfter) {
|
|
149
|
+
res.headers.set("x-mslxdff-qoder-retry-after", String(retryAfter));
|
|
150
|
+
res.headers.set("Retry-After", String(retryAfter)); // 对客户端的标准交代(relay 层原样转发响应头)
|
|
151
|
+
}
|
|
152
|
+
} catch {}
|
|
153
|
+
return res;
|
|
154
|
+
};
|
|
155
|
+
// 登录过期标记(内部交接,不落日志):门面据此走「刷新令牌→原地重发」,不当普通坏号冷却轮询。
|
|
156
|
+
const withLogin = (res) => {
|
|
157
|
+
try { res.headers.set("x-mslxdff-qoder-login", "1"); } catch {}
|
|
158
|
+
return res;
|
|
159
|
+
};
|
|
160
|
+
// 判决结果钉头(2026-10-22 用户实证「没多打日志→排查全靠猜」后补):verdict 取值
|
|
161
|
+
// login/quota/queue/queue-nocapacity/none,由 model-trace.upstreamEcho 落进模型日志。
|
|
162
|
+
// 关键在 none 也要显式钉——「压根没命中」与「命中但走别的分支」在 403 盲视图里必须分得开。
|
|
163
|
+
const verdictName = (v) => (v.login ? "login" : v.quota ? "quota" : v.queued ? (v.noCapacity ? "queue-nocapacity" : "queue") : "none");
|
|
164
|
+
const withVerdict = (res, v) => {
|
|
165
|
+
try { res.headers.set("x-mslxdff-qoder-verdict", verdictName(v)); } catch {}
|
|
166
|
+
return res;
|
|
167
|
+
};
|
|
117
168
|
|
|
118
169
|
async function runChat(body, sess, region = "global", pick = "") {
|
|
119
170
|
const stream = body?.stream !== false;
|
|
@@ -122,14 +173,20 @@ export function createChatService({ id = "qoder", fetchImpl, timeoutMs, getModel
|
|
|
122
173
|
const tools = Array.isArray(body?.tools) && body.tools.length ? body.tools : null;
|
|
123
174
|
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
|
124
175
|
const maxTokens = Number(body?.max_tokens) || 0;
|
|
125
|
-
// is_reasoning
|
|
126
|
-
//
|
|
176
|
+
// is_reasoning 与能力元数据(窗口/档位)由模型目录一次供数(index 注入 getModelMeta,
|
|
177
|
+
// 只读 peekModels 快照缓存,chat 路径恒 0 额外上游调用;preheat 负责灌缓存)。
|
|
178
|
+
// 冷缓存/缺失一律降级:isReasoning=false、无 meta(= 旧行为,不阻断对话)。
|
|
179
|
+
let meta = null;
|
|
127
180
|
let isReasoning = false;
|
|
128
181
|
try {
|
|
129
|
-
if (getModelMeta)
|
|
182
|
+
if (getModelMeta) {
|
|
183
|
+
meta = (await getModelMeta(sess, normalizeRegion(region), mapModel(model))) || null;
|
|
184
|
+
isReasoning = meta?.is_reasoning === true;
|
|
185
|
+
}
|
|
130
186
|
} catch {}
|
|
131
187
|
// 客户端显式档位优先于目录推断(grill-decisions Q3):关断→false、其余非空→true;无档位→沿用目录结论。
|
|
132
188
|
isReasoning = isReasoningEnabled(effortFromBody(body), isReasoning);
|
|
189
|
+
const effortLevel = effortLevelFromBody(body); // 真档位串(low/medium/high…),透传给装配口对齐模型档位
|
|
133
190
|
|
|
134
191
|
let upRes; let reqUrl = "";
|
|
135
192
|
const echo = (res) => withEcho(res, reqUrl, region, pick);
|
|
@@ -137,7 +194,7 @@ export function createChatService({ id = "qoder", fetchImpl, timeoutMs, getModel
|
|
|
137
194
|
const cdbg = (...a) => { if (process.env.QODER_DEBUG_STREAM === "1") console.log("[qoder-chat]", ...a); };
|
|
138
195
|
let upMs = 0;
|
|
139
196
|
try {
|
|
140
|
-
const req = buildUpstreamRequest({ sess, region: normalizeRegion(region), model, messages, tools, maxTokens, isReasoning });
|
|
197
|
+
const req = buildUpstreamRequest({ sess, region: normalizeRegion(region), model, messages, tools, maxTokens, isReasoning, meta, effort: effortLevel });
|
|
141
198
|
reqUrl = req.url;
|
|
142
199
|
cdbg(`[req] url=${hostOf(req.url)} region=${normalizeRegion(region)} model=${model} msgs=${messages.length} tools=${tools?.length ?? 0} maxTokens=${maxTokens} stream=${stream} bodyBytes=${req.bodyStr?.length ?? 0}`);
|
|
143
200
|
const t0 = Date.now();
|
|
@@ -159,7 +216,15 @@ export function createChatService({ id = "qoder", fetchImpl, timeoutMs, getModel
|
|
|
159
216
|
if (upRes.status !== 200) {
|
|
160
217
|
const detail = await upRes.text().catch(() => "");
|
|
161
218
|
cdbg(`[res-non200] status=${upRes.status} body=${detail.slice(0, 300)}`);
|
|
162
|
-
|
|
219
|
+
// 真 HTTP 403 也可能直出 10605 排队/110 额度/105 登录(不只裹 200 信封)。
|
|
220
|
+
// 旧版一律 mapUpstreamError(403)→auth→冷却换号,把「整池共享的 p3 无容量」当死号轮询白烧。
|
|
221
|
+
// 先解析 body 定旗标:无容量 → 不换号、带 Retry-After 如实透;额度/登录 → 交门面对应处理。
|
|
222
|
+
const vf = verdictFlagsFrom(detail);
|
|
223
|
+
cdbg(`[verdict] status=${upRes.status} login=${vf.login} quota=${vf.quota} queued=${vf.queued} noCapacity=${vf.noCapacity} ra=${vf.retryAfter} bodyLen=${detail.length} body=${detail.slice(0, 300)}`);
|
|
224
|
+
const e = vf.login ? { kind: "auth", status: 401, detail }
|
|
225
|
+
: vf.quota ? { kind: "quota", status: 429, detail }
|
|
226
|
+
: vf.queued ? { kind: "upstream", status: 429, detail }
|
|
227
|
+
: mapUpstreamError(upRes.status, detail);
|
|
163
228
|
if (stream) {
|
|
164
229
|
// 首包错误也走流内 error 事件(与旧 reshapeStream catch 语义一致:200 + event:error + DONE)
|
|
165
230
|
const enc = new TextEncoder();
|
|
@@ -171,21 +236,36 @@ export function createChatService({ id = "qoder", fetchImpl, timeoutMs, getModel
|
|
|
171
236
|
},
|
|
172
237
|
});
|
|
173
238
|
// 对外仍是 200(契约不变),真实状态码经回显头交门面决定是否冷却
|
|
174
|
-
|
|
239
|
+
let shaped = withUpstreamStatus(echo(new Response(s, { status: 200, headers: { "Content-Type": "text/event-stream" } })), upRes.status);
|
|
240
|
+
shaped = withVerdict(shaped, vf);
|
|
241
|
+
if (vf.quota) shaped = withQuota(shaped);
|
|
242
|
+
if (vf.login) shaped = withLogin(shaped);
|
|
243
|
+
if (vf.queued) shaped = vf.noCapacity ? withNoCapacity(shaped, vf.retryAfter) : withQueued(shaped);
|
|
244
|
+
return shaped;
|
|
175
245
|
}
|
|
176
|
-
|
|
246
|
+
let out = withUpstreamStatus(echo(errRes(errorStatus(e), e.detail, e.kind)), upRes.status);
|
|
247
|
+
out = withVerdict(out, vf);
|
|
248
|
+
if (vf.quota) out = withQuota(out);
|
|
249
|
+
if (vf.login) out = withLogin(out);
|
|
250
|
+
if (vf.queued) out = vf.noCapacity ? withNoCapacity(out, vf.retryAfter) : withQueued(out);
|
|
251
|
+
return out;
|
|
177
252
|
}
|
|
178
253
|
if (stream) {
|
|
179
254
|
// 预读首帧取流内判决(qoder 把 403/429 裹在 200 的信封里),预读帧原样回灌给整形器。
|
|
180
255
|
// 判决状态码经 withUpstreamStatus 出门面 → index.js 据此 ring.onError 冷却该号 →
|
|
181
256
|
// 粘号选择器下次因 isCooling 为真而换号。对外仍恒 200(契约不变)。
|
|
182
257
|
const peek = await peekEnvelopeVerdict(upRes, cdbg);
|
|
258
|
+
const pvf = { login: peek.login, quota: peek.quota, queued: peek.queued, noCapacity: peek.noCapacity };
|
|
183
259
|
let shaped = echo(reshapeQoderStream(peek.res, { model, chatId, prefetched: peek.frames }));
|
|
184
260
|
// 只在真有判决时挂状态码:正常流不写这个头(index.js 靠它区分"要不要冷却")
|
|
185
261
|
if (peek.status) shaped = withUpstreamStatus(shaped, peek.status);
|
|
262
|
+
// verdict 钉头同理只钉有判决的流——正常流不写,避免成功请求日志被 verdict=none 刷屏
|
|
263
|
+
if (peek.status) shaped = withVerdict(shaped, pvf);
|
|
186
264
|
if (peek.quota) shaped = withQuota(shaped);
|
|
187
|
-
//
|
|
188
|
-
if (peek.queued) shaped = withQueued(shaped);
|
|
265
|
+
// 排队旗标(内部交接):真排队(有人等位)→ 门面换号碰别的号;无容量 → 门面不换号透 Retry-After
|
|
266
|
+
if (peek.queued) shaped = peek.noCapacity ? withNoCapacity(shaped, peek.retryAfter) : withQueued(shaped);
|
|
267
|
+
// 登录过期旗标:门面据此刷新令牌原地重发(不冷却换号轮询整池过期号)
|
|
268
|
+
if (peek.login) shaped = withLogin(shaped);
|
|
189
269
|
return shaped;
|
|
190
270
|
}
|
|
191
271
|
try {
|
|
@@ -193,9 +273,18 @@ export function createChatService({ id = "qoder", fetchImpl, timeoutMs, getModel
|
|
|
193
273
|
const out = toCompletionJson({ model, chatId, ...agg });
|
|
194
274
|
return echo(new Response(JSON.stringify(out), { status: 200, headers: { "Content-Type": "application/json" } }));
|
|
195
275
|
} catch (e) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
276
|
+
// 聚合出口的判决折态(2026-10-22 真机实锤 req=mvj6u7wj:HTTP 恒 200、403 裹在 SSE 信封
|
|
277
|
+
// statusCodeValue 里,extractDelta 的 err.status=403 原样 errRes 出去 → nocapacity 头挂了、
|
|
278
|
+
// 状态却还是 403。无容量档必须与非 200 直出路径同口径折 429,否则门面 break 后
|
|
279
|
+
// lastRes 透给客户端仍是 403,Retry-After 语义全丢)。
|
|
280
|
+
const cvf = { login: e?.loginExpired === true, quota: e?.kind === "quota", queued: e?.queued === true, noCapacity: e?.noCapacity === true };
|
|
281
|
+
const aggStatus = cvf.noCapacity ? 429 : errorStatus(e);
|
|
282
|
+
let out = echo(errRes(aggStatus, String(e?.detail || e?.message || e).slice(0, 300), e?.kind || "upstream_error"));
|
|
283
|
+
out = withVerdict(out, cvf);
|
|
284
|
+
if (cvf.noCapacity && e?.retryAfter) out = withNoCapacity(out, e.retryAfter);
|
|
285
|
+
let marked = cvf.quota ? withQuota(out) : out;
|
|
286
|
+
if (cvf.queued && !cvf.noCapacity) marked = withQueued(marked);
|
|
287
|
+
return cvf.login ? withLogin(marked) : marked;
|
|
199
288
|
}
|
|
200
289
|
}
|
|
201
290
|
|
|
@@ -54,18 +54,27 @@ export function dailyCheckinClaim({ deviceToken, region, fetchImpl = compatFetch
|
|
|
54
54
|
return call(fetchImpl, region, EP_CHECKIN_CLAIM, { method: "POST", deviceToken, body: {}, timeoutMs });
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
// 余额:/api/v2/quota/usage → userQuota
|
|
57
|
+
// 余额:/api/v2/quota/usage → 基础池 userQuota + 奖励池 addOnQuota(签到/活动领的额度)。
|
|
58
|
+
// ⚠ 取证教训(2026-10-22):旧版只读 userQuota,漏了 addOnQuota —— Free 号 userQuota 恒 0/0,
|
|
59
|
+
// 但领过签到奖励的号 addOnQuota.remaining=100 仍有额度,被误报成「已耗尽」。
|
|
60
|
+
// exhausted 以接口综合判断 isQuotaExceeded 为权威(两池都用完才 true),不自行推断。
|
|
58
61
|
export async function fetchQuota({ deviceToken, region, fetchImpl = compatFetch, timeoutMs } = {}) {
|
|
59
62
|
const r = await call(fetchImpl, region, EP_QUOTA, { deviceToken, timeoutMs });
|
|
60
63
|
if (r.status !== 200) return { ok: false, status: r.status, error: r.error || r.text.slice(0, 160) };
|
|
61
|
-
const
|
|
64
|
+
const num = (v) => Number(v) || 0;
|
|
65
|
+
const q = r.json?.userQuota;
|
|
66
|
+
const add = r.json?.addOnQuota;
|
|
67
|
+
const base = { total: num(q?.total), used: num(q?.used), remaining: num(q?.remaining) };
|
|
68
|
+
const addOn = add ? { total: num(add.total), used: num(add.used), remaining: num(add.remaining) } : null;
|
|
62
69
|
return {
|
|
63
70
|
ok: true,
|
|
64
|
-
total:
|
|
65
|
-
used:
|
|
66
|
-
remaining:
|
|
67
|
-
unit: String(q
|
|
71
|
+
total: base.total + (addOn?.total ?? 0),
|
|
72
|
+
used: base.used + (addOn?.used ?? 0),
|
|
73
|
+
remaining: base.remaining + (addOn?.remaining ?? 0),
|
|
74
|
+
unit: String(q?.unit || add?.unit || "credits"),
|
|
68
75
|
exhausted: Boolean(r.json?.isQuotaExceeded),
|
|
76
|
+
base,
|
|
77
|
+
addOn,
|
|
69
78
|
};
|
|
70
79
|
}
|
|
71
80
|
|
|
@@ -102,15 +111,19 @@ export async function runCheckin({ deviceToken, region, fetchImpl = compatFetch,
|
|
|
102
111
|
}
|
|
103
112
|
if (st.status === 401) { r.message = `device token 失效(HTTP 401):请重新 mslxdff -provider qoder login`; return r; }
|
|
104
113
|
|
|
105
|
-
//
|
|
106
|
-
|
|
114
|
+
// 取证教训(2026-10-22):上游 cn 老入口 daily-check-in 已 DISABLED,但字段还在返回。
|
|
115
|
+
// 旧代码把 DISABLED 当活跃态去 claim,拿到 409 就报「今日已领取」直接 return ——
|
|
116
|
+
// 永远走不到 campaigns 兜底,天天错过真积分活动(act-20261020-993,每日 10:00 UTC+8 刷新)。
|
|
117
|
+
// 修法:DISABLED 唯一豁免——不碰老入口直接落 campaigns;其余状态保持原行为(试领,409=已领)。
|
|
118
|
+
const legacy = st.status === 200 ? String(st.json?.status || "") : "";
|
|
119
|
+
const legacyActive = st.status === 200 && st.json?.status && legacy !== "DISABLED";
|
|
120
|
+
if (probeOnly && legacyActive) {
|
|
107
121
|
r.ok = true;
|
|
108
|
-
r.status =
|
|
109
|
-
r.message = `干跑:status=${
|
|
122
|
+
r.status = legacy === "CLAIMED" ? "already" : "claimable";
|
|
123
|
+
r.message = `干跑:status=${legacy} 每日奖励 ${r.reward}`;
|
|
110
124
|
return r;
|
|
111
125
|
}
|
|
112
|
-
|
|
113
|
-
if (st.status === 200 && st.json?.status) {
|
|
126
|
+
if (legacyActive) {
|
|
114
127
|
const claim = await dailyCheckinClaim({ deviceToken, region: reg, fetchImpl, timeoutMs });
|
|
115
128
|
if (claim.status === 409) { r.ok = true; r.status = "already"; r.message = "今日已领取"; return r; }
|
|
116
129
|
if (claim.status === 200) {
|