@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/index.js CHANGED
@@ -29,6 +29,7 @@ import fs from 'node:fs'
29
29
  import crypto from 'node:crypto'
30
30
  import { fileURLToPath } from 'node:url'
31
31
  import { externalUrl, openExternal } from './src/open-external.js'
32
+ import { findBunExecutable } from './src/bun-fetch.js'
32
33
  import { ROUTE_LABELS, ROUTE_MAIN, ROUTE_REGION } from './src/adapter.js'
33
34
  import { DATA_DIR_NAME, resolveDshHome, publicModelVisibility, validateModelVisibility, isModelHidden } from './src/store.js'
34
35
  import { isEacEntry, isKiloEntry } from './src/catalog.js'
@@ -36,7 +37,7 @@ import { STATE } from './src/probe.js'
36
37
  import { generateKey, rankLanAddresses, startForwardServer, startLanRelay } from './src/forward.js'
37
38
  import { chanGatewayCredential, chanGatewayEnabled, chanGatewayPort, startChanRelay } from './src/chan-relay.js'
38
39
  import { CODE, UpstreamError } from './src/http.js'
39
- import { egressActive, egressFetch, outletLabel, readOutletSelection, startEgressRelay } from './src/egress.js'
40
+ import { egressActive, egressFetch, outletLabel, readOutletSelection, startEgressRelay, setFreeLaneProxy, freeLaneProxyActive, laneProxyEcho } from './src/egress.js'
40
41
  import { directFetch } from './src/eac.js'
41
42
  import { clearEacUser, readEacUser, writeEacUser } from './src/eac-user.js'
42
43
  import { createEacLoginPoller } from './src/eac-login.js'
@@ -60,7 +61,9 @@ import { createModelRuntime, createRuntimeStores } from './src/core.js'
60
61
  import { createSubscriptions } from './src/subscriptions.js'
61
62
  import { createSubscriptionRuntime } from './src/subscription-completion.js'
62
63
  import { isSubscriptionEntry } from './src/subscription-adapter.js'
63
- import { createMagpieEngine } from './src/magpie-engine.js'
64
+ import { createMagpieEngine, findAgentCli } from './src/magpie-engine.js'
65
+ import { createExtensionRuntime } from './src/extensions.js'
66
+ import { builtinDescriptors } from './src/plugins.js'
64
67
  import { createMagpieModels, combineSubscriptionRuntimes } from './src/magpie-models.js'
65
68
  import { DISCLAIMER, DISCLAIMER_VERSION, disclaimerAcknowledged } from './src/disclaimer.js'
66
69
 
