diff --git a/packages/ui-mac/src/main/alpha-auth.ts b/packages/ui-mac/src/main/alpha-auth.ts index 4aec2e96ca9f..ce17d4cf3eeb 100644 --- a/packages/ui-mac/src/main/alpha-auth.ts +++ b/packages/ui-mac/src/main/alpha-auth.ts @@ -250,9 +250,12 @@ async function completeAuth(parsed: URL) { const tokens = await exchangeCode(code, verifier) stored = { - // Login does not flip the mode — the user explicitly toggles platform-pays to activate the - // proxy (which relaunches). Logging in just records identity; default stays BYOK. - mode: stored.mode ?? "byok", + // 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, + // so subsequent launches come up with the proxy live and zero clicks; the CURRENT session activates + // via enableProxy() (a controlled relaunch). We deliberately do NOT auto-relaunch on login: a + // deep-link callback can cold-start the app, and ad-hoc-signed builds quit on relaunch (see ADR-017). + mode: "platform", accessToken: tokens.access_token, refreshToken: tokens.refresh_token, sessionId: tokens.session_id, @@ -316,3 +319,17 @@ export async function setAuthMode(mode: AuthMode): Promise { log("alpha-auth: mode changed", { mode }) relaunchApp() } + +// One-click "activate the ALPHA proxy in THIS running session". Login already defaults mode → platform +// and applyAuthEnv() wrote the proxy env, but the sidecar that's currently running forked BEFORE that, +// so provider.alpha only appears after a fresh fork. Force mode=platform (covers a pre-fix stored +// "byok") and relaunch so the new sidecar inherits ALPHA_BASE_URL/ALPHA_API_KEY. Later launches pick it +// up automatically (initAuthEnv runs before the fork), so this is a one-time step after the first login. +export function enableProxy() { + if (stored.mode !== "platform") { + stored.mode = "platform" + persist() + } + applyAuthEnv() + relaunchApp() +} diff --git a/packages/ui-mac/src/main/alpha-models.ts b/packages/ui-mac/src/main/alpha-models.ts index e55761116fdd..60c5cefc1a99 100644 --- a/packages/ui-mac/src/main/alpha-models.ts +++ b/packages/ui-mac/src/main/alpha-models.ts @@ -20,8 +20,8 @@ // Escape hatch: ALPHA_MODELS_DISABLE=1 skips this entirely. import catalog from "./alpha-models.json" -import type { AlphaModelCatalog } from "../shared/alpha-model-types" -import { readUserProviderIds } from "./ext-config" +import type { AlphaModelCatalog, ProviderKeyStatus } from "../shared/alpha-model-types" +import { readConfiguredProviderKeys, readUserProviderIds } from "./ext-config" const CATALOG = catalog as unknown as AlphaModelCatalog @@ -30,6 +30,31 @@ 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 diff --git a/packages/ui-mac/src/main/ext-config.ts b/packages/ui-mac/src/main/ext-config.ts index ce8104ee63f7..20f32f4c40b5 100644 --- a/packages/ui-mac/src/main/ext-config.ts +++ b/packages/ui-mac/src/main/ext-config.ts @@ -197,6 +197,40 @@ export function readUserProviderIds(): string[] { } } +/** + * Provider ids in opencode.jsonc that carry an INLINE api key (provider[id].options.apiKey). The + * model picker uses this (plus the keyEnv env check) to show "已配置 / 需配置" state — builtin + * providers are injected as config-only, so without this they look identical whether keyed or not. + */ +export function readConfiguredProviderKeys(): Map { + const out = new Map() + try { + const target = userConfigPath() + if (!fs.existsSync(target)) return out + const parsed = parse(fs.readFileSync(target, "utf8")) as { provider?: Record } | undefined + const prov = parsed?.provider + if (prov && typeof prov === "object") { + for (const [id, def] of Object.entries(prov)) { + const key = (def as { options?: { apiKey?: unknown } } | null)?.options?.apiKey + if (typeof key === "string" && key.trim().length > 0) out.set(id, key) + } + } + } catch { + /* unreadable config → treat as none configured */ + } + return out +} + +/** + * 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. + */ +export function removeProvider(id: string): ConfigResult { + if (!SAFE_NAME.test(id)) return { ok: false, reason: "invalid provider id" } + return writeKey(["provider", id], undefined) +} + // npm package name (optional scope), optionally pinned with @version. No shell metacharacters — // opencode installs the package itself on next launch (loader.ts resolvePluginTarget), so we never // shell out; this only gates what we write into the config. diff --git a/packages/ui-mac/src/main/index.ts b/packages/ui-mac/src/main/index.ts index 131ba31dedbe..763072424023 100644 --- a/packages/ui-mac/src/main/index.ts +++ b/packages/ui-mac/src/main/index.ts @@ -44,6 +44,7 @@ import { spawnWslSidecar } from "./wsl/sidecar" import { migrate } from "./migrate" import { ensureAlphaLayoutDefault } from "./alpha-defaults" import { + enableProxy, getAuthState, handleAuthDeepLink, initAuthEnv, @@ -306,6 +307,7 @@ const main = Effect.gen(function* () { start: () => startAuth(), logout: () => authLogout(), setMode: (mode) => setAuthMode(mode), + enableProxy: () => enableProxy(), }, }) registerWslIpcHandlers(wslServers) diff --git a/packages/ui-mac/src/main/ipc.ts b/packages/ui-mac/src/main/ipc.ts index 0497a23ffeac..79f78aa5d0a3 100644 --- a/packages/ui-mac/src/main/ipc.ts +++ b/packages/ui-mac/src/main/ipc.ts @@ -42,6 +42,7 @@ type Deps = { start: () => Promise logout: () => Promise setMode: (mode: AuthMode) => Promise + enableProxy: () => void } } @@ -86,6 +87,7 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.handle("auth-start", () => deps.auth.start()) ipcMain.handle("auth-logout", () => deps.auth.logout()) ipcMain.handle("auth-set-mode", (_event: IpcMainInvokeEvent, mode: AuthMode) => deps.auth.setMode(mode)) + ipcMain.handle("auth-enable-proxy", () => deps.auth.enableProxy()) ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => { try { const store = getStore(name) diff --git a/packages/ui-mac/src/main/provider-ipc.ts b/packages/ui-mac/src/main/provider-ipc.ts index 443cc8b8d4ae..4ca9e1128804 100644 --- a/packages/ui-mac/src/main/provider-ipc.ts +++ b/packages/ui-mac/src/main/provider-ipc.ts @@ -4,10 +4,16 @@ import { ipcMain, type IpcMainInvokeEvent } from "electron" import type { ProviderInput, ProviderTestInput } from "../shared/alpha-model-types" -import { persistProvider } from "./ext-config" +import { getProviderKeyStatus } from "./alpha-models" +import { persistProvider, removeProvider } from "./ext-config" import { testProvider } from "./provider-test" export function registerProviderIpcHandlers() { ipcMain.handle("providers-add", (_event: IpcMainInvokeEvent, input: ProviderInput) => persistProvider(input)) ipcMain.handle("providers-test", (_event: IpcMainInvokeEvent, input: ProviderTestInput) => testProvider(input)) + // 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). + ipcMain.handle("providers-remove", (_event: IpcMainInvokeEvent, id: string) => removeProvider(id)) } diff --git a/packages/ui-mac/src/preload/index.ts b/packages/ui-mac/src/preload/index.ts index 0b535069894b..b47f56df1041 100644 --- a/packages/ui-mac/src/preload/index.ts +++ b/packages/ui-mac/src/preload/index.ts @@ -122,6 +122,7 @@ const api: ElectronAPI = { start: () => ipcRenderer.invoke("auth-start"), logout: () => ipcRenderer.invoke("auth-logout"), setMode: (mode) => ipcRenderer.invoke("auth-set-mode", mode), + enableProxy: () => ipcRenderer.invoke("auth-enable-proxy"), subscribe: (cb) => { const handler = (_: unknown, state: AuthState) => cb(state) ipcRenderer.on("auth-state", handler) @@ -152,6 +153,8 @@ const api: ElectronAPI = { providers: { add: (input) => ipcRenderer.invoke("providers-add", input), test: (input) => ipcRenderer.invoke("providers-test", input), + keyStatus: () => ipcRenderer.invoke("providers-key-status"), + 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 45022fc3b7dd..fadc735842d6 100644 --- a/packages/ui-mac/src/preload/types.ts +++ b/packages/ui-mac/src/preload/types.ts @@ -4,6 +4,7 @@ import type { UpdaterState } from "@opencode-ai/app/updater" import type { AlphaModelCatalog, ProviderInput, + ProviderKeyStatus, ProviderResult, ProviderTestInput, ProviderTestResult, @@ -155,6 +156,7 @@ export type ElectronAPI = { start: () => Promise logout: () => Promise setMode: (mode: AuthMode) => Promise + enableProxy: () => Promise subscribe: (cb: (state: AuthState) => void) => () => void } // Extension Hub (定制中心): thin privileged operations the renderer can't do itself. persistMcp @@ -190,5 +192,9 @@ export type ElectronAPI = { providers: { add: (input: ProviderInput) => Promise 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). */ + 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 1d26b45b2250..b4697425ca67 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 @@ -4,8 +4,8 @@ // 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). -import { createMemo, createSignal, For, Show } from "solid-js" -import type { AlphaModelCatalog, ByokProvider } from "../../shared/alpha-model-types" +import { createMemo, createSignal, For, onMount, Show } from "solid-js" +import type { AlphaModelCatalog, ByokProvider, ProviderKeyStatus } from "../../shared/alpha-model-types" function slug(s: string): string { return ( @@ -17,7 +17,15 @@ function slug(s: string): string { ) } -export function AddProvider(props: { catalog: AlphaModelCatalog | null; onClose: () => void; onSaved?: () => void }) { +export function AddProvider(props: { + catalog: AlphaModelCatalog | null + onClose: () => void + onSaved?: () => void + /** When set, open straight into this provider's config form (e.g. clicking a 需 Key BYOK row). */ + initialId?: string + /** Per-provider key state (drives 已配置 / 替换 / 移除 for a provider that already has a key). */ + keyStatus?: ProviderKeyStatus +}) { const [sel, setSel] = createSignal(null) // null = step 1 (preset list) const [name, setName] = createSignal("") const [compat, setCompat] = createSignal<"openai" | "anthropic">("openai") @@ -39,6 +47,12 @@ export function AddProvider(props: { catalog: AlphaModelCatalog | null; onClose: const isCustom = () => sel() === "custom" const inForm = () => sel() !== null + // Key state for the provider currently open in the form (preset only; a fresh custom has none yet). + const currentStatus = createMemo(() => { + const s = sel() + if (!s || s === "custom") return undefined + return props.keyStatus?.[(s as ByokProvider).id] + }) const title = () => (sel() === "custom" ? "自定义端点" : sel() ? (sel() as ByokProvider).name : "添加节点 / 供应商") function openPreset(p: ByokProvider) { @@ -63,6 +77,14 @@ export function AddProvider(props: { catalog: AlphaModelCatalog | null; onClose: setTest({ s: "idle", msg: "" }) setError("") } + + // Opened to configure a specific provider (a 需 Key row) → jump straight to its form. + onMount(() => { + const id = props.initialId + if (!id) return + const p = props.catalog?.byokProviders.find((x) => x.id === id) + if (p) openPreset(p) + }) function back() { if (inForm()) { setSel(null) @@ -97,6 +119,17 @@ export function AddProvider(props: { catalog: AlphaModelCatalog | null; onClose: return } const id = isCustom() ? slug(name()) : (sel() as ByokProvider).id + // Empty key on an already-configured provider = keep the existing key (don't overwrite). Only + // require a key when none is configured yet. + if (!apiKey().trim()) { + if (currentStatus()?.configured) { + props.onSaved?.() + props.onClose() + return + } + setError("请填写 API Key") + return + } setSaving(true) const r = await window.api.providers.add({ id, @@ -113,6 +146,25 @@ export function AddProvider(props: { catalog: AlphaModelCatalog | null; onClose: } else setError(r.reason) } + // Remove the stored key. Config keys are removed via opencode.jsonc; env keys can't be touched from + // here (they live in alpha.env) — tell the user where to clear them. + async function removeKey() { + const s = sel() + if (!s || s === "custom") return + const p = s as ByokProvider + if (currentStatus()?.source === "env") { + setError(`该 Key 来自环境变量,请在 alpha.env 中删除 ${p.keyEnv}`) + return + } + setSaving(true) + const r = await window.api.providers.remove(p.id) + setSaving(false) + if (r.ok) { + props.onSaved?.() + props.onClose() + } else setError(r.reason) + } + return (
e.stopPropagation()}>
@@ -138,6 +190,9 @@ export function AddProvider(props: { catalog: AlphaModelCatalog | null; onClose: {p.name} {p.compat === "anthropic" ? "Anthropic 兼容" : "OpenAI 兼容"} · 填 Key 即用 + + 已配置 + )} @@ -198,18 +253,39 @@ export function AddProvider(props: { catalog: AlphaModelCatalog | null; onClose:
+ +
+ 已配置 ••••{currentStatus()?.hint ?? ""} + {currentStatus()?.source === "env" ? "来源:环境变量" : "来源:配置"} + + + +
+
setApiKey(e.currentTarget.value)} - placeholder="sk-..." + placeholder={currentStatus()?.configured ? "留空 = 保留现有 Key,或粘贴新 Key 替换" : "sk-..."} /> + +

+ 该 Key 来自环境变量 {(sel() as ByokProvider).keyEnv}。保存新 Key 将写入配置并以其为准;要清除请删除 + alpha.env 中该项。 +

+
{(r) => }
国内直连 · 自带 KEY (BYOK) +
{(r) => }
@@ -252,12 +303,25 @@ export function ModelPickerInject() {
-
- setAddOpen(false)} /> + { + setAddOpen(false) + setConfigureId(null) + }} + onSaved={() => void refreshKeyStatus()} + /> diff --git a/packages/ui-mac/src/renderer/alpha-ui/model-picker-reskin.css b/packages/ui-mac/src/renderer/alpha-ui/model-picker-reskin.css index 463c2db3a57e..9a63c6b5c8a1 100644 --- a/packages/ui-mac/src/renderer/alpha-ui/model-picker-reskin.css +++ b/packages/ui-mac/src/renderer/alpha-ui/model-picker-reskin.css @@ -103,6 +103,40 @@ font-size: var(--a-text-2xs); color: var(--a-text-tertiary); } +/* "启用代理 · 重启" — shown when logged-in + funded but the proxy isn't live yet (one-time after first + * login). A real CTA, unlike the old dead-end that routed healthy accounts to recharge/re-login. */ +.a-mp2-activate { + margin-left: auto; + flex: none; + font-size: var(--a-text-2xs); + font-weight: var(--a-weight-semibold); + padding: 3px 9px; + border: 0; + border-radius: var(--a-radius-full); + cursor: pointer; + background: var(--a-accent-solid); + color: #fff; +} +.a-mp2-activate:hover { + background: var(--a-accent-solid-hover); +} +/* "管理" — opens the provider flow (now a manage surface: cards show 已配置, drilling in = 替换/移除). */ +.a-mp2-manage { + margin-left: auto; + flex: none; + font-size: var(--a-text-2xs); + font-weight: var(--a-weight-semibold); + padding: 3px 9px; + border: 0; + border-radius: var(--a-radius-full); + background: var(--a-bg-muted); + color: var(--a-text-secondary); + cursor: pointer; +} +.a-mp2-manage:hover { + background: var(--a-overlay-hover); + color: var(--a-text); +} .a-mp2-row { display: flex; align-items: center; @@ -266,6 +300,17 @@ color: var(--a-text-tertiary); font-variant-numeric: tabular-nums; } +/* BYOK row with no key — shown instead of the tier pill; the row is locked and clicking opens the + * provider's configure form (so we never present a model that 401s at call time). */ +.a-mp-needkey { + flex: none; + font-size: var(--a-text-2xs); + font-weight: var(--a-weight-semibold); + padding: 2px 7px; + border-radius: var(--a-radius-full); + background: var(--a-warning-subtle); + color: var(--a-warning); +} .a-mp-grouptag { font-size: 9.5px; font-weight: var(--a-weight-semibold); @@ -439,6 +484,16 @@ border-style: dashed; margin-top: 6px; } +/* "已配置" badge on a provider card in the chooser — turns the add flow into a manage list. */ +.a-mpa-pstate { + flex: none; + font-size: var(--a-text-2xs); + font-weight: var(--a-weight-semibold); + padding: 2px 7px; + border-radius: var(--a-radius-full); + background: var(--a-success-subtle); + color: var(--a-success); +} .a-mpa-plus { width: 24px; height: 24px; @@ -495,6 +550,44 @@ cursor: pointer; font-weight: var(--a-weight-medium); } +/* "已配置 ••••1234 · 来源 · 移除" — shown above the key input when the provider already has a key, so + * editing is replace/keep/remove (not blind re-entry). The full key never reaches the renderer. */ +.a-mpa-keystate { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 7px; +} +.a-mpa-keystate .ks-badge { + font-family: var(--a-font-mono); + font-size: var(--a-text-2xs); + font-weight: var(--a-weight-semibold); + padding: 3px 8px; + border-radius: var(--a-radius-full); + background: var(--a-success-subtle); + color: var(--a-success); +} +.a-mpa-keystate .ks-src { + font-size: var(--a-text-2xs); + color: var(--a-text-tertiary); +} +.a-mpa-keystate .ks-remove { + margin-left: auto; + font-size: var(--a-text-2xs); + font-weight: var(--a-weight-medium); + color: var(--a-error); + background: none; + border: 0; + cursor: pointer; + padding: 2px 4px; +} +.a-mpa-keystate .ks-remove:hover { + text-decoration: underline; +} +.a-mpa-keystate .ks-remove:disabled { + opacity: 0.5; + cursor: default; +} .a-mpa-input { width: 100%; height: 36px; diff --git a/packages/ui-mac/src/shared/alpha-model-types.ts b/packages/ui-mac/src/shared/alpha-model-types.ts index 91309ae2cea3..294cf26a9e20 100644 --- a/packages/ui-mac/src/shared/alpha-model-types.ts +++ b/packages/ui-mac/src/shared/alpha-model-types.ts @@ -63,3 +63,11 @@ export type ProviderTestInput = { } export type ProviderResult = { ok: true } | { ok: false; reason: string } export type ProviderTestResult = { ok: true; ms: number } | { ok: false; reason: string } + +// Per-provider BYOK key state for the picker. Builtin providers are injected as opencode CONFIG +// providers (alpha-models.ts), so opencode lists their models whether or not a key exists — the +// 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 ProviderKeyStatus = Record