mslxdff 0.1.131 → 0.1.132

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.131",
3
+ "version": "0.1.132",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@ export async function handleProviderAdd(id, sub, rest) {
16
16
  console.error(`provider "${gid}" is built-in — use: mslxdff -provider ${gid} add <key> or mslxdff -provider ${gid} set-url <url>`);
17
17
  process.exit(1);
18
18
  }
19
- const { saveProviderConfig, loadProviderConfig, loadProviderShareKeys } = await import("../../../state.js");
19
+ const { saveProviderConfig, loadProviderConfig } = await import("../../../state.js");
20
20
  const { normalizeProviderId } = await import("../../../providers/model-id.js");
21
21
  const nid = normalizeProviderId(gid);
22
22
  if (!nid) { console.error(`invalid provider id: ${gid}`); process.exit(1); }
@@ -98,7 +98,7 @@ export async function handleProviderAdd(id, sub, rest) {
98
98
  console.log(` keys: ${keys.length} (${keys.map((k) => `${k.slice(0, 4)}…${k.slice(-4)}`).join(", ")})`);
99
99
  if (allowedModels.length) console.log(` allowedModels: ${allowedModels.length} (${allowedModels.join(", ")})`);
100
100
  else console.log(` allowedModels: (none — BLOCKED, otherwise unusable) → mslxdff -provider ${nid} allowlist set <model...> OR mslxdff -provider ${nid} allowAny on (allow all)`);
101
- console.log(` share: ${loadProviderShareKeys(nid) ? "ON" : "off"} (mslxdff -provider ${nid} share on|off)`);
101
+ console.log(` share: 借出(默认,key 随转发自动附带,ADR-0019)`);
102
102
  console.log(` allowAny: OFF (secure, empty allowlist = 403 block before upstream) — enable via: mslxdff -provider ${nid} allowAny on`);
103
103
  console.log(` use as: ${nid}/<model-id> — restart daemon to activate`);
104
104
  if (allowedModels.length) console.log(` opencode 中使用: mslxdff -setto opencode ${nid}/${allowedModels[0]} (同步进 opencode.json,会一并设为默认模型)`);
@@ -5,7 +5,7 @@ export async function handleProviderConfig(id, sub, rest) {
5
5
  console.error("opencode 是内置供应商,不能删除");
6
6
  process.exit(1);
7
7
  }
8
- const { loadProviderConfigs, saveProviderConfig, saveProviderShareKeys } = await import("../../../state.js");
8
+ const { loadProviderConfigs, saveProviderConfig } = await import("../../../state.js");
9
9
  const { isPidAlive, readPid } = await import("../../../daemon.js");
10
10
  const configs = loadProviderConfigs();
11
11
  if (!configs[id] && !configs[nid]) {
@@ -33,16 +33,9 @@ export async function handleProviderConfig(id, sub, rest) {
33
33
  writeStateImmediate(file, { providerKeys: nk });
34
34
  changed = true;
35
35
  }
36
- if (raw.providerShareKeys && raw.providerShareKeys[id] !== undefined) {
37
- const ns = { ...raw.providerShareKeys };
38
- delete ns[id];
39
- writeStateImmediate(file, { providerShareKeys: ns });
40
- changed = true;
41
- }
42
36
  // 清理模型错误/延迟中该供应商前缀的条目(可选,不阻塞)
43
37
  void changed;
44
38
  } catch {}
45
- try { saveProviderShareKeys(id, false); } catch {}
46
39
  console.log(`已删除供应商: ${id} — 配置已清空`);
47
40
  // 需要重启才生效,自动重启
48
41
  const pid = readPid();
@@ -122,21 +115,5 @@ export async function handleProviderConfig(id, sub, rest) {
122
115
  console.log(`set ${id} baseUrl: ${String(url).trim().replace(/\/+$/, "")} — restart daemon to activate`);
123
116
  process.exit(0);
124
117
  }
125
- if (sub === "share") {
126
- const { loadProviderShareKeys, saveProviderShareKeys } = await import("../../../state.js");
127
- const on = rest[1];
128
- if (!on) {
129
- console.log(`share keys to peers: ${loadProviderShareKeys(id) ? "ON" : "off"}`);
130
- process.exit(0);
131
- }
132
- if (!["on", "off", "1", "0", "true", "false"].includes(String(on).toLowerCase())) {
133
- console.error("usage: mslxdff -provider openrouter share on|off");
134
- process.exit(1);
135
- }
136
- const state = ["on", "1", "true"].includes(String(on).toLowerCase());
137
- saveProviderShareKeys(id, state);
138
- console.log(`share keys to peers: ${state ? "ON" : "off"} — restart daemon to activate`);
139
- process.exit(0);
140
- }
141
118
  return false;
142
119
  }
