@dsh-edge/dsh-edge-agent 0.0.0-stage → 0.1.2
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/CHANGELOG.md +158 -0
- package/LICENSE +21 -0
- package/README.md +181 -2
- package/config/edge.example.json +179 -0
- package/config/privacy-policy.json +202 -0
- package/cordis.desktop-safe.patch.yml +129 -0
- package/cordis.patch.yml +382 -0
- package/cordis.yml +374 -0
- package/lib/attestation.d.ts +2 -0
- package/lib/attestation.d.ts.map +1 -0
- package/lib/attestation.js +2 -0
- package/lib/audit-log.d.ts +2 -0
- package/lib/audit-log.d.ts.map +1 -0
- package/lib/audit-log.js +2 -0
- package/lib/client.js +87 -0
- package/lib/cloud-llm-adapter.d.ts +2 -0
- package/lib/cloud-llm-adapter.d.ts.map +1 -0
- package/lib/cloud-llm-adapter.js +2 -0
- package/lib/dsh-adapter/attestation.d.ts +42 -0
- package/lib/dsh-adapter/attestation.d.ts.map +1 -0
- package/lib/dsh-adapter/attestation.js +40 -0
- package/lib/dsh-adapter/audit-log.d.ts +139 -0
- package/lib/dsh-adapter/audit-log.d.ts.map +1 -0
- package/lib/dsh-adapter/audit-log.js +376 -0
- package/lib/dsh-adapter/cloud-egress.d.ts +181 -0
- package/lib/dsh-adapter/cloud-egress.d.ts.map +1 -0
- package/lib/dsh-adapter/cloud-egress.js +257 -0
- package/lib/dsh-adapter/cloud-llm-adapter.d.ts +416 -0
- package/lib/dsh-adapter/cloud-llm-adapter.d.ts.map +1 -0
- package/lib/dsh-adapter/cloud-llm-adapter.js +1472 -0
- package/lib/dsh-adapter/contract.d.ts +80 -0
- package/lib/dsh-adapter/contract.d.ts.map +1 -0
- package/lib/dsh-adapter/contract.js +194 -0
- package/lib/dsh-adapter/desktop-cookie-janitor.d.ts +214 -0
- package/lib/dsh-adapter/desktop-cookie-janitor.d.ts.map +1 -0
- package/lib/dsh-adapter/desktop-cookie-janitor.js +409 -0
- package/lib/dsh-adapter/dsh-edge-agent.d.ts +48 -0
- package/lib/dsh-adapter/dsh-edge-agent.d.ts.map +1 -0
- package/lib/dsh-adapter/dsh-edge-agent.js +64 -0
- package/lib/dsh-adapter/edge-config.d.ts +484 -0
- package/lib/dsh-adapter/edge-config.d.ts.map +1 -0
- package/lib/dsh-adapter/edge-config.js +898 -0
- package/lib/dsh-adapter/edge-context-view.d.ts +184 -0
- package/lib/dsh-adapter/edge-context-view.d.ts.map +1 -0
- package/lib/dsh-adapter/edge-context-view.js +483 -0
- package/lib/dsh-adapter/effective-model.d.ts +85 -0
- package/lib/dsh-adapter/effective-model.d.ts.map +1 -0
- package/lib/dsh-adapter/effective-model.js +171 -0
- package/lib/dsh-adapter/etscli-shell-guard.d.ts +244 -0
- package/lib/dsh-adapter/etscli-shell-guard.d.ts.map +1 -0
- package/lib/dsh-adapter/etscli-shell-guard.js +420 -0
- package/lib/dsh-adapter/etscli-tools.d.ts +112 -0
- package/lib/dsh-adapter/etscli-tools.d.ts.map +1 -0
- package/lib/dsh-adapter/etscli-tools.js +393 -0
- package/lib/dsh-adapter/file-logger.d.ts +294 -0
- package/lib/dsh-adapter/file-logger.d.ts.map +1 -0
- package/lib/dsh-adapter/file-logger.js +564 -0
- package/lib/dsh-adapter/function-call-external.d.ts +357 -0
- package/lib/dsh-adapter/function-call-external.d.ts.map +1 -0
- package/lib/dsh-adapter/function-call-external.js +1005 -0
- package/lib/dsh-adapter/host-egress-guard.d.ts +161 -0
- package/lib/dsh-adapter/host-egress-guard.d.ts.map +1 -0
- package/lib/dsh-adapter/host-egress-guard.js +213 -0
- package/lib/dsh-adapter/host-llm-trace.d.ts +198 -0
- package/lib/dsh-adapter/host-llm-trace.d.ts.map +1 -0
- package/lib/dsh-adapter/host-llm-trace.js +344 -0
- package/lib/dsh-adapter/http-api-routes.d.ts +61 -0
- package/lib/dsh-adapter/http-api-routes.d.ts.map +1 -0
- package/lib/dsh-adapter/http-api-routes.js +79 -0
- package/lib/dsh-adapter/http-api-sessions.d.ts +105 -0
- package/lib/dsh-adapter/http-api-sessions.d.ts.map +1 -0
- package/lib/dsh-adapter/http-api-sessions.js +173 -0
- package/lib/dsh-adapter/http-api.d.ts +221 -0
- package/lib/dsh-adapter/http-api.d.ts.map +1 -0
- package/lib/dsh-adapter/http-api.js +1811 -0
- package/lib/dsh-adapter/index.d.ts +62 -0
- package/lib/dsh-adapter/index.d.ts.map +1 -0
- package/lib/dsh-adapter/index.js +97 -0
- package/lib/dsh-adapter/input-guard-messages.d.ts +56 -0
- package/lib/dsh-adapter/input-guard-messages.d.ts.map +1 -0
- package/lib/dsh-adapter/input-guard-messages.js +136 -0
- package/lib/dsh-adapter/input-guard.d.ts +508 -0
- package/lib/dsh-adapter/input-guard.d.ts.map +1 -0
- package/lib/dsh-adapter/input-guard.js +2174 -0
- package/lib/dsh-adapter/intent-prompts.d.ts +77 -0
- package/lib/dsh-adapter/intent-prompts.d.ts.map +1 -0
- package/lib/dsh-adapter/intent-prompts.js +97 -0
- package/lib/dsh-adapter/intent-router.d.ts +710 -0
- package/lib/dsh-adapter/intent-router.d.ts.map +1 -0
- package/lib/dsh-adapter/intent-router.js +2802 -0
- package/lib/dsh-adapter/intent-signals.d.ts +208 -0
- package/lib/dsh-adapter/intent-signals.d.ts.map +1 -0
- package/lib/dsh-adapter/intent-signals.js +391 -0
- package/lib/dsh-adapter/invariant-install.d.ts +16 -0
- package/lib/dsh-adapter/invariant-install.d.ts.map +1 -0
- package/lib/dsh-adapter/invariant-install.js +15 -0
- package/lib/dsh-adapter/invariant.d.ts +16 -0
- package/lib/dsh-adapter/invariant.d.ts.map +1 -0
- package/lib/dsh-adapter/invariant.js +22 -0
- package/lib/dsh-adapter/llm-adapters.d.ts +19 -0
- package/lib/dsh-adapter/llm-adapters.d.ts.map +1 -0
- package/lib/dsh-adapter/llm-adapters.js +16 -0
- package/lib/dsh-adapter/llm-internal-caller.d.ts +92 -0
- package/lib/dsh-adapter/llm-internal-caller.d.ts.map +1 -0
- package/lib/dsh-adapter/llm-internal-caller.js +100 -0
- package/lib/dsh-adapter/llm-max-turns.d.ts +55 -0
- package/lib/dsh-adapter/llm-max-turns.d.ts.map +1 -0
- package/lib/dsh-adapter/llm-max-turns.js +127 -0
- package/lib/dsh-adapter/llm-trace-store.d.ts +131 -0
- package/lib/dsh-adapter/llm-trace-store.d.ts.map +1 -0
- package/lib/dsh-adapter/llm-trace-store.js +205 -0
- package/lib/dsh-adapter/llm-trace.d.ts +450 -0
- package/lib/dsh-adapter/llm-trace.d.ts.map +1 -0
- package/lib/dsh-adapter/llm-trace.js +463 -0
- package/lib/dsh-adapter/local-llm-adapter.d.ts +141 -0
- package/lib/dsh-adapter/local-llm-adapter.d.ts.map +1 -0
- package/lib/dsh-adapter/local-llm-adapter.js +787 -0
- package/lib/dsh-adapter/local-storage.d.ts +96 -0
- package/lib/dsh-adapter/local-storage.d.ts.map +1 -0
- package/lib/dsh-adapter/local-storage.js +198 -0
- package/lib/dsh-adapter/model-router.d.ts +79 -0
- package/lib/dsh-adapter/model-router.d.ts.map +1 -0
- package/lib/dsh-adapter/model-router.js +107 -0
- package/lib/dsh-adapter/ohttp-gateway.d.ts +49 -0
- package/lib/dsh-adapter/ohttp-gateway.d.ts.map +1 -0
- package/lib/dsh-adapter/ohttp-gateway.js +32 -0
- package/lib/dsh-adapter/openai-sse.d.ts +85 -0
- package/lib/dsh-adapter/openai-sse.d.ts.map +1 -0
- package/lib/dsh-adapter/openai-sse.js +462 -0
- package/lib/dsh-adapter/pii-utils.d.ts +96 -0
- package/lib/dsh-adapter/pii-utils.d.ts.map +1 -0
- package/lib/dsh-adapter/pii-utils.js +324 -0
- package/lib/dsh-adapter/plugin-host.d.ts +38 -0
- package/lib/dsh-adapter/plugin-host.d.ts.map +1 -0
- package/lib/dsh-adapter/plugin-host.js +38 -0
- package/lib/dsh-adapter/plugin.d.ts +46 -0
- package/lib/dsh-adapter/plugin.d.ts.map +1 -0
- package/lib/dsh-adapter/plugin.js +100 -0
- package/lib/dsh-adapter/privacy-policy.d.ts +237 -0
- package/lib/dsh-adapter/privacy-policy.d.ts.map +1 -0
- package/lib/dsh-adapter/privacy-policy.js +391 -0
- package/lib/dsh-adapter/prompt-guard.d.ts +152 -0
- package/lib/dsh-adapter/prompt-guard.d.ts.map +1 -0
- package/lib/dsh-adapter/prompt-guard.js +872 -0
- package/lib/dsh-adapter/realm-skill-bridge.d.ts +365 -0
- package/lib/dsh-adapter/realm-skill-bridge.d.ts.map +1 -0
- package/lib/dsh-adapter/realm-skill-bridge.js +1180 -0
- package/lib/dsh-adapter/sanitizer-pipeline.d.ts +140 -0
- package/lib/dsh-adapter/sanitizer-pipeline.d.ts.map +1 -0
- package/lib/dsh-adapter/sanitizer-pipeline.js +431 -0
- package/lib/dsh-adapter/secure-bridge.d.ts +47 -0
- package/lib/dsh-adapter/secure-bridge.d.ts.map +1 -0
- package/lib/dsh-adapter/secure-bridge.js +35 -0
- package/lib/dsh-adapter/session-attribution.d.ts +70 -0
- package/lib/dsh-adapter/session-attribution.d.ts.map +1 -0
- package/lib/dsh-adapter/session-attribution.js +76 -0
- package/lib/dsh-adapter/sessions.d.ts +86 -0
- package/lib/dsh-adapter/sessions.d.ts.map +1 -0
- package/lib/dsh-adapter/sessions.js +179 -0
- package/lib/dsh-adapter/tcm.d.ts +55 -0
- package/lib/dsh-adapter/tcm.d.ts.map +1 -0
- package/lib/dsh-adapter/tcm.js +94 -0
- package/lib/dsh-adapter/td-bus.d.ts +87 -0
- package/lib/dsh-adapter/td-bus.d.ts.map +1 -0
- package/lib/dsh-adapter/td-bus.js +196 -0
- package/lib/dsh-adapter/tool-runner.d.ts +185 -0
- package/lib/dsh-adapter/tool-runner.d.ts.map +1 -0
- package/lib/dsh-adapter/tool-runner.js +271 -0
- package/lib/dsh-adapter/tools.d.ts +118 -0
- package/lib/dsh-adapter/tools.d.ts.map +1 -0
- package/lib/dsh-adapter/tools.js +292 -0
- package/lib/dsh-adapter/types.d.ts +23 -0
- package/lib/dsh-adapter/types.d.ts.map +1 -0
- package/lib/dsh-adapter/types.js +16 -0
- package/lib/dsh-adapter/voice-input.d.ts +30 -0
- package/lib/dsh-adapter/voice-input.d.ts.map +1 -0
- package/lib/dsh-adapter/voice-input.js +34 -0
- package/lib/dsh-adapter/web-search-tavily-bocha.d.ts +152 -0
- package/lib/dsh-adapter/web-search-tavily-bocha.d.ts.map +1 -0
- package/lib/dsh-adapter/web-search-tavily-bocha.js +289 -0
- package/lib/http-api.d.ts +2 -0
- package/lib/http-api.d.ts.map +1 -0
- package/lib/http-api.js +2 -0
- package/lib/index.d.ts +48 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +49 -0
- package/lib/intent-router.d.ts +2 -0
- package/lib/intent-router.d.ts.map +1 -0
- package/lib/intent-router.js +2 -0
- package/lib/llm-adapters.d.ts +2 -0
- package/lib/llm-adapters.d.ts.map +1 -0
- package/lib/llm-adapters.js +2 -0
- package/lib/local-llm-adapter.d.ts +2 -0
- package/lib/local-llm-adapter.d.ts.map +1 -0
- package/lib/local-llm-adapter.js +2 -0
- package/lib/local-storage.d.ts +2 -0
- package/lib/local-storage.d.ts.map +1 -0
- package/lib/local-storage.js +2 -0
- package/lib/model-router.d.ts +2 -0
- package/lib/model-router.d.ts.map +1 -0
- package/lib/model-router.js +2 -0
- package/lib/ohttp-gateway.d.ts +2 -0
- package/lib/ohttp-gateway.d.ts.map +1 -0
- package/lib/ohttp-gateway.js +2 -0
- package/lib/plugin-host.d.ts +2 -0
- package/lib/plugin-host.d.ts.map +1 -0
- package/lib/plugin-host.js +2 -0
- package/lib/privacy-policy.d.ts +2 -0
- package/lib/privacy-policy.d.ts.map +1 -0
- package/lib/privacy-policy.js +2 -0
- package/lib/prompt-guard.d.ts +2 -0
- package/lib/prompt-guard.d.ts.map +1 -0
- package/lib/prompt-guard.js +2 -0
- package/lib/sanitizer-pipeline.d.ts +2 -0
- package/lib/sanitizer-pipeline.d.ts.map +1 -0
- package/lib/sanitizer-pipeline.js +2 -0
- package/lib/secure-bridge.d.ts +2 -0
- package/lib/secure-bridge.d.ts.map +1 -0
- package/lib/secure-bridge.js +2 -0
- package/lib/sessions.d.ts +2 -0
- package/lib/sessions.d.ts.map +1 -0
- package/lib/sessions.js +2 -0
- package/lib/tcm.d.ts +2 -0
- package/lib/tcm.d.ts.map +1 -0
- package/lib/tcm.js +2 -0
- package/lib/td-bus.d.ts +2 -0
- package/lib/td-bus.d.ts.map +1 -0
- package/lib/td-bus.js +2 -0
- package/lib/tools.d.ts +2 -0
- package/lib/tools.d.ts.map +1 -0
- package/lib/tools.js +2 -0
- package/lib/voice-input.d.ts +2 -0
- package/lib/voice-input.d.ts.map +1 -0
- package/lib/voice-input.js +2 -0
- package/package.json +253 -3
- package/presets/agent-preset-edge.json +50 -0
- package/presets/edge.desktop-safe.patch.yml +503 -0
- package/presets/edge.patch.yml +557 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cloud-egress — 出网请求的「只带云段历史」不变量。
|
|
3
|
+
*
|
|
4
|
+
* ## 主人裁决(2026-10-27)
|
|
5
|
+
*
|
|
6
|
+
* > 发给 cloudllm 的请求,只能携带**之前所有的上 cloudllm 的历史**;
|
|
7
|
+
* > 而 locallm 需要携带**所有的**历史会话信息。
|
|
8
|
+
*
|
|
9
|
+
* 拆开就是两条不对称的规则:
|
|
10
|
+
*
|
|
11
|
+
* | 出口 | 允许携带的历史 |
|
|
12
|
+
* |---|---|
|
|
13
|
+
* | `local-llm` | **全部**历史会话信息(含云段)—— 本地池是 operator 自有设施,带什么都不算出网 |
|
|
14
|
+
* | `cloud-llm` | **只有之前上过 cloud-llm 的历史**;任何 local-llm 段的历史一律不带 |
|
|
15
|
+
*
|
|
16
|
+
* 为什么要不对称:默认路由是本地池,本地轮次积累的会话历史属于**本地资产**。
|
|
17
|
+
* 一旦路由被改写 / 恢复切回云端(`input-guard.route.restored`,实测在 10-24 真实
|
|
18
|
+
* 发生过),整段本地历史会跟着上云 —— 那是**新增**泄露。本地池自己带全部历史
|
|
19
|
+
* 不构成泄露,所以不裁。
|
|
20
|
+
*
|
|
21
|
+
* ## 分段依据:宿主给每条 assistant 消息打了 provider 标记
|
|
22
|
+
*
|
|
23
|
+
* `@deepseek-ai/dsh-llm` 的 `ModelMessageSource` 是 `AssistantProviderMetadata`:
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* interface AssistantProviderMetadata {
|
|
27
|
+
* provider: string // Provider route that produced the message.
|
|
28
|
+
* model: string
|
|
29
|
+
* }
|
|
30
|
+
* interface ModelMessageSource extends AssistantProviderMetadata { kind: 'model' }
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* 所以「这条 assistant 消息是在哪台池上生成的」是**逐条可判**的事实,不需要
|
|
34
|
+
* 靠推断或靠会话边界记账。
|
|
35
|
+
*
|
|
36
|
+
* 用户消息 / 工具结果没有 provider 标记 —— 它们归属于**所在的那一轮**。轮次
|
|
37
|
+
* 起点 = 一条**真实用户输入**消息:
|
|
38
|
+
*
|
|
39
|
+
* - 持久化形态:`role === 'user' && source.kind === 'user'`
|
|
40
|
+
* - 单次请求形态:`role === 'user'` 且**完全没有 source**(宿主的
|
|
41
|
+
* `RequestUserInput`:「User input for one LLM request; it has no durable
|
|
42
|
+
* Session identity or source」)—— 它就是**当前轮**的问题(§7.165)
|
|
43
|
+
*
|
|
44
|
+
* 而下面这些**不是**用户输入,所以按 source 过滤能精确切出轮次:
|
|
45
|
+
*
|
|
46
|
+
* - 宿主的 model-switch 通知也是 user-role,但 `source.kind === 'model-selection'`
|
|
47
|
+
* - dsh-agent-instructions 注入的运行上下文也是 user-role,但 `source.kind` 是
|
|
48
|
+
* 别的 producer kind
|
|
49
|
+
*
|
|
50
|
+
* 它们都**带 source**,所以「没有 source」这条判据不会把它们误认成用户输入。
|
|
51
|
+
*
|
|
52
|
+
* ## 纯函数
|
|
53
|
+
*
|
|
54
|
+
* 本模块不碰 React、不碰 Cordis、不发请求 —— 只做切分与标注,单测直接断言。
|
|
55
|
+
* 真正的出网调用在 `cloud-llm-adapter`(直连与 delegate 两条路径都接)。
|
|
56
|
+
*/
|
|
57
|
+
/** 一条出网候选消息的最小形状(dsh 的 Message 是它的超集)。 */
|
|
58
|
+
export interface EgressMessage {
|
|
59
|
+
role: string;
|
|
60
|
+
content: unknown;
|
|
61
|
+
source?: {
|
|
62
|
+
kind?: string;
|
|
63
|
+
provider?: string;
|
|
64
|
+
model?: string;
|
|
65
|
+
} & Record<string, unknown>;
|
|
66
|
+
[k: string]: unknown;
|
|
67
|
+
}
|
|
68
|
+
/** 一条消息归属的段。`unknown` = 产不出它的 provider(无法证明它上过云)。 */
|
|
69
|
+
export type EgressSegment = 'cloud' | 'local' | 'unknown';
|
|
70
|
+
/** 一段历史的归属判定结果。 */
|
|
71
|
+
export interface EgressPlanOptions {
|
|
72
|
+
/**
|
|
73
|
+
* 本轮(正在出网的这一轮)的归属。本轮还没有 assistant 消息 —— 它正是这次
|
|
74
|
+
* 请求要生成的,所以段的归属由**出网目标**决定,而不是由消息内容决定。
|
|
75
|
+
*/
|
|
76
|
+
currentSegment: EgressSegment;
|
|
77
|
+
/**
|
|
78
|
+
* 本地 provider 名单(含 'local-llm')。判定规则与 `model-router.resolveRoute`
|
|
79
|
+
* 同源:名单内或以 `local-` 开头 → local 段;其余 → cloud 段。
|
|
80
|
+
*/
|
|
81
|
+
localProviders?: readonly string[];
|
|
82
|
+
}
|
|
83
|
+
/** 切分结果。 */
|
|
84
|
+
export interface EgressPlan {
|
|
85
|
+
/** 允许出网的消息(按原顺序)。 */
|
|
86
|
+
kept: EgressMessage[];
|
|
87
|
+
/** 被拦下的消息条数(本地段)。 */
|
|
88
|
+
droppedCount: number;
|
|
89
|
+
/** 逐条标注(与传入数组等长)。`null` = 该消息不属于任何轮次(轮次起点之前)。 */
|
|
90
|
+
segments: Array<EgressSegment | null>;
|
|
91
|
+
/** 轮次边界下标(每一轮的起始消息下标)。 */
|
|
92
|
+
turnStarts: number[];
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* provider → 段归属。规则与 `model-router.resolveRoute` 同源,避免两处口径漂移:
|
|
96
|
+
* 本地名单内、或以 `local-` 开头 → local;其余(`cloud-llm` / 宿主自选模型名
|
|
97
|
+
* 如 `qwen-token-plan-cn`)→ cloud。
|
|
98
|
+
*
|
|
99
|
+
* **provider 缺失 → `unknown`**:产不出它的 provider,就**无法证明**它上过云。
|
|
100
|
+
* 主人裁决(2026-10-27)「不用兼容历史会话」—— 旧会话里没有 provider 标记的
|
|
101
|
+
* 那一段直接按 unknown 丢弃,不为它写兼容分支。保守方向:宁可少带,不可多带。
|
|
102
|
+
*/
|
|
103
|
+
export declare function providerSegment(provider: string | undefined, localProviders?: readonly string[]): EgressSegment;
|
|
104
|
+
/**
|
|
105
|
+
* 是不是一条**真实用户输入**消息(轮次起点)。
|
|
106
|
+
*
|
|
107
|
+
* 两种形态都算:
|
|
108
|
+
*
|
|
109
|
+
* 1. `source.kind === 'user'` —— 持久化的会话消息(宿主把用户输入落进会话日志,
|
|
110
|
+
* source 标记为 `user`)。
|
|
111
|
+
* 2. **完全没有 source** —— 宿主的 `RequestUserInput`:
|
|
112
|
+
*
|
|
113
|
+
* ```ts
|
|
114
|
+
* /** User input for one LLM request; it has no durable Session identity or source. *\/
|
|
115
|
+
* interface RequestUserInput {
|
|
116
|
+
* readonly role: 'user'
|
|
117
|
+
* readonly content: UserMessage['content']
|
|
118
|
+
* readonly id?: never
|
|
119
|
+
* readonly source?: never
|
|
120
|
+
* }
|
|
121
|
+
* ```
|
|
122
|
+
*
|
|
123
|
+
* 它是**只用于这一次请求**的用户输入 —— 也就是**当前轮**的问题。漏判它会
|
|
124
|
+
* 让归属整个颠倒(§7.165):当前轮被并进上一轮,而上一轮若判成云段,
|
|
125
|
+
* 结果是**当前的问题被丢掉**(云端模型收到一个没有问题的请求)、
|
|
126
|
+
* **上一轮本地段的输入反而被带上云**。
|
|
127
|
+
*
|
|
128
|
+
* 宿主的 user-role 注入消息(`runtime-context` / `skill-catalog` /
|
|
129
|
+
* `time-context` / `model-selection`)都**带 source**(kind 不是 `user`)⇒
|
|
130
|
+
* 不受影响。
|
|
131
|
+
*/
|
|
132
|
+
export declare function isUserInputMessage(message: EgressMessage): boolean;
|
|
133
|
+
/**
|
|
134
|
+
* 切分消息流,标出每一段(轮次)的归属。
|
|
135
|
+
*
|
|
136
|
+
* 轮次起点 = 真实用户输入消息。段起点之前的消息(理论上只有 system / 早期注入)
|
|
137
|
+
* 不属于任何轮次 → `null`,保留(它们不是会话历史,是运行时上下文)。
|
|
138
|
+
*
|
|
139
|
+
* ## 每一轮怎么判归属(§7.164 修订)
|
|
140
|
+
*
|
|
141
|
+
* 判据只有一条:**这条内容真的上过云吗?** 上过才带。
|
|
142
|
+
*
|
|
143
|
+
* | 轮次形状 | 归属 |
|
|
144
|
+
* |---|---|
|
|
145
|
+
* | 整轮都是云端产出 | 云段 —— 全带 |
|
|
146
|
+
* | 整轮都是本地/未知池产出 | 本地段 —— 整轮丢(含该轮用户输入) |
|
|
147
|
+
* | **云在前、本地在后**(同一轮内路由翻转)| 云前缀带、从本地产出起全丢 |
|
|
148
|
+
* | 本轮(正在出网的这一轮)| 按出网目标归属;出现本地产出则从它起全丢 |
|
|
149
|
+
*
|
|
150
|
+
* ### 为什么「云在前、本地在后」必须切(§7.164 实测发现的泄漏)
|
|
151
|
+
*
|
|
152
|
+
* 旧口径:非当前轮的归属 = 该轮**第一条** assistant 的 provider ⇒ 一轮里只要
|
|
153
|
+
* 第一条是云,**整轮**都算云段 —— 于是同一轮里本地模型产出的回答、以及它之后
|
|
154
|
+
* 的本地工具输出,会被后续出网请求原样带走。
|
|
155
|
+
*
|
|
156
|
+
* 实测形状(真实会话 `session-bd598cb9-…`,provider 顺序
|
|
157
|
+
* `qwen-token-plan-cn → qwen-token-plan-cn → local-llm → …`)⇒ 旧口径下
|
|
158
|
+
* `kept 5 / dropped 0`,裁剪**根本没触发**(`planHostEgress` 见
|
|
159
|
+
* `droppedCount === 0` 就返回 undefined)。本地模型的回答 + 本地工具输出跟着
|
|
160
|
+
* 上云。
|
|
161
|
+
*
|
|
162
|
+
* 修法:非当前轮也按「出现本地产出即从它起切开」处理 —— 云段前缀(真的上过云的
|
|
163
|
+
* 那部分)留下,本地产出起(含其后的工具结果)全部拦下。
|
|
164
|
+
*
|
|
165
|
+
* 纯本地轮(本地产出在前)仍整轮丢:那一轮的用户输入只到过本地池,没上过云。
|
|
166
|
+
*/
|
|
167
|
+
export declare function planCloudEgress(messages: readonly EgressMessage[], options: EgressPlanOptions): EgressPlan;
|
|
168
|
+
/**
|
|
169
|
+
* 统计消息流里 assistant 消息的 provider 分布。
|
|
170
|
+
*
|
|
171
|
+
* 纯观测(审计用)—— 让人能直接确认「这段历史是本地池产生的还是云端产生的」,
|
|
172
|
+
* 也就是裁剪判据到底成不成立。不读正文,只读 `source.provider`。
|
|
173
|
+
*/
|
|
174
|
+
export declare function assistantProviderHistogram(messages: ReadonlyArray<{
|
|
175
|
+
role?: string;
|
|
176
|
+
source?: {
|
|
177
|
+
kind?: string;
|
|
178
|
+
provider?: string;
|
|
179
|
+
};
|
|
180
|
+
}>): Record<string, number>;
|
|
181
|
+
//# sourceMappingURL=cloud-egress.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cloud-egress.d.ts","sourceRoot":"","sources":["../../src/dsh-adapter/cloud-egress.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AAEH,0CAA0C;AAC1C,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,OAAO,CAAA;IAChB,MAAM,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACvF,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;CACrB;AAED,qDAAqD;AACrD,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,OAAO,GAAG,SAAS,CAAA;AAEzD,mBAAmB;AACnB,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,cAAc,EAAE,aAAa,CAAA;IAC7B;;;OAGG;IACH,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CACnC;AAED,YAAY;AACZ,MAAM,WAAW,UAAU;IACzB,qBAAqB;IACrB,IAAI,EAAE,aAAa,EAAE,CAAA;IACrB,qBAAqB;IACrB,YAAY,EAAE,MAAM,CAAA;IACpB,iDAAiD;IACjD,QAAQ,EAAE,KAAK,CAAC,aAAa,GAAG,IAAI,CAAC,CAAA;IACrC,0BAA0B;IAC1B,UAAU,EAAE,MAAM,EAAE,CAAA;CACrB;AAID;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,cAAc,GAAE,SAAS,MAAM,EAA4B,GAC1D,aAAa,CAKf;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAKlE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,SAAS,aAAa,EAAE,EAClC,OAAO,EAAE,iBAAiB,GACzB,UAAU,CA2FZ;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,aAAa,CAAC;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC,GACxF,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAYxB"}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cloud-egress — 出网请求的「只带云段历史」不变量。
|
|
3
|
+
*
|
|
4
|
+
* ## 主人裁决(2026-10-27)
|
|
5
|
+
*
|
|
6
|
+
* > 发给 cloudllm 的请求,只能携带**之前所有的上 cloudllm 的历史**;
|
|
7
|
+
* > 而 locallm 需要携带**所有的**历史会话信息。
|
|
8
|
+
*
|
|
9
|
+
* 拆开就是两条不对称的规则:
|
|
10
|
+
*
|
|
11
|
+
* | 出口 | 允许携带的历史 |
|
|
12
|
+
* |---|---|
|
|
13
|
+
* | `local-llm` | **全部**历史会话信息(含云段)—— 本地池是 operator 自有设施,带什么都不算出网 |
|
|
14
|
+
* | `cloud-llm` | **只有之前上过 cloud-llm 的历史**;任何 local-llm 段的历史一律不带 |
|
|
15
|
+
*
|
|
16
|
+
* 为什么要不对称:默认路由是本地池,本地轮次积累的会话历史属于**本地资产**。
|
|
17
|
+
* 一旦路由被改写 / 恢复切回云端(`input-guard.route.restored`,实测在 10-24 真实
|
|
18
|
+
* 发生过),整段本地历史会跟着上云 —— 那是**新增**泄露。本地池自己带全部历史
|
|
19
|
+
* 不构成泄露,所以不裁。
|
|
20
|
+
*
|
|
21
|
+
* ## 分段依据:宿主给每条 assistant 消息打了 provider 标记
|
|
22
|
+
*
|
|
23
|
+
* `@deepseek-ai/dsh-llm` 的 `ModelMessageSource` 是 `AssistantProviderMetadata`:
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* interface AssistantProviderMetadata {
|
|
27
|
+
* provider: string // Provider route that produced the message.
|
|
28
|
+
* model: string
|
|
29
|
+
* }
|
|
30
|
+
* interface ModelMessageSource extends AssistantProviderMetadata { kind: 'model' }
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* 所以「这条 assistant 消息是在哪台池上生成的」是**逐条可判**的事实,不需要
|
|
34
|
+
* 靠推断或靠会话边界记账。
|
|
35
|
+
*
|
|
36
|
+
* 用户消息 / 工具结果没有 provider 标记 —— 它们归属于**所在的那一轮**。轮次
|
|
37
|
+
* 起点 = 一条**真实用户输入**消息:
|
|
38
|
+
*
|
|
39
|
+
* - 持久化形态:`role === 'user' && source.kind === 'user'`
|
|
40
|
+
* - 单次请求形态:`role === 'user'` 且**完全没有 source**(宿主的
|
|
41
|
+
* `RequestUserInput`:「User input for one LLM request; it has no durable
|
|
42
|
+
* Session identity or source」)—— 它就是**当前轮**的问题(§7.165)
|
|
43
|
+
*
|
|
44
|
+
* 而下面这些**不是**用户输入,所以按 source 过滤能精确切出轮次:
|
|
45
|
+
*
|
|
46
|
+
* - 宿主的 model-switch 通知也是 user-role,但 `source.kind === 'model-selection'`
|
|
47
|
+
* - dsh-agent-instructions 注入的运行上下文也是 user-role,但 `source.kind` 是
|
|
48
|
+
* 别的 producer kind
|
|
49
|
+
*
|
|
50
|
+
* 它们都**带 source**,所以「没有 source」这条判据不会把它们误认成用户输入。
|
|
51
|
+
*
|
|
52
|
+
* ## 纯函数
|
|
53
|
+
*
|
|
54
|
+
* 本模块不碰 React、不碰 Cordis、不发请求 —— 只做切分与标注,单测直接断言。
|
|
55
|
+
* 真正的出网调用在 `cloud-llm-adapter`(直连与 delegate 两条路径都接)。
|
|
56
|
+
*/
|
|
57
|
+
const DEFAULT_LOCAL_PROVIDERS = ['local-llm'];
|
|
58
|
+
/**
|
|
59
|
+
* provider → 段归属。规则与 `model-router.resolveRoute` 同源,避免两处口径漂移:
|
|
60
|
+
* 本地名单内、或以 `local-` 开头 → local;其余(`cloud-llm` / 宿主自选模型名
|
|
61
|
+
* 如 `qwen-token-plan-cn`)→ cloud。
|
|
62
|
+
*
|
|
63
|
+
* **provider 缺失 → `unknown`**:产不出它的 provider,就**无法证明**它上过云。
|
|
64
|
+
* 主人裁决(2026-10-27)「不用兼容历史会话」—— 旧会话里没有 provider 标记的
|
|
65
|
+
* 那一段直接按 unknown 丢弃,不为它写兼容分支。保守方向:宁可少带,不可多带。
|
|
66
|
+
*/
|
|
67
|
+
export function providerSegment(provider, localProviders = DEFAULT_LOCAL_PROVIDERS) {
|
|
68
|
+
if (typeof provider !== 'string' || provider.length === 0)
|
|
69
|
+
return 'unknown';
|
|
70
|
+
if (localProviders.includes(provider))
|
|
71
|
+
return 'local';
|
|
72
|
+
if (provider.startsWith('local-'))
|
|
73
|
+
return 'local';
|
|
74
|
+
return 'cloud';
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 是不是一条**真实用户输入**消息(轮次起点)。
|
|
78
|
+
*
|
|
79
|
+
* 两种形态都算:
|
|
80
|
+
*
|
|
81
|
+
* 1. `source.kind === 'user'` —— 持久化的会话消息(宿主把用户输入落进会话日志,
|
|
82
|
+
* source 标记为 `user`)。
|
|
83
|
+
* 2. **完全没有 source** —— 宿主的 `RequestUserInput`:
|
|
84
|
+
*
|
|
85
|
+
* ```ts
|
|
86
|
+
* /** User input for one LLM request; it has no durable Session identity or source. *\/
|
|
87
|
+
* interface RequestUserInput {
|
|
88
|
+
* readonly role: 'user'
|
|
89
|
+
* readonly content: UserMessage['content']
|
|
90
|
+
* readonly id?: never
|
|
91
|
+
* readonly source?: never
|
|
92
|
+
* }
|
|
93
|
+
* ```
|
|
94
|
+
*
|
|
95
|
+
* 它是**只用于这一次请求**的用户输入 —— 也就是**当前轮**的问题。漏判它会
|
|
96
|
+
* 让归属整个颠倒(§7.165):当前轮被并进上一轮,而上一轮若判成云段,
|
|
97
|
+
* 结果是**当前的问题被丢掉**(云端模型收到一个没有问题的请求)、
|
|
98
|
+
* **上一轮本地段的输入反而被带上云**。
|
|
99
|
+
*
|
|
100
|
+
* 宿主的 user-role 注入消息(`runtime-context` / `skill-catalog` /
|
|
101
|
+
* `time-context` / `model-selection`)都**带 source**(kind 不是 `user`)⇒
|
|
102
|
+
* 不受影响。
|
|
103
|
+
*/
|
|
104
|
+
export function isUserInputMessage(message) {
|
|
105
|
+
if (message.role !== 'user')
|
|
106
|
+
return false;
|
|
107
|
+
const source = message.source;
|
|
108
|
+
if (source === undefined || source === null)
|
|
109
|
+
return true;
|
|
110
|
+
return source.kind === 'user';
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* 切分消息流,标出每一段(轮次)的归属。
|
|
114
|
+
*
|
|
115
|
+
* 轮次起点 = 真实用户输入消息。段起点之前的消息(理论上只有 system / 早期注入)
|
|
116
|
+
* 不属于任何轮次 → `null`,保留(它们不是会话历史,是运行时上下文)。
|
|
117
|
+
*
|
|
118
|
+
* ## 每一轮怎么判归属(§7.164 修订)
|
|
119
|
+
*
|
|
120
|
+
* 判据只有一条:**这条内容真的上过云吗?** 上过才带。
|
|
121
|
+
*
|
|
122
|
+
* | 轮次形状 | 归属 |
|
|
123
|
+
* |---|---|
|
|
124
|
+
* | 整轮都是云端产出 | 云段 —— 全带 |
|
|
125
|
+
* | 整轮都是本地/未知池产出 | 本地段 —— 整轮丢(含该轮用户输入) |
|
|
126
|
+
* | **云在前、本地在后**(同一轮内路由翻转)| 云前缀带、从本地产出起全丢 |
|
|
127
|
+
* | 本轮(正在出网的这一轮)| 按出网目标归属;出现本地产出则从它起全丢 |
|
|
128
|
+
*
|
|
129
|
+
* ### 为什么「云在前、本地在后」必须切(§7.164 实测发现的泄漏)
|
|
130
|
+
*
|
|
131
|
+
* 旧口径:非当前轮的归属 = 该轮**第一条** assistant 的 provider ⇒ 一轮里只要
|
|
132
|
+
* 第一条是云,**整轮**都算云段 —— 于是同一轮里本地模型产出的回答、以及它之后
|
|
133
|
+
* 的本地工具输出,会被后续出网请求原样带走。
|
|
134
|
+
*
|
|
135
|
+
* 实测形状(真实会话 `session-bd598cb9-…`,provider 顺序
|
|
136
|
+
* `qwen-token-plan-cn → qwen-token-plan-cn → local-llm → …`)⇒ 旧口径下
|
|
137
|
+
* `kept 5 / dropped 0`,裁剪**根本没触发**(`planHostEgress` 见
|
|
138
|
+
* `droppedCount === 0` 就返回 undefined)。本地模型的回答 + 本地工具输出跟着
|
|
139
|
+
* 上云。
|
|
140
|
+
*
|
|
141
|
+
* 修法:非当前轮也按「出现本地产出即从它起切开」处理 —— 云段前缀(真的上过云的
|
|
142
|
+
* 那部分)留下,本地产出起(含其后的工具结果)全部拦下。
|
|
143
|
+
*
|
|
144
|
+
* 纯本地轮(本地产出在前)仍整轮丢:那一轮的用户输入只到过本地池,没上过云。
|
|
145
|
+
*/
|
|
146
|
+
export function planCloudEgress(messages, options) {
|
|
147
|
+
const localProviders = options.localProviders ?? DEFAULT_LOCAL_PROVIDERS;
|
|
148
|
+
const list = messages ?? [];
|
|
149
|
+
// 轮次起点
|
|
150
|
+
const turnStarts = [];
|
|
151
|
+
for (let i = 0; i < list.length; i++) {
|
|
152
|
+
if (isUserInputMessage(list[i]))
|
|
153
|
+
turnStarts.push(i);
|
|
154
|
+
}
|
|
155
|
+
const segments = new Array(list.length).fill(null);
|
|
156
|
+
if (turnStarts.length === 0) {
|
|
157
|
+
// 没有可识别的用户输入 —— 整段当作「轮次起点之前」的上下文,全部保留。
|
|
158
|
+
// 这种情况很罕见(宿主每轮至少一条用户输入),保留比静默清空安全。
|
|
159
|
+
return { kept: [...list], droppedCount: 0, segments, turnStarts };
|
|
160
|
+
}
|
|
161
|
+
for (let t = 0; t < turnStarts.length; t++) {
|
|
162
|
+
const start = turnStarts[t];
|
|
163
|
+
const end = t + 1 < turnStarts.length ? turnStarts[t + 1] : list.length;
|
|
164
|
+
const isCurrent = t === turnStarts.length - 1;
|
|
165
|
+
// 本轮内的三条事实(一次扫描得出):
|
|
166
|
+
// - firstLocal:本轮**第一条**本地/未知池产出的 assistant 下标(-1 = 没有)
|
|
167
|
+
// - cloudBeforeLocal:firstLocal 之前是否出现过云端产出
|
|
168
|
+
// (= 这一轮的用户输入真的被送到云端过)
|
|
169
|
+
// - hasAssistant:本轮有没有任何 assistant 产出
|
|
170
|
+
let firstLocal = -1;
|
|
171
|
+
let cloudBeforeLocal = false;
|
|
172
|
+
let hasAssistant = false;
|
|
173
|
+
for (let i = start; i < end; i++) {
|
|
174
|
+
const m = list[i];
|
|
175
|
+
if (m.role !== 'assistant' || m.source?.kind !== 'model')
|
|
176
|
+
continue;
|
|
177
|
+
hasAssistant = true;
|
|
178
|
+
const seg = providerSegment(m.source.provider, localProviders);
|
|
179
|
+
if (seg === 'cloud') {
|
|
180
|
+
if (firstLocal < 0)
|
|
181
|
+
cloudBeforeLocal = true;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (firstLocal < 0)
|
|
185
|
+
firstLocal = i;
|
|
186
|
+
}
|
|
187
|
+
if (firstLocal < 0) {
|
|
188
|
+
// 本轮没有任何本地产出 ⇒ 整轮是云段。
|
|
189
|
+
// 本轮(正在出网的这一轮)还没有 assistant 消息时,归属由出网目标决定。
|
|
190
|
+
const segment = hasAssistant ? 'cloud' : options.currentSegment;
|
|
191
|
+
for (let i = start; i < end; i++)
|
|
192
|
+
segments[i] = segment;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (isCurrent) {
|
|
196
|
+
// §7.151:当前轮里出现本地产出 ⇒ 从它起(含其后的工具结果)全部拦下。
|
|
197
|
+
//
|
|
198
|
+
// 为什么不能整轮丢:整轮丢会把本轮的**当前用户输入**一起丢掉 —— 那是
|
|
199
|
+
// 「当前内容」,本来就该出网(裁决:当前内容出网前脱敏)。丢了它,云端
|
|
200
|
+
// 模型收到的是一个没有问题的请求。
|
|
201
|
+
for (let i = start; i < firstLocal; i++)
|
|
202
|
+
segments[i] = options.currentSegment;
|
|
203
|
+
for (let i = firstLocal; i < end; i++)
|
|
204
|
+
segments[i] = 'local';
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (cloudBeforeLocal) {
|
|
208
|
+
// §7.164:历史轮里的「云在前、本地在后」形状 —— 云前缀(真上过云)留下,
|
|
209
|
+
// 从本地产出起(含其后的工具结果)全部拦下。
|
|
210
|
+
//
|
|
211
|
+
// 旧口径按「第一条 assistant 的 provider」整轮归云 ⇒ 本地模型的回答和本地
|
|
212
|
+
// 工具输出会跟着下一次出网请求上云(实测 + 真实会话数据都证实过)。
|
|
213
|
+
for (let i = start; i < firstLocal; i++)
|
|
214
|
+
segments[i] = 'cloud';
|
|
215
|
+
for (let i = firstLocal; i < end; i++)
|
|
216
|
+
segments[i] = 'local';
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
// 纯本地轮(本地产出在前,没有云段前缀)⇒ 整轮拦下,含该轮的用户输入:
|
|
220
|
+
// 那一轮的内容只到过本地池,没上过云。
|
|
221
|
+
for (let i = start; i < end; i++)
|
|
222
|
+
segments[i] = 'local';
|
|
223
|
+
}
|
|
224
|
+
const kept = [];
|
|
225
|
+
let droppedCount = 0;
|
|
226
|
+
for (let i = 0; i < list.length; i++) {
|
|
227
|
+
const seg = segments[i];
|
|
228
|
+
// local / unknown 都拦下:前者是本地资产,后者无法证明上过云。
|
|
229
|
+
if (seg === 'local' || seg === 'unknown') {
|
|
230
|
+
droppedCount++;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
kept.push(list[i]);
|
|
234
|
+
}
|
|
235
|
+
return { kept, droppedCount, segments, turnStarts };
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* 统计消息流里 assistant 消息的 provider 分布。
|
|
239
|
+
*
|
|
240
|
+
* 纯观测(审计用)—— 让人能直接确认「这段历史是本地池产生的还是云端产生的」,
|
|
241
|
+
* 也就是裁剪判据到底成不成立。不读正文,只读 `source.provider`。
|
|
242
|
+
*/
|
|
243
|
+
export function assistantProviderHistogram(messages) {
|
|
244
|
+
const out = {};
|
|
245
|
+
for (const m of messages ?? []) {
|
|
246
|
+
if (m?.role !== 'assistant')
|
|
247
|
+
continue;
|
|
248
|
+
const kind = m.source?.kind;
|
|
249
|
+
if (kind !== undefined && kind !== 'model')
|
|
250
|
+
continue;
|
|
251
|
+
const provider = typeof m.source?.provider === 'string' && m.source.provider.length > 0
|
|
252
|
+
? m.source.provider
|
|
253
|
+
: '<none>';
|
|
254
|
+
out[provider] = (out[provider] ?? 0) + 1;
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|