dsh-newapi-provider 0.8.3 → 0.9.4
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/PUBLISHING.md +13 -8
- package/README.md +77 -33
- package/README.zh.md +85 -52
- package/client.js +193 -2
- package/index.js +6 -1
- package/lib/adapter.js +15 -4
- package/lib/catalog.js +31 -4
- package/lib/channels.js +16 -1
- package/lib/levels.js +235 -0
- package/package.json +1 -1
package/PUBLISHING.md
CHANGED
|
@@ -6,13 +6,15 @@
|
|
|
6
6
|
|
|
7
7
|
| 项目 | 状态 |
|
|
8
8
|
| --- | --- |
|
|
9
|
-
|
|
|
10
|
-
|
|
|
9
|
+
| npm | **已发布 0.8.3** — https://www.npmjs.com/package/dsh-newapi-provider (`latest`,2026-10-23 17:48 东八区) |
|
|
10
|
+
| 包内容校验 | 下载 tarball 与仓库逐文件字节一致,19 个文件,shasum 一致,无凭据泄漏 |
|
|
11
|
+
| Git tag | `v0.8.3` 已推送 |
|
|
11
12
|
| 仓库 | https://github.com/zenVibeCoding/dsh-newapi-provider (公开,已加 `dsh-plugin` topic) |
|
|
13
|
+
| 市场投稿 | **未提交**,条目文件已备好:[docs/market-entry.yml](docs/market-entry.yml),卡片图 [assets/icon-180.png](assets/icon-180.png) |
|
|
14
|
+
| 可投稿时间 | 仓库创建 2026-10-23 07:01 UTC,满 24 小时 = **2026-10-24 15:01(东八区)之后** |
|
|
12
15
|
| 许可证 | MIT,作者 `zenVibeCoding` |
|
|
13
16
|
| 验证环境 | DSH 0.2.1-rc.2 / Node.js 22.19+,`engines.dsh: >=0.2.1-rc.1 <0.3.0-0` |
|
|
14
17
|
| 离线测试 | `npm test`,103 项全过 |
|
|
15
|
-
| 打包 | 19 个文件,无凭据/本机路径 |
|
|
16
18
|
| 真实生图 | **未验证**(网关返回 503 无可用渠道) |
|
|
17
19
|
| 真实视频 | **未验证**(无视频渠道),工具为实验性 |
|
|
18
20
|
|
|
@@ -85,25 +87,28 @@ dsh plugin add dsh-newapi-provider # 走 npm 而非本地目录
|
|
|
85
87
|
|
|
86
88
|
**时间门槛**:仓库创建于 2026-10-23 07:01 UTC,需满 24 小时,即 **2026-10-24 15:01(东八区)之后**再提 PR。
|
|
87
89
|
|
|
88
|
-
|
|
90
|
+
条目已经写好并校验通过:**[docs/market-entry.yml](docs/market-entry.yml)**(用 js-yaml 解析验证过,分类合法、描述以句号结尾)。目标路径是 `data/plugins/zenVibeCoding__dsh-newapi-provider.yml`,直接复制即可:
|
|
89
91
|
|
|
90
92
|
```yaml
|
|
91
93
|
url: https://github.com/zenVibeCoding/dsh-newapi-provider
|
|
92
94
|
name: zenVibeCoding/dsh-newapi-provider
|
|
93
95
|
category: model
|
|
94
96
|
description:
|
|
95
|
-
en: 'New API gateway provider:
|
|
96
|
-
zh: 'New API
|
|
97
|
+
en: 'New API gateway provider: several gateways with one active at a time, per-gateway model lists with context and modality details, quota, and image and video generation tools.'
|
|
98
|
+
zh: 'New API 网关提供商:多网关同一时间激活一个,每个网关独立的模型清单与上下文/多模态标注、额度查询,以及生图与视频生成工具。'
|
|
99
|
+
icon: https://raw.githubusercontent.com/zenVibeCoding/dsh-newapi-provider/master/assets/icon-180.png
|
|
97
100
|
```
|
|
98
101
|
|
|
99
|
-
> 描述里有 `: ` 必须加引号,否则 YAML
|
|
102
|
+
> 描述里有 `: ` 必须加引号,否则 YAML 解析失败(上面英文描述就是这种情况)。
|
|
103
|
+
> 卡片图是 `assets/icon-180.png`(180×180 PNG),市场要求 GitHub 托管的位图;仓库里的 `icon.svg` 是 DSH 插件页用的,不适用于市场卡片。
|
|
100
104
|
|
|
101
105
|
提交步骤:
|
|
102
106
|
|
|
103
107
|
```sh
|
|
104
108
|
gh repo fork awesome-dsh-plugin/awesome-dsh-plugin --clone
|
|
105
109
|
cd awesome-dsh-plugin
|
|
106
|
-
|
|
110
|
+
mkdir -p data/plugins
|
|
111
|
+
cp ~/dsh-newapi-provider/docs/market-entry.yml data/plugins/zenVibeCoding__dsh-newapi-provider.yml
|
|
107
112
|
git checkout -b add-dsh-newapi-provider
|
|
108
113
|
git add data/plugins/zenVibeCoding__dsh-newapi-provider.yml
|
|
109
114
|
git commit -m "Add dsh-newapi-provider"
|
package/README.md
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
English · [中文](README.zh.md)
|
|
4
4
|
|
|
5
|
-
A New API gateway workbench for DeepSeek Harness: several gateways with one active at a time, per-gateway model lists and parameter overrides, quota, and image and experimental video generation.
|
|
5
|
+
A New API gateway workbench for DeepSeek Harness: several gateways with one active at a time, per-gateway model lists and parameter overrides, quota, and image and experimental video generation.
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/dsh-newapi-provider)
|
|
8
|
+
[](LICENSE)
|
|
6
9
|
|
|
7
10
|

|
|
8
11
|
|
|
@@ -16,54 +19,52 @@ A New API gateway workbench for DeepSeek Harness: several gateways with one acti
|
|
|
16
19
|
|
|
17
20
|
The screenshots render this plugin's real components and stylesheet with sample data; gateway names, URLs and amounts are examples. The UI follows the DSH theme, so colours adapt to your skin and dark mode.
|
|
18
21
|
|
|
19
|
-
##
|
|
20
|
-
|
|
21
|
-
- Save multiple gateways and activate exactly one. Each keeps its own URL, credential reference, model list and model overrides.
|
|
22
|
-
- Channel-scoped model management: fetch what a gateway lists, choose which models appear in DSH, test one model, remove with undo, add models the gateway does not list.
|
|
23
|
-
- Capability data from models.dev (context window, output cap, modalities, reasoning), correctable per model, with an optional per-model upstream name mapping.
|
|
24
|
-
- Token quota plus account balance when the token is allowed to query it; unlimited tokens show used quota instead of a negative remainder.
|
|
25
|
-
- Generate images as durable DSH attachments; experimental video through the unified `/v1/videos` interface with background polling and restart recovery.
|
|
26
|
-
|
|
27
|
-
## Requirements and local installation
|
|
22
|
+
## Install
|
|
28
23
|
|
|
29
|
-
|
|
24
|
+
Requires **DSH 0.2.1-rc.1 or newer** (verified on 0.2.1-rc.2) and Node.js 22.19+.
|
|
30
25
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
## Migrating from the former name (v0.7.0)
|
|
26
|
+
Install `dsh-newapi-provider` with DSH's plugin manager, then refresh the page and open **Settings → New API Provider**. No separate web server is needed.
|
|
34
27
|
|
|
35
|
-
|
|
28
|
+
1. **Add gateway** — name, New API site URL, calling token.
|
|
29
|
+
2. **Fetch models** — read what the gateway lists, select the ones you want.
|
|
30
|
+
3. **Activate the gateway** — this plugin's model route now serves those models.
|
|
36
31
|
|
|
37
|
-
|
|
32
|
+
The token is stored in the DSH credential service, never in the gateway configuration, and is never sent back to the browser.
|
|
38
33
|
|
|
39
|
-
|
|
34
|
+
## Features
|
|
40
35
|
|
|
41
|
-
|
|
36
|
+
- Several gateways, exactly one active; each keeps its own URL, credential reference, model list and model overrides.
|
|
37
|
+
- Channel-scoped model management: fetch what a gateway lists, choose which models appear in DSH, test one model, remove with undo, add models the gateway does not list.
|
|
38
|
+
- Capability data from models.dev (context window, output cap, modalities, reasoning), correctable per model, with an optional per-model upstream name mapping.
|
|
39
|
+
- Token quota in the settings summary and at the sidebar foot, plus the account balance when the token is allowed to query it.
|
|
40
|
+
- Image generation saved as durable DSH attachments with a generation history; experimental video through the unified `/v1/videos` interface with background polling and restart recovery.
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
2. **Test connection** reads the model list only and does not make paid model calls; it also shows the gateway version and model counts.
|
|
45
|
-
3. **Activate** switches every subsequent call made through this plugin, or tick "activate after saving".
|
|
46
|
-
4. Switching never rewrites other adapters or your default provider.
|
|
47
|
-
5. Submitted video tasks keep their originating gateway and are never polled on a newly activated one.
|
|
42
|
+
## Gateways and quota
|
|
48
43
|
|
|
49
|
-
|
|
44
|
+
1. **Test connection** reads the model list only and does not make paid model calls; it also shows the gateway version and model counts.
|
|
45
|
+
2. **Activate** switches every subsequent call made through this plugin, or tick "activate after saving".
|
|
46
|
+
3. Switching never rewrites other adapters or your default provider.
|
|
47
|
+
4. Submitted video tasks keep their originating gateway and are never polled on a newly activated one.
|
|
50
48
|
|
|
51
|
-
|
|
49
|
+
Quota details:
|
|
52
50
|
|
|
53
51
|
- Quota comes from `GET /api/usage/token/` (note the trailing slash).
|
|
54
52
|
- Amounts are converted with `/api/status` (`quota_per_unit`, `quota_display_type`, `usd_exchange_rate`), never the top-up price.
|
|
55
53
|
- A token that allows user-quota queries also reports the account balance.
|
|
54
|
+
- An unlimited token shows used quota instead of a meaningless negative remainder.
|
|
56
55
|
- Both numbers are the site's own; a present catalog or balance does not prove an upstream channel can serve a model.
|
|
57
56
|
|
|
57
|
+
The sidebar shows the active gateway's name and amount, with a health dot; click it to refresh. It collapses to a status dot when the sidebar is a rail.
|
|
58
|
+
|
|
58
59
|
## Per-gateway model lists
|
|
59
60
|
|
|
60
61
|
Each gateway keeps an independent list, even when two gateways point at the same site with the same token:
|
|
61
62
|
|
|
62
63
|
- **Model capabilities tab** has a gateway picker; the active gateway is marked and its list edits apply to that gateway only.
|
|
63
|
-
- **Fetch models** reads the gateway catalog with a search box plus status (new / added / removed upstream) and kind filters; filtering only changes what is listed, never the current selection. The list scrolls on its own and the action bar stays in view.
|
|
64
|
+
- **Fetch models** reads the gateway catalog with a search box plus status (new / added / removed upstream) and kind filters, grouped by kind; filtering only changes what is listed, never the current selection. The list scrolls on its own and the action bar stays in view.
|
|
64
65
|
- **Remove** only removes the model from this gateway; custom parameters can be kept and the removal can be undone briefly.
|
|
65
66
|
- **Add manually** is for models the gateway can serve but does not list.
|
|
66
|
-
- Gateways created in
|
|
67
|
+
- Gateways created in current versions start with an empty list; channels saved before the feature existed stay in automatic mode (all gateway models) until their first edit, which materialises the list from what the gateway currently lists.
|
|
67
68
|
|
|
68
69
|
## Testing models
|
|
69
70
|
|
|
@@ -79,7 +80,38 @@ Each gateway keeps an independent list, even when two gateways point at the same
|
|
|
79
80
|
- **Upstream model name** (`upstreamModel`): the `model` actually sent. Empty means the local id. Mapping is one hop and applies to chat, image generation, image edits, video and model tests.
|
|
80
81
|
- The editor previews the mapping, resolves automatic protocol/capabilities/pricing from the mapping target, and lets input modalities and reasoning follow the catalog or be pinned. Reset clears every override including the mapping.
|
|
81
82
|
|
|
82
|
-
Capability data is a hint, not proof: a relay may list a model and still fail to serve it.
|
|
83
|
+
Capability data is a hint, not proof: a relay may list a model and still fail to serve it.
|
|
84
|
+
|
|
85
|
+
## Thinking levels
|
|
86
|
+
|
|
87
|
+
Vendors do not agree on reasoning levels, so each model resolves its own set instead of every reasoning model showing the same five:
|
|
88
|
+
|
|
89
|
+
| Vendor / model | Levels offered | Notes |
|
|
90
|
+
| --- | --- | --- |
|
|
91
|
+
| Claude adaptive thinking (Opus 5 / Sonnet 5 / Fable) | low / medium / high / xhigh / max | no `minimal`, no `off` |
|
|
92
|
+
| Older Claude (budget based) | off / minimal / low / medium / high | mapped to token budgets |
|
|
93
|
+
| GPT-5.1+ / GPT-6 | off / low / medium / high / xhigh / max | `off` is sent as `none`, no `minimal` |
|
|
94
|
+
| GPT-5.0 / o-series | minimal / low / medium / high | GPT-5.0 cannot be turned off; o-series stops at high |
|
|
95
|
+
| gpt-5-pro | high only | the vendor accepts nothing else |
|
|
96
|
+
| Gemini 3 / 4 | minimal / low / medium / high | `thinking_level` has no off; no xhigh/max |
|
|
97
|
+
| Gemini 2.5 | off / minimal / low / medium / high | budget based; Pro cannot really be switched off |
|
|
98
|
+
| DeepSeek V4 | off / low / high / max | the vendor folds the in-between levels |
|
|
99
|
+
| GLM-5.3 | low / high / max | sending `disabled` errors, so no off |
|
|
100
|
+
| GLM-5.2 | all seven | most levels are folded upstream |
|
|
101
|
+
| Kimi K3 | low / high / max | no medium, cannot be switched off |
|
|
102
|
+
| Qwen3 / unknown | off / minimal / low / medium / high | pi-ai's own default when a model has no levels |
|
|
103
|
+
|
|
104
|
+
Resolution order: **your override → pi-ai's shipped model data (same model, same behaviour as the built-in provider) → vendor-family default**.
|
|
105
|
+
|
|
106
|
+
**Model capabilities → Edit model → Thinking levels** opens on a table pre-filled from the model data (turn the switch off to see the detected set read-only). The left column is the level shown in the chat selector — the built-ins (off/minimal/low/medium/high/xhigh/max) or a name you type yourself (1–24 lowercase letters, digits, `-`, `_`); a custom level must carry a value and then appears in the chat selector like any other. The right column is what that level sends upstream; an empty value sends nothing. The mapping is stored per model per gateway and affects only this plugin.
|
|
107
|
+
|
|
108
|
+
> Limitation: relays rename models (`claude-opus-5-5`), so a family guess fills the gap when the exact id is unknown. Use a custom override if the upstream disagrees. A level only changes the request; whether the upstream honours that depth is up to it.
|
|
109
|
+
|
|
110
|
+
## Models and modalities
|
|
111
|
+
|
|
112
|
+
models.dev data is cached. Exact matches win; when nothing matches, defaults apply. Base-model matches, defaults and manual overrides are all marked in the UI. Never treat a guessed capability as something the gateway has proven.
|
|
113
|
+
|
|
114
|
+
The DSH chat interface currently accepts text and image input only. PDF, audio and video tags are catalog metadata and cannot be sent to a DSH chat. Image and video models serve the generation tools and do not appear as chat models.
|
|
83
115
|
|
|
84
116
|
## Tools
|
|
85
117
|
|
|
@@ -94,7 +126,9 @@ Capability data is a hint, not proof: a relay may list a model and still fail to
|
|
|
94
126
|
|
|
95
127
|
Settings-page actions and these tools share one service, so nothing can drift between the UI and the agent.
|
|
96
128
|
|
|
97
|
-
|
|
129
|
+
**Image and video generation cost money.** A submission whose outcome is unknown is never resent automatically; check the gateway's task log first. Stopping a local watch does not cancel or refund the upstream task.
|
|
130
|
+
|
|
131
|
+
## Paid results
|
|
98
132
|
|
|
99
133
|
- A video submission that loses the connection or returns an unreadable body after HTTP 200 is reported as **submission unknown, possibly billed**, and is never resent automatically.
|
|
100
134
|
- A video that finished upstream but failed to download stays **generated, retrying download**. Only GET requests are retried, against refreshed URLs; when the recovery deadline passes it becomes **generated, download unsuccessful**. Do not regenerate.
|
|
@@ -103,10 +137,18 @@ Settings-page actions and these tools share one service, so nothing can drift be
|
|
|
103
137
|
|
|
104
138
|
## Verification status
|
|
105
139
|
|
|
106
|
-
- Real chat, model discovery, quota
|
|
140
|
+
- Real chat, model discovery, quota, per-gateway lists and gateway editing have been exercised against a local New API instance (v1.0.0-rc.45).
|
|
107
141
|
- A real image request was rejected with HTTP 503 (no available upstream channel). Successful paid image generation is therefore **not yet verified**; the attachment pipeline is covered by offline tests.
|
|
108
142
|
- Video is **experimental**: no real video channel was available. Submission, polling, download recovery, restart recovery and duplicate-submission prevention are covered offline only.
|
|
109
|
-
-
|
|
143
|
+
- Only one New API deployment has been tested. Other versions and relays may differ in endpoint details.
|
|
144
|
+
|
|
145
|
+
## Migrating from the former name (v0.7.0)
|
|
146
|
+
|
|
147
|
+
The plugin was called `dsh-newapi-studio`. The rename covers the package, the loader row and provider route (`newapi-provider`), the HTTP prefix (`/newapi-provider/api/`) and the data directory (`plugin-data/dsh-newapi-provider`).
|
|
148
|
+
|
|
149
|
+
To migrate an existing installation, back up the profile patch and plugin data, then keep each channel's id, credential reference, model list and overrides while moving the data directory to the new name. Current releases do not read the old directory automatically. Keep existing credential references even if they contain `STUDIO`: they still hold the working tokens; new references use the `NEWAPI_PROVIDER_` prefix.
|
|
150
|
+
|
|
151
|
+
Sessions pinned to the old `newapi-studio` route must pick models again under the new route. Other adapters are unaffected. Tool names (`newapi_channels`, `newapi_models`, …) did not change.
|
|
110
152
|
|
|
111
153
|
## Development
|
|
112
154
|
|
|
@@ -117,9 +159,11 @@ node --check client.js
|
|
|
117
159
|
npm pack --dry-run --ignore-scripts
|
|
118
160
|
```
|
|
119
161
|
|
|
120
|
-
|
|
162
|
+
To work on a checkout instead of the published package, install the project directory with DSH's plugin manager.
|
|
163
|
+
|
|
164
|
+
DSH runtime imports are optional peerDependencies supplied by the host, so no DSH package is installed or copied here. Tests use local fake gateways and never read personal credentials. This repository replaces, rather than depends on, `dsh-gateway-provider`; two adapters cannot own the same provider route.
|
|
121
165
|
|
|
122
|
-
|
|
166
|
+
Release steps are in [PUBLISHING.md](PUBLISHING.md).
|
|
123
167
|
|
|
124
168
|
MIT License. Uses public DSH exports and pi-ai protocol implementations; no third-party plugin source is vendored.
|
|
125
169
|
|
package/README.zh.md
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) · 中文
|
|
4
4
|
|
|
5
|
-
DeepSeek Harness 的 New API
|
|
5
|
+
DeepSeek Harness 的 New API 渠道与模型工作台:多网关同一时间激活一个,每个网关独立的模型清单与参数覆盖,额度查询,以及生图与实验性视频生成。
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/dsh-newapi-provider)
|
|
8
|
+
[](LICENSE)
|
|
6
9
|
|
|
7
10
|

