opencode-claude-code-ua 0.3.6 → 0.3.9

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/README.md CHANGED
@@ -1,65 +1,70 @@
1
1
  # UA PRO
2
2
 
3
- Magpie 的出站客户端兼容插件。将 **Codex / Claude Code 请求规则**保存为版本化模板,按连接或模型应用,支持 Responses、Anthropic Messages、Chat Completions 和 Gemini。
3
+ 独立的 Magpie 出站请求配置插件,内置 **Codex / Claude Code 请求模板**,支持 Responses、Anthropic Messages、Chat Completions 和 Gemini。模板随插件提供,可按连接或模型应用,支持版本保存、测试与回退。
4
4
 
5
- 当前版本 **0.3.6**,npm 包名 [`opencode-claude-code-ua`](https://www.npmjs.com/package/opencode-claude-code-ua),主供应商 ID 为 `ua-pro`。无 npm 运行依赖。
5
+ npm 包名:[`opencode-claude-code-ua`](https://www.npmjs.com/package/opencode-claude-code-ua)。供应商 ID:`ua-pro`。无 npm 运行依赖。
6
+
7
+ > 当前源码版本为 **0.3.9**,尚未发布到 npm。通过 npm 安装获取的是当前已发布版本。
6
8
 
7
9
  ## 安装与打开管理页
8
10
 
9
- 在 Magpie「插件 → 添加插件」填写 npm 来源:
11
+ 在 Magpie「插件 → 添加插件」中填写:
10
12
 
11
13
  ```text
12
- opencode-claude-code-ua@0.3.6
14
+ opencode-claude-code-ua
13
15
  ```
14
16
 
15
- 也可以使用终端:
17
+ 或使用终端:
16
18
 
17
19
  ```sh
18
- magpie plugin add "opencode-claude-code-ua@0.3.6"
20
+ magpie plugin add opencode-claude-code-ua
19
21
  magpie plugin login ua-pro
20
22
  ```
21
23
 
22
- 登录方式中选择 **打开 UA PRO 管理**。已经登录账号后,也可再次执行登录命令打开管理页,管理其他连接和模板。插件内嵌日芒 SVG 图标,无需联网加载图片;界面支持纯白、纯黑及跟随系统主题。
24
+ 选择 **打开 UA PRO 管理**,已有账号时也可再次执行登录命令。
25
+
26
+ 在 **管理连接**中查看、编辑已保存连接;它打开“连接与模型”,不执行模型请求。从 Magpie 打开管理页保存普通连接后,连接显示在 **UA PRO → 账号列表**中,而不是新增一个同名供应商。概览会显示实际注册状态。
23
27
 
24
- 如需使用 GitHub 来源:
28
+ 若连接由独立 `node setup.mjs` 保存,需从 Magpie 打开管理页,点击 **同步到宿主**,再刷新供应商的模型列表。只有勾选独立显示才会有单独供应商;新增独立站点保存后,需要重载 UA PRO 插件。
29
+
30
+ 也支持 GitHub 源码来源。在 Magpie 的手动添加框填写,或使用终端:
25
31
 
26
32
  ```sh
27
- magpie plugin add "github:1466109846/ua-pro"
33
+ magpie plugin add "github:1466109846/ua-pro#main"
28
34
  ```
29
35
 
30
- 同一插件请选择一种安装来源,不要重复安装。安装或更新不会自动迁移其他插件的配置。
36
+ **发现页的 GitHub 卡片不一定安装源码**:当仓库对应的 npm 包存在时,Magpie 会优先安装 npm 的 `latest`。若 npm 尚未发布新版本,反复卸载重装该卡片仍会得到相同版本。使用上面的 `#main` 来源可明确选择 GitHub 默认分支。
37
+
38
+ npm 与 GitHub 来源二选一,不要重复安装。管理页侧栏显示实际运行版本,用于核对安装是否更新。
31
39
 
32
- ### 独立运行管理页
40
+ ### 独立打开管理页
33
41
 
34
- 需要 Node.js 22 或以上。下载仓库或 Release 后,在插件目录运行:
42
+ 需要 Node.js 22 或以上。在下载的插件目录运行:
35
43
 
36
44
  ```sh
37
45
  node setup.mjs
38
46
  ```
39
47
 
40
- 打开终端输出的一次性回环链接。服务仅监听本机,不默认监听公网。独立工具保存连接后,需要在 Magpie 重载插件;宿主与独立工具应指向同一个 UA 状态文件。
48
+ 打开终端输出的本机链接。管理服务仅监听回环地址,链接只在本次会话有效。保存连接后,在 Magpie 重载插件;两处需要使用同一个 UA 状态文件。
41
49
 
42
50
  ## 快速配置
43
51
 
44
- 1. 打开 **连接 → 新增连接**,填写名称、上游 Base URL、认证方式和 API key。
45
- 2. 点击 **获取模型**,按当前表单中的地址和凭据查询;新连接无需先保存。
46
- 3. 多选模型,选择 **追加选中模型**或**替换为选中模型**,然后保存连接。
47
- 4. 选择自动模板,或指定一个已保存的模板版本。
48
- 5. 在测试页检查最终地址、模型、协议和模板,再确认发送请求。
49
- 6. 在 Magpie 重新获取模型或重载插件,让宿主载入保存的连接和路由。
52
+ 1. 打开 **连接与模型 → 添加连接**,填写名称、上游 Base URL、认证方式和 API key。
53
+ 2. 点击 **获取模型**,选择需要的模型,追加或替换到列表,再保存连接。
54
+ 3. 使用自动模板,或在 **应用模板**中选择一个已保存的模板版本。
55
+ 4. 打开 **测试**,检查最终地址、模型、协议和模板,再确认发送请求。
56
+ 5. 在 Magpie 重新获取模型或重载插件,载入保存的连接与路由。
50
57
 
51
- 客户端连接的是 **Magpie 本地网关**,使用 Magpie 网关密钥,而不是直接填写上游 key。模型引用请从测试页复制:
58
+ 客户端连接 **Magpie 本地网关**,使用 Magpie 网关密钥,不是上游 API key。模型引用可从测试页复制:
52
59
 
53
60
  - 主池:`ua-pro/<模型 ID>`。
54
61
  - 独立站点:`ua-pro-<siteId>/<模型 ID>`。
55
62
 
56
- 独立站点的模型不能写成主池引用。客户端协议转换由 Magpie 负责;UA PRO 接收转换后的请求,执行对应出站模板并使用所选账号凭据。
63
+ 勾选 **在 Magpie 中独立显示此站点**并填写站点 ID,可将连接显示为独立供应商。最多 8 个独立站点,每个站点可配置多个账号;站点增删后需要重载插件。
57
64
 
58
65
  ## 连接与模型
59
66
 
60
- 每个连接支持启停、自动或固定上游协议、模型列表、模型级协议覆盖、协议根路径,以及连接级和模型级模板绑定。
61
-
62
- Base URL 可填写站点根地址,或以 `/v1`、`/v1beta` 结尾的地址。插件去除末尾版本段,再按协议拼接路径;自定义根路径可以保留部署前缀,例如 `anthropic: "/anthropic/v1"`。不能填写外域 URL、查询参数或路径穿越。远程地址要求 HTTPS,本机回环可使用 HTTP。
67
+ Base URL 可填写站点根地址,或以 `/v1`、`/v1beta` 结尾的地址。远程地址要求 HTTPS,本机回环可使用 HTTP。自定义协议根路径可保留部署前缀,例如 `/anthropic/v1`;不要填写外域 URL、查询参数或具体请求路径。
63
68
 
64
69
  | 认证方式 | 发送方式 |
65
70
  |---|---|
@@ -68,125 +73,124 @@ Base URL 可填写站点根地址,或以 `/v1`、`/v1beta` 结尾的地址。
68
73
  | `custom` | 填写完整 Authorization 值 |
69
74
  | `none` | 不发送认证 |
70
75
 
71
- 编辑已保存连接时,同一地址留空 key 可以复用原密钥;更换地址需要重新填写凭据。原生 Magpie 账号的 key 由宿主保存,UA PRO 仅记录账号引用,并核对账号端点;同一账号槽换站后,旧连接不能使用新站点凭据。
72
-
73
- 模型查询只读取列表,不发送生成请求、不改变模板绑定,也不会清空手填模型。追加/替换只修改表单草稿,保存后才持久化。失败、取消和空列表都会保留手填内容;目标或凭据变更会使旧查询结果失效。
74
-
75
- 宿主与管理页共用按目标和凭据隔离的模型发现目录,显示来源和时间。点击 **获取模型**始终尝试实时查询;网络失败时可显示过去 24 小时的同作用域目录,并标明缓存及失败原因。401、403、无效响应或主动取消不会用缓存冒充成功,成功的空列表会替换旧目录。
76
-
77
- 默认多个账号共用 `ua-pro` 主池。勾选独立显示并填写 `siteId` 后,供应商为 `ua-pro-<siteId>`;最多 8 个独立站点槽,每个站点可有多个账号。站点增删需要重载宿主。
76
+ 编辑同一地址的连接时,留空 key 可保留已有密钥;更换地址需要重新填写凭据。通过 Magpie 保存的账号凭据由宿主管理,不会复制到模板。
78
77
 
79
- ### 生产路由与测试路由
78
+ 模型查询只读取列表,不发送生成请求。选择模型只修改草稿,保存后才生效;失败、取消或空列表不会清空手填内容。缓存模型会标明来源与时间,不会冒充本次查询成功。
80
79
 
81
- 模型级协议和模板绑定优先于连接默认配置。自动协议按模型名推断,不代表上游已经支持该接口;明确知道端点类型时,应保存固定协议或模型覆盖。
80
+ 每个连接可以设置默认协议、模型级协议覆盖,以及连接级或模型级模板绑定。自动协议是按模型名推断的,不保证上游支持;明确知道接口类型时,应保存固定协议。
82
81
 
83
- 测试页会显示 **已保存的生产上游**。仅切换测试协议不会修改生产配置。如需应用,可点击 **将本次协议与模板保存为此模型路由**,一次性保存该模型的协议和精确模板版本,其他模型保持不变;随后在 Magpie 重新获取模型或重载插件。
84
-
85
- 插件不在失败后自动更换 key、模型或协议。Magpie 自身的路由与 fallback 需单独配置。
82
+ 测试页显示 **已保存的生产上游**。仅切换测试协议不会修改连接;需要保存时,点击 **将本次协议与模板保存为此模型路由**。插件不会在请求失败后自动更换 key、模型或协议。
86
83
 
87
84
  ## 模板与版本
88
85
 
89
- 首次加载建立三份初始模板,已有记录不会因启动而被覆盖:
86
+ 插件内提供三份初始模板:
90
87
 
91
- | 模板 | 类型 | 默认行为 |
92
- |---|---|---|
93
- | Codex | 内置只读 | Responses 请求头、元数据及会话兼容;固定基线版本 0.159.0 |
94
- | Claude Code | 内置只读 | Messages 请求头、条件性 system / user_id 补全及 fast beta 联动;固定 CLI 基线 2.1.296 |
95
- | Claude Code VS Code 2.1.316 | 可编辑独立模板 | 固定 UA 和 14 项请求头,默认不补客户端身份正文 |
88
+ | 模板 | 用途 |
89
+ |---|---|
90
+ | Codex | 内置只读的 Responses 请求规则 |
91
+ | Claude Code | 内置只读的 Messages 请求规则 |
92
+ | Claude Code VS Code 2.1.316 | 可编辑的 Messages 请求模板 |
96
93
 
97
- 内置基线不自动联网追踪新版本。自动绑定按最终上游协议选择:Responses → Codex,Messages → Claude Code,Chat / Gemini → 普通转发;绑定仍锁定具体模板版本。
94
+ 自动绑定按最终上游协议选择:Responses → Codex,Messages → Claude Code,Chat / Gemini → 普通转发。
98
95
 
99
- ### 新增、保存与应用
96
+ 要自定义规则:
100
97
 
101
- 1. 在 **模板库 → 新增模板**选择内置或已有模板版本作为基线。
102
- 2. 完整复制为独立草稿,修改名称、UA、请求头、会话或正文规则。
103
- 3. **保存新版本**,先预览或测试,再 **应用**到连接或模型。
98
+ 1. 在 **模板库 → 新增模板**中选择一个模板作为基线。
99
+ 2. 修改名称、UA、请求头或正文规则,**保存新版本**。
100
+ 3. 预览或测试后,**应用**到指定连接或模型。
104
101
 
105
- 自定义模板是完整副本,不在运行时继承原模板。保存 v2 不影响仍绑定 v1 的连接。版本历史支持比较、查看和回退;被引用的版本不能删除。归档不会使已有绑定失效,但不能新应用归档模板。
102
+ 保存与应用分开。保存 v2 不影响仍绑定 v1 的连接;回退时选择历史版本重新应用。被引用的版本不能删除,归档不会中断已有绑定。JSON 导入只创建草稿,不自动保存或应用。
106
103
 
107
- JSON 导入只建立经校验的草稿,不自动保存或应用;导出为脱敏规则副本。自定义普通文本和附加请求头分享前仍需人工检查。
104
+ 请求头支持覆盖、补缺、删除和保留;参数按 `defaults → overrides → remove` 处理。模板不能覆盖 Authorization、API key、Cookie 等认证头,也不执行用户脚本。默认保护模型、消息、工具与流式选择;计数接口不执行生成参数覆盖。
108
105
 
109
- ### 请求头与参数规则
106
+ 内置模板的会话与正文兼容规则可能影响提示前缀、token 用量或缓存。可复制为自定义模板后查看、修改或关闭。UA 和兼容字段不证明官方客户端身份,也不提供额外账号权限。
110
107
 
111
- 请求头支持 `set`、`set-if-missing`、`set-if-empty`、`delete`、`preserve`,可限定条件和操作路径。模板变量仅使用支持的上下文,例如 `{{session.id}}`、`{{model.id}}`、`{{request.id}}`、`{{client.version}}`;缺少变量时跳过覆盖,不发送未展开的字符串。
108
+ ## 余额与签到
112
109
 
113
- 认证最后写入。模板不能覆盖 Authorization、API key、Cookie、Host、Content-Length 等认证或传输控制头。模板使用声明式规则,不执行用户脚本。
110
+ ### 在 Magpie 用量页查看余额
114
111
 
115
- 参数按 **`defaults` → `overrides` → `remove`**处理:补缺、显式覆盖、最后删除。默认保护模型、消息、工具和流式选择。`count_tokens` / Gemini `countTokens` 不执行生成参数覆盖或 Claude 身份正文补全。
112
+ UA PRO 中已配置的连接可以复用模型 Key 查询站点支持的用量接口:
116
113
 
117
- ### 正文与会话兼容
114
+ - **NewAPI**:显示 Key 剩余额度;站点明确授权账号级查询时,才显示账号余额。Key 额度不等于账号总余额。
115
+ - **不限额 Key**:不会显示成零余额。站点额度倍率无法确认时,金额会标注估算。
116
+ - **仅支持累计消耗的站点**:显示已用金额与“余额未知”,不把未知余额当成 0。
118
117
 
119
- 客户端原生判定仅基于转换前可见 UA,是启发式,不验证官方客户端。宿主可能将原 UA 替换为 `magpie/...`,插件不会假称能够恢复它。
118
+ 在 **余额与签到**页点击 **刷新全部余额**,可同时读取站点账号余额和已配置连接的 Key 用量。Key 余额不依赖签到凭据;签到账号缺少凭据、读取失败或被停用时,不会阻止对应连接的 Key 余额查询。两类读数分别展示,不混合汇总。
120
119
 
121
- 内置 Claude 模板对非原生 Messages 请求可补 system 身份块及兼容 `metadata.user_id`,并补缺失的 `x-claude-code-session-id`,保持会话一致。已有原生会话头不改写,`preserve` / `off` 策略不会凭空新增。Codex 模板可提升允许的 `client_metadata` 头,并生成按连接和会话隔离的兼容标识。
120
+ 用量刷新只查询数据,不会自动登录或签到。用量页显示的是添加到 UA PRO 的连接,余额是否可读取取决于站点接口与权限;目前不提供余额趋势图。
122
121
 
123
- 这些显式兼容规则可能改变提示前缀、输入 token 或缓存;可复制模板后查看、修改或关闭。正文读取有体积、时限和取消保护,原生已有账单字段不伪造或替换。
122
+ ### 添加签到账号
124
123
 
125
- ## 三层测试
124
+ 打开 **余额与签到 → 添加账号**,选择站点类型:
126
125
 
127
- | 测试 | 网络行为与结论 |
126
+ | 站点类型 | 签到所需凭据 |
128
127
  |---|---|
129
- | 本地转换预览 | 不联网,展示同一转换函数准备的脱敏 URL、headers、body 和规则变化 |
130
- | 模型列表检查 | 明确确认后读取目录,不证明模型能够生成回复 |
131
- | 实际模型请求 | 明确确认后直测所选连接,支持普通/流式,可能产生费用,不自动回退 |
128
+ | NewAPI | 站点根地址、数字用户 ID、登录 Cookie 或有签到权限的系统访问令牌 |
129
+ | AgentRouter | 站点地址、登录用户名和密码;用户 ID 可留空,由成功登录确认 |
132
130
 
133
- 测试页自动载入已保存模型及同凭据的发现目录,优先选择已保存模型。实际请求模式下没有已知模型时,会先读取一次目录,但 **不会自动发送生成请求**;离线预览不自动联网。也可展开高级选项手动填写真实模型 ID。
131
+ NewAPI 使用站点签到接口。AgentRouter 通过登录领取每日额度;成功返回的 session 仅保存在本机,用于之后的余额查询。模型 API key 不能替代签到账号凭据。
134
132
 
135
- 默认 **自动适配本次协议**会保留兼容的连接绑定;绑定不兼容时,本次明确选用相应内置模板,生产绑定不变。选择严格绑定或指定版本时,不兼容会在发送前阻止请求。
133
+ 添加账号后:
136
134
 
137
- 确认框显示最终 URL、模型、协议和模板。高级根路径覆盖只用于本次测试,不修改连接。自定义正文在切换协议或模型后保留,结构不兼容时需明确替换为样例。
135
+ 1. 在 **连接与模型 → 编辑连接 → 余额与签到账号**中选择对应账号并保存。
136
+ 2. 重载插件,在 Magpie **用量**页刷新余额或点击 **立即签到**。
138
137
 
139
- 结果显示连接、模型、测试时间、HTTP 状态、耗时、首块时间、响应和上游 usage。表单变化后,旧结果标为过期;HTTP 200 中的错误 JSON 或错误 SSE 不算成功,取消和超时分别说明。测试不修改绑定;出站预览不代表站点确认接收了所有字段。
138
+ 绑定不修改模型 Key、协议或模板。同站点、同数字用户 ID 的账号修改签到类型或登录用户名时,已有 PAT/Cookie 和余额记录保留;更换站点或用户 ID 不会沿用原账号凭据。
140
139
 
141
- ## 保存、应用与回退
140
+ 一般只能绑定同站点账号;AgentRouter 支持 `agentrouter.org` 与 `ps.air-outer.com` 的双地址配置。已删除或异站绑定会提示失效,不会自动换成另一个账号。站点要求验证码时,需前往站点完成验证。
142
141
 
143
- ```text
144
- 编辑草稿 → 保存模板版本 → 预览/测试 → 应用到连接或模型
145
- ```
142
+ ### 每日定时签到
146
143
 
147
- 请求开始时冻结模板、连接和凭据快照,在途请求不随编辑改变。保存与应用分离;保存文件不等于正在运行的宿主已载入。独立工具无法观察另一进程的运行状态,会显示等待宿主确认。
144
+ 在账号编辑页开启定时签到,设置每天的时间和 IANA 时区,例如 `Asia/Shanghai`。
148
145
 
149
- 写操作使用 operationId 防止重复提交,网络断开后可查询原操作。失败保留草稿,结果未知时阻止盲目重提;多窗口有 revision / generation 冲突检查,跨进程不提供严格原子 CAS。
146
+ - 需要 Magpie 插件进程保持运行;关闭管理页面不影响定时任务。
147
+ - 独立运行 `node setup.mjs` 不启动定时签到。
148
+ - 开启定时后仍可手动签到;同日已尝试或领取时,插件定时器不会重复提交。
149
+ - 错过指定分钟不补跑,失败或结果未知不自动重试。
150
+ - 不要同时开启 Magpie 自身的自动签到计划,以免两套计划重复执行。
150
151
 
151
- ## 兼容入口与 New API
152
+ 签到是否领取成功以站点返回为准。余额变化和消耗增量来自两次成功读取,不等于“今日用量”。
152
153
 
153
- 旧 `claude-code-ua` 供应商保留兼容,原模型引用仍可使用。UA PRO 是唯一连接/模板管理前端,旧书签跳转到统一页面。设置中可预览旧 profile 并明确复制为新连接,不自动删除旧配置或复制原生账号 key。
154
+ ## 测试请求
154
155
 
155
- New API 账号管理、余额查询、手动签到和按 IANA 时区的每日签到保留。站点 PAT / Cookie 与模型 key 分开;定时器仅宿主运行时启用,独立工具不自动签到。余额绑定依赖宿主明确提供账号作用域,旧宿主不支持时不会猜测账号。
156
+ | 测试层次 | 行为 |
157
+ |---|---|
158
+ | 本地转换预览 | 不联网,展示脱敏后的 URL、请求头、正文和规则变化 |
159
+ | 模型列表检查 | 读取模型目录,不证明模型可以生成回复 |
160
+ | 实际模型请求 | 经确认后发送一次请求,支持普通/流式,可能产生费用 |
161
+
162
+ 发送前的确认框展示最终地址、模型、协议和模板。测试路径覆盖只影响本次测试;自定义正文不会因切换模型或协议而被静默覆盖。HTTP 200 中的错误 JSON 或错误 SSE 仍显示为失败。
156
163
 
157
164
  ## 数据与安全
158
165
 
159
166
  | 默认文件 | 用途 |
160
167
  |---|---|
161
- | `runtime/ua-pro.json` | 模板、连接、绑定、generation 及本地保存的连接凭据 |
162
- | `runtime/ua-pro.json.models.json` | 模型发现目录、来源、时间和作用域摘要,不含 key |
163
- | `runtime/profile.json` | 旧 Claude 配置,兼容保留 |
164
- | `runtime/accounts.json` | New API 站点账号 |
168
+ | `runtime/ua-pro.json` | 模板、连接、绑定及本地保存的连接凭据 |
169
+ | `runtime/ua-pro.json.models.json` | 模型目录、来源与时间,不含 key |
170
+ | `runtime/accounts.json` | 站点账号、Cookie 和显式保存的登录凭据 |
165
171
  | `runtime/schedule-claims/` | 每日签到防重复记录 |
166
172
 
167
- `uaFile` 插件选项或 `UA_PRO_FILE` 环境变量可指定 UA 状态文件绝对路径。`profileFile` / `MAGPIE_CLAUDE_PROFILE_FILE` 指定旧配置;独立工具的 `--config` 指定旧 profile 路径,默认 UA 文件放在同目录,也可由 `UA_PRO_FILE` 覆盖。
168
-
169
- 本地凭据以文件权限保护的明文保存,不是加密保险库。POSIX 使用 0600 / 0700,Windows 依赖 NTFS ACL。不要分享或提交 runtime、配置副本或附加秘密请求头。npm 包仅包含运行代码、图标、README 和必要许可证,不含用户数据或开发测试。
173
+ `uaFile` 插件选项或 `UA_PRO_FILE` 环境变量可指定 UA 状态文件的绝对路径。
170
174
 
171
- 仅连接自己拥有或已获授权的 API。UA 和兼容标识不证明官方客户端身份,不提供 OAuth、设备认证、签名或额外账户权限,也不能保证任意上游接受请求。
175
+ 本地凭据以文件权限保护的明文保存,不是加密保险库。不要分享 `runtime/`、配置副本或含秘密的自定义请求头。仅连接自己拥有或已获授权的 API。
172
176
 
173
177
  ## 常见问题
174
178
 
175
- **管理页如何再次打开?**
179
+ **如何再次打开管理页?**
176
180
 
177
- 执行 `magpie plugin login ua-pro`,选择 **打开 UA PRO 管理**。管理会话过期后需重新打开;刷新旧页面不能延长授权。
181
+ 执行 `magpie plugin login ua-pro`,选择 **打开 UA PRO 管理**。管理会话过期后需重新打开,刷新页面不能延长授权。
178
182
 
179
183
  **宿主能获取模型,独立管理工具却失败?**
180
184
 
181
- 从 Magpie 打开管理页后,查询可复用最近 15 分钟同目标/凭据模型发现的账号网络上下文,包括账号代理。独立 Node 进程不能继承 Magpie 账号代理,需要自行配置网络;支持的 Node 版本可显式使用环境代理。插件不硬编码本地代理端口,也没有独立代理设置面板。
185
+ 独立 Node 进程不继承 Magpie 账号代理。可从 Magpie 打开管理页,或为独立进程配置网络环境。
182
186
 
183
- **模型列表成功,但生成返回 404?**
187
+ **模型列表成功,生成却返回 404?**
184
188
 
185
- 确认生产模型路由、实际协议根路径,以及独立站点的供应商前缀。目录成功不代表所有生成端点可用;测试页直测上游,客户端到上游的协议转换由 Magpie 根据已保存路由完成。
189
+ 检查上游协议、协议根路径和模型引用。目录读取成功不代表站点支持该生成接口。
186
190
 
187
- **修改后为什么没有生效?**
191
+ **保存后为什么没有生效?**
188
192
 
189
- 模板先保存,再应用;连接、站点或模型协议更新后,在 Magpie 重新获取模型或重载插件,并重新打开管理页。不要将文件保存或单次测试成功当作生产生效证明。
193
+ 模板要先保存、再应用。连接、站点或模型协议更新后,在 Magpie 重新获取模型或重载插件,并重新打开管理页。
190
194
 
191
195
  ## 反馈与许可
192
196
 
package/index.mjs CHANGED
@@ -2,14 +2,10 @@ import { createPlugin } from './src/plugin.mjs'
2
2
  import { pluginOptions } from './src/options.mjs'
3
3
  import { createUAPlugin, createUASite } from './src/ua-plugin.mjs'
4
4
 
5
- export default async function ClaudeCodeUAPlugin(input, options) {
5
+ export default async function UAProPlugin(input, options) {
6
6
  if (options !== undefined && (!options || typeof options !== 'object' || Array.isArray(options))) throw new TypeError('Plugin options must be an object')
7
- const legacy = { ...options }
8
- delete legacy.uaFile
9
- return createPlugin(input, legacy)
7
+ return createUAPlugin(input, options)
10
8
  }
11
-
12
- export const UAProPlugin = async (input, options) => createUAPlugin(input, options)
13
9
  export const UAProSite1 = async (input, options) => createUASite(input, options, 0)
14
10
  export const UAProSite2 = async (input, options) => createUASite(input, options, 1)
15
11
  export const UAProSite3 = async (input, options) => createUASite(input, options, 2)
@@ -24,6 +24,14 @@ export function siteRoot(baseURL) {
24
24
  return url.href.replace(/\/+$/, '').replace(/\/(v1|v1beta)$/, '')
25
25
  }
26
26
 
27
+ export function matchesAccountSite(baseURL, account) {
28
+ const modelRoot = siteRoot(baseURL)
29
+ const accountRoot = siteRoot(account.siteUrl)
30
+ if (modelRoot === accountRoot) return true
31
+ const agentRouterSites = ['https://agentrouter.org', 'https://ps.air-outer.com']
32
+ return account.siteType === 'agentrouter' && agentRouterSites.includes(modelRoot) && agentRouterSites.includes(accountRoot)
33
+ }
34
+
27
35
  export function apiBase(connection, protocol) {
28
36
  const path = connection.apiPaths?.[protocol] ?? PATHS[protocol]
29
37
  if (typeof path !== 'string' || !/^\/(?!\/)[A-Za-z0-9/_-]*$/.test(path)) throw err('协议根路径无效')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-claude-code-ua",
3
- "version": "0.3.6",
3
+ "version": "0.3.9",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
package/setup.mjs CHANGED
@@ -15,7 +15,7 @@ async function main() {
15
15
  const store = createProfileStore(resolveProfilePath({ configPath: args.configPath }));
16
16
  const accountManager = createAccountManager({ path: join(dirname(store.path), 'accounts.json') });
17
17
  const uaStore = createUAStore(process.env.UA_PRO_FILE || join(dirname(store.path), 'ua-pro.json'));
18
- const uaService = createUAService({ store: uaStore, legacyStore: store });
18
+ const uaService = createUAService({ store: uaStore, legacyStore: store, accountManager });
19
19
  const session = await startSetupServer({ store, accountManager, uaService, port: args.port, finishOnSave: false });
20
20
  console.log(`配置页面:${session.url}\n账号管理:${new URL('accounts', session.url).href}\n配置文件:${store.path}\n页面链接仅本次有效,请勿分享。`);
21
21
  if (args.open && !await openBrowser(session.url)) console.log('未找到可用的浏览器,请手动打开上面的本地链接。');
@@ -0,0 +1,33 @@
1
+ const success = new Set(['success', 'claimed'])
2
+ const done = new Set(['already', 'already_checked', 'checked', 'done'])
3
+ const captcha = new Set(['requires_verification', 'verification_required', 'captcha'])
4
+ const amount = value => Number.isFinite(value) ? value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 6 }) : '—'
5
+
6
+ export function accountUsage(account) {
7
+ const snapshot = account.snapshot
8
+ const unit = account.unit ?? 'USD'
9
+ const currency = unit.toLowerCase() === 'usd'
10
+ const stale = account.status?.stale === true
11
+ const remaining = snapshot?.remaining
12
+ const used = snapshot?.used
13
+ const total = Number.isFinite(remaining) && Number.isFinite(used) ? remaining + used : null
14
+ return {
15
+ plan: account.siteType === 'agentrouter' ? 'AgentRouter' : 'New API', user: account.name, signIn: 'kept',
16
+ ...(Number.isFinite(remaining) ? { balance: `${currency ? '$' : ''}${amount(remaining)}${currency ? '' : ` ${unit}`}${stale ? '(缓存)' : ''}` } : {}),
17
+ ...(account.status?.error ? { error: account.status.error } : {}),
18
+ windows: [{ name: account.name, aside: true, unit: currency ? 'usd' : unit,
19
+ ...(Number.isFinite(used) ? { amount: used } : {}),
20
+ ...(Number.isFinite(total) && total >= 0 ? { limit: total, used: total > 0 ? Math.min(100, Math.max(0, used / total * 100)) : 0 } : {}),
21
+ display: snapshot ? `余额 ${amount(remaining)} ${unit};累计消耗 ${amount(used)};消耗增量 ${amount(snapshot.deltaUsed)};余额变化 ${amount(snapshot.deltaBalance)}${stale ? '(缓存)' : ''}${account.status?.error ? `;${account.status.error}` : ''}` : account.status?.error || '尚未读取余额' }],
22
+ }
23
+ }
24
+
25
+ export function accountCheckin(account) {
26
+ if (!account?.enabled || !account.checkinEnabled) return { outcome: 'inactive', message: '当前账号未启用签到' }
27
+ const state = account.checkin?.state
28
+ const claimed = success.has(state)
29
+ const outcome = claimed ? 'claimed' : done.has(state) ? 'done' : captcha.has(state) ? 'captcha' : state === 'disabled' ? 'inactive' : 'failed'
30
+ const reward = account.checkin?.reward
31
+ return { outcome, ...(claimed && Number.isFinite(reward) ? { credit: reward } : {}),
32
+ message: `${account.name}:${claimed ? `签到成功${reward == null ? '' : `,奖励 ${reward} ${account.unit}`}` : done.has(state) ? '今日已签到' : captcha.has(state) ? '需要在站点完成验证' : state === 'disabled' ? '站点未开启签到' : `签到失败(${account.checkin?.message || '未完成'})`}` }
33
+ }
package/src/accounts.mjs CHANGED
@@ -2,22 +2,33 @@ import { randomUUID } from 'node:crypto';
2
2
  import { mkdir, open, readFile, rename, unlink, stat } from 'node:fs/promises';
3
3
  import { dirname, isAbsolute, resolve } from 'node:path';
4
4
  import { AccountValidationError, createNewApiClient, NewApiError, normalizeAccountConfig, safeNewApiMessage } from './newapi.mjs';
5
- import { scheduleOccurrence } from './schedule.mjs';
5
+ import { scheduleDate, scheduleOccurrence } from './schedule.mjs';
6
6
 
7
7
  export { AccountValidationError } from './newapi.mjs';
8
8
  const MAX_ACCOUNTS = 100;
9
9
  const MAX_HISTORY = 90;
10
10
  const MAX_FILE_BYTES = 8 * 1024 * 1024;
11
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/u;
11
12
  // All managers sharing a file share the transaction queue and network deduplication.
12
13
  const sessions = new Map();
13
14
  const freshStatus = () => ({ stale: false, error: null, lastAttemptAt: null });
14
- const freshCheckin = () => ({ date: null, checkedAt: null, rewardQuota: null, state: 'unknown', message: '' });
15
- const identity = a => JSON.stringify([a.siteUrl, a.userId]);
15
+ const freshCheckin = () => ({ date: null, checkedAt: null, lastAttemptAt: null, rewardQuota: null, state: 'unknown', message: '' });
16
+ const identity = a => JSON.stringify([a.siteUrl, a.siteType, a.userId || `username:${a.username}`]);
17
+ const balanceIdentity = a => JSON.stringify([a.siteUrl, a.userId || `username:${a.username}`]);
18
+ const loginIdentity = a => JSON.stringify([a.siteUrl, a.siteType, a.userId, a.username]);
16
19
  const missing = () => new AccountValidationError('账号不存在。', 404);
17
20
  const own = (state, id) => { const account = state.accounts.find(a => a.id === id); if (!account) throw missing(); return account; };
18
21
  const successful = result => ['success', 'already', 'already_checked'].includes(result?.state);
19
22
  const day = value => new Date(value).toISOString().slice(0, 10);
20
23
  const amount = (quota, divisor) => quota === null || quota === undefined || !Number.isFinite(quota / divisor) ? null : quota / divisor;
24
+ const localDay = (value, timeZone) => {
25
+ const at = value instanceof Date ? value : typeof value === 'string' && Number.isFinite(Date.parse(value)) ? Date.parse(value) : null;
26
+ if (at === null) return null;
27
+ try { return scheduleDate(at, timeZone); } catch { return null; }
28
+ };
29
+ function attemptDay(checkin, timeZone) {
30
+ return localDay(checkin?.lastAttemptAt, timeZone) ?? localDay(checkin?.checkedAt, timeZone);
31
+ }
21
32
 
22
33
  function projectSnapshot(snapshot, divisor) {
23
34
  if (!snapshot) return null;
@@ -29,14 +40,32 @@ function projectSnapshot(snapshot, divisor) {
29
40
 
30
41
  function publicAccount(account) {
31
42
  const config = normalizeAccountConfig(account);
32
- return { id: account.id, revision: account.revision, ...config, accessToken: '', cookie: '', secrets: { accessToken: Boolean(account.accessToken), cookie: Boolean(account.cookie) },
43
+ const secrets = { accessToken: Boolean(account.accessToken), cookie: Boolean(account.cookie) };
44
+ // Keep legacy New API clients byte-compatible; AgentRouter always needs the password flag.
45
+ if (config.siteType === 'agentrouter' || config.password) secrets.password = Boolean(account.password);
46
+ return { id: account.id, revision: account.revision, ...config, accessToken: '', cookie: '', password: '', secrets,
33
47
  snapshot: projectSnapshot(account.snapshot, account.quotaPerUnit),
34
48
  history: account.history.map(item => projectSnapshot(item, account.quotaPerUnit)),
35
49
  status: { ...account.status },
36
- checkin: { date: account.checkin.date, checkedAt: account.checkin.checkedAt, rewardQuota: account.checkin.rewardQuota,
50
+ checkin: { date: account.checkin.date, checkedAt: account.checkin.checkedAt,
51
+ lastAttemptAt: account.checkin.lastAttemptAt ?? null, rewardQuota: account.checkin.rewardQuota,
37
52
  reward: amount(account.checkin.rewardQuota, account.quotaPerUnit), state: account.checkin.state, message: account.checkin.message } };
38
53
  }
39
54
 
55
+ function alreadyResult(account, source) {
56
+ const current = publicAccount(account);
57
+ const recorded = source ?? account;
58
+ const rewardQuota = Number.isSafeInteger(recorded.checkin?.rewardQuota) && recorded.checkin.rewardQuota >= 0
59
+ ? recorded.checkin.rewardQuota : current.checkin.rewardQuota;
60
+ return { ...current, checkin: { ...current.checkin,
61
+ date: recorded.checkin?.date ?? current.checkin.date,
62
+ checkedAt: recorded.checkin?.checkedAt ?? current.checkin.checkedAt,
63
+ lastAttemptAt: recorded.checkin?.lastAttemptAt ?? current.checkin.lastAttemptAt,
64
+ rewardQuota,
65
+ reward: amount(rewardQuota, account.quotaPerUnit),
66
+ state: 'already', message: '今日已签到。' } };
67
+ }
68
+
40
69
  function totalsFor(accounts) {
41
70
  const groups = new Map();
42
71
  for (const account of accounts) {
@@ -116,7 +145,7 @@ export function createAccountManager({ path, fetchImpl, now = () => new Date(),
116
145
  async function get(id) { return locked(async () => publicAccount(own(await read(), id))); }
117
146
 
118
147
  async function save(raw, clearSecrets = []) {
119
- if (!Array.isArray(clearSecrets) || clearSecrets.some(name => !['accessToken', 'cookie'].includes(name))) throw new AccountValidationError('清除凭据的字段无效。');
148
+ if (!Array.isArray(clearSecrets) || clearSecrets.some(name => !['accessToken', 'cookie', 'password'].includes(name))) throw new AccountValidationError('清除凭据的字段无效。');
120
149
  const config = normalizeAccountConfig(raw);
121
150
  return locked(async () => {
122
151
  const state = await read();
@@ -128,13 +157,17 @@ export function createAccountManager({ path, fetchImpl, now = () => new Date(),
128
157
  'schedule.enabled': '同一站点账号的别名只能设置一份自动签到,请先关闭另一份',
129
158
  });
130
159
  }
131
- const unchangedIdentity = previous && identity(previous) === identity(config);
132
- for (const secret of ['accessToken', 'cookie']) config[secret] = config[secret] || (!unchangedIdentity || clearSecrets.includes(secret) ? '' : previous?.[secret] ?? '');
160
+ const sameAccount = previous && balanceIdentity(previous) === balanceIdentity(config);
161
+ const sameLogin = previous && loginIdentity(previous) === loginIdentity(config);
162
+ for (const secret of ['accessToken', 'cookie', 'password']) {
163
+ const preserve = secret === 'password' ? sameLogin : sameAccount;
164
+ config[secret] = config[secret] || (!preserve || clearSecrets.includes(secret) ? '' : previous?.[secret] ?? '');
165
+ }
133
166
  const account = { ...config, id: previous?.id ?? uuid(), revision: randomUUID(),
134
- snapshot: unchangedIdentity ? previous.snapshot : null,
135
- history: unchangedIdentity ? previous.history : [],
136
- status: unchangedIdentity ? previous.status : freshStatus(),
137
- checkin: unchangedIdentity ? previous.checkin : freshCheckin() };
167
+ snapshot: sameAccount ? previous.snapshot : null,
168
+ history: sameAccount ? previous.history : [],
169
+ status: sameAccount ? previous.status : freshStatus(),
170
+ checkin: sameLogin ? previous.checkin : freshCheckin() };
138
171
  if (typeof account.id !== 'string' || !account.id || state.accounts.some(a => a.id === account.id && a !== previous)) throw new AccountValidationError('无法创建唯一账号 ID。', 409);
139
172
  if (previous) state.accounts[state.accounts.indexOf(previous)] = account;
140
173
  else state.accounts.push(account);
@@ -186,33 +219,60 @@ export function createAccountManager({ path, fetchImpl, now = () => new Date(),
186
219
  return session.refreshes.get(requestKey);
187
220
  }
188
221
 
189
- function checkinResult(result, at) {
222
+ function checkinResult(result, at, timeZone = 'UTC') {
190
223
  const states = ['success', 'already', 'disabled', 'requires_verification', 'unknown', 'error'];
191
224
  const state = result?.state === 'already_checked' ? 'already' : states.includes(result?.state) ? result.state : 'error';
192
225
  const validDate = /^\d{4}-\d{2}-\d{2}$/u.test(result?.date ?? '') && Number.isFinite(Date.parse(result.date)) && day(result.date) === result.date;
226
+ const fallbackDay = localDay(at, timeZone) ?? day(at);
193
227
  const messages = { success: '签到成功。', already: '今日已签到。', disabled: '站点未启用签到。', requires_verification: '请前往站点完成验证码后签到。', unknown: '签到结果未确认,请在站点确认记录;不会自动重试。', error: '签到未完成,请前往站点检查。' };
194
- return { date: successful({ state }) ? validDate ? result.date : day(at) : null,
228
+ return { date: successful({ state }) ? validDate ? result.date : fallbackDay : null,
195
229
  checkedAt: successful({ state }) ? at : null,
230
+ lastAttemptAt: at,
196
231
  rewardQuota: successful({ state }) && Number.isSafeInteger(result.rewardQuota) && result.rewardQuota >= 0 ? result.rewardQuota : null,
197
232
  state, message: messages[state],
198
- // Returned site dates are authoritative; UTC is the explicit fallback when absent.
199
- dedupeDay: successful({ state }) ? day(at) : null };
233
+ // Returned site dates are authoritative; the configured schedule zone is the fallback.
234
+ dedupeDay: successful({ state }) ? fallbackDay : null };
200
235
  }
201
236
 
202
237
  async function performCheckin(captured, aliases, options) {
203
- const at = timestamp(); let result;
204
- try { result = checkinResult(await api.checkin({ ...captured }, options), at); }
205
- catch (error) {
238
+ const at = timestamp(); let result; let authUpdate = null;
239
+ try {
240
+ const response = await api.checkin({ ...captured }, options);
241
+ result = checkinResult(response, at, captured.schedule.timeZone);
242
+ if (response?.authenticated === true) {
243
+ const session = typeof response.cookie === 'string' && response.cookie
244
+ && response.cookie.length <= 32768 && !CONTROL.test(response.cookie) ? response.cookie : null;
245
+ if (typeof response.userId === 'string' && /^[1-9]\d*$/u.test(response.userId) && response.userId.length <= 32) {
246
+ authUpdate = { ...authUpdate, userId: response.userId };
247
+ }
248
+ if (session) {
249
+ authUpdate = { ...authUpdate, cookie: session };
250
+ // A password-only AgentRouter login can immediately use its new session.
251
+ // Existing PATs are never downgraded or replaced.
252
+ if (captured.authMode === 'token' && !captured.accessToken) authUpdate.authMode = 'cookie';
253
+ }
254
+ }
255
+ } catch (error) {
206
256
  const code = error instanceof NewApiError ? error.code : null;
207
257
  const state = code === 'turnstile' ? 'requires_verification' : code === 'ambiguous' ? 'unknown' : 'error';
208
- result = { ...checkinResult({ state }, at), message: safeNewApiMessage(error) };
258
+ result = { ...checkinResult({ state }, at, captured.schedule.timeZone), message: safeNewApiMessage(error) };
209
259
  }
260
+ const capturedIdentity = identity(captured);
210
261
  const updated = await locked(async () => {
211
262
  const state = await read(); const initiator = state.accounts.find(a => a.id === captured.id);
212
263
  if (!initiator || initiator.revision !== captured.revision) return false;
264
+ if (authUpdate?.userId) initiator.userId = authUpdate.userId;
265
+ if (authUpdate?.cookie) initiator.cookie = authUpdate.cookie;
266
+ if (authUpdate?.authMode) initiator.authMode = authUpdate.authMode;
267
+ initiator.checkin = { ...result };
213
268
  for (const alias of aliases) {
214
269
  const current = state.accounts.find(a => a.id === alias.id);
215
- if (current && current.revision === alias.revision && identity(current) === identity(captured)) current.checkin = { ...result };
270
+ if (current && current !== initiator && current.revision === alias.revision && identity(current) === capturedIdentity) {
271
+ if (authUpdate?.userId) current.userId = authUpdate.userId;
272
+ if (authUpdate?.cookie) current.cookie = authUpdate.cookie;
273
+ if (authUpdate?.authMode && current.authMode === 'token' && !current.accessToken) current.authMode = authUpdate.authMode;
274
+ current.checkin = { ...result };
275
+ }
216
276
  }
217
277
  await write(state); return true;
218
278
  });
@@ -241,8 +301,21 @@ export function createAccountManager({ path, fetchImpl, now = () => new Date(),
241
301
  const requestKey = identity(captured);
242
302
  if (session.checkins.has(requestKey)) return { pending: session.checkins.get(requestKey) };
243
303
  const sameIdentity = state.accounts.filter(a => identity(a) === identity(captured));
244
- const known = sameIdentity.find(a => successful(a.checkin) && (a.checkin.dedupeDay ?? a.checkin.date) === day(timestamp()));
245
- if (known && !api.authoritativeCheckinDay) {
304
+ const scheduleDay = localDay(now(), captured.schedule.timeZone);
305
+ if (expectedRevision !== undefined && scheduleDay) {
306
+ // A manual or earlier timer attempt at any result blocks a second automatic POST
307
+ // for the same local schedule day; the persisted first record is never rewritten.
308
+ const attempted = sameIdentity.find(a => attemptDay(a.checkin, captured.schedule.timeZone) === scheduleDay);
309
+ if (attempted) {
310
+ return { skip: successful(attempted.checkin) ? alreadyResult(captured, attempted) : publicAccount(captured) };
311
+ }
312
+ }
313
+ const authoritative = captured.siteType === 'newapi' && api.authoritativeCheckinDay;
314
+ const known = sameIdentity.find(a => successful(a.checkin)
315
+ && (attemptDay(a.checkin, captured.schedule.timeZone) ?? a.checkin.dedupeDay ?? a.checkin.date) === scheduleDay);
316
+ if (known && !authoritative) {
317
+ // A repeated AgentRouter query reports done/already without touching the first reward record.
318
+ if (captured.siteType === 'agentrouter') return { skip: alreadyResult(captured, known) };
246
319
  if (captured !== known) { captured.checkin = { ...known.checkin }; await write(state); }
247
320
  return { skip: publicAccount(captured) };
248
321
  }