@qvac/sdk 0.17.1 → 0.18.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.
Files changed (154) hide show
  1. package/NOTICE +124 -64
  2. package/README.md +19 -0
  3. package/dist/client/api/audio-gen.d.ts +17 -0
  4. package/dist/client/api/audio-gen.js +17 -0
  5. package/dist/client/api/finetune.d.ts +2 -0
  6. package/dist/client/api/finetune.js +7 -1
  7. package/dist/client/api/index.d.ts +1 -1
  8. package/dist/client/api/index.js +1 -1
  9. package/dist/client/api/load-model.d.ts +8 -0
  10. package/dist/client/api/transcribe.d.ts +19 -2
  11. package/dist/client/api/transcribe.js +66 -27
  12. package/dist/client/api/translate.d.ts +1 -0
  13. package/dist/client/api/translate.js +40 -19
  14. package/dist/client/api/vla.d.ts +42 -1
  15. package/dist/client/api/vla.js +50 -1
  16. package/dist/commands/bundle/entry-gen.d.ts +4 -0
  17. package/dist/commands/bundle/entry-gen.js +6 -0
  18. package/dist/commands/bundle/index.js +19 -12
  19. package/dist/examples/asr/parakeet-indic-conformer-filesystem.d.ts +2 -0
  20. package/dist/examples/asr/parakeet-indic-conformer-filesystem.js +52 -0
  21. package/dist/examples/audiogen/generate-cover.d.ts +2 -0
  22. package/dist/examples/audiogen/generate-cover.js +100 -0
  23. package/dist/examples/llamacpp-fallback.d.ts +2 -0
  24. package/dist/examples/llamacpp-fallback.js +34 -0
  25. package/dist/examples/multi-job-completion.d.ts +28 -0
  26. package/dist/examples/multi-job-completion.js +128 -0
  27. package/dist/examples/profiling/basic.js +32 -1
  28. package/dist/examples/system-resources.d.ts +2 -0
  29. package/dist/examples/system-resources.js +86 -0
  30. package/dist/examples/tts/audio8.d.ts +2 -0
  31. package/dist/examples/tts/audio8.js +54 -0
  32. package/dist/examples/tts/cosyvoice3.d.ts +2 -0
  33. package/dist/examples/tts/cosyvoice3.js +52 -0
  34. package/dist/examples/vla-groot.js +92 -49
  35. package/dist/index.d.ts +2 -2
  36. package/dist/index.js +2 -2
  37. package/dist/models/registry/models.d.ts +782 -6
  38. package/dist/models/registry/models.js +2664 -1859
  39. package/dist/schemas/audio-gen.d.ts +103 -0
  40. package/dist/schemas/audio-gen.js +135 -4
  41. package/dist/schemas/common.d.ts +550 -11
  42. package/dist/schemas/constants-registry.d.ts +18 -4
  43. package/dist/schemas/constants-registry.js +16 -3
  44. package/dist/schemas/index.d.ts +1 -1
  45. package/dist/schemas/index.js +1 -1
  46. package/dist/schemas/llamacpp-config.d.ts +10 -10
  47. package/dist/schemas/llamacpp-config.js +12 -5
  48. package/dist/schemas/load-model.d.ts +3678 -323
  49. package/dist/schemas/load-model.js +21 -3
  50. package/dist/schemas/sdk-config.d.ts +60 -24
  51. package/dist/schemas/sdk-errors-server.d.ts +1 -0
  52. package/dist/schemas/sdk-errors-server.js +5 -0
  53. package/dist/schemas/text-to-speech.d.ts +1086 -110
  54. package/dist/schemas/text-to-speech.js +194 -4
  55. package/dist/schemas/tools.d.ts +0 -16
  56. package/dist/schemas/tools.js +0 -15
  57. package/dist/schemas/transcription-config.d.ts +2 -0
  58. package/dist/schemas/transcription-config.js +10 -4
  59. package/dist/schemas/transcription.d.ts +3 -0
  60. package/dist/schemas/vla.d.ts +83 -0
  61. package/dist/schemas/vla.js +105 -2
  62. package/dist/scripts/link-workspace-inference.d.ts +2 -0
  63. package/dist/scripts/link-workspace-inference.js +31 -0
  64. package/dist/server/bare/ops/cancel.d.ts +1 -1
  65. package/dist/server/bare/ops/cancel.js +1 -1
  66. package/dist/server/bare/ops/kv-cache-retention.js +11 -0
  67. package/dist/server/bare/ops/kv-cache-utils.d.ts +1 -1
  68. package/dist/server/bare/ops/kv-cache-utils.js +40 -12
  69. package/dist/server/bare/ops/transcribe.d.ts +2 -2
  70. package/dist/server/bare/ops/transcribe.js +54 -48
  71. package/dist/server/bare/ops/translate-stats.d.ts +5 -0
  72. package/dist/server/bare/ops/translate-stats.js +11 -0
  73. package/dist/server/bare/ops/translate.js +66 -38
  74. package/dist/server/bare/ops/unload-model.js +30 -24
  75. package/dist/server/bare/plugins/audiogen-ggml/ops/audio-gen-input.d.ts +20 -0
  76. package/dist/server/bare/plugins/audiogen-ggml/ops/audio-gen-input.js +153 -0
  77. package/dist/server/bare/plugins/audiogen-ggml/ops/audio-gen-stream.js +52 -1
  78. package/dist/server/bare/plugins/audiogen-ggml/plugin.d.ts +28 -0
  79. package/dist/server/bare/plugins/ggml-ocr/plugin.js +3 -1
  80. package/dist/server/bare/plugins/ggml-vla/ops/vla-set-embodiment.d.ts +3 -0
  81. package/dist/server/bare/plugins/ggml-vla/ops/vla-set-embodiment.js +9 -0
  82. package/dist/server/bare/plugins/ggml-vla/plugin.d.ts +37 -0
  83. package/dist/server/bare/plugins/ggml-vla/plugin.js +21 -4
  84. package/dist/server/bare/plugins/llamacpp-completion/ops/batch-completion-stream.js +16 -15
  85. package/dist/server/bare/plugins/llamacpp-completion/ops/cache-logger.d.ts +1 -1
  86. package/dist/server/bare/plugins/llamacpp-completion/ops/cache-logger.js +2 -2
  87. package/dist/server/bare/plugins/llamacpp-completion/ops/completion-stats.d.ts +6 -0
  88. package/dist/server/bare/plugins/llamacpp-completion/ops/completion-stats.js +10 -0
  89. package/dist/server/bare/plugins/llamacpp-completion/ops/completion-stream.d.ts +1 -0
  90. package/dist/server/bare/plugins/llamacpp-completion/ops/completion-stream.js +131 -123
  91. package/dist/server/bare/plugins/llamacpp-completion/ops/finetune.js +110 -20
  92. package/dist/server/bare/plugins/llamacpp-completion/ops/kv-cache-session.d.ts +49 -23
  93. package/dist/server/bare/plugins/llamacpp-completion/ops/kv-cache-session.js +308 -143
  94. package/dist/server/bare/plugins/llamacpp-completion/ops/kv-cache-state.d.ts +1 -0
  95. package/dist/server/bare/plugins/llamacpp-completion/ops/kv-cache-state.js +2 -2
  96. package/dist/server/bare/plugins/llamacpp-completion/plugin.d.ts +6 -6
  97. package/dist/server/bare/plugins/llamacpp-completion/plugin.js +55 -36
  98. package/dist/server/bare/plugins/llamacpp-completion/transform.js +1 -7
  99. package/dist/server/bare/plugins/llamacpp-embedding/plugin.js +2 -3
  100. package/dist/server/bare/plugins/nmtcpp-translation/plugin.js +6 -5
  101. package/dist/server/bare/plugins/parakeet-transcription/plugin.js +27 -16
  102. package/dist/server/bare/plugins/tts-ggml/ops/parler-options.js +23 -3
  103. package/dist/server/bare/plugins/tts-ggml/plugin.d.ts +302 -4
  104. package/dist/server/bare/plugins/tts-ggml/plugin.js +131 -0
  105. package/dist/server/bare/plugins/whispercpp-transcription/plugin.js +48 -39
  106. package/dist/server/bare/registry/model-registry.d.ts +1 -0
  107. package/dist/server/bare/runtime/request-registry-singleton.js +29 -15
  108. package/dist/server/bare/runtime/request-registry.d.ts +27 -0
  109. package/dist/server/bare/runtime/request-registry.js +246 -76
  110. package/dist/server/bare/types/addon-responses.d.ts +4 -12
  111. package/dist/server/rpc/handlers/load-model/handler.js +2 -2
  112. package/dist/server/rpc/handlers/load-model/resolve-session.d.ts +1 -1
  113. package/dist/server/rpc/handlers/load-model/resolve-session.js +3 -3
  114. package/dist/server/rpc/handlers/load-model/resolve.d.ts +12 -2
  115. package/dist/server/rpc/handlers/load-model/resolve.js +90 -12
  116. package/dist/server/utils/audio/decoder.d.ts +37 -1
  117. package/dist/server/utils/audio/decoder.js +81 -51
  118. package/dist/server/utils/config-transform.d.ts +3 -0
  119. package/dist/server/utils/config-transform.js +6 -0
  120. package/dist/server/utils/index.d.ts +0 -1
  121. package/dist/server/utils/index.js +0 -1
  122. package/dist/server/utils/shard-utils.d.ts +1 -0
  123. package/dist/server/utils/shard-utils.js +14 -0
  124. package/dist/server/utils/tool-integration.d.ts +3 -10
  125. package/dist/server/utils/tool-integration.js +3 -12
  126. package/dist/utils/errors-server.d.ts +3 -0
  127. package/dist/utils/errors-server.js +5 -0
  128. package/package.json +12 -14
  129. package/dist/examples/llamacpp-dynamic-tools.d.ts +0 -2
  130. package/dist/examples/llamacpp-dynamic-tools.js +0 -152
  131. package/dist/models/update-models/codegen.d.ts +0 -3
  132. package/dist/models/update-models/codegen.js +0 -196
  133. package/dist/models/update-models/companions.d.ts +0 -30
  134. package/dist/models/update-models/companions.js +0 -296
  135. package/dist/models/update-models/history.d.ts +0 -24
  136. package/dist/models/update-models/history.js +0 -107
  137. package/dist/models/update-models/index.d.ts +0 -2
  138. package/dist/models/update-models/index.js +0 -123
  139. package/dist/models/update-models/naming.d.ts +0 -4
  140. package/dist/models/update-models/naming.js +0 -396
  141. package/dist/models/update-models/processing.d.ts +0 -8
  142. package/dist/models/update-models/processing.js +0 -65
  143. package/dist/models/update-models/registry.d.ts +0 -5
  144. package/dist/models/update-models/registry.js +0 -87
  145. package/dist/models/update-models/shards.d.ts +0 -4
  146. package/dist/models/update-models/shards.js +0 -57
  147. package/dist/models/update-models/types.d.ts +0 -88
  148. package/dist/models/update-models/types.js +0 -1
  149. package/dist/models/update-models/utils.d.ts +0 -3
  150. package/dist/models/update-models/utils.js +0 -21
  151. package/dist/server/bare/plugins/llamacpp-completion/ops/batch-cancelled.d.ts +0 -23
  152. package/dist/server/bare/plugins/llamacpp-completion/ops/batch-cancelled.js +0 -30
  153. package/dist/server/utils/expand-gguf-shards.d.ts +0 -3
  154. package/dist/server/utils/expand-gguf-shards.js +0 -22
