@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/feed.js CHANGED
@@ -109,6 +109,11 @@ function parseItem(row, now) {
109
109
  && /^https?:\/\//i.test(row.link.url)) {
110
110
  link = { url: row.link.url, label: typeof row.link.label === 'string' ? row.link.label.slice(0, 100) : '' }
111
111
  }
112
+ // `holdSeconds` asks the client to keep the modal's close control locked for
113
+ // a few seconds so the body is actually read (0/absent closes immediately).
114
+ // The ceiling keeps an owner from writing "hold for a week".
115
+ const holdSeconds = row.holdSeconds
116
+ const hold = Number.isInteger(holdSeconds) && holdSeconds >= 0 && holdSeconds <= 30 ? holdSeconds : 0
112
117
  return {
113
118
  id,
114
119
  title,
@@ -118,6 +123,7 @@ function parseItem(row, now) {
118
123
  ...expiresAt === undefined ? {} : { expiresAt },
119
124
  ...row.pinned === true ? { pinned: true } : {},
120
125
  ...link === undefined ? {} : { link },
126
+ ...(hold > 0 ? { holdSeconds: hold } : {}),
121
127
  }
122
128
  }
123
129
 
package/src/http.js CHANGED
@@ -419,7 +419,12 @@ function emptyBodyFailure(status, retryAfterMs, imageContent) {
419
419
  }
420
420
 
