opencode-claude-code-ua 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Magpie Codex Profile contributors
4
+ Copyright (c) 2026 sub2api-magpie-plugin contributors
5
+ Copyright (c) 2026 Claude Code UA contributors
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
package/NOTICE.md ADDED
@@ -0,0 +1,9 @@
1
+ # 第三方代码说明
2
+
3
+ UA PRO 按 MIT 许可分发,原始版权声明保留在 [LICENSE](LICENSE)。
4
+
5
+ - Profile 配置、设置服务、New API 账号与调度以及部分界面基于 `magpie-codex-profile` 0.7.0,按 MIT 许可复用和适配。原始作者:Magpie Codex Profile contributors。
6
+ - 初始 Codex / Claude 转换模板及部分兼容算法参考并适配 MIT 项目 [FischelChern/sub2api-magpie-plugin](https://github.com/FischelChern/sub2api-magpie-plugin) 0.4.16,固定提交 `a22d079b241e630fc05d4ed2890580156c7828dd`。
7
+ - 未包含参考项目的用户配置、凭据、抓包、截图或其他运行数据。
8
+
9
+ 兼容模板用于已获授权的上游 API,不提供订阅 OAuth、设备认证、账单签名或额外访问权限。
package/README.md CHANGED
@@ -1,3 +1,201 @@
1
- # Temporary Holding Version
1
+ # UA PRO
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 独立的 Magpie 出站请求配置插件,内置 **Codex / Claude Code 请求模板**,支持 Responses、Anthropic Messages、Chat Completions 和 Gemini。模板随插件提供,可按连接或模型应用,支持版本保存、测试与回退。
4
+
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 安装获取的是当前已发布版本。
8
+
9
+ ## 安装与打开管理页
10
+
11
+ 在 Magpie「插件 → 添加插件」中填写:
12
+
13
+ ```text
14
+ opencode-claude-code-ua
15
+ ```
16
+
17
+ 或使用终端:
18
+
19
+ ```sh
20
+ magpie plugin add opencode-claude-code-ua
21
+ magpie plugin login ua-pro
22
+ ```
23
+
24
+ 选择 **打开 UA PRO 管理**,已有账号时也可再次执行登录命令。
25
+
26
+ 在 **管理连接**中查看、编辑已保存连接;它打开“连接与模型”,不执行模型请求。从 Magpie 打开管理页保存普通连接后,连接显示在 **UA PRO → 账号列表**中,而不是新增一个同名供应商。概览会显示实际注册状态。
27
+
28
+ 若连接由独立 `node setup.mjs` 保存,需从 Magpie 打开管理页,点击 **同步到宿主**,再刷新供应商的模型列表。只有勾选独立显示才会有单独供应商;新增独立站点保存后,需要重载 UA PRO 插件。
29
+
30
+ 也支持 GitHub 源码来源。在 Magpie 的手动添加框填写,或使用终端:
31
+
32
+ ```sh
33
+ magpie plugin add "github:1466109846/ua-pro#main"
34
+ ```
35
+
36
+ **发现页的 GitHub 卡片不一定安装源码**:当仓库对应的 npm 包存在时,Magpie 会优先安装 npm 的 `latest`。若 npm 尚未发布新版本,反复卸载重装该卡片仍会得到相同版本。使用上面的 `#main` 来源可明确选择 GitHub 默认分支。
37
+
38
+ npm 与 GitHub 来源二选一,不要重复安装。管理页侧栏显示实际运行版本,用于核对安装是否更新。
39
+
40
+ ### 独立打开管理页
41
+
42
+ 需要 Node.js 22 或以上。在下载的插件目录运行:
43
+
44
+ ```sh
45
+ node setup.mjs
46
+ ```
47
+
48
+ 打开终端输出的本机链接。管理服务仅监听回环地址,链接只在本次会话有效。保存连接后,在 Magpie 重载插件;两处需要使用同一个 UA 状态文件。
49
+
50
+ ## 快速配置
51
+
52
+ 1. 打开 **连接与模型 → 添加连接**,填写名称、上游 Base URL、认证方式和 API key。
53
+ 2. 点击 **获取模型**,选择需要的模型,追加或替换到列表,再保存连接。
54
+ 3. 使用自动模板,或在 **应用模板**中选择一个已保存的模板版本。
55
+ 4. 打开 **测试**,检查最终地址、模型、协议和模板,再确认发送请求。
56
+ 5. 在 Magpie 重新获取模型或重载插件,载入保存的连接与路由。
57
+
58
+ 客户端连接 **Magpie 本地网关**,使用 Magpie 网关密钥,不是上游 API key。模型引用可从测试页复制:
59
+
60
+ - 主池:`ua-pro/<模型 ID>`。
61
+ - 独立站点:`ua-pro-<siteId>/<模型 ID>`。
62
+
63
+ 勾选 **在 Magpie 中独立显示此站点**并填写站点 ID,可将连接显示为独立供应商。最多 8 个独立站点,每个站点可配置多个账号;站点增删后需要重载插件。
64
+
65
+ ## 连接与模型
66
+
67
+ Base URL 可填写站点根地址,或以 `/v1`、`/v1beta` 结尾的地址。远程地址要求 HTTPS,本机回环可使用 HTTP。自定义协议根路径可保留部署前缀,例如 `/anthropic/v1`;不要填写外域 URL、查询参数或具体请求路径。
68
+
69
+ | 认证方式 | 发送方式 |
70
+ |---|---|
71
+ | `api-key` | Messages 使用 `x-api-key`,Gemini 使用 `x-goog-api-key`,Responses / Chat 使用 Bearer |
72
+ | `bearer` | 始终使用 Bearer,填写纯 key |
73
+ | `custom` | 填写完整 Authorization 值 |
74
+ | `none` | 不发送认证 |
75
+
76
+ 编辑同一地址的连接时,留空 key 可保留已有密钥;更换地址需要重新填写凭据。通过 Magpie 保存的账号凭据由宿主管理,不会复制到模板。
77
+
78
+ 模型查询只读取列表,不发送生成请求。选择模型只修改草稿,保存后才生效;失败、取消或空列表不会清空手填内容。缓存模型会标明来源与时间,不会冒充本次查询成功。
79
+
80
+ 每个连接可以设置默认协议、模型级协议覆盖,以及连接级或模型级模板绑定。自动协议是按模型名推断的,不保证上游支持;明确知道接口类型时,应保存固定协议。
81
+
82
+ 测试页显示 **已保存的生产上游**。仅切换测试协议不会修改连接;需要保存时,点击 **将本次协议与模板保存为此模型路由**。插件不会在请求失败后自动更换 key、模型或协议。
83
+
84
+ ## 模板与版本
85
+
86
+ 插件内提供三份初始模板:
87
+
88
+ | 模板 | 用途 |
89
+ |---|---|
90
+ | Codex | 内置只读的 Responses 请求规则 |
91
+ | Claude Code | 内置只读的 Messages 请求规则 |
92
+ | Claude Code VS Code 2.1.316 | 可编辑的 Messages 请求模板 |
93
+
94
+ 自动绑定按最终上游协议选择:Responses → Codex,Messages → Claude Code,Chat / Gemini → 普通转发。
95
+
96
+ 要自定义规则:
97
+
98
+ 1. 在 **模板库 → 新增模板**中选择一个模板作为基线。
99
+ 2. 修改名称、UA、请求头或正文规则,**保存新版本**。
100
+ 3. 预览或测试后,**应用**到指定连接或模型。
101
+
102
+ 保存与应用分开。保存 v2 不影响仍绑定 v1 的连接;回退时选择历史版本重新应用。被引用的版本不能删除,归档不会中断已有绑定。JSON 导入只创建草稿,不自动保存或应用。
103
+
104
+ 请求头支持覆盖、补缺、删除和保留;参数按 `defaults → overrides → remove` 处理。模板不能覆盖 Authorization、API key、Cookie 等认证头,也不执行用户脚本。默认保护模型、消息、工具与流式选择;计数接口不执行生成参数覆盖。
105
+
106
+ 内置模板的会话与正文兼容规则可能影响提示前缀、token 用量或缓存。可复制为自定义模板后查看、修改或关闭。UA 和兼容字段不证明官方客户端身份,也不提供额外账号权限。
107
+
108
+ ## 余额与签到
109
+
110
+ ### 在 Magpie 用量页查看余额
111
+
112
+ UA PRO 中已配置的连接可以复用模型 Key 查询站点支持的用量接口:
113
+
114
+ - **NewAPI**:显示 Key 剩余额度;站点明确授权账号级查询时,才显示账号余额。Key 额度不等于账号总余额。
115
+ - **不限额 Key**:不会显示成零余额。站点额度倍率无法确认时,金额会标注估算。
116
+ - **仅支持累计消耗的站点**:显示已用金额与“余额未知”,不把未知余额当成 0。
117
+
118
+ 在 **余额与签到**页点击 **刷新全部余额**,可同时读取站点账号余额和已配置连接的 Key 用量。Key 余额不依赖签到凭据;签到账号缺少凭据、读取失败或被停用时,不会阻止对应连接的 Key 余额查询。两类读数分别展示,不混合汇总。
119
+
120
+ 用量刷新只查询数据,不会自动登录或签到。用量页显示的是添加到 UA PRO 的连接,余额是否可读取取决于站点接口与权限;目前不提供余额趋势图。
121
+
122
+ ### 添加签到账号
123
+
124
+ 打开 **余额与签到 → 添加账号**,选择站点类型:
125
+
126
+ | 站点类型 | 签到所需凭据 |
127
+ |---|---|
128
+ | NewAPI | 站点根地址、数字用户 ID、登录 Cookie 或有签到权限的系统访问令牌 |
129
+ | AgentRouter | 站点地址、登录用户名和密码;用户 ID 可留空,由成功登录确认 |
130
+
131
+ NewAPI 使用站点签到接口。AgentRouter 通过登录领取每日额度;成功返回的 session 仅保存在本机,用于之后的余额查询。模型 API key 不能替代签到账号凭据。
132
+
133
+ 添加账号后:
134
+
135
+ 1. 在 **连接与模型 → 编辑连接 → 余额与签到账号**中选择对应账号并保存。
136
+ 2. 重载插件,在 Magpie **用量**页刷新余额或点击 **立即签到**。
137
+
138
+ 绑定不修改模型 Key、协议或模板。同站点、同数字用户 ID 的账号修改签到类型或登录用户名时,已有 PAT/Cookie 和余额记录保留;更换站点或用户 ID 不会沿用原账号凭据。
139
+
140
+ 一般只能绑定同站点账号;AgentRouter 支持 `agentrouter.org` 与 `ps.air-outer.com` 的双地址配置。已删除或异站绑定会提示失效,不会自动换成另一个账号。站点要求验证码时,需前往站点完成验证。
141
+
142
+ ### 每日定时签到
143
+
144
+ 在账号编辑页开启定时签到,设置每天的时间和 IANA 时区,例如 `Asia/Shanghai`。
145
+
146
+ - 需要 Magpie 插件进程保持运行;关闭管理页面不影响定时任务。
147
+ - 独立运行 `node setup.mjs` 不启动定时签到。
148
+ - 开启定时后仍可手动签到;同日已尝试或领取时,插件定时器不会重复提交。
149
+ - 错过指定分钟不补跑,失败或结果未知不自动重试。
150
+ - 不要同时开启 Magpie 自身的自动签到计划,以免两套计划重复执行。
151
+
152
+ 签到是否领取成功以站点返回为准。余额变化和消耗增量来自两次成功读取,不等于“今日用量”。
153
+
154
+ ## 测试请求
155
+
156
+ | 测试层次 | 行为 |
157
+ |---|---|
158
+ | 本地转换预览 | 不联网,展示脱敏后的 URL、请求头、正文和规则变化 |
159
+ | 模型列表检查 | 读取模型目录,不证明模型可以生成回复 |
160
+ | 实际模型请求 | 经确认后发送一次请求,支持普通/流式,可能产生费用 |
161
+
162
+ 发送前的确认框展示最终地址、模型、协议和模板。测试路径覆盖只影响本次测试;自定义正文不会因切换模型或协议而被静默覆盖。HTTP 200 中的错误 JSON 或错误 SSE 仍显示为失败。
163
+
164
+ ## 数据与安全
165
+
166
+ | 默认文件 | 用途 |
167
+ |---|---|
168
+ | `runtime/ua-pro.json` | 模板、连接、绑定及本地保存的连接凭据 |
169
+ | `runtime/ua-pro.json.models.json` | 模型目录、来源与时间,不含 key |
170
+ | `runtime/accounts.json` | 站点账号、Cookie 和显式保存的登录凭据 |
171
+ | `runtime/schedule-claims/` | 每日签到防重复记录 |
172
+
173
+ `uaFile` 插件选项或 `UA_PRO_FILE` 环境变量可指定 UA 状态文件的绝对路径。
174
+
175
+ 本地凭据以文件权限保护的明文保存,不是加密保险库。不要分享 `runtime/`、配置副本或含秘密的自定义请求头。仅连接自己拥有或已获授权的 API。
176
+
177
+ ## 常见问题
178
+
179
+ **如何再次打开管理页?**
180
+
181
+ 执行 `magpie plugin login ua-pro`,选择 **打开 UA PRO 管理**。管理会话过期后需重新打开,刷新页面不能延长授权。
182
+
183
+ **宿主能获取模型,独立管理工具却失败?**
184
+
185
+ 独立 Node 进程不继承 Magpie 账号代理。可从 Magpie 打开管理页,或为独立进程配置网络环境。
186
+
187
+ **模型列表成功,生成却返回 404?**
188
+
189
+ 检查上游协议、协议根路径和模型引用。目录读取成功不代表站点支持该生成接口。
190
+
191
+ **保存后为什么没有生效?**
192
+
193
+ 模板要先保存、再应用。连接、站点或模型协议更新后,在 Magpie 重新获取模型或重载插件,并重新打开管理页。
194
+
195
+ ## 反馈与许可
196
+
197
+ - [GitHub 仓库](https://github.com/1466109846/ua-pro)
198
+ - [问题反馈](https://github.com/1466109846/ua-pro/issues)
199
+ - [npm 包](https://www.npmjs.com/package/opencode-claude-code-ua)
200
+
201
+ 本项目按 MIT 许可分发,版权和第三方来源见 [LICENSE](LICENSE) 与 [NOTICE.md](NOTICE.md)。
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Magpie Claude Code UA">
2
+ <rect width="64" height="64" rx="14" fill="#3b2b24"/>
3
+ <g fill="none" stroke="#f0b79b" stroke-width="3.2" stroke-linecap="round">
4
+ <path d="M32 11.5v9.2M32 43.3v9.2M11.5 32h9.2M43.3 32h9.2"/>
5
+ <path d="M17.4 17.4l6.5 6.5M40.1 40.1l6.5 6.5M46.6 17.4l-6.5 6.5M23.9 40.1l-6.5 6.5"/>
6
+ </g>
7
+ <circle cx="32" cy="32" r="7" fill="#e6a184"/>
8
+ <circle cx="32" cy="32" r="2.4" fill="#3b2b24"/>
9
+ </svg>
package/index.mjs ADDED
@@ -0,0 +1,19 @@
1
+ import { createPlugin } from './src/plugin.mjs'
2
+ import { pluginOptions } from './src/options.mjs'
3
+ import { createUAPlugin, createUASite } from './src/ua-plugin.mjs'
4
+
5
+ export default async function UAProPlugin(input, options) {
6
+ if (options !== undefined && (!options || typeof options !== 'object' || Array.isArray(options))) throw new TypeError('Plugin options must be an object')
7
+ return createUAPlugin(input, options)
8
+ }
9
+ export const UAProSite1 = async (input, options) => createUASite(input, options, 0)
10
+ export const UAProSite2 = async (input, options) => createUASite(input, options, 1)
11
+ export const UAProSite3 = async (input, options) => createUASite(input, options, 2)
12
+ export const UAProSite4 = async (input, options) => createUASite(input, options, 3)
13
+ export const UAProSite5 = async (input, options) => createUASite(input, options, 4)
14
+ export const UAProSite6 = async (input, options) => createUASite(input, options, 5)
15
+ export const UAProSite7 = async (input, options) => createUASite(input, options, 6)
16
+ export const UAProSite8 = async (input, options) => createUASite(input, options, 7)
17
+
18
+ // The host calls every exported function; helpers therefore live on an object.
19
+ export const _internal = Object.freeze({ createPlugin, pluginOptions, createUAPlugin, createUASite })
@@ -0,0 +1,108 @@
1
+ const protectedFields = new Set(["model", "messages", "stream", "tools", "tool_choice"])
2
+ const responsesFields = new Set([
3
+ "store", "include", "instructions", "input", "reasoning", "max_output_tokens",
4
+ "parallel_tool_calls", "prompt_cache_key", "response_format", "stream_options",
5
+ "previous_response_id", "conversation", "background", "truncation",
6
+ ])
7
+ const unsafeKeys = new Set(["__proto__", "constructor", "prototype"])
8
+
9
+ export function isObject(value) {
10
+ return value !== null && typeof value === "object" && !Array.isArray(value)
11
+ && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)
12
+ }
13
+
14
+ export function copyJSON(value, depth = 0) {
15
+ if (depth > 32) throw new TypeError("JSON depth exceeds 32 (possibly cyclic)")
16
+ if (value === null || typeof value === "string" || typeof value === "boolean") return value
17
+ if (typeof value === "number" && Number.isFinite(value)) return value
18
+ if (Array.isArray(value)) return value.map((item) => copyJSON(item, depth + 1))
19
+ if (!isObject(value)) throw new TypeError("Settings must contain only JSON values")
20
+ const result = {}
21
+ for (const [key, item] of Object.entries(value)) {
22
+ if (unsafeKeys.has(key)) throw new TypeError(`Unsafe option key: ${key}`)
23
+ result[key] = copyJSON(item, depth + 1)
24
+ }
25
+ return result
26
+ }
27
+
28
+ export function compileParameters(options = {}) {
29
+ if (!isObject(options)) throw new TypeError("parameters must be an object")
30
+ for (const key of Object.keys(options)) {
31
+ if (!["defaults", "overrides", "remove"].includes(key)) throw new TypeError(`Unknown parameters option: ${key}`)
32
+ }
33
+ const plan = {}
34
+ for (const section of ["defaults", "overrides"]) {
35
+ const value = options[section] === undefined ? {} : options[section]
36
+ if (!isObject(value)) throw new TypeError(`parameters.${section} must be an object`)
37
+ for (const key of Object.keys(value)) {
38
+ if (protectedFields.has(key)) throw new TypeError(`parameters.${section}.${key} is protected`)
39
+ if (responsesFields.has(key)) throw new TypeError(`${key} is not an Anthropic Messages parameter`)
40
+ }
41
+ plan[section] = copyJSON(value)
42
+ }
43
+ const remove = options.remove === undefined ? [] : options.remove
44
+ if (!Array.isArray(remove)) throw new TypeError("parameters.remove must be an array of object paths")
45
+ plan.remove = remove.map((path) => {
46
+ if (typeof path !== "string" || !/^[A-Za-z_][A-Za-z0-9_-]*(\.[A-Za-z_][A-Za-z0-9_-]*)*$/.test(path)) {
47
+ throw new TypeError("parameters.remove must contain nonempty dotted object paths (no array indexes)")
48
+ }
49
+ const keys = path.split(".")
50
+ if (keys.some((key) => unsafeKeys.has(key))) throw new TypeError(`Unsafe remove path: ${path}`)
51
+ if (protectedFields.has(keys[0])) throw new TypeError(`parameters.remove: ${keys[0]} is protected`)
52
+ return keys
53
+ })
54
+ plan.active = Object.keys(plan.defaults).length > 0 || Object.keys(plan.overrides).length > 0 || plan.remove.length > 0
55
+ return plan
56
+ }
57
+
58
+ function merge(target, source, defaults) {
59
+ for (const [key, value] of Object.entries(source)) {
60
+ if (!Object.hasOwn(target, key)) {
61
+ target[key] = copyJSON(value)
62
+ } else if (isObject(value) && isObject(target[key])) {
63
+ // A different thinking mode must not inherit the previous mode's budget.
64
+ if (key === "thinking" && Object.hasOwn(value, "type") && value.type !== target[key].type) {
65
+ if (!defaults) target[key] = copyJSON(value)
66
+ } else merge(target[key], value, defaults)
67
+ } else if (!defaults) target[key] = copyJSON(value)
68
+ }
69
+ }
70
+
71
+ function validate(body) {
72
+ if (!Number.isSafeInteger(body.max_tokens) || body.max_tokens < 0) throw new TypeError("max_tokens must be a nonnegative safe integer")
73
+ for (const key of ["temperature", "top_p"]) {
74
+ if (Object.hasOwn(body, key) && (typeof body[key] !== "number" || !Number.isFinite(body[key]) || body[key] < 0 || body[key] > 1)) {
75
+ throw new TypeError(`${key} must be a number between 0 and 1`)
76
+ }
77
+ }
78
+ if (Object.hasOwn(body, "top_k") && (!Number.isSafeInteger(body.top_k) || body.top_k < 0)) throw new TypeError("top_k must be a nonnegative safe integer")
79
+ if (!Object.hasOwn(body, "thinking")) return
80
+ const thinking = body.thinking
81
+ if (!isObject(thinking) || typeof thinking.type !== "string" || !thinking.type.trim()) throw new TypeError("thinking.type must be a nonempty string")
82
+ if (thinking.type === "enabled") {
83
+ // Interleaved thinking can legitimately budget more than max_tokens.
84
+ if (!Number.isSafeInteger(thinking.budget_tokens) || thinking.budget_tokens < 1024) {
85
+ throw new TypeError("thinking.budget_tokens must be an integer >= 1024")
86
+ }
87
+ } else if (["adaptive", "disabled", "between_tools"].includes(thinking.type) && Object.hasOwn(thinking, "budget_tokens")) {
88
+ throw new TypeError("budget_tokens is only valid with thinking.type enabled")
89
+ }
90
+ }
91
+
92
+ export function applyParameters(body, plan) {
93
+ if (!plan.active) return body
94
+ if (!isObject(body)) throw new TypeError("Messages body must be a JSON object")
95
+ const result = structuredClone(body)
96
+ merge(result, plan.defaults, true)
97
+ merge(result, plan.overrides, false)
98
+ for (const keys of plan.remove) {
99
+ let parent = result
100
+ for (const key of keys.slice(0, -1)) {
101
+ if (!isObject(parent) || !Object.hasOwn(parent, key)) { parent = undefined; break }
102
+ parent = parent[key]
103
+ }
104
+ if (isObject(parent)) delete parent[keys.at(-1)]
105
+ }
106
+ validate(result)
107
+ return result
108
+ }
@@ -0,0 +1,51 @@
1
+ // Shared URL/routing primitives. Keep runtime and browser test plans on one path contract.
2
+ export const SDK_BY_PROTOCOL = Object.freeze({ anthropic: '@ai-sdk/anthropic', responses: '@ai-sdk/openai', 'chat-completions': '@ai-sdk/openai-compatible', gemini: '@ai-sdk/google' })
3
+ export const PATHS = { anthropic: '/v1', responses: '/v1', 'chat-completions': '/v1', gemini: '/v1beta' }
4
+ const err = (message, status = 400) => Object.assign(new Error(message), { status })
5
+
6
+ export function protocolForModel(connection, model = '') {
7
+ const configured = connection.modelProtocols?.[model] ?? connection.protocol ?? 'auto'
8
+ if (configured !== 'auto') {
9
+ if (!Object.hasOwn(SDK_BY_PROTOCOL, configured)) throw err('不支持的上游协议')
10
+ return configured
11
+ }
12
+ const name = model.toLowerCase().split('/').at(-1)
13
+ if (/^claude[-.]/.test(name) || model.startsWith('anthropic/')) return 'anthropic'
14
+ if (/^(gpt-|o\d|codex-|chatgpt-)/.test(name)) return 'responses'
15
+ if (/^gemini[-.]/.test(name)) return 'gemini'
16
+ return 'chat-completions'
17
+ }
18
+
19
+ export function siteRoot(baseURL) {
20
+ let url
21
+ try { url = new URL(baseURL) } catch { throw err('请配置有效的上游地址') }
22
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw err('上游地址不能包含凭据、查询或片段')
23
+ if (url.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) throw err('远程上游必须使用 HTTPS')
24
+ return url.href.replace(/\/+$/, '').replace(/\/(v1|v1beta)$/, '')
25
+ }
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
+
35
+ export function apiBase(connection, protocol) {
36
+ const path = connection.apiPaths?.[protocol] ?? PATHS[protocol]
37
+ if (typeof path !== 'string' || !/^\/(?!\/)[A-Za-z0-9/_-]*$/.test(path)) throw err('协议根路径无效')
38
+ return siteRoot(connection.baseURL) + path.replace(/\/+$/, '')
39
+ }
40
+
41
+ export function operationURL(connection, protocol, operation, model) {
42
+ let suffix = operation
43
+ if (protocol === 'anthropic' && operation === 'count_tokens') suffix = 'messages/count_tokens'
44
+ if (protocol === 'gemini') {
45
+ if (!['generateContent', 'streamGenerateContent', 'countTokens'].includes(operation)) throw err('Gemini 操作无效')
46
+ suffix = `models/${encodeURIComponent(model)}:${operation}`
47
+ }
48
+ const allowed = { anthropic: ['messages', 'count_tokens'], responses: ['responses'], 'chat-completions': ['chat/completions'], gemini: ['generateContent', 'streamGenerateContent', 'countTokens'] }
49
+ if (!allowed[protocol]?.includes(operation)) throw err('操作与上游协议不匹配')
50
+ return apiBase(connection, protocol) + '/' + suffix
51
+ }