@@ -25,13 +25,12 @@ export async function handleProvider(args) {
25
25
  const sub = args[idx + 2];
26
26
  const rest = args.slice(idx + 2);
27
27
  if (!id) {
28
- console.error("usage: mslxdff -provider <id> [key...|add|remove|list|models|clear|share|set-url|allowlist|allowAny]");
28
+ console.error("usage: mslxdff -provider <id> [key...|add|remove|list|models|clear|set-url|allowlist|allowAny]");
29
29
  console.error(" e.g. mslxdff -provider openrouter sk-1 sk-2 sk-3 set multiple keys (replaces all)");
30
30
  console.error(" mslxdff -provider openrouter add sk-4 append one key");
31
31
  console.error(" mslxdff -provider openrouter remove sk-1 remove a key by value");
32
32
  console.error(" mslxdff -provider openrouter list list all keys (masked)");
33
33
  console.error(" mslxdff -provider openrouter models [--json] list provider models (allowlist filtered)");
34
- console.error(" mslxdff -provider openrouter share on|off share keys with peers on outgoing forward (ADR-0008)");
35
34
  console.error(" mslxdff -provider openrouter set-url https://api.example.com/v1");
36
35
  console.error(" mslxdff -provider openrouter set-models-path /v1/models");
37
36
  console.error(" mslxdff -provider openrouter set-chat-path /v1/chat/completions");
@@ -1,6 +1,6 @@
1
1
  export async function handleProviderKeys(id, sub, rest, args) {
2
2
  if (sub === "list" || sub === "status") {
3
- const { loadProviderKeys, loadProviderConfig, loadProviderShareKeys, loadProviderAllowedModels, loadProviderAllowAnyModels } = await import("../../../state.js");
3
+ const { loadProviderKeys, loadProviderConfig, loadProviderAllowedModels, loadProviderAllowAnyModels } = await import("../../../state.js");
4
4
  const keys = loadProviderKeys(id);
5
5
  const cfg = loadProviderConfig(id);
6
6
  const allowed = loadProviderAllowedModels(id);
@@ -15,7 +15,7 @@ export async function handleProviderKeys(id, sub, rest, args) {
15
15
  } else {
16
16
  console.log(` keys: (no keys configured)`);
17
17
  }
18
- console.log(` share keys to peers: ${loadProviderShareKeys(id) ? "ON" : "off"} (mslxdff -provider ${id} share on|off)`);
18
+ console.log(` share keys to peers: 借出(默认,key 随转发自动附带,ADR-0019)`);
19
19
  console.log(` allowAnyModels: ${allowAny ? "ON (empty allowlist = allow all)" : "OFF (empty allowlist = BLOCK ALL)"} (mslxdff -provider ${id} allowAny on|off)`);
20
20
  if (allowed.length) console.log(` allowedModels: ${allowed.length} (${allowed.join(", ")}) — only these can be used`);
21
21
  else console.log(` allowedModels: (none — ${allowAny ? "allow all" : "BLOCK ALL"}) (mslxdff -provider ${id} allowlist set <model...> or allowAny on)`);
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, existsSync } from "node:fs";
2
- import { loadProviderKeys, loadProviderAuths, loadProviderConfigs, loadProviderAllowedModels, loadProviderAllowAnyModels, loadProviderShareKeys, loadProviderBaseUrl } from "../state.js";
2
+ import { loadProviderKeys, loadProviderAuths, loadProviderConfigs, loadProviderAllowedModels, loadProviderAllowAnyModels, loadProviderBaseUrl } from "../state.js";
3
3
 
4
4
  /**
5
5
  * 聚合 genericIds:来自 providerConfigs + providerKeys + env MSLXDFF_*_KEY
@@ -37,12 +37,10 @@ export function buildProviderRows({ stateFile, env = process.env } = {}) {
37
37
  const cfg = configs[gid];
38
38
  let keys = [];
39
39
  let baseUrl = "";
40
- let share = false;
41
40
  let allowed = [];
42
41
  let allowAny = false;
43
42
  try { keys = loadProviderKeys(gid, stateFile ? { file: stateFile } : undefined); } catch {}
44
43
  try { baseUrl = loadProviderBaseUrl(gid, stateFile ? { file: stateFile } : undefined) || cfg?.baseUrl || (gid === "openrouter" ? "https://openrouter.ai/api/v1" : gid === "workbuddy" ? "https://copilot.tencent.com" : ""); } catch { baseUrl = cfg?.baseUrl || ""; }
45
- try { share = loadProviderShareKeys(gid, stateFile ? { file: stateFile } : undefined); } catch {}
46
44
  try { allowed = loadProviderAllowedModels(gid, stateFile ? { file: stateFile } : undefined); } catch {}
47
45
  try { allowAny = loadProviderAllowAnyModels(gid, stateFile ? { file: stateFile } : undefined); } catch {}
48
46
  // openrouter 特殊:opencode 例外默认 allowAny true,其余默认 false
@@ -61,7 +59,7 @@ export function buildProviderRows({ stateFile, env = process.env } = {}) {
61
59
  } else if (gid === "workbuddy" && auths.length && auths.length !== keys.length) {
62
60
  note = `${auths.length} auth(s) / ${keys.length} key(s) — 数量不一致请重跑 workbuddy-token-auto.js`;
63
61
  }
64
- rows.push({ id: gid, enabled, baseUrl: baseUrl || "(none)", keys, allowed, allowAny, share, note, authCount: auths.length });
62
+ rows.push({ id: gid, enabled, baseUrl: baseUrl || "(none)", keys, allowed, allowAny, note, authCount: auths.length });
65
63
  }
66
64
  return rows;
67
65
  }
@@ -94,8 +92,8 @@ export function formatProviderRow(p) {
94
92
  const more = p.allowed.length > 2 ? ` …+${p.allowed.length - 2}` : "";
95
93
  allowInfo = `allow ${p.allowed.length} → ${head}${more}`;
96
94
  }
97
- // share
98
- const shareInfo = p.id === "opencode" ? "无法共享" : `共享 ${p.share ? "开" : "关"}`;
95
+ // share(ADR-0019:默认借出,无开关)
96
+ const shareInfo = p.id === "opencode" ? "无法共享" : "共享 借出";
99
97
  // base
100
98
  const base = p.baseUrl && p.baseUrl !== "(none)" ? p.baseUrl : "(none)";
101
99
  const baseLine = ` └ ${base}${p.note ? ` · ${p.note}` : ""}`;
package/src/cli/status.js CHANGED
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import { readPid, readPidVersion, isPidAlive, pidFile } from "../daemon.js";
5
5
  import { defaultStateFile, getPort, loadGroupsJoined, loadModelErrors, loadModelStats, loadModelPicks } from "../state.js";
6
6
  import { resolvePort } from "../server.js";
7
- import { loadProviderKeys, loadProviderAuths, loadProviderConfigs, loadProviderAllowedModels, loadProviderShareKeys, loadProviderBaseUrl } from "../state.js";
7
+ import { loadProviderKeys, loadProviderAuths, loadProviderConfigs, loadProviderAllowedModels, loadProviderBaseUrl } from "../state.js";
8
8
  import { loadToken } from "../state.js";
9
9
  import { logDir } from "../logs.js";
10
10
  import { createGroupsService } from "../groups.js";
@@ -1,29 +1,25 @@
1
- // ADR-0008:供应商 key 瞬时共享 —— 转发时附带、组员一次性借用,用完即弃。
1
+ // ADR-0019:借道 = 用你的 key —— 转发时自动借出,无开关、无白名单(组内互信是前提)。
2
2
  // header: `x-mslxdff-share-keys: <providerId>=<key1>,<key2>;<providerId2>=<key3>`
3
3
  import { splitModelId, DEFAULT_PROVIDER } from "./model-id.js";
4
- import { loadProviderKeys, loadProviderShareKeys } from "../state.js";
4
+ import { classifyProvider } from "./classify.js";
5
+ import { loadProviderKeys, listProviderIdsWithKeys } from "../state.js";
5
6
 
6
7
  export const SHARE_KEYS_HEADER = "x-mslxdff-share-keys";
7
8
 
8
- // 本节点应 cast key 到出站转发的供应商 id 集合(开启 share 且配了 key 的)。
9
- // share 只在"有 key 且开关为 true"时发生;否则不附带,避免空头泄露配置意图。
10
- // 默认供应商(opencode)恒排除:它无 key 可共享(public 即可),限流以 IP 为主,
11
- // 分散 IP 由 peer 转发天然实现,不需要也不应走 key 共享。
9
+ // 硬排除:refresh-token 型凭据(对端刷新会轮换 token,与本机互相踢下线)——正确性问题。
10
+ const NEVER_SHARE_IDS = new Set(["cline", "clinebot"]);
11
+
12
+ // 本节点应 cast key 到出站转发的供应商 id 集合 = 所有「本机有 key」的供应商,
13
+ // 减去:opencode(无 key 且恒排除)、local-only(workbuddy,本就不走组员)、刷新型凭据。
12
14
  export function shareableProviderIds({ file } = {}) {
13
- const ids = new Set();
14
- const raw = process.env.MSLXDFF_SHARE_PROVIDERS || ""; // 高级:显式白名单,逗号分隔
15
- const explicit = raw.split(",").map((s) => s.trim()).filter(Boolean);
16
- if (explicit.length) {
17
- for (const id of explicit) {
18
- if (id === DEFAULT_PROVIDER) continue; // opencode 永远不可 share
19
- ids.add(id);
20
- }
21
- return [...ids];
22
- }
23
- for (const id of ["openrouter"]) {
24
- if (id !== DEFAULT_PROVIDER && loadProviderKeys(id, { file }).length && loadProviderShareKeys(id, { file })) ids.add(id);
15
+ const ids = [];
16
+ for (const id of listProviderIdsWithKeys({ file })) {
17
+ if (id === DEFAULT_PROVIDER) continue;
18
+ if (NEVER_SHARE_IDS.has(id)) continue;
19
+ try { if (classifyProvider(id) === "local-only") continue; } catch {}
20
+ ids.push(id);
25
21
  }
26
- return [...ids];
22
+ return ids;
27
23
  }
28
24
 
29
25
  // 组装 share header 值(只含 shareable 且当前请求命中前缀的供应商)。
@@ -1,5 +1,5 @@
1
1
  import { createKeyRing } from "../keyring.js";
2
- import { loadProviderKeys, loadProviderAuths, loadProviderBaseUrl, loadProviderShareKeys, WORKBUDDY_DEFAULT_BASE_URL, loadProviderModelsPath, loadProviderChatPath } from "../../state.js";
2
+ import { loadProviderKeys, loadProviderAuths, loadProviderBaseUrl, WORKBUDDY_DEFAULT_BASE_URL, loadProviderModelsPath, loadProviderChatPath } from "../../state.js";
3
3
  import { existsSync, readdirSync, readFileSync } from "node:fs";
4
4
  import { compatFetch } from "../../compat.js";
5
5
  import { join } from "node:path";
@@ -176,15 +176,7 @@ export function createWorkbuddyProvider({
176
176
  }
177
177
 
178
178
  async function chatWithKeys(body, keysOverride) {
179
- const shareOn = (() => {
180
- try { return loadProviderShareKeys(id, file ? { file } : {}); } catch { return false; }
181
- })();
182
- // allow injected balanceCache/logger to bypass share check for isolated tests:
183
- // if keysOverride provided and logger is injected (test mode), permit share without state flag
184
- const isTestInjection = !!balanceCacheOpt || !!loggerOpt;
185
- if (!shareOn && !isTestInjection) {
186
- return chatSvc.runChat(body, ring);
187
- }
179
+ // 借入的 key(ADR-0019:转发时自动附带)——用隔离的 tmp ring/auth,不改动本机 ring。
188
180
  // Isolated tmp ring and auth without mutating shared arrays
189
181
  const tmpKeys = [...keysOverride].filter((k) => typeof k === "string" && k.trim().length).map((k) => k.trim());
190
182
  const tmpAuth = authList[0] || { uid: "", domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" };
@@ -2,8 +2,7 @@ import { createRelayPipeline } from "./relay-pipeline.js";
2
2
  import { relay, SLOW_TOTAL_MS, STREAM_TIMEOUT_MS, STALL_TIMEOUT_MS, SCORE_STALL_MS } from "../stream.js";
3
3
  import { buildFallbackInfo } from "../fallback.js";
4
4
  import { getViaRoute } from "../../bench/via-routes.js";
5
- import { loadProviderKeys } from "../../state.js";
6
- import { SHARE_KEYS_HEADER } from "../../providers/share-keys.js";
5
+ import { buildShareKeysHeader, SHARE_KEYS_HEADER } from "../../providers/share-keys.js";
7
6
  import { errMsg } from "../helpers.js";
8
7
  import { compatFetch } from "../../compat.js";
9
8
 
@@ -61,12 +60,9 @@ export async function handleViaRoute({
61
60
  evt("via-route-hit", { reqId: handlerCtx.reqId, model, peer: peer.url, peerLabel, routeBest: route.best, at: route.at });
62
61
  // 单路径转发,不并发:直接打 best peer
63
62
  const hops = handlerCtx.hops || 0;
64
- const providerId = String(model).split("/")[0] || "";
65
- let shareHeader = null;
66
- try {
67
- const keys = loadProviderKeys(providerId) || [];
68
- if (keys.length) shareHeader = `${providerId}=${keys.join(",")}`;
69
- } catch {}
63
+ // ADR-0019:借道 = 用 A 的 key —— 转发时自动附带(组内互信,无开关),与 peers.js 共用同一组装点。
64
+ // Note: 见 .agents/notes/implemented/architecture/2026-09-17-share-keys-no-switch.md
65
+ const shareHeader = buildShareKeysHeader(model);
70
66
  const controller = new AbortController();
71
67
  const timer = setTimeout(() => controller.abort(new Error("via-route timeout 30000ms")), 30000);
72
68
  let upRes;
@@ -122,7 +122,7 @@ async function forwardToPeer(peer, body, model, hops, controller = new AbortCont
122
122
  "x-mslxdff-model-lock": model,
123
123
  "Accept": "text/event-stream",
124
124
  };
125
- // ADR-0008:该模型命中的供应商若开启 share → 附带瞬时 key 给组员借用(opencode 恒排除)
125
+ // ADR-0019:命中供应商的 key 随转发自动借出(组内互信,无开关;opencode/workbuddy/cline 硬排除)
126
126
  const shareHeader = buildShareKeysHeader(model);
127
127
  if (shareHeader) headers[SHARE_KEYS_HEADER] = shareHeader;
128
128
  const dispatcher = getPeerDispatcher();
@@ -5,16 +5,14 @@ export {
5
5
  WORKBUDDY_DEFAULT_BASE_URL,
6
6
  providerKeyEnv,
7
7
  providerBaseUrlEnv,
8
- providerShareEnv,
9
8
  loadProviderKeys,
10
9
  loadProviderKey,
10
+ listProviderIdsWithKeys,
11
11
  saveProviderKeys,
12
12
  saveProviderKey,
13
13
  addProviderKey,
14
14
  removeProviderKey,
15
15
  removeProviderKeys,
16
- loadProviderShareKeys,
17
- saveProviderShareKeys,
18
16
  loadProviderConfigs,
19
17
  loadProviderConfig,
20
18
  loadProviderBaseUrl,
@@ -5,7 +5,6 @@
5
5
  export const COLD_WINS = new Set([
6
6
  "providerConfigs",
7
7
  "providerKeys",
8
- "providerShareKeys",
9
8
  "port",
10
9
  "token",
11
10
  "preferredModel",
@@ -56,10 +56,9 @@ export function defaultChatPath(id) {
56
56
 
57
57
  export const providerKeyEnv = (id) => `MSLXDFF_${String(id || "").toUpperCase().replace(/[^A-Z0-9]/g, "_")}_KEY`;
58
58
  export const providerBaseUrlEnv = (id) => `MSLXDFF_${String(id || "").toUpperCase().replace(/[^A-Z0-9]/g, "_")}_BASE_URL`;
59
- export const providerShareEnv = (id) => `MSLXDFF_${String(id || "").toUpperCase().replace(/[^A-Z0-9]/g, "_")}_SHARE_KEYS`;
60
59
 
61
60
  /**
62
- * validate 7 字段:baseUrl, keys, auths, allowedModels, modelsPath, chatPath, shareKeys?(share 单独)
61
+ * validate 7 字段:baseUrl, keys, auths, allowedModels, modelsPath, chatPath(share 已删,key 随转发自动借出)
63
62
  * 返回 {ok, clean, errors}
64
63
  */
65
64
  export function validateProviderConfig(raw = {}, providerId = "") {
@@ -9,7 +9,6 @@ export {
9
9
  defaultChatPath,
10
10
  providerKeyEnv,
11
11
  providerBaseUrlEnv,
12
- providerShareEnv,
13
12
  } from "../provider-config.js";
14
13
  import {
15
14
  normalizeBaseUrl as _normalizeBaseUrl,
@@ -20,7 +19,6 @@ import {
20
19
  defaultChatPath as _defaultChatPath,
21
20
  providerKeyEnv as _providerKeyEnv,
22
21
  providerBaseUrlEnv as _providerBaseUrlEnv,
23
- providerShareEnv as _providerShareEnv,
24
22
  WORKBUDDY_DEFAULT_BASE_URL as _WORKBUDDY,
25
23
  } from "../provider-config.js";
26
24
 
@@ -52,6 +50,16 @@ export function loadProviderKeys(id, { file = defaultStateFile() } = {}) {
52
50
  return [];
53
51
  }
54
52
  export function loadProviderKey(id, opts = {}) { return loadProviderKeys(id, opts)[0] || ""; }
53
+ // 本节点所有「有 key」的供应商 id(providerConfigs ∪ providerKeys,含 env 覆盖)——
54
+ // 转发时默认借出的候选集合由它枚举;见 src/providers/share-keys.js
55
+ export function listProviderIdsWithKeys({ file = defaultStateFile() } = {}) {
56
+ const st = readState(file);
57
+ const ids = new Set();
58
+ for (const src of [st.providerConfigs, st.providerKeys]) {
59
+ if (src && typeof src === "object" && !Array.isArray(src)) for (const id of Object.keys(src)) ids.add(id);
60
+ }
61
+ return [...ids].filter((id) => loadProviderKeys(id, { file }).length > 0);
62
+ }
55
63
  export function saveProviderKeys(id, list, { file = defaultStateFile() } = {}) {
56
64
  const keys = { ...(readState(file).providerKeys || {}) };
57
65
  const clean = [...new Set((Array.isArray(list) ? list : []).map((k) => String(k || "").trim()).filter(Boolean))];
@@ -72,18 +80,6 @@ export function removeProviderKeys(id, targets = [], opts = {}) {
72
80
  const list = loadProviderKeys(id, opts).filter((k) => !set.has(k));
73
81
  return saveProviderKeys(id, list, opts);
74
82
  }
75
- export function loadProviderShareKeys(id, { file = defaultStateFile() } = {}) {
76
- const env = process.env[_providerShareEnv(id)] || "";
77
- if (env) return ["1", "true", "on", "yes"].includes(String(env).trim().toLowerCase());
78
- const map = readState(file).providerShareKeys;
79
- return !!(map && typeof map === "object" && map[id]);
80
- }
81
- export function saveProviderShareKeys(id, on, { file = defaultStateFile() } = {}) {
82
- const map = { ...(readState(file).providerShareKeys || {}) };
83
- if (on) map[id] = true; else delete map[id];
84
- writeStateImmediate(file, { providerShareKeys: map });
85
- return !!on;
86
- }
87
83
  export function loadProviderConfigs({ file = defaultStateFile() } = {}) {
88
84
  const v = readState(file).providerConfigs;
89
85
  if (v && typeof v === "object" && !Array.isArray(v)) return v;