@kinski/opencode-web-gateway 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 opencode-web-gateway contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,168 @@
1
- # Temporary Holding Version
1
+ # opencode-web-gateway
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 浏览器出网桥:让 opencode 的原生 **DeepSeek provider** 只访问本机,真正请求 DeepSeek 的 `fetch` 由网页完成。
4
+
5
+ 插件在本机 `127.0.0.1:8787` 启动端点;页面主动通过 WebSocket 连接插件,转发原始请求体和响应字节(包括 SSE),不解析、不改写。
6
+
7
+ - **不捆绑 opencode**:使用你已安装的 opencode;本包包含全局 CLI、插件和网页
8
+ - **只影响 DeepSeek 通路**:保留 opencode 原生 `deepseek` provider、原生模型目录和 `/connect`,只覆盖它的 `baseURL`
9
+ - **参数透传**:`temperature`、`top_p`、`tools`、reasoning 等由 opencode 原生 provider 构造,插件按字节转发
10
+ - **Key 原样透传**:只使用 `/connect` 或 `DEEPSEEK_API_KEY` 的凭据;插件不保存、不注入、不覆盖
11
+ - 插件内嵌完整网页,不需要额外前端资源
12
+ - 同一份插件支持 opencode v1(`server()`)和 v2(`setup()`)
13
+ - 默认只监听回环地址;跨源页面需要显式加入 `allowedOrigins`
14
+
15
+ ## 安装(推荐:全局 CLI)
16
+
17
+ ```bash
18
+ npm install -g @kinski/opencode-web-gateway
19
+ opencode-web-gateway
20
+ ```
21
+
22
+ `npm install -g` 会把 `opencode-web-gateway` 写到 PATH。之后在任何目录运行:
23
+
24
+ ```bash
25
+ opencode-web-gateway
26
+ # 不自动打开浏览器
27
+ opencode-web-gateway --no-open
28
+
29
+ # 直接跑一次
30
+ opencode-web-gateway run -m deepseek/deepseek-flash "hello"
31
+ ```
32
+
33
+ 它做三件事:
34
+
35
+ 1. 调用你系统已安装的 `opencode`,不捆绑 opencode;
36
+ 2. 通过额外的 opencode config source 加载本包插件,并只覆盖原生 `deepseek` 的 `baseURL`;
37
+ 3. 等本地桥接页就绪后自动打开页面。
38
+
39
+ 不会改写你的项目 `opencode.json`、全局 opencode 配置、`/connect` 或 auth 存储。除 Node.js 外不需要 bash、PowerShell 或额外的项目文件。
40
+
41
+ 要求:Node.js 20+,并且 `opencode` 已在 PATH(也可用 `OPENCODE_BIN` 指定)。
42
+
43
+ ## 可选:项目内手动配置
44
+
45
+ 如果不想用全局 CLI,也可以把本包作为普通 opencode 插件加载,并在项目配置里覆盖 DeepSeek `baseURL`:
46
+
47
+ opencode v2 的 `opencode.jsonc`:
48
+
49
+ ```jsonc
50
+ {
51
+ "$schema": "https://opencode.ai/config.json",
52
+ "plugin": ["opencode-web-gateway"]
53
+ }
54
+ ```
55
+
56
+ opencode v1 的 `opencode.json`:
57
+
58
+ ```json
59
+ {
60
+ "$schema": "https://opencode.ai/config.json",
61
+ "plugin": ["opencode-web-gateway"]
62
+ }
63
+ ```
64
+
65
+ ## 只覆盖原生 DeepSeek 的 baseURL
66
+
67
+ 不要创建自定义 provider。把原生 `deepseek` 的端点改到本地即可:
68
+
69
+ opencode v1:
70
+
71
+ ```json
72
+ {
73
+ "provider": {
74
+ "deepseek": {
75
+ "options": {
76
+ "baseURL": "http://127.0.0.1:8787/v1"
77
+ }
78
+ }
79
+ }
80
+ }
81
+ ```
82
+
83
+ opencode v2:
84
+
85
+ ```jsonc
86
+ {
87
+ "providers": {
88
+ "deepseek": {
89
+ "settings": {
90
+ "baseURL": "http://127.0.0.1:8787/v1"
91
+ }
92
+ }
93
+ }
94
+ }
95
+ ```
96
+
97
+ 完整示例见 [`examples/opencode.v1.json`](examples/opencode.v1.json) 和 [`examples/opencode.v2.jsonc`](examples/opencode.v2.jsonc)。
98
+
99
+ 原生模型目录会保留(当前为 `deepseek/deepseek-flash`、`deepseek/deepseek-v4-pro`),provider ID 仍是 `deepseek`,所以 `/connect`、模型 capability、工具调用和参数映射都不变。
100
+
101
+ ## 连接与 Key
102
+
103
+ 推荐直接使用 opencode 原生流程:
104
+
105
+ ```text
106
+ /connect -> DeepSeek -> 粘贴 API Key
107
+ ```
108
+
109
+ 或在启动前设置 `DEEPSEEK_API_KEY`。插件将 opencode 发来的 `Authorization` 原样转发给页面,不保存、不注入、不覆盖。
110
+
111
+ `web-gateway.config.json` 只用于上游、端口、跨源页面等传输配置:
112
+
113
+ ```json
114
+ {
115
+ "upstream": "https://api.deepseek.com",
116
+ "host": "127.0.0.1",
117
+ "port": 8787,
118
+ "allowedOrigins": [],
119
+ "waitForBridgeMs": 15000
120
+ }
121
+ ```
122
+
123
+ ## 使用
124
+
125
+ 1. 启动 opencode,`/connect` 连接 DeepSeek(或设置 `DEEPSEEK_API_KEY`)。
126
+ 2. 打开 <http://127.0.0.1:8787/>,保持标签页打开。
127
+ 3. 页面显示“已连接”后,在 opencode 中执行 `/models`,选择原生 DeepSeek 模型(如 `deepseek/deepseek-flash`)。
128
+ 4. 如果请求时页面还没打开,请求会等待 `waitForBridgeMs`(默认 15 秒),然后返回 503。
129
+
130
+ ## 托管页面(pages.dev)
131
+
132
+ 页面也可以部署到 Cloudflare Pages 等静态托管。页面会默认连接 `ws://127.0.0.1:8787/bridge`。
133
+
134
+ 跨源页面默认会被插件拒绝。部署后,在本机 `web-gateway.config.json` 加入页面来源:
135
+
136
+ ```json
137
+ {
138
+ "allowedOrigins": ["https://kinski-opencode-web-gateway.pages.dev"]
139
+ }
140
+ ```
141
+
142
+ 然后刷新 opencode/插件,再打开托管页面。HTTPS 页面连接本机回环 `ws://127.0.0.1` 已在 Chrome 验证;如果浏览器拦截混合内容,直接使用插件自带的 <http://127.0.0.1:8787/> 即可。
143
+
144
+ ## 配置项
145
+
146
+ | 字段 | 默认 | 说明 |
147
+ | --- | --- | --- |
148
+ | `upstream` | `https://api.deepseek.com` | 浏览器实际 fetch 的上游 |
149
+ | `host` | `127.0.0.1` | 监听地址;`0.0.0.0` 可供局域网页面连接 |
150
+ | `port` | `8787` | 本地端口,修改后同步修改 provider 的 `baseURL` |
151
+ | `allowedOrigins` | `[]` | 允许跨源 WebSocket 的页面来源列表(精确 origin) |
152
+ | `token` | `""` | 可选的页面访问令牌 |
153
+ | `waitForBridgeMs` | `15000` | 没有页面时请求等待时间 |
154
+
155
+ ## 安全
156
+
157
+ - 使用 `/connect` 时,Key 在 opencode 的 auth 存储中;插件不改写它,只在转发瞬间把 `Authorization` 交给页面。
158
+ - 页面 HTML、bootstrap、localStorage、日志都不保存 Key。
159
+ - 默认监听 `127.0.0.1`;跨源页面必须显式加入 `allowedOrigins`。
160
+ - 可使用 `token` 增加一道页面访问校验。
161
+ - 页面只转发,不解析 SSE;上游必须允许浏览器 CORS(DeepSeek 已支持)。
162
+
163
+ ## 兼容性
164
+
165
+ - 已测试:opencode **1.19.5**(v1 API)与 **2.2.0**(v2 API),包含原生 `/connect` 凭据透传。
166
+ - 1.18.x 的 Bun 运行时对 `node:http` upgrade socket 写入不刷新,不适用于本 WebSocket 桥。
167
+
168
+ MIT License
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env node
2
+ // Global CLI: run the system opencode with this package loaded as a plugin.
3
+ // No shell scripts and no changes to the opencode config on disk.
4
+
5
+ import { spawn, spawnSync } from "node:child_process"
6
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
7
+ import { tmpdir } from "node:os"
8
+ import path from "node:path"
9
+ import { fileURLToPath } from "node:url"
10
+
11
+ const here = path.dirname(fileURLToPath(import.meta.url))
12
+ const packageRoot = path.resolve(here, "..")
13
+ const packageJson = JSON.parse(readFileSync(path.join(packageRoot, "package.json"), "utf8"))
14
+
15
+ const help = `Usage: opencode-web-gateway [--no-open] [opencode args...]
16
+
17
+ Runs the system opencode with the opencode-web-gateway plugin loaded and
18
+ DeepSeek's baseURL pointed at the local bridge. Project opencode config is
19
+ preserved; the gateway is added through an extra config source.
20
+
21
+ Options:
22
+ --no-open Do not open the bridge page in a browser
23
+ -h, --help Show this help
24
+ -v, --version Print the CLI version
25
+
26
+ Everything after the options is passed to opencode, for example:
27
+ opencode-web-gateway run -m deepseek/deepseek-flash "hello"
28
+ `
29
+
30
+ let args = process.argv.slice(2)
31
+ let noOpen = false
32
+
33
+ if (args[0] === "--help" || args[0] === "-h") {
34
+ process.stdout.write(help)
35
+ process.exit(0)
36
+ }
37
+ if (args[0] === "--version" || args[0] === "-v") {
38
+ console.log(packageJson.version)
39
+ process.exit(0)
40
+ }
41
+ while (args[0] === "--no-open") {
42
+ noOpen = true
43
+ args = args.slice(1)
44
+ }
45
+
46
+ const DEFAULT_CONFIG = {
47
+ upstream: "https://api.deepseek.com",
48
+ host: "127.0.0.1",
49
+ port: 8787,
50
+ allowedOrigins: [],
51
+ waitForBridgeMs: 15000,
52
+ }
53
+
54
+ function fail(message) {
55
+ console.error(message)
56
+ process.exitCode = 1
57
+ }
58
+
59
+ function readConfig() {
60
+ const candidates = [
61
+ process.env.OPENCODE_WEB_GATEWAY_CONFIG,
62
+ path.join(process.cwd(), "web-gateway.config.json"),
63
+ path.join(process.cwd(), ".opencode", "web-gateway.config.json"),
64
+ ].filter(Boolean)
65
+
66
+ for (const candidate of candidates) {
67
+ if (!existsSync(candidate)) continue
68
+ try {
69
+ const value = JSON.parse(readFileSync(candidate, "utf8"))
70
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue
71
+ return { path: path.resolve(candidate), value }
72
+ } catch {
73
+ console.warn(`warning: ignoring invalid config ${candidate}`)
74
+ }
75
+ }
76
+ return null
77
+ }
78
+
79
+ function displayHost(host) {
80
+ if (host === "0.0.0.0") return "127.0.0.1"
81
+ if (host === "::" || host === "0:0:0:0:0:0:0:0") return "[::1]"
82
+ if (host.includes(":") && !host.startsWith("[")) return `[${host}]`
83
+ return host
84
+ }
85
+
86
+ function openBrowser(url) {
87
+ try {
88
+ if (process.platform === "win32") {
89
+ spawn(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `start "" "${url}"`], {
90
+ stdio: "ignore",
91
+ windowsHide: true,
92
+ }).unref()
93
+ } else if (process.platform === "darwin") {
94
+ spawn("open", [url], { stdio: "ignore", detached: true }).unref()
95
+ } else {
96
+ spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref()
97
+ }
98
+ } catch {
99
+ // The URL is printed above; opening a browser is best-effort.
100
+ }
101
+ }
102
+
103
+ async function openWhenReady(url, child) {
104
+ const deadline = Date.now() + 10_000
105
+ while (Date.now() < deadline) {
106
+ if (child.exitCode !== null) return
107
+ try {
108
+ const response = await fetch(`${url}healthz`, { signal: AbortSignal.timeout(500) })
109
+ if (response.ok) {
110
+ openBrowser(url)
111
+ return
112
+ }
113
+ } catch {
114
+ // server not up yet
115
+ }
116
+ await new Promise((resolve) => setTimeout(resolve, 250))
117
+ }
118
+ openBrowser(url)
119
+ }
120
+
121
+ const opencode = process.env.OPENCODE_BIN || (process.platform === "win32" ? "opencode.exe" : "opencode")
122
+ const probe = spawnSync(opencode, ["--version"], {
123
+ encoding: "utf8",
124
+ windowsHide: true,
125
+ })
126
+ if (probe.error || probe.status !== 0) {
127
+ fail(
128
+ `opencode was not found (${opencode}).\n` +
129
+ "Install opencode with its normal installer, or set OPENCODE_BIN to the executable.",
130
+ )
131
+ process.exit()
132
+ }
133
+
134
+ const opencodeVersionText = `${probe.stdout || ""}\n${probe.stderr || ""}`
135
+ const opencodeVersion = (opencodeVersionText.match(/\d+\.\d+\.\d+/) || ["unknown"])[0]
136
+ const opencodeMajor = Number(opencodeVersion.split(".")[0]) || 0
137
+
138
+ const found = readConfig()
139
+ const config = { ...DEFAULT_CONFIG, ...(found ? found.value : {}) }
140
+ const port = Number(config.port) || DEFAULT_CONFIG.port
141
+ const host = String(config.host || DEFAULT_CONFIG.host)
142
+ const pageUrl = `http://${displayHost(host)}:${port}/`
143
+
144
+ const temp = mkdtempSync(path.join(tmpdir(), "opencode-web-gateway-"))
145
+ const cleanup = () => {
146
+ try {
147
+ rmSync(temp, { recursive: true, force: true })
148
+ } catch {
149
+ // best effort
150
+ }
151
+ }
152
+ process.on("exit", cleanup)
153
+
154
+ let configPath = found ? found.path : ""
155
+ if (!configPath) {
156
+ configPath = path.join(temp, "web-gateway.config.json")
157
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8")
158
+ }
159
+
160
+ const baseURL = `http://${displayHost(host)}:${port}/v1`
161
+ const pluginPath = packageRoot.replace(/\\/g, "/")
162
+ const overlay = {
163
+ plugin: [pluginPath],
164
+ provider: {
165
+ deepseek: {
166
+ options: { baseURL },
167
+ },
168
+ },
169
+ providers: {
170
+ deepseek: {
171
+ settings: { baseURL },
172
+ },
173
+ },
174
+ }
175
+ const overlayPath = path.join(temp, "opencode.json")
176
+ writeFileSync(overlayPath, `${JSON.stringify(overlay, null, 2)}\n`, "utf8")
177
+
178
+ const env = {
179
+ ...process.env,
180
+ OPENCODE_WEB_GATEWAY_CONFIG: configPath,
181
+ }
182
+
183
+ if (!process.env.OPENCODE_CONFIG) {
184
+ env.OPENCODE_CONFIG = overlayPath
185
+ } else if (!process.env.OPENCODE_CONFIG_CONTENT) {
186
+ // Keep the caller's OPENCODE_CONFIG and add the bridge overlay as a document.
187
+ env.OPENCODE_CONFIG_CONTENT = JSON.stringify(overlay)
188
+ } else {
189
+ fail(
190
+ "Both OPENCODE_CONFIG and OPENCODE_CONFIG_CONTENT are set in this environment.\n" +
191
+ "opencode-web-gateway needs one config slot for its overlay; unset one of them.",
192
+ )
193
+ process.exit()
194
+ }
195
+
196
+ const finalArgs = [...args]
197
+ const has = (flag) => finalArgs.includes(flag)
198
+ if (opencodeMajor >= 2 && !has("--standalone") && !has("--server") && !has("--version") && !has("-v") && !has("--help") && !has("-h")) {
199
+ finalArgs.unshift("--standalone")
200
+ }
201
+
202
+ const firstCommand = args.find((arg) => !arg.startsWith("-"))
203
+ const wantsPage = !noOpen && (!firstCommand || firstCommand === "run" || firstCommand === "serve")
204
+
205
+ console.log(`opencode-web-gateway ${packageJson.version}`)
206
+ console.log(` opencode ${opencodeVersion}`)
207
+ console.log(` page ${pageUrl}`)
208
+ console.log(` config ${configPath}`)
209
+ if (noOpen) console.log(" browser disabled (--no-open)")
210
+
211
+ let child
212
+ try {
213
+ child = spawn(opencode, finalArgs, {
214
+ cwd: process.cwd(),
215
+ env,
216
+ stdio: "inherit",
217
+ windowsHide: false,
218
+ })
219
+ } catch (error) {
220
+ fail(`could not start opencode: ${error instanceof Error ? error.message : String(error)}`)
221
+ process.exit()
222
+ }
223
+
224
+ child.on("error", (error) => {
225
+ fail(`could not start opencode: ${error.message}`)
226
+ cleanup()
227
+ })
228
+ child.on("exit", (code, signal) => {
229
+ cleanup()
230
+ process.exitCode = code ?? (signal ? 1 : 0)
231
+ })
232
+
233
+ if (wantsPage) {
234
+ void openWhenReady(pageUrl, child)
235
+ }