@ebony-vinyl/dsh-our-free-model 2.7.4 → 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.
Files changed (44) hide show
  1. package/README.md +23 -8
  2. package/client.js +894 -6
  3. package/cordis.patch.yml +1 -1
  4. package/index.js +248 -11
  5. package/package.json +6 -2
  6. package/src/adapter.js +54 -6
  7. package/src/aggregate.js +53 -13
  8. package/src/bun-fetch.js +33 -10
  9. package/src/catalog.js +238 -58
  10. package/src/core.js +838 -0
  11. package/src/egress.js +204 -3
  12. package/src/engine-parts/win32-x64.000.part +0 -0
  13. package/src/engine-parts/win32-x64.001.part +0 -0
  14. package/src/engine-parts/win32-x64.002.part +0 -0
  15. package/src/engine-parts/win32-x64.003.part +0 -0
  16. package/src/engine-parts/win32-x64.004.part +0 -0
  17. package/src/engine-parts/win32-x64.005.part +0 -0
  18. package/src/engine-parts/win32-x64.006.part +0 -0
  19. package/src/extensions.js +1888 -0
  20. package/src/feed.js +6 -0
  21. package/src/http.js +15 -2
  22. package/src/kilo.js +55 -5
  23. package/src/magpie-engine.js +61 -2
  24. package/src/magpie-models.js +31 -3
  25. package/src/model-test.js +3 -1
  26. package/src/o2a.js +956 -0
  27. package/src/open-external.js +43 -5
  28. package/src/plugins.js +1240 -0
  29. package/src/providers/registry.json +72 -30
  30. package/src/semver.js +40 -0
  31. package/src/store.js +17 -1
  32. package/src/stream.js +39 -4
  33. package/src/subscription-completion.js +12 -3
  34. package/src/updater.js +5 -33
  35. package/src/upstream.js +283 -363
  36. package/vendor/channel-pack/NOTICE.md +1 -1
  37. package/vendor/channel-pack/pack.js +7242 -3952
  38. package/vendor/magpie/NOTICE.md +159 -6
  39. package/vendor/magpie/engine.json +58 -27
  40. package/src/core/completion.js +0 -196
  41. package/src/core/runtime.js +0 -456
  42. package/src/core/stats.js +0 -98
  43. package/src/engine-parts/win32-x64.007.part +0 -0
  44. package/src/engine-parts/win32-x64.008.part +0 -0
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 && transport !== 'node') {
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) throw new Error('OpenCode free inference requires a local Bun executable; install Bun or set OUR_FREE_MODEL_BUN to its executable path (OUR_FREE_MODEL_FREE_TRANSPORT=node restores the legacy transport)')
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
- const proxy = relay ? `http://ofm:${relay.key}@${RELAY_HOST}:${relay.port}` : undefined
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