421
421
  export async function postStreamed({ path, body, session, attributionUserAgent, signal, onData, timeoutMs = 300000 }) {
422
- const headers = gatewayHeaders({ session: truncateSession(session), stream: true })
422
+ // The Messages wire carries the 2.x client's beta header (o2a/headers.js);
423
+ // the path is the only wire signal this layer sees. The project id derives
424
+ // from the same canonical session, so one conversation's retries,
425
+ // continuations and refreshes share one upstream quota bucket (PR #757).
426
+ const canonical = truncateSession(session)
427
+ const headers = gatewayHeaders({ session: canonical, project: canonical, stream: true, anthropic: path.includes('/messages') })
423
428
  headers['user-agent'] = userAgentWith(attributionUserAgent)
424
429
  let response
425
430
  try {
@@ -429,6 +434,13 @@ export async function postStreamed({ path, body, session, attributionUserAgent,
429
434
  // `TimeoutError`/user Error rather than `AbortError` — testing the name alone
430
435
  // reported a cancelled turn as `TRANSPORT`, which is retryable.
431
436
  if (signal?.aborted === true || error?.name === 'AbortError') throw new UpstreamError('request aborted', CODE.aborted)
437
+ // A lane-configuration refusal (a missing Bun, an unusable proxy) is not an
438
+ // "upstream request failed": the sentence the lane wrote is already the
439
+ // actionable answer, so it ships unwrapped, with its flag intact for the
440
+ // surfaces that distinguish "fix your environment" from "upstream broke".
441
+ if (error?.laneConfig === true) {
442
+ throw new UpstreamError(String(error?.message ?? error), CODE.transport, { laneConfig: true })
443
+ }
432
444
  throw new UpstreamError(`our-free-model: upstream request failed: ${error?.message ?? error}${transportCause(error)}`, CODE.transport)
433
445
  }
434
446
 
@@ -623,7 +635,8 @@ export async function readSse(source, onData, signal, timeoutMs = 300000) {
623
635
 
624
636
  /** Fetch a small JSON document from the gateway with the fingerprint headers. */
625
637
  export async function getJson(path, { session, attributionUserAgent, signal, timeoutMs = 15000 } = {}) {
626
- const headers = gatewayHeaders({ session: truncateSession(session ?? ''), stream: false, accept: 'application/json' })
638
+ const canonical = truncateSession(session ?? '')
639
+ const headers = gatewayHeaders({ session: canonical, project: canonical, stream: false, accept: 'application/json' })
627
640
  headers['user-agent'] = userAgentWith(attributionUserAgent)
628
641
  const controller = new AbortController()
629
642
  const timer = setTimeout(() => controller.abort(), timeoutMs)
package/src/kilo.js CHANGED
@@ -25,8 +25,20 @@ import { egressFetch } from './egress.js'
25
25
  const LISTING_TIMEOUT_MS = 15000
26
26
  const TURN_TIMEOUT_MS = 300000
27
27
 
28
- /** Overridable for the offline suite and the live probes, like the free lane's base. */
29
- export const KILO_BASE = (process.env.OUR_FREE_MODEL_KILO_BASE ?? 'https://api.kilo.ai/api/gateway').replace(/\/+$/, '')
28
+ /**
29
+ * The gateway base, newest first (`ai-gateway.kilo.ai` is what the official
30
+ * docs publish; the older `api.kilo.ai/api/gateway` path answers the same
31
+ * catalog and stays as the override/fallback). Overridable for the offline
32
+ * suite and the live probes, like the free lane's base.
33
+ */
34
+ export const KILO_BASE = (process.env.OUR_FREE_MODEL_KILO_BASE ?? 'https://ai-gateway.kilo.ai/api/v1').replace(/\/+$/, '')
35
+
36
+ /** The attribution headers Kilo's own clients send; documented optional, and a
37
+ * gateway that ranks by referrer sees the plugin, not a spoofed editor. */
38
+ const KILO_ATTRIBUTION = {
39
+ 'http-referer': 'https://kilo.ai/',
40
+ 'x-title': 'Our Free Model',
41
+ }
30
42
 
31
43
  /**
32
44
  * The lane's transport is `egressFetch`, exactly like the free lane's: an
@@ -37,7 +49,13 @@ function kiloFetch(url, init) {
37
49
  return egressFetch(url, init)
38
50
  }
39
51
 
40
- /** One listing round: `GET {base}/models`. Returns the parsed JSON document. */
52
+ /** The cached listing document and its validator, for conditional rounds. */
53
+ let listingCache = null
54
+ let listingETag = null
55
+
56
+ /** One listing round: `GET {base}/models`, conditionally when the gateway
57
+ * named a validator last time. Returns the parsed JSON document; a `304`
58
+ * answers with the cached one. */
41
59
  export async function fetchKiloListing({ signal, timeoutMs = LISTING_TIMEOUT_MS } = {}) {
42
60
  const controller = new AbortController()
43
61
  const timer = setTimeout(() => controller.abort(), timeoutMs)
@@ -46,15 +64,27 @@ export async function fetchKiloListing({ signal, timeoutMs = LISTING_TIMEOUT_MS
46
64
  const onCallerAbort = () => { callerAborted = true; controller.abort() }
47
65
  signal?.addEventListener('abort', onCallerAbort, { once: true })
48
66
  try {
67
+ const headers = {
68
+ accept: 'application/json',
69
+ 'user-agent': 'dsh-our-free-model',
70
+ ...KILO_ATTRIBUTION,
71
+ ...(listingETag !== null ? { 'if-none-match': listingETag } : {}),
72
+ }
49
73
  const response = await kiloFetch(`${KILO_BASE}/models`, {
50
- headers: { accept: 'application/json', 'user-agent': 'dsh-our-free-model' },
74
+ headers,
51
75
  redirect: 'error',
52
76
  signal: controller.signal,
53
77
  })
78
+ if (response.status === 304 && listingCache !== null) return listingCache
54
79
  const text = await response.text()
55
80
  let payload
56
81
  try { payload = JSON.parse(text) } catch { payload = { error: { message: text.slice(0, 300) || `HTTP ${response.status}` } } }
57
82
  if (!response.ok) throw classifyFailure(response.status, payload)
83
+ const etag = response.headers.get('etag')
84
+ if (typeof etag === 'string' && etag !== '') {
85
+ listingETag = etag
86
+ listingCache = payload
87
+ }
58
88
  return payload
59
89
  } catch (error) {
60
90
  if (error instanceof UpstreamError) throw error
@@ -83,7 +113,7 @@ export async function postKiloStreamed({ body, signal, onData, timeoutMs = TURN_
83
113
  try {
84
114
  response = await kiloFetch(`${KILO_BASE}/chat/completions`, {
85
115
  method: 'POST',
86
- headers: { 'content-type': 'application/json', accept: 'text/event-stream', 'user-agent': 'dsh-our-free-model' },
116
+ headers: { 'content-type': 'application/json', accept: 'text/event-stream', 'user-agent': 'dsh-our-free-model', ...KILO_ATTRIBUTION },
87
117
  body: bodyText,
88
118
  redirect: 'error',
89
119
  signal,
@@ -151,9 +181,29 @@ function classifyKilo(status, text, retryAfterMs) {
151
181
  let payload
152
182
  try { payload = JSON.parse(text) } catch { payload = { error: { message: text.slice(0, 300) || `HTTP ${status}` } } }
153
183
  const type = payload?.error_type ?? payload?.error?.code ?? payload?.error?.type
184
+ const rawMeta = payload?.error?.metadata?.raw ?? payload?.metadata?.raw
185
+ let message = payload?.error?.message ?? (typeof payload?.message === 'string' ? payload.message : undefined)
186
+ if (typeof rawMeta === 'string' && rawMeta.trim() !== '') {
187
+ let rawDetail = ''
188
+ try {
189
+ const parsed = JSON.parse(rawMeta)
190
+ rawDetail = typeof parsed?.error === 'string' ? parsed.error
191
+ : typeof parsed?.message === 'string' ? parsed.message
192
+ : typeof parsed?.error?.message === 'string' ? parsed.error.message
193
+ : ''
194
+ } catch {
195
+ rawDetail = rawMeta.slice(0, 200).trim()
196
+ }
197
+ const providerName = payload?.error?.metadata?.provider_name ?? payload?.metadata?.provider_name
198
+ const providerSuffix = typeof providerName === 'string' && providerName !== '' ? ` (${providerName})` : ''
199
+ if (rawDetail !== '') {
200
+ message = message ? `${message}: ${rawDetail}${providerSuffix}` : `${rawDetail}${providerSuffix}`
201
+ }
202
+ }
154
203
  const shaped = {
155
204
  error: {
156
205
  ...payload?.error,
206
+ ...(message !== undefined ? { message } : {}),
157
207
  ...(type === undefined ? {} : { type: String(type) }),
158
208
  },
159
209
  }
@@ -1,6 +1,6 @@
1
1
  import { spawn } from 'node:child_process'
2
2
  import { randomBytes, createHash } from 'node:crypto'
3
- import { existsSync, readFileSync, createReadStream } from 'node:fs'
3
+ import { existsSync, readFileSync, createReadStream, statSync } from 'node:fs'
4
4
  import { mkdir, readFile, writeFile, rename, link, unlink, realpath } from 'node:fs/promises'
5
5
  import http from 'node:http'
6
6
  import net from 'node:net'
@@ -400,6 +400,11 @@ const engineAppearanceCSS = `
400
400
  :root[data-ofm-engine] #addSheet > .row-head { height:auto; min-height:32px; gap:8px; }
401
401
  :root[data-ofm-engine] #addSheet .find { height:32px; }
402
402
  :root[data-ofm-engine] #addSheet .kind { padding-top:16px; }
403
+ /* Upstream's "Compact at" row (#876) shrinks and wraps inside the agent prefs,
404
+ but the settings lists kept it flex:none: at a narrow panel width the fixed
405
+ 76px number input sticks past the clipped list and loses half its width.
406
+ The same shrink-and-wrap answer, for the rows upstream missed. */
407
+ :root[data-ofm-engine] .row.pref .compact-at { flex:0 1 auto; min-width:0; flex-wrap:wrap; row-gap:6px; }
403
408
  :root[data-ofm-engine-theme] {
404
409
  --ofm-bg:#f4f4f6; --ofm-card:#fff; --ofm-fg:#1c1c21; --ofm-quiet:#63636b;
405
410
  --ofm-muted:#85858d; --ofm-line:#e3e3e8; --ofm-accent:#4f46e5;
@@ -762,7 +767,31 @@ export function createMagpieEngine({ dataDir, logger, binary } = {}) {
762
767
  const env = { ...process.env }
763
768
  // An inherited Magpie override must never redirect this owned instance.
764
769
  for (const key of Object.keys(env)) if (key.toUpperCase().startsWith('MAGPIE_')) delete env[key]
770
+ // The engine wires every agent it finds under the user's home (ZCode's
771
+ // provider_config.json, Codex's config.toml, …) to itself when it starts.
772
+ // This engine's catalog is its own and may be empty, so that startup sync
773
+ // would rewrite those agents with no models. Give it a home of its own:
774
+ // it still serves its page, and no file of a real agent is touched. The
775
+ // Windows folders os.UserConfigDir reads are moved too, for the agents
776
+ // that keep their settings there (VS Code, Cline, …).
777
+ const agentHome = path.join(home, 'agent-home')
778
+ const agentRoaming = path.join(agentHome, 'AppData', 'Roaming')
779
+ const agentLocal = path.join(agentHome, 'AppData', 'Local')
780
+ for (const dir of [agentHome, agentRoaming, agentLocal]) {
781
+ await mkdir(dir, { recursive: true, mode: 0o700 })
782
+ }
783
+ // Windows folds every ancestor of a session folder into a 260-character
784
+ // budget; this redirection adds ~40, and claude appends a slug of the
785
+ // whole project path on top. A long or non-ASCII data home is the class
786
+ // of failure behind "rename the folder to something short and it works"
787
+ // — say so up front, with the escape hatch, instead of leaving a bare
788
+ // mkdir error nobody can act on.
789
+ if (agentHome.length > 200 || /[^\x20-\x7E]/.test(agentHome)) {
790
+ logger?.warn?.(`our-free-model: 引擎数据目录较长或含非 ASCII 字符(${agentHome.length} 字符)——Claude 等客户端可能无法新建会话文件夹;如遇到,请把 DSH_HOME 指向一个短的纯英文路径后重启。(engine data home is long or contains non-ASCII characters; if agents cannot create session folders, point DSH_HOME at a short ASCII path and restart)`)
791
+ }
765
792
  Object.assign(env, {
793
+ HOME: agentHome, USERPROFILE: agentHome,
794
+ APPDATA: agentRoaming, LOCALAPPDATA: agentLocal,
766
795
  XDG_CONFIG_HOME: dirs.config, XDG_CACHE_HOME: dirs.cache,
767
796
  XDG_DATA_HOME: dirs.data, XDG_STATE_HOME: dirs.state,
768
797
  MAGPIE_ADDR: `127.0.0.1:${gatewayPort}`, MAGPIE_WEB_KEY: webKey,
@@ -924,7 +953,13 @@ export function createMagpieEngine({ dataDir, logger, binary } = {}) {
924
953
  if (failure.statusCode === 400) throw engineError('Invalid gateway key request', 400)
925
954
  if (failure.statusCode === 413) throw engineError('Gateway key request exceeds limit', 413)
926
955
  if (options.signal?.aborted) throw new DOMException('Engine request aborted', 'AbortError')
927
- throw engineError('Engine request failed')
956
+ // The engine's own transport refusals carry the actionable fact (the
957
+ // refused path, the socket error); a bare "Engine request failed" turns a
958
+ // diagnosable boot into a support thread. Keep the detail, bounded, and
959
+ // never from response bodies — those can quote user content.
960
+ const detail = typeof failure?.message === 'string' && failure.message !== ''
961
+ ? `: ${failure.message.slice(0, 160)}` : ''
962
+ throw engineError(`Engine request failed${detail}`)
928
963
  }
929
964
  }
930
965
  const proxy = async (req, res, subpath = '/', options = {}) => {
@@ -1101,3 +1136,27 @@ export function createMagpieEngine({ dataDir, logger, binary } = {}) {
1101
1136
  }
1102
1137
  return { start, status, close, request, proxy, gateway }
1103
1138
  }
1139
+
1140
+ /**
1141
+ * A lightweight PATH probe for the agent CLIs the engine connects to (claude
1142
+ * first among them). The engine manages agent installs itself, but its
1143
+ * downloader cannot reach the release host from every network, so the practical
1144
+ * requirement is a pre-installed CLI — warn at boot instead of at the user's
1145
+ * first failed connect. Never resolves shell shims deeper than a PATH entry.
1146
+ */
1147
+ export function findAgentCli(command) {
1148
+ const name = String(command ?? '').trim()
1149
+ if (name === '' || name.includes('/') || name.includes('\\')) return undefined
1150
+ const spellings = process.platform === 'win32' ? ['.cmd', '.bat', '.exe', ''] : ['']
1151
+ const dirs = [
1152
+ ...(process.env.PATH ?? '').split(path.delimiter).filter(Boolean),
1153
+ ...(process.platform === 'win32' && process.env.APPDATA ? [path.join(process.env.APPDATA, 'npm')] : []),
1154
+ ]
1155
+ for (const dir of dirs) {
1156
+ for (const spelling of spellings) {
1157
+ const candidate = path.join(dir, `${name}${spelling}`)
1158
+ try { if (statSync(candidate).isFile()) return candidate } catch { /* try the next spelling */ }
1159
+ }
1160
+ }
1161
+ return undefined
1162
+ }
@@ -4,14 +4,42 @@ import { deniedModel } from './magpie-policy.js'
4
4
 
5
5
  const PREFIX = 'subscription:magpie/'
6
6
 
7
+ /**
8
+ * Engine inference rides the host's replaceable `globalThis.fetch` — the same
9
+ * transport the imported sidecar subscriptions already use — so local proxy
10
+ * plugins that swap `globalThis.fetch` to re-serialize outgoing bodies can see
11
+ * engine turns again, as they could before the 2026-10-24 private-transport
12
+ * quarantine. Startup, health, management and catalog reads keep the private
13
+ * loopback transport: a broken fetch replacement must never keep the workspace
14
+ * from becoming ready. An explicit `fetchImpl` (tests, embedded hosts) still
15
+ * governs both paths, and OFM_ENGINE_PRIVATE_FETCH=1 forces inference back
16
+ * onto the private transport.
17
+ */
18
+ function defaultInferenceTransport() {
19
+ return process.env.OFM_ENGINE_PRIVATE_FETCH === '1' ? loopbackFetch : globalThis.fetch
20
+ }
21
+
7
22
  /** Expose the engine's live catalog through the existing DSH stream adapter. */
8
- export function createMagpieModels({ engine, stats, logger, onChanged = () => {}, fetchImpl = loopbackFetch }) {
23
+ export function createMagpieModels({ engine, stats, logger, onChanged = () => {}, fetchImpl = loopbackFetch, inferenceFetch, extensionRuntime } = {}) {
9
24
  let models = []
10
25
  let pending
11
26
  let closed = false
12
27
  let owner
28
+ // An explicit fetchImpl governs inference and catalog alike (the historical
29
+ // contract); otherwise inference is the replaceable transport and only the
30
+ // catalog read stays private.
31
+ const catalogFetch = fetchImpl
32
+ const inferenceTransport = inferenceFetch ?? (fetchImpl !== loopbackFetch ? fetchImpl : defaultInferenceTransport())
13
33
  const runtime = createSubscriptionRuntime({
14
- stats, logger, fetchImpl,
34
+ stats, logger,
35
+ fetchImpl: inferenceTransport,
36
+ // Enhancement extensions get the same fail-open body seam the harness lane
37
+ // gives them: one plain-object pass, microseconds, no gateway hop.
38
+ onPayload: async (payload, meta) => {
39
+ const source = typeof extensionRuntime === 'function' ? extensionRuntime() : extensionRuntime
40
+ if (source?.applyRequestHooks === undefined) return payload
41
+ return source.applyRequestHooks(payload, { ...meta, lane: 'magpie' })
42
+ },
15
43
  subscriptions: {
16
44
  list: () => [{
17
45
  id: 'magpie', provider: 'magpie', enabled: engine.status().state === 'ready',
@@ -65,7 +93,7 @@ export function createMagpieModels({ engine, stats, logger, onChanged = () => {}
65
93
  if (pending) return pending
66
94
  pending = (async () => {
67
95
  const gateway = engine.gateway()
68
- const response = await fetchImpl(`${gateway.url.replace(/\/$/, '')}/v1/models`, {
96
+ const response = await catalogFetch(`${gateway.url.replace(/\/$/, '')}/v1/models`, {
69
97
  headers: { authorization: `Bearer ${gateway.key}` },
70
98
  signal: AbortSignal.timeout(15000), redirect: 'error',
71
99
  })
package/src/model-test.js CHANGED
@@ -84,7 +84,9 @@ export function modelAvailability(id, snapshot, listed = false) {
84
84
  // Older saved snapshots have only `at`, which retains their previous order.
85
85
  const measured = manual && (!probe || manual.at >= (probe.startedAt ?? probe.at)) ? manual : probe
86
86
  return {
87
- availabilitySource: measured === manual && manual ? 'manual' : listed ? 'listing' : 'probe',
87
+ availabilitySource: measured === manual && manual ? 'manual'
88
+ : measured?.source === 'passive' ? 'passive'
89
+ : listed ? 'listing' : 'probe',
88
90
  availability: measured?.state ?? 'unknown',
89
91
  detail: measured?.detail ?? '', probedAt: measured?.at ?? 0,
90
92
  ttftMs: measured?.ttftMs, latencyMs: measured?.latencyMs,