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
34 changes: 34 additions & 0 deletions packages/ui-mac/scripts/patch-server-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env bun
// A4 fix — give the embedded opencode server a REAL InstallationVersion.
//
// The embedded server is bundled by upstream `packages/opencode/script/build-node.ts`, which defines
// OPENCODE_CHANNEL but NOT OPENCODE_VERSION. So the bundle's
// InstallationVersion = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
// resolves to "local". When any project with `.opencode` plugins loads, opencode's waitForDependencies
// tries to install `@opencode-ai/plugin@local` — which doesn't exist on npm — and the first request
// hangs (register A4; logged ~152×). We can't add a build `define` to the upstream build script
// (ADR-005: only-add, never edit `packages/opencode/**`), so we patch the BUILD OUTPUT (gitignored
// dist/node/node.js) instead: swap the "local" fallback for a real, npm-installable version.
//
// Purely additive alpha step (runs in prebuild after build-node). If upstream ever rewrites the
// expression, the substring won't match → we WARN and no-op (falls back to "local", loudly), so this
// can't silently rot. Bump ALPHA_OPENCODE_VERSION on upstream sync if you want it to track releases.
import path from "node:path"

// Latest published @opencode-ai/plugin (npm) — any real version makes the plugin install resolve.
const VERSION = process.env.ALPHA_OPENCODE_VERSION ?? "1.17.13"
const FILE = path.resolve(import.meta.dir, "../../opencode/dist/node/node.js")
const FROM = 'typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"'
const TO = `typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "${VERSION}"`

const text = await Bun.file(FILE).text()
if (!text.includes(FROM)) {
console.warn(
`[alpha:patch-server-version] InstallationVersion fallback not found in ${path.basename(FILE)} — ` +
`upstream reworded it? Embedded server may report "local" and A4 (@opencode-ai/plugin@local) can recur. ` +
`Update scripts/patch-server-version.ts.`,
)
process.exit(0)
}
await Bun.write(FILE, text.replaceAll(FROM, TO))
console.log(`[alpha:patch-server-version] embedded opencode InstallationVersion "local" → "${VERSION}" (A4)`)
4 changes: 4 additions & 0 deletions packages/ui-mac/scripts/prebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ await $`bun ./scripts/copy-metainfo.ts ${channel}`
await $`cd ../ext && bun run build`

await $`cd ../opencode && bun script/build-node.ts`

// A4: patch the freshly-built embedded server so InstallationVersion isn't "local"
// (else @opencode-ai/plugin@local install fails for any .opencode-plugin project). See the script.
await $`bun ./scripts/patch-server-version.ts`
131 changes: 131 additions & 0 deletions packages/ui-mac/src/main/alpha-models.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Unit tests for the sidecar-injected model config assembly (alpha-models.ts). This is the load-bearing
// logic that decides which providers/models opencode sees: BYOK direct nodes (only when keyed), the
// alpha platform gateway (only when ALPHA_BASE_URL is set), and user custom providers merged into the
// hard allowlist. All driven by process.env + the user's opencode.jsonc (via OPENCODE_CONFIG_DIR).

import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { buildAlphaModelConfig, getModelCatalog } from "./alpha-models"

// Every env var this module reads — snapshot + restore so tests don't leak into each other or the host.
const MANAGED = [
"ALPHA_MODELS_DISABLE",
"ALPHA_BASE_URL",
"ALPHA_API_KEY",
"ALPHA_DEFAULT_MODEL",
"DEEPSEEK_API_KEY",
"ZHIPU_API_KEY",
"MINIMAX_API_KEY",
"DASHSCOPE_API_KEY",
"MOONSHOT_API_KEY",
"OPENCODE_CONFIG_DIR",
]
const saved: Record<string, string | undefined> = {}
let tmp = ""

beforeEach(() => {
for (const k of MANAGED) {
saved[k] = process.env[k]
delete process.env[k]
}
// Empty config dir → readUserProviderIds() sees no user providers (isolates the byok/gateway logic).
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "alpha-models-"))
process.env.OPENCODE_CONFIG_DIR = tmp
})
afterEach(() => {
for (const k of MANAGED) {
if (saved[k] === undefined) delete process.env[k]
else process.env[k] = saved[k]
}
try {
fs.rmSync(tmp, { recursive: true, force: true })
} catch {
/* best effort */
}
})

describe("getModelCatalog", () => {
test("exposes the catalog with the expected shape", () => {
const c = getModelCatalog()
expect(c.platformProvider?.id).toBe("alpha")
expect(Array.isArray(c.byokProviders)).toBe(true)
expect(c.byokProviders.some((p) => p.id === "deepseek")).toBe(true)
})
})