@@ -11,23 +11,19 @@ import { createRequestRegistry as createRegistry } from '../../../server/bare/ru
11
11
  * baseline concurrency policies.
12
12
  */
13
13
  let registry = null;
14
- // `completion` and `batchCompletion` both run on the same `@qvac/llm-llamacpp`
15
- // instance, which funnels every `run()` (single-prompt and batch alike) through
16
- // one per-instance exclusive run queue plus a single-job native runner. They
17
- // therefore can't actually execute at once on the same model. Sharing one
18
- // admission lane makes the SDK queue reflect that reality: a completion and a
19
- // batch on the same model serialize FIFO at the SDK layer instead of both being
20
- // admitted and silently serializing inside the addon (which hides them from the
21
- // registry's queue-depth accounting, `requestId` diagnostics, and cancel).
14
+ // One admission lane per model for the llama.cpp addon: completion,
15
+ // batchCompletion, LLM translate, and finetune all key on
16
+ // (LLAMACPP_COMPLETION_SLOT_GROUP, modelId), so they compete for the model's
17
+ // `parallel` slots first-come-first-serve and share ONE bounded FIFO wait queue.
18
+ // The queue depth is the per-model cap below (64): once 64 requests across those
19
+ // kinds are waiting, the 65th begin is rejected with RequestRejectedByPolicyError.
20
+ // Disk-KV-cache turns ride the same lane and serialise same-file writes per cache
21
+ // path in the KV-cache session, so they need no separate group. NMT translate
22
+ // passes an infinite cap and never enters the lane, so it stays ungated.
22
23
  const LLAMACPP_COMPLETION_SLOT_GROUP = 'llamacppCompletion';