|
|
8
11
|
|
|
@@ -16,65 +19,61 @@ DeepSeek Harness 的 New API 渠道与模型工作台。**开发中,尚未发
|
|
|
16
19
|
|
|
17
20
|
截图使用插件真实的组件与样式渲染,数据为示例(渠道名、地址、金额均为虚构)。界面跟随 DSH 主题,配色会随你的皮肤和深色模式变化。
|
|
18
21
|
|
|
19
|
-
##
|
|
20
|
-
|
|
21
|
-
- 保存多个网关,每个渠道独立保存地址、凭据引用与模型参数;一次只激活一个。
|
|
22
|
-
- 在「渠道与连接」中新增、编辑、测试、激活和删除渠道。
|
|
23
|
-
- 查询令牌的剩余额度和已用量;无限额度显示累计已用,不展示无意义的负余额。
|
|
24
|
-
- 从网关发现模型,从 models.dev 补充上下文、输出上限和输入/输出能力。
|
|
25
|
-
- 在「模型能力」中搜索模型,直接编辑上下文、输出上限、模型类型、输入能力与显隐。
|
|
26
|
-
- 生图工具返回 DSH 图片附件;视频工具使用统一 `/v1/videos` 接口后台轮询。
|
|
27
|
-
|
|
28
|
-
## 环境与安装
|
|
29
|
-
|
|
30
|
-
目前验证环境为 DSH **0.2.1-rc.2**,Node.js 22.19 或以上;Web 与 Desktop 配置页通过 DSH 原生插槽显示。不需要单独启动网页服务器。
|
|
31
|
-
|
|
32
|
-
通过 DSH 的插件管理工具安装本地项目目录。安装后刷新现有 DSH 页面,打开「设置 → New API 提供商」。未发布到 npm 前不要直接执行 `npm install dsh-newapi-provider`。
|
|
33
|
-
|
|
34
|
-
本插件不依赖 `dsh-gateway-provider`。若需要保留旧会话的 provider 名称,可使用相同的 `providerId`,但不能让两个适配器占用同一路由。当前开发安装保留独立的 `newapi-provider` 路由。
|
|
22
|
+
## 安装
|
|
35
23
|
|
|
36
|
-
|
|
24
|
+
需要 **DSH 0.2.1-rc.1 或以上**(已在 0.2.1-rc.2 验证),Node.js 22.19 或以上。
|
|
37
25
|
|
|
38
|
-
|
|
26
|
+
用 DSH 的插件管理工具安装 `dsh-newapi-provider`,安装后刷新页面,打开「设置 → New API 提供商」。不需要单独启动网页服务器。
|
|
39
27
|
|
|
40
|
-
|
|
28
|
+
1. **添加渠道** —— 填名称、New API 站点地址和调用令牌。
|
|
29
|
+
2. **获取模型列表** —— 读取网关目录,勾选要用的模型。
|
|
30
|
+
3. **激活渠道** —— 本插件的模型路由开始提供这些模型。
|
|
41
31
|
|
|
42
|
-
|
|
32
|
+
令牌存在 DSH 凭据服务里,不写进渠道配置,也不会回传到浏览器。
|
|
43
33
|
|
|
44
|
-
##
|
|
34
|
+
## 能做什么
|
|
45
35
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
36
|
+
- 保存多个网关,一次只激活一个;每个渠道独立保存地址、凭据引用、模型清单和模型参数覆盖。
|
|
37
|
+
- 按渠道管理模型:读取网关目录、挑选哪些模型出现在 DSH、单个测试、删除后撤销、手动添加网关未列出的模型。
|
|
38
|
+
- 能力数据来自 models.dev(上下文窗口、输出上限、多模态、推理),可按模型校正,并支持为单个模型设置上游映射名。
|
|
39
|
+
- 设置页汇总和侧栏底部都能看到令牌额度;令牌允许查询用户额度时同时显示账户余额。
|
|
40
|
+
- 生图结果保存为 DSH 附件并进入生成记录;视频使用统一 `/v1/videos` 接口、后台轮询与重启恢复,属实验性能力。
|
|
51
41
|
|
|
52
|
-
|
|
42
|
+
## 渠道与额度
|
|
53
43
|
|
|
54
|
-
|
|
44
|
+
1. **测试连接**只读取模型目录,不产生生成费用;同时显示网关版本与模型数量。
|
|
45
|
+
2. **激活**切换本插件后续所有调用,或勾选「保存后激活」。
|
|
46
|
+
3. 切换不会修改官方适配器或其他插件。
|
|
47
|
+
4. 已提交的视频任务保持原始渠道,不会转向新激活的网关轮询。
|
|
55
48
|
|
|
56
|
-
|
|
49
|
+
额度说明:
|
|
57
50
|
|
|
58
51
|
- 主接口为 `GET /api/usage/token/`,注意末尾斜杠。
|
|
59
|
-
- 金额换算使用 `/api/status` 的 `quota_per_unit`、`quota_display_type
|
|
60
|
-
- 在 New API
|
|
52
|
+
- 金额换算使用 `/api/status` 的 `quota_per_unit`、`quota_display_type`、`usd_exchange_rate`,不使用充值单价。
|
|
53
|
+
- 在 New API 为令牌开启「允许查询用户额度」后,可同时返回账户余额。
|
|
54
|
+
- 无限额度令牌显示累计已用,不展示无意义的负余额。
|
|
61
55
|
- 这些数字来自站点;模型目录存在、余额可查,都不保证站点已配置可调用的上游。
|
|
62
56
|
|
|
63
|
-
|
|
57
|
+
侧栏显示当前激活渠道的名称与金额,带健康状态点,点击即刷新;侧栏收成 56px 窄栏时只保留状态点。
|
|
58
|
+
|
|
59
|
+
## 模型清单(按渠道独立)
|
|
64
60
|
|
|
65
61
|
每个渠道独立维护自己的模型清单,即使两个渠道指向同一个站点、使用同一个令牌也互不影响:
|
|
66
62
|
|
|
67
63
|
- 「模型能力」页顶部有渠道切换器。激活的渠道带标记,编辑只作用于当前选中的渠道。
|
|
68
|
-
- 「获取模型列表」只读取网关目录,可按模型 ID
|
|
69
|
-
-
|
|
64
|
+
- 「获取模型列表」只读取网关目录,可按模型 ID/厂商搜索,按状态(新增/已添加/网关已下架)和类型(对话/生图/视频/其他)筛选,并按类型分组显示。筛选只影响显示,已勾选的模型不会丢失;列表自身滚动,底部操作栏始终可见。
|
|
65
|
+
- 新增渠道的清单默认为空;旧渠道兼容原有自动模式,第一次添加或删除时按网关当前目录物化清单。
|
|
70
66
|
- 删除只影响当前渠道,不删除上游模型;可以保留自定义参数,并短暂提供撤销。刷新不会把删除的模型自动加回来。
|
|
71
67
|
- 「手动添加」适合网关未列出但可调用的自定义模型。已从网关下架的模型会单独标注,不自动删除。
|
|
72
|
-
- 对话模型测试发送一条很短的真实请求,**会产生调用费用**,显示总耗时、首包时间、最近结果和脱敏错误。首包时间不等于首字时间。
|
|
73
|
-
- 生图/视频测试每次需要二次确认,实际产生一个生成任务;视频提交成功只显示「任务已接受」,不等于生成验证通过。
|
|
74
|
-
- 批量测试只针对当前筛选下的对话模型,最多3个并发。停止阻止后续排队请求;已发出的请求仍会完成并可能计费。
|
|
75
|
-
- 当前测试器支持 OpenAI Chat、Responses 和 Anthropic Messages;Gemini专用协议测试会明确提示暂不支持,不会悄悄换协议测试。
|
|
76
68
|
|
|
77
|
-
##
|
|
69
|
+
## 模型测试
|
|
70
|
+
|
|
71
|
+
- 对话模型测试发送一条很短的真实请求,**会产生调用费用**,显示总耗时、首包时间、最近结果和脱敏错误。只有 `[DONE]` 而没有协议响应的流不算成功。
|
|
72
|
+
- 生图/视频测试每次需要二次确认,实际产生一个生成任务。签名图片链接无法安全展示时仍记录为已生成,但不会暴露链接;界面会提示不要因为看不到预览而重复生成。
|
|
73
|
+
- 批量测试只针对当前筛选下的对话模型,最多 3 个并发。停止会阻止后续排队请求;已发出的请求仍会完成并可能计费。
|
|
74
|
+
- 测试器支持 OpenAI Chat、Responses 和 Anthropic Messages;Gemini 专用协议会明确提示暂不支持,不会悄悄换协议。
|
|
75
|
+
|
|
76
|
+
## 模型名称映射
|
|
78
77
|
|
|
79
78
|
在模型编辑器的「名称与映射」中,三个名称各司其职:
|
|
80
79
|
|
|
@@ -86,7 +85,32 @@ DeepSeek Harness 的 New API 渠道与模型工作台。**开发中,尚未发
|
|
|
86
85
|
|
|
87
86
|
编辑器显示请求映射预览。自动协议、能力、额度参数和定价优先从映射目标补全,手动覆盖仍然优先;输入能力可选自动或自定义,推理支持也可恢复为跟随目录。恢复默认会清除该模型的全部覆盖,包括上游映射。
|
|
88
87
|
|
|
89
|
-
|
|
88
|
+
跨厂商或跨模型系列映射需要检查协议、容量和能力是否合适。模型编辑器修改参数时绑定打开时的渠道;出现配置冲突提示应刷新再编辑,避免覆盖另一个窗口的修改。
|
|
89
|
+
|
|
90
|
+
## 思考等级(v0.9.0)
|
|
91
|
+
|
|
92
|
+
不同厂商的推理档位并不相同,插件按模型分别解析,不再对所有推理模型显示同一组档位:
|
|
93
|
+
|
|
94
|
+
| 厂商 / 模型 | 提供的档位 | 说明 |
|
|
95
|
+
| --- | --- | --- |
|
|
96
|
+
| Claude 自适应思考(Opus 5 / Sonnet 5 / Fable) | 低 / 中 / 高 / 很高 / 最高 | 无「最少」,`off` 不提供 |
|
|
97
|
+
| 旧版 Claude(预算式) | 关闭 / 最少 / 低 / 中 / 高 | 按 token 预算换算 |
|
|
98
|
+
| GPT-5.1+ / GPT-6 | 关闭 / 低 / 中 / 高 / 很高 / 最高 | `off` 发 `none`,无「最少」 |
|
|
99
|
+
| GPT-5.0 / o 系列 | 最少 / 低 / 中 / 高 | GPT-5.0 无法关闭;o 系列只到高 |
|
|
100
|
+
| gpt-5-pro | 仅「高」 | 官方只接受 high |
|
|
101
|
+
| Gemini 3 / 4 | 最少 / 低 / 中 / 高 | `thinking_level` 无关闭项;xhigh/max 不存在 |
|
|
102
|
+
| Gemini 2.5 | 关闭 / 最少 / 低 / 中 / 高 | 预算式;Pro 无法真正关闭 |
|
|
103
|
+
| DeepSeek V4 | 关闭 / 低 / 高 / 最高 | 官方会折叠无意义档位 |
|
|
104
|
+
| GLM-5.3 | 低 / 高 / 最高 | 传 `disabled` 会报错,故不提供关闭 |
|
|
105
|
+
| GLM-5.2 | 全部 7 档 | 多数档位会被上游折叠 |
|
|
106
|
+
| Kimi K3 | 低 / 高 / 最高 | 无 medium、无法关闭 |
|
|
107
|
+
| Qwen3 / 未知模型 | 关闭 / 最少 / 低 / 中 / 高 | 无离散档位时沿用 pi-ai 默认 |
|
|
108
|
+
|
|
109
|
+
解析顺序:**你的自定义覆盖 → pi-ai 自带模型数据(同一模型与官方 provider 行为一致)→ 厂商家族默认**。
|
|
110
|
+
|
|
111
|
+
在「模型能力 → 编辑模型参数 → 推理等级」里,开关默认打开并预填模型当前档位。左列是对话选择器里的档位(英文),右列是该档位发给网关的参数值,留空表示不发送参数。左列除了 off/minimal/low/medium/high/xhigh/max 内置档位,也可以直接输入自定义名字(1–24 位小写字母、数字、`-`、`_`),自定义档位必须填写参数值,它会像内置档位一样出现在对话选择器里。配置按渠道按模型保存,只影响本插件。
|
|
112
|
+
|
|
113
|
+
> 限制:中转站的模型 id 常与厂商原名不同(如 `claude-opus-5-5`),无法逐字命中时按家族推断;若上游行为与推断不符,用自定义覆盖修正。思考等级只影响发送给网关的参数,是否能达到该档位仍取决于上游。
|
|
90
114
|
|
|
91
115
|
## 模型与多模态
|
|
92
116
|
|
|
@@ -109,20 +133,27 @@ DSH 当前对话接口只支持文本和图片输入。PDF、音频、视频标
|
|
|
109
133
|
|
|
110
134
|
**生图和视频可能扣费。** 提交结果不确定时不会自动重新提交;请先到网关任务日志核对。停止本地观察任务不代表取消网关端生成或退款。
|
|
111
135
|
|
|
112
|
-
##
|
|
136
|
+
## 付费结果保护
|
|
113
137
|
|
|
114
138
|
- 视频提交在收到响应头后断流、超时或响应不可解析,显示「提交结果未知,可能已计费」;不会自动重发提交。
|
|
115
|
-
- 视频上游已生成、仅下载失败时显示「已生成,等待下载重试」。仅重试 GET
|
|
139
|
+
- 视频上游已生成、仅下载失败时显示「已生成,等待下载重试」。仅重试 GET 状态/下载,并重新获取可能过期的下载链接;下载恢复期限届满则显示「已生成,下载未成功」。不要因此重新生成。
|
|
116
140
|
- 同一任务的并发查询合并为一个请求流程,卸载时中止在途网络请求,防止旧实例覆盖新实例缓存。
|
|
117
|
-
-
|
|
118
|
-
|
|
141
|
+
- 失败的付费请求不会自动重试。
|
|
142
|
+
|
|
143
|
+
## 验证范围与限制
|
|
119
144
|
|
|
120
|
-
|
|
145
|
+
- 对话、模型目录、额度、按渠道独立清单与渠道编辑:已在本地 New API(v1.0.0-rc.45)上验证过。
|
|
146
|
+
- 生图真实调用曾返回 503「无可用渠道」,**真实生成成功尚未验证**;附件保存链路由离线测试覆盖。
|
|
147
|
+
- 视频属**实验性**能力:没有真实上游渠道,只做离线提交、后台轮询、下载恢复、重启恢复与不重复提交测试。
|
|
148
|
+
- 只测过一种 New API 部署;其他版本与中转站的接口细节可能不同。
|
|
121
149
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
-
|
|
125
|
-
|
|
150
|
+
## 从旧开发名称迁移(v0.7.0)
|
|
151
|
+
|
|
152
|
+
旧名称为 `dsh-newapi-studio`。此版本统一改为 `dsh-newapi-provider`:配置行与模型路由为 `newapi-provider`,HTTP 接口为 `/newapi-provider/api/`,数据目录为 `plugin-data/dsh-newapi-provider`。
|
|
153
|
+
|
|
154
|
+
已有安装迁移时需先备份配置和数据,再保留各渠道的 ID、凭据引用、模型清单及覆盖参数,将旧数据目录迁移至新名称。当前版本不自动从旧目录读取。既有凭据引用即使含有 `STUDIO` 也应保留,新建引用使用 `NEWAPI_PROVIDER_` 前缀,无需复制令牌。
|
|
155
|
+
|
|
156
|
+
使用旧 `newapi-studio` 路由的会话需要重新选择新路由下的模型;其他适配器不受影响。工具名 `newapi_channels`、`newapi_models` 等保持不变。
|
|
126
157
|
|
|
127
158
|
## 开发
|
|
128
159
|
|
|
@@ -133,9 +164,11 @@ node --check client.js
|
|
|
133
164
|
npm pack --dry-run --ignore-scripts
|
|
134
165
|
```
|
|
135
166
|
|
|
136
|
-
|
|
167
|
+
要针对源码目录开发,用 DSH 插件管理工具安装该项目目录,而不是 npm 包。
|
|
168
|
+
|
|
169
|
+
无需安装或复制 DSH 官方包;运行时引用通过 optional peerDependencies 声明,由 DSH 宿主提供。测试不依赖真实网关、不读取用户密钥。本插件替代而非依赖 `dsh-gateway-provider`,两个适配器不能占用同一路由。
|
|
137
170
|
|
|
138
|
-
|
|
171
|
+
发布步骤见 [PUBLISHING.md](PUBLISHING.md)。
|
|
139
172
|
|
|
140
173
|
MIT License。使用 DSH 的公开导出与 pi-ai 协议实现,不包含第三方插件的源码副本。
|
|
141
174
|
|
package/client.js
CHANGED
|
@@ -48,6 +48,17 @@ window.__ModuleLoader__.load({
|
|
|
48
48
|
reasoning: '推理', noReasoning: '未声明推理', auto: '自动识别', overridden: '已自定义', defaults: '默认值',
|
|
49
49
|
modelEmpty: '没有匹配的模型', noModels: '当前渠道暂无模型目录', hidden: '已隐藏', modelEdit: '编辑模型参数', modelEditHint: '只影响此渠道的该模型;不会修改 New API 站点。',
|
|
50
50
|
kind: '模型类型', displayName: '显示名称', inputModalities: '输入能力', inputHint: 'DSH 对话层支持文本与图片;PDF、音视频标签仅用于展示,不能直接作为对话输入。',
|
|
51
|
+
reasoningLevels: '推理等级', reasoningLevelsMode: '推理等级模式',
|
|
52
|
+
levelsAutoHint: '已关闭自定义,按模型自动识别的档位发送;打开开关可以逐个修改。',
|
|
53
|
+
levelsCustomHint: '下面是从模型数据预填的档位。左侧决定对话里可选什么,右侧是发给网关的参数值;留空表示不发送参数(常用于 off)。不改动就保持跟随模型。',
|
|
54
|
+
levelsOffered: '将提供',
|
|
55
|
+
levelsRequired: '至少保留一个档位', level_off: 'off', level_minimal: 'minimal', level_low: 'low', level_medium: 'medium', level_high: 'high', level_xhigh: 'xhigh', level_max: 'max',
|
|
56
|
+
levelColumn: '档位', apiValueColumn: 'API 参数值', apiValuePlaceholder: '留空则不发送',
|
|
57
|
+
levelPlaceholder: '档位名,可自定义', levelInvalid: '档位只能用 1-24 位小写字母、数字、- 或 _',
|
|
58
|
+
levelDuplicate: '档位名重复了', levelNeedsValue: '自定义档位必须填写 API 参数值',
|
|
59
|
+
levelCustomOption: '自定义…', levelBackToPreset: '改回内置档位',
|
|
60
|
+
addLevel: '添加档位', removeLevel: '移除该档位',
|
|
61
|
+
levelsHelp: '不同厂商的推理档位并不相同:这里决定对话里可选哪些档位,以及每个档位发给网关的参数值。关闭开关则跟随模型自动识别。',
|
|
51
62
|
reasoningEnabled: '声明支持推理', hideModel: '隐藏该模型', modelSave: '保存参数', reset: '恢复自动识别',
|
|
52
63
|
resetHint: '清除此渠道中该模型的全部自定义参数。', modelSaved: '模型参数已更新', modelReset: '已恢复自动识别',
|
|
53
64
|
inherited: '留空使用自动识别或默认值', positiveInteger: '请输入大于 0 的整数',
|
|
@@ -127,6 +138,17 @@ window.__ModuleLoader__.load({
|
|
|
127
138
|
reasoning: 'Reasoning', noReasoning: 'No reasoning declared', auto: 'Automatic', overridden: 'Customized', defaults: 'Default',
|
|
128
139
|
modelEmpty: 'No matching models', noModels: 'No model catalog for this gateway', hidden: 'Hidden', modelEdit: 'Edit model', modelEditHint: 'Changes this model on this gateway only, not the New API site.',
|
|
129
140
|
kind: 'Model kind', displayName: 'Display name', inputModalities: 'Input capabilities', inputHint: 'DSH chat accepts text and image. PDF, video and audio tags are informational only.',
|
|
141
|
+
reasoningLevels: 'Thinking levels', reasoningLevelsMode: 'Thinking level mode',
|
|
142
|
+
levelsAutoHint: 'Customisation is off: the levels detected for this model are sent as-is. Turn the switch on to edit them.',
|
|
143
|
+
levelsCustomHint: 'Pre-filled from the model data. The left column decides what the chat selector offers, the right column is the value sent upstream; empty sends nothing (common for off). Leave it untouched to keep following the model.',
|
|
144
|
+
levelsOffered: 'Offers',
|
|
145
|
+
levelsRequired: 'Pick at least one thinking level', level_off: 'off', level_minimal: 'minimal', level_low: 'low', level_medium: 'medium', level_high: 'high', level_xhigh: 'xhigh', level_max: 'max',
|
|
146
|
+
levelColumn: 'Level', apiValueColumn: 'API value', apiValuePlaceholder: 'Empty sends nothing',
|
|
147
|
+
levelPlaceholder: 'Level name, custom allowed', levelInvalid: 'Levels use 1-24 lowercase letters, digits, - or _',
|
|
148
|
+
levelDuplicate: 'Duplicate level name', levelNeedsValue: 'A custom level needs an API value',
|
|
149
|
+
levelCustomOption: 'Custom…', levelBackToPreset: 'Back to built-in levels',
|
|
150
|
+
addLevel: 'Add level', removeLevel: 'Remove level',
|
|
151
|
+
levelsHelp: 'Vendors differ: this decides which levels the chat selector offers and what each one sends upstream. Leave the switch off to follow the detected set.',
|
|
130
152
|
reasoningEnabled: 'Declare reasoning support', hideModel: 'Hide model', modelSave: 'Save parameters', reset: 'Use automatic values',
|
|
131
153
|
resetHint: 'Removes all overrides for this model on this gateway.', modelSaved: 'Model parameters updated', modelReset: 'Automatic values restored',
|
|
132
154
|
inherited: 'Leave empty for automatic or default values', positiveInteger: 'Enter a positive integer',
|
|
@@ -178,6 +200,7 @@ window.__ModuleLoader__.load({
|
|
|
178
200
|
useEffect(() => locale?.subscribe(() => rerender((x) => x + 1)), []);
|
|
179
201
|
return (key) => translate?.(key) ?? ZH[key] ?? key;
|
|
180
202
|
}
|
|
203
|
+
const LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
181
204
|
const ICONS = {
|
|
182
205
|
plus: 'M12 5v14M5 12h14', refresh: 'M20 7v5h-5M4 17v-5h5M6.1 7a7 7 0 0 1 11.6-1L20 9M4 15l2.3 3a7 7 0 0 0 11.6-1',
|
|
183
206
|
check: 'm5 12 4 4L19 6', close: 'm6 6 12 12M6 18 18 6', edit: 'm15 5 4 4M4 20l4-1L20 7a2.1 2.1 0 0 0-3-3L5 16Z',
|
|
@@ -371,6 +394,38 @@ window.__ModuleLoader__.load({
|
|
|
371
394
|
.nas-side-balance-icon{display:inline-flex;color:var(--nas-sub);flex-shrink:0}
|
|
372
395
|
.nas-side-balance[data-wide=no] .nas-side-balance-btn{justify-content:center;padding:6px 0;gap:0}
|
|
373
396
|
.nas-side-balance[data-wide=no] .nas-side-balance-icon{display:none}
|
|
397
|
+
/* Level chips: same look as the input-modality toggles. */
|
|
398
|
+
.nas-chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
|
|
399
|
+
.nas-chips .nas-chip,.nas-chips button{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--nas-line);background:var(--nas-bg);color:var(--nas-text);border-radius:7px;padding:4px 9px;font-size:12px;line-height:1.5}
|
|
400
|
+
.nas-chips button:hover{border-color:var(--nas-brand)}
|
|
401
|
+
.nas-chips button[aria-pressed=true]{border-color:var(--nas-brand);background:color-mix(in srgb,var(--nas-accent) 7%,transparent);font-weight:550}
|
|
402
|
+
.nas-chips.readonly .nas-chip{cursor:default;color:var(--nas-sub)}
|
|
403
|
+
.nas-chips.readonly .nas-chip.on{color:var(--nas-text);border-color:var(--nas-line-strong,var(--nas-line))}
|
|
404
|
+
/* Reasoning levels: switch + level/value table, mirroring the editor rows. */
|
|
405
|
+
.nas-levels{display:flex;flex-direction:column;gap:8px;margin-top:14px;padding:12px 13px;border:1px solid var(--nas-line);border-radius:10px;background:var(--nas-raised)}
|
|
406
|
+
.nas-levels-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
|
407
|
+
.nas-levels-title{display:flex;align-items:center;gap:6px;font-size:12.5px;font-weight:550}
|
|
408
|
+
.nas-help{display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;border:1px solid var(--nas-line-strong,var(--nas-line));border-radius:50%;font-size:10px;color:var(--nas-sub);cursor:help}
|
|
409
|
+
.nas-switch{position:relative;flex-shrink:0;width:38px;height:22px;border:1px solid var(--nas-line);border-radius:999px;background:var(--nas-line);cursor:pointer;transition:background .15s,border-color .15s}
|
|
410
|
+
.nas-switch>i{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.2);transition:transform .15s}
|
|
411
|
+
.nas-switch.on{background:var(--nas-brand);border-color:var(--nas-brand)}
|
|
412
|
+
.nas-switch.on>i{transform:translateX(16px)}
|
|
413
|
+
.nas-switch:disabled{opacity:.5;cursor:default}
|
|
414
|
+
.nas-levels-table{display:flex;flex-direction:column;gap:8px}
|
|
415
|
+
.nas-levels-row{display:grid;grid-template-columns:minmax(96px,150px) minmax(0,1fr) 26px;align-items:center;gap:10px}
|
|
416
|
+
.nas-levels-row.head{font-size:11px;color:var(--nas-sub);padding:0 1px}
|
|
417
|
+
.nas-levels-row select,.nas-levels-row input{width:100%;min-width:0;border:1px solid var(--nas-line);border-radius:8px;background:var(--dsw-alias-bg-base);color:var(--nas-text);padding:6px 9px;font-size:12.5px;font-family:inherit}
|
|
418
|
+
.nas-levels-row select:focus-visible,.nas-levels-row input:focus-visible{outline:2px solid var(--nas-brand);outline-offset:1px}
|
|
419
|
+
.nas-levels-remove{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border:1px solid transparent;border-radius:7px;background:transparent;color:var(--nas-sub);cursor:pointer}
|
|
420
|
+
.nas-levels-remove:hover:not(:disabled){color:var(--dsw-alias-state-error-primary);border-color:var(--nas-line)}
|
|
421
|
+
.nas-levels-remove:disabled{opacity:.35;cursor:default}
|
|
422
|
+
.nas-levels-add{display:inline-flex;align-items:center;gap:6px;align-self:flex-start;border:1px solid var(--nas-line);border-radius:8px;background:var(--dsw-alias-bg-base);color:var(--nas-text);padding:6px 10px;font-size:12px;cursor:pointer}
|
|
423
|
+
.nas-levels-add:hover:not(:disabled){border-color:var(--nas-brand)}
|
|
424
|
+
.nas-levels-add:disabled{opacity:.5;cursor:default}
|
|
425
|
+
.nas-levels-custom{display:flex;align-items:center;gap:6px;min-width:0}
|
|
426
|
+
.nas-levels-custom input{flex:1;min-width:0}
|
|
427
|
+
.nas-levels-back{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;flex-shrink:0;border:1px solid var(--nas-line);border-radius:6px;background:var(--dsw-alias-bg-base);color:var(--nas-sub);cursor:pointer}
|
|
428
|
+
.nas-levels-back:hover:not(:disabled){color:var(--nas-text);border-color:var(--nas-brand)}
|
|
374
429
|
/* Tooltips: instant, themed, keyboard accessible. */
|
|
375
430
|
.nas [data-tip]{position:relative}
|
|
376
431
|
.nas [data-tip]::after{content:attr(data-tip);position:absolute;z-index:60;bottom:calc(100% + 9px);left:50%;transform:translateX(-50%);width:max-content;max-width:270px;padding:6px 9px;border-radius:7px;border:1px solid var(--nas-line);background:var(--dsw-alias-bg-overlay);color:var(--nas-text);font-size:11px;font-weight:400;line-height:1.55;text-align:left;white-space:pre-line;box-shadow:0 8px 24px color-mix(in srgb,var(--dsw-alias-label-primary) 16%,transparent);opacity:0;pointer-events:none;transition:opacity .12s}
|
|
@@ -569,6 +624,117 @@ window.__ModuleLoader__.load({
|
|
|
569
624
|
: h('div', { className: 'nas-test-error' }, tested.error))
|
|
570
625
|
: h('div', { className: 'nas-note', style: { marginTop: 14 } }, h(Icon, { name: 'info', size: 13 }), t('testHint'))));
|
|
571
626
|
}
|
|
627
|
+
/**
|
|
628
|
+
* Reasoning levels: a switch plus a level→wire-value table.
|
|
629
|
+
* Off = follow what the model declares; on = the table is exactly what ships.
|
|
630
|
+
*/
|
|
631
|
+
function ReasoningLevelsEditor({ enabled, rows, detected, busy, onToggle, onChange }) {
|
|
632
|
+
const CUSTOM_CHOICE = '__custom';
|
|
633
|
+
const t = useT();
|
|
634
|
+
const setRow = (index, patch) => onChange(rows.map((row, i) => (i === index ? { ...row, ...patch } : row)));
|
|
635
|
+
// A new row starts on the first preset nobody uses yet; when all seven are taken it
|
|
636
|
+
// starts as a custom row instead, which is the only case where a name is typed.
|
|
637
|
+
const add = () => {
|
|
638
|
+
const free = LEVELS.find((level) => !rows.some((row) => row.level === level));
|
|
639
|
+
onChange([...rows, free ? { level: free, value: defaultWireFor(free) } : { level: '', value: '' }]);
|
|
640
|
+
};
|
|
641
|
+
const used = new Set(rows.map((row) => row.level).filter(Boolean));
|
|
642
|
+
const problems = rows.map((row) => {
|
|
643
|
+
if (!row.level) return { row: true };
|
|
644
|
+
if (!LEVELS.includes(row.level) && !CUSTOM_LEVEL.test(row.level)) return { invalid: true };
|
|
645
|
+
if (rows.filter((other) => other.level === row.level).length > 1) return { duplicate: true };
|
|
646
|
+
if (row.value.trim() === '' && !LEVELS.includes(row.level)) return { needsValue: true };
|
|
647
|
+
return {};
|
|
648
|
+
});
|
|
649
|
+
return h('div', { className: 'nas-levels' },
|
|
650
|
+
h('div', { className: 'nas-levels-head' },
|
|
651
|
+
h('div', { className: 'nas-levels-title' },
|
|
652
|
+
h('span', null, t('reasoningLevels')),
|
|
653
|
+
h('span', { className: 'nas-help', role: 'img', 'aria-label': t('levelsHelp'), 'data-tip': t('levelsHelp'), 'data-tip-align': 'start' }, '?')),
|
|
654
|
+
h('button', {
|
|
655
|
+
type: 'button', role: 'switch', 'aria-checked': enabled, 'aria-label': t('reasoningLevelsMode'),
|
|
656
|
+
className: `nas-switch ${enabled ? 'on' : ''}`, disabled: busy, onClick: onToggle,
|
|
657
|
+
}, h('i'))),
|
|
658
|
+
h('div', { className: 'nas-sub' }, enabled ? t('levelsCustomHint') : t('levelsAutoHint')),
|
|
659
|
+
!enabled && h('div', { className: 'nas-levels-summary', lang: 'en' }, `${t('levelsOffered')}: ${detected.join(' / ') || '—'}`),
|
|
660
|
+
enabled && h(React.Fragment, null,
|
|
661
|
+
h('div', { className: 'nas-levels-table' },
|
|
662
|
+
h('div', { className: 'nas-levels-row head' },
|
|
663
|
+
h('span', null, t('levelColumn')), h('span', null, t('apiValueColumn')), h('span', { 'aria-hidden': true })),
|
|
664
|
+
rows.map((row, index) => h('div', { className: 'nas-levels-row', key: `${row.level}-${index}` },
|
|
665
|
+
LEVELS.includes(row.level)
|
|
666
|
+
? h('select', {
|
|
667
|
+
value: row.level, disabled: busy, lang: 'en', 'aria-label': t('levelColumn'),
|
|
668
|
+
onChange: (e) => setRow(index, e.target.value === CUSTOM_CHOICE
|
|
669
|
+
? { level: '' } // switch this row to a typed name
|
|
670
|
+
: { level: e.target.value, value: row.value || defaultWireFor(e.target.value) }),
|
|
671
|
+
},
|
|
672
|
+
LEVELS.map((level) => h('option', {
|
|
673
|
+
key: level, value: level, lang: 'en',
|
|
674
|
+
disabled: level !== row.level && used.has(level),
|
|
675
|
+
}, level)),
|
|
676
|
+
h('option', { value: CUSTOM_CHOICE }, t('levelCustomOption')))
|
|
677
|
+
: h('div', { className: 'nas-levels-custom' },
|
|
678
|
+
h('input', {
|
|
679
|
+
value: row.level, disabled: busy, lang: 'en', spellCheck: false, autoFocus: true,
|
|
680
|
+
placeholder: t('levelPlaceholder'), 'aria-label': t('levelColumn'),
|
|
681
|
+
'aria-invalid': Boolean(problems[index].invalid || problems[index].duplicate),
|
|
682
|
+
onChange: (e) => setRow(index, { level: e.target.value.trim().toLowerCase() }),
|
|
683
|
+
}),
|
|
684
|
+
h('button', { type: 'button', className: 'nas-levels-back', disabled: busy,
|
|
685
|
+
'aria-label': t('levelBackToPreset'), 'data-tip': t('levelBackToPreset'), 'data-tip-align': 'end',
|
|
686
|
+
onClick: () => {
|
|
687
|
+
const free = LEVELS.find((level) => !used.has(level));
|
|
688
|
+
setRow(index, free ? { level: free, value: defaultWireFor(free) } : { level: LEVELS[0], value: defaultWireFor(LEVELS[0]) });
|
|
689
|
+
} }, h(Icon, { name: 'refresh', size: 12 }))),
|
|
690
|
+
h('input', {
|
|
691
|
+
value: row.value, disabled: busy, lang: 'en', spellCheck: false, placeholder: t('apiValuePlaceholder'),
|
|
692
|
+
'aria-label': t('apiValueColumn'), onChange: (e) => setRow(index, { value: e.target.value }),
|
|
693
|
+
}),
|
|
694
|
+
h('button', {
|
|
695
|
+
type: 'button', className: 'nas-levels-remove', disabled: busy || rows.length <= 1,
|
|
696
|
+
'aria-label': t('removeLevel'), 'data-tip': t('removeLevel'), 'data-tip-align': 'end',
|
|
697
|
+
onClick: () => onChange(rows.filter((_, i) => i !== index)),
|
|
698
|
+
}, h(Icon, { name: 'trash', size: 13 })))),
|
|
699
|
+
h('button', { type: 'button', className: 'nas-levels-add', disabled: busy || rows.length >= LEVELS.length, onClick: add },
|
|
700
|
+
h(Icon, { name: 'plus', size: 13 }), t('addLevel')),
|
|
701
|
+
!rows.length && h('span', { className: 'nas-field-error' }, t('levelsRequired')))));
|
|
702
|
+
}
|
|
703
|
+
/** Level→wire map to editable rows; falls back to the detected set when nothing is pinned. */
|
|
704
|
+
function rowsFromMap(map, detectedLevels, detectedMap) {
|
|
705
|
+
if (map && Object.keys(map).length) {
|
|
706
|
+
const names = Object.keys(map).filter((l) => typeof map[l] === 'string');
|
|
707
|
+
return orderLevelNames(names).map((l) => ({ level: l, value: map[l] }));
|
|
708
|
+
}
|
|
709
|
+
const levels = detectedLevels ?? [];
|
|
710
|
+
return levels.map((l) => ({ level: l, value: (detectedMap && typeof detectedMap[l] === 'string') ? detectedMap[l] : defaultWireFor(l) }));
|
|
711
|
+
}
|
|
712
|
+
/** Preset levels first, then custom names — the same order the server resolves. */
|
|
713
|
+
function orderLevelNames(names) {
|
|
714
|
+
const preset = LEVELS.filter((l) => names.includes(l));
|
|
715
|
+
const custom = names.filter((l) => !LEVELS.includes(l)).sort();
|
|
716
|
+
return [...preset, ...custom];
|
|
717
|
+
}
|
|
718
|
+
/** A custom level name must be a safe id; presets are always allowed. */
|
|
719
|
+
const CUSTOM_LEVEL = /^[a-z0-9][a-z0-9_-]{0,23}$/;
|
|
720
|
+
/** Rows to the override map. A blank value means "this level sends nothing" (e.g. off). */
|
|
721
|
+
function rowsToMap(rows) {
|
|
722
|
+
const map = {};
|
|
723
|
+
for (const row of rows) map[row.level] = row.value.trim() ? row.value.trim() : null;
|
|
724
|
+
return map;
|
|
725
|
+
}
|
|
726
|
+
/** True when two level maps say the same thing (null and absent both mean "hidden"). */
|
|
727
|
+
function sameLevelMap(a, b) {
|
|
728
|
+
const read = (map, level) => {
|
|
729
|
+
const value = map ? map[level] : undefined;
|
|
730
|
+
return typeof value === 'string' ? value : null;
|
|
731
|
+
};
|
|
732
|
+
return LEVELS.every((level) => read(a, level) === read(b, level));
|
|
733
|
+
}
|
|
734
|
+
/** Wire value a level usually needs when the user adds it by hand. */
|
|
735
|
+
function defaultWireFor(level) {
|
|
736
|
+
return level === 'off' ? '' : level;
|
|
737
|
+
}
|
|
572
738
|
function ModelEditor({ model, channel, revision, siblings = [], onClose, onSaved }) {
|
|
573
739
|
const t = useT(); const id = useId(); const a = useAction();
|
|
574
740
|
const initial = model.override || {};
|
|
@@ -581,6 +747,9 @@ window.__ModuleLoader__.load({
|
|
|
581
747
|
inputMode: Array.isArray(initial.input) ? 'custom' : 'auto',
|
|
582
748
|
input: [...(initial.input ?? automaticInput)],
|
|
583
749
|
reasoningMode: initial.reasoning == null ? 'auto' : initial.reasoning ? 'on' : 'off',
|
|
750
|
+
levelsMode: 'custom',
|
|
751
|
+
// Custom rows: level id plus the wire value it sends ('' means "no value", i.e. off/none).
|
|
752
|
+
rows: rowsFromMap(initial.thinkingLevelMap, model.automaticReasoningLevels, model.automaticThinkingLevelMap),
|
|
584
753
|
hidden: Boolean(initial.hidden),
|
|
585
754
|
});
|
|
586
755
|
const original = useRef(JSON.stringify(form)); const dirty = original.current !== JSON.stringify(form);
|
|
@@ -593,6 +762,13 @@ window.__ModuleLoader__.load({
|
|
|
593
762
|
if (a.pending) return;
|
|
594
763
|
for (const k of ['contextWindow', 'maxTokens']) if (form[k] && (!/^\d+$/.test(form[k]) || Number(form[k]) < 1 || !Number.isSafeInteger(Number(form[k])))) return setInvalid(t('positiveInteger'));
|
|
595
764
|
if (form.upstreamModel.trim() && (/\s|[\u0000-\u001f\u007f-\u009f]/u.test(form.upstreamModel.trim()) || form.upstreamModel.trim().length > 500)) return setInvalid(t('invalidMapping'));
|
|
765
|
+
if (form.levelsMode === 'custom') {
|
|
766
|
+
const levels = form.rows.map((row) => row.level).filter(Boolean);
|
|
767
|
+
if (!levels.length) return setInvalid(t('levelsRequired'));
|
|
768
|
+
if (levels.some((level) => !LEVELS.includes(level) && !CUSTOM_LEVEL.test(level))) return setInvalid(t('levelInvalid'));
|
|
769
|
+
if (new Set(levels).size !== levels.length) return setInvalid(t('levelDuplicate'));
|
|
770
|
+
if (form.rows.some((row) => row.level && !LEVELS.includes(row.level) && !row.value.trim())) return setInvalid(t('levelNeedsValue'));
|
|
771
|
+
}
|
|
596
772
|
const override = { ...initial };
|
|
597
773
|
for (const k of ['name', 'upstreamModel', 'kind', 'protocol', 'contextWindow', 'maxTokens']) {
|
|
598
774
|
const value = form[k].trim();
|
|
@@ -600,6 +776,12 @@ window.__ModuleLoader__.load({
|
|
|
600
776
|
}
|
|
601
777
|
override.input = form.inputMode === 'auto' ? null : form.input;
|
|
602
778
|
override.reasoning = form.reasoningMode === 'auto' ? null : form.reasoningMode === 'on';
|
|
779
|
+
// A table that still matches what the model declares is not an override: keeping it
|
|
780
|
+
// null means the levels keep following pi-ai's data instead of freezing today's copy.
|
|
781
|
+
override.reasoningLevels = null;
|
|
782
|
+
const edited = rowsToMap(form.rows);
|
|
783
|
+
const detected = model.automaticThinkingLevelMap;
|
|
784
|
+
override.thinkingLevelMap = sameLevelMap(edited, detected) ? null : edited;
|
|
603
785
|
override.hidden = form.hidden ? true : null;
|
|
604
786
|
return a.run(async () => { await store.mutate('model/override', { revision, channelId: channel.id, modelId: model.id, override }); onSaved(false); });
|
|
605
787
|
};
|
|
@@ -637,7 +819,15 @@ window.__ModuleLoader__.load({
|
|
|
637
819
|
})),
|
|
638
820
|
h('div', { className: 'nas-note', style: { marginTop: 8, marginBottom: 16 } }, t('inputHint')),
|
|
639
821
|
h(Field, { label: t('reasoning'), htmlFor: `${id}-reasoning` }, h('select', { id: `${id}-reasoning`, value: form.reasoningMode, onChange: changed('reasoningMode'), disabled: a.pending },
|
|
640
|
-
h('option', { value: 'auto' }, `${t('followAuto')} · ${t((model.automaticReasoning ?? model.reasoning) ? 'enableReasoning' : 'disableReasoning')}`), h('option', { value: 'on' }, t('enableReasoning')), h('option', { value: 'off' }, t('disableReasoning'))))
|
|
822
|
+
h('option', { value: 'auto' }, `${t('followAuto')} · ${t((model.automaticReasoning ?? model.reasoning) ? 'enableReasoning' : 'disableReasoning')}`), h('option', { value: 'on' }, t('enableReasoning')), h('option', { value: 'off' }, t('disableReasoning')))),
|
|
823
|
+
(form.reasoningMode !== 'off') && h(ReasoningLevelsEditor, {
|
|
824
|
+
enabled: form.levelsMode === 'custom',
|
|
825
|
+
rows: form.rows,
|
|
826
|
+
detected: model.automaticReasoningLevels ?? [],
|
|
827
|
+
busy: a.pending,
|
|
828
|
+
onToggle: () => setForm({ ...form, levelsMode: form.levelsMode === 'custom' ? 'auto' : 'custom' }),
|
|
829
|
+
onChange: (rows) => setForm({ ...form, rows }),
|
|
830
|
+
})),
|
|
641
831
|
h('details', { className: 'nas-editor-advanced' }, h('summary', null, t('advancedOptions')),
|
|
642
832
|
h('div', { className: 'nas-two' },
|
|
643
833
|
h(Field, { label: t('kind'), htmlFor: `${id}-kind` }, h('select', { id: `${id}-kind`, value: form.kind, onChange: changed('kind'), disabled: a.pending }, h('option', { value: '' }, t('auto')), ['chat', 'image', 'video', 'audio', 'embedding', 'other'].map((k) => h('option', { key: k, value: k }, t(k))))),
|
|
@@ -1032,6 +1222,7 @@ window.__ModuleLoader__.load({
|
|
|
1032
1222
|
m.kind === 'chat' ? `${t('context')}: ${m.effectiveContextWindow} / ${t('maxOutput')}: ${m.effectiveMaxTokens}` : t(m.kind),
|
|
1033
1223
|
`${t('source')}: ${m.source?.modelsDev?.provider || t('defaults')} · ${t(m.source?.modelsDev ? m.source.modelsDev.match === 'prefix' ? 'originPrefix' : 'originExact' : 'originNone')}`,
|
|
1034
1224
|
m.source?.overridden?.length ? `${t('overridden')}: ${m.source.overridden.join(', ')}` : null,
|
|
1225
|
+
m.reasoning && m.reasoningLevels?.length ? `${t('reasoningLevels')}: ${m.reasoningLevels.map((l) => t('level_' + l)).join(' / ')}` : null,
|
|
1035
1226
|
].filter(Boolean).join('\n') },
|
|
1036
1227
|
m.kind === 'chat' ? h('span', { className: 'nas-cap-numbers' }, tokens(m.effectiveContextWindow), h('span', { className: 'nas-cap-divider' }, ' / '), tokens(m.effectiveMaxTokens)) : h('span', null, t(m.kind)),
|
|
1037
1228
|
h('span', { className: 'nas-cap-icons' },
|
|
@@ -1086,7 +1277,7 @@ window.__ModuleLoader__.load({
|
|
|
1086
1277
|
const modelChannel = channels.find((c) => c.id === modelChannelId) || active;
|
|
1087
1278
|
const count = (kind) => entries.filter((e) => e.kind === kind).length;
|
|
1088
1279
|
const operate = (path, ch, key) => a.run(async () => { await store.mutate(path, { id: ch.id, revision: state.revision }); notify(key); });
|
|
1089
|
-
return h('div', { className: 'nas nas-page', 'data-newapi-provider-version': '0.
|
|
1280
|
+
return h('div', { className: 'nas nas-page', 'data-newapi-provider-version': '0.9.4' }, h(Styles), header,
|
|
1090
1281
|
notice && h('div', { className: 'nas-feedback', role: 'status' }, h(Icon, { name: 'check', size: 14 }), notice),
|
|
1091
1282
|
h(ErrorText, { text: a.error || error }),
|
|
1092
1283
|
h('div', { className: 'nas-summary' },
|
package/index.js
CHANGED
|
@@ -45,6 +45,8 @@ const ModelOverride = z.object({
|
|
|
45
45
|
input: z.array(z.union(MODALITIES)).default(undefined).description('Input modalities the model accepts.'),
|
|
46
46
|
reasoning: z.boolean().description('Whether the model supports reasoning effort.'),
|
|
47
47
|
reasoningLevels: z.array(z.union(LEVELS)).default(undefined).description('Reasoning effort levels to offer.'),
|
|
48
|
+
thinkingLevelMap: z.dict(z.union([z.string(), z.const(null)])).default(undefined)
|
|
49
|
+
.description('Per-level wire value: a string is sent upstream, null hides the level. Omitted levels fall back to the detected value.'),
|
|
48
50
|
protocol: z.union(ENDPOINT_TYPES).description('Force the wire protocol for this model.'),
|
|
49
51
|
custom: z.boolean().description('Add this model even if the gateway does not list it.'),
|
|
50
52
|
});
|
|
@@ -461,9 +463,10 @@ export function apply(ctx, config) {
|
|
|
461
463
|
fetchedAt: value.fetchedAt,
|
|
462
464
|
pricingAvailable: value.pricingAvailable,
|
|
463
465
|
pricingError: value.pricingError,
|
|
464
|
-
entries: value.entries.map((e) => ({
|
|
466
|
+
entries: value.entries.map(({ thinkingLevelMap, ...e }) => ({ // the map is wire detail; the UI gets the same data under its own name
|
|
465
467
|
...e,
|
|
466
468
|
// Own-property read: built-in names such as `toString` are legal model ids.
|
|
469
|
+
automaticThinkingLevelMap: thinkingLevelMap ?? null,
|
|
467
470
|
override: structuredClone(Object.hasOwn(captured.models ?? {}, e.id) ? captured.models[e.id] : {}),
|
|
468
471
|
automaticContextWindow: e.automaticContextWindow ?? conn.defaultContextWindow,
|
|
469
472
|
automaticMaxTokens: e.automaticMaxTokens ?? conn.defaultMaxTokens,
|
|
@@ -612,6 +615,8 @@ export function apply(ctx, config) {
|
|
|
612
615
|
input: e.input,
|
|
613
616
|
output: e.outputModalities,
|
|
614
617
|
reasoning: e.reasoning,
|
|
618
|
+
reasoningLevels: e.reasoningLevels ?? [],
|
|
619
|
+
reasoningLevelsSource: e.reasoningLevelsSource ?? 'default',
|
|
615
620
|
endpointTypes: e.endpointTypes,
|
|
616
621
|
vendor: e.vendor,
|
|
617
622
|
pricing: e.pricing,
|
package/lib/adapter.js
CHANGED
|
@@ -41,9 +41,14 @@ function baseUrlFor(baseURL, api) {
|
|
|
41
41
|
return api === 'openai-completions' || api === 'openai-responses' ? openaiBase(baseURL) : gatewayRoot(baseURL);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Reasoning level map. The catalog resolves it per model — the user's override,
|
|
46
|
+
* pi-ai's own data for that id, or a vendor-family default — so pi-ai hides the
|
|
47
|
+
* levels this model cannot express instead of offering the same five everywhere.
|
|
48
|
+
*/
|
|
45
49
|
function thinkingLevelMap(entry) {
|
|
46
50
|
if (!entry.reasoning) return undefined;
|
|
51
|
+
if (entry.thinkingLevelMap) return entry.thinkingLevelMap;
|
|
47
52
|
if (!entry.reasoningLevels?.length) return undefined;
|
|
48
53
|
const allowed = new Set(entry.reasoningLevels);
|
|
49
54
|
return Object.fromEntries(LEVELS.map((l) => [l, allowed.has(l) ? l : null]));
|
|
@@ -163,9 +168,15 @@ export class NewApiAdapter extends LlmAdapter {
|
|
|
163
168
|
async modelInfo(bound, provider, model, signal) {
|
|
164
169
|
const info = await bound.inner.resolveModel(provider, model, signal);
|
|
165
170
|
const entry = bound.catalog.entries.find((e) => e.id === model);
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
171
|
+
// Report exactly the levels the request path can express: DSH rejects an effort
|
|
172
|
+
// it did not advertise, and clamps nothing, so these two must agree.
|
|
173
|
+
if (entry?.reasoning && entry.reasoningLevels?.length) {
|
|
174
|
+
const names = { off: 'Off', minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', max: 'Max' };
|
|
175
|
+
info.reasoning = {
|
|
176
|
+
efforts: entry.reasoningLevels.map((l) => ({ id: ReasoningEffortId(l), name: names[l] ?? l })),
|
|
177
|
+
...(entry.reasoningLevels.includes('medium') ? { defaultEffort: ReasoningEffortId('medium') } : {}),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
169
180
|
if (entry?.maxTokens) info.defaultMaxTokens = entry.maxTokens;
|
|
170
181
|
return info;
|
|
171
182
|
}
|
package/lib/catalog.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* other) so chat models go to the picker and media models go to the tools.
|
|
12
12
|
*/
|
|
13
13
|
import { fetchJson, gatewayRoot } from './util.js';
|
|
14
|
+
import { LevelCatalog, resolveReasoningLevels } from './levels.js';
|
|
14
15
|
|
|
15
16
|
export const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
16
17
|
export const DEFAULT_MAX_TOKENS = 32_768;
|
|
@@ -44,9 +45,10 @@ function prettyName(id) {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
export class Catalog {
|
|
47
|
-
constructor({ modelsDev, logger }) {
|
|
48
|
+
constructor({ modelsDev, logger, levels }) {
|
|
48
49
|
this.modelsDev = modelsDev;
|
|
49
50
|
this.logger = logger;
|
|
51
|
+
this.levels = levels ?? new LevelCatalog();
|
|
50
52
|
this.cache = new Map();
|
|
51
53
|
this.epoch = 0;
|
|
52
54
|
}
|
|
@@ -81,11 +83,32 @@ export class Catalog {
|
|
|
81
83
|
if (!force && hit && Date.now() - hit.at < CATALOG_TTL_MS && hit.overridesRev === conn.overridesRev) return hit.value;
|
|
82
84
|
void this.modelsDev.refresh();
|
|
83
85
|
const raw = await this.fetchGateway(conn, apiKey, signal);
|
|
84
|
-
const value = this.build(conn, raw);
|
|
86
|
+
const value = this.build(conn, raw, await this.resolveLevels(conn, raw));
|
|
85
87
|
if (epoch === this.epoch) this.cache.set(key, { at: Date.now(), value, overridesRev: conn.overridesRev });
|
|
86
88
|
return value;
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Reasoning levels for every id the catalog will contain, resolved before the
|
|
93
|
+
* synchronous build so each entry can carry its exact selector.
|
|
94
|
+
*/
|
|
95
|
+
async resolveLevels(conn, { models }) {
|
|
96
|
+
const overrides = conn.overrides ?? {};
|
|
97
|
+
const ids = new Set((models?.data ?? []).map((m) => m?.id).filter(Boolean));
|
|
98
|
+
for (const [id, ov] of Object.entries(overrides)) if (ov?.custom === true) ids.add(id);
|
|
99
|
+
const out = new Map();
|
|
100
|
+
await Promise.all([...ids].map(async (id) => {
|
|
101
|
+
const own = Object.hasOwn(overrides, id) ? overrides[id] : undefined;
|
|
102
|
+
const override = Array.isArray(own?.reasoningLevels) && own.reasoningLevels.length ? own.reasoningLevels : undefined;
|
|
103
|
+
const overrideMap = own?.thinkingLevelMap && Object.keys(own.thinkingLevelMap).length ? own.thinkingLevelMap : undefined;
|
|
104
|
+
const hasOverride = Boolean(override || overrideMap);
|
|
105
|
+
const effective = await resolveReasoningLevels({ id, override, overrideMap, catalog: this.levels });
|
|
106
|
+
const automatic = hasOverride ? await resolveReasoningLevels({ id, catalog: this.levels }) : effective;
|
|
107
|
+
out.set(id, { effective, automatic });
|
|
108
|
+
}));
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
89
112
|
/** Last good catalog for a connection without network, if any. */
|
|
90
113
|
key(conn) {
|
|
91
114
|
return JSON.stringify([conn.channelId, gatewayRoot(conn.baseURL), conn.keyFingerprint, conn.overridesRev, conn.headers, conn.enabledModels ?? null]);
|
|
@@ -95,7 +118,7 @@ export class Catalog {
|
|
|
95
118
|
return this.cache.get(this.key(conn))?.value;
|
|
96
119
|
}
|
|
97
120
|
|
|
98
|
-
build(conn, { models, pricing, pricingError }) {
|
|
121
|
+
build(conn, { models, pricing, pricingError }, levelInfo = new Map()) {
|
|
99
122
|
const priced = new Map();
|
|
100
123
|
for (const p of pricing?.data ?? []) priced.set(p.model_name, p);
|
|
101
124
|
const vendors = new Map((pricing?.vendors ?? []).map((v) => [v.id, v.name]));
|
|
@@ -146,7 +169,11 @@ export class Catalog {
|
|
|
146
169
|
automaticReasoning: dev?.reasoning ?? false,
|
|
147
170
|
outputModalities: dev?.outputModalities ?? [],
|
|
148
171
|
reasoning: ov.reasoning ?? dev?.reasoning ?? false,
|
|
149
|
-
|
|
172
|
+
// Only a reasoning model has a selector; image/embedding models show none.
|
|
173
|
+
reasoningLevels: (ov.reasoning ?? dev?.reasoning ?? false) ? levelInfo.get(id)?.effective.levels : [],
|
|
174
|
+
automaticReasoningLevels: (ov.reasoning ?? dev?.reasoning ?? false) ? levelInfo.get(id)?.automatic.levels : [],
|
|
175
|
+
reasoningLevelsSource: levelInfo.get(id)?.effective.source ?? 'default',
|
|
176
|
+
thinkingLevelMap: levelInfo.get(id)?.effective.map,
|
|
150
177
|
source: {
|
|
151
178
|
gateway: Boolean(localGatewayEntry),
|
|
152
179
|
upstreamGateway: gateway.has(upstreamModel),
|
package/lib/channels.js
CHANGED
|
@@ -45,12 +45,26 @@ export function resolveChannels(raw = {}) {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/** Merge only supplied fields; null removes one override, reset removes the whole override. */
|
|
48
|
+
/** A level map may only key known levels, and each value is a wire string or null. */
|
|
49
|
+
function isValidLevelMap(value) {
|
|
50
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
51
|
+
const entries = Object.entries(value);
|
|
52
|
+
if (!entries.length) return false;
|
|
53
|
+
return entries.every(([level, mapped]) => {
|
|
54
|
+
const known = LEVELS.includes(level);
|
|
55
|
+
// Custom names must be a safe id and must carry a value: that value is the whole point.
|
|
56
|
+
if (!known && !/^[a-z0-9][a-z0-9_-]{0,23}$/.test(level)) return false;
|
|
57
|
+
if (mapped === null) return known;
|
|
58
|
+
return typeof mapped === 'string' && mapped.length > 0 && mapped.length <= 64 && !/[\u0000-\u001f\u007f-\u009f]/.test(mapped);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
48
62
|
export function mergeModelOverride(previous, patch, reset = false) {
|
|
49
63
|
if (reset) return undefined;
|
|
50
64
|
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw channelError(400, 'override must be an object');
|
|
51
65
|
const next = { ...previous };
|
|
52
66
|
for (const [key, value] of Object.entries(patch)) {
|
|
53
|
-
if (!['name', 'upstreamModel', 'kind', 'hidden', 'contextWindow', 'maxTokens', 'input', 'reasoning', 'reasoningLevels', 'protocol', 'custom'].includes(key)) {
|
|
67
|
+
if (!['name', 'upstreamModel', 'kind', 'hidden', 'contextWindow', 'maxTokens', 'input', 'reasoning', 'reasoningLevels', 'thinkingLevelMap', 'protocol', 'custom'].includes(key)) {
|
|
54
68
|
if (Object.hasOwn(previous ?? {}, key) && JSON.stringify(value) === JSON.stringify(previous[key])) continue;
|
|
55
69
|
throw channelError(400, `unsupported override field: ${key}`);
|
|
56
70
|
}
|
|
@@ -62,6 +76,7 @@ export function mergeModelOverride(previous, patch, reset = false) {
|
|
|
62
76
|
else if (key === 'upstreamModel') valid = typeof value === 'string' && value.trim().length > 0 && value.length <= 500 && !/[\u0000-\u001f\u007f-\u009f]/.test(value);
|
|
63
77
|
else if (key === 'kind') valid = KINDS.includes(value);
|
|
64
78
|
else if (key === 'protocol') valid = PROTOCOLS.includes(value);
|
|
79
|
+
else if (key === 'thinkingLevelMap') valid = isValidLevelMap(value);
|
|
65
80
|
else valid = Array.isArray(value) && value.every((v) => (key === 'input' ? MODALITIES : LEVELS).includes(v));
|
|
66
81
|
if (!valid) throw channelError(400, `invalid override field: ${key}`);
|
|
67
82
|
next[key] = ['name', 'upstreamModel'].includes(key) ? value.trim() : value;
|
package/lib/levels.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reasoning ("thinking") levels per model.
|
|
3
|
+
*
|
|
4
|
+
* pi-ai renders a level selector from `thinkingLevelMap`: a `null` value hides that
|
|
5
|
+
* level, a string maps it to the vendor's own vocabulary, and an absent key falls
|
|
6
|
+
* back to the provider default. Without a map every reasoning model advertises the
|
|
7
|
+
* same five levels (`off`, `minimal`, `low`, `medium`, `high`) — which is wrong for
|
|
8
|
+
* vendors that cannot turn thinking off, have no effort parameter at all, or support
|
|
9
|
+
* `xhigh`/`max`.
|
|
10
|
+
*
|
|
11
|
+
* Resolution order, highest precedence first:
|
|
12
|
+
* 1. the user's per-model override (`reasoningLevels` in the channel config),
|
|
13
|
+
* 2. pi-ai's own shipped catalog for that model id — the exact map the built-in
|
|
14
|
+
* providers use, so a model here behaves like the same model there,
|
|
15
|
+
* 3. a vendor-family default (below) when the id is a relay alias the catalog
|
|
16
|
+
* does not know,
|
|
17
|
+
* 4. the pi-ai default (no map at all) for anything unrecognised.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** pi-ai's built-in level vocabulary, in display order. Custom names may be added on top. */
|
|
21
|
+
export const LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
22
|
+
|
|
23
|
+
/** A level id: the built-ins, or a user-chosen name the upstream understands. */
|
|
24
|
+
export const CUSTOM_LEVEL = /^[a-z0-9][a-z0-9_-]{0,23}$/;
|
|
25
|
+
|
|
26
|
+
/** Preset levels first (pi's order), then custom names alphabetically. */
|
|
27
|
+
export function orderLevels(names) {
|
|
28
|
+
const preset = LEVELS.filter((level) => names.includes(level));
|
|
29
|
+
const custom = names.filter((name) => !LEVELS.includes(name)).sort();
|
|
30
|
+
return [...preset, ...custom];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Provider catalogs to consult. Kept small: only vendors a New API relay resells. */
|
|
34
|
+
const CATALOG_PROVIDERS = ['openai', 'anthropic', 'google', 'deepseek', 'zai', 'moonshotai', 'xai', 'minimax', 'alibaba'];
|
|
35
|
+
|
|
36
|
+
/** First-party provider per model family, used to prefer the right catalog and default. */
|
|
37
|
+
const FAMILY_RULES = [
|
|
38
|
+
[/^claude|^fable/, 'anthropic'],
|
|
39
|
+
[/^(gpt|o\d|codex|chatgpt|dall-e)/, 'openai'],
|
|
40
|
+
[/^gemini|^gemma/, 'google'],
|
|
41
|
+
[/^deepseek/, 'deepseek'],
|
|
42
|
+
[/^glm|^cogview|^cogvideo/, 'zai'],
|
|
43
|
+
[/^(kimi|moonshot)/, 'moonshotai'],
|
|
44
|
+
[/^grok/, 'xai'],
|
|
45
|
+
[/^(minimax|abab|hailuo)/, 'minimax'],
|
|
46
|
+
[/^(qwen|qwq|wan)/, 'alibaba'],
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Family defaults for ids the catalog does not know (relay aliases such as
|
|
51
|
+
* `claude-opus-5-5` or `deepseek-v4.1-flash`). Each entry mirrors the shape pi-ai
|
|
52
|
+
* ships for that family; `null` hides a level, a string is sent upstream.
|
|
53
|
+
*/
|
|
54
|
+
const FAMILY_DEFAULTS = [
|
|
55
|
+
// Adaptive thinking: effort levels, no `minimal`, `max` available on every adaptive model.
|
|
56
|
+
[/^claude-(opus-([456]-?\d*|5)|sonnet-([456]|4-\d+|[56]-\d+)|fable|mythos)/, { off: null, minimal: null, low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max' }],
|
|
57
|
+
// Budget-based thinking: every level maps to a token budget, so all five apply.
|
|
58
|
+
// Older/unknown Claude: budget-based thinking covers every level (pi-ai ships no map).
|
|
59
|
+
[/^claude/, null],
|
|
60
|
+
// Pro variants expose a narrower set (gpt-5-pro: high only; later pros: medium+).
|
|
61
|
+
[/^gpt-5-pro/, { off: null, minimal: null, low: null, medium: null, high: 'high', xhigh: null, max: null }],
|
|
62
|
+
[/^gpt-5[.-][245]-pro/, { off: null, minimal: null, low: null, medium: 'medium', high: 'high', xhigh: 'xhigh', max: null }],
|
|
63
|
+
[/^gpt-6-astra/, { off: null, minimal: null, low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max' }],
|
|
64
|
+
[/^gpt-6[.-]1/, { off: null, minimal: null, low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max' }],
|
|
65
|
+
// GPT-5.1+ and GPT-6: "none" turns reasoning off; minimal is gone; xhigh/max exist.
|
|
66
|
+
[/^gpt-(5[.-][1-9]|5[.-]\d\d|6)/, { off: 'none', minimal: null, low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max' }],
|
|
67
|
+
// GPT-5.0: minimal exists, no xhigh/max.
|
|
68
|
+
[/^gpt-5/, { off: null, minimal: 'minimal', low: 'low', medium: 'medium', high: 'high', xhigh: null, max: null }],
|
|
69
|
+
// o-series: low/medium/high only.
|
|
70
|
+
[/^o\d/, { off: null, minimal: null, low: 'low', medium: 'medium', high: 'high', xhigh: null, max: null }],
|
|
71
|
+
// Gemini 3: two-step thinking level, cannot be switched off.
|
|
72
|
+
[/^gemini-[34]/, { off: null, minimal: 'MINIMAL', low: 'LOW', medium: 'MEDIUM', high: 'HIGH', xhigh: null, max: null }],
|
|
73
|
+
// Gemini 2.5: thinkingBudget; 0 disables it on flash models.
|
|
74
|
+
// Gemini 2.5 and older: budget-based, no level vocabulary (pi-ai ships no map).
|
|
75
|
+
[/^gemini/, null],
|
|
76
|
+
// DeepSeek V4 exposes effort with a gap at `medium`.
|
|
77
|
+
[/^deepseek/, { minimal: null, medium: null, low: 'low', high: 'high', max: 'max', xhigh: null }],
|
|
78
|
+
[/^glm-5[.-][3-9]/, { off: null, minimal: null, low: 'low', medium: null, high: 'high', xhigh: null, max: 'max' }],
|
|
79
|
+
[/^glm-5[.-]2/, { off: 'none', minimal: 'minimal', low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max' }],
|
|
80
|
+
[/^glm-5/, { off: 'none', minimal: null, low: 'low', medium: null, high: 'high', xhigh: null, max: 'max' }],
|
|
81
|
+
[/^glm/, null],
|
|
82
|
+
[/^(kimi-k3|moonshot-k3)/, { off: null, minimal: null, low: 'low', medium: null, high: 'high', xhigh: null, max: 'max' }],
|
|
83
|
+
[/^(kimi|moonshot)/, null],
|
|
84
|
+
[/^grok-4\.[5-9]/, { off: null, minimal: null, low: 'low', medium: 'medium', high: 'high', xhigh: null, max: null }],
|
|
85
|
+
[/^grok/, { off: 'none', minimal: null, low: 'low', medium: 'medium', high: 'high', xhigh: null, max: null }],
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
/** Normalise an id for catalog comparison: case, dots/underscores, vendor prefixes. */
|
|
89
|
+
function normalize(id) {
|
|
90
|
+
return String(id).toLowerCase().split('/').pop().replace(/[._]/g, '-');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Index one provider catalog by its normalised ids. */
|
|
94
|
+
function indexCatalog(catalog) {
|
|
95
|
+
const index = new Map();
|
|
96
|
+
for (const model of Object.values(catalog ?? {})) {
|
|
97
|
+
if (!model?.id) continue;
|
|
98
|
+
const key = normalize(model.id);
|
|
99
|
+
if (!index.has(key)) index.set(key, model);
|
|
100
|
+
}
|
|
101
|
+
return index;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Lazily loads and indexes pi-ai's shipped catalogs. Import failures are not fatal:
|
|
106
|
+
* the family defaults still give a model the right level set.
|
|
107
|
+
*/
|
|
108
|
+
export class LevelCatalog {
|
|
109
|
+
constructor({ providers = CATALOG_PROVIDERS } = {}) {
|
|
110
|
+
this.providers = providers;
|
|
111
|
+
this.index = undefined;
|
|
112
|
+
this.loading = undefined;
|
|
113
|
+
this.byFamily = new Map();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async load() {
|
|
117
|
+
if (this.index) return this.index;
|
|
118
|
+
this.loading ??= (async () => {
|
|
119
|
+
const index = new Map();
|
|
120
|
+
for (const name of this.providers) {
|
|
121
|
+
try {
|
|
122
|
+
const mod = await import(`@earendil-works/pi-ai/providers/${name}.models`);
|
|
123
|
+
for (const value of Object.values(mod)) {
|
|
124
|
+
const models = indexCatalog(value);
|
|
125
|
+
for (const [key, model] of models) if (!index.has(key)) index.set(key, model);
|
|
126
|
+
}
|
|
127
|
+
} catch { /* that provider catalog is absent in this pi-ai build */ }
|
|
128
|
+
}
|
|
129
|
+
this.index = index;
|
|
130
|
+
return index;
|
|
131
|
+
})();
|
|
132
|
+
return this.loading;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
familyOf(id) {
|
|
136
|
+
const key = normalize(id);
|
|
137
|
+
for (const [re, provider] of FAMILY_RULES) if (re.test(key)) return provider;
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* pi-ai's own entry for this id: exact, then progressively shorter prefixes so a
|
|
143
|
+
* relay variant (`claude-opus-5-5`, `claude-sonnet-5-5-high`) lands on its base model.
|
|
144
|
+
*/
|
|
145
|
+
async find(id) {
|
|
146
|
+
const index = await this.load();
|
|
147
|
+
const key = normalize(id);
|
|
148
|
+
const exact = index.get(key);
|
|
149
|
+
if (exact) return { model: exact, match: 'exact' };
|
|
150
|
+
const parts = key.split('-');
|
|
151
|
+
for (let n = parts.length - 1; n >= 2; n--) {
|
|
152
|
+
const hit = index.get(parts.slice(0, n).join('-'));
|
|
153
|
+
if (hit) return { model: hit, match: 'prefix' };
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Family default map, or `undefined` when the family keeps the pi-ai default. */
|
|
159
|
+
familyDefault(id) {
|
|
160
|
+
const key = normalize(id);
|
|
161
|
+
if (this.byFamily.has(key)) return this.byFamily.get(key);
|
|
162
|
+
let map;
|
|
163
|
+
for (const [re, value] of FAMILY_DEFAULTS) if (re.test(key)) { map = value; break; }
|
|
164
|
+
this.byFamily.set(key, map);
|
|
165
|
+
return map;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Levels a map exposes, in pi-ai display order. `off`/`minimal`/`low`/`medium`/`high`
|
|
171
|
+
* are shown unless explicitly `null`; `xhigh`/`max` only when mapped.
|
|
172
|
+
*/
|
|
173
|
+
export function levelsFromMap(map) {
|
|
174
|
+
if (!map) return ['off', 'minimal', 'low', 'medium', 'high'];
|
|
175
|
+
const names = LEVELS.filter((level) => {
|
|
176
|
+
const mapped = map[level];
|
|
177
|
+
if (mapped === null) return false;
|
|
178
|
+
if (level === 'xhigh' || level === 'max') return mapped !== undefined;
|
|
179
|
+
return true;
|
|
180
|
+
});
|
|
181
|
+
// A custom level exists only when it is mapped to something the upstream accepts.
|
|
182
|
+
for (const [level, mapped] of Object.entries(map)) {
|
|
183
|
+
if (LEVELS.includes(level)) continue;
|
|
184
|
+
if (typeof mapped === 'string' && CUSTOM_LEVEL.test(level)) names.push(level);
|
|
185
|
+
}
|
|
186
|
+
return orderLevels(names);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Build a pi-ai map that exposes exactly `levels`, keeping `base`'s upstream strings. */
|
|
190
|
+
export function mapFromLevels(levels, base) {
|
|
191
|
+
if (!Array.isArray(levels) || levels.length === 0) return undefined;
|
|
192
|
+
const allowed = new Set(levels);
|
|
193
|
+
const map = {};
|
|
194
|
+
for (const level of LEVELS) {
|
|
195
|
+
if (!allowed.has(level)) { map[level] = null; continue; }
|
|
196
|
+
const mapped = base?.[level];
|
|
197
|
+
map[level] = typeof mapped === 'string' ? mapped : level;
|
|
198
|
+
}
|
|
199
|
+
return map;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Resolve one model's reasoning levels.
|
|
204
|
+
*
|
|
205
|
+
* @returns `{ levels, map, source }` where `source` is `override` (user),
|
|
206
|
+
* `catalog` (pi-ai shipped data), `family` (vendor default) or `default`.
|
|
207
|
+
*/
|
|
208
|
+
export async function resolveReasoningLevels({ id, override, overrideMap, catalog }) {
|
|
209
|
+
const found = await catalog.find(id);
|
|
210
|
+
const base = found?.model?.thinkingLevelMap ?? catalog.familyDefault(id);
|
|
211
|
+
const source = found?.model?.thinkingLevelMap
|
|
212
|
+
? 'catalog'
|
|
213
|
+
: base ? 'family' : 'default';
|
|
214
|
+
// An explicit level→wire map is authoritative: it is literally what the user configured.
|
|
215
|
+
if (overrideMap && typeof overrideMap === 'object' && Object.keys(overrideMap).length) {
|
|
216
|
+
// The table is authoritative: preset levels not listed are hidden, and custom names
|
|
217
|
+
// are honoured as long as they carry a wire value.
|
|
218
|
+
const map = {};
|
|
219
|
+
for (const level of LEVELS) {
|
|
220
|
+
const mapped = overrideMap[level];
|
|
221
|
+
map[level] = typeof mapped === 'string' ? mapped : null;
|
|
222
|
+
}
|
|
223
|
+
for (const [level, mapped] of Object.entries(overrideMap)) {
|
|
224
|
+
if (LEVELS.includes(level) || !CUSTOM_LEVEL.test(level)) continue;
|
|
225
|
+
if (typeof mapped === 'string') map[level] = mapped;
|
|
226
|
+
}
|
|
227
|
+
const levels = levelsFromMap(map);
|
|
228
|
+
// A table that exposes nothing usable is a mistake, not an empty selector: fall back.
|
|
229
|
+
if (levels.length) return { levels, map, source: 'override' };
|
|
230
|
+
}
|
|
231
|
+
if (Array.isArray(override) && override.length) {
|
|
232
|
+
return { levels: override.filter((l) => LEVELS.includes(l)), map: mapFromLevels(override, base), source: 'override' };
|
|
233
|
+
}
|
|
234
|
+
return { levels: levelsFromMap(base), map: base ?? undefined, source };
|
|
235
|
+
}
|
package/package.json
CHANGED