describe("buildAlphaModelConfig — escape hatch + empty state", () => {
test("ALPHA_MODELS_DISABLE=1 returns undefined (opencode uses its own defaults)", () => {
process.env.ALPHA_MODELS_DISABLE = "1"
expect(buildAlphaModelConfig()).toBeUndefined()
})

test("no keys / no gateway / no user providers → empty allowlist, no forced default", () => {
const cfg = buildAlphaModelConfig()
expect(cfg).toBeDefined()
expect(cfg!.enabled_providers).toEqual([])
expect(cfg!.provider).toEqual({})
// defaultModel is null in the catalog and no env override → never force a `model`
expect(cfg!.model).toBeUndefined()
})
})

describe("buildAlphaModelConfig — BYOK direct nodes (only when keyed)", () => {
test("an openai-compat provider is injected only when its keyEnv is set, with the inlined key", () => {
process.env.DEEPSEEK_API_KEY = "sk-deepseek-123"
const cfg = buildAlphaModelConfig()!
expect(cfg.enabled_providers).toContain("deepseek")
const p = cfg.provider.deepseek as any
expect(p.npm).toBe("@ai-sdk/openai-compatible")
expect(p.options.apiKey).toBe("sk-deepseek-123")
expect(Object.keys(p.models).length).toBeGreaterThan(0)
})

test("an anthropic-compat provider uses the anthropic sdk npm", () => {
process.env.ZHIPU_API_KEY = "sk-zhipu-xyz"
const p = buildAlphaModelConfig()!.provider.zhipuai as any
expect(p.npm).toBe("@ai-sdk/anthropic")
expect(p.options.apiKey).toBe("sk-zhipu-xyz")
})

test("keyless catalog providers are NOT injected", () => {
const cfg = buildAlphaModelConfig()!
expect(cfg.provider.deepseek).toBeUndefined()
expect(cfg.provider.moonshot).toBeUndefined()
})
})

describe("buildAlphaModelConfig — platform gateway (代发)", () => {
test("the alpha platform provider joins the FRONT of the allowlist only when ALPHA_BASE_URL is set", () => {
process.env.ALPHA_BASE_URL = "https://gw.example/v1"
process.env.DEEPSEEK_API_KEY = "sk-deepseek-123" // a BYOK node too, to prove ordering
const cfg = buildAlphaModelConfig()!
expect(cfg.enabled_providers[0]).toBe("alpha") // unshifted to the front
const p = cfg.provider.alpha as any
expect(p.options.baseURL).toBe("https://gw.example/v1")
// key is fronted by ALPHA (代发) as an env ref, never inlined
expect(p.options.apiKey).toBe("{env:ALPHA_API_KEY}")
expect(Object.keys(p.models).length).toBeGreaterThan(0)
})

test("without ALPHA_BASE_URL there is no platform provider", () => {
expect(buildAlphaModelConfig()!.provider.alpha).toBeUndefined()
})
})

describe("buildAlphaModelConfig — default model + user providers", () => {
test("ALPHA_DEFAULT_MODEL overrides the (null) catalog default", () => {
process.env.ALPHA_DEFAULT_MODEL = "deepseek/deepseek-chat"
expect(buildAlphaModelConfig()!.model).toBe("deepseek/deepseek-chat")
})

test("user custom providers in opencode.jsonc are merged into the allowlist (survive the hard reset)", () => {
fs.writeFileSync(
path.join(tmp, "opencode.jsonc"),
JSON.stringify({ provider: { myco: { npm: "@ai-sdk/openai-compatible", options: {} } } }),
)
const cfg = buildAlphaModelConfig()!
expect(cfg.enabled_providers).toContain("myco")
})
})
102 changes: 102 additions & 0 deletions packages/ui-mac/src/main/alpha-provider-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Unit tests for the picker's BYOK key-status logic (alpha-provider-status.ts). The source-priority
// (keychain > env > opencode.jsonc inline > none) and the last-4 masking are what the model picker
// shows as 已配置/需配置 — worth locking down. The keychain (alpha-byok-keys → electron safeStorage) is
// mocked so this runs off-device; the env + opencode.jsonc paths are driven for real via a temp config.

import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"

// Mutable so each test can set what the "keychain" holds. The mock closure reads it by reference.
let mockKeychain = new Map<string, string>()
mock.module("./alpha-byok-keys", () => ({ byokKeyMap: () => mockKeychain }))

const { getProviderKeyStatus } = await import("./alpha-provider-status")

const MANAGED = ["DEEPSEEK_API_KEY", "ZHIPU_API_KEY", "MINIMAX_API_KEY", "DASHSCOPE_API_KEY", "MOONSHOT_API_KEY", "OPENCODE_CONFIG_DIR"]
const saved: Record<string, string | undefined> = {}
let tmp = ""

beforeEach(() => {
mockKeychain = new Map()
for (const k of MANAGED) {
saved[k] = process.env[k]
delete process.env[k]
}
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "alpha-provstatus-"))
process.env.OPENCODE_CONFIG_DIR = tmp
})
afterEach(() => {
for (const k of MANAGED) {
if (saved[k] === undefined) delete process.env[k]
else process.env[k] = saved[k]
}
try {
fs.rmSync(tmp, { recursive: true, force: true })
} catch {
/* best effort */
}
})