@@ -145,6 +148,15 @@ export function apply(ctx, config) {
145
148
  fs.mkdirSync(dataDir, { recursive: true })
146
149
  const packageVersion = version
147
150
 
151
+ // Environment readiness: the official free lane's POST inference needs a
152
+ // local Bun, and a user who never learns that reads only a failed turn.
153
+ // Probe once at boot — the summary's `bun` bit and this log line are the two
154
+ // surfaces the answer lives on.
155
+ const bunAvailable = findBunExecutable() !== undefined
156
+ if (!bunAvailable) {
157
+ logger.warn?.('our-free-model: 未检测到本机 Bun —— OpenCode 免费车道无法推理。请安装 Bun(https://bun.sh 或 npm i -g bun)后重启,或把可执行文件路径写入 OUR_FREE_MODEL_BUN。(Bun not found: the OpenCode free lane cannot run inference)')
158
+ }
159
+
148
160
  // A hot reload re-enters apply with fresh stores; the generation counter lives
149
161
  // on globalThis so the new instance knows it replaced a predecessor, and does
150
162
  // the post-swap bookkeeping itself (the old closure must not touch stores
@@ -414,9 +426,13 @@ export function apply(ctx, config) {
414
426
  }
415
427
 
416
428
  const magpieEngine = createMagpieEngine({ dataDir, logger })
429
+ // The extension runtime is created a few lines below; the lazy reference
430
+ // gives the magpie lane the enhancement-hook seam without reordering boot.
431
+ let extensionHookRuntime = null
417
432
  const magpieModels = createMagpieModels({
418
433
  engine: magpieEngine, stats, logger,
419
434
  onChanged: () => modelRuntime.syncAggregateCatalog(),
435
+ extensionRuntime: () => extensionHookRuntime,
420
436
  })
421
437
  let subscriptions
422
438
  let subscriptionRuntime
@@ -429,17 +445,35 @@ export function apply(ctx, config) {
429
445
  logger.warn?.('our-free-model: private subscription storage is unavailable')
430
446
  }
431
447
  subscriptionRuntime = combineSubscriptionRuntimes(magpieModels, subscriptionRuntime)
448
+ // The OFM extension platform: the marketplace's client side. Created before
449
+ // the model runtime so the extension lane exists for the first merge; the
450
+ // async mount (worker starts, registry warm-up) lands through the callback.
451
+ const extensions = createExtensionRuntime({
452
+ dataDir,
453
+ logger,
454
+ hostVersion: packageVersion,
455
+ onCatalogChanged: () => {
456
+ if (disposed) return
457
+ try { modelRuntime.syncExtensionCatalog() } catch { /* the runtime may not exist yet during boot */ }
458
+ },
459
+ })
460
+ // Built-ins register through the same kernel as installed plugins. The DSH
461
+ // client renders channel/enhancement contributions; page contributions are
462
+ // a desktop-console surface.
463
+ for (const descriptor of builtinDescriptors()) extensions.registerBuiltin(descriptor)
464
+ extensionHookRuntime = extensions
432
465
  const modelRuntime = createModelRuntime({
433
466
  ...stores,
434
467
  logger,
435
468
  subscriptionRuntime,
469
+ extensionRuntime: extensions,
436
470
  attributionUserAgent: 'deepseek-harness',
437
471
  resolveImage: imageResolver(ctx, logger),
438
472
  sealedCredential: sealedCredentialOf,
439
473
  onSealedUnavailable: () => logger.warn?.('our-free-model: the sealed lane is not available in this composition (no recognized host profile); its models stay hidden'),
440
474
  onTopology: () => emitTopology(),
441
475
  })
442
- const { state, adapter, refreshCatalog, refreshAvailability, watchEgress, fetchListing, publicModelRows, syncAggregateCatalog } = modelRuntime
476
+ const { state, adapter, refreshCatalog, refreshAvailability, watchEgress, fetchListing, publicModelRows, syncAggregateCatalog, clearLaneBreaker } = modelRuntime
443
477
  const runForwarded = modelRuntime.complete
444
478
 
445
479
  // ── registration ────────────────────────────────────────────────────────────
@@ -857,6 +891,23 @@ export function apply(ctx, config) {
857
891
  outletSyncInFlight = run
858
892
  try { await run } finally { if (outletSyncInFlight === run) outletSyncInFlight = null }
859
893
  }
894
+ /**
895
+ * Apply the settings file's lane proxy to the egress layer.
896
+ *
897
+ * Boot-time sync; the settings route re-applies on every `freeLane` patch
898
+ * through `sanitizeSettings` (which is where a bad address fails the write).
899
+ * A stored value that no longer parses is dropped with one log line — the
900
+ * lane falls back to the global outlet rather than failing every turn.
901
+ */
902
+ function syncFreeLaneProxy() {
903
+ try {
904
+ setFreeLaneProxy(String(settings.get().freeLane?.proxyUrl ?? ''))
905
+ } catch (error) {
906
+ logger.warn?.(`our-free-model: the OpenCode lane proxy address is invalid (${error?.message ?? error}); the lane keeps its current exit`)
907
+ setFreeLaneProxy('')
908
+ }
909
+ }
910
+ syncFreeLaneProxy()
860
911
  async function syncEgressOnce() {
861
912
  const desired = settings.get().egress ?? {}
862
913
  const wanted = desired.enabled === true
@@ -1086,7 +1137,9 @@ export function apply(ctx, config) {
1086
1137
  }
1087
1138
  const api = createApiRoutes({
1088
1139
  settings, stats, availability, catalog: () => modelRuntime.catalog, state,
1089
- refreshCatalog, refreshAvailability, syncForward, syncRelay, syncEgress,
1140
+ refreshCatalog, refreshAvailability, syncForward, syncRelay, syncEgress, clearLaneBreaker,
1141
+ extensions,
1142
+ bunAvailable,
1090
1143
  onVisibilityChanged: emitTopology,
1091
1144
  pool: fetchPoolSnapshot,
1092
1145
  eacAuth,
@@ -1440,6 +1493,7 @@ export function apply(ctx, config) {
1440
1493
 
1441
1494
  // ── boot + background loop ──────────────────────────────────────────────────
1442
1495
  ctx.effect(() => () => {
1496
+ extensions.dispose()
1443
1497
  magpieModels.close()
1444
1498
  const closing = magpieEngine.close()
1445
1499
  modelRuntime.dispose()
@@ -1452,9 +1506,40 @@ export function apply(ctx, config) {
1452
1506
  void magpieEngine.start().then(() => magpieModels.refresh()).catch(() => {
1453
1507
  logger.warn?.('our-free-model: model workspace could not resume')
1454
1508
  })
1509
+ // The engine connects Claude through the locally installed Claude Code
1510
+ // CLI; its own downloader cannot reach the release host from every
1511
+ // network, so a pre-installed CLI is the practical requirement. Warn at
1512
+ // boot — the user's first failed connect is too late to learn it.
1513
+ if (findAgentCli('claude') === undefined) {
1514
+ logger.warn?.('our-free-model: 未检测到 Claude Code CLI —— 在模型工作台连接 Claude 前请先安装(npm i -g @anthropic-ai/claude-code)。(Claude Code CLI not found; install it before connecting Claude in the model workspace)')
1515
+ }
1455
1516
  }
1456
1517
  }, 'our-free-model: model workspace')
1457
1518
 
1519
+ ctx.effect(() => {
1520
+ // The extension mount: start every installed, enabled extension (its
1521
+ // catalog lands through onCatalogChanged → syncExtensionCatalog), then
1522
+ // warm the marketplace cache in the background — a cold registry only
1523
+ // means an empty Discover tab, never a blocked lane.
1524
+ void extensions.init().catch(error => {
1525
+ logger.warn?.(`our-free-model: extension mount failed (${error?.message ?? error})`)
1526
+ })
1527
+ void extensions.refreshRegistry().catch(() => {})
1528
+ // The marketplace index refreshes on its own cadence too: an update that
1529
+ // lands upstream should reach the Installed tab without the user opening
1530
+ // the market first. The TTL cache keeps ticks cheap; failures are silent —
1531
+ // the next tick retries and the last good view keeps serving.
1532
+ const registryTimer = setInterval(() => {
1533
+ void extensions.refreshRegistry().catch(() => {})
1534
+ // The automatic updater rides the same tick: it only installs what the
1535
+ // soak has already cleared, so no extra network touch beyond the
1536
+ // refresh's own fetches, and a failure is next tick's retry.
1537
+ if (settings.get().autoUpdateExtensions === true) void extensions.autoUpdate().catch(() => {})
1538
+ }, 6 * 60 * 60 * 1000)
1539
+ registryTimer.unref?.()
1540
+ return () => clearInterval(registryTimer)
1541
+ }, 'our-free-model: extensions')
1542
+
1458
1543
  ctx.effect(() => () => {
1459
1544
  void forward?.close().catch(() => {})
1460
1545
  void relay?.close().catch(() => {})
@@ -1566,7 +1651,9 @@ export function apply(ctx, config) {
1566
1651
  every(() => {
1567
1652
  void (async () => {
1568
1653
  await watchEgress()
1569
- await refreshCatalog({ probe: false })
1654
+ // throttleListing: the periodic round may keep the cached roster when it
1655
+ // just refreshed — the upstream listing is read at most once per window.
1656
+ await refreshCatalog({ probe: false, throttleListing: true })
1570
1657
  })().catch(error => logger.warn?.(`our-free-model: periodic refresh failed (${error?.message ?? error})`))
1571
1658
  }, () => positiveOr(settings.get().probeIntervalMinutes, 15, 1) * 60_000)
1572
1659
  // A failing egress watch means the network is down, which is worth one line —
@@ -1663,6 +1750,16 @@ function sanitizeSettings(patch, current) {
1663
1750
  const hours = Number(next.updateCheckHours)
1664
1751
  next.updateCheckHours = Number.isFinite(hours) && hours >= 0 ? Math.trunc(hours) : (Number(current.updateCheckHours) || 6)
1665
1752
  }
1753
+ if (next.freeLane !== undefined) {
1754
+ // The lane proxy is a URL the egress layer validates on save: a typo must
1755
+ // fail the write, not the first turn. Everything else about the object is
1756
+ // carried verbatim.
1757
+ const incoming = next.freeLane
1758
+ const merged = { ...(current.freeLane ?? {}), ...(incoming && typeof incoming === 'object' ? incoming : {}) }
1759
+ const proxyUrl = String(merged.proxyUrl ?? '').trim()
1760
+ setFreeLaneProxy(proxyUrl)
1761
+ next.freeLane = { ...merged, proxyUrl }
1762
+ }
1666
1763
  return next
1667
1764
  }
1668
1765
 
@@ -1987,7 +2084,7 @@ export function createApiRoutes(deps) {
1987
2084
  const patch = await readJson(req)
1988
2085
  const visibility = validateModelVisibility(patch)
1989
2086
  const current = deps.settings.get()
1990
- const next = sanitizeSettings({ ...current, ...pick(patch, ['enabled', 'exposeRegionModels', 'probeIntervalMinutes', 'defaultMaxTokens', 'announcementAck', 'feedUrl', 'feedPollMinutes', 'notifyOs', 'updateCheckHours', 'autoReloadWatch']), ...visibility }, current)
2087
+ const next = sanitizeSettings({ ...current, ...pick(patch, ['enabled', 'exposeRegionModels', 'probeIntervalMinutes', 'defaultMaxTokens', 'announcementAck', 'feedUrl', 'feedPollMinutes', 'notifyOs', 'updateCheckHours', 'autoReloadWatch', 'freeLane', 'developerPlugins', 'autoUpdateExtensions']), ...visibility }, current)
1991
2088
  if (next.autoReloadWatch === true && deps.update.status().hotReloadAvailable === false) next.autoReloadWatch = false
1992
2089
  if (patch.forward !== undefined) {
1993
2090
  const forward = { ...(current.forward ?? {}), ...pick(patch.forward, ['enabled', 'host', 'port']) }
@@ -2053,8 +2150,134 @@ export function createApiRoutes(deps) {
2053
2150
  return send(200, { ok: true, ...buildSummary(deps) })
2054
2151
  }
2055
2152
  if (method === 'POST' && routePath === '/reprobe') {
2056
- await deps.refreshAvailability(true)
2057
- return send(200, { ok: true, ...buildSummary(deps) })
2153
+ // One probe, one model: the caller's pick, or the roster head. The
2154
+ // quota breaker refuses while a window is open (the response names it).
2155
+ const body = await readJson(req).catch(() => ({}))
2156
+ const outcome = await deps.refreshAvailability(true, { model: typeof body?.model === 'string' ? body.model : undefined })
2157
+ return send(200, { ok: true, probe: outcome, ...buildSummary(deps) })
2158
+ }
2159
+ if (method === 'POST' && routePath === '/lane/clear') {
2160
+ // 换出口后重试一次:出口已变(或用户自备了新出口代理),把额度断路器
2161
+ // 清掉,让下一次手动检测/真实回合重新出发。一次调用只清一次。
2162
+ const cleared = deps.clearLaneBreaker()
2163
+ return send(200, { ok: true, cleared, ...buildSummary(deps) })
2164
+ }
2165
+ if (method === 'POST' && routePath === '/lane/echo') {
2166
+ // 测试出口:打第三方回显服务,不打免费网关 —— 一次测试不花配额。
2167
+ try {
2168
+ const echo = await laneProxyEcho({ signal: req.signal ?? undefined })
2169
+ return send(200, { ok: true, ...echo })
2170
+ } catch (error) {
2171
+ return send(502, { ok: false, error: String(error?.message ?? error).slice(0, 200) })
2172
+ }
2173
+ }
2174
+
2175
+ // ── the extension marketplace ───────────────────────────────────────────
2176
+ if (routePath === '/extensions' || routePath.startsWith('/extensions/')) {
2177
+ if (deps.extensions === undefined) return send(503, { error: 'the extension platform is unavailable' })
2178
+ const extensions = deps.extensions
2179
+ if (method === 'GET' && routePath === '/extensions/cover') {
2180
+ // The marketplace card's banner. An <img> cannot attach headers, so
2181
+ // the image rides the same admission fence as every fetch from the
2182
+ // settings page — same origin, same cookies, same gate. Failure is
2183
+ // a 404 the card renders without; a missing banner never breaks it.
2184
+ const image = await extensions.coverImage(url.searchParams.get('id') ?? '')
2185
+ if (image === null) return send(404, { error: 'no cover for this extension' })
2186
+ res.writeHead(200, {
2187
+ 'content-type': image.contentType,
2188
+ 'content-length': image.bytes.length,
2189
+ // Cosmetics only: a stale banner for a few minutes is invisible.
2190
+ 'cache-control': 'private, max-age=600',
2191
+ 'x-content-type-options': 'nosniff',
2192
+ })
2193
+ res.end(image.bytes)
2194
+ return
2195
+ }
2196
+ if (method === 'GET' && routePath === '/extensions') {
2197
+ // A first paint waits briefly for the boot warm-up and hears why a
2198
+ // read failed; `registryError` used to be structurally empty because
2199
+ // `registryView()` never throws.
2200
+ const status = await extensions.registryStatusSoon()
2201
+ // `updates` feeds the soak countdown on the Installed tab; `audit`
2202
+ // gives the page its own 最近操作 trail; the settings bit lets the
2203
+ // toolbar's auto-update switch render the value it will write.
2204
+ return send(200, {
2205
+ installed: extensions.listInstalled().filter(record => record.builtin !== true),
2206
+ registry: status.registry,
2207
+ registryError: status.error,
2208
+ updates: extensions.updatesAvailable(),
2209
+ registryMeta: extensions.registryMeta(),
2210
+ audit: extensions.auditTail(30),
2211
+ autoUpdate: deps.settings.get().autoUpdateExtensions === true,
2212
+ })
2213
+ }
2214
+ if (method === 'POST' && routePath === '/extensions/registry/refresh') {
2215
+ try {
2216
+ await extensions.refreshRegistry({ force: true })
2217
+ return send(200, { ok: true, installed: extensions.listInstalled().filter(record => record.builtin !== true), registry: extensions.registryView() })
2218
+ } catch (error) {
2219
+ return send(502, { error: String(error?.message ?? error), installed: extensions.listInstalled().filter(record => record.builtin !== true) })
2220
+ }
2221
+ }
2222
+ if (method === 'POST' && routePath === '/extensions/install') {
2223
+ const body = await readJson(req)
2224
+ try {
2225
+ const record = await extensions.install(String(body?.id ?? ''), { version: typeof body?.version === 'string' ? body.version : undefined })
2226
+ return send(200, { ok: true, record, installed: extensions.listInstalled().filter(record => record.builtin !== true) })
2227
+ } catch (error) {
2228
+ return send(400, { error: String(error?.message ?? error) })
2229
+ }
2230
+ }
2231
+ if (method === 'POST' && routePath === '/extensions/uninstall') {
2232
+ const body = await readJson(req)
2233
+ try {
2234
+ await extensions.uninstall(String(body?.id ?? ''))
2235
+ return send(200, { ok: true, installed: extensions.listInstalled().filter(record => record.builtin !== true) })
2236
+ } catch (error) {
2237
+ return send(400, { error: String(error?.message ?? error) })
2238
+ }
2239
+ }
2240
+ if (method === 'POST' && routePath === '/extensions/toggle') {
2241
+ const body = await readJson(req)
2242
+ try {
2243
+ const record = await extensions.setEnabled(String(body?.id ?? ''), body?.enabled === true)
2244
+ return send(200, { ok: true, record, installed: extensions.listInstalled().filter(record => record.builtin !== true) })
2245
+ } catch (error) {
2246
+ return send(400, { error: String(error?.message ?? error) })
2247
+ }
2248
+ }
2249
+ if (method === 'POST' && routePath === '/extensions/config') {
2250
+ const body = await readJson(req)
2251
+ try {
2252
+ const record = await extensions.saveConfig(String(body?.id ?? ''), body?.values ?? {})
2253
+ // `installed` travels with every mutation: the page renders its cards
2254
+ // from it, so a config write has to hand the new values back or the
2255
+ // control snaps to the state it was rendered with.
2256
+ return send(200, { ok: true, record, installed: extensions.listInstalled().filter(record => record.builtin !== true) })
2257
+ } catch (error) {
2258
+ return send(400, { error: String(error?.message ?? error) })
2259
+ }
2260
+ }
2261
+ if (method === 'POST' && routePath === '/extensions/secret') {
2262
+ const body = await readJson(req)
2263
+ try {
2264
+ const record = await extensions.saveSecret(String(body?.id ?? ''), String(body?.key ?? ''), body?.value)
2265
+ return send(200, { ok: true, record, installed: extensions.listInstalled().filter(record => record.builtin !== true) })
2266
+ } catch (error) {
2267
+ return send(400, { error: String(error?.message ?? error) })
2268
+ }
2269
+ }
2270
+ if (method === 'POST' && routePath === '/extensions/self-test') {
2271
+ // The manifest's optional selfTest(ctx) on demand; the verdict rides
2272
+ // back as data (a failed hook is a result, not an HTTP error), so the
2273
+ // card renders ✗ + reason instead of the page falling into the toast.
2274
+ const body = await readJson(req)
2275
+ try {
2276
+ return send(200, await extensions.runSelfTest(String(body?.id ?? '')))
2277
+ } catch (error) {
2278
+ return send(400, { error: String(error?.message ?? error) })
2279
+ }
2280
+ }
2058
2281
  }
2059
2282
  if (method === 'GET' && routePath === '/forward/key') {
2060
2283
  return send(200, { key: deps.settings.get().forwardKey ?? '' })
@@ -2469,6 +2692,19 @@ function buildSummary(deps) {
2469
2692
  settings: publicSettings(deps.settings.get(), forwardInfo, deps.egressInfo()),
2470
2693
  egress: forwardInfo.egress ?? snapshot.egress ?? null,
2471
2694
  outlet: deps.egressInfo(),
2695
+ // The free lane's quota breaker and its exit: what the page's lane card
2696
+ // renders as the countdown / lock banner, and the proxy seam issue #708's
2697
+ // bypass reduces to. `lane` is null until a real verdict exists.
2698
+ lane: snapshot.lane ?? null,
2699
+ freeLane: {
2700
+ proxyActive: freeLaneProxyActive(),
2701
+ proxyConfigured: String(deps.settings.get().freeLane?.proxyUrl ?? '').trim() !== '',
2702
+ transport: process.env.OUR_FREE_MODEL_FREE_TRANSPORT ?? 'auto',
2703
+ },
2704
+ // Environment readiness for the free lane: the official POST inference
2705
+ // needs a local Bun, and a user who never learns that reads only a failed
2706
+ // turn. Probed once at boot (see the readiness effect below).
2707
+ bun: deps.bunAvailable !== false,
2472
2708
  probedAt: snapshot.at ?? 0,
2473
2709
  announcementVersion: ANNOUNCEMENT_VERSION,
2474
2710
  disclaimerVersion: DISCLAIMER_VERSION,
@@ -2486,6 +2722,7 @@ function buildSummary(deps) {
2486
2722
  // Whether the absorbed free-channel pack is live on this host, and its
2487
2723
  // failure reason when it is not (see the mount in `apply`).
2488
2724
  channels: deps.channels?.() ?? { state: 'unknown', error: '' },
2725
+ extensions: deps.extensions !== undefined ? { installed: deps.extensions.listInstalled().filter(record => record.builtin !== true) } : null,
2489
2726
  update: { available: update.available, latest: update.latest, current: update.current, checkedAt: update.checkedAt, applying: update.applying, managed: update.managed === true },
2490
2727
  }
2491
2728
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ebony-vinyl/dsh-our-free-model",
3
- "version": "2.8.0",
3
+ "version": "2.8.2",
4
4
  "type": "module",
5
5
  "description": "Our Free Model — 在 dsh 里装上这个插件即可,无需登录、注册或填 API Key,就能使用包括 DeepSeek V4.1 Flash、Kimi K3 在内的前沿模型,桌面端另有 EAC 协付渠道;2.0 起接入 LobsterAI、Qoder、TRAE、Cline、Loomy、Raccoon、MiniMax、ZCode、Gemini 十个账号渠道(原有匿名免费通道保留)(登录、账号池、积分领取、本地 OpenAI 网关与局域网转发、数据看板与运行日志),六页毛玻璃界面统一呈现。",
6
6
  "repository": {
@@ -58,7 +58,10 @@
58
58
  "src/push.js",
59
59
  "src/recovery.js",
60
60
  "src/reload.js",
61
+ "src/semver.js",
61
62
  "src/store.js",
63
+ "src/extensions.js",
64
+ "src/plugins.js",
62
65
  "src/stream.js",
63
66
  "src/subscription-adapter.js",
64
67
  "src/subscription-completion.js",
package/src/adapter.js CHANGED
@@ -22,12 +22,12 @@ import { toChatMessages, toClaudeMessages, toResponseInput, toToolDefs, repairTo
22
22
  import { CODE, UpstreamError, postStreamed } from './http.js'
23
23
  import { postSealedStreamed } from './eac.js'
24
24
  import { postKiloStreamed } from './kilo.js'
25
- import { postAggregateStreamed, isAggregateEntry } from './aggregate.js'
25
+ import { postAggregateStreamed, postCompatibleStreamed, isAggregateEntry } from './aggregate.js'
26
26
  import { finishReason, readStream, sanitizeUsage, windowTokens } from './stream.js'
27
27
  import { DEFAULT_LEVEL, MIN_BUDGET, budgetFor, defaultEffortFor, effortPatchFor, effortsFor, hasDeclaredEffortMenu, resolveLevel } from './effort.js'
28
28
  import { createChannel } from './channel.js'
29
29
  import { recoveryPolicy, canRecover, canRecoverSilentStop, recoveryMessages, continuationMessages, checkpointFits, addUsage, createBlockTracker } from './recovery.js'
30
- import { isEacEntry, isKiloEntry } from './catalog.js'
30
+ import { isEacEntry, isKiloEntry, isExtensionEntry } from './catalog.js'
31
31
  import { isComboEntry, comboChain } from './combos.js'
32
32
  import { applyTokenSavers, saverConfig } from './token-saver.js'
33
33
  import { classifyFailure, describeVerdict, shouldAdvance } from './route/classify.js'
@@ -375,6 +375,25 @@ export class FreeModelAdapter {
375
375
  const sealed = isEacEntry(entry)
376
376
  const kilo = isKiloEntry(entry)
377
377
  const aggregate = isAggregateEntry(entry)
378
+ const extension = isExtensionEntry(entry)
379
+ // The keyless anonymous lane — the only one the quota breaker and the
380
+ // passive availability learner describe. Every other lane carries its own
381
+ // channel tag and its own accounting.
382
+ const freeLane = !sealed && !kilo && !aggregate && !extension && !isComboEntry(entry) && !isSubscriptionEntry(entry)
383
+ if (freeLane) {
384
+ // The quota breaker: a window the upstream opened on this egress is a
385
+ // wall no model/session/UA choice moves (issue #767 measured exactly
386
+ // that), so the lane refuses locally instead of feeding the wall another
387
+ // request. `重试` here means changing the exit or waiting for `retryAt`.
388
+ const gate = this.deps.freeLaneGate?.()
389
+ if (gate?.ok === false) {
390
+ const when = Number.isFinite(gate.retryAt) ? new Date(gate.retryAt).toISOString() : 'until the exit changes'
391
+ throw new UpstreamError(
392
+ `the OpenCode free quota window is open on this egress${gate.message ? `: ${gate.message}` : ''} — retry after ${when}, or switch the lane's exit proxy; Kilo and extension channels are unaffected`,
393
+ CODE.quota,
394
+ { ...(Number.isFinite(gate.retryAt) ? { providerRetryAfterMs: Math.max(1000, gate.retryAt - Date.now()) } : {}), laneQuota: true, laneState: gate.state })
395
+ }
396
+ }
378
397
  // The absorbed channels speak the Chat wire regardless of what their ids
379
398
  // resemble; only the free lane splits endpoints per model, and an aggregate
380
399
  // row carries the wire its provider actually serves.
@@ -386,7 +405,10 @@ export class FreeModelAdapter {
386
405
  const budget = budgetFor(options.reasoningEffort, entry, options.maxTokens, settings.defaultMaxTokens)
387
406
  const declared = toToolDefs(options.tools, style)
388
407
  const policy = recoveryPolicy(settings.streamRecovery)
389
- const session = sessionForConversation(options.sessionId)
408
+ const sessionSeed = typeof options.purpose === 'string' && options.purpose.trim() !== ''
409
+ ? `${options.purpose.trim()}:${options.sessionId ?? 'global'}`
410
+ : options.sessionId
411
+ const session = sessionForConversation(sessionSeed)
390
412
  // A turn without a conversation id lands on the reserved "global" session,
391
413
  // shared by every such turn on this machine. Minting a fresh session per
392
414
  // id-less turn is not an option: the free lane accounts quota per session,
@@ -492,13 +514,20 @@ export class FreeModelAdapter {
492
514
  const attemptStarted = Date.now()
493
515
  const recovering = attempt === 1
494
516
  const savingsSink = {}
495
- const payload = payloadFor(attemptMessages, attemptBudget, recovering ? [] : warnings, savingsSink)
517
+ let payload = payloadFor(attemptMessages, attemptBudget, recovering ? [] : warnings, savingsSink)
496
518
  attemptSavings = savingsSink.report ?? null
519
+ // Enhancement extensions see the harness body after every lane's own
520
+ // shaping but before the free lane's fingerprint — they rewrite the
521
+ // caller's request, not the camouflage. Fail-open inside the runtime.
522
+ if (this.deps.extensionRuntime?.applyRequestHooks !== undefined) {
523
+ payload = await this.deps.extensionRuntime.applyRequestHooks(payload, { model: entry.id, wire, sessionId: session })
524
+ }
497
525
  // The absorbed channels have no tool-name gate; their models see the
498
526
  // caller's tools exactly as declared, so the fingerprint pass is free-lane
499
527
  // only. The aggregate lane is somebody else's gateway entirely — renaming
500
528
  // its tools to this lane's gate placeholders would break the caller's calls.
501
- const renameMap = sealed || kilo || aggregate ? new Map() : applyFingerprint(payload, wire === 'messages' ? 'claude' : style === 'flat')
529
+ // An extension channel shaped its own body in its sandbox.
530
+ const renameMap = sealed || kilo || aggregate || extension ? new Map() : applyFingerprint(payload, wire === 'messages' ? 'claude' : style === 'flat')
502
531
  const controller = new AbortController()
503
532
  const onAbort = () => controller.abort(options.signal?.reason)
504
533
  options.signal?.addEventListener('abort', onAbort, { once: true })
@@ -525,7 +554,14 @@ export class FreeModelAdapter {
525
554
  signal: controller.signal,
526
555
  onData: value => channel.push(value),
527
556
  })
528
- : postStreamed({
557
+ : extension
558
+ ? this.deps.extensionRuntime.prepareChat(entry, payload, session)
559
+ .then(prepared => postCompatibleStreamed({
560
+ ...prepared,
561
+ signal: controller.signal,
562
+ onData: value => channel.push(value),
563
+ }))
564
+ : postStreamed({
529
565
  path: endpointFor(entry.id), body: payload, session,
530
566
  attributionUserAgent: snapshot.attributionUserAgent,
531
567
  signal: controller.signal,
@@ -544,6 +580,10 @@ export class FreeModelAdapter {
544
580
  if (recorded) return
545
581
  recorded = true
546
582
  const usage = outcome?.sawUsage === true ? outcome.usage : undefined
583
+ this.deps.extensionRuntime?.noteUsage?.({
584
+ at: attemptStarted, model: entry.id, extensionId: entry.extensionId, ok,
585
+ input: usage?.inputTokens ?? 0, output: usage?.outputTokens ?? 0,
586
+ })
547
587
  this.deps.recordUsage({
548
588
  at: attemptStarted,
549
589
  model: entry.id,
@@ -689,6 +729,7 @@ export class FreeModelAdapter {
689
729
  return { ok: false, ...advanceVerdict(CODE.empty, 'empty response') }
690
730
  }
691
731
  record(true, outcome, recovering ? { recovered: reason.kind === 'stop' } : {})
732
+ if (freeLane) this.deps.onFreeLaneOutcome?.({ entry, ok: true })
692
733
  finishTurn(true, recovering && reason.kind === 'stop', attempt + 1)
693
734
  yield { type: 'usage', usage: totalUsage }
694
735
  // Keep the provider token when the harness fallback maps an abnormal
@@ -698,6 +739,12 @@ export class FreeModelAdapter {
698
739
  return { advance: false, verdict: null, ok: true, recovered: recovering && reason.kind === 'stop' }
699
740
  } catch (error) {
700
741
  if (error?.code === CODE.region) this.deps.onRegionBlocked?.(entry.id)
742
+ // Passive availability: a real turn's verdict is the only probe this
743
+ // lane still spends. A 429 opens the quota breaker; a client-gate 403
744
+ // locks the lane; a region refusal marks just the model.
745
+ if (freeLane && options.signal?.aborted !== true && error?.code !== CODE.aborted) {
746
+ this.deps.onFreeLaneOutcome?.({ entry, ok: false, error })
747
+ }
701
748
  const aborted = options.signal?.aborted === true
702
749
  let failure = toFailure(error)
703
750
  if (sealed && failure.code === CODE.server) failure = { ...failure, code: 'EAC_UPSTREAM_FAILED' }
@@ -917,6 +964,7 @@ function toFailure(error) {
917
964
  function wireOf(entry) {
918
965
  if (isSubscriptionEntry(entry)) return entry.wire ?? 'chat'
919
966
  if (isAggregateEntry(entry)) return entry.wire ?? 'chat'
967
+ if (isExtensionEntry(entry)) return entry.wire ?? 'chat'
920
968
  if (isEacEntry(entry) || isKiloEntry(entry)) return 'chat'
921
969
  return wireFor(entry.id)
922
970
  }
package/src/aggregate.js CHANGED
@@ -530,33 +530,73 @@ export async function postAggregateStreamed({ config, providerId, modelId, body,
530
530
  }
531
531
  const bodyText = typeof outbound === 'string' ? outbound
532
532
  : JSON.stringify({ ...outbound, ...(model === '' ? {} : { model }), stream: true })
533
+ return postCompatibleStreamed({
534
+ url: resolved.chatUrl,
535
+ headers: {
536
+ ...resolved.headers,
537
+ // The provider's gateway either ignores this or uses it to keep this
538
+ // conversation's state separate from concurrent ones on the same key —
539
+ // the free lane's session dimension, without its fingerprint family.
540
+ ...(text(session) === '' ? {} : { 'x-session-id': text(session) }),
541
+ },
542
+ body: bodyText,
543
+ model,
544
+ wire: resolved.wire,
545
+ signal,
546
+ onData,
547
+ timeoutMs,
548
+ secret: resolved.apiKey,
549
+ label: resolved.provider.name,
550
+ })
551
+ }
552
+
553
+ /**
554
+ * POST one streaming completion to a resolved endpoint and push raw SSE `data:`
555
+ * payloads to `onData`.
556
+ *
557
+ * The lane-neutral core `postAggregateStreamed` shares with the extension
558
+ * channels: the URL, headers, body and wire are already decided by the caller
559
+ * (the aggregate resolver, or an extension's `prepareChat`); this layer owns
560
+ * the transport discipline — body-shape sniffing, head replay, the idle
561
+ * deadline, the JSON-answer fold into the row's wire's frame sequence — and
562
+ * the credential scrubbing (`secret`) on every refusal message.
563
+ *
564
+ * @param {object} options
565
+ * @param {string} options.url - absolute endpoint
566
+ * @param {Record<string, string>} options.headers - complete header set
567
+ * @param {string} options.body - serialized request body (`stream: true` included)
568
+ * @param {string} [options.model] - the bare outbound model id, labelling synthesised frames
569
+ * @param {'chat'|'messages'|'responses'} [options.wire='chat']
570
+ * @param {AbortSignal} [options.signal]
571
+ * @param {(payload: string) => void} options.onData
572
+ * @param {number} [options.timeoutMs]
573
+ * @param {string} [options.secret] - the credential scrubbed from refusal text
574
+ * @param {string} [options.label] - the lane name in error messages
575
+ */
576
+ export async function postCompatibleStreamed({ url, headers, body, model = '', wire = 'chat', signal, onData, timeoutMs = TURN_TIMEOUT_MS, secret = '', label = 'the provider' }) {
533
577
  let response
534
578
  try {
535
- response = await egressFetch(resolved.chatUrl, {
579
+ response = await egressFetch(url, {
536
580
  method: 'POST',
537
581
  headers: {
538
- ...resolved.headers,
582
+ ...headers,
539
583
  accept: 'text/event-stream',
540
- // The provider's gateway either ignores this or uses it to keep this
541
- // conversation's state separate from concurrent ones on the same key —
542
- // the free lane's session dimension, without its fingerprint family.
543
- ...(text(session) === '' ? {} : { 'x-session-id': text(session) }),
544
584
  },
545
- body: bodyText,
585
+ body,
546
586
  redirect: 'error',
547
587
  signal,
548
588
  })
549
589
  } catch (error) {
550
590
  if (signal?.aborted === true || error?.name === 'AbortError') throw new UpstreamError('request aborted', CODE.aborted)
551
591
  throw new UpstreamError(
552
- `model request to ${resolved.provider.name} failed: ${redact(error?.message ?? error, resolved.apiKey)}${transportCause(error)}`,
592
+ `model request to ${label} failed: ${redact(error?.message ?? error, secret)}${transportCause(error)}`,
553
593
  CODE.transport)
554
594
  }
555
595
 
556
596
  const setRetry = retryAfterMs(response)
557
597
  if (!response.ok) {
558
598
  const refusal = await response.text().catch(() => '')
559
- throw classifyUpstream(response.status, refusal, setRetry, resolved.apiKey)
599
+ throw classifyUpstream(response.status, refusal, setRetry, secret)
560
600
  }
561
601
  if (response.body === null) throw new UpstreamError('model stream returned no body', CODE.empty)
562
602
 
@@ -588,7 +628,7 @@ export async function postAggregateStreamed({ config, providerId, modelId, body,
588
628
  rawText += head.decoder.decode()
589
629
  let payload
590
630
  try {
591
- payload = JSON.parse(redact(rawText, resolved.apiKey))
631
+ payload = JSON.parse(redact(rawText, secret))
592
632
  } catch {
593
633
  // A 200 body that is not JSON is a hop speaking for the provider. Markup is
594
634
  // a front proxy (a WAF page, a CDN error), and replaying the identical body
@@ -598,12 +638,12 @@ export async function postAggregateStreamed({ config, providerId, modelId, body,
598
638
  `the provider's front proxy answered HTTP ${response.status} with an HTML error page`,
599
639
  CODE.client, { status: response.status })
600
640
  }
601
- throw new UpstreamError(`unexpected non-stream response: ${redact(rawText, resolved.apiKey).slice(0, 200)}`, CODE.server, { status: response.status })
641
+ throw new UpstreamError(`unexpected non-stream response: ${redact(rawText, secret).slice(0, 200)}`, CODE.server, { status: response.status })
602
642
  }
603
643
  if (payload?.error !== undefined || payload?.type === 'error') {
604
- throw classifyUpstream(response.status, rawText, setRetry, resolved.apiKey)
644
+ throw classifyUpstream(response.status, rawText, setRetry, secret)
605
645
  }
606
- for (const frame of synthesisedFrames(resolved.wire, payload, model)) onData(frame)
646
+ for (const frame of synthesisedFrames(wire, payload, model)) onData(frame)
607
647
  return { status: response.status }
608
648
  }
609
649