Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions packages/ui-mac/src/main/alpha-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -316,3 +319,17 @@ export async function setAuthMode(mode: AuthMode): Promise<void> {
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()
}
29 changes: 27 additions & 2 deletions packages/ui-mac/src/main/alpha-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
34 changes: 34 additions & 0 deletions packages/ui-mac/src/main/ext-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
const out = new Map<string, string>()
try {
const target = userConfigPath()
if (!fs.existsSync(target)) return out
const parsed = parse(fs.readFileSync(target, "utf8")) as { provider?: Record<string, unknown> } | 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.
Expand Down
2 changes: 2 additions & 0 deletions packages/ui-mac/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { spawnWslSidecar } from "./wsl/sidecar"
import { migrate } from "./migrate"
import { ensureAlphaLayoutDefault } from "./alpha-defaults"
import {
enableProxy,
getAuthState,
handleAuthDeepLink,
initAuthEnv,
Expand Down Expand Up @@ -306,6 +307,7 @@ const main = Effect.gen(function* () {
start: () => startAuth(),
logout: () => authLogout(),
setMode: (mode) => setAuthMode(mode),
enableProxy: () => enableProxy(),
},
})
registerWslIpcHandlers(wslServers)
Expand Down
2 changes: 2 additions & 0 deletions packages/ui-mac/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ type Deps = {
start: () => Promise<void>
logout: () => Promise<void>
setMode: (mode: AuthMode) => Promise<void>
enableProxy: () => void
}
}

Expand Down Expand Up @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion packages/ui-mac/src/main/provider-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
3 changes: 3 additions & 0 deletions packages/ui-mac/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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),
},
}

Expand Down
6 changes: 6 additions & 0 deletions packages/ui-mac/src/preload/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { UpdaterState } from "@opencode-ai/app/updater"
import type {
AlphaModelCatalog,
ProviderInput,
ProviderKeyStatus,
ProviderResult,
ProviderTestInput,
ProviderTestResult,
Expand Down Expand Up @@ -155,6 +156,7 @@ export type ElectronAPI = {
start: () => Promise<void>
logout: () => Promise<void>
setMode: (mode: AuthMode) => Promise<void>
enableProxy: () => Promise<void>
subscribe: (cb: (state: AuthState) => void) => () => void
}
// Extension Hub (定制中心): thin privileged operations the renderer can't do itself. persistMcp
Expand Down Expand Up @@ -190,5 +192,9 @@ export type ElectronAPI = {
providers: {
add: (input: ProviderInput) => Promise<ProviderResult>
test: (input: ProviderTestInput) => Promise<ProviderTestResult>
/** Read-only BYOK key state per provider id (drives the picker's 需 Key / 已配置 gating). */
keyStatus: () => Promise<ProviderKeyStatus>
/** Remove a provider's inline key/definition from opencode.jsonc (env keys untouched). */
remove: (id: string) => Promise<ProviderResult>
}
}
86 changes: 81 additions & 5 deletions packages/ui-mac/src/renderer/alpha-ui/model-picker-add.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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<ByokProvider | "custom" | null>(null) // null = step 1 (preset list)
const [name, setName] = createSignal("")
const [compat, setCompat] = createSignal<"openai" | "anthropic">("openai")
Expand All @@ -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) {
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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 (
<div class="a-mpa" onClick={(e) => e.stopPropagation()}>
<div class="a-mpa-head">
Expand All @@ -138,6 +190,9 @@ export function AddProvider(props: { catalog: AlphaModelCatalog | null; onClose:
<span class="nm">{p.name}</span>
<span class="sb">{p.compat === "anthropic" ? "Anthropic 兼容" : "OpenAI 兼容"} · 填 Key 即用</span>
</span>
<Show when={props.keyStatus?.[p.id]?.configured}>
<span class="a-mpa-pstate">已配置</span>
</Show>
<Chevron />
</button>
)}
Expand Down Expand Up @@ -198,18 +253,39 @@ export function AddProvider(props: { catalog: AlphaModelCatalog | null; onClose:
</div>
<div class="a-mpa-field">
<label>
API Key <span class="req">*</span>
API Key
<Show when={!currentStatus()?.configured}>
{" "}
<span class="req">*</span>
</Show>
<span class="a-mpa-keytoggle" onClick={() => setShowKey((v) => !v)}>
{showKey() ? "隐藏" : "明文"}
</span>
</label>
<Show when={currentStatus()?.configured}>
<div class="a-mpa-keystate">
<span class="ks-badge">已配置 ••••{currentStatus()?.hint ?? ""}</span>
<span class="ks-src">{currentStatus()?.source === "env" ? "来源:环境变量" : "来源:配置"}</span>
<Show when={currentStatus()?.source === "config"}>
<button class="ks-remove" onClick={removeKey} disabled={saving()}>
移除
</button>
</Show>
</div>
</Show>
<input
class="a-mpa-input mono"
type={showKey() ? "text" : "password"}
value={apiKey()}
onInput={(e) => setApiKey(e.currentTarget.value)}
placeholder="sk-..."
placeholder={currentStatus()?.configured ? "留空 = 保留现有 Key,或粘贴新 Key 替换" : "sk-..."}
/>
<Show when={currentStatus()?.configured && currentStatus()?.source === "env"}>
<p class="a-mpa-note">
该 Key 来自环境变量 {(sel() as ByokProvider).keyEnv}。保存新 Key 将写入配置并以其为准;要清除请删除
alpha.env 中该项。
</p>
</Show>
</div>
<div class="a-mpa-field">
<label>
Expand Down
Loading
Loading