pi-provider-string-ai 0.0.0-stage → 0.1.0-beta.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 +40 -0
- package/README.md +155 -2
- package/package.json +56 -4
- package/src/catalog.ts +191 -0
- package/src/config.ts +208 -0
- package/src/index.ts +106 -0
- package/src/provider.ts +343 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# 变更记录
|
|
2
|
+
|
|
3
|
+
## 0.1.0-beta.1
|
|
4
|
+
|
|
5
|
+
- 首个公开 npm 测试版本,使用 `beta` 渠道;不作为稳定版发布。
|
|
6
|
+
- 增加 npm 安装、固定版本与卸载说明;补充仓库元数据和显式发布配置。
|
|
7
|
+
- 保持 Provider 生产源码不变;保留 `UNLICENSED`,本次不自行决定开源许可证。
|
|
8
|
+
- 实际包仅包含 manifest、README、CHANGELOG 和四个 Provider 源文件,不包含宿主补丁、凭据、依赖目录及审计产物。
|
|
9
|
+
- 原版 Pi 1.4.0 的 SEC-01 限制继续存在;真实 String AI 服务尚未完成验收,模拟工具闭环不等于生产可用性承诺。
|
|
10
|
+
|
|
11
|
+
## 0.1.0-dev.0(未发布)
|
|
12
|
+
|
|
13
|
+
### 普通官方 Pi 接入收敛(2026-10-23)
|
|
14
|
+
|
|
15
|
+
- 用户确认正式端点后,缺省使用 `https://www.string.ink/v1`,普通流程不再要求模拟宿主或手工填写地址。
|
|
16
|
+
- 新增可选择的 Pi 内置 OpenAI Responses 精确 ID 参考模板;显式档案优先,未知 ID 不猜测。模板能力/费用不代表 String 实测或分组账单。
|
|
17
|
+
- 保留显式 `models: []` 的限制;自定义端点默认不启用参考模板,不静默迁移用户文件。
|
|
18
|
+
- 完善原生登录说明、帮助与脱敏状态/刷新诊断;分组随 Key 绑定,不新增分组参数。
|
|
19
|
+
- 请求终检增加 `store:false`,不将其宣传为服务或客户端不保存日志。
|
|
20
|
+
- 新增未修改官方 Pi 的参考模板 manifest 集成与实际打包验证;具体结果另见 `docs/用户接入改进记录.md`,不沿用历史补丁运行时全绿数字。
|
|
21
|
+
- README 改为官方 Pi 使用入口,模拟演示单列。原版组合器的 SEC-01 未修复且发布风险仍需明确接受或解决。
|
|
22
|
+
|
|
23
|
+
### 历史开发与模拟验收
|
|
24
|
+
|
|
25
|
+
- 建立独立 ESM TypeScript Pi 扩展包;宿主 SDK 使用 peerDependencies,开发版本由锁文件固定。
|
|
26
|
+
- 实现严格配置校验、HTTPS 地址规范化、有限超时与响应大小的模型目录发现。
|
|
27
|
+
- 注册 `string-ai`,复用原生 API Key 登录和 Responses 适配器;新增状态与手动刷新命令。
|
|
28
|
+
- 模型可用性限定为服务目录与经确认能力档案的交集;不内置猜测的生产端点、模型或价格。
|
|
29
|
+
- 添加回环模拟测试、宿主集成验证及包内容检查;各项实际结果见工作区的 Markdown 验证报告。
|
|
30
|
+
- 修复 Pi 1.4.0 manifest/jiti 对适配器子路径的解析问题,使用宿主支持的 compat 原生工厂;支持默认 auto 固定走 SSE,继续拒绝显式 WebSocket。
|
|
31
|
+
|
|
32
|
+
- 修复 LOGIC-01:原 `onPayload` 完成后和原生 SDK 序列化后验证最终 model/stream,保留合法回调和单次序列化语义。
|
|
33
|
+
- SEC-01 本地修复:新增可选 `validateRequest` 准入与分发保留契约,覆盖隔离宿主 unbundled 与 bundled CLI;原安装不改,上游 Pi 1.4.0 仍有该问题。
|
|
34
|
+
- 新增 `npm run demo`:无需全局安装 Pi/真实 Key,真实终端搭配回环模拟模型完成受限文件工具演示;`demo:smoke` 验证同一 CLI 的 RPC 路径。
|
|
35
|
+
- 新增 `npm run test:local`:在独立运行时执行单测、集成、打包、类型和 lint 门;审查修复后父任务 Node22/24 均为184/184单测、6/6集成及其余门通过。
|
|
36
|
+
- 修复运行时静态硬链接写穿及补丁/CLI 预检缺口;已有文件改为非截断替换,未知文件不自动删除。
|
|
37
|
+
- 演示专用宿主在原生命令分发前限制 export/share/import/shell 等入口;smoke 按完整成功终态验收,拒绝完整文案后失败、缺终态或取消。第二轮两路独立复核均通过,结论仅限本地模拟体验。
|
|
38
|
+
- 新增本地体验与宿主修复 Markdown 指南;真实 API 和公开发布仍未执行。
|
|
39
|
+
|
|
40
|
+
没有生产服务修改、鉴权真实模型联调、npm 发布或 Git 提交。已按用户授权进行正式站点无凭据只读探测和生产版本标识核查;未读取真实 Key。当前面向用户本机限额试用,不构成正式支持承诺。模拟演示仍不得填真实 Key。
|
package/README.md
CHANGED
|
@@ -1,3 +1,156 @@
|
|
|
1
|
-
#
|
|
1
|
+
# String AI Pi Provider
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
为**官方 Pi** 增加 `string-ai` 提供商:复用原生 API Key 登录、模型选择、Responses 流式适配器和文件/终端工具,不新增网关或定制 Agent。
|
|
4
|
+
|
|
5
|
+
**测试版 `0.1.0-beta.1`,使用 npm `beta` 渠道,不是稳定版。** 真实 String AI 模型调用尚未完成验收,官方 Pi 1.4.0 的 SEC-01 限制仍存在(见下方安全边界)。仅建议在独立测试目录使用限额、可撤销的普通 API Key 试用;不把回环模拟结果或 npm 可安装当成生产可用性保证。
|
|
6
|
+
|
|
7
|
+
## 本机真实试用
|
|
8
|
+
|
|
9
|
+
### 1. 官网创建 Key
|
|
10
|
+
|
|
11
|
+
在 `https://www.string.ink/` 创建普通 API Key,选择支持 **Responses、第三方客户端和目标模型** 的分组。分组已绑定 Key,插件不再要求选分组,也不发送额外 `group` 参数。不要使用 WebChat 内部 Key 或浏览器登录态。
|
|
12
|
+
|
|
13
|
+
### 2. 在官方 Pi 安装插件
|
|
14
|
+
|
|
15
|
+
发布后可固定安装此测试版:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pi --version
|
|
19
|
+
pi install npm:pi-provider-string-ai@0.1.0-beta.1
|
|
20
|
+
pi list
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
若要跟随测试渠道,可安装 `npm:pi-provider-string-ai@beta`。已使用本地目录版本的用户,应先用 `pi remove <原本地插件路径>` 移除原来源,避免重复注册同名 Provider。
|
|
24
|
+
|
|
25
|
+
本轮已用官方 Pi `1.4.0` 在 Windows / Node `22.23.2`、`24.21.0` 验证普通插件加载、模拟工具闭环及实际包产物;引擎最低声明为 Node.js `>=22.19.0`,不代表所有满足声明的版本均已实测。开发者仍可使用 `pi install <本地源码目录>` 直接引用源码,不使用项目内 `.runtime` 副本;普通用户使用上方 npm 安装方式。尚未验证的 Pi 版本不属于支持承诺;`peerDependencies: "*"` 遵循 Pi 包规范,不是全版本兼容声明。
|
|
26
|
+
|
|
27
|
+
在专用测试项目目录启动 `pi`。已有窗口使用 `/reload` 或重启。
|
|
28
|
+
|
|
29
|
+
### 3. 登录、选择模型
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
/login string-ai
|
|
33
|
+
/model
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Key 只在本机 Pi 提示中输入,不发聊天、不放命令参数/源码/截图。Pi 1.4.0 原生 secret 输入并非真正遮罩,登录时避免录屏或共享终端;原生认证存储不是加密保险库。
|
|
37
|
+
|
|
38
|
+
Pi TUI 登录成功后自动刷新模型;从 `/model` 选择 `string-ai` 下当前可见的模型。**不需要登录 OpenAI/ChatGPT,也不需要填写 Codex TOML。** 默认 Base URL 为 `https://www.string.ink/v1`,全新安装通常无需 `string-ai.json`。
|
|
39
|
+
|
|
40
|
+
先测试短文本,再在测试目录做“读文件、修改、写测试、运行测试”。检查实际文件和测试输出。`gpt-6-astra` 只有在当前 Key 返回并出现在列表中时才能选;不要把示例当可用性保证。
|
|
41
|
+
|
|
42
|
+
辅助命令:
|
|
43
|
+
|
|
44
|
+
- `/string-ai-help`:查看流程、分组和安全提示,不联网。
|
|
45
|
+
- `/string-ai-status`:查看认证、模型、缺档案、参考模板和过期状态,不联网。
|
|
46
|
+
- `/string-ai-refresh`:换分组或诊断时手动重新获取模型目录;不发送推理请求。
|
|
47
|
+
|
|
48
|
+
## 模型能力与费用
|
|
49
|
+
|
|
50
|
+
可选模型来自**当前 Key 的服务目录**,不是 Pi 内置模型全集。模型 ID 必须精确匹配,不按前缀、`owned_by` 或显示名猜测能力。
|
|
51
|
+
|
|
52
|
+
1. 显式配置的 String AI 模型档案优先。
|
|
53
|
+
2. 默认官方端点、且未显式提供 `models` 时,可复用 Pi 内置 OpenAI Responses 同 ID 的**兼容参考模板**。名称有“Pi参考”标记,状态有参考模型数量。
|
|
54
|
+
3. 未匹配模型保持不可选;不会给未知模型虚构上下文、输出上限或免费价格。
|
|
55
|
+
|
|
56
|
+
**参考模板不是 String 实测能力;Pi 显示的费用是上游参考估算,不是当前 Key 分组的结算账单。** 实际费用、限额、路由与可用能力以官网和该 Key 的真实调用为准。参考模板保留原生推理档位和费用阶梯,但不复制上游请求头、地址、采样参数或专有兼容开关。
|
|
57
|
+
|
|
58
|
+
登录并确认模型可用后,如需 `xhigh`,可使用 Pi 原生参数:
|
|
59
|
+
|
|
60
|
+
```powershell
|
|
61
|
+
pi --provider string-ai --model gpt-6-astra --thinking xhigh
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
不强制所有用户使用最高推理档位;具体档位仍受模型模板和服务支持限制。`review_model`、`goals`、客户端网络权限、WSL 确认等不是 Provider 配置,不从 Codex 配置自动迁移。
|
|
65
|
+
|
|
66
|
+
## 旧配置和自定义端点
|
|
67
|
+
|
|
68
|
+
配置位于当前 Pi agent 目录的 `string-ai.json`,不包含 Key。修改后 `/reload`。旧文件中的 **`models: []` 是明确限制,不会自动被覆盖**;若只是旧教程模板,删除这个字段,或显式开启参考模板:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"version": 1,
|
|
73
|
+
"useBuiltinOpenAIModels": true
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
不要用此例覆盖自己的其他设置。保留 `models` 而不启用参考模板时,只使用显式档案。
|
|
78
|
+
|
|
79
|
+
已确认的备用地址是 `https://ai.qpcmqam.cn/v1`,仅手动切换,不自动跨域重发。使用时在 `string-ai.json` 同时设置 `baseUrl` 为该地址和 `useBuiltinOpenAIModels: true`;不填写 Key,修改后 `/reload`、`/string-ai-refresh`。默认主地址保持不变。
|
|
80
|
+
|
|
81
|
+
若停在 `/model` 面板,先按 `Esc` 回主输入框再输入命令;面板内输入会作为模型搜索文字。
|
|
82
|
+
|
|
83
|
+
其他自定义端点示例:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"version": 1,
|
|
88
|
+
"baseUrl": "https://api.example.invalid/v1",
|
|
89
|
+
"useBuiltinOpenAIModels": true,
|
|
90
|
+
"timeoutMs": 10000
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`example.invalid` 是不可用示例,必须换成你明确选择的端点;它不是 String AI 地址。自定义端点默认不启用参考模板,需显式选择。远程端点必须 HTTPS;测试回环 HTTP 另需 `allowInsecureLocalhost: true`。插件不验证自定义服务的实际能力或信任度。
|
|
95
|
+
|
|
96
|
+
完整显式档案 `models` 支持:`id`、`name`、`contextWindow`、`maxTokens`、`reasoning`、`input`、`cost`(四项每百万 token 费用)、`evidence`。资料须有来源,未知费用不能填零冒充免费。显式档案中的原始 ID 不重命名、不纠正,重复 ID 拒绝。
|
|
97
|
+
|
|
98
|
+
## 故障提示
|
|
99
|
+
|
|
100
|
+
- **401**:Key 无效、过期或撤销;重新登录,不输出 Key。
|
|
101
|
+
- **403**:检查分组、协议、模型或第三方客户端权限;不伪装 Codex 绕过。
|
|
102
|
+
- **429**:检查限流、并发和额度,不跨 Key 或协议重试。
|
|
103
|
+
- **无可用模型**:区分服务返回空目录与缺能力档案;查看 `/string-ai-status`。
|
|
104
|
+
- **服务/网络/超时**:诊断不回显原始服务错误体;同身份旧目录可标为过期,但不算新刷新成功。
|
|
105
|
+
- **离线模式**:`PI_OFFLINE` 只要存在(包括空字符串、`0`)便阻止手动刷新。恢复联网需移除变量。
|
|
106
|
+
- **Windows 找不到 Bash**:这是 Pi 工具环境问题;在当前 PowerShell 将实际 Git `bin` 目录加到 PATH。本机示例 `$env:Path = 'D:\Git\bin;' + $env:Path`,不要在其他机器照抄路径。
|
|
107
|
+
|
|
108
|
+
## 安全和兼容边界
|
|
109
|
+
|
|
110
|
+
- 固定 Responses HTTP/SSE;宿主默认 `auto` 选择 SSE,显式 WebSocket 拒绝。不静默跨协议、模型或 Key 重发。
|
|
111
|
+
- 经插件自身的请求路径校验最终 `model`、`stream:true`、`store:false`;`store:false` **不等于服务无日志或 Pi 不保存会话**。
|
|
112
|
+
- Provider 禁用内部重试,但 Pi agent 层、其他扩展和手工重试是另一层;限额测试可在自己的设置中禁用 agent 重试,不能声称全局零重复计费。
|
|
113
|
+
- 模型目录只在内存中保存,不从磁盘恢复;换 Key/端点隔离目录,401/403 清除可用性。重启离线没有模型可选。
|
|
114
|
+
- **原版 Pi 1.4.0 的 SEC-01 限制仍存在**:同名 `models.json` 覆盖可能导致宿主全局 adapter 绕过插件检查。不要为 `string-ai` 再叠加模型、端点或协议覆盖;高级配置使用插件自身配置。插件不是其他扩展或用户代码的安全沙箱,服务端仍须校验 Key/分组/模型/额度。
|
|
115
|
+
- 本地宿主补丁是研究设施,不要求普通用户安装,也不宣称上游问题已解决。历史184项通过属于补丁运行时,不代表官方 Pi 全量安全测试全绿。
|
|
116
|
+
- `/logout` 不撤销服务端 Key;环境变量 `STRING_AI_API_KEY` 仍可能认证。卸载不清除其他 Provider 的凭据,也不代为撤销 Key。
|
|
117
|
+
|
|
118
|
+
卸载此固定版本(以 `pi list` 显示的来源为准):
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
pi remove npm:pi-provider-string-ai@0.1.0-beta.1
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
固定版本不会自动升级;升级或回退时显式安装目标版本。跟随 `@beta` 渠道时,使用 `pi update npm:pi-provider-string-ai@beta`,然后 `/reload` 或重启。Pi 1.4.0 的裸 `pi update` 更新宿主,不是只更新插件。
|
|
125
|
+
|
|
126
|
+
## 开发验证和模拟演示
|
|
127
|
+
|
|
128
|
+
项目开发依赖锁定 Pi 1.4.0。真实服务、官方宿主、打包安装、npm 分发分别验收,不互相替代。设置专用 `TMPDIR` 后:
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
npm run check
|
|
132
|
+
npm run lint
|
|
133
|
+
npm test
|
|
134
|
+
npm run test:integration
|
|
135
|
+
npm run verify:package
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
原版宿主全量测试保留 SEC-01 已知失败,不能跳过后称全绿;查看本轮记录中的基线差异。打包验证检查实际 npm pack 产物,包含 env/login × 显式/参考模板闭环,均用回环模拟,无真实 Key。
|
|
139
|
+
|
|
140
|
+
仅看固定模拟演示:`npm run demo` → `/demo-run` → `/exit`。这是真实文件工具搭配固定模拟模型,不是上述真实使用入口。`npm run test:local` 使用隔离补丁宿主,仅作研究回归。
|
|
141
|
+
|
|
142
|
+
源码工作区说明(不随 npm 包分发):
|
|
143
|
+
|
|
144
|
+
以下是本地源码路径,不是 npm 包内链接;上面的接入步骤在包内 README 中已完整提供。
|
|
145
|
+
|
|
146
|
+
- `docs/官方Pi使用指南.md`
|
|
147
|
+
- `docs/用户接入改进记录.md`
|
|
148
|
+
- `docs/真实联调与发布待确认清单.md`
|
|
149
|
+
- `docs/本地体验指南.md`
|
|
150
|
+
- `docs/独立安全审查记录.md`
|
|
151
|
+
|
|
152
|
+
## 发布
|
|
153
|
+
|
|
154
|
+
本包的发布配置固定为公共 npm Registry、`public` 可见性和 `beta` 标签。许可证暂保留 `UNLICENSED`,不宣称采用开源许可证。公开 tarball 包含 Provider 源码;GitHub 仓库私有不改变 npm 产物的公开性。
|
|
155
|
+
|
|
156
|
+
`0.1.0-beta.1` 只用于受控测试,不作为真实服务和全部宿主边界已验收的声明。正式稳定版需完成真实模型、权限、费用、升级/回退验收及已知风险处理。开发脚本、测试和研究材料不包含在 npm tarball 中。
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-provider-string-ai",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-beta.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/priestwanderer/pi-provider-string-ai.git"
|
|
8
|
+
},
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"registry": "https://registry.npmjs.org/",
|
|
11
|
+
"access": "public",
|
|
12
|
+
"tag": "beta"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"description": "String AI Responses provider extension for Pi",
|
|
16
|
+
"license": "UNLICENSED",
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22.19.0"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"pi-package",
|
|
22
|
+
"string-ai",
|
|
23
|
+
"provider"
|
|
24
|
+
],
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"README.md",
|
|
28
|
+
"CHANGELOG.md"
|
|
29
|
+
],
|
|
30
|
+
"pi": {
|
|
31
|
+
"extensions": [
|
|
32
|
+
"./src/index.ts"
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@earendil-works/pi-ai": "*",
|
|
37
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@biomejs/biome": "2.6.2",
|
|
41
|
+
"@earendil-works/pi-ai": "1.4.0",
|
|
42
|
+
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
43
|
+
"@types/node": "22.20.5",
|
|
44
|
+
"tsx": "4.23.15",
|
|
45
|
+
"typescript": "7.0.2"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"demo": "node scripts/local-demo.mjs",
|
|
49
|
+
"demo:smoke": "node scripts/local-demo.mjs --smoke",
|
|
50
|
+
"test:local": "node scripts/run-local-tests.mjs",
|
|
51
|
+
"check": "tsc --noEmit",
|
|
52
|
+
"lint": "biome lint src",
|
|
53
|
+
"verify:package": "node scripts/verify-package.mjs",
|
|
54
|
+
"test": "node --import tsx --test tests/*.test.ts",
|
|
55
|
+
"test:integration": "node --import tsx --test tests/integration/*.test.ts",
|
|
56
|
+
"validate:docs": "python scripts/validate-docs.py"
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { normalizeBaseUrl } from './config.js';
|
|
2
|
+
|
|
3
|
+
type CatalogErrorCode =
|
|
4
|
+
| 'auth'
|
|
5
|
+
| 'permission'
|
|
6
|
+
| 'rate_limit'
|
|
7
|
+
| 'server'
|
|
8
|
+
| 'http'
|
|
9
|
+
| 'timeout'
|
|
10
|
+
| 'cancelled'
|
|
11
|
+
| 'network'
|
|
12
|
+
| 'invalid_catalog'
|
|
13
|
+
| 'too_large';
|
|
14
|
+
|
|
15
|
+
export class CatalogError extends Error {
|
|
16
|
+
code: CatalogErrorCode;
|
|
17
|
+
status?: number;
|
|
18
|
+
|
|
19
|
+
constructor(code: CatalogErrorCode, status?: number) {
|
|
20
|
+
super(`String AI 模型发现失败(${code})。`);
|
|
21
|
+
this.name = 'CatalogError';
|
|
22
|
+
this.code = code;
|
|
23
|
+
this.status = status;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseCatalog(raw: unknown): { id: string; name?: string }[] {
|
|
28
|
+
if (
|
|
29
|
+
raw === null ||
|
|
30
|
+
typeof raw !== 'object' ||
|
|
31
|
+
Array.isArray(raw) ||
|
|
32
|
+
!Object.hasOwn(raw, 'data') ||
|
|
33
|
+
!('data' in raw) ||
|
|
34
|
+
!Array.isArray(raw.data)
|
|
35
|
+
)
|
|
36
|
+
throw new CatalogError('invalid_catalog');
|
|
37
|
+
if (raw.data.length > 1000) throw new CatalogError('too_large');
|
|
38
|
+
const result: { id: string; name?: string }[] = [];
|
|
39
|
+
const seen = new Set<string>();
|
|
40
|
+
for (const item of raw.data) {
|
|
41
|
+
if (
|
|
42
|
+
item === null ||
|
|
43
|
+
typeof item !== 'object' ||
|
|
44
|
+
Array.isArray(item) ||
|
|
45
|
+
!Object.hasOwn(item, 'id') ||
|
|
46
|
+
typeof item.id !== 'string' ||
|
|
47
|
+
item.id.length === 0 ||
|
|
48
|
+
item.id.length > 256 ||
|
|
49
|
+
item.id.trim() !== item.id ||
|
|
50
|
+
/[\u0000-\u001f\u007f-\u009f]/u.test(item.id)
|
|
51
|
+
)
|
|
52
|
+
throw new CatalogError('invalid_catalog');
|
|
53
|
+
if (seen.has(item.id)) continue;
|
|
54
|
+
seen.add(item.id);
|
|
55
|
+
const name = Object.hasOwn(item, 'display_name') ? item.display_name : undefined;
|
|
56
|
+
const validName =
|
|
57
|
+
typeof name === 'string' &&
|
|
58
|
+
name.length > 0 &&
|
|
59
|
+
name.trim() === name &&
|
|
60
|
+
!/[\u0000-\u001f\u007f-\u009f]/u.test(name);
|
|
61
|
+
result.push(validName ? { id: item.id, name } : { id: item.id });
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function discoverModels(options: {
|
|
67
|
+
baseUrl: string;
|
|
68
|
+
apiKey: string;
|
|
69
|
+
signal: AbortSignal;
|
|
70
|
+
timeoutMs: number;
|
|
71
|
+
fetch?: typeof globalThis.fetch;
|
|
72
|
+
}): Promise<{ id: string; name?: string }[]> {
|
|
73
|
+
if (options.signal.aborted) throw new CatalogError('cancelled');
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
let abortCode: 'cancelled' | 'timeout' | undefined;
|
|
76
|
+
const abort = (code: 'cancelled' | 'timeout') => {
|
|
77
|
+
if (abortCode !== undefined) return;
|
|
78
|
+
abortCode = code;
|
|
79
|
+
controller.abort();
|
|
80
|
+
};
|
|
81
|
+
const onAbort = () => abort('cancelled');
|
|
82
|
+
options.signal.addEventListener('abort', onAbort, { once: true });
|
|
83
|
+
const timer = setTimeout(() => abort('timeout'), options.timeoutMs);
|
|
84
|
+
try {
|
|
85
|
+
// HTTP 回环地址由配置层的显式开关授权;此处仍拒绝非回环 HTTP。
|
|
86
|
+
const baseUrl = normalizeBaseUrl(options.baseUrl, true);
|
|
87
|
+
const pendingResponse = (options.fetch ?? globalThis.fetch)(`${baseUrl}/models`, {
|
|
88
|
+
method: 'GET',
|
|
89
|
+
redirect: 'error',
|
|
90
|
+
signal: controller.signal,
|
|
91
|
+
headers: { Authorization: `Bearer ${options.apiKey}` },
|
|
92
|
+
});
|
|
93
|
+
// 注入的 fetch 可能不遵守信号;晚到结果不能继续提交或持有连接。
|
|
94
|
+
void pendingResponse.then(
|
|
95
|
+
(response) => {
|
|
96
|
+
if (controller.signal.aborted) void response.body?.cancel().catch(() => {});
|
|
97
|
+
},
|
|
98
|
+
() => {},
|
|
99
|
+
);
|
|
100
|
+
const response = await abortable(pendingResponse, controller.signal);
|
|
101
|
+
if (!response.ok) {
|
|
102
|
+
const status = response.status;
|
|
103
|
+
const code =
|
|
104
|
+
status === 401
|
|
105
|
+
? 'auth'
|
|
106
|
+
: status === 403
|
|
107
|
+
? 'permission'
|
|
108
|
+
: status === 429
|
|
109
|
+
? 'rate_limit'
|
|
110
|
+
: status >= 500 && status <= 599
|
|
111
|
+
? 'server'
|
|
112
|
+
: 'http';
|
|
113
|
+
void response.body?.cancel().catch(() => {});
|
|
114
|
+
throw new CatalogError(code, status);
|
|
115
|
+
}
|
|
116
|
+
const length = response.headers.get('content-length');
|
|
117
|
+
if (length !== null && /^\d+$/.test(length) && Number(length) > 1024 * 1024) {
|
|
118
|
+
void response.body?.cancel().catch(() => {});
|
|
119
|
+
throw new CatalogError('too_large');
|
|
120
|
+
}
|
|
121
|
+
let raw: unknown;
|
|
122
|
+
const text = await readBody(response, controller.signal);
|
|
123
|
+
try {
|
|
124
|
+
raw = JSON.parse(text);
|
|
125
|
+
} catch {
|
|
126
|
+
throw new CatalogError('invalid_catalog');
|
|
127
|
+
}
|
|
128
|
+
return parseCatalog(raw);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (abortCode !== undefined) throw new CatalogError(abortCode);
|
|
131
|
+
if (error instanceof CatalogError) throw new CatalogError(error.code, error.status);
|
|
132
|
+
throw new CatalogError('network');
|
|
133
|
+
} finally {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
options.signal.removeEventListener('abort', onAbort);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function readBody(response: Response, signal: AbortSignal): Promise<string> {
|
|
140
|
+
if (response.body === null) throw new CatalogError('invalid_catalog');
|
|
141
|
+
const reader = response.body.getReader();
|
|
142
|
+
const chunks: Uint8Array[] = [];
|
|
143
|
+
let bytes = 0;
|
|
144
|
+
try {
|
|
145
|
+
while (true) {
|
|
146
|
+
const chunk = await abortable(reader.read(), signal);
|
|
147
|
+
if (chunk.done) break;
|
|
148
|
+
bytes += chunk.value.byteLength;
|
|
149
|
+
if (bytes > 1024 * 1024) {
|
|
150
|
+
void reader.cancel().catch(() => {});
|
|
151
|
+
throw new CatalogError('too_large');
|
|
152
|
+
}
|
|
153
|
+
chunks.push(chunk.value);
|
|
154
|
+
}
|
|
155
|
+
} catch (error) {
|
|
156
|
+
void reader.cancel().catch(() => {});
|
|
157
|
+
throw error;
|
|
158
|
+
} finally {
|
|
159
|
+
reader.releaseLock();
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks, bytes));
|
|
163
|
+
} catch {
|
|
164
|
+
throw new CatalogError('invalid_catalog');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function abortable<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
169
|
+
return new Promise<T>((resolve, reject) => {
|
|
170
|
+
const onAbort = () => {
|
|
171
|
+
signal.removeEventListener('abort', onAbort);
|
|
172
|
+
reject(new CatalogError('cancelled'));
|
|
173
|
+
};
|
|
174
|
+
if (signal.aborted) {
|
|
175
|
+
void pending.catch(() => {});
|
|
176
|
+
onAbort();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
180
|
+
pending.then(
|
|
181
|
+
(value) => {
|
|
182
|
+
signal.removeEventListener('abort', onAbort);
|
|
183
|
+
resolve(value);
|
|
184
|
+
},
|
|
185
|
+
(error: unknown) => {
|
|
186
|
+
signal.removeEventListener('abort', onAbort);
|
|
187
|
+
reject(error);
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export interface ModelProfile {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
contextWindow: number;
|
|
8
|
+
maxTokens: number;
|
|
9
|
+
reasoning: boolean;
|
|
10
|
+
input: ('text' | 'image')[];
|
|
11
|
+
cost: {
|
|
12
|
+
input: number;
|
|
13
|
+
output: number;
|
|
14
|
+
cacheRead: number;
|
|
15
|
+
cacheWrite: number;
|
|
16
|
+
};
|
|
17
|
+
evidence: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface StringAiConfig {
|
|
21
|
+
version: 1;
|
|
22
|
+
baseUrl?: string;
|
|
23
|
+
models: ModelProfile[];
|
|
24
|
+
useBuiltinOpenAIModels?: boolean;
|
|
25
|
+
allowInsecureLocalhost: boolean;
|
|
26
|
+
timeoutMs: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function invalidConfig(): never {
|
|
30
|
+
throw new Error('String AI 配置无效,请检查配置格式。');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function object(value: unknown): Record<string, unknown> {
|
|
34
|
+
if (
|
|
35
|
+
value === null ||
|
|
36
|
+
typeof value !== 'object' ||
|
|
37
|
+
Array.isArray(value) ||
|
|
38
|
+
![Object.prototype, null].includes(Object.getPrototypeOf(value))
|
|
39
|
+
)
|
|
40
|
+
invalidConfig();
|
|
41
|
+
return value as Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function knownFields(value: Record<string, unknown>, fields: string[]): void {
|
|
45
|
+
if (Reflect.ownKeys(value).some((key) => typeof key !== 'string' || !fields.includes(key)))
|
|
46
|
+
invalidConfig();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseConfig(raw: unknown): StringAiConfig {
|
|
50
|
+
const value = object(raw);
|
|
51
|
+
knownFields(value, [
|
|
52
|
+
'version',
|
|
53
|
+
'baseUrl',
|
|
54
|
+
'models',
|
|
55
|
+
'allowInsecureLocalhost',
|
|
56
|
+
'timeoutMs',
|
|
57
|
+
'useBuiltinOpenAIModels',
|
|
58
|
+
]);
|
|
59
|
+
if (value.version !== 1) invalidConfig();
|
|
60
|
+
const timeoutMs = value.timeoutMs === undefined ? 10000 : value.timeoutMs;
|
|
61
|
+
const allowInsecureLocalhost =
|
|
62
|
+
value.allowInsecureLocalhost === undefined ? false : value.allowInsecureLocalhost;
|
|
63
|
+
if (
|
|
64
|
+
typeof timeoutMs !== 'number' ||
|
|
65
|
+
!Number.isInteger(timeoutMs) ||
|
|
66
|
+
timeoutMs <= 0 ||
|
|
67
|
+
timeoutMs > 60000
|
|
68
|
+
)
|
|
69
|
+
invalidConfig();
|
|
70
|
+
if (typeof allowInsecureLocalhost !== 'boolean') invalidConfig();
|
|
71
|
+
// 正式端点由服务方确认;读取默认配置不会自行联网或创建凭据。
|
|
72
|
+
const baseUrl = normalizeBaseUrl(
|
|
73
|
+
value.baseUrl === undefined ? 'https://www.string.ink/v1' : (value.baseUrl as string),
|
|
74
|
+
allowInsecureLocalhost,
|
|
75
|
+
);
|
|
76
|
+
// 仅官网默认配置自动启用;显式模型列表(含空列表)保留原有安全门。
|
|
77
|
+
const useBuiltinOpenAIModels =
|
|
78
|
+
value.useBuiltinOpenAIModels === undefined
|
|
79
|
+
? baseUrl === 'https://www.string.ink/v1' && value.models === undefined
|
|
80
|
+
: value.useBuiltinOpenAIModels;
|
|
81
|
+
if (typeof useBuiltinOpenAIModels !== 'boolean') invalidConfig();
|
|
82
|
+
const models = value.models === undefined ? [] : value.models;
|
|
83
|
+
if (
|
|
84
|
+
!Array.isArray(models) ||
|
|
85
|
+
models.length > 1000 ||
|
|
86
|
+
(models.length > 0 && baseUrl === undefined)
|
|
87
|
+
)
|
|
88
|
+
invalidConfig();
|
|
89
|
+
const profiles = Array.from(models, parseProfile);
|
|
90
|
+
if (new Set(profiles.map((profile) => profile.id)).size !== profiles.length) invalidConfig();
|
|
91
|
+
return {
|
|
92
|
+
version: 1,
|
|
93
|
+
baseUrl,
|
|
94
|
+
models: profiles,
|
|
95
|
+
useBuiltinOpenAIModels,
|
|
96
|
+
allowInsecureLocalhost,
|
|
97
|
+
timeoutMs,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function normalizeBaseUrl(value: string, allowInsecureLocalhost = false): string {
|
|
102
|
+
// 先检查原始文本,不能让 URL 自动修复越界路径或模糊主机。
|
|
103
|
+
if (typeof value !== 'string' || /[\s\u0000-\u001f\u007f-\u009f\\%?#@]/u.test(value))
|
|
104
|
+
invalidConfig();
|
|
105
|
+
const parts = /^(https?):\/\/([A-Za-z0-9.-]+|\[[A-Fa-f0-9:]+\])(?::([0-9]+))?(\/.*)?$/.exec(
|
|
106
|
+
value,
|
|
107
|
+
);
|
|
108
|
+
if (!parts) invalidConfig();
|
|
109
|
+
const [, protocol, host, port, path = ''] = parts;
|
|
110
|
+
if (path.split('/').some((segment) => segment === '.' || segment === '..')) invalidConfig();
|
|
111
|
+
let url: URL;
|
|
112
|
+
try {
|
|
113
|
+
url = new URL(value);
|
|
114
|
+
} catch {
|
|
115
|
+
invalidConfig();
|
|
116
|
+
}
|
|
117
|
+
if (url.hostname.toLowerCase() !== host.toLowerCase() || url.pathname !== (path || '/'))
|
|
118
|
+
invalidConfig();
|
|
119
|
+
if (port && String(Number(port)) !== port) invalidConfig();
|
|
120
|
+
if (
|
|
121
|
+
protocol === 'http' &&
|
|
122
|
+
(!allowInsecureLocalhost || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))
|
|
123
|
+
)
|
|
124
|
+
invalidConfig();
|
|
125
|
+
const base = `${url.origin}${path}`.replace(/\/+$/, '');
|
|
126
|
+
return base.endsWith('/v1') ? base : `${base}/v1`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function parseProfile(raw: unknown): ModelProfile {
|
|
130
|
+
const value = object(raw);
|
|
131
|
+
const fields = [
|
|
132
|
+
'id',
|
|
133
|
+
'name',
|
|
134
|
+
'contextWindow',
|
|
135
|
+
'maxTokens',
|
|
136
|
+
'reasoning',
|
|
137
|
+
'input',
|
|
138
|
+
'cost',
|
|
139
|
+
'evidence',
|
|
140
|
+
];
|
|
141
|
+
knownFields(value, fields);
|
|
142
|
+
if (fields.some((field) => !Object.hasOwn(value, field))) invalidConfig();
|
|
143
|
+
if (
|
|
144
|
+
typeof value.id !== 'string' ||
|
|
145
|
+
value.id.length === 0 ||
|
|
146
|
+
value.id.length > 256 ||
|
|
147
|
+
value.id.trim() !== value.id ||
|
|
148
|
+
/[\u0000-\u001f\u007f-\u009f]/u.test(value.id)
|
|
149
|
+
)
|
|
150
|
+
invalidConfig();
|
|
151
|
+
if (
|
|
152
|
+
typeof value.contextWindow !== 'number' ||
|
|
153
|
+
!Number.isSafeInteger(value.contextWindow) ||
|
|
154
|
+
value.contextWindow <= 0
|
|
155
|
+
)
|
|
156
|
+
invalidConfig();
|
|
157
|
+
if (
|
|
158
|
+
typeof value.maxTokens !== 'number' ||
|
|
159
|
+
!Number.isSafeInteger(value.maxTokens) ||
|
|
160
|
+
value.maxTokens <= 0 ||
|
|
161
|
+
value.maxTokens > value.contextWindow
|
|
162
|
+
)
|
|
163
|
+
invalidConfig();
|
|
164
|
+
const cost = object(value.cost);
|
|
165
|
+
const costFields = ['input', 'output', 'cacheRead', 'cacheWrite'];
|
|
166
|
+
knownFields(cost, costFields);
|
|
167
|
+
if (
|
|
168
|
+
costFields.some(
|
|
169
|
+
(key) =>
|
|
170
|
+
!Object.hasOwn(cost, key) ||
|
|
171
|
+
typeof cost[key] !== 'number' ||
|
|
172
|
+
!Number.isFinite(cost[key]) ||
|
|
173
|
+
(cost[key] as number) < 0,
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
invalidConfig();
|
|
177
|
+
if (
|
|
178
|
+
!Array.isArray(value.input) ||
|
|
179
|
+
!value.input.includes('text') ||
|
|
180
|
+
Array.from(value.input).some((item) => item !== 'text' && item !== 'image') ||
|
|
181
|
+
new Set(value.input).size !== value.input.length
|
|
182
|
+
)
|
|
183
|
+
invalidConfig();
|
|
184
|
+
if (
|
|
185
|
+
typeof value.name !== 'string' ||
|
|
186
|
+
value.name.trim().length === 0 ||
|
|
187
|
+
typeof value.evidence !== 'string' ||
|
|
188
|
+
value.evidence.trim().length === 0 ||
|
|
189
|
+
typeof value.reasoning !== 'boolean'
|
|
190
|
+
)
|
|
191
|
+
invalidConfig();
|
|
192
|
+
return value as unknown as ModelProfile;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function loadConfig(agentDir: string): StringAiConfig {
|
|
196
|
+
let content: Buffer;
|
|
197
|
+
try {
|
|
198
|
+
content = readFileSync(join(agentDir, 'string-ai.json'));
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return parseConfig({ version: 1 });
|
|
201
|
+
throw new Error('无法读取 String AI 配置文件。');
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
return parseConfig(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(content)));
|
|
205
|
+
} catch {
|
|
206
|
+
invalidConfig();
|
|
207
|
+
}
|
|
208
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { getAgentDir, type ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { CatalogError } from './catalog.js';
|
|
3
|
+
import { loadConfig } from './config.js';
|
|
4
|
+
import { createStringAiProvider } from './provider.js';
|
|
5
|
+
|
|
6
|
+
/** 只展示固定诊断文本,不回显服务响应、凭据或任意错误正文。 */
|
|
7
|
+
function catalogErrorDescription(code?: string): string {
|
|
8
|
+
switch (code) {
|
|
9
|
+
case 'auth':
|
|
10
|
+
return '401 认证失败,请检查 Key 是否有效,或重新 /login string-ai。';
|
|
11
|
+
case 'permission':
|
|
12
|
+
return '403 权限不足,请检查 Key 所绑定分组及其模型权限。';
|
|
13
|
+
case 'rate_limit':
|
|
14
|
+
return '429 请求限流,请稍后重试并检查 Key 的使用限制。';
|
|
15
|
+
case 'server':
|
|
16
|
+
return '服务暂时不可用,请稍后重试。';
|
|
17
|
+
case 'timeout':
|
|
18
|
+
return '请求超时,请检查网络后重试。';
|
|
19
|
+
case 'cancelled':
|
|
20
|
+
return '刷新已取消。';
|
|
21
|
+
case 'network':
|
|
22
|
+
return '网络连接失败,请检查连接与端点设置。';
|
|
23
|
+
case 'invalid_catalog':
|
|
24
|
+
return '模型目录格式无效,请联系服务方确认接口。';
|
|
25
|
+
case 'too_large':
|
|
26
|
+
return '模型目录响应过大,请联系服务方确认接口。';
|
|
27
|
+
case 'http':
|
|
28
|
+
return 'HTTP 请求失败,请检查端点设置或稍后重试。';
|
|
29
|
+
default:
|
|
30
|
+
return '刷新失败,请检查认证、网络与模型配置。';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function refreshErrorDescription(error: unknown): string {
|
|
35
|
+
return catalogErrorDescription(error instanceof CatalogError ? error.code : undefined);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 认证与模型刷新交给宿主,加载插件时不自行联网。 */
|
|
39
|
+
export default function stringAiExtension(pi: ExtensionAPI): void {
|
|
40
|
+
const config = loadConfig(getAgentDir());
|
|
41
|
+
const provider = createStringAiProvider(config);
|
|
42
|
+
pi.registerProvider(provider);
|
|
43
|
+
pi.registerCommand('string-ai-help', {
|
|
44
|
+
description: '查看 String AI 接入帮助,不发送网络请求',
|
|
45
|
+
handler: async (_args, ctx) => {
|
|
46
|
+
ctx.ui.notify([
|
|
47
|
+
'String AI:在官网 https://www.string.ink 创建普通 API Key,选择支持 Responses 及第三方客户端的分组。',
|
|
48
|
+
'最短流程:官网创建 Key → /login string-ai → /model。TUI 登录后会自动刷新该 Provider。',
|
|
49
|
+
'Key 仅在 Pi 原生 /login string-ai 中输入,不要贴到聊天或命令参数。',
|
|
50
|
+
'默认无需 string-ai.json。旧 models:[] 会保持禁用内置参考模板;可删除仅用于旧模板的 models 字段,或显式开启 useBuiltinOpenAIModels,然后 /reload。插件不会代改用户文件;未知模型仍不注册。',
|
|
51
|
+
'/logout 移除 Pi 已保存凭据,但不会清除 STRING_AI_API_KEY 环境变量;若仍生效,请从启动环境移除该环境变量并重启 Pi。',
|
|
52
|
+
'Pi 1.4.0 的 secret prompt 并非真正遮罩;输入时不要录屏或共享终端。Responses 的 store:false 不等于服务端不留日志。',
|
|
53
|
+
'分组由 Key 决定;插件不另选分组,也不额外传 group。换分组、重新发现或故障诊断时使用 /string-ai-refresh,不是每次登录的必需步骤。',
|
|
54
|
+
].join('\n'), 'info');
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
pi.registerCommand('string-ai-refresh', {
|
|
58
|
+
description: '重新获取 String AI 模型列表,不发送推理请求',
|
|
59
|
+
handler: async (_args, ctx) => {
|
|
60
|
+
// 与 Pi 的 modelNetworkEnabled 一致:变量存在即离线,包括空字符串。
|
|
61
|
+
if (!config.baseUrl || process.env.PI_OFFLINE !== undefined) {
|
|
62
|
+
ctx.ui.notify('String AI:未配置端点或处于离线模式,未发送请求。', 'warning');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
if (!ctx.modelRegistry.getProviderAuthStatus('string-ai').configured) {
|
|
67
|
+
ctx.ui.notify('String AI:尚未配置认证,请先使用 /login string-ai。', 'warning');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const result = await ctx.modelRegistry.refresh({ providers: ['string-ai'], allowNetwork: true, force: true });
|
|
71
|
+
if (result.aborted) {
|
|
72
|
+
ctx.ui.notify('String AI:刷新已取消。', 'warning');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (result.errors.has('string-ai')) {
|
|
76
|
+
ctx.ui.notify(`String AI:${refreshErrorDescription(result.errors.get('string-ai'))}`, 'warning');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const status = provider.getStatus();
|
|
80
|
+
if (status.modelCount === 0) {
|
|
81
|
+
ctx.ui.notify(status.unknownCount > 0
|
|
82
|
+
? `String AI:${status.unknownCount} 个目录模型缺少能力档案,当前没有可用模型。请检查模板设置或补充有来源的档案。`
|
|
83
|
+
: 'String AI:当前没有可用模型,请检查 Key 分组的模型范围。', 'warning');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
ctx.ui.notify(`String AI:刷新完成,可用模型 ${status.modelCount};缺少能力档案 ${status.unknownCount}。使用 /model 选择。`, 'info');
|
|
87
|
+
} catch (error) {
|
|
88
|
+
ctx.ui.notify(`String AI:${refreshErrorDescription(error)}`, 'error');
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
pi.registerCommand('string-ai-status', {
|
|
93
|
+
description: '查看 String AI 配置状态,不发送网络请求',
|
|
94
|
+
handler: async (_args, ctx) => {
|
|
95
|
+
const status = provider.getStatus() as ReturnType<typeof provider.getStatus> & { referenceModelCount?: number };
|
|
96
|
+
const referenceCount = status.referenceModelCount ?? 0;
|
|
97
|
+
const configured = ctx.modelRegistry.getProviderAuthStatus('string-ai').configured;
|
|
98
|
+
const authentication = !configured ? '认证未配置' : status.configured ? '认证已解析(不代表服务端验证成功)' : '认证已配置,尚未解析';
|
|
99
|
+
const available = ctx.modelRegistry.getAvailable().filter((model) => model.provider === 'string-ai');
|
|
100
|
+
ctx.ui.notify(
|
|
101
|
+
`String AI:端点${config.baseUrl ? '已配置' : '未配置'};${authentication};可用模型 ${available.length};目录模型 ${status.modelCount};未知模型 ${status.unknownCount};Pi参考模板 ${referenceCount}。${referenceCount > 0 ? 'Pi上游参考能力/估算费用,非String分组实际账单。' : ''}${status.stale ? '目录陈旧,请用 /string-ai-refresh 重新确认。' : ''}${status.lastError ? `最近目录错误:${catalogErrorDescription(status.lastError.code)}` : ''}认证使用 /login string-ai,配置修改后使用 /reload。`,
|
|
102
|
+
status.stale || status.lastError ? 'warning' : 'info',
|
|
103
|
+
);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createProvider,
|
|
3
|
+
envApiKeyAuth,
|
|
4
|
+
lazyStream,
|
|
5
|
+
type Provider,
|
|
6
|
+
type Model,
|
|
7
|
+
type Api,
|
|
8
|
+
type Credential,
|
|
9
|
+
type StreamOptions,
|
|
10
|
+
} from '@earendil-works/pi-ai';
|
|
11
|
+
import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all';
|
|
12
|
+
import { openAIResponsesApi } from '@earendil-works/pi-ai/compat';
|
|
13
|
+
import { normalizeBaseUrl, type StringAiConfig } from './config.js';
|
|
14
|
+
import { CatalogError, discoverModels } from './catalog.js';
|
|
15
|
+
|
|
16
|
+
/** 最近一次认证解析与目录检查的脱敏快照;不主动读环境或联网。 */
|
|
17
|
+
export interface StringAiStatus {
|
|
18
|
+
configured: boolean;
|
|
19
|
+
modelCount: number;
|
|
20
|
+
referenceModelCount?: number;
|
|
21
|
+
unknownCount: number;
|
|
22
|
+
stale: boolean;
|
|
23
|
+
checkedAt?: number;
|
|
24
|
+
lastError?: { code: string; status?: number };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface StringAiProvider extends Provider<'openai-responses'> {
|
|
28
|
+
/** 本地宿主在任何组合分发前调用;同步拒绝,不能替代原生 payload 终检。 */
|
|
29
|
+
validateRequest(model: Model<Api>, options?: StreamOptions): void;
|
|
30
|
+
getStatus(): StringAiStatus;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 复用 Pi 原生认证和 Responses 适配器,不自行转换协议。 */
|
|
34
|
+
export function createStringAiProvider(config: StringAiConfig): StringAiProvider {
|
|
35
|
+
config = structuredClone(config);
|
|
36
|
+
if (config.baseUrl)
|
|
37
|
+
config = {
|
|
38
|
+
...config,
|
|
39
|
+
baseUrl: normalizeBaseUrl(config.baseUrl, config.allowInsecureLocalhost),
|
|
40
|
+
};
|
|
41
|
+
let generation = 0;
|
|
42
|
+
let authGeneration = 0;
|
|
43
|
+
let resolvedKey: string | undefined;
|
|
44
|
+
let catalogKey: string | undefined;
|
|
45
|
+
let endpoint = config.baseUrl;
|
|
46
|
+
let catalogEndpoint: string | undefined;
|
|
47
|
+
const visible = () =>
|
|
48
|
+
Boolean(catalogKey && catalogKey === resolvedKey && catalogEndpoint === endpoint);
|
|
49
|
+
const allowed = (credential: Credential | undefined) =>
|
|
50
|
+
visible() &&
|
|
51
|
+
(credential === undefined ||
|
|
52
|
+
(credential.type === 'api_key' && (!credential.key || credential.key === catalogKey)));
|
|
53
|
+
let models: Model<'openai-responses'>[] = [];
|
|
54
|
+
let hasCatalog = false;
|
|
55
|
+
let status: Omit<StringAiStatus, 'configured' | 'modelCount'> = {
|
|
56
|
+
unknownCount: 0,
|
|
57
|
+
stale: false,
|
|
58
|
+
};
|
|
59
|
+
// 环境凭据由宿主AuthContext解析;不能另读process.env破坏测试/浏览器隔离。
|
|
60
|
+
const nativeAuth = envApiKeyAuth('String AI API Key', ['STRING_AI_API_KEY']);
|
|
61
|
+
const apiKey = {
|
|
62
|
+
...nativeAuth,
|
|
63
|
+
login: async (interaction: Parameters<NonNullable<typeof nativeAuth.login>>[0]) => {
|
|
64
|
+
interaction.signal.throwIfAborted();
|
|
65
|
+
interaction.notify({
|
|
66
|
+
type: 'info',
|
|
67
|
+
message:
|
|
68
|
+
'请在 String 官网创建普通 API Key 时选择支持 Responses 和第三方客户端的分组;Pi 无需再传 group。Pi参考模型能力与费用仅为兼容参考,非 String 能力确认或 String 账单。Pi 1.4 的 secret 输入是非遮罩输入,请不要录屏或共享终端。',
|
|
69
|
+
});
|
|
70
|
+
// 提示后仍调用原生登录;凭据持久化与取消语义由 Pi 管理。
|
|
71
|
+
if (!nativeAuth.login) throw new Error('String AI 原生 API Key 登录不可用。');
|
|
72
|
+
return nativeAuth.login(interaction);
|
|
73
|
+
},
|
|
74
|
+
resolve: async (input: Parameters<typeof nativeAuth.resolve>[0]) => {
|
|
75
|
+
const ticket = ++authGeneration;
|
|
76
|
+
const result = await nativeAuth.resolve(input);
|
|
77
|
+
if (ticket === authGeneration) {
|
|
78
|
+
if (resolvedKey !== result?.auth.apiKey) {
|
|
79
|
+
generation++;
|
|
80
|
+
// 身份变化即销毁旧目录,不能切回旧身份后复活先前的授权快照。
|
|
81
|
+
models = [];
|
|
82
|
+
hasCatalog = false;
|
|
83
|
+
catalogKey = undefined;
|
|
84
|
+
catalogEndpoint = undefined;
|
|
85
|
+
status = { unknownCount: 0, stale: false };
|
|
86
|
+
}
|
|
87
|
+
resolvedKey = result?.auth.apiKey;
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
const knownModel = (model: {
|
|
93
|
+
id: string;
|
|
94
|
+
api: string;
|
|
95
|
+
provider: string;
|
|
96
|
+
baseUrl: string;
|
|
97
|
+
type?: string;
|
|
98
|
+
}) =>
|
|
99
|
+
(model.type === undefined || model.type === 'chat') &&
|
|
100
|
+
model.api === 'openai-responses' &&
|
|
101
|
+
model.provider === 'string-ai' &&
|
|
102
|
+
model.baseUrl === catalogEndpoint &&
|
|
103
|
+
models.some((item) => item.id === model.id);
|
|
104
|
+
const assertRequest = (model: Model<Api>, options?: StreamOptions) => {
|
|
105
|
+
if (options?.transport && !['auto', 'sse'].includes(options.transport))
|
|
106
|
+
throw new Error('String AI 仅支持 HTTP/SSE。');
|
|
107
|
+
if (
|
|
108
|
+
[model.headers, options?.headers].some(
|
|
109
|
+
(headers) =>
|
|
110
|
+
headers &&
|
|
111
|
+
Object.keys(headers).some((name) => name.toLowerCase() === 'authorization'),
|
|
112
|
+
)
|
|
113
|
+
) {
|
|
114
|
+
throw new Error('String AI 不允许覆盖认证头,请使用原生 API Key 认证。');
|
|
115
|
+
}
|
|
116
|
+
if (!visible() || options?.apiKey !== catalogKey || !knownModel(model)) {
|
|
117
|
+
throw new Error('String AI 尚未确认当前凭据和端点下的模型可用性,请刷新模型目录。');
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
// 只包装请求策略;事件、工具调用和SSE转换完全交给Pi原生适配器。
|
|
121
|
+
const requestOptions = <T extends StreamOptions>(
|
|
122
|
+
model: Model<Api>,
|
|
123
|
+
options?: T,
|
|
124
|
+
): T & StreamOptions => {
|
|
125
|
+
const modelId = model.id;
|
|
126
|
+
const assertPayload = (payload: unknown) => {
|
|
127
|
+
if (
|
|
128
|
+
typeof payload !== 'object' ||
|
|
129
|
+
payload === null ||
|
|
130
|
+
!('model' in payload) ||
|
|
131
|
+
payload.model !== modelId ||
|
|
132
|
+
!('stream' in payload) ||
|
|
133
|
+
payload.stream !== true ||
|
|
134
|
+
!('store' in payload) ||
|
|
135
|
+
payload.store !== false
|
|
136
|
+
) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
'String AI 最终请求必须保持已确认的模型、stream=true 和 store=false。',
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
return {
|
|
143
|
+
...options,
|
|
144
|
+
transport: 'sse',
|
|
145
|
+
maxRetries: 0,
|
|
146
|
+
onPayload: async (payload, requestModel) => {
|
|
147
|
+
const replacement = await options?.onPayload?.(payload, requestModel);
|
|
148
|
+
const finalPayload = replacement === undefined ? payload : replacement;
|
|
149
|
+
assertPayload(finalPayload);
|
|
150
|
+
return finalPayload;
|
|
151
|
+
},
|
|
152
|
+
fetch: (input, init) => {
|
|
153
|
+
assertRequest(model, options);
|
|
154
|
+
// 检查原生SDK已经序列化的原文,不再次调用toJSON或改写合法字段。
|
|
155
|
+
let payload: unknown;
|
|
156
|
+
try {
|
|
157
|
+
if (typeof init?.body !== 'string') throw new Error();
|
|
158
|
+
payload = JSON.parse(init.body);
|
|
159
|
+
} catch {
|
|
160
|
+
throw new Error('String AI 最终请求必须是可验证的 JSON。');
|
|
161
|
+
}
|
|
162
|
+
assertPayload(payload);
|
|
163
|
+
return (options?.fetch ?? globalThis.fetch)(input, {
|
|
164
|
+
...init,
|
|
165
|
+
redirect: 'error',
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
} as T & StreamOptions;
|
|
169
|
+
};
|
|
170
|
+
// Pi 1.4.0 的扩展加载器对未知子路径按根入口别名展开;使用其显式支持的入口。
|
|
171
|
+
const responses = openAIResponsesApi();
|
|
172
|
+
const provider = createProvider<'openai-responses'>({
|
|
173
|
+
id: 'string-ai',
|
|
174
|
+
name: 'String AI',
|
|
175
|
+
baseUrl: config.baseUrl,
|
|
176
|
+
auth: { apiKey },
|
|
177
|
+
models: [],
|
|
178
|
+
api: {
|
|
179
|
+
stream: (model, context, options) =>
|
|
180
|
+
lazyStream(model, async () => {
|
|
181
|
+
assertRequest(model, options);
|
|
182
|
+
return responses.stream(model, context, requestOptions(model, options));
|
|
183
|
+
}),
|
|
184
|
+
streamSimple: (model, context, options) =>
|
|
185
|
+
lazyStream(model, async () => {
|
|
186
|
+
assertRequest(model, options);
|
|
187
|
+
return responses.streamSimple(model, context, requestOptions(model, options));
|
|
188
|
+
}),
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
return {
|
|
192
|
+
...provider,
|
|
193
|
+
validateRequest: assertRequest,
|
|
194
|
+
getModels: () => (visible() ? structuredClone(models) : []),
|
|
195
|
+
getAllModels: () => (visible() ? structuredClone(models) : []),
|
|
196
|
+
filterModels: (items, credential) => (allowed(credential) ? items.filter(knownModel) : []),
|
|
197
|
+
filterAllModels: (items, credential) =>
|
|
198
|
+
allowed(credential) ? items.filter(knownModel) : [],
|
|
199
|
+
getStatus: () => ({
|
|
200
|
+
configured: Boolean(endpoint && resolvedKey),
|
|
201
|
+
modelCount: visible() ? models.length : 0,
|
|
202
|
+
...(config.useBuiltinOpenAIModels
|
|
203
|
+
? {
|
|
204
|
+
referenceModelCount: visible()
|
|
205
|
+
? models.filter(
|
|
206
|
+
(model) => !config.models.some((profile) => profile.id === model.id),
|
|
207
|
+
).length
|
|
208
|
+
: 0,
|
|
209
|
+
}
|
|
210
|
+
: {}),
|
|
211
|
+
...(visible() ? structuredClone(status) : { unknownCount: 0, stale: false }),
|
|
212
|
+
}),
|
|
213
|
+
refreshModels: async (context) => {
|
|
214
|
+
// 不读取stored,也不提供persist:授权目录仅限本实例内存。
|
|
215
|
+
// 原生publish与本地代次双重检查,覆盖宿主刷新及直接并发调用。
|
|
216
|
+
const ticket = ++generation;
|
|
217
|
+
const publish = async (update: () => void) => {
|
|
218
|
+
let applied = false;
|
|
219
|
+
const accepted = await context.publish({
|
|
220
|
+
update: () => {
|
|
221
|
+
if (ticket !== generation || context.signal.aborted) return;
|
|
222
|
+
update();
|
|
223
|
+
applied = true;
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
return accepted && applied && ticket === generation && !context.signal.aborted;
|
|
227
|
+
};
|
|
228
|
+
const key = context.credential?.type === 'api_key' ? context.credential.key : undefined;
|
|
229
|
+
const requestedUrl = context.baseUrl ?? config.baseUrl;
|
|
230
|
+
const baseUrl = requestedUrl
|
|
231
|
+
? normalizeBaseUrl(requestedUrl, config.allowInsecureLocalhost)
|
|
232
|
+
: undefined;
|
|
233
|
+
if (
|
|
234
|
+
!(await publish(() => {
|
|
235
|
+
endpoint = baseUrl;
|
|
236
|
+
if (key) {
|
|
237
|
+
authGeneration++;
|
|
238
|
+
resolvedKey = key;
|
|
239
|
+
}
|
|
240
|
+
if (catalogEndpoint !== baseUrl || (key && catalogKey !== key)) {
|
|
241
|
+
models = [];
|
|
242
|
+
hasCatalog = false;
|
|
243
|
+
status = { unknownCount: 0, stale: false };
|
|
244
|
+
catalogKey = key;
|
|
245
|
+
catalogEndpoint = baseUrl;
|
|
246
|
+
}
|
|
247
|
+
}))
|
|
248
|
+
)
|
|
249
|
+
return;
|
|
250
|
+
if (!context.allowNetwork || !baseUrl || !key) return;
|
|
251
|
+
let catalog: Awaited<ReturnType<typeof discoverModels>>;
|
|
252
|
+
try {
|
|
253
|
+
catalog = await discoverModels({
|
|
254
|
+
baseUrl,
|
|
255
|
+
apiKey: key,
|
|
256
|
+
signal: context.signal,
|
|
257
|
+
timeoutMs: config.timeoutMs,
|
|
258
|
+
});
|
|
259
|
+
} catch (error) {
|
|
260
|
+
const failure = error instanceof CatalogError ? error : new CatalogError('network');
|
|
261
|
+
await publish(() => {
|
|
262
|
+
const revoked = failure.code === 'auth' || failure.code === 'permission';
|
|
263
|
+
if (revoked) {
|
|
264
|
+
models = [];
|
|
265
|
+
hasCatalog = false;
|
|
266
|
+
}
|
|
267
|
+
status = {
|
|
268
|
+
...status,
|
|
269
|
+
unknownCount: revoked ? 0 : status.unknownCount,
|
|
270
|
+
stale: hasCatalog,
|
|
271
|
+
lastError: {
|
|
272
|
+
code: failure.code,
|
|
273
|
+
...(failure.status === undefined ? {} : { status: failure.status }),
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
});
|
|
277
|
+
throw failure;
|
|
278
|
+
}
|
|
279
|
+
const profiles = new Map(config.models.map((profile) => [profile.id, profile]));
|
|
280
|
+
const discovered: Model<'openai-responses'>[] = catalog.flatMap(({ id }) => {
|
|
281
|
+
const profile = profiles.get(id);
|
|
282
|
+
if (!profile) {
|
|
283
|
+
if (!config.useBuiltinOpenAIModels) return [];
|
|
284
|
+
// 仅按服务目录原始 ID 精确匹配;这是 Pi 参考,不是 String 能力确认。
|
|
285
|
+
const reference = getBuiltinModels('openai').find(
|
|
286
|
+
(item) =>
|
|
287
|
+
item.id === id &&
|
|
288
|
+
item.api === 'openai-responses' &&
|
|
289
|
+
(item.type === undefined || item.type === 'chat'),
|
|
290
|
+
);
|
|
291
|
+
if (!reference) return [];
|
|
292
|
+
// 白名单复制,不能继承官方端点、认证、采样及代理未承诺的 compat/cache 行为。
|
|
293
|
+
const {
|
|
294
|
+
name,
|
|
295
|
+
contextWindow,
|
|
296
|
+
maxTokens,
|
|
297
|
+
reasoning,
|
|
298
|
+
input,
|
|
299
|
+
cost,
|
|
300
|
+
thinkingLevelMap,
|
|
301
|
+
inputLimits,
|
|
302
|
+
} = reference;
|
|
303
|
+
return [
|
|
304
|
+
structuredClone({
|
|
305
|
+
id,
|
|
306
|
+
name: `${name}(Pi参考)`,
|
|
307
|
+
contextWindow,
|
|
308
|
+
maxTokens,
|
|
309
|
+
reasoning,
|
|
310
|
+
input,
|
|
311
|
+
cost,
|
|
312
|
+
...(thinkingLevelMap === undefined ? {} : { thinkingLevelMap }),
|
|
313
|
+
...(inputLimits === undefined ? {} : { inputLimits }),
|
|
314
|
+
api: 'openai-responses' as const,
|
|
315
|
+
provider: 'string-ai',
|
|
316
|
+
baseUrl,
|
|
317
|
+
}),
|
|
318
|
+
];
|
|
319
|
+
}
|
|
320
|
+
const { evidence: _evidence, ...capabilities } = profile;
|
|
321
|
+
return [
|
|
322
|
+
{
|
|
323
|
+
...capabilities,
|
|
324
|
+
api: 'openai-responses' as const,
|
|
325
|
+
provider: 'string-ai',
|
|
326
|
+
baseUrl,
|
|
327
|
+
},
|
|
328
|
+
];
|
|
329
|
+
});
|
|
330
|
+
await publish(() => {
|
|
331
|
+
catalogKey = key;
|
|
332
|
+
catalogEndpoint = baseUrl;
|
|
333
|
+
models = discovered;
|
|
334
|
+
hasCatalog = true;
|
|
335
|
+
status = {
|
|
336
|
+
unknownCount: catalog.length - discovered.length,
|
|
337
|
+
stale: false,
|
|
338
|
+
checkedAt: Date.now(),
|
|
339
|
+
};
|
|
340
|
+
});
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
}
|