dsh-magpie-connector 0.2.0 → 0.3.1
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 +15 -0
- package/README.md +20 -17
- package/README.zh.md +20 -17
- package/docs/SCREENSHOTS.md +2 -3
- package/docs/internal-api.md +4 -3
- package/docs/model-picker.png +0 -0
- package/docs/settings.png +0 -0
- package/lib/client/SettingsPage.js +4 -2
- package/lib/client/SpeedSelect.d.ts +1 -8
- package/lib/client/SpeedSelect.js +5 -3
- package/lib/client/view-model.d.ts +11 -1
- package/lib/client.js +23 -3
- package/lib/host/adapter.d.ts +17 -14
- package/lib/host/adapter.js +28 -17
- package/lib/host/catalog.d.ts +13 -9
- package/lib/host/catalog.js +20 -5
- package/lib/host/registrar.d.ts +36 -0
- package/lib/host/registrar.js +57 -0
- package/lib/host/runtime.d.ts +23 -4
- package/lib/host/runtime.js +98 -12
- package/lib/host/settings.d.ts +8 -0
- package/lib/host/settings.js +18 -0
- package/lib/host/transport.js +2 -1
- package/lib/index.d.ts +4 -0
- package/lib/index.js +13 -6
- package/package.json +3 -2
- package/screenshots.json +2 -1
- package/docs/model-picker.gif +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
- Replace both README demo GIFs with representative PNG screenshots (the model picker grouped by provider; per-provider visibility, default effort and usage cards in Settings), because the DSH plugin store does not show GIFs by default. No runtime behavior changes.
|
|
6
|
+
|
|
7
|
+
## 0.3.0
|
|
8
|
+
|
|
9
|
+
**BREAKING:** the single provider route `dsh-magpie-connector` is retired and not aliased. Sessions, the default model and any external configuration saved with that route id no longer resolve; reselect the model after upgrading, and update anything that lists the exact route id (subagent routing, egress allowlists).
|
|
10
|
+
|
|
11
|
+
- **Grouping on stock DSH.** One real route per gateway `owned_by`: `dsh-magpie-connector-<owner>` (lower-cased, 1–48 chars of `a-z0-9._-`; anything else goes to `-other`). The official picker shows `Magpie (<Owner>)` groups without any Host extension; the `displayGroup` metadata is gone.
|
|
12
|
+
- **Route identity.** Every model result carries the requested route as `provider`; lookups are by model id, so capabilities are identical on any plugin route.
|
|
13
|
+
- **Replay across routes.** Native reasoning replay uses a fixed transport identity, so encrypted reasoning survives tool turns, restarts and switching between plugin routes; foreign or tampered history degrades to plain history.
|
|
14
|
+
- **Remembered owners.** `settings.json` `knownOwners` (max 32, first-seen order) registers routes at start-up before the first sync. Invalid values are reset as a whole (`knownOwnersReset`), over-limit owners are reported (`ownerLimitReached`, `rejectedOwners`), and owners that disappear keep their empty route.
|
|
15
|
+
- **Failure handling.** A failed first registration (route owned by another plugin, or a host listener `INVARIANT`) is reported as `routeRegistrationError` in Settings and requires a DSH restart; replace conflicts roll back the new owner and still publish catalog changes. Settings writes go through one synchronous writer, so owner discovery never deadlocks with settings requests or overwrites concurrent line saves.
|
|
16
|
+
- **Speed control** shows on any plugin route.
|
|
17
|
+
|
|
3
18
|
## 0.2.0
|
|
4
19
|
|
|
5
20
|
**BREAKING:** the settings format changes from a single top-level `baseUrl` to an ordered `lines[]` list, and credentials move to per-line derived refs. Existing `settings.json` files with only `baseUrl` and the legacy fixed refs (`MAGPIE_API_KEY`, `MAGPIE_ACCESS_CLIENT_ID`, `MAGPIE_ACCESS_CLIENT_SECRET`) are **not read, migrated or deleted**; reconfigure the connection in Settings after upgrading. `visibleModels` / `defaultEfforts` are preserved. Back up `settings.json` and the old credential refs before upgrading if you need a rollback path.
|
package/README.md
CHANGED
|
@@ -3,15 +3,16 @@
|
|
|
3
3
|
English · [简体中文](README.zh.md)
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
<img src="docs/model-picker.
|
|
6
|
+
<img src="docs/model-picker.png" alt="Model picker grouped by upstream provider: Magpie (Claude), Magpie (GPT), Magpie (deepseek)" width="49%">
|
|
7
|
+
<img src="docs/settings.png" alt="Settings: the connection line on top, then models grouped by provider with visibility, default effort and usage cards" width="49%">
|
|
7
8
|
</p>
|
|
8
9
|
|
|
9
|
-
*
|
|
10
|
+
*Left: model picker grouped by upstream provider. Right: Settings → Magpie — the connection line, then per-provider model visibility, default effort and usage cards. Captured from an isolated DSH host with a local mock Magpie gateway; models and accounts are synthetic.*
|
|
10
11
|
|
|
11
|
-
[
|
|
12
|
+

|
|
12
13
|
|
|
13
14
|
<!-- problem -->
|
|
14
|
-
**Connect a self-hosted Magpie gateway to DeepSeek Harness
|
|
15
|
+
**Connect a self-hosted Magpie gateway to DeepSeek Harness with one provider route per upstream owner: models grouped by upstream provider on stock DSH, read-only usage cards, and a per-session fast mode.** Magpie keeps managing logins, accounts, routing and provider egress; this plugin adds the DSH side.
|
|
15
16
|
|
|
16
17
|
### What you get
|
|
17
18
|
|
|
@@ -19,7 +20,7 @@ English · [简体中文](README.zh.md)
|
|
|
19
20
|
|---|---|
|
|
20
21
|
| **Gateway lines** (basic) | Each line has its own URL and gateway key. The model catalog comes from the gateway's `GET /models`; Responses-capable models use Responses, everything else uses Chat Completions. Text, tool round trips, images, usage, cancellation and DSH-managed retries go through. |
|
|
21
22
|
| **Automatic multi-line failover** (signature) | Empty on first install; normally one line; add backup lines and let the order decide priority. Before every gateway operation the plugin picks the first reachable complete line with a ≤600ms TCP probe (10s positive / 15s negative in-memory cache, no background polling), so home uses the LAN while outside falls back to the public endpoint. Switching lines carries the catalog over (no flash), clears usage cards and re-syncs the new line; single-line users keep 0.1.x behavior. |
|
|
22
|
-
| **Grouped by upstream provider** (signature) |
|
|
23
|
+
| **Grouped by upstream provider** (signature) | Works on stock DSH, no Host extension needed: the plugin registers one real provider route per gateway `owned_by` (`dsh-magpie-connector-claude`, `-codex`, `-opencode-go`, …; missing or invalid ownership goes to `-other`), so the official model picker shows `Magpie (Claude)`, `Magpie (Codex)`, `Magpie (Opencode-go)`, `Magpie (Other)` groups. Settings and usage cards use the same grouping, and a provider that only appears in usage is never dropped. |
|
|
23
24
|
| **Per-line Cloudflare Access** | Each line independently chooses Direct or Cloudflare Access. Access-enabled line requests carry the service token pair plus the bearer key; the token is never sent to a plain-HTTP line. |
|
|
24
25
|
| **Read-only usage cards** | Each upstream account shows plan, expiry, window readings and Codex reset credits when the gateway provides them. Cards are read once when settings open and only re-read when you press **Refresh** on that card; bars turn orange above 70% and red at 90%. |
|
|
25
26
|
| **Model visibility and default effort** | Per provider, leave visibility untouched to follow the gateway catalog, or save an explicit allowlist so new models stay hidden. Per model, save a default reasoning effort; DSH applies it on the next request. |
|
|
@@ -33,7 +34,7 @@ dsh plugin --profile web add dsh-magpie-connector
|
|
|
33
34
|
|
|
34
35
|
Restart DSH and refresh the Web page. Inside [ohmydsh](https://github.com/prgrmrwy/ohmydsh) you can instead enable the local `dsh-magpie-connector` manifest entry and rebuild. Tested with DSH `0.2.0-rc.2`, Web UI, Node.js `>=22.19`.
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
> **0.3.0 BREAKING upgrade**: the single `dsh-magpie-connector` route is retired. Sessions and the default model saved on it no longer resolve; pick the model again from a `Magpie (…)` group. Routes that depend on the exact route id elsewhere (e.g. subagent routing, egress allowlists) must list the new `dsh-magpie-connector-<owner>` ids.
|
|
37
38
|
|
|
38
39
|
### Configure
|
|
39
40
|
|
|
@@ -52,8 +53,8 @@ Need another method — custom headers, mTLS, another zero-trust gateway? **Issu
|
|
|
52
53
|
|
|
53
54
|
| Where | What you do |
|
|
54
55
|
|---|---|
|
|
55
|
-
| **Model picker** | Search across group headings, model names and full ids.
|
|
56
|
-
| **Model menu groups** |
|
|
56
|
+
| **Model picker** | Search across group headings, model names and full ids. Each group is a real route `dsh-magpie-connector-<owner>`; model ids stay the gateway's full ids, and visibility keys and default effort are unchanged. |
|
|
57
|
+
| **Model menu groups** | `owned_by` (lower-cased; 1–48 chars of `a-z0-9._-`) picks the route and the label `Magpie (<Owner>)` with the first letter capitalised. Missing or invalid ownership goes to `Magpie (Other)` instead of guessing from the model name. Owners seen once are remembered in `settings.json` `knownOwners` (max 32) so their routes exist right after a restart; the models appear once the first catalog sync finishes. An owner that disappears keeps its (empty, hidden) route. Clearing `knownOwners` by hand invalidates selections saved on the removed routes. If route registration fails at start-up (another plugin owns the id), Settings shows the error and DSH must be restarted after resolving it. |
|
|
57
58
|
| **Composer speed control** | Shows only for the current model when the gateway advertises the `priority` tier (on current Magpie: the Codex/GPT models), and updates as soon as the selection changes. Hover the model menu to see effort; the official picker shows one effort value. |
|
|
58
59
|
| **Settings → Magpie** | Connection, advanced connection mode, per-provider model sections with visibility checkboxes and default effort selects, plus the usage cards for that provider. |
|
|
59
60
|
| **Usage card refresh** | Each card has its own **Refresh**, which asks Magpie to re-read that one account. A cold gateway may return no cards at all; the connection panel then offers **Fetch models and usage**, which first re-synchronizes the catalog and then asks Magpie to read every account once. A restricted held key cannot initialize an empty cache — ask the Magpie administrator to read usage first. |
|
|
@@ -68,22 +69,22 @@ Need another method — custom headers, mTLS, another zero-trust gateway? **Issu
|
|
|
68
69
|
- **Not supported:** `stop` sequences fail before any network access (`MAGPIE_STOP_UNSUPPORTED`) because Magpie cannot preserve them across every upstream translation. Usage cards and Codex reset credits are display-only; the plugin never calls account-management or reset endpoints.
|
|
69
70
|
- **Secrets stay out of responses and logs.** Gateway-reflected text is redacted with the current key before it can reach the settings API or logs. Files are written `0600`; the settings API reports only `configured` / `writable`.
|
|
70
71
|
|
|
71
|
-
###
|
|
72
|
-
|
|
73
|
-

|
|
72
|
+
### Code map
|
|
74
73
|
|
|
75
74
|
Entry: [index.ts](src/index.ts). Adapter and request/stream mapping: [adapter.ts](src/host/adapter.ts). Routes: [routes.ts](src/host/routes.ts). Transport allowlist: [transport.ts](src/host/transport.ts). Settings page: [SettingsPage.ts](src/client/SettingsPage.ts). The diagram source, with credential-free repository evidence: [architecture.en.json](docs/architecture.en.json).
|
|
76
75
|
|
|
77
76
|
### Development
|
|
78
77
|
|
|
78
|
+
In a standalone checkout of this repository:
|
|
79
|
+
|
|
79
80
|
```sh
|
|
80
81
|
npm install
|
|
81
|
-
npm
|
|
82
|
-
npm
|
|
83
|
-
npm run build
|
|
82
|
+
npm run typecheck
|
|
83
|
+
npm test
|
|
84
|
+
npm run build
|
|
84
85
|
```
|
|
85
86
|
|
|
86
|
-
Tests use local mock gateways and the real wire library; no real key is required. Build output is not committed. Both `prepare` and `prepack` build the package.
|
|
87
|
+
Inside the ohmydsh monorepo, install once at the root and add `--workspace=dsh-magpie-connector` to the last three commands. Tests use local mock gateways and the real wire library; no real key is required. Build output is not committed. Both `prepare` and `prepack` build the package.
|
|
87
88
|
|
|
88
89
|
## Removal
|
|
89
90
|
<!-- section: removal -->
|
|
@@ -96,6 +97,8 @@ Restart DSH and refresh the page. Disposal releases registrations, timers and co
|
|
|
96
97
|
|
|
97
98
|
Inside [ohmydsh](https://github.com/prgrmrwy/ohmydsh), set the manifest entry to `enabled: false` and rebuild instead.
|
|
98
99
|
|
|
99
|
-
##
|
|
100
|
+
## Source, license and contributing
|
|
101
|
+
|
|
102
|
+
This plugin is developed in [ohmydsh](https://github.com/prgrmrwy/ohmydsh) (`packages/dsh-magpie-connector`); [this repository](https://github.com/prgrmrwy/dsh-magpie-connector) is its release mirror, published automatically to [npm](https://www.npmjs.com/package/dsh-magpie-connector) and [GitHub Releases](https://github.com/prgrmrwy/dsh-magpie-connector/releases). Current behavior is specified in [`openspec/specs/dsh-magpie-connector`](https://github.com/prgrmrwy/ohmydsh/blob/main/openspec/specs/dsh-magpie-connector/spec.md).
|
|
100
103
|
|
|
101
|
-
MIT. `@earendil-works/pi-ai` and `undici` are separate MIT-licensed dependencies; the bird artwork follows the notice in [THIRD-PARTY-NOTICES](THIRD-PARTY-NOTICES).
|
|
104
|
+
[MIT](LICENSE). `@earendil-works/pi-ai` and `undici` are separate MIT-licensed dependencies; the bird artwork follows the notice in [THIRD-PARTY-NOTICES](THIRD-PARTY-NOTICES). Issues and pull requests are welcome at [prgrmrwy/dsh-magpie-connector](https://github.com/prgrmrwy/dsh-magpie-connector/issues).
|
package/README.zh.md
CHANGED
|
@@ -3,15 +3,16 @@
|
|
|
3
3
|
[English](README.md) · 简体中文
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
<img src="docs/model-picker.
|
|
6
|
+
<img src="docs/model-picker.png" alt="模型选择器:按上游 provider 分组为 Magpie (Claude)、Magpie (GPT)、Magpie (deepseek)" width="49%">
|
|
7
|
+
<img src="docs/settings.png" alt="设置页:顶部连接线路,下方按 provider 分组的模型可见性、默认 effort 与用量卡片" width="49%">
|
|
7
8
|
</p>
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
*左:按上游 provider 分组的模型选择器;右:设置 → Magpie,顶部连接线路,下方按 provider 分组的模型可见性、默认 effort 与用量卡片。截图来自隔离 DSH 宿主与本地 mock Magpie 网关,模型和账号均为演示数据。*
|
|
10
11
|
|
|
11
|
-
[
|
|
12
|
+

|
|
12
13
|
|
|
13
14
|
<!-- problem -->
|
|
14
|
-
**把自托管的 Magpie
|
|
15
|
+
**把自托管的 Magpie 网关接入 DeepSeek Harness,每个上游归属对应一个 provider route:原版 DSH 即按上游 provider 分组展示模型,附带只读用量卡片和会话级快速档。** Magpie 继续负责登录、账号、路由和厂商出站代理,本插件补齐 DSH 侧。
|
|
15
16
|
|
|
16
17
|
### 你会得到什么
|
|
17
18
|
|
|
@@ -19,7 +20,7 @@
|
|
|
19
20
|
|---|---|
|
|
20
21
|
| **Magpie 网关接入**(基础) | 每条线路只需地址和该线路的 key。模型目录来自网关 `GET /models`;支持 Responses 的模型走 Responses,其余走 Chat Completions。文本、工具往返、图片、usage、取消以及 DSH 统一重试都能正常工作。 |
|
|
21
22
|
| **多线路自动切换**(特色) | 通常一条线路(首次安装为空,最多 5 条),可添加备用线路并按序号自动选择:每次操作前对候选地址做 ≤600ms 的 TCP 可达探测(正缓存 10s / 负缓存 15s,不后台轮询),在家走局域网、在外自动落到公网。切换线路时模型目录继承不闪断、用量卡片清空并按新线路重读;单线路(通常一条)行为与旧版一致。 |
|
|
22
|
-
| **按上游 provider 分组**(特色) |
|
|
23
|
+
| **按上游 provider 分组**(特色) | 原版 DSH 即可使用,无需 Host 扩展:插件按网关 `owned_by` 为每个归属注册一个真实 provider route(`dsh-magpie-connector-claude`、`-codex`、`-opencode-go` 等;缺失或不合法归入 `-other`),官方模型选择器因此显示 `Magpie (Claude)`、`Magpie (Codex)`、`Magpie (Opencode-go)`、`Magpie (Other)` 分组。设置页和用量卡片使用同一套分组,只出现在用量里的 provider 也不会被丢弃。 |
|
|
23
24
|
| **按线路的 Cloudflare Access** | 每条线路可单独选择「直连」或「Cloudflare Access」。启用后该线路的请求带上服务令牌两项并保留 bearer key;服务令牌永远不会发往明文 HTTP 线路。 |
|
|
24
25
|
| **只读用量卡片** | 每个上游账号展示计划、到期时间、各窗口读数和 Codex 重置次数(网关提供时)。打开设置页只读一次,之后只有点单张卡片的「刷新」才会重新读取;用量条超过 70% 变橙、达到 90% 变红。 |
|
|
25
26
|
| **模型可见性与默认 effort** | 每个 provider 不设置可见性时跟随网关目录;保存过选择后只显示勾选项,新上架模型默认隐藏。每个模型可保存一个默认推理 effort,DSH 在下一次请求应用。 |
|
|
@@ -33,7 +34,7 @@ dsh plugin --profile web add dsh-magpie-connector
|
|
|
33
34
|
|
|
34
35
|
重启 DSH 并刷新页面。在 [ohmydsh](https://github.com/prgrmrwy/ohmydsh) 中也可以直接启用 local `dsh-magpie-connector` manifest 条目后重新构建。已验证 DSH `0.2.0-rc.2`、Web UI、Node.js `>=22.19`。
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
> **0.3.0 BREAKING 升级**:旧的单一 route `dsh-magpie-connector` 已退役。保存在它上面的会话与默认模型不再可用,请在 `Magpie (…)` 分组中重选模型。其它依赖精确 route id 的配置(例如子任务路由、出口豁免列表)需改成新的 `dsh-magpie-connector-<owner>`。
|
|
37
38
|
|
|
38
39
|
### 配置
|
|
39
40
|
|
|
@@ -52,8 +53,8 @@ dsh plugin --profile web add dsh-magpie-connector
|
|
|
52
53
|
|
|
53
54
|
| 位置 | 做什么 |
|
|
54
55
|
|---|---|
|
|
55
|
-
| **模型选择器** | 统一搜索覆盖分组标题、型号名和完整 id
|
|
56
|
-
| **模型菜单分组** | `owned_by`
|
|
56
|
+
| **模型选择器** | 统一搜索覆盖分组标题、型号名和完整 id。每个分组是一个真实 route `dsh-magpie-connector-<owner>`;model id 保持网关完整 id,可见性键和默认 effort 不变。 |
|
|
57
|
+
| **模型菜单分组** | `owned_by`(转小写,1–48 个 `a-z0-9._-` 字符)决定 route 和标题 `Magpie (<Owner>)`(首字母大写)。缺失或不合法时归入 `Magpie (Other)`,不按模型名猜测。见过的归属记在 `settings.json` 的 `knownOwners`(最多 32 个),重启后 route 立即存在,首次目录同步完成后模型才出现。归属下架后其 route 保留(空组自动隐藏)。手动清空 `knownOwners` 会让保存在被移除 route 上的选择失效。启动时若 route 注册失败(例如被别的插件占用),设置页会显示错误,排除后需重启 DSH。 |
|
|
57
58
|
| **输入框速度控件** | 仅当前模型被网关声明为 `priority` 档时显示(当前 Magpie 上即 Codex/GPT 模型),切换模型后立即更新。悬停模型菜单可看 effort;官方选择器只显示一个 effort 值。 |
|
|
58
59
|
| **设置 → Magpie** | 连接、接入方式、按 provider 分组的模型区(可见性复选框与默认 effort 选择器),以及该 provider 的用量卡片。 |
|
|
59
60
|
| **用量卡片刷新** | 每张卡片有独立的「刷新」,请求 Magpie 重新读取这一个账号。网关冷启动可能没有任何卡片;此时连接面板提供「获取模型与用量」,先同步模型目录,再请 Magpie 读取全部账号一次。受限 held key 无法初始化空缓存,需要管理员先在 Magpie 中读取一次。 |
|
|
@@ -70,22 +71,22 @@ dsh plugin --profile web add dsh-magpie-connector
|
|
|
70
71
|
- **不支持:** 非空 `stop` 序列会在发请求前失败(`MAGPIE_STOP_UNSUPPORTED`),因为 Magpie 无法在所有上游协议转换中保留 stop。用量卡片与 Codex 重置次数只读展示,插件不会调用账号管理或重置端点。
|
|
71
72
|
- **密钥不会进入响应或日志。** 网关反射的文本在进入设置接口或日志之前会用当前 key 脱敏。写盘文件权限 `0600`;设置接口只报告 `configured` / `writable`。
|
|
72
73
|
|
|
73
|
-
###
|
|
74
|
-
|
|
75
|
-

|
|
74
|
+
### 代码导览
|
|
76
75
|
|
|
77
76
|
入口:[index.ts](src/index.ts)。适配器与请求/流映射:[adapter.ts](src/host/adapter.ts)。路由:[routes.ts](src/host/routes.ts)。传输白名单:[transport.ts](src/host/transport.ts)。设置页:[SettingsPage.ts](src/client/SettingsPage.ts)。不含凭据的图源与仓库证据:[architecture.zh.json](docs/architecture.zh.json)。
|
|
78
77
|
|
|
79
78
|
### 开发
|
|
80
79
|
|
|
80
|
+
在本仓库的独立检出中:
|
|
81
|
+
|
|
81
82
|
```sh
|
|
82
83
|
npm install
|
|
83
|
-
npm
|
|
84
|
-
npm
|
|
85
|
-
npm run build
|
|
84
|
+
npm run typecheck
|
|
85
|
+
npm test
|
|
86
|
+
npm run build
|
|
86
87
|
```
|
|
87
88
|
|
|
88
|
-
|
|
89
|
+
在 ohmydsh monorepo 里,在仓库根目录安装一次,后三条命令加上 `--workspace=dsh-magpie-connector`。测试使用本机 mock 网关和真实线协议库,不需要真实 key。构建产物不入库;`prepare` 与 `prepack` 都会执行 build。
|
|
89
90
|
|
|
90
91
|
## 移除
|
|
91
92
|
<!-- section: removal -->
|
|
@@ -98,6 +99,8 @@ dsh plugin --profile web remove dsh-magpie-connector
|
|
|
98
99
|
|
|
99
100
|
在 [ohmydsh](https://github.com/prgrmrwy/ohmydsh) 中则把 manifest 条目设为 `enabled: false` 后重新构建。
|
|
100
101
|
|
|
101
|
-
##
|
|
102
|
+
## 来源、许可与参与
|
|
103
|
+
|
|
104
|
+
本插件在 [ohmydsh](https://github.com/prgrmrwy/ohmydsh)(`packages/dsh-magpie-connector`)中持续开发,[本仓库](https://github.com/prgrmrwy/dsh-magpie-connector)是发布镜像,自动发布到 [npm](https://www.npmjs.com/package/dsh-magpie-connector) 与 [GitHub Releases](https://github.com/prgrmrwy/dsh-magpie-connector/releases)。当前行为见规范 [`openspec/specs/dsh-magpie-connector`](https://github.com/prgrmrwy/ohmydsh/blob/main/openspec/specs/dsh-magpie-connector/spec.md)。
|
|
102
105
|
|
|
103
|
-
MIT
|
|
106
|
+
采用 [MIT](LICENSE) 许可。`@earendil-works/pi-ai` 与 `undici` 是分别以 MIT 许可分发的依赖;鸟形图标来源与许可见 [THIRD-PARTY-NOTICES](THIRD-PARTY-NOTICES)。欢迎到 [prgrmrwy/dsh-magpie-connector](https://github.com/prgrmrwy/dsh-magpie-connector/issues) 提 issue 和 PR。
|
package/docs/SCREENSHOTS.md
CHANGED
|
@@ -2,6 +2,5 @@
|
|
|
2
2
|
|
|
3
3
|
| file | kind | source | date | reviewer | verdict | note |
|
|
4
4
|
|---|---|---|---|---|---|---|
|
|
5
|
-
| model-picker.
|
|
6
|
-
|
|
7
|
-
> Settings screenshot removed pending re-recording after the 0.2.0 line-list settings rewrite; the old recording showed the pre-multi-line connection form.
|
|
5
|
+
| model-picker.png | screenshot | frame 11 of the 2026-10-28 recording (isolated DSH_HOME instance, temp HOME/XDG/DSH_HOME under /tmp, port 3199; local mock Magpie gateway on loopback with demo models; lossless deviceScaleFactor-2 screenshots of the real Web UI, 1500x880 at 1:1), exported losslessly from the global-palette GIF | 2026-10-29 | lead | pass (reviewed frame: demo workspace and mock models only; no hostname, user, path, token or real session) | 106789 bytes; picker open with Magpie (Claude), Magpie (GPT) and Magpie (deepseek) groups; replaces model-picker.gif because the plugin store does not show GIFs by default |
|
|
6
|
+
| settings.png | screenshot | isolated DSH_HOME under /tmp (temp HOME/XDG, port 3199) running the deployed 0.3.0 plugin (code identical to 0.3.1) against a local loopback mock Magpie gateway serving demo models and usage cards; Playwright/CDP deviceScaleFactor-2 screenshot of the settings dialog, 1600x1600 | 2026-10-29 | lead | pass (reviewed: loopback mock address, key from a fake startup env value, demo@example.com account, demo models; no real hostname, user, path, token or session) | 202707 bytes; connection section with line 1 collapsed, then the claude group with per-model default effort and its usage card, codex group below; replaces settings.gif |
|
package/docs/internal-api.md
CHANGED
|
@@ -53,7 +53,7 @@ try {
|
|
|
53
53
|
- `runtime.invalidateCredential(ref?)` maps a `credentials/reference-updated` ref to its line id (`lineIdOfCredentialRef`) and rotates only when that line is active; without a ref it rotates the current generation. It synchronously drops visible old state and aborts background operations before asynchronously resolving/syncing. Own credential writes suppress that listener until the combined settings mutation commits.
|
|
54
54
|
- `runtime.redact(text)` replaces current and active-operation keys literally with `[redacted]`. All gateway strings going into HTTP responses or logs must use this method. Catalog metadata is redacted before it is cached.
|
|
55
55
|
- `runtime.settings(body?)` handles the single-schema settings update `{lines?, resync?}` (plus GET projection) and returns a `Response`; the generic SettingsController adds preference fields (`visibleModels`, `defaultEffort`) around it. Credential fields use the tri-state (omit/null/write) contract above; unknown keys are rejected with 400 and nothing is written.
|
|
56
|
-
- `publish()`
|
|
56
|
+
- `publish(routes?)` goes through the route registrar: the first non-empty set calls `registerAdapter`, later sets call `replace` (also when only the catalog changed, so open pickers get `llm/adapters-updated`); the result drives owner rollback and `routeRegistrationError`. Same-generation sync is single-flight and times out after 15 seconds. Last successful catalog/time survive same-generation failure; identity changes never retain old data.
|
|
57
57
|
|
|
58
58
|
## Catalog and adapter
|
|
59
59
|
|
|
@@ -62,7 +62,8 @@ try {
|
|
|
62
62
|
```ts
|
|
63
63
|
interface CatalogModel {
|
|
64
64
|
info: LlmResolvedModelInfo
|
|
65
|
-
provider: string // owned_by, else first id segment
|
|
65
|
+
provider: string // visibility key: owned_by, else first id segment
|
|
66
|
+
owner: string // route key (ownerKey), route = dsh-magpie-connector-<owner>
|
|
66
67
|
efforts: string[]
|
|
67
68
|
nativeEndpoints: string[]
|
|
68
69
|
serviceTiers: string[] // tier ids, e.g. priority
|
|
@@ -97,7 +98,7 @@ Every request validates same origin and exact API-root-relative path, forces aut
|
|
|
97
98
|
|
|
98
99
|
## Host wiring
|
|
99
100
|
|
|
100
|
-
`apply(ctx,{home?})`
|
|
101
|
+
`apply(ctx,{home?})` registers one adapter on the owner routes derived from `knownOwners` (via `createRouteRegistrar`, see `src/host/registrar.ts`), credentials event, `/api/dsh-magpie-connector/settings` through injected `connection.fetch`, starts initial sync, and installs an async effect disposer. It returns `{runtime,adapter,routes}` for focused harness tests. The feature assembly adds cards/speed resources and their controller lifecycle here; do not add another adapter registration or another settings route.
|
|
101
102
|
|
|
102
103
|
## Evidence and remaining assembly
|
|
103
104
|
|
|
Binary file
|
|
Binary file
|
|
@@ -13,7 +13,7 @@ import { cardKey, formatTime, parseCards, providerOrder, readTime, sameTarget }
|
|
|
13
13
|
export const EMPTY_TEXT = 'Magpie 未返回可见用量';
|
|
14
14
|
export const RESTRICTED_HINT = '受限的 gateway key 无法让 Magpie 首次读取用量:需要 Magpie 管理员先在 Magpie 中读取一次,或者改用不受限的 key。';
|
|
15
15
|
export const NOT_REREAD = 'Magpie 未重新读取(可能处于节流间隔内)';
|
|
16
|
-
const SYNC_KIND = { unauthorized: 'key 无效(401)', forbidden: '无权访问(403)', http: '网关错误', unreachable: '无法连接网关' };
|
|
16
|
+
const SYNC_KIND = { unauthorized: 'key 无效(401)', forbidden: '无权访问(403)', http: '网关错误', unreachable: '无法连接网关', settings: '设置保存失败', registration: '路由注册失败' };
|
|
17
17
|
export function MagpieSettings({ api }) {
|
|
18
18
|
const [settings, setSettings] = useState();
|
|
19
19
|
const [settingsError, setSettingsError] = useState('');
|
|
@@ -307,7 +307,9 @@ function LineCard({ draft, index, total, saving, active, expanded, onToggle, onC
|
|
|
307
307
|
}
|
|
308
308
|
function SyncStatus({ settings }) {
|
|
309
309
|
const count = (settings.groups ?? []).reduce((n, g) => n + g.models.length, 0);
|
|
310
|
-
|
|
310
|
+
const rejected = settings.rejectedOwners ?? [];
|
|
311
|
+
const registration = settings.routeRegistrationError;
|
|
312
|
+
return h('div', { className: 'dmg-meta', 'data-sync': '' }, settings.lastSyncAt ? h('p', null, `共 ${count} 个模型 · 更新于 `, h('time', { dateTime: settings.lastSyncAt }, formatTime(settings.lastSyncAt))) : null, settings.lastSyncError ? h('p', { className: 'dmg-error' }, `同步失败${settings.lastSyncErrorKind ? `(${SYNC_KIND[settings.lastSyncErrorKind] ?? settings.lastSyncErrorKind})` : ''}:${settings.lastSyncError}`) : null, registration ? h('p', { className: 'dmg-error', role: 'alert' }, `Magpie 路由注册失败(${registration.code}${registration.route ? `,${registration.route}` : ''}):需重启 Host 后才能恢复。`) : null, settings.knownOwnersReset ? h('p', { className: 'dmg-hint' }, '归属列表已重置:已保存的归属无效,下次同步会重新建立。') : null, settings.ownerLimitReached ? h('p', { className: 'dmg-hint' }, `归属数量已达上限(32 个),以下归属未能加入:${rejected.join('、')}`) : null);
|
|
311
313
|
}
|
|
312
314
|
function ProviderGroup({ provider, group, settings, cards, busy, saving, mutateSettings, onRefresh, notice, alert }) {
|
|
313
315
|
const [error, setError] = useState('');
|
|
@@ -1,12 +1,5 @@
|
|
|
1
1
|
import { type Api } from './api.js';
|
|
2
|
-
|
|
3
|
-
* Composer speed control (`conversation.input.right`): standard / fast for
|
|
4
|
-
* models whose gateway catalog advertises the `priority` service tier. The
|
|
5
|
-
* choice lives in host memory per session (design D9); this component only
|
|
6
|
-
* views it. It renders nothing unless the session's current model is a
|
|
7
|
-
* `dsh-magpie-connector` model listed in `fastModels`.
|
|
8
|
-
*/
|
|
9
|
-
export declare const ROUTE = "dsh-magpie-connector";
|
|
2
|
+
export declare function isMagpieRoute(provider: string): boolean;
|
|
10
3
|
export type Tier = 'standard' | 'fast';
|
|
11
4
|
export interface SpeedState {
|
|
12
5
|
visible: boolean;
|
|
@@ -6,9 +6,11 @@ import { settingsCss } from './styles.js';
|
|
|
6
6
|
* models whose gateway catalog advertises the `priority` service tier. The
|
|
7
7
|
* choice lives in host memory per session (design D9); this component only
|
|
8
8
|
* views it. It renders nothing unless the session's current model is a
|
|
9
|
-
* `dsh-magpie-connector
|
|
9
|
+
* owner-route (`dsh-magpie-connector-<owner>`) model listed in `fastModels`.
|
|
10
10
|
*/
|
|
11
|
-
|
|
11
|
+
/** Owner-route id shape (D4/D8); must match the host's `isMagpieRoute` in catalog.ts. Client does not import host code. */
|
|
12
|
+
const MAGPIE_ROUTE = /^dsh-magpie-connector-[a-z0-9][a-z0-9._-]{0,47}$/;
|
|
13
|
+
export function isMagpieRoute(provider) { return MAGPIE_ROUTE.test(provider); }
|
|
12
14
|
/**
|
|
13
15
|
* Calls `onChange` when the session's selected provider/model changes. Other store
|
|
14
16
|
* updates (including the ones our own `load()` causes) are ignored, so a reload
|
|
@@ -42,7 +44,7 @@ export function createSpeedLoader(api, models, sessionId) {
|
|
|
42
44
|
const body = response.body;
|
|
43
45
|
const fastModels = Array.isArray(body.fastModels) ? body.fastModels.filter((m) => typeof m === 'string') : [];
|
|
44
46
|
const tier = body.tier === 'fast' ? 'fast' : 'standard';
|
|
45
|
-
return { visible: current !== null && current.provider
|
|
47
|
+
return { visible: current !== null && isMagpieRoute(current.provider) && fastModels.includes(current.model), tier };
|
|
46
48
|
};
|
|
47
49
|
}
|
|
48
50
|
export function createSpeedSetter(api, sessionId) {
|
|
@@ -44,7 +44,7 @@ export interface ModelGroup {
|
|
|
44
44
|
provider: string;
|
|
45
45
|
models: ModelRow[];
|
|
46
46
|
}
|
|
47
|
-
export type SyncErrorKind = 'unauthorized' | 'forbidden' | 'http' | 'unreachable';
|
|
47
|
+
export type SyncErrorKind = 'unauthorized' | 'forbidden' | 'http' | 'unreachable' | 'settings' | 'registration';
|
|
48
48
|
export interface LineView {
|
|
49
49
|
id?: string;
|
|
50
50
|
baseUrl?: string;
|
|
@@ -97,6 +97,16 @@ export interface SettingsView {
|
|
|
97
97
|
groups?: ModelGroup[];
|
|
98
98
|
visibleModels?: Record<string, string[]>;
|
|
99
99
|
defaultEfforts?: Record<string, string>;
|
|
100
|
+
/** Initial route registration failed (design D6): the host must be restarted to recover. */
|
|
101
|
+
routeRegistrationError?: {
|
|
102
|
+
code: string;
|
|
103
|
+
route?: string;
|
|
104
|
+
};
|
|
105
|
+
/** knownOwners was invalid at load and reset to empty (spec 归属 route 持久). */
|
|
106
|
+
knownOwnersReset?: boolean;
|
|
107
|
+
/** The owner cap (32) rejected new owners; `rejectedOwners` lists them. */
|
|
108
|
+
ownerLimitReached?: boolean;
|
|
109
|
+
rejectedOwners?: string[];
|
|
100
110
|
}
|
|
101
111
|
/** Defensive parse: unknown or malformed entries never reach the renderer. */
|
|
102
112
|
export declare function parseCards(body: unknown, httpStatus: number): CardsView;
|
package/lib/client.js
CHANGED
|
@@ -226,7 +226,9 @@ window.__ModuleLoader__.load({
|
|
|
226
226
|
unauthorized: "key 无效(401)",
|
|
227
227
|
forbidden: "无权访问(403)",
|
|
228
228
|
http: "网关错误",
|
|
229
|
-
unreachable: "无法连接网关"
|
|
229
|
+
unreachable: "无法连接网关",
|
|
230
|
+
settings: "设置保存失败",
|
|
231
|
+
registration: "路由注册失败"
|
|
230
232
|
};
|
|
231
233
|
function MagpieSettings({ api }) {
|
|
232
234
|
const [settings, setSettings] = (0, react.useState)();
|
|
@@ -735,10 +737,15 @@ window.__ModuleLoader__.load({
|
|
|
735
737
|
}
|
|
736
738
|
function SyncStatus({ settings }) {
|
|
737
739
|
const count = (settings.groups ?? []).reduce((n, g) => n + g.models.length, 0);
|
|
740
|
+
const rejected = settings.rejectedOwners ?? [];
|
|
741
|
+
const registration = settings.routeRegistrationError;
|
|
738
742
|
return (0, react.createElement)("div", {
|
|
739
743
|
className: "dmg-meta",
|
|
740
744
|
"data-sync": ""
|
|
741
|
-
}, settings.lastSyncAt ? (0, react.createElement)("p", null, `共 ${count} 个模型 · 更新于 `, (0, react.createElement)("time", { dateTime: settings.lastSyncAt }, formatTime(settings.lastSyncAt))) : null, settings.lastSyncError ? (0, react.createElement)("p", { className: "dmg-error" }, `同步失败${settings.lastSyncErrorKind ? `(${SYNC_KIND[settings.lastSyncErrorKind] ?? settings.lastSyncErrorKind})` : ""}:${settings.lastSyncError}`) : null)
|
|
745
|
+
}, settings.lastSyncAt ? (0, react.createElement)("p", null, `共 ${count} 个模型 · 更新于 `, (0, react.createElement)("time", { dateTime: settings.lastSyncAt }, formatTime(settings.lastSyncAt))) : null, settings.lastSyncError ? (0, react.createElement)("p", { className: "dmg-error" }, `同步失败${settings.lastSyncErrorKind ? `(${SYNC_KIND[settings.lastSyncErrorKind] ?? settings.lastSyncErrorKind})` : ""}:${settings.lastSyncError}`) : null, registration ? (0, react.createElement)("p", {
|
|
746
|
+
className: "dmg-error",
|
|
747
|
+
role: "alert"
|
|
748
|
+
}, `Magpie 路由注册失败(${registration.code}${registration.route ? `,${registration.route}` : ""}):需重启 Host 后才能恢复。`) : null, settings.knownOwnersReset ? (0, react.createElement)("p", { className: "dmg-hint" }, "归属列表已重置:已保存的归属无效,下次同步会重新建立。") : null, settings.ownerLimitReached ? (0, react.createElement)("p", { className: "dmg-hint" }, `归属数量已达上限(32 个),以下归属未能加入:${rejected.join("、")}`) : null);
|
|
742
749
|
}
|
|
743
750
|
function ProviderGroup({ provider, group, settings, cards, busy, saving, mutateSettings, onRefresh, notice, alert }) {
|
|
744
751
|
const [error, setError] = (0, react.useState)("");
|
|
@@ -835,6 +842,19 @@ window.__ModuleLoader__.load({
|
|
|
835
842
|
value: id
|
|
836
843
|
}, id))) : null);
|
|
837
844
|
}
|
|
845
|
+
// src/client/SpeedSelect.ts
|
|
846
|
+
/**
|
|
847
|
+
* Composer speed control (`conversation.input.right`): standard / fast for
|
|
848
|
+
* models whose gateway catalog advertises the `priority` service tier. The
|
|
849
|
+
* choice lives in host memory per session (design D9); this component only
|
|
850
|
+
* views it. It renders nothing unless the session's current model is a
|
|
851
|
+
* owner-route (`dsh-magpie-connector-<owner>`) model listed in `fastModels`.
|
|
852
|
+
*/
|
|
853
|
+
/** Owner-route id shape (D4/D8); must match the host's `isMagpieRoute` in catalog.ts. Client does not import host code. */
|
|
854
|
+
const MAGPIE_ROUTE = /^dsh-magpie-connector-[a-z0-9][a-z0-9._-]{0,47}$/;
|
|
855
|
+
function isMagpieRoute(provider) {
|
|
856
|
+
return MAGPIE_ROUTE.test(provider);
|
|
857
|
+
}
|
|
838
858
|
/**
|
|
839
859
|
* Calls `onChange` when the session's selected provider/model changes. Other store
|
|
840
860
|
* updates (including the ones our own `load()` causes) are ignored, so a reload
|
|
@@ -876,7 +896,7 @@ window.__ModuleLoader__.load({
|
|
|
876
896
|
const fastModels = Array.isArray(body.fastModels) ? body.fastModels.filter((m) => typeof m === "string") : [];
|
|
877
897
|
const tier = body.tier === "fast" ? "fast" : "standard";
|
|
878
898
|
return {
|
|
879
|
-
visible: current !== null && current.provider
|
|
899
|
+
visible: current !== null && isMagpieRoute(current.provider) && fastModels.includes(current.model),
|
|
880
900
|
tier
|
|
881
901
|
};
|
|
882
902
|
};
|
package/lib/host/adapter.d.ts
CHANGED
|
@@ -22,24 +22,27 @@ export declare class MagpieAdapter extends LlmAdapter {
|
|
|
22
22
|
readonly attachments?: Pick<AttachmentStore, "readImage" | "imageHostPath"> | undefined;
|
|
23
23
|
readonly isFast: (_session: string) => boolean;
|
|
24
24
|
constructor(runtime: MagpieRuntime, attachments?: Pick<AttachmentStore, "readImage" | "imageHostPath"> | undefined, isFast?: (_session: string) => boolean);
|
|
25
|
-
providerInfo(): {
|
|
25
|
+
providerInfo(route: string): {
|
|
26
26
|
id: string;
|
|
27
27
|
name: string;
|
|
28
28
|
};
|
|
29
29
|
providerRetryPolicy(): import("@deepseek-ai/dsh-llm").ResolvedRetryPolicy;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
30
|
+
/** Look the model up by id in the whole catalog (route-independent); the returned provider is the requested route. */
|
|
31
|
+
private entryFor;
|
|
32
|
+
listModels(route: string): Promise<{
|
|
33
|
+
provider: string;
|
|
34
|
+
context?: import("@deepseek-ai/dsh-llm").LlmModelContext;
|
|
35
|
+
defaultMaxTokens?: number;
|
|
36
|
+
reasoning?: import("@deepseek-ai/dsh-llm").LlmModelReasoningInfo;
|
|
37
|
+
systemPromptUpdate?: import("@deepseek-ai/dsh-llm").SystemPromptUpdate;
|
|
38
|
+
toolUpdate?: import("@deepseek-ai/dsh-llm").ToolUpdate;
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
description?: string;
|
|
42
|
+
inputModalities?: readonly import("@deepseek-ai/dsh-llm").ModelModality[];
|
|
43
|
+
}[]>;
|
|
44
|
+
resolveModel(route: string, id: string): Promise<import("@deepseek-ai/dsh-llm").LlmResolvedModelInfo>;
|
|
45
|
+
prepareCall(route: string, id: string, signal?: AbortSignal): Promise<PreparedAdapterCall>;
|
|
43
46
|
private failure;
|
|
44
47
|
stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
|
|
45
48
|
private context;
|
package/lib/host/adapter.js
CHANGED
|
@@ -4,9 +4,10 @@ import { createProvider } from '@earendil-works/pi-ai';
|
|
|
4
4
|
import { normalizeContext } from '@earendil-works/pi-ai/utils/transcript';
|
|
5
5
|
import * as chat from '@earendil-works/pi-ai/api/openai-completions';
|
|
6
6
|
import * as responses from '@earendil-works/pi-ai/api/openai-responses';
|
|
7
|
+
import { TRANSPORT_ID, isMagpieRoute, ownerLabel, ownerOfRoute } from './catalog.js';
|
|
7
8
|
import { visibleCatalog } from './visibility.js';
|
|
8
9
|
import { withDefaultEffort } from './default-effort.js';
|
|
9
|
-
const provider = createProvider({ id:
|
|
10
|
+
const provider = createProvider({ id: TRANSPORT_ID, auth: { apiKey: { name: 'Gateway key', resolve: async () => undefined } }, models: [], api: { 'openai-completions': chat, 'openai-responses': responses } });
|
|
10
11
|
const emptyUsage = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } });
|
|
11
12
|
const record = (value) => typeof value === 'object' && value !== null && !Array.isArray(value) ? value : undefined;
|
|
12
13
|
const blockHash = (block) => createHash('sha256').update(JSON.stringify(block.type === 'text' || block.type === 'reasoning' ? [block.type, block.text] : block.type === 'tool-call' ? [block.type, block.id, block.name, block.arguments] : [block.type])).digest('hex');
|
|
@@ -42,10 +43,12 @@ function restoreAssistant(message, model) {
|
|
|
42
43
|
return [{ type: 'toolCall', id: block.id, name: block.name, arguments: parseArguments(block.arguments) }];
|
|
43
44
|
return [];
|
|
44
45
|
});
|
|
45
|
-
//
|
|
46
|
-
const
|
|
46
|
+
// Native replay identity is the fixed transport id, shared by every owner route; only our own routes may claim it.
|
|
47
|
+
const ours = isMagpieRoute(message.source.provider);
|
|
48
|
+
// Same-model tool ids already belong to DSH content, even when no replay was stored.
|
|
49
|
+
const fallback = { role: 'assistant', api: ours && message.source.model === model.id ? model.api : 'dsh-foreign', provider: ours ? TRANSPORT_ID : message.source.provider, model: message.source.model, content: content.map(block => block.type === 'thinking' ? { type: 'text', text: block.thinking } : block), usage: emptyUsage(), stopReason: content.some(block => block.type === 'toolCall') ? 'toolUse' : 'stop', timestamp: 0 };
|
|
47
50
|
const envelope = record(message.source.replayState), response = record(envelope?.response), blocks = envelope?.blocks;
|
|
48
|
-
if (response?.kind !== 'dsh-magpie-connector-pi' || response.version !== 1 || typeof response.api !== 'string' || !response.api || response.provider !==
|
|
51
|
+
if (!ours || response?.kind !== 'dsh-magpie-connector-pi' || response.version !== 1 || typeof response.api !== 'string' || !response.api || response.provider !== TRANSPORT_ID || response.model !== message.source.model || !['stop', 'length', 'toolUse'].includes(String(response.stopReason)) || !Array.isArray(blocks) || blocks.length !== message.content.length)
|
|
49
52
|
return fallback;
|
|
50
53
|
for (const key of ['responseId', 'responseModel', 'providerThinkingLevel'])
|
|
51
54
|
if (response[key] !== undefined && typeof response[key] !== 'string')
|
|
@@ -135,28 +138,36 @@ export class MagpieAdapter extends LlmAdapter {
|
|
|
135
138
|
this.attachments = attachments;
|
|
136
139
|
this.isFast = isFast;
|
|
137
140
|
}
|
|
138
|
-
providerInfo() { return { id:
|
|
141
|
+
providerInfo(route) { return { id: route, name: ownerLabel(ownerOfRoute(route)) }; }
|
|
139
142
|
providerRetryPolicy() { return RETRY_POLICY; }
|
|
140
|
-
|
|
141
|
-
|
|
143
|
+
/** Look the model up by id in the whole catalog (route-independent); the returned provider is the requested route. */
|
|
144
|
+
entryFor(op, route, id) {
|
|
145
|
+
const entry = structuredClone(op.gen.catalog.find(m => m.info.id === id) ?? { info: { provider: route, id, name: id, inputModalities: ['text'] }, provider: id.split('/')[0], owner: ownerOfRoute(route), efforts: [], nativeEndpoints: [], serviceTiers: [] });
|
|
146
|
+
entry.info = { ...withDefaultEffort(entry, this.runtime.store.value.defaultEfforts), provider: route };
|
|
147
|
+
return entry;
|
|
142
148
|
}
|
|
143
|
-
|
|
144
|
-
op.
|
|
145
|
-
|
|
146
|
-
|
|
149
|
+
async listModels(route) {
|
|
150
|
+
const op = await this.runtime.beginOperation();
|
|
151
|
+
try {
|
|
152
|
+
const owner = ownerOfRoute(route);
|
|
153
|
+
return visibleCatalog(op.gen.catalog.filter(m => m.owner === owner), this.runtime.store.value.visibleModels).map(m => ({ ...structuredClone(withDefaultEffort(m, this.runtime.store.value.defaultEfforts)), provider: route }));
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
op.release();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async resolveModel(route, id) {
|
|
147
160
|
const op = await this.runtime.beginOperation();
|
|
148
161
|
try {
|
|
149
|
-
|
|
150
|
-
return structuredClone(entry ? withDefaultEffort(entry, this.runtime.store.value.defaultEfforts) : { provider: 'dsh-magpie-connector', id, name: id });
|
|
162
|
+
return this.entryFor(op, route, id).info;
|
|
151
163
|
}
|
|
152
164
|
finally {
|
|
153
165
|
op.release();
|
|
154
166
|
}
|
|
155
167
|
}
|
|
156
|
-
async prepareCall(
|
|
168
|
+
async prepareCall(route, id, signal) {
|
|
157
169
|
const op = await this.runtime.beginOperation();
|
|
158
|
-
const entry =
|
|
159
|
-
entry.info = withDefaultEffort(entry, this.runtime.store.value.defaultEfforts);
|
|
170
|
+
const entry = this.entryFor(op, route, id);
|
|
160
171
|
// Prepared objects may be abandoned by SDK validation; retain only while dispatched.
|
|
161
172
|
op.release();
|
|
162
173
|
const untrack = this.runtime.trackPrepared(op);
|
|
@@ -230,7 +241,7 @@ export class MagpieAdapter extends LlmAdapter {
|
|
|
230
241
|
if (options.stop?.length)
|
|
231
242
|
throw new Error('MAGPIE_STOP_UNSUPPORTED');
|
|
232
243
|
const api = entry.nativeEndpoints.includes('/v1/responses') ? 'openai-responses' : 'openai-completions';
|
|
233
|
-
const model = { id: entry.info.id, name: entry.info.name, provider:
|
|
244
|
+
const model = { id: entry.info.id, name: entry.info.name, provider: TRANSPORT_ID, api, baseUrl: op.baseUrl, reasoning: entry.efforts.length > 0, thinkingLevelMap: { off: null, ...Object.fromEntries(entry.efforts.map(e => [e, e])) }, input: entry.info.inputModalities?.includes('image') ? ['text', 'image'] : ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: entry.info.context?.contextWindow ?? 128000, maxTokens: entry.outputLimit ?? 16384,
|
|
234
245
|
compat: { supportsFinishReason: true, thinkingFormat: 'openai', maxTokensField: 'max_completion_tokens', ...(api === 'openai-responses' ? { supportsStrictMode: true } : {}) } };
|
|
235
246
|
const context = await this.context(options, model);
|
|
236
247
|
const fast = !!options.sessionId && this.isFast(options.sessionId) && entry.serviceTiers.includes('priority');
|
package/lib/host/catalog.d.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import type { LlmResolvedModelInfo } from '@deepseek-ai/dsh-llm';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
/** Fixed pi-ai/replay transport identity. It is NOT a registered LLM route. */
|
|
3
|
+
export declare const TRANSPORT_ID = "dsh-magpie-connector";
|
|
4
|
+
/** Owner key of a gateway `owned_by` (untrusted): valid lower-cased value, else `other`. */
|
|
5
|
+
export declare function ownerKey(ownedBy: unknown): string;
|
|
6
|
+
/** Display name depends on the owner key alone, never on catalog content. */
|
|
7
|
+
export declare const ownerLabel: (key: string) => string;
|
|
8
|
+
export declare const routeOf: (key: string) => string;
|
|
9
|
+
export declare const isMagpieRoute: (id: string) => boolean;
|
|
10
|
+
/** Owner key of one of our routes; anything else (direct adapter calls) reads as `other`. */
|
|
11
|
+
export declare const ownerOfRoute: (route: string) => string;
|
|
8
12
|
/** The gateway's `max_output_tokens`, the model's reply capacity; `info.defaultMaxTokens` is the smaller per-request budget. */
|
|
9
13
|
export interface CatalogModel {
|
|
10
|
-
info:
|
|
11
|
-
provider: string;
|
|
14
|
+
info: LlmResolvedModelInfo; /** Visibility key: raw `owned_by`, else the id's first segment. */
|
|
15
|
+
provider: string; /** Owner key that selects the route. */
|
|
16
|
+
owner: string;
|
|
12
17
|
efforts: string[];
|
|
13
18
|
nativeEndpoints: string[];
|
|
14
19
|
serviceTiers: string[];
|
|
@@ -23,4 +28,3 @@ export declare const COMPACTION_HEADROOM_TOKENS = 65536;
|
|
|
23
28
|
export declare const defaultOutputBudget: (contextWindow: number | undefined, outputLimit: number) => number;
|
|
24
29
|
export declare const strings: (value: unknown) => string[];
|
|
25
30
|
export declare function mapCatalog(body: unknown): CatalogModel[];
|
|
26
|
-
export {};
|
package/lib/host/catalog.js
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
/** Fixed pi-ai/replay transport identity. It is NOT a registered LLM route. */
|
|
2
|
+
export const TRANSPORT_ID = 'dsh-magpie-connector';
|
|
3
|
+
const ROUTE_PREFIX = `${TRANSPORT_ID}-`;
|
|
4
|
+
const OWNER_KEY = /^[a-z0-9][a-z0-9._-]*$/;
|
|
5
|
+
const ROUTE = /^dsh-magpie-connector-[a-z0-9][a-z0-9._-]{0,47}$/;
|
|
6
|
+
/** Owner key of a gateway `owned_by` (untrusted): valid lower-cased value, else `other`. */
|
|
7
|
+
export function ownerKey(ownedBy) {
|
|
8
|
+
if (typeof ownedBy !== 'string')
|
|
9
|
+
return 'other';
|
|
10
|
+
const key = ownedBy.trim().toLowerCase();
|
|
11
|
+
return key.length >= 1 && key.length <= 48 && OWNER_KEY.test(key) ? key : 'other';
|
|
12
|
+
}
|
|
13
|
+
/** Display name depends on the owner key alone, never on catalog content. */
|
|
14
|
+
export const ownerLabel = (key) => key === 'other' ? 'Magpie (Other)' : `Magpie (${key.charAt(0).toUpperCase()}${key.slice(1)})`;
|
|
15
|
+
export const routeOf = (key) => `${ROUTE_PREFIX}${key}`;
|
|
16
|
+
export const isMagpieRoute = (id) => ROUTE.test(id);
|
|
17
|
+
/** Owner key of one of our routes; anything else (direct adapter calls) reads as `other`. */
|
|
18
|
+
export const ownerOfRoute = (route) => isMagpieRoute(route) ? route.slice(ROUTE_PREFIX.length) : 'other';
|
|
1
19
|
/**
|
|
2
20
|
* DSH compaction-basic's default `headroomTokens` (DSH 0.2.0-rc.2, lib/index.js) and Magpie's `dshHeadroom`.
|
|
3
21
|
* Re-check on every DSH upgrade: DSH reserves `defaultMaxTokens` plus this headroom out of the window before compacting.
|
|
@@ -18,16 +36,13 @@ export function mapCatalog(body) {
|
|
|
18
36
|
const ownedBy = typeof raw.owned_by === 'string' && raw.owned_by ? raw.owned_by : undefined;
|
|
19
37
|
const contextWindow = positive(raw.context_window), outputLimit = positive(raw.max_output_tokens);
|
|
20
38
|
const info = {
|
|
21
|
-
provider:
|
|
22
|
-
// Namespace raw owners so a real 'other' cannot collide with missing ownership.
|
|
23
|
-
displayGroup: ownedBy === undefined ? { id: 'other', label: 'Magpie (Other)' }
|
|
24
|
-
: { id: ownedBy === 'claude' || ownedBy === 'codex' ? ownedBy : `owned-by:${ownedBy}`, label: `Magpie (${ownedBy === 'claude' ? 'Claude' : ownedBy === 'codex' ? 'GPT' : ownedBy})` },
|
|
39
|
+
provider: TRANSPORT_ID, id: raw.id, name: typeof raw.display_name === 'string' && raw.display_name ? raw.display_name : raw.id,
|
|
25
40
|
inputModalities: strings(raw.modalities?.input).includes('image') ? ['text', 'image'] : ['text'],
|
|
26
41
|
...(contextWindow !== undefined ? { context: { contextWindow } } : {}),
|
|
27
42
|
...(outputLimit !== undefined ? { defaultMaxTokens: defaultOutputBudget(contextWindow, outputLimit) } : {}),
|
|
28
43
|
...(efforts.length ? { reasoning: { efforts: efforts.map(id => ({ id: id, name: id })) } } : {}),
|
|
29
44
|
};
|
|
30
|
-
return [{ info, provider: ownedBy ?? raw.id.split('/')[0], efforts, ...(outputLimit !== undefined ? { outputLimit } : {}),
|
|
45
|
+
return [{ info, provider: ownedBy ?? raw.id.split('/')[0], owner: ownerKey(raw.owned_by), efforts, ...(outputLimit !== undefined ? { outputLimit } : {}),
|
|
31
46
|
nativeEndpoints: strings(raw.native_endpoints), serviceTiers: Array.isArray(raw.service_tiers) ? raw.service_tiers.flatMap((t) => typeof t?.id === 'string' ? [t.id] : []) : [] }];
|
|
32
47
|
});
|
|
33
48
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { LlmAdapter } from '@deepseek-ai/dsh-llm';
|
|
2
|
+
/** Outcome of one host call; the registrar applies host semantics only, never owner policy. */
|
|
3
|
+
export type PublishResult = {
|
|
4
|
+
ok: true;
|
|
5
|
+
} | {
|
|
6
|
+
ok: false;
|
|
7
|
+
phase: 'initial' | 'replace';
|
|
8
|
+
code: string;
|
|
9
|
+
route?: string;
|
|
10
|
+
};
|
|
11
|
+
export interface RouteRegistrar {
|
|
12
|
+
/** Make `next` the full route set: first call registers, later calls `replace`. */
|
|
13
|
+
publish(next: string[]): PublishResult;
|
|
14
|
+
/** Routes the host currently holds for this plugin (as far as the plugin can know). */
|
|
15
|
+
current(): string[];
|
|
16
|
+
/** Set once the initial registration threw; the plugin must then never call the host again. */
|
|
17
|
+
fatal(): undefined | {
|
|
18
|
+
code: string;
|
|
19
|
+
route?: string;
|
|
20
|
+
};
|
|
21
|
+
/** Release the registration handle. May throw (host `INVARIANT`); callers isolate it. */
|
|
22
|
+
dispose(): void;
|
|
23
|
+
}
|
|
24
|
+
type Registry = {
|
|
25
|
+
registerAdapter(providers: string[], adapter: LlmAdapter): unknown;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* D6 host-call semantics:
|
|
29
|
+
* - initial `registerAdapter`: any throw is a fatal start-up state (the host may have committed routes without
|
|
30
|
+
* returning a handle, so nothing can be retried or undone) and the host is never called again;
|
|
31
|
+
* - `replace`: DUPLICATE_ADAPTER / INVALID_ADAPTER left the registry unchanged and are reported; INVARIANT means
|
|
32
|
+
* the host already committed the new set, so it counts as success;
|
|
33
|
+
* - after dispose every call is a no-op.
|
|
34
|
+
*/
|
|
35
|
+
export declare function createRouteRegistrar(llm: Registry, adapter: LlmAdapter): RouteRegistrar;
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const codeOf = (error) => String(error?.code ?? 'UNKNOWN');
|
|
2
|
+
const routeIn = (error) => /provider "([^"]+)"/.exec(String(error?.message ?? ''))?.[1];
|
|
3
|
+
/**
|
|
4
|
+
* D6 host-call semantics:
|
|
5
|
+
* - initial `registerAdapter`: any throw is a fatal start-up state (the host may have committed routes without
|
|
6
|
+
* returning a handle, so nothing can be retried or undone) and the host is never called again;
|
|
7
|
+
* - `replace`: DUPLICATE_ADAPTER / INVALID_ADAPTER left the registry unchanged and are reported; INVARIANT means
|
|
8
|
+
* the host already committed the new set, so it counts as success;
|
|
9
|
+
* - after dispose every call is a no-op.
|
|
10
|
+
*/
|
|
11
|
+
export function createRouteRegistrar(llm, adapter) {
|
|
12
|
+
let handle, held = [], fatal, disposed = false;
|
|
13
|
+
return {
|
|
14
|
+
publish(next) {
|
|
15
|
+
if (disposed)
|
|
16
|
+
return { ok: true };
|
|
17
|
+
if (fatal)
|
|
18
|
+
return { ok: false, phase: 'initial', ...fatal };
|
|
19
|
+
if (!handle) {
|
|
20
|
+
if (!next.length)
|
|
21
|
+
return { ok: true };
|
|
22
|
+
try {
|
|
23
|
+
handle = llm.registerAdapter(next, adapter);
|
|
24
|
+
held = [...next];
|
|
25
|
+
return { ok: true };
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
const route = routeIn(error);
|
|
29
|
+
fatal = { code: codeOf(error), ...(route ? { route } : {}) };
|
|
30
|
+
return { ok: false, phase: 'initial', ...fatal };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
handle.replace(next);
|
|
35
|
+
held = [...next];
|
|
36
|
+
return { ok: true };
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
const code = codeOf(error);
|
|
40
|
+
if (code === 'INVARIANT') {
|
|
41
|
+
held = [...next];
|
|
42
|
+
return { ok: true };
|
|
43
|
+
}
|
|
44
|
+
const route = routeIn(error);
|
|
45
|
+
return { ok: false, phase: 'replace', code, ...(route ? { route } : {}) };
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
current: () => [...held],
|
|
49
|
+
fatal: () => fatal,
|
|
50
|
+
dispose() {
|
|
51
|
+
disposed = true;
|
|
52
|
+
const release = handle;
|
|
53
|
+
handle = undefined;
|
|
54
|
+
release?.();
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
package/lib/host/runtime.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type CredentialProvider, type CredentialRef } from '@deepseek-ai/dsh-credentials';
|
|
2
|
-
import { SettingsStore, type AccessMode, type CredentialField, type LineSettings } from './settings.js';
|
|
2
|
+
import { SettingsStore, type AccessMode, type CredentialField, type LineSettings, type Settings } from './settings.js';
|
|
3
3
|
import { GatewayTransport, type ConnectionIdentity } from './transport.js';
|
|
4
4
|
import { LineSelector, type Probe } from './selection.js';
|
|
5
5
|
import { type CatalogModel } from './catalog.js';
|
|
@@ -42,7 +42,7 @@ export interface RuntimeOptions {
|
|
|
42
42
|
export declare class MagpieRuntime {
|
|
43
43
|
readonly credentials: Credentials;
|
|
44
44
|
readonly store: SettingsStore;
|
|
45
|
-
readonly publish: () => void;
|
|
45
|
+
readonly publish: (routes?: string[]) => void;
|
|
46
46
|
current: Generation;
|
|
47
47
|
readonly transport: GatewayTransport;
|
|
48
48
|
readonly selector: LineSelector;
|
|
@@ -54,12 +54,29 @@ export declare class MagpieRuntime {
|
|
|
54
54
|
private updating;
|
|
55
55
|
private disposed;
|
|
56
56
|
private saving;
|
|
57
|
+
/** Validated `knownOwners`; the route set is derived from it. */
|
|
58
|
+
private owners;
|
|
59
|
+
private knownOwnersReset;
|
|
60
|
+
private rejectedOwners;
|
|
61
|
+
/** Set once the initial route registration failed: no further owner admission or publishing (needs a Host restart). */
|
|
62
|
+
private registrationError;
|
|
57
63
|
private readonly newId;
|
|
58
64
|
selectedLineId: string | null;
|
|
59
|
-
constructor(credentials: Credentials, store?: SettingsStore, publish?: () => void, options?: RuntimeOptions);
|
|
65
|
+
constructor(credentials: Credentials, store?: SettingsStore, publish?: (routes?: string[]) => void, options?: RuntimeOptions);
|
|
66
|
+
/** Route ids of the persisted owners: the full set the adapter should own. */
|
|
67
|
+
routeSet(): string[];
|
|
68
|
+
private emit;
|
|
69
|
+
/**
|
|
70
|
+
* The only settings write path. Synchronous (no await inside), so two writes can never interleave;
|
|
71
|
+
* the base is the in-memory known fields AT CALL TIME (never a snapshot from before an await) and
|
|
72
|
+
* unknown fields (e.g. the legacy `baseUrl`) are not written back. Throws when the disk write fails,
|
|
73
|
+
* leaving the in-memory value unchanged.
|
|
74
|
+
*/
|
|
75
|
+
writeSettings(patch: {
|
|
76
|
+
[K in keyof Settings]?: Settings[K] | undefined;
|
|
77
|
+
}): void;
|
|
60
78
|
/** Strict load: any invalid persisted line/pending makes the whole connection read as empty. */
|
|
61
79
|
private loadConfig;
|
|
62
|
-
private cleanSettings;
|
|
63
80
|
start(): Promise<void>;
|
|
64
81
|
trackPrepared(op: Operation): () => void;
|
|
65
82
|
private resolveLine;
|
|
@@ -79,6 +96,8 @@ export declare class MagpieRuntime {
|
|
|
79
96
|
formatErrorBody(text: string): string;
|
|
80
97
|
syncCatalog(): Promise<void>;
|
|
81
98
|
private syncGeneration;
|
|
99
|
+
/** D6 owner admission + publish for one successfully mapped catalog (synchronous: no await between the checks and the effects). */
|
|
100
|
+
private commitOwners;
|
|
82
101
|
settings(body?: unknown): Promise<Response>;
|
|
83
102
|
/** Structured line-list update: the only settings write path (BREAKING schema). */
|
|
84
103
|
private updateLines;
|
package/lib/host/runtime.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
2
|
import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
|
3
|
-
import { MAX_LINES, SettingsStore, isAccessMode, normalizeBaseUrl, validateLoadedLines, validatePendingCredentials, } from './settings.js';
|
|
3
|
+
import { MAX_LINES, MAX_OWNERS, SettingsStore, isAccessMode, normalizeBaseUrl, validateLoadedLines, validateKnownOwners, validatePendingCredentials, } from './settings.js';
|
|
4
4
|
import { GatewayTransport, validateIdentity } from './transport.js';
|
|
5
5
|
import { LineSelector } from './selection.js';
|
|
6
|
-
import { mapCatalog } from './catalog.js';
|
|
6
|
+
import { mapCatalog, routeOf } from './catalog.js';
|
|
7
7
|
/** Credential references bound to a line's stable random id. */
|
|
8
8
|
export function lineCredentialRefs(lineId) {
|
|
9
9
|
const upper = lineId.toUpperCase();
|
|
@@ -51,6 +51,12 @@ export class MagpieRuntime {
|
|
|
51
51
|
updating = false;
|
|
52
52
|
disposed = false;
|
|
53
53
|
saving = false;
|
|
54
|
+
/** Validated `knownOwners`; the route set is derived from it. */
|
|
55
|
+
owners = [];
|
|
56
|
+
knownOwnersReset = false;
|
|
57
|
+
rejectedOwners = [];
|
|
58
|
+
/** Set once the initial route registration failed: no further owner admission or publishing (needs a Host restart). */
|
|
59
|
+
registrationError;
|
|
54
60
|
newId;
|
|
55
61
|
selectedLineId = null;
|
|
56
62
|
constructor(credentials, store = new SettingsStore(), publish = () => { }, options = {}) {
|
|
@@ -61,6 +67,46 @@ export class MagpieRuntime {
|
|
|
61
67
|
this.transport.onConnectionFailure = lineId => this.selector.invalidate(lineId);
|
|
62
68
|
this.newId = options.newId ?? randomHexId;
|
|
63
69
|
this.failedFields = this.loadConfig().pending;
|
|
70
|
+
try {
|
|
71
|
+
this.owners = validateKnownOwners(this.store.value.knownOwners);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
this.owners = [];
|
|
75
|
+
this.knownOwnersReset = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Route ids of the persisted owners: the full set the adapter should own. */
|
|
79
|
+
routeSet() { return this.owners.map(routeOf); }
|
|
80
|
+
emit(routes) {
|
|
81
|
+
if (this.disposed)
|
|
82
|
+
return undefined;
|
|
83
|
+
const result = this.publish(routes);
|
|
84
|
+
if (result && !result.ok && result.phase === 'initial')
|
|
85
|
+
this.registrationError ??= { code: result.code, ...(result.route ? { route: result.route } : {}) };
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The only settings write path. Synchronous (no await inside), so two writes can never interleave;
|
|
90
|
+
* the base is the in-memory known fields AT CALL TIME (never a snapshot from before an await) and
|
|
91
|
+
* unknown fields (e.g. the legacy `baseUrl`) are not written back. Throws when the disk write fails,
|
|
92
|
+
* leaving the in-memory value unchanged.
|
|
93
|
+
*/
|
|
94
|
+
writeSettings(patch) {
|
|
95
|
+
const value = this.store.value;
|
|
96
|
+
const next = {
|
|
97
|
+
lines: value.lines ?? [], visibleModels: value.visibleModels ?? {}, defaultEfforts: value.defaultEfforts ?? {},
|
|
98
|
+
...(value.credentialUpdatePending ? { credentialUpdatePending: value.credentialUpdatePending } : {}),
|
|
99
|
+
...(this.owners.length ? { knownOwners: this.owners } : {}),
|
|
100
|
+
};
|
|
101
|
+
for (const [key, field] of Object.entries(patch)) {
|
|
102
|
+
if (field === undefined)
|
|
103
|
+
delete next[key];
|
|
104
|
+
else
|
|
105
|
+
next[key] = field;
|
|
106
|
+
}
|
|
107
|
+
this.store.save(next);
|
|
108
|
+
if (patch.knownOwners)
|
|
109
|
+
this.knownOwnersReset = false;
|
|
64
110
|
}
|
|
65
111
|
/** Strict load: any invalid persisted line/pending makes the whole connection read as empty. */
|
|
66
112
|
loadConfig() {
|
|
@@ -71,10 +117,7 @@ export class MagpieRuntime {
|
|
|
71
117
|
return { lines: [], pending: [] };
|
|
72
118
|
}
|
|
73
119
|
}
|
|
74
|
-
|
|
75
|
-
return { lines, visibleModels: this.store.value.visibleModels ?? {}, defaultEfforts: this.store.value.defaultEfforts ?? {}, ...(pending ? { credentialUpdatePending: pending } : {}) };
|
|
76
|
-
}
|
|
77
|
-
async start() { await this.syncCatalog(); }
|
|
120
|
+
async start() { this.emit(this.routeSet()); await this.syncCatalog(); }
|
|
78
121
|
trackPrepared(op) {
|
|
79
122
|
const ref = new WeakRef(op);
|
|
80
123
|
this.prepared.add(ref);
|
|
@@ -107,7 +150,7 @@ export class MagpieRuntime {
|
|
|
107
150
|
catalog: carry?.catalog ? [...carry.catalog] : [], bgAbort: new AbortController(),
|
|
108
151
|
...(carry?.lastSyncAt ? { lastSyncAt: carry.lastSyncAt } : {}),
|
|
109
152
|
};
|
|
110
|
-
this.
|
|
153
|
+
this.emit();
|
|
111
154
|
}
|
|
112
155
|
identityOf(resolved) {
|
|
113
156
|
if (!resolved)
|
|
@@ -266,7 +309,7 @@ export class MagpieRuntime {
|
|
|
266
309
|
gen.lastSyncAt = new Date().toISOString();
|
|
267
310
|
delete gen.lastSyncError;
|
|
268
311
|
delete gen.lastSyncErrorKind;
|
|
269
|
-
this.
|
|
312
|
+
this.commitOwners(gen, catalog);
|
|
270
313
|
}
|
|
271
314
|
catch (error) {
|
|
272
315
|
if (!this.isCurrent(gen))
|
|
@@ -283,6 +326,47 @@ export class MagpieRuntime {
|
|
|
283
326
|
})();
|
|
284
327
|
return gen.syncPromise;
|
|
285
328
|
}
|
|
329
|
+
/** D6 owner admission + publish for one successfully mapped catalog (synchronous: no await between the checks and the effects). */
|
|
330
|
+
commitOwners(gen, catalog) {
|
|
331
|
+
if (this.disposed || !this.isCurrent(gen))
|
|
332
|
+
return;
|
|
333
|
+
const fresh = [...new Set(catalog.map(m => m.owner))].filter(owner => !this.owners.includes(owner));
|
|
334
|
+
const admit = fresh.slice(0, Math.max(0, MAX_OWNERS - this.owners.length));
|
|
335
|
+
this.rejectedOwners = fresh.slice(admit.length);
|
|
336
|
+
if (this.registrationError)
|
|
337
|
+
return;
|
|
338
|
+
const previous = this.owners;
|
|
339
|
+
let added = false;
|
|
340
|
+
if (admit.length) {
|
|
341
|
+
try {
|
|
342
|
+
this.writeSettings({ knownOwners: [...previous, ...admit] });
|
|
343
|
+
this.owners = [...previous, ...admit];
|
|
344
|
+
added = true;
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
gen.lastSyncErrorKind = 'settings';
|
|
348
|
+
gen.lastSyncError = 'MAGPIE_SETTINGS_WRITE_FAILED: could not save known owners';
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const result = this.emit(this.routeSet());
|
|
352
|
+
if (!result || result.ok)
|
|
353
|
+
return;
|
|
354
|
+
const rollback = () => {
|
|
355
|
+
if (!added)
|
|
356
|
+
return;
|
|
357
|
+
this.owners = previous;
|
|
358
|
+
try {
|
|
359
|
+
this.writeSettings({ knownOwners: previous.length ? previous : undefined });
|
|
360
|
+
}
|
|
361
|
+
catch { /* reported below; memory stays rolled back */ }
|
|
362
|
+
};
|
|
363
|
+
rollback();
|
|
364
|
+
if (result.phase === 'initial')
|
|
365
|
+
return;
|
|
366
|
+
gen.lastSyncErrorKind = 'registration';
|
|
367
|
+
gen.lastSyncError = `MAGPIE_ROUTE_REGISTRATION_FAILED: ${result.code}${result.route ? ` ${result.route}` : ''}`;
|
|
368
|
+
this.emit(this.routeSet());
|
|
369
|
+
}
|
|
286
370
|
// ---- settings surface ---------------------------------------------------
|
|
287
371
|
async settings(body) {
|
|
288
372
|
if (body !== undefined) {
|
|
@@ -425,7 +509,7 @@ export class MagpieRuntime {
|
|
|
425
509
|
try {
|
|
426
510
|
// Marker saves keep the committed line list; a failed write must not apply the new lines.
|
|
427
511
|
if (pending.length)
|
|
428
|
-
this.
|
|
512
|
+
this.writeSettings({ lines: existing, credentialUpdatePending: pending });
|
|
429
513
|
for (const write of writes) {
|
|
430
514
|
const ref = lineCredentialRefs(write.lineId)[write.field];
|
|
431
515
|
if (write.value === null)
|
|
@@ -445,7 +529,7 @@ export class MagpieRuntime {
|
|
|
445
529
|
await this.credentials.unset(ref);
|
|
446
530
|
}
|
|
447
531
|
this.retained = retained;
|
|
448
|
-
this.
|
|
532
|
+
this.writeSettings({ lines: nextLines, credentialUpdatePending: undefined });
|
|
449
533
|
this.failedFields = [];
|
|
450
534
|
}
|
|
451
535
|
catch {
|
|
@@ -505,16 +589,18 @@ export class MagpieRuntime {
|
|
|
505
589
|
lastSyncAt: gen.lastSyncAt, lastSyncError: gen.lastSyncError, lastSyncErrorKind: gen.lastSyncErrorKind,
|
|
506
590
|
groups: [...new Set(gen.catalog.map(m => m.provider))].map(provider => ({ provider, models: gen.catalog.filter(m => m.provider === provider).map(m => ({ id: m.info.id, name: m.info.name, efforts: m.efforts })) })),
|
|
507
591
|
visibleModels: this.store.value.visibleModels, defaultEfforts: this.store.value.defaultEfforts,
|
|
592
|
+
knownOwnersReset: this.knownOwnersReset, ownerLimitReached: this.rejectedOwners.length > 0, rejectedOwners: this.rejectedOwners,
|
|
593
|
+
...(this.registrationError ? { routeRegistrationError: this.registrationError } : {}),
|
|
508
594
|
}));
|
|
509
595
|
}
|
|
510
596
|
/** Preference-only writes also persist the single clean schema (drops legacy fields). */
|
|
511
597
|
savePreferences(patch) {
|
|
512
598
|
const config = this.loadConfig();
|
|
513
|
-
this.
|
|
599
|
+
this.writeSettings({
|
|
514
600
|
lines: config.lines,
|
|
515
601
|
visibleModels: patch.visibleModels ?? this.store.value.visibleModels ?? {},
|
|
516
602
|
defaultEfforts: patch.defaultEfforts ?? this.store.value.defaultEfforts ?? {},
|
|
517
|
-
|
|
603
|
+
credentialUpdatePending: this.failedFields.length ? this.failedFields : undefined,
|
|
518
604
|
});
|
|
519
605
|
}
|
|
520
606
|
config() { return this.loadConfig().lines; }
|
package/lib/host/settings.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface Settings {
|
|
|
20
20
|
visibleModels: Record<string, string[]>;
|
|
21
21
|
defaultEfforts: Record<string, string>;
|
|
22
22
|
credentialUpdatePending?: PendingCredential[];
|
|
23
|
+
/** Owner keys seen so far (first-seen order, max {@link MAX_OWNERS}); each owns one registered LLM route. */
|
|
24
|
+
knownOwners?: string[];
|
|
23
25
|
}
|
|
24
26
|
export declare class SettingsStore {
|
|
25
27
|
readonly path?: string | undefined;
|
|
@@ -29,6 +31,7 @@ export declare class SettingsStore {
|
|
|
29
31
|
}
|
|
30
32
|
export declare function normalizeBaseUrl(value: unknown): string;
|
|
31
33
|
export declare const MAX_LINES = 5;
|
|
34
|
+
export declare const MAX_OWNERS = 32;
|
|
32
35
|
export declare const LINE_ID_PATTERN: RegExp;
|
|
33
36
|
export declare const isAccessMode: (value: unknown) => value is AccessMode;
|
|
34
37
|
export declare const isCredentialField: (value: unknown) => value is CredentialField;
|
|
@@ -43,3 +46,8 @@ export declare function validateLoadedLines(value: unknown): LineSettings[];
|
|
|
43
46
|
export declare function validatePendingCredentials(value: unknown): PendingCredential[];
|
|
44
47
|
/** Host-generated opaque stable line id: 8 lowercase hex, unique within the supplied set. */
|
|
45
48
|
export declare function randomLineId(existing: Iterable<string>): string;
|
|
49
|
+
/**
|
|
50
|
+
* Load-time check of `knownOwners`: missing = empty; anything else invalid (not an array, bad key,
|
|
51
|
+
* duplicate, more than {@link MAX_OWNERS}) throws so the caller resets the WHOLE array (no partial repair).
|
|
52
|
+
*/
|
|
53
|
+
export declare function validateKnownOwners(value: unknown): string[];
|
package/lib/host/settings.js
CHANGED
|
@@ -28,6 +28,8 @@ export function normalizeBaseUrl(value) {
|
|
|
28
28
|
return url.href.replace(/\/+$/, '');
|
|
29
29
|
}
|
|
30
30
|
export const MAX_LINES = 5;
|
|
31
|
+
export const MAX_OWNERS = 32;
|
|
32
|
+
const OWNER_KEY = /^[a-z0-9][a-z0-9._-]*$/;
|
|
31
33
|
export const LINE_ID_PATTERN = /^[0-9a-f]{8}$/;
|
|
32
34
|
export const isAccessMode = (value) => value === 'direct' || value === 'cloudflare';
|
|
33
35
|
export const isCredentialField = (value) => value === 'key' || value === 'accessClientId' || value === 'accessClientSecret';
|
|
@@ -95,3 +97,19 @@ export function randomLineId(existing) {
|
|
|
95
97
|
return id;
|
|
96
98
|
}
|
|
97
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Load-time check of `knownOwners`: missing = empty; anything else invalid (not an array, bad key,
|
|
102
|
+
* duplicate, more than {@link MAX_OWNERS}) throws so the caller resets the WHOLE array (no partial repair).
|
|
103
|
+
*/
|
|
104
|
+
export function validateKnownOwners(value) {
|
|
105
|
+
if (value === undefined)
|
|
106
|
+
return [];
|
|
107
|
+
if (!Array.isArray(value) || value.length > MAX_OWNERS)
|
|
108
|
+
throw new Error('Invalid knownOwners');
|
|
109
|
+
for (const key of value)
|
|
110
|
+
if (typeof key !== 'string' || key.length < 1 || key.length > 48 || !OWNER_KEY.test(key))
|
|
111
|
+
throw new Error('Invalid knownOwners');
|
|
112
|
+
if (new Set(value).size !== value.length)
|
|
113
|
+
throw new Error('Invalid knownOwners');
|
|
114
|
+
return [...value];
|
|
115
|
+
}
|
package/lib/host/transport.js
CHANGED
|
@@ -18,7 +18,8 @@ export class GatewayTransport {
|
|
|
18
18
|
signal = new AbortController();
|
|
19
19
|
/** Called when a request fails before the connection was established. */
|
|
20
20
|
onConnectionFailure;
|
|
21
|
-
|
|
21
|
+
// undici's default 10s connect timeout fails cold TLS handshakes to the Cloudflare ingress.
|
|
22
|
+
agent = new Agent({ connect: { timeout: 25_000 } });
|
|
22
23
|
async request(identity, method, path, init = {}) {
|
|
23
24
|
validateIdentity(identity);
|
|
24
25
|
const base = new URL(identity.baseUrl);
|
package/lib/index.d.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis';
|
|
2
2
|
import { MagpieRuntime } from './host/runtime.js';
|
|
3
3
|
import { MagpieAdapter } from './host/adapter.js';
|
|
4
|
+
import { type RouteRegistrar } from './host/registrar.js';
|
|
4
5
|
export { MagpieRuntime, lineCredentialRefs, lineIdOfCredentialRef } from './host/runtime.js';
|
|
5
6
|
export { MagpieAdapter, mapUsage } from './host/adapter.js';
|
|
6
7
|
export { SettingsStore } from './host/settings.js';
|
|
7
8
|
export { GatewayTransport } from './host/transport.js';
|
|
9
|
+
export { createRouteRegistrar, type RouteRegistrar, type PublishResult } from './host/registrar.js';
|
|
10
|
+
export { TRANSPORT_ID, ownerKey, ownerLabel, routeOf, isMagpieRoute } from './host/catalog.js';
|
|
8
11
|
export declare const name = "dsh-magpie-connector";
|
|
9
12
|
export declare const inject: string[];
|
|
10
13
|
export interface Config {
|
|
@@ -13,4 +16,5 @@ export interface Config {
|
|
|
13
16
|
export declare function apply(ctx: Context, config?: Config): {
|
|
14
17
|
runtime: MagpieRuntime;
|
|
15
18
|
adapter: MagpieAdapter;
|
|
19
|
+
routes: RouteRegistrar;
|
|
16
20
|
};
|
package/lib/index.js
CHANGED
|
@@ -5,24 +5,31 @@ import { MagpieAdapter } from './host/adapter.js';
|
|
|
5
5
|
import { SettingsStore } from './host/settings.js';
|
|
6
6
|
import { registerRoutes } from './host/routes.js';
|
|
7
7
|
import { SpeedController } from './host/speed.js';
|
|
8
|
+
import { createDisposer } from './host/dispose.js';
|
|
9
|
+
import { createRouteRegistrar } from './host/registrar.js';
|
|
8
10
|
export { MagpieRuntime, lineCredentialRefs, lineIdOfCredentialRef } from './host/runtime.js';
|
|
9
11
|
export { MagpieAdapter, mapUsage } from './host/adapter.js';
|
|
10
12
|
export { SettingsStore } from './host/settings.js';
|
|
11
13
|
export { GatewayTransport } from './host/transport.js';
|
|
14
|
+
export { createRouteRegistrar } from './host/registrar.js';
|
|
15
|
+
export { TRANSPORT_ID, ownerKey, ownerLabel, routeOf, isMagpieRoute } from './host/catalog.js';
|
|
12
16
|
export const name = 'dsh-magpie-connector';
|
|
13
17
|
export const inject = ['llm', 'credentials'];
|
|
14
18
|
export function apply(ctx, config = {}) {
|
|
15
19
|
const home = config.home ?? process.env.DSH_HOME ?? resolve(homedir(), '.dsh');
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
// Declared first, assigned below: the runtime's publish callback and the adapter reference each other.
|
|
21
|
+
let adapter;
|
|
22
|
+
let registrar;
|
|
23
|
+
const runtime = new MagpieRuntime(ctx.credentials, new SettingsStore(resolve(home, 'plugins/dsh-magpie-connector/settings.json')), routes => registrar.publish(routes ?? runtime.routeSet()));
|
|
18
24
|
const speed = new SpeedController();
|
|
19
|
-
|
|
20
|
-
|
|
25
|
+
adapter = new MagpieAdapter(runtime, ctx.get('attachments'), session => speed.get(session).tier === 'fast');
|
|
26
|
+
registrar = createRouteRegistrar(ctx.llm, adapter);
|
|
21
27
|
ctx.on('credentials/reference-updated', ref => runtime.invalidateCredential(ref));
|
|
22
28
|
ctx.inject(['connection'], child => {
|
|
23
29
|
child.effect(() => registerRoutes(child.connection.fetch, runtime, speed).dispose);
|
|
24
30
|
});
|
|
25
|
-
|
|
31
|
+
const dispose = createDisposer(runtime, [() => registrar.dispose()]);
|
|
32
|
+
ctx.effect(() => dispose);
|
|
26
33
|
void runtime.start().catch(() => { });
|
|
27
|
-
return { runtime, adapter };
|
|
34
|
+
return { runtime, adapter, routes: registrar };
|
|
28
35
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-magpie-connector",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "DeepSeek Harness plugin: connect a self-hosted Magpie gateway
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "DeepSeek Harness plugin: connect a self-hosted Magpie gateway with one real provider route per upstream owner (grouped model picker on stock DSH), automatic multi-line failover, read-only usage cards, model visibility, default effort and per-session fast mode",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "prgrmrwy",
|
|
7
7
|
"repository": {
|
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
},
|
|
72
72
|
"devDependencies": {
|
|
73
73
|
"@deepseek-ai/cordis": "^4.0.4",
|
|
74
|
+
"@deepseek-ai/dsh-api-session-controller": "^0.2.0-rc.2",
|
|
74
75
|
"@deepseek-ai/dsh-attachment": "^0.2.0-rc.2",
|
|
75
76
|
"@deepseek-ai/dsh-client-connection": "^0.2.0-rc.2",
|
|
76
77
|
"@deepseek-ai/dsh-client-ui-renderer": "^0.2.0-rc.2",
|
package/screenshots.json
CHANGED
package/docs/model-picker.gif
DELETED
|
Binary file
|