@ebony-vinyl/dsh-our-free-model 2.8.0 → 2.8.2
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/README.md +12 -7
- package/client.js +894 -6
- package/index.js +245 -8
- package/package.json +4 -1
- package/src/adapter.js +54 -6
- package/src/aggregate.js +53 -13
- package/src/bun-fetch.js +33 -10
- package/src/catalog.js +140 -16
- package/src/core.js +122 -33
- package/src/egress.js +204 -3
- package/src/engine-parts/win32-x64.000.part +0 -0
- package/src/engine-parts/win32-x64.001.part +0 -0
- package/src/engine-parts/win32-x64.002.part +0 -0
- package/src/engine-parts/win32-x64.003.part +0 -0
- package/src/engine-parts/win32-x64.004.part +0 -0
- package/src/engine-parts/win32-x64.005.part +0 -0
- package/src/engine-parts/win32-x64.006.part +0 -0
- package/src/extensions.js +1888 -0
- package/src/feed.js +6 -0
- package/src/http.js +14 -3
- package/src/kilo.js +55 -5
- package/src/magpie-engine.js +56 -2
- package/src/magpie-models.js +31 -3
- package/src/model-test.js +3 -1
- package/src/o2a.js +207 -8
- package/src/open-external.js +43 -5
- package/src/plugins.js +1240 -0
- package/src/providers/registry.json +72 -30
- package/src/semver.js +40 -0
- package/src/store.js +17 -1
- package/src/stream.js +39 -4
- package/src/subscription-completion.js +11 -2
- package/src/updater.js +5 -33
- package/src/upstream.js +43 -0
- package/vendor/channel-pack/NOTICE.md +1 -1
- package/vendor/channel-pack/pack.js +7242 -3952
- package/vendor/magpie/NOTICE.md +7 -2
- package/vendor/magpie/engine.json +18 -12
package/src/egress.js
CHANGED
|
@@ -54,6 +54,8 @@ import tls from 'node:tls'
|
|
|
54
54
|
import http from 'node:http'
|
|
55
55
|
import os from 'node:os'
|
|
56
56
|
import path from 'node:path'
|
|
57
|
+
import { Readable } from 'node:stream'
|
|
58
|
+
import { pipeline } from 'node:stream/promises'
|
|
57
59
|
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
58
60
|
import { lookup } from 'node:dns/promises'
|
|
59
61
|
import { spawn } from 'node:child_process'
|
|
@@ -90,11 +92,100 @@ const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'p
|
|
|
90
92
|
*/
|
|
91
93
|
let activeRelay = null
|
|
92
94
|
|
|
95
|
+
/**
|
|
96
|
+
* The OpenCode lane's own proxy address, or null. This is the seam issue #708's
|
|
97
|
+
* "zen quota bypass" reduces to: the free tier's usage ceiling is counted per
|
|
98
|
+
* egress IP, so moving *this lane's* requests through a different exit is the
|
|
99
|
+
* one lever that actually moves the counter. The plugin supports it two ways —
|
|
100
|
+
* the same `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` environment variables the
|
|
101
|
+
* issue used, and an explicit per-lane address set from the settings page —
|
|
102
|
+
* and never rotates anything on its own.
|
|
103
|
+
*/
|
|
104
|
+
let freeLaneProxy = null
|
|
105
|
+
|
|
106
|
+
/** The parsed lane proxy, for the settings page to display. */
|
|
107
|
+
export function freeLaneProxyActive() {
|
|
108
|
+
return freeLaneProxy !== null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Point the OpenCode lane at one proxy address (`setFreeLaneProxy`).
|
|
113
|
+
*
|
|
114
|
+
* Accepts http/https/socks5/socks5h URLs — the same schemes the global egress
|
|
115
|
+
* outlet speaks — or null/'' to clear. Invalid addresses throw: a typo'd proxy
|
|
116
|
+
* must fail loudly at save time, not on the first turn.
|
|
117
|
+
*/
|
|
118
|
+
export function setFreeLaneProxy(url) {
|
|
119
|
+
const text = String(url ?? '').trim()
|
|
120
|
+
if (text === '') {
|
|
121
|
+
freeLaneProxy = null
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
const parsed = new URL(text)
|
|
125
|
+
if (!CLIENT_SCHEMES.has(parsed.protocol)) {
|
|
126
|
+
throw new Error(`unsupported proxy scheme "${parsed.protocol}" — use http, https, socks5 or socks5h`)
|
|
127
|
+
}
|
|
128
|
+
freeLaneProxy = parsed
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The proxy address the environment names for this lane (`envProxyFor`), or
|
|
133
|
+
* null. Exactly the three variables issue #708 used: `HTTPS_PROXY` (this
|
|
134
|
+
* lane's targets are https), falling back to `HTTP_PROXY`, minus anything
|
|
135
|
+
* `NO_PROXY` excludes. Only opencode.ai is ever matched here — the lane's own
|
|
136
|
+
* host — so a broad `NO_PROXY` cannot silently un-proxy the plugin's other
|
|
137
|
+
* traffic (those never consulted the environment to begin with).
|
|
138
|
+
*/
|
|
139
|
+
export function envProxyFor(hostname) {
|
|
140
|
+
const noProxy = String(process.env.NO_PROXY ?? process.env.no_proxy ?? '')
|
|
141
|
+
if (noProxy.trim() === '*') return undefined
|
|
142
|
+
const host = String(hostname ?? '').toLowerCase()
|
|
143
|
+
for (const entry of noProxy.split(',').map(item => item.trim().toLowerCase()).filter(Boolean)) {
|
|
144
|
+
const bare = entry.replace(/^\./, '')
|
|
145
|
+
if (host === bare || host.endsWith(`.${bare}`)) return undefined
|
|
146
|
+
}
|
|
147
|
+
const https = process.env.HTTPS_PROXY ?? process.env.https_proxy ?? process.env.httpsproxy
|
|
148
|
+
if (https) return new URL(String(https))
|
|
149
|
+
const httpProxy = process.env.HTTP_PROXY ?? process.env.http_proxy ?? process.env.httpproxy
|
|
150
|
+
if (httpProxy) return new URL(String(httpProxy))
|
|
151
|
+
return undefined
|
|
152
|
+
}
|
|
153
|
+
|
|
93
154
|
/** True when requests will be rewritten onto the relay instead of going out direct. */
|
|
94
155
|
export function egressActive() {
|
|
95
156
|
return activeRelay !== null
|
|
96
157
|
}
|
|
97
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Which exit the OpenCode lane would use right now, and what that exit's
|
|
161
|
+
* public address is (`laneProxyEcho`) — the settings page's "测试出口" button.
|
|
162
|
+
* The echo service is third-party on purpose: nothing here dials the free
|
|
163
|
+
* lane's gateway, so a test never spends quota. When the effective exit is the
|
|
164
|
+
* environment's or a custom address, the echo request dials through it (the
|
|
165
|
+
* same `openTunnel` the relay uses); a direct exit answers without a proxy.
|
|
166
|
+
*/
|
|
167
|
+
export async function laneProxyEcho({ signal, timeoutMs = 8000 } = {}) {
|
|
168
|
+
const target = new URL('https://api.ipify.org/?format=json')
|
|
169
|
+
const proxy = freeLaneProxy ?? envProxyFor(target.hostname) ?? null
|
|
170
|
+
const controller = new AbortController()
|
|
171
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
172
|
+
timer.unref?.()
|
|
173
|
+
signal?.addEventListener('abort', () => controller.abort(), { once: true })
|
|
174
|
+
try {
|
|
175
|
+
let response
|
|
176
|
+
if (proxy !== null) {
|
|
177
|
+
response = await laneProxiedFetch(target.href, { headers: { accept: 'application/json' }, signal: controller.signal }, proxy)
|
|
178
|
+
} else {
|
|
179
|
+
response = await fetch(target, { headers: { accept: 'application/json' }, signal: controller.signal, redirect: 'error' })
|
|
180
|
+
}
|
|
181
|
+
if (!response.ok) throw new Error(`the echo answered HTTP ${response.status}`)
|
|
182
|
+
const payload = await response.json()
|
|
183
|
+
return { ip: String(payload.ip ?? ''), proxied: proxy !== null, source: freeLaneProxy !== null ? 'custom' : proxy !== null ? 'env' : 'direct' }
|
|
184
|
+
} finally {
|
|
185
|
+
clearTimeout(timer)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
98
189
|
/**
|
|
99
190
|
* `fetch`, through the outlet when one is running and straight out when not.
|
|
100
191
|
*
|
|
@@ -112,12 +203,49 @@ export async function egressFetch(url, init) {
|
|
|
112
203
|
const officialFreeLane = targetUrl.protocol === 'https:' && targetUrl.hostname === 'opencode.ai'
|
|
113
204
|
&& (targetUrl.port === '' || targetUrl.port === '443') && targetUrl.pathname.startsWith('/inference/')
|
|
114
205
|
const transport = process.env.OUR_FREE_MODEL_FREE_TRANSPORT ?? 'auto'
|
|
115
|
-
if (officialFreeLane
|
|
206
|
+
if (officialFreeLane) {
|
|
207
|
+
// Lane exit priority: the user's per-lane address first (issue #708's
|
|
208
|
+
// method, made explicit), then the global egress outlet, then the
|
|
209
|
+
// environment's HTTP(S)_PROXY, then direct. A per-lane address deliberately
|
|
210
|
+
// bypasses the global outlet: the point of setting one is that this lane
|
|
211
|
+
// exits somewhere else than everything else.
|
|
212
|
+
const laneProxy = freeLaneProxy ?? (relay !== null ? new URL(`http://ofm:${relay.key}@${RELAY_HOST}:${relay.port}`) : envProxyFor(targetUrl.hostname) ?? null)
|
|
213
|
+
const isPost = (init?.method ?? (url instanceof Request ? url.method : 'GET')).toUpperCase() !== 'GET'
|
|
214
|
+
if (transport === 'node') {
|
|
215
|
+
if (relay !== null && relay.dead) throw new Error('the configured egress relay is not running')
|
|
216
|
+
if (laneProxy !== null) return laneProxiedFetch(url, init, laneProxy)
|
|
217
|
+
return fetch(url, init)
|
|
218
|
+
}
|
|
116
219
|
if (transport !== 'auto' && transport !== 'bun') throw new Error('OUR_FREE_MODEL_FREE_TRANSPORT must be auto, bun, or node')
|
|
220
|
+
// The GET listing may ride Node: it is a read, not an inference turn, and
|
|
221
|
+
// a machine without Bun must still be able to learn the roster (a missing
|
|
222
|
+
// Bun used to blind the whole catalog). Only the POST inference keeps the
|
|
223
|
+
// Bun requirement — the upstream fingerprints its client there.
|
|
224
|
+
//
|
|
225
|
+
// The read still leaves through the lane's own exit. An address chosen to
|
|
226
|
+
// move this lane is not un-chosen by the request being a GET: borrowing the
|
|
227
|
+
// real one for the roster would advertise a different client than the turns
|
|
228
|
+
// use. The global relay already carries it (its HTTP path, below); a custom
|
|
229
|
+
// or environment address needs the tunnel here.
|
|
230
|
+
if (!isPost) {
|
|
231
|
+
if (laneProxy !== null && freeLaneProxy !== null) return laneProxiedFetch(url, init, laneProxy)
|
|
232
|
+
if (laneProxy !== null && relay === null) return laneProxiedFetch(url, init, laneProxy)
|
|
233
|
+
if (relay !== null) return relayFetch(url, init, relay)
|
|
234
|
+
return fetch(url, init)
|
|
235
|
+
}
|
|
117
236
|
const executable = findBunExecutable()
|
|
118
|
-
if (!executable)
|
|
237
|
+
if (!executable) {
|
|
238
|
+
// This sentence is the user's only answer until they install Bun, so it
|
|
239
|
+
// carries the fix inline, bilingually, without an "upstream failed" wrap.
|
|
240
|
+
const error = new Error('OpenCode 免费车道推理需要本机 Bun:请安装 Bun(https://bun.sh 或 npm i -g bun)后重试,或把 Bun 可执行文件路径写入 OUR_FREE_MODEL_BUN 环境变量。/ Install Bun or set OUR_FREE_MODEL_BUN to its executable path (OUR_FREE_MODEL_FREE_TRANSPORT=node restores the legacy transport).')
|
|
241
|
+
error.laneConfig = true
|
|
242
|
+
throw error
|
|
243
|
+
}
|
|
119
244
|
if (relay?.dead) throw new Error('the configured egress relay is not running')
|
|
120
|
-
|
|
245
|
+
// With no explicit proxy, the Bun worker inherits this process's
|
|
246
|
+
// HTTP_PROXY/HTTPS_PROXY/NO_PROXY — Bun honors them natively, which is
|
|
247
|
+
// exactly the configuration issue #708's script used.
|
|
248
|
+
const proxy = freeLaneProxy !== null || relay !== null ? laneProxy?.href : undefined
|
|
121
249
|
if (url instanceof Request) {
|
|
122
250
|
const request = new Request(url, init)
|
|
123
251
|
const body = request.body ? new Uint8Array(await request.arrayBuffer()) : undefined
|
|
@@ -126,6 +254,11 @@ export async function egressFetch(url, init) {
|
|
|
126
254
|
return bunFetch(url, init, { executable, proxy })
|
|
127
255
|
}
|
|
128
256
|
if (relay === null) return fetch(url, init)
|
|
257
|
+
return relayFetch(url, init, relay)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Rewrite one request onto the loopback relay (`egressFetch`'s second half). */
|
|
261
|
+
function relayFetch(url, init, relay) {
|
|
129
262
|
const request = url instanceof Request ? new Request(url, init) : null
|
|
130
263
|
const target = new URL(request ? request.url : String(url))
|
|
131
264
|
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
|
|
@@ -141,6 +274,74 @@ export async function egressFetch(url, init) {
|
|
|
141
274
|
return fetch(`http://${RELAY_HOST}:${relay.port}${target.pathname}${target.search}`, { ...outgoing, headers })
|
|
142
275
|
}
|
|
143
276
|
|
|
277
|
+
/**
|
|
278
|
+
* One proxied request dialled in-process (`laneProxiedFetch`).
|
|
279
|
+
*
|
|
280
|
+
* The Bun worker's fetch accepts a `proxy` per request; Node's own fetch does
|
|
281
|
+
* not, and the global relay is process-wide by design. When the lane's exit
|
|
282
|
+
* has to differ from the process's — a custom lane address, or the
|
|
283
|
+
* environment's `HTTP(S)_PROXY` on the `node` transport — this is the seam:
|
|
284
|
+
* the same `openTunnel` the relay uses dials the proxy, the one-shot agent
|
|
285
|
+
* hands the socket to `http.request`, and the response streams back as a
|
|
286
|
+
* plain `Response`. Streaming cadence, abort semantics and `redirect: 'error'`
|
|
287
|
+
* match what `postStreamed` expects of `fetch`.
|
|
288
|
+
*/
|
|
289
|
+
async function laneProxiedFetch(url, init, proxyUrl) {
|
|
290
|
+
const target = new URL(url instanceof Request ? url.url : String(url))
|
|
291
|
+
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
|
|
292
|
+
throw new Error(`the lane proxy only carries http(s) targets, got "${target.protocol}"`)
|
|
293
|
+
}
|
|
294
|
+
const socket = await openTunnel(target, { kind: 'url', url: proxyUrl })
|
|
295
|
+
const agent = new http.Agent({ keepAlive: false })
|
|
296
|
+
agent.createConnection = () => socket
|
|
297
|
+
const request = url instanceof Request ? new Request(url, init) : null
|
|
298
|
+
const headers = new Headers(request?.headers ?? init?.headers ?? {})
|
|
299
|
+
headers.set('host', target.host)
|
|
300
|
+
headers.delete('transfer-encoding')
|
|
301
|
+
const headerObject = {}
|
|
302
|
+
for (const [name, value] of headers.entries()) headerObject[name] = value
|
|
303
|
+
const outgoing = http.request({
|
|
304
|
+
method: request?.method ?? init?.method ?? 'GET',
|
|
305
|
+
path: `${target.pathname}${target.search}`,
|
|
306
|
+
headers: headerObject,
|
|
307
|
+
agent,
|
|
308
|
+
})
|
|
309
|
+
const signal = init?.signal ?? request?.signal
|
|
310
|
+
const tear = () => { outgoing.destroy(); socket.destroy() }
|
|
311
|
+
const onAbort = () => tear()
|
|
312
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
313
|
+
try {
|
|
314
|
+
const upstream = await new Promise((resolve, reject) => {
|
|
315
|
+
outgoing.on('response', resolve)
|
|
316
|
+
outgoing.on('error', reject)
|
|
317
|
+
const body = request?.body ?? init?.body ?? null
|
|
318
|
+
if (body === null || body === undefined) { outgoing.end(); return }
|
|
319
|
+
if (typeof body === 'string' || body instanceof Uint8Array) { outgoing.end(body); return }
|
|
320
|
+
// Node streams the request body through the same tunnel; flow control
|
|
321
|
+
// belongs to the two pipes. A pipeline error destroys both sides.
|
|
322
|
+
pipeline(body, outgoing, () => {})
|
|
323
|
+
})
|
|
324
|
+
const responseHeaders = new Headers()
|
|
325
|
+
for (const [name, value] of Object.entries(upstream.headers)) {
|
|
326
|
+
if (HOP_BY_HOP.has(name)) continue
|
|
327
|
+
for (const entry of Array.isArray(value) ? value : [value]) responseHeaders.append(name, String(entry))
|
|
328
|
+
}
|
|
329
|
+
// The one-shot tunnel lives exactly as long as the response: finished,
|
|
330
|
+
// destroyed or cancelled by the consumer, the socket goes with it.
|
|
331
|
+
upstream.on('close', () => socket.destroy())
|
|
332
|
+
const webBody = Readable.toWeb(upstream, { strategy: { highWaterMark: 64 * 1024 } })
|
|
333
|
+
return new Response(webBody, { status: upstream.statusCode ?? 502, statusText: upstream.statusMessage, headers: responseHeaders })
|
|
334
|
+
} catch (error) {
|
|
335
|
+
tear()
|
|
336
|
+
if (signal?.aborted === true || error?.name === 'AbortError') {
|
|
337
|
+
throw new DOMException('The operation was aborted.', 'AbortError')
|
|
338
|
+
}
|
|
339
|
+
throw error
|
|
340
|
+
} finally {
|
|
341
|
+
signal?.removeEventListener('abort', onAbort)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
144
345
|
/**
|
|
145
346
|
* Start the relay for the current settings; throws with a message the settings
|
|
146
347
|
* page can show when the configuration cannot run.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|