mslxdff 0.1.87 → 0.1.89
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/bench/cline-bench.js +21 -33
- package/src/bench/probe.js +4 -11
- package/src/bench/runner.js +8 -23
- package/src/bench/via-probe.js +12 -30
- package/src/bench/workbuddy-bench.js +15 -31
- package/src/chat-pipeline/engine.js +241 -0
- package/src/chat-pipeline/index.js +86 -0
- package/src/chat-pipeline/planner.js +31 -0
- package/src/chat-pipeline/policy.js +73 -0
- package/src/providers/cline/chat.js +26 -53
- package/src/providers/workbuddy/chat.js +109 -221
- package/src/routes/chat/gateway.js +23 -286
- package/src/routes/groups-relay.js +85 -1
- package/src/routes/index.js +7 -1
- package/src/routes/relay-queue.js +53 -0
- package/src/runtime/bootstrap.js +116 -34
- package/src/transport/index.js +248 -0
- package/src/transport/pool.js +60 -0
- package/src/transport/retry.js +24 -0
- package/src/transport/sse.js +93 -0
- package/src/upstream.js +113 -287
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { analyzePolicy } from "./policy.js";
|
|
3
|
+
import { planRoute } from "./planner.js";
|
|
4
|
+
import { createEngine } from "./engine.js";
|
|
5
|
+
import { runHook } from "../plugins.js";
|
|
6
|
+
import { clientIp, summarizePrompt } from "../routes/helpers.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* ChatPipeline 深模块门面 — 对外 execute(req) 单一 inlet
|
|
10
|
+
* 内部组合 Policy→Planner→Engine:解析 header/model → 产 order → 委托 engine 执行
|
|
11
|
+
* gateway 仅薄适配:readBody + request:received hook + 调 execute
|
|
12
|
+
*/
|
|
13
|
+
export function createChatPipeline({ upstream, auto, logs, peers, groups, bus, token, plugins, maxHops } = {}) {
|
|
14
|
+
const engine = createEngine();
|
|
15
|
+
|
|
16
|
+
async function execute({ req, res } = {}) {
|
|
17
|
+
const startedAt = Date.now();
|
|
18
|
+
const reqId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
19
|
+
const perf0 = performance.now();
|
|
20
|
+
const stages = [];
|
|
21
|
+
const mark = (name) => stages.push([name, Math.round(performance.now() - perf0)]);
|
|
22
|
+
|
|
23
|
+
const policy = analyzePolicy({ headers: req?.headers || {}, body: req?.body || {} });
|
|
24
|
+
const { requested, useAuto, lockModel, hops, shareKeys, workbuddyUid, aliasInfo } = policy;
|
|
25
|
+
mark("parsed");
|
|
26
|
+
if (aliasInfo) { try { res?.setHeader?.("x-mslxdff-alias", aliasInfo); } catch {} }
|
|
27
|
+
// mslxdff/ 前缀或 alias 命中时,把 body.model 改写为还原后的模型(与原 gateway 语义一致)
|
|
28
|
+
if (aliasInfo && req?.body && req.body.model !== requested) {
|
|
29
|
+
req.body = { ...req.body, model: requested };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// order 推导 + plugin model:select 可改
|
|
33
|
+
let order;
|
|
34
|
+
if (lockModel) order = [requested];
|
|
35
|
+
else if (useAuto) order = auto ? await auto.candidates() : [""];
|
|
36
|
+
else order = auto ? await auto.candidatesFor(requested) : [requested];
|
|
37
|
+
if (!order.length) order = [""];
|
|
38
|
+
const canFallback = order.length > 1;
|
|
39
|
+
const canForwardPeers = Boolean(peers) && hops < (maxHops ?? 3);
|
|
40
|
+
mark("ordered");
|
|
41
|
+
|
|
42
|
+
const logCall = (model, status) => logs?.appendCall({ reqId, model, auto: useAuto, status, durationMs: Date.now() - startedAt, stream: Boolean(req?.body?.stream), stages });
|
|
43
|
+
const logError = (model, status, message) => logs?.appendError({ reqId, model, auto: useAuto, status, message, stages });
|
|
44
|
+
const evt = (type, data) => {
|
|
45
|
+
const entry = { ts: Date.now(), reqId, type, ...data, model: data.model ?? requested, auto: useAuto, durationMs: Date.now() - startedAt, stages: [...stages] };
|
|
46
|
+
if (bus) bus.emit(entry);
|
|
47
|
+
logs?.appendEvent?.(entry);
|
|
48
|
+
};
|
|
49
|
+
const done = (info) => {
|
|
50
|
+
if (!plugins?.length) return;
|
|
51
|
+
runHook(plugins, "request:completed", { reqId, requested, useAuto, hops, stream: Boolean(req?.body?.stream), durationMs: Date.now() - startedAt, ...info }).catch(() => {});
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
evt("request", { reqId, hops, ip: clientIp(req), stream: Boolean(req?.body?.stream), prompt: summarizePrompt(req?.body), rawModel: policy.rawModel, requested, lockModel: lockModel || null });
|
|
55
|
+
if (aliasInfo) evt("alias", { reqId, alias: aliasInfo, rawModel: policy.rawModel, requested });
|
|
56
|
+
if (Object.keys(shareKeys).length) evt("share-keys", { reqId, providers: Object.keys(shareKeys) });
|
|
57
|
+
evt("ordered", { reqId, order, canFallback, canForwardPeers, useAuto, statuses: auto?.statuses?.() ?? null });
|
|
58
|
+
|
|
59
|
+
if (plugins?.length && !lockModel) {
|
|
60
|
+
const sel = await runHook(plugins, "model:select", { reqId, requested, useAuto, order: [...order], hops, stream: Boolean(req?.body?.stream) });
|
|
61
|
+
if (sel.changed && Array.isArray(sel.value) && sel.value.length) {
|
|
62
|
+
order = sel.value.filter(Boolean);
|
|
63
|
+
if (!order.length) order = [requested];
|
|
64
|
+
evt("plugin-hook", { reqId, hook: "model:select", applied: true, order: [...order] });
|
|
65
|
+
}
|
|
66
|
+
for (const e of sel.errors) evt("plugin-hook-error", { reqId, hook: "model:select", plugin: e.plugin, error: e.error });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const handlerCtx = { reqId, model: null, body: req?.body, hops, peers, plugins, evt, logError, logCall, logs, workbuddyUid };
|
|
70
|
+
|
|
71
|
+
const plan = planRoute(policy, {
|
|
72
|
+
candidates: order,
|
|
73
|
+
viaRoute: Boolean(!useAuto && requested.includes("/") && canForwardPeers) ? { via: true } : null,
|
|
74
|
+
});
|
|
75
|
+
await engine.run(plan, {
|
|
76
|
+
reqId, startedAt, req, res, body: req?.body, policy,
|
|
77
|
+
useAuto, lockModel, requested, hops,
|
|
78
|
+
canFallback, canForwardPeers,
|
|
79
|
+
perf0, stages, mark, evt, logCall, logError, done, handlerCtx,
|
|
80
|
+
auto, upstream, peers, groups, bus, token, plugins, logs,
|
|
81
|
+
order,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { execute, _policy: analyzePolicy, _plan: planRoute, _engine: engine };
|
|
86
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { DEFAULT_AUTO_MODELS, rankModels } from "../auto.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RoutePlanner 纯决策 — 根据 Policy + auto 状态产 RoutePlan
|
|
5
|
+
* 无网络副作用,供单测注入 FakeAuto
|
|
6
|
+
*/
|
|
7
|
+
export function planRoute(policy, autoState = {}) {
|
|
8
|
+
const { requested, useAuto, lockModel } = policy;
|
|
9
|
+
const { candidates = [], errors = {}, latencies = {}, viaRoute = null } = autoState;
|
|
10
|
+
|
|
11
|
+
// 锁模型时直接单点
|
|
12
|
+
if (lockModel) {
|
|
13
|
+
return { strategy: "direct", order: [requested], concLimit: 1, hedgeDelayMs: 0 };
|
|
14
|
+
}
|
|
15
|
+
// ViaRoute 单路径(显式锁模型且 via 表命中)
|
|
16
|
+
if (viaRoute && !useAuto && requested.includes("/")) {
|
|
17
|
+
return { strategy: "via", order: [requested], via: viaRoute, concLimit: 1, hedgeDelayMs: 0 };
|
|
18
|
+
}
|
|
19
|
+
// Auto 并发择优
|
|
20
|
+
if (useAuto && candidates.length > 1) {
|
|
21
|
+
const concLimit = Math.min(candidates.length, 5);
|
|
22
|
+
return { strategy: "autoRace", order: candidates, concLimit, hedgeDelayMs: 1000 };
|
|
23
|
+
}
|
|
24
|
+
// 显式模型回退链
|
|
25
|
+
if (!useAuto && candidates.length) {
|
|
26
|
+
const others = candidates.filter((m) => m !== requested);
|
|
27
|
+
const order = requested ? [requested, ...others] : candidates;
|
|
28
|
+
return { strategy: "direct", order, concLimit: 1, hedgeDelayMs: 0 };
|
|
29
|
+
}
|
|
30
|
+
return { strategy: "direct", order: requested ? [requested] : [""], concLimit: 1, hedgeDelayMs: 0 };
|
|
31
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { normalizeModel } from "../reasoning.js";
|
|
2
|
+
import { isAutoModel } from "../auto.js";
|
|
3
|
+
import { parseHops } from "../routes/helpers.js";
|
|
4
|
+
import { parseShareKeysHeader, SHARE_KEYS_HEADER } from "../providers/share-keys.js";
|
|
5
|
+
import { normalizeFullId, getModelAlias } from "../providers/model-id.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* PolicyStage 纯函数 — 解析 model 别名 / 白名单前置 / header 透传
|
|
9
|
+
* 零网络副作用,输入 headers+body,输出 PolicyResult
|
|
10
|
+
*/
|
|
11
|
+
export function analyzePolicy({ headers = {}, body = {} } = {}) {
|
|
12
|
+
const hops = parseHops(headers["x-mslxdff-hops"] || headers["X-Mslxdff-Hops"] || "");
|
|
13
|
+
const shareKeys = parseShareKeysHeader(headers[SHARE_KEYS_HEADER] || headers["x-mslxdff-share-keys"] || "");
|
|
14
|
+
const workbuddyUid = (headers["x-mslxdff-workbuddy-uid"] || headers["x-workbuddy-uid"] || "").toString().trim();
|
|
15
|
+
const lockModel = (headers["x-mslxdff-model-lock"] || headers["X-Mslxdff-Model-Lock"] || "").toString();
|
|
16
|
+
const rawModel = body.model || "";
|
|
17
|
+
|
|
18
|
+
let normalizedRequested = normalizeModel(lockModel || rawModel || "");
|
|
19
|
+
const aliasResolved = getModelAlias(normalizedRequested);
|
|
20
|
+
if (aliasResolved) {
|
|
21
|
+
normalizedRequested = aliasResolved;
|
|
22
|
+
}
|
|
23
|
+
let requested = normalizedRequested;
|
|
24
|
+
let aliasInfo = null;
|
|
25
|
+
|
|
26
|
+
if (requested.startsWith("mslxdff/")) {
|
|
27
|
+
const rawPart = requested.slice("mslxdff/".length);
|
|
28
|
+
aliasInfo = `${requested} -> ${rawPart} (mslxdff provider stripped)`;
|
|
29
|
+
requested = rawPart;
|
|
30
|
+
const alias2 = getModelAlias(requested);
|
|
31
|
+
if (alias2) {
|
|
32
|
+
aliasInfo = `${rawModel} -> ${alias2} (mslxdff + alias)`;
|
|
33
|
+
requested = alias2;
|
|
34
|
+
}
|
|
35
|
+
} else if (aliasResolved) {
|
|
36
|
+
// 非 mslxdff 的 dash 形态已在首轮 aliasResolved 处理
|
|
37
|
+
aliasInfo = aliasResolved !== (lockModel || rawModel) ? `${rawModel} -> ${aliasResolved} (alias)` : null;
|
|
38
|
+
if (!aliasInfo) aliasInfo = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// workbuddy <uid>:model 形式的 uid 钉死在 normalizeFullId 侧处理,这里透传原始 requested 供 planner 二次剥离
|
|
42
|
+
// 若 requested 含 workbuddy/ 前缀且含 :,则尝试提取 uid
|
|
43
|
+
let extractedUid = workbuddyUid;
|
|
44
|
+
if (!extractedUid && requested.startsWith("workbuddy/") && requested.includes(":")) {
|
|
45
|
+
const after = requested.slice("workbuddy/".length);
|
|
46
|
+
const uidPart = after.split(":")[0];
|
|
47
|
+
if (uidPart) extractedUid = uidPart;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const useAuto = isAutoModel(requested);
|
|
51
|
+
|
|
52
|
+
// 对 workbuddy 前缀的 model,做 normalizeFullId 归一(剥 uid 供上游)
|
|
53
|
+
// 但保留 requested 为完整带前缀形态,供 planner 做 ViaRoute 判定
|
|
54
|
+
let normalizedForUpstream = requested;
|
|
55
|
+
try {
|
|
56
|
+
const norm = normalizeFullId(requested);
|
|
57
|
+
if (norm && norm.raw) normalizedForUpstream = norm.raw ? `${norm.provider ? norm.provider + "/" : ""}${norm.raw}` : requested;
|
|
58
|
+
} catch {}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
rawModel,
|
|
62
|
+
requested,
|
|
63
|
+
normalizedRequested,
|
|
64
|
+
normalizedForUpstream,
|
|
65
|
+
aliasInfo,
|
|
66
|
+
useAuto,
|
|
67
|
+
shareKeys,
|
|
68
|
+
workbuddyUid: extractedUid,
|
|
69
|
+
lockModel,
|
|
70
|
+
hops,
|
|
71
|
+
bodyModel: body.model || null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { joinUrl, sleep } from "../base.js";
|
|
2
2
|
import { clineHeaders } from "./headers.js";
|
|
3
|
+
import { createTransport } from "../../transport/index.js";
|
|
3
4
|
|
|
4
5
|
function genSessionId() { return `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; }
|
|
5
6
|
|
|
@@ -12,40 +13,27 @@ function unwrapData(obj) {
|
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
async function streamToNonStream(upstream) {
|
|
15
|
-
const reader = upstream.body.getReader();
|
|
16
|
-
const decoder = new TextDecoder();
|
|
17
|
-
let buf = "";
|
|
18
16
|
let content = "";
|
|
19
17
|
let reasoning = "";
|
|
20
18
|
let finishReason = null;
|
|
21
19
|
let model = "";
|
|
22
20
|
let id = "";
|
|
23
21
|
let usage = null;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
if (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (!choice) continue;
|
|
40
|
-
const delta = choice.delta || {};
|
|
41
|
-
if (delta.content) content += delta.content;
|
|
42
|
-
if (delta.reasoning) reasoning += delta.reasoning;
|
|
43
|
-
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
44
|
-
if (normalized.id) id = normalized.id;
|
|
45
|
-
if (normalized.model) model = normalized.model;
|
|
46
|
-
if (normalized.usage) usage = normalized.usage;
|
|
47
|
-
} catch {}
|
|
48
|
-
}
|
|
22
|
+
for await (const ev of upstream.stream()) {
|
|
23
|
+
if (!ev || ev === "[DONE]") continue;
|
|
24
|
+
try {
|
|
25
|
+
const obj = JSON.parse(ev);
|
|
26
|
+
const normalized = unwrapData(obj);
|
|
27
|
+
const choice = normalized?.choices?.[0];
|
|
28
|
+
if (!choice) continue;
|
|
29
|
+
const delta = choice.delta || {};
|
|
30
|
+
if (delta.content) content += delta.content;
|
|
31
|
+
if (delta.reasoning) reasoning += delta.reasoning;
|
|
32
|
+
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
33
|
+
if (normalized.id) id = normalized.id;
|
|
34
|
+
if (normalized.model) model = normalized.model;
|
|
35
|
+
if (normalized.usage) usage = normalized.usage;
|
|
36
|
+
} catch {}
|
|
49
37
|
}
|
|
50
38
|
const msg = { role: "assistant", content };
|
|
51
39
|
if (reasoning) msg.reasoning = reasoning;
|
|
@@ -68,42 +56,34 @@ export function createChatService({
|
|
|
68
56
|
dispatcher,
|
|
69
57
|
authPool,
|
|
70
58
|
connectTimeoutMs = 30_000,
|
|
71
|
-
retry = { network: { attempts: 2, delayMs: 300 } },
|
|
72
59
|
} = {}) {
|
|
73
60
|
const resolvedBase = String(baseUrl).trim().replace(/\/+$/, "");
|
|
74
|
-
// chat 固定落在 /api/v1/chat/completions:base 已含 /api/v1 则只拼 /chat/completions,
|
|
75
|
-
// 否则拼 /api/v1/chat/completions(避免双 /api/v1 或 /v1 错路径导致 401/empty response)
|
|
76
61
|
const resolvedChat = chatPath || (String(resolvedBase).includes("/api/v1") ? "/chat/completions" : "/api/v1/chat/completions");
|
|
62
|
+
const transport = createTransport({ fetchImpl, dispatcher, keepAlive: !!dispatcher, timeoutMs: connectTimeoutMs, retry: {} });
|
|
77
63
|
|
|
78
64
|
async function clineFetch(body, sessionId) {
|
|
79
65
|
const token = await authPool.getAccessToken();
|
|
80
66
|
const headers = clineHeaders(sessionId, token);
|
|
81
67
|
const finalUrl = joinUrl(resolvedBase, resolvedChat);
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
const controller = new AbortController();
|
|
85
|
-
const timer = setTimeout(() => controller.abort(new Error(`${id} timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs);
|
|
86
|
-
opts.signal = controller.signal;
|
|
87
|
-
try { return await fetchImpl(finalUrl, opts); } finally { clearTimeout(timer); }
|
|
68
|
+
const isStream = body?.stream === true;
|
|
69
|
+
return transport.request({ url: finalUrl, headers, body, stream: isStream, timeoutMs: connectTimeoutMs });
|
|
88
70
|
}
|
|
89
71
|
|
|
90
|
-
|
|
91
|
-
function isLimitHit(status, bodyText, isStream) {
|
|
72
|
+
function isLimitHit(status, bodyText) {
|
|
92
73
|
if (status === 429) return true;
|
|
93
74
|
if (status >= 500 && String(bodyText).includes("empty response content")) return true;
|
|
94
|
-
if (status === 200 && !isStream && String(bodyText).includes("empty response content")) return true;
|
|
95
75
|
return false;
|
|
96
76
|
}
|
|
97
77
|
|
|
98
|
-
async function clineFetchWithRetry(body, sessionId
|
|
78
|
+
async function clineFetchWithRetry(body, sessionId) {
|
|
99
79
|
const maxRetries = 4;
|
|
100
80
|
let lastResp = null;
|
|
101
81
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
102
82
|
const resp = await authPool.enqueue(() => clineFetch(body, sessionId));
|
|
103
83
|
lastResp = resp;
|
|
104
84
|
let bodyText = "";
|
|
105
|
-
try { bodyText = await resp.
|
|
106
|
-
const hit = isLimitHit(resp.status, bodyText
|
|
85
|
+
if (resp.status !== 200) { try { bodyText = await resp.text(); } catch {} }
|
|
86
|
+
const hit = isLimitHit(resp.status, bodyText);
|
|
107
87
|
if (hit) {
|
|
108
88
|
const { parseCooldown } = await import("./auth.js");
|
|
109
89
|
const cooldownMs = parseCooldown(bodyText, resp.status);
|
|
@@ -126,7 +106,7 @@ export function createChatService({
|
|
|
126
106
|
let lastData = null;
|
|
127
107
|
let resp = firstResp;
|
|
128
108
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
129
|
-
if (!resp) resp = await clineFetchWithRetry(body, sessionId
|
|
109
|
+
if (!resp) resp = await clineFetchWithRetry(body, sessionId);
|
|
130
110
|
if (!resp.ok) {
|
|
131
111
|
const errText = await resp.text().catch(() => "");
|
|
132
112
|
const hdrs = new Headers(resp.headers);
|
|
@@ -165,7 +145,6 @@ export function createChatService({
|
|
|
165
145
|
const sessionId = genSessionId();
|
|
166
146
|
const isStream = body?.stream === true;
|
|
167
147
|
const upstreamModel = String(model).split("/").pop().includes(":") ? model : model;
|
|
168
|
-
// 构造上游 body:保留外部 model 名,Cline 上游用同名
|
|
169
148
|
const upstreamBody = {
|
|
170
149
|
model: upstreamModel,
|
|
171
150
|
max_tokens: body?.max_tokens || body?.max_completion_tokens || 4096,
|
|
@@ -178,16 +157,11 @@ export function createChatService({
|
|
|
178
157
|
for (const k of ["temperature", "top_p", "tools", "tool_choice", "stop", "presence_penalty", "frequency_penalty", "response_format", "user", "n", "seed"]) {
|
|
179
158
|
if (body[k] !== undefined) upstreamBody[k] = body[k];
|
|
180
159
|
}
|
|
181
|
-
|
|
182
|
-
// 网络层重试
|
|
183
160
|
for (let netAttempt = 0; netAttempt < 3; netAttempt++) {
|
|
184
161
|
try {
|
|
185
|
-
const resp = await clineFetchWithRetry(upstreamBody, sessionId
|
|
162
|
+
const resp = await clineFetchWithRetry(upstreamBody, sessionId);
|
|
186
163
|
if (!resp) throw new Error("empty response");
|
|
187
|
-
if (!resp.ok)
|
|
188
|
-
// 直通错误(403/400 等)
|
|
189
|
-
return resp;
|
|
190
|
-
}
|
|
164
|
+
if (!resp.ok) return resp;
|
|
191
165
|
if (isStream) return resp;
|
|
192
166
|
if (forceStream) {
|
|
193
167
|
const ret = await nonStreamWithContentCheck(upstreamBody, sessionId, resp);
|
|
@@ -196,7 +170,6 @@ export function createChatService({
|
|
|
196
170
|
const hdrs = new Headers({ "Content-Type": "application/json" });
|
|
197
171
|
return new Response(JSON.stringify(ret.data), { status: 200, headers: hdrs });
|
|
198
172
|
}
|
|
199
|
-
// 普通非流式(非 deepseek)
|
|
200
173
|
const raw = await resp.json().catch(() => null);
|
|
201
174
|
if (!raw) return resp;
|
|
202
175
|
const normalized = unwrapData(raw);
|