dsh-memu-memory 1.1.2 → 1.2.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/AGENTS.md +8 -6
- package/README.md +50 -5
- package/README.zh.md +49 -5
- package/docs/active-memory-ab.md +385 -0
- package/docs/binding-lifecycle.md +99 -0
- package/docs/contributing.md +6 -4
- package/docs/emotion-model-spec.md +281 -0
- package/docs/env-and-commands.md +13 -0
- package/docs/host-api.md +35 -3
- package/docs/memory-semantics.md +40 -11
- package/docs/recall-benchmark.md +238 -0
- package/docs/troubleshooting.md +395 -366
- package/lib/active-memory.js +165 -0
- package/lib/activity-log.js +190 -0
- package/lib/backup.js +1103 -0
- package/lib/binding-diagnostics.js +635 -0
- package/lib/client.js +728 -58
- package/lib/consolidation-ledger.js +347 -0
- package/lib/consolidation-receipt.js +284 -0
- package/lib/consolidation-snapshot.js +274 -0
- package/lib/consolidation-worker.js +115 -0
- package/lib/curator.js +81 -37
- package/lib/cycle.js +43 -6
- package/lib/embedding-catalog.js +763 -0
- package/lib/embedding-download.js +407 -0
- package/lib/embedding-runtime.js +710 -0
- package/lib/engine.js +2 -2
- package/lib/index.js +36 -0
- package/lib/inject.js +232 -103
- package/lib/local-embedding-entry.js +145 -0
- package/lib/local-embedding-manager.js +267 -0
- package/lib/local-embedding-protocol.js +173 -0
- package/lib/local-embedding-worker.js +652 -0
- package/lib/local-vector-service.js +283 -0
- package/lib/memory.js +599 -57
- package/lib/memu-config.js +10 -22
- package/lib/memu.js +53 -19
- package/lib/mood.js +268 -39
- package/lib/paths.js +24 -0
- package/lib/preset-scope.js +1 -1
- package/lib/private.js +450 -29
- package/lib/prompts/private-consolidation.coding.md +1 -0
- package/lib/prompts/private-consolidation.md +1 -0
- package/lib/prompts/private-consolidation.mixed.md +1 -0
- package/lib/prompts/private-consolidation.rp.md +1 -0
- package/lib/prompts.js +4 -4
- package/lib/remote-summary.js +51 -25
- package/lib/rpc.js +54 -5
- package/lib/state.js +137 -172
- package/lib/tools.js +58 -7
- package/lib/vector-migration.js +878 -0
- package/lib/vector-profile.js +968 -0
- package/package.json +4 -2
- package/scripts/acceptance-local.mjs +7 -5
- package/scripts/active-memory-ab-live.mjs +655 -0
- package/scripts/active-memory-ab.mjs +1302 -0
- package/scripts/chain-matrix.mjs +537 -0
- package/scripts/check-all.mjs +29 -1
- package/scripts/check-doc-drift.mjs +353 -0
- package/scripts/diagnose-bindings.mjs +176 -0
- package/scripts/local-runtime-install.mjs +142 -0
- package/scripts/local-runtime-verify.mjs +56 -0
- package/scripts/local-vector-verify.mjs +93 -0
- package/scripts/recall-bench.mjs +813 -0
- package/scripts/repetition-ab-scenarios.mjs +93 -0
- package/scripts/repetition-detector-snapshot.mjs +314 -0
- package/scripts/repetition-eval-live.mjs +360 -0
- package/scripts/repetition-eval.mjs +702 -0
- package/tests/activation.test.mjs +89 -0
- package/tests/active-memory-ab.test.mjs +745 -0
- package/tests/active-memory.test.mjs +129 -0
- package/tests/activity-log.test.mjs +126 -0
- package/tests/activity-wiring.test.mjs +103 -0
- package/tests/appendix-b.test.mjs +212 -0
- package/tests/appendix-b2.test.mjs +166 -0
- package/tests/audit-regression.test.mjs +272 -0
- package/tests/audit-safety.test.mjs +227 -0
- package/tests/backend-compensation.test.mjs +272 -0
- package/tests/backup.test.mjs +793 -0
- package/tests/binding-diagnostics.test.mjs +720 -0
- package/tests/browser.test.mjs +366 -0
- package/tests/client-render.test.mjs +892 -0
- package/tests/coalesce.test.mjs +113 -0
- package/tests/config.test.mjs +159 -0
- package/tests/consolidation-credential.test.mjs +320 -0
- package/tests/consolidation-fork.test.mjs +357 -0
- package/tests/consolidation-ledger.test.mjs +298 -0
- package/tests/consolidation-snapshot.test.mjs +321 -0
- package/tests/consolidation-value.test.mjs +89 -0
- package/tests/context-scope.test.mjs +40 -0
- package/tests/cue.test.mjs +44 -0
- package/tests/curator-probe.test.mjs +433 -0
- package/tests/curator.test.mjs +150 -0
- package/tests/cycle.test.mjs +411 -0
- package/tests/disclosure.test.mjs +231 -0
- package/tests/doc-drift.test.mjs +222 -0
- package/tests/embedding-catalog.test.mjs +266 -0
- package/tests/embedding-download.test.mjs +391 -0
- package/tests/embedding-runtime.test.mjs +389 -0
- package/tests/emotion-chain.test.mjs +84 -0
- package/tests/emotion-contract.test.mjs +445 -0
- package/tests/engine.test.mjs +35 -0
- package/tests/fixtures/active-memory-ab/README.md +56 -0
- package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
- package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
- package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
- package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
- package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
- package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
- package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
- package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
- package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
- package/tests/fixtures/active-memory-ab/manifest.json +199 -0
- package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
- package/tests/fixtures/recall-bench/02-exact.json +153 -0
- package/tests/fixtures/recall-bench/03-negation.json +173 -0
- package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
- package/tests/fixtures/recall-bench/05-conflict.json +194 -0
- package/tests/fixtures/recall-bench/06-boundary.json +273 -0
- package/tests/fixtures/recall-bench/07-secret.json +191 -0
- package/tests/fixtures/recall-bench/08-coding.json +164 -0
- package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
- package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
- package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
- package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
- package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
- package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
- package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
- package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
- package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
- package/tests/fixtures/repetition-eval/02-en.json +97 -0
- package/tests/fixtures/repetition-eval/03-code.json +91 -0
- package/tests/fixtures/repetition-eval/04-names.json +116 -0
- package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
- package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
- package/tests/graph-view.test.mjs +187 -0
- package/tests/graph.test.mjs +77 -0
- package/tests/hook-order.test.mjs +72 -0
- package/tests/humanlike.test.mjs +232 -0
- package/tests/hybrid-recall.test.mjs +92 -0
- package/tests/inbound.test.mjs +393 -0
- package/tests/inject-guide.test.mjs +364 -0
- package/tests/inject.test.mjs +222 -0
- package/tests/injection-budget-settings.test.mjs +35 -0
- package/tests/injection-budget.test.mjs +64 -0
- package/tests/injection-chain.test.mjs +191 -0
- package/tests/loader-register.mjs +3 -0
- package/tests/loader.mjs +12 -0
- package/tests/local-embedding-entry.test.mjs +95 -0
- package/tests/local-embedding-manager.test.mjs +476 -0
- package/tests/local-embedding-protocol.test.mjs +95 -0
- package/tests/local-embedding-worker.test.mjs +346 -0
- package/tests/managed-memory.test.mjs +536 -0
- package/tests/managed-runtime.test.mjs +40 -0
- package/tests/media.test.mjs +132 -0
- package/tests/migration-payload.test.mjs +42 -0
- package/tests/mood.test.mjs +109 -0
- package/tests/multiprocess.test.mjs +29 -0
- package/tests/new-session-gate.test.mjs +361 -0
- package/tests/package-shipping.test.mjs +84 -0
- package/tests/preset-scope.test.mjs +212 -0
- package/tests/prompts.test.mjs +250 -0
- package/tests/react-snapshot.test.mjs +23 -0
- package/tests/recall-bench.test.mjs +306 -0
- package/tests/remote.test.mjs +140 -0
- package/tests/rename.test.mjs +30 -0
- package/tests/repetition-detector-snapshot.test.mjs +231 -0
- package/tests/repetition-eval-live.test.mjs +177 -0
- package/tests/repetition-eval.test.mjs +373 -0
- package/tests/salient-date.test.mjs +142 -0
- package/tests/settings-beat.test.mjs +221 -0
- package/tests/short-term.test.mjs +166 -0
- package/tests/sidebar-beat.test.mjs +208 -0
- package/tests/sidebar-refresh.test.mjs +123 -0
- package/tests/sidebar.test.mjs +70 -0
- package/tests/snippet.test.mjs +47 -0
- package/tests/source-kind.test.mjs +52 -0
- package/tests/state.test.mjs +150 -0
- package/tests/stats.test.mjs +112 -0
- package/tests/storage.test.mjs +125 -0
- package/tests/stubs/dsh-llm.js +5 -0
- package/tests/stubs/dsh-session.js +7 -0
- package/tests/stubs/dsh-tools.js +101 -0
- package/tests/tool-schema.test.mjs +73 -0
- package/tests/tools.test.mjs +184 -0
- package/tests/ui-defaults.test.mjs +57 -0
- package/tests/update-fields.test.mjs +138 -0
- package/tests/vector-migration.test.mjs +733 -0
- package/tests/vector-profile.test.mjs +425 -0
- package/tests/visibility-migration.test.mjs +152 -0
- package/tests/webui-auth.test.mjs +454 -0
- package/tests/webui.test.mjs +164 -0
- package/tests/windows-env.test.mjs +89 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { EventEmitter } from 'node:events'
|
|
4
|
+
import { spawn as spawnReal } from 'node:child_process'
|
|
5
|
+
import { createEmbeddingWorkerManager, childEnvironment, embeddingWorkerManager, closeEmbeddingWorkers } from '../lib/local-embedding-manager.js'
|
|
6
|
+
|
|
7
|
+
const MODEL = 'bge-small-zh-v1.5@r1+mean-l2-512'
|
|
8
|
+
const TOKEN_PATTERN = /^[0-9a-f]{64}$/
|
|
9
|
+
|
|
10
|
+
const PROFILE = Object.freeze({
|
|
11
|
+
catalogId: 'bge-small-zh-v1.5',
|
|
12
|
+
fingerprint: 'f'.repeat(64),
|
|
13
|
+
dimension: 512,
|
|
14
|
+
modelDir: 'D:/managed/model',
|
|
15
|
+
runtimeDir: 'D:/managed/runtime',
|
|
16
|
+
manifest: Object.freeze({ id: 'bge-small-zh-v1.5', revision: 'r1', protocol: 'mean-l2-512', dimension: 512, verified: true }),
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const readyLine = (overrides = {}) => ({
|
|
20
|
+
type: 'ready',
|
|
21
|
+
baseUrl: 'http://127.0.0.1:45123/v1',
|
|
22
|
+
url: 'http://127.0.0.1:45123/v1/embeddings',
|
|
23
|
+
model: MODEL,
|
|
24
|
+
dimension: 512,
|
|
25
|
+
pid: 4242,
|
|
26
|
+
...overrides,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const tick = () => new Promise(resolve => setImmediate(resolve))
|
|
30
|
+
const push = (child, value) => child.stdout.emit('data', Buffer.from(JSON.stringify(value) + String.fromCharCode(10)))
|
|
31
|
+
const rawPush = (child, text) => child.stdout.emit('data', Buffer.from(text))
|
|
32
|
+
const exitChild = (child, code = 1) => child.emit('close', code)
|
|
33
|
+
|
|
34
|
+
// A stand-in for a spawned worker: real EventEmitters with the same event names, so
|
|
35
|
+
// the manager's lifecycle can be exercised without a native runtime on any platform.
|
|
36
|
+
function fakeSpawn() {
|
|
37
|
+
const calls = []
|
|
38
|
+
const children = []
|
|
39
|
+
const spawnImpl = (command, args, options) => {
|
|
40
|
+
const child = new EventEmitter()
|
|
41
|
+
child.command = command
|
|
42
|
+
child.args = args
|
|
43
|
+
child.options = options
|
|
44
|
+
child.events = []
|
|
45
|
+
child.stdin = new EventEmitter()
|
|
46
|
+
child.stdin.ended = false
|
|
47
|
+
child.stdin.end = () => { child.events.push('stdin.end'); child.stdin.ended = true }
|
|
48
|
+
child.stdout = new EventEmitter()
|
|
49
|
+
child.stderr = new EventEmitter()
|
|
50
|
+
child.killed = false
|
|
51
|
+
child.kill = () => { child.events.push('kill'); child.killed = true }
|
|
52
|
+
calls.push({ command, args, options })
|
|
53
|
+
children.push(child)
|
|
54
|
+
return child
|
|
55
|
+
}
|
|
56
|
+
return { spawnImpl, calls, children }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function captureLogger() {
|
|
60
|
+
const lines = []
|
|
61
|
+
const record = level => message => lines.push(level + ':' + String(message))
|
|
62
|
+
return { lines, logger: { log: record('log'), info: record('info'), warn: record('warn'), error: record('error') } }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const makeManager = (overrides = {}) => createEmbeddingWorkerManager({
|
|
66
|
+
verifyProfile: () => PROFILE,
|
|
67
|
+
logger: { warn: () => {}, info: () => {}, error: () => {} },
|
|
68
|
+
timeoutMs: 5_000,
|
|
69
|
+
...overrides,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('concurrent acquires of one profile share a single worker and a bare loopback endpoint', async () => {
|
|
73
|
+
const { spawnImpl, calls, children } = fakeSpawn()
|
|
74
|
+
const manager = makeManager({ spawnImpl })
|
|
75
|
+
const first = manager.acquire(PROFILE)
|
|
76
|
+
const second = manager.acquire(PROFILE)
|
|
77
|
+
assert.equal(calls.length, 1, 'a second acquire must not spawn a second child')
|
|
78
|
+
assert.deepEqual(manager.status(), { running: 0, booting: 1, closed: false, tripped: 0 })
|
|
79
|
+
push(children[0], readyLine())
|
|
80
|
+
const [a, b] = await Promise.all([first, second])
|
|
81
|
+
assert.equal(calls.length, 1)
|
|
82
|
+
assert.equal(a, b, 'both callers observe the same endpoint object')
|
|
83
|
+
assert.ok(Object.isFrozen(a), 'the endpoint handed to MemU is immutable')
|
|
84
|
+
assert.equal(a.baseUrl, 'http://127.0.0.1:45123/v1')
|
|
85
|
+
assert.equal(a.model, MODEL, 'the ready model id is the manifest id, not the catalog id')
|
|
86
|
+
assert.equal(a.dimension, 512)
|
|
87
|
+
assert.match(a.token, TOKEN_PATTERN)
|
|
88
|
+
assert.deepEqual(manager.status(), { running: 1, booting: 0, closed: false, tripped: 0 })
|
|
89
|
+
const { args, options } = calls[0]
|
|
90
|
+
assert.equal(args.length, 1)
|
|
91
|
+
assert.match(args[0], /local-embedding-entry\.js$/)
|
|
92
|
+
assert.equal(options.env.DSH_MEMU_EMBED_TOKEN, a.token)
|
|
93
|
+
assert.equal(options.env.DSH_MEMU_EMBED_HOST, '127.0.0.1')
|
|
94
|
+
assert.equal(options.env.DSH_MEMU_EMBED_PORT, '0')
|
|
95
|
+
assert.equal(options.env.DSH_MEMU_EMBED_MODEL_DIR, PROFILE.modelDir)
|
|
96
|
+
assert.equal(options.env.DSH_MEMU_EMBED_RUNTIME_DIR, PROFILE.runtimeDir)
|
|
97
|
+
assert.equal(options.env.MEMU_TELEMETRY, '0')
|
|
98
|
+
assert.equal(options.env.DO_NOT_TRACK, '1')
|
|
99
|
+
assert.equal(options.env.ELECTRON_RUN_AS_NODE, '1')
|
|
100
|
+
assert.equal(JSON.parse(options.env.DSH_MEMU_EMBED_MANIFEST).verified, true)
|
|
101
|
+
assert.equal(manager.close(), 1)
|
|
102
|
+
assert.deepEqual(children[0].events, ['stdin.end', 'kill'], 'stdin EOF is asked for before the kill')
|
|
103
|
+
assert.deepEqual(manager.status(), { running: 0, booting: 0, closed: true, tripped: 0 })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('every worker gets a fresh random token that never reaches logs, status or errors', async () => {
|
|
107
|
+
const log = captureLogger()
|
|
108
|
+
const { spawnImpl, children } = fakeSpawn()
|
|
109
|
+
const manager = makeManager({ spawnImpl, logger: log.logger })
|
|
110
|
+
const firstBoot = manager.acquire(PROFILE)
|
|
111
|
+
push(children[0], readyLine())
|
|
112
|
+
const endpoint = await firstBoot
|
|
113
|
+
assert.match(endpoint.token, TOKEN_PATTERN)
|
|
114
|
+
const other = manager.acquire({ ...PROFILE, fingerprint: 'e'.repeat(64) })
|
|
115
|
+
assert.notEqual(children[1].options.env.DSH_MEMU_EMBED_TOKEN, endpoint.token, 'tokens are per worker')
|
|
116
|
+
// A worker that echoes its own secret back must not get it into any message.
|
|
117
|
+
push(children[1], readyLine({ baseUrl: 'http://user:' + endpoint.token + '@127.0.0.1:45124/v1' }))
|
|
118
|
+
await assert.rejects(other, error => {
|
|
119
|
+
assert.equal(error.code, 'LOCAL_VECTOR_BAD_READY')
|
|
120
|
+
assert.equal(error.fallback, 'lexical')
|
|
121
|
+
assert.ok(!String(error.message).includes(endpoint.token))
|
|
122
|
+
return true
|
|
123
|
+
})
|
|
124
|
+
await tick()
|
|
125
|
+
const surface = JSON.stringify(manager.status()) + log.lines.join(String.fromCharCode(10))
|
|
126
|
+
assert.ok(!surface.includes(endpoint.token), 'no token may appear in status or logs')
|
|
127
|
+
assert.ok(!surface.includes('45124'), 'no endpoint may appear in status or logs')
|
|
128
|
+
manager.close()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('readiness is validated against the requested profile in every direction', async () => {
|
|
132
|
+
const cases = [
|
|
133
|
+
['a non-object', 'oops'],
|
|
134
|
+
['a null message', null],
|
|
135
|
+
['a wrong type', readyLine({ type: 'hello' })],
|
|
136
|
+
['another model', readyLine({ model: 'other-model@r1+mean-l2-512' })],
|
|
137
|
+
['the catalog id instead of the manifest id', readyLine({ model: PROFILE.catalogId })],
|
|
138
|
+
['another dimension', readyLine({ dimension: 384 })],
|
|
139
|
+
['a wildcard host', readyLine({ baseUrl: 'http://0.0.0.0:45123/v1', url: null })],
|
|
140
|
+
['a remote host', readyLine({ baseUrl: 'http://10.0.0.2:45123/v1', url: null })],
|
|
141
|
+
['the localhost alias', readyLine({ baseUrl: 'http://localhost:45123/v1', url: null })],
|
|
142
|
+
['https', readyLine({ baseUrl: 'https://127.0.0.1:45123/v1', url: null })],
|
|
143
|
+
['a missing port', readyLine({ baseUrl: 'http://127.0.0.1/v1', url: null })],
|
|
144
|
+
['port zero', readyLine({ baseUrl: 'http://127.0.0.1:0/v1', url: null })],
|
|
145
|
+
['embedded credentials', readyLine({ baseUrl: 'http://user:pass@127.0.0.1:45123/v1', url: null })],
|
|
146
|
+
['a query string', readyLine({ baseUrl: 'http://127.0.0.1:45123/v1?x=1', url: null })],
|
|
147
|
+
['a fragment', readyLine({ baseUrl: 'http://127.0.0.1:45123/v1#x', url: null })],
|
|
148
|
+
['another path', readyLine({ baseUrl: 'http://127.0.0.1:45123/', url: null })],
|
|
149
|
+
['an embeddings url on another base', readyLine({ url: 'http://127.0.0.1:45123/v2/embeddings' })],
|
|
150
|
+
]
|
|
151
|
+
for (const [label, value] of cases) {
|
|
152
|
+
const { spawnImpl, children } = fakeSpawn()
|
|
153
|
+
const manager = makeManager({ spawnImpl })
|
|
154
|
+
const pending = manager.acquire(PROFILE)
|
|
155
|
+
if (typeof value === 'string') rawPush(children[0], value + String.fromCharCode(10))
|
|
156
|
+
else push(children[0], value)
|
|
157
|
+
await assert.rejects(pending, error => {
|
|
158
|
+
assert.equal(error.code, 'LOCAL_VECTOR_BAD_READY', label)
|
|
159
|
+
assert.equal(error.fallback, 'lexical', label)
|
|
160
|
+
return true
|
|
161
|
+
})
|
|
162
|
+
assert.equal(children[0].stdin.ended, true, label)
|
|
163
|
+
assert.equal(children[0].killed, true, label)
|
|
164
|
+
assert.deepEqual(manager.status(), { running: 0, booting: 0, closed: false, tripped: 0 }, label)
|
|
165
|
+
manager.close()
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('unreadable and oversized readiness lines fail closed', async () => {
|
|
170
|
+
const unreadable = fakeSpawn()
|
|
171
|
+
const unreadableManager = makeManager({ spawnImpl: unreadable.spawnImpl })
|
|
172
|
+
const pending = unreadableManager.acquire(PROFILE)
|
|
173
|
+
rawPush(unreadable.children[0], 'not json at all' + String.fromCharCode(10))
|
|
174
|
+
await assert.rejects(pending, error => error.code === 'LOCAL_VECTOR_BAD_READY')
|
|
175
|
+
unreadableManager.close()
|
|
176
|
+
|
|
177
|
+
const oversized = fakeSpawn()
|
|
178
|
+
const oversizedManager = makeManager({ spawnImpl: oversized.spawnImpl })
|
|
179
|
+
const chunked = oversizedManager.acquire(PROFILE)
|
|
180
|
+
rawPush(oversized.children[0], 'x'.repeat(20_000))
|
|
181
|
+
await assert.rejects(chunked, error => error.code === 'LOCAL_VECTOR_BAD_READY')
|
|
182
|
+
assert.equal(oversized.children[0].killed, true)
|
|
183
|
+
oversizedManager.close()
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('a worker that never reports ready times out, is stopped and stays observable', async () => {
|
|
187
|
+
const log = captureLogger()
|
|
188
|
+
const { spawnImpl, children } = fakeSpawn()
|
|
189
|
+
const manager = makeManager({ spawnImpl, logger: log.logger, timeoutMs: 25 })
|
|
190
|
+
await assert.rejects(manager.acquire(PROFILE), error => {
|
|
191
|
+
assert.equal(error.code, 'LOCAL_VECTOR_START_TIMEOUT')
|
|
192
|
+
assert.equal(error.fallback, 'lexical')
|
|
193
|
+
return true
|
|
194
|
+
})
|
|
195
|
+
assert.deepEqual(children[0].events, ['stdin.end', 'kill'])
|
|
196
|
+
assert.deepEqual(manager.status(), { running: 0, booting: 0, closed: false, tripped: 0 })
|
|
197
|
+
manager.close()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test('healthy boots never count toward the fuse while an unexpected exit does', async () => {
|
|
201
|
+
const { spawnImpl, calls, children } = fakeSpawn()
|
|
202
|
+
const manager = makeManager({ spawnImpl, maxRestarts: 1, restartWindowMs: 60_000 })
|
|
203
|
+
const first = manager.acquire(PROFILE)
|
|
204
|
+
push(children[0], readyLine())
|
|
205
|
+
assert.equal((await first).model, MODEL)
|
|
206
|
+
const second = manager.acquire({ ...PROFILE, fingerprint: '1'.repeat(64) })
|
|
207
|
+
push(children[1], readyLine())
|
|
208
|
+
assert.equal((await second).model, MODEL)
|
|
209
|
+
assert.deepEqual(manager.status(), { running: 2, booting: 0, closed: false, tripped: 0 })
|
|
210
|
+
assert.equal(calls.length, 2)
|
|
211
|
+
// An exit the manager did not ask for is counted, so that profile is fused.
|
|
212
|
+
exitChild(children[0], 1)
|
|
213
|
+
assert.equal(manager.status().tripped, 1)
|
|
214
|
+
await assert.rejects(manager.acquire(PROFILE), error => error.code === 'LOCAL_VECTOR_RESTART_FUSED')
|
|
215
|
+
assert.equal(manager.status().running, 1, 'the other profile keeps its worker')
|
|
216
|
+
assert.equal(calls.length, 2)
|
|
217
|
+
manager.close()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('failed boots trip the fuse, block respawns, and the cooldown releases it', async () => {
|
|
221
|
+
let clock = 7_000_000
|
|
222
|
+
const log = captureLogger()
|
|
223
|
+
const { spawnImpl, calls, children } = fakeSpawn()
|
|
224
|
+
const manager = makeManager({ spawnImpl, logger: log.logger, now: () => clock, maxRestarts: 2, restartWindowMs: 60_000 })
|
|
225
|
+
for (const index of [0, 1]) {
|
|
226
|
+
const pending = manager.acquire(PROFILE)
|
|
227
|
+
push(children[index], readyLine({ model: 'some-other-model@r1+mean' }))
|
|
228
|
+
await assert.rejects(pending, error => error.code === 'LOCAL_VECTOR_BAD_READY')
|
|
229
|
+
clock += 1_000
|
|
230
|
+
}
|
|
231
|
+
assert.equal(calls.length, 2)
|
|
232
|
+
await assert.rejects(manager.acquire(PROFILE), error => {
|
|
233
|
+
assert.equal(error.code, 'LOCAL_VECTOR_RESTART_FUSED')
|
|
234
|
+
assert.equal(error.fallback, 'lexical')
|
|
235
|
+
return true
|
|
236
|
+
})
|
|
237
|
+
assert.equal(calls.length, 2, 'a fused profile must not spawn again')
|
|
238
|
+
assert.deepEqual(manager.status(), { running: 0, booting: 0, closed: false, tripped: 1 })
|
|
239
|
+
// The fuse is per profile: another fingerprint still boots.
|
|
240
|
+
const otherProfile = manager.acquire({ ...PROFILE, fingerprint: 'a'.repeat(64) })
|
|
241
|
+
push(children[2], readyLine())
|
|
242
|
+
assert.equal((await otherProfile).model, MODEL)
|
|
243
|
+
assert.equal(manager.status().tripped, 1)
|
|
244
|
+
// After the window the same profile is allowed one more attempt.
|
|
245
|
+
clock += 61_000
|
|
246
|
+
assert.equal(manager.status().tripped, 0)
|
|
247
|
+
const retry = manager.acquire(PROFILE)
|
|
248
|
+
push(children[3], readyLine())
|
|
249
|
+
assert.equal((await retry).model, MODEL)
|
|
250
|
+
assert.equal(manager.status().running, 2)
|
|
251
|
+
manager.close()
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('an unexpected exit after ready is warned about and the next acquire starts a new child', async () => {
|
|
255
|
+
const log = captureLogger()
|
|
256
|
+
const { spawnImpl, children } = fakeSpawn()
|
|
257
|
+
const manager = makeManager({ spawnImpl, logger: log.logger })
|
|
258
|
+
const pending = manager.acquire(PROFILE)
|
|
259
|
+
push(children[0], readyLine())
|
|
260
|
+
const endpoint = await pending
|
|
261
|
+
exitChild(children[0], 1)
|
|
262
|
+
assert.equal(manager.status().running, 0)
|
|
263
|
+
assert.ok(log.lines.some(line => line.startsWith('warn:') && line.includes('exited')), 'an exit must stay observable')
|
|
264
|
+
const again = manager.acquire(PROFILE)
|
|
265
|
+
push(children[1], readyLine({ baseUrl: 'http://127.0.0.1:45124/v1', url: 'http://127.0.0.1:45124/v1/embeddings' }))
|
|
266
|
+
const next = await again
|
|
267
|
+
assert.notEqual(next.token, endpoint.token)
|
|
268
|
+
assert.notEqual(next.baseUrl, endpoint.baseUrl)
|
|
269
|
+
manager.close()
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test('close rejects an in-flight acquire promptly instead of waiting for the child', async () => {
|
|
273
|
+
const log = captureLogger()
|
|
274
|
+
const { spawnImpl, calls, children } = fakeSpawn()
|
|
275
|
+
const manager = makeManager({ spawnImpl, logger: log.logger, timeoutMs: 120_000 })
|
|
276
|
+
const pending = manager.acquire(PROFILE)
|
|
277
|
+
assert.equal(manager.close(), 1)
|
|
278
|
+
await assert.rejects(pending, error => {
|
|
279
|
+
assert.equal(error.code, 'LOCAL_VECTOR_MANAGER_CLOSED')
|
|
280
|
+
assert.equal(error.fallback, 'lexical')
|
|
281
|
+
return true
|
|
282
|
+
})
|
|
283
|
+
assert.deepEqual(children[0].events, ['stdin.end', 'kill'])
|
|
284
|
+
// The late exit of the child we killed is not a crash and is not reported.
|
|
285
|
+
exitChild(children[0], 0)
|
|
286
|
+
await tick()
|
|
287
|
+
assert.deepEqual(log.lines, [])
|
|
288
|
+
assert.equal(manager.status().closed, true)
|
|
289
|
+
assert.equal(manager.close(), 0, 'close is idempotent')
|
|
290
|
+
await assert.rejects(manager.acquire(PROFILE), error => error.code === 'LOCAL_VECTOR_MANAGER_CLOSED')
|
|
291
|
+
assert.equal(calls.length, 1, 'a closed manager never spawns')
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
test('an EPIPE on the worker pipes degrades recall instead of crashing the host', async () => {
|
|
295
|
+
const { spawnImpl, children } = fakeSpawn()
|
|
296
|
+
const manager = makeManager({ spawnImpl })
|
|
297
|
+
const pending = manager.acquire(PROFILE)
|
|
298
|
+
const child = children[0]
|
|
299
|
+
const epipe = Object.assign(new Error('write EPIPE'), { code: 'EPIPE', errno: -4047 })
|
|
300
|
+
// The child died; its pipes now emit 'error'. Without a listener this is an
|
|
301
|
+
// uncaught exception in this very process.
|
|
302
|
+
assert.doesNotThrow(() => child.stdin.emit('error', epipe))
|
|
303
|
+
assert.doesNotThrow(() => child.stdout.emit('error', epipe))
|
|
304
|
+
assert.doesNotThrow(() => child.stderr.emit('error', epipe))
|
|
305
|
+
exitChild(child, 1)
|
|
306
|
+
await assert.rejects(pending, error => error.code === 'LOCAL_VECTOR_WORKER_EXITED')
|
|
307
|
+
assert.doesNotThrow(() => manager.close())
|
|
308
|
+
assert.doesNotThrow(() => manager.close())
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test('ambient proxies and remote credentials never reach the worker', async () => {
|
|
312
|
+
const ambient = {
|
|
313
|
+
HTTP_PROXY: 'http://127.0.0.1:7890',
|
|
314
|
+
https_proxy: 'http://127.0.0.1:7890',
|
|
315
|
+
ALL_PROXY: 'socks5://127.0.0.1:7890',
|
|
316
|
+
NO_PROXY: 'localhost',
|
|
317
|
+
MEMU_HTTP_PROXY: 'http://127.0.0.1:1',
|
|
318
|
+
MEMU_API_KEY: 'ambient-memu-key',
|
|
319
|
+
MEMU_BASE_URL: 'https://api.example.invalid/v1',
|
|
320
|
+
MEMU_EMBED_PROVIDER: 'openai',
|
|
321
|
+
OPENAI_API_KEY: 'ambient-openai-key',
|
|
322
|
+
DSH_MEMU_EMBED_TOKEN: 'stale-token',
|
|
323
|
+
DSH_MEMU_EMBED_MODEL_DIR: 'D:/stale/model',
|
|
324
|
+
GOOGLE_APPLICATION_CREDENTIALS: 'C:/stale.json',
|
|
325
|
+
}
|
|
326
|
+
const saved = new Map()
|
|
327
|
+
for (const [name, value] of Object.entries(ambient)) {
|
|
328
|
+
saved.set(name, process.env[name])
|
|
329
|
+
process.env[name] = value
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
const { spawnImpl, children } = fakeSpawn()
|
|
333
|
+
const manager = makeManager({ spawnImpl })
|
|
334
|
+
const pending = manager.acquire(PROFILE)
|
|
335
|
+
push(children[0], readyLine())
|
|
336
|
+
const endpoint = await pending
|
|
337
|
+
const env = children[0].options.env
|
|
338
|
+
for (const name of ['HTTP_PROXY', 'https_proxy', 'ALL_PROXY', 'NO_PROXY', 'MEMU_HTTP_PROXY', 'MEMU_API_KEY', 'MEMU_BASE_URL', 'MEMU_EMBED_PROVIDER', 'OPENAI_API_KEY', 'GOOGLE_APPLICATION_CREDENTIALS']) {
|
|
339
|
+
assert.equal(env[name], undefined, name + ' must not be inherited')
|
|
340
|
+
}
|
|
341
|
+
assert.equal(env.DSH_MEMU_EMBED_TOKEN, endpoint.token, 'the inherited token is replaced')
|
|
342
|
+
assert.equal(env.DSH_MEMU_EMBED_MODEL_DIR, PROFILE.modelDir, 'the inherited model directory is replaced')
|
|
343
|
+
for (const name of Object.keys(env)) {
|
|
344
|
+
assert.ok(!/proxy/i.test(name), name + ' must not look like a proxy setting')
|
|
345
|
+
assert.ok(!/_api_key$/i.test(name), name + ' must not carry a remote credential')
|
|
346
|
+
}
|
|
347
|
+
assert.equal(env.MEMU_TELEMETRY, '0')
|
|
348
|
+
assert.equal(env.DO_NOT_TRACK, '1')
|
|
349
|
+
assert.equal(env.MEMU_EVENTS_BASE_URL, '')
|
|
350
|
+
manager.close()
|
|
351
|
+
} finally {
|
|
352
|
+
for (const [name, value] of saved) {
|
|
353
|
+
if (value === undefined) delete process.env[name]
|
|
354
|
+
else process.env[name] = value
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
test('a profile that cannot work is refused before any child is spawned', async () => {
|
|
360
|
+
const { spawnImpl, calls } = fakeSpawn()
|
|
361
|
+
const { spawnImpl: noSpawn, calls: noCalls } = fakeSpawn()
|
|
362
|
+
const manager = makeManager({ spawnImpl })
|
|
363
|
+
await assert.rejects(manager.acquire({ ...PROFILE, modelDir: '' }), error => error.code === 'LOCAL_VECTOR_RUNTIME_UNAVAILABLE')
|
|
364
|
+
await assert.rejects(manager.acquire({ ...PROFILE, runtimeDir: null }), error => error.code === 'LOCAL_VECTOR_RUNTIME_UNAVAILABLE')
|
|
365
|
+
await assert.rejects(manager.acquire({ ...PROFILE, manifest: {} }), error => error.code === 'LOCAL_VECTOR_MANIFEST_INCOMPLETE')
|
|
366
|
+
await assert.rejects(manager.acquire({ ...PROFILE, dimension: 0 }), error => error.code === 'LOCAL_VECTOR_MANIFEST_INCOMPLETE')
|
|
367
|
+
assert.equal(calls.length, 0)
|
|
368
|
+
assert.deepEqual(manager.status(), { running: 0, booting: 0, closed: false, tripped: 0 })
|
|
369
|
+
const rejecting = makeManager({
|
|
370
|
+
spawnImpl: noSpawn,
|
|
371
|
+
verifyProfile: () => { throw Object.assign(new Error('profile needs repair'), { code: 'VECTOR_PROFILE_UNVERIFIED' }) },
|
|
372
|
+
})
|
|
373
|
+
await assert.rejects(rejecting.acquire(PROFILE), error => error.code === 'VECTOR_PROFILE_UNVERIFIED')
|
|
374
|
+
assert.equal(noCalls.length, 0, 'an unverified profile never starts a worker')
|
|
375
|
+
rejecting.close()
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
test('a spawn failure is a typed, observable failure rather than a hang', async () => {
|
|
379
|
+
const manager = makeManager({
|
|
380
|
+
spawnImpl: () => { throw Object.assign(new Error('spawn EACCES'), { code: 'EACCES' }) },
|
|
381
|
+
maxRestarts: 1,
|
|
382
|
+
})
|
|
383
|
+
await assert.rejects(manager.acquire(PROFILE), error => {
|
|
384
|
+
assert.equal(error.code, 'LOCAL_VECTOR_SPAWN_FAILED')
|
|
385
|
+
assert.equal(error.fallback, 'lexical')
|
|
386
|
+
return true
|
|
387
|
+
})
|
|
388
|
+
assert.deepEqual(manager.status(), { running: 0, booting: 0, closed: false, tripped: 1 }, 'a failed boot counts toward the fuse')
|
|
389
|
+
await assert.rejects(manager.acquire(PROFILE), error => error.code === 'LOCAL_VECTOR_RESTART_FUSED')
|
|
390
|
+
manager.close()
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
test('the process-wide manager can be disposed and rebuilt from the plugin lifecycle', () => {
|
|
394
|
+
const first = embeddingWorkerManager()
|
|
395
|
+
assert.equal(embeddingWorkerManager(), first, 'the singleton is stable')
|
|
396
|
+
closeEmbeddingWorkers()
|
|
397
|
+
assert.equal(first.status().closed, true)
|
|
398
|
+
const second = embeddingWorkerManager()
|
|
399
|
+
assert.notEqual(second, first)
|
|
400
|
+
assert.deepEqual(second.status(), { running: 0, booting: 0, closed: false, tripped: 0 })
|
|
401
|
+
closeEmbeddingWorkers()
|
|
402
|
+
assert.doesNotThrow(() => closeEmbeddingWorkers(), 'disposal is idempotent')
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
test('a real child process serves the ready line and does not outlive the manager', async () => {
|
|
406
|
+
const spawned = []
|
|
407
|
+
const script = [
|
|
408
|
+
'const line = JSON.stringify(' + JSON.stringify(readyLine()) + ')',
|
|
409
|
+
'process.stdout.write(line + String.fromCharCode(10))',
|
|
410
|
+
'const bye = () => process.exit(0)',
|
|
411
|
+
"process.stdin.on('end', bye)",
|
|
412
|
+
"process.stdin.on('close', bye)",
|
|
413
|
+
'process.stdin.resume()',
|
|
414
|
+
'setInterval(() => {}, 1000)',
|
|
415
|
+
].join(String.fromCharCode(10))
|
|
416
|
+
const spawnImpl = (command, args, options) => {
|
|
417
|
+
const child = spawnReal(process.execPath, ['-e', script], options)
|
|
418
|
+
spawned.push(child)
|
|
419
|
+
return child
|
|
420
|
+
}
|
|
421
|
+
const manager = makeManager({ spawnImpl, timeoutMs: 15_000 })
|
|
422
|
+
try {
|
|
423
|
+
const endpoint = await manager.acquire(PROFILE)
|
|
424
|
+
assert.equal(endpoint.baseUrl, 'http://127.0.0.1:45123/v1')
|
|
425
|
+
assert.match(endpoint.token, TOKEN_PATTERN)
|
|
426
|
+
let guard = null
|
|
427
|
+
const exited = new Promise(resolve => spawned[0].once('exit', () => resolve(true)))
|
|
428
|
+
manager.close()
|
|
429
|
+
const deadline = new Promise(resolve => { guard = setTimeout(() => resolve(false), 10_000) })
|
|
430
|
+
const finished = await Promise.race([exited, deadline])
|
|
431
|
+
clearTimeout(guard)
|
|
432
|
+
assert.equal(finished, true, 'the worker child must not survive its manager')
|
|
433
|
+
} finally {
|
|
434
|
+
manager.close()
|
|
435
|
+
for (const child of spawned) {
|
|
436
|
+
try { child.kill() } catch {}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
test('the ready timeout still fires when nothing but the timer holds the event loop', async () => {
|
|
442
|
+
// N2: an unref'd boot timer never fires on an event loop that has nothing else to do,
|
|
443
|
+
// so acquire() stays pending instead of degrading to lexical recall. A stand-in spawn
|
|
444
|
+
// (no pid, no libuv handle) is exactly that situation, and node:test keeps the loop
|
|
445
|
+
// alive for its own reasons, so the only honest check runs out of process: this probe
|
|
446
|
+
// exits on its own the moment nothing references it, and prints nothing if it does.
|
|
447
|
+
const managerUrl = new URL('../lib/local-embedding-manager.js', import.meta.url).href
|
|
448
|
+
const profileJson = JSON.stringify(PROFILE)
|
|
449
|
+
const script = [
|
|
450
|
+
'const { createEmbeddingWorkerManager } = await import(' + JSON.stringify(managerUrl) + ')',
|
|
451
|
+
'const { EventEmitter } = await import("node:events")',
|
|
452
|
+
'const spawnImpl = () => {',
|
|
453
|
+
' const child = new EventEmitter()',
|
|
454
|
+
' child.stdin = new EventEmitter(); child.stdin.end = () => {}',
|
|
455
|
+
' child.stdout = new EventEmitter(); child.stderr = new EventEmitter()',
|
|
456
|
+
' child.kill = () => {}',
|
|
457
|
+
' return child',
|
|
458
|
+
'}',
|
|
459
|
+
'const profile = ' + profileJson,
|
|
460
|
+
'const manager = createEmbeddingWorkerManager({ spawnImpl, verifyProfile: () => {}, logger: { warn() {} }, timeoutMs: 300 })',
|
|
461
|
+
'const outcome = await manager.acquire(profile).then(() => "RESOLVED", error => "REJECTED:" + error.code)',
|
|
462
|
+
'manager.close()',
|
|
463
|
+
'process.stdout.write(outcome + String.fromCharCode(10))',
|
|
464
|
+
].join(String.fromCharCode(10))
|
|
465
|
+
const child = spawnReal(process.execPath, ['--input-type=module', '-e', script], { stdio: ['ignore', 'pipe', 'ignore'], env: childEnvironment({}) })
|
|
466
|
+
let out = ''
|
|
467
|
+
child.stdout.setEncoding('utf8')
|
|
468
|
+
child.stdout.on('data', chunk => { out += chunk })
|
|
469
|
+
let guard = null
|
|
470
|
+
const closed = new Promise(resolve => child.once('close', code => resolve(code)))
|
|
471
|
+
const deadline = new Promise(resolve => { guard = setTimeout(() => resolve('hung'), 20_000) })
|
|
472
|
+
const result = await Promise.race([closed, deadline])
|
|
473
|
+
clearTimeout(guard)
|
|
474
|
+
assert.equal(result, 0, 'the probe must settle on the boot timeout, not drain with the acquire still pending')
|
|
475
|
+
assert.equal(out.trim(), 'REJECTED:LOCAL_VECTOR_START_TIMEOUT', 'a stand-in child that never reports ready must time out')
|
|
476
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_MAX_BATCH,
|
|
5
|
+
LocalEmbeddingError,
|
|
6
|
+
assertVector,
|
|
7
|
+
buildEmbeddingResponse,
|
|
8
|
+
decodeVectorBase64,
|
|
9
|
+
encodeVectorBase64,
|
|
10
|
+
errorBody,
|
|
11
|
+
errorStatus,
|
|
12
|
+
estimateUsage,
|
|
13
|
+
parseEmbeddingRequest,
|
|
14
|
+
} from '../lib/local-embedding-protocol.js'
|
|
15
|
+
|
|
16
|
+
const MODEL = 'bge-small-zh-v1.5@r1+mean-l2-512'
|
|
17
|
+
const rejects = (fn, pattern) => assert.throws(fn, (error) => error instanceof LocalEmbeddingError && pattern.test(error.code))
|
|
18
|
+
|
|
19
|
+
test('base64 payloads are raw little-endian float32, not a private array format', () => {
|
|
20
|
+
const vector = [0.5, -1.25, 0, 3]
|
|
21
|
+
const encoded = encodeVectorBase64(vector)
|
|
22
|
+
const expected = Buffer.alloc(vector.length * 4)
|
|
23
|
+
vector.forEach((value, index) => expected.writeFloatLE(value, index * 4))
|
|
24
|
+
assert.equal(encoded, expected.toString('base64'))
|
|
25
|
+
assert.deepEqual(decodeVectorBase64(encoded, 4), vector)
|
|
26
|
+
assert.deepEqual(Array.from(decodeVectorBase64(encoded)), Array.from(new Float32Array(vector)))
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('decoding a base64 payload of the wrong length reports a dimension mismatch', () => {
|
|
30
|
+
const encoded = encodeVectorBase64([1, 2, 3])
|
|
31
|
+
rejects(() => decodeVectorBase64(encoded, 4), /^LOCAL_VECTOR_DIMENSION_MISMATCH$/)
|
|
32
|
+
assert.throws(() => decodeVectorBase64('***'), (error) => error.code === 'LOCAL_VECTOR_INVALID_VECTOR')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('vectors must be finite, non-zero and exactly the manifest dimension', () => {
|
|
36
|
+
assert.deepEqual(assertVector([1, 2, 3], 3), [1, 2, 3])
|
|
37
|
+
assert.deepEqual(assertVector(new Float32Array([1, 2, 3]), 3), [1, 2, 3])
|
|
38
|
+
rejects(() => assertVector([1, 2], 3), /^LOCAL_VECTOR_DIMENSION_MISMATCH$/)
|
|
39
|
+
rejects(() => assertVector([0, 0, 0], 3), /^LOCAL_VECTOR_ZERO_VECTOR$/)
|
|
40
|
+
rejects(() => assertVector([1, Number.NaN, 3], 3), /^LOCAL_VECTOR_INVALID_VECTOR$/)
|
|
41
|
+
rejects(() => assertVector([1, Number.POSITIVE_INFINITY, 3], 3), /^LOCAL_VECTOR_INVALID_VECTOR$/)
|
|
42
|
+
rejects(() => assertVector('1,2,3', 3), /^LOCAL_VECTOR_INVALID_VECTOR$/)
|
|
43
|
+
rejects(() => assertVector([1, 2, 3], 0), /^LOCAL_VECTOR_MANIFEST_INCOMPLETE$/)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('the request parser accepts exactly the SDK shapes and refuses the rest', () => {
|
|
47
|
+
assert.deepEqual(parseEmbeddingRequest({ model: MODEL, input: 'hello' }, { model: MODEL }), { inputs: ['hello'], encoding: 'float' })
|
|
48
|
+
assert.deepEqual(parseEmbeddingRequest({ model: MODEL, input: ['a', 'b'], encoding_format: 'base64' }, { model: MODEL }), { inputs: ['a', 'b'], encoding: 'base64' })
|
|
49
|
+
rejects(() => parseEmbeddingRequest(null, { model: MODEL }), /^LOCAL_VECTOR_BAD_REQUEST$/)
|
|
50
|
+
rejects(() => parseEmbeddingRequest([], { model: MODEL }), /^LOCAL_VECTOR_BAD_REQUEST$/)
|
|
51
|
+
rejects(() => parseEmbeddingRequest({ input: 'hello' }, { model: MODEL }), /^LOCAL_VECTOR_BAD_REQUEST$/)
|
|
52
|
+
rejects(() => parseEmbeddingRequest({ model: MODEL, input: [] }, { model: MODEL }), /^LOCAL_VECTOR_BAD_REQUEST$/)
|
|
53
|
+
rejects(() => parseEmbeddingRequest({ model: MODEL, input: ' ' }, { model: MODEL }), /^LOCAL_VECTOR_BAD_REQUEST$/)
|
|
54
|
+
rejects(() => parseEmbeddingRequest({ model: MODEL, input: [1, 2] }, { model: MODEL }), /^LOCAL_VECTOR_BAD_REQUEST$/)
|
|
55
|
+
rejects(() => parseEmbeddingRequest({ model: MODEL, input: [[1, 2, 3]] }, { model: MODEL }), /^LOCAL_VECTOR_BAD_REQUEST$/)
|
|
56
|
+
rejects(() => parseEmbeddingRequest({ model: MODEL, input: { text: 'x' } }, { model: MODEL }), /^LOCAL_VECTOR_BAD_REQUEST$/)
|
|
57
|
+
rejects(() => parseEmbeddingRequest({ model: 'other-model', input: 'hello' }, { model: MODEL }), /^LOCAL_VECTOR_MODEL_MISMATCH$/)
|
|
58
|
+
rejects(() => parseEmbeddingRequest({ model: MODEL, input: 'hello', encoding_format: 'binary' }, { model: MODEL }), /^LOCAL_VECTOR_BAD_ENCODING$/)
|
|
59
|
+
rejects(() => parseEmbeddingRequest({ model: MODEL, input: 'x'.repeat(65_537) }, { model: MODEL }), /^LOCAL_VECTOR_INPUT_TOO_LONG$/)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('the batch cap is enforced before any inference happens', () => {
|
|
63
|
+
const many = new Array(DEFAULT_MAX_BATCH + 1).fill('x')
|
|
64
|
+
rejects(() => parseEmbeddingRequest({ model: MODEL, input: many }, { model: MODEL }), /^LOCAL_VECTOR_BATCH_TOO_LARGE$/)
|
|
65
|
+
rejects(() => parseEmbeddingRequest({ model: MODEL, input: ['a', 'b', 'c'] }, { model: MODEL, maxBatch: 2 }), /^LOCAL_VECTOR_BATCH_TOO_LARGE$/)
|
|
66
|
+
assert.equal(parseEmbeddingRequest({ model: MODEL, input: ['a', 'b'] }, { model: MODEL, maxBatch: 2 }).inputs.length, 2)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('responses keep input order, carry the model and honour the encoding', () => {
|
|
70
|
+
const vectors = [[1, 2, 3], [4, 5, 6]]
|
|
71
|
+
const floatBody = buildEmbeddingResponse({ model: MODEL, vectors, encoding: 'float' })
|
|
72
|
+
assert.equal(floatBody.object, 'list')
|
|
73
|
+
assert.equal(floatBody.model, MODEL)
|
|
74
|
+
assert.deepEqual(floatBody.data.map((entry) => entry.index), [0, 1])
|
|
75
|
+
assert.equal(floatBody.data[0].object, 'embedding')
|
|
76
|
+
assert.deepEqual(floatBody.data[1].embedding, [4, 5, 6])
|
|
77
|
+
assert.ok(Number.isFinite(floatBody.usage.prompt_tokens))
|
|
78
|
+
|
|
79
|
+
const base64Body = buildEmbeddingResponse({ model: MODEL, vectors, encoding: 'base64' })
|
|
80
|
+
assert.equal(typeof base64Body.data[0].embedding, 'string')
|
|
81
|
+
assert.deepEqual(decodeVectorBase64(base64Body.data[0].embedding, 3), [1, 2, 3])
|
|
82
|
+
|
|
83
|
+
const withUsage = buildEmbeddingResponse({ model: MODEL, vectors, encoding: 'float', usage: { prompt_tokens: 7, total_tokens: 7 } })
|
|
84
|
+
assert.deepEqual(withUsage.usage, { prompt_tokens: 7, total_tokens: 7 })
|
|
85
|
+
assert.deepEqual(estimateUsage(['abcd', 'abcdefgh']), { prompt_tokens: 3, total_tokens: 3 })
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('error envelopes expose a code without echoing the request body', () => {
|
|
89
|
+
const body = errorBody(new LocalEmbeddingError('unknown model; this worker serves m', 'LOCAL_VECTOR_MODEL_MISMATCH', 400))
|
|
90
|
+
assert.equal(body.error.type, 'invalid_request_error')
|
|
91
|
+
assert.equal(body.error.code, 'LOCAL_VECTOR_MODEL_MISMATCH')
|
|
92
|
+
assert.equal(errorStatus(new LocalEmbeddingError('x', 'X', 503)), 503)
|
|
93
|
+
assert.equal(errorStatus(new Error('plain')), 500)
|
|
94
|
+
assert.equal(errorBody(new LocalEmbeddingError('boom', 'LOCAL_VECTOR_INFERENCE_FAILED', 500)).error.type, 'server_error')
|
|
95
|
+
})
|