23
24
  function installDefaultPolicies(r) {
24
- // A loaded model is a single native context (one KV-cache, single-slot
25
- // decode), so two same-model completions can't run in parallel. Serialize
26
- // rather than reject: the second waits FIFO. maxConcurrentPerModel: 1 is
27
- // today's reality — raise it once continuous batching lands. The depth cap
28
- // bounds queue memory. The shared slot group extends that serialization
29
- // across `completion` + `batchCompletion` on the same model (see note
30
- // above).
25
+ // Cap is the model's `parallel`, passed per request by the handlers; the value
26
+ // here is only the fallback when a caller supplies none.
31
27
  r.policy({
32
28
  kind: 'completion',
33
29
  maxConcurrentPerModel: 1,
@@ -42,6 +38,24 @@ function installDefaultPolicies(r) {
42
38
  maxQueueDepthPerModel: 64,
43
39
  sharedSlotGroup: LLAMACPP_COMPLETION_SLOT_GROUP
44
40
  });
41
+ // Finetune's only stop is the addon's global cancel, which would kill
42
+ // concurrent completions. Run it exclusively so nothing else is on the model.
43
+ r.policy({
44
+ kind: 'finetune',
45
+ maxConcurrentPerModel: 1,
46
+ onOverflow: 'queue',
47
+ maxQueueDepthPerModel: 64,
48
+ sharedSlotGroup: LLAMACPP_COMPLETION_SLOT_GROUP,
49
+ exclusive: true
50
+ });
51
+ // LLM translate shares the completion lane as a reader (capped by `parallel`),
52
+ // so a finetune blocks it too. NMT passes no cap and stays ungated.
53
+ r.policy({
54
+ kind: 'translate',
55
+ onOverflow: 'queue',
56
+ maxQueueDepthPerModel: 64,
57
+ sharedSlotGroup: LLAMACPP_COMPLETION_SLOT_GROUP
58
+ });
45
59
  // ACE-Step owns one active job per model. Starting another run replaces
46
60
  // the addon's active response, and model-scoped cancel targets that single
47
61
  // active job. Keep the SDK registry authoritative by admitting one AudioGen
@@ -19,6 +19,18 @@ export interface BeginOpts {
19
19
  * the parent does not require iterating the registry.
20
20
  */
21
21
  parentSignal?: AbortSignal;
22
+ /**
23
+ * Per-request override of the kind policy's `maxConcurrentPerModel`. The
24
+ * policy value is one number for every model of a kind, but handlers may
25
+ * use a loaded model's `parallel` as its top-level request-permit cap.
26
+ * This counts requests, not native sequences: a multi-prompt batch still
27
+ * consumes one permit and the addon schedules its sequences internally.
28
+ * Must be uniform across all requests routed to the same `(lane, modelId)`
29
+ * (a given model always yields the same `parallel`): the FIFO slot
30
+ * hand-off assumes one cap per lane and does not re-check it. A finite
31
+ * value below 1 is floored to 1.
32
+ */
33
+ maxConcurrentPerModel?: number;
22
34
  }
23
35
  export interface CancelByRequestId {
24
36
  requestId: string;
@@ -97,6 +109,13 @@ export interface ConcurrencyPolicy {
97
109
  * internal queue.
98
110
  */
99
111
  sharedSlotGroup?: string;
112
+ /**
113
+ * Admit at most one such request per lane AND only while no other request
114
+ * holds the lane — a writer in a reader/writer lock. Used for finetune,
115
+ * whose only cancel is the addon's global cancel: making it exclusive keeps
116
+ * completions out of the addon while it runs, so that cancel can't hit them.
117
+ */
118
+ exclusive?: boolean;
100
119
  }
101
120
  /**
102
121
  * `ManagedRequestContext` is the value `begin(...)` resolves to. It extends
@@ -169,6 +188,14 @@ export interface RequestRegistry {
169
188
  * scope unwinding still happens on each handler's own dispose path.
170
189
  */
171
190
  cancelAll(reason: 'shutdown' | 'modelUnload'): Promise<void>;
191
+ /**
192
+ * Cancel every request for a model and wait for each to fully dispose (native
193
+ * context freed, slot released) before running `teardown` with an admission
194
+ * barrier held — any begin for the model starts aborted — then lift it. Used
195
+ * by model unload so the model object isn't freed while a request is still
196
+ * mid-teardown, and no new request goes active against it during teardown.
197
+ */
198
+ withModelDraining(modelId: string, teardown: () => Promise<void>): Promise<void>;
172
199
  /**
173
200
  * Mark a request finished and dispose its scope. Equivalent to
174
201
  * `await ctx[Symbol.asyncDispose]()` with an explicit outcome.
@@ -48,6 +48,26 @@ export function createRequestRegistry(options) {
48
48
  * FIFO in O(1) without scanning every `KeyState`.
49
49
  */
50
50
  const waitersById = new Map();
51
+ /**
52
+ * Ids reserved synchronously at `begin(...)` start and cleared once the entry
53
+ * lands in `entries` (or the begin throws). A same-tick second `begin(...)`
54
+ * with the same id runs before the first reaches `entries`/`waitersById`, so
55
+ * without this it would slip past the duplicate check and both would execute.
56
+ */
57
+ const reservedIds = new Set();
58
+ // Models being torn down: a begin(...) for one starts aborted. The barrier
59
+ // half of cancelAndDrain — catches begins its scan can't see yet.
60
+ const drainingModels = new Set();
61
+ // Entries whose disposal has started but not finished. disposeEntry removes
62
+ // from `entries` before unwinding, so a drain must consult this too or it
63
+ // would return while a request is still tearing down natively.
64
+ const disposingEntries = new Set();
65
+ // Request ids whose entry is mid-disposal. disposeEntry drops the id from
66
+ // `entries` before its async scope unwinds; without this the id looks free
67
+ // during that gap, so a same-id begin could activate concurrently with the
68
+ // still-tearing-down request and a cancel would land as a cancel-before-begin
69
+ // tripwire that aborts a later reuse. Held until teardown completes.
70
+ const disposingIds = new Set();
51
71
  function logLifecycle(event, ctx, durationMs) {
52
72
  const modelId = ctx.modelId !== undefined ? ctx.modelId : '-';
53
73
  const base = `[request-lifecycle] ${event} requestId=${ctx.requestId} kind=${ctx.kind} modelId=${modelId} state=${ctx.state}`;
@@ -132,7 +152,7 @@ export function createRequestRegistry(options) {
132
152
  * removal resolves (granted / graceful cancel) or rejects (timeout /
133
153
  * teardown).
134
154
  */
135
- function removeWaiter(key, waiter) {
155
+ function removeWaiter(key, waiter, drainAfter = true) {
136
156
  if (waiter.timer !== undefined) {
137
157
  clearTimeout(waiter.timer);
138
158
  waiter.timer = undefined;
@@ -144,8 +164,14 @@ export function createRequestRegistry(options) {
144
164
  const i = st.waiters.indexOf(waiter);
145
165
  if (i >= 0)
146
166
  st.waiters.splice(i, 1);
147
- if (st.active <= 0 && st.waiters.length === 0)
167
+ // Removing a queued writer can unblock the readers behind it, so drain
168
+ // (which also deletes the KeyState once idle). `cancelAll` passes false:
169
+ // it's tearing every waiter down, so granting one mid-sweep is wrong.
170
+ if (drainAfter)
171
+ drain(st, key);
172
+ else if (st.active <= 0 && !st.exclusiveActive && st.waiters.length === 0) {
148
173
  keyStates.delete(key);
174
+ }
149
175
  }
150
176
  /**
151
177
  * Admission gate. Resolves to the slot key the caller must release on
@@ -163,29 +189,50 @@ export function createRequestRegistry(options) {
163
189
  */
164
190
  async function acquireSlot(opts) {
165
191
  if (opts.modelId === undefined)
166
- return { slotKey: undefined };
192
+ return { slotKey: undefined, exclusive: false };
167
193
  const policy = policies.get(opts.kind);
168
- if (!policy || !Number.isFinite(policy.maxConcurrent)) {
169
- return { slotKey: undefined };
194
+ if (!policy)
195
+ return { slotKey: undefined, exclusive: false };
196
+ // The per-request override (the model's own `parallel`) wins over the
197
+ // per-kind default. A non-finite effective cap disables gating entirely.
198
+ const requestedMax = opts.maxConcurrentPerModel ?? policy.maxConcurrent;
199
+ if (!Number.isFinite(requestedMax)) {
200
+ return { slotKey: undefined, exclusive: false };
170
201
  }
202
+ const maxConcurrent = clampSlotCount(requestedMax);
171
203
  // A parent (worker-shutdown) signal that's already aborted: don't
172
204
  // queue behind live work that may never drain — let begin() proceed
173
205
  // and abort immediately via the parentSignal path.
174
206
  if (opts.parentSignal?.aborted)
175
- return { slotKey: undefined };
207
+ return { slotKey: undefined, exclusive: false };
176
208
  const modelId = opts.modelId;
177
- // A shared lane lets unrelated kinds (e.g. completion + batchCompletion)
178
- // contend for one slot pool per model, matching an addon that serializes
179
- // them on a single native context.
209
+ // Lane precedence: the policy's shared group → the kind itself. A shared
210
+ // lane lets unrelated kinds contend for one slot pool per model.
180
211
  const key = slotKey(policy.slotGroup ?? opts.kind, modelId);
181
212
  let st = keyStates.get(key);
182
213
  if (!st) {
183
- st = { active: 0, waiters: [] };
214
+ st = { active: 0, waiters: [], exclusiveActive: false };
184
215
  keyStates.set(key, st);
185
216
  }
186
- if (st.active < policy.maxConcurrent) {
187
- st.active++;
188
- return { slotKey: key };
217
+ const exclusive = policy.exclusive;
218
+ // A reader admits below the lane's pinned cap (see `readerCap`); an exclusive
219
+ // writer needs the lane empty. A queued waiter (of any kind) blocks admission
220
+ // so a writer isn't starved by a steady reader stream. For reader-only lanes
221
+ // `waiters` is empty whenever `active < cap`, so this guard is a no-op there.
222
+ const readerCap = st.readerCap ?? maxConcurrent;
223
+ const canAdmit = st.waiters.length === 0 &&
224
+ !st.exclusiveActive &&
225
+ (exclusive ? st.active === 0 : st.active < readerCap);
226
+ if (canAdmit) {
227
+ if (exclusive) {
228
+ st.exclusiveActive = true;
229
+ }
230
+ else {
231
+ st.active++;
232
+ if (st.readerCap === undefined)
233
+ st.readerCap = maxConcurrent;
234
+ }
235
+ return { slotKey: key, exclusive };
189
236
  }
190
237
  if (policy.onOverflow === 'reject') {
191
238
  throw new RequestRejectedByPolicyError(opts.requestId, opts.kind, modelId, `another ${opts.kind} request is already running on this model`);
@@ -202,7 +249,9 @@ export function createRequestRegistry(options) {
202
249
  enqueuedAt: Date.now(),
203
250
  resolve,
204
251
  reject,
205
- timer: undefined
252
+ timer: undefined,
253
+ exclusive,
254
+ cap: maxConcurrent
206
255
  };
207
256
  if (policy.queueTimeoutMs !== undefined) {
208
257
  const timeoutMs = policy.queueTimeoutMs;
@@ -214,32 +263,59 @@ export function createRequestRegistry(options) {
214
263
  queue.waiters.push(waiter);
215
264
  waitersById.set(opts.requestId, { key, waiter });
216
265
  });
217
- return granted ? { slotKey: key } : { slotKey: undefined };
266
+ return granted ? { slotKey: key, exclusive } : { slotKey: undefined, exclusive: false };
218
267
  }
219
268
  /**
220
- * Hand a freed slot to the next FIFO waiter, or free it outright when
221
- * none are queued. Called from `disposeEntry` after the scope has fully
222
- * unwound, so the native context is genuinely free before the next
223
- * same-model request is admitted.
269
+ * Grant the lane to as many FIFO-head waiters as now fit: an exclusive writer
270
+ * only when the lane is idle, readers up to each one's own cap. Stops at the
271
+ * first head that can't be admitted, so FIFO order holds. Deletes the
272
+ * `KeyState` once fully idle.
224
273
  */
225
- function releaseSlot(key) {
226
- const st = keyStates.get(key);
227
- if (!st)
228
- return;
229
- const next = st.waiters.shift();
230
- if (next) {
231
- // Hand off: the waiter inherits the slot, so `active` is unchanged.
232
- waitersById.delete(next.requestId);
233
- if (next.timer !== undefined) {
234
- clearTimeout(next.timer);
235
- next.timer = undefined;
274
+ function drain(st, key) {
275
+ while (st.waiters.length > 0) {
276
+ const head = st.waiters[0];
277
+ if (head === undefined)
278
+ break;
279
+ if (head.exclusive) {
280
+ if (st.exclusiveActive || st.active > 0)
281
+ break;
282
+ st.exclusiveActive = true;
236
283
  }
237
- next.resolve(true);
238
- return;
284
+ else {
285
+ const readerCap = st.readerCap ?? head.cap;
286
+ if (st.exclusiveActive || st.active >= readerCap)
287
+ break;
288
+ st.active++;
289
+ if (st.readerCap === undefined)
290
+ st.readerCap = head.cap;
291
+ }
292
+ st.waiters.shift();
293
+ waitersById.delete(head.requestId);
294
+ if (head.timer !== undefined) {
295
+ clearTimeout(head.timer);
296
+ head.timer = undefined;
297
+ }
298
+ head.resolve(true);
239
299
  }
240
- st.active--;
241
- if (st.active <= 0 && st.waiters.length === 0)
300
+ if (st.active <= 0 && !st.exclusiveActive && st.waiters.length === 0) {
242
301
  keyStates.delete(key);
302
+ }
303
+ }
304
+ /**
305
+ * Release a held slot, then hand the freed capacity to waiters. Called from
306
+ * `disposeEntry` after the scope has fully unwound, so its deferred teardown
307
+ * (for llama.cpp completion, disposing the run response that stops native
308
+ * decode) has run before the next same-model request is admitted.
309
+ */
310
+ function releaseSlot(key, wasExclusive) {
311
+ const st = keyStates.get(key);
312
+ if (!st)
313
+ return;
314
+ if (wasExclusive)
315
+ st.exclusiveActive = false;
316
+ else
317
+ st.active--;
318
+ drain(st, key);
243
319
  }
244
320
  function cancelEntry(entry, reason) {
245
321
  if (entry.controller.signal.aborted)
@@ -250,8 +326,13 @@ export function createRequestRegistry(options) {
250
326
  return true;
251
327
  }
252
328
  async function disposeEntry(entry, outcome) {
253
- if (entry.scope.disposed)
329
+ if (entry.scope.disposed || entry.disposing)
254
330
  return;
331
+ entry.disposing = true;
332
+ disposingEntries.add(entry);
333
+ // Keep the id reserved across async teardown so a same-id begin can't
334
+ // activate concurrently and a cancel can't strand a tripwire for a reuse.
335
+ disposingIds.add(entry.ctx.requestId);
255
336
  entry.ctx.state = outcome;
256
337
  entry.detachParent();
257
338
  logLifecycle('end', entry.ctx, Date.now() - entry.startedAt);
@@ -269,46 +350,81 @@ export function createRequestRegistry(options) {
269
350
  // guarantees the slot is freed even if a deferred cleanup throws,
270
351
  // otherwise a throwing teardown would strand the queue forever.
271
352
  if (entry.slotKey !== undefined)
272
- releaseSlot(entry.slotKey);
353
+ releaseSlot(entry.slotKey, entry.exclusive);
354
+ disposingEntries.delete(entry);
355
+ // The id is free for reuse only now that native teardown is complete.
356
+ disposingIds.delete(entry.ctx.requestId);
357
+ // Signal any `cancelAndDrain` waiting on this request that its native
358
+ // teardown is complete.
359
+ entry.resolveDisposed();
273
360
  }
274
361
  }
275
362
  async function begin(opts) {
276
- // A request id is reserved for its whole lifecycle, including the time
277
- // it spends *queued* for an admission slot. `waitersById` holds the
278
- // still-queued begins (they aren't in `entries` yet), so a duplicate id
279
- // must be rejected against both maps — otherwise a second begin with the
280
- // same id would enqueue behind the first and overwrite its `waitersById`
281
- // index, leaving the original waiter unreachable by `cancel({ requestId })`.
282
- if (entries.has(opts.requestId) || waitersById.has(opts.requestId)) {
363
+ // A request id is reserved for its whole lifecycle: the time it spends
364
+ // *queued* for an admission slot, while it is active, and while it is
365
+ // tearing down. `waitersById` holds the still-queued begins (not yet in
366
+ // `entries`) and `disposingIds` holds the ones whose async teardown hasn't
367
+ // finished, so a duplicate id must be rejected against all of them —
368
+ // otherwise a second begin with the same id would enqueue behind the first
369
+ // and overwrite its `waitersById` index (unreachable by `cancel(...)`), or
370
+ // activate while the prior request is still releasing its native context.
371
+ if (entries.has(opts.requestId) ||
372
+ waitersById.has(opts.requestId) ||
373
+ reservedIds.has(opts.requestId) ||
374
+ disposingIds.has(opts.requestId)) {
283
375
  throw new RequestIdConflictError(opts.requestId);
284
376
  }
285
- // Admission control runs before allocation so a rejected begin leaves
286
- // no controller / scope behind. This may await: a gated `(kind,
287
- // modelId)` at capacity queues the begin FIFO and resolves once a slot
288
- // frees (or rejects on overflow / queue-depth cap / timeout).
289
- const { slotKey } = await acquireSlot(opts);
290
- // The only interleaving point in this otherwise synchronous body is the
291
- // await above. A duplicate id could (astronomically unlikely) have
292
- // landed while we were queued — re-check and hand the just-acquired
293
- // slot back rather than stranding it on the conflicting throw.
294
- if (entries.has(opts.requestId)) {
295
- if (slotKey !== undefined)
296
- releaseSlot(slotKey);
297
- throw new RequestIdConflictError(opts.requestId);
377
+ // Reserve synchronously so a same-tick duplicate begin is rejected before
378
+ // this one reaches `entries`/`waitersById`. Cleared on every exit below.
379
+ reservedIds.add(opts.requestId);
380
+ // A request cancelled BEFORE begin must not queue for a slot on a saturated
381
+ // lane: consume its marker first and skip admission, so it resolves at once
382
+ // into an aborted context holding no slot instead of waiting for the holder.
383
+ let preCancel = consumeCancelBeforeBegin(opts.requestId);
384
+ // Admission control runs before allocation so a rejected begin leaves no
385
+ // controller / scope behind. This may await: a gated `(kind, modelId)` at
386
+ // capacity queues the begin FIFO and resolves once a slot frees (or rejects
387
+ // on overflow / queue-depth cap / timeout).
388
+ let slotKey;
389
+ let exclusive = false;
390
+ if (!preCancel) {
391
+ // Plain try/catch, not `.catch()`, so admission adds no extra microtask
392
+ // and observers still see the request registered on the same tick.
393
+ let acquired;
394
+ try {
395
+ acquired = await acquireSlot(opts);
396
+ }
397
+ catch (err) {
398
+ reservedIds.delete(opts.requestId);
399
+ throw err;
400
+ }
401
+ slotKey = acquired.slotKey;
402
+ exclusive = acquired.exclusive;
403
+ // The only interleaving point in this otherwise synchronous body is the
404
+ // await above. A duplicate id could (astronomically unlikely) have landed
405
+ // while we were queued — re-check and hand the just-acquired slot back
406
+ // rather than stranding it on the conflicting throw.
407
+ if (entries.has(opts.requestId)) {
408
+ if (slotKey !== undefined)
409
+ releaseSlot(slotKey, exclusive);
410
+ reservedIds.delete(opts.requestId);
411
+ throw new RequestIdConflictError(opts.requestId);
412
+ }
413
+ // A cancel that landed WHILE we were queued resolved acquireSlot with no
414
+ // slot and recorded a marker; consume it so the context starts aborted.
415
+ preCancel = consumeCancelBeforeBegin(opts.requestId);
298
416
  }
417
+ // A begin arriving while its model is being torn down starts aborted — no
418
+ // native work against a model about to be freed.
419
+ const drainReason = opts.modelId !== undefined && drainingModels.has(opts.modelId) ? 'modelUnload' : undefined;
299
420
  const controller = new AbortController();
300
421
  const scope = createDisposableScope();
301
- // Stop-button race close. If a
302
- // `cancel({ requestId })` already arrived for this id, abort the
303
- // new controller before observers can subscribe to it. The
304
- // tripwire entry is consumed so a later, separate `begin(...)`
305
- // with the same id is unaffected (in practice ids are UUIDv4 and
306
- // never reused; this guard just keeps the contract self-
307
- // consistent under retries).
308
- const preCancel = consumeCancelBeforeBegin(opts.requestId);
309
422
  if (preCancel) {
310
423
  controller.abort(preCancel.reason);
311
424
  }
425
+ else if (drainReason) {
426
+ controller.abort(drainReason);
427
+ }
312
428
  let detachParent = () => { };
313
429
  if (opts.parentSignal) {
314
430
  const parent = opts.parentSignal;
@@ -333,17 +449,26 @@ export function createRequestRegistry(options) {
333
449
  // already aborted at begin time. Both branches abort the
334
450
  // controller above, so without this guard observers would see a
335
451
  // momentarily-`running` context with an already-aborted signal.
336
- state: preCancel || opts.parentSignal?.aborted ? 'cancelling' : 'running'
452
+ state: preCancel || drainReason || opts.parentSignal?.aborted ? 'cancelling' : 'running'
337
453
  };
454
+ let resolveDisposed = () => { };
455
+ const disposed = new Promise((resolve) => {
456
+ resolveDisposed = resolve;
457
+ });
338
458
  const entry = {
339
459
  ctx,
340
460
  controller,
341
461
  scope,
342
462
  detachParent,
343
463
  startedAt: Date.now(),
344
- slotKey
464
+ slotKey,
465
+ exclusive,
466
+ disposing: false,
467
+ disposed,
468
+ resolveDisposed
345
469
  };
346
470
  entries.set(opts.requestId, entry);
471
+ reservedIds.delete(opts.requestId);
347
472
  logLifecycle('begin', ctx);
348
473
  return {
349
474
  get requestId() {
@@ -410,6 +535,12 @@ export function createRequestRegistry(options) {
410
535
  cancelQueuedWaiterGracefully(queued.key, queued.waiter, target.reason);
411
536
  return cancelled + 1;
412
537
  }
538
+ // Mid-disposal: the request already reached a terminal outcome and is
539
+ // releasing its native context. There is nothing to cancel, and a
540
+ // cancel-before-begin marker would wrongly abort a later begin that
541
+ // reuses this id once teardown frees it.
542
+ if (disposingIds.has(target.requestId))
543
+ return cancelled;
413
544
  // Stop-button race: the client beat its own
414
545
  // `begin(...)`. Record the cancel so the next matching `begin`
415
546
  // aborts immediately. The return value stays 0 — no in-flight
@@ -452,15 +583,48 @@ export function createRequestRegistry(options) {
452
583
  // so there is nothing left for them to run. Snapshot first —
453
584
  // `removeWaiter` mutates `waitersById`.
454
585
  for (const { key, waiter } of Array.from(waitersById.values())) {
455
- removeWaiter(key, waiter);
586
+ removeWaiter(key, waiter, false);
456
587
  waiter.reject(new RequestRejectedByPolicyError(waiter.requestId, waiter.kind, waiter.modelId, `queued request cancelled (${reason})`));
457
588
  }
458
- // The interface returns Promise<void> so we can later make this an
459
- // async sweep that awaits per-handler scope unwinding (e.g. join on
460
- // the disposers). Today every handler unwinds on its own dispose
461
- // path, so the function only needs to fire-and-forget the abort.
589
+ // Fire-and-forget the abort; each handler unwinds on its own dispose path.
462
590
  return Promise.resolve();
463
591
  }
592
+ // Cancel and drain every request for a model, then run `teardown` with the
593
+ // admission barrier held — any begin for the model (including one still
594
+ // suspended in admission) starts aborted — lifting it afterward. Scoped so the
595
+ // barrier can't be left raised.
596
+ async function withModelDraining(modelId, teardown) {
597
+ drainingModels.add(modelId);
598
+ try {
599
+ // Abort each active request and collect its disposal promise, so we wait
600
+ // for every handler's scope to fully unwind (native context freed, slot
601
+ // released) before teardown. Snapshot first — `disposeEntry` mutates
602
+ // `entries`.
603
+ const draining = [];
604
+ for (const entry of entries.values()) {
605
+ if (entry.ctx.modelId !== modelId)
606
+ continue;
607
+ cancelEntry(entry, 'modelUnload');
608
+ draining.push(entry.disposed);
609
+ }
610
+ // Requests already mid-disposal are out of `entries` but may still be
611
+ // tearing down natively — wait for them too.
612
+ for (const entry of disposingEntries) {
613
+ if (entry.ctx.modelId === modelId)
614
+ draining.push(entry.disposed);
615
+ }
616
+ // Queued waiters never held a slot, so just abort their begins.
617
+ for (const { key, waiter } of Array.from(waitersById.values())) {
618
+ if (waiter.modelId === modelId)
619
+ cancelQueuedWaiterGracefully(key, waiter, 'modelUnload');
620
+ }
621
+ await Promise.all(draining);
622
+ await teardown();
623
+ }
624
+ finally {
625
+ drainingModels.delete(modelId);
626
+ }
627
+ }
464
628
  async function end(requestId, outcome) {
465
629
  const entry = entries.get(requestId);
466
630
  if (!entry)
@@ -476,6 +640,7 @@ export function createRequestRegistry(options) {
476
640
  list,
477
641
  cancel,
478
642
  cancelAll,
643
+ withModelDraining,
479
644
  end,
480
645
  policy,
481
646
  // Test-only: lets the registry race tests assert the bound
@@ -490,6 +655,12 @@ export function createRequestRegistry(options) {
490
655
  __keyStateSize: () => keyStates.size
491
656
  };
492
657
  }
658
+ // A finite slot count floored to an integer >= 1: a value below 1 would gate
659
+ // every request forever and a fractional one could over-admit. Callers gate on
660
+ // `Number.isFinite` first — Infinity means "ungated" and must stay Infinity.
661
+ function clampSlotCount(count) {
662
+ return Math.max(1, Math.floor(count));
663
+ }
493
664
  /**
494
665
  * Resolve a `ConcurrencyPolicy` to concrete numbers, applying defaults and
495
666
  * the deprecated `oneAtATimePerModel` alias. Done once at registration so
@@ -512,16 +683,15 @@ function normalizePolicy(opts) {
512
683
  maxConcurrent = Infinity;
513
684
  onOverflow = opts.onOverflow ?? 'queue';
514
685
  }
515
- // A finite limit below 1 would gate every request forever; clamp to the
516
- // smallest sensible serial limit.
517
- if (Number.isFinite(maxConcurrent) && maxConcurrent < 1)
518
- maxConcurrent = 1;
686
+ if (Number.isFinite(maxConcurrent))
687
+ maxConcurrent = clampSlotCount(maxConcurrent);
519
688
  return {
520
689
  maxConcurrent,
521
690
  onOverflow,
522
691
  maxQueueDepth: opts.maxQueueDepthPerModel ?? DEFAULT_MAX_QUEUE_DEPTH_PER_MODEL,
523
692
  queueTimeoutMs: opts.queueTimeoutMs,
524
- slotGroup: opts.sharedSlotGroup
693
+ slotGroup: opts.sharedSlotGroup,
694
+ exclusive: opts.exclusive === true
525
695
  };
526
696
  }
527
697
  /**
@@ -1,4 +1,5 @@
1
1
  import type ASRGgml from '@qvac/asr-ggml';
2
+ import type TranslationNmtcpp from '@qvac/translation-nmtcpp';
2
3
  export interface LlmStats {
3
4
  TTFT?: number;
4
5
  TPS?: number;
@@ -7,23 +8,14 @@ export interface LlmStats {
7
8
  generatedTokens?: number;
8
9
  avgConcurrentSeq?: number;
9
10
  backendDevice?: 'cpu' | 'gpu';
11
+ stopReason?: 'none' | 'eos' | 'antiprompt' | 'predictionLimit' | 'sequenceLimit' | 'contextOverflow';
10
12
  }
11
13
  export interface LlmResponse {
12
14
  stats?: LlmStats;
13
15
  iterate(): AsyncIterable<string>;
14
16
  }
15
- export interface NmtStats {
16
- totalTime?: number;
17
- totalTokens?: number;
18
- decodeTime?: number;
19
- encodeTime?: number;
20
- TPS?: number;
21
- TTFT?: number;
22
- }
23
- export interface NmtResponse {
24
- stats?: NmtStats;
25
- iterate(): AsyncIterable<string>;
26
- }
17
+ export type NmtStats = TranslationNmtcpp.RuntimeStats;
18
+ export type NmtResponse = TranslationNmtcpp.TranslationResponse;
27
19
  export interface TtsStats {
28
20
  audioDurationMs?: number;
29
21
  totalSamples?: number;
@@ -74,7 +74,7 @@ export async function handleLoadModel(request, progressCallback) {
74
74
  if (isReloadConfigRequest(request)) {
75
75
  return handleConfigReload(request);
76
76
  }
77
- const { modelSrc, modelName, seed } = request;
77
+ const { modelSrc, modelName, seed, fallbackSrc } = request;
78
78
  const canonicalModelType = normalizeModelType(request.modelType);
79
79
  const profilingMeta = request[PROFILING_KEY];
80
80
  const profilingEnabled = profilingMeta?.enabled !== false && !!profilingMeta;
@@ -113,7 +113,7 @@ export async function handleLoadModel(request, progressCallback) {
113
113
  requestId
114
114
  }
115
115
  });
116
- const primaryResolve = session.resolvePrimaryModelPath(modelSrc);
116
+ const primaryResolve = session.resolvePrimaryModelPath(modelSrc, fallbackSrc);
117
117
  let resolvedModelPath;
118
118
  let pluginResolveResult;
119
119
  try {
@@ -25,7 +25,7 @@ export interface ResolveSessionOptions {
25
25
  requestBinding?: DownloadRequestBinding | undefined;
26
26
  }
27
27
  export interface ResolveSession {
28
- resolvePrimaryModelPath(modelSrc: unknown): Promise<string>;
28
+ resolvePrimaryModelPath(modelSrc: unknown, fallbackSrc?: string): Promise<string>;
29
29
  createResolveContext(modelSrc: string, modelType: string, modelName?: string): ResolveContext;
30
30
  getAggregateResult(): ResolveResult | undefined;
31
31
  cancelAll(): void;