dsh-provider-show 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MosCloud
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,225 @@
1
- # Temporary Holding Version
1
+ # dsh-provider-show
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![check](https://github.com/MosCloud/dsh-provider-show/actions/workflows/check.yml/badge.svg)](https://github.com/MosCloud/dsh-provider-show/actions/workflows/check.yml)
4
+
5
+ 在 DSH 输入框右下角的「模型 · 推理强度」前面,补上**当前模型所属的供应商**。
6
+
7
+ 已被其他插件接管的供应商(例如 WorkBuddy)保持原样,不重复显示。
8
+
9
+ ---
10
+
11
+ ## 它解决什么问题
12
+
13
+ DSH 的模型选择器(composer 模型位)自带两项信息:
14
+
15
+ | 位置 | 内容 | 由谁渲染 |
16
+ |---|---|---|
17
+ | 模型名 | `DeepSeek-V4.1-Flash` | 宿主,经 `conversation.input.model.label` slot |
18
+ | 推理强度 | `Default` / `High` | 宿主,非 slot |
19
+
20
+ **缺的是第三项:这是哪个供应商的模型。**
21
+
22
+ 第三方插件接管某个供应商后会自己补上这一项(WorkBuddy 画 `WB CN · 2,987`、倍率、促销角标),
23
+ 但没有任何插件接管的供应商(例如你在设置里自建的火山方舟路由)就只有「模型 · 推理强度」,
24
+ 看不出模型来自哪里。
25
+
26
+ 本插件补齐这个缺口,并遵守一条让位规则:
27
+
28
+ > **供应商已被第三方插件接管 ⇒ 我方完全不渲染;否则 ⇒ 我方显示供应商名。**
29
+
30
+ ## 效果
31
+
32
+ ```
33
+ 未接管(火山方舟) 火山方舟 DeepSeek V4.1 Flash · Default ← 我方新增「火山方舟」
34
+ 已接管(WorkBuddy) DeepSeek-V4.1-Flash · x0.11 … ← 我方不出现
35
+ ```
36
+
37
+ 芯片挂在模型选择器**左侧、同一条 flex 行**上,只显示供应商名:
38
+ 模型名与推理强度宿主已经画了,重复即噪声。
39
+
40
+ ## 安装
41
+
42
+ ```bash
43
+ dsh plugin --profile <你的-profile> add /path/to/dsh-provider-show
44
+ ```
45
+
46
+ 或在插件管理页安装该本地目录。安装后**刷新页面**。
47
+
48
+ 卸载同样通过插件管理页,或从 profile 的插件清单移除。
49
+
50
+ ## 判定规则
51
+
52
+ 「是否已被第三方插件接管」由 Host 半读取 LLM 目录后判定,优先级从上到下:
53
+
54
+ | 顺序 | 规则 | 结果 |
55
+ |---|---|---|
56
+ | 1 | `overrides[providerId]` 显式配置 | `third-party` 让位 / `mine` 显示 / `hide` 不显示 |
57
+ | 2 | `settingsNs` 不在 `coreNamespaces` 内 | 判定为**已被插件接管**,让位 |
58
+ | 3 | 目录里查不到该 provider,或目录不可读 | 判定为**未知**,**照常显示**(fail-open) |
59
+
60
+ `settingsNs` 是「注册该 provider 的 Loader entry id」:
61
+
62
+ - `llm-pi-ai` —— 你在设置里自建的 API 路由(火山、OpenRouter、任何 OpenAI 兼容服务)
63
+ - `llm-deepseek` / `llm-deepseek-account` —— 官方 DeepSeek 两条路由
64
+ - 其他任何值 —— 某个插件用**自己的** entry id 注册的,例如 `dsh-connect-workbuddy`
65
+
66
+ ### 为什么探测失败时选择「显示」而不是「隐藏」
67
+
68
+ 漏显只是信息不全,与插件没装时一样;
69
+ 误判为「已接管」会让**所有**供应商都不显示,属于明显回归。
70
+ 两种错误代价不对等,所以默认显示。
71
+
72
+ ## 配置
73
+
74
+ 写在 profile 的 `cordis.patch.yml`,或插件条目的 `config`。**每一项都有默认值,
75
+ 但一处配置会替换整个 `config` 对象,所以只写需要改的键即可,其余键自动取默认。**
76
+
77
+ ```yaml
78
+ - id: dsh-provider-show
79
+ name: dsh-provider-show
80
+ config:
81
+ # 总开关(默认 true)
82
+ enabled: true
83
+
84
+ # 供应商短名,覆盖目录里的显示名
85
+ shortNames:
86
+ volcano-api: 火山
87
+
88
+ # 强制某个 provider 的处置
89
+ overrides:
90
+ some-connector-provider: third-party # 让位给接管它的插件
91
+ another-provider: mine # 即使探测说被接管,也照常显示
92
+ noisy-provider: hide # 不显示,但不声称它被别人接管
93
+
94
+ # 宿主自有的 settings 命名空间;宿主升级新增内置 provider 时可扩展
95
+ coreNamespaces:
96
+ - llm-pi-ai
97
+ - llm-deepseek
98
+ - llm-deepseek-account
99
+ ```
100
+
101
+ ## 架构
102
+
103
+ ```
104
+ lib/ownership.js 纯函数:归属判定 + 文案选择(无任何 import,两侧共用)
105
+ lib/index.js Host 半:读 LLM 目录 → 决策 → GET /plugins/dsh-provider-show/providers
106
+ lib/client.js Client 半:注册 conversation.input.right,读宿主文档并渲染
107
+ ```
108
+
109
+ ### 三个关键设计取舍
110
+
111
+ **1. 用 `conversation.input.right`,不用 `conversation.input.model.label`**
112
+
113
+ `input.model.label` 是 `single` slot:同优先级二次注册会**直接抛错**,
114
+ 且 workbuddy 已用 `priority: -0.5` 占位、压制了宿主的 1017 徽标。
115
+ 去抢那一格必然与第三方插件冲突。
116
+
117
+ `input.right` 是 `list` slot(`replaceRisk: "none"`),用独有 id 叠加,
118
+ 渲染位置恰好就在模型选择器左边同一条 flex 行(`ui-conversation` 中
119
+ `.standardControls` 的子节点顺序即 `input.right` → `input.model`)。
120
+
121
+ 代价:芯片宽度计入 `.trailing`,会让模型名**更早**折叠成图标,
122
+ 所以芯片有 `max-width: 140px` 与省略号截断,并用 `margin-inline-end:-10px`
123
+ 抵消宿主的 12px 栅格间距。
124
+
125
+ **排版对齐:靠"同度量",不靠位移**(由 `scripts/verify.mjs` 守住)
126
+
127
+ | 约束 | 原因 |
128
+ |---|---|
129
+ | `font-size:13px` + `font-weight:400` + `line-height:20px` + `height:28px` + `align-items:center`,且**不声明 `font-family`** | 与模型触发器 `.htuFrW_trigger` 完全同一套度量。行盒内基线位置是 `half-leading + ascent`,**由字号与字体决定、与 `line-height` 无关**;度量相同 ⇒ 基线自动重合,不需要任何对齐技巧。 |
130
+ | `margin-inline-end:-10px`、自身 `padding:0` | 宿主 `.standardControls` 有 12px `gap`,模型触发器又有 8px 左内边距;不补偿则两标签相距约 20px,补偿后约 10px,读作一行。 |
131
+ | **禁止 `translateY`** | 见下节。任何人再想加中文补偿,`verify.mjs` 会直接判红。 |
132
+
133
+ ### 中文曾经"不居中"的真相(有实测数据)
134
+
135
+ 早期我在 12px 字号下看到中文偏高,推断是"汉字占满 em 框、无下伸部"导致的,于是加了位移补偿。
136
+ **这个推断在字号不一致的前提下才成立,字号对齐后就不成立了。**
137
+
138
+ 从 2 倍截图里量每个文字块的墨迹质心(1 CSS px = 2 图像 px):
139
+
140
+ | 文字块 | 墨迹行范围 | 质心 |
141
+ |---|---|---|
142
+ | `火山` | 19..43 | **33.12** |
143
+ | `DeepSeek` | 19..42 | 29.80 |
144
+ | `Flash` | 19..38 | 28.90 |
145
+ | `×0.20` | 19..38 | 28.76 |
146
+ | `High` | 19..42 | 30.02 |
147
+
148
+ 拉丁质心均值约 **29.4**。加上 `+2px` 补偿后汉字质心 33.12,比拉丁**低 3.7 图像 px ≈ 1.85 CSS px**;
149
+ 去掉补偿正好落到 29.1,与拉丁齐平。**所以 13px 下的最优补偿是 0。**
150
+
151
+ 这也解释了为什么纯拉丁标签一直正常:它们的位移恒为 0,从未被补偿影响。
152
+
153
+ ### 为什么层级靠颜色而不是字号
154
+
155
+ 宿主自己就是这么做的:模型名 `label-secondary`、推理强度 `label-caption`,**两者都是 13px**。
156
+ 供应商作为第三层用 `label-tertiary`、同为 13px,于是整行读作「供应商 · 模型 · 强度」一个视觉单位。
157
+
158
+ **2. 归属判定只在 Host 侧做一次**
159
+
160
+ 浏览器看不到 `settingsNs`:模型目录的 provider group 只有 `{ id, name, models }`,
161
+ `prepareRoutes` 在进入目录前就把适配器元数据裁掉了。
162
+ 所以规则放在 Host,客户端只做查表 —— 一处实现,不会自我漂移。
163
+
164
+ **3. Host 半不 import 任何 Harness 包**
165
+
166
+ 本地目录安装会把本仓库链接进 profile,其模块解析走 profile 的 `node_modules`,
167
+ 而那里**没有**内置 Harness 包。一个 `import Schema from '@deepseek-ai/schemastery'`
168
+ 就会让整个 Host 半加载失败。
169
+ 因此配置用纯数据描述(`CONFIG_KEYS` / `effectiveConfig`),不导出 `Config`;
170
+ Loader 在没有 `Config` 时原样透传配置,默认值由本插件自己补。
171
+
172
+ ## 开发
173
+
174
+ ```bash
175
+ npm test # 27 项单元 + 行为测试(纯 Node,不需要 DSH 运行时)
176
+ npm run check # 语法检查 + 19 项静态校验 + 全部测试
177
+ ```
178
+
179
+ `scripts/verify.mjs` 的 19 项检查各自对应一条「否则只会在运行时才暴露」的规则:
180
+ manifest 完整性、图标合法性与体积、locale 非占位文案、客户端只通过
181
+ `__ModuleLoader__` 注册且不 import Harness Client 包、样式只用主题 token 且有宽度上界、
182
+ 路由是只读 GET 且在 `ctx.effect` 内注册、归属规则只有一份实现等。
183
+
184
+ ## 已知限制
185
+
186
+ - 需要 Host 半的只读路由(`GET /plugins/dsh-provider-show/providers`)可用;
187
+ 该路由仅接受本页同源请求。该路径是 `/plugins/` 下的 **exact** 路由,
188
+ 优先级高于 `/plugins` 前缀路由(webserver 先查 exact 表),因此不与客户端模块路由冲突。
189
+ - 芯片会让模型名更早折叠为图标(见上文取舍 1)。
190
+ - 当 `conversation.input.activity` 展开整行时,宿主会隐藏整组 `standardControls`,
191
+ 芯片随之隐藏 —— 这是宿主行为。
192
+ - 供应商显示名默认取目录里的 `displayName`(即你在设置里填的名字);
193
+ 想更短就配 `shortNames`。
194
+ - 归属判定的可靠性取决于第三方插件是否用自有 `settingsNs` 注册 provider。
195
+ 若某插件复用核心命名空间,请用 `overrides` 显式标注。
196
+
197
+ ## 状态
198
+
199
+ **已完成:安装 → 运行 → 视觉验收。全部验收项通过。**
200
+
201
+ | 项 | 结果 |
202
+ |---|---|
203
+ | 安装 | 进入 profile bundles 与依赖(本地目录 `link:`) |
204
+ | Host 行 | `fiberPhase: active` |
205
+ | 只读路由 | `GET /plugins/dsh-provider-show/providers` 实测 200,决策正确 |
206
+ | 真实判定 | `火山API` → 显示;`workbuddy` / `workbuddy-global` → 让位 |
207
+ | 视觉验收 | 供应商与模型/强度齐平、间距合适、纯拉丁与中文均正常 |
208
+ | 测试 | 38 项(归属规则、Host 路由行为、Client 渲染) |
209
+ | 静态校验 | 23 项,逐条做过反证测试 |
210
+ | CI | GitHub Actions 在 Node 22 / 24 上跑同一套检查 |
211
+
212
+ ### 卸载
213
+
214
+ 插件管理页禁用该 bundle,或从 profile 依赖里移除 `dsh-provider-show`。
215
+
216
+ ## 文档
217
+
218
+ | 文件 | 内容 |
219
+ |---|---|
220
+ | [docs/design.md](docs/design.md) | 需求确认、架构决策(ADR)、边界约束、验收标准、返工记录 |
221
+ | [docs/research.md](docs/research.md) | 技术考据:槽位拓扑、归属判定信号、排版实测数据,均带源码文件与行号 |
222
+
223
+ ## 许可
224
+
225
+ [MIT](LICENSE)
@@ -0,0 +1,24 @@
1
+ # dsh-provider-show — bundle patch.
2
+ #
3
+ # Inserts one plugin row. The row's id is also the settings namespace the Host
4
+ # half's route is namespaced under, and the `provider` namespaces it treats as
5
+ # core by default come from the shipped dsh-base patch, not from this row.
6
+ #
7
+ # Configuration (optional; every key has a default, and this patch replaces the
8
+ # whole `config` object when it names one):
9
+ #
10
+ # - id: dsh-provider-show
11
+ # name: dsh-provider-show
12
+ # config:
13
+ # enabled: true
14
+ # shortNames:
15
+ # volcano-api: 火山
16
+ # overrides:
17
+ # some-connector-provider: third-party
18
+ # coreNamespaces:
19
+ # - llm-pi-ai
20
+ # - llm-deepseek
21
+ # - llm-deepseek-account
22
+ - insert:
23
+ - id: dsh-provider-show
24
+ name: dsh-provider-show
package/icon.svg ADDED
@@ -0,0 +1,12 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Provider Label">
2
+ <title>Provider Label</title>
3
+ <!-- A tag/label silhouette: the chip this plugin draws beside the model seat.
4
+ Two tags, the smaller behind, read as "the model, and the provider it
5
+ came from". Rendered in the product's brand blue and a muted second tier
6
+ so it stays legible on both the light and dark plugin panels, which have
7
+ their own surface colours rather than the page's. -->
8
+ <rect x="6" y="18" width="34" height="24" rx="6" fill="#5686fe" opacity="0.35"/>
9
+ <path d="M24 12h22a6 6 0 0 1 6 6v14a6 6 0 0 1-6 6H34l-10-8 10-8z" fill="#5686fe"/>
10
+ <circle cx="46" cy="21" r="3" fill="#ffffff"/>
11
+ <rect x="24" y="30" width="20" height="3" rx="1.5" fill="#ffffff" opacity="0.85"/>
12
+ </svg>
package/lib/client.js ADDED
@@ -0,0 +1,222 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-provider-show',
3
+ factory(require) {
4
+ const React = require('react')
5
+ const h = React.createElement
6
+ const { useState, useEffect } = React
7
+
8
+ const NS = 'dsh-provider-show'
9
+ const SLOT = 'conversation.input.right'
10
+ const ENTRY_ID = 'dsh-provider-show-provider'
11
+
12
+ /**
13
+ * The Host half's read-only document.
14
+ *
15
+ * Every ownership decision is already made there — this half performs a
16
+ * lookup, never a rule. That asymmetry is deliberate: the rule needs the
17
+ * Host's LLM directory (`settingsNs`), which the browser cannot see, and
18
+ * one authoritative copy cannot drift from itself.
19
+ */
20
+ const PROVIDERS_PATH = '/plugins/dsh-provider-show/providers'
21
+
22
+ /** How long one document is reused, in milliseconds. */
23
+ const CACHE_MS = 5000
24
+
25
+ const zh = {
26
+ 'chip.aria': '当前供应商:{provider}',
27
+ 'meta.title': '供应商信息补全',
28
+ 'meta.description': '在输入框右下角补全当前模型所属的供应商;已被其他插件接管的供应商不再重复显示。',
29
+ }
30
+ const en = {
31
+ 'chip.aria': 'Current provider: {provider}',
32
+ 'meta.title': 'Provider Label',
33
+ 'meta.description': 'Shows which provider the selected model belongs to in the composer row, and stays silent for a provider another plugin already presents.',
34
+ }
35
+
36
+ /** The chip's own stylesheet; theme tokens only, so light/dark both work. */
37
+ const CSS = [
38
+ // 13px / 400 / 20px / 28px are the model trigger's own metrics
39
+ // (`.htuFrW_trigger`), reproduced exactly and with no `font-family` of our
40
+ // own so both inherit one stack. That is the whole alignment mechanism:
41
+ // inside a 20px line box the baseline sits at `half-leading + ascent`,
42
+ // which is fixed by font-size and font — NOT by line-height — so equal
43
+ // metrics put both labels on one baseline with no nudging.
44
+ //
45
+ // Nothing else is needed, and a `translateY` for Han labels was measured
46
+ // out of the design: with the size matched, a screenshot showed the Han
47
+ // ink centroid sitting 1.85 CSS px LOW once nudged, and level with the
48
+ // Latin centroid at zero. Han glyphs extending above the Latin cap line is
49
+ // normal mixed-script typesetting, not a misalignment.
50
+ //
51
+ // The host separates tiers by COLOUR, not size: the model name is
52
+ // label-secondary and the effort is label-caption, both 13px. The provider
53
+ // is the third tier, label-tertiary at the same size.
54
+ '.dpsp-root{display:inline-flex;align-items:center;box-sizing:border-box;',
55
+ 'height:28px;min-width:0;max-width:140px;padding:0;',
56
+ // The row spaces its children 12px apart, and the model trigger carries
57
+ // 8px of its own leading padding, so an unpadded chip would sit ~20px from
58
+ // the model text. Pulling the pair together makes them read as one line.
59
+ 'margin-inline-end:-10px;',
60
+ 'font-size:13px;font-weight:400;line-height:20px;',
61
+ 'color:var(--dsw-alias-label-tertiary);white-space:nowrap;user-select:none}',
62
+ '.dpsp-name{min-width:0;overflow:hidden;text-overflow:ellipsis}',
63
+ ].join('')
64
+
65
+ /**
66
+ * Mount the stylesheet once.
67
+ *
68
+ * @returns {() => void} the disposer that removes it on unload.
69
+ */
70
+ function installStyles() {
71
+ if (typeof document === 'undefined') return () => {}
72
+ const tagId = 'dsh-provider-show/chip.css'
73
+ if (document.querySelector(`style[data-plugin-css=${JSON.stringify(tagId)}]`) !== null) return () => {}
74
+ const tag = document.createElement('style')
75
+ tag.dataset.plugin = 'dsh-provider-show'
76
+ tag.dataset.pluginCss = tagId
77
+ tag.textContent = CSS
78
+ document.head.appendChild(tag)
79
+ return () => {
80
+ tag.remove()
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Last document read from the Host, shared by every mounted composer so a
86
+ * session switch reuses one answer instead of re-asking.
87
+ */
88
+ const cache = { value: undefined, at: 0, inflight: undefined }
89
+
90
+ /**
91
+ * Read the Host's provider document, at most once per cache window.
92
+ *
93
+ * Any failure returns `undefined`, which the component renders as "draw
94
+ * nothing": a missing label is a smaller fault than a wrong one, and this
95
+ * path only runs when the Host is unreachable anyway.
96
+ *
97
+ * @returns {Promise<{ providers: Array<object>, enabled: boolean } | undefined>} the document.
98
+ */
99
+ async function readDocument() {
100
+ const now = Date.now()
101
+ if (cache.value !== undefined && now - cache.at < CACHE_MS) return cache.value
102
+ if (cache.inflight !== undefined) return cache.inflight
103
+ const operation = (async () => {
104
+ try {
105
+ const response = await fetch(PROVIDERS_PATH, {
106
+ headers: { accept: 'application/json' },
107
+ credentials: 'same-origin',
108
+ })
109
+ if (!response.ok) return undefined
110
+ const body = await response.json()
111
+ if (body?.ok !== true) return undefined
112
+ const value = body.value
113
+ if (value === null || typeof value !== 'object' || !Array.isArray(value.providers)) return undefined
114
+ cache.value = value
115
+ cache.at = Date.now()
116
+ return value
117
+ } catch {
118
+ return undefined
119
+ }
120
+ })()
121
+ cache.inflight = operation
122
+ try {
123
+ return await operation
124
+ } finally {
125
+ if (cache.inflight === operation) cache.inflight = undefined
126
+ }
127
+ }
128
+
129
+ /**
130
+ * One provider's decided row, or undefined when the Host has no opinion.
131
+ *
132
+ * @param {{ providers: Array<object> } | undefined} document - Host document.
133
+ * @param {string} providerId - the selected provider route.
134
+ * @returns {{ label?: string, visible?: boolean } | undefined} the row.
135
+ */
136
+ function rowFor(document, providerId) {
137
+ if (document === undefined || providerId === '') return undefined
138
+ for (const row of document.providers) {
139
+ if (row !== null && typeof row === 'object' && row.provider === providerId) return row
140
+ }
141
+ return undefined
142
+ }
143
+
144
+ /**
145
+ * The provider label beside the model seat.
146
+ *
147
+ * Reads the session's durable `modelSelection` projection — the same value
148
+ * the Host stamps on outgoing requests — so the label cannot disagree with
149
+ * the model seat about which provider is selected. It renders nothing while
150
+ * the document is in flight, because showing a label that is about to be
151
+ * suppressed (the provider belongs to another plugin) would flash.
152
+ *
153
+ * @param {object} props - slot props; `useProjection` and `t` are supplied by the slot.
154
+ * @returns {import('react').ReactElement | null} the label, or null.
155
+ */
156
+ function ProviderLabel(props) {
157
+ const { useProjection, sessionId, t } = props
158
+ const selection = useProjection('modelSelection')
159
+ const providerId = selection?.next?.provider ?? selection?.lastUsed?.provider ?? ''
160
+ const [document, setDocument] = useState(undefined)
161
+
162
+ useEffect(() => {
163
+ if (providerId === '') {
164
+ setDocument(undefined)
165
+ return undefined
166
+ }
167
+ let live = true
168
+ readDocument().then((next) => {
169
+ if (live) setDocument(next)
170
+ })
171
+ return () => {
172
+ live = false
173
+ }
174
+ }, [providerId, sessionId])
175
+
176
+ if (providerId === '') return null
177
+ const row = rowFor(document, providerId)
178
+ // `visible` is decided by the Host: it carries the plugin switch, the
179
+ // user's overrides, and the third-party check in one boolean.
180
+ if (row === undefined || row.visible !== true) return null
181
+ const label = typeof row.label === 'string' ? row.label.trim() : ''
182
+ if (label === '') return null
183
+
184
+ return h(
185
+ 'span',
186
+ {
187
+ className: 'dpsp-root',
188
+ title: t('chip.aria', { provider: label }),
189
+ 'aria-label': t('chip.aria', { provider: label }),
190
+ },
191
+ h('span', { className: 'dpsp-name' }, label),
192
+ )
193
+ }
194
+
195
+ return {
196
+ // Only what the component actually needs. `remote` is deliberately absent:
197
+ // this half reads its own Host route, which works in every composition and
198
+ // keeps the ownership rule on the side that can evaluate it.
199
+ // Only what the component actually needs, spelled the way the cordis
200
+ // Loader expects (a plain array of REQUIRED service keys — the Loader has
201
+ // no "optional" inject form, and a key listed here parks the plugin until
202
+ // that service exists). `remote` is deliberately absent: this half reads
203
+ // its own Host route, which works in every composition and keeps the
204
+ // ownership rule on the side that can evaluate it.
205
+ inject: ['slots', 'locale'],
206
+ apply(ctx) {
207
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-provider-show: dictionaries')
208
+ ctx.effect(installStyles, 'dsh-provider-show: stylesheet')
209
+ ctx.slots.inject(SLOT, () => ctx.slots.register({
210
+ name: SLOT,
211
+ id: ENTRY_ID,
212
+ // `input.right` is a list slot rendered immediately before the model
213
+ // seat. Order only matters if another plugin also occupies this slot;
214
+ // a connector that draws its own supplier chip suppresses ours for its
215
+ // providers, so in practice the two never render together.
216
+ order: 6,
217
+ locale: NS,
218
+ }, ProviderLabel))
219
+ },
220
+ }
221
+ },
222
+ })
package/lib/index.js ADDED
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Host half of dsh-provider-show.
3
+ *
4
+ * The browser can see the model catalog, but it cannot see WHO registered a
5
+ * provider route: the catalog group carries only `{ id, name, models }`, and the
6
+ * registry strips adapter metadata before it reaches the page. That answer
7
+ * lives in the Host's LLM directory, so this half reads it and publishes one
8
+ * small, already-decided document:
9
+ *
10
+ * GET /plugins/dsh-provider-show/providers
11
+ * { ok: true, value: { providers: [{ provider, displayName, label, owner, reason }] } }
12
+ *
13
+ * Each row is decided HERE by the shared pure resolver, so the ownership rule
14
+ * has exactly one implementation and the client half stays a lookup. No Client
15
+ * package, no `remote.llm` dependency, and no second copy of the rule that could
16
+ * drift from this one.
17
+ *
18
+ * A Host-only bundle would need the route's consumer to exist; the Client half
19
+ * is what consumes it, so the two ship together in one bundle.
20
+ *
21
+ * @module dsh-provider-show
22
+ */
23
+
24
+ import {
25
+ DEFAULT_CORE_NAMESPACES,
26
+ providerLabel,
27
+ resolveOwnership,
28
+ shouldRender,
29
+ } from './ownership.js'
30
+
31
+ /** Route this plugin owns, namespaced under `/plugins/` like other bundles. */
32
+ const PROVIDERS_PATH = '/plugins/dsh-provider-show/providers'
33
+
34
+ /** How long one directory snapshot is reused, in milliseconds. */
35
+ const CACHE_MS = 5000
36
+
37
+ /**
38
+ * The plugin's configuration shape, described once as plain data.
39
+ *
40
+ * Deliberately NOT a Schemastery schema. A bundle installed by linking this
41
+ * directory resolves its imports through the profile's `node_modules`, and the
42
+ * shipped Harness packages are NOT there for a bundle that declares no
43
+ * dependency on them — so importing a schema library from the Harness tree
44
+ * would make the whole Host half fail to load. The Loader treats a missing
45
+ * `Config` export as "no schema" and passes the row's config through
46
+ * (`resolveConfig` returns early when `runtime.Config` is absent), so the
47
+ * defaults are applied here instead, in {@link effectiveConfig}, where they cost
48
+ * no import.
49
+ *
50
+ * The shapes below are documented for the patch author and validated at use.
51
+ */
52
+ export const CONFIG_KEYS = Object.freeze({
53
+ enabled: 'boolean — draw the label (default true)',
54
+ shortNames: 'Record<providerId, string> — a shorter label than the catalog name',
55
+ overrides: 'Record<providerId, "mine" | "third-party" | "hide"> — force one decision',
56
+ coreNamespaces: 'string[] — settings namespaces the Harness itself owns',
57
+ })
58
+
59
+ /** The ownership decision values `overrides` accepts. */
60
+ const OVERRIDE_VALUES = Object.freeze(['mine', 'third-party', 'hide'])
61
+
62
+ /**
63
+ * Coerce one unknown value to a string record.
64
+ *
65
+ * Configuration arrives from YAML, so a value can be a number, a list, or
66
+ * absent. Anything unusable is dropped rather than throwing inside a request.
67
+ *
68
+ * @param {unknown} value - raw configured value.
69
+ * @returns {Record<string, string>} only the string-valued entries.
70
+ */
71
+ function stringRecord(value) {
72
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
73
+ const out = {}
74
+ for (const [key, entry] of Object.entries(value)) {
75
+ if (typeof key !== 'string' || key === '') continue
76
+ if (typeof entry !== 'string') continue
77
+ out[key] = entry
78
+ }
79
+ return out
80
+ }
81
+
82
+ /**
83
+ * Resolve the row's configuration into the struct the rest of this file uses.
84
+ *
85
+ * Every field falls back to a defined behaviour, because `cordis.patch.yml`
86
+ * replaces the WHOLE `config` object when it names one key: a user overriding
87
+ * `shortNames` must not thereby disable the default core namespace list.
88
+ *
89
+ * @param {unknown} raw - the row's config as the Loader supplied it.
90
+ * @returns {{ enabled: boolean, shortNames: Record<string,string>, overrides: Record<string,string>, coreNamespaces: string[] }}
91
+ * effective configuration.
92
+ */
93
+ export function effectiveConfig(raw) {
94
+ const base = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
95
+ const shortNames = stringRecord(base.shortNames)
96
+ const overrides = {}
97
+ for (const [key, value] of Object.entries(stringRecord(base.overrides))) {
98
+ // An unrecognised override would silently do nothing; dropping it keeps the
99
+ // resolver's single authority intact and the behaviour predictable.
100
+ if (OVERRIDE_VALUES.includes(value)) overrides[key] = value
101
+ }
102
+ const coreNamespaces = Array.isArray(base.coreNamespaces)
103
+ ? base.coreNamespaces.filter((entry) => typeof entry === 'string' && entry.trim() !== '').map((entry) => entry.trim())
104
+ : []
105
+ return {
106
+ enabled: base.enabled !== false,
107
+ shortNames,
108
+ overrides,
109
+ coreNamespaces: coreNamespaces.length > 0 ? coreNamespaces : [...DEFAULT_CORE_NAMESPACES],
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Read the live LLM registry into the raw rows the resolver consumes.
115
+ *
116
+ * Both calls are optional in a composition. A profile without an LLM service
117
+ * simply produces no rows, which the resolver reports as `unknown` — ours.
118
+ *
119
+ * @param {import('@deepseek-ai/cordis').Context} ctx - host context.
120
+ * @returns {{ rows: unknown[], providers: unknown[] }} directory rows and registered routes.
121
+ */
122
+ function readDirectory(ctx) {
123
+ const llm = ctx.get('llm')
124
+ if (llm === undefined || llm === null) return { rows: [], providers: [] }
125
+ const rows = read(() => llm.listConfigurableProviders?.())
126
+ const providers = read(() => llm.listProviders?.())
127
+ return {
128
+ rows: Array.isArray(rows) ? rows : [],
129
+ providers: Array.isArray(providers) ? providers : [],
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Call one optional registry method, treating any failure as "not available".
135
+ *
136
+ * @param {() => unknown} read - the call.
137
+ * @returns {unknown} its result, or undefined.
138
+ */
139
+ function read(call) {
140
+ try {
141
+ return call()
142
+ } catch {
143
+ return undefined
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Build the document the client half reads.
149
+ *
150
+ * Registered routes decide WHICH providers exist; the configurable directory
151
+ * decides WHO registers each one. The two can legitimately differ — a route
152
+ * may be registered without a directory entry and vice versa — so they are
153
+ * unioned, and a route with no directory row resolves to `unknown`, which
154
+ * renders our label.
155
+ *
156
+ * @param {import('@deepseek-ai/cordis').Context} ctx - host context.
157
+ * @param {ReturnType<typeof effectiveConfig>} config - effective configuration.
158
+ * @returns {{ providers: Array<{provider: string, displayName: string, label: string, owner: string, reason: string}>, coreNamespaces: string[] }}
159
+ * one decided row per provider route.
160
+ */
161
+ export function describeProviders(ctx, config) {
162
+ const { rows, providers } = readDirectory(ctx)
163
+ const coreNamespaces = config.coreNamespaces
164
+
165
+ const ids = []
166
+ const seen = new Set()
167
+ const claim = (id) => {
168
+ if (typeof id !== 'string' || id === '' || seen.has(id)) return
169
+ seen.add(id)
170
+ ids.push(id)
171
+ }
172
+ for (const provider of providers) claim(provider?.id)
173
+ for (const row of rows) claim(row?.provider)
174
+
175
+ const out = []
176
+ for (const providerId of ids) {
177
+ const decision = resolveOwnership({
178
+ providerId,
179
+ directoryRows: rows,
180
+ coreNamespaces,
181
+ overrides: config.overrides,
182
+ })
183
+ const label = providerLabel({
184
+ providerId,
185
+ displayName: decision.displayName,
186
+ shortNames: config.shortNames,
187
+ })
188
+ out.push({
189
+ provider: providerId,
190
+ displayName: decision.displayName ?? providerId,
191
+ label,
192
+ // The client draws the label only when `visible`. Both decisions — who
193
+ // owns the provider, and whether to draw anything — are made here so the
194
+ // page never has to re-derive them.
195
+ visible: label !== '' && shouldRender(decision, config),
196
+ owner: decision.owner,
197
+ reason: decision.reason,
198
+ })
199
+ }
200
+ return { providers: out, coreNamespaces }
201
+ }
202
+
203
+ /**
204
+ * Send one JSON reply that no cache should reuse.
205
+ *
206
+ * @param {import('node:http').ServerResponse} res - response to write.
207
+ * @param {number} status - HTTP status code.
208
+ * @param {unknown} body - JSON-serialisable payload.
209
+ * @returns {void}
210
+ */
211
+ function json(res, status, body) {
212
+ res.writeHead(status, {
213
+ 'content-type': 'application/json; charset=utf-8',
214
+ 'cache-control': 'no-store',
215
+ })
216
+ res.end(JSON.stringify(body))
217
+ }
218
+
219
+ /**
220
+ * Accept only requests issued by the page this Host itself serves.
221
+ *
222
+ * The document exposes provider topology, so the route is held to the loopback
223
+ * rule other plugin routes use: the Host header must name this listener, and a
224
+ * present Origin must be loopback too. A same-origin `fetch` from the served
225
+ * page satisfies both.
226
+ *
227
+ * @param {import('node:http').IncomingMessage} req - incoming request.
228
+ * @returns {boolean} whether the request may be served.
229
+ */
230
+ function fromThisPage(req) {
231
+ const local = req.socket?.localAddress
232
+ const host = req.headers.host
233
+ if (typeof host === 'string' && local !== undefined) {
234
+ const address = host.replace(/:\d+$/, '').replace(/^\[|\]$/g, '')
235
+ if (!(address === local || address === 'localhost' || address === '127.0.0.1' || address === '::1')) {
236
+ return false
237
+ }
238
+ }
239
+ const origin = req.headers.origin
240
+ if (typeof origin === 'string' && origin !== '') {
241
+ try {
242
+ const { hostname } = new URL(origin)
243
+ if (!(hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === local)) {
244
+ return false
245
+ }
246
+ } catch {
247
+ return false
248
+ }
249
+ }
250
+ return true
251
+ }
252
+
253
+ /**
254
+ * Host entry point: register the read-only provider directory route.
255
+ *
256
+ * The route is registered inside `ctx.effect`, so unloading the plugin removes
257
+ * it and drops the snapshot with it.
258
+ *
259
+ * @param {import('@deepseek-ai/cordis').Context} ctx - host context.
260
+ * @param {unknown} [rawConfig] - the row's configuration.
261
+ * @returns {void}
262
+ */
263
+ export function apply(ctx, rawConfig) {
264
+ const config = effectiveConfig(rawConfig)
265
+ let snapshot
266
+ let snapshotAt = 0
267
+
268
+ // `ctx.inject` — not `ctx.get` — because the web server is an ordinary service
269
+ // in the composition: at plugin activation it may not exist yet, and a
270
+ // one-shot `get` would silently leave the route unregistered forever.
271
+ //
272
+ // `scope.effect` — not a returned value — because a plugin's return value is
273
+ // DISCARDED by the Loader; only an effect disposer is run on unload. Returning
274
+ // the route disposer would register the route and then leak it forever.
275
+ ctx.inject(['webServer'], (scope) => {
276
+ const webServer = scope.webServer
277
+ if (webServer === undefined || typeof webServer.register !== 'function') return
278
+ scope.effect(() => webServer.register({
279
+ kind: 'exact',
280
+ path: PROVIDERS_PATH,
281
+ handler: async (req, res) => {
282
+ if (req.method !== 'GET') {
283
+ json(res, 405, { ok: false, error: 'method-not-allowed' })
284
+ return
285
+ }
286
+ if (!fromThisPage(req)) {
287
+ json(res, 403, { ok: false, error: 'not-this-page' })
288
+ return
289
+ }
290
+ const now = Date.now()
291
+ if (snapshot === undefined || now - snapshotAt >= CACHE_MS) {
292
+ try {
293
+ snapshot = describeProviders(ctx, config)
294
+ snapshotAt = now
295
+ } catch (error) {
296
+ // A failed read is not a failed route: the client treats an absent
297
+ // answer as "draw nothing", and the reason is reported rather than
298
+ // thrown into the request lifecycle.
299
+ json(res, 200, {
300
+ ok: false,
301
+ error: 'directory-unavailable',
302
+ message: error instanceof Error ? error.message : String(error),
303
+ })
304
+ return
305
+ }
306
+ }
307
+ json(res, 200, {
308
+ ok: true,
309
+ value: { ...snapshot, enabled: config.enabled },
310
+ })
311
+ },
312
+ }), 'dsh-provider-show: provider directory route')
313
+ })
314
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Ownership resolution for the composer's provider chip.
3
+ *
4
+ * The composer's model seat already renders "model name · reasoning effort"
5
+ * itself. What it cannot show is WHICH PROVIDER the selection belongs to,
6
+ * because the catalog name is the only provider-derived text it draws. A
7
+ * third-party plugin may already own a provider's whole presentation (the
8
+ * WorkBuddy connector draws its own supplier chip, credit readout, promotion
9
+ * badge and effort marker in the same row), and duplicating that is worse than
10
+ * staying silent.
11
+ *
12
+ * This module answers exactly one question, as a pure function of directory
13
+ * rows and configuration:
14
+ *
15
+ * Is this provider's presentation already owned elsewhere?
16
+ *
17
+ * It is deliberately free of DSH imports so it can be unit-tested with plain
18
+ * Node: the Host and Client halves both consume this same function.
19
+ *
20
+ * @module dsh-provider-show/ownership
21
+ */
22
+
23
+ /**
24
+ * Provider routes the Harness itself registers, keyed by the settings namespace
25
+ * that configures each one.
26
+ *
27
+ * These come from the shipped `dsh-base` bundle patch, where a Loader row's id
28
+ * is also the settings namespace its provider directory entry names:
29
+ * `llm-pi-ai` (user-configured API-key routes), and the two official DeepSeek
30
+ * routes. A provider whose `settingsNs` is one of these is core; anything else
31
+ * means some plugin registered the route under its own namespace.
32
+ *
33
+ * Kept as data rather than a switch so a deployment can extend it through the
34
+ * plugin's Config when a future Harness release adds another built-in route.
35
+ */
36
+ export const DEFAULT_CORE_NAMESPACES = Object.freeze([
37
+ 'llm-pi-ai',
38
+ 'llm-deepseek',
39
+ 'llm-deepseek-account',
40
+ ])
41
+
42
+ /** The three answers {@link resolveOwnership} can give. */
43
+ export const OWNERSHIP = Object.freeze({
44
+ /** Core Harness route: no third-party plugin presents it, so show our chip. */
45
+ CORE: 'core',
46
+ /** Another plugin registered this route and presents it; we stay silent. */
47
+ THIRD_PARTY: 'third-party',
48
+ /** No directory row for this route: treat it as ours (fail-open). */
49
+ UNKNOWN: 'unknown',
50
+ })
51
+
52
+ /**
53
+ * Normalise a configuration list of namespaces.
54
+ *
55
+ * Configuration arrives from YAML, so a missing field, `null`, a scalar, or a
56
+ * list with non-string members must all degrade to the default rather than
57
+ * throwing inside a render path. Duplicates and empty strings are dropped
58
+ * because a namespace is compared by equality.
59
+ *
60
+ * @param {unknown} value - raw configured value.
61
+ * @param {readonly string[]} fallback - list used when nothing usable was given.
62
+ * @returns {string[]} a usable namespace list.
63
+ */
64
+ export function normalizeNamespaces(value, fallback = DEFAULT_CORE_NAMESPACES) {
65
+ if (!Array.isArray(value)) return [...fallback]
66
+ const seen = new Set()
67
+ const out = []
68
+ for (const entry of value) {
69
+ if (typeof entry !== 'string') continue
70
+ const name = entry.trim()
71
+ if (name === '' || seen.has(name)) continue
72
+ seen.add(name)
73
+ out.push(name)
74
+ }
75
+ return out.length === 0 ? [...fallback] : out
76
+ }
77
+
78
+ /**
79
+ * Read one provider's directory row out of a `listConfigurableProviders()` reply.
80
+ *
81
+ * The remote reply is untrusted JSON (a Host generation may have changed shape),
82
+ * so every field is checked before use.
83
+ *
84
+ * @param {unknown} rows - raw reply.
85
+ * @param {string} providerId - provider route to find.
86
+ * @returns {{ provider: string, displayName?: string, settingsNs?: string, settingsPath?: readonly string[] } | undefined}
87
+ * the matching row, or undefined when the reply is unusable.
88
+ */
89
+ export function findProviderRow(rows, providerId) {
90
+ if (!Array.isArray(rows) || typeof providerId !== 'string' || providerId === '') return undefined
91
+ for (const row of rows) {
92
+ if (row === null || typeof row !== 'object') continue
93
+ if (row.provider !== providerId) continue
94
+ return {
95
+ provider: providerId,
96
+ ...(typeof row.displayName === 'string' && row.displayName !== ''
97
+ ? { displayName: row.displayName }
98
+ : {}),
99
+ ...(typeof row.settingsNs === 'string' && row.settingsNs !== ''
100
+ ? { settingsNs: row.settingsNs }
101
+ : {}),
102
+ ...(Array.isArray(row.settingsPath) ? { settingsPath: row.settingsPath } : {}),
103
+ }
104
+ }
105
+ return undefined
106
+ }
107
+
108
+ /**
109
+ * Decide who presents one provider route.
110
+ *
111
+ * Order of authority, strongest first:
112
+ *
113
+ * 1. The user's explicit override. A person who has looked at the composer and
114
+ * said "this one is somebody else's" outranks every inference below, because
115
+ * it is the only signal that cannot be wrong about intent.
116
+ * 2. The directory row's `settingsNs`. A namespace outside the core list means
117
+ * a plugin registered the route under its own Loader entry id.
118
+ * 3. Fail-open. A route with no row — or a directory that could not be read —
119
+ * is treated as ours. The asymmetry is deliberate: showing a provider that
120
+ * was in fact owned elsewhere costs a duplicate label, while hiding one that
121
+ * was not owned costs the entire feature, silently, for every provider.
122
+ *
123
+ * @param {object} input - resolution input.
124
+ * @param {string} input.providerId - selected provider route.
125
+ * @param {unknown} [input.directoryRows] - `listConfigurableProviders()` reply.
126
+ * @param {readonly string[]} [input.coreNamespaces] - namespaces the Harness owns.
127
+ * @param {Record<string, string>} [input.overrides] - provider id → decision.
128
+ * @returns {{ owner: string, reason: string, settingsNs?: string, displayName?: string }}
129
+ * the decision plus the evidence that produced it.
130
+ */
131
+ export function resolveOwnership(input) {
132
+ const providerId = input?.providerId
133
+ if (typeof providerId !== 'string' || providerId === '') {
134
+ return { owner: OWNERSHIP.UNKNOWN, reason: 'no-provider' }
135
+ }
136
+
137
+ const overrides = input.overrides
138
+ const override = overrides !== null && typeof overrides === 'object' && !Array.isArray(overrides)
139
+ ? overrides[providerId]
140
+ : undefined
141
+ if (override === 'third-party') return { owner: OWNERSHIP.THIRD_PARTY, reason: 'override' }
142
+ if (override === 'mine') return { owner: OWNERSHIP.CORE, reason: 'override' }
143
+ // `hide` is not an ownership claim: it says "draw nothing" without asserting
144
+ // that somebody else owns the provider. It rides along on the decision so
145
+ // there is still exactly ONE reader of the overrides table.
146
+ if (override === 'hide') return { owner: OWNERSHIP.CORE, reason: 'override-hidden', hidden: true }
147
+
148
+ const row = findProviderRow(input.directoryRows, providerId)
149
+ if (row === undefined) return { owner: OWNERSHIP.UNKNOWN, reason: 'no-directory-row' }
150
+
151
+ const core = normalizeNamespaces(input.coreNamespaces)
152
+ const settingsNs = row.settingsNs
153
+ const carries = {
154
+ ...(settingsNs === undefined ? {} : { settingsNs }),
155
+ ...(row.displayName === undefined ? {} : { displayName: row.displayName }),
156
+ }
157
+ if (settingsNs === undefined) {
158
+ return { owner: OWNERSHIP.UNKNOWN, reason: 'no-settings-namespace', ...carries }
159
+ }
160
+ if (core.includes(settingsNs)) {
161
+ return { owner: OWNERSHIP.CORE, reason: 'core-namespace', ...carries }
162
+ }
163
+ return { owner: OWNERSHIP.THIRD_PARTY, reason: 'plugin-namespace', ...carries }
164
+ }
165
+
166
+ /**
167
+ * Whether our chip should render at all for one selection.
168
+ *
169
+ * Three things suppress it, and only three: the plugin switch, an explicit
170
+ * `hide` override, and a confirmed third-party owner. `UNKNOWN` renders, for the
171
+ * reason given on {@link resolveOwnership}: a duplicate label is a cosmetic
172
+ * fault, a missing feature is the whole request.
173
+ *
174
+ * This function deliberately does NOT re-read `overrides`. Every decision that
175
+ * needs that table is made once, inside {@link resolveOwnership}, and arrives
176
+ * here on the decision object — two readers of one table could disagree after an
177
+ * edit, and the disagreement would be invisible until a user saw it.
178
+ *
179
+ * @param {{ owner: string, hidden?: boolean }} decision - result of {@link resolveOwnership}.
180
+ * @param {{ enabled?: boolean }} [config] - plugin configuration.
181
+ * @returns {boolean} whether to draw the chip.
182
+ */
183
+ export function shouldRender(decision, config = {}) {
184
+ if (config?.enabled === false) return false
185
+ if (decision?.hidden === true) return false
186
+ return decision?.owner !== OWNERSHIP.THIRD_PARTY
187
+ }
188
+
189
+ /**
190
+ * The text our chip shows for one provider.
191
+ *
192
+ * Model name and reasoning effort are already drawn by the composer's own model
193
+ * seat, so repeating them here would print each fact twice in one row. The chip
194
+ * therefore carries the one missing fact: the provider.
195
+ *
196
+ * @param {object} input - label input.
197
+ * @param {string} input.providerId - selected provider route.
198
+ * @param {string} [input.displayName] - provider name from the directory row.
199
+ * @param {Record<string, string>} [input.shortNames] - configured short names.
200
+ * @returns {string} the label, or an empty string when nothing usable exists.
201
+ */
202
+ export function providerLabel(input) {
203
+ const providerId = input?.providerId
204
+ const shortNames = input?.shortNames
205
+ if (shortNames !== null && typeof shortNames === 'object' && !Array.isArray(shortNames)) {
206
+ const short = typeof providerId === 'string' ? shortNames[providerId] : undefined
207
+ if (typeof short === 'string' && short.trim() !== '') return short.trim()
208
+ }
209
+ const displayName = input?.displayName
210
+ if (typeof displayName === 'string' && displayName.trim() !== '') return displayName.trim()
211
+ // A provider id is the last resort: it is always present and always accurate,
212
+ // and showing `volcano-cn` beats showing an empty chip. Callers that would
213
+ // rather show nothing pass fallbackToId: false.
214
+ if (input?.fallbackToId === false) return ''
215
+ return typeof providerId === 'string' ? providerId : ''
216
+ }
package/locale/en.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "meta": {
3
+ "title": "Provider Label",
4
+ "description": "Shows which provider the selected model belongs to in the composer row, and stays silent for a provider another plugin already presents."
5
+ },
6
+ "chip": {
7
+ "aria": "Current provider: {provider}"
8
+ }
9
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "meta": {
3
+ "title": "供应商信息补全",
4
+ "description": "在输入框右下角补全当前模型所属的供应商;已被其他插件接管的供应商不再重复显示。"
5
+ },
6
+ "chip": {
7
+ "aria": "当前供应商:{provider}"
8
+ }
9
+ }
package/package.json CHANGED
@@ -1,6 +1,66 @@
1
1
  {
2
2
  "name": "dsh-provider-show",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Shows which provider the selected model belongs to in the composer row, and stays silent for providers another plugin already presents.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./cordis.patch.yml": "./cordis.patch.yml",
11
+ "./locale/*.json": "./locale/*.json",
12
+ "./ownership": "./lib/ownership.js",
13
+ "./icon": "./icon.svg",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "icon": "./icon.svg",
17
+ "files": [
18
+ "lib",
19
+ "locale",
20
+ "icon.svg",
21
+ "cordis.patch.yml",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "dsh": {
26
+ "bundle": {
27
+ "patch": "./cordis.patch.yml"
28
+ },
29
+ "client": {
30
+ "platform": "web",
31
+ "inject": [
32
+ "@deepseek-ai/dsh-client-ui-conversation",
33
+ "@deepseek-ai/dsh-client-locale"
34
+ ]
35
+ }
36
+ },
37
+ "scripts": {
38
+ "test": "node --test",
39
+ "check": "node --check lib/index.js && node --check lib/client.js && node --check lib/ownership.js && node scripts/verify.mjs && node --test"
40
+ },
41
+ "keywords": [
42
+ "dsh-plugin",
43
+ "deepseek-harness",
44
+ "provider",
45
+ "composer"
46
+ ],
47
+ "license": "MIT",
48
+ "engines": {
49
+ "node": "^22.19.0 || >=24.0.0"
50
+ },
51
+ "author": {
52
+ "name": "MosCloud"
53
+ },
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "git+https://github.com/MosCloud/dsh-provider-show.git"
57
+ },
58
+ "homepage": "https://github.com/MosCloud/dsh-provider-show#readme",
59
+ "bugs": {
60
+ "url": "https://github.com/MosCloud/dsh-provider-show/issues"
61
+ },
62
+ "publishConfig": {
63
+ "access": "public",
64
+ "registry": "https://registry.npmjs.org/"
65
+ }
66
+ }