From c08f2120b054400246f088706c258518d7a32a73 Mon Sep 17 00:00:00 2001 From: jinjunnn Date: Tue, 30 Jun 2026 14:38:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui-mac):=20LLM=20auth=20routing=20+=20endp?= =?UTF-8?q?oint=20=E5=8F=91=E7=8E=B0=20+=20BYOK=20key=20=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=20+=20shared=20JWT=20=E5=90=8C=E6=AD=A5(auth=202/2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 承接 #16(模型选择器代理激活 + BYOK),补齐 auth 第二阶段(叠在 PR #16 之上): - endpoint 发现 / provider 状态:alpha-endpoints · alpha-provider-status · endpoints-ipc · use-alpha-endpoints - BYOK key 持久化:alpha-byok-keys - 继续完善 alpha-auth / alpha-models(.ts/.json)/ model-picker / sidecar / ext-config / alpha-config / 侧栏 - 设计 + 计划文档:docs/designs/2026-06-29-llm-auth-routing/(方案 A/B/C 评估)· docs/plans/{llm-auth-routing-impl, model-picker-proxy-byok-fix, shared-jwt-secret-sync} · docs/platform-endpoint-discovery-contract.md typecheck 通过(EXIT=0)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-29-llm-auth-routing/design.md | 125 ++++++++++++++ .../option-A-rejected.d2 | 40 +++++ .../option-A-rejected.svg | 114 +++++++++++++ .../option-B-variant.d2 | 41 +++++ .../option-B-variant.svg | 116 +++++++++++++ .../option-C-recommended.d2 | 43 +++++ .../option-C-recommended.svg | 116 +++++++++++++ .../plans/2026-06-29-llm-auth-routing-impl.md | 99 +++++++++++ ...-06-29-model-picker-proxy-byok-fix-plan.md | 100 +++++++++++ .../2026-06-29-shared-jwt-secret-sync-plan.md | 73 ++++++++ docs/platform-endpoint-discovery-contract.md | 40 +++++ packages/ui-mac/src/main/alpha-account.ts | 6 +- packages/ui-mac/src/main/alpha-auth.ts | 42 +++-- packages/ui-mac/src/main/alpha-byok-keys.ts | 158 ++++++++++++++++++ packages/ui-mac/src/main/alpha-endpoints.ts | 92 ++++++++++ packages/ui-mac/src/main/alpha-models.json | 4 +- packages/ui-mac/src/main/alpha-models.ts | 48 ++---- .../ui-mac/src/main/alpha-provider-status.ts | 43 +++++ packages/ui-mac/src/main/endpoints-ipc.ts | 10 ++ packages/ui-mac/src/main/ext-config.ts | 7 +- packages/ui-mac/src/main/index.ts | 42 ++++- packages/ui-mac/src/main/provider-ipc.ts | 9 +- packages/ui-mac/src/main/sidecar.ts | 27 +++ packages/ui-mac/src/preload/index.ts | 3 + packages/ui-mac/src/preload/types.ts | 10 +- .../renderer/alpha-ui/model-picker-add.tsx | 41 +++-- .../renderer/alpha-ui/model-picker-inject.tsx | 38 ++--- .../src/renderer/sidebar/alpha-sidebar.tsx | 14 +- .../src/renderer/use-alpha-endpoints.ts | 22 +++ packages/ui-mac/src/shared/alpha-config.ts | 14 +- .../ui-mac/src/shared/alpha-model-types.ts | 9 +- 31 files changed, 1444 insertions(+), 102 deletions(-) create mode 100644 docs/designs/2026-06-29-llm-auth-routing/design.md create mode 100644 docs/designs/2026-06-29-llm-auth-routing/option-A-rejected.d2 create mode 100644 docs/designs/2026-06-29-llm-auth-routing/option-A-rejected.svg create mode 100644 docs/designs/2026-06-29-llm-auth-routing/option-B-variant.d2 create mode 100644 docs/designs/2026-06-29-llm-auth-routing/option-B-variant.svg create mode 100644 docs/designs/2026-06-29-llm-auth-routing/option-C-recommended.d2 create mode 100644 docs/designs/2026-06-29-llm-auth-routing/option-C-recommended.svg create mode 100644 docs/plans/2026-06-29-llm-auth-routing-impl.md create mode 100644 docs/plans/2026-06-29-model-picker-proxy-byok-fix-plan.md create mode 100644 docs/plans/2026-06-29-shared-jwt-secret-sync-plan.md create mode 100644 docs/platform-endpoint-discovery-contract.md create mode 100644 packages/ui-mac/src/main/alpha-byok-keys.ts create mode 100644 packages/ui-mac/src/main/alpha-endpoints.ts create mode 100644 packages/ui-mac/src/main/alpha-provider-status.ts create mode 100644 packages/ui-mac/src/main/endpoints-ipc.ts create mode 100644 packages/ui-mac/src/renderer/use-alpha-endpoints.ts diff --git a/docs/designs/2026-06-29-llm-auth-routing/design.md b/docs/designs/2026-06-29-llm-auth-routing/design.md new file mode 100644 index 000000000000..4caad7160c3c --- /dev/null +++ b/docs/designs/2026-06-29-llm-auth-routing/design.md @@ -0,0 +1,125 @@ +# LLM 调用 / Auth / 路由架构设计(2026-06-29) + +> 状态:**待你审查**。审查通过前**不写任何代码**。 +> 决策来源:本会话上下文 + Explore 对 opencode provider/auth 接缝的实测(见下「接缝事实」)。 +> 配图(每方案一张,`docs/designs/2026-06-29-llm-auth-routing/`): +> - `option-C-recommended.svg` —— **推荐 · 已选** +> - `option-A-rejected.svg` —— 未选(对照) +> - `option-B-variant.svg` —— C 的"始终全开"变体 + +--- + +## 0. 你的决策(已锁定) + +1. **代理 = 默认一个 provider**(`provider.alpha`),其下挂多个 model id。 +2. **BYOK = 直连节点**(不走网关,直连厂商)。 +3. **支持哪些 BYOK provider + 哪些 model id → 全部在 alpha-code 的目录(`alpha-models.json`)里定义。** +4. **auth 全部由 alpha own**:代理用 alpha 自己的 JWT;BYOK 用 alpha 自管的用户 key。**opencode 自身的 auth(Zen 登录 + `auth.json`)一律不用。** + +5. **BYOK 节点 opt-in**:仅当用户为该 provider 填了 key 才注入对应节点(默认只代理)。 +6. **BYOK key 存储**:safeStorage 钥匙串加密(仿 `alpha-auth.ts`)。 +7. **目录外自定义节点:保留** —— 现有「添加节点」(填 baseURL + key + model id)继续可用,作高级逃生口;`alpha-models.json` 的精选厂商为默认。 +8. **BYOK 注入方式:自定义 provider(写法 a)** —— alpha-code 完整写出 baseURL/apiKey/models,opencode 照单全收、不查 models.dev。 +9. **可用性模型**:未登录 → 仅 BYOK 可用(代理不注入);登录且**有额度** → `provider.alpha` 可用(BYOK 若配了 key 同时在);**额度用尽** → 选择器禁用/隐藏代理模型、保留 BYOK 并提示充值或用自有 key。登录这步用**原地重启 sidecar**(kill + 重 fork,非整 app 重启)无感激活代理。 + +→ 对应 **方案 C**。 + +--- + +## 1. 接缝事实(Explore 实测,决定可行性) + +opencode 的"接缝" = `OPENCODE_CONFIG_CONTENT.provider[]` + `enabled_providers` + opencode auth store。alpha 全程只用前两者,零改 upstream。 + +- **取 key**:自定义 openai-compatible provider 的 key,**config `options.apiKey` 优先**,且**覆盖** `auth.json` 里的 key(`provider.ts:1669`)。→ alpha 用 config 注入 key,**完全绕过 opencode auth.json**。✅ 你的"不用 opencode auth"成立。 +- **`enabled_providers` = 硬白名单**(`provider.ts:1342`):设了就只剩这些 provider。 +- **自定义 model id 直接被接受**(无 models.dev 校验,`provider.ts:1389`)→ 网关的 `claude-opus-4.8` 等自定义 id 可用。 +- **自定义 header 支持**(`provider.ts:1670`,`options.headers` → SDK → 上游)。 +- **运行时换 key 不可靠**(model/SDK 缓存不失效)、**运行时加 provider 不可能**(config 仅启动时读)→ 「免重启」的干净做法是**原地重启 sidecar**(kill+重 fork),不是 opencode auth。 +- ⚠️ Explore 说「`{env:VAR}` 只对 baseURL 生效、对 key 不生效」——与现有代理能 authed(JWT 过了到 404)矛盾,几乎肯定是漏看了 config 层的 `{env}` 替换。**无论如何设计不依赖它**:alpha 在 Node 侧已有真值,直接 inline 已解析的 key 最稳。 + +--- + +## 2. 三方案对照 + +| 维度 | A 全统一网关 | B 双轨全开 | **C 推荐** | +|---|---|---|---| +| opencode 里的 provider 数 | 1(`alpha`) | 1 代理 + N 个 BYOK(全启用) | 1 代理(默认) + BYOK 按需注入 | +| BYOK 走哪 | **经网关**(vault 存 / 每请求透传) | **直连厂商** | **直连厂商** | +| BYOK 逃生出口 | **几乎没有**(网关挂=全挂) | 直连节点(始终在) | 直连节点(按需,目录定义) | +| 客户端复杂度 | 最低 | 偏高(双套 key/双类 provider 常驻) | 中(默认简单,用了 BYOK 才复杂) | +| 平台/网关复杂度 | **最高**(要收/转/存用户 key) | 低(只跑平台流量) | 低(只跑平台流量) | +| 成本(谁付推理) | alpha 全包(含 BYOK 也过网关) | 平台付平台、BYOK 用户自付 | 平台付平台、BYOK 用户自付 | +| 隐私(BYOK key/prompt) | **都过 alpha 网关** | BYOK 全程不碰 alpha | BYOK 全程不碰 alpha | +| auth | alpha own(+网关存/转 key) | alpha own | alpha own | +| opencode auth.json | 不用 | 不用 | 不用 | + +**为什么排除 A**:你明确要「BYOK 直连」。A 把 BYOK 也塞进网关 → 网关变唯一命门、要承担用户 key 的存储/转发(安全与运维负担全压平台),且**没有直连逃生出口**。与你的决策冲突。 + +**B vs C**:同样「BYOK 直连、目录在 alpha-code」,差别只在**默认是否把全部 BYOK provider 常驻启用**。B 常驻(干净度差、暴露未配 key 的节点);C 默认只代理、**BYOK 加了 key 才注入对应节点**(默认极简,BYOK 是显式逃生口)。→ 取 C。 + +--- + +## 3. 方案 C 详细(待你确认) + +### 3.1 目录:`alpha-models.json` = 唯一真源(已是此形,做一处精化) + +它同时定义**代理**与 **BYOK 直连**支持的 provider 与 model id: + +```jsonc +{ + "platformProvider": { "id": "alpha", "npm": "@ai-sdk/openai-compatible" }, + "platformModels": [ { "id": "claude-opus-4.8", ... }, ... ], // 代理:1 provider,多 model id + + "byokProviders": [ // BYOK:直连节点,全在这里定义 + { "id": "deepseek", "compat": "openai", "baseURL": "https://api.deepseek.com/v1", + "keyEnv": "DEEPSEEK_API_KEY", "models": ["deepseek-v4-flash", "deepseek-v4-pro"] }, + { "id": "zhipuai", "compat": "anthropic", "baseURL": "https://open.bigmodel.cn/api/anthropic", + "keyEnv": "ZHIPU_API_KEY", "models": ["glm-5.2", "glm-4.5-air"] } + // …新增 BYOK provider / model id = 只改这份 JSON,零代码 + ] +} +``` + +**精化点(关键)**:BYOK 直连节点改为**按目录注入「自定义 provider」**(用 `compat`→`@ai-sdk/openai-compatible`|`@ai-sdk/anthropic` + 目录里的 `baseURL`/`models`),**不再依赖 opencode 的 models.dev**。这样「支持哪些 provider/model id」**完全由 alpha-code 定义**(也能支持 models.dev 里没有的厂商),正是你要的。注入形如: + +```jsonc +"provider": { + "deepseek": { + "npm": "@ai-sdk/openai-compatible", + "options": { "baseURL": "https://api.deepseek.com/v1", "apiKey": "" }, + "models": { "deepseek-v4-flash": {"name":"…"}, "deepseek-v4-pro": {"name":"…"} } + } +} +``` + +### 3.2 auth(全 alpha,opencode auth.json 出局) + +- **代理**:登录拿 JWT → 作为 `provider.alpha.options.apiKey` 注入(网关校验 JWT)。 +- **BYOK**:用户在 alpha UI 填 key → 存进 **alpha 自管的密钥库**(建议复用 `alpha-auth.ts` 的 safeStorage 钥匙串加密,而非明文 alpha.env)→ 作为对应自定义 provider 的 `options.apiKey` 注入。 +- opencode 的 `auth.json` / Zen 登录:**完全不出现在 UI、不参与取 key**。 + +### 3.3 免重启:原地重启 sidecar + +登录 / 加 BYOK key / 启停节点 → **kill 当前 utilityProcess + 用新 env 重 fork**(不整 app 重启,规避 ADR-017 的 ad-hoc 签名退出),renderer 的 SDK client + SSE 重连同端口/密码。比现状的「整 app relaunch」体验好,也比 opencode auth 运行时换 key 可靠(后者有缓存坑)。 + +### 3.4 BYOK 逃生出口在哪(明确回答你) + += **alpha-code 目录里定义的 BYOK 直连节点本身**。它们直连厂商、**绕过网关**,所以:网关 404 / 挂了 / 没登录,BYOK 照常可用;key 与 prompt 全程不经过 alpha。这就是逃生口的物理位置。 + +--- + +## 4. 子决策(已锁定 · 2026-06-29) + +1. BYOK 默认形态 → **加 key 才注入节点**(opt-in,默认只代理) +2. BYOK key 存储 → **safeStorage 钥匙串加密**(仿 `alpha-auth.ts`) +3. 免重启 → **原地重启 sidecar**(kill + 重 fork + renderer 重连);可用性模型见 §0.9 +4. 目录外自定义节点 → **保留**(现有「添加节点」baseURL + key + model id 流程) +5. BYOK 注入方式 → **自定义 provider(写法 a)**(脱离 models.dev) + +--- + +## 5. 与现状的差距(实现时的工作量预估,仅供参考) + +- 已有:`alpha-models.json` 目录、`buildAlphaModelConfig` 注入、代理 provider、BYOK env-key、key 状态探测。 +- 要改:① BYOK 注入从 `{whitelist}` → 自定义 provider;② BYOK key 库(钥匙串) + UI 填 key;③ 原地重启 sidecar + renderer 重连;④ 默认只代理、BYOK opt-in 的启停。 +- 全部落 `packages/ui-mac/*`,**零改 opencode 源码**。 diff --git a/docs/designs/2026-06-29-llm-auth-routing/option-A-rejected.d2 b/docs/designs/2026-06-29-llm-auth-routing/option-A-rejected.d2 new file mode 100644 index 000000000000..11bd4529e3cf --- /dev/null +++ b/docs/designs/2026-06-29-llm-auth-routing/option-A-rejected.d2 @@ -0,0 +1,40 @@ +direction: right + +title: "方案 A(未选)— 全统一网关:BYOK 也走网关,opencode 只见 1 个 provider" { + near: top-center + shape: text + style.font-size: 24 + style.bold: true +} + +local: "本机 · alpha-code app" { + style.fill: "#f8fafc" + login: "alpha 登录 → JWT" { style.fill: "#eef2ff" } + byokkey: "用户 BYOK key" { style.fill: "#fef9c3" } + seam: "sidecar 注入 · config 接缝" { style.fill: "#e0e7ff" } + engine: "opencode 引擎 · provider 层" { style.fill: "#ffffff" } + alpha: "provider.alpha(唯一)· 平台+BYOK 模型混在一起" { style.fill: "#c7d2fe" } +} + +cloud: "alpha-platform 云" { + style.fill: "#f0fdf4" + gw: "alpha 网关 · 还要收/转用户 BYOK key(vault 或每请求透传)" { style.fill: "#bbf7d0" } +} + +up_platform: "上游 · 平台代付(alpha key)" { style.fill: "#fee2e2" } +up_byok: "上游 · BYOK 厂商(用户 key)" { style.fill: "#fde68a" } + +cost: "代价:网关成唯一命门(挂=全挂)· 用户 key 过/存在网关 · 复杂度全压平台 · 无直连逃生出口" { + near: bottom-center + shape: text + style.font-color: "#b91c1c" + style.bold: true +} + +local.login -> local.seam: "JWT" +local.byokkey -> local.seam: "BYOK key → header 透传 / 上传 vault" +local.seam -> local.engine: "只启用 alpha" +local.engine -> local.alpha +local.alpha -> cloud.gw: "所有模型" +cloud.gw -> up_platform: "alpha key" +cloud.gw -> up_byok: "用户 key(经网关)" diff --git a/docs/designs/2026-06-29-llm-auth-routing/option-A-rejected.svg b/docs/designs/2026-06-29-llm-auth-routing/option-A-rejected.svg new file mode 100644 index 000000000000..566257e156a9 --- /dev/null +++ b/docs/designs/2026-06-29-llm-auth-routing/option-A-rejected.svg @@ -0,0 +1,114 @@ +方案 A(未选)— 全统一网关:BYOK 也走网关,opencode 只见 1 个 provider本机 · alpha-code appalpha-platform 云上游 · 平台代付(alpha key)上游 · BYOK 厂商(用户 key)代价:网关成唯一命门(挂=全挂)· 用户 key 过/存在网关 · 复杂度全压平台 · 无直连逃生出口alpha 登录 → JWT用户 BYOK keysidecar 注入 · config 接缝opencode 引擎 · provider 层provider.alpha(唯一)· 平台+BYOK 模型混在一起alpha 网关 · 还要收/转用户 BYOK key(vault 或每请求透传) JWTBYOK key → header 透传 / 上传 vault只启用 alpha所有模型alpha key用户 key(经网关) + + + + + + + + diff --git a/docs/designs/2026-06-29-llm-auth-routing/option-B-variant.d2 b/docs/designs/2026-06-29-llm-auth-routing/option-B-variant.d2 new file mode 100644 index 000000000000..6efbf25e9ffc --- /dev/null +++ b/docs/designs/2026-06-29-llm-auth-routing/option-B-variant.d2 @@ -0,0 +1,41 @@ +direction: right + +title: "方案 B(变体)— 双轨并存:代理 provider + 全部 BYOK 直连 provider 始终启用" { + near: top-center + shape: text + style.font-size: 24 + style.bold: true +} + +catalog: "alpha-models.json(目录)\n定义代理 + BYOK 的 provider 与 model id" { + style.fill: "#fff7ed" + style.bold: true +} + +local: "本机 · alpha-code app" { + style.fill: "#f8fafc" + login: "alpha 登录 → JWT" { style.fill: "#eef2ff" } + byokkey: "BYOK key · alpha 本地存储" { style.fill: "#fef9c3" } + seam: "sidecar 注入 · config 接缝" { style.fill: "#e0e7ff" } + engine: "opencode 引擎 · provider 层" { style.fill: "#ffffff" } + alpha: "provider.alpha · 网关代理" { style.fill: "#c7d2fe" } + byok: "provider.deepseek/智谱/... · 原生直连(始终全部启用)" { style.fill: "#fde68a" } +} + +cloud: "alpha-platform 云" { + style.fill: "#f0fdf4" + gw: "alpha 网关 · 仅平台流量" { style.fill: "#bbf7d0" } +} + +up_platform: "上游 · 平台代付(alpha key)" { style.fill: "#fee2e2" } +up_byok: "上游 · BYOK 厂商(用户 key)" { style.fill: "#fde68a" } + +catalog -> local.seam: "驱动注入" +local.login -> local.seam: "JWT" +local.byokkey -> local.seam: "BYOK key" +local.seam -> local.engine: "启用 alpha + 全部 BYOK provider" +local.engine -> local.alpha: "平台模型" +local.engine -> local.byok: "BYOK 模型" +local.alpha -> cloud.gw +cloud.gw -> up_platform: "alpha key" +local.byok -> up_byok: "直连 · 用户 key" diff --git a/docs/designs/2026-06-29-llm-auth-routing/option-B-variant.svg b/docs/designs/2026-06-29-llm-auth-routing/option-B-variant.svg new file mode 100644 index 000000000000..63137f9018a3 --- /dev/null +++ b/docs/designs/2026-06-29-llm-auth-routing/option-B-variant.svg @@ -0,0 +1,116 @@ +方案 B(变体)— 双轨并存:代理 provider + 全部 BYOK 直连 provider 始终启用alpha-models.json(目录)定义代理 + BYOK 的 provider 与 model id本机 · alpha-code appalpha-platform 云上游 · 平台代付(alpha key)上游 · BYOK 厂商(用户 key)alpha 登录 → JWTBYOK key · alpha 本地存储sidecar 注入 · config 接缝opencode 引擎 · provider 层provider.alpha · 网关代理provider.deepseek/智谱/... · 原生直连(始终全部启用)alpha 网关 · 仅平台流量 驱动注入JWTBYOK key启用 alpha + 全部 BYOK provider平台模型BYOK 模型alpha key直连 · 用户 key + + + + + + + + + + diff --git a/docs/designs/2026-06-29-llm-auth-routing/option-C-recommended.d2 b/docs/designs/2026-06-29-llm-auth-routing/option-C-recommended.d2 new file mode 100644 index 000000000000..992e0f0980d0 --- /dev/null +++ b/docs/designs/2026-06-29-llm-auth-routing/option-C-recommended.d2 @@ -0,0 +1,43 @@ +direction: right + +title: "方案 C(推荐 · 已选)— 代理单 provider(多 model id) + BYOK 直连节点(目录在 alpha-code 定义)" { + near: top-center + shape: text + style.font-size: 24 + style.bold: true +} + +catalog: "alpha-models.json(单一目录)\n定义: 代理 model ids + BYOK 支持的 provider 与 model id" { + style.fill: "#fff7ed" + style.bold: true +} + +local: "本机 · alpha-code app" { + style.fill: "#f8fafc" + login: "alpha 登录 → JWT" { style.fill: "#eef2ff" } + byokkey: "BYOK key · alpha 本地存储(按需)" { style.fill: "#fef9c3" } + respawn: "登录/加 key → 原地重启 sidecar(免整 app 重启)" { style.fill: "#fae8ff" } + seam: "sidecar 注入 · config 接缝(零改 opencode)" { style.fill: "#e0e7ff" } + engine: "opencode 引擎 · provider 层" { style.fill: "#ffffff" } + alpha: "provider.alpha(默认唯一)· 多 model id" { style.fill: "#c7d2fe" } + byok: "BYOK 直连节点 · 自定义 provider · 按需注入(逃生出口)" { style.fill: "#fde68a"; style.stroke-dash: 4 } +} + +cloud: "alpha-platform 云" { + style.fill: "#f0fdf4" + gw: "alpha 网关 · JWT 校验 + 路由 + 计费" { style.fill: "#bbf7d0" } +} + +up_platform: "上游 · 平台代付(alpha 服务端 key)" { style.fill: "#fee2e2" } +up_byok: "上游 · BYOK 厂商(用户 key)" { style.fill: "#fde68a" } + +catalog -> local.seam: "驱动注入" +local.login -> local.respawn +local.byokkey -> local.respawn +local.respawn -> local.seam: "重读 env" +local.seam -> local.engine: "默认只 alpha;加 key 才注入对应 BYOK 节点" +local.engine -> local.alpha: "默认全部模型" +local.engine -> local.byok: "选了 BYOK 模型时" +local.alpha -> cloud.gw: "apiKey = JWT" +cloud.gw -> up_platform: "alpha key + 计费" +local.byok -> up_byok: "直连 · 用户 key(绕过网关,网关挂也能用)" diff --git a/docs/designs/2026-06-29-llm-auth-routing/option-C-recommended.svg b/docs/designs/2026-06-29-llm-auth-routing/option-C-recommended.svg new file mode 100644 index 000000000000..62b7b1a80723 --- /dev/null +++ b/docs/designs/2026-06-29-llm-auth-routing/option-C-recommended.svg @@ -0,0 +1,116 @@ +方案 C(推荐 · 已选)— 代理单 provider(多 model id) + BYOK 直连节点(目录在 alpha-code 定义)alpha-models.json(单一目录)定义: 代理 model ids + BYOK 支持的 provider 与 model id本机 · alpha-code appalpha-platform 云上游 · 平台代付(alpha 服务端 key)上游 · BYOK 厂商(用户 key)alpha 登录 → JWTBYOK key · alpha 本地存储(按需)登录/加 key → 原地重启 sidecar(免整 app 重启)sidecar 注入 · config 接缝(零改 opencode)opencode 引擎 · provider 层provider.alpha(默认唯一)· 多 model idBYOK 直连节点 · 自定义 provider · 按需注入(逃生出口)alpha 网关 · JWT 校验 + 路由 + 计费 驱动注入重读 env默认只 alpha;加 key 才注入对应 BYOK 节点默认全部模型选了 BYOK 模型时apiKey = JWTalpha key + 计费直连 · 用户 key(绕过网关,网关挂也能用) + + + + + + + + + + diff --git a/docs/plans/2026-06-29-llm-auth-routing-impl.md b/docs/plans/2026-06-29-llm-auth-routing-impl.md new file mode 100644 index 000000000000..7486e7a58156 --- /dev/null +++ b/docs/plans/2026-06-29-llm-auth-routing-impl.md @@ -0,0 +1,99 @@ +# 完整实施方案 — 方案 C(LLM 路由 / auth)2026-06-29 + +> 设计来源:`docs/designs/2026-06-29-llm-auth-routing/design.md`(决策已锁,§0)。 +> 现状已**逐条核实**(读源码 + git diff,非臆测)。 +> 纪律:**零改 opencode 源码;一次只落一个 Phase,真机(打包版)验证通过再下一个。** +> P0「打开本仓库崩溃(生-TS 工具)」是**独立项**,不在本方案内 —— 但工作区现有一处 codex 未验证改动要先处置(见 §0)。 +> +> **进度(2026-06-29)**:Phase 0 ✅(revert codex 的 `sidecar.ts`;`deepseek-v4-flash/pro` 经 DeepSeek API 实证为**真实 id**,保留)· Phase 1 ✅(BYOK 钥匙串 `alpha-byok-keys.ts` + opt-in 自定义 provider 注入 + auth.json 出 call-path + setKey/removeKey IPC/UI;typecheck+build 通过;**已 ship 到 /Applications,待真机验证**)· 下一步:Phase 4(原地重生 sidecar,去掉重启摩擦)/ Phase 5(自定义节点 key 入钥匙串)。 + +--- + +## 0. 先决:清理工作区到「已知良好」基线(动方案 C 之前) + +工作区现在混着**两批**未提交改动,必须先各自定性,否则又会"批量改→连环回归"。 + +### 0a. codex 的 `sidecar.ts` 改动(P0,未验证)— **建议 revert** +- 内容:`+24` 行,top-level 调 `registerProjectTypeScriptResolver()`(`node:module` `registerHooks` 的 `.js`→`.ts` resolve hook)。 +- 风险:**在 sidecar 启动期注册**,若 `registerHooks` 在本 Electron(Node 22.x)`utilityProcess` 里不可用/行为异常 → **每次启动崩 sidecar**(疑似"启动不了了"诱因)。codex 被中止,**从未实测**。 +- 它属 **P0**,与方案 C 无关;且你已明确「codex 只分析、不改代码」。 +- **建议:`git checkout -- packages/ui-mac/src/main/sidecar.ts` 还原**。P0 等方案 C 落定后,由我**亲手 + 真机实测**单独做(逻辑可借鉴它,但要验证 `registerHooks` 可用性)。 + +### 0b. 第二轮模型/端点改动(部分对齐方案 C)— **分类保留/删/验** +| 改动 | 处置 | +|---|---| +| `alpha-endpoints.ts` / `endpoints-ipc.ts` / `use-alpha-endpoints.ts`(端点发现:env>pin>discovery>default) | **保留**(基建,正交、有用) | +| `alpha-account.ts` 端点化、picker 额度门控(out/empty/balance/member) | **保留**(方案 C 的"可用性模型"已基本在此) | +| `ext-config.ts` `readAuthStoreKeys()` + `model-picker` 的 "auth" 来源显示 | **删** —— 违背决策#4「opencode auth 出局」 | +| `alpha-models.json` 改的 model id(`deepseek-v4-flash/pro`、`glm-5.2`) | **验证** —— 必须是厂商真实 API id,否则 BYOK 直连 400/404(疑似旧"P2") | + +> 产物:一个 typecheck+build 通过、真机冒烟过的基线(代理能连/未登录能用 BYOK),再开 Phase 1。 + +--- + +## 1. 现状核实(已读源码) + +**已有、可复用:** +- `buildAlphaModelConfig()`(`alpha-models.ts:65`):代理=自定义 provider(`ALPHA_BASE_URL` 在时注入);BYOK=builtin `{whitelist}`;user custom 合并进 allowlist。 +- picker 三组(代理/BYOK/自定义)+ **额度门控**(`model-picker-inject.tsx:64` `state()`=out/empty/balance/member、`accountLocked`、`proxyConnected`)。 +- account/quota 拉取(`alpha-account.ts` → `/v1/account/summary`,JWT,境内)。 +- 自定义节点(`persistProvider`→`opencode.jsonc`,`ext-config.ts:169`)、连接测试(`provider-test.ts`)、keyStatus(env/config/auth)。 +- IPC:`window.api.{models.catalog, providers.{add,test,keyStatus,remove}, account.{summary,transactions}, auth.*, endpoints}`。 + +**与方案 C 有差距,要改:** +- BYOK 注入 = `{whitelist}`(无条件、依赖 models.dev)→ 需 **opt-in 的自定义 provider(写法 a)**。 +- key 来源含 opencode `auth.json` → 需 **alpha 钥匙串**,删 auth.json。 +- 无 in-place respawn(`index.ts:96 killSidecar` + `:178 relaunch` 走**整 app 重启**)→ 需 **原地重生 sidecar + renderer 重连**。 + +**关键架构约束(决定实现形态):** `safeStorage` 仅主进程可用;config 由 **sidecar(utilityProcess)** 组装。 +→ BYOK key 钥匙串存**主进程**,(重)fork 前**解密注入 env**(沿用现有 `keyEnv` 通道),sidecar 端 `buildAlphaModelConfig` 仍只读 env。改 key ⇒ 需重 fork(正是 Phase 4 的价值)。key 一律 **inline 已解析值**(不用 `{env:VAR}` 模板,避开 Explore 存疑点)。 + +--- + +## 2. 分阶段实施(每阶段:文件 → 改动 → 真机验收) + +### Phase 1 — BYOK 钥匙串库(主进程)+ 删 auth.json 依赖 +- 新增 `main/alpha-byok-keys.ts`:`safeStorage` 加密存 `{ [providerId]: key }`(仿 `alpha-auth.ts` 的落盘/0600/钥匙串);`get/set/remove/listConfigured`。 +- `main/index.ts`:(重)fork 前,把 keychain 里每个 key 解密 → 写入对应 `keyEnv`(catalog 的 `byokProviders[].keyEnv`)到 sidecar env(类似 `applyAuthEnv`)。 +- `ext-config.ts`:**删** `readAuthStoreKeys`/`removeAuthStoreKey`;`getProviderKeyStatus`(`alpha-models.ts:40`)来源改为 **keychain(+env 兜底)**,去掉 "auth"。 +- `alpha-model-types.ts`:`source` 去掉 `"auth"`。 +- IPC:`providers.setKey(id,key)` / `providers.removeKey(id)`(写 keychain)。 +- **验收**:填 key→重启→`keyStatus` 显示 configured(来源 keychain);无 opencode auth 参与。 + +### Phase 2 — BYOK 注入改写:opt-in 自定义 provider(写法 a) +- `alpha-models.ts` `buildAlphaModelConfig`:BYOK 段从 `{whitelist}` 改为:**仅当该 provider 在 env 有 key** 时,注入完整自定义 provider: + `provider[id] = { npm: compat→@ai-sdk/openai-compatible|@ai-sdk/anthropic, name, options:{ baseURL, apiKey:<解析值> }, models:{...ids} }` + 入 allowlist。 +- `alpha-models.json`:`byokProviders[]` 即唯一目录(已有 baseURL/compat/models);新增厂商/模型只改这份 JSON。 +- **验收**:配了 key 的 BYOK 节点出现且可发消息(直连厂商);没配 key 的不出现(opt-in);未登录也能用 BYOK。 + +### Phase 3 — 可用性模型收口(对齐 design §0.9) +- 复核 `model-picker-inject.tsx` 的 `state()`:未登录→仅 BYOK;`empty`(额度 0)→锁代理、留 BYOK;`balance/member`→代理可用。基本已就位,按 §0.9 校准文案/按钮。 +- 代理 provider 仍仅登录(`ALPHA_BASE_URL`)时注入;额度 0 时 UI 锁(已有)。 +- **验收**:四态(未登录 / 有额度 / 额度尽 / 已订阅)行为符合 §0.9。 + +### Phase 4 — 原地重生 sidecar + renderer 重连(替换整 app relaunch) +- `server.ts`/`index.ts`:加 `restartSidecar()` = `killSidecar()` → 重 `spawnLocalServer`(同 port/password),**不** `app.relaunch()`。 +- renderer:sidecar ready 后,重建 opencode SDK client + 重连 `/global/event` SSE(复用 `awaitInitialization()` 的 url/password)。 +- `alpha-auth.ts`:`enableProxy`/`setAuthMode` 由 `relaunchApp()` 改调 `restartSidecar()`;login/加 key 同走。 +- **验收**:登录后**不整 app 重启**即出现代理模型;加 BYOK key 后**不整 app 重启**即出现该节点。 +> 风险最高一阶,单独做、单独验;若 renderer 重连不稳,**回退到现状的整 app relaunch**(功能不丢,仅体验差)。 + +### Phase 5 — 自定义节点(目录外)对齐 +- `persistProvider`/AddProvider:key 写 **keychain**(不再 inline 进 `opencode.jsonc` 的 `options.apiKey`);provider 元数据(baseURL/compat/models)仍可落 `opencode.jsonc` 或 alpha 自有存储,注入时按写法 a + keychain key。 +- **验收**:加一个目录外自定义厂商(baseURL+key+model id)→ 可用、key 在钥匙串、重启后仍在。 + +--- + +## 3. 文件清单(全部 `packages/ui-mac/*`,零改 opencode) +- 新增:`main/alpha-byok-keys.ts`。 +- 改:`main/alpha-models.ts`、`main/ext-config.ts`、`main/index.ts`、`main/server.ts`、`main/provider-ipc.ts`、`preload/{index,types}.ts`、`shared/alpha-model-types.ts`、`renderer/alpha-ui/model-picker-{inject,add}.tsx`、`alpha-auth.ts`(respawn 接线)。 +- 数据:`main/alpha-models.json`(BYOK 目录 = 真源)。 + +## 4. 风险与回退 +- Phase 4 respawn 最险 → 失败回退整 app relaunch。 +- v4 model id 未证 → Phase 0b 先验。 +- keychain 在 utilityProcess 不可用 → 已用「主进程解密→env」规避。 +- 每 Phase 独立 commit + 真机验,任一回归立即单点回退。 + +## 5. 验收总则(对齐 design §0) +代理=单 provider 多 model id;BYOK=opt-in 直连节点(目录在 alpha-code);key 走钥匙串;opencode auth 全程不参与;未登录用 BYOK、登录+额度用代理、额度尽锁代理留 BYOK;**零改 opencode 源码**。 diff --git a/docs/plans/2026-06-29-model-picker-proxy-byok-fix-plan.md b/docs/plans/2026-06-29-model-picker-proxy-byok-fix-plan.md new file mode 100644 index 000000000000..00127ef21027 --- /dev/null +++ b/docs/plans/2026-06-29-model-picker-proxy-byok-fix-plan.md @@ -0,0 +1,100 @@ +# 修复计划:代理 / BYOK "都不通" + 重启友好性 + 重复 effort(2026-06-29) + +> 由 4 路独立 codex:rescue 只读诊断 + 本会话上下文综合。**新 session 按此执行**。 +> 纪律:**一次只修一项,在真机(打包版 /Applications/alpha-code.app)验证后再下一项,不要批量**。本会话之前正是批量改导致连环回归。 + +--- + +## 核心洞察:"代理和 BYOK 都不通" 是**同一个** bug(P0 崩溃),不是两件事 + +opencode 在 `prompt_async` 里就崩了(发任何模型前),所以**每条 prompt 都无输出** —— 代理、BYOK 一起死。先修 P0,两者大概率一起复活。 + +--- + +## P0 —— 致命:每条 prompt 崩溃(ADR-006 运行时世界)【最高优先】 + +**证据**(codex 实测,日志在 `~/.local/share/opencode/log/opencode.log`,**不在**桌面 `server.log` —— 这就是之前 grep server.log 空的原因): +``` +prompt_async failed … Cannot find module +'/Users/tide/app/alpha-code/packages/plugin/src/tool.js' +imported from /Users/tide/app/alpha-code/packages/plugin/src/index.ts +``` + +**根因链**:opencode `ToolRegistry`(`packages/opencode/src/tool/registry.ts:172`)运行时动态 import 项目目录的 `.opencode/tool/*.ts` → 它们 import `@opencode-ai/plugin` → 该包 `package.json` exports 指向**生 TS**(`src/index.ts`)→ `src/index.ts:15` `import './tool.js'` 但磁盘只有 `tool.ts` → Electron-Node 不做 `.js`→`.ts` 重写 → 崩 → `SessionPrompt.run` 抛错 → 无输出。这正是 **ADR-006** 警告的"两个运行时世界"。 + +**关键判断(新 session 先确认)**:崩溃路径是**源码仓库路径**,说明 opencode 在加载**某个打开项目目录**里的生 TS 工具。CLAUDE.md/ADR-006 明确写过「**别把 fork/opencode 仓库本身当工作项目打开**(带生 TS 工具会 crash)」。**极可能是:用户把 `/Users/tide/app/alpha-code` 仓库当项目打开了** → 触发崩溃 → 全不通。也可能这条日志是早先被杀的 `bun run dev` 实例残留 —— **务必先复现确认**。 + +**第 1 步(验证 + 可能即解)**: +1. 看当前打开的是哪个项目;若是 alpha-code 仓库(或任何含生 TS `.opencode/tool/*.ts` 的项目)→ **换一个干净的普通项目**(无 `.opencode/tool/*.ts`)→ 重发消息 → 崩溃应消失,BYOK 应通(代理见 P1)。 +2. 确认 `~/.local/share/opencode/log/opencode.log` 末尾的崩溃是**当前打包版**产生的(对时间戳),而非 dev 残留。 + +**真修(若需在任意项目都稳,或要保留自有 `.opencode/tool`)**:按 ADR-006,自有/上游生 TS 工具必须**预编译成自包含 JS**: +- 给 `packages/plugin` 加 build(`src/index.ts`+`src/tool.ts` → JS)并改 exports 指向产物;或 +- 自有 `.opencode/tool/*` 一律走预 bundle 的 `@alpha-code/ext`,不在运行时解析生 TS。 + +**验收**:干净项目里,BYOK 模型 + 代理模型各发一条 → 都有返回。 + +--- + +## P1 —— 代理二级问题:网关对该 model id 返回 `Not Found`(P0 之后) + +**证据**:更早的日志显示模型**确实到达** opencode(`providerID=alpha modelID=deepseek-v4-flash`),但网关回 `AI_APICallError: Not Found`(是 **404 不是 401**,说明 **JWT 鉴权过了**,是路由/模型映射问题)。 + +**根因**:**已部署**的网关 worker(`alpha-gateway.jinjunnm.workers.dev`)没有路由这个 model id。**属 alpha-platform 侧**:对比 `/Users/tide/app/alpha-platform/packages/gateway/src/worker.ts` 的模型路由表 vs alpha-code `alpha-models.json` 的 `platformModels` ids。多半是**部署的 worker 落后于源码 `registry.ts`** → 需 `wrangler deploy`,或 id 映射缺失。 + +**验收**:`curl -H "authorization: Bearer <有效JWT>" https://alpha-gateway.jinjunnm.workers.dev/v1/chat/completions -d '{"model":"<每个代理id>",...}'` → 期望 200(非 404)。 + +**JWT(低优先,待证)**:网关 `worker.ts:39` 期望 `issuer:"alpha-web"`、`audience:"alpha-platform"`、有 `sub`、用 `JWT_SECRET` 签。token 在 `alpha-auth.json` 加密读不到 → 若 P1 修完仍 401 再核对 claims。 + +--- + +## P2 —— BYOK DeepSeek(P0 之后) + +主因 = P0 崩溃。P0 修好后**重测** BYOK。若仍不行: +- whitelist 从 `deepseek-chat/reasoner` 改成了 `deepseek-v4-flash/pro`(`alpha-models.json`)。若用户**当前选中的模型**是已被移除的 `deepseek-chat` → opencode 丢弃它 → 选中模型失效 → 需**重选** `deepseek-v4-flash`。 +- 考虑过渡期 whitelist **同时保留新旧 id**,避免老选择失效;或在 UI 上对"选中模型已下线"做兜底。 +- 确认 `deepseek-v4-flash/pro` 对用户的 DeepSeek key 可用(直连 api.deepseek.com)。 +> (P3 的 codex 仍在跑;若产出新细节,在此补。) + +--- + +## P3 —— 登录后还要点"启用代理·重启"(体验) + +**根因**:代理 provider 配置(`provider.alpha` + key)在 **sidecar fork 时**读;登录发生在 fork 之后 → 运行中的 sidecar 没有它 → 需重启。`enableProxy()` 走整 app relaunch(ad-hoc 签名本地包会**直接退出**,见 ADR-017)。 + +**方案(择优,新 session 实现)**: +- **A. 原地重生 sidecar**:kill+重 fork utilityProcess 让它重读 env。难点:renderer 的 opencode SDK client + SSE 要干净重连(同端口/密码)。 +- **B(推荐,若验证通过). 启动即恒定注册 `provider.alpha`**(baseURL 静态、由 endpoint 解析器给)+ **登录时把 JWT 作为该 provider 的 key 经 opencode 原生 auth 运行时写入**(`PUT /auth/alpha`)→ **无需 fork/重启**。 + - **必须先验证**:opencode 是否对**自定义 `@ai-sdk/openai-compatible`(自带 baseURL)provider** 使用 auth.set 的 api key?`provider.ts` 里多处用 `auth?.type==="api"?auth.key`(如 gitlab/cloudflare 行 601/734/781),但**通用 openai-compatible 路径**需逐行确认。确认 yes → 走 B。 +> (P1 的 restart codex 仍在跑;A/B 结论以其为准补全。) + +--- + +## P4 —— 重复 effort 控件(composer)【codex 已给确定结论,低风险】 + +**根因**:alpha 的「⚡高」`EffortChip`(`composer-inject.tsx` 注入)加进来时,**从没给原生 effort 控件加隐藏 CSS**。原生控件 = `data-component="prompt-variant-control"` / `data-action="prompt-model-variant"`(就是「默认 / 切换思考强度 ⇧⌘D」那个,源在 `packages/app/src/components/prompt-input.tsx`)。**不是回归**,是一直漏了。 + +**修复**:在 `packages/ui-mac/src/renderer/alpha-ui/composer-reskin.css` 加(仿现有 attach 按钮隐藏规则): +```css +[data-component="session-composer"] [data-component="prompt-variant-control"], +[data-component="session-new-composer"] [data-component="prompt-variant-control"] { + display: none !important; +} +``` + +--- + +## 工作区状态(新 session 必读) + +- **已提交**:`3139c42`(第一轮:代理模式激活修复 + BYOK P1-P3)。 +- **未提交(本会话第二轮,在 `alpha` 工作区)**:BYOK 读 auth.json 检测 + 隐藏未配置 + 删"管理"按钮、v4 模型 id、网关 URL 修正(`api.tidelabs.click`→`alpha-gateway.jinjunnm.workers.dev`)、**端点架构重构**(`alpha-endpoints.ts` 解析器 + IPC + 登录发现 + renderer 去 baked)。typecheck+build 过,但**真机未验证通**。 + - ⚠️ v4 id 变更可能正是 P2(老选择失效)诱因 —— 决定保留/改/回退时一并考虑。 + - 还有一份给 alpha-platform 的 ① 发现契约 `docs/platform-endpoint-discovery-contract.md`。 + +## 执行顺序(建议) +1. **P0**(崩溃)—— 先确认是不是"打开了仓库当项目";干净项目验证 BYOK+代理是否复活。 +2. **P4**(一条 CSS,最稳)。 +3. **P1**(网关 Not Found,alpha-platform 侧 redeploy/路由)。 +4. **P3**(重启友好性,走 B 若验证通过)。 +5. **P2** 兜底(whitelist 新旧并存 / 选中失效兜底)。 +- 每步真机验证后再下一步。 diff --git a/docs/plans/2026-06-29-shared-jwt-secret-sync-plan.md b/docs/plans/2026-06-29-shared-jwt-secret-sync-plan.md new file mode 100644 index 000000000000..27885c5333a4 --- /dev/null +++ b/docs/plans/2026-06-29-shared-jwt-secret-sync-plan.md @@ -0,0 +1,73 @@ +# 平台共享密钥单源同步计划 — JWT_SECRET 防漂移(2026-06-29) + +> 触发:代理 401 = **部署的 alpha-web 签名密钥 ≠ 部署的网关验签密钥**(本会话实测铁证:app 发的 JWT iss/aud/exp 全对,直接 curl 部署网关仍 401,任何本地 secret 都验不出该签名)。 +> 目标:**一处管理 + 双端必然一致 + 结构性防漂移**(确保不再发生)。 +> 范围:**alpha-web(签)↔ alpha-platform 网关(验)**。本计划落在那两个仓;alpha-code 只做可选的"诚实报错"(§4)。 + +## 0. 拓扑澄清(纠正"同步给 alpha-code") +| 组件 | 角色 | 持 JWT_SECRET? | +|---|---|---| +| alpha-web | 登录,用密钥**签** access_token(HS256) | ✅ 签名方 | +| alpha-platform 网关 | `jwtVerify(token, JWT_SECRET, {iss:alpha-web, aud:alpha-platform})` | ✅ 验签方 | +| **alpha-code(本 app)** | 登录拿 JWT → 作为 Bearer **转发**给网关 | ❌ 从不持有 | + +→ 需"双端一致"的只有 **web↔网关**。alpha-code 要的是**端点正确**(网关 URL),已由登录 discovery 解决(`alpha-endpoints.ts`),与密钥无关。 + +## 1. 即时解封(今天,先通) +挑一个强密钥,两端设成同一个,然后重登验证: +``` +# 网关(Cloudflare Worker) +cd /Users/tide/app/alpha-platform/packages/gateway +npx wrangler secret put JWT_SECRET # 粘贴该密钥 +npx wrangler deploy +# alpha-web 部署环境(Vercel/CF)的 JWT_SECRET 设成同一个 → 重新部署 +``` + +## 2. 治本(择一,§3 验证两者都加) + +### 方案 A(推荐 · 结构性免漂移):JWKS / 非对称签名 +- **alpha-web**:改用**私钥**签(RS256/ES256),暴露 `https://auth.tidelabs.click/.well-known/jwks.json`(公钥集)。 +- **网关**:用 `jose` 的 `createRemoteJWKSet(new URL(JWKS_URL))` 验签(自动拉取 + 缓存公钥)。 +- **效果**:**两端不再共享任何密钥** → 漂移在结构上不可能;密钥轮换只在 alpha-web 一处做,网关下次拉 JWKS 自动跟随。这是 OIDC/OAuth 的标准做法,也正是你要的"一处管理"。 +- **成本**:web 改签名 + 加 JWKS 端点;网关把 `jwtVerify(...TextEncoder(JWT_SECRET))` 换成 `jwtVerify(...remoteJWKS)`。一次性、各一处。 + +### 方案 B(最小改动):单源真值 + 同步脚本(保持 HS256) +- 建**唯一真源** SSoT,如 `alpha-platform/secrets.env`(gitignore),含 `JWT_SECRET`(+ `DEV_PLATFORM_TOKEN`/`RECONCILE_SECRET`/provider keys)。 +- `scripts/sync-secrets.mjs`:读 SSoT → `wrangler secret put` 推到网关各 Worker + 写 alpha-web 部署环境(`vercel env`/CF)同值。 +- 挂进 **predeploy / CI**:改密钥 = 只改 SSoT 跑同步;部署前自动同步。 +- **代价**:靠流程保证一致(人/CI 不跑同步仍会漂);比 A 弱,但改动小、保留对称密钥。 + +## 3. 防回归验证(两方案都加) +`verify-jwt-chain` 脚本:alpha-web 签一个测试 JWT → 调网关 `/v1/chat/completions`(或一个 `/v1/whoami`)→ **断言非-401**。挂进"部署后冒烟" + 可手动跑。本质 = 今天那条 curl 的脚本化,作为 CI gate。 +> 还可加一条更早的 tripwire:部署时比对 web 与网关的密钥/公钥指纹(A:JWKS kid;B:secret 的 sha256 前 8 位),不一致就 fail 部署。 + +## 4. alpha-code 侧(本仓,可选小改) +- 不持密钥、无需同步逻辑。 +- **可选**:把代理 401 在 model picker/会话里**诚实提示**「平台鉴权未通(JWT 验签失败)」而非静默失败 —— 下次此类问题能一眼定位是平台密钥,而不是耗一天排查。属 alpha-code 自有 UI 改动,零改 upstream。 + +## 推荐路径 +§1 即时解封(今天)→ §2 **方案 A(JWKS)根除** → §3 验证 gate。 +若暂时不想动 alpha-web 的签名算法 → 先 §2 方案 B(SSoT+同步脚本)兜底,日后再升 A。 + +--- + +## 实现状态(2026-06-29 · 方案 A 已落代码,待部署) + +**已改 + 验证(typecheck 双绿 + ES256 sign↔verify round-trip 实测通过):** +- **alpha-web(签名方,ES256)** + - `lib/jwt.ts`:HS256+共享密钥 → ES256 私钥签(`JWT_PRIVATE_KEY` PKCS8)+ `verifySession` 用公钥验 + 导出 `publicJwk()`。 + - `app/api/jwks/route.ts`(新):公开 `{ keys:[publicJwk] }`(无私钥)。 + - `scripts/gen-jwt-key.mjs`(新):一键生成 ES256 keypair → 打印 `JWT_KID` / `JWT_PUBLIC_JWK` / `JWT_PRIVATE_KEY`。 +- **alpha-platform 网关(验签方,JWKS)** + - `src/worker.ts` + `src/account.ts`:`jwtVerify(tok, JWT_SECRET)` → `jwtVerify(tok, createRemoteJWKSet(JWKS_URL))`;`Env.JWT_SECRET`→`JWKS_URL`;dev token bypass 保留;`src/` 已无 `JWT_SECRET` 引用。 + - `wrangler.jsonc` / `.dev.vars` / `.env`:加 `JWKS_URL=https://auth.tidelabs.click/api/jwks`。 + - 遗留:`scripts/sign-test-jwt.mjs` 仍 HS256(手动测试用,已被真登录流 + JWKS 取代,可改 ES256 或删);单测走 dev token,不受影响。 + +## 部署 runbook(你执行 —— 需 Vercel + CF 账号;部署完代理 401 即消) +1. **生成密钥**(私钥只留你机器,别贴聊天):`cd alpha-web && node scripts/gen-jwt-key.mjs` +2. **Vercel 设 alpha-web env(Production)**:`JWT_KID` / `JWT_PUBLIC_JWK` / `JWT_PRIVATE_KEY`(三个都设)。 +3. **部署 alpha-web** → 验证 JWKS:`curl https://auth.tidelabs.click/api/jwks` 应返回 `{"keys":[{"kty":"EC","crv":"P-256",...,"kid":"alpha-web-..."}]}`。 +4. **部署网关**:`cd alpha-platform/packages/gateway && npx wrangler deploy`(`JWKS_URL` 已在 `wrangler.jsonc`)。可选彻底无共享密钥:`npx wrangler secret delete JWT_SECRET`。 +5. **重登 app** → 选代理模型 → 应通(网关经 JWKS 验签;不再有可漂移的共享密钥)。 +> 顺序:alpha-web 先(JWKS 上线 + 开始签 ES256)→ 网关后。当前代理本就 401(无可破坏的好状态),两边部署完重登即好。 +> 轮换:以后换密钥只在 alpha-web 重跑 §1 + 重部署;网关经 JWKS 自动跟随,**永不需要再同步**。 diff --git a/docs/platform-endpoint-discovery-contract.md b/docs/platform-endpoint-discovery-contract.md new file mode 100644 index 000000000000..7c27c9796226 --- /dev/null +++ b/docs/platform-endpoint-discovery-contract.md @@ -0,0 +1,40 @@ +# Endpoint discovery contract (① — alpha-platform side) + +alpha-code resolves its backend endpoints (gateway / account / web / mcp) in this precedence: + +``` +env override > userData pin file > login discovery (①) > hardcoded default +ALPHA_*_URL / /auth/token response src/shared/alpha-config.ts + alpha-endpoints.json { endpoints: {...} } +``` + +The consumer is implemented (`packages/ui-mac/src/main/alpha-endpoints.ts`). Today the **login discovery layer is dormant** — until alpha-web adds the field below, alpha-code falls back to the hardcoded default (currently `https://alpha-gateway.jinjunnm.workers.dev`). + +## What alpha-web (`POST /auth/token`) should add + +Include an optional `endpoints` object in the token-exchange JSON response: + +```jsonc +{ + "access_token": "…", + "refresh_token": "…", + "expires_in": 3600, + "plan": "pro", + "endpoints": { // NEW — all optional, absolute https URLs + "platform": "https://alpha-gateway.jinjunnm.workers.dev", // model proxy (/v1) + "account": "https://account.tidelabs.click", // account-server + "mcp": "https:///…", // cloud-dispatch MCP (separate worker) + "web": "https://auth.tidelabs.click" // optional; identity/links + } +} +``` + +## Why +- The gateway has **no custom domain** — it's the raw `*.workers.dev` URL, which can change (account/subdomain/migration). Hardcoding it in the client shipped the wrong host once already (`api.tidelabs.click` → 404). +- With discovery, **moving the gateway = update the token response**; every client follows on next login, **no app release**. +- `mcp` is the real fix for cloud-dispatch: the MCP tool gateway is a *different* worker than the model gateway (`alpha-gateway.../mcp` → 404). Until `endpoints.mcp` is sent, alpha-code derives `${platform}/mcp` (which 404s) — harmless (cloud dispatch just won't connect), but discovery should provide the correct MCP URL. + +## alpha-code behavior once you add it +- `endpoints` is persisted (`/alpha-discovered-endpoints.json`, 0600) so it survives restart (the proxy env is read at sidecar fork). +- An explicit `ALPHA_*_URL` env or a `/alpha-endpoints.json` pin still overrides discovery (dev/self-host escape hatch). +- Sending only a subset is fine; unspecified keys keep their resolved value. diff --git a/packages/ui-mac/src/main/alpha-account.ts b/packages/ui-mac/src/main/alpha-account.ts index 07b12b08e1b0..521bc92f438f 100644 --- a/packages/ui-mac/src/main/alpha-account.ts +++ b/packages/ui-mac/src/main/alpha-account.ts @@ -10,12 +10,14 @@ // gateway. Overridable via ALPHA_ACCOUNT_URL for dev/staging (consistent with ALPHA_WEB_URL / // ALPHA_PLATFORM_URL; see shared/alpha-config.ts). -import { ALPHA_ENDPOINTS, ALPHA_PATHS } from "../shared/alpha-config" +import { ALPHA_PATHS } from "../shared/alpha-config" +import { resolveEndpoints } from "./alpha-endpoints" import { getAccessToken } from "./alpha-auth" import { getLogger } from "./logging" import type { AccountResult, AccountSummary, AccountTransaction } from "../preload/types" -const accountBase = () => (process.env.ALPHA_ACCOUNT_URL ?? ALPHA_ENDPOINTS.account).replace(/\/+$/, "") +// Resolved by alpha-endpoints (env ALPHA_ACCOUNT_URL > userData pin > login discovery > default). +const accountBase = () => resolveEndpoints().account async function authedGet(path: string): Promise> { const token = getAccessToken() diff --git a/packages/ui-mac/src/main/alpha-auth.ts b/packages/ui-mac/src/main/alpha-auth.ts index ce17d4cf3eeb..71bbe77cd5ad 100644 --- a/packages/ui-mac/src/main/alpha-auth.ts +++ b/packages/ui-mac/src/main/alpha-auth.ts @@ -19,7 +19,8 @@ import { join } from "node:path" import { safeStorage, shell, type BrowserWindow } from "electron" import type { AuthMode, AuthState } from "../preload/types" import { getLogger } from "./logging" -import { ALPHA_ENDPOINTS, ALPHA_PATHS } from "../shared/alpha-config" +import { ALPHA_PATHS } from "../shared/alpha-config" +import { resolveEndpoints, setDiscoveredEndpoints } from "./alpha-endpoints" type StoredAuth = { mode: AuthMode @@ -37,22 +38,24 @@ type TokenResponse = { expires_in?: number email?: string plan?: string + /** ① optional endpoint discovery — alpha-web may tell the app where the gateway/account live, so a + * moved backend updates clients without a release (see alpha-endpoints.ts). Producer side optional. */ + endpoints?: { web?: string; platform?: string; account?: string; mcp?: string } } const CLIENT_ID = "alpha-code" const REDIRECT_URI = "alpha-code://auth/callback" const AUTH_FILE = "alpha-auth.json" -// Endpoint defaults live in shared/alpha-config (single source of truth — change a domain THERE, -// not here). This layer only adds the env overrides for dev/staging: ALPHA_WEB_URL = alpha-web -// (C, identity authority — login/token); ALPHA_PLATFORM_URL = alpha-platform (B, model proxy /v1 + -// MCP gateway /mcp). -const webBase = () => (process.env.ALPHA_WEB_URL ?? ALPHA_ENDPOINTS.web).replace(/\/+$/, "") -const platformBase = () => (process.env.ALPHA_PLATFORM_URL ?? ALPHA_ENDPOINTS.platform).replace(/\/+$/, "") +// Endpoints come from the resolver (alpha-endpoints.ts): env override > userData pin > login discovery +// > shared/alpha-config default. webBase = alpha-web (C, identity/login/token); platformBase = +// alpha-platform (B, model proxy /v1). Env overrides ALPHA_WEB_URL / ALPHA_PLATFORM_URL still win. +const webBase = () => resolveEndpoints().web +const platformBase = () => resolveEndpoints().platform let userDataPath = "" let getWindow: () => BrowserWindow | null = () => null -let relaunchApp: () => void = () => {} +let respawnSidecar: () => void = () => {} let stored: StoredAuth = { mode: "byok" } let pkce: { verifier: string; state: string } | null = null @@ -133,11 +136,14 @@ export function applyAuthEnv() { const devToken = process.env.DEV_PLATFORM_TOKEN const loggedInPlatform = deriveState().status === "logged-in" && (stored.mode === "platform" || Boolean(devToken)) const token = devToken || (loggedInPlatform ? stored.accessToken : undefined) - const base = platformBase() + const ep = resolveEndpoints() + const base = ep.platform if (!token || !base) return if (!process.env.ALPHA_BASE_URL) process.env.ALPHA_BASE_URL = `${base}${ALPHA_PATHS.modelProxy}` if (!process.env.ALPHA_API_KEY) process.env.ALPHA_API_KEY = token - if (!process.env.ALPHA_CLOUD_MCP_URL) process.env.ALPHA_CLOUD_MCP_URL = `${base}${ALPHA_PATHS.mcpGateway}` + // mcp: a discovered/pinned mcp URL wins; else derive from the gateway base (note: the model gateway + // worker 404s /mcp — cloud dispatch is a separate worker, so discovery is the real fix for it). + if (!process.env.ALPHA_CLOUD_MCP_URL) process.env.ALPHA_CLOUD_MCP_URL = ep.mcp ?? `${base}${ALPHA_PATHS.mcpGateway}` if (!process.env.ALPHA_CLOUD_TOKEN) process.env.ALPHA_CLOUD_TOKEN = token } @@ -150,9 +156,9 @@ export function initAuthEnv(dataPath: string) { } // Called after the main window exists, so state pushes have a target + login can relaunch. -export function setAuthDeps(deps: { getWindow: () => BrowserWindow | null; relaunch: () => void }) { +export function setAuthDeps(deps: { getWindow: () => BrowserWindow | null; respawn: () => void }) { getWindow = deps.getWindow - relaunchApp = deps.relaunch + respawnSidecar = deps.respawn publish() } @@ -249,6 +255,9 @@ async function completeAuth(parsed: URL) { clearPkce() const tokens = await exchangeCode(code, verifier) + // ① learn where the platform's gateway/account live from the token response, so a moved backend + // updates clients without a release. No-op until alpha-web adds the `endpoints` field. + setDiscoveredEndpoints(tokens.endpoints) stored = { // The ALPHA proxy (代理节点) is the recommended path, so login opts into platform-pays BY DEFAULT // (ADR-016 product direction). applyAuthEnv() below writes the proxy env for the NEXT sidecar fork, @@ -266,6 +275,11 @@ async function completeAuth(parsed: URL) { applyAuthEnv() publish() log("alpha-auth: login complete", { plan: tokens.plan, mode: stored.mode }) + // Auto-activate the proxy in THIS session: respawn the sidecar in place (NOT a full app relaunch, + // ADR-017) so the new fork inherits ALPHA_BASE_URL/ALPHA_API_KEY → provider.alpha appears with no + // "启用代理" click and no restart. Guarded on a live window (no-op on cold-start; the next normal + // launch already comes up with the proxy env applied by initAuthEnv). + respawnSidecar() } async function exchangeCode(code: string, verifier: string): Promise { @@ -317,7 +331,7 @@ export async function setAuthMode(mode: AuthMode): Promise { applyAuthEnv() publish() log("alpha-auth: mode changed", { mode }) - relaunchApp() + respawnSidecar() } // One-click "activate the ALPHA proxy in THIS running session". Login already defaults mode → platform @@ -331,5 +345,5 @@ export function enableProxy() { persist() } applyAuthEnv() - relaunchApp() + respawnSidecar() } diff --git a/packages/ui-mac/src/main/alpha-byok-keys.ts b/packages/ui-mac/src/main/alpha-byok-keys.ts new file mode 100644 index 000000000000..59a8d3ce479c --- /dev/null +++ b/packages/ui-mac/src/main/alpha-byok-keys.ts @@ -0,0 +1,158 @@ +// alpha-managed BYOK key store (main process). BYOK API keys are encrypted at rest via Electron +// safeStorage (system keychain), mirroring alpha-auth.ts. This is alpha's OWN key vault — it replaces +// reliance on opencode's native auth.json AND on plaintext inline keys in opencode.jsonc for BYOK +// (design 2026-06-29 §0.4: "opencode auth 出局;auth 全归 alpha"). +// +// Why a main-process store + env bridge: safeStorage only works in the main process, but the config +// that embeds the key (buildAlphaModelConfig) runs in the SIDECAR (utilityProcess). So before each +// sidecar (re)fork, injectByokKeysIntoEnv() decrypts every stored key and writes it into the +// provider's keyEnv in process.env (NEVER clobbering a value the user exported / set in alpha.env), +// which the sidecar inherits and buildAlphaModelConfig reads — exactly how applyAuthEnv() feeds the +// proxy. Changing a key therefore takes effect on the next (re)fork (Phase 4 makes that a respawn). + +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import { safeStorage } from "electron" +import catalog from "./alpha-models.json" +import type { AlphaModelCatalog } from "../shared/alpha-model-types" +import { getLogger } from "./logging" + +const CATALOG = catalog as unknown as AlphaModelCatalog +const FILE = "alpha-byok-keys.json" + +let userDataPath = "" +let keys: Record = {} // providerId -> apiKey (decrypted, in-memory) + +function warn(message: string, meta?: unknown) { + try { + getLogger().warn(message, meta) + } catch {} +} + +function filePath() { + return join(userDataPath, FILE) +} + +/** keyEnv for a catalog BYOK provider (e.g. deepseek -> DEEPSEEK_API_KEY). undefined for off-catalog ids. */ +function keyEnvFor(id: string): string | undefined { + return CATALOG.byokProviders.find((p) => p.id === id)?.keyEnv +} + +function persist() { + try { + mkdirSync(userDataPath, { recursive: true, mode: 0o700 }) + const json = JSON.stringify(keys) + if (safeStorage.isEncryptionAvailable()) { + const enc = safeStorage.encryptString(json).toString("base64") + writeFileSync(filePath(), JSON.stringify({ v: 1, enc }), { encoding: "utf8", mode: 0o600 }) + } else { + // No OS keychain (e.g. headless): persist with a loud warning, owner-only perms. + warn("alpha-byok-keys: safeStorage unavailable, persisting keys without encryption") + writeFileSync(filePath(), JSON.stringify({ v: 1, plain: json }), { encoding: "utf8", mode: 0o600 }) + } + chmodSync(filePath(), 0o600) + } catch (error) { + warn("alpha-byok-keys: persist failed", error) + } +} + +function load() { + try { + const parsed = JSON.parse(readFileSync(filePath(), "utf8")) as { v: number; enc?: string; plain?: string } + let json: string | undefined + if (parsed.enc && safeStorage.isEncryptionAvailable()) { + json = safeStorage.decryptString(Buffer.from(parsed.enc, "base64")) + } else if (parsed.plain) { + json = parsed.plain + } + if (json) { + const obj = JSON.parse(json) as Record + keys = obj && typeof obj === "object" ? obj : {} + } + } catch { + // No store yet, or undecryptable after an app re-sign (see ADR-017) — start empty; user re-enters. + keys = {} + } +} + +/** + * One-time migration OFF opencode's native auth.json (design §0.4). Reads auth.json ONCE; for any + * catalog BYOK provider that has an "api" key there but no key in alpha's store yet, copies it in. + * Best-effort and idempotent. This is the ONLY place that reads opencode auth, and only to migrate + * away from it — after this the call path never touches auth.json. + */ +function migrateFromOpencodeAuth() { + try { + const dataHome = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share") + const authPath = join(dataHome, "opencode", "auth.json") + if (!existsSync(authPath)) return + const data = JSON.parse(readFileSync(authPath, "utf8")) as Record + let migrated = 0 + for (const p of CATALOG.byokProviders) { + if (keys[p.id]) continue + const entry = data?.[p.id] + const key = entry && typeof entry === "object" && entry.type === "api" ? entry.key : undefined + if (typeof key === "string" && key.length > 0) { + keys[p.id] = key + migrated++ + } + } + if (migrated > 0) { + persist() + try { + getLogger().log("alpha-byok-keys: migrated keys off opencode auth.json", { count: migrated }) + } catch {} + } + } catch (error) { + warn("alpha-byok-keys: migration from auth.json failed", error) + } +} + +/** Load the store + run one-time auth.json migration. Call once at startup (index.ts), before fork. */ +export function initByokKeys(dataPath: string) { + userDataPath = dataPath + load() + migrateFromOpencodeAuth() +} + +/** Decrypt-to-env bridge: set each stored key into its provider's keyEnv, without clobbering an + * existing (shell / alpha.env) value. Call before every sidecar (re)fork. */ +export function injectByokKeysIntoEnv() { + for (const [id, key] of Object.entries(keys)) { + const env = keyEnvFor(id) + if (env && !process.env[env]) process.env[env] = key + } +} + +export function getByokKey(id: string): string | undefined { + return keys[id] +} + +/** id -> key map (decrypted). Main-process only; used by getProviderKeyStatus for the configured/hint state. */ +export function byokKeyMap(): Map { + return new Map(Object.entries(keys)) +} + +export function setByokKey(id: string, key: string): { ok: true } | { ok: false; reason: string } { + if (typeof id !== "string" || !id) return { ok: false, reason: "invalid provider id" } + if (typeof key !== "string" || key.length === 0) return { ok: false, reason: "missing api key" } + keys[id] = key + persist() + return { ok: true } +} + +export function removeByokKey(id: string): { ok: true } | { ok: false; reason: string } { + if (!(id in keys)) return { ok: true } + delete keys[id] + persist() + return { ok: true } +} + +/** Clear the whole store (e.g. for tests / full reset). */ +export function clearByokKeys() { + keys = {} + try { + rmSync(filePath(), { force: true }) + } catch {} +} diff --git a/packages/ui-mac/src/main/alpha-endpoints.ts b/packages/ui-mac/src/main/alpha-endpoints.ts new file mode 100644 index 000000000000..782e70265c81 --- /dev/null +++ b/packages/ui-mac/src/main/alpha-endpoints.ts @@ -0,0 +1,92 @@ +// Single endpoint RESOLVER (main process). Replaces "hardcoded constant baked into every bundle" with +// a layered resolution so a moved gateway/account URL needs NO code change or repackage: +// +// env override > userData pin file > login discovery (①) > hardcoded default (bootstrap) +// ALPHA_*_URL / token response shared/alpha-config.ts +// alpha-endpoints.json { endpoints: {...} } +// +// Why this exists (the bug it prevents): `platform` was hardcoded to the wrong host (api.tidelabs.click) +// and shipped baked-in; the real gateway is alpha-gateway.jinjunnm.workers.dev. The volatile URLs +// (gateway/account) now come from discovery/pin so the platform can move clients without a release. +// The renderer no longer imports the constant directly — it reads the resolved set over IPC +// (endpoints-ipc.ts → window.api.endpoints). Pure node (fs only); no electron import. + +import * as fs from "node:fs" +import * as path from "node:path" +import { ALPHA_ENDPOINTS, type AlphaEndpoints } from "../shared/alpha-config" + +const ENV_KEYS: Record = { + web: "ALPHA_WEB_URL", + platform: "ALPHA_PLATFORM_URL", + account: "ALPHA_ACCOUNT_URL", + mcp: "ALPHA_MCP_URL", +} +const KEYS = ["web", "platform", "account", "mcp"] as const + +let userDataPath = "" +let override: Partial = {} // /alpha-endpoints.json — manual pin, read at init +let discovered: Partial = {} // from the login token response (①), persisted across restarts + +const strip = (u?: string | null): string | undefined => { + const v = u?.replace(/\/+$/, "") + return v ? v : undefined +} +const overrideFile = () => path.join(userDataPath, "alpha-endpoints.json") +const discoveredFile = () => path.join(userDataPath, "alpha-discovered-endpoints.json") + +function readPartial(file: string): Partial { + try { + const d = JSON.parse(fs.readFileSync(file, "utf8")) as Record + const out: Partial = {} + for (const k of KEYS) { + const s = strip(typeof d[k] === "string" ? (d[k] as string) : undefined) + if (s) out[k] = s + } + return out + } catch { + return {} + } +} + +/** Called once at startup (index.ts), AFTER preferAppEnv and BEFORE initAuthEnv — so applyAuthEnv + * resolves the proxy URL with the pin + persisted discovery already loaded. */ +export function initEndpoints(dataPath: string) { + userDataPath = dataPath + override = readPartial(overrideFile()) + discovered = readPartial(discoveredFile()) +} + +/** ① The alpha-web /auth/token response MAY carry `{ endpoints: { platform, account, mcp, web } }`. + * Persist it so the next sidecar fork resolves the right gateway without a release. No-op when the + * platform doesn't send it (the producer side is optional — defaults still apply). */ +export function setDiscoveredEndpoints(partial: Partial> | undefined) { + if (!partial || typeof partial !== "object") return + const next: Partial = {} + for (const k of KEYS) { + const s = strip(typeof partial[k] === "string" ? (partial[k] as string) : undefined) + if (s) next[k] = s + } + if (Object.keys(next).length === 0) return + discovered = { ...discovered, ...next } + try { + fs.mkdirSync(userDataPath, { recursive: true }) + fs.writeFileSync(discoveredFile(), JSON.stringify(discovered), { encoding: "utf8", mode: 0o600 }) + } catch { + /* discovery is best-effort; defaults still resolve */ + } +} + +/** Resolve all endpoints. Precedence (highest first): env override (dev/staging) > userData pin > + * login discovery > hardcoded default. `mcp` is omitted unless someone provides it (callers derive + * `${platform}/mcp`). */ +export function resolveEndpoints(): AlphaEndpoints { + const pick = (k: keyof AlphaEndpoints): string | undefined => + strip(process.env[ENV_KEYS[k]]) ?? override[k] ?? discovered[k] + const mcp = pick("mcp") + return { + web: pick("web") ?? ALPHA_ENDPOINTS.web, + platform: pick("platform") ?? ALPHA_ENDPOINTS.platform, + account: pick("account") ?? ALPHA_ENDPOINTS.account, + ...(mcp ? { mcp } : {}), + } +} diff --git a/packages/ui-mac/src/main/alpha-models.json b/packages/ui-mac/src/main/alpha-models.json index 61b9b82174e6..1efad00cb123 100644 --- a/packages/ui-mac/src/main/alpha-models.json +++ b/packages/ui-mac/src/main/alpha-models.json @@ -36,7 +36,7 @@ "baseURL": "https://api.deepseek.com/v1", "keyEnv": "DEEPSEEK_API_KEY", "pico": { "letter": "D", "color": "#2563eb" }, - "models": ["deepseek-chat", "deepseek-reasoner"] + "models": ["deepseek-v4-flash", "deepseek-v4-pro"] }, { "id": "zhipuai", @@ -47,7 +47,7 @@ "baseURL": "https://open.bigmodel.cn/api/anthropic", "keyEnv": "ZHIPU_API_KEY", "pico": { "letter": "智", "color": "#16a34a" }, - "models": ["glm-5", "glm-4.5-air"] + "models": ["glm-5.2", "glm-4.5-air"] }, { "id": "minimax", diff --git a/packages/ui-mac/src/main/alpha-models.ts b/packages/ui-mac/src/main/alpha-models.ts index 60c5cefc1a99..68aabe8b067b 100644 --- a/packages/ui-mac/src/main/alpha-models.ts +++ b/packages/ui-mac/src/main/alpha-models.ts @@ -20,8 +20,11 @@ // Escape hatch: ALPHA_MODELS_DISABLE=1 skips this entirely. import catalog from "./alpha-models.json" -import type { AlphaModelCatalog, ProviderKeyStatus } from "../shared/alpha-model-types" -import { readConfiguredProviderKeys, readUserProviderIds } from "./ext-config" +import type { AlphaModelCatalog } from "../shared/alpha-model-types" +import { readUserProviderIds } from "./ext-config" +// NOTE: this module is loaded by the SIDECAR (utilityProcess) via buildAlphaModelConfig, so it must +// stay electron-free. getProviderKeyStatus (which reads the safeStorage keychain) lives in the +// main-only alpha-provider-status.ts for that reason — do NOT import alpha-byok-keys here. const CATALOG = catalog as unknown as AlphaModelCatalog @@ -30,31 +33,6 @@ export function getModelCatalog(): AlphaModelCatalog { return CATALOG } -/** - * Per-provider BYOK key state for the picker (window.api.providers.keyStatus). A builtin provider is - * "configured" if its keyEnv is set in the (main) process env — which holds alpha.env + shell keys, - * loaded before the sidecar forks, so this matches exactly what opencode will see — OR if the user's - * opencode.jsonc has an inline apiKey for it. Custom providers (config-only) are reported too. - * Limitation: a key stored solely via opencode's native `auth login` is not visible here (P1). - */ -export function getProviderKeyStatus(): ProviderKeyStatus { - const cfgKeyed = readConfiguredProviderKeys() - // Masked tail only (never the full key) so the renderer can show WHICH key is set, not its value. - const last4 = (k?: string) => (k && k.length >= 4 ? k.slice(-4) : k ? "••" : undefined) - const out: ProviderKeyStatus = {} - for (const p of CATALOG.byokProviders) { - const envVal = p.keyEnv ? process.env[p.keyEnv] : undefined - out[p.id] = envVal - ? { configured: true, source: "env", hint: last4(envVal) } - : cfgKeyed.has(p.id) - ? { configured: true, source: "config", hint: last4(cfgKeyed.get(p.id)) } - : { configured: false, source: "none" } - } - // Custom providers (not in the catalog) carry their key inline → always "config". - for (const [id, key] of cfgKeyed) if (!out[id]) out[id] = { configured: true, source: "config", hint: last4(key) } - return out -} - export type AlphaModelConfig = { enabled_providers: string[] model?: string @@ -67,12 +45,18 @@ export function buildAlphaModelConfig(): AlphaModelConfig | undefined { const provider: Record = {} const enabled: string[] = [] - // (1) 国产 built-ins -- in the models.dev catalog, so they only need a model whitelist plus their - // key in the env (see byokProviders[].keyEnv). Non-builtin entries (e.g. Kimi) are presets for the - // 添加节点 flow and are NOT auto-injected here. + // (1) BYOK 直连节点 (方案 C): inject each catalog provider that HAS a key (opt-in) as a FULL custom + // provider — npm/baseURL/models come from the catalog (alpha-code defines them, independent of + // models.dev), and the apiKey is inlined from the env (fed by the alpha keychain via + // injectByokKeysIntoEnv, or a shell/alpha.env export). No key → not injected, so the picker only + // shows keyed BYOK nodes. Calls go DIRECT to the provider's baseURL (never via the gateway). for (const p of CATALOG.byokProviders) { - if (!p.builtin) continue - provider[p.id] = { whitelist: p.models } + const key = p.keyEnv ? process.env[p.keyEnv] : undefined + if (!key) continue + const npm = p.compat === "anthropic" ? "@ai-sdk/anthropic" : "@ai-sdk/openai-compatible" + const models: Record = {} + for (const m of p.models) models[m] = { name: m } + provider[p.id] = { npm, name: p.name, options: { baseURL: p.baseURL, apiKey: key }, models } enabled.push(p.id) } diff --git a/packages/ui-mac/src/main/alpha-provider-status.ts b/packages/ui-mac/src/main/alpha-provider-status.ts new file mode 100644 index 000000000000..bacc55103105 --- /dev/null +++ b/packages/ui-mac/src/main/alpha-provider-status.ts @@ -0,0 +1,43 @@ +// Per-provider BYOK key state for the picker (window.api.providers.keyStatus). MAIN-PROCESS ONLY. +// +// CRITICAL (do not move back into alpha-models.ts): this reads alpha's encrypted keychain +// (alpha-byok-keys → electron `safeStorage`) and the logger (→ logging.ts → electron `app` etc.). +// Those are main-process-only electron exports. alpha-models.ts is loaded by the SIDECAR +// (utilityProcess, via buildAlphaModelConfig); a utilityProcess cannot import main-only electron +// exports and crashes at module init ("Sidecar exited before ready … 'electron' does not provide an +// export named 'app'"). Keeping this electron-touching code in its own main-only module keeps the +// sidecar's module graph electron-free. + +import catalog from "./alpha-models.json" +import type { AlphaModelCatalog, ProviderKeyStatus } from "../shared/alpha-model-types" +import { readConfiguredProviderKeys } from "./ext-config" +import { byokKeyMap } from "./alpha-byok-keys" + +const CATALOG = catalog as unknown as AlphaModelCatalog + +/** + * Per-provider BYOK key state for the picker. A provider is "configured" if alpha's encrypted keychain + * holds a key for it (source "keychain", the normal path), else if its keyEnv is set in the (main) + * process env (alpha.env/shell export), else if opencode.jsonc has an inline apiKey (off-catalog custom + * nodes). opencode's native auth.json is NOT consulted — any prior key there is migrated into the + * keychain once at startup (alpha-byok-keys.initByokKeys). + */ +export function getProviderKeyStatus(): ProviderKeyStatus { + const cfgKeyed = readConfiguredProviderKeys() // opencode.jsonc inline apiKey (off-catalog custom nodes) + const kc = byokKeyMap() // alpha's encrypted BYOK store — the source of truth post-migration + // Masked tail only (never the full key) so the renderer can show WHICH key is set, not its value. + const last4 = (k?: string) => (k && k.length >= 4 ? k.slice(-4) : k ? "••" : undefined) + const out: ProviderKeyStatus = {} + for (const p of CATALOG.byokProviders) { + const kcKey = kc.get(p.id) + const envVal = p.keyEnv ? process.env[p.keyEnv] : undefined + if (kcKey) out[p.id] = { configured: true, source: "keychain", hint: last4(kcKey) } + else if (envVal) out[p.id] = { configured: true, source: "env", hint: last4(envVal) } + else if (cfgKeyed.has(p.id)) out[p.id] = { configured: true, source: "config", hint: last4(cfgKeyed.get(p.id)) } + else out[p.id] = { configured: false, source: "none" } + } + // Off-catalog custom providers: key in the alpha keychain or inline in opencode.jsonc. + for (const [id, key] of kc) if (!out[id]) out[id] = { configured: true, source: "keychain", hint: last4(key) } + for (const [id, key] of cfgKeyed) if (!out[id]) out[id] = { configured: true, source: "config", hint: last4(key) } + return out +} diff --git a/packages/ui-mac/src/main/endpoints-ipc.ts b/packages/ui-mac/src/main/endpoints-ipc.ts new file mode 100644 index 000000000000..6f92d122a2ac --- /dev/null +++ b/packages/ui-mac/src/main/endpoints-ipc.ts @@ -0,0 +1,10 @@ +// Expose the RESOLVED backend endpoints to the renderer (read-only; no secrets). Lets the renderer +// stop baking the URLs and instead read main's resolution (env > pin > discovery > default), so a +// moved URL propagates without a renderer rebuild. See alpha-endpoints.ts. + +import { ipcMain } from "electron" +import { resolveEndpoints } from "./alpha-endpoints" + +export function registerEndpointsIpcHandlers() { + ipcMain.handle("alpha-endpoints", () => resolveEndpoints()) +} diff --git a/packages/ui-mac/src/main/ext-config.ts b/packages/ui-mac/src/main/ext-config.ts index 20f32f4c40b5..adab6650a6ee 100644 --- a/packages/ui-mac/src/main/ext-config.ts +++ b/packages/ui-mac/src/main/ext-config.ts @@ -222,9 +222,10 @@ export function readConfiguredProviderKeys(): Map { } /** - * Remove a provider block (definition + inline key) from opencode.jsonc. For a builtin this only drops - * the user's inline key (alpha re-injects the definition at fork); for a custom provider it removes it - * entirely. Does NOT touch env keys (those live in alpha.env). Takes effect on the next reconnect. + * Remove a custom provider block (definition + inline key) from opencode.jsonc. For a builtin alpha + * re-injects the definition at fork, so this just drops a user-set inline key; for an off-catalog + * custom provider it removes it entirely. BYOK keys now live in alpha's keychain (alpha-byok-keys), + * removed separately via providers.removeKey. Env keys (alpha.env) are untouched. Next reconnect. */ export function removeProvider(id: string): ConfigResult { if (!SAFE_NAME.test(id)) return { ok: false, reason: "invalid provider id" } diff --git a/packages/ui-mac/src/main/index.ts b/packages/ui-mac/src/main/index.ts index 763072424023..0066e7d15fd6 100644 --- a/packages/ui-mac/src/main/index.ts +++ b/packages/ui-mac/src/main/index.ts @@ -43,6 +43,9 @@ import { registerWslIpcHandlers } from "./wsl/ipc" import { spawnWslSidecar } from "./wsl/sidecar" import { migrate } from "./migrate" import { ensureAlphaLayoutDefault } from "./alpha-defaults" +import { initEndpoints } from "./alpha-endpoints" +import { registerEndpointsIpcHandlers } from "./endpoints-ipc" +import { initByokKeys, injectByokKeysIntoEnv } from "./alpha-byok-keys" import { enableProxy, getAuthState, @@ -207,9 +210,17 @@ const main = Effect.gen(function* () { } preferAppEnv(app.getPath("userData")) + // Load the endpoint resolver (userData pin + persisted login discovery) BEFORE initAuthEnv, so the + // proxy URL it derives reflects discovery/pin, not just the hardcoded default. See alpha-endpoints.ts. + initEndpoints(app.getPath("userData")) // Derive the platform proxy env (ALPHA_BASE_URL/ALPHA_API_KEY for the model proxy + cloud MCP) // from any stored login or DEV_PLATFORM_TOKEN, BEFORE the sidecar forks so it inherits them. initAuthEnv(app.getPath("userData")) + // Load alpha's encrypted BYOK key vault (migrates any key off opencode auth.json once) and bridge + // each stored key into its provider's keyEnv BEFORE the sidecar forks, so buildAlphaModelConfig + // (sidecar) can inline it as a direct-node apiKey. See alpha-byok-keys.ts. + initByokKeys(app.getPath("userData")) + injectByokKeysIntoEnv() app.on("second-instance", (_event: Event, argv: string[]) => { const urls = argv.filter((arg: string) => arg.startsWith("opencode://") || arg.startsWith("alpha-code://")) @@ -315,6 +326,7 @@ const main = Effect.gen(function* () { registerAccountIpcHandlers() registerModelsIpcHandlers() registerProviderIpcHandlers() + registerEndpointsIpcHandlers() void updater.start() const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000) updateTimer.unref() @@ -395,7 +407,35 @@ const main = Effect.gen(function* () { yield* Fiber.await(loadingTask) mainWindow = createMainWindow() - setAuthDeps({ getWindow: () => mainWindow, relaunch }) + + // In-place sidecar respawn — NOT a full app relaunch (ad-hoc-signed builds quit on relaunch, ADR-017). + // Re-forks on the SAME host/port/password with freshly-derived env (login set ALPHA_BASE_URL/ + // ALPHA_API_KEY → buildAlphaModelConfig injects provider.alpha), then reloads the renderer so it + // reconnects (url/password unchanged → awaitInitialization stays valid) and re-fetches providers → + // the proxy activates with zero clicks and no restart. + const respawnSidecar = async () => { + if (!mainWindow || mainWindow.isDestroyed()) return + try { + logger.log("respawning sidecar (proxy activation)") + await killSidecar() + ensureLoopbackNoProxy() + useEnvProxy() + const { listener, health } = await spawnLocalServer(hostname, port, password, { + userDataPath: app.getPath("userData"), + onStdout: (message) => writeLog("server", "stdout", { message }), + onStderr: (message) => writeLog("server", "stderr", { message }, "warn"), + onExit: (code) => writeLog("utility", "sidecar exited", { code }, "warn"), + }) + server = listener + await Promise.race([health.wait.catch(() => {}), new Promise((resolve) => setTimeout(resolve, 20000))]) + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.reload() + logger.log("sidecar respawned + renderer reloaded") + } catch (error) { + logger.error("sidecar respawn failed", error) + } + } + + setAuthDeps({ getWindow: () => mainWindow, respawn: respawnSidecar }) if (mainWindow) { createMenu({ trigger: (id) => { diff --git a/packages/ui-mac/src/main/provider-ipc.ts b/packages/ui-mac/src/main/provider-ipc.ts index 4ca9e1128804..30a73cef630f 100644 --- a/packages/ui-mac/src/main/provider-ipc.ts +++ b/packages/ui-mac/src/main/provider-ipc.ts @@ -4,8 +4,9 @@ import { ipcMain, type IpcMainInvokeEvent } from "electron" import type { ProviderInput, ProviderTestInput } from "../shared/alpha-model-types" -import { getProviderKeyStatus } from "./alpha-models" +import { getProviderKeyStatus } from "./alpha-provider-status" import { persistProvider, removeProvider } from "./ext-config" +import { removeByokKey, setByokKey } from "./alpha-byok-keys" import { testProvider } from "./provider-test" export function registerProviderIpcHandlers() { @@ -14,6 +15,10 @@ export function registerProviderIpcHandlers() { // Read-only key-state for the picker's "需 Key / 已配置" gating. No secrets cross the boundary — // only { configured, source, hint(last4) } per provider id. ipcMain.handle("providers-key-status", () => getProviderKeyStatus()) - // Remove a provider's inline key/definition from opencode.jsonc (env keys are untouched). + // Store / drop a catalog BYOK provider's key in alpha's encrypted keychain (alpha-byok-keys). + // Applies on the next sidecar (re)fork: keychain → keyEnv → inline custom-provider apiKey. + ipcMain.handle("providers-set-key", (_event: IpcMainInvokeEvent, id: string, key: string) => setByokKey(id, key)) + ipcMain.handle("providers-remove-key", (_event: IpcMainInvokeEvent, id: string) => removeByokKey(id)) + // Remove an off-catalog custom provider's inline key/definition from opencode.jsonc (env untouched). ipcMain.handle("providers-remove", (_event: IpcMainInvokeEvent, id: string) => removeProvider(id)) } diff --git a/packages/ui-mac/src/main/sidecar.ts b/packages/ui-mac/src/main/sidecar.ts index a964f8e44121..100a56800bde 100644 --- a/packages/ui-mac/src/main/sidecar.ts +++ b/packages/ui-mac/src/main/sidecar.ts @@ -1,4 +1,5 @@ import * as fs from "node:fs" +import { registerHooks } from "node:module" import * as http from "node:http" import * as path from "node:path" import * as tls from "node:tls" @@ -6,6 +7,32 @@ import { ALPHA_BEHAVIOR_MD } from "./alpha-behavior" import { buildAlphaIdentity } from "./alpha-identity" import { buildAlphaModelConfig } from "./alpha-models" +// ADR-006 bridge ("two runtime worlds"). opencode's ToolRegistry dynamically imports a project's +// raw-TS tools (.opencode/tool/*.ts), and packages whose TS entry does `import "./x.js"` (e.g. +// @opencode-ai/plugin → src/index.ts → import "./tool.js") expect that to resolve to the sibling .ts. +// bun rewrites `.js`→`.ts`; the packaged Electron-Node sidecar does NOT, so those imports throw +// ERR_MODULE_NOT_FOUND → prompt_async crashes → the model never replies (looks like "no response"). +// This in-thread resolve hook restores bun's behavior: when a `.js` specifier fails to resolve but the +// sibling `.ts` exists on disk, use the `.ts`. Alpha-only, zero opencode edits; registered at module +// load (before the server import) so it covers every runtime tool load. +registerHooks({ + resolve(specifier, context, nextResolve) { + try { + return nextResolve(specifier, context) + } catch (error) { + if (!specifier.endsWith(".js")) throw error + let resolved: ReturnType + try { + resolved = nextResolve(`${specifier.slice(0, -3)}.ts`, context) + } catch { + throw error // sibling .ts also unresolvable → surface the original .js error + } + if (!resolved.url.startsWith("file:") || !fs.existsSync(new URL(resolved.url))) throw error + return { ...resolved, shortCircuit: true } + } + }, +}) + type NodeHttpWithEnvProxy = typeof http & { setGlobalProxyFromEnv: () => void } diff --git a/packages/ui-mac/src/preload/index.ts b/packages/ui-mac/src/preload/index.ts index b47f56df1041..b1b46395b8b4 100644 --- a/packages/ui-mac/src/preload/index.ts +++ b/packages/ui-mac/src/preload/index.ts @@ -98,6 +98,7 @@ const api: ElectronAPI = { setWindowFocus: () => ipcRenderer.invoke("set-window-focus"), showWindow: () => ipcRenderer.invoke("show-window"), relaunch: () => ipcRenderer.send("relaunch"), + endpoints: () => ipcRenderer.invoke("alpha-endpoints"), getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"), setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor), getPinchZoomEnabled: () => ipcRenderer.invoke("get-pinch-zoom-enabled"), @@ -154,6 +155,8 @@ const api: ElectronAPI = { add: (input) => ipcRenderer.invoke("providers-add", input), test: (input) => ipcRenderer.invoke("providers-test", input), keyStatus: () => ipcRenderer.invoke("providers-key-status"), + setKey: (id, key) => ipcRenderer.invoke("providers-set-key", id, key), + removeKey: (id) => ipcRenderer.invoke("providers-remove-key", id), remove: (id) => ipcRenderer.invoke("providers-remove", id), }, } diff --git a/packages/ui-mac/src/preload/types.ts b/packages/ui-mac/src/preload/types.ts index fadc735842d6..384b0e58833d 100644 --- a/packages/ui-mac/src/preload/types.ts +++ b/packages/ui-mac/src/preload/types.ts @@ -1,6 +1,7 @@ import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu" import type { WslServersPlatform } from "@opencode-ai/app/wsl/types" import type { UpdaterState } from "@opencode-ai/app/updater" +import type { AlphaEndpoints } from "../shared/alpha-config" import type { AlphaModelCatalog, ProviderInput, @@ -140,6 +141,9 @@ export type ElectronAPI = { setWindowFocus: () => Promise showWindow: () => Promise relaunch: () => void + /** Resolved backend endpoints (env > userData pin > login discovery > default). Renderer reads these + * instead of baking the URLs. */ + endpoints: () => Promise getZoomFactor: () => Promise setZoomFactor: (factor: number) => Promise getPinchZoomEnabled: () => Promise @@ -194,7 +198,11 @@ export type ElectronAPI = { test: (input: ProviderTestInput) => Promise /** Read-only BYOK key state per provider id (drives the picker's 需 Key / 已配置 gating). */ keyStatus: () => Promise - /** Remove a provider's inline key/definition from opencode.jsonc (env keys untouched). */ + /** Store a catalog BYOK provider's key in alpha's encrypted keychain (alpha-byok-keys). */ + setKey: (id: string, key: string) => Promise + /** Drop a catalog BYOK provider's key from alpha's keychain. */ + removeKey: (id: string) => Promise + /** Remove an off-catalog custom provider's inline key/definition from opencode.jsonc (env untouched). */ remove: (id: string) => Promise } } diff --git a/packages/ui-mac/src/renderer/alpha-ui/model-picker-add.tsx b/packages/ui-mac/src/renderer/alpha-ui/model-picker-add.tsx index b4697425ca67..b9ee0479f0ee 100644 --- a/packages/ui-mac/src/renderer/alpha-ui/model-picker-add.tsx +++ b/packages/ui-mac/src/renderer/alpha-ui/model-picker-add.tsx @@ -1,8 +1,8 @@ // AddProvider — the "添加自定义节点 / 供应商" two-step flow, rendered as an overlay over the model // picker popover (ADR-016: alpha owns this UI). Step 1: pick a known provider (filled from the catalog // — user only pastes a Key) or "其他/自定义" (manual model ids). Step 2: configure + 测试连接 (1-token -// chat) + 保存. Save → window.api.providers.add (writes opencode.jsonc provider[]); the new provider's -// models appear after the next reconnect (build.md §6). All catalog data is config-driven (no hardcode). +// chat) + 保存. Save → preset keys go to alpha's encrypted keychain (providers.setKey); custom endpoints +// persist to opencode.jsonc (providers.add). New nodes apply on the next sidecar (re)fork. Config-driven. import { createMemo, createSignal, For, onMount, Show } from "solid-js" import type { AlphaModelCatalog, ByokProvider, ProviderKeyStatus } from "../../shared/alpha-model-types" @@ -131,14 +131,19 @@ export function AddProvider(props: { return } setSaving(true) - const r = await window.api.providers.add({ - id, - name: name(), - compat: compat(), - baseURL: baseURL(), - apiKey: apiKey(), - models: models(), - }) + // Catalog presets: the key goes to alpha's encrypted keychain (the catalog already defines + // baseURL/models; buildAlphaModelConfig injects the node from keychain→env). Off-catalog custom + // endpoints: persist the full definition to opencode.jsonc as before (custom-key migration = Phase 5). + const r = isCustom() + ? await window.api.providers.add({ + id, + name: name(), + compat: compat(), + baseURL: baseURL(), + apiKey: apiKey(), + models: models(), + }) + : await window.api.providers.setKey(id, apiKey()) setSaving(false) if (r.ok) { props.onSaved?.() @@ -152,12 +157,14 @@ export function AddProvider(props: { const s = sel() if (!s || s === "custom") return const p = s as ByokProvider - if (currentStatus()?.source === "env") { + const src = currentStatus()?.source + if (src === "env") { setError(`该 Key 来自环境变量,请在 alpha.env 中删除 ${p.keyEnv}`) return } setSaving(true) - const r = await window.api.providers.remove(p.id) + // keychain is the normal store; "config" is a legacy inline key in opencode.jsonc. + const r = src === "config" ? await window.api.providers.remove(p.id) : await window.api.providers.removeKey(p.id) setSaving(false) if (r.ok) { props.onSaved?.() @@ -265,8 +272,14 @@ export function AddProvider(props: {
已配置 ••••{currentStatus()?.hint ?? ""} - {currentStatus()?.source === "env" ? "来源:环境变量" : "来源:配置"} - + + {currentStatus()?.source === "keychain" + ? "来源:钥匙串" + : currentStatus()?.source === "env" + ? "来源:环境变量" + : "来源:配置"} + + diff --git a/packages/ui-mac/src/renderer/alpha-ui/model-picker-inject.tsx b/packages/ui-mac/src/renderer/alpha-ui/model-picker-inject.tsx index 93df17c9362e..d135f32964fe 100644 --- a/packages/ui-mac/src/renderer/alpha-ui/model-picker-inject.tsx +++ b/packages/ui-mac/src/renderer/alpha-ui/model-picker-inject.tsx @@ -11,7 +11,8 @@ import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } import { Portal } from "solid-js/web" import type { AccountSummary, AuthState } from "../../preload/types" import type { AlphaModelCatalog, ByokProvider, ProviderKeyStatus, Tier } from "../../shared/alpha-model-types" -import { ALPHA_ENDPOINTS, ALPHA_PATHS } from "../../shared/alpha-config" +import { ALPHA_PATHS } from "../../shared/alpha-config" +import { useAlphaEndpoints } from "../use-alpha-endpoints" import { AddProvider } from "./model-picker-add" const [catalog, setCatalog] = createSignal(null) @@ -49,6 +50,7 @@ export function ModelPickerInject() { const [keyStatus, setKeyStatus] = createSignal({}) const [configureId, setConfigureId] = createSignal(null) const refreshKeyStatus = () => window.api.providers.keyStatus().then(setKeyStatus).catch(() => {}) + const endpoints = useAlphaEndpoints() onMount(() => onCleanup(window.api.auth.subscribe(setAuth))) createEffect(() => { @@ -173,7 +175,6 @@ export function ModelPickerInject() { .map((key): Row => { const prov = key.slice(0, key.indexOf(":")) const id = key.slice(key.indexOf(":") + 1) - const configured = keyStatus()[prov]?.configured ?? false return { key, name: id, @@ -181,14 +182,15 @@ export function ModelPickerInject() { pico: picoFor(prov), tier: heuristicTier(id), reasoning: EFFORT_RE.test(id), - locked: !configured, - needKey: !configured, } }) .filter((r) => { const prov = r.key.slice(0, r.key.indexOf(":")) const known = !!cat?.byokProviders.some((p) => p.id === prov) - return wantBuiltin ? known : !known + if (wantBuiltin ? !known : known) return false + // Hide providers with no key entirely (an unconfigured provider's whole row shouldn't show). + // Add a key via the footer "添加自定义节点 / 供应商" flow (lists every provider + 已配置 state). + return keyStatus()[prov]?.configured ?? false }) .filter(matchQ) } @@ -210,8 +212,8 @@ export function ModelPickerInject() { if (state() === "out") void window.api.auth.start() // not logged in → login (login now opts into the proxy by default) else if (state() === "empty") - window.api.openLink(`${ALPHA_ENDPOINTS.web}${ALPHA_PATHS.wallet}?tab=recharge`) // no funds → recharge - else void window.api.auth.enableProxy() // logged in + funded, proxy not live → relaunch to activate + window.api.openLink(`${endpoints().web}${ALPHA_PATHS.wallet}?tab=recharge`) // no funds → recharge + else void window.api.auth.enableProxy() // logged in + funded, proxy not live → respawn in place (no restart) return } const el = document.querySelector(`[data-slot="list-item"][data-key="${r.key}"]`) as HTMLElement | null @@ -272,7 +274,7 @@ export function ModelPickerInject() {
@@ -281,15 +283,6 @@ export function ModelPickerInject() {
国内直连 · 自带 KEY (BYOK) -
{(r) => }
@@ -348,8 +341,9 @@ export function ModelPickerInject() { } function AccountBanner(props: { state: "member" | "balance" | "empty" | "out"; summary: AccountSummary | null }) { - const rechargeUrl = `${ALPHA_ENDPOINTS.web}${ALPHA_PATHS.wallet}?tab=recharge` - const subscribeUrl = `${ALPHA_ENDPOINTS.web}${ALPHA_PATHS.wallet}?tab=subscription` + const endpoints = useAlphaEndpoints() + const rechargeUrl = () => `${endpoints().web}${ALPHA_PATHS.wallet}?tab=recharge` + const subscribeUrl = () => `${endpoints().web}${ALPHA_PATHS.wallet}?tab=subscription` const planName = () => (props.summary?.plan.status === "active" ? props.summary.plan.name : "Pro") return ( <> @@ -369,7 +363,7 @@ function AccountBanner(props: { state: "member" | "balance" | "empty" | "out"; s 钱包余额 {fmtYuan(props.summary?.balanceFen ?? 0)} · 未订阅 按量扣费 - +
@@ -379,8 +373,8 @@ function AccountBanner(props: { state: "member" | "balance" | "empty" | "out"; s 余额不足 · 充值后解锁代理 钱包 ¥0.00 - - + + diff --git a/packages/ui-mac/src/renderer/sidebar/alpha-sidebar.tsx b/packages/ui-mac/src/renderer/sidebar/alpha-sidebar.tsx index 52e65a8f18f0..a1b8e743e18a 100644 --- a/packages/ui-mac/src/renderer/sidebar/alpha-sidebar.tsx +++ b/packages/ui-mac/src/renderer/sidebar/alpha-sidebar.tsx @@ -15,7 +15,8 @@ import { useTheme } from "@opencode-ai/ui/theme/context" import { t } from "../i18n" import { pushToast } from "../alpha-ui/Toast" import { Mark } from "../logo-alpha" -import { ALPHA_ENDPOINTS, ALPHA_PATHS } from "../../shared/alpha-config" +import { ALPHA_PATHS } from "../../shared/alpha-config" +import { useAlphaEndpoints } from "../use-alpha-endpoints" import { base64UrlDecode, homeHref, newSessionHref, projectLabel, sessionHref } from "./route" import { clearHiddenProjects, @@ -146,8 +147,9 @@ export function AlphaSidebar(props: { server: Accessor } { k: "system", l: "系统" }, ] as const // 钱包购买页(/wallet)按页签深链:充值 → 钱包充值页签;升级会员/管理订阅 → 会员月卡页签。 - const rechargeUrl = `${ALPHA_ENDPOINTS.web}${ALPHA_PATHS.wallet}?tab=recharge` - const subscribeUrl = `${ALPHA_ENDPOINTS.web}${ALPHA_PATHS.wallet}?tab=subscription` + const endpoints = useAlphaEndpoints() + const rechargeUrl = () => `${endpoints().web}${ALPHA_PATHS.wallet}?tab=recharge` + const subscribeUrl = () => `${endpoints().web}${ALPHA_PATHS.wallet}?tab=subscription` // Account summary (balance / membership / token usage) from alpha-platform B, fetched on login. // Contract: alpha-platform docs/alpha-code-account-integration.md. usageSeries powers the 14-day @@ -1039,15 +1041,15 @@ export function AlphaSidebar(props: { server: Accessor }
- - - diff --git a/packages/ui-mac/src/renderer/use-alpha-endpoints.ts b/packages/ui-mac/src/renderer/use-alpha-endpoints.ts new file mode 100644 index 000000000000..d478ec3bf2e8 --- /dev/null +++ b/packages/ui-mac/src/renderer/use-alpha-endpoints.ts @@ -0,0 +1,22 @@ +// Renderer accessor for the resolved backend endpoints. The renderer no longer hardcodes the URLs — +// it reads main's resolution over IPC (window.api.endpoints). A module-level signal fetches once and +// is shared by every caller; the shared default is only the initial value until the IPC resolves +// (links are clicked after mount, so the resolved value is what's used). ALPHA_PATHS (route segments) +// stay imported from shared — those are the stable HTTP contract, not deployment-specific. + +import { createSignal } from "solid-js" +import { ALPHA_ENDPOINTS, type AlphaEndpoints } from "../shared/alpha-config" + +const [endpoints, setEndpoints] = createSignal(ALPHA_ENDPOINTS) +let started = false + +export function useAlphaEndpoints() { + if (!started) { + started = true + window.api + .endpoints() + .then((e) => e && setEndpoints((prev) => ({ ...prev, ...e }))) + .catch(() => {}) + } + return endpoints +} diff --git a/packages/ui-mac/src/shared/alpha-config.ts b/packages/ui-mac/src/shared/alpha-config.ts index ef30da7d223c..66549eeb2687 100644 --- a/packages/ui-mac/src/shared/alpha-config.ts +++ b/packages/ui-mac/src/shared/alpha-config.ts @@ -4,11 +4,21 @@ // process.env). Pure constants only — NO electron/node imports — so both the main and renderer // bundles can import this module. +/** Resolved alpha backend endpoints. `mcp` optional (callers derive `${platform}/mcp` when absent). + * The constants below are bootstrap DEFAULTS only — main resolves env > userData pin > login discovery + * > default (see main/alpha-endpoints.ts), and the renderer reads the resolved set over IPC + * (window.api.endpoints). Change a domain HERE only to move the default. */ +export type AlphaEndpoints = { web: string; platform: string; account: string; mcp?: string } + export const ALPHA_ENDPOINTS = { /** alpha-web (C): identity / login / token / billing portal. */ web: "https://auth.tidelabs.click", - /** alpha-platform (B): model proxy (/v1) + MCP gateway (/mcp). */ - platform: "https://api.tidelabs.click", + /** alpha-platform (B): model proxy (/v1). The gateway has NO custom domain (unlike account./auth.) — + * it's the raw Worker URL `alpha-gateway.jinjunnm.workers.dev`, confirmed against alpha-platform docs + * (M4-next-steps / design.md / ADR-014) AND a live probe (/health 200, /v1/models 200, /v1/chat + * /completions 401). The previous `api.tidelabs.click` 404'd every /v1 route (no gateway routed + * there). Override per-deploy with ALPHA_PLATFORM_URL once a custom domain is set up. */ + platform: "https://alpha-gateway.jinjunnm.workers.dev", /** alpha-platform (B) account-server (境内 PII/金融): balance / membership / usage ledger. */ account: "https://account.tidelabs.click", } as const diff --git a/packages/ui-mac/src/shared/alpha-model-types.ts b/packages/ui-mac/src/shared/alpha-model-types.ts index 294cf26a9e20..86ce5de288d2 100644 --- a/packages/ui-mac/src/shared/alpha-model-types.ts +++ b/packages/ui-mac/src/shared/alpha-model-types.ts @@ -69,5 +69,12 @@ export type ProviderTestResult = { ok: true; ms: number } | { ok: false; reason: // picker can't tell "keyed" from "unkeyed" without this. `source`: "env" = the provider's keyEnv is // set in the (main) process env (alpha.env/shell); "config" = an inline apiKey in opencode.jsonc; // "none" = no usable key (→ row is locked, click opens the configure form). -export type ProviderKeyState = { configured: boolean; source: "env" | "config" | "none"; hint?: string } +export type ProviderKeyState = { + configured: boolean + /** "keychain" = alpha's encrypted BYOK store (alpha-byok-keys, the source of truth); "env" = keyEnv + * in process env (alpha.env/shell export); "config" = inline apiKey in opencode.jsonc; "none" = no + * key. ("auth" = legacy opencode auth.json — no longer produced; kept in the union pending UI cleanup.) */ + source: "keychain" | "env" | "config" | "auth" | "none" + hint?: string +} export type ProviderKeyStatus = Record