function writeInlineKey(providerId: string, apiKey: string) {
fs.writeFileSync(path.join(tmp, "opencode.jsonc"), JSON.stringify({ provider: { [providerId]: { options: { apiKey } } } }))
}

describe("getProviderKeyStatus — source priority", () => {
test("nothing configured → every catalog provider is { configured:false, source:'none' }", () => {
const s = getProviderKeyStatus()
expect(s.deepseek).toEqual({ configured: false, source: "none" })
expect(s.zhipuai.configured).toBe(false)
})

test("keychain key → source 'keychain' with a masked last-4 hint", () => {
mockKeychain.set("deepseek", "sk-abcdef1234")
const s = getProviderKeyStatus()
expect(s.deepseek).toEqual({ configured: true, source: "keychain", hint: "1234" })
})

test("env keyEnv (no keychain) → source 'env'", () => {
process.env.DEEPSEEK_API_KEY = "sk-envkey9876"
const s = getProviderKeyStatus()
expect(s.deepseek).toEqual({ configured: true, source: "env", hint: "9876" })
})

test("opencode.jsonc inline apiKey (no keychain/env) → source 'config'", () => {
writeInlineKey("deepseek", "sk-configkeyWXYZ")
const s = getProviderKeyStatus()
expect(s.deepseek.configured).toBe(true)
expect(s.deepseek.source).toBe("config")
expect(s.deepseek.hint).toBe("WXYZ")
})

test("keychain beats env beats config", () => {
mockKeychain.set("deepseek", "sk-fromKeychainAAAA")
process.env.DEEPSEEK_API_KEY = "sk-fromEnvBBBB"
writeInlineKey("deepseek", "sk-fromConfigCCCC")
expect(getProviderKeyStatus().deepseek.source).toBe("keychain")
})
})

describe("getProviderKeyStatus — masking + off-catalog", () => {
test("keys shorter than 4 chars are masked to bullets, not leaked", () => {
mockKeychain.set("deepseek", "ab")
expect(getProviderKeyStatus().deepseek.hint).toBe("••")
})

test("an off-catalog provider present only in the keychain is still surfaced", () => {
mockKeychain.set("mycustom", "sk-offcatalog7890")
const s = getProviderKeyStatus()
expect(s.mycustom).toEqual({ configured: true, source: "keychain", hint: "7890" })
})

test("never leaks the full key — only a <=4 char hint", () => {
const key = "sk-verylongsecretkey-END"
mockKeychain.set("deepseek", key)
const hint = getProviderKeyStatus().deepseek.hint!
expect(hint).toBe("-END") // the last 4 chars only
expect(hint.length).toBe(4)
expect(key.includes(hint)).toBe(true)
expect(hint).not.toBe(key) // never the full secret
})
})
45 changes: 45 additions & 0 deletions packages/ui-mac/src/shared/alpha-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Contract lock for the single-source-of-truth endpoint/path constants (alpha-config.ts). These feed
// ALPHA_BASE_URL (the bearer-carrying proxy target) and every alpha↔backend URL, so a regression here
// (a plain-http host, a stray trailing slash, the old api.tidelabs.click gateway, a path missing its
// leading slash) is exactly the class of bug that broke /v1 before. Guard the invariants, not the exact
// domains (those may legitimately move) — except the known-bad host, which must never come back.

import { describe, expect, test } from "bun:test"
import { ALPHA_ENDPOINTS, ALPHA_PATHS } from "./alpha-config"

describe("ALPHA_ENDPOINTS", () => {
const required = ["web", "platform", "account", "cloud"] as const

test("has every required backend host", () => {
for (const k of required) expect(typeof (ALPHA_ENDPOINTS as any)[k]).toBe("string")
})

test.each(required)("%s is https, a valid URL, and has no trailing slash", (k) => {
const url = (ALPHA_ENDPOINTS as any)[k] as string
expect(url.startsWith("https://")).toBe(true)
expect(url.endsWith("/")).toBe(false)
expect(() => new URL(url)).not.toThrow()
})

test("platform gateway is NOT the old api.tidelabs.click host (which 404'd every /v1 route)", () => {
expect(ALPHA_ENDPOINTS.platform).not.toContain("tidelabs")
// it's the raw Worker that actually routes /v1
expect(ALPHA_ENDPOINTS.platform).toContain("workers.dev")
})
})

describe("ALPHA_PATHS", () => {
test("every path is a rooted URL segment (leading slash, no trailing slash)", () => {
for (const [key, p] of Object.entries(ALPHA_PATHS)) {
expect(p.startsWith("/"), `${key} must start with /`).toBe(true)
expect(p.endsWith("/"), `${key} must not end with /`).toBe(false)
}
})

test("the load-bearing routes are stable", () => {
expect(ALPHA_PATHS.modelProxy).toBe("/v1")
expect(ALPHA_PATHS.token).toBe("/auth/token")
expect(ALPHA_PATHS.mcpGateway).toBe("/mcp")
expect(ALPHA_PATHS.models).toBe("/v1/models")
})
})
Loading